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.
Files changed (67) hide show
  1. package/CLAUDE.md +2 -0
  2. package/admin/css/dashboard.css +1 -0
  3. package/admin/dist/domma/domma-tools.css +3 -3
  4. package/admin/dist/domma/domma-tools.min.js +4 -4
  5. package/admin/index.html +2 -1
  6. package/admin/js/api.js +1 -1
  7. package/admin/js/app.js +1 -1
  8. package/admin/js/lib/card-builder.js +3 -3
  9. package/admin/js/lib/effects-builder.js +1 -1
  10. package/admin/js/lib/markdown-toolbar.js +5 -5
  11. package/admin/js/templates/dashboard/activity-feed.html +3 -0
  12. package/admin/js/templates/dashboard/cache.html +32 -0
  13. package/admin/js/templates/dashboard/health-detail.html +2 -0
  14. package/admin/js/templates/dashboard/journeys.html +17 -0
  15. package/admin/js/templates/dashboard/kpi-strip.html +34 -0
  16. package/admin/js/templates/dashboard/spike-feed.html +3 -0
  17. package/admin/js/templates/dashboard/top-pages.html +3 -0
  18. package/admin/js/templates/dashboard/traffic-chart.html +3 -0
  19. package/admin/js/templates/dashboard.html +26 -44
  20. package/admin/js/templates/settings.html +26 -0
  21. package/admin/js/views/block-editor-enhance.js +1 -1
  22. package/admin/js/views/dashboard/lib/escape.js +1 -0
  23. package/admin/js/views/dashboard/widgets/activity-feed.js +1 -0
  24. package/admin/js/views/dashboard/widgets/cache.js +1 -0
  25. package/admin/js/views/dashboard/widgets/health-detail.js +1 -0
  26. package/admin/js/views/dashboard/widgets/journeys.js +1 -0
  27. package/admin/js/views/dashboard/widgets/kpi-strip.js +1 -0
  28. package/admin/js/views/dashboard/widgets/spike-feed.js +6 -0
  29. package/admin/js/views/dashboard/widgets/top-pages.js +1 -0
  30. package/admin/js/views/dashboard/widgets/traffic-chart.js +1 -0
  31. package/admin/js/views/dashboard.js +1 -1
  32. package/admin/js/views/form-editor.js +7 -7
  33. package/admin/js/views/index.js +1 -1
  34. package/admin/js/views/page-editor.js +42 -37
  35. package/admin/js/views/settings.js +3 -3
  36. package/config/cache.json +4 -0
  37. package/config/cache.json.example +12 -0
  38. package/config/plugins.json +3 -0
  39. package/package.json +2 -2
  40. package/plugins/analytics/daily.json +5 -0
  41. package/plugins/analytics/journeys.json +10 -0
  42. package/plugins/analytics/lifetime.json +25 -0
  43. package/plugins/analytics/plugin.js +231 -16
  44. package/plugins/analytics/public/inject-body.html +26 -2
  45. package/public/js/forms.js +1 -1
  46. package/public/js/site.js +1 -1
  47. package/server/config.js +12 -1
  48. package/server/routes/api/cache.js +57 -0
  49. package/server/routes/api/dashboard.js +239 -0
  50. package/server/routes/api/navigation.js +2 -0
  51. package/server/routes/api/settings.js +3 -0
  52. package/server/routes/public.js +11 -3
  53. package/server/server.js +18 -3
  54. package/server/services/blocks.js +3 -0
  55. package/server/services/cache/drivers/MemoryDriver.js +118 -0
  56. package/server/services/cache/drivers/NoneDriver.js +12 -0
  57. package/server/services/cache/index.js +229 -0
  58. package/server/services/cache/lru.js +61 -0
  59. package/server/services/collections.js +17 -4
  60. package/server/services/content.js +7 -2
  61. package/server/services/email.js +60 -20
  62. package/server/services/forms.js +3 -0
  63. package/server/services/health.js +282 -0
  64. package/server/services/markdown.js +25 -15
  65. package/server/services/plugins.js +37 -5
  66. package/server/services/views.js +4 -0
  67. 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 - "true" to auto-advance slides
1951
- * interval - milliseconds between slides (default 5000)
1952
- * loop - "false" to disable loop (default true)
1953
- * animation - "fade" or "slide" (default slide)
1954
- * id - optional id on the wrapper
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 withCollection = await processCollectionBlocks(withComponents);
2920
- const withView = await processViewBlocks(withCollection);
2921
- const withStaticBlock = await processStaticBlocks(withView);
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
- console.warn(`Plugin "${dir}": missing required field "${field}" in plugin.json — skipping`);
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
- console.warn(`Plugin "${dir}": missing required file plugin.js — skipping`);
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
- console.warn(`Plugin "${dir}": missing required file config.js — skipping`);
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
- console.warn(`Plugin "${name}": failed to load config.js — ${err.message}`);
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
- fastify.log.error(`Plugin "${manifest.name}" server failed to load: ${err.message}`);
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
 
@@ -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
  // ---------------------------------------------------------------------------