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
@@ -3,7 +3,7 @@
3
3
  * Project-scoped machine credentials for the external collections API.
4
4
  *
5
5
  * Tokens are entries in the file-based `api-tokens` preset collection.
6
- * Only a SHA-256 hash is stored — the plaintext (`dcms_<64 hex>`) is returned
6
+ * Only a SHA-256 hash is stored - the plaintext (`dcms_<64 hex>`) is returned
7
7
  * exactly once, from createToken(). A token is accepted only on collection
8
8
  * verbs configured with `api.<verb>.access === 'token'`, and only for
9
9
  * collections whose resolved project matches the token's `project` binding.
@@ -26,7 +26,7 @@ let tokenCache = null;
26
26
  * Serialises this service's fire-and-forget writes (the throttled lastUsedAt
27
27
  * update) against its mutations. FileAdapter does unlocked read-modify-write,
28
28
  * so an in-flight lastUsedAt update that read the data file BEFORE a revoke
29
- * wrote it would write the revoked token straight back — resurrection.
29
+ * wrote it would write the revoked token straight back - resurrection.
30
30
  * Mutations await the chain; lastUsedAt updates append to it.
31
31
  */
32
32
  let writeChain = Promise.resolve();
@@ -35,7 +35,7 @@ function invalidateCache() {
35
35
  tokenCache = null;
36
36
  }
37
37
 
38
- // Generic admin collection endpoints emit these for ALL collections — the
38
+ // Generic admin collection endpoints emit these for ALL collections - the
39
39
  // service's own mutations invalidate directly, this catches edits made
40
40
  // through the admin entries grid.
41
41
  for (const ev of ['collection:entryCreated', 'collection:entryUpdated', 'collection:entryDeleted']) {
@@ -162,7 +162,7 @@ export async function findTokenByName(name, project) {
162
162
  /**
163
163
  * Validate a presented plaintext token.
164
164
  * Returns the sanitised entry when the token exists, is enabled, and has not
165
- * expired — otherwise null. Updates lastUsedAt at most once per minute
165
+ * expired - otherwise null. Updates lastUsedAt at most once per minute
166
166
  * (fire-and-forget; the cached copy is patched in place to avoid thrash).
167
167
  *
168
168
  * @param {string} plaintext
@@ -184,7 +184,7 @@ export async function validateToken(plaintext) {
184
184
 
185
185
  const lastUsed = entry.data.lastUsedAt ? Date.parse(entry.data.lastUsedAt) : 0;
186
186
  if (Date.now() - lastUsed > LAST_USED_THROTTLE_MS) {
187
- // updateEntry replaces data wholesale — pass the full object.
187
+ // updateEntry replaces data wholesale - pass the full object.
188
188
  const data = {...entry.data, lastUsedAt: new Date().toISOString()};
189
189
  entry.data = data; // keep the cached copy current without invalidating
190
190
  // Fire-and-forget, but chained so revoke/update can await it.
@@ -222,7 +222,7 @@ export async function getTokenSanitised(id) {
222
222
 
223
223
  /**
224
224
  * Update mutable token fields. The project binding, hash, and hint are fixed
225
- * for the token's lifetime — revoke and re-issue to rebind.
225
+ * for the token's lifetime - revoke and re-issue to rebind.
226
226
  *
227
227
  * @param {string} id
228
228
  * @param {{name?: string, enabled?: boolean, scopes?: object[], expiresAt?: string|null}} patch
@@ -264,7 +264,7 @@ export async function updateToken(id, patch = {}) {
264
264
  * @returns {Promise<boolean>}
265
265
  */
266
266
  export async function revokeToken(id) {
267
- await writeChain; // drain any in-flight lastUsedAt write — revoke must be final
267
+ await writeChain; // drain any in-flight lastUsedAt write - revoke must be final
268
268
  const entry = await getEntry(API_TOKENS_COLLECTION_SLUG, id);
269
269
  if (!entry) return false;
270
270
  await deleteEntry(API_TOKENS_COLLECTION_SLUG, id);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Blocks Service
3
3
  * CRUD operations and seeding for reusable HTML block templates in content/blocks/.
4
- * Never overwrites existing files during seeding — user customisations are preserved.
4
+ * Never overwrites existing files during seeding - user customisations are preserved.
5
5
  */
6
6
  import fs from 'fs/promises';
7
7
  import path from 'path';
@@ -21,7 +21,7 @@ async function seedBlock(name, content) {
21
21
  const filePath = path.join(BLOCKS_DIR, `${name}.html`);
22
22
  try {
23
23
  await fs.access(filePath);
24
- // File exists — leave it alone
24
+ // File exists - leave it alone
25
25
  } catch {
26
26
  await fs.writeFile(filePath, content.trim() + '\n', 'utf8');
27
27
  }
@@ -34,7 +34,7 @@ async function seedBlock(name, content) {
34
34
  const BLOCKS = {
35
35
 
36
36
  /**
37
- * contact-info — standalone contact details panel for landing pages.
37
+ * contact-info - standalone contact details panel for landing pages.
38
38
  * Fields: heading, intro, phone, phoneHref, email, address, hours,
39
39
  * facebook, facebookLabel, instagram, instagramLabel,
40
40
  * website, websiteLabel, footnote
@@ -99,7 +99,7 @@ const BLOCKS = {
99
99
  `,
100
100
 
101
101
  /**
102
- * contact-card — for the Contacts form
102
+ * contact-card - for the Contacts form
103
103
  * Fields: full_name, phone_number, email_address
104
104
  */
105
105
  'contact-card': `
@@ -120,7 +120,7 @@ const BLOCKS = {
120
120
  `,
121
121
 
122
122
  /**
123
- * enquiry-card — for the Enquiries form
123
+ * enquiry-card - for the Enquiries form
124
124
  * Fields: full_name, email, phone, subject, message
125
125
  */
126
126
  'enquiry-card': `
@@ -143,7 +143,7 @@ const BLOCKS = {
143
143
  `,
144
144
 
145
145
  /**
146
- * feedback-card — for the Feedback form
146
+ * feedback-card - for the Feedback form
147
147
  * Fields: name, email, rating, category, subject, message, recommend
148
148
  */
149
149
  'feedback-card': `
@@ -159,7 +159,7 @@ const BLOCKS = {
159
159
  <div class="card-body">
160
160
  <p style="margin:0 0 1rem;line-height:1.65;">{{message}}</p>
161
161
  <div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:.5rem;font-size:.8rem;color:var(--dm-text-muted,#888);">
162
- <span>{{name}} &mdash; {{email}}</span>
162
+ <span>{{name}} - {{email}}</span>
163
163
  <span>Would recommend: <strong>{{recommend}}</strong></span>
164
164
  </div>
165
165
  </div>
@@ -167,7 +167,7 @@ const BLOCKS = {
167
167
  `,
168
168
 
169
169
  /**
170
- * note-card — for the Notes form
170
+ * note-card - for the Notes form
171
171
  * Fields: title, content, category, tags
172
172
  */
173
173
  'note-card': `
@@ -187,7 +187,7 @@ const BLOCKS = {
187
187
  `,
188
188
 
189
189
  /**
190
- * todo-item — for the To-Do form
190
+ * todo-item - for the To-Do form
191
191
  * Fields: title, description, status, priority, due_date, assigned_to
192
192
  */
193
193
  'todo-item': `
@@ -238,7 +238,7 @@ function blockMetaPath(name) {
238
238
  return path.join(BLOCKS_DIR, `${name}.meta.json`);
239
239
  }
240
240
 
241
- /** Per-block CSS size cap — matches the 100 KB site-wide cap, scaled down. */
241
+ /** Per-block CSS size cap - matches the 100 KB site-wide cap, scaled down. */
242
242
  const MAX_CSS_SIZE = 50 * 1024;
243
243
 
244
244
  // ---------------------------------------------------------------------------
@@ -300,7 +300,7 @@ export async function getBlock(name) {
300
300
  let css = '';
301
301
  try {
302
302
  css = await fs.readFile(blockCssPath(name), 'utf8');
303
- } catch { /* no CSS file — treat as empty */
303
+ } catch { /* no CSS file - treat as empty */
304
304
  }
305
305
  return {name, content, css, bundled, ...(meta && {meta})};
306
306
  } catch (err) {
@@ -334,7 +334,7 @@ export async function saveBlock(name, content, {bundled, css, meta} = {}) {
334
334
  await fs.mkdir(BLOCKS_DIR, {recursive: true});
335
335
  await fs.writeFile(blockFilePath(name), content, 'utf8');
336
336
  const metaPath = blockMetaPath(name);
337
- // Merge sidecar meta — bundled flag plus any caller-supplied fields (eg meta.project).
337
+ // Merge sidecar meta - bundled flag plus any caller-supplied fields (eg meta.project).
338
338
  const sidecar = {
339
339
  ...(meta && typeof meta === 'object' ? meta : {}),
340
340
  ...(bundled ? {bundled: true} : {})
@@ -345,7 +345,7 @@ export async function saveBlock(name, content, {bundled, css, meta} = {}) {
345
345
  await fs.unlink(metaPath).catch(() => {
346
346
  });
347
347
  }
348
- // CSS is optional — only touch the file when the caller passed the field explicitly.
348
+ // CSS is optional - only touch the file when the caller passed the field explicitly.
349
349
  if (typeof css === 'string') {
350
350
  if (css.trim().length > 0) {
351
351
  await fs.writeFile(blockCssPath(name), css, 'utf8');
@@ -3,7 +3,7 @@ import {LRU} from '../lru.js';
3
3
  /**
4
4
  * In-memory cache driver with TTL and tag invalidation.
5
5
  *
6
- * Each entry is stored as {value, expiresAt, tags}. TTL is lazy — entries
6
+ * Each entry is stored as {value, expiresAt, tags}. TTL is lazy - entries
7
7
  * past their expiresAt are returned as undefined on the next get and dropped.
8
8
  *
9
9
  * Tag index: a Map<tag, Set<key>>. set() with {tags} adds the key to each
@@ -79,14 +79,14 @@ export class MemoryDriver {
79
79
  this._tagIndex.clear();
80
80
  }
81
81
 
82
- /** Current entry count (sync — for the dashboard widget). */
82
+ /** Current entry count (sync - for the dashboard widget). */
83
83
  size() {
84
84
  return this._lru.size;
85
85
  }
86
86
 
87
87
  /**
88
88
  * Snapshot the currently cached entries as `{key, tags, expiresAt}`.
89
- * No values — they can be megabytes of HTML. Uses `entries()` (non-mutating)
89
+ * No values - they can be megabytes of HTML. Uses `entries()` (non-mutating)
90
90
  * so the LRU order is preserved through the iteration.
91
91
  */
92
92
  listEntries() {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Null driver — used when the cache is disabled.
2
+ * Null driver - used when the cache is disabled.
3
3
  * Every read misses; every write is dropped on the floor.
4
4
  */
5
5
  export class NoneDriver {
@@ -61,7 +61,7 @@ export async function initCache(config = {}) {
61
61
  *
62
62
  * When turning on, re-initialise with the last config (or sensible defaults)
63
63
  * so a previously disabled cache gets a real driver. When turning off, keep
64
- * the driver in place but short-circuit reads/writes — preserves cached data
64
+ * the driver in place but short-circuit reads/writes - preserves cached data
65
65
  * if the user toggles back on. The persistence of this choice across restarts
66
66
  * is the caller's responsibility (the route handler writes to config/cache.json).
67
67
  */
@@ -69,12 +69,12 @@ export async function setEnabled(enabled) {
69
69
  if (Boolean(enabled) === _enabled) return;
70
70
  if (enabled) {
71
71
  if (_driver instanceof NoneDriver) {
72
- // No usable driver yet — bootstrap one from last-known config.
72
+ // No usable driver yet - bootstrap one from last-known config.
73
73
  const cfg = {...(_lastConfig || {}), enabled: true};
74
74
  if (!cfg.driver || cfg.driver === 'none') cfg.driver = 'memory';
75
75
  await initCache(cfg);
76
76
  } else {
77
- // Memory/Redis driver still live — flip the flag and preserve data.
77
+ // Memory/Redis driver still live - flip the flag and preserve data.
78
78
  _enabled = true;
79
79
  }
80
80
  } else {
@@ -110,7 +110,7 @@ export async function clear() {
110
110
 
111
111
  /**
112
112
  * List the cache's keys with their tags and expiry. Values are deliberately
113
- * excluded — cached entries can be megabytes of HTML.
113
+ * excluded - cached entries can be megabytes of HTML.
114
114
  * @returns {{key: string, tags: string[], expiresAt: number|null}[]}
115
115
  */
116
116
  export function listEntries() {
@@ -148,7 +148,7 @@ export function getStats() {
148
148
  * Get-or-set with in-flight coalescing.
149
149
  *
150
150
  * Producer may return either a raw value, or an object with a `_cacheTags`
151
- * array — those tags are merged into opts.tags before storing, and the
151
+ * array - those tags are merged into opts.tags before storing, and the
152
152
  * `_cacheTags` field is stripped from both the cached and returned value.
153
153
  * This lets producers report tags discovered during rendering (e.g. the
154
154
  * markdown pipeline noting embedded collection slugs) without the caller
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Collections Service
3
- * Schema-first data store — one directory per collection under content/collections/{slug}/.
3
+ * Schema-first data store - one directory per collection under content/collections/{slug}/.
4
4
  * Each collection has a schema.json (field definitions + API access config).
5
5
  *
6
6
  * Entry storage is delegated to the active adapter (FileAdapter or MongoAdapter).
@@ -65,11 +65,11 @@ function defaultApiAccess() {
65
65
  }
66
66
 
67
67
  // ---------------------------------------------------------------------------
68
- // Schema (collection) operations — always file-based
68
+ // Schema (collection) operations - always file-based
69
69
  // ---------------------------------------------------------------------------
70
70
 
71
71
  /**
72
- * List all collections — reads all schema.json files and appends entryCount.
72
+ * List all collections - reads all schema.json files and appends entryCount.
73
73
  *
74
74
  * @returns {Promise<object[]>}
75
75
  */
@@ -93,7 +93,7 @@ export async function listCollections() {
93
93
  return results
94
94
  .filter(r => r.status === 'fulfilled')
95
95
  .map(r => r.value)
96
- // Hand-rolled schemas can lack `title` — fall back to slug rather
96
+ // Hand-rolled schemas can lack `title` - fall back to slug rather
97
97
  // than crashing the whole listing (seen in the wild: a schema with
98
98
  // `name` instead of `title` 500'd /api/collections permanently).
99
99
  .sort((a, b) => String(a.title || a.slug || '').localeCompare(String(b.title || b.slug || '')));
@@ -215,7 +215,7 @@ export async function deleteCollection(slug) {
215
215
  if (!schema) throw new Error(`Collection "${slug}" not found`);
216
216
 
217
217
  // Remove adapter-side data first (handles MongoDB collections on
218
- // non-file adapters). Prefer drop() — clear() only deletes documents
218
+ // non-file adapters). Prefer drop() - clear() only deletes documents
219
219
  // and leaves the backing Mongo collection + its unique index behind
220
220
  // forever.
221
221
  try {
@@ -228,7 +228,7 @@ export async function deleteCollection(slug) {
228
228
  }
229
229
  }
230
230
  } catch {
231
- // Ignore errors — directory removal handles file-backed data.
231
+ // Ignore errors - directory removal handles file-backed data.
232
232
  }
233
233
 
234
234
  invalidate(slug);
@@ -244,7 +244,7 @@ export async function deleteCollection(slug) {
244
244
  * Validate entry data against a collection schema.
245
245
  *
246
246
  * Reference-field validation (target id existence) requires async I/O and is
247
- * therefore done in `validateReferences()` — called from create/update paths
247
+ * therefore done in `validateReferences()` - called from create/update paths
248
248
  * after this synchronous shape check passes.
249
249
  *
250
250
  * @param {object} schema
@@ -268,7 +268,7 @@ export function validateEntryData(schema, data) {
268
268
  * entry that actually exists in the target collection. Single-valued and
269
269
  * array-valued references are both supported.
270
270
  *
271
- * Empty/null references are skipped here — the required check in
271
+ * Empty/null references are skipped here - the required check in
272
272
  * `validateEntryData()` handles those.
273
273
  *
274
274
  * @param {object} schema
@@ -280,7 +280,7 @@ export async function validateReferences(schema, data) {
280
280
  for (const field of (schema.fields || [])) {
281
281
  if (field.type !== 'reference') continue;
282
282
  const targetSlug = field.reference?.collection;
283
- if (!targetSlug) continue; // misconfigured schema — skip silently
283
+ if (!targetSlug) continue; // misconfigured schema - skip silently
284
284
 
285
285
  const raw = data[field.name];
286
286
  if (raw === undefined || raw === null || raw === '') continue;
@@ -301,7 +301,7 @@ export async function validateReferences(schema, data) {
301
301
  }
302
302
 
303
303
  // ---------------------------------------------------------------------------
304
- // Entry operations — delegated to storage adapter
304
+ // Entry operations - delegated to storage adapter
305
305
  // ---------------------------------------------------------------------------
306
306
 
307
307
  /**
@@ -309,22 +309,22 @@ export async function validateReferences(schema, data) {
309
309
  * structured filtering, and reference resolution.
310
310
  *
311
311
  * Delegates to the configured adapter (File or Mongo). Both adapters honour
312
- * identical option semantics — see `filterEngine.js` for the filter DSL.
312
+ * identical option semantics - see `filterEngine.js` for the filter DSL.
313
313
  *
314
314
  * @example
315
315
  * // Paginated, sorted, simple search
316
316
  * await listEntries('jobs', { page: 1, limit: 20, sort: 'postedAt', search: 'engineer' });
317
317
  *
318
318
  * @example
319
- * // Structured filter — full-time London roles paying £50k+
319
+ * // Structured filter - full-time London roles paying £50k+
320
320
  * await listEntries('jobs', { filter: { location: 'London', type: 'full-time', salary_gte: 50000 } });
321
321
  *
322
322
  * @example
323
- * // Row-level scoping — applications belonging to one user
323
+ * // Row-level scoping - applications belonging to one user
324
324
  * await listEntries('applications', { filter: { createdBy: userId } });
325
325
  *
326
326
  * @example
327
- * // Resolve reference fields — each entry gets `_refs.<field>` with display labels
327
+ * // Resolve reference fields - each entry gets `_refs.<field>` with display labels
328
328
  * await listEntries('applications', { resolveRefs: true });
329
329
  * // → entries[0]._refs.jobId === { id, display: 'Senior Engineer', missing: false, link: null }
330
330
  *
@@ -159,7 +159,7 @@ export function compileComponent(source, name) {
159
159
  catch (e) { return {js: null, errors: [{type: e.code, message: e.message}]}; }
160
160
 
161
161
  const js = [
162
- `// Compiled from ${name}.dmc — do not edit by hand.`,
162
+ `// Compiled from ${name}.dmc - do not edit by hand.`,
163
163
  `const template = ${toBacktickLiteral(parts.template)};`,
164
164
  `const style = ${toBacktickLiteral(parts.style)};`,
165
165
  `const props = ${JSON.stringify(propsObj)};`,
@@ -219,7 +219,7 @@ export async function getCompiledJs(name) {
219
219
  // ─── CRUD operations ──────────────────────────────────────────────────────
220
220
 
221
221
  /**
222
- * List all components. Lightweight metadata — does NOT compile.
222
+ * List all components. Lightweight metadata - does NOT compile.
223
223
  * The `props` field is parsed from each file's <props> block so the admin
224
224
  * list view can show props without a follow-up fetch.
225
225
  *
@@ -245,7 +245,7 @@ export async function listComponents() {
245
245
  const src = await fs.readFile(path.join(COMPONENTS_DIR, file), 'utf8');
246
246
  const {props: propsBlock} = parseDmcSource(src);
247
247
  props = validatePropsJson(propsBlock);
248
- } catch { /* malformed — list still usable */ }
248
+ } catch { /* malformed - list still usable */ }
249
249
  entries.push({
250
250
  name,
251
251
  size: stat?.size ?? 0,
@@ -263,7 +263,7 @@ export async function listComponents() {
263
263
  try {
264
264
  const parts = parseDmcSource(pc.source);
265
265
  props = validatePropsJson(parts.props);
266
- } catch { /* malformed plugin source — list still usable */ }
266
+ } catch { /* malformed plugin source - list still usable */ }
267
267
  entries.push({
268
268
  name: pc.name,
269
269
  size: pc.source.length,
@@ -313,7 +313,7 @@ export async function getComponent(name) {
313
313
  }
314
314
 
315
315
  /**
316
- * Create or update a component. Compiles BEFORE writing — a malformed source
316
+ * Create or update a component. Compiles BEFORE writing - a malformed source
317
317
  * never reaches disk. Returns the compile errors (if any) rather than throwing.
318
318
  *
319
319
  * @param {string} name
@@ -352,12 +352,12 @@ export async function saveComponent(name, source, {bundled = false, meta} = {})
352
352
  return {success: true, name, errors: []};
353
353
  }
354
354
 
355
- /** Lazy, circular-safe import — refreshes the markdown sanitiser's dm-* tag allowlist. */
355
+ /** Lazy, circular-safe import - refreshes the markdown sanitiser's dm-* tag allowlist. */
356
356
  async function _refreshSanitiserAllowlist() {
357
357
  try {
358
358
  const {refreshComponentTagAllowlist} = await import('./markdown.js');
359
359
  await refreshComponentTagAllowlist();
360
- } catch { /* no-op — markdown module not ready at first save */ }
360
+ } catch { /* no-op - markdown module not ready at first save */ }
361
361
  }
362
362
 
363
363
  /**
@@ -465,7 +465,7 @@ const SEED_COMPONENTS = {
465
465
  }
466
466
  </props>
467
467
  <script>
468
- // NB: Domma's factory calls data() with no \`this\` binding — props must be
468
+ // NB: Domma's factory calls data() with no \`this\` binding - props must be
469
469
  // read from within onMount() / methods, which ARE bound to the component context.
470
470
  export default {
471
471
  data() { return { count: 0 }; },
@@ -633,7 +633,7 @@ export default {
633
633
 
634
634
  /**
635
635
  * Seed starter components into content/components/.
636
- * Never overwrites existing files — user customisations are preserved.
636
+ * Never overwrites existing files - user customisations are preserved.
637
637
  * All seeded components are marked `bundled: true` via their .meta.json.
638
638
  */
639
639
  export async function seedDefaultComponents() {
@@ -2,7 +2,7 @@
2
2
  * Connection Manager (Pro feature)
3
3
  *
4
4
  * Manages MongoDB client lifecycle for named database connections.
5
- * Loaded dynamically via import() — never required in the free version.
5
+ * Loaded dynamically via import() - never required in the free version.
6
6
  *
7
7
  * Usage:
8
8
  * await initialise(connectionsConfig) // on server startup
@@ -15,13 +15,13 @@ const clients = new Map();
15
15
 
16
16
  /**
17
17
  * Initialise all named MongoDB connections from config.
18
- * Connects lazily — each client is created but connect() is deferred to first use.
18
+ * Connects lazily - each client is created but connect() is deferred to first use.
19
19
  *
20
20
  * @param {Record<string, { type: string, uri: string, database: string, options?: object }>} connectionsConfig
21
21
  * @returns {Promise<void>}
22
22
  */
23
23
  export async function initialise(connectionsConfig) {
24
- // Dynamic import — mongodb package is optional and not loaded in the free version.
24
+ // Dynamic import - mongodb package is optional and not loaded in the free version.
25
25
  const { MongoClient } = await import('mongodb');
26
26
 
27
27
  for (const [name, cfg] of Object.entries(connectionsConfig)) {
@@ -43,7 +43,7 @@ export async function initialise(connectionsConfig) {
43
43
  console.log(`[connectionManager] Connected: ${name} → ${cfg.database}`);
44
44
  } catch (err) {
45
45
  console.error(`[connectionManager] Failed to connect "${name}": ${err.message}`);
46
- // Don't throw — allow server to start with degraded MongoDB support.
46
+ // Don't throw - allow server to start with degraded MongoDB support.
47
47
  }
48
48
  }
49
49
  }
@@ -79,6 +79,17 @@ export async function getPage(urlPath, opts = {}) {
79
79
  /**
80
80
  * Create a new page. Auto-creates parent directories.
81
81
  *
82
+ * Every frontmatter key the caller sends is written through - the same
83
+ * contract as updatePage. An earlier version built `meta` from a fixed
84
+ * whitelist, which silently discarded `project`, `visibility`, `tags`,
85
+ * `category` and `theme` on the FIRST save of a page: a page created inside
86
+ * a project landed in core, vanished from that project's list, and yet the
87
+ * URL was taken, so re-creating it answered "already exists".
88
+ *
89
+ * `project: ''` means "inherit by rootUrl" - the key is omitted rather than
90
+ * written as null (null is the deprecated core opt-out, and it pins the page
91
+ * to core forever).
92
+ *
82
93
  * @param {string} urlPath
83
94
  * @param {object} frontmatter
84
95
  * @param {string} body
@@ -91,6 +102,7 @@ export async function createPage(urlPath, frontmatter, body) {
91
102
  const defaults = config.content.pageDefaults;
92
103
  const now = new Date().toISOString();
93
104
  const meta = {
105
+ ...frontmatter,
94
106
  title: frontmatter.title || 'Untitled',
95
107
  slug: slugFromPath(urlPath),
96
108
  description: frontmatter.description || '',
@@ -104,6 +116,7 @@ export async function createPage(urlPath, frontmatter, body) {
104
116
  createdAt: now,
105
117
  updatedAt: now
106
118
  };
119
+ if (meta.project === '' || meta.project === undefined) delete meta.project;
107
120
 
108
121
  await fs.writeFile(filePath, serialiseMarkdown(meta, body || ''), 'utf8');
109
122
  const page = await readPageFile(filePath);
@@ -134,6 +147,11 @@ export async function updatePage(urlPath, frontmatter, body, {author} = {}) {
134
147
 
135
148
  if (existingMeta.createdAt) meta.createdAt = existingMeta.createdAt;
136
149
 
150
+ // '' is the editor's "inherit by rootUrl" choice - drop the key so the
151
+ // resolver re-derives it. (null stays meaningful: the deprecated explicit
152
+ // core opt-out.)
153
+ if (meta.project === '') delete meta.project;
154
+
137
155
  try {
138
156
  await createVersion(urlPath, {author, type: 'auto', label: null});
139
157
  } catch {
@@ -321,7 +339,7 @@ async function collectMdFiles(dir) {
321
339
  * @param {object} [opts]
322
340
  * @param {object|null} [opts.user] - Forwarded to parseMarkdown so the
323
341
  * `[menu]` shortcode can filter items by per-user visibility. Defaults to
324
- * anonymous (null) — most callers (listings, admin previews) leave this unset.
342
+ * anonymous (null) - most callers (listings, admin previews) leave this unset.
325
343
  * @returns {Promise<object>}
326
344
  */
327
345
  async function readPageFile(filePath, opts = {}) {
@@ -331,7 +349,12 @@ async function readPageFile(filePath, opts = {}) {
331
349
  // against menus bound to this page or its project, exactly as the navbar does.
332
350
  const { data, content, html, usedComponents, tags } =
333
351
  await parseMarkdown(raw, {user: opts.user || null, urlPath});
334
- return { ...data, urlPath, content, html, usedComponents, tags };
352
+ // parseMarkdown's `tags` are CACHE tags (`block:x`, `collection:y`), not
353
+ // the page's editorial tags. Spreading them last used to shadow the
354
+ // frontmatter's `tags`, so the editor loaded `block:feedback-card` into
355
+ // the tag pillbox and wrote it back on save, and the pages list showed
356
+ // cache internals in its Tags column. Keep them apart.
357
+ return { ...data, urlPath, content, html, usedComponents, cacheTags: tags };
335
358
  }
336
359
 
337
360
  /**
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Domma Docs — request-time renderer
2
+ * Domma Docs - request-time renderer
3
3
  *
4
4
  * Serves the Domma CMS handbook at /domma-docs/* directly from the admin doc
5
5
  * HTML templates (admin/js/templates/docs/*.html) at request time. There is no
6
6
  * baked content: a domma-cms version bump ships new templates and the docs
7
- * update everywhere automatically — no porter, no per-site provisioning, no
7
+ * update everywhere automatically - no porter, no per-site provisioning, no
8
8
  * staleness. (Replaces the one-shot scripts/migrate-docs.js bake.)
9
9
  *
10
10
  * The publish switch is the `domma-docs` project's enabled flag, enforced by
@@ -33,7 +33,7 @@ const TPL_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../..', 'admin
33
33
  // Functional nav chrome only (breadcrumb + prev/next + section nav). Everything
34
34
  // else (hero, cards, tabs, accordion, buttons) is a native Domma component, so
35
35
  // it follows the theme. Injected as an inline <style> per docs page at render
36
- // time — no dependency on content/custom.css.
36
+ // time - no dependency on content/custom.css.
37
37
  const DOCS_BODY_CSS = `.doc-breadcrumb { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; max-width: 80ch; margin: 0 auto 1.25rem; font-size: .85rem; color: var(--dm-text-muted); }
38
38
  .doc-breadcrumb a { color: var(--dm-primary); text-decoration: none; }
39
39
  .doc-breadcrumb a:hover { text-decoration: underline; }
@@ -76,7 +76,7 @@ const MAP = [
76
76
  ];
77
77
 
78
78
  // Admin-SPA hash routes and stale /resources/… paths don't resolve on the
79
- // public site — rewrite doc-to-doc links to /domma-docs/…, admin-feature links
79
+ // public site - rewrite doc-to-doc links to /domma-docs/…, admin-feature links
80
80
  // to the admin SPA, and unwrap the rest.
81
81
  const LINK_MAP = {
82
82
  '#/docs/components-walkthrough': '/domma-docs/components/walkthrough',
@@ -97,7 +97,7 @@ const LINK_MAP = {
97
97
  '/resources/components': '/domma-docs/components'
98
98
  };
99
99
 
100
- // The four top-level doc sections — rendered as a persistent nav on every page.
100
+ // The four top-level doc sections - rendered as a persistent nav on every page.
101
101
  const SECTIONS = [
102
102
  {label: 'Usage', href: '/domma-docs/usage'},
103
103
  {label: 'Tutorials', href: '/domma-docs/tutorials'},
@@ -134,13 +134,13 @@ function rewriteLinks(html) {
134
134
  for (const [from, to] of Object.entries(LINK_MAP)) {
135
135
  out = out.split(`href="${from}"`).join(`href="${to}"`);
136
136
  }
137
- // Any remaining /resources/… link has no doc equivalent — unwrap to text.
137
+ // Any remaining /resources/… link has no doc equivalent - unwrap to text.
138
138
  out = out.replace(/<a\b[^>]*\shref="\/resources\/[^"]*"[^>]*>([\s\S]*?)<\/a>/gi, '$1');
139
139
  return out;
140
140
  }
141
141
 
142
142
  // Pull every inline <style> block out of the body, returning the cleaned HTML
143
- // plus the extracted (deduped) CSS — collected PER CALL (no shared module state,
143
+ // plus the extracted (deduped) CSS - collected PER CALL (no shared module state,
144
144
  // which would leak across requests in a long-lived server).
145
145
  function stripStyleBlocks(html) {
146
146
  const styles = new Set();
@@ -182,7 +182,7 @@ function unwrapDocsBody(html) {
182
182
  // Plain-text heading title, safe as a shortcode attribute value.
183
183
  function headingText(inner) {
184
184
  return inner.replace(/<[^>]+>/g, '')
185
- .replace(/&amp;/g, '&').replace(/&mdash;/g, '—').replace(/&ndash;/g, '–')
185
+ .replace(/&amp;/g, '&').replace(/-/g, '-').replace(/-/g, '-')
186
186
  .replace(/&quot;/g, "'").replace(/&#39;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>')
187
187
  .replace(/\s+/g, ' ').trim().replace(/"/g, "'");
188
188
  }
@@ -200,7 +200,7 @@ function splitSections(inner, level) {
200
200
  return {preamble, sections};
201
201
  }
202
202
 
203
- // ≥7 sections → accordion; 2–6 → tabs; <2 → a single card.
203
+ // ≥7 sections → accordion; 2-6 → tabs; <2 → a single card.
204
204
  function structureBody(body) {
205
205
  const inner = unwrapDocsBody(body);
206
206
  const level = /<h2\b/i.test(inner) ? 2 : 3;
@@ -265,9 +265,9 @@ function buildSectionLanding(label) {
265
265
  function buildHomeLanding() {
266
266
  const hero = `[hero title="Domma Docs" tagline="The Domma CMS handbook" variant="gradient" align="center" fullwidth="true"][/hero]`;
267
267
  const cards = [
268
- {label: 'Usage', href: '/domma-docs/usage', icon: 'book-open', blurb: 'Day-to-day guides for running the CMS — pages, media, navigation, shortcodes and more.'},
268
+ {label: 'Usage', href: '/domma-docs/usage', icon: 'book-open', blurb: 'Day-to-day guides for running the CMS - pages, media, navigation, shortcodes and more.'},
269
269
  {label: 'Tutorials', href: '/domma-docs/tutorials', icon: 'academic-cap', blurb: 'Step-by-step walkthroughs that build something real from start to finish.'},
270
- {label: 'Components', href: '/domma-docs/components', icon: 'puzzle', blurb: 'The full component reference — shortcodes, how-tos, walkthroughs and rules.'},
270
+ {label: 'Components', href: '/domma-docs/components', icon: 'puzzle', blurb: 'The full component reference - shortcodes, how-tos, walkthroughs and rules.'},
271
271
  {label: 'API Reference', href: '/domma-docs/api', icon: 'code', blurb: 'Every REST endpoint for headless and external integrations.'}
272
272
  ].map(s =>
273
273
  `[card icon="${s.icon}" title="${heroAttr(s.label)}"]\n${escText(s.blurb)}\n\n[button label="Browse ${heroAttr(s.label)} →" href="${s.href}" variant="primary" /]\n[/card]`
@@ -278,7 +278,7 @@ function buildHomeLanding() {
278
278
  // ── render ───────────────────────────────────────────────────────────────────
279
279
 
280
280
  // Build the shortcode body for a doc path, or null if the path is unknown.
281
- // Returns {title, body, styles[]} — `styles` is the inline CSS extracted from a
281
+ // Returns {title, body, styles[]} - `styles` is the inline CSS extracted from a
282
282
  // leaf template (empty for synthesized landings).
283
283
  async function buildDocBody(docPath) {
284
284
  if (docPath in LANDINGS) {
@@ -292,7 +292,7 @@ async function buildDocBody(docPath) {
292
292
  try {
293
293
  src = await readFile(join(TPL_DIR, `${tpl}.html`), 'utf8');
294
294
  } catch {
295
- return null; // template missing — treat as a 404
295
+ return null; // template missing - treat as a 404
296
296
  }
297
297
  const {html: stripped, styles} = stripStyleBlocks(stripHeader(src));
298
298
  const body = escapeCodeBrackets(dedentOutsidePre(rewriteLinks(stripped))).trim();
@@ -308,7 +308,7 @@ async function buildDocBody(docPath) {
308
308
  /**
309
309
  * Render a Domma Docs page to full HTML, or return null for an unknown path
310
310
  * (the route turns that into a 404). Cached per path+origin under the static
311
- * 'docs' tag — invalidated on boot so a version bump never serves stale HTML.
311
+ * 'docs' tag - invalidated on boot so a version bump never serves stale HTML.
312
312
  *
313
313
  * @param {string} docPath url tail after /domma-docs (e.g. 'usage/pages', '' for home)
314
314
  * @param {{user?: object|null, baseUrl?: string}} [opts]