domma-cms 1.6.60 → 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.
Files changed (137) hide show
  1. package/CHANGELOG.md +4344 -0
  2. package/README.md +1 -1
  3. package/admin/dist/domma/domma-tools.css +3 -3
  4. package/admin/dist/domma/domma-tools.min.js +3 -3
  5. package/admin/js/lib/effect-defs.js +1 -1
  6. package/admin/js/lib/effects-builder.js +2 -2
  7. package/admin/js/lib/page-editor-reference.js +5 -5
  8. package/admin/js/lib/shortcode-tags.js +1 -1
  9. package/admin/js/templates/docs/usage-shortcodes.html +39 -0
  10. package/docs/DommaFeatures.md +3005 -0
  11. package/docs/MultiTenant.md +176 -0
  12. package/docs/api-reference.md +1667 -0
  13. package/docs/cache.md +96 -0
  14. package/docs/configuration.md +1811 -0
  15. package/docs/editions-and-licences.md +190 -0
  16. package/docs/getting-started.md +201 -0
  17. package/docs/guides/form-triggers.md +260 -0
  18. package/docs/guides/mongodb-storage-adapter.md +282 -0
  19. package/docs/guides/multi-page-forms.md +121 -0
  20. package/docs/images/scribe-composer.png +0 -0
  21. package/docs/markdown-shortcodes.md +1738 -0
  22. package/docs/pentest-report-2026-04-03.md +181 -0
  23. package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
  24. package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
  25. package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
  26. package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
  27. package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
  28. package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
  29. package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
  30. package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
  31. package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
  32. package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
  33. package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
  34. package/docs/plans/2026-03-05-user-types-design.md +139 -0
  35. package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
  36. package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
  37. package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
  38. package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
  39. package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
  40. package/docs/plans/2026-06-10-core-project-design.md +240 -0
  41. package/docs/plugin-development.md +827 -0
  42. package/docs/recruitment-recipe.md +501 -0
  43. package/docs/scaffolding.md +312 -0
  44. package/docs/screenshots/calculator-floating.png +0 -0
  45. package/docs/screenshots/calculator-standard.png +0 -0
  46. package/docs/screenshots/email-settings.png +0 -0
  47. package/docs/screenshots/messages-count.png +0 -0
  48. package/docs/structure/user-types.md +107 -0
  49. package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
  50. package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
  51. package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
  52. package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
  53. package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
  54. package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
  55. package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
  56. package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
  57. package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
  58. package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
  59. package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
  60. package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
  61. package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
  62. package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
  63. package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
  64. package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
  65. package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
  66. package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
  67. package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
  68. package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
  69. package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
  70. package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
  71. package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
  72. package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
  73. package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
  74. package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
  75. package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
  76. package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
  77. package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
  78. package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
  79. package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
  80. package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
  81. package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
  82. package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
  83. package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
  84. package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
  85. package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
  86. package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
  87. package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
  88. package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
  89. package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
  90. package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
  91. package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
  92. package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
  93. package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
  94. package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
  95. package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
  96. package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
  97. package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
  98. package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
  99. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
  100. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
  101. package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
  102. package/docs/theming.md +320 -0
  103. package/package.json +2 -2
  104. package/public/js/celebrations/core/canvas.js +1 -1
  105. package/public/js/celebrations/index.js +1 -1
  106. package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
  107. package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
  108. package/public/js/celebrations/message-attrs.mjs +1 -0
  109. package/public/js/celebrations/themes/anniversary.js +1 -0
  110. package/public/js/celebrations/themes/autumn.js +1 -0
  111. package/public/js/celebrations/themes/birthday.js +1 -0
  112. package/public/js/celebrations/themes/butterfly-flight.js +1 -0
  113. package/public/js/celebrations/themes/calendar.js +1 -0
  114. package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
  115. package/public/js/celebrations/themes/chinese-new-year.js +1 -0
  116. package/public/js/celebrations/themes/christmas.js +1 -1
  117. package/public/js/celebrations/themes/easter.js +1 -0
  118. package/public/js/celebrations/themes/garden-drawing.js +1 -0
  119. package/public/js/celebrations/themes/garden.js +1 -0
  120. package/public/js/celebrations/themes/graduation.js +1 -0
  121. package/public/js/celebrations/themes/greeting.js +1 -0
  122. package/public/js/celebrations/themes/make-it-rain.js +1 -0
  123. package/public/js/celebrations/themes/money-rain.js +1 -0
  124. package/public/js/celebrations/themes/new-year.js +1 -0
  125. package/public/js/celebrations/themes/occasion-drawing.js +1 -0
  126. package/public/js/celebrations/themes/occasions.js +1 -0
  127. package/public/js/celebrations/themes/rain-control.js +1 -0
  128. package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
  129. package/public/js/celebrations/themes/sparkler-font.js +1 -0
  130. package/public/js/celebrations/themes/sparkler-writing.js +1 -0
  131. package/public/js/celebrations/themes/summer-garden.js +1 -0
  132. package/public/js/celebrations/themes/winter.js +1 -0
  133. package/public/js/celebrations/trait-data.mjs +1 -1
  134. package/public/js/effects.js +1 -1
  135. package/scripts/build.js +4 -0
  136. package/server/services/markdown.js +5 -2
  137. package/server/templates/page.html +1 -1
@@ -0,0 +1,827 @@
1
+ # Plugin Development
2
+
3
+ Plugins extend Domma CMS with new server-side routes, admin UI panels, and public-page injection snippets. Each plugin is a self-contained directory inside `plugins/`.
4
+
5
+ ---
6
+
7
+ ## Scaffolding a New Plugin
8
+
9
+ The quickest start is the built-in scaffolder: **Admin → Marketplace → New Plugin**. It instantiates `plugins/_template/` into `plugins/<slug>/` - server entry, config defaults, a wired admin view (sidebar item, route, template, CSS) and a `CLAUDE.md` - with your name, description and icon substituted throughout. The plugin is created pre-enabled; restart the server to activate it (routes and admin views only register at boot).
10
+
11
+ Programmatically: `POST /api/plugins/scaffold` with `{slug, displayName, description?, author?, icon?}` (requires the `plugins.create` permission). Slugs are lowercase letters, digits and hyphens; free-text fields may not contain `<`, `>`, `"`, backslash or `*/` since they are substituted into JSON, HTML and JS comment contexts.
12
+
13
+ ---
14
+
15
+ ## Editing a Plugin from the Admin
16
+
17
+ Every plugin card has a **Code** button opening an in-admin editor (`#/plugins/code/<name>`): file tree on the left, monospace editor on the right, `Ctrl+S` to save, plus new-file and delete-file actions. The two reload rules are surfaced on save:
18
+
19
+ - **`admin/` files** (views, templates, CSS) are fetched by the SPA - a hard refresh of the admin tab picks them up. No restart.
20
+ - **Everything else** (`plugin.js`, `config.js`, `plugin.json`, `public/` snippets) is imported or read server-side - the editor flags these saves as restart-required. The header's **Restart Server** button restarts in place (the process exits cleanly and the supervisor - fleet manager, pm2, systemd - brings it back; unsupervised `node server/server.js` runs are warned first).
21
+
22
+ **Enabling or disabling a plugin restarts the server automatically.** The same
23
+ boot-only registration rule applies to the enabled flag, so the toggle on
24
+ the admin Marketplace screen exits the process once the change is saved and waits for
25
+ the supervisor to bring it back - no manual step. It restarts only where a
26
+ supervisor is detected, and only when the flag actually changed (a settings save
27
+ never does). Turn it off with `restartOnPluginToggle: false` in
28
+ `config/server.json`; see [Configuration](configuration.md#configserverjson).
29
+
30
+ Guard rails: everything is confined to `plugins/<name>/` (traversal and symlink escapes are refused), only text extensions (`.js .json .md .html .css .txt .svg`) are editable, `.json` saves must parse (and `plugin.json` must keep its `name` matching the directory), and the three required files cannot be deleted. Access needs the **`plugins.develop`** permission - deliberately separate from `plugins.update`, because editing plugin source is code execution on the server. Base admin roles have it (bare `plugins` grants expand to all actions); grant it to custom roles explicitly via the role editor.
31
+
32
+ The scaffolded `CLAUDE.md` in each plugin also makes the directory a good target for a local Claude Code session if you prefer editing outside the browser.
33
+
34
+ ---
35
+
36
+ ## Required Structure
37
+
38
+ Every plugin must have exactly these three files:
39
+
40
+ ```
41
+ plugins/
42
+ └── my-plugin/
43
+ ├── plugin.json # Manifest (metadata + admin integration)
44
+ ├── plugin.js # Fastify plugin entry point (ESM)
45
+ └── config.js # Default settings export (ESM)
46
+ ```
47
+
48
+ Any missing mandatory file causes a startup warning and the plugin is skipped - the server does not crash.
49
+
50
+ Optional directories:
51
+
52
+ ```
53
+ plugins/my-plugin/
54
+ ├── admin/ # Admin SPA views, templates and CSS (served at /plugins/my-plugin/admin/)
55
+ │ ├── views/index.js
56
+ │ ├── templates/index.html
57
+ │ └── css/index.css
58
+ ├── public/ # Browser assets and inject snippets (served at /plugins/my-plugin/public/)
59
+ │ ├── inject-head.html
60
+ │ └── inject-body.html
61
+ ├── plugin.public.js # Optional root-level public routes (e.g. /shop)
62
+ ├── collections/<slug>/schema.json # Collections created on first enable
63
+ ├── forms/*.json, pages/**/*.md # Created on enable, removed on disable
64
+ ├── roles/*.json # Plugin roles, created once
65
+ └── data/ # Runtime state - never served, exported or shipped
66
+ ```
67
+
68
+ Only `admin/**` and `public/**` are served; `plugin.js`, `config.js` and `data/` are never reachable over HTTP.
69
+
70
+ ---
71
+
72
+ ## `plugin.json` - Manifest
73
+
74
+ ```json
75
+ {
76
+ "name": "my-plugin",
77
+ "displayName": "My Plugin",
78
+ "version": "1.0.0",
79
+ "description": "What this plugin does.",
80
+ "author": "Your Name",
81
+ "date": "2026-01-01",
82
+ "icon": "package",
83
+ "admin": {
84
+ "sidebar": [
85
+ {
86
+ "id": "my-plugin",
87
+ "text": "My Plugin",
88
+ "icon": "package",
89
+ "url": "#/plugins/my-plugin",
90
+ "section": "#/plugins/my-plugin"
91
+ }
92
+ ],
93
+ "routes": [
94
+ {
95
+ "path": "/plugins/my-plugin",
96
+ "view": "plugin-my-plugin",
97
+ "title": "My Plugin - Domma CMS"
98
+ }
99
+ ],
100
+ "views": {
101
+ "plugin-my-plugin": {
102
+ "entry": "my-plugin/admin/views/index.js",
103
+ "exportName": "myPluginView"
104
+ }
105
+ }
106
+ },
107
+ "inject": {
108
+ "head": "public/inject-head.html",
109
+ "bodyEnd": "public/inject-body.html"
110
+ },
111
+ "scaffold": {
112
+ "reset": [
113
+ { "path": "data.json", "content": "{}" }
114
+ ]
115
+ }
116
+ }
117
+ ```
118
+
119
+ ### Mandatory fields
120
+
121
+ | Field | Type | Description |
122
+ |---|---|---|
123
+ | `name` | string | Unique machine name - must match the directory name |
124
+ | `displayName` | string | Human-readable name shown in the admin Marketplace screen |
125
+ | `version` | string | Semver version string |
126
+ | `description` | string | Brief description of what the plugin does |
127
+ | `author` | string | Author name displayed in the admin Marketplace screen |
128
+ | `date` | string | Release date (ISO 8601, e.g. `"2026-01-01"`) |
129
+ | `icon` | string | Domma icon name for the plugin's card on the Marketplace screen |
130
+
131
+ ### Optional fields
132
+
133
+ | Field | Description |
134
+ |---|---|
135
+ | `admin.sidebar` | Array of sidebar nav items to add when the plugin is enabled. Each may name its default `folder` and a `group` inside it (see [Admin Sidebar Items](#6-admin-sidebar-items)) |
136
+ | `admin.routes` | Array of admin SPA route definitions |
137
+ | `admin.views` | Map of view name → `{ entry, exportName }` for admin SPA views |
138
+ | `inject.head` | Path (relative to the plugin directory) to an HTML snippet injected into `<head>` of every public page |
139
+ | `inject.headLate` | Path to an HTML snippet injected at the **end** of `<head>`, after the site stylesheets and other plugins' `inject.head` snippets (but before generated theme overrides and `content/custom.css`) - use it for CSS that must override the site's own styles |
140
+ | `inject.bodyEnd` | Path (relative to the plugin directory) to an HTML snippet injected before `</body>` of every public page |
141
+ | `scaffold.reset` | Array of `{ path, content }` objects - files to create/reset when `npm run reset` is run |
142
+ | `permissions` | Array of permission resources the plugin guards its routes with - see [Roles & Permissions](#roles--permissions) |
143
+ | `supersedes` | Array of plugin names this one replaces outright (a Pro edition of a free plugin, or a built-in Tool it takes over) - the replaced one registers nothing while this one runs |
144
+ | `requires` | Array of names this plugin cannot work without - a built-in Tool (`contacts`, `notes`, `todo`, `analytics`) or another plugin. It is not loaded while one is off; switching one off switches this off too, and switching this on brings them on (0.88.0) |
145
+ | `admin.css` | Array of admin stylesheets loaded with the plugin's views (may include `../_lib/admin/ui/help.css`) |
146
+ | `settingsSchema` | The Marketplace **Configure** form - see [Settings in the Marketplace](#settings-in-the-marketplace-configure) |
147
+ | `tier` | `free` or `paid` - used by the marketplace catalogue |
148
+ | `closedSource` | `true` for a licensed plugin: an entitlement is required, and the source editor and export are refused |
149
+ | `licence` | Overrides the licence line shown on the plugin's admin banner |
150
+ | `contentPaths` | Folders inside the plugin holding user-authored work; they are tracked in the site's git and preserved by `npx domma-cms update` |
151
+ | `npmDependencies`, `minCmsVersion` | Carried into the marketplace catalogue. `minCmsVersion` (e.g. `"0.92.2"`) is enforced: install and update refuse a plugin that needs a newer CMS (0.92.2). `npmDependencies` is informational |
152
+ | `uses` | `{name: "What is lost without it"}` - Tools or plugins this one works without but uses when they are on. Switching one off leaves this on and shows the admin the text first, so write it as a short feature phrase ("Invite a contact group"). Check at request time: `isToolEnabled(name)` from `server/services/tools.js`, or `GET /api/tools/enabled` in an admin view - and treat an engine without either (before 0.88.0) as "on" (0.88.0) |
153
+ | `docs.permission` | Who may read the plugin's user guides (`docs/*.md`, see [User guides](#user-guides-in-the-documentation-folder)). Defaults to the permission on the first `admin.sidebar` entry that names one; with neither, anyone signed in to the admin (0.93.1) |
154
+
155
+ ---
156
+
157
+ ## `plugin.js` - Server Entry Point
158
+
159
+ The default export must be an **async Fastify plugin function**. It receives the Fastify instance scoped to the plugin's route prefix (`/api/plugins/<name>/`) and an `options` object.
160
+
161
+ ```js
162
+ export default async function myPlugin(fastify, options) {
163
+ const { authenticate, requireAdmin } = options.auth;
164
+
165
+ // Public endpoint - no auth
166
+ fastify.get('/ping', async () => {
167
+ return { ok: true };
168
+ });
169
+
170
+ // Admin-only endpoint
171
+ fastify.get('/data', { preHandler: [authenticate, requireAdmin] }, async () => {
172
+ return { data: 'admin only' };
173
+ });
174
+ }
175
+ ```
176
+
177
+ ### `options.auth`
178
+
179
+ The `options` object exposes the following auth middleware helpers:
180
+
181
+ | Helper | Description |
182
+ |---|---|
183
+ | `authenticate` | Verifies the JWT Bearer token; rejects with `401` if missing or invalid |
184
+ | `requireAdmin` | Requires the authenticated user to have admin role level (level 0 or 1) |
185
+ | `requirePermission(resource, action?)` | Requires a role holding `resource` (or `resource.action`) - use this with your own resources, see below |
186
+ | `requireRole(roles)` | Requires one of the named roles as the PRIMARY role - prefer `requirePermission`, which the admin can re-assign |
187
+ | `requireVisibility(visibility)` | Gates a route with the page `visibility` syntax, including `=role` |
188
+
189
+ All routes registered inside `plugin.js` are automatically prefixed with `/api/plugins/<name>/`.
190
+
191
+ ---
192
+
193
+ ## Roles & Permissions
194
+
195
+ A plugin can bring its own permissions and its own roles - a job board's `candidate` and `employer`, say - and the
196
+ admin then controls them like any other: who holds which role, what each role may do, and which menu items and pages
197
+ each role sees.
198
+
199
+ **Permissions** are resources in the permission registry. Declare them in `plugin.json`:
200
+
201
+ ```json
202
+ {
203
+ "permissions": [
204
+ {"key": "vacancies", "label": "Vacancies", "actions": ["read", "create", "update", "delete"], "grant": ["admin"]},
205
+ {"key": "applications", "label": "Applications", "actions": ["read", "update"]}
206
+ ]
207
+ }
208
+ ```
209
+
210
+ - `actions` may be bare keys or `{key, label, description}` objects; omitted = read/create/update/delete.
211
+ - `group` (default `Plugins`) and `icon` place it in the role editor.
212
+ - `grant` gives those existing roles the permission **once**. The grant is recorded on the role, so if the admin later
213
+ removes it, it stays removed. The super-admin (level 0) holds every plugin permission without a grant.
214
+
215
+ Guard your routes with them:
216
+
217
+ ```js
218
+ const {authenticate, requirePermission} = options.auth;
219
+ fastify.post('/vacancies', {preHandler: [authenticate, requirePermission('vacancies', 'create')]}, handler);
220
+ ```
221
+
222
+ **Roles** are JSON files in `plugins/<name>/roles/`:
223
+
224
+ ```json
225
+ {
226
+ "name": "candidate",
227
+ "label": "Candidate",
228
+ "level": 3,
229
+ "permissions": ["applications.read", "vacancies.read"],
230
+ "badgeClass": "badge-info",
231
+ "resources": [{"key": "applications", "label": "Applications", "actions": ["read", "update"]}]
232
+ }
233
+ ```
234
+
235
+ - A role is created the first time the plugin is enabled and is then the admin's: a role already present is never
236
+ overwritten, so edits made at **System > Roles** survive updates and restarts.
237
+ - `level` must be 1 or more; a role file asking for level 0 is ignored. Roles on the same level are peers.
238
+ - `resources` in a role file is the older place to declare permissions and still works.
239
+ - `admin` confines the role to your plugin's screens: `{"home": "/my-plugin", "allow": ["/my-plugin"]}`.
240
+ `allow` lists admin route prefixes (`/my-plugin` also admits `/my-plugin/items`); `home` is where the user lands
241
+ and where any other route sends them, defaulting to the first allowed route. My Profile is always open. A user is
242
+ confined only if EVERY role they hold is, so giving someone `admin` as an additional role lifts it. The admin can
243
+ change this per role under **Roles > Admin area**. It keeps confined users on screens that work for them; the API
244
+ permission checks are still what protects data.
245
+ - Disabling the plugin unregisters its permissions but **keeps its roles**, so users holding them are not demoted.
246
+ The admin can delete a role that is no longer wanted.
247
+
248
+ Permissions are registered on every boot for every enabled plugin (they live in memory); roles are created once.
249
+
250
+ **From code: `hooks.registerRole()` / `hooks.unregisterRole()`.** For roles a plugin decides on at runtime (from
251
+ its settings, say), use the hooks instead of a file. Both are bound to your plugin, so they can only ever touch roles
252
+ your plugin created. They are on `options.hooks` in `plugin.js`, and on the `hooks` argument of `onEnable` /
253
+ `onDisable`.
254
+
255
+ ```js
256
+ export default async function plugin(fastify, options) {
257
+ const {registerRole} = options.hooks;
258
+ await registerRole({
259
+ name: 'candidate', label: 'Candidate', level: 3,
260
+ permissions: ['applications.read'],
261
+ resources: [{key: 'applications', label: 'Applications', actions: ['read', 'update'], grant: ['admin']}]
262
+ });
263
+ }
264
+
265
+ export async function onDisable({hooks, uninstall}) {
266
+ if (uninstall) await hooks.unregisterRole('candidate');
267
+ }
268
+ ```
269
+
270
+ | Hook | Returns | Behaviour |
271
+ |---|---|---|
272
+ | `registerRole(def)` | `{name, created}` | Same fields as a role file (`name`, `level` ≥ 1, `label?`, `permissions?`, `badgeClass?`, `resources?`, `admin?`). Creates the role once; calling it again for a role your plugin already owns is a no-op, so it is safe on every boot and admin edits are kept. **Throws** for an invalid definition, or a name the site or another plugin already holds. |
273
+ | `unregisterRole(name)` | `{name, removed, usersReassigned}` | Removes a role your plugin owns. Users holding it as their primary role are moved to `user`; it is dropped from everyone's additional roles. A name that does not exist answers `removed: false`. **Throws** for a role your plugin does not own. |
274
+
275
+ **Removal vs. disable.** Disabling a plugin keeps its roles, so a pause demotes nobody. Uninstalling it (Marketplace
276
+ > Uninstall) calls `onDisable` with `uninstall: true` and then removes every role the plugin still owns, the same way
277
+ `unregisterRole` does, so a plugin that forgets to clean up leaves nothing behind.
278
+
279
+ **Showing things to one role.** Menu items and pages take `visibility: "=candidate"` - candidates only, not employers
280
+ and not admins (the super-admin excepted). Plain `visibility: candidate` means "candidate and anyone more senior,
281
+ including roles on the same level". Admin sidebar items (`admin.sidebar` or `registerSidebarItem`) accept the same
282
+ `visibility` field as well as `permission`, and a user who can see nothing else still gets a sidebar - the menu comes
283
+ from `GET /api/sidebar/menu`, which needs only a signed-in user.
284
+
285
+ ---
286
+
287
+ ## `config.js` - Default Configuration
288
+
289
+ The default export must be a plain object containing the plugin's default settings. This object is merged with any user-defined overrides stored in `config/plugins.json` at runtime - user values take precedence.
290
+
291
+ ```js
292
+ export default {
293
+ excludeAdmin: true,
294
+ trackingInterval: 30,
295
+ storageBackend: 'file'
296
+ };
297
+ ```
298
+
299
+ Access merged settings in `plugin.js` via `options.settings` if you need them at registration time, or call `getPluginSettings(name)` from `server/services/plugins.js` inside route handlers.
300
+
301
+ ### Settings in the Marketplace (Configure)
302
+
303
+ Every plugin with a `config.js` can be configured from **System › Marketplace** - right-click it (or click
304
+ it) → settings. With no schema, the form is built from the effective settings, typed from their values
305
+ (boolean → switch, number → number box, object → JSON). To control labels, order, choices and help, declare
306
+ `settingsSchema` in `plugin.json`:
307
+
308
+ ```json
309
+ "settingsSchema": [
310
+ {"key": "maxBackups", "label": "Backups to keep", "type": "number", "min": 1, "max": 500,
311
+ "help": "The oldest beyond this are removed automatically."},
312
+ {"key": "mode", "label": "Mode", "type": "select", "options": ["fast", "safe"]},
313
+ {"key": "enabled", "label": "Enabled", "type": "boolean"},
314
+ {"key": "dvlaApiKey", "label": "DVLA API key", "type": "text", "secret": true}
315
+ ]
316
+ ```
317
+
318
+ Types: `text`, `number`, `select`, `boolean`. Saving writes only what differs from `config.js`.
319
+
320
+ **Secrets.** A value whose key looks secret (`password`, `secret`, `token`, `apiKey`, `private`,
321
+ `credential`, `…Uri`, `dsn`), or a field marked `"secret": true`, is sent to the browser masked; saving an
322
+ untouched secret keeps the stored value. Read secrets on the server with `getPluginSettings()` at request
323
+ time (not once at registration), so a new value works without a restart.
324
+
325
+ ---
326
+
327
+ ## Lifecycle Hooks
328
+
329
+ Plugins receive a `hooks` object via `options.hooks`. Hooks are bound to the plugin's name where it matters (sources,
330
+ roles and notifications are stamped with it). The sections below cover the main ones in detail; the full set is:
331
+
332
+ | Hook | Purpose |
333
+ |---|---|
334
+ | `on(event, fn)` | Subscribe to the event bus - see [Event Bus](#1-event-bus) |
335
+ | `registerShortcode(name, handler, {priority})` | Add a `[name]` shortcode - see [Custom Shortcodes](#2-custom-shortcodes) |
336
+ | `registerSanitizeRules({tags, attributes})` | Let custom tags/attributes through the HTML sanitiser |
337
+ | `registerTransform(hookName, fn, {priority})` | Modify values at a named hook point - see [Transform Pipeline](#4-transform-pipeline) |
338
+ | `registerMenuLocation(slot, {label, description, maxDepth})` | Add a menu slot - see [Menu Locations](#5-menu-locations) |
339
+ | `registerSidebarItem({folder?, parent?, item})` | Add an admin sidebar Tool at runtime - see [Admin Sidebar Items](#6-admin-sidebar-items) |
340
+ | `registerThemes(provider)` | Offer custom themes - see [Custom Themes](#7-custom-themes) (0.73.0) |
341
+ | `registerRole(def)` / `unregisterRole(name)` | Create or remove roles from code - only the plugin's own (0.71.0) |
342
+ | `registerCalendarSource({id, label, icon, tone, list})` | Dated items shown by the Calendar plugin, per viewer and date range (0.79.0) |
343
+ | `getCalendarSources()` | Read the registered calendar sources (used by the Calendar plugin) |
344
+ | `registerRelatedItems(kind, provider)` | Show items beside a core contact or note, with menu actions (0.79.0). Kind `message` (0.103.0): actions on an email's menu in the mail reader, `{email}`/`{name}`/`{subject}` in the url; any action can carry `permission: {resource, action}` |
345
+ | `enableInstantCalls()` | A paid Tool with calls switches on the CMS's instant calls ("Call Sam now…" on contacts and emails, `#/calls/new`, `/api/calls`, `/_call/<id>`). Bound to the plugin's name; refused unless the manifest is `tier: "paid"` AND `closedSource: true` (so it loads only under a licence). Disabling the plugin takes its hold off at once. Returns true when on. Call it with `?.()` - older cores lack it |
346
+ | `registerContactAccess(fn)` | A plugin that shares contacts says which contacts that are not the viewer's own they may read: `fn(entries, {id, roles})` resolves to the readable entry ids. Core code taking a contact id (instant calls) uses it through `readableContactIds()` |
347
+ | `registerSitemapSource({id, label, list})` / `sitemapChanged()` | Add public URLs to `/sitemap.xml`; call `sitemapChanged()` when they change (otherwise they show within ten minutes) (0.89.0) |
348
+ | `notify({...})` / `resolveNotification(key)` | Raise or clear an admin notification (source ids are `<plugin>:<id>`) (0.80.0) |
349
+ | `registerNotificationSource({id, label, severity, defaultOn, every, atBoot, check, audience})` | A switchable notification source, optionally scheduled (`boot`, `15m`, `hourly`, `daily`) (0.80.0) |
350
+ | `registerLoginCheck({id, phase, check, verify?})` | A sign-in check: `phase: 'before'` (e.g. lockout, before the password is checked) or `'after'` (e.g. two-factor, can ask one more question) (0.83.0) |
351
+ | `registerPasswordCheck({id, check})` | A password rule applied wherever a password is set (0.83.0) |
352
+ | `registerComponent({name, source})` | Add a Domma component from a `.dmc` source string, used on pages as `<dm-name>` or `[component]`; it is listed read-only in Components (0.92.2) |
353
+
354
+ `onEnable` / `onDisable` exports receive `{fastify, services, hooks}` where `hooks` holds only the role and notification
355
+ hooks; `plugin.public.js` receives only the notification hooks. `hideSidebarUrl` and the
356
+ `unregister*` counterparts of the source hooks are exported from `server/services/hooks.js` but are **not** handed to
357
+ plugins in `options.hooks`.
358
+
359
+ ```js
360
+ export default async function myPlugin(fastify, options) {
361
+ const { on, registerShortcode, registerSanitizeRules, registerTransform } = options.hooks;
362
+ // ...
363
+ }
364
+ ```
365
+
366
+ ---
367
+
368
+ ### 1. Event Bus
369
+
370
+ Subscribe to fire-and-forget lifecycle events emitted by the CMS. Handlers receive a payload object and must not return
371
+ a value - errors thrown inside handlers are caught and logged.
372
+
373
+ ```js
374
+ options.hooks.on('content:pageCreated', ({ page, urlPath }) => {
375
+ console.log('New page created:', urlPath);
376
+ });
377
+ ```
378
+
379
+ #### Content events
380
+
381
+ | Event | Payload | Fired when |
382
+ |-------------------------|----------------------|-------------------------------------------------------------|
383
+ | `content:pageCreated` | `{ page, urlPath }` | A page is created via the admin API |
384
+ | `content:pageRenamed` | `{ from, to }` | A page's URL path changes |
385
+ | `content:pageUpdated` | `{ page, urlPath }` | A page is saved or restored from version history |
386
+ | `content:pageDeleted` | `{ urlPath }` | A page is deleted |
387
+ | `content:mediaUploaded` | `{ filename }` | A file is uploaded to the media library |
388
+ | `content:mediaDeleted` | `{ filename }` | A file is deleted from the media library |
389
+ | `content:pageViewed` | `{ urlPath, title }` | A published public page is successfully rendered and served |
390
+
391
+ #### Collection events
392
+
393
+ | Event | Payload | Fired when |
394
+ |---------------------------|-----------------------------------|-------------------------------|
395
+ | `collection:entryCreated` | `{ slug, entryId, data, userId }` | A collection entry is created |
396
+ | `collection:entryUpdated` | `{ slug, entryId, data, userId }` | A collection entry is updated |
397
+ | `collection:entryDeleted` | `{ slug, entryId, userId }` | A collection entry is deleted |
398
+
399
+ #### Form events
400
+
401
+ | Event | Payload | Fired when |
402
+ |------------------|--------------------------------------|------------------------------------------------------------------------------------------------------------------|
403
+ | `form:submitted` | `{ slug, entryId, data, formTitle }` | A public form submission passes validation and is stored. `entryId` is `null` if collection storage is disabled. |
404
+
405
+ #### Auth events
406
+
407
+ | Event | Payload | Fired when |
408
+ |------------------------|-------------------------------------------------|----------------------------------------------------------------------------------------------|
409
+ | `user:loggedIn` | `{ userId, email, role }` | A user successfully authenticates via the login endpoint |
410
+ | `auth:loginSucceeded` | `{ userId, email, role, ip, ua, sid }` | The same, with where from and the new session's id |
411
+ | `auth:loginFailed` | `{ email, ip, ua, reason, userId?, by? }` | A sign-in is refused - `reason`: `unknown`, `inactive`, `password` or `denied` (a check) |
412
+ | `auth:loggedOut` | `{ userId, sid, ip, by }` | A session is signed out |
413
+ | `auth:rateLimited` | `{ ip, ua, route }` | The sign-in rate limit refuses an address |
414
+ | `auth:passwordChanged` | `{ userId, by, ip, ua? }` | A password changes - `by` is `'reset'` (a reset link) or the id of whoever changed it |
415
+ | `auth:resetRequested` | `{ email, userId, ip, ua, sent, reason }` | Someone asks "Forgot your password?" - after the reply; `reason`: `sent`, `unknown`, `inactive`, `throttled`, `no-smtp`, `no-origin`, `failed` |
416
+ | `auth:resetIssued` | `{ userId, by, via }` | An administrator makes a reset link from Users - `via`: `email` or `link` |
417
+
418
+ Every `auth:passwordChanged` also emails the account holder ("your password was changed"), when a mail server is
419
+ set up.
420
+
421
+ #### Other events
422
+
423
+ | Event | Fired when |
424
+ |---|---|
425
+ | `user:deleted` | A user account is deleted |
426
+ | `contact:deleted` | A contact is deleted from the built-in Contacts Tool |
427
+ | `note:deleted` | A note is deleted from the built-in Notes Tool |
428
+ | `audit` | Every successful POST, PUT, PATCH or DELETE under `/api/` by a signed-in user - `{actor, action, route, target, fields, status, ip}` (field names only, never values). Core stores nothing; Security Pro keeps the trail |
429
+
430
+ ---
431
+
432
+ ### 2. Custom Shortcodes
433
+
434
+ Register a handler for a custom `[shortcode]` tag processed in page Markdown. Lower priority values run first (default
435
+ `10`).
436
+
437
+ ```js
438
+ options.hooks.registerShortcode('my-tag', (attrStr, body, context) => {
439
+ // attrStr - raw attribute string, e.g. 'title="Hello" color="blue"'
440
+ // body - inner content string, or null for self-closing [my-tag /]
441
+ // context - { parseShortcodeAttrs, marked, processCardBlocks, processGridBlocks }
442
+ const { title } = context.parseShortcodeAttrs(attrStr);
443
+ return `<div class="my-tag"><h3>${title}</h3>${body || ''}</div>`;
444
+ }, { priority: 10 });
445
+ ```
446
+
447
+ Shortcode handlers run synchronously during Markdown processing. Self-closing shortcodes (`[name attrs /]`) receive
448
+ `null` as `body`.
449
+
450
+ ---
451
+
452
+ ### 3. Sanitize Rule Extensions
453
+
454
+ Extend the HTML sanitizer whitelist so that custom tags and attributes output by your shortcode are not stripped.
455
+
456
+ ```js
457
+ options.hooks.registerSanitizeRules({
458
+ tags: ['my-tag', 'my-widget'],
459
+ attributes: { 'my-tag': ['data-config', 'data-theme'] }
460
+ });
461
+ ```
462
+
463
+ Without registering custom tags here, any non-standard HTML your shortcode emits will be sanitized out before it reaches
464
+ the browser.
465
+
466
+ ---
467
+
468
+ ### 4. Transform Pipeline
469
+
470
+ Register a function at a named hook point to intercept and modify values as they flow through the Markdown and render
471
+ pipelines. Lower priority values run first.
472
+
473
+ ```js
474
+ options.hooks.registerTransform('markdown:afterParse', (html, context) => {
475
+ // Wrap every <table> in a scroll container
476
+ return html.replace(/<table/g, '<div class="table-scroll"><table')
477
+ .replace(/<\/table>/g, '</table></div>');
478
+ }, { priority: 20 });
479
+ ```
480
+
481
+ #### Available hook points
482
+
483
+ | Hook name | Value type | Context | Description |
484
+ |------------------------|------------|------------|-----------------------------------------------------------------------------------|
485
+ | `markdown:beforeParse` | `string` | `{}` | Raw Markdown string, before shortcode processing and `marked.parse()` |
486
+ | `markdown:afterParse` | `string` | `{}` | Sanitised HTML string, after parsing and `sanitize-html` |
487
+ | `render:beforeRender` | `object` | `{ page }` | Template variable map (title, body, headInject, etc.) before string interpolation |
488
+ | `render:afterRender` | `string` | `{ page }` | Final assembled HTML page string, just before it is sent to the browser |
489
+ | `seo:meta` | object | see `server/services/seo/` | The page's SEO meta before it is written into `<head>` (0.89.0) |
490
+ | `sitemap:entries` | array | see `server/services/seo/` | The `/sitemap.xml` entries before they are serialised (0.89.0) |
491
+ | `robots:lines` | array | see `server/services/seo/` | The `/robots.txt` lines before they are joined (0.89.0) |
492
+
493
+ Transforms run **synchronously** in the render path - keep them fast.
494
+
495
+ ---
496
+
497
+ ### 5. Menu Locations
498
+
499
+ Register a named slot that admins can map a menu to in the **Menus → Locations** editor. The slot becomes available to the public renderer and to the `[menu location="…" /]` shortcode. Call this during plugin registration (synchronously inside the plugin function, or from inside `setupPlugin()`).
500
+
501
+ ```js
502
+ options.hooks.registerMenuLocation('sidebar', {
503
+ label: 'Sidebar',
504
+ description: 'Renders in the left sidebar of pages with sidebar layout',
505
+ maxDepth: 1
506
+ });
507
+ ```
508
+
509
+ | Field | Required | Description |
510
+ |---------------|----------|--------------------------------------------------------------------------|
511
+ | `label` | yes | Human-readable slot name shown in the admin Locations table |
512
+ | `description` | no | What the slot is for and where it renders |
513
+ | `maxDepth` | no | Advisory depth cap - the admin Locations UI shows a depth-overflow warning when the mapped menu exceeds it, but rendering still attempts to flatten beyond the cap |
514
+
515
+ Slot names must match `/^[a-z0-9][a-z0-9-]*$/` (the same shape as menu slugs). Re-registering an existing slot logs a warning and the second registration wins (last-write semantics - consistent with other plugin hooks). Core registers `navbar`, `footer-primary`, `footer-legal`, and `admin-sidebar` at startup.
516
+
517
+ ---
518
+
519
+ ### 6. Admin Sidebar Items
520
+
521
+ Every admin screen is a **Tool**, and each Tool has a **default folder** in the admin sidebar. An
522
+ enabled plugin's entries go to **Tools** unless they name another folder; an admin can then move any
523
+ Tool anywhere in the sidebar (Content > Menus > Admin sidebar), and that choice wins.
524
+
525
+ Most plugins declare their entries in `plugin.json` and never call anything:
526
+
527
+ ```json
528
+ "admin": {
529
+ "sidebar": [
530
+ {"text": "Invoices", "url": "#/plugins/invoice", "icon": "file-text", "folder": "data"}
531
+ ]
532
+ }
533
+ ```
534
+
535
+ From code, register the same thing with the hook (inside `setupPlugin()`, or synchronously in the
536
+ plugin function):
537
+
538
+ ```js
539
+ options.hooks.registerSidebarItem({
540
+ folder: 'system',
541
+ item: {
542
+ text: 'My Plugin',
543
+ url: '#/plugins/my-plugin',
544
+ icon: 'puzzle',
545
+ permission: 'my-plugin'
546
+ }
547
+ });
548
+ ```
549
+
550
+ | Field | Required | Description |
551
+ |----------|----------|-------------|
552
+ | `folder` | no | The key of the folder the Tool lives in by default: `overview`, `projects`, `content`, `data`, `tools`, `system` or `documentation`. Defaults to `tools`. May also be given as `item.folder`. A folder that cannot be found falls back to Tools. |
553
+ | `group` | no | A sub-folder of `folder` to sit in, by name (`"Core"` puts the Tool in Tools > Core). The group is keyed `<folder>.<slug>` (`tools.core`) and made at render time if the menu has none; Tools that name the same group share it. May also be given as `item.group`. Leave it out to sit directly in the folder. |
554
+ | `parent` | no | Older form: the **text** of a node to nest under. Still honoured; prefer `folder`, which survives an admin renaming the folder. |
555
+ | `item` | yes | `{text, url, icon?, permission?, visibility?, countUrl?, hotkey?, items?}` - same shape as menu items (see `docs/configuration.md`). |
556
+
557
+ **Hotkeys.** Give the entry `"hotkey": "e"` and **G then E** opens your Tool from anywhere in the admin;
558
+ the sidebar shows it on hover and beside **Open** in the Tool's right-click menu, and **?** lists it.
559
+ One letter (`a`-`z`, not `g`, which starts the sequence) or a comma; digits are the Frequent folder's.
560
+ Every Tool also appears in the palette (Ctrl+K or Ctrl+D) by its name, hotkey or not. The built-in
561
+ Tools claim their letters first (`CORE_HOTKEYS` in `admin/js/lib/hotkeys.js`: D Dashboards, P Pages,
562
+ M Media, L Collections, F Forms, V Views, U Users, A Analytics, N Notes, T Todo, C Contacts, `,` Site
563
+ Settings); after that the first Tool in the sidebar to ask for a letter has it, and a later one goes
564
+ without - the browser console names it. A Pro Tool that supersedes another takes over its entry, so it
565
+ asks for the same letter (Blog Pro, like Blog, is B). Ours: E Email, R Mail Reader, B Blog, K Booking,
566
+ Y Calendar, C Contacts Pro, O Docs, I Invoices, H Shop, Q Surveys, W Waypoint, J Job Board, S Support.
567
+
568
+
569
+ **Placement.** Registered items are **not persisted** into `config/menus/admin-sidebar.json`; plugins
570
+ re-register on every boot and the renderer places them at render time
571
+ (`placeTools()` in `admin/js/lib/sidebar-grouping.js`):
572
+
573
+ 1. If the admin-sidebar menu has an item with the Tool's `url`, the Tool renders **there** - that is
574
+ an admin's move. Its live properties (`countUrl`, permission, sub-items) still come from your
575
+ registration; the admin's label and icon win.
576
+ 2. Otherwise it goes into its default `folder`, matched by the folder's `key`, so a folder the admin
577
+ renamed still receives its Tools - and, if it names a `group`, into that sub-folder: found by its
578
+ key anywhere (an admin may have moved the whole group), else by name inside the folder, else made.
579
+ A made group is saved only once the admin saves something in it.
580
+ 3. A placeholder `{url, ref: true}` for a Tool that is not enabled is dropped, so disabling a plugin
581
+ leaves no dead link and re-enabling it puts it back where the admin had it.
582
+
583
+ A Tool is identified by its `url`, never its text - give each entry a unique url.
584
+
585
+ **Moving and hiding a Tool.** In Content > Menus > Admin sidebar every enabled Tool is listed where it
586
+ appears, badged with the plugin it comes from. Move, hide or relabel it like any item and Save; only
587
+ the Tools you placed are saved, as `{url, ref: true}` placeholders, so the rest keep following their
588
+ default folder. A Tool's address and permission cannot be changed there,
589
+ and its bin puts it back in its default place. (The older same-`text`, url-less hidden item is still
590
+ honoured.)
591
+
592
+
593
+ ### 7. Custom Themes
594
+
595
+ Offer themes that join the built-in ones everywhere a theme is chosen (site, admin, day/night,
596
+ element overrides). The provider is a function, called on every read - including every page render -
597
+ so keep it synchronous and in memory:
598
+
599
+ ```js
600
+ options.hooks.registerThemes(() => [
601
+ {id: 'brand', label: 'Brand', baseTheme: 'ocean-dark', tokens: {'--dm-primary': '#1144ff'}}
602
+ ]);
603
+ ```
604
+
605
+ | Field | Description |
606
+ |-------------|-------------|
607
+ | `id` | Slug (`a-z0-9-`, 60 max). Must not be a built-in theme id; the first provider to claim an id keeps it. |
608
+ | `label` | Shown in the pickers. |
609
+ | `baseTheme` | A built-in theme id; an unknown one falls back to `charcoal-dark`. |
610
+ | `tokens` | `--dm-*` tokens and values; anything else, or a value that could break out of its declaration, is dropped. |
611
+
612
+ The CMS applies a custom theme as `dm-theme-<base> dm-custom-<id>` - see `docs/theming.md`. The hook is
613
+ bound to your plugin: registering again replaces your themes, never another plugin's. It exists from
614
+ domma-cms 0.73; check `typeof options.hooks.registerThemes === 'function'` to degrade on older ones.
615
+
616
+ ---
617
+
618
+ ## Admin Views Integration
619
+
620
+ To add a page to the admin SPA, declare `admin.routes` and `admin.views` in `plugin.json`.
621
+
622
+ ### View file
623
+
624
+ Admin views follow the same pattern as core admin views:
625
+
626
+ ```js
627
+ // plugins/my-plugin/admin/views/index.js
628
+ export const myPluginView = {
629
+ templateUrl: '/plugins/my-plugin/admin/templates/index.html',
630
+ async onMount($container) {
631
+ // H.get adds the auth header and refreshes the token for you -
632
+ // do not hand-build a Bearer header with fetch().
633
+ const res = await H.get('/api/plugins/my-plugin/data');
634
+ const data = res.data ?? res;
635
+ $container.find('#my-output').text(data);
636
+ }
637
+ };
638
+ ```
639
+
640
+ The view object must have:
641
+
642
+ - `templateUrl` - URL of the HTML template (plugin admin files are served at `/plugins/<name>/admin/...`)
643
+ - `onMount($container)` - called after the template is rendered; receives the Domma-wrapped container element
644
+
645
+ ### Template file
646
+
647
+ ```html
648
+ <!-- plugins/my-plugin/admin/templates/index.html -->
649
+ <div class="plugin-my-plugin">
650
+ <h2>My Plugin</h2>
651
+ <p id="my-output">Loading...</p>
652
+ </div>
653
+ ```
654
+
655
+ Use Domma class names (`card`, `btn`, `form-input`, `flex`, `justify-between` and so on), not Bootstrap ones such as
656
+ `container` or `py-4`. The admin already wraps every plugin view in a banner (`.dm-plugin-shell`) showing the plugin's
657
+ name, version and licence - do not draw your own. `plugins/CLAUDE.md` and the template plugin's `CLAUDE.md` list the
658
+ other conventions.
659
+
660
+
661
+ ### User guides in the Documentation folder
662
+
663
+ Ship user documentation for site owners as Markdown in `docs/` inside the plugin (0.93.1):
664
+
665
+ ```
666
+ plugins/my-plugin/docs/guide.md first page (tab "Guide")
667
+ plugins/my-plugin/docs/settings.md further pages, as tabs
668
+ ```
669
+
670
+ While the plugin is loaded (enabled, licensed, not superseded) each file is a page under
671
+ **Documentation > Plugins > <Display name>** (`#/docs/plugins/<name>[/<page>]`), and `#/docs/plugins` lists every
672
+ guide as a card. A guide is shown only to users who can use the plugin - see `docs.permission` in the manifest
673
+ fields. Optional frontmatter: `title` (the tab label; default from the file name, `guide.md` reads "Guide") and
674
+ `order` (lowest first; `guide.md` defaults to 0, others to 100).
675
+
676
+ - Plain GitHub-flavoured Markdown, sanitised on the server. Shortcodes are NOT run, so `[collection ...]`
677
+ examples in a fenced block show literally - which is what a guide wants.
678
+ - Do not open with an H1: the page header already shows the plugin name and page title. Use `##` sections.
679
+ - Admin hash links (`[Invoices](#/plugins/invoice)`) stay in the admin; `https://` links open a new tab.
680
+ - Guides are read at boot with the plugin, like its sidebar entry - a new or changed file shows after a restart.
681
+ - Suggested sections: What it does, Getting started, one `###` per screen, Settings, Shortcodes, Permissions and
682
+ roles, Tips, Limitations. Write for site owners, not developers - developer notes belong in `CLAUDE.md`.
683
+
684
+ ---
685
+
686
+ ## Dashboard widgets
687
+
688
+ With **Dashboard Pro**, admins build their own Dashboard from widgets the Tools offer. A widget ships
689
+ no code: it names a `kind` the admin knows how to draw and a `dataUrl` that answers in that kind's
690
+ shape, so every widget looks like part of the same admin.
691
+
692
+ **You may already have one.** Every sidebar entry with a `countUrl` is offered as a `count` widget
693
+ automatically - the badge's own answer (`{count, label, tone, details}`) is drawn as a big number with
694
+ its `details` rows and items underneath. Email, Booking, Support and the rest arrive this way.
695
+
696
+ For anything more, list widgets in `plugin.json`:
697
+
698
+ ```json
699
+ "admin": {
700
+ "widgets": [
701
+ {
702
+ "id": "blog:latest",
703
+ "title": "Latest posts",
704
+ "description": "The five newest posts and their status.",
705
+ "icon": "edit",
706
+ "kind": "list",
707
+ "size": "m",
708
+ "dataUrl": "/api/plugins/blog/widget/latest",
709
+ "href": "#/plugins/blog",
710
+ "permission": "blog",
711
+ "refresh": 120
712
+ }
713
+ ]
714
+ }
715
+ ```
716
+
717
+ | Field | Meaning |
718
+ |---|---|
719
+ | `id` | Unique; lowercase, digits, `: . _ / -`. Prefix it with your plugin's name. |
720
+ | `kind` | `count`, `stat`, `list` or `chart` - see below |
721
+ | `dataUrl` | An `/api/...` route of yours answering in the kind's shape, as the signed-in user |
722
+ | `size` | Its usual width on the four-column board: `s`, `m`, `l` or `xl` (full width) |
723
+ | `href` | The admin screen its title opens (`#/...`) |
724
+ | `permission` | Offered only to users holding it (any action on the resource) |
725
+ | `admin` | `true`: administrators only |
726
+ | `refresh` | Seconds between refreshes (15-3600, default 60). A `dm:sidebar-count` for its `href` refreshes it at once too. |
727
+
728
+ | Kind | `dataUrl` answers | Drawn as |
729
+ |---|---|---|
730
+ | `count` | `{count, label, tone, details: {rows, items}}` - a sidebar badge | big number, its rows and items |
731
+ | `stat` | `{value, label, sub, tone}` | big value with a line under it |
732
+ | `list` | `{items: [{text, meta, value, href, tone}], empty}` | rows; one with `href` opens it |
733
+ | `chart` | `{labels: [], values: [], label}` | a line chart |
734
+
735
+ Everything is drawn with `textContent`, so a widget's data can never inject markup. `tone` is `ok`,
736
+ `warn`, `danger`, `info` or `attention`. A route should answer quickly - every widget on a board asks
737
+ on its own, and again on its refresh. From code, `hooks.registerWidget(def)` takes the same object.
738
+ Widgets go away when your plugin is disabled.
739
+
740
+ Without Dashboard Pro the widgets are simply not offered: the fixed Dashboard is unchanged.
741
+
742
+ ## Head/Body Injection
743
+
744
+ Injection snippets are plain HTML files included verbatim into every rendered public page. They are loaded once at startup and cached. Paths are confined to the plugin's folder. Three slots exist: `inject.head` (early in `<head>`), `inject.headLate` (end of `<head>`, after the site stylesheets - for CSS that must win) and `inject.bodyEnd` (before `</body>`).
745
+
746
+ **`public/inject-head.html`** - injected inside `<head>`:
747
+
748
+ ```html
749
+ <script>
750
+ window.__MY_PLUGIN_CONFIG__ = { version: '1.0.0' };
751
+ </script>
752
+ ```
753
+
754
+ **`public/inject-body.html`** - injected just before `</body>`:
755
+
756
+ ```html
757
+ <script src="/plugins/my-plugin/public/tracker.js" defer></script>
758
+ ```
759
+
760
+ Static files in the plugin's `public/` directory are served at `/plugins/<name>/public/`.
761
+
762
+ ---
763
+
764
+ ## Minimal Plugin Skeleton
765
+
766
+ Below is the smallest possible working plugin:
767
+
768
+ **`plugins/hello-world/plugin.json`**
769
+
770
+ ```json
771
+ {
772
+ "name": "hello-world",
773
+ "displayName": "Hello World",
774
+ "version": "1.0.0",
775
+ "description": "A minimal example plugin.",
776
+ "author": "Your Name",
777
+ "date": "2026-01-01",
778
+ "icon": "smile"
779
+ }
780
+ ```
781
+
782
+ **`plugins/hello-world/plugin.js`**
783
+
784
+ ```js
785
+ export default async function helloWorldPlugin(fastify, options) {
786
+ fastify.get('/hello', async () => {
787
+ return { message: 'Hello from my plugin!' };
788
+ });
789
+ }
790
+ ```
791
+
792
+ **`plugins/hello-world/config.js`**
793
+
794
+ ```js
795
+ export default {};
796
+ ```
797
+
798
+ After enabling the plugin on the admin **Marketplace** screen, the endpoint `GET /api/plugins/hello-world/hello` will return `{ "message": "Hello from my plugin!" }`.
799
+
800
+ ---
801
+
802
+ ## Bundled Plugins Reference
803
+
804
+ These free plugins ship in the core package's `plugins/` directory and are good references:
805
+
806
+ | Plugin | Directory | Description |
807
+ |---------------|------------------|-----------------------------------------------------------------------------------------------|
808
+ | Blog | `blog/` | Public routes, collections, admin views and its own shortcodes - the fullest example here |
809
+ | Mail Reader | `mail-reader/` | Read-only IMAP mailbox reader; a permission-guarded admin view over an external service |
810
+ | Security | `security/` | Sign-in lockout and password rules via `registerLoginCheck` / `registerPasswordCheck`, a health check, sign-in log and scanner blocking |
811
+ | Shopping Cart | `shopping-cart/` | Products, storefront and cart with root-level public routes (`plugin.public.js`) and payment providers |
812
+
813
+ Analytics, effects, the theme switcher, Notes, Todo, Contacts and the demo viewer used to be plugins and are now part
814
+ of the core CMS. Theme Roller and the other paid plugins are installed from the Marketplace.
815
+
816
+ ## Signed plugins and `requireSignedPlugins`
817
+
818
+ Plugins from the marketplace are distributed as signed `.dcmsplugin` bundles, and the signature is verified at install.
819
+ A bundle whose signature does not verify is always refused. An **unsigned** bundle (for example one exported from
820
+ another running site) is only accepted when the installer explicitly confirms it - and never when the site sets:
821
+
822
+ ```json
823
+ // config/server.json
824
+ { "requireSignedPlugins": true }
825
+ ```
826
+
827
+ With that set, only signed bundles can be installed.