From 740e1c015d6531c7bde69d4e41acacaaa25652d5 Mon Sep 17 00:00:00 2001 From: Arlo Vance <30844134+invisibletype@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:17:08 -0400 Subject: [PATCH] fix(loops): let loops iterate repeater sub-fields and publish bound media URLs Repeater items ({ id, cells }) now expose their cells as loop item fields, so a loop nested in a repeater loop can iterate an item's own multi-value sub-field (a case-study block's images), and the Loop panel offers repeater fields plus a repeater's sub-fields inside a repeater loop. base.image and base.video now take src from the resolved media asset, so a src bound to a media field publishes the asset URL instead of its id. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/features/loops.md | 20 ++++- src/__tests__/base-modules.test.ts | 20 +++++ src/__tests__/loops/entryField.test.ts | 14 +++ .../modules/imageResponsiveAttrs.test.ts | 13 +++ src/__tests__/publisher/loopRender.test.ts | 42 +++++++++ .../PropertiesPanel/LoopPropertiesView.tsx | 85 ++++++++++++++----- src/core/loops/sources/entryField.ts | 21 +++++ src/modules/base/image/index.ts | 8 +- src/modules/base/video/index.ts | 9 +- 9 files changed, 203 insertions(+), 29 deletions(-) diff --git a/docs/features/loops.md b/docs/features/loops.md index b18364eda..15b1bd48e 100644 --- a/docs/features/loops.md +++ b/docs/features/loops.md @@ -167,8 +167,12 @@ Projects loop ``` The Properties panel offers collection fields from the current entry table: -multi-media, multi-relation, and multi-select fields. Primitive items are -available as `currentEntry.value`; object-array members are exposed by key. +multi-media, multi-relation, multi-select, and repeater fields. Inside a loop +over a repeater, it offers that repeater's own multi-value sub-fields instead. +Primitive items are available as `currentEntry.value`; object-array members are +exposed by key. Repeater items (`{ id, cells }`) expose their cells directly, so +a block reads as `currentEntry.layout` and a loop nested inside the repeater loop +can iterate the block's `images`. Media ids are resolved through the publisher's batched media prefetch and provide `currentEntry.src`, `url`, `path`, `altText`, `mimeType`, `width`, and `height`. @@ -373,6 +377,18 @@ Subscription granularity: the hook never subscribes to the whole `site` document 6. Publish the site and a Project entry. Each project route renders only that project's gallery items, in the field's authored order. +### Build case-study blocks from a repeater + +1. Add a Repeater field named `blocks` with a `layout` select and a multi-media + `images` sub-field. +2. On the entry template, insert a Loop with Source **Current entry field** and + Field **Blocks**. Give its child container an HTML attribute + `data-layout="{currentEntry.layout}"`. +3. Inside that container, insert a second Loop with Field **Images** and an + Image child bound to **Current entry field → Media source**. +4. Style the grid from `[data-layout]` and `:has()` in a site stylesheet. Each + block renders only its own images. + ### Build a loop with the AI agent The site-scope AI agent stays on the HTML-native edit surface. It calls `list_loop_sources` to get valid source ids, table ids, order options, and `{currentEntry.field}` tokens, then inserts an `` marker through `insertHtml` / `replaceNodeHtml`: diff --git a/src/__tests__/base-modules.test.ts b/src/__tests__/base-modules.test.ts index 8ed40b52c..31219a147 100644 --- a/src/__tests__/base-modules.test.ts +++ b/src/__tests__/base-modules.test.ts @@ -975,6 +975,26 @@ describe('base.video — render() specifics', () => { expect(VideoModule.canHaveChildren).toBe(false) }) + it('uses the resolved asset path when videoUrl holds a bound media id', () => { + const { html } = renderModule(VideoModule, { + videoUrl: 'asset-id-123', + _resolvedMediaByKey: { + videoUrl: { + publicPath: '/uploads/reel.mp4', + mimeType: 'video/mp4', + width: 1920, + height: 1080, + altText: '', + blurHash: null, + posterPath: null, + variants: [], + }, + }, + }) + expect(html).toContain('src="/uploads/reel.mp4"') + expect(html).not.toContain('asset-id-123') + }) + it('renders a YouTube iframe when videoUrl is a watch URL', () => { const { html } = renderModule(VideoModule, { videoUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', diff --git a/src/__tests__/loops/entryField.test.ts b/src/__tests__/loops/entryField.test.ts index eac9ce622..397a32f24 100644 --- a/src/__tests__/loops/entryField.test.ts +++ b/src/__tests__/loops/entryField.test.ts @@ -22,6 +22,20 @@ describe('current-entry-field loop items', () => { expect(result.items[0]?.fields.value).toEqual(object) }) + it('exposes repeater item cells as the item fields', () => { + const block = { id: 'block-1', cells: { layout: '2-col', images: ['m1', 'm2'] } } + const result = resolveEntryFieldItems([block]) + + expect(result.items[0]?.id).toBe('block-1') + expect(result.items[0]?.fields).toMatchObject({ + id: 'block-1', + layout: '2-col', + images: ['m1', 'm2'], + index: 0, + }) + expect(result.items[0]?.fields.value).toEqual(block) + }) + it('enriches media ids with renderable media fields', () => { const result = resolveEntryFieldItems(['media-1'], { mediaByReference: new Map([ diff --git a/src/__tests__/modules/imageResponsiveAttrs.test.ts b/src/__tests__/modules/imageResponsiveAttrs.test.ts index f2b27d2ac..89e6751ff 100644 --- a/src/__tests__/modules/imageResponsiveAttrs.test.ts +++ b/src/__tests__/modules/imageResponsiveAttrs.test.ts @@ -73,3 +73,16 @@ describe('base.image sizes resolution', () => { expect(m![1]).not.toContain('.png') }) }) + +describe('base.image src', () => { + it('uses the resolved asset path when src holds a bound media id', () => { + const html = renderImage({ src: 'asset-id-123' }) + expect(html).toContain('src="/uploads/hero.png"') + expect(html).not.toContain('asset-id-123') + }) + + it('keeps an authored URL when no asset resolved', () => { + const html = renderImage({ src: 'https://example.com/a.jpg', _resolvedMediaByKey: undefined }) + expect(html).toContain('src="https://example.com/a.jpg"') + }) +}) diff --git a/src/__tests__/publisher/loopRender.test.ts b/src/__tests__/publisher/loopRender.test.ts index 59902ebdd..b29b9f040 100644 --- a/src/__tests__/publisher/loopRender.test.ts +++ b/src/__tests__/publisher/loopRender.test.ts @@ -346,6 +346,48 @@ describe('publisher loop renderer', () => { expect(html).not.toContain('Project 1/p2-a') expect(html).not.toContain('Project 2/p1-a') }) + + it('iterates a multi-value sub-field of each repeater item', () => { + const page = makePage({ + root: { moduleId: 'base.body', children: ['blocks'] }, + blocks: { + moduleId: 'base.loop', + children: ['images'], + props: { sourceId: 'entry.field', filters: { fieldId: 'blocks' }, limit: 10 }, + }, + images: { + moduleId: 'base.loop', + children: ['card'], + props: { sourceId: 'entry.field', filters: { fieldId: 'images' }, limit: 10 }, + }, + card: { + moduleId: 'test.pair', + props: { parent: '', current: '' }, + dynamicBindings: { + parent: { source: 'parentEntry', field: 'layout' }, + current: { source: 'currentEntry', field: 'value' }, + }, + }, + }) + const caseStudy: LoopItem = { + id: 'case-study', + fields: { + title: 'Case study', + blocks: [ + { id: 'b1', cells: { layout: 'Full-width', images: ['hero'] } }, + { id: 'b2', cells: { layout: '2-col', images: ['left', 'right'] } }, + ], + }, + } + const html = publishPage(page, makeSite(), baseRegistry, { + templateContext: { entryStack: [caseStudy] }, + }).html + + expect(html).toContain('

Full-width/hero

') + expect(html).toContain('

2-col/left

') + expect(html).toContain('

2-col/right

') + expect(html).not.toContain('Full-width/left') + }) }) it('attaches data attrs and registers infinite mode', () => { diff --git a/src/admin/pages/site/panels/PropertiesPanel/LoopPropertiesView.tsx b/src/admin/pages/site/panels/PropertiesPanel/LoopPropertiesView.tsx index 176807657..3257c9ddb 100644 --- a/src/admin/pages/site/panels/PropertiesPanel/LoopPropertiesView.tsx +++ b/src/admin/pages/site/panels/PropertiesPanel/LoopPropertiesView.tsx @@ -24,10 +24,10 @@ import { withoutCellFilter, } from '@core/loops/cellFilter' import type { LoopEntitySource } from '@core/loops/types' -import type { DataRow, DataTableListItem } from '@core/data/schemas' +import type { DataField, DataRow, DataTableListItem, RepeaterItemField } from '@core/data/schemas' import type { PropertyControl, PropertySchema } from '@core/module-engine' import { listCmsDataRows, listCmsDataTables } from '@core/persistence/cmsData' -import { getAncestors, type Page } from '@core/page-tree' +import { getAncestors, type Page, type PageNode } from '@core/page-tree' import { PropertyControlRenderer } from '@site/property-controls/PropertyControlRenderer' import { CUSTOM_HTML_TAG_VALUE } from '@core/htmlAttributes' import { customHtmlTagControl, htmlTagControl } from '@modules/base/utils/htmlTag' @@ -348,34 +348,40 @@ function entryCollectionFieldOptions( ): Array<{ label: string; value: string }> { if (!activePage) return [] - const enclosingDataRowsLoop = [...getAncestors(activePage, nodeId)] + // Nearest loop first: the closest enclosing loop decides what "the current + // entry" is for this one. + const enclosingLoops = [...getAncestors(activePage, nodeId)] .reverse() - .find((node) => node.moduleId === 'base.loop' && node.props.sourceId === 'data.rows') - const filters = enclosingDataRowsLoop?.props.filters - const enclosingTableId = - filters && typeof filters === 'object' && !Array.isArray(filters) - ? (filters as Record).tableId - : null + .filter((node) => node.moduleId === 'base.loop') + const nearestLoop = enclosingLoops[0] + const nearestFilters = loopFilters(nearestLoop) - let contextTables: DataTableListItem[] - if (typeof enclosingTableId === 'string' && enclosingTableId) { - contextTables = tables.filter((table) => table.id === enclosingTableId) - } else if (activePage.template?.target.kind === 'postTypes') { - const slugs = new Set(activePage.template.target.tableSlugs) - contextTables = tables.filter((table) => slugs.has(table.slug)) - } else { - contextTables = [] + // Inside a loop over a repeater, the current entry is one repeater item, + // so the choices are that repeater's own multi-value sub-fields. + if (nearestLoop?.props.sourceId === ENTRY_FIELD_SOURCE_ID) { + const repeaterFieldId = nearestFilters[ENTRY_FIELD_FILTER_KEY] + const outerTables = contextTablesFor(activePage, enclosingLoops.slice(1), tables) + const options: Array<{ label: string; value: string }> = [] + const seen = new Set() + for (const table of outerTables) { + const repeater = table.fields.find((field) => field.id === repeaterFieldId) + if (repeater?.type !== 'repeater') continue + for (const field of repeater.fields) { + if (!isCollectionField(field) || seen.has(field.id)) continue + seen.add(field.id) + options.push({ label: field.label, value: field.id }) + } + } + return options } + const contextTables = contextTablesFor(activePage, enclosingLoops, tables) const showTableName = contextTables.length > 1 const seen = new Set() const options: Array<{ label: string; value: string }> = [] for (const table of contextTables) { for (const field of table.fields) { - const isCollection = - field.type === 'multiSelect' || - ((field.type === 'media' || field.type === 'relation') && field.allowMultiple === true) - if (!isCollection || seen.has(field.id)) continue + if (!isCollectionField(field) || seen.has(field.id)) continue seen.add(field.id) options.push({ label: showTableName ? `${table.name} → ${field.label}` : field.label, @@ -385,3 +391,40 @@ function entryCollectionFieldOptions( } return options } + +/** Fields an `entry.field` loop can iterate: multi-value fields and repeaters. */ +function isCollectionField(field: DataField | RepeaterItemField): boolean { + return ( + field.type === 'multiSelect' || + field.type === 'repeater' || + ((field.type === 'media' || field.type === 'relation') && field.allowMultiple === true) + ) +} + +function loopFilters(loop: PageNode | undefined): Record { + const filters = loop?.props.filters + return filters && typeof filters === 'object' && !Array.isArray(filters) + ? (filters as Record) + : {} +} + +/** + * The tables whose rows can be the current entry: the nearest enclosing + * `data.rows` loop's table, else the post types the template targets. + */ +function contextTablesFor( + activePage: Page, + enclosingLoops: PageNode[], + tables: DataTableListItem[], +): DataTableListItem[] { + const dataRowsLoop = enclosingLoops.find((node) => node.props.sourceId === 'data.rows') + const enclosingTableId = loopFilters(dataRowsLoop).tableId + if (typeof enclosingTableId === 'string' && enclosingTableId) { + return tables.filter((table) => table.id === enclosingTableId) + } + if (activePage.template?.target.kind === 'postTypes') { + const slugs = new Set(activePage.template.target.tableSlugs) + return tables.filter((table) => slugs.has(table.slug)) + } + return [] +} diff --git a/src/core/loops/sources/entryField.ts b/src/core/loops/sources/entryField.ts index d56335ca7..98575c688 100644 --- a/src/core/loops/sources/entryField.ts +++ b/src/core/loops/sources/entryField.ts @@ -7,6 +7,8 @@ * `data.rows` project listing resolves the gallery belonging to each project. */ +import { RepeaterItemSchema } from '@core/data/schemas' +import { safeParseValue } from '@core/utils/typeboxHelpers' import type { ContextualLoopEntitySource, LoopFetchResult, @@ -69,6 +71,23 @@ function projectItem( return { id, fields: { id, value, index } } } + // A repeater item stores its sub-field values under `cells`. Exposing the + // cells directly makes the item read like any other entry: bindings say + // `currentEntry.heading`, and a nested `entry.field` loop can iterate the + // item's own multi-value fields (a block's `images`). + const repeaterItem = safeParseValue(RepeaterItemSchema, value) + if (repeaterItem.ok) { + return { + id, + fields: { + ...repeaterItem.value.cells, + id, + value, + index, + }, + } + } + if (value && typeof value === 'object' && !Array.isArray(value)) { return { id, @@ -90,6 +109,8 @@ function projectItem( * Media ids are enriched when the caller supplies the publisher/canvas media * map. Other primitives remain available as `currentEntry.value`; object * members are exposed directly while the original object remains at `value`. + * Repeater items (`{ id, cells }`) expose their cells, so a loop nested in a + * repeater loop can iterate an item's own multi-value sub-field. */ export function resolveEntryFieldItems( value: unknown, diff --git a/src/modules/base/image/index.ts b/src/modules/base/image/index.ts index 070b1c7b8..2be122e0d 100644 --- a/src/modules/base/image/index.ts +++ b/src/modules/base/image/index.ts @@ -173,7 +173,12 @@ export const ImageModule: ModuleDefinition = { htmlTag: 'img', render: (props) => { - const src = safeUrl(props.src) + // A `src` bound to a media field holds the asset id, not a URL, so the + // resolved asset's `publicPath` wins whenever the publisher found one. + // Authored URLs resolve to an asset with the same path, so they render + // unchanged. + const media = props._resolvedMediaByKey?.src + const src = safeUrl(media?.publicPath ?? props.src) if (!src) return { html: '' } const htmlAttrs = htmlAttributesAttr(props.htmlAttributes) @@ -183,7 +188,6 @@ export const ImageModule: ModuleDefinition = { // // The resolved-media payload is raw (not run through the publisher's // `escapeProps`), so we HTML-escape here at the boundary. - const media = props._resolvedMediaByKey?.src const alt = escapeHtml(media?.altText?.trim() ?? '') const loading = props.loading === 'eager' ? 'eager' : 'lazy' diff --git a/src/modules/base/video/index.ts b/src/modules/base/video/index.ts index 3fcdd4724..1f0d6bba6 100644 --- a/src/modules/base/video/index.ts +++ b/src/modules/base/video/index.ts @@ -127,14 +127,15 @@ export const VideoModule: ModuleDefinition = { }) } - const videoSrc = safeUrl(rawUrl) - if (!videoSrc) return { html: '' } - // Resolved video asset gives us intrinsic dimensions — emits // `width` / `height` attrs so the browser reserves layout space // before the metadata downloads. Same CLS-avoidance trick as the - // image module. + // image module. Its `publicPath` is also the URL when `videoUrl` is + // bound to a media field and so holds the asset id. const videoMedia = props._resolvedMediaByKey?.videoUrl ?? null + const videoSrc = safeUrl(videoMedia?.publicPath ?? rawUrl) + if (!videoSrc) return { html: '' } + const posterMedia = props._resolvedMediaByKey?.poster ?? null // Poster picks the smallest variant that's still ≥ the video's