From 7072aaa29d4b4a626713adddfede7aa1e4cdba3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 3 Apr 2026 18:05:05 +0200 Subject: [PATCH 1/3] feat(core): add data-props interpolation for parameterized sub-compositions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add {{key}} mustache-style interpolation to sub-compositions, enabling component reuse with different data. Pass a JSON object via data-props on the host element; placeholders in the sub-composition's HTML, CSS, and scripts are replaced at build time, render time, and in the live preview. - New interpolateProps.ts utility with HTML-escaping (XSS-safe) - Wired into htmlBundler (build-time), compositionLoader (runtime), and producer htmlCompiler (render-time) - Renamed data-variable-values → data-props (shorter, saves tokens) - 22 new tests (19 unit + 3 integration), 444/444 total passing - Updated skill docs, CLI docs, and patterns with usage examples Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/docs/compositions.md | 34 +++-- .../core/src/compiler/htmlBundler.test.ts | 102 +++++++++++++++ packages/core/src/compiler/htmlBundler.ts | 32 ++++- packages/core/src/compiler/index.ts | 3 + .../src/compiler/interpolateProps.test.ts | 116 ++++++++++++++++++ .../core/src/compiler/interpolateProps.ts | 82 +++++++++++++ packages/core/src/generators/hyperframes.ts | 2 +- packages/core/src/index.ts | 7 ++ packages/core/src/parsers/htmlParser.ts | 2 +- .../core/src/runtime/compositionLoader.ts | 44 ++++++- .../producer/src/services/htmlCompiler.ts | 8 ++ skills/hyperframes-compose/SKILL.md | 73 ++++++++++- skills/hyperframes-compose/patterns.md | 63 ++++++++++ 13 files changed, 548 insertions(+), 20 deletions(-) create mode 100644 packages/core/src/compiler/interpolateProps.test.ts create mode 100644 packages/core/src/compiler/interpolateProps.ts diff --git a/packages/cli/src/docs/compositions.md b/packages/cli/src/docs/compositions.md index d9c809a33..60df355cf 100644 --- a/packages/cli/src/docs/compositions.md +++ b/packages/cli/src/docs/compositions.md @@ -20,14 +20,34 @@ 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}}` placeholders are replaced in HTML, CSS, and scripts +- Values are HTML-escaped (XSS-safe) +- Unmatched placeholders are preserved + +## 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..d2dadc5fc 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -227,4 +227,106 @@ 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,

"); + }); }); diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index 7933350cb..44d26aee7 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -5,6 +5,7 @@ import { transformSync } from "esbuild"; import { compileHtml, type MediaDurationProber } from "./htmlCompiler"; import { rewriteAssetPaths, rewriteCssAssetUrls } from "./rewriteSubCompPaths"; import { validateHyperframeHtmlContract } from "./staticGuard"; +import { parseVariableValues, interpolateProps, interpolateScriptProps } from "./interpolateProps"; /** * Parse an HTML string into a document. Fragments (without a full document @@ -431,7 +432,15 @@ export async function bundleToSingleHtml( continue; } - const compDoc = parseHTMLContent(compHtml); + // Parse variable values from host element for prop interpolation + const variableValues = parseVariableValues(hostEl.getAttribute("data-props")); + + // Interpolate {{key}} placeholders in the composition HTML before parsing + const interpolatedCompHtml = variableValues + ? interpolateProps(compHtml, variableValues) + : compHtml; + + const compDoc = parseHTMLContent(interpolatedCompHtml); const compId = hostEl.getAttribute("data-composition-id"); const contentRoot = compDoc.querySelector("template"); const contentHtml = contentRoot ? contentRoot.innerHTML || "" : compDoc.body.innerHTML || ""; @@ -441,7 +450,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 (e.g., {{accentColor}} in style rules) + const interpolatedCss = variableValues ? interpolateProps(cssText, variableValues) : cssText; + compStyleChunks.push(rewriteCssAssetUrls(interpolatedCss, src)); s.remove(); } for (const s of [...contentDoc.querySelectorAll("script")]) { @@ -453,8 +465,13 @@ 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 = variableValues + ? interpolateScriptProps(scriptText, variableValues) + : scriptText; 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(); @@ -510,7 +527,14 @@ export async function bundleToSingleHtml( // Get template content and inject into host const templateHtml = templateEl.innerHTML || ""; - const innerDoc = parseHTMLContent(templateHtml); + + // Interpolate {{key}} placeholders in inline template compositions + const templateVarValues = parseVariableValues(host.getAttribute("data-props")); + const interpolatedTemplateHtml = templateVarValues + ? interpolateProps(templateHtml, templateVarValues) + : templateHtml; + + const innerDoc = parseHTMLContent(interpolatedTemplateHtml); const innerRoot = innerDoc.querySelector(`[data-composition-id="${compId}"]`); if (innerRoot) { diff --git a/packages/core/src/compiler/index.ts b/packages/core/src/compiler/index.ts index c7e1678ea..b653d3d35 100644 --- a/packages/core/src/compiler/index.ts +++ b/packages/core/src/compiler/index.ts @@ -22,3 +22,6 @@ export { type HyperframeStaticFailureReason, type HyperframeStaticGuardResult, } from "./staticGuard"; + +// Prop interpolation +export { interpolateProps, interpolateScriptProps, 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..54fa33645 --- /dev/null +++ b/packages/core/src/compiler/interpolateProps.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from "vitest"; +import { interpolateProps, interpolateScriptProps, 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"); + }); +}); + +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 escape special characters in scripts", () => { + const result = interpolateScriptProps('const s = "{{val}}";', { val: "A & B" }); + expect(result).toBe('const s = "A & B";'); + }); +}); diff --git a/packages/core/src/compiler/interpolateProps.ts b/packages/core/src/compiler/interpolateProps.ts new file mode 100644 index 000000000..901b76005 --- /dev/null +++ b/packages/core/src/compiler/interpolateProps.ts @@ -0,0 +1,82 @@ +/** + * Interpolate `{{key}}` mustache-style placeholders in HTML content + * using values from a `data-props` JSON attribute. + * + * Supports: + * - `{{key}}` — replaced with the value (HTML-escaped for safety) + * - Nested keys are NOT supported (flat key-value only) + * - Unmatched placeholders are left as-is (no error) + * + * Values are coerced to strings. Numbers and booleans are stringified. + */ + +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, "'"); +} + +/** + * Interpolate `{{key}}` placeholders in an HTML string using the provided values. + * Values are HTML-escaped to prevent XSS. Unmatched placeholders are preserved. + */ +export function interpolateProps( + html: string, + values: Record, +): string { + if (!html || Object.keys(values).length === 0) return html; + return html.replace(MUSTACHE_RE, (_match, rawKey: string) => { + const key = rawKey.trim(); + if (key in values) { + return escapeHtml(String(values[key])); + } + return _match; // preserve unmatched placeholders + }); +} + +/** + * Interpolate props in script content. Values are NOT HTML-escaped here + * since they'll be used as JavaScript string values. + * Replaces `{{key}}` with the raw string value. + */ +export function interpolateScriptProps( + scriptContent: string, + values: Record, +): string { + if (!scriptContent || Object.keys(values).length === 0) return scriptContent; + return scriptContent.replace(MUSTACHE_RE, (_match, rawKey: string) => { + const key = rawKey.trim(); + if (key in values) { + const val = values[key]; + // For strings, return the raw value (caller wraps in quotes if needed) + // For numbers/booleans, return the stringified value + return String(val); + } + 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..dcd5a7d4b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -126,6 +126,13 @@ export { rewriteCssAssetUrls, } from "./compiler/rewriteSubCompPaths"; +// Prop interpolation +export { + interpolateProps, + interpolateScriptProps, + parseVariableValues, +} from "./compiler/interpolateProps"; + // Inline scripts export { HYPERFRAME_RUNTIME_ARTIFACTS, 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..e2f690bd2 100644 --- a/packages/core/src/runtime/compositionLoader.ts +++ b/packages/core/src/runtime/compositionLoader.ts @@ -1,3 +1,38 @@ +// ── Prop interpolation (browser-safe) ───────────────────────────────────── + +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 escapeHtml(str: string): string { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function interpolateHtml(html: string, values: Record): string { + if (!html || Object.keys(values).length === 0) return html; + return html.replace(MUSTACHE_RE, (_match, rawKey: string) => { + const key = rawKey.trim(); + if (key in values) return escapeHtml(String(values[key])); + return _match; + }); +} + +// ── Composition loader types ────────────────────────────────────────────── + type LoadExternalCompositionsParams = { injectedStyles: HTMLStyleElement[]; injectedScripts: HTMLScriptElement[]; @@ -277,7 +312,14 @@ export async function loadExternalCompositions( if (!response.ok) { throw new Error(`HTTP ${response.status}`); } - const html = await response.text(); + let html = await response.text(); + + // Interpolate {{key}} placeholders using data-props from host + const varValues = parseVariableValues(host.getAttribute("data-props")); + if (varValues) { + html = interpolateHtml(html, varValues); + } + const parser = new DOMParser(); const doc = parser.parseFromString(html, "text/html"); const template = diff --git a/packages/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index 7ac6a0b53..f4b356779 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -22,6 +22,8 @@ import { type UnresolvedElement, rewriteAssetPaths, rewriteCssAssetUrls, + interpolateProps, + parseVariableValues, } from "@hyperframes/core"; import { extractVideoMetadata, extractAudioMetadata } from "../utils/ffprobe.js"; import { @@ -507,6 +509,12 @@ function inlineSubCompositions( continue; } + // Interpolate {{key}} placeholders using data-props from the host element + const varValues = parseVariableValues(host.getAttribute("data-props")); + if (varValues) { + compHtml = interpolateProps(compHtml, varValues); + } + const compDoc = parseHTML(compHtml).document; const compId = host.getAttribute("data-composition-id"); diff --git a/skills/hyperframes-compose/SKILL.md b/skills/hyperframes-compose/SKILL.md index 6d873116c..f8bc6f93e 100644 --- a/skills/hyperframes-compose/SKILL.md +++ b/skills/hyperframes-compose/SKILL.md @@ -37,12 +37,13 @@ When no `visual-style.md` or animation direction is provided, follow [house-styl ### Composition Clips -| Attribute | Required | Values | -| ---------------------------- | -------- | -------------------------------------------- | -| `data-composition-id` | Yes | Unique composition ID | -| `data-duration` | Yes | Takes precedence over GSAP timeline duration | -| `data-width` / `data-height` | Yes | Pixel dimensions (1920x1080 or 1080x1920) | -| `data-composition-src` | No | Path to external HTML file | +| Attribute | Required | Values | +| ---------------------------- | -------- | ----------------------------------------------------------------- | +| `data-composition-id` | Yes | Unique composition ID | +| `data-duration` | Yes | Takes precedence over GSAP timeline duration | +| `data-width` / `data-height` | Yes | Pixel dimensions (1920x1080 or 1080x1920) | +| `data-composition-src` | No | Path to external HTML file | +| `data-props` | No | JSON object passed to sub-composition for `{{key}}` interpolation | ## Composition Structure @@ -70,6 +71,65 @@ Every composition is a ` +``` + +**Root (index.html) — 3 instances:** + +```html +
+ +
+ +
+``` + +**Key:** Each host needs a unique `data-composition-id`. The `{{key}}` placeholders work in HTML, CSS, and scripts. From 3a0a0a239061b4daa0bd6b793c720246d19d06eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 3 Apr 2026 18:31:02 +0200 Subject: [PATCH 2/3] feat(core): add {{key:default}} syntax for standalone composition rendering Extend mustache interpolation to support inline defaults: {{key:fallback}}. When a composition is rendered standalone (no parent data-props), defaults are used so CSS stays valid, text renders, and lint/preview/render all work. - Updated regex to capture optional :default group - All three interpolation functions (HTML, CSS, script) support defaults - interpolateProps/interpolateCssProps/interpolateScriptProps accept optional null values (standalone mode) - 21 new tests for default behavior, 465/465 total passing - Updated skill docs, patterns, and CLI docs with {{key:default}} examples Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/docs/compositions.md | 13 +- .../core/src/compiler/htmlBundler.test.ts | 25 +++ packages/core/src/compiler/htmlBundler.ts | 59 ++++--- packages/core/src/compiler/index.ts | 7 +- .../src/compiler/interpolateProps.test.ts | 146 +++++++++++++++++- .../core/src/compiler/interpolateProps.ts | 111 +++++++++---- packages/core/src/index.ts | 1 + .../core/src/runtime/compositionLoader.ts | 126 ++++++++++++--- .../producer/src/services/htmlCompiler.ts | 34 +++- skills/hyperframes-compose/SKILL.md | 16 +- skills/hyperframes-compose/patterns.md | 6 +- 11 files changed, 447 insertions(+), 97 deletions(-) diff --git a/packages/cli/src/docs/compositions.md b/packages/cli/src/docs/compositions.md index 60df355cf..81dedb539 100644 --- a/packages/cli/src/docs/compositions.md +++ b/packages/cli/src/docs/compositions.md @@ -36,17 +36,18 @@ Reuse a composition with different data using `data-props`: ``` - `data-props` accepts a JSON object -- `{{key}}` placeholders are replaced in HTML, CSS, and scripts -- Values are HTML-escaped (XSS-safe) -- Unmatched placeholders are preserved +- `{{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 diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index d2dadc5fc..1db13ba32 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -329,4 +329,29 @@ describe("bundleToSingleHtml", () => { // 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 44d26aee7..87f668d83 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -5,7 +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 } from "./interpolateProps"; +import { + parseVariableValues, + interpolateProps, + interpolateScriptProps, + interpolateCssProps, +} from "./interpolateProps"; /** * Parse an HTML string into a document. Fragments (without a full document @@ -432,15 +437,12 @@ export async function bundleToSingleHtml( continue; } - // Parse variable values from host element for prop interpolation + // Parse variable values from host element for per-context interpolation const variableValues = parseVariableValues(hostEl.getAttribute("data-props")); - // Interpolate {{key}} placeholders in the composition HTML before parsing - const interpolatedCompHtml = variableValues - ? interpolateProps(compHtml, variableValues) - : compHtml; - - const compDoc = parseHTMLContent(interpolatedCompHtml); + // 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"); const contentHtml = contentRoot ? contentRoot.innerHTML || "" : compDoc.body.innerHTML || ""; @@ -451,8 +453,8 @@ export async function bundleToSingleHtml( for (const s of [...contentDoc.querySelectorAll("style")]) { const cssText = s.textContent || ""; - // Interpolate props in CSS (e.g., {{accentColor}} in style rules) - const interpolatedCss = variableValues ? interpolateProps(cssText, variableValues) : cssText; + // 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(); } @@ -467,9 +469,7 @@ export async function bundleToSingleHtml( } else { const scriptText = s.textContent || ""; // Interpolate props in script content (e.g., {{duration}} in GSAP timelines) - const interpolatedScript = variableValues - ? interpolateScriptProps(scriptText, variableValues) - : scriptText; + const interpolatedScript = interpolateScriptProps(scriptText, variableValues); compScriptChunks.push( `(function(){ try { ${interpolatedScript} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`, ); @@ -491,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"); @@ -525,25 +534,23 @@ export async function bundleToSingleHtml( if (!host) continue; if (host.children.length > 0) continue; // already has content - // Get template content and inject into host + // Get template content and parse (interpolation happens per-context below) const templateHtml = templateEl.innerHTML || ""; - - // Interpolate {{key}} placeholders in inline template compositions const templateVarValues = parseVariableValues(host.getAttribute("data-props")); - const interpolatedTemplateHtml = templateVarValues - ? interpolateProps(templateHtml, templateVarValues) - : templateHtml; + + // Interpolate HTML content (always run so defaults resolve even without data-props) + const interpolatedTemplateHtml = interpolateProps(templateHtml, templateVarValues); const innerDoc = parseHTMLContent(interpolatedTemplateHtml); const innerRoot = innerDoc.querySelector(`[data-composition-id="${compId}"]`); if (innerRoot) { - // Hoist styles into the collected style chunks + // Hoist styles into the collected style chunks (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 into the collected script chunks (JS: JS-escaped interpolation) for (const scriptEl of [...innerRoot.querySelectorAll("script")]) { const externalSrc = (scriptEl.getAttribute("src") || "").trim(); if (externalSrc) { @@ -551,8 +558,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(); @@ -569,7 +577,7 @@ export async function bundleToSingleHtml( } 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")]) { @@ -579,8 +587,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(); diff --git a/packages/core/src/compiler/index.ts b/packages/core/src/compiler/index.ts index b653d3d35..dceefb7c2 100644 --- a/packages/core/src/compiler/index.ts +++ b/packages/core/src/compiler/index.ts @@ -24,4 +24,9 @@ export { } from "./staticGuard"; // Prop interpolation -export { interpolateProps, interpolateScriptProps, parseVariableValues } from "./interpolateProps"; +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 index 54fa33645..d5061329d 100644 --- a/packages/core/src/compiler/interpolateProps.test.ts +++ b/packages/core/src/compiler/interpolateProps.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from "vitest"; -import { interpolateProps, interpolateScriptProps, parseVariableValues } from "./interpolateProps"; +import { + interpolateProps, + interpolateScriptProps, + interpolateCssProps, + parseVariableValues, +} from "./interpolateProps"; describe("parseVariableValues", () => { it("parses valid JSON object", () => { @@ -93,6 +98,51 @@ describe("interpolateProps", () => { 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", () => { @@ -109,8 +159,100 @@ describe("interpolateScriptProps", () => { expect(result).toBe("const x = {{unknown}};"); }); - it("does not escape special characters in scripts", () => { + 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 index 901b76005..293ca6515 100644 --- a/packages/core/src/compiler/interpolateProps.ts +++ b/packages/core/src/compiler/interpolateProps.ts @@ -1,16 +1,21 @@ /** - * Interpolate `{{key}}` mustache-style placeholders in HTML content - * using values from a `data-props` JSON attribute. + * 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 (HTML-escaped for safety) + * - `{{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) - * - Unmatched placeholders are left as-is (no error) * * Values are coerced to strings. Numbers and booleans are stringified. */ -const MUSTACHE_RE = /\{\{(\s*[\w.-]+\s*)\}\}/g; +/** + * 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. @@ -42,41 +47,91 @@ function escapeHtml(str: string): string { } /** - * Interpolate `{{key}}` placeholders in an HTML string using the provided values. - * Values are HTML-escaped to prevent XSS. Unmatched placeholders are preserved. + * 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, + values?: Record | null, ): string { - if (!html || Object.keys(values).length === 0) return html; - return html.replace(MUSTACHE_RE, (_match, rawKey: 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 values) { - return escapeHtml(String(values[key])); + if (key in vals) { + return escapeHtml(String(vals[key])); } - return _match; // preserve unmatched placeholders + if (rawDefault !== undefined) { + return escapeHtml(rawDefault); + } + return _match; }); } /** - * Interpolate props in script content. Values are NOT HTML-escaped here - * since they'll be used as JavaScript string values. - * Replaces `{{key}}` with the raw string value. + * 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, + values?: Record | null, ): string { - if (!scriptContent || Object.keys(values).length === 0) return scriptContent; - return scriptContent.replace(MUSTACHE_RE, (_match, rawKey: string) => { - const key = rawKey.trim(); - if (key in values) { - const val = values[key]; - // For strings, return the raw value (caller wraps in quotes if needed) - // For numbers/booleans, return the stringified value - return String(val); - } - return _match; - }); + 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/index.ts b/packages/core/src/index.ts index dcd5a7d4b..e00f69a45 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -130,6 +130,7 @@ export { export { interpolateProps, interpolateScriptProps, + interpolateCssProps, parseVariableValues, } from "./compiler/interpolateProps"; diff --git a/packages/core/src/runtime/compositionLoader.ts b/packages/core/src/runtime/compositionLoader.ts index e2f690bd2..ccd1d5f05 100644 --- a/packages/core/src/runtime/compositionLoader.ts +++ b/packages/core/src/runtime/compositionLoader.ts @@ -1,6 +1,7 @@ // ── Prop interpolation (browser-safe) ───────────────────────────────────── -const MUSTACHE_RE = /\{\{(\s*[\w.-]+\s*)\}\}/g; +/** Matches `{{key}}` and `{{key:default value}}`. */ +const MUSTACHE_RE = /\{\{(\s*[\w.-]+\s*)(?::([^}]*))?\}\}/g; function parseVariableValues(raw: string | null): Record | null { if (!raw) return null; @@ -13,24 +14,98 @@ function parseVariableValues(raw: string | null): Record/g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/'/g, "\\'") + .replace(/`/g, "\\`") + .replace(/\$\{/g, "\\${") + .replace(/<\/(script)/gi, "<\\/$1"); } -function interpolateHtml(html: string, values: Record): string { - if (!html || Object.keys(values).length === 0) return html; - return html.replace(MUSTACHE_RE, (_match, rawKey: string) => { +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 values) return escapeHtml(String(values[key])); + 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 = { @@ -250,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}}

-

{{price}}/mo

+

{{title:Card Title}}

+

{{price:$0}}/mo

+`; + 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/producer/src/services/htmlCompiler.ts b/packages/producer/src/services/htmlCompiler.ts index 7eec1356b..392236f7a 100644 --- a/packages/producer/src/services/htmlCompiler.ts +++ b/packages/producer/src/services/htmlCompiler.ts @@ -22,7 +22,6 @@ import { type UnresolvedElement, rewriteAssetPaths, rewriteCssAssetUrls, - interpolateProps, interpolateScriptProps, interpolateCssProps, parseVariableValues, @@ -532,14 +531,16 @@ function inlineSubCompositions( : contentDoc.querySelector("[data-composition-id]"); const inferredCompId = innerRoot?.getAttribute("data-composition-id")?.trim() || null; - // Interpolate {{key}} and {{key:default}} in HTML text/attributes (always run for defaults) + // Interpolate {{key}} and {{key:default}} in HTML text/attributes (always run for defaults). + // Use raw replacement (interpolateCssProps) since linkedom auto-escapes on serialization — + // HTML-escaping here would double-escape (e.g., "A & B" → "A &amp; B"). { const bodyEl2 = contentDoc.querySelector("body"); if (bodyEl2) { for (const el of bodyEl2.querySelectorAll("*")) { if (el.tagName === "STYLE" || el.tagName === "SCRIPT") continue; for (const attr of [...el.attributes]) { - el.setAttribute(attr.name, interpolateProps(attr.value, varValues)); + el.setAttribute(attr.name, interpolateCssProps(attr.value, varValues)); } } const walk = contentDoc.createTreeWalker(bodyEl2, 4 /* NodeFilter.SHOW_TEXT */); @@ -547,7 +548,7 @@ function inlineSubCompositions( while ((textNode = walk.nextNode())) { const parent = textNode.parentNode as Element | null; if (parent && (parent.tagName === "STYLE" || parent.tagName === "SCRIPT")) continue; - textNode.textContent = interpolateProps(textNode.textContent || "", varValues); + textNode.textContent = interpolateCssProps(textNode.textContent || "", varValues); } } }