domma-cms 0.15.0 → 0.17.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 (65) hide show
  1. package/admin/css/dashboard.css +1 -0
  2. package/admin/dist/domma/domma-tools.css +3 -3
  3. package/admin/dist/domma/domma-tools.min.js +4 -4
  4. package/admin/index.html +1 -0
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +1 -1
  7. package/admin/js/templates/dashboard/activity-feed.html +3 -0
  8. package/admin/js/templates/dashboard/health-detail.html +2 -0
  9. package/admin/js/templates/dashboard/journeys.html +17 -0
  10. package/admin/js/templates/dashboard/kpi-strip.html +34 -0
  11. package/admin/js/templates/dashboard/spike-feed.html +3 -0
  12. package/admin/js/templates/dashboard/top-pages.html +3 -0
  13. package/admin/js/templates/dashboard/traffic-chart.html +3 -0
  14. package/admin/js/templates/dashboard.html +22 -44
  15. package/admin/js/views/dashboard/lib/escape.js +1 -0
  16. package/admin/js/views/dashboard/widgets/activity-feed.js +1 -0
  17. package/admin/js/views/dashboard/widgets/health-detail.js +1 -0
  18. package/admin/js/views/dashboard/widgets/journeys.js +1 -0
  19. package/admin/js/views/dashboard/widgets/kpi-strip.js +1 -0
  20. package/admin/js/views/dashboard/widgets/spike-feed.js +6 -0
  21. package/admin/js/views/dashboard/widgets/top-pages.js +1 -0
  22. package/admin/js/views/dashboard/widgets/traffic-chart.js +1 -0
  23. package/admin/js/views/dashboard.js +1 -1
  24. package/admin/js/views/form-editor.js +7 -7
  25. package/admin/js/views/index.js +1 -1
  26. package/config/plugins.json +3 -0
  27. package/package.json +2 -2
  28. package/plugins/analytics/admin/templates/analytics.html +52 -1
  29. package/plugins/analytics/admin/views/analytics.js +157 -32
  30. package/plugins/analytics/config.js +10 -2
  31. package/plugins/analytics/daily.json +5 -0
  32. package/plugins/analytics/journeys.json +10 -0
  33. package/plugins/analytics/lifetime.json +25 -0
  34. package/plugins/analytics/plugin.js +429 -25
  35. package/plugins/analytics/plugin.json +9 -5
  36. package/plugins/analytics/public/inject-body.html +49 -7
  37. package/plugins/blog/admin/templates/blog.html +25 -2
  38. package/plugins/blog/admin/views/blog.js +72 -56
  39. package/plugins/blog/admin/views/post-editor.js +98 -79
  40. package/plugins/blog/plugin.js +133 -0
  41. package/plugins/blog/plugin.json +3 -3
  42. package/plugins/blog/templates/post.html +2 -1
  43. package/plugins/invoice/admin/templates/editor.html +129 -0
  44. package/plugins/invoice/admin/templates/index.html +43 -0
  45. package/plugins/invoice/admin/templates/issuers.html +5 -0
  46. package/plugins/invoice/admin/templates/receivers.html +5 -0
  47. package/plugins/invoice/admin/views/editor.js +267 -0
  48. package/plugins/invoice/admin/views/index.js +155 -0
  49. package/plugins/invoice/admin/views/issuers.js +23 -0
  50. package/plugins/invoice/admin/views/party-view.js +148 -0
  51. package/plugins/invoice/admin/views/receivers.js +22 -0
  52. package/plugins/invoice/collections/invoice-issuers/schema.json +16 -0
  53. package/plugins/invoice/collections/invoice-receivers/schema.json +15 -0
  54. package/plugins/invoice/collections/invoices/schema.json +27 -0
  55. package/plugins/invoice/config.js +16 -0
  56. package/plugins/invoice/plugin.js +283 -0
  57. package/plugins/invoice/plugin.json +85 -0
  58. package/plugins/invoice/templates/invoice-print.html +213 -0
  59. package/server/routes/api/dashboard.js +239 -0
  60. package/server/server.js +2 -0
  61. package/server/services/email.js +60 -20
  62. package/server/services/health.js +282 -0
  63. package/server/services/markdown.js +24 -4
  64. package/server/services/plugins.js +37 -5
  65. package/server/services/renderer.js +9 -3
@@ -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 }; }
@@ -763,7 +763,7 @@ export function scrubCodeRegions(markdown) {
763
763
  * @param {string} markdown
764
764
  * @returns {string}
765
765
  */
766
- function processPluginShortcodes(markdown) {
766
+ async function processPluginShortcodes(markdown) {
767
767
  const processors = getShortcodeProcessors();
768
768
  if (!processors.length) return markdown;
769
769
 
@@ -771,14 +771,34 @@ function processPluginShortcodes(markdown) {
771
771
  let result = scrubbed;
772
772
  const context = {parseShortcodeAttrs, scrubCodeRegions, marked, processCardBlocks, processGridBlocks, escapeAttr};
773
773
 
774
+ async function replaceAsync(input, regex, mapMatch) {
775
+ const matches = [];
776
+ input.replace(regex, (...args) => {
777
+ const offset = args[args.length - 2];
778
+ matches.push({ args: args.slice(0, -2), full: args[0], offset });
779
+ return args[0];
780
+ });
781
+ if (!matches.length) return input;
782
+ const out = await Promise.all(matches.map((m) => Promise.resolve(mapMatch(...m.args))));
783
+ let res = '';
784
+ let lastEnd = 0;
785
+ matches.forEach((m, i) => {
786
+ res += input.slice(lastEnd, m.offset) + out[i];
787
+ lastEnd = m.offset + m.full.length;
788
+ });
789
+ return res + input.slice(lastEnd);
790
+ }
791
+
774
792
  for (const {name, handler} of processors) {
775
793
  // Self-closing: [name attrs /]
776
- result = result.replace(
794
+ result = await replaceAsync(
795
+ result,
777
796
  new RegExp(`\\[${name}([^\\]]*)\\s*\\/\\]`, 'gi'),
778
797
  (_, attrStr) => handler(attrStr, null, context)
779
798
  );
780
799
  // Wrapping: [name attrs]...[/name]
781
- result = result.replace(
800
+ result = await replaceAsync(
801
+ result,
782
802
  new RegExp(`\\[${name}([^\\]]*)\\]([\\s\\S]*?)\\[\\/${name}\\]`, 'gi'),
783
803
  (_, attrStr, body) => handler(attrStr, body, context)
784
804
  );
@@ -2901,7 +2921,7 @@ export async function parseMarkdown(raw) {
2901
2921
  const withStaticBlock = await processStaticBlocks(withView);
2902
2922
  const withDconfig = processDConfigBlocks(withStaticBlock);
2903
2923
  const withEffects = processEffectsBlocks(withDconfig);
2904
- const withPluginShortcodes = processPluginShortcodes(withEffects);
2924
+ const withPluginShortcodes = await processPluginShortcodes(withEffects);
2905
2925
  const withTabs = processTabsBlocks(withPluginShortcodes);
2906
2926
  const withAccordion = processAccordionBlocks(withTabs);
2907
2927
  const withCarousel = processCarouselBlocks(withAccordion);
@@ -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
 
@@ -312,11 +312,17 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
312
312
  const seoDescription = escapeHtml(seoMeta.description ?? site.seo?.defaultDescription ?? '');
313
313
  const ogImage = escapeHtml(seoMeta.ogImage ?? '');
314
314
 
315
+ const ogType = escapeHtml(seoMeta.ogType ?? 'website');
315
316
  const ogTags = [
316
317
  `<meta property="og:title" content="${seoTitle}">`,
317
318
  `<meta property="og:description" content="${seoDescription}">`,
318
- `<meta property="og:image" content="${ogImage}">`
319
- ].join('\n');
319
+ `<meta property="og:type" content="${ogType}">`,
320
+ ogImage ? `<meta property="og:image" content="${ogImage}">` : '',
321
+ `<meta name="twitter:card" content="${ogImage ? 'summary_large_image' : 'summary'}">`,
322
+ `<meta name="twitter:title" content="${seoTitle}">`,
323
+ `<meta name="twitter:description" content="${seoDescription}">`,
324
+ ogImage ? `<meta name="twitter:image" content="${ogImage}">` : ''
325
+ ].filter(Boolean).join('\n');
320
326
 
321
327
  const {fontLink, fontOverride} = buildFontVars(site.fontFamily, site.fontSize);
322
328
  const fontStyleTag = fontOverride ? `<style>${fontOverride}</style>` : '';
@@ -363,7 +369,7 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
363
369
  headInjectLate: [injection.headLate, customCssTag, navbarStyleTag].filter(Boolean).join('\n'),
364
370
  bodyEndInject: [
365
371
  injection.bodyEnd,
366
- (page.usedComponents || [])
372
+ (seoMeta.usedComponents || [])
367
373
  .map(n => `<script type="module" src="/api/components/${n}.js"></script>`)
368
374
  .join('\n')
369
375
  ].filter(Boolean).join('\n'),