domma-cms 0.52.0 → 0.54.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/admin/index.html +4 -0
- package/admin/js/lib/sidebar-renderer.js +3 -3
- package/admin/js/templates/menu-editor.html +47 -0
- package/admin/js/views/menu-editor.js +14 -14
- package/package.json +1 -1
- package/public/css/menu-highlight.css +1 -0
- package/public/css/site.css +1 -1
- package/public/js/menu-decor.mjs +1 -1
- package/public/js/site.js +1 -1
- package/server/server.js +24 -1
- package/server/services/markdown.js +8 -4
- package/server/services/menuRender.js +38 -8
- package/server/services/menus.js +42 -0
- package/server/services/renderer.js +16 -3
- package/server/templates/page.html +4 -1
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
* resolution happen before this - callers hand in the items they want drawn.
|
|
12
12
|
*/
|
|
13
13
|
import {colourToCss, floatToCss, floatBehaviour, spacerSize} from './menus.js';
|
|
14
|
+
// Highlight helpers live once, in the browser module, and are imported here -
|
|
15
|
+
// the same shared-module arrangement collection-export.mjs has with
|
|
16
|
+
// collections.js. Nothing in them touches the DOM.
|
|
17
|
+
import {highlightAttrs, isCurrentItem} from '../../public/js/menu-decor.mjs';
|
|
14
18
|
|
|
15
19
|
/** Attribute-safe escaping - mirrors escapeAttr in markdown.js. */
|
|
16
20
|
function esc(str) {
|
|
@@ -28,10 +32,13 @@ function esc(str) {
|
|
|
28
32
|
* children are drawn inline. Returns '' for an empty list so callers can
|
|
29
33
|
* decide whether an empty menu is worth a wrapper at all.
|
|
30
34
|
*
|
|
31
|
-
* @param {Array}
|
|
35
|
+
* @param {Array} items
|
|
36
|
+
* @param {string} [currentPath] The URL path being rendered. Items pointing at
|
|
37
|
+
* it are marked `is-active` + `aria-current` so a `highlight.active` effect
|
|
38
|
+
* has something to style. Omitted (the default) marks nothing.
|
|
32
39
|
* @returns {string}
|
|
33
40
|
*/
|
|
34
|
-
export function renderMenuItemsAsUl(items) {
|
|
41
|
+
export function renderMenuItemsAsUl(items, currentPath = '') {
|
|
35
42
|
if (!items || !items.length) return '';
|
|
36
43
|
const lis = items.map(it => {
|
|
37
44
|
if (it && it.type === 'separator') return '<li class="dm-menu-sep" role="separator"><hr></li>';
|
|
@@ -42,7 +49,13 @@ export function renderMenuItemsAsUl(items) {
|
|
|
42
49
|
}
|
|
43
50
|
const href = esc(it.url || '#');
|
|
44
51
|
const text = esc(it.text || '');
|
|
45
|
-
const child = Array.isArray(it.items) && it.items.length ? renderMenuItemsAsUl(it.items) : '';
|
|
52
|
+
const child = Array.isArray(it.items) && it.items.length ? renderMenuItemsAsUl(it.items, currentPath) : '';
|
|
53
|
+
|
|
54
|
+
// The item for the page we are on. Both markers are emitted because
|
|
55
|
+
// sanitize-html keeps the class on every path while the aria attribute
|
|
56
|
+
// needs an explicit allowlist entry.
|
|
57
|
+
const current = isCurrentItem(it.url, currentPath);
|
|
58
|
+
const classAttr = current ? ' class="is-active" aria-current="page"' : '';
|
|
46
59
|
|
|
47
60
|
// Colour override (the server sanitiser allows inline style on <a>).
|
|
48
61
|
const colourCss = it.colour ? colourToCss(it.colour) : '';
|
|
@@ -56,7 +69,7 @@ export function renderMenuItemsAsUl(items) {
|
|
|
56
69
|
badge = `<span class="dm-menu-badge"${badgeStyle}>${esc(String(it.badge.text))}</span>`;
|
|
57
70
|
}
|
|
58
71
|
|
|
59
|
-
return `<li><a href="${href}"${styleAttr}>${text}${badge}</a>${child}</li>`;
|
|
72
|
+
return `<li><a href="${href}"${classAttr}${styleAttr}>${text}${badge}</a>${child}</li>`;
|
|
60
73
|
}).join('');
|
|
61
74
|
return `<ul>${lis}</ul>`;
|
|
62
75
|
}
|
|
@@ -71,6 +84,8 @@ export function renderMenuItemsAsUl(items) {
|
|
|
71
84
|
* @param {string} [opts.variant] Adds `dm-menu--variant-<x>`
|
|
72
85
|
* @param {string} [opts.class] Extra classes on the wrapper
|
|
73
86
|
* @param {boolean}[opts.noFloat] Render inline even if the menu floats in its slot
|
|
87
|
+
* @param {string} [opts.currentPath] URL path of the page being rendered, for
|
|
88
|
+
* marking the current item. Callers that know it should always pass it.
|
|
74
89
|
* @returns {string}
|
|
75
90
|
*/
|
|
76
91
|
export function buildMenuNav(menu, items, opts = {}) {
|
|
@@ -86,8 +101,8 @@ export function buildMenuNav(menu, items, opts = {}) {
|
|
|
86
101
|
|
|
87
102
|
const floatCss = opts.noFloat ? '' : floatToCss(menu);
|
|
88
103
|
const floatClass = floatCss ? ' dm-menu-floating' : '';
|
|
89
|
-
const
|
|
90
|
-
? `
|
|
104
|
+
const floatAnchorAttr = floatCss
|
|
105
|
+
? ` data-float-anchor="${esc(menu.float?.anchor || 'TL')}"`
|
|
91
106
|
: '';
|
|
92
107
|
|
|
93
108
|
// Panel behaviour - only ever applies where the menu actually floats, so a
|
|
@@ -104,10 +119,25 @@ export function buildMenuNav(menu, items, opts = {}) {
|
|
|
104
119
|
|
|
105
120
|
const flexClass = hasFlexSpacer(items) ? ' dm-menu--has-flex-spacer' : '';
|
|
106
121
|
|
|
122
|
+
// Item highlighting - two data attributes and an optional accent custom
|
|
123
|
+
// property; public/css/menu-highlight.css does the rest. A menu with no
|
|
124
|
+
// `highlight` block emits nothing here and renders as it always has.
|
|
125
|
+
const hl = highlightAttrs(menu);
|
|
126
|
+
const hlAttrs = [
|
|
127
|
+
hl.hover && ` data-menu-hover="${esc(hl.hover)}"`,
|
|
128
|
+
hl.active && ` data-menu-active="${esc(hl.active)}"`
|
|
129
|
+
].filter(Boolean).join('');
|
|
130
|
+
// The accent joins whatever floatToCss already put in the style attribute
|
|
131
|
+
// rather than opening a second one.
|
|
132
|
+
const hlStyle = hl.accent ? `--dm-menu-highlight:${hl.accent}` : '';
|
|
133
|
+
const styleAttr = (floatCss || hlStyle)
|
|
134
|
+
? ` style="${esc([floatCss, hlStyle].filter(Boolean).join(';'))}"`
|
|
135
|
+
: '';
|
|
136
|
+
|
|
107
137
|
return `<nav class="dm-menu dm-menu--${esc(menu.slug)}${orientClass}${variantClass}${floatClass}${collapsedClass}${flexClass}${klass}"`
|
|
108
|
-
+ ` data-menu="${esc(menu.slug)}"${
|
|
138
|
+
+ ` data-menu="${esc(menu.slug)}"${styleAttr}${floatAnchorAttr}${behAttrs}${hlAttrs}>`
|
|
109
139
|
+ buildFloatChrome(menu, beh)
|
|
110
|
-
+ renderMenuItemsAsUl(items)
|
|
140
|
+
+ renderMenuItemsAsUl(items, opts.currentPath || '')
|
|
111
141
|
+ `</nav>`;
|
|
112
142
|
}
|
|
113
143
|
|
package/server/services/menus.js
CHANGED
|
@@ -14,6 +14,12 @@ import {fileURLToPath} from 'url';
|
|
|
14
14
|
import {checkVisibility} from '../middleware/auth.js';
|
|
15
15
|
import {listEntries} from './collections.js';
|
|
16
16
|
import * as cache from './cache/index.js';
|
|
17
|
+
// The highlight vocabularies are defined once, in the browser module, and
|
|
18
|
+
// imported here so validation and the renderer can never drift apart.
|
|
19
|
+
import {
|
|
20
|
+
HIGHLIGHT_HOVER, HIGHLIGHT_ACTIVE,
|
|
21
|
+
LEGACY_STYLE_HOVER, LEGACY_STYLE_ACTIVE, legacyStyleHighlight
|
|
22
|
+
} from '../../public/js/menu-decor.mjs';
|
|
17
23
|
|
|
18
24
|
/**
|
|
19
25
|
* Bust the response-cache tags that menu changes affect. Public pages are
|
|
@@ -444,6 +450,7 @@ export async function createMenu(input) {
|
|
|
444
450
|
...(input.side != null && {side: input.side}),
|
|
445
451
|
...(input.appearOnHover != null && {appearOnHover: !!input.appearOnHover}),
|
|
446
452
|
...(input.float != null && {float: input.float}),
|
|
453
|
+
...(input.highlight != null && {highlight: input.highlight}),
|
|
447
454
|
...(input.style != null && {style: input.style}),
|
|
448
455
|
...(binding != null && {binding}),
|
|
449
456
|
items: Array.isArray(input.items) ? input.items : [],
|
|
@@ -487,6 +494,7 @@ export async function updateMenu(slug, input) {
|
|
|
487
494
|
...(input.side != null && {side: input.side}),
|
|
488
495
|
...(input.appearOnHover != null && {appearOnHover: !!input.appearOnHover}),
|
|
489
496
|
...(input.float != null && {float: input.float}),
|
|
497
|
+
...(input.highlight != null && {highlight: input.highlight}),
|
|
490
498
|
...(input.style != null && {style: input.style}),
|
|
491
499
|
...(binding != null && {binding}),
|
|
492
500
|
items: Array.isArray(input.items) ? input.items : [],
|
|
@@ -604,6 +612,24 @@ export function validateMenu(menu) {
|
|
|
604
612
|
if (menu.side != null && !SIDES.has(menu.side)) {
|
|
605
613
|
errors.push(`Invalid side "${menu.side}" - expected one of: ${[...SIDES].join(', ')}`);
|
|
606
614
|
}
|
|
615
|
+
if (menu.style != null) {
|
|
616
|
+
if (typeof menu.style !== 'object' || Array.isArray(menu.style)) {
|
|
617
|
+
errors.push('style must be an object');
|
|
618
|
+
} else {
|
|
619
|
+
// `style.hover` / `style.active` are the deprecated spelling of the
|
|
620
|
+
// `highlight` block - see LEGACY_STYLE_HOVER in menu-decor.mjs.
|
|
621
|
+
// Judged against the same vocabulary the resolver accepts, so a file
|
|
622
|
+
// that validates is a file that renders.
|
|
623
|
+
if (menu.style.hover != null && menu.style.hover !== ''
|
|
624
|
+
&& !legacyStyleHighlight(menu.style).hover) {
|
|
625
|
+
errors.push(`style.hover must be one of: ${Object.keys(LEGACY_STYLE_HOVER).join(', ')}`);
|
|
626
|
+
}
|
|
627
|
+
if (menu.style.active != null && menu.style.active !== ''
|
|
628
|
+
&& !legacyStyleHighlight(menu.style).active) {
|
|
629
|
+
errors.push(`style.active must be one of: ${Object.keys(LEGACY_STYLE_ACTIVE).join(', ')}`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
607
633
|
if (menu.binding != null) {
|
|
608
634
|
if (typeof menu.binding !== 'object' || Array.isArray(menu.binding)) {
|
|
609
635
|
errors.push('binding must be an object');
|
|
@@ -661,6 +687,22 @@ export function validateMenu(menu) {
|
|
|
661
687
|
}
|
|
662
688
|
}
|
|
663
689
|
}
|
|
690
|
+
if (menu.highlight != null) {
|
|
691
|
+
if (typeof menu.highlight !== 'object' || Array.isArray(menu.highlight)) {
|
|
692
|
+
errors.push('highlight must be an object');
|
|
693
|
+
} else {
|
|
694
|
+
const {hover, active, accent} = menu.highlight;
|
|
695
|
+
if (hover != null && hover !== '' && !HIGHLIGHT_HOVER.includes(hover)) {
|
|
696
|
+
errors.push(`highlight.hover must be one of: ${HIGHLIGHT_HOVER.join(', ')}`);
|
|
697
|
+
}
|
|
698
|
+
if (active != null && active !== '' && !HIGHLIGHT_ACTIVE.includes(active)) {
|
|
699
|
+
errors.push(`highlight.active must be one of: ${HIGHLIGHT_ACTIVE.join(', ')}`);
|
|
700
|
+
}
|
|
701
|
+
if (accent != null && accent !== '' && !isValidColour(accent)) {
|
|
702
|
+
errors.push('highlight.accent must be a preset key or #rrggbb');
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
664
706
|
if (!Array.isArray(menu.items)) {
|
|
665
707
|
errors.push('items must be an array');
|
|
666
708
|
} else {
|
|
@@ -70,7 +70,8 @@ async function buildOverlayMenus(menuCtx, user) {
|
|
|
70
70
|
const overlays = await resolveOverlaysForPage(menuCtx, user);
|
|
71
71
|
if (!overlays.length) return '';
|
|
72
72
|
const navs = await Promise.all(overlays.map(async (menu) =>
|
|
73
|
-
buildMenuNav(menu, await resolveMenuDecorations(menu.items)
|
|
73
|
+
buildMenuNav(menu, await resolveMenuDecorations(menu.items),
|
|
74
|
+
{currentPath: menuCtx?.urlPath || ''})));
|
|
74
75
|
return navs.join('');
|
|
75
76
|
}
|
|
76
77
|
|
|
@@ -141,6 +142,9 @@ export async function renderPage(page, opts = {}) {
|
|
|
141
142
|
...(navMenu.orientation === 'vertical' && {orientation: 'vertical', side: navMenu.side === 'right' ? 'right' : 'left'}),
|
|
142
143
|
...(navMenu.float && {float: navMenu.float}),
|
|
143
144
|
...(navMenu.appearOnHover && {appearOnHover: true}),
|
|
145
|
+
// Item highlighting: the navbar is built in the browser, so the
|
|
146
|
+
// config has to travel to site.js rather than being rendered here.
|
|
147
|
+
...(navMenu.highlight && {highlight: navMenu.highlight}),
|
|
144
148
|
...(navMenu.style && {style: navMenu.style})
|
|
145
149
|
}
|
|
146
150
|
: (await readLegacyNavBackup()) || {brand: site.brand || {}, items: []};
|
|
@@ -209,7 +213,10 @@ export async function renderPage(page, opts = {}) {
|
|
|
209
213
|
const footer = {
|
|
210
214
|
primary: footerPrimaryDecorated,
|
|
211
215
|
legal: footerLegalDecorated,
|
|
212
|
-
copyright: site.footer?.copyright || ''
|
|
216
|
+
copyright: site.footer?.copyright || '',
|
|
217
|
+
// Highlighting is per menu, and the two footer columns are two menus.
|
|
218
|
+
...(footerPrimary?.highlight && {primaryHighlight: footerPrimary.highlight}),
|
|
219
|
+
...(footerLegal?.highlight && {legalHighlight: footerLegal.highlight})
|
|
213
220
|
};
|
|
214
221
|
const footerJson = JSON.stringify(footer).replace(/<\/script>/gi, '<\\/script>');
|
|
215
222
|
|
|
@@ -711,6 +718,9 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
|
|
|
711
718
|
// existing sites.
|
|
712
719
|
...(navMenu.orientation === 'vertical' && {orientation: 'vertical', side: navMenu.side === 'right' ? 'right' : 'left'}),
|
|
713
720
|
...(navMenu.appearOnHover && {appearOnHover: true}),
|
|
721
|
+
// Item highlighting: the navbar is built in the browser, so the
|
|
722
|
+
// config has to travel to site.js rather than being rendered here.
|
|
723
|
+
...(navMenu.highlight && {highlight: navMenu.highlight}),
|
|
714
724
|
...(navMenu.style && {style: navMenu.style})
|
|
715
725
|
}
|
|
716
726
|
: (await readLegacyNavBackup()) || {brand: site.brand || {}, items: []};
|
|
@@ -755,7 +765,10 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
|
|
|
755
765
|
const footer = {
|
|
756
766
|
primary: footerPrimaryDecorated,
|
|
757
767
|
legal: footerLegalDecorated,
|
|
758
|
-
copyright: site.footer?.copyright || ''
|
|
768
|
+
copyright: site.footer?.copyright || '',
|
|
769
|
+
// Highlighting is per menu, and the two footer columns are two menus.
|
|
770
|
+
...(footerPrimary?.highlight && {primaryHighlight: footerPrimary.highlight}),
|
|
771
|
+
...(footerLegal?.highlight && {legalHighlight: footerLegal.highlight})
|
|
759
772
|
};
|
|
760
773
|
const footerJson = JSON.stringify(footer).replace(/<\/script>/gi, '<\\/script>');
|
|
761
774
|
|
|
@@ -24,8 +24,11 @@
|
|
|
24
24
|
<link rel="stylesheet" href="/dist/domma/themes/domma-themes.css">
|
|
25
25
|
|
|
26
26
|
<!-- Site CSS -->
|
|
27
|
-
<link rel="stylesheet" href="/public/css/site.css?v=
|
|
27
|
+
<link rel="stylesheet" href="/public/css/site.css?v=20260905-menu-highlight">
|
|
28
28
|
<link rel="stylesheet" href="/public/css/forms.css?v=20260821-wizard-steps">
|
|
29
|
+
<!-- Menu item hover / current-page effects. Loaded after site.css so a
|
|
30
|
+
menu's own highlight wins over the blanket link styling there. -->
|
|
31
|
+
<link rel="stylesheet" href="/public/css/menu-highlight.css?v=20260905-menu-highlight">
|
|
29
32
|
|
|
30
33
|
<!-- Font overrides -->
|
|
31
34
|
{{fontStyleTag}}
|