#!/usr/bin/env tsx /** * Generate Catalog Preview Images + Videos * * Renders preview thumbnails and videos for registry blocks and components. * Examples use the separate generate-template-previews.ts script. * * - Blocks: renders the block's standalone HTML via a wrapper index.html * - Components: renders the component's demo.html via a wrapper index.html * * Output: docs/images/catalog//.png + .mp4 * (docs/images/ is gitignored — files are served from the CDN. After running * this script, run `bun run upload:docs-images` to publish.) * * Usage: * npx tsx scripts/generate-catalog-previews.ts # all items * npx tsx scripts/generate-catalog-previews.ts --only data-chart # single item * npx tsx scripts/generate-catalog-previews.ts --type block # blocks only * npx tsx scripts/generate-catalog-previews.ts --skip-video # thumbnails only */ import { readdirSync, readFileSync, existsSync, mkdirSync, cpSync, rmSync, writeFileSync, statSync, } from "node:fs"; import { execFileSync } from "node:child_process"; import { join, resolve, dirname } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; // Import from source — bun workspace linking doesn't resolve for scripts outside packages/. import { createFileServer, createCaptureSession, initializeSession, captureFrame, getCompositionDuration, closeCaptureSession, createRenderJob, executeRenderJob, } from "../packages/producer/src/index.js"; import { compileForRender } from "../packages/producer/src/services/htmlCompiler.js"; import { resolveContainedCopies } from "./registry-target-paths.mjs"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(scriptDir, ".."); const registryDir = resolve(repoRoot, "registry"); if (!process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH) { process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH = resolve( repoRoot, "packages/core/dist/hyperframe.manifest.json", ); } // ── Types ────────────────────────────────────────────────────────────────── type ItemKind = "block" | "component"; interface CatalogItem { name: string; kind: ItemKind; /** Directory containing the item's files in the registry. */ sourceDir: string; /** The HTML file to render (relative to sourceDir). */ entryFile: string; } // ── Discovery ────────────────────────────────────────────────────────────── function discoverItems(kindFilter: ItemKind | null, nameFilter: string | null): CatalogItem[] { const items: CatalogItem[] = []; // Blocks and components only — examples use the existing generate-template-previews.ts. const kinds: { kind: ItemKind; dir: string }[] = [ { kind: "block", dir: join(registryDir, "blocks") }, { kind: "component", dir: join(registryDir, "components") }, ]; for (const { kind, dir } of kinds) { if (kindFilter && kindFilter !== kind) continue; if (!existsSync(dir)) continue; for (const e of readdirSync(dir, { withFileTypes: true })) { if (!e.isDirectory()) continue; if (nameFilter && e.name !== nameFilter) continue; const sourceDir = join(dir, e.name); const manifestPath = join(sourceDir, "registry-item.json"); if (!existsSync(manifestPath)) continue; // Authored demos show transparent overlays against representative media. let entryFile: string; if (existsSync(join(sourceDir, "demo.html"))) { entryFile = "demo.html"; } else if (kind === "component") { continue; } else { const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")); const compFile = manifest.files?.find( (f: { type: string }) => f.type === "hyperframes:composition", ); entryFile = compFile?.path ?? `${e.name}.html`; } if (!existsSync(join(sourceDir, entryFile))) continue; items.push({ name: e.name, kind, sourceDir, entryFile }); } } if (nameFilter && items.length === 0) { const allNames = discoverItems(null, null).map((i) => i.name); console.error(`Item "${nameFilter}" not found. Available: ${allNames.join(", ")}`); process.exit(1); } return items; } // ── Preview generation ───────────────────────────────────────────────────── function outputDir(kind: ItemKind): string { const typeDir = kind === "block" ? "blocks" : "components"; return resolve(repoRoot, "docs/images/catalog", typeDir); } /** * Preview the item in the same layout users get after installation: some * components reference assets by their registry target path rather than by the * flat source path stored beside the manifest. */ function mirrorRegistryTargets(projectDir: string): void { const manifestPath = join(projectDir, "registry-item.json"); if (!existsSync(manifestPath)) return; const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { files?: { path?: string; target?: string }[]; }; // registry-item.json is untrusted: catalog-previews.yml runs on pull_request // for any registry change, so the manifest arrives from the PR. Containment // lives in its own module so the traversal cases stay testable without this // file's producer imports. for (const [from, to] of resolveContainedCopies(projectDir, manifest.files, existsSync)) { mkdirSync(dirname(to), { recursive: true }); cpSync(from, to); } } async function prepareProjectDir(item: CatalogItem): Promise { const tmpDir = join(tmpdir(), `hf-catalog-${item.name}-${Date.now()}`); mkdirSync(tmpDir, { recursive: true }); cpSync(item.sourceDir, tmpDir, { recursive: true }); mirrorRegistryTargets(tmpDir); // The HyperFrames producer navigates to index.html at the project root. // Blocks and component demos are standalone HTML files, not index.html. // If the entry file is a standalone HTML (has its own timeline registration), // just rename it to index.html. Otherwise create a wrapper. if (!existsSync(join(tmpDir, "index.html")) && existsSync(join(tmpDir, item.entryFile))) { const entryContent = readFileSync(join(tmpDir, item.entryFile), "utf-8"); // A registration inside