domma-cms 0.92.1 → 0.94.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 (177) hide show
  1. package/CLAUDE.md +5 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/app.js +2 -2
  4. package/admin/js/lib/action-editor-arrange.js +1 -1
  5. package/admin/js/lib/api-tokens-arrange.js +2 -2
  6. package/admin/js/lib/block-editor-arrange.js +1 -1
  7. package/admin/js/lib/blocks-arrange.js +1 -1
  8. package/admin/js/lib/collection-entries-arrange.js +1 -1
  9. package/admin/js/lib/components-arrange.js +1 -1
  10. package/admin/js/lib/dashboard-arrange.js +1 -1
  11. package/admin/js/lib/dates.js +1 -0
  12. package/admin/js/lib/forms-arrange.js +1 -1
  13. package/admin/js/lib/media-arrange.js +1 -1
  14. package/admin/js/lib/notifications-arrange.js +1 -1
  15. package/admin/js/lib/pages-arrange.js +1 -1
  16. package/admin/js/lib/related.js +1 -1
  17. package/admin/js/lib/timeline-builder.js +2 -2
  18. package/admin/js/templates/action-editor.html +6 -5
  19. package/admin/js/templates/actions-list.html +1 -1
  20. package/admin/js/templates/contacts.html +1 -1
  21. package/admin/js/templates/docs/api-actions.html +86 -60
  22. package/admin/js/templates/docs/api-authentication.html +159 -123
  23. package/admin/js/templates/docs/api-builder.html +197 -0
  24. package/admin/js/templates/docs/api-collections.html +199 -259
  25. package/admin/js/templates/docs/api-external.html +225 -0
  26. package/admin/js/templates/docs/api-forms.html +268 -0
  27. package/admin/js/templates/docs/api-layouts.html +70 -45
  28. package/admin/js/templates/docs/api-media.html +57 -80
  29. package/admin/js/templates/docs/api-navigation.html +66 -22
  30. package/admin/js/templates/docs/api-pages.html +109 -129
  31. package/admin/js/templates/docs/api-plugins.html +123 -61
  32. package/admin/js/templates/docs/api-scaffold.html +185 -0
  33. package/admin/js/templates/docs/api-settings.html +72 -64
  34. package/admin/js/templates/docs/api-users.html +74 -107
  35. package/admin/js/templates/docs/api-views.html +68 -54
  36. package/admin/js/templates/docs/components-howto.html +20 -17
  37. package/admin/js/templates/docs/components-reference.html +13 -16
  38. package/admin/js/templates/docs/components-rules.html +7 -6
  39. package/admin/js/templates/docs/components-walkthrough.html +19 -19
  40. package/admin/js/templates/docs/tutorial-crud.html +71 -40
  41. package/admin/js/templates/docs/tutorial-forms.html +51 -35
  42. package/admin/js/templates/docs/tutorial-plugin.html +132 -56
  43. package/admin/js/templates/docs/usage-actions.html +61 -15
  44. package/admin/js/templates/docs/usage-collections.html +108 -0
  45. package/admin/js/templates/docs/usage-cta-shortcode.html +14 -3
  46. package/admin/js/templates/docs/usage-dconfig.html +0 -3
  47. package/admin/js/templates/docs/usage-editions.html +213 -0
  48. package/admin/js/templates/docs/usage-media.html +22 -6
  49. package/admin/js/templates/docs/usage-navigation.html +74 -18
  50. package/admin/js/templates/docs/usage-pages.html +60 -20
  51. package/admin/js/templates/docs/usage-plugins.html +89 -17
  52. package/admin/js/templates/docs/usage-shortcodes.html +123 -70
  53. package/admin/js/templates/docs/usage-site-settings.html +50 -18
  54. package/admin/js/templates/docs/usage-tools.html +73 -0
  55. package/admin/js/templates/docs/usage-users-roles.html +99 -20
  56. package/admin/js/templates/docs/usage-views.html +36 -19
  57. package/admin/js/templates/documentation.html +153 -32
  58. package/admin/js/templates/page-editor.html +0 -5
  59. package/admin/js/templates/plugin-guide.html +15 -0
  60. package/admin/js/templates/plugin-guides.html +21 -0
  61. package/admin/js/templates/pro-docs.html +53 -234
  62. package/admin/js/templates/tutorials.html +5 -4
  63. package/admin/js/views/actions-list.js +3 -3
  64. package/admin/js/views/analytics.js +5 -5
  65. package/admin/js/views/api-endpoint-editor.js +2 -2
  66. package/admin/js/views/block-editor.js +4 -4
  67. package/admin/js/views/blocks.js +4 -4
  68. package/admin/js/views/collection-editor.js +4 -4
  69. package/admin/js/views/collection-entries.js +7 -7
  70. package/admin/js/views/component-editor.js +2 -2
  71. package/admin/js/views/contacts.js +22 -20
  72. package/admin/js/views/context-menu-editor.js +5 -5
  73. package/admin/js/views/doc-pages.js +1 -1
  74. package/admin/js/views/form-editor.js +4 -4
  75. package/admin/js/views/form-submissions.js +2 -2
  76. package/admin/js/views/index.js +1 -1
  77. package/admin/js/views/media.js +3 -3
  78. package/admin/js/views/menu-editor.js +13 -13
  79. package/admin/js/views/menu-locations.js +2 -2
  80. package/admin/js/views/my-profile.js +1 -1
  81. package/admin/js/views/page-editor.js +8 -8
  82. package/admin/js/views/plugin-guides.js +5 -0
  83. package/admin/js/views/project-detail.js +2 -2
  84. package/admin/js/views/project-settings.js +1 -1
  85. package/admin/js/views/role-editor.js +4 -4
  86. package/admin/js/views/search.js +2 -2
  87. package/admin/js/views/seo.js +17 -17
  88. package/admin/js/views/settings.js +3 -3
  89. package/admin/js/views/theme.js +3 -3
  90. package/admin/js/views/user-editor.js +1 -1
  91. package/admin/js/views/users.js +2 -2
  92. package/admin/js/views/view-editor.js +1 -1
  93. package/bin/cli.js +13 -13
  94. package/bin/lib/node-version.js +29 -0
  95. package/package.json +1 -1
  96. package/plugins/_lib/admin/mail/compose-window.js +3 -2
  97. package/plugins/_lib/admin/mail/reader-view.js +7 -6
  98. package/plugins/_lib/admin/mail/scheduling.js +4 -2
  99. package/plugins/_lib/admin/mail/templates.js +4 -4
  100. package/plugins/_lib/admin/ui/dates.js +85 -0
  101. package/plugins/blog/CLAUDE.md +31 -22
  102. package/plugins/blog/admin/views/blog.js +3 -2
  103. package/plugins/blog/admin/views/comments.js +2 -1
  104. package/plugins/blog/admin/views/post-editor.js +4 -4
  105. package/plugins/blog/blocks/blog-card-row.html +1 -1
  106. package/plugins/blog/blocks/blog-card.html +2 -2
  107. package/plugins/blog/blocks/blog-post-classic.html +2 -2
  108. package/plugins/blog/blocks/blog-post-essay.html +2 -2
  109. package/plugins/blog/blocks/blog-post-feature.html +2 -2
  110. package/plugins/blog/blocks/blog-post-minimal.html +2 -2
  111. package/plugins/blog/blocks/blog-post-sidebar.html +2 -2
  112. package/plugins/blog/blocks/blog-post-split.html +2 -2
  113. package/plugins/blog/docs/guide.md +205 -0
  114. package/plugins/blog/lib/layouts.js +3 -3
  115. package/plugins/blog/lib/page.js +2 -1
  116. package/plugins/blog/plugin.js +3 -3
  117. package/plugins/blog/plugin.json +4 -4
  118. package/plugins/blog/tests/layouts.test.js +6 -0
  119. package/plugins/feedback/CLAUDE.md +22 -3
  120. package/plugins/feedback/admin/lib/kit.js +6 -7
  121. package/plugins/feedback/admin/views/feedback.js +79 -10
  122. package/plugins/feedback/admin/views/send.js +28 -6
  123. package/plugins/feedback/docs/guide.md +95 -0
  124. package/plugins/feedback/lib/receiver.js +9 -2
  125. package/plugins/feedback/lib/sender.js +3 -2
  126. package/plugins/feedback/plugin.js +54 -6
  127. package/plugins/feedback/plugin.json +4 -4
  128. package/plugins/feedback/tests/api.test.js +74 -2
  129. package/plugins/free-tier.lock.json +49 -44
  130. package/plugins/mail-reader/CLAUDE.md +33 -18
  131. package/plugins/mail-reader/docs/guide.md +147 -0
  132. package/plugins/mail-reader/plugin.json +1 -1
  133. package/plugins/security/CLAUDE.md +4 -1
  134. package/plugins/security/admin/views/security.js +5 -5
  135. package/plugins/security/docs/guide.md +170 -0
  136. package/plugins/security/plugin.js +2 -1
  137. package/plugins/security/plugin.json +2 -1
  138. package/plugins/shopping-cart/CLAUDE.md +7 -1
  139. package/plugins/shopping-cart/admin/lib/kit.js +5 -2
  140. package/plugins/shopping-cart/admin/views/orders.js +4 -4
  141. package/plugins/shopping-cart/admin/views/overview.js +2 -2
  142. package/plugins/shopping-cart/docs/guide.md +191 -0
  143. package/plugins/shopping-cart/lib/render.js +2 -1
  144. package/plugins/shopping-cart/plugin.json +3 -3
  145. package/public/js/collection-browser.js +2 -2
  146. package/public/js/site.js +1 -1
  147. package/scripts/gen-instance-secret.js +3 -1
  148. package/scripts/setup.js +3 -1
  149. package/server/middleware/auth.js +2 -1
  150. package/server/routes/api/actions.js +47 -27
  151. package/server/routes/api/blocks.js +2 -1
  152. package/server/routes/api/collections.js +16 -52
  153. package/server/routes/api/contacts.js +66 -3
  154. package/server/routes/api/documentation.js +42 -0
  155. package/server/routes/api/notifications.js +3 -2
  156. package/server/routes/api/users.js +10 -6
  157. package/server/server.js +16 -1
  158. package/server/services/actions.js +110 -34
  159. package/server/services/adapterRegistry.js +169 -16
  160. package/server/services/adapters/FileAdapter.js +25 -0
  161. package/server/services/adapters/MongoAdapter.js +23 -0
  162. package/server/services/collections.js +104 -1
  163. package/server/services/connectionManager.js +12 -0
  164. package/server/services/dates.js +81 -0
  165. package/server/services/docs.js +13 -2
  166. package/server/services/markdown.js +75 -26
  167. package/server/services/notification-sources.js +6 -5
  168. package/server/services/passwordReset.js +2 -1
  169. package/server/services/permissionRegistry.js +3 -2
  170. package/server/services/pluginGuides.js +255 -0
  171. package/server/services/pluginInstaller.js +54 -13
  172. package/server/services/plugins.js +29 -1
  173. package/server/services/presetCollections.js +31 -5
  174. package/server/services/renderer.js +2 -2
  175. package/server/services/sidebarBadges.js +3 -1
  176. package/server/services/tools.js +4 -2
  177. package/server/templates/page.html +2 -2
@@ -12,6 +12,7 @@
12
12
 
13
13
  import crypto from 'crypto';
14
14
  import fs from 'fs/promises';
15
+ import {readFileSync} from 'fs';
15
16
  import path from 'path';
16
17
  import zlib from 'zlib';
17
18
  import {fileURLToPath} from 'url';
@@ -227,6 +228,40 @@ async function extractTar(tarBuffer, slug, destDir) {
227
228
  }
228
229
  }
229
230
 
231
+ /**
232
+ * The version of the CMS this site runs. The engine's own checkout has it in
233
+ * package.json; a scaffolded or managed site's package.json is the site's, so
234
+ * there it comes from the `.domma` marker the scaffolder and updater write
235
+ * (or the manager's `.cmsVersion`). Null when none says.
236
+ *
237
+ * @param {string} [root]
238
+ * @returns {string|null}
239
+ */
240
+ export function installedCmsVersion(root = ROOT) {
241
+ const read = (file) => {
242
+ try { return JSON.parse(readFileSync(path.join(root, file), 'utf8')); } catch { return null; }
243
+ };
244
+ const pkg = read('package.json');
245
+ if (pkg?.name === 'domma-cms' && pkg.version) return pkg.version;
246
+ return read('.domma')?.cmsVersion || read('.cmsVersion')?.cmsVersion || null;
247
+ }
248
+
249
+ /**
250
+ * The refusal for a manifest whose `minCmsVersion` is newer than this CMS, or
251
+ * null when it fits (or either version is unknown - an old site without a
252
+ * marker is not locked out of every plugin that sets the field).
253
+ *
254
+ * @param {{name?: string, displayName?: string, minCmsVersion?: string}} manifest
255
+ * @param {string|null} [cmsVersion]
256
+ * @returns {string|null}
257
+ */
258
+ export function cmsVersionShortfall(manifest, cmsVersion = installedCmsVersion()) {
259
+ const needed = String(manifest?.minCmsVersion ?? '').trim().replace(/^[^\d]+/, '');
260
+ if (!needed || !cmsVersion) return null;
261
+ if (compareVersions(cmsVersion, needed) >= 0) return null;
262
+ return `${manifest.displayName || manifest.name} needs Domma CMS ${needed} or later; this site runs ${cmsVersion}. Update the CMS first.`;
263
+ }
264
+
230
265
  // ---------------------------------------------------------------------------
231
266
  // Public API
232
267
  // ---------------------------------------------------------------------------
@@ -239,8 +274,10 @@ async function extractTar(tarBuffer, slug, destDir) {
239
274
  * 2. Reject if plugins/<slug>/ already exists
240
275
  * 3. Extract tarball to plugins/<slug>/ with path traversal guard
241
276
  * 4. Validate manifest via discoverPlugins()
277
+ * 4b. Refuse a plugin whose `minCmsVersion` is newer than this CMS
242
278
  * 5. Add { enabled: false, settings: {} } to config/plugins.json
243
- * 6. Run onEnable lifecycle hook (warn-only on failure)
279
+ * (onEnable runs when it is enabled, not here)
280
+ * 6. Write the envelope's entitlement, if any
244
281
  * 7. Append install record to audit log
245
282
  *
246
283
  * A bundle from the marketplace is signed. One exported from a running CMS
@@ -331,6 +368,11 @@ export async function installPlugin(slug, { tarball, signature, publicKeyId, all
331
368
  await fs.rm(destDir, { recursive: true, force: true });
332
369
  throw new Error(`Plugin manifest validation failed after extraction: ${slug}`);
333
370
  }
371
+ const tooNew = cmsVersionShortfall(manifest);
372
+ if (tooNew) {
373
+ await fs.rm(destDir, { recursive: true, force: true });
374
+ throw new Error(tooNew);
375
+ }
334
376
 
335
377
  // Step 5 - Update config/plugins.json
336
378
  const cfg = await readPluginsConfig();
@@ -339,25 +381,19 @@ export async function installPlugin(slug, { tarball, signature, publicKeyId, all
339
381
  await writePluginsConfig(cfg);
340
382
  }
341
383
 
342
- // Step 6 - Run onEnable lifecycle hook (warn on failure, do not throw)
343
- try {
344
- const pluginJsPath = path.join(destDir, 'plugin.js');
345
- const mod = await import(pluginJsPath);
346
- if (typeof mod.onEnable === 'function') {
347
- await mod.onEnable(null);
348
- }
349
- } catch (err) {
350
- console.warn(`[pluginInstaller] onEnable hook for "${slug}" failed: ${err.message}`);
351
- }
384
+ // No onEnable here: the plugin is installed switched OFF (step 5), and
385
+ // enabling it runs onEnable with its real context ({fastify, services, hooks})
386
+ // through runLifecycleHook(). Calling it at install too ran it twice, the
387
+ // first time with null, which threw in any plugin that destructured it.
352
388
 
353
- // Step 7 - Entitlement, if the envelope carried one. Written beside the
389
+ // Step 6 - Entitlement, if the envelope carried one. Written beside the
354
390
  // plugin so it travels with it and a renewal is a file drop.
355
391
  if (entitlement) {
356
392
  await fs.writeFile(path.join(destDir, '.entitlement.json'),
357
393
  JSON.stringify(entitlement, null, 2) + '\n', 'utf8');
358
394
  }
359
395
 
360
- // Step 8 - Audit log. An unsigned install is recorded as such: it is the one
396
+ // Step 7 - Audit log. An unsigned install is recorded as such: it is the one
361
397
  // that was a judgement call rather than a verified fact.
362
398
  await writeAuditLog({
363
399
  action: unsigned ? 'install:unsigned' : 'install',
@@ -412,6 +448,11 @@ async function upgradePlugin(slug, {tarball, unsigned, entitlement, allowDowngra
412
448
  await fs.rm(incoming, {recursive: true, force: true});
413
449
  throw new Error(`Refusing to upgrade ${slug}: the new version is ${next.reason}`);
414
450
  }
451
+ const tooNew = cmsVersionShortfall(next.manifest);
452
+ if (tooNew) {
453
+ await fs.rm(incoming, {recursive: true, force: true});
454
+ throw new Error(tooNew);
455
+ }
415
456
 
416
457
  const from = current.manifest?.version;
417
458
  const to = next.manifest.version;
@@ -25,6 +25,7 @@ import {
25
25
  registerSanitizeRules,
26
26
  registerShortcode,
27
27
  hideSidebarUrl,
28
+ registerComponent,
28
29
  registerSidebarItem,
29
30
  registerTransform
30
31
  } from './hooks.js';
@@ -80,6 +81,23 @@ export async function validatePluginDir(dir, slug) {
80
81
  return {ok: true, manifest};
81
82
  }
82
83
 
84
+ /**
85
+ * `hooks.registerComponent({name, source})` as plugins get it: registers the
86
+ * component (read-only in the Components list) and refreshes the sanitiser's
87
+ * `dm-*` tag allowlist, which was built at boot before any plugin ran - without
88
+ * the refresh a plugin's `<dm-name>` tag would be stripped from every page.
89
+ *
90
+ * @param {{name: string, source: string}} component
91
+ * @returns {Promise<void>}
92
+ */
93
+ export async function pluginComponentHook(component) {
94
+ registerComponent(component);
95
+ try {
96
+ const {refreshComponentTagAllowlist} = await import('./markdown.js');
97
+ await refreshComponentTagAllowlist();
98
+ } catch { /* the allowlist is refreshed again on the next component save */ }
99
+ }
100
+
83
101
  const failedLoads = [];
84
102
 
85
103
  /**
@@ -530,7 +548,9 @@ export async function registerPlugins(fastify) {
530
548
  ...notifierFor(manifest.name, manifest.displayName || manifest.name),
531
549
  // Sign-in and password checks (0.83) - Security / Security Pro. services/authChecks.js.
532
550
  registerLoginCheck: (def) => registerLoginCheck(manifest.name, def),
533
- registerPasswordCheck: (def) => registerPasswordCheck(manifest.name, def)},
551
+ registerPasswordCheck: (def) => registerPasswordCheck(manifest.name, def),
552
+ // A Domma component (.dmc source) usable as <dm-name> / [component] on pages (0.92.2).
553
+ registerComponent: pluginComponentHook},
534
554
  settings,
535
555
  config: {}
536
556
  });
@@ -586,6 +606,14 @@ export async function registerPlugins(fastify) {
586
606
  }
587
607
  }
588
608
 
609
+ // User guides (docs/*.md) of the plugins that loaded, under Documentation > Plugins.
610
+ try {
611
+ const {registerPluginGuides} = await import('./pluginGuides.js');
612
+ await registerPluginGuides(manifests.filter(m => loaded.includes(m.name)), PLUGINS_DIR);
613
+ } catch (err) {
614
+ fastify.log.warn(`[plugins] Plugin guides skipped: ${err.message}`);
615
+ }
616
+
589
617
  if (loaded.length) {
590
618
  fastify.log.info(`[plugins] Loaded ${loaded.length} plugin${loaded.length === 1 ? '' : 's'}: ${loaded.join(', ')}`);
591
619
  } else {
@@ -122,10 +122,10 @@ const PRESETS = [
122
122
  // them exactly preserves every existing entry; changing one would seed an
123
123
  // empty collection beside a site's real one.
124
124
  //
125
- // seedAll() rewrites schema.json on every boot, so `storage` is declared
126
- // exactly as the plugins declared it - a site that had these in Mongo must
127
- // not silently switch to files and lose sight of its rows. Sites with no
128
- // connections.json fall back to the file adapter as they always did.
125
+ // `storage` is declared exactly as the plugins declared it. It is used
126
+ // only where the site has a working MongoDB connection and no notes or
127
+ // todos already in files (adapterRegistry.js resolveUserDataPreset); a
128
+ // site's own storage block, once written, is kept by seedAll().
129
129
  {
130
130
  "slug": "notes-notes",
131
131
  "title": "Notes",
@@ -464,6 +464,28 @@ const PRESETS = [
464
464
  /** Slugs exported for use in adapterRegistry and the delete guard. */
465
465
  export const PRESET_COLLECTION_SLUGS = PRESETS.map(p => p.slug);
466
466
 
467
+ /**
468
+ * The presets that hold people's own data - the Notes, Todo and Contacts
469
+ * Tools - rather than the site's workings. These, and only these, may live in
470
+ * MongoDB: a site that declares it for one and has a working connection gets
471
+ * it (adapterRegistry.js), and their `storage` block survives the reseed below
472
+ * so a storage move sticks. Every other preset (roles, user profiles,
473
+ * projects, notifications, api tokens and endpoints) is files, always.
474
+ */
475
+ export const USER_DATA_PRESET_SLUGS = Object.freeze(['notes-notes', 'todo-items', 'contacts-contacts', 'contacts-groups']);
476
+
477
+ const USER_DATA = new Set(USER_DATA_PRESET_SLUGS);
478
+
479
+ /** A storage block worth keeping from an existing schema.json, or null. */
480
+ async function keptStorage(schemaPath) {
481
+ try {
482
+ const storage = JSON.parse(await fs.readFile(schemaPath, 'utf8'))?.storage;
483
+ if (storage?.adapter === 'file') return {adapter: 'file'};
484
+ if (storage?.adapter === 'mongodb') return {adapter: 'mongodb', connection: storage.connection || 'default'};
485
+ } catch { /* new, or unreadable - the preset's own block it is */ }
486
+ return null;
487
+ }
488
+
467
489
  /**
468
490
  * Seed all preset collections at boot.
469
491
  * Always overwrites schema.json; only creates data.json if absent.
@@ -477,7 +499,11 @@ export async function seedAll() {
477
499
  const dataPath = path.join(dir, 'data.json');
478
500
 
479
501
  await fs.mkdir(dir, {recursive: true});
480
- await fs.writeFile(schemaPath, JSON.stringify(schema, null, 2) + '\n', 'utf8');
502
+ // A user-data preset keeps the storage the site has (a move to or from
503
+ // MongoDB); rewriting it would flip the site to the other store.
504
+ const kept = USER_DATA.has(schema.slug) ? await keptStorage(schemaPath) : null;
505
+ const written = kept ? {...schema, storage: kept} : schema;
506
+ await fs.writeFile(schemaPath, JSON.stringify(written, null, 2) + '\n', 'utf8');
481
507
 
482
508
  try {
483
509
  await fs.access(dataPath);
@@ -15,6 +15,7 @@ import {resolveContextMenusForPage} from './contextMenus.js';
15
15
  import {buildCustomThemeStyleTag, buildOverrideStyleTag, getThemeTokenMap, listThemeIds, loadThemeConfig, resolveThemeClasses} from './themeSettings.js';
16
16
  import {getSearchSettings} from './search.js';
17
17
  import {isToolEnabled} from './tools.js';
18
+ import {fmtDateTime} from './dates.js';
18
19
 
19
20
  const VALID_LAYOUT_WIDTHS = new Set(['narrow', 'normal', 'wide', 'full']);
20
21
  const CUSTOM_CSS_PATH = new URL('../../content/custom.css', import.meta.url).pathname;
@@ -783,8 +784,7 @@ function formatExpiry(iso) {
783
784
  if (!iso) return '';
784
785
  const when = new Date(iso);
785
786
  if (Number.isNaN(when.getTime())) return '';
786
- const opts = {day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit'};
787
- return ` This link expires ${escapeHtml(when.toLocaleString('en-GB', opts))}.`;
787
+ return ` This link expires ${escapeHtml(fmtDateTime(when))}.`;
788
788
  }
789
789
 
790
790
  function escapeHtml(str) {
@@ -11,6 +11,8 @@
11
11
  * @module services/sidebarBadges
12
12
  */
13
13
 
14
+ import {fmtDate} from './dates.js';
15
+
14
16
  /** "1 task", "3 tasks". */
15
17
  export function plural(n, one, many = `${one}s`) {
16
18
  return `${n} ${n === 1 ? one : many}`;
@@ -25,7 +27,7 @@ export function ago(iso, now = Date.now()) {
25
27
  if (s < 3600) return `${Math.round(s / 60)} min ago`;
26
28
  if (s < 86400) return plural(Math.round(s / 3600), 'hour') + ' ago';
27
29
  if (s < 86400 * 14) return plural(Math.round(s / 86400), 'day') + ' ago';
28
- return new Date(t).toISOString().slice(0, 10);
30
+ return fmtDate(t);
29
31
  }
30
32
 
31
33
  /** Shorten to `max` characters on a word boundary, with an ellipsis. */
@@ -3,8 +3,10 @@
3
3
  *
4
4
  * ## Built-in Tools
5
5
  *
6
- * Contacts, Notes, Todo and Analytics were plugins until 0.67.0. They are part
7
- * of the CMS now and ON unless a site says otherwise, in `config/tools.json`:
6
+ * Contacts, Notes, Todo and Analytics were plugins until 0.67.0; SEO joined them
7
+ * in 0.90.0 (CORE_TOOLS below). They are part of the CMS and ON unless a site
8
+ * says otherwise, in `config/tools.json`, switched at System > Plugins
9
+ * (#/plugins, the Marketplace screen):
8
10
  *
9
11
  * { "notes": { "enabled": false, "locked": true } }
10
12
  *
@@ -136,7 +136,7 @@
136
136
  </script>
137
137
 
138
138
  <!-- Site initialisation -->
139
- <script src="/public/js/site.js?v=20260926-field-limits" type="module"></script>
139
+ <script src="/public/js/site.js?v=20260927-scope-mine" type="module"></script>
140
140
  <script src="/public/js/collection-context.js?v=20260927-ctx" type="module"></script>
141
141
  {{ctxMenusModule}}
142
142
 
@@ -147,7 +147,7 @@
147
147
 
148
148
  <!-- Interactive Collection Browser (used by [collection] shortcodes with
149
149
  searchable / filterable / sortable / paginate attributes) -->
150
- <script src="/public/js/collection-browser.js?v=20260927-browser"></script>
150
+ <script src="/public/js/collection-browser.js?v=20260927-scope-mine"></script>
151
151
 
152
152
  <!-- Core Forms (logic engine must load before renderer) -->
153
153
  <script src="/public/js/form-logic-engine.js?v=20260926-limits"></script>