Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/src/commands/add.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<!-- hyperframes-registry-item: my-block -->");
expect(installed).toContain("my-block.html");
expect(result.snippet).toContain("compositions/my-block.html");
} finally {
rmSync(dir, { recursive: true, force: true });
Expand Down
21 changes: 21 additions & 0 deletions packages/cli/src/registry/installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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*<!--\s*hyperframes-registry-item:[^>]*-->/i.test(source.slice(0, 512))) {
return source;
}

return `<!-- hyperframes-registry-item: ${item.name} -->\n${source}`;
}

/**
* Install a resolved `RegistryItem` into `destDir` by fetching each file in
* parallel and writing it to its validated target path.
Expand All @@ -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;
}),
);
Expand Down
17 changes: 12 additions & 5 deletions packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/src/utils/lintProject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": `<html><head><link rel="stylesheet" href="scene.css"></head><body>
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080" data-start="0" data-duration="2"></div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["scene"] = gsap.timeline({ paused: true });</script>
</body></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(),
Expand Down
42 changes: 38 additions & 4 deletions packages/cli/src/utils/lintProject.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 = /<link\b[^>]*>/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.
Expand All @@ -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;
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/core",
"version": "0.4.36",
"version": "0.4.37",
"description": "",
"repository": {
"type": "git",
Expand Down
65 changes: 57 additions & 8 deletions packages/core/src/compiler/compositionScoping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down Expand Up @@ -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) => {
Expand All @@ -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*\]`,
);
Expand All @@ -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};
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down
58 changes: 57 additions & 1 deletion packages/core/src/compiler/htmlBundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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": `<!doctype html>
<html><head>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
</head><body>
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div
id="scene-a"
data-composition-id="scene"
data-composition-src="compositions/scene.html"
data-start="0"
data-duration="5"></div>
<div
id="scene-b"
data-composition-id="scene"
data-composition-src="compositions/scene.html"
data-start="5"
data-duration="5"></div>
</div>
<script>window.__timelines={};</script>
</body></html>`,
"compositions/scene.html": `<template id="scene-template">
<div data-composition-id="scene" data-width="1920" data-height="1080">
<style>[data-composition-id="scene"] .title { opacity: 0; }</style>
<h1 class="title">Scene</h1>
<script>
const tl = gsap.timeline({ paused: true });
tl.to('[data-composition-id="scene"] .title', { opacity: 1 });
window.__timelines = window.__timelines || {};
window.__timelines["scene"] = tl;
</script>
</div>
</template>`,
});

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": `<!doctype html>
Expand Down
Loading