domma-cms 0.17.0 → 0.21.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.
Files changed (136) hide show
  1. package/CLAUDE.md +39 -3
  2. package/admin/css/admin.css +1 -1
  3. package/admin/css/dashboard.css +1 -1
  4. package/admin/index.html +2 -2
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +4 -4
  7. package/admin/js/config/sidebar-config.js +1 -1
  8. package/admin/js/lib/card-builder.js +3 -3
  9. package/admin/js/lib/crud-tutorial.js +1 -0
  10. package/admin/js/lib/effects-builder.js +1 -1
  11. package/admin/js/lib/markdown-toolbar.js +6 -6
  12. package/admin/js/lib/project-context.js +1 -0
  13. package/admin/js/lib/sidebar-renderer.js +4 -0
  14. package/admin/js/templates/action-editor.html +7 -0
  15. package/admin/js/templates/block-editor.html +7 -0
  16. package/admin/js/templates/collection-editor.html +9 -0
  17. package/admin/js/templates/dashboard/cache.html +32 -0
  18. package/admin/js/templates/dashboard.html +4 -0
  19. package/admin/js/templates/form-editor.html +9 -0
  20. package/admin/js/templates/menu-editor.html +98 -0
  21. package/admin/js/templates/menu-locations.html +14 -0
  22. package/admin/js/templates/menus.html +14 -0
  23. package/admin/js/templates/page-editor.html +9 -2
  24. package/admin/js/templates/project-detail.html +50 -0
  25. package/admin/js/templates/project-editor.html +45 -0
  26. package/admin/js/templates/project-settings.html +60 -0
  27. package/admin/js/templates/projects.html +13 -0
  28. package/admin/js/templates/role-editor.html +11 -0
  29. package/admin/js/templates/settings.html +26 -0
  30. package/admin/js/templates/tutorials.html +335 -2
  31. package/admin/js/templates/view-editor.html +7 -0
  32. package/admin/js/views/action-editor.js +1 -1
  33. package/admin/js/views/actions-list.js +1 -1
  34. package/admin/js/views/block-editor-enhance.js +1 -1
  35. package/admin/js/views/block-editor.js +8 -8
  36. package/admin/js/views/blocks.js +2 -2
  37. package/admin/js/views/collection-editor.js +4 -4
  38. package/admin/js/views/collections.js +1 -1
  39. package/admin/js/views/dashboard/widgets/activity-feed.js +1 -1
  40. package/admin/js/views/dashboard/widgets/cache.js +1 -0
  41. package/admin/js/views/dashboard/widgets/journeys.js +1 -1
  42. package/admin/js/views/dashboard/widgets/spike-feed.js +1 -1
  43. package/admin/js/views/dashboard/widgets/top-pages.js +1 -1
  44. package/admin/js/views/dashboard.js +1 -1
  45. package/admin/js/views/form-editor.js +6 -6
  46. package/admin/js/views/forms.js +1 -1
  47. package/admin/js/views/index.js +1 -1
  48. package/admin/js/views/menu-editor.js +19 -0
  49. package/admin/js/views/menu-locations.js +1 -0
  50. package/admin/js/views/menus.js +5 -0
  51. package/admin/js/views/page-editor.js +41 -36
  52. package/admin/js/views/pages.js +3 -3
  53. package/admin/js/views/project-detail.js +4 -0
  54. package/admin/js/views/project-editor.js +1 -0
  55. package/admin/js/views/project-settings.js +1 -0
  56. package/admin/js/views/projects.js +7 -0
  57. package/admin/js/views/role-editor.js +1 -1
  58. package/admin/js/views/roles.js +3 -3
  59. package/admin/js/views/settings.js +3 -3
  60. package/admin/js/views/tutorials.js +1 -1
  61. package/admin/js/views/user-editor.js +1 -1
  62. package/admin/js/views/users.js +3 -3
  63. package/admin/js/views/view-editor.js +1 -1
  64. package/admin/js/views/views-list.js +1 -1
  65. package/config/cache.json +4 -0
  66. package/config/cache.json.example +12 -0
  67. package/config/menu-locations.json +5 -0
  68. package/config/menus/admin-sidebar.json +185 -0
  69. package/config/menus/footer.json +33 -0
  70. package/config/menus/main.json +35 -0
  71. package/config/menus/sproj-1779696558011-menu.json +17 -0
  72. package/config/menus/sproj-1779696960337-menu.json +18 -0
  73. package/config/menus/sproj-1779696985353-menu.json +18 -0
  74. package/config/site.json +6 -22
  75. package/package.json +4 -3
  76. package/plugins/analytics/daily.json +3 -0
  77. package/plugins/analytics/journeys.json +8 -0
  78. package/plugins/analytics/lifetime.json +1 -1
  79. package/public/css/site.css +1 -1
  80. package/public/js/collection-browser.js +4 -0
  81. package/public/js/forms.js +1 -1
  82. package/public/js/site.js +1 -1
  83. package/server/config.js +12 -1
  84. package/server/middleware/auth.js +88 -22
  85. package/server/routes/api/actions.js +58 -5
  86. package/server/routes/api/auth.js +2 -2
  87. package/server/routes/api/blocks.js +18 -3
  88. package/server/routes/api/cache.js +57 -0
  89. package/server/routes/api/collections.js +201 -8
  90. package/server/routes/api/forms.js +266 -21
  91. package/server/routes/api/menu-locations.js +46 -0
  92. package/server/routes/api/menus.js +115 -0
  93. package/server/routes/api/navigation.js +2 -0
  94. package/server/routes/api/pages.js +1 -1
  95. package/server/routes/api/projects.js +107 -0
  96. package/server/routes/api/scaffold.js +86 -0
  97. package/server/routes/api/settings.js +3 -0
  98. package/server/routes/api/sidebar.js +23 -0
  99. package/server/routes/api/users.js +32 -7
  100. package/server/routes/api/views.js +10 -2
  101. package/server/routes/public.js +88 -7
  102. package/server/server.js +54 -3
  103. package/server/services/actions.js +137 -8
  104. package/server/services/adapters/FileAdapter.js +23 -8
  105. package/server/services/adapters/MongoAdapter.js +36 -18
  106. package/server/services/blocks.js +23 -8
  107. package/server/services/cache/drivers/MemoryDriver.js +118 -0
  108. package/server/services/cache/drivers/NoneDriver.js +12 -0
  109. package/server/services/cache/index.js +229 -0
  110. package/server/services/cache/lru.js +61 -0
  111. package/server/services/collections.js +102 -12
  112. package/server/services/content.js +25 -6
  113. package/server/services/filterEngine.js +281 -0
  114. package/server/services/forms.js +3 -0
  115. package/server/services/hooks.js +48 -0
  116. package/server/services/markdown.js +711 -124
  117. package/server/services/menus-migration.js +107 -0
  118. package/server/services/menus.js +422 -0
  119. package/server/services/permissionRegistry.js +26 -0
  120. package/server/services/plugins.js +9 -2
  121. package/server/services/presetCollections.js +22 -0
  122. package/server/services/projects.js +429 -0
  123. package/server/services/recipes/contact-list.json +78 -0
  124. package/server/services/recipes/onboarding.json +426 -0
  125. package/server/services/references.js +174 -0
  126. package/server/services/renderer.js +237 -40
  127. package/server/services/roles.js +6 -1
  128. package/server/services/rowAccess.js +86 -13
  129. package/server/services/scaffolder.js +465 -0
  130. package/server/services/sidebar-migration.js +117 -0
  131. package/server/services/sitemap.js +112 -0
  132. package/server/services/userRoles.js +86 -0
  133. package/server/services/users.js +23 -2
  134. package/server/services/views.js +19 -4
  135. package/server/templates/page.html +135 -130
  136. /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} 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,42 @@ 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
+ // Compose the navigation object the public site script expects:
82
+ // brand from site.json.brand (parallel to adminBrand); items + variant +
83
+ // position + style from the menu mapped to the navbar slot.
84
+ // One-release fallback: if the navbar slot is unmapped, read the legacy
85
+ // navigation.json.bak file so a restored backup keeps working.
86
+ const navigation = navMenu
87
+ ? {
88
+ brand: site.brand || {},
89
+ items: navMenu.items,
90
+ variant: navMenu.variant || 'default',
91
+ position: navMenu.position || 'sticky',
92
+ ...(navMenu.style && {style: navMenu.style})
93
+ }
94
+ : (await readLegacyNavBackup()) || {brand: site.brand || {}, items: []};
69
95
 
70
96
  const preset = presets[page.layout] || presets['default'] || {};
71
97
 
@@ -85,6 +111,7 @@ export async function renderPage(page) {
85
111
  const seoTitle = escapeHtml(page.seo?.title || `${page.title}${site.seo?.titleSeparator || ' | '}${site.seo?.defaultTitle || site.title}`);
86
112
  const seoDescription = escapeHtml(page.seo?.description || site.seo?.defaultDescription || '');
87
113
  const ogImage = escapeHtml(page.seo?.image || site.seo?.defaultImage || '');
114
+ const seoTags = buildSeoTags({page, site, baseUrl, seoTitle, seoDescription, ogImage});
88
115
 
89
116
  const dconfig = page.dconfig || null;
90
117
  // Escape </script> to prevent injection via dconfig values in the inline script block
@@ -118,10 +145,19 @@ export async function renderPage(page) {
118
145
  const dommaTheme = site.baseTheme || activeTheme;
119
146
  const customThemeClass = site.baseTheme ? `dm-theme-${activeTheme}` : '';
120
147
 
148
+ // Compose window.__CMS_FOOTER__ from the menus mapped to footer-primary / footer-legal.
149
+ const footer = {
150
+ primary: footerPrimary ? footerPrimary.items : [],
151
+ legal: footerLegal ? footerLegal.items : [],
152
+ copyright: site.footer?.copyright || ''
153
+ };
154
+ const footerJson = JSON.stringify(footer).replace(/<\/script>/gi, '<\\/script>');
155
+
121
156
  const vars = {
122
157
  seoTitle,
123
158
  seoDescription,
124
159
  ogImage,
160
+ seoTags,
125
161
  title: page.title,
126
162
  html: page.html,
127
163
  breadcrumbsHtml,
@@ -137,6 +173,7 @@ export async function renderPage(page) {
137
173
  showFooter: preset.footer !== false,
138
174
  showSidebar: preset.sidebar === true || page.sidebar === true,
139
175
  navJson: JSON.stringify(filterHiddenNavItems(navigation)).replace(/<\/script>/gi, '<\\/script>'),
176
+ footerScript: footerJson ? `window.__CMS_FOOTER__ = ${footerJson};` : '',
140
177
  siteJson: JSON.stringify(Object.assign(
141
178
  {
142
179
  title: site.title,
@@ -170,6 +207,42 @@ export async function renderPage(page) {
170
207
  return applyTransforms('render:afterRender', html, {page});
171
208
  }
172
209
 
210
+ /**
211
+ * Build the ordered list of breadcrumb items for a page.
212
+ * Returns an empty array on the home page (or when the path has no segments).
213
+ *
214
+ * Used by both the visual breadcrumb renderer and the BreadcrumbList JSON-LD
215
+ * emitter, so the structured data and the visible trail stay in sync.
216
+ *
217
+ * @param {object} page - Parsed page object with urlPath, title
218
+ * @param {object} site - Site config (uses breadcrumbs.homeLabel)
219
+ * @returns {Array<{name: string, urlPath: string}>}
220
+ */
221
+ function buildBreadcrumbItems(page, site) {
222
+ const urlPath = page.urlPath || '/';
223
+ if (urlPath === '/') return [];
224
+
225
+ const segments = urlPath.split('/').filter(Boolean);
226
+ if (!segments.length) return [];
227
+
228
+ const homeLabel = site?.breadcrumbs?.homeLabel || 'Home';
229
+ const items = [{name: homeLabel, urlPath: '/'}];
230
+
231
+ for (let i = 0; i < segments.length - 1; i += 1) {
232
+ items.push({
233
+ name: toTitleCase(segments[i]),
234
+ urlPath: '/' + segments.slice(0, i + 1).join('/')
235
+ });
236
+ }
237
+
238
+ items.push({
239
+ name: page.title || toTitleCase(segments[segments.length - 1]),
240
+ urlPath
241
+ });
242
+
243
+ return items;
244
+ }
245
+
173
246
  /**
174
247
  * Build breadcrumbs HTML for a page.
175
248
  * Returns an empty string if breadcrumbs are disabled globally or per-page.
@@ -183,13 +256,8 @@ function buildBreadcrumbsHtml(page, site) {
183
256
  if (!cfg.enabled) return '';
184
257
  if (page.breadcrumbs === false) return '';
185
258
 
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 '';
259
+ const items = buildBreadcrumbItems(page, site);
260
+ if (!items.length) return '';
193
261
 
194
262
  // Build fixed-position inline style from corner + offsets
195
263
  const pos = (cfg.position || 'TL').toUpperCase();
@@ -200,21 +268,16 @@ function buildBreadcrumbsHtml(page, site) {
200
268
  pos.includes('L') ? `left:${offsetX}px` : `right:${offsetX}px`
201
269
  ].join(';');
202
270
 
203
- const crumbs = [];
204
-
205
271
  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
272
 
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>`);
273
+ const crumbs = items.map((item, i) => {
274
+ const isLast = i === items.length - 1;
275
+ if (isLast) {
276
+ return `<span class="dm-breadcrumbs-item dm-breadcrumbs-current" aria-current="page">${escapeHtml(item.name)}</span>`;
277
+ }
278
+ const icon = i === 0 ? homeIcon : '';
279
+ return `<a href="${item.urlPath}" class="dm-breadcrumbs-item dm-breadcrumbs-link">${icon}${escapeHtml(item.name)}</a>`;
280
+ });
218
281
 
219
282
  const sep = '<span class="dm-breadcrumbs-separator" aria-hidden="true">›</span>';
220
283
  return `<nav class="dm-breadcrumbs" aria-label="Breadcrumb" style="${posStyle}">${crumbs.join(sep)}</nav>`;
@@ -224,6 +287,88 @@ function toTitleCase(str) {
224
287
  return str.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
225
288
  }
226
289
 
290
+ /**
291
+ * Build the consolidated SEO `<meta>` tag block: canonical link, Open Graph,
292
+ * Twitter cards, JSON-LD, and a noindex hint when requested.
293
+ *
294
+ * All values are HTML-escaped at the call site (seoTitle/Description/ogImage)
295
+ * or here (canonicalUrl). JSON-LD is JSON.stringify'd and `</script>` is
296
+ * escaped to prevent breaking out of the script context.
297
+ *
298
+ * @param {object} args
299
+ * @param {object} args.page - Parsed page (urlPath, seo, title)
300
+ * @param {object} args.site - Site config (seo defaults)
301
+ * @param {string} args.baseUrl - Absolute origin for this request, derived by
302
+ * the route handler. Pass empty string to skip canonical/og:url emission.
303
+ * @param {string} args.seoTitle - Already HTML-escaped
304
+ * @param {string} args.seoDescription - Already HTML-escaped
305
+ * @param {string} args.ogImage - Already HTML-escaped (may be empty)
306
+ * @returns {string}
307
+ */
308
+ function buildSeoTags({page, site, baseUrl, seoTitle, seoDescription, ogImage}) {
309
+ const origin = (baseUrl || '').toString().trim().replace(/\/+$/, '');
310
+ const urlPath = page.urlPath || '/';
311
+ const canonicalUrl = origin ? escapeHtml(`${origin}${urlPath}`) : '';
312
+ const ogType = escapeHtml(page.seo?.ogType || 'article');
313
+ const siteName = escapeHtml(site.title || '');
314
+ const twitterHandle = escapeHtml(site.social?.twitter || '');
315
+ const noindex = page.seo?.noindex === true;
316
+
317
+ const tags = [];
318
+ if (canonicalUrl) tags.push(`<link rel="canonical" href="${canonicalUrl}">`);
319
+ if (noindex) tags.push('<meta name="robots" content="noindex, nofollow">');
320
+
321
+ tags.push(`<meta property="og:title" content="${seoTitle}">`);
322
+ tags.push(`<meta property="og:description" content="${seoDescription}">`);
323
+ tags.push(`<meta property="og:type" content="${ogType}">`);
324
+ if (siteName) tags.push(`<meta property="og:site_name" content="${siteName}">`);
325
+ if (canonicalUrl) tags.push(`<meta property="og:url" content="${canonicalUrl}">`);
326
+ if (ogImage) tags.push(`<meta property="og:image" content="${ogImage}">`);
327
+
328
+ tags.push(`<meta name="twitter:card" content="${ogImage ? 'summary_large_image' : 'summary'}">`);
329
+ tags.push(`<meta name="twitter:title" content="${seoTitle}">`);
330
+ tags.push(`<meta name="twitter:description" content="${seoDescription}">`);
331
+ if (ogImage) tags.push(`<meta name="twitter:image" content="${ogImage}">`);
332
+ if (twitterHandle) tags.push(`<meta name="twitter:site" content="${twitterHandle}">`);
333
+
334
+ // JSON-LD WebPage schema — gives search engines a structured summary.
335
+ // Omit `url` when no baseUrl, since a relative URL in JSON-LD is invalid.
336
+ const jsonLd = {
337
+ '@context': 'https://schema.org',
338
+ '@type': 'WebPage',
339
+ name: page.title || '',
340
+ description: page.seo?.description || site.seo?.defaultDescription || ''
341
+ };
342
+ if (canonicalUrl) jsonLd.url = origin + urlPath;
343
+ if (page.seo?.image || site.seo?.defaultImage) {
344
+ jsonLd.image = page.seo?.image || site.seo?.defaultImage;
345
+ }
346
+ const jsonLdString = JSON.stringify(jsonLd).replace(/<\/script>/gi, '<\\/script>');
347
+ tags.push(`<script type="application/ld+json">${jsonLdString}</script>`);
348
+
349
+ // BreadcrumbList JSON-LD — emit whenever the page has a non-trivial path
350
+ // and an origin is known (Schema.org requires absolute URLs in `item`).
351
+ // Independent of the visual breadcrumb setting so SERPs can show the trail
352
+ // even when the on-page nav hides it.
353
+ const breadcrumbItems = buildBreadcrumbItems(page, site);
354
+ if (origin && breadcrumbItems.length > 1) {
355
+ const breadcrumbJsonLd = {
356
+ '@context': 'https://schema.org',
357
+ '@type': 'BreadcrumbList',
358
+ itemListElement: breadcrumbItems.map((item, i) => ({
359
+ '@type': 'ListItem',
360
+ position: i + 1,
361
+ name: item.name,
362
+ item: origin + item.urlPath
363
+ }))
364
+ };
365
+ const breadcrumbString = JSON.stringify(breadcrumbJsonLd).replace(/<\/script>/gi, '<\\/script>');
366
+ tags.push(`<script type="application/ld+json">${breadcrumbString}</script>`);
367
+ }
368
+
369
+ return tags.join('\n ');
370
+ }
371
+
227
372
  function escapeHtml(str) {
228
373
  return String(str)
229
374
  .replace(/&/g, '&amp;')
@@ -288,18 +433,41 @@ function buildFontVars(fontFamily, fontSize) {
288
433
  *
289
434
  * @param {string} templatePath - Absolute path to the plugin HTML fragment
290
435
  * @param {object} data - Token replacement map; keys become {{key}} placeholders
291
- * @param {object} seoMeta - Optional SEO overrides: { title, description, ogImage }
436
+ * @param {object} seoMeta - Optional SEO overrides: { title, description, ogImage,
437
+ * urlPath, ogType, noindex, baseUrl, user }. Pass `baseUrl` (derived from the
438
+ * request) to populate canonical and og:url tags. Pass `user` (`{role, additionalRoles}`
439
+ * or null) so menu items gated by `visibility` are filtered correctly; blog
440
+ * callers currently default to anonymous (null).
292
441
  * @returns {Promise<string>}
293
442
  */
294
443
  export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
295
- const [fragment, template, site, navigation, injection, customCss] = await Promise.all([
444
+ const user = seoMeta.user || null;
445
+ const [fragment, template, site, injection, customCss, navMenu, footerPrimary, footerLegal] = await Promise.all([
296
446
  fs.readFile(templatePath, 'utf8'),
297
447
  getTemplate(),
298
448
  Promise.resolve(getConfig('site')),
299
- Promise.resolve(getConfig('navigation')),
300
449
  getInjectionSnippets(),
301
- getCustomCss()
450
+ getCustomCss(),
451
+ resolveLocation('navbar', user),
452
+ resolveLocation('footer-primary', user),
453
+ resolveLocation('footer-legal', user)
302
454
  ]);
455
+ const baseUrl = seoMeta.baseUrl || site.baseUrl || '';
456
+
457
+ // Compose the navigation object the public site script expects:
458
+ // brand from site.json.brand (parallel to adminBrand); items + variant +
459
+ // position + style from the menu mapped to the navbar slot.
460
+ // One-release fallback: if the navbar slot is unmapped, read the legacy
461
+ // navigation.json.bak file so a restored backup keeps working.
462
+ const navigation = navMenu
463
+ ? {
464
+ brand: site.brand || {},
465
+ items: navMenu.items,
466
+ variant: navMenu.variant || 'default',
467
+ position: navMenu.position || 'sticky',
468
+ ...(navMenu.style && {style: navMenu.style})
469
+ }
470
+ : (await readLegacyNavBackup()) || {brand: site.brand || {}, items: []};
303
471
 
304
472
  // Replace {{key}} tokens in the plugin fragment with data values
305
473
  const renderedFragment = fragment.replace(/\{\{(\w+)\}\}/g, (_, key) => {
@@ -312,17 +480,18 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
312
480
  const seoDescription = escapeHtml(seoMeta.description ?? site.seo?.defaultDescription ?? '');
313
481
  const ogImage = escapeHtml(seoMeta.ogImage ?? '');
314
482
 
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');
483
+ // Plugin fragments use the unified SEO builder with a synthetic page object.
484
+ const syntheticPage = {
485
+ urlPath: seoMeta.urlPath || '/',
486
+ title: seoMeta.title || site.title || '',
487
+ seo: {
488
+ description: seoMeta.description,
489
+ image: seoMeta.ogImage,
490
+ ogType: seoMeta.ogType || 'website',
491
+ noindex: seoMeta.noindex === true
492
+ }
493
+ };
494
+ const seoTags = buildSeoTags({page: syntheticPage, site, baseUrl, seoTitle, seoDescription, ogImage});
326
495
 
327
496
  const {fontLink, fontOverride} = buildFontVars(site.fontFamily, site.fontSize);
328
497
  const fontStyleTag = fontOverride ? `<style>${fontOverride}</style>` : '';
@@ -336,10 +505,19 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
336
505
  const dommaTheme = site.baseTheme || activeTheme;
337
506
  const customThemeClass = site.baseTheme ? `dm-theme-${activeTheme}` : '';
338
507
 
508
+ // Compose window.__CMS_FOOTER__ from the menus mapped to footer-primary / footer-legal.
509
+ const footer = {
510
+ primary: footerPrimary ? footerPrimary.items : [],
511
+ legal: footerLegal ? footerLegal.items : [],
512
+ copyright: site.footer?.copyright || ''
513
+ };
514
+ const footerJson = JSON.stringify(footer).replace(/<\/script>/gi, '<\\/script>');
515
+
339
516
  const vars = {
340
517
  seoTitle,
341
518
  seoDescription,
342
519
  ogImage,
520
+ seoTags,
343
521
  title: seoTitle,
344
522
  html: renderedFragment,
345
523
  breadcrumbsHtml: '',
@@ -355,6 +533,7 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
355
533
  showFooter: true,
356
534
  showSidebar: false,
357
535
  navJson: JSON.stringify(filterHiddenNavItems(navigation)).replace(/<\/script>/gi, '<\\/script>'),
536
+ footerScript: footerJson ? `window.__CMS_FOOTER__ = ${footerJson};` : '',
358
537
  siteJson: JSON.stringify({
359
538
  title: site.title,
360
539
  footer: site.footer
@@ -365,7 +544,7 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
365
544
  : site.footer,
366
545
  social: site.social || null
367
546
  }).replace(/<\/script>/gi, '<\\/script>'),
368
- headInject: [ogTags, injection.head, navbarFontLink].filter(Boolean).join('\n'),
547
+ headInject: [injection.head, navbarFontLink].filter(Boolean).join('\n'),
369
548
  headInjectLate: [injection.headLate, customCssTag, navbarStyleTag].filter(Boolean).join('\n'),
370
549
  bodyEndInject: [
371
550
  injection.bodyEnd,
@@ -379,6 +558,24 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
379
558
  return interpolate(template, vars);
380
559
  }
381
560
 
561
+ /**
562
+ * Read config/navigation.json.bak as a one-release fallback when no menu is
563
+ * mapped to the navbar slot. Returns null when the .bak is missing.
564
+ *
565
+ * @returns {Promise<object|null>}
566
+ */
567
+ async function readLegacyNavBackup() {
568
+ try {
569
+ const here = fileURLToPath(import.meta.url);
570
+ const bak = path.resolve(path.dirname(here), '..', '..', 'config', 'navigation.json.bak');
571
+ const raw = await fs.readFile(bak, 'utf8');
572
+ console.warn('[menus] navbar location unmapped — falling back to navigation.json.bak (deprecated)');
573
+ return JSON.parse(raw);
574
+ } catch {
575
+ return null;
576
+ }
577
+ }
578
+
382
579
  /**
383
580
  * Simple template interpolation.
384
581
  * 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
  }