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
19 changes: 19 additions & 0 deletions docs/features/loops.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,25 @@ The `renderNode` callback is the publisher's normal walker — so a variant's su

See [docs/features/publisher.md](publisher.md) → "renderLoop" for the broader pipeline.

### The wrapper element

`renderLoop` emits one wrapper around the iterations, and the canvas
(`LoopEditor.tsx`) mirrors it attribute for attribute so user CSS targeting
`[data-instatic-loop] > article` matches in both places.

| Source | Attributes |
|---|---|
| Runtime | `data-instatic-loop`, `data-instatic-loop-page`, plus `data-instatic-loop-mode` / `-has-more` / `-page-size` in infinite mode |
| Author | `tag` / `customTag` choose the element; `htmlAttributes` adds arbitrary attributes, same control as `base.container` |
| Node | `classIds` → class names, `inlineStyles` → `style` |

The `htmlAttributes` bag is what lets a repeated list be *addressed*: `role="list"`
and `aria-label` for assistive technology, or a `data-*` hook for a carousel,
filter, or marquee script that has to find the collection wrapper. Values pass
through the shared sanitiser (`src/core/htmlAttributes/`), which reserves the
`data-instatic-*` and `data-canvas-*` prefixes — so the loop's own bookkeeping
cannot be redirected from the attributes panel.

---

## Prefetch
Expand Down
63 changes: 63 additions & 0 deletions src/__tests__/publisher/loopRender.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,3 +393,66 @@ describe('publisher loop renderer', () => {
expect(html).not.toContain('loop-runtime.js')
})
})

// ---------------------------------------------------------------------------
// Author attributes on the wrapper
// ---------------------------------------------------------------------------

describe('publisher loop renderer — htmlAttributes', () => {
const items = [makeItem('1', 'one'), makeItem('2', 'two')]

function publishLoopWith(htmlAttributes: unknown): string {
const page = makePage({
root: { moduleId: 'base.body', children: ['loop'] },
loop: { moduleId: 'base.loop', children: ['card'], props: { htmlAttributes } },
card: {
moduleId: 'base.text',
props: { text: '' },
dynamicBindings: { text: { source: 'currentEntry', field: 'title' } },
},
})
return publishPage(page, makeSite(), baseRegistry, {
loopData: new Map([['loop', loopData(items)]]),
}).html
}

it('emits author attributes on the wrapper element', () => {
// A repeated list is exactly what a carousel / filter script addresses,
// and what a screen reader needs labelled.
const html = publishLoopWith({ role: 'list', 'aria-label': 'Members', 'data-marquee': 'slow' })
expect(html).toContain('role="list"')
expect(html).toContain('aria-label="Members"')
expect(html).toContain('data-marquee="slow"')
})

it('keeps the loop runtime bookkeeping when an author reuses the name', () => {
// The shared sanitiser reserves the `data-instatic-*` prefix, so the
// pagination and hole machinery cannot be redirected from the attributes
// panel — the author's value never reaches the tag at all.
const html = publishLoopWith({ 'data-instatic-loop': 'hijacked' })
expect(html).toContain('data-instatic-loop="loop"')
expect(html).not.toContain('hijacked')
})

it('drops values the attribute sanitiser rejects', () => {
const html = publishLoopWith({ onclick: 'alert(1)', href: 'javascript:alert(1)' })
expect(html).not.toContain('alert(1)')
})

it('escapes attribute values', () => {
const html = publishLoopWith({ 'data-label': '"><script>x</script>' })
expect(html).not.toContain('<script>x</script>')
})

it('adds nothing when no attributes are set', () => {
const page = makePage({
root: { moduleId: 'base.body', children: ['loop'] },
loop: { moduleId: 'base.loop', children: ['card'], props: {} },
card: { moduleId: 'base.text', props: { text: 'x' } },
})
const html = publishPage(page, makeSite(), baseRegistry, {
loopData: new Map([['loop', loopData(items)]]),
}).html
expect(html).toContain('<div data-instatic-loop="loop" data-instatic-loop-page="1">')
})
})
6 changes: 6 additions & 0 deletions src/core/publisher/renderLoop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from '@core/loops'
import type { TemplateRenderDataContext } from '@core/templates/dynamicBindings'
import { resolveHtmlTag } from '@modules/base/utils/htmlTag'
import { htmlAttributesAttr } from '@modules/base/shared/htmlAttributes'
import { injectNodeClassIds, injectNodeId, injectNodeInlineStyles } from './classInjection'
import { escapeHtml } from './utils'
import type { RenderConfig, RenderAccumulators, RenderNodeFn } from './renderConfig'
Expand Down Expand Up @@ -117,6 +118,11 @@ export function renderLoop(
acc.infiniteLoopIds.add(loopId)
}

// Author attributes — normalised, escaped, and filtered by the shared
// sanitiser, which reserves the `data-instatic-*` prefix so the loop's own
// bookkeeping above can't be redirected from the attributes panel.
attrs += htmlAttributesAttr(props.htmlAttributes)

// Wrapper element — author-selectable via the shared htmlTag helper
// (defaults to 'div'). `resolveHtmlTag` always returns a safe lowercase
// tag name, so it's already escape-safe for interpolation.
Expand Down
5 changes: 5 additions & 0 deletions src/modules/base/loop/LoopEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type { ModuleComponentProps } from '@core/module-engine'
import { CanvasModulePlaceholder } from '@ui/components/CanvasModulePlaceholder'
import { BoxStackSolidIcon } from 'pixel-art-icons/icons/box-stack-solid'
import { resolveHtmlTag } from '@modules/base/utils/htmlTag'
import { htmlAttributesForReact } from '@modules/base/shared/htmlAttributes'

export const LoopEditor: React.FC<ModuleComponentProps> = ({ props, children, mcClassName, nodeWrapperProps, nodeId }) => {
const hasChildren = React.Children.count(children) > 0
Expand All @@ -44,11 +45,15 @@ export const LoopEditor: React.FC<ModuleComponentProps> = ({ props, children, mc
// diverges from the published DOM and user CSS targeting
// `[data-instatic-loop] > article` (a common grid-of-cards pattern) doesn't
// match in the editor preview.
// Author attributes are spread first, mirroring the published order. The
// shared sanitiser reserves `data-instatic-*` / `data-canvas-*`, so the
// wrapper's own attributes below cannot be overwritten either way.
const Tag = resolveHtmlTag(props.tag, props.customTag)
return React.createElement(
Tag,
{
...nodeWrapperProps,
...htmlAttributesForReact(props.htmlAttributes),
className: mcClassName,
'data-instatic-loop': nodeId,
'data-instatic-loop-page': '1',
Expand Down
6 changes: 6 additions & 0 deletions src/modules/base/loop/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,17 @@
* shared `htmlTag` helper (same controls as `base.container`): authors
* can pick a built-in tag (div, ul, nav, …) or supply a custom name.
* Default is 'div' so existing loops keep their current published HTML.
* It also carries `htmlAttributes` like the other structural modules —
* a repeated list is exactly the element third-party scripts and
* assistive technology need to address (`role`, `aria-label`, a `data-*`
* hook for a carousel or filter script).
*/
import type { ModuleDefinition } from '@core/module-engine'
import { registry } from '@core/module-engine'
import { Type, Value, type Static } from '@core/utils/typeboxHelpers'
import { BoxStackSolidIcon } from 'pixel-art-icons/icons/box-stack-solid'
import { resolveHtmlTag } from '@modules/base/utils/htmlTag'
import { HtmlAttributesPropSchemaOptions } from '@modules/base/shared/htmlAttributes'
import { LoopEditor } from './LoopEditor'

const LoopPropsSchema = Type.Object({
Expand All @@ -48,6 +53,7 @@ const LoopPropsSchema = Type.Object({
pageSize: Type.Number({ default: 10 }),
tag: Type.String({ default: 'div' }),
customTag: Type.String({ default: '' }),
htmlAttributes: Type.Record(Type.String(), Type.String(), HtmlAttributesPropSchemaOptions),
})

type LoopProps = Static<typeof LoopPropsSchema>
Expand Down