domma-cms 0.17.0 → 0.21.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 (136) hide show
  1. package/CLAUDE.md +39 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/css/dashboard.css +1 -1
  4. package/admin/index.html +2 -2
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +4 -4
  7. package/admin/js/config/sidebar-config.js +1 -1
  8. package/admin/js/lib/card-builder.js +3 -3
  9. package/admin/js/lib/crud-tutorial.js +1 -0
  10. package/admin/js/lib/effects-builder.js +1 -1
  11. package/admin/js/lib/markdown-toolbar.js +6 -6
  12. package/admin/js/lib/project-context.js +1 -0
  13. package/admin/js/lib/sidebar-renderer.js +4 -0
  14. package/admin/js/templates/action-editor.html +7 -0
  15. package/admin/js/templates/block-editor.html +7 -0
  16. package/admin/js/templates/collection-editor.html +9 -0
  17. package/admin/js/templates/dashboard/cache.html +32 -0
  18. package/admin/js/templates/dashboard.html +4 -0
  19. package/admin/js/templates/form-editor.html +9 -0
  20. package/admin/js/templates/menu-editor.html +98 -0
  21. package/admin/js/templates/menu-locations.html +14 -0
  22. package/admin/js/templates/menus.html +14 -0
  23. package/admin/js/templates/page-editor.html +9 -2
  24. package/admin/js/templates/project-detail.html +50 -0
  25. package/admin/js/templates/project-editor.html +45 -0
  26. package/admin/js/templates/project-settings.html +60 -0
  27. package/admin/js/templates/projects.html +13 -0
  28. package/admin/js/templates/role-editor.html +11 -0
  29. package/admin/js/templates/settings.html +26 -0
  30. package/admin/js/templates/tutorials.html +335 -2
  31. package/admin/js/templates/view-editor.html +7 -0
  32. package/admin/js/views/action-editor.js +1 -1
  33. package/admin/js/views/actions-list.js +1 -1
  34. package/admin/js/views/block-editor-enhance.js +1 -1
  35. package/admin/js/views/block-editor.js +8 -8
  36. package/admin/js/views/blocks.js +2 -2
  37. package/admin/js/views/collection-editor.js +4 -4
  38. package/admin/js/views/collections.js +1 -1
  39. package/admin/js/views/dashboard/widgets/activity-feed.js +1 -1
  40. package/admin/js/views/dashboard/widgets/cache.js +1 -0
  41. package/admin/js/views/dashboard/widgets/journeys.js +1 -1
  42. package/admin/js/views/dashboard/widgets/spike-feed.js +1 -1
  43. package/admin/js/views/dashboard/widgets/top-pages.js +1 -1
  44. package/admin/js/views/dashboard.js +1 -1
  45. package/admin/js/views/form-editor.js +6 -6
  46. package/admin/js/views/forms.js +1 -1
  47. package/admin/js/views/index.js +1 -1
  48. package/admin/js/views/menu-editor.js +19 -0
  49. package/admin/js/views/menu-locations.js +1 -0
  50. package/admin/js/views/menus.js +5 -0
  51. package/admin/js/views/page-editor.js +41 -36
  52. package/admin/js/views/pages.js +3 -3
  53. package/admin/js/views/project-detail.js +4 -0
  54. package/admin/js/views/project-editor.js +1 -0
  55. package/admin/js/views/project-settings.js +1 -0
  56. package/admin/js/views/projects.js +7 -0
  57. package/admin/js/views/role-editor.js +1 -1
  58. package/admin/js/views/roles.js +3 -3
  59. package/admin/js/views/settings.js +3 -3
  60. package/admin/js/views/tutorials.js +1 -1
  61. package/admin/js/views/user-editor.js +1 -1
  62. package/admin/js/views/users.js +3 -3
  63. package/admin/js/views/view-editor.js +1 -1
  64. package/admin/js/views/views-list.js +1 -1
  65. package/config/cache.json +4 -0
  66. package/config/cache.json.example +12 -0
  67. package/config/menu-locations.json +5 -0
  68. package/config/menus/admin-sidebar.json +185 -0
  69. package/config/menus/footer.json +33 -0
  70. package/config/menus/main.json +35 -0
  71. package/config/menus/sproj-1779696558011-menu.json +17 -0
  72. package/config/menus/sproj-1779696960337-menu.json +18 -0
  73. package/config/menus/sproj-1779696985353-menu.json +18 -0
  74. package/config/site.json +6 -22
  75. package/package.json +4 -3
  76. package/plugins/analytics/daily.json +3 -0
  77. package/plugins/analytics/journeys.json +8 -0
  78. package/plugins/analytics/lifetime.json +1 -1
  79. package/public/css/site.css +1 -1
  80. package/public/js/collection-browser.js +4 -0
  81. package/public/js/forms.js +1 -1
  82. package/public/js/site.js +1 -1
  83. package/server/config.js +12 -1
  84. package/server/middleware/auth.js +88 -22
  85. package/server/routes/api/actions.js +58 -5
  86. package/server/routes/api/auth.js +2 -2
  87. package/server/routes/api/blocks.js +18 -3
  88. package/server/routes/api/cache.js +57 -0
  89. package/server/routes/api/collections.js +201 -8
  90. package/server/routes/api/forms.js +266 -21
  91. package/server/routes/api/menu-locations.js +46 -0
  92. package/server/routes/api/menus.js +115 -0
  93. package/server/routes/api/navigation.js +2 -0
  94. package/server/routes/api/pages.js +1 -1
  95. package/server/routes/api/projects.js +107 -0
  96. package/server/routes/api/scaffold.js +86 -0
  97. package/server/routes/api/settings.js +3 -0
  98. package/server/routes/api/sidebar.js +23 -0
  99. package/server/routes/api/users.js +32 -7
  100. package/server/routes/api/views.js +10 -2
  101. package/server/routes/public.js +88 -7
  102. package/server/server.js +54 -3
  103. package/server/services/actions.js +137 -8
  104. package/server/services/adapters/FileAdapter.js +23 -8
  105. package/server/services/adapters/MongoAdapter.js +36 -18
  106. package/server/services/blocks.js +23 -8
  107. package/server/services/cache/drivers/MemoryDriver.js +118 -0
  108. package/server/services/cache/drivers/NoneDriver.js +12 -0
  109. package/server/services/cache/index.js +229 -0
  110. package/server/services/cache/lru.js +61 -0
  111. package/server/services/collections.js +102 -12
  112. package/server/services/content.js +25 -6
  113. package/server/services/filterEngine.js +281 -0
  114. package/server/services/forms.js +3 -0
  115. package/server/services/hooks.js +48 -0
  116. package/server/services/markdown.js +711 -124
  117. package/server/services/menus-migration.js +107 -0
  118. package/server/services/menus.js +422 -0
  119. package/server/services/permissionRegistry.js +26 -0
  120. package/server/services/plugins.js +9 -2
  121. package/server/services/presetCollections.js +22 -0
  122. package/server/services/projects.js +429 -0
  123. package/server/services/recipes/contact-list.json +78 -0
  124. package/server/services/recipes/onboarding.json +426 -0
  125. package/server/services/references.js +174 -0
  126. package/server/services/renderer.js +237 -40
  127. package/server/services/roles.js +6 -1
  128. package/server/services/rowAccess.js +86 -13
  129. package/server/services/scaffolder.js +465 -0
  130. package/server/services/sidebar-migration.js +117 -0
  131. package/server/services/sitemap.js +112 -0
  132. package/server/services/userRoles.js +86 -0
  133. package/server/services/users.js +23 -2
  134. package/server/services/views.js +19 -4
  135. package/server/templates/page.html +135 -130
  136. /package/config/{navigation.json → navigation.json.bak} +0 -0
@@ -11,6 +11,8 @@ import {fileURLToPath} from 'url';
11
11
  import {applyTransforms, getSanitizeExtensions, getShortcodeProcessors} from './hooks.js';
12
12
  import {getConfig} from '../config.js';
13
13
  import {getCollection, listEntries} from './collections.js';
14
+ import {getMenu, resolveLocation} from './menus.js';
15
+ import {checkVisibility} from '../middleware/auth.js';
14
16
 
15
17
  const __dirname_md = path.dirname(fileURLToPath(import.meta.url));
16
18
  const BLOCKS_DIR = path.resolve(__dirname_md, '../../content/blocks');
@@ -22,7 +24,7 @@ const BUILTIN_SHORTCODES = new Set([
22
24
  'text', 'button', 'link', 'cta', 'grid', 'row', 'col', 'card',
23
25
  'banner', 'slideover', 'counter', 'celebrate', 'firework', 'fireworks', 'scribe',
24
26
  'reveal', 'breathe', 'pulse', 'shake', 'scramble', 'ripple', 'twinkle',
25
- 'ticker-tape', 'animate', 'ambient', 'list-group',
27
+ 'ticker-tape', 'animate', 'ambient', 'list-group', 'menu',
26
28
  ]);
27
29
 
28
30
  // Configure marked for safe output
@@ -43,13 +45,49 @@ function escapeHtmlText(str) {
43
45
  .replace(/"/g, '"');
44
46
  }
45
47
 
46
- function sortEntries(entries, sort, order) {
47
- const dir = order === 'asc' ? 1 : -1;
48
- return [...entries].sort((a, b) => {
49
- const av = sort === 'createdAt' ? (a.meta?.createdAt || '') : (a.data?.[sort] ?? '');
50
- const bv = sort === 'createdAt' ? (b.meta?.createdAt || '') : (b.data?.[sort] ?? '');
51
- return av < bv ? -dir : av > bv ? dir : 0;
52
- });
48
+ /**
49
+ * Build a structured filter object from `[collection]` shortcode attributes.
50
+ *
51
+ * Two attribute styles are recognised and merged (with where_* taking precedence):
52
+ *
53
+ * 1. Legacy `where="field1=value1,field2=value2"` equality only, comma-separated.
54
+ * Predates the structured DSL; preserved for backward compat.
55
+ *
56
+ * 2. New `where_<field>[_<op>]="value"` attributes — full operator support.
57
+ * Example: `where_location="London" where_salary_gte="50000"
58
+ * where_tags_in="remote,hybrid"`.
59
+ * Maps directly onto the filterEngine DSL.
60
+ *
61
+ * The combined object is passed to `listEntries({ filter })` so the work is
62
+ * pushed down to the adapter (Mongo gets a native query; File runs in-memory).
63
+ *
64
+ * @param {Record<string, string>} attrs - All parsed shortcode attributes
65
+ * @returns {Record<string, string>} Filter object suitable for filterEngine
66
+ */
67
+ function buildShortcodeFilter(attrs) {
68
+ const filter = {};
69
+
70
+ // Legacy where="a=1,b=2"
71
+ const legacy = typeof attrs.where === 'string' ? attrs.where.trim() : '';
72
+ if (legacy) {
73
+ for (const part of legacy.split(',')) {
74
+ const eq = part.indexOf('=');
75
+ if (eq === -1) continue;
76
+ const key = part.slice(0, eq).trim();
77
+ const val = part.slice(eq + 1).trim();
78
+ if (key) filter[key] = val;
79
+ }
80
+ }
81
+
82
+ // Modern where_<field>[_<op>]="value"
83
+ for (const [k, v] of Object.entries(attrs)) {
84
+ if (!k.startsWith('where_')) continue;
85
+ const inner = k.slice('where_'.length);
86
+ if (!inner) continue;
87
+ filter[inner] = v;
88
+ }
89
+
90
+ return filter;
53
91
  }
54
92
 
55
93
  /**
@@ -247,7 +285,7 @@ async function getBlockShortTagNames() {
247
285
  * @param {string} content
248
286
  * @returns {Promise<string>}
249
287
  */
250
- async function processStaticBlocks(content) {
288
+ async function processStaticBlocks(content, tagSet) {
251
289
  // --- [block template="name" .../] ---
252
290
  const pattern = /\[block\s+([\s\S]+?)\/\]/g;
253
291
  const matches = [...content.matchAll(pattern)];
@@ -262,6 +300,7 @@ async function processStaticBlocks(content) {
262
300
  output = output.slice(0, match.index) + output.slice(match.index + match[0].length);
263
301
  continue;
264
302
  }
303
+ tagSet?.add(`block:${attrs.template}`);
265
304
  let replacement = '';
266
305
  try {
267
306
  const [tpl, css] = await Promise.all([
@@ -287,6 +326,7 @@ async function processStaticBlocks(content) {
287
326
  for (const [, key, dq, sq] of (match[1] ?? '').matchAll(/([\w-]+)=(?:"([^"]*)"|'([^']*)')/g)) {
288
327
  attrs[key] = dq ?? sq ?? '';
289
328
  }
329
+ tagSet?.add(`block:${tagName}`);
290
330
  let replacement = '';
291
331
  try {
292
332
  const [tpl, css] = await Promise.all([
@@ -304,12 +344,79 @@ async function processStaticBlocks(content) {
304
344
  return output;
305
345
  }
306
346
 
347
+ /**
348
+ * Detect a stored file reference — the object shape produced by the form's
349
+ * multipart parser: { url, name, size?, mime? }.
350
+ */
351
+ function isFileRef(v) {
352
+ return v && typeof v === 'object' && typeof v.url === 'string' && typeof v.name === 'string';
353
+ }
354
+
355
+ /**
356
+ * Resolve the display value for one field on one entry.
357
+ *
358
+ * For a `type: reference` field, returns the resolved label from `_refs`
359
+ * (set by `references.resolveReferences()`); arrays join with ", ", missing
360
+ * targets render as "<id> (missing)". For a `type: file` field (or any field
361
+ * holding a stored file ref), returns the filename. Everything else returns
362
+ * the raw value with `null`/`undefined` collapsed to '' and arrays joined.
363
+ *
364
+ * @param {object} entry
365
+ * @param {object} field
366
+ * @returns {string}
367
+ */
368
+ function displayValue(entry, field) {
369
+ if (field.type === 'reference') {
370
+ const r = entry._refs?.[field.name];
371
+ if (r != null) {
372
+ if (Array.isArray(r)) return r.map(x => x.missing ? `${x.id} (missing)` : x.display).join(', ');
373
+ return r.missing ? `${r.id} (missing)` : r.display;
374
+ }
375
+ }
376
+ const raw = entry.data?.[field.name];
377
+ if (isFileRef(raw)) return raw.name;
378
+ if (raw == null) return '';
379
+ if (Array.isArray(raw)) return raw.join(', ');
380
+ return String(raw);
381
+ }
382
+
383
+ /**
384
+ * As `displayValue` but returns an HTML fragment. References with a
385
+ * `linkTemplate` render as `<a>`; file references render as `<img>` (image
386
+ * mimes) or `<a download>` (everything else). The caller is responsible
387
+ * for sanitisation — values pass through `escapeHtmlText`/`escapeAttr`.
388
+ */
389
+ function displayValueHtml(entry, field) {
390
+ if (field.type === 'reference') {
391
+ const r = entry._refs?.[field.name];
392
+ if (r != null) {
393
+ const render = (one) => {
394
+ if (one.missing) return escapeHtmlText(`${one.id} (missing)`);
395
+ const text = escapeHtmlText(one.display);
396
+ return one.link ? `<a href="${escapeAttr(one.link)}">${text}</a>` : text;
397
+ };
398
+ if (Array.isArray(r)) return r.map(render).join(', ');
399
+ return render(r);
400
+ }
401
+ }
402
+ const raw = entry.data?.[field.name];
403
+ if (isFileRef(raw)) {
404
+ const url = escapeAttr(raw.url);
405
+ const name = escapeHtmlText(raw.name);
406
+ if (String(raw.mime || '').startsWith('image/')) {
407
+ return `<a href="${url}" target="_blank" rel="noopener"><img src="${url}" alt="${name}" loading="lazy" class="dm-file-thumb"></a>`;
408
+ }
409
+ return `<a href="${url}" download>${name}</a>`;
410
+ }
411
+ return escapeHtmlText(displayValue(entry, field));
412
+ }
413
+
307
414
  function renderCollectionTable(slug, entries, visibleFields, attrs, ctaOpts) {
308
415
  const columns = visibleFields.map(f => ({key: f.name, title: f.label || f.name}));
309
416
  const rows = entries.map(e => {
310
417
  const row = {};
311
418
  visibleFields.forEach(f => {
312
- row[f.name] = e.data?.[f.name] ?? '';
419
+ row[f.name] = displayValue(e, f);
313
420
  });
314
421
  if (ctaOpts) row._entryId = e.id || '';
315
422
  return row;
@@ -335,11 +442,12 @@ function renderCollectionCards(entries, visibleFields, titleField, columns, empt
335
442
  return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
336
443
  }
337
444
  const cards = entries.map(e => {
338
- const title = titleField ? escapeHtmlText(e.data?.[titleField] ?? '') : '';
445
+ const titleFld = visibleFields.find(f => f.name === titleField);
446
+ const title = titleFld ? displayValueHtml(e, titleFld) : (titleField ? escapeHtmlText(e.data?.[titleField] ?? '') : '');
339
447
  const body = visibleFields
340
448
  .filter(f => f.name !== titleField)
341
449
  .map(f => {
342
- const val = escapeHtmlText(e.data?.[f.name] ?? '');
450
+ const val = displayValueHtml(e, f);
343
451
  return val ? `<p><strong>${escapeHtmlText(f.label || f.name)}:</strong> ${val}</p>` : '';
344
452
  }).join('');
345
453
  let footer = '';
@@ -361,11 +469,12 @@ function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaO
361
469
  return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
362
470
  }
363
471
  const items = entries.map(e => {
364
- const title = titleField ? escapeHtmlText(e.data?.[titleField] ?? '') : '';
472
+ const titleFld = visibleFields.find(f => f.name === titleField);
473
+ const title = titleFld ? displayValueHtml(e, titleFld) : (titleField ? escapeHtmlText(e.data?.[titleField] ?? '') : '');
365
474
  const rest = visibleFields
366
475
  .filter(f => f.name !== titleField)
367
476
  .map(f => {
368
- const val = escapeHtmlText(e.data?.[f.name] ?? '');
477
+ const val = displayValueHtml(e, f);
369
478
  return val ? `<p>${val}</p>` : '';
370
479
  }).join('');
371
480
  let ctaHtml = '';
@@ -458,7 +567,7 @@ function renderCollectionTimeline(entries, opts) {
458
567
  * @param {string} markdown
459
568
  * @returns {Promise<string>}
460
569
  */
461
- async function processViewBlocks(markdown) {
570
+ async function processViewBlocks(markdown, tagSet) {
462
571
  const {scrubbed, restore} = scrubCodeRegions(markdown);
463
572
  const pattern = /\[view([^\]]*?)\/\]/gi;
464
573
  const matches = [...scrubbed.matchAll(pattern)];
@@ -474,6 +583,18 @@ async function processViewBlocks(markdown) {
474
583
  result = result.replace(fullMatch, '');
475
584
  continue;
476
585
  }
586
+ tagSet?.add(`view:${slug}`);
587
+
588
+ // Interactive `[view]` — when searchable / sortable / paginate is set,
589
+ // emit a Collection Browser shell against the view's executed dataset.
590
+ // No filter rail (views own their own filter logic) but search, sort,
591
+ // and pagination work as on `[collection]`. This is how cross-collection
592
+ // browsing happens without the shortcode itself having to learn joins.
593
+ if (isInteractiveCollection(attrs)) {
594
+ const shell = await renderViewBrowserShell(attrs);
595
+ result = result.replace(fullMatch, shell);
596
+ continue;
597
+ }
477
598
 
478
599
  const displayAttr = attrs.display || '';
479
600
  const emptyMsg = attrs.empty || 'No results found';
@@ -579,7 +700,7 @@ async function processViewBlocks(markdown) {
579
700
  * @param {string} markdown
580
701
  * @returns {Promise<string>}
581
702
  */
582
- async function processCollectionBlocks(markdown) {
703
+ async function processCollectionBlocks(markdown, tagSet) {
583
704
  const {scrubbed, restore} = scrubCodeRegions(markdown);
584
705
  // Find all [collection ...] shortcodes
585
706
  const pattern = /\[collection([^\]]*?)\/\]/gi;
@@ -596,115 +717,435 @@ async function processCollectionBlocks(markdown) {
596
717
  result = result.replace(fullMatch, '');
597
718
  continue;
598
719
  }
720
+ tagSet?.add(`collection:${slug}`);
721
+
722
+ // scope="mine" can't share the per-role page cache (data is per-user),
723
+ // so emit a hydration placeholder. The public site JS POSTs the raw
724
+ // attrs to /api/collections/render-scope, which re-runs renderCollectionFragment
725
+ // server-side with `createdBy = <current user>` injected — keeping rendering
726
+ // logic in ONE place (no client-side template duplication).
727
+ if (attrs.scope === 'mine') {
728
+ const encoded = Buffer.from(JSON.stringify(attrs), 'utf8').toString('base64');
729
+ const wrapperCls = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
730
+ const wrapperId = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
731
+ result = result.replace(fullMatch,
732
+ `<div class="dm-collection-hydrate${wrapperCls}"${wrapperId} ` +
733
+ `data-collection-scope="mine" data-collection-attrs="${encoded}">` +
734
+ `<div class="dm-collection-loading"><p>${escapeHtmlText('Loading…')}</p></div>` +
735
+ `</div>`
736
+ );
737
+ continue;
738
+ }
599
739
 
600
- const display = attrs.display || 'table';
601
- const limitAttr = parseInt(attrs.limit, 10) || 0;
602
- const sort = attrs.sort || 'createdAt';
603
- const order = attrs.order || 'desc';
604
- const titleField = attrs['title-field'] || '';
605
- const columns = attrs.columns || '3';
606
- const emptyMsg = attrs.empty || 'No entries found';
607
- const fieldFilter = attrs.fields ? attrs.fields.split(',').map(f => f.trim()).filter(Boolean) : null;
608
- const ctaAction = attrs.cta || '';
609
- const ctaOpts = ctaAction ? {
610
- action: ctaAction,
740
+ // Interactive Collection Browser — searchable / filterable / sortable
741
+ // turns the block into a full client-side browser with auto-derived
742
+ // filter UI, sort dropdown, search box, and pagination. We still emit
743
+ // a server-rendered fragment as the no-JS fallback (visible until the
744
+ // browser hydrates and replaces it).
745
+ if (isInteractiveCollection(attrs)) {
746
+ const shell = await renderCollectionBrowserShell(attrs, schemaForFallback => schemaForFallback);
747
+ result = result.replace(fullMatch, shell);
748
+ continue;
749
+ }
750
+
751
+ const replacement = await renderCollectionFragment(attrs);
752
+ result = result.replace(fullMatch, replacement);
753
+ }
754
+
755
+ return restore(result);
756
+ }
757
+
758
+ /**
759
+ * Detect whether a `[collection]` shortcode opts into the interactive Browser.
760
+ * Any of `searchable`, `sortable`, `filterable`, or `paginate` flips it on.
761
+ *
762
+ * @param {Record<string, unknown>} attrs
763
+ * @returns {boolean}
764
+ */
765
+ function isInteractiveCollection(attrs) {
766
+ return isTruthyAttr(attrs.searchable)
767
+ || isTruthyAttr(attrs.sortable)
768
+ || !!attrs.filterable
769
+ || isTruthyAttr(attrs.paginate);
770
+ }
771
+
772
+ /**
773
+ * Coerce a shortcode attribute value to a boolean. Bare flags parse as `true`,
774
+ * `"true"` / `"1"` are truthy, everything else is false.
775
+ *
776
+ * @param {unknown} v
777
+ * @returns {boolean}
778
+ */
779
+ function isTruthyAttr(v) {
780
+ return v === true || v === 'true' || v === '1';
781
+ }
782
+
783
+ /**
784
+ * Build the hydration shell for the Collection Browser component.
785
+ *
786
+ * The shell ships three things to the client:
787
+ * 1. **config** — display mode, columns, page size, which fields are
788
+ * sortable/filterable, mode (client|server), labels.
789
+ * 2. **schema** — the collection's field definitions, used to auto-derive
790
+ * the right filter control per field type (text → input,
791
+ * select → dropdown, number → range, date → from/to, etc.).
792
+ * 3. **data** — for mode=client, ALL entries (capped at `maxClientEntries`)
793
+ * so filtering is instant. For mode=server, only the first
794
+ * page is shipped and subsequent pages are fetched via the
795
+ * existing `/api/collections/:slug/public` endpoint with
796
+ * `filter[…]`, `sort`, `order`, `page`, `limit` params.
797
+ *
798
+ * A static server-rendered fragment is also embedded as the no-JS fallback —
799
+ * visible until `collection-browser.js` hydrates and replaces the shell.
800
+ *
801
+ * @param {Record<string, string>} attrs - Parsed shortcode attributes
802
+ * @returns {Promise<string>} HTML for the hydration shell
803
+ */
804
+ async function renderCollectionBrowserShell(attrs) {
805
+ const slug = attrs.slug;
806
+ const pageSize = parseInt(attrs['page-size'], 10) || 12;
807
+ const declaredMode = attrs.mode === 'server' ? 'server' : 'client';
808
+ const maxClientEntries = parseInt(attrs['max-client-entries'], 10) || 1000;
809
+
810
+ // Parse comma lists once.
811
+ const filterable = (attrs.filterable && typeof attrs.filterable === 'string')
812
+ ? attrs.filterable.split(',').map(s => s.trim()).filter(Boolean) : [];
813
+ const sortableFields = (typeof attrs.sortable === 'string' && attrs.sortable !== 'true' && attrs.sortable !== 'false')
814
+ ? attrs.sortable.split(',').map(s => s.trim()).filter(Boolean) : [];
815
+
816
+ let schema = null;
817
+ let entries = [];
818
+ let total = 0;
819
+ let mode = declaredMode;
820
+
821
+ try {
822
+ schema = await getCollection(slug);
823
+ if (!schema) throw new Error('not found');
824
+
825
+ // Build the seed listEntries() opts — push any author-baked where_* filter
826
+ // and the initial sort/order down to the adapter, just like the static path.
827
+ const seedFilter = buildShortcodeFilter(attrs);
828
+ const seedOpts = {
829
+ sort: attrs.sort || 'createdAt',
830
+ order: attrs.order || 'desc'
831
+ };
832
+ if (Object.keys(seedFilter).length) seedOpts.filter = seedFilter;
833
+
834
+ seedOpts.resolveRefs = true; // ship resolved labels in the inline payload
835
+
836
+ if (mode === 'client') {
837
+ // Cap the inline payload to avoid bloating cached HTML for huge collections.
838
+ // If we hit the cap, transparently downgrade to mode=server so pagination
839
+ // still works — the author gets correct UX even on misconfigured pages.
840
+ seedOpts.limit = maxClientEntries + 1;
841
+ const r = await listEntries(slug, seedOpts);
842
+ if (r.total > maxClientEntries) {
843
+ mode = 'server';
844
+ seedOpts.limit = pageSize;
845
+ seedOpts.page = 1;
846
+ const r2 = await listEntries(slug, seedOpts);
847
+ entries = r2.entries;
848
+ total = r2.total;
849
+ } else {
850
+ entries = r.entries;
851
+ total = r.total;
852
+ }
853
+ } else {
854
+ // mode=server — ship only the first page; subsequent pages fetched live.
855
+ seedOpts.limit = pageSize;
856
+ seedOpts.page = 1;
857
+ const r = await listEntries(slug, seedOpts);
858
+ entries = r.entries;
859
+ total = r.total;
860
+ }
861
+ } catch {
862
+ // Slug unknown / read error — emit an empty shell so the page still renders.
863
+ schema = {fields: [], title: attrs.slug};
864
+ entries = [];
865
+ total = 0;
866
+ }
867
+
868
+ const config = {
869
+ slug,
870
+ id: attrs.id || `cb-${slug}-${Math.random().toString(36).slice(2, 7)}`,
871
+ display: attrs.display || 'cards',
872
+ columns: parseInt(attrs.columns, 10) || 3,
873
+ titleField: attrs['title-field'] || '',
874
+ bodyField: attrs['body-field'] || '',
875
+ dateField: attrs['date-field'] || '',
876
+ statusField: attrs['status-field'] || '',
877
+ iconField: attrs['icon-field'] || '',
878
+ layout: attrs.layout || '',
879
+ theme: attrs.theme || '',
880
+ timelineMode: attrs.mode === 'roadmap' ? 'roadmap' : 'timeline',
881
+ block: attrs.block || '',
882
+ fields: attrs.fields ? attrs.fields.split(',').map(s => s.trim()).filter(Boolean) : null,
883
+ pageSize,
884
+ mode,
885
+ pagination: attrs.pagination === 'scroll' ? 'scroll' : 'pages', // 'pages' = prev/next, 'scroll' = infinite
886
+ searchable: isTruthyAttr(attrs.searchable),
887
+ sortable: isTruthyAttr(attrs.sortable) || sortableFields.length > 0,
888
+ sortableFields, // empty array = all fields sortable
889
+ filterable, // [] = no filter rail
890
+ exportable: isTruthyAttr(attrs.exportable), // adds "Export CSV" button
891
+ savedSearches: !isTruthyAttr(attrs['no-saved-searches']), // opt-out flag (defaults on)
892
+ transitions: isTruthyAttr(attrs.transitions), // render per-row transition buttons
893
+ emptyMsg: attrs.empty || 'No entries found',
894
+ cta: attrs.cta ? {
895
+ action: attrs.cta,
611
896
  label: attrs['cta-label'] || 'Run',
612
897
  icon: attrs['cta-icon'] || '',
613
898
  style: attrs['cta-style'] || 'primary',
614
899
  confirm: attrs['cta-confirm'] || ''
615
- } : null;
900
+ } : null,
901
+ baked: buildShortcodeFilter(attrs), // author's where_* filter — locked in, not user-tweakable
902
+ initial: { sort: attrs.sort || 'createdAt', order: attrs.order || 'desc' },
903
+ syncUrl: !isTruthyAttr(attrs['no-url-sync']) // default ON
904
+ };
616
905
 
617
- let replacement = `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
906
+ // No-JS fallback render the same first-page state with the static renderer.
907
+ const fallbackAttrs = {...attrs, limit: String(pageSize)};
908
+ const fallback = await renderCollectionFragment(fallbackAttrs);
618
909
 
619
- try {
620
- const schema = await getCollection(slug);
621
- if (!schema) throw new Error('not found');
622
-
623
- let {entries} = await listEntries(slug);
624
-
625
- // Row-level filter: where="field=value" (simple equality only).
626
- // Comma-separate multiple predicates, all AND'd together.
627
- // Example: where="tab=developers" or where="tab=developers,status=live"
628
- const whereAttr = typeof attrs.where === 'string' ? attrs.where.trim() : '';
629
- if (whereAttr) {
630
- const predicates = whereAttr.split(',').map(p => p.trim()).filter(Boolean).map(p => {
631
- const eq = p.indexOf('=');
632
- if (eq === -1) return null;
633
- return { key: p.slice(0, eq).trim(), val: p.slice(eq + 1).trim() };
634
- }).filter(Boolean);
635
- if (predicates.length) {
636
- entries = entries.filter(e => predicates.every(({key, val}) => String(e.data?.[key] ?? '') === val));
637
- }
638
- }
910
+ // Encode payloads. Schema can be modest; data can be large in client mode,
911
+ // so we base64 once each and let the browser decode.
912
+ const enc = (obj) => Buffer.from(JSON.stringify(obj), 'utf8').toString('base64');
639
913
 
640
- entries = sortEntries(entries, sort, order);
641
- if (limitAttr > 0) entries = entries.slice(0, limitAttr);
914
+ const wrapperCls = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
915
+ const wrapperId = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : ` id="${escapeAttr(config.id)}"`;
642
916
 
643
- // Determine visible fields from schema (optionally filtered)
644
- let fields = schema.fields || [];
645
- if (fieldFilter?.length) {
646
- fields = fieldFilter.map(name => fields.find(f => f.name === name) || { name, label: name });
647
- }
648
- if (!fields.length && entries.length) {
649
- // No schema fields — derive from first entry's data keys
650
- fields = Object.keys(entries[0].data || {}).map(k => ({ name: k, label: k }));
651
- }
917
+ return `<div class="dm-collection-browser${wrapperCls}"${wrapperId} ` +
918
+ `data-cb-config="${enc(config)}" ` +
919
+ `data-cb-schema="${enc({fields: schema?.fields || [], title: schema?.title || ''})}" ` +
920
+ `data-cb-data="${enc({entries, total, page: 1, pageSize})}">` +
921
+ `<div class="dm-cb-fallback">${fallback}</div>` +
922
+ `</div>`;
923
+ }
652
924
 
653
- if (display === 'cards') {
654
- replacement = renderCollectionCards(entries, fields, titleField, columns, emptyMsg, ctaOpts);
655
- } else if (display === 'list') {
656
- replacement = renderCollectionList(entries, fields, titleField, emptyMsg, ctaOpts);
657
- } else if (display === 'accordion') {
658
- const accordionTitleField = attrs['title-field'] || 'title';
659
- const bodyField = attrs['body-field'] || 'description';
660
- const multiple = attrs.multiple === 'true';
661
- replacement = renderCollectionAccordion(entries, accordionTitleField, bodyField, multiple, emptyMsg);
662
- } else if (display === 'timeline') {
663
- const timelineLayout = ['vertical', 'centred', 'horizontal'].includes(attrs.layout) ? attrs.layout : 'vertical';
664
- const timelineTheme = ['minimal', 'corporate', 'modern'].includes(attrs.theme) ? attrs.theme : 'minimal';
665
- const timelineMode = ['timeline', 'roadmap'].includes(attrs.mode) ? attrs.mode : 'timeline';
666
- replacement = renderCollectionTimeline(entries, {
667
- titleField: attrs['title-field'] || 'title',
668
- dateField: attrs['date-field'] || '',
669
- statusField: attrs['status-field'] || '',
670
- iconField: attrs['icon-field'] || '',
671
- bodyField: attrs['body-field'] || '',
672
- layout: timelineLayout,
673
- theme: timelineTheme,
674
- mode: timelineMode,
675
- emptyMsg,
676
- });
677
- } else if (display === 'block') {
678
- const blockName = attrs.block || '';
679
- if (blockName) {
680
- try {
681
- const [tpl, css] = await Promise.all([
682
- loadBlockTemplate(blockName),
683
- loadBlockCss(blockName),
684
- ]);
685
- const cols = attrs.cols || '';
686
- replacement = renderCollectionBlocks(entries, tpl, emptyMsg, ctaOpts, cols, blockName, css);
687
- } catch {
688
- replacement = `<div class="dm-collection-display dm-collection-empty"><p>Block template &ldquo;${escapeHtmlText(blockName)}&rdquo; not found.</p></div>`;
689
- }
690
- }
691
- } else {
692
- replacement = renderCollectionTable(slug, entries, fields, attrs, ctaOpts);
693
- }
694
- } catch {
695
- // Collection not found or read error — show empty message
925
+ /**
926
+ * Interactive `[view]` hydration shell. Saved views compose collections,
927
+ * joins, and pre-filters that aren't expressible in plain `where_*` syntax —
928
+ * so the browser treats a view as a read-only data source: search, sort,
929
+ * and pagination work; the filter rail is off by default (the view's own
930
+ * filter logic is the authoritative source). Authors can still expose user
931
+ * filters by setting `filterable="…"`, but the field names must exist on the
932
+ * view's projected document shape.
933
+ *
934
+ * @param {Record<string, string>} attrs
935
+ * @returns {Promise<string>}
936
+ */
937
+ async function renderViewBrowserShell(attrs) {
938
+ const slug = attrs.slug;
939
+ const pageSize = parseInt(attrs['page-size'], 10) || parseInt(attrs.limit, 10) || 12;
940
+ const declaredMode = attrs.mode === 'server' ? 'server' : 'client';
941
+
942
+ let entries = [];
943
+ let total = 0;
944
+ let viewConfig = null;
945
+
946
+ try {
947
+ const {executeView, getView} = await import('./views.js');
948
+ viewConfig = await getView(slug).catch(() => null);
949
+
950
+ if (declaredMode === 'client') {
951
+ // For views we cap aggressively — views can be expensive aggregations.
952
+ const r = await executeView(slug, {page: 1, limit: 500});
953
+ entries = (r.results || []).map(doc => ({id: doc.id || '', data: doc.data || doc}));
954
+ total = r.total ?? entries.length;
955
+ } else {
956
+ const r = await executeView(slug, {page: 1, limit: pageSize});
957
+ entries = (r.results || []).map(doc => ({id: doc.id || '', data: doc.data || doc}));
958
+ total = r.total ?? entries.length;
696
959
  }
960
+ } catch {
961
+ // executeView errored (no Pro / view missing) — emit empty shell.
962
+ }
697
963
 
698
- if (attrs.class || attrs.id) {
699
- const cls = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
700
- const id = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
701
- replacement = `<div class="dm-collection-wrapper${cls}"${id}>${replacement}</div>`;
964
+ // Derive a schema-like fields list from the first row so the browser can
965
+ // wire up sort dropdown labels even when the view has no explicit schema.
966
+ const schemaFields = entries.length
967
+ ? Object.keys(entries[0].data || {})
968
+ .filter(k => !['_id', 'id', 'meta'].includes(k))
969
+ .map(k => ({name: k, label: k, type: 'text'}))
970
+ : (viewConfig?.fields || []);
971
+
972
+ const filterable = (attrs.filterable && typeof attrs.filterable === 'string')
973
+ ? attrs.filterable.split(',').map(s => s.trim()).filter(Boolean) : [];
974
+ const sortableFields = (typeof attrs.sortable === 'string' && attrs.sortable !== 'true' && attrs.sortable !== 'false')
975
+ ? attrs.sortable.split(',').map(s => s.trim()).filter(Boolean) : [];
976
+
977
+ const config = {
978
+ slug,
979
+ id: attrs.id || `cb-view-${slug}-${Math.random().toString(36).slice(2, 7)}`,
980
+ display: attrs.display || viewConfig?.display?.mode || 'cards',
981
+ columns: parseInt(attrs.columns, 10) || 3,
982
+ titleField: attrs['title-field'] || viewConfig?.display?.titleField || '',
983
+ bodyField: attrs['body-field'] || '',
984
+ dateField: attrs['date-field'] || '',
985
+ statusField: attrs['status-field'] || '',
986
+ iconField: attrs['icon-field'] || '',
987
+ layout: attrs.layout || '',
988
+ theme: attrs.theme || '',
989
+ timelineMode: attrs.mode === 'roadmap' ? 'roadmap' : 'timeline',
990
+ block: attrs.block || '',
991
+ fields: attrs.fields ? attrs.fields.split(',').map(s => s.trim()).filter(Boolean) : null,
992
+ pageSize,
993
+ mode: declaredMode,
994
+ pagination: attrs.pagination === 'scroll' ? 'scroll' : 'pages',
995
+ searchable: isTruthyAttr(attrs.searchable),
996
+ sortable: isTruthyAttr(attrs.sortable) || sortableFields.length > 0,
997
+ sortableFields,
998
+ filterable,
999
+ exportable: isTruthyAttr(attrs.exportable),
1000
+ savedSearches: !isTruthyAttr(attrs['no-saved-searches']),
1001
+ emptyMsg: attrs.empty || 'No results found',
1002
+ cta: attrs.action ? {
1003
+ action: attrs.action,
1004
+ label: attrs['cta-label'] || 'Run',
1005
+ icon: attrs['cta-icon'] || '',
1006
+ style: attrs['cta-style'] || 'primary',
1007
+ confirm: attrs['cta-confirm'] || ''
1008
+ } : null,
1009
+ baked: {}, // views don't accept baked where_*; the view itself is the filter
1010
+ initial: { sort: attrs.sort || 'createdAt', order: attrs.order || 'desc' },
1011
+ syncUrl: !isTruthyAttr(attrs['no-url-sync']),
1012
+ sourceKind: 'view' // hint for client-side: server-mode would need a different fetch endpoint
1013
+ };
1014
+
1015
+ const enc = (obj) => Buffer.from(JSON.stringify(obj), 'utf8').toString('base64');
1016
+ const wrapperCls = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
1017
+ const wrapperId = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : ` id="${escapeAttr(config.id)}"`;
1018
+
1019
+ return `<div class="dm-collection-browser dm-cb-source-view${wrapperCls}"${wrapperId} ` +
1020
+ `data-cb-config="${enc(config)}" ` +
1021
+ `data-cb-schema="${enc({fields: schemaFields, title: viewConfig?.title || slug})}" ` +
1022
+ `data-cb-data="${enc({entries, total, page: 1, pageSize})}">` +
1023
+ `<div class="dm-cb-fallback"></div>` +
1024
+ `</div>`;
1025
+ }
1026
+
1027
+ /**
1028
+ * Render a `[collection]` block's HTML fragment from parsed attributes.
1029
+ *
1030
+ * Used by:
1031
+ * - The Markdown shortcode pipeline (server-side render at page build time)
1032
+ * - The collection hydration endpoint (client-side render when scope="mine"
1033
+ * forces per-user data that can't share the page cache)
1034
+ *
1035
+ * `extraFilter` is merged on top of any filter derived from `where_*` attrs —
1036
+ * this is how the hydration endpoint injects `createdBy = <current user>`
1037
+ * for scope=mine without trusting the client to set it.
1038
+ *
1039
+ * Returns a complete HTML fragment including the optional outer wrapper for
1040
+ * `class=` / `id=` attributes. Always succeeds — render errors fall back to
1041
+ * the empty-state HTML rather than throwing, so a typo in a single shortcode
1042
+ * can't break a whole page render.
1043
+ *
1044
+ * @param {Record<string, string>} attrs - Parsed shortcode attributes
1045
+ * @param {object} [opts]
1046
+ * @param {Record<string, unknown>} [opts.extraFilter] - Additional filter conditions to AND in
1047
+ * @returns {Promise<string>} HTML fragment ready to inject into a page
1048
+ */
1049
+ export async function renderCollectionFragment(attrs, { extraFilter = null } = {}) {
1050
+ const slug = attrs.slug || '';
1051
+ if (!slug) return '';
1052
+
1053
+ const display = attrs.display || 'table';
1054
+ const limitAttr = parseInt(attrs.limit, 10) || 0;
1055
+ const sort = attrs.sort || 'createdAt';
1056
+ const order = attrs.order || 'desc';
1057
+ const titleField = attrs['title-field'] || '';
1058
+ const columns = attrs.columns || '3';
1059
+ const emptyMsg = attrs.empty || 'No entries found';
1060
+ const fieldFilter = attrs.fields ? attrs.fields.split(',').map(f => f.trim()).filter(Boolean) : null;
1061
+ const ctaAction = attrs.cta || '';
1062
+ const ctaOpts = ctaAction ? {
1063
+ action: ctaAction,
1064
+ label: attrs['cta-label'] || 'Run',
1065
+ icon: attrs['cta-icon'] || '',
1066
+ style: attrs['cta-style'] || 'primary',
1067
+ confirm: attrs['cta-confirm'] || ''
1068
+ } : null;
1069
+
1070
+ let replacement = `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
1071
+
1072
+ try {
1073
+ const schema = await getCollection(slug);
1074
+ if (!schema) throw new Error('not found');
1075
+
1076
+ const filter = buildShortcodeFilter(attrs);
1077
+ if (extraFilter) Object.assign(filter, extraFilter);
1078
+
1079
+ const listOpts = {
1080
+ sort,
1081
+ order,
1082
+ limit: limitAttr > 0 ? limitAttr : 0,
1083
+ resolveRefs: true // decorate entries with display labels for reference fields
1084
+ };
1085
+ if (Object.keys(filter).length) listOpts.filter = filter;
1086
+
1087
+ const {entries} = await listEntries(slug, listOpts);
1088
+
1089
+ let fields = schema.fields || [];
1090
+ if (fieldFilter?.length) {
1091
+ fields = fieldFilter.map(name => fields.find(f => f.name === name) || { name, label: name });
1092
+ }
1093
+ if (!fields.length && entries.length) {
1094
+ fields = Object.keys(entries[0].data || {}).map(k => ({ name: k, label: k }));
702
1095
  }
703
1096
 
704
- result = result.replace(fullMatch, replacement);
1097
+ if (display === 'cards') {
1098
+ replacement = renderCollectionCards(entries, fields, titleField, columns, emptyMsg, ctaOpts);
1099
+ } else if (display === 'list') {
1100
+ replacement = renderCollectionList(entries, fields, titleField, emptyMsg, ctaOpts);
1101
+ } else if (display === 'accordion') {
1102
+ const accordionTitleField = attrs['title-field'] || 'title';
1103
+ const bodyField = attrs['body-field'] || 'description';
1104
+ const multiple = attrs.multiple === 'true';
1105
+ replacement = renderCollectionAccordion(entries, accordionTitleField, bodyField, multiple, emptyMsg);
1106
+ } else if (display === 'timeline') {
1107
+ const timelineLayout = ['vertical', 'centred', 'horizontal'].includes(attrs.layout) ? attrs.layout : 'vertical';
1108
+ const timelineTheme = ['minimal', 'corporate', 'modern'].includes(attrs.theme) ? attrs.theme : 'minimal';
1109
+ const timelineMode = ['timeline', 'roadmap'].includes(attrs.mode) ? attrs.mode : 'timeline';
1110
+ replacement = renderCollectionTimeline(entries, {
1111
+ titleField: attrs['title-field'] || 'title',
1112
+ dateField: attrs['date-field'] || '',
1113
+ statusField: attrs['status-field'] || '',
1114
+ iconField: attrs['icon-field'] || '',
1115
+ bodyField: attrs['body-field'] || '',
1116
+ layout: timelineLayout,
1117
+ theme: timelineTheme,
1118
+ mode: timelineMode,
1119
+ emptyMsg,
1120
+ });
1121
+ } else if (display === 'block') {
1122
+ const blockName = attrs.block || '';
1123
+ if (blockName) {
1124
+ try {
1125
+ const [tpl, css] = await Promise.all([
1126
+ loadBlockTemplate(blockName),
1127
+ loadBlockCss(blockName),
1128
+ ]);
1129
+ const cols = attrs.cols || '';
1130
+ replacement = renderCollectionBlocks(entries, tpl, emptyMsg, ctaOpts, cols, blockName, css);
1131
+ } catch {
1132
+ replacement = `<div class="dm-collection-display dm-collection-empty"><p>Block template &ldquo;${escapeHtmlText(blockName)}&rdquo; not found.</p></div>`;
1133
+ }
1134
+ }
1135
+ } else {
1136
+ replacement = renderCollectionTable(slug, entries, fields, attrs, ctaOpts);
1137
+ }
1138
+ } catch {
1139
+ // Collection not found or read error — show empty message
705
1140
  }
706
1141
 
707
- return restore(result);
1142
+ if (attrs.class || attrs.id) {
1143
+ const cls = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
1144
+ const id = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
1145
+ replacement = `<div class="dm-collection-wrapper${cls}"${id}>${replacement}</div>`;
1146
+ }
1147
+
1148
+ return replacement;
708
1149
  }
709
1150
 
710
1151
  /**
@@ -1947,11 +2388,13 @@ function processAccordionBlocks(markdown) {
1947
2388
  * [/carousel]
1948
2389
  *
1949
2390
  * Supported attributes on [carousel]:
1950
- * autoplay - "true" to auto-advance slides
1951
- * interval - milliseconds between slides (default 5000)
1952
- * loop - "false" to disable loop (default true)
1953
- * animation - "fade" or "slide" (default slide)
1954
- * id - optional id on the wrapper
2391
+ * autoplay - "true" to auto-advance slides
2392
+ * interval - milliseconds between slides (default 5000)
2393
+ * loop - "false" to disable loop (default true)
2394
+ * animation - "slide" | "fade" | "crossfade" (default slide)
2395
+ * animation-duration - transition length in milliseconds (default 500)
2396
+ * animation-easing - CSS timing function (e.g. ease, linear, ease-in-out, cubic-bezier(...))
2397
+ * id - optional id on the wrapper
1955
2398
  *
1956
2399
  * Supported attributes on [slide]:
1957
2400
  * image - URL of a background/header image
@@ -1970,7 +2413,9 @@ function processCarouselBlocks(markdown) {
1970
2413
  attrs.autoplay ? ` data-autoplay="${escapeAttr(attrs.autoplay)}"` : '',
1971
2414
  attrs.interval ? ` data-interval="${escapeAttr(attrs.interval)}"` : '',
1972
2415
  attrs.loop ? ` data-loop="${escapeAttr(attrs.loop)}"` : '',
1973
- attrs.animation ? ` data-animation="${escapeAttr(attrs.animation)}"` : ''
2416
+ attrs.animation ? ` data-animation="${escapeAttr(attrs.animation)}"` : '',
2417
+ attrs['animation-duration'] ? ` data-animation-duration="${escapeAttr(attrs['animation-duration'])}"` : '',
2418
+ attrs['animation-easing'] ? ` data-animation-easing="${escapeAttr(attrs['animation-easing'])}"` : ''
1974
2419
  ].join('');
1975
2420
  const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
1976
2421
 
@@ -2617,7 +3062,69 @@ function processIconBlocks(markdown) {
2617
3062
  */
2618
3063
  const FORMS_DIR = path.resolve(__dirname_md, '../../content/forms');
2619
3064
 
2620
- async function processFormBlocks(markdown) {
3065
+ /**
3066
+ * Transform `type: 'reference'` fields on a form definition into select-style
3067
+ * fields populated from the target collection. Mutates the form in place.
3068
+ *
3069
+ * Strategy: lazy server-side expansion at render time — the embedded form
3070
+ * doesn't know about references, it just sees a select with options. This
3071
+ * keeps the client-side form engine (`F.create`) ignorant of the schema
3072
+ * vocabulary and works without any extra round-trips at form render time.
3073
+ *
3074
+ * Limits: ships the first 200 entries per referenced collection. Beyond that,
3075
+ * a future enhancement can swap in an async-search picker; logged here as a
3076
+ * known cap so authors with huge target collections know what to do.
3077
+ *
3078
+ * @param {object} form - Mutated in place: reference fields → select fields
3079
+ * @returns {Promise<void>}
3080
+ */
3081
+ async function expandReferenceFields(form) {
3082
+ const fields = form?.fields;
3083
+ if (!Array.isArray(fields) || !fields.length) return;
3084
+
3085
+ const REF_LIMIT = 200;
3086
+
3087
+ for (let i = 0; i < fields.length; i++) {
3088
+ const f = fields[i];
3089
+ if (f.type !== 'reference') continue;
3090
+ const ref = f.reference || {};
3091
+ if (!ref.collection) continue;
3092
+
3093
+ const targetSlug = ref.collection;
3094
+ const displayField = ref.displayField || 'title';
3095
+
3096
+ try {
3097
+ const r = await listEntries(targetSlug, {limit: REF_LIMIT, sort: displayField, order: 'asc'});
3098
+ const options = (r.entries || []).map(e => ({
3099
+ value: e.id,
3100
+ label: (e.data?.[displayField] != null && e.data[displayField] !== '')
3101
+ ? String(e.data[displayField])
3102
+ : e.id
3103
+ }));
3104
+
3105
+ // Replace with a select; preserve label/required/helper from the original.
3106
+ fields[i] = {
3107
+ ...f,
3108
+ type: f.multiple ? 'multiselect' : 'select',
3109
+ options,
3110
+ // Keep the original reference metadata around so future code can
3111
+ // recognise that this select was reference-derived (e.g. for an
3112
+ // upgrade to an async picker without changing the form file).
3113
+ _ref: {collection: targetSlug, displayField, originalType: 'reference'}
3114
+ };
3115
+ } catch {
3116
+ // Target collection missing — render a disabled input with an explanatory placeholder.
3117
+ fields[i] = {
3118
+ ...f,
3119
+ type: 'text',
3120
+ placeholder: `(reference target "${targetSlug}" unavailable)`,
3121
+ _ref: {collection: targetSlug, displayField, originalType: 'reference', error: true}
3122
+ };
3123
+ }
3124
+ }
3125
+ }
3126
+
3127
+ async function processFormBlocks(markdown, tagSet) {
2621
3128
  const {scrubbed, restore} = scrubCodeRegions(markdown);
2622
3129
  const regex = /\[form([^\]]*?)\/\]/gi;
2623
3130
  let result = scrubbed;
@@ -2638,12 +3145,14 @@ async function processFormBlocks(markdown) {
2638
3145
  result = result.slice(0, index) + `<div class="cms-form-error">Invalid form slug: ${escapeAttr(slug)}</div>` + result.slice(index + full.length);
2639
3146
  continue;
2640
3147
  }
3148
+ tagSet?.add(`form:${slug}`);
2641
3149
  let replacement;
2642
3150
  try {
2643
3151
  const filePath = path.resolve(FORMS_DIR, `${slug}.json`);
2644
3152
  if (!filePath.startsWith(FORMS_DIR + path.sep)) throw new Error('Invalid slug');
2645
3153
  const raw = await readFile(filePath, 'utf8');
2646
3154
  const form = JSON.parse(raw);
3155
+ await expandReferenceFields(form);
2647
3156
  const encoded = Buffer.from(JSON.stringify(form)).toString('base64');
2648
3157
  const extraClass = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
2649
3158
  const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
@@ -2838,6 +3347,78 @@ function processSlideoverBlocks(markdown) {
2838
3347
  * @param {string} markdown
2839
3348
  * @returns {string}
2840
3349
  */
3350
+ /**
3351
+ * Pre-process [menu] shortcodes into rendered `<nav><ul>...</ul></nav>` markup.
3352
+ *
3353
+ * Forms:
3354
+ * [menu slug="my-menu" /]
3355
+ * [menu location="navbar" /]
3356
+ * [menu slug="..." depth="2" variant="..." class="..." /]
3357
+ *
3358
+ * Items flagged `hidden: true` are dropped. Items with a `visibility` field
3359
+ * are filtered against the supplied user via `checkVisibility`. The `depth`
3360
+ * attribute caps the nesting depth (1-indexed; depth="1" = top-level only).
3361
+ *
3362
+ * @param {string} markdown
3363
+ * @param {object|null} user
3364
+ * @returns {Promise<string>}
3365
+ */
3366
+ async function processMenuBlocks(markdown, user) {
3367
+ const re = /\[menu(\s+[^\]]*?)?\s*\/\]/gi;
3368
+ const matches = [...markdown.matchAll(re)];
3369
+ if (!matches.length) return markdown;
3370
+
3371
+ let out = markdown;
3372
+ for (const m of matches) {
3373
+ const attrs = parseShortcodeAttrs(m[1] || '');
3374
+ let menu = null;
3375
+ if (attrs.slug) menu = await getMenu(attrs.slug);
3376
+ else if (attrs.location) menu = await resolveLocation(attrs.location, user || null);
3377
+ if (!menu) { out = out.replace(m[0], ''); continue; }
3378
+
3379
+ // The slug path skipped resolveLocation, so filter visibility + hidden manually here.
3380
+ const items = attrs.slug
3381
+ ? filterMenuForUser(menu.items || [], user || null)
3382
+ : menu.items;
3383
+
3384
+ const depth = Number.parseInt(attrs.depth, 10);
3385
+ const capped = Number.isFinite(depth) && depth > 0 ? capDepth(items, depth) : items;
3386
+ const klass = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
3387
+ const variantClass = attrs.variant ? ` dm-menu--variant-${escapeAttr(attrs.variant)}` : '';
3388
+ const html = `<nav class="dm-menu dm-menu--${escapeAttr(menu.slug)}${variantClass}${klass}" data-menu="${escapeAttr(menu.slug)}">${renderMenuItemsAsUl(capped)}</nav>`;
3389
+ out = out.replace(m[0], html);
3390
+ }
3391
+ return out;
3392
+ }
3393
+
3394
+ function renderMenuItemsAsUl(items) {
3395
+ if (!items.length) return '';
3396
+ const lis = items.map(it => {
3397
+ const href = escapeAttr(it.url || '#');
3398
+ const text = escapeAttr(it.text || '');
3399
+ const child = Array.isArray(it.items) && it.items.length ? renderMenuItemsAsUl(it.items) : '';
3400
+ return `<li><a href="${href}">${text}</a>${child}</li>`;
3401
+ }).join('');
3402
+ return `<ul>${lis}</ul>`;
3403
+ }
3404
+
3405
+ function capDepth(items, max, depth = 1) {
3406
+ return items.map(it => ({
3407
+ ...it,
3408
+ items: depth < max && Array.isArray(it.items) ? capDepth(it.items, max, depth + 1) : []
3409
+ }));
3410
+ }
3411
+
3412
+ function filterMenuForUser(items, user) {
3413
+ const out = [];
3414
+ for (const item of items) {
3415
+ if (item.hidden) continue;
3416
+ if (item.visibility != null && !checkVisibility(user, item.visibility)) continue;
3417
+ out.push({...item, items: Array.isArray(item.items) ? filterMenuForUser(item.items, user) : []});
3418
+ }
3419
+ return out;
3420
+ }
3421
+
2841
3422
  function processDConfigBlocks(markdown) {
2842
3423
  const {scrubbed, restore} = scrubCodeRegions(markdown);
2843
3424
  const processed = scrubbed.replace(
@@ -2904,22 +3485,28 @@ function processCtaBlocks(markdown) {
2904
3485
  * Parse a Markdown file string into frontmatter data and rendered HTML.
2905
3486
  *
2906
3487
  * @param {string} raw - Raw file content (frontmatter + Markdown body)
3488
+ * @param {object} [opts]
3489
+ * @param {object|null} [opts.user] - Authenticated user (`{role, additionalRoles}`)
3490
+ * or null for anonymous. Used to filter menu items gated by `visibility` in
3491
+ * the `[menu]` shortcode. Backwards compatible — defaults to anonymous.
2907
3492
  * @returns {{ data: object, content: string, html: string }}
2908
3493
  */
2909
- export async function parseMarkdown(raw) {
3494
+ export async function parseMarkdown(raw, opts = {}) {
2910
3495
  const {data, content} = matter(raw);
2911
3496
  const extensions = getSanitizeExtensions();
2912
3497
 
2913
3498
  // Pipeline:
2914
- // beforeParse → collection → view → staticBlock → dconfig → effects → plugin shortcodes → tabs → accordion → carousel
3499
+ // beforeParse → collection → view → staticBlock → menu → dconfig → effects → plugin shortcodes → tabs → accordion → carousel
2915
3500
  // → countdown → timeline → spacer → center → icon → form → hero → table → badge → button → link → cta
2916
3501
  // → grid → card → slideover → marked → sanitize → afterParse
2917
3502
  const preprocessed = applyTransforms('markdown:beforeParse', content);
2918
3503
  const {output: withComponents, used: usedComponents} = collectAndRewriteComponents(preprocessed);
2919
- const withCollection = await processCollectionBlocks(withComponents);
2920
- const withView = await processViewBlocks(withCollection);
2921
- const withStaticBlock = await processStaticBlocks(withView);
2922
- const withDconfig = processDConfigBlocks(withStaticBlock);
3504
+ const tagSet = new Set();
3505
+ const withCollection = await processCollectionBlocks(withComponents, tagSet);
3506
+ const withView = await processViewBlocks(withCollection, tagSet);
3507
+ const withStaticBlock = await processStaticBlocks(withView, tagSet);
3508
+ const withMenu = await processMenuBlocks(withStaticBlock, opts.user || null);
3509
+ const withDconfig = processDConfigBlocks(withMenu);
2923
3510
  const withEffects = processEffectsBlocks(withDconfig);
2924
3511
  const withPluginShortcodes = await processPluginShortcodes(withEffects);
2925
3512
  const withTabs = processTabsBlocks(withPluginShortcodes);
@@ -2931,7 +3518,7 @@ export async function parseMarkdown(raw) {
2931
3518
  const withSpacer = processSpacerBlocks(withListGroup);
2932
3519
  const withCenter = processCenterBlocks(withSpacer);
2933
3520
  const withIcon = processIconBlocks(withCenter);
2934
- const withForm = await processFormBlocks(withIcon);
3521
+ const withForm = await processFormBlocks(withIcon, tagSet);
2935
3522
  const withHero = processHeroBlocks(withForm);
2936
3523
  const withTable = processTableBlocks(withHero);
2937
3524
  const withBadge = processBadgeBlocks(withTable);
@@ -2997,7 +3584,7 @@ export async function parseMarkdown(raw) {
2997
3584
  const allowed = new Set(_dmTagAllowlist.map(t => t.replace(/^dm-/, '')));
2998
3585
  const filteredUsed = [...usedComponents].filter(name => allowed.has(name));
2999
3586
 
3000
- return {data, content, html, usedComponents: filteredUsed};
3587
+ return {data, content, html, usedComponents: filteredUsed, tags: [...tagSet]};
3001
3588
  }
3002
3589
 
3003
3590
  /**