domma-cms 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +2 -0
- package/admin/css/dashboard.css +1 -0
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +4 -4
- package/admin/index.html +2 -1
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +1 -1
- package/admin/js/lib/card-builder.js +3 -3
- package/admin/js/lib/effects-builder.js +1 -1
- package/admin/js/lib/markdown-toolbar.js +5 -5
- package/admin/js/templates/dashboard/activity-feed.html +3 -0
- package/admin/js/templates/dashboard/cache.html +32 -0
- package/admin/js/templates/dashboard/health-detail.html +2 -0
- package/admin/js/templates/dashboard/journeys.html +17 -0
- package/admin/js/templates/dashboard/kpi-strip.html +34 -0
- package/admin/js/templates/dashboard/spike-feed.html +3 -0
- package/admin/js/templates/dashboard/top-pages.html +3 -0
- package/admin/js/templates/dashboard/traffic-chart.html +3 -0
- package/admin/js/templates/dashboard.html +26 -44
- package/admin/js/templates/settings.html +26 -0
- package/admin/js/views/block-editor-enhance.js +1 -1
- package/admin/js/views/dashboard/lib/escape.js +1 -0
- package/admin/js/views/dashboard/widgets/activity-feed.js +1 -0
- package/admin/js/views/dashboard/widgets/cache.js +1 -0
- package/admin/js/views/dashboard/widgets/health-detail.js +1 -0
- package/admin/js/views/dashboard/widgets/journeys.js +1 -0
- package/admin/js/views/dashboard/widgets/kpi-strip.js +1 -0
- package/admin/js/views/dashboard/widgets/spike-feed.js +6 -0
- package/admin/js/views/dashboard/widgets/top-pages.js +1 -0
- package/admin/js/views/dashboard/widgets/traffic-chart.js +1 -0
- package/admin/js/views/dashboard.js +1 -1
- package/admin/js/views/form-editor.js +7 -7
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/page-editor.js +42 -37
- package/admin/js/views/settings.js +3 -3
- package/config/cache.json +4 -0
- package/config/cache.json.example +12 -0
- package/config/plugins.json +3 -0
- package/package.json +2 -2
- package/plugins/analytics/daily.json +5 -0
- package/plugins/analytics/journeys.json +10 -0
- package/plugins/analytics/lifetime.json +25 -0
- package/plugins/analytics/plugin.js +231 -16
- package/plugins/analytics/public/inject-body.html +26 -2
- package/public/js/forms.js +1 -1
- package/public/js/site.js +1 -1
- package/server/config.js +12 -1
- package/server/routes/api/cache.js +57 -0
- package/server/routes/api/dashboard.js +239 -0
- package/server/routes/api/navigation.js +2 -0
- package/server/routes/api/settings.js +3 -0
- package/server/routes/public.js +11 -3
- package/server/server.js +18 -3
- package/server/services/blocks.js +3 -0
- package/server/services/cache/drivers/MemoryDriver.js +118 -0
- package/server/services/cache/drivers/NoneDriver.js +12 -0
- package/server/services/cache/index.js +229 -0
- package/server/services/cache/lru.js +61 -0
- package/server/services/collections.js +17 -4
- package/server/services/content.js +7 -2
- package/server/services/email.js +60 -20
- package/server/services/forms.js +3 -0
- package/server/services/health.js +282 -0
- package/server/services/markdown.js +25 -15
- package/server/services/plugins.js +37 -5
- package/server/services/views.js +4 -0
- package/server/templates/page.html +130 -130
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health Service
|
|
3
|
+
* Aggregates server + site health checks for the dashboard.
|
|
4
|
+
* Each check returns { id, level: 'ok'|'warn'|'fail', label, value, detail? }.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'fs/promises';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import {fileURLToPath} from 'url';
|
|
9
|
+
import {config} from '../config.js';
|
|
10
|
+
import {getLastSendResult} from './email.js';
|
|
11
|
+
import {listPages} from './content.js';
|
|
12
|
+
import {getPluginLoadFailures} from './plugins.js';
|
|
13
|
+
import {FORMS_DIR} from './forms.js';
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
17
|
+
|
|
18
|
+
const CACHE_TTL_MS = 30 * 1000;
|
|
19
|
+
const STALE_PAGE_DAYS = 90;
|
|
20
|
+
const FORM_WARN_DAYS = 30;
|
|
21
|
+
let cached = { at: 0, value: null };
|
|
22
|
+
|
|
23
|
+
const LEVELS = { ok: 0, warn: 1, fail: 2 };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Compute the worst level across a set of checks.
|
|
27
|
+
*
|
|
28
|
+
* @param {Array<{ level: string }>} checks
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
function worstLevel(checks) {
|
|
32
|
+
let worst = 'ok';
|
|
33
|
+
for (const c of checks) if (LEVELS[c.level] > LEVELS[worst]) worst = c.level;
|
|
34
|
+
return worst;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Format a byte count as a human-readable string.
|
|
39
|
+
*
|
|
40
|
+
* @param {number} n
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
function formatBytes(n) {
|
|
44
|
+
if (n < 1024) return n + ' B';
|
|
45
|
+
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
|
46
|
+
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB';
|
|
47
|
+
return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Format a duration in seconds as a human-readable string.
|
|
52
|
+
*
|
|
53
|
+
* @param {number} seconds
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
function formatDuration(seconds) {
|
|
57
|
+
const d = Math.floor(seconds / 86400);
|
|
58
|
+
const h = Math.floor((seconds % 86400) / 3600);
|
|
59
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
60
|
+
const s = Math.floor(seconds % 60);
|
|
61
|
+
if (d > 0) return `${d}d ${h}h`;
|
|
62
|
+
if (h > 0) return `${h}h ${m}m`;
|
|
63
|
+
if (m > 0) return `${m}m`;
|
|
64
|
+
return `${s}s`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Recursively compute the size of a directory in bytes.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} dir
|
|
71
|
+
* @returns {Promise<number>}
|
|
72
|
+
*/
|
|
73
|
+
async function dirSize(dir) {
|
|
74
|
+
let total = 0;
|
|
75
|
+
let entries;
|
|
76
|
+
try { entries = await fs.readdir(dir, { withFileTypes: true }); }
|
|
77
|
+
catch { return 0; }
|
|
78
|
+
for (const e of entries) {
|
|
79
|
+
const full = path.join(dir, e.name);
|
|
80
|
+
if (e.isDirectory()) total += await dirSize(full);
|
|
81
|
+
else if (e.isFile()) {
|
|
82
|
+
try { total += (await fs.stat(full)).size; } catch { /* ignore */ }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return total;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Individual checks
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
async function checkUptime() {
|
|
93
|
+
return {
|
|
94
|
+
id: 'uptime',
|
|
95
|
+
level: 'ok',
|
|
96
|
+
label: 'Uptime',
|
|
97
|
+
value: formatDuration(Math.floor(process.uptime()))
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function checkMemory() {
|
|
102
|
+
const mem = process.memoryUsage();
|
|
103
|
+
const usedMb = mem.heapUsed / 1024 / 1024;
|
|
104
|
+
const totalMb = mem.heapTotal / 1024 / 1024;
|
|
105
|
+
const pct = totalMb === 0 ? 0 : usedMb / totalMb;
|
|
106
|
+
return {
|
|
107
|
+
id: 'memory',
|
|
108
|
+
level: pct > 0.9 ? 'warn' : 'ok',
|
|
109
|
+
label: 'Heap',
|
|
110
|
+
value: `${usedMb.toFixed(0)} / ${totalMb.toFixed(0)} MB`
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function checkContentDisk() {
|
|
115
|
+
const contentDir = path.isAbsolute(config.content.contentDir)
|
|
116
|
+
? config.content.contentDir
|
|
117
|
+
: path.join(ROOT, config.content.contentDir);
|
|
118
|
+
const size = await dirSize(contentDir);
|
|
119
|
+
return {
|
|
120
|
+
id: 'disk-content',
|
|
121
|
+
level: 'ok',
|
|
122
|
+
label: 'Content disk',
|
|
123
|
+
value: formatBytes(size)
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function checkStalePages() {
|
|
128
|
+
const pages = await listPages().catch(() => []);
|
|
129
|
+
const cutoff = Date.now() - STALE_PAGE_DAYS * 24 * 60 * 60 * 1000;
|
|
130
|
+
const stale = pages.filter(p => {
|
|
131
|
+
const updated = p.updatedAt ? new Date(p.updatedAt).getTime() : 0;
|
|
132
|
+
return updated > 0 && updated < cutoff;
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
id: 'stale-pages',
|
|
136
|
+
level: stale.length === 0 ? 'ok' : 'warn',
|
|
137
|
+
label: `Stale pages (>${STALE_PAGE_DAYS}d)`,
|
|
138
|
+
value: stale.length,
|
|
139
|
+
detail: stale.slice(0, 10).map(p => p.urlPath)
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function checkMissingMeta() {
|
|
144
|
+
const pages = await listPages().catch(() => []);
|
|
145
|
+
const missing = pages.filter(p => !p.description || String(p.description).trim() === '');
|
|
146
|
+
return {
|
|
147
|
+
id: 'missing-meta',
|
|
148
|
+
level: missing.length === 0 ? 'ok' : 'warn',
|
|
149
|
+
label: 'Pages missing description',
|
|
150
|
+
value: missing.length,
|
|
151
|
+
detail: missing.slice(0, 10).map(p => p.urlPath)
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function checkLastForm() {
|
|
156
|
+
let formsExist = false;
|
|
157
|
+
try {
|
|
158
|
+
const formFiles = await fs.readdir(FORMS_DIR);
|
|
159
|
+
formsExist = formFiles.some(f => f.endsWith('.json'));
|
|
160
|
+
} catch { /* no forms dir */ }
|
|
161
|
+
|
|
162
|
+
const collectionsDir = path.join(ROOT, 'content', 'collections');
|
|
163
|
+
let newestIso = '';
|
|
164
|
+
let dirs = [];
|
|
165
|
+
try { dirs = await fs.readdir(collectionsDir, { withFileTypes: true }); } catch { /* none */ }
|
|
166
|
+
for (const d of dirs) {
|
|
167
|
+
if (!d.isDirectory()) continue;
|
|
168
|
+
const dataFile = path.join(collectionsDir, d.name, 'data.json');
|
|
169
|
+
let entries = [];
|
|
170
|
+
try { entries = JSON.parse(await fs.readFile(dataFile, 'utf8')); }
|
|
171
|
+
catch { continue; }
|
|
172
|
+
if (!Array.isArray(entries)) continue;
|
|
173
|
+
for (const e of entries) {
|
|
174
|
+
const at = e?.meta?.createdAt;
|
|
175
|
+
if (typeof at === 'string' && at > newestIso) newestIso = at;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!formsExist) {
|
|
180
|
+
return { id: 'last-form', level: 'ok', label: 'Last form submission', value: 'no forms defined' };
|
|
181
|
+
}
|
|
182
|
+
if (!newestIso) {
|
|
183
|
+
return { id: 'last-form', level: 'warn', label: 'Last form submission', value: 'never' };
|
|
184
|
+
}
|
|
185
|
+
const ageDays = (Date.now() - new Date(newestIso).getTime()) / (24 * 60 * 60 * 1000);
|
|
186
|
+
return {
|
|
187
|
+
id: 'last-form',
|
|
188
|
+
level: ageDays > FORM_WARN_DAYS ? 'warn' : 'ok',
|
|
189
|
+
label: 'Last form submission',
|
|
190
|
+
value: newestIso
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function checkSmtp() {
|
|
195
|
+
const r = getLastSendResult();
|
|
196
|
+
if (!r) return { id: 'smtp', level: 'ok', label: 'Last email send', value: 'never sent' };
|
|
197
|
+
return {
|
|
198
|
+
id: 'smtp',
|
|
199
|
+
level: r.ok ? 'ok' : 'fail',
|
|
200
|
+
label: 'Last email send',
|
|
201
|
+
value: r.at,
|
|
202
|
+
detail: r.info ? [r.info] : []
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function checkPluginLoad() {
|
|
207
|
+
const failures = getPluginLoadFailures();
|
|
208
|
+
return {
|
|
209
|
+
id: 'plugin-load',
|
|
210
|
+
level: failures.length === 0 ? 'ok' : 'fail',
|
|
211
|
+
label: 'Plugin failures',
|
|
212
|
+
value: failures.length,
|
|
213
|
+
detail: failures.slice(0, 10).map(f => `${f.name}: ${f.reason}`)
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// Orchestrator
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Run a single health check, isolating any thrown errors.
|
|
223
|
+
*
|
|
224
|
+
* If the check function throws, the failure is degraded to a `fail`-level
|
|
225
|
+
* result for that check rather than propagating and killing the aggregate.
|
|
226
|
+
*
|
|
227
|
+
* @param {string} label
|
|
228
|
+
* @param {() => Promise<object>} fn
|
|
229
|
+
* @returns {Promise<object>}
|
|
230
|
+
*/
|
|
231
|
+
async function safeCheck(label, fn) {
|
|
232
|
+
try {
|
|
233
|
+
return await fn();
|
|
234
|
+
} catch (err) {
|
|
235
|
+
return {
|
|
236
|
+
id: label,
|
|
237
|
+
level: 'fail',
|
|
238
|
+
label,
|
|
239
|
+
value: 'check failed',
|
|
240
|
+
detail: [String(err.message || err)]
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Aggregate site + server health checks.
|
|
247
|
+
*
|
|
248
|
+
* Returns the cached result if it's within the 30 s TTL; otherwise runs all eight
|
|
249
|
+
* checks in parallel and caches the result. Per-check errors are isolated — an
|
|
250
|
+
* unexpected throw degrades only that check to `fail`, never the whole call.
|
|
251
|
+
*
|
|
252
|
+
* Note: no in-flight deduplication. Two concurrent cache misses will both run the
|
|
253
|
+
* eight checks. Acceptable for the dashboard's traffic profile; revisit if the
|
|
254
|
+
* checks become heavy or the cache TTL drops.
|
|
255
|
+
*
|
|
256
|
+
* @returns {Promise<{status: 'ok'|'warn'|'fail', checks: Array}>}
|
|
257
|
+
*/
|
|
258
|
+
export async function getHealth() {
|
|
259
|
+
if (cached.value && Date.now() - cached.at < CACHE_TTL_MS) return cached.value;
|
|
260
|
+
const checks = await Promise.all([
|
|
261
|
+
safeCheck('uptime', checkUptime),
|
|
262
|
+
safeCheck('memory', checkMemory),
|
|
263
|
+
safeCheck('disk-content', checkContentDisk),
|
|
264
|
+
safeCheck('stale-pages', checkStalePages),
|
|
265
|
+
safeCheck('missing-meta', checkMissingMeta),
|
|
266
|
+
safeCheck('last-form', checkLastForm),
|
|
267
|
+
safeCheck('smtp', checkSmtp),
|
|
268
|
+
safeCheck('plugin-load', checkPluginLoad)
|
|
269
|
+
]);
|
|
270
|
+
const value = { status: worstLevel(checks), checks };
|
|
271
|
+
cached = { at: Date.now(), value };
|
|
272
|
+
return value;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export { worstLevel };
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Reset the in-memory cache. Test-only helper.
|
|
279
|
+
*
|
|
280
|
+
* @returns {void}
|
|
281
|
+
*/
|
|
282
|
+
export function _resetCache() { cached = { at: 0, value: null }; }
|
|
@@ -247,7 +247,7 @@ async function getBlockShortTagNames() {
|
|
|
247
247
|
* @param {string} content
|
|
248
248
|
* @returns {Promise<string>}
|
|
249
249
|
*/
|
|
250
|
-
async function processStaticBlocks(content) {
|
|
250
|
+
async function processStaticBlocks(content, tagSet) {
|
|
251
251
|
// --- [block template="name" .../] ---
|
|
252
252
|
const pattern = /\[block\s+([\s\S]+?)\/\]/g;
|
|
253
253
|
const matches = [...content.matchAll(pattern)];
|
|
@@ -262,6 +262,7 @@ async function processStaticBlocks(content) {
|
|
|
262
262
|
output = output.slice(0, match.index) + output.slice(match.index + match[0].length);
|
|
263
263
|
continue;
|
|
264
264
|
}
|
|
265
|
+
tagSet?.add(`block:${attrs.template}`);
|
|
265
266
|
let replacement = '';
|
|
266
267
|
try {
|
|
267
268
|
const [tpl, css] = await Promise.all([
|
|
@@ -287,6 +288,7 @@ async function processStaticBlocks(content) {
|
|
|
287
288
|
for (const [, key, dq, sq] of (match[1] ?? '').matchAll(/([\w-]+)=(?:"([^"]*)"|'([^']*)')/g)) {
|
|
288
289
|
attrs[key] = dq ?? sq ?? '';
|
|
289
290
|
}
|
|
291
|
+
tagSet?.add(`block:${tagName}`);
|
|
290
292
|
let replacement = '';
|
|
291
293
|
try {
|
|
292
294
|
const [tpl, css] = await Promise.all([
|
|
@@ -458,7 +460,7 @@ function renderCollectionTimeline(entries, opts) {
|
|
|
458
460
|
* @param {string} markdown
|
|
459
461
|
* @returns {Promise<string>}
|
|
460
462
|
*/
|
|
461
|
-
async function processViewBlocks(markdown) {
|
|
463
|
+
async function processViewBlocks(markdown, tagSet) {
|
|
462
464
|
const {scrubbed, restore} = scrubCodeRegions(markdown);
|
|
463
465
|
const pattern = /\[view([^\]]*?)\/\]/gi;
|
|
464
466
|
const matches = [...scrubbed.matchAll(pattern)];
|
|
@@ -474,6 +476,7 @@ async function processViewBlocks(markdown) {
|
|
|
474
476
|
result = result.replace(fullMatch, '');
|
|
475
477
|
continue;
|
|
476
478
|
}
|
|
479
|
+
tagSet?.add(`view:${slug}`);
|
|
477
480
|
|
|
478
481
|
const displayAttr = attrs.display || '';
|
|
479
482
|
const emptyMsg = attrs.empty || 'No results found';
|
|
@@ -579,7 +582,7 @@ async function processViewBlocks(markdown) {
|
|
|
579
582
|
* @param {string} markdown
|
|
580
583
|
* @returns {Promise<string>}
|
|
581
584
|
*/
|
|
582
|
-
async function processCollectionBlocks(markdown) {
|
|
585
|
+
async function processCollectionBlocks(markdown, tagSet) {
|
|
583
586
|
const {scrubbed, restore} = scrubCodeRegions(markdown);
|
|
584
587
|
// Find all [collection ...] shortcodes
|
|
585
588
|
const pattern = /\[collection([^\]]*?)\/\]/gi;
|
|
@@ -596,6 +599,7 @@ async function processCollectionBlocks(markdown) {
|
|
|
596
599
|
result = result.replace(fullMatch, '');
|
|
597
600
|
continue;
|
|
598
601
|
}
|
|
602
|
+
tagSet?.add(`collection:${slug}`);
|
|
599
603
|
|
|
600
604
|
const display = attrs.display || 'table';
|
|
601
605
|
const limitAttr = parseInt(attrs.limit, 10) || 0;
|
|
@@ -1947,11 +1951,13 @@ function processAccordionBlocks(markdown) {
|
|
|
1947
1951
|
* [/carousel]
|
|
1948
1952
|
*
|
|
1949
1953
|
* Supported attributes on [carousel]:
|
|
1950
|
-
* autoplay
|
|
1951
|
-
* interval
|
|
1952
|
-
* loop
|
|
1953
|
-
* animation
|
|
1954
|
-
*
|
|
1954
|
+
* autoplay - "true" to auto-advance slides
|
|
1955
|
+
* interval - milliseconds between slides (default 5000)
|
|
1956
|
+
* loop - "false" to disable loop (default true)
|
|
1957
|
+
* animation - "slide" | "fade" | "crossfade" (default slide)
|
|
1958
|
+
* animation-duration - transition length in milliseconds (default 500)
|
|
1959
|
+
* animation-easing - CSS timing function (e.g. ease, linear, ease-in-out, cubic-bezier(...))
|
|
1960
|
+
* id - optional id on the wrapper
|
|
1955
1961
|
*
|
|
1956
1962
|
* Supported attributes on [slide]:
|
|
1957
1963
|
* image - URL of a background/header image
|
|
@@ -1970,7 +1976,9 @@ function processCarouselBlocks(markdown) {
|
|
|
1970
1976
|
attrs.autoplay ? ` data-autoplay="${escapeAttr(attrs.autoplay)}"` : '',
|
|
1971
1977
|
attrs.interval ? ` data-interval="${escapeAttr(attrs.interval)}"` : '',
|
|
1972
1978
|
attrs.loop ? ` data-loop="${escapeAttr(attrs.loop)}"` : '',
|
|
1973
|
-
attrs.animation ? ` data-animation="${escapeAttr(attrs.animation)}"` : ''
|
|
1979
|
+
attrs.animation ? ` data-animation="${escapeAttr(attrs.animation)}"` : '',
|
|
1980
|
+
attrs['animation-duration'] ? ` data-animation-duration="${escapeAttr(attrs['animation-duration'])}"` : '',
|
|
1981
|
+
attrs['animation-easing'] ? ` data-animation-easing="${escapeAttr(attrs['animation-easing'])}"` : ''
|
|
1974
1982
|
].join('');
|
|
1975
1983
|
const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
|
|
1976
1984
|
|
|
@@ -2617,7 +2625,7 @@ function processIconBlocks(markdown) {
|
|
|
2617
2625
|
*/
|
|
2618
2626
|
const FORMS_DIR = path.resolve(__dirname_md, '../../content/forms');
|
|
2619
2627
|
|
|
2620
|
-
async function processFormBlocks(markdown) {
|
|
2628
|
+
async function processFormBlocks(markdown, tagSet) {
|
|
2621
2629
|
const {scrubbed, restore} = scrubCodeRegions(markdown);
|
|
2622
2630
|
const regex = /\[form([^\]]*?)\/\]/gi;
|
|
2623
2631
|
let result = scrubbed;
|
|
@@ -2638,6 +2646,7 @@ async function processFormBlocks(markdown) {
|
|
|
2638
2646
|
result = result.slice(0, index) + `<div class="cms-form-error">Invalid form slug: ${escapeAttr(slug)}</div>` + result.slice(index + full.length);
|
|
2639
2647
|
continue;
|
|
2640
2648
|
}
|
|
2649
|
+
tagSet?.add(`form:${slug}`);
|
|
2641
2650
|
let replacement;
|
|
2642
2651
|
try {
|
|
2643
2652
|
const filePath = path.resolve(FORMS_DIR, `${slug}.json`);
|
|
@@ -2916,9 +2925,10 @@ export async function parseMarkdown(raw) {
|
|
|
2916
2925
|
// → grid → card → slideover → marked → sanitize → afterParse
|
|
2917
2926
|
const preprocessed = applyTransforms('markdown:beforeParse', content);
|
|
2918
2927
|
const {output: withComponents, used: usedComponents} = collectAndRewriteComponents(preprocessed);
|
|
2919
|
-
const
|
|
2920
|
-
const
|
|
2921
|
-
const
|
|
2928
|
+
const tagSet = new Set();
|
|
2929
|
+
const withCollection = await processCollectionBlocks(withComponents, tagSet);
|
|
2930
|
+
const withView = await processViewBlocks(withCollection, tagSet);
|
|
2931
|
+
const withStaticBlock = await processStaticBlocks(withView, tagSet);
|
|
2922
2932
|
const withDconfig = processDConfigBlocks(withStaticBlock);
|
|
2923
2933
|
const withEffects = processEffectsBlocks(withDconfig);
|
|
2924
2934
|
const withPluginShortcodes = await processPluginShortcodes(withEffects);
|
|
@@ -2931,7 +2941,7 @@ export async function parseMarkdown(raw) {
|
|
|
2931
2941
|
const withSpacer = processSpacerBlocks(withListGroup);
|
|
2932
2942
|
const withCenter = processCenterBlocks(withSpacer);
|
|
2933
2943
|
const withIcon = processIconBlocks(withCenter);
|
|
2934
|
-
const withForm = await processFormBlocks(withIcon);
|
|
2944
|
+
const withForm = await processFormBlocks(withIcon, tagSet);
|
|
2935
2945
|
const withHero = processHeroBlocks(withForm);
|
|
2936
2946
|
const withTable = processTableBlocks(withHero);
|
|
2937
2947
|
const withBadge = processBadgeBlocks(withTable);
|
|
@@ -2997,7 +3007,7 @@ export async function parseMarkdown(raw) {
|
|
|
2997
3007
|
const allowed = new Set(_dmTagAllowlist.map(t => t.replace(/^dm-/, '')));
|
|
2998
3008
|
const filteredUsed = [...usedComponents].filter(name => allowed.has(name));
|
|
2999
3009
|
|
|
3000
|
-
return {data, content, html, usedComponents: filteredUsed};
|
|
3010
|
+
return {data, content, html, usedComponents: filteredUsed, tags: [...tagSet]};
|
|
3001
3011
|
}
|
|
3002
3012
|
|
|
3003
3013
|
/**
|
|
@@ -23,6 +23,28 @@ const PLUGINS_DIR = path.resolve('plugins');
|
|
|
23
23
|
|
|
24
24
|
const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'version', 'description', 'author', 'date', 'icon'];
|
|
25
25
|
|
|
26
|
+
const failedLoads = [];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Record a plugin load failure for health reporting.
|
|
30
|
+
*
|
|
31
|
+
* @param {string} name
|
|
32
|
+
* @param {string} reason
|
|
33
|
+
* @returns {void}
|
|
34
|
+
*/
|
|
35
|
+
export function recordPluginLoadFailure(name, reason) {
|
|
36
|
+
failedLoads.push({ name, reason, at: new Date().toISOString() });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Return a snapshot of all recorded plugin load failures.
|
|
41
|
+
*
|
|
42
|
+
* @returns {Array<{ name: string, reason: string, at: string }>}
|
|
43
|
+
*/
|
|
44
|
+
export function getPluginLoadFailures() {
|
|
45
|
+
return failedLoads.slice();
|
|
46
|
+
}
|
|
47
|
+
|
|
26
48
|
/**
|
|
27
49
|
* In-memory registry of loaded plugins, populated by registerPlugins().
|
|
28
50
|
* Keyed by plugin name; each entry mirrors the manifest plus runtime metadata.
|
|
@@ -71,7 +93,9 @@ export async function discoverPlugins() {
|
|
|
71
93
|
let valid = true;
|
|
72
94
|
for (const field of REQUIRED_MANIFEST_FIELDS) {
|
|
73
95
|
if (!manifest[field]) {
|
|
74
|
-
|
|
96
|
+
const reason = `missing required field "${field}" in plugin.json`;
|
|
97
|
+
console.warn(`Plugin "${dir}": ${reason} — skipping`);
|
|
98
|
+
recordPluginLoadFailure(dir, reason);
|
|
75
99
|
valid = false;
|
|
76
100
|
break;
|
|
77
101
|
}
|
|
@@ -85,14 +109,18 @@ export async function discoverPlugins() {
|
|
|
85
109
|
try {
|
|
86
110
|
await fs.access(pluginJsPath);
|
|
87
111
|
} catch {
|
|
88
|
-
|
|
112
|
+
const reason = 'missing required file plugin.js';
|
|
113
|
+
console.warn(`Plugin "${dir}": ${reason} — skipping`);
|
|
114
|
+
recordPluginLoadFailure(dir, reason);
|
|
89
115
|
continue;
|
|
90
116
|
}
|
|
91
117
|
|
|
92
118
|
try {
|
|
93
119
|
await fs.access(configJsPath);
|
|
94
120
|
} catch {
|
|
95
|
-
|
|
121
|
+
const reason = 'missing required file config.js';
|
|
122
|
+
console.warn(`Plugin "${dir}": ${reason} — skipping`);
|
|
123
|
+
recordPluginLoadFailure(dir, reason);
|
|
96
124
|
continue;
|
|
97
125
|
}
|
|
98
126
|
|
|
@@ -118,7 +146,9 @@ async function loadConfigDefaults(name) {
|
|
|
118
146
|
const {default: exported} = await import(configJsPath);
|
|
119
147
|
defaults = exported || {};
|
|
120
148
|
} catch (err) {
|
|
121
|
-
|
|
149
|
+
const reason = `failed to load config.js — ${err.message}`;
|
|
150
|
+
console.warn(`Plugin "${name}": ${reason}`);
|
|
151
|
+
recordPluginLoadFailure(name, reason);
|
|
122
152
|
}
|
|
123
153
|
_configDefaultsCache.set(name, defaults);
|
|
124
154
|
return defaults;
|
|
@@ -194,7 +224,9 @@ export async function registerPlugins(fastify) {
|
|
|
194
224
|
// no public entry — that's fine
|
|
195
225
|
}
|
|
196
226
|
} catch (err) {
|
|
197
|
-
|
|
227
|
+
const reason = `server failed to load: ${err.message}`;
|
|
228
|
+
fastify.log.error(`Plugin "${manifest.name}" ${reason}`);
|
|
229
|
+
recordPluginLoadFailure(manifest.name, reason);
|
|
198
230
|
}
|
|
199
231
|
}
|
|
200
232
|
|
package/server/services/views.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import {v4 as uuidv4} from 'uuid';
|
|
16
16
|
import {buildRowLevelMatch} from './rowAccess.js';
|
|
17
|
+
import * as cache from './cache/index.js';
|
|
17
18
|
|
|
18
19
|
/** MongoDB collection where view configs are stored. */
|
|
19
20
|
const VIEWS_COLLECTION = 'cms__views';
|
|
@@ -173,6 +174,7 @@ export async function createView(data, userId = null) {
|
|
|
173
174
|
};
|
|
174
175
|
|
|
175
176
|
await db.collection(VIEWS_COLLECTION).insertOne({ ...view });
|
|
177
|
+
await cache.invalidateTags([`view:${slug}`]);
|
|
176
178
|
return view;
|
|
177
179
|
}
|
|
178
180
|
|
|
@@ -206,6 +208,7 @@ export async function updateView(slug, data) {
|
|
|
206
208
|
};
|
|
207
209
|
|
|
208
210
|
await db.collection(VIEWS_COLLECTION).replaceOne({ slug }, { ...updated });
|
|
211
|
+
await cache.invalidateTags([`view:${slug}`]);
|
|
209
212
|
const { _id: _stripped, ...result } = updated;
|
|
210
213
|
return result;
|
|
211
214
|
}
|
|
@@ -221,6 +224,7 @@ export async function deleteView(slug) {
|
|
|
221
224
|
const db = await getMetaDb();
|
|
222
225
|
const result = await db.collection(VIEWS_COLLECTION).deleteOne({ slug });
|
|
223
226
|
if (result.deletedCount === 0) throw new Error(`View "${slug}" not found`);
|
|
227
|
+
await cache.invalidateTags([`view:${slug}`]);
|
|
224
228
|
}
|
|
225
229
|
|
|
226
230
|
// ---------------------------------------------------------------------------
|