Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/features/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions src/__tests__/templates/bindingSourcesAndTokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<source>` 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<string, string>).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 <p> would corrupt the URL.
const props = resolveDynamicProps(
{ htmlAttributes: { html: '{currentEntry.title}' } },
undefined,
ctx({ entryStack: [entry] }),
)
expect((props.htmlAttributes as Record<string, string>).html).toBe('Peak')
})

it('ignores a malformed htmlAttributes bag instead of throwing', () => {
const props = resolveDynamicProps(
{ htmlAttributes: { nested: { deep: 'x' } } as unknown as Record<string, string> },
undefined,
ctx({ entryStack: [entry] }),
)
expect(props.htmlAttributes).toEqual({ nested: { deep: 'x' } })
})
})
40 changes: 36 additions & 4 deletions src/core/templates/dynamicBindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
return Object.values(value).every((entry) => typeof entry === 'string')
}