domma-cms 1.6.60 → 1.7.1

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 (168) hide show
  1. package/CHANGELOG.md +4349 -0
  2. package/README.md +1 -1
  3. package/admin/dist/domma/domma-tools.css +3 -3
  4. package/admin/dist/domma/domma-tools.min.js +3 -3
  5. package/admin/js/lib/effect-defs.js +1 -1
  6. package/admin/js/lib/effects-builder.js +2 -2
  7. package/admin/js/lib/mail-compose.js +1 -0
  8. package/admin/js/lib/page-editor-reference.js +5 -5
  9. package/admin/js/lib/shortcode-tags.js +1 -1
  10. package/admin/js/templates/call-new.html +1 -1
  11. package/admin/js/templates/docs/usage-shortcodes.html +39 -0
  12. package/admin/js/views/call-new.js +1 -1
  13. package/admin/js/views/contacts.js +15 -15
  14. package/admin/js/views/form-submissions.js +2 -2
  15. package/docs/DommaFeatures.md +3005 -0
  16. package/docs/MultiTenant.md +176 -0
  17. package/docs/api-reference.md +1667 -0
  18. package/docs/cache.md +96 -0
  19. package/docs/configuration.md +1811 -0
  20. package/docs/editions-and-licences.md +190 -0
  21. package/docs/getting-started.md +201 -0
  22. package/docs/guides/form-triggers.md +260 -0
  23. package/docs/guides/mongodb-storage-adapter.md +282 -0
  24. package/docs/guides/multi-page-forms.md +121 -0
  25. package/docs/images/scribe-composer.png +0 -0
  26. package/docs/markdown-shortcodes.md +1738 -0
  27. package/docs/pentest-report-2026-04-03.md +181 -0
  28. package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
  29. package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
  30. package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
  31. package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
  32. package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
  33. package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
  34. package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
  35. package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
  36. package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
  37. package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
  38. package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
  39. package/docs/plans/2026-03-05-user-types-design.md +139 -0
  40. package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
  41. package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
  42. package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
  43. package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
  44. package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
  45. package/docs/plans/2026-06-10-core-project-design.md +240 -0
  46. package/docs/plugin-development.md +827 -0
  47. package/docs/recruitment-recipe.md +501 -0
  48. package/docs/scaffolding.md +312 -0
  49. package/docs/screenshots/calculator-floating.png +0 -0
  50. package/docs/screenshots/calculator-standard.png +0 -0
  51. package/docs/screenshots/email-settings.png +0 -0
  52. package/docs/screenshots/messages-count.png +0 -0
  53. package/docs/structure/user-types.md +107 -0
  54. package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
  55. package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
  56. package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
  57. package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
  58. package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
  59. package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
  60. package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
  61. package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
  62. package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
  63. package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
  64. package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
  65. package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
  66. package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
  67. package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
  68. package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
  69. package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
  70. package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
  71. package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
  72. package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
  73. package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
  74. package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
  75. package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
  76. package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
  77. package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
  78. package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
  79. package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
  80. package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
  81. package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
  82. package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
  83. package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
  84. package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
  85. package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
  86. package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
  87. package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
  88. package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
  89. package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
  90. package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
  91. package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
  92. package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
  93. package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
  94. package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
  95. package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
  96. package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
  97. package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
  98. package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
  99. package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
  100. package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
  101. package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
  102. package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
  103. package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
  104. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
  105. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
  106. package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
  107. package/docs/theming.md +320 -0
  108. package/package.json +2 -2
  109. package/plugins/_lib/admin/mail/compose-window.js +3 -1
  110. package/plugins/_lib/admin/mail/identity.js +18 -3
  111. package/plugins/_lib/admin/mail/reader-view.js +32 -3
  112. package/plugins/blog/CLAUDE.md +4 -0
  113. package/plugins/blog/admin/lib/email.js +191 -0
  114. package/plugins/blog/admin/views/comments.js +3 -1
  115. package/plugins/blog/plugin.json +2 -2
  116. package/plugins/blog/plugin.public.js +1 -1
  117. package/plugins/blog/tests/email.test.js +69 -0
  118. package/plugins/booking/CLAUDE.md +4 -0
  119. package/plugins/booking/admin/lib/email.js +191 -0
  120. package/plugins/booking/admin/views/panels.js +7 -3
  121. package/plugins/booking/plugin.json +1 -1
  122. package/plugins/booking/tests/email.test.js +69 -0
  123. package/plugins/free-tier.lock.json +27 -19
  124. package/plugins/shopping-cart/CLAUDE.md +4 -0
  125. package/plugins/shopping-cart/admin/lib/email.js +191 -0
  126. package/plugins/shopping-cart/admin/views/orders.js +11 -3
  127. package/plugins/shopping-cart/plugin.json +2 -2
  128. package/plugins/shopping-cart/tests/email.test.js +69 -0
  129. package/plugins/support/CLAUDE.md +4 -0
  130. package/plugins/support/admin/lib/email.js +191 -0
  131. package/plugins/support/admin/views/desk.js +4 -1
  132. package/plugins/support/admin/views/inbox.js +6 -2
  133. package/plugins/support/plugin.json +2 -2
  134. package/plugins/support/tests/email.test.js +69 -0
  135. package/public/js/celebrations/core/canvas.js +1 -1
  136. package/public/js/celebrations/index.js +1 -1
  137. package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
  138. package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
  139. package/public/js/celebrations/message-attrs.mjs +1 -0
  140. package/public/js/celebrations/themes/anniversary.js +1 -0
  141. package/public/js/celebrations/themes/autumn.js +1 -0
  142. package/public/js/celebrations/themes/birthday.js +1 -0
  143. package/public/js/celebrations/themes/butterfly-flight.js +1 -0
  144. package/public/js/celebrations/themes/calendar.js +1 -0
  145. package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
  146. package/public/js/celebrations/themes/chinese-new-year.js +1 -0
  147. package/public/js/celebrations/themes/christmas.js +1 -1
  148. package/public/js/celebrations/themes/easter.js +1 -0
  149. package/public/js/celebrations/themes/garden-drawing.js +1 -0
  150. package/public/js/celebrations/themes/garden.js +1 -0
  151. package/public/js/celebrations/themes/graduation.js +1 -0
  152. package/public/js/celebrations/themes/greeting.js +1 -0
  153. package/public/js/celebrations/themes/make-it-rain.js +1 -0
  154. package/public/js/celebrations/themes/money-rain.js +1 -0
  155. package/public/js/celebrations/themes/new-year.js +1 -0
  156. package/public/js/celebrations/themes/occasion-drawing.js +1 -0
  157. package/public/js/celebrations/themes/occasions.js +1 -0
  158. package/public/js/celebrations/themes/rain-control.js +1 -0
  159. package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
  160. package/public/js/celebrations/themes/sparkler-font.js +1 -0
  161. package/public/js/celebrations/themes/sparkler-writing.js +1 -0
  162. package/public/js/celebrations/themes/summer-garden.js +1 -0
  163. package/public/js/celebrations/themes/winter.js +1 -0
  164. package/public/js/celebrations/trait-data.mjs +1 -1
  165. package/public/js/effects.js +1 -1
  166. package/scripts/build.js +4 -0
  167. package/server/services/markdown.js +5 -2
  168. package/server/templates/page.html +1 -1
@@ -0,0 +1,1811 @@
1
+ # Configuration
2
+
3
+ All runtime configuration lives in the `config/` directory as plain JSON files. Secrets (JWT_SECRET, NODE_ENV) belong exclusively in `.env` - nothing else should be placed there.
4
+
5
+ Configuration files are loaded at startup via `server/config.js` and cached in memory. Admin-editable configs (`site`, `navigation`, `presets`, `plugins`) can be updated through the API at runtime and are written back to disk immediately.
6
+
7
+ ---
8
+
9
+ ## `config/server.json`
10
+
11
+ Controls the HTTP server.
12
+
13
+ ```json
14
+ {
15
+ "port": 4096,
16
+ "host": "0.0.0.0",
17
+ "cors": { "origin": false, "publicPrefixes": [] },
18
+ "uploads": { "maxFileSize": 10485760 },
19
+ "logging": { "level": "info" }
20
+ }
21
+ ```
22
+
23
+ | Field | Type | Default | Description |
24
+ |---|---|---|---|
25
+ | `port` | number | `4096` | TCP port the Fastify server binds to |
26
+ | `host` | string | `"0.0.0.0"` | Network interface to listen on. Use `"127.0.0.1"` to restrict to localhost |
27
+ | `cors.origin` | boolean / string / string[] | `false` | Passed directly to `@fastify/cors`. `false` disables CORS. Set to `"*"` or a domain to allow cross-origin requests |
28
+ | `cors.publicPrefixes` | string[] | `[]` | Absolute path prefixes whose **GET and HEAD** responses carry `Access-Control-Allow-Origin: *`, so other origins can hot-link those assets. A module script is fetched in CORS mode, so this is what makes e.g. `/public/demos/` importable from elsewhere. `@fastify/cors` decides per origin and cannot express a path, hence the separate key. Reads only - it never widens a write, and it applies to listed prefixes only. A value that is not an absolute path is ignored |
29
+ | `csp.connectHosts` | string[] | `[]` | Other sites a public page may read from in the browser (`fetch`), added to the Content-Security-Policy's `connect-src` as `https://<host>`. The site itself and cdn.jsdelivr.net are always allowed. Bare hostnames only (`dcm.dommajs.org`); a scheme, path, port or wildcard is ignored. Read at boot, so restart after changing it |
30
+ | `uploads.maxFileSize` | number | `10485760` (10 MB) | Maximum allowed upload size in bytes |
31
+ | `logging.level` | string | Log verbosity: `fatal`, `error`, `warn`, `info`, `debug`, `trace`, `silent`. Defaults to `info` in development, `warn` otherwise. `warn` hides 4xx responses and plugin `log.info` output, so a failed admin action leaves no trace - raise it to `info` if you need to diagnose one. Overridden by the `LOG_LEVEL` env var. |
32
+ | `restartOnPluginToggle` | boolean | `true` | Restart the server automatically when an admin enables or disables a plugin. See **Plugin toggles restart the server** below |
33
+ | `requireSignedPlugins` | boolean | `false` | When `true`, only signed `.dcmsplugin` bundles can be installed - an unsigned bundle (e.g. one exported from another site) is refused even if the installer confirms it. A bundle with a signature that does not verify is always refused, whatever this is set to |
34
+
35
+ ### Plugin toggles restart the server
36
+
37
+ A plugin's routes, hooks and admin views only register at boot, so enabling or
38
+ disabling one does nothing until the process restarts. The CMS does that for you:
39
+ after the toggle is saved the process exits cleanly and its supervisor brings it
40
+ back on the new plugin set. The admin UI waits for the server and reports when it
41
+ is live again.
42
+
43
+ Two guards keep this safe:
44
+
45
+ - **Only when supervised.** A restart happens only if something will start the
46
+ process again - pm2 (`pm_id` / `PM2_HOME`) or the fleet manager, which forks
47
+ each site with an IPC channel. Under a bare `node server/server.js` the toggle
48
+ is saved and the response says `restartRequired: true, supervised: false`, and
49
+ the admin is told to restart manually rather than losing the site.
50
+ - **Only on a real change.** Saving a plugin's *settings*, or re-sending the
51
+ `enabled` value it already had, never restarts anything. Several toggles in
52
+ quick succession are debounced into a single restart.
53
+
54
+ Set `restartOnPluginToggle: false` to keep the old behaviour, where the admin is
55
+ told a restart is needed and performs it themselves.
56
+
57
+ ---
58
+
59
+ ## `config/auth.json`
60
+
61
+ Controls JWT token lifetimes and password hashing cost. Does **not** store roles or permissions - those live in the `roles` collection.
62
+
63
+ ```json
64
+ {
65
+ "accessTokenExpiry": "15m",
66
+ "refreshTokenExpiry": "7d",
67
+ "resetTokenExpiry": "1h",
68
+ "bcryptRounds": 10
69
+ }
70
+ ```
71
+
72
+ | Field | Type | Default | Description |
73
+ |---|---|---|---|
74
+ | `accessTokenExpiry` | string | `"15m"` | Access token lifetime. Accepts `Nm` (minutes), `Nh` (hours), `Nd` (days) |
75
+ | `refreshTokenExpiry` | string | `"7d"` | Refresh token lifetime |
76
+ | `resetTokenExpiry` | string | `"1h"` | Password reset link lifetime - `30m`, `1h`, `2d`; the email and the login screen say it in words |
77
+ | `bcryptRounds` | number | `10` | bcrypt cost factor. Higher = slower hashing, more secure. 10-12 is typical |
78
+
79
+ ---
80
+
81
+ ## `config/content.json`
82
+
83
+ Tells the CMS where to find its content directories and what defaults to apply to new pages.
84
+
85
+ ```json
86
+ {
87
+ "contentDir": "./content",
88
+ "mediaDir": "./content/media",
89
+ "usersDir": "./content/users",
90
+ "collectionsDir": "./content/collections",
91
+ "pageDefaults": {
92
+ "layout": "default",
93
+ "status": "draft",
94
+ "sortOrder": 99
95
+ }
96
+ }
97
+ ```
98
+
99
+ | Field | Type | Description |
100
+ |---|---|---|
101
+ | `contentDir` | string | Root content directory - pages live at `{contentDir}/pages/` |
102
+ | `mediaDir` | string | Where uploaded media files are stored |
103
+ | `usersDir` | string | Where user account JSON files are stored |
104
+ | `collectionsDir` | string | Root directory for all collection data and schemas |
105
+ | `pageDefaults.layout` | string | Layout preset applied to new pages |
106
+ | `pageDefaults.status` | string | Initial publish status for new pages (`"draft"` or `"published"`) |
107
+ | `pageDefaults.sortOrder` | number | Default sort order for new pages |
108
+
109
+ ---
110
+
111
+ ## `config/site.json`
112
+
113
+ The main site-wide configuration. This file is editable from the **Settings** admin panel.
114
+
115
+ ```json
116
+ {
117
+ "title": "My Site",
118
+ "tagline": "A site built with Domma CMS",
119
+ "fontFamily": "Roboto",
120
+ "fontSize": 16,
121
+ "theme": "silver-dark",
122
+ "adminTheme": "charcoal-dark",
123
+ "layoutOptions": {
124
+ "spacerSize": 40
125
+ },
126
+ "seo": {
127
+ "defaultTitle": "My Site",
128
+ "titleSeparator": " | ",
129
+ "defaultDescription": "A site built with Domma CMS",
130
+ "defaultImage": "/media/og-default.png",
131
+ "robotsExtra": ""
132
+ },
133
+ "footer": {
134
+ "copyright": "© 2026 My Company",
135
+ "layout": "auto",
136
+ "blurb": "",
137
+ "showMotionToggle": true,
138
+ "links": [
139
+ { "text": "Privacy Policy", "url": "/privacy" }
140
+ ]
141
+ },
142
+ "social": {
143
+ "twitter": "",
144
+ "facebook": "",
145
+ "instagram": "",
146
+ "linkedin": "",
147
+ "github": "",
148
+ "youtube": ""
149
+ },
150
+ "smtp": {
151
+ "host": "localhost",
152
+ "port": 1025,
153
+ "user": "",
154
+ "pass": "",
155
+ "secure": false,
156
+ "fromAddress": "noreply@example.com",
157
+ "fromName": "My Site"
158
+ },
159
+ "backToTop": {
160
+ "enabled": true,
161
+ "scrollThreshold": 180,
162
+ "position": "bottom-right",
163
+ "offset": 16,
164
+ "bottomOffset": 16,
165
+ "label": "",
166
+ "smooth": true
167
+ },
168
+ "cookieConsent": {
169
+ "enabled": true,
170
+ "message": "We use cookies...",
171
+ "position": "bottom",
172
+ "layout": "bar",
173
+ "theme": "dark"
174
+ },
175
+ "breadcrumbs": {
176
+ "enabled": true,
177
+ "homeLabel": "Home",
178
+ "position": "TL",
179
+ "offsetX": 24,
180
+ "offsetY": 64
181
+ }
182
+ }
183
+ ```
184
+
185
+ ### Key fields
186
+
187
+ | Field | Description |
188
+ |---|---|
189
+ | `title` | Site name injected into `<title>` and the navbar brand |
190
+ | `tagline` | Short strapline displayed in the navbar brand |
191
+ | `adminHome` | The admin route `#/` (Dashboard) opens - `/plugins/site-manager/dashboard`, say. Absent or `/` = the built-in dashboard. Set at System > Site Settings. Ignored for a user without the permission guarding that screen, or when the sidebar no longer knows it (its plugin disabled); a confined role's own home wins. See `server/services/adminHome.js`. |
192
+ | `theme`, `adminTheme`, `fontFamily`, `fontSize`, `autoTheme`, `baseTheme` | **Mirror only.** These moved to `config/theme.json` (see below) and are rewritten here on every theme save so plugins and fleet tooling that read them keep working. Editing them here has no effect. |
193
+ | `smtp` | SMTP connection details for outgoing email (password is stripped from GET responses) - see **Outgoing email** below |
194
+ | `social` | Social profile URLs; empty strings are ignored by the frontend |
195
+ | `footer` | Footer copyright, legacy link list, and the "Reduce motion" switch - see below |
196
+ | `backToTop` | Scroll-to-top button configuration |
197
+ | `cookieConsent` | Cookie consent banner configuration |
198
+ | `breadcrumbs` | Breadcrumb navigation configuration |
199
+ | `bodyClass` | Your own CSS classes on the `<body>` of every public page (Site Settings > General > Body CSS classes) |
200
+ | `baseUrl` | **Optional override.** Absolute origin (e.g. `https://example.com`). By default the CMS derives the origin from the incoming request (`request.protocol` + `request.host`), so sitemap URLs and canonical tags automatically match whatever domain you serve from - set this only if you sit behind infrastructure that doesn't forward `Host` correctly. Password reset links always use it when it is set; without it they go only to an address someone has signed in at, never to whatever host a reset request claims. Editable as **Site URL** in Settings › General. |
201
+ | `seo` | SEO defaults applied when a page has no overrides - see below |
202
+
203
+ ### Outgoing email
204
+
205
+ A new site ships with a complete `smtp` block, so the fields are visible in **Settings → Email** from
206
+ the first boot and the site never starts out with nowhere to send mail.
207
+
208
+ **Why it is seeded rather than left blank.** An empty `smtp.host` is not an error - the CMS falls
209
+ back to a throwaway [Ethereal](https://ethereal.email) test account, which accepts the message and
210
+ reports success. Password resets and form notifications all appear to send, the Health panel's "Last
211
+ email send" goes green, and nothing arrives. That fallback is useful in development and is still
212
+ there, but it is no longer silent.
213
+
214
+ **What a new site gets.**
215
+
216
+ - Created by the **CLI** (`npx domma-cms my-site`): `bin/lib/smtp-defaults.js` reads the environment
217
+ and falls back to a local MTA.
218
+
219
+ | Variable | Default |
220
+ |---------------|-------------------------------------------|
221
+ | `SMTP_HOST` | `localhost` |
222
+ | `SMTP_PORT` | `587` with a host set, `25` without |
223
+ | `SMTP_SECURE` | `false` (`1`/`true`/`yes`/`on` to enable) |
224
+ | `SMTP_USER` | empty |
225
+ | `SMTP_PASS` | empty |
226
+ | `SMTP_FROM` | empty |
227
+
228
+ `fromName` is seeded from the site title, and `npm run setup` renames it along with the site - but
229
+ only while the two still agree, so a From name you chose yourself is never overwritten.
230
+
231
+ - Created by the **fleet manager**: `composeSmtp` in
232
+ `plugins/site-manager/services/siteScaffolder.js` inherits the manager's own relay host, port and
233
+ TLS setting (never its credentials) and defaults `fromAddress` to `noreply@<primary domain>`.
234
+
235
+ **Credentials.** They are read from the environment at scaffold time rather than compiled in, but
236
+ they are written into `config/site.json`, which is where this CMS keeps them. That file is yours and
237
+ the updater never merges it, but it is not a secret store - treat a site directory holding a relay
238
+ password accordingly.
239
+
240
+ **When a site has no host.** Existing sites are not migrated, so they keep whatever they have. If
241
+ the host is empty you get, in order of how likely you are to see it:
242
+
243
+ 1. A warning banner on **Settings → Email**.
244
+ 2. An `SMTP host: not configured` warning in **Health**, alongside - and deliberately separate from -
245
+ the `Last email send` check, which reports green on an Ethereal send because nodemailer really did
246
+ accept the message. The two together are the whole story; neither tells it alone.
247
+ 3. A `console.warn` at startup of the first send.
248
+
249
+ ### Footer
250
+
251
+ | `footer` field | Type | Description |
252
+ |---|---|---|
253
+ | `copyright` | string | Copyright line - above the footer navigation in the one-row layout, at the left of the bottom bar in the columns layout |
254
+ | `layout` | string | `auto` (default), `columns` or `row`. **Columns**: the brand (the navbar's text, logo or icon), the blurb and the social links beside one column per group of the `footer-primary` menu (a top-level item with sub-items; its text is the heading, a link only if it has a URL), over a bottom bar of copyright, the `footer-legal` menu and the motion control. **Row**: everything on one wrapped row, as before. **Auto**: columns when the footer menu has groups, else a row. Site Settings > Footer > Layout. |
255
+ | `blurb` | string | A sentence or two under the brand in the columns layout. Plain text. |
256
+ | `showMotionToggle` | boolean | Whether the footer shows the "Reduce motion" accessibility switch. **Defaults to `true`** - omit the key and the switch appears, so sites that predate this setting are unchanged. Editable from Site Settings > Footer. |
257
+ | `class` | string | Your own CSS classes on the site footer (`#site-footer`). Site Settings > Footer. |
258
+ | `socialClass` | string | Your own CSS classes on every social media icon link in the footer. |
259
+ | `links` | array | Legacy single footer link list. Migrated to `config/menus/` on first boot (see [Multi-menu system](#multi-menu-system)); kept only so the migration can read it. |
260
+
261
+ Setting `showMotionToggle: false` hides the control, not the capability. A visitor who already
262
+ flipped the switch keeps their stored `domma:reduced_motion` preference, and anyone whose browser
263
+ sends `prefers-reduced-motion: reduce` still gets the calmer site - they just lose the ability to
264
+ override it from the footer.
265
+
266
+ ### SEO and indexing
267
+
268
+ The `seo` block on `site.json` provides site-wide defaults. Individual pages can override any of these via the `seo` block in their frontmatter.
269
+
270
+ | `seo` field | Description |
271
+ |---|---|
272
+ | `defaultTitle` | Used in the title fallback chain and as `<title>` for pages without one |
273
+ | `titleSeparator` | Separator between page title and site title (default ` | `) |
274
+ | `defaultDescription` | Falls back into `<meta name="description">` and Open Graph / Twitter description |
275
+ | `defaultImage` | Falls back into `og:image` / `twitter:image` |
276
+ | `robotsExtra` | Extra lines appended to the end of `/robots.txt` (e.g. additional `Disallow` rules) |
277
+
278
+ **Page-level frontmatter overrides:**
279
+
280
+ ```yaml
281
+ ---
282
+ title: My Page
283
+ seo:
284
+ title: Custom <title> override
285
+ description: Custom meta description override
286
+ image: /media/my-page-og.png
287
+ ogType: website # default 'article' for content pages
288
+ noindex: true # emits <meta name="robots" content="noindex, nofollow"> and excludes from sitemap
289
+ ---
290
+ ```
291
+
292
+ **Generated tags on every page:**
293
+
294
+ - `<title>` and `<meta name="description">`
295
+ - `<link rel="canonical">` (requires `baseUrl`)
296
+ - Open Graph: `og:title`, `og:description`, `og:type`, `og:site_name`, `og:url`, `og:image`
297
+ - Twitter Card: `twitter:card`, `twitter:title`, `twitter:description`, `twitter:image`, `twitter:site`
298
+ - JSON-LD `WebPage` schema block
299
+
300
+ **Sitemap and robots.txt:**
301
+
302
+ - `/sitemap.xml` is generated automatically from the published page list. The absolute URLs (`<loc>`) are derived from the incoming request's `Host` and protocol, so the same CMS serves correct URLs whether you visit it at `localhost`, a staging domain, or production - no config required.
303
+ - `/robots.txt` is generated the same way and references the sitemap at the matching origin.
304
+ - When the CMS sits behind a reverse proxy (nginx, Apache, the fleet manager, etc.), set the `TRUST_PROXY` environment variable so Fastify honours `X-Forwarded-Proto`, `X-Forwarded-Host` and `X-Forwarded-For` from the proxy. Use `TRUST_PROXY=loopback` when the proxy is on the same machine (the default for sites the manager creates), or a comma-separated list of the proxy's addresses/CIDRs, or a hop count. Avoid `TRUST_PROXY=true` (or `1`): it trusts every hop, so Fastify takes the left-most `X-Forwarded-For` entry - which the visitor controls - letting anyone spoof their IP address and dodge rate limits and sign-in lockouts.
305
+ - The cache is keyed per-origin, so a multi-domain deployment returns the right URLs for each host without cross-contamination.
306
+ - The sitemap is cached and invalidated whenever a page is created, updated, renamed, or deleted.
307
+ - Indexability rules (which pages enter the sitemap) live in `server/services/sitemap.js` → `shouldIncludeInSitemap()`. The default behaviour follows the `status`, `visibility`, and `seo.noindex` fields above.
308
+
309
+ ---
310
+
311
+ ## `config/theme.json`
312
+
313
+ Everything that decides how the site and the admin look. Editable from **Content > Theme** in the admin.
314
+ Created on first boot by a one-shot migration out of `config/site.json`.
315
+
316
+ ```json
317
+ {
318
+ "version": 1,
319
+ "theme": "silver-dark",
320
+ "adminTheme": "charcoal-dark",
321
+ "baseTheme": null,
322
+ "autoTheme": {
323
+ "enabled": false,
324
+ "dayTheme": "charcoal-light",
325
+ "nightTheme": "charcoal-dark",
326
+ "dayStart": "07:00",
327
+ "nightStart": "19:00"
328
+ },
329
+ "font": {"family": "Nunito", "size": 18},
330
+ "overrides": [
331
+ {
332
+ "id": "ov-1789805000000-a1b2c3",
333
+ "label": "Navbar island",
334
+ "enabled": true,
335
+ "scope": "#site-navbar",
336
+ "theme": "ocean-light",
337
+ "tokens": {"--dm-primary": "#0096c7"},
338
+ "css": ""
339
+ }
340
+ ]
341
+ }
342
+ ```
343
+
344
+ | Field | Description |
345
+ |---|---|
346
+ | `theme` | Public site theme id |
347
+ | `adminTheme` | Admin panel theme id (admin-chrome themes are only offered here) |
348
+ | `baseTheme` | Set by the Theme Roller plugin when a generated theme is active. `theme` then names the custom theme and `baseTheme` the built-in one Domma's engine is given; the picker is disabled while it is set. |
349
+ | `autoTheme` | Day/night pair, switched on each visitor's local time. Ignored when a page pins its own theme, and unavailable with a Theme Roller theme. |
350
+ | `font.family` / `font.size` | Public site body font and root size |
351
+ | `overrides[]` | Per-element theme overrides - see below |
352
+
353
+ ### Override fields
354
+
355
+ | Field | Description |
356
+ |---|---|
357
+ | `id` | Generated; stable across saves |
358
+ | `label` | Name shown in the admin. Emitted as a comment in the generated CSS. |
359
+ | `enabled` | `false` keeps the record but compiles nothing |
360
+ | `scope` | CSS selector for the region. `*`, `:root` and `html` are refused. |
361
+ | `theme` | Optional theme id whose tokens are copied onto the scope |
362
+ | `tokens` | `--dm-*` values applied after the adopted theme, so they win |
363
+ | `css` | Verbatim CSS appended after the generated rule. `@import`, `javascript:` and `expression()` are refused, and `</style>` is neutralised. |
364
+
365
+ The compiled stylesheet is injected into every public page as `<style id="dm-theme-overrides">`, **before**
366
+ `content/custom.css`. See [Theming](theming.md#element-overrides) for the mechanism and its one known limit.
367
+
368
+ ### API
369
+
370
+ | Method | Path | Permission |
371
+ |---|---|---|
372
+ | `GET` | `/api/theme` | `theme.read` |
373
+ | `PUT` | `/api/theme` | `theme.update` |
374
+ | `GET` | `/api/theme/catalog` | `theme.read` - theme ids and `--dm-*` token names the installed stylesheet defines |
375
+ | `POST` | `/api/theme/compile` | `theme.read` - compile a candidate config to CSS without saving |
376
+
377
+ ---
378
+
379
+ ## `config/search.json`
380
+
381
+ The public site's own search: what the overlay says, how it is opened, and where its trigger sits. Editable from
382
+ **System > Search** in the admin. The file is optional - a site without one uses the defaults below - and it is
383
+ written on first boot by the migration that retired the old `site-search` plugin.
384
+
385
+ ```json
386
+ {
387
+ "enabled": true,
388
+ "placeholder": "Search pages...",
389
+ "keyboardShortcut": true,
390
+ "maxResults": 10,
391
+ "minQueryLength": 2,
392
+ "debounceMs": 300,
393
+ "displayMode": "floating",
394
+ "position": "right-center",
395
+ "offsetX": 32,
396
+ "offsetY": 32,
397
+ "iconOnly": true,
398
+ "iconSize": 52,
399
+ "iconRadius": 26
400
+ }
401
+ ```
402
+
403
+ | Field | Type | Default | Purpose |
404
+ |---|---|---|---|
405
+ | `enabled` | boolean | `true` | Off adds no trigger, stylesheet or script to public pages at all |
406
+ | `placeholder` | string | `Search pages...` | Placeholder text inside the search input |
407
+ | `keyboardShortcut` | boolean | `true` | `Ctrl+K` / `⌘K` opens and closes the overlay |
408
+ | `maxResults` | number | `10` | Results returned per query (1-50) |
409
+ | `minQueryLength` | number | `2` | Characters required before a query runs (1-5) |
410
+ | `debounceMs` | number | `300` | Pause after typing stops before the query fires (100-1000) |
411
+ | `displayMode` | `navbar` \| `floating` | `floating` | Search icon in the navbar, or a fixed button on every page |
412
+ | `position` | `top-left` \| `top-right` \| `left-center` \| `right-center` \| `bottom-left` \| `bottom-right` | `right-center` | Floating button placement (ignored in navbar mode) |
413
+ | `offsetX` / `offsetY` | number | `32` | Floating button distance from the screen edge, in pixels (0-500) |
414
+ | `iconOnly` | boolean | `true` | Off puts a "Search" label beside the floating icon |
415
+ | `iconSize` | number | `52` | Width and height of the floating icon button, in pixels (32-96). The search glyph scales with it, keeping the 20/52 proportion the button shipped with. Icon mode only |
416
+ | `iconRadius` | number | `26` | Corner radius of the floating icon button, in pixels. `0` is a square; **half of `iconSize` is a circle**, and that is the ceiling - a larger value is clamped on save, since at half its width a square is already round. Applies in icon mode only: the navbar trigger has no surface to round, and the labelled variant is a pill |
417
+
418
+ Unknown keys are dropped on save and out-of-range numbers are clamped: the whole object is embedded in the source of
419
+ every public page as `window.__CMS_SEARCH__`, so nothing private belongs in it.
420
+
421
+ ### API
422
+
423
+ | Method | Endpoint | Auth |
424
+ |---|---|---|
425
+ | `GET` | `/api/search?q=…` | Public - scored results across published pages |
426
+ | `GET` | `/api/search/settings` | Public - the same object the page already carries |
427
+ | `PUT` | `/api/search/settings` | Admin |
428
+
429
+ ### What gets searched
430
+
431
+ `listPages()` on every query - there is no index, so cost is linear in the number of pages. Drafts and pages with
432
+ `visibility: private` are skipped. Scoring is weighted by field (title, then tags, then description, then body); the
433
+ body is reduced to plain prose first (`stripMarkdown` in `server/services/search.js`), which drops fenced code,
434
+ `<pre>`/`<code>`, shortcode tags and `[dconfig]` while keeping readable shortcode attributes such as
435
+ `[card title="…"]`.
436
+
437
+ ---
438
+
439
+ ## `config/menus/<slug>.json`
440
+
441
+ **Site menus** - the menus built in the admin (**Content → Site menus**) - each live in their own file under `config/menus/`. (They are not the **page contents**: the list a layout with a sidebar builds from a page's own headings, set up in Layouts and with `[contents]`.) The filename (minus extension) is the menu slug. A menu is a tree of items with per-menu style/behaviour overrides - sites can have any number of menus and map them to named slots (see `config/menu-locations.json` below).
442
+
443
+ ```json
444
+ {
445
+ "slug": "main",
446
+ "name": "Main navigation",
447
+ "description": "Top-level site nav",
448
+ "variant": "transparent",
449
+ "position": "floating",
450
+ "style": {
451
+ "fontFamily": "Inter",
452
+ "fontSize": "14px",
453
+ "fontWeight": "500",
454
+ "letterSpacing": "0.01em",
455
+ "iconSize": "15px"
456
+ },
457
+ "items": [
458
+ {
459
+ "text": "Home",
460
+ "url": "/",
461
+ "icon": "home",
462
+ "hidden": false,
463
+ "bundled": false,
464
+ "visibility": "public",
465
+ "items": []
466
+ },
467
+ {
468
+ "text": "Resources",
469
+ "url": "/resources",
470
+ "icon": "book-open",
471
+ "items": [
472
+ {
473
+ "text": "Guides",
474
+ "url": "/resources/guides",
475
+ "items": [
476
+ { "text": "Beginner", "url": "/resources/guides/beginner" },
477
+ { "text": "Advanced", "url": "/resources/guides/advanced" }
478
+ ]
479
+ }
480
+ ]
481
+ }
482
+ ],
483
+ "meta": {
484
+ "createdAt": "2026-05-24T10:00:00Z",
485
+ "updatedAt": "2026-05-24T10:00:00Z",
486
+ "bundled": false,
487
+ "presetOwner": null
488
+ }
489
+ }
490
+ ```
491
+
492
+ ### Menu fields
493
+
494
+ | Field | Required | Type | Description |
495
+ |---------------|----------|--------|--------------------------------------------------------------------------------------------|
496
+ | `slug` | yes | string | URL-safe identifier; matches filename |
497
+ | `name` | yes | string | Human-readable label shown in admin |
498
+ | `description` | no | string | Free text |
499
+ | `variant` | no | string | Domma navbar variant - only meaningful in the navbar location |
500
+ | `position` | no | string | `sticky | fixed | static | floating` - honoured by the navbar, the admin sidebar, and `[menu]` |
501
+ | `orientation` | no | string | `horizontal` (default) or `vertical` - see **Orientation** below |
502
+ | `align` | no | string | `left | centre | right | spread` - where items sit along a horizontal menu; see **Alignment** below |
503
+ | `side` | no | string | `left` (default) or `right` - which edge a vertical navbar docks to |
504
+ | `float` | no | object | Pinning + panel surface for `position: floating` - see **Floating menus** below |
505
+ | `highlight` | no | object | Hover + current-page item effects - see **Item highlighting** below |
506
+ | `entrance` | no | object | Staggered entrance of the items when the menu is drawn - see **Staggered entrance** below |
507
+ | `class` | no | string | Your own CSS classes on the menu's outer element - see **Designer classes** below |
508
+ | `style` | no | object | Font/spacing overrides; applied as CSS custom properties on the menu root |
509
+ | `binding` | no | object | Slot takeover for one project or a set of pages - see **Menu bindings** below |
510
+ | `items` | yes | array | Items tree (depth-unbounded) |
511
+ | `meta` | no | object | Audit / lifecycle metadata (`createdAt`, `updatedAt`, `bundled`, `presetOwner`) |
512
+
513
+ ### Item fields
514
+
515
+ | Field | Required | Type | Description |
516
+ |--------------|----------|--------------------|-----------------------------------------------------------------------------------|
517
+ | `text` | yes | string | Label |
518
+ | `url` | yes | string | href |
519
+ | `icon` | no | string | Domma icon name |
520
+ | `hidden` | no | boolean | Hard off-switch - never rendered |
521
+ | `bundled` | no | boolean | Preset/plugin-seeded marker |
522
+ | `visibility` | no | string \| string[] | `public | private | <role> | =<role> | [<role>, ...]` (page-style; `=<role>` = that role only). Defaults to `public`. |
523
+ | `permission` | no | string | Resource key from `permissionRegistry` (e.g. `pages`, `menus`, `projects`). Renderer hides items the user can't act on. Missing = always visible (subject to `visibility`). |
524
+ | `highlight` | no | object | Per-item override of the menu's `highlight` - same `{hover, active, accent}` shape. See **Item highlighting** below |
525
+ | `class` | no | string | Your own CSS classes on the item's `<li>` - see **Designer classes** below |
526
+ | `items` | no | array | Nested children |
527
+
528
+ `visibility` is checked at render time by `checkVisibility(userRole, visibility)` from `server/middleware/auth.js` - items the requesting user can't see are removed from the output server-side.
529
+
530
+ #### Structural items
531
+
532
+ Three item types carry no `text` and no `url` - they shape the list rather than link anywhere.
533
+ All are positional like any other item, so they move, indent and drag the same way, and none can
534
+ take children.
535
+
536
+ | `type` | Renders as | Extra fields |
537
+ |------------------|----------------------------------------------|--------------|
538
+ | `separator` | A dividing line | - |
539
+ | `spacer` | Blank room, no line | `size` |
540
+ | `theme-switcher` | The site's theme dots, among the links | - |
541
+
542
+ `size` is `xs | sm | md | lg | xl` (default `md`), mapped to the theme's `--dm-space-*` tokens so a
543
+ spacer scales with the rest of the UI - or `flex`, which claims all the leftover room instead of a
544
+ fixed amount:
545
+
546
+ ```json
547
+ "items": [
548
+ { "text": "Home", "url": "/" },
549
+ { "text": "Docs", "url": "/docs" },
550
+ { "type": "spacer", "size": "flex" },
551
+ { "text": "Log in", "url": "/login" }
552
+ ]
553
+ ```
554
+
555
+ A `flex` spacer is how you push what follows it to the far end: the right of a horizontal navbar,
556
+ the foot of a vertical rail or the admin sidebar. It needs a flex container to claim room from, so
557
+ the renderers stamp `dm-menu--has-flex-spacer` (or `dm-admin-sidebar--has-flex-spacer`) on a menu
558
+ that actually contains one, rather than making every menu list flex.
559
+
560
+ The axis follows the menu: in a vertical list a spacer is height, in a horizontal one it is width.
561
+ Spacers are `aria-hidden`, so they never read as empty items.
562
+
563
+ Two rendering notes:
564
+
565
+ - **Public navbar** - Domma has no spacer or top-level divider concept, so both are removed before
566
+ the navbar is built. Spacers are then re-inserted into the rendered `<ul>`; top-level separators
567
+ stay dropped, since a dividing line across a horizontal bar has nothing to divide.
568
+ - **Floating panels** - a panel sized to its content has no leftover room, so a `flex` spacer there
569
+ falls back to a fixed minimum gap.
570
+
571
+ A `theme-switcher` item shows the themes chosen in `config/theme.json`'s `switcher.themes`
572
+ (all of them when the list is empty), whether or not the floating switcher (`switcher.enabled`)
573
+ is on - so a site can turn the floating disc off and keep the switcher in its menu. The disc is
574
+ drawn inline and its panel drops down from it. In the public navbar it works at the top level
575
+ only; one inside a dropdown is left out, as a dropdown has no room for the panel. The Menus editor
576
+ adds one with **Add theme switcher** (not offered for the admin sidebar, where it means nothing).
577
+
578
+ ### Docked menus
579
+
580
+ The menu in the navbar slot, when it is docked (any position but `floating`), takes a `dock` block:
581
+
582
+ ```json
583
+ "dock": { "width": 280, "collapsible": true, "collapsed": false, "autohide": false, "hideOnScroll": false }
584
+ ```
585
+
586
+ | Key | For | Does |
587
+ |-----|-----|------|
588
+ | `width` | vertical rail | Its width in px, 160-480 (240 when unset). The page's gutter follows it. |
589
+ | `collapsible` | vertical rail | A toggle shrinks the rail to its icons (labels become tooltips; an item with no icon shows its initial; sub-menus fly out beside it). Each visitor's choice is remembered in their browser. |
590
+ | `collapsed` | vertical rail | Starts collapsed until the visitor chooses (implies `collapsible`). |
591
+ | `autohide` | vertical rail | The rail slides off its edge, leaving a 10px strip; it comes back on hover, on a tap, or when focus moves into it. The page takes the full width. Wins over `collapsible`. |
592
+ | `hideOnScroll` | horizontal bar | The bar slides away while scrolling down and returns on the way up - never while a dropdown is open or focus is inside it. Best with `position: sticky`. |
593
+
594
+ Width and autohide are drawn from the first paint (body classes and a `--dm-vnav-width` token); a
595
+ visitor's collapsed or hidden choice is applied by a small script straight after `<body>`, so the
596
+ gutter never jumps. Visitors can right-click the rail for **Collapse to icons / Expand menu**,
597
+ **Hide when not in use / Keep on screen** and **Reset to the site's setting**. All of it is desktop only
598
+ (993px and up) - below that the menu is the usual drawer. Set it in **Menus → (menu) → Behaviour**.
599
+
600
+ A page can change any of this - and any other Behaviour setting - for itself with the `[navbar]` tag
601
+ (see `docs/markdown-shortcodes.md`).
602
+
603
+ ### Floating menus
604
+
605
+ `position: "floating"` detaches a menu from the document flow and pins it to a viewport corner or
606
+ edge - the same shape as `site.json`'s `breadcrumbs` block. The `float` object tunes the placement:
607
+
608
+ | Field | Required | Type | Default | Description |
609
+ |-----------|----------|--------|---------|-----------------------------------------------------------------------------|
610
+ | `anchor` | no | string | `TL` | `TL | TR | BL | BR | L | R`. `L`/`R` pin to the middle of that edge |
611
+ | `offsetX` | no | number | `16` | Distance in px from the anchored left/right edge (clamped 0-4000) |
612
+ | `offsetY` | no | number | `16` | Distance in px from the anchored top/bottom edge; ignored for `L`/`R` |
613
+ | `width` | no | number | - | Panel width in px (clamped 120-600). Omit for content width |
614
+ | `zIndex` | no | number | `150` | Stacking order against the rest of the page |
615
+ | `radius` | no | number | theme | Corner rounding in px (clamped 0-48). Omit to inherit the theme radius |
616
+ | `shadow` | no | string | theme | `none | sm | md | lg | xl` - maps to the `--dm-shadow-*` tokens |
617
+ | `accent` | no | string | - | Preset colour key or `#rrggbb`. Tints the border and draws a stripe down the panel's leading edge |
618
+ | `opacity` | no | number | `100` | Panel translucency in percent (clamped 20-100). Below 100 the surface is tinted and blurred; text stays opaque |
619
+ | `title` | no | string | menu name | Text shown in the panel's bar, next to the drag handle and hamburger |
620
+ | `collapsible` | no | bool | `false` | Adds a hamburger that folds the panel down to just that button |
621
+ | `collapsed` | no | bool | `false` | Start collapsed. Needs `collapsible`; a visitor's own last state wins over it |
622
+ | `movable` | no | bool | `false` | Visitors drag the panel by its bar |
623
+ | `resizable` | no | bool | `false` | Visitors drag the panel's bottom-right corner to resize it |
624
+
625
+ ```json
626
+ {
627
+ "slug": "quick-links",
628
+ "name": "Quick Links",
629
+ "position": "floating",
630
+ "float": {
631
+ "anchor": "BR", "offsetX": 24, "offsetY": 40, "width": 240,
632
+ "radius": 14, "shadow": "xl", "accent": "primary", "opacity": 92
633
+ },
634
+ "items": [{ "text": "Contact", "url": "/contact" }]
635
+ }
636
+ ```
637
+
638
+ The surface fields resolve to theme tokens rather than literal colours, so a floating panel still
639
+ tracks the active theme. Omitting one leaves the `.dm-menu-floating` default in `site.css` in
640
+ charge rather than freezing today's value into the file.
641
+
642
+ Applies in three places:
643
+
644
+ - **Public navbar** - the menu mapped to the `navbar` slot floats instead of docking.
645
+ - **`[menu]` shortcode** - floats wherever the shortcode is placed. Pass `float="no"` on an
646
+ individual shortcode to render that one inline while the menu still floats in its slot.
647
+ - **Admin sidebar** - the `admin-sidebar` menu pins itself and the shell opens a matching gutter,
648
+ so the view never sits underneath it.
649
+
650
+ A floating menu is capped to the viewport height and scrolls internally rather than overflowing.
651
+ Below 768px floating is dropped and the menu returns to the flow, so it can't cover the content it
652
+ belongs to on a phone.
653
+
654
+ #### Collapse, move and resize
655
+
656
+ `collapsible`, `movable` and `resizable` turn a pinned panel into something the visitor can handle.
657
+ Any of them makes the server draw a bar across the top of the panel - a drag grip, the `title` (or
658
+ the menu's name), and the hamburger - and `public/js/menu-float.js` wires the behaviour.
659
+
660
+ ```json
661
+ {
662
+ "slug": "quick-links",
663
+ "name": "Quick Links",
664
+ "position": "floating",
665
+ "float": {
666
+ "anchor": "BR", "offsetX": 24, "offsetY": 24,
667
+ "collapsible": true, "collapsed": true, "movable": true, "resizable": true
668
+ },
669
+ "items": [{ "text": "Contact", "url": "/contact" }]
670
+ }
671
+ ```
672
+
673
+ Collapsed, the panel is nothing but a round hamburger button sitting on its anchor; clicking it
674
+ brings the menu back.
675
+
676
+ **The config is only ever a starting point.** Where a visitor drags a panel to, what size they
677
+ leave it, and whether they collapsed it are stored in *their* browser under
678
+ `dm_menu_float_<slug>`, and they win over `float` on the next page load. Double-clicking the bar
679
+ throws that away and puts the panel back where you configured it. Nothing about a visitor's own
680
+ layout reaches the server or any other visitor.
681
+
682
+ Two consequences worth knowing:
683
+
684
+ - `collapsed` is a **default**, not a state. Once a visitor has used the toggle, their choice
685
+ sticks and this field stops applying to them.
686
+ - A panel only stops tracking its `anchor` once the visitor actually moves or resizes it. Until
687
+ then it keeps re-pinning to the corner as the viewport changes size. Once freed, it is clamped
688
+ to stay on screen when the window shrinks.
689
+
690
+ Below 768px floating is dropped, so moving and resizing do nothing there - only collapsing still
691
+ applies, which is exactly the hamburger behaviour a phone wants anyway.
692
+
693
+ ### Orientation
694
+
695
+ `orientation` decides how a menu flows wherever it renders. `horizontal` is the default and the
696
+ historic behaviour; `vertical` stacks the items into a column.
697
+
698
+ | Where | `horizontal` | `vertical` |
699
+ |----------------------|------------------------------------------------|------------------------------------------------------------------------|
700
+ | Public navbar | The usual bar across the top | A fixed side rail (`side: left | right`); the page body gains a matching gutter |
701
+ | `[menu]` shortcode | Top-level items in a row, submenus drop on hover| Indented stack with hover highlights |
702
+ | Floating menu | Row inside the pinned panel | Stack inside the pinned panel |
703
+ | Admin sidebar | n/a - always vertical | n/a |
704
+
705
+ The vertical navbar only applies from 993px up; below that the existing hamburger drawer is already
706
+ a vertical list, so nothing changes there. A menu that sets no `orientation` at all emits no
707
+ orientation class from the `[menu]` shortcode and keeps its plain nested-list rendering, so existing
708
+ pages look exactly as they did.
709
+
710
+ Individual placements can override the menu: `[menu slug="main" orientation="horizontal" /]`.
711
+
712
+ ### Alignment
713
+
714
+ `align` decides where the items sit along a **horizontal** menu. A vertical rail and the mobile
715
+ drawer ignore it - there is nothing to align along.
716
+
717
+ | Value | Effect |
718
+ |-----------|-------------------------------------------------------------|
719
+ | `left` | Items pack against the start of the bar, after the brand |
720
+ | `centre` | Items sit on the centre line of the bar itself |
721
+ | `right` | Items pack against the end of the bar |
722
+ | `spread` | Items spread evenly across the available width |
723
+
724
+ **The default differs by render path, deliberately.**
725
+
726
+ - The **public navbar** centres when `align` is unset. Domma already intends this - its stylesheet
727
+ says `justify-content: center` - but it centres the list inside the space *left over* once the
728
+ brand and the actions block have taken theirs, so a wide brand shunts the items right by half its
729
+ width and a search icon pulls them back left by half of its own. They are only ever truly centred
730
+ when those two happen to be the same width. Setting `align` (or leaving it unset, which means
731
+ centre) switches the bar to a three-track grid whose outer tracks are equal, so the items land on
732
+ the bar's real centre line whatever flanks them.
733
+ - A **`[menu]` shortcode** in the page body emits no alignment class when `align` is unset and keeps
734
+ packing left, because silently re-flowing body content on upgrade is not wanted.
735
+
736
+ Where the brand and the items together need more room than the bar has, the outer tracks stop
737
+ shrinking rather than squeezing the brand into a wrap - the layout gives up exact centring before it
738
+ gives up legibility.
739
+
740
+ ### Staggered entrance
741
+
742
+ `entrance` brings a menu's items in one after another when the menu is drawn, the way `[row reveal]`
743
+ brings in a row of cards. Set it on the menu's **Behaviour** tab; it applies on every public path -
744
+ navbar, footer, `[menu]` and floating overlays (not the admin sidebar).
745
+
746
+ ```json
747
+ "entrance": {"enabled": true, "animation": "slide-up", "stagger": 60, "duration": 400}
748
+ ```
749
+
750
+ | Key | Values |
751
+ |-------------|--------------------------------------------------------------------------------------------|
752
+ | `enabled` | `true` plays it. `false` keeps the choices below without playing them |
753
+ | `animation` | `fade` (default), `slide-up`, `slide-down`, `slide-left`, `slide-right`, `zoom`, `pop`, `flip`, `blur` |
754
+ | `stagger` | ms between one item and the next, 0-1000 (default 60) |
755
+ | `duration` | ms each item's animation takes, 50-3000 (default 400) |
756
+
757
+ Items are numbered per list, so a dropdown or footer column staggers in on its own when it opens;
758
+ items past the twentieth share its delay. Visitors who asked for less motion (the OS setting or the
759
+ footer's motion switch) see the items at once.
760
+
761
+ ### Item highlighting
762
+
763
+ `highlight` decides what a menu item does under the pointer and how the item for the page you are
764
+ on is marked out. Both halves are optional: a menu with no `highlight` block keeps the theme's own
765
+ look, exactly as before.
766
+
767
+ ```json
768
+ {
769
+ "slug": "main",
770
+ "name": "Main navigation",
771
+ "highlight": {
772
+ "hover": "underline",
773
+ "active": "pill",
774
+ "accent": "#ff8800"
775
+ },
776
+ "items": []
777
+ }
778
+ ```
779
+
780
+ | Field | Type | Description |
781
+ |----------|--------|--------------------------------------------------------------------------|
782
+ | `hover` | string | What an item does under the pointer - see the table below |
783
+ | `active` | string | How the item for the current page is marked |
784
+ | `accent` | string | Preset key (`primary`, `success`, `danger`, `warning`, `info`, `neutral`) or `#rrggbb`. Defaults to the theme primary |
785
+
786
+ **`hover`**
787
+
788
+ | Value | Effect |
789
+ |--------------|---------------------------------------------------------------------------|
790
+ | *(unset)* | The theme's own hover - colour shift plus a tinted background |
791
+ | `none` | Nothing at all. Rest and hover are identical |
792
+ | `colour` | Text takes the accent colour |
793
+ | `underline` | Accent-coloured underline appears |
794
+ | `overline` | Accent-coloured line above the text |
795
+ | `background` | Accent-tinted chip behind the item |
796
+ | `darken` | Darkened chip, lighter than the selected one - works on a light and a dark bar alike |
797
+ | `uppercase` | Text switches to uppercase. The item gets wider as it does, so neighbours shift slightly |
798
+ | `grow` | Item scales up |
799
+ | `breathe` | Item pulses slowly for as long as the pointer rests on it |
800
+ | `glow` | Accent-coloured halo around the text |
801
+
802
+ **`active`**
803
+
804
+ | Value | Effect |
805
+ |--------------|---------------------------------------------------------------------------|
806
+ | *(unset)* | The theme's own current-page marker - a primary underline plus a weight bump |
807
+ | `none` | The current page is not marked at all |
808
+ | `bold` | Bold text |
809
+ | `underline` | Accent-coloured underline |
810
+ | `overline` | Accent-coloured line above the text |
811
+ | `background` | Accent-tinted chip |
812
+ | `darken` | Darkened chip - works on a light and a dark bar alike |
813
+ | `pill` | Filled accent pill with white text |
814
+
815
+ The effects apply wherever the menu is drawn: the public navbar, the footer columns, a `[menu]`
816
+ shortcode, an overlay panel, and the admin sidebar. `grow` and `breathe` are motion, so they are
817
+ dropped for visitors whose browser asks for reduced motion; the rest still apply.
818
+
819
+ **Marking the current page.** "The item for the page you are on" means an exact URL match, ignoring
820
+ a query string, a fragment and a trailing slash. A parent item is not marked because you are
821
+ somewhere beneath it, and an item pointing at `/` is only marked on the home page. Marked items
822
+ carry `aria-current="page"`, so the current page is announced whether or not a visual effect is set.
823
+
824
+ **Deprecated spelling.** An earlier cut of this feature put the two effects on the `style` block as
825
+ `style.hover` / `style.active`, with `transform` for uppercase and `background-color` for the tint.
826
+ Those are still read and translated, so a menu saved that way keeps working, but `style` is
827
+ otherwise a block of CSS declarations written straight into a `<style>` tag - behaviour keys do not
828
+ belong in it. Use `highlight`; it wins per half when both are present.
829
+
830
+ **Precedence.** A highlight sets the colour for the states it controls, so on a menu whose items
831
+ carry their own `colour` overrides the highlight wins for those states and the item colour owns
832
+ every other one. Pick `hover: none` and `active: none` to hand every state back to the item colours.
833
+
834
+ **Per-item overrides.** Any single item can carry its own `highlight` block, in the same shape and
835
+ with the same values. Each half is resolved independently: an item that sets only `hover` keeps the
836
+ menu's `active`, and vice versa.
837
+
838
+ ```json
839
+ {
840
+ "slug": "main",
841
+ "highlight": { "hover": "colour", "active": "underline", "accent": "primary" },
842
+ "items": [
843
+ { "text": "Home", "url": "/" },
844
+ { "text": "Contact", "url": "/contact",
845
+ "highlight": { "hover": "glow", "active": "pill", "accent": "#ff8800" } }
846
+ ]
847
+ }
848
+ ```
849
+
850
+ Here every item gets a colour shift on hover and an underline when it is the current page, except
851
+ Contact, which glows in orange and becomes an orange pill instead.
852
+
853
+ An item override **replaces** the menu's effect for that half rather than adding to it: in the
854
+ example above, hovering Contact glows and does *not* also take the menu's hover colour. Leaving a
855
+ half blank in the admin means "inherit"; choosing `none` means "switch this off for this item while
856
+ the rest of the menu keeps it". They are different things.
857
+
858
+ The accent is resolved per item too, so one item can highlight in a different colour from the menu
859
+ without any other item changing.
860
+
861
+ ### Designer classes
862
+
863
+ `class` takes your own class names, separated by spaces, so a designer can style a menu or a single
864
+ item from Custom CSS without the editor offering a setting for it. Set it under **Behaviour > CSS
865
+ classes** for the whole menu, or **Styling > CSS classes** in an item's editor.
866
+
867
+ - A menu's classes go on its outer element: the navbar (`#site-navbar`), the footer column's
868
+ `<nav class="footer-nav">`, or the `[menu]` shortcode's `<nav class="dm-menu">` (before any
869
+ `class=` the shortcode itself passes).
870
+ - An item's classes go on its `<li>` on every render path, so they can lay the item out as well as
871
+ style it. Target the link with `.your-class > a`.
872
+
873
+ ```json
874
+ { "slug": "main", "class": "site-nav",
875
+ "items": [ { "text": "Get a quote", "url": "/quote", "class": "nav-cta push-right" } ] }
876
+ ```
877
+
878
+ ```css
879
+ .site-nav .nav-cta > a { border: 1px solid currentColor; border-radius: 999px; }
880
+ .site-nav .push-right { margin-left: auto; }
881
+ ```
882
+
883
+ Names are letters, digits, hyphens and underscores, not starting with a digit; a leading dot is
884
+ dropped, and at most 20 are kept. Anything else is refused on save. Navbar links carry Domma's own
885
+ high-specificity hover rules, so a navbar hover style may need `#site-navbar` in its selector.
886
+
887
+ ### Menu bindings
888
+
889
+ A binding lets one menu **take a slot over for part of the site** rather than being mapped to it
890
+ everywhere. It is the answer to "the shop section needs its own navbar" without touching
891
+ `menu-locations.json`.
892
+
893
+ ```json
894
+ {
895
+ "slug": "shop-nav",
896
+ "name": "Shop navigation",
897
+ "binding": {
898
+ "slot": "navbar",
899
+ "projects": ["shop"],
900
+ "pages": ["/pricing", "/docs/*"]
901
+ },
902
+ "items": [{ "text": "Catalogue", "url": "/shop" }]
903
+ }
904
+ ```
905
+
906
+ | Field | Required | Type | Description |
907
+ |------------|----------|----------|-----------------------------------------------------------------------------------|
908
+ | `slot` | yes | string | The slot this menu claims (`navbar`, `footer-primary`, … - any registered slot) |
909
+ | `projects` | no | string[] | Project slugs; applies on every page resolving to one of them |
910
+ | `pages` | no | string[] | URL paths. A trailing `/*` matches that path and everything under it; `/*` matches the whole site |
911
+
912
+ Resolution order for a slot on a given page, most specific first:
913
+
914
+ 1. A menu bound to that **exact page URL**
915
+ 2. A menu bound to a **page wildcard** that matches (longest pattern wins)
916
+ 3. A menu bound to the **project** the page resolves to (see **Page URL inheritance** below)
917
+ 4. The menu mapped to the slot in `config/menu-locations.json`
918
+
919
+ Ties break on the longest pattern and then the slug, so resolution never depends on directory order.
920
+ A binding with a slot but no projects or pages never matches; the admin's Binding tab says so
921
+ explicitly. Bindings are not copied when a menu is duplicated - two menus claiming the same slot on
922
+ the same pages would be a conflict nobody asked for.
923
+
924
+ Bindings are honoured everywhere a slot is resolved with page context: the public navbar and footer
925
+ columns, the overlay slot, and `[menu location="…" /]` inside a page body. `[menu slug="…" /]` names
926
+ a menu directly and is unaffected. Rendering the whole set is cheap - menu files are read once and
927
+ cached in process, invalidated on every write.
928
+
929
+ ### The overlay slot
930
+
931
+ `navbar`, `footer-primary` and `footer-legal` are fixed regions: one menu wins each. The `overlay`
932
+ slot is the "put a menu anywhere" placement. A menu bound to it renders on matching pages positioned
933
+ by its own `float` config - no navbar, no footer, and no shortcode in the page body.
934
+
935
+ ```json
936
+ {
937
+ "slug": "docs-rail",
938
+ "name": "Docs rail",
939
+ "position": "floating",
940
+ "orientation": "vertical",
941
+ "float": { "anchor": "L", "offsetX": 16, "radius": 8, "shadow": "md", "accent": "primary" },
942
+ "binding": { "slot": "overlay", "pages": ["/docs/*"] },
943
+ "items": [{ "text": "Getting started", "url": "/docs/start" }]
944
+ }
945
+ ```
946
+
947
+ That pins a vertical rail to the left edge on every page under `/docs`, and nowhere else.
948
+
949
+ It differs from the other slots in three ways:
950
+
951
+ - **Every match renders**, not just the most specific one - a page can carry a left rail and a
952
+ corner widget at once. (Precedence still applies *within* the other slots.)
953
+ - **Mapping it in `menu-locations.json` makes it site-wide.** A menu mapped to `overlay` appears on
954
+ every page without needing page rules; a menu that is both mapped and bound appears once.
955
+ - **An overlay with no visible items is dropped**, so a fully role-gated overlay leaves no empty box
956
+ floating over the page.
957
+
958
+ Overlays are injected at the end of `<body>`, so a menu with `position: floating` pins itself
959
+ anywhere on the viewport, while one left `static` simply renders after the page content. They are
960
+ drawn by the same builder as the `[menu]` shortcode - the same menu looks identical either way - and
961
+ items are filtered per visitor exactly as elsewhere.
962
+
963
+ ### Admin sidebar slot
964
+
965
+ `admin-sidebar` is a built-in slot registered by core alongside `navbar`, `footer-primary`, and `footer-legal`. It drives the admin SPA's left sidebar - rendered from menu data rather than hardcoded config. The mapped menu is filtered per-request by each item's optional `permission` field, so users only see entries they can act on.
966
+
967
+ `config/menus/admin-sidebar.json` is **auto-seeded** on first boot with the standard tree (Overview, Projects, Content, Data, System, Documentation). Seeding is idempotent - guarded by file existence - so subsequent boots leave admin edits intact. Delete the file to re-seed on the next boot.
968
+
969
+ ---
970
+
971
+ ## `config/menu-locations.json`
972
+
973
+ Maps registered slot names to menu slugs. Built-in slots are `navbar`, `footer-primary`, and `footer-legal`; plugins register additional slots via `hooks.registerMenuLocation()` (see `docs/plugin-development.md`).
974
+
975
+ ```json
976
+ {
977
+ "navbar": "main",
978
+ "footer-primary": "footer",
979
+ "footer-legal": "legal"
980
+ }
981
+ ```
982
+
983
+ Keys are slot names (must exist in the registry). Values are menu slugs (must resolve to a file in `config/menus/`). Unmapped slots render empty. Mappings to non-existent menus log a warning at request time and render empty.
984
+
985
+ ### Built-in slot registry
986
+
987
+ | Slot | Where it renders | Default `maxDepth` |
988
+ |------------------|-------------------------------------------------------|--------------------|
989
+ | `navbar` | `server/templates/page.html` navbar | unbounded |
990
+ | `footer-primary` | Primary footer column rendered by `public/js/site.js` | 1 |
991
+ | `footer-legal` | Legal/secondary footer column | 1 |
992
+ | `admin-sidebar` | Admin SPA left sidebar (`admin/js/lib/sidebar-renderer.js`) | unbounded |
993
+ | `overlay` | Anywhere on the page - see **The overlay slot** below | unbounded |
994
+
995
+ `maxDepth` is advisory - the admin Locations UI surfaces a depth-overflow warning when the mapped menu exceeds the cap, but rendering still attempts to flatten beyond it.
996
+
997
+ ### Backwards compatibility
998
+
999
+ The CMS auto-migrates from the legacy `config/navigation.json` (single navbar) and `site.json.footer.links` (single footer link list) to the multi-menu system on first boot. The originals are renamed to `.bak`; a one-release read-fallback restores the navbar from `navigation.json.bak` if the navbar slot is unmapped. The migration also populates a new top-level `brand` field on `site.json` (parallel to `adminBrand`) from the legacy `navigation.json.brand` - the public renderer reads `site.json.brand` when composing the navbar.
1000
+
1001
+ ---
1002
+
1003
+ ## Projects
1004
+
1005
+ A **Project** is a named grouping of related artefacts (Pages, Collections, Forms, Actions, Menus, Blocks, Views, Roles, Users). It produces a sidebar section, an access-scope boundary for users, and a tag that the scaffolder stamps on recipe-produced artefacts.
1006
+
1007
+ Projects are stored as a **preset collection** at `content/collections/projects/` - the same pattern as `user-types` and `roles`. CRUD UI, validation, and permission integration are inherited from the collection machinery.
1008
+
1009
+ ### The core project
1010
+
1011
+ Every site has a built-in **core** project representing the core site itself. It is seeded at boot (create-if-absent, so existing sites gain it on their first start after updating) and owns **every artefact not claimed by another project** - by resolution fallback, not by tagging. Nothing is ever stamped `meta.project: 'core'`; "no tag" *is* core membership.
1012
+
1013
+ - `slug: 'core'`, seeded as `name: 'Core'`, `icon: 'home'`, `rootUrl: '/'`, `sortOrder: -1`, `protected: true`.
1014
+ - **Not removable.** `DELETE /api/projects/core` and `POST /api/projects/core/untag-all` return 403; the admin UI hides the delete button and danger zone.
1015
+ - **`rootUrl` and `protected` are locked**; name, description, icon and sortOrder remain editable.
1016
+ - **Never an access boundary.** Core artefacts are visible to all authenticated users regardless of their `projects: []` scope - scoped users always keep the core site's pages, menus, and blocks.
1017
+ - If a site already had a user-created project with the slug `core`, boot seeding adopts it (sets `protected: true`, leaves every other field untouched).
1018
+
1019
+ ### Project record
1020
+
1021
+ ```json
1022
+ {
1023
+ "slug": "members",
1024
+ "name": "Members Area",
1025
+ "description": "Curated members-only zone with profile, billing, and content.",
1026
+ "icon": "users",
1027
+ "rootUrl": "/members",
1028
+ "sortOrder": 10,
1029
+ "createdAt": "2026-05-25T10:00:00Z",
1030
+ "updatedAt": "2026-05-25T10:00:00Z"
1031
+ }
1032
+ ```
1033
+
1034
+ | Field | Required | Type | Description |
1035
+ |---------------|----------|--------|------------------------------------------------------------------------------------------------------------|
1036
+ | `slug` | yes | string | URL-safe identifier; matches `^[a-z0-9][a-z0-9-]*$`; immutable after create; used as the `meta.project` tag |
1037
+ | `name` | yes | string | Human-readable; sidebar label |
1038
+ | `description` | no | string | Free text |
1039
+ | `icon` | no | string | Sidebar icon (Domma icon name); defaults to `folder` |
1040
+ | `rootUrl` | no | string | URL prefix (must start with `/`; exactly `/` is reserved for the core project); enables page auto-inheritance |
1041
+ | `sortOrder` | no | number | Sidebar order; default = creation order |
1042
+ | `protected` | system | bool | Built-in marker (core project only) - locks `rootUrl`, blocks delete/untag-all; not user-settable |
1043
+
1044
+ ### `meta.project` on artefacts
1045
+
1046
+ Nine artefact types accept an optional `meta.project: '<slug>'` field. It declares "this artefact lives in this Project" for sidebar grouping and permission scoping. Artefacts without the field belong to the **core** project (and continue to appear under the standard Content/Data/Structure sections).
1047
+
1048
+ | Type | Where `meta.project` lives |
1049
+ |-----------------|-----------------------------------------------------------------------|
1050
+ | **Pages** | YAML frontmatter `project:` (with URL inheritance - see below) |
1051
+ | **Collections** | `schema.json` top-level `meta.project` |
1052
+ | **Forms** | Form JSON top-level `meta.project` |
1053
+ | **Actions** | Mongo doc `meta.project` |
1054
+ | **Menus** | `meta.project` alongside existing `meta.bundled` / `meta.presetOwner` |
1055
+ | **Blocks** | Block JSON top-level `meta.project` |
1056
+ | **Views** | View JSON top-level `meta.project` |
1057
+ | **Roles** | Role entry `data.meta.project` |
1058
+ | **Users** | User JSON `meta.project` (administrative ownership - sidebar location) |
1059
+
1060
+ ### Page URL inheritance
1061
+
1062
+ When a page is loaded the project is resolved in this order:
1063
+
1064
+ 1. Frontmatter has `project: '<slug>'` → use it (explicit override).
1065
+ 2. Frontmatter has `project: null` → resolves to `core` (legacy opt-out, deprecated - "untagged" no longer exists as a state).
1066
+ 3. Frontmatter `project:` field is missing → look up the project whose `rootUrl` is the longest prefix match of the page's URL → use that.
1067
+ 4. No match → page belongs to the **core** project.
1068
+
1069
+ The core project does not participate in prefix matching - it is the fallback when nothing else claims the page.
1070
+
1071
+ Example: project `members` has `rootUrl: '/members'`. A page at `/members/profile.md` with no `project:` frontmatter is implicitly in `members`. A page at `/about.md` belongs to `core`. A page at `/members/admin.md` with explicit `project: 'staff'` overrides the auto-match.
1072
+
1073
+ Where multiple `rootUrl` values match, longest wins; ties broken alphabetically by slug.
1074
+
1075
+ ### User access scope - `projects: []`
1076
+
1077
+ Users gain a new optional field `projects: ['<slug>', ...]` - the **access scope**. Distinct from `meta.project` on the user record (the **administrative ownership** that tells the sidebar where to file the user).
1078
+
1079
+ | Field | Purpose |
1080
+ |------------------|------------------------------------------------------------------------------------------|
1081
+ | `meta.project` | Administrative ownership - where the user appears in the sidebar's Projects section |
1082
+ | `projects: []` | Access scope - which projects' artefacts the user can see/edit |
1083
+
1084
+ Most users have both fields set to the same single project; a recruiter user owned by `members` might be granted access to `members` + `intranet`.
1085
+
1086
+ **Access rule** (enforced in API route guards, sidebar render, list/get endpoints):
1087
+
1088
+ - Super-admins bypass entirely - they see all projects regardless of `projects`.
1089
+ - Core artefacts (no `meta.project`, or tagged `core`) are always visible (subject to role permission) - the core project is organisational, never an access boundary.
1090
+ - **Empty `projects: []` = no restriction** - a user with no scope set sees everything (subject to role).
1091
+ - A non-empty `projects` array restricts the user to artefacts whose `meta.project` is in their list, plus core artefacts. The core project always appears in their project list.
1092
+
1093
+ ### Permissions
1094
+
1095
+ A new permission family `projects.{read, create, update, delete}` is added to `permissionRegistry`. The base `super-admin` and `admin` roles receive it through the boot self-heal (see below); **custom roles are not back-filled** - an admin grants `projects.read` to them in the role editor (**System > Roles**).
1096
+
1097
+ ---
1098
+
1099
+ ## API tokens & the external API
1100
+
1101
+ API tokens are project-scoped machine credentials for the external collections API
1102
+ (`/api/v1/<collection>` - see `docs/api-reference.md`). They are stored as a **preset collection**
1103
+ at `content/collections/api-tokens/` (always file-based, like the other presets) and managed in
1104
+ the admin under **System → API Tokens**. Only a SHA-256 hash of each token is stored - the
1105
+ plaintext (`dcms_` + 64 hex) is shown once, at creation.
1106
+
1107
+ ### Per-verb collection API config - `schema.api`
1108
+
1109
+ Each collection's `schema.json` carries an `api` block with one entry per verb:
1110
+
1111
+ ```json
1112
+ "api": {
1113
+ "read": { "enabled": true, "access": "token", "fields": ["title", "price"] },
1114
+ "create": { "enabled": true, "access": "token" },
1115
+ "update": { "enabled": false, "access": "admin" },
1116
+ "delete": { "enabled": false, "access": "admin" }
1117
+ }
1118
+ ```
1119
+
1120
+ - `access` - `"public"` (no credentials), `"token"` (project-scoped API token), or a role name
1121
+ (JWT at that role level or higher). A token is **only** accepted in token mode; a JWT is never
1122
+ accepted in token mode.
1123
+ - `fields` (read only) - optional allowlist of `data` fields returned by the public/external read
1124
+ endpoints. Absent or empty = all fields. Admin endpoints are unaffected.
1125
+
1126
+ ### Public export config - `schema.export`
1127
+
1128
+ Each collection's `schema.json` may also carry an `export` block, controlling the "Export…" entry
1129
+ in the public right-click menu on collection displays (see `docs/markdown-shortcodes.md`) - separate
1130
+ from `schema.api`, which governs the external `/api/v1` API.
1131
+
1132
+ ```json
1133
+ "export": {
1134
+ "enabled": true,
1135
+ "access": "admin",
1136
+ "formats": ["csv", "json"]
1137
+ }
1138
+ ```
1139
+
1140
+ | Key | Default | Meaning |
1141
+ |-----------|---------------------------------|--------------------------------------------------------------------------|
1142
+ | `enabled` | `true` | `false` disables export entirely, overriding `exportable="true"` on the shortcode |
1143
+ | `access` | least-privileged role | `"public"`, or a role name. Never `"token"` - token mode is for the external API only |
1144
+ | `formats` | `["csv", "json"]` | Offered download formats; unrecognised values are filtered out |
1145
+
1146
+ Resolution is first-match-wins, in this order:
1147
+
1148
+ 1. `export.enabled === false` - export is never offered, and `exportable="true"` on the shortcode is
1149
+ ignored
1150
+ 2. `exportable="true"` on the `[collection]`/`[view]` shortcode - offered to everyone. This is
1151
+ grandfathered: `exportable` has always meant "public CSV button", so existing pages do not regress
1152
+ 3. `export.access` - as configured
1153
+ 4. otherwise - the least-privileged role (i.e. signed-in users only)
1154
+
1155
+ > **This gate is a convenience affordance, not an enforcement boundary.** Collection entries are
1156
+ > already embedded in the rendered page's `data-ctx` attribute so the browser can filter and sort
1157
+ > them without a round trip. Restricting `access` stops a visitor being handed a download button;
1158
+ > it does not stop a determined one reading the page source. A collection whose contents must not
1159
+ > reach a visitor at all should not be rendered on a page that visitor can load - use
1160
+ > `page.visibility`, or gate the collection's `api.read.access`.
1161
+
1162
+ ### Token record
1163
+
1164
+ `name`, `project` (binding - the token only works on collections resolving to this project; immutable),
1165
+ `scopes` (optional `[{collection, verbs[]}]`; empty = the whole project), `enabled`, `expiresAt`,
1166
+ `lastUsedAt`, `tokenHint` (last 4 characters, for display), `tokenHash` (never returned by any API).
1167
+
1168
+ ### Scaffolder integration
1169
+
1170
+ A recipe may declare `"apiTokens": [{ "name": "{{namespace}}-api", "scopes": [...] }]`. Tokens are
1171
+ generated at apply time under the recipe's project; the plaintext appears once in the apply summary.
1172
+ Re-applying a recipe never re-issues an existing name+project token.
1173
+
1174
+ ### Permissions
1175
+
1176
+ A new permission family `api-tokens.{read, create, update, delete}` is added to `permissionRegistry`.
1177
+ Base roles self-heal at boot against their seed definitions (append-only): the root role always
1178
+ receives every registry resource, and since 0.25.1 the base `admin` role includes `api-tokens` and
1179
+ `api-endpoints`. Custom roles are never touched - grant new families to them via the Roles editor.
1180
+ Note: a *seeded* permission removed from a base role is re-added at boot; use a custom role to run
1181
+ with less than the base set.
1182
+
1183
+ ---
1184
+
1185
+ ### Content API - `config/content-api.json`
1186
+
1187
+ Settings for the read-only Content API at `/api/content/v1` (see `docs/api-reference.md`), edited at
1188
+ **System → Content API**. Optional: **absence means off**, so an upgrade exposes nothing.
1189
+
1190
+ ```json
1191
+ {
1192
+ "enabled": false,
1193
+ "access": "token",
1194
+ "resources": {
1195
+ "pages": { "enabled": true },
1196
+ "menus": { "enabled": true },
1197
+ "site": { "enabled": true },
1198
+ "search": { "enabled": true }
1199
+ },
1200
+ "formats": ["html"],
1201
+ "listable": true,
1202
+ "maxLimit": 200
1203
+ }
1204
+ ```
1205
+
1206
+ | Key | Type | Default | Description |
1207
+ |-----|------|---------|-------------|
1208
+ | `enabled` | boolean | `false` | Only a literal `true` turns it on. Off = every address answers `404` |
1209
+ | `access` | string | `"token"` | `"public"`, `"token"` (project API token) or a role name (Bearer access JWT, that role or more senior). Saving a role the site does not have is refused |
1210
+ | `resources.<name>.enabled` | boolean | `true` | Switch `pages`, `menus`, `site` or `search` off on its own |
1211
+ | `formats` | string[] | `["html"]` | Which of `html` / `markdown` a page may be asked for. Never empty - HTML is kept. Markdown is the source, shortcodes included |
1212
+ | `listable` | boolean | `true` | `false` removes the page list and keeps single pages |
1213
+ | `maxLimit` | number | `200` | Largest page size a list may ask for (1-200) |
1214
+
1215
+ Unknown keys are dropped on save (`normaliseContentApiSettings()` in `server/services/contentApi.js`).
1216
+ A page opts out with front matter `api: false`.
1217
+
1218
+ ---
1219
+
1220
+ ## Custom API endpoints (API Builder)
1221
+
1222
+ Named REST endpoints over collection data, served at `/api/x/<project><path>` (see
1223
+ `docs/api-reference.md`). Definitions are entries in the **`api-endpoints` preset collection**
1224
+ (always file-based) and are built in the admin under **Data → API Builder**, which includes a
1225
+ try-it console for live testing against the saved definition.
1226
+
1227
+ | Field | Type | Notes |
1228
+ |--------------|----------|-------|
1229
+ | `name` | string | Display name |
1230
+ | `project` | string | URL namespace + access scoping - immutable after creation |
1231
+ | `path` | string | `/segments` or `:param` placeholders; unique path *shape* per project |
1232
+ | `collection` | string | The collection to query; system-managed collections are refused |
1233
+ | `auth` | string | `public` \| `token` \| role name |
1234
+ | `mode` | string | `list` (paged payload) \| `single` (first match or 404) |
1235
+ | `filter` | object | `{field_op: value}`; values may embed `{{params.x}}` / `{{query.x}}` |
1236
+ | `sort`/`order`/`limit` | - | Query shape; `limit: 0` = no limit (max 500 otherwise) |
1237
+ | `fields` | string[] | Response field allowlist; empty = all |
1238
+ | `enabled` | boolean | Disabled endpoints 404 |
1239
+
1240
+ Scaffolder recipes may declare `"apiEndpoints": [{ "path": "/by-day/:date", "collection": "{{namespace}}-items", "filter": {...} }]` -
1241
+ created at apply time under the recipe's project, idempotent on re-apply (existing path shapes
1242
+ are left unchanged).
1243
+
1244
+ Permission family `api-endpoints.{read, create, update, delete}` - included in the base
1245
+ `super-admin` and `admin` roles via the boot self-heal; custom roles need the grant via the
1246
+ Roles editor.
1247
+
1248
+ An endpoint may only expose collections from **its own project or core** - the builder's
1249
+ collection picker filters accordingly, and the server refuses cross-project bindings (400) and
1250
+ collections outside the caller's access scope (403).
1251
+
1252
+ ---
1253
+
1254
+ ## `config/presets.json`
1255
+
1256
+ Layout presets available when creating or editing a page.
1257
+
1258
+ ```json
1259
+ {
1260
+ "default": {
1261
+ "label": "Default",
1262
+ "description": "Standard page with navbar and footer",
1263
+ "navbar": true,
1264
+ "footer": true,
1265
+ "sidebar": false
1266
+ },
1267
+ "minimal": {
1268
+ "label": "Minimal",
1269
+ "description": "Clean page with no navbar or footer",
1270
+ "navbar": false,
1271
+ "footer": false,
1272
+ "sidebar": false
1273
+ }
1274
+ }
1275
+ ```
1276
+
1277
+ The key (e.g. `"default"`) is stored in the page's `layout` frontmatter field. Each preset object must have at least a `label` string.
1278
+
1279
+ | Field | Type | Description |
1280
+ |---|---|---|
1281
+ | `label` | string | Human-readable name shown in the page editor |
1282
+ | `description` | string | Optional description |
1283
+ | `navbar` | boolean | Whether the navbar is rendered for this layout |
1284
+ | `footer` | boolean | Whether the footer is rendered |
1285
+ | `sidebar` | boolean | Whether a sidebar column is rendered |
1286
+
1287
+ ---
1288
+
1289
+ ## `config/plugins.json`
1290
+
1291
+ Stores the enabled state and user-overridden settings for each plugin. This file is managed by the **Plugins** admin panel.
1292
+
1293
+ ```json
1294
+ {
1295
+ "analytics": {
1296
+ "enabled": true,
1297
+ "settings": {}
1298
+ },
1299
+ "domma-effects": {
1300
+ "enabled": true,
1301
+ "settings": {
1302
+ "defaultDuration": 600,
1303
+ "defaultAnimation": "fade",
1304
+ "defaultThreshold": 0.1,
1305
+ "respectMotion": false
1306
+ }
1307
+ }
1308
+ }
1309
+ ```
1310
+
1311
+ The key is the plugin's `name` field from its `plugin.json` manifest. Settings are merged with the plugin's own `config.js` defaults at runtime - the user's values take precedence.
1312
+
1313
+ `locked: true` on an entry means the site's manager holds that switch: the admin here cannot switch the plugin on or off (the API answers 423). Only the manager writes it.
1314
+
1315
+ ---
1316
+
1317
+ ## `config/tools.json`
1318
+
1319
+ The built-in Tools that can be switched off: `contacts`, `notes`, `todo`, `analytics` (0.88.0) and `seo` (0.90.0). Absent file or entry = on and unlocked. Managed at the top of the **Marketplace** screen (`#/plugins`), or by the site's manager.
1320
+
1321
+ ```json
1322
+ {
1323
+ "notes": { "enabled": false },
1324
+ "analytics": { "enabled": false, "locked": true }
1325
+ }
1326
+ ```
1327
+
1328
+ A Tool that is off keeps its data. Its API answers 404, its sidebar entry, notification and calendar sources go, and (for `analytics`) the page-view beacon is not emitted. Switching one off also switches off any enabled plugin that `requires` it - see `server/services/tools.js`.
1329
+
1330
+ ---
1331
+
1332
+ ## `config/connections.json` (Pro)
1333
+
1334
+ Defines named MongoDB connections used by the storage adapter system. This file is absent in a standard installation, which means all collections use the default file-based adapter.
1335
+
1336
+ ```json
1337
+ {
1338
+ "default": {
1339
+ "type": "mongodb",
1340
+ "uri": "mongodb://localhost:27017",
1341
+ "database": "my_cms"
1342
+ }
1343
+ }
1344
+ ```
1345
+
1346
+ | Field | Type | Description |
1347
+ |---|---|---|
1348
+ | `type` | string | Must be `"mongodb"` |
1349
+ | `uri` | string | MongoDB connection URI |
1350
+ | `database` | string | Database name within the MongoDB instance |
1351
+
1352
+ A `connections.json.example` template is provided in the `config/` directory. To enable MongoDB for a collection, add a `storage` block to that collection's `schema.json`:
1353
+
1354
+ ```json
1355
+ {
1356
+ "storage": {
1357
+ "adapter": "mongodb",
1358
+ "connection": "default"
1359
+ }
1360
+ }
1361
+ ```
1362
+
1363
+ The `mongodb` package is an optional dependency - it is not required unless you configure a MongoDB connection.
1364
+
1365
+ ---
1366
+
1367
+ ## Page frontmatter
1368
+
1369
+ Pages live as Markdown files in `content/pages/` with YAML frontmatter at the top:
1370
+
1371
+ ```yaml
1372
+ ---
1373
+ title: My Page
1374
+ layout: default # any preset key in config/presets.json
1375
+ description: SEO description
1376
+ status: published # draft | published - drafts 404 publicly, preview when signed in
1377
+ visibility: public # see below - controls who can view the page
1378
+ ---
1379
+ ```
1380
+
1381
+ ### `visibility`
1382
+
1383
+ Controls page-level access. Three accepted shapes:
1384
+
1385
+ | Value | Who can view |
1386
+ |------------------------------------|-----------------------------------------------------------------------------------------|
1387
+ | `public` (or omitted) | Everyone, including unauthenticated visitors |
1388
+ | `private` | Super-admin only (level 0) - useful for staging pages |
1389
+ | A role name (e.g. `user`) | That role plus anyone more privileged (lower or equal role level) |
1390
+ | `=` + a role name (e.g. `=candidate`) | Holders of that role only (primary or additional role), plus the super-admin (level 0) |
1391
+ | An **array** of the above | Anyone matching ANY entry (each entry follows its own rule above) |
1392
+
1393
+ The array form makes "sibling role" gating natural - both roles can view the same page even if neither inherits from
1394
+ the other:
1395
+
1396
+ ```yaml
1397
+ ---
1398
+ title: Members Area
1399
+ visibility: [candidate, employer]
1400
+ ---
1401
+ ```
1402
+
1403
+ This grants access to `candidate`, `employer`, and anyone more privileged than either (typically administrators, who
1404
+ inherit access automatically through the role hierarchy). Note that a role on the SAME level also passes: if
1405
+ `candidate` and `employer` are both level 3, `visibility: candidate` lets employers in too.
1406
+
1407
+ To keep a page (or a menu item) to one role alone, use the exact form:
1408
+
1409
+ ```yaml
1410
+ ---
1411
+ title: My Applications
1412
+ visibility: "=candidate" # candidates only - not employers, not admins
1413
+ ---
1414
+ ```
1415
+
1416
+ Exact tokens skip the hierarchy: only users who hold that role pass, whether it is their primary role or one of their
1417
+ `additionalRoles`. The super-admin (level 0) still passes, as it does for `private`; an ordinary admin does not, so give
1418
+ an admin the role as an additional role if they need to see what candidates see. Exact and hierarchical tokens mix in
1419
+ an array: `["=candidate", "=employer"]` is "those two roles and nobody else". The page and menu editors offer both
1420
+ forms for every role ("Candidate and above", "Candidate only").
1421
+
1422
+ The check is implemented by `checkVisibility(user, visibility)` in `server/middleware/auth.js`. The viewer is resolved
1423
+ on every page request (from the `dm_session` cookie or a Bearer token), because menus on a public page filter their
1424
+ items per viewer too. The response is cached per set of roles the viewer holds (`role${roles ?? 'anon'}`), so gated
1425
+ content never reaches the anonymous cache and anonymous visitors share a single entry.
1426
+
1427
+ Anonymous visitors hitting a non-public page receive a 403 with a minimal access-denied response. Authenticated
1428
+ visitors whose role doesn't satisfy the requirement get the same 403. To redirect users to a sign-in page instead,
1429
+ intercept the 403 at your reverse proxy or use the `4xx` page customisation.
1430
+
1431
+ ### Other frontmatter fields
1432
+
1433
+ | Field | Type | Description |
1434
+ |---------------|-----------------|------------------------------------------------------------------------------|
1435
+ | `title` | string | Used as `<title>` (overridden by `seo.title`) and the page heading |
1436
+ | `description` | string | Meta description fallback |
1437
+ | `layout` | string | A preset key from `config/presets.json`: `default`, `with-sidebar`, `minimal`, `blank`, `landing`, `article`, `product`, `dashboard`, `wide` - see `docs/theming.md` |
1438
+ | `status` | string | `draft` or `published` - see "Draft previews" below |
1439
+ | `visibility` | string\|string[]| As above |
1440
+ | `seo` | object | SEO overrides - see "Page-level frontmatter overrides" above |
1441
+ | `tags` | string[] | Editorial tags - weighted in site search and shown (and searchable) in the admin Pages list. Blanks and duplicates are removed on save. These are **not** cache tags: a page's cache tags are derived automatically from the shortcodes it uses (see `docs/cache.md`) |
1442
+ | `dconfig` | object | Page-level `$.setup()` config; merged with inline `[dconfig]` blocks |
1443
+ | `sortOrder` | number | Ordering hint used by page lists (default from `pageDefaults`, normally 99) |
1444
+ | `category` | string | Free-form category shown in the page editor |
1445
+ | `project` | string | Owning project slug. Blank or absent = inherit from the URL |
1446
+ | `theme` | string | Per-page theme override (a theme key such as `ocean-light`) |
1447
+ | `width` | string | `narrow`, `normal`, `wide` or `full` - overrides the layout's content width for this page (hand-written only) |
1448
+ | `sidebar` | boolean | Show the layout sidebar on this page even when the preset does not |
1449
+ | `breadcrumbs` | boolean | `false` hides the breadcrumb trail on this page |
1450
+ | `showInNav` | boolean | Legacy - no longer read by the public site and no longer offered in the editor (a stored value is kept). Navigation is managed in **Menus** |
1451
+ | `bundled` | boolean | Marks a page as shipped with the CMS (shown as a "Bundled" chip in the Pages list) |
1452
+
1453
+ ### Draft previews
1454
+
1455
+ A page with `status: draft` returns 404 to the public, exactly as before. A
1456
+ signed-in user whose role holds the `pages.read` permission is instead served
1457
+ the real page - real layout, navbar, footer, theme, menus, custom CSS - with a
1458
+ draft banner fixed to the bottom of the viewport. Designers and developers can
1459
+ therefore verify a page at its real URL before it is released.
1460
+
1461
+ The banner names the viewer, links to the page in the admin editor, and offers
1462
+ a Publish button. It is hidden from print output and can be dismissed for the
1463
+ current page view.
1464
+
1465
+ Notes:
1466
+
1467
+ - **Nothing is revealed to the public.** An anonymous visitor, or a signed-in
1468
+ user without `pages.read`, gets the same 404 as a page that does not exist.
1469
+ - **Draft renders are never cached** (`Cache-Control: no-store, private`) and
1470
+ never indexed (`X-Robots-Tag: noindex, nofollow`), so a preview can never be
1471
+ replayed to an anonymous visitor from the response cache.
1472
+ - **Drafts stay out of `sitemap.xml`** and do not emit a `content:pageViewed`
1473
+ hook, so previewing does not pollute analytics.
1474
+ - **A disabled project still wins.** Pages in a disabled project 404 for
1475
+ everyone, previewers included - the project switch remains a hard kill.
1476
+ - Browser navigation is authenticated by the `dm_session` cookie set at login
1477
+ (HttpOnly, SameSite=Lax, Secure over https, expiring with the access token).
1478
+ It is read only when rendering public pages; every API route remains
1479
+ Bearer-only, so the cookie grants no write authority.
1480
+
1481
+ ### Full-page preview in the editor
1482
+
1483
+ The page editor's **Live Preview** tab renders the *unsaved* editor state through
1484
+ the server's real page pipeline - the same `parseMarkdown` and `renderPage` the
1485
+ public site runs on save - so layout, navbar, footer, theme, menus, `dconfig` and
1486
+ custom CSS are exactly what publishing would produce. The split-view pane beside
1487
+ the markdown still renders the body alone; that answers "did my shortcodes
1488
+ parse", this answers "is the page right".
1489
+
1490
+ It re-renders about half a second after you stop typing, but only while the tab
1491
+ is on screen. Width buttons constrain the frame to 1024px or 390px to check
1492
+ tablet and mobile layout. The banner is deliberately absent here: the editor is
1493
+ already telling you what you are looking at.
1494
+
1495
+ Backed by `POST /api/pages/preview/full` (`pages.read`), which takes
1496
+ `{urlPath, frontmatter, body}` and returns `{html}`.
1497
+
1498
+ ### Share links
1499
+
1500
+ **Share link** in the Live Preview tab mints a URL that renders one unpublished
1501
+ page to someone with no account - a client, an external designer, a copywriter.
1502
+
1503
+ - Scoped to **one page**. The page is named inside the signed token, so a holder
1504
+ cannot repoint the link at anything else.
1505
+ - **Expiring**: 1 hour, 24 hours, 7 days or 30 days. 30 days is the ceiling.
1506
+ - **Revocable at any time.** Each token carries an id that must still be present
1507
+ and unrevoked in `content/preview-links.json`; a signed token alone is not
1508
+ enough. Revocation takes effect on the next request.
1509
+ - Responses carry `no-store`, `noindex, nofollow` and `Referrer-Policy:
1510
+ no-referrer` - the credential is in the URL, so the token must not ride along
1511
+ in `Referer` to third-party assets.
1512
+ - The page renders **as anonymous**: menus and role-gated shortcodes show the
1513
+ recipient the least, not the most. The token satisfies that one page's own
1514
+ gate and nothing else.
1515
+ - Links are dropped automatically when the page is deleted or renamed, so one
1516
+ cannot outlive what it pointed at.
1517
+ - A disabled project still 404s, share link or not.
1518
+
1519
+ Minting requires `pages.update`, not merely `pages.read`: handing an
1520
+ unauthenticated stranger a working URL is an act of publishing, however narrow.
1521
+
1522
+ | Endpoint | Permission | Purpose |
1523
+ |---|---|---|
1524
+ | `GET /api/pages/preview-links[?urlPath=]` | `pages.read` | List links |
1525
+ | `POST /api/pages/preview-links` | `pages.update` | Mint (`{urlPath, expiresIn, label}`) |
1526
+ | `DELETE /api/pages/preview-links/:id` | `pages.update` | Revoke |
1527
+
1528
+ ---
1529
+
1530
+ ## Collection schema fields
1531
+
1532
+ Collection schemas live at `content/collections/<slug>/schema.json`. Each field
1533
+ declaration looks like `{ name, label, type, required, ... }`. Supported `type`
1534
+ values include the obvious primitives (`text`, `textarea`, `number`, `date`,
1535
+ `boolean`, `select`, `multiselect`, `chooser`, `tags`, `email`) plus one
1536
+ relational type:
1537
+
1538
+ ### `type: "reference"` - relational field
1539
+
1540
+ Stores the id of an entry from another collection. Auto-renders as a populated
1541
+ dropdown on forms, resolves to the target's display label everywhere the field
1542
+ is shown, and (optionally) links to the target's detail page.
1543
+
1544
+ ```json
1545
+ {
1546
+ "name": "jobId",
1547
+ "label": "Job",
1548
+ "type": "reference",
1549
+ "required": true,
1550
+ "reference": {
1551
+ "collection": "jobs",
1552
+ "displayField": "title",
1553
+ "searchField": "title",
1554
+ "linkTemplate": "/jobs/{{id}}"
1555
+ }
1556
+ }
1557
+ ```
1558
+
1559
+ | Sub-field | Required | Description |
1560
+ |----------------|----------|--------------------------------------------------------------------------|
1561
+ | `collection` | yes | Slug of the target collection |
1562
+ | `displayField` | no | Field on the target whose value is shown (default `title`) |
1563
+ | `searchField` | no | Field used for type-ahead picker filtering (default = `displayField`) |
1564
+ | `linkTemplate` | no | URL template; `{{id}}` is substituted with the target's id (URL-encoded) |
1565
+
1566
+ **Cardinality** - set `multiple: true` on the field to store an array of ids
1567
+ (one-to-many). The form renders as a multi-select; display joins values with
1568
+ ", ". Without `multiple`, the field stores a single id string.
1569
+
1570
+ **Validation** - on every create/update, the target id is checked against the
1571
+ target collection. Unknown ids fail with `Validation failed: "Job" → no entry
1572
+ "abc" in "jobs"`.
1573
+
1574
+ **Resolution** - `listEntries(slug, { resolveRefs: true })` decorates entries
1575
+ with a parallel `_refs` map:
1576
+
1577
+ ```js
1578
+ entry._refs.jobId = { id: 'abc', display: 'Senior Engineer', missing: false, link: '/jobs/abc' }
1579
+ ```
1580
+
1581
+ The `[collection]` shortcode, the Collection Browser, and the public read API
1582
+ all resolve references automatically. Missing-target records render as
1583
+ `<id> (missing)` rather than crashing the page.
1584
+
1585
+ **Filtering** - reference fields participate in the `where_*` DSL using their
1586
+ id values:
1587
+
1588
+ ```
1589
+ [collection slug="applications" where_jobId="abc-123" /]
1590
+ ```
1591
+
1592
+ The Browser's filter rail also auto-renders a dropdown of resolved-label
1593
+ options when a reference field is in `filterable="…"`.
1594
+
1595
+ ### `type: "file"` - uploaded file field
1596
+
1597
+ Stores an uploaded file's metadata: `{ url, name, size, mime }`. The actual
1598
+ bytes live in `content/media/` (under a uuid-prefixed filename so concurrent
1599
+ uploads of the same name don't collide).
1600
+
1601
+ ```json
1602
+ {
1603
+ "name": "resume",
1604
+ "label": "Resume",
1605
+ "type": "file",
1606
+ "required": true,
1607
+ "file": {
1608
+ "accept": "application/pdf",
1609
+ "maxSize": 5242880
1610
+ }
1611
+ }
1612
+ ```
1613
+
1614
+ | Sub-field | Required | Description |
1615
+ |-----------|----------|--------------------------------------------------------------------------------------|
1616
+ | `accept` | no | Comma-separated mime types or wildcards (`image/*`, `application/pdf,image/jpeg`) |
1617
+ | `maxSize` | no | Maximum bytes (default 5 MB) |
1618
+
1619
+ **Form rendering** - `<input type="file">` with the `accept` hint. Setting
1620
+ `multiple: true` on the field renders a multi-file picker (one file per entry
1621
+ update; the form takes the last selection).
1622
+
1623
+ **Submission** - the form switches automatically to `multipart/form-data`
1624
+ when any file input has a selected file. The server-side handler:
1625
+
1626
+ 1. Validates each file against its field's `accept` and `maxSize`
1627
+ 2. Sanitises the filename (lowercases, strips path chars, prepends a uuid)
1628
+ 3. Saves to `content/media/<uuid>-<safe-name>`
1629
+ 4. Replaces the entry's field value with the reference object
1630
+
1631
+ **Display** - `[collection]`, the Collection Browser, and the public read API
1632
+ all render file references appropriately:
1633
+
1634
+ - Image mimes (`image/png`, `image/jpeg`, etc.) → clickable `<img>` thumbnail
1635
+ - Everything else → `<a href download>filename</a>`
1636
+
1637
+ Stored reference shape:
1638
+
1639
+ ```json
1640
+ {
1641
+ "url": "/media/abc12345-resume.pdf",
1642
+ "name": "resume.pdf",
1643
+ "size": 245678,
1644
+ "mime": "application/pdf"
1645
+ }
1646
+ ```
1647
+
1648
+ **Security note** - the `/media/` URL is publicly served (the file is in
1649
+ `content/media/` which the CMS exposes statically). If your use case needs
1650
+ gated downloads (e.g. CVs only visible to the candidate + recruiters),
1651
+ serve the file through an authenticated route instead - that's outside the
1652
+ field type's scope.
1653
+
1654
+ ---
1655
+
1656
+ ## Row-level access (per-entry scoping)
1657
+
1658
+ Actions, views, and (where wired) collection endpoints accept an `access.rowLevel`
1659
+ config that restricts operations to entries the user is allowed to touch.
1660
+ Three modes are supported, all checked async server-side via `checkEntryAccess()`.
1661
+
1662
+ ### `mode: 'owner'`
1663
+
1664
+ Match the entry's `meta.createdBy` against the user. Standard "you only see
1665
+ entries you created":
1666
+
1667
+ ```json
1668
+ { "mode": "owner" }
1669
+ ```
1670
+
1671
+ ### `mode: 'field'`
1672
+
1673
+ Match a data field on the entry against the user. Use when ownership is
1674
+ explicit (e.g. tickets routed to a specific assignee):
1675
+
1676
+ ```json
1677
+ { "mode": "field", "field": "assignedTo" }
1678
+ ```
1679
+
1680
+ ### `mode: 'reference'` - cross-collection scoping
1681
+
1682
+ Resolve a reference field, then check ownership on the *target* entry.
1683
+ This is the recruiter pattern - "I can act on applications because I own
1684
+ the job they point at":
1685
+
1686
+ ```json
1687
+ {
1688
+ "mode": "reference",
1689
+ "field": "jobId",
1690
+ "targetCollection": "jobs",
1691
+ "targetField": "createdBy"
1692
+ }
1693
+ ```
1694
+
1695
+ | Sub-field | Required | Description |
1696
+ |--------------------|----------|-----------------------------------------------------------------------------------|
1697
+ | `field` | yes | Reference field on THIS entry (e.g. `jobId`) |
1698
+ | `targetCollection` | yes | Collection slug to resolve `field` against |
1699
+ | `targetField` | no | `'createdBy'` (default) checks `target.meta.createdBy`; any other value checks `target.data.<targetField>` |
1700
+
1701
+ **Array references** are supported - access is granted when ANY of the
1702
+ targets is owned by the user (e.g. "I can see this entry because I own at
1703
+ least one of its parents").
1704
+
1705
+ **Fail-closed semantics**: dangling references (target was deleted),
1706
+ misconfigured rules (missing fields), and unknown modes all return `false`.
1707
+ A typo in the config can't accidentally open data up.
1708
+
1709
+ ---
1710
+
1711
+ ## Multi-role users
1712
+
1713
+ A user has ONE **primary role** (`user.role`) and an optional array of
1714
+ **additional roles** (`user.additionalRoles`). Permission, visibility, and
1715
+ row-access checks consult ALL of them and grant access if any one role
1716
+ satisfies - union semantics. Hierarchical-level comparisons (admin tier,
1717
+ "can manage this user") use the LOWEST level number across the user's roles,
1718
+ i.e. the user's most-privileged role wins.
1719
+
1720
+ ```json
1721
+ {
1722
+ "id": "ab3-...",
1723
+ "email": "alex@company.com",
1724
+ "name": "Alex",
1725
+ "role": "candidate",
1726
+ "additionalRoles": ["recruiter"]
1727
+ }
1728
+ ```
1729
+
1730
+ That user can do everything a candidate can do AND everything a recruiter can
1731
+ do. The same person submitting an application gets `meta.createdBy` stamped
1732
+ with their id (as a candidate, via `scope="mine"`); the same person picking
1733
+ up applications to review acts as a recruiter (via `mode: "reference"`
1734
+ row-access on the recruiter's transition actions).
1735
+
1736
+ ### When to use multi-role
1737
+
1738
+ - A single person fills more than one job - recruiter who also reviews
1739
+ candidates, admin who also writes content, etc.
1740
+ - Migrating from another system where users had a role *set* rather than a
1741
+ scalar role.
1742
+ - Granting temporary elevated access without changing the user's "home" role.
1743
+
1744
+ ### Helper API
1745
+
1746
+ `server/services/userRoles.js`:
1747
+
1748
+ | Helper | Use |
1749
+ |---|---|
1750
+ | `getEffectiveRoles(user)` | All roles, primary first, deduplicated (`[]` if user has none) |
1751
+ | `getEffectiveLevel(user)` | Lowest level across all roles (`Infinity` if no roles) |
1752
+ | `userHasRole(user, role)` | Exact-name match - does NOT walk the hierarchy |
1753
+ | `userHasAnyRole(user, [...])` | OR across the list |
1754
+
1755
+ ### Security guarantees
1756
+
1757
+ - **Assignment requires `canManageUser`** - the actor must be more
1758
+ privileged than EVERY role they're trying to assign as either primary or
1759
+ additional. A regular admin can't make someone "additionally super-admin".
1760
+ - **JWT carries `additionalRoles`** - so the public-page visibility check
1761
+ and every action's role gate see the same union the admin sees.
1762
+ - **`isAdmin(user)` uses effective level** - row-access bypass for admins
1763
+ works correctly when admin is an additional role.
1764
+ - **Per-row cache key uses primary role only** - see `server/routes/public.js`:
1765
+ multi-role users see correctly-gated content but their served HTML is the
1766
+ cache variant for their primary role's tier. Acceptable trade for keeping
1767
+ cache cardinality bounded.
1768
+
1769
+ ### Migration
1770
+
1771
+ Existing users with no `additionalRoles` field behave identically to today
1772
+ - `getEffectiveRoles` returns `[user.role]`, `getEffectiveLevel` returns
1773
+ `getRoleLevel(user.role)`. No data backfill needed. The field becomes
1774
+ available when the admin user editor exposes it (the API accepts it now;
1775
+ the UI is the next step).
1776
+
1777
+ ---
1778
+
1779
+ ### Example: recruiter dashboard
1780
+
1781
+ A recruiter has posted several jobs and should see only the applications to
1782
+ those jobs:
1783
+
1784
+ ```json
1785
+ // Action: "Mark application as reviewing"
1786
+ {
1787
+ "slug": "review-application",
1788
+ "collection": "applications",
1789
+ "transition": { "field": "status", "from": ["submitted"], "to": "reviewing" },
1790
+ "access": {
1791
+ "roles": ["recruiter", "admin"],
1792
+ "rowLevel": {
1793
+ "mode": "reference",
1794
+ "field": "jobId",
1795
+ "targetCollection": "jobs",
1796
+ "targetField": "createdBy"
1797
+ }
1798
+ },
1799
+ "steps": [
1800
+ { "type": "updateField", "config": { "field": "status", "value": "reviewing" } }
1801
+ ]
1802
+ }
1803
+ ```
1804
+
1805
+ Now `recruiter` user Bob can transition applications, but only for jobs Bob
1806
+ posted. Recruiter Alice can transition different applications. Neither can
1807
+ touch the other's. Admin bypasses the row-level check entirely (role level 0).
1808
+
1809
+ The transitions UI (`[collection ... transitions]`) automatically filters
1810
+ out actions the user can't perform on each row - so each recruiter sees
1811
+ exactly the applications they're allowed to act on, with the right buttons.