domma-cms 0.42.0 → 0.44.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.
Files changed (36) hide show
  1. package/CLAUDE.md +36 -2
  2. package/admin/js/templates/collection-editor.html +1 -1
  3. package/admin/js/templates/effects.html +752 -752
  4. package/admin/js/templates/forms.html +17 -17
  5. package/admin/js/templates/my-profile.html +17 -17
  6. package/admin/js/templates/role-editor.html +70 -70
  7. package/admin/js/templates/roles.html +10 -10
  8. package/admin/js/views/action-editor.js +1 -1
  9. package/admin/js/views/block-editor.js +2 -2
  10. package/admin/js/views/collection-editor.js +4 -4
  11. package/admin/js/views/form-editor.js +6 -6
  12. package/admin/js/views/navigation.js +16 -16
  13. package/admin/js/views/page-editor.js +41 -41
  14. package/admin/js/views/view-editor.js +1 -1
  15. package/bin/lib/config-merge.js +44 -44
  16. package/config/plugins.json +1 -1
  17. package/config/site.json +86 -86
  18. package/package.json +1 -1
  19. package/public/css/site.css +1 -1
  20. package/public/js/collection-context.js +2 -2
  21. package/public/js/collection-export.mjs +3 -0
  22. package/public/js/collection-query.mjs +1 -0
  23. package/public/js/collection-sort.mjs +1 -0
  24. package/public/js/site.js +1 -1
  25. package/scripts/build.js +94 -0
  26. package/scripts/setup.js +23 -2
  27. package/server/middleware/auth.js +253 -253
  28. package/server/routes/api/auth.js +309 -309
  29. package/server/routes/api/navigation.js +42 -42
  30. package/server/services/collections.js +12 -11
  31. package/server/services/email.js +167 -167
  32. package/server/services/markdown.js +106 -33
  33. package/server/services/userProfiles.js +199 -199
  34. package/server/services/users.js +302 -302
  35. package/server/templates/page.html +1 -1
  36. package/config/navigation.json.bak +0 -31
@@ -14,6 +14,7 @@ import {getCollection, listEntries} from './collections.js';
14
14
  import {getMenu, resolveLocation, resolveMenuDecorations} from './menus.js';
15
15
  import {buildMenuNav} from './menuRender.js';
16
16
  import {checkVisibility} from '../middleware/auth.js';
17
+ import {getRoleHierarchy} from './roles.js';
17
18
 
18
19
  const __dirname_md = path.dirname(fileURLToPath(import.meta.url));
19
20
  const BLOCKS_DIR = path.resolve(__dirname_md, '../../content/blocks');
@@ -174,6 +175,51 @@ function formatDate(input, fmt) {
174
175
  t => String(tokens[t]()));
175
176
  }
176
177
 
178
+ const EXPORT_FORMATS = ['csv', 'json'];
179
+
180
+ /**
181
+ * Decide whether a display offers export, and to whom. First match wins:
182
+ * 1. schema export.enabled === false → never (exportable="true" ignored)
183
+ * 2. exportable="true" on the shortcode → public (grandfathered: that is
184
+ * exactly what the attribute has always meant)
185
+ * 3. schema export.access → as configured
186
+ * 4. otherwise → the least-privileged role
187
+ *
188
+ * @param {object|undefined} schemaExport - schema.json `export` block
189
+ * @param {object} attrs - shortcode attributes
190
+ * @param {string} defaultRole - least-privileged role name
191
+ */
192
+ export function resolveExportConfig(schemaExport, attrs, defaultRole) {
193
+ const cfg = schemaExport || {};
194
+ if (cfg.enabled === false) return {allowed: false, access: null, formats: [], roles: []};
195
+ const formats = (Array.isArray(cfg.formats) ? cfg.formats : EXPORT_FORMATS)
196
+ .filter(f => EXPORT_FORMATS.includes(f));
197
+ const access = attrs?.exportable === 'true' ? 'public' : (cfg.access || defaultRole);
198
+ return {allowed: true, access, formats, roles: rolesAtOrAbove(access)};
199
+ }
200
+
201
+ /**
202
+ * The role names permitted by an `access` setting — the named role plus every
203
+ * more-privileged one above it.
204
+ *
205
+ * The browser cannot do this itself: it learns the visitor's role name from
206
+ * /api/auth/me but has no copy of the hierarchy, so without this list it could
207
+ * only ask "is anyone signed in?" and every role setting would behave
208
+ * identically. Resolving it server-side keeps the check a single membership
209
+ * test on the client.
210
+ *
211
+ * @param {string} access - 'public', or a role name
212
+ * @returns {string[]} empty for 'public' (no check needed) or an unknown role
213
+ */
214
+ function rolesAtOrAbove(access) {
215
+ if (access === 'public') return [];
216
+ const hierarchy = getRoleHierarchy(); // most → least privileged
217
+ const idx = hierarchy.indexOf(access);
218
+ // Unknown role name: fail closed with an empty list rather than granting
219
+ // everyone, so a typo in schema.json cannot open export up.
220
+ return idx === -1 ? [] : hierarchy.slice(0, idx + 1);
221
+ }
222
+
177
223
  /**
178
224
  * Build the `data-ctx` attribute that powers the public-site right-click
179
225
  * search/filter menu (public/js/collection-context.js). The payload carries
@@ -182,9 +228,12 @@ function formatDate(input, fmt) {
182
228
  *
183
229
  * @param {Array<{name: string, label?: string}>} fields - displayed fields
184
230
  * @param {object[]} entries - collection entries {id, data}
231
+ * @param {object} [opts]
232
+ * @param {string} [opts.slug] - collection slug (for export requests)
233
+ * @param {object} [opts.export] - resolveExportConfig() result
185
234
  * @returns {string} ` data-ctx="<base64 JSON>"` (leading space included)
186
235
  */
187
- function buildCtxAttr(fields, entries) {
236
+ function buildCtxAttr(fields, entries, opts = {}) {
188
237
  const names = [...new Set(fields.map(f => f?.name).filter(Boolean))];
189
238
  if (!names.length || !entries.length) return '';
190
239
  const payload = {
@@ -194,6 +243,8 @@ function buildCtxAttr(fields, entries) {
194
243
  data: Object.fromEntries(names.map(n => [n, e.data?.[n] ?? '']))
195
244
  }))
196
245
  };
246
+ if (opts.slug) payload.slug = opts.slug;
247
+ if (opts.export) payload.export = opts.export;
197
248
  return ` data-ctx="${Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')}"`;
198
249
  }
199
250
 
@@ -255,7 +306,7 @@ function interpolateBlockTemplate(blockTemplate, e) {
255
306
  * @param {object|null} ctaOpts - Optional CTA button options
256
307
  * @returns {string}
257
308
  */
258
- function renderCollectionBlocks(entries, blockTemplate, emptyMsg, ctaOpts, cols, blockName = '', blockCss = '') {
309
+ function renderCollectionBlocks(entries, blockTemplate, emptyMsg, ctaOpts, cols, blockName = '', blockCss = '', ctxOpts = {}) {
259
310
  if (!entries.length) {
260
311
  return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
261
312
  }
@@ -287,7 +338,7 @@ function renderCollectionBlocks(entries, blockTemplate, emptyMsg, ctaOpts, cols,
287
338
  ? `dm-collection-display dm-collection-blocks grid ${responsiveGridCols(validCols).join(' ')} gap-4`
288
339
  : 'dm-collection-display dm-collection-blocks';
289
340
 
290
- const ctx = buildCtxAttr(blockTemplateFields(blockTemplate), entries);
341
+ const ctx = buildCtxAttr(blockTemplateFields(blockTemplate), entries, ctxOpts);
291
342
  const styleTag = buildBlockStyleTag(blockName, blockCss);
292
343
  return `${styleTag}<div class="${wrapperClass}"${ctx}>\n${items.join('\n')}\n</div>`;
293
344
  }
@@ -456,7 +507,7 @@ function displayValueHtml(entry, field) {
456
507
  return escapeHtmlText(displayValue(entry, field));
457
508
  }
458
509
 
459
- function renderCollectionTable(slug, entries, visibleFields, attrs, ctaOpts) {
510
+ function renderCollectionTable(slug, entries, visibleFields, attrs, ctaOpts, ctxOpts = {}) {
460
511
  const columns = visibleFields.map(f => ({key: f.name, title: f.label || f.name}));
461
512
  const rows = entries.map(e => {
462
513
  const row = {};
@@ -474,14 +525,16 @@ function renderCollectionTable(slug, entries, visibleFields, attrs, ctaOpts) {
474
525
  sortable: attrs.sortable !== 'false',
475
526
  exportable: attrs.exportable === 'true',
476
527
  pageSize: parseInt(attrs['page-size'], 10) || 25,
477
- empty: attrs.empty || 'No entries found'
528
+ empty: attrs.empty || 'No entries found',
529
+ slug: ctxOpts.slug || slug,
530
+ exportCfg: ctxOpts.export || {allowed: false, access: null, formats: []}
478
531
  };
479
532
  if (ctaOpts) tableOpts.ctaConfig = ctaOpts;
480
533
  const payload = Buffer.from(JSON.stringify(tableOpts)).toString('base64');
481
534
  return `<div class="dm-collection-display" data-collection-table data-slug="${escapeAttr(slug)}" data-payload="${payload}"></div>`;
482
535
  }
483
536
 
484
- function renderCollectionCards(entries, visibleFields, titleField, columns, emptyMsg, ctaOpts) {
537
+ function renderCollectionCards(entries, visibleFields, titleField, columns, emptyMsg, ctaOpts, ctxOpts = {}) {
485
538
  const cols = ['2', '3', '4'].includes(String(columns)) ? columns : '3';
486
539
  if (!entries.length) {
487
540
  return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
@@ -506,10 +559,10 @@ function renderCollectionCards(entries, visibleFields, titleField, columns, empt
506
559
  }
507
560
  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>`;
508
561
  }).join('\n');
509
- return `<div class="dm-collection-display grid ${responsiveGridCols(cols).join(' ')} gap-4"${buildCtxAttr(visibleFields, entries)}>\n${cards}\n</div>`;
562
+ return `<div class="dm-collection-display grid ${responsiveGridCols(cols).join(' ')} gap-4"${buildCtxAttr(visibleFields, entries, ctxOpts)}>\n${cards}\n</div>`;
510
563
  }
511
564
 
512
- function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaOpts) {
565
+ function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaOpts, ctxOpts = {}) {
513
566
  if (!entries.length) {
514
567
  return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
515
568
  }
@@ -533,7 +586,7 @@ function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaO
533
586
  }
534
587
  return `<div class="dm-collection-list-item" data-entry-id="${escapeAttr(String(e.id ?? ''))}">${title ? `<strong>${title}</strong>` : ''}${rest}${ctaHtml}</div>`;
535
588
  }).join('\n');
536
- return `<div class="dm-collection-display dm-collection-list"${buildCtxAttr(visibleFields, entries)}>\n${items}\n</div>`;
589
+ return `<div class="dm-collection-display dm-collection-list"${buildCtxAttr(visibleFields, entries, ctxOpts)}>\n${items}\n</div>`;
537
590
  }
538
591
 
539
592
  /**
@@ -545,7 +598,7 @@ function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaO
545
598
  * @param {string} emptyMsg
546
599
  * @returns {string}
547
600
  */
548
- function renderCollectionAccordion(entries, titleField, bodyField, multiple, emptyMsg) {
601
+ function renderCollectionAccordion(entries, titleField, bodyField, multiple, emptyMsg, ctxOpts = {}) {
549
602
  if (!entries.length) {
550
603
  return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
551
604
  }
@@ -564,7 +617,7 @@ function renderCollectionAccordion(entries, titleField, bodyField, multiple, emp
564
617
  );
565
618
  }).join('\n');
566
619
 
567
- const ctx = buildCtxAttr([{name: titleField}, {name: bodyField}], entries);
620
+ const ctx = buildCtxAttr([{name: titleField}, {name: bodyField}], entries, ctxOpts);
568
621
  return `<div class="dm-collection-display accordion"${multiAttr}${ctx}>\n${items}\n</div>`;
569
622
  }
570
623
 
@@ -585,7 +638,7 @@ function renderCollectionAccordion(entries, titleField, bodyField, multiple, emp
585
638
  * @param {string} opts.emptyMsg
586
639
  * @returns {string}
587
640
  */
588
- function renderCollectionTimeline(entries, opts) {
641
+ function renderCollectionTimeline(entries, opts, ctxOpts = {}) {
589
642
  const {titleField, dateField, statusField, iconField, bodyField, layout, theme, mode, emptyMsg} = opts;
590
643
  if (!entries.length) {
591
644
  return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
@@ -604,7 +657,8 @@ function renderCollectionTimeline(entries, opts) {
604
657
 
605
658
  const ctx = buildCtxAttr(
606
659
  [{name: titleField}, {name: dateField}, {name: statusField}, {name: bodyField}].filter(f => f.name),
607
- entries
660
+ entries,
661
+ ctxOpts
608
662
  );
609
663
  return `<div class="dm-collection-display dm-progression" data-layout="${layout}" data-theme="${theme}" data-mode="${mode}"${ctx}>\n${items}\n</div>`;
610
664
  }
@@ -630,7 +684,7 @@ function renderCollectionTimeline(entries, opts) {
630
684
  * @param {string} opts.emptyMsg
631
685
  * @returns {string}
632
686
  */
633
- function renderCollectionCarousel(entries, opts) {
687
+ function renderCollectionCarousel(entries, opts, ctxOpts = {}) {
634
688
  const {imageField, titleField, bodyField, blockTemplate, blockName, blockCss, dataAttrs = {}, emptyMsg} = opts;
635
689
  if (!entries.length) {
636
690
  return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
@@ -664,7 +718,7 @@ function renderCollectionCarousel(entries, opts) {
664
718
  const ctxFields = blockTemplate
665
719
  ? blockTemplateFields(blockTemplate)
666
720
  : [{name: titleField}, {name: bodyField}].filter(f => f.name);
667
- const ctx = buildCtxAttr(ctxFields, entries);
721
+ const ctx = buildCtxAttr(ctxFields, entries, ctxOpts);
668
722
  const styleTag = blockTemplate ? buildBlockStyleTag(blockName, blockCss) : '';
669
723
  return `${styleTag}<div class="dm-collection-display carousel"${passthrough}${ctx}><div class="carousel-track">${slides}</div></div>`;
670
724
  }
@@ -717,7 +771,7 @@ async function loadOptionalBlockAssets(blockName) {
717
771
  }
718
772
  }
719
773
 
720
- function renderCollectionListgroup(entries, opts) {
774
+ function renderCollectionListgroup(entries, opts, ctxOpts = {}) {
721
775
  const {titleField, bodyField, variant, size, itemVariant, action, blockTemplate, blockName, blockCss, emptyMsg} = opts;
722
776
  if (!entries.length) {
723
777
  return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
@@ -757,7 +811,7 @@ function renderCollectionListgroup(entries, opts) {
757
811
  const ctxFields = blockTemplate
758
812
  ? blockTemplateFields(blockTemplate)
759
813
  : [{name: titleField}, {name: bodyField}].filter(f => f.name);
760
- const ctx = buildCtxAttr(ctxFields, entries);
814
+ const ctx = buildCtxAttr(ctxFields, entries, ctxOpts);
761
815
  const styleTag = blockTemplate ? buildBlockStyleTag(blockName, blockCss) : '';
762
816
  return `${styleTag}<div class="${wrapperClasses.join(' ')}"${ctx}>\n${items}\n</div>`;
763
817
  }
@@ -826,6 +880,19 @@ async function processViewBlocks(markdown, tagSet) {
826
880
  // Map MongoDB docs to { id, data } shape. MongoAdapter docs have a top-level id field.
827
881
  const entries = results.map(doc => ({id: doc.id || '', data: doc.data || doc}));
828
882
 
883
+ // Resolve the view's underlying collection schema to compute export
884
+ // permission. Views don't own a schema themselves — [dis]allowing
885
+ // export must fall back CLOSED if the source collection can't be
886
+ // resolved, since an open fallback would be a permission bypass.
887
+ const sourceSlug = viewConfig?.pipeline?.source || '';
888
+ const sourceSchema = sourceSlug ? await getCollection(sourceSlug) : null;
889
+ const ctxOpts = sourceSchema
890
+ ? {
891
+ slug: sourceSlug,
892
+ export: resolveExportConfig(sourceSchema.export, attrs, getRoleHierarchy().at(-1))
893
+ }
894
+ : {slug: sourceSlug || undefined, export: {allowed: false, access: null, formats: []}};
895
+
829
896
  // Derive visible fields from attr filter or first entry's data keys
830
897
  let fields;
831
898
  if (fieldFilter?.length) {
@@ -841,14 +908,14 @@ async function processViewBlocks(markdown, tagSet) {
841
908
  const display = displayAttr || viewConfig?.display?.mode || 'table';
842
909
 
843
910
  if (display === 'cards') {
844
- replacement = renderCollectionCards(entries, fields, titleField, columns, emptyMsg, ctaOpts);
911
+ replacement = renderCollectionCards(entries, fields, titleField, columns, emptyMsg, ctaOpts, ctxOpts);
845
912
  } else if (display === 'list') {
846
- replacement = renderCollectionList(entries, fields, titleField, emptyMsg, ctaOpts);
913
+ replacement = renderCollectionList(entries, fields, titleField, emptyMsg, ctaOpts, ctxOpts);
847
914
  } else if (display === 'accordion') {
848
915
  const accordionTitleField = attrs['title-field'] || 'title';
849
916
  const bodyField = attrs['body-field'] || 'description';
850
917
  const multiple = attrs.multiple === 'true';
851
- replacement = renderCollectionAccordion(entries, accordionTitleField, bodyField, multiple, emptyMsg);
918
+ replacement = renderCollectionAccordion(entries, accordionTitleField, bodyField, multiple, emptyMsg, ctxOpts);
852
919
  } else if (display === 'timeline') {
853
920
  const timelineLayout = ['vertical', 'centred', 'horizontal'].includes(attrs.layout) ? attrs.layout : 'vertical';
854
921
  const timelineTheme = ['minimal', 'corporate', 'modern'].includes(attrs.theme) ? attrs.theme : 'minimal';
@@ -863,7 +930,7 @@ async function processViewBlocks(markdown, tagSet) {
863
930
  theme: timelineTheme,
864
931
  mode: timelineMode,
865
932
  emptyMsg,
866
- });
933
+ }, ctxOpts);
867
934
  } else if (display === 'carousel') {
868
935
  const {tpl, css, missing} = await loadOptionalBlockAssets(attrs.block);
869
936
  replacement = missing
@@ -877,7 +944,7 @@ async function processViewBlocks(markdown, tagSet) {
877
944
  blockCss: css,
878
945
  dataAttrs: attrs,
879
946
  emptyMsg,
880
- });
947
+ }, ctxOpts);
881
948
  } else if (display === 'listgroup') {
882
949
  const {tpl, css, missing} = await loadOptionalBlockAssets(attrs.block);
883
950
  replacement = missing
@@ -893,7 +960,7 @@ async function processViewBlocks(markdown, tagSet) {
893
960
  blockName: attrs.block || '',
894
961
  blockCss: css,
895
962
  emptyMsg,
896
- });
963
+ }, ctxOpts);
897
964
  } else if (display === 'block') {
898
965
  const blockName = attrs.block || viewConfig?.display?.block || '';
899
966
  if (blockName) {
@@ -902,13 +969,13 @@ async function processViewBlocks(markdown, tagSet) {
902
969
  loadBlockTemplate(blockName),
903
970
  loadBlockCss(blockName),
904
971
  ]);
905
- replacement = renderCollectionBlocks(entries, tpl, emptyMsg, ctaOpts, '', blockName, css);
972
+ replacement = renderCollectionBlocks(entries, tpl, emptyMsg, ctaOpts, '', blockName, css, ctxOpts);
906
973
  } catch {
907
974
  replacement = blockNotFoundHtml(blockName);
908
975
  }
909
976
  }
910
977
  } else {
911
- replacement = renderCollectionTable(`view:${slug}`, entries, fields, attrs, ctaOpts);
978
+ replacement = renderCollectionTable(`view:${slug}`, entries, fields, attrs, ctaOpts, ctxOpts);
912
979
  }
913
980
  } catch {
914
981
  // View not found or pipeline error — show empty state
@@ -1306,6 +1373,12 @@ export async function renderCollectionFragment(attrs, { extraFilter = null } = {
1306
1373
  const schema = await getCollection(slug);
1307
1374
  if (!schema) throw new Error('not found');
1308
1375
 
1376
+ const defaultRole = getRoleHierarchy().at(-1);
1377
+ const ctxOpts = {
1378
+ slug: schema.slug,
1379
+ export: resolveExportConfig(schema.export, attrs, defaultRole)
1380
+ };
1381
+
1309
1382
  const filter = buildShortcodeFilter(attrs);
1310
1383
  if (extraFilter) Object.assign(filter, extraFilter);
1311
1384
 
@@ -1328,14 +1401,14 @@ export async function renderCollectionFragment(attrs, { extraFilter = null } = {
1328
1401
  }
1329
1402
 
1330
1403
  if (display === 'cards') {
1331
- replacement = renderCollectionCards(entries, fields, titleField, columns, emptyMsg, ctaOpts);
1404
+ replacement = renderCollectionCards(entries, fields, titleField, columns, emptyMsg, ctaOpts, ctxOpts);
1332
1405
  } else if (display === 'list') {
1333
- replacement = renderCollectionList(entries, fields, titleField, emptyMsg, ctaOpts);
1406
+ replacement = renderCollectionList(entries, fields, titleField, emptyMsg, ctaOpts, ctxOpts);
1334
1407
  } else if (display === 'accordion') {
1335
1408
  const accordionTitleField = attrs['title-field'] || 'title';
1336
1409
  const bodyField = attrs['body-field'] || 'description';
1337
1410
  const multiple = attrs.multiple === 'true';
1338
- replacement = renderCollectionAccordion(entries, accordionTitleField, bodyField, multiple, emptyMsg);
1411
+ replacement = renderCollectionAccordion(entries, accordionTitleField, bodyField, multiple, emptyMsg, ctxOpts);
1339
1412
  } else if (display === 'timeline') {
1340
1413
  const timelineLayout = ['vertical', 'centred', 'horizontal'].includes(attrs.layout) ? attrs.layout : 'vertical';
1341
1414
  const timelineTheme = ['minimal', 'corporate', 'modern'].includes(attrs.theme) ? attrs.theme : 'minimal';
@@ -1350,7 +1423,7 @@ export async function renderCollectionFragment(attrs, { extraFilter = null } = {
1350
1423
  theme: timelineTheme,
1351
1424
  mode: timelineMode,
1352
1425
  emptyMsg,
1353
- });
1426
+ }, ctxOpts);
1354
1427
  } else if (display === 'carousel') {
1355
1428
  const {tpl, css, missing} = await loadOptionalBlockAssets(attrs.block);
1356
1429
  replacement = missing
@@ -1364,7 +1437,7 @@ export async function renderCollectionFragment(attrs, { extraFilter = null } = {
1364
1437
  blockCss: css,
1365
1438
  dataAttrs: attrs,
1366
1439
  emptyMsg,
1367
- });
1440
+ }, ctxOpts);
1368
1441
  } else if (display === 'listgroup') {
1369
1442
  const {tpl, css, missing} = await loadOptionalBlockAssets(attrs.block);
1370
1443
  replacement = missing
@@ -1380,7 +1453,7 @@ export async function renderCollectionFragment(attrs, { extraFilter = null } = {
1380
1453
  blockName: attrs.block || '',
1381
1454
  blockCss: css,
1382
1455
  emptyMsg,
1383
- });
1456
+ }, ctxOpts);
1384
1457
  } else if (display === 'block') {
1385
1458
  const blockName = attrs.block || '';
1386
1459
  if (blockName) {
@@ -1390,13 +1463,13 @@ export async function renderCollectionFragment(attrs, { extraFilter = null } = {
1390
1463
  loadBlockCss(blockName),
1391
1464
  ]);
1392
1465
  const cols = attrs.cols || '';
1393
- replacement = renderCollectionBlocks(entries, tpl, emptyMsg, ctaOpts, cols, blockName, css);
1466
+ replacement = renderCollectionBlocks(entries, tpl, emptyMsg, ctaOpts, cols, blockName, css, ctxOpts);
1394
1467
  } catch {
1395
1468
  replacement = blockNotFoundHtml(blockName);
1396
1469
  }
1397
1470
  }
1398
1471
  } else {
1399
- replacement = renderCollectionTable(slug, entries, fields, attrs, ctaOpts);
1472
+ replacement = renderCollectionTable(slug, entries, fields, attrs, ctaOpts, ctxOpts);
1400
1473
  }
1401
1474
  } catch {
1402
1475
  // Collection not found or read error — show empty message