Skip to content

Commit cc1b050

Browse files
authored
fix(templates): resolve dynamic data in outlet previews (CoreBunch#78)
1 parent 40764bf commit cc1b050

5 files changed

Lines changed: 209 additions & 10 deletions

File tree

docs/features/templates.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ The design canvas renders the active document the way it publishes: **inside its
266266
- `resolveEditorWrapperTemplates(site, activeDoc)` (`canvasComposition.ts`) returns the templates that WRAP the active document, outermost-first — the editor-side mirror of `resolveTemplateChain`. Editing a page, a `postTypes` template, or a `notFound` template ⇒ wrapped by the `everywhere` layout; editing the `everywhere` layout ⇒ nothing wraps it.
267267
- Wrappers render **read-only** via `ReadOnlyNodeTree` with the editable document spliced into the innermost wrapper's `base.outlet` (the `outletSlot` prop replaces the outlet node, mirroring `spliceIntoOutlet`). Only the active document's nodes keep `data-node-id` + handlers, so selection / hover / DnD stay scoped to it; the chrome is pixel-identical but non-interactive.
268268
- Body ownership mirrors the publisher: the iframe `<body>` carries the OUTERMOST wrapper body's classes, and the active document renders as its body *children* (its own `base.body` is dropped, just as the composer drops the inner body).
269-
- `ReadOnlyNodeTree` (`src/modules/base/utils/ReadOnlyNodeTree.tsx`) is the shared non-interactive tree renderer — also used by `VCInlineTree` for inlined Visual Component bodies. It mirrors the publisher's per-node output: `classIds` resolve to class names AND `inlineStyles` are applied as the element's `style` (via `bagToReactStyle` from `@core/publisher`, the same sanitisation gate as the published `style="…"` attribute) — so composed content (template chrome, outlet previews, VC bodies) renders with the same inline styles as the editable canvas and the published page.
269+
- `ReadOnlyNodeTree` (`src/modules/base/utils/ReadOnlyNodeTree.tsx`) is the shared non-interactive tree renderer — also used by `VCInlineTree` for inlined Visual Component bodies. It mirrors the publisher's per-node output: `classIds` resolve to class names, `inlineStyles` are applied as the element's `style` (via `bagToReactStyle` from `@core/publisher`, the same sanitisation gate as the published `style="…"` attribute), template bindings/tokens resolve against the canvas render context when one is provided, and `base.loop` nodes use the same live preview items as the editable canvas. Composed content (template chrome, outlet previews, VC bodies) therefore renders with the same styles and dynamic data as the editable canvas and the published page.
270270
- **Navigation guard:** the canvas iframe is an editing surface, never a browsing surface. `IframeFrameSurface` installs a capture-phase `click`/`auxclick`/`submit` listener on the iframe document that `preventDefault`s link navigation and form submission (without `stopPropagation`, so node selection still works) — so clicking a logo/link in the read-only template chrome, an inlined component, or any authored content never reloads the frame. Applies to both the design canvas and the live/preview frame.
271271
- **Read-only affordance:** `ReadOnlyNodeTree` stamps `data-instatic-readonly-{label,kind,id}` on every read-only element (the source is named by `CanvasComposedTree`, `OutletEditor`, and `VCInlineTree`). `BreakpointFrame` shows a cursor-following `CursorTooltip` ("Part of X — double-click to edit") on hover, and `IframeFrameSurface` opens the source on double-click (`onReadonlyOpen``openPageInCanvas` / `setActiveDocument`). The read-only markers ride the optional fields on `NodeWrapperProps`.
272272

src/__tests__/canvas/templatePreviewBindings.test.tsx

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,98 @@ describe('canvas template preview bindings', () => {
149149
expect(combinedCanvasText()).toContain('No image selected')
150150
})
151151
})
152+
153+
it('resolves loop currentEntry tokens when an everywhere template previews a page in its outlet', async () => {
154+
globalThis.fetch = (async (input: RequestInfo | URL) => {
155+
const url = String(input)
156+
if (url === '/admin/api/cms/data/tables/posts') {
157+
return new Response(JSON.stringify({ table: postsTable }), { status: 200 })
158+
}
159+
if (url.startsWith('/admin/api/cms/data/tables/posts/loop-preview')) {
160+
return new Response(JSON.stringify({
161+
items: [
162+
{
163+
id: 'post-1',
164+
fields: {
165+
id: 'post-1',
166+
title: 'Published Blog Post',
167+
slug: 'published-blog-post',
168+
body: 'Body',
169+
permalink: '/posts/published-blog-post',
170+
publishedAt: '2026-05-01T10:00:00.000Z',
171+
},
172+
},
173+
],
174+
totalItems: 1,
175+
}), { status: 200 })
176+
}
177+
if (url === '/admin/api/cms/data/tables') {
178+
return new Response(JSON.stringify({ tables: [postsTable] }), { status: 200 })
179+
}
180+
181+
return new Response('{}', { status: 404 })
182+
}) as typeof fetch
183+
184+
const mainRoot = makeNode({ id: 'main-root', moduleId: 'base.body', children: ['main-outlet'] })
185+
const mainOutlet = makeNode({ id: 'main-outlet', moduleId: 'base.outlet', props: {} })
186+
const mainTemplate = makePage({
187+
id: 'main-template',
188+
title: 'Main',
189+
slug: 'main',
190+
rootNodeId: 'main-root',
191+
nodes: { 'main-root': mainRoot, 'main-outlet': mainOutlet },
192+
template: {
193+
enabled: true,
194+
target: { kind: 'everywhere' },
195+
priority: 100,
196+
},
197+
})
198+
199+
const blogRoot = makeNode({ id: 'blog-root', moduleId: 'base.body', children: ['post-loop'] })
200+
const postLoop = makeNode({
201+
id: 'post-loop',
202+
moduleId: 'base.loop',
203+
props: {
204+
sourceId: 'data.rows',
205+
filters: { tableId: 'posts' },
206+
orderBy: 'publishedAt',
207+
direction: 'desc',
208+
limit: 6,
209+
offset: 0,
210+
pagination: 'none',
211+
pageSize: 10,
212+
tag: 'section',
213+
customTag: '',
214+
},
215+
children: ['post-title'],
216+
})
217+
const postTitle = makeNode({
218+
id: 'post-title',
219+
moduleId: 'base.text',
220+
props: { text: '{currentEntry.title}', tag: 'h2' },
221+
})
222+
const blogPage = makePage({
223+
id: 'blog',
224+
title: 'Blog',
225+
slug: 'blog',
226+
rootNodeId: 'blog-root',
227+
nodes: { 'blog-root': blogRoot, 'post-loop': postLoop, 'post-title': postTitle },
228+
})
229+
230+
useEditorStore.setState({
231+
site: makeSite({ pages: [mainTemplate, blogPage] }),
232+
activePageId: mainTemplate.id,
233+
activeDocument: { kind: 'page', pageId: mainTemplate.id },
234+
} as Parameters<typeof useEditorStore.setState>[0])
235+
236+
renderCanvas()
237+
238+
await waitFor(() => {
239+
const text = combinedCanvasText()
240+
expect(text).toContain('Published Blog Post')
241+
expect(text).not.toContain('{currentEntry.title}')
242+
})
243+
})
152244
})
153245

154246
function canvasFrameDocs(): Document[] {

src/__tests__/editor/readOnlyNodeTree.test.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,27 @@ describe('ReadOnlyNodeTree — inline styles', () => {
101101
})
102102
})
103103

104+
describe('ReadOnlyNodeTree — dynamic preview context', () => {
105+
it('resolves currentEntry tokens in read-only template previews', () => {
106+
const nodes: Record<string, BaseNode> = {
107+
h1: textNode('h1', {
108+
props: { text: '{currentEntry.title}', tag: 'h1', htmlAttributes: {} },
109+
}),
110+
}
111+
const { container } = render(
112+
<ReadOnlyNodeTree
113+
nodes={nodes}
114+
rootNodeId="h1"
115+
classes={{}}
116+
templateContext={{ entryStack: [{ id: 'post-1', fields: { title: 'Dynamic Post' } }] }}
117+
/>,
118+
)
119+
const h1 = container.querySelector('h1')
120+
expect(h1).not.toBeNull()
121+
expect(h1!.textContent).toBe('Dynamic Post')
122+
})
123+
})
124+
104125
describe('bagToReactStyle', () => {
105126
it('passes fluid values like clamp() through unchanged', () => {
106127
expect(bagToReactStyle({ fontSize: 'clamp(46px, 10.5vw, 138px)' })).toEqual({

src/admin/pages/site/canvas/CanvasComposedTree.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,14 @@
2525
* `OutletEditor`.
2626
*/
2727

28-
import { useEffect, useRef, type ReactNode } from 'react'
28+
import { use, useEffect, useRef, type ReactNode } from 'react'
2929
import type { BaseNode, Page } from '@core/page-tree'
3030
import { classNamesForClassIds } from '@core/page-tree'
3131
import { useEditorStore } from '@site/store/store'
3232
import { ReadOnlyNodeTree } from '@modules/base/utils/ReadOnlyNodeTree'
3333
import { NodeRenderer } from './NodeRenderer'
3434
import { resolveEditorWrapperTemplates } from './canvasComposition'
35+
import { CanvasTemplateContext } from './CanvasContexts'
3536

3637
const NO_WRAPPERS: Page[] = []
3738

@@ -44,6 +45,7 @@ export function CanvasComposedTree({ page }: CanvasComposedTreeProps) {
4445
const site = useEditorStore((s) => s.site)
4546
const isVcMode = useEditorStore((s) => s.activeDocument?.kind === 'visualComponent')
4647
const styleRules = useEditorStore((s) => s.site?.styleRules ?? null)
48+
const templateContext = use(CanvasTemplateContext)
4749

4850
// Templates wrapping the active document (outermost-first). A Visual
4951
// Component edit surface is never a published route, so it is never wrapped.
@@ -75,6 +77,7 @@ export function CanvasComposedTree({ page }: CanvasComposedTreeProps) {
7577
classes={styleRules}
7678
outletSlot={composed}
7779
readonly={{ label: `${wrapper.title} template`, kind: 'page', targetId: wrapper.id }}
80+
templateContext={templateContext}
7881
/>
7982
)
8083
}

src/modules/base/utils/ReadOnlyNodeTree.tsx

Lines changed: 91 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,15 @@
3131
import type { ReactNode } from 'react'
3232
import { registry } from '@core/module-engine'
3333
import type { NodeWrapperProps as NodeWrapperPropsType } from '@core/module-engine'
34-
import type { BaseNode } from '@core/page-tree'
35-
import { classNamesForClassIds, type StyleRuleRegistry } from '@core/page-tree'
34+
import type { BaseNode, PageNode } from '@core/page-tree'
35+
import { classNamesForClassIds, resolveProps, type StyleRuleRegistry } from '@core/page-tree'
3636
import { bagToReactStyle } from '@core/publisher'
37+
import {
38+
effectiveNodeBindings,
39+
resolveDynamicProps,
40+
type TemplateRenderDataContext,
41+
} from '@core/templates/dynamicBindings'
42+
import { useLoopPreviewItems } from '@site/canvas/useLoopPreviewItems'
3743

3844
/**
3945
* Identifies the editable source a read-only region was composed from, so the
@@ -78,6 +84,11 @@ interface ReadOnlyNodeTreeProps {
7884
* canvas can show a hover hint and open the source on double-click.
7985
*/
8086
readonly?: ReadOnlyRegion
87+
/**
88+
* Render data used to resolve template bindings and `{source.field}` tokens
89+
* in read-only canvas content. When omitted, unresolved tokens stay visible.
90+
*/
91+
templateContext?: TemplateRenderDataContext
8192
}
8293

8394
/** Build the `data-instatic-readonly-*` marker bag spread onto read-only nodes. */
@@ -106,6 +117,7 @@ export function ReadOnlyNodeTree({
106117
rootNodeWrapperProps,
107118
outletSlot,
108119
readonly,
120+
templateContext,
109121
}: ReadOnlyNodeTreeProps) {
110122
// Resolve the single outlet that hosts `outletSlot` up front so only the
111123
// first outlet is filled (matching the composer's "first outlet wins").
@@ -120,6 +132,7 @@ export function ReadOnlyNodeTree({
120132
outletNodeId={outletNodeId}
121133
outletSlot={outletSlot}
122134
readonlyMarkers={readonlyMarkers(readonly)}
135+
templateContext={templateContext}
123136
/>
124137
)
125138
}
@@ -142,6 +155,8 @@ interface ReadOnlyNodeRendererProps {
142155
outletSlot?: ReactNode
143156
/** Read-only marker bag stamped on every node lacking selection props. */
144157
readonlyMarkers?: NodeWrapperPropsType
158+
/** Render data used to resolve dynamic props and token interpolation. */
159+
templateContext?: TemplateRenderDataContext
145160
}
146161

147162
function ReadOnlyNodeRenderer({
@@ -153,6 +168,7 @@ function ReadOnlyNodeRenderer({
153168
outletNodeId,
154169
outletSlot,
155170
readonlyMarkers,
171+
templateContext,
156172
}: ReadOnlyNodeRendererProps) {
157173
const node = nodes[nodeId]
158174
if (!node) return null
@@ -180,6 +196,7 @@ function ReadOnlyNodeRenderer({
180196
outletNodeId={outletNodeId}
181197
outletSlot={outletSlot}
182198
readonlyMarkers={readonlyMarkers}
199+
templateContext={templateContext}
183200
/>
184201
))}
185202
</>
@@ -190,18 +207,36 @@ function ReadOnlyNodeRenderer({
190207
if (!definition) return null
191208

192209
const ComponentType = definition.component
210+
const effectiveProps = resolveDynamicProps(
211+
resolveProps(node, undefined, definition.schema),
212+
effectiveNodeBindings(node),
213+
templateContext,
214+
)
193215

194-
const children = node.children.map((childId) => (
195-
<ReadOnlyNodeRenderer
196-
key={childId}
197-
nodeId={childId}
216+
const children = node.moduleId === 'base.loop' && node.children.length > 0 ? (
217+
<ReadOnlyLoopIterationsPreview
218+
node={node as PageNode}
198219
nodes={nodes}
199220
classes={classes}
200221
outletNodeId={outletNodeId}
201222
outletSlot={outletSlot}
202223
readonlyMarkers={readonlyMarkers}
224+
templateContext={templateContext}
203225
/>
204-
))
226+
) : (
227+
node.children.map((childId) => (
228+
<ReadOnlyNodeRenderer
229+
key={childId}
230+
nodeId={childId}
231+
nodes={nodes}
232+
classes={classes}
233+
outletNodeId={outletNodeId}
234+
outletSlot={outletSlot}
235+
readonlyMarkers={readonlyMarkers}
236+
templateContext={templateContext}
237+
/>
238+
))
239+
)
205240

206241
const ownClassNames = classNamesForClassIds(classes, node.classIds)
207242
const merged = [extraClassName, ...ownClassNames].filter(Boolean).join(' ')
@@ -226,7 +261,7 @@ function ReadOnlyNodeRenderer({
226261

227262
return (
228263
<ComponentType
229-
props={node.props as never}
264+
props={effectiveProps as never}
230265
nodeId={node.id}
231266
isSelected={false}
232267
mcClassName={mcClassName}
@@ -245,3 +280,51 @@ function isRenderableNode(nodes: Record<string, BaseNode>, nodeId: string): bool
245280
}
246281
return Boolean(registry.get(node.moduleId))
247282
}
283+
284+
interface ReadOnlyLoopIterationsPreviewProps {
285+
node: PageNode
286+
nodes: Record<string, BaseNode>
287+
classes: StyleRuleRegistry
288+
outletNodeId?: string
289+
outletSlot?: ReactNode
290+
readonlyMarkers?: NodeWrapperPropsType
291+
templateContext?: TemplateRenderDataContext
292+
}
293+
294+
function ReadOnlyLoopIterationsPreview({
295+
node,
296+
nodes,
297+
classes,
298+
outletNodeId,
299+
outletSlot,
300+
readonlyMarkers,
301+
templateContext,
302+
}: ReadOnlyLoopIterationsPreviewProps) {
303+
const items = useLoopPreviewItems(node)
304+
if (items.length === 0) return null
305+
306+
const baseStack = templateContext?.entryStack ?? []
307+
return (
308+
<>
309+
{items.map((item, i) => {
310+
const variantId = node.children[i % node.children.length]
311+
const augmentedContext: TemplateRenderDataContext = {
312+
...templateContext,
313+
entryStack: [...baseStack, item],
314+
}
315+
return (
316+
<ReadOnlyNodeRenderer
317+
key={`${variantId}-${i}-${item.id}`}
318+
nodeId={variantId}
319+
nodes={nodes}
320+
classes={classes}
321+
outletNodeId={outletNodeId}
322+
outletSlot={outletSlot}
323+
readonlyMarkers={readonlyMarkers}
324+
templateContext={augmentedContext}
325+
/>
326+
)
327+
})}
328+
</>
329+
)
330+
}

0 commit comments

Comments
 (0)