From 3c2bb6f7c0c65b01a58eed352fed66f2296b2b9c Mon Sep 17 00:00:00 2001 From: Mostafa Sadeghi <205455727+mostafasadeghidev@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:18:20 +0200 Subject: [PATCH] fix(templates): interpolate tokens inside htmlAttributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveDynamicProps` interpolated every string-typed prop, then skipped anything that was not a string — including `htmlAttributes`, the one prop that holds strings a level down. Tokens written on an author-set attribute were never substituted: they shipped to the browser as literal text, so the attribute pointed nowhere. The asymmetry was invisible until you needed it. `href` on a link is a first-class string prop and interpolated fine, so authors reasonably expected `src` written on a custom tag to behave the same way. It did not: That is the shape a per-item hover video takes on an imported site, and the failure is silent — valid HTML, an attribute that resolves to nothing, no error anywhere. `htmlAttributes` is now walked as the string map it is. Attribute values are deliberately NOT run through the richtext shim: an attribute is a value, not a body, and wrapping a URL in `

` would corrupt it. Nothing else is descended into — `filters` on a loop holds configuration, not authored output. The no-token path still returns the caller's object untouched, so pages that use no tokens allocate nothing extra. All three render surfaces resolve through this function, so the publisher, the editor canvas, and ReadOnlyNodeTree stay identical. Co-Authored-By: Claude Fable 5 --- docs/features/templates.md | 11 +++ .../templates/bindingSourcesAndTokens.test.ts | 74 +++++++++++++++++++ src/core/templates/dynamicBindings.ts | 40 +++++++++- 3 files changed, 121 insertions(+), 4 deletions(-) diff --git a/docs/features/templates.md b/docs/features/templates.md index ad4816d90..a31a24ac9 100644 --- a/docs/features/templates.md +++ b/docs/features/templates.md @@ -232,6 +232,17 @@ Text props mix literal text + tokens: Source: `src/core/templates/tokenInterpolation.ts`. +### Where tokens are substituted + +`resolveDynamicProps` walks a node's props and interpolates: + +- **every string-typed prop** — `text`, `href`, `src`, `alt`, and any module's own string prop. Richtext prop keys (`html`, `richtext`, `*html`, `*richtext`) additionally render the interpolated value as markdown. +- **every value inside `htmlAttributes`** — the one prop holding strings a level down. An author writing `src="{currentEntry.video-link}"` on a custom tag gets the same substitution a first-class `href` prop gets. Attribute values are never markdown-rendered: an attribute is a value, not a body. + +Nothing else is descended into. `filters` on a loop, for example, is a free-form bag whose values are configuration rather than authored output. + +All three render surfaces — the publisher (`renderNode.ts`), the editor canvas (`NodeRenderer.tsx`), and `ReadOnlyNodeTree` — resolve through this one function, so a token behaves identically in all of them. + --- ## Editor canvas preview diff --git a/src/__tests__/templates/bindingSourcesAndTokens.test.ts b/src/__tests__/templates/bindingSourcesAndTokens.test.ts index 17712ba6f..6f940c30a 100644 --- a/src/__tests__/templates/bindingSourcesAndTokens.test.ts +++ b/src/__tests__/templates/bindingSourcesAndTokens.test.ts @@ -383,3 +383,77 @@ describe('frame builders', () => { expect(route.path).toBe('/') }) }) + +// --------------------------------------------------------------------------- +// Tokens inside htmlAttributes +// --------------------------------------------------------------------------- + +describe('resolveDynamicProps — tokens in htmlAttributes', () => { + const entry = { id: 'r1', fields: { 'video-link': 'https://cdn.example.com/hover.mp4', title: 'Peak' } } + + it('interpolates a token written on an author-set attribute', () => { + // The case this exists for: a custom `` tag whose `src` is bound + // per loop item. `href` on a link already worked because it is a + // first-class string prop; an attribute is one level down and was skipped, + // so the token shipped to the browser as literal text. + const props = resolveDynamicProps( + { tag: 'custom', customTag: 'source', htmlAttributes: { src: '{currentEntry.video-link}' } }, + undefined, + ctx({ entryStack: [entry] }), + ) + expect(props.htmlAttributes).toEqual({ src: 'https://cdn.example.com/hover.mp4' }) + }) + + it('leaves attributes without tokens untouched, and does not copy needlessly', () => { + const staticProps = { htmlAttributes: { 'data-w-id': 'abc-123', loading: 'lazy' } } + const props = resolveDynamicProps(staticProps, undefined, ctx({ entryStack: [entry] })) + expect(props).toBe(staticProps) + }) + + it('interpolates only the attributes that carry tokens', () => { + const props = resolveDynamicProps( + { htmlAttributes: { src: '{currentEntry.video-link}', 'data-w-id': 'abc-123' } }, + undefined, + ctx({ entryStack: [entry] }), + ) + expect(props.htmlAttributes).toEqual({ + src: 'https://cdn.example.com/hover.mp4', + 'data-w-id': 'abc-123', + }) + }) + + it('does not mutate the caller’s props object', () => { + const staticProps = { htmlAttributes: { src: '{currentEntry.video-link}' } } + resolveDynamicProps(staticProps, undefined, ctx({ entryStack: [entry] })) + expect(staticProps.htmlAttributes.src).toBe('{currentEntry.video-link}') + }) + + it('an unresolvable token becomes empty rather than shipping the literal', () => { + const props = resolveDynamicProps( + { htmlAttributes: { src: '{currentEntry.nope}' } }, + undefined, + ctx({ entryStack: [entry] }), + ) + expect(String((props.htmlAttributes as Record).src)).not.toContain('{currentEntry') + }) + + it('never markdown-renders an attribute value', () => { + // `isRichtextPropKey` must not reach attribute values — an attribute is a + // value, not a body, and wrapping it in

would corrupt the URL. + const props = resolveDynamicProps( + { htmlAttributes: { html: '{currentEntry.title}' } }, + undefined, + ctx({ entryStack: [entry] }), + ) + expect((props.htmlAttributes as Record).html).toBe('Peak') + }) + + it('ignores a malformed htmlAttributes bag instead of throwing', () => { + const props = resolveDynamicProps( + { htmlAttributes: { nested: { deep: 'x' } } as unknown as Record }, + undefined, + ctx({ entryStack: [entry] }), + ) + expect(props.htmlAttributes).toEqual({ nested: { deep: 'x' } }) + }) +}) diff --git a/src/core/templates/dynamicBindings.ts b/src/core/templates/dynamicBindings.ts index 941dadcec..250ccf25d 100644 --- a/src/core/templates/dynamicBindings.ts +++ b/src/core/templates/dynamicBindings.ts @@ -164,14 +164,38 @@ export function resolveDynamicProps( // tokens so the loop below does nothing). const target = resolved ?? staticProps let mutated = resolved !== null - for (const key of Object.keys(target)) { - const v = target[key] - if (typeof v !== 'string') continue - if (!containsTokens(v)) continue + const ensureCopy = () => { if (!mutated) { resolved = { ...staticProps } mutated = true } + } + + for (const key of Object.keys(target)) { + const v = target[key] + + // `htmlAttributes` is the one prop that holds strings a level down, and + // its values are authored the same way every other string prop is — an + // `href` written on a link interpolates, so a `src` written on a custom + // tag has to as well. Without this the token ships to the browser as + // literal text and the attribute silently points nowhere. + if (key === HTML_ATTRIBUTES_PROP_KEY) { + if (!isStringRecord(v)) continue + const withTokens = Object.entries(v).filter(([, av]) => containsTokens(av)) + if (withTokens.length === 0) continue + ensureCopy() + const attrs = { ...v } + for (const [attrName, attrValue] of withTokens) { + // Never markdown-rendered: an attribute value is a value, not a body. + attrs[attrName] = interpolateTokens(attrValue, context) + } + resolved![key] = attrs + continue + } + + if (typeof v !== 'string') continue + if (!containsTokens(v)) continue + ensureCopy() const interpolated = interpolateTokens(v, context) resolved![key] = isRichtextPropKey(key) ? renderMarkdownToHtml(interpolated) @@ -180,3 +204,11 @@ export function resolveDynamicProps( return resolved ?? staticProps } + +/** Prop holding author-set HTML attributes — see the loop in `resolveDynamicProps`. */ +const HTML_ATTRIBUTES_PROP_KEY = 'htmlAttributes' + +function isStringRecord(value: unknown): value is Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + return Object.values(value).every((entry) => typeof entry === 'string') +}