domma-cms 0.41.1 → 0.42.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/package.json +1 -1
- package/server/services/markdown.js +10 -46
- package/server/services/menuRender.js +90 -0
- package/server/services/menus.js +46 -0
- package/server/services/renderer.js +33 -5
package/CLAUDE.md
CHANGED
|
@@ -147,6 +147,8 @@ See `docs/configuration.md` for full schema reference.
|
|
|
147
147
|
|
|
148
148
|
Menus live in `config/menus/<slug>.json`. The slot → menu map lives in `config/menu-locations.json`. Built-in slots: `navbar`, `footer-primary`, `footer-legal`, `admin-sidebar`. Plugins register additional slots via `hooks.registerMenuLocation()`. The public navbar's brand is on `site.json.brand` (parallel to `adminBrand`); items + variant + position come from the menu mapped to `navbar`. The `[menu]` shortcode renders any menu inline (`[menu slug="…" /]` or `[menu location="…" /]`). On first boot the CMS auto-migrates the legacy `config/navigation.json` and `site.json.footer.links` into this system; the originals are renamed `.bak` and a one-release read-fallback restores the navbar from `navigation.json.bak` if the navbar slot is unmapped. See `docs/configuration.md` for full schema.
|
|
149
149
|
|
|
150
|
+
**Overlay slot (place a menu anywhere).** `overlay` is a core slot that is *not* one-menu-wins: `resolveOverlaysForPage(ctx, user)` returns **every** menu bound to it that matches the page, plus the menu mapped to it in `menu-locations.json` (site-wide, deduped). The renderer injects them at the end of `<body>` via `buildOverlayMenus()`, so a `position: floating` overlay pins anywhere on the viewport with no navbar/footer/shortcode involved. Overlays with zero visible items after per-user filtering are dropped. Menu→HTML lives in `server/services/menuRender.js` (`buildMenuNav`/`renderMenuItemsAsUl`) and is shared with the `[menu]` shortcode, so both render identically — change markup there, not in markdown.js.
|
|
151
|
+
|
|
150
152
|
**Bindings.** A menu can carry `binding: {slot, projects: [], pages: []}` and take that slot over on matching pages instead of being mapped globally. `resolveLocation(slot, user, {urlPath, project})` checks bindings first (exact page > `/glob/*` > project, longest pattern wins) and falls back to `menu-locations.json`; **callers that know the page must pass the ctx** or bindings silently never fire — `renderer.js` (both render paths) and `parseMarkdown({urlPath})` do. All menu files are cached in-process via `getAllMenus()`; anything writing a menu file outside `menus.js` must call `invalidateMenuIndex()` (the two boot migrations do).
|
|
151
153
|
|
|
152
154
|
**Orientation.** `orientation: horizontal|vertical` + `side: left|right`. Vertical navbar = CSS rail over Domma's normal navbar markup (`#site-navbar.dm-nav-vertical` + server-stamped `body.dm-vnav-*` gutter, ≥993px only; below that the mobile drawer already applies). The `[menu]` shortcode emits `dm-menu--vertical|horizontal` **only when set** — no class means the historic plain-list rendering.
|
package/package.json
CHANGED
|
@@ -11,7 +11,8 @@ 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, resolveMenuDecorations
|
|
14
|
+
import {getMenu, resolveLocation, resolveMenuDecorations} from './menus.js';
|
|
15
|
+
import {buildMenuNav} from './menuRender.js';
|
|
15
16
|
import {checkVisibility} from '../middleware/auth.js';
|
|
16
17
|
|
|
17
18
|
const __dirname_md = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -3894,26 +3895,15 @@ async function processMenuBlocks(markdown, user, ctx = {}) {
|
|
|
3894
3895
|
|
|
3895
3896
|
const depth = Number.parseInt(attrs.depth, 10);
|
|
3896
3897
|
const capped = Number.isFinite(depth) && depth > 0 ? capDepth(items, depth) : items;
|
|
3897
|
-
const klass = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
|
|
3898
|
-
const variantClass = attrs.variant ? ` dm-menu--variant-${escapeAttr(attrs.variant)}` : '';
|
|
3899
|
-
// Orientation: the shortcode attribute wins over the menu's own setting,
|
|
3900
|
-
// so one placement can run horizontally while the menu is vertical in
|
|
3901
|
-
// its slot. A menu that sets neither emits no orientation class at all
|
|
3902
|
-
// and keeps the historic plain-nested-list rendering.
|
|
3903
|
-
const orientation = ['horizontal', 'vertical'].includes(attrs.orientation)
|
|
3904
|
-
? attrs.orientation
|
|
3905
|
-
: (['horizontal', 'vertical'].includes(menu.orientation) ? menu.orientation : null);
|
|
3906
|
-
const orientClass = orientation ? ` dm-menu--${orientation}` : '';
|
|
3907
3898
|
const decorated = await resolveMenuDecorations(capped);
|
|
3908
|
-
//
|
|
3909
|
-
//
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
const html = `<nav class="dm-menu dm-menu--${escapeAttr(menu.slug)}${orientClass}${variantClass}${floatClass}${klass}" data-menu="${escapeAttr(menu.slug)}"${floatAttrs}>${renderMenuItemsAsUl(decorated)}</nav>`;
|
|
3899
|
+
// `orientation` overrides the menu for this one placement; `float="no"`
|
|
3900
|
+
// renders inline here even when the menu floats in its slot.
|
|
3901
|
+
const html = buildMenuNav(menu, decorated, {
|
|
3902
|
+
orientation: attrs.orientation,
|
|
3903
|
+
variant: attrs.variant,
|
|
3904
|
+
class: attrs.class,
|
|
3905
|
+
noFloat: attrs.float === 'no'
|
|
3906
|
+
});
|
|
3917
3907
|
out = out.replace(m[0], html);
|
|
3918
3908
|
}
|
|
3919
3909
|
return out;
|
|
@@ -3942,32 +3932,6 @@ async function resolveProjectForUrl(urlPath) {
|
|
|
3942
3932
|
}
|
|
3943
3933
|
}
|
|
3944
3934
|
|
|
3945
|
-
function renderMenuItemsAsUl(items) {
|
|
3946
|
-
if (!items.length) return '';
|
|
3947
|
-
const lis = items.map(it => {
|
|
3948
|
-
// Separator items render as a list-style <hr> divider.
|
|
3949
|
-
if (it && it.type === 'separator') return '<li class="dm-menu-sep" role="separator"><hr></li>';
|
|
3950
|
-
const href = escapeAttr(it.url || '#');
|
|
3951
|
-
const text = escapeAttr(it.text || '');
|
|
3952
|
-
const child = Array.isArray(it.items) && it.items.length ? renderMenuItemsAsUl(it.items) : '';
|
|
3953
|
-
|
|
3954
|
-
// Colour override (server sanitiser allows inline style on <a>).
|
|
3955
|
-
const colourCss = it.colour ? colourToCss(it.colour) : '';
|
|
3956
|
-
const styleAttr = colourCss ? ` style="color:${colourCss}"` : '';
|
|
3957
|
-
|
|
3958
|
-
// Badge (static text or resolver-stamped count). Background from variant.
|
|
3959
|
-
let badge = '';
|
|
3960
|
-
if (it.badge && it.badge.text != null && it.badge.text !== '') {
|
|
3961
|
-
const bg = it.badge.variant ? colourToCss(it.badge.variant) : '';
|
|
3962
|
-
const badgeStyle = bg ? ` style="background:${bg};color:#fff"` : '';
|
|
3963
|
-
badge = `<span class="dm-menu-badge"${badgeStyle}>${escapeAttr(String(it.badge.text))}</span>`;
|
|
3964
|
-
}
|
|
3965
|
-
|
|
3966
|
-
return `<li><a href="${href}"${styleAttr}>${text}${badge}</a>${child}</li>`;
|
|
3967
|
-
}).join('');
|
|
3968
|
-
return `<ul>${lis}</ul>`;
|
|
3969
|
-
}
|
|
3970
|
-
|
|
3971
3935
|
function capDepth(items, max, depth = 1) {
|
|
3972
3936
|
return items.map(it => ({
|
|
3973
3937
|
...it,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Menu → HTML.
|
|
3
|
+
*
|
|
4
|
+
* One builder, three callers: the `[menu]` shortcode, the overlay menus the
|
|
5
|
+
* renderer injects into the page shell, and anything a plugin wants to render
|
|
6
|
+
* itself. Keeping it here means a menu looks the same however it reaches the
|
|
7
|
+
* page — the shortcode and an overlay binding of the same menu produce byte-
|
|
8
|
+
* identical markup.
|
|
9
|
+
*
|
|
10
|
+
* Item filtering (visibility, `hidden`), depth capping and badge-count
|
|
11
|
+
* resolution happen before this — callers hand in the items they want drawn.
|
|
12
|
+
*/
|
|
13
|
+
import {colourToCss, floatToCss} from './menus.js';
|
|
14
|
+
|
|
15
|
+
/** Attribute-safe escaping — mirrors escapeAttr in markdown.js. */
|
|
16
|
+
function esc(str) {
|
|
17
|
+
return String(str ?? '')
|
|
18
|
+
.replace(/&/g, '&')
|
|
19
|
+
.replace(/"/g, '"')
|
|
20
|
+
.replace(/</g, '<')
|
|
21
|
+
.replace(/>/g, '>');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Render a menu's items as a nested `<ul>`.
|
|
26
|
+
*
|
|
27
|
+
* Separators become a classed `<li><hr></li>`; colour, badge and nested
|
|
28
|
+
* children are drawn inline. Returns '' for an empty list so callers can
|
|
29
|
+
* decide whether an empty menu is worth a wrapper at all.
|
|
30
|
+
*
|
|
31
|
+
* @param {Array} items
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
export function renderMenuItemsAsUl(items) {
|
|
35
|
+
if (!items || !items.length) return '';
|
|
36
|
+
const lis = items.map(it => {
|
|
37
|
+
if (it && it.type === 'separator') return '<li class="dm-menu-sep" role="separator"><hr></li>';
|
|
38
|
+
const href = esc(it.url || '#');
|
|
39
|
+
const text = esc(it.text || '');
|
|
40
|
+
const child = Array.isArray(it.items) && it.items.length ? renderMenuItemsAsUl(it.items) : '';
|
|
41
|
+
|
|
42
|
+
// Colour override (the server sanitiser allows inline style on <a>).
|
|
43
|
+
const colourCss = it.colour ? colourToCss(it.colour) : '';
|
|
44
|
+
const styleAttr = colourCss ? ` style="color:${colourCss}"` : '';
|
|
45
|
+
|
|
46
|
+
// Badge — static text, or a count stamped on by resolveMenuDecorations.
|
|
47
|
+
let badge = '';
|
|
48
|
+
if (it.badge && it.badge.text != null && it.badge.text !== '') {
|
|
49
|
+
const bg = it.badge.variant ? colourToCss(it.badge.variant) : '';
|
|
50
|
+
const badgeStyle = bg ? ` style="background:${bg};color:#fff"` : '';
|
|
51
|
+
badge = `<span class="dm-menu-badge"${badgeStyle}>${esc(String(it.badge.text))}</span>`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return `<li><a href="${href}"${styleAttr}>${text}${badge}</a>${child}</li>`;
|
|
55
|
+
}).join('');
|
|
56
|
+
return `<ul>${lis}</ul>`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build the full `<nav>` for a menu.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} menu The menu record (slug, orientation, position, float)
|
|
63
|
+
* @param {Array} items Items to draw — already filtered/capped/decorated
|
|
64
|
+
* @param {object} [opts]
|
|
65
|
+
* @param {string} [opts.orientation] Override the menu's own orientation for this placement
|
|
66
|
+
* @param {string} [opts.variant] Adds `dm-menu--variant-<x>`
|
|
67
|
+
* @param {string} [opts.class] Extra classes on the wrapper
|
|
68
|
+
* @param {boolean}[opts.noFloat] Render inline even if the menu floats in its slot
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
export function buildMenuNav(menu, items, opts = {}) {
|
|
72
|
+
const klass = opts.class ? ` ${esc(opts.class)}` : '';
|
|
73
|
+
const variantClass = opts.variant ? ` dm-menu--variant-${esc(opts.variant)}` : '';
|
|
74
|
+
|
|
75
|
+
// A menu that declares no orientation emits no class at all and keeps the
|
|
76
|
+
// historic plain-nested-list rendering.
|
|
77
|
+
const orientation = ['horizontal', 'vertical'].includes(opts.orientation)
|
|
78
|
+
? opts.orientation
|
|
79
|
+
: (['horizontal', 'vertical'].includes(menu.orientation) ? menu.orientation : null);
|
|
80
|
+
const orientClass = orientation ? ` dm-menu--${orientation}` : '';
|
|
81
|
+
|
|
82
|
+
const floatCss = opts.noFloat ? '' : floatToCss(menu);
|
|
83
|
+
const floatClass = floatCss ? ' dm-menu-floating' : '';
|
|
84
|
+
const floatAttrs = floatCss
|
|
85
|
+
? ` style="${esc(floatCss)}" data-float-anchor="${esc(menu.float?.anchor || 'TL')}"`
|
|
86
|
+
: '';
|
|
87
|
+
|
|
88
|
+
return `<nav class="dm-menu dm-menu--${esc(menu.slug)}${orientClass}${variantClass}${floatClass}${klass}"`
|
|
89
|
+
+ ` data-menu="${esc(menu.slug)}"${floatAttrs}>${renderMenuItemsAsUl(items)}</nav>`;
|
|
90
|
+
}
|
package/server/services/menus.js
CHANGED
|
@@ -710,6 +710,11 @@ registerLocation('navbar', {label: 'Navbar', description: 'Site
|
|
|
710
710
|
registerLocation('footer-primary', {label: 'Footer primary', description: 'Primary footer column', source: 'core', maxDepth: 1});
|
|
711
711
|
registerLocation('footer-legal', {label: 'Footer legal', description: 'Legal/secondary footer column', source: 'core', maxDepth: 1});
|
|
712
712
|
registerLocation('admin-sidebar', {label: 'Admin sidebar', description: 'The left-side navigation in the admin panel', source: 'core', maxDepth: null});
|
|
713
|
+
// The overlay slot is the "anywhere" placement: menus bound to it render on
|
|
714
|
+
// matching pages positioned by their own `float` config, with no navbar, no
|
|
715
|
+
// footer and no shortcode involved. Unlike the other slots it is not
|
|
716
|
+
// one-menu-wins — a page can carry several overlays at once.
|
|
717
|
+
registerLocation('overlay', {label: 'Page overlay', description: 'Floating menus placed on matching pages by their binding', source: 'core', maxDepth: null});
|
|
713
718
|
|
|
714
719
|
// ---------------------------------------------------------------------------
|
|
715
720
|
// Slot → menu-slug map
|
|
@@ -904,6 +909,47 @@ export async function resolveLocation(slot, user, ctx = {}) {
|
|
|
904
909
|
};
|
|
905
910
|
}
|
|
906
911
|
|
|
912
|
+
/** The slot whose menus are drawn over the page rather than in a fixed region. */
|
|
913
|
+
export const OVERLAY_SLOT = 'overlay';
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Every overlay menu that applies to this page.
|
|
917
|
+
*
|
|
918
|
+
* Overlays are the "place a menu anywhere" path: instead of one menu winning a
|
|
919
|
+
* region, *all* menus whose binding matches the page render, each positioned by
|
|
920
|
+
* its own `float` config. A menu mapped to the overlay slot in
|
|
921
|
+
* menu-locations.json applies site-wide and joins the list.
|
|
922
|
+
*
|
|
923
|
+
* Menus left with no visible items after per-user filtering are dropped, so a
|
|
924
|
+
* fully gated overlay doesn't leave an empty box floating on the page.
|
|
925
|
+
*
|
|
926
|
+
* @param {{urlPath?: string, project?: string|null}} ctx
|
|
927
|
+
* @param {object|null} user
|
|
928
|
+
* @returns {Promise<object[]>} menus with filtered items, ordered by slug
|
|
929
|
+
*/
|
|
930
|
+
export async function resolveOverlaysForPage(ctx = {}, user = null) {
|
|
931
|
+
const bySlug = new Map();
|
|
932
|
+
|
|
933
|
+
for (const menu of await getAllMenus()) {
|
|
934
|
+
if (menu?.binding?.slot !== OVERLAY_SLOT) continue;
|
|
935
|
+
if (!scoreBinding(menu.binding, ctx)) continue;
|
|
936
|
+
bySlug.set(menu.slug, menu);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// A site-wide overlay: mapped rather than bound, so it needs no page rules.
|
|
940
|
+
const mappedSlug = (await getLocations())[OVERLAY_SLOT];
|
|
941
|
+
if (mappedSlug && !bySlug.has(mappedSlug)) {
|
|
942
|
+
const mapped = await getMenu(mappedSlug);
|
|
943
|
+
if (mapped) bySlug.set(mapped.slug, mapped);
|
|
944
|
+
else console.warn(`[menus] Overlay slot maps to "${mappedSlug}" which doesn't exist`);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
return [...bySlug.values()]
|
|
948
|
+
.map(menu => ({...menu, items: filterItemsForUser(menu.items || [], user)}))
|
|
949
|
+
.filter(menu => menu.items.length > 0)
|
|
950
|
+
.sort((a, b) => String(a.slug).localeCompare(String(b.slug)));
|
|
951
|
+
}
|
|
952
|
+
|
|
907
953
|
function filterItemsForUser(items, user) {
|
|
908
954
|
const out = [];
|
|
909
955
|
for (const item of items) {
|
|
@@ -8,7 +8,8 @@ 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, resolveMenuDecorations} from './menus.js';
|
|
11
|
+
import {resolveLocation, resolveMenuDecorations, resolveOverlaysForPage} from './menus.js';
|
|
12
|
+
import {buildMenuNav} from './menuRender.js';
|
|
12
13
|
import {getProjectForPage} from './projects.js';
|
|
13
14
|
|
|
14
15
|
const VALID_LAYOUT_WIDTHS = new Set(['narrow', 'normal', 'wide', 'full']);
|
|
@@ -53,6 +54,26 @@ function filterHiddenNavItems(nav) {
|
|
|
53
54
|
return {...nav, items};
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Build the overlay menus for a page: every menu bound to the `overlay` slot
|
|
59
|
+
* whose binding matches, each drawn by the same builder the `[menu]` shortcode
|
|
60
|
+
* uses so an overlay and a shortcode of the same menu look identical.
|
|
61
|
+
*
|
|
62
|
+
* Emitted as bare <nav> elements with no wrapper — a wrapping element would
|
|
63
|
+
* risk becoming a containing block for their position:fixed pinning.
|
|
64
|
+
*
|
|
65
|
+
* @param {{urlPath?: string, project?: string|null}} menuCtx
|
|
66
|
+
* @param {object|null} user
|
|
67
|
+
* @returns {Promise<string>} concatenated HTML, '' when nothing applies
|
|
68
|
+
*/
|
|
69
|
+
async function buildOverlayMenus(menuCtx, user) {
|
|
70
|
+
const overlays = await resolveOverlaysForPage(menuCtx, user);
|
|
71
|
+
if (!overlays.length) return '';
|
|
72
|
+
const navs = await Promise.all(overlays.map(async (menu) =>
|
|
73
|
+
buildMenuNav(menu, await resolveMenuDecorations(menu.items))));
|
|
74
|
+
return navs.join('');
|
|
75
|
+
}
|
|
76
|
+
|
|
56
77
|
/**
|
|
57
78
|
* Render a page to a full HTML string.
|
|
58
79
|
*
|
|
@@ -89,10 +110,11 @@ export async function renderPage(page, opts = {}) {
|
|
|
89
110
|
|
|
90
111
|
// Resolve live badge counts (countFrom) before the menus are injected as
|
|
91
112
|
// globals. Static badges/colours/pills pass through unchanged.
|
|
92
|
-
const [navItemsDecorated, footerPrimaryDecorated, footerLegalDecorated] = await Promise.all([
|
|
113
|
+
const [navItemsDecorated, footerPrimaryDecorated, footerLegalDecorated, overlayMenus] = await Promise.all([
|
|
93
114
|
resolveMenuDecorations(navMenu ? navMenu.items : []),
|
|
94
115
|
resolveMenuDecorations(footerPrimary ? footerPrimary.items : []),
|
|
95
|
-
resolveMenuDecorations(footerLegal ? footerLegal.items : [])
|
|
116
|
+
resolveMenuDecorations(footerLegal ? footerLegal.items : []),
|
|
117
|
+
buildOverlayMenus(menuCtx, opts.user || null)
|
|
96
118
|
]);
|
|
97
119
|
|
|
98
120
|
// Compose the navigation object the public site script expects:
|
|
@@ -220,6 +242,10 @@ export async function renderPage(page, opts = {}) {
|
|
|
220
242
|
headInject: [injection.head, navbarFontLink].filter(Boolean).join('\n'),
|
|
221
243
|
headInjectLate: [injection.headLate, customCssTag, navbarStyleTag].filter(Boolean).join('\n'),
|
|
222
244
|
bodyEndInject: [
|
|
245
|
+
// Overlay menus — bound to the `overlay` slot and pinned by their own
|
|
246
|
+
// float config, so they belong at the end of the body, clear of the
|
|
247
|
+
// content flow.
|
|
248
|
+
overlayMenus,
|
|
223
249
|
injection.bodyEnd,
|
|
224
250
|
site.backToTop?.enabled ? '<script src="/public/js/btt.js"></script>' : '',
|
|
225
251
|
site.cookieConsent?.enabled ? '<script src="/public/js/cookie-consent.js"></script>' : '',
|
|
@@ -516,10 +542,11 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
|
|
|
516
542
|
|
|
517
543
|
// Resolve live badge counts (countFrom) before the menus are injected as
|
|
518
544
|
// globals. Static badges/colours/pills pass through unchanged.
|
|
519
|
-
const [navItemsDecorated, footerPrimaryDecorated, footerLegalDecorated] = await Promise.all([
|
|
545
|
+
const [navItemsDecorated, footerPrimaryDecorated, footerLegalDecorated, overlayMenus] = await Promise.all([
|
|
520
546
|
resolveMenuDecorations(navMenu ? navMenu.items : []),
|
|
521
547
|
resolveMenuDecorations(footerPrimary ? footerPrimary.items : []),
|
|
522
|
-
resolveMenuDecorations(footerLegal ? footerLegal.items : [])
|
|
548
|
+
resolveMenuDecorations(footerLegal ? footerLegal.items : []),
|
|
549
|
+
buildOverlayMenus(menuCtx, user)
|
|
523
550
|
]);
|
|
524
551
|
|
|
525
552
|
// Compose the navigation object the public site script expects:
|
|
@@ -623,6 +650,7 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
|
|
|
623
650
|
headInject: [injection.head, navbarFontLink].filter(Boolean).join('\n'),
|
|
624
651
|
headInjectLate: [injection.headLate, customCssTag, navbarStyleTag].filter(Boolean).join('\n'),
|
|
625
652
|
bodyEndInject: [
|
|
653
|
+
overlayMenus,
|
|
626
654
|
injection.bodyEnd,
|
|
627
655
|
(seoMeta.usedComponents || [])
|
|
628
656
|
.map(n => `<script type="module" src="/api/components/${n}.js"></script>`)
|