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('