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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions docs/features/publisher.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,16 @@ plus global preflight.
Media-library background images are optimized in the same publish pass as
`<img srcset>`. `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
Expand Down
14 changes: 13 additions & 1 deletion docs/features/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<main>`), 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.

Expand Down Expand Up @@ -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<string, TemplateMediaAsset> // asset id + path → { publicPath }
}
```

Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions server/handlers/cms/hole.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.
}
Expand Down
18 changes: 14 additions & 4 deletions server/handlers/cms/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -130,15 +131,24 @@ export async function handleLoopRequest(
if (variants.length === 0) {
return jsonResponse({ html: '', hasMore, pageNumber })
}
const loopData = new Map<string, ResolvedLoopRenderData>([
[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<string, ResolvedLoopRenderData>([
[loopId, { items: result.items, totalItems: result.totalItems, pageNumber, hasMore }],
]),
templateContext: { entryStack: [], media: mediaAssets },
loopData,
mediaAssets,
}
const acc: RenderAccumulators = {
cssMap: new Map(),
Expand Down
6 changes: 5 additions & 1 deletion server/handlers/cms/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,11 @@ export async function handleRuntimeRoutes(req: Request, db: DbClient): Promise<R
const breakpointId = body.breakpointId?.trim() || undefined
// TemplateRenderDataContext has deep-nested types that can't be modelled in
// TypeBox without mirroring the full interface — pass through as-is.
const templateContext = body.templateContext as TemplateRenderDataContext | undefined
// EXCEPT `media`: it is a live in-memory Map on the canvas side, so
// whatever survived JSON here is garbage — drop it and let the preview's
// own `prefetchMediaAssets` supply the lookup via `publishPage`.
const wireContext = body.templateContext as TemplateRenderDataContext | undefined
const templateContext = wireContext ? { ...wireContext, media: undefined } : undefined
if (!pageId) return badRequest('Missing pageId')

try {
Expand Down
94 changes: 89 additions & 5 deletions server/publish/mediaPrefetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@
* transparently picks up the new variant list.
*/

import type { Page, SiteDocument } from '@core/page-tree'
import type { Page, PageNode, SiteDocument } from '@core/page-tree'
import type { IModuleRegistry } from '@core/module-engine'
import {
collectNodeBackgroundImagePaths,
collectSiteStyleBackgroundImagePaths,
type ResolvedLoopRenderData,
} from '@core/publisher'
import type { TemplateRenderDataContext } from '@core/templates/dynamicBindings'
import { walkFieldPath } from '@core/templates/tokenInterpolation'
import { walkRenderTree } from './renderTreeWalk'
import type { DbClient } from '../db/client'
import type { MediaAsset } from '../repositories/media'
Expand All @@ -41,17 +42,28 @@ type MediaAssetMap = Map<string, MediaAsset>
interface MediaPrefetchOptions {
templateContext?: TemplateRenderDataContext
loopData?: ReadonlyMap<string, ResolvedLoopRenderData>
/**
* 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<string> {
function collectMediaPaths(
page: Page,
site: SiteDocument,
registry: IModuleRegistry,
rootNodeId: string,
): Set<string> {
const paths = new Set<string>()
// 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)
Expand Down Expand Up @@ -85,8 +97,12 @@ export async function prefetchMediaAssets(
options: MediaPrefetchOptions = {},
): Promise<MediaAssetMap> {
const map = new Map<string, MediaAsset>()
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,
Expand Down Expand Up @@ -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
}

/**
Expand Down Expand Up @@ -198,3 +223,62 @@ function collectEntryMediaReferences(options: MediaPrefetchOptions): Set<string>
}
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<string> {
const fieldPaths = new Set<string>()
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<string>()
if (fieldPaths.size === 0) return references

const collectFrom = (fields: Record<string, unknown>): 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
}
30 changes: 29 additions & 1 deletion src/__tests__/publisher/outletEntryBody.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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('<h2>Summary heading</h2>')
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'] },
Expand Down
61 changes: 61 additions & 0 deletions src/__tests__/server/mediaBatchResolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
})
})
Loading