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