Files
hyperframes/packages/cli/scripts/build-copy.mjs
James RussoandClaude Opus 5.5 7f19f2335a feat(studio): load GSAP from the CLI's bundled copy by default (#6256)
* feat(studio): load GSAP from the CLI's bundled copy by default

Studio preview now loads the GSAP, CustomEase and MotionPathPlugin scripts
it adds from files the CLI ships and serves same-origin at /api/vendor/npm,
instead of jsDelivr. HYPERFRAMES_GSAP_BASE_URL still wins when set, and a
composition on another GSAP version gets its plugin from jsDelivr.

Renders now honor HYPERFRAMES_GSAP_BASE_URL when they replace a missing
local GSAP script, and the setting accepts plain http only for loopback
hosts.

Closes #6205

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

* refactor(studio): keep comment share flat for the GSAP local root

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

* fix(cli): find the installed gsap with node resolution when run from source

The Windows CI lane installs with the hoisted linker, so gsap is not under
packages/core/node_modules there.

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

* fix(producer): load a nested composition's missing GSAP from the mirror

A sub-composition's missing local gsap/dist script was hoisted into the
render unchanged, so HYPERFRAMES_GSAP_BASE_URL (and the jsDelivr fallback)
only reached the entry document. Run the same rewrite on every
sub-composition before it is compiled, resolving its path the way the
inliner will hoist it.

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 03:08:42 +00:00

206 lines
7.9 KiB
JavaScript

// Cross-platform replacement for the previous `mkdir -p … && cp -r …` shell
// chain, which failed on Windows because `cp` doesn't accept `-r` there.
import {
chmodSync,
copyFileSync,
cpSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
} from "node:fs";
import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { setTimeout as sleep } from "node:timers/promises";
const HERE = dirname(fileURLToPath(import.meta.url));
const CLI_ROOT = resolve(HERE, "..");
const REPO_ROOT = resolve(CLI_ROOT, "..", "..");
const DIST = join(CLI_ROOT, "dist");
// Studio's vite build clears its dist before rewriting it; don't start the
// copy until both sentinels are present so we never observe a partial tree.
const STUDIO_WAIT_TIMEOUT_MS = 30_000;
const STUDIO_POLL_INTERVAL_MS = 250;
// fallow-ignore-next-line complexity
async function waitForStudioDist(dir) {
const deadline = Date.now() + STUDIO_WAIT_TIMEOUT_MS;
while (Date.now() < deadline) {
try {
const entries = new Set(readdirSync(dir));
// vite emits `assets/` before rewriting `index.html` at the end of the
// build — so once both are present, the tree is complete.
if (entries.has("index.html") && entries.has("assets")) return;
} catch {
// dir doesn't exist yet — vite will create it
}
await sleep(STUDIO_POLL_INTERVAL_MS);
}
throw new Error(`[build-copy] timed out waiting for studio dist at ${dir}`);
}
function copyDir(src, dest) {
cpSync(src, dest, { recursive: true, force: true });
}
// Source maps stay out: the CLI ships none of its own (tsup sourcemap: false).
function copyDirContents(src, dest) {
for (const entry of readdirSync(src)) {
cpSync(join(src, entry), join(dest, entry), {
recursive: true,
force: true,
filter: (path) => !path.endsWith(".map"),
});
}
}
function copyMdFiles(srcDir, destDir) {
if (!existsSync(srcDir)) return;
for (const name of readdirSync(srcDir)) {
if (name.endsWith(".md")) {
cpSync(join(srcDir, name), join(destDir, name));
}
}
}
// Studio preview loads these from the CLI's own server instead of jsDelivr
// (studioServer.ts serves dist/vendor/npm). They come from the workspace's
// installed gsap, which must match the version core asks for, or Studio would
// quietly fall back to the CDN for every preview.
const GSAP_VENDOR_FILES = ["gsap.min.js", "CustomEase.min.js", "MotionPathPlugin.min.js"];
function copyGsapVendorFiles() {
const coreRequire = createRequire(join(REPO_ROOT, "packages", "core", "package.json"));
const gsapRoot = dirname(coreRequire.resolve("gsap/package.json"));
const { version } = JSON.parse(readFileSync(join(gsapRoot, "package.json"), "utf-8"));
const coreSource = readFileSync(
join(REPO_ROOT, "packages", "core", "src", "gsapCdn.ts"),
"utf-8",
);
const expected = coreSource.match(/GSAP_CDN_VERSION = "([^"]+)"/)?.[1];
if (version !== expected) {
throw new Error(
`[build-copy] installed gsap ${version} does not match GSAP_CDN_VERSION ${expected}`,
);
}
const vendorDir = join(DIST, "vendor", "npm");
rmSync(vendorDir, { recursive: true, force: true });
const dest = join(vendorDir, `gsap@${version}`, "dist");
mkdirSync(dest, { recursive: true });
for (const file of GSAP_VENDOR_FILES) {
copyFileSync(join(gsapRoot, "dist", file), join(dest, file));
chmodSync(join(dest, file), 0o644);
}
}
// fallow-ignore-next-line complexity
async function main() {
for (const sub of ["studio", "docs", "templates", "skills", "docker"]) {
mkdirSync(join(DIST, sub), { recursive: true });
}
mkdirSync(join(DIST, "commands"), { recursive: true });
const studioDist = resolve(CLI_ROOT, "..", "studio", "dist");
await waitForStudioDist(studioDist);
// Only the SPA studioServer.ts serves (index.html, /assets, /icons, /favicon.svg);
// the rest of studio/dist is the npm library build.
for (const entry of ["index.html", "assets", "icons", "favicon.svg"]) {
copyDir(join(studioDist, entry), join(DIST, "studio", entry));
}
for (const tmpl of ["blank", "from-file", "_shared"]) {
copyDir(join(CLI_ROOT, "src", "templates", tmpl), join(DIST, "templates", tmpl));
}
// Bundle warm-grain from the repo registry so the built CLI can scaffold it
// offline and CI smoke tests pick up PR-branch changes before merge to main.
const warmGrainSrc = join(REPO_ROOT, "registry", "examples", "warm-grain");
if (existsSync(warmGrainSrc)) {
copyDir(warmGrainSrc, join(DIST, "templates", "warm-grain"));
}
// Skills bundled into the published CLI. Branches don't all carry the same
// skills/ tree (it gets restructured), so each entry is existsSync-guarded:
// a missing skill dir warns + skips instead of crashing the build.
for (const skill of ["hyperframes", "hyperframes-cli", "gsap", "media-use"]) {
const src = join(REPO_ROOT, "skills", skill);
if (!existsSync(src)) {
console.warn(`[build-copy] skill not found, skipping: skills/${skill}`);
continue;
}
const destination = join(DIST, "skills", skill);
rmSync(destination, { recursive: true, force: true });
copyDir(src, destination);
}
// The media-use engine lives with the CLI source, but keeps its published
// skill-relative layout so the moved .mjs tree can run without a rewrite.
const mediaEngine = join(CLI_ROOT, "src", "media-use");
const publishedMediaLib = join(DIST, "skills", "media-use", "scripts", "lib");
rmSync(publishedMediaLib, { recursive: true, force: true });
mkdirSync(publishedMediaLib, { recursive: true });
copyDirContents(join(mediaEngine, "lib"), publishedMediaLib);
cpSync(
join(mediaEngine, "resolve.mjs"),
join(DIST, "skills", "media-use", "scripts", "resolve.mjs"),
);
const dockerfile = join(CLI_ROOT, "src", "docker", "Dockerfile.render");
if (existsSync(dockerfile)) {
cpSync(dockerfile, join(DIST, "docker", "Dockerfile.render"));
}
const layoutAuditScript = join(CLI_ROOT, "src", "commands", "layout-audit.browser.js");
if (existsSync(layoutAuditScript)) {
cpSync(layoutAuditScript, join(DIST, "commands", "layout-audit.browser.js"));
}
const contrastAuditScript = join(CLI_ROOT, "src", "commands", "contrast-audit.browser.js");
if (existsSync(contrastAuditScript)) {
cpSync(contrastAuditScript, join(DIST, "commands", "contrast-audit.browser.js"));
}
const motionSignatureScript = join(CLI_ROOT, "src", "commands", "motion-signature.browser.js");
if (existsSync(motionSignatureScript)) {
cpSync(motionSignatureScript, join(DIST, "commands", "motion-signature.browser.js"));
}
const motionSampleScript = join(CLI_ROOT, "src", "commands", "motion-sample.browser.js");
if (existsSync(motionSampleScript)) {
cpSync(motionSampleScript, join(DIST, "commands", "motion-sample.browser.js"));
}
// Player bundles for the standalone browser player used by `present` and
// `play`. resolvePlayerPath/resolveSlideshowPath look for these alongside the
// built CLI (dist/<name>.global.js), so they must ship in the package — the
// monorepo-dev fallback paths don't exist once installed from npm. Without
// this, `npx hyperframes present` fails with "@hyperframes/player not found".
const playerDist = join(REPO_ROOT, "packages", "player", "dist");
const playerGlobals = [
[join(playerDist, "hyperframes-player.global.js"), join(DIST, "hyperframes-player.global.js")],
[
join(playerDist, "slideshow", "hyperframes-slideshow.global.js"),
join(DIST, "hyperframes-slideshow.global.js"),
],
];
for (const [src, dest] of playerGlobals) {
if (existsSync(src)) {
cpSync(src, dest);
} else {
console.warn(`[build-copy] player bundle not found, skipping: ${src}`);
}
}
copyGsapVendorFiles();
copyMdFiles(join(CLI_ROOT, "src", "docs"), join(DIST, "docs"));
console.log("[build-copy] done");
}
await main();