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
@@ -20,15 +20,24 @@ import {buildTextStyle} from '../../public/js/text-style.mjs';
20
20
  const __dirname_md = path.dirname(fileURLToPath(import.meta.url));
21
21
  const BLOCKS_DIR = path.resolve(__dirname_md, '../../content/blocks');
22
22
 
23
- const BUILTIN_SHORTCODES = new Set([
24
- 'block', 'collection', 'view', 'dconfig', 'effect', 'tabs', 'tab',
23
+ /**
24
+ * Tag names the pipeline owns, so a block template with one of these names is
25
+ * never treated as a block short tag (`[name /]`). Keep in step with the
26
+ * processors in parseMarkdown() when adding a shortcode.
27
+ */
28
+ export const BUILTIN_SHORTCODES = new Set([
29
+ 'block', 'collection', 'view', 'dconfig', 'component', 'tabs', 'tab',
25
30
  'accordion', 'item', 'carousel', 'slide', 'countdown', 'timeline',
26
- 'event', 'spacer', 'center', 'icon', 'form', 'hero', 'table', 'badge',
31
+ 'event', 'listgroup', 'spacer', 'center', 'icon', 'demo', 'embed', 'form', 'hero', 'table', 'badge',
27
32
  'text', 'button', 'link', 'cta', 'grid', 'row', 'col', 'card', 'box',
28
- 'banner', 'slideover', 'counter', 'celebrate', 'firework', 'fireworks', 'scribe',
33
+ 'banner', 'slideover', 'menu',
34
+ // effects
35
+ 'counter', 'celebrate', 'firework', 'fireworks', 'scribe',
29
36
  'reveal', 'breathe', 'pulse', 'shake', 'scramble', 'ripple', 'twinkle',
30
- 'ticker-tape', 'butterflies', 'strobe', 'animate', 'ambient', 'list-group', 'menu',
31
- 'demo',
37
+ 'ticker-tape', 'butterflies', 'strobe', 'animate', 'ambient',
38
+ // named celebrations - sugar for [celebrate theme="…"]
39
+ 'christmas', 'halloween', 'st-patricks', 'valentines', 'guy-fawkes',
40
+ 'st-andrews', 'st-davids', 'st-georges',
32
41
  ]);
33
42
 
34
43
  // Configure marked for safe output
@@ -380,6 +389,7 @@ function renderCollectionBlocks(entries, blockTemplate, emptyMsg, ctaOpts, cols,
380
389
  let btnCls = `btn btn-${ctaStyle} dm-cta-trigger`;
381
390
  let btnData = `data-action="${escapeAttr(ctaOpts.action)}" data-entry="${escapeAttr(e.id || '')}"`;
382
391
  if (ctaOpts.confirm) btnData += ` data-confirm="${escapeAttr(ctaOpts.confirm)}"`;
392
+ if (ctaOpts.scope) btnData += ` data-scope="${escapeAttr(ctaOpts.scope)}"`;
383
393
  const iconHtml = ctaOpts.icon ? `<span data-icon="${escapeAttr(ctaOpts.icon)}"></span> ` : '';
384
394
  html += `\n<button class="${btnCls}" ${btnData}>${iconHtml}${escapeHtmlText(ctaOpts.label || 'Run')}</button>`;
385
395
  }
@@ -607,6 +617,7 @@ function renderCollectionCards(entries, visibleFields, titleField, columns, empt
607
617
  let btnCls = `btn btn-${ctaStyle} dm-cta-trigger`;
608
618
  let btnData = `data-action="${escapeAttr(ctaOpts.action)}" data-entry="${escapeAttr(e.id || '')}"`;
609
619
  if (ctaOpts.confirm) btnData += ` data-confirm="${escapeAttr(ctaOpts.confirm)}"`;
620
+ if (ctaOpts.scope) btnData += ` data-scope="${escapeAttr(ctaOpts.scope)}"`;
610
621
  const iconHtml = ctaOpts.icon ? `<span data-icon="${escapeAttr(ctaOpts.icon)}"></span> ` : '';
611
622
  footer = `<div class="card-footer"><button class="${btnCls}" ${btnData}>${iconHtml}${escapeHtmlText(ctaOpts.label || 'Run')}</button></div>`;
612
623
  }
@@ -634,6 +645,7 @@ function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaO
634
645
  let btnCls = `btn btn-${ctaStyle} dm-cta-trigger`;
635
646
  let btnData = `data-action="${escapeAttr(ctaOpts.action)}" data-entry="${escapeAttr(e.id || '')}"`;
636
647
  if (ctaOpts.confirm) btnData += ` data-confirm="${escapeAttr(ctaOpts.confirm)}"`;
648
+ if (ctaOpts.scope) btnData += ` data-scope="${escapeAttr(ctaOpts.scope)}"`;
637
649
  const iconHtml = ctaOpts.icon ? `<span data-icon="${escapeAttr(ctaOpts.icon)}"></span> ` : '';
638
650
  ctaHtml = `<button class="${btnCls}" ${btnData}>${iconHtml}${escapeHtmlText(ctaOpts.label || 'Run')}</button>`;
639
651
  }
@@ -1116,7 +1128,7 @@ async function processCollectionBlocks(markdown, tagSet) {
1116
1128
  // a server-rendered fragment as the no-JS fallback (visible until the
1117
1129
  // browser hydrates and replaces it).
1118
1130
  if (isInteractiveCollection(attrs)) {
1119
- const shell = await renderCollectionBrowserShell(attrs, schemaForFallback => schemaForFallback);
1131
+ const shell = await renderCollectionBrowserShell(attrs);
1120
1132
  result = result.replace(fullMatch, shell);
1121
1133
  continue;
1122
1134
  }
@@ -1128,6 +1140,25 @@ async function processCollectionBlocks(markdown, tagSet) {
1128
1140
  return restore(result);
1129
1141
  }
1130
1142
 
1143
+ /**
1144
+ * Render a `[collection scope="mine"]` for one signed-in viewer - what
1145
+ * `POST /api/collections/render-scope` returns. The rows are always those the
1146
+ * viewer created (`createdBy = userId`, set here, never by the client). An
1147
+ * interactive block (searchable / sortable / filterable / paginate) comes back
1148
+ * as a Collection Browser shell, so `transitions` works on it; anything else
1149
+ * as the static fragment.
1150
+ *
1151
+ * @param {Record<string, string>} attrs - Parsed shortcode attributes
1152
+ * @param {string} userId
1153
+ * @returns {Promise<string>}
1154
+ */
1155
+ export async function renderScopedCollection(attrs, userId) {
1156
+ const opts = {extraFilter: {createdBy: userId}, scope: 'mine'};
1157
+ return isInteractiveCollection(attrs)
1158
+ ? renderCollectionBrowserShell(attrs, opts)
1159
+ : renderCollectionFragment(attrs, opts);
1160
+ }
1161
+
1131
1162
  /**
1132
1163
  * Detect whether a `[collection]` shortcode opts into the interactive Browser.
1133
1164
  * Any of `searchable`, `sortable`, `filterable`, or `paginate` flips it on.
@@ -1171,10 +1202,19 @@ function isTruthyAttr(v) {
1171
1202
  * A static server-rendered fragment is also embedded as the no-JS fallback -
1172
1203
  * visible until `collection-browser.js` hydrates and replaces the shell.
1173
1204
  *
1205
+ * With `scope: 'mine'` (render-scope, for `[collection scope="mine"]`) the
1206
+ * shell is built for one viewer: `extraFilter` (`createdBy = <user id>`) is
1207
+ * ANDed into the seed and the fallback, and `config.scope` makes the browser
1208
+ * ask for `scope=mine` on every later fetch, transition list and action run,
1209
+ * so the server keeps the viewer to their own rows throughout.
1210
+ *
1174
1211
  * @param {Record<string, string>} attrs - Parsed shortcode attributes
1212
+ * @param {object} [opts]
1213
+ * @param {Record<string, unknown>} [opts.extraFilter] - ANDed into every listing
1214
+ * @param {string} [opts.scope] - 'mine' for a per-viewer shell
1175
1215
  * @returns {Promise<string>} HTML for the hydration shell
1176
1216
  */
1177
- async function renderCollectionBrowserShell(attrs) {
1217
+ async function renderCollectionBrowserShell(attrs, {extraFilter = null, scope = ''} = {}) {
1178
1218
  const slug = attrs.slug;
1179
1219
  const pageSize = parseInt(attrs['page-size'], 10) || 12;
1180
1220
  const declaredMode = attrs.mode === 'server' ? 'server' : 'client';
@@ -1198,6 +1238,7 @@ async function renderCollectionBrowserShell(attrs) {
1198
1238
  // Build the seed listEntries() opts - push any author-baked where_* filter
1199
1239
  // and the initial sort/order down to the adapter, just like the static path.
1200
1240
  const seedFilter = buildShortcodeFilter(attrs);
1241
+ if (extraFilter) Object.assign(seedFilter, extraFilter);
1201
1242
  const seedOpts = {
1202
1243
  sort: attrs.sort || 'createdAt',
1203
1244
  order: attrs.order || 'desc'
@@ -1263,6 +1304,7 @@ async function renderCollectionBrowserShell(attrs) {
1263
1304
  exportable: isTruthyAttr(attrs.exportable), // adds "Export CSV" button
1264
1305
  savedSearches: !isTruthyAttr(attrs['no-saved-searches']), // opt-out flag (defaults on)
1265
1306
  transitions: isTruthyAttr(attrs.transitions), // render per-row transition buttons
1307
+ scope: scope === 'mine' ? 'mine' : '', // per-viewer shell (render-scope)
1266
1308
  emptyMsg: attrs.empty || 'No entries found',
1267
1309
  cta: attrs.cta ? {
1268
1310
  action: attrs.cta,
@@ -1278,7 +1320,7 @@ async function renderCollectionBrowserShell(attrs) {
1278
1320
 
1279
1321
  // No-JS fallback - render the same first-page state with the static renderer.
1280
1322
  const fallbackAttrs = {...attrs, limit: String(pageSize)};
1281
- const fallback = await renderCollectionFragment(fallbackAttrs);
1323
+ const fallback = await renderCollectionFragment(fallbackAttrs, {extraFilter, scope});
1282
1324
 
1283
1325
  // Encode payloads. Schema can be modest; data can be large in client mode,
1284
1326
  // so we base64 once each and let the browser decode.
@@ -1420,9 +1462,10 @@ async function renderViewBrowserShell(attrs, {viewConfig = null, allowed = false
1420
1462
  * @param {Record<string, string>} attrs - Parsed shortcode attributes
1421
1463
  * @param {object} [opts]
1422
1464
  * @param {Record<string, unknown>} [opts.extraFilter] - Additional filter conditions to AND in
1465
+ * @param {string} [opts.scope] - 'mine': CTA buttons carry data-scope
1423
1466
  * @returns {Promise<string>} HTML fragment ready to inject into a page
1424
1467
  */
1425
- export async function renderCollectionFragment(attrs, { extraFilter = null } = {}) {
1468
+ export async function renderCollectionFragment(attrs, { extraFilter = null, scope = '' } = {}) {
1426
1469
  const slug = attrs.slug || '';
1427
1470
  if (!slug) return '';
1428
1471
 
@@ -1440,7 +1483,10 @@ export async function renderCollectionFragment(attrs, { extraFilter = null } = {
1440
1483
  label: attrs['cta-label'] || 'Run',
1441
1484
  icon: attrs['cta-icon'] || '',
1442
1485
  style: attrs['cta-style'] || 'primary',
1443
- confirm: attrs['cta-confirm'] || ''
1486
+ confirm: attrs['cta-confirm'] || '',
1487
+ // scope="mine": the button tells the server so, and a row outside the
1488
+ // viewer's scope is refused when the action runs (entryInScope).
1489
+ ...(scope === 'mine' && {scope: 'mine'})
1444
1490
  } : null;
1445
1491
 
1446
1492
  let replacement = `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
@@ -4221,10 +4267,13 @@ export async function parseMarkdown(raw, opts = {}) {
4221
4267
  const {data, content} = matter(raw);
4222
4268
  const extensions = getSanitizeExtensions();
4223
4269
 
4224
- // Pipeline:
4225
- // beforeParse → collection → view → staticBlock → menu → dconfig → effects → plugin shortcodes → tabs → accordion → carousel
4226
- // → countdown → timeline → spacer → center → icon → form → hero → table → badge → button → link → cta
4227
- // → grid → card → slideover → marked → sanitize → afterParse
4270
+ // Pipeline (earlier stages are already HTML when later ones run; keep in step
4271
+ // with "Shortcode Nesting Rules" in CLAUDE.md and docs/markdown-shortcodes.md):
4272
+ // beforeParse → components ([component] / <dm-*>) → collection → view → block (+ short tags) → menu
4273
+ // → dconfig → effects → plugin shortcodes → tabs → accordion → carousel → countdown → timeline
4274
+ // → listgroup → spacer → center → icon → demo → embed → form → hero → table
4275
+ // → badge → text → button → link → cta → grid → card (+ box) → banner → slideover
4276
+ // → marked → sanitize → afterParse
4228
4277
  const preprocessed = applyTransforms('markdown:beforeParse', content);
4229
4278
  const {output: withComponents, used: usedComponents} = collectAndRewriteComponents(preprocessed);
4230
4279
  const tagSet = new Set();
@@ -4237,19 +4286,19 @@ export async function parseMarkdown(raw, opts = {}) {
4237
4286
  : {};
4238
4287
  const withMenu = await processMenuBlocks(withStaticBlock, opts.user || null, menuCtx);
4239
4288
  const withDconfig = processDConfigBlocks(withMenu);
4240
- const withEffects = processEffectsBlocks(withDconfig);
4241
- const withPluginShortcodes = await processPluginShortcodes(withEffects);
4289
+ const withEffects = processEffectsBlocks(withDconfig);
4290
+ const withPluginShortcodes = await processPluginShortcodes(withEffects);
4242
4291
  const withTabs = processTabsBlocks(withPluginShortcodes);
4243
4292
  const withAccordion = processAccordionBlocks(withTabs);
4244
4293
  const withCarousel = processCarouselBlocks(withAccordion);
4245
4294
  const withCountdown = processCountdownBlocks(withCarousel);
4246
4295
  const withTimeline = processTimelineBlocks(withCountdown);
4247
- const withListGroup = processListGroupBlocks(withTimeline);
4248
- const withSpacer = processSpacerBlocks(withListGroup);
4249
- const withCenter = processCenterBlocks(withSpacer);
4250
- const withIcon = processIconBlocks(withCenter);
4251
- const withDemo = processDemoBlocks(withIcon);
4252
- const withEmbed = processEmbedBlocks(withDemo);
4296
+ const withListGroup = processListGroupBlocks(withTimeline);
4297
+ const withSpacer = processSpacerBlocks(withListGroup);
4298
+ const withCenter = processCenterBlocks(withSpacer);
4299
+ const withIcon = processIconBlocks(withCenter);
4300
+ const withDemo = processDemoBlocks(withIcon);
4301
+ const withEmbed = processEmbedBlocks(withDemo);
4253
4302
  const withForm = await processFormBlocks(withEmbed, tagSet);
4254
4303
  const withHero = processHeroBlocks(withForm);
4255
4304
  const withTable = processTableBlocks(withHero);
@@ -4257,11 +4306,11 @@ export async function parseMarkdown(raw, opts = {}) {
4257
4306
  const withText = processTextBlocks(withBadge);
4258
4307
  const withButton = processButtonBlocks(withText);
4259
4308
  const withLink = processLinkBlocks(withButton);
4260
- const withCta = processCtaBlocks(withLink);
4309
+ const withCta = processCtaBlocks(withLink);
4261
4310
  const withGrid = processGridBlocks(withCta);
4262
4311
  const withCard = processCardBlocks(withGrid);
4263
- const withBanner = processBannerBlocks(withCard);
4264
- const withSlideover = processSlideoverBlocks(withBanner);
4312
+ const withBanner = processBannerBlocks(withCard);
4313
+ const withSlideover = processSlideoverBlocks(withBanner);
4265
4314
  const rendered = marked.parse(withSlideover);
4266
4315
 
4267
4316
  const sanitized = sanitizeHtml(rendered, {
@@ -19,6 +19,7 @@ import fs from 'fs';
19
19
  import path from 'path';
20
20
  import {config, getConfig} from '../config.js';
21
21
  import {notify, registerNotificationSource, resolveNotification} from './notify.js';
22
+ import {fmtDate, fmtDateTime} from './dates.js';
22
23
 
23
24
  const day = (d = new Date()) => d.toISOString().slice(0, 10);
24
25
 
@@ -37,7 +38,7 @@ const day = (d = new Date()) => d.toISOString().slice(0, 10);
37
38
  */
38
39
  export function licenceNotice(name, label, verdict, now = new Date()) {
39
40
  const e = verdict?.entitlement || {};
40
- const ends = (iso) => String(iso || '').slice(0, 10);
41
+ const ends = (iso) => fmtDate(iso, {utc: true});
41
42
  switch (verdict?.state) {
42
43
  case 'unlicensed':
43
44
  return {severity: 'critical', title: `${label} is not running: no valid licence`,
@@ -48,7 +49,7 @@ export function licenceNotice(name, label, verdict, now = new Date()) {
48
49
  case 'grace': {
49
50
  const graceEnd = new Date(Date.parse(e.expiresAt) + (Number(e.gracePeriodDays) || 0) * 86_400_000);
50
51
  return {severity: 'warning', title: `${label} licence has expired`,
51
- body: `The ${label} licence ended on ${ends(e.expiresAt)}. It keeps working until ${day(graceEnd)}, then stops. Renew it before then.`};
52
+ body: `The ${label} licence ended on ${ends(e.expiresAt)}. It keeps working until ${fmtDate(graceEnd, {utc: true})}, then stops. Renew it before then.`};
52
53
  }
53
54
  case 'support-ended':
54
55
  return {severity: 'info', title: `${label}: support and updates have ended`,
@@ -78,9 +79,9 @@ export function tokenNotice(data, now = new Date()) {
78
79
  const name = data.name || 'An API token';
79
80
  return left < 0
80
81
  ? {severity: 'warning', title: `API token "${name}" has expired`,
81
- body: `It expired on ${String(data.expiresAt).slice(0, 10)}. Anything still using it is being refused - issue a new one in API Tokens.`}
82
+ body: `It expired on ${fmtDate(data.expiresAt, {utc: true})}. Anything still using it is being refused - issue a new one in API Tokens.`}
82
83
  : {severity: 'warning', title: `API token "${name}" expires in ${left} day${left === 1 ? '' : 's'}`,
83
- body: `It expires on ${String(data.expiresAt).slice(0, 10)}. Anything using it will be refused after that - extend it or issue a new one in API Tokens.`};
84
+ body: `It expires on ${fmtDate(data.expiresAt, {utc: true})}. Anything using it will be refused after that - extend it or issue a new one in API Tokens.`};
84
85
  }
85
86
 
86
87
  /** Sign-in failures: this many for one account inside the window is worth telling. */
@@ -242,7 +243,7 @@ export async function registerCoreSources() {
242
243
  try { fs.rmSync(crashMarker(), {force: true}); } catch { /* raised once anyway: dedupe */ }
243
244
  await raise({severity: 'critical', dedupeKey: `crash:${marker.at}`,
244
245
  title: 'The site restarted after a crash',
245
- body: `It stopped on an unexpected error at ${String(marker.at).replace('T', ' ').slice(0, 16)} UTC and was started again.\n\n${marker.message}`});
246
+ body: `It stopped on an unexpected error at ${fmtDateTime(marker.at, {utc: true})} UTC and was started again.\n\n${marker.message}`});
246
247
  }
247
248
  });
248
249
 
@@ -40,6 +40,7 @@ import {config, getConfig} from '../config.js';
40
40
  import {hooks} from './hooks.js';
41
41
  import {getUserByEmail, getUserById, getUserByResetToken, setResetToken} from './users.js';
42
42
  import * as email from './email.js';
43
+ import {fmtDateTime} from './dates.js';
43
44
 
44
45
  export const ORIGINS_FILE = path.resolve(path.dirname(config.content.usersDir), 'sessions', 'origins.json');
45
46
  const MAX_ORIGINS = 20;
@@ -482,7 +483,7 @@ export async function passwordChangedNotice({userId, by}) {
482
483
  if (!mailer.isSmtpConfigured(site.smtp)) return {sent: false, reason: 'no-smtp'};
483
484
  const how = by === 'reset' ? 'reset' : by === userId ? 'self' : 'admin';
484
485
  const origin = await trustedOrigin('');
485
- const when = new Date().toISOString().replace('T', ' ').slice(0, 16) + ' UTC';
486
+ const when = `${fmtDateTime(new Date(), {utc: true})} UTC`;
486
487
  const message = buildChangedEmail({name: user.name, how, when, siteTitle: site.title,
487
488
  signInUrl: origin ? `${origin}/admin/#/login` : ''});
488
489
  const transport = await mailer.createTransport(site.smtp);
@@ -212,7 +212,8 @@ export const REGISTRY = [
212
212
  {key: 'read', label: 'View', description: 'View own contacts'},
213
213
  {key: 'create', label: 'Create', description: 'Add contacts and groups'},
214
214
  {key: 'update', label: 'Edit', description: 'Edit own contacts'},
215
- {key: 'delete', label: 'Delete', description: 'Delete own contacts'}
215
+ {key: 'delete', label: 'Delete', description: 'Delete own contacts'},
216
+ {key: 'manageGroups', label: 'Manage groups', description: 'Rename or delete any group, not only the ones they created - groups are shared, so this changes everyone\'s contacts'}
216
217
  ]
217
218
  },
218
219
  {
@@ -384,7 +385,7 @@ export const RESOURCES = REGISTRY.map(r => r.key);
384
385
  export const ACTIONS = ['read', 'create', 'update', 'delete'];
385
386
 
386
387
  /** Display order for permission groups. */
387
- export const GROUP_ORDER = ['Content', 'Structure', 'Data', 'Configuration', 'Plugins'];
388
+ export const GROUP_ORDER = ['Content', 'Structure', 'Data', 'Configuration', 'Tools', 'Plugins'];
388
389
 
389
390
  /**
390
391
  * Return the action keys defined for a given resource (base or plugin-contributed).
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Plugin guides - user documentation a plugin ships for the admin's
3
+ * Documentation folder.
4
+ *
5
+ * A plugin puts Markdown files in `plugins/<name>/docs/*.md`. When the plugin
6
+ * is loaded (enabled, licensed, not superseded), each file becomes a page under
7
+ * Documentation > Plugins in the admin sidebar (`#/docs/plugins/<name>`), shown
8
+ * to users who can use the plugin: the manifest's `docs.permission`, else the
9
+ * permission on its first `admin.sidebar` entry that names one, else anyone
10
+ * signed in to the admin.
11
+ *
12
+ * Files are plain Markdown with optional frontmatter `title` (the tab label)
13
+ * and `order` (a number, lowest first; `guide.md` comes first by default).
14
+ * Shortcodes are NOT run - a guide shows `[collection ...]` examples literally,
15
+ * which is what a guide wants. The HTML is sanitised like any other page body.
16
+ *
17
+ * Pure helpers are exported for tests; the registry is module state filled once
18
+ * per boot by registerPluginGuides() (plugins load at boot only).
19
+ */
20
+ import fs from 'fs/promises';
21
+ import path from 'path';
22
+ import matter from 'gray-matter';
23
+ import {marked} from 'marked';
24
+ import sanitizeHtml from 'sanitize-html';
25
+ import {registerSidebarItem} from './hooks.js';
26
+
27
+ /** The Documentation folder's key in the admin-sidebar menu. */
28
+ export const DOCS_FOLDER_KEY = 'documentation';
29
+ /** Identity (url) of the Plugins sub-folder and its landing page. */
30
+ export const GUIDES_URL = '#/docs/plugins';
31
+
32
+ const SLUG_RE = /^[a-z0-9][a-z0-9_-]*$/i;
33
+
34
+ /** name -> {name, displayName, description, icon, version, permission, toolUrl, dir, pages: [{slug, title, order, file}]} */
35
+ let _guides = new Map();
36
+
37
+ /**
38
+ * Who may read a plugin's guides: `docs.permission`, else the first sidebar
39
+ * entry's permission, else null (anyone signed in to the admin).
40
+ *
41
+ * @param {object} manifest
42
+ * @returns {string|null}
43
+ */
44
+ export function guidePermission(manifest) {
45
+ const explicit = manifest?.docs?.permission;
46
+ if (typeof explicit === 'string' && explicit.trim()) return explicit.trim();
47
+ const entry = (manifest?.admin?.sidebar || []).find(s => typeof s?.permission === 'string' && s.permission);
48
+ return entry ? entry.permission : null;
49
+ }
50
+
51
+ /**
52
+ * Does this permission list cover `resource`? Same rule as the sidebar's canAny:
53
+ * the bare resource or any of its actions.
54
+ *
55
+ * @param {string[]} permissions
56
+ * @param {string|null} resource
57
+ * @returns {boolean}
58
+ */
59
+ export function canUse(permissions, resource) {
60
+ if (!resource) return true;
61
+ if (!Array.isArray(permissions) || !permissions.length) return false;
62
+ if (permissions.includes(resource)) return true;
63
+ return ['read', 'create', 'update', 'delete'].some(a => permissions.includes(`${resource}.${a}`));
64
+ }
65
+
66
+ /** "shipping-zones" -> "Shipping zones"; "guide" -> "Guide". */
67
+ export function titleFromSlug(slug) {
68
+ const words = String(slug || '').replace(/^\d+[-_]/, '').replace(/[-_]+/g, ' ').trim();
69
+ return words ? words.charAt(0).toUpperCase() + words.slice(1) : 'Guide';
70
+ }
71
+
72
+ /**
73
+ * Parse one guide file's source into its page meta.
74
+ *
75
+ * @param {string} slug - file name without `.md`
76
+ * @param {string} source
77
+ * @returns {{slug: string, title: string, order: number}}
78
+ */
79
+ export function pageMeta(slug, source) {
80
+ let data = {};
81
+ try { data = matter(source).data || {}; } catch { data = {}; }
82
+ const title = typeof data.title === 'string' && data.title.trim() ? data.title.trim() : titleFromSlug(slug);
83
+ const order = Number.isFinite(Number(data.order)) && data.order !== null && data.order !== ''
84
+ ? Number(data.order)
85
+ : (slug === 'guide' ? 0 : 100);
86
+ return {slug, title, order};
87
+ }
88
+
89
+ /** Pages sorted by order, then slug. */
90
+ export function sortPages(pages) {
91
+ return [...pages].sort((a, b) => (a.order - b.order) || a.slug.localeCompare(b.slug));
92
+ }
93
+
94
+ const SANITIZE = {
95
+ allowedTags: [...sanitizeHtml.defaults.allowedTags, 'img', 'h1', 'h2', 'del', 'details', 'summary'],
96
+ allowedAttributes: {
97
+ a: ['href', 'title', 'target', 'rel'],
98
+ img: ['src', 'alt', 'title', 'width', 'height'],
99
+ code: ['class'],
100
+ table: ['class'],
101
+ th: ['align', 'style'],
102
+ td: ['align', 'style'],
103
+ ol: ['start']
104
+ },
105
+ allowedStyles: {th: {'text-align': [/^(left|right|center)$/]}, td: {'text-align': [/^(left|right|center)$/]}},
106
+ allowedSchemes: ['http', 'https', 'mailto'],
107
+ transformTags: {
108
+ // Links leaving the admin open in a new tab; hash links stay in the SPA.
109
+ a: (tagName, attribs) => {
110
+ const href = attribs.href || '';
111
+ if (/^https?:\/\//i.test(href)) return {tagName, attribs: {...attribs, target: '_blank', rel: 'noopener noreferrer'}};
112
+ return {tagName, attribs};
113
+ },
114
+ table: () => ({tagName: 'table', attribs: {class: 'table table-sm'}})
115
+ }
116
+ };
117
+
118
+ /**
119
+ * Markdown -> sanitised HTML. Frontmatter is dropped; shortcodes are not run.
120
+ *
121
+ * @param {string} source
122
+ * @returns {string}
123
+ */
124
+ export function renderGuide(source) {
125
+ let body = source;
126
+ try { body = matter(source).content; } catch { /* no frontmatter */ }
127
+ return sanitizeHtml(marked.parse(body, {gfm: true, async: false}), SANITIZE);
128
+ }
129
+
130
+ /**
131
+ * Read a plugin's `docs/` folder into page metas. Missing folder = no pages.
132
+ *
133
+ * @param {string} pluginDir
134
+ * @returns {Promise<Array<{slug, title, order, file}>>}
135
+ */
136
+ export async function readGuidePages(pluginDir) {
137
+ const dir = path.join(pluginDir, 'docs');
138
+ let names;
139
+ try { names = await fs.readdir(dir); } catch { return []; }
140
+ const pages = [];
141
+ for (const name of names) {
142
+ if (!name.toLowerCase().endsWith('.md')) continue;
143
+ const slug = name.slice(0, -3);
144
+ if (!SLUG_RE.test(slug)) continue;
145
+ const file = path.join(dir, name);
146
+ try {
147
+ const source = await fs.readFile(file, 'utf8');
148
+ pages.push({...pageMeta(slug, source), file});
149
+ } catch { /* unreadable - skip */ }
150
+ }
151
+ return sortPages(pages);
152
+ }
153
+
154
+ /**
155
+ * The sidebar node for Documentation > Plugins: a folder of one link per
156
+ * plugin, each gated by that plugin's permission (the renderer drops the ones
157
+ * a user cannot use).
158
+ *
159
+ * @param {Array<object>} guides - registry values
160
+ * @returns {object|null}
161
+ */
162
+ export function guidesSidebarItem(guides) {
163
+ if (!guides.length) return null;
164
+ return {
165
+ source: 'Built in',
166
+ text: 'Plugins',
167
+ url: GUIDES_URL,
168
+ icon: 'package',
169
+ items: [...guides]
170
+ .sort((a, b) => a.displayName.localeCompare(b.displayName))
171
+ .map(g => ({
172
+ text: g.displayName,
173
+ url: `${GUIDES_URL}/${g.name}`,
174
+ icon: g.icon || 'package',
175
+ ...(g.permission && {permission: g.permission})
176
+ }))
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Collect the guides of the plugins that loaded and add the sidebar folder.
182
+ * Called once at boot, after registerPlugins() has loaded them.
183
+ *
184
+ * @param {object[]} manifests - manifests of LOADED plugins
185
+ * @param {string} pluginsDir
186
+ * @param {{register?: Function}} [opts] - sidebar registration (tests pass a stub)
187
+ * @returns {Promise<number>} how many plugins have guides
188
+ */
189
+ export async function registerPluginGuides(manifests, pluginsDir, {register = registerSidebarItem} = {}) {
190
+ const next = new Map();
191
+ for (const manifest of manifests || []) {
192
+ if (!manifest?.name) continue;
193
+ const dir = path.join(pluginsDir, manifest.name);
194
+ const pages = await readGuidePages(dir);
195
+ if (!pages.length) continue;
196
+ next.set(manifest.name, {
197
+ name: manifest.name,
198
+ displayName: manifest.displayName || manifest.name,
199
+ description: manifest.description || '',
200
+ icon: manifest.icon || 'package',
201
+ version: manifest.version || '',
202
+ tier: manifest.tier || '',
203
+ permission: guidePermission(manifest),
204
+ toolUrl: (manifest.admin?.sidebar || []).find(s => s?.url)?.url || null,
205
+ pages
206
+ });
207
+ }
208
+ _guides = next;
209
+ const item = guidesSidebarItem([...next.values()]);
210
+ if (item) register({folder: DOCS_FOLDER_KEY, item});
211
+ return next.size;
212
+ }
213
+
214
+ /** Public shape of a guide (no file paths). */
215
+ function publicGuide(g) {
216
+ const {pages, ...rest} = g;
217
+ return {...rest, pages: pages.map(({slug, title}) => ({slug, title}))};
218
+ }
219
+
220
+ /**
221
+ * Every guide this permission list may read.
222
+ *
223
+ * @param {string[]} permissions
224
+ * @returns {object[]}
225
+ */
226
+ export function listGuides(permissions) {
227
+ return [...(_guides.values())]
228
+ .filter(g => canUse(permissions, g.permission))
229
+ .sort((a, b) => a.displayName.localeCompare(b.displayName))
230
+ .map(publicGuide);
231
+ }
232
+
233
+ /**
234
+ * One guide with one page rendered, or a reason it cannot be shown.
235
+ *
236
+ * @param {string} name
237
+ * @param {string|undefined} pageSlug - defaults to the first page
238
+ * @param {string[]} permissions
239
+ * @returns {Promise<{status: 200|403|404, guide?: object}>}
240
+ */
241
+ export async function getGuide(name, pageSlug, permissions) {
242
+ const g = _guides.get(name);
243
+ if (!g) return {status: 404};
244
+ if (!canUse(permissions, g.permission)) return {status: 403};
245
+ const page = pageSlug ? g.pages.find(p => p.slug === pageSlug) : g.pages[0];
246
+ if (!page) return {status: 404};
247
+ let source;
248
+ try { source = await fs.readFile(page.file, 'utf8'); } catch { return {status: 404}; }
249
+ return {status: 200, guide: {...publicGuide(g), page: {slug: page.slug, title: page.title, html: renderGuide(source)}}};
250
+ }
251
+
252
+ /** Test seam: replace the registry. */
253
+ export function _setGuidesForTest(list) {
254
+ _guides = new Map((list || []).map(g => [g.name, g]));
255
+ }