domma-cms 0.21.0 → 0.22.2

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.
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Asset Import Verifier — build/publish gate
3
+ *
4
+ * Statically resolves every local ESM import under the browser-facing code
5
+ * (admin/ + public/) against the files that actually exist, and fails if any
6
+ * specifier points at a missing file ("dangling import").
7
+ *
8
+ * Why this exists
9
+ * ---------------
10
+ * The admin SPA and public site ship UNMINIFIED-then-minified ESM that browsers
11
+ * import natively (no bundler). If a release ships consumer code that imports a
12
+ * new module but forgets to include the module file (as 0.22.1 did with
13
+ * /public/js/menu-decor.mjs), every page 404s on the import and the whole site
14
+ * breaks — with no signal but a browser console error, and it cascades to every
15
+ * downstream project that copies these dirs on update. A bundler would catch
16
+ * this at build time; because we don't bundle, this gate stands in for that.
17
+ *
18
+ * Usage
19
+ * -----
20
+ * node scripts/verify-assets.mjs [root] # default root: repo root
21
+ * Exits 1 (with a report) on any dangling import, 0 otherwise. Wired into
22
+ * scripts/build.js so `make pack` / `make release-npm` cannot ship a broken set.
23
+ *
24
+ * Scope (deliberately conservative — zero false positives is the whole point):
25
+ * only STATIC, LOCAL specifiers with an explicit extension are checked — the
26
+ * only kind this codebase emits. Bare (npm) specifiers, URLs, dynamic/template
27
+ * specifiers, commented-out imports, and extension-less paths are all ignored.
28
+ */
29
+
30
+ import path from 'node:path';
31
+ import fs from 'node:fs/promises';
32
+ import { existsSync } from 'node:fs';
33
+
34
+ const SCAN_EXTS = new Set(['.js', '.mjs']);
35
+ const CHECK_EXTS = new Set(['.js', '.mjs', '.css']);
36
+
37
+ const FROM_RE = /\bfrom\s*(['"])([^'"]+)\1/g;
38
+ const SIDE_EFFECT_RE = /(?:^|[;{}\s])import\s*(['"])([^'"]+)\1/g;
39
+ const DYNAMIC_RE = /\bimport\s*\(\s*(['"])([^'"]+)\1\s*\)/g;
40
+
41
+ function isCheckableSpecifier(spec) {
42
+ if (typeof spec !== 'string' || !spec) return false;
43
+ if (!(spec.startsWith('/') || spec.startsWith('./') || spec.startsWith('../'))) return false;
44
+ const clean = spec.replace(/[?#].*$/, '');
45
+ return CHECK_EXTS.has(path.extname(clean).toLowerCase());
46
+ }
47
+
48
+ function resolveSpecifier(spec, importer, projectRoot) {
49
+ const clean = spec.replace(/[?#].*$/, '');
50
+ if (clean.startsWith('/')) return path.join(projectRoot, clean); // served /public/... → root/public/...
51
+ return path.resolve(path.dirname(importer), clean);
52
+ }
53
+
54
+ /**
55
+ * Strip // and /* *\/ comments while preserving string-literal bodies, so a
56
+ * commented-out import isn't flagged and a "https://…" URL isn't mistaken for a
57
+ * comment. Both are false positives, fatal for a publish gate.
58
+ */
59
+ function stripComments(source) {
60
+ let out = '';
61
+ let state = 'code';
62
+ for (let i = 0; i < source.length; i++) {
63
+ const c = source[i];
64
+ const n = source[i + 1];
65
+ switch (state) {
66
+ case 'code':
67
+ if (c === '/' && n === '/') { state = 'line'; i++; }
68
+ else if (c === '/' && n === '*') { state = 'block'; i++; }
69
+ else if (c === "'") { state = 'single'; out += c; }
70
+ else if (c === '"') { state = 'double'; out += c; }
71
+ else if (c === '`') { state = 'template'; out += c; }
72
+ else out += c;
73
+ break;
74
+ case 'line':
75
+ if (c === '\n') { state = 'code'; out += c; }
76
+ break;
77
+ case 'block':
78
+ if (c === '*' && n === '/') { state = 'code'; i++; }
79
+ break;
80
+ case 'single':
81
+ case 'double':
82
+ case 'template': {
83
+ out += c;
84
+ const quote = state === 'single' ? "'" : state === 'double' ? '"' : '`';
85
+ if (c === '\\') { out += source[i + 1] ?? ''; i++; }
86
+ else if (c === quote) state = 'code';
87
+ break;
88
+ }
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+
94
+ function extractSpecifiers(rawSource) {
95
+ const source = stripComments(rawSource);
96
+ const out = new Set();
97
+ for (const re of [FROM_RE, SIDE_EFFECT_RE, DYNAMIC_RE]) {
98
+ re.lastIndex = 0;
99
+ let m;
100
+ while ((m = re.exec(source)) !== null) {
101
+ if (isCheckableSpecifier(m[2])) out.add(m[2]);
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+
107
+ async function listScannable(dir) {
108
+ const result = [];
109
+ let entries;
110
+ try {
111
+ entries = await fs.readdir(dir, { withFileTypes: true });
112
+ } catch {
113
+ return result;
114
+ }
115
+ for (const entry of entries) {
116
+ const full = path.join(dir, entry.name);
117
+ let isDir = entry.isDirectory();
118
+ let isFile = entry.isFile();
119
+ if (entry.isSymbolicLink()) {
120
+ try {
121
+ const st = await fs.stat(full);
122
+ isDir = st.isDirectory();
123
+ isFile = st.isFile();
124
+ } catch { continue; }
125
+ }
126
+ if (isDir) {
127
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
128
+ result.push(...await listScannable(full));
129
+ } else if (isFile && SCAN_EXTS.has(path.extname(entry.name).toLowerCase())) {
130
+ result.push(full);
131
+ }
132
+ }
133
+ return result;
134
+ }
135
+
136
+ export async function verifyAssetImports({ projectRoot, roots = ['admin', 'public'] }) {
137
+ const missing = [];
138
+ let scanned = 0;
139
+ for (const rel of roots) {
140
+ const rootAbs = path.join(projectRoot, rel);
141
+ if (!existsSync(rootAbs)) continue;
142
+ for (const file of await listScannable(rootAbs)) {
143
+ scanned++;
144
+ let source;
145
+ try { source = await fs.readFile(file, 'utf8'); } catch { continue; }
146
+ for (const spec of extractSpecifiers(source)) {
147
+ const resolved = resolveSpecifier(spec, file, projectRoot);
148
+ if (!existsSync(resolved)) {
149
+ missing.push({
150
+ importer: path.relative(projectRoot, file),
151
+ specifier: spec,
152
+ resolved: path.relative(projectRoot, resolved),
153
+ });
154
+ }
155
+ }
156
+ }
157
+ }
158
+ return { ok: missing.length === 0, scanned, missing };
159
+ }
160
+
161
+ export function formatReport(result, label) {
162
+ if (result.ok) {
163
+ return `[verify-assets] OK — ${result.scanned} files scanned, all local imports resolve (${label}).`;
164
+ }
165
+ const lines = [
166
+ '',
167
+ ' ╔════════════════════════════════════════════════════════════════╗',
168
+ ' ║ ASSET INTEGRITY FAILURE — dangling front-end imports detected ║',
169
+ ' ╚════════════════════════════════════════════════════════════════╝',
170
+ ` Context: ${label}`,
171
+ ` ${result.missing.length} import(s) reference files that do not exist:`,
172
+ '',
173
+ ];
174
+ for (const m of result.missing) {
175
+ lines.push(` ✗ ${m.importer}`);
176
+ lines.push(` imports "${m.specifier}" → missing ${m.resolved}`);
177
+ }
178
+ lines.push('');
179
+ lines.push(' A page that loads one of these modules will 404 and fail to render.');
180
+ lines.push(' Add the missing file (or remove the import) before building/publishing.');
181
+ lines.push('');
182
+ return lines.join('\n');
183
+ }
184
+
185
+ if (import.meta.url === `file://${process.argv[1]}`) {
186
+ const projectRoot = path.resolve(process.argv[2] || process.cwd());
187
+ const result = await verifyAssetImports({ projectRoot });
188
+ console.log(formatReport(result, projectRoot));
189
+ process.exit(result.ok ? 0 : 1);
190
+ }
@@ -11,7 +11,7 @@ import {fileURLToPath} from 'url';
11
11
  import {applyTransforms, getSanitizeExtensions, getShortcodeProcessors} from './hooks.js';
12
12
  import {getConfig} from '../config.js';
13
13
  import {getCollection, listEntries} from './collections.js';
14
- import {getMenu, resolveLocation} from './menus.js';
14
+ import {getMenu, resolveLocation, resolveMenuDecorations, colourToCss} from './menus.js';
15
15
  import {checkVisibility} from '../middleware/auth.js';
16
16
 
17
17
  const __dirname_md = path.dirname(fileURLToPath(import.meta.url));
@@ -3385,7 +3385,8 @@ async function processMenuBlocks(markdown, user) {
3385
3385
  const capped = Number.isFinite(depth) && depth > 0 ? capDepth(items, depth) : items;
3386
3386
  const klass = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
3387
3387
  const variantClass = attrs.variant ? ` dm-menu--variant-${escapeAttr(attrs.variant)}` : '';
3388
- const html = `<nav class="dm-menu dm-menu--${escapeAttr(menu.slug)}${variantClass}${klass}" data-menu="${escapeAttr(menu.slug)}">${renderMenuItemsAsUl(capped)}</nav>`;
3388
+ const decorated = await resolveMenuDecorations(capped);
3389
+ const html = `<nav class="dm-menu dm-menu--${escapeAttr(menu.slug)}${variantClass}${klass}" data-menu="${escapeAttr(menu.slug)}">${renderMenuItemsAsUl(decorated)}</nav>`;
3389
3390
  out = out.replace(m[0], html);
3390
3391
  }
3391
3392
  return out;
@@ -3394,10 +3395,25 @@ async function processMenuBlocks(markdown, user) {
3394
3395
  function renderMenuItemsAsUl(items) {
3395
3396
  if (!items.length) return '';
3396
3397
  const lis = items.map(it => {
3398
+ // Separator items render as a list-style <hr> divider.
3399
+ if (it && it.type === 'separator') return '<li class="dm-menu-sep" role="separator"><hr></li>';
3397
3400
  const href = escapeAttr(it.url || '#');
3398
3401
  const text = escapeAttr(it.text || '');
3399
3402
  const child = Array.isArray(it.items) && it.items.length ? renderMenuItemsAsUl(it.items) : '';
3400
- return `<li><a href="${href}">${text}</a>${child}</li>`;
3403
+
3404
+ // Colour override (server sanitiser allows inline style on <a>).
3405
+ const colourCss = it.colour ? colourToCss(it.colour) : '';
3406
+ const styleAttr = colourCss ? ` style="color:${colourCss}"` : '';
3407
+
3408
+ // Badge (static text or resolver-stamped count). Background from variant.
3409
+ let badge = '';
3410
+ if (it.badge && it.badge.text != null && it.badge.text !== '') {
3411
+ const bg = it.badge.variant ? colourToCss(it.badge.variant) : '';
3412
+ const badgeStyle = bg ? ` style="background:${bg};color:#fff"` : '';
3413
+ badge = `<span class="dm-menu-badge"${badgeStyle}>${escapeAttr(String(it.badge.text))}</span>`;
3414
+ }
3415
+
3416
+ return `<li><a href="${href}"${styleAttr}>${text}${badge}</a>${child}</li>`;
3401
3417
  }).join('');
3402
3418
  return `<ul>${lis}</ul>`;
3403
3419
  }
@@ -12,6 +12,7 @@ import fs from 'fs/promises';
12
12
  import path from 'path';
13
13
  import {fileURLToPath} from 'url';
14
14
  import {checkVisibility} from '../middleware/auth.js';
15
+ import {listEntries} from './collections.js';
15
16
 
16
17
  const __filename = fileURLToPath(import.meta.url);
17
18
  const __dirname = path.dirname(__filename);
@@ -37,6 +38,71 @@ export function validateSlug(slug) {
37
38
  return typeof slug === 'string' && SLUG_RE.test(slug);
38
39
  }
39
40
 
41
+ // ---------------------------------------------------------------------------
42
+ // Decoration colour convention — preset key → theme CSS var, or #rrggbb hex.
43
+ // A "colour value" is one of these preset keys or a 6-digit hex.
44
+ // Keep PRESET_COLOUR_VARS in sync with the copy in public/js/menu-decor.mjs.
45
+ // ---------------------------------------------------------------------------
46
+ export const PRESET_COLOUR_VARS = {
47
+ primary: '--dm-primary',
48
+ success: '--dm-success',
49
+ danger: '--dm-danger',
50
+ warning: '--dm-warning',
51
+ info: '--dm-info',
52
+ neutral: '--dm-text-muted'
53
+ };
54
+
55
+ const HEX_COLOUR_RE = /^#[0-9a-fA-F]{6}$/;
56
+
57
+ /** True if value is a known preset key or a 6-digit hex colour. */
58
+ export function isValidColour(value) {
59
+ return typeof value === 'string'
60
+ && (Object.prototype.hasOwnProperty.call(PRESET_COLOUR_VARS, value) || HEX_COLOUR_RE.test(value));
61
+ }
62
+
63
+ /** Map a colour value to inline-CSS-ready text: var(--dm-…) | #hex | ''. */
64
+ export function colourToCss(value) {
65
+ if (typeof value !== 'string') return '';
66
+ if (PRESET_COLOUR_VARS[value]) return `var(${PRESET_COLOUR_VARS[value]})`;
67
+ if (HEX_COLOUR_RE.test(value)) return value;
68
+ return '';
69
+ }
70
+
71
+ /**
72
+ * Walk a menu tree and resolve live badge counts. For any item whose
73
+ * `badge.countFrom` names a collection, the entry count replaces `badge.text`.
74
+ * Static badges and all other fields are left untouched. Failures (missing /
75
+ * unreadable collection) drop only that count, never the menu.
76
+ *
77
+ * @param {Array} items Menu items (may be nested via `items`).
78
+ * @param {{countEntries?: (slug:string)=>Promise<number>}} [opts]
79
+ * countEntries is injectable for testing; defaults to listEntries().length.
80
+ * @returns {Promise<Array>} A decorated copy (inputs are not mutated).
81
+ */
82
+ export async function resolveMenuDecorations(items, opts = {}) {
83
+ const countEntries = opts.countEntries
84
+ || (async (slug) => (await listEntries(slug)).length);
85
+
86
+ async function walk(list) {
87
+ return Promise.all((list || []).map(async (it) => {
88
+ if (!it || it.type === 'separator') return it;
89
+ const next = {...it};
90
+ if (it.badge && it.badge.countFrom) {
91
+ try {
92
+ const n = await countEntries(it.badge.countFrom);
93
+ next.badge = {...it.badge, text: String(n)};
94
+ } catch {
95
+ next.badge = {...it.badge}; // keep variant/etc; drop the count
96
+ }
97
+ }
98
+ if (Array.isArray(it.items)) next.items = await walk(it.items);
99
+ return next;
100
+ }));
101
+ }
102
+
103
+ return walk(items);
104
+ }
105
+
40
106
  /**
41
107
  * Ensure MENUS_DIR exists. Safe to call repeatedly.
42
108
  *
@@ -253,6 +319,10 @@ export function validateMenu(menu) {
253
319
  errors.push('items must be an array');
254
320
  } else {
255
321
  walkItems(menu.items, (item, path) => {
322
+ // Separator items are purely visual dividers — no text/url required.
323
+ // Renderers translate them into <hr> / structural breaks.
324
+ if (item && item.type === 'separator') return;
325
+
256
326
  if (!item.text || typeof item.text !== 'string') {
257
327
  errors.push(`Item ${path}: text is required`);
258
328
  }
@@ -272,6 +342,38 @@ export function validateMenu(menu) {
272
342
  if (item.items != null && !Array.isArray(item.items)) {
273
343
  errors.push(`Item ${path}: items must be an array if present`);
274
344
  }
345
+
346
+ // --- Decoration shape checks (all optional) ---
347
+ if (item.badge != null) {
348
+ if (typeof item.badge !== 'object' || Array.isArray(item.badge)) {
349
+ errors.push(`Item ${path}: badge must be an object`);
350
+ } else {
351
+ if (item.badge.text != null && typeof item.badge.text !== 'string') {
352
+ errors.push(`Item ${path}: badge.text must be a string`);
353
+ }
354
+ if (item.badge.countFrom != null && typeof item.badge.countFrom !== 'string') {
355
+ errors.push(`Item ${path}: badge.countFrom must be a collection slug`);
356
+ }
357
+ if (item.badge.variant != null && !isValidColour(item.badge.variant)) {
358
+ errors.push(`Item ${path}: badge.variant must be a preset key or #rrggbb`);
359
+ }
360
+ }
361
+ }
362
+ if (item.pill != null) {
363
+ if (typeof item.pill !== 'object' || Array.isArray(item.pill)) {
364
+ errors.push(`Item ${path}: pill must be an object`);
365
+ } else {
366
+ if (item.pill.style != null && item.pill.style !== 'filled' && item.pill.style !== 'outline') {
367
+ errors.push(`Item ${path}: pill.style must be "filled" or "outline"`);
368
+ }
369
+ if (item.pill.variant != null && !isValidColour(item.pill.variant)) {
370
+ errors.push(`Item ${path}: pill.variant must be a preset key or #rrggbb`);
371
+ }
372
+ }
373
+ }
374
+ if (item.colour != null && !isValidColour(item.colour)) {
375
+ errors.push(`Item ${path}: colour must be a preset key or #rrggbb`);
376
+ }
275
377
  });
276
378
  }
277
379
  return errors;
@@ -8,7 +8,7 @@ import {fileURLToPath} from 'url';
8
8
  import {getConfig} from '../config.js';
9
9
  import {getInjectionSnippets} from './plugins.js';
10
10
  import {applyTransforms} from './hooks.js';
11
- import {resolveLocation} from './menus.js';
11
+ import {resolveLocation, resolveMenuDecorations} from './menus.js';
12
12
 
13
13
  const VALID_LAYOUT_WIDTHS = new Set(['narrow', 'normal', 'wide', 'full']);
14
14
  const CUSTOM_CSS_PATH = new URL('../../content/custom.css', import.meta.url).pathname;
@@ -78,6 +78,14 @@ export async function renderPage(page, opts = {}) {
78
78
  ]);
79
79
  const baseUrl = opts.baseUrl || site.baseUrl || '';
80
80
 
81
+ // Resolve live badge counts (countFrom) before the menus are injected as
82
+ // globals. Static badges/colours/pills pass through unchanged.
83
+ const [navItemsDecorated, footerPrimaryDecorated, footerLegalDecorated] = await Promise.all([
84
+ resolveMenuDecorations(navMenu ? navMenu.items : []),
85
+ resolveMenuDecorations(footerPrimary ? footerPrimary.items : []),
86
+ resolveMenuDecorations(footerLegal ? footerLegal.items : [])
87
+ ]);
88
+
81
89
  // Compose the navigation object the public site script expects:
82
90
  // brand from site.json.brand (parallel to adminBrand); items + variant +
83
91
  // position + style from the menu mapped to the navbar slot.
@@ -86,7 +94,7 @@ export async function renderPage(page, opts = {}) {
86
94
  const navigation = navMenu
87
95
  ? {
88
96
  brand: site.brand || {},
89
- items: navMenu.items,
97
+ items: navItemsDecorated,
90
98
  variant: navMenu.variant || 'default',
91
99
  position: navMenu.position || 'sticky',
92
100
  ...(navMenu.style && {style: navMenu.style})
@@ -147,8 +155,8 @@ export async function renderPage(page, opts = {}) {
147
155
 
148
156
  // Compose window.__CMS_FOOTER__ from the menus mapped to footer-primary / footer-legal.
149
157
  const footer = {
150
- primary: footerPrimary ? footerPrimary.items : [],
151
- legal: footerLegal ? footerLegal.items : [],
158
+ primary: footerPrimaryDecorated,
159
+ legal: footerLegalDecorated,
152
160
  copyright: site.footer?.copyright || ''
153
161
  };
154
162
  const footerJson = JSON.stringify(footer).replace(/<\/script>/gi, '<\\/script>');
@@ -454,6 +462,14 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
454
462
  ]);
455
463
  const baseUrl = seoMeta.baseUrl || site.baseUrl || '';
456
464
 
465
+ // Resolve live badge counts (countFrom) before the menus are injected as
466
+ // globals. Static badges/colours/pills pass through unchanged.
467
+ const [navItemsDecorated, footerPrimaryDecorated, footerLegalDecorated] = await Promise.all([
468
+ resolveMenuDecorations(navMenu ? navMenu.items : []),
469
+ resolveMenuDecorations(footerPrimary ? footerPrimary.items : []),
470
+ resolveMenuDecorations(footerLegal ? footerLegal.items : [])
471
+ ]);
472
+
457
473
  // Compose the navigation object the public site script expects:
458
474
  // brand from site.json.brand (parallel to adminBrand); items + variant +
459
475
  // position + style from the menu mapped to the navbar slot.
@@ -462,7 +478,7 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
462
478
  const navigation = navMenu
463
479
  ? {
464
480
  brand: site.brand || {},
465
- items: navMenu.items,
481
+ items: navItemsDecorated,
466
482
  variant: navMenu.variant || 'default',
467
483
  position: navMenu.position || 'sticky',
468
484
  ...(navMenu.style && {style: navMenu.style})
@@ -507,8 +523,8 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
507
523
 
508
524
  // Compose window.__CMS_FOOTER__ from the menus mapped to footer-primary / footer-legal.
509
525
  const footer = {
510
- primary: footerPrimary ? footerPrimary.items : [],
511
- legal: footerLegal ? footerLegal.items : [],
526
+ primary: footerPrimaryDecorated,
527
+ legal: footerLegalDecorated,
512
528
  copyright: site.footer?.copyright || ''
513
529
  };
514
530
  const footerJson = JSON.stringify(footer).replace(/<\/script>/gi, '<\\/script>');