Files
hyperframes/packages/lint/src/rules/composition.ts
T
Miguel Ángel afa7f292fb feat(lint): warn when <html dir> can blank render output (#1893)
* feat(lint): flag dir="rtl" on <html> as a confirmed silent render failure

Two independent reports diagnosed the same exact bug: dir="rtl" (or any
non-ltr value) on <html> renders correctly in preview/snapshot but
produces a fully blank/black video from render, with no other
lint/validate/inspect check catching it - output file size (far smaller
than expected) was the only tell for both reporters. Both independently
confirmed the same fix: drop dir from <html>, keep lang, and scope
direction: rtl to individual text-containing elements via CSS instead.

Could not empirically verify the render pipeline's own root cause in this
session (headless Chrome screenshot capture is unreliable in this
sandboxed environment - even a baseline, non-RTL capture timed out), so
this ships the safe, already-confirmed advisory rather than guessing at a
runtime fix. Both reporters explicitly asked for exactly this: "deserves
a lint rule or render-time warning."

* fix(lint): only flag valid non-ltr html dir values
2026-07-06 18:16:40 -04:00

915 lines
43 KiB
TypeScript

import type { LintContext, HyperframeLintFinding, ExtractedBlock } from "../context";
import {
findHtmlTag,
readAttr,
readJsonAttr,
stripJsComments,
truncateSnippet,
WINDOW_TIMELINE_ASSIGN_PATTERN,
} from "../utils";
import { COMPOSITION_VARIABLE_TYPES } from "@hyperframes/parsers/composition";
// Agent guidance thresholds: warning-only nudges for files/tracks that become hard
// to inspect and revise reliably in a single composition.
const MAX_COMPOSITION_LINES = 300;
const MAX_TIMED_ELEMENTS_PER_TRACK = 3;
const TRACK_DENSITY_EXEMPT_TAGS = new Set(["audio", "script", "style", "video"]);
// `parseFloat("0.1") + parseFloat("0.2") = 0.30000000000000004`. Sub-second
// authored adjacencies survive parse + add as a value a few ulps above the
// next clip's start; a strict `>` fires the overlap rule on adjacencies that
// are exact in the source HTML. 1μs sits ~11 orders of magnitude above the
// observed drift (worst ~2e-16s across every realistic decimal pair) and 4
// below one 60fps frame (~16.67ms), so this only ever swallows float slop.
const OVERLAP_EPSILON_SECONDS = 1e-6;
function countPhysicalLines(source: string): number {
if (source.length === 0) return 0;
const normalized = source.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const withoutFinalNewline = normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized;
return withoutFinalNewline.split("\n").length;
}
function countStructuralLines(source: string): number {
return countPhysicalLines(source.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "<style></style>"));
}
export function isRegistrySourceFile(filePath?: string): boolean {
if (!filePath) return false;
const normalized = filePath.replace(/\\/g, "/");
return /(?:^|\/)registry\/blocks\/([^/]+)\/\1\.html$/i.test(normalized);
}
export function isRegistryInstalledFile(rawSource: string): boolean {
return /^\s*<!--\s*hyperframes-registry-item:[^>]*-->/i.test(rawSource.slice(0, 512));
}
function isCompositionRootOrMount(rawTag: string): boolean {
return Boolean(
readAttr(rawTag, "data-composition-id") || readAttr(rawTag, "data-composition-src"),
);
}
// Asset references inside CSS `url(...)`/`url("...")`/`url('...')` functions.
// Returns the inner path without quotes; comments are stripped first so
// `/* url(foo) */` is ignored. Bare `url()` and `data:` are excluded by the
// rules that consume this — the helper just yields raw URL values.
function extractCssUrlReferences(css: string): string[] {
const out: string[] = [];
const noComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
const urlPattern = /\burl\(\s*(["']?)([^)"']+)\1\s*\)/g;
let m: RegExpExecArray | null;
while ((m = urlPattern.exec(noComments)) !== null) {
const raw = (m[2] ?? "").trim();
if (raw) out.push(raw);
}
return out;
}
// Top-level CSS selectors (comma-split) in a stylesheet, skipping at-rule headers
// (@media/@keyframes/...) and keyframe stops. Heuristic — the lint layer has no
// full CSS parser, and rules elsewhere in this file scan CSS the same way.
function extractCssSelectors(css: string): string[] {
const out: string[] = [];
const noComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
const ruleHeader = /([^{}]+)\{/g;
let m: RegExpExecArray | null;
while ((m = ruleHeader.exec(noComments)) !== null) {
const header = (m[1] ?? "").trim();
if (!header || header.startsWith("@")) continue;
for (const sel of header.split(",")) {
const s = sel.trim();
if (s) out.push(s);
}
}
return out;
}
// Class tokens in a selector's leftmost compound (before the first descendant /
// child / sibling combinator). `.frame .title` → ["frame"]; `.a.b > .c` → ["a","b"].
function leftmostCompoundClasses(selector: string): string[] {
const leftmost = selector.trim().split(/[\s>+~]+/)[0] ?? "";
return (leftmost.match(/\.([\w-]+)/g) ?? []).map((c) => c.slice(1));
}
// Distinct selectors across all <style> blocks whose leftmost compound keys off one
// of the root element's own classes — the ones that break under id-scoping.
function rootClassStyledSelectors(styles: ExtractedBlock[], rootClasses: string[]): string[] {
const offenders: string[] = [];
for (const style of styles) {
for (const selector of extractCssSelectors(style.content)) {
const hitsRoot = leftmostCompoundClasses(selector).some((c) => rootClasses.includes(c));
if (hitsRoot && !offenders.includes(selector)) offenders.push(selector);
}
}
return offenders;
}
export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// invalid_parent_traversal_in_asset_path — catches `../` traversal in src,
// href, inline-style url(), and <style> url() asset references on
// compositions. Sub-compositions live under compositions/ but are served
// with the project root as their base URL, so any `../`-traversing path
// climbs above the project root and 404s in Studio preview. Renders
// tolerate it because the server-side bundler rewrites `../foo` against
// each sub-composition's source path; the runtime now mirrors that fallback
// (see rewriteSubCompositionAssetPaths in runtime/compositionLoader.ts), but
// the authoring-time signal is still wrong — flag it at lint time so the
// baked path is plain root-relative and matches what the bundler emits.
//
// Mirrors the runtime fallback's surface: `[src]` / `[href]` attribute
// values, `[style]` inline url(), and `<style>` block url() references.
// Skips absolute URLs (http(s)://, //, data:, /-prefixed root-relative),
// hash anchors, and plain relative paths (`assets/x.mp4`) — only `../`
// traversal is flagged. Subsumes the older `../capture/`-specific rule.
// fallow-ignore-next-line complexity
({ tags, styles, rawSource, options }) => {
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
const offenders: string[] = [];
const collect = (value: string | null) => {
if (!value) return;
const trimmed = value.trim();
if (!trimmed.startsWith("../") && trimmed !== "..") return;
offenders.push(trimmed);
};
for (const tag of tags) {
collect(readAttr(tag.raw, "src"));
collect(readAttr(tag.raw, "href"));
// Use readJsonAttr for `style` — inline url('...') values contain the
// opposite quote, which readAttr's [^"']+ class would truncate.
const styleAttr = readJsonAttr(tag.raw, "style");
if (styleAttr) {
for (const url of extractCssUrlReferences(styleAttr)) collect(url);
}
}
for (const style of styles) {
for (const url of extractCssUrlReferences(style.content)) collect(url);
}
if (offenders.length === 0) return [];
// Group counts by leading path token (e.g. ../capture/, ../assets/, ../../assets/)
// so the message names the offending prefixes instead of a bare count.
const prefixCounts = new Map<string, number>();
for (const path of offenders) {
const prefix = path.match(/^(?:\.\.\/)+[^/]+\//)?.[0] ?? path;
prefixCounts.set(prefix, (prefixCounts.get(prefix) ?? 0) + 1);
}
const prefixSummary = Array.from(prefixCounts.entries())
.sort(([, a], [, b]) => b - a)
.map(([prefix, count]) => (count > 1 ? `${prefix} (${count})` : prefix))
.join(", ");
return [
{
code: "invalid_parent_traversal_in_asset_path",
severity: "error",
message:
`Found ${offenders.length} asset path(s) traversing above the project root with "../" ` +
`(${prefixSummary}). Renders rewrite this against each sub-composition's source path, but Studio preview and other live consumers resolve against the project root and 404.`,
fixHint:
'Use plain root-relative paths (e.g. "assets/...", "capture/...", "fonts/...") — compositions are served with the project root as their base URL, so paths must be root-relative, not relative to the compositions/ directory.',
},
];
},
// composition_file_too_large
({ rawSource, options }) => {
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
const lineCount = countStructuralLines(rawSource);
if (lineCount <= MAX_COMPOSITION_LINES) return [];
const splitTarget = options.isSubComposition
? "Split this sub-composition further into smaller .html files"
: "Split coherent scenes or layers into separate .html files under compositions/";
return [
{
code: "composition_file_too_large",
severity: "warning",
message: `This HTML composition file has ${lineCount} lines. Smaller sub-compositions are easier to read, iterate on, and diff.`,
fixHint: `${splitTarget}, then mount them from the parent with data-composition-src so each file stays small enough to inspect, revise, and validate independently.`,
},
];
},
// timeline_track_too_dense
// fallow-ignore-next-line complexity
({ tags, options }) => {
const trackCounts = new Map<string, number>();
for (const tag of tags) {
if (TRACK_DENSITY_EXEMPT_TAGS.has(tag.name)) continue;
if (isCompositionRootOrMount(tag.raw)) continue;
if (!readAttr(tag.raw, "data-start")) continue;
const track = readAttr(tag.raw, "data-track-index");
if (!track) continue;
trackCounts.set(track, (trackCounts.get(track) ?? 0) + 1);
}
const findings: HyperframeLintFinding[] = [];
for (const [track, count] of trackCounts) {
if (count <= MAX_TIMED_ELEMENTS_PER_TRACK) continue;
const splitTarget = options.isSubComposition
? "Move coherent scene groups into smaller .html files"
: "Move coherent scene groups into separate .html files under compositions/";
findings.push({
code: "timeline_track_too_dense",
severity: "warning",
message: `Track ${track} has ${count} timed elements in this HTML file. Smaller sub-compositions keep timelines easier to read, iterate on, and diff.`,
fixHint: `${splitTarget} and mount them from the parent with data-composition-src so the timeline stays easier to inspect, revise, and validate.`,
});
}
return findings;
},
// timed_element_missing_visibility_hidden
// fallow-ignore-next-line complexity
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
if (tag.name === "audio" || tag.name === "script" || tag.name === "style") continue;
if (!readAttr(tag.raw, "data-start")) continue;
if (readAttr(tag.raw, "data-composition-id")) continue;
if (readAttr(tag.raw, "data-composition-src")) continue;
const classAttr = readAttr(tag.raw, "class") || "";
const styleAttr = readAttr(tag.raw, "style") || "";
const hasClip = classAttr.split(/\s+/).includes("clip");
const hasHiddenStyle =
/visibility\s*:\s*hidden/i.test(styleAttr) || /opacity\s*:\s*0/i.test(styleAttr);
if (!hasClip && !hasHiddenStyle) {
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "timed_element_missing_visibility_hidden",
severity: "info",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has data-start but no class="clip", visibility:hidden, or opacity:0. Consider adding initial hidden state if the element should not be visible before its start time.`,
elementId,
fixHint:
'Add class="clip" (with CSS: .clip { visibility: hidden; }) or style="opacity:0" if the element should start hidden.',
snippet: truncateSnippet(tag.raw),
});
}
}
return findings;
},
// deprecated_data_layer + deprecated_data_end
// fallow-ignore-next-line complexity
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
if (readAttr(tag.raw, "data-layer") && !readAttr(tag.raw, "data-track-index")) {
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "deprecated_data_layer",
severity: "error",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses data-layer instead of data-track-index.`,
elementId,
fixHint: "Replace data-layer with data-track-index. The runtime reads data-track-index.",
snippet: truncateSnippet(tag.raw),
});
}
if (readAttr(tag.raw, "data-end") && !readAttr(tag.raw, "data-duration")) {
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "deprecated_data_end",
severity: "error",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses data-end without data-duration. Use data-duration in source HTML.`,
elementId,
fixHint:
"Replace data-end with data-duration. The compiler generates data-end from data-duration automatically.",
snippet: truncateSnippet(tag.raw),
});
}
}
return findings;
},
// split_data_attribute_selector
({ scripts, styles }) => {
const findings: HyperframeLintFinding[] = [];
const splitDataAttrSelectorPattern =
/\[data-composition-id=(["'])([^"'\]]+)\1\s+(data-[\w:-]+)=(["'])([^"'\]]*)\4\]/g;
const scan = (content: string) => {
splitDataAttrSelectorPattern.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = splitDataAttrSelectorPattern.exec(content)) !== null) {
const compId = match[2] ?? "";
const attrName = match[3] ?? "";
const attrValue = match[5] ?? "";
findings.push({
code: "split_data_attribute_selector",
severity: "error",
message:
`Selector "${match[0]}" combines two attributes inside one CSS attribute selector. ` +
"Browsers reject it, so GSAP timelines or querySelector calls will fail before registering.",
selector: match[0],
fixHint: `Use separate attribute selectors: [data-composition-id="${compId}"][${attrName}="${attrValue}"].`,
snippet: truncateSnippet(match[0]),
});
}
};
for (const style of styles) scan(style.content);
for (const script of scripts) scan(script.content);
return findings;
},
// template_literal_selector
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
const templateLiteralSelectorPattern =
/(?:querySelector|querySelectorAll)\s*\(\s*`[^`]*\$\{[^}]+\}[^`]*`\s*\)/g;
let tlMatch: RegExpExecArray | null;
while ((tlMatch = templateLiteralSelectorPattern.exec(script.content)) !== null) {
findings.push({
code: "template_literal_selector",
severity: "error",
message:
"querySelector uses a template literal variable (e.g. `${compId}`). " +
"The HTML bundler's CSS parser crashes on these. Use a hardcoded string instead.",
fixHint:
"Replace the template literal variable with a hardcoded string. The bundler's CSS parser cannot handle interpolated variables in script content.",
snippet: truncateSnippet(tlMatch[0]),
});
}
}
return findings;
},
// timed_element_missing_clip_class
// fallow-ignore-next-line complexity
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
const skipTags = new Set(["audio", "video", "script", "style", "template"]);
for (const tag of tags) {
if (skipTags.has(tag.name)) continue;
// Skip composition hosts
if (readAttr(tag.raw, "data-composition-id")) continue;
if (readAttr(tag.raw, "data-composition-src")) continue;
const hasStart = readAttr(tag.raw, "data-start") !== null;
const hasDuration = readAttr(tag.raw, "data-duration") !== null;
// data-track-index alone marks a layer container, not a time-bounded clip
if (!hasStart && !hasDuration) continue;
const classAttr = readAttr(tag.raw, "class") || "";
const hasClip = classAttr.split(/\s+/).includes("clip");
if (hasClip) continue;
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "timed_element_missing_clip_class",
severity: "error",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has timing attributes but no class="clip". The element will be visible for the entire composition instead of only during its scheduled time range.`,
elementId,
fixHint:
'Add class="clip" to the element. The HyperFrames runtime uses .clip to control visibility based on data-start/data-duration.',
snippet: truncateSnippet(tag.raw),
});
}
return findings;
},
// overlapping_clips_same_track
// fallow-ignore-next-line complexity
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
type ClipInfo = { start: number; end: number; elementId?: string; snippet: string };
const trackMap = new Map<string, ClipInfo[]>();
for (const tag of tags) {
const startStr = readAttr(tag.raw, "data-start");
const durationStr = readAttr(tag.raw, "data-duration");
const trackStr = readAttr(tag.raw, "data-track-index");
if (!startStr || !durationStr || !trackStr) continue;
const start = Number(startStr);
const duration = Number(durationStr);
const track = trackStr;
// Skip non-numeric (relative timing references like "intro-comp")
if (Number.isNaN(start) || Number.isNaN(duration)) continue;
const clips = trackMap.get(track) || [];
clips.push({
start,
end: start + duration,
elementId: readAttr(tag.raw, "id") || undefined,
snippet: truncateSnippet(tag.raw) || "",
});
trackMap.set(track, clips);
}
for (const [track, clips] of trackMap) {
clips.sort((a, b) => a.start - b.start);
for (let i = 0; i < clips.length - 1; i++) {
const current = clips[i];
const next = clips[i + 1];
if (!current || !next) continue;
if (current.end - next.start > OVERLAP_EPSILON_SECONDS) {
findings.push({
code: "overlapping_clips_same_track",
severity: "error",
message: `Track ${track}: clip ending at ${current.end}s overlaps with clip starting at ${next.start}s. Overlapping clips on the same track cause rendering conflicts.`,
fixHint:
"Adjust data-start or data-duration so clips on the same track do not overlap, or move one clip to a different data-track-index.",
});
}
}
}
return findings;
},
// root_composition_missing_data_start
({ rootTag, options }) => {
const findings: HyperframeLintFinding[] = [];
if (options.isSubComposition) return findings;
if (!rootTag) return findings;
const compId = readAttr(rootTag.raw, "data-composition-id");
if (!compId) return findings;
const hasStart = readAttr(rootTag.raw, "data-start") !== null;
if (!hasStart) {
findings.push({
code: "root_composition_missing_data_start",
severity: "error",
message: `Root composition "${compId}" is missing data-start. The runtime needs data-start="0" on the root element to begin playback.`,
fixHint: 'Add data-start="0" to the root composition element.',
snippet: truncateSnippet(rootTag.raw),
});
}
return findings;
},
// standalone_composition_wrapped_in_template
({ rawSource, options }) => {
const findings: HyperframeLintFinding[] = [];
if (options.isSubComposition) return findings;
const trimmed = rawSource.trimStart().toLowerCase();
if (trimmed.startsWith("<template")) {
findings.push({
code: "standalone_composition_wrapped_in_template",
severity: "error",
message:
"Root index.html is wrapped in a <template> tag. " +
"Only sub-compositions loaded via data-composition-src should use <template> wrappers. " +
"The runtime cannot play a standalone composition inside a template.",
fixHint:
"Remove the <template> wrapper. Use <!DOCTYPE html><html>...<div data-composition-id>...</div>...</html> instead.",
});
}
return findings;
},
// root_composition_missing_html_wrapper
({ rawSource, rootTag, options }) => {
const findings: HyperframeLintFinding[] = [];
if (options.isSubComposition) return findings;
const trimmed = rawSource.trimStart().toLowerCase();
// Compositions inside <template> are caught by standalone_composition_wrapped_in_template
if (trimmed.startsWith("<template")) return findings;
const hasDoctype = trimmed.startsWith("<!doctype") || trimmed.startsWith("<html");
const hasComposition = rawSource.includes("data-composition-id");
if (hasComposition && !hasDoctype) {
findings.push({
code: "root_composition_missing_html_wrapper",
severity: "error",
message:
"Composition starts with a bare element instead of a proper HTML document. " +
"An index.html that contains data-composition-id but no <!DOCTYPE html>, <html>, or <body> " +
"is a fragment — browsers quirks-mode it, the preview server cannot load it, and " +
"the bundler will fail to inject runtime scripts.",
fixHint:
'Wrap the composition in <!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>...</body></html>.',
snippet: rootTag ? truncateSnippet(rootTag.raw) : undefined,
});
}
return findings;
},
// requestanimationframe_in_composition
({ scripts, rawSource, options }) => {
if (isRegistrySourceFile(options.filePath) || isRegistryInstalledFile(rawSource)) return [];
const findings: HyperframeLintFinding[] = [];
for (const script of scripts) {
const stripped = stripJsComments(script.content);
if (/requestAnimationFrame\s*\(/.test(stripped)) {
findings.push({
code: "requestanimationframe_in_composition",
severity: "error",
message:
"`requestAnimationFrame` runs on wall-clock time, not the GSAP timeline. It will not sync with frame capture and may cause flickering or missed frames during rendering.",
fixHint:
"Use GSAP tweens or onUpdate callbacks instead of requestAnimationFrame for animation logic.",
snippet: truncateSnippet(script.content),
});
}
}
return findings;
},
// invalid_variable_values_json
// Host elements (`[data-composition-src]`) carry per-instance values via
// `data-variable-values`. The runtime swallows JSON errors silently and
// falls back to declared defaults, which masks typos. This rule surfaces
// the parse failure so authors notice before render time.
// fallow-ignore-next-line complexity
({ tags }) => {
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
const raw = readJsonAttr(tag.raw, "data-variable-values");
if (!raw) continue;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
const reason = err instanceof Error ? err.message : "unknown";
findings.push({
code: "invalid_variable_values_json",
severity: "error",
message: `data-variable-values is not valid JSON (${reason}).`,
fixHint:
'Wrap the attribute value in single quotes and the JSON keys/values in double quotes, e.g. data-variable-values=\'{"title":"Hello"}\'.',
elementId: readAttr(tag.raw, "id") || undefined,
snippet: truncateSnippet(tag.raw),
});
continue;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
findings.push({
code: "invalid_variable_values_json",
severity: "error",
message:
'data-variable-values must be a JSON object keyed by variable id (e.g. {"title":"Hello"}).',
fixHint:
"Replace the value with a JSON object whose keys are variable ids declared in the sub-composition's data-composition-variables.",
elementId: readAttr(tag.raw, "id") || undefined,
snippet: truncateSnippet(tag.raw),
});
}
}
return findings;
},
// invalid_composition_variables_declaration
// The runtime parses `data-composition-variables` and silently returns []
// on any structural problem. Surface JSON / shape failures so authors
// catch them at lint time rather than wondering why their `getVariables()`
// defaults aren't applied.
// fallow-ignore-next-line complexity
({ source }) => {
const htmlTag = findHtmlTag(source);
if (!htmlTag) return [];
const raw = readJsonAttr(htmlTag.raw, "data-composition-variables");
if (!raw) return [];
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
const reason = err instanceof Error ? err.message : "unknown";
return [
{
code: "invalid_composition_variables_declaration",
severity: "error",
message: `data-composition-variables is not valid JSON (${reason}).`,
fixHint:
'Provide a JSON array of variable declarations: data-composition-variables=\'[{"id":"title","type":"string","label":"Title","default":"Hello"}]\'.',
snippet: truncateSnippet(htmlTag.raw),
},
];
}
if (!Array.isArray(parsed)) {
return [
{
code: "invalid_composition_variables_declaration",
severity: "error",
message: "data-composition-variables must be a JSON array of variable declarations.",
fixHint:
'Wrap declarations in [] and give each an id, type, label, and default: \'[{"id":"title","type":"string","label":"Title","default":"Hello"}]\'.',
snippet: truncateSnippet(htmlTag.raw),
},
];
}
const findings: HyperframeLintFinding[] = [];
const knownTypes = new Set<string>(COMPOSITION_VARIABLE_TYPES);
for (let i = 0; i < parsed.length; i += 1) {
const entry = parsed[i];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
findings.push({
code: "invalid_composition_variables_declaration",
severity: "error",
message: `data-composition-variables entry [${i}] must be an object with id, type, label, and default.`,
snippet: truncateSnippet(htmlTag.raw),
});
continue;
}
const e = entry as Record<string, unknown>;
const missing: string[] = [];
if (typeof e.id !== "string") missing.push("id");
if (typeof e.type !== "string" || !knownTypes.has(e.type)) missing.push("type");
if (typeof e.label !== "string") missing.push("label");
if (!("default" in e)) missing.push("default");
if (missing.length > 0) {
findings.push({
code: "invalid_composition_variables_declaration",
severity: "error",
message: `data-composition-variables entry [${i}] is missing or has invalid: ${missing.join(", ")}. Type must be one of string, number, color, boolean, enum, font, image.`,
snippet: truncateSnippet(htmlTag.raw),
});
}
}
return findings;
},
// html_dir_attribute_breaks_render — valid non-LTR dir values on
// <html> renders correctly in preview/snapshot but produces a fully
// blank/black video from render, with no other lint/validate/inspect
// check catching it (output file size, far smaller than expected, is the
// only tell). Confirmed independently by two separate reports, both
// diagnosing the same exact trigger and the same fix: drop dir from
// <html>, keep lang, and scope `direction: rtl` to individual
// text-containing elements via CSS instead (text still bidi-shapes
// correctly). Advisory-only — this does not attempt to fix the render
// pipeline's own root cause (suspected to be a capture step that clips a
// fixed top-left-origin screenshot region, which RTL layout can shift the
// actual content away from), only surfaces the already-confirmed footgun
// before someone hits it blind.
({ source }) => {
const htmlTag = findHtmlTag(source);
if (!htmlTag) return [];
const dir = readAttr(htmlTag.raw, "dir");
if (!dir) return [];
const normalizedDir = dir.toLowerCase();
if (normalizedDir !== "rtl" && normalizedDir !== "auto") return [];
const scopedDirection = normalizedDir === "auto" ? 'dir="auto"' : `direction: ${normalizedDir}`;
return [
{
code: "html_dir_attribute_breaks_render",
severity: "error",
message: `<html dir="${dir}"> renders correctly in preview/snapshot but produces a fully blank/black video from render — a confirmed, silent failure.`,
fixHint: `Remove dir="${dir}" from <html>. Keep lang, and scope ${scopedDirection} to individual text-containing elements instead — text still shapes correctly via the browser's own bidi algorithm.`,
snippet: truncateSnippet(htmlTag.raw),
},
];
},
// subcomposition_blanks_before_host
// Warns when a full-bleed sub-composition slot ends before the host composition
// does, leaving the slot blank for the remainder (issue #1540). Scoped narrowly to
// the high-signal shape — a sole/dominant external mount starting at ~0 — so it
// stays silent on intentional short clips (an intro followed by other clips that
// carry the timeline forward).
// fallow-ignore-next-line complexity
({ tags, rootTag }) => {
if (!rootTag) return [];
const rootDuration = Number(readAttr(rootTag.raw, "data-duration"));
if (!Number.isFinite(rootDuration) || rootDuration <= 0) return [];
// Two independent knobs that happen to share a 0.5s magnitude. Tuned for
// real hosts (tens to hundreds of seconds); on a very short host (~6s) the
// EPSILON slack would let a ~10% blank tail pass unflagged — acceptable
// because the silent-blank trap this rule targets only matters at scale.
const EPSILON = 0.5; // seconds; tolerance for "ends/covers near the host end"
const START_TOLERANCE = 0.5; // seconds; "starts at the composition start"
const round3 = (n: number) => Math.round(n * 1000) / 1000;
// Timed children of the root. An element with data-start but no usable
// data-duration is treated as covering the tail (end = Infinity), so an
// unknown-length sibling suppresses the warning rather than triggering it.
const timed = tags
.filter((tag) => tag.index !== rootTag.index && readAttr(tag.raw, "data-start") !== null)
.map((tag) => {
const start = Number(readAttr(tag.raw, "data-start")) || 0;
const dur = Number(readAttr(tag.raw, "data-duration"));
const end = Number.isFinite(dur) && dur > 0 ? start + dur : Infinity;
return { tag, start, end };
});
// `tags` is a flat list (no nesting depth), so a timed element nested
// *inside* a candidate slot is treated as a tail-covering sibling rather
// than a descendant. Acceptable: external src mounts are empty by
// convention (content is loaded from the linked file), so the only
// false-negative path is rare and matches the flat-tag scope of the
// sibling rules in this file.
const tailCovered = (exceptIndex: number) =>
timed.some((t) => t.tag.index !== exceptIndex && t.end >= rootDuration - EPSILON);
const findings: HyperframeLintFinding[] = [];
for (const t of timed) {
if (readAttr(t.tag.raw, "data-composition-src") === null) continue; // external slot only
if (t.start > START_TOLERANCE) continue; // must start at the composition start
if (!Number.isFinite(t.end)) continue; // known, finite slot length
if (t.end >= rootDuration - EPSILON) continue; // already fills the host window
if (tailCovered(t.tag.index)) continue; // another clip covers the tail — not full-bleed
const elementId = readAttr(t.tag.raw, "id") || undefined;
const gap = round3(rootDuration - t.end);
findings.push({
code: "subcomposition_blanks_before_host",
severity: "warning",
message: `<${t.tag.name}${elementId ? ` id="${elementId}"` : ""}> sub-composition ends at ${round3(t.end)}s but the composition runs to ${round3(rootDuration)}s — its slot will be blank for ~${gap}s.`,
elementId,
fixHint: `data-duration is the slot's visible window. Set this sub-composition's data-duration to ${round3(rootDuration - t.start)} to fill the host window, or add another clip to cover the remaining ~${gap}s.`,
snippet: truncateSnippet(t.tag.raw),
});
}
return findings;
},
// subcomposition_root_styled_by_class
// A sub-composition's <style> is scoped at render time to
// `[data-composition-id="<id>"] <selector>` so scenes inlined into one document
// can't leak styles into each other. A rule whose LEFTMOST selector is the ROOT
// element's own class (e.g. `.frame { ... }` on the same element that carries
// data-composition-id) therefore becomes a DESCENDANT selector that can never
// match the root — the whole scene renders unstyled (tiny text top-left, images
// at natural size). lint/validate/inspect evaluate the file in isolation (no
// scoping) and Studio previews each scene in its own iframe (no scoping), so the
// break is invisible until the composited MP4 render. Style the root via `#root`
// (the scoper special-cases the root id) and descendants via plain selectors,
// like the registry blocks — the runtime already scopes each scene by id, so a
// class namespace on the root is redundant.
({ rootTag, rootCompositionId, styles, options }) => {
if (!options.isSubComposition) return [];
if (isRegistrySourceFile(options.filePath)) return [];
if (!rootTag || !rootCompositionId) return [];
const rootClasses = (readAttr(rootTag.raw, "class") || "").split(/\s+/).filter(Boolean);
if (rootClasses.length === 0) return [];
const offenders = rootClassStyledSelectors(styles, rootClasses);
if (offenders.length === 0) return [];
const example = offenders.slice(0, 3).join(", ");
return [
{
code: "subcomposition_root_styled_by_class",
severity: "error",
message:
`Root element has class="${rootClasses.join(" ")}" and is styled by ${offenders.length} rule(s) keyed off that class (e.g. ${example}). ` +
`At render, every sub-composition rule is scoped to [data-composition-id="${rootCompositionId}"] <selector>, so a selector whose leftmost part is the ROOT's own class becomes a descendant selector that cannot match the root — the scene renders unstyled (tiny text top-left, full-size images). ` +
`lint/validate/inspect and Studio's per-frame iframe preview do not scope, so this passes every static check and looks correct in preview.`,
selector: example,
fixHint: `Give the root id="root" and style it with \`#root { ... }\` plus plain descendant selectors (\`.kicker\`, \`#hero\`) — the runtime already scopes each sub-composition by data-composition-id, so a class namespace on the root is redundant and breaks under scoping.`,
snippet: truncateSnippet(rootTag.raw),
},
];
},
// root_composition_missing_duration_source
//
// The render engine (packages/engine/src/services/frameCapture.ts) needs a
// positive window.__hf.duration to know how many frames to capture. GSAP
// timelines set this automatically. Non-GSAP runtimes (CSS, WAAPI, Lottie)
// are now auto-inferred by the runtime too (see
// packages/core/src/runtime/init.ts resolveAdapterDurationFloorSeconds and
// the adapters' getInferredDurationSeconds) — so data-duration is optional
// wherever the runtime can work it out on its own.
//
// This rule fires for cases where the total render length is not reliably
// determinable without an explicit data-duration:
// - No GSAP timeline AND no data-duration AND no non-GSAP animation
// signal at all (nothing for any adapter to discover — render fails).
// - Three.js used with no data-duration (no discoverable AnimationClip
// duration in this codebase's adapter — see adapters/three.ts).
// - Any infinite CSS animation-iteration-count with no data-duration,
// EVEN when a finite CSS animation is present alongside it. An unbounded
// animation makes the intended total length ambiguous — the runtime will
// infer a finite sibling's length if one exists, but that's a fallback,
// not a declaration of intent, so we still require data-duration here.
// (This is intentionally stricter than the runtime's own inference.)
// Purely finite CSS/WAAPI animations and Lottie are excluded — the runtime
// infers those unambiguously, so requiring data-duration there would be a
// false positive against the runtime's own auto-inference. Note lint is
// advisory by default (see shouldBlockRender) — it only blocks render under
// --strict/--strict-all — so a strict flag here nudges toward an explicit,
// guaranteed-correct value without failing renders that would succeed.
// fallow-ignore-next-line complexity
({ rootTag, scripts, styles, tags, options }) => {
if (options.isSubComposition) return [];
if (!rootTag) return [];
// Not every file linted as a "root" HTML document is a video composition
// — e.g. a slideshow demo.html mounts <hyperframes-player src="index.html">
// with no data-composition-id of its own. Nothing to capture there, so
// there's no duration contract to enforce.
if (readAttr(rootTag.raw, "data-composition-id") === null) return [];
if (readAttr(rootTag.raw, "data-duration") !== null) return [];
// Strip comments before scanning for signals — a commented-out
// `.animate(...)` call or `/* animation: spin 2s infinite; */` must not
// satisfy the "has a duration source" check, or the composition still
// fails at render with zero duration despite lint passing.
const allScriptTexts = scripts.map((s) => stripJsComments(s.content));
const hasGsapTimeline = allScriptTexts.some((t) => /gsap\.timeline\s*\(/.test(t));
const hasRegisteredTimeline = allScriptTexts.some((t) =>
WINDOW_TIMELINE_ASSIGN_PATTERN.test(t),
);
// A GSAP timeline drives duration via window.__timelines regardless of
// data-duration — nothing to flag once one is registered.
if (hasGsapTimeline && hasRegisteredTimeline) return [];
const allCss = styles.map((s) => s.content).join("\n");
const allInlineStyles = tags.map((t) => readAttr(t.raw, "style") || "").join("\n");
const combinedCss = `${allCss}\n${allInlineStyles}`.replace(/\/\*[\s\S]*?\*\//g, "");
const usesLottie =
tags.some((t) => readAttr(t.raw, "data-lottie-src") !== null) ||
allScriptTexts.some((t) => /lottie\.(loadAnimation)\b|__hfLottie\b/.test(t));
const usesThree = allScriptTexts.some((t) => /\bTHREE\./.test(t));
// `.animate([...], ...)` catches the array-literal keyframes form;
// `.animate({...}, ...)` catches the object-literal (PropertyIndexedKeyframes)
// form; `.animate(someVar, ...)` catches keyframes built up in a variable
// first.
const usesWaapi = allScriptTexts.some((t) => /\.animate\s*\(\s*[[{$A-Za-z_]/.test(t));
const hasCssAnimationName = /\banimation(?:-name)?\s*:/.test(combinedCss);
const hasInfiniteCssAnimation =
/\banimation(?:-iteration-count)?\s*:[^;{}]*(?<![\w-])infinite(?![\w-])/.test(combinedCss);
const hasAnyNonGsapSignal = usesLottie || usesThree || usesWaapi || hasCssAnimationName;
if (!hasAnyNonGsapSignal) {
// No GSAP timeline, no data-duration, and nothing for any adapter to
// discover — the composition has no source of truth for duration at
// all. This is the exact shape of the 27K "zero duration" render
// failures this rule exists to catch before render time.
return [
{
code: "root_composition_missing_duration_source",
severity: "error",
message:
"Root composition has no data-duration, no GSAP timeline, and no CSS/WAAPI/Lottie/Three.js " +
"animation for the runtime to infer a duration from. The render engine cannot determine " +
'how long to capture and will fail with "Composition has zero duration".',
fixHint:
'Add data-duration="<seconds>" to the root element, or add a paused GSAP timeline registered ' +
"on window.__timelines.",
snippet: truncateSnippet(rootTag.raw),
},
];
}
if (usesThree) {
// No AnimationMixer/AnimationClip discovery in the three.js adapter
// today (see adapters/three.ts) — genuinely not inferable.
return [
{
code: "root_composition_missing_duration_source",
severity: "error",
message:
"Root composition uses Three.js with no data-duration. The runtime cannot discover a " +
"Three.js scene's duration automatically (no AnimationClip/AnimationMixer inspection) — " +
'render will fail with "Composition has zero duration".',
fixHint: 'Add data-duration="<seconds>" to the root element.',
snippet: truncateSnippet(rootTag.raw),
},
];
}
if (hasInfiniteCssAnimation && !usesLottie && !usesWaapi) {
// An infinite/unbounded CSS animation makes the intended total length
// ambiguous, so we require an explicit data-duration even when a finite
// CSS animation is present alongside it. This is deliberately stricter
// than the runtime's own inference: the CSS adapter's
// getInferredDurationSeconds (see adapters/css.ts) returns the longest
// finite animation end-time when one exists (so a finite sibling would
// render at that length) and null when every animation is unbounded (so
// a render with no finite source fails outright). Either way the author
// hasn't declared how long the video should be — a decorative infinite
// spinner next to a 3s fade doesn't tell us the clip is meant to be 3s
// — so we flag it and let them state intent. The message stays honest
// about both outcomes rather than claiming the render always fails.
return [
{
code: "root_composition_missing_duration_source",
severity: "error",
message:
"Root composition uses a CSS animation with animation-iteration-count: infinite and no " +
"data-duration, so the intended total length is ambiguous. If a finite animation is also " +
"present the runtime infers that length; with no finite source the render fails with " +
'"Composition has zero duration". Declare the intended length explicitly.',
fixHint:
'Add data-duration="<seconds>" to the root element with the intended total length.',
snippet: truncateSnippet(rootTag.raw),
},
];
}
// Finite CSS animation, WAAPI .animate(), or Lottie — the runtime infers
// duration from these at render time (see resolveAdapterDurationFloorSeconds
// in runtime/init.ts). Not an error; data-duration is optional here.
return [];
},
];