domma-cms 0.40.2 → 0.41.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 +6 -0
- package/admin/css/admin.css +1 -1
- package/admin/js/lib/menu-tree.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +1 -1
- package/admin/js/templates/menu-editor.html +90 -0
- package/admin/js/templates/navigation.html +5 -1
- package/admin/js/templates/settings.html +29 -11
- package/admin/js/views/menu-editor.js +12 -8
- package/admin/js/views/menus.js +4 -4
- package/package.json +1 -1
- package/public/css/site.css +1 -1
- package/public/js/menu-decor.mjs +1 -1
- package/public/js/site.js +1 -1
- package/server/services/content.js +4 -1
- package/server/services/markdown.js +41 -4
- package/server/services/menus-migration.js +4 -0
- package/server/services/menus.js +313 -13
- package/server/services/renderer.js +69 -11
- package/server/services/sidebar-migration.js +4 -0
- package/server/templates/page.html +1 -1
|
@@ -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
|
-
|
|
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}
|
|
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(
|
|
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:
|
|
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-
|
|
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
|