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
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:
@@ -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`,
|
||||
);
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
Executable
+84
@@ -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
|
||||
@@ -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();
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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] : [];
|
||||
}
|
||||
@@ -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}"));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
Executable
+281
@@ -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 ""
|
||||
Executable
+31
@@ -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/"
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user