chore: import upstream snapshot with attribution
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "@hyperframes/lint",
|
||||
"version": "0.7.55",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/heygen-com/hyperframes",
|
||||
"directory": "packages/lint"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"bun": "./src/index.ts",
|
||||
"node": "./dist/index.js",
|
||||
"import": "./src/index.ts",
|
||||
"types": "./src/index.ts"
|
||||
},
|
||||
"./browser": {
|
||||
"browser": "./src/browser.ts",
|
||||
"bun": "./src/browser.ts",
|
||||
"import": "./src/browser.ts",
|
||||
"types": "./src/browser.ts"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./browser": {
|
||||
"import": "./dist/browser.js",
|
||||
"types": "./dist/browser.d.ts"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepublishOnly": "echo skip"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hyperframes/parsers": "workspace:*",
|
||||
"htmlparser2": "^10.1.0",
|
||||
"linkedom": "^0.18.12",
|
||||
"postcss": "^8.5.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.10",
|
||||
"tsup": "^8.0.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { lintHyperframeHtml, shouldBlockRender } from "./browser.js";
|
||||
|
||||
// Guards that @hyperframes/lint/browser exposes a working, node-free rule engine.
|
||||
// (The platform:"browser" tsup build is the compile-time node-free guarantee;
|
||||
// this verifies the API actually runs.)
|
||||
describe("@hyperframes/lint/browser", () => {
|
||||
it("lints an HTML string with no filesystem access", async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, { filePath: "index.html" });
|
||||
expect(typeof result.ok).toBe("boolean");
|
||||
expect(Array.isArray(result.findings)).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes the pure shouldBlockRender gate", () => {
|
||||
expect(shouldBlockRender(true, false, 1, 0)).toBe(true);
|
||||
expect(shouldBlockRender(true, false, 0, 3)).toBe(false);
|
||||
expect(shouldBlockRender(false, true, 0, 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Browser-safe entry for @hyperframes/lint.
|
||||
*
|
||||
* Exposes the composition rule engine — HTML-string in, findings out — with
|
||||
* **zero Node.js dependencies**: no `node:fs`, no filesystem, no server. This
|
||||
* lets browser-only editors and tools validate compositions entirely
|
||||
* client-side, before any network call.
|
||||
*
|
||||
* The Node-only project layer (`lintProject`, which walks a directory) is NOT
|
||||
* exported here — import it from the main `@hyperframes/lint` entry in Node.
|
||||
*/
|
||||
export type {
|
||||
HyperframeLintSeverity,
|
||||
HyperframeLintFinding,
|
||||
HyperframeLintResult,
|
||||
HyperframeLinterOptions,
|
||||
} from "./types.js";
|
||||
export { lintHyperframeHtml, lintMediaUrls } from "./hyperframeLinter.js";
|
||||
export { shouldBlockRender } from "./shouldBlockRender.js";
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { HyperframeLintFinding, HyperframeLinterOptions } from "./types";
|
||||
import {
|
||||
parseHtmlStructure,
|
||||
findRootTag,
|
||||
collectCompositionIds,
|
||||
readAttr,
|
||||
stripHtmlComments,
|
||||
} from "./utils";
|
||||
import type { OpenTag, ExtractedBlock } from "./utils";
|
||||
|
||||
export type { OpenTag, ExtractedBlock };
|
||||
|
||||
export type LintContext = {
|
||||
source: string;
|
||||
rawSource: string;
|
||||
tags: OpenTag[];
|
||||
styles: ExtractedBlock[];
|
||||
scripts: ExtractedBlock[];
|
||||
compositionIds: Set<string>;
|
||||
rootTag: OpenTag | null;
|
||||
rootCompositionId: string | null;
|
||||
options: HyperframeLinterOptions;
|
||||
};
|
||||
|
||||
// Re-export for convenience so rule modules only need one import for the finding type
|
||||
export type { HyperframeLintFinding };
|
||||
|
||||
export function buildLintContext(html: string, options: HyperframeLinterOptions = {}): LintContext {
|
||||
const rawSource = html || "";
|
||||
// Strip HTML comments before scanning so a commented-out <template> or tag can't
|
||||
// hijack the boundary match below. Linear + fixpoint (see stripHtmlComments) to
|
||||
// stay ReDoS-free and catch markers that re-form when a comment is removed.
|
||||
let source = stripHtmlComments(rawSource);
|
||||
const initialStructure = parseHtmlStructure(source);
|
||||
const templateTags = initialStructure.tags.filter(
|
||||
(tag) => tag.name === "template" && tag.closeIndex != null,
|
||||
);
|
||||
let sourceWithoutTemplates = source;
|
||||
for (const template of [...templateTags].reverse()) {
|
||||
const end = template.endIndex ?? template.index;
|
||||
sourceWithoutTemplates =
|
||||
sourceWithoutTemplates.slice(0, template.index) +
|
||||
" ".repeat(end - template.index) +
|
||||
sourceWithoutTemplates.slice(end);
|
||||
}
|
||||
// Some sub-composition files are HTML shells whose real root lives inside a
|
||||
// <template>. Keep nested templates intact when the visible document already
|
||||
// has a composition root; only unwrap when no root exists outside templates.
|
||||
const template = templateTags[0];
|
||||
let structure = initialStructure;
|
||||
if (template && !findRootTag(sourceWithoutTemplates)) {
|
||||
source = source.slice(template.index + template.raw.length, template.closeIndex);
|
||||
structure = parseHtmlStructure(source);
|
||||
}
|
||||
|
||||
const tags = structure.tags;
|
||||
const styles = [
|
||||
...structure.styles,
|
||||
...(options.externalStyles ?? []).map((style) => ({
|
||||
attrs: `href="${style.href}"`,
|
||||
content: style.content,
|
||||
raw: style.content,
|
||||
index: -1,
|
||||
})),
|
||||
];
|
||||
const scripts = structure.scripts;
|
||||
const compositionIds = collectCompositionIds(tags);
|
||||
const rootTag = findRootTag(source, tags);
|
||||
const rootCompositionId = readAttr(rootTag?.raw || "", "data-composition-id");
|
||||
|
||||
return {
|
||||
source,
|
||||
rawSource,
|
||||
tags,
|
||||
styles,
|
||||
scripts,
|
||||
compositionIds,
|
||||
rootTag,
|
||||
rootCompositionId,
|
||||
options,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { lintHyperframeHtml, lintMediaUrls } from "./hyperframeLinter.js";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("lintHyperframeHtml — orchestrator", () => {
|
||||
const validComposition = `
|
||||
<html>
|
||||
<body>
|
||||
<div id="root" data-composition-id="comp-1" data-width="1920" data-height="1080" data-start="0">
|
||||
<div id="stage"></div>
|
||||
</div>
|
||||
<script src="https://cdn.gsap.com/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
|
||||
window.__timelines["comp-1"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
it("reports no errors for a valid composition", async () => {
|
||||
const result = await lintHyperframeHtml(validComposition);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.errorCount).toBe(0);
|
||||
});
|
||||
|
||||
it("attaches filePath to findings when option is set", async () => {
|
||||
const html = "<html><body><div></div></body></html>";
|
||||
const result = await lintHyperframeHtml(html, { filePath: "test.html" });
|
||||
for (const finding of result.findings) {
|
||||
expect(finding.file).toBe("test.html");
|
||||
}
|
||||
});
|
||||
|
||||
it("deduplicates identical findings", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root"></div>
|
||||
<script>const tl = gsap.timeline();</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const codes = result.findings.map((f) => `${f.code}|${f.message}`);
|
||||
const uniqueCodes = [...new Set(codes)];
|
||||
expect(codes.length).toBe(uniqueCodes.length);
|
||||
});
|
||||
|
||||
it("strips <template> wrapper before linting composition files", async () => {
|
||||
const html = `<template id="my-comp-template">
|
||||
<div data-composition-id="my-comp" data-width="1920" data-height="1080"
|
||||
style="position:relative;width:1920px;height:1080px;">
|
||||
<div id="stage"></div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
|
||||
window.__timelines["my-comp"] = tl;
|
||||
</script>
|
||||
</template>`;
|
||||
const result = await lintHyperframeHtml(html, { filePath: "compositions/my-comp.html" });
|
||||
const missing = result.findings.filter(
|
||||
(f) => f.code === "missing-composition-id" || f.code === "missing-dimensions",
|
||||
);
|
||||
expect(missing).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores comments that mention template tags before the real template", async () => {
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<!-- Authoring note: styles and scripts live inside <template>. -->
|
||||
</head>
|
||||
<body>
|
||||
<template id="my-comp-template">
|
||||
<style>#root { width: 1920px; height: 1080px; }</style>
|
||||
<div data-composition-id="my-comp" data-width="1920" data-height="1080">
|
||||
<div id="stage"></div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
|
||||
window.__timelines["my-comp"] = tl;
|
||||
</script>
|
||||
</template>
|
||||
</body>
|
||||
</html>`;
|
||||
const result = await lintHyperframeHtml(html, { filePath: "compositions/my-comp.html" });
|
||||
const rootFindings = result.findings.filter(
|
||||
(f) => f.code === "root_missing_composition_id" || f.code === "root_missing_dimensions",
|
||||
);
|
||||
expect(rootFindings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("strips comments whose markers re-form after one pass (no decoy template survives)", async () => {
|
||||
// Adjacent comment markers: removing the inner `<!-- -->` in a single pass
|
||||
// re-joins `<` + `!-- … -->` into a fresh, complete `<!-- … -->` that a lone
|
||||
// global replace leaves behind — surfacing a decoy <template> with no
|
||||
// composition-id. A fixpoint strip removes it; this guards that behavior.
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<<!-- -->!-- <template id="decoy-template"></template> -->
|
||||
<template id="my-comp-template">
|
||||
<style>#root { width: 1920px; height: 1080px; }</style>
|
||||
<div data-composition-id="my-comp" data-width="1920" data-height="1080">
|
||||
<div id="stage"></div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
|
||||
window.__timelines["my-comp"] = tl;
|
||||
</script>
|
||||
</template>
|
||||
</body>
|
||||
</html>`;
|
||||
const result = await lintHyperframeHtml(html, { filePath: "compositions/my-comp.html" });
|
||||
const rootFindings = result.findings.filter(
|
||||
(f) => f.code === "root_missing_composition_id" || f.code === "root_missing_dimensions",
|
||||
);
|
||||
expect(rootFindings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lintMediaUrls", () => {
|
||||
it("checks top-level remote media elements", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await lintMediaUrls('<img id="hero" src="https://example.com/hero.png">');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://example.com/hero.png",
|
||||
expect.objectContaining({ method: "HEAD" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores remote media embedded inside an iframe srcdoc attribute", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await lintMediaUrls(`<iframe srcdoc='<img src="https://example.com/embedded.png">'></iframe>`);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { HyperframeLintFinding, HyperframeLintResult, HyperframeLinterOptions } from "./types";
|
||||
import { buildLintContext } from "./context";
|
||||
import { parseHtmlStructure, readAttr, truncateSnippet } from "./utils";
|
||||
import { coreRules } from "./rules/core";
|
||||
import { mediaRules } from "./rules/media";
|
||||
import { gsapRules } from "./rules/gsap";
|
||||
import { captionRules } from "./rules/captions";
|
||||
import { compositionRules } from "./rules/composition";
|
||||
import { adapterRules } from "./rules/adapters";
|
||||
import { textureRules } from "./rules/textures";
|
||||
import { fontRules } from "./rules/fonts";
|
||||
import { slideshowRules } from "./rules/slideshow";
|
||||
|
||||
const ALL_RULES = [
|
||||
...coreRules,
|
||||
...mediaRules,
|
||||
...gsapRules,
|
||||
...captionRules,
|
||||
...compositionRules,
|
||||
...adapterRules,
|
||||
...textureRules,
|
||||
...fontRules,
|
||||
...slideshowRules,
|
||||
];
|
||||
|
||||
export async function lintHyperframeHtml(
|
||||
html: string,
|
||||
options: HyperframeLinterOptions = {},
|
||||
): Promise<HyperframeLintResult> {
|
||||
const ctx = buildLintContext(html, options);
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const rule of ALL_RULES) {
|
||||
for (const finding of await Promise.resolve(rule(ctx))) {
|
||||
const dedupeKey = [
|
||||
finding.code,
|
||||
finding.severity,
|
||||
finding.selector || "",
|
||||
finding.elementId || "",
|
||||
finding.message,
|
||||
].join("|");
|
||||
if (seen.has(dedupeKey)) continue;
|
||||
seen.add(dedupeKey);
|
||||
findings.push(options.filePath ? { ...finding, file: options.filePath } : finding);
|
||||
}
|
||||
}
|
||||
|
||||
const errorCount = findings.filter((f) => f.severity === "error").length;
|
||||
const warningCount = findings.filter((f) => f.severity === "warning").length;
|
||||
const infoCount = findings.filter((f) => f.severity === "info").length;
|
||||
|
||||
return {
|
||||
ok: errorCount === 0,
|
||||
errorCount,
|
||||
warningCount,
|
||||
infoCount,
|
||||
findings,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Async media URL accessibility checker ─────────────────────────────────
|
||||
|
||||
function extractMediaUrls(html: string): Array<{
|
||||
url: string;
|
||||
tagName: string;
|
||||
elementId?: string;
|
||||
snippet: string;
|
||||
}> {
|
||||
const results: Array<{
|
||||
url: string;
|
||||
tagName: string;
|
||||
elementId?: string;
|
||||
snippet: string;
|
||||
}> = [];
|
||||
for (const { name: tagName, raw } of parseHtmlStructure(html).tags) {
|
||||
if (!/^(?:video|audio|img|source)$/.test(tagName)) continue;
|
||||
const src = readAttr(raw, "src");
|
||||
if (!src) continue;
|
||||
if (/^https?:\/\//i.test(src)) {
|
||||
results.push({
|
||||
url: src,
|
||||
tagName,
|
||||
elementId: readAttr(raw, "id") || undefined,
|
||||
snippet: truncateSnippet(raw) ?? "",
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async lint pass: HEAD-checks every remote media URL in the HTML.
|
||||
* Returns findings for URLs that are unreachable (non-2xx status or network error).
|
||||
*
|
||||
* Call this after `lintHyperframeHtml()` and merge the findings.
|
||||
*
|
||||
* @param timeoutMs - per-request timeout (default 8000ms)
|
||||
*/
|
||||
export async function lintMediaUrls(
|
||||
html: string,
|
||||
options: { timeoutMs?: number } = {},
|
||||
): Promise<HyperframeLintFinding[]> {
|
||||
const urls = extractMediaUrls(html);
|
||||
if (urls.length === 0) return [];
|
||||
|
||||
const timeout = options.timeoutMs ?? 8000;
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const unique = urls.filter((u) => {
|
||||
if (seen.has(u.url)) return false;
|
||||
seen.add(u.url);
|
||||
return true;
|
||||
});
|
||||
|
||||
const checks = unique.map(async ({ url, tagName, elementId, snippet }) => {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
const resp = await fetch(url, {
|
||||
method: "HEAD",
|
||||
signal: controller.signal,
|
||||
redirect: "follow",
|
||||
});
|
||||
clearTimeout(timer);
|
||||
if (!resp.ok) {
|
||||
findings.push({
|
||||
code: "inaccessible_media_url",
|
||||
severity: "error",
|
||||
message: `<${tagName}${elementId ? ` id="${elementId}"` : ""}> references a URL that returned HTTP ${resp.status}: ${url.slice(0, 100)}`,
|
||||
elementId,
|
||||
fixHint: "This URL is not accessible. Replace with a valid, reachable media URL.",
|
||||
snippet,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.name : "unknown";
|
||||
findings.push({
|
||||
code: "inaccessible_media_url",
|
||||
severity: "error",
|
||||
message: `<${tagName}${elementId ? ` id="${elementId}"` : ""}> references an unreachable URL (${reason}): ${url.slice(0, 100)}`,
|
||||
elementId,
|
||||
fixHint: "This URL is not accessible. Replace with a valid, reachable media URL.",
|
||||
snippet,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(checks);
|
||||
return findings;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
HyperframeLintSeverity,
|
||||
HyperframeLintFinding,
|
||||
HyperframeLintResult,
|
||||
HyperframeLinterOptions,
|
||||
} from "./types.js";
|
||||
export { lintHyperframeHtml, lintMediaUrls } from "./hyperframeLinter.js";
|
||||
export { lintProject, shouldBlockRender } from "./project.js";
|
||||
export type { ProjectLintResult } from "./project.js";
|
||||
@@ -0,0 +1,237 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { HyperframeLintFinding } from "./types.js";
|
||||
import { lintProject } from "./project.js";
|
||||
|
||||
function tmpProject(name: string): string {
|
||||
return mkdtempSync(join(tmpdir(), `hf-lint-test-${name}-`));
|
||||
}
|
||||
|
||||
function validHtml(compId = "main"): string {
|
||||
return `<html><body>
|
||||
<div data-composition-id="${compId}" data-width="1920" data-height="1080" data-start="0" data-duration="10"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["${compId}"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
let dirs: string[] = [];
|
||||
|
||||
function makeProject(indexHtml: string, subComps?: Record<string, string>): string {
|
||||
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;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const d of dirs) {
|
||||
rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
dirs = [];
|
||||
});
|
||||
|
||||
describe("missing_or_empty_sub_composition", () => {
|
||||
function htmlWithSubComp(srcPath: string): string {
|
||||
return `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10">
|
||||
<div data-composition-src="${srcPath}" data-composition-id="scene-title" data-start="0" data-duration="5"></div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
function validSubCompHtml(): string {
|
||||
return `<!doctype html><html><body>
|
||||
<div data-composition-id="scene-title" data-width="1920" data-height="1080">
|
||||
<div class="title">Hello</div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
// Shared assertion: lint a project referencing "compositions/scene-title.html"
|
||||
// (or a custom srcPath) and return the missing_or_empty_sub_composition
|
||||
// finding, if any, plus the raw lint result for callers that need totalErrors.
|
||||
async function lintSubComp(
|
||||
srcPath: string,
|
||||
subCompFiles?: Record<string, string>,
|
||||
): Promise<{ finding: HyperframeLintFinding | undefined; totalErrors: number }> {
|
||||
const project = makeProject(htmlWithSubComp(srcPath), subCompFiles);
|
||||
const { totalErrors, results } = await lintProject(project);
|
||||
const finding = results
|
||||
.flatMap((r) => r.result.findings)
|
||||
.find((f) => f.code === "missing_or_empty_sub_composition");
|
||||
return { finding, totalErrors };
|
||||
}
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: "empty",
|
||||
content: "",
|
||||
expectMessageContains: "empty",
|
||||
},
|
||||
{
|
||||
label: "whitespace-only",
|
||||
content: " \n\t ",
|
||||
expectMessageContains: "empty",
|
||||
},
|
||||
{
|
||||
label: "malformed / non-HTML",
|
||||
content: "just some plain text, no tags at all",
|
||||
expectMessageContains: "could not be parsed",
|
||||
},
|
||||
])(
|
||||
"errors when the referenced sub-composition file is $label",
|
||||
async ({ content, expectMessageContains }) => {
|
||||
const { finding, totalErrors } = await lintSubComp("compositions/scene-title.html", {
|
||||
"scene-title.html": content,
|
||||
});
|
||||
|
||||
expect(totalErrors).toBeGreaterThan(0);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain(expectMessageContains);
|
||||
},
|
||||
);
|
||||
|
||||
it("errors when the referenced sub-composition file does not exist", async () => {
|
||||
// No subComps passed — compositions/ directory doesn't even exist.
|
||||
const { finding, totalErrors } = await lintSubComp("compositions/does-not-exist.html");
|
||||
|
||||
expect(totalErrors).toBeGreaterThan(0);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toContain("compositions/does-not-exist.html");
|
||||
expect(finding?.message).toContain("does not exist");
|
||||
});
|
||||
|
||||
it("errors when the referenced sub-composition file has content but no data-composition-id root", async () => {
|
||||
const { finding, totalErrors } = await lintSubComp("compositions/scene-title.html", {
|
||||
"scene-title.html": "<!doctype html><html><body><p>TODO: scene content</p></body></html>",
|
||||
});
|
||||
|
||||
expect(totalErrors).toBeGreaterThan(0);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toContain("data-composition-id");
|
||||
});
|
||||
|
||||
it("does not error when the referenced sub-composition file is valid (happy path)", async () => {
|
||||
const { finding } = await lintSubComp("compositions/scene-title.html", {
|
||||
"scene-title.html": validSubCompHtml(),
|
||||
});
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not error on a project with no data-composition-src references", async () => {
|
||||
const project = makeProject(validHtml());
|
||||
const { results } = await lintProject(project);
|
||||
const finding = results
|
||||
.flatMap((r) => r.result.findings)
|
||||
.find((f) => f.code === "missing_or_empty_sub_composition");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("dedupes a single bad reference into one finding even if repeated", async () => {
|
||||
const html = `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10">
|
||||
<div data-composition-src="compositions/scene-title.html" data-composition-id="a" data-start="0" data-duration="5"></div>
|
||||
<div data-composition-src="compositions/scene-title.html" data-composition-id="b" data-start="5" data-duration="5"></div>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const project = makeProject(html, { "scene-title.html": "" });
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
|
||||
const findings = results
|
||||
.flatMap((r) => r.result.findings)
|
||||
.filter((f) => f.code === "missing_or_empty_sub_composition");
|
||||
expect(findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Regression: lint used to raw-filesystem-walk every .html under
|
||||
// compositions/, regardless of whether the root composition actually
|
||||
// references it. render's pre-flight (assertSubCompositionsUsable) only
|
||||
// follows real data-composition-src references starting from the root, so
|
||||
// an orphaned file with its own dangling reference made `lint`/`validate`
|
||||
// fail even though `render` succeeds fine on the same project.
|
||||
it("does not error on an orphaned, unreferenced file under compositions/ with a dangling reference inside it", async () => {
|
||||
const project = makeProject(validHtml(), {});
|
||||
const archivedDir = join(project, "compositions", "archived");
|
||||
mkdirSync(archivedDir, { recursive: true });
|
||||
// Never referenced from index.html — this file is unreachable.
|
||||
writeFileSync(
|
||||
join(archivedDir, "old-draft.html"),
|
||||
`<!doctype html><html><body>
|
||||
<div data-composition-id="old-draft" data-width="1920" data-height="1080">
|
||||
<div data-composition-src="compositions/does-not-exist.html" data-composition-id="ghost"></div>
|
||||
</div>
|
||||
</body></html>`,
|
||||
);
|
||||
|
||||
const { results, totalErrors } = await lintProject(project);
|
||||
const finding = results
|
||||
.flatMap((r) => r.result.findings)
|
||||
.find((f) => f.code === "missing_or_empty_sub_composition");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
expect(totalErrors).toBe(0);
|
||||
});
|
||||
|
||||
it("still errors when a broken reference IS reachable from the root (nested, not just top-level)", async () => {
|
||||
const project = makeProject(htmlWithSubComp("compositions/parent.html"));
|
||||
mkdirSync(join(project, "compositions"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(project, "compositions", "parent.html"),
|
||||
`<!doctype html><html><body>
|
||||
<div data-composition-id="scene-title" data-width="1920" data-height="1080">
|
||||
<div data-composition-src="compositions/does-not-exist.html" data-composition-id="child"></div>
|
||||
</div>
|
||||
</body></html>`,
|
||||
);
|
||||
|
||||
const { results, totalErrors } = await lintProject(project);
|
||||
const finding = results
|
||||
.flatMap((r) => r.result.findings)
|
||||
.find((f) => f.code === "missing_or_empty_sub_composition");
|
||||
|
||||
expect(totalErrors).toBeGreaterThan(0);
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toContain("compositions/does-not-exist.html");
|
||||
});
|
||||
});
|
||||
|
||||
describe("template shell style sources", () => {
|
||||
it("collects links, style blocks, and inline styles from template content", async () => {
|
||||
const project = makeProject(`<html><body>
|
||||
<div id="scene" data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10"></div>
|
||||
<template data-composition-id="shell">
|
||||
<link rel="stylesheet" href="shell.css">
|
||||
<style>[data-composition-id="main"] .title { opacity: 0; }</style>
|
||||
<div style="mask-image: url(missing-inline-mask.png)"></div>
|
||||
<template><style>[data-composition-id="main"] .nested { opacity: 0; }</style></template>
|
||||
</template>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`);
|
||||
writeFileSync(
|
||||
join(project, "shell.css"),
|
||||
'[data-composition-id="main"] .from-link { opacity: 0; }',
|
||||
);
|
||||
|
||||
const { results } = await lintProject(project);
|
||||
const findings = results.flatMap((entry) => entry.result.findings);
|
||||
expect(
|
||||
findings.filter((finding) => finding.code === "composition_self_attribute_selector"),
|
||||
).toHaveLength(3);
|
||||
expect(findings.some((finding) => finding.code === "texture_mask_asset_not_found")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,642 @@
|
||||
export { shouldBlockRender } from "./shouldBlockRender.js";
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import { dirname, extname, isAbsolute, join, posix, relative, resolve } from "node:path";
|
||||
import { decodeUrlPathVariants } from "@hyperframes/parsers/composition";
|
||||
import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths";
|
||||
import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { lintHyperframeHtml } from "./hyperframeLinter.js";
|
||||
import type { HyperframeLintFinding, HyperframeLintResult } from "./types.js";
|
||||
import type { ParsableDocumentLike } from "@hyperframes/parsers/sub-composition-validity";
|
||||
|
||||
/** Adapts linkedom's `parseHTML` to the `checkSubCompositionUsability` contract. */
|
||||
function parseSubCompHtml(html: string): ParsableDocumentLike {
|
||||
return parseHTML(html).document as unknown as ParsableDocumentLike;
|
||||
}
|
||||
|
||||
interface HtmlSource {
|
||||
html: string;
|
||||
compSrcPath?: string;
|
||||
}
|
||||
|
||||
interface CssSource {
|
||||
content: string;
|
||||
rootRelativePath?: string;
|
||||
}
|
||||
|
||||
/** Linkedom keeps template contents in a DocumentFragment that is not part of
|
||||
* the document query tree. Lint rules must still see shell styles and links
|
||||
* inside templates, so walk each template's content recursively without
|
||||
* falling back to regex parsing. */
|
||||
function querySelectorAllIncludingTemplates(root: ParentNode, selector: string): Element[] {
|
||||
const matches: Element[] = [...root.querySelectorAll(selector)];
|
||||
for (const template of root.querySelectorAll("template")) {
|
||||
const content = (template as HTMLTemplateElement).content;
|
||||
if (content) matches.push(...querySelectorAllIncludingTemplates(content, selector));
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
export interface ProjectLintResult {
|
||||
results: Array<{ file: string; result: HyperframeLintResult }>;
|
||||
totalErrors: number;
|
||||
totalWarnings: number;
|
||||
totalInfos: number;
|
||||
}
|
||||
|
||||
const AUDIO_EXTENSIONS = new Set([".mp3", ".wav", ".aac", ".ogg", ".m4a", ".flac", ".opus"]);
|
||||
const MASK_IMAGE_URL_RE =
|
||||
/\b(?:-webkit-)?mask-image\s*:\s*[^;{}]*url\(\s*(?:"([^"]+)"|'([^']+)'|([^"')\s]+))\s*\)/gi;
|
||||
|
||||
function isLocalStylesheetHref(href: string): boolean {
|
||||
return !!href && !/^(https?:|data:|blob:|\/\/)/i.test(href);
|
||||
}
|
||||
|
||||
function collectLocalStylesheets(
|
||||
projectDir: string,
|
||||
document: ParentNode,
|
||||
compSrcPath?: string,
|
||||
): Array<{ href: string; content: string; rootRelativePath: string }> {
|
||||
const styles: Array<{ href: string; content: string; rootRelativePath: string }> = [];
|
||||
for (const link of querySelectorAllIncludingTemplates(document, "link")) {
|
||||
const rel = link.getAttribute("rel") ?? "";
|
||||
if (!rel.split(/\s+/).some((part) => part.toLowerCase() === "stylesheet")) continue;
|
||||
const href = link.getAttribute("href") ?? "";
|
||||
if (!isLocalStylesheetHref(href)) continue;
|
||||
const rootRelative = compSrcPath ? join(dirname(compSrcPath), href) : href;
|
||||
const stylesheet = resolveExistingLocalAsset(projectDir, rootRelative);
|
||||
if (!stylesheet) continue;
|
||||
styles.push({
|
||||
href,
|
||||
content: readFileSync(stylesheet.resolved, "utf-8"),
|
||||
rootRelativePath: stylesheet.rootRelativePath,
|
||||
});
|
||||
}
|
||||
return styles;
|
||||
}
|
||||
|
||||
function collectExternalStyles(
|
||||
projectDir: string,
|
||||
html: string,
|
||||
compSrcPath?: string,
|
||||
): Array<{ href: string; content: string }> {
|
||||
const styles: Array<{ href: string; content: string }> = [];
|
||||
const { document } = parseHTML(html);
|
||||
for (const { href, content } of collectLocalStylesheets(projectDir, document, compSrcPath)) {
|
||||
styles.push({ href, content });
|
||||
}
|
||||
return styles;
|
||||
}
|
||||
|
||||
function collectCssSources(projectDir: string, html: string, compSrcPath?: string): CssSource[] {
|
||||
const sources: CssSource[] = [];
|
||||
const { document } = parseHTML(html);
|
||||
|
||||
for (const style of querySelectorAllIncludingTemplates(document, "style")) {
|
||||
sources.push({ content: style.textContent ?? "" });
|
||||
}
|
||||
|
||||
for (const { content, rootRelativePath } of collectLocalStylesheets(
|
||||
projectDir,
|
||||
document,
|
||||
compSrcPath,
|
||||
)) {
|
||||
sources.push({ content, rootRelativePath });
|
||||
}
|
||||
|
||||
for (const element of querySelectorAllIncludingTemplates(document, "[style]")) {
|
||||
const style = element.getAttribute("style");
|
||||
if (!style) continue;
|
||||
sources.push({ content: style });
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
function isRemoteOrInlineUrl(url: string): boolean {
|
||||
return /^(https?:|data:|blob:|\/\/|#)/i.test(url);
|
||||
}
|
||||
|
||||
function cleanAssetUrl(url: string): string {
|
||||
return url.trim().split(/[?#]/, 1)[0] ?? "";
|
||||
}
|
||||
|
||||
function isWithinProjectRoot(projectDir: string, candidate: string): boolean {
|
||||
const projectRoot = resolve(projectDir);
|
||||
const relativePath = relative(projectRoot, candidate);
|
||||
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
||||
}
|
||||
|
||||
function addCandidate(candidates: string[], candidate: string): void {
|
||||
if (!candidates.includes(candidate)) candidates.push(candidate);
|
||||
}
|
||||
|
||||
function resolveLocalAssetCandidates(projectDir: string, url: string): string[] {
|
||||
const cleanUrl = cleanAssetUrl(url);
|
||||
const projectRoot = resolve(projectDir);
|
||||
const candidates: string[] = [];
|
||||
|
||||
for (const variant of decodeUrlPathVariants(cleanUrl)) {
|
||||
const projectRelative = variant.startsWith("/") ? variant.slice(1) : variant;
|
||||
const resolved = resolve(projectRoot, projectRelative);
|
||||
if (isWithinProjectRoot(projectRoot, resolved)) {
|
||||
addCandidate(candidates, resolved);
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalized = posix.normalize(projectRelative.replace(/\\/g, "/"));
|
||||
const clamped = normalized.replace(/^(\.\.\/)+/, "");
|
||||
if (clamped && !clamped.startsWith("..")) {
|
||||
addCandidate(candidates, resolve(projectRoot, clamped));
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function resolveExistingLocalAsset(
|
||||
projectDir: string,
|
||||
url: string,
|
||||
): { resolved: string; rootRelativePath: string } | null {
|
||||
const projectRoot = resolve(projectDir);
|
||||
const resolved = resolveLocalAssetCandidates(projectRoot, url).find(existsSync);
|
||||
if (!resolved) return null;
|
||||
return { resolved, rootRelativePath: relative(projectRoot, resolved) };
|
||||
}
|
||||
|
||||
function resolveCssAssetCandidates(
|
||||
projectDir: string,
|
||||
url: string,
|
||||
htmlCompSrcPath?: string,
|
||||
cssRootRelativePath?: string,
|
||||
): string[] {
|
||||
if (url.startsWith("/")) return resolveLocalAssetCandidates(projectDir, url);
|
||||
if (cssRootRelativePath) {
|
||||
return resolveLocalAssetCandidates(projectDir, join(dirname(cssRootRelativePath), url));
|
||||
}
|
||||
if (htmlCompSrcPath) {
|
||||
return resolveLocalAssetCandidates(projectDir, rewriteAssetPath(htmlCompSrcPath, url));
|
||||
}
|
||||
return resolveLocalAssetCandidates(projectDir, url);
|
||||
}
|
||||
|
||||
export async function lintProject(
|
||||
projectDir: string,
|
||||
entryFile?: string,
|
||||
): Promise<ProjectLintResult> {
|
||||
const indexPath = entryFile ? resolve(entryFile) : resolve(projectDir, "index.html");
|
||||
if (entryFile && !isWithinProjectRoot(projectDir, indexPath)) {
|
||||
throw new Error(`Explicit lint entry is outside the project directory: ${entryFile}`);
|
||||
}
|
||||
const rootFile = relative(resolve(projectDir), indexPath).replace(/\\/g, "/") || "index.html";
|
||||
const rootCompSrcPath = rootFile === "index.html" ? undefined : rootFile;
|
||||
const results: Array<{ file: string; result: HyperframeLintResult }> = [];
|
||||
let totalErrors = 0;
|
||||
let totalWarnings = 0;
|
||||
let totalInfos = 0;
|
||||
|
||||
const rootHtml = readFileSync(indexPath, "utf-8");
|
||||
const rootResult = await lintHyperframeHtml(rootHtml, {
|
||||
filePath: indexPath,
|
||||
externalStyles: collectExternalStyles(projectDir, rootHtml, rootCompSrcPath),
|
||||
});
|
||||
results.push({ file: rootFile, result: rootResult });
|
||||
totalErrors += rootResult.errorCount;
|
||||
totalWarnings += rootResult.warningCount;
|
||||
totalInfos += rootResult.infoCount;
|
||||
|
||||
const allHtmlSources: HtmlSource[] = [{ html: rootHtml, compSrcPath: rootCompSrcPath }];
|
||||
const compositionsDir = resolve(projectDir, "compositions");
|
||||
if (!entryFile && existsSync(compositionsDir)) {
|
||||
const collectHtmlFiles = (dir: string, rel: string): string[] => {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) out.push(...collectHtmlFiles(join(dir, entry.name), relPath));
|
||||
else if (entry.isFile() && entry.name.endsWith(".html") && !entry.name.startsWith("._")) {
|
||||
out.push(relPath);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const files = collectHtmlFiles(compositionsDir, "").sort();
|
||||
for (const file of files) {
|
||||
const filePath = join(compositionsDir, file);
|
||||
const html = readFileSync(filePath, "utf-8");
|
||||
const compSrcPath = `compositions/${file}`;
|
||||
allHtmlSources.push({ html, compSrcPath });
|
||||
// Mountable fragments (figma component imports, registry snippets) are
|
||||
// not standalone compositions — composition-root rules don't apply.
|
||||
// Anchored to the file's ROOT element so a real composition that merely
|
||||
// inlines snippet markup (or mentions the token in text) is still linted.
|
||||
if (isSnippetFragment(html)) continue;
|
||||
const result = await lintHyperframeHtml(html, {
|
||||
filePath,
|
||||
isSubComposition: true,
|
||||
externalStyles: collectExternalStyles(projectDir, html, compSrcPath),
|
||||
});
|
||||
results.push({ file: `compositions/${file}`, result });
|
||||
totalErrors += result.errorCount;
|
||||
totalWarnings += result.warningCount;
|
||||
totalInfos += result.infoCount;
|
||||
}
|
||||
}
|
||||
|
||||
const projectFindings = [
|
||||
...lintProjectAudioFiles(projectDir, allHtmlSources),
|
||||
...lintAudioSrcNotFound(projectDir, allHtmlSources),
|
||||
...lintMissingLocalAsset(projectDir, allHtmlSources),
|
||||
...lintTextureMaskAssetNotFound(projectDir, allHtmlSources),
|
||||
...(!entryFile ? lintMultipleRootCompositions(projectDir) : []),
|
||||
...lintDuplicateAudioTracks(allHtmlSources),
|
||||
...lintMissingOrEmptySubComposition(projectDir, rootHtml),
|
||||
];
|
||||
if (projectFindings.length > 0) {
|
||||
for (const finding of projectFindings) {
|
||||
rootResult.findings.push(finding);
|
||||
if (finding.severity === "error") {
|
||||
rootResult.errorCount++;
|
||||
rootResult.ok = false;
|
||||
totalErrors++;
|
||||
} else if (finding.severity === "warning") {
|
||||
rootResult.warningCount++;
|
||||
totalWarnings++;
|
||||
} else {
|
||||
rootResult.infoCount++;
|
||||
totalInfos++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { results, totalErrors, totalWarnings, totalInfos };
|
||||
}
|
||||
|
||||
function lintProjectAudioFiles(
|
||||
projectDir: string,
|
||||
htmlSources: HtmlSource[],
|
||||
): HyperframeLintFinding[] {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
|
||||
let audioFiles: string[];
|
||||
try {
|
||||
audioFiles = readdirSync(projectDir).filter((f) =>
|
||||
AUDIO_EXTENSIONS.has(extname(f).toLowerCase()),
|
||||
);
|
||||
} catch {
|
||||
return findings;
|
||||
}
|
||||
|
||||
if (audioFiles.length === 0) return findings;
|
||||
|
||||
const hasAudioElement = htmlSources.some(({ html }) => /<audio\b/i.test(html));
|
||||
|
||||
if (!hasAudioElement) {
|
||||
findings.push({
|
||||
code: "audio_file_without_element",
|
||||
severity: "warning",
|
||||
message: `Found audio file(s) in project (${audioFiles.join(", ")}) but no <audio> element in any composition. The rendered video will be silent.`,
|
||||
fixHint:
|
||||
'Add an <audio id="my-audio" src="' +
|
||||
audioFiles[0] +
|
||||
'" data-start="0" data-duration="__DURATION__" data-track-index="0" data-volume="1"></audio> element inside the composition root. Replace __DURATION__ with the audio length in seconds.',
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
function lintAudioSrcNotFound(
|
||||
projectDir: string,
|
||||
htmlSources: HtmlSource[],
|
||||
): HyperframeLintFinding[] {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
|
||||
const audioSrcRe = /<audio\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
||||
|
||||
const missingSrcs: string[] = [];
|
||||
for (const { html, compSrcPath } of htmlSources) {
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = audioSrcRe.exec(html)) !== null) {
|
||||
const src = match[1]!;
|
||||
if (/^(https?:|data:|blob:)/i.test(src)) continue;
|
||||
if (/^__[A-Z_]+__$/.test(src)) continue;
|
||||
const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
|
||||
if (!resolveLocalAssetCandidates(projectDir, rootRelative).some(existsSync)) {
|
||||
missingSrcs.push(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missingSrcs.length > 0) {
|
||||
const unique = [...new Set(missingSrcs)];
|
||||
findings.push({
|
||||
code: "audio_src_not_found",
|
||||
severity: "error",
|
||||
message: `<audio> element references file(s) not found in the project: ${unique.join(", ")}. The rendered video will be silent.`,
|
||||
fixHint:
|
||||
unique.length === 1
|
||||
? `Add the file "${unique[0]}" to the project directory, or update the src attribute to point to an existing file.`
|
||||
: `Add the missing files to the project directory, or update the src attributes to point to existing files.`,
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
function maskRange(src: string, pattern: RegExp): string {
|
||||
return src.replace(pattern, (m) => " ".repeat(m.length));
|
||||
}
|
||||
|
||||
function maskNonScannableRanges(html: string): string {
|
||||
let out = maskRange(html, /<!--[\s\S]*?-->/g);
|
||||
out = maskRange(out, /<style\b[^>]*>[\s\S]*?<\/style\b[^>]*>/gi);
|
||||
out = maskRange(out, /<script\b[^>]*>[\s\S]*?<\/script\b[^>]*>/gi);
|
||||
return out;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function lintMissingLocalAsset(
|
||||
projectDir: string,
|
||||
htmlSources: HtmlSource[],
|
||||
): HyperframeLintFinding[] {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
|
||||
const localAssetSrcRe = /<(video|img|source)\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
||||
|
||||
const missingByTag = new Map<string, Map<string, string>>();
|
||||
|
||||
for (const { html, compSrcPath } of htmlSources) {
|
||||
const scannable = maskNonScannableRanges(html);
|
||||
const re = new RegExp(localAssetSrcRe.source, localAssetSrcRe.flags);
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(scannable)) !== null) {
|
||||
const tagName = (match[1] ?? "").toLowerCase();
|
||||
const rawSrc = match[2] ?? "";
|
||||
const src = cleanAssetUrl(rawSrc);
|
||||
if (!src) continue;
|
||||
if (isRemoteOrInlineUrl(src)) continue;
|
||||
if (/^__[A-Z_]+__$/.test(src)) continue;
|
||||
const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
|
||||
const resolvedAsset = resolveExistingLocalAsset(projectDir, rootRelative);
|
||||
if (resolvedAsset) continue;
|
||||
|
||||
const resolvedKey = resolve(projectDir, rootRelative);
|
||||
let bucket = missingByTag.get(tagName);
|
||||
if (!bucket) {
|
||||
bucket = new Map<string, string>();
|
||||
missingByTag.set(tagName, bucket);
|
||||
}
|
||||
if (!bucket.has(resolvedKey)) bucket.set(resolvedKey, src);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [tagName, byResolved] of missingByTag) {
|
||||
const unique = [...byResolved.values()];
|
||||
findings.push({
|
||||
code: "missing_local_asset",
|
||||
severity: "error",
|
||||
message:
|
||||
`<${tagName}> element references local file(s) not found in the project: ${unique.join(", ")}. ` +
|
||||
"The renderer will silently skip these and produce a video with missing visuals.",
|
||||
fixHint:
|
||||
unique.length === 1
|
||||
? `Add "${unique[0]}" to the project directory, or update the src attribute to point to an existing file. ` +
|
||||
"Common cause: captured asset filenames are unreliable (heygen-logo.svg often contains Google, nvidia-logo.svg may contain Autodesk, etc.). " +
|
||||
"Open the contact sheets and verify the file actually exists at this path before referencing it."
|
||||
: "Add the missing files to the project directory, or update the src attributes to point to existing files. " +
|
||||
"Captured asset filenames are unreliable — verify against capture/contact-sheets/ and capture/extracted/asset-descriptions.md.",
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
function lintTextureMaskAssetNotFound(
|
||||
projectDir: string,
|
||||
htmlSources: HtmlSource[],
|
||||
): HyperframeLintFinding[] {
|
||||
const missing = new Map<string, string>();
|
||||
|
||||
for (const { html, compSrcPath } of htmlSources) {
|
||||
for (const cssSource of collectCssSources(projectDir, html, compSrcPath)) {
|
||||
let match: RegExpExecArray | null;
|
||||
const pattern = new RegExp(MASK_IMAGE_URL_RE.source, MASK_IMAGE_URL_RE.flags);
|
||||
while ((match = pattern.exec(cssSource.content)) !== null) {
|
||||
const rawUrl = match[1] ?? match[2] ?? match[3] ?? "";
|
||||
const url = cleanAssetUrl(rawUrl);
|
||||
if (!url || isRemoteOrInlineUrl(url)) continue;
|
||||
if (/^__[A-Z_]+__$/.test(url)) continue;
|
||||
|
||||
const candidates = resolveCssAssetCandidates(
|
||||
projectDir,
|
||||
url,
|
||||
compSrcPath,
|
||||
cssSource.rootRelativePath,
|
||||
);
|
||||
if (candidates.some(existsSync)) continue;
|
||||
missing.set(url, candidates[0] ?? resolve(projectDir, url));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.size === 0) return [];
|
||||
const urls = [...missing.keys()];
|
||||
return [
|
||||
{
|
||||
code: "texture_mask_asset_not_found",
|
||||
severity: "error",
|
||||
message: `CSS mask-image references file(s) not found in the project: ${urls.join(", ")}.`,
|
||||
fixHint:
|
||||
urls.length === 1
|
||||
? `Add "${urls[0]}" to the project, or update the mask-image URL to point to an existing texture mask.`
|
||||
: "Add the missing texture mask files to the project, or update the mask-image URLs to point to existing files.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function lintMultipleRootCompositions(projectDir: string): HyperframeLintFinding[] {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
try {
|
||||
const rootHtmlFiles = readdirSync(projectDir).filter(
|
||||
(file) => file.endsWith(".html") && !file.startsWith("._"),
|
||||
);
|
||||
const rootCompositions: string[] = [];
|
||||
for (const file of rootHtmlFiles) {
|
||||
if (file === "caption-skin.html") continue;
|
||||
const content = readFileSync(join(projectDir, file), "utf-8");
|
||||
if (/data-composition-id/i.test(content)) {
|
||||
rootCompositions.push(file);
|
||||
}
|
||||
}
|
||||
if (rootCompositions.length > 1) {
|
||||
findings.push({
|
||||
code: "multiple_root_compositions",
|
||||
severity: "error",
|
||||
message: `Multiple root-level HTML files with data-composition-id: ${rootCompositions.join(", ")}. The runtime may discover both as entry points, causing duplicate audio playback.`,
|
||||
fixHint:
|
||||
"A project should have exactly one root index.html with data-composition-id. Remove or rename extra files.",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* directory read failed — skip */
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
function lintDuplicateAudioTracks(htmlSources: HtmlSource[]): HyperframeLintFinding[] {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
function extractAttr(tag: string, name: string): string | null {
|
||||
const re = new RegExp(`\\b${name}\\s*=\\s*["']([^"']+)["']`, "i");
|
||||
const m = tag.match(re);
|
||||
return m?.[1] ?? null;
|
||||
}
|
||||
|
||||
const tracks: Array<{ trackIndex: number; start: number; end: number; src: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const { html } of htmlSources) {
|
||||
const audioTagRe = /<audio\b[^>]*>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = audioTagRe.exec(html)) !== null) {
|
||||
const tag = match[0];
|
||||
const trackStr = extractAttr(tag, "data-track-index");
|
||||
const startStr = extractAttr(tag, "data-start");
|
||||
const durStr = extractAttr(tag, "data-duration");
|
||||
const src = extractAttr(tag, "src") ?? "unknown";
|
||||
if (!trackStr || !startStr) continue;
|
||||
|
||||
const trackIndex = parseInt(trackStr, 10);
|
||||
const start = parseFloat(startStr);
|
||||
const duration = durStr ? parseFloat(durStr) : Infinity;
|
||||
const key = `${src}:${start}:${duration}:${trackIndex}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
tracks.push({ trackIndex, start, end: start + duration, src });
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < tracks.length; i++) {
|
||||
for (let j = i + 1; j < tracks.length; j++) {
|
||||
const a = tracks[i]!;
|
||||
const b = tracks[j]!;
|
||||
if (a.trackIndex !== b.trackIndex) continue;
|
||||
if (a.start < b.end && b.start < a.end) {
|
||||
findings.push({
|
||||
code: "duplicate_audio_track",
|
||||
severity: "warning",
|
||||
message: `Multiple <audio> elements on track ${a.trackIndex} overlap (${a.src} at ${a.start}-${Number.isFinite(a.end) ? a.end.toFixed(1) : "end"}s, ${b.src} at ${b.start}-${Number.isFinite(b.end) ? b.end.toFixed(1) : "end"}s). This causes layered audio playback.`,
|
||||
fixHint: "Use non-overlapping time windows or different track indices.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error if a `data-composition-src` reference points at a file that is
|
||||
* missing, empty, or does not parse to usable HTML. This is the #1 render
|
||||
* failure bucket in production telemetry: a scene-authoring step (an AI
|
||||
* agent, most commonly) writes the reference before — or without ever —
|
||||
* writing valid content into the scene file.
|
||||
*
|
||||
* The render pre-flight check (`assertSubCompositionsUsable` in
|
||||
* `packages/producer/src/services/htmlCompiler.ts`) now aborts the render
|
||||
* loudly and immediately when this happens, rather than silently dropping
|
||||
* the scene — so catching it here, before the render even starts, means the
|
||||
* failure surfaces at lint/validate time with the same message instead of
|
||||
* only at render time.
|
||||
*
|
||||
* Only follows files actually reachable via `data-composition-src` starting
|
||||
* from the root composition — mirroring the reachability semantics of
|
||||
* `assertSubCompositionsUsable`. A raw filesystem walk of every `.html`
|
||||
* under `compositions/` would flag orphaned/unreferenced files that the
|
||||
* renderer never visits, producing false-positive lint/validate failures on
|
||||
* projects that actually render fine. Lint, render, and the inliner must
|
||||
* never disagree about whether a given file would actually render
|
||||
* something.
|
||||
*/
|
||||
function lintMissingOrEmptySubComposition(
|
||||
projectDir: string,
|
||||
rootHtml: string,
|
||||
): HyperframeLintFinding[] {
|
||||
// Dedup by src path — the same reference can appear from nested sub-comps.
|
||||
const checked = new Map<string, { srcPath: string; problem: string }>();
|
||||
const visited = new Set<string>();
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const walk = (html: string): void => {
|
||||
const compositionSrcRe = /<[^>]*\bdata-composition-src\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
||||
const scannable = maskNonScannableRanges(html);
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = compositionSrcRe.exec(scannable)) !== null) {
|
||||
const srcPath = (match[1] ?? "").trim();
|
||||
if (!srcPath) continue;
|
||||
if (/^__[A-Z_]+__$/.test(srcPath)) continue; // template placeholder
|
||||
|
||||
// data-composition-src is always written root-relative (even from a
|
||||
// nested sub-composition) — matches the resolution the renderer uses
|
||||
// in packages/producer/src/services/htmlCompiler.ts (parseSubCompositions
|
||||
// / assertSubCompositionsUsable).
|
||||
const filePath = resolve(projectDir, srcPath);
|
||||
|
||||
// Circular reference guard — same as assertSubCompositionsUsable.
|
||||
// Already-visited files were already checked (or are mid-walk); skip
|
||||
// re-checking/re-recursing but still let a later distinct reference to
|
||||
// the same broken file surface (checked is keyed by srcPath, not filePath).
|
||||
if (visited.has(filePath)) continue;
|
||||
visited.add(filePath);
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
if (!checked.has(srcPath)) {
|
||||
checked.set(srcPath, { srcPath, problem: "the file does not exist" });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileHtml = readFileSync(filePath, "utf-8");
|
||||
const validity = checkSubCompositionUsability(fileHtml, parseSubCompHtml);
|
||||
if (!validity.ok) {
|
||||
if (!checked.has(srcPath)) {
|
||||
checked.set(srcPath, {
|
||||
srcPath,
|
||||
problem: validity.detail ?? "the file is empty or could not be parsed",
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Usable — recurse into it so nested references are validated too,
|
||||
// but only because this file is itself reachable from the root.
|
||||
walk(fileHtml);
|
||||
}
|
||||
};
|
||||
|
||||
walk(rootHtml);
|
||||
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const { srcPath, problem } of checked.values()) {
|
||||
findings.push({
|
||||
code: "missing_or_empty_sub_composition",
|
||||
severity: "error",
|
||||
message: `data-composition-src references "${srcPath}", but ${problem}.`,
|
||||
fixHint:
|
||||
`Fix this before rendering — the render pre-flight rejects unusable sub-compositions. ` +
|
||||
`Write valid HTML into "${srcPath}" — it needs a <template> or <body> containing an element with ` +
|
||||
`data-composition-id, data-width, and data-height. Preview/studio still tolerates and skips the ` +
|
||||
"scene while you author it. If a scene-authoring step is still running, wait for it to finish " +
|
||||
"before referencing the file, or re-run the step that generates it.",
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
/** True when the file's first element carries data-hf-snippet — i.e. the file
|
||||
* IS a mountable fragment, not a composition that merely contains one. */
|
||||
function isSnippetFragment(html: string): boolean {
|
||||
const firstTag = html.match(/<[a-zA-Z][^>]*>/);
|
||||
if (!firstTag) return false;
|
||||
return /\bdata-hf-snippet\b/.test(firstTag[0]);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { lintHyperframeHtml } from "../hyperframeLinter.js";
|
||||
|
||||
describe("adapter rules", () => {
|
||||
it("reports error when GSAP is used without a GSAP script tag", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { x: 100, duration: 1 }, 0);
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("GSAP");
|
||||
});
|
||||
|
||||
it("does not report missing_gsap_script when GSAP CDN script is present", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#box", { x: 100, duration: 1 }, 0);
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "missing_gsap_script");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when Lottie container exists without a Lottie script tag", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="lottie-player" data-lottie-src="animation.json"></div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "missing_lottie_script");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("Lottie");
|
||||
});
|
||||
|
||||
it("reports error when lottie.loadAnimation is used without a Lottie script tag", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = gsap.timeline({ paused: true });
|
||||
lottie.loadAnimation({ container: document.getElementById('lottie'), path: 'anim.json' });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "missing_lottie_script");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("does not report missing_lottie_script when Lottie CDN script is present", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="lottie-player" data-lottie-src="animation.json"></div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/lottie-web@5/build/player/lottie.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "missing_lottie_script");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when Three.js is used without a Three.js script tag", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = gsap.timeline({ paused: true });
|
||||
const scene = new THREE.Scene();
|
||||
const renderer = new THREE.WebGLRenderer();
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "missing_three_script");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("Three.js");
|
||||
});
|
||||
|
||||
it("does not report missing_three_script when Three.js CDN script is present", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.160/build/three.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = gsap.timeline({ paused: true });
|
||||
const scene = new THREE.Scene();
|
||||
const renderer = new THREE.WebGLRenderer();
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "missing_three_script");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not report missing_three_script for an ESM +esm CDN import (jsdelivr)", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script type="module">
|
||||
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160/+esm';
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = gsap.timeline({ paused: true });
|
||||
const scene = new THREE.Scene();
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "missing_three_script");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not report missing_three_script for an esm.sh/three import", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script type="module">
|
||||
import * as THREE from 'https://esm.sh/three';
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = gsap.timeline({ paused: true });
|
||||
const scene = new THREE.Scene();
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "missing_three_script");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not report missing_three_script for a local three.module.js import", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script type="module">
|
||||
import { Scene } from './vendor/three.module.js';
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = gsap.timeline({ paused: true });
|
||||
const scene = new Scene();
|
||||
THREE.foo();
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "missing_three_script");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not report missing_three_script for a bare 'three' import (regression)", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = gsap.timeline({ paused: true });
|
||||
const scene = new THREE.Scene();
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "missing_three_script");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still reports missing_three_script when THREE is used with no three loaded", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080"></div>
|
||||
<script type="module">
|
||||
import { gsap } from 'https://cdn.jsdelivr.net/npm/gsap@3/+esm';
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = gsap.timeline({ paused: true });
|
||||
const scene = new THREE.Scene();
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "missing_three_script");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("does not report any adapter errors for composition with no adapter usage", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div id="content">Hello World</div>
|
||||
</div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
window.__timelines["main"] = { totalDuration: function() { return 3; } };
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const adapterFindings = result.findings.filter((f) =>
|
||||
["missing_gsap_script", "missing_lottie_script", "missing_three_script"].includes(f.code),
|
||||
);
|
||||
expect(adapterFindings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { LintContext, HyperframeLintFinding } from "../context";
|
||||
import { readAttr, extractScriptTextsAndSrcs } from "../utils";
|
||||
|
||||
export const adapterRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
// missing_lottie_script
|
||||
({ tags, scripts }) => {
|
||||
const { texts, srcs } = extractScriptTextsAndSrcs(scripts);
|
||||
|
||||
const hasLottieAttr = tags.some((t) => readAttr(t.raw, "data-lottie-src") !== null);
|
||||
const usesLottieApi = texts.some((t) =>
|
||||
/lottie\.(loadAnimation|setSpeed|play|stop|destroy)\b/.test(t),
|
||||
);
|
||||
const hasLottieScript = srcs.some((src) => /lottie/i.test(src));
|
||||
|
||||
if (!(hasLottieAttr || usesLottieApi) || hasLottieScript) return [];
|
||||
return [
|
||||
{
|
||||
code: "missing_lottie_script",
|
||||
severity: "error",
|
||||
message:
|
||||
"Composition uses Lottie but no Lottie script is loaded. The animation will not render.",
|
||||
fixHint:
|
||||
'Add <script src="https://cdn.jsdelivr.net/npm/lottie-web@5/build/player/lottie.min.js"></script> before your Lottie code.',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// missing_three_script
|
||||
({ scripts }) => {
|
||||
const { texts, srcs } = extractScriptTextsAndSrcs(scripts);
|
||||
|
||||
const usesThree = texts.some((t) => /\bTHREE\./.test(t));
|
||||
const hasThreeScript = srcs.some((src) => /three/i.test(src));
|
||||
const hasThreeImportMap = texts.some(
|
||||
(t) =>
|
||||
/["']three["']/.test(t) &&
|
||||
/importmap/.test(scripts.find((s) => s.content === t)?.attrs || ""),
|
||||
);
|
||||
// Matches any import/from whose specifier contains "three" (bare 'three', or a
|
||||
// URL/path like .../+esm, esm.sh/three, three.module.js), mirroring the loose
|
||||
// /three/i treatment of <script src>.
|
||||
const hasThreeModuleImport = texts.some((t) =>
|
||||
/\b(?:import|from)\s*[^;\n]*['"][^'"]*three[^'"]*['"]/i.test(t),
|
||||
);
|
||||
|
||||
if (!usesThree || hasThreeScript || hasThreeImportMap || hasThreeModuleImport) return [];
|
||||
return [
|
||||
{
|
||||
code: "missing_three_script",
|
||||
severity: "error",
|
||||
message:
|
||||
"Composition uses Three.js but no Three.js script is loaded. The 3D scene will not render.",
|
||||
fixHint:
|
||||
'Add <script src="https://cdn.jsdelivr.net/npm/three@0.160/build/three.min.js"></script> before your Three.js code.',
|
||||
},
|
||||
];
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { lintHyperframeHtml } from "../hyperframeLinter.js";
|
||||
|
||||
describe("caption rules", () => {
|
||||
it("warns when caption exit has no hard kill tl.set", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="captions" data-width="1920" data-height="1080">
|
||||
<div id="caption-container"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
GROUPS.forEach(function(group, gi) {
|
||||
var groupEl = document.createElement("div");
|
||||
groupEl.id = "cg-" + gi;
|
||||
tl.set(groupEl, { opacity: 1 }, group.start);
|
||||
tl.to(groupEl, { opacity: 0, duration: 0.12 }, group.end - 0.12);
|
||||
});
|
||||
window.__timelines["captions"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("does not warn when caption exit has hard kill tl.set", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="captions" data-width="1920" data-height="1080">
|
||||
<div id="caption-container"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
GROUPS.forEach(function(group, gi) {
|
||||
var groupEl = document.createElement("div");
|
||||
groupEl.id = "cg-" + gi;
|
||||
tl.set(groupEl, { opacity: 1 }, group.start);
|
||||
tl.to(groupEl, { opacity: 0, duration: 0.12 }, group.end - 0.12);
|
||||
tl.set(groupEl, { opacity: 0, visibility: "hidden" }, group.end);
|
||||
});
|
||||
window.__timelines["captions"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not warn for generic GSAP opacity exits in non-caption loops", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
var sceneCaption = document.querySelector("#scene-caption");
|
||||
CARDS.forEach(function(group, gi) {
|
||||
var groupEl = document.createElement("div");
|
||||
groupEl.id = "card-" + gi;
|
||||
tl.to(groupEl, { opacity: 0, duration: 0.12 }, 2);
|
||||
});
|
||||
window.__timelines["main"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not warn on a content frame that only mentions karaoke in a comment", async () => {
|
||||
const html = `<template id="06-one-platform-template">
|
||||
<div id="root" data-composition-id="06-one-platform" data-width="1920" data-height="1080">
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
// "Minutes, not weeks" lands with a karaoke-style keyword glow
|
||||
SCREENS.forEach(function (s, i) {
|
||||
var el = document.getElementById("screen-" + i);
|
||||
tl.to(el, { y: -40, opacity: 0, duration: 0.3 }, i * 1.3);
|
||||
});
|
||||
window.__timelines["06-one-platform"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</template>`;
|
||||
const result = await lintHyperframeHtml(html, { isSubComposition: true });
|
||||
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("warns when caption group has nowrap without max-width", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="captions" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
.caption-group {
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["captions"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "caption_text_overflow_risk");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
});
|
||||
|
||||
it("does not warn when caption group has nowrap with max-width", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="captions" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
.caption-group {
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
max-width: 1600px;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["captions"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find(
|
||||
(f) => f.code === "caption_text_overflow_risk" && f.severity === "warning",
|
||||
);
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("warns when caption container uses position: relative", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="captions" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
.caption-group {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
var tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["captions"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "caption_container_relative_position");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
import type { LintContext, HyperframeLintFinding } from "../context";
|
||||
|
||||
/** Extract a bracket-balanced array literal starting at the `[` found by `varMatch`. */
|
||||
// fallow-ignore-next-line complexity
|
||||
function extractArrayLiteral(src: string, varMatch: RegExpExecArray): string | null {
|
||||
const openIdx = varMatch.index + varMatch[0].length - 1;
|
||||
let depth = 0;
|
||||
let inStr = false;
|
||||
let strChar = "";
|
||||
for (let i = openIdx; i < src.length; i++) {
|
||||
const c = src[i]!;
|
||||
if (inStr) {
|
||||
if (c === "\\") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === strChar) inStr = false;
|
||||
} else if (c === '"' || c === "'") {
|
||||
inStr = true;
|
||||
strChar = c;
|
||||
} else if (c === "[") {
|
||||
depth++;
|
||||
} else if (c === "]") {
|
||||
depth--;
|
||||
if (depth === 0) return src.slice(openIdx, i + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
// caption_exit_missing_hard_kill
|
||||
({ scripts, styles, options, rootCompositionId }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
// Only the ACTUAL captions composition. A content frame that merely mentions
|
||||
// "karaoke" / "caption-*" in a comment (or uses an unrelated forEach + opacity:0
|
||||
// screen-swap) is NOT captions — gating here prevents the false positive that fired
|
||||
// on a content frame whose only caption signal was a descriptive comment.
|
||||
const isCaptionComposition =
|
||||
Boolean(options.filePath && /caption/i.test(options.filePath)) ||
|
||||
rootCompositionId === "captions" ||
|
||||
styles.some((s) => /\.caption[-_]?(?:group|word|line|block)\b|\.cg-/.test(s.content));
|
||||
if (!isCaptionComposition) return findings;
|
||||
for (const script of scripts) {
|
||||
const content = script.content;
|
||||
const hasExitTween = /\.to\s*\([^,]+,\s*\{[^}]*opacity\s*:\s*0/.test(content);
|
||||
const hasHardKill =
|
||||
/\.set\s*\([^,]+,\s*\{[^}]*(?:visibility\s*:\s*["']hidden["']|opacity\s*:\s*0)/.test(
|
||||
content,
|
||||
);
|
||||
const hasCaptionLoop =
|
||||
/forEach|\.forEach\s*\(/.test(content) &&
|
||||
/karaoke|caption[-_]?(?:group|word|line|block)|cg-/.test(content);
|
||||
if (hasCaptionLoop && hasExitTween && !hasHardKill) {
|
||||
findings.push({
|
||||
code: "caption_exit_missing_hard_kill",
|
||||
severity: "error",
|
||||
message:
|
||||
"Caption exit animations (tl.to with opacity: 0) detected without a hard tl.set kill. " +
|
||||
"Exit tweens can fail when karaoke word-level tweens conflict, leaving captions stuck on screen.",
|
||||
fixHint:
|
||||
'Add `tl.set(groupEl, { opacity: 0, visibility: "hidden" }, group.end)` after every ' +
|
||||
"exit tl.to animation as a deterministic kill.",
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// caption_text_overflow_risk
|
||||
({ styles }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const style of styles) {
|
||||
const captionBlocks = style.content.matchAll(
|
||||
/(\.caption[-_]?(?:group|container|text|line|word)|#caption[-_]?container)\s*\{([^}]+)\}/gi,
|
||||
);
|
||||
for (const [, selector, body] of captionBlocks) {
|
||||
if (!body) continue;
|
||||
const hasNowrap = /white-space\s*:\s*nowrap/i.test(body);
|
||||
const hasMaxWidth = /max-width/i.test(body);
|
||||
if (hasNowrap && !hasMaxWidth) {
|
||||
findings.push({
|
||||
code: "caption_text_overflow_risk",
|
||||
severity: "warning",
|
||||
selector: (selector ?? "").trim(),
|
||||
message: `Caption selector "${(selector ?? "").trim()}" has white-space: nowrap but no max-width. Long phrases will clip off-screen.`,
|
||||
fixHint:
|
||||
"Add max-width: 1600px (landscape) or max-width: 900px (portrait) and overflow: hidden.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// caption_transcript_not_inline
|
||||
// fallow-ignore-next-line complexity
|
||||
({ scripts, styles, options }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
// Only check files that look like caption compositions
|
||||
const isCaptionFile =
|
||||
(options.filePath && /caption/i.test(options.filePath)) ||
|
||||
styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content));
|
||||
if (!isCaptionFile) return findings;
|
||||
|
||||
const allScript = scripts.map((s) => s.content).join("\n");
|
||||
const hasInlineTranscript = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*\[/.test(
|
||||
allScript,
|
||||
);
|
||||
const hasFetchTranscript = /fetch\s*\(\s*["'][^"']*transcript/i.test(allScript);
|
||||
|
||||
if (!hasInlineTranscript && hasFetchTranscript) {
|
||||
findings.push({
|
||||
code: "caption_transcript_not_inline",
|
||||
severity: "error",
|
||||
message:
|
||||
"Captions composition loads transcript via fetch(). The studio caption editor " +
|
||||
"requires an inline `var TRANSCRIPT = [...]` array to detect and edit captions.",
|
||||
fixHint:
|
||||
'Embed the transcript as `var TRANSCRIPT = [{ "text": "...", "start": 0, "end": 1 }, ...]` ' +
|
||||
"with JSON-quoted property keys. See the captions skill for details.",
|
||||
});
|
||||
}
|
||||
|
||||
if (hasInlineTranscript) {
|
||||
// Verify the inline transcript can be parsed.
|
||||
// Use a balanced-bracket scan instead of a regex to correctly handle
|
||||
// nested arrays (e.g. word-level timing arrays inside each entry).
|
||||
const varStart = /(?:const|let|var)\s+(?:TRANSCRIPT|script)\s*=\s*\[/.exec(allScript);
|
||||
const transcriptJson = varStart ? extractArrayLiteral(allScript, varStart) : null;
|
||||
if (transcriptJson) {
|
||||
try {
|
||||
JSON.parse(transcriptJson);
|
||||
} catch {
|
||||
findings.push({
|
||||
code: "caption_transcript_parse_error",
|
||||
severity: "error",
|
||||
message:
|
||||
"Inline TRANSCRIPT array is not valid JSON. The studio caption editor may fail " +
|
||||
"to parse it. Common cause: unquoted property keys with apostrophes in text.",
|
||||
fixHint:
|
||||
'Use JSON-quoted keys: { "text": "don\'t", "start": 0, "end": 1 } instead of ' +
|
||||
'{ text: "don\'t", start: 0, end: 1 }.',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
},
|
||||
|
||||
// caption_container_relative_position
|
||||
({ styles }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const style of styles) {
|
||||
const captionBlocks = style.content.matchAll(
|
||||
/(\.caption[-_]?(?:group|container|text|line)|#caption[-_]?container)\s*\{([^}]+)\}/gi,
|
||||
);
|
||||
for (const [, selector, body] of captionBlocks) {
|
||||
if (!body) continue;
|
||||
if (/position\s*:\s*relative/i.test(body)) {
|
||||
findings.push({
|
||||
code: "caption_container_relative_position",
|
||||
severity: "error",
|
||||
selector: (selector ?? "").trim(),
|
||||
message: `Caption selector "${(selector ?? "").trim()}" uses position: relative which causes overflow and breaks caption stacking.`,
|
||||
fixHint: "Use position: absolute for all caption elements.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// caption_overflow_clips_scaled_words
|
||||
({ styles, scripts }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const hasScaledWords = scripts.some(
|
||||
(s) => /scale\s*:\s*1\.[2-9]/.test(s.content) && /caption|word|cg-/.test(s.content),
|
||||
);
|
||||
if (!hasScaledWords) return findings;
|
||||
|
||||
for (const style of styles) {
|
||||
const captionBlocks = style.content.matchAll(
|
||||
/(\.caption[-_]?(?:group|container)|#caption[-_]?(?:layer|container))\s*\{([^}]+)\}/gi,
|
||||
);
|
||||
for (const [, selector, body] of captionBlocks) {
|
||||
if (!body) continue;
|
||||
if (/overflow\s*:\s*hidden/i.test(body)) {
|
||||
findings.push({
|
||||
code: "caption_overflow_clips_scaled_words",
|
||||
severity: "error",
|
||||
selector: (selector ?? "").trim(),
|
||||
message: `"${(selector ?? "").trim()}" has overflow: hidden but GSAP scales caption words above 1.0x. Scaled emphasis words and their glow effects will be clipped.`,
|
||||
fixHint:
|
||||
"Use overflow: visible on caption containers. Rely on fitTextFontSize with reduced maxWidth to prevent overflow instead.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// caption_textshadow_on_group_container
|
||||
({ scripts, styles }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const isCaptionFile = styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content));
|
||||
if (!isCaptionFile) return findings;
|
||||
|
||||
for (const script of scripts) {
|
||||
// Detect textShadow tweened on a group container (div with child word spans)
|
||||
const groupShadowPattern =
|
||||
/\.to\s*\(\s*(?:div|groupEl|el|captionEl|document\.getElementById\s*\(\s*["']cg-)\s*[^,]*,\s*\{[^}]*textShadow/g;
|
||||
// Also catch selector-based targeting of group containers
|
||||
const selectorShadowPattern =
|
||||
/\.to\s*\(\s*["'](?:#cg-\d+|\.caption[-_]?group)["']\s*,\s*\{[^}]*textShadow/g;
|
||||
if (groupShadowPattern.test(script.content) || selectorShadowPattern.test(script.content)) {
|
||||
findings.push({
|
||||
code: "caption_textshadow_on_group_container",
|
||||
severity: "warning",
|
||||
message:
|
||||
"textShadow is tweened on a caption group container. When children have semi-transparent " +
|
||||
"color (e.g., inactive karaoke words at rgba opacity), the glow renders as a visible " +
|
||||
"rectangle behind the entire group.",
|
||||
fixHint:
|
||||
"Apply textShadow to individual active word elements instead of the group container. " +
|
||||
"Use scale on the group for bass-reactive pulsing.",
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// caption_fittext_scale_mismatch
|
||||
// fallow-ignore-next-line complexity
|
||||
({ scripts }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const script of scripts) {
|
||||
const content = script.content;
|
||||
const fitTextMatch = content.match(/fitTextFontSize\s*\([^)]*maxWidth\s*:\s*(\d+)/);
|
||||
if (!fitTextMatch) continue;
|
||||
const maxWidth = parseInt(fitTextMatch[1] ?? "0", 10);
|
||||
if (!maxWidth) continue;
|
||||
|
||||
// Find max scale on caption words
|
||||
const scaleMatches = [...content.matchAll(/scale\s*:\s*(1\.\d+)/g)];
|
||||
const captionContext = /caption|word|cg-|karaoke/i.test(content);
|
||||
if (!captionContext || scaleMatches.length === 0) continue;
|
||||
|
||||
let maxScale = 1;
|
||||
for (const m of scaleMatches) {
|
||||
const val = parseFloat(m[1] ?? "1");
|
||||
if (val > maxScale) maxScale = val;
|
||||
}
|
||||
|
||||
// Check if maxWidth * maxScale exceeds safe bounds (1920 - reasonable margins)
|
||||
const effectiveWidth = maxWidth * maxScale;
|
||||
if (effectiveWidth > 1760) {
|
||||
findings.push({
|
||||
code: "caption_fittext_scale_mismatch",
|
||||
severity: "warning",
|
||||
message:
|
||||
`fitTextFontSize uses maxWidth: ${maxWidth}px but emphasis words scale up to ${maxScale}x. ` +
|
||||
`Effective width ${Math.round(effectiveWidth)}px may overflow the composition (1920px minus margins).`,
|
||||
fixHint: `Reduce maxWidth to ${Math.floor(1700 / maxScale)}px to leave headroom for scaled emphasis words.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,954 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { lintHyperframeHtml } from "../hyperframeLinter.js";
|
||||
|
||||
function compositionWithHead(headContent: string): string {
|
||||
return `
|
||||
<html>
|
||||
<head>
|
||||
${headContent}
|
||||
</head>
|
||||
<body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function compositionWithHeadBoundary(boundaryContent: string): string {
|
||||
return `
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
${boundaryContent}
|
||||
<body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function compositionWithBodyPrefix(prefixContent: string, rootContent = ""): string {
|
||||
return `
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${prefixContent}
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
${rootContent}
|
||||
</div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function compositionWithImplicitBodyPrefix(prefixContent: string): string {
|
||||
return `
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { margin: 0; }
|
||||
</style>
|
||||
</head>
|
||||
${prefixContent}
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function templateCompositionWithHead(headContent: string): string {
|
||||
return `
|
||||
<template>
|
||||
<html>
|
||||
<head>
|
||||
${headContent}
|
||||
</head>
|
||||
<body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
</body>
|
||||
</html>
|
||||
</template>`;
|
||||
}
|
||||
|
||||
describe("core rules", () => {
|
||||
it("does not lint scripts embedded inside an iframe srcdoc attribute", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="root" data-width="1280" data-height="720"></div>
|
||||
<iframe srcdoc="<script>const child = gsap.timeline({ paused: true }); child.to("#x", { opacity: 1 });</script>"></iframe>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const rootTl = gsap.timeline({ paused: true });
|
||||
window.__timelines["root"] = rootTl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
|
||||
const result = await lintHyperframeHtml(html);
|
||||
|
||||
expect(
|
||||
result.findings.find((finding) => finding.code === "invalid_inline_script_syntax"),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
result.findings.find((finding) => finding.code === "gsap_timeline_not_registered"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not lint elements embedded inside an iframe srcdoc attribute", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="root" data-width="1280" data-height="720"></div>
|
||||
<iframe srcdoc='<video src="child.mp4" data-start="0"></video>'></iframe>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`;
|
||||
|
||||
const result = await lintHyperframeHtml(html);
|
||||
|
||||
expect(
|
||||
result.findings.find(
|
||||
(finding) => finding.elementId === undefined && finding.message.includes("<video"),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("warns when an id starts with a digit and is unsafe in a hash selector", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="123-frame"></div>
|
||||
</div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`;
|
||||
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((item) => item.code === "id_requires_css_escape");
|
||||
|
||||
expect(finding?.severity).toBe("warning");
|
||||
expect(finding?.elementId).toBe("123-frame");
|
||||
expect(finding?.fixHint).toContain("CSS.escape");
|
||||
});
|
||||
|
||||
it("accepts ids that start with a letter", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="frame-123"></div>
|
||||
</div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`;
|
||||
|
||||
const result = await lintHyperframeHtml(html);
|
||||
|
||||
expect(result.findings.find((item) => item.code === "id_requires_css_escape")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when root is missing data-composition-id", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-width="1920" data-height="1080"></div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "root_missing_composition_id");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("reports error when root is missing data-width or data-height", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1"></div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "root_missing_dimensions");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("accepts body as the composition root", async () => {
|
||||
const html = `
|
||||
<html><body data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="overlay-flash"></div>
|
||||
<script>window.__timelines = window.__timelines || {};</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code === "root_missing_composition_id")).toBeUndefined();
|
||||
expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips a leading <svg> defs block when detecting the composition root", async () => {
|
||||
// Regression: two independent reports of a leading <svg><defs><filter>...
|
||||
// block (icon/gradient/filter plumbing referenced via url(#id) elsewhere)
|
||||
// getting mistaken for the composition root, since findRootTag returned
|
||||
// the first non-script/style/meta/link/title body child unconditionally.
|
||||
// The <svg> here carries no composition markers, so it must be skipped in
|
||||
// favor of the real root that follows it.
|
||||
const html = `
|
||||
<html><body>
|
||||
<svg width="0" height="0" style="position:absolute">
|
||||
<defs><filter id="glow"><feGaussianBlur stdDeviation="4" /></filter></defs>
|
||||
</svg>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script>window.__timelines = window.__timelines || {};</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code === "root_missing_composition_id")).toBeUndefined();
|
||||
expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still treats an <svg> as the root when it carries composition markers itself", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<svg id="root" data-composition-id="c1" data-width="1920" data-height="1080"></svg>
|
||||
<script>window.__timelines = window.__timelines || {};</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code === "root_missing_composition_id")).toBeUndefined();
|
||||
expect(result.findings.find((f) => f.code === "root_missing_dimensions")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when timeline registry is missing", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "missing_timeline_registry");
|
||||
expect(finding).toBeDefined();
|
||||
});
|
||||
|
||||
it("allows a timeline-free root that explicitly declares data-no-timeline", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-no-timeline data-width="1920" data-height="1080" data-duration="5"></div>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code === "missing_timeline_registry")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not flag missing_timeline_registry on a sub-composition (inherits from host)", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, { isSubComposition: true });
|
||||
const finding = result.findings.find((f) => f.code === "missing_timeline_registry");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error for composition host missing data-composition-id", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="host1" data-composition-src="child.html"></div>
|
||||
</div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "host_missing_composition_id");
|
||||
expect(finding).toBeDefined();
|
||||
});
|
||||
|
||||
it("reports error when timeline registry is assigned without initializing", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="stage"></div>
|
||||
</div>
|
||||
<script>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
|
||||
window.__timelines["c1"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("without initializing");
|
||||
});
|
||||
|
||||
it("reports error when dot timeline registry is assigned without initializing", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="stage"></div>
|
||||
</div>
|
||||
<script>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
|
||||
window.__timelines.c1 = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("does not flag timeline assignment when init guard is present", async () => {
|
||||
const validComposition = `
|
||||
<html>
|
||||
<body>
|
||||
<div id="root" data-composition-id="comp-1" data-width="1920" data-height="1080">
|
||||
<div id="stage"></div>
|
||||
</div>
|
||||
<script src="https://cdn.gsap.com/gsap.min.js"></script>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#stage", { opacity: 1, duration: 1 }, 0);
|
||||
window.__timelines["comp-1"] = tl;
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
const result = await lintHyperframeHtml(validComposition);
|
||||
const finding = result.findings.find((f) => f.code === "timeline_registry_missing_init");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when CSS text is left outside a style block in the document head", async () => {
|
||||
const html = compositionWithHead(`
|
||||
<style>
|
||||
body { margin: 0; }
|
||||
</style>
|
||||
</style>
|
||||
/* Decorative Elements */
|
||||
.particle {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("<head>");
|
||||
expect(finding?.snippet).toContain(".particle");
|
||||
});
|
||||
|
||||
it("reports error when CSS variables leak between head and body", async () => {
|
||||
const html = compositionWithHeadBoundary(`
|
||||
--bg-color: #F5F1E8;
|
||||
--text-color: #212121;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toContain("<head>");
|
||||
expect(finding?.snippet).toContain("body");
|
||||
});
|
||||
|
||||
it("reports error when stray close tags leak between head and body", async () => {
|
||||
const html = compositionWithHeadBoundary(`
|
||||
</style>
|
||||
</script>
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain("</style>");
|
||||
});
|
||||
|
||||
it("reports error when markdown code fences leak between head and body", async () => {
|
||||
const html = compositionWithHeadBoundary(`
|
||||
\`\`\`css
|
||||
.particle {
|
||||
color: white;
|
||||
}
|
||||
\`\`\`
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain("```css");
|
||||
});
|
||||
|
||||
it("reports error when CSS at-rules leak between head and body", async () => {
|
||||
const html = compositionWithHeadBoundary(`
|
||||
@media (min-width: 800px) {
|
||||
.particle {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain("@media");
|
||||
});
|
||||
|
||||
it("does not report leaked text for valid script and style blocks around the head boundary", async () => {
|
||||
const html = compositionWithHeadBoundary(`
|
||||
<script>
|
||||
window.__headReady = true;
|
||||
</script>
|
||||
<template>
|
||||
<style>
|
||||
.template-only { color: red; }
|
||||
</style>
|
||||
</template>
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when CSS text leaks before the composition root", async () => {
|
||||
const html = compositionWithBodyPrefix(`
|
||||
.orphan {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain(".orphan");
|
||||
});
|
||||
|
||||
it("reports error when CSS text leaks before the composition root without an explicit body", async () => {
|
||||
const html = compositionWithImplicitBodyPrefix(`
|
||||
.implicit-body-orphan {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain(".implicit-body-orphan");
|
||||
});
|
||||
|
||||
it("does not report leaked text for valid script and style blocks before the composition root", async () => {
|
||||
const html = compositionWithBodyPrefix(`
|
||||
<style>
|
||||
.pre-root-helper { color: red; }
|
||||
</style>
|
||||
<script>
|
||||
window.__preRootReady = true;
|
||||
</script>
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not report CSS-looking educational text inside the composition root", async () => {
|
||||
const html = compositionWithBodyPrefix(
|
||||
"",
|
||||
`
|
||||
<pre>
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
</pre>
|
||||
`,
|
||||
);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when CSS block comment syntax leaks into visible markup", async () => {
|
||||
const html = compositionWithBodyPrefix(
|
||||
"",
|
||||
`
|
||||
/* Main Content Block */
|
||||
<div class="editorial-block">Hello</div>
|
||||
`,
|
||||
);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "visible_markup_comment");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("visible HTML markup");
|
||||
expect(finding?.snippet).toContain("Main Content Block");
|
||||
});
|
||||
|
||||
it("reports error when a misbalanced style block leaves block comment syntax visible", async () => {
|
||||
const html = compositionWithBodyPrefix(
|
||||
"",
|
||||
`
|
||||
<style>
|
||||
.editorial-block { color: #fff; }
|
||||
</style>
|
||||
</style>
|
||||
/* Main Content Block */
|
||||
<div class="editorial-block">Hello</div>
|
||||
`,
|
||||
);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "visible_markup_comment");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain("Main Content Block");
|
||||
});
|
||||
|
||||
it("does not report block comments inside style or script blocks", async () => {
|
||||
const html = `
|
||||
<html>
|
||||
<head>
|
||||
<title>/* tab name */ Particle Field</title>
|
||||
<style>
|
||||
/* Layout reset */
|
||||
body { margin: 0; }
|
||||
</style>
|
||||
<noscript>/* fallback note */</noscript>
|
||||
</head>
|
||||
<body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
/* Timeline registry */
|
||||
window.__timelines = {};
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "visible_markup_comment");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not report block comments in attributes, html comments, or protected text contexts", async () => {
|
||||
const html = compositionWithBodyPrefix(
|
||||
"",
|
||||
`
|
||||
<!-- /* hidden implementation note */ -->
|
||||
<div data-note="/* attribute note */"></div>
|
||||
<div data-note="a > b /* quoted attribute note */"></div>
|
||||
<pre>/* visible code sample */</pre>
|
||||
<code>/* visible inline code sample */</code>
|
||||
<textarea>/* editable code sample */</textarea>
|
||||
<template>/* template-only note */</template>
|
||||
<svg viewBox="0 0 100 20"><text x="0" y="15">/* svg label */</text></svg>
|
||||
`,
|
||||
);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "visible_markup_comment");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when a stray style close tag is left in the document head", async () => {
|
||||
const html = compositionWithHead(`
|
||||
<style>
|
||||
body { margin: 0; }
|
||||
</style>
|
||||
</style>
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain("</style>");
|
||||
});
|
||||
|
||||
it("reports error when a stray script close tag is left in the document head", async () => {
|
||||
const html = compositionWithHead(`
|
||||
<script>
|
||||
window.__headReady = true;
|
||||
</script>
|
||||
</script>
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain("</script>");
|
||||
});
|
||||
|
||||
it("does not report leaked head text for valid closing tags with trailing whitespace", async () => {
|
||||
const html = compositionWithHead(`
|
||||
<style>
|
||||
body { margin: 0; }
|
||||
</style data-parser-error-close>
|
||||
<script>
|
||||
window.__headReady = true;
|
||||
</script
|
||||
data-parser-error-close>
|
||||
<title>Particle Field</title >
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when markdown code fences leak into the document head", async () => {
|
||||
const withLanguage = compositionWithHead(`
|
||||
\`\`\`css
|
||||
.particle {
|
||||
position: absolute;
|
||||
}
|
||||
\`\`\`
|
||||
`);
|
||||
const withoutLanguage = compositionWithHead(`
|
||||
\`\`\`
|
||||
.particle {
|
||||
position: absolute;
|
||||
}
|
||||
\`\`\`
|
||||
`);
|
||||
const withTsxLanguage = compositionWithHead(`
|
||||
\`\`\`tsx
|
||||
export function Particle() {
|
||||
return <div className="particle" />;
|
||||
}
|
||||
\`\`\`
|
||||
`);
|
||||
const withLanguageResult = await lintHyperframeHtml(withLanguage);
|
||||
const withoutLanguageResult = await lintHyperframeHtml(withoutLanguage);
|
||||
const withTsxLanguageResult = await lintHyperframeHtml(withTsxLanguage);
|
||||
const languageFinding = withLanguageResult.findings.find((f) => f.code === "head_leaked_text");
|
||||
const unlabeledFinding = withoutLanguageResult.findings.find(
|
||||
(f) => f.code === "head_leaked_text",
|
||||
);
|
||||
const tsxLanguageFinding = withTsxLanguageResult.findings.find(
|
||||
(f) => f.code === "head_leaked_text",
|
||||
);
|
||||
|
||||
expect(languageFinding).toBeDefined();
|
||||
expect(languageFinding?.snippet).toContain("```css");
|
||||
expect(unlabeledFinding).toBeDefined();
|
||||
expect(unlabeledFinding?.snippet).toContain("```");
|
||||
expect(tsxLanguageFinding).toBeDefined();
|
||||
expect(tsxLanguageFinding?.snippet).toContain("```tsx");
|
||||
});
|
||||
|
||||
it("reports error when CSS at-rules leak into the document head", async () => {
|
||||
const html = compositionWithHead(`
|
||||
@media (min-width: 800px) {
|
||||
.particle {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain("@media");
|
||||
});
|
||||
|
||||
it("reports leaked CSS when a style block is unclosed in the document head", async () => {
|
||||
const html = compositionWithHead(`
|
||||
<style>
|
||||
.particle {
|
||||
color: white;
|
||||
}
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain(".particle");
|
||||
});
|
||||
|
||||
it("does not report leaked head text for commented CSS", async () => {
|
||||
const html = compositionWithHead(`
|
||||
<!-- .particle { color: red; } -->
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not report leaked head text for valid noscript content", async () => {
|
||||
const html = compositionWithHead(`
|
||||
<noscript>
|
||||
.no-js { display: block; }
|
||||
</noscript>
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not report orphan CSS for valid head metadata and style blocks", async () => {
|
||||
const html = compositionWithHead(`
|
||||
<title>Particle Field</title>
|
||||
<meta name="description" content="Particle field">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||
<base href="https://example.com/">
|
||||
<style>
|
||||
.particle {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
</style>
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports leaked head text inside template-wrapped sub-compositions", async () => {
|
||||
const html = templateCompositionWithHead(`
|
||||
</style>
|
||||
.particle { color: white; }
|
||||
`);
|
||||
const result = await lintHyperframeHtml(html, { isSubComposition: true });
|
||||
const finding = result.findings.find((f) => f.code === "head_leaked_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.snippet).toContain(".particle");
|
||||
});
|
||||
|
||||
describe("timeline_id_mismatch", () => {
|
||||
it("accepts dot timeline registration", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="launch" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines.launch = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "timeline_id_mismatch");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports mismatched dot timeline registration", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="launch" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines.intro = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "timeline_id_mismatch");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toContain('Timeline registered as "intro"');
|
||||
});
|
||||
|
||||
it("accepts bracket timeline registration for hyphenated ids", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="product-launch" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["product-launch"] = tl;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "timeline_id_mismatch");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts object-literal timeline registration and extracts its keys", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="comp-1" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines = { "comp-1": tl };
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code === "missing_timeline_registry")).toBeUndefined();
|
||||
expect(
|
||||
result.findings.find((f) => f.code === "timeline_registry_missing_init"),
|
||||
).toBeUndefined();
|
||||
expect(result.findings.find((f) => f.code === "timeline_id_mismatch")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports mismatched object-literal timeline registration keys", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="comp-1" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
window.__timelines = { main: tl };
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "timeline_id_mismatch");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toContain('Timeline registered as "main"');
|
||||
});
|
||||
});
|
||||
|
||||
it("warns when a timeline-visible element has no stable id for Studio editing", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<section class="clip hero-card" data-start="0" data-duration="3"></section>
|
||||
</div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "studio_missing_editable_id");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
expect(finding?.message).toContain('<section class="hero-card" data-start="0">');
|
||||
expect(finding?.fixHint).toContain("stable, human-readable id");
|
||||
});
|
||||
|
||||
it("does not warn about the composition root or timeline elements with ids", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080" data-start="0">
|
||||
<section id="hero-card" class="clip hero-card" data-start="0" data-duration="3"></section>
|
||||
</div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "studio_missing_editable_id");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("non_deterministic_code", () => {
|
||||
it("detects Math.random() in script content", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const x = Math.random();
|
||||
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "non_deterministic_code");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("Math.random");
|
||||
});
|
||||
|
||||
it("detects Date.now() in script content", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const ts = Date.now();
|
||||
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "non_deterministic_code");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("Date.now");
|
||||
});
|
||||
|
||||
it("does not flag non-deterministic calls inside single-line comments", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div data-composition-id="c1" data-width="1920" data-height="1080"></div>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
// const x = Math.random();
|
||||
// Date.now() is not used here
|
||||
window.__timelines["c1"] = gsap.timeline({ paused: true });
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "non_deterministic_code");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("composition_self_attribute_selector", () => {
|
||||
it("warns when inline CSS targets the root composition id", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
[data-composition-id="scene"] .title { opacity: 0; }
|
||||
[data-composition-id="other"] .title { color: red; }
|
||||
</style>
|
||||
<h1 class="title">Hello</h1>
|
||||
</div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const findings = result.findings.filter(
|
||||
(f) => f.code === "composition_self_attribute_selector",
|
||||
);
|
||||
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]?.severity).toBe("warning");
|
||||
expect(findings[0]?.selector).toBe('[data-composition-id="scene"] .title');
|
||||
expect(findings[0]?.fixHint).toContain("#scene");
|
||||
expect(findings[0]?.fixHint).not.toContain("#556");
|
||||
});
|
||||
|
||||
it("warns when external CSS targets the root composition id", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080"></div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html, {
|
||||
externalStyles: [
|
||||
{
|
||||
href: "scene.css",
|
||||
content: '[data-composition-id="scene"] .title { opacity: 0; }',
|
||||
},
|
||||
],
|
||||
});
|
||||
const finding = result.findings.find((f) => f.code === "composition_self_attribute_selector");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.selector).toBe('[data-composition-id="scene"] .title');
|
||||
});
|
||||
|
||||
it("does not warn when CSS targets a different composition id", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="scene" data-composition-id="scene" data-width="1920" data-height="1080">
|
||||
<style>[data-composition-id="other"] .title { opacity: 0; }</style>
|
||||
</div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "composition_self_attribute_selector");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,566 @@
|
||||
import type { LintContext, HyperframeLintFinding } from "../context";
|
||||
import postcss from "postcss";
|
||||
import {
|
||||
readAttr,
|
||||
truncateSnippet,
|
||||
stripJsComments,
|
||||
extractCompositionIdsFromCss,
|
||||
extractTimelineRegistryKeys,
|
||||
getInlineScriptSyntaxError,
|
||||
TIMELINE_REGISTRY_INIT_PATTERN,
|
||||
TIMELINE_REGISTRY_ASSIGN_PATTERN,
|
||||
TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN,
|
||||
INVALID_SCRIPT_CLOSE_PATTERN,
|
||||
} from "../utils";
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function selectorTargetsCompositionId(selector: string, compositionId: string): boolean {
|
||||
const escaped = escapeRegExp(compositionId);
|
||||
return new RegExp(
|
||||
String.raw`\[\s*data-composition-id\s*=\s*(?:"${escaped}"|'${escaped}')\s*\]`,
|
||||
).test(selector);
|
||||
}
|
||||
|
||||
function isStudioTimelineElement(tag: { raw: string; name: string }): boolean {
|
||||
if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(
|
||||
readAttr(tag.raw, "data-start") ||
|
||||
readAttr(tag.raw, "data-track-index") ||
|
||||
readAttr(tag.raw, "data-track") ||
|
||||
readAttr(tag.raw, "data-composition-src") ||
|
||||
readAttr(tag.raw, "data-composition-file"),
|
||||
);
|
||||
}
|
||||
|
||||
function describeStudioElement(tag: { raw: string; name: string }): string {
|
||||
const parts = [`<${tag.name}`];
|
||||
const className = readAttr(tag.raw, "class");
|
||||
const compositionId = readAttr(tag.raw, "data-composition-id");
|
||||
const dataStart = readAttr(tag.raw, "data-start");
|
||||
const dataTrack = readAttr(tag.raw, "data-track-index") ?? readAttr(tag.raw, "data-track");
|
||||
|
||||
if (className) {
|
||||
const primaryClass = className
|
||||
.split(/\s+/)
|
||||
.map((value) => value.trim())
|
||||
.find((value) => value && value !== "clip");
|
||||
if (primaryClass) parts.push(` class="${primaryClass}"`);
|
||||
}
|
||||
if (compositionId) parts.push(` data-composition-id="${compositionId}"`);
|
||||
if (dataStart) parts.push(` data-start="${dataStart}"`);
|
||||
if (dataTrack) parts.push(` data-track-index="${dataTrack}"`);
|
||||
parts.push(">");
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
const HEAD_BLOCKS_TO_IGNORE_PATTERN =
|
||||
/<(?:style|script|template|title|noscript)\b[^>]*>[\s\S]*?<\/(?:style|script|template|title|noscript)(?:\s[^>]*)?>/gi;
|
||||
const HTML_TAG_PATTERN = /<[^>]+>/g;
|
||||
const HEAD_CONTENT_PATTERN = /<head\b[^>]*>([\s\S]*?)(?:<\/head>|<body\b|$)/gi;
|
||||
const AFTER_HEAD_BEFORE_BODY_PATTERN = /<\/head(?:\s[^>]*)?>([\s\S]*?)(?=<body\b|$)/gi;
|
||||
const STRAY_HEAD_CLOSE_PATTERN = /<\/(?:style|script)(?:\s[^>]*)?>/i;
|
||||
const MARKDOWN_CODE_FENCE_PATTERN = /```[^\r\n`]*(?:\r?\n|$)[\s\S]*?```/i;
|
||||
const ORPHAN_CSS_AT_RULE_PATTERN =
|
||||
/(?:^|\s)@(?:container|font-face|keyframes|layer|media|page|property|scope|supports)[^{<]*\{[\s\S]*?:[\s\S]*?\}/i;
|
||||
const ORPHAN_CSS_RULE_PATTERN =
|
||||
/(?:^|\s)(?:\/\*[\s\S]*?\*\/\s*)?(?:@[a-z-]+[^{}<]*|[.#][\w-]+[^{}<]*|[a-z][\w-]*(?:\s+[.#:[\w-][^{}<]*)?)\s*\{[^{}]*:[^{}]*\}/i;
|
||||
const VISIBLE_MARKUP_COMMENT_PATTERN = /\/\*[\s\S]*?\*\//g;
|
||||
const VISIBLE_MARKUP_COMMENT_PROTECTED_BLOCK_PATTERN =
|
||||
/<(style|script|template|title|noscript|pre|code|textarea|text)\b[^>]*>[\s\S]*?<\/\1(?:\s[^>]*)?>/gi;
|
||||
|
||||
interface SourceRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
function findCodeFenceLeak(headWithoutValidBlocks: string): string | null {
|
||||
return MARKDOWN_CODE_FENCE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null;
|
||||
}
|
||||
|
||||
function findOrphanCssLeak(headContent: string): string | null {
|
||||
const residualText = headContent
|
||||
.replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " ")
|
||||
.replace(HTML_TAG_PATTERN, " ");
|
||||
return (
|
||||
ORPHAN_CSS_AT_RULE_PATTERN.exec(residualText)?.[0] ??
|
||||
ORPHAN_CSS_RULE_PATTERN.exec(residualText)?.[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function findStrayCloseLeak(headWithoutValidBlocks: string): string | null {
|
||||
return STRAY_HEAD_CLOSE_PATTERN.exec(headWithoutValidBlocks)?.[0] ?? null;
|
||||
}
|
||||
|
||||
function findLeakedTextInHeadContent(headContent: string): string | null {
|
||||
const withoutValidBlocks = headContent.replace(HEAD_BLOCKS_TO_IGNORE_PATTERN, " ");
|
||||
return (
|
||||
findCodeFenceLeak(withoutValidBlocks) ??
|
||||
findOrphanCssLeak(headContent) ??
|
||||
findStrayCloseLeak(withoutValidBlocks)
|
||||
);
|
||||
}
|
||||
|
||||
function findLeakedTextInHead(rawSource: string): string | null {
|
||||
const headMatches = [...rawSource.matchAll(HEAD_CONTENT_PATTERN)];
|
||||
for (const match of headMatches) {
|
||||
const leakedText = findLeakedTextInHeadContent(match[1] ?? "");
|
||||
if (leakedText) return leakedText;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findLeakedTextBetweenHeadAndBody(rawSource: string): string | null {
|
||||
const boundaryMatches = [...rawSource.matchAll(AFTER_HEAD_BEFORE_BODY_PATTERN)];
|
||||
for (const match of boundaryMatches) {
|
||||
const leakedText = findLeakedTextInHeadContent(match[1] ?? "");
|
||||
if (leakedText) return leakedText;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findLeakedTextBeforeCompositionRoot(
|
||||
source: string,
|
||||
rootTag: LintContext["rootTag"],
|
||||
): string | null {
|
||||
if (!rootTag || rootTag.name === "body") return null;
|
||||
const bodyOpenMatch = /<body\b[^>]*>/i.exec(source);
|
||||
const prefixStart = bodyOpenMatch ? bodyOpenMatch.index + bodyOpenMatch[0].length : 0;
|
||||
const prefixEnd = rootTag.index;
|
||||
if (prefixEnd <= prefixStart) return null;
|
||||
return findLeakedTextInHeadContent(source.slice(prefixStart, prefixEnd));
|
||||
}
|
||||
|
||||
function findProtectedVisibleMarkupRanges(source: string): SourceRange[] {
|
||||
const ranges: SourceRange[] = [];
|
||||
for (const match of source.matchAll(VISIBLE_MARKUP_COMMENT_PROTECTED_BLOCK_PATTERN)) {
|
||||
ranges.push({ start: match.index, end: match.index + match[0].length });
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function isInsideSourceRange(index: number, ranges: SourceRange[]): boolean {
|
||||
return ranges.some((range) => range.start <= index && index < range.end);
|
||||
}
|
||||
|
||||
function isInsideHtmlTag(source: string, index: number): boolean {
|
||||
let inTag = false;
|
||||
let quote: '"' | "'" | null = null;
|
||||
for (let i = 0; i < index; i++) {
|
||||
const char = source[i];
|
||||
if (!inTag) {
|
||||
if (char === "<") inTag = true;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (char === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
} else if (char === ">") {
|
||||
inTag = false;
|
||||
}
|
||||
}
|
||||
return inTag;
|
||||
}
|
||||
|
||||
function findVisibleMarkupCommentLeak(source: string): string | null {
|
||||
const protectedRanges = findProtectedVisibleMarkupRanges(source);
|
||||
for (const match of source.matchAll(VISIBLE_MARKUP_COMMENT_PATTERN)) {
|
||||
if (isInsideHtmlTag(source, match.index)) continue;
|
||||
if (isInsideSourceRange(match.index, protectedRanges)) continue;
|
||||
return match[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
// id_requires_css_escape
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const tag of tags) {
|
||||
const id = readAttr(tag.raw, "id");
|
||||
if (!id || !/^\d/.test(id)) continue;
|
||||
findings.push({
|
||||
code: "id_requires_css_escape",
|
||||
severity: "warning",
|
||||
message: `id="${id}" starts with a digit, so the common selector \`#${id}\` throws a SyntaxError in querySelector().`,
|
||||
elementId: id,
|
||||
fixHint:
|
||||
"Rename the id to start with a letter (recommended), or build selectors with `#${CSS.escape(id)}` at runtime.",
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// root_missing_composition_id + root_missing_dimensions
|
||||
({ rootTag }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
if (!rootTag || !readAttr(rootTag.raw, "data-composition-id")) {
|
||||
findings.push({
|
||||
code: "root_missing_composition_id",
|
||||
severity: "error",
|
||||
message: "Root composition is missing `data-composition-id`.",
|
||||
elementId: rootTag ? readAttr(rootTag.raw, "id") || undefined : undefined,
|
||||
fixHint: "Add a stable `data-composition-id` to the entry composition wrapper.",
|
||||
snippet: truncateSnippet(rootTag?.raw || ""),
|
||||
});
|
||||
}
|
||||
if (!rootTag || !readAttr(rootTag.raw, "data-width") || !readAttr(rootTag.raw, "data-height")) {
|
||||
findings.push({
|
||||
code: "root_missing_dimensions",
|
||||
severity: "error",
|
||||
message: "Root composition is missing `data-width` or `data-height`.",
|
||||
elementId: rootTag ? readAttr(rootTag.raw, "id") || undefined : undefined,
|
||||
fixHint: "Set numeric `data-width` and `data-height` on the entry composition root.",
|
||||
snippet: truncateSnippet(rootTag?.raw || ""),
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// head_leaked_text
|
||||
({ source, rootTag }) => {
|
||||
const snippet =
|
||||
findLeakedTextInHead(source) ??
|
||||
findLeakedTextBetweenHeadAndBody(source) ??
|
||||
findLeakedTextBeforeCompositionRoot(source, rootTag);
|
||||
if (!snippet) return [];
|
||||
return [
|
||||
{
|
||||
code: "head_leaked_text",
|
||||
severity: "error",
|
||||
message:
|
||||
"Detected leaked code or CSS text around the document `<head>` or before the composition root. Browsers render this as visible text in the video.",
|
||||
fixHint:
|
||||
"Move CSS into a single `<style>...</style>` block and remove stray close tags, markdown fences, or code text from `<head>`, the `</head>`/`<body>` boundary, or the pre-root body prefix.",
|
||||
snippet: truncateSnippet(snippet),
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// visible_markup_comment
|
||||
({ source }) => {
|
||||
const snippet = findVisibleMarkupCommentLeak(source);
|
||||
if (!snippet) return [];
|
||||
return [
|
||||
{
|
||||
code: "visible_markup_comment",
|
||||
severity: "error",
|
||||
message:
|
||||
"CSS/JS block comment syntax (`/* ... */`) appears in visible HTML markup. HTML only treats `<!-- ... -->` as comments, so this renders as on-screen text.",
|
||||
fixHint:
|
||||
"Remove the text or convert it to a real HTML comment (`<!-- ... -->`). Keep CSS comments inside `<style>` and JS comments inside `<script>`.",
|
||||
snippet: truncateSnippet(snippet),
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// missing_timeline_registry + timeline_registry_missing_init
|
||||
({ source, rawSource, rootTag, options }) => {
|
||||
// Sub-compositions inherit window.__timelines from the host composition
|
||||
if (options.isSubComposition || rawSource.trimStart().toLowerCase().startsWith("<template")) {
|
||||
return [];
|
||||
}
|
||||
if (/(?:^|\s)data-no-timeline(?=[\s=/]|$)/i.test(rootTag?.attrs || "")) return [];
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
if (
|
||||
!TIMELINE_REGISTRY_INIT_PATTERN.test(source) &&
|
||||
!TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source) &&
|
||||
!TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN.test(source)
|
||||
) {
|
||||
findings.push({
|
||||
code: "missing_timeline_registry",
|
||||
severity: "error",
|
||||
message: "Missing `window.__timelines` registration.",
|
||||
fixHint: "Register each composition timeline on `window.__timelines[compositionId]`.",
|
||||
});
|
||||
}
|
||||
if (
|
||||
TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source) &&
|
||||
!TIMELINE_REGISTRY_INIT_PATTERN.test(source)
|
||||
) {
|
||||
findings.push({
|
||||
code: "timeline_registry_missing_init",
|
||||
severity: "error",
|
||||
message:
|
||||
"`window.__timelines[…] = …` is used without initializing `window.__timelines` first.",
|
||||
fixHint:
|
||||
"Add `window.__timelines = window.__timelines || {};` before any timeline assignment.",
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// timeline_id_mismatch
|
||||
({ source }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const htmlCompIds = new Set<string>();
|
||||
const timelineRegKeys = new Set<string>();
|
||||
const compIdRe = /data-composition-id\s*=\s*["']([^"']+)["']/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = compIdRe.exec(source)) !== null) {
|
||||
if (m[1]) htmlCompIds.add(m[1]);
|
||||
}
|
||||
for (const key of extractTimelineRegistryKeys(source)) {
|
||||
timelineRegKeys.add(key);
|
||||
}
|
||||
for (const key of timelineRegKeys) {
|
||||
if (!htmlCompIds.has(key)) {
|
||||
findings.push({
|
||||
code: "timeline_id_mismatch",
|
||||
severity: "error",
|
||||
message: `Timeline registered as "${key}" but no element has data-composition-id="${key}". The runtime cannot auto-nest this timeline.`,
|
||||
fixHint: `Change window.__timelines["${key}"] to match the data-composition-id attribute, or vice versa.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// invalid_inline_script_syntax (malformed close tag)
|
||||
({ source }) => {
|
||||
if (!INVALID_SCRIPT_CLOSE_PATTERN.test(source)) return [];
|
||||
return [
|
||||
{
|
||||
code: "invalid_inline_script_syntax",
|
||||
severity: "error",
|
||||
message: "Detected malformed inline `<script>` closing syntax.",
|
||||
fixHint: "Close inline scripts with a valid `</script>` tag.",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// invalid_inline_script_syntax (JS parse error)
|
||||
({ scripts }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const script of scripts) {
|
||||
const attrs = script.attrs || "";
|
||||
if (
|
||||
/\bsrc\s*=/.test(attrs) ||
|
||||
/\btype\s*=\s*["'](?:application\/json|application\/hyperframes-slideshow\+json|importmap|module)["']/.test(
|
||||
attrs,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
const syntaxError = getInlineScriptSyntaxError(script.content);
|
||||
if (!syntaxError) continue;
|
||||
findings.push({
|
||||
code: "invalid_inline_script_syntax",
|
||||
severity: "error",
|
||||
message: `Inline script has invalid syntax: ${syntaxError}`,
|
||||
fixHint: "Fix the inline script syntax before render verification.",
|
||||
snippet: truncateSnippet(script.content),
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// host_missing_composition_id
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const tag of tags) {
|
||||
const src = readAttr(tag.raw, "data-composition-src");
|
||||
if (!src) continue;
|
||||
if (readAttr(tag.raw, "data-composition-id")) continue;
|
||||
findings.push({
|
||||
code: "host_missing_composition_id",
|
||||
severity: "error",
|
||||
message: `Composition host for "${src}" is missing \`data-composition-id\`.`,
|
||||
elementId: readAttr(tag.raw, "id") || undefined,
|
||||
fixHint: "Set `data-composition-id` on every `data-composition-src` host element.",
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// scoped_css_missing_wrapper
|
||||
({ styles, compositionIds }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const scopedCssCompositionIds = new Set<string>();
|
||||
for (const style of styles) {
|
||||
for (const compId of extractCompositionIdsFromCss(style.content)) {
|
||||
scopedCssCompositionIds.add(compId);
|
||||
}
|
||||
}
|
||||
for (const compId of scopedCssCompositionIds) {
|
||||
if (compositionIds.has(compId)) continue;
|
||||
findings.push({
|
||||
code: "scoped_css_missing_wrapper",
|
||||
severity: "warning",
|
||||
message: `Scoped CSS targets composition "${compId}" but no matching wrapper exists in this HTML.`,
|
||||
selector: `[data-composition-id="${compId}"]`,
|
||||
fixHint:
|
||||
"Preserve the matching composition wrapper or align the CSS scope to an existing wrapper.",
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// composition_self_attribute_selector
|
||||
({ styles, rootCompositionId, rootTag }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
if (!rootCompositionId) return findings;
|
||||
const seenSelectors = new Set<string>();
|
||||
const rootId = readAttr(rootTag?.raw || "", "id");
|
||||
for (const style of styles) {
|
||||
let root: postcss.Root;
|
||||
try {
|
||||
root = postcss.parse(style.content);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
root.walkRules((rule) => {
|
||||
for (const selector of rule.selectors) {
|
||||
if (!selectorTargetsCompositionId(selector, rootCompositionId)) continue;
|
||||
if (seenSelectors.has(selector)) continue;
|
||||
seenSelectors.add(selector);
|
||||
findings.push({
|
||||
code: "composition_self_attribute_selector",
|
||||
severity: "warning",
|
||||
message:
|
||||
"Selector matches the block's own id; will leak to sibling instances when the block is embedded twice.",
|
||||
selector,
|
||||
fixHint: rootId
|
||||
? `Use #${rootId} for clearer authoring intent and instance-isolated styling.`
|
||||
: "Add a stable id to the composition root and use that id selector for clearer authoring intent and instance-isolated styling.",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// studio_missing_editable_id
|
||||
({ tags, rootTag }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const tag of tags) {
|
||||
if (rootTag && tag.index === rootTag.index) continue;
|
||||
if (!isStudioTimelineElement(tag)) continue;
|
||||
if (readAttr(tag.raw, "id")) continue;
|
||||
|
||||
const descriptor = describeStudioElement(tag);
|
||||
findings.push({
|
||||
code: "studio_missing_editable_id",
|
||||
severity: "warning",
|
||||
message: `${descriptor} has no id, so Studio cannot use a stable edit target for its timeline and canvas controls.`,
|
||||
selector: readAttr(tag.raw, "data-composition-id")
|
||||
? `[data-composition-id="${readAttr(tag.raw, "data-composition-id")}"]`
|
||||
: undefined,
|
||||
fixHint:
|
||||
'Add a stable, human-readable id such as id="hero-title" or id="scene-1-card" to every timeline-visible element you want agents or Studio to edit.',
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// non_deterministic_code
|
||||
({ scripts }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const patterns: Array<{ pattern: RegExp; label: string; hint: string }> = [
|
||||
{
|
||||
pattern: /Math\.random\s*\(/,
|
||||
label: "Math.random()",
|
||||
hint: "Use a seeded PRNG (e.g. a simple mulberry32) so renders are deterministic across frames.",
|
||||
},
|
||||
{
|
||||
pattern: /Date\.now\s*\(/,
|
||||
label: "Date.now()",
|
||||
hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time.",
|
||||
},
|
||||
{
|
||||
pattern: /new\s+Date\s*\(/,
|
||||
label: "new Date()",
|
||||
hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time.",
|
||||
},
|
||||
{
|
||||
pattern: /performance\.now\s*\(/,
|
||||
label: "performance.now()",
|
||||
hint: "Remove time-dependent code. Use GSAP timeline position instead of wall-clock time.",
|
||||
},
|
||||
{
|
||||
pattern: /crypto\.getRandomValues\s*\(/,
|
||||
label: "crypto.getRandomValues()",
|
||||
hint: "Remove time-dependent code. Use a seeded PRNG for deterministic renders.",
|
||||
},
|
||||
];
|
||||
|
||||
for (const script of scripts) {
|
||||
const stripped = stripJsComments(script.content);
|
||||
for (const { pattern, label, hint } of patterns) {
|
||||
if (pattern.test(stripped)) {
|
||||
findings.push({
|
||||
code: "non_deterministic_code",
|
||||
severity: "error",
|
||||
message: `Script contains \`${label}\` which produces non-deterministic output. Renders may differ between frames or runs.`,
|
||||
fixHint: hint,
|
||||
snippet: truncateSnippet(script.content),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// pointer_events_none
|
||||
// fallow-ignore-next-line complexity
|
||||
({ tags, styles }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const reported = new Set<string>();
|
||||
|
||||
for (const tag of tags) {
|
||||
if (["script", "style", "link", "meta", "template", "noscript"].includes(tag.name)) continue;
|
||||
const inlineStyle = readAttr(tag.raw, "style") ?? "";
|
||||
if (!/pointer-events\s*:\s*none/i.test(inlineStyle)) continue;
|
||||
const id = readAttr(tag.raw, "id");
|
||||
const key = id ?? tag.raw;
|
||||
if (reported.has(key)) continue;
|
||||
reported.add(key);
|
||||
findings.push({
|
||||
code: "pointer_events_none",
|
||||
severity: "info",
|
||||
message: `<${tag.name}${id ? ` id="${id}"` : ""}> has \`pointer-events: none\` in its inline style. Elements with this property are harder to select in the Studio preview.`,
|
||||
elementId: id || undefined,
|
||||
fixHint:
|
||||
"If this element should be selectable in the Studio, remove `pointer-events: none` or move it to a wrapper that doesn't contain editable content.",
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
|
||||
for (const style of styles) {
|
||||
let root: postcss.Root;
|
||||
try {
|
||||
root = postcss.parse(style.content);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
root.walkDecls("pointer-events", (decl) => {
|
||||
if (decl.value.trim().toLowerCase() !== "none") return;
|
||||
const rule = decl.parent;
|
||||
if (!rule || rule.type !== "rule") return;
|
||||
const selector = (rule as postcss.Rule).selector;
|
||||
if (reported.has(selector)) return;
|
||||
reported.add(selector);
|
||||
findings.push({
|
||||
code: "pointer_events_none",
|
||||
severity: "info",
|
||||
message: `\`${selector}\` sets \`pointer-events: none\`. Elements matching this selector are harder to select in the Studio preview.`,
|
||||
selector,
|
||||
fixHint:
|
||||
"If these elements should be selectable in the Studio, remove `pointer-events: none` or move it to a wrapper that doesn't contain editable content.",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,353 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { lintHyperframeHtml } from "../hyperframeLinter.js";
|
||||
|
||||
async function findByCode(html: string, code: string, isSubComposition = true) {
|
||||
const result = await lintHyperframeHtml(html, { isSubComposition });
|
||||
return result.findings.filter((f) => f.code === code);
|
||||
}
|
||||
|
||||
describe("font rules", () => {
|
||||
describe("google_fonts_import", () => {
|
||||
it("warns on @import url with fonts.googleapis.com without failing lint", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap');</style>
|
||||
</div>`;
|
||||
const result = await lintHyperframeHtml(html, { isSubComposition: true });
|
||||
const findings = result.findings.filter((f) => f.code === "google_fonts_import");
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]!.severity).toBe("warning");
|
||||
expect(result.errorCount).toBe(0);
|
||||
});
|
||||
|
||||
it("warns on <link> to fonts.googleapis.com", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "google_fonts_import");
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]!.severity).toBe("warning");
|
||||
});
|
||||
|
||||
it("does not flag local @font-face usage", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>@font-face { font-family: 'Inter'; src: url('../capture/assets/fonts/Inter.woff2'); }</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "google_fonts_import");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not flag installed registry blocks that bundle Google Fonts", async () => {
|
||||
const html =
|
||||
`<!-- hyperframes-registry-item: my-block -->\n` +
|
||||
`<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "google_fonts_import");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("system_font_will_alias", () => {
|
||||
it("flags SF Mono as aliased to JetBrains Mono", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>code { font-family: 'SF Mono', monospace; }</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "system_font_will_alias");
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]!.severity).toBe("info");
|
||||
expect(findings[0]!.message).toContain("JetBrains Mono");
|
||||
});
|
||||
|
||||
it("flags Helvetica Neue as aliased to Inter", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>body { font-family: 'Helvetica Neue', sans-serif; }</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "system_font_will_alias");
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]!.message).toContain("Inter");
|
||||
});
|
||||
|
||||
it("does not flag canonical font names", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>body { font-family: 'Inter', sans-serif; }</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "system_font_will_alias");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not flag Roboto (canonical name)", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>body { font-family: 'Roboto', sans-serif; }</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "system_font_will_alias");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not flag unknown fonts (handled by font_family_without_font_face)", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>body { font-family: 'Comic Sans MS', sans-serif; }</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "system_font_will_alias");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not flag aliased fonts that have explicit @font-face", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
@font-face { font-family: 'Menlo'; src: url('../fonts/menlo.woff2'); }
|
||||
code { font-family: 'Menlo', monospace; }
|
||||
</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "system_font_will_alias");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles case-insensitive font names", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>body { font-family: 'VERDANA', sans-serif; }</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "system_font_will_alias");
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]!.message).toContain("Inter");
|
||||
});
|
||||
|
||||
it("reports multiple aliased fonts in one finding", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
body { font-family: 'Verdana', sans-serif; }
|
||||
code { font-family: 'Consolas', monospace; }
|
||||
</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "system_font_will_alias");
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]!.message).toContain("Inter");
|
||||
expect(findings[0]!.message).toContain("JetBrains Mono");
|
||||
});
|
||||
});
|
||||
|
||||
describe("font_family_without_font_face", () => {
|
||||
it("flags font-family used without @font-face", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>body { font-family: 'GT Walsheim', sans-serif; }</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]!.message).toContain("gt walsheim");
|
||||
});
|
||||
|
||||
it("does not flag when @font-face is declared", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
@font-face { font-family: 'GT Walsheim'; src: url('../fonts/gt.woff2'); }
|
||||
body { font-family: 'GT Walsheim', sans-serif; }
|
||||
</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not flag a system font declared via @font-face src: local()", async () => {
|
||||
// Regression: two independent reports of this rule hard-erroring on OS
|
||||
// system fonts (Hiragino Sans, Microsoft YaHei) that have no downloadable
|
||||
// file. src: local(...) already satisfies the check (extractFontFaceFamilies
|
||||
// only looks at the font-family declaration, not the src value) — the gap
|
||||
// was that the fixHint didn't mention this as an option.
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
@font-face { font-family: 'Microsoft YaHei'; src: local('Microsoft YaHei'); }
|
||||
body { font-family: 'Microsoft YaHei', sans-serif; }
|
||||
</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fixHint mentions the local() pattern for system fonts", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>body { font-family: 'GT Walsheim', sans-serif; }</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "font_family_without_font_face");
|
||||
expect(findings[0]!.fixHint).toContain("local(");
|
||||
});
|
||||
|
||||
it("does not flag generic font families", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>body { font-family: monospace; }</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reports multiple missing families in one finding", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
h1 { font-family: 'Aeonik', sans-serif; }
|
||||
code { font-family: 'Feature Deck', monospace; }
|
||||
</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]!.message).toContain("aeonik");
|
||||
expect(findings[0]!.message).toContain("feature deck");
|
||||
});
|
||||
|
||||
it("does not flag fonts the producer has pre-bundled", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; }
|
||||
code { font-family: 'JetBrains Mono', monospace; }
|
||||
h1 { font-family: 'Roboto', sans-serif; }
|
||||
</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("still flags Google-Fonts-only fonts not pre-bundled", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>body { font-family: 'Geist', sans-serif; }</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]!.message).toContain("geist");
|
||||
});
|
||||
|
||||
it("does not flag a non-bundled family when a Google Fonts link loads it", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;700&display=swap">
|
||||
<style>body { font-family: 'Geist', sans-serif; }</style>
|
||||
</div>`;
|
||||
const result = await lintHyperframeHtml(html, { isSubComposition: true });
|
||||
expect(result.findings.filter((f) => f.code === "google_fonts_import")).toHaveLength(1);
|
||||
expect(
|
||||
result.findings.filter((f) => f.code === "font_family_without_font_face"),
|
||||
).toHaveLength(0);
|
||||
expect(result.errorCount).toBe(0);
|
||||
});
|
||||
|
||||
it("parses unquoted Google Fonts link href values", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<link rel=stylesheet href=https://fonts.googleapis.com/css2?family=Geist:wght@400;700&display=swap>
|
||||
<style>body { font-family: 'Geist', sans-serif; }</style>
|
||||
</div>`;
|
||||
const result = await lintHyperframeHtml(html, { isSubComposition: true });
|
||||
expect(result.findings.filter((f) => f.code === "google_fonts_import")).toHaveLength(1);
|
||||
expect(
|
||||
result.findings.filter((f) => f.code === "font_family_without_font_face"),
|
||||
).toHaveLength(0);
|
||||
expect(result.errorCount).toBe(0);
|
||||
});
|
||||
|
||||
it("parses multiple Google Fonts family parameters and URL-encoded spaces", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
@import url("https://fonts.googleapis.com/css2?family=Libre+Baskerville:wght@400;700&family=DM+Sans:ital,wght@0,400;1,700&display=swap");
|
||||
h1 { font-family: 'Libre Baskerville', serif; }
|
||||
body { font-family: 'DM Sans', sans-serif; }
|
||||
</style>
|
||||
</div>`;
|
||||
const result = await lintHyperframeHtml(html, { isSubComposition: true });
|
||||
expect(result.findings.filter((f) => f.code === "google_fonts_import")).toHaveLength(1);
|
||||
expect(
|
||||
result.findings.filter((f) => f.code === "font_family_without_font_face"),
|
||||
).toHaveLength(0);
|
||||
expect(result.errorCount).toBe(0);
|
||||
});
|
||||
|
||||
it("still flags non-bundled families not covered by the Google Fonts URL", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">
|
||||
<style>body { font-family: 'Geist', sans-serif; }</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]!.message).toContain("geist");
|
||||
});
|
||||
|
||||
it("is case-insensitive when matching @font-face to font-family", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
@font-face { font-family: 'Inter'; src: url('../fonts/inter.woff2'); }
|
||||
body { font-family: 'inter', sans-serif; }
|
||||
</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("ignores font-family inside @font-face blocks", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
@font-face { font-family: 'CustomFont'; src: url('../fonts/custom.woff2'); }
|
||||
</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not flag vendor-prefixed system-font keywords (-apple-system, BlinkMacSystemFont)", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif; }
|
||||
</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not flag installed registry blocks that declare fonts via Google Fonts", async () => {
|
||||
const html =
|
||||
`<!-- hyperframes-registry-item: my-block -->\n` +
|
||||
`<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>body { font-family: 'Poppins', sans-serif; }</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("matches @font-face even when a CSS comment inside the block contains a brace (#1534)", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
@font-face { /* weight 400 } regular */ font-family: 'Noto Sans SC'; src: url('../fonts/noto-400.woff2'); }
|
||||
.title { font-family: 'Noto Sans SC'; }
|
||||
</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not flag the -apple-system / BlinkMacSystemFont system-ui stack", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not flag a var() font-family indirection it cannot resolve", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>:root { --heading: 'Inter'; } h1 { font-family: var(--heading); }</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not flag a var() with a quoted fallback font", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>h1 { font-family: var(--heading, 'Geist'), sans-serif; }</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("still flags a real undeclared font sitting next to a system stack", async () => {
|
||||
const html = `<div data-composition-id="test" data-width="1920" data-height="1080">
|
||||
<style>body { font-family: 'Aeonik', -apple-system, BlinkMacSystemFont, sans-serif; }</style>
|
||||
</div>`;
|
||||
const findings = await findByCode(html, "font_family_without_font_face");
|
||||
expect(findings).toHaveLength(1);
|
||||
expect(findings[0]!.message).toContain("aeonik");
|
||||
expect(findings[0]!.message).not.toContain("apple-system");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,242 @@
|
||||
import { FONT_ALIAS_KEYS, resolveAliasDisplayName } from "@hyperframes/parsers/composition";
|
||||
import type { LintContext, HyperframeLintFinding } from "../context";
|
||||
import { isRegistrySourceFile, isRegistryInstalledFile } from "./composition";
|
||||
|
||||
const GENERIC_FAMILIES = new Set([
|
||||
"serif",
|
||||
"sans-serif",
|
||||
"monospace",
|
||||
"cursive",
|
||||
"fantasy",
|
||||
"system-ui",
|
||||
"ui-serif",
|
||||
"ui-sans-serif",
|
||||
"ui-monospace",
|
||||
"ui-rounded",
|
||||
"math",
|
||||
"emoji",
|
||||
"fangsong",
|
||||
// Vendor-prefixed system-font keywords. Like `system-ui`, the engine resolves
|
||||
// these to the OS UI font — they are never installable files and must not be
|
||||
// flagged as a missing @font-face, even when a generic fallback follows them
|
||||
// (e.g. `-apple-system, system-ui, sans-serif`).
|
||||
"-apple-system",
|
||||
"blinkmacsystemfont",
|
||||
"inherit",
|
||||
"initial",
|
||||
"unset",
|
||||
"revert",
|
||||
]);
|
||||
|
||||
// A CSS comment can contain a `}` (e.g. `@font-face { /* 400 } regular */
|
||||
// font-family: 'X'; ... }`), which truncates the naive `@font-face\s*\{[^}]*\}`
|
||||
// block match at the comment's brace — so the rule never sees the real
|
||||
// `font-family` and reports a false-positive font_family_without_font_face.
|
||||
// Large/"framework" stylesheets hit this far more often than minimal ones,
|
||||
// which is why a simple <style> passes while a complex one fails. Strip
|
||||
// comments before scanning so a brace inside one cannot split a block. See #1534.
|
||||
function stripCssComments(css: string): string {
|
||||
return css.replace(/\/\*[\s\S]*?\*\//g, " ");
|
||||
}
|
||||
|
||||
function extractFontFaceFamilies(styles: Array<{ content: string }>): Set<string> {
|
||||
const families = new Set<string>();
|
||||
const fontFaceRe = /@font-face\s*\{[^}]*\}/gi;
|
||||
const familyRe = /font-family\s*:\s*(['"]?)([^;'"]+)\1/i;
|
||||
for (const style of styles) {
|
||||
const content = stripCssComments(style.content);
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = fontFaceRe.exec(content)) !== null) {
|
||||
const familyMatch = match[0].match(familyRe);
|
||||
if (familyMatch?.[2]) {
|
||||
families.add(familyMatch[2].trim().toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
return families;
|
||||
}
|
||||
|
||||
// Normalize one comma-separated font-family entry to a lowercase family name,
|
||||
// or null if it carries no resolvable name. `var(--heading)` (or any function
|
||||
// token) is an indirection the linter cannot statically resolve, so the literal
|
||||
// `var(...)` is not a font name and flagging it is a false positive. Comma-split
|
||||
// fallbacks like `var(--x, 'Inter')` also leave a dangling `)` on the fallback
|
||||
// part, so skip anything bearing parentheses.
|
||||
function normalizeUsedFontName(part: string): string | null {
|
||||
const name = part
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!name || name.includes("(") || name.includes(")")) return null;
|
||||
return name;
|
||||
}
|
||||
|
||||
function extractUsedFontFamilies(styles: Array<{ content: string }>): string[] {
|
||||
const used: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const propRe = /font-family\s*:\s*([^;}{]+)/gi;
|
||||
for (const style of styles) {
|
||||
const withoutFontFace = stripCssComments(style.content).replace(/@font-face\s*\{[^}]*\}/gi, "");
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = propRe.exec(withoutFontFace)) !== null) {
|
||||
for (const part of match[1]!.split(",")) {
|
||||
const name = normalizeUsedFontName(part);
|
||||
if (name && !GENERIC_FAMILIES.has(name) && !seen.has(name)) {
|
||||
seen.add(name);
|
||||
used.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return used;
|
||||
}
|
||||
|
||||
function collectAliasedFonts(used: string[], declared: Set<string>): string[] {
|
||||
const aliased: string[] = [];
|
||||
for (const name of used) {
|
||||
if (declared.has(name)) continue;
|
||||
const displayName = resolveAliasDisplayName(name);
|
||||
if (!displayName) continue;
|
||||
if (displayName.toLowerCase() === name) continue;
|
||||
aliased.push(`'${name}' → ${displayName}`);
|
||||
}
|
||||
return aliased;
|
||||
}
|
||||
|
||||
function normalizeFontFamily(name: string): string | null {
|
||||
const decoded = name.replace(/\+/g, " ").trim();
|
||||
if (!decoded) return null;
|
||||
try {
|
||||
return decodeURIComponent(decoded).trim().toLowerCase() || null;
|
||||
} catch {
|
||||
return decoded.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
function extractGoogleFontFamiliesFromUrl(rawUrl: string): string[] {
|
||||
const url = rawUrl.replace(/&/gi, "&");
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url, "https://fonts.googleapis.com");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (parsed.hostname.toLowerCase() !== "fonts.googleapis.com") return [];
|
||||
const families: string[] = [];
|
||||
for (const value of parsed.searchParams.getAll("family")) {
|
||||
for (const familySpec of value.split("|")) {
|
||||
const family = normalizeFontFamily(familySpec.split(":")[0] || "");
|
||||
if (family) families.push(family);
|
||||
}
|
||||
}
|
||||
return families;
|
||||
}
|
||||
|
||||
function collectGoogleFontFamilies(
|
||||
source: string,
|
||||
styles: Array<{ content: string }>,
|
||||
): Set<string> {
|
||||
const families = new Set<string>();
|
||||
const addUrl = (url: string) => {
|
||||
for (const family of extractGoogleFontFamiliesFromUrl(url)) families.add(family);
|
||||
};
|
||||
|
||||
const linkHrefRe =
|
||||
/<link\b[^>]*\bhref\s*=\s*(?:(["'])([^"']*fonts\.googleapis\.com[^"']*)\1|([^\s>]*fonts\.googleapis\.com[^\s>]*))[^>]*>/gi;
|
||||
for (const match of source.matchAll(linkHrefRe)) {
|
||||
const href = match[2] || match[3];
|
||||
if (href) addUrl(href);
|
||||
}
|
||||
|
||||
const importUrlRe =
|
||||
/@import\s+(?:url\(\s*)?(["']?)([^"')\s]*fonts\.googleapis\.com[^"')\s]*)\1\s*\)?/gi;
|
||||
for (const style of styles) {
|
||||
for (const match of style.content.matchAll(importUrlRe)) {
|
||||
if (match[2]) addUrl(match[2]);
|
||||
}
|
||||
}
|
||||
|
||||
return families;
|
||||
}
|
||||
|
||||
export const fontRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
// google_fonts_import
|
||||
({ styles, source, rawSource, options }) => {
|
||||
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const googleFontsInLink = /<link\b[^>]*fonts\.googleapis\.com[^>]*>/i.test(source);
|
||||
const googleFontsInImport = styles.some((s) =>
|
||||
/@import\s+url\s*\(\s*['"]?[^)]*fonts\.googleapis\.com/i.test(s.content),
|
||||
);
|
||||
|
||||
if (googleFontsInLink || googleFontsInImport) {
|
||||
findings.push({
|
||||
code: "google_fonts_import",
|
||||
severity: "warning",
|
||||
message:
|
||||
"Composition loads fonts from fonts.googleapis.com. The producer resolves Google Fonts " +
|
||||
"during compile/render, but raw external font requests add latency and can fail before " +
|
||||
"canonicalization. Prefer mapped family names or local @font-face declarations when possible.",
|
||||
fixHint:
|
||||
"For bundled fonts, remove the Google Fonts <link> or @import and keep the font-family " +
|
||||
"declaration. For custom fonts, use @font-face { font-family: '...'; src: url('...woff2'); }.",
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// system_font_will_alias — inform when a font will be silently substituted
|
||||
({ styles, options }) => {
|
||||
const declared = extractFontFaceFamilies(styles);
|
||||
const used = extractUsedFontFamilies(styles);
|
||||
const aliased = collectAliasedFonts(used, declared);
|
||||
if (aliased.length === 0) return [];
|
||||
// In distributed / Lambda renders system-font capture is disabled, so
|
||||
// the alias substitution does NOT happen — elevate to a warning.
|
||||
const severity = options.distributed ? ("warning" as const) : ("info" as const);
|
||||
return [
|
||||
{
|
||||
code: "system_font_will_alias",
|
||||
severity,
|
||||
message:
|
||||
`Font ${aliased.length === 1 ? "family" : "families"} will be substituted at render time: ${aliased.join(", ")}. ` +
|
||||
(options.distributed
|
||||
? "In distributed/Lambda rendering system-font capture is disabled — these fonts will fall back to OS defaults. Embed explicit @font-face declarations instead."
|
||||
: "The renderer maps these to bundled fonts for cross-platform consistency. " +
|
||||
"Use the target font name directly for consistent preview and render results."),
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// font_family_without_font_face
|
||||
({ styles, source, rawSource, options }) => {
|
||||
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const declared = extractFontFaceFamilies(styles);
|
||||
const used = extractUsedFontFamilies(styles);
|
||||
const googleFonts = collectGoogleFontFamilies(source, styles);
|
||||
|
||||
const undeclared = used.filter(
|
||||
(name) => !declared.has(name) && !FONT_ALIAS_KEYS.has(name) && !googleFonts.has(name),
|
||||
);
|
||||
if (undeclared.length === 0) return findings;
|
||||
|
||||
findings.push({
|
||||
code: "font_family_without_font_face",
|
||||
severity: "error",
|
||||
message:
|
||||
`Font ${undeclared.length === 1 ? "family" : "families"} used without @font-face declaration: ${undeclared.join(", ")}. ` +
|
||||
"These are not in the auto-resolved font list, so the renderer cannot supply them automatically. " +
|
||||
"Text will fall back to a generic font, producing incorrect typography in the video.",
|
||||
fixHint:
|
||||
"Add @font-face { font-family: '...'; src: url('capture/assets/fonts/...woff2'); } " +
|
||||
"for each font family, pointing to the captured .woff2 files. For an OS-bundled " +
|
||||
"system font (e.g. Hiragino Sans, Microsoft YaHei) that has no downloadable file, " +
|
||||
"use src: local('Exact Font Name') instead — the declaration alone satisfies this " +
|
||||
"check without needing a font file.",
|
||||
});
|
||||
return findings;
|
||||
},
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,319 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { lintHyperframeHtml } from "../hyperframeLinter.js";
|
||||
|
||||
describe("media rules", () => {
|
||||
it("reports error for duplicate media ids", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<video id="v1" src="a.mp4" data-start="0" data-duration="5"></video>
|
||||
<video id="v1" src="b.mp4" data-start="0" data-duration="3"></video>
|
||||
</div>
|
||||
<script>window.__timelines = {};</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "duplicate_media_id");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.elementId).toBe("v1");
|
||||
});
|
||||
|
||||
it("reports error for audio with data-start but no id", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<audio data-start="0" data-duration="10" src="narration.wav"></audio>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "media_missing_id");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("SILENT");
|
||||
});
|
||||
|
||||
it("reports error for video with data-start but no id", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<video data-start="0" data-duration="10" src="clip.mp4" muted playsinline></video>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "media_missing_id");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.message).toContain("FROZEN");
|
||||
});
|
||||
|
||||
it("flags media that has data-hf-id but no real id", async () => {
|
||||
// Regression: readAttr(tag, "id") used a \b boundary that matched the
|
||||
// trailing `id="…"` inside `data-hf-id="…"`, so media carrying only a
|
||||
// Studio-stamped data-hf-id passed the check and then rendered as a blank
|
||||
// wash (video) / silent (audio). data-hf-id is NOT a render id.
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<video data-hf-id="hf-v1a2b3" data-start="0" data-duration="10" src="clip.mp4" muted playsinline></video>
|
||||
<audio data-hf-id="hf-a4c5d6" data-start="0" data-duration="10" src="narration.wav"></audio>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const findings = result.findings.filter((f) => f.code === "media_missing_id");
|
||||
expect(findings).toHaveLength(2);
|
||||
expect(findings.every((f) => f.severity === "error")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag media elements that have id", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<audio id="a1" data-start="0" data-duration="10" src="narration.wav"></audio>
|
||||
<video id="v1" data-start="0" data-duration="10" src="clip.mp4" muted playsinline></video>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "media_missing_id");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports warning for media with preload=none", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<video id="v1" data-start="0" data-duration="10" src="clip.mp4" muted playsinline preload="none"></video>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "media_preload_none");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
});
|
||||
|
||||
it("reports error for media with id but no src", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<audio id="a1" data-start="0" data-duration="10"></audio>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "media_missing_src");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("reports error for media with src but no data-start", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<video id="demo-video" src="clip.mp4" muted playsinline></video>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "media_missing_data_start");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.elementId).toBe("demo-video");
|
||||
});
|
||||
|
||||
it("allows audible video clips to omit muted when data-has-audio is true", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<video id="demo-video" data-start="0" data-duration="5" data-has-audio="true" src="clip.mp4" playsinline></video>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code === "video_missing_muted")).toBeUndefined();
|
||||
expect(
|
||||
result.findings.find((f) => f.code === "video_muted_with_declared_audio"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error for videos that declare audio while muted", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<video id="demo-video" data-start="0" data-duration="5" data-has-audio="true" src="clip.mp4" muted playsinline></video>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "video_muted_with_declared_audio");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.elementId).toBe("demo-video");
|
||||
});
|
||||
|
||||
it("does NOT flag <video> as nested in a void element with data-start (regression)", async () => {
|
||||
// Regression: void elements like <img> have no closing tag, so the previous
|
||||
// implementation kept them on the parent stack indefinitely and flagged any
|
||||
// later <video> with data-start as "nested" inside them.
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<img id="hdr-img" src="hdr.png" data-start="0" data-duration="5" data-track-index="0" />
|
||||
<video id="hdr-vid" src="clip.mp4" data-start="5" data-duration="5" data-track-index="1" muted playsinline></video>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "video_nested_in_timed_element");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports imperative play() control on managed media ids", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<video id="demo-video" data-start="0" data-duration="5" src="clip.mp4" muted playsinline></video>
|
||||
</div>
|
||||
<script>
|
||||
const video = document.getElementById("demo-video");
|
||||
video.play();
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "imperative_media_control");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.elementId).toBe("demo-video");
|
||||
});
|
||||
|
||||
it("reports imperative currentTime writes on query-selected managed media", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<video id="demo-video" data-start="0" data-duration="5" src="clip.mp4" muted playsinline></video>
|
||||
</div>
|
||||
<script>
|
||||
const demo = document.querySelector("#demo-video");
|
||||
demo.currentTime = 1.5;
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "imperative_media_control");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("reports imperative muted/play control on class-selected media without ids", async () => {
|
||||
const html = `
|
||||
<template id="scene-template">
|
||||
<div data-composition-id="scene" data-width="1920" data-height="1080">
|
||||
<video class="demo-video" src="clip.mp4" muted playsinline></video>
|
||||
<script>
|
||||
const vid = document.querySelector('[data-composition-id="scene"] .demo-video');
|
||||
if (vid) { vid.muted = true; vid.play(); }
|
||||
</script>
|
||||
</div>
|
||||
</template>`;
|
||||
const result = await lintHyperframeHtml(html, { filePath: "compositions/scene.html" });
|
||||
const imperativeFindings = result.findings.filter((f) => f.code === "imperative_media_control");
|
||||
expect(imperativeFindings.length).toBe(2);
|
||||
expect(imperativeFindings.some((f) => f.snippet === "vid.muted =")).toBe(true);
|
||||
expect(imperativeFindings.some((f) => f.snippet === "vid.play(")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag play() on non-media elements", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<div id="panel"></div>
|
||||
</div>
|
||||
<script>
|
||||
const panel = document.getElementById("panel");
|
||||
panel.play?.();
|
||||
</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "imperative_media_control");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("flags <video> inside a sub-composition (media must be a host-root child)", async () => {
|
||||
const html = `<template id="scene-template">
|
||||
<div id="root" data-composition-id="scene" data-width="1920" data-height="1080">
|
||||
<video id="v1" src="clip.mp4" data-start="0" data-duration="5" muted playsinline></video>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["scene"] = gsap.timeline({ paused: true });</script>
|
||||
</div>
|
||||
</template>`;
|
||||
const result = await lintHyperframeHtml(html, { isSubComposition: true });
|
||||
const finding = result.findings.find((f) => f.code === "media_in_subcomposition");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.elementId).toBe("v1");
|
||||
expect(finding?.message).toContain("sub-composition");
|
||||
});
|
||||
|
||||
it("does not flag media in a host-root (non-sub) composition", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<video id="v1" src="clip.mp4" data-start="0" data-duration="5" muted playsinline></video>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "media_in_subcomposition");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error for media with crossorigin (breaks preview when host omits CORS)", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<video id="v1" crossorigin="anonymous" src="https://cdn.example.com/clip.mp4" data-start="0" data-duration="5" muted playsinline></video>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "media_crossorigin_breaks_preview");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.elementId).toBe("v1");
|
||||
});
|
||||
|
||||
it("does not flag media without crossorigin", async () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<video id="v1" src="https://cdn.example.com/clip.mp4" data-start="0" data-duration="5" muted playsinline></video>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "media_crossorigin_breaks_preview");
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("media_variable_src_no_fallback", () => {
|
||||
it("downgrades missing src to a warning when data-var-src is present", async () => {
|
||||
const html = `<html><body>
|
||||
<video id="clip" data-start="0" data-duration="2" data-var-src="media"></video>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(result.findings.some((f) => f.code === "media_missing_src")).toBe(false);
|
||||
const finding = result.findings.find((f) => f.code === "media_variable_src_no_fallback");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
});
|
||||
|
||||
it("keeps the hard error when neither src nor data-var-src exists", async () => {
|
||||
const html = `<html><body>
|
||||
<video id="clip" data-start="0" data-duration="2"></video>
|
||||
</body></html>`;
|
||||
const result = await lintHyperframeHtml(html);
|
||||
expect(result.findings.some((f) => f.code === "media_missing_src")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,569 @@
|
||||
import type { LintContext, HyperframeLintFinding } from "../context";
|
||||
import { readAttr, truncateSnippet, isMediaTag } from "../utils";
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function hasAttrName(tagSource: string, attr: string): boolean {
|
||||
const escaped = escapeRegExp(attr);
|
||||
const attrs = tagSource.replace(/^<\s*[a-z][\w:-]*/i, "");
|
||||
return new RegExp(`(?:^|\\s)${escaped}(?:\\s*=|\\s|/?>)`, "i").test(attrs);
|
||||
}
|
||||
|
||||
function classNamesFromAttr(classAttr: string | null): string[] {
|
||||
if (!classAttr) return [];
|
||||
return classAttr.split(/\s+/).filter(Boolean);
|
||||
}
|
||||
|
||||
type MediaSelectorIndex = {
|
||||
ids: Set<string>;
|
||||
classes: Set<string>;
|
||||
hasVideo: boolean;
|
||||
hasAudio: boolean;
|
||||
};
|
||||
|
||||
function selectorTargetsManagedMedia(selector: string, mediaIndex: MediaSelectorIndex): boolean {
|
||||
const normalized = selector.trim();
|
||||
if (!normalized) return false;
|
||||
if (mediaIndex.hasVideo && /\bvideo\b/i.test(normalized)) return true;
|
||||
if (mediaIndex.hasAudio && /\baudio\b/i.test(normalized)) return true;
|
||||
for (const mediaId of mediaIndex.ids) {
|
||||
const escapedId = escapeRegExp(mediaId);
|
||||
if (
|
||||
new RegExp(`#${escapedId}(?![\\w-])`).test(normalized) ||
|
||||
normalized.includes(`[id="${mediaId}"]`) ||
|
||||
normalized.includes(`[id='${mediaId}']`)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (const className of mediaIndex.classes) {
|
||||
if (new RegExp(`\\.${escapeRegExp(className)}(?![\\w-])`).test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findImperativeMediaControlFindings(ctx: LintContext): HyperframeLintFinding[] {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const mediaTags = ctx.tags.filter((tag) => tag.name === "video" || tag.name === "audio");
|
||||
const mediaIndex: MediaSelectorIndex = {
|
||||
ids: new Set(
|
||||
mediaTags.map((tag) => readAttr(tag.raw, "id")).filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
classes: new Set(mediaTags.flatMap((tag) => classNamesFromAttr(readAttr(tag.raw, "class")))),
|
||||
hasVideo: mediaTags.some((tag) => tag.name === "video"),
|
||||
hasAudio: mediaTags.some((tag) => tag.name === "audio"),
|
||||
};
|
||||
|
||||
if (mediaTags.length === 0 || ctx.scripts.length === 0) return findings;
|
||||
|
||||
for (const script of ctx.scripts) {
|
||||
const mediaVars = new Map<string, string | undefined>();
|
||||
const assignmentPatterns = [
|
||||
{
|
||||
pattern:
|
||||
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)/g,
|
||||
variableIndex: 1,
|
||||
targetIndex: 2,
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\2\s*\)/g,
|
||||
variableIndex: 1,
|
||||
targetIndex: 3,
|
||||
},
|
||||
];
|
||||
|
||||
for (const { pattern, variableIndex, targetIndex } of assignmentPatterns) {
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(script.content)) !== null) {
|
||||
const variableName = match[variableIndex];
|
||||
const target = match[targetIndex];
|
||||
if (!variableName || !target) continue;
|
||||
if (mediaIndex.ids.has(target) || selectorTargetsManagedMedia(target, mediaIndex)) {
|
||||
mediaVars.set(variableName, mediaIndex.ids.has(target) ? target : undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const directIdPatterns = [
|
||||
{
|
||||
pattern:
|
||||
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.play\s*\(/g,
|
||||
kind: "play()",
|
||||
targetIndex: 1,
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.pause\s*\(/g,
|
||||
kind: "pause()",
|
||||
targetIndex: 1,
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.currentTime\s*=/g,
|
||||
kind: "currentTime",
|
||||
targetIndex: 1,
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.muted\s*=/g,
|
||||
kind: "muted assignment",
|
||||
targetIndex: 1,
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.play\s*\(/g,
|
||||
kind: "play()",
|
||||
targetIndex: 2,
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.pause\s*\(/g,
|
||||
kind: "pause()",
|
||||
targetIndex: 2,
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.currentTime\s*=/g,
|
||||
kind: "currentTime",
|
||||
targetIndex: 2,
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.muted\s*=/g,
|
||||
kind: "muted assignment",
|
||||
targetIndex: 2,
|
||||
},
|
||||
];
|
||||
|
||||
for (const { pattern, kind, targetIndex } of directIdPatterns) {
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(script.content)) !== null) {
|
||||
const target = match[targetIndex];
|
||||
if (!target) continue;
|
||||
const elementId = mediaIndex.ids.has(target)
|
||||
? target
|
||||
: selectorTargetsManagedMedia(target, mediaIndex)
|
||||
? undefined
|
||||
: null;
|
||||
if (elementId === null) continue;
|
||||
findings.push({
|
||||
code: "imperative_media_control",
|
||||
severity: "error",
|
||||
message: `Inline <script> imperatively controls managed media via ${kind}. HyperFrames must own media play/pause/seek to keep preview, timeline, and renders deterministic.`,
|
||||
elementId: elementId || undefined,
|
||||
fixHint:
|
||||
"Remove imperative media play/pause/currentTime/muted control. Express timing with data-start/data-duration and media offsets like data-media-start or data-playback-start instead.",
|
||||
snippet: truncateSnippet(match[0]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [variableName, elementId] of mediaVars) {
|
||||
const escapedVar = escapeRegExp(variableName);
|
||||
const variablePatterns = [
|
||||
{ pattern: new RegExp(`\\b${escapedVar}\\.play\\s*\\(`, "g"), kind: "play()" },
|
||||
{ pattern: new RegExp(`\\b${escapedVar}\\.pause\\s*\\(`, "g"), kind: "pause()" },
|
||||
{ pattern: new RegExp(`\\b${escapedVar}\\.currentTime\\s*=`, "g"), kind: "currentTime" },
|
||||
{
|
||||
pattern: new RegExp(`\\b${escapedVar}\\.muted\\s*=`, "g"),
|
||||
kind: "muted assignment",
|
||||
},
|
||||
];
|
||||
for (const { pattern, kind } of variablePatterns) {
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(script.content)) !== null) {
|
||||
findings.push({
|
||||
code: "imperative_media_control",
|
||||
severity: "error",
|
||||
message: `Inline <script> imperatively controls managed media via ${kind}. HyperFrames must own media play/pause/seek to keep preview, timeline, and renders deterministic.`,
|
||||
elementId,
|
||||
fixHint:
|
||||
"Remove imperative media play/pause/currentTime/muted control. Express timing with data-start/data-duration and media offsets like data-media-start or data-playback-start instead.",
|
||||
snippet: truncateSnippet(match[0]),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
// duplicate_media_id + duplicate_media_discovery_risk
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const mediaById = new Map<string, typeof tags>();
|
||||
const mediaFingerprintCounts = new Map<string, number>();
|
||||
|
||||
for (const tag of tags) {
|
||||
if (!isMediaTag(tag.name)) continue;
|
||||
const elementId = readAttr(tag.raw, "id");
|
||||
if (elementId) {
|
||||
const existing = mediaById.get(elementId) || [];
|
||||
existing.push(tag);
|
||||
mediaById.set(elementId, existing);
|
||||
}
|
||||
const fingerprint = [
|
||||
tag.name,
|
||||
readAttr(tag.raw, "src") || "",
|
||||
readAttr(tag.raw, "data-start") || "",
|
||||
readAttr(tag.raw, "data-duration") || "",
|
||||
].join("|");
|
||||
mediaFingerprintCounts.set(fingerprint, (mediaFingerprintCounts.get(fingerprint) || 0) + 1);
|
||||
}
|
||||
|
||||
for (const [elementId, mediaTags] of mediaById) {
|
||||
if (mediaTags.length < 2) continue;
|
||||
findings.push({
|
||||
code: "duplicate_media_id",
|
||||
severity: "error",
|
||||
message: `Media id "${elementId}" is defined multiple times.`,
|
||||
elementId,
|
||||
fixHint:
|
||||
"Give each media element a unique id so preview and producer discover the same media graph.",
|
||||
snippet: truncateSnippet(mediaTags[0]?.raw || ""),
|
||||
});
|
||||
}
|
||||
|
||||
for (const [fingerprint, count] of mediaFingerprintCounts) {
|
||||
if (count < 2) continue;
|
||||
const [tagName, src, dataStart, dataDuration] = fingerprint.split("|");
|
||||
findings.push({
|
||||
code: "duplicate_media_discovery_risk",
|
||||
severity: "warning",
|
||||
message: `Detected ${count} matching ${tagName} entries with the same source/start/duration.`,
|
||||
fixHint: "Avoid duplicated media nodes that can be discovered twice during compilation.",
|
||||
snippet: truncateSnippet(
|
||||
`${tagName} src=${src} data-start=${dataStart} data-duration=${dataDuration}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// video_missing_muted
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const tag of tags) {
|
||||
if (tag.name !== "video") continue;
|
||||
const hasMuted = hasAttrName(tag.raw, "muted");
|
||||
const hasDeclaredAudio = readAttr(tag.raw, "data-has-audio") === "true";
|
||||
if (!hasMuted && !hasDeclaredAudio && readAttr(tag.raw, "data-start")) {
|
||||
const elementId = readAttr(tag.raw, "id") || undefined;
|
||||
findings.push({
|
||||
code: "video_missing_muted",
|
||||
severity: "error",
|
||||
message: `<video${elementId ? ` id="${elementId}"` : ""}> has data-start but is not muted. Mark audible videos with data-has-audio="true"; otherwise keep video muted and use a separate <audio> element for sound.`,
|
||||
elementId,
|
||||
fixHint:
|
||||
'Add the `muted` attribute for silent video, or add data-has-audio="true" when the video track should contribute audio.',
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
if (hasMuted && hasDeclaredAudio) {
|
||||
const elementId = readAttr(tag.raw, "id") || undefined;
|
||||
findings.push({
|
||||
code: "video_muted_with_declared_audio",
|
||||
severity: "error",
|
||||
message: `<video${elementId ? ` id="${elementId}"` : ""}> declares data-has-audio="true" but also has muted. Studio preview will silence the video audio.`,
|
||||
elementId,
|
||||
fixHint:
|
||||
'Remove the `muted` attribute if this video should be audible, or remove data-has-audio="true" and use data-volume="0" for silent visual video.',
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// video_nested_in_timed_element
|
||||
({ source, tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
// HTML5 void elements cannot contain children, so they can never be a
|
||||
// parent of a nested <video>. Skipping them avoids false positives where
|
||||
// the linter looks for `</img>` and never finds it.
|
||||
const voidElements = new Set([
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
]);
|
||||
const timedTagPositions: Array<{ name: string; start: number; id?: string }> = [];
|
||||
for (const tag of tags) {
|
||||
if (tag.name === "video" || tag.name === "audio") continue;
|
||||
if (voidElements.has(tag.name)) continue;
|
||||
// Skip the composition root — it uses data-start as a playback anchor, not as a clip timer
|
||||
if (readAttr(tag.raw, "data-composition-id")) continue;
|
||||
if (readAttr(tag.raw, "data-start")) {
|
||||
timedTagPositions.push({
|
||||
name: tag.name,
|
||||
start: tag.index,
|
||||
id: readAttr(tag.raw, "id") || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const tag of tags) {
|
||||
if (tag.name !== "video") continue;
|
||||
if (!readAttr(tag.raw, "data-start")) continue;
|
||||
for (const parent of timedTagPositions) {
|
||||
if (parent.start < tag.index) {
|
||||
const parentClosePattern = new RegExp(`</${parent.name}>`, "gi");
|
||||
const between = source.substring(parent.start, tag.index);
|
||||
if (!parentClosePattern.test(between)) {
|
||||
findings.push({
|
||||
code: "video_nested_in_timed_element",
|
||||
severity: "error",
|
||||
message: `<video> with data-start is nested inside <${parent.name}${parent.id ? ` id="${parent.id}"` : ""}> which also has data-start. The framework cannot manage playback of nested media — video will be FROZEN in renders.`,
|
||||
elementId: readAttr(tag.raw, "id") || undefined,
|
||||
fixHint:
|
||||
"Move the <video> to be a direct child of the stage, or remove data-start from the wrapper div (use it as a non-timed visual container).",
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// media_in_subcomposition — <video>/<audio> only render as a DIRECT child of the host
|
||||
// root (index.html). Inside a sub-composition <template> the runtime never seeks/decodes
|
||||
// them, so they render BLANK/black in preview and renders — and the other lint/validate
|
||||
// passes otherwise miss it (only a per-frame snapshot reveals the blank panel).
|
||||
({ tags, options }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
if (!options.isSubComposition) return findings;
|
||||
for (const tag of tags) {
|
||||
if (tag.name !== "video" && tag.name !== "audio") continue;
|
||||
const elementId = readAttr(tag.raw, "id") || undefined;
|
||||
findings.push({
|
||||
code: "media_in_subcomposition",
|
||||
severity: "error",
|
||||
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> is inside a sub-composition. The runtime only drives media that is a DIRECT child of the host root (index.html); media inside a sub-comp <template> is never seeked/decoded and renders BLANK/black in preview and renders.`,
|
||||
elementId,
|
||||
fixHint:
|
||||
"Move the media OUT of the sub-composition: place the <video>/<audio> as a direct child of #root in index.html, positioned over the scene, and drive any per-scene motion on the MAIN timeline at global time (a sub-comp timeline cannot reach host elements). See composition-patterns.md archetype B.",
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// self_closing_media_tag
|
||||
({ source }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const selfClosingMediaRe = /<(audio|video)\b[^>]*\/>/gi;
|
||||
let scMatch: RegExpExecArray | null;
|
||||
while ((scMatch = selfClosingMediaRe.exec(source)) !== null) {
|
||||
const tagName = scMatch[1] || "audio";
|
||||
const elementId = readAttr(scMatch[0], "id") || undefined;
|
||||
findings.push({
|
||||
code: "self_closing_media_tag",
|
||||
severity: "error",
|
||||
message: `Self-closing <${tagName}/> is invalid HTML. The browser will leave the tag open, swallowing all subsequent elements as invisible fallback content. This makes compositions INVISIBLE.`,
|
||||
elementId,
|
||||
fixHint: `Change <${tagName} .../> to <${tagName} ...></${tagName}> — media elements MUST have explicit closing tags.`,
|
||||
snippet: truncateSnippet(scMatch[0]),
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// placeholder_media_url
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const PLACEHOLDER_DOMAINS =
|
||||
/\b(placehold\.co|placeholder\.com|placekitten\.com|picsum\.photos|example\.com|via\.placeholder\.com|dummyimage\.com)\b/i;
|
||||
for (const tag of tags) {
|
||||
if (!isMediaTag(tag.name)) continue;
|
||||
const src = readAttr(tag.raw, "src");
|
||||
if (!src) continue;
|
||||
if (PLACEHOLDER_DOMAINS.test(src)) {
|
||||
const elementId = readAttr(tag.raw, "id") || undefined;
|
||||
findings.push({
|
||||
code: "placeholder_media_url",
|
||||
severity: "error",
|
||||
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses a placeholder URL that will 404 at render time: ${src.slice(0, 80)}`,
|
||||
elementId,
|
||||
fixHint: "Replace with a real media URL. Placeholder domains will 404 at render time.",
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// base64_media_prohibited
|
||||
({ source }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const base64MediaRe =
|
||||
/src\s*=\s*["'](data:(?:audio|video)\/[^;]+;base64,([A-Za-z0-9+/=]{20,}))["']/gi;
|
||||
let b64Match: RegExpExecArray | null;
|
||||
while ((b64Match = base64MediaRe.exec(source)) !== null) {
|
||||
const sample = (b64Match[2] || "").slice(0, 200);
|
||||
const uniqueChars = new Set(sample.replace(/[A-Za-z0-9+/=]/g, (c) => c)).size;
|
||||
const dataSize = Math.round(((b64Match[2] || "").length * 3) / 4);
|
||||
const isSuspicious = uniqueChars < 15 || (dataSize > 1000 && dataSize < 50000);
|
||||
findings.push({
|
||||
code: "base64_media_prohibited",
|
||||
severity: "error",
|
||||
message: `Inline base64 audio/video detected (${(dataSize / 1024).toFixed(0)} KB)${isSuspicious ? " — likely fabricated data" : ""}. Base64 media is prohibited — it bloats file size and breaks rendering.`,
|
||||
fixHint:
|
||||
"Use a relative path (assets/music.mp3) or HTTPS URL for the audio/video src. Never embed media as base64.",
|
||||
snippet: truncateSnippet((b64Match[1] ?? "").slice(0, 80) + "..."),
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// media_missing_data_start + media_missing_id + media_missing_src + media_preload_none
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const tag of tags) {
|
||||
if (tag.name !== "video" && tag.name !== "audio") continue;
|
||||
const hasDataStart = readAttr(tag.raw, "data-start");
|
||||
const hasId = readAttr(tag.raw, "id");
|
||||
const hasSrc = readAttr(tag.raw, "src");
|
||||
if (hasSrc && !hasDataStart) {
|
||||
findings.push({
|
||||
code: "media_missing_data_start",
|
||||
severity: "error",
|
||||
message: `<${tag.name}${hasId ? ` id="${hasId}"` : ""}> has src but no data-start. HyperFrames cannot own playback for untimed media, so preview and render behavior can diverge.`,
|
||||
elementId: hasId || undefined,
|
||||
fixHint: `Add data-start="0" (or the intended start time) and data-duration if the clip should stop before the source ends.`,
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
if (hasDataStart && !hasId) {
|
||||
findings.push({
|
||||
code: "media_missing_id",
|
||||
severity: "error",
|
||||
message: `<${tag.name}> has data-start but no id attribute. The renderer requires id to discover media elements — this ${tag.name === "audio" ? "audio will be SILENT" : "video will be FROZEN"} in renders.`,
|
||||
fixHint: `Add a unique id attribute: <${tag.name} id="my-${tag.name}" ...>`,
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
if (hasDataStart && hasId && !hasSrc) {
|
||||
const varSrc = readAttr(tag.raw, "data-var-src");
|
||||
if (varSrc) {
|
||||
// Variable-bound media without a fallback still renders when the
|
||||
// variable resolves, but a render without a value can't load the
|
||||
// media, and the audio pipeline discovers tracks from the AUTHORED
|
||||
// src — warn instead of hard-failing the binding pattern.
|
||||
findings.push({
|
||||
code: "media_variable_src_no_fallback",
|
||||
severity: "warning",
|
||||
message: `<${tag.name} id="${hasId}"> relies on data-var-src="${varSrc}" with no fallback src. Renders without a "${varSrc}" value cannot load this media, and audio extraction reads the authored src.`,
|
||||
elementId: hasId,
|
||||
fixHint: `Add a fallback src the composition can render with when the variable is not provided.`,
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
} else {
|
||||
findings.push({
|
||||
code: "media_missing_src",
|
||||
severity: "error",
|
||||
message: `<${tag.name} id="${hasId}"> has data-start but no src attribute. The renderer cannot load this media.`,
|
||||
elementId: hasId,
|
||||
fixHint: `Add a src attribute to the <${tag.name}> element directly. If using <source> children, the renderer still requires src on the parent element.`,
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (readAttr(tag.raw, "preload") === "none") {
|
||||
findings.push({
|
||||
code: "media_preload_none",
|
||||
severity: "warning",
|
||||
message: `<${tag.name}${hasId ? ` id="${hasId}"` : ""}> has preload="none" which prevents the renderer from loading this media. The compiler strips it for renders, but preview may also have issues.`,
|
||||
elementId: hasId || undefined,
|
||||
fixHint: `Remove preload="none" or change to preload="auto". The framework manages media loading.`,
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// media_crossorigin_breaks_preview — `crossorigin` on <video>/<audio> forces a
|
||||
// CORS-checked fetch. The server-side renderer downloads media directly (no CORS),
|
||||
// so it always works there; but Studio preview runs in the browser, where a media
|
||||
// host that omits Access-Control-Allow-Origin silently fails the load — the media
|
||||
// shows BLANK/black in preview while renders look fine, hiding the bug. Plain
|
||||
// displayed media never needs crossorigin; it's only required to read pixels/samples
|
||||
// back (canvas/WebGL texture, WebAudio createMediaElementSource) AND only when the
|
||||
// host is known CORS-enabled.
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const tag of tags) {
|
||||
if (tag.name !== "video" && tag.name !== "audio") continue;
|
||||
if (!hasAttrName(tag.raw, "crossorigin")) continue;
|
||||
const elementId = readAttr(tag.raw, "id") || undefined;
|
||||
findings.push({
|
||||
code: "media_crossorigin_breaks_preview",
|
||||
severity: "error",
|
||||
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has crossorigin, which forces a CORS-checked fetch. If the media host omits Access-Control-Allow-Origin, the load silently fails in Studio preview (media shows BLANK/black) while server-side renders still work — hiding the bug.`,
|
||||
elementId,
|
||||
fixHint:
|
||||
"Remove the crossorigin attribute unless you read the media back via canvas/WebGL/WebAudio AND the host is known to send CORS headers. Plain displayed media never needs it.",
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// video_audio_double_source — catches audible <video> paired with a separate
|
||||
// <audio> pointing to the same file, which causes double playback at runtime
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const videoSources = new Map<string, { id?: string; raw: string }>();
|
||||
const audioSources = new Map<string, { id?: string; raw: string }>();
|
||||
|
||||
for (const tag of tags) {
|
||||
if (!readAttr(tag.raw, "data-start")) continue;
|
||||
const src = readAttr(tag.raw, "src");
|
||||
if (!src) continue;
|
||||
const elementId = readAttr(tag.raw, "id") || undefined;
|
||||
if (tag.name === "video") {
|
||||
const isMuted = hasAttrName(tag.raw, "muted");
|
||||
if (!isMuted) {
|
||||
videoSources.set(src, { id: elementId, raw: tag.raw });
|
||||
}
|
||||
} else if (tag.name === "audio") {
|
||||
audioSources.set(src, { id: elementId, raw: tag.raw });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [src, audioInfo] of audioSources) {
|
||||
const videoInfo = videoSources.get(src);
|
||||
if (!videoInfo) continue;
|
||||
findings.push({
|
||||
code: "video_audio_double_source",
|
||||
severity: "error",
|
||||
message: `<audio${audioInfo.id ? ` id="${audioInfo.id}"` : ""}> and <video${videoInfo.id ? ` id="${videoInfo.id}"` : ""}> both point to the same source. The unmuted video already provides audio — the duplicate <audio> will cause double playback and echo.`,
|
||||
elementId: audioInfo.id,
|
||||
fixHint:
|
||||
"Either mute the video (add `muted` attribute) and keep the separate <audio>, or remove the <audio> element and let the video provide its own audio track.",
|
||||
snippet: truncateSnippet(audioInfo.raw),
|
||||
});
|
||||
}
|
||||
return findings;
|
||||
},
|
||||
|
||||
// imperative_media_control
|
||||
findImperativeMediaControlFindings,
|
||||
];
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { lintHyperframeHtml } from "../hyperframeLinter.js";
|
||||
|
||||
async function findSlideshow(html: string) {
|
||||
const result = await lintHyperframeHtml(html, { isSubComposition: true });
|
||||
return result.findings.filter((f) => f.code.startsWith("slideshow_"));
|
||||
}
|
||||
|
||||
describe("slideshow lint rule", () => {
|
||||
it("passes a composition with no slideshow island", async () => {
|
||||
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
|
||||
<div id="a" class="clip" data-start="0" data-duration="5"></div>
|
||||
</div>`;
|
||||
expect(await findSlideshow(html)).toEqual([]);
|
||||
});
|
||||
|
||||
it("passes a valid island where sceneId resolves to a data-composition-id scene", async () => {
|
||||
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
|
||||
<div data-composition-id="a" data-start="0" data-duration="5"></div>
|
||||
<script type="application/hyperframes-slideshow+json">{"slides":[{"sceneId":"a"}]}</script>
|
||||
</div>`;
|
||||
expect(await findSlideshow(html)).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags a sceneId that matches only a .clip[id] (not a data-composition-id)", async () => {
|
||||
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
|
||||
<div id="a" class="clip" data-start="0" data-duration="5"></div>
|
||||
<script type="application/hyperframes-slideshow+json">{"slides":[{"sceneId":"a"}]}</script>
|
||||
</div>`;
|
||||
const findings = await findSlideshow(html);
|
||||
expect(findings.length).toBeGreaterThan(0);
|
||||
expect(findings[0]?.message).toContain("a");
|
||||
});
|
||||
|
||||
it("flags an unresolved sceneId", async () => {
|
||||
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
|
||||
<div id="a" class="clip" data-start="0" data-duration="5"></div>
|
||||
<script type="application/hyperframes-slideshow+json">{"slides":[{"sceneId":"ghost"}]}</script>
|
||||
</div>`;
|
||||
const findings = await findSlideshow(html);
|
||||
expect(findings.length).toBeGreaterThan(0);
|
||||
expect(findings[0]!.message).toContain("ghost");
|
||||
});
|
||||
|
||||
it("flags invalid JSON in the island", async () => {
|
||||
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
|
||||
<script type="application/hyperframes-slideshow+json">NOT_JSON</script>
|
||||
</div>`;
|
||||
const findings = await findSlideshow(html);
|
||||
expect(findings.length).toBeGreaterThan(0);
|
||||
expect(findings[0]!.code).toBe("slideshow_invalid");
|
||||
});
|
||||
|
||||
it("passes when sceneId resolves to a data-composition-id element (no .clip[id])", async () => {
|
||||
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
|
||||
<div data-composition-id="scene-a" data-start="0" data-duration="5"></div>
|
||||
<script type="application/hyperframes-slideshow+json">{"slides":[{"sceneId":"scene-a"}]}</script>
|
||||
</div>`;
|
||||
expect(await findSlideshow(html)).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags a hotspot targeting an unknown sequence", async () => {
|
||||
const html = `<div data-composition-id="c" data-width="1920" data-height="1080">
|
||||
<div data-composition-id="a" data-start="0" data-duration="5"></div>
|
||||
<script type="application/hyperframes-slideshow+json">${JSON.stringify({
|
||||
slides: [{ sceneId: "a", hotspots: [{ id: "h1", label: "Go", target: "no-such-seq" }] }],
|
||||
})}</script>
|
||||
</div>`;
|
||||
const findings = await findSlideshow(html);
|
||||
expect(findings.length).toBeGreaterThan(0);
|
||||
expect(findings[0]!.message).toContain("no-such-seq");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { LintContext, HyperframeLintFinding } from "../context";
|
||||
import type { LintRule } from "../types";
|
||||
import { readAttr } from "../utils";
|
||||
import {
|
||||
parseSlideshowManifest,
|
||||
resolveSlideshow,
|
||||
isSceneLikeCompositionId,
|
||||
} from "@hyperframes/parsers/slideshow";
|
||||
|
||||
type Scene = { id: string; start: number; duration: number };
|
||||
|
||||
function parseTiming(raw: string): { start: number; duration: number } | null {
|
||||
const startStr = readAttr(raw, "data-start");
|
||||
if (startStr === null) return null;
|
||||
const start = Number(startStr);
|
||||
if (!Number.isFinite(start)) return null;
|
||||
|
||||
const durationStr = readAttr(raw, "data-duration");
|
||||
if (durationStr !== null) {
|
||||
const duration = Number(durationStr);
|
||||
if (Number.isFinite(duration)) return { start, duration };
|
||||
}
|
||||
const endStr = readAttr(raw, "data-end") ?? readAttr(raw, "data-hf-authored-end");
|
||||
if (endStr !== null) {
|
||||
const end = Number(endStr);
|
||||
if (Number.isFinite(end) && end > start) return { start, duration: end - start };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectCompositionIdScenes(ctx: LintContext, seen: Set<string>, out: Scene[]): void {
|
||||
for (const tag of ctx.tags) {
|
||||
const compositionId = readAttr(tag.raw, "data-composition-id");
|
||||
if (!compositionId || !isSceneLikeCompositionId(compositionId) || seen.has(compositionId))
|
||||
continue;
|
||||
const timing = parseTiming(tag.raw);
|
||||
if (!timing || timing.duration <= 0) continue;
|
||||
seen.add(compositionId);
|
||||
out.push({ id: compositionId, ...timing });
|
||||
}
|
||||
}
|
||||
|
||||
function extractScenesFromClips(ctx: LintContext): Scene[] {
|
||||
const seen = new Set<string>();
|
||||
const scenes: Scene[] = [];
|
||||
collectCompositionIdScenes(ctx, seen, scenes);
|
||||
return scenes;
|
||||
}
|
||||
|
||||
export const slideshowRules: LintRule<LintContext>[] = [
|
||||
(ctx) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = parseSlideshowManifest(ctx.source);
|
||||
} catch (e) {
|
||||
findings.push({
|
||||
code: "slideshow_invalid",
|
||||
severity: "error",
|
||||
message: `Slideshow island contains invalid JSON or structure: ${e instanceof Error ? e.message : String(e)}`,
|
||||
fixHint:
|
||||
'Ensure the <script type="application/hyperframes-slideshow+json"> block contains valid JSON matching the SlideshowManifest schema.',
|
||||
});
|
||||
return findings;
|
||||
}
|
||||
|
||||
if (!manifest) return findings;
|
||||
|
||||
const scenes = extractScenesFromClips(ctx);
|
||||
const { errors } = resolveSlideshow(manifest, scenes);
|
||||
|
||||
for (const error of errors) {
|
||||
findings.push({
|
||||
code: "slideshow_unresolved_ref",
|
||||
severity: "error",
|
||||
message: `Slideshow manifest error: ${error}`,
|
||||
fixHint:
|
||||
"Ensure every sceneId in the slideshow island matches the data-composition-id of a scene element in the composition, or provide explicit startTime/endTime.",
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { lintHyperframeHtml } from "../hyperframeLinter.js";
|
||||
|
||||
function baseHtml(body: string, style = ""): string {
|
||||
return `<html><body>
|
||||
<div data-composition-id="main" data-width="1920" data-height="1080">
|
||||
${body}
|
||||
</div>
|
||||
<style>${style}</style>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
const textureCss = `
|
||||
.hf-texture-text {
|
||||
color: #fff;
|
||||
-webkit-mask-size: var(--mask-size, cover);
|
||||
mask-size: var(--mask-size, cover);
|
||||
}
|
||||
.hf-texture-lava {
|
||||
-webkit-mask-image: url("masks/lava.png");
|
||||
mask-image: url("masks/lava.png");
|
||||
}
|
||||
`;
|
||||
|
||||
describe("texture rules", () => {
|
||||
it("does not warn for a valid texture mask text usage", async () => {
|
||||
const html = baseHtml(
|
||||
'<div class="shadow"><div class="hf-texture-text hf-texture-lava">TEXT</div></div>',
|
||||
`${textureCss}.shadow { filter: drop-shadow(1px 2px 1px rgba(0,0,0,.48)); }`,
|
||||
);
|
||||
|
||||
const result = await lintHyperframeHtml(html);
|
||||
|
||||
expect(result.findings.filter((finding) => finding.code.startsWith("texture_"))).toEqual([]);
|
||||
});
|
||||
|
||||
it("warns when a material class is used without hf-texture-text", async () => {
|
||||
const html = baseHtml('<div class="hf-texture-lava">TEXT</div>', textureCss);
|
||||
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((item) => item.code === "texture_class_missing_base");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
expect(finding?.fixHint).toContain("hf-texture-text");
|
||||
});
|
||||
|
||||
it("warns when hf-texture-text has no material class or custom mask image", async () => {
|
||||
const html = baseHtml('<div class="hf-texture-text">TEXT</div>', textureCss);
|
||||
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((item) => item.code === "texture_text_missing_mask");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("warning");
|
||||
});
|
||||
|
||||
it("allows hf-texture-text with an inline custom mask image", async () => {
|
||||
const html = baseHtml(
|
||||
'<div class="hf-texture-text" style="-webkit-mask-image:url(custom.png); mask-image:url(custom.png)">TEXT</div>',
|
||||
textureCss,
|
||||
);
|
||||
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((item) => item.code === "texture_text_missing_mask");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("warns when a texture material class is not defined by local CSS", async () => {
|
||||
const html = baseHtml('<div class="hf-texture-text hf-texture-marbel">TEXT</div>', textureCss);
|
||||
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((item) => item.code === "texture_class_unknown");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.message).toContain("hf-texture-marbel");
|
||||
});
|
||||
|
||||
it("warns when drop-shadow is applied inline to the textured text element", async () => {
|
||||
const html = baseHtml(
|
||||
'<div class="hf-texture-text hf-texture-lava" style="filter: drop-shadow(1px 2px 1px black)">TEXT</div>',
|
||||
textureCss,
|
||||
);
|
||||
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.fixHint).toContain("wrapper");
|
||||
});
|
||||
|
||||
it("warns when drop-shadow is applied by CSS directly to hf-texture-text", async () => {
|
||||
const html = baseHtml(
|
||||
'<div class="hf-texture-text hf-texture-lava">TEXT</div>',
|
||||
`${textureCss}.hf-texture-text { filter: drop-shadow(1px 2px 1px black); }`,
|
||||
);
|
||||
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.selector).toBe(".hf-texture-text");
|
||||
});
|
||||
|
||||
it("warns when drop-shadow targets a material class before the mask rule is declared", async () => {
|
||||
const html = baseHtml(
|
||||
'<div class="hf-texture-text hf-texture-lava">TEXT</div>',
|
||||
`.hf-texture-lava { filter: drop-shadow(1px 2px 1px black); }
|
||||
${textureCss}`,
|
||||
);
|
||||
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.selector).toBe(".hf-texture-lava");
|
||||
});
|
||||
|
||||
it("warns when drop-shadow targets another class on the textured text element", async () => {
|
||||
const html = baseHtml(
|
||||
'<div class="hf-texture-text hf-texture-lava headline">TEXT</div>',
|
||||
`${textureCss}.headline { filter: drop-shadow(1px 2px 1px black); }`,
|
||||
);
|
||||
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
|
||||
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.selector).toBe(".headline");
|
||||
});
|
||||
|
||||
it("does not warn when another-class drop-shadow selector needs an unmatched ancestor", async () => {
|
||||
const html = baseHtml(
|
||||
'<div class="hf-texture-text hf-texture-lava headline">TEXT</div>',
|
||||
`${textureCss}.card .headline { filter: drop-shadow(1px 2px 1px black); }`,
|
||||
);
|
||||
|
||||
const result = await lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((item) => item.code === "texture_drop_shadow_on_text");
|
||||
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import postcss from "postcss";
|
||||
import type { LintContext, HyperframeLintFinding, OpenTag } from "../context";
|
||||
import { readAttr, truncateSnippet } from "../utils";
|
||||
|
||||
const TEXTURE_BASE_CLASS = "hf-texture-text";
|
||||
const TEXTURE_CLASS_PREFIX = "hf-texture-";
|
||||
|
||||
type DropShadowRule = {
|
||||
selector: string;
|
||||
directlyTargetsTexture: boolean;
|
||||
};
|
||||
|
||||
function classNames(tag: OpenTag): string[] {
|
||||
return (readAttr(tag.raw, "class") ?? "").split(/\s+/).filter(Boolean);
|
||||
}
|
||||
|
||||
function isTextureMaterialClass(className: string): boolean {
|
||||
return className.startsWith(TEXTURE_CLASS_PREFIX) && className !== TEXTURE_BASE_CLASS;
|
||||
}
|
||||
|
||||
function hasInlineMaskImage(tag: OpenTag): boolean {
|
||||
const style = readAttr(tag.raw, "style") ?? "";
|
||||
return /\b(?:-webkit-)?mask-image\s*:/i.test(style);
|
||||
}
|
||||
|
||||
function hasInlineDropShadow(tag: OpenTag): boolean {
|
||||
const style = readAttr(tag.raw, "style") ?? "";
|
||||
return /\bfilter\s*:\s*[^;]*\bdrop-shadow\s*\(/i.test(style);
|
||||
}
|
||||
|
||||
function classNamesInSelector(selector: string): string[] {
|
||||
const classes = new Set<string>();
|
||||
const pattern = /\.([A-Za-z_][\w-]*)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(selector)) !== null) {
|
||||
const className = match[1];
|
||||
if (!className) continue;
|
||||
classes.add(className);
|
||||
}
|
||||
return [...classes];
|
||||
}
|
||||
|
||||
function textureClassesInSelector(selector: string): string[] {
|
||||
return classNamesInSelector(selector).filter(isTextureMaterialClass);
|
||||
}
|
||||
|
||||
function simpleSelectorMatchesTag(selector: string, tag: OpenTag, tagClasses: string[]): boolean {
|
||||
const trimmed = selector.trim();
|
||||
const simpleSelectorPattern = /^(?:[A-Za-z][\w-]*)?(?:\.[A-Za-z_][\w-]*)+$/;
|
||||
if (!simpleSelectorPattern.test(trimmed)) return false;
|
||||
|
||||
const typeMatch = /^([A-Za-z][\w-]*)/.exec(trimmed);
|
||||
if (typeMatch && typeMatch[1]!.toLowerCase() !== tag.name) return false;
|
||||
|
||||
const selectorClasses = classNamesInSelector(trimmed);
|
||||
return (
|
||||
selectorClasses.length > 0 &&
|
||||
selectorClasses.every((className) => tagClasses.includes(className))
|
||||
);
|
||||
}
|
||||
|
||||
function collectTextureCss(styles: LintContext["styles"]): {
|
||||
definedTextureClasses: Set<string>;
|
||||
dropShadowRules: DropShadowRule[];
|
||||
} {
|
||||
const definedTextureClasses = new Set<string>();
|
||||
const dropShadowRules: DropShadowRule[] = [];
|
||||
const roots: postcss.Root[] = [];
|
||||
|
||||
for (const style of styles) {
|
||||
let root: postcss.Root;
|
||||
try {
|
||||
root = postcss.parse(style.content);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
roots.push(root);
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
root.walkRules((rule) => {
|
||||
const selectors = rule.selectors ?? [];
|
||||
let hasMaskImage = false;
|
||||
|
||||
for (const node of rule.nodes ?? []) {
|
||||
if (node.type !== "decl") continue;
|
||||
const prop = node.prop.toLowerCase();
|
||||
if (prop === "mask-image" || prop === "-webkit-mask-image") hasMaskImage = true;
|
||||
}
|
||||
|
||||
if (hasMaskImage) {
|
||||
for (const selector of selectors) {
|
||||
for (const className of textureClassesInSelector(selector)) {
|
||||
definedTextureClasses.add(className);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const root of roots) {
|
||||
// fallow-ignore-next-line complexity
|
||||
root.walkRules((rule) => {
|
||||
const selectors = rule.selectors ?? [];
|
||||
let hasDropShadow = false;
|
||||
|
||||
for (const node of rule.nodes ?? []) {
|
||||
if (node.type !== "decl") continue;
|
||||
if (node.prop.toLowerCase() === "filter" && /\bdrop-shadow\s*\(/i.test(node.value)) {
|
||||
hasDropShadow = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasDropShadow) {
|
||||
for (const selector of selectors) {
|
||||
const targetsBaseClass = /\.hf-texture-text\b/.test(selector);
|
||||
const targetsDefinedTextureClass = textureClassesInSelector(selector).some((className) =>
|
||||
definedTextureClasses.has(className),
|
||||
);
|
||||
dropShadowRules.push({
|
||||
selector,
|
||||
directlyTargetsTexture: targetsBaseClass || targetsDefinedTextureClass,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { definedTextureClasses, dropShadowRules };
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export const textureRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
|
||||
({ tags, styles }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const { definedTextureClasses, dropShadowRules } = collectTextureCss(styles);
|
||||
|
||||
for (const { selector, directlyTargetsTexture } of dropShadowRules) {
|
||||
if (!directlyTargetsTexture) continue;
|
||||
findings.push({
|
||||
code: "texture_drop_shadow_on_text",
|
||||
severity: "warning",
|
||||
message: "Drop shadow is applied directly to textured text.",
|
||||
selector,
|
||||
fixHint:
|
||||
"Wrap the textured text and apply `filter: drop-shadow(...)` to the wrapper, not the `hf-texture-text` element.",
|
||||
});
|
||||
}
|
||||
|
||||
for (const tag of tags) {
|
||||
if (tag.name === "style" || tag.name === "script") continue;
|
||||
|
||||
const classes = classNames(tag);
|
||||
if (classes.length === 0) continue;
|
||||
|
||||
const hasBaseClass = classes.includes(TEXTURE_BASE_CLASS);
|
||||
const textureClasses = classes.filter(isTextureMaterialClass);
|
||||
|
||||
if (textureClasses.length > 0 && !hasBaseClass) {
|
||||
findings.push({
|
||||
code: "texture_class_missing_base",
|
||||
severity: "warning",
|
||||
message: `Texture material class \`${textureClasses[0]}\` is used without \`${TEXTURE_BASE_CLASS}\`.`,
|
||||
elementId: readAttr(tag.raw, "id") || undefined,
|
||||
fixHint: `Add \`${TEXTURE_BASE_CLASS}\` alongside the material class, for example \`class="${TEXTURE_BASE_CLASS} ${textureClasses[0]}"\`.`,
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
|
||||
if (hasBaseClass && textureClasses.length === 0 && !hasInlineMaskImage(tag)) {
|
||||
findings.push({
|
||||
code: "texture_text_missing_mask",
|
||||
severity: "warning",
|
||||
message: `\`${TEXTURE_BASE_CLASS}\` is used without a texture material class or custom mask image.`,
|
||||
elementId: readAttr(tag.raw, "id") || undefined,
|
||||
fixHint:
|
||||
"Add a material class such as `hf-texture-lava`, or set `mask-image` and `-webkit-mask-image` on the element.",
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
|
||||
for (const textureClass of textureClasses) {
|
||||
if (definedTextureClasses.has(textureClass)) continue;
|
||||
findings.push({
|
||||
code: "texture_class_unknown",
|
||||
severity: "error",
|
||||
message: `Texture material class \`${textureClass}\` is not defined by local CSS.`,
|
||||
elementId: readAttr(tag.raw, "id") || undefined,
|
||||
fixHint:
|
||||
"Paste the Texture Mask Text component `<style>...</style>` block into the composition, or fix the texture class typo.",
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
|
||||
if (hasBaseClass) {
|
||||
for (const rule of dropShadowRules) {
|
||||
if (rule.directlyTargetsTexture) continue;
|
||||
if (!simpleSelectorMatchesTag(rule.selector, tag, classes)) continue;
|
||||
findings.push({
|
||||
code: "texture_drop_shadow_on_text",
|
||||
severity: "warning",
|
||||
message: "Drop shadow is applied directly to textured text.",
|
||||
selector: rule.selector,
|
||||
elementId: readAttr(tag.raw, "id") || undefined,
|
||||
fixHint:
|
||||
"Wrap the textured text and apply `filter: drop-shadow(...)` to the wrapper, not the `hf-texture-text` element.",
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (hasBaseClass && hasInlineDropShadow(tag)) {
|
||||
findings.push({
|
||||
code: "texture_drop_shadow_on_text",
|
||||
severity: "warning",
|
||||
message: "Drop shadow is applied directly to textured text.",
|
||||
elementId: readAttr(tag.raw, "id") || undefined,
|
||||
fixHint:
|
||||
"Wrap the textured text and apply `filter: drop-shadow(...)` to the wrapper, not the `hf-texture-text` element.",
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Pure render-gate decision — no Node.js dependencies, so it is safe to import
|
||||
* from the browser entry alongside the rule engine.
|
||||
*/
|
||||
export function shouldBlockRender(
|
||||
strictErrors: boolean,
|
||||
strictAll: boolean,
|
||||
totalErrors: number,
|
||||
totalWarnings: number,
|
||||
): boolean {
|
||||
return (strictErrors && totalErrors > 0) || (strictAll && (totalErrors > 0 || totalWarnings > 0));
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { lintProject } from "./project.js";
|
||||
|
||||
let dirs: string[] = [];
|
||||
afterEach(() => {
|
||||
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
||||
dirs = [];
|
||||
});
|
||||
|
||||
const INDEX = `<html><body>
|
||||
<div id="root" data-composition-id="main" data-start="0" data-duration="2" data-width="640" data-height="360"></div>
|
||||
<script>window.__timelines = { main: { paused: true } };</script>
|
||||
</body></html>`;
|
||||
|
||||
async function lintWithFragment(name: string, html: string) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-snippet-lint-"));
|
||||
dirs.push(dir);
|
||||
writeFileSync(join(dir, "index.html"), INDEX);
|
||||
mkdirSync(join(dir, "compositions"), { recursive: true });
|
||||
writeFileSync(join(dir, "compositions", name), html);
|
||||
const result = await lintProject(dir);
|
||||
return result.results.filter((r) => r.file.includes(name.replace(".html", "")));
|
||||
}
|
||||
|
||||
describe("snippet fragment exemption", () => {
|
||||
it("skips composition-root rules for files whose ROOT carries data-hf-snippet", async () => {
|
||||
const findings = await lintWithFragment(
|
||||
"frag.html",
|
||||
'<div id="frag" data-hf-snippet="" data-figma-id="1:1" style="width: 10px"></div>\n',
|
||||
);
|
||||
expect(findings).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("still lints a composition that merely CONTAINS snippet markup", async () => {
|
||||
const results = await lintWithFragment(
|
||||
"scene.html",
|
||||
'<div id="scene"><div data-hf-snippet="" data-figma-id="1:1"></div></div>\n',
|
||||
);
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.result.findings.some((f) => f.code === "root_missing_composition_id")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
export type HyperframeLintSeverity = "error" | "warning" | "info";
|
||||
|
||||
export type HyperframeLintFinding = {
|
||||
code: string;
|
||||
severity: HyperframeLintSeverity;
|
||||
message: string;
|
||||
file?: string;
|
||||
selector?: string;
|
||||
elementId?: string;
|
||||
fixHint?: string;
|
||||
snippet?: string;
|
||||
};
|
||||
|
||||
export type HyperframeLintResult = {
|
||||
ok: boolean;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
infoCount: number;
|
||||
findings: HyperframeLintFinding[];
|
||||
};
|
||||
|
||||
export type HyperframeLinterOptions = {
|
||||
filePath?: string;
|
||||
isSubComposition?: boolean;
|
||||
externalStyles?: Array<{ href: string; content: string }>;
|
||||
/**
|
||||
* Set to `true` when linting compositions destined for distributed / Lambda
|
||||
* rendering, where system-font capture (`allowSystemFontCapture`) is
|
||||
* disabled. When `true`, the `system_font_will_alias` rule is elevated from
|
||||
* `"info"` to `"warning"` because the alias substitution will NOT happen at
|
||||
* render time — the font will silently fall back to whatever the OS provides.
|
||||
*/
|
||||
distributed?: boolean;
|
||||
};
|
||||
|
||||
// A rule is a function: receives parsed context, returns zero or more findings.
|
||||
// Rules may be async (e.g. when lazy-loading heavy dependencies like recast).
|
||||
export type LintRule<TContext> = (
|
||||
ctx: TContext,
|
||||
) => HyperframeLintFinding[] | Promise<HyperframeLintFinding[]>;
|
||||
@@ -0,0 +1,379 @@
|
||||
// Shared types, regex constants, and utility functions used across lint rule modules.
|
||||
// Nothing in this file should emit findings — it only parses and extracts.
|
||||
|
||||
import { Parser } from "htmlparser2";
|
||||
|
||||
export type OpenTag = {
|
||||
raw: string;
|
||||
name: string;
|
||||
attrs: string;
|
||||
index: number;
|
||||
closeIndex?: number;
|
||||
endIndex?: number;
|
||||
};
|
||||
|
||||
export type ExtractedBlock = {
|
||||
attrs: string;
|
||||
content: string;
|
||||
raw: string;
|
||||
index: number;
|
||||
};
|
||||
|
||||
const COMPOSITION_ID_IN_CSS_PATTERN = /\[data-composition-id=["']([^"']+)["']\]/g;
|
||||
export const TIMELINE_REGISTRY_INIT_PATTERN =
|
||||
/window\.__timelines\s*=\s*window\.__timelines\s*\|\|\s*\{\}|window\.__timelines\s*=\s*\{\}|window\.__timelines\s*\?\?=\s*\{\}/i;
|
||||
// Object-literal registration that assigns at least one `key: value` entry inline,
|
||||
// e.g. `window.__timelines = { main: tl }` or `window.__timelines = { "comp-1": tl }`.
|
||||
// Distinct from the empty-init form (`= {}`) — requires a key followed by `:`.
|
||||
export const TIMELINE_REGISTRY_OBJECT_LITERAL_PATTERN =
|
||||
/window\.__timelines\s*=\s*\{\s*(?:["'][^"']+["']|[A-Za-z_$][\w$]*)\s*:/i;
|
||||
export const TIMELINE_REGISTRY_ASSIGN_PATTERN =
|
||||
/window\.__timelines(?:\[[^\]]+\]|\.[A-Za-z_$][\w$]*)\s*=/i;
|
||||
// The bracket branch accepts either a quoted string key (`["root"]`) or a
|
||||
// computed key (`[spec.id]`, `[id]`) — a bare-identifier-only bracket branch
|
||||
// missed `window.__timelines[spec.id] = tl`, a pattern the shipped
|
||||
// code-particle-assemble/code-3d-extrude registry blocks actually use,
|
||||
// making gsap_timeline_not_registered false-fire on correctly registered
|
||||
// timelines. The computed-key alternative is deliberately non-capturing:
|
||||
// its text isn't a literal composition id, so callers reading group 1/2
|
||||
// (readRegisteredTimelineCompositionId) must keep falling back to null for it.
|
||||
export const WINDOW_TIMELINE_ASSIGN_PATTERN =
|
||||
/window\.__timelines(?:\[\s*(?:["']([^"']+)["']|[A-Za-z_$][\w$.]*)\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*=\s*([A-Za-z_$][\w$]*)/i;
|
||||
export const INVALID_SCRIPT_CLOSE_PATTERN = /<script[^>]*>[\s\S]*?<\s*\/\s*script(?!>)/i;
|
||||
|
||||
const TIMELINE_REGISTRY_KEY_PATTERN =
|
||||
/window\.__timelines(?:\[\s*["']([^"']+)["']\s*\]|\.\s*([A-Za-z_$][\w$]*))\s*=/g;
|
||||
|
||||
// The `window.__timelines = { ... }` object-literal body (group 1), captured so its
|
||||
// `key: value` entries can be scanned for registered keys.
|
||||
const TIMELINE_REGISTRY_OBJECT_BODY_PATTERN = /window\.__timelines\s*=\s*\{([\s\S]*?)\}/i;
|
||||
// A single object-literal entry whose value is an identifier (real timeline registration),
|
||||
// e.g. `main: tl` or `"comp-1": tl`. Captures the key in group 1 (quoted) or 2 (bare).
|
||||
const TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN =
|
||||
/(?:["']([^"']+)["']|([A-Za-z_$][\w$]*))\s*:\s*[A-Za-z_$][\w$]*/g;
|
||||
|
||||
export function parseHtmlStructure(source: string): {
|
||||
tags: OpenTag[];
|
||||
scripts: ExtractedBlock[];
|
||||
styles: ExtractedBlock[];
|
||||
} {
|
||||
const tags: OpenTag[] = [];
|
||||
const blocks = { script: [] as ExtractedBlock[], style: [] as ExtractedBlock[] };
|
||||
const openTagsByName = new Map<string, OpenTag[]>();
|
||||
const openBlocks: Array<{
|
||||
name: "script" | "style";
|
||||
attrs: string;
|
||||
contentStart: number;
|
||||
index: number;
|
||||
}> = [];
|
||||
const parser: Parser = new Parser(
|
||||
{
|
||||
onopentag(name) {
|
||||
const index = parser.startIndex;
|
||||
const raw = source.slice(index, parser.endIndex + 1);
|
||||
const attrs = raw.slice(name.length + 1, -1).replace(/\s*\/$/, "");
|
||||
const tag = { raw, name, attrs, index };
|
||||
tags.push(tag);
|
||||
const sameNameStack = openTagsByName.get(name) ?? [];
|
||||
sameNameStack.push(tag);
|
||||
openTagsByName.set(name, sameNameStack);
|
||||
if (name === "script" || name === "style") {
|
||||
openBlocks.push({ name, attrs, contentStart: parser.endIndex + 1, index });
|
||||
}
|
||||
},
|
||||
onclosetag(name) {
|
||||
const tag = openTagsByName.get(name)?.pop();
|
||||
if (tag) {
|
||||
tag.closeIndex = parser.startIndex;
|
||||
tag.endIndex = parser.endIndex + 1;
|
||||
}
|
||||
if (name !== "script" && name !== "style") return;
|
||||
const block = openBlocks.pop();
|
||||
if (!block || block.name !== name) return;
|
||||
blocks[name].push({
|
||||
attrs: block.attrs,
|
||||
content: source.slice(block.contentStart, parser.startIndex),
|
||||
raw: source.slice(block.index, parser.endIndex + 1),
|
||||
index: block.index,
|
||||
});
|
||||
},
|
||||
},
|
||||
{ decodeEntities: false, lowerCaseAttributeNames: false, lowerCaseTags: true },
|
||||
);
|
||||
parser.end(source);
|
||||
|
||||
return { tags, scripts: blocks.script, styles: blocks.style };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the `<html>` open tag in the source. Distinct from `findRootTag`,
|
||||
* which returns the first element inside `<body>` — the latter is "the
|
||||
* composition's visible root", whereas `<html>` is where document-level
|
||||
* metadata like `data-composition-variables` lives.
|
||||
*/
|
||||
export function findHtmlTag(tags: readonly OpenTag[]): OpenTag | null {
|
||||
return tags.find((tag) => tag.name === "html") ?? null;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function findRootTag(source: string, parsedTags?: readonly OpenTag[]): OpenTag | null {
|
||||
const tags = parsedTags ?? parseHtmlStructure(source).tags;
|
||||
const bodyTag = tags.find((tag) => tag.name === "body");
|
||||
if (
|
||||
bodyTag &&
|
||||
(readAttr(bodyTag.raw, "data-composition-id") ||
|
||||
readAttr(bodyTag.raw, "data-width") ||
|
||||
readAttr(bodyTag.raw, "data-height"))
|
||||
) {
|
||||
return bodyTag;
|
||||
}
|
||||
const bodyStart = bodyTag ? bodyTag.index + bodyTag.raw.length : 0;
|
||||
const bodyEnd = bodyTag?.closeIndex ?? source.length;
|
||||
const bodyTags = tags.filter((tag) => tag.index >= bodyStart && tag.index < bodyEnd);
|
||||
// Set when a leading <svg> defs block is skipped (see below) — extractOpenTags
|
||||
// is a flat, nesting-unaware scan, so without this the very next tag it
|
||||
// returns is the svg's own nested child (<defs>, <filter>, ...), not the
|
||||
// sibling that follows the closed </svg>.
|
||||
let skipBefore = -1;
|
||||
for (const tag of bodyTags) {
|
||||
if (tag.index < skipBefore) continue;
|
||||
if (["script", "style", "meta", "link", "title"].includes(tag.name)) continue;
|
||||
// A leading <svg> block (icon/gradient/filter <defs>, referenced by url(#id)
|
||||
// from elsewhere in the document) is shared visual plumbing, not the
|
||||
// composition root — two independent reports of this being mistaken for
|
||||
// the root, manufacturing root_missing_composition_id/root_missing_dimensions
|
||||
// on an otherwise-correct composition. Only skip it when it carries none of
|
||||
// the composition markers itself, so an intentionally SVG-rooted composition
|
||||
// (data-composition-id/data-width/data-height directly on the <svg>) is
|
||||
// still eligible as the root.
|
||||
if (
|
||||
tag.name === "svg" &&
|
||||
!readAttr(tag.raw, "data-composition-id") &&
|
||||
!readAttr(tag.raw, "data-width") &&
|
||||
!readAttr(tag.raw, "data-height")
|
||||
) {
|
||||
// No closing tag found (malformed HTML) — skip everything rather than
|
||||
// risk returning one of the svg's own children as the root.
|
||||
skipBefore = tag.endIndex ?? Infinity;
|
||||
continue;
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readAttr(tagSource: string, attr: string): string | null {
|
||||
if (!tagSource) return null;
|
||||
const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
// `(?<![\w-])` not `\b`: a plain `\b` boundary treats the hyphen in a longer
|
||||
// attribute as a word break, so reading "id" would wrongly match the trailing
|
||||
// `id="…"` inside `data-hf-id="…"` (and "width" inside `data-width`, etc.).
|
||||
// The lookbehind requires the match to start a fresh attribute name.
|
||||
const match = tagSource.match(new RegExp(`(?<![\\w-])${escaped}\\s*=\\s*["']([^"']+)["']`, "i"));
|
||||
return match?.[1] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an attribute that may legitimately contain the opposite quote
|
||||
* character. `readAttr` truncates `data-variable-values='{"title":"Hello"}'`
|
||||
* at the first internal `"` because its `[^"']+` class excludes both quote
|
||||
* types. This variant alternates: a double-quoted value never contains an
|
||||
* unescaped `"`, and a single-quoted value never contains an unescaped `'`,
|
||||
* so each branch can use a quote-specific class.
|
||||
*
|
||||
* Use for attributes whose values are JSON or otherwise carry the opposite
|
||||
* quote character. Existing single-token attributes (`id`, `class`, etc.)
|
||||
* stick with `readAttr` for consistency with the rest of the lint code.
|
||||
*/
|
||||
export function readJsonAttr(tagSource: string, attr: string): string | null {
|
||||
if (!tagSource) return null;
|
||||
const escaped = attr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
// See readAttr: `(?<![\w-])` prevents a short name from matching the tail of a
|
||||
// longer hyphenated attribute (e.g. "id" inside `data-hf-id`).
|
||||
const match = tagSource.match(
|
||||
new RegExp(`(?<![\\w-])${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i"),
|
||||
);
|
||||
if (!match) return null;
|
||||
return match[1] ?? match[2] ?? null;
|
||||
}
|
||||
|
||||
export function collectCompositionIds(tags: OpenTag[]): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const tag of tags) {
|
||||
const compId = readAttr(tag.raw, "data-composition-id");
|
||||
if (compId) ids.add(compId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function extractCompositionIdsFromCss(css: string): string[] {
|
||||
const ids = new Set<string>();
|
||||
let match: RegExpExecArray | null;
|
||||
const pattern = new RegExp(
|
||||
COMPOSITION_ID_IN_CSS_PATTERN.source,
|
||||
COMPOSITION_ID_IN_CSS_PATTERN.flags,
|
||||
);
|
||||
while ((match = pattern.exec(css)) !== null) {
|
||||
if (match[1]) ids.add(match[1]);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
export function extractTimelineRegistryKeys(source: string): string[] {
|
||||
const keys = new Set<string>();
|
||||
let match: RegExpExecArray | null;
|
||||
const pattern = new RegExp(
|
||||
TIMELINE_REGISTRY_KEY_PATTERN.source,
|
||||
TIMELINE_REGISTRY_KEY_PATTERN.flags,
|
||||
);
|
||||
while ((match = pattern.exec(source)) !== null) {
|
||||
const key = match[1] ?? match[2];
|
||||
if (key) keys.add(key);
|
||||
}
|
||||
const objectBody = TIMELINE_REGISTRY_OBJECT_BODY_PATTERN.exec(source)?.[1];
|
||||
if (objectBody) {
|
||||
const entryPattern = new RegExp(
|
||||
TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN.source,
|
||||
TIMELINE_REGISTRY_OBJECT_ENTRY_PATTERN.flags,
|
||||
);
|
||||
while ((match = entryPattern.exec(objectBody)) !== null) {
|
||||
const key = match[1] ?? match[2];
|
||||
if (key) keys.add(key);
|
||||
}
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
export function getInlineScriptSyntaxError(source: string): string | null {
|
||||
if (!source.trim()) return null;
|
||||
try {
|
||||
// eslint-disable-next-line no-new-func
|
||||
new Function(source);
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function stripJsComments(source: string): string {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
let quote: "'" | '"' | "`" | null = null;
|
||||
let escaped = false;
|
||||
|
||||
while (i < source.length) {
|
||||
const ch = source[i] ?? "";
|
||||
const next = source[i + 1] ?? "";
|
||||
|
||||
if (quote) {
|
||||
out += ch;
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (ch === "\\") {
|
||||
escaped = true;
|
||||
} else if (ch === quote) {
|
||||
quote = null;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'" || ch === '"' || ch === "`") {
|
||||
quote = ch;
|
||||
out += ch;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "/" && next === "/") {
|
||||
out += " ";
|
||||
i += 2;
|
||||
while (i < source.length && source[i] !== "\n" && source[i] !== "\r") {
|
||||
out += " ";
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "/" && next === "*") {
|
||||
out += " ";
|
||||
i += 2;
|
||||
while (i < source.length) {
|
||||
const blockCh = source[i] ?? "";
|
||||
const blockNext = source[i + 1] ?? "";
|
||||
if (blockCh === "*" && blockNext === "/") {
|
||||
out += " ";
|
||||
i += 2;
|
||||
break;
|
||||
}
|
||||
out += blockCh === "\n" || blockCh === "\r" ? blockCh : " ";
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
out += ch;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// One linear pass that drops every `<!-- … -->` region. Uses indexOf, not a
|
||||
// `/<!--[\s\S]*?-->/` regex: that pattern backtracks O(n²) on inputs with many
|
||||
// unterminated "<!--" (CodeQL js/polynomial-redos). An unterminated "<!--" with
|
||||
// no closing "-->" is kept verbatim, matching the prior regex's no-match behavior.
|
||||
function stripHtmlCommentsOnce(source: string): string {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
for (;;) {
|
||||
const start = source.indexOf("<!--", i);
|
||||
if (start < 0) return out + source.slice(i);
|
||||
const end = source.indexOf("-->", start + 4);
|
||||
if (end < 0) return out + source.slice(i);
|
||||
out += source.slice(i, start);
|
||||
i = end + 3;
|
||||
}
|
||||
}
|
||||
|
||||
// Strip HTML comments to a fixpoint. A single pass is not enough: deleting one
|
||||
// comment can splice adjacent markers into a fresh, complete <!-- … --> (e.g.
|
||||
// "<<!-- -->!-- … -->" → "<!-- … -->"), which would otherwise survive and let a
|
||||
// commented-out <template>/tag hijack the linter's tag scan.
|
||||
export function stripHtmlComments(source: string): string {
|
||||
let out = source;
|
||||
for (let prev = ""; prev !== out; ) {
|
||||
prev = out;
|
||||
out = stripHtmlCommentsOnce(out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function extractScriptTextsAndSrcs(scripts: ExtractedBlock[]): {
|
||||
texts: string[];
|
||||
srcs: string[];
|
||||
} {
|
||||
const texts = scripts.filter((s) => !/\bsrc\s*=/.test(s.attrs)).map((s) => s.content);
|
||||
const srcs = scripts.map((s) => readAttr(`<script ${s.attrs}>`, "src") || "").filter(Boolean);
|
||||
return { texts, srcs };
|
||||
}
|
||||
|
||||
export function isMediaTag(tagName: string): boolean {
|
||||
return tagName === "video" || tagName === "audio" || tagName === "img";
|
||||
}
|
||||
|
||||
// Whether any <style> block in the composition defines caption group/word
|
||||
// classes (`.caption-group`, `.caption_word`, etc.) — the signal several
|
||||
// caption-specific rules use to skip non-caption compositions entirely.
|
||||
export function hasCaptionStyles(styles: ExtractedBlock[]): boolean {
|
||||
return styles.some((s) => /\.caption[-_]?(?:group|word)/i.test(s.content));
|
||||
}
|
||||
|
||||
export function truncateSnippet(value: string, maxLength = 220): string | undefined {
|
||||
const normalized = value.replace(/\s+/g, " ").trim();
|
||||
if (!normalized) return undefined;
|
||||
if (normalized.length <= maxLength) return normalized;
|
||||
return `${normalized.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: { index: "src/index.ts" },
|
||||
format: ["esm"],
|
||||
outDir: "dist",
|
||||
target: "node22",
|
||||
platform: "node",
|
||||
bundle: true,
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
dts: true,
|
||||
},
|
||||
{
|
||||
// Browser-safe subset. platform: "browser" makes the build FAIL if any
|
||||
// node:* builtin sneaks into the rule engine — a compile-time guarantee
|
||||
// that @hyperframes/lint/browser stays client-side runnable.
|
||||
entry: { browser: "src/browser.ts" },
|
||||
format: ["esm"],
|
||||
outDir: "dist",
|
||||
target: "es2022",
|
||||
platform: "browser",
|
||||
bundle: true,
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
clean: false,
|
||||
dts: true,
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
environment: "jsdom",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user