domma-cms 0.54.2 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CLAUDE.md +151 -77
  2. package/README.md +4 -4
  3. package/admin/css/admin.css +1 -1
  4. package/admin/dist/domma/domma-tools.css +3 -3
  5. package/admin/dist/domma/domma-tools.min.js +3 -3
  6. package/admin/js/api.js +1 -1
  7. package/admin/js/app.js +3 -3
  8. package/admin/js/lib/page-picker.js +1 -0
  9. package/admin/js/lib/plugin-accent.js +1 -0
  10. package/admin/js/lib/plugin-chrome.js +1 -0
  11. package/admin/js/lib/shortcode-context-menu.js +2 -2
  12. package/admin/js/lib/sidebar-grouping.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  14. package/admin/js/lib/sidebar-renderer.js +4 -4
  15. package/admin/js/lib/slideover-resizable.js +1 -0
  16. package/admin/js/templates/context-menu-editor.html +212 -0
  17. package/admin/js/templates/context-menus.html +16 -0
  18. package/admin/js/templates/menu-editor.html +19 -25
  19. package/admin/js/templates/plugin-code.html +1 -1
  20. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  21. package/admin/js/templates/settings.html +27 -97
  22. package/admin/js/templates/theme.html +173 -0
  23. package/admin/js/views/context-menu-editor.js +55 -0
  24. package/admin/js/views/context-menus.js +5 -0
  25. package/admin/js/views/form-editor.js +7 -7
  26. package/admin/js/views/index.js +1 -1
  27. package/admin/js/views/menu-editor.js +13 -13
  28. package/admin/js/views/plugin-marketplace.js +1 -1
  29. package/admin/js/views/plugins.js +25 -23
  30. package/admin/js/views/search.js +1 -0
  31. package/admin/js/views/settings.js +3 -3
  32. package/admin/js/views/theme.js +1 -0
  33. package/bin/cli.js +11 -2
  34. package/bin/lib/smtp-defaults.js +53 -0
  35. package/bin/update.js +13 -2
  36. package/config/menus/admin-sidebar.json +129 -23
  37. package/config/plugins.json +5 -5
  38. package/config/search.json +13 -0
  39. package/config/theme.json +18 -0
  40. package/package.json +12 -4
  41. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  42. package/plugins/_lib/admin/mail/contacts.js +301 -0
  43. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  44. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  45. package/plugins/_lib/admin/mail/identity.js +480 -0
  46. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  47. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  48. package/plugins/_lib/admin/mail/mail.css +1 -0
  49. package/plugins/_lib/admin/mail/mail.html +71 -0
  50. package/plugins/_lib/admin/mail/panes.js +253 -0
  51. package/plugins/_lib/admin/mail/reader-view.js +4453 -0
  52. package/plugins/_lib/admin/mail/resizable.js +26 -0
  53. package/plugins/_lib/admin/mail/rules.js +343 -0
  54. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  55. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  56. package/plugins/_lib/admin/mail/templates.js +238 -0
  57. package/plugins/_lib/admin/mail/threads.js +200 -0
  58. package/plugins/_lib/admin/mail/vacation.js +269 -0
  59. package/plugins/_lib/admin/ui/help.css +1 -0
  60. package/plugins/_lib/admin/ui/help.js +174 -0
  61. package/plugins/_lib/admin/ui/resizable.js +151 -0
  62. package/plugins/_lib/dataStore.js +117 -0
  63. package/plugins/_lib/mail/accounts.js +919 -0
  64. package/plugins/_lib/mail/bodyTokens.js +101 -0
  65. package/plugins/_lib/mail/compose.js +256 -0
  66. package/plugins/_lib/mail/defaults.js +57 -0
  67. package/plugins/_lib/mail/diagnostics.js +154 -0
  68. package/plugins/_lib/mail/envelope.js +161 -0
  69. package/plugins/_lib/mail/folders.js +192 -0
  70. package/plugins/_lib/mail/handoff.js +274 -0
  71. package/plugins/_lib/mail/imapPool.js +293 -0
  72. package/plugins/_lib/mail/mbox.js +74 -0
  73. package/plugins/_lib/mail/pollSchedule.js +79 -0
  74. package/plugins/_lib/mail/poller.js +135 -0
  75. package/plugins/_lib/mail/priority.js +138 -0
  76. package/plugins/_lib/mail/readRoutes.js +680 -0
  77. package/plugins/_lib/mail/render.js +291 -0
  78. package/plugins/_lib/mail/ruleRunner.js +152 -0
  79. package/plugins/_lib/mail/scheduler.js +254 -0
  80. package/plugins/_lib/mail/secretbox.js +229 -0
  81. package/plugins/_lib/mail/send.js +396 -0
  82. package/plugins/_lib/mail/store.js +1002 -0
  83. package/plugins/_lib/mail/sync.js +292 -0
  84. package/plugins/_lib/mail/syncPlan.js +126 -0
  85. package/plugins/_lib/mail/syncSelection.js +82 -0
  86. package/plugins/_lib/mail/unsubscribe.js +183 -0
  87. package/plugins/_lib/mail/vacationRunner.js +114 -0
  88. package/plugins/_lib/mail/write.js +473 -0
  89. package/plugins/_lib/schemaSync.js +83 -0
  90. package/plugins/_template/admin/css/index.css +0 -0
  91. package/plugins/_template/admin/templates/index.html +4 -4
  92. package/plugins/_template/admin/views/index.js +7 -0
  93. package/plugins/analytics/admin/css/index.css +1 -0
  94. package/plugins/analytics/admin/templates/analytics.html +22 -13
  95. package/plugins/analytics/plugin.json +3 -0
  96. package/plugins/blog/admin/css/index.css +1 -0
  97. package/plugins/blog/admin/templates/blog.html +30 -18
  98. package/plugins/blog/admin/templates/categories.html +2 -2
  99. package/plugins/blog/admin/templates/comments.html +2 -2
  100. package/plugins/blog/admin/templates/post-editor.html +34 -34
  101. package/plugins/blog/admin/templates/settings.html +6 -3
  102. package/plugins/blog/admin/views/blog.js +8 -5
  103. package/plugins/blog/admin/views/categories.js +5 -10
  104. package/plugins/blog/admin/views/comments.js +5 -5
  105. package/plugins/blog/admin/views/post-editor.js +39 -20
  106. package/plugins/blog/admin/views/settings.js +52 -50
  107. package/plugins/blog/collections/categories/schema.json +7 -6
  108. package/plugins/blog/collections/comments/schema.json +11 -10
  109. package/plugins/blog/collections/posts/schema.json +14 -13
  110. package/plugins/blog/plugin.js +36 -13
  111. package/plugins/blog/plugin.json +13 -5
  112. package/plugins/blog/plugin.public.js +312 -0
  113. package/plugins/contacts/admin/css/index.css +1 -0
  114. package/plugins/contacts/admin/templates/contacts.html +128 -0
  115. package/plugins/contacts/admin/views/contacts.js +237 -4
  116. package/plugins/contacts/collections/user-contacts/schema.json +108 -0
  117. package/plugins/contacts/plugin.js +214 -27
  118. package/plugins/contacts/plugin.json +4 -1
  119. package/plugins/invoice/admin/css/index.css +1 -0
  120. package/plugins/invoice/admin/templates/editor.html +140 -49
  121. package/plugins/invoice/admin/templates/index.html +153 -23
  122. package/plugins/invoice/admin/templates/issuers.html +2 -5
  123. package/plugins/invoice/admin/templates/receivers.html +2 -5
  124. package/plugins/invoice/admin/views/contacts-source.js +266 -0
  125. package/plugins/invoice/admin/views/editor.js +366 -199
  126. package/plugins/invoice/admin/views/export.js +199 -0
  127. package/plugins/invoice/admin/views/help-content.js +61 -0
  128. package/plugins/invoice/admin/views/index.js +582 -94
  129. package/plugins/invoice/admin/views/issuers.js +24 -17
  130. package/plugins/invoice/admin/views/media.js +172 -0
  131. package/plugins/invoice/admin/views/party-view.js +305 -67
  132. package/plugins/invoice/admin/views/payments.js +127 -0
  133. package/plugins/invoice/admin/views/print.js +130 -0
  134. package/plugins/invoice/admin/views/receivers.js +49 -16
  135. package/plugins/invoice/admin/views/send.js +212 -0
  136. package/plugins/invoice/admin/views/settings.js +594 -0
  137. package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
  138. package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
  139. package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
  140. package/plugins/invoice/collections/invoices/schema.json +19 -13
  141. package/plugins/invoice/config.js +27 -6
  142. package/plugins/invoice/pdf.js +164 -0
  143. package/plugins/invoice/plugin.js +1217 -44
  144. package/plugins/invoice/plugin.json +10 -9
  145. package/plugins/invoice/templates/_base.css +1 -0
  146. package/plugins/invoice/templates/classic-nologo.html +100 -0
  147. package/plugins/invoice/templates/classic.html +91 -0
  148. package/plugins/invoice/templates/invoice-print.html +24 -0
  149. package/plugins/invoice/templates/minimal.html +99 -0
  150. package/plugins/invoice/templates/modern-nologo.html +114 -0
  151. package/plugins/invoice/templates/modern.html +113 -0
  152. package/plugins/invoice/templates/templates.json +11 -0
  153. package/plugins/mail-reader/admin/views/mail.js +19 -0
  154. package/plugins/mail-reader/config.js +7 -0
  155. package/plugins/mail-reader/plugin.js +48 -0
  156. package/plugins/mail-reader/plugin.json +33 -0
  157. package/plugins/notes/admin/views/notes.js +1 -1
  158. package/plugins/notes/plugin.json +2 -2
  159. package/plugins/surveys/lib/audience.js +37 -0
  160. package/plugins/surveys/lib/campaigns.js +43 -0
  161. package/plugins/surveys/lib/ledger.js +110 -0
  162. package/plugins/surveys/lib/sending.js +106 -0
  163. package/plugins/surveys/lib/stats.js +62 -0
  164. package/plugins/surveys/lib/submit.js +95 -0
  165. package/plugins/surveys/lib/tokens.js +28 -0
  166. package/plugins/surveys/plugin.public.js +149 -0
  167. package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
  168. package/public/css/forms.css +1 -1
  169. package/public/css/menu-highlight.css +1 -1
  170. package/public/css/search.css +1 -0
  171. package/public/css/site.css +1 -1
  172. package/public/js/collection-context.js +2 -2
  173. package/public/js/context-menus.js +1 -0
  174. package/public/js/form-logic-engine.js +1 -1
  175. package/public/js/forms.js +2 -2
  176. package/public/js/menu-decor.mjs +1 -1
  177. package/public/js/search.js +1 -0
  178. package/public/js/site.js +1 -1
  179. package/scripts/build.js +37 -3
  180. package/scripts/copy-domma.js +48 -0
  181. package/scripts/seed.js +1996 -0
  182. package/scripts/setup.js +8 -0
  183. package/server/routes/api/collections.js +34 -0
  184. package/server/routes/api/context-menus.js +104 -0
  185. package/server/routes/api/forms.js +42 -3
  186. package/server/routes/api/notifications.js +69 -19
  187. package/server/routes/api/plugins.js +50 -6
  188. package/server/routes/api/search.js +43 -0
  189. package/server/routes/api/theme.js +69 -0
  190. package/server/routes/public.js +42 -7
  191. package/server/server.js +74 -0
  192. package/server/services/adapters/FileAdapter.js +6 -1
  193. package/server/services/content.js +26 -0
  194. package/server/services/contextMenus.js +477 -0
  195. package/server/services/email.js +29 -3
  196. package/server/services/health.js +23 -2
  197. package/server/services/markdown.js +70 -9
  198. package/server/services/menuRender.js +28 -4
  199. package/server/services/menus.js +25 -1
  200. package/server/services/permissionRegistry.js +24 -0
  201. package/server/services/pluginFiles.js +52 -11
  202. package/server/services/plugins.js +229 -6
  203. package/server/services/renderer.js +148 -22
  204. package/server/services/roles.js +1 -1
  205. package/server/services/search-migration.js +82 -0
  206. package/server/services/search.js +413 -0
  207. package/server/services/sidebar-migration.js +1 -0
  208. package/server/services/themeSettings.js +541 -0
  209. package/server/services/users.js +8 -0
  210. package/server/templates/page.html +4 -2
  211. package/plugins/contacts/data/contacts.json +0 -20
  212. package/plugins/notes/data/notes.json +0 -1
  213. package/plugins/site-search/admin/views/site-search.js +0 -116
  214. package/plugins/site-search/config.js +0 -15
  215. package/plugins/site-search/plugin.js +0 -188
  216. package/plugins/site-search/plugin.json +0 -40
  217. package/plugins/site-search/public/inject-body.html +0 -17
  218. package/plugins/site-search/public/inject-head.html +0 -1
  219. package/plugins/site-search/public/search.css +0 -1
  220. package/plugins/site-search/public/search.js +0 -1
  221. package/plugins/todo/data/todos.json +0 -1
@@ -18,6 +18,7 @@ import {getEffectiveRoles} from '../services/userRoles.js';
18
18
  import {hooks} from '../services/hooks.js';
19
19
  import {getConfig} from '../config.js';
20
20
  import * as cache from '../services/cache/index.js';
21
+ import {createHash} from 'crypto';
21
22
 
22
23
  /**
23
24
  * Derive the absolute origin for the current request.
@@ -55,6 +56,40 @@ function escapeHtml(str) {
55
56
  .replace(/'/g, ''');
56
57
  }
57
58
 
59
+ /*
60
+ * Send an HTML page with headers that make a browser CHECK before reusing it.
61
+ *
62
+ * Public pages went out with no Cache-Control, no ETag and no Last-Modified at
63
+ * all. With nothing to go on a browser applies heuristic caching, so an edited
64
+ * page could keep showing the old content on a normal refresh - the server was
65
+ * serving the new one the whole time. That is indistinguishable from a CMS bug
66
+ * and was reported as one.
67
+ *
68
+ * `no-cache` does not mean "do not store": it means store it, then revalidate
69
+ * before every reuse. Paired with an ETag that makes the common case a 304 with
70
+ * no body rather than a full re-download.
71
+ *
72
+ * `private` because these responses are role-dependent - the response cache
73
+ * keys on role for exactly that reason - and a shared proxy must never hand one
74
+ * visitor a page rendered for another's permissions.
75
+ */
76
+ function sendHtml(request, reply, html) {
77
+ const etag = `"${createHash('sha1').update(html).digest('base64')}"`;
78
+ reply.header('cache-control', 'private, no-cache');
79
+ reply.header('etag', etag);
80
+
81
+ // If-None-Match may carry a list, and a weak validator's W/ prefix.
82
+ const seen = String(request.headers['if-none-match'] || '')
83
+ .split(',')
84
+ .map((t) => t.trim().replace(/^W\//, ''));
85
+ if (seen.includes(etag)) return reply.code(304).send();
86
+
87
+ // Deliberately no reply.code() here: the 404 handlers have already called
88
+ // reply.status(404), and forcing 200 would turn every not-found into a
89
+ // soft 404 that search engines index as a real page.
90
+ return reply.type('text/html').send(html);
91
+ }
92
+
58
93
  export async function publicRoutes(fastify) {
59
94
  // Admin panel: serve index.html for all /admin/* paths (SPA fallback)
60
95
  fastify.get('/admin', async (request, reply) => {
@@ -104,13 +139,13 @@ export async function publicRoutes(fastify) {
104
139
  const link = await verifyPreviewToken((request.query || {}).token, fastify.jwt);
105
140
  if (!link) {
106
141
  reply.status(404);
107
- return reply.type('text/html').send(await render404('/_preview'));
142
+ return sendHtml(request, reply, await render404('/_preview'));
108
143
  }
109
144
 
110
145
  const page = await getPage(link.urlPath);
111
146
  if (!page) {
112
147
  reply.status(404);
113
- return reply.type('text/html').send(await render404(link.urlPath));
148
+ return sendHtml(request, reply, await render404(link.urlPath));
114
149
  }
115
150
 
116
151
  // A disabled project stays a hard kill switch. A share link narrows who
@@ -118,7 +153,7 @@ export async function publicRoutes(fastify) {
118
153
  const project = await getProjectForPage(page.urlPath || link.urlPath, page.project);
119
154
  if (!(await isProjectEnabled(project))) {
120
155
  reply.status(404);
121
- return reply.type('text/html').send(await render404(link.urlPath));
156
+ return sendHtml(request, reply, await render404(link.urlPath));
122
157
  }
123
158
 
124
159
  // Rendered as anonymous on purpose: the recipient has no account, so
@@ -161,7 +196,7 @@ export async function publicRoutes(fastify) {
161
196
 
162
197
  if (!page) {
163
198
  reply.status(404);
164
- return reply.type('text/html').send(await render404(urlPath));
199
+ return sendHtml(request, reply, await render404(urlPath));
165
200
  }
166
201
 
167
202
  // Hard kill-switch: a disabled project takes its pages off the public
@@ -169,7 +204,7 @@ export async function publicRoutes(fastify) {
169
204
  const pageProject = await getProjectForPage(page.urlPath || urlPath, page.project);
170
205
  if (!(await isProjectEnabled(pageProject))) {
171
206
  reply.status(404);
172
- return reply.type('text/html').send(await render404(urlPath));
207
+ return sendHtml(request, reply, await render404(urlPath));
173
208
  }
174
209
 
175
210
  // Draft preview: a page that is not published stays invisible to the
@@ -209,7 +244,7 @@ export async function publicRoutes(fastify) {
209
244
 
210
245
  if (isDraft && !canPreviewDrafts(userObj)) {
211
246
  reply.status(404);
212
- return reply.type('text/html').send(await render404(urlPath));
247
+ return sendHtml(request, reply, await render404(urlPath));
213
248
  }
214
249
 
215
250
  if (!isPublic && !checkVisibility(userObj, vis)) {
@@ -249,7 +284,7 @@ export async function publicRoutes(fastify) {
249
284
  const cacheTags = [`page:${urlPath}`, ...(page.cacheTags || []), 'nav', 'site'];
250
285
  const html = await cache.wrap(cacheKey, render, {tags: cacheTags});
251
286
  hooks.emit('content:pageViewed', {urlPath, title: page.title || ''});
252
- return reply.type('text/html').send(html);
287
+ return sendHtml(request, reply, html);
253
288
  });
254
289
  }
255
290
 
package/server/server.js CHANGED
@@ -218,6 +218,29 @@ await refreshComponentTagAllowlist();
218
218
  // Menus - one-shot migration from legacy navigation.json + site footer links
219
219
  // ---------------------------------------------------------------------------
220
220
 
221
+ // ---------------------------------------------------------------------------
222
+ // Theme - one-shot lift of the theme keys out of site.json into theme.json
223
+ // ---------------------------------------------------------------------------
224
+
225
+ try {
226
+ const {migrateThemeConfig} = await import('./services/themeSettings.js');
227
+ if (await migrateThemeConfig()) app.log.info('[theme] Migrated theme settings into config/theme.json');
228
+ } catch (err) {
229
+ app.log.warn(`[theme] Migration skipped: ${err.message}`);
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Search - one-shot retirement of the site-search plugin (settings + files)
234
+ // ---------------------------------------------------------------------------
235
+
236
+ try {
237
+ const {migrateSiteSearchPlugin} = await import('./services/search-migration.js');
238
+ const changes = migrateSiteSearchPlugin();
239
+ if (changes.length) app.log.info(`[search] Migrated from the site-search plugin: ${changes.join(', ')}`);
240
+ } catch (err) {
241
+ app.log.warn(`[search] Migration skipped: ${err.message}`);
242
+ }
243
+
221
244
  try {
222
245
  const {runMigration: runMenusMigration} = await import('./services/menus-migration.js');
223
246
  await runMenusMigration();
@@ -237,6 +260,18 @@ try {
237
260
  groupText: 'Data',
238
261
  item: {text: 'API Builder', url: '#/api-endpoints', icon: 'code', permission: 'api-endpoints'}
239
262
  });
263
+ await ensureSidebarItem({
264
+ groupText: 'Content',
265
+ item: {text: 'Context Menus', url: '#/context-menus', icon: 'menu', permission: 'context-menus'}
266
+ });
267
+ await ensureSidebarItem({
268
+ groupText: 'Content',
269
+ item: {text: 'Theme', url: '#/theme', icon: 'droplet', permission: 'theme'}
270
+ });
271
+ await ensureSidebarItem({
272
+ groupText: 'System',
273
+ item: {text: 'Search', url: '#/search', icon: 'search', permission: 'settings'}
274
+ });
240
275
  // Restructure Documentation: Tutorials → submenu, add Components reference set.
241
276
  await ensureDocumentationSubmenus();
242
277
  } catch (err) {
@@ -245,6 +280,39 @@ try {
245
280
 
246
281
  // Initialise response cache (Memory/None/Redis driver per config.cache).
247
282
  await cache.initCache(config.cache);
283
+
284
+ /*
285
+ * An admin change clears the response cache.
286
+ *
287
+ * Every service that mutates something is meant to invalidate the tags its
288
+ * change affects, and mostly they do - but a miss is INVISIBLE. The cache is
289
+ * off in development and on in production, so a missed invalidation works
290
+ * perfectly on the machine it was written on and serves stale HTML live until
291
+ * the TTL expires an hour later. Context menus shipped that way for three
292
+ * releases: the call named a function the cache module does not export AND a
293
+ * tag nothing is ever stored with, and a try/catch ate the evidence.
294
+ *
295
+ * This is the safety net, not the mechanism. The targeted invalidations stay -
296
+ * they are what stops one collection edit clearing every unrelated page - and
297
+ * this catches whatever they missed.
298
+ *
299
+ * Scoped to AUTHENTICATED mutations on purpose. A public form submission is
300
+ * not an admin change, and clearing on each of those would cold-start the
301
+ * cache on a busy site; entry writes already invalidate their own collection
302
+ * tag. Runs on onResponse, after the reply has gone, so it costs the caller
303
+ * nothing - and a memory-driver clear is two Map resets.
304
+ */
305
+ const CACHE_CLEARING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
306
+ app.addHook('onResponse', async (request, reply) => {
307
+ if (!CACHE_CLEARING_METHODS.has(request.method)) return;
308
+ if (reply.statusCode >= 400) return;
309
+ if (!request.user) return;
310
+ try {
311
+ await cache.clear();
312
+ } catch (err) {
313
+ app.log.warn(`[cache] Admin change did not clear the cache: ${err.message}`);
314
+ }
315
+ });
248
316
  console.log(`[cache] driver=${cache.isEnabled() ? config.cache.driver : 'disabled'}`);
249
317
 
250
318
  // Docs are rendered at request time from the shipped admin templates; clear any
@@ -332,9 +400,11 @@ await app.register(authRoutes, { prefix: '/api' });
332
400
 
333
401
  const { pagesRoutes } = await import('./routes/api/pages.js');
334
402
  const { settingsRoutes } = await import('./routes/api/settings.js');
403
+ const { themeRoutes } = await import('./routes/api/theme.js');
335
404
  const { layoutsRoutes } = await import('./routes/api/layouts.js');
336
405
  const { navigationRoutes } = await import('./routes/api/navigation.js');
337
406
  const {menusRoutes} = await import('./routes/api/menus.js');
407
+ const {contextMenusRoutes} = await import('./routes/api/context-menus.js');
338
408
  const {menuLocationsRoutes} = await import('./routes/api/menu-locations.js');
339
409
  const {projectsRoutes} = await import('./routes/api/projects.js');
340
410
  const {apiTokensRoutes} = await import('./routes/api/api-tokens.js');
@@ -353,6 +423,7 @@ const {blocksRoutes} = await import('./routes/api/blocks.js');
353
423
  const {componentsRoutes} = await import('./routes/api/components.js');
354
424
  const {versionsRoutes} = await import('./routes/api/versions.js');
355
425
  const {effectsRoutes} = await import('./routes/api/effects.js');
426
+ const {searchRoutes} = await import('./routes/api/search.js');
356
427
  const {notificationsRoutes} = await import('./routes/api/notifications.js');
357
428
  const {dashboardRoutes} = await import('./routes/api/dashboard.js');
358
429
  const {cacheRoutes} = await import('./routes/api/cache.js');
@@ -363,6 +434,8 @@ await app.register(settingsRoutes, { prefix: '/api' });
363
434
  await app.register(layoutsRoutes, { prefix: '/api' });
364
435
  await app.register(navigationRoutes, { prefix: '/api' });
365
436
  await app.register(menusRoutes, {prefix: '/api'});
437
+ await app.register(contextMenusRoutes, {prefix: '/api'});
438
+ await app.register(themeRoutes, {prefix: '/api'});
366
439
  await app.register(menuLocationsRoutes, {prefix: '/api'});
367
440
  await app.register(projectsRoutes, {prefix: '/api'});
368
441
  await app.register(apiTokensRoutes, {prefix: '/api'});
@@ -381,6 +454,7 @@ await app.register(blocksRoutes, {prefix: '/api'});
381
454
  await app.register(componentsRoutes, {prefix: '/api'});
382
455
  await app.register(versionsRoutes, {prefix: '/api'});
383
456
  await app.register(effectsRoutes, {prefix: '/api'});
457
+ await app.register(searchRoutes, {prefix: '/api'});
384
458
  await app.register(notificationsRoutes, {prefix: '/api'});
385
459
  await app.register(dashboardRoutes, {prefix: '/api'});
386
460
  await app.register(cacheRoutes, {prefix: '/api'});
@@ -86,7 +86,12 @@ export class FileAdapter {
86
86
  entries.sort((a, b) => {
87
87
  const aVal = sort === 'createdAt' ? a.meta?.createdAt : (a.data?.[sort] ?? '');
88
88
  const bVal = sort === 'createdAt' ? b.meta?.createdAt : (b.data?.[sort] ?? '');
89
- const cmp = String(aVal).localeCompare(String(bVal));
89
+ // Numeric collation, because a plain string compare orders a number
90
+ // field 1, 10, 11, 2 - so any collection sorted by `order` scrambles
91
+ // itself the moment it has ten entries. MongoAdapter sorts natively
92
+ // and has always ordered these correctly, so this also stops the two
93
+ // adapters disagreeing about the same data.
94
+ const cmp = String(aVal).localeCompare(String(bVal), undefined, {numeric: true});
90
95
  return order === 'desc' ? -cmp : cmp;
91
96
  });
92
97
 
@@ -122,6 +122,25 @@ export async function buildPreviewPage(urlPath, frontmatter, body, opts = {}) {
122
122
  * @param {string} body
123
123
  * @returns {Promise<object>}
124
124
  */
125
+ /**
126
+ * Keep a page's tag list to actual tags.
127
+ *
128
+ * An empty tag input has a way of arriving as `[null]` - which is an array,
129
+ * passes every `Array.isArray` guard, and then throws the moment anything
130
+ * calls a string method on the contents. It reached disk on dozens of pages
131
+ * and took the whole of site search down with it, so it is cleaned on the way
132
+ * IN rather than defended against at each of the places that read it.
133
+ *
134
+ * @param {unknown} tags
135
+ * @returns {string[]}
136
+ */
137
+ function cleanTags(tags) {
138
+ if (!Array.isArray(tags)) return [];
139
+ return [...new Set(
140
+ tags.filter(tag => typeof tag === 'string' && tag.trim()).map(tag => tag.trim())
141
+ )];
142
+ }
143
+
125
144
  export async function createPage(urlPath, frontmatter, body) {
126
145
  const filePath = urlPathToFilePath(urlPath);
127
146
  await fs.mkdir(path.dirname(filePath), { recursive: true });
@@ -138,6 +157,7 @@ export async function createPage(urlPath, frontmatter, body) {
138
157
  sortOrder: frontmatter.sortOrder ?? defaults.sortOrder,
139
158
  showInNav: frontmatter.showInNav ?? false,
140
159
  sidebar: frontmatter.sidebar ?? false,
160
+ tags: cleanTags(frontmatter.tags),
141
161
  seo: frontmatter.seo || {},
142
162
  dconfig: frontmatter.dconfig || null,
143
163
  createdAt: now,
@@ -172,6 +192,12 @@ export async function updatePage(urlPath, frontmatter, body, {author} = {}) {
172
192
  updatedAt: new Date().toISOString()
173
193
  };
174
194
 
195
+ // Cleaned on every save, so an existing page carrying `[null]` is repaired
196
+ // the next time anyone touches it rather than staying broken for ever.
197
+ // Only when tags are part of this save: a caller that sends no tags is not
198
+ // asking for the page's tags to be emptied.
199
+ if ('tags' in meta) meta.tags = cleanTags(meta.tags);
200
+
175
201
  if (existingMeta.createdAt) meta.createdAt = existingMeta.createdAt;
176
202
 
177
203
  // '' is the editor's "inherit by rootUrl" choice - drop the key so the