domma-cms 0.36.19 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -173,12 +173,77 @@ function formatDate(input, fmt) {
173
173
  t => String(tokens[t]()));
174
174
  }
175
175
 
176
+ /**
177
+ * Build the `data-ctx` attribute that powers the public-site right-click
178
+ * search/filter menu (public/js/collection-context.js). The payload carries
179
+ * ONLY the fields the display actually renders — never the full entry data —
180
+ * so hidden/allowlisted-away fields can't leak through the attribute.
181
+ *
182
+ * @param {Array<{name: string, label?: string}>} fields - displayed fields
183
+ * @param {object[]} entries - collection entries {id, data}
184
+ * @returns {string} ` data-ctx="<base64 JSON>"` (leading space included)
185
+ */
186
+ function buildCtxAttr(fields, entries) {
187
+ const names = [...new Set(fields.map(f => f?.name).filter(Boolean))];
188
+ if (!names.length || !entries.length) return '';
189
+ const payload = {
190
+ fields: names.map(n => ({name: n, label: fields.find(f => f.name === n)?.label || n})),
191
+ entries: entries.map(e => ({
192
+ id: String(e.id ?? ''),
193
+ data: Object.fromEntries(names.map(n => [n, e.data?.[n] ?? '']))
194
+ }))
195
+ };
196
+ return ` data-ctx="${Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')}"`;
197
+ }
198
+
199
+ /** Fields referenced by a block template's {{placeholders}} (system keys excluded). */
200
+ function blockTemplateFields(tpl) {
201
+ const names = new Set();
202
+ for (const m of String(tpl || '').matchAll(/\{\{\{?([\w]+)/g)) {
203
+ if (!['_id', '_createdAt', '_updatedAt'].includes(m[1])) names.add(m[1]);
204
+ }
205
+ return [...names].map(n => ({name: n, label: n}));
206
+ }
207
+
176
208
  function buildBlockStyleTag(blockName, css) {
177
209
  if (!css || !css.trim()) return '';
178
210
  const body = String(css).replace(/<\/style/gi, '<\\/style');
179
211
  return `<style>[data-block="${escapeAttr(blockName)}"] {\n${body}\n}</style>\n`;
180
212
  }
181
213
 
214
+ /**
215
+ * Interpolate a block template against a single collection entry.
216
+ *
217
+ * Triple-brace `{{{field}}}` — raw HTML pass-through (no escape). Use for
218
+ * fields that contain pre-rendered HTML. Must be processed BEFORE double-brace
219
+ * so the outer `{`/`}` of a triple match aren't consumed by the double-brace
220
+ * regex. Double-brace `{{field}}` — HTML-escaped (default, safe), with an
221
+ * optional `:format` suffix (e.g. `{{_createdAt:DD MMM YYYY}}`) that applies
222
+ * moment-style date formatting when the value parses as a Date.
223
+ *
224
+ * @param {string} blockTemplate - Raw HTML template with {{placeholders}}
225
+ * @param {object} e - Collection entry { id, data, meta }
226
+ * @returns {string}
227
+ */
228
+ function interpolateBlockTemplate(blockTemplate, e) {
229
+ let html = blockTemplate.replace(/\{\{\{(\w+)\}\}\}/g, (_, key) => {
230
+ return String(e.data?.[key] ?? '');
231
+ });
232
+ html = html.replace(/\{\{([\w_]+)(?::([^{}]+))?\}\}/g, (_, key, fmt) => {
233
+ let value;
234
+ if (key === '_id') value = e.id ?? '';
235
+ else if (key === '_createdAt') value = e.meta?.createdAt ?? '';
236
+ else if (key === '_updatedAt') value = e.meta?.updatedAt ?? '';
237
+ else value = e.data?.[key] ?? '';
238
+ if (fmt) {
239
+ const formatted = formatDate(value, fmt);
240
+ if (formatted !== null) return escapeHtmlText(formatted);
241
+ }
242
+ return escapeHtmlText(value);
243
+ });
244
+ return html;
245
+ }
246
+
182
247
  /**
183
248
  * Render collection entries using a reusable HTML block template.
184
249
  * Replaces {{fieldName}}, {{_id}}, {{_createdAt}}, {{_updatedAt}} placeholders.
@@ -194,38 +259,16 @@ function renderCollectionBlocks(entries, blockTemplate, emptyMsg, ctaOpts, cols,
194
259
  return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
195
260
  }
196
261
 
197
- // Only wrap entries when the block carries CSS — otherwise keep the existing
198
- // DOM structure unchanged (backward-compatible for pages using block display
199
- // before this feature existed).
262
+ // Every entry gets a neutral wrapper carrying data-entry-id (needed by the
263
+ // public right-click filter menu); the data-block attribute — which scopes
264
+ // the block's CSS — is only added when the block actually carries CSS.
200
265
  const hasCss = !!(blockCss && blockCss.trim());
201
- const wrapOpen = hasCss && blockName ? `<div data-block="${escapeAttr(blockName)}">` : '';
202
- const wrapClose = hasCss && blockName ? `</div>` : '';
266
+ const blockAttr = hasCss && blockName ? ` data-block="${escapeAttr(blockName)}"` : '';
203
267
 
204
268
  const items = entries.map(e => {
205
- // Triple-brace `{{{field}}}` — raw HTML pass-through (no escape).
206
- // Use for fields that contain pre-rendered HTML (bullet lists, code
207
- // blocks, embedded elements). Must be processed BEFORE double-brace
208
- // so the outer `{` and `}` of a triple match aren't consumed by the
209
- // double-brace regex.
210
- let html = blockTemplate.replace(/\{\{\{(\w+)\}\}\}/g, (_, key) => {
211
- return String(e.data?.[key] ?? '');
212
- });
213
- // Double-brace `{{field}}` — HTML-escaped (default, safe).
214
- // Optional `:format` suffix (e.g. `{{_createdAt:DD MMM YYYY}}`) applies
215
- // moment-style date formatting when the value parses as a Date; any
216
- // field that doesn't parse falls back to its raw value.
217
- html = html.replace(/\{\{([\w_]+)(?::([^{}]+))?\}\}/g, (_, key, fmt) => {
218
- let value;
219
- if (key === '_id') value = e.id ?? '';
220
- else if (key === '_createdAt') value = e.meta?.createdAt ?? '';
221
- else if (key === '_updatedAt') value = e.meta?.updatedAt ?? '';
222
- else value = e.data?.[key] ?? '';
223
- if (fmt) {
224
- const formatted = formatDate(value, fmt);
225
- if (formatted !== null) return escapeHtmlText(formatted);
226
- }
227
- return escapeHtmlText(value);
228
- });
269
+ const wrapOpen = `<div${blockAttr} data-entry-id="${escapeAttr(String(e.id ?? ''))}">`;
270
+ const wrapClose = `</div>`;
271
+ let html = interpolateBlockTemplate(blockTemplate, e);
229
272
 
230
273
  if (ctaOpts) {
231
274
  const ctaStyle = escapeAttr(ctaOpts.style || 'primary');
@@ -243,8 +286,9 @@ function renderCollectionBlocks(entries, blockTemplate, emptyMsg, ctaOpts, cols,
243
286
  ? `dm-collection-display dm-collection-blocks grid ${responsiveGridCols(validCols).join(' ')} gap-4`
244
287
  : 'dm-collection-display dm-collection-blocks';
245
288
 
289
+ const ctx = buildCtxAttr(blockTemplateFields(blockTemplate), entries);
246
290
  const styleTag = buildBlockStyleTag(blockName, blockCss);
247
- return `${styleTag}<div class="${wrapperClass}">\n${items.join('\n')}\n</div>`;
291
+ return `${styleTag}<div class="${wrapperClass}"${ctx}>\n${items.join('\n')}\n</div>`;
248
292
  }
249
293
 
250
294
  function renderBlockFromAttrs(tmpl, attrs, blockName = '') {
@@ -459,9 +503,9 @@ function renderCollectionCards(entries, visibleFields, titleField, columns, empt
459
503
  const iconHtml = ctaOpts.icon ? `<span data-icon="${escapeAttr(ctaOpts.icon)}"></span> ` : '';
460
504
  footer = `<div class="card-footer"><button class="${btnCls}" ${btnData}>${iconHtml}${escapeHtmlText(ctaOpts.label || 'Run')}</button></div>`;
461
505
  }
462
- return `<div class="card">${title ? `<div class="card-header">${title}</div>` : ''}<div class="card-body">${body || '&nbsp;'}</div>${footer}</div>`;
506
+ return `<div class="card" data-entry-id="${escapeAttr(String(e.id ?? ''))}">${title ? `<div class="card-header">${title}</div>` : ''}<div class="card-body">${body || '&nbsp;'}</div>${footer}</div>`;
463
507
  }).join('\n');
464
- return `<div class="dm-collection-display grid ${responsiveGridCols(cols).join(' ')} gap-4">\n${cards}\n</div>`;
508
+ return `<div class="dm-collection-display grid ${responsiveGridCols(cols).join(' ')} gap-4"${buildCtxAttr(visibleFields, entries)}>\n${cards}\n</div>`;
465
509
  }
466
510
 
467
511
  function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaOpts) {
@@ -486,9 +530,9 @@ function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaO
486
530
  const iconHtml = ctaOpts.icon ? `<span data-icon="${escapeAttr(ctaOpts.icon)}"></span> ` : '';
487
531
  ctaHtml = `<button class="${btnCls}" ${btnData}>${iconHtml}${escapeHtmlText(ctaOpts.label || 'Run')}</button>`;
488
532
  }
489
- return `<div class="dm-collection-list-item">${title ? `<strong>${title}</strong>` : ''}${rest}${ctaHtml}</div>`;
533
+ return `<div class="dm-collection-list-item" data-entry-id="${escapeAttr(String(e.id ?? ''))}">${title ? `<strong>${title}</strong>` : ''}${rest}${ctaHtml}</div>`;
490
534
  }).join('\n');
491
- return `<div class="dm-collection-display dm-collection-list">\n${items}\n</div>`;
535
+ return `<div class="dm-collection-display dm-collection-list"${buildCtxAttr(visibleFields, entries)}>\n${items}\n</div>`;
492
536
  }
493
537
 
494
538
  /**
@@ -511,7 +555,7 @@ function renderCollectionAccordion(entries, titleField, bodyField, multiple, emp
511
555
  const bodyVal = String(e.data?.[bodyField] ?? '');
512
556
  const bodyHtml = marked.parse(bodyVal);
513
557
  return (
514
- `<div class="accordion-item">\n` +
558
+ `<div class="accordion-item" data-entry-id="${escapeAttr(String(e.id ?? ''))}">\n` +
515
559
  ` <h3 class="accordion-header"><button class="accordion-button" type="button">${title}` +
516
560
  `<span class="accordion-icon" data-icon="chevron-down"></span></button></h3>\n` +
517
561
  ` <div class="accordion-body"><div class="accordion-content">${bodyHtml}</div></div>\n` +
@@ -519,7 +563,8 @@ function renderCollectionAccordion(entries, titleField, bodyField, multiple, emp
519
563
  );
520
564
  }).join('\n');
521
565
 
522
- return `<div class="dm-collection-display accordion"${multiAttr}>\n${items}\n</div>`;
566
+ const ctx = buildCtxAttr([{name: titleField}, {name: bodyField}], entries);
567
+ return `<div class="dm-collection-display accordion"${multiAttr}${ctx}>\n${items}\n</div>`;
523
568
  }
524
569
 
525
570
  /**
@@ -553,10 +598,167 @@ function renderCollectionTimeline(entries, opts) {
553
598
  const bodyHtml = bodyField
554
599
  ? marked.parse(String(e.data?.[bodyField] ?? ''))
555
600
  : '';
556
- return `<div class="dm-progression-item"${date}${status}${icon}><div class="dm-progression-item-title">${title}</div><div class="dm-progression-item-body">${bodyHtml}</div></div>`;
601
+ return `<div class="dm-progression-item" data-entry-id="${escapeAttr(String(e.id ?? ''))}"${date}${status}${icon}><div class="dm-progression-item-title">${title}</div><div class="dm-progression-item-body">${bodyHtml}</div></div>`;
602
+ }).join('\n');
603
+
604
+ const ctx = buildCtxAttr(
605
+ [{name: titleField}, {name: dateField}, {name: statusField}, {name: bodyField}].filter(f => f.name),
606
+ entries
607
+ );
608
+ return `<div class="dm-collection-display dm-progression" data-layout="${layout}" data-theme="${theme}" data-mode="${mode}"${ctx}>\n${items}\n</div>`;
609
+ }
610
+
611
+ /**
612
+ * Render a collection as a Domma carousel — one slide per entry.
613
+ * Output mirrors processCarouselBlocks so site.js's `.carousel` auto-init
614
+ * (Domma.elements.carousel) picks it up unchanged.
615
+ *
616
+ * Per-slide content comes either from a block template (opts.blockTemplate,
617
+ * interpolated per entry like display="block") or from image/title/body
618
+ * fields rendered in the standard carousel-slide structure.
619
+ *
620
+ * @param {object[]} entries
621
+ * @param {object} opts
622
+ * @param {string} [opts.imageField] - field holding the slide image URL
623
+ * @param {string} [opts.titleField] - field for the slide title
624
+ * @param {string} [opts.bodyField] - Markdown body field
625
+ * @param {string} [opts.blockTemplate] - optional block template (wins over fields)
626
+ * @param {string} [opts.blockName]
627
+ * @param {string} [opts.blockCss]
628
+ * @param {object} [opts.dataAttrs] - autoplay/interval/loop/animation passthrough
629
+ * @param {string} opts.emptyMsg
630
+ * @returns {string}
631
+ */
632
+ function renderCollectionCarousel(entries, opts) {
633
+ const {imageField, titleField, bodyField, blockTemplate, blockName, blockCss, dataAttrs = {}, emptyMsg} = opts;
634
+ if (!entries.length) {
635
+ return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
636
+ }
637
+
638
+ const passthrough = ['autoplay', 'interval', 'loop', 'animation', 'animation-duration', 'animation-easing']
639
+ .map(k => dataAttrs[k] ? ` data-${k}="${escapeAttr(String(dataAttrs[k]))}"` : '')
640
+ .join('');
641
+
642
+ const hasCss = !!(blockCss && blockCss.trim());
643
+ const slides = entries.map(e => {
644
+ let inner = '';
645
+ if (blockTemplate) {
646
+ const html = interpolateBlockTemplate(blockTemplate, e);
647
+ inner = hasCss && blockName ? `<div data-block="${escapeAttr(blockName)}">${html}</div>` : html;
648
+ } else {
649
+ const img = imageField && e.data?.[imageField]
650
+ ? `<img src="${escapeAttr(String(e.data[imageField]))}" alt="${escapeAttr(String(e.data?.[titleField] ?? ''))}">\n`
651
+ : '';
652
+ const title = titleField && e.data?.[titleField] != null
653
+ ? `<h2 class="carousel-slide-title">${escapeHtmlText(String(e.data[titleField]))}</h2>`
654
+ : '';
655
+ const bodyHtml = bodyField && e.data?.[bodyField] != null
656
+ ? `<div class="carousel-slide-description">${marked.parse(String(e.data[bodyField]))}</div>`
657
+ : '';
658
+ inner = img + (title || bodyHtml ? `<div class="carousel-slide-content">${title}${bodyHtml}</div>` : '');
659
+ }
660
+ return `<div class="carousel-slide" data-entry-id="${escapeAttr(String(e.id ?? ''))}">${inner}</div>`;
661
+ }).join('\n');
662
+
663
+ const ctxFields = blockTemplate
664
+ ? blockTemplateFields(blockTemplate)
665
+ : [{name: titleField}, {name: bodyField}].filter(f => f.name);
666
+ const ctx = buildCtxAttr(ctxFields, entries);
667
+ const styleTag = blockTemplate ? buildBlockStyleTag(blockName, blockCss) : '';
668
+ return `${styleTag}<div class="dm-collection-display carousel"${passthrough}${ctx}><div class="carousel-track">${slides}</div></div>`;
669
+ }
670
+
671
+ /**
672
+ * Render a collection as a Domma list group — one item per entry.
673
+ * Output mirrors processListGroupBlocks (pure CSS component, no JS init).
674
+ *
675
+ * Per-item content comes either from a block template (interpolated per
676
+ * entry) or from title/body fields.
677
+ *
678
+ * @param {object[]} entries
679
+ * @param {object} opts
680
+ * @param {string} [opts.titleField] - field for the item's leading text
681
+ * @param {string} [opts.bodyField] - inline-Markdown body field
682
+ * @param {string} [opts.variant] - flush | numbered | horizontal
683
+ * @param {string} [opts.size] - sm | lg
684
+ * @param {string} [opts.itemVariant] - primary | secondary | success | danger | warning | info
685
+ * @param {boolean} [opts.action] - add .list-group-item-action per item
686
+ * @param {string} [opts.blockTemplate] - optional block template (wins over fields)
687
+ * @param {string} [opts.blockName]
688
+ * @param {string} [opts.blockCss]
689
+ * @param {string} opts.emptyMsg
690
+ * @returns {string}
691
+ */
692
+ /** Standard "block template not found" fragment. */
693
+ function blockNotFoundHtml(blockName) {
694
+ return `<div class="dm-collection-display dm-collection-empty"><p>Block template &ldquo;${escapeHtmlText(blockName || '')}&rdquo; not found.</p></div>`;
695
+ }
696
+
697
+ /**
698
+ * Load a block's template + CSS when a block attr is present.
699
+ * Returns {tpl: '', css: '', missing: false} when no block was requested,
700
+ * and {missing: true} when the named block can't be loaded — callers show
701
+ * blockNotFoundHtml() rather than silently falling back to field rendering.
702
+ *
703
+ * @param {string} [blockName]
704
+ * @returns {Promise<{tpl: string, css: string, missing: boolean}>}
705
+ */
706
+ async function loadOptionalBlockAssets(blockName) {
707
+ if (!blockName) return {tpl: '', css: '', missing: false};
708
+ try {
709
+ const [tpl, css] = await Promise.all([
710
+ loadBlockTemplate(blockName),
711
+ loadBlockCss(blockName),
712
+ ]);
713
+ return {tpl, css, missing: false};
714
+ } catch {
715
+ return {tpl: '', css: '', missing: true};
716
+ }
717
+ }
718
+
719
+ function renderCollectionListgroup(entries, opts) {
720
+ const {titleField, bodyField, variant, size, itemVariant, action, blockTemplate, blockName, blockCss, emptyMsg} = opts;
721
+ if (!entries.length) {
722
+ return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
723
+ }
724
+
725
+ const variantMap = {flush: 'list-group-flush', numbered: 'list-group-numbered', horizontal: 'list-group-horizontal'};
726
+ const sizeMap = {sm: 'list-group-sm', lg: 'list-group-lg'};
727
+ const wrapperClasses = ['dm-collection-display', 'list-group'];
728
+ if (variantMap[variant]) wrapperClasses.push(variantMap[variant]);
729
+ if (sizeMap[size]) wrapperClasses.push(sizeMap[size]);
730
+
731
+ const itemClasses = ['list-group-item'];
732
+ if (action) itemClasses.push('list-group-item-action');
733
+ if (itemVariant && /^(primary|secondary|success|danger|warning|info)$/.test(itemVariant)) {
734
+ itemClasses.push(`list-group-item-${itemVariant}`);
735
+ }
736
+ const itemClassStr = itemClasses.join(' ');
737
+
738
+ const hasCss = !!(blockCss && blockCss.trim());
739
+ const items = entries.map(e => {
740
+ let inner;
741
+ if (blockTemplate) {
742
+ const html = interpolateBlockTemplate(blockTemplate, e);
743
+ inner = hasCss && blockName ? `<div data-block="${escapeAttr(blockName)}">${html}</div>` : html;
744
+ } else {
745
+ const title = titleField && e.data?.[titleField] != null
746
+ ? `<strong>${escapeHtmlText(String(e.data[titleField]))}</strong>`
747
+ : '';
748
+ const body = bodyField && e.data?.[bodyField] != null
749
+ ? `${title ? ' ' : ''}${marked.parseInline(String(e.data[bodyField]))}`
750
+ : '';
751
+ inner = (title + body) || escapeHtmlText(String(e.id ?? ''));
752
+ }
753
+ return `<div class="${itemClassStr}" data-entry-id="${escapeAttr(String(e.id ?? ''))}">${inner}</div>`;
557
754
  }).join('\n');
558
755
 
559
- return `<div class="dm-collection-display dm-progression" data-layout="${layout}" data-theme="${theme}" data-mode="${mode}">\n${items}\n</div>`;
756
+ const ctxFields = blockTemplate
757
+ ? blockTemplateFields(blockTemplate)
758
+ : [{name: titleField}, {name: bodyField}].filter(f => f.name);
759
+ const ctx = buildCtxAttr(ctxFields, entries);
760
+ const styleTag = blockTemplate ? buildBlockStyleTag(blockName, blockCss) : '';
761
+ return `${styleTag}<div class="${wrapperClasses.join(' ')}"${ctx}>\n${items}\n</div>`;
560
762
  }
561
763
 
562
764
  /**
@@ -661,6 +863,36 @@ async function processViewBlocks(markdown, tagSet) {
661
863
  mode: timelineMode,
662
864
  emptyMsg,
663
865
  });
866
+ } else if (display === 'carousel') {
867
+ const {tpl, css, missing} = await loadOptionalBlockAssets(attrs.block);
868
+ replacement = missing
869
+ ? blockNotFoundHtml(attrs.block)
870
+ : renderCollectionCarousel(entries, {
871
+ imageField: attrs['image-field'] || '',
872
+ titleField: attrs['title-field'] || 'title',
873
+ bodyField: attrs['body-field'] || '',
874
+ blockTemplate: tpl,
875
+ blockName: attrs.block || '',
876
+ blockCss: css,
877
+ dataAttrs: attrs,
878
+ emptyMsg,
879
+ });
880
+ } else if (display === 'listgroup') {
881
+ const {tpl, css, missing} = await loadOptionalBlockAssets(attrs.block);
882
+ replacement = missing
883
+ ? blockNotFoundHtml(attrs.block)
884
+ : renderCollectionListgroup(entries, {
885
+ titleField: attrs['title-field'] || 'title',
886
+ bodyField: attrs['body-field'] || '',
887
+ variant: attrs.variant || '',
888
+ size: attrs.size || '',
889
+ itemVariant: attrs.itemvariant || '',
890
+ action: attrs.action === 'true',
891
+ blockTemplate: tpl,
892
+ blockName: attrs.block || '',
893
+ blockCss: css,
894
+ emptyMsg,
895
+ });
664
896
  } else if (display === 'block') {
665
897
  const blockName = attrs.block || viewConfig?.display?.block || '';
666
898
  if (blockName) {
@@ -671,7 +903,7 @@ async function processViewBlocks(markdown, tagSet) {
671
903
  ]);
672
904
  replacement = renderCollectionBlocks(entries, tpl, emptyMsg, ctaOpts, '', blockName, css);
673
905
  } catch {
674
- replacement = `<div class="dm-collection-display dm-collection-empty"><p>Block template &ldquo;${escapeHtmlText(blockName)}&rdquo; not found.</p></div>`;
906
+ replacement = blockNotFoundHtml(blockName);
675
907
  }
676
908
  }
677
909
  } else {
@@ -1118,6 +1350,36 @@ export async function renderCollectionFragment(attrs, { extraFilter = null } = {
1118
1350
  mode: timelineMode,
1119
1351
  emptyMsg,
1120
1352
  });
1353
+ } else if (display === 'carousel') {
1354
+ const {tpl, css, missing} = await loadOptionalBlockAssets(attrs.block);
1355
+ replacement = missing
1356
+ ? blockNotFoundHtml(attrs.block)
1357
+ : renderCollectionCarousel(entries, {
1358
+ imageField: attrs['image-field'] || '',
1359
+ titleField: attrs['title-field'] || 'title',
1360
+ bodyField: attrs['body-field'] || '',
1361
+ blockTemplate: tpl,
1362
+ blockName: attrs.block || '',
1363
+ blockCss: css,
1364
+ dataAttrs: attrs,
1365
+ emptyMsg,
1366
+ });
1367
+ } else if (display === 'listgroup') {
1368
+ const {tpl, css, missing} = await loadOptionalBlockAssets(attrs.block);
1369
+ replacement = missing
1370
+ ? blockNotFoundHtml(attrs.block)
1371
+ : renderCollectionListgroup(entries, {
1372
+ titleField: attrs['title-field'] || 'title',
1373
+ bodyField: attrs['body-field'] || '',
1374
+ variant: attrs.variant || '',
1375
+ size: attrs.size || '',
1376
+ itemVariant: attrs.itemvariant || '',
1377
+ action: attrs.action === 'true',
1378
+ blockTemplate: tpl,
1379
+ blockName: attrs.block || '',
1380
+ blockCss: css,
1381
+ emptyMsg,
1382
+ });
1121
1383
  } else if (display === 'block') {
1122
1384
  const blockName = attrs.block || '';
1123
1385
  if (blockName) {
@@ -1129,7 +1391,7 @@ export async function renderCollectionFragment(attrs, { extraFilter = null } = {
1129
1391
  const cols = attrs.cols || '';
1130
1392
  replacement = renderCollectionBlocks(entries, tpl, emptyMsg, ctaOpts, cols, blockName, css);
1131
1393
  } catch {
1132
- replacement = `<div class="dm-collection-display dm-collection-empty"><p>Block template &ldquo;${escapeHtmlText(blockName)}&rdquo; not found.</p></div>`;
1394
+ replacement = blockNotFoundHtml(blockName);
1133
1395
  }
1134
1396
  }
1135
1397
  } else {
@@ -116,7 +116,8 @@
116
116
  </script>
117
117
 
118
118
  <!-- Site initialisation -->
119
- <script src="/public/js/site.js?v=20260524-formerror" type="module"></script>
119
+ <script src="/public/js/site.js?v=20260722-collection-ctx" type="module"></script>
120
+ <script src="/public/js/collection-context.js?v=20260722-collection-ctx" defer></script>
120
121
 
121
122
  <!-- Interactive Collection Browser (used by [collection] shortcodes with
122
123
  searchable / filterable / sortable / paginate attributes) -->
@@ -124,7 +125,7 @@
124
125
 
125
126
  <!-- Core Forms (logic engine must load before renderer) -->
126
127
  <script src="/public/js/form-logic-engine.js?v=20260704-chooser-events"></script>
127
- <script src="/public/js/forms.js?v=20260509-chooser" type="module"></script>
128
+ <script src="/public/js/forms.js?v=20260722-pickers" type="module"></script>
128
129
 
129
130
  <!-- Core effects runtime -->
130
131
  <script src="/public/js/effects.js"></script>