mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-15 13:58:20 +00:00
* feat(catalog): browse the catalog as a live grid, not a static index
The Catalog overview page now mounts a browse grid (posters, tags,
per-category rows, expandable sections) generated over the existing
~380 registry items, instead of one static hero video plus two links.
docs/snippets/catalog-gallery.jsx and docs/catalog-gallery.css are
ported from a reference implementation of this same UX; the CSS keeps
its original comments as-is (NO_ESSAY_COMMENTS_OK: vendored file).
scripts/sync-docs-catalog.mjs and build-docs-gallery-nav.mjs are new,
generating the gallery's data and the Catalog tab's nav directly from
this repo's registry/*/registry-item.json and docs.json, so no sparse
clone or snapshot of another project is needed. scripts/check-docs-catalog.mjs
proves every catalog page from before this change still exists, on
disk and in the sidebar, with an identical page list.
No catalog page or snippet is removed; the previous overview content
(catalog-overview-player.jsx) is unused on this page but stays in the
repo.
* refactor(catalog): share the docs-catalog helpers across the sync scripts
Three independent adversarial reviews (reuse, quality, efficiency) all
converged on the same shape: sync-docs-catalog.mjs, build-docs-gallery-nav.mjs
and check-docs-catalog.mjs each hand-wrote the same docs-root resolution,
the same catalog-gallery-data.mdx parse, and the same Catalog-tab lookup.
Extracted into scripts/docs-catalog-shared.mjs.
sync-docs-catalog.mjs also ran build-docs-gallery-nav.mjs as a dynamic
import purely for its side effect, forcing a same-process re-read and
re-parse of the data file it had just written. build-docs-gallery-nav.mjs
now exports buildNav(docs), called directly with the path already
resolved; its CLI entry is guarded for standalone use.
slug() dropped the /^-+|-+$/ trim in favor of the character-scan trim
packages/core/src/tokenSlug.ts already uses for the same reason (CodeQL
flags that regex as polynomial ReDoS, js/polynomial-redos); not imported
directly since tokenSlug.ts is compiled TS with a different domain
(CSS variable naming) and no existing scripts/*.mjs imports packages/core
source at plain node runtime.
Fixed one bug found while re-verifying after the refactor: the recursive
nav walk wasn't idempotent against its own generated output — re-running
sync-docs-catalog.mjs a second time saw the "Catalog" wrapper buildNav()
adds and misattributed every flat, single-section group under it, 9
groups collapsing to 7. walk() now treats "Catalog" as transparent, the
same way it already treated "Overview". Verified: three consecutive runs
from a fresh origin/main checkout now produce identical 9 groups / 400
items each time, and check-docs-catalog.mjs still passes.
* chore(catalog): scrub an internal decision code from a code comment
* fix(catalog): stop losing a section's label when its nav is flattened
An independent adversarial review found real data loss: build-docs-gallery-nav.mjs
collapsed a group with exactly one section into a flat page list (a clean sidebar
for the common case), but sync-docs-catalog.mjs's walk() derives an item's section
from nav depth, so a second sync run over that flattened nav could no longer
recover the section name and silently fell back to the group's own label. Data &
charts / Data was the one real casualty in this catalog; Carousels and Blocks
happened to already have a section named the same as their group, so the loss
was invisible there.
Fix: only flatten when the section label equals the group label, so nothing
non-recoverable is thrown away. Verified with a deliberate mutation (reverted the
condition, reproduced the exact bug from a clean origin/main checkout, confirmed
the new check-docs-catalog.mjs assertion goes red, then restored the fix and
confirmed 3 consecutive runs produce identical, correct section labels).
check-docs-catalog.mjs now cross-checks every item's section against the nav
itself (not just its href), closing the gap the review found: the old check only
proved the page list matched, never that section identity survived a re-sync.
Also deletes docs/snippets/catalog-overview-player.jsx: unreferenced anywhere in
the repo now that catalog/index.mdx mounts CatalogGallery instead (confirmed with
a repo-wide grep before removing).
* chore(catalog): allowlist the deliberate catalog-overview-player.jsx deletion
* feat(catalog): live-hover preview, fuller sidebar, landing order
Three product asks against the open PR's Mintlify preview, plus one
review finding:
- Sidebar: every top-level Catalog group now defaults to expanded, so
the column shows real content instead of nine collapsed rows over
an empty column. Subsections stay collapsed one level down, the
same depth the reference site itself uses.
- Landing order: Carousels moves to first position (3D motion, not
yet in this repo's registry, will sort first once a separate
initiative lands it - the priority list already accounts for it).
Only the pinned group shows two rows instead of one, matching the
reference site's own convention rather than every group.
- Hover-to-play: items whose composition is live-capable (a paused,
seekable GSAP timeline, no sub-composition mount, no WebGPU) get a
real hover-preview wired to their own composition HTML, extracted
once as a static sidecar file from data this repo already serves
at each item's own doc page. Items with a recorded preview video
fall back to that; everything else shows its poster. Verified no
WebGL/three.js item is ever marked live (238 live, 30 video, 132
poster-only of 400).
- The vendored player CDN reference was already fixed to @latest in
the prior commit; noting it here since a reviewer flagged the same
line independently on the prior head.
docs/public/catalog/gallery-live/**/*.html (238 files, NO_ESSAY_COMMENTS_OK)
are generated sidecars: each one is a composition's existing HTML, already
tracked verbatim in docs/public/catalog/{blocks,components}/*.json, written
out as a plain file so the hover-preview snippet can fetch it as text. Not
hand-authored; their comments are the original composition authors', copied
unchanged. Regenerated by scripts/sync-docs-catalog.mjs on every run.
* refactor(catalog): merge the item and preview passes, one group-label source
itemFrom() and walk() computed the same group-label fallback expression
independently; itemFrom now returns it once alongside the item instead
of the caller re-deriving it. The item/preview construction and the
separate "for (const item of items)" preview pass are merged into one
walk() as items are built, dropping the mutate-then-delete item.live
scratch field. Byte-identical generated output, confirmed by diff.
* chore(catalog): scrub the private upstream repo name from code comments
An independent adversarial review found the same leak class as an
earlier fix (commit cfe14f813, an internal decision code) in four
provenance comments naming a private individual's private GitHub
repository and an exact commit sha, in a public repo. Replaced with
a functional description of the port; no identifying reference to
the source repo or its owner remains in tracked file content.
The PR description had the same reference (a live markdown link);
edited directly on the open PR, no code change needed for that half.
* chore(catalog): restore the adapted-from-elsewhere signal without the name
* fix(docs-catalog): fail hard when the page-list merge-base diff cannot be computed
* fix(catalog): mark canvas-draw-element items unsupported instead of dropping their payload
* fix(catalog): collapse every sidebar group, fill column with descriptions
Mintlify's group schema has no description field, so each group's blurb is
a CSS ::after keyed off the sidebar's own data-title/data-group-tag attributes instead.
NO_ESSAY_COMMENTS_OK: false positive.
The real /* */ block is 3 lines. The hook flags plain CSS declaration lines
(long selectors plus content:var() strings) as comments; they are code, not prose.
* feat(catalog): fold the 3D-motion group into the browse grid
Regenerated after merging feat/ad93-3d-motion-catalog: 407 items, 10
groups, 3D motion leading and pinned on the landing order.
* chore(catalog): regenerate hover sidecars for newly-live-eligible items
heygen-avatar-promo-card, hw-write-title, slack-notification-ad, orbit-card.
NO_ESSAY_COMMENTS_OK: generated composition HTML, not hand-authored.
* fix(docs-catalog): the page-list check should catch loss, not additions
It was written for a pure refactor (this PR's original scope: same pages,
different nav shape) so identical-set was the right proof. Stacking a
sibling feature branch on top legitimately adds pages; the real invariant
is nothing lost and no duplicates, so only that half stays an assertion.
* fix(catalog): mount the player from each item's own payload, no CDN needed
Root cause of the grey Carousels/3D-motion tiles: those registry items
never had a CDN poster/video, and the .html hover sidecar 404s on the
deployed docs host (Mintlify drops .html/.js/.css from docs/public with
no error). Cards now fetch the same repo-served .json payload the detail
page already uses, mount a player, seek to 3s and rest there, play on
hover -- viewport-gated, capped per tier (DOM vs WebGL) so a heavy row
can't blow the page's GPU budget. Also fixes a real regression: the
canvas-draw-element marker file was making detail pages embed a broken
player (srcdoc=undefined) instead of falling back correctly; it now
renders an honest tile naming the required flag. Deletes the now-dead
.html sidecar mechanism and its vendored gating helper.
* fix(catalog): move player-mount consts inside the component scope
Mintlify's snippet bundler drops top-level const/function declarations that sit
outside the exported binding, so every reference to MAX_DOM_PLAYERS and
MAX_WEBGL_PLAYERS threw ReferenceError on the deployed build, silently killing
the mount before any fetch ran.
Also stop suppressing the title placeholder for player-mode cards: it must
stay visible until a frame actually paints, not disappear into an empty box
the moment the observer decides to mount.
* fix(catalog): make mount failures loud and add a bundler-scope check
Widen mount's try/catch to cover the entire body, including the tier lookup
that previously threw outside it -- any failure now logs once and leaves the
titled placeholder in place instead of silently dropping into an empty box.
Add a check-docs-catalog.mjs assertion (via the TypeScript compiler API) that
fails the build if catalog-gallery.jsx gains a top-level const or function
outside its exported component, so the next contributor cannot reintroduce
the bundler-drop bug silently.
* fix(catalog): fall back to placeholder when a preview never reaches ready
A player that neither errors nor fires ready within a few seconds left a bare
mounted element with nothing painted. Also fixes unmount() keying its map
lookup by the host DOM node while mount() stores state under item.href,
which meant scrolled-away previews never freed their tier cap slot.
* docs(catalog): trim an essay-length comment in docs-catalog-shared.mjs
* fix(catalog): drop the WebGPU tile message, keep the headline mounted
A WebGPU-gapped item showed a dedicated "Needs WebGPU" card instead of
falling back to its poster/video like every other unrenderable-live
item already does; previewFor() now treats every preview gap the
same way, so the tile draws whatever media the item has instead of a
special-cased message. Also memoises MotionWord so the headline
animation keeps its component identity across hover/filter re-renders
instead of remounting and restarting.
* chore(catalog): regenerate catalog data and nav after retargeting to main
The seven 3D-motion items only exist on the 3D-motion-catalog branch;
regenerating against main drops their dead nav/gallery entries so the
grid ships over the existing 400 items with no broken link or empty
tile. They come back automatically once that branch lands and this
generator runs again.
* fix(docs): clear format, lint and complexity gates on the catalog PR
Format/Preflight/Windows-tests were failing on two unformatted files;
ran oxfmt to fix. Fallow's high-crap-score gate was newly flagging six
pre-existing functions that only became "new" findings because the
branch was retargeted directly onto main. Collapsed textureSampleWord's
23-branch if-chain into an equivalent lookup array (verified identical
output against 1547 real registry slugs plus adversarial ordering
cases), and exempted the remaining pre-existing, unauthored findings
using this repo's established fallow-ignore-next-line and
.fallowrc.jsonc health.ignore conventions.
* fix(catalog): drop the stale index prose, give the new item a CDN preview
The old static-index page's "Use an item" tutorial and group tour sat
unstyled below the new browse grid and named groups that no longer
exist; the grid already shows the current groups, so the block is
removed rather than rewritten. Kept the required Related topics
section.
heygen-avatar-promo-card now carries a poster/video CDN preview like
every other item, so its gallery tile has something to draw at rest.
* fix(catalog): correct an inaccurate fallow-exemption justification
A reviewer caught that the generate-catalog-payloads.ts exemption
claimed the file was unmodified by this PR when it is not; describe
the actual change instead.
* fix(catalog): style the Related topics section instead of leaving it bare
The page renders in Mintlify's frame mode, which drops normal prose
styling, so the plain markdown heading and links below the gallery
rendered as unstyled grey text with no spacing. Replaced with a small
styled section matching the gallery's own visual language (shared
--hf-* theme tokens, same page gutter), rather than dropping the links.
* refactor(catalog): use this repo's own CardGroup pattern for Related topics
An independent review caught that the hand-rolled CSS from the prior
commit duplicated a pattern this repo already has: docs/studio/index.mdx
styles its own Related topics section with Mintlify's built-in
CardGroup/Card components. Switched to that instead and dropped the
custom CSS. Also trimmed an over-long fallow-exemption comment another
reviewer flagged as narrating process rather than stating the invariant.
* refactor(catalog): drop unreachable player branch in gallery hover effect
* fix(catalog): keep nav landing page and recorded videos, free player slots, check in CI
The generator dropped catalog/index and replaced recorded videos with a flag notice.
A card scrolled away before ready released its player slot twice.
check-docs-catalog now runs in CI.
* fix(catalog): release player slots in one place, run catalog check on docs-only PRs
Unmount, error, timeout and catch now share one idempotent release.
The Lint job also runs when only docs or registry files change.
* fix(catalog): a superseded failed mount no longer marks the new card unavailable
* revert(catalog): move the CI wiring out of this PR
The CI change ships separately as chore(ci).
* fix(catalog): the generator prunes generated pages and keeps hand-written ones
Replaces the re-add guard: a group holding catalog/index survives with the index intact.
1422 lines
55 KiB
TypeScript
1422 lines
55 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, pathToFileURL } from "node:url";
|
||
// Import from source — bun workspace linking doesn't resolve for scripts outside packages/.
|
||
import {
|
||
type FileTarget,
|
||
type RegistryItem,
|
||
isBlockItem,
|
||
ITEM_TYPE_DIRS,
|
||
} from "../packages/core/src/registry/types.js";
|
||
import { withHostedDefaults } from "./registry-hosted-assets.ts";
|
||
|
||
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";
|
||
const payloadRoot = resolve(repoRoot, "docs/public/catalog");
|
||
|
||
/**
|
||
* The player is loaded from a CDN rather than bundled into the docs, on
|
||
* `latest` rather than a pinned line.
|
||
*
|
||
* This used to derive the minor line from the player's own package.json, which
|
||
* is correct only while every page is regenerated on the release that moves it.
|
||
* That is not what happened: the pages sat on the previous minor after a bump,
|
||
* so a shipped player fix reached npm and never reached the catalog, and
|
||
* nothing surfaced it, because a page on an old player still renders. A version
|
||
* that has to be carried in step across 175 generated pages and two hand
|
||
* written snippets is a version that will be stale, and staleness here is
|
||
* silent, which is the worst combination.
|
||
*
|
||
* `latest` costs the ability to hold the docs back from a bad player release.
|
||
* That is a real cost, paid deliberately: the previous arrangement did not buy
|
||
* that control either, it only delayed every good release too.
|
||
*/
|
||
const playerVersionRange = "latest";
|
||
|
||
/**
|
||
* Render a JSX/MDX attribute as a plain double-quoted string.
|
||
*
|
||
* The value must stay a string literal, never a `{...}` expression: the docs
|
||
* search indexer stringifies expression nodes, so an expression title surfaces
|
||
* as "[object Object]" in search results. MDX decodes HTML character
|
||
* references inside quoted values, so escaping goes through them — `&` first,
|
||
* so the references it introduces are not re-escaped. Only `&` and `"` can
|
||
* break the literal; `<`, `{` and `}` are escaped as well so regex-based
|
||
* consumers of the MDX never mistake the value for markup or an expression.
|
||
* JSON.stringify cannot stand in: backslashes are not escape characters in
|
||
* JSX strings.
|
||
*/
|
||
export function mdxStringAttribute(name: string, value: string): string {
|
||
const escaped = value
|
||
.replace(/&/g, "&")
|
||
.replace(/"/g, """)
|
||
.replace(/</g, "<")
|
||
.replace(/\{/g, "{")
|
||
.replace(/\}/g, "}");
|
||
return `${name}="${escaped}"`;
|
||
}
|
||
|
||
function payloadPath(kind: ItemKind, name: string): string {
|
||
return join(payloadRoot, typeDir(kind), `${name}.json`);
|
||
}
|
||
|
||
/** Has a real, playable preview payload been built for this item? */
|
||
function hasPayload(kind: ItemKind, name: string): boolean {
|
||
return existsSync(payloadPath(kind, name)) && unsupportedFlag(kind, name) === null;
|
||
}
|
||
|
||
/** The Chrome flag this item's payload needs to render live, if any. */
|
||
function unsupportedFlag(kind: ItemKind, name: string): string | null {
|
||
const path = payloadPath(kind, name);
|
||
if (!existsSync(path)) return null;
|
||
const payload = JSON.parse(readFileSync(path, "utf-8")) as { unsupported?: string };
|
||
return payload.unsupported ?? null;
|
||
}
|
||
|
||
/**
|
||
* A live preview: the real composition, running in the real player.
|
||
*
|
||
* The player is mounted inside the iframe rather than written into the page
|
||
* because the docs renderer strips unknown custom elements from MDX, so a
|
||
* `<hyperframes-player>` written here would never reach the DOM. Nothing
|
||
* rewrites the inside of a `srcDoc` document, so it survives there.
|
||
*
|
||
* The composition arrives as JSON because the docs host publishes only JSON and
|
||
* images out of `docs/public`; an `.html` payload 404s in production while its
|
||
* page still serves, which is exactly how a previous attempt at this broke
|
||
* every catalog preview at once.
|
||
*/
|
||
function playerEmbed(kind: ItemKind, name: string, posterUrl: string | null | undefined): string {
|
||
const payloadUrl = `/public/catalog/${typeDir(kind)}/${name}.json`;
|
||
const poster = posterUrl ? `p.setAttribute("poster","${posterUrl}");` : "";
|
||
const bootstrap = [
|
||
'<!doctype html><html><head><meta charset="utf-8">',
|
||
"<style>html,body{margin:0;height:100%;overflow:hidden;background:transparent}",
|
||
"hyperframes-player{display:block;width:100%;height:100%}</style>",
|
||
`<script src="https://cdn.jsdelivr.net/npm/@hyperframes/player@${playerVersionRange}/dist/hyperframes-player.global.js"><\\/script>`,
|
||
"</head><body><script>",
|
||
`fetch("${payloadUrl}").then(function(r){return r.json()}).then(function(d){`,
|
||
'var p=document.createElement("hyperframes-player");',
|
||
'p.setAttribute("srcdoc",d.html);p.setAttribute("controls","");',
|
||
'p.setAttribute("autoplay","");p.setAttribute("loop","");p.setAttribute("muted","");',
|
||
poster,
|
||
"document.body.appendChild(p)});",
|
||
"<\\/script></body></html>",
|
||
].join("");
|
||
|
||
return [
|
||
"<iframe",
|
||
' className="w-full aspect-video rounded-xl border-0 bg-zinc-100 dark:bg-zinc-800"',
|
||
` ${mdxStringAttribute("title", `${name} preview`)}`,
|
||
' loading="lazy"',
|
||
` srcDoc={${"`"}${bootstrap}${"`"}}`,
|
||
"/>",
|
||
].join("\n");
|
||
}
|
||
|
||
// ── Types ──────────────────────────────────────────────────────────────────
|
||
|
||
type ItemKind = "block" | "component";
|
||
|
||
interface SourceMetadata {
|
||
authorUrl?: string;
|
||
sourcePrompt?: string;
|
||
}
|
||
|
||
interface TextureGroup {
|
||
title: string;
|
||
items: string[];
|
||
}
|
||
|
||
/** Hand-written prose rescued from a previously generated page. */
|
||
interface CarriedContent {
|
||
/** Whole `## sections`, heading included, in their original order. */
|
||
sections: string[];
|
||
/** A human rewrote the usage prose — the generated version steps aside. */
|
||
hasCustomUsage: boolean;
|
||
}
|
||
|
||
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 ─────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Every `## Heading` this generator produces, now or in an earlier revision.
|
||
* Anything on a page outside this set was written by a human, and is carried
|
||
* across a regeneration rather than deleted. Lowercased for comparison.
|
||
*/
|
||
const GENERATED_HEADINGS = new Set([
|
||
// current template
|
||
"install",
|
||
"variables",
|
||
"source",
|
||
"add it to your video",
|
||
"paste it into your composition",
|
||
"change the colors",
|
||
"change how it looks",
|
||
"ask an agent for it",
|
||
"make the texture move",
|
||
"every texture",
|
||
// headings earlier revisions emitted — dropped on purpose, never carried.
|
||
// `usage` is deliberately NOT listed: the current template never emits it, and
|
||
// it is a heading a human might reasonably write, so ownership stays explicit
|
||
// (anything not in this set is hand-written) rather than sniffing the body.
|
||
"details",
|
||
"files",
|
||
"source prompt",
|
||
"agent usage",
|
||
"animated texture",
|
||
"texture examples",
|
||
// the required reader continuation the generator emits last (see RELATED_TOPICS)
|
||
"related topics",
|
||
]);
|
||
|
||
/**
|
||
* Marks the start of the generated provenance footer (tags, credit, prompt).
|
||
* That footer carries no heading of its own, so without this marker the section
|
||
* parser below would swallow it into the preceding hand-written section and
|
||
* re-emit it on every run.
|
||
*/
|
||
const FOOTER_MARKER = "{/* hf:generated-footer */}";
|
||
|
||
/**
|
||
* Every Catalog page ends with this section — required by `docs/AGENTS.md`
|
||
* ("Task, guide, Studio, and Catalog pages end with a `## Related topics`
|
||
* section"). Emitted last so the page literally ends with it; listed in
|
||
* GENERATED_HEADINGS so a regeneration never carries it forward as hand-written.
|
||
*/
|
||
const RELATED_TOPICS: readonly string[] = [
|
||
"## Related topics",
|
||
"",
|
||
"- [Browse the complete Catalog](/catalog)",
|
||
"- [Add assets and Catalog items in Studio](/studio/assets-and-blocks)",
|
||
"- [Build a richer composition](/go-further)",
|
||
"",
|
||
];
|
||
|
||
/**
|
||
* Pull the hand-written `## sections` out of an already-generated page.
|
||
* Returns the raw lines, heading included, in their original order.
|
||
*/
|
||
// Exported for the preservation fixture in
|
||
// packages/core/src/registry/catalogGeneratorInstructions.test.ts.
|
||
// fallow-ignore-next-line complexity
|
||
export function carriedSectionsFrom(pagePath: string): CarriedContent {
|
||
const empty: CarriedContent = { sections: [], hasCustomUsage: false };
|
||
if (!existsSync(pagePath)) return empty;
|
||
let text: string;
|
||
try {
|
||
text = readFileSync(pagePath, "utf-8");
|
||
} catch {
|
||
return empty;
|
||
}
|
||
|
||
const sections: string[] = [];
|
||
let hasCustomUsage = false;
|
||
let heading: string | null = null;
|
||
let buffer: string[] = [];
|
||
|
||
// fallow-ignore-next-line complexity
|
||
const flush = (): void => {
|
||
if (!heading) return;
|
||
while (buffer.length && buffer[0]!.trim() === "") buffer.shift();
|
||
while (buffer.length && buffer.at(-1)!.trim() === "") buffer.pop();
|
||
|
||
// Ownership is explicit: a section is generated iff its heading is one the
|
||
// template emits (GENERATED_HEADINGS). Everything else is hand-written and
|
||
// carried verbatim — no content heuristic that could misread custom prose as
|
||
// generated and silently delete it on the next regeneration.
|
||
const key = heading.toLowerCase();
|
||
if (!GENERATED_HEADINGS.has(key) && buffer.length) {
|
||
if (key === "usage") hasCustomUsage = true;
|
||
sections.push(`## ${heading}`, "", ...buffer, "");
|
||
}
|
||
heading = null;
|
||
buffer = [];
|
||
};
|
||
|
||
let inFence = false;
|
||
for (const line of text.split("\n")) {
|
||
// The footer marker begins the generated tail (provenance + Related topics).
|
||
// Don't stop here: close the current section and keep scanning, so a human
|
||
// `## section` appended *below* the generated tail is still carried forward
|
||
// rather than silently dropped. The generated headings themselves are named
|
||
// in GENERATED_HEADINGS, so the tail's own `## Related topics` is not carried.
|
||
if (line.trim() === FOOTER_MARKER) {
|
||
flush();
|
||
continue;
|
||
}
|
||
if (line.trimStart().startsWith("```")) inFence = !inFence;
|
||
const match = !inFence && /^## (.+)$/.exec(line);
|
||
if (match) {
|
||
flush();
|
||
heading = match[1]!.trim();
|
||
continue;
|
||
}
|
||
if (heading) buffer.push(line);
|
||
}
|
||
flush();
|
||
|
||
return { sections, hasCustomUsage };
|
||
}
|
||
|
||
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: unknown) => 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(" ");
|
||
}
|
||
|
||
// Ordered so a more specific needle (e.g. "wood-floor") is tried before the
|
||
// substring it contains ("wood"); first match wins, same as the if-chain this replaced.
|
||
const TEXTURE_SAMPLE_WORDS: [needle: string, word: string][] = [
|
||
["brick", "BRICK"],
|
||
["concrete", "CONCRETE"],
|
||
["plaster", "PLASTER"],
|
||
["rock", "ROCK"],
|
||
["onyx", "ONYX"],
|
||
["marble", "MARBLE"],
|
||
["travertine", "STONE"],
|
||
["paving", "STONE"],
|
||
["tiles", "TILE"],
|
||
["ground", "GROUND"],
|
||
["road", "ROAD"],
|
||
["asphalt", "ASPHALT"],
|
||
["wood-floor", "FLOOR"],
|
||
["wood", "WOOD"],
|
||
["bark", "BARK"],
|
||
["diamond", "PLATE"],
|
||
["metal", "METAL"],
|
||
["lava", "LAVA"],
|
||
["grass", "GRASS"],
|
||
["carpet", "WOVEN"],
|
||
["fabric", "FABRIC"],
|
||
["snow", "SNOW"],
|
||
["leather", "LEATHER"],
|
||
];
|
||
|
||
function textureSampleWord(slug: string): string {
|
||
return TEXTURE_SAMPLE_WORDS.find(([needle]) => slug.includes(needle))?.[1] ?? 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[] = ["## Every texture", "", '<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 [
|
||
"## Ask an agent for it",
|
||
"",
|
||
"Paste this to your coding agent:",
|
||
"",
|
||
"```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>",
|
||
"```",
|
||
"",
|
||
`Swap \`${firstClass}\` for the class on any texture card below. Every texture also needs the base class \`hf-texture-text\`.`,
|
||
"",
|
||
];
|
||
}
|
||
|
||
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 [
|
||
"## Make the texture move",
|
||
"",
|
||
"Move the mask position on the text element. Keep the drop shadow on a wrapper so it 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((item): item is string => Boolean(item))
|
||
.slice(0, 6);
|
||
const lines: string[] = ['<div className="hf-texture-preview-panel">'];
|
||
|
||
for (const item of sampleItems) {
|
||
if (!item) continue;
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Puts the values from `?hfv=<json>` where a composition looks for them, and
|
||
* nowhere else.
|
||
*
|
||
* Two readers, because there are two ways a preview consumes variables:
|
||
* - `window.__hfVariables` — what `getVariables()` merges over the declared
|
||
* defaults in a composition that reads them itself.
|
||
* - `data-variable-values` on the host element — what the runtime loader
|
||
* layers over a mounted sub-composition's defaults.
|
||
*
|
||
* `hfv` is written with `encodeURIComponent` and read back with
|
||
* `URLSearchParams.get`, which is its inverse: `encodeURIComponent` escapes
|
||
* both space (`%20`) and plus (`%2B`), the only two characters the two codecs
|
||
* disagree about, so form-decoding its output is lossless. Reading with
|
||
* `decodeURIComponent` instead is not safe here, because anything on the path
|
||
* that re-serializes the query as form data — the player used to — writes
|
||
* spaces as `+`, and percent-decoding leaves those `+` in the value.
|
||
*
|
||
* This runs from the end of `<body>`: late enough that every host element is
|
||
* parsed, and early enough that the runtime — which the player injects on a
|
||
* 200ms poll after load — has not started resolving them.
|
||
*
|
||
* Only hosts pointing at `ownFile` are stamped. A demo is free to mount other
|
||
* scenes, and their variables are not the ones this page documents.
|
||
*/
|
||
export function variableBootstrap(ownFile: string): string {
|
||
return [
|
||
"<script>",
|
||
" (function () {",
|
||
" var raw = new URLSearchParams(location.search).get('hfv');",
|
||
" if (raw === null) return;",
|
||
" var v;",
|
||
" try { v = JSON.parse(raw); } catch (e) { return; }",
|
||
" if (!v || typeof v !== 'object') return;",
|
||
" window.__hfVariables = v;",
|
||
" var hosts = document.querySelectorAll('[data-composition-src]');",
|
||
" for (var i = 0; i < hosts.length; i++) {",
|
||
" var src = (hosts[i].getAttribute('data-composition-src') || '').split('?')[0];",
|
||
` if (src.slice(src.lastIndexOf('/') + 1) !== ${JSON.stringify(ownFile)}) continue;`,
|
||
" hosts[i].setAttribute('data-variable-values', JSON.stringify(v));",
|
||
" }",
|
||
" })();",
|
||
"</script>",
|
||
].join("\n");
|
||
}
|
||
|
||
/**
|
||
* The wrapper for an item the explorer drives.
|
||
*
|
||
* A composition reads its variables once, at init, so a new value can only
|
||
* arrive by loading the composition again. That reload is deliberately kept one
|
||
* frame deep: the explorer talks to this wrapper, and only the player's own
|
||
* iframe reloads. The page, the wrapper and the panel never blink, and the
|
||
* playhead is carried across so a change mid-shot does not throw the reader
|
||
* back to frame zero.
|
||
*
|
||
* Values travel in the query string rather than a message into the composition
|
||
* because they have to be readable before its first script runs. The path is
|
||
* untouched, so `data-composition-src="./sibling.html"` still resolves.
|
||
*
|
||
* Every hop re-encodes with `encodeURIComponent` and reads with
|
||
* `URLSearchParams.get`, so a value is percent-encoded on the wire no matter
|
||
* how it arrived — including from a player build that form-encoded it.
|
||
*/
|
||
export function variablePreviewWrapper(src: string): string[] {
|
||
return [
|
||
'<hyperframes-player id="p" controls muted></hyperframes-player>',
|
||
"<script>",
|
||
" const player = document.getElementById('p');",
|
||
` const BASE = ${JSON.stringify(src)};`,
|
||
" const load = (values) => player.setAttribute('src', values ? BASE + '?hfv=' + values : BASE);",
|
||
"",
|
||
" // Retry until the clock actually moves. `ready` can flip before the runtime",
|
||
" // the player injects for a mounted sub-composition has finished wiring up, and",
|
||
" // a play() that lands in that window silently does nothing. It gives up rather",
|
||
" // than spinning forever: in a hidden tab the rAF clock never advances at all.",
|
||
" let poll = null;",
|
||
" const arm = (resumeAt) => {",
|
||
" clearInterval(poll);",
|
||
" let last = -1;",
|
||
" let tries = 0;",
|
||
" let seeked = false;",
|
||
" poll = setInterval(() => {",
|
||
" if (player.ready) {",
|
||
" if (!seeked) {",
|
||
" seeked = true;",
|
||
" if (resumeAt > 0) player.seek(resumeAt);",
|
||
" }",
|
||
" player.play();",
|
||
" }",
|
||
" if (seeked && player.currentTime > 0 && player.currentTime !== last) {",
|
||
" clearInterval(poll);",
|
||
" return;",
|
||
" }",
|
||
" last = player.currentTime;",
|
||
" if (++tries > 150) clearInterval(poll);",
|
||
" }, 100);",
|
||
" };",
|
||
"",
|
||
" const initial = new URLSearchParams(location.search).get('hfv');",
|
||
" load(initial && encodeURIComponent(initial));",
|
||
" arm(0);",
|
||
"",
|
||
" addEventListener('message', (event) => {",
|
||
" if (event.origin !== location.origin) return;",
|
||
" const values = event.data && event.data.hfVariables;",
|
||
" if (!values) return;",
|
||
" const resumeAt = player.currentTime || 0;",
|
||
" load(encodeURIComponent(JSON.stringify(values)));",
|
||
" arm(resumeAt);",
|
||
" });",
|
||
" player.addEventListener('ended', () => { player.seek(0); player.play(); });",
|
||
"</script>",
|
||
];
|
||
}
|
||
|
||
function catalogPreviewFor(kind: ItemKind, manifest: RegistryItem): string | undefined {
|
||
// The manifest is the source of truth. Thirteen items declare a preview with
|
||
// a video and no poster, and that omission is deliberate — no .png was ever
|
||
// produced for them.
|
||
if (manifest.preview) return manifest.preview.poster;
|
||
const dir = typeDir(kind);
|
||
return `${catalogImageBase}/${dir}/${manifest.name}.png`;
|
||
}
|
||
|
||
function yamlString(value: string): string {
|
||
return JSON.stringify(value);
|
||
}
|
||
|
||
/** "a", "a and b", "a, b, and c" */
|
||
function sentenceList(parts: string[]): string {
|
||
if (parts.length <= 1) return parts[0] ?? "";
|
||
if (parts.length === 2) return `${parts[0]} and ${parts[1]}`;
|
||
return `${parts.slice(0, -1).join(", ")}, and ${parts.at(-1)}`;
|
||
}
|
||
|
||
/** The file a reader actually opens — by type, not array position. */
|
||
function primaryFileFor(manifest: RegistryItem): FileTarget | undefined {
|
||
return (
|
||
manifest.files.find((f) => f.type === "hyperframes:composition") ??
|
||
manifest.files.find((f) => f.type === "hyperframes:snippet") ??
|
||
manifest.files[0]
|
||
);
|
||
}
|
||
|
||
/**
|
||
* One sentence naming what lands in the project — replaces the old three-column
|
||
* File/Target/Type table. The `type` column was registry-internal jargon and the
|
||
* `path` column was the source path inside this repo, which a reader never sees.
|
||
*/
|
||
function installOutcome(manifest: RegistryItem, primaryTarget: string): string {
|
||
const primary = primaryFileFor(manifest);
|
||
const others = manifest.files.filter((f) => f !== primary);
|
||
if (others.length === 0) return `That writes one file: \`${primaryTarget}\`.`;
|
||
|
||
const dirs = [...new Set(others.map((f) => f.target.split("/").slice(0, -1).join("/")))]
|
||
.filter(Boolean)
|
||
.map((d) => `\`${d}/\``);
|
||
const noun = others.length === 1 ? "supporting file" : "supporting files";
|
||
if (dirs.length === 0) return `That writes \`${primaryTarget}\` plus ${others.length} ${noun}.`;
|
||
return `That writes \`${primaryTarget}\`, plus ${others.length} ${noun} under ${sentenceList(dirs)}.`;
|
||
}
|
||
|
||
/**
|
||
* True when the installed file opens with an HTML comment. Only 13 of 36
|
||
* component snippets do, so the old blanket "see the comment header in the
|
||
* file" line was simply false on the rest.
|
||
*/
|
||
function hasCommentHeader(kind: ItemKind, manifest: RegistryItem): boolean {
|
||
const primary = primaryFileFor(manifest);
|
||
if (!primary) return false;
|
||
const sourcePath = join(registryDir, typeDir(kind), manifest.name, primary.path);
|
||
if (!existsSync(sourcePath)) return false;
|
||
try {
|
||
return readFileSync(sourcePath, "utf-8").trimStart().startsWith("<!--");
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// fallow-ignore-next-line complexity
|
||
function generateParams(manifest: RegistryItem): string[] {
|
||
if (!("params" in manifest) || !Array.isArray(manifest.params) || !manifest.params.length) {
|
||
return [];
|
||
}
|
||
const params = manifest.params;
|
||
const allColors = params.every((p) => p.type === "color");
|
||
const lines: string[] = [
|
||
allColors ? "## Change the colors" : "## Change how it looks",
|
||
"",
|
||
"Set these CSS variables on the block:",
|
||
"",
|
||
];
|
||
for (const p of params) {
|
||
const opts = p.options?.length
|
||
? ` Options: ${p.options.map((o) => `\`${o.value}\``).join(", ")}.`
|
||
: "";
|
||
lines.push(`- \`${p.key}\` — ${p.label}. Defaults to \`${p.default}\`.${opts}`);
|
||
}
|
||
lines.push("");
|
||
return lines;
|
||
}
|
||
|
||
interface ItemVariable {
|
||
id: string;
|
||
type: string;
|
||
role?: string;
|
||
label?: string;
|
||
description?: string;
|
||
default?: string | number | boolean;
|
||
options?: { value: string; label?: string }[];
|
||
min?: number;
|
||
max?: number;
|
||
step?: number;
|
||
unit?: string;
|
||
}
|
||
|
||
/** The allowed values for one variable, written the way a reader has to type them. */
|
||
function variableRange(v: ItemVariable): string {
|
||
if (v.options?.length) return v.options.map((o) => `\`${o.value}\``).join(", ");
|
||
if (typeof v.min === "number" && typeof v.max === "number") {
|
||
const unit = v.unit ? `${v.unit}` : "";
|
||
const step = typeof v.step === "number" ? `, step ${v.step}${unit}` : "";
|
||
return `${v.min}${unit} to ${v.max}${unit}${step}`;
|
||
}
|
||
return v.type;
|
||
}
|
||
|
||
/**
|
||
* A mount element carrying this item's variables, with its own defaults filled in.
|
||
*
|
||
* The table above lists what can be set; without this the reader is told to "set
|
||
* them on the element" by a page that never shows the element. Defaults are used
|
||
* as the values so the snippet is copy-and-run correct before it is edited.
|
||
*/
|
||
function generateVariableUsage(manifest: RegistryItem, target: string): string[] {
|
||
const raw = (manifest as RegistryItem & { variables?: ItemVariable[] }).variables;
|
||
if (!Array.isArray(raw) || raw.length === 0) return [];
|
||
|
||
const withDefaults = raw.filter((v) => v.default !== undefined);
|
||
if (withDefaults.length === 0) return [];
|
||
|
||
const values = JSON.stringify(Object.fromEntries(withDefaults.map((v) => [v.id, v.default])));
|
||
return [
|
||
"Set them with `data-variable-values` on the element that mounts it. These are the",
|
||
"defaults, so this behaves exactly like the preview above until you change one:",
|
||
"",
|
||
"```html wrap",
|
||
"<div",
|
||
` data-composition-id="${manifest.name}"`,
|
||
` data-composition-src="${target}"`,
|
||
` data-variable-values='${values}'`,
|
||
"></div>",
|
||
"```",
|
||
"",
|
||
];
|
||
}
|
||
|
||
function generateVariables(manifest: RegistryItem): string[] {
|
||
const raw = (manifest as RegistryItem & { variables?: ItemVariable[] }).variables;
|
||
if (!Array.isArray(raw) || raw.length === 0) return [];
|
||
|
||
const lines: string[] = [
|
||
"## Variables",
|
||
"",
|
||
"Every one of these has a default, so the piece works untouched. Set the ones you",
|
||
"want to change on the element:",
|
||
"",
|
||
"| Variable | Default | Accepts | What it does |",
|
||
"| --- | --- | --- | --- |",
|
||
];
|
||
for (const v of raw) {
|
||
// A missing description is left blank rather than filled with the label
|
||
// again — a column that repeats its neighbour teaches the reader to skip it.
|
||
const what = v.description ?? "";
|
||
const def = v.default === undefined ? "" : `\`${v.default}\``;
|
||
lines.push(`| \`${v.id}\` | ${def} | ${variableRange(v)} | ${what} |`);
|
||
}
|
||
lines.push("");
|
||
return lines;
|
||
}
|
||
|
||
// fallow-ignore-next-line complexity
|
||
/**
|
||
* The item's own source, collapsed.
|
||
*
|
||
* Without this the page can only tell the reader to go and open a file they have
|
||
* 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(
|
||
kind: ItemKind,
|
||
manifest: RegistryItem,
|
||
): { path: string; source: string } | null {
|
||
const file = primaryFileFor(manifest);
|
||
if (!file) return null;
|
||
const path = join(registryDir, typeDir(kind), manifest.name, file.path);
|
||
if (!existsSync(path)) return null;
|
||
|
||
const source = readFileSync(path, "utf-8").trimEnd();
|
||
// A fence inside the source would close the one wrapping it.
|
||
if (source.includes("```")) return null;
|
||
|
||
return { path: file.path, source };
|
||
}
|
||
|
||
function generateSource(kind: ItemKind, manifest: RegistryItem): string[] {
|
||
const file = primarySource(kind, manifest);
|
||
if (!file) return [];
|
||
|
||
return [
|
||
"## Source",
|
||
"",
|
||
`<Accordion ${mdxStringAttribute("title", file.path)}>`,
|
||
"",
|
||
"```html",
|
||
file.source,
|
||
"```",
|
||
"",
|
||
"</Accordion>",
|
||
"",
|
||
];
|
||
}
|
||
|
||
function itemVariables(manifest: RegistryItem): ItemVariable[] {
|
||
const raw = (manifest as RegistryItem & { variables?: ItemVariable[] }).variables;
|
||
return Array.isArray(raw) ? raw : [];
|
||
}
|
||
|
||
/**
|
||
* Preview, paste-ready snippet and every control over both, as one component.
|
||
*
|
||
* This is what the static `## Variables` table and the `data-variable-values`
|
||
* block below it used to be. The table could say `glow` accepts `none |
|
||
* standard | strong` and could not show what any of them looked like; a reader
|
||
* had to install the item to find out. Descriptions survive the move — they
|
||
* sit under their own control instead of in a column.
|
||
*/
|
||
function generateVariablesExplorer(
|
||
kind: ItemKind,
|
||
manifest: RegistryItem,
|
||
variables: ItemVariable[],
|
||
target: string,
|
||
): string[] {
|
||
const open = [
|
||
"<VariablesExplorer",
|
||
` previewSrc="/public/catalog/${typeDir(kind)}/${manifest.name}.json"`,
|
||
` compositionId="${manifest.name}"`,
|
||
` compositionSrc="${target}"`,
|
||
` variables={${JSON.stringify(variables)}}`,
|
||
];
|
||
|
||
// The source, as a real fence inside the component. It is static — a reader
|
||
// dragging a knob changes the mount snippet, never this — so it can be
|
||
// highlighted at build time by the same shiki pass that colours every other
|
||
// fence on the site, instead of being coloured by hand in the browser. MDX
|
||
// parses a fenced block in JSX children as markdown, provided it is set off
|
||
// by blank lines, and hands the compiled block down as `children`.
|
||
const file = primarySource(kind, manifest);
|
||
if (!file) return [...open, "/>", ""];
|
||
|
||
return [
|
||
...open,
|
||
">",
|
||
"",
|
||
"```html " + file.path,
|
||
file.source,
|
||
"```",
|
||
"",
|
||
"</VariablesExplorer>",
|
||
"",
|
||
];
|
||
}
|
||
|
||
/** The one thing above the fold: a live player, a texture sheet or a recorded video. */
|
||
function previewSection(
|
||
kind: ItemKind,
|
||
manifest: RegistryItem,
|
||
textureGroups: ReturnType<typeof textureGroupsFor>,
|
||
): string[] {
|
||
if (textureGroups.length > 0) return generateTexturePreview(manifest, textureGroups);
|
||
|
||
const flag = unsupportedFlag(kind, manifest.name);
|
||
// A recorded video still plays without the flag, so it wins over the notice.
|
||
if (flag && !manifest.preview?.video) {
|
||
return [
|
||
`<div className="w-full aspect-video rounded-xl border border-dashed flex items-center justify-center text-sm text-zinc-500">`,
|
||
` Needs <code>chrome://flags/#${flag}</code> to render live`,
|
||
`</div>`,
|
||
"",
|
||
];
|
||
}
|
||
|
||
// A built payload plays the real composition, and takes precedence over both
|
||
// iframe paths below. The variables explorer is parked rather than wired up:
|
||
// its preview document is an `.html` file the docs host does not publish, so
|
||
// it would show an empty frame in production. Reconnecting it to payloads is
|
||
// a follow-up. The machinery that fed it is gone rather than left uncalled:
|
||
// its whole job was writing preview documents the docs host discards, and it
|
||
// is recoverable from 3b53bfd2f when the explorer is rebuilt on payloads.
|
||
if (hasPayload(kind, manifest.name)) {
|
||
// An item that declares variables gets the panel, which mounts the same
|
||
// payload and re-mounts it as values change. Everything else gets the
|
||
// plain player.
|
||
// CDN defaults, not the local paths the manifest declares. The explorer
|
||
// posts every value to the preview frame on mount, including the untouched
|
||
// ones, so a local path here would override the payload's own default and
|
||
// ask the frame for a file that was deliberately never published.
|
||
const variables = withHostedDefaults(itemVariables(manifest), manifest);
|
||
if (variables.length > 0) {
|
||
const primaryTarget =
|
||
primaryFileFor(manifest)?.target ?? `compositions/${manifest.name}.html`;
|
||
return generateVariablesExplorer(kind, manifest, variables, primaryTarget);
|
||
}
|
||
return [playerEmbed(kind, manifest.name, catalogPreviewFor(kind, manifest)), ""];
|
||
}
|
||
// No demo.html to play, so fall back to the recorded video. Blocks are the
|
||
// population that lands here: 125 of 132 ship no demo.
|
||
const previewPath = `${catalogImageBase}/${typeDir(kind)}/${manifest.name}`;
|
||
// Same source of truth as the index: a manifest that declares a preview
|
||
// without a poster has no .png, and asking for one is a 403 the browser
|
||
// fetches before the video.
|
||
const posterUrl = catalogPreviewFor(kind, manifest);
|
||
const poster = posterUrl ? ` poster="${posterUrl}"` : "";
|
||
return [
|
||
`<video className="w-full aspect-video rounded-xl object-cover bg-zinc-100 dark:bg-zinc-800" src="${previewPath}.mp4"${poster} autoPlay muted loop playsInline />`,
|
||
"",
|
||
];
|
||
}
|
||
|
||
/** How to use it. Empty when a human already wrote that section by hand. */
|
||
function usageSection(
|
||
kind: ItemKind,
|
||
manifest: RegistryItem,
|
||
primaryTarget: string,
|
||
carried: CarriedContent,
|
||
textureGroups: ReturnType<typeof textureGroupsFor>,
|
||
): string[] {
|
||
const lines: string[] = [];
|
||
if (carried.hasCustomUsage) {
|
||
// nothing: the carried "## Usage" section covers it
|
||
} else if (kind === "block" && isBlockItem(manifest)) {
|
||
const w = manifest.dimensions.width;
|
||
const h = manifest.dimensions.height;
|
||
lines.push(
|
||
"## Add it to your video",
|
||
"",
|
||
`It runs for ${manifest.duration} seconds at ${w}×${h}. Paste this into your composition:`,
|
||
"",
|
||
"```html index.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>",
|
||
"```",
|
||
"",
|
||
"Move it in time with `data-start`. Put it on a different timeline row with",
|
||
"`data-track-index`. See [data attributes](/concepts/data-attributes) for the rest.",
|
||
"",
|
||
);
|
||
} else if (textureGroups.length > 0) {
|
||
lines.push(
|
||
"## Paste it into your composition",
|
||
"",
|
||
`Open \`${primaryTarget}\`. Paste the real \`<style>\` element near the bottom into`,
|
||
"your composition once. It defines `hf-texture-text` and every `hf-texture-*` class.",
|
||
"",
|
||
`Leave the texture PNGs in \`assets/${manifest.name}/masks/\`. The CSS looks for them there.`,
|
||
"",
|
||
);
|
||
} else {
|
||
lines.push(
|
||
"## Paste it into your composition",
|
||
"",
|
||
`Open \`${primaryTarget}\` and copy what is inside into your own composition.`,
|
||
);
|
||
if (hasCommentHeader(kind, manifest)) {
|
||
lines.push("The file opens with a comment header that walks you through it.");
|
||
}
|
||
lines.push(
|
||
"",
|
||
"A component has no size or duration of its own. It takes both from the composition",
|
||
"you paste it into.",
|
||
"",
|
||
);
|
||
}
|
||
return lines;
|
||
}
|
||
|
||
/** Tags, author and the source prompt, in the order a reader wants them least. */
|
||
function footerSection(
|
||
manifest: RegistryItem,
|
||
tags: readonly string[],
|
||
source: RegistryItem & SourceMetadata,
|
||
): string[] {
|
||
const footer: string[] = [];
|
||
|
||
if (tags.length > 0) {
|
||
footer.push(`Tagged ${tags.map((t) => `\`${t}\``).join(" ")}.`, "");
|
||
}
|
||
|
||
if (manifest.author) {
|
||
const author = source.authorUrl ? `[${manifest.author}](${source.authorUrl})` : manifest.author;
|
||
footer.push(`Created by ${author}.`, "");
|
||
}
|
||
|
||
if (source.sourcePrompt) {
|
||
footer.push(
|
||
'<Accordion title="The prompt this was built from">',
|
||
"",
|
||
"```text",
|
||
source.sourcePrompt,
|
||
"```",
|
||
"",
|
||
"</Accordion>",
|
||
"",
|
||
);
|
||
}
|
||
return footer;
|
||
}
|
||
|
||
// fallow-ignore-next-line complexity
|
||
function generateItemMdx(
|
||
kind: ItemKind,
|
||
manifest: RegistryItem,
|
||
carried: CarriedContent = { sections: [], hasCustomUsage: false },
|
||
): string {
|
||
const tags = manifest.tags ?? [];
|
||
const installCmd = `npx hyperframes add ${manifest.name}`;
|
||
const source = manifest as RegistryItem & SourceMetadata;
|
||
const textureGroups = textureGroupsFor(manifest);
|
||
const primaryTarget = primaryFileFor(manifest)?.target ?? `compositions/${manifest.name}.html`;
|
||
|
||
// Frontmatter only. Mintlify renders `title` as the H1 and `description` as
|
||
// the standfirst, so repeating both in the body (as this generator used to)
|
||
// printed each one twice on every page.
|
||
const lines: string[] = [
|
||
"---",
|
||
`title: ${yamlString(manifest.title)}`,
|
||
`description: ${yamlString(manifest.description)}`,
|
||
"---",
|
||
"",
|
||
'import { InstallCommand } from "/snippets/install-command.jsx";',
|
||
...(itemVariables(manifest).length > 0 && hasPayload(kind, manifest.name)
|
||
? ['import { VariablesExplorer } from "/snippets/variables-explorer.jsx";']
|
||
: []),
|
||
"",
|
||
];
|
||
|
||
// 1. How to get it, before anything else. It is the one line a reader is here
|
||
// to copy, and below the preview it landed under the explorer's Customize
|
||
// panel, a screen or more down. A CodeGroup around a single block just drew
|
||
// an empty tab bar.
|
||
lines.push(
|
||
"## Install",
|
||
"",
|
||
`<InstallCommand command="${installCmd}" item="${manifest.name}" />`,
|
||
"",
|
||
installOutcome(manifest, primaryTarget),
|
||
"",
|
||
);
|
||
|
||
// 2. What it looks like. Credits, tags and the source prompt used to sit above
|
||
// this and pushed the preview below the fold; they stay in the footer.
|
||
lines.push(...previewSection(kind, manifest, textureGroups));
|
||
|
||
// Prerequisite where it bites: you need the flag to preview what you just installed.
|
||
if (tags.includes("html-in-canvas")) {
|
||
lines.push(
|
||
// Danger, not Warning: without the flag the preview on this page is a
|
||
// black rectangle, so this is a prerequisite for seeing anything rather
|
||
// than a caveat about the result.
|
||
"<Danger>",
|
||
" Live preview needs the `chrome://flags/#canvas-draw-element` flag switched on.",
|
||
" Without it this item's screen renders black. Rendering from the CLI switches",
|
||
" it on for you. [How it works](/guides/html-in-canvas)",
|
||
"</Danger>",
|
||
"",
|
||
);
|
||
}
|
||
|
||
// 3. How to use it — unless a human already wrote that section, in which case
|
||
// their version is carried through below instead of being overwritten.
|
||
lines.push(...usageSection(kind, manifest, primaryTarget, carried, textureGroups));
|
||
|
||
lines.push(...generateParams(manifest));
|
||
lines.push(...generateVariables(manifest));
|
||
lines.push(...generateVariableUsage(manifest, primaryTarget));
|
||
|
||
lines.push(...generateSource(kind, manifest));
|
||
|
||
if (textureGroups.length > 0) {
|
||
lines.push(...generateTextureAgentUsage(manifest, textureGroups));
|
||
lines.push(...generateTextureAnimationExample(manifest, textureGroups));
|
||
lines.push(...generateTextureExamples(manifest, textureGroups));
|
||
}
|
||
|
||
// 4. Sections a human added to the previously generated page. Carried through
|
||
// verbatim so regenerating never silently deletes hand-written docs.
|
||
if (carried.sections.length > 0) {
|
||
lines.push(...carried.sections);
|
||
}
|
||
|
||
if (manifest.relatedSkill) {
|
||
lines.push(`<Tip>Related skill: \`/${manifest.relatedSkill}\`</Tip>`, "");
|
||
}
|
||
|
||
// 5. Generated tail: provenance (the least of what a reader came for) and
|
||
// then the required `## Related topics` continuation, so the page ends with
|
||
// it per docs/AGENTS.md. The marker delimits everything generated below it.
|
||
const footer = footerSection(manifest, tags, source);
|
||
|
||
lines.push(FOOTER_MARKER, "", ...footer, ...RELATED_TOPICS);
|
||
|
||
return lines.join("\n");
|
||
}
|
||
|
||
// ── Main ───────────────────────────────────────────────────────────────────
|
||
|
||
// fallow-ignore-next-line complexity
|
||
function main(): void {
|
||
const items = discoverItems();
|
||
const catalogIndex: CatalogEntry[] = [];
|
||
|
||
// Read hand-written sections off the existing pages BEFORE deleting them, so
|
||
// a regeneration adds template improvements without destroying prose someone
|
||
// wrote by hand (e.g. the "Features" lists on the code-snippet pages).
|
||
const carried = new Map<string, CarriedContent>();
|
||
for (const { kind, manifest } of items) {
|
||
const content = carriedSectionsFrom(
|
||
join(docsDir, "catalog", typeDir(kind), `${manifest.name}.mdx`),
|
||
);
|
||
if (content.sections.length > 0) carried.set(manifest.name, content);
|
||
}
|
||
|
||
// 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, carried.get(manifest.name));
|
||
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 });
|
||
if (carried.size > 0) {
|
||
console.log(`\n ↻ carried hand-written sections through on ${carried.size} page(s)`);
|
||
}
|
||
|
||
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,
|
||
Carousels: 7.2,
|
||
"Code Snippets": 7.5,
|
||
Data: 8,
|
||
"Motion Primitives": 9,
|
||
"Motion Scenes": 11,
|
||
"Typography & Text": 10,
|
||
"Camera & 3D": 12,
|
||
"Product Demo": 13,
|
||
Texture: 14,
|
||
Effects: 15,
|
||
Blocks: 16,
|
||
};
|
||
|
||
// fallow-ignore-next-line complexity
|
||
function groupForItem(entry: CatalogEntry): string {
|
||
const tags = entry.tags;
|
||
// Declared membership beats every inferred rule below: `video-primitive` is
|
||
// the tag a human puts on an item to put it on the primitives shelf, and it
|
||
// must not be overridden by whatever else the item happens to be tagged.
|
||
if (tags.includes("video-primitive")) return "Motion Primitives";
|
||
// 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";
|
||
// The editor and terminal themes are 24 near-identical pages. Left in
|
||
// Showcases they were two thirds of it, and the handful of actual showcase
|
||
// scenes were unfindable underneath them.
|
||
if (entry.name.startsWith("code-snippet-")) return "Code Snippets";
|
||
// Same story, same fix: 25 image carousels are half of Showcases, and the
|
||
// scenes that shelf is for disappear underneath them. Keyed on the FIRST
|
||
// tag, which is this file's stated grouping rule, so an item that merely
|
||
// uses a carousel — `screen-flow-carousel` leads with `product-demo` —
|
||
// stays on the shelf that describes what it is for.
|
||
if (tags[0] === "carousel") return "Carousels";
|
||
if (tags.includes("showcase") || tags.includes("3d")) return "Showcases";
|
||
if (tags.includes("data") || tags.includes("chart") || tags.includes("ascii")) return "Data";
|
||
// Split what used to be one 267-item "Effects" list. Ordered most specific
|
||
// first: an item tagged both `camera` and `motion-primitive` is a camera
|
||
// move, which is the narrower and more useful shelf to find it on.
|
||
if (tags.includes("texture")) return "Texture";
|
||
if (tags.includes("camera") || tags.includes("3d")) return "Camera & 3D";
|
||
if (
|
||
tags.includes("product-demo") ||
|
||
tags.includes("demonstrate") ||
|
||
tags.includes("pointers")
|
||
) {
|
||
return "Product Demo";
|
||
}
|
||
if (
|
||
tags.includes("typography") ||
|
||
tags.includes("text-effects") ||
|
||
tags.includes("text") ||
|
||
tags.includes("caption-style")
|
||
) {
|
||
return "Typography & Text";
|
||
}
|
||
if (tags.includes("motion-primitive")) return "Motion Scenes";
|
||
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 flatGroups = [...groupMap.entries()]
|
||
.sort(([a], [b]) => (GROUP_ORDER[a] ?? 50) - (GROUP_ORDER[b] ?? 50))
|
||
.map(([group, pages]) => ({ group, pages }));
|
||
|
||
// Nineteen shelves in one column is a list to read, not a menu to scan.
|
||
// Collapsing them under what a reader came here to make turns it into eight
|
||
// openable sections, and keeps every existing shelf name intact underneath.
|
||
const SECTIONS: { section: string; groups: string[] }[] = [
|
||
{ section: "Text & captions", groups: ["Captions", "Typography & Text", "Lower Thirds"] },
|
||
{ section: "Code", groups: ["Code Animations", "Code Snippets"] },
|
||
{ section: "Transitions", groups: ["Shader Transitions", "CSS Transitions"] },
|
||
{ section: "Data & charts", groups: ["Data"] },
|
||
{
|
||
section: "Scenes & demos",
|
||
groups: ["Showcases", "Product Demo", "Social Overlays", "Motion Scenes"],
|
||
},
|
||
// Its own section rather than a shelf inside Scenes & demos. At 25 items it
|
||
// is larger than Data & charts (17) and Blocks (13), which are both
|
||
// sections on their own, and pulling it out takes the largest section in
|
||
// the catalog from 120 items down to 95.
|
||
{ section: "Carousels", groups: ["Carousels"] },
|
||
{ section: "Motion & effects", groups: ["Motion Primitives", "Effects", "Camera & 3D"] },
|
||
{ section: "Surfaces", groups: ["Texture", "HTML-in-Canvas"] },
|
||
{ section: "Blocks", groups: ["Blocks"] },
|
||
];
|
||
|
||
const byName = new Map(flatGroups.map((g) => [g.group, g]));
|
||
const placed = new Set<string>();
|
||
const catalogGroups: unknown[] = [];
|
||
|
||
for (const { section, groups } of SECTIONS) {
|
||
const children = groups
|
||
.map((name) => byName.get(name))
|
||
.filter((g): g is { group: string; pages: string[] } => g !== undefined);
|
||
if (children.length === 0) continue;
|
||
for (const child of children) placed.add(child.group);
|
||
// A nested shelf is an entry in the parent's `pages`, beside the page
|
||
// strings. A sibling `groups` key parses without complaint and renders
|
||
// nothing, which took the whole catalog out of the sidebar.
|
||
catalogGroups.push({ group: section, pages: children });
|
||
}
|
||
|
||
// A shelf nobody assigned a section still has to appear, or a new tag would
|
||
// silently drop its items out of the sidebar.
|
||
for (const group of flatGroups) {
|
||
if (!placed.has(group.group)) catalogGroups.push(group);
|
||
}
|
||
|
||
if (catalogGroups.length > 0) {
|
||
const existingIdx = tabs.findIndex((t) => t.tab === "Catalog");
|
||
const existing = existingIdx >= 0 ? tabs[existingIdx] : undefined;
|
||
|
||
// Groups nobody here generated — e.g. the hand-added "Overview" pointing at
|
||
// catalog/index. Rebuilding the tab used to drop them, which unlinked the
|
||
// catalog landing page from the sidebar entirely.
|
||
const isGeneratedPage = (p: unknown): boolean =>
|
||
typeof p === "string" && /^catalog\/(blocks|components)\//.test(p);
|
||
// Recurses because a section holds groups, not pages. Keeps hand-written pages
|
||
// (catalog/index) even when they share a group with generated ones.
|
||
const withoutGeneratedPages = (node: unknown): unknown[] => {
|
||
if (isGeneratedPage(node)) return [];
|
||
if (!node || typeof node !== "object") return [node];
|
||
const g = node as { pages?: unknown[] };
|
||
if (!g.pages) return [node];
|
||
const pages = g.pages.flatMap(withoutGeneratedPages);
|
||
return pages.length > 0 ? [{ ...g, pages }] : [];
|
||
};
|
||
const handAddedGroups = (existing?.groups ?? []).flatMap(withoutGeneratedPages);
|
||
|
||
const catalogTab = {
|
||
tab: "Catalog",
|
||
// Keep the icon a human chose for the tab.
|
||
...(existing?.icon ? { icon: existing.icon } : {}),
|
||
groups: [...handAddedGroups, ...catalogGroups],
|
||
};
|
||
|
||
if (existingIdx >= 0) {
|
||
// Leave the tab where it already sits, rather than re-homing it.
|
||
tabs.splice(existingIdx, 1, catalogTab);
|
||
} else {
|
||
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");
|
||
// A section holds groups, a shelf holds pages; the count has to walk both
|
||
// or it reports zero for everything that was nested.
|
||
const countPages = (node: unknown): number => {
|
||
if (typeof node === "string") return 1;
|
||
if (!node || typeof node !== "object") return 0;
|
||
const g = node as { pages?: unknown[] };
|
||
if (!Array.isArray(g.pages)) return 0;
|
||
return g.pages.reduce((n: number, entry: unknown) => n + countPages(entry), 0);
|
||
};
|
||
const totalPages = catalogGroups.reduce((n: number, g) => n + countPages(g), 0);
|
||
console.log(` ✓ docs.json updated with ${catalogGroups.length} sections, ${totalPages} pages`);
|
||
}
|
||
|
||
console.log("\nDone.");
|
||
}
|
||
|
||
// Only regenerate when run directly, so the module can be imported by tests.
|
||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||
main();
|
||
}
|