domma-cms 0.40.2 → 0.41.1

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.
@@ -9,6 +9,7 @@ import {getConfig} from '../config.js';
9
9
  import {getInjectionSnippets} from './plugins.js';
10
10
  import {applyTransforms} from './hooks.js';
11
11
  import {resolveLocation, resolveMenuDecorations} from './menus.js';
12
+ import {getProjectForPage} from './projects.js';
12
13
 
13
14
  const VALID_LAYOUT_WIDTHS = new Set(['narrow', 'normal', 'wide', 'full']);
14
15
  const CUSTOM_CSS_PATH = new URL('../../content/custom.css', import.meta.url).pathname;
@@ -66,15 +67,23 @@ function filterHiddenNavItems(nav) {
66
67
  * @returns {Promise<string>}
67
68
  */
68
69
  export async function renderPage(page, opts = {}) {
70
+ // Page context for menu bindings — a menu bound to this URL or this page's
71
+ // project takes its slot over. `resolvedProject` is stamped by the content
72
+ // service; fall back to resolving it here for callers that skip that path.
73
+ const menuCtx = {
74
+ urlPath: page.urlPath || page.url || '/',
75
+ project: page.resolvedProject
76
+ || await getProjectForPage(page.urlPath || page.url || '/', page.project)
77
+ };
69
78
  const [template, site, presets, injection, customCss, navMenu, footerPrimary, footerLegal] = await Promise.all([
70
79
  getTemplate(),
71
80
  Promise.resolve(getConfig('site')),
72
81
  Promise.resolve(getConfig('presets')),
73
82
  getInjectionSnippets(),
74
83
  getCustomCss(),
75
- resolveLocation('navbar', opts.user || null),
76
- resolveLocation('footer-primary', opts.user || null),
77
- resolveLocation('footer-legal', opts.user || null)
84
+ resolveLocation('navbar', opts.user || null, menuCtx),
85
+ resolveLocation('footer-primary', opts.user || null, menuCtx),
86
+ resolveLocation('footer-legal', opts.user || null, menuCtx)
78
87
  ]);
79
88
  const baseUrl = opts.baseUrl || site.baseUrl || '';
80
89
 
@@ -97,6 +106,11 @@ export async function renderPage(page, opts = {}) {
97
106
  items: navItemsDecorated,
98
107
  variant: navMenu.variant || 'default',
99
108
  position: navMenu.position || 'sticky',
109
+ // Orientation drives the client-side layout class; `side` picks the
110
+ // edge a vertical rail docks to. Both are omitted when the menu is
111
+ // the horizontal default, so __CMS_NAV__ stays unchanged for
112
+ // existing sites.
113
+ ...(navMenu.orientation === 'vertical' && {orientation: 'vertical', side: navMenu.side === 'right' ? 'right' : 'left'}),
100
114
  ...(navMenu.appearOnHover && {appearOnHover: true}),
101
115
  ...(navMenu.style && {style: navMenu.style})
102
116
  }
@@ -107,7 +121,12 @@ export async function renderPage(page, opts = {}) {
107
121
  // Per-page `width:` frontmatter overrides the preset's width when valid.
108
122
  const pageWidth = VALID_LAYOUT_WIDTHS.has(page.width) ? page.width : null;
109
123
  const layoutWidth = pageWidth || (VALID_LAYOUT_WIDTHS.has(preset.width) ? preset.width : 'normal');
110
- const layoutBodyClass = ['dm-layout-' + layoutWidth, preset.class || ''].filter(Boolean).join(' ');
124
+ // A vertical navbar docks as a side rail, so the body needs its gutter from
125
+ // the first paint — stamping the class client-side would shift the layout.
126
+ const verticalNavClass = navigation.orientation === 'vertical'
127
+ ? `dm-vnav dm-vnav-${navigation.side === 'right' ? 'right' : 'left'}`
128
+ : '';
129
+ const layoutBodyClass = ['dm-layout-' + layoutWidth, preset.class || '', verticalNavClass].filter(Boolean).join(' ');
111
130
  const pageBodyStyleParts = [];
112
131
  const BG_COLOR_SAFE = /^[a-zA-Z0-9#(),.\s%/-]+$/;
113
132
  if (preset.bgColor && BG_COLOR_SAFE.test(preset.bgColor)) pageBodyStyleParts.push(`background-color:${preset.bgColor}`);
@@ -398,7 +417,7 @@ function buildNavbarStyleTag(nav) {
398
417
  }
399
418
  const rules = [];
400
419
  const decls = [];
401
- if (s.fontFamily) decls.push(`font-family: ${s.fontFamily}, sans-serif`);
420
+ if (s.fontFamily) decls.push(`font-family: ${fontFamilyValue(s.fontFamily)}`);
402
421
  if (s.fontSize) decls.push(`font-size: ${s.fontSize}`);
403
422
  if (s.fontWeight) decls.push(`font-weight: ${s.fontWeight}`);
404
423
  if (s.letterSpacing) decls.push(`letter-spacing: ${s.letterSpacing}`);
@@ -414,6 +433,33 @@ function buildNavbarStyleTag(nav) {
414
433
  return {navbarFontLink, navbarStyleTag};
415
434
  }
416
435
 
436
+ // Families whose CSS fallback should be `serif`/`monospace` rather than the default `sans-serif`.
437
+ const SERIF_FAMILIES = new Set([
438
+ 'Merriweather', 'Playfair Display', 'Lora', 'Roboto Slab',
439
+ 'Cormorant Garamond', 'DM Serif Display', 'IBM Plex Serif'
440
+ ]);
441
+ const MONO_FAMILIES = new Set(['Fira Code', 'JetBrains Mono', 'IBM Plex Mono']);
442
+
443
+ /**
444
+ * Build a `font-family` value for a configured family name.
445
+ *
446
+ * Family names are quoted: `Source Sans 3` is not a valid unquoted CSS identifier
447
+ * (a digit cannot start one), so the whole declaration would be dropped. The name is
448
+ * also stripped to letters/digits/space/hyphen because the navbar family is a free-text
449
+ * admin field that lands inside a `<style>` tag.
450
+ *
451
+ * @param {string} family - Configured family name, or the `system-ui`/`inherit` keywords
452
+ * @returns {string} CSS font-family value including its generic fallback
453
+ */
454
+ function fontFamilyValue(family) {
455
+ if (family === 'inherit') return 'inherit';
456
+ if (family === 'system-ui') return 'system-ui, -apple-system, sans-serif';
457
+ const safe = String(family).replace(/[^\w\s-]/g, '').trim();
458
+ if (!safe) return 'inherit';
459
+ const generic = SERIF_FAMILIES.has(safe) ? 'serif' : MONO_FAMILIES.has(safe) ? 'monospace' : 'sans-serif';
460
+ return `'${safe}', ${generic}`;
461
+ }
462
+
417
463
  function buildFontVars(fontFamily, fontSize) {
418
464
  const PRECONNECT = '<link rel="preconnect" href="https://fonts.googleapis.com">\n <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>';
419
465
  const family = fontFamily || 'Roboto';
@@ -424,13 +470,13 @@ function buildFontVars(fontFamily, fontSize) {
424
470
  let fontLink;
425
471
  if (family === 'system-ui') {
426
472
  fontLink = null;
427
- rules.push('body, button, input, select, textarea { font-family: system-ui, -apple-system, sans-serif; }');
473
+ rules.push(`body, button, input, select, textarea { font-family: ${fontFamilyValue(family)}; }`);
428
474
  } else {
429
475
  const encoded = family.replace(/ /g, '+');
430
476
  const href = `https://fonts.googleapis.com/css2?family=${encoded}:ital,wght@0,300;0,400;0,500;0,700;1,400&display=swap`;
431
477
  fontLink = `${PRECONNECT}\n <link rel="stylesheet" href="${href}">`;
432
478
  if (family !== 'Roboto') {
433
- rules.push(`body, button, input, select, textarea { font-family: '${family}', sans-serif; }`);
479
+ rules.push(`body, button, input, select, textarea { font-family: ${fontFamilyValue(family)}; }`);
434
480
  }
435
481
  }
436
482
 
@@ -451,15 +497,20 @@ function buildFontVars(fontFamily, fontSize) {
451
497
  */
452
498
  export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
453
499
  const user = seoMeta.user || null;
500
+ // Plugin fragments know their own URL via seoMeta.urlPath — enough for a
501
+ // menu bound to that page or to the project the URL falls under.
502
+ const menuCtx = seoMeta.urlPath
503
+ ? {urlPath: seoMeta.urlPath, project: await getProjectForPage(seoMeta.urlPath)}
504
+ : {};
454
505
  const [fragment, template, site, injection, customCss, navMenu, footerPrimary, footerLegal] = await Promise.all([
455
506
  fs.readFile(templatePath, 'utf8'),
456
507
  getTemplate(),
457
508
  Promise.resolve(getConfig('site')),
458
509
  getInjectionSnippets(),
459
510
  getCustomCss(),
460
- resolveLocation('navbar', user),
461
- resolveLocation('footer-primary', user),
462
- resolveLocation('footer-legal', user)
511
+ resolveLocation('navbar', user, menuCtx),
512
+ resolveLocation('footer-primary', user, menuCtx),
513
+ resolveLocation('footer-legal', user, menuCtx)
463
514
  ]);
464
515
  const baseUrl = seoMeta.baseUrl || site.baseUrl || '';
465
516
 
@@ -482,6 +533,11 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
482
533
  items: navItemsDecorated,
483
534
  variant: navMenu.variant || 'default',
484
535
  position: navMenu.position || 'sticky',
536
+ // Orientation drives the client-side layout class; `side` picks the
537
+ // edge a vertical rail docks to. Both are omitted when the menu is
538
+ // the horizontal default, so __CMS_NAV__ stays unchanged for
539
+ // existing sites.
540
+ ...(navMenu.orientation === 'vertical' && {orientation: 'vertical', side: navMenu.side === 'right' ? 'right' : 'left'}),
485
541
  ...(navMenu.appearOnHover && {appearOnHover: true}),
486
542
  ...(navMenu.style && {style: navMenu.style})
487
543
  }
@@ -545,7 +601,9 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
545
601
  fontLink,
546
602
  fontStyleTag,
547
603
  layout: 'default',
548
- layoutBodyClass: 'dm-layout-normal',
604
+ layoutBodyClass: ['dm-layout-normal', navigation.orientation === 'vertical'
605
+ ? `dm-vnav dm-vnav-${navigation.side === 'right' ? 'right' : 'left'}`
606
+ : ''].filter(Boolean).join(' '),
549
607
  pageBodyStyle: '',
550
608
  showNavbar: true,
551
609
  showFooter: true,
@@ -9,6 +9,7 @@
9
9
  import fs from 'fs/promises';
10
10
  import path from 'path';
11
11
  import {fileURLToPath} from 'url';
12
+ import {invalidateMenuIndex} from './menus.js';
12
13
 
13
14
  const __filename = fileURLToPath(import.meta.url);
14
15
  const DEFAULT_CONFIG_DIR = path.resolve(path.dirname(__filename), '..', '..', 'config');
@@ -147,6 +148,9 @@ async function readJson(p) {
147
148
 
148
149
  async function writeJson(p, data) {
149
150
  await fs.writeFile(p, JSON.stringify(data, null, 2) + '\n', 'utf8');
151
+ // Menu files written outside the menus service — drop its cached index so
152
+ // binding resolution sees the seeded sidebar menu.
153
+ invalidateMenuIndex();
150
154
  }
151
155
 
152
156
  /**
@@ -116,7 +116,7 @@
116
116
  </script>
117
117
 
118
118
  <!-- Site initialisation -->
119
- <script src="/public/js/site.js?v=20260817-floating-menus" type="module"></script>
119
+ <script src="/public/js/site.js?v=20260817-menu-bindings" type="module"></script>
120
120
  <script src="/public/js/collection-context.js?v=20260722-ctx-cursor" defer></script>
121
121
 
122
122
  <!-- Interactive Collection Browser (used by [collection] shortcodes with