domma-cms 0.17.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/CLAUDE.md +39 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/css/dashboard.css +1 -1
  4. package/admin/index.html +2 -2
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +4 -4
  7. package/admin/js/config/sidebar-config.js +1 -1
  8. package/admin/js/lib/card-builder.js +3 -3
  9. package/admin/js/lib/crud-tutorial.js +1 -0
  10. package/admin/js/lib/effects-builder.js +1 -1
  11. package/admin/js/lib/markdown-toolbar.js +6 -6
  12. package/admin/js/lib/project-context.js +1 -0
  13. package/admin/js/lib/sidebar-renderer.js +4 -0
  14. package/admin/js/templates/action-editor.html +7 -0
  15. package/admin/js/templates/block-editor.html +7 -0
  16. package/admin/js/templates/collection-editor.html +9 -0
  17. package/admin/js/templates/dashboard/cache.html +32 -0
  18. package/admin/js/templates/dashboard.html +4 -0
  19. package/admin/js/templates/form-editor.html +9 -0
  20. package/admin/js/templates/menu-editor.html +98 -0
  21. package/admin/js/templates/menu-locations.html +14 -0
  22. package/admin/js/templates/menus.html +14 -0
  23. package/admin/js/templates/page-editor.html +9 -2
  24. package/admin/js/templates/project-detail.html +50 -0
  25. package/admin/js/templates/project-editor.html +45 -0
  26. package/admin/js/templates/project-settings.html +60 -0
  27. package/admin/js/templates/projects.html +13 -0
  28. package/admin/js/templates/role-editor.html +11 -0
  29. package/admin/js/templates/settings.html +26 -0
  30. package/admin/js/templates/tutorials.html +335 -2
  31. package/admin/js/templates/view-editor.html +7 -0
  32. package/admin/js/views/action-editor.js +1 -1
  33. package/admin/js/views/actions-list.js +1 -1
  34. package/admin/js/views/block-editor-enhance.js +1 -1
  35. package/admin/js/views/block-editor.js +8 -8
  36. package/admin/js/views/blocks.js +2 -2
  37. package/admin/js/views/collection-editor.js +4 -4
  38. package/admin/js/views/collections.js +1 -1
  39. package/admin/js/views/dashboard/widgets/activity-feed.js +1 -1
  40. package/admin/js/views/dashboard/widgets/cache.js +1 -0
  41. package/admin/js/views/dashboard/widgets/journeys.js +1 -1
  42. package/admin/js/views/dashboard/widgets/spike-feed.js +1 -1
  43. package/admin/js/views/dashboard/widgets/top-pages.js +1 -1
  44. package/admin/js/views/dashboard.js +1 -1
  45. package/admin/js/views/form-editor.js +6 -6
  46. package/admin/js/views/forms.js +1 -1
  47. package/admin/js/views/index.js +1 -1
  48. package/admin/js/views/menu-editor.js +19 -0
  49. package/admin/js/views/menu-locations.js +1 -0
  50. package/admin/js/views/menus.js +5 -0
  51. package/admin/js/views/page-editor.js +41 -36
  52. package/admin/js/views/pages.js +3 -3
  53. package/admin/js/views/project-detail.js +4 -0
  54. package/admin/js/views/project-editor.js +1 -0
  55. package/admin/js/views/project-settings.js +1 -0
  56. package/admin/js/views/projects.js +7 -0
  57. package/admin/js/views/role-editor.js +1 -1
  58. package/admin/js/views/roles.js +3 -3
  59. package/admin/js/views/settings.js +3 -3
  60. package/admin/js/views/tutorials.js +1 -1
  61. package/admin/js/views/user-editor.js +1 -1
  62. package/admin/js/views/users.js +3 -3
  63. package/admin/js/views/view-editor.js +1 -1
  64. package/admin/js/views/views-list.js +1 -1
  65. package/config/cache.json +4 -0
  66. package/config/cache.json.example +12 -0
  67. package/config/menu-locations.json +5 -0
  68. package/config/menus/admin-sidebar.json +185 -0
  69. package/config/menus/footer.json +33 -0
  70. package/config/menus/main.json +35 -0
  71. package/config/menus/sproj-1779696558011-menu.json +17 -0
  72. package/config/menus/sproj-1779696960337-menu.json +18 -0
  73. package/config/menus/sproj-1779696985353-menu.json +18 -0
  74. package/config/site.json +6 -22
  75. package/package.json +4 -3
  76. package/plugins/analytics/daily.json +3 -0
  77. package/plugins/analytics/journeys.json +8 -0
  78. package/plugins/analytics/lifetime.json +1 -1
  79. package/public/css/site.css +1 -1
  80. package/public/js/collection-browser.js +4 -0
  81. package/public/js/forms.js +1 -1
  82. package/public/js/site.js +1 -1
  83. package/server/config.js +12 -1
  84. package/server/middleware/auth.js +88 -22
  85. package/server/routes/api/actions.js +58 -5
  86. package/server/routes/api/auth.js +2 -2
  87. package/server/routes/api/blocks.js +18 -3
  88. package/server/routes/api/cache.js +57 -0
  89. package/server/routes/api/collections.js +201 -8
  90. package/server/routes/api/forms.js +266 -21
  91. package/server/routes/api/menu-locations.js +46 -0
  92. package/server/routes/api/menus.js +115 -0
  93. package/server/routes/api/navigation.js +2 -0
  94. package/server/routes/api/pages.js +1 -1
  95. package/server/routes/api/projects.js +107 -0
  96. package/server/routes/api/scaffold.js +86 -0
  97. package/server/routes/api/settings.js +3 -0
  98. package/server/routes/api/sidebar.js +23 -0
  99. package/server/routes/api/users.js +32 -7
  100. package/server/routes/api/views.js +10 -2
  101. package/server/routes/public.js +88 -7
  102. package/server/server.js +54 -3
  103. package/server/services/actions.js +137 -8
  104. package/server/services/adapters/FileAdapter.js +23 -8
  105. package/server/services/adapters/MongoAdapter.js +36 -18
  106. package/server/services/blocks.js +23 -8
  107. package/server/services/cache/drivers/MemoryDriver.js +118 -0
  108. package/server/services/cache/drivers/NoneDriver.js +12 -0
  109. package/server/services/cache/index.js +229 -0
  110. package/server/services/cache/lru.js +61 -0
  111. package/server/services/collections.js +102 -12
  112. package/server/services/content.js +25 -6
  113. package/server/services/filterEngine.js +281 -0
  114. package/server/services/forms.js +3 -0
  115. package/server/services/hooks.js +48 -0
  116. package/server/services/markdown.js +711 -124
  117. package/server/services/menus-migration.js +107 -0
  118. package/server/services/menus.js +422 -0
  119. package/server/services/permissionRegistry.js +26 -0
  120. package/server/services/plugins.js +9 -2
  121. package/server/services/presetCollections.js +22 -0
  122. package/server/services/projects.js +429 -0
  123. package/server/services/recipes/contact-list.json +78 -0
  124. package/server/services/recipes/onboarding.json +426 -0
  125. package/server/services/references.js +174 -0
  126. package/server/services/renderer.js +237 -40
  127. package/server/services/roles.js +6 -1
  128. package/server/services/rowAccess.js +86 -13
  129. package/server/services/scaffolder.js +465 -0
  130. package/server/services/sidebar-migration.js +117 -0
  131. package/server/services/sitemap.js +112 -0
  132. package/server/services/userRoles.js +86 -0
  133. package/server/services/users.js +23 -2
  134. package/server/services/views.js +19 -4
  135. package/server/templates/page.html +135 -130
  136. /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,422 @@
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
+
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = path.dirname(__filename);
18
+
19
+ /** Project root config directory. */
20
+ const CONFIG_DIR = path.resolve(__dirname, '..', '..', 'config');
21
+
22
+ /** Per-menu files live here. */
23
+ export const MENUS_DIR = path.join(CONFIG_DIR, 'menus');
24
+
25
+ /** Slot → menu-slug map. */
26
+ export const LOCATIONS_FILE = path.join(CONFIG_DIR, 'menu-locations.json');
27
+
28
+ const SLUG_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
29
+
30
+ /**
31
+ * Validate a slug (used for both menu slugs and location slot names).
32
+ *
33
+ * @param {unknown} slug
34
+ * @returns {boolean}
35
+ */
36
+ export function validateSlug(slug) {
37
+ return typeof slug === 'string' && SLUG_RE.test(slug);
38
+ }
39
+
40
+ /**
41
+ * Ensure MENUS_DIR exists. Safe to call repeatedly.
42
+ *
43
+ * @returns {Promise<void>}
44
+ */
45
+ export async function ensureMenusDir() {
46
+ await fs.mkdir(MENUS_DIR, {recursive: true});
47
+ }
48
+
49
+ function menuPath(slug) {
50
+ return path.join(MENUS_DIR, `${slug}.json`);
51
+ }
52
+
53
+ async function readMenuFile(slug) {
54
+ const raw = await fs.readFile(menuPath(slug), 'utf8');
55
+ return JSON.parse(raw);
56
+ }
57
+
58
+ async function writeMenuFile(menu) {
59
+ await ensureMenusDir();
60
+ await fs.writeFile(menuPath(menu.slug), JSON.stringify(menu, null, 2) + '\n', 'utf8');
61
+ }
62
+
63
+ /**
64
+ * List all menus. Returns metadata only (no items) — use getMenu(slug) for
65
+ * the full payload. Sorted alphabetically by slug.
66
+ *
67
+ * @returns {Promise<Array<{slug:string,name:string,description:string,itemsCount:number,meta:object}>>}
68
+ * @throws {Error} if the menus directory cannot be read for reasons other than ENOENT
69
+ */
70
+ export async function listMenus() {
71
+ await ensureMenusDir();
72
+ const entries = await fs.readdir(MENUS_DIR);
73
+ const out = [];
74
+ for (const file of entries) {
75
+ if (!file.endsWith('.json')) continue;
76
+ const slug = file.slice(0, -5);
77
+ if (!validateSlug(slug)) continue;
78
+ try {
79
+ const menu = await readMenuFile(slug);
80
+ out.push({
81
+ slug: menu.slug || slug,
82
+ name: menu.name || '',
83
+ description: menu.description || '',
84
+ itemsCount: Array.isArray(menu.items) ? menu.items.length : 0,
85
+ meta: menu.meta || {}
86
+ });
87
+ } catch (err) {
88
+ console.warn(`[menus] Skipping malformed menu "${slug}": ${err.message}`);
89
+ }
90
+ }
91
+ return out.sort((a, b) => a.slug.localeCompare(b.slug));
92
+ }
93
+
94
+ /**
95
+ * Read a full menu by slug. Returns null if missing or slug is invalid.
96
+ *
97
+ * @param {string} slug
98
+ * @returns {Promise<object|null>}
99
+ * @throws {Error} if the file exists but cannot be read for reasons other than ENOENT
100
+ */
101
+ export async function getMenu(slug) {
102
+ if (!validateSlug(slug)) return null;
103
+ try {
104
+ return await readMenuFile(slug);
105
+ } catch (err) {
106
+ if (err.code === 'ENOENT') return null;
107
+ throw err;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Create a new menu. Refuses on slug collision.
113
+ *
114
+ * @param {object} input - { slug, name, description?, variant?, position?, style?, items, meta? }
115
+ * @returns {Promise<object>} the persisted menu
116
+ * @throws {Error} if the slug is invalid or already exists
117
+ */
118
+ export async function createMenu(input) {
119
+ if (!validateSlug(input?.slug)) {
120
+ throw new Error(`Invalid menu slug: ${input?.slug}`);
121
+ }
122
+ if (await getMenu(input.slug)) {
123
+ throw new Error(`Menu "${input.slug}" already exists`);
124
+ }
125
+ const errors = validateMenu(input);
126
+ if (errors.length) {
127
+ throw new Error('Validation failed: ' + errors.join('; '));
128
+ }
129
+ const now = new Date().toISOString();
130
+ const menu = {
131
+ slug: input.slug,
132
+ name: input.name || input.slug,
133
+ description: input.description || '',
134
+ ...(input.variant != null && {variant: input.variant}),
135
+ ...(input.position != null && {position: input.position}),
136
+ ...(input.style != null && {style: input.style}),
137
+ items: Array.isArray(input.items) ? input.items : [],
138
+ meta: {
139
+ createdAt: now,
140
+ updatedAt: now,
141
+ bundled: Boolean(input.meta?.bundled),
142
+ presetOwner: input.meta?.presetOwner || null,
143
+ ...(input.meta?.project != null && {project: input.meta.project})
144
+ }
145
+ };
146
+ await writeMenuFile(menu);
147
+ return menu;
148
+ }
149
+
150
+ /**
151
+ * Replace an existing menu. Preserves meta.createdAt; updates meta.updatedAt.
152
+ * The slug in the path wins over any slug in the body.
153
+ *
154
+ * @param {string} slug
155
+ * @param {object} input
156
+ * @returns {Promise<object>} the persisted menu
157
+ * @throws {Error} if the menu does not exist
158
+ */
159
+ export async function updateMenu(slug, input) {
160
+ const existing = await getMenu(slug);
161
+ if (!existing) throw new Error(`Menu "${slug}" not found`);
162
+ const errors = validateMenu({...input, slug});
163
+ if (errors.length) {
164
+ throw new Error('Validation failed: ' + errors.join('; '));
165
+ }
166
+ const now = new Date().toISOString();
167
+ const merged = {
168
+ slug,
169
+ name: input.name || existing.name,
170
+ description: input.description || '',
171
+ ...(input.variant != null && {variant: input.variant}),
172
+ ...(input.position != null && {position: input.position}),
173
+ ...(input.style != null && {style: input.style}),
174
+ items: Array.isArray(input.items) ? input.items : [],
175
+ meta: {
176
+ ...existing.meta,
177
+ ...(input.meta || {}),
178
+ createdAt: existing.meta?.createdAt || now,
179
+ updatedAt: now
180
+ }
181
+ };
182
+ await writeMenuFile(merged);
183
+ return merged;
184
+ }
185
+
186
+ /**
187
+ * Delete a menu file. Returns true if removed, false if it didn't exist.
188
+ * Callers must check the locations map first — service does not.
189
+ *
190
+ * @param {string} slug
191
+ * @returns {Promise<boolean>}
192
+ * @throws {Error} if the file exists but cannot be removed for reasons other than ENOENT
193
+ */
194
+ export async function deleteMenu(slug) {
195
+ if (!validateSlug(slug)) return false;
196
+ try {
197
+ await fs.unlink(menuPath(slug));
198
+ return true;
199
+ } catch (err) {
200
+ if (err.code === 'ENOENT') return false;
201
+ throw err;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Duplicate a menu as <slug>-copy, falling back to -copy-2, -copy-3, … if
207
+ * needed. The duplicate's name is suffixed with " (copy)" for clarity.
208
+ *
209
+ * @param {string} slug
210
+ * @returns {Promise<object>} the new menu
211
+ * @throws {Error} if the source menu does not exist
212
+ */
213
+ export async function duplicateMenu(slug) {
214
+ const source = await getMenu(slug);
215
+ if (!source) throw new Error(`Menu "${slug}" not found`);
216
+ let candidate = slug + '-copy';
217
+ let n = 2;
218
+ while (await getMenu(candidate)) {
219
+ candidate = slug + '-copy-' + n++;
220
+ }
221
+ return createMenu({
222
+ ...source,
223
+ slug: candidate,
224
+ name: source.name + ' (copy)',
225
+ meta: {bundled: false, presetOwner: null}
226
+ });
227
+ }
228
+
229
+ const POSITIONS = new Set(['sticky', 'fixed', 'static', 'floating']);
230
+ const URL_PREFIX_RE = /^(\/|#|https?:\/\/|mailto:)/;
231
+
232
+ /**
233
+ * Validate a menu object. Returns an array of human-readable error strings
234
+ * (empty on success). Performs structural + per-item recursive checks; does
235
+ * NOT validate visibility role names (those are checked at render time).
236
+ *
237
+ * @param {object} menu
238
+ * @returns {string[]}
239
+ */
240
+ export function validateMenu(menu) {
241
+ const errors = [];
242
+ if (!menu || typeof menu !== 'object') return ['Menu must be an object'];
243
+ if (!validateSlug(menu.slug)) {
244
+ errors.push(`Invalid slug "${menu.slug}" — lowercase alphanumeric + hyphen only`);
245
+ }
246
+ if (!menu.name || typeof menu.name !== 'string') {
247
+ errors.push('name is required');
248
+ }
249
+ if (menu.position != null && !POSITIONS.has(menu.position)) {
250
+ errors.push(`Invalid position "${menu.position}" — expected one of: ${[...POSITIONS].join(', ')}`);
251
+ }
252
+ if (!Array.isArray(menu.items)) {
253
+ errors.push('items must be an array');
254
+ } else {
255
+ walkItems(menu.items, (item, path) => {
256
+ if (!item.text || typeof item.text !== 'string') {
257
+ errors.push(`Item ${path}: text is required`);
258
+ }
259
+ // url is only required for LEAF items (no children). Folder items
260
+ // — those with their own `items` array — are pure groupings and
261
+ // don't need a navigation target. The admin sidebar uses this
262
+ // shape extensively (Content / Data / System headings etc.).
263
+ const hasChildren = Array.isArray(item.items) && item.items.length > 0;
264
+ if (!hasChildren) {
265
+ if (!item.url || typeof item.url !== 'string' || !URL_PREFIX_RE.test(item.url)) {
266
+ errors.push(`Item ${path}: url must start with /, #, http(s)://, or mailto:`);
267
+ }
268
+ } else if (item.url != null && item.url !== '' && (typeof item.url !== 'string' || !URL_PREFIX_RE.test(item.url))) {
269
+ // Folder items MAY have a URL (clickable parent); if present it must still be well-formed.
270
+ errors.push(`Item ${path}: url must start with /, #, http(s)://, or mailto:`);
271
+ }
272
+ if (item.items != null && !Array.isArray(item.items)) {
273
+ errors.push(`Item ${path}: items must be an array if present`);
274
+ }
275
+ });
276
+ }
277
+ return errors;
278
+ }
279
+
280
+ function walkItems(items, fn, parentPath = '') {
281
+ items.forEach((item, idx) => {
282
+ const itemPath = parentPath ? `${parentPath}.${idx}` : String(idx);
283
+ if (item == null || typeof item !== 'object') {
284
+ // Surface a structural error rather than crashing on a malformed entry.
285
+ fn({}, itemPath);
286
+ return;
287
+ }
288
+ fn(item, itemPath);
289
+ if (Array.isArray(item.items)) walkItems(item.items, fn, itemPath);
290
+ });
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // Locations registry — module-level Map, seeded by core, extended by plugins
295
+ // ---------------------------------------------------------------------------
296
+
297
+ const _locationRegistry = new Map(); // slot -> { label, description, maxDepth, source }
298
+
299
+ /**
300
+ * Register a slot where a menu can be rendered. Re-registering an existing
301
+ * slot logs a warning and the second registration wins (last-write).
302
+ *
303
+ * @param {string} slot
304
+ * @param {{label:string, description?:string, maxDepth?:number, source?:string}} def
305
+ * @throws {Error} if the slot name fails validateSlug
306
+ */
307
+ export function registerLocation(slot, def) {
308
+ if (!validateSlug(slot)) throw new Error(`Invalid slot name: ${slot}`);
309
+ if (_locationRegistry.has(slot)) {
310
+ console.warn(`[menus] Slot "${slot}" re-registered — last write wins`);
311
+ }
312
+ _locationRegistry.set(slot, {
313
+ label: def?.label || slot,
314
+ description: def?.description || '',
315
+ maxDepth: Number.isFinite(def?.maxDepth) ? def.maxDepth : null,
316
+ source: def?.source || 'core'
317
+ });
318
+ }
319
+
320
+ /**
321
+ * @returns {Array<{slot:string, label:string, description:string, maxDepth:number|null, source:string}>}
322
+ */
323
+ export function getLocationRegistry() {
324
+ return [..._locationRegistry.entries()].map(([slot, def]) => ({slot, ...def}));
325
+ }
326
+
327
+ // Core slots — registered at module load. Plugins register theirs from
328
+ // their plugin.js setup function via the registerMenuLocation hook (Task 7).
329
+ registerLocation('navbar', {label: 'Navbar', description: 'Site navbar', source: 'core'});
330
+ registerLocation('footer-primary', {label: 'Footer primary', description: 'Primary footer column', source: 'core', maxDepth: 1});
331
+ registerLocation('footer-legal', {label: 'Footer legal', description: 'Legal/secondary footer column', source: 'core', maxDepth: 1});
332
+ registerLocation('admin-sidebar', {label: 'Admin sidebar', description: 'The left-side navigation in the admin panel', source: 'core', maxDepth: null});
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // Slot → menu-slug map
336
+ // ---------------------------------------------------------------------------
337
+
338
+ /**
339
+ * Read the slot → menu-slug map from disk. Returns an empty object when the
340
+ * file doesn't exist or is malformed (logged as a warning).
341
+ *
342
+ * @returns {Promise<Record<string,string>>}
343
+ */
344
+ export async function getLocations() {
345
+ try {
346
+ const raw = await fs.readFile(LOCATIONS_FILE, 'utf8');
347
+ return JSON.parse(raw);
348
+ } catch (err) {
349
+ if (err.code === 'ENOENT') return {};
350
+ console.warn(`[menus] menu-locations.json unreadable: ${err.message}`);
351
+ return {};
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Replace the slot map. Validates that every slot is registered and every
357
+ * menu slug resolves to a real menu file.
358
+ *
359
+ * @param {Record<string,string>} map
360
+ * @throws {Error} if the map is not a plain object, contains an unregistered slot, or references a missing menu
361
+ */
362
+ export async function setLocations(map) {
363
+ if (!map || typeof map !== 'object' || Array.isArray(map)) {
364
+ throw new Error('Locations map must be a plain object');
365
+ }
366
+ for (const slot of Object.keys(map)) {
367
+ if (!_locationRegistry.has(slot)) {
368
+ throw new Error(`Slot "${slot}" is not registered`);
369
+ }
370
+ const slug = map[slot];
371
+ if (slug != null && slug !== '' && !(await getMenu(slug))) {
372
+ throw new Error(`Menu "${slug}" not found for slot "${slot}"`);
373
+ }
374
+ }
375
+ await fs.writeFile(LOCATIONS_FILE, JSON.stringify(map, null, 2) + '\n', 'utf8');
376
+ }
377
+
378
+ // ---------------------------------------------------------------------------
379
+ // Render-time resolution
380
+ // ---------------------------------------------------------------------------
381
+
382
+ /**
383
+ * Resolve the menu mapped to `slot` for `user`, with items filtered by
384
+ * `hidden` (always dropped) and `visibility` (per-user role gating).
385
+ * Returns null if the slot is unmapped or the mapped menu is missing.
386
+ *
387
+ * Note: `user` may be null (anonymous). `user.role` and
388
+ * `user.additionalRoles` are honoured via `checkVisibility`.
389
+ *
390
+ * @param {string} slot
391
+ * @param {object|null} user
392
+ * @returns {Promise<object|null>}
393
+ */
394
+ export async function resolveLocation(slot, user) {
395
+ const map = await getLocations();
396
+ const slug = map[slot];
397
+ if (!slug) return null;
398
+ const menu = await getMenu(slug);
399
+ if (!menu) {
400
+ console.warn(`[menus] Slot "${slot}" maps to "${slug}" which doesn't exist`);
401
+ return null;
402
+ }
403
+ return {
404
+ ...menu,
405
+ items: filterItemsForUser(menu.items || [], user)
406
+ };
407
+ }
408
+
409
+ function filterItemsForUser(items, user) {
410
+ const out = [];
411
+ for (const item of items) {
412
+ if (item.hidden) continue;
413
+ if (item.visibility != null) {
414
+ if (!checkVisibility(user, item.visibility)) continue;
415
+ }
416
+ const children = Array.isArray(item.items)
417
+ ? filterItemsForUser(item.items, user)
418
+ : [];
419
+ out.push({...item, items: children});
420
+ }
421
+ return out;
422
+ }
@@ -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
  });
@@ -37,6 +37,28 @@ const PRESETS = [
37
37
  delete: {enabled: false, access: 'admin'}
38
38
  }
39
39
  }
40
+ ,
41
+ {
42
+ slug: 'projects',
43
+ title: 'Projects',
44
+ description: 'Named groupings of related artefacts.',
45
+ preset: true,
46
+ systemManaged: true,
47
+ fields: [
48
+ {name: 'slug', label: 'Slug', type: 'text', required: true},
49
+ {name: 'name', label: 'Name', type: 'text', required: true},
50
+ {name: 'description', label: 'Description', type: 'textarea'},
51
+ {name: 'icon', label: 'Icon', type: 'text', default: 'folder'},
52
+ {name: 'rootUrl', label: 'Root URL', type: 'text'},
53
+ {name: 'sortOrder', label: 'Sort Order', type: 'number', default: 0}
54
+ ],
55
+ api: {
56
+ create: {enabled: false, access: 'admin'},
57
+ read: {enabled: false, access: 'admin'},
58
+ update: {enabled: false, access: 'admin'},
59
+ delete: {enabled: false, access: 'admin'}
60
+ }
61
+ }
40
62
  ];
41
63
 
42
64
  /** Slugs exported for use in adapterRegistry and the delete guard. */