domma-cms 0.55.1 → 0.69.3

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 (305) hide show
  1. package/CLAUDE.md +194 -3
  2. package/README.md +18 -14
  3. package/admin/css/admin.css +1 -1
  4. package/admin/css/contacts.css +1 -0
  5. package/admin/dist/domma/domma-tools.css +3 -3
  6. package/admin/dist/domma/domma-tools.min.js +3 -3
  7. package/admin/index.html +1 -0
  8. package/admin/js/api.js +1 -1
  9. package/admin/js/app.js +4 -4
  10. package/admin/js/lib/analytics-shape.js +1 -0
  11. package/admin/js/lib/contacts-arrange.js +1 -0
  12. package/admin/js/lib/help-popover.js +1 -0
  13. package/admin/js/lib/notes-arrange.js +1 -0
  14. package/admin/js/lib/page-picker.js +1 -0
  15. package/admin/js/lib/plugin-accent.js +1 -0
  16. package/admin/js/lib/plugin-chrome.js +1 -0
  17. package/admin/js/lib/shortcode-context-menu.js +2 -2
  18. package/admin/js/lib/sidebar-grouping.js +1 -1
  19. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  20. package/admin/js/lib/sidebar-renderer.js +4 -4
  21. package/admin/js/lib/slideover-resizable.js +1 -0
  22. package/admin/js/lib/todo-arrange.js +1 -0
  23. package/admin/js/lib/tool-kit.js +1 -0
  24. package/admin/js/templates/analytics.html +138 -0
  25. package/admin/js/templates/contacts.html +156 -0
  26. package/admin/js/templates/context-menu-editor.html +212 -0
  27. package/admin/js/templates/context-menus.html +16 -0
  28. package/admin/js/templates/notes.html +137 -0
  29. package/admin/js/templates/plugin-code.html +1 -1
  30. package/admin/js/templates/plugin-marketplace.html +17 -4
  31. package/admin/js/templates/plugins.html +13 -7
  32. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  33. package/admin/js/templates/settings.html +16 -97
  34. package/admin/js/templates/theme.html +211 -0
  35. package/admin/js/templates/todo.html +113 -0
  36. package/admin/js/views/analytics.js +62 -0
  37. package/admin/js/views/contacts.js +174 -0
  38. package/admin/js/views/context-menu-editor.js +55 -0
  39. package/admin/js/views/context-menus.js +5 -0
  40. package/admin/js/views/form-editor.js +7 -7
  41. package/admin/js/views/index.js +1 -1
  42. package/admin/js/views/notes.js +104 -0
  43. package/admin/js/views/plugin-marketplace.js +1 -1
  44. package/admin/js/views/plugins.js +28 -24
  45. package/admin/js/views/search.js +1 -0
  46. package/admin/js/views/settings.js +3 -3
  47. package/admin/js/views/theme.js +1 -0
  48. package/admin/js/views/todo.js +96 -0
  49. package/bin/cli.js +6 -10
  50. package/bin/lib/plugin-version.js +28 -0
  51. package/bin/update.js +27 -2
  52. package/config/menus/admin-sidebar.json +129 -23
  53. package/config/plugins.json +5 -5
  54. package/config/search.json +13 -0
  55. package/config/server.json +3 -1
  56. package/package.json +14 -10
  57. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  58. package/plugins/_lib/admin/mail/contacts.js +301 -0
  59. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  60. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  61. package/plugins/_lib/admin/mail/identity.js +480 -0
  62. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  63. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  64. package/plugins/_lib/admin/mail/mail.css +1 -0
  65. package/plugins/_lib/admin/mail/mail.html +71 -0
  66. package/plugins/_lib/admin/mail/panes.js +253 -0
  67. package/plugins/_lib/admin/mail/reader-view.js +4473 -0
  68. package/plugins/_lib/admin/mail/resizable.js +26 -0
  69. package/plugins/_lib/admin/mail/rules.js +343 -0
  70. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  71. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  72. package/plugins/_lib/admin/mail/templates.js +238 -0
  73. package/plugins/_lib/admin/mail/threads.js +200 -0
  74. package/plugins/_lib/admin/mail/vacation.js +269 -0
  75. package/plugins/_lib/admin/ui/help.css +1 -0
  76. package/plugins/_lib/admin/ui/help.js +174 -0
  77. package/plugins/_lib/admin/ui/resizable.js +151 -0
  78. package/plugins/_lib/dataStore.js +117 -0
  79. package/plugins/_lib/mail/accounts.js +919 -0
  80. package/plugins/_lib/mail/bodyTokens.js +101 -0
  81. package/plugins/_lib/mail/compose.js +256 -0
  82. package/plugins/_lib/mail/defaults.js +57 -0
  83. package/plugins/_lib/mail/diagnostics.js +154 -0
  84. package/plugins/_lib/mail/envelope.js +161 -0
  85. package/plugins/_lib/mail/folders.js +192 -0
  86. package/plugins/_lib/mail/handoff.js +274 -0
  87. package/plugins/_lib/mail/imapPool.js +293 -0
  88. package/plugins/_lib/mail/mbox.js +74 -0
  89. package/plugins/_lib/mail/pollSchedule.js +79 -0
  90. package/plugins/_lib/mail/poller.js +135 -0
  91. package/plugins/_lib/mail/priority.js +138 -0
  92. package/plugins/_lib/mail/readRoutes.js +680 -0
  93. package/plugins/_lib/mail/render.js +291 -0
  94. package/plugins/_lib/mail/ruleRunner.js +152 -0
  95. package/plugins/_lib/mail/scheduler.js +254 -0
  96. package/plugins/_lib/mail/secretbox.js +229 -0
  97. package/plugins/_lib/mail/send.js +396 -0
  98. package/plugins/_lib/mail/store.js +1022 -0
  99. package/plugins/_lib/mail/sync.js +292 -0
  100. package/plugins/_lib/mail/syncPlan.js +126 -0
  101. package/plugins/_lib/mail/syncSelection.js +82 -0
  102. package/plugins/_lib/mail/unsubscribe.js +183 -0
  103. package/plugins/_lib/mail/vacationRunner.js +114 -0
  104. package/plugins/_lib/mail/write.js +473 -0
  105. package/plugins/_lib/schemaSync.js +83 -0
  106. package/plugins/_template/admin/css/index.css +0 -0
  107. package/plugins/_template/admin/templates/index.html +4 -4
  108. package/plugins/_template/admin/views/index.js +7 -0
  109. package/plugins/blog/CLAUDE.md +237 -0
  110. package/plugins/blog/admin/css/index.css +178 -0
  111. package/plugins/blog/admin/templates/blog.html +35 -41
  112. package/plugins/blog/admin/templates/categories.html +19 -6
  113. package/plugins/blog/admin/templates/comments.html +26 -9
  114. package/plugins/blog/admin/templates/post-editor.html +63 -40
  115. package/plugins/blog/admin/templates/settings.html +3 -9
  116. package/plugins/blog/admin/views/blog.js +288 -159
  117. package/plugins/blog/admin/views/categories.js +212 -206
  118. package/plugins/blog/admin/views/comments.js +207 -158
  119. package/plugins/blog/admin/views/kit.js +372 -0
  120. package/plugins/blog/admin/views/post-editor.js +306 -39
  121. package/plugins/blog/admin/views/settings-cog.js +56 -0
  122. package/plugins/blog/admin/views/settings.js +16 -84
  123. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  124. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  125. package/plugins/blog/blocks/blog-card-row.css +22 -0
  126. package/plugins/blog/blocks/blog-card-row.html +11 -0
  127. package/plugins/blog/blocks/blog-card.css +35 -0
  128. package/plugins/blog/blocks/blog-card.html +16 -0
  129. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  130. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  131. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  132. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  133. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  134. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  135. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  136. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  137. package/plugins/blog/collections/categories/schema.json +7 -6
  138. package/plugins/blog/collections/comments/schema.json +11 -10
  139. package/plugins/blog/collections/posts/schema.json +16 -13
  140. package/plugins/blog/config.js +6 -0
  141. package/plugins/blog/lib/layouts.js +304 -0
  142. package/plugins/blog/lib/page.js +158 -0
  143. package/plugins/blog/lib/render.js +119 -0
  144. package/plugins/blog/lib/samples.js +234 -0
  145. package/plugins/blog/plugin.js +259 -26
  146. package/plugins/blog/plugin.json +21 -73
  147. package/plugins/blog/plugin.public.js +217 -0
  148. package/plugins/blog/public/blog.css +177 -0
  149. package/plugins/blog/public/blog.js +586 -0
  150. package/plugins/blog/public/samples/aurora.svg +7 -0
  151. package/plugins/blog/public/samples/dusk.svg +7 -0
  152. package/plugins/blog/public/samples/ember.svg +7 -0
  153. package/plugins/blog/public/samples/harbour.svg +7 -0
  154. package/plugins/blog/public/samples/meadow.svg +7 -0
  155. package/plugins/blog/public/samples/rosewood.svg +7 -0
  156. package/plugins/blog/templates/index.html +9 -8
  157. package/plugins/blog/templates/post.html +4 -17
  158. package/plugins/blog/tests/layouts.test.js +97 -0
  159. package/plugins/blog/tests/public.test.js +71 -0
  160. package/plugins/free-tier.lock.json +74 -0
  161. package/plugins/mail-reader/CLAUDE.md +78 -0
  162. package/plugins/mail-reader/admin/views/mail.js +19 -0
  163. package/plugins/mail-reader/config.js +7 -0
  164. package/plugins/mail-reader/plugin.js +48 -0
  165. package/plugins/mail-reader/plugin.json +34 -0
  166. package/public/css/forms.css +1 -1
  167. package/public/css/search.css +1 -0
  168. package/public/css/site.css +1 -1
  169. package/public/css/theme-switcher.css +1 -0
  170. package/public/js/analytics.js +1 -0
  171. package/public/js/collection-context.js +2 -2
  172. package/public/js/context-menus.js +1 -0
  173. package/public/js/form-logic-engine.js +1 -1
  174. package/public/js/forms.js +2 -2
  175. package/public/js/search.js +1 -0
  176. package/public/js/site.js +1 -1
  177. package/public/js/theme-switcher.js +1 -0
  178. package/scripts/build.js +77 -4
  179. package/scripts/copy-domma.js +48 -0
  180. package/scripts/run-tests.mjs +92 -0
  181. package/scripts/seed.js +1996 -0
  182. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  183. package/server/routes/api/collections.js +34 -0
  184. package/server/routes/api/contacts.js +506 -0
  185. package/server/routes/api/context-menus.js +104 -0
  186. package/server/routes/api/dashboard.js +8 -2
  187. package/server/routes/api/forms.js +42 -3
  188. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  189. package/server/routes/api/notifications.js +69 -19
  190. package/server/routes/api/plugin-marketplace.js +78 -23
  191. package/server/routes/api/plugins.js +198 -9
  192. package/server/routes/api/search.js +43 -0
  193. package/server/routes/api/theme.js +69 -0
  194. package/server/routes/api/todo.js +178 -0
  195. package/server/routes/public.js +42 -7
  196. package/server/server.js +135 -1
  197. package/server/services/adapters/FileAdapter.js +6 -1
  198. package/server/services/collections.js +13 -3
  199. package/server/services/content.js +26 -0
  200. package/server/services/contextMenus.js +477 -0
  201. package/server/services/managerClient.js +72 -136
  202. package/server/services/markdown.js +124 -10
  203. package/server/services/permissionRegistry.js +74 -0
  204. package/server/services/pluginEntitlement.js +171 -0
  205. package/server/services/pluginEnvelope.js +242 -0
  206. package/server/services/pluginFiles.js +52 -11
  207. package/server/services/pluginInstaller.js +246 -26
  208. package/server/services/pluginScaffold.js +5 -0
  209. package/server/services/plugins.js +340 -7
  210. package/server/services/presetCollections.js +344 -0
  211. package/server/services/promoted-plugins-migration.js +254 -0
  212. package/server/services/publicCors.js +46 -0
  213. package/server/services/renderer.js +224 -22
  214. package/server/services/roles.js +1 -1
  215. package/server/services/search-migration.js +82 -0
  216. package/server/services/search.js +413 -0
  217. package/server/services/sidebar-migration.js +1 -0
  218. package/server/services/sidebarBadges.js +37 -0
  219. package/server/services/siteGitignore.js +307 -0
  220. package/server/services/themeSettings.js +580 -0
  221. package/server/services/users.js +8 -0
  222. package/server/templates/page.html +4 -2
  223. package/plugins/analytics/admin/templates/analytics.html +0 -61
  224. package/plugins/analytics/admin/views/analytics.js +0 -176
  225. package/plugins/analytics/config.js +0 -14
  226. package/plugins/analytics/plugin.json +0 -57
  227. package/plugins/analytics/public/inject-body.html +0 -60
  228. package/plugins/analytics/public/inject-head.html +0 -1
  229. package/plugins/blog/templates/author.html +0 -9
  230. package/plugins/blog/templates/category.html +0 -9
  231. package/plugins/blog/templates/tag.html +0 -9
  232. package/plugins/contacts/admin/templates/contacts.html +0 -126
  233. package/plugins/contacts/admin/views/contacts.js +0 -710
  234. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  235. package/plugins/contacts/collections/user-contacts/schema.json +0 -71
  236. package/plugins/contacts/config.js +0 -6
  237. package/plugins/contacts/data/contacts.json +0 -20
  238. package/plugins/contacts/plugin.js +0 -291
  239. package/plugins/contacts/plugin.json +0 -33
  240. package/plugins/demo-viewer/config.js +0 -4
  241. package/plugins/demo-viewer/plugin.js +0 -36
  242. package/plugins/demo-viewer/plugin.json +0 -9
  243. package/plugins/invoice/admin/templates/editor.html +0 -129
  244. package/plugins/invoice/admin/templates/index.html +0 -43
  245. package/plugins/invoice/admin/templates/issuers.html +0 -5
  246. package/plugins/invoice/admin/templates/receivers.html +0 -5
  247. package/plugins/invoice/admin/views/editor.js +0 -267
  248. package/plugins/invoice/admin/views/index.js +0 -155
  249. package/plugins/invoice/admin/views/issuers.js +0 -23
  250. package/plugins/invoice/admin/views/party-view.js +0 -148
  251. package/plugins/invoice/admin/views/receivers.js +0 -22
  252. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -16
  253. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -15
  254. package/plugins/invoice/collections/invoices/schema.json +0 -27
  255. package/plugins/invoice/config.js +0 -16
  256. package/plugins/invoice/plugin.js +0 -283
  257. package/plugins/invoice/plugin.json +0 -85
  258. package/plugins/invoice/templates/invoice-print.html +0 -213
  259. package/plugins/notes/admin/templates/notes.html +0 -83
  260. package/plugins/notes/admin/views/notes.js +0 -281
  261. package/plugins/notes/collections/user-notes/schema.json +0 -54
  262. package/plugins/notes/config.js +0 -6
  263. package/plugins/notes/data/notes.json +0 -1
  264. package/plugins/notes/plugin.json +0 -33
  265. package/plugins/site-search/admin/views/site-search.js +0 -116
  266. package/plugins/site-search/config.js +0 -15
  267. package/plugins/site-search/plugin.js +0 -188
  268. package/plugins/site-search/plugin.json +0 -40
  269. package/plugins/site-search/public/inject-body.html +0 -17
  270. package/plugins/site-search/public/inject-head.html +0 -1
  271. package/plugins/site-search/public/search.css +0 -1
  272. package/plugins/site-search/public/search.js +0 -1
  273. package/plugins/surveys/admin/templates/audience.html +0 -47
  274. package/plugins/surveys/admin/templates/results.html +0 -58
  275. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  276. package/plugins/surveys/admin/templates/surveys.html +0 -25
  277. package/plugins/surveys/admin/views/audience.js +0 -301
  278. package/plugins/surveys/admin/views/results.js +0 -172
  279. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  280. package/plugins/surveys/admin/views/surveys.js +0 -161
  281. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  282. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  283. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  284. package/plugins/surveys/collections/surveys/schema.json +0 -23
  285. package/plugins/surveys/config.js +0 -8
  286. package/plugins/surveys/plugin.js +0 -174
  287. package/plugins/surveys/plugin.json +0 -65
  288. package/plugins/surveys/public/dist-shared.mjs +0 -1
  289. package/plugins/surveys/public/survey.css +0 -1
  290. package/plugins/surveys/public/survey.mjs +0 -1
  291. package/plugins/surveys/templates/survey-page.html +0 -63
  292. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  293. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  294. package/plugins/theme-switcher/config.js +0 -10
  295. package/plugins/theme-switcher/plugin.js +0 -26
  296. package/plugins/theme-switcher/plugin.json +0 -74
  297. package/plugins/theme-switcher/public/inject-body.html +0 -153
  298. package/plugins/theme-switcher/public/inject-head.html +0 -260
  299. package/plugins/todo/admin/templates/todo.html +0 -158
  300. package/plugins/todo/admin/views/todo.js +0 -343
  301. package/plugins/todo/collections/todos/schema.json +0 -60
  302. package/plugins/todo/config.js +0 -7
  303. package/plugins/todo/data/todos.json +0 -1
  304. package/plugins/todo/plugin.js +0 -102
  305. package/plugins/todo/plugin.json +0 -33
@@ -1,93 +1,55 @@
1
1
  /**
2
2
  * Client for outbound calls from domma-cms → domma-cms-manager.
3
- * Reads MANAGER_URL and INSTANCE_SECRET from env at call time.
4
- * All functions return null/[] gracefully if MANAGER_URL is unset.
5
- */
6
-
7
- import {createHmac} from 'node:crypto';
8
- import {readFile} from 'node:fs/promises';
9
- import {fileURLToPath} from 'node:url';
10
- import {dirname, join} from 'node:path';
11
-
12
- const __dirname = dirname(fileURLToPath(import.meta.url));
13
-
14
- /**
15
- * Read the CMS version from the root package.json.
16
- * @returns {Promise<string>} The version string, or '0.0.0' on error.
3
+ *
4
+ * A MANAGED site is one the manager forked, and the manager tells it so in its
5
+ * environment: `MANAGER_URL` (where), `SITE_SLUG` (who) and `FLEET_TOKEN` (the
6
+ * proof, minted per fork and never written down). An unmanaged site has none of
7
+ * these, every call here returns null/[], and the Marketplace screen points at
8
+ * Install from File instead - which is the whole of that customer's procedure.
9
+ *
10
+ * There used to be a `registerInstance()` handshake: HMAC a fingerprint with a
11
+ * shared INSTANCE_SECRET, get a licence token back. It never had a caller, and
12
+ * it was solving a problem this fleet does not have - the manager starts every
13
+ * child itself, so it already knows which site is on the other end. Replacing
14
+ * it with a fork-time token removed a shared secret, a distribution problem and
15
+ * a round trip.
16
+ *
17
+ * Nothing here throws. A manager that is down, slow or mid-restart must leave
18
+ * the site serving pages, so every failure is a warning and an empty answer.
17
19
  */
18
- async function getCmsVersion() {
19
- try {
20
- const pkgPath = join(__dirname, '../../package.json');
21
- const raw = await readFile(pkgPath, 'utf-8');
22
- return JSON.parse(raw).version ?? '0.0.0';
23
- } catch {
24
- return '0.0.0';
25
- }
26
- }
27
20
 
28
21
  /**
29
- * Register this CMS instance with domma-cms-manager.
22
+ * True when this site was started by a manager and can talk to it.
30
23
  *
31
- * @param {string} fingerprint - Unique instance fingerprint.
32
- * @returns {Promise<{licenseToken: string, instanceId: string}|null>}
24
+ * @returns {boolean}
33
25
  */
34
- export async function registerInstance(fingerprint) {
35
- const MANAGER_URL = process.env.MANAGER_URL;
36
- const INSTANCE_SECRET = process.env.INSTANCE_SECRET;
37
-
38
- if (!MANAGER_URL) return null;
39
- if (!INSTANCE_SECRET) {
40
- console.warn('[managerClient] INSTANCE_SECRET not set - skipping registerInstance');
41
- return null;
42
- }
43
-
44
- try {
45
- const cmsVersion = await getCmsVersion();
46
- const hostname = process.env.HOSTNAME ?? 'unknown';
47
-
48
- const token = createHmac('sha256', INSTANCE_SECRET)
49
- .update(fingerprint)
50
- .digest('hex');
51
-
52
- const res = await fetch(`${MANAGER_URL}/api/instances/register`, {
53
- signal: AbortSignal.timeout(10_000),
54
- method: 'POST',
55
- headers: {
56
- 'Content-Type': 'application/json',
57
- 'X-Instance-Token': token,
58
- },
59
- body: JSON.stringify({ fingerprint, cmsVersion, hostname }),
60
- });
26
+ export function isManaged() {
27
+ return Boolean(process.env.MANAGER_URL && process.env.FLEET_TOKEN);
28
+ }
61
29
 
62
- if (!res.ok) {
63
- console.warn(`[managerClient] registerInstance failed: ${res.status} ${res.statusText}`);
64
- return null;
65
- }
30
+ /** The slug this site is known by in its fleet, or null when unmanaged. */
31
+ export function siteSlug() {
32
+ return process.env.SITE_SLUG || null;
33
+ }
66
34
 
67
- return await res.json();
68
- } catch (err) {
69
- console.warn('[managerClient] registerInstance error:', err.message);
70
- return null;
71
- }
35
+ /** Bearer the fork-time token. Read per call: a restart mints a new one. */
36
+ function fleetHeaders() {
37
+ return {Authorization: `Bearer ${process.env.FLEET_TOKEN}`};
72
38
  }
73
39
 
74
40
  /**
75
41
  * Fetch the plugin catalogue from domma-cms-manager.
76
42
  *
77
- * @param {string} licenseToken - License token obtained from registerInstance.
78
- * @returns {Promise<Array<{slug: string, version: string, displayName: string, description: string, price: number, entitled: boolean}>>}
43
+ * @returns {Promise<Array<{slug: string, version: string, displayName: string,
44
+ * description: string, price: number|null, entitled: boolean, licenceStatus: string|null}>>}
79
45
  */
80
- export async function fetchCatalogue(licenseToken) {
81
- const MANAGER_URL = process.env.MANAGER_URL;
82
-
83
- if (!MANAGER_URL) return [];
46
+ export async function fetchCatalogue() {
47
+ if (!isManaged()) return [];
84
48
 
85
49
  try {
86
- const res = await fetch(`${MANAGER_URL}/api/plugins/catalogue`, {
50
+ const res = await fetch(`${process.env.MANAGER_URL}/api/plugins/catalogue`, {
87
51
  signal: AbortSignal.timeout(10_000),
88
- headers: {
89
- Authorization: `Bearer ${licenseToken}`,
90
- },
52
+ headers: fleetHeaders(),
91
53
  });
92
54
 
93
55
  if (!res.ok) {
@@ -103,80 +65,54 @@ export async function fetchCatalogue(licenseToken) {
103
65
  }
104
66
 
105
67
  /**
106
- * Fetch a plugin bundle (tarball + signature) from domma-cms-manager.
68
+ * Fetch a plugin bundle from domma-cms-manager.
69
+ *
70
+ * Returns the triple `installPlugin()` takes, plus the entitlement the manager
71
+ * minted for THIS site. The entitlement travels with the bytes it licenses
72
+ * rather than arriving separately, because a paid plugin installed without one
73
+ * is a plugin that will not load - a failure that looks like a broken install
74
+ * rather than a licensing answer.
107
75
  *
108
- * @param {string} slug - Plugin slug.
109
- * @param {string} version - Plugin version.
110
- * @param {string} licenseToken - License token obtained from registerInstance.
111
- * @returns {Promise<{tarball: Buffer, signature: string, publicKeyId: string}|null>}
76
+ * @param {string} slug
77
+ * @param {string} version
78
+ * @returns {Promise<{tarball: Buffer, signature: string, publicKeyId: string,
79
+ * entitlement: object|null, version: string}|null>} null when unmanaged or unreachable
80
+ * @throws {Error} when the manager refuses - an unlicensed plugin is an answer
81
+ * worth showing, not a silent empty result
112
82
  */
113
- export async function fetchPluginBundle(slug, version, licenseToken) {
114
- const MANAGER_URL = process.env.MANAGER_URL;
115
-
116
- if (!MANAGER_URL) return null;
83
+ export async function fetchPluginBundle(slug, version) {
84
+ if (!isManaged()) return null;
117
85
 
86
+ let res;
118
87
  try {
119
- const res = await fetch(`${MANAGER_URL}/api/plugins/install/${slug}/${version}`, {
120
- signal: AbortSignal.timeout(10_000),
121
- headers: {
122
- Authorization: `Bearer ${licenseToken}`,
123
- },
88
+ res = await fetch(`${process.env.MANAGER_URL}/api/plugins/install/${slug}/${version}`, {
89
+ signal: AbortSignal.timeout(30_000),
90
+ headers: fleetHeaders(),
124
91
  });
125
-
126
- if (!res.ok) {
127
- console.warn(`[managerClient] fetchPluginBundle failed: ${res.status} ${res.statusText}`);
128
- return null;
129
- }
130
-
131
- const json = await res.json();
132
-
133
- if (typeof json.tarball !== 'string' || json.tarball.length > 100 * 1024 * 1024) {
134
- console.warn('[managerClient] fetchPluginBundle: tarball too large or invalid, refusing decode');
135
- return null;
136
- }
137
-
138
- return {
139
- tarball: Buffer.from(json.tarball, 'base64'),
140
- signature: json.signature,
141
- publicKeyId: json.publicKeyId,
142
- };
143
92
  } catch (err) {
144
93
  console.warn('[managerClient] fetchPluginBundle error:', err.message);
145
94
  return null;
146
95
  }
147
- }
148
96
 
149
- /**
150
- * Send a heartbeat to domma-cms-manager with the list of installed plugins.
151
- *
152
- * @param {string} licenseToken - License token obtained from registerInstance.
153
- * @param {string[]} installedSlugs - Array of installed plugin slugs.
154
- * @returns {Promise<boolean>} True on success, false on error.
155
- */
156
- export async function heartbeat(licenseToken, installedSlugs) {
157
- const MANAGER_URL = process.env.MANAGER_URL;
158
-
159
- if (!MANAGER_URL) return false;
160
-
161
- try {
162
- const res = await fetch(`${MANAGER_URL}/api/instances/heartbeat`, {
163
- signal: AbortSignal.timeout(10_000),
164
- method: 'POST',
165
- headers: {
166
- 'Content-Type': 'application/json',
167
- Authorization: `Bearer ${licenseToken}`,
168
- },
169
- body: JSON.stringify({ installedSlugs, versions: {} }),
170
- });
171
-
172
- if (!res.ok) {
173
- console.warn(`[managerClient] heartbeat failed: ${res.status} ${res.statusText}`);
174
- return false;
175
- }
97
+ if (!res.ok) {
98
+ // 403 is "you have no licence for this", 409 "your catalogue is stale".
99
+ // Both are things the admin can act on, so they are raised rather than
100
+ // logged and swallowed the way an unreachable manager is.
101
+ const body = await res.json().catch(() => ({}));
102
+ throw new Error(body.error || `The manager refused that download (${res.status}).`);
103
+ }
176
104
 
177
- return true;
178
- } catch (err) {
179
- console.warn('[managerClient] heartbeat error:', err.message);
180
- return false;
105
+ const json = await res.json();
106
+ if (typeof json.tarball !== 'string' || json.tarball.length > 100 * 1024 * 1024) {
107
+ console.warn('[managerClient] fetchPluginBundle: tarball too large or invalid, refusing decode');
108
+ return null;
181
109
  }
110
+
111
+ return {
112
+ tarball: Buffer.from(json.tarball, 'base64'),
113
+ signature: json.signature,
114
+ publicKeyId: json.publicKeyId,
115
+ entitlement: json.entitlement ?? null,
116
+ version: json.version ?? version,
117
+ };
182
118
  }
@@ -28,6 +28,7 @@ const BUILTIN_SHORTCODES = new Set([
28
28
  'banner', 'slideover', 'counter', 'celebrate', 'firework', 'fireworks', 'scribe',
29
29
  'reveal', 'breathe', 'pulse', 'shake', 'scramble', 'ripple', 'twinkle',
30
30
  'ticker-tape', 'butterflies', 'strobe', 'animate', 'ambient', 'list-group', 'menu',
31
+ 'demo',
31
32
  ]);
32
33
 
33
34
  // Configure marked for safe output
@@ -97,12 +98,44 @@ function buildShortcodeFilter(attrs) {
97
98
  * Load a block template by name from content/blocks/{name}.html.
98
99
  * Throws if the file does not exist.
99
100
  *
101
+ * The template's HTML comments are DROPPED here, before anything looks at it.
102
+ * Every block ships with a header comment documenting its parameters and
103
+ * showing an example call - documentation for whoever opens the file, which
104
+ * has no business on a public page and, worse, is still live markup by the
105
+ * time the shortcode processor sees it.
106
+ *
107
+ * `plugin-card` is what that costs: its example reads
108
+ * `[collection slug="plugins" display="block" block="plugin-card" …]` with no
109
+ * `/]`, so the processor took it for an unclosed tag and swallowed the rest of
110
+ * the render. Four plugin cards came out as one empty `<div>`, and the block's
111
+ * stylesheet came out as prose. The card that renders documentation for itself
112
+ * is not a rare shape - every bundled block has that comment - so this is
113
+ * fixed at the load point rather than in one template.
114
+ *
100
115
  * @param {string} blockName
101
116
  * @returns {Promise<string>}
102
117
  */
103
118
  async function loadBlockTemplate(blockName) {
104
119
  const safe = path.basename(blockName).replace(/[^a-z0-9-]/g, '');
105
- return readFile(path.join(BLOCKS_DIR, `${safe}.html`), 'utf8');
120
+ const raw = await readFile(path.join(BLOCKS_DIR, `${safe}.html`), 'utf8');
121
+ return stripTemplateComments(raw);
122
+ }
123
+
124
+ /**
125
+ * Remove HTML comments from a block template.
126
+ *
127
+ * Unterminated (`<!--` with no `-->`) is treated as comment-to-end: a truncated
128
+ * comment leaking its remainder onto the page is the failure this exists to
129
+ * prevent, so the safe reading is that everything after it was meant to be one.
130
+ *
131
+ * @param {string} tpl
132
+ * @returns {string}
133
+ */
134
+ export function stripTemplateComments(tpl) {
135
+ return String(tpl || '')
136
+ .replace(/<!--[\s\S]*?-->/g, '')
137
+ .replace(/<!--[\s\S]*$/, '')
138
+ .trim();
106
139
  }
107
140
 
108
141
  /**
@@ -258,9 +291,23 @@ function blockTemplateFields(tpl) {
258
291
  return [...names].map(n => ({name: n, label: n}));
259
292
  }
260
293
 
261
- function buildBlockStyleTag(blockName, css) {
294
+ /**
295
+ * Wrap a block's CSS in a `<style>` scoped to that block.
296
+ *
297
+ * BLANK LINES ARE REMOVED, and that is the whole of the second bug here. A
298
+ * block rendered inside another shortcode's wrapper - `[reveal]`, a `[col]`,
299
+ * anything - arrives mid-HTML-block as far as Markdown is concerned, and a
300
+ * blank line ENDS an HTML block: everything after the first one in the
301
+ * stylesheet came back wrapped in `<p>` and was printed on the page as text.
302
+ * Readable CSS is not worth a stylesheet in the body copy, so the blank lines
303
+ * go and the line breaks stay.
304
+ */
305
+ export function buildBlockStyleTag(blockName, css) {
262
306
  if (!css || !css.trim()) return '';
263
- const body = String(css).replace(/<\/style/gi, '<\\/style');
307
+ const body = String(css)
308
+ .replace(/<\/style/gi, '<\\/style')
309
+ .replace(/\n\s*\n+/g, '\n')
310
+ .trim();
264
311
  return `<style>[data-block="${escapeAttr(blockName)}"] {\n${body}\n}</style>\n`;
265
312
  }
266
313
 
@@ -1940,8 +1987,14 @@ export async function refreshComponentTagAllowlist() {
1940
1987
  export function collectAndRewriteComponents(source) {
1941
1988
  const used = new Set();
1942
1989
 
1990
+ // Scrub first: a `[component …/]` or a `<dm-x>` tag written inside a fenced
1991
+ // block is documentation showing the syntax, not a component to mount.
1992
+ // Rewriting it turns the example into the thing it was explaining, and
1993
+ // counting it pulls that component's script onto a page that never uses it.
1994
+ const {scrubbed, restore} = scrubCodeRegions(source);
1995
+
1943
1996
  // Rewrite shortcode form → raw tag form.
1944
- const rewritten = source.replace(
1997
+ const rewritten = scrubbed.replace(
1945
1998
  /\[component\s+([^\]]+?)\s*\/?\]/g,
1946
1999
  (_, attrs) => {
1947
2000
  const nameMatch = attrs.match(/name\s*=\s*["']([a-z][a-z0-9-]*)["']/);
@@ -1957,7 +2010,7 @@ export function collectAndRewriteComponents(source) {
1957
2010
  used.add(m[1]);
1958
2011
  }
1959
2012
 
1960
- return {output: rewritten, used};
2013
+ return {output: restore(rewritten), used};
1961
2014
  }
1962
2015
 
1963
2016
  export function escapeAttr(str) {
@@ -3371,6 +3424,51 @@ function processIconBlocks(markdown) {
3371
3424
  ));
3372
3425
  }
3373
3426
 
3427
+ // ─── [demo] - embed a local demo page ──────────────────────────────────────
3428
+
3429
+ /** Where [demo src="..."] looks for its pages, unless the tag says otherwise. */
3430
+ const DEMO_BASE_PATH = '/public/demos';
3431
+ const DEMO_DEFAULT_HEIGHT = '500px';
3432
+
3433
+ /**
3434
+ * [demo src="elements/accordion" /]
3435
+ * [demo src="effects/ambient" height="600px" title="Ambient Effect" base="/public/x" /]
3436
+ *
3437
+ * Was the `demo-viewer` plugin until 2026-09-22. It is a shortcode and nothing
3438
+ * else - no admin screen, no routes, no data - so it belongs here rather than
3439
+ * behind an install.
3440
+ *
3441
+ * The src is a SAME-ORIGIN path, which is why `allowIframeRelativeUrls` matters
3442
+ * in the sanitizer below. `base` exists because the plugin had a configurable
3443
+ * basePath; an attribute serves the one site that set it without inventing a
3444
+ * config key nobody else would ever touch.
3445
+ *
3446
+ * @param {string} markdown
3447
+ * @returns {string}
3448
+ */
3449
+ function processDemoBlocks(markdown) {
3450
+ const {scrubbed, restore} = scrubCodeRegions(markdown);
3451
+ return restore(scrubbed.replace(
3452
+ /\[demo([^\]]*?)\/\]/gi,
3453
+ (_, attrStr) => {
3454
+ const attrs = parseShortcodeAttrs(attrStr);
3455
+ const src = typeof attrs.src === 'string' ? attrs.src.trim() : '';
3456
+ if (!src) return '';
3457
+ // A src is a path fragment under base, never a URL of its own: anything
3458
+ // carrying a scheme or a protocol-relative prefix would escape the site.
3459
+ if (/^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith('//')) return '';
3460
+ const base = (typeof attrs.base === 'string' && attrs.base.startsWith('/'))
3461
+ ? attrs.base.replace(/\/+$/, '')
3462
+ : DEMO_BASE_PATH;
3463
+ const height = attrs.height || DEMO_DEFAULT_HEIGHT;
3464
+ const title = attrs.title || '';
3465
+ const iframeSrc = `${base}/${src.replace(/^\/+/, '')}.html`;
3466
+ const titleBar = title ? `<div class="demo-viewer-title">${escapeAttr(title)}</div>` : '';
3467
+ return `<div class="demo-viewer">${titleBar}<iframe src="${escapeAttr(iframeSrc)}" style="width:100%;height:${escapeAttr(height)};border:none;border-radius:var(--dm-radius, 8px);background:var(--dm-surface, #1a1a2e);" loading="lazy" allowfullscreen></iframe></div>`;
3468
+ }
3469
+ ));
3470
+ }
3471
+
3374
3472
  // ─── [embed] - video / iframe embeds ───────────────────────────────────────
3375
3473
 
3376
3474
  /**
@@ -3652,7 +3750,12 @@ async function processFormBlocks(markdown, tagSet) {
3652
3750
  const raw = await readFile(filePath, 'utf8');
3653
3751
  const form = JSON.parse(raw);
3654
3752
  await expandReferenceFields(form);
3655
- const encoded = Buffer.from(JSON.stringify(form)).toString('base64');
3753
+ // `actions` holds the notification email recipients and the webhook URL.
3754
+ // The embed is base64 in the page source, which is not a secret keeping
3755
+ // measure, so strip them exactly as GET /forms/:slug/public does - the
3756
+ // browser never reads them, the submit route resolves them server-side.
3757
+ const {actions: _actions, ...publicForm} = form;
3758
+ const encoded = Buffer.from(JSON.stringify(publicForm)).toString('base64');
3656
3759
  const extraClass = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
3657
3760
  const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
3658
3761
  replacement = `<div class="cms-form-embed${extraClass}" data-form-inline="${escapeAttr(encoded)}"${idAttr}></div>`;
@@ -3868,11 +3971,15 @@ function processSlideoverBlocks(markdown) {
3868
3971
  * @returns {Promise<string>}
3869
3972
  */
3870
3973
  async function processMenuBlocks(markdown, user, ctx = {}) {
3974
+ // Scrub first, or a `[menu …/]` written inside a fenced block as an example
3975
+ // of the syntax is rendered into a real nav and then escaped into the code
3976
+ // block - the docs page shows the output instead of the shortcode.
3977
+ const {scrubbed, restore} = scrubCodeRegions(markdown);
3871
3978
  const re = /\[menu(\s+[^\]]*?)?\s*\/\]/gi;
3872
- const matches = [...markdown.matchAll(re)];
3979
+ const matches = [...scrubbed.matchAll(re)];
3873
3980
  if (!matches.length) return markdown;
3874
3981
 
3875
- let out = markdown;
3982
+ let out = scrubbed;
3876
3983
  for (const m of matches) {
3877
3984
  const attrs = parseShortcodeAttrs(m[1] || '');
3878
3985
  // A bare `slug` / `location` with no value parses as the boolean `true`
@@ -3925,7 +4032,7 @@ async function processMenuBlocks(markdown, user, ctx = {}) {
3925
4032
  });
3926
4033
  out = out.replace(m[0], html);
3927
4034
  }
3928
- return out;
4035
+ return restore(out);
3929
4036
  }
3930
4037
 
3931
4038
  /** Visible diagnostic for an unusable [menu] shortcode - mirrors [form]'s. */
@@ -4074,7 +4181,8 @@ export async function parseMarkdown(raw, opts = {}) {
4074
4181
  const withSpacer = processSpacerBlocks(withListGroup);
4075
4182
  const withCenter = processCenterBlocks(withSpacer);
4076
4183
  const withIcon = processIconBlocks(withCenter);
4077
- const withEmbed = processEmbedBlocks(withIcon);
4184
+ const withDemo = processDemoBlocks(withIcon);
4185
+ const withEmbed = processEmbedBlocks(withDemo);
4078
4186
  const withForm = await processFormBlocks(withEmbed, tagSet);
4079
4187
  const withHero = processHeroBlocks(withForm);
4080
4188
  const withTable = processTableBlocks(withHero);
@@ -4102,6 +4210,12 @@ export async function parseMarkdown(raw, opts = {}) {
4102
4210
  'player.vimeo.com',
4103
4211
  ...getEmbedAllowedHosts(),
4104
4212
  ],
4213
+ // sanitize-html drops a RELATIVE iframe src whenever allowedIframeHostnames
4214
+ // is set, because it cannot match a hostname it has not been given. That
4215
+ // silently emptied every [demo] iframe. A relative src is same-origin by
4216
+ // definition, so the hostname gate has nothing to say about it: the gate
4217
+ // exists to stop embedding somebody else's site, not your own page.
4218
+ allowIframeRelativeUrls: true,
4105
4219
  allowedTags: sanitizeHtml.defaults.allowedTags.concat([
4106
4220
  'img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
4107
4221
  'form', 'input', 'textarea', 'select', 'option', 'optgroup',
@@ -87,6 +87,19 @@ export const REGISTRY = [
87
87
  {key: 'delete', label: 'Delete', description: 'Delete menus'}
88
88
  ]
89
89
  },
90
+ {
91
+ key: 'context-menus',
92
+ label: 'Context Menus',
93
+ description: 'Author right-click menus and the regions they bind to.',
94
+ icon: 'menu',
95
+ group: 'Structure',
96
+ actions: [
97
+ {key: 'read', label: 'View', description: 'View context menus'},
98
+ {key: 'create', label: 'Create', description: 'Create new context menus'},
99
+ {key: 'update', label: 'Edit', description: 'Edit context menus'},
100
+ {key: 'delete', label: 'Delete', description: 'Delete context menus'}
101
+ ]
102
+ },
90
103
  {
91
104
  key: 'projects',
92
105
  label: 'Projects',
@@ -165,6 +178,17 @@ export const REGISTRY = [
165
178
  {key: 'delete', label: 'Delete', description: 'Delete users'}
166
179
  ]
167
180
  },
181
+ {
182
+ key: 'theme',
183
+ label: 'Theme',
184
+ description: 'Choose the site and admin themes, fonts, and per-element theme overrides.',
185
+ icon: 'droplet',
186
+ group: 'Configuration',
187
+ actions: [
188
+ {key: 'read', label: 'View', description: 'View theme settings and overrides'},
189
+ {key: 'update', label: 'Edit', description: 'Change themes, fonts and overrides'}
190
+ ]
191
+ },
168
192
  {
169
193
  key: 'settings',
170
194
  label: 'Settings',
@@ -178,6 +202,56 @@ export const REGISTRY = [
178
202
  {key: 'delete', label: 'Delete', description: 'Remove settings'}
179
203
  ]
180
204
  },
205
+ {
206
+ key: 'contacts',
207
+ label: 'Contacts',
208
+ description: 'Address book with groups, favourites and import/export. Each user sees only their own.',
209
+ icon: 'users',
210
+ group: 'Tools',
211
+ actions: [
212
+ {key: 'read', label: 'View', description: 'View own contacts'},
213
+ {key: 'create', label: 'Create', description: 'Add contacts and groups'},
214
+ {key: 'update', label: 'Edit', description: 'Edit own contacts'},
215
+ {key: 'delete', label: 'Delete', description: 'Delete own contacts'}
216
+ ]
217
+ },
218
+ {
219
+ key: 'analytics',
220
+ label: 'Analytics',
221
+ description: 'Page-view analytics: trends, top pages, journeys and export.',
222
+ icon: 'chart-bar',
223
+ group: 'Tools',
224
+ actions: [
225
+ {key: 'read', label: 'View', description: 'View analytics'},
226
+ {key: 'delete', label: 'Reset', description: 'Reset all counters'}
227
+ ]
228
+ },
229
+ {
230
+ key: 'notes',
231
+ label: 'Notes',
232
+ description: 'Personal notes. Each user sees only their own.',
233
+ icon: 'edit-3',
234
+ group: 'Tools',
235
+ actions: [
236
+ {key: 'read', label: 'View', description: 'View own notes'},
237
+ {key: 'create', label: 'Create', description: 'Write a note'},
238
+ {key: 'update', label: 'Edit', description: 'Edit own notes'},
239
+ {key: 'delete', label: 'Delete', description: 'Delete own notes'}
240
+ ]
241
+ },
242
+ {
243
+ key: 'todo',
244
+ label: 'Todo',
245
+ description: 'Personal task list. Each user sees only their own.',
246
+ icon: 'check-square',
247
+ group: 'Tools',
248
+ actions: [
249
+ {key: 'read', label: 'View', description: 'View own tasks'},
250
+ {key: 'create', label: 'Create', description: 'Add a task'},
251
+ {key: 'update', label: 'Edit', description: 'Edit own tasks'},
252
+ {key: 'delete', label: 'Delete', description: 'Delete own tasks'}
253
+ ]
254
+ },
181
255
  {
182
256
  key: 'plugins',
183
257
  label: 'Plugins',