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.
- package/CLAUDE.md +39 -3
- package/admin/css/admin.css +1 -1
- package/admin/css/dashboard.css +1 -1
- package/admin/index.html +2 -2
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/config/sidebar-config.js +1 -1
- package/admin/js/lib/card-builder.js +3 -3
- package/admin/js/lib/crud-tutorial.js +1 -0
- package/admin/js/lib/effects-builder.js +1 -1
- package/admin/js/lib/markdown-toolbar.js +6 -6
- package/admin/js/lib/project-context.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +4 -0
- package/admin/js/templates/action-editor.html +7 -0
- package/admin/js/templates/block-editor.html +7 -0
- package/admin/js/templates/collection-editor.html +9 -0
- package/admin/js/templates/dashboard/cache.html +32 -0
- package/admin/js/templates/dashboard.html +4 -0
- package/admin/js/templates/form-editor.html +9 -0
- package/admin/js/templates/menu-editor.html +98 -0
- package/admin/js/templates/menu-locations.html +14 -0
- package/admin/js/templates/menus.html +14 -0
- package/admin/js/templates/page-editor.html +9 -2
- package/admin/js/templates/project-detail.html +50 -0
- package/admin/js/templates/project-editor.html +45 -0
- package/admin/js/templates/project-settings.html +60 -0
- package/admin/js/templates/projects.html +13 -0
- package/admin/js/templates/role-editor.html +11 -0
- package/admin/js/templates/settings.html +26 -0
- package/admin/js/templates/tutorials.html +335 -2
- package/admin/js/templates/view-editor.html +7 -0
- package/admin/js/views/action-editor.js +1 -1
- package/admin/js/views/actions-list.js +1 -1
- package/admin/js/views/block-editor-enhance.js +1 -1
- package/admin/js/views/block-editor.js +8 -8
- package/admin/js/views/blocks.js +2 -2
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/collections.js +1 -1
- package/admin/js/views/dashboard/widgets/activity-feed.js +1 -1
- package/admin/js/views/dashboard/widgets/cache.js +1 -0
- package/admin/js/views/dashboard/widgets/journeys.js +1 -1
- package/admin/js/views/dashboard/widgets/spike-feed.js +1 -1
- package/admin/js/views/dashboard/widgets/top-pages.js +1 -1
- package/admin/js/views/dashboard.js +1 -1
- package/admin/js/views/form-editor.js +6 -6
- package/admin/js/views/forms.js +1 -1
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/menu-editor.js +19 -0
- package/admin/js/views/menu-locations.js +1 -0
- package/admin/js/views/menus.js +5 -0
- package/admin/js/views/page-editor.js +41 -36
- package/admin/js/views/pages.js +3 -3
- package/admin/js/views/project-detail.js +4 -0
- package/admin/js/views/project-editor.js +1 -0
- package/admin/js/views/project-settings.js +1 -0
- package/admin/js/views/projects.js +7 -0
- package/admin/js/views/role-editor.js +1 -1
- package/admin/js/views/roles.js +3 -3
- package/admin/js/views/settings.js +3 -3
- package/admin/js/views/tutorials.js +1 -1
- package/admin/js/views/user-editor.js +1 -1
- package/admin/js/views/users.js +3 -3
- package/admin/js/views/view-editor.js +1 -1
- package/admin/js/views/views-list.js +1 -1
- package/config/cache.json +4 -0
- package/config/cache.json.example +12 -0
- package/config/menu-locations.json +5 -0
- package/config/menus/admin-sidebar.json +185 -0
- package/config/menus/footer.json +33 -0
- package/config/menus/main.json +35 -0
- package/config/menus/sproj-1779696558011-menu.json +17 -0
- package/config/menus/sproj-1779696960337-menu.json +18 -0
- package/config/menus/sproj-1779696985353-menu.json +18 -0
- package/config/site.json +6 -22
- package/package.json +4 -3
- package/plugins/analytics/daily.json +3 -0
- package/plugins/analytics/journeys.json +8 -0
- package/plugins/analytics/lifetime.json +1 -1
- package/public/css/site.css +1 -1
- package/public/js/collection-browser.js +4 -0
- package/public/js/forms.js +1 -1
- package/public/js/site.js +1 -1
- package/server/config.js +12 -1
- package/server/middleware/auth.js +88 -22
- package/server/routes/api/actions.js +58 -5
- package/server/routes/api/auth.js +2 -2
- package/server/routes/api/blocks.js +18 -3
- package/server/routes/api/cache.js +57 -0
- package/server/routes/api/collections.js +201 -8
- package/server/routes/api/forms.js +266 -21
- package/server/routes/api/menu-locations.js +46 -0
- package/server/routes/api/menus.js +115 -0
- package/server/routes/api/navigation.js +2 -0
- package/server/routes/api/pages.js +1 -1
- package/server/routes/api/projects.js +107 -0
- package/server/routes/api/scaffold.js +86 -0
- package/server/routes/api/settings.js +3 -0
- package/server/routes/api/sidebar.js +23 -0
- package/server/routes/api/users.js +32 -7
- package/server/routes/api/views.js +10 -2
- package/server/routes/public.js +88 -7
- package/server/server.js +54 -3
- package/server/services/actions.js +137 -8
- package/server/services/adapters/FileAdapter.js +23 -8
- package/server/services/adapters/MongoAdapter.js +36 -18
- package/server/services/blocks.js +23 -8
- package/server/services/cache/drivers/MemoryDriver.js +118 -0
- package/server/services/cache/drivers/NoneDriver.js +12 -0
- package/server/services/cache/index.js +229 -0
- package/server/services/cache/lru.js +61 -0
- package/server/services/collections.js +102 -12
- package/server/services/content.js +25 -6
- package/server/services/filterEngine.js +281 -0
- package/server/services/forms.js +3 -0
- package/server/services/hooks.js +48 -0
- package/server/services/markdown.js +711 -124
- package/server/services/menus-migration.js +107 -0
- package/server/services/menus.js +422 -0
- package/server/services/permissionRegistry.js +26 -0
- package/server/services/plugins.js +9 -2
- package/server/services/presetCollections.js +22 -0
- package/server/services/projects.js +429 -0
- package/server/services/recipes/contact-list.json +78 -0
- package/server/services/recipes/onboarding.json +426 -0
- package/server/services/references.js +174 -0
- package/server/services/renderer.js +237 -40
- package/server/services/roles.js +6 -1
- package/server/services/rowAccess.js +86 -13
- package/server/services/scaffolder.js +465 -0
- package/server/services/sidebar-migration.js +117 -0
- package/server/services/sitemap.js +112 -0
- package/server/services/userRoles.js +86 -0
- package/server/services/users.js +23 -2
- package/server/services/views.js +19 -4
- package/server/templates/page.html +135 -130
- /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 {
|
|
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. */
|