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.
- package/CLAUDE.md +6 -0
- package/admin/css/admin.css +1 -1
- package/admin/js/lib/menu-tree.js +1 -0
- package/admin/js/lib/server-restart.js +1 -0
- package/admin/js/lib/shortcode-registry.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +1 -1
- package/admin/js/templates/menu-editor.html +90 -0
- package/admin/js/templates/navigation.html +5 -1
- package/admin/js/templates/settings.html +29 -11
- package/admin/js/views/menu-editor.js +12 -8
- package/admin/js/views/menus.js +4 -4
- package/admin/js/views/plugin-code.js +2 -2
- package/admin/js/views/plugins.js +14 -14
- package/package.json +1 -1
- package/public/css/site.css +1 -1
- package/public/js/menu-decor.mjs +1 -1
- package/public/js/site.js +1 -1
- package/server/routes/api/plugins.js +74 -6
- package/server/services/content.js +4 -1
- package/server/services/markdown.js +83 -8
- package/server/services/menus-migration.js +4 -0
- package/server/services/menus.js +313 -13
- package/server/services/renderer.js +69 -11
- package/server/services/sidebar-migration.js +4 -0
- package/server/templates/page.html +1 -1
|
@@ -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:
|
|
1427
|
-
|
|
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 (
|
|
3850
|
-
else if (
|
|
3851
|
-
|
|
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
|
|
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
|
/**
|
package/server/services/menus.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
*
|
|
582
|
-
*
|
|
583
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
599
|
-
|
|
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,
|