feat(core): declarative variable bindings — data-var-src, data-var-text, css custom props

This commit is contained in:
James
2026-07-09 13:31:03 -07:00
parent bc0e0b314b
commit f7ee0768ae
17 changed files with 643 additions and 34 deletions
@@ -753,6 +753,37 @@ describe("composition rules", () => {
});
});
describe("unknown_variable_binding", () => {
it("warns when data-var-src references an undeclared variable", async () => {
const html = `<html data-composition-variables='[{"id":"hero","type":"image","label":"Hero","default":"a.jpg"}]'><body>
<img id="i" data-start="0" data-duration="2" data-var-src="heroImge" src="a.jpg" />
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "unknown_variable_binding");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("warning");
expect(finding?.message).toMatch(/heroImge/);
});
it("stays quiet for declared binding ids and for fragment files", async () => {
const declared = `<html data-composition-variables='[{"id":"title","type":"string","label":"T","default":"x"}]'><body>
<h1 data-var-text="title">x</h1>
</body></html>`;
expect(
(await lintHyperframeHtml(declared)).findings.some(
(f) => f.code === "unknown_variable_binding",
),
).toBe(false);
const fragment = `<div class="clip" data-start="0" data-duration="2" data-var-text="hostProvided">x</div>`;
expect(
(await lintHyperframeHtml(fragment)).findings.some(
(f) => f.code === "unknown_variable_binding",
),
).toBe(false);
});
});
describe("invalid_variable_values_json", () => {
it("warns when data-variable-values is unparseable JSON", async () => {
const html = `<html><body>
+50
View File
@@ -107,6 +107,25 @@ function rootClassStyledSelectors(styles: ExtractedBlock[], rootClasses: string[
return offenders;
}
/** Declared variable ids from an <html> tag's raw text; null when the JSON is unparseable. */
function collectDeclaredVariableIds(htmlTagRaw: string): Set<string> | null {
const declared = new Set<string>();
const raw = readJsonAttr(htmlTagRaw, "data-composition-variables");
if (!raw) return declared;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!Array.isArray(parsed)) return declared;
for (const entry of parsed) {
const id = (entry as { id?: unknown } | null)?.id;
if (typeof id === "string") declared.add(id);
}
return declared;
}
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
@@ -597,6 +616,37 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
return findings;
},
// unknown_variable_binding
// data-var-src / data-var-text bind an element to a declared variable id;
// the runtime silently keeps the authored fallback when the id resolves to
// nothing, so a typo'd binding is invisible until a customer's override
// does nothing. Skipped for fragment files (no <html>): their values come
// from a host's data-variable-values, which this file can't see.
({ source, tags }) => {
const htmlTag = findHtmlTag(source);
if (!htmlTag) return [];
const declared = collectDeclaredVariableIds(htmlTag.raw);
// null = unparseable declarations; invalid_composition_variables_declaration
// reports that failure, so this rule stays quiet.
if (declared === null) return [];
const findings: HyperframeLintFinding[] = [];
for (const tag of tags) {
for (const attr of ["data-var-src", "data-var-text"]) {
const id = readAttr(tag.raw, attr)?.trim();
if (!id || declared.has(id)) continue;
findings.push({
code: "unknown_variable_binding",
severity: "warning",
message: `<${tag.name}> binds ${attr}="${id}" but no variable "${id}" is declared in data-composition-variables — the binding will silently keep the authored fallback.`,
fixHint: `Declare the variable on <html>: data-composition-variables='[{"id":"${id}","type":"${attr === "data-var-src" ? "image" : "string"}","label":"${id}","default":"..."}]', or fix the binding id.`,
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
+21
View File
@@ -296,3 +296,24 @@ describe("media rules", () => {
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);
});
});
+24 -8
View File
@@ -460,14 +460,30 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
});
}
if (hasDataStart && hasId && !hasSrc) {
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),
});
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({