mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 05:26:24 +09:00
Ref #6416 The publish workflow was doing too much work in one short-lived API request. This moves content indexing into the build runner and keeps article bodies out of hosted/SSR sync. ## What changed - The build-bundle API can return the lightweight project bundle without preparing a full content index. - The CLI downloads required assets directly from storage and builds the index locally for SSG. Asset downloads remain bounded to 10 concurrent requests. - Hosted/SSR sync skips Markdown and MDX article downloads and does not compile an article index. It still downloads regular assets needed by the build. - Markdown body queries use stored frontmatter and fetch only the requested article at runtime. MDX still needs source downloads and compilation for SSG. - Public MCP and content-engine docs explain the Markdown/MDX tradeoff. This removes article parsing and article-body transfer from the tRPC request. SSG still downloads article sources from storage and compiles MDX in the build runner. Hosted runtime article reads remain on demand. ## Checklist - [x] Keep the tRPC bundle request limited to project metadata and necessary records. - [x] Prepare SSG content indexes in the build runner; keep hosted article bodies out of sync. - [x] Cover no-body query preparation, direct asset reads, and SSG-versus-hosted sync behavior with regression tests. - [x] Update public CLI, MCP, and content-engine guidance. - [x] Regenerate and verify fixtures. - [x] Run workspace tests, typechecks, lint, formatting, package-boundary, generated API/docs, and fixture-size checks. - [ ] Verify the deployed publish flow has comfortable margin below the hosted API timeout. ## Verification - `pnpm -r --workspace-concurrency=1 test`: passed, including all 3,082 Builder tests and 1,150 CLI tests. - `pnpm -r typecheck`, `pnpm lint`, `pnpm check:package-boundaries`, `pnpm check:generated-api`, `pnpm check:generated-docs`, `pnpm check:fixture-sizes`, and `pnpm exec oxfmt --check`: passed. - The new hosted article-download regression test was confirmed to fail with the old behavior and pass after the fix. - A deployed end-to-end publish timing check is still needed, so this PR remains draft.
240 lines
6.8 KiB
TypeScript
240 lines
6.8 KiB
TypeScript
import { describe, expect, test } from "vitest";
|
|
import {
|
|
compileDocumentSourceGraph,
|
|
createDocumentSourceUrl,
|
|
DocumentSourceCompilationError,
|
|
} from "./source-compiler";
|
|
|
|
const documents = [
|
|
{
|
|
id: "post",
|
|
documentUrl: "https://content.webstudio.test/posts/hello.json",
|
|
revision: "post-r1",
|
|
contentRef: "content:post",
|
|
format: "json" as const,
|
|
source:
|
|
'{"author":{"$ref":"../authors/ada.md#frontmatter"},"body":{"$ref":"../authors/ada.md#body"}}',
|
|
},
|
|
{
|
|
id: "author",
|
|
documentUrl: "https://content.webstudio.test/authors/ada.md",
|
|
revision: "author-r1",
|
|
contentRef: "content:author",
|
|
format: "markdown" as const,
|
|
source:
|
|
"---\nname: Ada\navatar:\n $ref: ../media/ada.json#/url\n---\nWriter.\n",
|
|
},
|
|
{
|
|
id: "avatar",
|
|
documentUrl: "https://content.webstudio.test/media/ada.json",
|
|
revision: "avatar-r1",
|
|
contentRef: "content:avatar",
|
|
format: "json" as const,
|
|
source: '{"url":"/ada.png"}',
|
|
},
|
|
];
|
|
|
|
describe("document source graph compiler", () => {
|
|
test("rejects invalid reference markers in supplied frontmatter", async () => {
|
|
await expect(
|
|
compileDocumentSourceGraph({
|
|
documents: [
|
|
{
|
|
...documents[1],
|
|
source: "---\nname: Ada\n---\nBody",
|
|
frontmatter: { avatar: { $ref: 42 } },
|
|
},
|
|
],
|
|
})
|
|
).rejects.toMatchObject({
|
|
code: "DOCUMENT_ANALYSIS_FAILED",
|
|
documentId: "author",
|
|
cause: {
|
|
code: "INVALID_REFERENCE_MARKER",
|
|
referenceId: "#frontmatter/avatar",
|
|
},
|
|
});
|
|
});
|
|
|
|
test.each(["markdown", "mdx"] as const)(
|
|
"discovers %s references from supplied frontmatter without reading its body",
|
|
async (format) => {
|
|
const expected = await compileDocumentSourceGraph({ documents });
|
|
const graph = await compileDocumentSourceGraph({
|
|
documents: documents.map((document) =>
|
|
document.id === "author"
|
|
? {
|
|
...document,
|
|
format,
|
|
frontmatter: {
|
|
name: "Ada",
|
|
avatar: { $ref: "../media/ada.json#/url" },
|
|
},
|
|
source: {
|
|
async *[Symbol.asyncIterator]() {
|
|
yield await Promise.reject<Uint8Array>(
|
|
new Error("Markdown body must not be downloaded")
|
|
);
|
|
},
|
|
},
|
|
}
|
|
: document
|
|
),
|
|
});
|
|
expect(graph.edges).toEqual(expected.edges);
|
|
expect(graph.nodes).toEqual(
|
|
expected.nodes.map((node) =>
|
|
node.id === "author" ? { ...node, format } : node
|
|
)
|
|
);
|
|
}
|
|
);
|
|
|
|
test("encodes every Asset path segment as document URL data", () => {
|
|
expect(createDocumentSourceUrl("authors/Ada #1/プロフィール.md")).toBe(
|
|
"https://content.webstudio.local/authors/Ada%20%231/%E3%83%97%E3%83%AD%E3%83%95%E3%82%A3%E3%83%BC%E3%83%AB.md"
|
|
);
|
|
});
|
|
|
|
test("discovers mixed-format references and compiles a deterministic graph", async () => {
|
|
const graph = await compileDocumentSourceGraph({
|
|
documents: [...documents].reverse(),
|
|
concurrency: 2,
|
|
});
|
|
|
|
expect(graph.nodes.map(({ id }) => id)).toEqual([
|
|
"author",
|
|
"avatar",
|
|
"post",
|
|
]);
|
|
expect(graph.nodes.map(({ id, format }) => [id, format])).toEqual([
|
|
["author", "markdown"],
|
|
["avatar", "json"],
|
|
["post", "json"],
|
|
]);
|
|
expect(graph.edges).toEqual([
|
|
{
|
|
sourceId: "author",
|
|
referenceId: "#frontmatter/avatar",
|
|
reference: {
|
|
documentId: "avatar",
|
|
revision: "avatar-r1",
|
|
representation: { type: "json", path: ["url"] },
|
|
},
|
|
},
|
|
{
|
|
sourceId: "post",
|
|
referenceId: "#/author",
|
|
reference: {
|
|
documentId: "author",
|
|
revision: "author-r1",
|
|
representation: { type: "markdown-frontmatter" },
|
|
},
|
|
},
|
|
{
|
|
sourceId: "post",
|
|
referenceId: "#/body",
|
|
reference: {
|
|
documentId: "author",
|
|
revision: "author-r1",
|
|
representation: { type: "markdown-body" },
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("reports analysis failures with their source document", async () => {
|
|
await expect(
|
|
compileDocumentSourceGraph({
|
|
documents: [
|
|
{
|
|
...documents[0],
|
|
source: '{"author":{"$ref":42}}',
|
|
},
|
|
],
|
|
concurrency: 1,
|
|
})
|
|
).rejects.toMatchObject({
|
|
code: "DOCUMENT_ANALYSIS_FAILED",
|
|
documentId: "post",
|
|
cause: expect.objectContaining({
|
|
code: "INVALID_REFERENCE_MARKER",
|
|
referenceId: "#/author",
|
|
}),
|
|
} satisfies Partial<DocumentSourceCompilationError>);
|
|
});
|
|
|
|
test("ignores invalid catalog entries outside the selected root closure", async () => {
|
|
const graph = await compileDocumentSourceGraph({
|
|
documents: [
|
|
{ ...documents[0], source: '{"title":"Selected"}' },
|
|
{
|
|
...documents[1],
|
|
id: "unrelated-one",
|
|
documentUrl: "https://content.webstudio.test/unrelated.md",
|
|
},
|
|
{
|
|
...documents[1],
|
|
id: "unrelated-two",
|
|
documentUrl: "https://content.webstudio.test/unrelated.md",
|
|
},
|
|
],
|
|
rootIds: ["post"],
|
|
});
|
|
|
|
expect(graph.nodes.map(({ id }) => id)).toEqual(["post"]);
|
|
});
|
|
|
|
test("reports every reachable reference failure", async () => {
|
|
await expect(
|
|
compileDocumentSourceGraph({
|
|
documents: [
|
|
{
|
|
...documents[0],
|
|
id: "first",
|
|
documentUrl: "https://content.webstudio.test/posts/first.json",
|
|
source: '{"author":{"$ref":"../missing/first.md#frontmatter"}}',
|
|
},
|
|
{
|
|
...documents[0],
|
|
id: "second",
|
|
documentUrl: "https://content.webstudio.test/posts/second.json",
|
|
source: '{"author":{"$ref":"../missing/second.md#frontmatter"}}',
|
|
},
|
|
],
|
|
rootIds: ["first", "second"],
|
|
})
|
|
).rejects.toMatchObject({
|
|
errors: [
|
|
{ code: "TARGET_NOT_FOUND", sourceDocumentId: "first" },
|
|
{ code: "TARGET_NOT_FOUND", sourceDocumentId: "second" },
|
|
],
|
|
});
|
|
});
|
|
|
|
test("rejects an already-cancelled compilation before reading sources", async () => {
|
|
const controller = new AbortController();
|
|
controller.abort("cancelled");
|
|
let read = false;
|
|
|
|
await expect(
|
|
compileDocumentSourceGraph({
|
|
documents: [
|
|
{
|
|
...documents[0],
|
|
source: {
|
|
async *[Symbol.asyncIterator]() {
|
|
read = true;
|
|
yield new Uint8Array();
|
|
},
|
|
},
|
|
},
|
|
],
|
|
concurrency: 1,
|
|
signal: controller.signal,
|
|
})
|
|
).rejects.toMatchObject({ code: "REQUEST_CANCELLED" });
|
|
expect(read).toBe(false);
|
|
});
|
|
});
|