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,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, '&amp;')
108
+ .replace(/</g, '&lt;')
109
+ .replace(/>/g, '&gt;')
110
+ .replace(/"/g, '&quot;')
111
+ .replace(/'/g, '&apos;');
112
+ }