From b3959cdba87a01c490dbd5e53a7645a32c8ac02c Mon Sep 17 00:00:00 2001 From: Mostafa Sadeghi <205455727+mostafasadeghidev@users.noreply.github.com> Date: Mon, 3 Aug 2026 05:29:48 +0200 Subject: [PATCH 1/2] fix(templates): media bindings resolve asset ids and outlets bind any rich field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two dynamic-binding engine gaps surfaced while building a template-generating plugin against the platform: 1. `effectiveNodeBindings` spread the implicit `currentEntry.body` outlet binding AFTER the node's persisted overlay, so a persisted custom `html` binding was always clobbered — an outlet could never render any field except `body`. The spread order is now swapped: the implicit binding is a default, a persisted binding wins, so authors and plugins can point an outlet at any rich field. The html-format markdown shim also covers richtext-destination props now (not just body/bodyMarkdown), so both markdown- and HTML-stored richText cells render correctly. 2. `format: 'media'` bindings had no resolver branch — binding e.g. an image `src` to a custom media cell published the raw asset id. The render context now carries a `media` lookup (id + path keyed): - `resolveBindingValue` translates bare references through it; path/URL values pass through; unresolvable references fall back instead of leaking the id. - `publishPage` seeds the lookup from the prefetched asset map; the prefetch collects the bare ids referenced by media bindings (walking the tree's bindings + entry/loop fields) and also keys assets by their materialized publicPath so enrichment survives URL transformers. - The hole and loop fragment endpoints now prefetch media too — appended infinite-scroll items previously rendered without srcset/alt enrichment. - The canvas attaches the admin media-library cache via a new `useCmsMediaAssetLookup`, so the editor resolves ids like the publisher. - The runtime-preview endpoint strips the non-serializable `media` member arriving over JSON and substitutes the server-side prefetch. Co-Authored-By: Claude Fable 5 --- docs/features/publisher.md | 12 +- docs/features/templates.md | 14 +- server/handlers/cms/hole.ts | 10 ++ server/handlers/cms/loop.ts | 18 +- server/handlers/cms/runtime.ts | 6 +- server/publish/mediaPrefetch.ts | 94 +++++++++- .../publisher/outletEntryBody.test.ts | 30 +++- .../server/mediaBatchResolution.test.ts | 61 +++++++ src/__tests__/templates/dynamicRender.test.ts | 170 ++++++++++++++++++ .../media/hooks/useCmsMediaAssetByPath.ts | 51 +++++- .../site/hooks/useTemplatePreviewContext.ts | 6 + src/core/publisher/render.ts | 8 +- src/core/templates/dynamicBindings.ts | 67 ++++--- src/core/templates/renderDataContext.ts | 19 ++ 14 files changed, 524 insertions(+), 42 deletions(-) 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}`. 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/hooks/useTemplatePreviewContext.ts b/src/admin/pages/site/hooks/useTemplatePreviewContext.ts index de4a9f8c4..3f88566c2 100644 --- a/src/admin/pages/site/hooks/useTemplatePreviewContext.ts +++ b/src/admin/pages/site/hooks/useTemplatePreviewContext.ts @@ -1,4 +1,5 @@ import { useAsyncResource } from '@admin/lib/useAsyncResource' +import { useCmsMediaAssetLookup } from '@admin/pages/media/hooks/useCmsMediaAssetByPath' import type { Page } from '@core/page-tree' import type { TemplateRenderDataContext } from '@core/templates/dynamicBindings' import { dataTablePreviewToLoopItem } from '@core/templates/templatePreviewData' @@ -30,6 +31,10 @@ export function useTemplatePreviewContext(page: Page | null): TemplatePreviewCon // Read site once; the page argument is already reactive via the caller. const site = useEditorStore((s) => s.site) + // Canvas-side media lookup (id + path keyed) so `format: 'media'` bindings + // resolve bare asset references exactly like the publisher does. + const media = useCmsMediaAssetLookup() + // ── Template-page entry-stack seed ─────────────────────────────────── // A `postTypes` template previews against the FIRST REAL published row of // its first targeted table — live data, consistent with how `everywhere` @@ -98,6 +103,7 @@ 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), + media, }, } } 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 } From 6b2b239c37f2d42cd3cb8ae6b45359066c35e2a3 Mon Sep 17 00:00:00 2001 From: Mostafa Sadeghi <205455727+mostafasadeghidev@users.noreply.github.com> Date: Mon, 3 Aug 2026 05:29:48 +0200 Subject: [PATCH 2/2] fix(editor): attach the canvas media lookup in CanvasRoot, not the preview-context hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useTemplatePreviewContext also serves the SSR-preview surfaces (site-explorer hover preview, preview overlay, runtime script build), whose context is serialized to the runtime-preview endpoint — a live media Map is dead weight there, and the extra listCmsMediaAssets fetch broke the hover preview's one-request contract. CanvasRoot now enriches the context with useCmsMediaAssetLookup only for the client-side rendering surfaces (transform layer, live surface, agent snapshot frame). Co-Authored-By: Claude Fable 5 --- src/admin/pages/site/canvas/CanvasRoot.tsx | 17 ++++++++++++++--- .../site/hooks/useTemplatePreviewContext.ts | 9 +++------ 2 files changed, 17 insertions(+), 9 deletions(-) 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 3f88566c2..ae420f62a 100644 --- a/src/admin/pages/site/hooks/useTemplatePreviewContext.ts +++ b/src/admin/pages/site/hooks/useTemplatePreviewContext.ts @@ -1,5 +1,4 @@ import { useAsyncResource } from '@admin/lib/useAsyncResource' -import { useCmsMediaAssetLookup } from '@admin/pages/media/hooks/useCmsMediaAssetByPath' import type { Page } from '@core/page-tree' import type { TemplateRenderDataContext } from '@core/templates/dynamicBindings' import { dataTablePreviewToLoopItem } from '@core/templates/templatePreviewData' @@ -31,10 +30,6 @@ export function useTemplatePreviewContext(page: Page | null): TemplatePreviewCon // Read site once; the page argument is already reactive via the caller. const site = useEditorStore((s) => s.site) - // Canvas-side media lookup (id + path keyed) so `format: 'media'` bindings - // resolve bare asset references exactly like the publisher does. - const media = useCmsMediaAssetLookup() - // ── Template-page entry-stack seed ─────────────────────────────────── // A `postTypes` template previews against the FIRST REAL published row of // its first targeted table — live data, consistent with how `everywhere` @@ -103,7 +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), - media, + // 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. }, } }