domma-cms 0.43.1 → 0.45.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 (251) hide show
  1. package/CLAUDE.md +53 -53
  2. package/README.md +27 -27
  3. package/admin/css/admin.css +1 -1
  4. package/admin/dist/domma/domma-tools.css +3 -3
  5. package/admin/dist/domma/domma-tools.min.js +3 -3
  6. package/admin/index.html +2 -2
  7. package/admin/js/app.js +1 -1
  8. package/admin/js/lib/card-builder.js +2 -2
  9. package/admin/js/lib/crud-tutorial.js +1 -1
  10. package/admin/js/lib/effect-defs.js +1 -1
  11. package/admin/js/lib/effects-builder.js +1 -1
  12. package/admin/js/lib/image-editor.js +1 -1
  13. package/admin/js/lib/markdown-toolbar.js +2 -2
  14. package/admin/js/lib/project-context.js +1 -1
  15. package/admin/js/lib/project-quick-create.js +1 -1
  16. package/admin/js/lib/scribe-composer.js +1 -1
  17. package/admin/js/lib/server-restart.js +1 -1
  18. package/admin/js/lib/shortcode-context-menu.js +2 -2
  19. package/admin/js/lib/simple-editor.js +1 -1
  20. package/admin/js/lib/themes.js +1 -1
  21. package/admin/js/lib/timeline-builder.js +1 -1
  22. package/admin/js/templates/action-editor.html +8 -8
  23. package/admin/js/templates/api-endpoint-editor.html +7 -7
  24. package/admin/js/templates/api-endpoints.html +1 -1
  25. package/admin/js/templates/api-reference.html +1 -1
  26. package/admin/js/templates/block-editor.html +6 -6
  27. package/admin/js/templates/collection-editor.html +1 -1
  28. package/admin/js/templates/component-editor.html +2 -2
  29. package/admin/js/templates/dashboard/cache.html +3 -3
  30. package/admin/js/templates/dashboard/journeys.html +3 -3
  31. package/admin/js/templates/dashboard/kpi-strip.html +3 -3
  32. package/admin/js/templates/dashboard/top-pages.html +1 -1
  33. package/admin/js/templates/dashboard/traffic-chart.html +1 -1
  34. package/admin/js/templates/docs/api-actions.html +1 -1
  35. package/admin/js/templates/docs/api-collections.html +4 -4
  36. package/admin/js/templates/docs/api-pages.html +2 -2
  37. package/admin/js/templates/docs/api-plugins.html +1 -1
  38. package/admin/js/templates/docs/api-settings.html +2 -2
  39. package/admin/js/templates/docs/api-users.html +1 -1
  40. package/admin/js/templates/docs/components-howto.html +4 -4
  41. package/admin/js/templates/docs/components-reference.html +21 -21
  42. package/admin/js/templates/docs/components-rules.html +14 -14
  43. package/admin/js/templates/docs/components-walkthrough.html +13 -13
  44. package/admin/js/templates/docs/tutorial-crud.html +49 -49
  45. package/admin/js/templates/docs/tutorial-forms.html +4 -4
  46. package/admin/js/templates/docs/tutorial-plugin.html +10 -10
  47. package/admin/js/templates/docs/usage-actions.html +6 -6
  48. package/admin/js/templates/docs/usage-cta-shortcode.html +9 -9
  49. package/admin/js/templates/docs/usage-dconfig.html +4 -4
  50. package/admin/js/templates/docs/usage-navigation.html +1 -1
  51. package/admin/js/templates/docs/usage-pages.html +1 -1
  52. package/admin/js/templates/docs/usage-shortcodes.html +24 -24
  53. package/admin/js/templates/docs/usage-users-roles.html +3 -3
  54. package/admin/js/templates/docs/usage-views.html +4 -4
  55. package/admin/js/templates/documentation.html +1 -1
  56. package/admin/js/templates/effects.html +25 -25
  57. package/admin/js/templates/form-editor.html +5 -5
  58. package/admin/js/templates/login.html +5 -5
  59. package/admin/js/templates/menu-editor.html +10 -10
  60. package/admin/js/templates/navigation.html +11 -11
  61. package/admin/js/templates/notifications.html +1 -1
  62. package/admin/js/templates/page-editor.html +4 -4
  63. package/admin/js/templates/pro-docs.html +14 -14
  64. package/admin/js/templates/role-editor.html +1 -1
  65. package/admin/js/templates/settings.html +10 -10
  66. package/admin/js/templates/tutorials.html +3 -3
  67. package/admin/js/templates/view-editor.html +15 -15
  68. package/admin/js/views/action-editor.js +1 -1
  69. package/admin/js/views/actions-list.js +1 -1
  70. package/admin/js/views/api-endpoint-editor.js +2 -2
  71. package/admin/js/views/api-endpoints.js +2 -2
  72. package/admin/js/views/api-tokens.js +3 -3
  73. package/admin/js/views/blocks.js +2 -2
  74. package/admin/js/views/collection-editor.js +1 -1
  75. package/admin/js/views/collection-entries.js +1 -1
  76. package/admin/js/views/collections.js +1 -1
  77. package/admin/js/views/component-editor.js +1 -1
  78. package/admin/js/views/components.js +4 -4
  79. package/admin/js/views/dashboard/widgets/cache.js +1 -1
  80. package/admin/js/views/dashboard/widgets/journeys.js +1 -1
  81. package/admin/js/views/dashboard/widgets/kpi-strip.js +1 -1
  82. package/admin/js/views/form-editor.js +7 -7
  83. package/admin/js/views/form-submissions.js +1 -1
  84. package/admin/js/views/forms.js +1 -1
  85. package/admin/js/views/layouts.js +1 -1
  86. package/admin/js/views/login.js +2 -2
  87. package/admin/js/views/menu-editor.js +3 -3
  88. package/admin/js/views/menu-locations.js +1 -1
  89. package/admin/js/views/menus.js +3 -3
  90. package/admin/js/views/navigation.js +8 -8
  91. package/admin/js/views/page-editor.js +40 -40
  92. package/admin/js/views/pages.js +3 -3
  93. package/admin/js/views/plugin-code.js +2 -2
  94. package/admin/js/views/plugins.js +1 -1
  95. package/admin/js/views/project-settings.js +1 -1
  96. package/admin/js/views/projects.js +1 -1
  97. package/admin/js/views/roles.js +1 -1
  98. package/admin/js/views/user-editor.js +1 -1
  99. package/admin/js/views/users.js +3 -3
  100. package/admin/js/views/view-editor.js +1 -1
  101. package/admin/js/views/view-preview.js +1 -1
  102. package/admin/js/views/views-list.js +1 -1
  103. package/bin/cli.js +8 -8
  104. package/bin/lib/config-merge.js +5 -5
  105. package/bin/update.js +16 -16
  106. package/config/site.json +86 -86
  107. package/package.json +2 -2
  108. package/plugins/_template/admin/views/index.js +1 -1
  109. package/plugins/_template/config.js +1 -1
  110. package/plugins/_template/plugin.js +1 -1
  111. package/plugins/analytics/admin/templates/analytics.html +3 -3
  112. package/plugins/analytics/admin/views/analytics.js +1 -1
  113. package/plugins/analytics/config.js +1 -1
  114. package/plugins/analytics/plugin.js +4 -4
  115. package/plugins/analytics/plugin.json +1 -1
  116. package/plugins/analytics/public/inject-body.html +2 -2
  117. package/plugins/analytics/public/inject-head.html +1 -1
  118. package/plugins/blog/admin/templates/blog.html +4 -4
  119. package/plugins/blog/admin/views/blog.js +3 -3
  120. package/plugins/blog/admin/views/categories.js +2 -2
  121. package/plugins/blog/admin/views/comments.js +4 -4
  122. package/plugins/blog/admin/views/post-editor.js +1 -1
  123. package/plugins/blog/plugin.js +2 -2
  124. package/plugins/blog/plugin.json +4 -4
  125. package/plugins/contacts/admin/views/contacts.js +8 -8
  126. package/plugins/contacts/plugin.js +4 -4
  127. package/plugins/contacts/plugin.json +1 -1
  128. package/plugins/invoice/admin/templates/editor.html +6 -6
  129. package/plugins/invoice/admin/templates/index.html +4 -4
  130. package/plugins/invoice/admin/views/editor.js +3 -3
  131. package/plugins/invoice/admin/views/index.js +4 -4
  132. package/plugins/invoice/admin/views/issuers.js +1 -1
  133. package/plugins/invoice/admin/views/party-view.js +3 -3
  134. package/plugins/invoice/admin/views/receivers.js +1 -1
  135. package/plugins/invoice/config.js +1 -1
  136. package/plugins/invoice/plugin.js +1 -1
  137. package/plugins/invoice/plugin.json +4 -4
  138. package/plugins/notes/admin/views/notes.js +4 -4
  139. package/plugins/notes/plugin.js +1 -1
  140. package/plugins/notes/plugin.json +1 -1
  141. package/plugins/site-search/admin/templates/site-search.html +3 -3
  142. package/plugins/site-search/admin/views/site-search.js +1 -1
  143. package/plugins/site-search/config.js +1 -1
  144. package/plugins/site-search/plugin.js +4 -4
  145. package/plugins/site-search/plugin.json +1 -1
  146. package/plugins/surveys/admin/templates/results.html +1 -1
  147. package/plugins/surveys/admin/templates/survey-editor.html +4 -4
  148. package/plugins/surveys/admin/views/audience.js +6 -6
  149. package/plugins/surveys/admin/views/results.js +3 -3
  150. package/plugins/surveys/admin/views/survey-editor.js +1 -1
  151. package/plugins/surveys/admin/views/surveys.js +2 -2
  152. package/plugins/surveys/plugin.js +1 -1
  153. package/plugins/surveys/plugin.json +4 -4
  154. package/plugins/theme-switcher/admin/views/theme-switcher.js +1 -1
  155. package/plugins/theme-switcher/plugin.json +3 -3
  156. package/plugins/theme-switcher/public/inject-body.html +28 -28
  157. package/plugins/theme-switcher/public/inject-head.html +1 -1
  158. package/plugins/todo/plugin.js +1 -1
  159. package/public/css/forms.css +1 -1
  160. package/public/js/collection-browser.js +1 -1
  161. package/public/js/collection-context.js +1 -1
  162. package/public/js/form-logic-engine.js +1 -1
  163. package/public/js/forms.js +2 -1
  164. package/public/js/site.js +1 -1
  165. package/scripts/build.js +100 -6
  166. package/scripts/create-plugin.js +2 -2
  167. package/scripts/fresh.js +2 -2
  168. package/scripts/gen-instance-secret.js +1 -1
  169. package/scripts/pro.js +12 -12
  170. package/scripts/reset.js +2 -2
  171. package/scripts/setup.js +15 -15
  172. package/scripts/users.js +5 -5
  173. package/scripts/verify-assets.mjs +6 -6
  174. package/server/config.js +1 -1
  175. package/server/middleware/auth.js +11 -11
  176. package/server/middleware/managerAuth.js +3 -3
  177. package/server/routes/api/actions.js +4 -4
  178. package/server/routes/api/api-endpoints.js +7 -7
  179. package/server/routes/api/api-tokens.js +5 -5
  180. package/server/routes/api/auth.js +10 -10
  181. package/server/routes/api/blocks.js +3 -3
  182. package/server/routes/api/collections.js +18 -18
  183. package/server/routes/api/dashboard.js +5 -5
  184. package/server/routes/api/effects.js +2 -2
  185. package/server/routes/api/endpoints-public.js +5 -5
  186. package/server/routes/api/forms.js +47 -47
  187. package/server/routes/api/menu-locations.js +3 -3
  188. package/server/routes/api/menus.js +7 -7
  189. package/server/routes/api/notifications.js +2 -2
  190. package/server/routes/api/pages.js +14 -2
  191. package/server/routes/api/plugin-marketplace.js +3 -3
  192. package/server/routes/api/plugins.js +6 -6
  193. package/server/routes/api/projects.js +9 -9
  194. package/server/routes/api/scaffold.js +7 -7
  195. package/server/routes/api/settings.js +6 -6
  196. package/server/routes/api/sidebar.js +1 -1
  197. package/server/routes/api/users.js +5 -5
  198. package/server/routes/api/versions.js +1 -1
  199. package/server/routes/api/views.js +1 -1
  200. package/server/routes/docs-public.js +3 -3
  201. package/server/routes/public.js +14 -14
  202. package/server/server.js +34 -16
  203. package/server/services/actions.js +19 -19
  204. package/server/services/adapters/FileAdapter.js +2 -2
  205. package/server/services/adapters/MongoAdapter.js +5 -5
  206. package/server/services/apiEndpoints.js +15 -15
  207. package/server/services/apiTokens.js +7 -7
  208. package/server/services/blocks.js +13 -13
  209. package/server/services/cache/drivers/MemoryDriver.js +3 -3
  210. package/server/services/cache/drivers/NoneDriver.js +1 -1
  211. package/server/services/cache/index.js +5 -5
  212. package/server/services/collections.js +14 -14
  213. package/server/services/components.js +9 -9
  214. package/server/services/connectionManager.js +4 -4
  215. package/server/services/content.js +25 -2
  216. package/server/services/docs.js +14 -14
  217. package/server/services/email.js +1 -1
  218. package/server/services/filterEngine.js +6 -6
  219. package/server/services/forms.js +18 -10
  220. package/server/services/health.js +1 -1
  221. package/server/services/hooks.js +7 -7
  222. package/server/services/images.js +4 -4
  223. package/server/services/managerClient.js +1 -1
  224. package/server/services/markdown.js +79 -79
  225. package/server/services/menuRender.js +5 -5
  226. package/server/services/menus-migration.js +3 -3
  227. package/server/services/menus.js +31 -31
  228. package/server/services/permissionRegistry.js +1 -1
  229. package/server/services/pluginFiles.js +5 -5
  230. package/server/services/pluginInstaller.js +17 -17
  231. package/server/services/pluginScaffold.js +1 -1
  232. package/server/services/plugins.js +17 -17
  233. package/server/services/presetCollections.js +2 -2
  234. package/server/services/projects.js +35 -26
  235. package/server/services/recipes/contact-list.json +1 -1
  236. package/server/services/recipes/onboarding.json +8 -8
  237. package/server/services/references.js +8 -8
  238. package/server/services/renderer.js +9 -9
  239. package/server/services/roles.js +9 -9
  240. package/server/services/rowAccess.js +10 -10
  241. package/server/services/scaffolder.js +30 -30
  242. package/server/services/sidebar-migration.js +10 -10
  243. package/server/services/sitemap.js +1 -1
  244. package/server/services/userProfiles.js +4 -4
  245. package/server/services/userRoles.js +7 -7
  246. package/server/services/users.js +5 -5
  247. package/server/services/versions.js +3 -3
  248. package/server/services/viewPipeline.js +6 -6
  249. package/server/services/views.js +11 -11
  250. package/server/templates/page.html +5 -5
  251. package/config/navigation.json.bak +0 -31
@@ -4,18 +4,18 @@
4
4
  * Submissions are stored exclusively in Collections (no dual-storage).
5
5
  *
6
6
  * Endpoints (prefix: /api):
7
- * GET /forms — admin: list all forms
8
- * POST /forms — admin: create new form
9
- * GET /forms/:slug — admin: get form definition
10
- * GET /forms/:slug/public — public: get form (no actions block)
11
- * PUT /forms/:slug — admin: update form definition
12
- * DELETE /forms/:slug — admin: delete form
13
- * GET /forms/:slug/submissions — admin: list submissions from collection
14
- * GET /forms/:slug/submissions/export — admin: CSV export from collection
15
- * DELETE /forms/:slug/submissions — admin: clear all submissions
16
- * DELETE /forms/:slug/submissions/:id — admin: delete one submission
17
- * POST /forms/submit/:slug — public: accept submission
18
- * POST /forms/test-email — admin: send test email
7
+ * GET /forms - admin: list all forms
8
+ * POST /forms - admin: create new form
9
+ * GET /forms/:slug - admin: get form definition
10
+ * GET /forms/:slug/public - public: get form (no actions block)
11
+ * PUT /forms/:slug - admin: update form definition
12
+ * DELETE /forms/:slug - admin: delete form
13
+ * GET /forms/:slug/submissions - admin: list submissions from collection
14
+ * GET /forms/:slug/submissions/export - admin: CSV export from collection
15
+ * DELETE /forms/:slug/submissions - admin: clear all submissions
16
+ * DELETE /forms/:slug/submissions/:id - admin: delete one submission
17
+ * POST /forms/submit/:slug - public: accept submission
18
+ * POST /forms/test-email - admin: send test email
19
19
  */
20
20
  import {createRequire} from 'module';
21
21
  import {fileURLToPath} from 'url';
@@ -40,7 +40,7 @@ import {v4 as uuidv4} from 'uuid';
40
40
  /**
41
41
  * Detect form↔collection mismatches at save time so admins know BEFORE
42
42
  * users hit silent rejection. Returns an array of human-readable warning
43
- * strings — empty when the form lines up cleanly with its target collection.
43
+ * strings - empty when the form lines up cleanly with its target collection.
44
44
  *
45
45
  * Checks performed:
46
46
  * - Target collection exists (otherwise every submission goes nowhere)
@@ -63,7 +63,7 @@ async function detectFormCollectionMismatch(form) {
63
63
  return warnings;
64
64
  }
65
65
  if (!collection) {
66
- warnings.push(`Target collection "${targetSlug}" does not exist — submissions will fail. Either create the collection or change the form's target.`);
66
+ warnings.push(`Target collection "${targetSlug}" does not exist - submissions will fail. Either create the collection or change the form's target.`);
67
67
  return warnings;
68
68
  }
69
69
 
@@ -145,7 +145,7 @@ function validateUpload(fieldCfg, part, size) {
145
145
  * `content/media/`, and replaced by a `{ url, name, size, mime }` reference.
146
146
  *
147
147
  * Throws on validation failure (caller handles HTTP 400). Files are streamed
148
- * into memory up to fastify-multipart's configured limit — fine for the
148
+ * into memory up to fastify-multipart's configured limit - fine for the
149
149
  * resume-pdf / portrait-photo scale; large uploads should still use the admin
150
150
  * media flow.
151
151
  *
@@ -163,7 +163,7 @@ async function parseMultipartForm(request, form) {
163
163
  if (part.type === 'file') {
164
164
  const fieldCfg = fileFields.get(part.fieldname);
165
165
  if (!fieldCfg) {
166
- // Unknown file field — drain and skip rather than 400, so a
166
+ // Unknown file field - drain and skip rather than 400, so a
167
167
  // form that gains a file input later doesn't reject older
168
168
  // submissions in-flight from a stale page.
169
169
  for await (const _ of part.file) { /* drain */ }
@@ -174,7 +174,7 @@ async function parseMultipartForm(request, form) {
174
174
  const buffer = Buffer.concat(chunks);
175
175
  const err = validateUpload(fieldCfg, part, buffer.length);
176
176
  if (err) throw new Error(err);
177
- if (buffer.length === 0) continue; // empty file input — skip
177
+ if (buffer.length === 0) continue; // empty file input - skip
178
178
  const saved = await saveMedia(safeFilename(part.filename), buffer);
179
179
  out[part.fieldname] = {
180
180
  url: saved.url,
@@ -183,7 +183,7 @@ async function parseMultipartForm(request, form) {
183
183
  mime: part.mimetype
184
184
  };
185
185
  } else {
186
- // Text field — last value wins on duplicates (browser-standard).
186
+ // Text field - last value wins on duplicates (browser-standard).
187
187
  out[part.fieldname] = part.value;
188
188
  }
189
189
  }
@@ -237,7 +237,7 @@ export async function formsRoutes(fastify) {
237
237
  const canDelete = { preHandler: [authenticate, requirePermission('collections', 'delete')] };
238
238
 
239
239
  // -----------------------------------------------------------------------
240
- // GET /forms — list all form definitions with submission counts
240
+ // GET /forms - list all form definitions with submission counts
241
241
  // -----------------------------------------------------------------------
242
242
  fastify.get('/forms', canRead, async (request) => {
243
243
  const {canSeeArtefact} = await import('../../services/projects.js');
@@ -245,7 +245,7 @@ export async function formsRoutes(fastify) {
245
245
  const result = await Promise.all(forms.map(async form => {
246
246
  let submissionCount = 0;
247
247
  try {
248
- // listEntries returns { entries, total, page, limit } — total is the
248
+ // listEntries returns { entries, total, page, limit } - total is the
249
249
  // unpaginated count which is what we want for the badge here.
250
250
  const r = await listEntries(form.slug, { limit: 0 });
251
251
  submissionCount = r.total ?? (r.entries || []).length;
@@ -263,7 +263,7 @@ export async function formsRoutes(fastify) {
263
263
  });
264
264
 
265
265
  // -----------------------------------------------------------------------
266
- // POST /forms — create new form
266
+ // POST /forms - create new form
267
267
  // -----------------------------------------------------------------------
268
268
  fastify.post('/forms', canCreate, async (request, reply) => {
269
269
  const { title, slug: rawSlug } = request.body || {};
@@ -280,7 +280,7 @@ export async function formsRoutes(fastify) {
280
280
  await readForm(slug);
281
281
  return reply.status(409).send({ error: `A form with slug "${slug}" already exists.` });
282
282
  } catch {
283
- // Does not exist — good
283
+ // Does not exist - good
284
284
  }
285
285
 
286
286
  const now = new Date().toISOString();
@@ -333,7 +333,7 @@ export async function formsRoutes(fastify) {
333
333
  });
334
334
 
335
335
  // -----------------------------------------------------------------------
336
- // GET /forms/:slug — get single form (admin, includes actions)
336
+ // GET /forms/:slug - get single form (admin, includes actions)
337
337
  // -----------------------------------------------------------------------
338
338
  fastify.get('/forms/:slug', canRead, async (request, reply) => {
339
339
  let form;
@@ -350,7 +350,7 @@ export async function formsRoutes(fastify) {
350
350
  });
351
351
 
352
352
  // -----------------------------------------------------------------------
353
- // GET /forms/:slug/public — get form for public rendering (no actions)
353
+ // GET /forms/:slug/public - get form for public rendering (no actions)
354
354
  // -----------------------------------------------------------------------
355
355
  fastify.get('/forms/:slug/public', async (request, reply) => {
356
356
  try {
@@ -363,7 +363,7 @@ export async function formsRoutes(fastify) {
363
363
  });
364
364
 
365
365
  // -----------------------------------------------------------------------
366
- // PUT /forms/:slug — update form definition
366
+ // PUT /forms/:slug - update form definition
367
367
  // -----------------------------------------------------------------------
368
368
  fastify.put('/forms/:slug', canUpdate, async (request, reply) => {
369
369
  const { slug } = request.params;
@@ -386,14 +386,14 @@ export async function formsRoutes(fastify) {
386
386
 
387
387
  // Non-blocking warnings: check the form's fields against its target
388
388
  // collection's required fields. Missing-required-on-form would cause
389
- // every submission to fail server-side validation — admin gets a
389
+ // every submission to fail server-side validation - admin gets a
390
390
  // heads-up here so they can fix it BEFORE users hit silent rejection.
391
391
  const warnings = await detectFormCollectionMismatch(updated);
392
392
  return warnings.length ? { ...updated, warnings } : updated;
393
393
  });
394
394
 
395
395
  // -----------------------------------------------------------------------
396
- // DELETE /forms/:slug — delete form
396
+ // DELETE /forms/:slug - delete form
397
397
  // -----------------------------------------------------------------------
398
398
  fastify.delete('/forms/:slug', canDelete, async (request, reply) => {
399
399
  const { slug } = request.params;
@@ -406,7 +406,7 @@ export async function formsRoutes(fastify) {
406
406
  });
407
407
 
408
408
  // -----------------------------------------------------------------------
409
- // GET /forms/:slug/submissions — list submissions from collection
409
+ // GET /forms/:slug/submissions - list submissions from collection
410
410
  // -----------------------------------------------------------------------
411
411
  fastify.get('/forms/:slug/submissions', canRead, async (request, reply) => {
412
412
  const { slug } = request.params;
@@ -420,19 +420,19 @@ export async function formsRoutes(fastify) {
420
420
  const targetSlug = (collectionAction?.enabled && collectionAction.slug) ? collectionAction.slug : slug;
421
421
 
422
422
  // A form whose backing collection has not been provisioned yet simply
423
- // has no submissions — return an empty list rather than a 404 so the
423
+ // has no submissions - return an empty list rather than a 404 so the
424
424
  // admin view loads cleanly instead of erroring.
425
425
  const collection = await getCollection(targetSlug);
426
426
  if (!collection) return [];
427
427
 
428
- // listEntries returns { entries, total, page, limit } — unwrap to the
428
+ // listEntries returns { entries, total, page, limit } - unwrap to the
429
429
  // array. Newest-first is the conventional display order.
430
430
  const { entries } = await listEntries(targetSlug, { limit: 0 });
431
431
  return [...entries].reverse();
432
432
  });
433
433
 
434
434
  // -----------------------------------------------------------------------
435
- // GET /forms/:slug/submissions/export — CSV download from collection
435
+ // GET /forms/:slug/submissions/export - CSV download from collection
436
436
  // -----------------------------------------------------------------------
437
437
  fastify.get('/forms/:slug/submissions/export', canRead, async (request, reply) => {
438
438
  const { slug } = request.params;
@@ -456,7 +456,7 @@ export async function formsRoutes(fastify) {
456
456
  });
457
457
 
458
458
  // -----------------------------------------------------------------------
459
- // GET /forms/:slug/submissions/export/json — JSON export from collection
459
+ // GET /forms/:slug/submissions/export/json - JSON export from collection
460
460
  // -----------------------------------------------------------------------
461
461
  fastify.get('/forms/:slug/submissions/export/json', canRead, async (request, reply) => {
462
462
  const { slug } = request.params;
@@ -479,7 +479,7 @@ export async function formsRoutes(fastify) {
479
479
  });
480
480
 
481
481
  // -----------------------------------------------------------------------
482
- // DELETE /forms/:slug/submissions — clear all submissions
482
+ // DELETE /forms/:slug/submissions - clear all submissions
483
483
  // -----------------------------------------------------------------------
484
484
  fastify.delete('/forms/:slug/submissions', canDelete, async (request, reply) => {
485
485
  const { slug } = request.params;
@@ -492,7 +492,7 @@ export async function formsRoutes(fastify) {
492
492
  });
493
493
 
494
494
  // -----------------------------------------------------------------------
495
- // DELETE /forms/:slug/submissions/:id — delete one submission
495
+ // DELETE /forms/:slug/submissions/:id - delete one submission
496
496
  // -----------------------------------------------------------------------
497
497
  fastify.delete('/forms/:slug/submissions/:id', canDelete, async (request, reply) => {
498
498
  const { slug, id } = request.params;
@@ -505,12 +505,12 @@ export async function formsRoutes(fastify) {
505
505
  });
506
506
 
507
507
  /*
508
- * POST /forms/submit/:slug — public form submission.
508
+ * POST /forms/submit/:slug - public form submission.
509
509
  *
510
510
  * Authentication is OPTIONAL: when a valid JWT is present the submitter
511
511
  * is captured as the entry's `createdBy` and passed into any triggered
512
512
  * action's template context (as `{{user.id}}`, `{{user.email}}`, etc.).
513
- * Anonymous submissions still work — `createdBy` is null and actions
513
+ * Anonymous submissions still work - `createdBy` is null and actions
514
514
  * receive an empty user. This is what makes the apply/bookmark patterns
515
515
  * work without forcing every form to be auth-only:
516
516
  *
@@ -527,7 +527,7 @@ export async function formsRoutes(fastify) {
527
527
  return reply.status(404).send({ error: 'Form not found.' });
528
528
  }
529
529
 
530
- // Multipart parsing — when the request is multipart (file uploads),
530
+ // Multipart parsing - when the request is multipart (file uploads),
531
531
  // we walk parts ourselves: text fields go into `body`, file parts are
532
532
  // validated against the form's field config and saved to /content/media,
533
533
  // then their { url, name, size, mime } reference object lands in `body`
@@ -542,7 +542,7 @@ export async function formsRoutes(fastify) {
542
542
  }
543
543
  const settings = form.settings || {};
544
544
 
545
- // Best-effort auth — never reject, just enrich.
545
+ // Best-effort auth - never reject, just enrich.
546
546
  let submittingUser = null;
547
547
  try {
548
548
  const decoded = await request.jwtVerify();
@@ -556,12 +556,12 @@ export async function formsRoutes(fastify) {
556
556
  }
557
557
  } catch { /* anonymous submission */ }
558
558
 
559
- // Honeypot check — silently accept if filled (bot detected)
559
+ // Honeypot check - silently accept if filled (bot detected)
560
560
  if (settings.honeypot && body._hp) {
561
561
  return { ok: true, message: settings.successMessage, redirect: settings.successRedirect || null };
562
562
  }
563
563
 
564
- // Timing check — silently accept if submitted too fast (< 2 s, likely a bot)
564
+ // Timing check - silently accept if submitted too fast (< 2 s, likely a bot)
565
565
  if (settings.honeypot && body._t) {
566
566
  const elapsed = Date.now() - Number(body._t);
567
567
  if (!Number.isNaN(elapsed) && elapsed < 2000) {
@@ -614,7 +614,7 @@ export async function formsRoutes(fastify) {
614
614
  return reply.status(429).send({ error: 'Too many submissions. Please try again later.' });
615
615
  }
616
616
 
617
- // Build submission data — only include visible fields
617
+ // Build submission data - only include visible fields
618
618
  const data = {};
619
619
  for (const field of form.fields || []) {
620
620
  if (field.type === 'page-break' || field.type === 'spacer') continue;
@@ -630,9 +630,9 @@ export async function formsRoutes(fastify) {
630
630
  // CRITICAL: collection write failure is the difference between "your
631
631
  // submission was saved" and "your submission disappeared into thin air".
632
632
  // We MUST surface the error to the user rather than logging a warning
633
- // and returning success — anything else is a silent-data-loss bug.
633
+ // and returning success - anything else is a silent-data-loss bug.
634
634
  // (Email / webhook / action failures downstream are still treated as
635
- // non-fatal — they fire AFTER the entry is persisted, so even a partial
635
+ // non-fatal - they fire AFTER the entry is persisted, so even a partial
636
636
  // delivery has the original submission safely stored.)
637
637
  const collectionAction = form.actions?.collection;
638
638
  const targetSlug = (collectionAction?.enabled && collectionAction.slug) ? collectionAction.slug : slug;
@@ -653,12 +653,12 @@ export async function formsRoutes(fastify) {
653
653
  createdBy: submittingUser?.id || null
654
654
  });
655
655
  } else {
656
- // Could not resolve or provision a target — e.g. collection
656
+ // Could not resolve or provision a target - e.g. collection
657
657
  // storage is explicitly disabled with no slug. Fail loudly
658
658
  // rather than silently lose submissions.
659
659
  fastify.log.warn(`[forms] Submission for "${slug}" had no target collection "${targetSlug}"`);
660
660
  return reply.status(500).send({
661
- error: `Submission not saved — target collection "${targetSlug}" does not exist. Please contact the site administrator.`
661
+ error: `Submission not saved - target collection "${targetSlug}" does not exist. Please contact the site administrator.`
662
662
  });
663
663
  }
664
664
  } catch (err) {
@@ -706,7 +706,7 @@ export async function formsRoutes(fastify) {
706
706
  }
707
707
  }
708
708
 
709
- // CMS Action trigger — forwards the authenticated submitter so
709
+ // CMS Action trigger - forwards the authenticated submitter so
710
710
  // action steps can interpolate {{user.id}}, {{user.email}}, etc.
711
711
  // (Anonymous submissions still trigger the action with user={}.)
712
712
  const actionSlug = form.settings?.actionSlug;
@@ -735,7 +735,7 @@ export async function formsRoutes(fastify) {
735
735
  });
736
736
 
737
737
  // -----------------------------------------------------------------------
738
- // POST /forms/test-email — send a test email
738
+ // POST /forms/test-email - send a test email
739
739
  // -----------------------------------------------------------------------
740
740
  fastify.post('/forms/test-email', { preHandler: [authenticate, requireAdmin] }, async (request, reply) => {
741
741
  const smtp = getConfig('site').smtp || {};
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Menu locations API
3
3
  *
4
- * GET /api/menu-locations — current slot → menu-slug map
5
- * PUT /api/menu-locations — replace the map (validates against registry + menus)
6
- * GET /api/menu-locations/registry — list registered slots (core + plugin-contributed)
4
+ * GET /api/menu-locations - current slot → menu-slug map
5
+ * PUT /api/menu-locations - replace the map (validates against registry + menus)
6
+ * GET /api/menu-locations/registry - list registered slots (core + plugin-contributed)
7
7
  *
8
8
  * Auth middlewares are accepted as options so tests can supply no-ops without
9
9
  * needing to register `@fastify/jwt` + seed the roles cache. In production the
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Menus API
3
3
  *
4
- * GET /api/menus — list (metadata only)
5
- * GET /api/menus/:slug — full menu
6
- * POST /api/menus — create
7
- * PUT /api/menus/:slug — replace
8
- * DELETE /api/menus/:slug — delete (409 if mapped to a slot)
9
- * POST /api/menus/:slug/duplicate — copy as <slug>-copy
4
+ * GET /api/menus - list (metadata only)
5
+ * GET /api/menus/:slug - full menu
6
+ * POST /api/menus - create
7
+ * PUT /api/menus/:slug - replace
8
+ * DELETE /api/menus/:slug - delete (409 if mapped to a slot)
9
+ * POST /api/menus/:slug/duplicate - copy as <slug>-copy
10
10
  *
11
11
  * Auth middlewares are accepted as options so tests can supply no-ops without
12
12
  * needing to register `@fastify/jwt` + seed the roles cache. In production the
@@ -93,7 +93,7 @@ export async function menusRoutes(fastify, opts = {}) {
93
93
  const slot = Object.entries(map).find(([, slug]) => slug === request.params.slug)?.[0];
94
94
  if (slot) {
95
95
  return reply.status(409).send({
96
- error: `Cannot delete — menu is mapped to slot "${slot}". Unmap it first.`
96
+ error: `Cannot delete - menu is mapped to slot "${slot}". Unmap it first.`
97
97
  });
98
98
  }
99
99
  const ok = await deleteMenu(request.params.slug);
@@ -83,7 +83,7 @@ export async function notificationsRoutes(fastify) {
83
83
  }));
84
84
  });
85
85
 
86
- // --- Unread count (lightweight — for bell badge) ---
86
+ // --- Unread count (lightweight - for bell badge) ---
87
87
  fastify.get('/system/notifications/unread-count', canRead, async (request) => {
88
88
  const userId = request.user.id;
89
89
  const now = new Date().toISOString();
@@ -110,7 +110,7 @@ export async function notificationsRoutes(fastify) {
110
110
  if (!entry) return reply.code(404).send({ error: 'Notification not found.' });
111
111
 
112
112
  const readBy = Array.isArray(entry.data.readBy) ? entry.data.readBy : [];
113
- if (readBy.includes(userId)) return { ok: true }; // already read — idempotent
113
+ if (readBy.includes(userId)) return { ok: true }; // already read - idempotent
114
114
 
115
115
  const updated = await adapter.update(SLUG, id, {
116
116
  ...entry.data,
@@ -26,7 +26,7 @@ export async function pagesRoutes(fastify) {
26
26
  return {html};
27
27
  });
28
28
 
29
- // Aggregate unique tags from all pages — must be registered before the wildcard /pages/* route
29
+ // Aggregate unique tags from all pages - must be registered before the wildcard /pages/* route
30
30
  fastify.get('/pages/tags', canRead, async (request, reply) => {
31
31
  const pages = await listPages();
32
32
  const tagSet = new Set();
@@ -72,8 +72,20 @@ export async function pagesRoutes(fastify) {
72
72
  const { urlPath, frontmatter, body } = request.body;
73
73
  if (!urlPath) return reply.status(400).send({ error: 'urlPath is required' });
74
74
 
75
+ // Name the project in the conflict. A page filed under a project the
76
+ // admin isn't currently browsing is absent from the filtered list, so
77
+ // a bare "already exists" reads as a phantom.
75
78
  const existing = await getPage(urlPath);
76
- if (existing) return reply.status(409).send({ error: 'Page already exists at that path' });
79
+ if (existing) {
80
+ const {getProjectForPage} = await import('../../services/projects.js');
81
+ const project = await getProjectForPage(urlPath, existing.project);
82
+ return reply.status(409).send({
83
+ error: `A page already exists at ${urlPath}`
84
+ + (project ? ` (project: ${project})` : ''),
85
+ urlPath,
86
+ project: project || null
87
+ });
88
+ }
77
89
 
78
90
  const page = await createPage(urlPath, frontmatter || {}, body || '');
79
91
  return reply.status(201).send(page);
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Plugin Marketplace API
3
- * GET /api/plugins/marketplace — fetch catalogue from manager (requires MANAGER_URL)
4
- * POST /api/plugins/marketplace/install — download + install a plugin bundle
5
- * DELETE /api/plugins/marketplace/:slug — uninstall an installed plugin
3
+ * GET /api/plugins/marketplace - fetch catalogue from manager (requires MANAGER_URL)
4
+ * POST /api/plugins/marketplace/install - download + install a plugin bundle
5
+ * DELETE /api/plugins/marketplace/:slug - uninstall an installed plugin
6
6
  *
7
7
  * All routes require authentication + admin role.
8
8
  */
@@ -24,7 +24,7 @@ import {getConfig} from '../../config.js';
24
24
  * True when something will bring the process back after a clean exit.
25
25
  *
26
26
  * Two supervisors matter in practice: pm2 (sets `pm_id` / `PM2_HOME`) and the
27
- * fleet manager, which forks each site with an IPC channel — hence
27
+ * fleet manager, which forks each site with an IPC channel - hence
28
28
  * `process.send`. A bare `node server/server.js` has neither, and exiting there
29
29
  * would leave the site down for good, so restarts are refused.
30
30
  *
@@ -35,7 +35,7 @@ export function hasSupervisor() {
35
35
  }
36
36
 
37
37
  // One pending restart at a time. An admin flipping four plugins in a row should
38
- // cost one restart, not four — each toggle pushes the timer out instead of
38
+ // cost one restart, not four - each toggle pushes the timer out instead of
39
39
  // stacking exits.
40
40
  let _restartTimer = null;
41
41
 
@@ -43,7 +43,7 @@ let _restartTimer = null;
43
43
  * Exit cleanly so the supervisor restarts us on the new plugin set. Debounced,
44
44
  * and long enough after the reply for it to reach the browser.
45
45
  *
46
- * The timer is unref'd so it can never be the thing keeping the process alive —
46
+ * The timer is unref'd so it can never be the thing keeping the process alive -
47
47
  * in production the listening socket does that.
48
48
  *
49
49
  * @param {Function} exit How to exit; injectable so tests don't take the runner down
@@ -98,7 +98,7 @@ export async function pluginsRoutes(fastify, opts = {}) {
98
98
 
99
99
  // Scaffold a new plugin from plugins/_template. The plugin is created
100
100
  // pre-enabled so it goes live on the next server restart (its routes and
101
- // admin views only register at boot — same restart rule as enable/disable).
101
+ // admin views only register at boot - same restart rule as enable/disable).
102
102
  fastify.post('/plugins/scaffold', canCreate, async (request, reply) => {
103
103
  const {slug, displayName, description, author, icon} = request.body || {};
104
104
  try {
@@ -134,7 +134,7 @@ export async function pluginsRoutes(fastify, opts = {}) {
134
134
 
135
135
  // A plugin's routes, hooks and admin views only register at boot, so a
136
136
  // toggle does nothing until the process restarts. Do it for the admin
137
- // rather than leaving a "restart to apply" note they have to action —
137
+ // rather than leaving a "restart to apply" note they have to action -
138
138
  // but only where a supervisor will bring the process back, and only
139
139
  // when the enabled flag actually changed (a settings-only save must not
140
140
  // bounce the site).
@@ -154,7 +154,7 @@ export async function pluginsRoutes(fastify, opts = {}) {
154
154
  return getAdminPluginConfig();
155
155
  });
156
156
 
157
- // ── In-admin code editor (plugins.develop — code execution grade) ──────
157
+ // ── In-admin code editor (plugins.develop - code execution grade) ──────
158
158
 
159
159
  const mapErr = (reply, err) => reply.status(err.statusCode || 500).send({ error: err.message });
160
160
 
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Projects API
3
3
  *
4
- * GET /api/projects — list visible projects
5
- * GET /api/projects/:slug — full project
6
- * POST /api/projects — create
7
- * PUT /api/projects/:slug — edit (slug immutable)
8
- * DELETE /api/projects/:slug — delete (403 for core; 409 if any artefacts tagged)
9
- * GET /api/projects/:slug/artefacts — grouped artefacts
10
- * POST /api/projects/:slug/untag-all — escape hatch before delete (403 for core)
4
+ * GET /api/projects - list visible projects
5
+ * GET /api/projects/:slug - full project
6
+ * POST /api/projects - create
7
+ * PUT /api/projects/:slug - edit (slug immutable)
8
+ * DELETE /api/projects/:slug - delete (403 for core; 409 if any artefacts tagged)
9
+ * GET /api/projects/:slug/artefacts - grouped artefacts
10
+ * POST /api/projects/:slug/untag-all - escape hatch before delete (403 for core)
11
11
  *
12
12
  * Auth middlewares are accepted as DI options so tests can supply no-ops.
13
13
  */
@@ -88,7 +88,7 @@ export async function projectsRoutes(fastify, opts = {}) {
88
88
  const total = Object.values(grouped).reduce((sum, arr) => sum + arr.length, 0);
89
89
  if (total > 0) {
90
90
  return reply.status(409).send({
91
- error: `Cannot delete — ${total} artefacts still tagged with this project`,
91
+ error: `Cannot delete - ${total} artefacts still tagged with this project`,
92
92
  artefacts: Object.fromEntries(Object.entries(grouped).map(([k, v]) => [k, v.length]))
93
93
  });
94
94
  }
@@ -107,7 +107,7 @@ export async function projectsRoutes(fastify, opts = {}) {
107
107
 
108
108
  fastify.post('/projects/:slug/untag-all', canDelete, async (request, reply) => {
109
109
  if (request.params.slug === CORE_PROJECT_SLUG) {
110
- return reply.status(403).send({error: 'Cannot untag the core project — artefacts would immediately resolve back to it'});
110
+ return reply.status(403).send({error: 'Cannot untag the core project - artefacts would immediately resolve back to it'});
111
111
  }
112
112
  const target = await getProject(request.params.slug);
113
113
  if (target?.protected === true) {
@@ -1,15 +1,15 @@
1
1
  /**
2
- * Scaffold API — list bundled recipes and apply one to create a working CRUD
2
+ * Scaffold API - list bundled recipes and apply one to create a working CRUD
3
3
  * system (Collection + Form + Actions) in a single call.
4
4
  *
5
- * Admin-only — these endpoints can create resources, so they require the same
5
+ * Admin-only - these endpoints can create resources, so they require the same
6
6
  * permission tier as collection/form management. The `apply` endpoint is
7
7
  * non-atomic across recipe pieces but returns a clear summary of what was
8
8
  * created vs. skipped so the caller can clean up if needed.
9
9
  *
10
10
  * Endpoints:
11
- * GET /api/scaffold/recipes — list bundled recipes (name, description, options)
12
- * POST /api/scaffold/apply — apply one recipe with caller-provided option overrides
11
+ * GET /api/scaffold/recipes - list bundled recipes (name, description, options)
12
+ * POST /api/scaffold/apply - apply one recipe with caller-provided option overrides
13
13
  */
14
14
  import {applyRecipe, getRecipe, listRecipes} from '../../services/scaffolder.js';
15
15
  import {authenticate, requirePermission} from '../../middleware/auth.js';
@@ -17,7 +17,7 @@ import {authenticate, requirePermission} from '../../middleware/auth.js';
17
17
  export async function scaffoldRoutes(fastify) {
18
18
  // Scaffolding creates collections + forms + actions, so we require both
19
19
  // collections.create and forms.create permissions. The cheap check is to
20
- // gate on collections.create — anyone with that should already be a
20
+ // gate on collections.create - anyone with that should already be a
21
21
  // privileged role; we add forms.create on top as a belt-and-braces.
22
22
  const canScaffold = {preHandler: [authenticate, requirePermission('collections', 'create')]};
23
23
 
@@ -38,7 +38,7 @@ export async function scaffoldRoutes(fastify) {
38
38
  /*
39
39
  * GET /api/scaffold/recipes/:slug
40
40
  *
41
- * Returns the full recipe document — useful for a preview view that wants
41
+ * Returns the full recipe document - useful for a preview view that wants
42
42
  * to show the user exactly what will be created before they click Apply.
43
43
  */
44
44
  fastify.get('/scaffold/recipes/:slug', canScaffold, async (request, reply) => {
@@ -61,7 +61,7 @@ export async function scaffoldRoutes(fastify) {
61
61
  * { created: {collection, form, actions: [...]}, skipped: [...], warnings: [...], snippet: string|null }
62
62
  *
63
63
  * The `snippet` is a Markdown fragment the caller can insert at the
64
- * cursor position in the page editor — typically the [form] + [collection]
64
+ * cursor position in the page editor - typically the [form] + [collection]
65
65
  * combination needed to see the new system on a page immediately.
66
66
  */
67
67
  fastify.post('/scaffold/apply', canScaffold, async (request, reply) => {
@@ -41,7 +41,7 @@ export async function settingsRoutes(fastify) {
41
41
  'smtp', 'footer', 'analytics', 'social', 'locale',
42
42
  'layoutOptions', 'seo', 'backToTop', 'cookieConsent', 'breadcrumbs', 'autoTheme',
43
43
  'adminBrand',
44
- // Keys that legitimately live in site.json (written elsewhere — e.g.
44
+ // Keys that legitimately live in site.json (written elsewhere - e.g.
45
45
  // `brand` by the menus migration) and are echoed back by GET /settings.
46
46
  // They must be accepted here or any client that round-trips the GET
47
47
  // payload into a save gets a 400 for keys it never touched.
@@ -80,7 +80,7 @@ export async function settingsRoutes(fastify) {
80
80
  return reply.status(400).send({ error: 'SMTP is not configured. Save your SMTP settings first.' });
81
81
  }
82
82
 
83
- // Shared transport factory — carries the self-signed-cert TLS relaxation.
83
+ // Shared transport factory - carries the self-signed-cert TLS relaxation.
84
84
  // Do NOT build a raw nodemailer transport here; that regresses on engine sync.
85
85
  const transporter = await createTransport(smtp);
86
86
 
@@ -93,7 +93,7 @@ export async function settingsRoutes(fastify) {
93
93
  await transporter.sendMail({
94
94
  from: smtp.fromName ? `"${smtp.fromName}" <${smtp.fromAddress}>` : smtp.fromAddress,
95
95
  to,
96
- subject: 'Domma CMS — Test Email',
96
+ subject: 'Domma CMS - Test Email',
97
97
  text: 'This is a test email sent from Domma CMS to verify your SMTP configuration.',
98
98
  html: '<p>This is a test email sent from <strong>Domma CMS</strong> to verify your SMTP configuration.</p>'
99
99
  });
@@ -103,7 +103,7 @@ export async function settingsRoutes(fastify) {
103
103
  }
104
104
  });
105
105
 
106
- // GET /api/settings/db-status — returns whether MongoDB connections are configured
106
+ // GET /api/settings/db-status - returns whether MongoDB connections are configured
107
107
  fastify.get('/settings/db-status', canRead, async () => {
108
108
  try {
109
109
  const raw = await fs.readFile(CONNECTIONS_FILE, 'utf8');
@@ -117,7 +117,7 @@ export async function settingsRoutes(fastify) {
117
117
  }
118
118
  });
119
119
 
120
- // GET /api/settings/custom-css — return current CSS as JSON
120
+ // GET /api/settings/custom-css - return current CSS as JSON
121
121
  fastify.get('/settings/custom-css', canUpdate, async () => {
122
122
  try {
123
123
  const css = await fs.readFile(CUSTOM_CSS_FILE, 'utf8');
@@ -127,7 +127,7 @@ export async function settingsRoutes(fastify) {
127
127
  }
128
128
  });
129
129
 
130
- // PUT /api/settings/custom-css — save CSS to content/custom.css
130
+ // PUT /api/settings/custom-css - save CSS to content/custom.css
131
131
  fastify.put('/settings/custom-css', canUpdate, async (request, reply) => {
132
132
  const { css } = request.body || {};
133
133
  if (typeof css !== 'string') {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Admin sidebar API
3
3
  *
4
- * GET /api/sidebar/registered-items — plugin-registered sidebar items
4
+ * GET /api/sidebar/registered-items - plugin-registered sidebar items
5
5
  *
6
6
  * Auth middlewares are accepted as DI options so tests can supply no-ops.
7
7
  */