domma-cms 0.44.0 → 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 (254) 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 +752 -752
  57. package/admin/js/templates/form-editor.html +5 -5
  58. package/admin/js/templates/forms.html +17 -17
  59. package/admin/js/templates/login.html +5 -5
  60. package/admin/js/templates/menu-editor.html +10 -10
  61. package/admin/js/templates/my-profile.html +17 -17
  62. package/admin/js/templates/navigation.html +11 -11
  63. package/admin/js/templates/notifications.html +1 -1
  64. package/admin/js/templates/page-editor.html +4 -4
  65. package/admin/js/templates/pro-docs.html +14 -14
  66. package/admin/js/templates/role-editor.html +70 -70
  67. package/admin/js/templates/roles.html +10 -10
  68. package/admin/js/templates/settings.html +10 -10
  69. package/admin/js/templates/tutorials.html +3 -3
  70. package/admin/js/templates/view-editor.html +15 -15
  71. package/admin/js/views/action-editor.js +1 -1
  72. package/admin/js/views/actions-list.js +1 -1
  73. package/admin/js/views/api-endpoint-editor.js +2 -2
  74. package/admin/js/views/api-endpoints.js +2 -2
  75. package/admin/js/views/api-tokens.js +3 -3
  76. package/admin/js/views/blocks.js +2 -2
  77. package/admin/js/views/collection-editor.js +1 -1
  78. package/admin/js/views/collection-entries.js +1 -1
  79. package/admin/js/views/collections.js +1 -1
  80. package/admin/js/views/component-editor.js +1 -1
  81. package/admin/js/views/components.js +4 -4
  82. package/admin/js/views/dashboard/widgets/cache.js +1 -1
  83. package/admin/js/views/dashboard/widgets/journeys.js +1 -1
  84. package/admin/js/views/dashboard/widgets/kpi-strip.js +1 -1
  85. package/admin/js/views/form-editor.js +7 -7
  86. package/admin/js/views/form-submissions.js +1 -1
  87. package/admin/js/views/forms.js +1 -1
  88. package/admin/js/views/layouts.js +1 -1
  89. package/admin/js/views/login.js +2 -2
  90. package/admin/js/views/menu-editor.js +3 -3
  91. package/admin/js/views/menu-locations.js +1 -1
  92. package/admin/js/views/menus.js +3 -3
  93. package/admin/js/views/navigation.js +8 -8
  94. package/admin/js/views/page-editor.js +40 -40
  95. package/admin/js/views/pages.js +3 -3
  96. package/admin/js/views/plugin-code.js +2 -2
  97. package/admin/js/views/plugins.js +1 -1
  98. package/admin/js/views/project-settings.js +1 -1
  99. package/admin/js/views/projects.js +1 -1
  100. package/admin/js/views/roles.js +1 -1
  101. package/admin/js/views/user-editor.js +1 -1
  102. package/admin/js/views/users.js +3 -3
  103. package/admin/js/views/view-editor.js +1 -1
  104. package/admin/js/views/view-preview.js +1 -1
  105. package/admin/js/views/views-list.js +1 -1
  106. package/bin/cli.js +8 -8
  107. package/bin/lib/config-merge.js +44 -44
  108. package/bin/update.js +16 -16
  109. package/config/plugins.json +1 -1
  110. package/package.json +2 -2
  111. package/plugins/_template/admin/views/index.js +1 -1
  112. package/plugins/_template/config.js +1 -1
  113. package/plugins/_template/plugin.js +1 -1
  114. package/plugins/analytics/admin/templates/analytics.html +3 -3
  115. package/plugins/analytics/admin/views/analytics.js +1 -1
  116. package/plugins/analytics/config.js +1 -1
  117. package/plugins/analytics/plugin.js +4 -4
  118. package/plugins/analytics/plugin.json +1 -1
  119. package/plugins/analytics/public/inject-body.html +2 -2
  120. package/plugins/analytics/public/inject-head.html +1 -1
  121. package/plugins/blog/admin/templates/blog.html +4 -4
  122. package/plugins/blog/admin/views/blog.js +3 -3
  123. package/plugins/blog/admin/views/categories.js +2 -2
  124. package/plugins/blog/admin/views/comments.js +4 -4
  125. package/plugins/blog/admin/views/post-editor.js +1 -1
  126. package/plugins/blog/plugin.js +2 -2
  127. package/plugins/blog/plugin.json +4 -4
  128. package/plugins/contacts/admin/views/contacts.js +8 -8
  129. package/plugins/contacts/plugin.js +4 -4
  130. package/plugins/contacts/plugin.json +1 -1
  131. package/plugins/invoice/admin/templates/editor.html +6 -6
  132. package/plugins/invoice/admin/templates/index.html +4 -4
  133. package/plugins/invoice/admin/views/editor.js +3 -3
  134. package/plugins/invoice/admin/views/index.js +4 -4
  135. package/plugins/invoice/admin/views/issuers.js +1 -1
  136. package/plugins/invoice/admin/views/party-view.js +3 -3
  137. package/plugins/invoice/admin/views/receivers.js +1 -1
  138. package/plugins/invoice/config.js +1 -1
  139. package/plugins/invoice/plugin.js +1 -1
  140. package/plugins/invoice/plugin.json +4 -4
  141. package/plugins/notes/admin/views/notes.js +4 -4
  142. package/plugins/notes/plugin.js +1 -1
  143. package/plugins/notes/plugin.json +1 -1
  144. package/plugins/site-search/admin/templates/site-search.html +3 -3
  145. package/plugins/site-search/admin/views/site-search.js +1 -1
  146. package/plugins/site-search/config.js +1 -1
  147. package/plugins/site-search/plugin.js +4 -4
  148. package/plugins/site-search/plugin.json +1 -1
  149. package/plugins/surveys/admin/templates/results.html +1 -1
  150. package/plugins/surveys/admin/templates/survey-editor.html +4 -4
  151. package/plugins/surveys/admin/views/audience.js +6 -6
  152. package/plugins/surveys/admin/views/results.js +3 -3
  153. package/plugins/surveys/admin/views/survey-editor.js +1 -1
  154. package/plugins/surveys/admin/views/surveys.js +2 -2
  155. package/plugins/surveys/plugin.js +1 -1
  156. package/plugins/surveys/plugin.json +4 -4
  157. package/plugins/theme-switcher/admin/views/theme-switcher.js +1 -1
  158. package/plugins/theme-switcher/plugin.json +3 -3
  159. package/plugins/theme-switcher/public/inject-body.html +28 -28
  160. package/plugins/theme-switcher/public/inject-head.html +1 -1
  161. package/plugins/todo/plugin.js +1 -1
  162. package/public/css/forms.css +1 -1
  163. package/public/js/collection-browser.js +1 -1
  164. package/public/js/collection-context.js +1 -1
  165. package/public/js/form-logic-engine.js +1 -1
  166. package/public/js/forms.js +2 -1
  167. package/public/js/site.js +1 -1
  168. package/scripts/build.js +14 -14
  169. package/scripts/create-plugin.js +2 -2
  170. package/scripts/fresh.js +2 -2
  171. package/scripts/gen-instance-secret.js +1 -1
  172. package/scripts/pro.js +12 -12
  173. package/scripts/reset.js +2 -2
  174. package/scripts/setup.js +15 -15
  175. package/scripts/users.js +5 -5
  176. package/scripts/verify-assets.mjs +6 -6
  177. package/server/config.js +1 -1
  178. package/server/middleware/auth.js +253 -253
  179. package/server/middleware/managerAuth.js +3 -3
  180. package/server/routes/api/actions.js +4 -4
  181. package/server/routes/api/api-endpoints.js +7 -7
  182. package/server/routes/api/api-tokens.js +5 -5
  183. package/server/routes/api/auth.js +309 -309
  184. package/server/routes/api/blocks.js +3 -3
  185. package/server/routes/api/collections.js +18 -18
  186. package/server/routes/api/dashboard.js +5 -5
  187. package/server/routes/api/effects.js +2 -2
  188. package/server/routes/api/endpoints-public.js +5 -5
  189. package/server/routes/api/forms.js +47 -47
  190. package/server/routes/api/menu-locations.js +3 -3
  191. package/server/routes/api/menus.js +7 -7
  192. package/server/routes/api/navigation.js +42 -42
  193. package/server/routes/api/notifications.js +2 -2
  194. package/server/routes/api/pages.js +14 -2
  195. package/server/routes/api/plugin-marketplace.js +3 -3
  196. package/server/routes/api/plugins.js +6 -6
  197. package/server/routes/api/projects.js +9 -9
  198. package/server/routes/api/scaffold.js +7 -7
  199. package/server/routes/api/settings.js +6 -6
  200. package/server/routes/api/sidebar.js +1 -1
  201. package/server/routes/api/users.js +5 -5
  202. package/server/routes/api/versions.js +1 -1
  203. package/server/routes/api/views.js +1 -1
  204. package/server/routes/docs-public.js +3 -3
  205. package/server/routes/public.js +14 -14
  206. package/server/server.js +34 -16
  207. package/server/services/actions.js +19 -19
  208. package/server/services/adapters/FileAdapter.js +2 -2
  209. package/server/services/adapters/MongoAdapter.js +5 -5
  210. package/server/services/apiEndpoints.js +15 -15
  211. package/server/services/apiTokens.js +7 -7
  212. package/server/services/blocks.js +13 -13
  213. package/server/services/cache/drivers/MemoryDriver.js +3 -3
  214. package/server/services/cache/drivers/NoneDriver.js +1 -1
  215. package/server/services/cache/index.js +5 -5
  216. package/server/services/collections.js +14 -14
  217. package/server/services/components.js +9 -9
  218. package/server/services/connectionManager.js +4 -4
  219. package/server/services/content.js +25 -2
  220. package/server/services/docs.js +14 -14
  221. package/server/services/email.js +167 -167
  222. package/server/services/filterEngine.js +6 -6
  223. package/server/services/forms.js +18 -10
  224. package/server/services/health.js +1 -1
  225. package/server/services/hooks.js +7 -7
  226. package/server/services/images.js +4 -4
  227. package/server/services/managerClient.js +1 -1
  228. package/server/services/markdown.js +79 -79
  229. package/server/services/menuRender.js +5 -5
  230. package/server/services/menus-migration.js +3 -3
  231. package/server/services/menus.js +31 -31
  232. package/server/services/permissionRegistry.js +1 -1
  233. package/server/services/pluginFiles.js +5 -5
  234. package/server/services/pluginInstaller.js +17 -17
  235. package/server/services/pluginScaffold.js +1 -1
  236. package/server/services/plugins.js +17 -17
  237. package/server/services/presetCollections.js +2 -2
  238. package/server/services/projects.js +35 -26
  239. package/server/services/recipes/contact-list.json +1 -1
  240. package/server/services/recipes/onboarding.json +8 -8
  241. package/server/services/references.js +8 -8
  242. package/server/services/renderer.js +9 -9
  243. package/server/services/roles.js +9 -9
  244. package/server/services/rowAccess.js +10 -10
  245. package/server/services/scaffolder.js +30 -30
  246. package/server/services/sidebar-migration.js +10 -10
  247. package/server/services/sitemap.js +1 -1
  248. package/server/services/userProfiles.js +199 -199
  249. package/server/services/userRoles.js +7 -7
  250. package/server/services/users.js +302 -302
  251. package/server/services/versions.js +3 -3
  252. package/server/services/viewPipeline.js +6 -6
  253. package/server/services/views.js +11 -11
  254. package/server/templates/page.html +5 -5
@@ -4,13 +4,13 @@
4
4
  * Projects group related artefacts (Pages, Collections, Forms, Actions,
5
5
  * Menus, Blocks, Components, Views, Roles, Users, API endpoints) under a
6
6
  * named slug. Membership is
7
- * a metadata tag (`meta.project: '<slug>'`) on each artefact — no
7
+ * a metadata tag (`meta.project: '<slug>'`) on each artefact - no
8
8
  * filesystem isolation. Projects also act as a permission boundary:
9
9
  * users with `projects: []` set only see their projects' artefacts plus
10
10
  * the core project's artefacts.
11
11
  *
12
12
  * The built-in **core** project owns every artefact not claimed by another
13
- * project — by resolution fallback, not by tagging. It is seeded at boot
13
+ * project - by resolution fallback, not by tagging. It is seeded at boot
14
14
  * (`seedCoreProject()`), cannot be deleted, and is never an access
15
15
  * boundary: core-resolved artefacts are visible to all authenticated
16
16
  * users, regardless of their `projects: []` scope.
@@ -32,7 +32,7 @@ export const CORE_PROJECT_SLUG = 'core';
32
32
  const CORE_PROJECT_SEED = {
33
33
  slug: CORE_PROJECT_SLUG,
34
34
  name: 'Core',
35
- description: 'The core site — everything not assigned to another project.',
35
+ description: 'The core site - everything not assigned to another project.',
36
36
  icon: 'home',
37
37
  rootUrl: '/',
38
38
  sortOrder: -1,
@@ -47,7 +47,7 @@ export const DOCS_PROJECT_SLUG = 'domma-docs';
47
47
  const DOCS_PROJECT_SEED = {
48
48
  slug: DOCS_PROJECT_SLUG,
49
49
  name: 'Domma Docs',
50
- description: 'The Domma CMS handbook — guides, tutorials, component & API ' +
50
+ description: 'The Domma CMS handbook - guides, tutorials, component & API ' +
51
51
  'reference. Switch on to publish it on your own site.',
52
52
  icon: 'document',
53
53
  rootUrl: '/domma-docs',
@@ -132,7 +132,7 @@ export async function createProject(input) {
132
132
  }
133
133
 
134
134
  /**
135
- * Seed the built-in core project at boot. Create-if-absent by slug — NOT by
135
+ * Seed the built-in core project at boot. Create-if-absent by slug - NOT by
136
136
  * data.json existence: preset seeding already creates an empty data file, and
137
137
  * retrofitted sites have project entries that must be preserved. If a project
138
138
  * with the core slug already exists it is adopted: `protected: true` is set
@@ -145,7 +145,7 @@ export async function seedCoreProject() {
145
145
  const {entries} = await listEntries(PROJECTS_COLLECTION_SLUG, {limit: 0});
146
146
  const existing = entries.find(e => e.data?.slug === CORE_PROJECT_SLUG);
147
147
  if (existing) {
148
- // Core is always protected and always enabled — adopt both invariants.
148
+ // Core is always protected and always enabled - adopt both invariants.
149
149
  if (existing.data.protected === true && existing.data.enabled !== false) return normalise(existing);
150
150
  const adopted = {...existing.data, protected: true, enabled: true, updatedAt: new Date().toISOString()};
151
151
  await updateEntry(PROJECTS_COLLECTION_SLUG, existing.id, adopted);
@@ -158,7 +158,7 @@ export async function seedCoreProject() {
158
158
  }
159
159
 
160
160
  /**
161
- * Seed the built-in Domma Docs project at boot. Create-if-absent by slug — an
161
+ * Seed the built-in Domma Docs project at boot. Create-if-absent by slug - an
162
162
  * existing record (including the admin's enabled/disabled choice and any edits)
163
163
  * is returned untouched. Disabled by default: a fresh site ships with the docs
164
164
  * off until an admin enables the project.
@@ -223,7 +223,7 @@ export async function updateProject(slug, input) {
223
223
 
224
224
  /**
225
225
  * Delete a project. Returns true if removed, false if not found.
226
- * Does NOT check for tagged artefacts — caller (API route) must do that.
226
+ * Does NOT check for tagged artefacts - caller (API route) must do that.
227
227
  * The core project can never be deleted.
228
228
  *
229
229
  * @param {string} slug
@@ -254,7 +254,7 @@ export function validateProject(project) {
254
254
  const errors = [];
255
255
  if (!project || typeof project !== 'object') return ['Project must be an object'];
256
256
  if (!validateSlug(project.slug)) {
257
- errors.push(`Invalid slug "${project.slug}" — lowercase alphanumeric + hyphen only`);
257
+ errors.push(`Invalid slug "${project.slug}" - lowercase alphanumeric + hyphen only`);
258
258
  }
259
259
  if (!project.name || typeof project.name !== 'string') {
260
260
  errors.push('name is required');
@@ -281,7 +281,7 @@ function normalise(entry) {
281
281
 
282
282
  /**
283
283
  * Whether the given project is enabled (i.e. not switched off). A disabled
284
- * project is a hard kill-switch on its PUBLIC surfaces — public pages 404 and
284
+ * project is a hard kill-switch on its PUBLIC surfaces - public pages 404 and
285
285
  * its external API (collections + custom endpoints + tokens) is refused.
286
286
  *
287
287
  * The core project is always enabled. An unknown slug resolves to core by
@@ -300,7 +300,7 @@ export async function isProjectEnabled(slug) {
300
300
  /**
301
301
  * List projects the given user is allowed to see. Super-admin sees all;
302
302
  * users with empty `projects: []` see all; scoped users see their listed
303
- * projects plus the core project (whose artefacts they can see anyway —
303
+ * projects plus the core project (whose artefacts they can see anyway -
304
304
  * see canSeeArtefact).
305
305
  *
306
306
  * @param {object|null} user
@@ -321,7 +321,7 @@ export async function listProjectsForUser(user) {
321
321
  * Rules:
322
322
  * - Super-admin (role level 0) bypasses everything.
323
323
  * - Core artefacts (no `meta.project`, or tagged `core`) are visible to
324
- * all — the core project is organisational, never an access boundary.
324
+ * all - the core project is organisational, never an access boundary.
325
325
  * - Users with no scope set (`projects: []` or missing) have no restriction.
326
326
  * - Otherwise: artefact's project must be in user's scope.
327
327
  *
@@ -341,7 +341,7 @@ export function canSeeArtefact(user, artefact) {
341
341
  /**
342
342
  * Resolve the project a non-page artefact belongs to. Artefacts without a
343
343
  * `meta.project` tag belong to the core project by exclusion. (Pages resolve
344
- * via getProjectForPage instead — they carry a frontmatter `project` field
344
+ * via getProjectForPage instead - they carry a frontmatter `project` field
345
345
  * and inherit by rootUrl prefix.)
346
346
  *
347
347
  * @param {object} artefact - Anything with optional `meta.project`.
@@ -354,15 +354,22 @@ export function resolveArtefactProject(artefact) {
354
354
  /**
355
355
  * Resolve the project a page belongs to using the inheritance rules:
356
356
  * 1. Explicit `project: '<slug>'` → use it
357
- * 2. Explicit `project: null` → core (legacy opt-out, deprecated — core
357
+ * 2. Explicit `project: null` → core (legacy opt-out, deprecated - core
358
358
  * owns the floor, so "untagged" no longer exists as a state)
359
359
  * 3. Missing field → longest matching `rootUrl` prefix wins
360
360
  * 4. No match → core
361
361
  *
362
- * The core project does not participate in prefix matching — it is the
362
+ * The core project does not participate in prefix matching - it is the
363
363
  * fallback when nothing else claims the page. If the core record has not
364
364
  * been seeded yet, falls back to null (pre-seed behaviour).
365
365
  *
366
+ * A trailing slash on `rootUrl` is ignored: '/shop/' claims '/shop/items'
367
+ * exactly as '/shop' does. (The naive prefix test built `rootUrl + '/'` =
368
+ * '/shop//', which nothing starts with, so one stray keystroke in the project
369
+ * editor silently switched inheritance off.) '/' itself is rejected at
370
+ * validation - it belongs to core - so a whole-site project inherits nothing
371
+ * by URL and its pages must carry an explicit `project:` tag.
372
+ *
366
373
  * @param {string} urlPath
367
374
  * @param {string|null|undefined} explicitProject
368
375
  * @returns {Promise<string|null>}
@@ -376,11 +383,13 @@ export async function getProjectForPage(urlPath, explicitProject) {
376
383
  let bestLen = -1;
377
384
  for (const p of projects) {
378
385
  if (!p.rootUrl || p.slug === CORE_PROJECT_SLUG) continue;
379
- if (urlPath === p.rootUrl || urlPath.startsWith(p.rootUrl + '/')) {
380
- if (p.rootUrl.length > bestLen) {
386
+ const root = p.rootUrl.replace(/\/+$/, '');
387
+ if (!root) continue;
388
+ if (urlPath === root || urlPath.startsWith(root + '/')) {
389
+ if (root.length > bestLen) {
381
390
  best = p.slug;
382
- bestLen = p.rootUrl.length;
383
- } else if (p.rootUrl.length === bestLen && p.slug < best) {
391
+ bestLen = root.length;
392
+ } else if (root.length === bestLen && p.slug < best) {
384
393
  best = p.slug;
385
394
  }
386
395
  }
@@ -390,7 +399,7 @@ export async function getProjectForPage(urlPath, explicitProject) {
390
399
 
391
400
  /**
392
401
  * Enumerate every artefact belonging to the given project. Returns a flat
393
- * grouping by type — each value is an array of artefact summary objects.
402
+ * grouping by type - each value is an array of artefact summary objects.
394
403
  * Used by the project detail page (counts + per-type tabs).
395
404
  *
396
405
  * Membership is by resolution, not tag equality: artefacts without a
@@ -497,7 +506,7 @@ export async function getArtefactsForProject(projectSlug) {
497
506
 
498
507
  try {
499
508
  // Custom API endpoints store their project in data.project (required
500
- // field — no untagged-→core fallback applies, so match directly).
509
+ // field - no untagged-→core fallback applies, so match directly).
501
510
  const {entries} = await listEntries('api-endpoints', {limit: 0});
502
511
  for (const e of entries) {
503
512
  if (e.data?.project === projectSlug) {
@@ -514,11 +523,11 @@ export async function getArtefactsForProject(projectSlug) {
514
523
  * Returns a count map of how many of each type were untagged. Used as the
515
524
  * "Untag all" escape hatch before delete.
516
525
  *
517
- * Per-service plumbing (Tasks 9–16) is required for non-menu artefacts to
526
+ * Per-service plumbing (Tasks 9-16) is required for non-menu artefacts to
518
527
  * carry meta.project through their update path; without that plumbing, this
519
528
  * function simply returns zero counts for those types.
520
529
  *
521
- * Refused for the core project — membership there is by resolution, so
530
+ * Refused for the core project - membership there is by resolution, so
522
531
  * untagging would be a no-op that resolution immediately reverses.
523
532
  *
524
533
  * @param {string} projectSlug
@@ -527,11 +536,11 @@ export async function getArtefactsForProject(projectSlug) {
527
536
  */
528
537
  export async function untagAllForProject(projectSlug) {
529
538
  if (projectSlug === CORE_PROJECT_SLUG) {
530
- throw new Error('Cannot untag the core project — artefacts would immediately resolve back to it');
539
+ throw new Error('Cannot untag the core project - artefacts would immediately resolve back to it');
531
540
  }
532
541
  const proj = await getProject(projectSlug);
533
542
  if (proj?.protected === true) {
534
- throw new Error(`Cannot untag the protected "${projectSlug}" project — its pages would resolve back to core`);
543
+ throw new Error(`Cannot untag the protected "${projectSlug}" project - its pages would resolve back to core`);
535
544
  }
536
545
  const counts = {
537
546
  pages: 0, collections: 0, forms: 0, actions: 0,
@@ -629,7 +638,7 @@ export async function untagAllForProject(projectSlug) {
629
638
  // tasks may revisit.
630
639
  //
631
640
  // API endpoints: intentionally skipped. An endpoint's project IS its URL
632
- // namespace (/api/x/<project>/...) — silently untagging would move live
641
+ // namespace (/api/x/<project>/...) - silently untagging would move live
633
642
  // endpoints to /api/x/core/... and break external callers. Delete or
634
643
  // recreate them explicitly instead.
635
644
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "slug": "contact-list",
3
3
  "name": "Contact list (CRM-lite)",
4
- "description": "A simple address book — contacts collection with email/phone/company/tags, a quick-add form, and a single status transition for follow-ups.",
4
+ "description": "A simple address book - contacts collection with email/phone/company/tags, a quick-add form, and a single status transition for follow-ups.",
5
5
  "icon": "users",
6
6
  "options": [
7
7
  { "name": "collectionSlug", "label": "Collection slug", "default": "contacts", "hint": "Where contact records are stored" },
@@ -19,7 +19,7 @@
19
19
  "name": "namespace",
20
20
  "label": "Namespace",
21
21
  "default": "onboarding",
22
- "hint": "Single name that cascades into every slug below. Change this once and the collection, form, and actions all rename in lockstep — or override the individual slugs below to break the chain."
22
+ "hint": "Single name that cascades into every slug below. Change this once and the collection, form, and actions all rename in lockstep - or override the individual slugs below to break the chain."
23
23
  },
24
24
  {
25
25
  "name": "collectionSlug",
@@ -129,7 +129,7 @@
129
129
  "label": "Reference contacts",
130
130
  "type": "textarea",
131
131
  "required": false,
132
- "placeholder": "Name, email, relationship \u2014 one per line"
132
+ "placeholder": "Name, email, relationship - one per line"
133
133
  },
134
134
  {
135
135
  "name": "notes",
@@ -194,7 +194,7 @@
194
194
  },
195
195
  "form": {
196
196
  "title": "Employee Onboarding",
197
- "description": "Welcome aboard \u2014 let's get you set up.",
197
+ "description": "Welcome aboard - let's get you set up.",
198
198
  "fields": [
199
199
  {
200
200
  "name": "fullName",
@@ -267,19 +267,19 @@
267
267
  "accept": "application/pdf,image/jpeg,image/png",
268
268
  "maxSize": 5242880
269
269
  },
270
- "helper": "PDF, JPEG or PNG \u2014 up to 5 MB"
270
+ "helper": "PDF, JPEG or PNG - up to 5 MB"
271
271
  },
272
272
  {
273
273
  "name": "references",
274
274
  "label": "Reference contacts",
275
275
  "type": "textarea",
276
276
  "required": false,
277
- "placeholder": "Name, email, relationship \u2014 one per line"
277
+ "placeholder": "Name, email, relationship - one per line"
278
278
  }
279
279
  ],
280
280
  "settings": {
281
281
  "submitText": "Send to HR",
282
- "successMessage": "Thanks \u2014 HR will review your details shortly.",
282
+ "successMessage": "Thanks - HR will review your details shortly.",
283
283
  "honeypot": true,
284
284
  "rateLimitPerMinute": 3,
285
285
  "actionSlug": ""
@@ -378,8 +378,8 @@
378
378
  "type": "email",
379
379
  "config": {
380
380
  "to": "{{entry.data.email}}",
381
- "subject": "Welcome \u2014 you're cleared to start",
382
- "template": "Hi {{entry.data.fullName}},\n\nGreat news \u2014 your onboarding paperwork has been approved. Your first day is {{entry.data.startDate}}.\n\nIf you have any questions before then, just reply to this email.\n\nThe HR team"
381
+ "subject": "Welcome - you're cleared to start",
382
+ "template": "Hi {{entry.data.fullName}},\n\nGreat news - your onboarding paperwork has been approved. Your first day is {{entry.data.startDate}}.\n\nIf you have any questions before then, just reply to this email.\n\nThe HR team"
383
383
  }
384
384
  }
385
385
  ]
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Reference resolver — turn raw foreign-key ids stored on entries into
2
+ * Reference resolver - turn raw foreign-key ids stored on entries into
3
3
  * displayable values from the target collections.
4
4
  *
5
5
  * Reference field schema shape:
@@ -11,7 +11,7 @@
11
11
  * collection: "jobs", // target collection slug (required)
12
12
  * displayField: "title", // field on the target to show (default: "title")
13
13
  * searchField: "title", // field to search on in picker UI (default: displayField)
14
- * linkTemplate: "/jobs/{{id}}" // optional link template — `{{id}}` substituted
14
+ * linkTemplate: "/jobs/{{id}}" // optional link template - `{{id}}` substituted
15
15
  * }
16
16
  * }
17
17
  *
@@ -22,7 +22,7 @@
22
22
  *
23
23
  * Batching: when resolving N entries that each have a `jobId`, we collect
24
24
  * all distinct ids once, fetch the target collection in a single pass, then
25
- * decorate every entry from the lookup map — one adapter call per *target
25
+ * decorate every entry from the lookup map - one adapter call per *target
26
26
  * collection* per resolve, not per entry.
27
27
  *
28
28
  * Missing-target handling: if a referenced id no longer exists (the target
@@ -59,7 +59,7 @@ export function referenceFields(schema) {
59
59
  * @param {object} schema - Source collection schema
60
60
  * @param {object[]} entries - Source entries; each may be mutated
61
61
  * @param {object} [opts]
62
- * @param {Function} [opts.adapterResolver] - Optional async (slug) → adapter — for tests
62
+ * @param {Function} [opts.adapterResolver] - Optional async (slug) → adapter - for tests
63
63
  * @returns {Promise<object[]>}
64
64
  */
65
65
  export async function resolveReferences(schema, entries, opts = {}) {
@@ -95,17 +95,17 @@ export async function resolveReferences(schema, entries, opts = {}) {
95
95
  const adapter = await resolver(collectionSlug);
96
96
  // FileAdapter: cheap to read all then filter; MongoAdapter: $in is the
97
97
  // right call but we don't have a list-by-ids API yet. For now, get()
98
- // each id in parallel — N requests but small N (distinct refs only).
98
+ // each id in parallel - N requests but small N (distinct refs only).
99
99
  const map = new Map();
100
100
  await Promise.all([...idSet].map(async id => {
101
101
  try {
102
102
  const target = await adapter.get(collectionSlug, id);
103
103
  if (target) map.set(id, target);
104
- } catch { /* missing target — left out of the map */ }
104
+ } catch { /* missing target - left out of the map */ }
105
105
  }));
106
106
  lookups.set(collectionSlug, map);
107
107
  } catch {
108
- lookups.set(collectionSlug, new Map()); // collection gone — all refs render as missing
108
+ lookups.set(collectionSlug, new Map()); // collection gone - all refs render as missing
109
109
  }
110
110
  }
111
111
 
@@ -151,7 +151,7 @@ function buildRef(field, id, lookupMap) {
151
151
  }
152
152
 
153
153
  /**
154
- * Convenience helper for renderers — get the display label for a reference
154
+ * Convenience helper for renderers - get the display label for a reference
155
155
  * field on a single entry, falling back to the raw value when no resolution
156
156
  * was performed (e.g. a caller skipped `resolveRefs: true`).
157
157
  *
@@ -36,7 +36,7 @@ let _templateCache = null;
36
36
 
37
37
  /**
38
38
  * Strip hidden items from the navigation config before public injection.
39
- * Returns a shallow clone — never mutates the cached config object.
39
+ * Returns a shallow clone - never mutates the cached config object.
40
40
  *
41
41
  * @param {object} nav - Raw navigation config from getConfig('navigation')
42
42
  * @returns {object} Filtered navigation object safe for public injection
@@ -59,7 +59,7 @@ function filterHiddenNavItems(nav) {
59
59
  * whose binding matches, each drawn by the same builder the `[menu]` shortcode
60
60
  * uses so an overlay and a shortcode of the same menu look identical.
61
61
  *
62
- * Emitted as bare <nav> elements with no wrapper — a wrapping element would
62
+ * Emitted as bare <nav> elements with no wrapper - a wrapping element would
63
63
  * risk becoming a containing block for their position:fixed pinning.
64
64
  *
65
65
  * @param {{urlPath?: string, project?: string|null}} menuCtx
@@ -88,7 +88,7 @@ async function buildOverlayMenus(menuCtx, user) {
88
88
  * @returns {Promise<string>}
89
89
  */
90
90
  export async function renderPage(page, opts = {}) {
91
- // Page context for menu bindings — a menu bound to this URL or this page's
91
+ // Page context for menu bindings - a menu bound to this URL or this page's
92
92
  // project takes its slot over. `resolvedProject` is stamped by the content
93
93
  // service; fall back to resolving it here for callers that skip that path.
94
94
  const menuCtx = {
@@ -144,7 +144,7 @@ export async function renderPage(page, opts = {}) {
144
144
  const pageWidth = VALID_LAYOUT_WIDTHS.has(page.width) ? page.width : null;
145
145
  const layoutWidth = pageWidth || (VALID_LAYOUT_WIDTHS.has(preset.width) ? preset.width : 'normal');
146
146
  // A vertical navbar docks as a side rail, so the body needs its gutter from
147
- // the first paint — stamping the class client-side would shift the layout.
147
+ // the first paint - stamping the class client-side would shift the layout.
148
148
  const verticalNavClass = navigation.orientation === 'vertical'
149
149
  ? `dm-vnav dm-vnav-${navigation.side === 'right' ? 'right' : 'left'}`
150
150
  : '';
@@ -242,7 +242,7 @@ export async function renderPage(page, opts = {}) {
242
242
  headInject: [injection.head, navbarFontLink].filter(Boolean).join('\n'),
243
243
  headInjectLate: [injection.headLate, customCssTag, navbarStyleTag].filter(Boolean).join('\n'),
244
244
  bodyEndInject: [
245
- // Overlay menus — bound to the `overlay` slot and pinned by their own
245
+ // Overlay menus - bound to the `overlay` slot and pinned by their own
246
246
  // float config, so they belong at the end of the body, clear of the
247
247
  // content flow.
248
248
  overlayMenus,
@@ -385,7 +385,7 @@ function buildSeoTags({page, site, baseUrl, seoTitle, seoDescription, ogImage})
385
385
  if (ogImage) tags.push(`<meta name="twitter:image" content="${ogImage}">`);
386
386
  if (twitterHandle) tags.push(`<meta name="twitter:site" content="${twitterHandle}">`);
387
387
 
388
- // JSON-LD WebPage schema — gives search engines a structured summary.
388
+ // JSON-LD WebPage schema - gives search engines a structured summary.
389
389
  // Omit `url` when no baseUrl, since a relative URL in JSON-LD is invalid.
390
390
  const jsonLd = {
391
391
  '@context': 'https://schema.org',
@@ -400,7 +400,7 @@ function buildSeoTags({page, site, baseUrl, seoTitle, seoDescription, ogImage})
400
400
  const jsonLdString = JSON.stringify(jsonLd).replace(/<\/script>/gi, '<\\/script>');
401
401
  tags.push(`<script type="application/ld+json">${jsonLdString}</script>`);
402
402
 
403
- // BreadcrumbList JSON-LD — emit whenever the page has a non-trivial path
403
+ // BreadcrumbList JSON-LD - emit whenever the page has a non-trivial path
404
404
  // and an origin is known (Schema.org requires absolute URLs in `item`).
405
405
  // Independent of the visual breadcrumb setting so SERPs can show the trail
406
406
  // even when the on-page nav hides it.
@@ -523,7 +523,7 @@ function buildFontVars(fontFamily, fontSize) {
523
523
  */
524
524
  export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
525
525
  const user = seoMeta.user || null;
526
- // Plugin fragments know their own URL via seoMeta.urlPath — enough for a
526
+ // Plugin fragments know their own URL via seoMeta.urlPath - enough for a
527
527
  // menu bound to that page or to the project the URL falls under.
528
528
  const menuCtx = seoMeta.urlPath
529
529
  ? {urlPath: seoMeta.urlPath, project: await getProjectForPage(seoMeta.urlPath)}
@@ -673,7 +673,7 @@ async function readLegacyNavBackup() {
673
673
  const here = fileURLToPath(import.meta.url);
674
674
  const bak = path.resolve(path.dirname(here), '..', '..', 'config', 'navigation.json.bak');
675
675
  const raw = await fs.readFile(bak, 'utf8');
676
- console.warn('[menus] navbar location unmapped — falling back to navigation.json.bak (deprecated)');
676
+ console.warn('[menus] navbar location unmapped - falling back to navigation.json.bak (deprecated)');
677
677
  return JSON.parse(raw);
678
678
  } catch {
679
679
  return null;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Roles Service
3
- * Preset Collection — seeds roles on first startup, caches them in memory.
3
+ * Preset Collection - seeds roles on first startup, caches them in memory.
4
4
  * Auth middleware calls getRoleMap() / getPermissionsFor() at request time.
5
5
  *
6
6
  * Base roles: super-admin (0), admin (1), user (2).
@@ -18,13 +18,13 @@ const DIR = path.join(COLLECTIONS_DIR, SLUG);
18
18
  const SCHEMA_PATH = path.join(DIR, 'schema.json');
19
19
  const DATA_PATH = path.join(DIR, 'data.json');
20
20
 
21
- /** Users dir — derived from collections dir parent (content/users/) */
21
+ /** Users dir - derived from collections dir parent (content/users/) */
22
22
  const USERS_DIR = path.resolve(path.dirname(COLLECTIONS_DIR), 'users');
23
23
 
24
24
  /** Base role slugs that should always exist */
25
25
  const BASE_ROLE_NAMES = ['super-admin', 'admin', 'user'];
26
26
 
27
- /** Role slugs that were seeded by the old 4-role system — used to detect legacy data */
27
+ /** Role slugs that were seeded by the old 4-role system - used to detect legacy data */
28
28
  const DEFUNCT_ROLE_NAMES = ['manager', 'editor', 'subscriber'];
29
29
 
30
30
  export {RESOURCES, ACTIONS};
@@ -32,7 +32,7 @@ export {RESOURCES, ACTIONS};
32
32
  const PRESET_SCHEMA = {
33
33
  slug: SLUG,
34
34
  title: 'Roles',
35
- description: 'CMS role definitions — managed by the system.',
35
+ description: 'CMS role definitions - managed by the system.',
36
36
  preset: true,
37
37
  fields: [
38
38
  {name: 'name', label: 'Name (slug)', type: 'text', required: true},
@@ -194,7 +194,7 @@ export async function seed() {
194
194
  await writeData(entries);
195
195
  }
196
196
  } catch {
197
- // data.json doesn't exist — fresh install
197
+ // data.json doesn't exist - fresh install
198
198
  entries = SEED_ENTRIES.map(data => makeEntry(data));
199
199
  await writeData(entries);
200
200
  }
@@ -203,7 +203,7 @@ export async function seed() {
203
203
  // definitions in an update. Role permissions are a persisted snapshot
204
204
  // taken at seed time, so a new family (e.g. api-endpoints) would
205
205
  // otherwise be invisible on existing installs until granted by hand.
206
- // Append-only — permissions an admin ADDED are preserved; note that a
206
+ // Append-only - permissions an admin ADDED are preserved; note that a
207
207
  // seeded permission deliberately removed from a base role is re-added on
208
208
  // boot (use a custom role to run with less than the base set). The root
209
209
  // role's seed list is the full registry, so it always carries every
@@ -241,7 +241,7 @@ async function migrateUserRoles(roleEntries) {
241
241
  try {
242
242
  files = await fs.readdir(USERS_DIR);
243
243
  } catch {
244
- return; // users dir doesn't exist yet — nothing to migrate
244
+ return; // users dir doesn't exist yet - nothing to migrate
245
245
  }
246
246
 
247
247
  for (const file of files) {
@@ -277,7 +277,7 @@ export async function load() {
277
277
  }
278
278
 
279
279
  /**
280
- * Reload from disk — call after any CRUD on the roles collection.
280
+ * Reload from disk - call after any CRUD on the roles collection.
281
281
  *
282
282
  * @returns {Promise<void>}
283
283
  */
@@ -287,7 +287,7 @@ export async function invalidate() {
287
287
 
288
288
  /**
289
289
  * Persist a new role entry and rebuild the cache.
290
- * Idempotent — if a role with the same name exists, it is updated in place.
290
+ * Idempotent - if a role with the same name exists, it is updated in place.
291
291
  *
292
292
  * @param {{name:string,label:string,level:number,permissions:string[],badgeClass?:string,plugin?:string}} data
293
293
  * @returns {Promise<void>}
@@ -18,7 +18,7 @@
18
18
  * Resolve `data.<field>` against `targetCollection` and check that target
19
19
  * entry's `meta.createdBy` (or `data.<targetField>` if `targetField` is a
20
20
  * data field name) equals `user[userKey]`. This is "user can access THIS
21
- * entry because they own the entry it points at" — the recruiter/employer
21
+ * entry because they own the entry it points at" - the recruiter/employer
22
22
  * pattern: applications scoped to the jobs the user posted.
23
23
  *
24
24
  * `targetField` accepts:
@@ -76,13 +76,13 @@ function isAdmin(user) {
76
76
  * satisfies an `owner`/`field` rule, OR the entry's referenced target
77
77
  * satisfies the `reference` rule. Returns `false` only when a rule is set
78
78
  * AND the entry fails it. Misconfigured rules (missing fields, unknown
79
- * targets) fail closed — return `false` to avoid leaking data through typos.
79
+ * targets) fail closed - return `false` to avoid leaking data through typos.
80
80
  *
81
81
  * @param {object} entry - Full entry object ({ id, data, meta })
82
82
  * @param {object|null} user - Authenticated user object
83
83
  * @param {object|null} rowLevel - rowLevel config from action/view access
84
84
  * @param {object} [opts]
85
- * @param {Function} [opts.adapterResolver] - Optional async (slug) → adapter — for tests
85
+ * @param {Function} [opts.adapterResolver] - Optional async (slug) → adapter - for tests
86
86
  * @returns {Promise<boolean>}
87
87
  */
88
88
  export async function checkEntryAccess(entry, user, rowLevel, opts = {}) {
@@ -98,7 +98,7 @@ export async function checkEntryAccess(entry, user, rowLevel, opts = {}) {
98
98
 
99
99
  if (rowLevel.mode === 'field') {
100
100
  const field = rowLevel.field;
101
- if (!field) return false; // misconfigured — fail closed
101
+ if (!field) return false; // misconfigured - fail closed
102
102
  return entry?.data?.[field] === userVal;
103
103
  }
104
104
 
@@ -106,10 +106,10 @@ export async function checkEntryAccess(entry, user, rowLevel, opts = {}) {
106
106
  const field = rowLevel.field;
107
107
  const targetCollection = rowLevel.targetCollection;
108
108
  const targetField = rowLevel.targetField || 'createdBy';
109
- if (!field || !targetCollection) return false; // misconfigured — fail closed
109
+ if (!field || !targetCollection) return false; // misconfigured - fail closed
110
110
 
111
111
  // The referenced id lives at data.<field>. Arrays of ids are supported
112
- // — access is granted when ANY of the targets is owned by the user
112
+ // - access is granted when ANY of the targets is owned by the user
113
113
  // (e.g. "I can see this entry because I own at least one of its parents").
114
114
  const raw = entry?.data?.[field];
115
115
  if (raw == null || raw === '') return false;
@@ -126,7 +126,7 @@ export async function checkEntryAccess(entry, user, rowLevel, opts = {}) {
126
126
  for (const id of ids) {
127
127
  try {
128
128
  const target = await adapter.get(targetCollection, id);
129
- if (!target) continue; // dangling reference — skip
129
+ if (!target) continue; // dangling reference - skip
130
130
  const value = targetField === 'createdBy'
131
131
  ? target.meta?.createdBy
132
132
  : target.data?.[targetField];
@@ -138,7 +138,7 @@ export async function checkEntryAccess(entry, user, rowLevel, opts = {}) {
138
138
  return false;
139
139
  }
140
140
 
141
- return false; // unknown mode — fail closed
141
+ return false; // unknown mode - fail closed
142
142
  }
143
143
 
144
144
  /**
@@ -148,7 +148,7 @@ export async function checkEntryAccess(entry, user, rowLevel, opts = {}) {
148
148
  * The returned object is ready to use as `{ $match: buildRowLevelMatch(...) }`.
149
149
  *
150
150
  * Note: `mode: 'reference'` can't be expressed as a flat $match without a
151
- * `$lookup` — for that mode this function returns `null` and callers must
151
+ * `$lookup` - for that mode this function returns `null` and callers must
152
152
  * fall back to per-entry `checkEntryAccess()` filtering (slower but correct).
153
153
  *
154
154
  * @param {object|null} user
@@ -171,7 +171,7 @@ export function buildRowLevelMatch(user, rowLevel) {
171
171
  return {[`data.${field}`]: userVal};
172
172
  }
173
173
 
174
- // 'reference' mode needs a join — caller should use checkEntryAccess()
174
+ // 'reference' mode needs a join - caller should use checkEntryAccess()
175
175
  // per-entry instead. Return null so the upstream code knows to do that.
176
176
  return null;
177
177
  }