domma-cms 0.66.0 → 0.69.4
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.
- package/CLAUDE.md +64 -1
- package/README.md +14 -10
- package/admin/css/admin.css +1 -1
- package/admin/index.html +1 -0
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/analytics-shape.js +1 -0
- package/admin/js/lib/contacts-arrange.js +1 -0
- package/admin/js/lib/help-popover.js +1 -0
- package/admin/js/lib/notes-arrange.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -1
- package/admin/js/lib/plugin-chrome.js +1 -1
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-grouping.test.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +3 -3
- package/admin/js/lib/todo-arrange.js +1 -0
- package/admin/js/lib/tool-kit.js +1 -0
- package/admin/js/templates/analytics.html +138 -0
- package/admin/js/templates/contacts.html +156 -0
- package/admin/js/templates/notes.html +137 -0
- package/admin/js/templates/plugin-marketplace.html +17 -4
- package/admin/js/templates/plugins.html +13 -7
- package/admin/js/templates/theme.html +38 -0
- package/admin/js/templates/todo.html +113 -0
- package/admin/js/views/analytics.js +62 -0
- package/admin/js/views/contacts.js +174 -0
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/notes.js +104 -0
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +24 -22
- package/admin/js/views/theme.js +1 -1
- package/admin/js/views/todo.js +96 -0
- package/bin/cli.js +4 -10
- package/bin/lib/plugin-version.js +28 -0
- package/bin/update.js +14 -0
- package/config/menus/admin-sidebar.json +1 -1
- package/config/server.json +3 -1
- package/package.json +3 -7
- package/plugins/_lib/admin/mail/reader-view.js +20 -0
- package/plugins/_lib/mail/store.js +21 -1
- package/plugins/blog/CLAUDE.md +237 -0
- package/plugins/blog/admin/css/index.css +178 -1
- package/plugins/blog/admin/templates/blog.html +35 -53
- package/plugins/blog/admin/templates/categories.html +19 -6
- package/plugins/blog/admin/templates/comments.html +26 -9
- package/plugins/blog/admin/templates/post-editor.html +36 -13
- package/plugins/blog/admin/templates/settings.html +2 -11
- package/plugins/blog/admin/views/blog.js +288 -162
- package/plugins/blog/admin/views/categories.js +212 -201
- package/plugins/blog/admin/views/comments.js +207 -158
- package/plugins/blog/admin/views/kit.js +372 -0
- package/plugins/blog/admin/views/post-editor.js +269 -21
- package/plugins/blog/admin/views/settings-cog.js +56 -0
- package/plugins/blog/admin/views/settings.js +16 -86
- package/plugins/blog/blocks/blog-card-compact.css +17 -0
- package/plugins/blog/blocks/blog-card-compact.html +8 -0
- package/plugins/blog/blocks/blog-card-row.css +22 -0
- package/plugins/blog/blocks/blog-card-row.html +11 -0
- package/plugins/blog/blocks/blog-card.css +35 -0
- package/plugins/blog/blocks/blog-card.html +16 -0
- package/plugins/blog/blocks/blog-post-classic.css +15 -0
- package/plugins/blog/blocks/blog-post-classic.html +28 -0
- package/plugins/blog/blocks/blog-post-feature.css +20 -0
- package/plugins/blog/blocks/blog-post-feature.html +30 -0
- package/plugins/blog/blocks/blog-post-minimal.css +10 -0
- package/plugins/blog/blocks/blog-post-minimal.html +23 -0
- package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
- package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
- package/plugins/blog/collections/posts/schema.json +3 -1
- package/plugins/blog/config.js +6 -0
- package/plugins/blog/lib/layouts.js +304 -0
- package/plugins/blog/lib/page.js +158 -0
- package/plugins/blog/lib/render.js +119 -0
- package/plugins/blog/lib/samples.js +234 -0
- package/plugins/blog/plugin.js +225 -15
- package/plugins/blog/plugin.json +21 -81
- package/plugins/blog/plugin.public.js +115 -210
- package/plugins/blog/public/blog.css +177 -0
- package/plugins/blog/public/blog.js +586 -0
- package/plugins/blog/public/samples/aurora.svg +7 -0
- package/plugins/blog/public/samples/dusk.svg +7 -0
- package/plugins/blog/public/samples/ember.svg +7 -0
- package/plugins/blog/public/samples/harbour.svg +7 -0
- package/plugins/blog/public/samples/meadow.svg +7 -0
- package/plugins/blog/public/samples/rosewood.svg +7 -0
- package/plugins/blog/templates/index.html +9 -8
- package/plugins/blog/templates/post.html +4 -17
- package/plugins/blog/tests/layouts.test.js +97 -0
- package/plugins/blog/tests/public.test.js +71 -0
- package/plugins/free-tier.lock.json +74 -0
- package/plugins/mail-reader/CLAUDE.md +78 -0
- package/plugins/mail-reader/plugin.json +2 -1
- package/public/css/theme-switcher.css +1 -0
- package/public/js/analytics.js +1 -0
- package/public/js/theme-switcher.js +1 -0
- package/scripts/build.js +41 -2
- package/scripts/run-tests.mjs +94 -0
- package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
- package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
- package/server/routes/api/dashboard.js +8 -2
- package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
- package/server/routes/api/plugin-marketplace.js +78 -23
- package/server/routes/api/plugins.js +148 -3
- package/server/routes/api/todo.js +178 -0
- package/server/server.js +61 -1
- package/server/services/collections.js +13 -3
- package/server/services/managerClient.js +72 -136
- package/server/services/markdown.js +54 -1
- package/server/services/permissionRegistry.js +50 -0
- package/server/services/pluginEntitlement.js +171 -0
- package/server/services/pluginEnvelope.js +242 -0
- package/server/services/pluginInstaller.js +246 -26
- package/server/services/pluginScaffold.js +5 -0
- package/server/services/plugins.js +117 -7
- package/server/services/presetCollections.js +344 -0
- package/server/services/promoted-plugins-migration.js +254 -0
- package/server/services/publicCors.js +46 -0
- package/server/services/renderer.js +83 -3
- package/server/services/sidebarBadges.js +37 -0
- package/server/services/siteGitignore.js +310 -0
- package/server/services/themeSettings.js +39 -0
- package/config/theme.json +0 -18
- package/plugins/analytics/admin/css/index.css +0 -1
- package/plugins/analytics/admin/templates/analytics.html +0 -70
- package/plugins/analytics/admin/views/analytics.js +0 -176
- package/plugins/analytics/config.js +0 -14
- package/plugins/analytics/plugin.json +0 -60
- package/plugins/analytics/public/inject-body.html +0 -60
- package/plugins/analytics/public/inject-head.html +0 -1
- package/plugins/blog/templates/author.html +0 -9
- package/plugins/blog/templates/category.html +0 -9
- package/plugins/blog/templates/tag.html +0 -9
- package/plugins/contacts/admin/templates/contacts.html +0 -254
- package/plugins/contacts/admin/views/contacts.js +0 -943
- package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
- package/plugins/contacts/collections/user-contacts/schema.json +0 -179
- package/plugins/contacts/config.js +0 -6
- package/plugins/contacts/plugin.json +0 -36
- package/plugins/demo-viewer/config.js +0 -4
- package/plugins/demo-viewer/plugin.js +0 -36
- package/plugins/demo-viewer/plugin.json +0 -9
- package/plugins/invoice/admin/css/index.css +0 -1
- package/plugins/invoice/admin/templates/editor.html +0 -220
- package/plugins/invoice/admin/templates/index.html +0 -173
- package/plugins/invoice/admin/templates/issuers.html +0 -2
- package/plugins/invoice/admin/templates/receivers.html +0 -2
- package/plugins/invoice/admin/views/contacts-source.js +0 -266
- package/plugins/invoice/admin/views/editor.js +0 -434
- package/plugins/invoice/admin/views/export.js +0 -199
- package/plugins/invoice/admin/views/help-content.js +0 -61
- package/plugins/invoice/admin/views/index.js +0 -643
- package/plugins/invoice/admin/views/issuers.js +0 -30
- package/plugins/invoice/admin/views/media.js +0 -172
- package/plugins/invoice/admin/views/party-view.js +0 -386
- package/plugins/invoice/admin/views/payments.js +0 -127
- package/plugins/invoice/admin/views/print.js +0 -130
- package/plugins/invoice/admin/views/receivers.js +0 -55
- package/plugins/invoice/admin/views/send.js +0 -212
- package/plugins/invoice/admin/views/settings.js +0 -594
- package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
- package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
- package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
- package/plugins/invoice/collections/invoices/schema.json +0 -33
- package/plugins/invoice/config.js +0 -37
- package/plugins/invoice/pdf.js +0 -164
- package/plugins/invoice/plugin.js +0 -1456
- package/plugins/invoice/plugin.json +0 -86
- package/plugins/invoice/templates/_base.css +0 -1
- package/plugins/invoice/templates/classic-nologo.html +0 -100
- package/plugins/invoice/templates/classic.html +0 -91
- package/plugins/invoice/templates/invoice-print.html +0 -237
- package/plugins/invoice/templates/minimal.html +0 -99
- package/plugins/invoice/templates/modern-nologo.html +0 -114
- package/plugins/invoice/templates/modern.html +0 -113
- package/plugins/invoice/templates/templates.json +0 -11
- package/plugins/notes/admin/templates/notes.html +0 -83
- package/plugins/notes/admin/views/notes.js +0 -281
- package/plugins/notes/collections/user-notes/schema.json +0 -54
- package/plugins/notes/config.js +0 -6
- package/plugins/notes/plugin.json +0 -33
- package/plugins/surveys/admin/templates/audience.html +0 -47
- package/plugins/surveys/admin/templates/results.html +0 -58
- package/plugins/surveys/admin/templates/survey-editor.html +0 -141
- package/plugins/surveys/admin/templates/surveys.html +0 -25
- package/plugins/surveys/admin/views/audience.js +0 -301
- package/plugins/surveys/admin/views/results.js +0 -172
- package/plugins/surveys/admin/views/survey-editor.js +0 -211
- package/plugins/surveys/admin/views/surveys.js +0 -161
- package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
- package/plugins/surveys/collections/survey-groups/schema.json +0 -11
- package/plugins/surveys/collections/survey-invites/schema.json +0 -16
- package/plugins/surveys/collections/surveys/schema.json +0 -23
- package/plugins/surveys/config.js +0 -8
- package/plugins/surveys/lib/audience.js +0 -37
- package/plugins/surveys/lib/campaigns.js +0 -43
- package/plugins/surveys/lib/ledger.js +0 -110
- package/plugins/surveys/lib/sending.js +0 -106
- package/plugins/surveys/lib/stats.js +0 -62
- package/plugins/surveys/lib/submit.js +0 -95
- package/plugins/surveys/lib/tokens.js +0 -28
- package/plugins/surveys/plugin.js +0 -174
- package/plugins/surveys/plugin.json +0 -65
- package/plugins/surveys/plugin.public.js +0 -149
- package/plugins/surveys/public/dist-shared.mjs +0 -1
- package/plugins/surveys/public/survey.css +0 -1
- package/plugins/surveys/public/survey.mjs +0 -1
- package/plugins/surveys/templates/survey-page.html +0 -63
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
- package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
- package/plugins/theme-switcher/config.js +0 -10
- package/plugins/theme-switcher/plugin.js +0 -26
- package/plugins/theme-switcher/plugin.json +0 -74
- package/plugins/theme-switcher/public/inject-body.html +0 -153
- package/plugins/theme-switcher/public/inject-head.html +0 -260
- package/plugins/todo/admin/templates/todo.html +0 -158
- package/plugins/todo/admin/views/todo.js +0 -343
- package/plugins/todo/collections/todos/schema.json +0 -60
- package/plugins/todo/config.js +0 -7
- package/plugins/todo/plugin.js +0 -102
- package/plugins/todo/plugin.json +0 -33
- /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
|
-
<
|
|
2
|
-
|
|
3
|
-
<
|
|
4
|
-
{{
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
<
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
<h1>{{title}}</h1>
|
|
5
|
-
<p class="text-muted">by {{authorName}} • <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><script>x</script></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 <i>/);
|
|
80
|
+
assert.match(v.tags, /data-blog-tag="x"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=
|
|
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,94 @@
|
|
|
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
|
+
// DOMMA_REPO_ROOT: where the real checkout is, for the few tests that compare
|
|
81
|
+
// against a sibling repo (../dcms-marketplace) the temp copy has no neighbour for.
|
|
82
|
+
const child = spawn(process.execPath, args, {cwd: copy, stdio: 'inherit', env: {...process.env, DOMMA_REPO_ROOT: ROOT}});
|
|
83
|
+
|
|
84
|
+
// Ctrl+C, or a kill: stop the suite, then still delete the copy.
|
|
85
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
86
|
+
process.on(sig, () => {
|
|
87
|
+
child.kill(sig);
|
|
88
|
+
setTimeout(() => child.kill('SIGKILL'), 5000).unref();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
child.on('exit', (code, signal) => {
|
|
92
|
+
cleanup();
|
|
93
|
+
process.exit(code ?? (signal ? 1 : 0));
|
|
94
|
+
});
|