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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/lint/src/rules/composition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1119,6 +1119,15 @@ describe("composition rules", () => {
});

describe("invalid_composition_variables_declaration", () => {
it("checks a declaration on the composition root too", async () => {
const html = `<html><body><div data-composition-id="x" data-composition-variables='[{"id":12345}]'></div></body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(f) => f.code === "invalid_composition_variables_declaration",
);
expect(finding).toBeDefined();
});

it("warns when data-composition-variables is unparseable JSON", async () => {
const html = `<html data-composition-variables='[{not json'><body><div data-composition-id="x"></div></body></html>`;
const result = await lintHyperframeHtml(html);
Expand Down
189 changes: 98 additions & 91 deletions packages/lint/src/rules/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,99 @@ function collectDeclaredVariableIds(htmlTagRaw: string): Set<string> | null {
* template/fragment sub-comps hold it on their composition root div. Returns
* null if any occurrence has unparseable JSON.
*/
// fallow-ignore-next-line complexity
function variablesDeclarationFindings(
tag: OpenTag,
tags: readonly OpenTag[],
): HyperframeLintFinding[] {
const raw = readJsonAttr(tag.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(tag.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(tag.raw),
},
];
}

const findings: HyperframeLintFinding[] = [];
const knownTypes = new Set<string>(COMPOSITION_VARIABLE_TYPES);
// Ids whose value the runtime pushes through isSafeMediaUrl: every
// data-var-src binding, plus image-typed variables (always consumed as a
// URL even when the binding lives in a sub-composition this file can't see).
const varSrcIds = new Set<string>();
for (const other of tags) {
const bound = readAttr(other.raw, "data-var-src");
if (bound) varSrcIds.add(bound);
}
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(tag.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(tag.raw),
});
continue;
}
const id = String(e.id);
if (
(e.type === "image" || varSrcIds.has(id)) &&
typeof e.default === "string" &&
e.default.length > 0 &&
!isSafeMediaUrl(e.default)
) {
findings.push({
code: "unloadable_media_variable_default",
severity: "error",
message: `Variable "${id}" defaults to a URL the runtime will refuse to load, so any element bound to it renders its authored fallback src instead and the render still exits 0.`,
fixHint: `Media URLs must be relative, http(s), blob:, or a data:image/* URI. Copy the file into the project and reference it relatively (e.g. "assets/bg.png") rather than by absolute path.`,
snippet: truncateSnippet(tag.raw),
});
}
}
return findings;
}

export function collectAllDeclaredVariableIds(tags: readonly OpenTag[]): Set<string> | null {
const all = new Set<string>();
for (const tag of tags) {
Expand Down Expand Up @@ -1011,97 +1104,11 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
// 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
({ tags }) => {
const htmlTag = findHtmlTag(tags);
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);
// Ids whose value the runtime pushes through isSafeMediaUrl: every
// data-var-src binding, plus image-typed variables (always consumed as a
// URL even when the binding lives in a sub-composition this file can't see).
const varSrcIds = new Set<string>();
for (const tag of tags) {
const bound = readAttr(tag.raw, "data-var-src");
if (bound) varSrcIds.add(bound);
}
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),
});
continue;
}
const id = String(e.id);
if (
(e.type === "image" || varSrcIds.has(id)) &&
typeof e.default === "string" &&
e.default.length > 0 &&
!isSafeMediaUrl(e.default)
) {
findings.push({
code: "unloadable_media_variable_default",
severity: "error",
message: `Variable "${id}" defaults to a URL the runtime will refuse to load, so any element bound to it renders its authored fallback src instead and the render still exits 0.`,
fixHint: `Media URLs must be relative, http(s), blob:, or a data:image/* URI. Copy the file into the project and reference it relatively (e.g. "assets/bg.png") rather than by absolute path.`,
snippet: truncateSnippet(htmlTag.raw),
});
}
}
return findings;
},
// Checked on every element that declares: <html>, or the composition root.
({ tags }) =>
tags
.filter((tag) => readJsonAttr(tag.raw, "data-composition-variables"))
.flatMap((tag) => variablesDeclarationFindings(tag, tags)),

// html_dir_attribute_breaks_render — valid non-LTR dir values on
// <html> renders correctly in preview/snapshot but produces a fully
Expand Down
19 changes: 19 additions & 0 deletions packages/parsers/src/htmlParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,25 @@ describe("extractCompositionMetadata", () => {
expect(meta.variables[1].type).toBe("number");
});

it("reads variables declared on the composition root, templated or not", () => {
const decl = (id: string, def = "x") =>
JSON.stringify([{ id, type: "string", label: id, default: def }]);
const onRoot = `<!DOCTYPE html><html><body><div data-composition-id="c" data-composition-variables='${decl("title")}'></div></body></html>`;
expect(extractCompositionMetadata(onRoot).variables.map((v) => v.id)).toEqual(["title"]);
const inTemplate = `<!DOCTYPE html><html><body><template id="c-template"><div data-composition-id="c" data-composition-variables='${decl("sub")}'></div></template></body></html>`;
expect(extractCompositionMetadata(inTemplate).variables.map((v) => v.id)).toEqual(["sub"]);
});

it("merges <html> and root declarations, the root winning a shared id", () => {
const html = `<!DOCTYPE html>
<html data-composition-variables='[{"id":"title","type":"string","label":"T","default":"A"},{"id":"count","type":"number","label":"C","default":1}]'>
<body><div data-composition-id="c" data-composition-variables='[{"id":"title","type":"string","label":"T","default":"B"}]'></div></body>
</html>`;
const vars = extractCompositionMetadata(html).variables;
expect(vars.map((v) => v.id)).toEqual(["title", "count"]);
expect(vars[0]?.default).toBe("B");
});

// T9 — CompositionVariable font/image parse (WS-B R1 implemented).

it("parses a font variable (type: font) with name and source", () => {
Expand Down
18 changes: 13 additions & 5 deletions packages/parsers/src/htmlParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -803,11 +803,19 @@ export function extractCompositionMetadata(html: string): CompositionMetadata {
const durationStr = htmlEl.getAttribute("data-composition-duration");
const compositionDuration = durationStr ? parseFloat(durationStr) : null;

// TODO(template-var-carriers): reads `<html>` only. A template/fragment comp
// that declares variables on its `[data-composition-id]` root div (the
// dual-carrier contract from #2081) reports no variables when its metadata is
// extracted standalone (e.g. CLI --variables validation of a sub-comp file).
const variables = parseCompositionVariables(htmlEl);
// Declarations live on <html> or on the composition root (inside its <template> when
// templated); like the runtime, read both and let the root's win on a shared id.
const root = (doc.querySelector("template")?.content ?? doc).querySelector(
"[data-composition-id]",
);
const variables = [
...new Map(
[
...parseCompositionVariables(htmlEl),
...(root && root !== htmlEl ? parseCompositionVariables(root) : []),
].map((v) => [v.id, v]),
).values(),
];

return {
compositionId,
Expand Down
12 changes: 9 additions & 3 deletions packages/sdk/src/engine/apply-patches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,13 +264,19 @@ function applyOne(parsed: ParsedDocument, patch: JsonPatchOp, p: ParsedPath): vo
case "variableDeclaration": {
if (!p.id) return;
if (patch.op === "remove") {
removeVariableDeclarationEntry(declarationElement(parsed.document, parsed.wrapped), p.id);
removeVariableDeclarationEntry(
declarationElement(parsed.document, parsed.wrapped, p.id),
p.id,
);
} else if (isRawDeclarationEntry(patch.value)) {
// Replay is faithful, not strict: inverse patches capture raw entries
// (loose hand-authored declarations included) and undo must restore
// them verbatim — gating on isCompositionVariable here would make
// undo of a remove/update on a loose entry silently no-op.
writeVariableDeclaration(declarationElement(parsed.document, parsed.wrapped), patch.value);
writeVariableDeclaration(
declarationElement(parsed.document, parsed.wrapped, p.id),
patch.value,
);
}
break;
}
Expand All @@ -282,7 +288,7 @@ function applyOne(parsed: ParsedDocument, patch: JsonPatchOp, p: ParsedPath): vo
// CSS compat is handled by explicit style-path patches emitted by mutate.ts,
// so we do NOT write CSS here — the style case above handles those patches.
applyVariableDefault(
declarationElement(parsed.document, parsed.wrapped),
declarationElement(parsed.document, parsed.wrapped, p.id),
p.id,
patch.op === "remove" ? null : patch.value,
);
Expand Down
40 changes: 33 additions & 7 deletions packages/sdk/src/engine/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { parseHTML } from "linkedom";
import { findVariableDeclaration } from "./variableModel.js";
import {
ensureHfIds,
isCompositionTemplate,
Expand Down Expand Up @@ -169,15 +170,40 @@ export function isNewHostBoundary(el: Element): boolean {
}

/**
* The element that carries composition-level declarations
* (`data-composition-variables`). Full-document comps use `<html>`; a wrapped
* template/fragment comp has a synthetic `<html>` that serialize() strips, so
* its declarations must live on the composition root div (where values/metadata
* already live) to survive save.
* The elements carrying `data-composition-variables`, in the runtime's merge
* order: `<html>`, then the composition root, whose entries win a shared id. A
* wrapped template/fragment comp has a synthetic `<html>` that serialize()
* strips, so only its root counts.
*/
export function declarationElement(document: Document, wrapped: boolean): Element | null {
export function declarationCarriers(document: Document, wrapped: boolean): Element[] {
const root = findRoot(document);
const html = wrapped
? null
: (document as Document & { documentElement?: Element }).documentElement;
const carriers = [html, root].filter(
(el): el is Element => !!el?.hasAttribute("data-composition-variables"),
);
return [...new Set(carriers)];
}

/**
* Where a declaration op lands: the carrier that declares `id` (the root first,
* as it wins), else the root if it carries declarations, else `<html>` (the
* root div for wrapped comps).
*/
export function declarationElement(
document: Document,
wrapped: boolean,
id?: string,
): Element | null {
const winnerFirst = declarationCarriers(document, wrapped).reverse();
const owner =
id === undefined ? undefined : winnerFirst.find((el) => findVariableDeclaration(el, id));
if (owner) return owner;
if (wrapped) return findRoot(document);
return (document as Document & { documentElement?: Element }).documentElement ?? null;
return (
winnerFirst[0] ?? (document as Document & { documentElement?: Element }).documentElement ?? null
);
}

export function findRoot(document: Document): Element | null {
Expand Down
Loading
Loading