mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
## Problem The Catalog did not include the four prompt-matched Stronkter one-shot HyperFrames projects, and registry metadata only supported a plain author string, so there was no structured way to show creator attribution or the original generation prompt on generated catalog pages. ## What this fixes - Adds four Catalog blocks matching the provided prompts, in order: - `north-korea-locked-down` - `apple-money-count` - `nyc-paris-flight` - `goonvpn-youtube-spot` - Attributes each block to [Stronkter](https://x.com/Stronkter). - Stores and renders the original source prompt for each generated catalog page. - Adds a local realistic map plate for the North Korea block so rendering does not depend on live map tile requests. - Extends registry item metadata/schema with `authorUrl` and `sourcePrompt`. - Updates catalog page generation to read items from `registry/registry.json`, keeping generated docs aligned to the public registry manifest. - Ignores normal browser media preload `net::ERR_ABORTED` request failures for media assets during `hyperframes validate`, while preserving failures for real missing assets. ## Root cause The imported projects are Catalog-ready compositions, but the registry/docs pipeline did not have first-class source-prompt or linked-author fields to expose creator credit on generated MDX pages. The audio-backed compositions also surfaced a validation edge case: Chrome can report aborted media preload requests as `net::ERR_ABORTED` even when the audio file exists and playback is valid. ## Verification ### Local - `bun run --filter @hyperframes/cli test src/commands/validate.test.ts` - `bun run --filter @hyperframes/core test src/registry/types.test.ts` - `bun run sync-schemas:check` - `bunx oxlint packages/cli/src/commands/validate.ts packages/cli/src/commands/validate.test.ts packages/core/src/registry/types.ts packages/core/src/registry/types.test.ts scripts/generate-catalog-pages.ts` - `bunx oxfmt --check ...` on changed source, registry, docs, and composition files - `git diff --check` - `bun packages/cli/src/cli.ts lint` and `validate` against temp installed projects for all four blocks - Lefthook pre-commit: lint/format/typecheck on the initial commit, plus format on the amend - Lefthook commit-msg: commitlint ### Browser - Exercised all four blocks through HyperFrames preview routes with `agent-browser`. - Captured playback screenshots and WebM recordings for: - `north-korea-locked-down` - `apple-money-count` - `nyc-paris-flight` - `goonvpn-youtube-spot` ## Notes - The zip also contained unrelated project directories, but this PR intentionally includes only the four prompt-matched Catalog blocks requested here. - The imported one-shot compositions may trigger the existing large-composition lint warning, but there are no lint errors and runtime validation passes.
335 lines
12 KiB
TypeScript
335 lines
12 KiB
TypeScript
#!/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 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 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 lines: string[] = [
|
||
"---",
|
||
`title: "${manifest.title.replace(/"/g, '\\"')}"`,
|
||
`description: "${manifest.description.replace(/"/g, '\\"')}"`,
|
||
"---",
|
||
"",
|
||
`# ${manifest.title}`,
|
||
"",
|
||
manifest.description,
|
||
"",
|
||
];
|
||
|
||
if (tagBadges) {
|
||
lines.push(tagBadges, "");
|
||
}
|
||
|
||
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, "```", "");
|
||
}
|
||
|
||
// 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 />`,
|
||
"",
|
||
);
|
||
|
||
// 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)) {
|
||
const w = manifest.dimensions.width;
|
||
const h = manifest.dimensions.height;
|
||
lines.push(
|
||
"## Usage",
|
||
"",
|
||
"After installing, add the block to your host composition:",
|
||
"",
|
||
"```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 {
|
||
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: `${catalogImageBase}/${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;
|
||
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> = {
|
||
"Social Overlays": 0,
|
||
"Shader Transitions": 1,
|
||
"CSS Transitions": 2,
|
||
Showcases: 3,
|
||
Data: 4,
|
||
Effects: 5,
|
||
Blocks: 6,
|
||
};
|
||
|
||
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";
|
||
// Single-tag mapping
|
||
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) {
|
||
// Replace or insert the Catalog tab
|
||
const existingIdx = tabs.findIndex((t) => t.tab === "Catalog");
|
||
const catalogTab = { tab: "Catalog", groups: catalogGroups };
|
||
// Remove existing Catalog tab if present, then insert at position 1
|
||
// (after Documentation, before Packages).
|
||
if (existingIdx >= 0) {
|
||
tabs.splice(existingIdx, 1);
|
||
}
|
||
const docsIdx = tabs.findIndex((t) => t.tab === "Documentation");
|
||
tabs.splice(docsIdx >= 0 ? docsIdx + 1 : 1, 0, catalogTab);
|
||
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();
|