domma-cms 0.41.1 → 0.43.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 +38 -2
- package/admin/js/templates/collection-editor.html +1 -1
- package/admin/js/views/action-editor.js +1 -1
- package/admin/js/views/block-editor.js +2 -2
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/form-editor.js +6 -6
- package/admin/js/views/navigation.js +16 -16
- package/admin/js/views/page-editor.js +41 -41
- package/admin/js/views/view-editor.js +1 -1
- package/package.json +1 -1
- package/public/css/site.css +1 -1
- package/public/js/collection-context.js +2 -2
- package/public/js/collection-export.mjs +3 -0
- package/public/js/collection-query.mjs +1 -0
- package/public/js/collection-sort.mjs +1 -0
- package/public/js/site.js +1 -1
- package/scripts/setup.js +23 -2
- package/server/services/collections.js +12 -11
- package/server/services/markdown.js +116 -79
- package/server/services/menuRender.js +90 -0
- package/server/services/menus.js +46 -0
- package/server/services/renderer.js +33 -5
- package/server/templates/page.html +1 -1
|
@@ -11,8 +11,10 @@ import {fileURLToPath} from 'url';
|
|
|
11
11
|
import {applyTransforms, getSanitizeExtensions, getShortcodeProcessors} from './hooks.js';
|
|
12
12
|
import {getConfig} from '../config.js';
|
|
13
13
|
import {getCollection, listEntries} from './collections.js';
|
|
14
|
-
import {getMenu, resolveLocation, resolveMenuDecorations
|
|
14
|
+
import {getMenu, resolveLocation, resolveMenuDecorations} from './menus.js';
|
|
15
|
+
import {buildMenuNav} from './menuRender.js';
|
|
15
16
|
import {checkVisibility} from '../middleware/auth.js';
|
|
17
|
+
import {getRoleHierarchy} from './roles.js';
|
|
16
18
|
|
|
17
19
|
const __dirname_md = path.dirname(fileURLToPath(import.meta.url));
|
|
18
20
|
const BLOCKS_DIR = path.resolve(__dirname_md, '../../content/blocks');
|
|
@@ -173,6 +175,51 @@ function formatDate(input, fmt) {
|
|
|
173
175
|
t => String(tokens[t]()));
|
|
174
176
|
}
|
|
175
177
|
|
|
178
|
+
const EXPORT_FORMATS = ['csv', 'json'];
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Decide whether a display offers export, and to whom. First match wins:
|
|
182
|
+
* 1. schema export.enabled === false → never (exportable="true" ignored)
|
|
183
|
+
* 2. exportable="true" on the shortcode → public (grandfathered: that is
|
|
184
|
+
* exactly what the attribute has always meant)
|
|
185
|
+
* 3. schema export.access → as configured
|
|
186
|
+
* 4. otherwise → the least-privileged role
|
|
187
|
+
*
|
|
188
|
+
* @param {object|undefined} schemaExport - schema.json `export` block
|
|
189
|
+
* @param {object} attrs - shortcode attributes
|
|
190
|
+
* @param {string} defaultRole - least-privileged role name
|
|
191
|
+
*/
|
|
192
|
+
export function resolveExportConfig(schemaExport, attrs, defaultRole) {
|
|
193
|
+
const cfg = schemaExport || {};
|
|
194
|
+
if (cfg.enabled === false) return {allowed: false, access: null, formats: [], roles: []};
|
|
195
|
+
const formats = (Array.isArray(cfg.formats) ? cfg.formats : EXPORT_FORMATS)
|
|
196
|
+
.filter(f => EXPORT_FORMATS.includes(f));
|
|
197
|
+
const access = attrs?.exportable === 'true' ? 'public' : (cfg.access || defaultRole);
|
|
198
|
+
return {allowed: true, access, formats, roles: rolesAtOrAbove(access)};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The role names permitted by an `access` setting — the named role plus every
|
|
203
|
+
* more-privileged one above it.
|
|
204
|
+
*
|
|
205
|
+
* The browser cannot do this itself: it learns the visitor's role name from
|
|
206
|
+
* /api/auth/me but has no copy of the hierarchy, so without this list it could
|
|
207
|
+
* only ask "is anyone signed in?" and every role setting would behave
|
|
208
|
+
* identically. Resolving it server-side keeps the check a single membership
|
|
209
|
+
* test on the client.
|
|
210
|
+
*
|
|
211
|
+
* @param {string} access - 'public', or a role name
|
|
212
|
+
* @returns {string[]} empty for 'public' (no check needed) or an unknown role
|
|
213
|
+
*/
|
|
214
|
+
function rolesAtOrAbove(access) {
|
|
215
|
+
if (access === 'public') return [];
|
|
216
|
+
const hierarchy = getRoleHierarchy(); // most → least privileged
|
|
217
|
+
const idx = hierarchy.indexOf(access);
|
|
218
|
+
// Unknown role name: fail closed with an empty list rather than granting
|
|
219
|
+
// everyone, so a typo in schema.json cannot open export up.
|
|
220
|
+
return idx === -1 ? [] : hierarchy.slice(0, idx + 1);
|
|
221
|
+
}
|
|
222
|
+
|
|
176
223
|
/**
|
|
177
224
|
* Build the `data-ctx` attribute that powers the public-site right-click
|
|
178
225
|
* search/filter menu (public/js/collection-context.js). The payload carries
|
|
@@ -181,9 +228,12 @@ function formatDate(input, fmt) {
|
|
|
181
228
|
*
|
|
182
229
|
* @param {Array<{name: string, label?: string}>} fields - displayed fields
|
|
183
230
|
* @param {object[]} entries - collection entries {id, data}
|
|
231
|
+
* @param {object} [opts]
|
|
232
|
+
* @param {string} [opts.slug] - collection slug (for export requests)
|
|
233
|
+
* @param {object} [opts.export] - resolveExportConfig() result
|
|
184
234
|
* @returns {string} ` data-ctx="<base64 JSON>"` (leading space included)
|
|
185
235
|
*/
|
|
186
|
-
function buildCtxAttr(fields, entries) {
|
|
236
|
+
function buildCtxAttr(fields, entries, opts = {}) {
|
|
187
237
|
const names = [...new Set(fields.map(f => f?.name).filter(Boolean))];
|
|
188
238
|
if (!names.length || !entries.length) return '';
|
|
189
239
|
const payload = {
|
|
@@ -193,6 +243,8 @@ function buildCtxAttr(fields, entries) {
|
|
|
193
243
|
data: Object.fromEntries(names.map(n => [n, e.data?.[n] ?? '']))
|
|
194
244
|
}))
|
|
195
245
|
};
|
|
246
|
+
if (opts.slug) payload.slug = opts.slug;
|
|
247
|
+
if (opts.export) payload.export = opts.export;
|
|
196
248
|
return ` data-ctx="${Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')}"`;
|
|
197
249
|
}
|
|
198
250
|
|
|
@@ -254,7 +306,7 @@ function interpolateBlockTemplate(blockTemplate, e) {
|
|
|
254
306
|
* @param {object|null} ctaOpts - Optional CTA button options
|
|
255
307
|
* @returns {string}
|
|
256
308
|
*/
|
|
257
|
-
function renderCollectionBlocks(entries, blockTemplate, emptyMsg, ctaOpts, cols, blockName = '', blockCss = '') {
|
|
309
|
+
function renderCollectionBlocks(entries, blockTemplate, emptyMsg, ctaOpts, cols, blockName = '', blockCss = '', ctxOpts = {}) {
|
|
258
310
|
if (!entries.length) {
|
|
259
311
|
return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
|
|
260
312
|
}
|
|
@@ -286,7 +338,7 @@ function renderCollectionBlocks(entries, blockTemplate, emptyMsg, ctaOpts, cols,
|
|
|
286
338
|
? `dm-collection-display dm-collection-blocks grid ${responsiveGridCols(validCols).join(' ')} gap-4`
|
|
287
339
|
: 'dm-collection-display dm-collection-blocks';
|
|
288
340
|
|
|
289
|
-
const ctx = buildCtxAttr(blockTemplateFields(blockTemplate), entries);
|
|
341
|
+
const ctx = buildCtxAttr(blockTemplateFields(blockTemplate), entries, ctxOpts);
|
|
290
342
|
const styleTag = buildBlockStyleTag(blockName, blockCss);
|
|
291
343
|
return `${styleTag}<div class="${wrapperClass}"${ctx}>\n${items.join('\n')}\n</div>`;
|
|
292
344
|
}
|
|
@@ -455,7 +507,7 @@ function displayValueHtml(entry, field) {
|
|
|
455
507
|
return escapeHtmlText(displayValue(entry, field));
|
|
456
508
|
}
|
|
457
509
|
|
|
458
|
-
function renderCollectionTable(slug, entries, visibleFields, attrs, ctaOpts) {
|
|
510
|
+
function renderCollectionTable(slug, entries, visibleFields, attrs, ctaOpts, ctxOpts = {}) {
|
|
459
511
|
const columns = visibleFields.map(f => ({key: f.name, title: f.label || f.name}));
|
|
460
512
|
const rows = entries.map(e => {
|
|
461
513
|
const row = {};
|
|
@@ -473,14 +525,16 @@ function renderCollectionTable(slug, entries, visibleFields, attrs, ctaOpts) {
|
|
|
473
525
|
sortable: attrs.sortable !== 'false',
|
|
474
526
|
exportable: attrs.exportable === 'true',
|
|
475
527
|
pageSize: parseInt(attrs['page-size'], 10) || 25,
|
|
476
|
-
empty: attrs.empty || 'No entries found'
|
|
528
|
+
empty: attrs.empty || 'No entries found',
|
|
529
|
+
slug: ctxOpts.slug || slug,
|
|
530
|
+
exportCfg: ctxOpts.export || {allowed: false, access: null, formats: []}
|
|
477
531
|
};
|
|
478
532
|
if (ctaOpts) tableOpts.ctaConfig = ctaOpts;
|
|
479
533
|
const payload = Buffer.from(JSON.stringify(tableOpts)).toString('base64');
|
|
480
534
|
return `<div class="dm-collection-display" data-collection-table data-slug="${escapeAttr(slug)}" data-payload="${payload}"></div>`;
|
|
481
535
|
}
|
|
482
536
|
|
|
483
|
-
function renderCollectionCards(entries, visibleFields, titleField, columns, emptyMsg, ctaOpts) {
|
|
537
|
+
function renderCollectionCards(entries, visibleFields, titleField, columns, emptyMsg, ctaOpts, ctxOpts = {}) {
|
|
484
538
|
const cols = ['2', '3', '4'].includes(String(columns)) ? columns : '3';
|
|
485
539
|
if (!entries.length) {
|
|
486
540
|
return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
|
|
@@ -505,10 +559,10 @@ function renderCollectionCards(entries, visibleFields, titleField, columns, empt
|
|
|
505
559
|
}
|
|
506
560
|
return `<div class="card" data-entry-id="${escapeAttr(String(e.id ?? ''))}">${title ? `<div class="card-header">${title}</div>` : ''}<div class="card-body">${body || ' '}</div>${footer}</div>`;
|
|
507
561
|
}).join('\n');
|
|
508
|
-
return `<div class="dm-collection-display grid ${responsiveGridCols(cols).join(' ')} gap-4"${buildCtxAttr(visibleFields, entries)}>\n${cards}\n</div>`;
|
|
562
|
+
return `<div class="dm-collection-display grid ${responsiveGridCols(cols).join(' ')} gap-4"${buildCtxAttr(visibleFields, entries, ctxOpts)}>\n${cards}\n</div>`;
|
|
509
563
|
}
|
|
510
564
|
|
|
511
|
-
function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaOpts) {
|
|
565
|
+
function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaOpts, ctxOpts = {}) {
|
|
512
566
|
if (!entries.length) {
|
|
513
567
|
return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
|
|
514
568
|
}
|
|
@@ -532,7 +586,7 @@ function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaO
|
|
|
532
586
|
}
|
|
533
587
|
return `<div class="dm-collection-list-item" data-entry-id="${escapeAttr(String(e.id ?? ''))}">${title ? `<strong>${title}</strong>` : ''}${rest}${ctaHtml}</div>`;
|
|
534
588
|
}).join('\n');
|
|
535
|
-
return `<div class="dm-collection-display dm-collection-list"${buildCtxAttr(visibleFields, entries)}>\n${items}\n</div>`;
|
|
589
|
+
return `<div class="dm-collection-display dm-collection-list"${buildCtxAttr(visibleFields, entries, ctxOpts)}>\n${items}\n</div>`;
|
|
536
590
|
}
|
|
537
591
|
|
|
538
592
|
/**
|
|
@@ -544,7 +598,7 @@ function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaO
|
|
|
544
598
|
* @param {string} emptyMsg
|
|
545
599
|
* @returns {string}
|
|
546
600
|
*/
|
|
547
|
-
function renderCollectionAccordion(entries, titleField, bodyField, multiple, emptyMsg) {
|
|
601
|
+
function renderCollectionAccordion(entries, titleField, bodyField, multiple, emptyMsg, ctxOpts = {}) {
|
|
548
602
|
if (!entries.length) {
|
|
549
603
|
return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
|
|
550
604
|
}
|
|
@@ -563,7 +617,7 @@ function renderCollectionAccordion(entries, titleField, bodyField, multiple, emp
|
|
|
563
617
|
);
|
|
564
618
|
}).join('\n');
|
|
565
619
|
|
|
566
|
-
const ctx = buildCtxAttr([{name: titleField}, {name: bodyField}], entries);
|
|
620
|
+
const ctx = buildCtxAttr([{name: titleField}, {name: bodyField}], entries, ctxOpts);
|
|
567
621
|
return `<div class="dm-collection-display accordion"${multiAttr}${ctx}>\n${items}\n</div>`;
|
|
568
622
|
}
|
|
569
623
|
|
|
@@ -584,7 +638,7 @@ function renderCollectionAccordion(entries, titleField, bodyField, multiple, emp
|
|
|
584
638
|
* @param {string} opts.emptyMsg
|
|
585
639
|
* @returns {string}
|
|
586
640
|
*/
|
|
587
|
-
function renderCollectionTimeline(entries, opts) {
|
|
641
|
+
function renderCollectionTimeline(entries, opts, ctxOpts = {}) {
|
|
588
642
|
const {titleField, dateField, statusField, iconField, bodyField, layout, theme, mode, emptyMsg} = opts;
|
|
589
643
|
if (!entries.length) {
|
|
590
644
|
return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
|
|
@@ -603,7 +657,8 @@ function renderCollectionTimeline(entries, opts) {
|
|
|
603
657
|
|
|
604
658
|
const ctx = buildCtxAttr(
|
|
605
659
|
[{name: titleField}, {name: dateField}, {name: statusField}, {name: bodyField}].filter(f => f.name),
|
|
606
|
-
entries
|
|
660
|
+
entries,
|
|
661
|
+
ctxOpts
|
|
607
662
|
);
|
|
608
663
|
return `<div class="dm-collection-display dm-progression" data-layout="${layout}" data-theme="${theme}" data-mode="${mode}"${ctx}>\n${items}\n</div>`;
|
|
609
664
|
}
|
|
@@ -629,7 +684,7 @@ function renderCollectionTimeline(entries, opts) {
|
|
|
629
684
|
* @param {string} opts.emptyMsg
|
|
630
685
|
* @returns {string}
|
|
631
686
|
*/
|
|
632
|
-
function renderCollectionCarousel(entries, opts) {
|
|
687
|
+
function renderCollectionCarousel(entries, opts, ctxOpts = {}) {
|
|
633
688
|
const {imageField, titleField, bodyField, blockTemplate, blockName, blockCss, dataAttrs = {}, emptyMsg} = opts;
|
|
634
689
|
if (!entries.length) {
|
|
635
690
|
return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
|
|
@@ -663,7 +718,7 @@ function renderCollectionCarousel(entries, opts) {
|
|
|
663
718
|
const ctxFields = blockTemplate
|
|
664
719
|
? blockTemplateFields(blockTemplate)
|
|
665
720
|
: [{name: titleField}, {name: bodyField}].filter(f => f.name);
|
|
666
|
-
const ctx = buildCtxAttr(ctxFields, entries);
|
|
721
|
+
const ctx = buildCtxAttr(ctxFields, entries, ctxOpts);
|
|
667
722
|
const styleTag = blockTemplate ? buildBlockStyleTag(blockName, blockCss) : '';
|
|
668
723
|
return `${styleTag}<div class="dm-collection-display carousel"${passthrough}${ctx}><div class="carousel-track">${slides}</div></div>`;
|
|
669
724
|
}
|
|
@@ -716,7 +771,7 @@ async function loadOptionalBlockAssets(blockName) {
|
|
|
716
771
|
}
|
|
717
772
|
}
|
|
718
773
|
|
|
719
|
-
function renderCollectionListgroup(entries, opts) {
|
|
774
|
+
function renderCollectionListgroup(entries, opts, ctxOpts = {}) {
|
|
720
775
|
const {titleField, bodyField, variant, size, itemVariant, action, blockTemplate, blockName, blockCss, emptyMsg} = opts;
|
|
721
776
|
if (!entries.length) {
|
|
722
777
|
return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
|
|
@@ -756,7 +811,7 @@ function renderCollectionListgroup(entries, opts) {
|
|
|
756
811
|
const ctxFields = blockTemplate
|
|
757
812
|
? blockTemplateFields(blockTemplate)
|
|
758
813
|
: [{name: titleField}, {name: bodyField}].filter(f => f.name);
|
|
759
|
-
const ctx = buildCtxAttr(ctxFields, entries);
|
|
814
|
+
const ctx = buildCtxAttr(ctxFields, entries, ctxOpts);
|
|
760
815
|
const styleTag = blockTemplate ? buildBlockStyleTag(blockName, blockCss) : '';
|
|
761
816
|
return `${styleTag}<div class="${wrapperClasses.join(' ')}"${ctx}>\n${items}\n</div>`;
|
|
762
817
|
}
|
|
@@ -825,6 +880,19 @@ async function processViewBlocks(markdown, tagSet) {
|
|
|
825
880
|
// Map MongoDB docs to { id, data } shape. MongoAdapter docs have a top-level id field.
|
|
826
881
|
const entries = results.map(doc => ({id: doc.id || '', data: doc.data || doc}));
|
|
827
882
|
|
|
883
|
+
// Resolve the view's underlying collection schema to compute export
|
|
884
|
+
// permission. Views don't own a schema themselves — [dis]allowing
|
|
885
|
+
// export must fall back CLOSED if the source collection can't be
|
|
886
|
+
// resolved, since an open fallback would be a permission bypass.
|
|
887
|
+
const sourceSlug = viewConfig?.pipeline?.source || '';
|
|
888
|
+
const sourceSchema = sourceSlug ? await getCollection(sourceSlug) : null;
|
|
889
|
+
const ctxOpts = sourceSchema
|
|
890
|
+
? {
|
|
891
|
+
slug: sourceSlug,
|
|
892
|
+
export: resolveExportConfig(sourceSchema.export, attrs, getRoleHierarchy().at(-1))
|
|
893
|
+
}
|
|
894
|
+
: {slug: sourceSlug || undefined, export: {allowed: false, access: null, formats: []}};
|
|
895
|
+
|
|
828
896
|
// Derive visible fields from attr filter or first entry's data keys
|
|
829
897
|
let fields;
|
|
830
898
|
if (fieldFilter?.length) {
|
|
@@ -840,14 +908,14 @@ async function processViewBlocks(markdown, tagSet) {
|
|
|
840
908
|
const display = displayAttr || viewConfig?.display?.mode || 'table';
|
|
841
909
|
|
|
842
910
|
if (display === 'cards') {
|
|
843
|
-
replacement = renderCollectionCards(entries, fields, titleField, columns, emptyMsg, ctaOpts);
|
|
911
|
+
replacement = renderCollectionCards(entries, fields, titleField, columns, emptyMsg, ctaOpts, ctxOpts);
|
|
844
912
|
} else if (display === 'list') {
|
|
845
|
-
replacement = renderCollectionList(entries, fields, titleField, emptyMsg, ctaOpts);
|
|
913
|
+
replacement = renderCollectionList(entries, fields, titleField, emptyMsg, ctaOpts, ctxOpts);
|
|
846
914
|
} else if (display === 'accordion') {
|
|
847
915
|
const accordionTitleField = attrs['title-field'] || 'title';
|
|
848
916
|
const bodyField = attrs['body-field'] || 'description';
|
|
849
917
|
const multiple = attrs.multiple === 'true';
|
|
850
|
-
replacement = renderCollectionAccordion(entries, accordionTitleField, bodyField, multiple, emptyMsg);
|
|
918
|
+
replacement = renderCollectionAccordion(entries, accordionTitleField, bodyField, multiple, emptyMsg, ctxOpts);
|
|
851
919
|
} else if (display === 'timeline') {
|
|
852
920
|
const timelineLayout = ['vertical', 'centred', 'horizontal'].includes(attrs.layout) ? attrs.layout : 'vertical';
|
|
853
921
|
const timelineTheme = ['minimal', 'corporate', 'modern'].includes(attrs.theme) ? attrs.theme : 'minimal';
|
|
@@ -862,7 +930,7 @@ async function processViewBlocks(markdown, tagSet) {
|
|
|
862
930
|
theme: timelineTheme,
|
|
863
931
|
mode: timelineMode,
|
|
864
932
|
emptyMsg,
|
|
865
|
-
});
|
|
933
|
+
}, ctxOpts);
|
|
866
934
|
} else if (display === 'carousel') {
|
|
867
935
|
const {tpl, css, missing} = await loadOptionalBlockAssets(attrs.block);
|
|
868
936
|
replacement = missing
|
|
@@ -876,7 +944,7 @@ async function processViewBlocks(markdown, tagSet) {
|
|
|
876
944
|
blockCss: css,
|
|
877
945
|
dataAttrs: attrs,
|
|
878
946
|
emptyMsg,
|
|
879
|
-
});
|
|
947
|
+
}, ctxOpts);
|
|
880
948
|
} else if (display === 'listgroup') {
|
|
881
949
|
const {tpl, css, missing} = await loadOptionalBlockAssets(attrs.block);
|
|
882
950
|
replacement = missing
|
|
@@ -892,7 +960,7 @@ async function processViewBlocks(markdown, tagSet) {
|
|
|
892
960
|
blockName: attrs.block || '',
|
|
893
961
|
blockCss: css,
|
|
894
962
|
emptyMsg,
|
|
895
|
-
});
|
|
963
|
+
}, ctxOpts);
|
|
896
964
|
} else if (display === 'block') {
|
|
897
965
|
const blockName = attrs.block || viewConfig?.display?.block || '';
|
|
898
966
|
if (blockName) {
|
|
@@ -901,13 +969,13 @@ async function processViewBlocks(markdown, tagSet) {
|
|
|
901
969
|
loadBlockTemplate(blockName),
|
|
902
970
|
loadBlockCss(blockName),
|
|
903
971
|
]);
|
|
904
|
-
replacement = renderCollectionBlocks(entries, tpl, emptyMsg, ctaOpts, '', blockName, css);
|
|
972
|
+
replacement = renderCollectionBlocks(entries, tpl, emptyMsg, ctaOpts, '', blockName, css, ctxOpts);
|
|
905
973
|
} catch {
|
|
906
974
|
replacement = blockNotFoundHtml(blockName);
|
|
907
975
|
}
|
|
908
976
|
}
|
|
909
977
|
} else {
|
|
910
|
-
replacement = renderCollectionTable(`view:${slug}`, entries, fields, attrs, ctaOpts);
|
|
978
|
+
replacement = renderCollectionTable(`view:${slug}`, entries, fields, attrs, ctaOpts, ctxOpts);
|
|
911
979
|
}
|
|
912
980
|
} catch {
|
|
913
981
|
// View not found or pipeline error — show empty state
|
|
@@ -1305,6 +1373,12 @@ export async function renderCollectionFragment(attrs, { extraFilter = null } = {
|
|
|
1305
1373
|
const schema = await getCollection(slug);
|
|
1306
1374
|
if (!schema) throw new Error('not found');
|
|
1307
1375
|
|
|
1376
|
+
const defaultRole = getRoleHierarchy().at(-1);
|
|
1377
|
+
const ctxOpts = {
|
|
1378
|
+
slug: schema.slug,
|
|
1379
|
+
export: resolveExportConfig(schema.export, attrs, defaultRole)
|
|
1380
|
+
};
|
|
1381
|
+
|
|
1308
1382
|
const filter = buildShortcodeFilter(attrs);
|
|
1309
1383
|
if (extraFilter) Object.assign(filter, extraFilter);
|
|
1310
1384
|
|
|
@@ -1327,14 +1401,14 @@ export async function renderCollectionFragment(attrs, { extraFilter = null } = {
|
|
|
1327
1401
|
}
|
|
1328
1402
|
|
|
1329
1403
|
if (display === 'cards') {
|
|
1330
|
-
replacement = renderCollectionCards(entries, fields, titleField, columns, emptyMsg, ctaOpts);
|
|
1404
|
+
replacement = renderCollectionCards(entries, fields, titleField, columns, emptyMsg, ctaOpts, ctxOpts);
|
|
1331
1405
|
} else if (display === 'list') {
|
|
1332
|
-
replacement = renderCollectionList(entries, fields, titleField, emptyMsg, ctaOpts);
|
|
1406
|
+
replacement = renderCollectionList(entries, fields, titleField, emptyMsg, ctaOpts, ctxOpts);
|
|
1333
1407
|
} else if (display === 'accordion') {
|
|
1334
1408
|
const accordionTitleField = attrs['title-field'] || 'title';
|
|
1335
1409
|
const bodyField = attrs['body-field'] || 'description';
|
|
1336
1410
|
const multiple = attrs.multiple === 'true';
|
|
1337
|
-
replacement = renderCollectionAccordion(entries, accordionTitleField, bodyField, multiple, emptyMsg);
|
|
1411
|
+
replacement = renderCollectionAccordion(entries, accordionTitleField, bodyField, multiple, emptyMsg, ctxOpts);
|
|
1338
1412
|
} else if (display === 'timeline') {
|
|
1339
1413
|
const timelineLayout = ['vertical', 'centred', 'horizontal'].includes(attrs.layout) ? attrs.layout : 'vertical';
|
|
1340
1414
|
const timelineTheme = ['minimal', 'corporate', 'modern'].includes(attrs.theme) ? attrs.theme : 'minimal';
|
|
@@ -1349,7 +1423,7 @@ export async function renderCollectionFragment(attrs, { extraFilter = null } = {
|
|
|
1349
1423
|
theme: timelineTheme,
|
|
1350
1424
|
mode: timelineMode,
|
|
1351
1425
|
emptyMsg,
|
|
1352
|
-
});
|
|
1426
|
+
}, ctxOpts);
|
|
1353
1427
|
} else if (display === 'carousel') {
|
|
1354
1428
|
const {tpl, css, missing} = await loadOptionalBlockAssets(attrs.block);
|
|
1355
1429
|
replacement = missing
|
|
@@ -1363,7 +1437,7 @@ export async function renderCollectionFragment(attrs, { extraFilter = null } = {
|
|
|
1363
1437
|
blockCss: css,
|
|
1364
1438
|
dataAttrs: attrs,
|
|
1365
1439
|
emptyMsg,
|
|
1366
|
-
});
|
|
1440
|
+
}, ctxOpts);
|
|
1367
1441
|
} else if (display === 'listgroup') {
|
|
1368
1442
|
const {tpl, css, missing} = await loadOptionalBlockAssets(attrs.block);
|
|
1369
1443
|
replacement = missing
|
|
@@ -1379,7 +1453,7 @@ export async function renderCollectionFragment(attrs, { extraFilter = null } = {
|
|
|
1379
1453
|
blockName: attrs.block || '',
|
|
1380
1454
|
blockCss: css,
|
|
1381
1455
|
emptyMsg,
|
|
1382
|
-
});
|
|
1456
|
+
}, ctxOpts);
|
|
1383
1457
|
} else if (display === 'block') {
|
|
1384
1458
|
const blockName = attrs.block || '';
|
|
1385
1459
|
if (blockName) {
|
|
@@ -1389,13 +1463,13 @@ export async function renderCollectionFragment(attrs, { extraFilter = null } = {
|
|
|
1389
1463
|
loadBlockCss(blockName),
|
|
1390
1464
|
]);
|
|
1391
1465
|
const cols = attrs.cols || '';
|
|
1392
|
-
replacement = renderCollectionBlocks(entries, tpl, emptyMsg, ctaOpts, cols, blockName, css);
|
|
1466
|
+
replacement = renderCollectionBlocks(entries, tpl, emptyMsg, ctaOpts, cols, blockName, css, ctxOpts);
|
|
1393
1467
|
} catch {
|
|
1394
1468
|
replacement = blockNotFoundHtml(blockName);
|
|
1395
1469
|
}
|
|
1396
1470
|
}
|
|
1397
1471
|
} else {
|
|
1398
|
-
replacement = renderCollectionTable(slug, entries, fields, attrs, ctaOpts);
|
|
1472
|
+
replacement = renderCollectionTable(slug, entries, fields, attrs, ctaOpts, ctxOpts);
|
|
1399
1473
|
}
|
|
1400
1474
|
} catch {
|
|
1401
1475
|
// Collection not found or read error — show empty message
|
|
@@ -3894,26 +3968,15 @@ async function processMenuBlocks(markdown, user, ctx = {}) {
|
|
|
3894
3968
|
|
|
3895
3969
|
const depth = Number.parseInt(attrs.depth, 10);
|
|
3896
3970
|
const capped = Number.isFinite(depth) && depth > 0 ? capDepth(items, depth) : items;
|
|
3897
|
-
const klass = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
|
|
3898
|
-
const variantClass = attrs.variant ? ` dm-menu--variant-${escapeAttr(attrs.variant)}` : '';
|
|
3899
|
-
// Orientation: the shortcode attribute wins over the menu's own setting,
|
|
3900
|
-
// so one placement can run horizontally while the menu is vertical in
|
|
3901
|
-
// its slot. A menu that sets neither emits no orientation class at all
|
|
3902
|
-
// and keeps the historic plain-nested-list rendering.
|
|
3903
|
-
const orientation = ['horizontal', 'vertical'].includes(attrs.orientation)
|
|
3904
|
-
? attrs.orientation
|
|
3905
|
-
: (['horizontal', 'vertical'].includes(menu.orientation) ? menu.orientation : null);
|
|
3906
|
-
const orientClass = orientation ? ` dm-menu--${orientation}` : '';
|
|
3907
3971
|
const decorated = await resolveMenuDecorations(capped);
|
|
3908
|
-
//
|
|
3909
|
-
//
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
const html = `<nav class="dm-menu dm-menu--${escapeAttr(menu.slug)}${orientClass}${variantClass}${floatClass}${klass}" data-menu="${escapeAttr(menu.slug)}"${floatAttrs}>${renderMenuItemsAsUl(decorated)}</nav>`;
|
|
3972
|
+
// `orientation` overrides the menu for this one placement; `float="no"`
|
|
3973
|
+
// renders inline here even when the menu floats in its slot.
|
|
3974
|
+
const html = buildMenuNav(menu, decorated, {
|
|
3975
|
+
orientation: attrs.orientation,
|
|
3976
|
+
variant: attrs.variant,
|
|
3977
|
+
class: attrs.class,
|
|
3978
|
+
noFloat: attrs.float === 'no'
|
|
3979
|
+
});
|
|
3917
3980
|
out = out.replace(m[0], html);
|
|
3918
3981
|
}
|
|
3919
3982
|
return out;
|
|
@@ -3942,32 +4005,6 @@ async function resolveProjectForUrl(urlPath) {
|
|
|
3942
4005
|
}
|
|
3943
4006
|
}
|
|
3944
4007
|
|
|
3945
|
-
function renderMenuItemsAsUl(items) {
|
|
3946
|
-
if (!items.length) return '';
|
|
3947
|
-
const lis = items.map(it => {
|
|
3948
|
-
// Separator items render as a list-style <hr> divider.
|
|
3949
|
-
if (it && it.type === 'separator') return '<li class="dm-menu-sep" role="separator"><hr></li>';
|
|
3950
|
-
const href = escapeAttr(it.url || '#');
|
|
3951
|
-
const text = escapeAttr(it.text || '');
|
|
3952
|
-
const child = Array.isArray(it.items) && it.items.length ? renderMenuItemsAsUl(it.items) : '';
|
|
3953
|
-
|
|
3954
|
-
// Colour override (server sanitiser allows inline style on <a>).
|
|
3955
|
-
const colourCss = it.colour ? colourToCss(it.colour) : '';
|
|
3956
|
-
const styleAttr = colourCss ? ` style="color:${colourCss}"` : '';
|
|
3957
|
-
|
|
3958
|
-
// Badge (static text or resolver-stamped count). Background from variant.
|
|
3959
|
-
let badge = '';
|
|
3960
|
-
if (it.badge && it.badge.text != null && it.badge.text !== '') {
|
|
3961
|
-
const bg = it.badge.variant ? colourToCss(it.badge.variant) : '';
|
|
3962
|
-
const badgeStyle = bg ? ` style="background:${bg};color:#fff"` : '';
|
|
3963
|
-
badge = `<span class="dm-menu-badge"${badgeStyle}>${escapeAttr(String(it.badge.text))}</span>`;
|
|
3964
|
-
}
|
|
3965
|
-
|
|
3966
|
-
return `<li><a href="${href}"${styleAttr}>${text}${badge}</a>${child}</li>`;
|
|
3967
|
-
}).join('');
|
|
3968
|
-
return `<ul>${lis}</ul>`;
|
|
3969
|
-
}
|
|
3970
|
-
|
|
3971
4008
|
function capDepth(items, max, depth = 1) {
|
|
3972
4009
|
return items.map(it => ({
|
|
3973
4010
|
...it,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Menu → HTML.
|
|
3
|
+
*
|
|
4
|
+
* One builder, three callers: the `[menu]` shortcode, the overlay menus the
|
|
5
|
+
* renderer injects into the page shell, and anything a plugin wants to render
|
|
6
|
+
* itself. Keeping it here means a menu looks the same however it reaches the
|
|
7
|
+
* page — the shortcode and an overlay binding of the same menu produce byte-
|
|
8
|
+
* identical markup.
|
|
9
|
+
*
|
|
10
|
+
* Item filtering (visibility, `hidden`), depth capping and badge-count
|
|
11
|
+
* resolution happen before this — callers hand in the items they want drawn.
|
|
12
|
+
*/
|
|
13
|
+
import {colourToCss, floatToCss} from './menus.js';
|
|
14
|
+
|
|
15
|
+
/** Attribute-safe escaping — mirrors escapeAttr in markdown.js. */
|
|
16
|
+
function esc(str) {
|
|
17
|
+
return String(str ?? '')
|
|
18
|
+
.replace(/&/g, '&')
|
|
19
|
+
.replace(/"/g, '"')
|
|
20
|
+
.replace(/</g, '<')
|
|
21
|
+
.replace(/>/g, '>');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Render a menu's items as a nested `<ul>`.
|
|
26
|
+
*
|
|
27
|
+
* Separators become a classed `<li><hr></li>`; colour, badge and nested
|
|
28
|
+
* children are drawn inline. Returns '' for an empty list so callers can
|
|
29
|
+
* decide whether an empty menu is worth a wrapper at all.
|
|
30
|
+
*
|
|
31
|
+
* @param {Array} items
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
export function renderMenuItemsAsUl(items) {
|
|
35
|
+
if (!items || !items.length) return '';
|
|
36
|
+
const lis = items.map(it => {
|
|
37
|
+
if (it && it.type === 'separator') return '<li class="dm-menu-sep" role="separator"><hr></li>';
|
|
38
|
+
const href = esc(it.url || '#');
|
|
39
|
+
const text = esc(it.text || '');
|
|
40
|
+
const child = Array.isArray(it.items) && it.items.length ? renderMenuItemsAsUl(it.items) : '';
|
|
41
|
+
|
|
42
|
+
// Colour override (the server sanitiser allows inline style on <a>).
|
|
43
|
+
const colourCss = it.colour ? colourToCss(it.colour) : '';
|
|
44
|
+
const styleAttr = colourCss ? ` style="color:${colourCss}"` : '';
|
|
45
|
+
|
|
46
|
+
// Badge — static text, or a count stamped on by resolveMenuDecorations.
|
|
47
|
+
let badge = '';
|
|
48
|
+
if (it.badge && it.badge.text != null && it.badge.text !== '') {
|
|
49
|
+
const bg = it.badge.variant ? colourToCss(it.badge.variant) : '';
|
|
50
|
+
const badgeStyle = bg ? ` style="background:${bg};color:#fff"` : '';
|
|
51
|
+
badge = `<span class="dm-menu-badge"${badgeStyle}>${esc(String(it.badge.text))}</span>`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return `<li><a href="${href}"${styleAttr}>${text}${badge}</a>${child}</li>`;
|
|
55
|
+
}).join('');
|
|
56
|
+
return `<ul>${lis}</ul>`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build the full `<nav>` for a menu.
|
|
61
|
+
*
|
|
62
|
+
* @param {object} menu The menu record (slug, orientation, position, float)
|
|
63
|
+
* @param {Array} items Items to draw — already filtered/capped/decorated
|
|
64
|
+
* @param {object} [opts]
|
|
65
|
+
* @param {string} [opts.orientation] Override the menu's own orientation for this placement
|
|
66
|
+
* @param {string} [opts.variant] Adds `dm-menu--variant-<x>`
|
|
67
|
+
* @param {string} [opts.class] Extra classes on the wrapper
|
|
68
|
+
* @param {boolean}[opts.noFloat] Render inline even if the menu floats in its slot
|
|
69
|
+
* @returns {string}
|
|
70
|
+
*/
|
|
71
|
+
export function buildMenuNav(menu, items, opts = {}) {
|
|
72
|
+
const klass = opts.class ? ` ${esc(opts.class)}` : '';
|
|
73
|
+
const variantClass = opts.variant ? ` dm-menu--variant-${esc(opts.variant)}` : '';
|
|
74
|
+
|
|
75
|
+
// A menu that declares no orientation emits no class at all and keeps the
|
|
76
|
+
// historic plain-nested-list rendering.
|
|
77
|
+
const orientation = ['horizontal', 'vertical'].includes(opts.orientation)
|
|
78
|
+
? opts.orientation
|
|
79
|
+
: (['horizontal', 'vertical'].includes(menu.orientation) ? menu.orientation : null);
|
|
80
|
+
const orientClass = orientation ? ` dm-menu--${orientation}` : '';
|
|
81
|
+
|
|
82
|
+
const floatCss = opts.noFloat ? '' : floatToCss(menu);
|
|
83
|
+
const floatClass = floatCss ? ' dm-menu-floating' : '';
|
|
84
|
+
const floatAttrs = floatCss
|
|
85
|
+
? ` style="${esc(floatCss)}" data-float-anchor="${esc(menu.float?.anchor || 'TL')}"`
|
|
86
|
+
: '';
|
|
87
|
+
|
|
88
|
+
return `<nav class="dm-menu dm-menu--${esc(menu.slug)}${orientClass}${variantClass}${floatClass}${klass}"`
|
|
89
|
+
+ ` data-menu="${esc(menu.slug)}"${floatAttrs}>${renderMenuItemsAsUl(items)}</nav>`;
|
|
90
|
+
}
|
package/server/services/menus.js
CHANGED
|
@@ -710,6 +710,11 @@ registerLocation('navbar', {label: 'Navbar', description: 'Site
|
|
|
710
710
|
registerLocation('footer-primary', {label: 'Footer primary', description: 'Primary footer column', source: 'core', maxDepth: 1});
|
|
711
711
|
registerLocation('footer-legal', {label: 'Footer legal', description: 'Legal/secondary footer column', source: 'core', maxDepth: 1});
|
|
712
712
|
registerLocation('admin-sidebar', {label: 'Admin sidebar', description: 'The left-side navigation in the admin panel', source: 'core', maxDepth: null});
|
|
713
|
+
// The overlay slot is the "anywhere" placement: menus bound to it render on
|
|
714
|
+
// matching pages positioned by their own `float` config, with no navbar, no
|
|
715
|
+
// footer and no shortcode involved. Unlike the other slots it is not
|
|
716
|
+
// one-menu-wins — a page can carry several overlays at once.
|
|
717
|
+
registerLocation('overlay', {label: 'Page overlay', description: 'Floating menus placed on matching pages by their binding', source: 'core', maxDepth: null});
|
|
713
718
|
|
|
714
719
|
// ---------------------------------------------------------------------------
|
|
715
720
|
// Slot → menu-slug map
|
|
@@ -904,6 +909,47 @@ export async function resolveLocation(slot, user, ctx = {}) {
|
|
|
904
909
|
};
|
|
905
910
|
}
|
|
906
911
|
|
|
912
|
+
/** The slot whose menus are drawn over the page rather than in a fixed region. */
|
|
913
|
+
export const OVERLAY_SLOT = 'overlay';
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Every overlay menu that applies to this page.
|
|
917
|
+
*
|
|
918
|
+
* Overlays are the "place a menu anywhere" path: instead of one menu winning a
|
|
919
|
+
* region, *all* menus whose binding matches the page render, each positioned by
|
|
920
|
+
* its own `float` config. A menu mapped to the overlay slot in
|
|
921
|
+
* menu-locations.json applies site-wide and joins the list.
|
|
922
|
+
*
|
|
923
|
+
* Menus left with no visible items after per-user filtering are dropped, so a
|
|
924
|
+
* fully gated overlay doesn't leave an empty box floating on the page.
|
|
925
|
+
*
|
|
926
|
+
* @param {{urlPath?: string, project?: string|null}} ctx
|
|
927
|
+
* @param {object|null} user
|
|
928
|
+
* @returns {Promise<object[]>} menus with filtered items, ordered by slug
|
|
929
|
+
*/
|
|
930
|
+
export async function resolveOverlaysForPage(ctx = {}, user = null) {
|
|
931
|
+
const bySlug = new Map();
|
|
932
|
+
|
|
933
|
+
for (const menu of await getAllMenus()) {
|
|
934
|
+
if (menu?.binding?.slot !== OVERLAY_SLOT) continue;
|
|
935
|
+
if (!scoreBinding(menu.binding, ctx)) continue;
|
|
936
|
+
bySlug.set(menu.slug, menu);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// A site-wide overlay: mapped rather than bound, so it needs no page rules.
|
|
940
|
+
const mappedSlug = (await getLocations())[OVERLAY_SLOT];
|
|
941
|
+
if (mappedSlug && !bySlug.has(mappedSlug)) {
|
|
942
|
+
const mapped = await getMenu(mappedSlug);
|
|
943
|
+
if (mapped) bySlug.set(mapped.slug, mapped);
|
|
944
|
+
else console.warn(`[menus] Overlay slot maps to "${mappedSlug}" which doesn't exist`);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
return [...bySlug.values()]
|
|
948
|
+
.map(menu => ({...menu, items: filterItemsForUser(menu.items || [], user)}))
|
|
949
|
+
.filter(menu => menu.items.length > 0)
|
|
950
|
+
.sort((a, b) => String(a.slug).localeCompare(String(b.slug)));
|
|
951
|
+
}
|
|
952
|
+
|
|
907
953
|
function filterItemsForUser(items, user) {
|
|
908
954
|
const out = [];
|
|
909
955
|
for (const item of items) {
|