chore: import upstream snapshot with attribution
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Docs / Validate docs (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Sync skills to ClawHub / Publish changed skills (push) Waiting to run
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:35 +08:00
commit 85453da49f
4031 changed files with 710987 additions and 0 deletions
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env tsx
/**
* Backfill preview URLs in registry block + component manifests.
*
* - Blocks: adds `preview: { video, poster }` using the deterministic CDN pattern
* - Components: normalizes bare-string `preview` to `{ video }` object format
*
* Usage:
* npx tsx scripts/backfill-block-previews.ts # all items
* npx tsx scripts/backfill-block-previews.ts --dry-run # preview changes only
*/
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(scriptDir, "..");
const registryDir = resolve(repoRoot, "registry");
const CDN_BASE = "https://static.heygen.ai/hyperframes-oss/docs/images/catalog";
const dryRun = process.argv.includes("--dry-run");
interface Preview {
video?: string;
poster?: string;
}
let updated = 0;
let skipped = 0;
function tryReadManifest(manifestPath: string): Record<string, unknown> | null {
try {
return JSON.parse(readFileSync(manifestPath, "utf-8"));
} catch {
return null;
}
}
function writeManifest(manifestPath: string, manifest: Record<string, unknown>): void {
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
}
function backfillBlocks() {
const blocksDir = join(registryDir, "blocks");
let entries: ReturnType<typeof readdirSync<string>>;
try {
entries = readdirSync(blocksDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const manifestPath = join(blocksDir, entry.name, "registry-item.json");
const manifest = tryReadManifest(manifestPath);
if (!manifest) continue;
const preview: Preview = {
video: `${CDN_BASE}/blocks/${entry.name}.mp4`,
poster: `${CDN_BASE}/blocks/${entry.name}.png`,
};
const existing = manifest.preview as Preview | undefined;
if (existing?.video === preview.video && existing?.poster === preview.poster) {
skipped++;
continue;
}
manifest.preview = preview;
if (dryRun) {
console.log(`[dry-run] ${entry.name}: would set preview →`, preview);
} else {
writeManifest(manifestPath, manifest);
}
updated++;
}
}
function normalizeComponents() {
const componentsDir = join(registryDir, "components");
let entries: ReturnType<typeof readdirSync<string>>;
try {
entries = readdirSync(componentsDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const manifestPath = join(componentsDir, entry.name, "registry-item.json");
const manifest = tryReadManifest(manifestPath);
if (!manifest) continue;
if (!manifest.preview) continue;
if (typeof manifest.preview === "string") {
manifest.preview = { video: manifest.preview };
if (dryRun) {
console.log(
`[dry-run] ${entry.name}: normalize string → { video: "${(manifest.preview as Preview).video}" }`,
);
} else {
writeManifest(manifestPath, manifest);
}
updated++;
} else {
skipped++;
}
}
}
backfillBlocks();
normalizeComponents();
console.log(
`\n${dryRun ? "[dry-run] " : ""}Done: ${updated} updated, ${skipped} already up-to-date`,
);
+79
View File
@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createWeeklyDraft, parseWeeklyOptions, weeklyPacketPaths } from "./changelog-weekly.ts";
import { parseCommit, type RawCommit } from "./draft-changelog.ts";
function commit(subject: string) {
const raw: RawCommit = {
sha: "1234567890abcdef1234567890abcdef12345678",
shortSha: "1234567",
author: "Test Author",
subject,
};
return {
...parseCommit(raw),
date: "2026-06-03",
};
}
describe("weekly changelog arguments", () => {
it("parses date range and write flags", () => {
assert.deepEqual(
parseWeeklyOptions(["--from", "2026-06-01", "--to=2026-06-07", "--write", "--force"]),
{
from: "2026-06-01",
to: "2026-06-07",
write: true,
force: true,
},
);
});
});
describe("weekly changelog rendering", () => {
it("creates docs, source, Discord, and X drafts", () => {
const draft = createWeeklyDraft(
{
from: "2026-06-01",
to: "2026-06-07",
write: false,
force: false,
},
[commit("feat(cli): add render hints (#42)"), commit("fix: repair playback")],
);
assert.match(draft.docsUpdate, /label="Week of June 1, 2026"/);
assert.match(draft.weeklyNotes, /HyperFrames weekly digest - June 1, 2026 - June 7, 2026/);
assert.match(draft.discordDraft, /This week's highlights:/);
assert.match(draft.xDraft, /Full update: TODO add docs link/);
});
it("keeps internal and editorial-only changes out of top highlights", () => {
const draft = createWeeklyDraft(
{
from: "2026-06-01",
to: "2026-06-07",
write: false,
force: false,
},
[
commit("feat(docs): add changelog release workflow (#41)"),
commit("fix(cli): validate cloud render input (#42)"),
commit("chore: update generated baselines (#43)"),
],
);
assert.match(draft.discordDraft, /CLI: Validate cloud render input/);
assert.doesNotMatch(draft.discordDraft, /Docs: Add changelog release workflow/);
assert.doesNotMatch(draft.docsUpdate, /Update generated baselines/);
});
it("uses predictable packet paths from the week ending date", () => {
assert.deepEqual(weeklyPacketPaths("2026-06-07"), {
weeklyNotes: "updates/weekly/2026-06-07.md",
discordDraft: "updates/social/2026-06-07.discord.md",
xDraft: "updates/social/2026-06-07.x.md",
});
});
});
+555
View File
@@ -0,0 +1,555 @@
#!/usr/bin/env tsx
import { execFileSync } from "child_process";
import { mkdirSync, readFileSync, writeFileSync } from "fs";
import { join } from "path";
import { pathToFileURL } from "url";
import { parseMappedArgument, validateCliDate, type InlineValueOption } from "./cli-options.ts";
import {
escapeForMdx,
formatScope,
parseCommit,
shouldSkipCommit,
type ParsedCommit,
type RawCommit,
} from "./draft-changelog.ts";
const ROOT = join(import.meta.dirname, "..");
const REPO_URL = "https://github.com/heygen-com/hyperframes";
const DOCS_MARKER =
"{/* New weekly digest entries are prepended by `bun run changelog:weekly --from YYYY-MM-DD --to YYYY-MM-DD --write`. */}";
const WEEKLY_REVIEW_TODO = "<!-- TODO: review and rewrite before publishing. -->";
const CATEGORY_ORDER = [
"Breaking Changes",
"Features",
"Fixes",
"Performance",
"Catalog",
"Docs & Examples",
"Other Changes",
"Internal",
];
const HIGHLIGHT_CATEGORIES = new Set([
"Breaking Changes",
"Features",
"Fixes",
"Performance",
"Catalog",
]);
const MONTHS = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
type WeeklyOptions = {
from: string;
to: string;
write: boolean;
force: boolean;
};
type MutableWeeklyOptions = Partial<WeeklyOptions>;
type WeeklyCommit = ParsedCommit & {
date: string;
};
type WeeklyDraft = {
docsUpdate: string;
weeklyNotes: string;
discordDraft: string;
xDraft: string;
};
type ValueOptionKey = "from" | "to";
type BooleanOptionKey = "write" | "force";
const VALUE_OPTIONS = new Map<string, ValueOptionKey>([
["--from", "from"],
["--to", "to"],
]);
const BOOLEAN_OPTIONS = new Map<string, BooleanOptionKey>([
["--write", "write"],
["--force", "force"],
]);
const INLINE_VALUE_OPTIONS = [
{ prefix: "--from=", key: "from" },
{ prefix: "--to=", key: "to" },
] satisfies Array<InlineValueOption<ValueOptionKey>>;
function main() {
const options = parseWeeklyOptions(process.argv.slice(2));
const commits = getWeeklyCommits(options);
const draft = createWeeklyDraft(options, commits);
outputWeeklyDraft(options, draft);
}
export function parseWeeklyOptions(args: string[]): WeeklyOptions {
const parsed = createDefaultOptions();
for (let index = 0; index < args.length; index += 1) {
index = parseArgument(args, index, parsed);
}
return finalizeOptions(parsed);
}
function createDefaultOptions(): MutableWeeklyOptions {
return {
write: false,
force: false,
};
}
function parseArgument(args: string[], index: number, parsed: MutableWeeklyOptions) {
const arg = args[index];
if (arg === "--help" || arg === "-h") {
printUsage();
process.exit(0);
}
return parseMappedArgument(args, index, parsed, {
inlineValueOptions: INLINE_VALUE_OPTIONS,
valueOptions: VALUE_OPTIONS,
booleanOptions: BOOLEAN_OPTIONS,
parsePositional: (positional) => fail(`Unexpected positional argument: ${positional}`),
fail,
});
}
function finalizeOptions(parsed: MutableWeeklyOptions): WeeklyOptions {
const { from, to } = requireDateRange(parsed);
return {
from,
to,
write: parsed.write ?? false,
force: parsed.force ?? false,
};
}
function requireDateRange(parsed: MutableWeeklyOptions) {
if (!parsed.from || !parsed.to) {
printUsage();
process.exit(1);
}
validateDateRange(parsed.from, parsed.to);
return {
from: parsed.from,
to: parsed.to,
};
}
function validateDateRange(from: string, to: string) {
validateCliDate(from, fail);
validateCliDate(to, fail);
if (from > to) {
fail(`Invalid date range: --from ${from} is after --to ${to}.`);
}
}
function getWeeklyCommits(options: WeeklyOptions): WeeklyCommit[] {
return getCommits(options.from, options.to)
.filter((commit) => !shouldSkipCommit(commit))
.map((commit) => ({
...parseCommit(commit),
date: commit.date,
}))
.sort(compareCommitsForDigest);
}
type RawWeeklyCommit = RawCommit & {
date: string;
};
function getCommits(from: string, to: string): RawWeeklyCommit[] {
const output = git([
"log",
"--format=%H%x09%h%x09%an%x09%cs%x09%s",
"--no-merges",
`--since=${from}T00:00:00`,
`--until=${to}T23:59:59`,
]);
if (!output) {
return [];
}
return output.split("\n").map(parseGitLogLine);
}
function parseGitLogLine(line: string): RawWeeklyCommit {
const [sha = "", shortSha = "", author = "", date = "", ...subjectParts] = line.split("\t");
return {
sha,
shortSha,
author,
date,
subject: subjectParts.join("\t"),
};
}
export function createWeeklyDraft(options: WeeklyOptions, commits: WeeklyCommit[]): WeeklyDraft {
const range = formatDateRange(options.from, options.to);
const highlights = selectHighlights(commits);
return {
docsUpdate: renderDocsUpdate(options, range, commits, highlights),
weeklyNotes: renderWeeklyNotes(options, range, commits, highlights),
discordDraft: renderDiscordDraft(range, highlights),
xDraft: renderXDraft(range, highlights),
};
}
function outputWeeklyDraft(options: WeeklyOptions, draft: WeeklyDraft) {
if (!options.write) {
console.log(draft.weeklyNotes);
console.log("\n--- Discord draft ---\n");
console.log(draft.discordDraft);
console.log("\n--- X draft ---\n");
console.log(draft.xDraft);
console.log("\n--- Mintlify update block ---\n");
console.log(draft.docsUpdate);
console.log(
"\nRun with --write to create the weekly digest packet and prepend the docs entry.",
);
return;
}
writeWeeklyPacket(options, draft);
prependDocsUpdate(options, draft.docsUpdate);
}
function writeWeeklyPacket(options: WeeklyOptions, draft: WeeklyDraft) {
const paths = weeklyPacketPaths(options.to);
mkdirSync(join(ROOT, "updates", "weekly"), { recursive: true });
mkdirSync(join(ROOT, "updates", "social"), { recursive: true });
writeFile(paths.weeklyNotes, draft.weeklyNotes, options.force);
writeFile(paths.discordDraft, draft.discordDraft, options.force);
writeFile(paths.xDraft, draft.xDraft, options.force);
}
export function weeklyPacketPaths(date: string) {
return {
weeklyNotes: join("updates", "weekly", `${date}.md`),
discordDraft: join("updates", "social", `${date}.discord.md`),
xDraft: join("updates", "social", `${date}.x.md`),
};
}
function writeFile(relativePath: string, contents: string, force: boolean) {
try {
writeFileSync(join(ROOT, relativePath), `${contents}\n`, { flag: force ? "w" : "wx" });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
fail(`${relativePath} already exists. Pass --force to overwrite it before review.`);
}
throw error;
}
console.log(`Wrote ${relativePath}`);
}
function prependDocsUpdate(options: WeeklyOptions, docsUpdate: string) {
const weeklyUpdatesPath = join(ROOT, "docs", "weekly-updates.mdx");
const weeklyUpdates = readFileSync(weeklyUpdatesPath, "utf-8");
const label = weeklyLabel(options.from);
if (weeklyUpdates.includes(`label="${label}"`)) {
console.log(`docs/weekly-updates.mdx already has ${label}; leaving it unchanged.`);
return;
}
if (!weeklyUpdates.includes(DOCS_MARKER)) {
fail(`Could not find insertion marker in ${weeklyUpdatesPath}`);
}
const updated = weeklyUpdates.replace(DOCS_MARKER, `${DOCS_MARKER}\n\n${docsUpdate}`);
writeFileSync(weeklyUpdatesPath, updated);
console.log(`Prepended ${label} to ${weeklyUpdatesPath}`);
}
function renderDocsUpdate(
options: WeeklyOptions,
range: string,
commits: WeeklyCommit[],
highlights: WeeklyCommit[],
) {
const alsoNotable = selectAlsoNotable(commits, highlights);
return [
"<Update",
` label="${weeklyLabel(options.from)}"`,
` description="Weekly digest - ${range}"`,
` tags={["Weekly update", "Highlights"]}`,
">",
WEEKLY_REVIEW_TODO,
"",
"A curated summary of the most important HyperFrames changes this week.",
"",
renderHighlights(highlights, renderMdxWeeklyBullet),
"",
renderAlsoNotable(alsoNotable, renderMdxWeeklyBullet),
"",
"For exact versioned release notes, see the [Changelog](/changelog).",
"</Update>",
].join("\n");
}
function renderWeeklyNotes(
options: WeeklyOptions,
range: string,
commits: WeeklyCommit[],
highlights: WeeklyCommit[],
) {
return [
`# HyperFrames weekly digest - ${range}`,
"",
WEEKLY_REVIEW_TODO,
"",
"This digest is the editable source for the docs weekly update and social drafts.",
"",
"## Highlights",
"",
renderListOrEmpty(highlights, renderMarkdownWeeklyBullet),
"",
"## Full draft",
"",
renderGroupedChanges(commits, renderMarkdownWeeklyBullet),
"",
"## Publishing checklist",
"",
"- Remove the TODO marker after review.",
"- Run this from an up-to-date `main` branch when drafting the real weekly update.",
"- Keep the docs entry in `docs/weekly-updates.mdx` aligned with this source file.",
"- Edit the Discord and X drafts before posting.",
"- Add screenshots, rendered clips, or catalog links where they make the update clearer.",
"",
`Range: ${options.from} through ${options.to}.`,
].join("\n");
}
function renderDiscordDraft(range: string, highlights: WeeklyCommit[]) {
return [
`# HyperFrames weekly update - ${range}`,
"",
WEEKLY_REVIEW_TODO,
"",
"This week's highlights:",
"",
renderListOrEmpty(highlights, renderPlainWeeklyBullet),
"",
"Read the full update: TODO add docs link after publishing.",
].join("\n");
}
function renderXDraft(range: string, highlights: WeeklyCommit[]) {
const threadItems =
highlights.length > 0
? highlights.map((commit, index) => `${index + 1}. ${plainWeeklySummary(commit)}`)
: ["TODO: add the most important user-facing highlights from this week."];
return [
`HyperFrames weekly update - ${range}`,
"",
WEEKLY_REVIEW_TODO,
"",
...threadItems,
"",
"Full update: TODO add docs link after publishing.",
].join("\n");
}
function renderHighlights(
highlights: WeeklyCommit[],
renderBullet: (commit: WeeklyCommit) => string,
) {
return ["## Highlights", "", renderListOrEmpty(highlights, renderBullet)].join("\n");
}
function renderAlsoNotable(
commits: WeeklyCommit[],
renderBullet: (commit: WeeklyCommit) => string,
) {
if (commits.length === 0) {
return "## Also notable\n\n- TODO: add any supporting changes worth mentioning.";
}
return ["## Also notable", "", commits.map(renderBullet).join("\n")].join("\n");
}
function renderGroupedChanges(
commits: WeeklyCommit[],
renderBullet: (commit: WeeklyCommit) => string,
) {
if (commits.length === 0) {
return "No notable changes were found in the selected date range.";
}
return CATEGORY_ORDER.flatMap((category) => {
const categoryCommits = commits.filter((commit) => commit.category === category);
if (categoryCommits.length === 0) {
return [];
}
return [`## ${category}`, "", ...categoryCommits.map(renderBullet), ""];
})
.join("\n")
.trim();
}
function renderListOrEmpty(
commits: WeeklyCommit[],
renderBullet: (commit: WeeklyCommit) => string,
) {
if (commits.length === 0) {
return "- TODO: add the most important user-facing highlights from this week.";
}
return commits.map(renderBullet).join("\n");
}
function renderMarkdownWeeklyBullet(commit: WeeklyCommit) {
const scope = commit.scope ? `**${formatScope(commit.scope)}:** ` : "";
return `- ${scope}${capitalize(commit.summary)} (${commitLinks(commit).join(", ")})`;
}
function renderMdxWeeklyBullet(commit: WeeklyCommit) {
const scope = commit.scope ? `**${escapeForMdx(formatScope(commit.scope))}:** ` : "";
return `- ${scope}${escapeForMdx(capitalize(commit.summary))} (${commitLinks(commit).join(", ")})`;
}
function renderPlainWeeklyBullet(commit: WeeklyCommit) {
return `- ${plainWeeklySummary(commit)}`;
}
function plainWeeklySummary(commit: WeeklyCommit) {
const scope = commit.scope ? `${formatScope(commit.scope)}: ` : "";
return `${scope}${capitalize(commit.summary)}`;
}
function commitLinks(commit: WeeklyCommit) {
const links = [`[${commit.shortSha}](${REPO_URL}/commit/${commit.sha})`];
if (commit.prNumber) {
links.push(`[#${commit.prNumber}](${REPO_URL}/pull/${commit.prNumber})`);
}
return links;
}
function selectHighlights(commits: WeeklyCommit[]) {
const highlighted = commits.filter(isHighImpactCandidate);
const fallback = commits.filter((commit) => commit.category !== "Internal");
return (highlighted.length > 0 ? highlighted : fallback).slice(0, 5);
}
function selectAlsoNotable(commits: WeeklyCommit[], highlights: WeeklyCommit[]) {
const highlightedShas = new Set(highlights.map((commit) => commit.sha));
return commits
.filter((commit) => commit.category !== "Internal")
.filter((commit) => !highlightedShas.has(commit.sha))
.slice(0, 5);
}
function isHighImpactCandidate(commit: WeeklyCommit) {
return HIGHLIGHT_CATEGORIES.has(commit.category) && !isEditorialOnlyScope(commit.scope);
}
function isEditorialOnlyScope(scope: string | undefined) {
if (!scope) {
return false;
}
return ["docs", "readme", "skills"].includes(scope.toLowerCase());
}
function compareCommitsForDigest(a: WeeklyCommit, b: WeeklyCommit) {
const categoryDelta = categoryRank(a.category) - categoryRank(b.category);
if (categoryDelta !== 0) {
return categoryDelta;
}
return b.date.localeCompare(a.date);
}
function categoryRank(category: string) {
const index = CATEGORY_ORDER.indexOf(category);
return index === -1 ? CATEGORY_ORDER.length : index;
}
function weeklyLabel(from: string) {
return `Week of ${formatDate(from)}`;
}
function formatDateRange(from: string, to: string) {
return `${formatDate(from)} - ${formatDate(to)}`;
}
function formatDate(date: string) {
const { year, month, day } = dateParts(date);
return `${MONTHS[month - 1]} ${day}, ${year}`;
}
function dateParts(date: string) {
const [year = "0", month = "0", day = "0"] = date.split("-");
return {
year,
month: Number(month),
day: Number(day),
};
}
function capitalize(value: string) {
if (!value) {
return value;
}
return value[0].toUpperCase() + value.slice(1);
}
function git(args: string[]) {
return execFileSync("git", args, {
cwd: ROOT,
encoding: "utf-8",
}).trim();
}
function printUsage() {
console.log(`changelog:weekly drafts an editable weekly digest packet.
Usage:
bun run changelog:weekly --from YYYY-MM-DD --to YYYY-MM-DD [--write] [--force]
Examples:
bun run changelog:weekly --from 2026-06-01 --to 2026-06-07
bun run changelog:weekly --from 2026-06-01 --to 2026-06-07 --write
bun run changelog:weekly --from 2026-06-01 --to 2026-06-07 --write --force
`);
}
function fail(message: string): never {
console.error(`changelog:weekly: ${message}`);
process.exit(1);
}
function isDirectRun(scriptPath: string | undefined) {
return scriptPath ? import.meta.url === pathToFileURL(scriptPath).href : false;
}
if (isDirectRun(process.argv[1])) {
main();
}
+84
View File
@@ -0,0 +1,84 @@
#!/bin/sh
# Reject large binaries committed straight into the git pack instead of LFS.
#
# Why this exists: the repo's history carries hundreds of MB of binaries that
# should have been LFS — a 31 MB ONNX model, nested HDR-regression MP4s that
# dodged non-recursive .gitattributes globs, demo clips, scratch renders. Each
# was "noticed later and deleted," but a raw commit lives in history forever and
# every clone pays for it. This hook stops the next one at commit time.
#
# Rule: any staged file larger than $MAX_KB that is NOT routed through Git LFS
# fails the commit. Fix by either adding an LFS pattern in .gitattributes for
# that path/extension, or not committing the file (assets/, gitignore, etc.).
#
# Usage:
# check-large-files.sh # default: check the staged file set
# check-large-files.sh <file> [<file>] # explicit files (handy for testing)
#
# We read the staged set ourselves rather than taking lefthook's {staged_files}
# expansion: that expands to a bare space-separated string, which splits paths
# containing spaces into separate args. `git diff --cached` + a line-based read
# keeps whole paths intact (only a literal newline in a filename would break it,
# which git quotes/escapes anyway).
set -u
MAX_KB="${HF_MAX_NONLFS_KB:-500}"
# Emit the list of paths to check, one per line.
list_files() {
if [ "$#" -gt 0 ]; then
printf '%s\n' "$@"
else
# Added/Copied/Modified/Renamed staged paths (skip Deleted — nothing to size).
git diff --cached --name-only --diff-filter=ACMR
fi
}
violations="$(mktemp)"
trap 'rm -f "$violations"' EXIT INT TERM
list_files "$@" | while IFS= read -r f; do
[ -n "$f" ] || continue
# Skip symlinks: `wc -c` would measure the link *target's* bytes, so a symlink
# to a large LFS-tracked asset could be flagged even though the real blob is a
# tiny pointer. Symlinks themselves are never the bloat we're hunting.
[ -L "$f" ] && continue
[ -f "$f" ] || continue
# registry/ intentionally ships raw binary assets (block backgrounds, avatar
# PNGs, .glb models, audio) so installed blocks stay portable without an LFS
# round-trip. Those are the product, not accidental bloat — skip them here.
case "$f" in registry/*) continue ;; esac
bytes="$(wc -c < "$f" | tr -d ' ')"
# Ceiling division: a sub-1024-byte file must report >=1 KB, never 0, so it
# can't slip past a strict threshold (e.g. HF_MAX_NONLFS_KB=0). Plain
# `bytes / 1024` would round a 512-byte binary down to 0 and pass it.
kb=$(( (bytes + 1023) / 1024 ))
[ "$kb" -le "$MAX_KB" ] && continue
# Is this path routed through LFS? `git check-attr` reads .gitattributes.
filter="$(git check-attr filter -- "$f" | sed 's/.*: //')"
[ "$filter" = "lfs" ] && continue
printf '%s\t%s\n' "$kb" "$f" >> "$violations"
done
# `while` ran in a pipeline subshell, so it couldn't set a parent-shell flag —
# the violations file is the durable signal.
if [ -s "$violations" ]; then
echo "ERROR: large binaries are being committed to git instead of LFS." >&2
echo " (limit: ${MAX_KB} KB — override per-commit with HF_MAX_NONLFS_KB)" >&2
echo >&2
while IFS=' ' read -r kb f; do
echo "${f} (${kb} KB)" >&2
done < "$violations"
echo >&2
echo "Fix: add an LFS pattern for it in .gitattributes, e.g." >&2
echo " path/to/**/*.ext filter=lfs diff=lfs merge=lfs -text" >&2
echo " then re-stage the file. Or, if it should not be committed at all," >&2
echo " add it to .gitignore." >&2
exit 1
fi
+48
View File
@@ -0,0 +1,48 @@
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const FORBIDDEN_BASENAMES = new Set([".DS_Store"]);
export function isForbiddenTrackedPath(filePath) {
const normalized = filePath.replaceAll("\\", "/");
const segments = normalized.split("/");
return segments.includes("node_modules") || FORBIDDEN_BASENAMES.has(segments.at(-1));
}
export function findForbiddenTrackedPaths(filePaths) {
return filePaths.filter(isForbiddenTrackedPath).sort();
}
export function readTrackedPaths(cwd = process.cwd()) {
const result = spawnSync("git", ["ls-files", "-z"], {
cwd,
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
});
if (result.error) throw result.error;
if (result.status !== 0) {
throw new Error(result.stderr.trim() || `git ls-files exited with status ${result.status}`);
}
return result.stdout.split("\0").filter(Boolean);
}
export function checkTrackedArtifacts(cwd = process.cwd()) {
return findForbiddenTrackedPaths(readTrackedPaths(cwd));
}
function main() {
const forbidden = checkTrackedArtifacts();
if (forbidden.length === 0) {
console.log("Tracked artifact check passed.");
return;
}
console.error("Forbidden generated artifacts are tracked by Git:");
for (const filePath of forbidden) console.error(`- ${filePath}`);
console.error("Remove these paths from the index; .gitignore already excludes them.");
process.exitCode = 1;
}
const entryPath = process.argv[1] ? resolve(process.argv[1]) : null;
if (entryPath === fileURLToPath(import.meta.url)) main();
+32
View File
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { findForbiddenTrackedPaths, isForbiddenTrackedPath } from "./check-tracked-artifacts.mjs";
describe("tracked artifact check", () => {
it("rejects node_modules at any directory depth", () => {
assert.equal(isForbiddenTrackedPath("node_modules/pkg/index.js"), true);
assert.equal(isForbiddenTrackedPath("packages/producer/node_modules/pkg"), true);
assert.equal(isForbiddenTrackedPath("packages\\producer\\node_modules\\pkg"), true);
});
it("rejects platform metadata by basename", () => {
assert.equal(isForbiddenTrackedPath(".DS_Store"), true);
assert.equal(isForbiddenTrackedPath("packages/producer/tests/.DS_Store"), true);
});
it("does not reject similarly named source paths", () => {
assert.equal(isForbiddenTrackedPath("docs/node_modules-policy.md"), false);
assert.equal(isForbiddenTrackedPath("packages/producer/src/DS_Store.ts"), false);
});
it("returns a deterministic sorted list", () => {
assert.deepEqual(
findForbiddenTrackedPaths([
"packages/z/.DS_Store",
"packages/producer/src/index.ts",
"packages/a/node_modules/pkg",
]),
["packages/a/node_modules/pkg", "packages/z/.DS_Store"],
);
});
});
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env node
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = join(import.meta.dirname, "..");
export const REQUIRED_WORKSPACE_SCRIPTS = ["build", "typecheck", "test"];
function hasExecutableScript(pkg, script) {
return typeof pkg.scripts?.[script] === "string" && pkg.scripts[script].trim().length > 0;
}
function hasSpecificReason(reason) {
return typeof reason === "string" ? reason.trim().length >= 20 : false;
}
function hasDocumentedOptOut(pkg, script) {
const optOut = pkg.hyperframesWorkspaceContract?.[script];
return optOut?.optOut === true && hasSpecificReason(optOut.reason);
}
export function listWorkspaceContractIssues(workspace, pkg) {
return REQUIRED_WORKSPACE_SCRIPTS.flatMap((script) => {
if (hasExecutableScript(pkg, script) || hasDocumentedOptOut(pkg, script)) return [];
return [
`${workspace}: missing executable \`${script}\` script or ` +
`hyperframesWorkspaceContract.${script} opt-out with a specific reason`,
];
});
}
export function listWorkspacePackages(root = ROOT) {
const packagesDir = join(root, "packages");
return readdirSync(packagesDir)
.sort()
.filter((name) => existsSync(join(packagesDir, name, "package.json")))
.map((name) => {
const workspace = `packages/${name}`;
const pkg = JSON.parse(readFileSync(join(root, workspace, "package.json"), "utf8"));
return { workspace, pkg };
});
}
export function checkWorkspaceContracts(root = ROOT) {
return listWorkspacePackages(root).flatMap(({ workspace, pkg }) =>
listWorkspaceContractIssues(workspace, pkg),
);
}
function main() {
const issues = checkWorkspaceContracts();
if (issues.length > 0) {
console.error("Workspace contract violations:");
issues.forEach((issue) => console.error(`- ${issue}`));
process.exitCode = 1;
return;
}
console.log("Workspace contracts verified: build, typecheck, and test are explicit.");
}
if (process.argv[1] === fileURLToPath(import.meta.url)) main();
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
listWorkspaceContractIssues,
REQUIRED_WORKSPACE_SCRIPTS,
} from "./check-workspace-contracts.mjs";
describe("workspace contract checker", () => {
it("accepts workspaces with all executable lifecycle scripts", () => {
const scripts = Object.fromEntries(REQUIRED_WORKSPACE_SCRIPTS.map((name) => [name, "echo ok"]));
assert.deepEqual(listWorkspaceContractIssues("packages/example", { scripts }), []);
});
it("reports every missing or empty lifecycle script", () => {
assert.deepEqual(
listWorkspaceContractIssues("packages/example", { scripts: { build: " " } }),
REQUIRED_WORKSPACE_SCRIPTS.map(
(script) =>
`packages/example: missing executable \`${script}\` script or ` +
`hyperframesWorkspaceContract.${script} opt-out with a specific reason`,
),
);
});
it("accepts an explicit opt-out only when it has a specific reason", () => {
const pkg = {
scripts: { build: "build", typecheck: "typecheck" },
hyperframesWorkspaceContract: {
test: {
optOut: true,
reason: "Generated fixture validated by the owning package integration suite.",
},
},
};
assert.deepEqual(listWorkspaceContractIssues("packages/example", pkg), []);
pkg.hyperframesWorkspaceContract.test.reason = "no tests";
assert.equal(listWorkspaceContractIssues("packages/example", pkg).length, 1);
});
});
+32
View File
@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { describe, it } from "node:test";
import { fileURLToPath } from "node:url";
import { deflateRawSync } from "node:zlib";
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const MAX_INSTALL_COMPRESSION_RATIO = 50;
const CLAUDE_PLUGIN_PAYLOAD_FILES = [
"packages/producer/tests/missing-host-comp-id/src/silence.wav",
] as const;
function compressionRatio(bytes: Buffer): number {
const compressedBytes = deflateRawSync(bytes).byteLength;
return bytes.byteLength / compressedBytes;
}
describe("Claude plugin install payload", () => {
it("keeps bundled files below the suspicious compression-ratio guard", () => {
for (const relativePath of CLAUDE_PLUGIN_PAYLOAD_FILES) {
const bytes = readFileSync(resolve(REPO_ROOT, relativePath));
const ratio = compressionRatio(bytes);
assert.ok(
ratio <= MAX_INSTALL_COMPRESSION_RATIO,
`${relativePath} compresses at ${ratio.toFixed(1)}:1, above the ${MAX_INSTALL_COMPRESSION_RATIO}:1 install guard`,
);
}
});
});
+24
View File
@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { CLI_SEMVER_PATTERN, validateCliVersion } from "./cli-options.ts";
function fail(message: string): never {
throw new Error(message);
}
describe("CLI semver validation", () => {
it("accepts stable and hyphenated prerelease versions", () => {
assert.doesNotThrow(() => validateCliVersion("1.2.3", CLI_SEMVER_PATTERN, fail));
assert.doesNotThrow(() => validateCliVersion("1.2.3-alpha.1", CLI_SEMVER_PATTERN, fail));
assert.doesNotThrow(() =>
validateCliVersion("1.2.3-alpha-feature.2", CLI_SEMVER_PATTERN, fail),
);
});
it("rejects underscores in prerelease versions", () => {
assert.throws(
() => validateCliVersion("1.2.3-alpha_1", CLI_SEMVER_PATTERN, fail),
/Invalid semver: 1\.2\.3-alpha_1/,
);
});
});
+174
View File
@@ -0,0 +1,174 @@
export type InlineValueOption<Key extends string> = {
prefix: string;
key: Key;
};
export const CLI_SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
type ParserConfig<
Parsed extends object,
ValueKey extends keyof Parsed & string,
BooleanKey extends keyof Parsed & string,
> = {
inlineValueOptions: Array<InlineValueOption<ValueKey>>;
valueOptions: Map<string, ValueKey>;
booleanOptions: Map<string, BooleanKey>;
parsePositional: (arg: string, index: number) => number;
fail: (message: string) => never;
};
export function parseMappedArgument<
Parsed extends object,
ValueKey extends keyof Parsed & string,
BooleanKey extends keyof Parsed & string,
>(
args: string[],
index: number,
parsed: Parsed,
config: ParserConfig<Parsed, ValueKey, BooleanKey>,
) {
const arg = args[index];
if (applyInlineValueOption(arg, parsed, config.inlineValueOptions)) {
return index;
}
if (applyBooleanOption(arg, parsed, config.booleanOptions)) {
return index;
}
return applyValueOrPositionalOption(args, index, parsed, config, arg);
}
function applyInlineValueOption<Parsed extends object, ValueKey extends keyof Parsed & string>(
arg: string,
parsed: Parsed,
inlineOptions: Array<InlineValueOption<ValueKey>>,
) {
const option = inlineOptions.find((candidate) => arg.startsWith(candidate.prefix));
if (!option) {
return false;
}
parsed[option.key] = arg.slice(option.prefix.length) as Parsed[ValueKey];
return true;
}
function applyBooleanOption<Parsed extends object, BooleanKey extends keyof Parsed & string>(
arg: string,
parsed: Parsed,
booleanOptions: Map<string, BooleanKey>,
) {
const option = booleanOptions.get(arg);
if (!option) {
return false;
}
parsed[option] = true as Parsed[BooleanKey];
return true;
}
function applyValueOrPositionalOption<
Parsed extends object,
ValueKey extends keyof Parsed & string,
BooleanKey extends keyof Parsed & string,
>(
args: string[],
index: number,
parsed: Parsed,
config: ParserConfig<Parsed, ValueKey, BooleanKey>,
arg: string,
) {
const option = config.valueOptions.get(arg);
if (!option) {
return config.parsePositional(arg, index);
}
parsed[option] = readNextArg(args, index, arg, config.fail) as Parsed[ValueKey];
return index + 1;
}
export function parseVersionOptionArgument<
Parsed extends { version?: string },
ValueKey extends keyof Parsed & string,
BooleanKey extends keyof Parsed & string,
>(
args: string[],
index: number,
parsed: Parsed,
config: Omit<ParserConfig<Parsed, ValueKey, BooleanKey>, "parsePositional"> & {
printUsage: () => void;
},
) {
return parseMappedArgument(args, index, parsed, {
...config,
parsePositional: (arg, positionalIndex) =>
parseVersionOrHelp(arg, positionalIndex, parsed, config),
});
}
function parseVersionOrHelp<Parsed extends { version?: string }>(
arg: string,
index: number,
parsed: Parsed,
config: { printUsage: () => void; fail: (message: string) => never },
) {
if (arg === "--help" || arg === "-h") {
config.printUsage();
process.exit(0);
}
parsed.version = parseVersionPositionalArg(arg, parsed.version, config.fail);
return index;
}
export function parseVersionPositionalArg(
arg: string,
currentVersion: string | undefined,
fail: (message: string) => never,
) {
if (arg.startsWith("--")) {
fail(`Unknown option: ${arg}`);
}
if (currentVersion) {
fail(`Unexpected positional argument: ${arg}`);
}
return arg.replace(/^v/, "");
}
export function readNextArg(
args: string[],
index: number,
flag: string,
fail: (message: string) => never,
) {
const value = args[index + 1];
if (!value || value.startsWith("--")) {
fail(`Missing value for ${flag}`);
}
return value;
}
export function validateCliVersion(
version: string,
pattern: RegExp,
fail: (message: string) => never,
) {
if (!pattern.test(version)) {
fail(`Invalid semver: ${version}`);
}
}
export function validateCliDate(date: string, fail: (message: string) => never) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
fail(`Invalid date: ${date}. Expected YYYY-MM-DD.`);
}
}
export function optionalFlagArg(flag: string, enabled: boolean) {
return enabled ? [flag] : [];
}
export function optionalValueArg(flag: string, value: string | undefined) {
return value ? [flag, value] : [];
}
+110
View File
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
escapeForMdx,
parseArgs,
parseCommit,
renderCommitBullet,
renderMdxCommitBullet,
shouldSkipCommit,
type RawCommit,
} from "./draft-changelog.ts";
const REPO_URL = "https://github.com/heygen-com/hyperframes";
function commit(subject: string): RawCommit {
return {
sha: "1234567890abcdef1234567890abcdef12345678",
shortSha: "1234567",
author: "Test Author",
subject,
};
}
describe("draft changelog arguments", () => {
it("parses positional, value, inline, and boolean options", () => {
assert.deepEqual(
parseArgs([
"v1.2.3",
"--from",
"v1.2.2",
"--to=HEAD",
"--date",
"2026-06-02",
"--write",
"--force",
]),
{
version: "1.2.3",
from: "v1.2.2",
to: "HEAD",
date: "2026-06-02",
write: true,
force: true,
},
);
});
});
describe("draft changelog commit parsing", () => {
it("categorizes conventional commit types", () => {
assert.equal(parseCommit(commit("feat: add timeline markers")).category, "Features");
assert.equal(parseCommit(commit("fix: repair audio sync")).category, "Fixes");
assert.equal(parseCommit(commit("perf: reduce render startup")).category, "Performance");
assert.equal(parseCommit(commit("docs: update quickstart")).category, "Docs & Examples");
assert.equal(parseCommit(commit("test: cover frame capture")).category, "Internal");
assert.equal(parseCommit(commit("move the preview panel")).category, "Other Changes");
});
it("detects catalog changes from scope or summary", () => {
assert.equal(parseCommit(commit("feat(catalog): add kinetic title")).category, "Catalog");
assert.equal(parseCommit(commit("fix: repair registry preview metadata")).category, "Catalog");
});
it("lets breaking changes override the normal category", () => {
const parsed = parseCommit(commit("fix(cli)!: remove legacy render flag"));
assert.equal(parsed.breaking, true);
assert.equal(parsed.category, "Breaking Changes");
});
it("skips release, bump, and explicit skip commits", () => {
assert.equal(shouldSkipCommit(commit("chore: release v1.2.3")), true);
assert.equal(shouldSkipCommit(commit("chore: bump version to v1.2.3")), true);
assert.equal(shouldSkipCommit(commit("fix: internal cleanup [skip changelog]")), true);
assert.equal(shouldSkipCommit(commit("fix: real user-facing bug")), false);
});
});
describe("draft changelog rendering", () => {
it("renders commit bullets with scope and pull request links", () => {
const parsed = parseCommit(commit("feat(cli): add render hints (#42)"));
assert.equal(
renderCommitBullet(parsed),
`- **CLI:** Add render hints ([1234567](${REPO_URL}/commit/1234567890abcdef1234567890abcdef12345678), [#42](${REPO_URL}/pull/42))`,
);
});
it("renders commit bullets without scope or pull request links", () => {
const parsed = parseCommit(commit("fix: repair playback"));
assert.equal(
renderCommitBullet(parsed),
`- Repair playback ([1234567](${REPO_URL}/commit/1234567890abcdef1234567890abcdef12345678))`,
);
});
it("escapes MDX-sensitive characters only in docs bullets", () => {
const parsed = parseCommit(commit("feat(docs): support <Update> blocks with {tags} (#7)"));
assert.equal(
escapeForMdx("\\<Update>{tags}</Update>"),
"\\\\\\<Update\\>\\{tags\\}\\</Update\\>",
);
assert.ok(
renderMdxCommitBullet(parsed).includes("Support \\<Update\\> blocks with \\{tags\\}"),
);
assert.ok(renderCommitBullet(parsed).includes("Support <Update> blocks with {tags}"));
});
});
+498
View File
@@ -0,0 +1,498 @@
#!/usr/bin/env tsx
import { execFileSync } from "child_process";
import { mkdirSync, readFileSync, writeFileSync } from "fs";
import { join } from "path";
import { pathToFileURL } from "url";
import {
CLI_SEMVER_PATTERN,
parseVersionOptionArgument,
validateCliDate,
validateCliVersion,
type InlineValueOption,
} from "./cli-options.ts";
const ROOT = join(import.meta.dirname, "..");
const REPO_URL = "https://github.com/heygen-com/hyperframes";
const DOCS_MARKER =
"{/* New release entries are prepended by `bun run changelog:draft <version> --write`. */}";
const CATEGORY_ORDER = [
"Breaking Changes",
"Features",
"Fixes",
"Performance",
"Docs & Examples",
"Catalog",
"Internal",
"Other Changes",
];
type Options = {
version: string;
from?: string;
to?: string;
date: string;
write: boolean;
force: boolean;
};
export type RawCommit = {
sha: string;
shortSha: string;
author: string;
subject: string;
};
export type ParsedCommit = RawCommit & {
type: string;
scope?: string;
summary: string;
breaking: boolean;
category: string;
prNumber?: string;
};
type DraftOutput = {
releaseNotes: string;
docsUpdate: string;
};
type MutableOptions = Omit<Options, "version"> & {
version?: string;
};
type ValueOptionKey = "from" | "to" | "date";
type BooleanOptionKey = "write" | "force";
type ParsedSubject = Pick<ParsedCommit, "type" | "scope" | "summary" | "breaking">;
const VALUE_OPTIONS = new Map<string, ValueOptionKey>([
["--from", "from"],
["--to", "to"],
["--date", "date"],
]);
const BOOLEAN_OPTIONS = new Map<string, BooleanOptionKey>([
["--write", "write"],
["--force", "force"],
]);
const INLINE_VALUE_OPTIONS = [
{ prefix: "--from=", key: "from" },
{ prefix: "--to=", key: "to" },
{ prefix: "--date=", key: "date" },
] satisfies Array<InlineValueOption<ValueOptionKey>>;
const TYPE_CATEGORIES = new Map([
["feat", "Features"],
["fix", "Fixes"],
["perf", "Performance"],
]);
const INTERNAL_TYPES = new Set(["build", "chore", "ci", "refactor", "test"]);
function main() {
const options = parseArgs(process.argv.slice(2));
const draft = createDraft(options);
outputDraft(options, draft);
}
function createDraft(options: Options): DraftOutput {
const versionTag = `v${options.version}`;
const to = options.to ?? (tagExists(versionTag) ? versionTag : "HEAD");
const from = options.from ?? resolvePreviousTag(versionTag, to);
const commits = getCommits(from, to).filter((commit) => !shouldSkipCommit(commit));
const parsedCommits = commits.map(parseCommit);
const releaseNotes = renderReleaseNotes(options.version, options.date, from, parsedCommits);
const docsUpdate = renderDocsUpdate(options.version, options.date, from, parsedCommits);
return { releaseNotes, docsUpdate };
}
function outputDraft(options: Options, draft: DraftOutput) {
if (!options.write) {
console.log(draft.releaseNotes);
console.log("\n--- Mintlify update block ---\n");
console.log(draft.docsUpdate);
console.log(
"\nRun with --write to create the release file and prepend the docs changelog entry.",
);
return;
}
writeReleaseNotes(options.version, draft.releaseNotes, options.force);
prependDocsUpdate(options.version, draft.docsUpdate);
}
export function parseArgs(args: string[]): Options {
const parsed = createDefaultOptions();
for (let index = 0; index < args.length; index += 1) {
index = parseArgument(args, index, parsed);
}
return finalizeOptions(parsed);
}
function createDefaultOptions(): MutableOptions {
return {
date: new Date().toISOString().slice(0, 10),
write: false,
force: false,
};
}
function parseArgument(args: string[], index: number, parsed: MutableOptions) {
return parseVersionOptionArgument(args, index, parsed, {
inlineValueOptions: INLINE_VALUE_OPTIONS,
valueOptions: VALUE_OPTIONS,
booleanOptions: BOOLEAN_OPTIONS,
printUsage,
fail,
});
}
function finalizeOptions(parsed: MutableOptions): Options {
if (!parsed.version) {
printUsage();
process.exit(1);
}
validateCliVersion(parsed.version, CLI_SEMVER_PATTERN, fail);
validateCliDate(parsed.date, fail);
return {
version: parsed.version,
from: parsed.from,
to: parsed.to,
date: parsed.date,
write: parsed.write,
force: parsed.force,
};
}
function printUsage() {
console.log(`Usage:
bun run changelog:draft <version> [--write] [--force] [--from <ref>] [--to <ref>] [--date YYYY-MM-DD]
Examples:
bun run changelog:draft 0.6.53
bun run changelog:draft 0.6.53 --write
bun run changelog:draft 0.6.53 --from v0.6.52 --to HEAD --write
`);
}
function fail(message: string): never {
console.error(message);
process.exit(1);
}
function git(args: string[]) {
return execFileSync("git", args, {
cwd: ROOT,
encoding: "utf-8",
}).trim();
}
function tagExists(tag: string) {
try {
git(["rev-parse", "--verify", "--quiet", `refs/tags/${tag}`]);
return true;
} catch {
return false;
}
}
function resolvePreviousTag(versionTag: string, to: string) {
try {
if (tagExists(versionTag)) {
return git(["describe", "--tags", "--abbrev=0", "--match", "v[0-9]*", `${versionTag}^`]);
}
return git(["describe", "--tags", "--abbrev=0", "--match", "v[0-9]*", to]);
} catch {
fail("Could not resolve the previous release tag. Pass --from <tag> explicitly.");
}
}
function getCommits(from: string, to: string): RawCommit[] {
const output = git(["log", "--format=%H%x09%h%x09%an%x09%s", "--no-merges", `${from}..${to}`]);
if (!output) {
return [];
}
return output.split("\n").map((line) => {
const [sha = "", shortSha = "", author = "", ...subjectParts] = line.split("\t");
return {
sha,
shortSha,
author,
subject: subjectParts.join("\t"),
};
});
}
export function shouldSkipCommit(commit: RawCommit) {
const subject = commit.subject.toLowerCase();
return (
subject.includes("[skip changelog]") ||
/^chore: release v\d+\.\d+\.\d+/.test(subject) ||
/^chore: bump version/.test(subject)
);
}
export function parseCommit(commit: RawCommit): ParsedCommit {
const prNumber = extractPrNumber(commit.subject);
const subjectWithoutPr = commit.subject.replace(/\s+\(#\d+\)$/, "");
const parsedSubject = parseConventionalSubject(subjectWithoutPr);
const category = categorizeCommit(parsedSubject);
return {
...commit,
...parsedSubject,
category,
prNumber,
};
}
export function parseConventionalSubject(subject: string): ParsedSubject {
const match = /^([a-z]+)(?:\(([^)]+)\))?(!)?:\s+(.+)$/.exec(subject);
if (!match) {
return {
type: "other",
summary: subject,
breaking: false,
};
}
return {
type: match[1],
scope: match[2],
summary: match[4],
breaking: match[3] === "!",
};
}
function extractPrNumber(subject: string) {
return /\(#(\d+)\)$/.exec(subject)?.[1];
}
export function categorizeCommit(subject: ParsedSubject) {
if (subject.breaking) {
return "Breaking Changes";
}
if (isCatalogChange(subject)) {
return "Catalog";
}
return knownCategoryFor(subject) ?? "Other Changes";
}
function isCatalogChange(subject: ParsedSubject) {
const normalizedScope = subject.scope?.toLowerCase() ?? "";
const normalizedSummary = subject.summary.toLowerCase();
return (
["catalog", "registry"].includes(normalizedScope) || /catalog|registry/.test(normalizedSummary)
);
}
function knownCategoryFor(subject: ParsedSubject) {
return (
TYPE_CATEGORIES.get(subject.type) ??
docsCategoryFor(subject) ??
internalCategoryFor(subject.type)
);
}
function docsCategoryFor(subject: ParsedSubject) {
return isDocsChange(subject) ? "Docs & Examples" : undefined;
}
function isDocsChange(subject: ParsedSubject) {
const normalizedFields = [subject.type, subject.scope?.toLowerCase()];
return normalizedFields.includes("docs") || subject.summary.toLowerCase().includes("example");
}
function internalCategoryFor(type: string) {
return INTERNAL_TYPES.has(type) ? "Internal" : undefined;
}
function renderReleaseNotes(version: string, date: string, from: string, commits: ParsedCommit[]) {
const sections = renderSections(commits, renderCommitBullet);
const compareUrl = `${REPO_URL}/compare/${from}...v${version}`;
return [
`# HyperFrames v${version}`,
"",
`Released on ${date}.`,
"",
"<!-- TODO: write a 1-2 sentence release summary here. -->",
"",
sections,
"",
"## Full changelog",
"",
compareUrl,
].join("\n");
}
function renderDocsUpdate(version: string, date: string, from: string, commits: ParsedCommit[]) {
const sections = renderSections(commits, renderMdxCommitBullet);
const compareUrl = `${REPO_URL}/compare/${from}...v${version}`;
const tags = renderTags(commits);
return [
"<Update",
` label="HyperFrames v${version}"`,
` description="Released - ${date}"`,
` tags={${renderTagsLiteral(tags)}}`,
">",
"<!-- TODO: write a 1-2 sentence release summary here. -->",
"",
sections,
"",
`[View the full commit range](${compareUrl}).`,
"</Update>",
].join("\n");
}
function renderSections(commits: ParsedCommit[], renderBullet: (commit: ParsedCommit) => string) {
if (commits.length === 0) {
return "No notable changes were found in the selected commit range.";
}
return CATEGORY_ORDER.flatMap((category) => {
const commitsInCategory = commits.filter((commit) => commit.category === category);
if (commitsInCategory.length === 0) {
return [];
}
return [`## ${category}`, "", ...commitsInCategory.map(renderBullet), ""];
})
.join("\n")
.trim();
}
export function renderCommitBullet(commit: ParsedCommit) {
const scope = commit.scope ? `**${formatScope(commit.scope)}:** ` : "";
const links = [`[${commit.shortSha}](${REPO_URL}/commit/${commit.sha})`];
if (commit.prNumber) {
links.push(`[#${commit.prNumber}](${REPO_URL}/pull/${commit.prNumber})`);
}
return `- ${scope}${capitalize(commit.summary)} (${links.join(", ")})`;
}
export function renderMdxCommitBullet(commit: ParsedCommit) {
const scope = commit.scope ? `**${escapeForMdx(formatScope(commit.scope))}:** ` : "";
const links = [`[${commit.shortSha}](${REPO_URL}/commit/${commit.sha})`];
if (commit.prNumber) {
links.push(`[#${commit.prNumber}](${REPO_URL}/pull/${commit.prNumber})`);
}
return `- ${scope}${escapeForMdx(capitalize(commit.summary))} (${links.join(", ")})`;
}
function renderTags(commits: ParsedCommit[]) {
return ["Release", ...uniqueScopeTags(commits).slice(0, 3)];
}
function uniqueScopeTags(commits: ParsedCommit[]) {
return Array.from(new Set(commits.flatMap(scopeTagsForCommit)));
}
function scopeTagsForCommit(commit: ParsedCommit) {
return commit.scope ? [formatScope(commit.scope)] : [];
}
export function formatScope(scope: string) {
const knownScopes = new Map([
["api", "API"],
["aws", "AWS"],
["aws-lambda", "AWS Lambda"],
["cli", "CLI"],
["core", "Core"],
["docs", "Docs"],
["engine", "Engine"],
["ffmpeg", "FFmpeg"],
["producer", "Producer"],
["readme", "README"],
["studio", "Studio"],
]);
const known = knownScopes.get(scope.toLowerCase());
if (known) {
return known;
}
return scope
.split(/[-_]/)
.map((part) => capitalize(part))
.join(" ");
}
function capitalize(value: string) {
if (!value) {
return value;
}
return value[0].toUpperCase() + value.slice(1);
}
function renderTagsLiteral(tags: string[]) {
return `[${tags.map((tag) => JSON.stringify(tag)).join(", ")}]`;
}
export function escapeForMdx(text: string) {
return text
.replace(/\\/g, "\\\\")
.replace(/</g, "\\<")
.replace(/>/g, "\\>")
.replace(/\{/g, "\\{")
.replace(/\}/g, "\\}");
}
function writeReleaseNotes(version: string, releaseNotes: string, force: boolean) {
const releasesDir = join(ROOT, "releases");
const releasePath = join(releasesDir, `v${version}.md`);
mkdirSync(releasesDir, { recursive: true });
// Use an exclusive-write flag rather than a separate existsSync check so the
// "already exists" guard is atomic with the write (no TOCTOU race).
// EEXIST is only reachable when force is false (the "wx" flag); with force the
// "w" flag overwrites and never throws it, so no separate force check is needed.
try {
writeFileSync(releasePath, `${releaseNotes}\n`, { flag: force ? "w" : "wx" });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
console.log(
`${releasePath} already exists; leaving it unchanged. Pass --force to overwrite it.`,
);
return;
}
throw error;
}
console.log(`Wrote ${releasePath}`);
}
function prependDocsUpdate(version: string, docsUpdate: string) {
const changelogPath = join(ROOT, "docs", "changelog.mdx");
const changelog = readFileSync(changelogPath, "utf-8");
if (changelog.includes(`label="HyperFrames v${version}"`)) {
console.log(`docs/changelog.mdx already has a v${version} entry; leaving it unchanged.`);
return;
}
if (!changelog.includes(DOCS_MARKER)) {
fail(`Could not find insertion marker in ${changelogPath}`);
}
const updated = changelog.replace(DOCS_MARKER, `${DOCS_MARKER}\n\n${docsUpdate}`);
writeFileSync(changelogPath, updated);
console.log(`Prepended v${version} to ${changelogPath}`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}
+590
View File
@@ -0,0 +1,590 @@
#!/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 } from "node:url";
// Import from source — bun workspace linking doesn't resolve for scripts outside packages/.
import {
type RegistryItem,
isBlockItem,
ITEM_TYPE_DIRS,
} from "../packages/core/src/registry/types.js";
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";
// ── Types ──────────────────────────────────────────────────────────────────
type ItemKind = "block" | "component";
interface SourceMetadata {
authorUrl?: string;
sourcePrompt?: string;
}
interface TextureGroup {
title: string;
items: string[];
}
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 ─────────────────────────────────────────────────────────
function typeLabel(kind: ItemKind): string {
return kind === "block" ? "Block" : "Component";
}
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) => 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(" ");
}
function textureSampleWord(slug: string): string {
if (slug.includes("brick")) return "BRICK";
if (slug.includes("concrete")) return "CONCRETE";
if (slug.includes("plaster")) return "PLASTER";
if (slug.includes("rock")) return "ROCK";
if (slug.includes("onyx")) return "ONYX";
if (slug.includes("marble")) return "MARBLE";
if (slug.includes("travertine")) return "STONE";
if (slug.includes("paving")) return "STONE";
if (slug.includes("tiles")) return "TILE";
if (slug.includes("ground")) return "GROUND";
if (slug.includes("road")) return "ROAD";
if (slug.includes("asphalt")) return "ASPHALT";
if (slug.includes("wood-floor")) return "FLOOR";
if (slug.includes("wood")) return "WOOD";
if (slug.includes("bark")) return "BARK";
if (slug.includes("diamond")) return "PLATE";
if (slug.includes("metal")) return "METAL";
if (slug.includes("lava")) return "LAVA";
if (slug.includes("grass")) return "GRASS";
if (slug.includes("carpet")) return "WOVEN";
if (slug.includes("fabric")) return "FABRIC";
if (slug.includes("snow")) return "SNOW";
if (slug.includes("leather")) return "LEATHER";
return 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[] = [
"## Texture Examples",
"",
'<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 [
"## Agent Usage",
"",
"Use this wording when asking an agent to apply a texture:",
"",
"```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>",
"```",
"",
`After install, the snippet lives at \`${installedSnippet}\` inside the project where you ran \`npx hyperframes add ${manifest.name}\`. The part to paste is the real \`<style>\` element near the bottom of that file; the texture PNGs install to \`assets/${manifest.name}/masks/\` and are referenced by project-root URLs in that CSS.`,
"",
`Swap \`${firstClass}\` for the class shown on any texture card below. The base class \`hf-texture-text\` is always required.`,
"",
];
}
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 [
"## Animated Texture",
"",
"Animate the texture by moving the mask position on the text element. Keep drop shadow on a wrapper so the shadow 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(Boolean)
.slice(0, 6);
const lines: string[] = ['<div className="hf-texture-preview-panel">'];
for (const item of sampleItems) {
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;
}
function catalogPreviewFor(kind: ItemKind, manifest: RegistryItem): string {
const dir = typeDir(kind);
return `${catalogImageBase}/${dir}/${manifest.name}.png`;
}
function yamlString(value: string): string {
return JSON.stringify(value);
}
function generateItemMdx(kind: ItemKind, manifest: RegistryItem): string {
const tags = manifest.tags ?? [];
const tagBadges = tags.map((t) => `\`${t}\``).join(" ");
const installCmd = `npx hyperframes add ${manifest.name}`;
const source = manifest as RegistryItem & SourceMetadata;
const textureGroups = textureGroupsFor(manifest);
const lines: string[] = ["---", `title: ${yamlString(manifest.title)}`];
if (textureGroups.length === 0) {
lines.push(`description: ${yamlString(manifest.description)}`);
}
lines.push("---", "");
if (textureGroups.length === 0) {
lines.push(`# ${manifest.title}`, "", manifest.description, "");
}
if (tagBadges) {
lines.push(tagBadges, "");
}
if (tags.includes("html-in-canvas")) {
lines.push(
`<Warning>`,
`**Requires Chrome flag.** Enable \`chrome://flags/#canvas-draw-element\` for live preview. Rendering via CLI enables the flag automatically. [Learn more](/guides/html-in-canvas).`,
`</Warning>`,
"",
);
}
if (manifest.author) {
const author = source.authorUrl ? `[${manifest.author}](${source.authorUrl})` : manifest.author;
lines.push(`Created by ${author}.`, "");
}
if (source.sourcePrompt) {
lines.push("## Source Prompt", "", "```text", source.sourcePrompt, "```", "");
}
if (textureGroups.length > 0) {
lines.push(...generateTexturePreview(manifest, textureGroups));
} else {
// Preview video with poster — muted loop, no autoPlay (matches examples page).
const previewPath = `${catalogImageBase}/${typeDir(kind)}/${manifest.name}`;
lines.push(
`<video className="w-full aspect-video rounded-xl object-cover bg-zinc-100 dark:bg-zinc-800" src="${previewPath}.mp4" poster="${previewPath}.png" autoPlay muted loop playsInline />`,
"",
);
}
// Install command
lines.push(
"## Install",
"",
"<CodeGroup>",
"",
"```bash Terminal",
installCmd,
"```",
"",
"</CodeGroup>",
"",
);
// Details
if (kind === "block" && manifest.dimensions && manifest.duration) {
lines.push(
"## Details",
"",
`| Property | Value |`,
`| --- | --- |`,
`| Type | ${typeLabel(kind)} |`,
`| Dimensions | ${manifest.dimensions.width}×${manifest.dimensions.height} |`,
`| Duration | ${manifest.duration}s |`,
"",
);
} else {
lines.push(
"## Details",
"",
`| Property | Value |`,
`| --- | --- |`,
`| Type | ${typeLabel(kind)} |`,
"",
);
}
if (textureGroups.length > 0) {
lines.push(...generateTextureAgentUsage(manifest, textureGroups));
lines.push(...generateTextureAnimationExample(manifest, textureGroups));
lines.push(...generateTextureExamples(manifest, textureGroups));
}
// Files
if (textureGroups.length === 0) {
lines.push("## Files", "", "| File | Target | Type |", "| --- | --- | --- |");
for (const f of manifest.files) {
lines.push(`| \`${f.path}\` | \`${f.target}\` | ${f.type} |`);
}
lines.push("");
}
// Usage hint — find the primary file by type, not array position.
const primaryFile =
manifest.files.find((f) => f.type === "hyperframes:composition") ??
manifest.files.find((f) => f.type === "hyperframes:snippet") ??
manifest.files[0];
const primaryTarget = primaryFile?.target ?? `compositions/${manifest.name}.html`;
if (kind === "block" && isBlockItem(manifest)) {
const w = manifest.dimensions.width;
const h = manifest.dimensions.height;
lines.push(
"## Usage",
"",
"After installing, add the block to your host composition:",
"",
"```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>`,
"```",
"",
);
} else {
if (textureGroups.length > 0) {
lines.push(
"## Usage",
"",
`After \`${installCmd}\`, the installed snippet lives at \`${primaryTarget}\` inside your current HyperFrames project. Open that file and paste the real \`<style>\` element near the bottom into your composition once; it defines \`hf-texture-text\` and every \`hf-texture-*\` class used by the examples above. Keep the installed texture PNGs in \`assets/${manifest.name}/masks/\`; the CSS references them with project-root URLs.`,
"",
);
} else {
lines.push(
"## Usage",
"",
`Open \`${primaryTarget}\` and paste its contents into your composition. See the comment header in the file for detailed instructions.`,
"",
);
}
}
// Related skill
if (manifest.relatedSkill) {
lines.push(`<Tip>Related skill: \`/${manifest.relatedSkill}\`</Tip>`, "");
}
return lines.join("\n");
}
// ── Main ───────────────────────────────────────────────────────────────────
function main(): void {
const items = discoverItems();
const catalogIndex: CatalogEntry[] = [];
// 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);
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 });
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,
Data: 8,
Effects: 9,
Blocks: 10,
};
// fallow-ignore-next-line complexity
function groupForItem(entry: CatalogEntry): string {
const tags = entry.tags;
// 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";
if (tags.includes("showcase") || tags.includes("3d")) return "Showcases";
if (tags.includes("data") || tags.includes("chart") || tags.includes("ascii")) return "Data";
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 catalogGroups = [...groupMap.entries()]
.sort(([a], [b]) => (GROUP_ORDER[a] ?? 50) - (GROUP_ORDER[b] ?? 50))
.map(([group, pages]) => ({ group, pages }));
if (catalogGroups.length > 0) {
// Replace or insert the Catalog tab
const existingIdx = tabs.findIndex((t) => t.tab === "Catalog");
const catalogTab = { tab: "Catalog", groups: catalogGroups };
// Remove existing Catalog tab if present, then insert at position 1
// (after Documentation, before Packages).
if (existingIdx >= 0) {
tabs.splice(existingIdx, 1);
}
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");
const totalPages = catalogGroups.reduce((n, g) => n + g.pages.length, 0);
console.log(` ✓ docs.json updated with ${catalogGroups.length} groups, ${totalPages} pages`);
}
console.log("\nDone.");
}
main();
+349
View File
@@ -0,0 +1,349 @@
#!/usr/bin/env tsx
/**
* Generate Catalog Preview Images + Videos
*
* Renders preview thumbnails and videos for registry blocks and components.
* Examples use the separate generate-template-previews.ts script.
*
* - Blocks: renders the block's standalone HTML via a wrapper index.html
* - Components: renders the component's demo.html via a wrapper index.html
*
* Output: docs/images/catalog/<type>/<name>.png + <name>.mp4
* (docs/images/ is gitignored — files are served from the CDN. After running
* this script, run `bun run upload:docs-images` to publish.)
*
* Usage:
* npx tsx scripts/generate-catalog-previews.ts # all items
* npx tsx scripts/generate-catalog-previews.ts --only data-chart # single item
* npx tsx scripts/generate-catalog-previews.ts --type block # blocks only
* npx tsx scripts/generate-catalog-previews.ts --skip-video # thumbnails only
*/
import {
readdirSync,
readFileSync,
existsSync,
mkdirSync,
cpSync,
rmSync,
writeFileSync,
} from "node:fs";
import { join, resolve, dirname } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
// Import from source — bun workspace linking doesn't resolve for scripts outside packages/.
import {
createFileServer,
createCaptureSession,
initializeSession,
captureFrame,
getCompositionDuration,
closeCaptureSession,
createRenderJob,
executeRenderJob,
} from "../packages/producer/src/index.js";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(scriptDir, "..");
const registryDir = resolve(repoRoot, "registry");
if (!process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH) {
process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH = resolve(
repoRoot,
"packages/core/dist/hyperframe.manifest.json",
);
}
// ── Types ──────────────────────────────────────────────────────────────────
type ItemKind = "block" | "component";
interface CatalogItem {
name: string;
kind: ItemKind;
/** Directory containing the item's files in the registry. */
sourceDir: string;
/** The HTML file to render (relative to sourceDir). */
entryFile: string;
}
// ── Discovery ──────────────────────────────────────────────────────────────
function discoverItems(kindFilter: ItemKind | null, nameFilter: string | null): CatalogItem[] {
const items: CatalogItem[] = [];
// Blocks and components only — examples use the existing generate-template-previews.ts.
const kinds: { kind: ItemKind; dir: string }[] = [
{ kind: "block", dir: join(registryDir, "blocks") },
{ kind: "component", dir: join(registryDir, "components") },
];
for (const { kind, dir } of kinds) {
if (kindFilter && kindFilter !== kind) continue;
if (!existsSync(dir)) continue;
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (!e.isDirectory()) continue;
if (nameFilter && e.name !== nameFilter) continue;
const sourceDir = join(dir, e.name);
const manifestPath = join(sourceDir, "registry-item.json");
if (!existsSync(manifestPath)) continue;
// Blocks: find the first composition file. Components: use demo.html.
let entryFile: string;
if (kind === "component") {
entryFile = "demo.html";
} else {
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
const compFile = manifest.files?.find(
(f: { type: string }) => f.type === "hyperframes:composition",
);
entryFile = compFile?.path ?? `${e.name}.html`;
}
if (!existsSync(join(sourceDir, entryFile))) continue;
items.push({ name: e.name, kind, sourceDir, entryFile });
}
}
if (nameFilter && items.length === 0) {
const allNames = discoverItems(null, null).map((i) => i.name);
console.error(`Item "${nameFilter}" not found. Available: ${allNames.join(", ")}`);
process.exit(1);
}
return items;
}
// ── Preview generation ─────────────────────────────────────────────────────
function outputDir(kind: ItemKind): string {
const typeDir = kind === "block" ? "blocks" : "components";
return resolve(repoRoot, "docs/images/catalog", typeDir);
}
function prepareProjectDir(item: CatalogItem): string {
const tmpDir = join(tmpdir(), `hf-catalog-${item.name}-${Date.now()}`);
mkdirSync(tmpDir, { recursive: true });
cpSync(item.sourceDir, tmpDir, { recursive: true });
// The HyperFrames producer navigates to index.html at the project root.
// Blocks and component demos are standalone HTML files, not index.html.
// If the entry file is a standalone HTML (has its own timeline registration),
// just rename it to index.html. Otherwise create a wrapper.
if (!existsSync(join(tmpDir, "index.html")) && existsSync(join(tmpDir, item.entryFile))) {
const entryContent = readFileSync(join(tmpDir, item.entryFile), "utf-8");
const hasTimeline = entryContent.includes("__timelines");
if (hasTimeline) {
// Standalone block — copy to index.html and render directly.
// For social overlays with transparent backgrounds, inject a dark bg
// so the overlay card is visible against something.
let content = entryContent;
const hasSocialTag = (() => {
try {
const m = JSON.parse(readFileSync(join(tmpDir, "registry-item.json"), "utf-8"));
return (m.tags ?? []).includes("social");
} catch {
return false;
}
})();
if (hasSocialTag) {
// Dark bg for transparent overlays
if (content.includes("background: transparent")) {
content = content.replace("background: transparent", "background: #1a1a2e");
}
// Reposition bottom-anchored overlays to center for preview.
// Social overlays use "bottom: Npx" positioning — replace with
// "top: 50%; transform: translate(-50%, -50%)" for a centered preview.
content = content.replace(
/bottom:\s*\d+px;\s*\n(\s*)left:\s*50%;\s*\n(\s*)transform:\s*translateX\(-50%\)/,
"top: 50%;\n$1left: 50%;\n$2transform: translate(-50%, -50%)",
);
// Scale down large centered cards (like Spotify) that use
// margin-based centering with large negative margins.
if (/margin-top:\s*-[3-9]\d\dpx/.test(content)) {
content = content.replace(
/(<body[^>]*>)/,
"$1\n<style>body { transform: scale(0.55); transform-origin: center center; }</style>",
);
}
}
writeFileSync(join(tmpDir, "index.html"), content, "utf-8");
return tmpDir;
}
}
if (!existsSync(join(tmpDir, "index.html"))) {
const manifestPath = join(tmpDir, "registry-item.json");
let width = 1920;
let height = 1080;
let duration = 5;
if (existsSync(manifestPath)) {
const m = JSON.parse(readFileSync(manifestPath, "utf-8"));
width = m.dimensions?.width ?? width;
height = m.dimensions?.height ?? height;
duration = m.duration ?? duration;
}
// Dark background for social overlays so transparent cards are visible.
const tags: string[] = (() => {
try {
return JSON.parse(readFileSync(join(tmpDir, "registry-item.json"), "utf-8")).tags ?? [];
} catch {
return [];
}
})();
const isSocialOverlay = tags.includes("social") || tags.includes("overlay");
const bgColor = isSocialOverlay ? "#1a1a2e" : "#ffffff";
const wrapper = `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=${width}, height=${height}" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>* { margin: 0; padding: 0; } html, body { width: ${width}px; height: ${height}px; overflow: hidden; background: ${bgColor}; }</style>
</head>
<body>
<div data-composition-id="preview-root" data-width="${width}" data-height="${height}" data-start="0" data-duration="${duration}">
<div data-composition-id="${item.name}" data-composition-src="${item.entryFile}" data-start="0" data-duration="${duration}" data-track-index="0" data-width="${width}" data-height="${height}"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["preview-root"] = gsap.timeline({ paused: true });
</script>
</body>
</html>`;
writeFileSync(join(tmpDir, "index.html"), wrapper, "utf-8");
}
return tmpDir;
}
async function generateThumbnail(item: CatalogItem, projectDir: string): Promise<void> {
const outDir = outputDir(item.kind);
mkdirSync(outDir, { recursive: true });
// Read dimensions from the wrapper index.html (which may differ from native
// dimensions for portrait overlays that are scaled to fit landscape).
let width = 1920;
let height = 1080;
const wrapperPath = join(projectDir, "index.html");
const wrapperHtml = readFileSync(wrapperPath, "utf-8");
const wMatch = wrapperHtml.match(/data-width="(\d+)"/);
const hMatch = wrapperHtml.match(/data-height="(\d+)"/);
if (wMatch) width = parseInt(wMatch[1], 10);
if (hMatch) height = parseInt(hMatch[1], 10);
const framesDir = join(projectDir, "_thumb_frames");
mkdirSync(framesDir, { recursive: true });
const fileServer = await createFileServer({
projectDir,
port: 0,
fps: { num: 30, den: 1 },
});
try {
const session = await createCaptureSession(fileServer.url, framesDir, {
width,
height,
fps: { num: 30, den: 1 },
format: "png",
});
await initializeSession(session);
let duration: number;
try {
duration = await getCompositionDuration(session);
} catch {
duration = 5;
}
// Capture at 40% of duration for a representative frame
// Capture at 60% of duration so the animation is well underway.
// Cap at 3s to avoid overly-late captures on long compositions.
const captureTime = Math.min(3.0, duration * 0.6);
const result = await captureFrame(session, 0, captureTime);
cpSync(result.path, join(outDir, `${item.name}.png`));
console.log(`${item.name}.png (${result.captureTimeMs}ms)`);
await closeCaptureSession(session);
} finally {
fileServer.close();
rmSync(framesDir, { recursive: true, force: true });
}
}
async function generateVideo(item: CatalogItem, projectDir: string): Promise<void> {
const outDir = outputDir(item.kind);
mkdirSync(outDir, { recursive: true });
const outMp4 = join(outDir, `${item.name}.mp4`);
const job = createRenderJob({
fps: { num: 24, den: 1 },
quality: "draft",
format: "mp4",
});
await executeRenderJob(job, projectDir, outMp4);
console.log(`${item.name}.mp4`);
}
// ── CLI ────────────────────────────────────────────────────────────────────
function parseArgs(): { only: string | null; type: ItemKind | null; skipVideo: boolean } {
let only: string | null = null;
let type: ItemKind | null = null;
let skipVideo = false;
for (let i = 2; i < process.argv.length; i++) {
const arg = process.argv[i];
if (arg === "--only" && process.argv[i + 1]) {
i++;
only = process.argv[i] ?? null;
}
if (arg === "--type" && process.argv[i + 1]) {
i++;
const val = process.argv[i];
if (val === "block" || val === "component") {
type = val;
} else {
console.error(`Invalid --type: "${val}". Must be block or component.`);
process.exit(1);
}
}
if (arg === "--skip-video") skipVideo = true;
}
return { only, type, skipVideo };
}
async function main(): Promise<void> {
const { only, type, skipVideo } = parseArgs();
const items = discoverItems(type, only);
console.log(
`Generating catalog previews for ${items.length} item(s)${skipVideo ? " (thumbnails only)" : " + videos"}...\n`,
);
for (const item of items) {
console.log(`[${item.kind}] ${item.name}`);
const projectDir = prepareProjectDir(item);
try {
await generateThumbnail(item, projectDir);
if (!skipVideo) {
await generateVideo(item, projectDir);
}
} catch (err) {
console.error(`${item.name}: ${err instanceof Error ? err.message : err}`);
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
}
console.log("\nDone.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+203
View File
@@ -0,0 +1,203 @@
#!/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[] {
try {
const raw = readFileSync(legacyManifestPath, "utf-8");
const parsed = JSON.parse(raw) as LegacyManifest;
return parsed.templates;
} catch {
// templates.json was the bootstrap source and has been deleted. Fall back
// to scanning existing registry-item.json files and reconstructing entries.
return scanExistingItems();
}
}
function scanExistingItems(): LegacyTemplateEntry[] {
const entries: LegacyTemplateEntry[] = [];
for (const dir of readdirSync(examplesDir, { withFileTypes: true })) {
if (!dir.isDirectory()) continue;
const itemPath = join(examplesDir, dir.name, "registry-item.json");
try {
const item = JSON.parse(readFileSync(itemPath, "utf-8")) as RegistryItem;
entries.push({ id: item.name, label: item.title, hint: item.description, bundled: false });
} catch {
// No manifest — skip.
}
}
return entries;
}
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();
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env tsx
/**
* Generate Template Preview Images + Videos
*
* Uses @hyperframes/producer to render PNG thumbnails and short MP4 preview
* videos of each built-in template.
*
* Output: docs/images/templates/<id>.png + <id>.mp4
* (docs/images/ is gitignored — files are served from the CDN. After running
* this script, run `bun run upload:docs-images` to publish.)
*
* Usage:
* bun run generate:previews # all templates (PNG + MP4)
* bun run generate:previews --only warm-grain
* bun run generate:previews --skip-video # thumbnails only (faster)
*/
import {
readdirSync,
readFileSync,
writeFileSync,
existsSync,
mkdirSync,
cpSync,
rmSync,
} from "node:fs";
import { join, resolve, dirname } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import {
createFileServer,
createCaptureSession,
initializeSession,
captureFrame,
getCompositionDuration,
closeCaptureSession,
createRenderJob,
executeRenderJob,
} from "@hyperframes/producer";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(scriptDir, "..");
const bundledTemplatesDir = resolve(repoRoot, "packages/cli/src/templates");
const remoteTemplatesDir = resolve(repoRoot, "registry/examples");
const outputDir = resolve(repoRoot, "docs/images/templates");
if (!process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH) {
process.env.PRODUCER_HYPERFRAME_MANIFEST_PATH = resolve(
repoRoot,
"packages/core/dist/hyperframe.manifest.json",
);
}
const SKIP_TEMPLATES = new Set(["blank"]);
const DEFAULT_CONFIG = { width: 1920, height: 1080, captureTime: 2.0 };
const TEMPLATE_CONFIG: Record<string, { width: number; height: number; captureTime: number }> = {
vignelli: { width: 1080, height: 1920, captureTime: 2.0 },
};
function patchTemplateHtml(dir: string, durationSeconds: number): void {
const htmlFiles = readdirSync(dir, { withFileTypes: true, recursive: true })
.filter((e) => e.isFile() && e.name.endsWith(".html"))
.map((e) => join(e.parentPath ?? e.path, e.name));
for (const file of htmlFiles) {
let content = readFileSync(file, "utf-8");
content = content.replace(/<video[^>]*src="__VIDEO_SRC__"[^>]*>[\s\S]*?<\/video>/g, "");
content = content.replace(/<video[^>]*src="__VIDEO_SRC__"[^>]*>/g, "");
content = content.replace(/<audio[^>]*src="__VIDEO_SRC__"[^>]*>[\s\S]*?<\/audio>/g, "");
content = content.replace(/<audio[^>]*src="__VIDEO_SRC__"[^>]*>/g, "");
const dur = String(Math.round(durationSeconds * 100) / 100);
content = content.replaceAll("__VIDEO_DURATION__", dur);
writeFileSync(file, content, "utf-8");
}
}
function parseArgs(): { only: string | null; skipVideo: boolean } {
let only: string | null = null;
let skipVideo = false;
for (let i = 2; i < process.argv.length; i++) {
if (process.argv[i] === "--only" && process.argv[i + 1]) {
i++;
only = process.argv[i] ?? null;
}
if (process.argv[i] === "--skip-video") skipVideo = true;
}
return { only, skipVideo };
}
function resolveTemplateDir(templateId: string): string | null {
for (const base of [bundledTemplatesDir, remoteTemplatesDir]) {
const dir = join(base, templateId);
if (existsSync(join(dir, "index.html"))) return dir;
}
return null;
}
function discoverTemplates(only: string | null): string[] {
const seen = new Set<string>();
const all: string[] = [];
for (const dir of [bundledTemplatesDir, remoteTemplatesDir]) {
if (!existsSync(dir)) continue;
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (
e.isDirectory() &&
e.name !== "_shared" &&
!SKIP_TEMPLATES.has(e.name) &&
!seen.has(e.name) &&
existsSync(join(dir, e.name, "index.html"))
) {
seen.add(e.name);
all.push(e.name);
}
}
}
if (only) {
if (!all.includes(only)) {
console.error(`Template "${only}" not found. Available: ${all.join(", ")}`);
process.exit(1);
}
return [only];
}
return all;
}
function prepareTemplateDir(templateId: string): string {
const tmpDir = join(tmpdir(), `hf-preview-${templateId}-${Date.now()}`);
mkdirSync(tmpDir, { recursive: true });
const src = resolveTemplateDir(templateId);
if (!src) throw new Error(`Template directory not found for "${templateId}"`);
cpSync(src, tmpDir, { recursive: true });
patchTemplateHtml(tmpDir, 5);
return tmpDir;
}
async function generateThumbnail(templateId: string, projectDir: string): Promise<void> {
const config = TEMPLATE_CONFIG[templateId] ?? DEFAULT_CONFIG;
const framesDir = join(projectDir, "_thumb_frames");
mkdirSync(framesDir, { recursive: true });
const fileServer = await createFileServer({
projectDir,
port: 0,
fps: { num: 30, den: 1 },
});
try {
const session = await createCaptureSession(fileServer.url, framesDir, {
width: config.width,
height: config.height,
fps: 30,
format: "png",
});
await initializeSession(session);
let duration: number;
try {
duration = await getCompositionDuration(session);
} catch {
duration = 5;
}
const t = Math.min(config.captureTime, duration * 0.8);
const result = await captureFrame(session, 0, t);
cpSync(result.path, join(outputDir, `${templateId}.png`));
console.log(`${templateId}.png (${result.captureTimeMs}ms)`);
await closeCaptureSession(session);
} finally {
fileServer.close();
rmSync(framesDir, { recursive: true, force: true });
}
}
async function generateVideo(templateId: string, projectDir: string): Promise<void> {
const outMp4 = join(outputDir, `${templateId}.mp4`);
const job = createRenderJob({
fps: 24,
quality: "draft",
format: "mp4",
});
await executeRenderJob(job, projectDir, outMp4);
console.log(`${templateId}.mp4`);
}
async function main(): Promise<void> {
const { only, skipVideo } = parseArgs();
const templates = discoverTemplates(only);
console.log(
`Generating previews for ${templates.length} templates${skipVideo ? " (thumbnails only)" : " + videos"}...\n`,
);
mkdirSync(outputDir, { recursive: true });
for (const templateId of templates) {
const projectDir = prepareTemplateDir(templateId);
try {
await generateThumbnail(templateId, projectDir);
if (!skipVideo) {
await generateVideo(templateId, projectDir);
}
} catch (err) {
console.error(`${templateId}: ${err instanceof Error ? err.message : err}`);
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
}
console.log(`\nDone. Output: ${outputDir}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+167
View File
@@ -0,0 +1,167 @@
/**
* Lint SKILL.md files for patterns that break Claude Code's bash permission checker.
*
* Claude Code scans skill content for shell-like patterns. Inline backtick code
* containing `!` (history expansion) or `>` (output redirection) outside of fenced
* code blocks triggers false positives and prevents the skill from loading.
*
* Safe: fenced code blocks (```...```), HTML tags in backticks (`<div>`)
* Unsafe: `!` followed by `>` later in the same text block
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, relative } from "node:path";
const SKILLS_DIR = join(import.meta.dirname, "..", "skills");
interface Violation {
file: string;
line: number;
message: string;
text: string;
}
// Patterns that trigger Claude Code's bash permission checker when found in
// inline backtick spans (not fenced code blocks).
// - Backtick-wrapped `!` — interpreted as bash history expansion
// - Bare `>` outside fenced blocks when preceded by `!` — interpreted as redirection
const DANGEROUS_INLINE_PATTERNS: { pattern: RegExp; message: string }[] = [
{
// `!` in backticks triggers bash history expansion detection, which then
// causes Claude Code to scan surrounding text for `>` (redirection).
pattern: /`[^`]*![^`]*`/,
message:
'Inline backtick contains `!` — Claude Code interprets this as bash history expansion. Use the word instead (e.g., "exclamation").',
},
{
// Bare `>` followed by a word char (e.g., `>file`, `>150ms`) looks like
// output redirection. HTML tag closers (`<div>`, `</script>`) are fine
// because `>` is followed by `<`, space, backtick, or end of string.
pattern: /`[^`]*>\w[^`]*`/,
message:
'Inline backtick contains `>` followed by a word character — Claude Code may interpret this as output redirection. Rephrase (e.g., "150ms+" instead of ">150ms").',
},
];
function collectSkillFiles(dir: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...collectSkillFiles(full));
} else if (entry.name === "SKILL.md") {
files.push(full);
}
}
return files;
}
/**
* Flag YAML frontmatter that won't parse, which aborts `skills add` for the
* WHOLE repo (one bad SKILL.md blocks installing every skill).
*
* ponytail: targets the one failure mode we've actually hit — an unquoted
* top-level scalar whose value contains `: ` (colon-space), which YAML 1.2
* reads as a nested mapping ("Nested mappings are not allowed in compact
* mappings"). Not a full YAML parse; if a different malformation appears,
* swap this for a real parser (the `yaml` package).
*/
function lintFrontmatter(content: string): Omit<Violation, "file">[] {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return [];
const violations: Omit<Violation, "file">[] = [];
const fmLines = match[1].split("\n");
for (let i = 0; i < fmLines.length; i++) {
const line = fmLines[i];
// Top-level `key: value` (no indentation). Skip block scalars (> |),
// already-quoted values, and flow collections — those handle colons fine.
const m = line.match(/^([A-Za-z0-9_-]+):[ \t]+(.+)$/);
if (!m) continue;
const value = m[2].trim();
if (/^["'>|[{]/.test(value)) continue;
if (/:[ \t]/.test(value)) {
violations.push({
line: i + 2, // +1 for the opening `---`, +1 for 1-based
message:
`Unquoted frontmatter value for "${m[1]}" contains ": " — YAML reads ` +
`this as a nested mapping and the parse fails, which aborts ` +
`\`skills add\` for the entire repo. Quote the value or rephrase the colon.`,
text: line.trim(),
});
}
}
return violations;
}
/** Strip fenced code blocks so we only lint prose + inline code. */
function stripFencedBlocks(content: string): string {
return content.replace(/^```[\s\S]*?^```/gm, (match) =>
match
.split("\n")
.map(() => "")
.join("\n"),
);
}
function lintFile(filePath: string): Violation[] {
const raw = readFileSync(filePath, "utf-8");
const file = relative(process.cwd(), filePath);
const violations: Violation[] = lintFrontmatter(raw).map((v) => ({
...v,
file,
}));
const stripped = stripFencedBlocks(raw);
const lines = stripped.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
for (const { pattern, message } of DANGEROUS_INLINE_PATTERNS) {
if (pattern.test(line)) {
violations.push({
file: relative(process.cwd(), filePath),
line: i + 1,
message,
text: line.trim(),
});
}
}
}
return violations;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
if (!statSync(SKILLS_DIR, { throwIfNoEntry: false })?.isDirectory()) {
console.log("No skills/ directory found — skipping skill lint.");
process.exit(0);
}
const files = collectSkillFiles(SKILLS_DIR);
if (files.length === 0) {
console.log("No SKILL.md files found.");
process.exit(0);
}
let totalViolations = 0;
for (const file of files) {
const violations = lintFile(file);
for (const v of violations) {
console.error(`${v.file}:${v.line}: ${v.message}`);
console.error(` ${v.text}\n`);
totalViolations++;
}
}
if (totalViolations > 0) {
console.error(`\n${totalViolations} skill lint error(s) found.`);
process.exit(1);
} else {
console.log(`Checked ${files.length} skill file(s) — no issues found.`);
}
@@ -0,0 +1,82 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>page-side-compositing-smoke</title>
<!--
Loaded at smoke time: copy ../packages/shader-transitions/dist/index.global.js
into the fixture's working directory so the composition uses the LOCAL build
(containing the page-side compositor canary) instead of a CDN release.
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="./shader-transitions.global.js"></script>
<style>
html,
body {
margin: 0;
padding: 0;
background: #000;
}
#main {
position: relative;
width: 1280px;
height: 720px;
overflow: hidden;
background: #000;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.scene {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 200px;
font-weight: 800;
color: #fff;
letter-spacing: 0.08em;
}
/* Visually distinctive so a Mac viewer can eyeball parity between
the two paths: scene A is warm orange, scene B is cool blue,
the transition shader morphs UV space — the result should look
clearly intermediate at the midpoint. */
#scene-a {
background: linear-gradient(135deg, #e85d04, #f48c06);
opacity: 1;
}
#scene-b {
background: linear-gradient(135deg, #03045e, #0077b6);
opacity: 0;
}
</style>
</head>
<body>
<div
id="main"
data-composition-id="page-side-compositing-smoke"
data-start="0"
data-duration="2"
data-width="1280"
data-height="720"
>
<div id="scene-a" class="scene">A</div>
<div id="scene-b" class="scene">B</div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
// 2s composition; shader fires at t=1.0s, runs 0.5s.
// `cross-warp-morph` is FBM-warp + noise-mask — visually distinctive at
// the midpoint, exercises NQ noise helpers in the fragment shader.
HyperShader.init({
bgColor: "#000000",
accentColor: "#ffb703",
scenes: ["scene-a", "scene-b"],
transitions: [{ time: 1.0, shader: "cross-warp-morph", duration: 0.5 }],
timeline: tl,
});
window.__timelines["page-side-compositing-smoke"] = tl;
</script>
</body>
</html>
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env node
/**
* Bundled-CLI smoke for `--page-side-compositing` (opt-in spike).
*
* Validates that:
* 1. The bundled CLI accepts the new flag.
* 2. The local `@hyperframes/shader-transitions` IIFE bundle carries the
* page-side compositor canary string (build is wired correctly).
* 3. Rendering the fixture WITH and WITHOUT the flag both produce valid
* MP4s with the same duration. (Pixel-equality is NOT a correctness
* property here — see the determinism note in the PR body.)
* 4. Wall-time pair is captured for the PR body.
*
* Per `feedback_validate_bundled_cli_not_dev_path.md`: the canonical
* execution path is the BUNDLED CLI at `packages/cli/dist/cli.js`, never
* `bun run` against raw TS sources. Do not "improve" this script to use
* `bun run` — bundle-specific bugs (path resolvers, env bootstrap, lazy
* modules) are invisible to the dev path.
*
* Usage from the repo root:
*
* node scripts/page-side-compositing-smoke/run.mjs
*
* Outputs:
*
* <tmpdir>/raw.mp4 (baseline path, flag off)
* <tmpdir>/page-side.mp4 (page-side path, flag on)
* <tmpdir>/wall-times.json (wall-time pair for the PR)
*/
import { execFileSync, spawnSync } from "node:child_process";
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { dirname, join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(HERE, "..", "..");
const FIXTURE_SRC = join(HERE, "fixture");
const WORK_DIR = mkdtempSync(join(tmpdir(), "hf-page-side-smoke-"));
const FIXTURE_RUN_DIR = join(WORK_DIR, "fixture");
const CLI_PATH = join(REPO_ROOT, "packages", "cli", "dist", "cli.js");
const SHADER_BUNDLE = join(REPO_ROOT, "packages", "shader-transitions", "dist", "index.global.js");
// Canary string defined in
// `packages/shader-transitions/src/engineModePageComposite.ts` —
// kept identical here on purpose. Test broken → engineModePageComposite.test
// fails first.
const PAGE_COMPOSITOR_CANARY = "__hf_page_compositor_v1__";
function note(line) {
process.stdout.write("[smoke] " + line + "\n");
}
function fail(msg) {
process.stderr.write("[smoke] FAIL: " + msg + "\n");
process.exit(1);
}
function assertExists(path, label) {
if (!existsSync(path)) fail(label + " missing at " + path);
}
function assertCanary() {
assertExists(SHADER_BUNDLE, "shader-transitions IIFE bundle");
const buf = execFileSync("grep", ["-c", PAGE_COMPOSITOR_CANARY, SHADER_BUNDLE]);
const count = Number(buf.toString().trim());
if (count < 1) {
fail(
"shader-transitions bundle is missing the page-side compositor canary " +
`("${PAGE_COMPOSITOR_CANARY}"). Rebuild @hyperframes/shader-transitions and re-run.`,
);
}
note(`canary present in ${SHADER_BUNDLE} (${count}× hit)`);
}
function assertCliCanary() {
assertExists(CLI_PATH, "bundled CLI");
// The CLI bundle should carry the env-var key for HF_PAGE_SIDE_COMPOSITING
// and the page-side flag name. Either confirms the engine + producer +
// CLI side of the change is wired through tsup.
const cliCanaries = ["HF_PAGE_SIDE_COMPOSITING", "page-side-compositing"];
for (const needle of cliCanaries) {
const buf = execFileSync("grep", ["-c", needle, CLI_PATH]);
const count = Number(buf.toString().trim());
if (count < 1) {
fail(`bundled CLI is missing canary "${needle}". Rebuild @hyperframes/cli and re-run.`);
}
note(`CLI bundle carries "${needle}" (${count}× hit)`);
}
}
function setupFixture() {
if (existsSync(WORK_DIR)) rmSync(WORK_DIR, { recursive: true, force: true });
mkdirSync(FIXTURE_RUN_DIR, { recursive: true });
copyFileSync(join(FIXTURE_SRC, "index.html"), join(FIXTURE_RUN_DIR, "index.html"));
copyFileSync(SHADER_BUNDLE, join(FIXTURE_RUN_DIR, "shader-transitions.global.js"));
note("fixture staged at " + FIXTURE_RUN_DIR);
}
function runRender(label, outputPath, extraArgs) {
note(`render: ${label}${outputPath}`);
const argv = [
CLI_PATH,
"render",
FIXTURE_RUN_DIR,
"-o",
outputPath,
"--fps",
"30",
"--workers",
"1",
"--quality",
"draft",
"--quiet",
...extraArgs,
];
const t0 = Date.now();
const res = spawnSync("node", argv, { stdio: "inherit", cwd: REPO_ROOT });
const wallMs = Date.now() - t0;
if (res.status !== 0) {
fail(`render "${label}" exited with status ${res.status}`);
}
if (!existsSync(outputPath)) {
fail(`render "${label}" did not produce ${outputPath}`);
}
const size = statSync(outputPath).size;
if (size < 1024) {
fail(`render "${label}" produced suspiciously small output (${size} bytes)`);
}
note(` wall=${(wallMs / 1000).toFixed(2)}s size=${(size / 1024).toFixed(1)}KB`);
return { label, wallMs, sizeBytes: size, outputPath };
}
function main() {
note(`repo root: ${REPO_ROOT}`);
assertExists(CLI_PATH, "bundled CLI (packages/cli/dist/cli.js)");
assertExists(SHADER_BUNDLE, "shader-transitions IIFE bundle");
assertCliCanary();
assertCanary();
setupFixture();
const baseline = runRender("baseline (flag OFF)", join(WORK_DIR, "raw.mp4"), []);
const pageSide = runRender("page-side (flag ON)", join(WORK_DIR, "page-side.mp4"), [
"--page-side-compositing",
]);
const summary = {
baseline: { wallMs: baseline.wallMs, sizeBytes: baseline.sizeBytes },
pageSide: { wallMs: pageSide.wallMs, sizeBytes: pageSide.sizeBytes },
walltimeRatio: baseline.wallMs / Math.max(1, pageSide.wallMs),
notes:
"fixture: 2s @ 30fps, 1280x720, single cross-warp-morph transition. " +
"Wall times include CLI startup + browser launch — for a perf signal, " +
"amortize over a longer fixture on the target host (Vance's Mac).",
};
writeFileSync(join(WORK_DIR, "wall-times.json"), JSON.stringify(summary, null, 2));
note("wrote " + join(WORK_DIR, "wall-times.json"));
note("baseline: " + (baseline.wallMs / 1000).toFixed(2) + "s");
note("page-side: " + (pageSide.wallMs / 1000).toFixed(2) + "s");
note(
`ratio (baseline/page-side): ${summary.walltimeRatio.toFixed(2)}× ` +
"(>1 means page-side faster, <1 means slower — sandbox is software " +
"WebGL, so a ratio near 1 here is expected and NOT predictive of Mac).",
);
note("OK");
}
main();
+138
View File
@@ -0,0 +1,138 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
buildDraftCommandArgs,
buildSetVersionCommandArgs,
parsePrepareOptions,
resolveStableReleaseAction,
} from "./release-prepare.ts";
import {
CHANGELOG_REVIEW_TODO,
docsChangelogEntryHasGeneratedTodo,
hasGeneratedChangelogTodo,
} from "./set-version.ts";
describe("release prepare arguments", () => {
it("parses changelog draft and set-version options", () => {
assert.deepEqual(
parsePrepareOptions([
"v1.2.3",
"--from",
"v1.2.2",
"--to=HEAD",
"--date",
"2026-06-02",
"--force",
"--no-tag",
"--skip-changelog-check",
]),
{
version: "1.2.3",
from: "v1.2.2",
to: "HEAD",
date: "2026-06-02",
force: true,
skipTag: true,
skipChangelogCheck: true,
},
);
});
});
describe("release prepare actions", () => {
it("drafts when reviewed changelog artifacts are missing", () => {
assert.equal(
resolveStableReleaseAction({
missingArtifacts: ["releases/v1.2.3.md"],
unreviewedArtifacts: [],
}),
"draft",
);
});
it("blocks on review when generated TODOs are still present", () => {
assert.equal(
resolveStableReleaseAction({
missingArtifacts: [],
unreviewedArtifacts: ["docs/changelog.mdx#HyperFrames v1.2.3"],
}),
"review",
);
});
it("delegates to set-version after artifacts are reviewed", () => {
assert.equal(
resolveStableReleaseAction({
missingArtifacts: [],
unreviewedArtifacts: [],
}),
"set-version",
);
});
});
describe("release prepare command builders", () => {
it("passes only changelog options to changelog:draft", () => {
assert.deepEqual(
buildDraftCommandArgs({
version: "1.2.3",
from: "v1.2.2",
to: "HEAD",
date: "2026-06-02",
force: true,
skipTag: true,
skipChangelogCheck: true,
}),
[
"run",
"changelog:draft",
"1.2.3",
"--write",
"--force",
"--from",
"v1.2.2",
"--to",
"HEAD",
"--date",
"2026-06-02",
],
);
});
it("passes only release options to set-version", () => {
assert.deepEqual(
buildSetVersionCommandArgs({
version: "1.2.3",
from: "v1.2.2",
to: "HEAD",
date: "2026-06-02",
force: true,
skipTag: true,
skipChangelogCheck: true,
}),
["run", "set-version", "1.2.3", "--no-tag", "--skip-changelog-check"],
);
});
});
describe("reviewed changelog detection", () => {
it("detects the generated TODO marker", () => {
assert.equal(hasGeneratedChangelogTodo(CHANGELOG_REVIEW_TODO), true);
assert.equal(hasGeneratedChangelogTodo("Polished user-facing summary."), false);
});
it("checks only the matching docs changelog entry", () => {
const docs = `
<Update label="HyperFrames v1.2.4">
Reviewed summary.
</Update>
<Update label="HyperFrames v1.2.3">
${CHANGELOG_REVIEW_TODO}
</Update>
`;
assert.equal(docsChangelogEntryHasGeneratedTodo(docs, "HyperFrames v1.2.3"), true);
assert.equal(docsChangelogEntryHasGeneratedTodo(docs, "HyperFrames v1.2.4"), false);
});
});
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env tsx
import { execFileSync } from "child_process";
import { join } from "path";
import { pathToFileURL } from "url";
import {
CLI_SEMVER_PATTERN,
optionalFlagArg,
optionalValueArg,
parseVersionOptionArgument,
validateCliDate,
validateCliVersion,
type InlineValueOption,
} from "./cli-options.ts";
import {
missingChangelogArtifacts,
releaseRequiresChangelog,
unreviewedChangelogArtifacts,
} from "./set-version.ts";
type PrepareOptions = {
version: string;
from?: string;
to?: string;
date?: string;
force: boolean;
skipTag: boolean;
skipChangelogCheck: boolean;
};
type MutablePrepareOptions = Omit<PrepareOptions, "version"> & {
version?: string;
};
type ValueOptionKey = "from" | "to" | "date";
type BooleanOptionKey = "force" | "skipTag" | "skipChangelogCheck";
export type StableReleaseAction = "draft" | "review" | "set-version";
const ROOT = join(import.meta.dirname, "..");
const VALUE_OPTIONS = new Map<string, ValueOptionKey>([
["--from", "from"],
["--to", "to"],
["--date", "date"],
]);
const BOOLEAN_OPTIONS = new Map<string, BooleanOptionKey>([
["--force", "force"],
["--no-tag", "skipTag"],
["--skip-changelog-check", "skipChangelogCheck"],
]);
const INLINE_VALUE_OPTIONS = [
{ prefix: "--from=", key: "from" },
{ prefix: "--to=", key: "to" },
{ prefix: "--date=", key: "date" },
] satisfies Array<InlineValueOption<ValueOptionKey>>;
function main() {
const options = parsePrepareOptions(process.argv.slice(2));
if (!releaseRequiresChangelog(options)) {
runSetVersion(options);
return;
}
const missingArtifacts = missingChangelogArtifacts(options.version);
const unreviewedArtifacts = unreviewedChangelogArtifacts(options.version);
const action = resolveStableReleaseAction({ missingArtifacts, unreviewedArtifacts });
if (action === "draft") {
runDraft(options);
printReviewNextSteps(options.version, missingArtifacts);
process.exit(1);
}
if (action === "review") {
printReviewNextSteps(options.version, unreviewedArtifacts);
process.exit(1);
}
runSetVersion(options);
}
export function parsePrepareOptions(args: string[]): PrepareOptions {
const parsed = createDefaultOptions();
for (let index = 0; index < args.length; index += 1) {
index = parseArgument(args, index, parsed);
}
return finalizeOptions(parsed);
}
function createDefaultOptions(): MutablePrepareOptions {
return {
force: false,
skipTag: false,
skipChangelogCheck: false,
};
}
function parseArgument(args: string[], index: number, parsed: MutablePrepareOptions) {
return parseVersionOptionArgument(args, index, parsed, {
inlineValueOptions: INLINE_VALUE_OPTIONS,
valueOptions: VALUE_OPTIONS,
booleanOptions: BOOLEAN_OPTIONS,
printUsage,
fail,
});
}
function finalizeOptions(parsed: MutablePrepareOptions): PrepareOptions {
if (!parsed.version) {
printUsage();
process.exit(1);
}
validateCliVersion(parsed.version, CLI_SEMVER_PATTERN, fail);
if (parsed.date) {
validateCliDate(parsed.date, fail);
}
return {
version: parsed.version,
from: parsed.from,
to: parsed.to,
date: parsed.date,
force: parsed.force,
skipTag: parsed.skipTag,
skipChangelogCheck: parsed.skipChangelogCheck,
};
}
export function resolveStableReleaseAction(state: {
missingArtifacts: string[];
unreviewedArtifacts: string[];
}): StableReleaseAction {
if (state.missingArtifacts.length > 0) {
return "draft";
}
if (state.unreviewedArtifacts.length > 0) {
return "review";
}
return "set-version";
}
export function buildDraftCommandArgs(options: PrepareOptions) {
return [
"run",
"changelog:draft",
options.version,
"--write",
...optionalFlagArg("--force", options.force),
...optionalValueArg("--from", options.from),
...optionalValueArg("--to", options.to),
...optionalValueArg("--date", options.date),
];
}
export function buildSetVersionCommandArgs(options: PrepareOptions) {
return [
"run",
"set-version",
options.version,
...optionalFlagArg("--no-tag", options.skipTag),
...optionalFlagArg("--skip-changelog-check", options.skipChangelogCheck),
];
}
function runDraft(options: PrepareOptions) {
runBun(buildDraftCommandArgs(options));
}
function runSetVersion(options: PrepareOptions) {
runBun(buildSetVersionCommandArgs(options));
}
function runBun(args: string[]) {
try {
execFileSync("bun", args, { cwd: ROOT, stdio: "inherit" });
} catch (error) {
const status =
typeof (error as { status?: unknown }).status === "number"
? (error as { status: number }).status
: 1;
process.exit(status);
}
}
function printReviewNextSteps(version: string, artifacts: string[]) {
console.error(`\nRelease v${version} needs reviewed changelog copy before tagging.`);
if (artifacts.length > 0) {
console.error("Check:");
artifacts.forEach((artifact) => console.error(` ${artifact}`));
}
console.error("\nReview and rewrite the generated summary, remove the TODO marker, then rerun:");
console.error(` bun run release:prepare ${version}`);
console.error(
"\nThe non-zero exit is intentional so chained release commands stop before tagging.",
);
}
function printUsage() {
console.log(`Usage:
bun run release:prepare <version> [--force] [--from <ref>] [--to <ref>] [--date YYYY-MM-DD]
bun run release:prepare <version> [--no-tag] [--skip-changelog-check]
Examples:
bun run release:prepare 0.6.53
bun run release:prepare 0.6.53 --from v0.6.52 --to HEAD
bun run release:prepare 0.6.53 --force
`);
}
function fail(message: string): never {
console.error(message);
process.exit(1);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}
+163
View File
@@ -0,0 +1,163 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
changelogArtifacts,
compareSemver,
findBlockingTags,
findUnexpectedChanges,
isPrerelease,
parseReleaseOptions,
releaseAllowedPaths,
releaseRequiresChangelog,
splitNulList,
} from "./set-version.ts";
describe("set-version release options", () => {
it("parses stable release flags", () => {
assert.deepEqual(parseReleaseOptions(["1.2.3", "--no-tag", "--skip-changelog-check"]), {
version: "1.2.3",
skipTag: true,
skipChangelogCheck: true,
skipMonotonicityCheck: false,
});
});
it("parses --skip-monotonicity-check flag", () => {
assert.deepEqual(parseReleaseOptions(["0.6.82", "--skip-monotonicity-check"]), {
version: "0.6.82",
skipTag: false,
skipChangelogCheck: false,
skipMonotonicityCheck: true,
});
});
it("requires reviewed changelog artifacts for stable tagged releases", () => {
assert.equal(
releaseRequiresChangelog({
version: "1.2.3",
skipTag: false,
skipChangelogCheck: false,
}),
true,
);
});
it("does not require changelog artifacts for prereleases, no-tag bumps, or emergency skips", () => {
assert.equal(isPrerelease("1.2.3-alpha.1"), true);
assert.equal(
releaseRequiresChangelog({
version: "1.2.3-alpha.1",
skipTag: false,
skipChangelogCheck: false,
}),
false,
);
assert.equal(
releaseRequiresChangelog({
version: "1.2.3",
skipTag: true,
skipChangelogCheck: false,
}),
false,
);
assert.equal(
releaseRequiresChangelog({
version: "1.2.3",
skipTag: false,
skipChangelogCheck: true,
}),
false,
);
});
it("tracks both GitHub release and docs changelog artifacts", () => {
assert.deepEqual(changelogArtifacts("1.2.3"), [
"releases/v1.2.3.md",
"docs/changelog.mdx#HyperFrames v1.2.3",
]);
});
});
describe("tag-monotonicity guard", () => {
it("blocks a higher tag that is reachable from HEAD", () => {
assert.deepEqual(
findBlockingTags(["0.6.112", "0.7.0"], "0.6.113", () => true),
["0.7.0"],
);
});
it("ignores a higher tag that is not reachable from HEAD (orphan)", () => {
// v1.0.3 is higher but lives on a dead branch — not an ancestor of HEAD.
assert.deepEqual(
findBlockingTags(["0.6.112", "1.0.3"], "0.6.113", (t) => t !== "1.0.3"),
[],
);
});
it("ignores lower-or-equal tags regardless of reachability", () => {
assert.deepEqual(
findBlockingTags(["0.6.112", "0.6.113"], "0.6.113", () => true),
[],
);
});
});
describe("semver comparison", () => {
it("returns negative when a < b", () => {
assert.ok(compareSemver("0.6.82", "1.0.3") < 0);
});
it("returns positive when a > b", () => {
assert.ok(compareSemver("1.0.4", "0.6.82") > 0);
});
it("returns zero for equal versions", () => {
assert.equal(compareSemver("0.6.82", "0.6.82"), 0);
});
it("compares major version first", () => {
assert.ok(compareSemver("2.0.0", "1.99.99") > 0);
});
it("compares minor when major is equal", () => {
assert.ok(compareSemver("0.7.0", "0.6.99") > 0);
});
it("compares patch when major and minor are equal", () => {
assert.ok(compareSemver("0.6.83", "0.6.82") > 0);
});
});
describe("changed-path guard", () => {
it("splits NUL-separated git output and drops the empty trailing entry", () => {
assert.deepEqual(splitNulList("packages/core/package.json\0releases/v1.2.3.md\0"), [
"packages/core/package.json",
"releases/v1.2.3.md",
]);
assert.deepEqual(splitNulList(""), []);
});
it("accepts a release whose changes are all allowed paths", () => {
const allowed = releaseAllowedPaths("1.2.3");
assert.deepEqual(
findUnexpectedChanges(
[
"packages/core/package.json",
"packages/sdk/package.json",
".claude-plugin/plugin.json",
"releases/v1.2.3.md",
],
allowed,
),
[],
);
});
it("flags changes outside the allowed release paths", () => {
const allowed = releaseAllowedPaths("1.2.3");
assert.deepEqual(
findUnexpectedChanges(["packages/core/package.json", "packages/core/src/index.ts"], allowed),
["packages/core/src/index.ts"],
);
});
});
+366
View File
@@ -0,0 +1,366 @@
#!/usr/bin/env tsx
/**
* Set the version across all publishable packages and plugins in the monorepo,
* then create a git commit and tag.
*
* Usage:
* bun run set-version 0.1.1 # stable release → npm "latest" tag
* bun run set-version 0.1.1-alpha.1 # pre-release → npm "alpha" tag
* bun run set-version 0.1.1 --no-tag # bump only (no commit or tag)
* bun run set-version 0.1.1 --skip-changelog-check # emergency stable release
*
* All packages and plugins share a single version number (fixed versioning).
* Pre-release suffixes (-alpha, -beta, -rc, etc.) are detected by the
* publish workflow and published to the corresponding npm dist-tag.
*/
import { existsSync, readFileSync, writeFileSync } from "fs";
import { join } from "path";
import { execFileSync } from "child_process";
import { pathToFileURL } from "url";
import { CLI_SEMVER_PATTERN } from "./cli-options.ts";
const PACKAGES = [
"packages/parsers",
"packages/lint",
"packages/studio-server",
"packages/core",
"packages/engine",
"packages/player",
"packages/producer",
"packages/shader-transitions",
"packages/studio",
"packages/cli",
"packages/aws-lambda",
"packages/gcp-cloud-run",
"packages/sdk",
];
const PLUGINS = [".claude-plugin", ".codex-plugin", ".cursor-plugin"];
const ROOT = join(import.meta.dirname, "..");
export const CHANGELOG_REVIEW_TODO = "<!-- TODO: write a 1-2 sentence release summary here. -->";
type ReleaseOptions = {
version: string;
skipTag: boolean;
skipChangelogCheck: boolean;
skipMonotonicityCheck: boolean;
};
function main() {
const options = parseReleaseOptions(process.argv.slice(2));
if (releaseRequiresChangelog(options)) {
assertReviewedChangelog(options.version);
}
updatePackageVersions(options.version);
updatePluginVersions(options.version);
console.log(
`\nSet ${PACKAGES.length} packages and ${PLUGINS.length} plugin manifests to v${options.version}`,
);
if (options.skipTag) {
console.log(`\nSkipped commit and tag (--no-tag). Remember to commit and tag manually.`);
return;
}
createReleaseCommitAndTag(options.version, options.skipMonotonicityCheck);
printReleaseNextSteps(options.version);
}
export function parseReleaseOptions(args: string[]): ReleaseOptions {
const version = args.find((a) => !a.startsWith("--"));
const skipTag = args.includes("--no-tag");
const skipChangelogCheck = args.includes("--skip-changelog-check");
const skipMonotonicityCheck = args.includes("--skip-monotonicity-check");
if (!version) {
console.error(
"Usage: bun run set-version <version> [--no-tag] [--skip-changelog-check] [--skip-monotonicity-check]",
);
console.error("Example: bun run set-version 0.1.1");
process.exit(1);
}
if (!CLI_SEMVER_PATTERN.test(version)) {
console.error(`Invalid semver: ${version}`);
process.exit(1);
}
return { version, skipTag, skipChangelogCheck, skipMonotonicityCheck };
}
function updatePackageVersions(version: string) {
for (const pkg of PACKAGES) {
const pkgPath = join(ROOT, pkg, "package.json");
const content = JSON.parse(readFileSync(pkgPath, "utf-8"));
const oldVersion = content.version;
content.version = version;
writeFileSync(pkgPath, JSON.stringify(content, null, 2) + "\n");
console.log(` ${content.name}: ${oldVersion} -> ${version}`);
}
}
function updatePluginVersions(version: string) {
// Update each plugin.json. Replace just the version string rather than
// round-tripping through JSON.parse/stringify: oxfmt keeps these manifests'
// short arrays inline, but JSON.stringify expands them, which would fail the
// pre-commit format check on the release commit this script creates.
for (const plugin of PLUGINS) {
const pluginPath = join(ROOT, plugin, "plugin.json");
const text = readFileSync(pluginPath, "utf-8");
const oldVersion = text.match(/"version"\s*:\s*"([^"]*)"/)?.[1] ?? "unknown";
writeFileSync(pluginPath, text.replace(/("version"\s*:\s*)"[^"]*"/, `$1"${version}"`));
console.log(` ${plugin}: ${oldVersion} -> ${version}`);
}
}
function createReleaseCommitAndTag(version: string, skipMonotonicityCheck: boolean = false) {
if (!skipMonotonicityCheck) {
assertTagMonotonicity(version);
}
const allowedPaths = releaseAllowedPaths(version);
assertNoUnexpectedChanges(collectChangedPaths(), allowedPaths);
// Pass git arguments as an array (execFileSync, no shell) so the interpolated
// version and paths can never be interpreted as shell commands.
const pathsToAdd = allowedPaths.filter((path) => existsSync(join(ROOT, path)));
execFileSync("git", ["add", ...pathsToAdd], { cwd: ROOT, stdio: "inherit" });
execFileSync("git", ["commit", "-m", `chore: release v${version}`], {
cwd: ROOT,
stdio: "inherit",
});
// Annotated tag (-a -m): works regardless of a contributor's git config; a
// lightweight `git tag` fails ("no tag message?") when tag.forceSignAnnotated
// or similar is set globally.
execFileSync("git", ["tag", "-a", `v${version}`, "-m", `v${version}`], {
cwd: ROOT,
stdio: "inherit",
});
console.log(`\nCreated commit and tag v${version}`);
}
export function compareSemver(a: string, b: string): number {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
}
return 0;
}
function tagReachableFromHead(tag: string): boolean {
// `merge-base --is-ancestor` exits 0 when v<tag> is an ancestor of HEAD, 1
// otherwise. Orphan tags (abandoned release attempts on dead branches) are
// NOT ancestors, so they return false and are ignored by the guard below.
try {
execFileSync("git", ["merge-base", "--is-ancestor", `v${tag}`, "HEAD"], {
cwd: ROOT,
stdio: "ignore",
});
return true;
} catch {
return false;
}
}
/**
* Tags that would hijack a tag-sorting installer for this line: BOTH
* semver-higher than the release AND reachable from the release commit. A
* higher tag that isn't an ancestor of HEAD (e.g. a stray `chore: release`
* commit on a dead branch that was never published) can't appear in this
* history and is excluded — it should not block a legitimate release.
*/
export function findBlockingTags(
stableVersions: string[],
version: string,
isReachable: (tag: string) => boolean,
): string[] {
return stableVersions.filter((t) => compareSemver(t, version) > 0 && isReachable(t));
}
function assertTagMonotonicity(version: string) {
if (isPrerelease(version)) return;
let tags: string;
try {
tags = execFileSync("git", ["tag", "--list", "v[0-9]*"], {
cwd: ROOT,
encoding: "utf-8",
});
} catch {
return;
}
const stableVersions = tags
.trim()
.split("\n")
.filter((t) => t && !t.includes("-"))
.map((t) => t.replace(/^v/, ""));
const blocking = findBlockingTags(stableVersions, version, tagReachableFromHead);
if (blocking.length === 0) return;
const existing = blocking[0];
console.error(
`\nTag v${existing} already exists, is reachable from HEAD, and is semver-higher than v${version}.`,
);
console.error(`Tag-sorting installers (npx skills, etc.) would resolve the wrong version.`);
console.error(`\nOptions:`);
console.error(
` Delete the stale tag: git tag -d v${existing} && git push origin :refs/tags/v${existing}`,
);
console.error(` Skip this check: bun run set-version ${version} --skip-monotonicity-check`);
process.exit(1);
}
export function releaseRequiresChangelog(options: ReleaseOptions) {
return !options.skipTag && !options.skipChangelogCheck && !isPrerelease(options.version);
}
export function isPrerelease(version: string) {
return version.includes("-");
}
function assertReviewedChangelog(version: string) {
const missing = missingChangelogArtifacts(version);
const unreviewed = unreviewedChangelogArtifacts(version);
if (missing.length > 0 || unreviewed.length > 0) {
console.error("\nChangelog review required:");
missing.forEach((artifact) => console.error(` ${artifact}`));
unreviewed.forEach((artifact) =>
console.error(` ${artifact} still contains the generated TODO summary`),
);
console.error(`\nRun: bun run release:prepare ${version}`);
console.error(
"Review and rewrite the generated release notes, then rerun release:prepare. Use --skip-changelog-check only for emergency releases.",
);
process.exit(1);
}
}
export function missingChangelogArtifacts(version: string) {
return changelogArtifacts(version).filter((artifact) => !artifactExists(artifact));
}
export function changelogArtifacts(version: string) {
return [join("releases", `v${version}.md`), `docs/changelog.mdx#HyperFrames v${version}`];
}
export function unreviewedChangelogArtifacts(version: string) {
return changelogArtifacts(version).filter(
(artifact) => artifactExists(artifact) && artifactHasGeneratedTodo(artifact),
);
}
function artifactExists(artifact: string) {
const [path, marker] = artifact.split("#");
const absolutePath = join(ROOT, path);
if (!existsSync(absolutePath)) {
return false;
}
return marker ? readFileSync(absolutePath, "utf-8").includes(`label="${marker}"`) : true;
}
function artifactHasGeneratedTodo(artifact: string) {
const [path, marker] = artifact.split("#");
const content = readFileSync(join(ROOT, path), "utf-8");
if (!marker) {
return hasGeneratedChangelogTodo(content);
}
return docsChangelogEntryHasGeneratedTodo(content, marker);
}
export function hasGeneratedChangelogTodo(content: string) {
return content.includes(CHANGELOG_REVIEW_TODO);
}
export function docsChangelogEntryHasGeneratedTodo(content: string, marker: string) {
const labelIndex = content.indexOf(`label="${marker}"`);
if (labelIndex === -1) {
return false;
}
const entryStart = content.lastIndexOf("<Update", labelIndex);
const entryEnd = content.indexOf("</Update>", labelIndex);
const entry = content.slice(
entryStart === -1 ? labelIndex : entryStart,
entryEnd === -1 ? undefined : entryEnd + "</Update>".length,
);
return hasGeneratedChangelogTodo(entry);
}
export function releaseAllowedPaths(version: string) {
return [
...PACKAGES.map((pkg) => join(pkg, "package.json")),
...PLUGINS.map((plugin) => join(plugin, "plugin.json")),
"docs/changelog.mdx",
join("releases", `v${version}.md`),
];
}
// Collect every uncommitted path (modified-tracked + untracked) as clean,
// repo-relative paths. We deliberately use `diff --name-only` and `ls-files`
// with `-z` rather than parsing `git status --porcelain`: the porcelain
// "XY <path>" prefix width shifts with stage state, and a fixed-width slice
// of it once mis-read a legitimate release file (`.claude-plugin/plugin.json`)
// as an unexpected change, falsely blocking a release. These two commands
// emit bare NUL-separated paths with no status column to misparse.
function collectChangedPaths(): string[] {
const tracked = execFileSync("git", ["diff", "--name-only", "-z", "HEAD"], {
cwd: ROOT,
encoding: "utf-8",
});
const untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], {
cwd: ROOT,
encoding: "utf-8",
});
return [...splitNulList(tracked), ...splitNulList(untracked)];
}
export function splitNulList(output: string): string[] {
return output.split("\0").filter(Boolean);
}
export function findUnexpectedChanges(changedPaths: string[], allowedPaths: string[]): string[] {
const allowed = new Set(allowedPaths);
return changedPaths.filter((path) => !allowed.has(path));
}
function assertNoUnexpectedChanges(changedPaths: string[], allowedPaths: string[]) {
const unexpected = findUnexpectedChanges(changedPaths, allowedPaths);
if (unexpected.length > 0) {
console.error("\nUnexpected uncommitted changes:");
unexpected.forEach((path) => console.error(` ${path}`));
console.error("Commit or stash these before releasing.");
process.exit(1);
}
}
function printReleaseNextSteps(version: string) {
if (isPrerelease(version)) {
const distTag = version.replace(/^.*-([a-zA-Z]+).*$/, "$1");
console.log(`\nThis is a pre-release — npm dist-tag will be "${distTag}" (not "latest").`);
console.log(`Consumers install with: npm install @hyperframes/core@${distTag}`);
console.log(`\nRun 'git push origin v${version}' to trigger the publish workflow.`);
} else {
console.log(`\nRun the following to trigger the publish workflow:`);
console.log(` git push origin main`);
console.log(` git push origin v${version}`);
console.log(
`(push the specific tag, NOT 'git push --tags' — that fails on any pre-existing tag).`,
);
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env node
import { createRequire } from "node:module";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = join(import.meta.dirname, "..");
const PROJECT_ID = "smoke-test";
export const SMOKE_COMPOSITION_HTML =
'<!doctype html><html><body><div data-composition-id="root" data-width="1920" ' +
'data-height="1080" data-duration="1" data-start="0"><div class="clip" ' +
'data-hf-id="title" data-start="0" data-duration="1">Test</div></div></body></html>';
function json(body, status = 200) {
return { status, contentType: "application/json", body: JSON.stringify(body) };
}
function text(body, contentType = "text/plain") {
return { status: 200, contentType, body };
}
const PROJECT_PATH = `/api/projects/${PROJECT_ID}`;
const GET_RESPONSES = new Map([
[
"/api/projects",
json({ projects: [{ id: PROJECT_ID, dir: "/tmp/smoke-test", title: "Smoke test" }] }),
],
[
PROJECT_PATH,
json({
id: PROJECT_ID,
dir: "/tmp/smoke-test",
title: "Smoke test",
files: ["index.html"],
compositions: ["index.html"],
}),
],
[`${PROJECT_PATH}/preview`, text(SMOKE_COMPOSITION_HTML, "text/html")],
[`${PROJECT_PATH}/renders`, json({ renders: [] })],
[`${PROJECT_PATH}/lint`, json({ findings: [] })],
[
`${PROJECT_PATH}/storyboard`,
json({
exists: false,
path: "STORYBOARD.md",
globals: { extra: {} },
frames: [],
warnings: [],
script: { exists: false, path: "SCRIPT.md", content: "" },
}),
],
[`${PROJECT_PATH}/selection`, json({ selection: null, updatedAt: null })],
["/api/registry/blocks", json([])],
["/api/fonts", json({ fonts: [] })],
["/api/fonts/google", json({ fonts: [] })],
["/api/assets/global", json({ assets: [] })],
]);
const MUTATION_RESPONSES = new Map([
[`${PROJECT_PATH}/selection`, json({ ok: true, selection: null, updatedAt: null })],
]);
function projectFileResponse(pathname) {
if (!pathname.startsWith(`${PROJECT_PATH}/files/`)) return undefined;
const filename = decodeURIComponent(pathname.split("/files/")[1] ?? "");
return json({
filename,
content: filename === "index.html" ? SMOKE_COMPOSITION_HTML : "",
});
}
function projectPreviewResponse(pathname) {
if (!pathname.startsWith(`${PROJECT_PATH}/preview/`)) return undefined;
return pathname.endsWith("/.media/manifest.jsonl")
? text("", "application/x-ndjson")
: text(SMOKE_COMPOSITION_HTML, "text/html");
}
function gsapAnimationsResponse(pathname) {
if (!pathname.startsWith(`${PROJECT_PATH}/gsap-animations/`)) return undefined;
return json({ animations: [], timelineVar: "tl", preamble: "", postamble: "" });
}
function getStudioSmokeResponse(pathname) {
return (
GET_RESPONSES.get(pathname) ??
projectFileResponse(pathname) ??
projectPreviewResponse(pathname) ??
gsapAnimationsResponse(pathname)
);
}
function studioSmokeApiPathResponse(method, pathname) {
return method === "GET"
? (getStudioSmokeResponse(pathname) ?? null)
: (MUTATION_RESPONSES.get(pathname) ?? null);
}
export function studioSmokeApiResponse(method, requestUrl) {
const { pathname } = new URL(requestUrl);
return pathname.startsWith("/api/") ? studioSmokeApiPathResponse(method, pathname) : undefined;
}
export function isExpectedStudioSmokeError(message) {
return message.includes("favicon.ico");
}
async function loadPuppeteer() {
const requireFromProducer = createRequire(join(ROOT, "packages", "producer", "package.json"));
return requireFromProducer("puppeteer");
}
export async function runStudioRuntimeSmoke(targetUrl) {
const puppeteer = await loadPuppeteer();
const browser = await puppeteer.launch({
headless: "new",
args: ["--no-sandbox", "--disable-setuid-sandbox"],
});
const page = await browser.newPage();
const errors = [];
const unmockedApiRequests = [];
page.on("pageerror", (error) => errors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
await page.setRequestInterception(true);
page.on("request", (request) => {
const response = studioSmokeApiResponse(request.method(), request.url());
if (response === undefined) {
void request.continue();
return;
}
if (response === null) {
unmockedApiRequests.push(`${request.method()} ${new URL(request.url()).pathname}`);
void request.respond(json({ error: "unmocked smoke endpoint" }, 501));
return;
}
void request.respond(response);
});
try {
await page.goto(targetUrl, { waitUntil: "networkidle0", timeout: 30_000 });
await new Promise((resolve) => setTimeout(resolve, 3_000));
const errorBoundary = await page.evaluate(() => {
const textContent = document.body.innerText;
return textContent.includes("Something went wrong") ? textContent : null;
});
if (errorBoundary) errors.push(`React error boundary triggered: ${errorBoundary}`);
} finally {
await browser.close();
}
const fatal = errors.filter((error) => !isExpectedStudioSmokeError(error));
const failures = [
...new Set(unmockedApiRequests.map((request) => `unmocked API request: ${request}`)),
...fatal,
];
if (failures.length > 0) {
throw new Error(
`Studio runtime smoke failed:\n${failures.map((error) => `- ${error}`).join("\n")}`,
);
}
}
async function main() {
const targetUrl = process.argv[2] ?? "http://localhost:5199/#project=smoke-test";
await runStudioRuntimeSmoke(targetUrl);
console.log("PASS: studio loaded with schema-valid API fixtures and no runtime errors");
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
}
+58
View File
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
isExpectedStudioSmokeError,
SMOKE_COMPOSITION_HTML,
studioSmokeApiResponse,
} from "./studio-runtime-smoke.mjs";
function bodyOf(method, path) {
const response = studioSmokeApiResponse(method, `http://localhost:5199${path}`);
assert.ok(response && response !== null);
return JSON.parse(response.body);
}
describe("Studio runtime smoke fixtures", () => {
it("matches the collection and project-detail API schemas", () => {
assert.deepEqual(bodyOf("GET", "/api/projects"), {
projects: [{ id: "smoke-test", dir: "/tmp/smoke-test", title: "Smoke test" }],
});
assert.deepEqual(bodyOf("GET", "/api/projects/smoke-test"), {
id: "smoke-test",
dir: "/tmp/smoke-test",
title: "Smoke test",
files: ["index.html"],
compositions: ["index.html"],
});
});
it("returns file content rather than the unrelated file-tree shape", () => {
assert.deepEqual(bodyOf("GET", "/api/projects/smoke-test/files/index.html"), {
filename: "index.html",
content: SMOKE_COMPOSITION_HTML,
});
});
it("returns iterable collections for eager Studio queries", () => {
assert.deepEqual(bodyOf("GET", "/api/projects/smoke-test/renders"), { renders: [] });
assert.deepEqual(bodyOf("GET", "/api/projects/smoke-test/gsap-animations/index.html"), {
animations: [],
timelineVar: "tl",
preamble: "",
postamble: "",
});
assert.deepEqual(bodyOf("GET", "/api/projects/smoke-test/lint"), { findings: [] });
});
it("marks unknown API requests as fixture failures", () => {
assert.equal(studioSmokeApiResponse("GET", "http://localhost:5199/api/new-endpoint"), null);
assert.equal(studioSmokeApiResponse("GET", "http://localhost:5199/src/main.tsx"), undefined);
});
it("does not suppress generic JavaScript or network failures", () => {
assert.equal(isExpectedStudioSmokeError("Cannot read properties of undefined"), false);
assert.equal(isExpectedStudioSmokeError("value is not iterable"), false);
assert.equal(isExpectedStudioSmokeError("Failed to fetch"), false);
assert.equal(isExpectedStudioSmokeError("GET /favicon.ico 404"), true);
});
});
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env tsx
/**
* Mirror JSON Schemas from `packages/core/schemas/` into `docs/schema/` so
* Mintlify serves them at `https://hyperframes.heygen.com/schema/*`. The core
* copies stay authoritative — they're exported from `@hyperframes/core` for
* npm consumers — and this script is the single contract that prevents the
* docs mirror from drifting.
*
* Usage:
* bun run sync-schemas # copy core → docs
* bun run sync-schemas --check # exit non-zero if copies are stale (CI)
*
* `docs/schema/hyperframes.json` is authored directly in docs (no source in
* core) so it's skipped by this script.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const ROOT = join(import.meta.dirname, "..");
const SOURCE_DIR = join(ROOT, "packages/core/schemas");
const TARGET_DIR = join(ROOT, "docs/schema");
const MIRRORED = ["registry.json", "registry-item.json"];
function main() {
const checkOnly = process.argv.includes("--check");
let drift = 0;
for (const name of MIRRORED) {
const source = readFileSync(join(SOURCE_DIR, name), "utf-8");
const targetPath = join(TARGET_DIR, name);
const target = (() => {
try {
return readFileSync(targetPath, "utf-8");
} catch {
return null;
}
})();
if (target === source) {
console.log(`${name} in sync`);
continue;
}
drift++;
if (checkOnly) {
console.error(`${name} out of sync (run \`bun run sync-schemas\` to fix)`);
continue;
}
writeFileSync(targetPath, source);
console.log(`${name} updated`);
}
if (checkOnly && drift > 0) {
console.error(`\n${drift} schema${drift === 1 ? "" : "s"} drifted from source.`);
process.exit(1);
}
}
main();
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env bash
# test-skills-fresh.sh
#
# Generic sandbox for the `test/skills-fresh` branch — fully simulates a real
# user's `npx` install, with BOTH channels coming from the working tree:
# • skills → installed from skills/ via `npx skills add <repo>` (exactly what
# `npx skills add heygen-com/hyperframes` does for a real user)
# • CLI → wired via a `file:` dep so `npx hyperframes` resolves to the LOCAL
# build, which carries this branch's packages/cli/src/capture changes.
# It adds NO CLAUDE.md / AGENTS.md — it mirrors the plain install, nothing more.
# You then launch your agent in the sandbox and type whatever request you want.
#
# Agents: works for Claude Code (default) and Codex. `--agent` is passed straight
# to `skills add`, so the skills land in that agent's project dir:
# • claude-code → .claude/skills/ (launch: claude --dangerously-skip-permissions)
# • codex → .agents/skills/ (launch: codex --dangerously-bypass-approvals-and-sandbox)
# ← both launch fully auto (no approval prompts); codex stays
# project-local and does NOT touch your global ~/.codex/skills.
#
# Why a sandbox (and not `npx skills add heygen-com/hyperframes#test/skills-fresh`):
# `skills add` only copies skills/. The capture tool you changed lives in
# packages/cli (the @hyperframes/cli package), so an online skills-only install
# would pull this branch's skills but the PUBLISHED CLI's old capture. This
# script builds + file:-links the local CLI so capture comes from the branch too.
#
# Usage:
# bash scripts/test-skills-fresh.sh # Claude Code (default)
# bash scripts/test-skills-fresh.sh --agent codex # Codex
# bash scripts/test-skills-fresh.sh --rebuild # force a CLI rebuild
# bash scripts/test-skills-fresh.sh --no-build # skip the build step
# bash scripts/test-skills-fresh.sh -h # help
#
# What it does:
# 1. Verifies prerequisites (bun, npm, the chosen agent, optionally Chrome).
# 2. Builds the local CLI if dist/cli.js is missing OR any packages/cli source
# is newer than the built bundle (so your capture edits are never tested stale).
# 3. Creates a fresh WORKSPACE ROOT under /tmp/skills-fresh-<timestamp>/ with a
# package.json (`file:` CLI dep). It does NOT init a hyperframes project here
# — the video workflows run `npx hyperframes init` inside their own subdirs.
# 4. Runs npm install so `npx hyperframes` resolves to the local CLI build.
# 5. Installs the full skills tree from the LOCAL repo via `npx skills add
# --agent <agent>`, then prunes the internal _meta/ authoring skills so the
# installed set matches what an end user gets.
# 6. Verifies the router + 10 workflows + 6 domain skills landed.
# 7. Prints the command to start the agent + example prompts to try.
#
# Iterate after editing:
# • skills → re-run this script (fresh dir), or in the existing test dir:
# rm -rf <skills-dir>/<name> && npx --yes skills add <repo> \
# --skill <name> --agent <agent> --yes
# • capture / CLI → just re-run this script; step 2's staleness check rebuilds.
set -uo pipefail
# --------- defaults ---------
EXPECTED_BRANCH="test/skills-fresh"
AGENT="claude-code"
# --------- arg parse ---------
BUILD_MODE="auto" # auto | force | skip
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
--agent) AGENT="${2:-}"; shift 2 ;;
--rebuild) BUILD_MODE="force"; shift ;;
--no-build) BUILD_MODE="skip"; shift ;;
*)
echo "Unknown arg: $1" >&2
echo "Run with --help for usage." >&2
exit 1
;;
esac
done
if [[ -z "$AGENT" ]]; then
echo "--agent needs a value (e.g. claude-code, codex)" >&2
exit 1
fi
# Map the agent to its project skills dir + launch binary.
case "$AGENT" in
claude-code) SKILLS_DIR=".claude/skills"; AGENT_BIN="claude"; LAUNCH="claude --dangerously-skip-permissions" ;;
codex) SKILLS_DIR=".agents/skills"; AGENT_BIN="codex"; LAUNCH="codex --dangerously-bypass-approvals-and-sandbox" ;;
*) SKILLS_DIR=".agents/skills"; AGENT_BIN="$AGENT"; LAUNCH="$AGENT" ;;
esac
# --------- self-locate the hyperframes repo ---------
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HF_REPO="$(cd "$SCRIPT_DIR/.." && pwd)"
HF_CLI_PKG="$HF_REPO/packages/cli"
HF_CLI_BIN="$HF_CLI_PKG/dist/cli.js"
# --------- pretty output helpers ---------
say() { printf "\033[1;36m→ %s\033[0m\n" "$*"; }
ok() { printf " \033[0;32m✓\033[0m %s\n" "$*"; }
warn() { printf " \033[0;33m! %s\033[0m\n" "$*"; }
fail() { printf " \033[0;31m✗ %s\033[0m\n" "$*"; exit 1; }
# --------- step 1: prerequisites ---------
say "Checking prerequisites (agent: $AGENT)..."
command -v bun >/dev/null 2>&1 || fail "bun not installed. Install: curl -fsSL https://bun.sh/install | bash"
command -v npm >/dev/null 2>&1 || fail "npm not installed (need Node.js — install Node 22+)."
ok "bun: $(bun --version)"
ok "node: $(node --version)"
ok "npm: $(npm --version)"
if command -v "$AGENT_BIN" >/dev/null 2>&1; then
ok "$AGENT_BIN on PATH"
else
warn "$AGENT_BIN not on PATH — install the $AGENT CLI before running the test."
fi
CHROME_MAC="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
CHROME_LINUX="/usr/bin/chromium"
if [[ -x "$CHROME_MAC" ]] || [[ -x "$CHROME_LINUX" ]]; then
ok "Chrome / Chromium found (capture / web-extraction needs headless Chrome)"
else
warn "No Chrome at $CHROME_MAC or $CHROME_LINUX — capture-based workflows will fail without it"
fi
CURRENT_BRANCH="$(cd "$HF_REPO" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
if [[ "$CURRENT_BRANCH" != "$EXPECTED_BRANCH" ]]; then
warn "Repo is on '$CURRENT_BRANCH', not '$EXPECTED_BRANCH' — you'll be testing whatever is checked out."
else
ok "repo on branch: $CURRENT_BRANCH"
fi
# --------- step 2: build local CLI (staleness-aware) ---------
say "Checking local CLI build..."
needs_build() {
[[ ! -f "$HF_CLI_BIN" ]] && return 0
# any CLI source (incl. capture/) newer than the built bundle → rebuild,
# so your working-tree edits are never silently tested against a stale dist.
local newer
newer="$(find "$HF_CLI_PKG/src" "$HF_CLI_PKG/scripts" -type f -newer "$HF_CLI_BIN" 2>/dev/null | head -1)"
[[ -n "$newer" ]] && return 0
return 1
}
DO_BUILD=0
case "$BUILD_MODE" in
force) DO_BUILD=1; warn "--rebuild: forcing a fresh CLI build" ;;
skip) warn "--no-build: skipping build; using existing dist (may be stale!)" ;;
auto) if needs_build; then
DO_BUILD=1
[[ -f "$HF_CLI_BIN" ]] && warn "CLI source is newer than dist — rebuilding to pick up your capture/CLI edits" \
|| warn "CLI not built — building (~1-2 min)..."
fi ;;
esac
if [[ "$DO_BUILD" == "1" ]]; then
(cd "$HF_REPO" && bun install && bun run build) || fail "CLI build failed."
[[ -f "$HF_CLI_BIN" ]] || fail "Build completed but $HF_CLI_BIN still missing."
fi
ok "local CLI: $(node "$HF_CLI_BIN" --version 2>/dev/null || echo unknown)"
# --------- step 3: scaffold a fresh test project ---------
TEST_PARENT="${TEST_PARENT:-/tmp}"
TEST_NAME="skills-fresh-$(date +%H%M%S)"
TEST_DIR="$TEST_PARENT/$TEST_NAME"
say "Creating test project at $TEST_DIR ..."
mkdir -p "$TEST_PARENT"
cd "$TEST_PARENT"
[[ -e "$TEST_NAME" ]] && fail "$TEST_DIR already exists. Wait 1s and re-run."
# WORKSPACE ROOT, not a hyperframes project: the video workflows run
# `npx hyperframes init` inside their own subdirs, so a project at the root would
# make a skill find a stray composition here. We only need a package.json with the
# `file:` CLI dep so `npx hyperframes` (and the skills' init/render calls from
# subdirs) resolve to the local build.
mkdir -p "$TEST_NAME"
cd "$TEST_NAME"
cat > package.json <<JSON
{
"name": "$TEST_NAME",
"private": true,
"type": "module",
"dependencies": {
"hyperframes": "file:$HF_CLI_PKG"
}
}
JSON
ok "package.json points hyperframes → file:$HF_CLI_PKG"
# --------- step 4: npm install (NOT bun) ---------
# MUST be npm: bun follows the cli pkg's `workspace:*` devDependencies and fails.
# npm only resolves the file: package's `dependencies`.
say "Running npm install (must be npm here, not bun)..."
npm install --no-audit --no-fund --silent || fail "npm install failed."
[[ -x "node_modules/.bin/hyperframes" ]] || fail "node_modules/.bin/hyperframes missing after install."
ok "node_modules/.bin/hyperframes → local CLI"
# --------- step 5: install skills from the local repo, then prune _meta ---------
say "Installing skills from the local repo (--agent $AGENT) ..."
npx --yes skills add "$HF_REPO" --skill '*' --agent "$AGENT" --yes \
|| fail "skills add failed."
# Resolve where they actually landed (claude-code → .claude/skills,
# codex/others → .agents/skills); fall back to whichever dir got populated.
if [[ ! -d "$SKILLS_DIR" ]]; then
for d in .claude/skills .agents/skills .cursor/skills; do
[[ -d "$d" ]] && SKILLS_DIR="$d" && break
done
fi
[[ -d "$SKILLS_DIR" ]] || fail "No skills dir found after install (looked for .claude/skills, .agents/skills, .cursor/skills)."
ok "skills installed under $SKILLS_DIR/"
# skills add walks skills/_meta/ too — those are internal authoring skills, not
# part of the end-user set. Prune them so the sandbox matches a real install.
if [[ -d "$HF_REPO/skills/_meta" ]]; then
for meta in "$HF_REPO/skills/_meta"/*/; do
[[ -d "$meta" ]] || continue
name="$(basename "$meta")"
if [[ -d "$SKILLS_DIR/$name" ]]; then
rm -rf "$SKILLS_DIR/$name"
ok "pruned internal meta-skill: $name"
fi
done
fi
# --------- step 6: verify the skills landed ---------
say "Verifying skill installation..."
ROUTER="hyperframes"
WORKFLOWS=(product-launch-video website-to-video faceless-explainer embedded-captions \
talking-head-recut pr-to-video motion-graphics general-video \
remotion-to-hyperframes slideshow)
DOMAIN=(hyperframes-core hyperframes-creative hyperframes-animation hyperframes-cli media-use hyperframes-registry)
MISSING=()
check_skill() { if [[ -d "$SKILLS_DIR/$1" ]]; then ok "$SKILLS_DIR/$1/"; else MISSING+=("$1"); fi; }
check_skill "$ROUTER"
for s in "${WORKFLOWS[@]}"; do check_skill "$s"; done
for s in "${DOMAIN[@]}"; do check_skill "$s"; done
if [[ ${#MISSING[@]} -gt 0 ]]; then
warn "Missing skill(s): ${MISSING[*]}"
warn "Check skills/ in the repo and re-run — routing / dispatch will break without these."
fi
INSTALLED_COUNT=$(find "$SKILLS_DIR" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | wc -l | tr -d ' ')
ok "$INSTALLED_COUNT skill(s) installed under $SKILLS_DIR/"
# --------- step 7: print next steps ---------
echo ""
printf "\033[1;32m========================================================\033[0m\n"
printf "\033[1;32m Sandbox ready — branch skills + branch CLI (capture).\033[0m\n"
printf "\033[1;32m========================================================\033[0m\n"
echo ""
echo "Project: $TEST_DIR"
echo "Agent: $AGENT (skills in $SKILLS_DIR/)"
echo "CLI: file:$HF_CLI_PKG (local build — includes your capture changes)"
echo "Branch: $CURRENT_BRANCH"
echo ""
echo "To start, run:"
echo ""
printf " \033[1;37mcd %s\033[0m\n" "$TEST_DIR"
printf " \033[1;37m%s\033[0m\n" "$LAUNCH"
echo ""
echo "Then type any request you want to test — the agent routes it to a workflow. e.g.:"
echo " • \"make a product launch video for https://your-site.com/\" → product-launch-video (exercises capture)"
echo " • \"explain how transformers work as a faceless explainer video\" → faceless-explainer"
echo " • \"make a video from this PR: owner/repo#123\" → pr-to-video"
echo " • \"add lower-thirds / overlay cards to ./clip.mp4\" → talking-head-recut"
echo " • \"add captions/subtitles to ./clip.mp4\" → embedded-captions"
echo " • \"turn https://your-site.com/ into a site tour video\" → website-to-video"
echo " • \"a logo reveal / title card / data montage\" → general-video"
echo ""
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
#
# Upload docs/images/ to the HeyGen public CDN.
#
# Docs previews (mp4/png/gif) are served from https://static.heygen.ai/hyperframes-oss/docs/images/
# rather than committed to the repo. After regenerating previews with
# `scripts/generate-catalog-previews.ts` or `scripts/generate-template-previews.ts`,
# run this script to publish the new files.
#
# Requires AWS credentials for the heygen engineering account (profile: engineering-767398024897).
# Contributors without AWS access: open a PR with the HTML/MDX changes and a
# maintainer will run the generators + this upload before merging.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SRC="$REPO_ROOT/docs/images/"
DEST="s3://heygen-public/hyperframes-oss/docs/images/"
PROFILE="${AWS_PROFILE:-engineering-767398024897}"
if [ ! -d "$SRC" ]; then
echo "No docs/images/ directory to upload — nothing to do."
exit 0
fi
echo "Uploading $SRC$DEST (profile: $PROFILE)"
aws --profile "$PROFILE" s3 sync "$SRC" "$DEST" \
--cache-control "public, max-age=31536000, immutable" \
--metadata-directive REPLACE
echo "Done. Files are live at https://static.heygen.ai/hyperframes-oss/docs/images/"
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
const VERSION_RE = /^\d+\.\d+\.\d+(?:-([0-9A-Za-z-]+)(?:\.[0-9A-Za-z-]+)*)?$/;
const STABLE_BRANCH_RE = /^origin\/(main|release\/v.+)$/;
const PRERELEASE_BRANCH_RE = /^origin\/(next|alpha|beta|rc|canary|prerelease\/.+)$/;
const RELEASE_PR_RE = /^release\/v\d+\.\d+\.\d+$/;
export function getPrereleaseId(version) {
const match = VERSION_RE.exec(version);
if (!match) {
return null;
}
return match[1] ?? null;
}
export function expectedDistTag(version) {
const prereleaseId = getPrereleaseId(version);
return prereleaseId ?? "latest";
}
export function normalizeRemoteBranches(output) {
return output
.split("\n")
.map((line) => line.replace(/^[* ]+/, "").trim())
.filter((line) => line && !line.includes("HEAD ->"));
}
export function validateReleaseChannel({ version, distTag, eventName, prHeadRef, remoteBranches }) {
const errors = [];
if (!VERSION_RE.test(version)) {
errors.push(`Invalid release version "${version}". Expected x.y.z or x.y.z-channel.N.`);
return errors;
}
const expectedTag = expectedDistTag(version);
const isPrerelease = expectedTag !== "latest";
if (distTag !== expectedTag) {
errors.push(
`Version "${version}" must publish with npm dist-tag "${expectedTag}", got "${distTag}".`,
);
}
if (eventName === "pull_request") {
if (!RELEASE_PR_RE.test(prHeadRef)) {
errors.push(
`Merged release PRs must come from release/vX.Y.Z branches, got "${prHeadRef || "<empty>"}".`,
);
}
if (isPrerelease) {
errors.push(
"Merged release PRs publish stable releases only. Publish prereleases from next/alpha tags instead.",
);
}
return errors;
}
if (eventName !== "push" && eventName !== "workflow_dispatch") {
errors.push(`Unsupported publish event "${eventName}".`);
return errors;
}
const allowedBranch = isPrerelease
? remoteBranches.some((branch) => PRERELEASE_BRANCH_RE.test(branch))
: remoteBranches.some((branch) => STABLE_BRANCH_RE.test(branch));
if (!allowedBranch) {
const expectedBranches = isPrerelease
? "origin/next, origin/alpha, origin/beta, origin/rc, origin/canary, or origin/prerelease/*"
: "origin/main or origin/release/v*";
const actualBranches = remoteBranches.length > 0 ? remoteBranches.join(", ") : "<none>";
errors.push(
`Tag v${version} is on ${actualBranches}, but ${distTag} releases must be reachable from ${expectedBranches}.`,
);
}
return errors;
}
function readRemoteBranchesContainingHead() {
const sha = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
const output = execFileSync("git", ["branch", "-r", "--contains", sha], {
encoding: "utf8",
});
return normalizeRemoteBranches(output);
}
function main() {
const version = process.env.VERSION ?? "";
const distTag = process.env.DIST_TAG ?? "";
const eventName = process.env.EVENT_NAME ?? "";
const prHeadRef = process.env.PR_HEAD_REF ?? "";
const remoteBranches = eventName === "pull_request" ? [] : readRemoteBranchesContainingHead();
const errors = validateReleaseChannel({
version,
distTag,
eventName,
prHeadRef,
remoteBranches,
});
if (errors.length > 0) {
for (const error of errors) {
console.error(`::error::${error}`);
}
process.exit(1);
}
const branches = remoteBranches.length > 0 ? remoteBranches.join(", ") : "not required";
console.log(`Release channel validated for v${version} (${distTag}); branches: ${branches}`);
}
if (import.meta.url === `file://${process.argv[1]}`) {
main();
}
+101
View File
@@ -0,0 +1,101 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
expectedDistTag,
getPrereleaseId,
normalizeRemoteBranches,
validateReleaseChannel,
} from "./validate-release-channel.mjs";
describe("release channel validation", () => {
it("derives dist-tags from stable and prerelease versions", () => {
assert.equal(getPrereleaseId("0.4.24"), null);
assert.equal(getPrereleaseId("0.4.24-alpha.1"), "alpha");
assert.equal(expectedDistTag("0.4.24"), "latest");
assert.equal(expectedDistTag("0.4.24-alpha.1"), "alpha");
});
it("allows stable tags reachable from main", () => {
const errors = validateReleaseChannel({
version: "0.4.24",
distTag: "latest",
eventName: "push",
prHeadRef: "",
remoteBranches: ["origin/main", "origin/next"],
});
assert.deepEqual(errors, []);
});
it("blocks stable tags that only live on prerelease branches", () => {
const errors = validateReleaseChannel({
version: "0.4.24",
distTag: "latest",
eventName: "push",
prHeadRef: "",
remoteBranches: ["origin/next"],
});
assert.equal(errors.length, 1);
assert.match(errors[0], /latest releases must be reachable from origin\/main/);
});
it("allows alpha tags reachable from next", () => {
const errors = validateReleaseChannel({
version: "0.4.24-alpha.1",
distTag: "alpha",
eventName: "push",
prHeadRef: "",
remoteBranches: ["origin/next"],
});
assert.deepEqual(errors, []);
});
it("blocks prerelease tags from stable branches", () => {
const errors = validateReleaseChannel({
version: "0.4.24-alpha.1",
distTag: "alpha",
eventName: "push",
prHeadRef: "",
remoteBranches: ["origin/main"],
});
assert.equal(errors.length, 1);
assert.match(errors[0], /alpha releases must be reachable from origin\/next/);
});
it("blocks dist-tag mismatches", () => {
const errors = validateReleaseChannel({
version: "0.4.24-alpha.1",
distTag: "latest",
eventName: "push",
prHeadRef: "",
remoteBranches: ["origin/next"],
});
assert.equal(errors.length, 1);
assert.match(errors[0], /must publish with npm dist-tag "alpha"/);
});
it("keeps merged release PRs stable-only", () => {
const errors = validateReleaseChannel({
version: "0.4.24-alpha.1",
distTag: "alpha",
eventName: "pull_request",
prHeadRef: "release/v0.4.24-alpha.1",
remoteBranches: [],
});
assert.equal(errors.length, 2);
assert.match(errors.join("\n"), /release\/vX\.Y\.Z/);
assert.match(errors.join("\n"), /stable releases only/);
});
it("normalizes git branch output", () => {
assert.deepEqual(
normalizeRemoteBranches(" origin/HEAD -> origin/main\n* origin/main\n origin/next\n"),
["origin/main", "origin/next"],
);
});
});
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { extname, join, posix } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
const ROOT = join(import.meta.dirname, "..");
const PACKAGES_DIR = join(ROOT, "packages");
const DEP_FIELDS = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];
const RUNTIME_IMPORT_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".json", ".wasm", ".node"]);
const PACKED_JAVASCRIPT_FILE_PATTERN = /\.(?:js|mjs|cjs)$/;
function listWorkspacePackageDirs() {
return readdirSync(PACKAGES_DIR)
.map((dir) => join("packages", dir))
.filter((dir) => existsSync(join(ROOT, dir, "package.json")));
}
function listWorkspaceRefs(pkg) {
return DEP_FIELDS.flatMap((field) =>
Object.entries(pkg[field] || {})
.filter(([, spec]) => String(spec).startsWith("workspace:"))
.map(([depName, spec]) => `${field}:${depName}=${spec}`),
);
}
function listMissingPublishedExports(pkg) {
if (!pkg.exports || !pkg.publishConfig?.exports) return [];
return Object.keys(pkg.exports).filter((exportKey) => !(exportKey in pkg.publishConfig.exports));
}
function normalizePackagePath(path) {
return path.replace(/^\.\//, "");
}
function isPackageLocalPath(path) {
return path.startsWith("./") || path.startsWith("dist/") || path.startsWith("src/");
}
function isPublishedSourceEntrypoint(path) {
return /^src\/.*\.(?:ts|tsx|mts|cts)$/.test(normalizePackagePath(path));
}
function appendManifestEntry(entries, trail, path) {
entries.push({ field: trail.join(".") || "<root>", path });
}
function collectStringEntrypoint(value, trail, entries) {
appendManifestEntry(entries, trail, value);
return entries;
}
function collectArrayEntrypoints(value, trail, entries) {
value.forEach((item, index) =>
collectManifestEntrypoints(item, [...trail, String(index)], entries),
);
return entries;
}
function collectObjectEntrypoints(value, trail, entries) {
Object.entries(value).forEach(([key, nested]) =>
collectManifestEntrypoints(nested, [...trail, key], entries),
);
return entries;
}
function isStringEntrypoint(value) {
return typeof value === "string";
}
function isArrayEntrypoint(value) {
return Array.isArray(value);
}
function isObjectEntrypoint(value) {
return Boolean(value) && typeof value === "object";
}
const MANIFEST_ENTRY_COLLECTORS = [
[isStringEntrypoint, collectStringEntrypoint],
[isArrayEntrypoint, collectArrayEntrypoints],
[isObjectEntrypoint, collectObjectEntrypoints],
];
function collectManifestEntrypoints(value, trail = [], entries = []) {
const collector = MANIFEST_ENTRY_COLLECTORS.find(([matches]) => matches(value));
return collector ? collector[1](value, trail, entries) : entries;
}
function listPackedEntrypoints(pkg) {
const entries = [];
for (const field of ["main", "module", "types", "typings"]) {
if (typeof pkg[field] === "string") {
entries.push({ field, path: pkg[field] });
}
}
if (pkg.exports != null) {
entries.push(...collectManifestEntrypoints(pkg.exports, ["exports"]));
}
return entries.filter((entry) => isPackageLocalPath(entry.path));
}
function listPackedFiles(filename) {
const output = execFileSync("tar", ["-tf", filename], {
cwd: ROOT,
encoding: "utf8",
});
return new Set(
output
.split("\n")
.filter(Boolean)
.map((path) => path.replace(/^package\//, "")),
);
}
function stripSpecifierQuery(specifier) {
return specifier.replace(/[?#].*$/, "");
}
function hasExplicitRuntimeExtension(specifier) {
return RUNTIME_IMPORT_EXTENSIONS.has(extname(stripSpecifierQuery(specifier)));
}
function listRelativeImportSpecifiers(source) {
const patterns = [
/^\s*import\s+["'](\.\.?\/[^"']+)["']/gm,
/^\s*(?:import|export)\b(?:(?!;)[\s\S])*?\s+from\s+["'](\.\.?\/[^"']+)["']/gm,
/\bimport\s*\(\s*["'](\.\.?\/[^"']+)["']\s*\)/gm,
/\brequire\s*\(\s*["'](\.\.?\/[^"']+)["']\s*\)/gm,
];
const specifiers = [];
for (const pattern of patterns) {
for (const match of source.matchAll(pattern)) {
specifiers.push({ index: match.index, specifier: match[1] });
}
}
return specifiers;
}
function lineNumberAt(source, index) {
return source.slice(0, index).split("\n").length;
}
function readPackedFile(filename, file) {
return execFileSync("tar", ["-xOf", filename, `package/${file}`], {
cwd: ROOT,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
}
function verifyPackedEntrypoints(workspace, packedPackage, packedFiles) {
const entries = listPackedEntrypoints(packedPackage);
const sourceEntries = entries.filter((entry) => isPublishedSourceEntrypoint(entry.path));
if (sourceEntries.length > 0) {
throw new Error(
`Packed manifest for ${workspace} exposes source TypeScript entrypoints: ` +
sourceEntries.map((entry) => `${entry.field}:${entry.path}`).join(", "),
);
}
const missingEntries = entries.filter((entry) => {
const normalized = normalizePackagePath(entry.path);
return !normalized.includes("*") && !packedFiles.has(normalized);
});
if (missingEntries.length > 0) {
throw new Error(
`Packed manifest for ${workspace} points at missing files: ` +
missingEntries.map((entry) => `${entry.field}:${entry.path}`).join(", "),
);
}
}
function resolvePackedRelativeImport(fromFile, specifier) {
return posix.normalize(posix.join(posix.dirname(fromFile), stripSpecifierQuery(specifier)));
}
export function listPackedJavaScriptImportIssues(filename, packedFiles) {
return [...packedFiles]
.filter((file) => PACKED_JAVASCRIPT_FILE_PATTERN.test(file))
.flatMap((file) => {
const source = readPackedFile(filename, file);
return listRelativeImportSpecifiers(source).flatMap(({ index, specifier }) => {
if (!hasExplicitRuntimeExtension(specifier)) {
return [`${file}:${lineNumberAt(source, index)} imports ${specifier}`];
}
const target = resolvePackedRelativeImport(file, specifier);
if (!packedFiles.has(target)) {
return [`${file}:${lineNumberAt(source, index)} imports missing ${specifier}`];
}
return [];
});
});
}
function verifyPackedJavaScriptImports(workspace, filename, packedFiles) {
const importIssues = listPackedJavaScriptImportIssues(filename, packedFiles);
if (importIssues.length > 0) {
throw new Error(
`Packed JavaScript for ${workspace} contains Node-incompatible relative imports: ` +
importIssues.slice(0, 10).join(", "),
);
}
}
function parsePackJson(output, workspace) {
try {
const parsed = JSON.parse(output);
return Array.isArray(parsed) ? parsed : [parsed];
} catch {
throw new Error(`Could not parse pnpm pack JSON output for ${workspace}`);
}
}
function readWorkspacePackage(workspace) {
return JSON.parse(readFileSync(join(ROOT, workspace, "package.json"), "utf8"));
}
function assertPublishedExportsMatchSource(workspace, sourcePackageJson) {
const missingPublishedExports = listMissingPublishedExports(sourcePackageJson);
if (missingPublishedExports.length === 0) return;
throw new Error(
`${workspace} publishConfig.exports is missing source exports: ${missingPublishedExports.join(", ")}`,
);
}
function packWorkspace(workspace, packDir) {
const packOutput = execFileSync("pnpm", ["pack", "--json", "--pack-destination", packDir], {
cwd: join(ROOT, workspace),
encoding: "utf8",
});
const [{ filename }] = parsePackJson(packOutput, workspace);
return filename;
}
function readPackedPackage(filename) {
const packedPackageJson = execFileSync("tar", ["-xOf", filename, "package/package.json"], {
cwd: ROOT,
encoding: "utf8",
});
return JSON.parse(packedPackageJson);
}
function assertNoWorkspaceRefs(workspace, packedPackage) {
const packedRefs = listWorkspaceRefs(packedPackage);
if (packedRefs.length === 0) return;
throw new Error(
`Packed manifest for ${workspace} still contains workspace refs: ${packedRefs.join(", ")}`,
);
}
function verifyPackedWorkspace(workspace, filename) {
const packedPackage = readPackedPackage(filename);
const packedFiles = listPackedFiles(filename);
assertNoWorkspaceRefs(workspace, packedPackage);
verifyPackedEntrypoints(workspace, packedPackage, packedFiles);
verifyPackedJavaScriptImports(workspace, filename, packedFiles);
}
function verifyWorkspace(workspace) {
const sourcePackageJson = readWorkspacePackage(workspace);
if (sourcePackageJson.private) return;
assertPublishedExportsMatchSource(workspace, sourcePackageJson);
const packDir = mkdtempSync(join(tmpdir(), "hyperframes-pack-"));
try {
verifyPackedWorkspace(workspace, packWorkspace(workspace, packDir));
console.log(`Verified ${workspace}: packed manifest is publish-safe.`);
} finally {
rmSync(packDir, { force: true, recursive: true });
}
}
function main() {
listWorkspacePackageDirs().forEach(verifyWorkspace);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
main();
}
+100
View File
@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { describe, it } from "node:test";
import { listPackedJavaScriptImportIssues } from "./verify-packed-manifests.mjs";
describe("packed manifest verifier", () => {
function withPackedFiles(files, packedFiles, callback) {
const dir = mkdtempSync(join(tmpdir(), "hyperframes-pack-test-"));
try {
const packageDir = join(dir, "package");
mkdirSync(packageDir, { recursive: true });
for (const [file, source] of Object.entries(files)) {
mkdirSync(dirname(join(packageDir, file)), { recursive: true });
writeFileSync(join(packageDir, file), source, "utf8");
}
const tarball = join(dir, "package.tgz");
execFileSync("tar", ["-czf", tarball, "-C", dir, "package"]);
callback(tarball, new Set(packedFiles));
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
it("passes explicit relative JavaScript imports whose target is packed", () => {
withPackedFiles(
{
"dist/index.js": 'import { runtime } from "./generated/runtime-inline.js";\n',
"dist/generated/runtime-inline.js": "export const runtime = 'ok';\n",
},
["dist/index.js", "dist/generated/runtime-inline.js"],
(tarball, packedFiles) => {
assert.deepEqual(listPackedJavaScriptImportIssues(tarball, packedFiles), []);
},
);
});
it("reports explicit relative JavaScript imports whose target is missing from the tarball", () => {
withPackedFiles(
{
"dist/index.js": 'import { runtime } from "./generated/runtime-inline.js";\n',
},
["dist/index.js"],
(tarball, packedFiles) => {
assert.deepEqual(listPackedJavaScriptImportIssues(tarball, packedFiles), [
"dist/index.js:1 imports missing ./generated/runtime-inline.js",
]);
},
);
});
it("checks export-from, dynamic import, require, and mjs/cjs files", () => {
withPackedFiles(
{
"dist/index.js": 'export * from "./generated/exports.js";\n',
"dist/dynamic.mjs": 'await import("./generated/dynamic.js");\n',
"dist/require.cjs": 'require("./generated/require.js");\n',
},
["dist/index.js", "dist/dynamic.mjs", "dist/require.cjs"],
(tarball, packedFiles) => {
assert.deepEqual(listPackedJavaScriptImportIssues(tarball, packedFiles), [
"dist/index.js:1 imports missing ./generated/exports.js",
"dist/dynamic.mjs:1 imports missing ./generated/dynamic.js",
"dist/require.cjs:1 imports missing ./generated/require.js",
]);
},
);
});
it("reports extensionless relative imports", () => {
withPackedFiles(
{
"dist/index.js": 'export {\n runtime\n} from "./generated/runtime-inline";\n',
},
["dist/index.js"],
(tarball, packedFiles) => {
assert.deepEqual(listPackedJavaScriptImportIssues(tarball, packedFiles), [
"dist/index.js:1 imports ./generated/runtime-inline",
]);
},
);
});
it("reports side-effect imports whose target is missing from the tarball", () => {
withPackedFiles(
{
"index.js": 'import "./missing.js";\n',
},
["index.js"],
(tarball, packedFiles) => {
assert.deepEqual(listPackedJavaScriptImportIssues(tarball, packedFiles), [
"index.js:1 imports missing ./missing.js",
]);
},
);
});
});