domma-cms 0.66.0 → 0.69.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CLAUDE.md +64 -1
  2. package/README.md +14 -10
  3. package/admin/css/admin.css +1 -1
  4. package/admin/index.html +1 -0
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +4 -4
  7. package/admin/js/lib/analytics-shape.js +1 -0
  8. package/admin/js/lib/contacts-arrange.js +1 -0
  9. package/admin/js/lib/help-popover.js +1 -0
  10. package/admin/js/lib/notes-arrange.js +1 -0
  11. package/admin/js/lib/plugin-accent.js +1 -1
  12. package/admin/js/lib/plugin-chrome.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.js +1 -1
  14. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  15. package/admin/js/lib/sidebar-renderer.js +3 -3
  16. package/admin/js/lib/todo-arrange.js +1 -0
  17. package/admin/js/lib/tool-kit.js +1 -0
  18. package/admin/js/templates/analytics.html +138 -0
  19. package/admin/js/templates/contacts.html +156 -0
  20. package/admin/js/templates/notes.html +137 -0
  21. package/admin/js/templates/plugin-marketplace.html +17 -4
  22. package/admin/js/templates/plugins.html +13 -7
  23. package/admin/js/templates/theme.html +38 -0
  24. package/admin/js/templates/todo.html +113 -0
  25. package/admin/js/views/analytics.js +62 -0
  26. package/admin/js/views/contacts.js +174 -0
  27. package/admin/js/views/index.js +1 -1
  28. package/admin/js/views/notes.js +104 -0
  29. package/admin/js/views/plugin-marketplace.js +1 -1
  30. package/admin/js/views/plugins.js +24 -22
  31. package/admin/js/views/theme.js +1 -1
  32. package/admin/js/views/todo.js +96 -0
  33. package/bin/cli.js +4 -10
  34. package/bin/lib/plugin-version.js +28 -0
  35. package/bin/update.js +14 -0
  36. package/config/menus/admin-sidebar.json +1 -1
  37. package/config/server.json +3 -1
  38. package/package.json +3 -7
  39. package/plugins/_lib/admin/mail/reader-view.js +20 -0
  40. package/plugins/_lib/mail/store.js +21 -1
  41. package/plugins/blog/CLAUDE.md +237 -0
  42. package/plugins/blog/admin/css/index.css +178 -1
  43. package/plugins/blog/admin/templates/blog.html +35 -53
  44. package/plugins/blog/admin/templates/categories.html +19 -6
  45. package/plugins/blog/admin/templates/comments.html +26 -9
  46. package/plugins/blog/admin/templates/post-editor.html +36 -13
  47. package/plugins/blog/admin/templates/settings.html +2 -11
  48. package/plugins/blog/admin/views/blog.js +288 -162
  49. package/plugins/blog/admin/views/categories.js +212 -201
  50. package/plugins/blog/admin/views/comments.js +207 -158
  51. package/plugins/blog/admin/views/kit.js +372 -0
  52. package/plugins/blog/admin/views/post-editor.js +269 -21
  53. package/plugins/blog/admin/views/settings-cog.js +56 -0
  54. package/plugins/blog/admin/views/settings.js +16 -86
  55. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  56. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  57. package/plugins/blog/blocks/blog-card-row.css +22 -0
  58. package/plugins/blog/blocks/blog-card-row.html +11 -0
  59. package/plugins/blog/blocks/blog-card.css +35 -0
  60. package/plugins/blog/blocks/blog-card.html +16 -0
  61. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  62. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  63. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  64. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  65. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  66. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  67. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  68. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  69. package/plugins/blog/collections/posts/schema.json +3 -1
  70. package/plugins/blog/config.js +6 -0
  71. package/plugins/blog/lib/layouts.js +304 -0
  72. package/plugins/blog/lib/page.js +158 -0
  73. package/plugins/blog/lib/render.js +119 -0
  74. package/plugins/blog/lib/samples.js +234 -0
  75. package/plugins/blog/plugin.js +225 -15
  76. package/plugins/blog/plugin.json +21 -81
  77. package/plugins/blog/plugin.public.js +115 -210
  78. package/plugins/blog/public/blog.css +177 -0
  79. package/plugins/blog/public/blog.js +586 -0
  80. package/plugins/blog/public/samples/aurora.svg +7 -0
  81. package/plugins/blog/public/samples/dusk.svg +7 -0
  82. package/plugins/blog/public/samples/ember.svg +7 -0
  83. package/plugins/blog/public/samples/harbour.svg +7 -0
  84. package/plugins/blog/public/samples/meadow.svg +7 -0
  85. package/plugins/blog/public/samples/rosewood.svg +7 -0
  86. package/plugins/blog/templates/index.html +9 -8
  87. package/plugins/blog/templates/post.html +4 -17
  88. package/plugins/blog/tests/layouts.test.js +97 -0
  89. package/plugins/blog/tests/public.test.js +71 -0
  90. package/plugins/free-tier.lock.json +74 -0
  91. package/plugins/mail-reader/CLAUDE.md +78 -0
  92. package/plugins/mail-reader/plugin.json +2 -1
  93. package/public/css/theme-switcher.css +1 -0
  94. package/public/js/analytics.js +1 -0
  95. package/public/js/theme-switcher.js +1 -0
  96. package/scripts/build.js +41 -2
  97. package/scripts/run-tests.mjs +92 -0
  98. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  99. package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
  100. package/server/routes/api/dashboard.js +8 -2
  101. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  102. package/server/routes/api/plugin-marketplace.js +78 -23
  103. package/server/routes/api/plugins.js +148 -3
  104. package/server/routes/api/todo.js +178 -0
  105. package/server/server.js +61 -1
  106. package/server/services/collections.js +13 -3
  107. package/server/services/managerClient.js +72 -136
  108. package/server/services/markdown.js +54 -1
  109. package/server/services/permissionRegistry.js +50 -0
  110. package/server/services/pluginEntitlement.js +171 -0
  111. package/server/services/pluginEnvelope.js +242 -0
  112. package/server/services/pluginInstaller.js +246 -26
  113. package/server/services/pluginScaffold.js +5 -0
  114. package/server/services/plugins.js +117 -7
  115. package/server/services/presetCollections.js +344 -0
  116. package/server/services/promoted-plugins-migration.js +254 -0
  117. package/server/services/publicCors.js +46 -0
  118. package/server/services/renderer.js +83 -3
  119. package/server/services/sidebarBadges.js +37 -0
  120. package/server/services/siteGitignore.js +307 -0
  121. package/server/services/themeSettings.js +39 -0
  122. package/config/theme.json +0 -18
  123. package/plugins/analytics/admin/css/index.css +0 -1
  124. package/plugins/analytics/admin/templates/analytics.html +0 -70
  125. package/plugins/analytics/admin/views/analytics.js +0 -176
  126. package/plugins/analytics/config.js +0 -14
  127. package/plugins/analytics/plugin.json +0 -60
  128. package/plugins/analytics/public/inject-body.html +0 -60
  129. package/plugins/analytics/public/inject-head.html +0 -1
  130. package/plugins/blog/templates/author.html +0 -9
  131. package/plugins/blog/templates/category.html +0 -9
  132. package/plugins/blog/templates/tag.html +0 -9
  133. package/plugins/contacts/admin/templates/contacts.html +0 -254
  134. package/plugins/contacts/admin/views/contacts.js +0 -943
  135. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  136. package/plugins/contacts/collections/user-contacts/schema.json +0 -179
  137. package/plugins/contacts/config.js +0 -6
  138. package/plugins/contacts/plugin.json +0 -36
  139. package/plugins/demo-viewer/config.js +0 -4
  140. package/plugins/demo-viewer/plugin.js +0 -36
  141. package/plugins/demo-viewer/plugin.json +0 -9
  142. package/plugins/invoice/admin/css/index.css +0 -1
  143. package/plugins/invoice/admin/templates/editor.html +0 -220
  144. package/plugins/invoice/admin/templates/index.html +0 -173
  145. package/plugins/invoice/admin/templates/issuers.html +0 -2
  146. package/plugins/invoice/admin/templates/receivers.html +0 -2
  147. package/plugins/invoice/admin/views/contacts-source.js +0 -266
  148. package/plugins/invoice/admin/views/editor.js +0 -434
  149. package/plugins/invoice/admin/views/export.js +0 -199
  150. package/plugins/invoice/admin/views/help-content.js +0 -61
  151. package/plugins/invoice/admin/views/index.js +0 -643
  152. package/plugins/invoice/admin/views/issuers.js +0 -30
  153. package/plugins/invoice/admin/views/media.js +0 -172
  154. package/plugins/invoice/admin/views/party-view.js +0 -386
  155. package/plugins/invoice/admin/views/payments.js +0 -127
  156. package/plugins/invoice/admin/views/print.js +0 -130
  157. package/plugins/invoice/admin/views/receivers.js +0 -55
  158. package/plugins/invoice/admin/views/send.js +0 -212
  159. package/plugins/invoice/admin/views/settings.js +0 -594
  160. package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
  161. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
  162. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
  163. package/plugins/invoice/collections/invoices/schema.json +0 -33
  164. package/plugins/invoice/config.js +0 -37
  165. package/plugins/invoice/pdf.js +0 -164
  166. package/plugins/invoice/plugin.js +0 -1456
  167. package/plugins/invoice/plugin.json +0 -86
  168. package/plugins/invoice/templates/_base.css +0 -1
  169. package/plugins/invoice/templates/classic-nologo.html +0 -100
  170. package/plugins/invoice/templates/classic.html +0 -91
  171. package/plugins/invoice/templates/invoice-print.html +0 -237
  172. package/plugins/invoice/templates/minimal.html +0 -99
  173. package/plugins/invoice/templates/modern-nologo.html +0 -114
  174. package/plugins/invoice/templates/modern.html +0 -113
  175. package/plugins/invoice/templates/templates.json +0 -11
  176. package/plugins/notes/admin/templates/notes.html +0 -83
  177. package/plugins/notes/admin/views/notes.js +0 -281
  178. package/plugins/notes/collections/user-notes/schema.json +0 -54
  179. package/plugins/notes/config.js +0 -6
  180. package/plugins/notes/plugin.json +0 -33
  181. package/plugins/surveys/admin/templates/audience.html +0 -47
  182. package/plugins/surveys/admin/templates/results.html +0 -58
  183. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  184. package/plugins/surveys/admin/templates/surveys.html +0 -25
  185. package/plugins/surveys/admin/views/audience.js +0 -301
  186. package/plugins/surveys/admin/views/results.js +0 -172
  187. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  188. package/plugins/surveys/admin/views/surveys.js +0 -161
  189. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  190. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  191. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  192. package/plugins/surveys/collections/surveys/schema.json +0 -23
  193. package/plugins/surveys/config.js +0 -8
  194. package/plugins/surveys/lib/audience.js +0 -37
  195. package/plugins/surveys/lib/campaigns.js +0 -43
  196. package/plugins/surveys/lib/ledger.js +0 -110
  197. package/plugins/surveys/lib/sending.js +0 -106
  198. package/plugins/surveys/lib/stats.js +0 -62
  199. package/plugins/surveys/lib/submit.js +0 -95
  200. package/plugins/surveys/lib/tokens.js +0 -28
  201. package/plugins/surveys/plugin.js +0 -174
  202. package/plugins/surveys/plugin.json +0 -65
  203. package/plugins/surveys/plugin.public.js +0 -149
  204. package/plugins/surveys/public/dist-shared.mjs +0 -1
  205. package/plugins/surveys/public/survey.css +0 -1
  206. package/plugins/surveys/public/survey.mjs +0 -1
  207. package/plugins/surveys/templates/survey-page.html +0 -63
  208. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  209. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  210. package/plugins/theme-switcher/config.js +0 -10
  211. package/plugins/theme-switcher/plugin.js +0 -26
  212. package/plugins/theme-switcher/plugin.json +0 -74
  213. package/plugins/theme-switcher/public/inject-body.html +0 -153
  214. package/plugins/theme-switcher/public/inject-head.html +0 -260
  215. package/plugins/todo/admin/templates/todo.html +0 -158
  216. package/plugins/todo/admin/views/todo.js +0 -343
  217. package/plugins/todo/collections/todos/schema.json +0 -60
  218. package/plugins/todo/config.js +0 -7
  219. package/plugins/todo/plugin.js +0 -102
  220. package/plugins/todo/plugin.json +0 -33
  221. /package/{plugins/contacts/admin/css/index.css → admin/css/contacts.css} +0 -0
@@ -0,0 +1,7 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" width="1600" height="900">
2
+ <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#831843"/><stop offset="1" stop-color="#ec4899"/></linearGradient>
3
+ <filter id="blur"><feGaussianBlur stdDeviation="40"/></filter></defs>
4
+ <rect width="1600" height="900" fill="url(#g)"/>
5
+ <g filter="url(#blur)"><circle cx="1309" cy="431" r="339" fill="#fecdd3" opacity="0.1"/><circle cx="750" cy="714" r="357" fill="#fecdd3" opacity="0.18"/><circle cx="1245" cy="680" r="207" fill="#ffffff" opacity="0.18"/><circle cx="546" cy="727" r="288" fill="#ffffff" opacity="0.18"/><circle cx="1564" cy="596" r="325" fill="#ec4899" opacity="0.14"/><circle cx="172" cy="540" r="370" fill="#ffffff" opacity="0.14"/><circle cx="1078" cy="252" r="125" fill="#ffffff" opacity="0.22"/></g>
6
+ <path d="M0 520 C400 400 1200 640 1600 520" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 580 C400 460 1200 700 1600 580" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 640 C400 520 1200 760 1600 640" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 700 C400 580 1200 820 1600 700" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 760 C400 640 1200 880 1600 760" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 820 C400 700 1200 940 1600 820" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/><path d="M0 880 C400 760 1200 1000 1600 880" stroke="#fff" stroke-opacity=".12" stroke-width="2" fill="none"/>
7
+ </svg>
@@ -1,9 +1,10 @@
1
- <main class="container" style="padding: 2rem 0;">
2
- <h1 class="mb-4">{{archiveTitle}}</h1>
3
- <div id="blog-posts-list">
4
- {{posts}}
5
- </div>
6
- <div id="blog-pagination" class="mt-4">
7
- {{pagination}}
8
- </div>
1
+ <link rel="stylesheet" href="/plugins/blog/public/blog.css?v={{assetV}}">
2
+ <main class="container blog-page" id="dm-blog">
3
+ <header class="blog-archive-head">
4
+ <h1>{{archiveTitle}}</h1>
5
+ {{archiveIntro}}
6
+ </header>
7
+ {{archive}}
8
+ {{pagination}}
9
9
  </main>
10
+ <script type="module" src="/plugins/blog/public/blog.js?v={{assetV}}"></script>
@@ -1,18 +1,5 @@
1
- <main class="container" style="padding: 2rem 0;">
2
- <article class="blog-post">
3
- <header class="mb-4">
4
- <h1>{{title}}</h1>
5
- <p class="text-muted">by {{authorName}} &bull; <time>{{publishedAt}}</time></p>
6
- <div class="mb-2">{{categories}}{{tags}}</div>
7
- </header>
8
- {{featuredImage}}
9
- <div class="post-content mb-5">
10
- {{content}}
11
- </div>
12
- </article>
13
-
14
- <section id="comments" class="mt-5">
15
- {{comments}}
16
- {{commentForm}}
17
- </section>
1
+ <link rel="stylesheet" href="/plugins/blog/public/blog.css?v={{assetV}}">
2
+ <main class="container blog-page" id="dm-blog">
3
+ {{post}}
18
4
  </main>
5
+ <script type="module" src="/plugins/blog/public/blog.js?v={{assetV}}"></script>
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Blog layouts - the pure half of rendering through blocks (lib/layouts.js).
3
+ *
4
+ * Post fields reach these functions from authors, and the listing's metadata
5
+ * reaches every visitor's browser, so the escaping and the whitelists are
6
+ * pinned here rather than trusted.
7
+ */
8
+ import {test} from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import {
11
+ buildToc, cardMeta, cardVars, interpolate, listDisplays, pickDisplay, pickDisplaySettings,
12
+ pickPostLayout, postLayouts, relatedPosts, safeImageUrl
13
+ } from '../lib/layouts.js';
14
+
15
+ test('interpolate: escaped by default, raw on request, dates formatted, comments dropped', () => {
16
+ const tpl = '<!-- header: {{title}} --><h1>{{title}}</h1>{{{html}}}<time>{{when:D MMM YYYY}}</time>{{missing}}';
17
+ assert.equal(
18
+ interpolate(tpl, {title: '<script>x</script>', html: '<b>ok</b>', when: '2026-09-23T10:00:00Z'}),
19
+ '<h1>&lt;script&gt;x&lt;/script&gt;</h1><b>ok</b><time>23 Sep 2026</time>'
20
+ );
21
+ assert.equal(interpolate('{{when:YYYY}}', {when: 'not a date'}), 'not a date', 'a non-date prints as itself');
22
+ });
23
+
24
+ test('the layouts on offer: shipped ones first, then any blog-post-* / blog-card-* block', () => {
25
+ const names = ['blog-post-long-read', 'blog-card-poster', 'blog-card', 'cta-banner', 'blog-post-classic', 'blog-post-Bad'];
26
+ assert.deepEqual(postLayouts(names).map(l => l.id),
27
+ ['blog-post-classic', 'blog-post-feature', 'blog-post-minimal', 'blog-post-sidebar', 'blog-post-long-read']);
28
+ assert.equal(postLayouts(names).at(-1).label, 'Long read');
29
+ const displays = listDisplays(names);
30
+ assert.deepEqual(displays.map(d => d.id), ['cards', 'list', 'magazine', 'compact', 'blog-card-poster']);
31
+ assert.equal(displays.at(-1).block, 'blog-card-poster');
32
+ });
33
+
34
+ test('a post uses its own layout, else the site default, else Classic', () => {
35
+ const layouts = postLayouts([]);
36
+ assert.equal(pickPostLayout({template: 'blog-post-feature'}, {postLayout: 'blog-post-minimal'}, layouts), 'blog-post-feature');
37
+ assert.equal(pickPostLayout({template: 'blog-post-gone'}, {postLayout: 'blog-post-minimal'}, layouts), 'blog-post-minimal');
38
+ assert.equal(pickPostLayout({}, {postLayout: 'blog-post-gone'}, layouts), 'blog-post-classic');
39
+ const displays = listDisplays([]);
40
+ assert.equal(pickDisplay('list', {listDisplay: 'compact'}, displays).id, 'list');
41
+ assert.equal(pickDisplay('nope', {listDisplay: 'compact'}, displays).id, 'compact');
42
+ assert.equal(pickDisplay('', {}, displays).id, 'cards');
43
+ });
44
+
45
+ test('display settings are whitelisted, and only the keys sent come back', () => {
46
+ assert.deepEqual(pickDisplaySettings({postLayout: 'blog-post-x', listDisplay: 'List!', relatedCount: '99', interactive: 0, other: 1}),
47
+ {postLayout: 'blog-post-x', relatedCount: 6, interactive: false});
48
+ assert.deepEqual(pickDisplaySettings({postLayout: '../etc'}), {});
49
+ assert.deepEqual(pickDisplaySettings({}), {});
50
+ });
51
+
52
+ test('contents: headings get unique ids; one heading is not worth a list', () => {
53
+ const {html, toc, count} = buildToc('<h2>Intro</h2><p>x</p><h3 id="kept">Detail</h3><h2>Intro</h2>');
54
+ assert.equal(count, 3);
55
+ assert.match(html, /<h2 id="intro">Intro<\/h2>/);
56
+ assert.match(html, /<h2 id="intro-2">Intro<\/h2>/);
57
+ assert.match(html, /<h3 id="kept">/);
58
+ assert.match(toc, /href="#intro-2"/);
59
+ assert.equal(buildToc('<h2>Only</h2>').toc, '');
60
+ });
61
+
62
+ test('image addresses are only ever plain URLs - nothing that escapes url() or src=""', () => {
63
+ assert.equal(safeImageUrl('/media/a.jpg'), '/media/a.jpg');
64
+ assert.equal(safeImageUrl('https://x.test/a.png'), 'https://x.test/a.png');
65
+ for (const bad of ["javascript:alert(1)", "/a.jpg') ; background:red", '/a".jpg', 'a.jpg', '']) {
66
+ assert.equal(safeImageUrl(bad), '', bad);
67
+ }
68
+ });
69
+
70
+ test('a card: markup only where the plugin built it; the browser gets plain values', () => {
71
+ const post = {id: 'p1', slug: 'hi there', title: 'A <b>', excerpt: 'e', categories: ['c1', 'gone'], tags: ['x"y'],
72
+ authorId: 'u1', content: 'word '.repeat(450), featuredImage: 'javascript:1', publishedAt: '2026-01-02T00:00:00Z'};
73
+ const catMap = {c1: {name: 'News <i>', slug: 'news'}};
74
+ const v = cardVars(post, {base: '/blog', catMap, authorName: 'Ann'});
75
+ assert.equal(v.url, '/blog/hi%20there');
76
+ assert.equal(v.image, '', 'an unsafe image is dropped, not escaped into place');
77
+ assert.equal(v.imageClass, 'no-image');
78
+ assert.equal(v.readingTime, '2 min read');
79
+ assert.match(v.categories, /News &lt;i&gt;/);
80
+ assert.match(v.tags, /data-blog-tag="x&quot;y"/);
81
+ const m = cardMeta(post, {catMap, authorName: 'Ann'});
82
+ assert.deepEqual(m.cats, [{slug: 'news', name: 'News <i>'}], 'plain text: the browser sets it as text');
83
+ assert.equal(m.minutes, 2);
84
+ });
85
+
86
+ test('related posts: shared categories outweigh shared tags; newer breaks a tie', () => {
87
+ const me = {id: 'me', categories: ['a'], tags: ['t']};
88
+ const all = [
89
+ me,
90
+ {id: 'tag-only', tags: ['t'], publishedAt: '2026-05-01'},
91
+ {id: 'cat-old', categories: ['a'], publishedAt: '2026-01-01'},
92
+ {id: 'cat-new', categories: ['a'], publishedAt: '2026-03-01'},
93
+ {id: 'none', publishedAt: '2026-06-01'}
94
+ ];
95
+ assert.deepEqual(relatedPosts(me, all, 3).map(p => p.id), ['cat-new', 'cat-old', 'tag-only']);
96
+ assert.deepEqual(relatedPosts(me, all, 0), []);
97
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Blog - the routes readers and authors actually hit.
3
+ *
4
+ * The public pages are driven through @fastify/compress with a browser's
5
+ * Accept-Encoding, because that is where they broke: the handlers sent the
6
+ * page without returning the reply, and under compression every browser got a
7
+ * 200 with an EMPTY body. Plain requests (curl, tests without compression)
8
+ * still got the page, which is how it went unnoticed.
9
+ *
10
+ * Nothing here writes to the host CMS: the public plugin only reads, and the
11
+ * post rules are tested on the pure function that applies them.
12
+ */
13
+ import {test} from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ import zlib from 'zlib';
16
+ import Fastify from 'fastify';
17
+ import compress from '@fastify/compress';
18
+ import blogPublicPlugin from '../plugin.public.js';
19
+ import {postFields} from '../plugin.js';
20
+
21
+ async function buildPublic() {
22
+ const app = Fastify();
23
+ await app.register(compress, {global: true, threshold: 1024, encodings: ['br', 'gzip', 'deflate']});
24
+ await app.register(blogPublicPlugin, {settings: {basePath: '/blog'}, config: {}});
25
+ return app;
26
+ }
27
+
28
+ test('the blog index reaches a browser whole, under every encoding', async () => {
29
+ const app = await buildPublic();
30
+ const decoders = {gzip: zlib.gunzipSync, br: zlib.brotliDecompressSync, deflate: zlib.inflateSync};
31
+ for (const enc of ['gzip, deflate, br', 'gzip', 'br', 'identity']) {
32
+ const res = await app.inject({method: 'GET', url: '/blog', headers: {'accept-encoding': enc}});
33
+ assert.equal(res.statusCode, 200, enc);
34
+ const used = res.headers['content-encoding'];
35
+ const body = (used ? decoders[used](res.rawPayload) : res.rawPayload).toString('utf8');
36
+ assert.ok(body.length > 1000, `${enc}: a page, not an empty body (${body.length} bytes)`);
37
+ assert.match(body, /data-blog-archive/, enc);
38
+ assert.match(body, /\/plugins\/blog\/public\/blog\.js/, `${enc}: the listing script is on the page`);
39
+ }
40
+ const feed = await app.inject({method: 'GET', url: '/blog/feed.xml', headers: {'accept-encoding': 'gzip'}});
41
+ const xml = feed.headers['content-encoding'] === 'gzip' ? zlib.gunzipSync(feed.rawPayload) : feed.rawPayload;
42
+ assert.match(xml.toString('utf8'), /<rss version="2.0">/);
43
+ await app.close();
44
+ });
45
+
46
+ test('a display can be asked for, and an unknown one falls back', async () => {
47
+ const app = await buildPublic();
48
+ const list = await app.inject({method: 'GET', url: '/blog?display=compact'});
49
+ assert.match(list.body, /data-display="compact"/);
50
+ const odd = await app.inject({method: 'GET', url: '/blog?display=%3Cscript%3E'});
51
+ assert.doesNotMatch(odd.body, /data-display="<script>/);
52
+ assert.match(odd.body, /data-display="cards"/);
53
+ await app.close();
54
+ });
55
+
56
+ test('an author cannot publish by writing the status - that is what the publish route is for', () => {
57
+ const author = {id: 'a', role: 'blog-author'};
58
+ const editor = {id: 'e', role: 'blog-editor'};
59
+ assert.equal(postFields({status: 'published'}, author).status, undefined);
60
+ assert.equal(postFields({status: 'scheduled'}, author).status, undefined);
61
+ assert.equal(postFields({status: 'draft'}, author).status, 'draft', 'anyone may move their post back to draft');
62
+ assert.equal(postFields({status: 'published'}, editor).status, 'published');
63
+ assert.equal(postFields({status: 'bogus'}, editor).status, undefined);
64
+ });
65
+
66
+ test('a post keeps only the fields it has - no authorship, no sample flag, a valid layout', () => {
67
+ const out = postFields({title: ' T ', authorId: 'someone-else', sample: true, template: 'blog-post-feature', tags: [' a ', '', 'b']}, {role: 'admin'});
68
+ assert.deepEqual(out, {title: 'T', template: 'blog-post-feature', tags: ['a', 'b']});
69
+ assert.equal(postFields({template: '../../etc/passwd'}, {role: 'admin'}).template, '');
70
+ assert.equal(postFields({template: ''}, {role: 'admin'}).template, '', 'empty is "use the site default"');
71
+ });
@@ -0,0 +1,74 @@
1
+ {
2
+ "note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
3
+ "source": "dcms-marketplace@6f9dbf3",
4
+ "plugins": {
5
+ "blog": {
6
+ "version": "1.4.4",
7
+ "files": {
8
+ "CLAUDE.md": "69087729ee22dddef5868e14fd6dd4570bb5387917020de873948785e6919756",
9
+ "admin/css/index.css": "2f39febfe02491f6db0e32b766723c3a2950a6a1139a108b2ec756d395472f30",
10
+ "admin/templates/blog.html": "a6054c1febe764bb1edf5c9e466d44a9080d9c83ba5486437b7a8244acf5ba54",
11
+ "admin/templates/categories.html": "ab7d6fc2fedd48876c585bc216a1532b3bdd78c946dee976c1171bfe4664feb6",
12
+ "admin/templates/comments.html": "095ff03bd5e1ed9ac75edf1ceb9fbe7b2907a1e35b261672da51e00742c2814a",
13
+ "admin/templates/post-editor.html": "c5292ecac2bfe4994ac62b4bf67631b7959dfa068cc3c1332d2268d870d0b467",
14
+ "admin/templates/settings.html": "e0730cd57435e592034328a699198d5e1326084767156bfc2289852f5f1a45af",
15
+ "admin/views/blog.js": "52bf73bfbc2d8736c1217d89dec1e69649f4b2f5334b7da1abf33bb1981dc921",
16
+ "admin/views/categories.js": "dade241e8d70d101366c5c8e9eed721f7b39ffda65025a272ae5679660f0f50c",
17
+ "admin/views/comments.js": "f5055e7b8e0c3fd8f71b2a8aeeb47c68fab5ff7d24ffb5d9e052eedfc1da1df2",
18
+ "admin/views/kit.js": "c7b475fe5920b6429a03976a1463a218751e0ed2e42135920e4c3b1ebf47e79f",
19
+ "admin/views/post-editor.js": "97ea927c539419940fccf4a78fc3098e00beca3454a56ef432fa94b7f3cacb81",
20
+ "admin/views/settings-cog.js": "307efc93835863dd8511f8fb17a224cf45a77532c90b854a86d2cd1d4e5f9f18",
21
+ "admin/views/settings.js": "2b7f775e37e034dee4ae3dfadea5e446c2e526bed8a066ef3ca2e777146ada23",
22
+ "blocks/blog-card-compact.css": "eb8e8ae9a6461c362bc021ea0396c6b4481047b6b36a50eeb59e47375e49be75",
23
+ "blocks/blog-card-compact.html": "7e1e31923808e6108d57d34309d3442b5d93b4aa4396f0e471e870249934ab8f",
24
+ "blocks/blog-card-row.css": "f5f0249c51a40cc71ff8d054bfa887353badc79730b0f323c76ac32f7d893b44",
25
+ "blocks/blog-card-row.html": "eb28d26c86c4cefcf553273e6547885b0dc03fbb6e2543e9c1aa4b132db6216a",
26
+ "blocks/blog-card.css": "d480a80a7378bd55659e9549bb30d8c106bdae1a9cd450a8c0114bfaefa94cd2",
27
+ "blocks/blog-card.html": "cf676e8e9f676ea8b49637cb54a7d6fcd6f3395af8f4a8833a65ba78b35efc76",
28
+ "blocks/blog-post-classic.css": "9acac617847b5a10dabd5efccb601572e2751f792c4038475325f11e24142584",
29
+ "blocks/blog-post-classic.html": "1fab77c7aeb67fe72b73c4db42ff4c3a66466c041104639f4898c390c665fc38",
30
+ "blocks/blog-post-feature.css": "7d8a242b627f3826df21d5470e643b386967122ea3585e69f96cce9ffc2452e9",
31
+ "blocks/blog-post-feature.html": "c0408538cc269e4f2873f9c9c44a000347e4d5cafe3ecb024f62112ea711a9ab",
32
+ "blocks/blog-post-minimal.css": "2323b84a7d0a99e1c85c30faa18bb2ee16763ff08cfe87f181ffadf8cff84dd0",
33
+ "blocks/blog-post-minimal.html": "3a04925980018a13038ebf4bcfecc4f69284c1bac087631ddc0f66e0e95e07fe",
34
+ "blocks/blog-post-sidebar.css": "3c66109fa48ca448605eb5acd0c998f43649fb3747813b4a811992ec89886007",
35
+ "blocks/blog-post-sidebar.html": "d510a9e272e9415941064b4567def47a179b88825e29c6dd340540870ab04a61",
36
+ "collections/categories/schema.json": "0728e522ed8e3c623fd45e137cd03e0960aa5158e70613ec8628c8b3928e0929",
37
+ "collections/comments/schema.json": "29cbd289cbfcfc4158c9357a218db4ce06eddc3370bfbf9afb7d7f52065a849e",
38
+ "collections/posts/schema.json": "19a5a770d3eefea73f4117454bd1312a7a7e3d727cfcdfde141139fbc81cabd7",
39
+ "config.js": "ee67396262762f5c31269d258affd75f7392a3e364f72f7792c2f1cdafd94bba",
40
+ "lib/layouts.js": "e55dfd79227a36e190d648dcbd3c31de538dafed9b5ca68844c98ef79b6c2509",
41
+ "lib/page.js": "1fb9a01c3080e8ecf65c19b1273b23d550a131fe30079a493a9f5b1c90b720ae",
42
+ "lib/render.js": "0e97c6a950bc962c8d8ea4b2f21d06cb73fbcdc738d8942d6935d01309a29b1d",
43
+ "lib/samples.js": "e8cdb77ce22e43937707f6854d3a5cb722437dc584e22ac7b24480bef686cf85",
44
+ "plugin.js": "32e223ff46bfc72a4db6904425ef9488538a901d73c67be41a0fc0cc037e457b",
45
+ "plugin.json": "16104f855c0afa2912d5a96465d6388e4090d56fabbf566401942d8160ea2d1b",
46
+ "plugin.public.js": "053bbc0322ba205640fdbb834340883d0698600e7a637c8165ecac9d49c8d040",
47
+ "public/blog.css": "fd733e6b624190da1e70dc1b6613e2376efd7f7570a28fab311d81006d03d960",
48
+ "public/blog.js": "d01c72a9834dcc7c5241923371f2b164ac30d20ac9f1a5c5dc5b71fa3cb09f08",
49
+ "public/samples/aurora.svg": "feb29e2b46465913fc29de9eda221b00b1541f1fa3ea7120f0c1d1cd11a9a4f6",
50
+ "public/samples/dusk.svg": "92469dcfe2e78ed9bfa6ddeb9f9c4726ec64e04c6e9b40260523a825b582b580",
51
+ "public/samples/ember.svg": "754cb6e1cc2d26274f24bf86baa7658a402448944dcfb10709c658abad9b29d0",
52
+ "public/samples/harbour.svg": "a5f09eafa1406da3c2d017e9bee308a98543df15090ed27227cbbf8341aec460",
53
+ "public/samples/meadow.svg": "90efae21e209b7d48901eed457f47d0a347c58a6b6e1ea81ba98c131c4c559cb",
54
+ "public/samples/rosewood.svg": "0f963fe5633585786271255b7c9df36be67748ae751f9cf7b0bbda6f5b93449d",
55
+ "roles/blog-author.json": "ccdd4d99296c157ddc839af22ef6e79c47c381dd9d937069f7090a4fb76bb41d",
56
+ "roles/blog-editor.json": "bea0b673cf9e3da04c5882d51dd33bdcaf035b047c1cfe87624a9f16e6514ffc",
57
+ "templates/index.html": "3c2cf8a4ffde8599d451f6d00b736aeba42fd073b726ada3b0ffe4d56e1ef0f4",
58
+ "templates/post.html": "28e9f06071115ecc4fbba007b35782732bf12ef72528525893d424e3c4f0313f",
59
+ "tests/layouts.test.js": "70e0e462985d14618838c3897ffff7c453b711b64c6fc9277023268b0755377c",
60
+ "tests/public.test.js": "ef04ba271d03cee86adac332c6834bb51102b20d892d5f84d30eb04507605de5"
61
+ }
62
+ },
63
+ "mail-reader": {
64
+ "version": "1.0.0",
65
+ "files": {
66
+ "CLAUDE.md": "a8dd2cb285e1d24b2f072aa11a644e1c8c1be89013da6f6cc46d9820fe24ccd5",
67
+ "admin/views/mail.js": "c5c6483f9618c75cac8f4be32d3aea99511db76d4b9d38b8ef43bdc1d35db3c5",
68
+ "config.js": "6e65803ad822a9acda40c203631b8eb32a87cf9d9e5f1c7cb59d01aa406075c6",
69
+ "plugin.js": "79b17346ed0192b092040460cc8d762d5207ee6e4af7bfc2d1cf654a51e2fa81",
70
+ "plugin.json": "5f5b98757514aa70d7c7a2be5a1fbfdde8ef21a2ccc7586b3b5aea9b44fbe5e6"
71
+ }
72
+ }
73
+ }
74
+ }
@@ -0,0 +1,78 @@
1
+ # mail-reader — AI Assistant Guide
2
+
3
+ ## Purpose
4
+
5
+ Read-only IMAP mailbox reader. Connects to a **standard IMAP server** and displays mail. It
6
+ deliberately cannot write: no mark-as-read, no flagging, no move, no delete, no send.
7
+
8
+ Full mail (sending, flags, drafts, search, sync) is **`email-pro`** — a separate Pro-tier plugin
9
+ that requires MongoDB. Nothing here should grow toward it by accident — phase 1 keeps **no message store at
10
+ all**, which is what makes the Pro sync layer a clean addition rather than a migration.
11
+
12
+ ## Files
13
+
14
+ - `plugin.json`, `config.js`, `plugin.js` — manifest, defaults, routes
15
+ - `admin/views/mail.js`, `admin/templates/mail.html`, `admin/css/mail.css`
16
+
17
+ The IMAP core — crypto, account store, connection pool, renderer — is **not here**. It lives in
18
+ `plugins/_lib/mail/`, shared with `email-pro`, with its state at `plugins/_lib/data/`. See
19
+ `plugins/_lib/mail/CLAUDE.md` before changing anything about credentials or connections.
20
+
21
+ ## Routes
22
+
23
+ All under `/api/plugins/mail-reader`, all requiring authentication.
24
+
25
+ | Method | Path | Purpose |
26
+ |---|---|---|
27
+ | GET | `/accounts` | Every mailbox this user owns. Never returns passwords, only `hasPassword`. |
28
+ | POST | `/accounts` | Add a mailbox. |
29
+ | PUT | `/accounts/:id` | Update one. A blank password means "keep the stored one". |
30
+ | DELETE | `/accounts/:id` | Forget a mailbox and its password. |
31
+ | POST | `/accounts/test` | Try details without saving. With `id` and a blank password, uses the stored one. |
32
+ | GET | `/folders` | Folder list, `\Noselect` filtered out. `?account=` |
33
+ | GET | `/messages` | One page of envelopes, newest first. `?account=&folder=&page=&limit=` |
34
+ | GET | `/messages/:uid` | One message, rendered. `?account=&folder=&images=load` |
35
+ | GET | `/messages/:uid/attachments/:index` | Streams one part as a download. `?account=&folder=` |
36
+
37
+ A user may hold several mailboxes. Omitting `?account=` selects their first one, so the reader can
38
+ open on something rather than asking which before it has shown anything.
39
+
40
+ ## Gotchas
41
+
42
+ - **Read-only is enforced, not merely omitted.** Mailboxes open `{readOnly: true}` and ImapFlow
43
+ fetches with `BODY.PEEK[]`. Drop either and the server sets `\Seen` as a side effect of *reading*
44
+ a message — the user's phone then shows mail they never opened. There is no route that writes;
45
+ keep it that way until mark-as-read is a deliberate, visible action.
46
+ - **No permission resource, on purpose.** Access control is ownership: every route resolves the
47
+ account from `request.user`, and no route takes someone else's id. Registering a `mail-reader` resource
48
+ would 403 every user *including super-admin* (no bypass since 0.22) from the moment the plugin was
49
+ enabled, because a new resource belongs to no role and nothing back-fills it.
50
+ - **Credentials are not a collection.** `schema.plugin` is a badge, not a visibility gate — a
51
+ plugin-owned collection still appears in admin Collections with a working Entries link, is
52
+ reachable via `/api/v1`, and can be pointed at a shared Mongo. Hence `data/accounts.json`, 0600,
53
+ gitignored, and excluded from the npm `files` list.
54
+ - **Key resolution is a chain**, not a single source: `MAIL_SECRET` → `INSTANCE_SECRET` →
55
+ generated `data/.mailkey`. `INSTANCE_SECRET` only exists on sites that ran setup or registered
56
+ with the manager, so relying on it alone breaks every older site. The sealing source is recorded
57
+ in each token so a key change fails with a sentence, not a bare auth error.
58
+ - **Never derive from `JWT_SECRET`.** Rotating it logs everyone out, which is recoverable; silently
59
+ bricking every stored mailbox password at the same time is not.
60
+ - **`transformTags` runs BEFORE attribute filtering** in sanitize-html. `target` and `rel` must be
61
+ on `allowedAttributes.a` or the values the transform just set are stripped straight back off, and
62
+ links inside the sandboxed iframe silently do nothing. This has already been fixed once.
63
+ - **Remote images are blocked by default** and the per-message override is never persisted — it
64
+ lives in a `Set` in the view. Loading a remote image reports the open back to the sender; that
65
+ decision should be per message, not a preference someone forgets they set.
66
+ - **The iframe sandbox has neither `allow-scripts` nor `allow-same-origin`.** That pair is the whole
67
+ defence. `allow-popups` is there only so links can open in a new tab.
68
+ - **Attachment downloads go through `fetch` + blob**, not a plain `<a href>` — the route needs the
69
+ bearer token, which a link cannot carry.
70
+ - **Single-process only.** The connection pool is module state, like the messages plugin's SSE
71
+ registry. Multiple workers would each open their own connections and hit per-user server limits.
72
+
73
+ ## Testing
74
+
75
+ `services/secretbox.js` and `services/render.js` are pure and unit-tested. Connection behaviour
76
+ should be checked against a **real IMAP server** — `mail.dommajs.org` answers on 993 (TLS) and 143
77
+ (STARTTLS). A wrong-credentials attempt is enough to verify TLS, auth-failure mapping and that the
78
+ pool leaves nothing open; it does not verify folder listing or rendering, which need a real mailbox.
@@ -2,6 +2,7 @@
2
2
  "name": "mail-reader",
3
3
  "displayName": "Mail Reader",
4
4
  "version": "1.0.0",
5
+ "tier": "free",
5
6
  "description": "Read-only IMAP mailbox reader. Connects to a standard IMAP server and reads mail without ever writing to it.",
6
7
  "author": "Darryl Waterhouse",
7
8
  "date": "2026-09-19",
@@ -25,7 +26,7 @@
25
26
  ],
26
27
  "views": {
27
28
  "plugin-mail-reader": {
28
- "entry": "mail-reader/admin/views/mail.js?v=c5c6483f",
29
+ "entry": "mail-reader/admin/views/mail.js?v=1",
29
30
  "exportName": "mailView"
30
31
  }
31
32
  }
@@ -0,0 +1 @@
1
+ .dm-theme-switcher{position:fixed;z-index:9998}.dm-ts-trigger{width:48px;height:48px;border-radius:50%;border:2px solid rgba(255,255,255,.25);cursor:pointer;padding:0;transition:transform .18s ease,box-shadow .18s ease;box-shadow:0 3px 12px #0006,0 0 0 1px #ffffff14}.dm-ts-trigger:hover{transform:scale(1.14);box-shadow:0 6px 20px #00000080,0 0 0 1px #ffffff26}.dm-ts-options{position:absolute;display:grid;grid-template-columns:repeat(2,28px);gap:8px;padding:10px;background:var(--dm-surface, #1e2030);border:1px solid var(--dm-border, rgba(255, 255, 255, .12));border-radius:14px;box-shadow:0 10px 28px #00000073;opacity:0;visibility:hidden;transform:scale(.92) translateY(6px);transform-origin:bottom right;transition:opacity .18s ease,transform .18s ease,visibility .18s;pointer-events:none;bottom:56px;right:0;max-height:60vh;overflow-y:auto}.dm-theme-switcher.dm-ts-left .dm-ts-options{right:auto;left:0;transform-origin:bottom left}.dm-theme-switcher.dm-ts-top .dm-ts-options{bottom:auto;top:56px;transform:scale(.92) translateY(-6px);transform-origin:top right}.dm-theme-switcher.dm-ts-top.dm-ts-left .dm-ts-options{transform-origin:top left}.dm-theme-switcher:hover .dm-ts-options,.dm-theme-switcher:focus-within .dm-ts-options{opacity:1;visibility:visible;transform:scale(1) translateY(0);pointer-events:all}.dm-theme-switcher.dm-ts-top:hover .dm-ts-options,.dm-theme-switcher.dm-ts-top:focus-within .dm-ts-options{transform:scale(1) translateY(0)}.dm-ts-dot{width:28px;height:28px;border-radius:50%;border:2px solid transparent;cursor:pointer;padding:0;position:relative;transition:transform .15s ease,border-color .15s ease,box-shadow .15s ease;box-shadow:0 1px 4px #00000059;outline:none}.dm-ts-dot:hover{transform:scale(1.22)}.dm-ts-dot.active{border-color:#fff;box-shadow:0 0 0 2px #ffffff80,0 2px 8px #0006}.dm-ts-dot:after,.dm-ts-trigger:after{content:attr(data-tooltip);position:absolute;top:50%;transform:translateY(-50%);background:#0a0c14eb;color:#fff;padding:4px 9px;border-radius:5px;font-size:.7rem;font-family:system-ui,sans-serif;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .15s ease;z-index:1}.dm-theme-switcher:not(.dm-ts-left) .dm-ts-dot:after,.dm-theme-switcher:not(.dm-ts-left) .dm-ts-trigger:after{right:calc(100% + 7px);left:auto}.dm-theme-switcher.dm-ts-left .dm-ts-dot:after,.dm-theme-switcher.dm-ts-left .dm-ts-trigger:after{left:calc(100% + 7px);right:auto}.dm-ts-dot:hover:after,.dm-ts-trigger:hover:after{opacity:1}@media(prefers-reduced-motion:reduce){.dm-ts-trigger,.dm-ts-dot,.dm-ts-options{transition:none}}
@@ -0,0 +1 @@
1
+ (function(){if(typeof fetch=="function"&&!window.__CMS_PREVIEW__){var s=navigator.doNotTrack==="1"||window.doNotTrack==="1"||navigator.msDoNotTrack==="1";if(!s){var r=window.location.pathname,t="";try{t=sessionStorage.getItem("dm_sid")||"",t||(t=crypto&&crypto.randomUUID?crypto.randomUUID():Date.now().toString(36)+Math.random().toString(36).slice(2,10),sessionStorage.setItem("dm_sid",t))}catch{}try{var n="dm_analytics_seen",a=sessionStorage.getItem(n),o=a?a.split("|"):[];if(o.indexOf(r)!==-1)return;o.push(r),sessionStorage.setItem(n,o.join("|"))}catch{}var i="";try{if(document.referrer){var e=new URL(document.referrer);i=e.origin===window.location.origin?e.pathname:e.origin+e.pathname}}catch{}fetch("/api/analytics/hit",{method:"POST",headers:{"Content-Type":"application/json"},keepalive:!0,body:JSON.stringify({url:r,sid:t,ref:i})}).catch(function(){})}}})();
@@ -0,0 +1 @@
1
+ (function(){"use strict";var a=window.__CMS_THEME_SWITCHER__;if(!a||!Array.isArray(a.themes)||!a.themes.length)return;var r=a.position||"right-center",d=r==="left-center"||r==="bottom-left"||r==="top-left",m=r==="bottom-left"||r==="bottom-right",u=r==="top-left"||r==="top-right",l=a.offsetX!=null?a.offsetX:32,c=a.offsetY!=null?a.offsetY:32,n=document.createElement("div");n.className="dm-theme-switcher"+(d?" dm-ts-left":"")+(u?" dm-ts-top":""),n.id="dm-theme-switcher",n.setAttribute("role","navigation"),n.setAttribute("aria-label","Theme switcher");var o=document.createElement("button");o.className="dm-ts-trigger",o.setAttribute("data-tooltip","Switch theme"),o.setAttribute("aria-haspopup","true"),o.setAttribute("aria-expanded","false");var f=360/a.themes.length;o.style.background="conic-gradient("+a.themes.map(function(e,t){return(e.from||"#888")+" "+(t*f).toFixed(2)+"deg "+((t+1)*f).toFixed(2)+"deg"}).join(", ")+")";var s=document.createElement("div");s.className="dm-ts-options",s.setAttribute("role","menu");var p=a.themes.map(function(e){var t=document.createElement("button");return t.className="dm-ts-dot",t.dataset.theme=e.id,t.setAttribute("data-tooltip",e.label),t.setAttribute("aria-label",e.label),t.setAttribute("role","menuitem"),t.style.background="linear-gradient(135deg, "+(e.from||"#888")+", "+(e.to||e.from||"#888")+")",s.appendChild(t),t});n.appendChild(o),n.appendChild(s),Object.assign(n.style,{right:d?"auto":l+"px",left:d?l+"px":"auto",top:u?c+"px":m?"auto":"50vh",bottom:m?c+"px":"auto",transform:u||m?"none":"translateY(-50%)"});function h(){document.body.appendChild(n)}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",h):h();function g(e,t){typeof Domma<"u"&&Domma.theme?e(Domma.theme):t>0&&setTimeout(function(){g(e,t-1)},100)}g(function(e){function t(){var i=e.get();p.forEach(function(b){b.classList.toggle("active",b.dataset.theme===i)})}p.forEach(function(i){i.addEventListener("click",function(){e.set(i.dataset.theme),t()})}),e.onChange(t),t(),o.addEventListener("click",function(){var i=o.getAttribute("aria-expanded")==="true";o.setAttribute("aria-expanded",String(!i))})},30)})();
package/scripts/build.js CHANGED
@@ -80,8 +80,30 @@ const PLUGIN_AS_IS_PATTERNS = [
80
80
  'plugins/**/lib/**/*.js',
81
81
  'plugins/**/*.json',
82
82
  'plugins/**/*.html',
83
+ // A plugin's own BLOCKS: blog ships its post layouts and listing cards as
84
+ // blocks/<name>.html + .css. The .html rode along with the line above and
85
+ // the .css did not, so a published blog laid its posts out with no styling.
86
+ // As-is, not minified: block CSS is nested under [data-block] at render.
87
+ 'plugins/*/blocks/**/*.css',
88
+ // Images a plugin serves from public/ (blog's sample-post covers).
89
+ 'plugins/*/public/**/*.svg',
83
90
  ];
84
91
 
92
+ /**
93
+ * The free plugins (Blog, Mail Reader) are written in dcms-marketplace and
94
+ * copied here by its `make sync-free-tier`, which records every file's hash in
95
+ * plugins/free-tier.lock.json. The engine ships them EXACTLY as locked - no
96
+ * minifying, no ?v= stamping, nothing added or left out - so a site the engine
97
+ * updates runs the same bytes the catalogue ships, and Marketplace Sync sees
98
+ * the same plugin, not a "modified" one. tests/plugins/free-tier-lock.test.js
99
+ * keeps the copies here honest.
100
+ */
101
+ const FREE_TIER_LOCK = join(ROOT, 'plugins', 'free-tier.lock.json');
102
+ const FREE_TIER = existsSync(FREE_TIER_LOCK)
103
+ ? JSON.parse(readFileSync(FREE_TIER_LOCK, 'utf8')).plugins ?? {}
104
+ : {};
105
+ const isFreeTier = (rel) => Object.keys(FREE_TIER).some(slug => rel.startsWith(`plugins/${slug}/`));
106
+
85
107
  // HTML templates in admin (copied as-is)
86
108
  const ADMIN_HTML_PATTERNS = [
87
109
  'admin/**/*.html',
@@ -177,6 +199,8 @@ async function stampPluginVersions() {
177
199
  const outPath = join(OUT, rel);
178
200
  if (!existsSync(outPath)) continue;
179
201
 
202
+ if (isFreeTier(rel)) continue; // shipped exactly as synced - see FREE_TIER
203
+
180
204
  let manifest;
181
205
  try { manifest = JSON.parse(readFileSync(outPath, 'utf8')); } catch { continue; }
182
206
  if (!manifest.admin?.views) continue;
@@ -306,13 +330,27 @@ async function build() {
306
330
  // the build artifact has no business containing them in the first place.
307
331
  const PLUGIN_RUNTIME_DATA = /^plugins\/[^/]+\/data\//;
308
332
  const pluginAsIs = (await collectFiles('{' + PLUGIN_AS_IS_PATTERNS.join(',') + '}'))
309
- .filter(rel => !PLUGIN_RUNTIME_DATA.test(rel));
333
+ .filter(rel => !PLUGIN_RUNTIME_DATA.test(rel) && !isFreeTier(rel));
310
334
  for (const rel of pluginAsIs) {
311
335
  const outPath = join(OUT, rel);
312
336
  mkdirSync(dirname(outPath), {recursive: true});
313
337
  cpSync(join(ROOT, rel), outPath);
314
338
  }
315
339
 
340
+ // The free plugins: every locked file, byte for byte, and nothing else.
341
+ let freeTierCount = 0;
342
+ for (const [slug, entry] of Object.entries(FREE_TIER)) {
343
+ for (const file of Object.keys(entry.files ?? {})) {
344
+ const rel = `plugins/${slug}/${file}`;
345
+ const src = join(ROOT, rel);
346
+ if (!existsSync(src)) throw new Error(`${rel} is in the free-tier lock but not on disk - run make sync-free-tier in dcms-marketplace`);
347
+ const outPath = join(OUT, rel);
348
+ mkdirSync(dirname(outPath), {recursive: true});
349
+ cpSync(src, outPath);
350
+ freeTierCount++;
351
+ }
352
+ }
353
+
316
354
  // Copy admin HTML templates verbatim
317
355
  const adminHtml = await collectFiles(ADMIN_HTML_PATTERNS[0]);
318
356
  for (const rel of adminHtml) {
@@ -360,6 +398,7 @@ async function build() {
360
398
  const files = await collectFiles(pattern);
361
399
  for (const rel of files) {
362
400
  if (pluginAsIsSet.has(rel)) continue; // skip plugin server-side JS
401
+ if (isFreeTier(rel)) continue; // shipped exactly as synced
363
402
  const loader = rel.endsWith('.css') ? 'css' : 'js';
364
403
  await minifyFile(rel, loader);
365
404
  if (loader === 'js') jsCount++;
@@ -368,7 +407,7 @@ async function build() {
368
407
  }
369
408
 
370
409
  const totalCopied = COPY_AS_IS.filter(i => existsSync(join(ROOT, i))).length
371
- + pluginAsIs.length + adminHtml.length;
410
+ + pluginAsIs.length + adminHtml.length + freeTierCount;
372
411
 
373
412
  await stampPluginVersions();
374
413
 
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Run the test suite in a throwaway copy of this checkout.
4
+ *
5
+ * Plenty of tests exercise the real services, and those resolve their data
6
+ * from the tree they live in (content/, config/, plugins/<slug>/data/). Run
7
+ * in place, the suite rewrote tracked files every time - config/plugins.json,
8
+ * and seed data such as the "Family" contacts group - which then sat in
9
+ * `git status` waiting to be committed by mistake.
10
+ *
11
+ * So the working tree (edits included, .git and node_modules left out) is
12
+ * copied to a temp directory, node_modules is linked in, and the suite runs
13
+ * there. Whatever a test writes lands in the copy, which is deleted after.
14
+ *
15
+ * npm test # the whole suite
16
+ * npm test -- tests/services/foo.test.js # named files (paths as from the repo root)
17
+ * npm test -- --test-name-pattern='client copy' tests/services/foo.test.js
18
+ * # node --test flags pass through - name the FILE
19
+ * # too: a pattern alone over the whole suite hangs
20
+ * # on files whose before() hooks start servers
21
+ * KEEP_TEST_COPY=1 npm test # keep the copy, to look at what a test wrote
22
+ *
23
+ * @module scripts/run-tests
24
+ */
25
+ import {spawn} from 'node:child_process';
26
+ import {cpSync, existsSync, mkdtempSync, readdirSync, rmSync, statSync, symlinkSync} from 'node:fs';
27
+ import os from 'node:os';
28
+ import path from 'node:path';
29
+ import {fileURLToPath} from 'node:url';
30
+
31
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
+
33
+ /** Never copied: history, dependencies (linked instead) and build output. */
34
+ const SKIP = new Set(['.git', 'node_modules', '_publish', 'test-site', '.domma-backups']);
35
+
36
+ // Copies a crashed or SIGKILLed run could not delete, once they are clearly
37
+ // not in use by a run happening now.
38
+ const STALE_MS = 6 * 60 * 60 * 1000;
39
+ for (const name of readdirSync(os.tmpdir())) {
40
+ if (!name.startsWith('domma-cms-tests-')) continue;
41
+ const dir = path.join(os.tmpdir(), name);
42
+ try {
43
+ if (Date.now() - statSync(dir).mtimeMs > STALE_MS) rmSync(dir, {recursive: true, force: true});
44
+ } catch { /* someone else's, or gone */ }
45
+ }
46
+
47
+ const copy = mkdtempSync(path.join(os.tmpdir(), 'domma-cms-tests-'));
48
+ const cleanup = () => {
49
+ if (process.env.KEEP_TEST_COPY) {
50
+ console.log(`\nTest copy kept at ${copy}`);
51
+ return;
52
+ }
53
+ rmSync(copy, {recursive: true, force: true});
54
+ };
55
+
56
+ try {
57
+ cpSync(ROOT, copy, {
58
+ recursive: true,
59
+ // Links are copied as links, so nothing in the copy points back into
60
+ // this checkout except node_modules, below, on purpose.
61
+ verbatimSymlinks: true,
62
+ filter: (src) => {
63
+ const rel = path.relative(ROOT, src);
64
+ return !rel || !SKIP.has(rel.split(path.sep)[0]);
65
+ }
66
+ });
67
+ if (existsSync(path.join(ROOT, 'node_modules'))) {
68
+ symlinkSync(path.join(ROOT, 'node_modules'), path.join(copy, 'node_modules'), 'dir');
69
+ }
70
+ } catch (err) {
71
+ console.error(`Could not prepare the test copy: ${err.message}`);
72
+ cleanup();
73
+ process.exit(1);
74
+ }
75
+
76
+ // Flags (e.g. --test-name-pattern=...) pass through; with no FILES named, run the whole suite.
77
+ const given = process.argv.slice(2);
78
+ const files = given.filter(a => !a.startsWith('-'));
79
+ const args = ['--test', '--test-concurrency=1', ...given, ...(files.length ? [] : ['tests/**/*.test.js'])];
80
+ const child = spawn(process.execPath, args, {cwd: copy, stdio: 'inherit', env: process.env});
81
+
82
+ // Ctrl+C, or a kill: stop the suite, then still delete the copy.
83
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
84
+ process.on(sig, () => {
85
+ child.kill(sig);
86
+ setTimeout(() => child.kill('SIGKILL'), 5000).unref();
87
+ });
88
+ }
89
+ child.on('exit', (code, signal) => {
90
+ cleanup();
91
+ process.exit(code ?? (signal ? 1 : 0));
92
+ });