mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user