feat(core): add registry schema + TS types (#252)

* feat(core): add registry schema + TS types

PR 1/17 of the catalog system rollout. Foundation for a shadcn-style
registry with three item tiers: examples (full projects), blocks
(sub-compositions), components (effect snippets).

## What

- TS types: RegistryItem (discriminated union of ExampleItem/BlockItem/
  ComponentItem), RegistryManifest, FileTarget, ItemType, FileType
- JSON Schemas: schemas/registry.json, schemas/registry-item.json
- Compile-time exhaustiveness asserts on ITEM_TYPES/FILE_TYPES so adding
  to the TS union without updating the constant stops compiling
- Drift-guard test: schema enums must equal ITEM_TYPES/FILE_TYPES by
  set-equality; exactly 2 distinct type enums in registry-item.json
- Public API via new ./registry export path plus re-exports from root;
  schemas exposed via ./schemas/registry.json export for external tooling

## Why

- Every downstream PR (resolver, installer, hyperframes add, docs
  codegen, CI previews, skill, catalog command) builds on these types
- Getting the shape right now avoids painful migrations later

## How

- Discriminated union enforces that components do not have dimensions
  or duration and examples/blocks must have them (schema mirrors via
  if/then/else on the type discriminant)
- target path pattern rejects .. segments, Unix absolute paths, and
  Windows drive letters (defense-in-depth; CLI validates at runtime in
  PR 3)
- name pattern requires alphanumeric start and end (no trailing hyphens)
- Optional metadata: version, author, license, deprecated, minCliVersion
- additionalProperties: false on nested objects (catches typos on
  critical fields) but relaxed on top-level RegistryItem (allows
  third-party custom metadata in PR 15 custom registries)

## Test plan

- [x] Unit tests: 11 new tests covering type guards, discriminant
      narrowing, schema/TS drift guards, schema \$id sanity, optional
      metadata acceptance, and compile-time checks (via @ts-expect-error)
- [x] bun run test in packages/core: 445 passed (was 434 on main,
      +11 from this PR)
- [x] bunx oxfmt and bunx oxlint: clean
- [x] bun run typecheck: clean
- [ ] Manual testing: N/A (types + schemas only)
- [ ] Documentation updated: per-item doc pages land in PR 9 (codegen
      from these manifests); guide updates in PR 10+

## Breaking / migration

None. Pure additive — new module, new export paths, no existing
surface touched.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(core): remove version field + hyperframes:demo file type

Address review feedback from Miguel:

- Remove `version` from RegistryItemBase + schema. Per shadcn model,
  the registry is versioned by git tags, not per-item. The adversarial
  review added it; the original design doc was correct.
- Remove `hyperframes:demo` from FileType union + FILE_TYPES constant
  + schema. Demo files exist on disk for the CI preview pipeline but
  are NOT installed to user projects and should not appear in
  registry-item.json files[]. Neither shadcn nor Remotion has a
  dedicated demo file type — demos are just compositions.
- Add `required: ["type"]` to the if-condition in the schema's
  allOf discriminant (Miguel's nit — makes the condition self-
  contained)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ci): add shader-transitions to Dockerfile.test

PR #251 added packages/shader-transitions/ to the workspace but didn't
update Dockerfile.test to COPY its package.json. This caused
`bun install --frozen-lockfile` to fail in the regression Docker build:
bun saw a lockfile referencing @hyperframes/shader-transitions but the
package.json wasn't present in the container, so it wanted to remove
the entry — triggering "lockfile had changes."

Verified: Docker build passes with `--no-cache` after this fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-04-13 20:18:37 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent cb3d94c2a5
commit eb338ae859
8 changed files with 634 additions and 2 deletions
+1
View File
@@ -72,6 +72,7 @@ COPY packages/player/package.json packages/player/package.json
COPY packages/producer/package.json packages/producer/package.json
COPY packages/cli/package.json packages/cli/package.json
COPY packages/studio/package.json packages/studio/package.json
COPY packages/shader-transitions/package.json packages/shader-transitions/package.json
RUN bun install --frozen-lockfile
# Copy source
+15 -2
View File
@@ -10,6 +10,7 @@
"files": [
"dist",
"docs",
"schemas",
"README.md"
],
"type": "module",
@@ -36,7 +37,13 @@
"./text": {
"import": "./src/text/index.ts",
"types": "./src/text/index.ts"
}
},
"./registry": {
"import": "./src/registry/index.ts",
"types": "./src/registry/index.ts"
},
"./schemas/registry.json": "./schemas/registry.json",
"./schemas/registry-item.json": "./schemas/registry-item.json"
},
"publishConfig": {
"access": "public",
@@ -61,7 +68,13 @@
"./text": {
"import": "./dist/text/index.js",
"types": "./dist/text/index.d.ts"
}
},
"./registry": {
"import": "./dist/registry/index.js",
"types": "./dist/registry/index.d.ts"
},
"./schemas/registry.json": "./schemas/registry.json",
"./schemas/registry-item.json": "./schemas/registry-item.json"
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
+141
View File
@@ -0,0 +1,141 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://hyperframes.heygen.com/schema/registry-item.json",
"title": "Hyperframes Registry Item",
"description": "Manifest for a single distributable item (example, block, or component).",
"type": "object",
"required": ["name", "type", "title", "description", "files"],
"properties": {
"$schema": {
"type": "string",
"format": "uri"
},
"name": {
"type": "string",
"pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$",
"description": "Item name in kebab-case, must start and end with alphanumeric."
},
"type": {
"type": "string",
"enum": ["hyperframes:example", "hyperframes:block", "hyperframes:component"]
},
"title": {
"type": "string",
"minLength": 1
},
"description": {
"type": "string",
"minLength": 1
},
"tags": {
"type": "array",
"items": { "type": "string" }
},
"author": {
"type": "string",
"minLength": 1
},
"license": {
"type": "string",
"minLength": 1,
"description": "SPDX license identifier (e.g. \"Apache-2.0\", \"MIT\")."
},
"minCliVersion": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$",
"description": "Minimum `hyperframes` CLI version required to install this item."
},
"deprecated": {
"type": "string",
"minLength": 1,
"description": "If set, the item is deprecated; the value is the reason or migration note."
},
"dimensions": {
"type": "object",
"required": ["width", "height"],
"additionalProperties": false,
"properties": {
"width": { "type": "integer", "minimum": 1 },
"height": { "type": "integer", "minimum": 1 }
}
},
"duration": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Duration in seconds. Must be > 0."
},
"registryDependencies": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"
}
},
"files": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["path", "target", "type"],
"additionalProperties": false,
"properties": {
"path": {
"type": "string",
"minLength": 1,
"description": "Source path, relative to registry-item.json."
},
"target": {
"type": "string",
"minLength": 1,
"description": "Destination path in the user's project, relative to project root. Must not traverse outside the project (no `..` segments, no absolute paths).",
"not": {
"anyOf": [
{ "pattern": "(^|[/\\\\])\\.\\.([/\\\\]|$)" },
{ "pattern": "^[/\\\\]" },
{ "pattern": "^[A-Za-z]:[/\\\\]" }
]
}
},
"type": {
"type": "string",
"enum": [
"hyperframes:composition",
"hyperframes:asset",
"hyperframes:snippet",
"hyperframes:style",
"hyperframes:timeline"
]
}
}
}
},
"preview": {
"type": "object",
"additionalProperties": false,
"properties": {
"video": { "type": "string" },
"poster": { "type": "string" }
}
},
"relatedSkill": {
"type": "string",
"minLength": 1
}
},
"allOf": [
{
"if": {
"required": ["type"],
"properties": { "type": { "const": "hyperframes:component" } }
},
"then": {
"not": {
"anyOf": [{ "required": ["dimensions"] }, { "required": ["duration"] }]
}
},
"else": {
"required": ["dimensions", "duration"]
}
}
]
}
+45
View File
@@ -0,0 +1,45 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://hyperframes.heygen.com/schema/registry.json",
"title": "Hyperframes Registry Manifest",
"description": "Top-level manifest describing all items in a Hyperframes registry.",
"type": "object",
"required": ["name", "homepage", "items"],
"additionalProperties": false,
"properties": {
"$schema": {
"type": "string",
"format": "uri"
},
"name": {
"type": "string",
"minLength": 1,
"description": "Registry name (e.g. \"hyperframes\")."
},
"homepage": {
"type": "string",
"format": "uri",
"description": "Registry homepage URL."
},
"items": {
"type": "array",
"description": "Items in this registry. Each entry is a shorthand reference; the full item manifest lives at <type-dir>/<name>/registry-item.json.",
"items": {
"type": "object",
"required": ["name", "type"],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"pattern": "^[a-z0-9]([a-z0-9-]*[a-z0-9])?$",
"description": "Item name in kebab-case, must start and end with alphanumeric."
},
"type": {
"type": "string",
"enum": ["hyperframes:example", "hyperframes:block", "hyperframes:component"]
}
}
}
}
}
}
+23
View File
@@ -163,3 +163,26 @@ export { createGSAPFrameAdapter } from "./adapters/gsap";
// Text measurement
export { fitTextFontSize } from "./text/index.js";
export type { FitTextOptions, FitTextResult } from "./text/index.js";
// Registry
export type {
ItemType,
FileType,
FileTarget,
RegistryItemDimensions,
RegistryItemPreview,
RegistryItem,
ExampleItem,
BlockItem,
ComponentItem,
RegistryManifestEntry,
RegistryManifest,
} from "./registry/index.js";
export {
ITEM_TYPES,
FILE_TYPES,
isExampleItem,
isBlockItem,
isComponentItem,
} from "./registry/index.js";
+15
View File
@@ -0,0 +1,15 @@
export type {
ItemType,
FileType,
FileTarget,
RegistryItemDimensions,
RegistryItemPreview,
RegistryItem,
ExampleItem,
BlockItem,
ComponentItem,
RegistryManifestEntry,
RegistryManifest,
} from "./types.js";
export { ITEM_TYPES, FILE_TYPES, isExampleItem, isBlockItem, isComponentItem } from "./types.js";
+236
View File
@@ -0,0 +1,236 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import {
FILE_TYPES,
ITEM_TYPES,
isBlockItem,
isComponentItem,
isExampleItem,
type BlockItem,
type ComponentItem,
type ExampleItem,
type FileType,
type ItemType,
type RegistryItem,
type RegistryManifest,
} from "./index.js";
const here = dirname(fileURLToPath(import.meta.url));
const schemasDir = resolve(here, "..", "..", "schemas");
function readSchema(name: string): Record<string, unknown> {
const raw = readFileSync(resolve(schemasDir, name), "utf-8");
return JSON.parse(raw) as Record<string, unknown>;
}
// Walk a JSON schema and collect every `enum` array found under a property
// with the given name. Visits each node exactly once; no cycles in parsed JSON.
function collectEnums(schema: unknown, propName: string): string[][] {
const results: string[][] = [];
const visit = (node: unknown): void => {
if (!node || typeof node !== "object") return;
if (Array.isArray(node)) {
node.forEach(visit);
return;
}
const obj = node as Record<string, unknown>;
const props = obj.properties;
if (props && typeof props === "object") {
const target = (props as Record<string, unknown>)[propName];
if (target && typeof target === "object") {
const e = (target as Record<string, unknown>).enum;
if (Array.isArray(e)) results.push(e.map(String));
}
}
for (const v of Object.values(obj)) visit(v);
};
visit(schema);
return results;
}
function setKey(values: readonly string[]): string {
return [...values].sort().join("|");
}
function setEquals(a: readonly string[], b: readonly string[]): boolean {
return setKey(a) === setKey(b);
}
describe("registry types", () => {
const registrySchema = readSchema("registry.json");
const registryItemSchema = readSchema("registry-item.json");
describe("type guards", () => {
const baseFiles = [
{ path: "x.html", target: "compositions/x.html", type: "hyperframes:composition" as const },
];
const example: ExampleItem = {
name: "demo",
type: "hyperframes:example",
title: "Demo",
description: "d",
dimensions: { width: 1920, height: 1080 },
duration: 5,
files: baseFiles,
};
const block: BlockItem = {
name: "demo-block",
type: "hyperframes:block",
title: "Demo Block",
description: "d",
dimensions: { width: 1080, height: 1350 },
duration: 6,
files: baseFiles,
};
const component: ComponentItem = {
name: "demo-component",
type: "hyperframes:component",
title: "Demo Component",
description: "d",
files: baseFiles,
};
it("discriminates item types", () => {
expect(isExampleItem(example)).toBe(true);
expect(isExampleItem(block)).toBe(false);
expect(isBlockItem(block)).toBe(true);
expect(isBlockItem(component)).toBe(false);
expect(isComponentItem(component)).toBe(true);
expect(isComponentItem(example)).toBe(false);
});
it("narrows to the discriminant's shape", () => {
const items: RegistryItem[] = [example, block, component];
for (const item of items) {
if (isExampleItem(item) || isBlockItem(item)) {
// dimensions and duration are required on examples and blocks.
expect(item.dimensions.width).toBeGreaterThan(0);
expect(item.duration).toBeGreaterThan(0);
}
}
});
});
describe("constants match schema enums (drift guard)", () => {
it("registry.json has exactly one `type` enum, equal to ITEM_TYPES", () => {
const enums = collectEnums(registrySchema, "type");
expect(enums).toHaveLength(1);
expect(setEquals(enums[0]!, ITEM_TYPES)).toBe(true);
});
it("registry-item.json has exactly two `type` enums: one ITEM_TYPES, one FILE_TYPES", () => {
const enums = collectEnums(registryItemSchema, "type");
const distinct = new Set(enums.map(setKey));
// Two semantically distinct enums — the item's `type` and each file's `type`.
expect(distinct.size).toBe(2);
expect(enums.some((e) => setEquals(e, ITEM_TYPES))).toBe(true);
expect(enums.some((e) => setEquals(e, FILE_TYPES))).toBe(true);
});
});
describe("schema files", () => {
it("registry.json has the expected $id", () => {
expect(registrySchema.$id).toBe("https://hyperframes.heygen.com/schema/registry.json");
});
it("registry-item.json has the expected $id", () => {
expect(registryItemSchema.$id).toBe(
"https://hyperframes.heygen.com/schema/registry-item.json",
);
});
});
describe("type-level sanity", () => {
it("RegistryManifest accepts well-formed shape", () => {
const m: RegistryManifest = {
$schema: "https://hyperframes.heygen.com/schema/registry.json",
name: "hyperframes",
homepage: "https://hyperframes.heygen.com",
items: [
{ name: "warm-grain", type: "hyperframes:example" },
{ name: "linkedin-post-card", type: "hyperframes:block" },
{ name: "shader-wipe", type: "hyperframes:component" },
],
};
expect(m.items).toHaveLength(3);
});
it("ItemType and FileType are assignable from their constants", () => {
const _it: ItemType = ITEM_TYPES[0];
const _ft: FileType = FILE_TYPES[0];
expect(_it).toBeDefined();
expect(_ft).toBeDefined();
});
it("components cannot carry dimensions or duration (compile-time)", () => {
// @ts-expect-error — ComponentItem forbids `dimensions`.
const _bad1: ComponentItem = {
name: "bad",
type: "hyperframes:component",
title: "Bad",
description: "d",
files: [],
dimensions: { width: 1, height: 1 },
};
// @ts-expect-error — ComponentItem forbids `duration`.
const _bad2: ComponentItem = {
name: "bad",
type: "hyperframes:component",
title: "Bad",
description: "d",
files: [],
duration: 1,
};
void _bad1;
void _bad2;
expect(true).toBe(true);
});
it("examples and blocks require dimensions and duration (compile-time)", () => {
// @ts-expect-error — ExampleItem requires `dimensions`.
const _bad1: ExampleItem = {
name: "bad",
type: "hyperframes:example",
title: "Bad",
description: "d",
duration: 5,
files: [],
};
// @ts-expect-error — BlockItem requires `duration`.
const _bad2: BlockItem = {
name: "bad",
type: "hyperframes:block",
title: "Bad",
description: "d",
dimensions: { width: 1, height: 1 },
files: [],
};
void _bad1;
void _bad2;
expect(true).toBe(true);
});
it("optional metadata fields are accepted (author, license, deprecated, minCliVersion)", () => {
const item: ComponentItem = {
name: "shader-wipe",
type: "hyperframes:component",
title: "Shader Wipe",
description: "d",
author: "heygen",
license: "Apache-2.0",
minCliVersion: "0.4.0",
deprecated: "Use `shader-wipe-v2` instead.",
files: [
{
path: "shader-wipe.html",
target: "compositions/components/shader-wipe/shader-wipe.html",
type: "hyperframes:snippet",
},
],
};
expect(item.author).toBe("heygen");
});
});
});
+158
View File
@@ -0,0 +1,158 @@
// The `enum` arrays in `packages/core/schemas/registry*.json` must match
// `ITEM_TYPES` / `FILE_TYPES` below — `types.test.ts` is the drift guard.
/** Top-level classification for a registry item. */
export type ItemType = "hyperframes:example" | "hyperframes:block" | "hyperframes:component";
/** File-level classification, drives installer behavior. */
export type FileType =
| "hyperframes:composition"
| "hyperframes:asset"
| "hyperframes:snippet"
| "hyperframes:style"
| "hyperframes:timeline";
/** A single file to install as part of a registry item. */
export interface FileTarget {
/** Path to the source file, relative to the item's `registry-item.json`. */
path: string;
/** Destination path in the user's project, relative to the project root. */
target: string;
/** File type — controls how the installer treats this file. */
type: FileType;
}
export interface RegistryItemDimensions {
width: number;
height: number;
}
export interface RegistryItemPreview {
/** Path or URL to the preview video (looping mp4). */
video?: string;
/** Path or URL to the preview poster image. */
poster?: string;
}
/** Fields common to every registry item, regardless of type. */
interface RegistryItemBase {
/** JSON Schema URL — `https://hyperframes.heygen.com/schema/registry-item.json`. */
$schema?: string;
/** Item name in kebab-case, unique within a registry. */
name: string;
/** Short human-readable title. */
title: string;
/** One-line description. */
description: string;
/** Filter tags (e.g. `["social", "portrait", "card"]`). */
tags?: string[];
/** Item author / maintainer. */
author?: string;
/** SPDX license identifier. */
license?: string;
/** Minimum `hyperframes` CLI version required to install this item (semver). */
minCliVersion?: string;
/** If set, the item is deprecated; the value is the reason or migration note. */
deprecated?: string;
/** Names of other registry items this item depends on. */
registryDependencies?: string[];
/** Files to install. Must be non-empty. */
files: FileTarget[];
/** Optional preview media. */
preview?: RegistryItemPreview;
/** Related skill slug (e.g. `hyperframes-captions`) — shown in docs. */
relatedSkill?: string;
}
/** Full-project example — scaffolded by `hyperframes init --example <name>`. */
export interface ExampleItem extends RegistryItemBase {
type: "hyperframes:example";
/** Canvas dimensions (required for examples). */
dimensions: RegistryItemDimensions;
/** Duration in seconds (required for examples). */
duration: number;
}
/** Sub-composition block — installed by `hyperframes add <name>`. */
export interface BlockItem extends RegistryItemBase {
type: "hyperframes:block";
/** Canvas dimensions (required for blocks — they are standalone compositions). */
dimensions: RegistryItemDimensions;
/** Duration in seconds (required for blocks). */
duration: number;
}
/** Effect / snippet — merged into an existing composition. */
export interface ComponentItem extends RegistryItemBase {
type: "hyperframes:component";
/** Components have no intrinsic dimensions — they inherit from the host composition. */
dimensions?: never;
/** Components have no intrinsic duration — they inherit from the host composition. */
duration?: never;
}
/**
* A registry item — the unit of distribution. Stored on disk as
* `registry/<examples|blocks|components>/<name>/registry-item.json`.
*/
export type RegistryItem = ExampleItem | BlockItem | ComponentItem;
/** Shorthand reference used in the top-level `registry.json` items array. */
export interface RegistryManifestEntry {
name: string;
type: ItemType;
}
/** The top-level `registry.json` manifest. */
export interface RegistryManifest {
/** JSON Schema URL — `https://hyperframes.heygen.com/schema/registry.json`. */
$schema?: string;
/** Registry name (e.g. `hyperframes`). */
name: string;
/** Registry homepage URL. */
homepage: string;
/** Items in this registry. */
items: RegistryManifestEntry[];
}
// ── Constants (kept in sync with JSON Schema enums) ─────────────────────────
export const ITEM_TYPES = [
"hyperframes:example",
"hyperframes:block",
"hyperframes:component",
] as const satisfies readonly ItemType[];
export const FILE_TYPES = [
"hyperframes:composition",
"hyperframes:asset",
"hyperframes:snippet",
"hyperframes:style",
"hyperframes:timeline",
] as const satisfies readonly FileType[];
// 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.)
type _AssertItemTypesExhaustive =
Exclude<ItemType, (typeof ITEM_TYPES)[number]> extends never ? true : never;
type _AssertFileTypesExhaustive =
Exclude<FileType, (typeof FILE_TYPES)[number]> extends never ? true : never;
const _itemTypesExhaustive: _AssertItemTypesExhaustive = true;
const _fileTypesExhaustive: _AssertFileTypesExhaustive = true;
void _itemTypesExhaustive;
void _fileTypesExhaustive;
// ── Type guards ─────────────────────────────────────────────────────────────
export function isExampleItem(item: RegistryItem): item is ExampleItem {
return item.type === "hyperframes:example";
}
export function isBlockItem(item: RegistryItem): item is BlockItem {
return item.type === "hyperframes:block";
}
export function isComponentItem(item: RegistryItem): item is ComponentItem {
return item.type === "hyperframes:component";
}