Skip to content

Commit b9ee769

Browse files
DavidBabinecclaude
andauthored
fix(publisher): render current entry body in any content outlet (CoreBunch#62)
A base.outlet only rendered the post body when the node carried a persisted dynamicBindings.html overlay, which was set in exactly one place: the auto-seeded default template. A hand-dropped outlet (custom template) had no binding, so the publisher filled it with nothing and it rendered as an empty <main data-instatic-content-region></main> — on the published page, the content preview, and the Live Canvas alike. An outlet is, by definition, the hole the current entry's body flows into; there is no UI to bind it and the binding is always the same. Apply it implicitly via effectiveNodeBindings() at the shared resolution layer so every outlet renders the body, and drop the now-redundant persisted binding from the seed (single source of truth). Outside an entry route the entry stack is empty, so the binding stays inert and an everywhere layout's outlet still hosts a whole page. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f2131fb commit b9ee769

6 files changed

Lines changed: 117 additions & 20 deletions

File tree

server/publish/__tests__/templateSeeding.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ describe('buildDefaultTemplateCells', () => {
1818
const all = Object.values(nodes)
1919
const outlet = all.find((n) => n.moduleId === 'base.outlet')
2020
expect(outlet).toBeTruthy()
21-
expect(outlet?.dynamicBindings?.html).toEqual({ source: 'currentEntry', field: 'body', format: 'html' })
21+
// The outlet carries NO persisted binding — the publisher fills every
22+
// outlet's body implicitly (see `effectiveNodeBindings`), so a seeded
23+
// outlet is identical to one a user drags in by hand.
24+
expect(outlet?.dynamicBindings).toBeUndefined()
2225
expect(all.some((n) => n.moduleId === 'base.content')).toBe(false)
2326
})
2427
})

server/publish/templateSeeding.ts

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,9 @@
2525
* - `base.body` root
2626
* - `base.text` <h1> bound to `{currentEntry.title}` via static-token
2727
* interpolation (cheap; no DynamicPropBinding overlay required).
28-
* - `base.outlet` bound to `{currentEntry.body}` with `format: 'html'`
29-
* so the publisher converts the post's markdown body to HTML before
30-
* rendering. Markdown rendering is the host's responsibility for the
31-
* post-type built-in `body` field.
28+
* - `base.outlet` — the publisher fills every outlet's `html` with the
29+
* current entry's body (markdown → HTML) implicitly, so the seed just
30+
* needs the bare outlet node; no per-node binding overlay is required.
3231
*
3332
* Site owners are expected to customise the template in the editor; the
3433
* seed exists so the public URL works the moment a row is published, and
@@ -113,20 +112,13 @@ export function buildDefaultTemplateCells(table: DataTable, slug: string): Recor
113112
[bodyId]: {
114113
id: bodyId,
115114
moduleId: 'base.outlet',
116-
// For HTML output (markdown rendering) we need the dynamic
117-
// binding overlay — token interpolation only handles strings,
118-
// and we need the binding system's `format: 'html'` branch to
119-
// run `renderMarkdownToHtml`.
115+
// The outlet needs no binding overlay: the publisher fills every
116+
// outlet's `html` with the current entry's body (markdown → HTML)
117+
// implicitly (see `effectiveNodeBindings`), so the seed — like a
118+
// hand-dropped outlet — just needs the bare node.
120119
props: { html: '' },
121120
breakpointOverrides: {},
122121
children: [],
123-
dynamicBindings: {
124-
html: {
125-
source: 'currentEntry',
126-
field: 'body',
127-
format: 'html',
128-
},
129-
},
130122
},
131123
},
132124
},
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* The content outlet is, by definition, the hole the current entry's body
3+
* flows into. That must hold for ANY `base.outlet` on an entry-route template —
4+
* including one a user drags onto a custom template by hand, which carries no
5+
* persisted `dynamicBindings` overlay. The publisher applies the entry-body
6+
* binding implicitly (see `effectiveNodeBindings`), so the body renders without
7+
* the node needing to remember a binding it never had a UI to set.
8+
*/
9+
10+
import { describe, expect, it } from 'bun:test'
11+
import { makeModule, makePage, makeRegistry, makeSite } from './helpers'
12+
import { publishPage } from '@core/publisher'
13+
import type { LoopItem } from '@core/loops/types'
14+
15+
const bodyModule = makeModule('base.body', {
16+
canHaveChildren: true,
17+
render: (_props, children) => ({ html: `<main>${children.join('')}</main>` }),
18+
})
19+
20+
// Mirrors the real base.outlet render: a hidden richtext `html` prop (so
21+
// `escapeProps` sanitises rather than HTML-escapes it) emitted inside the
22+
// content-region marker.
23+
const outletModule = makeModule('base.outlet', {
24+
schema: { html: { type: 'richtext', label: 'Content', hidden: true } },
25+
render: (props) => ({
26+
html: `<section data-instatic-content-region>${String((props as { html?: string }).html ?? '')}</section>`,
27+
}),
28+
})
29+
30+
const registry = makeRegistry({ 'base.body': bodyModule, 'base.outlet': outletModule })
31+
32+
function entry(body: string): LoopItem {
33+
return { id: 'p1', fields: { id: 'p1', title: 'Untitled', body } }
34+
}
35+
36+
describe('entry outlet body binding', () => {
37+
it('renders the current entry body into an outlet that carries no persisted binding', () => {
38+
// A hand-dropped outlet: NO dynamicBindings on the node.
39+
const page = makePage({
40+
root: { moduleId: 'base.body', children: ['outlet'] },
41+
outlet: { moduleId: 'base.outlet' },
42+
})
43+
44+
const { html } = publishPage(page, makeSite(), registry, {
45+
templateContext: { entryStack: [entry('## Heading\n\nHello world')] },
46+
})
47+
48+
expect(html).toContain('data-instatic-content-region')
49+
expect(html).toContain('<h2>Heading</h2>')
50+
expect(html).toContain('Hello world')
51+
})
52+
53+
it('leaves the outlet empty on a non-entry render (no current entry in scope)', () => {
54+
const page = makePage({
55+
root: { moduleId: 'base.body', children: ['outlet'] },
56+
outlet: { moduleId: 'base.outlet' },
57+
})
58+
59+
// No entryStack → currentEntry.body resolves to nothing; the outlet renders
60+
// its marker but no body (an `everywhere` layout previewing a page relies on
61+
// this so the implicit binding stays inert outside entry routes).
62+
const { html } = publishPage(page, makeSite(), registry, {
63+
templateContext: { entryStack: [] },
64+
})
65+
66+
expect(html).toContain('data-instatic-content-region')
67+
expect(html).not.toContain('Hello world')
68+
})
69+
})

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { useEditorStore, selectActiveCanvasPage } from '@site/store/store'
2626
import { resolveProps } from '@core/page-tree'
2727
import { registry } from '@core/module-engine'
2828
import type { NodeWrapperProps as NodeWrapperPropsType } from '@core/module-engine'
29-
import { resolveDynamicProps, type TemplateRenderDataContext } from '@core/templates/dynamicBindings'
29+
import { resolveDynamicProps, effectiveNodeBindings, type TemplateRenderDataContext } from '@core/templates/dynamicBindings'
3030
import type { PageNode } from '@core/page-tree'
3131
import { WarningDiamondSolidIcon } from 'pixel-art-icons/icons/warning-diamond-solid'
3232
import { ErrorBoundary } from '@ui/components/ErrorBoundary'
@@ -233,7 +233,7 @@ export const NodeRenderer = memo(function NodeRenderer({ nodeId }: NodeRendererP
233233
node.moduleId,
234234
resolveDynamicProps(
235235
resolveProps(node, breakpointId, definition.schema),
236-
node.dynamicBindings,
236+
effectiveNodeBindings(node),
237237
templateContext,
238238
),
239239
editorFormPreviewState,

src/core/publisher/renderNode.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { isPageRef, resolvePageRef } from '@core/page-tree'
2626
import type { AnyModuleDefinition } from '@core/module-engine'
2727
import { validateNodeProps } from '@core/module-engine'
2828
import { resolveProps } from '@core/page-tree'
29-
import { resolveDynamicProps } from '@core/templates/dynamicBindings'
29+
import { resolveDynamicProps, effectiveNodeBindings } from '@core/templates/dynamicBindings'
3030
import { sanitizeModuleCSS } from './cssCollector'
3131
import { escapeHtml } from './utils'
3232
import { escapeProps } from './escapeProps'
@@ -142,7 +142,7 @@ function renderStandardNode(
142142
const effectiveProps = resolveProps(node, config.breakpointId, def.schema)
143143
const dynamicProps = resolveDynamicProps(
144144
effectiveProps,
145-
node.dynamicBindings,
145+
effectiveNodeBindings(node),
146146
config.templateContext,
147147
)
148148

src/core/templates/dynamicBindings.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,39 @@ function resolveBindingValue(
116116
return value
117117
}
118118

119+
/**
120+
* The implicit binding every `base.outlet` carries: its `html` prop is filled
121+
* with the current entry's markdown body, rendered to HTML. An outlet is, by
122+
* definition, the hole the current entry's body flows into — there is no UI to
123+
* set this and it is never persisted on the node. Resolving it here means ANY
124+
* outlet renders the body, including one a user drags onto a custom template by
125+
* hand (which carries no `dynamicBindings` overlay). Outside an entry route the
126+
* entry stack is empty, so `currentEntry.body` resolves to nothing and the
127+
* outlet stays empty — an `everywhere` layout's outlet then hosts a whole page
128+
* instead.
129+
*/
130+
const OUTLET_BODY_BINDING: DynamicPropBinding = {
131+
source: 'currentEntry',
132+
field: 'body',
133+
format: 'html',
134+
}
135+
136+
/**
137+
* The bindings that actually apply to a node at render time: its persisted
138+
* `dynamicBindings` overlay plus the implicit outlet body binding for
139+
* `base.outlet`. Both the publisher (`renderNode`) and the editor canvas
140+
* (`NodeRenderer`) resolve through this so the two surfaces render identically.
141+
*/
142+
export function effectiveNodeBindings(node: {
143+
moduleId: string
144+
dynamicBindings?: Record<string, DynamicPropBinding>
145+
}): Record<string, DynamicPropBinding> | undefined {
146+
if (node.moduleId === 'base.outlet') {
147+
return { ...node.dynamicBindings, html: OUTLET_BODY_BINDING }
148+
}
149+
return node.dynamicBindings
150+
}
151+
119152
export function resolveDynamicProps(
120153
staticProps: Record<string, unknown>,
121154
bindings: Record<string, DynamicPropBinding> | undefined,

0 commit comments

Comments
 (0)