docs: codegen per-item MDX pages from registry (#263)

## What

Script that auto-generates per-item catalog documentation from `registry-item.json` manifests.

**New file:** `scripts/generate-catalog-pages.ts`

**Outputs:**
- `docs/catalog/blocks/<name>.mdx` — per-block detail page
- `docs/catalog/components/<name>.mdx` — per-component detail page
- `docs/public/catalog-index.json` — flat manifest for the grid page (constant-sized regardless of catalog size)
- Updates `docs/docs.json` with a Catalog tab containing Blocks + Components groups

## Why

Phase B of the catalog plan (PR 9). After this lands, future content PRs don't need to write MDX by hand — the script generates everything from `registry-item.json`.

## How

The script:
1. Walks `registry/blocks/*/registry-item.json` and `registry/components/*/registry-item.json`
2. **Wipes `docs/catalog/` before regenerating** — deleted items don't leave stale pages
3. Generates MDX per item with: title, description, tag badges, preview image, install command, details table, files table, usage hint, and related skill link
4. Emits `catalog-index.json` with `{name, type, title, description, tags, href, preview}` per item
5. Updates `docs.json` navigation — inserts or replaces the Catalog tab with current block/component page lists

Run before Mintlify builds: `npx tsx scripts/generate-catalog-pages.ts`

## Test plan

- [x] Script compiles — passes `lefthook` typecheck + lint + format
- [x] CONTRIBUTING.md documents the auto-generation workflow
- [ ] Full end-to-end test requires PRs 6+7 to merge first (items must exist in registry/)
This commit is contained in:
James Russo
2026-04-14 16:24:24 -07:00
committed by GitHub
parent ea6f949922
commit b32611c2c3
2 changed files with 289 additions and 0 deletions
+19
View File
@@ -97,6 +97,25 @@ Blocks don't need `demo.html` — they are already standalone compositions.
4. Run `npx hyperframes lint` and `npx hyperframes validate` on your HTML
5. Test the install flow: `hyperframes add <name> --dir /tmp/test-project`
### Auto-generated docs
When you add a new block or component, its documentation page is generated automatically — you don't need to write MDX by hand.
Run the codegen script after adding items:
```bash
npx tsx scripts/generate-catalog-pages.ts
```
This produces:
- `docs/catalog/blocks/<name>.mdx` — per-block detail page
- `docs/catalog/components/<name>.mdx` — per-component detail page
- `docs/public/catalog-index.json` — flat manifest for the catalog grid page
- Updates `docs/docs.json` navigation with the new pages
The script wipes `docs/catalog/` before regenerating, so deleted items are automatically cleaned up.
## Pull Requests
- Use [conventional commit](https://www.conventionalcommits.org/) format for **all commits** (e.g., `feat: add timeline export`, `fix: resolve seek overflow`). Enforced by a git hook.
+270
View File
@@ -0,0 +1,270 @@
#!/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/<name>.mdx — per-block detail page
* docs/catalog/components/<name>.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 { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { type RegistryItem, isBlockItem, ITEM_TYPE_DIRS } from "@hyperframes/core";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(scriptDir, "..");
const registryDir = resolve(repoRoot, "registry");
const docsDir = resolve(repoRoot, "docs");
// ── Types ──────────────────────────────────────────────────────────────────
type ItemKind = "block" | "component";
interface CatalogEntry {
name: string;
type: ItemKind;
title: string;
description: string;
tags: string[];
/** Relative href within the docs site. */
href: string;
/** Preview poster image path (relative to docs root). */
preview?: string;
}
// ── Discovery ──────────────────────────────────────────────────────────────
function discoverItems(): { kind: ItemKind; manifest: RegistryItem }[] {
const items: { kind: ItemKind; manifest: RegistryItem }[] = [];
const dirs: { kind: ItemKind; dir: string }[] = [
{ kind: "block", dir: join(registryDir, "blocks") },
{ kind: "component", dir: join(registryDir, "components") },
];
for (const { kind, dir } of dirs) {
if (!existsSync(dir)) continue;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const manifestPath = join(dir, entry.name, "registry-item.json");
if (!existsSync(manifestPath)) continue;
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as RegistryItem;
items.push({ kind, manifest });
}
}
return items.sort((a, b) => a.manifest.name.localeCompare(b.manifest.name));
}
// ── MDX generation ─────────────────────────────────────────────────────────
function typeLabel(kind: ItemKind): string {
return kind === "block" ? "Block" : "Component";
}
function typeDir(kind: ItemKind): string {
return ITEM_TYPE_DIRS[kind === "block" ? "hyperframes:block" : "hyperframes:component"];
}
function generateItemMdx(kind: ItemKind, manifest: RegistryItem): string {
const tags = manifest.tags ?? [];
const tagBadges = tags.map((t) => `\`${t}\``).join(" ");
const installCmd = `npx hyperframes add ${manifest.name}`;
const lines: string[] = [
"---",
`title: "${manifest.title.replace(/"/g, '\\"')}"`,
`description: "${manifest.description.replace(/"/g, '\\"')}"`,
"---",
"",
`# ${manifest.title}`,
"",
manifest.description,
"",
];
if (tagBadges) {
lines.push(tagBadges, "");
}
// Preview — rendered by CI preview pipeline (PR 8).
// The image/video may not exist yet if previews haven't been generated.
const previewPath = `/images/catalog/${typeDir(kind)}/${manifest.name}`;
lines.push(
`<img className="w-full aspect-video rounded-xl object-cover bg-zinc-100 dark:bg-zinc-800" src="${previewPath}.png" alt="${manifest.title} preview" />`,
"",
);
// Install command
lines.push(
"## Install",
"",
"<CodeGroup>",
"",
"```bash Terminal",
installCmd,
"```",
"",
"</CodeGroup>",
"",
);
// Details
if (kind === "block" && manifest.dimensions && manifest.duration) {
lines.push(
"## Details",
"",
`| Property | Value |`,
`| --- | --- |`,
`| Type | ${typeLabel(kind)} |`,
`| Dimensions | ${manifest.dimensions.width}×${manifest.dimensions.height} |`,
`| Duration | ${manifest.duration}s |`,
"",
);
} else {
lines.push(
"## Details",
"",
`| Property | Value |`,
`| --- | --- |`,
`| Type | ${typeLabel(kind)} |`,
"",
);
}
// Files
lines.push("## Files", "", "| File | Target | Type |", "| --- | --- | --- |");
for (const f of manifest.files) {
lines.push(`| \`${f.path}\` | \`${f.target}\` | ${f.type} |`);
}
lines.push("");
// Usage hint — find the primary file by type, not array position.
const primaryFile =
manifest.files.find((f) => f.type === "hyperframes:composition") ??
manifest.files.find((f) => f.type === "hyperframes:snippet") ??
manifest.files[0];
const primaryTarget = primaryFile?.target ?? `compositions/${manifest.name}.html`;
if (kind === "block" && isBlockItem(manifest)) {
lines.push(
"## Usage",
"",
"After installing, add the block to your host composition as an iframe:",
"",
"```html",
`<iframe src="${primaryTarget}" data-start="0" data-duration="${manifest.duration}" data-track-index="1"></iframe>`,
"```",
"",
);
} else {
lines.push(
"## Usage",
"",
`Open \`${primaryTarget}\` and paste its contents into your composition. See the comment header in the file for detailed instructions.`,
"",
);
}
// Related skill
if (manifest.relatedSkill) {
lines.push(`<Tip>Related skill: \`/${manifest.relatedSkill}\`</Tip>`, "");
}
return lines.join("\n");
}
// ── Main ───────────────────────────────────────────────────────────────────
function main(): void {
const items = discoverItems();
const catalogIndex: CatalogEntry[] = [];
// Clean previous generated output so deleted items don't leave stale pages.
// Only remove the generated subdirectories, not the entire catalog/ dir
// (which may contain hand-written pages like an overview).
for (const sub of ["blocks", "components"]) {
const dir = join(docsDir, "catalog", sub);
if (existsSync(dir)) rmSync(dir, { recursive: true });
}
console.log(`Generating catalog pages for ${items.length} item(s)...\n`);
for (const { kind, manifest } of items) {
const dir = typeDir(kind);
const outDir = join(docsDir, "catalog", dir);
mkdirSync(outDir, { recursive: true });
const mdx = generateItemMdx(kind, manifest);
const outPath = join(outDir, `${manifest.name}.mdx`);
writeFileSync(outPath, mdx, "utf-8");
console.log(` ✓ catalog/${dir}/${manifest.name}.mdx`);
catalogIndex.push({
name: manifest.name,
type: kind,
title: manifest.title,
description: manifest.description,
tags: manifest.tags ?? [],
href: `/catalog/${dir}/${manifest.name}`,
preview: `/images/catalog/${dir}/${manifest.name}.png`,
});
}
// Write catalog-index.json
const publicDir = join(docsDir, "public");
mkdirSync(publicDir, { recursive: true });
const indexPath = join(publicDir, "catalog-index.json");
writeFileSync(indexPath, JSON.stringify(catalogIndex, null, 2) + "\n", "utf-8");
console.log(`\n ✓ public/catalog-index.json (${catalogIndex.length} items)`);
// Update docs.json navigation with generated catalog pages.
const docsJsonPath = join(docsDir, "docs.json");
const docsJson = JSON.parse(readFileSync(docsJsonPath, "utf-8"));
const tabs = docsJson.navigation?.tabs as Array<{ tab: string; groups: unknown[] }>;
// Build catalog groups from discovered items
const blockPages = catalogIndex
.filter((i) => i.type === "block")
.map((i) => `catalog/blocks/${i.name}`);
const componentPages = catalogIndex
.filter((i) => i.type === "component")
.map((i) => `catalog/components/${i.name}`);
const catalogGroups: { group: string; pages: string[] }[] = [];
if (blockPages.length > 0) catalogGroups.push({ group: "Blocks", pages: blockPages });
if (componentPages.length > 0) catalogGroups.push({ group: "Components", pages: componentPages });
if (catalogGroups.length > 0) {
// Replace or insert the Catalog tab
const existingIdx = tabs.findIndex((t) => t.tab === "Catalog");
const catalogTab = { tab: "Catalog", groups: catalogGroups };
if (existingIdx >= 0) {
tabs[existingIdx] = catalogTab;
} else {
// Insert before the last tab (Reference)
const refIdx = tabs.findIndex((t) => t.tab === "Reference");
if (refIdx >= 0) {
tabs.splice(refIdx, 0, catalogTab);
} else {
tabs.push(catalogTab);
}
}
writeFileSync(docsJsonPath, JSON.stringify(docsJson, null, 2) + "\n", "utf-8");
console.log(
` ✓ docs.json updated with ${blockPages.length} blocks + ${componentPages.length} components`,
);
}
console.log("\nDone.");
}
main();