#!/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"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(scriptDir, ".."); const registryDir = resolve(repoRoot, "registry"); const docsDir = resolve(repoRoot, "docs"); const catalogImageBase = "https://static.heygen.ai/hyperframes-oss/docs/images/catalog"; const payloadRoot = resolve(repoRoot, "docs/public/catalog"); /** * The player is loaded from a CDN rather than bundled into the docs, on * `latest` rather than a pinned line. * * This used to derive the minor line from the player's own package.json, which * is correct only while every page is regenerated on the release that moves it. * That is not what happened: the pages sat on the previous minor after a bump, * so a shipped player fix reached npm and never reached the catalog, and * nothing surfaced it, because a page on an old player still renders. A version * that has to be carried in step across 175 generated pages and two hand * written snippets is a version that will be stale, and staleness here is * silent, which is the worst combination. * * `latest` costs the ability to hold the docs back from a bad player release. * That is a real cost, paid deliberately: the previous arrangement did not buy * that control either, it only delayed every good release too. */ const playerVersionRange = "latest"; /** Has a preview payload been built for this item? */ function hasPayload(kind: ItemKind, name: string): boolean { return existsSync(join(payloadRoot, typeDir(kind), `${name}.json`)); } /** * A live preview: the real composition, running in the real player. * * The player is mounted inside the iframe rather than written into the page * because the docs renderer strips unknown custom elements from MDX, so a * `` written here would never reach the DOM. Nothing * rewrites the inside of a `srcDoc` document, so it survives there. * * The composition arrives as JSON because the docs host publishes only JSON and * images out of `docs/public`; an `.html` payload 404s in production while its * page still serves, which is exactly how a previous attempt at this broke * every catalog preview at once. */ function playerEmbed(kind: ItemKind, name: string, posterUrl: string | null | undefined): string { const payloadUrl = `/public/catalog/${typeDir(kind)}/${name}.json`; const poster = posterUrl ? `p.setAttribute("poster","${posterUrl}");` : ""; const bootstrap = [ '', "", `", ].join("\n"); } /** * The wrapper for an item the explorer drives. * * A composition reads its variables once, at init, so a new value can only * arrive by loading the composition again. That reload is deliberately kept one * frame deep: the explorer talks to this wrapper, and only the player's own * iframe reloads. The page, the wrapper and the panel never blink, and the * playhead is carried across so a change mid-shot does not throw the reader * back to frame zero. * * Values travel in the query string rather than a message into the composition * because they have to be readable before its first script runs. The path is * untouched, so `data-composition-src="./sibling.html"` still resolves. * * Every hop re-encodes with `encodeURIComponent` and reads with * `URLSearchParams.get`, so a value is percent-encoded on the wire no matter * how it arrived — including from a player build that form-encoded it. */ export function variablePreviewWrapper(src: string): string[] { return [ '', "", ]; } function catalogPreviewFor(kind: ItemKind, manifest: RegistryItem): string | undefined { // The manifest is the source of truth. Thirteen items declare a preview with // a video and no poster, and that omission is deliberate — no .png was ever // produced for them. if (manifest.preview) return manifest.preview.poster; const dir = typeDir(kind); return `${catalogImageBase}/${dir}/${manifest.name}.png`; } function yamlString(value: string): string { return JSON.stringify(value); } /** "a", "a and b", "a, b, and c" */ function sentenceList(parts: string[]): string { if (parts.length <= 1) return parts[0] ?? ""; if (parts.length === 2) return `${parts[0]} and ${parts[1]}`; return `${parts.slice(0, -1).join(", ")}, and ${parts.at(-1)}`; } /** The file a reader actually opens — by type, not array position. */ function primaryFileFor(manifest: RegistryItem): FileTarget | undefined { return ( manifest.files.find((f) => f.type === "hyperframes:composition") ?? manifest.files.find((f) => f.type === "hyperframes:snippet") ?? manifest.files[0] ); } /** * One sentence naming what lands in the project — replaces the old three-column * File/Target/Type table. The `type` column was registry-internal jargon and the * `path` column was the source path inside this repo, which a reader never sees. */ function installOutcome(manifest: RegistryItem, primaryTarget: string): string { const primary = primaryFileFor(manifest); const others = manifest.files.filter((f) => f !== primary); if (others.length === 0) return `That writes one file: \`${primaryTarget}\`.`; const dirs = [...new Set(others.map((f) => f.target.split("/").slice(0, -1).join("/")))] .filter(Boolean) .map((d) => `\`${d}/\``); const noun = others.length === 1 ? "supporting file" : "supporting files"; if (dirs.length === 0) return `That writes \`${primaryTarget}\` plus ${others.length} ${noun}.`; return `That writes \`${primaryTarget}\`, plus ${others.length} ${noun} under ${sentenceList(dirs)}.`; } /** * True when the installed file opens with an HTML comment. Only 13 of 36 * component snippets do, so the old blanket "see the comment header in the * file" line was simply false on the rest. */ function hasCommentHeader(kind: ItemKind, manifest: RegistryItem): boolean { const primary = primaryFileFor(manifest); if (!primary) return false; const sourcePath = join(registryDir, typeDir(kind), manifest.name, primary.path); if (!existsSync(sourcePath)) return false; try { return readFileSync(sourcePath, "utf-8").trimStart().startsWith("