diff --git a/packages/cli/package.json b/packages/cli/package.json index aa09f36471..b9cf9a06fb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/cli", - "version": "0.4.36", + "version": "0.4.37", "description": "HyperFrames CLI — create, preview, and render HTML video compositions", "repository": { "type": "git", diff --git a/packages/cli/src/commands/add.test.ts b/packages/cli/src/commands/add.test.ts index 87b82f525e..6836b20d0d 100644 --- a/packages/cli/src/commands/add.test.ts +++ b/packages/cli/src/commands/add.test.ts @@ -181,9 +181,9 @@ describe("runAdd (integration, mocked registry)", () => { expect(result.type).toBe("hyperframes:block"); expect(result.written).toHaveLength(1); expect(existsSync(join(dir, "compositions/my-block.html"))).toBe(true); - expect(readFileSync(join(dir, "compositions/my-block.html"), "utf-8")).toContain( - "my-block.html", - ); + const installed = readFileSync(join(dir, "compositions/my-block.html"), "utf-8"); + expect(installed).toContain(""); + expect(installed).toContain("my-block.html"); expect(result.snippet).toContain("compositions/my-block.html"); } finally { rmSync(dir, { recursive: true, force: true }); diff --git a/packages/cli/src/registry/installer.ts b/packages/cli/src/registry/installer.ts index 5ba6d2cdeb..4ce4314e14 100644 --- a/packages/cli/src/registry/installer.ts +++ b/packages/cli/src/registry/installer.ts @@ -6,6 +6,7 @@ * runtime to reject traversal even if the registry JSON schema was bypassed. */ +import { readFileSync, writeFileSync } from "node:fs"; import { resolve, relative, isAbsolute } from "node:path"; import type { FileTarget, RegistryItem } from "@hyperframes/core"; import { fetchItemFile, DEFAULT_REGISTRY_URL } from "./remote.js"; @@ -45,6 +46,22 @@ export function assertSafeTarget(destDir: string, target: string): void { } } +function isInstalledRegistryBlockComposition(item: RegistryItem, file: FileTarget): boolean { + return ( + item.type === "hyperframes:block" && + file.type === "hyperframes:composition" && + file.target.toLowerCase().endsWith(".html") + ); +} + +function addRegistryItemMarker(source: string, item: RegistryItem): string { + if (/^\s*/i.test(source.slice(0, 512))) { + return source; + } + + return `\n${source}`; +} + /** * Install a resolved `RegistryItem` into `destDir` by fetching each file in * parallel and writing it to its validated target path. @@ -65,6 +82,10 @@ export async function installItem( item.files.map(async (file: FileTarget) => { const destPath = resolve(destDir, file.target); await fetchItemFile(item, file, destPath, baseUrl); + if (isInstalledRegistryBlockComposition(item, file)) { + const source = readFileSync(destPath, "utf-8"); + writeFileSync(destPath, addRegistryItemMarker(source, item), "utf-8"); + } return destPath; }), ); diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index adc99d6068..0f4d06024e 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -278,11 +278,18 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { }; }, opts.selector); } - const screenshot = (await page.screenshot({ - type: "jpeg", - quality: 80, - ...(clip ? { clip } : {}), - })) as Buffer; + const screenshot = (await page.screenshot( + opts.format === "png" + ? { + type: "png", + ...(clip ? { clip } : {}), + } + : { + type: "jpeg", + quality: 80, + ...(clip ? { clip } : {}), + }, + )) as Buffer; return screenshot; } catch { return null; diff --git a/packages/cli/src/utils/lintProject.test.ts b/packages/cli/src/utils/lintProject.test.ts index bc03648383..3c72febc4e 100644 --- a/packages/cli/src/utils/lintProject.test.ts +++ b/packages/cli/src/utils/lintProject.test.ts @@ -99,6 +99,28 @@ describe("lintProject", () => { expect(subFindings.some((f) => f.code === "media_missing_id")).toBe(true); }); + it("lints linked CSS next to sub-compositions", () => { + const project = makeProject(validHtml(), { + "scene.html": ` +
+ +`, + }); + writeFileSync( + join(project.dir, "compositions", "scene.css"), + '[data-composition-id="scene"] .title { opacity: 0; }', + ); + + const { results } = lintProject(project); + const subResult = results.find((result) => result.file === "compositions/scene.html"); + const finding = subResult?.result.findings.find( + (item) => item.code === "composition_self_attribute_selector", + ); + + expect(finding).toBeDefined(); + expect(finding?.selector).toBe('[data-composition-id="scene"] .title'); + }); + it("aggregates errors across index.html and sub-compositions", () => { const project = makeProject(htmlWithMissingMediaId(), { "overlay.html": htmlWithMissingMediaId(), diff --git a/packages/cli/src/utils/lintProject.ts b/packages/cli/src/utils/lintProject.ts index c119a141fa..45eef143c1 100644 --- a/packages/cli/src/utils/lintProject.ts +++ b/packages/cli/src/utils/lintProject.ts @@ -1,5 +1,5 @@ import { existsSync, readFileSync, readdirSync } from "node:fs"; -import { join, resolve, extname } from "node:path"; +import { dirname, join, resolve, extname } from "node:path"; import { lintHyperframeHtml, type HyperframeLintResult } from "@hyperframes/core/lint"; import type { HyperframeLintFinding } from "@hyperframes/core/lint"; import { rewriteAssetPath } from "@hyperframes/core"; @@ -27,6 +27,32 @@ export interface ProjectLintResult { const AUDIO_EXTENSIONS = new Set([".mp3", ".wav", ".aac", ".ogg", ".m4a", ".flac", ".opus"]); +function isLocalStylesheetHref(href: string): boolean { + return !!href && !/^(https?:|data:|blob:|\/\/)/i.test(href); +} + +function collectExternalStyles( + projectDir: string, + html: string, + compSrcPath?: string, +): Array<{ href: string; content: string }> { + const styles: Array<{ href: string; content: string }> = []; + const linkRe = /]*>/gi; + let match: RegExpExecArray | null; + while ((match = linkRe.exec(html)) !== null) { + const tag = match[0]; + const rel = tag.match(/\brel\s*=\s*["']([^"']+)["']/i)?.[1] ?? ""; + if (!rel.split(/\s+/).some((part) => part.toLowerCase() === "stylesheet")) continue; + const href = tag.match(/\bhref\s*=\s*["']([^"']+)["']/i)?.[1] ?? ""; + if (!isLocalStylesheetHref(href)) continue; + const rootRelative = compSrcPath ? join(dirname(compSrcPath), href) : href; + const resolved = resolve(projectDir, rootRelative); + if (!existsSync(resolved)) continue; + styles.push({ href, content: readFileSync(resolved, "utf-8") }); + } + return styles; +} + /** * Lint the root index.html and all sub-compositions in the compositions/ directory. * Returns aggregated results across all files. @@ -39,7 +65,10 @@ export function lintProject(project: ProjectDir): ProjectLintResult { // Lint root composition const rootHtml = readFileSync(project.indexPath, "utf-8"); - const rootResult = lintHyperframeHtml(rootHtml, { filePath: project.indexPath }); + const rootResult = lintHyperframeHtml(rootHtml, { + filePath: project.indexPath, + externalStyles: collectExternalStyles(project.dir, rootHtml), + }); results.push({ file: "index.html", result: rootResult }); totalErrors += rootResult.errorCount; totalWarnings += rootResult.warningCount; @@ -53,8 +82,13 @@ export function lintProject(project: ProjectDir): ProjectLintResult { for (const file of files) { const filePath = join(compositionsDir, file); const html = readFileSync(filePath, "utf-8"); - allHtmlSources.push({ html, compSrcPath: `compositions/${file}` }); - const result = lintHyperframeHtml(html, { filePath, isSubComposition: true }); + const compSrcPath = `compositions/${file}`; + allHtmlSources.push({ html, compSrcPath }); + const result = lintHyperframeHtml(html, { + filePath, + isSubComposition: true, + externalStyles: collectExternalStyles(project.dir, html, compSrcPath), + }); results.push({ file: `compositions/${file}`, result }); totalErrors += result.errorCount; totalWarnings += result.warningCount; diff --git a/packages/core/package.json b/packages/core/package.json index 3dafe6ec9e..22a451a7de 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/core", - "version": "0.4.36", + "version": "0.4.37", "description": "", "repository": { "type": "git", diff --git a/packages/core/src/compiler/compositionScoping.ts b/packages/core/src/compiler/compositionScoping.ts index 4667202c34..6194723f28 100644 --- a/packages/core/src/compiler/compositionScoping.ts +++ b/packages/core/src/compiler/compositionScoping.ts @@ -18,9 +18,12 @@ function scopeSelector(selector: string, scope: string, compositionId: string): if (!trimmed) return selector; if (/^(html|body|:root|\*)$/i.test(trimmed)) return selector; const compositionIdPattern = new RegExp( - `data-composition-id\\s*=\\s*(["'])${escapeRegExp(compositionId)}\\1`, + `\\[\\s*data-composition-id\\s*=\\s*(["'])${escapeRegExp(compositionId)}\\1\\s*\\]`, + "g", ); - if (compositionIdPattern.test(trimmed)) return selectorWithoutRootTiming; + if (compositionIdPattern.test(trimmed)) { + return selectorWithoutRootTiming.replace(compositionIdPattern, scope); + } const leading = selectorWithoutRootTiming.match(/^\s*/)?.[0] ?? ""; const trailing = selectorWithoutRootTiming.match(/\s*$/)?.[0] ?? ""; return `${leading}${scope} ${trimmed}${trailing}`; @@ -56,10 +59,16 @@ function isInsideGlobalAtRule(rule: Rule): boolean { return false; } -export function scopeCssToComposition(css: string, compositionId: string): string { +export function scopeCssToComposition( + css: string, + compositionId: string, + scopeSelectorOverride?: string, +): string { const trimmedCompositionId = compositionId.trim(); if (!css || !trimmedCompositionId) return css; - const scope = `[data-composition-id="${escapeCssAttributeValue(trimmedCompositionId)}"]`; + const scope = + scopeSelectorOverride || + `[data-composition-id="${escapeCssAttributeValue(trimmedCompositionId)}"]`; const root = postcss.parse(css); root.walkRules((rule) => { @@ -76,10 +85,14 @@ export function wrapScopedCompositionScript( source: string, compositionId: string, errorLabel = "[HyperFrames] composition script error:", + scopeSelectorOverride?: string, + timelineCompositionId = compositionId, ): string { const compositionIdLiteral = JSON.stringify(compositionId); + const timelineCompositionIdLiteral = JSON.stringify(timelineCompositionId); const errorLabelLiteral = JSON.stringify(errorLabel); const escapedCompositionId = escapeRegExp(compositionId); + const scopeSelectorLiteral = JSON.stringify(scopeSelectorOverride ?? null); const rootSelectorPatternLiteral = JSON.stringify( String.raw`\[\s*data-composition-id\s*=\s*(?:"${escapedCompositionId}"|'${escapedCompositionId}')\s*\]`, ); @@ -88,13 +101,14 @@ export function wrapScopedCompositionScript( ); return `(function(){ var __hfCompId = ${compositionIdLiteral}; + var __hfTimelineCompId = ${timelineCompositionIdLiteral}; var __hfErrorLabel = ${errorLabelLiteral}; var __hfEscapeAttr = function(value) { return (value + "").replace(/\\\\/g, "\\\\\\\\").replace(/"/g, "\\\\\\""); }; - var __hfRootSelector = __hfCompId + var __hfRootSelector = ${scopeSelectorLiteral} || (__hfCompId ? '[data-composition-id="' + __hfEscapeAttr(__hfCompId) + '"]' - : ""; + : ""); var __hfRoot = null; var __hfRootSelectorPattern = ${rootSelectorPatternLiteral}; var __hfTimingSelectorPattern = ${timingSelectorPatternLiteral}; @@ -143,6 +157,41 @@ export function wrapScopedCompositionScript( }, }) : window.document; + var __hfTimelineRegistryProxy = null; + var __hfGetTimelineRegistry = function() { + window.__timelines = window.__timelines || {}; + if (!__hfCompId || __hfCompId === __hfTimelineCompId || typeof Proxy !== "function") { + return window.__timelines; + } + if (!__hfTimelineRegistryProxy) { + __hfTimelineRegistryProxy = new Proxy(window.__timelines, { + get: function(target, prop, receiver) { + return Reflect.get(target, prop === __hfCompId ? __hfTimelineCompId : prop, receiver); + }, + set: function(target, prop, value, receiver) { + return Reflect.set(target, prop === __hfCompId ? __hfTimelineCompId : prop, value, receiver); + }, + }); + } + return __hfTimelineRegistryProxy; + }; + var __hfScopedWindow = typeof Proxy === "function" + ? new Proxy(window, { + get: function(target, prop, receiver) { + if (prop === "__timelines") return __hfGetTimelineRegistry(); + var value = Reflect.get(target, prop, receiver); + return typeof value === "function" ? value.bind(target) : value; + }, + set: function(target, prop, value, receiver) { + if (prop === "__timelines") { + target.__timelines = value || {}; + __hfTimelineRegistryProxy = null; + return true; + } + return Reflect.set(target, prop, value, receiver); + }, + }) + : window; var __hfResolveGsapTarget = function(target) { if (typeof target !== "string") return target; return __hfQueryAll(target); @@ -214,9 +263,9 @@ export function wrapScopedCompositionScript( }); var __hfRun = function() { try { - (function(document, gsap) { + (function(document, gsap, window) { ${source} - }).call(window, __hfScopedDocument, __hfScopedGsap); + }).call(window, __hfScopedDocument, __hfScopedGsap, __hfScopedWindow); } catch (_err) { console.error(__hfErrorLabel, __hfCompId, _err); } diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index 197c313230..1d93d219bf 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -137,7 +137,7 @@ describe("bundleToSingleHtml", () => { expect(bundled).toContain(".logo"); // Scripts from template should be included - expect(bundled).toContain('window.__timelines["logo-reveal"]'); + expect(bundled).toContain('__timelines["logo-reveal"]'); }); it("does not inline template when host already has content", async () => { @@ -284,6 +284,62 @@ describe("bundleToSingleHtml", () => { expect(bundled).toContain('tl.to(".title"'); }); + it("isolates sibling instances of the same external sub-composition", async () => { + const dir = makeTempProject({ + "index.html": ` + + + +
+
+
+
+ +`, + "compositions/scene.html": ``, + }); + + const bundled = await bundleToSingleHtml(dir); + + const { document } = parseHTML(bundled); + const sceneA = document.querySelector("#scene-a"); + const sceneB = document.querySelector("#scene-b"); + const sceneAId = sceneA?.getAttribute("data-composition-id") ?? ""; + const sceneBId = sceneB?.getAttribute("data-composition-id") ?? ""; + + expect(sceneAId).not.toBe("scene"); + expect(sceneBId).not.toBe("scene"); + expect(sceneAId).not.toBe(sceneBId); + expect(sceneA?.getAttribute("data-hf-original-composition-id")).toBe("scene"); + expect(sceneB?.getAttribute("data-hf-original-composition-id")).toBe("scene"); + expect(bundled).toContain(`[data-composition-id="${sceneAId}"] .title`); + expect(bundled).toContain(`[data-composition-id="${sceneBId}"] .title`); + expect(bundled).toContain('var __hfTimelineCompId = "scene__hf1"'); + expect(bundled).toContain('var __hfTimelineCompId = "scene__hf2"'); + expect(bundled).not.toContain('[data-composition-id="scene"] .title { opacity: 0; }'); + }); + it("rewrites CSS url(...) asset paths from sub-compositions when styles are hoisted", async () => { const dir = makeTempProject({ "index.html": ` diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index 158c4c3441..34e139a010 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -193,6 +193,14 @@ function rewriteCssUrlsWithInlinedAssets(cssText: string, projectDir: string): s ); } +function cssAttributeSelector(attr: string, value: string): string { + return `[${attr}="${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`; +} + +function uniqueCompositionId(baseId: string, index: number): string { + return `${baseId}__hf${index}`; +} + function enforceCompositionPixelSizing(document: Document): void { const compositionEls = [ ...document.querySelectorAll("[data-composition-id][data-width][data-height]"), @@ -422,7 +430,15 @@ export async function bundleToSingleHtml( const compStyleChunks: string[] = []; const compScriptChunks: string[] = []; const compExternalScriptSrcs: string[] = []; - for (const hostEl of [...document.querySelectorAll("[data-composition-src]")]) { + const subCompositionHosts = [...document.querySelectorAll("[data-composition-src]")]; + const hostCountsByCompositionId = new Map(); + for (const hostEl of subCompositionHosts) { + const compId = (hostEl.getAttribute("data-composition-id") || "").trim(); + if (!compId) continue; + hostCountsByCompositionId.set(compId, (hostCountsByCompositionId.get(compId) || 0) + 1); + } + const hostInstanceByCompositionId = new Map(); + for (const hostEl of subCompositionHosts) { const src = hostEl.getAttribute("data-composition-src"); if (!src || !isRelativeUrl(src)) continue; const compPath = safePath(projectDir, src); @@ -442,6 +458,22 @@ export async function bundleToSingleHtml( : contentDoc.querySelector("[data-composition-id]"); const inferredCompId = innerRoot?.getAttribute("data-composition-id")?.trim() || ""; const scopeCompId = compId || inferredCompId; + const duplicateInstance = scopeCompId && (hostCountsByCompositionId.get(scopeCompId) || 0) > 1; + const instanceIndex = duplicateInstance + ? (hostInstanceByCompositionId.get(scopeCompId) || 0) + 1 + : 0; + if (duplicateInstance) hostInstanceByCompositionId.set(scopeCompId, instanceIndex); + const runtimeCompId = + duplicateInstance && scopeCompId + ? uniqueCompositionId(scopeCompId, instanceIndex) + : scopeCompId; + const runtimeScope = runtimeCompId + ? cssAttributeSelector("data-composition-id", runtimeCompId) + : ""; + if (duplicateInstance && runtimeCompId) { + hostEl.setAttribute("data-hf-original-composition-id", scopeCompId); + hostEl.setAttribute("data-composition-id", runtimeCompId); + } // When a sub-composition is a full HTML document (no