domma-cms 0.66.0 → 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 (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 +92 -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 +307 -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,307 @@
1
+ /**
2
+ * Site .gitignore - the content-only allowlist
3
+ *
4
+ * A site repo holds the site's CONTENT and nothing else: pages, media, blocks,
5
+ * views, forms, components, collections the site built, API endpoints, menus,
6
+ * theme and every other config/ file, plus plugins the site wrote itself. The
7
+ * engine - server/, admin/, public/, scripts/, node_modules/ (which carries
8
+ * domma-js and domma-reactive) - and every distributed plugin arrive through
9
+ * the updater, so they are never committed.
10
+ *
11
+ * The file is an ALLOWLIST: ignore everything at the root, then re-include what
12
+ * the site owns. A denylist would have to know every engine path in advance and
13
+ * would start tracking whatever a new release adds; an allowlist can only fail
14
+ * by leaving something out, and the two places a site grows new owned code
15
+ * append their own rule through `allowInGitignore()`:
16
+ * - pluginScaffold.js: a plugin created in the admin is the site's code
17
+ * - pluginInstaller.js: a distributed plugin that stores what users author
18
+ * inside its own folder declares it in plugin.json `contentPaths`
19
+ *
20
+ * ONE COPY, TWO REPOS. domma-cms-manager keeps a byte-identical copy at
21
+ * plugins/site-manager/services/siteGitignore.js (its production install runs
22
+ * an npm domma-cms that may predate this file), and a test there fails when the
23
+ * two drift. Change this one, then copy it across.
24
+ *
25
+ * No imports beyond node built-ins: bin/cli.js loads this before a site exists.
26
+ */
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+
30
+ export const MANAGED_HEADER = '# ── Managed by Domma: content-only allowlist (server/services/siteGitignore.js) ──';
31
+ export const SITE_RULES_MARKER = '# ── Site-specific rules (kept when this file is regenerated) ──';
32
+
33
+ /** Top-level entries that belong to the engine or the host, never to the site. */
34
+ export const ENGINE_TOP_LEVEL = new Set([
35
+ 'server', 'admin', 'public', 'scripts', 'node_modules', 'bin',
36
+ 'package.json', 'package-lock.json', '.cmsVersion', '.domma', 'CLAUDE.md',
37
+ 'logs', 'backups', '_backups', '.domma-backups', '.env', '.env.example', '.claude', '.git'
38
+ ]);
39
+
40
+ /** Always-kept roots. public/ is handled separately: only site-added entries in it are kept. */
41
+ const SITE_ROOTS = ['content', 'config', 'plugins'];
42
+
43
+ /**
44
+ * Plugins that were promoted into core (0.67.0) or retired. A site that has not
45
+ * yet booted a release that removes them still has the folder; it is engine,
46
+ * not the site's.
47
+ */
48
+ export const RETIRED_PLUGINS = [
49
+ 'analytics', 'contacts', 'notes', 'todo', 'demo-viewer', 'theme-switcher', 'site-search'
50
+ ];
51
+
52
+ /**
53
+ * Distributed plugins that keep what users AUTHOR inside their own folder, for
54
+ * installs whose plugin.json predates `contentPaths`. Both ship the folder
55
+ * empty, so everything in it was written by the site.
56
+ */
57
+ export const KNOWN_PLUGIN_CONTENT = {
58
+ 'docs': ['data'], // documents, folders, templates, versions
59
+ 'theme-roller': ['data/themes'] // themes built in the roller
60
+ };
61
+
62
+ /**
63
+ * Collections whose schema.json the CMS rewrites on every boot
64
+ * (presetCollections.seedAll, roles.seed, userProfiles). Tracking them records
65
+ * the engine's schema, not the site's, and shows a diff after every update.
66
+ * Their data.json is a separate question - see PRIVATE_DATA.
67
+ */
68
+ export const ENGINE_SCHEMAS = [
69
+ 'notifications', 'projects', 'api-tokens', 'api-endpoints', 'notes-notes', 'todo-items',
70
+ 'contacts-contacts', 'contacts-groups', 'roles', 'user-profiles'
71
+ ];
72
+
73
+ /**
74
+ * Collection entries that are secrets, visitor-submitted or per-user private
75
+ * data, or runtime state - not something the site's authors built. Everything
76
+ * else in content/collections is tracked, including every collection a site
77
+ * creates, projects and api-endpoints.
78
+ */
79
+ export const PRIVATE_DATA = [
80
+ 'notifications', 'api-tokens', 'notes-notes', 'todo-items',
81
+ 'contacts-contacts', 'contacts-groups', 'roles', 'user-profiles',
82
+ 'user-messages', 'user-message-threads', 'analytics-events', 'enquiries',
83
+ 'blog-comments', 'invoices', 'invoice-receivers', 'invoice-issuers',
84
+ 'surveys-contacts', 'surveys-invites'
85
+ ];
86
+
87
+ /**
88
+ * Lines bin/update.js appends when it cannot find them VERBATIM. They are
89
+ * already ignored by `/*` and `/plugins/*`, but writing them out keeps the
90
+ * updater from editing the file on every site after every release.
91
+ */
92
+ const UPDATER_REQUIRED = ['.domma-backups/', 'plugins/analytics/data/'];
93
+
94
+ /**
95
+ * Split a plugin folder listing into distributed and site-owned.
96
+ *
97
+ * @param {string[]} sitePlugins - Directory names under the site's plugins/
98
+ * @param {Iterable<string>} distributed - Every plugin name an upstream ships
99
+ * @returns {{distributed: string[], siteOwned: string[]}}
100
+ */
101
+ export function classifyPlugins(sitePlugins, distributed) {
102
+ const known = new Set([...distributed, ...RETIRED_PLUGINS]);
103
+ const out = {distributed: [], siteOwned: []};
104
+ for (const name of [...sitePlugins].sort()) {
105
+ if (name.startsWith('.')) continue;
106
+ (known.has(name) ? out.distributed : out.siteOwned).push(name);
107
+ }
108
+ return out;
109
+ }
110
+
111
+ /**
112
+ * Rules that re-include a site-owned plugin.
113
+ *
114
+ * @param {string} name
115
+ * @returns {string[]}
116
+ */
117
+ export function sitePluginRules(name) {
118
+ return [`!/plugins/${name}/`];
119
+ }
120
+
121
+ /**
122
+ * Rules that re-include ONE content folder inside an otherwise ignored
123
+ * distributed plugin. Git cannot re-include a file whose parent is excluded,
124
+ * so every level is opened and its siblings closed again:
125
+ * data/themes -> !plugins/x/, plugins/x/*, !plugins/x/data/, plugins/x/data/*, !plugins/x/data/themes/
126
+ *
127
+ * @param {string} name - Plugin folder name
128
+ * @param {string} rel - Folder inside the plugin, e.g. 'data' or 'data/themes'
129
+ * @returns {string[]}
130
+ */
131
+ export function pluginContentRules(name, rel) {
132
+ const parts = rel.split('/').filter(p => p && p !== '.' && p !== '..');
133
+ if (!parts.length) return [];
134
+ const out = [`!/plugins/${name}/`, `/plugins/${name}/*`];
135
+ for (let i = 1; i <= parts.length; i++) {
136
+ const sub = parts.slice(0, i).join('/');
137
+ out.push(`!/plugins/${name}/${sub}/`);
138
+ if (i < parts.length) out.push(`/plugins/${name}/${sub}/*`);
139
+ }
140
+ return out;
141
+ }
142
+
143
+ /**
144
+ * Build the .gitignore text.
145
+ *
146
+ * @param {object} [opts]
147
+ * @param {string[]} [opts.sitePlugins=[]] - Site-owned plugin folder names
148
+ * @param {Object<string,string[]>} [opts.pluginContent={}] - Extra content folders in distributed plugins
149
+ * @param {string[]} [opts.sitePublic=[]] - Site-added entries directly under public/
150
+ * @param {string[]} [opts.extras=[]] - Other site-owned top-level entries (docs, tools, README.md)
151
+ * @param {string[]} [opts.siteRules=[]] - The site-specific section
152
+ * @returns {string}
153
+ */
154
+ export function buildGitignore({sitePlugins = [], pluginContent = {}, sitePublic = [], extras = [], siteRules = []} = {}) {
155
+ const L = [
156
+ MANAGED_HEADER,
157
+ '# This repo holds the site\'s CONTENT only. Everything is ignored, then what',
158
+ '# the site owns is re-included. The engine (server/, admin/, public/, scripts/,',
159
+ '# node_modules/ incl. domma-js + domma-reactive) and distributed plugins come',
160
+ '# from the updater and are never committed. A plugin created in the admin adds',
161
+ '# its own rule to the site section below.',
162
+ '/*',
163
+ '!/.gitignore'
164
+ ];
165
+ for (const root of SITE_ROOTS) L.push(`!/${root}/`);
166
+ for (const x of [...extras].sort()) L.push(`!/${x}${x.includes('.') ? '' : '/'}`);
167
+
168
+ L.push('', '# Plugins: only the ones this site wrote itself', '/plugins/*');
169
+ for (const p of [...sitePlugins].sort()) L.push(...sitePluginRules(p));
170
+
171
+ const content = {};
172
+ for (const [name, rels] of [...Object.entries(KNOWN_PLUGIN_CONTENT), ...Object.entries(pluginContent)]) {
173
+ if (sitePlugins.includes(name)) continue; // already tracked whole
174
+ content[name] = [...new Set([...(content[name] || []), ...rels])];
175
+ }
176
+ L.push('', '# ...and what users author inside a distributed plugin (its code stays out)');
177
+ for (const name of Object.keys(content).sort()) {
178
+ const seen = new Set();
179
+ for (const rel of content[name].sort()) {
180
+ for (const r of pluginContentRules(name, rel)) if (!seen.has(r)) { seen.add(r); L.push(r); }
181
+ }
182
+ }
183
+
184
+ if (sitePublic.length) {
185
+ L.push('', '# public/ is engine except what the site added to it', '!/public/', '/public/*');
186
+ for (const p of [...sitePublic].sort()) L.push(`!/public/${p}`);
187
+ }
188
+
189
+ L.push('', '# Schemas the CMS rewrites on every boot - engine-owned, not authored');
190
+ for (const c of ENGINE_SCHEMAS) L.push(`/content/collections/${c}/schema.json`);
191
+ L.push('', '# Secrets, visitor-submitted and per-user data, runtime state');
192
+ for (const c of PRIVATE_DATA) L.push(`/content/collections/${c}/data.json`);
193
+ L.push(
194
+ '/content/users/',
195
+ '/content/versions/',
196
+ '/content/analytics/',
197
+ '/content/instances_log.json',
198
+ '/content/preview-links.json',
199
+ '/config/connections.json*',
200
+ '/config/keys/',
201
+ '/plugins/*/data/*.jsonl',
202
+ '/plugins/*/backups/',
203
+ ...UPDATER_REQUIRED,
204
+ '',
205
+ '# Cruft',
206
+ '*.log', '*.pid', '*.bak', '*.bak-*', '*.orig', '*.swp', '*.prev-*/', '.DS_Store',
207
+ '',
208
+ SITE_RULES_MARKER,
209
+ ...siteRules
210
+ );
211
+ return L.join('\n') + '\n';
212
+ }
213
+
214
+ /** Every non-comment rule above the site section, for de-duplicating carried rules. */
215
+ export function generatedRules(text) {
216
+ const end = text.indexOf(SITE_RULES_MARKER);
217
+ return (end === -1 ? text : text.slice(0, end)).split('\n').map(l => l.trim())
218
+ .filter(l => l && !l.startsWith('#'));
219
+ }
220
+
221
+ /**
222
+ * Old rules that would hide a whole content root. The first scaffolded
223
+ * .gitignore ignored content/media/, and media is content - carrying that rule
224
+ * would keep every uploaded image out of the repo that exists to hold them.
225
+ */
226
+ const NEVER_CARRY = /^(content|config|content\/(media|pages|blocks|components|forms|views|collections))\/?\*?$/;
227
+
228
+ function norm(rule) {
229
+ return rule.replace(/^\//, '').replace(/\/$/, '');
230
+ }
231
+
232
+ /**
233
+ * Pull the site-specific section out of an existing .gitignore.
234
+ *
235
+ * A file this module wrote keeps everything after the marker verbatim. A
236
+ * hand-written file has no marker, so only the rules that still narrow
237
+ * something kept (content/, config/, a kept plugin or public entry) are
238
+ * carried; engine paths are covered by the allowlist and dropped.
239
+ *
240
+ * @param {string} existing - Current .gitignore text ('' if none)
241
+ * @param {{keptPlugins: string[], keptPublic: string[]}} kept
242
+ * @param {string[]} generated - Rules the new file already has
243
+ * @returns {string[]}
244
+ */
245
+ export function carrySiteRules(existing, {keptPlugins, keptPublic}, generated) {
246
+ if (!existing) return [];
247
+ const idx = existing.indexOf(SITE_RULES_MARKER);
248
+ if (idx !== -1) {
249
+ return existing.slice(idx + SITE_RULES_MARKER.length).replace(/^\n+/, '').replace(/\n+$/, '').split('\n')
250
+ .filter(l => l.length);
251
+ }
252
+ const have = new Set(generated.map(norm));
253
+ // A generated `foo*` already covers every carried rule that starts with foo.
254
+ const globs = generated.filter(r => r.endsWith('*') && !r.startsWith('!')).map(r => norm(r).slice(0, -1))
255
+ .filter(g => g.includes('/') && !g.endsWith('/') && !g.includes('*'));
256
+ const keptPrefixes = [
257
+ 'content/', 'config/',
258
+ ...keptPlugins.map(p => `plugins/${p}/`),
259
+ ...keptPublic.map(p => `public/${p}`)
260
+ ];
261
+ const carried = [];
262
+ for (const raw of existing.split('\n')) {
263
+ const line = raw.trim();
264
+ if (!line || line.startsWith('#')) continue;
265
+ const bare = line.replace(/^!?\//, '').replace(/^!/, '');
266
+ if (!keptPrefixes.some(p => bare.startsWith(p))) continue;
267
+ if (NEVER_CARRY.test(bare)) continue;
268
+ if (have.has(norm(line)) || globs.some(g => norm(line).startsWith(g))) continue;
269
+ carried.push(line);
270
+ }
271
+ return carried;
272
+ }
273
+
274
+ /**
275
+ * Add rules to the site section of a managed .gitignore, skipping any already
276
+ * present. A missing or hand-written file is left alone: the site chose its own
277
+ * rules and this must not rewrite them.
278
+ *
279
+ * @param {string} siteRoot - Directory holding .gitignore
280
+ * @param {string[]} rules
281
+ * @returns {string[]} The rules actually added
282
+ */
283
+ export function allowInGitignore(siteRoot, rules) {
284
+ const file = path.join(siteRoot, '.gitignore');
285
+ let text;
286
+ try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
287
+ if (!text.startsWith(MANAGED_HEADER) || !text.includes(SITE_RULES_MARKER)) return [];
288
+ const present = new Set(text.split('\n').map(l => l.trim()));
289
+ const added = rules.filter(r => !present.has(r));
290
+ if (added.length) fs.writeFileSync(file, text.replace(/\n*$/, '\n') + added.join('\n') + '\n');
291
+ return added;
292
+ }
293
+
294
+ /**
295
+ * The content folders a plugin manifest declares, sanitised. `contentPaths`
296
+ * is a list of folders relative to the plugin root; anything that could climb
297
+ * out of it is dropped.
298
+ *
299
+ * @param {object} manifest - Parsed plugin.json
300
+ * @returns {string[]}
301
+ */
302
+ export function manifestContentPaths(manifest) {
303
+ const raw = Array.isArray(manifest?.contentPaths) ? manifest.contentPaths : [];
304
+ return raw.filter(p => typeof p === 'string')
305
+ .map(p => p.replace(/^\.?\/+/, '').replace(/\/+$/, ''))
306
+ .filter(p => p && !p.split('/').some(s => s === '..' || s === '.' || s === ''));
307
+ }
@@ -85,6 +85,20 @@ export function defaultThemeConfig() {
85
85
  nightStart: '19:00'
86
86
  },
87
87
  font: {family: 'Roboto', size: 16},
88
+ // The public theme switcher - a floating disc of theme dots. Off by
89
+ // default: a visitor-facing theme picker is a choice a site makes, not
90
+ // something it should acquire by upgrading.
91
+ //
92
+ // `themes: []` means every theme the stylesheet ships. It is a filter,
93
+ // never a list to maintain - listThemeIds() is the source of truth, and
94
+ // the plugin this replaced hardcoded 26 dots against 32 real themes.
95
+ switcher: {
96
+ enabled: false,
97
+ position: 'right-center',
98
+ offsetX: 32,
99
+ offsetY: 32,
100
+ themes: []
101
+ },
88
102
  overrides: []
89
103
  };
90
104
  }
@@ -223,6 +237,7 @@ function mergeDefaults(raw) {
223
237
  const cfg = {...d, ...definedOnly(raw)};
224
238
  cfg.autoTheme = {...d.autoTheme, ...definedOnly(raw?.autoTheme)};
225
239
  cfg.font = {...d.font, ...definedOnly(raw?.font)};
240
+ cfg.switcher = {...d.switcher, ...definedOnly(raw?.switcher)};
226
241
  cfg.overrides = Array.isArray(raw?.overrides) ? raw.overrides : [];
227
242
  return cfg;
228
243
  }
@@ -298,6 +313,9 @@ export async function saveThemeConfig(input) {
298
313
  ...input,
299
314
  autoTheme: {...current.autoTheme, ...(input.autoTheme || {})},
300
315
  font: {...current.font, ...(input.font || {})},
316
+ // Merged like the others so a partial save - {switcher: {enabled: true}}
317
+ // from an API client - keeps the placement rather than resetting it.
318
+ switcher: {...current.switcher, ...(input.switcher || {})},
301
319
  overrides: Array.isArray(input.overrides) ? input.overrides.map(normaliseOverride) : current.overrides
302
320
  });
303
321
  cfg.version = 1;
@@ -435,6 +453,27 @@ export function validateThemeConfig(input) {
435
453
  }
436
454
  }
437
455
  }
456
+ if (input.switcher) {
457
+ const sw = input.switcher;
458
+ const POSITIONS = ['right-center', 'left-center', 'bottom-right', 'bottom-left',
459
+ 'top-left', 'top-right'];
460
+ if (sw.position !== undefined && !POSITIONS.includes(sw.position)) {
461
+ errors.push(`switcher.position must be one of: ${POSITIONS.join(', ')}`);
462
+ }
463
+ for (const key of ['offsetX', 'offsetY']) {
464
+ const v = sw[key];
465
+ if (v !== undefined && (!Number.isFinite(Number(v)) || Number(v) < 0 || Number(v) > 400)) {
466
+ errors.push(`switcher.${key} must be between 0 and 400`);
467
+ }
468
+ }
469
+ if (sw.themes !== undefined) {
470
+ if (!Array.isArray(sw.themes)) {
471
+ errors.push('switcher.themes must be an array');
472
+ } else {
473
+ for (const id of sw.themes) checkTheme(id, 'switcher.themes');
474
+ }
475
+ }
476
+ }
438
477
  if (input.font) {
439
478
  const size = input.font.size;
440
479
  if (size !== undefined && (!Number.isFinite(Number(size)) || Number(size) < 8 || Number(size) > 48)) {
package/config/theme.json DELETED
@@ -1,18 +0,0 @@
1
- {
2
- "version": 1,
3
- "theme": "silver-dark",
4
- "adminTheme": "silver-dark",
5
- "baseTheme": null,
6
- "autoTheme": {
7
- "enabled": false,
8
- "dayTheme": "charcoal-light",
9
- "nightTheme": "charcoal-dark",
10
- "dayStart": "07:00",
11
- "nightStart": "19:00"
12
- },
13
- "font": {
14
- "family": "Nunito",
15
- "size": 18
16
- },
17
- "overrides": []
18
- }
@@ -1 +0,0 @@
1
- .analytics-filter{max-width:240px}
@@ -1,70 +0,0 @@
1
- <div class="view-header">
2
- <h1><span data-icon="chart-bar"></span> Analytics</h1>
3
- <div class="view-header-actions">
4
- <button id="export-btn" class="btn btn-ghost btn-sm" type="button">
5
- <span data-icon="download"></span> Export CSV
6
- </button>
7
- <button id="reset-btn" class="btn btn-ghost btn-sm">
8
- <span data-icon="trash"></span> Reset stats
9
- </button>
10
- </div>
11
- </div>
12
-
13
- <div class="card mb-4">
14
- <div class="card-body">
15
- <div id="range-tabs" class="flex gap-2 flex-wrap">
16
- <button class="btn btn-ghost btn-sm range-btn" data-range="today">Today</button>
17
- <button class="btn btn-ghost btn-sm range-btn active" data-range="7d">Last 7 days</button>
18
- <button class="btn btn-ghost btn-sm range-btn" data-range="30d">Last 30 days</button>
19
- <button class="btn btn-ghost btn-sm range-btn" data-range="90d">Last 90 days</button>
20
- <button class="btn btn-ghost btn-sm range-btn" data-range="all">All time</button>
21
- </div>
22
- </div>
23
- </div>
24
-
25
- <div class="grid grid-cols-3 gap-4 mb-4">
26
- <div class="card stat-card">
27
- <div class="card-body">
28
- <div class="stat-icon"><span data-icon="activity"></span></div>
29
- <div>
30
- <div class="stat-value" id="kpi-range">-</div>
31
- <div class="stat-label">Hits in range</div>
32
- </div>
33
- </div>
34
- </div>
35
- <div class="card stat-card">
36
- <div class="card-body">
37
- <div class="stat-icon"><span data-icon="file-text"></span></div>
38
- <div>
39
- <div class="stat-value" id="kpi-pages">-</div>
40
- <div class="stat-label">Unique pages</div>
41
- </div>
42
- </div>
43
- </div>
44
- <div class="card stat-card">
45
- <div class="card-body">
46
- <div class="stat-icon stat-icon--success"><span data-icon="trending-up"></span></div>
47
- <div>
48
- <div class="stat-value" id="kpi-lifetime">-</div>
49
- <div class="stat-label">Lifetime hits</div>
50
- </div>
51
- </div>
52
- </div>
53
- </div>
54
-
55
- <div class="card mb-4">
56
- <div class="card-header"><strong>Hits over time</strong></div>
57
- <div class="card-body">
58
- <canvas id="trend-chart" height="90"></canvas>
59
- </div>
60
- </div>
61
-
62
- <div class="card">
63
- <div class="card-header flex justify-between items-center">
64
- <strong>Top pages</strong>
65
- <input id="filter-input" type="search" class="form-input form-input-sm analytics-filter" placeholder="Filter URL…">
66
- </div>
67
- <div class="card-body">
68
- <div id="analytics-table"></div>
69
- </div>
70
- </div>
@@ -1,176 +0,0 @@
1
- /**
2
- * Analytics Plugin - Admin View
3
- * KPI cards, trend chart (Chart.js via CDN), filterable top-pages table, CSV export.
4
- */
5
- const CHART_CDN = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js';
6
-
7
- let chartInstance = null;
8
- let currentRange = '7d';
9
- let currentTop = [];
10
- let filterTerm = '';
11
-
12
- export const analyticsView = {
13
- templateUrl: '/plugins/analytics/admin/templates/analytics.html',
14
-
15
- async onMount($container) {
16
- await ensureChartJs();
17
- await refresh($container, currentRange);
18
- wireRangeTabs($container);
19
- wireExport($container);
20
- wireReset($container);
21
- wireFilter($container);
22
- Domma.icons.scan();
23
- }
24
- };
25
-
26
- function authHeaders() {
27
- return { 'Authorization': 'Bearer ' + (S.get('auth_token') || '') };
28
- }
29
-
30
- function ensureChartJs() {
31
- if (window.Chart) return Promise.resolve();
32
- return new Promise((resolve, reject) => {
33
- const s = document.createElement('script');
34
- s.src = CHART_CDN;
35
- s.async = true;
36
- s.onload = () => resolve();
37
- s.onerror = () => reject(new Error('chart.js load failed'));
38
- document.head.appendChild(s);
39
- });
40
- }
41
-
42
- async function refresh($container, range) {
43
- currentRange = range;
44
- try {
45
- const [stats, series] = await Promise.all([
46
- fetch('/api/plugins/analytics/stats?range=' + encodeURIComponent(range), { headers: authHeaders() })
47
- .then((r) => r.json()),
48
- fetch('/api/plugins/analytics/series?range=' + encodeURIComponent(range === 'all' ? '30d' : range), { headers: authHeaders() })
49
- .then((r) => r.json())
50
- ]);
51
-
52
- $container.find('#kpi-range').text(stats.total.toLocaleString());
53
- $container.find('#kpi-pages').text(stats.uniquePages.toLocaleString());
54
- $container.find('#kpi-lifetime').text(stats.lifetimeTotal.toLocaleString());
55
-
56
- currentTop = stats.top || [];
57
- renderTable($container);
58
- renderChart($container, series.series || []);
59
- updateExportLink($container);
60
- } catch (err) {
61
- E.toast('Could not load analytics data.', { type: 'error' });
62
- }
63
- }
64
-
65
- function renderTable($container) {
66
- const filtered = filterTerm
67
- ? currentTop.filter((row) => row.url.toLowerCase().includes(filterTerm))
68
- : currentTop;
69
-
70
- T.create('#analytics-table', {
71
- data: filtered,
72
- columns: [
73
- { key: 'url', title: 'Page URL', render: (val) => `<code>${val}</code>` },
74
- { key: 'hits', title: 'Page views' }
75
- ],
76
- emptyMessage: filterTerm ? 'No pages match that filter.' : 'No page views recorded yet.'
77
- });
78
- }
79
-
80
- function renderChart($container, series) {
81
- const canvas = $container.find('#trend-chart').get(0);
82
- if (!canvas || !window.Chart) return;
83
- if (chartInstance) {
84
- chartInstance.destroy();
85
- chartInstance = null;
86
- }
87
- chartInstance = new window.Chart(canvas, {
88
- type: 'line',
89
- data: {
90
- labels: series.map((p) => p.date),
91
- datasets: [{
92
- label: 'Hits',
93
- data: series.map((p) => p.hits),
94
- borderColor: 'rgba(99, 102, 241, 1)',
95
- backgroundColor: 'rgba(99, 102, 241, 0.15)',
96
- fill: true,
97
- tension: 0.3,
98
- borderWidth: 2,
99
- pointRadius: 2
100
- }]
101
- },
102
- options: {
103
- responsive: true,
104
- maintainAspectRatio: false,
105
- plugins: { legend: { display: false } },
106
- scales: {
107
- y: { beginAtZero: true, ticks: { precision: 0 } },
108
- x: { ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 10 } }
109
- }
110
- }
111
- });
112
- }
113
-
114
- function wireRangeTabs($container) {
115
- $container.find('.range-btn').on('click', function () {
116
- const btn = $(this);
117
- $container.find('.range-btn').removeClass('active');
118
- btn.addClass('active');
119
- refresh($container, btn.attr('data-range'));
120
- });
121
- }
122
-
123
- function wireExport($container) {
124
- $container.find('#export-btn').on('click', async () => {
125
- try {
126
- const res = await fetch('/api/plugins/analytics/export.csv?range=' + encodeURIComponent(currentRange), {
127
- headers: authHeaders()
128
- });
129
- if (!res.ok) throw new Error('export failed');
130
- const blob = await res.blob();
131
- const cd = res.headers.get('Content-Disposition') || '';
132
- const match = cd.match(/filename="?([^"]+)"?/);
133
- const filename = match ? match[1] : 'analytics.csv';
134
- const url = URL.createObjectURL(blob);
135
- const a = document.createElement('a');
136
- a.href = url;
137
- a.download = filename;
138
- document.body.appendChild(a);
139
- a.click();
140
- a.remove();
141
- URL.revokeObjectURL(url);
142
- } catch {
143
- E.toast('Export failed.', { type: 'error' });
144
- }
145
- });
146
- }
147
-
148
- function updateExportLink() {
149
- // no-op; export button uses fetch+blob to preserve Bearer auth
150
- }
151
-
152
- function wireReset($container) {
153
- $container.find('#reset-btn').on('click', async () => {
154
- const confirmed = await E.confirm('Reset all analytics data? This cannot be undone.');
155
- if (!confirmed) return;
156
- try {
157
- const res = await fetch('/api/plugins/analytics/stats', {
158
- method: 'DELETE',
159
- headers: authHeaders()
160
- });
161
- if (!res.ok) throw new Error('reset failed');
162
- E.toast('Analytics reset.', { type: 'success' });
163
- await refresh($container, currentRange);
164
- } catch {
165
- E.toast('Reset failed.', { type: 'error' });
166
- }
167
- });
168
- }
169
-
170
- function wireFilter($container) {
171
- const input = $container.find('#filter-input');
172
- input.on('input', () => {
173
- filterTerm = (input.val() || '').toString().toLowerCase().trim();
174
- renderTable($container);
175
- });
176
- }
@@ -1,14 +0,0 @@
1
- /**
2
- * Analytics Plugin - Default Configuration
3
- *
4
- * excludeAdmin strip /admin pageviews before recording
5
- * excludePaths array of path prefixes to ignore (e.g. ['/api', '/healthz'])
6
- * honourDnt respect navigator.doNotTrack on the client
7
- * sessionDedup only record one hit per URL per browser session
8
- */
9
- export default {
10
- excludeAdmin: true,
11
- excludePaths: ['/api'],
12
- honourDnt: true,
13
- sessionDedup: true
14
- };