diff --git a/docs/features/publisher.md b/docs/features/publisher.md
index c87e8eb12..7aea97b15 100644
--- a/docs/features/publisher.md
+++ b/docs/features/publisher.md
@@ -221,8 +221,16 @@ plus global preflight.
Media-library background images are optimized in the same publish pass as
`
`. `mediaPrefetch.ts` collects `/uploads/...` URLs from
image/media module props, node `inlineStyles.backgroundImage`, and StyleRule
-`backgroundImage` values (including breakpoint/context overrides), then
-batch-fetches their media rows. During CSS emission,
+`backgroundImage` values (including breakpoint/context overrides), plus
+media references carried by the ENTRY data itself: multi-media array members,
+scalar `/uploads/...` values, and the bare asset ids stored in fields that a
+`format: 'media'` binding references (custom media cells store the id — the
+binding, not the value's shape, marks the field as media). The resulting map
+is keyed by stored reference (id or path) AND by each asset's materialized
+`publicPath`, and is handed both to the render walk (prop enrichment) and to
+the template render context (`ctx.media`) so `format: 'media'` bindings can
+translate id → served URL. It then batch-fetches the media rows in one
+id-or-path query. During CSS emission,
`responsiveBackground.ts` rewrites each matched `url('/uploads/original.png')`
to two `background-image` declarations: an optimized variant URL fallback and
an `image-set(...)` ladder built only from `media_assets.variants_json`. The
diff --git a/docs/features/templates.md b/docs/features/templates.md
index f65f6c3b4..366602d6b 100644
--- a/docs/features/templates.md
+++ b/docs/features/templates.md
@@ -119,7 +119,7 @@ Result: one merged `Page` consumed by `publishPage` unchanged — one CSS collec
- **Tag:** the outlet renders as an author-chosen semantic element (`tag` / `customTag` props, default ``), sharing `htmlTagControl` / `customHtmlTagControl` with `base.container` / `base.loop`. The Properties panel exposes the tag dropdown.
- **Render:** emits `<{tag} data-instatic-content-region>{props.html}{tag}>`. When `props.html` is empty, the empty element is the live-edit anchor for the Content workspace.
-- **Binding (entry route):** the seed attaches `dynamicBindings: { html: { source: 'currentEntry', field: 'body', format: 'html' } }` to the outlet node so the entry's body flows in at render time. The `html` prop is a binding target ONLY — it carries no panel control (you never hand-edit it). This keeps the Content workspace's Tiptap mount working via the `data-instatic-content-region` marker.
+- **Binding (entry route):** every outlet carries an IMPLICIT `html: { source: 'currentEntry', field: 'body', format: 'html' }` binding (applied by `effectiveNodeBindings`, never persisted), so even a hand-dropped outlet renders the entry body. A **persisted** `html` binding on the node wins over the implicit default — authors and plugins can point the outlet at any rich field (e.g. a custom table's richText cell) instead of `body`. The `html` prop is a binding target ONLY — it carries no panel control (you never hand-edit it). This keeps the Content workspace's Tiptap mount working via the `data-instatic-content-region` marker.
- **Splice (page route):** `composeTemplateChain` removes the `base.outlet` node and inserts the page's content in its place before `publishPage` is called. No outlet node reaches the renderer on page routes.
- **Canvas preview:** `OutletEditor` renders the matched content READ-ONLY so the author sees what flows in — the first non-template page (`everywhere` target) via `ReadOnlyNodeTree`, or the entry body (`postTypes` target, resolved into `props.html`). It carries the editor wrapper bag so the outlet has a proper selection overlay; an empty match falls back to the shared placeholder.
@@ -201,6 +201,7 @@ interface TemplateRenderDataContext {
site?: SiteFrame // site name, settings, breakpoints
route?: RouteFrame // URL path, slug, segments, and query params
entryStack: LoopItem[] // pushed by loops + entry route render
+ media?: ReadonlyMap // asset id + path → { publicPath }
}
```
@@ -218,6 +219,17 @@ See the "Dynamic bindings" section below for the full source table.
| `route` | `ctx.route` | URL-driven (`route.segments`, `route.slug`, `route.query.*`) |
| `page` | `ctx.page` | Current page metadata |
+### Binding formats
+
+A binding's optional `format` tag tells the resolver how to coerce the raw field value:
+
+- **`plain`** (and unset) — the value passes through as-is; the publisher escapes it like any string prop.
+- **`html`** — the value renders through the markdown pipeline (tokens interpolated first) when the binding targets `body`/`bodyMarkdown` OR the destination prop is richtext-typed (`html`, `*richtext`). richText cells stored as HTML survive unchanged — block HTML passes through the GFM renderer verbatim — so one path serves both storage formats.
+- **`url`** — the value is expected to be a URL; emission runs the publisher's URL safety checks.
+- **`media`** — the value references a media asset. Values already carrying a path or URL (`featuredMediaPath`, external URLs) pass through; a **bare asset id** (what a custom media cell stores) is translated to the asset's served URL through `ctx.media`. A reference that cannot be resolved counts as "missing", so the binding's fallback strategy applies instead of the raw id leaking into `src`.
+
+`ctx.media` is attached per surface: `publishPage` wires in the server's `prefetchMediaAssets` map (which also collects the bare ids referenced by `format: 'media'` bindings), the hole/loop fragment endpoints build their own, and the canvas attaches the admin media-library cache (`useCmsMediaAssetLookup`). It is a live `Map` and never travels the runtime-preview JSON boundary — the server strips whatever arrived on the wire and substitutes its own prefetch.
+
---
## Token interpolation
diff --git a/server/handlers/cms/hole.ts b/server/handlers/cms/hole.ts
index 79736e7db..35e6ed41e 100644
--- a/server/handlers/cms/hole.ts
+++ b/server/handlers/cms/hole.ts
@@ -42,6 +42,7 @@ import { loopSourceRegistry } from '@core/loops/registry'
import { renderNode, type RenderConfig, type RenderAccumulators } from '@core/publisher'
import { buildPageFrame, buildRouteFrame, buildSiteFrame } from '@core/templates/contextFrames'
import { prefetchLoopData } from '../../publish/loopPrefetch'
+import { prefetchMediaAssets } from '../../publish/mediaPrefetch'
import { getOrRender } from '../../publish/renderCache'
import { getPublishedNodeIndexForVersion } from '../../publish/publishedSnapshotCache'
import { getPublishVersion } from '../../publish/publishState'
@@ -129,17 +130,26 @@ async function renderHoleFragment(
request,
rootNodeId: nodeId,
})
+ // Media assets for the fragment subtree — request-time loops carry entry
+ // items whose media references (custom cells, multi-media arrays) resolve
+ // through this map, and image modules read it for srcset/alt enrichment.
+ const mediaAssets = await prefetchMediaAssets(page, site, registry, db, {
+ loopData,
+ rootNodeId: nodeId,
+ })
const config: RenderConfig = {
page,
site,
registry,
breakpointId: undefined,
loopData,
+ mediaAssets,
templateContext: {
entryStack: [],
page: buildPageFrame(page),
site: buildSiteFrame(site),
route,
+ media: mediaAssets,
},
// No dynamicNodeIds: inside a hole endpoint we render the full subtree.
}
diff --git a/server/handlers/cms/loop.ts b/server/handlers/cms/loop.ts
index f5c035930..69c98172e 100644
--- a/server/handlers/cms/loop.ts
+++ b/server/handlers/cms/loop.ts
@@ -31,6 +31,7 @@ import {
} from '@core/publisher'
import { jsonResponse } from '../../http'
import { readLoopProps } from '../../publish/loopPrefetch'
+import { prefetchMediaAssets } from '../../publish/mediaPrefetch'
import { getPublishedLoopIndexForVersion } from '../../publish/publishedSnapshotCache'
import { getPublishVersion } from '../../publish/publishState'
import { LOOP_RUNTIME_JS } from '../../publish/loopRuntime'
@@ -130,15 +131,24 @@ export async function handleLoopRequest(
if (variants.length === 0) {
return jsonResponse({ html: '', hasMore, pageNumber })
}
+ const loopData = new Map([
+ [loopId, { items: result.items, totalItems: result.totalItems, pageNumber, hasMore }],
+ ])
+ // Media assets for the appended items — the same lookup the publish-time
+ // render used for page 1, so `format: 'media'` bindings resolve and image
+ // modules keep their srcset/alt enrichment on every subsequent page.
+ const mediaAssets = await prefetchMediaAssets(containingPage, site, registry, ctx.db, {
+ loopData,
+ rootNodeId: loopId,
+ })
const baseConfig: RenderConfig = {
page: containingPage,
site,
registry,
breakpointId: undefined,
- templateContext: { entryStack: [] },
- loopData: new Map([
- [loopId, { items: result.items, totalItems: result.totalItems, pageNumber, hasMore }],
- ]),
+ templateContext: { entryStack: [], media: mediaAssets },
+ loopData,
+ mediaAssets,
}
const acc: RenderAccumulators = {
cssMap: new Map(),
diff --git a/server/handlers/cms/runtime.ts b/server/handlers/cms/runtime.ts
index 7716892bf..4c841cb8f 100644
--- a/server/handlers/cms/runtime.ts
+++ b/server/handlers/cms/runtime.ts
@@ -154,7 +154,11 @@ export async function handleRuntimeRoutes(req: Request, db: DbClient): Promise
interface MediaPrefetchOptions {
templateContext?: TemplateRenderDataContext
loopData?: ReadonlyMap
+ /**
+ * Limit the tree walk to a subtree — the hole and loop fragment endpoints
+ * pass their fragment root so only assets that fragment can reference are
+ * fetched. Defaults to the page root (mirrors `prefetchLoopData`).
+ */
+ rootNodeId?: string
}
/**
* Collect every `/uploads/...` path referenced by an image/media-typed prop
* across the page tree.
*/
-function collectMediaPaths(page: Page, site: SiteDocument, registry: IModuleRegistry): Set {
+function collectMediaPaths(
+ page: Page,
+ site: SiteDocument,
+ registry: IModuleRegistry,
+ rootNodeId: string,
+): Set {
const paths = new Set()
// Descend into referenced VC definition trees so an image/media prop inside a
// VC body is resolved too (ISS-022).
- walkRenderTree(page.nodes, page.rootNodeId, site, (node) => {
+ walkRenderTree(page.nodes, rootNodeId, site, (node) => {
const def = registry.get(node.moduleId)
if (!def) return
collectNodeBackgroundImagePaths(node, paths)
@@ -85,8 +97,12 @@ export async function prefetchMediaAssets(
options: MediaPrefetchOptions = {},
): Promise {
const map = new Map()
- const paths = collectMediaPaths(page, site, registry)
+ const rootNodeId = options.rootNodeId ?? page.rootNodeId
+ const paths = collectMediaPaths(page, site, registry, rootNodeId)
const entryReferences = collectEntryMediaReferences(options)
+ for (const reference of collectMediaBindingReferences(page, site, rootNodeId, options)) {
+ entryReferences.add(reference)
+ }
if (paths.size === 0 && entryReferences.size === 0) return map
// `collectMediaPaths` and `collectEntryMediaReferences` both return Sets,
@@ -144,7 +160,16 @@ export async function prefetchMediaAssets(
// stored token so the renderer's O(1) lookup still works; the VALUE is
// rewritten so transformer plugins (passive CDN, image-CDN) take effect
// on the published page AND the editor preview iframe in one place.
- return materializeAssetMapForClient(map)
+ const materialized = await materializeAssetMapForClient(map)
+ // ALSO key each asset by its (possibly transformed) publicPath: a media
+ // binding resolves an asset id to that URL, and `attachResolvedMediaByKey`
+ // looks the resolved prop value back up in this map — without this key the
+ // enrichment (srcset / alt / dimensions) would miss whenever a transformer
+ // rewrote the URL.
+ for (const asset of [...new Set(materialized.values())]) {
+ materialized.set(asset.publicPath, asset)
+ }
+ return materialized
}
/**
@@ -198,3 +223,62 @@ function collectEntryMediaReferences(options: MediaPrefetchOptions): Set
}
return references
}
+
+/**
+ * Bare asset ids referenced by `format: 'media'` bindings.
+ *
+ * A CUSTOM media cell stores the asset id as a scalar string — no `/uploads/`
+ * prefix, not inside an array — so neither collector above can see it. The
+ * bindings tell us exactly which entry fields hold media references; the
+ * entry stack and the pre-fetched loop items hold the values. Collecting the
+ * two together (values of media-bound fields across every candidate frame)
+ * puts the ids into the batched id-or-path query, which is what lets
+ * `resolveBindingValue` translate id → public path during the render walk.
+ *
+ * Values that already look like a path or URL are skipped — the generic
+ * collectors and the render pipeline handle those without a lookup.
+ */
+function collectMediaBindingReferences(
+ page: Page,
+ site: SiteDocument,
+ rootNodeId: string,
+ options: MediaPrefetchOptions,
+): Set {
+ const fieldPaths = new Set()
+ walkRenderTree(page.nodes, rootNodeId, site, (node) => {
+ // Page trees carry PageNode (BaseNode + dynamicBindings); VC definition
+ // trees carry plain BaseNode. Reading through the PageNode view of the
+ // same object yields `undefined` for VC nodes, which is exactly right.
+ const bindings = (node as PageNode).dynamicBindings
+ if (!bindings) return
+ for (const binding of Object.values(bindings)) {
+ if (binding.format !== 'media') continue
+ if (binding.source !== 'currentEntry' && binding.source !== 'parentEntry') continue
+ fieldPaths.add(binding.field)
+ }
+ })
+
+ const references = new Set()
+ if (fieldPaths.size === 0) return references
+
+ const collectFrom = (fields: Record): void => {
+ for (const fieldPath of fieldPaths) {
+ const value = walkFieldPath(fields, fieldPath)
+ if (
+ typeof value === 'string' &&
+ value !== '' &&
+ !value.includes('/') &&
+ !value.includes(':')
+ ) {
+ references.add(value)
+ }
+ }
+ }
+ for (const entry of options.templateContext?.entryStack ?? []) {
+ collectFrom(entry.fields)
+ }
+ for (const data of options.loopData?.values() ?? []) {
+ for (const item of data.items) collectFrom(item.fields)
+ }
+ return references
+}
diff --git a/src/__tests__/publisher/outletEntryBody.test.ts b/src/__tests__/publisher/outletEntryBody.test.ts
index d53f8be96..d7c8ad0a0 100644
--- a/src/__tests__/publisher/outletEntryBody.test.ts
+++ b/src/__tests__/publisher/outletEntryBody.test.ts
@@ -1,10 +1,14 @@
/**
- * The content outlet is, by definition, the hole the current entry's body
+ * The content outlet is, by definition, the hole the current entry's content
* flows into. That must hold for ANY `base.outlet` on an entry-route template —
* including one a user drags onto a custom template by hand, which carries no
* persisted `dynamicBindings` overlay. The publisher applies the entry-body
* binding implicitly (see `effectiveNodeBindings`), so the body renders without
* the node needing to remember a binding it never had a UI to set.
+ *
+ * The implicit binding is a DEFAULT, not a lock: a persisted `html` binding on
+ * the outlet node wins, so authors and plugins can point an outlet at any rich
+ * field (e.g. a custom table's richText cell) instead of `body`.
*/
import { describe, expect, it } from 'bun:test'
@@ -50,6 +54,30 @@ describe('entry outlet body binding', () => {
expect(html).toContain('Hello world')
})
+ it('lets a persisted html binding override the implicit body default', () => {
+ const page = makePage({
+ root: { moduleId: 'base.body', children: ['outlet'] },
+ outlet: {
+ moduleId: 'base.outlet',
+ dynamicBindings: {
+ html: { source: 'currentEntry', field: 'summary', format: 'html' },
+ },
+ },
+ })
+
+ const { html } = publishPage(page, makeSite(), registry, {
+ templateContext: {
+ entryStack: [{
+ id: 'p1',
+ fields: { id: 'p1', body: 'BODY — must not render', summary: '## Summary heading' },
+ }],
+ },
+ })
+
+ expect(html).toContain('Summary heading
')
+ expect(html).not.toContain('must not render')
+ })
+
it('leaves the outlet empty on a non-entry render (no current entry in scope)', () => {
const page = makePage({
root: { moduleId: 'base.body', children: ['outlet'] },
diff --git a/src/__tests__/server/mediaBatchResolution.test.ts b/src/__tests__/server/mediaBatchResolution.test.ts
index 505cb3a22..6dfdb1d01 100644
--- a/src/__tests__/server/mediaBatchResolution.test.ts
+++ b/src/__tests__/server/mediaBatchResolution.test.ts
@@ -397,4 +397,65 @@ describe('prefetchMediaAssets (Finding 2)', () => {
await cleanup()
}
})
+
+ it('resolves a bare scalar id when a format:media binding references the field', async () => {
+ const { db, cleanup } = await createTestDb()
+ try {
+ await insertMediaAsset(db, 'aid-4', '/uploads/aid-4.png')
+ await insertMediaAsset(db, 'aid-5', '/uploads/aid-5.png')
+ // A CUSTOM media cell stores the bare asset id. The node's binding is
+ // what marks the field as a media reference — that, not the value's
+ // shape, is what pulls the id into the batch lookup.
+ const page = {
+ id: 'p',
+ nodes: {
+ root: { id: 'root', moduleId: 'base.body', props: {}, children: ['n1'], breakpointOverrides: {}, classIds: [] },
+ n1: {
+ id: 'n1',
+ moduleId: 'test.img',
+ props: { src: '' },
+ children: [],
+ breakpointOverrides: {},
+ classIds: [],
+ dynamicBindings: {
+ src: { source: 'currentEntry', field: 'thumbnail', format: 'media' },
+ },
+ },
+ },
+ rootNodeId: 'root',
+ }
+ const registry = makeImageRegistry('src')
+
+ const map = await prefetchMediaAssets(
+ page as never,
+ { visualComponents: [] } as never,
+ registry,
+ db,
+ {
+ templateContext: {
+ entryStack: [{
+ id: 'row-1',
+ fields: { thumbnail: 'aid-4', otherCell: 'aid-6' },
+ }],
+ },
+ loopData: new Map([
+ ['loop-1', {
+ items: [{ id: 'row-2', fields: { thumbnail: 'aid-5' } }],
+ totalItems: 1,
+ pageNumber: 1,
+ hasMore: false,
+ }],
+ ]) as never,
+ },
+ )
+
+ // Both the template entry's and the loop item's values for the bound
+ // field are resolved; the unbound cell stays out of the lookup.
+ expect(map.get('aid-4')?.publicPath).toBe('/uploads/aid-4.png')
+ expect(map.get('aid-5')?.publicPath).toBe('/uploads/aid-5.png')
+ expect(map.has('aid-6')).toBe(false)
+ } finally {
+ await cleanup()
+ }
+ })
})
diff --git a/src/__tests__/templates/dynamicRender.test.ts b/src/__tests__/templates/dynamicRender.test.ts
index 429ed3d60..3ffb83b13 100644
--- a/src/__tests__/templates/dynamicRender.test.ts
+++ b/src/__tests__/templates/dynamicRender.test.ts
@@ -83,6 +83,53 @@ describe('dynamic template rendering', () => {
expect(props.src).toBe('/uploads/body-hero.jpg')
})
+ it('translates a bare media reference (custom media cell id) through the context media lookup', () => {
+ const itemWithCustomMediaCell: LoopItem = {
+ ...currentEntry,
+ fields: { ...currentEntry.fields, thumbnail: 'asset_123' },
+ }
+ const props = resolveDynamicProps(
+ { src: '' },
+ { src: { source: 'currentEntry', field: 'thumbnail', format: 'media' } },
+ {
+ entryStack: [itemWithCustomMediaCell],
+ media: new Map([['asset_123', { publicPath: '/uploads/thumb.jpg' }]]),
+ },
+ )
+
+ expect(props.src).toBe('/uploads/thumb.jpg')
+ })
+
+ it('keeps the static fallback when a media reference cannot be resolved', () => {
+ const itemWithCustomMediaCell: LoopItem = {
+ ...currentEntry,
+ fields: { ...currentEntry.fields, thumbnail: 'asset_deleted' },
+ }
+ // No media lookup on the context (and the id is not a path) — the raw id
+ // must never leak into the prop; the static value wins instead.
+ const props = resolveDynamicProps(
+ { src: '/placeholder.png' },
+ { src: { source: 'currentEntry', field: 'thumbnail', format: 'media' } },
+ { entryStack: [itemWithCustomMediaCell] },
+ )
+
+ expect(props.src).toBe('/placeholder.png')
+ })
+
+ it('passes external URLs in media bindings through without a lookup', () => {
+ const itemWithExternalUrl: LoopItem = {
+ ...currentEntry,
+ fields: { ...currentEntry.fields, thumbnail: 'https://cdn.example.com/thumb.jpg' },
+ }
+ const props = resolveDynamicProps(
+ { src: '' },
+ { src: { source: 'currentEntry', field: 'thumbnail', format: 'media' } },
+ { entryStack: [itemWithExternalUrl], media: new Map() },
+ )
+
+ expect(props.src).toBe('https://cdn.example.com/thumb.jpg')
+ })
+
it('resolves parentEntry from the frame below the stack top', () => {
const outer: LoopItem = {
id: 'outer',
@@ -146,6 +193,129 @@ describe('dynamic template rendering', () => {
expect(staticHtml).toContain('Static body
')
})
+ it('lets a persisted outlet html binding win over the implicit body binding', () => {
+ // A custom data table stores its content in a `rich-text` cell instead of
+ // `body`. A persisted binding on the outlet must beat the implicit
+ // `currentEntry.body` default so the outlet can render ANY rich field.
+ const registry = makeRegistry({
+ 'base.body': makeModule('base.body', {
+ canHaveChildren: true,
+ render: (_props, children) => ({ html: `${children.join('')}` }),
+ }),
+ 'base.outlet': OutletModule,
+ })
+ const itemWithCustomRichCell: LoopItem = {
+ ...currentEntry,
+ fields: {
+ ...currentEntry.fields,
+ body: 'BODY CELL — must not render',
+ 'rich-text': '## Custom heading\n\nCustom cell content',
+ },
+ }
+ const page = makePage({
+ root: { moduleId: 'base.body', props: {}, children: ['outlet'] },
+ outlet: {
+ moduleId: 'base.outlet',
+ props: {},
+ dynamicBindings: {
+ html: { source: 'currentEntry', field: 'rich-text', format: 'html' },
+ },
+ },
+ })
+
+ const { html } = publishPage(page, makeSite(), registry, {
+ templateContext: { entryStack: [itemWithCustomRichCell] },
+ })
+
+ expect(html).toContain('Custom heading
')
+ expect(html).toContain('Custom cell content')
+ expect(html).not.toContain('must not render')
+ })
+
+ it('renders markdown for a custom rich field bound with format html into a richtext prop', () => {
+ const itemWithCustomRichCell: LoopItem = {
+ ...currentEntry,
+ fields: { ...currentEntry.fields, excerpt: '**Bold** intro' },
+ }
+ const props = resolveDynamicProps(
+ { html: '' },
+ { html: { source: 'currentEntry', field: 'excerpt', format: 'html' } },
+ { entryStack: [itemWithCustomRichCell] },
+ )
+
+ expect(props.html).toContain('Bold')
+ })
+
+ it('passes HTML-stored custom rich cells through the markdown render unchanged', () => {
+ // richText fields with `format: 'html'` storage hold HTML, not markdown.
+ // Block HTML survives the GFM renderer verbatim, so the same binding path
+ // serves both storage formats.
+ const itemWithHtmlCell: LoopItem = {
+ ...currentEntry,
+ fields: { ...currentEntry.fields, 'rich-text': 'First
Section
' },
+ }
+ const props = resolveDynamicProps(
+ { html: '' },
+ { html: { source: 'currentEntry', field: 'rich-text', format: 'html' } },
+ { entryStack: [itemWithHtmlCell] },
+ )
+
+ expect(props.html).toContain('First
')
+ expect(props.html).toContain('Section
')
+ })
+
+ it('publishes a bound custom media cell as the asset URL through publishPage', () => {
+ const imageModule = makeModule('test.image', {
+ schema: { src: { type: 'image', label: 'Image' } },
+ render: (props) => ({
+ html: `
`,
+ }),
+ })
+ const registry = makeRegistry({
+ 'base.body': makeModule('base.body', {
+ canHaveChildren: true,
+ render: (_props, children) => ({ html: `${children.join('')}` }),
+ }),
+ 'test.image': imageModule,
+ })
+ const itemWithCustomMediaCell: LoopItem = {
+ ...currentEntry,
+ fields: { ...currentEntry.fields, thumbnail: 'asset_123' },
+ }
+ const page = makePage({
+ root: { moduleId: 'base.body', props: {}, children: ['img'] },
+ img: {
+ moduleId: 'test.image',
+ props: { src: '' },
+ dynamicBindings: {
+ src: { source: 'currentEntry', field: 'thumbnail', format: 'media' },
+ },
+ },
+ })
+
+ const { html } = publishPage(page, makeSite(), registry, {
+ templateContext: { entryStack: [itemWithCustomMediaCell] },
+ mediaAssets: new Map([
+ [
+ 'asset_123',
+ {
+ publicPath: '/uploads/thumb.jpg',
+ mimeType: 'image/jpeg',
+ width: 800,
+ height: 600,
+ altText: 'Thumb',
+ blurHash: null,
+ variants: [],
+ posterPath: null,
+ },
+ ],
+ ]),
+ })
+
+ expect(html).toContain('
')
+ expect(html).not.toContain('asset_123')
+ })
+
it('renders markdown when a token resolves into a richtext-typed prop', () => {
// Legacy template shape (still used in dev DBs): the `base.outlet`
// node carries a static `html: "{currentEntry.body}"` prop and no
diff --git a/src/admin/pages/media/hooks/useCmsMediaAssetByPath.ts b/src/admin/pages/media/hooks/useCmsMediaAssetByPath.ts
index 567aed13b..0835eff5d 100644
--- a/src/admin/pages/media/hooks/useCmsMediaAssetByPath.ts
+++ b/src/admin/pages/media/hooks/useCmsMediaAssetByPath.ts
@@ -19,9 +19,11 @@ import { use, useEffect, useState } from 'react'
import { listCmsMediaAssets, type CmsMediaAsset } from '@core/persistence/cmsMedia'
import { CanvasPreviewReadinessContext } from '@site/canvas/CanvasPreviewReadiness'
-// Module-level cache, shared across every consumer. CmsMediaAsset objects
-// are small (< 1 KB each), so a Map of every asset the user has touched
-// in this session is negligible memory.
+// Module-level cache, shared across every consumer, keyed by BOTH the
+// asset's publicPath and its id (ids and paths can never collide — ids are
+// nanoid strings, paths start with `/`). CmsMediaAsset objects are small
+// (< 1 KB each), so a Map of every asset the user has touched in this
+// session is negligible memory.
const cache = new Map()
let listPromise: Promise | null = null
const subscribers = new Set<() => void>()
@@ -58,7 +60,10 @@ function cachedAssetsForKey(key: string): ReadonlyMap {
}
function cacheAssetList(assets: readonly CmsMediaAsset[]): void {
- for (const asset of assets) cache.set(asset.publicPath, asset)
+ for (const asset of assets) {
+ cache.set(asset.publicPath, asset)
+ cache.set(asset.id, asset)
+ }
notifySubscribers()
}
@@ -97,6 +102,44 @@ export function useCmsMediaAssetByPath(publicPath: string | null | undefined): C
return publicPath ? assets.get(publicPath) ?? null : null
}
+/**
+ * The whole media library as an id-and-path-keyed lookup — the canvas-side
+ * counterpart of the publisher's `prefetchMediaAssets` map. Attached to the
+ * template render context so `format: 'media'` bindings resolve bare asset
+ * references (custom media cells store the id) in the editor exactly like
+ * they do on the published page. One shared `listCmsMediaAssets()` round
+ * trip per session, reusing the same module cache as the by-path hooks.
+ */
+export function useCmsMediaAssetLookup(): ReadonlyMap {
+ const previewReadiness = use(CanvasPreviewReadinessContext)
+ const [snapshot, setSnapshot] = useState>(
+ () => new Map(cache),
+ )
+
+ useEffect(() => {
+ let canceled = false
+ const updateSnapshot = () => {
+ if (!canceled) setSnapshot(new Map(cache))
+ }
+ subscribers.add(updateSnapshot)
+
+ // `ensureList` memoises its promise, so this is one network round trip
+ // per session no matter how many surfaces mount the lookup.
+ const request = ensureList()
+ previewReadiness?.track(request)
+ void request
+ .then(updateSnapshot)
+ .catch(() => { /* swallow — bindings fall back to their static props */ })
+
+ return () => {
+ canceled = true
+ subscribers.delete(updateSnapshot)
+ }
+ }, [previewReadiness])
+
+ return snapshot
+}
+
export function useCmsMediaAssetsByPath(publicPaths: readonly string[]): ReadonlyMap {
const previewReadiness = use(CanvasPreviewReadinessContext)
const key = publicPathsKey(publicPaths)
diff --git a/src/admin/pages/site/canvas/CanvasRoot.tsx b/src/admin/pages/site/canvas/CanvasRoot.tsx
index 0bf9dc8b1..136afabb9 100644
--- a/src/admin/pages/site/canvas/CanvasRoot.tsx
+++ b/src/admin/pages/site/canvas/CanvasRoot.tsx
@@ -57,6 +57,7 @@ import { clientPointToEditorDoc } from './canvasDomGeometry'
import { useConfirmDelete } from '@admin/shared/dialogs/ConfirmDeleteDialog'
import { useEditorPreference, readEditorSelectPreference } from '@site/preferences/editorPreferences'
import { useTemplatePreviewContext } from '@site/hooks/useTemplatePreviewContext'
+import { useCmsMediaAssetLookup } from '@admin/pages/media/hooks/useCmsMediaAssetByPath'
import styles from './CanvasRoot.module.css'
const VisualComponentModeControl = lazy(() =>
@@ -138,6 +139,16 @@ export function CanvasRoot({ editable = true }: CanvasRootProps) {
context: templatePreviewContext,
loading: templatePreviewContextLoading,
} = useTemplatePreviewContext(canvasPage)
+ // Client-side rendering context: the media lookup (id + path keyed) lets
+ // `format: 'media'` bindings resolve bare asset references on the canvas
+ // exactly like the publisher does. Attached HERE, not inside
+ // useTemplatePreviewContext, because that hook's context also serializes to
+ // the runtime-preview endpoint (hover preview, preview overlay, script
+ // build), where the server substitutes its own prefetch map.
+ const canvasMediaLookup = useCmsMediaAssetLookup()
+ const canvasTemplateContext = templatePreviewContext
+ ? { ...templatePreviewContext, media: canvasMediaLookup }
+ : undefined
const agentSnapshotBreakpoint = agentSnapshotCaptureRequest
? breakpoints.find((breakpoint) => breakpoint.id === agentSnapshotCaptureRequest.breakpointId) ?? null
: null
@@ -524,7 +535,7 @@ export function CanvasRoot({ editable = true }: CanvasRootProps) {
) : (
@@ -536,7 +547,7 @@ export function CanvasRoot({ editable = true }: CanvasRootProps) {
dimInactiveBreakpoints={focusActiveBreakpoint}
activationHintEnabled={preserveSelectionWhenActivatingBreakpoint}
onBreakpointActivate={setActiveBreakpoint}
- templateContext={templatePreviewContext}
+ templateContext={canvasTemplateContext}
runtimeScripts={runtimeScripts}
/>
)}
@@ -582,7 +593,7 @@ export function CanvasRoot({ editable = true }: CanvasRootProps) {
requestId={agentSnapshotCaptureRequest.requestId}
page={canvasPage}
breakpoint={agentSnapshotBreakpoint}
- templateContext={templatePreviewContext}
+ templateContext={canvasTemplateContext}
templateContextLoading={templatePreviewContextLoading}
/>
) : null}
diff --git a/src/admin/pages/site/hooks/useTemplatePreviewContext.ts b/src/admin/pages/site/hooks/useTemplatePreviewContext.ts
index de4a9f8c4..ae420f62a 100644
--- a/src/admin/pages/site/hooks/useTemplatePreviewContext.ts
+++ b/src/admin/pages/site/hooks/useTemplatePreviewContext.ts
@@ -98,6 +98,9 @@ export function useTemplatePreviewContext(page: Page | null): TemplatePreviewCon
// doesn't have the real request URL, so we derive from the page's
// permalink — same shape, same fields.
route: buildRouteFrame(pageFrame.permalink),
+ // No `media` here: this context is also serialized to the runtime-preview
+ // endpoint (hover preview, preview overlay), where a live Map is dead
+ // weight. CanvasRoot attaches the media lookup for client-side rendering.
},
}
}
diff --git a/src/core/publisher/render.ts b/src/core/publisher/render.ts
index c7ead0b2a..8c2936a95 100644
--- a/src/core/publisher/render.ts
+++ b/src/core/publisher/render.ts
@@ -236,11 +236,16 @@ function slugToFilename(slug: string, title: string): string {
* populated so dynamic bindings against those sources resolve — even on
* plain (non-template, non-loop) pages. Caller-provided values always
* win; missing slots fall back to defaults derived from the page/site.
+ *
+ * The media lookup defaults to the pre-fetched asset map so `format: 'media'`
+ * bindings can translate bare asset references (custom media cells store the
+ * id) into served URLs during the render walk.
*/
function composeTemplateContext(
page: Page,
site: SiteDocument,
incoming: TemplateRenderDataContext | undefined,
+ mediaAssets: Map | undefined,
): TemplateRenderDataContext {
const provided = incoming ?? { entryStack: [] }
const pageFrame = provided.page ?? buildPageFrame(page)
@@ -249,6 +254,7 @@ function composeTemplateContext(
page: pageFrame,
site: provided.site ?? buildSiteFrame(site),
route: provided.route ?? buildRouteFrame(pageFrame.permalink),
+ media: provided.media ?? mediaAssets,
}
}
@@ -507,7 +513,7 @@ export function publishPage(
site,
registry,
breakpointId: options.breakpointId,
- templateContext: composeTemplateContext(page, site, options.templateContext),
+ templateContext: composeTemplateContext(page, site, options.templateContext, options.mediaAssets),
loopData: options.loopData,
mediaAssets: options.mediaAssets,
dynamicNodeIds: dynamicNodeIds.size > 0 ? dynamicNodeIds : undefined,
diff --git a/src/core/templates/dynamicBindings.ts b/src/core/templates/dynamicBindings.ts
index 941dadcec..5a2fa830d 100644
--- a/src/core/templates/dynamicBindings.ts
+++ b/src/core/templates/dynamicBindings.ts
@@ -30,7 +30,7 @@ import { renderMarkdownToHtml } from '@core/markdown/renderMarkdown'
import { isRichtextPropKey } from '@core/sanitize'
import type { TemplateRenderDataContext } from './renderDataContext'
-export type { TemplateRenderDataContext } from './renderDataContext'
+export type { TemplateMediaAsset, TemplateRenderDataContext } from './renderDataContext'
import {
containsTokens,
interpolateTokens,
@@ -60,6 +60,7 @@ import {
* — both live in `./tokenInterpolation.ts` to avoid duplication.
*/
function resolveBindingValue(
+ propKey: string,
binding: DynamicPropBinding,
context: TemplateRenderDataContext,
): unknown {
@@ -68,17 +69,33 @@ function resolveBindingValue(
const value = walkFieldPath(frame, binding.field)
- // Markdown shim: when a binding targets the `body` cell (post-type rows)
- // or any `richText` field stored as markdown and the binding requests
- // `format: 'html'`, render markdown to HTML here so the module receives
- // ready-to-embed HTML rather than raw markdown. Tokens embedded inside
- // the body markdown are interpolated FIRST so authors can write
- // `Hello {currentEntry.title|untitled}` directly in a blog post body and
- // have it resolve against the same render context as page props.
+ // Media shim: a `format: 'media'` binding promises the destination prop a
+ // served URL, but a custom media cell stores the bare ASSET ID. Values that
+ // already carry a path or URL (the pre-materialised aliases like
+ // `featuredMediaPath`, or an external URL) pass through untouched; a bare
+ // reference is translated through the context's media lookup. A miss
+ // (deleted asset, or a surface without a lookup) resolves as "missing" so
+ // the caller's fallback strategy applies instead of the raw id leaking
+ // into `src` attributes.
+ if (binding.format === 'media' && typeof value === 'string' && value !== '') {
+ if (value.includes('/') || value.includes(':')) return value
+ return context.media?.get(value)?.publicPath
+ }
+
+ // Markdown shim: when a `format: 'html'` binding targets the `body` cell
+ // (post-type rows) or lands in a richtext-typed prop (`html`, `*richtext`,
+ // …) — the outlet bound to any custom rich field — render the value
+ // through the markdown pipeline so the module receives ready-to-embed
+ // HTML. richText cells stored as HTML survive unchanged (block HTML passes
+ // through the GFM renderer verbatim), so one code path serves both storage
+ // formats. Tokens embedded inside the value are interpolated FIRST so
+ // authors can write `Hello {currentEntry.title|untitled}` directly in a
+ // blog post body and have it resolve against the same render context as
+ // page props.
if (
binding.format === 'html' &&
typeof value === 'string' &&
- (binding.field === 'body' || binding.field === 'bodyMarkdown')
+ (binding.field === 'body' || binding.field === 'bodyMarkdown' || isRichtextPropKey(propKey))
) {
const interpolated = containsTokens(value) ? interpolateTokens(value, context) : value
return renderMarkdownToHtml(interpolated)
@@ -88,15 +105,15 @@ function resolveBindingValue(
}
/**
- * The implicit binding every `base.outlet` carries: its `html` prop is filled
+ * The DEFAULT binding every `base.outlet` carries: its `html` prop is filled
* with the current entry's markdown body, rendered to HTML. An outlet is, by
- * definition, the hole the current entry's body flows into — there is no UI to
- * set this and it is never persisted on the node. Resolving it here means ANY
- * outlet renders the body, including one a user drags onto a custom template by
- * hand (which carries no `dynamicBindings` overlay). Outside an entry route the
- * entry stack is empty, so `currentEntry.body` resolves to nothing and the
- * outlet stays empty — an `everywhere` layout's outlet then hosts a whole page
- * instead.
+ * definition, the hole the current entry's content flows into — there is no UI
+ * to set this default and it is never persisted on the node. Resolving it here
+ * means ANY outlet renders the body, including one a user drags onto a custom
+ * template by hand (which carries no `dynamicBindings` overlay). Outside an
+ * entry route the entry stack is empty, so `currentEntry.body` resolves to
+ * nothing and the outlet stays empty — an `everywhere` layout's outlet then
+ * hosts a whole page instead.
*/
const OUTLET_BODY_BINDING: DynamicPropBinding = {
source: 'currentEntry',
@@ -105,17 +122,21 @@ const OUTLET_BODY_BINDING: DynamicPropBinding = {
}
/**
- * The bindings that actually apply to a node at render time: its persisted
- * `dynamicBindings` overlay plus the implicit outlet body binding for
- * `base.outlet`. Both the publisher (`renderNode`) and the editor canvas
- * (`NodeRenderer`) resolve through this so the two surfaces render identically.
+ * The bindings that actually apply to a node at render time: the implicit
+ * outlet body binding for `base.outlet` overlaid with the node's persisted
+ * `dynamicBindings`. Persisted bindings win — an author or plugin can point
+ * an outlet's `html` at ANY rich field (a custom table's richText cell, not
+ * just `body`); the implicit binding is only the default for outlets that
+ * carry no overlay of their own. Both the publisher (`renderNode`) and the
+ * editor canvas (`NodeRenderer`) resolve through this so the two surfaces
+ * render identically.
*/
export function effectiveNodeBindings(node: {
moduleId: string
dynamicBindings?: Record
}): Record | undefined {
if (node.moduleId === 'base.outlet') {
- return { ...node.dynamicBindings, html: OUTLET_BODY_BINDING }
+ return { html: OUTLET_BODY_BINDING, ...node.dynamicBindings }
}
return node.dynamicBindings
}
@@ -137,7 +158,7 @@ export function resolveDynamicProps(
if (bindings) {
resolved = { ...staticProps }
for (const [propKey, binding] of Object.entries(bindings)) {
- const value = resolveBindingValue(binding, context)
+ const value = resolveBindingValue(propKey, binding, context)
if (value === undefined || value === null) {
if (binding.fallback === 'empty') resolved[propKey] = ''
continue
diff --git a/src/core/templates/renderDataContext.ts b/src/core/templates/renderDataContext.ts
index 695c65d04..96ae5c688 100644
--- a/src/core/templates/renderDataContext.ts
+++ b/src/core/templates/renderDataContext.ts
@@ -10,16 +10,35 @@ import type {
SiteFrame,
} from './contextFrames'
+/**
+ * Minimal media-asset shape the binding resolver needs to translate a stored
+ * asset reference into a served URL. The publisher's `RenderResolvedMedia`,
+ * the server repo's `MediaAsset`, and the admin's `CmsMediaAsset` all satisfy
+ * it structurally, so every surface can hand its own map straight in.
+ */
+export interface TemplateMediaAsset {
+ readonly publicPath: string
+}
+
/**
* Render-time context handed to the publisher.
*
* `entryStack` is an immutable snapshot for the current frame. Stack-top
* resolves `currentEntry`; one below resolves `parentEntry`. The named frames
* are built by the publisher and referenced by their matching binding sources.
+ *
+ * `media` is a live in-memory lookup keyed by asset id AND public path, used
+ * by `format: 'media'` bindings to translate a bare asset reference (a custom
+ * media cell stores the id) into the asset's served URL. Each surface attaches
+ * its own: the publisher wires in the `prefetchMediaAssets` map, the canvas
+ * wires in the admin media-library cache. Being a Map, it can never travel a
+ * JSON boundary — the runtime-preview endpoint strips whatever arrived on the
+ * wire and lets the server-side prefetch supply it instead.
*/
export interface TemplateRenderDataContext {
readonly entryStack: readonly LoopItem[]
readonly page?: PageFrame
readonly site?: SiteFrame
readonly route?: RouteFrame
+ readonly media?: ReadonlyMap
}