domma-cms 1.6.59 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4344 -0
- package/README.md +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +2 -2
- package/admin/js/lib/draft-owner.js +1 -0
- package/admin/js/lib/effect-defs.js +1 -1
- package/admin/js/lib/effects-builder.js +2 -2
- package/admin/js/lib/page-editor-reference.js +5 -5
- package/admin/js/lib/shortcode-tags.js +1 -1
- package/admin/js/templates/docs/usage-shortcodes.html +39 -0
- package/admin/js/views/user-editor.js +1 -1
- package/docs/DommaFeatures.md +3005 -0
- package/docs/MultiTenant.md +176 -0
- package/docs/api-reference.md +1667 -0
- package/docs/cache.md +96 -0
- package/docs/configuration.md +1811 -0
- package/docs/editions-and-licences.md +190 -0
- package/docs/getting-started.md +201 -0
- package/docs/guides/form-triggers.md +260 -0
- package/docs/guides/mongodb-storage-adapter.md +282 -0
- package/docs/guides/multi-page-forms.md +121 -0
- package/docs/images/scribe-composer.png +0 -0
- package/docs/markdown-shortcodes.md +1738 -0
- package/docs/pentest-report-2026-04-03.md +181 -0
- package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
- package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
- package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
- package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
- package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
- package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
- package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
- package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
- package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
- package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
- package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
- package/docs/plans/2026-03-05-user-types-design.md +139 -0
- package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
- package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
- package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
- package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
- package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
- package/docs/plans/2026-06-10-core-project-design.md +240 -0
- package/docs/plugin-development.md +827 -0
- package/docs/recruitment-recipe.md +501 -0
- package/docs/scaffolding.md +312 -0
- package/docs/screenshots/calculator-floating.png +0 -0
- package/docs/screenshots/calculator-standard.png +0 -0
- package/docs/screenshots/email-settings.png +0 -0
- package/docs/screenshots/messages-count.png +0 -0
- package/docs/structure/user-types.md +107 -0
- package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
- package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
- package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
- package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
- package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
- package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
- package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
- package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
- package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
- package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
- package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
- package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
- package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
- package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
- package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
- package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
- package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
- package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
- package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
- package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
- package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
- package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
- package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
- package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
- package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
- package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
- package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
- package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
- package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
- package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
- package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
- package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
- package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
- package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
- package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
- package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
- package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
- package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
- package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
- package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
- package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
- package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
- package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
- package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
- package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
- package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
- package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
- package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
- package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
- package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
- package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
- package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
- package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
- package/docs/theming.md +320 -0
- package/package.json +2 -2
- package/plugins/_lib/admin/mail/keyboard.js +49 -0
- package/plugins/_lib/admin/mail/reader-view.js +4 -2
- package/public/js/celebrations/core/canvas.js +1 -1
- package/public/js/celebrations/index.js +1 -1
- package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
- package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
- package/public/js/celebrations/message-attrs.mjs +1 -0
- package/public/js/celebrations/themes/anniversary.js +1 -0
- package/public/js/celebrations/themes/autumn.js +1 -0
- package/public/js/celebrations/themes/birthday.js +1 -0
- package/public/js/celebrations/themes/butterfly-flight.js +1 -0
- package/public/js/celebrations/themes/calendar.js +1 -0
- package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
- package/public/js/celebrations/themes/chinese-new-year.js +1 -0
- package/public/js/celebrations/themes/christmas.js +1 -1
- package/public/js/celebrations/themes/easter.js +1 -0
- package/public/js/celebrations/themes/garden-drawing.js +1 -0
- package/public/js/celebrations/themes/garden.js +1 -0
- package/public/js/celebrations/themes/graduation.js +1 -0
- package/public/js/celebrations/themes/greeting.js +1 -0
- package/public/js/celebrations/themes/make-it-rain.js +1 -0
- package/public/js/celebrations/themes/money-rain.js +1 -0
- package/public/js/celebrations/themes/new-year.js +1 -0
- package/public/js/celebrations/themes/occasion-drawing.js +1 -0
- package/public/js/celebrations/themes/occasions.js +1 -0
- package/public/js/celebrations/themes/rain-control.js +1 -0
- package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
- package/public/js/celebrations/themes/sparkler-font.js +1 -0
- package/public/js/celebrations/themes/sparkler-writing.js +1 -0
- package/public/js/celebrations/themes/summer-garden.js +1 -0
- package/public/js/celebrations/themes/winter.js +1 -0
- package/public/js/celebrations/trait-data.mjs +1 -1
- package/public/js/effects.js +1 -1
- package/scripts/build.js +4 -0
- package/server/services/markdown.js +5 -2
- 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.
|