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,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scaffolder — one-shot creator for a working CRUD system from a recipe.
|
|
3
|
+
*
|
|
4
|
+
* A "recipe" is a JSON file under `server/services/recipes/` that describes:
|
|
5
|
+
* - one Collection (schema + initial api access rules)
|
|
6
|
+
* - one Form (fields + submit settings)
|
|
7
|
+
* - zero or more Actions (with optional transition + access config)
|
|
8
|
+
* - an optional Markdown snippet to insert into the calling page
|
|
9
|
+
*
|
|
10
|
+
* The recipe declares a set of `options` whose `default` values can be
|
|
11
|
+
* overridden by the caller (e.g. to change a slug). Token substitution
|
|
12
|
+
* (`{{collectionSlug}}` style) is applied across the whole recipe AFTER the
|
|
13
|
+
* options are merged, so a single user choice flows into every slug, title,
|
|
14
|
+
* and template variable.
|
|
15
|
+
*
|
|
16
|
+
* Atomicity: pre-flight check refuses to start if ANY target slug already
|
|
17
|
+
* exists — keeps the user out of a partial-create state. If pre-flight passes
|
|
18
|
+
* we apply collection → form → actions in order. Action creation requires
|
|
19
|
+
* MongoDB (Pro feature); when not configured we still complete the
|
|
20
|
+
* collection + form and report actions as skipped with a warning.
|
|
21
|
+
*/
|
|
22
|
+
import fs from 'fs/promises';
|
|
23
|
+
import path from 'path';
|
|
24
|
+
import {fileURLToPath} from 'url';
|
|
25
|
+
import {createCollection, getCollection} from './collections.js';
|
|
26
|
+
import {readForm, slugify, writeForm} from './forms.js';
|
|
27
|
+
import {createAction, getAction} from './actions.js';
|
|
28
|
+
import {createRole, getRoleMap} from './roles.js';
|
|
29
|
+
import {createUser, getUserByEmail} from './users.js';
|
|
30
|
+
import {
|
|
31
|
+
createMenu as createMenuFile,
|
|
32
|
+
getLocations as getMenuLocations,
|
|
33
|
+
getMenu as getMenuFile,
|
|
34
|
+
setLocations as setMenuLocations
|
|
35
|
+
} from './menus.js';
|
|
36
|
+
import {createProject, getProject} from './projects.js';
|
|
37
|
+
|
|
38
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
39
|
+
const RECIPES_DIR = path.join(path.dirname(__filename), 'recipes');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* List all bundled recipes.
|
|
43
|
+
*
|
|
44
|
+
* @returns {Promise<Array<{slug:string,name:string,description:string,icon:string,options:object[]}>>}
|
|
45
|
+
*/
|
|
46
|
+
export async function listRecipes() {
|
|
47
|
+
let entries;
|
|
48
|
+
try {
|
|
49
|
+
entries = await fs.readdir(RECIPES_DIR);
|
|
50
|
+
} catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const recipes = [];
|
|
55
|
+
for (const entry of entries.filter(e => e.endsWith('.json'))) {
|
|
56
|
+
try {
|
|
57
|
+
const raw = await fs.readFile(path.join(RECIPES_DIR, entry), 'utf8');
|
|
58
|
+
const r = JSON.parse(raw);
|
|
59
|
+
recipes.push({
|
|
60
|
+
slug: r.slug,
|
|
61
|
+
name: r.name,
|
|
62
|
+
description: r.description,
|
|
63
|
+
icon: r.icon || 'box',
|
|
64
|
+
options: r.options || []
|
|
65
|
+
});
|
|
66
|
+
} catch {
|
|
67
|
+
/* skip malformed recipe — don't break the listing */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return recipes;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Read a single recipe by slug.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} slug
|
|
77
|
+
* @returns {Promise<object|null>}
|
|
78
|
+
*/
|
|
79
|
+
export async function getRecipe(slug) {
|
|
80
|
+
const safe = String(slug || '').replace(/[^a-z0-9-]/gi, '');
|
|
81
|
+
if (!safe) return null;
|
|
82
|
+
try {
|
|
83
|
+
const raw = await fs.readFile(path.join(RECIPES_DIR, `${safe}.json`), 'utf8');
|
|
84
|
+
return JSON.parse(raw);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Recursively walk a value and replace `{{tokenName}}` placeholders inside
|
|
92
|
+
* any strings with values from `tokens`. Non-string leaves pass through
|
|
93
|
+
* unchanged. Arrays and plain objects are walked into; other object types
|
|
94
|
+
* (Date, Buffer, etc.) are returned as-is.
|
|
95
|
+
*
|
|
96
|
+
* @param {unknown} value
|
|
97
|
+
* @param {Record<string,string>} tokens
|
|
98
|
+
* @returns {unknown}
|
|
99
|
+
*/
|
|
100
|
+
function substituteTokens(value, tokens) {
|
|
101
|
+
if (typeof value === 'string') {
|
|
102
|
+
return value.replace(/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g, (_m, key) => {
|
|
103
|
+
// Leave action template variables (entry.*, user.*, now, env.*) untouched
|
|
104
|
+
// — those are resolved at action execution time, not at scaffold time.
|
|
105
|
+
if (key.includes('.') || key === 'now') return _m;
|
|
106
|
+
if (key === 'entry' || key === 'user' || key === 'env') return _m;
|
|
107
|
+
return tokens[key] != null ? String(tokens[key]) : _m;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (Array.isArray(value)) return value.map(v => substituteTokens(v, tokens));
|
|
111
|
+
if (value && typeof value === 'object' && value.constructor === Object) {
|
|
112
|
+
const out = {};
|
|
113
|
+
for (const [k, v] of Object.entries(value)) out[k] = substituteTokens(v, tokens);
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Resolve effective options for a recipe — recipe defaults merged with caller
|
|
121
|
+
* overrides, with non-empty user values winning. Returns a plain `{ name: value }`
|
|
122
|
+
* map suitable to pass to `substituteTokens`.
|
|
123
|
+
*
|
|
124
|
+
* **Default-chaining**: an option's `default` may template-reference earlier
|
|
125
|
+
* options via `{{otherOption}}`. This is the "namespacing" affordance — set
|
|
126
|
+
* one `namespace` option and let `collectionSlug`, `formSlug`, `actionPrefix`
|
|
127
|
+
* default to `{{namespace}}`, `{{namespace}}-form`, `{{namespace}}` etc. The
|
|
128
|
+
* recipe author chooses which options participate; users can still override
|
|
129
|
+
* any individual option to break the chain.
|
|
130
|
+
*
|
|
131
|
+
* Resolution is left-to-right over the options array — so the option being
|
|
132
|
+
* referenced MUST appear earlier in the array than the option that references
|
|
133
|
+
* it. (If forward references become a recurring need we can topologically
|
|
134
|
+
* sort, but the explicit-order rule keeps recipe files readable.)
|
|
135
|
+
*
|
|
136
|
+
* @param {object} recipe
|
|
137
|
+
* @param {object} userOverrides
|
|
138
|
+
* @returns {Record<string,string>}
|
|
139
|
+
*/
|
|
140
|
+
function resolveOptions(recipe, userOverrides = {}) {
|
|
141
|
+
const tokens = {};
|
|
142
|
+
for (const opt of (recipe.options || [])) {
|
|
143
|
+
const v = userOverrides?.[opt.name];
|
|
144
|
+
if (v != null && String(v).trim() !== '') {
|
|
145
|
+
tokens[opt.name] = slugify(String(v));
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const def = String(opt.default ?? '');
|
|
149
|
+
// Substitute already-resolved tokens inside the default. Unresolved
|
|
150
|
+
// references (typo, forward ref) pass through unchanged so the author
|
|
151
|
+
// sees the placeholder text and can fix the recipe.
|
|
152
|
+
tokens[opt.name] = def.replace(/\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g, (m, key) => {
|
|
153
|
+
return tokens[key] != null ? String(tokens[key]) : m;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return tokens;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Check whether anything the recipe wants to create already exists. Returns a
|
|
161
|
+
* list of human-readable conflict descriptions; empty array means clear.
|
|
162
|
+
*
|
|
163
|
+
* Action conflict checks are skipped silently when MongoDB isn't configured —
|
|
164
|
+
* the apply phase will degrade those into warnings rather than blocking.
|
|
165
|
+
*
|
|
166
|
+
* @param {object} resolvedRecipe
|
|
167
|
+
* @returns {Promise<string[]>}
|
|
168
|
+
*/
|
|
169
|
+
async function preflight(resolvedRecipe) {
|
|
170
|
+
const conflicts = [];
|
|
171
|
+
|
|
172
|
+
const colSlug = resolvedRecipe.collection?.slug || resolvedRecipe._collectionSlug;
|
|
173
|
+
if (colSlug && await getCollection(colSlug)) {
|
|
174
|
+
conflicts.push(`Collection "${colSlug}" already exists`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const formSlug = resolvedRecipe._formSlug;
|
|
178
|
+
if (formSlug) {
|
|
179
|
+
try {
|
|
180
|
+
await readForm(formSlug);
|
|
181
|
+
conflicts.push(`Form "${formSlug}" already exists`);
|
|
182
|
+
} catch { /* not found = OK */ }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const action of (resolvedRecipe.actions || [])) {
|
|
186
|
+
if (!action.slug) continue;
|
|
187
|
+
try {
|
|
188
|
+
const existing = await getAction(action.slug);
|
|
189
|
+
if (existing) conflicts.push(`Action "${action.slug}" already exists`);
|
|
190
|
+
} catch {
|
|
191
|
+
// Mongo unavailable — treat as not-conflicting; the apply step
|
|
192
|
+
// will report actions as skipped instead.
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Roles + users are NOT treated as pre-flight conflicts — they're a
|
|
197
|
+
// looser kind of resource that frequently already exists (re-running a
|
|
198
|
+
// recipe, admin-created role, etc.) and where overwriting would lose
|
|
199
|
+
// data the user cares about (existing role's permissions tweaks, user's
|
|
200
|
+
// password). The apply step handles them with skip-with-warning instead,
|
|
201
|
+
// so the rest of the recipe can still land cleanly.
|
|
202
|
+
|
|
203
|
+
for (const menu of (resolvedRecipe.menus || [])) {
|
|
204
|
+
if (!menu.slug) continue;
|
|
205
|
+
if (await getMenuFile(menu.slug)) {
|
|
206
|
+
conflicts.push(`Menu "${menu.slug}" already exists`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return conflicts;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Apply a recipe. Returns a summary describing what got created (or skipped),
|
|
215
|
+
* plus any non-fatal warnings.
|
|
216
|
+
*
|
|
217
|
+
* @param {string} recipeSlug
|
|
218
|
+
* @param {object} [opts]
|
|
219
|
+
* @param {object} [opts.options] - User overrides for recipe options (slug renames etc.)
|
|
220
|
+
* @param {string} [opts.createdBy] - User id stamped onto created action records
|
|
221
|
+
* @returns {Promise<{created:object, skipped:string[], warnings:string[], snippet:string|null}>}
|
|
222
|
+
* @throws {Error} If a target slug already exists (pre-flight conflict)
|
|
223
|
+
*/
|
|
224
|
+
export async function applyRecipe(recipeSlug, opts = {}) {
|
|
225
|
+
const recipe = await getRecipe(recipeSlug);
|
|
226
|
+
if (!recipe) throw new Error(`Recipe "${recipeSlug}" not found`);
|
|
227
|
+
|
|
228
|
+
const tokens = resolveOptions(recipe, opts.options || {});
|
|
229
|
+
const resolved = substituteTokens(recipe, tokens);
|
|
230
|
+
|
|
231
|
+
// Carry the resolved slugs as sidecar fields so preflight + apply don't
|
|
232
|
+
// have to re-derive them from the (now also substituted) snippet text.
|
|
233
|
+
resolved._collectionSlug = tokens.collectionSlug;
|
|
234
|
+
resolved._formSlug = tokens.formSlug;
|
|
235
|
+
|
|
236
|
+
// Wire form → collection so submissions land in the right place
|
|
237
|
+
if (resolved.form) {
|
|
238
|
+
resolved.form.slug = tokens.formSlug;
|
|
239
|
+
if (resolved.form.actions?.collection?.enabled) {
|
|
240
|
+
resolved.form.actions.collection.slug = tokens.collectionSlug;
|
|
241
|
+
}
|
|
242
|
+
// If the recipe wired an actionSlug template into form settings, ensure
|
|
243
|
+
// it points to a real action slug from this scaffold (no dangling refs).
|
|
244
|
+
if (resolved.form.settings?.actionSlug) {
|
|
245
|
+
const targetExists = (resolved.actions || []).some(a => a.slug === resolved.form.settings.actionSlug);
|
|
246
|
+
if (!targetExists) resolved.form.settings.actionSlug = '';
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Project — auto-create if the recipe declares one and it doesn't exist.
|
|
251
|
+
// Uses the recipe's `namespace` token as the project slug, so a single user
|
|
252
|
+
// choice ties together every artefact's `meta.project` tag below.
|
|
253
|
+
let projectSlug = null;
|
|
254
|
+
if (resolved.project) {
|
|
255
|
+
projectSlug = tokens.namespace;
|
|
256
|
+
if (projectSlug && !(await getProject(projectSlug))) {
|
|
257
|
+
try {
|
|
258
|
+
await createProject({
|
|
259
|
+
slug: projectSlug,
|
|
260
|
+
name: resolved.project.name || projectSlug,
|
|
261
|
+
description: resolved.project.description || '',
|
|
262
|
+
icon: resolved.project.icon || 'folder',
|
|
263
|
+
...(resolved.project.rootUrl != null && {rootUrl: resolved.project.rootUrl}),
|
|
264
|
+
...(Number.isFinite(resolved.project.sortOrder) && {sortOrder: resolved.project.sortOrder})
|
|
265
|
+
});
|
|
266
|
+
} catch (err) {
|
|
267
|
+
const e = new Error(`Cannot create project "${projectSlug}": ${err.message}`);
|
|
268
|
+
e.statusCode = 400;
|
|
269
|
+
throw e;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const conflicts = await preflight(resolved);
|
|
275
|
+
if (conflicts.length) {
|
|
276
|
+
const err = new Error(`Cannot apply recipe — conflicts:\n - ${conflicts.join('\n - ')}`);
|
|
277
|
+
err.statusCode = 409;
|
|
278
|
+
err.conflicts = conflicts;
|
|
279
|
+
throw err;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const created = { collection: null, form: null, actions: [], roles: [], users: [], menus: [] };
|
|
283
|
+
const skipped = [];
|
|
284
|
+
const warnings = [];
|
|
285
|
+
|
|
286
|
+
// 0. Roles — create FIRST so subsequent actions can reference them by name.
|
|
287
|
+
// If a role with the same name already exists (re-run, admin-created),
|
|
288
|
+
// we skip silently rather than failing — the role's existence is what
|
|
289
|
+
// matters for the actions that reference it, not who created it.
|
|
290
|
+
const existingRoles = getRoleMap();
|
|
291
|
+
for (const role of (resolved.roles || [])) {
|
|
292
|
+
if (!role.name) continue;
|
|
293
|
+
if (existingRoles.has(role.name)) {
|
|
294
|
+
skipped.push(`role:${role.name}`);
|
|
295
|
+
warnings.push(`Role "${role.name}" already exists — using existing definition (recipe-defined permissions not applied)`);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
await createRole({
|
|
300
|
+
name: role.name,
|
|
301
|
+
label: role.label || role.name,
|
|
302
|
+
level: Number.isFinite(role.level) ? role.level : 5,
|
|
303
|
+
permissions: Array.isArray(role.permissions) ? role.permissions : [],
|
|
304
|
+
badgeClass: role.badgeClass || 'badge-secondary',
|
|
305
|
+
...(projectSlug && {meta: {project: projectSlug}})
|
|
306
|
+
});
|
|
307
|
+
created.roles.push(role.name);
|
|
308
|
+
} catch (err) {
|
|
309
|
+
warnings.push(`Role "${role.name}" failed: ${err.message}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 0b. Menus — write each declared menu and, if requested, map it to a slot.
|
|
314
|
+
for (const menu of (resolved.menus || [])) {
|
|
315
|
+
if (!menu.slug) continue;
|
|
316
|
+
try {
|
|
317
|
+
await createMenuFile({
|
|
318
|
+
slug: menu.slug,
|
|
319
|
+
name: menu.name || menu.slug,
|
|
320
|
+
description: menu.description || '',
|
|
321
|
+
...(menu.variant != null && {variant: menu.variant}),
|
|
322
|
+
...(menu.position != null && {position: menu.position}),
|
|
323
|
+
...(menu.style != null && {style: menu.style}),
|
|
324
|
+
items: Array.isArray(menu.items) ? menu.items : [],
|
|
325
|
+
meta: {bundled: true, presetOwner: recipeSlug, ...(projectSlug && {project: projectSlug})}
|
|
326
|
+
});
|
|
327
|
+
created.menus.push(menu.slug);
|
|
328
|
+
|
|
329
|
+
if (Array.isArray(menu.locations) && menu.locations.length) {
|
|
330
|
+
const map = await getMenuLocations();
|
|
331
|
+
let touched = false;
|
|
332
|
+
for (const slot of menu.locations) {
|
|
333
|
+
if (map[slot] && map[slot] !== menu.slug && !menu.force) {
|
|
334
|
+
warnings.push(`Slot "${slot}" already mapped to "${map[slot]}" — left unchanged (set force:true to overwrite)`);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
map[slot] = menu.slug;
|
|
338
|
+
touched = true;
|
|
339
|
+
}
|
|
340
|
+
if (touched) {
|
|
341
|
+
try { await setMenuLocations(map); } catch (err) {
|
|
342
|
+
warnings.push(`Locations update failed: ${err.message}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
} catch (err) {
|
|
347
|
+
warnings.push(`Menu "${menu.slug}" failed: ${err.message}`);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// 1. Collection
|
|
352
|
+
if (resolved.collection) {
|
|
353
|
+
await createCollection({
|
|
354
|
+
slug: tokens.collectionSlug,
|
|
355
|
+
title: resolved.collection.title,
|
|
356
|
+
description: resolved.collection.description,
|
|
357
|
+
fields: resolved.collection.fields || [],
|
|
358
|
+
api: resolved.collection.api,
|
|
359
|
+
// rowAccess is a top-level schema field; createCollection accepts arbitrary keys via spread,
|
|
360
|
+
// but to be explicit we set it on the schema after create. Simpler: write it via the schema
|
|
361
|
+
// file directly — createCollection already writes the schema, so we re-read + patch + write.
|
|
362
|
+
...(projectSlug && {meta: {project: projectSlug}})
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// Patch in rowAccess (if any) — collections.createCollection doesn't take it directly
|
|
366
|
+
if (resolved.collection.rowAccess) {
|
|
367
|
+
const {getCollection: get, updateCollection} = await import('./collections.js');
|
|
368
|
+
const cur = await get(tokens.collectionSlug);
|
|
369
|
+
if (cur) {
|
|
370
|
+
await updateCollection(tokens.collectionSlug, {
|
|
371
|
+
...cur,
|
|
372
|
+
rowAccess: resolved.collection.rowAccess
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
created.collection = tokens.collectionSlug;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// 2. Form
|
|
381
|
+
if (resolved.form) {
|
|
382
|
+
const formData = projectSlug
|
|
383
|
+
? {...resolved.form, meta: {...(resolved.form.meta || {}), project: projectSlug}}
|
|
384
|
+
: resolved.form;
|
|
385
|
+
await writeForm(tokens.formSlug, formData);
|
|
386
|
+
created.form = tokens.formSlug;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// 2b. Users — recipe-defined seed accounts. Password is mandatory (the
|
|
390
|
+
// scaffolder won't invent one); if omitted, the user is reported as
|
|
391
|
+
// skipped so the admin knows to create them manually. Auto-creating
|
|
392
|
+
// accounts with a default password would be a meaningful security trap.
|
|
393
|
+
for (const u of (resolved.users || [])) {
|
|
394
|
+
if (!u.email) continue;
|
|
395
|
+
// Existing user with the same email → skip silently. We don't update
|
|
396
|
+
// existing accounts (would silently change their password / role).
|
|
397
|
+
try {
|
|
398
|
+
const existing = await getUserByEmail(u.email);
|
|
399
|
+
if (existing) {
|
|
400
|
+
skipped.push(`user:${u.email}`);
|
|
401
|
+
warnings.push(`User "${u.email}" already exists — left unchanged`);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
} catch { /* no-op */ }
|
|
405
|
+
|
|
406
|
+
if (!u.password) {
|
|
407
|
+
skipped.push(`user:${u.email}`);
|
|
408
|
+
warnings.push(`User "${u.email}" skipped — no password set in recipe. Create the account manually under Users → New.`);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
const newUser = await createUser({
|
|
413
|
+
name: u.name || u.email,
|
|
414
|
+
email: u.email,
|
|
415
|
+
password: u.password,
|
|
416
|
+
role: u.role || 'user',
|
|
417
|
+
additionalRoles: Array.isArray(u.additionalRoles) ? u.additionalRoles : [],
|
|
418
|
+
...(projectSlug && {meta: {project: projectSlug}, projects: [projectSlug]})
|
|
419
|
+
});
|
|
420
|
+
created.users.push(newUser.email);
|
|
421
|
+
} catch (err) {
|
|
422
|
+
warnings.push(`User "${u.email}" failed: ${err.message}`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// 3. Actions — best-effort; skip with warning when Mongo isn't available
|
|
427
|
+
for (const action of (resolved.actions || [])) {
|
|
428
|
+
if (!action.slug) continue;
|
|
429
|
+
try {
|
|
430
|
+
const a = await createAction({
|
|
431
|
+
slug: action.slug,
|
|
432
|
+
title: action.title,
|
|
433
|
+
description: action.description,
|
|
434
|
+
collection: action.collection,
|
|
435
|
+
trigger: action.trigger,
|
|
436
|
+
steps: action.steps,
|
|
437
|
+
transition: action.transition,
|
|
438
|
+
access: action.access,
|
|
439
|
+
...(projectSlug && {meta: {project: projectSlug}})
|
|
440
|
+
}, opts.createdBy || null);
|
|
441
|
+
created.actions.push(a.slug);
|
|
442
|
+
} catch (err) {
|
|
443
|
+
const reason = err.message.includes('MongoDB') || err.message.includes('Mongo')
|
|
444
|
+
? `MongoDB not configured — action "${action.slug}" skipped (Pro feature)`
|
|
445
|
+
: `Action "${action.slug}" failed: ${err.message}`;
|
|
446
|
+
skipped.push(action.slug);
|
|
447
|
+
warnings.push(reason);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Wire the form's actionSlug to the first created action if the recipe
|
|
452
|
+
// wanted it — convenient for "submit this form, fire that action" patterns.
|
|
453
|
+
if (created.form && created.actions.length && resolved.form?.settings?.actionSlug === '') {
|
|
454
|
+
try {
|
|
455
|
+
const formDef = await readForm(created.form);
|
|
456
|
+
formDef.settings = formDef.settings || {};
|
|
457
|
+
formDef.settings.actionSlug = created.actions[0];
|
|
458
|
+
await writeForm(created.form, formDef);
|
|
459
|
+
} catch { /* non-fatal — admin can wire it manually */ }
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const snippet = resolved.snippet || null;
|
|
463
|
+
|
|
464
|
+
return { created, skipped, warnings, snippet };
|
|
465
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin sidebar auto-migration.
|
|
3
|
+
*
|
|
4
|
+
* Runs once on server boot. Idempotent — guarded by the existence of
|
|
5
|
+
* config/menus/admin-sidebar.json. Seeds the admin sidebar menu with the
|
|
6
|
+
* standard tree (Overview, Projects, Content, Data, System, Documentation)
|
|
7
|
+
* and patches config/menu-locations.json to map the `admin-sidebar` slot.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'fs/promises';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import {fileURLToPath} from 'url';
|
|
12
|
+
|
|
13
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
14
|
+
const DEFAULT_CONFIG_DIR = path.resolve(path.dirname(__filename), '..', '..', 'config');
|
|
15
|
+
|
|
16
|
+
const SEED_ITEMS = [
|
|
17
|
+
{
|
|
18
|
+
text: 'Overview', icon: 'home',
|
|
19
|
+
items: [
|
|
20
|
+
{text: 'Dashboard', url: '#/', icon: 'home'}
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
text: 'Projects', icon: 'folder', permission: 'projects',
|
|
25
|
+
items: [
|
|
26
|
+
{text: 'Manage projects', url: '#/projects', icon: 'folder', permission: 'projects'}
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
text: 'Content', icon: 'edit',
|
|
31
|
+
items: [
|
|
32
|
+
{text: 'Pages', url: '#/pages', icon: 'file-text', permission: 'pages'},
|
|
33
|
+
{text: 'Media', url: '#/media', icon: 'image', permission: 'media'},
|
|
34
|
+
{text: 'Menus', url: '#/menus', icon: 'menu', permission: 'menus'}
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
text: 'Data', icon: 'database',
|
|
39
|
+
items: [
|
|
40
|
+
{text: 'Collections', url: '#/collections', icon: 'database', permission: 'collections'},
|
|
41
|
+
{text: 'Forms', url: '#/forms', icon: 'layout', permission: 'collections'},
|
|
42
|
+
{text: 'Views', url: '#/views', icon: 'eye', permission: 'views'},
|
|
43
|
+
{text: 'Actions', url: '#/actions', icon: 'zap', permission: 'actions'},
|
|
44
|
+
{text: 'Blocks', url: '#/blocks', icon: 'box', permission: 'pages'},
|
|
45
|
+
{text: 'Components', url: '#/components', icon: 'component', permission: 'components'}
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
text: 'System', icon: 'settings',
|
|
50
|
+
items: [
|
|
51
|
+
{text: 'Notifications', url: '#/system/notifications', icon: 'bell', permission: 'notifications'},
|
|
52
|
+
{text: 'Roles', url: '#/roles', icon: 'shield', permission: 'plugins'},
|
|
53
|
+
{text: 'Users', url: '#/users', icon: 'users', permission: 'users'},
|
|
54
|
+
{text: 'Site Settings', url: '#/settings', icon: 'settings', permission: 'settings'},
|
|
55
|
+
{text: 'Effects', url: '#/effects', icon: 'sparkles', permission: 'settings'},
|
|
56
|
+
{text: 'Layouts', url: '#/layouts', icon: 'layout', permission: 'layouts'},
|
|
57
|
+
{text: 'Plugins', url: '#/plugins', icon: 'package', permission: 'plugins'},
|
|
58
|
+
{text: 'My Profile', url: '#/my-profile', icon: 'user'}
|
|
59
|
+
]
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
text: 'Documentation', icon: 'book',
|
|
63
|
+
items: [
|
|
64
|
+
{text: 'Usage', url: '#/documentation', icon: 'book'},
|
|
65
|
+
{text: 'Tutorials', url: '#/tutorials', icon: 'document'},
|
|
66
|
+
{text: 'API Reference', url: '#/api-reference', icon: 'code'}
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
async function exists(p) {
|
|
72
|
+
try { await fs.access(p); return true; } catch { return false; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readJson(p) {
|
|
76
|
+
return JSON.parse(await fs.readFile(p, 'utf8'));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function writeJson(p, data) {
|
|
80
|
+
await fs.writeFile(p, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Run the migration. Returns `{migrated: boolean, reason?: string}`.
|
|
85
|
+
*
|
|
86
|
+
* @param {{configDir?: string}} [opts]
|
|
87
|
+
*/
|
|
88
|
+
export async function runMigration(opts = {}) {
|
|
89
|
+
const configDir = opts.configDir || DEFAULT_CONFIG_DIR;
|
|
90
|
+
const menusDir = path.join(configDir, 'menus');
|
|
91
|
+
const menuPath = path.join(menusDir, 'admin-sidebar.json');
|
|
92
|
+
const locPath = path.join(configDir, 'menu-locations.json');
|
|
93
|
+
|
|
94
|
+
if (await exists(menuPath)) {
|
|
95
|
+
return {migrated: false, reason: 'admin-sidebar.json already present'};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
await fs.mkdir(menusDir, {recursive: true});
|
|
99
|
+
const now = new Date().toISOString();
|
|
100
|
+
await writeJson(menuPath, {
|
|
101
|
+
slug: 'admin-sidebar',
|
|
102
|
+
name: 'Admin sidebar',
|
|
103
|
+
description: 'Auto-seeded by sidebar migration on first boot',
|
|
104
|
+
items: SEED_ITEMS,
|
|
105
|
+
meta: {createdAt: now, updatedAt: now, bundled: false, presetOwner: null}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
let locations = {};
|
|
109
|
+
if (await exists(locPath)) {
|
|
110
|
+
try { locations = await readJson(locPath); } catch { /* malformed — start fresh */ }
|
|
111
|
+
}
|
|
112
|
+
locations['admin-sidebar'] = 'admin-sidebar';
|
|
113
|
+
await writeJson(locPath, locations);
|
|
114
|
+
|
|
115
|
+
console.log('[admin-sidebar] Seeded admin-sidebar menu + mapped admin-sidebar slot');
|
|
116
|
+
return {migrated: true};
|
|
117
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sitemap Service
|
|
3
|
+
* Builds sitemap.xml from the page list. Filter logic lives in
|
|
4
|
+
* shouldIncludeInSitemap() — see the TODO below.
|
|
5
|
+
*
|
|
6
|
+
* Cache: callers should wrap generate() with the 'sitemap' tag so it
|
|
7
|
+
* invalidates whenever a page is created, updated, renamed, or deleted.
|
|
8
|
+
*/
|
|
9
|
+
import {listPages} from './content.js';
|
|
10
|
+
import {getConfig} from '../config.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Decide whether a page should appear in sitemap.xml.
|
|
14
|
+
*
|
|
15
|
+
* Rules:
|
|
16
|
+
* - Must be published (drafts never index)
|
|
17
|
+
* - Must be publicly visible (role-gated pages never index)
|
|
18
|
+
* - Must not be the error page
|
|
19
|
+
* - Must not opt out via `seo.noindex`
|
|
20
|
+
*
|
|
21
|
+
* @param {object} page - Parsed page from content service
|
|
22
|
+
* @returns {boolean}
|
|
23
|
+
*/
|
|
24
|
+
function shouldIncludeInSitemap(page) {
|
|
25
|
+
return page.status === 'published'
|
|
26
|
+
&& page.visibility === 'public'
|
|
27
|
+
&& page.urlPath !== '/404'
|
|
28
|
+
&& !page.seo?.noindex;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build the full sitemap.xml document as a string.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} baseUrl - Absolute origin (e.g. 'https://example.com'),
|
|
35
|
+
* typically derived from the incoming request. Trailing slash is stripped.
|
|
36
|
+
* @returns {Promise<string>}
|
|
37
|
+
*/
|
|
38
|
+
export async function generate(baseUrl) {
|
|
39
|
+
const normalised = normaliseBaseUrl(baseUrl);
|
|
40
|
+
const pages = await listPages();
|
|
41
|
+
const entries = pages
|
|
42
|
+
.filter(shouldIncludeInSitemap)
|
|
43
|
+
.map(page => buildUrlEntry(page, normalised));
|
|
44
|
+
|
|
45
|
+
return wrapInUrlset(entries);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the robots.txt document. Points at /sitemap.xml on the given base URL.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} baseUrl - Absolute origin, typically derived from the
|
|
52
|
+
* incoming request.
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function buildRobotsTxt(baseUrl) {
|
|
56
|
+
const site = getConfig('site') || {};
|
|
57
|
+
const normalised = normaliseBaseUrl(baseUrl);
|
|
58
|
+
const sitemapUrl = normalised ? `${normalised}/sitemap.xml` : '/sitemap.xml';
|
|
59
|
+
const extra = (site.seo?.robotsExtra || '').toString().trim();
|
|
60
|
+
|
|
61
|
+
const lines = [
|
|
62
|
+
'User-agent: *',
|
|
63
|
+
'Allow: /',
|
|
64
|
+
'Disallow: /admin/',
|
|
65
|
+
'Disallow: /api/',
|
|
66
|
+
'',
|
|
67
|
+
`Sitemap: ${sitemapUrl}`
|
|
68
|
+
];
|
|
69
|
+
if (extra) lines.push('', extra);
|
|
70
|
+
return lines.join('\n') + '\n';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Helpers
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
function buildUrlEntry(page, baseUrl) {
|
|
78
|
+
const loc = escapeXml(baseUrl ? `${baseUrl}${page.urlPath}` : page.urlPath);
|
|
79
|
+
const lastmod = page.updatedAt
|
|
80
|
+
? new Date(page.updatedAt).toISOString()
|
|
81
|
+
: null;
|
|
82
|
+
|
|
83
|
+
const parts = [`<loc>${loc}</loc>`];
|
|
84
|
+
if (lastmod) parts.push(`<lastmod>${lastmod}</lastmod>`);
|
|
85
|
+
return ` <url>\n ${parts.join('\n ')}\n </url>`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function wrapInUrlset(entries) {
|
|
89
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
90
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
91
|
+
${entries.join('\n')}
|
|
92
|
+
</urlset>
|
|
93
|
+
`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Strip trailing slash from a base URL so we can concatenate urlPaths
|
|
98
|
+
* (which always start with /).
|
|
99
|
+
*/
|
|
100
|
+
function normaliseBaseUrl(raw) {
|
|
101
|
+
if (!raw || typeof raw !== 'string') return '';
|
|
102
|
+
return raw.trim().replace(/\/+$/, '');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function escapeXml(str) {
|
|
106
|
+
return String(str)
|
|
107
|
+
.replace(/&/g, '&')
|
|
108
|
+
.replace(/</g, '<')
|
|
109
|
+
.replace(/>/g, '>')
|
|
110
|
+
.replace(/"/g, '"')
|
|
111
|
+
.replace(/'/g, ''');
|
|
112
|
+
}
|