domma-cms 0.40.2 → 0.41.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.
@@ -1423,8 +1423,17 @@ export function parseShortcodeAttrs(attrStr) {
1423
1423
  for (const [, key, dq, sq] of attrStr.matchAll(/([\w-]+)=(?:"([^"]*)"|'([^']*)')/g)) {
1424
1424
  attrs[key] = dq ?? sq ?? '';
1425
1425
  }
1426
- // Pass 2: standalone flag attributes (no value) — blank out key=value matches first
1427
- const stripped = attrStr.replace(/([\w-]+)=(?:"[^"]*"|'[^']*')/g, m => ' '.repeat(m.length));
1426
+ // Pass 2: unquoted key=value (a value with no whitespace or quotes). Authors
1427
+ // type `slug=main` constantly; without this pass it parsed as the two flags
1428
+ // `slug` and `main`, so the shortcode silently lost its argument — and a
1429
+ // [menu] with a boolean slug then resolved to nothing and vanished.
1430
+ let stripped = attrStr.replace(/([\w-]+)=(?:"[^"]*"|'[^']*')/g, m => ' '.repeat(m.length));
1431
+ for (const [, key, val] of stripped.matchAll(/([\w-]+)=([^\s"'\]]+)/g)) {
1432
+ if (!(key in attrs)) attrs[key] = val;
1433
+ }
1434
+ stripped = stripped.replace(/([\w-]+)=([^\s"'\]]+)/g, m => ' '.repeat(m.length));
1435
+
1436
+ // Pass 3: standalone flag attributes (no value at all).
1428
1437
  for (const [, key] of stripped.matchAll(/\b([\w-]+)\b/g)) {
1429
1438
  if (!(key in attrs)) attrs[key] = true;
1430
1439
  }
@@ -3828,16 +3837,20 @@ function processSlideoverBlocks(markdown) {
3828
3837
  * [menu slug="my-menu" /]
3829
3838
  * [menu location="navbar" /]
3830
3839
  * [menu slug="..." depth="2" variant="..." class="..." /]
3840
+ * [menu slug="..." orientation="horizontal" /]
3831
3841
  *
3832
3842
  * Items flagged `hidden: true` are dropped. Items with a `visibility` field
3833
3843
  * are filtered against the supplied user via `checkVisibility`. The `depth`
3834
3844
  * attribute caps the nesting depth (1-indexed; depth="1" = top-level only).
3845
+ * `orientation` overrides the menu's own setting for this one placement.
3835
3846
  *
3836
3847
  * @param {string} markdown
3837
3848
  * @param {object|null} user
3849
+ * @param {{urlPath?: string, project?: string|null}} [ctx] - page context, so a
3850
+ * `location=` lookup honours menus bound to this page or project
3838
3851
  * @returns {Promise<string>}
3839
3852
  */
3840
- async function processMenuBlocks(markdown, user) {
3853
+ async function processMenuBlocks(markdown, user, ctx = {}) {
3841
3854
  const re = /\[menu(\s+[^\]]*?)?\s*\/\]/gi;
3842
3855
  const matches = [...markdown.matchAll(re)];
3843
3856
  if (!matches.length) return markdown;
@@ -3845,10 +3858,34 @@ async function processMenuBlocks(markdown, user) {
3845
3858
  let out = markdown;
3846
3859
  for (const m of matches) {
3847
3860
  const attrs = parseShortcodeAttrs(m[1] || '');
3861
+ // A bare `slug` / `location` with no value parses as the boolean `true`
3862
+ // (flag syntax). Treat that as "not supplied" rather than looking up a
3863
+ // menu called "true" — otherwise the shortcode vanished with no clue why.
3864
+ const slugAttr = typeof attrs.slug === 'string' ? attrs.slug : '';
3865
+ const locationAttr = typeof attrs.location === 'string' ? attrs.location : '';
3866
+
3867
+ if (!slugAttr && !locationAttr) {
3868
+ out = out.replace(m[0], menuError('[menu] needs a slug or location — e.g. [menu slug="main" /]'));
3869
+ continue;
3870
+ }
3871
+
3848
3872
  let menu = null;
3849
- if (attrs.slug) menu = await getMenu(attrs.slug);
3850
- else if (attrs.location) menu = await resolveLocation(attrs.location, user || null);
3851
- if (!menu) { out = out.replace(m[0], ''); continue; }
3873
+ if (slugAttr) menu = await getMenu(slugAttr);
3874
+ else if (locationAttr) menu = await resolveLocation(locationAttr, user || null, ctx);
3875
+
3876
+ if (!menu) {
3877
+ if (slugAttr) {
3878
+ // A named menu that doesn't exist is always an authoring error,
3879
+ // so say so on the page the way [form] and [block] do.
3880
+ out = out.replace(m[0], menuError(`Menu not found: ${slugAttr}`));
3881
+ } else {
3882
+ // An unmapped slot is a legitimate configuration state (no menu
3883
+ // assigned yet) — render nothing, but leave a trace in the log.
3884
+ console.warn(`[menu] location "${locationAttr}" resolves to no menu — rendering nothing`);
3885
+ out = out.replace(m[0], '');
3886
+ }
3887
+ continue;
3888
+ }
3852
3889
 
3853
3890
  // The slug path skipped resolveLocation, so filter visibility + hidden manually here.
3854
3891
  const items = attrs.slug
@@ -3859,6 +3896,14 @@ async function processMenuBlocks(markdown, user) {
3859
3896
  const capped = Number.isFinite(depth) && depth > 0 ? capDepth(items, depth) : items;
3860
3897
  const klass = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
3861
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}` : '';
3862
3907
  const decorated = await resolveMenuDecorations(capped);
3863
3908
  // A floating menu pins itself to a viewport corner/edge. `float="no"`
3864
3909
  // opts a single placement out, so the same menu can be floated in its
@@ -3868,12 +3913,35 @@ async function processMenuBlocks(markdown, user) {
3868
3913
  const floatAttrs = floatCss
3869
3914
  ? ` style="${escapeAttr(floatCss)}" data-float-anchor="${escapeAttr(menu.float?.anchor || 'TL')}"`
3870
3915
  : '';
3871
- const html = `<nav class="dm-menu dm-menu--${escapeAttr(menu.slug)}${variantClass}${floatClass}${klass}" data-menu="${escapeAttr(menu.slug)}"${floatAttrs}>${renderMenuItemsAsUl(decorated)}</nav>`;
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>`;
3872
3917
  out = out.replace(m[0], html);
3873
3918
  }
3874
3919
  return out;
3875
3920
  }
3876
3921
 
3922
+ /** Visible diagnostic for an unusable [menu] shortcode — mirrors [form]'s. */
3923
+ function menuError(message) {
3924
+ return `<div class="dm-menu-missing" role="note"><em>${escapeHtmlText(message)}</em></div>`;
3925
+ }
3926
+
3927
+ /**
3928
+ * Project a URL belongs to, for menu-binding lookups. Dynamically imported so
3929
+ * the markdown service keeps no load-time dependency on the projects service.
3930
+ * Failures resolve to null — a missing project only means project bindings
3931
+ * don't match, never a broken page.
3932
+ *
3933
+ * @param {string} urlPath
3934
+ * @returns {Promise<string|null>}
3935
+ */
3936
+ async function resolveProjectForUrl(urlPath) {
3937
+ try {
3938
+ const {getProjectForPage} = await import('./projects.js');
3939
+ return await getProjectForPage(urlPath);
3940
+ } catch {
3941
+ return null;
3942
+ }
3943
+ }
3944
+
3877
3945
  function renderMenuItemsAsUl(items) {
3878
3946
  if (!items.length) return '';
3879
3947
  const lis = items.map(it => {
@@ -3987,6 +4055,10 @@ function processCtaBlocks(markdown) {
3987
4055
  * @param {object|null} [opts.user] - Authenticated user (`{role, additionalRoles}`)
3988
4056
  * or null for anonymous. Used to filter menu items gated by `visibility` in
3989
4057
  * the `[menu]` shortcode. Backwards compatible — defaults to anonymous.
4058
+ * @param {string} [opts.urlPath] - URL of the page being parsed. Lets
4059
+ * `[menu location="…"]` honour menus bound to this page or its project.
4060
+ * @param {string|null} [opts.project] - Project the page belongs to; resolved
4061
+ * from `urlPath` when omitted.
3990
4062
  * @returns {{ data: object, content: string, html: string }}
3991
4063
  */
3992
4064
  export async function parseMarkdown(raw, opts = {}) {
@@ -4003,7 +4075,10 @@ export async function parseMarkdown(raw, opts = {}) {
4003
4075
  const withCollection = await processCollectionBlocks(withComponents, tagSet);
4004
4076
  const withView = await processViewBlocks(withCollection, tagSet);
4005
4077
  const withStaticBlock = await processStaticBlocks(withView, tagSet);
4006
- const withMenu = await processMenuBlocks(withStaticBlock, opts.user || null);
4078
+ const menuCtx = opts.urlPath
4079
+ ? {urlPath: opts.urlPath, project: opts.project ?? await resolveProjectForUrl(opts.urlPath)}
4080
+ : {};
4081
+ const withMenu = await processMenuBlocks(withStaticBlock, opts.user || null, menuCtx);
4007
4082
  const withDconfig = processDConfigBlocks(withMenu);
4008
4083
  const withEffects = processEffectsBlocks(withDconfig);
4009
4084
  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,