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.
- package/CLAUDE.md +39 -3
- package/admin/css/admin.css +1 -1
- package/admin/css/dashboard.css +1 -1
- package/admin/index.html +2 -2
- 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/card-builder.js +3 -3
- package/admin/js/lib/crud-tutorial.js +1 -0
- package/admin/js/lib/effects-builder.js +1 -1
- package/admin/js/lib/markdown-toolbar.js +6 -6
- 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/dashboard/cache.html +32 -0
- package/admin/js/templates/dashboard.html +4 -0
- package/admin/js/templates/form-editor.html +9 -0
- package/admin/js/templates/menu-editor.html +98 -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/settings.html +26 -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-enhance.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/dashboard/widgets/activity-feed.js +1 -1
- package/admin/js/views/dashboard/widgets/cache.js +1 -0
- package/admin/js/views/dashboard/widgets/journeys.js +1 -1
- package/admin/js/views/dashboard/widgets/spike-feed.js +1 -1
- package/admin/js/views/dashboard/widgets/top-pages.js +1 -1
- package/admin/js/views/dashboard.js +1 -1
- package/admin/js/views/form-editor.js +6 -6
- package/admin/js/views/forms.js +1 -1
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/menu-editor.js +19 -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 +41 -36
- 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/settings.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/cache.json +4 -0
- package/config/cache.json.example +12 -0
- 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/config.js +12 -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/cache.js +57 -0
- 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/navigation.js +2 -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/settings.js +3 -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 +88 -7
- package/server/server.js +54 -3
- 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 +23 -8
- package/server/services/cache/drivers/MemoryDriver.js +118 -0
- package/server/services/cache/drivers/NoneDriver.js +12 -0
- package/server/services/cache/index.js +229 -0
- package/server/services/cache/lru.js +61 -0
- package/server/services/collections.js +102 -12
- package/server/services/content.js +25 -6
- package/server/services/filterEngine.js +281 -0
- package/server/services/forms.js +3 -0
- package/server/services/hooks.js +48 -0
- package/server/services/markdown.js +711 -124
- package/server/services/menus-migration.js +107 -0
- package/server/services/menus.js +422 -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 +237 -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 +19 -4
- package/server/templates/page.html +135 -130
- /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,
|
|
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
|
|
187
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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, '&')
|
|
@@ -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
|
|
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
|
-
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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: [
|
|
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}}.
|
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
|
}
|