diff --git a/packages/core/src/registry/catalogGeneratorInstructions.test.ts b/packages/core/src/registry/catalogGeneratorInstructions.test.ts index 6c327de11..5de9dda7f 100644 --- a/packages/core/src/registry/catalogGeneratorInstructions.test.ts +++ b/packages/core/src/registry/catalogGeneratorInstructions.test.ts @@ -1,7 +1,9 @@ -import { readdirSync, readFileSync } from "node:fs"; +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { afterAll, describe, expect, it } from "vitest"; +import { carriedSectionsFrom } from "../../../../scripts/generate-catalog-pages.ts"; const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(here, "../../../.."); @@ -39,3 +41,77 @@ describe("catalog pages keep the required reader continuation", () => { expect(lastHeading).toBe("## Related topics"); }); }); + +// The carry-forward path is the subtler half: a regeneration must preserve every +// hand-written section, including one whose heading (## Usage) the generator once +// emitted and whose first line looks like generated prose, and one appended below +// the generated footer marker. +describe("carriedSectionsFrom preserves hand-written sections", () => { + const dir = mkdtempSync(join(tmpdir(), "hf-catalog-carry-")); + afterAll(() => rmSync(dir, { recursive: true, force: true })); + + const write = (name: string, body: string) => { + const p = join(dir, name); + writeFileSync(p, body, "utf-8"); + return p; + }; + + it("keeps a custom Usage section that opens like generated prose", () => { + const page = write( + "usage.mdx", + [ + "## Install", + "", + "```bash Terminal", + "npx hyperframes add sample", + "```", + "", + "## Usage", + "", + // deliberately opens with a phrase an old generated Usage used + "After installing, add the block — but here is our own hand-written guidance the author cares about.", + "", + "{/* hf:generated-footer */}", + "", + "Tagged `sample`.", + "", + ].join("\n"), + ); + const carried = carriedSectionsFrom(page); + const joined = carried.sections.join("\n"); + expect(joined).toContain("## Usage"); + expect(joined).toContain("hand-written guidance the author cares about"); + expect(carried.hasCustomUsage).toBe(true); + // The generated Install section is owned by the template and not carried. + expect(joined).not.toContain("## Install"); + }); + + it("keeps a hand-written section appended below the generated footer", () => { + const page = write( + "below-footer.mdx", + [ + "## Install", + "", + "text", + "", + "{/* hf:generated-footer */}", + "", + "Tagged `sample`.", + "", + "## Related topics", + "", + "- [Browse the complete Catalog](/catalog)", + "", + "## Field notes", + "", + "A section a human added after the generated tail.", + "", + ].join("\n"), + ); + const joined = carriedSectionsFrom(page).sections.join("\n"); + expect(joined).toContain("## Field notes"); + expect(joined).toContain("A section a human added after the generated tail."); + // The generator's own Related topics is not carried (it re-emits it). + expect(joined).not.toContain("## Related topics"); + }); +}); diff --git a/scripts/generate-catalog-pages.ts b/scripts/generate-catalog-pages.ts index 348e96ea6..ef7dac037 100644 --- a/scripts/generate-catalog-pages.ts +++ b/scripts/generate-catalog-pages.ts @@ -15,7 +15,7 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { join, resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; // Import from source — bun workspace linking doesn't resolve for scripts outside packages/. import { type FileTarget, @@ -118,10 +118,12 @@ const GENERATED_HEADINGS = new Set([ "ask an agent for it", "make the texture move", "every texture", - // headings earlier revisions emitted — dropped on purpose, never carried + // headings earlier revisions emitted — dropped on purpose, never carried. + // `usage` is deliberately NOT listed: the current template never emits it, and + // it is a heading a human might reasonably write, so ownership stays explicit + // (anything not in this set is hand-written) rather than sniffing the body. "details", "files", - "usage", "source prompt", "agent usage", "animated texture", @@ -130,19 +132,6 @@ const GENERATED_HEADINGS = new Set([ "related topics", ]); -/** - * Openers of every "## Usage" body this generator has written. A Usage section - * that starts with none of them was rewritten by hand, so it is kept and the - * generated usage prose steps aside for it. - */ -const GENERATED_USAGE_OPENERS = [ - /^After installing, add the block/, - /^Open `[^`]+` and paste its contents/, - /^Open `[^`]+` and copy what is inside/, - /^After `npx hyperframes add/, - /^It runs for /, -]; - /** * Marks the start of the generated provenance footer (tags, credit, prompt). * That footer carries no heading of its own, so without this marker the section @@ -170,8 +159,10 @@ const RELATED_TOPICS: readonly string[] = [ * Pull the hand-written `## sections` out of an already-generated page. * Returns the raw lines, heading included, in their original order. */ +// Exported for the preservation fixture in +// packages/core/src/registry/catalogGeneratorInstructions.test.ts. // fallow-ignore-next-line complexity -function carriedSectionsFrom(pagePath: string): CarriedContent { +export function carriedSectionsFrom(pagePath: string): CarriedContent { const empty: CarriedContent = { sections: [], hasCustomUsage: false }; if (!existsSync(pagePath)) return empty; let text: string; @@ -192,12 +183,12 @@ function carriedSectionsFrom(pagePath: string): CarriedContent { while (buffer.length && buffer[0]!.trim() === "") buffer.shift(); while (buffer.length && buffer.at(-1)!.trim() === "") buffer.pop(); + // Ownership is explicit: a section is generated iff its heading is one the + // template emits (GENERATED_HEADINGS). Everything else is hand-written and + // carried verbatim — no content heuristic that could misread custom prose as + // generated and silently delete it on the next regeneration. const key = heading.toLowerCase(); - const handWritten = - !GENERATED_HEADINGS.has(key) || - (key === "usage" && !GENERATED_USAGE_OPENERS.some((re) => re.test(buffer[0] ?? ""))); - - if (handWritten && buffer.length) { + if (!GENERATED_HEADINGS.has(key) && buffer.length) { if (key === "usage") hasCustomUsage = true; sections.push(`## ${heading}`, "", ...buffer, ""); } @@ -844,4 +835,7 @@ function main(): void { console.log("\nDone."); } -main(); +// Only regenerate when run directly, so the module can be imported by tests. +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main(); +}