domma-cms 0.40.2 → 0.41.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.
@@ -3828,16 +3828,20 @@ function processSlideoverBlocks(markdown) {
3828
3828
  * [menu slug="my-menu" /]
3829
3829
  * [menu location="navbar" /]
3830
3830
  * [menu slug="..." depth="2" variant="..." class="..." /]
3831
+ * [menu slug="..." orientation="horizontal" /]
3831
3832
  *
3832
3833
  * Items flagged `hidden: true` are dropped. Items with a `visibility` field
3833
3834
  * are filtered against the supplied user via `checkVisibility`. The `depth`
3834
3835
  * attribute caps the nesting depth (1-indexed; depth="1" = top-level only).
3836
+ * `orientation` overrides the menu's own setting for this one placement.
3835
3837
  *
3836
3838
  * @param {string} markdown
3837
3839
  * @param {object|null} user
3840
+ * @param {{urlPath?: string, project?: string|null}} [ctx] - page context, so a
3841
+ * `location=` lookup honours menus bound to this page or project
3838
3842
  * @returns {Promise<string>}
3839
3843
  */
3840
- async function processMenuBlocks(markdown, user) {
3844
+ async function processMenuBlocks(markdown, user, ctx = {}) {
3841
3845
  const re = /\[menu(\s+[^\]]*?)?\s*\/\]/gi;
3842
3846
  const matches = [...markdown.matchAll(re)];
3843
3847
  if (!matches.length) return markdown;
@@ -3847,7 +3851,7 @@ async function processMenuBlocks(markdown, user) {
3847
3851
  const attrs = parseShortcodeAttrs(m[1] || '');
3848
3852
  let menu = null;
3849
3853
  if (attrs.slug) menu = await getMenu(attrs.slug);
3850
- else if (attrs.location) menu = await resolveLocation(attrs.location, user || null);
3854
+ else if (attrs.location) menu = await resolveLocation(attrs.location, user || null, ctx);
3851
3855
  if (!menu) { out = out.replace(m[0], ''); continue; }
3852
3856
 
3853
3857
  // The slug path skipped resolveLocation, so filter visibility + hidden manually here.
@@ -3859,6 +3863,14 @@ async function processMenuBlocks(markdown, user) {
3859
3863
  const capped = Number.isFinite(depth) && depth > 0 ? capDepth(items, depth) : items;
3860
3864
  const klass = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
3861
3865
  const variantClass = attrs.variant ? ` dm-menu--variant-${escapeAttr(attrs.variant)}` : '';
3866
+ // Orientation: the shortcode attribute wins over the menu's own setting,
3867
+ // so one placement can run horizontally while the menu is vertical in
3868
+ // its slot. A menu that sets neither emits no orientation class at all
3869
+ // and keeps the historic plain-nested-list rendering.
3870
+ const orientation = ['horizontal', 'vertical'].includes(attrs.orientation)
3871
+ ? attrs.orientation
3872
+ : (['horizontal', 'vertical'].includes(menu.orientation) ? menu.orientation : null);
3873
+ const orientClass = orientation ? ` dm-menu--${orientation}` : '';
3862
3874
  const decorated = await resolveMenuDecorations(capped);
3863
3875
  // A floating menu pins itself to a viewport corner/edge. `float="no"`
3864
3876
  // opts a single placement out, so the same menu can be floated in its
@@ -3868,12 +3880,30 @@ async function processMenuBlocks(markdown, user) {
3868
3880
  const floatAttrs = floatCss
3869
3881
  ? ` style="${escapeAttr(floatCss)}" data-float-anchor="${escapeAttr(menu.float?.anchor || 'TL')}"`
3870
3882
  : '';
3871
- const html = `<nav class="dm-menu dm-menu--${escapeAttr(menu.slug)}${variantClass}${floatClass}${klass}" data-menu="${escapeAttr(menu.slug)}"${floatAttrs}>${renderMenuItemsAsUl(decorated)}</nav>`;
3883
+ const html = `<nav class="dm-menu dm-menu--${escapeAttr(menu.slug)}${orientClass}${variantClass}${floatClass}${klass}" data-menu="${escapeAttr(menu.slug)}"${floatAttrs}>${renderMenuItemsAsUl(decorated)}</nav>`;
3872
3884
  out = out.replace(m[0], html);
3873
3885
  }
3874
3886
  return out;
3875
3887
  }
3876
3888
 
3889
+ /**
3890
+ * Project a URL belongs to, for menu-binding lookups. Dynamically imported so
3891
+ * the markdown service keeps no load-time dependency on the projects service.
3892
+ * Failures resolve to null — a missing project only means project bindings
3893
+ * don't match, never a broken page.
3894
+ *
3895
+ * @param {string} urlPath
3896
+ * @returns {Promise<string|null>}
3897
+ */
3898
+ async function resolveProjectForUrl(urlPath) {
3899
+ try {
3900
+ const {getProjectForPage} = await import('./projects.js');
3901
+ return await getProjectForPage(urlPath);
3902
+ } catch {
3903
+ return null;
3904
+ }
3905
+ }
3906
+
3877
3907
  function renderMenuItemsAsUl(items) {
3878
3908
  if (!items.length) return '';
3879
3909
  const lis = items.map(it => {
@@ -3987,6 +4017,10 @@ function processCtaBlocks(markdown) {
3987
4017
  * @param {object|null} [opts.user] - Authenticated user (`{role, additionalRoles}`)
3988
4018
  * or null for anonymous. Used to filter menu items gated by `visibility` in
3989
4019
  * the `[menu]` shortcode. Backwards compatible — defaults to anonymous.
4020
+ * @param {string} [opts.urlPath] - URL of the page being parsed. Lets
4021
+ * `[menu location="…"]` honour menus bound to this page or its project.
4022
+ * @param {string|null} [opts.project] - Project the page belongs to; resolved
4023
+ * from `urlPath` when omitted.
3990
4024
  * @returns {{ data: object, content: string, html: string }}
3991
4025
  */
3992
4026
  export async function parseMarkdown(raw, opts = {}) {
@@ -4003,7 +4037,10 @@ export async function parseMarkdown(raw, opts = {}) {
4003
4037
  const withCollection = await processCollectionBlocks(withComponents, tagSet);
4004
4038
  const withView = await processViewBlocks(withCollection, tagSet);
4005
4039
  const withStaticBlock = await processStaticBlocks(withView, tagSet);
4006
- const withMenu = await processMenuBlocks(withStaticBlock, opts.user || null);
4040
+ const menuCtx = opts.urlPath
4041
+ ? {urlPath: opts.urlPath, project: opts.project ?? await resolveProjectForUrl(opts.urlPath)}
4042
+ : {};
4043
+ const withMenu = await processMenuBlocks(withStaticBlock, opts.user || null, menuCtx);
4007
4044
  const withDconfig = processDConfigBlocks(withMenu);
4008
4045
  const withEffects = processEffectsBlocks(withDconfig);
4009
4046
  const withPluginShortcodes = await processPluginShortcodes(withEffects);
@@ -12,6 +12,7 @@
12
12
  import fs from 'fs/promises';
13
13
  import path from 'path';
14
14
  import {fileURLToPath} from 'url';
15
+ import {invalidateMenuIndex} from './menus.js';
15
16
 
16
17
  const __filename = fileURLToPath(import.meta.url);
17
18
  const DEFAULT_CONFIG_DIR = path.resolve(path.dirname(__filename), '..', '..', 'config');
@@ -27,6 +28,9 @@ async function readJson(p) {
27
28
 
28
29
  async function writeJson(p, data) {
29
30
  await fs.writeFile(p, JSON.stringify(data, null, 2) + '\n', 'utf8');
31
+ // Menu files written outside the menus service — drop its cached index so
32
+ // binding resolution sees the migrated menus.
33
+ invalidateMenuIndex();
30
34
  }
31
35
 
32
36
  /**
@@ -94,12 +94,54 @@ export const FLOAT_ANCHORS = new Set(['TL', 'TR', 'BL', 'BR', 'L', 'R']);
94
94
 
95
95
  export const FLOAT_DEFAULTS = {anchor: 'TL', offsetX: 16, offsetY: 16, zIndex: 150};
96
96
 
97
+ // Panel surface options for a floating menu. Each maps to a theme token rather
98
+ // than a literal, so a floating panel still tracks the active theme.
99
+ // Keep FLOAT_SHADOWS / floatSurfaceCss in sync with public/js/menu-decor.mjs.
100
+ export const FLOAT_SHADOWS = {
101
+ none: 'none',
102
+ sm: 'var(--dm-shadow-sm)',
103
+ md: 'var(--dm-shadow-md)',
104
+ lg: 'var(--dm-shadow-lg)',
105
+ xl: 'var(--dm-shadow-xl)'
106
+ };
107
+
97
108
  function floatNumber(value, fallback, min, max) {
98
109
  const n = Number(value);
99
110
  if (!Number.isFinite(n)) return fallback;
100
111
  return Math.min(max, Math.max(min, Math.round(n)));
101
112
  }
102
113
 
114
+ /**
115
+ * Surface declarations for a floating panel — corner rounding, drop shadow and
116
+ * accent colour. Everything is optional: an omitted field falls through to the
117
+ * `.dm-menu-floating` defaults in site.css rather than being frozen inline.
118
+ *
119
+ * The accent both tints the border and publishes `--dm-menu-accent`, which
120
+ * site.css uses to draw the leading edge stripe.
121
+ *
122
+ * @param {object} f - the menu's `float` block
123
+ * @returns {string[]} CSS declarations
124
+ */
125
+ function floatSurfaceCss(f) {
126
+ const decls = [];
127
+ if (f.radius != null && f.radius !== '') {
128
+ decls.push(`border-radius:${floatNumber(f.radius, 8, 0, 48)}px`);
129
+ }
130
+ if (f.shadow != null && f.shadow !== '' && Object.prototype.hasOwnProperty.call(FLOAT_SHADOWS, f.shadow)) {
131
+ decls.push(`box-shadow:${FLOAT_SHADOWS[f.shadow]}`);
132
+ }
133
+ if (f.accent && isValidColour(f.accent)) {
134
+ const css = colourToCss(f.accent);
135
+ decls.push(`--dm-menu-accent:${css}`, `border-color:${css}`);
136
+ }
137
+ if (f.opacity != null && f.opacity !== '') {
138
+ // Panel translucency, not item translucency — the backdrop filter in
139
+ // site.css keeps the text readable over busy page content.
140
+ decls.push(`--dm-menu-panel-opacity:${floatNumber(f.opacity, 100, 20, 100) / 100}`);
141
+ }
142
+ return decls;
143
+ }
144
+
103
145
  /**
104
146
  * Build the inline CSS for a floating menu. Returns '' for any menu that is
105
147
  * not floating, so callers can apply it unconditionally.
@@ -137,9 +179,70 @@ export function floatToCss(menu) {
137
179
  // A pinned panel must never grow past the viewport — it scrolls instead.
138
180
  decls.push(`max-height:calc(100vh - ${(anchor === 'L' || anchor === 'R') ? 32 : y * 2}px)`);
139
181
 
182
+ decls.push(...floatSurfaceCss(f));
183
+
140
184
  return decls.join(';');
141
185
  }
142
186
 
187
+ // ---------------------------------------------------------------------------
188
+ // Orientation — how the menu flows where it renders. `horizontal` is the
189
+ // historic behaviour and stays the default for every menu without the field.
190
+ // `vertical` stacks the items into a column: a side rail for the navbar slot,
191
+ // a stacked list for `[menu]` and floating panels.
192
+ //
193
+ // `side` only means anything for a vertical menu in the navbar slot — it picks
194
+ // the viewport edge the rail docks to.
195
+ // ---------------------------------------------------------------------------
196
+ export const ORIENTATIONS = new Set(['horizontal', 'vertical']);
197
+ export const SIDES = new Set(['left', 'right']);
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Bindings — a menu can claim a slot for a subset of the site instead of being
201
+ // mapped to it globally in menu-locations.json. `binding.slot` names the slot
202
+ // it takes over; `binding.projects` and `binding.pages` say where.
203
+ //
204
+ // {"slot": "navbar", "projects": ["shop"], "pages": ["/about", "/docs/*"]}
205
+ //
206
+ // Match precedence (see resolveBoundMenu): an exact page URL beats a page glob,
207
+ // a page glob beats a project, and any binding beats the menu-locations
208
+ // default. Ties break on the longest pattern, then slug, so resolution is
209
+ // deterministic regardless of directory order.
210
+ // ---------------------------------------------------------------------------
211
+
212
+ /** A page pattern is a URL path, optionally ending in a `/*` wildcard. */
213
+ const PAGE_PATTERN_RE = /^\/(?:[^\s?#*]*)?(?:\/\*)?$|^\/\*$/;
214
+
215
+ /** True if `pattern` is a well-formed page pattern. */
216
+ export function isValidPagePattern(pattern) {
217
+ return typeof pattern === 'string' && pattern !== '' && PAGE_PATTERN_RE.test(pattern);
218
+ }
219
+
220
+ /**
221
+ * Match a page pattern against a URL path.
222
+ *
223
+ * `/about` matches only `/about`.
224
+ * `/docs/*` matches `/docs` and everything beneath it.
225
+ * `/*` matches every page.
226
+ *
227
+ * Trailing slashes on the URL are ignored (`/about/` === `/about`), matching
228
+ * how the public router resolves pages.
229
+ *
230
+ * @param {string} pattern
231
+ * @param {string} urlPath
232
+ * @returns {boolean}
233
+ */
234
+ export function matchesPagePattern(pattern, urlPath) {
235
+ if (typeof pattern !== 'string' || typeof urlPath !== 'string') return false;
236
+ const url = urlPath.length > 1 ? urlPath.replace(/\/+$/, '') : urlPath;
237
+ if (pattern === '/*') return true;
238
+ if (pattern.endsWith('/*')) {
239
+ const prefix = pattern.slice(0, -2) || '/';
240
+ return url === prefix || url.startsWith(prefix === '/' ? '/' : prefix + '/');
241
+ }
242
+ const exact = pattern.length > 1 ? pattern.replace(/\/+$/, '') : pattern;
243
+ return url === exact;
244
+ }
245
+
143
246
  /**
144
247
  * Walk a menu tree and resolve live badge counts. For any item whose
145
248
  * `badge.countFrom` names a collection, the entry count replaces `badge.text`.
@@ -196,6 +299,7 @@ async function readMenuFile(slug) {
196
299
  async function writeMenuFile(menu) {
197
300
  await ensureMenusDir();
198
301
  await fs.writeFile(menuPath(menu.slug), JSON.stringify(menu, null, 2) + '\n', 'utf8');
302
+ invalidateMenuIndex();
199
303
  // Menu content / behaviour / style changed — drop the cached public pages.
200
304
  await invalidateNavCache();
201
305
  }
@@ -222,6 +326,7 @@ export async function listMenus() {
222
326
  name: menu.name || '',
223
327
  description: menu.description || '',
224
328
  itemsCount: Array.isArray(menu.items) ? menu.items.length : 0,
329
+ ...(menu.binding && {binding: menu.binding}),
225
330
  meta: menu.meta || {}
226
331
  });
227
332
  } catch (err) {
@@ -262,7 +367,10 @@ export async function createMenu(input) {
262
367
  if (await getMenu(input.slug)) {
263
368
  throw new Error(`Menu "${input.slug}" already exists`);
264
369
  }
265
- const errors = validateMenu(input);
370
+ // Normalise the binding first so validation judges what will actually be
371
+ // written — surrounding whitespace and duplicates are tidied, not rejected.
372
+ const binding = normaliseBinding(input.binding);
373
+ const errors = validateMenu({...input, binding});
266
374
  if (errors.length) {
267
375
  throw new Error('Validation failed: ' + errors.join('; '));
268
376
  }
@@ -273,9 +381,12 @@ export async function createMenu(input) {
273
381
  description: input.description || '',
274
382
  ...(input.variant != null && {variant: input.variant}),
275
383
  ...(input.position != null && {position: input.position}),
384
+ ...(input.orientation != null && {orientation: input.orientation}),
385
+ ...(input.side != null && {side: input.side}),
276
386
  ...(input.appearOnHover != null && {appearOnHover: !!input.appearOnHover}),
277
387
  ...(input.float != null && {float: input.float}),
278
388
  ...(input.style != null && {style: input.style}),
389
+ ...(binding != null && {binding}),
279
390
  items: Array.isArray(input.items) ? input.items : [],
280
391
  meta: {
281
392
  createdAt: now,
@@ -301,7 +412,8 @@ export async function createMenu(input) {
301
412
  export async function updateMenu(slug, input) {
302
413
  const existing = await getMenu(slug);
303
414
  if (!existing) throw new Error(`Menu "${slug}" not found`);
304
- const errors = validateMenu({...input, slug});
415
+ const binding = normaliseBinding(input.binding);
416
+ const errors = validateMenu({...input, slug, binding});
305
417
  if (errors.length) {
306
418
  throw new Error('Validation failed: ' + errors.join('; '));
307
419
  }
@@ -312,9 +424,12 @@ export async function updateMenu(slug, input) {
312
424
  description: input.description || '',
313
425
  ...(input.variant != null && {variant: input.variant}),
314
426
  ...(input.position != null && {position: input.position}),
427
+ ...(input.orientation != null && {orientation: input.orientation}),
428
+ ...(input.side != null && {side: input.side}),
315
429
  ...(input.appearOnHover != null && {appearOnHover: !!input.appearOnHover}),
316
430
  ...(input.float != null && {float: input.float}),
317
431
  ...(input.style != null && {style: input.style}),
432
+ ...(binding != null && {binding}),
318
433
  items: Array.isArray(input.items) ? input.items : [],
319
434
  meta: {
320
435
  ...existing.meta,
@@ -339,6 +454,7 @@ export async function deleteMenu(slug) {
339
454
  if (!validateSlug(slug)) return false;
340
455
  try {
341
456
  await fs.unlink(menuPath(slug));
457
+ invalidateMenuIndex();
342
458
  await invalidateNavCache();
343
459
  return true;
344
460
  } catch (err) {
@@ -367,6 +483,9 @@ export async function duplicateMenu(slug) {
367
483
  ...source,
368
484
  slug: candidate,
369
485
  name: source.name + ' (copy)',
486
+ // Bindings are deliberately NOT copied: two menus claiming the same
487
+ // slot on the same pages is a conflict the admin never asked for.
488
+ binding: null,
370
489
  meta: {bundled: false, presetOwner: null}
371
490
  });
372
491
  }
@@ -374,6 +493,32 @@ export async function duplicateMenu(slug) {
374
493
  const POSITIONS = new Set(['sticky', 'fixed', 'static', 'floating']);
375
494
  const URL_PREFIX_RE = /^(\/|#|https?:\/\/|mailto:)/;
376
495
 
496
+ /**
497
+ * Coerce a binding block into its persisted shape: a slot plus de-duplicated,
498
+ * trimmed project/page lists. Empty lists are dropped so a binding that targets
499
+ * nothing doesn't linger in the file as noise.
500
+ *
501
+ * @param {object} binding
502
+ * @returns {object|undefined} undefined when the binding claims no slot
503
+ */
504
+ function normaliseBinding(binding) {
505
+ if (!binding || typeof binding !== 'object' || Array.isArray(binding)) return undefined;
506
+ const slot = typeof binding.slot === 'string' ? binding.slot.trim() : '';
507
+ if (!slot) return undefined;
508
+ const clean = (list) => [...new Set(
509
+ (Array.isArray(list) ? list : [])
510
+ .map(v => typeof v === 'string' ? v.trim() : '')
511
+ .filter(Boolean)
512
+ )];
513
+ const projects = clean(binding.projects);
514
+ const pages = clean(binding.pages);
515
+ return {
516
+ slot,
517
+ ...(projects.length && {projects}),
518
+ ...(pages.length && {pages})
519
+ };
520
+ }
521
+
377
522
  /**
378
523
  * Validate a menu object. Returns an array of human-readable error strings
379
524
  * (empty on success). Performs structural + per-item recursive checks; does
@@ -394,6 +539,38 @@ export function validateMenu(menu) {
394
539
  if (menu.position != null && !POSITIONS.has(menu.position)) {
395
540
  errors.push(`Invalid position "${menu.position}" — expected one of: ${[...POSITIONS].join(', ')}`);
396
541
  }
542
+ if (menu.orientation != null && !ORIENTATIONS.has(menu.orientation)) {
543
+ errors.push(`Invalid orientation "${menu.orientation}" — expected one of: ${[...ORIENTATIONS].join(', ')}`);
544
+ }
545
+ if (menu.side != null && !SIDES.has(menu.side)) {
546
+ errors.push(`Invalid side "${menu.side}" — expected one of: ${[...SIDES].join(', ')}`);
547
+ }
548
+ if (menu.binding != null) {
549
+ if (typeof menu.binding !== 'object' || Array.isArray(menu.binding)) {
550
+ errors.push('binding must be an object');
551
+ } else {
552
+ const {slot, projects, pages} = menu.binding;
553
+ // A binding with no slot is inert rather than invalid — the admin
554
+ // sends an empty block when the feature is switched off — but a
555
+ // slot that IS present has to be a usable slot name.
556
+ if (slot != null && slot !== '' && !validateSlug(slot)) {
557
+ errors.push(`binding.slot "${slot}" — lowercase alphanumeric + hyphen only`);
558
+ }
559
+ for (const [key, list] of [['projects', projects], ['pages', pages]]) {
560
+ if (list == null) continue;
561
+ if (!Array.isArray(list)) { errors.push(`binding.${key} must be an array`); continue; }
562
+ for (const v of list) {
563
+ if (typeof v !== 'string') { errors.push(`binding.${key} entries must be strings`); continue; }
564
+ if (key === 'projects' && !validateSlug(v)) {
565
+ errors.push(`binding.projects "${v}" is not a valid project slug`);
566
+ }
567
+ if (key === 'pages' && !isValidPagePattern(v)) {
568
+ errors.push(`binding.pages "${v}" must be a URL path, optionally ending in /*`);
569
+ }
570
+ }
571
+ }
572
+ }
573
+ }
397
574
  if (menu.float != null) {
398
575
  if (typeof menu.float !== 'object' || Array.isArray(menu.float)) {
399
576
  errors.push('float must be an object');
@@ -401,12 +578,19 @@ export function validateMenu(menu) {
401
578
  if (menu.float.anchor != null && !FLOAT_ANCHORS.has(menu.float.anchor)) {
402
579
  errors.push(`float.anchor must be one of: ${[...FLOAT_ANCHORS].join(', ')}`);
403
580
  }
404
- for (const key of ['offsetX', 'offsetY', 'width', 'zIndex']) {
581
+ for (const key of ['offsetX', 'offsetY', 'width', 'zIndex', 'radius', 'opacity']) {
405
582
  const v = menu.float[key];
406
583
  if (v != null && v !== '' && !Number.isFinite(Number(v))) {
407
584
  errors.push(`float.${key} must be a number`);
408
585
  }
409
586
  }
587
+ if (menu.float.shadow != null && menu.float.shadow !== ''
588
+ && !Object.prototype.hasOwnProperty.call(FLOAT_SHADOWS, menu.float.shadow)) {
589
+ errors.push(`float.shadow must be one of: ${Object.keys(FLOAT_SHADOWS).join(', ')}`);
590
+ }
591
+ if (menu.float.accent != null && menu.float.accent !== '' && !isValidColour(menu.float.accent)) {
592
+ errors.push('float.accent must be a preset key or #rrggbb');
593
+ }
410
594
  }
411
595
  }
412
596
  if (!Array.isArray(menu.items)) {
@@ -578,25 +762,141 @@ export async function setLocations(map) {
578
762
  // ---------------------------------------------------------------------------
579
763
 
580
764
  /**
581
- * Resolve the menu mapped to `slot` for `user`, with items filtered by
582
- * `hidden` (always dropped) and `visibility` (per-user role gating).
583
- * Returns null if the slot is unmapped or the mapped menu is missing.
765
+ * Every menu on disk, cached in memory. Bound-menu resolution has to consider
766
+ * all of them on each render, and the alternative — re-reading the directory
767
+ * three times per uncached page — is wasteful for a set of files that only
768
+ * changes when an admin saves a menu.
769
+ *
770
+ * Every write path in this module invalidates the cache; anything writing menu
771
+ * files directly (the boot migrations) must call `invalidateMenuIndex()`.
772
+ */
773
+ let _menuIndex = null;
774
+
775
+ /** Drop the cached menu index — call after writing menu files out-of-band. */
776
+ export function invalidateMenuIndex() {
777
+ _menuIndex = null;
778
+ }
779
+
780
+ /**
781
+ * Read every menu file, memoised. Malformed files are skipped with a warning,
782
+ * exactly as listMenus() does.
783
+ *
784
+ * @returns {Promise<object[]>} full menu records, sorted by slug
785
+ */
786
+ export async function getAllMenus() {
787
+ if (_menuIndex) return _menuIndex;
788
+ await ensureMenusDir();
789
+ const files = await fs.readdir(MENUS_DIR);
790
+ const out = [];
791
+ for (const file of files) {
792
+ if (!file.endsWith('.json')) continue;
793
+ const slug = file.slice(0, -5);
794
+ if (!validateSlug(slug)) continue;
795
+ try {
796
+ out.push(await readMenuFile(slug));
797
+ } catch (err) {
798
+ console.warn(`[menus] Skipping malformed menu "${slug}": ${err.message}`);
799
+ }
800
+ }
801
+ out.sort((a, b) => String(a.slug).localeCompare(String(b.slug)));
802
+ _menuIndex = out;
803
+ return out;
804
+ }
805
+
806
+ // Match strengths, most specific first. Exact page > page glob > project.
807
+ const MATCH_PAGE_EXACT = 3;
808
+ const MATCH_PAGE_GLOB = 2;
809
+ const MATCH_PROJECT = 1;
810
+
811
+ /**
812
+ * Score a menu's binding against the current page context.
813
+ *
814
+ * @param {object} binding
815
+ * @param {{urlPath?: string, project?: string|null}} ctx
816
+ * @returns {{strength: number, specificity: number}|null} null when it doesn't apply
817
+ */
818
+ function scoreBinding(binding, ctx) {
819
+ let strength = 0;
820
+ let specificity = 0;
821
+ if (ctx.urlPath) {
822
+ for (const pattern of binding.pages || []) {
823
+ if (!matchesPagePattern(pattern, ctx.urlPath)) continue;
824
+ const s = pattern.endsWith('/*') ? MATCH_PAGE_GLOB : MATCH_PAGE_EXACT;
825
+ if (s > strength || (s === strength && pattern.length > specificity)) {
826
+ strength = s;
827
+ specificity = Math.max(specificity, pattern.length);
828
+ }
829
+ }
830
+ }
831
+ if (strength === 0 && ctx.project && (binding.projects || []).includes(ctx.project)) {
832
+ strength = MATCH_PROJECT;
833
+ specificity = ctx.project.length;
834
+ }
835
+ return strength ? {strength, specificity} : null;
836
+ }
837
+
838
+ /**
839
+ * Find the menu whose binding claims `slot` for this page, if any.
840
+ *
841
+ * Bindings are how a menu takes a slot over for part of the site: the project
842
+ * a page belongs to, or a list of page URL patterns. The most specific match
843
+ * wins (exact page > page glob > project); ties break on the longest pattern
844
+ * and then the slug, so the result never depends on directory order.
845
+ *
846
+ * @param {string} slot
847
+ * @param {{urlPath?: string, project?: string|null}} [ctx]
848
+ * @returns {Promise<object|null>} the winning menu record, or null
849
+ */
850
+ export async function resolveBoundMenu(slot, ctx = {}) {
851
+ if (!slot || (!ctx.urlPath && !ctx.project)) return null;
852
+ let best = null;
853
+ let bestScore = null;
854
+ for (const menu of await getAllMenus()) {
855
+ if (menu?.binding?.slot !== slot) continue;
856
+ const score = scoreBinding(menu.binding, ctx);
857
+ if (!score) continue;
858
+ const better = !bestScore
859
+ || score.strength > bestScore.strength
860
+ || (score.strength === bestScore.strength && score.specificity > bestScore.specificity)
861
+ || (score.strength === bestScore.strength && score.specificity === bestScore.specificity
862
+ && String(menu.slug) < String(best.slug));
863
+ if (better) {
864
+ best = menu;
865
+ bestScore = score;
866
+ }
867
+ }
868
+ return best;
869
+ }
870
+
871
+ /**
872
+ * Resolve the menu that should render in `slot` for `user`, with items filtered
873
+ * by `hidden` (always dropped) and `visibility` (per-user role gating).
874
+ * Returns null if nothing claims the slot or the mapped menu is missing.
875
+ *
876
+ * A menu bound to the current page or project (see `resolveBoundMenu`) takes
877
+ * the slot over; otherwise the `menu-locations.json` mapping applies. Callers
878
+ * that know the page they are rendering should pass `ctx` — without it only the
879
+ * global mapping is considered, which is the pre-binding behaviour.
584
880
  *
585
881
  * Note: `user` may be null (anonymous). `user.role` and
586
882
  * `user.additionalRoles` are honoured via `checkVisibility`.
587
883
  *
588
884
  * @param {string} slot
589
885
  * @param {object|null} user
886
+ * @param {{urlPath?: string, project?: string|null}} [ctx] - current page context
590
887
  * @returns {Promise<object|null>}
591
888
  */
592
- export async function resolveLocation(slot, user) {
593
- const map = await getLocations();
594
- const slug = map[slot];
595
- if (!slug) return null;
596
- const menu = await getMenu(slug);
889
+ export async function resolveLocation(slot, user, ctx = {}) {
890
+ let menu = await resolveBoundMenu(slot, ctx);
597
891
  if (!menu) {
598
- console.warn(`[menus] Slot "${slot}" maps to "${slug}" which doesn't exist`);
599
- return null;
892
+ const map = await getLocations();
893
+ const slug = map[slot];
894
+ if (!slug) return null;
895
+ menu = await getMenu(slug);
896
+ if (!menu) {
897
+ console.warn(`[menus] Slot "${slot}" maps to "${slug}" which doesn't exist`);
898
+ return null;
899
+ }
600
900
  }
601
901
  return {
602
902
  ...menu,