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,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promoted-plugin migration - one-shot retirement of plugins that became core.
|
|
3
|
+
*
|
|
4
|
+
* A plugin that is promoted into the CMS leaves two traces on an existing
|
|
5
|
+
* install:
|
|
6
|
+
*
|
|
7
|
+
* 1. its `plugins.json` entry, which would otherwise offer a toggle for a
|
|
8
|
+
* feature that is no longer optional, and
|
|
9
|
+
* 2. `plugins/<slug>/` itself - the updater replaces the plugins it ships and
|
|
10
|
+
* adds new ones, but never removes one that has gone away. Left in place,
|
|
11
|
+
* the loader would register a SECOND copy of a feature the CMS now has of
|
|
12
|
+
* its own, which for a shortcode means two handlers racing for the same
|
|
13
|
+
* tag.
|
|
14
|
+
*
|
|
15
|
+
* This is the generic form of `search-migration.js`, which did the same job for
|
|
16
|
+
* `site-search` alone. Each promotion adds a row to PROMOTED rather than another
|
|
17
|
+
* near-identical file.
|
|
18
|
+
*
|
|
19
|
+
* Idempotent: with no entry and no directory there is nothing to do, which is
|
|
20
|
+
* the state of every fresh install.
|
|
21
|
+
*/
|
|
22
|
+
import fs from 'fs';
|
|
23
|
+
import path from 'path';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Plugins that are now part of the CMS.
|
|
27
|
+
*
|
|
28
|
+
* `author` is checked against the directory's manifest before anything is
|
|
29
|
+
* deleted. A directory of that name carrying somebody else's work is their
|
|
30
|
+
* plugin, not our retired one, and is left exactly where it is.
|
|
31
|
+
*
|
|
32
|
+
* `defaults` is what the plugin's own config.js shipped. Settings matching it
|
|
33
|
+
* are dropped silently; anything else is reported, because a site that tuned a
|
|
34
|
+
* setting deserves to be told where it went rather than have it vanish.
|
|
35
|
+
*
|
|
36
|
+
* `lift(settings, enabled)` moves those settings somewhere they still matter.
|
|
37
|
+
* A row without one is a plugin whose settings core does not need. It runs
|
|
38
|
+
* BEFORE the entry is deleted, so a throw leaves the entry in place and the
|
|
39
|
+
* next start tries again rather than losing the values.
|
|
40
|
+
*/
|
|
41
|
+
const PROMOTED = [
|
|
42
|
+
{
|
|
43
|
+
slug: 'demo-viewer',
|
|
44
|
+
author: 'Domma',
|
|
45
|
+
defaults: {basePath: '/public/demos', defaultHeight: '500px'},
|
|
46
|
+
note: 'the [demo] shortcode is built in'
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
slug: 'notes',
|
|
50
|
+
author: 'Darryl Waterhouse',
|
|
51
|
+
defaults: {scope: 'user', maxPerUser: 1000, storage: {adapter: 'mongodb', connection: 'default'}},
|
|
52
|
+
note: 'Notes is a built-in Tool; its entries are untouched in notes-notes'
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
slug: 'todo',
|
|
56
|
+
author: 'Darryl Waterhouse',
|
|
57
|
+
defaults: {scope: 'user', defaultPriority: 'medium', maxPerUser: 500, storage: {adapter: 'mongodb', connection: 'default'}},
|
|
58
|
+
note: 'Todo is a built-in Tool; its entries are untouched in todo-items'
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
slug: 'contacts',
|
|
62
|
+
author: 'Darryl Waterhouse',
|
|
63
|
+
defaults: {scope: 'user'},
|
|
64
|
+
note: 'Contacts is a built-in Tool; contacts-contacts and contacts-groups are untouched'
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
slug: 'analytics',
|
|
68
|
+
author: 'Darryl Waterhouse',
|
|
69
|
+
note: 'analytics is built in; counters moved to content/analytics/',
|
|
70
|
+
lift: liftAnalyticsCounters
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
slug: 'theme-switcher',
|
|
74
|
+
author: 'Domma',
|
|
75
|
+
note: 'the switcher is a theme setting now',
|
|
76
|
+
lift: liftThemeSwitcher
|
|
77
|
+
}
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Move the analytics counters out from under plugins/.
|
|
82
|
+
*
|
|
83
|
+
* This is the only lift that moves data a site cannot regenerate: lifetime.json,
|
|
84
|
+
* daily.json and journeys.json are real traffic history, tens to hundreds of
|
|
85
|
+
* kilobytes per site. The directory they sit in is about to be deleted, so the
|
|
86
|
+
* copy has to happen first and has to be verified.
|
|
87
|
+
*
|
|
88
|
+
* Copy, re-read, compare, and only then let the caller proceed. A mismatch
|
|
89
|
+
* throws, which abandons the whole row - the plugin directory stays, the entry
|
|
90
|
+
* stays, and the next start tries again. A site keeping a duplicate 96K for one
|
|
91
|
+
* release is not a problem; losing the original is.
|
|
92
|
+
*
|
|
93
|
+
* Files already present at the destination are left alone: a second run must not
|
|
94
|
+
* overwrite counters that have moved on since.
|
|
95
|
+
*
|
|
96
|
+
* @returns {string|null} what changed, for the log
|
|
97
|
+
*/
|
|
98
|
+
function liftAnalyticsCounters() {
|
|
99
|
+
const SRC = path.resolve('plugins', 'analytics', 'data');
|
|
100
|
+
const DEST = path.resolve('content', 'analytics');
|
|
101
|
+
if (!fs.existsSync(SRC)) return null;
|
|
102
|
+
|
|
103
|
+
fs.mkdirSync(DEST, {recursive: true});
|
|
104
|
+
const copied = [];
|
|
105
|
+
|
|
106
|
+
for (const name of ['lifetime.json', 'daily.json', 'journeys.json', 'stats.json']) {
|
|
107
|
+
const from = path.join(SRC, name);
|
|
108
|
+
const to = path.join(DEST, name);
|
|
109
|
+
if (!fs.existsSync(from) || fs.existsSync(to)) continue;
|
|
110
|
+
|
|
111
|
+
const raw = fs.readFileSync(from);
|
|
112
|
+
fs.writeFileSync(to, raw);
|
|
113
|
+
|
|
114
|
+
// Verified by comparing what is now on disk, not by trusting the write.
|
|
115
|
+
const back = fs.readFileSync(to);
|
|
116
|
+
if (!back.equals(raw)) {
|
|
117
|
+
throw new Error(`counters failed to copy intact (${name})`);
|
|
118
|
+
}
|
|
119
|
+
copied.push(`${name} (${raw.length} bytes)`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return copied.length
|
|
123
|
+
? `copied counters to content/analytics/: ${copied.join(', ')}`
|
|
124
|
+
: null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Move the theme switcher's placement into config/theme.json.
|
|
129
|
+
*
|
|
130
|
+
* `enabled` carries across deliberately: a site showing the disc keeps showing
|
|
131
|
+
* it, and one that never turned the plugin on does not suddenly acquire a
|
|
132
|
+
* visitor-facing theme picker.
|
|
133
|
+
*
|
|
134
|
+
* theme.json is read and written directly rather than through saveThemeConfig(),
|
|
135
|
+
* which validates and recompiles. Migrations run before the app is up, and a
|
|
136
|
+
* placement value is not worth pulling that machinery into startup. Written only
|
|
137
|
+
* when theme.json already exists - migrateThemeConfig() runs first and creates
|
|
138
|
+
* it, so an absent file means something is wrong enough not to paper over.
|
|
139
|
+
*
|
|
140
|
+
* @param {object} settings
|
|
141
|
+
* @param {boolean} enabled
|
|
142
|
+
* @returns {string|null} what changed, for the log
|
|
143
|
+
*/
|
|
144
|
+
function liftThemeSwitcher(settings, enabled) {
|
|
145
|
+
const THEME_CONFIG = path.resolve('config', 'theme.json');
|
|
146
|
+
if (!fs.existsSync(THEME_CONFIG)) return null;
|
|
147
|
+
|
|
148
|
+
let cfg;
|
|
149
|
+
try {
|
|
150
|
+
cfg = JSON.parse(fs.readFileSync(THEME_CONFIG, 'utf8'));
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
if (cfg.switcher && cfg.switcher.enabled !== undefined) {
|
|
155
|
+
return null; // already lifted, or configured since
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const POSITIONS = ['right-center', 'left-center', 'bottom-right', 'bottom-left',
|
|
159
|
+
'top-left', 'top-right'];
|
|
160
|
+
const num = (v, fallback) =>
|
|
161
|
+
(Number.isFinite(Number(v)) && Number(v) >= 0 && Number(v) <= 400) ? Number(v) : fallback;
|
|
162
|
+
|
|
163
|
+
cfg.switcher = {
|
|
164
|
+
enabled,
|
|
165
|
+
position: POSITIONS.includes(settings.position) ? settings.position : 'right-center',
|
|
166
|
+
offsetX: num(settings.offsetX, 32),
|
|
167
|
+
offsetY: num(settings.offsetY, 32),
|
|
168
|
+
// The plugin's `themes` list was never read by its own markup, which
|
|
169
|
+
// hardcoded 26 dots. Starting empty means "every theme", which is what
|
|
170
|
+
// the markup effectively promised and never delivered.
|
|
171
|
+
themes: []
|
|
172
|
+
};
|
|
173
|
+
fs.writeFileSync(THEME_CONFIG, JSON.stringify(cfg, null, 2) + '\n');
|
|
174
|
+
return `lifted placement into theme.json (enabled: ${enabled})`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Run the migration.
|
|
179
|
+
*
|
|
180
|
+
* Paths are resolved per call rather than at import, so the same process can run
|
|
181
|
+
* this against more than one install - which the tests do.
|
|
182
|
+
*
|
|
183
|
+
* @param {object[]} [table] - override for testing
|
|
184
|
+
* @returns {string[]} what changed, for the startup log (empty = nothing to do)
|
|
185
|
+
*/
|
|
186
|
+
export function migratePromotedPlugins(table = PROMOTED) {
|
|
187
|
+
const PLUGINS_CONFIG = path.resolve('config', 'plugins.json');
|
|
188
|
+
const changes = [];
|
|
189
|
+
|
|
190
|
+
let plugins = null;
|
|
191
|
+
try {
|
|
192
|
+
plugins = JSON.parse(fs.readFileSync(PLUGINS_CONFIG, 'utf8'));
|
|
193
|
+
} catch {
|
|
194
|
+
// No plugins.json, or a malformed one. Neither is this migration's
|
|
195
|
+
// business - a broken file is reported loudly elsewhere.
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let dirty = false;
|
|
199
|
+
|
|
200
|
+
for (const {slug, author, defaults = {}, note, lift} of table) {
|
|
201
|
+
const stored = plugins?.[slug];
|
|
202
|
+
|
|
203
|
+
// 1. The entry, and any settings it carried.
|
|
204
|
+
if (plugins && stored) {
|
|
205
|
+
const settings = stored.settings || {};
|
|
206
|
+
|
|
207
|
+
if (lift) {
|
|
208
|
+
// A lift that throws must abandon THIS row entirely - entry and
|
|
209
|
+
// directory both. Deleting either after a failed lift is how a
|
|
210
|
+
// site loses the thing the lift was moving.
|
|
211
|
+
let lifted;
|
|
212
|
+
try {
|
|
213
|
+
lifted = lift(settings, stored.enabled === true);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
changes.push(`${slug}: NOT retired - ${err.message}`);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (lifted) changes.push(`${slug}: ${lifted}`);
|
|
219
|
+
} else {
|
|
220
|
+
const changed = Object.entries(settings)
|
|
221
|
+
.filter(([k, v]) => JSON.stringify(v) !== JSON.stringify(defaults[k]));
|
|
222
|
+
if (changed.length) {
|
|
223
|
+
changes.push(
|
|
224
|
+
`${slug}: dropped non-default settings (${changed.map(([k]) => k).join(', ')}) - ${note}`
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
delete plugins[slug];
|
|
230
|
+
dirty = true;
|
|
231
|
+
changes.push(`${slug}: removed the plugins.json entry`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 2. The directory - but only if it is the one we shipped.
|
|
235
|
+
const dir = path.resolve('plugins', slug);
|
|
236
|
+
if (!fs.existsSync(dir)) continue;
|
|
237
|
+
|
|
238
|
+
let ours = false;
|
|
239
|
+
try {
|
|
240
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'plugin.json'), 'utf8'));
|
|
241
|
+
ours = manifest.name === slug && manifest.author === author;
|
|
242
|
+
} catch { /* no readable manifest - leave it alone */ }
|
|
243
|
+
|
|
244
|
+
if (ours) {
|
|
245
|
+
fs.rmSync(dir, {recursive: true, force: true});
|
|
246
|
+
changes.push(`${slug}: removed plugins/${slug}/`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (dirty) {
|
|
251
|
+
fs.writeFileSync(PLUGINS_CONFIG, JSON.stringify(plugins, null, 2) + '\n');
|
|
252
|
+
}
|
|
253
|
+
return changes;
|
|
254
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public asset CORS
|
|
3
|
+
*
|
|
4
|
+
* A module script is fetched in CORS mode, so `import … from
|
|
5
|
+
* 'https://this-site/public/whatever/index.js'` on another origin fails without
|
|
6
|
+
* Access-Control-Allow-Origin. A site that deliberately wants some of its own
|
|
7
|
+
* assets hot-linkable - engine source, demo code, a shared bundle - needs that
|
|
8
|
+
* header on those paths and nowhere else.
|
|
9
|
+
*
|
|
10
|
+
* `@fastify/cors` is registered once with `config/server.json`'s `cors` block and
|
|
11
|
+
* decides per ORIGIN, not per path, so it cannot express "open reads under this
|
|
12
|
+
* one prefix". Hence `cors.publicPrefixes`.
|
|
13
|
+
*
|
|
14
|
+
* Reads only. This never widens a write, and it never applies to a path that was
|
|
15
|
+
* not explicitly listed.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Read and sanitise the configured prefixes.
|
|
20
|
+
*
|
|
21
|
+
* A prefix must be an absolute path. Anything else is dropped rather than
|
|
22
|
+
* corrected: a typo that silently matched everything would be the one mistake
|
|
23
|
+
* here with real consequences.
|
|
24
|
+
*
|
|
25
|
+
* @param {object} [corsConfig] - the `cors` block from config/server.json
|
|
26
|
+
* @returns {string[]}
|
|
27
|
+
*/
|
|
28
|
+
export function normalisePublicPrefixes(corsConfig) {
|
|
29
|
+
const raw = corsConfig?.publicPrefixes;
|
|
30
|
+
if (!Array.isArray(raw)) return [];
|
|
31
|
+
return raw.filter(p => typeof p === 'string' && p.startsWith('/') && p.length > 1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Does this request qualify for the open-read header?
|
|
36
|
+
*
|
|
37
|
+
* @param {string} method
|
|
38
|
+
* @param {string} url - request URL, path first (query is ignored by startsWith)
|
|
39
|
+
* @param {string[]} prefixes - from normalisePublicPrefixes()
|
|
40
|
+
* @returns {boolean}
|
|
41
|
+
*/
|
|
42
|
+
export function isPublicAssetRead(method, url, prefixes) {
|
|
43
|
+
if (method !== 'GET' && method !== 'HEAD') return false;
|
|
44
|
+
if (!prefixes.length || typeof url !== 'string') return false;
|
|
45
|
+
return prefixes.some(p => url.startsWith(p));
|
|
46
|
+
}
|
|
@@ -12,7 +12,7 @@ import {resolveLocation, resolveMenuDecorations, resolveOverlaysForPage} from '.
|
|
|
12
12
|
import {buildMenuNav} from './menuRender.js';
|
|
13
13
|
import {getProjectForPage} from './projects.js';
|
|
14
14
|
import {resolveContextMenusForPage} from './contextMenus.js';
|
|
15
|
-
import {buildOverrideStyleTag, loadThemeConfig} from './themeSettings.js';
|
|
15
|
+
import {buildOverrideStyleTag, getThemeTokenMap, listThemeIds, loadThemeConfig} from './themeSettings.js';
|
|
16
16
|
import {getSearchSettings} from './search.js';
|
|
17
17
|
|
|
18
18
|
const VALID_LAYOUT_WIDTHS = new Set(['narrow', 'normal', 'wide', 'full']);
|
|
@@ -74,6 +74,80 @@ function buildSearchAssets() {
|
|
|
74
74
|
};
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
const THEME_SWITCHER_ASSET_V = '20260922-ts';
|
|
78
|
+
const ANALYTICS_ASSET_V = '20260922-analytics';
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The page-view beacon.
|
|
82
|
+
*
|
|
83
|
+
* Always emitted: analytics was a CORE_PLUGINS member, meaning it could not be
|
|
84
|
+
* switched off, and promotion should not quietly change that. What it counts is
|
|
85
|
+
* decided in the browser - Do-Not-Track, per-session dedup and preview renders
|
|
86
|
+
* all opt out there, where the facts are.
|
|
87
|
+
*
|
|
88
|
+
* Both render paths, the standing trap in this file.
|
|
89
|
+
*
|
|
90
|
+
* @returns {string} the body tag
|
|
91
|
+
*/
|
|
92
|
+
function buildAnalyticsTag() {
|
|
93
|
+
return `<script src="/public/js/analytics.js?v=${ANALYTICS_ASSET_V}"></script>`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The public theme switcher: stylesheet, theme list, script.
|
|
98
|
+
*
|
|
99
|
+
* The theme LIST is built here from listThemeIds() rather than being written
|
|
100
|
+
* out anywhere. The plugin this replaced shipped 26 hand-written dots against a
|
|
101
|
+
* stylesheet carrying 32 themes, and nothing made the gap visible - deriving it
|
|
102
|
+
* means a theme added to domma-themes.css simply appears.
|
|
103
|
+
*
|
|
104
|
+
* Each dot's colours are that theme's own --dm-primary and --dm-secondary, for
|
|
105
|
+
* the same reason: no second list of colours to drift.
|
|
106
|
+
*
|
|
107
|
+
* Both render paths need this, the same standing trap buildThemeView() and
|
|
108
|
+
* buildSearchAssets() carry.
|
|
109
|
+
*
|
|
110
|
+
* @returns {{headTag: string, bodyTag: string}} Empty strings when it is off
|
|
111
|
+
*/
|
|
112
|
+
function buildThemeSwitcherAssets(themeCfg) {
|
|
113
|
+
const sw = themeCfg?.switcher;
|
|
114
|
+
if (!sw || sw.enabled !== true) return {headTag: '', bodyTag: ''};
|
|
115
|
+
|
|
116
|
+
let ids;
|
|
117
|
+
try {
|
|
118
|
+
ids = listThemeIds();
|
|
119
|
+
} catch {
|
|
120
|
+
return {headTag: '', bodyTag: ''};
|
|
121
|
+
}
|
|
122
|
+
const wanted = Array.isArray(sw.themes) && sw.themes.length
|
|
123
|
+
? ids.filter(id => sw.themes.includes(id))
|
|
124
|
+
: ids;
|
|
125
|
+
if (!wanted.length) return {headTag: '', bodyTag: ''};
|
|
126
|
+
|
|
127
|
+
const themes = wanted.map(id => {
|
|
128
|
+
const tokens = getThemeTokenMap(id) || {};
|
|
129
|
+
return {
|
|
130
|
+
id,
|
|
131
|
+
label: id.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
|
|
132
|
+
from: tokens['--dm-primary'] || tokens['--dm-surface'] || '#888',
|
|
133
|
+
to: tokens['--dm-secondary'] || tokens['--dm-background'] || '#888'
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const payload = {
|
|
138
|
+
position: sw.position || 'right-center',
|
|
139
|
+
offsetX: sw.offsetX ?? 32,
|
|
140
|
+
offsetY: sw.offsetY ?? 32,
|
|
141
|
+
themes
|
|
142
|
+
};
|
|
143
|
+
const json = JSON.stringify(payload).replace(/<\/script>/gi, '<\\/script>');
|
|
144
|
+
return {
|
|
145
|
+
headTag: `<link rel="stylesheet" href="/public/css/theme-switcher.css?v=${THEME_SWITCHER_ASSET_V}">`,
|
|
146
|
+
bodyTag: `<script>window.__CMS_THEME_SWITCHER__ = ${json};</script>\n`
|
|
147
|
+
+ `<script src="/public/js/theme-switcher.js?v=${THEME_SWITCHER_ASSET_V}"></script>`
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
77
151
|
/**
|
|
78
152
|
* Strip hidden items from the navigation config before public injection.
|
|
79
153
|
* Returns a shallow clone - never mutates the cached config object.
|
|
@@ -233,6 +307,7 @@ export async function renderPage(page, opts = {}) {
|
|
|
233
307
|
|
|
234
308
|
const themeView = await buildThemeView(page);
|
|
235
309
|
const searchAssets = buildSearchAssets();
|
|
310
|
+
const switcherAssets = buildThemeSwitcherAssets(themeView.cfg);
|
|
236
311
|
const {fontLink, fontStyleTag, autoTheme, activeTheme, dommaTheme, customThemeClass} = themeView;
|
|
237
312
|
|
|
238
313
|
const {navbarFontLink, navbarStyleTag} = buildNavbarStyleTag(navigation);
|
|
@@ -322,7 +397,7 @@ export async function renderPage(page, opts = {}) {
|
|
|
322
397
|
// Theme overrides land BEFORE content/custom.css: the overrides are
|
|
323
398
|
// generated, custom.css is hand-written, and the hand-written one stays
|
|
324
399
|
// the last word.
|
|
325
|
-
headInjectLate: [injection.headLate, searchAssets.headTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
|
|
400
|
+
headInjectLate: [injection.headLate, searchAssets.headTag, switcherAssets.headTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
|
|
326
401
|
.filter(Boolean).join('\n'),
|
|
327
402
|
bodyEndInject: [
|
|
328
403
|
// Draft banner - first, so it is the topmost fixed element and a
|
|
@@ -335,6 +410,8 @@ export async function renderPage(page, opts = {}) {
|
|
|
335
410
|
overlayMenus,
|
|
336
411
|
injection.bodyEnd,
|
|
337
412
|
searchAssets.bodyTag,
|
|
413
|
+
switcherAssets.bodyTag,
|
|
414
|
+
buildAnalyticsTag(),
|
|
338
415
|
site.backToTop?.enabled ? '<script src="/public/js/btt.js"></script>' : '',
|
|
339
416
|
site.cookieConsent?.enabled ? '<script src="/public/js/cookie-consent.js"></script>' : '',
|
|
340
417
|
(page.usedComponents || [])
|
|
@@ -853,6 +930,7 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
|
|
|
853
930
|
// other is the standing trap in this file.
|
|
854
931
|
const themeView = await buildThemeView({});
|
|
855
932
|
const searchAssets = buildSearchAssets();
|
|
933
|
+
const switcherAssets = buildThemeSwitcherAssets(themeView.cfg);
|
|
856
934
|
const {fontLink, fontStyleTag, activeTheme, dommaTheme, customThemeClass} = themeView;
|
|
857
935
|
const {navbarFontLink, navbarStyleTag} = buildNavbarStyleTag(navigation);
|
|
858
936
|
|
|
@@ -930,12 +1008,14 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
|
|
|
930
1008
|
// Theme overrides land BEFORE content/custom.css: the overrides are
|
|
931
1009
|
// generated, custom.css is hand-written, and the hand-written one stays
|
|
932
1010
|
// the last word.
|
|
933
|
-
headInjectLate: [injection.headLate, searchAssets.headTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
|
|
1011
|
+
headInjectLate: [injection.headLate, searchAssets.headTag, switcherAssets.headTag, themeView.overrideStyleTag, customCssTag, navbarStyleTag]
|
|
934
1012
|
.filter(Boolean).join('\n'),
|
|
935
1013
|
bodyEndInject: [
|
|
936
1014
|
overlayMenus,
|
|
937
1015
|
injection.bodyEnd,
|
|
938
1016
|
searchAssets.bodyTag,
|
|
1017
|
+
switcherAssets.bodyTag,
|
|
1018
|
+
buildAnalyticsTag(),
|
|
939
1019
|
(seoMeta.usedComponents || [])
|
|
940
1020
|
.map(n => `<script type="module" src="/api/components/${n}.js"></script>`)
|
|
941
1021
|
.join('\n')
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar badges - what a count route answers.
|
|
3
|
+
*
|
|
4
|
+
* {count, label, tone, details: {title, rows: [{label, value, tone?}], items: [{text, meta?, tone?}], empty?}}
|
|
5
|
+
*
|
|
6
|
+
* `count` and `tone` draw the badge; `details` fills the popover shown on
|
|
7
|
+
* hover. Every value is plain text - the popover sets it with textContent, so
|
|
8
|
+
* a note title or a comment is never markup. See "Live counts" in
|
|
9
|
+
* admin/js/lib/sidebar-renderer.js.
|
|
10
|
+
*
|
|
11
|
+
* @module services/sidebarBadges
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** "1 task", "3 tasks". */
|
|
15
|
+
export function plural(n, one, many = `${one}s`) {
|
|
16
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** "just now", "5 min ago", "3 hours ago", "2 days ago", then the date. */
|
|
20
|
+
export function ago(iso, now = Date.now()) {
|
|
21
|
+
const t = new Date(iso).getTime();
|
|
22
|
+
if (!Number.isFinite(t)) return '';
|
|
23
|
+
const s = Math.max(0, Math.round((now - t) / 1000));
|
|
24
|
+
if (s < 60) return 'just now';
|
|
25
|
+
if (s < 3600) return `${Math.round(s / 60)} min ago`;
|
|
26
|
+
if (s < 86400) return plural(Math.round(s / 3600), 'hour') + ' ago';
|
|
27
|
+
if (s < 86400 * 14) return plural(Math.round(s / 86400), 'day') + ' ago';
|
|
28
|
+
return new Date(t).toISOString().slice(0, 10);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Shorten to `max` characters on a word boundary, with an ellipsis. */
|
|
32
|
+
export function clip(text, max = 60) {
|
|
33
|
+
const s = String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
34
|
+
if (s.length <= max) return s;
|
|
35
|
+
const cut = s.slice(0, max - 1);
|
|
36
|
+
return `${cut.slice(0, cut.lastIndexOf(' ') > max * 0.6 ? cut.lastIndexOf(' ') : cut.length)}…`;
|
|
37
|
+
}
|