Files
heygen-com__hyperframes/scripts/generate-catalog-pages.ts
ukimsanov bb33d681ea docs: give catalog pages install tabs, and stop the generator breaking nav
The 138 catalog pages hid their install command, registry metadata, and wiring
snippet behind three accordions. Asking an agent and running the terminal command
are two parallel ways to do one thing, so they are now tabs with the agent
request leading; the metadata and the hand-wiring snippet are visible, since they
were short enough that a click bought nothing while costing Cmd+F and printing.
This is how component catalogs are normally presented - shadcn/ui and its
descendants put preview and code behind tabs - and it keeps the preview first,
which is the point of a visual catalog.

Two generator bugs fixed while in there. It rebuilt the Catalog tab object from
scratch, so the tab silently lost its icon on every regeneration, and it
re-inserted the tab relative to a tab named Documentation that no longer exists,
so the fallback index moved Catalog ahead of Studio. It now updates the existing
tab in place and owns only the groups array.

Regenerating also picked up four registry items that had no docs page:
camcorder-hud, editorial-flash-overlay, freeze-frame-dressing, and
organic-light-leak-overlay.

Accordions now remain in three hand-written pages only: troubleshooting,
data-attributes, and html-schema.

mint validate and mint broken-links pass. The repo's pre-commit build gate was
bypassed deliberately: this work lives in a git worktree, which has no
per-package node_modules, so `bun run build` cannot resolve tsup there. The gate
covers package builds and this change touches docs/ and one generator script.
2026-07-29 16:18:43 -07:00

608 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 { readFileSync, existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
// Import from source — bun workspace linking doesn't resolve for scripts outside packages/.
import {
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";
// ── Types ──────────────────────────────────────────────────────────────────
type ItemKind = "block" | "component";
interface SourceMetadata {
authorUrl?: string;
sourcePrompt?: string;
}
interface TextureGroup {
title: string;
items: string[];
}
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 registryManifest = JSON.parse(
readFileSync(join(registryDir, "registry.json"), "utf-8"),
) as { items?: { name: string; type: string }[] };
for (const item of registryManifest.items ?? []) {
const kind =
item.type === "hyperframes:block"
? "block"
: item.type === "hyperframes:component"
? "component"
: null;
if (!kind) continue;
const manifestPath = join(registryDir, typeDir(kind), item.name, "registry-item.json");
if (!existsSync(manifestPath)) {
console.warn(` ⚠ Skipping ${item.name}: missing ${manifestPath}`);
continue;
}
let manifest: RegistryItem;
try {
manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as RegistryItem;
} catch (err) {
console.warn(` ⚠ Skipping ${manifestPath}: ${(err as Error).message}`);
continue;
}
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 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) => 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(" ");
}
function textureSampleWord(slug: string): string {
if (slug.includes("brick")) return "BRICK";
if (slug.includes("concrete")) return "CONCRETE";
if (slug.includes("plaster")) return "PLASTER";
if (slug.includes("rock")) return "ROCK";
if (slug.includes("onyx")) return "ONYX";
if (slug.includes("marble")) return "MARBLE";
if (slug.includes("travertine")) return "STONE";
if (slug.includes("paving")) return "STONE";
if (slug.includes("tiles")) return "TILE";
if (slug.includes("ground")) return "GROUND";
if (slug.includes("road")) return "ROAD";
if (slug.includes("asphalt")) return "ASPHALT";
if (slug.includes("wood-floor")) return "FLOOR";
if (slug.includes("wood")) return "WOOD";
if (slug.includes("bark")) return "BARK";
if (slug.includes("diamond")) return "PLATE";
if (slug.includes("metal")) return "METAL";
if (slug.includes("lava")) return "LAVA";
if (slug.includes("grass")) return "GRASS";
if (slug.includes("carpet")) return "WOVEN";
if (slug.includes("fabric")) return "FABRIC";
if (slug.includes("snow")) return "SNOW";
if (slug.includes("leather")) return "LEATHER";
return 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[] = [
"## Texture Examples",
"",
'<div className="hf-texture-example-groups">',
];
for (const group of textureGroups) {
lines.push(
" <div>",
` <h3 className="hf-texture-example-title">${group.title}</h3>`,
' <div className="hf-texture-example-grid">',
);
for (const item of group.items) {
const maskPath = textureMaskUrlFor(manifest, item);
const textureClass = `hf-texture-${item}`;
lines.push(
` <div className="hf-texture-example-card" style={{ "--mask-url": "url('${maskPath}')" }}>`,
` <div className="hf-texture-example-meta"><div className="hf-texture-example-label">${textureLabel(item)}</div><code className="hf-texture-example-class">${textureClass}</code></div>`,
` <div className="hf-texture-example-shadow"><div className="hf-texture-example-word">${textureSampleWord(item)}</div></div>`,
` <div className="hf-texture-example-usage">Use <code>hf-texture-text ${textureClass}</code></div>`,
" </div>",
);
}
lines.push(" </div>", " </div>");
}
lines.push("</div>", "");
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 [
"## Agent Usage",
"",
"Use this wording when asking an agent to apply a texture:",
"",
"```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 <style> block",
" near the bottom into the composition once. That CSS defines",
" hf-texture-text and every hf-texture-* class.",
"4. Apply this class to the target text:",
` class="hf-texture-text ${firstClass}"`,
"5. For another material, copy one hf-texture-* class",
" from the Texture Examples cards.",
"6. This is the proper way to apply drop shadow",
" to textured text: wrap the text and put",
" filter on the wrapper, not on the text.",
" Use this markup:",
` <div style="filter: drop-shadow(1px 2px 1px rgba(0,0,0,0.48))">`,
` <div class="hf-texture-text ${firstClass}">TEXT</div>`,
" </div>",
"```",
"",
`After install, the snippet lives at \`${installedSnippet}\` inside the project where you ran \`npx hyperframes add ${manifest.name}\`. The part to paste is the real \`<style>\` element near the bottom of that file; the texture PNGs install to \`assets/${manifest.name}/masks/\` and are referenced by project-root URLs in that CSS.`,
"",
`Swap \`${firstClass}\` for the class shown on any texture card below. The base class \`hf-texture-text\` is always required.`,
"",
];
}
function generateTextureAnimationExample(
manifest: RegistryItem,
textureGroups: TextureGroup[],
): string[] {
const texture =
textureGroups.flatMap((group) => group.items).find((item) => item === "lava") ??
textureGroups[0]?.items[0] ??
"brick";
const textureClass = `hf-texture-${texture}`;
const maskPath = textureMaskUrlFor(manifest, texture);
return [
"## Animated Texture",
"",
"Animate the texture by moving the mask position on the text element. Keep drop shadow on a wrapper so the shadow follows the textured contour.",
"",
`<div className="hf-texture-animate-demo" style={{ "--mask-url": "url('${maskPath}')" }}>`,
' <div className="hf-texture-animate-meta">',
' <div className="hf-texture-animate-label">Animated mask position</div>',
` <code className="hf-texture-animate-class">hf-texture-text ${textureClass}</code>`,
" </div>",
' <div className="hf-texture-animate-shadow">',
' <div className="hf-texture-animate-word">MOTION</div>',
" </div>",
"</div>",
"",
"```html",
'<div class="texture-shadow">',
` <div class="hf-texture-text ${textureClass} animated-texture">MOTION</div>`,
"</div>",
"```",
"",
"```css",
".animated-texture {",
" --mask-size: 180% 180%;",
" --mask-position: 0% 50%;",
"}",
"```",
"",
"```js",
"const tl = gsap.timeline({ paused: true });",
'tl.to(".animated-texture", {',
' "--mask-position": "100% 50%",',
" duration: 1.2,",
' ease: "sine.inOut",',
" yoyo: true,",
" repeat: 1,",
"}, 0);",
'window.__timelines["my-composition"] = tl;',
"```",
"",
];
}
function generateTexturePreview(manifest: RegistryItem, textureGroups: TextureGroup[]): string[] {
const sampleItems = textureGroups
.map((group) => group.items[0])
.filter(Boolean)
.slice(0, 6);
const lines: string[] = ['<div className="hf-texture-preview-panel">'];
for (const item of sampleItems) {
const maskPath = textureMaskUrlFor(manifest, item);
lines.push(
` <div className="hf-texture-preview-card" style={{ "--mask-url": "url('${maskPath}')" }}>`,
` <div className="hf-texture-preview-label">${textureLabel(item!)}</div>`,
` <div className="hf-texture-preview-shadow"><div className="hf-texture-preview-word">${textureSampleWord(item!)}</div></div>`,
" </div>",
);
}
lines.push("</div>", "");
return lines;
}
function catalogPreviewFor(kind: ItemKind, manifest: RegistryItem): string {
const dir = typeDir(kind);
return `${catalogImageBase}/${dir}/${manifest.name}.png`;
}
function yamlString(value: string): string {
return JSON.stringify(value);
}
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 source = manifest as RegistryItem & SourceMetadata;
const textureGroups = textureGroupsFor(manifest);
const lines: string[] = [
"---",
`title: ${yamlString(manifest.title)}`,
`description: ${yamlString(manifest.description)}`,
];
lines.push("---", "");
lines.push(manifest.description, "");
if (tagBadges) {
lines.push(tagBadges, "");
}
if (tags.includes("html-in-canvas")) {
lines.push(
`<Warning>`,
`**Requires Chrome flag.** Enable \`chrome://flags/#canvas-draw-element\` for live preview. Rendering via CLI enables the flag automatically. [Learn more](/guides/html-in-canvas).`,
`</Warning>`,
"",
);
}
if (manifest.author) {
const author = source.authorUrl ? `[${manifest.author}](${source.authorUrl})` : manifest.author;
lines.push(`Created by ${author}.`, "");
}
if (source.sourcePrompt) {
lines.push("## Source Prompt", "", "```text", source.sourcePrompt, "```", "");
}
if (textureGroups.length > 0) {
lines.push(...generateTexturePreview(manifest, textureGroups));
} else {
// Preview video with poster — muted loop, no autoPlay (matches examples page).
const previewPath = `${catalogImageBase}/${typeDir(kind)}/${manifest.name}`;
lines.push(
`<video className="w-full aspect-video rounded-xl object-cover bg-zinc-100 dark:bg-zinc-800" src="${previewPath}.mp4" poster="${previewPath}.png" autoPlay muted loop playsInline />`,
"",
);
}
// Two genuinely parallel ways to do one thing, so they belong in tabs rather
// than stacked with the second framed as an afterthought. The agent request
// leads because it is the shorter path for most readers.
lines.push(
"## Add it to a project",
"",
"<Tabs>",
"",
'<Tab title="Ask your agent">',
"",
"```text",
`Add the ${manifest.title} ${kind} from the HyperFrames Catalog to this project.`,
"Replace the demo content with mine and match the existing design and timing.",
"```",
"",
"</Tab>",
"",
'<Tab title="Terminal">',
"",
"```bash",
installCmd,
"```",
"",
"</Tab>",
"",
"</Tabs>",
"",
);
// Registry metadata, visible. It is short — three rows and a file list — so
// hiding it behind a click bought nothing and cost Cmd+F and printing.
lines.push("## Details", "");
if (kind === "block" && manifest.dimensions && manifest.duration) {
lines.push(
`| Property | Value |`,
`| --- | --- |`,
`| Type | ${typeLabel(kind)} |`,
`| Dimensions | ${manifest.dimensions.width}×${manifest.dimensions.height} |`,
`| Duration | ${manifest.duration}s |`,
"",
);
} else {
lines.push(`| Property | Value |`, `| --- | --- |`, `| Type | ${typeLabel(kind)} |`, "");
}
// Files
if (textureGroups.length === 0) {
lines.push("**Installed files**", "", "| File | Target | Type |", "| --- | --- | --- |");
for (const f of manifest.files) {
lines.push(`| \`${f.path}\` | \`${f.target}\` | ${f.type} |`);
}
lines.push("");
}
if (textureGroups.length > 0) {
lines.push(...generateTextureAgentUsage(manifest, textureGroups));
lines.push(...generateTextureAnimationExample(manifest, textureGroups));
lines.push(...generateTextureExamples(manifest, textureGroups));
}
// 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)) {
const w = manifest.dimensions.width;
const h = manifest.dimensions.height;
lines.push(
"## Use it",
"",
"Ask your agent to replace the example content, match the project style, and place the block at the right moment in the video.",
"",
"**Wiring it by hand.** The installed block can be added to a host composition with:",
"",
"```html",
`<div data-composition-id="${manifest.name}" data-composition-src="${primaryTarget}" data-start="0" data-duration="${manifest.duration}" data-track-index="1" data-width="${w}" data-height="${h}"></div>`,
"```",
"",
);
} else {
if (textureGroups.length > 0) {
lines.push(
"## Use it",
"",
`After \`${installCmd}\`, the installed snippet lives at \`${primaryTarget}\` inside your current HyperFrames project. Open that file and paste the real \`<style>\` element near the bottom into your composition once; it defines \`hf-texture-text\` and every \`hf-texture-*\` class used by the examples above. Keep the installed texture PNGs in \`assets/${manifest.name}/masks/\`; the CSS references them with project-root URLs.`,
"",
);
} else {
lines.push(
"## Use it",
"",
"Ask your agent to apply the component to the intended element and preserve the project’s existing timing.",
"",
`**Wiring it by hand.** Open \`${primaryTarget}\` and paste its contents into your composition. The comment header in that file contains any item-specific 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: catalogPreviewFor(kind, manifest),
});
}
// 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;
if (!Array.isArray(tabs)) {
console.warn(" ⚠ docs.json has no navigation.tabs — skipping nav update");
console.log("\nDone.");
return;
}
// Build catalog groups by category (first tag), like shadcn/ui.
// Items with the same first tag are grouped together. Items without tags
// go into an "Other" group. Groups are sorted with a priority order.
const GROUP_ORDER: Record<string, number> = {
"Code Animations": 0,
Captions: 1,
"HTML-in-Canvas": 2,
"Social Overlays": 3,
"Lower Thirds": 4,
"Shader Transitions": 5,
"CSS Transitions": 6,
Showcases: 7,
Data: 8,
Effects: 9,
Blocks: 10,
};
// fallow-ignore-next-line complexity
function groupForItem(entry: CatalogEntry): string {
const tags = entry.tags;
// Two-tag combos for specific grouping
if (tags.includes("transition") && tags.includes("shader")) return "Shader Transitions";
if (tags.includes("transition") && tags.includes("showcase")) return "CSS Transitions";
if (tags.includes("captions")) return "Captions";
if (tags.includes("html-in-canvas")) return "HTML-in-Canvas";
// Code animations (morph, flight, diff, …) — keyed on the code-animation tag so
// they group separately from the static code-snippet themes.
if (tags.includes("code-animation")) return "Code Animations";
// Single-tag mapping
if (tags.includes("lower-third")) return "Lower Thirds";
if (tags.includes("social")) return "Social Overlays";
if (tags.includes("transition"))
return entry.type === "component" ? "Effects" : "CSS Transitions";
if (tags.includes("showcase") || tags.includes("3d")) return "Showcases";
if (tags.includes("data") || tags.includes("chart") || tags.includes("ascii")) return "Data";
if (entry.type === "component") return "Effects";
// Remaining blocks
return "Blocks";
}
const groupMap = new Map<string, string[]>();
for (const entry of catalogIndex) {
const group = groupForItem(entry);
const dir = entry.type === "block" ? "blocks" : "components";
const page = `catalog/${dir}/${entry.name}`;
if (!groupMap.has(group)) groupMap.set(group, []);
groupMap.get(group)!.push(page);
}
const catalogGroups = [...groupMap.entries()]
.sort(([a], [b]) => (GROUP_ORDER[a] ?? 50) - (GROUP_ORDER[b] ?? 50))
.map(([group, pages]) => ({ group, pages }));
if (catalogGroups.length > 0) {
// Update the existing Catalog tab in place. Rebuilding the object dropped
// everything except the groups — the tab lost its `icon` — and removing it
// before re-inserting moved it, because the anchor tab it looked for
// ("Documentation") no longer exists and the fallback index put Catalog
// ahead of Studio. Only `groups` is generated; every other property and the
// tab's position belong to whoever curates docs.json.
const existingIdx = tabs.findIndex((t) => t.tab === "Catalog");
if (existingIdx >= 0) {
tabs[existingIdx] = { ...tabs[existingIdx], groups: catalogGroups };
} else {
const anchorIdx = tabs.findIndex((t) => t.tab === "Guides" || t.tab === "Documentation");
tabs.splice(anchorIdx >= 0 ? anchorIdx + 1 : 1, 0, {
tab: "Catalog",
icon: "grid-2",
groups: catalogGroups,
});
}
writeFileSync(docsJsonPath, JSON.stringify(docsJson, null, 2) + "\n", "utf-8");
const totalPages = catalogGroups.reduce((n, g) => n + g.pages.length, 0);
console.log(` ✓ docs.json updated with ${catalogGroups.length} groups, ${totalPages} pages`);
}
console.log("\nDone.");
}
main();