fix(sdk): read a composition's variables from its root when its html has none (#4454)

* fix(sdk): read a composition's variables from its root when its html has none

* fix: composition metadata and lint also read variables declared on the root

* fix(sdk): keep root declarations on the root through undo and prefer them like the runtime

* fix(sdk): read variables from both <html> and the root, editing each where declared
This commit is contained in:
Miguel Ángel
2026-09-25 00:14:11 -04:00
committed by GitHub
parent 278bc712d7
commit 571c99ce9e
13 changed files with 297 additions and 133 deletions
@@ -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);
+98 -91
View File
@@ -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) {
@@ -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
+19
View File
@@ -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", () => {
+13 -5
View File
@@ -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,
+9 -3
View File
@@ -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;
}
@@ -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,
);
+33 -7
View File
@@ -6,6 +6,7 @@
*/
import { parseHTML } from "linkedom";
import { findVariableDeclaration } from "./variableModel.js";
import {
ensureHfIds,
isCompositionTemplate,
@@ -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 {
+14 -9
View File
@@ -867,7 +867,7 @@ function handleSetVariableValue(
): MutationResult {
const root = findRoot(parsed.document);
if (!root) return EMPTY;
const declEl = declarationElement(parsed.document, parsed.wrapped);
const declEl = declarationElement(parsed.document, parsed.wrapped, id);
const modelPath = variablePath(id);
const oldVarDefault = readVariableDefault(declEl, id);
@@ -990,7 +990,8 @@ function handleDeclareVariable(
if (!declEl) return EMPTY;
if (!isCompositionVariable(declaration)) return EMPTY;
if (!isValidVariableId(declaration.id)) return EMPTY;
if (findVariableDeclaration(declEl, declaration.id) !== undefined) return EMPTY;
const existing = declarationElement(parsed.document, parsed.wrapped, declaration.id);
if (findVariableDeclaration(existing, declaration.id) !== undefined) return EMPTY;
if (!writeVariableDeclaration(declEl, declaration)) return EMPTY;
const path = variableDeclPath(declaration.id);
const result: MutationResult = {
@@ -1014,7 +1015,7 @@ function handleUpdateVariableDeclaration(
id: string,
declaration: CompositionVariable,
): MutationResult {
const declEl = declarationElement(parsed.document, parsed.wrapped);
const declEl = declarationElement(parsed.document, parsed.wrapped, id);
if (!declEl) return EMPTY;
if (!isCompositionVariable(declaration) || declaration.id !== id) return EMPTY;
const old = findVariableDeclaration(declEl, id);
@@ -1043,7 +1044,7 @@ function handleUpdateVariableDeclaration(
}
function handleRemoveVariableDeclaration(parsed: ParsedDocument, id: string): MutationResult {
const declEl = declarationElement(parsed.document, parsed.wrapped);
const declEl = declarationElement(parsed.document, parsed.wrapped, id);
if (!declEl) return EMPTY;
const old = findVariableDeclaration(declEl, id);
if (old === undefined) return EMPTY;
@@ -1678,7 +1679,7 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
if (preErr) return preErr;
if (
findVariableDeclaration(
declarationElement(parsed.document, parsed.wrapped),
declarationElement(parsed.document, parsed.wrapped, op.declaration.id),
op.declaration.id,
) !== undefined
)
@@ -1699,8 +1700,10 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
"Variable ids are immutable — rename via removeVariableDeclaration + declareVariable.",
);
if (
findVariableDeclaration(declarationElement(parsed.document, parsed.wrapped), op.id) ===
undefined
findVariableDeclaration(
declarationElement(parsed.document, parsed.wrapped, op.id),
op.id,
) === undefined
)
return canErr(
"E_VARIABLE_NOT_FOUND",
@@ -1713,8 +1716,10 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): CanResult {
const fragmentErr = fragmentCompositionErr(parsed);
if (fragmentErr) return fragmentErr;
if (
findVariableDeclaration(declarationElement(parsed.document, parsed.wrapped), op.id) ===
undefined
findVariableDeclaration(
declarationElement(parsed.document, parsed.wrapped, op.id),
op.id,
) === undefined
)
return canErr(
"E_VARIABLE_NOT_FOUND",
+6 -6
View File
@@ -1,8 +1,8 @@
/**
* Shared helpers for the composition variable JSON model
* (`data-composition-variables`). The declaration-carrying element is resolved
* by the caller (`declarationElement` in model.ts): `<html>` for full-document
* comps, the composition root div for wrapped template/fragment comps.
* by the caller (`declarationElement` in model.ts): the composition root when it
* declares or the comp is a wrapped template/fragment, else `<html>`.
*
* Single source for the parse → find-by-id → read/write/clear logic so the
* forward-mutation path (engine/mutate.ts) and the patch-replay path
@@ -93,9 +93,9 @@ export function writeVariableDeclaration(
}
/**
* Remove a variable declaration by id. Drops the whole attribute when the
* last declaration is removed (an empty `[]` is noise in authored HTML).
* No-ops (returns false) when the attribute or the entry is absent.
* Remove a variable declaration by id. Drops the attribute from `<html>` with the
* last declaration; any other element keeps `[]` so it still owns the declarations
* (undo and later declares land back on it). No-ops when the attribute or entry is absent.
*/
export function removeVariableDeclarationEntry(declEl: Element | null, id: string): boolean {
const decls = readDecls(declEl);
@@ -103,7 +103,7 @@ export function removeVariableDeclarationEntry(declEl: Element | null, id: strin
const idx = indexOfId(decls.arr, id);
if (idx < 0) return false;
decls.arr.splice(idx, 1);
if (decls.arr.length === 0) {
if (decls.arr.length === 0 && decls.declEl === decls.declEl.ownerDocument?.documentElement) {
decls.declEl.removeAttribute("data-composition-variables");
} else {
decls.declEl.setAttribute("data-composition-variables", JSON.stringify(decls.arr));
+16 -9
View File
@@ -35,7 +35,7 @@ import type { PersistAdapter, PreviewAdapter } from "./adapters/types.js";
import { parseMutable } from "./engine/model.js";
import type { ParsedDocument } from "./engine/model.js";
import { applyOp, validateOp, type MutationResult } from "./engine/mutate.js";
import { getGsapScripts, resolveScoped, declarationElement } from "./engine/model.js";
import { getGsapScripts, resolveScoped, declarationCarriers } from "./engine/model.js";
import { extractGsapLabels } from "@hyperframes/core/gsap-parser-acorn";
import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler/html-document";
import { readClipTiming, type ClipTiming } from "@hyperframes/core/composition-contract";
@@ -221,21 +221,22 @@ class CompositionImpl implements Composition {
}
getVariableDeclarations(): CompositionVariable[] {
return readVariableDeclarations(declarationElement(this.parsed.document, this.parsed.wrapped));
const byId = new Map<string, CompositionVariable>();
for (const el of declarationCarriers(this.parsed.document, this.parsed.wrapped))
for (const decl of readVariableDeclarations(el)) byId.set(decl.id, decl);
return [...byId.values()];
}
getVariableValues(overrides?: Record<string, unknown>): Record<string, unknown> {
// THIS composition's own declared defaults (loose extraction: any entry with
// a string id + a `default` key, even ones the strict declaration parser
// drops) spread under the overrides. Scope note: this reads the composition's
// single declaration element only — NOT a union of every `[data-composition-
// own carriers (<html> and its root) only — NOT every `[data-composition-
// variables]` in the document. The runtime's getVariables()
// (core/runtime/getVariables.ts) additionally walks inlined sub-composition
// declarers because it operates on the bundled multi-composition document;
// the SDK models one composition file, so per-file scope is intended.
const defaults = readDeclaredDefaults(
declarationElement(this.parsed.document, this.parsed.wrapped),
);
const defaults = declaredDefaults(this.parsed);
return { ...defaults, ...(overrides ?? {}) };
}
@@ -845,6 +846,14 @@ class CompositionImpl implements Composition {
}
}
/** Declared defaults across the composition's carriers, the root winning a shared id as in the runtime. */
function declaredDefaults(parsed: ParsedDocument): Record<string, unknown> {
return Object.assign(
{},
...declarationCarriers(parsed.document, parsed.wrapped).map((el) => readDeclaredDefaults(el)),
);
}
// ─── Public factory ───────────────────────────────────────────────────────────
/**
@@ -867,9 +876,7 @@ export async function openComposition(
// overrides destructively into the declarations, so this is the last moment
// the authored base values are readable. getVariableValue({ base: true })
// serves them for the rest of the session (undo-to-base restores).
const baseVariableDefaults = readDeclaredDefaults(
declarationElement(parsed.document, parsed.wrapped),
);
const baseVariableDefaults = declaredDefaults(parsed);
// T3 embedded: replay the stored override-set onto the base in one pass,
// so the session exposes the user's exact edited state — not the template.
@@ -133,6 +133,83 @@ describe("declareVariable", () => {
});
});
describe("a full document declaring on its composition root", () => {
// The shape cosmic-orb and bar-chart-race ship: <html> carries nothing, the root div does.
const ROOT_DECLARED_HTML = `<!DOCTYPE html>
<html lang="en">
<body>
<div id="co-root" data-composition-id="orb" data-width="1280" data-height="720" data-duration="5"
data-composition-variables='${JSON.stringify([TITLE_DECL, COUNT_DECL])}'>
<h1 data-start="0" data-end="3">Hello</h1>
</div>
</body>
</html>`;
it("reads the root's declarations", async () => {
const comp = await openComposition(ROOT_DECLARED_HTML);
expect(comp.getVariableDeclarations()).toEqual([TITLE_DECL, COUNT_DECL]);
});
it("edits them on the root and leaves <html> without any", async () => {
const comp = await openComposition(ROOT_DECLARED_HTML);
comp.declareVariable({ id: "dark", type: "boolean", label: "Dark", default: false });
comp.removeVariableDeclaration("count");
const saved = comp.serialize();
expect(saved).toMatch(/<html lang="en">/);
const reopened = await openComposition(saved);
expect(reopened.getVariableDeclarations().map((d) => d.id)).toEqual(["title", "dark"]);
});
it("undo of removing the last declaration restores it on the root, not <html>", async () => {
const comp = await openComposition(ROOT_DECLARED_HTML);
comp.removeVariableDeclaration("title");
comp.removeVariableDeclaration("count");
comp.undo();
comp.undo();
const saved = comp.serialize();
expect(saved).toMatch(/<html lang="en">/);
const reopened = await openComposition(saved);
expect(
reopened
.getVariableDeclarations()
.map((d) => d.id)
.sort(),
).toEqual(["count", "title"]);
});
it("reads both <html> and the root, and edits each id where it is declared", async () => {
const THEME_DECL = { id: "theme", type: "string", label: "Theme", default: "dark" };
const both = ROOT_DECLARED_HTML.replace(
'<html lang="en">',
`<html lang="en" data-composition-variables='${JSON.stringify([THEME_DECL])}'>`,
);
const comp = await openComposition(both);
expect(comp.getVariableDeclarations().map((d) => d.id)).toEqual(["theme", "title", "count"]);
comp.removeVariableDeclaration("title");
comp.removeVariableDeclaration("count");
expect(comp.getVariableDeclarations()).toEqual([THEME_DECL]);
expect(
comp.can({ type: "declareVariable", declaration: { ...THEME_DECL, default: "x" } }),
).toMatchObject({ ok: false, code: "E_DUPLICATE_VARIABLE" });
comp.updateVariableDeclaration("theme", { ...THEME_DECL, default: "light" });
const reopened = await openComposition(comp.serialize());
expect(reopened.getVariableDeclarations()).toEqual([{ ...THEME_DECL, default: "light" }]);
expect(comp.serialize()).toMatch(/<html lang="en" data-composition-variables="[^"]*light/);
});
it("prefers the root when <html> declares too, as the runtime does", async () => {
const both = ROOT_DECLARED_HTML.replace(
'<html lang="en">',
`<html lang="en" data-composition-variables='${JSON.stringify([{ ...TITLE_DECL, default: "HTML" }])}'>`,
);
const comp = await openComposition(both);
expect(comp.getVariableDeclarations()).toEqual([TITLE_DECL, COUNT_DECL]);
comp.updateVariableDeclaration("title", { ...TITLE_DECL, default: "Edited" });
expect(comp.serialize()).toMatch(/<html lang="en" data-composition-variables="[^"]*HTML/);
});
});
describe("updateVariableDeclaration", () => {
it("replaces the declaration wholesale", async () => {
const comp = await openComposition(DECLARED_HTML);
+1 -1
View File
@@ -94,7 +94,7 @@
<div
id="lb-root"
data-composition-id="lower-third-bild"
data-composition-variables='{"TXT_MAIN_1_Line":{"type":"string","default":"BILD EXKLUSIV"},"TXT_SUB":{"type":"string","default":"Sonderbericht"}}'
data-composition-variables='[{"id":"TXT_MAIN_1_Line","type":"string","label":"Main line","default":"BILD EXKLUSIV"},{"id":"TXT_SUB","type":"string","label":"Sub line","default":"Sonderbericht"}]'
data-start="0"
data-duration="8"
data-width="1920"
+1 -1
View File
@@ -95,7 +95,7 @@
data-fps="30"
data-width="1920"
data-height="1080"
data-composition-variables='{"texture":{"type":"string","default":"lava"}}'
data-composition-variables='[{"id":"texture","type":"string","label":"Texture","default":"lava"}]'
>
<div class="lv-overlay"></div>
<div id="lv-container"></div>
+1 -1
View File
@@ -94,7 +94,7 @@
data-fps="30"
data-width="1920"
data-height="1080"
data-composition-variables='{"texture":{"type":"string","default":"lava"}}'
data-composition-variables='[{"id":"texture","type":"string","label":"Texture","default":"lava"}]'
>
<div class="lv-overlay"></div>
<div id="lv-container"></div>