domma-cms 0.18.0 → 0.22.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.
Files changed (110) hide show
  1. package/CLAUDE.md +37 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/api.js +1 -1
  4. package/admin/js/app.js +4 -4
  5. package/admin/js/config/sidebar-config.js +1 -1
  6. package/admin/js/lib/crud-tutorial.js +1 -0
  7. package/admin/js/lib/markdown-toolbar.js +5 -5
  8. package/admin/js/lib/project-context.js +1 -0
  9. package/admin/js/lib/sidebar-renderer.js +4 -0
  10. package/admin/js/templates/action-editor.html +7 -0
  11. package/admin/js/templates/block-editor.html +7 -0
  12. package/admin/js/templates/collection-editor.html +9 -0
  13. package/admin/js/templates/form-editor.html +9 -0
  14. package/admin/js/templates/menu-editor.html +99 -0
  15. package/admin/js/templates/menu-locations.html +14 -0
  16. package/admin/js/templates/menus.html +14 -0
  17. package/admin/js/templates/page-editor.html +9 -2
  18. package/admin/js/templates/project-detail.html +50 -0
  19. package/admin/js/templates/project-editor.html +45 -0
  20. package/admin/js/templates/project-settings.html +60 -0
  21. package/admin/js/templates/projects.html +13 -0
  22. package/admin/js/templates/role-editor.html +11 -0
  23. package/admin/js/templates/tutorials.html +335 -2
  24. package/admin/js/templates/view-editor.html +7 -0
  25. package/admin/js/views/action-editor.js +1 -1
  26. package/admin/js/views/actions-list.js +1 -1
  27. package/admin/js/views/block-editor.js +8 -8
  28. package/admin/js/views/blocks.js +2 -2
  29. package/admin/js/views/collection-editor.js +4 -4
  30. package/admin/js/views/collections.js +1 -1
  31. package/admin/js/views/form-editor.js +5 -5
  32. package/admin/js/views/forms.js +1 -1
  33. package/admin/js/views/index.js +1 -1
  34. package/admin/js/views/menu-editor.js +20 -0
  35. package/admin/js/views/menu-locations.js +1 -0
  36. package/admin/js/views/menus.js +5 -0
  37. package/admin/js/views/page-editor.js +24 -24
  38. package/admin/js/views/pages.js +3 -3
  39. package/admin/js/views/project-detail.js +4 -0
  40. package/admin/js/views/project-editor.js +1 -0
  41. package/admin/js/views/project-settings.js +1 -0
  42. package/admin/js/views/projects.js +7 -0
  43. package/admin/js/views/role-editor.js +1 -1
  44. package/admin/js/views/roles.js +3 -3
  45. package/admin/js/views/tutorials.js +1 -1
  46. package/admin/js/views/user-editor.js +1 -1
  47. package/admin/js/views/users.js +3 -3
  48. package/admin/js/views/view-editor.js +1 -1
  49. package/admin/js/views/views-list.js +1 -1
  50. package/config/menu-locations.json +5 -0
  51. package/config/menus/admin-sidebar.json +185 -0
  52. package/config/menus/footer.json +33 -0
  53. package/config/menus/main.json +35 -0
  54. package/config/menus/sproj-1779696558011-menu.json +17 -0
  55. package/config/menus/sproj-1779696960337-menu.json +18 -0
  56. package/config/menus/sproj-1779696985353-menu.json +18 -0
  57. package/config/site.json +6 -22
  58. package/package.json +4 -3
  59. package/plugins/analytics/daily.json +3 -0
  60. package/plugins/analytics/journeys.json +8 -0
  61. package/plugins/analytics/lifetime.json +1 -1
  62. package/public/css/site.css +1 -1
  63. package/public/js/collection-browser.js +4 -0
  64. package/public/js/forms.js +1 -1
  65. package/public/js/site.js +1 -1
  66. package/server/middleware/auth.js +88 -22
  67. package/server/routes/api/actions.js +58 -5
  68. package/server/routes/api/auth.js +2 -2
  69. package/server/routes/api/blocks.js +18 -3
  70. package/server/routes/api/collections.js +201 -8
  71. package/server/routes/api/forms.js +266 -21
  72. package/server/routes/api/menu-locations.js +46 -0
  73. package/server/routes/api/menus.js +115 -0
  74. package/server/routes/api/pages.js +1 -1
  75. package/server/routes/api/projects.js +107 -0
  76. package/server/routes/api/scaffold.js +86 -0
  77. package/server/routes/api/sidebar.js +23 -0
  78. package/server/routes/api/users.js +32 -7
  79. package/server/routes/api/views.js +10 -2
  80. package/server/routes/public.js +79 -6
  81. package/server/server.js +38 -0
  82. package/server/services/actions.js +137 -8
  83. package/server/services/adapters/FileAdapter.js +23 -8
  84. package/server/services/adapters/MongoAdapter.js +36 -18
  85. package/server/services/blocks.js +20 -8
  86. package/server/services/collections.js +85 -8
  87. package/server/services/content.js +23 -9
  88. package/server/services/filterEngine.js +281 -0
  89. package/server/services/hooks.js +48 -0
  90. package/server/services/markdown.js +702 -109
  91. package/server/services/menus-migration.js +107 -0
  92. package/server/services/menus.js +524 -0
  93. package/server/services/permissionRegistry.js +26 -0
  94. package/server/services/plugins.js +9 -2
  95. package/server/services/presetCollections.js +22 -0
  96. package/server/services/projects.js +429 -0
  97. package/server/services/recipes/contact-list.json +78 -0
  98. package/server/services/recipes/onboarding.json +426 -0
  99. package/server/services/references.js +174 -0
  100. package/server/services/renderer.js +253 -40
  101. package/server/services/roles.js +6 -1
  102. package/server/services/rowAccess.js +86 -13
  103. package/server/services/scaffolder.js +465 -0
  104. package/server/services/sidebar-migration.js +117 -0
  105. package/server/services/sitemap.js +112 -0
  106. package/server/services/userRoles.js +86 -0
  107. package/server/services/users.js +23 -2
  108. package/server/services/views.js +15 -4
  109. package/server/templates/page.html +7 -2
  110. /package/config/{navigation.json → navigation.json.bak} +0 -0
@@ -8,6 +8,7 @@ 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
12
 
12
13
  const VALID_LAYOUT_WIDTHS = new Set(['narrow', 'normal', 'wide', 'full']);
13
14
  const CUSTOM_CSS_PATH = new URL('../../content/custom.css', import.meta.url).pathname;
@@ -55,17 +56,50 @@ function filterHiddenNavItems(nav) {
55
56
  * Render a page to a full HTML string.
56
57
  *
57
58
  * @param {object} page - Parsed page from content service
59
+ * @param {object} [opts]
60
+ * @param {string} [opts.baseUrl] - Absolute origin used for canonical URLs and
61
+ * Open Graph `og:url`. Typically derived from the request (e.g.
62
+ * `${request.protocol}://${request.hostname}`) by the route handler.
63
+ * Falls back to `site.baseUrl` from config, then to no canonical at all.
64
+ * @param {object|null} [opts.user] - Authenticated user (`{role, additionalRoles}`)
65
+ * or null for anonymous. Used to filter menu items gated by `visibility`.
58
66
  * @returns {Promise<string>}
59
67
  */
60
- export async function renderPage(page) {
61
- const [template, site, navigation, presets, injection, customCss] = await Promise.all([
68
+ export async function renderPage(page, opts = {}) {
69
+ const [template, site, presets, injection, customCss, navMenu, footerPrimary, footerLegal] = await Promise.all([
62
70
  getTemplate(),
63
71
  Promise.resolve(getConfig('site')),
64
- Promise.resolve(getConfig('navigation')),
65
72
  Promise.resolve(getConfig('presets')),
66
73
  getInjectionSnippets(),
67
- getCustomCss()
74
+ getCustomCss(),
75
+ resolveLocation('navbar', opts.user || null),
76
+ resolveLocation('footer-primary', opts.user || null),
77
+ resolveLocation('footer-legal', opts.user || null)
68
78
  ]);
79
+ const baseUrl = opts.baseUrl || site.baseUrl || '';
80
+
81
+ // Resolve live badge counts (countFrom) before the menus are injected as
82
+ // globals. Static badges/colours/pills pass through unchanged.
83
+ const [navItemsDecorated, footerPrimaryDecorated, footerLegalDecorated] = await Promise.all([
84
+ resolveMenuDecorations(navMenu ? navMenu.items : []),
85
+ resolveMenuDecorations(footerPrimary ? footerPrimary.items : []),
86
+ resolveMenuDecorations(footerLegal ? footerLegal.items : [])
87
+ ]);
88
+
89
+ // Compose the navigation object the public site script expects:
90
+ // brand from site.json.brand (parallel to adminBrand); items + variant +
91
+ // position + style from the menu mapped to the navbar slot.
92
+ // One-release fallback: if the navbar slot is unmapped, read the legacy
93
+ // navigation.json.bak file so a restored backup keeps working.
94
+ const navigation = navMenu
95
+ ? {
96
+ brand: site.brand || {},
97
+ items: navItemsDecorated,
98
+ variant: navMenu.variant || 'default',
99
+ position: navMenu.position || 'sticky',
100
+ ...(navMenu.style && {style: navMenu.style})
101
+ }
102
+ : (await readLegacyNavBackup()) || {brand: site.brand || {}, items: []};
69
103
 
70
104
  const preset = presets[page.layout] || presets['default'] || {};
71
105
 
@@ -85,6 +119,7 @@ export async function renderPage(page) {
85
119
  const seoTitle = escapeHtml(page.seo?.title || `${page.title}${site.seo?.titleSeparator || ' | '}${site.seo?.defaultTitle || site.title}`);
86
120
  const seoDescription = escapeHtml(page.seo?.description || site.seo?.defaultDescription || '');
87
121
  const ogImage = escapeHtml(page.seo?.image || site.seo?.defaultImage || '');
122
+ const seoTags = buildSeoTags({page, site, baseUrl, seoTitle, seoDescription, ogImage});
88
123
 
89
124
  const dconfig = page.dconfig || null;
90
125
  // Escape </script> to prevent injection via dconfig values in the inline script block
@@ -118,10 +153,19 @@ export async function renderPage(page) {
118
153
  const dommaTheme = site.baseTheme || activeTheme;
119
154
  const customThemeClass = site.baseTheme ? `dm-theme-${activeTheme}` : '';
120
155
 
156
+ // Compose window.__CMS_FOOTER__ from the menus mapped to footer-primary / footer-legal.
157
+ const footer = {
158
+ primary: footerPrimaryDecorated,
159
+ legal: footerLegalDecorated,
160
+ copyright: site.footer?.copyright || ''
161
+ };
162
+ const footerJson = JSON.stringify(footer).replace(/<\/script>/gi, '<\\/script>');
163
+
121
164
  const vars = {
122
165
  seoTitle,
123
166
  seoDescription,
124
167
  ogImage,
168
+ seoTags,
125
169
  title: page.title,
126
170
  html: page.html,
127
171
  breadcrumbsHtml,
@@ -137,6 +181,7 @@ export async function renderPage(page) {
137
181
  showFooter: preset.footer !== false,
138
182
  showSidebar: preset.sidebar === true || page.sidebar === true,
139
183
  navJson: JSON.stringify(filterHiddenNavItems(navigation)).replace(/<\/script>/gi, '<\\/script>'),
184
+ footerScript: footerJson ? `window.__CMS_FOOTER__ = ${footerJson};` : '',
140
185
  siteJson: JSON.stringify(Object.assign(
141
186
  {
142
187
  title: site.title,
@@ -170,6 +215,42 @@ export async function renderPage(page) {
170
215
  return applyTransforms('render:afterRender', html, {page});
171
216
  }
172
217
 
218
+ /**
219
+ * Build the ordered list of breadcrumb items for a page.
220
+ * Returns an empty array on the home page (or when the path has no segments).
221
+ *
222
+ * Used by both the visual breadcrumb renderer and the BreadcrumbList JSON-LD
223
+ * emitter, so the structured data and the visible trail stay in sync.
224
+ *
225
+ * @param {object} page - Parsed page object with urlPath, title
226
+ * @param {object} site - Site config (uses breadcrumbs.homeLabel)
227
+ * @returns {Array<{name: string, urlPath: string}>}
228
+ */
229
+ function buildBreadcrumbItems(page, site) {
230
+ const urlPath = page.urlPath || '/';
231
+ if (urlPath === '/') return [];
232
+
233
+ const segments = urlPath.split('/').filter(Boolean);
234
+ if (!segments.length) return [];
235
+
236
+ const homeLabel = site?.breadcrumbs?.homeLabel || 'Home';
237
+ const items = [{name: homeLabel, urlPath: '/'}];
238
+
239
+ for (let i = 0; i < segments.length - 1; i += 1) {
240
+ items.push({
241
+ name: toTitleCase(segments[i]),
242
+ urlPath: '/' + segments.slice(0, i + 1).join('/')
243
+ });
244
+ }
245
+
246
+ items.push({
247
+ name: page.title || toTitleCase(segments[segments.length - 1]),
248
+ urlPath
249
+ });
250
+
251
+ return items;
252
+ }
253
+
173
254
  /**
174
255
  * Build breadcrumbs HTML for a page.
175
256
  * Returns an empty string if breadcrumbs are disabled globally or per-page.
@@ -183,13 +264,8 @@ function buildBreadcrumbsHtml(page, site) {
183
264
  if (!cfg.enabled) return '';
184
265
  if (page.breadcrumbs === false) return '';
185
266
 
186
- const urlPath = page.urlPath || '/';
187
- // Don't render breadcrumbs on the home page
188
- if (urlPath === '/') return '';
189
-
190
- const homeLabel = cfg.homeLabel || 'Home';
191
- const segments = urlPath.split('/').filter(Boolean);
192
- if (!segments.length) return '';
267
+ const items = buildBreadcrumbItems(page, site);
268
+ if (!items.length) return '';
193
269
 
194
270
  // Build fixed-position inline style from corner + offsets
195
271
  const pos = (cfg.position || 'TL').toUpperCase();
@@ -200,21 +276,16 @@ function buildBreadcrumbsHtml(page, site) {
200
276
  pos.includes('L') ? `left:${offsetX}px` : `right:${offsetX}px`
201
277
  ].join(';');
202
278
 
203
- const crumbs = [];
204
-
205
279
  const homeIcon = '<svg class="dm-breadcrumbs-home-icon" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>';
206
- // Home crumb
207
- crumbs.push(`<a href="/" class="dm-breadcrumbs-item dm-breadcrumbs-link">${homeIcon}${escapeHtml(homeLabel)}</a>`);
208
-
209
- // Middle crumbs (each intermediate path segment)
210
- for (let i = 0; i < segments.length - 1; i++) {
211
- const href = '/' + segments.slice(0, i + 1).join('/');
212
- const label = toTitleCase(segments[i]);
213
- crumbs.push(`<a href="${href}" class="dm-breadcrumbs-item dm-breadcrumbs-link">${escapeHtml(label)}</a>`);
214
- }
215
280
 
216
- // Last crumb current page title, no link
217
- crumbs.push(`<span class="dm-breadcrumbs-item dm-breadcrumbs-current" aria-current="page">${escapeHtml(page.title || toTitleCase(segments[segments.length - 1]))}</span>`);
281
+ const crumbs = items.map((item, i) => {
282
+ const isLast = i === items.length - 1;
283
+ if (isLast) {
284
+ return `<span class="dm-breadcrumbs-item dm-breadcrumbs-current" aria-current="page">${escapeHtml(item.name)}</span>`;
285
+ }
286
+ const icon = i === 0 ? homeIcon : '';
287
+ return `<a href="${item.urlPath}" class="dm-breadcrumbs-item dm-breadcrumbs-link">${icon}${escapeHtml(item.name)}</a>`;
288
+ });
218
289
 
219
290
  const sep = '<span class="dm-breadcrumbs-separator" aria-hidden="true">›</span>';
220
291
  return `<nav class="dm-breadcrumbs" aria-label="Breadcrumb" style="${posStyle}">${crumbs.join(sep)}</nav>`;
@@ -224,6 +295,88 @@ function toTitleCase(str) {
224
295
  return str.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
225
296
  }
226
297
 
298
+ /**
299
+ * Build the consolidated SEO `<meta>` tag block: canonical link, Open Graph,
300
+ * Twitter cards, JSON-LD, and a noindex hint when requested.
301
+ *
302
+ * All values are HTML-escaped at the call site (seoTitle/Description/ogImage)
303
+ * or here (canonicalUrl). JSON-LD is JSON.stringify'd and `</script>` is
304
+ * escaped to prevent breaking out of the script context.
305
+ *
306
+ * @param {object} args
307
+ * @param {object} args.page - Parsed page (urlPath, seo, title)
308
+ * @param {object} args.site - Site config (seo defaults)
309
+ * @param {string} args.baseUrl - Absolute origin for this request, derived by
310
+ * the route handler. Pass empty string to skip canonical/og:url emission.
311
+ * @param {string} args.seoTitle - Already HTML-escaped
312
+ * @param {string} args.seoDescription - Already HTML-escaped
313
+ * @param {string} args.ogImage - Already HTML-escaped (may be empty)
314
+ * @returns {string}
315
+ */
316
+ function buildSeoTags({page, site, baseUrl, seoTitle, seoDescription, ogImage}) {
317
+ const origin = (baseUrl || '').toString().trim().replace(/\/+$/, '');
318
+ const urlPath = page.urlPath || '/';
319
+ const canonicalUrl = origin ? escapeHtml(`${origin}${urlPath}`) : '';
320
+ const ogType = escapeHtml(page.seo?.ogType || 'article');
321
+ const siteName = escapeHtml(site.title || '');
322
+ const twitterHandle = escapeHtml(site.social?.twitter || '');
323
+ const noindex = page.seo?.noindex === true;
324
+
325
+ const tags = [];
326
+ if (canonicalUrl) tags.push(`<link rel="canonical" href="${canonicalUrl}">`);
327
+ if (noindex) tags.push('<meta name="robots" content="noindex, nofollow">');
328
+
329
+ tags.push(`<meta property="og:title" content="${seoTitle}">`);
330
+ tags.push(`<meta property="og:description" content="${seoDescription}">`);
331
+ tags.push(`<meta property="og:type" content="${ogType}">`);
332
+ if (siteName) tags.push(`<meta property="og:site_name" content="${siteName}">`);
333
+ if (canonicalUrl) tags.push(`<meta property="og:url" content="${canonicalUrl}">`);
334
+ if (ogImage) tags.push(`<meta property="og:image" content="${ogImage}">`);
335
+
336
+ tags.push(`<meta name="twitter:card" content="${ogImage ? 'summary_large_image' : 'summary'}">`);
337
+ tags.push(`<meta name="twitter:title" content="${seoTitle}">`);
338
+ tags.push(`<meta name="twitter:description" content="${seoDescription}">`);
339
+ if (ogImage) tags.push(`<meta name="twitter:image" content="${ogImage}">`);
340
+ if (twitterHandle) tags.push(`<meta name="twitter:site" content="${twitterHandle}">`);
341
+
342
+ // JSON-LD WebPage schema — gives search engines a structured summary.
343
+ // Omit `url` when no baseUrl, since a relative URL in JSON-LD is invalid.
344
+ const jsonLd = {
345
+ '@context': 'https://schema.org',
346
+ '@type': 'WebPage',
347
+ name: page.title || '',
348
+ description: page.seo?.description || site.seo?.defaultDescription || ''
349
+ };
350
+ if (canonicalUrl) jsonLd.url = origin + urlPath;
351
+ if (page.seo?.image || site.seo?.defaultImage) {
352
+ jsonLd.image = page.seo?.image || site.seo?.defaultImage;
353
+ }
354
+ const jsonLdString = JSON.stringify(jsonLd).replace(/<\/script>/gi, '<\\/script>');
355
+ tags.push(`<script type="application/ld+json">${jsonLdString}</script>`);
356
+
357
+ // BreadcrumbList JSON-LD — emit whenever the page has a non-trivial path
358
+ // and an origin is known (Schema.org requires absolute URLs in `item`).
359
+ // Independent of the visual breadcrumb setting so SERPs can show the trail
360
+ // even when the on-page nav hides it.
361
+ const breadcrumbItems = buildBreadcrumbItems(page, site);
362
+ if (origin && breadcrumbItems.length > 1) {
363
+ const breadcrumbJsonLd = {
364
+ '@context': 'https://schema.org',
365
+ '@type': 'BreadcrumbList',
366
+ itemListElement: breadcrumbItems.map((item, i) => ({
367
+ '@type': 'ListItem',
368
+ position: i + 1,
369
+ name: item.name,
370
+ item: origin + item.urlPath
371
+ }))
372
+ };
373
+ const breadcrumbString = JSON.stringify(breadcrumbJsonLd).replace(/<\/script>/gi, '<\\/script>');
374
+ tags.push(`<script type="application/ld+json">${breadcrumbString}</script>`);
375
+ }
376
+
377
+ return tags.join('\n ');
378
+ }
379
+
227
380
  function escapeHtml(str) {
228
381
  return String(str)
229
382
  .replace(/&/g, '&amp;')
@@ -288,19 +441,50 @@ function buildFontVars(fontFamily, fontSize) {
288
441
  *
289
442
  * @param {string} templatePath - Absolute path to the plugin HTML fragment
290
443
  * @param {object} data - Token replacement map; keys become {{key}} placeholders
291
- * @param {object} seoMeta - Optional SEO overrides: { title, description, ogImage }
444
+ * @param {object} seoMeta - Optional SEO overrides: { title, description, ogImage,
445
+ * urlPath, ogType, noindex, baseUrl, user }. Pass `baseUrl` (derived from the
446
+ * request) to populate canonical and og:url tags. Pass `user` (`{role, additionalRoles}`
447
+ * or null) so menu items gated by `visibility` are filtered correctly; blog
448
+ * callers currently default to anonymous (null).
292
449
  * @returns {Promise<string>}
293
450
  */
294
451
  export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
295
- const [fragment, template, site, navigation, injection, customCss] = await Promise.all([
452
+ const user = seoMeta.user || null;
453
+ const [fragment, template, site, injection, customCss, navMenu, footerPrimary, footerLegal] = await Promise.all([
296
454
  fs.readFile(templatePath, 'utf8'),
297
455
  getTemplate(),
298
456
  Promise.resolve(getConfig('site')),
299
- Promise.resolve(getConfig('navigation')),
300
457
  getInjectionSnippets(),
301
- getCustomCss()
458
+ getCustomCss(),
459
+ resolveLocation('navbar', user),
460
+ resolveLocation('footer-primary', user),
461
+ resolveLocation('footer-legal', user)
462
+ ]);
463
+ const baseUrl = seoMeta.baseUrl || site.baseUrl || '';
464
+
465
+ // Resolve live badge counts (countFrom) before the menus are injected as
466
+ // globals. Static badges/colours/pills pass through unchanged.
467
+ const [navItemsDecorated, footerPrimaryDecorated, footerLegalDecorated] = await Promise.all([
468
+ resolveMenuDecorations(navMenu ? navMenu.items : []),
469
+ resolveMenuDecorations(footerPrimary ? footerPrimary.items : []),
470
+ resolveMenuDecorations(footerLegal ? footerLegal.items : [])
302
471
  ]);
303
472
 
473
+ // Compose the navigation object the public site script expects:
474
+ // brand from site.json.brand (parallel to adminBrand); items + variant +
475
+ // position + style from the menu mapped to the navbar slot.
476
+ // One-release fallback: if the navbar slot is unmapped, read the legacy
477
+ // navigation.json.bak file so a restored backup keeps working.
478
+ const navigation = navMenu
479
+ ? {
480
+ brand: site.brand || {},
481
+ items: navItemsDecorated,
482
+ variant: navMenu.variant || 'default',
483
+ position: navMenu.position || 'sticky',
484
+ ...(navMenu.style && {style: navMenu.style})
485
+ }
486
+ : (await readLegacyNavBackup()) || {brand: site.brand || {}, items: []};
487
+
304
488
  // Replace {{key}} tokens in the plugin fragment with data values
305
489
  const renderedFragment = fragment.replace(/\{\{(\w+)\}\}/g, (_, key) => {
306
490
  const val = data[key];
@@ -312,17 +496,18 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
312
496
  const seoDescription = escapeHtml(seoMeta.description ?? site.seo?.defaultDescription ?? '');
313
497
  const ogImage = escapeHtml(seoMeta.ogImage ?? '');
314
498
 
315
- const ogType = escapeHtml(seoMeta.ogType ?? 'website');
316
- const ogTags = [
317
- `<meta property="og:title" content="${seoTitle}">`,
318
- `<meta property="og:description" content="${seoDescription}">`,
319
- `<meta property="og:type" content="${ogType}">`,
320
- ogImage ? `<meta property="og:image" content="${ogImage}">` : '',
321
- `<meta name="twitter:card" content="${ogImage ? 'summary_large_image' : 'summary'}">`,
322
- `<meta name="twitter:title" content="${seoTitle}">`,
323
- `<meta name="twitter:description" content="${seoDescription}">`,
324
- ogImage ? `<meta name="twitter:image" content="${ogImage}">` : ''
325
- ].filter(Boolean).join('\n');
499
+ // Plugin fragments use the unified SEO builder with a synthetic page object.
500
+ const syntheticPage = {
501
+ urlPath: seoMeta.urlPath || '/',
502
+ title: seoMeta.title || site.title || '',
503
+ seo: {
504
+ description: seoMeta.description,
505
+ image: seoMeta.ogImage,
506
+ ogType: seoMeta.ogType || 'website',
507
+ noindex: seoMeta.noindex === true
508
+ }
509
+ };
510
+ const seoTags = buildSeoTags({page: syntheticPage, site, baseUrl, seoTitle, seoDescription, ogImage});
326
511
 
327
512
  const {fontLink, fontOverride} = buildFontVars(site.fontFamily, site.fontSize);
328
513
  const fontStyleTag = fontOverride ? `<style>${fontOverride}</style>` : '';
@@ -336,10 +521,19 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
336
521
  const dommaTheme = site.baseTheme || activeTheme;
337
522
  const customThemeClass = site.baseTheme ? `dm-theme-${activeTheme}` : '';
338
523
 
524
+ // Compose window.__CMS_FOOTER__ from the menus mapped to footer-primary / footer-legal.
525
+ const footer = {
526
+ primary: footerPrimaryDecorated,
527
+ legal: footerLegalDecorated,
528
+ copyright: site.footer?.copyright || ''
529
+ };
530
+ const footerJson = JSON.stringify(footer).replace(/<\/script>/gi, '<\\/script>');
531
+
339
532
  const vars = {
340
533
  seoTitle,
341
534
  seoDescription,
342
535
  ogImage,
536
+ seoTags,
343
537
  title: seoTitle,
344
538
  html: renderedFragment,
345
539
  breadcrumbsHtml: '',
@@ -355,6 +549,7 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
355
549
  showFooter: true,
356
550
  showSidebar: false,
357
551
  navJson: JSON.stringify(filterHiddenNavItems(navigation)).replace(/<\/script>/gi, '<\\/script>'),
552
+ footerScript: footerJson ? `window.__CMS_FOOTER__ = ${footerJson};` : '',
358
553
  siteJson: JSON.stringify({
359
554
  title: site.title,
360
555
  footer: site.footer
@@ -365,7 +560,7 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
365
560
  : site.footer,
366
561
  social: site.social || null
367
562
  }).replace(/<\/script>/gi, '<\\/script>'),
368
- headInject: [ogTags, injection.head, navbarFontLink].filter(Boolean).join('\n'),
563
+ headInject: [injection.head, navbarFontLink].filter(Boolean).join('\n'),
369
564
  headInjectLate: [injection.headLate, customCssTag, navbarStyleTag].filter(Boolean).join('\n'),
370
565
  bodyEndInject: [
371
566
  injection.bodyEnd,
@@ -379,6 +574,24 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
379
574
  return interpolate(template, vars);
380
575
  }
381
576
 
577
+ /**
578
+ * Read config/navigation.json.bak as a one-release fallback when no menu is
579
+ * mapped to the navbar slot. Returns null when the .bak is missing.
580
+ *
581
+ * @returns {Promise<object|null>}
582
+ */
583
+ async function readLegacyNavBackup() {
584
+ try {
585
+ const here = fileURLToPath(import.meta.url);
586
+ const bak = path.resolve(path.dirname(here), '..', '..', 'config', 'navigation.json.bak');
587
+ const raw = await fs.readFile(bak, 'utf8');
588
+ console.warn('[menus] navbar location unmapped — falling back to navigation.json.bak (deprecated)');
589
+ return JSON.parse(raw);
590
+ } catch {
591
+ return null;
592
+ }
593
+ }
594
+
382
595
  /**
383
596
  * Simple template interpolation.
384
597
  * Handles {{variable}}, {{#if flag}}...{{/if}}.
@@ -118,7 +118,12 @@ function buildCache(entries) {
118
118
 
119
119
  for (const entry of entries) {
120
120
  const d = entry.data;
121
- roleMap.set(d.name, {label: d.label, level: d.level, badgeClass: d.badgeClass || ''});
121
+ roleMap.set(d.name, {
122
+ label: d.label,
123
+ level: d.level,
124
+ badgeClass: d.badgeClass || '',
125
+ ...(d.meta != null && {meta: d.meta})
126
+ });
122
127
  rawPermissionsMap.set(d.name, d.permissions || []);
123
128
  for (const perm of (d.permissions || [])) {
124
129
  if (perm.includes('.')) {
@@ -2,18 +2,37 @@
2
2
  * Row-Level Access Control
3
3
  *
4
4
  * Shared helpers for scoping Actions and Views to specific entries based on
5
- * ownership or a foreign-key field match.
5
+ * ownership, a foreign-key field match, or a referenced entry's ownership.
6
6
  *
7
7
  * rowLevel shape (stored in action/view access config):
8
- * {
9
- * mode: 'owner' | 'field',
10
- * field: 'assigned_to', // required when mode === 'field'
11
- * userKey: 'id' // user property to match (default: 'id')
12
- * }
8
+ *
9
+ * { mode: 'owner' }
10
+ * Entry's `meta.createdBy` must equal `user[userKey]` (defaults to user.id).
11
+ * Standard "you only see entries you created" pattern.
12
+ *
13
+ * { mode: 'field', field: 'assignedTo' }
14
+ * Entry's `data.<field>` must equal `user[userKey]`. Use for explicit
15
+ * assignment (e.g. tickets routed to a specific user id).
16
+ *
17
+ * { mode: 'reference', field: 'jobId', targetCollection: 'jobs', targetField: 'createdBy' }
18
+ * Resolve `data.<field>` against `targetCollection` and check that target
19
+ * entry's `meta.createdBy` (or `data.<targetField>` if `targetField` is a
20
+ * data field name) equals `user[userKey]`. This is "user can access THIS
21
+ * entry because they own the entry it points at" — the recruiter/employer
22
+ * pattern: applications scoped to the jobs the user posted.
23
+ *
24
+ * `targetField` accepts:
25
+ * - 'createdBy' (default) → matches against target's meta.createdBy
26
+ * - any other string → matches against target's data.<targetField>
27
+ *
28
+ * All modes accept an optional `userKey` (default 'id') to control which
29
+ * user property is matched.
13
30
  *
14
31
  * Admin users (role level 0) always bypass row-level checks.
15
32
  */
16
33
  import {getRoleLevel} from './roles.js';
34
+ import {getEffectiveLevel} from './userRoles.js';
35
+ import {getAdapter} from './adapterRegistry.js';
17
36
 
18
37
  // ---------------------------------------------------------------------------
19
38
  // Helpers
@@ -38,7 +57,9 @@ function resolveUserValue(user, userKey = 'id') {
38
57
  */
39
58
  function isAdmin(user) {
40
59
  if (!user?.role) return false;
41
- return getRoleLevel(user.role) === 0;
60
+ // Use effective level so a user with "additionalRoles: ['super-admin']"
61
+ // bypasses row-level checks even when their primary role is lower-tier.
62
+ return getEffectiveLevel(user) === 0;
42
63
  }
43
64
 
44
65
  // ---------------------------------------------------------------------------
@@ -47,19 +68,29 @@ function isAdmin(user) {
47
68
 
48
69
  /**
49
70
  * Check whether a user may access a specific entry under row-level rules.
50
- * Returns `true` when: rowLevel is absent, user is admin, or the entry
51
- * satisfies the configured mode.
71
+ *
72
+ * **Async because `mode: 'reference'` requires loading the referenced entry
73
+ * to compare its owner.** Callers MUST `await` the result.
74
+ *
75
+ * Returns `true` when: rowLevel is absent, user is admin, the entry directly
76
+ * satisfies an `owner`/`field` rule, OR the entry's referenced target
77
+ * satisfies the `reference` rule. Returns `false` only when a rule is set
78
+ * AND the entry fails it. Misconfigured rules (missing fields, unknown
79
+ * targets) fail closed — return `false` to avoid leaking data through typos.
52
80
  *
53
81
  * @param {object} entry - Full entry object ({ id, data, meta })
54
82
  * @param {object|null} user - Authenticated user object
55
83
  * @param {object|null} rowLevel - rowLevel config from action/view access
56
- * @returns {boolean}
84
+ * @param {object} [opts]
85
+ * @param {Function} [opts.adapterResolver] - Optional async (slug) → adapter — for tests
86
+ * @returns {Promise<boolean>}
57
87
  */
58
- export function checkEntryAccess(entry, user, rowLevel) {
88
+ export async function checkEntryAccess(entry, user, rowLevel, opts = {}) {
59
89
  if (!rowLevel) return true;
60
90
  if (isAdmin(user)) return true;
61
91
 
62
92
  const userVal = resolveUserValue(user, rowLevel.userKey);
93
+ if (userVal == null) return false;
63
94
 
64
95
  if (rowLevel.mode === 'owner') {
65
96
  return entry?.meta?.createdBy === userVal;
@@ -67,11 +98,47 @@ export function checkEntryAccess(entry, user, rowLevel) {
67
98
 
68
99
  if (rowLevel.mode === 'field') {
69
100
  const field = rowLevel.field;
70
- if (!field) return true; // misconfigured — be permissive
101
+ if (!field) return false; // misconfigured — fail closed
71
102
  return entry?.data?.[field] === userVal;
72
103
  }
73
104
 
74
- return true; // unknown mode be permissive
105
+ if (rowLevel.mode === 'reference') {
106
+ const field = rowLevel.field;
107
+ const targetCollection = rowLevel.targetCollection;
108
+ const targetField = rowLevel.targetField || 'createdBy';
109
+ if (!field || !targetCollection) return false; // misconfigured — fail closed
110
+
111
+ // The referenced id lives at data.<field>. Arrays of ids are supported
112
+ // — access is granted when ANY of the targets is owned by the user
113
+ // (e.g. "I can see this entry because I own at least one of its parents").
114
+ const raw = entry?.data?.[field];
115
+ if (raw == null || raw === '') return false;
116
+ const ids = Array.isArray(raw) ? raw.map(String) : [String(raw)];
117
+
118
+ const resolver = opts.adapterResolver || getAdapter;
119
+ let adapter;
120
+ try {
121
+ adapter = await resolver(targetCollection);
122
+ } catch {
123
+ return false;
124
+ }
125
+
126
+ for (const id of ids) {
127
+ try {
128
+ const target = await adapter.get(targetCollection, id);
129
+ if (!target) continue; // dangling reference — skip
130
+ const value = targetField === 'createdBy'
131
+ ? target.meta?.createdBy
132
+ : target.data?.[targetField];
133
+ if (value === userVal) return true;
134
+ } catch {
135
+ /* continue probing other ids on adapter error */
136
+ }
137
+ }
138
+ return false;
139
+ }
140
+
141
+ return false; // unknown mode — fail closed
75
142
  }
76
143
 
77
144
  /**
@@ -80,6 +147,10 @@ export function checkEntryAccess(entry, user, rowLevel) {
80
147
  *
81
148
  * The returned object is ready to use as `{ $match: buildRowLevelMatch(...) }`.
82
149
  *
150
+ * Note: `mode: 'reference'` can't be expressed as a flat $match without a
151
+ * `$lookup` — for that mode this function returns `null` and callers must
152
+ * fall back to per-entry `checkEntryAccess()` filtering (slower but correct).
153
+ *
83
154
  * @param {object|null} user
84
155
  * @param {object|null} rowLevel
85
156
  * @returns {object|null}
@@ -100,5 +171,7 @@ export function buildRowLevelMatch(user, rowLevel) {
100
171
  return {[`data.${field}`]: userVal};
101
172
  }
102
173
 
174
+ // 'reference' mode needs a join — caller should use checkEntryAccess()
175
+ // per-entry instead. Return null so the upstream code knows to do that.
103
176
  return null;
104
177
  }