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.
- package/admin/css/admin.css +1 -1
- package/admin/js/lib/sidebar-renderer.js +4 -4
- package/admin/js/templates/menu-editor.html +1 -0
- package/admin/js/views/menu-editor.js +13 -12
- package/package.json +1 -1
- package/public/css/site.css +1 -1
- package/public/js/menu-decor.mjs +1 -0
- package/public/js/site.js +1 -1
- package/scripts/build.js +263 -246
- package/scripts/verify-assets.mjs +190 -0
- package/server/services/markdown.js +19 -3
- package/server/services/menus.js +102 -0
- package/server/services/renderer.js +23 -7
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
package/server/services/menus.js
CHANGED
|
@@ -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:
|
|
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:
|
|
151
|
-
legal:
|
|
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:
|
|
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:
|
|
511
|
-
legal:
|
|
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>');
|