diff --git a/docs/catalog/components/motion-blur.mdx b/docs/catalog/components/motion-blur.mdx
index f0e5a33184..911f6d61aa 100644
--- a/docs/catalog/components/motion-blur.mdx
+++ b/docs/catalog/components/motion-blur.mdx
@@ -14,7 +14,7 @@ import { InstallCommand } from "/snippets/install-command.jsx";
title="Motion Blur"
description="After Effects-style motion blur — integrates each element over a shutter window (shutter angle, phase, samples per frame) by sampling the GSAP timeline at sub-frame times and averaging equal-weight additive copies of the element along its real trajectory, so translation, scale, rotation, 3D rotation and skew all drive the smear"
variables={[]}
- meta={{"category":"Effects","badge":"Stable","codeLines":406}}
+ meta={{"category":"Effects","badge":"Stable","codeLines":722}}
attribution={{"path":"registry/components/motion-blur","tags":["effect","motion-blur","shutter","after-effects","velocity","animation"]}}
hasCode
rawUrl="https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry/components/motion-blur/motion-blur.html"
diff --git a/docs/catalog/components/shutter-slam.mdx b/docs/catalog/components/shutter-slam.mdx
index 928166cc1c..78be2c98ae 100644
--- a/docs/catalog/components/shutter-slam.mdx
+++ b/docs/catalog/components/shutter-slam.mdx
@@ -14,7 +14,7 @@ import { InstallCommand } from "/snippets/install-command.jsx";
title="Shutter Slam"
description="One word thrown through six single-property beats under integrated shutter blur — two translate slams, a scale punch, then a full turn about each axis — on the measured easing of the After Effects export the shutter model was fitted to"
variables={[{"id":"text","type":"string","role":"content","label":"Word","description":"The word driven through the six beats.","default":"MOTION"},{"id":"accent","type":"enum","role":"style","label":"Accent","description":"Word color.","default":"green","options":[{"value":"green","label":"Green"},{"value":"blue","label":"Blue"},{"value":"violet","label":"Violet"}]},{"id":"blur","type":"enum","role":"motion","label":"Blur","description":"Integrate each frame over the shutter window, or capture one instant per frame.","default":"shutter","options":[{"value":"shutter","label":"Shutter"},{"value":"none","label":"None"}]},{"id":"cues","type":"string","role":"motion","label":"Cues","description":"Comma seconds from mount start for each of the six beats; empty keeps the authored rhythm.","default":""},{"id":"exit","type":"enum","role":"motion","label":"Exit","description":"How the word leaves.","default":"none","options":[{"value":"none","label":"None"},{"value":"fade","label":"Fade"},{"value":"up","label":"Up"}]}]}
- meta={{"category":"Effects","badge":"Stable","codeLines":541}}
+ meta={{"category":"Effects","badge":"Stable","codeLines":830}}
attribution={{"path":"registry/components/shutter-slam","tags":["effect","motion-blur","shutter","after-effects","slam","animation"]}}
hasCode
rawUrl="https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry/components/shutter-slam/shutter-slam.html"
diff --git a/docs/public/catalog-index.json b/docs/public/catalog-index.json
index 651d4c5584..96ac29b00e 100644
--- a/docs/public/catalog-index.json
+++ b/docs/public/catalog-index.json
@@ -3682,7 +3682,7 @@
"sphere"
],
"href": "/catalog/blocks/orbit-card",
- "preview": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/orbit-card.png"
+ "preview": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/orbit-card-v2.png"
},
{
"name": "ordered-dither-pass",
diff --git a/docs/snippets/catalog-detail.jsx b/docs/snippets/catalog-detail.jsx
index 5f42c75ec3..574c367722 100644
--- a/docs/snippets/catalog-detail.jsx
+++ b/docs/snippets/catalog-detail.jsx
@@ -62,6 +62,64 @@ export const CatalogSlot = ({ slot, children }) => (
);
+/** The Tune panel's variable list, with a pulsing cue while more rows sit below the fold. */
+export const TuneList = ({ variables, values, notes, onValues, onNotes, onTyping, control, readout }) => {
+ // BEGIN hasMoreBelow: true once unseen content sits past the scrolled viewport (4px epsilon).
+ const hasMoreBelow = (scrollHeight, scrollTop, clientHeight) => scrollHeight - scrollTop - clientHeight > 4;
+ // END hasMoreBelow
+ const listRef = useRef(null);
+ const [moreBelow, setMoreBelow] = useState(false);
+ const check = () => {
+ const el = listRef.current;
+ if (el) setMoreBelow(hasMoreBelow(el.scrollHeight, el.scrollTop, el.clientHeight));
+ };
+ useEffect(() => {
+ const el = listRef.current;
+ if (!el) return;
+ check();
+ const observer = new ResizeObserver(check);
+ observer.observe(el);
+ for (const child of el.children) observer.observe(child);
+ return () => observer.disconnect();
+ }, []);
+ return (
+
+
+ {variables.map((v) => (
+
+
+ {v.label ?? v.id}
+ {readout(v, values[v.id])}
+
+ {control(
+ v,
+ values[v.id],
+ (next) => onValues((prev) => ({ ...prev, [v.id]: next })),
+ notes[v.id],
+ (note) => onNotes((prev) => ({ ...prev, [v.id]: note })),
+ onTyping,
+ )}
+ {v.description &&
{v.description}
}
+
+ ))}
+
+ {moreBelow && (
+
+ )}
+
+ );
+};
+
export const CatalogDetail = ({
previewSrc,
compositionId,
@@ -668,6 +726,7 @@ export const CatalogDetail = ({
font-weight: 600;
}
.hf-ve-tune-head small { font-weight: 400; font-size: 13px; color: var(--ve-muted); }
+.hf-ve-tune-list-wrap { position: relative; flex: 1; min-height: 0; display: flex; }
.hf-ve-tune-list {
flex: 1;
min-height: 0;
@@ -679,6 +738,29 @@ export const CatalogDetail = ({
align-content: start;
mask-image: linear-gradient(to bottom, transparent 0, #000 12px, #000 calc(100% - 12px), transparent 100%);
}
+.hf-ve-tune-more {
+ position: absolute;
+ left: 50%;
+ bottom: 6px;
+ transform: translateX(-50%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border-radius: 9999px;
+ background: var(--ve-hover);
+ color: var(--ve-muted);
+ pointer-events: none;
+ animation: hf-ve-tune-more-pulse 1.6s ease-in-out infinite;
+}
+@keyframes hf-ve-tune-more-pulse {
+ 0%, 100% { opacity: 1; transform: translateX(-50%) scale(1); }
+ 50% { opacity: 0.55; transform: translateX(-50%) scale(1.15); }
+}
+@media (prefers-reduced-motion: reduce) {
+ .hf-ve-tune-more { animation: none; }
+}
.hf-ve-tune-foot {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
@@ -2141,25 +2223,16 @@ export const CatalogDetail = ({
Tune {variables.length} {variables.length === 1 ? "variable" : "variables"}
-
- {variables.map((v) => (
-
-
- {v.label ?? v.id}
- {readout(v, values[v.id])}
-
- {control(
- v,
- values[v.id],
- (next) => setValues((prev) => ({ ...prev, [v.id]: next })),
- notes[v.id],
- (note) => setNotes((prev) => ({ ...prev, [v.id]: note })),
- setTyping,
- )}
- {v.description &&
{v.description}
}
-
- ))}
-
+
\n`,
+ });
+}
function tree(files: Record): string {
const root = mkdtempSync(join(tmpdir(), "catalog-drift-test-"));
@@ -33,3 +44,22 @@ test("a stale payload, a missing file and a leftover file each turn the check re
rmSync(generated, { recursive: true });
rmSync(committed, { recursive: true });
});
+
+test("codeLinesDrift is clean when the committed page matches its own source", () => {
+ const committed = catalogDir(REAL_CODE_LINES);
+ assert.deepEqual(codeLinesDrift(committed), []);
+ rmSync(committed, { recursive: true });
+});
+
+test("a stale codeLines turns the check red, naming the page and both counts", () => {
+ const committed = catalogDir(REAL_CODE_LINES + 50);
+ const found = codeLinesDrift(committed);
+ assert.equal(found.length, 1);
+ assert.match(
+ found[0]!,
+ new RegExp(
+ `blocks/${REAL_ITEM}\\.mdx says ${REAL_CODE_LINES + 50}, source is ${REAL_CODE_LINES} lines`,
+ ),
+ );
+ rmSync(committed, { recursive: true });
+});
diff --git a/scripts/catalog-drift.ts b/scripts/catalog-drift.ts
index 4eec1eab3c..0f49a159a1 100644
--- a/scripts/catalog-drift.ts
+++ b/scripts/catalog-drift.ts
@@ -1,10 +1,11 @@
// Fails when the committed docs/public/catalog, which the docs build serves as-is, differs from generator output.
import { spawnSync } from "node:child_process";
-import { mkdirSync, readdirSync, readFileSync, mkdtempSync, rmSync } from "node:fs";
+import { existsSync, mkdirSync, readdirSync, readFileSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, relative, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { runAsCommand } from "./entrypoint.ts";
+import { discoverItems, primarySource } from "./generate-catalog-pages.ts";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const MAX_LISTED = 40;
@@ -34,6 +35,31 @@ export function treeDifferences(generatedRoot: string, committedRoot: string): s
];
}
+/** One item's stale-codeLines message, or null when there's nothing to compare (no source file,
+ * no committed page, or a page with no code fence). */
+function codeLinesMismatch(
+ item: ReturnType[number],
+ committedCatalogDir: string,
+): string | null {
+ const file = primarySource(item.kind, item.manifest);
+ if (!file) return null;
+ const dir = item.kind === "block" ? "blocks" : "components";
+ const pagePath = join(committedCatalogDir, dir, `${item.manifest.name}.mdx`);
+ if (!existsSync(pagePath)) return null; // an absent page is treeDifferences' job, not this one's
+ const match = readFileSync(pagePath, "utf-8").match(/"codeLines":(\d+)/);
+ if (!match || !match[1]) return null; // page has no code fence, nothing to compare
+ const committed = Number(match[1]);
+ const real = file.source.split("\n").length;
+ if (committed === real) return null;
+ return `stale codeLines: catalog/${dir}/${item.manifest.name}.mdx says ${committed}, source is ${real} lines`;
+}
+
+export function codeLinesDrift(committedCatalogDir: string): string[] {
+ return discoverItems()
+ .map((item) => codeLinesMismatch(item, committedCatalogDir))
+ .filter((line) => line !== null);
+}
+
function generateInto(outRoot: string): number {
const run = spawnSync("npx", ["tsx", "scripts/generate-catalog-payloads.ts"], {
cwd: repoRoot,
@@ -43,17 +69,17 @@ function generateInto(outRoot: string): number {
return run.status ?? 1;
}
-/** The catalog as `git` holds it, so a file the working tree has but a .gitignore rule keeps out cannot hide drift. */
-function extractCommittedCatalog(into: string): string {
- const archive = spawnSync("git", ["archive", "HEAD", "docs/public/catalog"], {
+/** A tree as `git` holds it, so a file the working tree has but a .gitignore rule keeps out cannot hide drift. */
+function extractCommitted(gitPath: string, into: string): string {
+ const archive = spawnSync("git", ["archive", "HEAD", gitPath], {
cwd: repoRoot,
maxBuffer: 2 ** 31 - 1,
});
- if (archive.status !== 0) throw new Error("git archive of docs/public/catalog failed.");
+ if (archive.status !== 0) throw new Error(`git archive of ${gitPath} failed.`);
mkdirSync(into, { recursive: true });
const untar = spawnSync("tar", ["-x", "-C", into], { input: archive.stdout });
- if (untar.status !== 0) throw new Error("could not unpack the committed catalog.");
- return join(into, "docs/public/catalog");
+ if (untar.status !== 0) throw new Error(`could not unpack the committed ${gitPath}.`);
+ return join(into, gitPath);
}
async function main(): Promise {
@@ -61,13 +87,39 @@ async function main(): Promise {
const outRoot = join(base, "generated");
try {
if (generateInto(outRoot) !== 0) throw new Error("The catalog payload generator failed.");
- const committedRoot = extractCommittedCatalog(join(base, "committed"));
- const differences = treeDifferences(outRoot, committedRoot);
- if (differences.length === 0) return console.log("docs/public/catalog matches the generator.");
- const listed = differences.slice(0, MAX_LISTED).map((line) => ` ${line}`);
- throw new Error(
- `docs/public/catalog differs from the generator in ${differences.length} file(s):\n${listed.join("\n")}\nRegenerate with \`tsx scripts/generate-catalog-payloads.ts\` and commit the result.`,
+ const committedPayloadRoot = extractCommitted(
+ "docs/public/catalog",
+ join(base, "committed-payload"),
+ );
+ const differences = treeDifferences(outRoot, committedPayloadRoot);
+ const committedCatalogDir = extractCommitted("docs/catalog", join(base, "committed-catalog"));
+ const staleCodeLines = codeLinesDrift(committedCatalogDir);
+ if (differences.length === 0 && staleCodeLines.length === 0) {
+ return console.log("docs/public/catalog and every page's codeLines match the generator.");
+ }
+ const sections = [
+ {
+ items: differences,
+ header: (n: number) => `docs/public/catalog differs from the generator in ${n} file(s):`,
+ regenCmd:
+ "Regenerate with `tsx scripts/generate-catalog-payloads.ts` and commit the result.",
+ },
+ {
+ items: staleCodeLines,
+ header: (n: number) => `${n} page(s) have a stale codeLines:`,
+ regenCmd: "Regenerate with `tsx scripts/generate-catalog-pages.ts` and commit the result.",
+ },
+ ];
+ const lines = sections.flatMap(({ items, header, regenCmd }) =>
+ items.length === 0
+ ? []
+ : [
+ header(items.length),
+ ...items.slice(0, MAX_LISTED).map((line) => ` ${line}`),
+ regenCmd,
+ ],
);
+ throw new Error(lines.join("\n"));
} finally {
rmSync(base, { recursive: true, force: true });
}
diff --git a/scripts/catalog-fetch-mirror.test.ts b/scripts/catalog-fetch-mirror.test.ts
index 7a59a36fc0..b04b80c243 100644
--- a/scripts/catalog-fetch-mirror.test.ts
+++ b/scripts/catalog-fetch-mirror.test.ts
@@ -1,10 +1,34 @@
import assert from "node:assert/strict";
-import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { installFetchMirror } from "./catalog-fetch-mirror.ts";
+/** Runs `run` against a fresh record-mode mirror stubbed to answer every fetch with
+ * `bodyBytes` bytes of `contentType`. `run` calls `mirror.finish()` itself if it needs
+ * the written index; `fetch` and the temp dir are always restored/cleaned up after. */
+async function withRecordMirror(
+ contentType: string,
+ bodyBytes: number,
+ run: (dir: string, mirror: ReturnType) => Promise | void,
+): Promise {
+ const realFetch = globalThis.fetch;
+ const dir = mkdtempSync(join(tmpdir(), "catalog-mirror-record-"));
+ globalThis.fetch = (async () =>
+ new Response(new Uint8Array(bodyBytes), {
+ status: 200,
+ headers: { "content-type": contentType },
+ })) as typeof fetch;
+ const mirror = installFetchMirror(dir, "record");
+ try {
+ await run(dir, mirror);
+ } finally {
+ globalThis.fetch = realFetch;
+ rmSync(dir, { recursive: true });
+ }
+}
+
function mirrorDir(): string {
const dir = mkdtempSync(join(tmpdir(), "catalog-mirror-test-"));
mkdirSync(dir, { recursive: true });
@@ -34,3 +58,57 @@ test("an unmirrored fetch fails and is still reported after the caller swallows
mirror.finish();
rmSync(dir, { recursive: true });
});
+
+test("record writes an allowed, small response to disk and indexes it", async () => {
+ await withRecordMirror("text/css; charset=utf-8", 12, async (dir, mirror) => {
+ const response = await fetch("https://cdn.example/a.css");
+ assert.equal(response.status, 200);
+ mirror.assertNoMisses();
+ mirror.finish();
+ const files = readdirSync(dir).filter((f) => f.endsWith(".bin"));
+ assert.equal(files.length, 1);
+ const [file] = files;
+ assert.ok(file);
+ assert.equal(readFileSync(join(dir, file)).length, 12);
+ const index = JSON.parse(readFileSync(join(dir, "index.json"), "utf-8"));
+ assert.equal(index["https://cdn.example/a.css"].file, file);
+ });
+});
+
+/** A refused fetch reports as a miss and writes nothing, whatever the reason. */
+async function assertRefused(
+ url: string,
+ dir: string,
+ mirror: ReturnType,
+ message: RegExp,
+): Promise {
+ await fetch(url);
+ assert.throws(() => mirror.assertNoMisses(), message);
+ mirror.finish();
+ assert.deepEqual(
+ readdirSync(dir).filter((f) => f.endsWith(".bin")),
+ [],
+ );
+}
+
+test("record refuses a content-type the generator does not expect", async () => {
+ await withRecordMirror("application/octet-stream", 12, (dir, mirror) =>
+ assertRefused(
+ "https://cdn.example/a.bin",
+ dir,
+ mirror,
+ /content-type application\/octet-stream is not mirrored/,
+ ),
+ );
+});
+
+test("record refuses a body over the mirror's size cap", async () => {
+ await withRecordMirror("text/css", 8 * 1024 * 1024 + 1, (dir, mirror) =>
+ assertRefused(
+ "https://cdn.example/huge.css",
+ dir,
+ mirror,
+ /exceeds the 8388608-byte mirror cap/,
+ ),
+ );
+});
diff --git a/scripts/catalog-fetch-mirror.ts b/scripts/catalog-fetch-mirror.ts
index d411934988..6c2eec3144 100644
--- a/scripts/catalog-fetch-mirror.ts
+++ b/scripts/catalog-fetch-mirror.ts
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
+import { isContainedIn } from "./registry-target-paths.mjs";
interface MirrorEntry {
status: number;
@@ -11,6 +12,25 @@ type MirrorIndex = Record;
export type MirrorMode = "replay" | "record";
+// Text/font/js cover what's recorded today; json/image/video are allowed for a future source.
+// Anything else, or any response over the size cap below, is refused, not silently recorded,
+// so an unexpected fetch fails the run instead of writing an unbounded, unvetted body to disk.
+const MIRROR_CONTENT_TYPES = [
+ /^text\//,
+ /^font\//,
+ /^image\//,
+ /^video\//,
+ /^application\/json/,
+ /^application\/javascript/,
+];
+const MAX_MIRROR_BYTES = 8 * 1024 * 1024;
+
+function mirrorableContentType(contentType: string | null): boolean {
+ if (!contentType) return false;
+ const base = (contentType.split(";")[0] ?? "").trim().toLowerCase();
+ return MIRROR_CONTENT_TYPES.some((re) => re.test(base));
+}
+
export interface FetchMirror {
/** Throws when a fetch during the run was not in the mirror, even if the caller swallowed the error. */
assertNoMisses(): void;
@@ -52,12 +72,23 @@ export function installFetchMirror(dir: string, mode: MirrorMode): FetchMirror {
async function recordFetch(input: Parameters[0], init?: RequestInit) {
const response = await realFetch(input, init);
if (!response.ok) return response;
+ const url = requestUrl(input);
+ const contentType = response.headers.get("content-type");
+ if (!mirrorableContentType(contentType)) {
+ misses.push(`${url} (content-type ${contentType ?? "none"} is not mirrored)`);
+ return response;
+ }
const body = Buffer.from(await response.arrayBuffer());
+ if (body.length > MAX_MIRROR_BYTES) {
+ misses.push(`${url} (${body.length} bytes exceeds the ${MAX_MIRROR_BYTES}-byte mirror cap)`);
+ return response;
+ }
const file = `${createHash("sha256").update(body).digest("hex").slice(0, 24)}.bin`;
mkdirSync(dir, { recursive: true });
+ if (!isContainedIn(dir, file))
+ throw new Error(`catalog fetch mirror: refusing to write outside ${dir}`);
writeFileSync(join(dir, file), body);
- const contentType = response.headers.get("content-type");
- index[requestUrl(input)] = { status: response.status, contentType, file };
+ index[url] = { status: response.status, contentType, file };
return new Response(body, { status: response.status, headers: response.headers });
}
diff --git a/scripts/generate-catalog-pages.test.ts b/scripts/generate-catalog-pages.test.ts
index 2ce2ceef3c..8ec06f49df 100644
--- a/scripts/generate-catalog-pages.test.ts
+++ b/scripts/generate-catalog-pages.test.ts
@@ -279,6 +279,46 @@ describe("WebGPU adapter probe", () => {
});
});
+describe("Tune scroll cue", () => {
+ const source = readFileSync(join(here, "..", "docs", "snippets", "catalog-detail.jsx"), "utf-8");
+ const fn = source.slice(
+ source.indexOf("const hasMoreBelow"),
+ source.indexOf("// END hasMoreBelow"),
+ );
+ const hasMoreBelow = runInNewContext(`${fn} hasMoreBelow`, {}) as (
+ scrollHeight: number,
+ scrollTop: number,
+ clientHeight: number,
+ ) => boolean;
+
+ it("is true while content remains below the visible list, false once scrolled to the end", () => {
+ assert.equal(hasMoreBelow(800, 0, 300), true);
+ assert.equal(hasMoreBelow(800, 490, 300), true);
+ assert.equal(hasMoreBelow(800, 500, 300), false);
+ assert.equal(hasMoreBelow(300, 0, 300), false);
+ });
+
+ it("turns the pulse off under prefers-reduced-motion", () => {
+ assert.match(
+ source,
+ /@media \(prefers-reduced-motion: reduce\) \{\s*\n\s*\.hf-ve-tune-more \{ animation: none; \}/,
+ );
+ });
+
+ it("watches every row for height changes, not just the first", () => {
+ // A note that grows on a row other than the first (e.g. the file-drop control's
+ // status text) must still retrigger the cue -- watching one child would miss it.
+ assert.match(source, /for \(const child of el\.children\) observer\.observe\(child\);/);
+ assert.doesNotMatch(source, /el\.firstElementChild/);
+ });
+
+ it("keeps TuneList's identity fixed so typing doesn't remount it", () => {
+ // Nested inside CatalogDetail, TuneList would be a new function every render (every
+ // value/note edit), so React would remount it, losing focus/scroll each keystroke.
+ assert.match(source, /^export const TuneList = /m);
+ });
+});
+
describe("snippet scope", () => {
it("has no top-level helper outside its exports, because Mintlify only evaluates exports", () => {
const source = readFileSync(
@@ -349,6 +389,23 @@ describe("tile reveal", () => {
});
});
+describe("tile player concurrency cap", () => {
+ const source = readFileSync(join(here, "..", "docs", "snippets", "catalog-gallery.jsx"), "utf-8");
+ const css = readFileSync(join(here, "..", "docs", "catalog-gallery.css"), "utf-8");
+
+ it("caps dom-tier players wide enough to cover a full grid's worth of visible tiles", () => {
+ const columns = Number(css.match(/grid-template-columns:repeat\((\d+),/)?.[1]);
+ const cap = Number(source.match(/const MAX_DOM_PLAYERS = (\d+);/)?.[1]);
+ assert.ok(columns > 0 && cap > 0, "both constants must be found in their source files");
+ // Below 6 rows' worth, tiles at the bottom of a tall viewport stay on their dark
+ // fallback until hover steals another tile's slot -- the exact bug this guards.
+ assert.ok(
+ cap >= columns * 6,
+ `MAX_DOM_PLAYERS (${cap}) is too small for a ${columns}-column grid`,
+ );
+ });
+});
+
describe("tile poster priority", () => {
const source = readFileSync(join(here, "..", "docs", "snippets", "catalog-gallery.jsx"), "utf-8");
diff --git a/scripts/generate-catalog-pages.ts b/scripts/generate-catalog-pages.ts
index 17bf1691fd..aa19caa428 100644
--- a/scripts/generate-catalog-pages.ts
+++ b/scripts/generate-catalog-pages.ts
@@ -112,7 +112,7 @@ interface CatalogEntry {
// ── Discovery ──────────────────────────────────────────────────────────────
-function discoverItems(): { kind: ItemKind; manifest: RegistryItem }[] {
+export function discoverItems(): { kind: ItemKind; manifest: RegistryItem }[] {
const items: { kind: ItemKind; manifest: RegistryItem }[] = [];
const registryManifest = JSON.parse(
readFileSync(join(registryDir, "registry.json"), "utf-8"),
@@ -765,7 +765,7 @@ function generateVariables(manifest: RegistryItem): string[] {
* not installed yet. Collapsed because these run to several hundred lines and an
* expanded wall of markup would push everything else off the page.
*/
-function primarySource(
+export function primarySource(
kind: ItemKind,
manifest: RegistryItem,
): { path: string; source: string } | null {