domma-cms 0.18.0 → 0.22.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.
Files changed (110) hide show
  1. package/CLAUDE.md +37 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/api.js +1 -1
  4. package/admin/js/app.js +4 -4
  5. package/admin/js/config/sidebar-config.js +1 -1
  6. package/admin/js/lib/crud-tutorial.js +1 -0
  7. package/admin/js/lib/markdown-toolbar.js +5 -5
  8. package/admin/js/lib/project-context.js +1 -0
  9. package/admin/js/lib/sidebar-renderer.js +4 -0
  10. package/admin/js/templates/action-editor.html +7 -0
  11. package/admin/js/templates/block-editor.html +7 -0
  12. package/admin/js/templates/collection-editor.html +9 -0
  13. package/admin/js/templates/form-editor.html +9 -0
  14. package/admin/js/templates/menu-editor.html +99 -0
  15. package/admin/js/templates/menu-locations.html +14 -0
  16. package/admin/js/templates/menus.html +14 -0
  17. package/admin/js/templates/page-editor.html +9 -2
  18. package/admin/js/templates/project-detail.html +50 -0
  19. package/admin/js/templates/project-editor.html +45 -0
  20. package/admin/js/templates/project-settings.html +60 -0
  21. package/admin/js/templates/projects.html +13 -0
  22. package/admin/js/templates/role-editor.html +11 -0
  23. package/admin/js/templates/tutorials.html +335 -2
  24. package/admin/js/templates/view-editor.html +7 -0
  25. package/admin/js/views/action-editor.js +1 -1
  26. package/admin/js/views/actions-list.js +1 -1
  27. package/admin/js/views/block-editor.js +8 -8
  28. package/admin/js/views/blocks.js +2 -2
  29. package/admin/js/views/collection-editor.js +4 -4
  30. package/admin/js/views/collections.js +1 -1
  31. package/admin/js/views/form-editor.js +5 -5
  32. package/admin/js/views/forms.js +1 -1
  33. package/admin/js/views/index.js +1 -1
  34. package/admin/js/views/menu-editor.js +20 -0
  35. package/admin/js/views/menu-locations.js +1 -0
  36. package/admin/js/views/menus.js +5 -0
  37. package/admin/js/views/page-editor.js +24 -24
  38. package/admin/js/views/pages.js +3 -3
  39. package/admin/js/views/project-detail.js +4 -0
  40. package/admin/js/views/project-editor.js +1 -0
  41. package/admin/js/views/project-settings.js +1 -0
  42. package/admin/js/views/projects.js +7 -0
  43. package/admin/js/views/role-editor.js +1 -1
  44. package/admin/js/views/roles.js +3 -3
  45. package/admin/js/views/tutorials.js +1 -1
  46. package/admin/js/views/user-editor.js +1 -1
  47. package/admin/js/views/users.js +3 -3
  48. package/admin/js/views/view-editor.js +1 -1
  49. package/admin/js/views/views-list.js +1 -1
  50. package/config/menu-locations.json +5 -0
  51. package/config/menus/admin-sidebar.json +185 -0
  52. package/config/menus/footer.json +33 -0
  53. package/config/menus/main.json +35 -0
  54. package/config/menus/sproj-1779696558011-menu.json +17 -0
  55. package/config/menus/sproj-1779696960337-menu.json +18 -0
  56. package/config/menus/sproj-1779696985353-menu.json +18 -0
  57. package/config/site.json +6 -22
  58. package/package.json +4 -3
  59. package/plugins/analytics/daily.json +3 -0
  60. package/plugins/analytics/journeys.json +8 -0
  61. package/plugins/analytics/lifetime.json +1 -1
  62. package/public/css/site.css +1 -1
  63. package/public/js/collection-browser.js +4 -0
  64. package/public/js/forms.js +1 -1
  65. package/public/js/site.js +1 -1
  66. package/server/middleware/auth.js +88 -22
  67. package/server/routes/api/actions.js +58 -5
  68. package/server/routes/api/auth.js +2 -2
  69. package/server/routes/api/blocks.js +18 -3
  70. package/server/routes/api/collections.js +201 -8
  71. package/server/routes/api/forms.js +266 -21
  72. package/server/routes/api/menu-locations.js +46 -0
  73. package/server/routes/api/menus.js +115 -0
  74. package/server/routes/api/pages.js +1 -1
  75. package/server/routes/api/projects.js +107 -0
  76. package/server/routes/api/scaffold.js +86 -0
  77. package/server/routes/api/sidebar.js +23 -0
  78. package/server/routes/api/users.js +32 -7
  79. package/server/routes/api/views.js +10 -2
  80. package/server/routes/public.js +79 -6
  81. package/server/server.js +38 -0
  82. package/server/services/actions.js +137 -8
  83. package/server/services/adapters/FileAdapter.js +23 -8
  84. package/server/services/adapters/MongoAdapter.js +36 -18
  85. package/server/services/blocks.js +20 -8
  86. package/server/services/collections.js +85 -8
  87. package/server/services/content.js +23 -9
  88. package/server/services/filterEngine.js +281 -0
  89. package/server/services/hooks.js +48 -0
  90. package/server/services/markdown.js +702 -109
  91. package/server/services/menus-migration.js +107 -0
  92. package/server/services/menus.js +524 -0
  93. package/server/services/permissionRegistry.js +26 -0
  94. package/server/services/plugins.js +9 -2
  95. package/server/services/presetCollections.js +22 -0
  96. package/server/services/projects.js +429 -0
  97. package/server/services/recipes/contact-list.json +78 -0
  98. package/server/services/recipes/onboarding.json +426 -0
  99. package/server/services/references.js +174 -0
  100. package/server/services/renderer.js +253 -40
  101. package/server/services/roles.js +6 -1
  102. package/server/services/rowAccess.js +86 -13
  103. package/server/services/scaffolder.js +465 -0
  104. package/server/services/sidebar-migration.js +117 -0
  105. package/server/services/sitemap.js +112 -0
  106. package/server/services/userRoles.js +86 -0
  107. package/server/services/users.js +23 -2
  108. package/server/services/views.js +15 -4
  109. package/server/templates/page.html +7 -2
  110. /package/config/{navigation.json → navigation.json.bak} +0 -0
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Menus auto-migration.
3
+ *
4
+ * Runs once on server boot. Idempotent — guarded by the existence of
5
+ * config/menu-locations.json. Converts:
6
+ * - config/navigation.json → config/menus/main.json + locations.navbar
7
+ * (brand moves into site.json.brand)
8
+ * - site.json.footer.links → config/menus/footer.json + locations.footer-primary
9
+ * (footer.copyright preserved)
10
+ * The originals are renamed to .bak for one release of read-fallback safety.
11
+ */
12
+ import fs from 'fs/promises';
13
+ import path from 'path';
14
+ import {fileURLToPath} from 'url';
15
+
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const DEFAULT_CONFIG_DIR = path.resolve(path.dirname(__filename), '..', '..', 'config');
18
+
19
+ async function exists(p) {
20
+ try { await fs.access(p); return true; } catch { return false; }
21
+ }
22
+
23
+ async function readJson(p) {
24
+ const raw = await fs.readFile(p, 'utf8');
25
+ return JSON.parse(raw);
26
+ }
27
+
28
+ async function writeJson(p, data) {
29
+ await fs.writeFile(p, JSON.stringify(data, null, 2) + '\n', 'utf8');
30
+ }
31
+
32
+ /**
33
+ * Run the migration. Returns `{migrated: boolean, reason?: string}`.
34
+ *
35
+ * @param {{configDir?: string}} [opts]
36
+ */
37
+ export async function runMigration(opts = {}) {
38
+ const configDir = opts.configDir || DEFAULT_CONFIG_DIR;
39
+
40
+ // 1. Guard
41
+ if (await exists(path.join(configDir, 'menu-locations.json'))) {
42
+ return {migrated: false, reason: 'menu-locations.json already present'};
43
+ }
44
+
45
+ const menusDir = path.join(configDir, 'menus');
46
+ await fs.mkdir(menusDir, {recursive: true});
47
+ const locations = {};
48
+ const now = new Date().toISOString();
49
+
50
+ // 2. navigation.json
51
+ const navPath = path.join(configDir, 'navigation.json');
52
+ if (await exists(navPath)) {
53
+ const nav = await readJson(navPath);
54
+ const mainMenu = {
55
+ slug: 'main',
56
+ name: 'Main navigation',
57
+ description: 'Migrated from config/navigation.json',
58
+ ...(nav.variant != null && {variant: nav.variant}),
59
+ ...(nav.position != null && {position: nav.position}),
60
+ ...(nav.style != null && {style: nav.style}),
61
+ items: Array.isArray(nav.items) ? nav.items : [],
62
+ meta: {createdAt: now, updatedAt: now, bundled: false, presetOwner: null}
63
+ };
64
+ await writeJson(path.join(menusDir, 'main.json'), mainMenu);
65
+ locations.navbar = 'main';
66
+
67
+ if (nav.brand) {
68
+ const sitePath = path.join(configDir, 'site.json');
69
+ const site = (await exists(sitePath)) ? await readJson(sitePath) : {};
70
+ site.brand = nav.brand;
71
+ await writeJson(sitePath, site);
72
+ }
73
+
74
+ await fs.rename(navPath, navPath + '.bak');
75
+ console.log(`[menus] Migrated navigation.json → menus/main.json + locations.navbar`);
76
+ }
77
+
78
+ // 3. site.json footer.links
79
+ const sitePath = path.join(configDir, 'site.json');
80
+ if (await exists(sitePath)) {
81
+ const site = await readJson(sitePath);
82
+ const links = site.footer?.links;
83
+ if (Array.isArray(links) && links.length) {
84
+ const footerMenu = {
85
+ slug: 'footer',
86
+ name: 'Footer',
87
+ description: 'Migrated from site.json footer.links',
88
+ items: links.map(l => ({
89
+ text: l.text || '',
90
+ url: l.url || '/',
91
+ ...(l.hidden && {hidden: true})
92
+ })),
93
+ meta: {createdAt: now, updatedAt: now, bundled: false, presetOwner: null}
94
+ };
95
+ await writeJson(path.join(menusDir, 'footer.json'), footerMenu);
96
+ locations['footer-primary'] = 'footer';
97
+ site.footer = {...site.footer, links: []};
98
+ await writeJson(sitePath, site);
99
+ console.log(`[menus] Migrated footer.links → menus/footer.json + locations.footer-primary`);
100
+ }
101
+ }
102
+
103
+ // 4. Write the locations map (even if empty — its existence is the migration guard)
104
+ await writeJson(path.join(configDir, 'menu-locations.json'), locations);
105
+
106
+ return {migrated: true};
107
+ }
@@ -0,0 +1,524 @@
1
+ /**
2
+ * Menus service.
3
+ *
4
+ * Multi-menu storage backing the public navbar, footer, plugin-registered
5
+ * slots, and the `[menu]` shortcode. Each menu lives in its own JSON file
6
+ * under `config/menus/<slug>.json`. A slot → menu-slug map lives in
7
+ * `config/menu-locations.json`. The list of registered slots (a separate
8
+ * concept from the map) is held in process memory and seeded by core +
9
+ * plugin hooks at startup.
10
+ */
11
+ import fs from 'fs/promises';
12
+ import path from 'path';
13
+ import {fileURLToPath} from 'url';
14
+ import {checkVisibility} from '../middleware/auth.js';
15
+ import {listEntries} from './collections.js';
16
+
17
+ const __filename = fileURLToPath(import.meta.url);
18
+ const __dirname = path.dirname(__filename);
19
+
20
+ /** Project root config directory. */
21
+ const CONFIG_DIR = path.resolve(__dirname, '..', '..', 'config');
22
+
23
+ /** Per-menu files live here. */
24
+ export const MENUS_DIR = path.join(CONFIG_DIR, 'menus');
25
+
26
+ /** Slot → menu-slug map. */
27
+ export const LOCATIONS_FILE = path.join(CONFIG_DIR, 'menu-locations.json');
28
+
29
+ const SLUG_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
30
+
31
+ /**
32
+ * Validate a slug (used for both menu slugs and location slot names).
33
+ *
34
+ * @param {unknown} slug
35
+ * @returns {boolean}
36
+ */
37
+ export function validateSlug(slug) {
38
+ return typeof slug === 'string' && SLUG_RE.test(slug);
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Decoration colour convention — preset key → theme CSS var, or #rrggbb hex.
43
+ // A "colour value" is one of these preset keys or a 6-digit hex.
44
+ // Keep PRESET_COLOUR_VARS in sync with the copy in public/js/menu-decor.mjs.
45
+ // ---------------------------------------------------------------------------
46
+ export const PRESET_COLOUR_VARS = {
47
+ primary: '--dm-primary',
48
+ success: '--dm-success',
49
+ danger: '--dm-danger',
50
+ warning: '--dm-warning',
51
+ info: '--dm-info',
52
+ neutral: '--dm-text-muted'
53
+ };
54
+
55
+ const HEX_COLOUR_RE = /^#[0-9a-fA-F]{6}$/;
56
+
57
+ /** True if value is a known preset key or a 6-digit hex colour. */
58
+ export function isValidColour(value) {
59
+ return typeof value === 'string'
60
+ && (Object.prototype.hasOwnProperty.call(PRESET_COLOUR_VARS, value) || HEX_COLOUR_RE.test(value));
61
+ }
62
+
63
+ /** Map a colour value to inline-CSS-ready text: var(--dm-…) | #hex | ''. */
64
+ export function colourToCss(value) {
65
+ if (typeof value !== 'string') return '';
66
+ if (PRESET_COLOUR_VARS[value]) return `var(${PRESET_COLOUR_VARS[value]})`;
67
+ if (HEX_COLOUR_RE.test(value)) return value;
68
+ return '';
69
+ }
70
+
71
+ /**
72
+ * Walk a menu tree and resolve live badge counts. For any item whose
73
+ * `badge.countFrom` names a collection, the entry count replaces `badge.text`.
74
+ * Static badges and all other fields are left untouched. Failures (missing /
75
+ * unreadable collection) drop only that count, never the menu.
76
+ *
77
+ * @param {Array} items Menu items (may be nested via `items`).
78
+ * @param {{countEntries?: (slug:string)=>Promise<number>}} [opts]
79
+ * countEntries is injectable for testing; defaults to listEntries().length.
80
+ * @returns {Promise<Array>} A decorated copy (inputs are not mutated).
81
+ */
82
+ export async function resolveMenuDecorations(items, opts = {}) {
83
+ const countEntries = opts.countEntries
84
+ || (async (slug) => (await listEntries(slug)).length);
85
+
86
+ async function walk(list) {
87
+ return Promise.all((list || []).map(async (it) => {
88
+ if (!it || it.type === 'separator') return it;
89
+ const next = {...it};
90
+ if (it.badge && it.badge.countFrom) {
91
+ try {
92
+ const n = await countEntries(it.badge.countFrom);
93
+ next.badge = {...it.badge, text: String(n)};
94
+ } catch {
95
+ next.badge = {...it.badge}; // keep variant/etc; drop the count
96
+ }
97
+ }
98
+ if (Array.isArray(it.items)) next.items = await walk(it.items);
99
+ return next;
100
+ }));
101
+ }
102
+
103
+ return walk(items);
104
+ }
105
+
106
+ /**
107
+ * Ensure MENUS_DIR exists. Safe to call repeatedly.
108
+ *
109
+ * @returns {Promise<void>}
110
+ */
111
+ export async function ensureMenusDir() {
112
+ await fs.mkdir(MENUS_DIR, {recursive: true});
113
+ }
114
+
115
+ function menuPath(slug) {
116
+ return path.join(MENUS_DIR, `${slug}.json`);
117
+ }
118
+
119
+ async function readMenuFile(slug) {
120
+ const raw = await fs.readFile(menuPath(slug), 'utf8');
121
+ return JSON.parse(raw);
122
+ }
123
+
124
+ async function writeMenuFile(menu) {
125
+ await ensureMenusDir();
126
+ await fs.writeFile(menuPath(menu.slug), JSON.stringify(menu, null, 2) + '\n', 'utf8');
127
+ }
128
+
129
+ /**
130
+ * List all menus. Returns metadata only (no items) — use getMenu(slug) for
131
+ * the full payload. Sorted alphabetically by slug.
132
+ *
133
+ * @returns {Promise<Array<{slug:string,name:string,description:string,itemsCount:number,meta:object}>>}
134
+ * @throws {Error} if the menus directory cannot be read for reasons other than ENOENT
135
+ */
136
+ export async function listMenus() {
137
+ await ensureMenusDir();
138
+ const entries = await fs.readdir(MENUS_DIR);
139
+ const out = [];
140
+ for (const file of entries) {
141
+ if (!file.endsWith('.json')) continue;
142
+ const slug = file.slice(0, -5);
143
+ if (!validateSlug(slug)) continue;
144
+ try {
145
+ const menu = await readMenuFile(slug);
146
+ out.push({
147
+ slug: menu.slug || slug,
148
+ name: menu.name || '',
149
+ description: menu.description || '',
150
+ itemsCount: Array.isArray(menu.items) ? menu.items.length : 0,
151
+ meta: menu.meta || {}
152
+ });
153
+ } catch (err) {
154
+ console.warn(`[menus] Skipping malformed menu "${slug}": ${err.message}`);
155
+ }
156
+ }
157
+ return out.sort((a, b) => a.slug.localeCompare(b.slug));
158
+ }
159
+
160
+ /**
161
+ * Read a full menu by slug. Returns null if missing or slug is invalid.
162
+ *
163
+ * @param {string} slug
164
+ * @returns {Promise<object|null>}
165
+ * @throws {Error} if the file exists but cannot be read for reasons other than ENOENT
166
+ */
167
+ export async function getMenu(slug) {
168
+ if (!validateSlug(slug)) return null;
169
+ try {
170
+ return await readMenuFile(slug);
171
+ } catch (err) {
172
+ if (err.code === 'ENOENT') return null;
173
+ throw err;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Create a new menu. Refuses on slug collision.
179
+ *
180
+ * @param {object} input - { slug, name, description?, variant?, position?, style?, items, meta? }
181
+ * @returns {Promise<object>} the persisted menu
182
+ * @throws {Error} if the slug is invalid or already exists
183
+ */
184
+ export async function createMenu(input) {
185
+ if (!validateSlug(input?.slug)) {
186
+ throw new Error(`Invalid menu slug: ${input?.slug}`);
187
+ }
188
+ if (await getMenu(input.slug)) {
189
+ throw new Error(`Menu "${input.slug}" already exists`);
190
+ }
191
+ const errors = validateMenu(input);
192
+ if (errors.length) {
193
+ throw new Error('Validation failed: ' + errors.join('; '));
194
+ }
195
+ const now = new Date().toISOString();
196
+ const menu = {
197
+ slug: input.slug,
198
+ name: input.name || input.slug,
199
+ description: input.description || '',
200
+ ...(input.variant != null && {variant: input.variant}),
201
+ ...(input.position != null && {position: input.position}),
202
+ ...(input.style != null && {style: input.style}),
203
+ items: Array.isArray(input.items) ? input.items : [],
204
+ meta: {
205
+ createdAt: now,
206
+ updatedAt: now,
207
+ bundled: Boolean(input.meta?.bundled),
208
+ presetOwner: input.meta?.presetOwner || null,
209
+ ...(input.meta?.project != null && {project: input.meta.project})
210
+ }
211
+ };
212
+ await writeMenuFile(menu);
213
+ return menu;
214
+ }
215
+
216
+ /**
217
+ * Replace an existing menu. Preserves meta.createdAt; updates meta.updatedAt.
218
+ * The slug in the path wins over any slug in the body.
219
+ *
220
+ * @param {string} slug
221
+ * @param {object} input
222
+ * @returns {Promise<object>} the persisted menu
223
+ * @throws {Error} if the menu does not exist
224
+ */
225
+ export async function updateMenu(slug, input) {
226
+ const existing = await getMenu(slug);
227
+ if (!existing) throw new Error(`Menu "${slug}" not found`);
228
+ const errors = validateMenu({...input, slug});
229
+ if (errors.length) {
230
+ throw new Error('Validation failed: ' + errors.join('; '));
231
+ }
232
+ const now = new Date().toISOString();
233
+ const merged = {
234
+ slug,
235
+ name: input.name || existing.name,
236
+ description: input.description || '',
237
+ ...(input.variant != null && {variant: input.variant}),
238
+ ...(input.position != null && {position: input.position}),
239
+ ...(input.style != null && {style: input.style}),
240
+ items: Array.isArray(input.items) ? input.items : [],
241
+ meta: {
242
+ ...existing.meta,
243
+ ...(input.meta || {}),
244
+ createdAt: existing.meta?.createdAt || now,
245
+ updatedAt: now
246
+ }
247
+ };
248
+ await writeMenuFile(merged);
249
+ return merged;
250
+ }
251
+
252
+ /**
253
+ * Delete a menu file. Returns true if removed, false if it didn't exist.
254
+ * Callers must check the locations map first — service does not.
255
+ *
256
+ * @param {string} slug
257
+ * @returns {Promise<boolean>}
258
+ * @throws {Error} if the file exists but cannot be removed for reasons other than ENOENT
259
+ */
260
+ export async function deleteMenu(slug) {
261
+ if (!validateSlug(slug)) return false;
262
+ try {
263
+ await fs.unlink(menuPath(slug));
264
+ return true;
265
+ } catch (err) {
266
+ if (err.code === 'ENOENT') return false;
267
+ throw err;
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Duplicate a menu as <slug>-copy, falling back to -copy-2, -copy-3, … if
273
+ * needed. The duplicate's name is suffixed with " (copy)" for clarity.
274
+ *
275
+ * @param {string} slug
276
+ * @returns {Promise<object>} the new menu
277
+ * @throws {Error} if the source menu does not exist
278
+ */
279
+ export async function duplicateMenu(slug) {
280
+ const source = await getMenu(slug);
281
+ if (!source) throw new Error(`Menu "${slug}" not found`);
282
+ let candidate = slug + '-copy';
283
+ let n = 2;
284
+ while (await getMenu(candidate)) {
285
+ candidate = slug + '-copy-' + n++;
286
+ }
287
+ return createMenu({
288
+ ...source,
289
+ slug: candidate,
290
+ name: source.name + ' (copy)',
291
+ meta: {bundled: false, presetOwner: null}
292
+ });
293
+ }
294
+
295
+ const POSITIONS = new Set(['sticky', 'fixed', 'static', 'floating']);
296
+ const URL_PREFIX_RE = /^(\/|#|https?:\/\/|mailto:)/;
297
+
298
+ /**
299
+ * Validate a menu object. Returns an array of human-readable error strings
300
+ * (empty on success). Performs structural + per-item recursive checks; does
301
+ * NOT validate visibility role names (those are checked at render time).
302
+ *
303
+ * @param {object} menu
304
+ * @returns {string[]}
305
+ */
306
+ export function validateMenu(menu) {
307
+ const errors = [];
308
+ if (!menu || typeof menu !== 'object') return ['Menu must be an object'];
309
+ if (!validateSlug(menu.slug)) {
310
+ errors.push(`Invalid slug "${menu.slug}" — lowercase alphanumeric + hyphen only`);
311
+ }
312
+ if (!menu.name || typeof menu.name !== 'string') {
313
+ errors.push('name is required');
314
+ }
315
+ if (menu.position != null && !POSITIONS.has(menu.position)) {
316
+ errors.push(`Invalid position "${menu.position}" — expected one of: ${[...POSITIONS].join(', ')}`);
317
+ }
318
+ if (!Array.isArray(menu.items)) {
319
+ errors.push('items must be an array');
320
+ } else {
321
+ walkItems(menu.items, (item, path) => {
322
+ // Separator items are purely visual dividers — no text/url required.
323
+ // Renderers translate them into <hr> / structural breaks.
324
+ if (item && item.type === 'separator') return;
325
+
326
+ if (!item.text || typeof item.text !== 'string') {
327
+ errors.push(`Item ${path}: text is required`);
328
+ }
329
+ // url is only required for LEAF items (no children). Folder items
330
+ // — those with their own `items` array — are pure groupings and
331
+ // don't need a navigation target. The admin sidebar uses this
332
+ // shape extensively (Content / Data / System headings etc.).
333
+ const hasChildren = Array.isArray(item.items) && item.items.length > 0;
334
+ if (!hasChildren) {
335
+ if (!item.url || typeof item.url !== 'string' || !URL_PREFIX_RE.test(item.url)) {
336
+ errors.push(`Item ${path}: url must start with /, #, http(s)://, or mailto:`);
337
+ }
338
+ } else if (item.url != null && item.url !== '' && (typeof item.url !== 'string' || !URL_PREFIX_RE.test(item.url))) {
339
+ // Folder items MAY have a URL (clickable parent); if present it must still be well-formed.
340
+ errors.push(`Item ${path}: url must start with /, #, http(s)://, or mailto:`);
341
+ }
342
+ if (item.items != null && !Array.isArray(item.items)) {
343
+ errors.push(`Item ${path}: items must be an array if present`);
344
+ }
345
+
346
+ // --- Decoration shape checks (all optional) ---
347
+ if (item.badge != null) {
348
+ if (typeof item.badge !== 'object' || Array.isArray(item.badge)) {
349
+ errors.push(`Item ${path}: badge must be an object`);
350
+ } else {
351
+ if (item.badge.text != null && typeof item.badge.text !== 'string') {
352
+ errors.push(`Item ${path}: badge.text must be a string`);
353
+ }
354
+ if (item.badge.countFrom != null && typeof item.badge.countFrom !== 'string') {
355
+ errors.push(`Item ${path}: badge.countFrom must be a collection slug`);
356
+ }
357
+ if (item.badge.variant != null && !isValidColour(item.badge.variant)) {
358
+ errors.push(`Item ${path}: badge.variant must be a preset key or #rrggbb`);
359
+ }
360
+ }
361
+ }
362
+ if (item.pill != null) {
363
+ if (typeof item.pill !== 'object' || Array.isArray(item.pill)) {
364
+ errors.push(`Item ${path}: pill must be an object`);
365
+ } else {
366
+ if (item.pill.style != null && item.pill.style !== 'filled' && item.pill.style !== 'outline') {
367
+ errors.push(`Item ${path}: pill.style must be "filled" or "outline"`);
368
+ }
369
+ if (item.pill.variant != null && !isValidColour(item.pill.variant)) {
370
+ errors.push(`Item ${path}: pill.variant must be a preset key or #rrggbb`);
371
+ }
372
+ }
373
+ }
374
+ if (item.colour != null && !isValidColour(item.colour)) {
375
+ errors.push(`Item ${path}: colour must be a preset key or #rrggbb`);
376
+ }
377
+ });
378
+ }
379
+ return errors;
380
+ }
381
+
382
+ function walkItems(items, fn, parentPath = '') {
383
+ items.forEach((item, idx) => {
384
+ const itemPath = parentPath ? `${parentPath}.${idx}` : String(idx);
385
+ if (item == null || typeof item !== 'object') {
386
+ // Surface a structural error rather than crashing on a malformed entry.
387
+ fn({}, itemPath);
388
+ return;
389
+ }
390
+ fn(item, itemPath);
391
+ if (Array.isArray(item.items)) walkItems(item.items, fn, itemPath);
392
+ });
393
+ }
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Locations registry — module-level Map, seeded by core, extended by plugins
397
+ // ---------------------------------------------------------------------------
398
+
399
+ const _locationRegistry = new Map(); // slot -> { label, description, maxDepth, source }
400
+
401
+ /**
402
+ * Register a slot where a menu can be rendered. Re-registering an existing
403
+ * slot logs a warning and the second registration wins (last-write).
404
+ *
405
+ * @param {string} slot
406
+ * @param {{label:string, description?:string, maxDepth?:number, source?:string}} def
407
+ * @throws {Error} if the slot name fails validateSlug
408
+ */
409
+ export function registerLocation(slot, def) {
410
+ if (!validateSlug(slot)) throw new Error(`Invalid slot name: ${slot}`);
411
+ if (_locationRegistry.has(slot)) {
412
+ console.warn(`[menus] Slot "${slot}" re-registered — last write wins`);
413
+ }
414
+ _locationRegistry.set(slot, {
415
+ label: def?.label || slot,
416
+ description: def?.description || '',
417
+ maxDepth: Number.isFinite(def?.maxDepth) ? def.maxDepth : null,
418
+ source: def?.source || 'core'
419
+ });
420
+ }
421
+
422
+ /**
423
+ * @returns {Array<{slot:string, label:string, description:string, maxDepth:number|null, source:string}>}
424
+ */
425
+ export function getLocationRegistry() {
426
+ return [..._locationRegistry.entries()].map(([slot, def]) => ({slot, ...def}));
427
+ }
428
+
429
+ // Core slots — registered at module load. Plugins register theirs from
430
+ // their plugin.js setup function via the registerMenuLocation hook (Task 7).
431
+ registerLocation('navbar', {label: 'Navbar', description: 'Site navbar', source: 'core'});
432
+ registerLocation('footer-primary', {label: 'Footer primary', description: 'Primary footer column', source: 'core', maxDepth: 1});
433
+ registerLocation('footer-legal', {label: 'Footer legal', description: 'Legal/secondary footer column', source: 'core', maxDepth: 1});
434
+ registerLocation('admin-sidebar', {label: 'Admin sidebar', description: 'The left-side navigation in the admin panel', source: 'core', maxDepth: null});
435
+
436
+ // ---------------------------------------------------------------------------
437
+ // Slot → menu-slug map
438
+ // ---------------------------------------------------------------------------
439
+
440
+ /**
441
+ * Read the slot → menu-slug map from disk. Returns an empty object when the
442
+ * file doesn't exist or is malformed (logged as a warning).
443
+ *
444
+ * @returns {Promise<Record<string,string>>}
445
+ */
446
+ export async function getLocations() {
447
+ try {
448
+ const raw = await fs.readFile(LOCATIONS_FILE, 'utf8');
449
+ return JSON.parse(raw);
450
+ } catch (err) {
451
+ if (err.code === 'ENOENT') return {};
452
+ console.warn(`[menus] menu-locations.json unreadable: ${err.message}`);
453
+ return {};
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Replace the slot map. Validates that every slot is registered and every
459
+ * menu slug resolves to a real menu file.
460
+ *
461
+ * @param {Record<string,string>} map
462
+ * @throws {Error} if the map is not a plain object, contains an unregistered slot, or references a missing menu
463
+ */
464
+ export async function setLocations(map) {
465
+ if (!map || typeof map !== 'object' || Array.isArray(map)) {
466
+ throw new Error('Locations map must be a plain object');
467
+ }
468
+ for (const slot of Object.keys(map)) {
469
+ if (!_locationRegistry.has(slot)) {
470
+ throw new Error(`Slot "${slot}" is not registered`);
471
+ }
472
+ const slug = map[slot];
473
+ if (slug != null && slug !== '' && !(await getMenu(slug))) {
474
+ throw new Error(`Menu "${slug}" not found for slot "${slot}"`);
475
+ }
476
+ }
477
+ await fs.writeFile(LOCATIONS_FILE, JSON.stringify(map, null, 2) + '\n', 'utf8');
478
+ }
479
+
480
+ // ---------------------------------------------------------------------------
481
+ // Render-time resolution
482
+ // ---------------------------------------------------------------------------
483
+
484
+ /**
485
+ * Resolve the menu mapped to `slot` for `user`, with items filtered by
486
+ * `hidden` (always dropped) and `visibility` (per-user role gating).
487
+ * Returns null if the slot is unmapped or the mapped menu is missing.
488
+ *
489
+ * Note: `user` may be null (anonymous). `user.role` and
490
+ * `user.additionalRoles` are honoured via `checkVisibility`.
491
+ *
492
+ * @param {string} slot
493
+ * @param {object|null} user
494
+ * @returns {Promise<object|null>}
495
+ */
496
+ export async function resolveLocation(slot, user) {
497
+ const map = await getLocations();
498
+ const slug = map[slot];
499
+ if (!slug) return null;
500
+ const menu = await getMenu(slug);
501
+ if (!menu) {
502
+ console.warn(`[menus] Slot "${slot}" maps to "${slug}" which doesn't exist`);
503
+ return null;
504
+ }
505
+ return {
506
+ ...menu,
507
+ items: filterItemsForUser(menu.items || [], user)
508
+ };
509
+ }
510
+
511
+ function filterItemsForUser(items, user) {
512
+ const out = [];
513
+ for (const item of items) {
514
+ if (item.hidden) continue;
515
+ if (item.visibility != null) {
516
+ if (!checkVisibility(user, item.visibility)) continue;
517
+ }
518
+ const children = Array.isArray(item.items)
519
+ ? filterItemsForUser(item.items, user)
520
+ : [];
521
+ out.push({...item, items: children});
522
+ }
523
+ return out;
524
+ }
@@ -74,6 +74,32 @@ export const REGISTRY = [
74
74
  {key: 'delete', label: 'Delete', description: 'Remove menu items'}
75
75
  ]
76
76
  },
77
+ {
78
+ key: 'menus',
79
+ label: 'Menus',
80
+ description: 'Manage menus and the slots they render into.',
81
+ icon: 'menu',
82
+ group: 'Structure',
83
+ actions: [
84
+ {key: 'read', label: 'View', description: 'View menus and locations'},
85
+ {key: 'create', label: 'Create', description: 'Create new menus'},
86
+ {key: 'update', label: 'Edit', description: 'Edit menus and the slot map'},
87
+ {key: 'delete', label: 'Delete', description: 'Delete menus'}
88
+ ]
89
+ },
90
+ {
91
+ key: 'projects',
92
+ label: 'Projects',
93
+ description: 'Group related artefacts under named projects.',
94
+ icon: 'folder',
95
+ group: 'Structure',
96
+ actions: [
97
+ {key: 'read', label: 'View', description: 'View projects and their artefacts'},
98
+ {key: 'create', label: 'Create', description: 'Create new projects'},
99
+ {key: 'update', label: 'Edit', description: 'Edit projects and tag/untag artefacts'},
100
+ {key: 'delete', label: 'Delete', description: 'Delete projects (refused if any artefacts tagged)'}
101
+ ]
102
+ },
77
103
  {
78
104
  key: 'layouts',
79
105
  label: 'Layouts',
@@ -15,7 +15,14 @@ import path from 'path';
15
15
  import matter from 'gray-matter';
16
16
  import {getConfig, saveConfig} from '../config.js';
17
17
  import {authenticate, requireAdmin, requireRole, requireVisibility} from '../middleware/auth.js';
18
- import {hooks, registerSanitizeRules, registerShortcode, registerTransform} from './hooks.js';
18
+ import {
19
+ hooks,
20
+ registerMenuLocation,
21
+ registerSanitizeRules,
22
+ registerShortcode,
23
+ registerSidebarItem,
24
+ registerTransform
25
+ } from './hooks.js';
19
26
  import {registerPluginResource, unregisterPluginResourcesByPlugin} from './permissionRegistry.js';
20
27
  import {createCollection, getCollection} from './collections.js';
21
28
 
@@ -204,7 +211,7 @@ export async function registerPlugins(fastify) {
204
211
  await fastify.register(plugin, {
205
212
  prefix,
206
213
  auth: {authenticate, requireRole, requireAdmin, requireVisibility},
207
- hooks: {registerShortcode, registerSanitizeRules, registerTransform, on: hooks.on.bind(hooks)},
214
+ hooks: {registerShortcode, registerSanitizeRules, registerTransform, registerMenuLocation, registerSidebarItem, on: hooks.on.bind(hooks)},
208
215
  settings,
209
216
  config: {}
210
217
  });