Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions apps/hub/src/artifacts/design-draft.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, test } from "bun:test";
import { describe, expect, test } from "bun:test";
import {
mkdir,
mkdtemp,
Expand Down Expand Up @@ -46,7 +46,12 @@ import {
dropDegenerateTurn,
trimHistory,
} from "../workflows/compaction.ts";
import { readDesignBundle, readDesignDraft } from "./design-draft.ts";
import {
readDesignBundle,
readDesignDraft,
manualPictureRefs,
manualPictures,
} from "./design-draft.ts";
import { createDesignTools } from "./design-tools.ts";

const STARTER = designStarterFiles();
Expand Down Expand Up @@ -710,3 +715,35 @@ test("readDesignDraft serves spliced index.html whether synced on disk or packed
await rm(workspace, { recursive: true, force: true });
}
});

describe("user manual pictures", () => {
const markdown = [
"# Using the app",
"![The home page](manual/home.png)",
'![Adding a card](manual/board.new-card.png "highlight: button | New card")',
'<img src="manual/settings.png" alt="Settings">',
"![Not ours](../secret.png)",
"![Elsewhere](https://example.com/x.png)",
"![Again](manual/home.png)",
].join("\n");
test("reads each picture under docs/manual/ once, as the manual names it", () => {
expect(manualPictureRefs(markdown)).toEqual([
"manual/home.png",
"manual/board.new-card.png",
"manual/settings.png",
]);
});
test("carries the pictures it can find as data URLs of their kind", () => {
const bytes = new TextEncoder().encode("png-bytes");
const pictures = manualPictures(markdown, (ref) =>
ref === "manual/settings.png" ? undefined : bytes,
);
expect(Object.keys(pictures)).toEqual([
"manual/home.png",
"manual/board.new-card.png",
]);
expect(pictures["manual/home.png"]).toBe(
`data:image/png;base64,${Buffer.from(bytes).toString("base64")}`,
);
});
});
99 changes: 93 additions & 6 deletions apps/hub/src/artifacts/design-draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
import { type } from "arktype";
import { workflowRunRepoIdForAddress } from "@intx/hub-sessions";
import { hasCode } from "@intx/types";
import type { UserManual } from "@corbits-builder/schemas/artifact";
import { packApp } from "../builds/app-snapshot.ts";
import {
spliceDesignScreens,
Expand Down Expand Up @@ -134,13 +135,30 @@ const DEMO_ACCOUNT = "server/src/data/demo-account.json";
const utf8 = new TextDecoder("utf-8", { fatal: true });

// A regular UTF-8 file in the workspace, null when it is larger than maxBytes, or undefined while
// there is none. No directory on its way may be a symlink and the file is opened without following
// one, so nothing outside the workspace is served.
// there is none or it is not UTF-8.
async function readWorkspaceText(
workspace: string,
path: string,
maxBytes: number,
) {
const bytes = await readWorkspaceBytes(workspace, path, maxBytes);
if (bytes === undefined || bytes === null) return bytes;
try {
return utf8.decode(bytes);
} catch (error) {
if (error instanceof TypeError) return undefined;
throw error;
}
}

// A regular file in the workspace, null when it is larger than maxBytes, or undefined while there
// is none. No directory on its way may be a symlink and the file is opened without following one,
// so nothing outside the workspace is served.
async function readWorkspaceBytes(
workspace: string,
path: string,
maxBytes: number,
): Promise<Uint8Array | null | undefined> {
const target = join(workspace, path);
try {
if (
Expand All @@ -164,15 +182,84 @@ async function readWorkspaceText(
const stat = await file.stat();
if (!stat.isFile()) return undefined;
if (stat.size > maxBytes) return null;
return utf8.decode(await file.readFile());
} catch (error) {
if (error instanceof TypeError) return undefined;
throw error;
return await file.readFile();
} finally {
await file.close();
}
}

const MAX_MANUAL_BYTES = 512 * 1024;
const MAX_PICTURE_BYTES = 4 * 1024 * 1024;
// Every picture a manual shows travels in one answer, so the whole set is bounded.
const MAX_PICTURES_BYTES = 32 * 1024 * 1024;
const PICTURE_TYPES: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
};
// A picture as Markdown or HTML names it, as check-app reads them.
const PICTURE_REF =
/!\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)|<img\b[^>]*\bsrc=["']([^"']+)/g;

/** The pictures a manual shows under docs/manual/, as it names them: "manual/home.png". */
export function manualPictureRefs(markdown: string): string[] {
const refs = new Set<string>();
for (const [, link, img] of markdown.matchAll(PICTURE_REF)) {
const ref = decodeURIComponent(link ?? img ?? "");
if (ref.startsWith("manual/") && !ref.split("/").includes(".."))
refs.add(ref);
}
return [...refs];
}

/** The manual's pictures as data URLs, keyed as it names them, from wherever their bytes are kept. */
export function manualPictures(
markdown: string,
bytesOf: (ref: string) => Uint8Array | undefined,
): Record<string, string> {
const pictures: Record<string, string> = {};
let total = 0;
for (const ref of manualPictureRefs(markdown)) {
const type = PICTURE_TYPES[ref.split(".").at(-1)?.toLowerCase() ?? ""];
const bytes = bytesOf(ref);
if (type === undefined || bytes === undefined) continue;
total += bytes.byteLength;
if (total > MAX_PICTURES_BYTES) break;
pictures[ref] =
`data:${type};base64,${Buffer.from(bytes).toString("base64")}`;
}
return pictures;
}

// The coder's docs/USER-MANUAL.md with the pictures it shows, or undefined until it writes one.
export async function readUserManual(
workspace: string,
): Promise<UserManual | undefined> {
const markdown = await readWorkspaceText(
workspace,
join("docs", "USER-MANUAL.md"),
MAX_MANUAL_BYTES,
);
if (markdown === null) throw new ProjectError(409, "manual_too_large");
if (markdown === undefined) return undefined;
const read = new Map<string, Uint8Array>();
for (const ref of manualPictureRefs(markdown)) {
const bytes = await readWorkspaceBytes(
workspace,
join("docs", ref),
MAX_PICTURE_BYTES,
);
if (bytes !== undefined && bytes !== null) read.set(ref, bytes);
}
return {
markdown,
pictures: manualPictures(markdown, (ref) => read.get(ref)),
};
}

// The coder's ACCEPTANCE.md at the workspace root, or undefined until it writes one.
export async function readAcceptance(workspace: string) {
const markdown = await readWorkspaceText(
Expand Down
24 changes: 24 additions & 0 deletions apps/hub/src/artifacts/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { TenantEnv } from "@intx/hub-api";
import {
AcceptanceChecklist,
AcceptedMaterials,
UserManual,
AppSnapshotReceipt,
AppVersionList,
ArtifactApprovalReceipt,
Expand Down Expand Up @@ -439,6 +440,29 @@ export function mountArtifactRoutes(
),
);
});
app.get(
"/:projectId/manual",
describeRoute({
tags: ["Artifacts"],
operationId: "readProjectUserManual",
security,
responses: {
200: {
description:
"The app's user manual, docs/USER-MANUAL.md, as the live coder keeps it, with the pictures it shows as data URLs; with no coder at work, the one filed with the latest app",
content: {
"application/json": { schema: resolver(UserManual) },
},
},
404: {
description: "Neither a coder at work nor the filed app has a manual",
},
},
}),
async (c) => {
return c.json(await service.manual(actorOf(c), c.req.param("projectId")));
},
);
app.get(
"/:projectId/acceptance",
describeRoute({
Expand Down
24 changes: 24 additions & 0 deletions apps/hub/src/artifacts/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
DesignTweaks,
type AcceptanceChecklist,
type AcceptedMaterials,
type UserManual,
type MaterialReceipt,
type DesignTweaksReceipt,
type DesignChoiceReceipt,
Expand Down Expand Up @@ -115,6 +116,8 @@ import {
readDemoLogin,
readDesignBundle,
readDesignDraft,
readUserManual,
manualPictures,
warmStepWorkspace,
} from "./design-draft.ts";
import type { LiveApps } from "../live-app/runner.ts";
Expand Down Expand Up @@ -1244,6 +1247,27 @@ export function createArtifactService(
throw new ProjectError(404, "acceptance_not_found");
},

// The user manual as the coder keeps it, with the pictures check-app saved; once the coder
// ends, the one filed with the app, whose pictures the bundle carries as binaries.
async manual(actor: Actor, projectId: string): Promise<UserManual> {
await requireProject(actor, projectId, "read");
for (const { dir } of await liveWorkspaces(actor, "coder")) {
const manual = await readUserManual(dir);
if (manual !== undefined) return manual;
}
const filed = await filedApp(actor);
const markdown = filed?.bundle.files["docs/USER-MANUAL.md"];
if (filed !== undefined && markdown !== undefined)
return {
markdown,
pictures: manualPictures(markdown, (ref) => {
const data = filed.bundle.binary?.[`docs/${ref}`];
return data === undefined ? undefined : Buffer.from(data, "base64");
}),
};
throw new ProjectError(404, "manual_not_found");
},

async read(
actor: Actor,
projectId: string,
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/api/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
import type {
DesignChoice,
DesignTweaks,
UserManual,
} from "@corbits-builder/schemas/artifact";
import type {
RecordReviewerDecision,
Expand Down Expand Up @@ -51,6 +52,15 @@ export async function readAcceptance(projectId: string, signal?: AbortSignal) {
return read?.markdown ?? null;
}

/** The app's user manual with its pictures, or null until the coder has written one. */
export async function readUserManual(
projectId: string,
signal?: AbortSignal,
): Promise<UserManual | null> {
const tenantId = await workspaceTenantId();
return nullWhenMissing(productClient().manual(tenantId, projectId, signal));
}

/** The project's app as the person can open it: running at its own origin, starting, or why not. */
export async function readLiveApp(projectId: string, signal?: AbortSignal) {
const tenantId = await workspaceTenantId();
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export const wording = {
design: "Design Plan",
approval_package: "Approval",
app: "App",
user_manual: "User Manual",
} as Record<string, string>,
documentUnread: "new version",
tips: {
Expand All @@ -122,6 +123,7 @@ export const wording = {
liveApp: {
tab: "Live Preview",
title: "The app as it is being built",
manualTitle: "How to use the app, with pictures of it as built",
frameTitle: "The running app",
notBuiltTitle: "Your app appears here",
notBuilt:
Expand Down
Loading
Loading