diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..095491496 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "timeout": 180, + "statusMessage": "Running build + lint + typecheck before commit…", + "command": "node -e \"\nconst chunks = [];\nprocess.stdin.on('data', d => chunks.push(d));\nprocess.stdin.on('end', () => {\n const input = JSON.parse(Buffer.concat(chunks).toString());\n const cmd = input.tool_input?.command || '';\n if (!/git\\\\s+commit\\\\b/.test(cmd)) process.exit(0);\n const { execSync } = require('child_process');\n const cwd = process.env.PWD || process.cwd();\n const steps = [\n ['pnpm build', 'Build'],\n ['pnpm run -w lint', 'Lint'],\n ['bun run --filter \\'*\\' typecheck 2>&1 | grep -v \\'vitest\\\\|test\\\\.ts\\' || true', 'Typecheck'],\n ];\n const failures = [];\n for (const [script, label] of steps) {\n try { execSync(script, { cwd, stdio: 'pipe' }); }\n catch (e) {\n failures.push(label + ':\\\\n' + (e.stdout?.toString() || e.message).slice(0, 400));\n }\n }\n if (failures.length > 0) {\n process.stdout.write(JSON.stringify({\n continue: false,\n stopReason: '\\u274c Pre-commit checks failed:\\\\n\\\\n' + failures.join('\\\\n\\\\n') + '\\\\n\\\\nFix the issues above before committing.',\n }));\n }\n});\"" + } + ] + } + ] + } +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0806a1e11..22f1d7b36 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,16 +60,17 @@ Git hooks (via [lefthook](https://github.com/evilmartians/lefthook)) run automat All packages use **fixed versioning** — every release bumps all packages to the same version. ```bash -bun run set-version 0.2.0 -git checkout -b release/v0.2.0 -git add packages/*/package.json -git commit -m "chore: release v0.2.0" -git push origin release/v0.2.0 -gh pr create --title "chore: release v0.2.0" --base main -# After merge, tag + npm publish + GitHub Release happen automatically +bun run set-version 0.2.0 # bumps all packages, commits, and creates git tag +git push origin main --tags # triggers the publish workflow ``` -You can also publish manually by pushing a tag: `git tag v0.2.0 && git push origin v0.2.0` +The `set-version` script automatically creates a `chore: release v` commit and a `v` git tag. Pushing the tag triggers CI to publish all packages to npm and create a GitHub Release. + +If you need to bump versions without committing (e.g., for a release PR), pass `--no-tag`: + +```bash +bun run set-version 0.2.0 --no-tag # updates package.json files only +``` ## Reporting Issues diff --git a/README.md b/README.md index f9733081d..05d7d46cb 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Hyperframes +[![npm version](https://img.shields.io/npm/v/hyperframes.svg?style=flat)](https://www.npmjs.com/package/hyperframes) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Node.js](https://img.shields.io/badge/node-%3E%3D22-brightgreen)](https://nodejs.org) **Write HTML. Render video. Built for agents.** @@ -29,6 +31,10 @@ npx hyperframes render # render to MP4 **Requirements:** Node.js >= 22, FFmpeg +## Documentation + +Full documentation at **[hyperframes.heygen.com](https://hyperframes.heygen.com)** — start with the [Quickstart](https://hyperframes.heygen.com/quickstart), then explore guides, concepts, API reference, and package docs. + ## How It Works Define your video as HTML with data attributes: @@ -102,10 +108,6 @@ npx skills add greensock/gsap-skills In Claude Code, invoke with `/hyperframes-compose`, `/hyperframes-captions`, `/gsap-core`, etc. -## Documentation - -Full docs at [hyperframes.heygen.com](https://hyperframes.heygen.com) — includes guides, concepts, API reference, and package documentation. - ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to contribute. diff --git a/docs/contributing/testing-local-changes.mdx b/docs/contributing/testing-local-changes.mdx new file mode 100644 index 000000000..f8cfec389 --- /dev/null +++ b/docs/contributing/testing-local-changes.mdx @@ -0,0 +1,128 @@ +--- +title: Testing Local CLI Changes +description: How to test unreleased CLI changes outside the monorepo using your local build. +--- + +When you modify the CLI or any package it bundles (core, engine, producer, studio), you need to test those changes against real projects _outside_ the monorepo — the same way an end user would run `hyperframes dev`. + +## Prerequisites + +Build the monorepo first. Every time you change source files, rebuild before testing. + +```bash +# From the monorepo root +pnpm build +``` + +## Option 1: pnpm link (recommended) + +`pnpm link --global` makes the `hyperframes` binary in your `$PATH` point at your local build. It survives across terminal sessions and auto-picks up new builds without re-linking. + +```bash +# One-time setup +cd packages/cli +pnpm link --global + +# Verify — should print your local version +hyperframes --version +``` + +Now use `hyperframes` normally in any directory: + +```bash +cd ~/my-video-project +hyperframes dev . +``` + +**After every `pnpm build`** the linked binary is already up to date — no re-linking needed. + +To restore the published release when you're done: + +```bash +pnpm unlink --global hyperframes +npm install -g hyperframes@latest +``` + +## Option 2: node alias (no PATH changes) + +If you don't want to touch your global `$PATH`, add a shell alias or call `node` directly: + +```bash +# Temporary alias for your current shell session +alias hyperframes="node /path/to/hyperframes-oss/packages/cli/dist/cli.js" + +# Or invoke directly +node /path/to/hyperframes-oss/packages/cli/dist/cli.js dev . +``` + +Replace `/path/to/hyperframes-oss` with your actual monorepo path. + +## Option 3: npm pack (test the exact published artifact) + +Use this when you want to verify what would actually ship in a release, including the bundled studio and templates. + +```bash +cd packages/cli +npm pack +# Creates: hyperframes-.tgz + +# Test it in an isolated directory +mkdir /tmp/pack-test && cd /tmp/pack-test +npx /path/to/hyperframes-oss/packages/cli/hyperframes-.tgz init my-video +cd my-video +npx /path/to/hyperframes-oss/packages/cli/hyperframes-.tgz dev . +``` + +## Testing the fix branches + +When validating a specific bug fix, extract one of the test project archives and run through the scenario: + +```bash +# Example: testing audio-after-seek fix +unzip golden-lyric-video.zip && cd golden-lyric-video +hyperframes dev . +# 1. Press Play — confirm audio plays +# 2. Drag the timeline scrubber to a different position +# 3. Press Play again — audio should resume from the seeked position +``` + +Common test scenarios: + +| Bug | Project | Steps | +|---|---|---| +| Audio silent after seek | `golden-lyric-video` | Play → seek → play again, verify audio | +| Render stuck at 0% | any | Renders tab → Export → watch progress bar | +| Download 404 after restart | any | Complete a render → `Ctrl+C` → restart → Download | +| Timeline stops early | `intro-vid` | Play → should reach `0:05`, not stop at `0:03` | +| Lottie missing | `hyperframe-build-up-demo` | Play → rocket visible during 0–2 s | +| Blank thumbnails | any | Compositions sidebar should show previews | + +## Troubleshooting + +**Changes not reflected after `pnpm build`** + +The CLI binary is a single bundled file at `packages/cli/dist/cli.js`. If your change is in `@hyperframes/core` or another workspace package, make sure `pnpm build` rebuilt _all_ packages — the CLI bundles its dependencies at build time. + +**`hyperframes` still shows the old version** + +Check which binary is active: + +```bash +which hyperframes +hyperframes --version +``` + +If it points to a global npm installation rather than your link, uninstall the npm version first: + +```bash +npm uninstall -g hyperframes +cd packages/cli && pnpm link --global +``` + +**Port already in use** + +`hyperframes dev` defaults to port 3002 and auto-increments if it's taken. Pass `--port` to use a specific port: + +```bash +hyperframes dev . --port 4000 +``` diff --git a/docs/docs.json b/docs/docs.json index c7589d8e9..6deb4074f 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -82,8 +82,8 @@ "pages": ["reference/html-schema"] }, { - "group": "Community", - "pages": ["contributing"] + "group": "Contributing", + "pages": ["contributing", "contributing/testing-local-changes"] } ] } diff --git a/packages/cli/package.json b/packages/cli/package.json index 7016b2411..d07c6e95c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/cli", - "version": "0.1.12", + "version": "0.1.13", "description": "HyperFrames CLI — create, preview, and render HTML video compositions", "repository": { "type": "git", @@ -15,6 +15,7 @@ ], "type": "module", "scripts": { + "test": "vitest run", "dev": "tsx src/cli.ts", "build": "bun run build:fonts && bun run build:studio && tsup && bun run build:runtime && bun run build:copy", "build:fonts": "cd ../producer && tsx scripts/generate-font-data.ts", @@ -52,7 +53,8 @@ "picocolors": "^1.1.1", "tsup": "^8.0.0", "tsx": "^4.0.0", - "typescript": "^5.0.0" + "typescript": "^5.0.0", + "vitest": "^3.2.4" }, "engines": { "node": ">=22" diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index d3be75aef..1f33c9286 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -7,6 +7,8 @@ import { createRequire } from "node:module"; import * as clack from "@clack/prompts"; import { c } from "../ui/colors.js"; import { isDevMode } from "../utils/env.js"; +import { lintProject } from "../utils/lintProject.js"; +import { formatLintFindings } from "../utils/lintFormat.js"; /** * Try to start a server on the given port, auto-incrementing up to maxAttempts @@ -71,6 +73,19 @@ export default defineCommand({ const isImplicitCwd = !rawArg || rawArg === "." || rawArg === "./"; const projectName = isImplicitCwd ? basename(process.env.PWD ?? dir) : basename(dir); + // Lint before starting — surface issues for the agent to fix. + // dev.ts doesn't use resolveProject() because it needs to proceed even without index.html. + const indexPath = join(dir, "index.html"); + if (existsSync(indexPath)) { + const project = { dir, name: projectName, indexPath }; + const lintResult = lintProject(project); + if (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0) { + console.log(); + for (const line of formatLintFindings(lintResult)) console.log(line); + console.log(); + } + } + if (isDevMode()) { return runDevMode(dir, projectName); } diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index b33dc3d4b..527e4a00e 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -1,11 +1,8 @@ import { defineCommand } from "citty"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { lintHyperframeHtml } from "@hyperframes/core/lint"; -import type { HyperframeLintFinding } from "@hyperframes/core/lint"; -import { walkDir } from "@hyperframes/core/studio-api"; import { c } from "../ui/colors.js"; import { resolveProject } from "../utils/project.js"; +import { lintProject } from "../utils/lintProject.js"; +import { formatLintFindings } from "../utils/lintFormat.js"; import { withMeta } from "../utils/updateCheck.js"; export default defineCommand({ @@ -17,75 +14,36 @@ export default defineCommand({ async run({ args }) { try { const project = resolveProject(args.dir); - const htmlFiles = walkDir(project.dir).filter((f) => f.endsWith(".html")); - - const allFindings: (HyperframeLintFinding & { file: string })[] = []; - let totalErrors = 0; - let totalWarnings = 0; - let totalInfos = 0; - - for (const file of htmlFiles) { - const html = readFileSync(join(project.dir, file), "utf-8"); - const result = lintHyperframeHtml(html, { filePath: file }); - for (const f of result.findings) { - allFindings.push({ ...f, file }); - } - totalErrors += result.errorCount; - totalWarnings += result.warningCount; - totalInfos += result.infoCount; - } + const lintResult = lintProject(project); if (args.json) { - console.log( - JSON.stringify( - withMeta({ - ok: totalErrors === 0, - findings: allFindings, - errorCount: totalErrors, - warningCount: totalWarnings, - infoCount: totalInfos, - filesScanned: htmlFiles.length, - }), - null, - 2, - ), - ); - process.exit(totalErrors > 0 ? 1 : 0); + const combined = { + ok: lintResult.totalErrors === 0, + errorCount: lintResult.totalErrors, + warningCount: lintResult.totalWarnings, + infoCount: lintResult.totalInfos, + findings: lintResult.results.flatMap((r) => r.result.findings), + filesScanned: lintResult.results.length, + }; + console.log(JSON.stringify(withMeta(combined), null, 2)); + process.exit(combined.ok ? 0 : 1); } - console.log( - `${c.accent("◆")} Linting ${c.accent(project.name)} (${htmlFiles.length} HTML files)`, - ); + const fileCount = lintResult.results.length; + const fileLabel = + fileCount === 1 ? (lintResult.results[0]?.file ?? "index.html") : `${fileCount} files`; + console.log(`${c.accent("◆")} Linting ${c.accent(project.name + "/" + fileLabel)}`); console.log(); - if (allFindings.length === 0) { + if (lintResult.totalErrors === 0 && lintResult.totalWarnings === 0) { console.log(`${c.success("◇")} ${c.success("0 errors, 0 warnings")}`); return; } - for (const finding of allFindings) { - const prefix = - finding.severity === "error" - ? c.error("✗") - : finding.severity === "warning" - ? c.warn("⚠") - : c.dim("ℹ"); - const loc = finding.elementId ? ` ${c.accent(`[${finding.elementId}]`)}` : ""; - console.log( - `${prefix} ${c.bold(finding.code)}${loc}: ${finding.message} ${c.dim(finding.file)}`, - ); - if (finding.fixHint) { - console.log(` ${c.dim(`Fix: ${finding.fixHint}`)}`); - } - } + const lines = formatLintFindings(lintResult, { showElementId: true, showSummary: true }); + for (const line of lines) console.log(line); - const summaryIcon = totalErrors > 0 ? c.error("◇") : c.success("◇"); - const summaryParts = [`${totalErrors} error(s)`, `${totalWarnings} warning(s)`]; - if (totalInfos > 0) { - summaryParts.push(`${totalInfos} info(s)`); - } - console.log(`\n${summaryIcon} ${summaryParts.join(", ")}`); - process.exit(totalErrors > 0 ? 1 : 0); + process.exit(lintResult.totalErrors > 0 ? 1 : 0); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); if (args.json) { diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 4f1ff6818..4bc73211c 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -3,6 +3,8 @@ import { existsSync, mkdirSync, statSync } from "node:fs"; import { cpus, freemem } from "node:os"; import { resolve, dirname, join } from "node:path"; import { resolveProject } from "../utils/project.js"; +import { lintProject, shouldBlockRender } from "../utils/lintProject.js"; +import { formatLintFindings } from "../utils/lintFormat.js"; import { loadProducer } from "../utils/producer.js"; import { c } from "../ui/colors.js"; import { formatBytes, formatDuration, errorBox } from "../ui/format.js"; @@ -75,6 +77,16 @@ Examples: description: "Suppress verbose output", default: false, }, + strict: { + type: "boolean", + description: "Fail render on lint errors", + default: false, + }, + "strict-all": { + type: "boolean", + description: "Fail render on lint errors AND warnings", + default: false, + }, }, async run({ args }) { // ── Resolve project ──────────────────────────────────────────────────── @@ -131,6 +143,8 @@ Examples: const useDocker = args.docker ?? false; const useGpu = args.gpu ?? false; const quiet = args.quiet ?? false; + const strictAll = args["strict-all"] ?? false; + const strictErrors = (args.strict ?? false) || strictAll; // ── Print render plan ───────────────────────────────────────────────── const workerCount = workers ?? defaultWorkerCount(); @@ -193,6 +207,31 @@ Examples: } } + // ── Pre-render lint ────────────────────────────────────────────────── + { + const lintResult = lintProject(project); + if (!quiet && (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0)) { + console.log(""); + for (const line of formatLintFindings(lintResult, { errorsFirst: true })) console.log(line); + if ( + shouldBlockRender( + strictErrors, + strictAll, + lintResult.totalErrors, + lintResult.totalWarnings, + ) + ) { + const mode = strictAll ? "--strict-all" : "--strict"; + console.log(""); + console.log(c.error(` Aborting render due to lint issues (${mode} mode).`)); + console.log(""); + process.exit(1); + } + console.log(c.dim(" Continuing render despite lint issues. Use --strict to block.")); + console.log(""); + } + } + // ── Render ──────────────────────────────────────────────────────────── if (useDocker) { await renderDocker(project.dir, outputPath, { diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 53296091d..758b9ad46 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -44,6 +44,50 @@ function resolveRuntimePath(): string { return builtPath; } +// ── Shared thumbnail browser (singleton per process) ──────────────────────── +// One browser instance is reused across all composition thumbnail requests. +// Spawning a new Puppeteer process per request adds 2-5s overhead and causes +// contention when the sidebar requests multiple thumbnails simultaneously. + +let _thumbnailBrowser: import("puppeteer-core").Browser | null = null; +let _thumbnailBrowserInitializing: Promise | null = null; + +async function getThumbnailBrowser(): Promise { + if (_thumbnailBrowser?.connected) return _thumbnailBrowser; + if (_thumbnailBrowserInitializing) return _thumbnailBrowserInitializing; + + _thumbnailBrowserInitializing = (async () => { + try { + const { ensureBrowser } = await import("../browser/manager.js"); + const { acquireBrowser, buildChromeArgs } = await import("@hyperframes/engine"); + + try { + const b = await ensureBrowser(); + if (b.executablePath && !process.env.PRODUCER_HEADLESS_SHELL_PATH) { + process.env.PRODUCER_HEADLESS_SHELL_PATH = b.executablePath; + } + } catch { + /* continue — acquireBrowser will try its own resolution */ + } + + const acquired = await acquireBrowser(buildChromeArgs({ width: 1920, height: 1080 }), { + enableBrowserPool: false, + }); + _thumbnailBrowser = acquired.browser; + _thumbnailBrowser.on("disconnected", () => { + _thumbnailBrowser = null; + _thumbnailBrowserInitializing = null; + }); + return _thumbnailBrowser; + } catch { + _thumbnailBrowserInitializing = null; + return null; + } + })(); + + return _thumbnailBrowserInitializing; +} + // ── Server factory ────────────────────────────────────────────────────────── export interface StudioServerOptions { @@ -152,6 +196,44 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { return state; }, + + async generateThumbnail(opts): Promise { + // Reuse a single browser across all thumbnail requests for this server + // instance — avoids paying the ~2s Puppeteer startup cost per composition. + // The browser is created lazily and kept alive until the process exits. + const browser = await getThumbnailBrowser(); + if (!browser) return null; + let page: import("puppeteer-core").Page | null = null; + try { + page = await browser.newPage(); + await page.setViewport({ width: opts.width || 1920, height: opts.height || 1080 }); + // domcontentloaded instead of networkidle2 — CDN scripts (GSAP, Lottie, + // fonts) never reach "idle" and cause a 15s timeout per thumbnail. + await page.goto(opts.previewUrl, { waitUntil: "domcontentloaded", timeout: 10000 }); + // Wait for the runtime to register timelines (up to 5s, non-fatal). + await page + .waitForFunction(() => !!(window as any).__timelines || !!(window as any).__playerReady, { + timeout: 5000, + }) + .catch(() => {}); + await page.evaluate((t: number) => { + const win = window as any; + if (win.__player?.seek) win.__player.seek(t); + else if (win.__timeline?.seek) { + win.__timeline.pause(); + win.__timeline.seek(t); + } + }, opts.seekTime); + // Let the seek render settle. + await new Promise((r) => setTimeout(r, 200)); + const screenshot = (await page.screenshot({ type: "jpeg", quality: 80 })) as Buffer; + return screenshot; + } catch { + return null; + } finally { + await page?.close().catch(() => {}); + } + }, }; // ── Build the Hono app ───────────────────────────────────────────────── diff --git a/packages/cli/src/utils/lintFormat.ts b/packages/cli/src/utils/lintFormat.ts new file mode 100644 index 000000000..65506ea9d --- /dev/null +++ b/packages/cli/src/utils/lintFormat.ts @@ -0,0 +1,58 @@ +import { c } from "../ui/colors.js"; +import type { ProjectLintResult } from "./lintProject.js"; + +export interface LintFormatOptions { + /** Show elementId in brackets after the code (default: true) */ + showElementId?: boolean; + /** Show summary line with error/warning counts (default: false) */ + showSummary?: boolean; + /** Group errors before warnings per file (default: false — interleaved) */ + errorsFirst?: boolean; +} + +/** + * Format lint findings for console output. Used by lint, render, and dev commands. + */ +export function formatLintFindings( + { results, totalErrors, totalWarnings, totalInfos }: ProjectLintResult, + options: LintFormatOptions = {}, +): string[] { + const { showElementId = true, showSummary = false, errorsFirst = false } = options; + const lines: string[] = []; + const multiFile = results.length > 1; + + for (const { file, result } of results) { + if (result.findings.length === 0) continue; + + const format = (finding: (typeof result.findings)[0]) => { + const prefix = + finding.severity === "error" + ? c.error("✗") + : finding.severity === "warning" + ? c.warn("⚠") + : c.dim("ℹ"); + const fileLabel = multiFile ? c.dim(`[${file}] `) : ""; + const loc = + showElementId && finding.elementId ? ` ${c.accent(`[${finding.elementId}]`)}` : ""; + lines.push(` ${prefix} ${fileLabel}${c.bold(finding.code)}${loc}: ${finding.message}`); + if (finding.fixHint) lines.push(` ${c.dim(`Fix: ${finding.fixHint}`)}`); + }; + + if (errorsFirst) { + for (const f of result.findings) if (f.severity === "error") format(f); + for (const f of result.findings) if (f.severity === "warning") format(f); + } else { + for (const f of result.findings) format(f); + } + } + + if (showSummary) { + const icon = totalErrors > 0 ? c.error("◇") : c.success("◇"); + lines.push(""); + const summaryParts = [`${totalErrors} error(s)`, `${totalWarnings} warning(s)`]; + if (totalInfos > 0) summaryParts.push(`${totalInfos} info(s)`); + lines.push(`${icon} ${summaryParts.join(", ")}`); + } + + return lines; +} diff --git a/packages/cli/src/utils/lintProject.test.ts b/packages/cli/src/utils/lintProject.test.ts new file mode 100644 index 000000000..c7cf05f6f --- /dev/null +++ b/packages/cli/src/utils/lintProject.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { lintProject, shouldBlockRender } from "./lintProject.js"; +import type { ProjectDir } from "./project.js"; + +function tmpProject(name: string): string { + const dir = join(tmpdir(), `hf-test-${name}-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function validHtml(compId = "main"): string { + return ` +
+ +`; +} + +function htmlWithMissingMediaId(): string { + return ` +
+ +
+ +`; +} + +function htmlWithPreloadNone(): string { + return ` +
+ +
+ +`; +} + +let dirs: string[] = []; + +function makeProject(indexHtml: string, subComps?: Record): ProjectDir { + const dir = tmpProject("lint"); + dirs.push(dir); + writeFileSync(join(dir, "index.html"), indexHtml); + if (subComps) { + const compsDir = join(dir, "compositions"); + mkdirSync(compsDir, { recursive: true }); + for (const [name, html] of Object.entries(subComps)) { + writeFileSync(join(compsDir, name), html); + } + } + return { dir, name: "test-project", indexPath: join(dir, "index.html") }; +} + +afterEach(() => { + for (const d of dirs) { + rmSync(d, { recursive: true, force: true }); + } + dirs = []; +}); + +describe("lintProject", () => { + it("returns zero errors/warnings for a clean project", () => { + const project = makeProject(validHtml()); + const { totalErrors, totalWarnings, results } = lintProject(project); + + expect(totalErrors).toBe(0); + expect(totalWarnings).toBe(0); + expect(results).toHaveLength(1); + const first = results[0]; + expect(first).toBeDefined(); + expect(first?.file).toBe("index.html"); + }); + + it("detects errors in index.html", () => { + const project = makeProject(htmlWithMissingMediaId()); + const { totalErrors, results } = lintProject(project); + + expect(totalErrors).toBeGreaterThan(0); + const first = results[0]; + expect(first).toBeDefined(); + const mediaFinding = first?.result.findings.find((f) => f.code === "media_missing_id"); + expect(mediaFinding).toBeDefined(); + }); + + it("lints sub-compositions in compositions/ directory", () => { + const project = makeProject(validHtml(), { + "captions.html": htmlWithMissingMediaId(), + }); + const { totalErrors, results } = lintProject(project); + + expect(results).toHaveLength(2); + const second = results[1]; + expect(second).toBeDefined(); + expect(second?.file).toBe("compositions/captions.html"); + expect(totalErrors).toBeGreaterThan(0); + const subFindings = second?.result.findings ?? []; + expect(subFindings.some((f) => f.code === "media_missing_id")).toBe(true); + }); + + it("aggregates errors across index.html and sub-compositions", () => { + const project = makeProject(htmlWithMissingMediaId(), { + "overlay.html": htmlWithMissingMediaId(), + }); + const { totalErrors, results } = lintProject(project); + + expect(results).toHaveLength(2); + const first = results[0]; + const second = results[1]; + expect(first).toBeDefined(); + expect(second).toBeDefined(); + // Both files have media_missing_id errors + const rootErrors = first?.result.errorCount ?? 0; + const subErrors = second?.result.errorCount ?? 0; + expect(totalErrors).toBe(rootErrors + subErrors); + }); + + it("aggregates warnings from sub-compositions", () => { + const project = makeProject(validHtml(), { + "captions.html": htmlWithPreloadNone(), + }); + const { totalWarnings, results } = lintProject(project); + + expect(results).toHaveLength(2); + expect(totalWarnings).toBeGreaterThan(0); + const second = results[1]; + expect(second).toBeDefined(); + const preloadWarning = second?.result.findings.find((f) => f.code === "media_preload_none"); + expect(preloadWarning).toBeDefined(); + }); + + it("handles project with no compositions/ directory", () => { + const project = makeProject(validHtml()); + // No compositions/ dir created + const { results } = lintProject(project); + + expect(results).toHaveLength(1); + }); + + it("ignores non-HTML files in compositions/", () => { + const project = makeProject(validHtml(), { + "captions.html": validHtml("captions"), + }); + // Add a non-HTML file + writeFileSync(join(project.dir, "compositions", "readme.txt"), "not html"); + + const { results } = lintProject(project); + + expect(results).toHaveLength(2); // index.html + captions.html, not readme.txt + }); +}); + +describe("shouldBlockRender", () => { + it("default: does not block on errors", () => { + expect(shouldBlockRender(false, false, 5, 0)).toBe(false); + }); + + it("default: does not block on warnings", () => { + expect(shouldBlockRender(false, false, 0, 3)).toBe(false); + }); + + it("--strict: blocks on errors", () => { + expect(shouldBlockRender(true, false, 1, 0)).toBe(true); + }); + + it("--strict: does not block on warnings only", () => { + expect(shouldBlockRender(true, false, 0, 5)).toBe(false); + }); + + it("--strict-all: blocks on errors", () => { + expect(shouldBlockRender(true, true, 1, 0)).toBe(true); + }); + + it("--strict-all: blocks on warnings", () => { + expect(shouldBlockRender(true, true, 0, 1)).toBe(true); + }); + + it("--strict-all: does not block when clean", () => { + expect(shouldBlockRender(true, true, 0, 0)).toBe(false); + }); + + it("--strict-all alone: blocks on errors", () => { + expect(shouldBlockRender(false, true, 1, 0)).toBe(true); + }); + + it("--strict-all alone: blocks on warnings", () => { + expect(shouldBlockRender(false, true, 0, 1)).toBe(true); + }); +}); diff --git a/packages/cli/src/utils/lintProject.ts b/packages/cli/src/utils/lintProject.ts new file mode 100644 index 000000000..cb08dd7c9 --- /dev/null +++ b/packages/cli/src/utils/lintProject.ts @@ -0,0 +1,60 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/core/lint"; +import type { ProjectDir } from "./project.js"; + +export interface ProjectLintResult { + results: Array<{ file: string; result: HyperframeLintResult }>; + totalErrors: number; + totalWarnings: number; + totalInfos: number; +} + +/** + * Lint the root index.html and all sub-compositions in the compositions/ directory. + * Returns aggregated results across all files. + */ +export function lintProject(project: ProjectDir): ProjectLintResult { + const results: Array<{ file: string; result: HyperframeLintResult }> = []; + let totalErrors = 0; + let totalWarnings = 0; + let totalInfos = 0; + + // Lint root composition + const rootHtml = readFileSync(project.indexPath, "utf-8"); + const rootResult = lintHyperframeHtml(rootHtml, { filePath: project.indexPath }); + results.push({ file: "index.html", result: rootResult }); + totalErrors += rootResult.errorCount; + totalWarnings += rootResult.warningCount; + totalInfos += rootResult.infoCount; + + // Lint sub-compositions in compositions/ directory + const compositionsDir = resolve(project.dir, "compositions"); + if (existsSync(compositionsDir)) { + const files = readdirSync(compositionsDir).filter((f) => f.endsWith(".html")); + for (const file of files) { + const filePath = join(compositionsDir, file); + const html = readFileSync(filePath, "utf-8"); + const result = lintHyperframeHtml(html, { filePath }); + results.push({ file: `compositions/${file}`, result }); + totalErrors += result.errorCount; + totalWarnings += result.warningCount; + totalInfos += result.infoCount; + } + } + + return { results, totalErrors, totalWarnings, totalInfos }; +} + +/** + * Determine whether a render should be blocked based on lint results and strict mode. + * --strict blocks on errors; --strict-all blocks on errors or warnings. + */ +export function shouldBlockRender( + strictErrors: boolean, + strictAll: boolean, + totalErrors: number, + totalWarnings: number, +): boolean { + return (strictErrors && totalErrors > 0) || (strictAll && (totalErrors > 0 || totalWarnings > 0)); +} diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 000000000..ae847ff6d --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/packages/core/package.json b/packages/core/package.json index ab2891c21..7fc8ce27a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/core", - "version": "0.1.12", + "version": "0.1.13", "description": "", "repository": { "type": "git", diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts new file mode 100644 index 000000000..9f0fc64e3 --- /dev/null +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -0,0 +1,100 @@ +// @vitest-environment node +import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it, expect } from "vitest"; +import { bundleToSingleHtml } from "./htmlBundler"; + +function makeTempProject(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), "hf-bundler-test-")); + for (const [rel, content] of Object.entries(files)) { + const full = join(dir, rel); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, content, "utf-8"); + } + return dir; +} + +describe("bundleToSingleHtml", () => { + it("hoists external CDN scripts from sub-compositions into the bundle", async () => { + const dir = makeTempProject({ + "index.html": ` + + + +
+
+
+ +`, + "compositions/rockets.html": ``, + }); + + const bundled = await bundleToSingleHtml(dir); + + // Lottie CDN script from sub-composition must be present in the bundle + expect(bundled).toContain( + "https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.12.2/lottie.min.js", + ); + + // Should only appear once (deduped) + const occurrences = (bundled.match(/cdnjs\.cloudflare\.com\/ajax\/libs\/lottie-web/g) ?? []) + .length; + expect(occurrences).toBe(1); + + // GSAP CDN from main doc should still be present + expect(bundled).toContain("cdn.jsdelivr.net/npm/gsap"); + + // data-composition-src should be stripped (composition was inlined) + expect(bundled).not.toContain("data-composition-src"); + }); + + it("does not duplicate CDN scripts already present in the main document", async () => { + const dir = makeTempProject({ + "index.html": ` + + + +
+
+
+ +`, + "compositions/child.html": ``, + }); + + const bundled = await bundleToSingleHtml(dir); + + // GSAP CDN should appear exactly once (deduped) + const gsapOccurrences = ( + bundled.match(/cdn\.jsdelivr\.net\/npm\/gsap@3\.14\.2\/dist\/gsap\.min\.js/g) ?? [] + ).length; + expect(gsapOccurrences).toBe(1); + }); +}); diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index f9a2035a3..54b3250c7 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -390,6 +390,7 @@ export async function bundleToSingleHtml( // Inline sub-compositions const compStyleChunks: string[] = []; const compScriptChunks: string[] = []; + const compExternalScriptSrcs: string[] = []; $("[data-composition-src]").each((_, hostEl) => { const src = $(hostEl).attr("data-composition-src"); if (!src || !isRelativeUrl(src)) return; @@ -416,9 +417,18 @@ export async function bundleToSingleHtml( $content(s).remove(); }); $content("script").each((_, s) => { - compScriptChunks.push( - `(function(){ try { ${$content(s).html() || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`, - ); + const externalSrc = ($content(s).attr("src") || "").trim(); + if (externalSrc) { + // External CDN/remote script — collect for deduped injection into the document. + // Do NOT try to inline the content (external scripts have no innerHTML). + if (!compExternalScriptSrcs.includes(externalSrc)) { + compExternalScriptSrcs.push(externalSrc); + } + } else { + compScriptChunks.push( + `(function(){ try { ${$content(s).html() || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`, + ); + } $content(s).remove(); }); @@ -439,6 +449,14 @@ export async function bundleToSingleHtml( $(hostEl).removeAttr("data-composition-src"); }); + // Inject external scripts from sub-compositions (e.g., Lottie CDN) + // that aren't already present in the main document. + for (const extSrc of compExternalScriptSrcs) { + if (!$(`script[src="${extSrc}"]`).length) { + $("body").append(``); + } + } + if (compStyleChunks.length) $("head").append(``); if (compScriptChunks.length) $("body").append(``); diff --git a/packages/core/src/lint/hyperframeLinter.test.ts b/packages/core/src/lint/hyperframeLinter.test.ts index 201c455bb..a051b1f44 100644 --- a/packages/core/src/lint/hyperframeLinter.test.ts +++ b/packages/core/src/lint/hyperframeLinter.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from "vitest"; -import { lintHyperframeHtml } from "./hyperframeLinter.js"; +import { describe, it, expect, vi } from "vitest"; +import { lintHyperframeHtml, lintScriptUrls } from "./hyperframeLinter.js"; describe("lintHyperframeHtml", () => { const validComposition = ` @@ -111,6 +111,42 @@ describe("lintHyperframeHtml", () => { expect(codes.length).toBe(uniqueCodes.length); }); + it("reports info for composition with external CDN script dependency", () => { + const html = ``; + const result = lintHyperframeHtml(html, { filePath: "compositions/rockets.html" }); + const finding = result.findings.find((f) => f.code === "external_script_dependency"); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("info"); + expect(finding?.message).toContain("cdnjs.cloudflare.com"); + // info findings do not count as errors — ok should still be true + expect(result.ok).toBe(true); + expect(result.errorCount).toBe(0); + }); + + it("does not report external_script_dependency for inline scripts", () => { + const html = ` + +
+ +
+`; + const result = lintHyperframeHtml(html); + expect(result.findings.find((f) => f.code === "external_script_dependency")).toBeUndefined(); + }); + it("strips