domma-cms 0.66.0 → 0.69.4

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 (221) hide show
  1. package/CLAUDE.md +64 -1
  2. package/README.md +14 -10
  3. package/admin/css/admin.css +1 -1
  4. package/admin/index.html +1 -0
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +4 -4
  7. package/admin/js/lib/analytics-shape.js +1 -0
  8. package/admin/js/lib/contacts-arrange.js +1 -0
  9. package/admin/js/lib/help-popover.js +1 -0
  10. package/admin/js/lib/notes-arrange.js +1 -0
  11. package/admin/js/lib/plugin-accent.js +1 -1
  12. package/admin/js/lib/plugin-chrome.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.js +1 -1
  14. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  15. package/admin/js/lib/sidebar-renderer.js +3 -3
  16. package/admin/js/lib/todo-arrange.js +1 -0
  17. package/admin/js/lib/tool-kit.js +1 -0
  18. package/admin/js/templates/analytics.html +138 -0
  19. package/admin/js/templates/contacts.html +156 -0
  20. package/admin/js/templates/notes.html +137 -0
  21. package/admin/js/templates/plugin-marketplace.html +17 -4
  22. package/admin/js/templates/plugins.html +13 -7
  23. package/admin/js/templates/theme.html +38 -0
  24. package/admin/js/templates/todo.html +113 -0
  25. package/admin/js/views/analytics.js +62 -0
  26. package/admin/js/views/contacts.js +174 -0
  27. package/admin/js/views/index.js +1 -1
  28. package/admin/js/views/notes.js +104 -0
  29. package/admin/js/views/plugin-marketplace.js +1 -1
  30. package/admin/js/views/plugins.js +24 -22
  31. package/admin/js/views/theme.js +1 -1
  32. package/admin/js/views/todo.js +96 -0
  33. package/bin/cli.js +4 -10
  34. package/bin/lib/plugin-version.js +28 -0
  35. package/bin/update.js +14 -0
  36. package/config/menus/admin-sidebar.json +1 -1
  37. package/config/server.json +3 -1
  38. package/package.json +3 -7
  39. package/plugins/_lib/admin/mail/reader-view.js +20 -0
  40. package/plugins/_lib/mail/store.js +21 -1
  41. package/plugins/blog/CLAUDE.md +237 -0
  42. package/plugins/blog/admin/css/index.css +178 -1
  43. package/plugins/blog/admin/templates/blog.html +35 -53
  44. package/plugins/blog/admin/templates/categories.html +19 -6
  45. package/plugins/blog/admin/templates/comments.html +26 -9
  46. package/plugins/blog/admin/templates/post-editor.html +36 -13
  47. package/plugins/blog/admin/templates/settings.html +2 -11
  48. package/plugins/blog/admin/views/blog.js +288 -162
  49. package/plugins/blog/admin/views/categories.js +212 -201
  50. package/plugins/blog/admin/views/comments.js +207 -158
  51. package/plugins/blog/admin/views/kit.js +372 -0
  52. package/plugins/blog/admin/views/post-editor.js +269 -21
  53. package/plugins/blog/admin/views/settings-cog.js +56 -0
  54. package/plugins/blog/admin/views/settings.js +16 -86
  55. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  56. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  57. package/plugins/blog/blocks/blog-card-row.css +22 -0
  58. package/plugins/blog/blocks/blog-card-row.html +11 -0
  59. package/plugins/blog/blocks/blog-card.css +35 -0
  60. package/plugins/blog/blocks/blog-card.html +16 -0
  61. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  62. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  63. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  64. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  65. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  66. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  67. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  68. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  69. package/plugins/blog/collections/posts/schema.json +3 -1
  70. package/plugins/blog/config.js +6 -0
  71. package/plugins/blog/lib/layouts.js +304 -0
  72. package/plugins/blog/lib/page.js +158 -0
  73. package/plugins/blog/lib/render.js +119 -0
  74. package/plugins/blog/lib/samples.js +234 -0
  75. package/plugins/blog/plugin.js +225 -15
  76. package/plugins/blog/plugin.json +21 -81
  77. package/plugins/blog/plugin.public.js +115 -210
  78. package/plugins/blog/public/blog.css +177 -0
  79. package/plugins/blog/public/blog.js +586 -0
  80. package/plugins/blog/public/samples/aurora.svg +7 -0
  81. package/plugins/blog/public/samples/dusk.svg +7 -0
  82. package/plugins/blog/public/samples/ember.svg +7 -0
  83. package/plugins/blog/public/samples/harbour.svg +7 -0
  84. package/plugins/blog/public/samples/meadow.svg +7 -0
  85. package/plugins/blog/public/samples/rosewood.svg +7 -0
  86. package/plugins/blog/templates/index.html +9 -8
  87. package/plugins/blog/templates/post.html +4 -17
  88. package/plugins/blog/tests/layouts.test.js +97 -0
  89. package/plugins/blog/tests/public.test.js +71 -0
  90. package/plugins/free-tier.lock.json +74 -0
  91. package/plugins/mail-reader/CLAUDE.md +78 -0
  92. package/plugins/mail-reader/plugin.json +2 -1
  93. package/public/css/theme-switcher.css +1 -0
  94. package/public/js/analytics.js +1 -0
  95. package/public/js/theme-switcher.js +1 -0
  96. package/scripts/build.js +41 -2
  97. package/scripts/run-tests.mjs +94 -0
  98. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  99. package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
  100. package/server/routes/api/dashboard.js +8 -2
  101. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  102. package/server/routes/api/plugin-marketplace.js +78 -23
  103. package/server/routes/api/plugins.js +148 -3
  104. package/server/routes/api/todo.js +178 -0
  105. package/server/server.js +61 -1
  106. package/server/services/collections.js +13 -3
  107. package/server/services/managerClient.js +72 -136
  108. package/server/services/markdown.js +54 -1
  109. package/server/services/permissionRegistry.js +50 -0
  110. package/server/services/pluginEntitlement.js +171 -0
  111. package/server/services/pluginEnvelope.js +242 -0
  112. package/server/services/pluginInstaller.js +246 -26
  113. package/server/services/pluginScaffold.js +5 -0
  114. package/server/services/plugins.js +117 -7
  115. package/server/services/presetCollections.js +344 -0
  116. package/server/services/promoted-plugins-migration.js +254 -0
  117. package/server/services/publicCors.js +46 -0
  118. package/server/services/renderer.js +83 -3
  119. package/server/services/sidebarBadges.js +37 -0
  120. package/server/services/siteGitignore.js +310 -0
  121. package/server/services/themeSettings.js +39 -0
  122. package/config/theme.json +0 -18
  123. package/plugins/analytics/admin/css/index.css +0 -1
  124. package/plugins/analytics/admin/templates/analytics.html +0 -70
  125. package/plugins/analytics/admin/views/analytics.js +0 -176
  126. package/plugins/analytics/config.js +0 -14
  127. package/plugins/analytics/plugin.json +0 -60
  128. package/plugins/analytics/public/inject-body.html +0 -60
  129. package/plugins/analytics/public/inject-head.html +0 -1
  130. package/plugins/blog/templates/author.html +0 -9
  131. package/plugins/blog/templates/category.html +0 -9
  132. package/plugins/blog/templates/tag.html +0 -9
  133. package/plugins/contacts/admin/templates/contacts.html +0 -254
  134. package/plugins/contacts/admin/views/contacts.js +0 -943
  135. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  136. package/plugins/contacts/collections/user-contacts/schema.json +0 -179
  137. package/plugins/contacts/config.js +0 -6
  138. package/plugins/contacts/plugin.json +0 -36
  139. package/plugins/demo-viewer/config.js +0 -4
  140. package/plugins/demo-viewer/plugin.js +0 -36
  141. package/plugins/demo-viewer/plugin.json +0 -9
  142. package/plugins/invoice/admin/css/index.css +0 -1
  143. package/plugins/invoice/admin/templates/editor.html +0 -220
  144. package/plugins/invoice/admin/templates/index.html +0 -173
  145. package/plugins/invoice/admin/templates/issuers.html +0 -2
  146. package/plugins/invoice/admin/templates/receivers.html +0 -2
  147. package/plugins/invoice/admin/views/contacts-source.js +0 -266
  148. package/plugins/invoice/admin/views/editor.js +0 -434
  149. package/plugins/invoice/admin/views/export.js +0 -199
  150. package/plugins/invoice/admin/views/help-content.js +0 -61
  151. package/plugins/invoice/admin/views/index.js +0 -643
  152. package/plugins/invoice/admin/views/issuers.js +0 -30
  153. package/plugins/invoice/admin/views/media.js +0 -172
  154. package/plugins/invoice/admin/views/party-view.js +0 -386
  155. package/plugins/invoice/admin/views/payments.js +0 -127
  156. package/plugins/invoice/admin/views/print.js +0 -130
  157. package/plugins/invoice/admin/views/receivers.js +0 -55
  158. package/plugins/invoice/admin/views/send.js +0 -212
  159. package/plugins/invoice/admin/views/settings.js +0 -594
  160. package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
  161. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
  162. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
  163. package/plugins/invoice/collections/invoices/schema.json +0 -33
  164. package/plugins/invoice/config.js +0 -37
  165. package/plugins/invoice/pdf.js +0 -164
  166. package/plugins/invoice/plugin.js +0 -1456
  167. package/plugins/invoice/plugin.json +0 -86
  168. package/plugins/invoice/templates/_base.css +0 -1
  169. package/plugins/invoice/templates/classic-nologo.html +0 -100
  170. package/plugins/invoice/templates/classic.html +0 -91
  171. package/plugins/invoice/templates/invoice-print.html +0 -237
  172. package/plugins/invoice/templates/minimal.html +0 -99
  173. package/plugins/invoice/templates/modern-nologo.html +0 -114
  174. package/plugins/invoice/templates/modern.html +0 -113
  175. package/plugins/invoice/templates/templates.json +0 -11
  176. package/plugins/notes/admin/templates/notes.html +0 -83
  177. package/plugins/notes/admin/views/notes.js +0 -281
  178. package/plugins/notes/collections/user-notes/schema.json +0 -54
  179. package/plugins/notes/config.js +0 -6
  180. package/plugins/notes/plugin.json +0 -33
  181. package/plugins/surveys/admin/templates/audience.html +0 -47
  182. package/plugins/surveys/admin/templates/results.html +0 -58
  183. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  184. package/plugins/surveys/admin/templates/surveys.html +0 -25
  185. package/plugins/surveys/admin/views/audience.js +0 -301
  186. package/plugins/surveys/admin/views/results.js +0 -172
  187. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  188. package/plugins/surveys/admin/views/surveys.js +0 -161
  189. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  190. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  191. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  192. package/plugins/surveys/collections/surveys/schema.json +0 -23
  193. package/plugins/surveys/config.js +0 -8
  194. package/plugins/surveys/lib/audience.js +0 -37
  195. package/plugins/surveys/lib/campaigns.js +0 -43
  196. package/plugins/surveys/lib/ledger.js +0 -110
  197. package/plugins/surveys/lib/sending.js +0 -106
  198. package/plugins/surveys/lib/stats.js +0 -62
  199. package/plugins/surveys/lib/submit.js +0 -95
  200. package/plugins/surveys/lib/tokens.js +0 -28
  201. package/plugins/surveys/plugin.js +0 -174
  202. package/plugins/surveys/plugin.json +0 -65
  203. package/plugins/surveys/plugin.public.js +0 -149
  204. package/plugins/surveys/public/dist-shared.mjs +0 -1
  205. package/plugins/surveys/public/survey.css +0 -1
  206. package/plugins/surveys/public/survey.mjs +0 -1
  207. package/plugins/surveys/templates/survey-page.html +0 -63
  208. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  209. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  210. package/plugins/theme-switcher/config.js +0 -10
  211. package/plugins/theme-switcher/plugin.js +0 -26
  212. package/plugins/theme-switcher/plugin.json +0 -74
  213. package/plugins/theme-switcher/public/inject-body.html +0 -153
  214. package/plugins/theme-switcher/public/inject-head.html +0 -260
  215. package/plugins/todo/admin/templates/todo.html +0 -158
  216. package/plugins/todo/admin/views/todo.js +0 -343
  217. package/plugins/todo/collections/todos/schema.json +0 -60
  218. package/plugins/todo/config.js +0 -7
  219. package/plugins/todo/plugin.js +0 -102
  220. package/plugins/todo/plugin.json +0 -33
  221. /package/{plugins/contacts/admin/css/index.css → admin/css/contacts.css} +0 -0
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Promoted-plugin migration - one-shot retirement of plugins that became core.
3
+ *
4
+ * A plugin that is promoted into the CMS leaves two traces on an existing
5
+ * install:
6
+ *
7
+ * 1. its `plugins.json` entry, which would otherwise offer a toggle for a
8
+ * feature that is no longer optional, and
9
+ * 2. `plugins/<slug>/` itself - the updater replaces the plugins it ships and
10
+ * adds new ones, but never removes one that has gone away. Left in place,
11
+ * the loader would register a SECOND copy of a feature the CMS now has of
12
+ * its own, which for a shortcode means two handlers racing for the same
13
+ * tag.
14
+ *
15
+ * This is the generic form of `search-migration.js`, which did the same job for
16
+ * `site-search` alone. Each promotion adds a row to PROMOTED rather than another
17
+ * near-identical file.
18
+ *
19
+ * Idempotent: with no entry and no directory there is nothing to do, which is
20
+ * the state of every fresh install.
21
+ */
22
+ import fs from 'fs';
23
+ import path from 'path';
24
+
25
+ /**
26
+ * Plugins that are now part of the CMS.
27
+ *
28
+ * `author` is checked against the directory's manifest before anything is
29
+ * deleted. A directory of that name carrying somebody else's work is their
30
+ * plugin, not our retired one, and is left exactly where it is.
31
+ *
32
+ * `defaults` is what the plugin's own config.js shipped. Settings matching it
33
+ * are dropped silently; anything else is reported, because a site that tuned a
34
+ * setting deserves to be told where it went rather than have it vanish.
35
+ *
36
+ * `lift(settings, enabled)` moves those settings somewhere they still matter.
37
+ * A row without one is a plugin whose settings core does not need. It runs
38
+ * BEFORE the entry is deleted, so a throw leaves the entry in place and the
39
+ * next start tries again rather than losing the values.
40
+ */
41
+ const PROMOTED = [
42
+ {
43
+ slug: 'demo-viewer',
44
+ author: 'Domma',
45
+ defaults: {basePath: '/public/demos', defaultHeight: '500px'},
46
+ note: 'the [demo] shortcode is built in'
47
+ },
48
+ {
49
+ slug: 'notes',
50
+ author: 'Darryl Waterhouse',
51
+ defaults: {scope: 'user', maxPerUser: 1000, storage: {adapter: 'mongodb', connection: 'default'}},
52
+ note: 'Notes is a built-in Tool; its entries are untouched in notes-notes'
53
+ },
54
+ {
55
+ slug: 'todo',
56
+ author: 'Darryl Waterhouse',
57
+ defaults: {scope: 'user', defaultPriority: 'medium', maxPerUser: 500, storage: {adapter: 'mongodb', connection: 'default'}},
58
+ note: 'Todo is a built-in Tool; its entries are untouched in todo-items'
59
+ },
60
+ {
61
+ slug: 'contacts',
62
+ author: 'Darryl Waterhouse',
63
+ defaults: {scope: 'user'},
64
+ note: 'Contacts is a built-in Tool; contacts-contacts and contacts-groups are untouched'
65
+ },
66
+ {
67
+ slug: 'analytics',
68
+ author: 'Darryl Waterhouse',
69
+ note: 'analytics is built in; counters moved to content/analytics/',
70
+ lift: liftAnalyticsCounters
71
+ },
72
+ {
73
+ slug: 'theme-switcher',
74
+ author: 'Domma',
75
+ note: 'the switcher is a theme setting now',
76
+ lift: liftThemeSwitcher
77
+ }
78
+ ];
79
+
80
+ /**
81
+ * Move the analytics counters out from under plugins/.
82
+ *
83
+ * This is the only lift that moves data a site cannot regenerate: lifetime.json,
84
+ * daily.json and journeys.json are real traffic history, tens to hundreds of
85
+ * kilobytes per site. The directory they sit in is about to be deleted, so the
86
+ * copy has to happen first and has to be verified.
87
+ *
88
+ * Copy, re-read, compare, and only then let the caller proceed. A mismatch
89
+ * throws, which abandons the whole row - the plugin directory stays, the entry
90
+ * stays, and the next start tries again. A site keeping a duplicate 96K for one
91
+ * release is not a problem; losing the original is.
92
+ *
93
+ * Files already present at the destination are left alone: a second run must not
94
+ * overwrite counters that have moved on since.
95
+ *
96
+ * @returns {string|null} what changed, for the log
97
+ */
98
+ function liftAnalyticsCounters() {
99
+ const SRC = path.resolve('plugins', 'analytics', 'data');
100
+ const DEST = path.resolve('content', 'analytics');
101
+ if (!fs.existsSync(SRC)) return null;
102
+
103
+ fs.mkdirSync(DEST, {recursive: true});
104
+ const copied = [];
105
+
106
+ for (const name of ['lifetime.json', 'daily.json', 'journeys.json', 'stats.json']) {
107
+ const from = path.join(SRC, name);
108
+ const to = path.join(DEST, name);
109
+ if (!fs.existsSync(from) || fs.existsSync(to)) continue;
110
+
111
+ const raw = fs.readFileSync(from);
112
+ fs.writeFileSync(to, raw);
113
+
114
+ // Verified by comparing what is now on disk, not by trusting the write.
115
+ const back = fs.readFileSync(to);
116
+ if (!back.equals(raw)) {
117
+ throw new Error(`counters failed to copy intact (${name})`);
118
+ }
119
+ copied.push(`${name} (${raw.length} bytes)`);
120
+ }
121
+
122
+ return copied.length
123
+ ? `copied counters to content/analytics/: ${copied.join(', ')}`
124
+ : null;
125
+ }
126
+
127
+ /**
128
+ * Move the theme switcher's placement into config/theme.json.
129
+ *
130
+ * `enabled` carries across deliberately: a site showing the disc keeps showing
131
+ * it, and one that never turned the plugin on does not suddenly acquire a
132
+ * visitor-facing theme picker.
133
+ *
134
+ * theme.json is read and written directly rather than through saveThemeConfig(),
135
+ * which validates and recompiles. Migrations run before the app is up, and a
136
+ * placement value is not worth pulling that machinery into startup. Written only
137
+ * when theme.json already exists - migrateThemeConfig() runs first and creates
138
+ * it, so an absent file means something is wrong enough not to paper over.
139
+ *
140
+ * @param {object} settings
141
+ * @param {boolean} enabled
142
+ * @returns {string|null} what changed, for the log
143
+ */
144
+ function liftThemeSwitcher(settings, enabled) {
145
+ const THEME_CONFIG = path.resolve('config', 'theme.json');
146
+ if (!fs.existsSync(THEME_CONFIG)) return null;
147
+
148
+ let cfg;
149
+ try {
150
+ cfg = JSON.parse(fs.readFileSync(THEME_CONFIG, 'utf8'));
151
+ } catch {
152
+ return null;
153
+ }
154
+ if (cfg.switcher && cfg.switcher.enabled !== undefined) {
155
+ return null; // already lifted, or configured since
156
+ }
157
+
158
+ const POSITIONS = ['right-center', 'left-center', 'bottom-right', 'bottom-left',
159
+ 'top-left', 'top-right'];
160
+ const num = (v, fallback) =>
161
+ (Number.isFinite(Number(v)) && Number(v) >= 0 && Number(v) <= 400) ? Number(v) : fallback;
162
+
163
+ cfg.switcher = {
164
+ enabled,
165
+ position: POSITIONS.includes(settings.position) ? settings.position : 'right-center',
166
+ offsetX: num(settings.offsetX, 32),
167
+ offsetY: num(settings.offsetY, 32),
168
+ // The plugin's `themes` list was never read by its own markup, which
169
+ // hardcoded 26 dots. Starting empty means "every theme", which is what
170
+ // the markup effectively promised and never delivered.
171
+ themes: []
172
+ };
173
+ fs.writeFileSync(THEME_CONFIG, JSON.stringify(cfg, null, 2) + '\n');
174
+ return `lifted placement into theme.json (enabled: ${enabled})`;
175
+ }
176
+
177
+ /**
178
+ * Run the migration.
179
+ *
180
+ * Paths are resolved per call rather than at import, so the same process can run
181
+ * this against more than one install - which the tests do.
182
+ *
183
+ * @param {object[]} [table] - override for testing
184
+ * @returns {string[]} what changed, for the startup log (empty = nothing to do)
185
+ */
186
+ export function migratePromotedPlugins(table = PROMOTED) {
187
+ const PLUGINS_CONFIG = path.resolve('config', 'plugins.json');
188
+ const changes = [];
189
+
190
+ let plugins = null;
191
+ try {
192
+ plugins = JSON.parse(fs.readFileSync(PLUGINS_CONFIG, 'utf8'));
193
+ } catch {
194
+ // No plugins.json, or a malformed one. Neither is this migration's
195
+ // business - a broken file is reported loudly elsewhere.
196
+ }
197
+
198
+ let dirty = false;
199
+
200
+ for (const {slug, author, defaults = {}, note, lift} of table) {
201
+ const stored = plugins?.[slug];
202
+
203
+ // 1. The entry, and any settings it carried.
204
+ if (plugins && stored) {
205
+ const settings = stored.settings || {};
206
+
207
+ if (lift) {
208
+ // A lift that throws must abandon THIS row entirely - entry and
209
+ // directory both. Deleting either after a failed lift is how a
210
+ // site loses the thing the lift was moving.
211
+ let lifted;
212
+ try {
213
+ lifted = lift(settings, stored.enabled === true);
214
+ } catch (err) {
215
+ changes.push(`${slug}: NOT retired - ${err.message}`);
216
+ continue;
217
+ }
218
+ if (lifted) changes.push(`${slug}: ${lifted}`);
219
+ } else {
220
+ const changed = Object.entries(settings)
221
+ .filter(([k, v]) => JSON.stringify(v) !== JSON.stringify(defaults[k]));
222
+ if (changed.length) {
223
+ changes.push(
224
+ `${slug}: dropped non-default settings (${changed.map(([k]) => k).join(', ')}) - ${note}`
225
+ );
226
+ }
227
+ }
228
+
229
+ delete plugins[slug];
230
+ dirty = true;
231
+ changes.push(`${slug}: removed the plugins.json entry`);
232
+ }
233
+
234
+ // 2. The directory - but only if it is the one we shipped.
235
+ const dir = path.resolve('plugins', slug);
236
+ if (!fs.existsSync(dir)) continue;
237
+
238
+ let ours = false;
239
+ try {
240
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'plugin.json'), 'utf8'));
241
+ ours = manifest.name === slug && manifest.author === author;
242
+ } catch { /* no readable manifest - leave it alone */ }
243
+
244
+ if (ours) {
245
+ fs.rmSync(dir, {recursive: true, force: true});
246
+ changes.push(`${slug}: removed plugins/${slug}/`);
247
+ }
248
+ }
249
+
250
+ if (dirty) {
251
+ fs.writeFileSync(PLUGINS_CONFIG, JSON.stringify(plugins, null, 2) + '\n');
252
+ }
253
+ return changes;
254
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Public asset CORS
3
+ *
4
+ * A module script is fetched in CORS mode, so `import … from
5
+ * 'https://this-site/public/whatever/index.js'` on another origin fails without
6
+ * Access-Control-Allow-Origin. A site that deliberately wants some of its own
7
+ * assets hot-linkable - engine source, demo code, a shared bundle - needs that
8
+ * header on those paths and nowhere else.
9
+ *
10
+ * `@fastify/cors` is registered once with `config/server.json`'s `cors` block and
11
+ * decides per ORIGIN, not per path, so it cannot express "open reads under this
12
+ * one prefix". Hence `cors.publicPrefixes`.
13
+ *
14
+ * Reads only. This never widens a write, and it never applies to a path that was
15
+ * not explicitly listed.
16
+ */
17
+
18
+ /**
19
+ * Read and sanitise the configured prefixes.
20
+ *
21
+ * A prefix must be an absolute path. Anything else is dropped rather than
22
+ * corrected: a typo that silently matched everything would be the one mistake
23
+ * here with real consequences.
24
+ *
25
+ * @param {object} [corsConfig] - the `cors` block from config/server.json
26
+ * @returns {string[]}
27
+ */
28
+ export function normalisePublicPrefixes(corsConfig) {
29
+ const raw = corsConfig?.publicPrefixes;
30
+ if (!Array.isArray(raw)) return [];
31
+ return raw.filter(p => typeof p === 'string' && p.startsWith('/') && p.length > 1);
32
+ }
33
+
34
+ /**
35
+ * Does this request qualify for the open-read header?
36
+ *
37
+ * @param {string} method
38
+ * @param {string} url - request URL, path first (query is ignored by startsWith)
39
+ * @param {string[]} prefixes - from normalisePublicPrefixes()
40
+ * @returns {boolean}
41
+ */
42
+ export function isPublicAssetRead(method, url, prefixes) {
43
+ if (method !== 'GET' && method !== 'HEAD') return false;
44
+ if (!prefixes.length || typeof url !== 'string') return false;
45
+ return prefixes.some(p => url.startsWith(p));
46
+ }
@@ -12,7 +12,7 @@ import {resolveLocation, resolveMenuDecorations, resolveOverlaysForPage} from '.
12
12
  import {buildMenuNav} from './menuRender.js';
13
13
  import {getProjectForPage} from './projects.js';
14
14
  import {resolveContextMenusForPage} from './contextMenus.js';
15
- import {buildOverrideStyleTag, loadThemeConfig} from './themeSettings.js';
15
+ import {buildOverrideStyleTag, getThemeTokenMap, listThemeIds, loadThemeConfig} from './themeSettings.js';
16
16
  import {getSearchSettings} from './search.js';
17
17
 
18
18
  const VALID_LAYOUT_WIDTHS = new Set(['narrow', 'normal', 'wide', 'full']);
@@ -74,6 +74,80 @@ function buildSearchAssets() {
74
74
  };
75
75
  }
76
76
 
77
+ const THEME_SWITCHER_ASSET_V = '20260922-ts';
78
+ const ANALYTICS_ASSET_V = '20260922-analytics';
79
+
80
+ /**
81
+ * The page-view beacon.
82
+ *
83
+ * Always emitted: analytics was a CORE_PLUGINS member, meaning it could not be
84
+ * switched off, and promotion should not quietly change that. What it counts is
85
+ * decided in the browser - Do-Not-Track, per-session dedup and preview renders
86
+ * all opt out there, where the facts are.
87
+ *
88
+ * Both render paths, the standing trap in this file.
89
+ *
90
+ * @returns {string} the body tag
91
+ */
92
+ function buildAnalyticsTag() {
93
+ return `<script src="/public/js/analytics.js?v=${ANALYTICS_ASSET_V}"></script>`;
94
+ }
95
+
96
+ /**
97
+ * The public theme switcher: stylesheet, theme list, script.
98
+ *
99
+ * The theme LIST is built here from listThemeIds() rather than being written
100
+ * out anywhere. The plugin this replaced shipped 26 hand-written dots against a
101
+ * stylesheet carrying 32 themes, and nothing made the gap visible - deriving it
102
+ * means a theme added to domma-themes.css simply appears.
103
+ *
104
+ * Each dot's colours are that theme's own --dm-primary and --dm-secondary, for
105
+ * the same reason: no second list of colours to drift.
106
+ *
107
+ * Both render paths need this, the same standing trap buildThemeView() and
108
+ * buildSearchAssets() carry.
109
+ *
110
+ * @returns {{headTag: string, bodyTag: string}} Empty strings when it is off
111
+ */
112
+ function buildThemeSwitcherAssets(themeCfg) {
113
+ const sw = themeCfg?.switcher;
114
+ if (!sw || sw.enabled !== true) return {headTag: '', bodyTag: ''};
115
+
116
+ let ids;
117
+ try {
118
+ ids = listThemeIds();
119
+ } catch {
120
+ return {headTag: '', bodyTag: ''};
121
+ }
122
+ const wanted = Array.isArray(sw.themes) && sw.themes.length
123
+ ? ids.filter(id => sw.themes.includes(id))
124
+ : ids;
125
+ if (!wanted.length) return {headTag: '', bodyTag: ''};
126
+
127
+ const themes = wanted.map(id => {
128
+ const tokens = getThemeTokenMap(id) || {};
129
+ return {
130
+ id,
131
+ label: id.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
132
+ from: tokens['--dm-primary'] || tokens['--dm-surface'] || '#888',
133
+ to: tokens['--dm-secondary'] || tokens['--dm-background'] || '#888'
134
+ };
135
+ });
136
+
137
+ const payload = {
138
+ position: sw.position || 'right-center',
139
+ offsetX: sw.offsetX ?? 32,
140
+ offsetY: sw.offsetY ?? 32,
141
+ themes
142
+ };
143
+ const json = JSON.stringify(payload).replace(/<\/script>/gi, '<\\/script>');
144
+ return {
145
+ headTag: `<link rel="stylesheet" href="/public/css/theme-switcher.css?v=${THEME_SWITCHER_ASSET_V}">`,
146
+ bodyTag: `<script>window.__CMS_THEME_SWITCHER__ = ${json};</script>\n`
147
+ + `<script src="/public/js/theme-switcher.js?v=${THEME_SWITCHER_ASSET_V}"></script>`
148
+ };
149
+ }
150
+
77
151
  /**
78
152
  * Strip hidden items from the navigation config before public injection.
79
153
  * Returns a shallow clone - never mutates the cached config object.
@@ -233,6 +307,7 @@ export async function renderPage(page, opts = {}) {
233
307
 
234
308
  const themeView = await buildThemeView(page);
235
309
  const searchAssets = buildSearchAssets();
310
+ const switcherAssets = buildThemeSwitcherAssets(themeView.cfg);
236
311
  const {fontLink, fontStyleTag, autoTheme, activeTheme, dommaTheme, customThemeClass} = themeView;
237
312
 
238
313
  const {navbarFontLink, navbarStyleTag} = buildNavbarStyleTag(navigation);
@@ -322,7 +397,7 @@ export async function renderPage(page, opts = {}) {
322
397
  // Theme overrides land BEFORE content/custom.css: the overrides are
323
398
  // generated, custom.css is hand-written, and the hand-written one stays
324
399
  // the last word.
325
- headInjectLate: [injection.headLate, searchAssets.headTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
400
+ headInjectLate: [injection.headLate, searchAssets.headTag, switcherAssets.headTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
326
401
  .filter(Boolean).join('\n'),
327
402
  bodyEndInject: [
328
403
  // Draft banner - first, so it is the topmost fixed element and a
@@ -335,6 +410,8 @@ export async function renderPage(page, opts = {}) {
335
410
  overlayMenus,
336
411
  injection.bodyEnd,
337
412
  searchAssets.bodyTag,
413
+ switcherAssets.bodyTag,
414
+ buildAnalyticsTag(),
338
415
  site.backToTop?.enabled ? '<script src="/public/js/btt.js"></script>' : '',
339
416
  site.cookieConsent?.enabled ? '<script src="/public/js/cookie-consent.js"></script>' : '',
340
417
  (page.usedComponents || [])
@@ -853,6 +930,7 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
853
930
  // other is the standing trap in this file.
854
931
  const themeView = await buildThemeView({});
855
932
  const searchAssets = buildSearchAssets();
933
+ const switcherAssets = buildThemeSwitcherAssets(themeView.cfg);
856
934
  const {fontLink, fontStyleTag, activeTheme, dommaTheme, customThemeClass} = themeView;
857
935
  const {navbarFontLink, navbarStyleTag} = buildNavbarStyleTag(navigation);
858
936
 
@@ -930,12 +1008,14 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
930
1008
  // Theme overrides land BEFORE content/custom.css: the overrides are
931
1009
  // generated, custom.css is hand-written, and the hand-written one stays
932
1010
  // the last word.
933
- headInjectLate: [injection.headLate, searchAssets.headTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
1011
+ headInjectLate: [injection.headLate, searchAssets.headTag, switcherAssets.headTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
934
1012
  .filter(Boolean).join('\n'),
935
1013
  bodyEndInject: [
936
1014
  overlayMenus,
937
1015
  injection.bodyEnd,
938
1016
  searchAssets.bodyTag,
1017
+ switcherAssets.bodyTag,
1018
+ buildAnalyticsTag(),
939
1019
  (seoMeta.usedComponents || [])
940
1020
  .map(n => `<script type="module" src="/api/components/${n}.js"></script>`)
941
1021
  .join('\n')
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Sidebar badges - what a count route answers.
3
+ *
4
+ * {count, label, tone, details: {title, rows: [{label, value, tone?}], items: [{text, meta?, tone?}], empty?}}
5
+ *
6
+ * `count` and `tone` draw the badge; `details` fills the popover shown on
7
+ * hover. Every value is plain text - the popover sets it with textContent, so
8
+ * a note title or a comment is never markup. See "Live counts" in
9
+ * admin/js/lib/sidebar-renderer.js.
10
+ *
11
+ * @module services/sidebarBadges
12
+ */
13
+
14
+ /** "1 task", "3 tasks". */
15
+ export function plural(n, one, many = `${one}s`) {
16
+ return `${n} ${n === 1 ? one : many}`;
17
+ }
18
+
19
+ /** "just now", "5 min ago", "3 hours ago", "2 days ago", then the date. */
20
+ export function ago(iso, now = Date.now()) {
21
+ const t = new Date(iso).getTime();
22
+ if (!Number.isFinite(t)) return '';
23
+ const s = Math.max(0, Math.round((now - t) / 1000));
24
+ if (s < 60) return 'just now';
25
+ if (s < 3600) return `${Math.round(s / 60)} min ago`;
26
+ if (s < 86400) return plural(Math.round(s / 3600), 'hour') + ' ago';
27
+ if (s < 86400 * 14) return plural(Math.round(s / 86400), 'day') + ' ago';
28
+ return new Date(t).toISOString().slice(0, 10);
29
+ }
30
+
31
+ /** Shorten to `max` characters on a word boundary, with an ellipsis. */
32
+ export function clip(text, max = 60) {
33
+ const s = String(text ?? '').replace(/\s+/g, ' ').trim();
34
+ if (s.length <= max) return s;
35
+ const cut = s.slice(0, max - 1);
36
+ return `${cut.slice(0, cut.lastIndexOf(' ') > max * 0.6 ? cut.lastIndexOf(' ') : cut.length)}…`;
37
+ }