mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(cli): registry resolver + installer (#254)
## What PR 3/17 of the catalog system rollout. Introduces the registry resolver/installer abstraction. No UX change — `init --template` still works identically. Stacks on #253. **New module: `packages/cli/src/registry/`** - `remote.ts` — fetches manifests (`registry.json`, `registry-item.json`) and item files from a GitHub-hosted registry. 24h cache on manifests; item files stream straight to `destDir` - `resolver.ts` — `listRegistryItems`, `loadAllItems` (parallel fetch for picker UX), `resolveItem` (single-item fetch with `Available:` error) - `installer.ts` — `assertSafeTarget` (runtime path-traversal guard) + `installItem` (parallel file download with up-front validation; all-or-nothing semantics) - `index.ts` — barrel **Registry content:** - `registry/registry.json` — top-level manifest in PR 1's `RegistryManifest` shape. 8 examples - `registry/examples/<id>/registry-item.json` — per-item manifest for each existing example, generated from legacy `templates.json` + HTML data-attribute probing - `registry/examples/templates.json` — **deleted**, replaced by the above **Compat layer:** - `packages/cli/src/templates/{remote,generators}.ts` — thin shims that delegate to `../registry/`, keeping `init.ts`'s existing imports stable. `init.ts` doesn't move to the new API until PR 5 where it's part of a larger UX pass **Tooling:** - `scripts/generate-registry-items.ts` — idempotent one-off generator for this PR, kept in-repo for future example additions (`--only <name>` flag) Design doc: [Hyperframes Catalog System](https://www.notion.so/heygen/Hyperframes-Catalog-System-Design-Plan-341449792c69813f899dcd53b4c0383a). Tracker entry in local `hyperframes-catalog-plan.md`. ## Why Every future PR (`hyperframes add`, seed blocks, seed components, custom registries) otherwise has to keep piling onto the ad-hoc fetch + `cpSync` pattern in the old `fetchRemoteTemplate`. The new module is the single place that understands the registry wire format and file layout. **This is also where PR 1's schema comes alive.** ## How ### Scope-trimmed from the plan - **No transitive dependency resolution yet.** Examples have no deps today. `resolveItem` doesn't walk `registryDependencies`; PR 5 adds that when blocks/components need it. - **No ajv schema validation yet.** TS types + runtime path-traversal guard are the only safety nets. Full JSON-Schema validation lands when the registry starts accepting third-party content (PR 14 / custom registries). - **init.ts refactor deferred to PR 5.** Compat shims keep this PR small and reviewable. PR 5 rewrites init alongside adding the `add` command. ### Safety - `assertSafeTarget` rejects absolute paths, `..` segments, Windows drive letters, and any target that `path.resolve` shows to escape `destDir`. Mirrors the PR 1 schema `pattern`/`not.anyOf` on `target`, but runs at install-time so a registry that bypasses schema validation still can't write outside the project - Up-front validation in `installItem` means a malformed item fails **before** any file is written. Atomic-ish semantics: all files land or none do ### Caching - 24h manifest cache lives at `~/.hyperframes/cache/` per existing convention, but now keyed by `<baseUrl>__<kind>__<name>.json` so PR 14 custom registries can coexist ## Test plan - [x] `bun run test` in `packages/cli`: **70 passed** (was 57 on #253, +13). Same 4 pre-existing failures (SRT/VTT whisper normalizer + `lintProject` clean-project test) — identical to main. No regressions - [x] **Resolver unit tests (8):** filter by type, parallel load with fail-safe, resolve-by-name with `Available:` error message, unreachable-registry handling - [x] **Installer unit tests (5):** accepts simple relative paths, rejects `..` segments, rejects Unix absolute paths, rejects Windows drive letters, permits `.` and dotfile-like names - [x] **Smoke test**: `hyperframes init /tmp/x --template blank` (bundled code path, unchanged) works end-to-end - [x] `bunx oxfmt --check` + `bunx oxlint`: clean - [x] Pre-commit typecheck (core + studio): clean. CLI typecheck has 2 pre-existing errors (`render.ts`, `studioServer.ts` — unrelated `"mov"` format issue on main) - [ ] **Smoke test remote fetch (`--template warm-grain`)** — verifiable only post-merge; registry paths live on `main` after this PR lands ## Breaking / migration **No end-user-visible UX change.** `init --template <name>` still works the same way. Internally, `templates.json` is gone and the CLI now reads `registry.json` + `registry-item.json` per example. Installed CLIs on old versions (`hyperframes@0.1.0`–`0.3.0`) already broke at PR 2 merge (see #253 rollout note). The next CLI release after this lands (`0.3.1`+) is the full fix. ## Commits 1. `generate-registry-items.ts` + generated manifests + deleted `templates.json` 2. Resolver + installer + compat shims 3. Unit tests (All squashed into one commit on this branch; see `git log feat/registry-resolver ^refactor/registry-examples-dir`.) ## Stacks on #253 — base branch. When #253 merges, this rebases onto `main`. ## Next in stack PR 4 — `feat(cli)!: rename --template to --example`. Single clean cut, no alias. Tiny PR (~150 lines) that mostly updates `init.ts`'s argument schema, help text, and docs. Depends on this PR so the new flag name can be applied against the refactored code path. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
DEFAULT_REGISTRY_URL,
|
||||
fetchRegistryManifest,
|
||||
fetchItemManifest,
|
||||
fetchItemFile,
|
||||
} from "./remote.js";
|
||||
|
||||
export { listRegistryItems, loadAllItems, resolveItem, type ResolveOptions } from "./resolver.js";
|
||||
|
||||
export {
|
||||
installItem,
|
||||
assertSafeTarget,
|
||||
type InstallOptions,
|
||||
type InstallResult,
|
||||
} from "./installer.js";
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { assertSafeTarget } from "./installer.js";
|
||||
|
||||
const DEST = "/tmp/hf-install-test";
|
||||
|
||||
describe("assertSafeTarget", () => {
|
||||
it("allows simple relative paths", () => {
|
||||
expect(() => assertSafeTarget(DEST, "index.html")).not.toThrow();
|
||||
expect(() => assertSafeTarget(DEST, "compositions/intro.html")).not.toThrow();
|
||||
expect(() => assertSafeTarget(DEST, "assets/nested/deep/file.svg")).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects `..` path segments", () => {
|
||||
expect(() => assertSafeTarget(DEST, "../escape.html")).toThrow(/\.\./);
|
||||
expect(() => assertSafeTarget(DEST, "compositions/../../escape.html")).toThrow(/\.\./);
|
||||
expect(() => assertSafeTarget(DEST, "a/b/../../../escape.html")).toThrow();
|
||||
});
|
||||
|
||||
it("rejects Unix absolute paths", () => {
|
||||
expect(() => assertSafeTarget(DEST, "/etc/passwd")).toThrow(/absolute/);
|
||||
expect(() => assertSafeTarget(DEST, "/home/user/file.txt")).toThrow();
|
||||
});
|
||||
|
||||
it("rejects Windows drive-letter paths", () => {
|
||||
expect(() => assertSafeTarget(DEST, "C:/Windows/System32")).toThrow(/Windows/);
|
||||
expect(() => assertSafeTarget(DEST, "D:\\notes.txt")).toThrow();
|
||||
});
|
||||
|
||||
it("allows `.` segments (no-op) and dotfile-like names", () => {
|
||||
expect(() => assertSafeTarget(DEST, ".hidden")).not.toThrow();
|
||||
expect(() => assertSafeTarget(DEST, "./file.html")).not.toThrow();
|
||||
expect(() => assertSafeTarget(DEST, "a..b/file.html")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Registry installer — copies item files into a destination project.
|
||||
*
|
||||
* The top-level directory used under the source registry is determined by the
|
||||
* item's `type` (examples/blocks/components). Target paths are validated at
|
||||
* runtime to reject traversal even if the registry JSON schema was bypassed.
|
||||
*/
|
||||
|
||||
import { resolve, relative, isAbsolute } from "node:path";
|
||||
import type { FileTarget, RegistryItem } from "@hyperframes/core";
|
||||
import { fetchItemFile, DEFAULT_REGISTRY_URL } from "./remote.js";
|
||||
|
||||
export interface InstallOptions {
|
||||
/** Project root where files land. Every target resolves relative to this. */
|
||||
destDir: string;
|
||||
/** Base URL of the registry. Defaults to the official public registry. */
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export interface InstallResult {
|
||||
/** Absolute paths of files actually written. */
|
||||
written: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject target paths that would escape `destDir`. Mirrors the pattern check
|
||||
* in `packages/core/schemas/registry-item.json#files.items.target`, but runs at
|
||||
* install time so a registry that bypasses schema validation still can't write
|
||||
* outside the project.
|
||||
*/
|
||||
export function assertSafeTarget(destDir: string, target: string): void {
|
||||
if (isAbsolute(target)) {
|
||||
throw new Error(`Unsafe target "${target}": absolute paths are not allowed.`);
|
||||
}
|
||||
if (/(^|[/\\])\.\.([/\\]|$)/.test(target)) {
|
||||
throw new Error(`Unsafe target "${target}": path segments may not contain "..".`);
|
||||
}
|
||||
if (/^[A-Za-z]:[/\\]/.test(target)) {
|
||||
throw new Error(`Unsafe target "${target}": Windows drive letters are not allowed.`);
|
||||
}
|
||||
const resolved = resolve(destDir, target);
|
||||
const rel = relative(resolve(destDir), resolved);
|
||||
if (rel.startsWith("..") || isAbsolute(rel)) {
|
||||
throw new Error(`Unsafe target "${target}": resolves outside destDir ${destDir}.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a resolved `RegistryItem` into `destDir` by fetching each file in
|
||||
* parallel and writing it to its validated target path.
|
||||
*/
|
||||
export async function installItem(
|
||||
item: RegistryItem,
|
||||
options: InstallOptions,
|
||||
): Promise<InstallResult> {
|
||||
const baseUrl = options.baseUrl ?? DEFAULT_REGISTRY_URL;
|
||||
const destDir = resolve(options.destDir);
|
||||
|
||||
// Validate all targets up-front so a malformed item fails before any write.
|
||||
for (const file of item.files) {
|
||||
assertSafeTarget(destDir, file.target);
|
||||
}
|
||||
|
||||
const written = await Promise.all(
|
||||
item.files.map(async (file: FileTarget) => {
|
||||
const destPath = resolve(destDir, file.target);
|
||||
await fetchItemFile(item, file, destPath, baseUrl);
|
||||
return destPath;
|
||||
}),
|
||||
);
|
||||
|
||||
return { written };
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Remote Registry Fetching
|
||||
*
|
||||
* Fetches registry manifests and item files from a Hyperframes registry hosted
|
||||
* on GitHub (or any HTTPS endpoint serving the same file layout).
|
||||
*
|
||||
* Base URL layout:
|
||||
* <base>/registry.json → top-level manifest
|
||||
* <base>/<type-dir>/<name>/registry-item.json
|
||||
* <base>/<type-dir>/<name>/<file.path> → individual files referenced by the item
|
||||
*
|
||||
* `<type-dir>` comes from ITEM_TYPE_DIRS in @hyperframes/core.
|
||||
*/
|
||||
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import {
|
||||
ITEM_TYPE_DIRS,
|
||||
type FileTarget,
|
||||
type ItemType,
|
||||
type RegistryItem,
|
||||
type RegistryManifest,
|
||||
} from "@hyperframes/core";
|
||||
|
||||
export const DEFAULT_REGISTRY_URL =
|
||||
"https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry";
|
||||
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
|
||||
// ── Caching ─────────────────────────────────────────────────────────────────
|
||||
// 24h TTL on manifest fetches so the interactive picker stays snappy offline.
|
||||
// Item files aren't cached — they're written straight to destDir on install.
|
||||
|
||||
const CACHE_DIR = join(homedir(), ".hyperframes", "cache");
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
interface CacheEntry<T> {
|
||||
fetchedAt: number;
|
||||
data: T;
|
||||
}
|
||||
|
||||
function cachePath(baseUrl: string, key: string): string {
|
||||
const slug = baseUrl.replace(/[^a-zA-Z0-9]/g, "_");
|
||||
return join(CACHE_DIR, `${slug}__${key}.json`);
|
||||
}
|
||||
|
||||
function readCache<T>(path: string): T | undefined {
|
||||
try {
|
||||
const entry = JSON.parse(readFileSync(path, "utf-8")) as CacheEntry<T>;
|
||||
if (Date.now() - entry.fetchedAt > CACHE_TTL_MS) return undefined;
|
||||
return entry.data;
|
||||
} catch {
|
||||
// Missing file or corrupt JSON → cache miss.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache<T>(path: string, data: T): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const entry: CacheEntry<T> = { fetchedAt: Date.now(), data };
|
||||
writeFileSync(path, JSON.stringify(entry), "utf-8");
|
||||
}
|
||||
|
||||
// ── Fetchers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Registry fetch failed: ${url} — HTTP ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the top-level registry.json manifest. Cached for 24h.
|
||||
* Returns undefined if the registry is unreachable (offline / 404).
|
||||
*/
|
||||
export async function fetchRegistryManifest(
|
||||
baseUrl: string = DEFAULT_REGISTRY_URL,
|
||||
): Promise<RegistryManifest | undefined> {
|
||||
const cacheFile = cachePath(baseUrl, "registry");
|
||||
const cached = readCache<RegistryManifest>(cacheFile);
|
||||
if (cached) return cached;
|
||||
|
||||
try {
|
||||
const manifest = await fetchJson<RegistryManifest>(`${baseUrl}/registry.json`);
|
||||
writeCache(cacheFile, manifest);
|
||||
return manifest;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single item's `registry-item.json` manifest. Cached for 24h.
|
||||
* Throws on network failure (callers decide whether to degrade gracefully).
|
||||
*/
|
||||
export async function fetchItemManifest(
|
||||
name: string,
|
||||
type: ItemType,
|
||||
baseUrl: string = DEFAULT_REGISTRY_URL,
|
||||
): Promise<RegistryItem> {
|
||||
const dir = ITEM_TYPE_DIRS[type];
|
||||
const cacheFile = cachePath(baseUrl, `${dir}__${name}`);
|
||||
const cached = readCache<RegistryItem>(cacheFile);
|
||||
if (cached) return cached;
|
||||
|
||||
const url = `${baseUrl}/${dir}/${name}/registry-item.json`;
|
||||
const item = await fetchJson<RegistryItem>(url);
|
||||
writeCache(cacheFile, item);
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a single file referenced by an item to a local destination.
|
||||
* Caller is responsible for target-path validation (see installer.ts).
|
||||
*/
|
||||
export async function fetchItemFile(
|
||||
item: RegistryItem,
|
||||
file: FileTarget,
|
||||
destPath: string,
|
||||
baseUrl: string = DEFAULT_REGISTRY_URL,
|
||||
): Promise<void> {
|
||||
const url = `${baseUrl}/${ITEM_TYPE_DIRS[item.type]}/${item.name}/${file.path}`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
||||
if (!res.ok) {
|
||||
throw new Error(`File fetch failed: ${url} — HTTP ${res.status}`);
|
||||
}
|
||||
const buf = new Uint8Array(await res.arrayBuffer());
|
||||
mkdirSync(dirname(destPath), { recursive: true });
|
||||
writeFileSync(destPath, buf);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { RegistryItem, RegistryManifest } from "@hyperframes/core";
|
||||
import { listRegistryItems, loadAllItems, resolveItem } from "./resolver.js";
|
||||
|
||||
const MANIFEST: RegistryManifest = {
|
||||
$schema: "https://hyperframes.heygen.com/schema/registry.json",
|
||||
name: "test",
|
||||
homepage: "https://example.com",
|
||||
items: [
|
||||
{ name: "alpha", type: "hyperframes:example" },
|
||||
{ name: "beta", type: "hyperframes:example" },
|
||||
{ name: "gamma", type: "hyperframes:block" },
|
||||
],
|
||||
};
|
||||
|
||||
function buildItem(name: string, type: "hyperframes:example" | "hyperframes:block"): RegistryItem {
|
||||
if (type === "hyperframes:example") {
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
title: name.toUpperCase(),
|
||||
description: `${name} desc`,
|
||||
dimensions: { width: 1920, height: 1080 },
|
||||
duration: 10,
|
||||
files: [{ path: "index.html", target: "index.html", type: "hyperframes:composition" }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
title: name.toUpperCase(),
|
||||
description: `${name} desc`,
|
||||
dimensions: { width: 1080, height: 1350 },
|
||||
duration: 6,
|
||||
files: [
|
||||
{
|
||||
path: `${name}.html`,
|
||||
target: `compositions/${name}.html`,
|
||||
type: "hyperframes:composition",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function mockFetch(overrides: Record<string, unknown> = {}): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (urlInput: string | URL) => {
|
||||
const url = typeof urlInput === "string" ? urlInput : urlInput.toString();
|
||||
if (url.endsWith("/registry.json") && !overrides.registryFails) {
|
||||
return new Response(JSON.stringify(MANIFEST), { status: 200 });
|
||||
}
|
||||
const m = /\/(examples|blocks|components)\/([^/]+)\/registry-item\.json$/.exec(url);
|
||||
if (m && !(overrides.missing as string[] | undefined)?.includes(m[2]!)) {
|
||||
const type = m[1] === "examples" ? "hyperframes:example" : "hyperframes:block";
|
||||
return new Response(JSON.stringify(buildItem(m[2]!, type)), { status: 200 });
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueBaseUrl(): string {
|
||||
// Unique per-test so the 24h on-disk cache doesn't pollute sibling tests.
|
||||
return `https://test.invalid/${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
describe("registry resolver", () => {
|
||||
beforeEach(() => mockFetch());
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("listRegistryItems", () => {
|
||||
it("returns all items when no filter is given", async () => {
|
||||
const items = await listRegistryItems(undefined, { baseUrl: uniqueBaseUrl() });
|
||||
expect(items.map((i) => i.name)).toEqual(["alpha", "beta", "gamma"]);
|
||||
});
|
||||
|
||||
it("filters by type", async () => {
|
||||
const baseUrl = uniqueBaseUrl();
|
||||
const examples = await listRegistryItems({ type: "hyperframes:example" }, { baseUrl });
|
||||
expect(examples.map((i) => i.name)).toEqual(["alpha", "beta"]);
|
||||
|
||||
const blocks = await listRegistryItems({ type: "hyperframes:block" }, { baseUrl });
|
||||
expect(blocks.map((i) => i.name)).toEqual(["gamma"]);
|
||||
});
|
||||
|
||||
it("returns empty on unreachable registry", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("oops", { status: 500 })),
|
||||
);
|
||||
const items = await listRegistryItems(undefined, { baseUrl: uniqueBaseUrl() });
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadAllItems", () => {
|
||||
it("loads manifests in parallel", async () => {
|
||||
const baseUrl = uniqueBaseUrl();
|
||||
const entries = await listRegistryItems(undefined, { baseUrl });
|
||||
const items = await loadAllItems(entries, { baseUrl });
|
||||
expect(items.map((i) => i.name).sort()).toEqual(["alpha", "beta", "gamma"]);
|
||||
expect(items.find((i) => i.name === "alpha")?.title).toBe("ALPHA");
|
||||
});
|
||||
|
||||
it("skips items whose manifest fails to load (warning, not failure)", async () => {
|
||||
mockFetch({ missing: ["beta"] });
|
||||
const baseUrl = uniqueBaseUrl();
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const entries = await listRegistryItems(undefined, { baseUrl });
|
||||
const items = await loadAllItems(entries, { baseUrl });
|
||||
expect(items.map((i) => i.name).sort()).toEqual(["alpha", "gamma"]);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveItem", () => {
|
||||
it("returns the full manifest for a known item", async () => {
|
||||
const baseUrl = uniqueBaseUrl();
|
||||
const item = await resolveItem("alpha", { baseUrl });
|
||||
expect(item.name).toBe("alpha");
|
||||
expect(item.type).toBe("hyperframes:example");
|
||||
expect(item.files).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("throws with an `Available:` list when the name is unknown", async () => {
|
||||
const baseUrl = uniqueBaseUrl();
|
||||
await expect(resolveItem("nonexistent", { baseUrl })).rejects.toThrow(
|
||||
/Available: alpha, beta, gamma/,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws a clear message when the registry itself is unreachable", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("down", { status: 500 })),
|
||||
);
|
||||
const baseUrl = uniqueBaseUrl();
|
||||
await expect(resolveItem("alpha", { baseUrl })).rejects.toThrow(/unreachable/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Registry resolver — loads the top-level manifest and per-item manifests.
|
||||
* No transitive dependency resolution yet (examples don't have any); added
|
||||
* when blocks/components need it for the `add` command.
|
||||
*/
|
||||
|
||||
import type { ItemType, RegistryItem, RegistryManifestEntry } from "@hyperframes/core";
|
||||
import { fetchItemManifest, fetchRegistryManifest, DEFAULT_REGISTRY_URL } from "./remote.js";
|
||||
|
||||
export interface ResolveOptions {
|
||||
baseUrl?: string;
|
||||
/**
|
||||
* Called once per item that fails to load inside `loadAllItems`. Defaults
|
||||
* to writing a diagnostic line to stderr. Pass a quieter implementation
|
||||
* when rendering structured output (clack prompts, JSON, etc.).
|
||||
*/
|
||||
onWarn?: (message: string) => void;
|
||||
}
|
||||
|
||||
function defaultWarn(message: string): void {
|
||||
process.stderr.write(`hyperframes:registry ${message}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all items in the registry, optionally filtered by type. Returns empty
|
||||
* if the registry is unreachable — callers should fall back to bundled items.
|
||||
*/
|
||||
export async function listRegistryItems(
|
||||
filter?: { type?: ItemType },
|
||||
options: ResolveOptions = {},
|
||||
): Promise<RegistryManifestEntry[]> {
|
||||
const baseUrl = options.baseUrl ?? DEFAULT_REGISTRY_URL;
|
||||
const manifest = await fetchRegistryManifest(baseUrl);
|
||||
if (!manifest) return [];
|
||||
if (!filter?.type) return manifest.items;
|
||||
return manifest.items.filter((item) => item.type === filter.type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load every item's full manifest in parallel. Used by the interactive init
|
||||
* picker to populate titles/descriptions for all examples at once. Items that
|
||||
* fail to load are skipped with a warning so one missing manifest doesn't
|
||||
* break the picker.
|
||||
*/
|
||||
export async function loadAllItems(
|
||||
entries: RegistryManifestEntry[],
|
||||
options: ResolveOptions = {},
|
||||
): Promise<RegistryItem[]> {
|
||||
const baseUrl = options.baseUrl ?? DEFAULT_REGISTRY_URL;
|
||||
const warn = options.onWarn ?? defaultWarn;
|
||||
const results = await Promise.allSettled(
|
||||
entries.map((e) => fetchItemManifest(e.name, e.type, baseUrl)),
|
||||
);
|
||||
const items: RegistryItem[] = [];
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === "fulfilled") {
|
||||
items.push(r.value);
|
||||
} else {
|
||||
const name = entries[i]?.name ?? "<unknown>";
|
||||
warn(`skipped item "${name}": ${String(r.reason)}`);
|
||||
}
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Resolve a single item by name. Throws if unknown or unreachable. */
|
||||
export async function resolveItem(
|
||||
name: string,
|
||||
options: ResolveOptions = {},
|
||||
): Promise<RegistryItem> {
|
||||
const entries = await listRegistryItems(undefined, options);
|
||||
const entry = entries.find((e) => e.name === name);
|
||||
if (!entry) {
|
||||
const available = entries.map((e) => e.name).join(", ");
|
||||
throw new Error(
|
||||
available.length > 0
|
||||
? `Item "${name}" not found in registry. Available: ${available}`
|
||||
: `Item "${name}" not found — registry unreachable or empty.`,
|
||||
);
|
||||
}
|
||||
return fetchItemManifest(entry.name, entry.type, options.baseUrl);
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import { listRemoteTemplates, type RemoteTemplateInfo } from "./remote.js";
|
||||
// Compat shim — the registry resolver (packages/cli/src/registry/) is the
|
||||
// canonical implementation. Kept so init.ts and any external imports that
|
||||
// reference this path keep working. Converts new RegistryItem manifests back
|
||||
// into the TemplateOption shape the init wizard still uses. Deletable once
|
||||
// init.ts is fully ported to call the resolver directly.
|
||||
|
||||
import { listRegistryItems, loadAllItems } from "../registry/index.js";
|
||||
|
||||
export type TemplateSource = "bundled" | "remote";
|
||||
|
||||
@@ -20,27 +26,22 @@ export const BUNDLED_TEMPLATES: TemplateOption[] = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolve the full template list by merging bundled and remote templates.
|
||||
* Fetches `registry/examples/templates.json` from GitHub (cached 24h). No CLI release needed to add templates.
|
||||
* If offline, returns only bundled templates.
|
||||
* Resolve the full template list by merging bundled templates with remote
|
||||
* examples fetched from the registry. Offline / unreachable → bundled only.
|
||||
*/
|
||||
export async function resolveTemplateList(): Promise<TemplateOption[]> {
|
||||
const bundled = [...BUNDLED_TEMPLATES];
|
||||
const bundledIds = new Set(bundled.map((t) => t.id));
|
||||
|
||||
let remote: RemoteTemplateInfo[] = [];
|
||||
try {
|
||||
remote = await listRemoteTemplates();
|
||||
} catch {
|
||||
// Offline — return bundled only
|
||||
}
|
||||
const entries = await listRegistryItems({ type: "hyperframes:example" });
|
||||
const items = await loadAllItems(entries);
|
||||
|
||||
const remoteOptions: TemplateOption[] = remote
|
||||
.filter((r) => !r.bundled && !bundledIds.has(r.id))
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
label: r.label,
|
||||
hint: r.hint,
|
||||
const remoteOptions: TemplateOption[] = items
|
||||
.filter((item) => !bundledIds.has(item.name))
|
||||
.map((item) => ({
|
||||
id: item.name,
|
||||
label: item.title,
|
||||
hint: item.description,
|
||||
source: "remote" as const,
|
||||
}));
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ describe("remote template path constants", () => {
|
||||
expect(TEMPLATES_DIR).toBe("registry/examples");
|
||||
});
|
||||
|
||||
it("MANIFEST_FILENAME is templates.json (renamed to registry.json in PR 3)", () => {
|
||||
it("MANIFEST_FILENAME is retained for backwards-compat with any external consumers", () => {
|
||||
expect(MANIFEST_FILENAME).toBe("templates.json");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
/**
|
||||
* Remote Template Fetching
|
||||
*
|
||||
* Downloads templates from the hyperframes GitHub repository using giget.
|
||||
* Templates live in the `registry/examples/` directory of the repo.
|
||||
*/
|
||||
// Compat shim — fetchRemoteTemplate delegates to the registry resolver +
|
||||
// installer (packages/cli/src/registry/). Kept so init.ts and external imports
|
||||
// that reference this path keep working. Deletable once init.ts is fully
|
||||
// ported to call the resolver directly.
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { installItem, listRegistryItems, loadAllItems, resolveItem } from "../registry/index.js";
|
||||
|
||||
const REPO = "heygen-com/hyperframes";
|
||||
// Exported for regression testing — see remote.test.ts.
|
||||
// Re-exported for the existing remote.test.ts regression guard. These paths
|
||||
// describe the repo layout under the default registry URL; updating them in
|
||||
// sync with any future move prevents silent breakage of installed CLIs.
|
||||
export const TEMPLATES_DIR = "registry/examples";
|
||||
export const MANIFEST_FILENAME = "templates.json";
|
||||
|
||||
/** Cache directory for remote template metadata. */
|
||||
const CACHE_DIR = join(homedir(), ".hyperframes", "cache");
|
||||
const MANIFEST_CACHE_PATH = join(CACHE_DIR, "remote-templates.json");
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
export interface RemoteTemplateInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -26,79 +20,33 @@ export interface RemoteTemplateInfo {
|
||||
bundled: boolean;
|
||||
}
|
||||
|
||||
interface ManifestCache {
|
||||
fetchedAt: number;
|
||||
templates: RemoteTemplateInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the remote template manifest from GitHub.
|
||||
* Caches the result for 24 hours to avoid rate limits.
|
||||
* List available remote templates — kept for backwards compat with external
|
||||
* imports. Internally, `resolveTemplateList` in generators.ts is what init.ts
|
||||
* uses, and it goes through the registry resolver directly.
|
||||
*/
|
||||
export async function listRemoteTemplates(): Promise<RemoteTemplateInfo[]> {
|
||||
// Check cache first
|
||||
if (existsSync(MANIFEST_CACHE_PATH)) {
|
||||
try {
|
||||
const cached: ManifestCache = JSON.parse(readFileSync(MANIFEST_CACHE_PATH, "utf-8"));
|
||||
if (Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||
return cached.templates;
|
||||
}
|
||||
} catch {
|
||||
// Cache corrupt — refetch
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from GitHub raw content
|
||||
const url = `https://raw.githubusercontent.com/${REPO}/main/${TEMPLATES_DIR}/${MANIFEST_FILENAME}`;
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(5_000) });
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as { templates: RemoteTemplateInfo[] };
|
||||
const templates = data.templates;
|
||||
|
||||
// Write cache
|
||||
mkdirSync(CACHE_DIR, { recursive: true });
|
||||
const cache: ManifestCache = { fetchedAt: Date.now(), templates };
|
||||
writeFileSync(MANIFEST_CACHE_PATH, JSON.stringify(cache), "utf-8");
|
||||
|
||||
return templates;
|
||||
} catch {
|
||||
// Offline or rate-limited — return empty (caller should fall back to bundled)
|
||||
return [];
|
||||
}
|
||||
const entries = await listRegistryItems({ type: "hyperframes:example" });
|
||||
const items = await loadAllItems(entries);
|
||||
return items.map((item) => ({
|
||||
id: item.name,
|
||||
label: item.title,
|
||||
hint: item.description,
|
||||
bundled: false,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a template from GitHub into destDir using giget.
|
||||
* Fetches from `registry/examples/<templateId>` in the hyperframes repo.
|
||||
* Download a template into destDir. Delegates to the registry installer.
|
||||
*/
|
||||
export async function fetchRemoteTemplate(
|
||||
templateId: string,
|
||||
destDir: string,
|
||||
options?: { ref?: string },
|
||||
): Promise<void> {
|
||||
// Validate against manifest before downloading
|
||||
const known = await listRemoteTemplates();
|
||||
if (known.length > 0 && !known.some((t) => t.id === templateId)) {
|
||||
const available = known.map((t) => t.id).join(", ");
|
||||
throw new Error(`Template "${templateId}" not found. Available: ${available}`);
|
||||
}
|
||||
export async function fetchRemoteTemplate(templateId: string, destDir: string): Promise<void> {
|
||||
const item = await resolveItem(templateId);
|
||||
await installItem(item, { destDir });
|
||||
|
||||
const { downloadTemplate } = await import("giget");
|
||||
const ref = options?.ref ?? "main";
|
||||
const source = `github:${REPO}/${TEMPLATES_DIR}/${templateId}#${ref}`;
|
||||
|
||||
await downloadTemplate(source, {
|
||||
dir: destDir,
|
||||
force: true,
|
||||
});
|
||||
|
||||
// Safety check — giget can succeed with empty dir if path doesn't exist
|
||||
// Safety check — an item with no index.html isn't a valid example.
|
||||
if (!existsSync(join(destDir, "index.html"))) {
|
||||
throw new Error(
|
||||
`Template "${templateId}" downloaded but missing index.html. The template may be malformed.`,
|
||||
`Template "${templateId}" installed but missing index.html. The registry item may be malformed.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,7 @@ export type {
|
||||
export {
|
||||
ITEM_TYPES,
|
||||
FILE_TYPES,
|
||||
ITEM_TYPE_DIRS,
|
||||
isExampleItem,
|
||||
isBlockItem,
|
||||
isComponentItem,
|
||||
|
||||
@@ -12,4 +12,11 @@ export type {
|
||||
RegistryManifest,
|
||||
} from "./types.js";
|
||||
|
||||
export { ITEM_TYPES, FILE_TYPES, isExampleItem, isBlockItem, isComponentItem } from "./types.js";
|
||||
export {
|
||||
ITEM_TYPES,
|
||||
FILE_TYPES,
|
||||
ITEM_TYPE_DIRS,
|
||||
isExampleItem,
|
||||
isBlockItem,
|
||||
isComponentItem,
|
||||
} from "./types.js";
|
||||
|
||||
@@ -131,6 +131,18 @@ export const FILE_TYPES = [
|
||||
"hyperframes:timeline",
|
||||
] as const satisfies readonly FileType[];
|
||||
|
||||
/**
|
||||
* Directory segment where each item type lives under a registry root — both
|
||||
* on disk (`registry/examples/…`) and in URL construction
|
||||
* (`<baseUrl>/examples/<name>/registry-item.json`). Shared so CLIs, docs
|
||||
* tooling, and codegen scripts all agree.
|
||||
*/
|
||||
export const ITEM_TYPE_DIRS = {
|
||||
"hyperframes:example": "examples",
|
||||
"hyperframes:block": "blocks",
|
||||
"hyperframes:component": "components",
|
||||
} as const satisfies Record<ItemType, string>;
|
||||
|
||||
// Compile-time exhaustiveness: every member of the TS union appears in the constant.
|
||||
// If someone adds to `ItemType`/`FileType` without updating `ITEM_TYPES`/`FILE_TYPES`,
|
||||
// these lines stop compiling. (The `satisfies` above covers the other direction.)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
|
||||
"name": "decision-tree",
|
||||
"type": "hyperframes:example",
|
||||
"title": "Decision Tree",
|
||||
"description": "Animated flowchart with branching paths",
|
||||
"dimensions": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
},
|
||||
"duration": 15,
|
||||
"files": [
|
||||
{
|
||||
"path": "compositions/decision_tree.html",
|
||||
"target": "compositions/decision_tree.html",
|
||||
"type": "hyperframes:composition"
|
||||
},
|
||||
{
|
||||
"path": "index.html",
|
||||
"target": "index.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
|
||||
"name": "kinetic-type",
|
||||
"type": "hyperframes:example",
|
||||
"title": "Kinetic Type",
|
||||
"description": "Bold kinetic typography promo",
|
||||
"dimensions": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
},
|
||||
"duration": 15,
|
||||
"files": [
|
||||
{
|
||||
"path": "compositions/main-graphics.html",
|
||||
"target": "compositions/main-graphics.html",
|
||||
"type": "hyperframes:composition"
|
||||
},
|
||||
{
|
||||
"path": "index.html",
|
||||
"target": "index.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
|
||||
"name": "nyt-graph",
|
||||
"type": "hyperframes:example",
|
||||
"title": "NYT Graph",
|
||||
"description": "Animated data chart in print editorial style",
|
||||
"dimensions": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
},
|
||||
"duration": 15,
|
||||
"files": [
|
||||
{
|
||||
"path": "compositions/nyt-chart.html",
|
||||
"target": "compositions/nyt-chart.html",
|
||||
"type": "hyperframes:composition"
|
||||
},
|
||||
{
|
||||
"path": "index.html",
|
||||
"target": "index.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
|
||||
"name": "play-mode",
|
||||
"type": "hyperframes:example",
|
||||
"title": "Play Mode",
|
||||
"description": "Playful elastic animations",
|
||||
"dimensions": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
},
|
||||
"duration": 10,
|
||||
"files": [
|
||||
{
|
||||
"path": "compositions/captions.html",
|
||||
"target": "compositions/captions.html",
|
||||
"type": "hyperframes:composition"
|
||||
},
|
||||
{
|
||||
"path": "compositions/intro.html",
|
||||
"target": "compositions/intro.html",
|
||||
"type": "hyperframes:composition"
|
||||
},
|
||||
{
|
||||
"path": "compositions/stats.html",
|
||||
"target": "compositions/stats.html",
|
||||
"type": "hyperframes:composition"
|
||||
},
|
||||
{
|
||||
"path": "index.html",
|
||||
"target": "index.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
|
||||
"name": "product-promo",
|
||||
"type": "hyperframes:example",
|
||||
"title": "Product Promo",
|
||||
"description": "Multi-scene product showcase with SVG assets",
|
||||
"dimensions": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
},
|
||||
"duration": 20,
|
||||
"files": [
|
||||
{
|
||||
"path": "assets/figma-cursors.svg",
|
||||
"target": "assets/figma-cursors.svg",
|
||||
"type": "hyperframes:asset"
|
||||
},
|
||||
{
|
||||
"path": "assets/figma-logo-pieces.svg",
|
||||
"target": "assets/figma-logo-pieces.svg",
|
||||
"type": "hyperframes:asset"
|
||||
},
|
||||
{
|
||||
"path": "assets/figma-logo-pills.svg",
|
||||
"target": "assets/figma-logo-pills.svg",
|
||||
"type": "hyperframes:asset"
|
||||
},
|
||||
{
|
||||
"path": "compositions/scene1-logo-intro.html",
|
||||
"target": "compositions/scene1-logo-intro.html",
|
||||
"type": "hyperframes:composition"
|
||||
},
|
||||
{
|
||||
"path": "compositions/scene2-4-canvas.html",
|
||||
"target": "compositions/scene2-4-canvas.html",
|
||||
"type": "hyperframes:composition"
|
||||
},
|
||||
{
|
||||
"path": "compositions/scene5-logo-outro.html",
|
||||
"target": "compositions/scene5-logo-outro.html",
|
||||
"type": "hyperframes:composition"
|
||||
},
|
||||
{
|
||||
"path": "index.html",
|
||||
"target": "index.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
|
||||
"name": "swiss-grid",
|
||||
"type": "hyperframes:example",
|
||||
"title": "Swiss Grid",
|
||||
"description": "Structured grid layout",
|
||||
"dimensions": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
},
|
||||
"duration": 10,
|
||||
"files": [
|
||||
{
|
||||
"path": "assets/swiss-grid.svg",
|
||||
"target": "assets/swiss-grid.svg",
|
||||
"type": "hyperframes:asset"
|
||||
},
|
||||
{
|
||||
"path": "compositions/captions.html",
|
||||
"target": "compositions/captions.html",
|
||||
"type": "hyperframes:composition"
|
||||
},
|
||||
{
|
||||
"path": "compositions/graphics.html",
|
||||
"target": "compositions/graphics.html",
|
||||
"type": "hyperframes:composition"
|
||||
},
|
||||
{
|
||||
"path": "compositions/intro.html",
|
||||
"target": "compositions/intro.html",
|
||||
"type": "hyperframes:composition"
|
||||
},
|
||||
{
|
||||
"path": "index.html",
|
||||
"target": "index.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
{
|
||||
"templates": [
|
||||
{
|
||||
"id": "blank",
|
||||
"label": "Blank",
|
||||
"hint": "Empty composition — just the scaffolding",
|
||||
"bundled": true
|
||||
},
|
||||
{
|
||||
"id": "warm-grain",
|
||||
"label": "Warm Grain",
|
||||
"hint": "Cream aesthetic with grain texture",
|
||||
"bundled": false
|
||||
},
|
||||
{
|
||||
"id": "play-mode",
|
||||
"label": "Play Mode",
|
||||
"hint": "Playful elastic animations",
|
||||
"bundled": false
|
||||
},
|
||||
{
|
||||
"id": "swiss-grid",
|
||||
"label": "Swiss Grid",
|
||||
"hint": "Structured grid layout",
|
||||
"bundled": false
|
||||
},
|
||||
{
|
||||
"id": "vignelli",
|
||||
"label": "Vignelli",
|
||||
"hint": "Bold typography with red accents",
|
||||
"bundled": false
|
||||
},
|
||||
{
|
||||
"id": "decision-tree",
|
||||
"label": "Decision Tree",
|
||||
"hint": "Animated flowchart with branching paths",
|
||||
"bundled": false
|
||||
},
|
||||
{
|
||||
"id": "kinetic-type",
|
||||
"label": "Kinetic Type",
|
||||
"hint": "Bold kinetic typography promo",
|
||||
"bundled": false
|
||||
},
|
||||
{
|
||||
"id": "product-promo",
|
||||
"label": "Product Promo",
|
||||
"hint": "Multi-scene product showcase with SVG assets",
|
||||
"bundled": false
|
||||
},
|
||||
{
|
||||
"id": "nyt-graph",
|
||||
"label": "NYT Graph",
|
||||
"hint": "Animated data chart in print editorial style",
|
||||
"bundled": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
|
||||
"name": "vignelli",
|
||||
"type": "hyperframes:example",
|
||||
"title": "Vignelli",
|
||||
"description": "Bold typography with red accents",
|
||||
"dimensions": {
|
||||
"width": 1080,
|
||||
"height": 1920
|
||||
},
|
||||
"duration": 10,
|
||||
"files": [
|
||||
{
|
||||
"path": "compositions/captions.html",
|
||||
"target": "compositions/captions.html",
|
||||
"type": "hyperframes:composition"
|
||||
},
|
||||
{
|
||||
"path": "compositions/overlays.html",
|
||||
"target": "compositions/overlays.html",
|
||||
"type": "hyperframes:composition"
|
||||
},
|
||||
{
|
||||
"path": "index.html",
|
||||
"target": "index.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
|
||||
"name": "warm-grain",
|
||||
"type": "hyperframes:example",
|
||||
"title": "Warm Grain",
|
||||
"description": "Cream aesthetic with grain texture",
|
||||
"dimensions": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
},
|
||||
"duration": 10,
|
||||
"files": [
|
||||
{
|
||||
"path": "compositions/captions.html",
|
||||
"target": "compositions/captions.html",
|
||||
"type": "hyperframes:composition"
|
||||
},
|
||||
{
|
||||
"path": "compositions/graphics.html",
|
||||
"target": "compositions/graphics.html",
|
||||
"type": "hyperframes:composition"
|
||||
},
|
||||
{
|
||||
"path": "compositions/intro.html",
|
||||
"target": "compositions/intro.html",
|
||||
"type": "hyperframes:composition"
|
||||
},
|
||||
{
|
||||
"path": "index.html",
|
||||
"target": "index.html",
|
||||
"type": "hyperframes:composition"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"$schema": "https://hyperframes.heygen.com/schema/registry.json",
|
||||
"name": "hyperframes",
|
||||
"homepage": "https://hyperframes.heygen.com",
|
||||
"items": [
|
||||
{
|
||||
"name": "warm-grain",
|
||||
"type": "hyperframes:example"
|
||||
},
|
||||
{
|
||||
"name": "play-mode",
|
||||
"type": "hyperframes:example"
|
||||
},
|
||||
{
|
||||
"name": "swiss-grid",
|
||||
"type": "hyperframes:example"
|
||||
},
|
||||
{
|
||||
"name": "vignelli",
|
||||
"type": "hyperframes:example"
|
||||
},
|
||||
{
|
||||
"name": "decision-tree",
|
||||
"type": "hyperframes:example"
|
||||
},
|
||||
{
|
||||
"name": "kinetic-type",
|
||||
"type": "hyperframes:example"
|
||||
},
|
||||
{
|
||||
"name": "product-promo",
|
||||
"type": "hyperframes:example"
|
||||
},
|
||||
{
|
||||
"name": "nyt-graph",
|
||||
"type": "hyperframes:example"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Generate registry-item.json manifests for every example in registry/examples/,
|
||||
* plus the top-level registry/registry.json manifest.
|
||||
*
|
||||
* Reads the legacy registry/examples/templates.json (label + hint) and probes
|
||||
* each example's index.html for dimensions / duration data attributes.
|
||||
* Placeholder `__VIDEO_DURATION__` falls back to 10 (the init-time default).
|
||||
*
|
||||
* Idempotent — safe to re-run, but will overwrite any hand-edits. Intended as
|
||||
* one-shot scaffolding for PR 3.
|
||||
*
|
||||
* Usage:
|
||||
* bun run scripts/generate-registry-items.ts
|
||||
* bun run scripts/generate-registry-items.ts --only warm-grain
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, relative, resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
ITEM_TYPE_DIRS,
|
||||
type FileTarget,
|
||||
type FileType,
|
||||
type RegistryItem,
|
||||
type RegistryManifest,
|
||||
} from "@hyperframes/core";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(scriptDir, "..");
|
||||
const examplesDir = resolve(repoRoot, "registry", ITEM_TYPE_DIRS["hyperframes:example"]);
|
||||
const registryManifestPath = resolve(repoRoot, "registry/registry.json");
|
||||
const legacyManifestPath = resolve(examplesDir, "templates.json");
|
||||
|
||||
const DEFAULT_DURATION_SECONDS = 10;
|
||||
const PLACEHOLDER_DURATION = "__VIDEO_DURATION__";
|
||||
|
||||
interface LegacyTemplateEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
hint: string;
|
||||
bundled: boolean;
|
||||
}
|
||||
|
||||
interface LegacyManifest {
|
||||
templates: LegacyTemplateEntry[];
|
||||
}
|
||||
|
||||
function readLegacyManifest(): LegacyTemplateEntry[] {
|
||||
const raw = readFileSync(legacyManifestPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as LegacyManifest;
|
||||
return parsed.templates;
|
||||
}
|
||||
|
||||
function extractAttr(html: string, attr: string): string | undefined {
|
||||
const match = new RegExp(`data-${attr}="([^"]*)"`).exec(html);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
interface CanvasMeta {
|
||||
width: number;
|
||||
height: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
function probeCanvas(exampleDir: string): CanvasMeta {
|
||||
const html = readFileSync(join(exampleDir, "index.html"), "utf-8");
|
||||
const width = Number(extractAttr(html, "width") ?? 1920);
|
||||
const height = Number(extractAttr(html, "height") ?? 1080);
|
||||
const rawDuration = extractAttr(html, "duration");
|
||||
const duration =
|
||||
rawDuration === undefined || rawDuration === PLACEHOLDER_DURATION
|
||||
? DEFAULT_DURATION_SECONDS
|
||||
: Number(rawDuration);
|
||||
return { width, height, duration };
|
||||
}
|
||||
|
||||
function fileTypeFor(path: string): FileType {
|
||||
if (path.endsWith(".html")) return "hyperframes:composition";
|
||||
return "hyperframes:asset";
|
||||
}
|
||||
|
||||
/** Walk the example dir and collect every tracked file (HTML + assets). */
|
||||
function collectFiles(exampleDir: string): FileTarget[] {
|
||||
const files: FileTarget[] = [];
|
||||
const walk = (dir: string): void => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(full);
|
||||
} else if (entry.isFile()) {
|
||||
// Skip the registry-item.json itself if it already exists from a
|
||||
// prior run; we're regenerating it.
|
||||
if (entry.name === "registry-item.json") continue;
|
||||
const rel = relative(exampleDir, full);
|
||||
files.push({ path: rel, target: rel, type: fileTypeFor(rel) });
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(exampleDir);
|
||||
files.sort((a, b) => a.path.localeCompare(b.path));
|
||||
return files;
|
||||
}
|
||||
|
||||
function buildItem(entry: LegacyTemplateEntry): RegistryItem {
|
||||
// The `blank` template is bundled inside the CLI package; don't generate a
|
||||
// manifest in registry/examples/ for it.
|
||||
const exampleDir = join(examplesDir, entry.id);
|
||||
const canvas = probeCanvas(exampleDir);
|
||||
const files = collectFiles(exampleDir);
|
||||
|
||||
return {
|
||||
$schema: "https://hyperframes.heygen.com/schema/registry-item.json",
|
||||
name: entry.id,
|
||||
type: "hyperframes:example",
|
||||
title: entry.label,
|
||||
description: entry.hint,
|
||||
dimensions: { width: canvas.width, height: canvas.height },
|
||||
duration: canvas.duration,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
function writeItem(item: RegistryItem): void {
|
||||
if (item.type !== "hyperframes:example") return;
|
||||
const out = join(examplesDir, item.name, "registry-item.json");
|
||||
writeFileSync(out, JSON.stringify(item, null, 2) + "\n", "utf-8");
|
||||
console.log(`wrote ${relative(repoRoot, out)}`);
|
||||
}
|
||||
|
||||
function writeRegistryManifest(items: RegistryItem[]): void {
|
||||
const manifest: RegistryManifest = {
|
||||
$schema: "https://hyperframes.heygen.com/schema/registry.json",
|
||||
name: "hyperframes",
|
||||
homepage: "https://hyperframes.heygen.com",
|
||||
items: items.map((item) => ({ name: item.name, type: item.type })),
|
||||
};
|
||||
writeFileSync(registryManifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
|
||||
console.log(`wrote ${relative(repoRoot, registryManifestPath)}`);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const args = process.argv.slice(2);
|
||||
const onlyIdx = args.indexOf("--only");
|
||||
const only = onlyIdx >= 0 ? args[onlyIdx + 1] : undefined;
|
||||
|
||||
const legacy = readLegacyManifest();
|
||||
// Skip bundled templates (e.g. `blank`) — they live inside the CLI package,
|
||||
// not under registry/examples/.
|
||||
const onDisk = legacy.filter((t) => !t.bundled);
|
||||
const filtered = only ? onDisk.filter((t) => t.id === only) : onDisk;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
console.error(
|
||||
only
|
||||
? `No example matches --only ${only}. Available: ${onDisk.map((t) => t.id).join(", ")}`
|
||||
: "No examples found in registry/examples/templates.json",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const items: RegistryItem[] = [];
|
||||
for (const entry of filtered) {
|
||||
const exampleDir = join(examplesDir, entry.id);
|
||||
try {
|
||||
statSync(exampleDir);
|
||||
} catch {
|
||||
console.warn(`skip ${entry.id}: directory not found at ${relative(repoRoot, exampleDir)}`);
|
||||
continue;
|
||||
}
|
||||
const item = buildItem(entry);
|
||||
writeItem(item);
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
// Only rewrite the top-level manifest on a full-run (not --only).
|
||||
if (!only) {
|
||||
writeRegistryManifest(items);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user