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
6 changes: 6 additions & 0 deletions docs/reference/lint-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ lints the file it is editing.

**Fix:** Give every caption element the same `data-track-index`.

### `multiple_scene_templates`

**Warning.** A file has more than one `<template>` holding a composition root and no composition root outside them. Only the first one renders; the message names each template.

**Fix:** Keep one composition root per file, or move the other scenes into their own files.

### `clip_ends_past_root_duration`

**Warning.** A clip runs past the root composition's `data-duration`, so it is cut off in previews, posters and renders.
Expand Down
47 changes: 47 additions & 0 deletions packages/core/src/compiler/htmlBundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,53 @@ describe("bundleToSingleHtml", () => {
},
);

it("bundles the first scene template of a file with two scene templates and no live root", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html><html><body>
<div data-composition-id="root" data-width="320" data-height="180" data-duration="6">
<div data-composition-id="scene" data-composition-src="compositions/scene.html" data-start="0" data-duration="6"></div>
</div>
</body></html>`,
"compositions/scene.html": `<template><div data-composition-id="scene" data-duration="6"><h1>first scene</h1></div></template>
<template><div data-composition-id="other"><h1>second scene</h1></div></template>`,
});

const bundled = await bundleToSingleHtml(dir, { entryFile: "compositions/scene.html" });

expect(bundled).toContain("first scene");
expect(bundled).not.toContain("second scene");
});

it.each([
[
"a card template before the scene",
`<template id="card"><div class="card">card</div></template>`,
"mounted scene",
"",
],
[
"two scene templates",
"",
"mounted scene",
`<template><div data-composition-id="other"><h1>second scene</h1></div></template>`,
],
])("mounts the scene template of a file with %s", async (_label, before, expected, after) => {
const dir = makeTempProject({
"index.html": `<!doctype html><html><body>
<div data-composition-id="root" data-width="320" data-height="180" data-duration="6">
<div data-composition-id="scene" data-composition-src="compositions/scene.html" data-start="0" data-duration="6"></div>
</div>
</body></html>`,
"compositions/scene.html": `${before}<template><div data-composition-id="scene" data-duration="6"><h1>mounted scene</h1></div></template>${after}`,
});

const bundled = await bundleToSingleHtml(dir);

expect(bundled).toContain(expected);
expect(bundled).not.toContain("second scene");
expect(bundled).not.toContain(">card<");
});

it("rebases direct-entry authored asset paths before inlining", async () => {
const spriteSvg = '<svg xmlns="http://www.w3.org/2000/svg"><circle r="4"/></svg>';
const bgSvg = '<svg xmlns="http://www.w3.org/2000/svg"><rect width="8" height="8"/></svg>';
Expand Down
25 changes: 24 additions & 1 deletion packages/core/src/compiler/htmlDocument.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { acceleratedCanvasTrackerSource } from "../pageMotionBlur.js";
import { findSceneTemplate } from "@hyperframes/parsers/sub-composition-validity";
import { parseHTML } from "linkedom";

export const RUNTIME_BOOTSTRAP_ATTR = "data-hyperframes-preview-runtime";
Expand All @@ -16,8 +17,30 @@ const SIMPLE_RUNTIME_FLAG_ASSIGNMENTS = [

const LEADING_COMMENTS = /^(?:\s|<!--(?:>|->|[\s\S]*?-->))*/;

export interface SceneRootLookup {
root: Element | null;
hasTemplate: boolean;
hasLiveRoot: boolean;
}

export function locateSceneRoot(html: string): SceneRootLookup {
const { document } = parseHTML(html);
const liveRoot = document.querySelector("[data-composition-id]") as Element | null;
const templates = Array.from(document.querySelectorAll("template"));
const sceneRoot =
Comment thread
heygengenesis[bot] marked this conversation as resolved.
Resolved
(findSceneTemplate(templates)?.content.querySelector(
"[data-composition-id]",
) as Element | null) ?? null;
return {
root: sceneRoot,
hasTemplate: templates.length > 0,
hasLiveRoot: liveRoot !== null,
};
}

export function isTemplateEntry(html: string): boolean {
return /^<template[\s>/]/i.test(html.replace(LEADING_COMMENTS, ""));
const { root, hasTemplate, hasLiveRoot } = locateSceneRoot(html);
return hasTemplate && root !== null && !hasLiveRoot;
}

export function isFullHtmlDocument(html: string): boolean {
Expand Down
87 changes: 79 additions & 8 deletions packages/core/src/compiler/standaloneEntry.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,96 @@
import { parseHTML } from "linkedom";
import { describe, expect, it } from "vitest";
import { locateSceneRoot } from "./htmlDocument";
import { extractStandaloneEntryFromIndex, isTemplateEntry } from "./standaloneEntry";

const ROOT = `<div id="root" data-composition-id="scene" data-width="640" data-height="360" data-duration="3"><p id="label">Hello</p></div>`;
const SCENE_SCRIPT = `<script>window.__timelines = window.__timelines || {}; const tl = gsap.timeline({ paused: true }); tl.to("#label", { opacity: 1, duration: 3 }, 0); window.__timelines["scene"] = tl;</script>`;
const SCENE_STYLE = `<style>#root { position: absolute; inset: 0; background: #123456; }</style>`;

describe("isTemplateEntry", () => {
it.each([
"<template></template>",
" \n<!-- Scene author notes -->\n<template id='scene'></template>",
"<!-- a --><!-- <template> is inert --><TEMPLATE></TEMPLATE>",
"<!--><template></template>",
])("recognizes a template entry past leading comments: %s", (html) => {
`<template>${ROOT}</template>`,
` \n<!-- Scene author notes -->\n<template id='scene'>${ROOT}</template>`,
`<!-- a --><!-- <template> is inert --><TEMPLATE>${ROOT}</TEMPLATE>`,
`<!--><template>${ROOT}</template>`,
])("recognizes a template-first fragment entry: %s", (html) => {
expect(isTemplateEntry(html)).toBe(true);
});

it("recognizes the full-document shape whose composition root lives in the body template", () => {
const html = `<!doctype html>
<html>
<head><meta charset="UTF-8" /></head>
<body>
<template>
${SCENE_STYLE}
${ROOT}
${SCENE_SCRIPT}
</template>
</body>
</html>`;
expect(isTemplateEntry(html)).toBe(true);
});

it.each([
"<template-card></template-card>",
"<!doctype html><html><body><template></template></body></html>",
"<div></div><template></template>",
"<!-- <template></template> -->",
`<template-card>${ROOT}</template-card>`,
`<!doctype html><html><body><template></template></body></html>`,
`<div></div><template></template>`,
`<!-- <template>${ROOT}</template> -->`,
`<!doctype html><html><body>${ROOT}</body></html>`,
])("does not mistake other entries for a template scene: %s", (html) => {
expect(isTemplateEntry(html)).toBe(false);
});

it("keeps a full document with a live composition root outside the template a plain entry", () => {
const html = `<!doctype html>
<html>
<body>
<div id="live" data-composition-id="live" data-width="640" data-height="360" data-duration="3"></div>
<template>${ROOT}</template>
</body>
</html>`;
expect(isTemplateEntry(html)).toBe(false);
});

it("keeps a template-first fragment with a live composition root beside it a plain entry", () => {
const html = `<template>${ROOT}</template><div data-composition-id="live"></div>`;
expect(isTemplateEntry(html)).toBe(false);
});

it.each([
`<template id="card"><div class="card">Card</div></template><template id="scene">${ROOT}</template>`,
`<!doctype html><html><body><template><svg><symbol id="i"></symbol></svg></template><template>${ROOT}</template></body></html>`,
`<template></template><template><p>x</p></template><template>${ROOT}</template>`,
])("finds the scene template when an earlier template holds no composition root: %s", (html) => {
expect(isTemplateEntry(html)).toBe(true);
expect(locateSceneRoot(html).root?.getAttribute("data-composition-id")).toBe("scene");
});

it("renders the first scene template when several hold a composition root and none is live", () => {
const html = `<template id="intro">${ROOT}</template><template><div data-composition-id="later"></div></template>`;
expect(locateSceneRoot(html).root?.getAttribute("data-composition-id")).toBe("scene");
expect(isTemplateEntry(html)).toBe(true);
});

it("does not refuse when a live root makes the file a plain entry", () => {
const html = `<template>${ROOT}</template><template>${ROOT}</template><div data-composition-id="live"></div>`;
expect(isTemplateEntry(html)).toBe(false);
});

it("reports no root when no template holds a composition root", () => {
const lookup = locateSceneRoot(`<template><p>a</p></template><template><p>b</p></template>`);
expect(lookup).toMatchObject({ root: null, hasTemplate: true, hasLiveRoot: false });
});

it("reads a single template and a template-free document as before", () => {
expect(locateSceneRoot(`<template>${ROOT}</template>`).root?.id).toBe("root");
expect(locateSceneRoot(`<!doctype html><html><body>${ROOT}</body></html>`)).toMatchObject({
hasTemplate: false,
hasLiveRoot: true,
});
});
});

describe("extractStandaloneEntryFromIndex", () => {
Expand Down
8 changes: 2 additions & 6 deletions packages/core/src/compiler/standaloneEntry.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
import { parseHTML } from "linkedom";
import { HF_AUDIO_FX_ATTR } from "../audioFx";
import { locateSceneRoot } from "./htmlDocument";

export { isTemplateEntry } from "./htmlDocument";

function normalizeCompositionSrcPath(srcPath: string): string {
return srcPath.replace(/\\/g, "/").replace(/^\.\//, "");
}

// linkedom has no inert <template> content, so the template's innerHTML is re-parsed.
function readSceneRoot(entryHtml: string | undefined): Element | null {
if (!entryHtml) return null;
const { document } = parseHTML(entryHtml);
const template = document.querySelector("template");
const scope = template ? parseHTML(template.innerHTML).document : document;
return scope.querySelector("[data-composition-id]") as Element | null;
return entryHtml ? locateSceneRoot(entryHtml).root : null;
}

// The scene's own chain replaces the index's, which belongs to the whole project.
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/runtime/compositionLoader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,27 @@ describe("loadCompositions external hosts", () => {
).toBe(false);
});

it.each([
["a card template before the scene", `<template><div class="card">card</div></template>`, ""],
[
"two scene templates",
"",
`<template><div data-composition-id="other"><p>second scene</p></div></template>`,
],
])("mounts the scene template of a fetched file with %s", async (_label, before, after) => {
const host = appendExternalHost("https://example.com/comp.html", "scene-1");
const scene = `<template><div data-composition-id="scene-1"><p>Hello World</p></div></template>`;
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(`${before}${scene}${after}`, { status: 200 }),
);

await loadFixture();

expect(host.textContent).toContain("Hello World");
expect(host.textContent).not.toContain("card");
expect(host.textContent).not.toContain("second scene");
});

it("sizes a flattened inner root from a px-suffixed or fractional size", async () => {
const host = appendExternalHost("https://example.com/comp.html", "scene-1");
const compositionHtml = `
Expand Down
8 changes: 7 additions & 1 deletion packages/core/src/runtime/compositionLoader.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { findSceneTemplate } from "@hyperframes/parsers/sub-composition-validity";
import { wallClearTimeout, wallSetTimeout } from "./wallClock";
import {
SVG_REFERENCE_ALIASES_ATTR,
Expand Down Expand Up @@ -186,6 +187,11 @@ function rewriteSubCompositionAssetPaths(root: ParentNode, compositionUrl: URL |
}
}

function sceneOrFirstTemplate(doc: Document): HTMLTemplateElement | null {
const templates = Array.from(doc.querySelectorAll<HTMLTemplateElement>("template"));
return findSceneTemplate(templates) ?? templates[0] ?? null;
}

function uniqueCompositionId(baseId: string, index: number): string {
return `${baseId}__hf${index}`;
}
Expand Down Expand Up @@ -801,7 +807,7 @@ async function mountExternalCompositions(
? doc.querySelector<HTMLTemplateElement>(
`template#${CSS.escape(authoredCompositionId)}-template`,
)
: null) ?? doc.querySelector<HTMLTemplateElement>("template");
: null) ?? sceneOrFirstTemplate(doc);
const sourceNode = template ? template.content : doc.body;
return await mount({
host,
Expand Down
26 changes: 26 additions & 0 deletions packages/lint/src/rules/composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,32 @@ import { lintHyperframeHtml } from "../hyperframeLinter.js";
import { parseCompositionVariables } from "@hyperframes/parsers/composition";

describe("composition rules", () => {
describe("multiple_scene_templates", () => {
const scene = (id: string) =>
`<div data-composition-id="${id}" data-width="320" data-height="180"></div>`;
const find = async (html: string) =>
(await lintHyperframeHtml(html, { isSubComposition: true })).findings.find(
(f) => f.code === "multiple_scene_templates",
);

it("warns, naming each scene template", async () => {
const finding = await find(
`<template id="intro">${scene("a")}</template><template>${scene("b")}</template>`,
);
expect(finding?.severity).toBe("warning");
expect(finding?.message).toContain('template #1 (id="intro") holding composition "a"');
expect(finding?.message).toContain('template #2 holding composition "b"');
});

it.each([
`<template>${scene("a")}</template>`,
`<template><svg></svg></template><template>${scene("a")}</template>`,
`<template>${scene("a")}</template><template>${scene("b")}</template>${scene("live")}`,
])("is quiet for %s", async (html) => {
expect(await find(html)).toBeUndefined();
});
});

describe("canonical timing contract", () => {
it("rejects deprecated attributes even when canonical attributes are also present", async () => {
const result = await lintHyperframeHtml(`<!doctype html><html><body>
Expand Down
27 changes: 27 additions & 0 deletions packages/lint/src/rules/composition.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { LintContext, HyperframeLintFinding, ExtractedBlock, OpenTag } from "../context";
import selectorParser from "postcss-selector-parser";
import { parseHTML } from "linkedom";
import {
findHtmlTag,
extractTimelineRegistryKeys,
Expand Down Expand Up @@ -1946,6 +1947,32 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
snippet: truncateSnippet(style.content.slice(0, 120)),
}));
},

// multiple_scene_templates: the renderer takes the first scene template of a file with no live root.
({ rawSource }) => {
const { document } = parseHTML(rawSource);
if (document.querySelector("[data-composition-id]")) return [];
const scenes = Array.from(document.querySelectorAll("template")).flatMap((template, index) => {
const compositionId = parseHTML(template.innerHTML)
.document.querySelector("[data-composition-id]")
?.getAttribute("data-composition-id");
if (compositionId === undefined) return [];
const id = template.getAttribute("id");
return [
`template #${index + 1}${id ? ` (id="${id}")` : ""} holding composition "${compositionId}"`,
];
});
if (scenes.length < 2) return [];
return [
{
code: "multiple_scene_templates",
severity: "warning",
message: `This file has more than one scene template and no live composition root, so only the first renders: ${scenes.join("; ")}.`,
fixHint:
"Keep one composition root per file, or move the other scenes into their own files.",
},
];
},
];

const CLIP_MEDIA_TAGS = new Set<string>(["img", "video", "audio"]);
Expand Down
16 changes: 15 additions & 1 deletion packages/parsers/src/subCompositionValidity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,25 @@ export interface ParsableDocumentLike {
documentElement: { outerHTML?: string } | null;
body?: { innerHTML?: string | null } | null;
querySelector(selector: string): { innerHTML?: string | null } | null;
querySelectorAll(selector: string): ArrayLike<{ innerHTML?: string | null } & SceneTemplateLike>;
}

export interface SceneTemplateLike {
content: { querySelector(selector: string): unknown };
}

export function findSceneTemplate<T extends SceneTemplateLike>(
templates: ArrayLike<T>,
): T | undefined {
return Array.from(templates).find(
(template) => template.content.querySelector("[data-composition-id]") != null,
);
}

/** Resolve authored content without invoking linkedom's synthetic body getter. */
export function resolveSubCompositionContent(html: string, document: ParsableDocumentLike) {
const template = document.querySelector("template");
const templates = Array.from(document.querySelectorAll("template"));
const template = findSceneTemplate(templates) ?? templates[0];
if (template) return { contentHtml: template.innerHTML ?? "", hasTemplate: true };

const body = document.querySelector("body");
Expand Down
Loading
Loading