domma-cms 0.41.1 → 0.43.1

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.
@@ -11,8 +11,10 @@ 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, resolveMenuDecorations, colourToCss, floatToCss} from './menus.js';
14
+ import {getMenu, resolveLocation, resolveMenuDecorations} from './menus.js';
15
+ import {buildMenuNav} from './menuRender.js';
15
16
  import {checkVisibility} from '../middleware/auth.js';
17
+ import {getRoleHierarchy} from './roles.js';
16
18
 
17
19
  const __dirname_md = path.dirname(fileURLToPath(import.meta.url));
18
20
  const BLOCKS_DIR = path.resolve(__dirname_md, '../../content/blocks');
@@ -173,6 +175,51 @@ function formatDate(input, fmt) {
173
175
  t => String(tokens[t]()));
174
176
  }
175
177
 
178
+ const EXPORT_FORMATS = ['csv', 'json'];
179
+
180
+ /**
181
+ * Decide whether a display offers export, and to whom. First match wins:
182
+ * 1. schema export.enabled === false → never (exportable="true" ignored)
183
+ * 2. exportable="true" on the shortcode → public (grandfathered: that is
184
+ * exactly what the attribute has always meant)
185
+ * 3. schema export.access → as configured
186
+ * 4. otherwise → the least-privileged role
187
+ *
188
+ * @param {object|undefined} schemaExport - schema.json `export` block
189
+ * @param {object} attrs - shortcode attributes
190
+ * @param {string} defaultRole - least-privileged role name
191
+ */
192
+ export function resolveExportConfig(schemaExport, attrs, defaultRole) {
193
+ const cfg = schemaExport || {};
194
+ if (cfg.enabled === false) return {allowed: false, access: null, formats: [], roles: []};
195
+ const formats = (Array.isArray(cfg.formats) ? cfg.formats : EXPORT_FORMATS)
196
+ .filter(f => EXPORT_FORMATS.includes(f));
197
+ const access = attrs?.exportable === 'true' ? 'public' : (cfg.access || defaultRole);
198
+ return {allowed: true, access, formats, roles: rolesAtOrAbove(access)};
199
+ }
200
+
201
+ /**
202
+ * The role names permitted by an `access` setting — the named role plus every
203
+ * more-privileged one above it.
204
+ *
205
+ * The browser cannot do this itself: it learns the visitor's role name from
206
+ * /api/auth/me but has no copy of the hierarchy, so without this list it could
207
+ * only ask "is anyone signed in?" and every role setting would behave
208
+ * identically. Resolving it server-side keeps the check a single membership
209
+ * test on the client.
210
+ *
211
+ * @param {string} access - 'public', or a role name
212
+ * @returns {string[]} empty for 'public' (no check needed) or an unknown role
213
+ */
214
+ function rolesAtOrAbove(access) {
215
+ if (access === 'public') return [];
216
+ const hierarchy = getRoleHierarchy(); // most → least privileged
217
+ const idx = hierarchy.indexOf(access);
218
+ // Unknown role name: fail closed with an empty list rather than granting
219
+ // everyone, so a typo in schema.json cannot open export up.
220
+ return idx === -1 ? [] : hierarchy.slice(0, idx + 1);
221
+ }
222
+
176
223
  /**
177
224
  * Build the `data-ctx` attribute that powers the public-site right-click
178
225
  * search/filter menu (public/js/collection-context.js). The payload carries
@@ -181,9 +228,12 @@ function formatDate(input, fmt) {
181
228
  *
182
229
  * @param {Array<{name: string, label?: string}>} fields - displayed fields
183
230
  * @param {object[]} entries - collection entries {id, data}
231
+ * @param {object} [opts]
232
+ * @param {string} [opts.slug] - collection slug (for export requests)
233
+ * @param {object} [opts.export] - resolveExportConfig() result
184
234
  * @returns {string} ` data-ctx="<base64 JSON>"` (leading space included)
185
235
  */
186
- function buildCtxAttr(fields, entries) {
236
+ function buildCtxAttr(fields, entries, opts = {}) {
187
237
  const names = [...new Set(fields.map(f => f?.name).filter(Boolean))];
188
238
  if (!names.length || !entries.length) return '';
189
239
  const payload = {
@@ -193,6 +243,8 @@ function buildCtxAttr(fields, entries) {
193
243
  data: Object.fromEntries(names.map(n => [n, e.data?.[n] ?? '']))
194
244
  }))
195
245
  };
246
+ if (opts.slug) payload.slug = opts.slug;
247
+ if (opts.export) payload.export = opts.export;
196
248
  return ` data-ctx="${Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')}"`;
197
249
  }
198
250
 
@@ -254,7 +306,7 @@ function interpolateBlockTemplate(blockTemplate, e) {
254
306
  * @param {object|null} ctaOpts - Optional CTA button options
255
307
  * @returns {string}
256
308
  */
257
- function renderCollectionBlocks(entries, blockTemplate, emptyMsg, ctaOpts, cols, blockName = '', blockCss = '') {
309
+ function renderCollectionBlocks(entries, blockTemplate, emptyMsg, ctaOpts, cols, blockName = '', blockCss = '', ctxOpts = {}) {
258
310
  if (!entries.length) {
259
311
  return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
260
312
  }
@@ -286,7 +338,7 @@ function renderCollectionBlocks(entries, blockTemplate, emptyMsg, ctaOpts, cols,
286
338
  ? `dm-collection-display dm-collection-blocks grid ${responsiveGridCols(validCols).join(' ')} gap-4`
287
339
  : 'dm-collection-display dm-collection-blocks';
288
340
 
289
- const ctx = buildCtxAttr(blockTemplateFields(blockTemplate), entries);
341
+ const ctx = buildCtxAttr(blockTemplateFields(blockTemplate), entries, ctxOpts);
290
342
  const styleTag = buildBlockStyleTag(blockName, blockCss);
291
343
  return `${styleTag}<div class="${wrapperClass}"${ctx}>\n${items.join('\n')}\n</div>`;
292
344
  }
@@ -455,7 +507,7 @@ function displayValueHtml(entry, field) {
455
507
  return escapeHtmlText(displayValue(entry, field));
456
508
  }
457
509
 
458
- function renderCollectionTable(slug, entries, visibleFields, attrs, ctaOpts) {
510
+ function renderCollectionTable(slug, entries, visibleFields, attrs, ctaOpts, ctxOpts = {}) {
459
511
  const columns = visibleFields.map(f => ({key: f.name, title: f.label || f.name}));
460
512
  const rows = entries.map(e => {
461
513
  const row = {};
@@ -473,14 +525,16 @@ function renderCollectionTable(slug, entries, visibleFields, attrs, ctaOpts) {
473
525
  sortable: attrs.sortable !== 'false',
474
526
  exportable: attrs.exportable === 'true',
475
527
  pageSize: parseInt(attrs['page-size'], 10) || 25,
476
- empty: attrs.empty || 'No entries found'
528
+ empty: attrs.empty || 'No entries found',
529
+ slug: ctxOpts.slug || slug,
530
+ exportCfg: ctxOpts.export || {allowed: false, access: null, formats: []}
477
531
  };
478
532
  if (ctaOpts) tableOpts.ctaConfig = ctaOpts;
479
533
  const payload = Buffer.from(JSON.stringify(tableOpts)).toString('base64');
480
534
  return `<div class="dm-collection-display" data-collection-table data-slug="${escapeAttr(slug)}" data-payload="${payload}"></div>`;
481
535
  }
482
536
 
483
- function renderCollectionCards(entries, visibleFields, titleField, columns, emptyMsg, ctaOpts) {
537
+ function renderCollectionCards(entries, visibleFields, titleField, columns, emptyMsg, ctaOpts, ctxOpts = {}) {
484
538
  const cols = ['2', '3', '4'].includes(String(columns)) ? columns : '3';
485
539
  if (!entries.length) {
486
540
  return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
@@ -505,10 +559,10 @@ function renderCollectionCards(entries, visibleFields, titleField, columns, empt
505
559
  }
506
560
  return `<div class="card" data-entry-id="${escapeAttr(String(e.id ?? ''))}">${title ? `<div class="card-header">${title}</div>` : ''}<div class="card-body">${body || '&nbsp;'}</div>${footer}</div>`;
507
561
  }).join('\n');
508
- return `<div class="dm-collection-display grid ${responsiveGridCols(cols).join(' ')} gap-4"${buildCtxAttr(visibleFields, entries)}>\n${cards}\n</div>`;
562
+ return `<div class="dm-collection-display grid ${responsiveGridCols(cols).join(' ')} gap-4"${buildCtxAttr(visibleFields, entries, ctxOpts)}>\n${cards}\n</div>`;
509
563
  }
510
564
 
511
- function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaOpts) {
565
+ function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaOpts, ctxOpts = {}) {
512
566
  if (!entries.length) {
513
567
  return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
514
568
  }
@@ -532,7 +586,7 @@ function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaO
532
586
  }
533
587
  return `<div class="dm-collection-list-item" data-entry-id="${escapeAttr(String(e.id ?? ''))}">${title ? `<strong>${title}</strong>` : ''}${rest}${ctaHtml}</div>`;
534
588
  }).join('\n');
535
- return `<div class="dm-collection-display dm-collection-list"${buildCtxAttr(visibleFields, entries)}>\n${items}\n</div>`;
589
+ return `<div class="dm-collection-display dm-collection-list"${buildCtxAttr(visibleFields, entries, ctxOpts)}>\n${items}\n</div>`;
536
590
  }
537
591
 
538
592
  /**
@@ -544,7 +598,7 @@ function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaO
544
598
  * @param {string} emptyMsg
545
599
  * @returns {string}
546
600
  */
547
- function renderCollectionAccordion(entries, titleField, bodyField, multiple, emptyMsg) {
601
+ function renderCollectionAccordion(entries, titleField, bodyField, multiple, emptyMsg, ctxOpts = {}) {
548
602
  if (!entries.length) {
549
603
  return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
550
604
  }
@@ -563,7 +617,7 @@ function renderCollectionAccordion(entries, titleField, bodyField, multiple, emp
563
617
  );
564
618
  }).join('\n');
565
619
 
566
- const ctx = buildCtxAttr([{name: titleField}, {name: bodyField}], entries);
620
+ const ctx = buildCtxAttr([{name: titleField}, {name: bodyField}], entries, ctxOpts);
567
621
  return `<div class="dm-collection-display accordion"${multiAttr}${ctx}>\n${items}\n</div>`;
568
622
  }
569
623
 
@@ -584,7 +638,7 @@ function renderCollectionAccordion(entries, titleField, bodyField, multiple, emp
584
638
  * @param {string} opts.emptyMsg
585
639
  * @returns {string}
586
640
  */
587
- function renderCollectionTimeline(entries, opts) {
641
+ function renderCollectionTimeline(entries, opts, ctxOpts = {}) {
588
642
  const {titleField, dateField, statusField, iconField, bodyField, layout, theme, mode, emptyMsg} = opts;
589
643
  if (!entries.length) {
590
644
  return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
@@ -603,7 +657,8 @@ function renderCollectionTimeline(entries, opts) {
603
657
 
604
658
  const ctx = buildCtxAttr(
605
659
  [{name: titleField}, {name: dateField}, {name: statusField}, {name: bodyField}].filter(f => f.name),
606
- entries
660
+ entries,
661
+ ctxOpts
607
662
  );
608
663
  return `<div class="dm-collection-display dm-progression" data-layout="${layout}" data-theme="${theme}" data-mode="${mode}"${ctx}>\n${items}\n</div>`;
609
664
  }
@@ -629,7 +684,7 @@ function renderCollectionTimeline(entries, opts) {
629
684
  * @param {string} opts.emptyMsg
630
685
  * @returns {string}
631
686
  */
632
- function renderCollectionCarousel(entries, opts) {
687
+ function renderCollectionCarousel(entries, opts, ctxOpts = {}) {
633
688
  const {imageField, titleField, bodyField, blockTemplate, blockName, blockCss, dataAttrs = {}, emptyMsg} = opts;
634
689
  if (!entries.length) {
635
690
  return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
@@ -663,7 +718,7 @@ function renderCollectionCarousel(entries, opts) {
663
718
  const ctxFields = blockTemplate
664
719
  ? blockTemplateFields(blockTemplate)
665
720
  : [{name: titleField}, {name: bodyField}].filter(f => f.name);
666
- const ctx = buildCtxAttr(ctxFields, entries);
721
+ const ctx = buildCtxAttr(ctxFields, entries, ctxOpts);
667
722
  const styleTag = blockTemplate ? buildBlockStyleTag(blockName, blockCss) : '';
668
723
  return `${styleTag}<div class="dm-collection-display carousel"${passthrough}${ctx}><div class="carousel-track">${slides}</div></div>`;
669
724
  }
@@ -716,7 +771,7 @@ async function loadOptionalBlockAssets(blockName) {
716
771
  }
717
772
  }
718
773
 
719
- function renderCollectionListgroup(entries, opts) {
774
+ function renderCollectionListgroup(entries, opts, ctxOpts = {}) {
720
775
  const {titleField, bodyField, variant, size, itemVariant, action, blockTemplate, blockName, blockCss, emptyMsg} = opts;
721
776
  if (!entries.length) {
722
777
  return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
@@ -756,7 +811,7 @@ function renderCollectionListgroup(entries, opts) {
756
811
  const ctxFields = blockTemplate
757
812
  ? blockTemplateFields(blockTemplate)
758
813
  : [{name: titleField}, {name: bodyField}].filter(f => f.name);
759
- const ctx = buildCtxAttr(ctxFields, entries);
814
+ const ctx = buildCtxAttr(ctxFields, entries, ctxOpts);
760
815
  const styleTag = blockTemplate ? buildBlockStyleTag(blockName, blockCss) : '';
761
816
  return `${styleTag}<div class="${wrapperClasses.join(' ')}"${ctx}>\n${items}\n</div>`;
762
817
  }
@@ -825,6 +880,19 @@ async function processViewBlocks(markdown, tagSet) {
825
880
  // Map MongoDB docs to { id, data } shape. MongoAdapter docs have a top-level id field.
826
881
  const entries = results.map(doc => ({id: doc.id || '', data: doc.data || doc}));
827
882
 
883
+ // Resolve the view's underlying collection schema to compute export
884
+ // permission. Views don't own a schema themselves — [dis]allowing
885
+ // export must fall back CLOSED if the source collection can't be
886
+ // resolved, since an open fallback would be a permission bypass.
887
+ const sourceSlug = viewConfig?.pipeline?.source || '';
888
+ const sourceSchema = sourceSlug ? await getCollection(sourceSlug) : null;
889
+ const ctxOpts = sourceSchema
890
+ ? {
891
+ slug: sourceSlug,
892
+ export: resolveExportConfig(sourceSchema.export, attrs, getRoleHierarchy().at(-1))
893
+ }
894
+ : {slug: sourceSlug || undefined, export: {allowed: false, access: null, formats: []}};
895
+
828
896
  // Derive visible fields from attr filter or first entry's data keys
829
897
  let fields;
830
898
  if (fieldFilter?.length) {
@@ -840,14 +908,14 @@ async function processViewBlocks(markdown, tagSet) {
840
908
  const display = displayAttr || viewConfig?.display?.mode || 'table';
841
909
 
842
910
  if (display === 'cards') {
843
- replacement = renderCollectionCards(entries, fields, titleField, columns, emptyMsg, ctaOpts);
911
+ replacement = renderCollectionCards(entries, fields, titleField, columns, emptyMsg, ctaOpts, ctxOpts);
844
912
  } else if (display === 'list') {
845
- replacement = renderCollectionList(entries, fields, titleField, emptyMsg, ctaOpts);
913
+ replacement = renderCollectionList(entries, fields, titleField, emptyMsg, ctaOpts, ctxOpts);
846
914
  } else if (display === 'accordion') {
847
915
  const accordionTitleField = attrs['title-field'] || 'title';
848
916
  const bodyField = attrs['body-field'] || 'description';
849
917
  const multiple = attrs.multiple === 'true';
850
- replacement = renderCollectionAccordion(entries, accordionTitleField, bodyField, multiple, emptyMsg);
918
+ replacement = renderCollectionAccordion(entries, accordionTitleField, bodyField, multiple, emptyMsg, ctxOpts);
851
919
  } else if (display === 'timeline') {
852
920
  const timelineLayout = ['vertical', 'centred', 'horizontal'].includes(attrs.layout) ? attrs.layout : 'vertical';
853
921
  const timelineTheme = ['minimal', 'corporate', 'modern'].includes(attrs.theme) ? attrs.theme : 'minimal';
@@ -862,7 +930,7 @@ async function processViewBlocks(markdown, tagSet) {
862
930
  theme: timelineTheme,
863
931
  mode: timelineMode,
864
932
  emptyMsg,
865
- });
933
+ }, ctxOpts);
866
934
  } else if (display === 'carousel') {
867
935
  const {tpl, css, missing} = await loadOptionalBlockAssets(attrs.block);
868
936
  replacement = missing
@@ -876,7 +944,7 @@ async function processViewBlocks(markdown, tagSet) {
876
944
  blockCss: css,
877
945
  dataAttrs: attrs,
878
946
  emptyMsg,
879
- });
947
+ }, ctxOpts);
880
948
  } else if (display === 'listgroup') {
881
949
  const {tpl, css, missing} = await loadOptionalBlockAssets(attrs.block);
882
950
  replacement = missing
@@ -892,7 +960,7 @@ async function processViewBlocks(markdown, tagSet) {
892
960
  blockName: attrs.block || '',
893
961
  blockCss: css,
894
962
  emptyMsg,
895
- });
963
+ }, ctxOpts);
896
964
  } else if (display === 'block') {
897
965
  const blockName = attrs.block || viewConfig?.display?.block || '';
898
966
  if (blockName) {
@@ -901,13 +969,13 @@ async function processViewBlocks(markdown, tagSet) {
901
969
  loadBlockTemplate(blockName),
902
970
  loadBlockCss(blockName),
903
971
  ]);
904
- replacement = renderCollectionBlocks(entries, tpl, emptyMsg, ctaOpts, '', blockName, css);
972
+ replacement = renderCollectionBlocks(entries, tpl, emptyMsg, ctaOpts, '', blockName, css, ctxOpts);
905
973
  } catch {
906
974
  replacement = blockNotFoundHtml(blockName);
907
975
  }
908
976
  }
909
977
  } else {
910
- replacement = renderCollectionTable(`view:${slug}`, entries, fields, attrs, ctaOpts);
978
+ replacement = renderCollectionTable(`view:${slug}`, entries, fields, attrs, ctaOpts, ctxOpts);
911
979
  }
912
980
  } catch {
913
981
  // View not found or pipeline error — show empty state
@@ -1305,6 +1373,12 @@ export async function renderCollectionFragment(attrs, { extraFilter = null } = {
1305
1373
  const schema = await getCollection(slug);
1306
1374
  if (!schema) throw new Error('not found');
1307
1375
 
1376
+ const defaultRole = getRoleHierarchy().at(-1);
1377
+ const ctxOpts = {
1378
+ slug: schema.slug,
1379
+ export: resolveExportConfig(schema.export, attrs, defaultRole)
1380
+ };
1381
+
1308
1382
  const filter = buildShortcodeFilter(attrs);
1309
1383
  if (extraFilter) Object.assign(filter, extraFilter);
1310
1384
 
@@ -1327,14 +1401,14 @@ export async function renderCollectionFragment(attrs, { extraFilter = null } = {
1327
1401
  }
1328
1402
 
1329
1403
  if (display === 'cards') {
1330
- replacement = renderCollectionCards(entries, fields, titleField, columns, emptyMsg, ctaOpts);
1404
+ replacement = renderCollectionCards(entries, fields, titleField, columns, emptyMsg, ctaOpts, ctxOpts);
1331
1405
  } else if (display === 'list') {
1332
- replacement = renderCollectionList(entries, fields, titleField, emptyMsg, ctaOpts);
1406
+ replacement = renderCollectionList(entries, fields, titleField, emptyMsg, ctaOpts, ctxOpts);
1333
1407
  } else if (display === 'accordion') {
1334
1408
  const accordionTitleField = attrs['title-field'] || 'title';
1335
1409
  const bodyField = attrs['body-field'] || 'description';
1336
1410
  const multiple = attrs.multiple === 'true';
1337
- replacement = renderCollectionAccordion(entries, accordionTitleField, bodyField, multiple, emptyMsg);
1411
+ replacement = renderCollectionAccordion(entries, accordionTitleField, bodyField, multiple, emptyMsg, ctxOpts);
1338
1412
  } else if (display === 'timeline') {
1339
1413
  const timelineLayout = ['vertical', 'centred', 'horizontal'].includes(attrs.layout) ? attrs.layout : 'vertical';
1340
1414
  const timelineTheme = ['minimal', 'corporate', 'modern'].includes(attrs.theme) ? attrs.theme : 'minimal';
@@ -1349,7 +1423,7 @@ export async function renderCollectionFragment(attrs, { extraFilter = null } = {
1349
1423
  theme: timelineTheme,
1350
1424
  mode: timelineMode,
1351
1425
  emptyMsg,
1352
- });
1426
+ }, ctxOpts);
1353
1427
  } else if (display === 'carousel') {
1354
1428
  const {tpl, css, missing} = await loadOptionalBlockAssets(attrs.block);
1355
1429
  replacement = missing
@@ -1363,7 +1437,7 @@ export async function renderCollectionFragment(attrs, { extraFilter = null } = {
1363
1437
  blockCss: css,
1364
1438
  dataAttrs: attrs,
1365
1439
  emptyMsg,
1366
- });
1440
+ }, ctxOpts);
1367
1441
  } else if (display === 'listgroup') {
1368
1442
  const {tpl, css, missing} = await loadOptionalBlockAssets(attrs.block);
1369
1443
  replacement = missing
@@ -1379,7 +1453,7 @@ export async function renderCollectionFragment(attrs, { extraFilter = null } = {
1379
1453
  blockName: attrs.block || '',
1380
1454
  blockCss: css,
1381
1455
  emptyMsg,
1382
- });
1456
+ }, ctxOpts);
1383
1457
  } else if (display === 'block') {
1384
1458
  const blockName = attrs.block || '';
1385
1459
  if (blockName) {
@@ -1389,13 +1463,13 @@ export async function renderCollectionFragment(attrs, { extraFilter = null } = {
1389
1463
  loadBlockCss(blockName),
1390
1464
  ]);
1391
1465
  const cols = attrs.cols || '';
1392
- replacement = renderCollectionBlocks(entries, tpl, emptyMsg, ctaOpts, cols, blockName, css);
1466
+ replacement = renderCollectionBlocks(entries, tpl, emptyMsg, ctaOpts, cols, blockName, css, ctxOpts);
1393
1467
  } catch {
1394
1468
  replacement = blockNotFoundHtml(blockName);
1395
1469
  }
1396
1470
  }
1397
1471
  } else {
1398
- replacement = renderCollectionTable(slug, entries, fields, attrs, ctaOpts);
1472
+ replacement = renderCollectionTable(slug, entries, fields, attrs, ctaOpts, ctxOpts);
1399
1473
  }
1400
1474
  } catch {
1401
1475
  // Collection not found or read error — show empty message
@@ -3894,26 +3968,15 @@ async function processMenuBlocks(markdown, user, ctx = {}) {
3894
3968
 
3895
3969
  const depth = Number.parseInt(attrs.depth, 10);
3896
3970
  const capped = Number.isFinite(depth) && depth > 0 ? capDepth(items, depth) : items;
3897
- const klass = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
3898
- const variantClass = attrs.variant ? ` dm-menu--variant-${escapeAttr(attrs.variant)}` : '';
3899
- // Orientation: the shortcode attribute wins over the menu's own setting,
3900
- // so one placement can run horizontally while the menu is vertical in
3901
- // its slot. A menu that sets neither emits no orientation class at all
3902
- // and keeps the historic plain-nested-list rendering.
3903
- const orientation = ['horizontal', 'vertical'].includes(attrs.orientation)
3904
- ? attrs.orientation
3905
- : (['horizontal', 'vertical'].includes(menu.orientation) ? menu.orientation : null);
3906
- const orientClass = orientation ? ` dm-menu--${orientation}` : '';
3907
3971
  const decorated = await resolveMenuDecorations(capped);
3908
- // A floating menu pins itself to a viewport corner/edge. `float="no"`
3909
- // opts a single placement out, so the same menu can be floated in its
3910
- // slot yet rendered inline where the shortcode drops it.
3911
- const floatCss = attrs.float === 'no' ? '' : floatToCss(menu);
3912
- const floatClass = floatCss ? ' dm-menu-floating' : '';
3913
- const floatAttrs = floatCss
3914
- ? ` style="${escapeAttr(floatCss)}" data-float-anchor="${escapeAttr(menu.float?.anchor || 'TL')}"`
3915
- : '';
3916
- const html = `<nav class="dm-menu dm-menu--${escapeAttr(menu.slug)}${orientClass}${variantClass}${floatClass}${klass}" data-menu="${escapeAttr(menu.slug)}"${floatAttrs}>${renderMenuItemsAsUl(decorated)}</nav>`;
3972
+ // `orientation` overrides the menu for this one placement; `float="no"`
3973
+ // renders inline here even when the menu floats in its slot.
3974
+ const html = buildMenuNav(menu, decorated, {
3975
+ orientation: attrs.orientation,
3976
+ variant: attrs.variant,
3977
+ class: attrs.class,
3978
+ noFloat: attrs.float === 'no'
3979
+ });
3917
3980
  out = out.replace(m[0], html);
3918
3981
  }
3919
3982
  return out;
@@ -3942,32 +4005,6 @@ async function resolveProjectForUrl(urlPath) {
3942
4005
  }
3943
4006
  }
3944
4007
 
3945
- function renderMenuItemsAsUl(items) {
3946
- if (!items.length) return '';
3947
- const lis = items.map(it => {
3948
- // Separator items render as a list-style <hr> divider.
3949
- if (it && it.type === 'separator') return '<li class="dm-menu-sep" role="separator"><hr></li>';
3950
- const href = escapeAttr(it.url || '#');
3951
- const text = escapeAttr(it.text || '');
3952
- const child = Array.isArray(it.items) && it.items.length ? renderMenuItemsAsUl(it.items) : '';
3953
-
3954
- // Colour override (server sanitiser allows inline style on <a>).
3955
- const colourCss = it.colour ? colourToCss(it.colour) : '';
3956
- const styleAttr = colourCss ? ` style="color:${colourCss}"` : '';
3957
-
3958
- // Badge (static text or resolver-stamped count). Background from variant.
3959
- let badge = '';
3960
- if (it.badge && it.badge.text != null && it.badge.text !== '') {
3961
- const bg = it.badge.variant ? colourToCss(it.badge.variant) : '';
3962
- const badgeStyle = bg ? ` style="background:${bg};color:#fff"` : '';
3963
- badge = `<span class="dm-menu-badge"${badgeStyle}>${escapeAttr(String(it.badge.text))}</span>`;
3964
- }
3965
-
3966
- return `<li><a href="${href}"${styleAttr}>${text}${badge}</a>${child}</li>`;
3967
- }).join('');
3968
- return `<ul>${lis}</ul>`;
3969
- }
3970
-
3971
4008
  function capDepth(items, max, depth = 1) {
3972
4009
  return items.map(it => ({
3973
4010
  ...it,
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Menu → HTML.
3
+ *
4
+ * One builder, three callers: the `[menu]` shortcode, the overlay menus the
5
+ * renderer injects into the page shell, and anything a plugin wants to render
6
+ * itself. Keeping it here means a menu looks the same however it reaches the
7
+ * page — the shortcode and an overlay binding of the same menu produce byte-
8
+ * identical markup.
9
+ *
10
+ * Item filtering (visibility, `hidden`), depth capping and badge-count
11
+ * resolution happen before this — callers hand in the items they want drawn.
12
+ */
13
+ import {colourToCss, floatToCss} from './menus.js';
14
+
15
+ /** Attribute-safe escaping — mirrors escapeAttr in markdown.js. */
16
+ function esc(str) {
17
+ return String(str ?? '')
18
+ .replace(/&/g, '&amp;')
19
+ .replace(/"/g, '&quot;')
20
+ .replace(/</g, '&lt;')
21
+ .replace(/>/g, '&gt;');
22
+ }
23
+
24
+ /**
25
+ * Render a menu's items as a nested `<ul>`.
26
+ *
27
+ * Separators become a classed `<li><hr></li>`; colour, badge and nested
28
+ * children are drawn inline. Returns '' for an empty list so callers can
29
+ * decide whether an empty menu is worth a wrapper at all.
30
+ *
31
+ * @param {Array} items
32
+ * @returns {string}
33
+ */
34
+ export function renderMenuItemsAsUl(items) {
35
+ if (!items || !items.length) return '';
36
+ const lis = items.map(it => {
37
+ if (it && it.type === 'separator') return '<li class="dm-menu-sep" role="separator"><hr></li>';
38
+ const href = esc(it.url || '#');
39
+ const text = esc(it.text || '');
40
+ const child = Array.isArray(it.items) && it.items.length ? renderMenuItemsAsUl(it.items) : '';
41
+
42
+ // Colour override (the server sanitiser allows inline style on <a>).
43
+ const colourCss = it.colour ? colourToCss(it.colour) : '';
44
+ const styleAttr = colourCss ? ` style="color:${colourCss}"` : '';
45
+
46
+ // Badge — static text, or a count stamped on by resolveMenuDecorations.
47
+ let badge = '';
48
+ if (it.badge && it.badge.text != null && it.badge.text !== '') {
49
+ const bg = it.badge.variant ? colourToCss(it.badge.variant) : '';
50
+ const badgeStyle = bg ? ` style="background:${bg};color:#fff"` : '';
51
+ badge = `<span class="dm-menu-badge"${badgeStyle}>${esc(String(it.badge.text))}</span>`;
52
+ }
53
+
54
+ return `<li><a href="${href}"${styleAttr}>${text}${badge}</a>${child}</li>`;
55
+ }).join('');
56
+ return `<ul>${lis}</ul>`;
57
+ }
58
+
59
+ /**
60
+ * Build the full `<nav>` for a menu.
61
+ *
62
+ * @param {object} menu The menu record (slug, orientation, position, float)
63
+ * @param {Array} items Items to draw — already filtered/capped/decorated
64
+ * @param {object} [opts]
65
+ * @param {string} [opts.orientation] Override the menu's own orientation for this placement
66
+ * @param {string} [opts.variant] Adds `dm-menu--variant-<x>`
67
+ * @param {string} [opts.class] Extra classes on the wrapper
68
+ * @param {boolean}[opts.noFloat] Render inline even if the menu floats in its slot
69
+ * @returns {string}
70
+ */
71
+ export function buildMenuNav(menu, items, opts = {}) {
72
+ const klass = opts.class ? ` ${esc(opts.class)}` : '';
73
+ const variantClass = opts.variant ? ` dm-menu--variant-${esc(opts.variant)}` : '';
74
+
75
+ // A menu that declares no orientation emits no class at all and keeps the
76
+ // historic plain-nested-list rendering.
77
+ const orientation = ['horizontal', 'vertical'].includes(opts.orientation)
78
+ ? opts.orientation
79
+ : (['horizontal', 'vertical'].includes(menu.orientation) ? menu.orientation : null);
80
+ const orientClass = orientation ? ` dm-menu--${orientation}` : '';
81
+
82
+ const floatCss = opts.noFloat ? '' : floatToCss(menu);
83
+ const floatClass = floatCss ? ' dm-menu-floating' : '';
84
+ const floatAttrs = floatCss
85
+ ? ` style="${esc(floatCss)}" data-float-anchor="${esc(menu.float?.anchor || 'TL')}"`
86
+ : '';
87
+
88
+ return `<nav class="dm-menu dm-menu--${esc(menu.slug)}${orientClass}${variantClass}${floatClass}${klass}"`
89
+ + ` data-menu="${esc(menu.slug)}"${floatAttrs}>${renderMenuItemsAsUl(items)}</nav>`;
90
+ }
@@ -710,6 +710,11 @@ registerLocation('navbar', {label: 'Navbar', description: 'Site
710
710
  registerLocation('footer-primary', {label: 'Footer primary', description: 'Primary footer column', source: 'core', maxDepth: 1});
711
711
  registerLocation('footer-legal', {label: 'Footer legal', description: 'Legal/secondary footer column', source: 'core', maxDepth: 1});
712
712
  registerLocation('admin-sidebar', {label: 'Admin sidebar', description: 'The left-side navigation in the admin panel', source: 'core', maxDepth: null});
713
+ // The overlay slot is the "anywhere" placement: menus bound to it render on
714
+ // matching pages positioned by their own `float` config, with no navbar, no
715
+ // footer and no shortcode involved. Unlike the other slots it is not
716
+ // one-menu-wins — a page can carry several overlays at once.
717
+ registerLocation('overlay', {label: 'Page overlay', description: 'Floating menus placed on matching pages by their binding', source: 'core', maxDepth: null});
713
718
 
714
719
  // ---------------------------------------------------------------------------
715
720
  // Slot → menu-slug map
@@ -904,6 +909,47 @@ export async function resolveLocation(slot, user, ctx = {}) {
904
909
  };
905
910
  }
906
911
 
912
+ /** The slot whose menus are drawn over the page rather than in a fixed region. */
913
+ export const OVERLAY_SLOT = 'overlay';
914
+
915
+ /**
916
+ * Every overlay menu that applies to this page.
917
+ *
918
+ * Overlays are the "place a menu anywhere" path: instead of one menu winning a
919
+ * region, *all* menus whose binding matches the page render, each positioned by
920
+ * its own `float` config. A menu mapped to the overlay slot in
921
+ * menu-locations.json applies site-wide and joins the list.
922
+ *
923
+ * Menus left with no visible items after per-user filtering are dropped, so a
924
+ * fully gated overlay doesn't leave an empty box floating on the page.
925
+ *
926
+ * @param {{urlPath?: string, project?: string|null}} ctx
927
+ * @param {object|null} user
928
+ * @returns {Promise<object[]>} menus with filtered items, ordered by slug
929
+ */
930
+ export async function resolveOverlaysForPage(ctx = {}, user = null) {
931
+ const bySlug = new Map();
932
+
933
+ for (const menu of await getAllMenus()) {
934
+ if (menu?.binding?.slot !== OVERLAY_SLOT) continue;
935
+ if (!scoreBinding(menu.binding, ctx)) continue;
936
+ bySlug.set(menu.slug, menu);
937
+ }
938
+
939
+ // A site-wide overlay: mapped rather than bound, so it needs no page rules.
940
+ const mappedSlug = (await getLocations())[OVERLAY_SLOT];
941
+ if (mappedSlug && !bySlug.has(mappedSlug)) {
942
+ const mapped = await getMenu(mappedSlug);
943
+ if (mapped) bySlug.set(mapped.slug, mapped);
944
+ else console.warn(`[menus] Overlay slot maps to "${mappedSlug}" which doesn't exist`);
945
+ }
946
+
947
+ return [...bySlug.values()]
948
+ .map(menu => ({...menu, items: filterItemsForUser(menu.items || [], user)}))
949
+ .filter(menu => menu.items.length > 0)
950
+ .sort((a, b) => String(a.slug).localeCompare(String(b.slug)));
951
+ }
952
+
907
953
  function filterItemsForUser(items, user) {
908
954
  const out = [];
909
955
  for (const item of items) {