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.
- package/CLAUDE.md +37 -3
- package/admin/css/admin.css +1 -1
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/config/sidebar-config.js +1 -1
- package/admin/js/lib/crud-tutorial.js +1 -0
- package/admin/js/lib/markdown-toolbar.js +5 -5
- package/admin/js/lib/project-context.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +4 -0
- package/admin/js/templates/action-editor.html +7 -0
- package/admin/js/templates/block-editor.html +7 -0
- package/admin/js/templates/collection-editor.html +9 -0
- package/admin/js/templates/form-editor.html +9 -0
- package/admin/js/templates/menu-editor.html +99 -0
- package/admin/js/templates/menu-locations.html +14 -0
- package/admin/js/templates/menus.html +14 -0
- package/admin/js/templates/page-editor.html +9 -2
- package/admin/js/templates/project-detail.html +50 -0
- package/admin/js/templates/project-editor.html +45 -0
- package/admin/js/templates/project-settings.html +60 -0
- package/admin/js/templates/projects.html +13 -0
- package/admin/js/templates/role-editor.html +11 -0
- package/admin/js/templates/tutorials.html +335 -2
- package/admin/js/templates/view-editor.html +7 -0
- package/admin/js/views/action-editor.js +1 -1
- package/admin/js/views/actions-list.js +1 -1
- package/admin/js/views/block-editor.js +8 -8
- package/admin/js/views/blocks.js +2 -2
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/collections.js +1 -1
- package/admin/js/views/form-editor.js +5 -5
- package/admin/js/views/forms.js +1 -1
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/menu-editor.js +20 -0
- package/admin/js/views/menu-locations.js +1 -0
- package/admin/js/views/menus.js +5 -0
- package/admin/js/views/page-editor.js +24 -24
- package/admin/js/views/pages.js +3 -3
- package/admin/js/views/project-detail.js +4 -0
- package/admin/js/views/project-editor.js +1 -0
- package/admin/js/views/project-settings.js +1 -0
- package/admin/js/views/projects.js +7 -0
- package/admin/js/views/role-editor.js +1 -1
- package/admin/js/views/roles.js +3 -3
- package/admin/js/views/tutorials.js +1 -1
- package/admin/js/views/user-editor.js +1 -1
- package/admin/js/views/users.js +3 -3
- package/admin/js/views/view-editor.js +1 -1
- package/admin/js/views/views-list.js +1 -1
- package/config/menu-locations.json +5 -0
- package/config/menus/admin-sidebar.json +185 -0
- package/config/menus/footer.json +33 -0
- package/config/menus/main.json +35 -0
- package/config/menus/sproj-1779696558011-menu.json +17 -0
- package/config/menus/sproj-1779696960337-menu.json +18 -0
- package/config/menus/sproj-1779696985353-menu.json +18 -0
- package/config/site.json +6 -22
- package/package.json +4 -3
- package/plugins/analytics/daily.json +3 -0
- package/plugins/analytics/journeys.json +8 -0
- package/plugins/analytics/lifetime.json +1 -1
- package/public/css/site.css +1 -1
- package/public/js/collection-browser.js +4 -0
- package/public/js/forms.js +1 -1
- package/public/js/site.js +1 -1
- package/server/middleware/auth.js +88 -22
- package/server/routes/api/actions.js +58 -5
- package/server/routes/api/auth.js +2 -2
- package/server/routes/api/blocks.js +18 -3
- package/server/routes/api/collections.js +201 -8
- package/server/routes/api/forms.js +266 -21
- package/server/routes/api/menu-locations.js +46 -0
- package/server/routes/api/menus.js +115 -0
- package/server/routes/api/pages.js +1 -1
- package/server/routes/api/projects.js +107 -0
- package/server/routes/api/scaffold.js +86 -0
- package/server/routes/api/sidebar.js +23 -0
- package/server/routes/api/users.js +32 -7
- package/server/routes/api/views.js +10 -2
- package/server/routes/public.js +79 -6
- package/server/server.js +38 -0
- package/server/services/actions.js +137 -8
- package/server/services/adapters/FileAdapter.js +23 -8
- package/server/services/adapters/MongoAdapter.js +36 -18
- package/server/services/blocks.js +20 -8
- package/server/services/collections.js +85 -8
- package/server/services/content.js +23 -9
- package/server/services/filterEngine.js +281 -0
- package/server/services/hooks.js +48 -0
- package/server/services/markdown.js +702 -109
- package/server/services/menus-migration.js +107 -0
- package/server/services/menus.js +524 -0
- package/server/services/permissionRegistry.js +26 -0
- package/server/services/plugins.js +9 -2
- package/server/services/presetCollections.js +22 -0
- package/server/services/projects.js +429 -0
- package/server/services/recipes/contact-list.json +78 -0
- package/server/services/recipes/onboarding.json +426 -0
- package/server/services/references.js +174 -0
- package/server/services/renderer.js +253 -40
- package/server/services/roles.js +6 -1
- package/server/services/rowAccess.js +86 -13
- package/server/services/scaffolder.js +465 -0
- package/server/services/sidebar-migration.js +117 -0
- package/server/services/sitemap.js +112 -0
- package/server/services/userRoles.js +86 -0
- package/server/services/users.js +23 -2
- package/server/services/views.js +15 -4
- package/server/templates/page.html +7 -2
- /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,
|
|
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
|
|
187
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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, '&')
|
|
@@ -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
|
|
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
|
-
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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: [
|
|
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}}.
|
package/server/services/roles.js
CHANGED
|
@@ -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, {
|
|
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
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
51
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
101
|
+
if (!field) return false; // misconfigured — fail closed
|
|
71
102
|
return entry?.data?.[field] === userVal;
|
|
72
103
|
}
|
|
73
104
|
|
|
74
|
-
|
|
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
|
}
|