#!/usr/bin/env tsx /** * Generate Catalog MDX Pages + Index * * Walks registry/blocks/ and registry/components/, reads each item's * registry-item.json, and emits: * * docs/catalog/blocks/.mdx — per-block detail page * docs/catalog/components/.mdx — per-component detail page * docs/public/catalog-index.json — flat manifest for the grid page * * Run before building docs (e.g., in a Mintlify pre-build script): * npx tsx scripts/generate-catalog-pages.ts */ import { readFileSync, existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; import { join, resolve, dirname } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; // Import from source — bun workspace linking doesn't resolve for scripts outside packages/. import { type FileTarget, type RegistryItem, isBlockItem, ITEM_TYPE_DIRS, } from "../packages/core/src/registry/types.js"; import { withHostedDefaults } from "./registry-hosted-assets.ts"; import { declaredVariables } from "./catalog/component-variables.ts"; import { usesWebgpu } from "./docs-catalog-shared.mjs"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(scriptDir, ".."); const registryDir = resolve(repoRoot, "registry"); const docsDir = resolve(repoRoot, "docs"); const rawSourceBase = "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry"; const catalogImageBase = "https://static.heygen.ai/hyperframes-oss/docs/images/catalog"; const payloadRoot = resolve(repoRoot, "docs/public/catalog"); /** * Render a JSX/MDX attribute as a plain double-quoted string. * * The value must stay a string literal, never a `{...}` expression: the docs * search indexer stringifies expression nodes, so an expression title surfaces * as "[object Object]" in search results. MDX decodes HTML character * references inside quoted values, so escaping goes through them — `&` first, * so the references it introduces are not re-escaped. Only `&` and `"` can * break the literal; `<`, `{` and `}` are escaped as well so regex-based * consumers of the MDX never mistake the value for markup or an expression. * JSON.stringify cannot stand in: backslashes are not escape characters in * JSX strings. */ export function mdxStringAttribute(name: string, value: string): string { const escaped = value .replace(/&/g, "&") .replace(/"/g, """) .replace(/ a.manifest.name.localeCompare(b.manifest.name)); } // ── MDX generation ───────────────────────────────────────────────────────── /** * Every `## Heading` this generator produces, now or in an earlier revision. * Anything on a page outside this set was written by a human, and is carried * across a regeneration rather than deleted. Lowercased for comparison. */ const GENERATED_HEADINGS = new Set([ // current template "install", "variables", "source", "add it to your video", "paste it into your composition", "change the colors", "change how it looks", "ask an agent for it", "make the texture move", "every texture", "texture masks", // 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", "source prompt", "agent usage", "animated texture", "texture examples", // the required reader continuation the generator emits last (see RELATED_TOPICS) "related topics", ]); /** * 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 * parser below would swallow it into the preceding hand-written section and * re-emit it on every run. */ const FOOTER_MARKER = "{/* hf:generated-footer */}"; /** * Every Catalog page ends with this section — required by `docs/AGENTS.md` * ("Task, guide, Studio, and Catalog pages end with a `## Related topics` * section"). Emitted last so the page literally ends with it; listed in * GENERATED_HEADINGS so a regeneration never carries it forward as hand-written. */ const RELATED_TOPICS: readonly string[] = [ "## Related topics", "", "- [Browse the complete Catalog](/catalog)", "- [Add assets and Catalog items in the browser editor](/studio/assets-and-blocks)", "- [Put blocks together into one video](/concepts/compositions)", "", ]; /** * 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 export function carriedSectionsFrom(pagePath: string): CarriedContent { const empty: CarriedContent = { sections: [], hasCustomUsage: false }; if (!existsSync(pagePath)) return empty; let text: string; try { text = readFileSync(pagePath, "utf-8"); } catch { return empty; } const sections: string[] = []; let hasCustomUsage = false; let heading: string | null = null; let buffer: string[] = []; // fallow-ignore-next-line complexity const flush = (): void => { if (!heading) return; 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(); if (!GENERATED_HEADINGS.has(key) && buffer.length) { if (key === "usage") hasCustomUsage = true; sections.push(`## ${heading}`, "", ...buffer, ""); } heading = null; buffer = []; }; let inFence = false; for (const line of text.split("\n")) { // The footer marker begins the generated tail (provenance + Related topics). // Don't stop here: close the current section and keep scanning, so a human // `## section` appended *below* the generated tail is still carried forward // rather than silently dropped. The generated headings themselves are named // in GENERATED_HEADINGS, so the tail's own `## Related topics` is not carried. if (line.trim() === FOOTER_MARKER) { flush(); continue; } if (line.trimStart().startsWith("```")) inFence = !inFence; const match = !inFence && /^## (.+)$/.exec(line); if (match) { flush(); heading = match[1]!.trim(); continue; } if (heading) buffer.push(line); } flush(); return { sections, hasCustomUsage }; } function typeDir(kind: ItemKind): string { return ITEM_TYPE_DIRS[kind === "block" ? "hyperframes:block" : "hyperframes:component"]; } function textureGroupsFor(manifest: RegistryItem): TextureGroup[] { if (!("textureGroups" in manifest)) return []; const value = manifest.textureGroups; if (!Array.isArray(value)) return []; return value.filter((group): group is TextureGroup => { if (!group || typeof group !== "object") return false; if (!("title" in group) || typeof group.title !== "string") return false; if (!("items" in group) || !Array.isArray(group.items)) return false; return group.items.every((item: unknown) => typeof item === "string"); }); } function textureLabel(slug: string): string { return slug .split("-") .map((part) => part.length === 1 ? part.toUpperCase() : part[0]!.toUpperCase() + part.slice(1), ) .join(" "); } // Ordered so a more specific needle (e.g. "wood-floor") is tried before the // substring it contains ("wood"); first match wins, same as the if-chain this replaced. const TEXTURE_SAMPLE_WORDS: [needle: string, word: string][] = [ ["brick", "BRICK"], ["concrete", "CONCRETE"], ["plaster", "PLASTER"], ["rock", "ROCK"], ["onyx", "ONYX"], ["marble", "MARBLE"], ["travertine", "STONE"], ["paving", "STONE"], ["tiles", "TILE"], ["ground", "GROUND"], ["road", "ROAD"], ["asphalt", "ASPHALT"], ["wood-floor", "FLOOR"], ["wood", "WOOD"], ["bark", "BARK"], ["diamond", "PLATE"], ["metal", "METAL"], ["lava", "LAVA"], ["grass", "GRASS"], ["carpet", "WOVEN"], ["fabric", "FABRIC"], ["snow", "SNOW"], ["leather", "LEATHER"], ]; function textureSampleWord(slug: string): string { return TEXTURE_SAMPLE_WORDS.find(([needle]) => slug.includes(needle))?.[1] ?? slug.toUpperCase(); } function textureMaskUrlFor(manifest: RegistryItem, texture: string): string { return `${catalogImageBase}/components/${manifest.name}/masks/${texture}.png`; } function generateTextureExamples(manifest: RegistryItem, textureGroups: TextureGroup[]): string[] { const lines: string[] = ["## Every texture", "", '
']; for (const group of textureGroups) { lines.push( "
", `

${group.title}

`, '
', ); for (const item of group.items) { const maskPath = textureMaskUrlFor(manifest, item); const textureClass = `hf-texture-${item}`; lines.push( `
`, `
${textureLabel(item)}
${textureClass}
`, `
${textureSampleWord(item)}
`, `
Use hf-texture-text ${textureClass}
`, "
", ); } lines.push("
", "
"); } lines.push("
", ""); return lines; } function generateTextureAgentUsage( manifest: RegistryItem, textureGroups: TextureGroup[], ): string[] { const firstTexture = textureGroups[0]?.items[0] ?? "brick"; const firstClass = `hf-texture-${firstTexture}`; const installedSnippet = `compositions/components/${manifest.name}/${manifest.name}.html`; return [ "## Ask an agent for it", "", "Paste this to your coding agent:", "", "```text", `Use the ${manifest.title} catalog component.`, "", "1. From the project root, run:", ` npx hyperframes add ${manifest.name}`, "2. That command creates this installed snippet:", ` ${installedSnippet}`, "3. Open that file and paste the real