---
docs/features/loops.md | 19 +++++++
src/__tests__/publisher/loopRender.test.ts | 63 ++++++++++++++++++++++
src/core/publisher/renderLoop.ts | 6 +++
src/modules/base/loop/LoopEditor.tsx | 5 ++
src/modules/base/loop/index.ts | 6 +++
5 files changed, 99 insertions(+)
diff --git a/docs/features/loops.md b/docs/features/loops.md
index 88dd46306..89bc1f0a8 100644
--- a/docs/features/loops.md
+++ b/docs/features/loops.md
@@ -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
diff --git a/src/__tests__/publisher/loopRender.test.ts b/src/__tests__/publisher/loopRender.test.ts
index ce2d1ae42..59902ebdd 100644
--- a/src/__tests__/publisher/loopRender.test.ts
+++ b/src/__tests__/publisher/loopRender.test.ts
@@ -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': '">' })
+ expect(html).not.toContain('')
+ })
+
+ 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('')
+ })
+})
diff --git a/src/core/publisher/renderLoop.ts b/src/core/publisher/renderLoop.ts
index e5ce6dbd3..d17e05a79 100644
--- a/src/core/publisher/renderLoop.ts
+++ b/src/core/publisher/renderLoop.ts
@@ -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'
@@ -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.
diff --git a/src/modules/base/loop/LoopEditor.tsx b/src/modules/base/loop/LoopEditor.tsx
index ce7f3dbd5..fd4fd6a4f 100644
--- a/src/modules/base/loop/LoopEditor.tsx
+++ b/src/modules/base/loop/LoopEditor.tsx
@@ -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 = ({ props, children, mcClassName, nodeWrapperProps, nodeId }) => {
const hasChildren = React.Children.count(children) > 0
@@ -44,11 +45,15 @@ export const LoopEditor: React.FC = ({ 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',
diff --git a/src/modules/base/loop/index.ts b/src/modules/base/loop/index.ts
index d5503d053..8d1e7e35b 100644
--- a/src/modules/base/loop/index.ts
+++ b/src/modules/base/loop/index.ts
@@ -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({
@@ -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