diff --git a/packages/cli/src/docs/compositions.md b/packages/cli/src/docs/compositions.md
index d9c809a33..81dedb539 100644
--- a/packages/cli/src/docs/compositions.md
+++ b/packages/cli/src/docs/compositions.md
@@ -20,14 +20,35 @@ Embed one composition inside another:
```
-## Listing Compositions
-
-Use `npx hyperframes compositions` to see all compositions in a project.
+## Parameterized Compositions
-## Variables
-
-Compositions can expose variables for dynamic content:
+Reuse a composition with different data using `data-props`:
```html
-
+
+
+
+
+
+
+
+
+
+
{{title:Card Title}}
+
{{price:$0}}/mo
+
+
```
+
+- `data-props` accepts a JSON object
+- `{{key}}` — replaced with value, left as-is if unmatched
+- `{{key:default}}` — replaced with value, or default if unmatched (use this so compositions render standalone)
+- Works in HTML, CSS, and scripts
+- Values are HTML-escaped in content, raw in CSS, JS-escaped in scripts
+
+## Listing Compositions
+
+Use `npx hyperframes compositions` to see all compositions in a project.
diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts
index 118cd9a45..1db13ba32 100644
--- a/packages/core/src/compiler/htmlBundler.test.ts
+++ b/packages/core/src/compiler/htmlBundler.test.ts
@@ -227,4 +227,131 @@ describe("bundleToSingleHtml", () => {
expect(bundled).toContain('url("fonts/brand.woff2")');
expect(bundled).not.toContain('url("../fonts/brand.woff2")');
});
+
+ it("interpolates {{key}} placeholders in sub-compositions using data-props", async () => {
+ const dir = makeTempProject({
+ "index.html": `
+
+
+
+
+
+`,
+ "compositions/card.html": `
+
+
{{title}}
+
{{price}}
+
Featured: {{featured}}
+
+
+`,
+ });
+
+ const bundled = await bundleToSingleHtml(dir);
+
+ // Card 1 should have interpolated values
+ expect(bundled).toContain("Pro Plan");
+ expect(bundled).toContain("$19/mo");
+
+ // Card 2 should have its own interpolated values
+ expect(bundled).toContain("Enterprise");
+ expect(bundled).toContain("$49/mo");
+
+ // No raw mustache placeholders should remain for resolved keys
+ expect(bundled).not.toContain("{{title}}");
+ expect(bundled).not.toContain("{{price}}");
+ });
+
+ it("interpolates {{key}} in inline template compositions", async () => {
+ const dir = makeTempProject({
+ "index.html": `
+
+
+
+ {{label}}
+
+
+
+
+`,
+ });
+
+ const bundled = await bundleToSingleHtml(dir);
+
+ expect(bundled).toContain("BEST VALUE");
+ expect(bundled).toContain("#ff0000");
+ expect(bundled).not.toContain("{{label}}");
+ expect(bundled).not.toContain("{{color}}");
+ });
+
+ it("HTML-escapes interpolated values to prevent XSS", async () => {
+ const dir = makeTempProject({
+ "index.html": `
+
+
+`,
+ "compositions/unsafe.html": `
+
+`,
+ });
+
+ const bundled = await bundleToSingleHtml(dir);
+
+ // The interpolated content inside should be escaped
+ expect(bundled).toContain("Hello, <script>alert(1)</script>");
+ // The raw script tag should NOT appear as actual HTML content (only in the JSON attribute)
+ expect(bundled).not.toContain("
Hello,
");
+ });
+
+ it("resolves {{key:default}} when no data-props provided", async () => {
+ const dir = makeTempProject({
+ "index.html": `
+
+
+
+
+
{{title:Default Title}}
+
+
+
+`,
+ });
+
+ const bundled = await bundleToSingleHtml(dir);
+
+ expect(bundled).toContain("Default Title");
+ expect(bundled).toContain("#6366f1");
+ expect(bundled).not.toContain("{{title");
+ expect(bundled).not.toContain("{{bgColor");
+ });
});
diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts
index 7933350cb..455115ea8 100644
--- a/packages/core/src/compiler/htmlBundler.ts
+++ b/packages/core/src/compiler/htmlBundler.ts
@@ -5,6 +5,12 @@ import { transformSync } from "esbuild";
import { compileHtml, type MediaDurationProber } from "./htmlCompiler";
import { rewriteAssetPaths, rewriteCssAssetUrls } from "./rewriteSubCompPaths";
import { validateHyperframeHtmlContract } from "./staticGuard";
+import {
+ parseVariableValues,
+ interpolateProps,
+ interpolateScriptProps,
+ interpolateCssProps,
+} from "./interpolateProps";
/**
* Parse an HTML string into a document. Fragments (without a full document
@@ -431,6 +437,11 @@ export async function bundleToSingleHtml(
continue;
}
+ // Parse variable values from host element for per-context interpolation
+ const variableValues = parseVariableValues(hostEl.getAttribute("data-props"));
+
+ // Parse first — interpolation happens per-context below (HTML-escaped for
+ // text/attributes, raw for CSS, JS-escaped for scripts).
const compDoc = parseHTMLContent(compHtml);
const compId = hostEl.getAttribute("data-composition-id");
const contentRoot = compDoc.querySelector("template");
@@ -441,7 +452,10 @@ export async function bundleToSingleHtml(
: contentDoc.querySelector("[data-composition-id]");
for (const s of [...contentDoc.querySelectorAll("style")]) {
- compStyleChunks.push(rewriteCssAssetUrls(s.textContent || "", src));
+ const cssText = s.textContent || "";
+ // Interpolate props in CSS with raw replacement (HTML entities are invalid in CSS)
+ const interpolatedCss = interpolateCssProps(cssText, variableValues);
+ compStyleChunks.push(rewriteCssAssetUrls(interpolatedCss, src));
s.remove();
}
for (const s of [...contentDoc.querySelectorAll("script")]) {
@@ -453,8 +467,11 @@ export async function bundleToSingleHtml(
compExternalScriptSrcs.push(externalSrc);
}
} else {
+ const scriptText = s.textContent || "";
+ // Interpolate props in script content (e.g., {{duration}} in GSAP timelines)
+ const interpolatedScript = interpolateScriptProps(scriptText, variableValues);
compScriptChunks.push(
- `(function(){ try { ${s.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
+ `(function(){ try { ${interpolatedScript} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
);
}
s.remove();
@@ -474,6 +491,15 @@ export async function bundleToSingleHtml(
},
);
+ // Interpolate {{key}} and {{key:default}} in remaining HTML content (always run for defaults)
+ {
+ const targetRoot = innerRoot ?? contentDoc.body;
+ if (targetRoot) {
+ const rawInner = targetRoot.innerHTML || "";
+ targetRoot.innerHTML = interpolateProps(rawInner, variableValues);
+ }
+ }
+
if (innerRoot) {
const innerCompId = innerRoot.getAttribute("data-composition-id");
const innerW = innerRoot.getAttribute("data-width");
@@ -508,18 +534,21 @@ export async function bundleToSingleHtml(
if (!host) continue;
if (host.children.length > 0) continue; // already has content
- // Get template content and inject into host
+ // Parse first (raw), then interpolate per-context — matching the external
+ // composition path so CSS gets raw values and scripts get JS-escaped values.
const templateHtml = templateEl.innerHTML || "";
+ const templateVarValues = parseVariableValues(host.getAttribute("data-props"));
+
const innerDoc = parseHTMLContent(templateHtml);
const innerRoot = innerDoc.querySelector(`[data-composition-id="${compId}"]`);
if (innerRoot) {
- // Hoist styles into the collected style chunks
+ // Hoist styles (CSS: raw interpolation)
for (const styleEl of [...innerRoot.querySelectorAll("style")]) {
- compStyleChunks.push(styleEl.textContent || "");
+ compStyleChunks.push(interpolateCssProps(styleEl.textContent || "", templateVarValues));
styleEl.remove();
}
- // Hoist scripts into the collected script chunks
+ // Hoist scripts (JS: JS-escaped interpolation)
for (const scriptEl of [...innerRoot.querySelectorAll("script")]) {
const externalSrc = (scriptEl.getAttribute("src") || "").trim();
if (externalSrc) {
@@ -527,8 +556,9 @@ export async function bundleToSingleHtml(
compExternalScriptSrcs.push(externalSrc);
}
} else {
+ const scriptText = interpolateScriptProps(scriptEl.textContent || "", templateVarValues);
compScriptChunks.push(
- `(function(){ try { ${scriptEl.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
+ `(function(){ try { ${scriptText} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
);
}
scriptEl.remove();
@@ -540,12 +570,13 @@ export async function bundleToSingleHtml(
if (innerW && !host.getAttribute("data-width")) host.setAttribute("data-width", innerW);
if (innerH && !host.getAttribute("data-height")) host.setAttribute("data-height", innerH);
- // Set host content from inner root
+ // Interpolate remaining HTML content (HTML-escaped via innerHTML serialization)
+ innerRoot.innerHTML = interpolateProps(innerRoot.innerHTML || "", templateVarValues);
host.innerHTML = innerRoot.innerHTML || "";
} else {
// No matching inner root — inject all template content directly
for (const styleEl of [...innerDoc.querySelectorAll("style")]) {
- compStyleChunks.push(styleEl.textContent || "");
+ compStyleChunks.push(interpolateCssProps(styleEl.textContent || "", templateVarValues));
styleEl.remove();
}
for (const scriptEl of [...innerDoc.querySelectorAll("script")]) {
@@ -555,12 +586,15 @@ export async function bundleToSingleHtml(
compExternalScriptSrcs.push(externalSrc);
}
} else {
+ const scriptText = interpolateScriptProps(scriptEl.textContent || "", templateVarValues);
compScriptChunks.push(
- `(function(){ try { ${scriptEl.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
+ `(function(){ try { ${scriptText} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
);
}
scriptEl.remove();
}
+ // Interpolate remaining HTML content
+ innerDoc.body.innerHTML = interpolateProps(innerDoc.body.innerHTML || "", templateVarValues);
host.innerHTML = innerDoc.body.innerHTML || "";
}
diff --git a/packages/core/src/compiler/index.ts b/packages/core/src/compiler/index.ts
index c7e1678ea..dceefb7c2 100644
--- a/packages/core/src/compiler/index.ts
+++ b/packages/core/src/compiler/index.ts
@@ -22,3 +22,11 @@ export {
type HyperframeStaticFailureReason,
type HyperframeStaticGuardResult,
} from "./staticGuard";
+
+// Prop interpolation
+export {
+ interpolateProps,
+ interpolateScriptProps,
+ interpolateCssProps,
+ parseVariableValues,
+} from "./interpolateProps";
diff --git a/packages/core/src/compiler/interpolateProps.test.ts b/packages/core/src/compiler/interpolateProps.test.ts
new file mode 100644
index 000000000..d5061329d
--- /dev/null
+++ b/packages/core/src/compiler/interpolateProps.test.ts
@@ -0,0 +1,258 @@
+import { describe, it, expect } from "vitest";
+import {
+ interpolateProps,
+ interpolateScriptProps,
+ interpolateCssProps,
+ parseVariableValues,
+} from "./interpolateProps";
+
+describe("parseVariableValues", () => {
+ it("parses valid JSON object", () => {
+ expect(parseVariableValues('{"title":"Hello","price":19}')).toEqual({
+ title: "Hello",
+ price: 19,
+ });
+ });
+
+ it("returns null for null/undefined/empty", () => {
+ expect(parseVariableValues(null)).toBeNull();
+ expect(parseVariableValues(undefined)).toBeNull();
+ expect(parseVariableValues("")).toBeNull();
+ });
+
+ it("returns null for non-object JSON", () => {
+ expect(parseVariableValues('"just a string"')).toBeNull();
+ expect(parseVariableValues("[1,2,3]")).toBeNull();
+ expect(parseVariableValues("42")).toBeNull();
+ });
+
+ it("returns null for invalid JSON", () => {
+ expect(parseVariableValues("{broken}")).toBeNull();
+ });
+
+ it("handles boolean values", () => {
+ expect(parseVariableValues('{"featured":true}')).toEqual({ featured: true });
+ });
+});
+
+describe("interpolateProps", () => {
+ it("replaces {{key}} placeholders with values", () => {
+ const result = interpolateProps('', {
+ title: "Pro Plan",
+ price: "$19/mo",
+ });
+ expect(result).toBe('');
+ });
+
+ it("handles numeric values", () => {
+ const result = interpolateProps("{{count}} items", { count: 42 });
+ expect(result).toBe("42 items");
+ });
+
+ it("handles boolean values", () => {
+ const result = interpolateProps("Featured: {{featured}}", { featured: true });
+ expect(result).toBe("Featured: true");
+ });
+
+ it("preserves unmatched placeholders", () => {
+ const result = interpolateProps("{{title}} and {{unknown}}", { title: "Hello" });
+ expect(result).toBe("Hello and {{unknown}}");
+ });
+
+ it("handles whitespace in placeholder keys", () => {
+ const result = interpolateProps("{{ title }}", { title: "Hello" });
+ expect(result).toBe("Hello");
+ });
+
+ it("HTML-escapes values to prevent XSS", () => {
+ const result = interpolateProps("{{name}}", {
+ name: '',
+ });
+ expect(result).toBe("<script>alert("xss")</script>");
+ });
+
+ it("escapes ampersands and quotes", () => {
+ const result = interpolateProps('{{text}}
', {
+ label: 'A & B "quoted"',
+ text: "Tom & Jerry",
+ });
+ expect(result).toContain("A & B "quoted"");
+ expect(result).toContain("Tom & Jerry");
+ });
+
+ it("returns original html when values is empty", () => {
+ const html = "{{title}}
";
+ expect(interpolateProps(html, {})).toBe(html);
+ });
+
+ it("returns original html when html is empty", () => {
+ expect(interpolateProps("", { title: "Hello" })).toBe("");
+ });
+
+ it("handles multiple occurrences of the same key", () => {
+ const result = interpolateProps("{{name}} said {{name}}", { name: "Alice" });
+ expect(result).toBe("Alice said Alice");
+ });
+
+ it("handles dotted keys", () => {
+ const result = interpolateProps("{{card.title}}", { "card.title": "Premium" });
+ expect(result).toBe("Premium");
+ });
+
+ it("uses default when no value provided", () => {
+ const result = interpolateProps("{{title:Hello World}}
");
+ expect(result).toBe("Hello World
");
+ });
+
+ it("uses default when values object is empty", () => {
+ const result = interpolateProps("{{title:Fallback}}
", {});
+ expect(result).toBe("Fallback
");
+ });
+
+ it("overrides default with provided value", () => {
+ const result = interpolateProps("{{title:Fallback}}
", { title: "Override" });
+ expect(result).toBe("Override
");
+ });
+
+ it("handles default with special characters", () => {
+ const result = interpolateProps("{{color:#ff0000}}
");
+ expect(result).toBe("#ff0000
");
+ });
+
+ it("handles empty default", () => {
+ const result = interpolateProps("{{title:}}
");
+ expect(result).toBe("");
+ });
+
+ it("handles default with spaces", () => {
+ const result = interpolateProps("{{label:Hello World}}
");
+ expect(result).toBe("Hello World
");
+ });
+
+ it("HTML-escapes defaults too", () => {
+ const result = interpolateProps("{{val:bold}}
");
+ expect(result).toBe("<b>bold</b>
");
+ });
+
+ it("mixes defaulted and non-defaulted placeholders", () => {
+ const result = interpolateProps("{{title:Default}} by {{author}}", { author: "Alice" });
+ expect(result).toBe("Default by Alice");
+ });
+
+ it("resolves all defaults when called with no values", () => {
+ const result = interpolateProps('{{text:Sample}}
');
+ expect(result).toBe('Sample
');
+ });
+});
+
+describe("interpolateScriptProps", () => {
+ it("replaces placeholders without HTML escaping", () => {
+ const result = interpolateScriptProps('const title = "{{title}}"; const dur = {{duration}};', {
+ title: "My Video",
+ duration: 10,
+ });
+ expect(result).toBe('const title = "My Video"; const dur = 10;');
+ });
+
+ it("preserves unmatched placeholders", () => {
+ const result = interpolateScriptProps("const x = {{unknown}};", { title: "Hello" });
+ expect(result).toBe("const x = {{unknown}};");
+ });
+
+ it("does not HTML-escape ampersands in scripts", () => {
+ const result = interpolateScriptProps('const s = "{{val}}";', { val: "A & B" });
+ expect(result).toBe('const s = "A & B";');
+ });
+
+ it("JS-escapes quotes to prevent injection", () => {
+ const result = interpolateScriptProps('const s = "{{val}}";', {
+ val: 'hello"; alert(1); //',
+ });
+ expect(result).toBe('const s = "hello\\"; alert(1); //";');
+ });
+
+ it("JS-escapes backslashes and backticks", () => {
+ const result = interpolateScriptProps("const s = `{{val}}`;", { val: "a\\b`c" });
+ expect(result).toBe("const s = `a\\\\b\\`c`;");
+ });
+
+ it("JS-escapes to prevent tag breakout", () => {
+ const result = interpolateScriptProps('const s = "{{val}}";', {
+ val: "");
+ expect(result).toContain("<\\/script>");
+ });
+
+ it("does not escape numbers and booleans", () => {
+ const result = interpolateScriptProps("const n = {{num}}; const b = {{bool}};", {
+ num: 42,
+ bool: true,
+ });
+ expect(result).toBe("const n = 42; const b = true;");
+ });
+
+ it("preserves $ in regular string contexts", () => {
+ const result = interpolateScriptProps('const p = "{{price}}";', { price: "$19/mo" });
+ expect(result).toBe('const p = "$19/mo";');
+ });
+
+ it("escapes ${ in template literal contexts", () => {
+ const result = interpolateScriptProps("const s = `{{val}}`;", { val: "${dangerous}" });
+ expect(result).toBe("const s = `\\${dangerous}`;");
+ });
+
+ it("uses default when no value provided", () => {
+ const result = interpolateScriptProps("const dur = {{duration:10}};");
+ expect(result).toBe("const dur = 10;");
+ });
+
+ it("overrides default with provided value", () => {
+ const result = interpolateScriptProps("const dur = {{duration:10}};", { duration: 5 });
+ expect(result).toBe("const dur = 5;");
+ });
+
+ it("JS-escapes defaults containing special characters", () => {
+ const result = interpolateScriptProps('const s = "{{label:hello\\"world}}";');
+ expect(result).toBe('const s = "hello\\\\\\"world";');
+ });
+});
+
+describe("interpolateCssProps", () => {
+ it("replaces placeholders with raw values", () => {
+ const result = interpolateCssProps(".card { background: {{bgColor}}; }", {
+ bgColor: "#ec4899",
+ });
+ expect(result).toBe(".card { background: #ec4899; }");
+ });
+
+ it("does not HTML-escape values in CSS", () => {
+ const result = interpolateCssProps(".card { content: '{{text}}'; }", {
+ text: "A & B",
+ });
+ expect(result).toBe(".card { content: 'A & B'; }");
+ expect(result).not.toContain("&");
+ });
+
+ it("preserves unmatched placeholders", () => {
+ const result = interpolateCssProps(".card { color: {{unknown}}; }", { bg: "red" });
+ expect(result).toBe(".card { color: {{unknown}}; }");
+ });
+
+ it("returns original content when values is empty and no defaults", () => {
+ const css = ".card { color: {{color}}; }";
+ expect(interpolateCssProps(css, {})).toBe(css);
+ });
+
+ it("uses default when no value provided", () => {
+ const result = interpolateCssProps(".card { background: {{bgColor:#6366f1}}; }");
+ expect(result).toBe(".card { background: #6366f1; }");
+ });
+
+ it("overrides default with provided value", () => {
+ const result = interpolateCssProps(".card { background: {{bgColor:#6366f1}}; }", {
+ bgColor: "#ec4899",
+ });
+ expect(result).toBe(".card { background: #ec4899; }");
+ });
+});
diff --git a/packages/core/src/compiler/interpolateProps.ts b/packages/core/src/compiler/interpolateProps.ts
new file mode 100644
index 000000000..293ca6515
--- /dev/null
+++ b/packages/core/src/compiler/interpolateProps.ts
@@ -0,0 +1,137 @@
+/**
+ * Interpolate `{{key}}` and `{{key:default}}` mustache-style placeholders
+ * in HTML content using values from a `data-props` JSON attribute.
+ *
+ * Supports:
+ * - `{{key}}` — replaced with the value, or left as-is if no value provided
+ * - `{{key:default}}` — replaced with the value, or the default if no value provided
+ * - Nested keys are NOT supported (flat key-value only)
+ *
+ * Values are coerced to strings. Numbers and booleans are stringified.
+ */
+
+/**
+ * Matches `{{key}}` and `{{key:default value}}`.
+ * Group 1: key (trimmed)
+ * Group 2: default value (everything after the first colon, if present)
+ */
+const MUSTACHE_RE = /\{\{(\s*[\w.-]+\s*)(?::([^}]*))?\}\}/g;
+
+/**
+ * Parse `data-props` JSON from an element attribute.
+ * Returns null if the attribute is missing or invalid JSON.
+ */
+export function parseVariableValues(
+ raw: string | null | undefined,
+): Record | null {
+ if (!raw) return null;
+ try {
+ const parsed = JSON.parse(raw);
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
+ return parsed as Record;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Escape a string for safe insertion into HTML content.
+ */
+function escapeHtml(str: string): string {
+ return str
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+/**
+ * Escape a string for safe insertion into a JavaScript string context.
+ * Handles quote characters, backslashes, template literal delimiters,
+ * and `` sequences that would prematurely close a script tag.
+ */
+function escapeJsString(str: string): string {
+ return str
+ .replace(/\\/g, "\\\\")
+ .replace(/"/g, '\\"')
+ .replace(/'/g, "\\'")
+ .replace(/`/g, "\\`")
+ .replace(/\$\{/g, "\\${")
+ .replace(/<\/(script)/gi, "<\\/$1");
+}
+
+/**
+ * Interpolate `{{key}}` and `{{key:default}}` placeholders in an HTML string.
+ * Values are HTML-escaped to prevent XSS. When no value is provided and a
+ * default is specified, the default is used. Unmatched placeholders without
+ * defaults are preserved as-is.
+ */
+export function interpolateProps(
+ html: string,
+ values?: Record | null,
+): string {
+ if (!html) return html;
+ const vals = values ?? {};
+ return html.replace(MUSTACHE_RE, (_match, rawKey: string, rawDefault: string | undefined) => {
+ const key = rawKey.trim();
+ if (key in vals) {
+ return escapeHtml(String(vals[key]));
+ }
+ if (rawDefault !== undefined) {
+ return escapeHtml(rawDefault);
+ }
+ return _match;
+ });
+}
+
+/**
+ * Interpolate props in script content. String values are JS-escaped to
+ * prevent breaking out of string delimiters. Numbers and booleans are
+ * inserted as-is (safe for direct use as JS literals).
+ * Defaults are used when no value is provided.
+ */
+export function interpolateScriptProps(
+ scriptContent: string,
+ values?: Record | null,
+): string {
+ if (!scriptContent) return scriptContent;
+ const vals = values ?? {};
+ return scriptContent.replace(
+ MUSTACHE_RE,
+ (_match, rawKey: string, rawDefault: string | undefined) => {
+ const key = rawKey.trim();
+ if (key in vals) {
+ const val = vals[key];
+ if (typeof val === "string") return escapeJsString(val);
+ return String(val);
+ }
+ if (rawDefault !== undefined) {
+ return escapeJsString(rawDefault);
+ }
+ return _match;
+ },
+ );
+}
+
+/**
+ * Interpolate props in CSS content. Values are inserted raw — HTML entity
+ * escaping (`&`, `<`) is invalid in CSS and would produce broken rules.
+ * Defaults are used when no value is provided.
+ */
+export function interpolateCssProps(
+ cssContent: string,
+ values?: Record | null,
+): string {
+ if (!cssContent) return cssContent;
+ const vals = values ?? {};
+ return cssContent.replace(
+ MUSTACHE_RE,
+ (_match, rawKey: string, rawDefault: string | undefined) => {
+ const key = rawKey.trim();
+ if (key in vals) return String(vals[key]);
+ if (rawDefault !== undefined) return rawDefault;
+ return _match;
+ },
+ );
+}
diff --git a/packages/core/src/generators/hyperframes.ts b/packages/core/src/generators/hyperframes.ts
index 68307921b..81a0b4c04 100644
--- a/packages/core/src/generators/hyperframes.ts
+++ b/packages/core/src/generators/hyperframes.ts
@@ -532,7 +532,7 @@ function generateElementHtml(element: TimelineElement, keyframes?: Keyframe[]):
}
if (element.variableValues && Object.keys(element.variableValues).length > 0) {
const varJson = JSON.stringify(element.variableValues);
- compositionAttrs.push(`data-variable-values='${varJson.replace(/'/g, "'")}'`);
+ compositionAttrs.push(`data-props='${varJson.replace(/'/g, "'")}'`);
}
const attrs = compositionAttrs.join(" ");
// Build iframe src with variable values as query params if present
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index ec37011d2..e00f69a45 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -126,6 +126,14 @@ export {
rewriteCssAssetUrls,
} from "./compiler/rewriteSubCompPaths";
+// Prop interpolation
+export {
+ interpolateProps,
+ interpolateScriptProps,
+ interpolateCssProps,
+ parseVariableValues,
+} from "./compiler/interpolateProps";
+
// Inline scripts
export {
HYPERFRAME_RUNTIME_ARTIFACTS,
diff --git a/packages/core/src/lint/context.ts b/packages/core/src/lint/context.ts
index b0020ead9..4451a62ed 100644
--- a/packages/core/src/lint/context.ts
+++ b/packages/core/src/lint/context.ts
@@ -13,7 +13,10 @@ import type { OpenTag, ExtractedBlock } from "./utils";
export type { OpenTag, ExtractedBlock };
export type LintContext = {
+ /** Source after template unwrapping (used by most rules). */
source: string;
+ /** Original unmodified HTML (for rules that need to cross-reference templates with hosts). */
+ rawSource: string;
tags: OpenTag[];
styles: ExtractedBlock[];
scripts: ExtractedBlock[];
@@ -27,7 +30,8 @@ export type LintContext = {
export type { HyperframeLintFinding };
export function buildLintContext(html: string, options: HyperframeLinterOptions = {}): LintContext {
- let source = html || "";
+ const rawSource = html || "";
+ let source = rawSource;
const templateMatch = source.match(/]*>([\s\S]*)<\/template>/i);
if (templateMatch?.[1]) source = templateMatch[1];
@@ -40,6 +44,7 @@ export function buildLintContext(html: string, options: HyperframeLinterOptions
return {
source,
+ rawSource,
tags,
styles,
scripts,
diff --git a/packages/core/src/lint/rules/composition.test.ts b/packages/core/src/lint/rules/composition.test.ts
index 34c0fffcd..7f7ced8a5 100644
--- a/packages/core/src/lint/rules/composition.test.ts
+++ b/packages/core/src/lint/rules/composition.test.ts
@@ -201,6 +201,227 @@ describe("composition rules", () => {
});
});
+ describe("invalid_data_props_json", () => {
+ it("flags invalid JSON in data-props", () => {
+ const html = `
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "invalid_data_props_json");
+ expect(finding).toBeDefined();
+ expect(finding?.severity).toBe("error");
+ });
+
+ it("flags array in data-props", () => {
+ const html = `
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const finding = result.findings.find((f) => f.code === "invalid_data_props_json");
+ expect(finding).toBeDefined();
+ });
+
+ it("passes with valid data-props JSON", () => {
+ const html = `
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ expect(result.findings.find((f) => f.code === "invalid_data_props_json")).toBeUndefined();
+ });
+
+ it("no finding when data-props is absent", () => {
+ const html = `
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ expect(result.findings.find((f) => f.code === "invalid_data_props_json")).toBeUndefined();
+ });
+ });
+
+ describe("mustache_placeholder_without_default", () => {
+ it("warns about placeholders without defaults in sub-compositions", () => {
+ const html = `
+
+
{{title}}
+
{{price}}
+
+
+
+`;
+ const result = lintHyperframeHtml(html, { filePath: "compositions/card.html" });
+ const findings = result.findings.filter(
+ (f) => f.code === "mustache_placeholder_without_default",
+ );
+ expect(findings).toHaveLength(2);
+ expect(findings[0]?.message).toContain("{{title}}");
+ expect(findings[1]?.message).toContain("{{price}}");
+ expect(findings[0]?.severity).toBe("warning");
+ });
+
+ it("does not warn when placeholders have defaults", () => {
+ const html = `
+
+
{{title:Card Title}}
+
{{price:$0}}
+
+
+
+`;
+ const result = lintHyperframeHtml(html, { filePath: "compositions/card.html" });
+ expect(
+ result.findings.find((f) => f.code === "mustache_placeholder_without_default"),
+ ).toBeUndefined();
+ });
+
+ it("does not warn for placeholders in root index.html", () => {
+ const html = `
+
+
+
{{title}}
+
+
+`;
+ const result = lintHyperframeHtml(html, { filePath: "index.html" });
+ expect(
+ result.findings.find((f) => f.code === "mustache_placeholder_without_default"),
+ ).toBeUndefined();
+ });
+
+ it("deduplicates warnings for the same key used multiple times", () => {
+ const html = `
+
+
{{title}}
+
Also: {{title}}
+
+
+
+`;
+ const result = lintHyperframeHtml(html, { filePath: "compositions/card.html" });
+ const findings = result.findings.filter(
+ (f) => f.code === "mustache_placeholder_without_default",
+ );
+ expect(findings).toHaveLength(1);
+ });
+ });
+
+ describe("unused_data_props_key", () => {
+ it("warns about props keys that don't match any placeholder in inline template", () => {
+ const html = `
+
+
+
+
{{title:Default}}
+
+
+
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ const findings = result.findings.filter((f) => f.code === "unused_data_props_key");
+ expect(findings).toHaveLength(1);
+ expect(findings[0]?.message).toContain('"typo"');
+ expect(findings[0]?.severity).toBe("warning");
+ expect(findings[0]?.fixHint).toContain("{{title}}");
+ });
+
+ it("does not warn when all props keys match placeholders", () => {
+ const html = `
+
+
+
+
{{title:Default}}
+
{{price:$0}}
+
+
+
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ expect(result.findings.find((f) => f.code === "unused_data_props_key")).toBeUndefined();
+ });
+
+ it("skips external compositions (data-composition-src)", () => {
+ const html = `
+
+
+
+`;
+ const result = lintHyperframeHtml(html);
+ expect(result.findings.find((f) => f.code === "unused_data_props_key")).toBeUndefined();
+ });
+ });
+
describe("requestanimationframe_in_composition", () => {
it("flags requestAnimationFrame usage in script content", () => {
const html = `
diff --git a/packages/core/src/lint/rules/composition.ts b/packages/core/src/lint/rules/composition.ts
index 702a081a4..a7a23a4eb 100644
--- a/packages/core/src/lint/rules/composition.ts
+++ b/packages/core/src/lint/rules/composition.ts
@@ -1,5 +1,5 @@
import type { LintContext, HyperframeLintFinding } from "../context";
-import { readAttr, truncateSnippet } from "../utils";
+import { readAttr, truncateSnippet, extractOpenTags } from "../utils";
export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// timed_element_missing_visibility_hidden
@@ -191,6 +191,134 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
return findings;
},
+ // invalid_data_props_json
+ ({ tags }) => {
+ const findings: HyperframeLintFinding[] = [];
+ // readAttr uses [^"']+ which breaks on JSON containing quotes inside
+ // single-quoted attributes. Use a dedicated regex that captures the full
+ // single-quoted or double-quoted attribute value.
+ const dataPropsRe = /\bdata-props\s*=\s*(?:'([^']*)'|"([^"]*)")/i;
+ for (const tag of tags) {
+ const match = tag.raw.match(dataPropsRe);
+ if (!match) continue;
+ const propsRaw = (match[1] ?? match[2] ?? "")
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/&/g, "&");
+ try {
+ const parsed = JSON.parse(propsRaw);
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
+ throw new Error("not a plain object");
+ }
+ } catch {
+ const elementId = readAttr(tag.raw, "id") || undefined;
+ findings.push({
+ code: "invalid_data_props_json",
+ severity: "error",
+ message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> has data-props with invalid JSON. Value must be a JSON object: '{"key":"value"}'.`,
+ elementId,
+ fixHint:
+ 'Fix the data-props JSON. It must be a valid JSON object with string keys. Example: data-props=\'{"title":"Hello","color":"#fff"}\'',
+ snippet: truncateSnippet(tag.raw),
+ });
+ }
+ }
+ return findings;
+ },
+
+ // mustache_placeholder_without_default
+ ({ source, options }) => {
+ const findings: HyperframeLintFinding[] = [];
+ // Only warn in sub-composition files (not index.html), where standalone rendering matters
+ const filePath = options.filePath || "";
+ const isSubComposition =
+ filePath.includes("compositions/") || filePath.includes("compositions\\");
+ if (!isSubComposition) return findings;
+
+ const placeholderRe = /\{\{(\s*[\w.-]+\s*)(?::([^}]*))?\}\}/g;
+ let match: RegExpExecArray | null;
+ const seen = new Set();
+ while ((match = placeholderRe.exec(source)) !== null) {
+ const key = (match[1] ?? "").trim();
+ const hasDefault = match[2] !== undefined;
+ if (!hasDefault && !seen.has(key)) {
+ seen.add(key);
+ findings.push({
+ code: "mustache_placeholder_without_default",
+ severity: "warning",
+ message: `Placeholder {{${key}}} has no default value. This composition will show raw "{{${key}}}" when rendered standalone (preview, lint, render without a parent).`,
+ fixHint: `Add a default: {{${key}:your default value}}. This ensures the composition renders correctly both standalone and when used with data-props.`,
+ snippet: match[0],
+ });
+ }
+ }
+ return findings;
+ },
+
+ // unused_data_props_key (inline templates only — cross-file not supported)
+ ({ rawSource }) => {
+ const findings: HyperframeLintFinding[] = [];
+ const placeholderRe = /\{\{(\s*[\w.-]+\s*)(?::[^}]*)?\}\}/g;
+
+ // Use rawSource (not template-stripped source) to find hosts with data-props
+ const rawTags = extractOpenTags(rawSource);
+ for (const tag of rawTags) {
+ // Only check hosts that reference inline templates (same-file)
+ const compId = readAttr(tag.raw, "data-composition-id");
+ if (!compId) continue;
+ if (readAttr(tag.raw, "data-composition-src")) continue; // external — can't check
+
+ const dataPropsMatch = tag.raw.match(/\bdata-props\s*=\s*(?:'([^']*)'|"([^"]*)")/i);
+ if (!dataPropsMatch) continue;
+
+ const propsRaw = (dataPropsMatch[1] ?? dataPropsMatch[2] ?? "")
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/&/g, "&");
+ let propsKeys: Set;
+ try {
+ const parsed = JSON.parse(propsRaw);
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) continue;
+ propsKeys = new Set(Object.keys(parsed));
+ } catch {
+ continue; // invalid JSON is caught by invalid_data_props_json rule
+ }
+ if (propsKeys.size === 0) continue;
+
+ // Find the matching inline template
+ const templateRe = new RegExp(
+ `]*id=["']${compId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-template["'][^>]*>([\\s\\S]*?)`,
+ "i",
+ );
+ const templateMatch = rawSource.match(templateRe);
+ if (!templateMatch?.[1]) continue;
+
+ // Collect all placeholder keys in the template
+ const templateKeys = new Set();
+ let pMatch: RegExpExecArray | null;
+ const pRe = new RegExp(placeholderRe.source, placeholderRe.flags);
+ while ((pMatch = pRe.exec(templateMatch[1])) !== null) {
+ templateKeys.add((pMatch[1] ?? "").trim());
+ }
+
+ // Warn about props keys that don't match any placeholder
+ for (const key of propsKeys) {
+ if (!templateKeys.has(key)) {
+ const elementId = readAttr(tag.raw, "id") || undefined;
+ findings.push({
+ code: "unused_data_props_key",
+ severity: "warning",
+ message: `data-props key "${key}" on <${tag.name}${elementId ? ` id="${elementId}"` : ""}> does not match any {{${key}}} placeholder in the "${compId}" template. Possible typo.`,
+ elementId,
+ fixHint: `Check the key name. Available placeholders in the template: ${[...templateKeys].map((k) => `{{${k}}}`).join(", ") || "(none found)"}`,
+ snippet: truncateSnippet(tag.raw),
+ });
+ }
+ }
+ }
+ return findings;
+ },
+
// requestanimationframe_in_composition
({ scripts }) => {
const findings: HyperframeLintFinding[] = [];
diff --git a/packages/core/src/parsers/htmlParser.ts b/packages/core/src/parsers/htmlParser.ts
index 2027ee895..9fe29e131 100644
--- a/packages/core/src/parsers/htmlParser.ts
+++ b/packages/core/src/parsers/htmlParser.ts
@@ -272,7 +272,7 @@ export function parseHtml(html: string): ParsedHtml {
const sourceHeight = sourceHeightAttr ? parseInt(sourceHeightAttr, 10) : undefined;
// Parse variable values if present
- const variableValuesAttr = el.getAttribute("data-variable-values");
+ const variableValuesAttr = el.getAttribute("data-props");
let variableValues: Record | undefined;
if (variableValuesAttr) {
try {
diff --git a/packages/core/src/runtime/compositionLoader.ts b/packages/core/src/runtime/compositionLoader.ts
index a2958745b..ccd1d5f05 100644
--- a/packages/core/src/runtime/compositionLoader.ts
+++ b/packages/core/src/runtime/compositionLoader.ts
@@ -1,3 +1,113 @@
+// ── Prop interpolation (browser-safe) ─────────────────────────────────────
+
+/** Matches `{{key}}` and `{{key:default value}}`. */
+const MUSTACHE_RE = /\{\{(\s*[\w.-]+\s*)(?::([^}]*))?\}\}/g;
+
+function parseVariableValues(raw: string | null): Record | null {
+ if (!raw) return null;
+ try {
+ const parsed = JSON.parse(raw);
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
+ return parsed as Record;
+ } catch {
+ return null;
+ }
+}
+
+function escapeJsString(str: string): string {
+ return str
+ .replace(/\\/g, "\\\\")
+ .replace(/"/g, '\\"')
+ .replace(/'/g, "\\'")
+ .replace(/`/g, "\\`")
+ .replace(/\$\{/g, "\\${")
+ .replace(/<\/(script)/gi, "<\\/$1");
+}
+
+function interpolateScriptContent(
+ content: string,
+ values?: Record | null,
+): string {
+ if (!content) return content;
+ const vals = values ?? {};
+ return content.replace(MUSTACHE_RE, (_match, rawKey: string, rawDefault: string | undefined) => {
+ const key = rawKey.trim();
+ if (key in vals) {
+ const val = vals[key];
+ if (typeof val === "string") return escapeJsString(val);
+ return String(val);
+ }
+ if (rawDefault !== undefined) return escapeJsString(rawDefault);
+ return _match;
+ });
+}
+
+/** Raw interpolation for CSS — HTML entity escaping is invalid in CSS. */
+function interpolateCss(
+ content: string,
+ values?: Record | null,
+): string {
+ if (!content) return content;
+ const vals = values ?? {};
+ return content.replace(MUSTACHE_RE, (_match, rawKey: string, rawDefault: string | undefined) => {
+ const key = rawKey.trim();
+ if (key in vals) return String(vals[key]);
+ if (rawDefault !== undefined) return rawDefault;
+ return _match;
+ });
+}
+
+/**
+ * Walk a parsed DOM document and interpolate {{key}} placeholders in-place,
+ * using context-appropriate escaping: HTML-escaped for text nodes and
+ * attributes, raw for CSS, JS-escaped for scripts.
+ */
+function interpolateParsedDocument(
+ doc: Document,
+ values?: Record | null,
+): void {
+ // CSS: raw interpolation
+ for (const style of Array.from(doc.querySelectorAll("style"))) {
+ const text = style.textContent || "";
+ if (MUSTACHE_RE.test(text)) {
+ MUSTACHE_RE.lastIndex = 0;
+ style.textContent = interpolateCss(text, values);
+ }
+ }
+ // Scripts: JS-escaped interpolation
+ for (const script of Array.from(doc.querySelectorAll("script"))) {
+ const text = script.textContent || "";
+ if (MUSTACHE_RE.test(text)) {
+ MUSTACHE_RE.lastIndex = 0;
+ script.textContent = interpolateScriptContent(text, values);
+ }
+ }
+ // Text nodes and attributes: raw replacement. The browser auto-escapes
+ // when rendering textContent / setAttribute, so we must NOT HTML-escape here.
+ const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
+ let node: Text | null;
+ while ((node = walker.nextNode() as Text | null)) {
+ const parent = node.parentElement;
+ if (parent && (parent.tagName === "STYLE" || parent.tagName === "SCRIPT")) continue;
+ const text = node.textContent || "";
+ if (MUSTACHE_RE.test(text)) {
+ MUSTACHE_RE.lastIndex = 0;
+ node.textContent = interpolateCss(text, values); // raw replacement
+ }
+ }
+ // Attributes
+ for (const el of Array.from(doc.querySelectorAll("*"))) {
+ for (const attr of Array.from(el.attributes)) {
+ if (MUSTACHE_RE.test(attr.value)) {
+ MUSTACHE_RE.lastIndex = 0;
+ el.setAttribute(attr.name, interpolateCss(attr.value, values)); // raw replacement
+ }
+ }
+ }
+}
+
+// ── Composition loader types ──────────────────────────────────────────────
+
type LoadExternalCompositionsParams = {
injectedStyles: HTMLStyleElement[];
injectedScripts: HTMLScriptElement[];
@@ -215,12 +325,26 @@ export async function loadInlineTemplateCompositions(
`template#${CSS.escape(compId)}-template`,
)!;
+ // Interpolate {{key}} and {{key:default}} placeholders using data-props from host.
+ // Always run interpolation so defaults resolve even when data-props is absent.
+ const varValues = parseVariableValues(host.getAttribute("data-props"));
+ // Serialize template content to HTML, parse as a full document for
+ // interpolateParsedDocument (which needs querySelectorAll on
+ {{title:Card Title}}
+ {{price:$0}}/mo
+
+
+
+
+```
+
+**Rules:**
+
+- `data-props` value must be valid JSON: `'{"key":"value"}'`
+- `{{key}}` — replaced with value, or left as-is if no value provided
+- `{{key:default}}` — replaced with value, or the default if no value provided
+- Whitespace OK: `{{ key }}`, `{{ key : default }}`
+- Works in HTML content, CSS `
+ {{value:$0}}
+ {{label:Metric}}
+
+
+
+
+```
+
+**Root (index.html) — 3 instances:**
+
+```html
+
+
+
+
+
+```
+
+**Key:** Each host needs a unique `data-composition-id`. The `{{key}}` placeholders work in HTML, CSS, and scripts.