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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "0.41.1",
3
+ "version": "0.42.0",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -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, colourToCss, floatToCss} from './menus.js';
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
- // A floating menu pins itself to a viewport corner/edge. `float="no"`
3909
- // opts a single placement out, so the same menu can be floated in its
3910
- // slot yet rendered inline where the shortcode drops it.
3911
- const floatCss = attrs.float === 'no' ? '' : floatToCss(menu);
3912
- const floatClass = floatCss ? ' dm-menu-floating' : '';
3913
- const floatAttrs = floatCss
3914
- ? ` style="${escapeAttr(floatCss)}" data-float-anchor="${escapeAttr(menu.float?.anchor || 'TL')}"`
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, '&amp;')
19
+ .replace(/"/g, '&quot;')
20
+ .replace(/</g, '&lt;')
21
+ .replace(/>/g, '&gt;');
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
+ }
@@ -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>`)