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') +}