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.
@@ -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} items
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 floatAttrs = floatCss
90
- ? ` style="${esc(floatCss)}" data-float-anchor="${esc(menu.float?.anchor || 'TL')}"`
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)}"${floatAttrs}${behAttrs}>`
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
 
@@ -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=20260822-vertical-menu">
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}}