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 -
+ +
+
+ + + + ``` + +- `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": ``, + }); + + 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": ` + + +
+
+
+`, + }); + + 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": ` + + +
+
+
+`, + }); + + 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}}

{{price}}

', { + title: "Pro Plan", + price: "$19/mo", + }); + expect(result).toBe('

Pro Plan

$19/mo

'); + }); + + 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 = ``; + 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 = ``; + 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 = ``; + 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 = ` + + +
+
+
+ +`; + 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 = ` + + +
+
+
+ +`; + 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.