domma-cms 0.18.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 (110) hide show
  1. package/CLAUDE.md +37 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/api.js +1 -1
  4. package/admin/js/app.js +4 -4
  5. package/admin/js/config/sidebar-config.js +1 -1
  6. package/admin/js/lib/crud-tutorial.js +1 -0
  7. package/admin/js/lib/markdown-toolbar.js +5 -5
  8. package/admin/js/lib/project-context.js +1 -0
  9. package/admin/js/lib/sidebar-renderer.js +4 -0
  10. package/admin/js/templates/action-editor.html +7 -0
  11. package/admin/js/templates/block-editor.html +7 -0
  12. package/admin/js/templates/collection-editor.html +9 -0
  13. package/admin/js/templates/form-editor.html +9 -0
  14. package/admin/js/templates/menu-editor.html +98 -0
  15. package/admin/js/templates/menu-locations.html +14 -0
  16. package/admin/js/templates/menus.html +14 -0
  17. package/admin/js/templates/page-editor.html +9 -2
  18. package/admin/js/templates/project-detail.html +50 -0
  19. package/admin/js/templates/project-editor.html +45 -0
  20. package/admin/js/templates/project-settings.html +60 -0
  21. package/admin/js/templates/projects.html +13 -0
  22. package/admin/js/templates/role-editor.html +11 -0
  23. package/admin/js/templates/tutorials.html +335 -2
  24. package/admin/js/templates/view-editor.html +7 -0
  25. package/admin/js/views/action-editor.js +1 -1
  26. package/admin/js/views/actions-list.js +1 -1
  27. package/admin/js/views/block-editor.js +8 -8
  28. package/admin/js/views/blocks.js +2 -2
  29. package/admin/js/views/collection-editor.js +4 -4
  30. package/admin/js/views/collections.js +1 -1
  31. package/admin/js/views/form-editor.js +5 -5
  32. package/admin/js/views/forms.js +1 -1
  33. package/admin/js/views/index.js +1 -1
  34. package/admin/js/views/menu-editor.js +19 -0
  35. package/admin/js/views/menu-locations.js +1 -0
  36. package/admin/js/views/menus.js +5 -0
  37. package/admin/js/views/page-editor.js +24 -24
  38. package/admin/js/views/pages.js +3 -3
  39. package/admin/js/views/project-detail.js +4 -0
  40. package/admin/js/views/project-editor.js +1 -0
  41. package/admin/js/views/project-settings.js +1 -0
  42. package/admin/js/views/projects.js +7 -0
  43. package/admin/js/views/role-editor.js +1 -1
  44. package/admin/js/views/roles.js +3 -3
  45. package/admin/js/views/tutorials.js +1 -1
  46. package/admin/js/views/user-editor.js +1 -1
  47. package/admin/js/views/users.js +3 -3
  48. package/admin/js/views/view-editor.js +1 -1
  49. package/admin/js/views/views-list.js +1 -1
  50. package/config/menu-locations.json +5 -0
  51. package/config/menus/admin-sidebar.json +185 -0
  52. package/config/menus/footer.json +33 -0
  53. package/config/menus/main.json +35 -0
  54. package/config/menus/sproj-1779696558011-menu.json +17 -0
  55. package/config/menus/sproj-1779696960337-menu.json +18 -0
  56. package/config/menus/sproj-1779696985353-menu.json +18 -0
  57. package/config/site.json +6 -22
  58. package/package.json +4 -3
  59. package/plugins/analytics/daily.json +3 -0
  60. package/plugins/analytics/journeys.json +8 -0
  61. package/plugins/analytics/lifetime.json +1 -1
  62. package/public/css/site.css +1 -1
  63. package/public/js/collection-browser.js +4 -0
  64. package/public/js/forms.js +1 -1
  65. package/public/js/site.js +1 -1
  66. package/server/middleware/auth.js +88 -22
  67. package/server/routes/api/actions.js +58 -5
  68. package/server/routes/api/auth.js +2 -2
  69. package/server/routes/api/blocks.js +18 -3
  70. package/server/routes/api/collections.js +201 -8
  71. package/server/routes/api/forms.js +266 -21
  72. package/server/routes/api/menu-locations.js +46 -0
  73. package/server/routes/api/menus.js +115 -0
  74. package/server/routes/api/pages.js +1 -1
  75. package/server/routes/api/projects.js +107 -0
  76. package/server/routes/api/scaffold.js +86 -0
  77. package/server/routes/api/sidebar.js +23 -0
  78. package/server/routes/api/users.js +32 -7
  79. package/server/routes/api/views.js +10 -2
  80. package/server/routes/public.js +79 -6
  81. package/server/server.js +38 -0
  82. package/server/services/actions.js +137 -8
  83. package/server/services/adapters/FileAdapter.js +23 -8
  84. package/server/services/adapters/MongoAdapter.js +36 -18
  85. package/server/services/blocks.js +20 -8
  86. package/server/services/collections.js +85 -8
  87. package/server/services/content.js +23 -9
  88. package/server/services/filterEngine.js +281 -0
  89. package/server/services/hooks.js +48 -0
  90. package/server/services/markdown.js +686 -109
  91. package/server/services/menus-migration.js +107 -0
  92. package/server/services/menus.js +422 -0
  93. package/server/services/permissionRegistry.js +26 -0
  94. package/server/services/plugins.js +9 -2
  95. package/server/services/presetCollections.js +22 -0
  96. package/server/services/projects.js +429 -0
  97. package/server/services/recipes/contact-list.json +78 -0
  98. package/server/services/recipes/onboarding.json +426 -0
  99. package/server/services/references.js +174 -0
  100. package/server/services/renderer.js +237 -40
  101. package/server/services/roles.js +6 -1
  102. package/server/services/rowAccess.js +86 -13
  103. package/server/services/scaffolder.js +465 -0
  104. package/server/services/sidebar-migration.js +117 -0
  105. package/server/services/sitemap.js +112 -0
  106. package/server/services/userRoles.js +86 -0
  107. package/server/services/users.js +23 -2
  108. package/server/services/views.js +15 -4
  109. package/server/templates/page.html +7 -2
  110. /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
  /**
@@ -306,12 +344,79 @@ async function processStaticBlocks(content, tagSet) {
306
344
  return output;
307
345
  }
308
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
+
309
414
  function renderCollectionTable(slug, entries, visibleFields, attrs, ctaOpts) {
310
415
  const columns = visibleFields.map(f => ({key: f.name, title: f.label || f.name}));
311
416
  const rows = entries.map(e => {
312
417
  const row = {};
313
418
  visibleFields.forEach(f => {
314
- row[f.name] = e.data?.[f.name] ?? '';
419
+ row[f.name] = displayValue(e, f);
315
420
  });
316
421
  if (ctaOpts) row._entryId = e.id || '';
317
422
  return row;
@@ -337,11 +442,12 @@ function renderCollectionCards(entries, visibleFields, titleField, columns, empt
337
442
  return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
338
443
  }
339
444
  const cards = entries.map(e => {
340
- 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] ?? '') : '');
341
447
  const body = visibleFields
342
448
  .filter(f => f.name !== titleField)
343
449
  .map(f => {
344
- const val = escapeHtmlText(e.data?.[f.name] ?? '');
450
+ const val = displayValueHtml(e, f);
345
451
  return val ? `<p><strong>${escapeHtmlText(f.label || f.name)}:</strong> ${val}</p>` : '';
346
452
  }).join('');
347
453
  let footer = '';
@@ -363,11 +469,12 @@ function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaO
363
469
  return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
364
470
  }
365
471
  const items = entries.map(e => {
366
- 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] ?? '') : '');
367
474
  const rest = visibleFields
368
475
  .filter(f => f.name !== titleField)
369
476
  .map(f => {
370
- const val = escapeHtmlText(e.data?.[f.name] ?? '');
477
+ const val = displayValueHtml(e, f);
371
478
  return val ? `<p>${val}</p>` : '';
372
479
  }).join('');
373
480
  let ctaHtml = '';
@@ -478,6 +585,17 @@ async function processViewBlocks(markdown, tagSet) {
478
585
  }
479
586
  tagSet?.add(`view:${slug}`);
480
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
+ }
598
+
481
599
  const displayAttr = attrs.display || '';
482
600
  const emptyMsg = attrs.empty || 'No results found';
483
601
  const titleField = attrs['title-field'] || '';
@@ -601,114 +719,433 @@ async function processCollectionBlocks(markdown, tagSet) {
601
719
  }
602
720
  tagSet?.add(`collection:${slug}`);
603
721
 
604
- const display = attrs.display || 'table';
605
- const limitAttr = parseInt(attrs.limit, 10) || 0;
606
- const sort = attrs.sort || 'createdAt';
607
- const order = attrs.order || 'desc';
608
- const titleField = attrs['title-field'] || '';
609
- const columns = attrs.columns || '3';
610
- const emptyMsg = attrs.empty || 'No entries found';
611
- const fieldFilter = attrs.fields ? attrs.fields.split(',').map(f => f.trim()).filter(Boolean) : null;
612
- const ctaAction = attrs.cta || '';
613
- const ctaOpts = ctaAction ? {
614
- action: ctaAction,
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
+ }
739
+
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,
615
896
  label: attrs['cta-label'] || 'Run',
616
897
  icon: attrs['cta-icon'] || '',
617
898
  style: attrs['cta-style'] || 'primary',
618
899
  confirm: attrs['cta-confirm'] || ''
619
- } : 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
+ };
620
905
 
621
- 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);
622
909
 
623
- try {
624
- const schema = await getCollection(slug);
625
- if (!schema) throw new Error('not found');
626
-
627
- let {entries} = await listEntries(slug);
628
-
629
- // Row-level filter: where="field=value" (simple equality only).
630
- // Comma-separate multiple predicates, all AND'd together.
631
- // Example: where="tab=developers" or where="tab=developers,status=live"
632
- const whereAttr = typeof attrs.where === 'string' ? attrs.where.trim() : '';
633
- if (whereAttr) {
634
- const predicates = whereAttr.split(',').map(p => p.trim()).filter(Boolean).map(p => {
635
- const eq = p.indexOf('=');
636
- if (eq === -1) return null;
637
- return { key: p.slice(0, eq).trim(), val: p.slice(eq + 1).trim() };
638
- }).filter(Boolean);
639
- if (predicates.length) {
640
- entries = entries.filter(e => predicates.every(({key, val}) => String(e.data?.[key] ?? '') === val));
641
- }
642
- }
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');
643
913
 
644
- entries = sortEntries(entries, sort, order);
645
- 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)}"`;
646
916
 
647
- // Determine visible fields from schema (optionally filtered)
648
- let fields = schema.fields || [];
649
- if (fieldFilter?.length) {
650
- fields = fieldFilter.map(name => fields.find(f => f.name === name) || { name, label: name });
651
- }
652
- if (!fields.length && entries.length) {
653
- // No schema fields — derive from first entry's data keys
654
- fields = Object.keys(entries[0].data || {}).map(k => ({ name: k, label: k }));
655
- }
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
+ }
656
924
 
657
- if (display === 'cards') {
658
- replacement = renderCollectionCards(entries, fields, titleField, columns, emptyMsg, ctaOpts);
659
- } else if (display === 'list') {
660
- replacement = renderCollectionList(entries, fields, titleField, emptyMsg, ctaOpts);
661
- } else if (display === 'accordion') {
662
- const accordionTitleField = attrs['title-field'] || 'title';
663
- const bodyField = attrs['body-field'] || 'description';
664
- const multiple = attrs.multiple === 'true';
665
- replacement = renderCollectionAccordion(entries, accordionTitleField, bodyField, multiple, emptyMsg);
666
- } else if (display === 'timeline') {
667
- const timelineLayout = ['vertical', 'centred', 'horizontal'].includes(attrs.layout) ? attrs.layout : 'vertical';
668
- const timelineTheme = ['minimal', 'corporate', 'modern'].includes(attrs.theme) ? attrs.theme : 'minimal';
669
- const timelineMode = ['timeline', 'roadmap'].includes(attrs.mode) ? attrs.mode : 'timeline';
670
- replacement = renderCollectionTimeline(entries, {
671
- titleField: attrs['title-field'] || 'title',
672
- dateField: attrs['date-field'] || '',
673
- statusField: attrs['status-field'] || '',
674
- iconField: attrs['icon-field'] || '',
675
- bodyField: attrs['body-field'] || '',
676
- layout: timelineLayout,
677
- theme: timelineTheme,
678
- mode: timelineMode,
679
- emptyMsg,
680
- });
681
- } else if (display === 'block') {
682
- const blockName = attrs.block || '';
683
- if (blockName) {
684
- try {
685
- const [tpl, css] = await Promise.all([
686
- loadBlockTemplate(blockName),
687
- loadBlockCss(blockName),
688
- ]);
689
- const cols = attrs.cols || '';
690
- replacement = renderCollectionBlocks(entries, tpl, emptyMsg, ctaOpts, cols, blockName, css);
691
- } catch {
692
- replacement = `<div class="dm-collection-display dm-collection-empty"><p>Block template &ldquo;${escapeHtmlText(blockName)}&rdquo; not found.</p></div>`;
693
- }
694
- }
695
- } else {
696
- replacement = renderCollectionTable(slug, entries, fields, attrs, ctaOpts);
697
- }
698
- } catch {
699
- // 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;
700
959
  }
960
+ } catch {
961
+ // executeView errored (no Pro / view missing) — emit empty shell.
962
+ }
701
963
 
702
- if (attrs.class || attrs.id) {
703
- const cls = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
704
- const id = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
705
- 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 }));
706
1095
  }
707
1096
 
708
- 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
709
1140
  }
710
1141
 
711
- 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;
712
1149
  }
713
1150
 
714
1151
  /**
@@ -2625,6 +3062,68 @@ function processIconBlocks(markdown) {
2625
3062
  */
2626
3063
  const FORMS_DIR = path.resolve(__dirname_md, '../../content/forms');
2627
3064
 
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
+
2628
3127
  async function processFormBlocks(markdown, tagSet) {
2629
3128
  const {scrubbed, restore} = scrubCodeRegions(markdown);
2630
3129
  const regex = /\[form([^\]]*?)\/\]/gi;
@@ -2653,6 +3152,7 @@ async function processFormBlocks(markdown, tagSet) {
2653
3152
  if (!filePath.startsWith(FORMS_DIR + path.sep)) throw new Error('Invalid slug');
2654
3153
  const raw = await readFile(filePath, 'utf8');
2655
3154
  const form = JSON.parse(raw);
3155
+ await expandReferenceFields(form);
2656
3156
  const encoded = Buffer.from(JSON.stringify(form)).toString('base64');
2657
3157
  const extraClass = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
2658
3158
  const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
@@ -2847,6 +3347,78 @@ function processSlideoverBlocks(markdown) {
2847
3347
  * @param {string} markdown
2848
3348
  * @returns {string}
2849
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
+
2850
3422
  function processDConfigBlocks(markdown) {
2851
3423
  const {scrubbed, restore} = scrubCodeRegions(markdown);
2852
3424
  const processed = scrubbed.replace(
@@ -2913,14 +3485,18 @@ function processCtaBlocks(markdown) {
2913
3485
  * Parse a Markdown file string into frontmatter data and rendered HTML.
2914
3486
  *
2915
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.
2916
3492
  * @returns {{ data: object, content: string, html: string }}
2917
3493
  */
2918
- export async function parseMarkdown(raw) {
3494
+ export async function parseMarkdown(raw, opts = {}) {
2919
3495
  const {data, content} = matter(raw);
2920
3496
  const extensions = getSanitizeExtensions();
2921
3497
 
2922
3498
  // Pipeline:
2923
- // beforeParse → collection → view → staticBlock → dconfig → effects → plugin shortcodes → tabs → accordion → carousel
3499
+ // beforeParse → collection → view → staticBlock → menu → dconfig → effects → plugin shortcodes → tabs → accordion → carousel
2924
3500
  // → countdown → timeline → spacer → center → icon → form → hero → table → badge → button → link → cta
2925
3501
  // → grid → card → slideover → marked → sanitize → afterParse
2926
3502
  const preprocessed = applyTransforms('markdown:beforeParse', content);
@@ -2929,7 +3505,8 @@ export async function parseMarkdown(raw) {
2929
3505
  const withCollection = await processCollectionBlocks(withComponents, tagSet);
2930
3506
  const withView = await processViewBlocks(withCollection, tagSet);
2931
3507
  const withStaticBlock = await processStaticBlocks(withView, tagSet);
2932
- const withDconfig = processDConfigBlocks(withStaticBlock);
3508
+ const withMenu = await processMenuBlocks(withStaticBlock, opts.user || null);
3509
+ const withDconfig = processDConfigBlocks(withMenu);
2933
3510
  const withEffects = processEffectsBlocks(withDconfig);
2934
3511
  const withPluginShortcodes = await processPluginShortcodes(withEffects);
2935
3512
  const withTabs = processTabsBlocks(withPluginShortcodes);