domma-cms 0.18.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 +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 +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/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 +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 +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 +686 -109
- 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 +15 -4
- package/server/templates/page.html +7 -2
- /package/config/{navigation.json → navigation.json.bak} +0 -0
|
@@ -11,6 +11,8 @@ 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} from './menus.js';
|
|
15
|
+
import {checkVisibility} from '../middleware/auth.js';
|
|
14
16
|
|
|
15
17
|
const __dirname_md = path.dirname(fileURLToPath(import.meta.url));
|
|
16
18
|
const BLOCKS_DIR = path.resolve(__dirname_md, '../../content/blocks');
|
|
@@ -22,7 +24,7 @@ const BUILTIN_SHORTCODES = new Set([
|
|
|
22
24
|
'text', 'button', 'link', 'cta', 'grid', 'row', 'col', 'card',
|
|
23
25
|
'banner', 'slideover', 'counter', 'celebrate', 'firework', 'fireworks', 'scribe',
|
|
24
26
|
'reveal', 'breathe', 'pulse', 'shake', 'scramble', 'ripple', 'twinkle',
|
|
25
|
-
'ticker-tape', 'animate', 'ambient', 'list-group',
|
|
27
|
+
'ticker-tape', 'animate', 'ambient', 'list-group', 'menu',
|
|
26
28
|
]);
|
|
27
29
|
|
|
28
30
|
// Configure marked for safe output
|
|
@@ -43,13 +45,49 @@ function escapeHtmlText(str) {
|
|
|
43
45
|
.replace(/"/g, '"');
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Build a structured filter object from `[collection]` shortcode attributes.
|
|
50
|
+
*
|
|
51
|
+
* Two attribute styles are recognised and merged (with where_* taking precedence):
|
|
52
|
+
*
|
|
53
|
+
* 1. Legacy `where="field1=value1,field2=value2"` — equality only, comma-separated.
|
|
54
|
+
* Predates the structured DSL; preserved for backward compat.
|
|
55
|
+
*
|
|
56
|
+
* 2. New `where_<field>[_<op>]="value"` attributes — full operator support.
|
|
57
|
+
* Example: `where_location="London" where_salary_gte="50000"
|
|
58
|
+
* where_tags_in="remote,hybrid"`.
|
|
59
|
+
* Maps directly onto the filterEngine DSL.
|
|
60
|
+
*
|
|
61
|
+
* The combined object is passed to `listEntries({ filter })` so the work is
|
|
62
|
+
* pushed down to the adapter (Mongo gets a native query; File runs in-memory).
|
|
63
|
+
*
|
|
64
|
+
* @param {Record<string, string>} attrs - All parsed shortcode attributes
|
|
65
|
+
* @returns {Record<string, string>} Filter object suitable for filterEngine
|
|
66
|
+
*/
|
|
67
|
+
function buildShortcodeFilter(attrs) {
|
|
68
|
+
const filter = {};
|
|
69
|
+
|
|
70
|
+
// Legacy where="a=1,b=2"
|
|
71
|
+
const legacy = typeof attrs.where === 'string' ? attrs.where.trim() : '';
|
|
72
|
+
if (legacy) {
|
|
73
|
+
for (const part of legacy.split(',')) {
|
|
74
|
+
const eq = part.indexOf('=');
|
|
75
|
+
if (eq === -1) continue;
|
|
76
|
+
const key = part.slice(0, eq).trim();
|
|
77
|
+
const val = part.slice(eq + 1).trim();
|
|
78
|
+
if (key) filter[key] = val;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Modern where_<field>[_<op>]="value"
|
|
83
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
84
|
+
if (!k.startsWith('where_')) continue;
|
|
85
|
+
const inner = k.slice('where_'.length);
|
|
86
|
+
if (!inner) continue;
|
|
87
|
+
filter[inner] = v;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return filter;
|
|
53
91
|
}
|
|
54
92
|
|
|
55
93
|
/**
|
|
@@ -306,12 +344,79 @@ async function processStaticBlocks(content, tagSet) {
|
|
|
306
344
|
return output;
|
|
307
345
|
}
|
|
308
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Detect a stored file reference — the object shape produced by the form's
|
|
349
|
+
* multipart parser: { url, name, size?, mime? }.
|
|
350
|
+
*/
|
|
351
|
+
function isFileRef(v) {
|
|
352
|
+
return v && typeof v === 'object' && typeof v.url === 'string' && typeof v.name === 'string';
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Resolve the display value for one field on one entry.
|
|
357
|
+
*
|
|
358
|
+
* For a `type: reference` field, returns the resolved label from `_refs`
|
|
359
|
+
* (set by `references.resolveReferences()`); arrays join with ", ", missing
|
|
360
|
+
* targets render as "<id> (missing)". For a `type: file` field (or any field
|
|
361
|
+
* holding a stored file ref), returns the filename. Everything else returns
|
|
362
|
+
* the raw value with `null`/`undefined` collapsed to '' and arrays joined.
|
|
363
|
+
*
|
|
364
|
+
* @param {object} entry
|
|
365
|
+
* @param {object} field
|
|
366
|
+
* @returns {string}
|
|
367
|
+
*/
|
|
368
|
+
function displayValue(entry, field) {
|
|
369
|
+
if (field.type === 'reference') {
|
|
370
|
+
const r = entry._refs?.[field.name];
|
|
371
|
+
if (r != null) {
|
|
372
|
+
if (Array.isArray(r)) return r.map(x => x.missing ? `${x.id} (missing)` : x.display).join(', ');
|
|
373
|
+
return r.missing ? `${r.id} (missing)` : r.display;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const raw = entry.data?.[field.name];
|
|
377
|
+
if (isFileRef(raw)) return raw.name;
|
|
378
|
+
if (raw == null) return '';
|
|
379
|
+
if (Array.isArray(raw)) return raw.join(', ');
|
|
380
|
+
return String(raw);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* As `displayValue` but returns an HTML fragment. References with a
|
|
385
|
+
* `linkTemplate` render as `<a>`; file references render as `<img>` (image
|
|
386
|
+
* mimes) or `<a download>` (everything else). The caller is responsible
|
|
387
|
+
* for sanitisation — values pass through `escapeHtmlText`/`escapeAttr`.
|
|
388
|
+
*/
|
|
389
|
+
function displayValueHtml(entry, field) {
|
|
390
|
+
if (field.type === 'reference') {
|
|
391
|
+
const r = entry._refs?.[field.name];
|
|
392
|
+
if (r != null) {
|
|
393
|
+
const render = (one) => {
|
|
394
|
+
if (one.missing) return escapeHtmlText(`${one.id} (missing)`);
|
|
395
|
+
const text = escapeHtmlText(one.display);
|
|
396
|
+
return one.link ? `<a href="${escapeAttr(one.link)}">${text}</a>` : text;
|
|
397
|
+
};
|
|
398
|
+
if (Array.isArray(r)) return r.map(render).join(', ');
|
|
399
|
+
return render(r);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const raw = entry.data?.[field.name];
|
|
403
|
+
if (isFileRef(raw)) {
|
|
404
|
+
const url = escapeAttr(raw.url);
|
|
405
|
+
const name = escapeHtmlText(raw.name);
|
|
406
|
+
if (String(raw.mime || '').startsWith('image/')) {
|
|
407
|
+
return `<a href="${url}" target="_blank" rel="noopener"><img src="${url}" alt="${name}" loading="lazy" class="dm-file-thumb"></a>`;
|
|
408
|
+
}
|
|
409
|
+
return `<a href="${url}" download>${name}</a>`;
|
|
410
|
+
}
|
|
411
|
+
return escapeHtmlText(displayValue(entry, field));
|
|
412
|
+
}
|
|
413
|
+
|
|
309
414
|
function renderCollectionTable(slug, entries, visibleFields, attrs, ctaOpts) {
|
|
310
415
|
const columns = visibleFields.map(f => ({key: f.name, title: f.label || f.name}));
|
|
311
416
|
const rows = entries.map(e => {
|
|
312
417
|
const row = {};
|
|
313
418
|
visibleFields.forEach(f => {
|
|
314
|
-
row[f.name] = e
|
|
419
|
+
row[f.name] = displayValue(e, f);
|
|
315
420
|
});
|
|
316
421
|
if (ctaOpts) row._entryId = e.id || '';
|
|
317
422
|
return row;
|
|
@@ -337,11 +442,12 @@ function renderCollectionCards(entries, visibleFields, titleField, columns, empt
|
|
|
337
442
|
return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
|
|
338
443
|
}
|
|
339
444
|
const cards = entries.map(e => {
|
|
340
|
-
const
|
|
445
|
+
const titleFld = visibleFields.find(f => f.name === titleField);
|
|
446
|
+
const title = titleFld ? displayValueHtml(e, titleFld) : (titleField ? escapeHtmlText(e.data?.[titleField] ?? '') : '');
|
|
341
447
|
const body = visibleFields
|
|
342
448
|
.filter(f => f.name !== titleField)
|
|
343
449
|
.map(f => {
|
|
344
|
-
const val =
|
|
450
|
+
const val = displayValueHtml(e, f);
|
|
345
451
|
return val ? `<p><strong>${escapeHtmlText(f.label || f.name)}:</strong> ${val}</p>` : '';
|
|
346
452
|
}).join('');
|
|
347
453
|
let footer = '';
|
|
@@ -363,11 +469,12 @@ function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaO
|
|
|
363
469
|
return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
|
|
364
470
|
}
|
|
365
471
|
const items = entries.map(e => {
|
|
366
|
-
const
|
|
472
|
+
const titleFld = visibleFields.find(f => f.name === titleField);
|
|
473
|
+
const title = titleFld ? displayValueHtml(e, titleFld) : (titleField ? escapeHtmlText(e.data?.[titleField] ?? '') : '');
|
|
367
474
|
const rest = visibleFields
|
|
368
475
|
.filter(f => f.name !== titleField)
|
|
369
476
|
.map(f => {
|
|
370
|
-
const val =
|
|
477
|
+
const val = displayValueHtml(e, f);
|
|
371
478
|
return val ? `<p>${val}</p>` : '';
|
|
372
479
|
}).join('');
|
|
373
480
|
let ctaHtml = '';
|
|
@@ -478,6 +585,17 @@ async function processViewBlocks(markdown, tagSet) {
|
|
|
478
585
|
}
|
|
479
586
|
tagSet?.add(`view:${slug}`);
|
|
480
587
|
|
|
588
|
+
// Interactive `[view]` — when searchable / sortable / paginate is set,
|
|
589
|
+
// emit a Collection Browser shell against the view's executed dataset.
|
|
590
|
+
// No filter rail (views own their own filter logic) but search, sort,
|
|
591
|
+
// and pagination work as on `[collection]`. This is how cross-collection
|
|
592
|
+
// browsing happens without the shortcode itself having to learn joins.
|
|
593
|
+
if (isInteractiveCollection(attrs)) {
|
|
594
|
+
const shell = await renderViewBrowserShell(attrs);
|
|
595
|
+
result = result.replace(fullMatch, shell);
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
|
|
481
599
|
const displayAttr = attrs.display || '';
|
|
482
600
|
const emptyMsg = attrs.empty || 'No results found';
|
|
483
601
|
const titleField = attrs['title-field'] || '';
|
|
@@ -601,114 +719,433 @@ async function processCollectionBlocks(markdown, tagSet) {
|
|
|
601
719
|
}
|
|
602
720
|
tagSet?.add(`collection:${slug}`);
|
|
603
721
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
722
|
+
// scope="mine" can't share the per-role page cache (data is per-user),
|
|
723
|
+
// so emit a hydration placeholder. The public site JS POSTs the raw
|
|
724
|
+
// attrs to /api/collections/render-scope, which re-runs renderCollectionFragment
|
|
725
|
+
// server-side with `createdBy = <current user>` injected — keeping rendering
|
|
726
|
+
// logic in ONE place (no client-side template duplication).
|
|
727
|
+
if (attrs.scope === 'mine') {
|
|
728
|
+
const encoded = Buffer.from(JSON.stringify(attrs), 'utf8').toString('base64');
|
|
729
|
+
const wrapperCls = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
|
|
730
|
+
const wrapperId = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
|
|
731
|
+
result = result.replace(fullMatch,
|
|
732
|
+
`<div class="dm-collection-hydrate${wrapperCls}"${wrapperId} ` +
|
|
733
|
+
`data-collection-scope="mine" data-collection-attrs="${encoded}">` +
|
|
734
|
+
`<div class="dm-collection-loading"><p>${escapeHtmlText('Loading…')}</p></div>` +
|
|
735
|
+
`</div>`
|
|
736
|
+
);
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Interactive Collection Browser — searchable / filterable / sortable
|
|
741
|
+
// turns the block into a full client-side browser with auto-derived
|
|
742
|
+
// filter UI, sort dropdown, search box, and pagination. We still emit
|
|
743
|
+
// a server-rendered fragment as the no-JS fallback (visible until the
|
|
744
|
+
// browser hydrates and replaces it).
|
|
745
|
+
if (isInteractiveCollection(attrs)) {
|
|
746
|
+
const shell = await renderCollectionBrowserShell(attrs, schemaForFallback => schemaForFallback);
|
|
747
|
+
result = result.replace(fullMatch, shell);
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const replacement = await renderCollectionFragment(attrs);
|
|
752
|
+
result = result.replace(fullMatch, replacement);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
return restore(result);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Detect whether a `[collection]` shortcode opts into the interactive Browser.
|
|
760
|
+
* Any of `searchable`, `sortable`, `filterable`, or `paginate` flips it on.
|
|
761
|
+
*
|
|
762
|
+
* @param {Record<string, unknown>} attrs
|
|
763
|
+
* @returns {boolean}
|
|
764
|
+
*/
|
|
765
|
+
function isInteractiveCollection(attrs) {
|
|
766
|
+
return isTruthyAttr(attrs.searchable)
|
|
767
|
+
|| isTruthyAttr(attrs.sortable)
|
|
768
|
+
|| !!attrs.filterable
|
|
769
|
+
|| isTruthyAttr(attrs.paginate);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Coerce a shortcode attribute value to a boolean. Bare flags parse as `true`,
|
|
774
|
+
* `"true"` / `"1"` are truthy, everything else is false.
|
|
775
|
+
*
|
|
776
|
+
* @param {unknown} v
|
|
777
|
+
* @returns {boolean}
|
|
778
|
+
*/
|
|
779
|
+
function isTruthyAttr(v) {
|
|
780
|
+
return v === true || v === 'true' || v === '1';
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Build the hydration shell for the Collection Browser component.
|
|
785
|
+
*
|
|
786
|
+
* The shell ships three things to the client:
|
|
787
|
+
* 1. **config** — display mode, columns, page size, which fields are
|
|
788
|
+
* sortable/filterable, mode (client|server), labels.
|
|
789
|
+
* 2. **schema** — the collection's field definitions, used to auto-derive
|
|
790
|
+
* the right filter control per field type (text → input,
|
|
791
|
+
* select → dropdown, number → range, date → from/to, etc.).
|
|
792
|
+
* 3. **data** — for mode=client, ALL entries (capped at `maxClientEntries`)
|
|
793
|
+
* so filtering is instant. For mode=server, only the first
|
|
794
|
+
* page is shipped and subsequent pages are fetched via the
|
|
795
|
+
* existing `/api/collections/:slug/public` endpoint with
|
|
796
|
+
* `filter[…]`, `sort`, `order`, `page`, `limit` params.
|
|
797
|
+
*
|
|
798
|
+
* A static server-rendered fragment is also embedded as the no-JS fallback —
|
|
799
|
+
* visible until `collection-browser.js` hydrates and replaces the shell.
|
|
800
|
+
*
|
|
801
|
+
* @param {Record<string, string>} attrs - Parsed shortcode attributes
|
|
802
|
+
* @returns {Promise<string>} HTML for the hydration shell
|
|
803
|
+
*/
|
|
804
|
+
async function renderCollectionBrowserShell(attrs) {
|
|
805
|
+
const slug = attrs.slug;
|
|
806
|
+
const pageSize = parseInt(attrs['page-size'], 10) || 12;
|
|
807
|
+
const declaredMode = attrs.mode === 'server' ? 'server' : 'client';
|
|
808
|
+
const maxClientEntries = parseInt(attrs['max-client-entries'], 10) || 1000;
|
|
809
|
+
|
|
810
|
+
// Parse comma lists once.
|
|
811
|
+
const filterable = (attrs.filterable && typeof attrs.filterable === 'string')
|
|
812
|
+
? attrs.filterable.split(',').map(s => s.trim()).filter(Boolean) : [];
|
|
813
|
+
const sortableFields = (typeof attrs.sortable === 'string' && attrs.sortable !== 'true' && attrs.sortable !== 'false')
|
|
814
|
+
? attrs.sortable.split(',').map(s => s.trim()).filter(Boolean) : [];
|
|
815
|
+
|
|
816
|
+
let schema = null;
|
|
817
|
+
let entries = [];
|
|
818
|
+
let total = 0;
|
|
819
|
+
let mode = declaredMode;
|
|
820
|
+
|
|
821
|
+
try {
|
|
822
|
+
schema = await getCollection(slug);
|
|
823
|
+
if (!schema) throw new Error('not found');
|
|
824
|
+
|
|
825
|
+
// Build the seed listEntries() opts — push any author-baked where_* filter
|
|
826
|
+
// and the initial sort/order down to the adapter, just like the static path.
|
|
827
|
+
const seedFilter = buildShortcodeFilter(attrs);
|
|
828
|
+
const seedOpts = {
|
|
829
|
+
sort: attrs.sort || 'createdAt',
|
|
830
|
+
order: attrs.order || 'desc'
|
|
831
|
+
};
|
|
832
|
+
if (Object.keys(seedFilter).length) seedOpts.filter = seedFilter;
|
|
833
|
+
|
|
834
|
+
seedOpts.resolveRefs = true; // ship resolved labels in the inline payload
|
|
835
|
+
|
|
836
|
+
if (mode === 'client') {
|
|
837
|
+
// Cap the inline payload to avoid bloating cached HTML for huge collections.
|
|
838
|
+
// If we hit the cap, transparently downgrade to mode=server so pagination
|
|
839
|
+
// still works — the author gets correct UX even on misconfigured pages.
|
|
840
|
+
seedOpts.limit = maxClientEntries + 1;
|
|
841
|
+
const r = await listEntries(slug, seedOpts);
|
|
842
|
+
if (r.total > maxClientEntries) {
|
|
843
|
+
mode = 'server';
|
|
844
|
+
seedOpts.limit = pageSize;
|
|
845
|
+
seedOpts.page = 1;
|
|
846
|
+
const r2 = await listEntries(slug, seedOpts);
|
|
847
|
+
entries = r2.entries;
|
|
848
|
+
total = r2.total;
|
|
849
|
+
} else {
|
|
850
|
+
entries = r.entries;
|
|
851
|
+
total = r.total;
|
|
852
|
+
}
|
|
853
|
+
} else {
|
|
854
|
+
// mode=server — ship only the first page; subsequent pages fetched live.
|
|
855
|
+
seedOpts.limit = pageSize;
|
|
856
|
+
seedOpts.page = 1;
|
|
857
|
+
const r = await listEntries(slug, seedOpts);
|
|
858
|
+
entries = r.entries;
|
|
859
|
+
total = r.total;
|
|
860
|
+
}
|
|
861
|
+
} catch {
|
|
862
|
+
// Slug unknown / read error — emit an empty shell so the page still renders.
|
|
863
|
+
schema = {fields: [], title: attrs.slug};
|
|
864
|
+
entries = [];
|
|
865
|
+
total = 0;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const config = {
|
|
869
|
+
slug,
|
|
870
|
+
id: attrs.id || `cb-${slug}-${Math.random().toString(36).slice(2, 7)}`,
|
|
871
|
+
display: attrs.display || 'cards',
|
|
872
|
+
columns: parseInt(attrs.columns, 10) || 3,
|
|
873
|
+
titleField: attrs['title-field'] || '',
|
|
874
|
+
bodyField: attrs['body-field'] || '',
|
|
875
|
+
dateField: attrs['date-field'] || '',
|
|
876
|
+
statusField: attrs['status-field'] || '',
|
|
877
|
+
iconField: attrs['icon-field'] || '',
|
|
878
|
+
layout: attrs.layout || '',
|
|
879
|
+
theme: attrs.theme || '',
|
|
880
|
+
timelineMode: attrs.mode === 'roadmap' ? 'roadmap' : 'timeline',
|
|
881
|
+
block: attrs.block || '',
|
|
882
|
+
fields: attrs.fields ? attrs.fields.split(',').map(s => s.trim()).filter(Boolean) : null,
|
|
883
|
+
pageSize,
|
|
884
|
+
mode,
|
|
885
|
+
pagination: attrs.pagination === 'scroll' ? 'scroll' : 'pages', // 'pages' = prev/next, 'scroll' = infinite
|
|
886
|
+
searchable: isTruthyAttr(attrs.searchable),
|
|
887
|
+
sortable: isTruthyAttr(attrs.sortable) || sortableFields.length > 0,
|
|
888
|
+
sortableFields, // empty array = all fields sortable
|
|
889
|
+
filterable, // [] = no filter rail
|
|
890
|
+
exportable: isTruthyAttr(attrs.exportable), // adds "Export CSV" button
|
|
891
|
+
savedSearches: !isTruthyAttr(attrs['no-saved-searches']), // opt-out flag (defaults on)
|
|
892
|
+
transitions: isTruthyAttr(attrs.transitions), // render per-row transition buttons
|
|
893
|
+
emptyMsg: attrs.empty || 'No entries found',
|
|
894
|
+
cta: attrs.cta ? {
|
|
895
|
+
action: attrs.cta,
|
|
615
896
|
label: attrs['cta-label'] || 'Run',
|
|
616
897
|
icon: attrs['cta-icon'] || '',
|
|
617
898
|
style: attrs['cta-style'] || 'primary',
|
|
618
899
|
confirm: attrs['cta-confirm'] || ''
|
|
619
|
-
} : null
|
|
900
|
+
} : null,
|
|
901
|
+
baked: buildShortcodeFilter(attrs), // author's where_* filter — locked in, not user-tweakable
|
|
902
|
+
initial: { sort: attrs.sort || 'createdAt', order: attrs.order || 'desc' },
|
|
903
|
+
syncUrl: !isTruthyAttr(attrs['no-url-sync']) // default ON
|
|
904
|
+
};
|
|
620
905
|
|
|
621
|
-
|
|
906
|
+
// No-JS fallback — render the same first-page state with the static renderer.
|
|
907
|
+
const fallbackAttrs = {...attrs, limit: String(pageSize)};
|
|
908
|
+
const fallback = await renderCollectionFragment(fallbackAttrs);
|
|
622
909
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
let {entries} = await listEntries(slug);
|
|
628
|
-
|
|
629
|
-
// Row-level filter: where="field=value" (simple equality only).
|
|
630
|
-
// Comma-separate multiple predicates, all AND'd together.
|
|
631
|
-
// Example: where="tab=developers" or where="tab=developers,status=live"
|
|
632
|
-
const whereAttr = typeof attrs.where === 'string' ? attrs.where.trim() : '';
|
|
633
|
-
if (whereAttr) {
|
|
634
|
-
const predicates = whereAttr.split(',').map(p => p.trim()).filter(Boolean).map(p => {
|
|
635
|
-
const eq = p.indexOf('=');
|
|
636
|
-
if (eq === -1) return null;
|
|
637
|
-
return { key: p.slice(0, eq).trim(), val: p.slice(eq + 1).trim() };
|
|
638
|
-
}).filter(Boolean);
|
|
639
|
-
if (predicates.length) {
|
|
640
|
-
entries = entries.filter(e => predicates.every(({key, val}) => String(e.data?.[key] ?? '') === val));
|
|
641
|
-
}
|
|
642
|
-
}
|
|
910
|
+
// Encode payloads. Schema can be modest; data can be large in client mode,
|
|
911
|
+
// so we base64 once each and let the browser decode.
|
|
912
|
+
const enc = (obj) => Buffer.from(JSON.stringify(obj), 'utf8').toString('base64');
|
|
643
913
|
|
|
644
|
-
|
|
645
|
-
|
|
914
|
+
const wrapperCls = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
|
|
915
|
+
const wrapperId = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : ` id="${escapeAttr(config.id)}"`;
|
|
646
916
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
fields = Object.keys(entries[0].data || {}).map(k => ({ name: k, label: k }));
|
|
655
|
-
}
|
|
917
|
+
return `<div class="dm-collection-browser${wrapperCls}"${wrapperId} ` +
|
|
918
|
+
`data-cb-config="${enc(config)}" ` +
|
|
919
|
+
`data-cb-schema="${enc({fields: schema?.fields || [], title: schema?.title || ''})}" ` +
|
|
920
|
+
`data-cb-data="${enc({entries, total, page: 1, pageSize})}">` +
|
|
921
|
+
`<div class="dm-cb-fallback">${fallback}</div>` +
|
|
922
|
+
`</div>`;
|
|
923
|
+
}
|
|
656
924
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
} catch {
|
|
692
|
-
replacement = `<div class="dm-collection-display dm-collection-empty"><p>Block template “${escapeHtmlText(blockName)}” not found.</p></div>`;
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
} else {
|
|
696
|
-
replacement = renderCollectionTable(slug, entries, fields, attrs, ctaOpts);
|
|
697
|
-
}
|
|
698
|
-
} catch {
|
|
699
|
-
// Collection not found or read error — show empty message
|
|
925
|
+
/**
|
|
926
|
+
* Interactive `[view]` hydration shell. Saved views compose collections,
|
|
927
|
+
* joins, and pre-filters that aren't expressible in plain `where_*` syntax —
|
|
928
|
+
* so the browser treats a view as a read-only data source: search, sort,
|
|
929
|
+
* and pagination work; the filter rail is off by default (the view's own
|
|
930
|
+
* filter logic is the authoritative source). Authors can still expose user
|
|
931
|
+
* filters by setting `filterable="…"`, but the field names must exist on the
|
|
932
|
+
* view's projected document shape.
|
|
933
|
+
*
|
|
934
|
+
* @param {Record<string, string>} attrs
|
|
935
|
+
* @returns {Promise<string>}
|
|
936
|
+
*/
|
|
937
|
+
async function renderViewBrowserShell(attrs) {
|
|
938
|
+
const slug = attrs.slug;
|
|
939
|
+
const pageSize = parseInt(attrs['page-size'], 10) || parseInt(attrs.limit, 10) || 12;
|
|
940
|
+
const declaredMode = attrs.mode === 'server' ? 'server' : 'client';
|
|
941
|
+
|
|
942
|
+
let entries = [];
|
|
943
|
+
let total = 0;
|
|
944
|
+
let viewConfig = null;
|
|
945
|
+
|
|
946
|
+
try {
|
|
947
|
+
const {executeView, getView} = await import('./views.js');
|
|
948
|
+
viewConfig = await getView(slug).catch(() => null);
|
|
949
|
+
|
|
950
|
+
if (declaredMode === 'client') {
|
|
951
|
+
// For views we cap aggressively — views can be expensive aggregations.
|
|
952
|
+
const r = await executeView(slug, {page: 1, limit: 500});
|
|
953
|
+
entries = (r.results || []).map(doc => ({id: doc.id || '', data: doc.data || doc}));
|
|
954
|
+
total = r.total ?? entries.length;
|
|
955
|
+
} else {
|
|
956
|
+
const r = await executeView(slug, {page: 1, limit: pageSize});
|
|
957
|
+
entries = (r.results || []).map(doc => ({id: doc.id || '', data: doc.data || doc}));
|
|
958
|
+
total = r.total ?? entries.length;
|
|
700
959
|
}
|
|
960
|
+
} catch {
|
|
961
|
+
// executeView errored (no Pro / view missing) — emit empty shell.
|
|
962
|
+
}
|
|
701
963
|
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
964
|
+
// Derive a schema-like fields list from the first row so the browser can
|
|
965
|
+
// wire up sort dropdown labels even when the view has no explicit schema.
|
|
966
|
+
const schemaFields = entries.length
|
|
967
|
+
? Object.keys(entries[0].data || {})
|
|
968
|
+
.filter(k => !['_id', 'id', 'meta'].includes(k))
|
|
969
|
+
.map(k => ({name: k, label: k, type: 'text'}))
|
|
970
|
+
: (viewConfig?.fields || []);
|
|
971
|
+
|
|
972
|
+
const filterable = (attrs.filterable && typeof attrs.filterable === 'string')
|
|
973
|
+
? attrs.filterable.split(',').map(s => s.trim()).filter(Boolean) : [];
|
|
974
|
+
const sortableFields = (typeof attrs.sortable === 'string' && attrs.sortable !== 'true' && attrs.sortable !== 'false')
|
|
975
|
+
? attrs.sortable.split(',').map(s => s.trim()).filter(Boolean) : [];
|
|
976
|
+
|
|
977
|
+
const config = {
|
|
978
|
+
slug,
|
|
979
|
+
id: attrs.id || `cb-view-${slug}-${Math.random().toString(36).slice(2, 7)}`,
|
|
980
|
+
display: attrs.display || viewConfig?.display?.mode || 'cards',
|
|
981
|
+
columns: parseInt(attrs.columns, 10) || 3,
|
|
982
|
+
titleField: attrs['title-field'] || viewConfig?.display?.titleField || '',
|
|
983
|
+
bodyField: attrs['body-field'] || '',
|
|
984
|
+
dateField: attrs['date-field'] || '',
|
|
985
|
+
statusField: attrs['status-field'] || '',
|
|
986
|
+
iconField: attrs['icon-field'] || '',
|
|
987
|
+
layout: attrs.layout || '',
|
|
988
|
+
theme: attrs.theme || '',
|
|
989
|
+
timelineMode: attrs.mode === 'roadmap' ? 'roadmap' : 'timeline',
|
|
990
|
+
block: attrs.block || '',
|
|
991
|
+
fields: attrs.fields ? attrs.fields.split(',').map(s => s.trim()).filter(Boolean) : null,
|
|
992
|
+
pageSize,
|
|
993
|
+
mode: declaredMode,
|
|
994
|
+
pagination: attrs.pagination === 'scroll' ? 'scroll' : 'pages',
|
|
995
|
+
searchable: isTruthyAttr(attrs.searchable),
|
|
996
|
+
sortable: isTruthyAttr(attrs.sortable) || sortableFields.length > 0,
|
|
997
|
+
sortableFields,
|
|
998
|
+
filterable,
|
|
999
|
+
exportable: isTruthyAttr(attrs.exportable),
|
|
1000
|
+
savedSearches: !isTruthyAttr(attrs['no-saved-searches']),
|
|
1001
|
+
emptyMsg: attrs.empty || 'No results found',
|
|
1002
|
+
cta: attrs.action ? {
|
|
1003
|
+
action: attrs.action,
|
|
1004
|
+
label: attrs['cta-label'] || 'Run',
|
|
1005
|
+
icon: attrs['cta-icon'] || '',
|
|
1006
|
+
style: attrs['cta-style'] || 'primary',
|
|
1007
|
+
confirm: attrs['cta-confirm'] || ''
|
|
1008
|
+
} : null,
|
|
1009
|
+
baked: {}, // views don't accept baked where_*; the view itself is the filter
|
|
1010
|
+
initial: { sort: attrs.sort || 'createdAt', order: attrs.order || 'desc' },
|
|
1011
|
+
syncUrl: !isTruthyAttr(attrs['no-url-sync']),
|
|
1012
|
+
sourceKind: 'view' // hint for client-side: server-mode would need a different fetch endpoint
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
const enc = (obj) => Buffer.from(JSON.stringify(obj), 'utf8').toString('base64');
|
|
1016
|
+
const wrapperCls = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
|
|
1017
|
+
const wrapperId = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : ` id="${escapeAttr(config.id)}"`;
|
|
1018
|
+
|
|
1019
|
+
return `<div class="dm-collection-browser dm-cb-source-view${wrapperCls}"${wrapperId} ` +
|
|
1020
|
+
`data-cb-config="${enc(config)}" ` +
|
|
1021
|
+
`data-cb-schema="${enc({fields: schemaFields, title: viewConfig?.title || slug})}" ` +
|
|
1022
|
+
`data-cb-data="${enc({entries, total, page: 1, pageSize})}">` +
|
|
1023
|
+
`<div class="dm-cb-fallback"></div>` +
|
|
1024
|
+
`</div>`;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Render a `[collection]` block's HTML fragment from parsed attributes.
|
|
1029
|
+
*
|
|
1030
|
+
* Used by:
|
|
1031
|
+
* - The Markdown shortcode pipeline (server-side render at page build time)
|
|
1032
|
+
* - The collection hydration endpoint (client-side render when scope="mine"
|
|
1033
|
+
* forces per-user data that can't share the page cache)
|
|
1034
|
+
*
|
|
1035
|
+
* `extraFilter` is merged on top of any filter derived from `where_*` attrs —
|
|
1036
|
+
* this is how the hydration endpoint injects `createdBy = <current user>`
|
|
1037
|
+
* for scope=mine without trusting the client to set it.
|
|
1038
|
+
*
|
|
1039
|
+
* Returns a complete HTML fragment including the optional outer wrapper for
|
|
1040
|
+
* `class=` / `id=` attributes. Always succeeds — render errors fall back to
|
|
1041
|
+
* the empty-state HTML rather than throwing, so a typo in a single shortcode
|
|
1042
|
+
* can't break a whole page render.
|
|
1043
|
+
*
|
|
1044
|
+
* @param {Record<string, string>} attrs - Parsed shortcode attributes
|
|
1045
|
+
* @param {object} [opts]
|
|
1046
|
+
* @param {Record<string, unknown>} [opts.extraFilter] - Additional filter conditions to AND in
|
|
1047
|
+
* @returns {Promise<string>} HTML fragment ready to inject into a page
|
|
1048
|
+
*/
|
|
1049
|
+
export async function renderCollectionFragment(attrs, { extraFilter = null } = {}) {
|
|
1050
|
+
const slug = attrs.slug || '';
|
|
1051
|
+
if (!slug) return '';
|
|
1052
|
+
|
|
1053
|
+
const display = attrs.display || 'table';
|
|
1054
|
+
const limitAttr = parseInt(attrs.limit, 10) || 0;
|
|
1055
|
+
const sort = attrs.sort || 'createdAt';
|
|
1056
|
+
const order = attrs.order || 'desc';
|
|
1057
|
+
const titleField = attrs['title-field'] || '';
|
|
1058
|
+
const columns = attrs.columns || '3';
|
|
1059
|
+
const emptyMsg = attrs.empty || 'No entries found';
|
|
1060
|
+
const fieldFilter = attrs.fields ? attrs.fields.split(',').map(f => f.trim()).filter(Boolean) : null;
|
|
1061
|
+
const ctaAction = attrs.cta || '';
|
|
1062
|
+
const ctaOpts = ctaAction ? {
|
|
1063
|
+
action: ctaAction,
|
|
1064
|
+
label: attrs['cta-label'] || 'Run',
|
|
1065
|
+
icon: attrs['cta-icon'] || '',
|
|
1066
|
+
style: attrs['cta-style'] || 'primary',
|
|
1067
|
+
confirm: attrs['cta-confirm'] || ''
|
|
1068
|
+
} : null;
|
|
1069
|
+
|
|
1070
|
+
let replacement = `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
|
|
1071
|
+
|
|
1072
|
+
try {
|
|
1073
|
+
const schema = await getCollection(slug);
|
|
1074
|
+
if (!schema) throw new Error('not found');
|
|
1075
|
+
|
|
1076
|
+
const filter = buildShortcodeFilter(attrs);
|
|
1077
|
+
if (extraFilter) Object.assign(filter, extraFilter);
|
|
1078
|
+
|
|
1079
|
+
const listOpts = {
|
|
1080
|
+
sort,
|
|
1081
|
+
order,
|
|
1082
|
+
limit: limitAttr > 0 ? limitAttr : 0,
|
|
1083
|
+
resolveRefs: true // decorate entries with display labels for reference fields
|
|
1084
|
+
};
|
|
1085
|
+
if (Object.keys(filter).length) listOpts.filter = filter;
|
|
1086
|
+
|
|
1087
|
+
const {entries} = await listEntries(slug, listOpts);
|
|
1088
|
+
|
|
1089
|
+
let fields = schema.fields || [];
|
|
1090
|
+
if (fieldFilter?.length) {
|
|
1091
|
+
fields = fieldFilter.map(name => fields.find(f => f.name === name) || { name, label: name });
|
|
1092
|
+
}
|
|
1093
|
+
if (!fields.length && entries.length) {
|
|
1094
|
+
fields = Object.keys(entries[0].data || {}).map(k => ({ name: k, label: k }));
|
|
706
1095
|
}
|
|
707
1096
|
|
|
708
|
-
|
|
1097
|
+
if (display === 'cards') {
|
|
1098
|
+
replacement = renderCollectionCards(entries, fields, titleField, columns, emptyMsg, ctaOpts);
|
|
1099
|
+
} else if (display === 'list') {
|
|
1100
|
+
replacement = renderCollectionList(entries, fields, titleField, emptyMsg, ctaOpts);
|
|
1101
|
+
} else if (display === 'accordion') {
|
|
1102
|
+
const accordionTitleField = attrs['title-field'] || 'title';
|
|
1103
|
+
const bodyField = attrs['body-field'] || 'description';
|
|
1104
|
+
const multiple = attrs.multiple === 'true';
|
|
1105
|
+
replacement = renderCollectionAccordion(entries, accordionTitleField, bodyField, multiple, emptyMsg);
|
|
1106
|
+
} else if (display === 'timeline') {
|
|
1107
|
+
const timelineLayout = ['vertical', 'centred', 'horizontal'].includes(attrs.layout) ? attrs.layout : 'vertical';
|
|
1108
|
+
const timelineTheme = ['minimal', 'corporate', 'modern'].includes(attrs.theme) ? attrs.theme : 'minimal';
|
|
1109
|
+
const timelineMode = ['timeline', 'roadmap'].includes(attrs.mode) ? attrs.mode : 'timeline';
|
|
1110
|
+
replacement = renderCollectionTimeline(entries, {
|
|
1111
|
+
titleField: attrs['title-field'] || 'title',
|
|
1112
|
+
dateField: attrs['date-field'] || '',
|
|
1113
|
+
statusField: attrs['status-field'] || '',
|
|
1114
|
+
iconField: attrs['icon-field'] || '',
|
|
1115
|
+
bodyField: attrs['body-field'] || '',
|
|
1116
|
+
layout: timelineLayout,
|
|
1117
|
+
theme: timelineTheme,
|
|
1118
|
+
mode: timelineMode,
|
|
1119
|
+
emptyMsg,
|
|
1120
|
+
});
|
|
1121
|
+
} else if (display === 'block') {
|
|
1122
|
+
const blockName = attrs.block || '';
|
|
1123
|
+
if (blockName) {
|
|
1124
|
+
try {
|
|
1125
|
+
const [tpl, css] = await Promise.all([
|
|
1126
|
+
loadBlockTemplate(blockName),
|
|
1127
|
+
loadBlockCss(blockName),
|
|
1128
|
+
]);
|
|
1129
|
+
const cols = attrs.cols || '';
|
|
1130
|
+
replacement = renderCollectionBlocks(entries, tpl, emptyMsg, ctaOpts, cols, blockName, css);
|
|
1131
|
+
} catch {
|
|
1132
|
+
replacement = `<div class="dm-collection-display dm-collection-empty"><p>Block template “${escapeHtmlText(blockName)}” not found.</p></div>`;
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
} else {
|
|
1136
|
+
replacement = renderCollectionTable(slug, entries, fields, attrs, ctaOpts);
|
|
1137
|
+
}
|
|
1138
|
+
} catch {
|
|
1139
|
+
// Collection not found or read error — show empty message
|
|
709
1140
|
}
|
|
710
1141
|
|
|
711
|
-
|
|
1142
|
+
if (attrs.class || attrs.id) {
|
|
1143
|
+
const cls = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
|
|
1144
|
+
const id = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
|
|
1145
|
+
replacement = `<div class="dm-collection-wrapper${cls}"${id}>${replacement}</div>`;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
return replacement;
|
|
712
1149
|
}
|
|
713
1150
|
|
|
714
1151
|
/**
|
|
@@ -2625,6 +3062,68 @@ function processIconBlocks(markdown) {
|
|
|
2625
3062
|
*/
|
|
2626
3063
|
const FORMS_DIR = path.resolve(__dirname_md, '../../content/forms');
|
|
2627
3064
|
|
|
3065
|
+
/**
|
|
3066
|
+
* Transform `type: 'reference'` fields on a form definition into select-style
|
|
3067
|
+
* fields populated from the target collection. Mutates the form in place.
|
|
3068
|
+
*
|
|
3069
|
+
* Strategy: lazy server-side expansion at render time — the embedded form
|
|
3070
|
+
* doesn't know about references, it just sees a select with options. This
|
|
3071
|
+
* keeps the client-side form engine (`F.create`) ignorant of the schema
|
|
3072
|
+
* vocabulary and works without any extra round-trips at form render time.
|
|
3073
|
+
*
|
|
3074
|
+
* Limits: ships the first 200 entries per referenced collection. Beyond that,
|
|
3075
|
+
* a future enhancement can swap in an async-search picker; logged here as a
|
|
3076
|
+
* known cap so authors with huge target collections know what to do.
|
|
3077
|
+
*
|
|
3078
|
+
* @param {object} form - Mutated in place: reference fields → select fields
|
|
3079
|
+
* @returns {Promise<void>}
|
|
3080
|
+
*/
|
|
3081
|
+
async function expandReferenceFields(form) {
|
|
3082
|
+
const fields = form?.fields;
|
|
3083
|
+
if (!Array.isArray(fields) || !fields.length) return;
|
|
3084
|
+
|
|
3085
|
+
const REF_LIMIT = 200;
|
|
3086
|
+
|
|
3087
|
+
for (let i = 0; i < fields.length; i++) {
|
|
3088
|
+
const f = fields[i];
|
|
3089
|
+
if (f.type !== 'reference') continue;
|
|
3090
|
+
const ref = f.reference || {};
|
|
3091
|
+
if (!ref.collection) continue;
|
|
3092
|
+
|
|
3093
|
+
const targetSlug = ref.collection;
|
|
3094
|
+
const displayField = ref.displayField || 'title';
|
|
3095
|
+
|
|
3096
|
+
try {
|
|
3097
|
+
const r = await listEntries(targetSlug, {limit: REF_LIMIT, sort: displayField, order: 'asc'});
|
|
3098
|
+
const options = (r.entries || []).map(e => ({
|
|
3099
|
+
value: e.id,
|
|
3100
|
+
label: (e.data?.[displayField] != null && e.data[displayField] !== '')
|
|
3101
|
+
? String(e.data[displayField])
|
|
3102
|
+
: e.id
|
|
3103
|
+
}));
|
|
3104
|
+
|
|
3105
|
+
// Replace with a select; preserve label/required/helper from the original.
|
|
3106
|
+
fields[i] = {
|
|
3107
|
+
...f,
|
|
3108
|
+
type: f.multiple ? 'multiselect' : 'select',
|
|
3109
|
+
options,
|
|
3110
|
+
// Keep the original reference metadata around so future code can
|
|
3111
|
+
// recognise that this select was reference-derived (e.g. for an
|
|
3112
|
+
// upgrade to an async picker without changing the form file).
|
|
3113
|
+
_ref: {collection: targetSlug, displayField, originalType: 'reference'}
|
|
3114
|
+
};
|
|
3115
|
+
} catch {
|
|
3116
|
+
// Target collection missing — render a disabled input with an explanatory placeholder.
|
|
3117
|
+
fields[i] = {
|
|
3118
|
+
...f,
|
|
3119
|
+
type: 'text',
|
|
3120
|
+
placeholder: `(reference target "${targetSlug}" unavailable)`,
|
|
3121
|
+
_ref: {collection: targetSlug, displayField, originalType: 'reference', error: true}
|
|
3122
|
+
};
|
|
3123
|
+
}
|
|
3124
|
+
}
|
|
3125
|
+
}
|
|
3126
|
+
|
|
2628
3127
|
async function processFormBlocks(markdown, tagSet) {
|
|
2629
3128
|
const {scrubbed, restore} = scrubCodeRegions(markdown);
|
|
2630
3129
|
const regex = /\[form([^\]]*?)\/\]/gi;
|
|
@@ -2653,6 +3152,7 @@ async function processFormBlocks(markdown, tagSet) {
|
|
|
2653
3152
|
if (!filePath.startsWith(FORMS_DIR + path.sep)) throw new Error('Invalid slug');
|
|
2654
3153
|
const raw = await readFile(filePath, 'utf8');
|
|
2655
3154
|
const form = JSON.parse(raw);
|
|
3155
|
+
await expandReferenceFields(form);
|
|
2656
3156
|
const encoded = Buffer.from(JSON.stringify(form)).toString('base64');
|
|
2657
3157
|
const extraClass = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
|
|
2658
3158
|
const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
|
|
@@ -2847,6 +3347,78 @@ function processSlideoverBlocks(markdown) {
|
|
|
2847
3347
|
* @param {string} markdown
|
|
2848
3348
|
* @returns {string}
|
|
2849
3349
|
*/
|
|
3350
|
+
/**
|
|
3351
|
+
* Pre-process [menu] shortcodes into rendered `<nav><ul>...</ul></nav>` markup.
|
|
3352
|
+
*
|
|
3353
|
+
* Forms:
|
|
3354
|
+
* [menu slug="my-menu" /]
|
|
3355
|
+
* [menu location="navbar" /]
|
|
3356
|
+
* [menu slug="..." depth="2" variant="..." class="..." /]
|
|
3357
|
+
*
|
|
3358
|
+
* Items flagged `hidden: true` are dropped. Items with a `visibility` field
|
|
3359
|
+
* are filtered against the supplied user via `checkVisibility`. The `depth`
|
|
3360
|
+
* attribute caps the nesting depth (1-indexed; depth="1" = top-level only).
|
|
3361
|
+
*
|
|
3362
|
+
* @param {string} markdown
|
|
3363
|
+
* @param {object|null} user
|
|
3364
|
+
* @returns {Promise<string>}
|
|
3365
|
+
*/
|
|
3366
|
+
async function processMenuBlocks(markdown, user) {
|
|
3367
|
+
const re = /\[menu(\s+[^\]]*?)?\s*\/\]/gi;
|
|
3368
|
+
const matches = [...markdown.matchAll(re)];
|
|
3369
|
+
if (!matches.length) return markdown;
|
|
3370
|
+
|
|
3371
|
+
let out = markdown;
|
|
3372
|
+
for (const m of matches) {
|
|
3373
|
+
const attrs = parseShortcodeAttrs(m[1] || '');
|
|
3374
|
+
let menu = null;
|
|
3375
|
+
if (attrs.slug) menu = await getMenu(attrs.slug);
|
|
3376
|
+
else if (attrs.location) menu = await resolveLocation(attrs.location, user || null);
|
|
3377
|
+
if (!menu) { out = out.replace(m[0], ''); continue; }
|
|
3378
|
+
|
|
3379
|
+
// The slug path skipped resolveLocation, so filter visibility + hidden manually here.
|
|
3380
|
+
const items = attrs.slug
|
|
3381
|
+
? filterMenuForUser(menu.items || [], user || null)
|
|
3382
|
+
: menu.items;
|
|
3383
|
+
|
|
3384
|
+
const depth = Number.parseInt(attrs.depth, 10);
|
|
3385
|
+
const capped = Number.isFinite(depth) && depth > 0 ? capDepth(items, depth) : items;
|
|
3386
|
+
const klass = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
|
|
3387
|
+
const variantClass = attrs.variant ? ` dm-menu--variant-${escapeAttr(attrs.variant)}` : '';
|
|
3388
|
+
const html = `<nav class="dm-menu dm-menu--${escapeAttr(menu.slug)}${variantClass}${klass}" data-menu="${escapeAttr(menu.slug)}">${renderMenuItemsAsUl(capped)}</nav>`;
|
|
3389
|
+
out = out.replace(m[0], html);
|
|
3390
|
+
}
|
|
3391
|
+
return out;
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
function renderMenuItemsAsUl(items) {
|
|
3395
|
+
if (!items.length) return '';
|
|
3396
|
+
const lis = items.map(it => {
|
|
3397
|
+
const href = escapeAttr(it.url || '#');
|
|
3398
|
+
const text = escapeAttr(it.text || '');
|
|
3399
|
+
const child = Array.isArray(it.items) && it.items.length ? renderMenuItemsAsUl(it.items) : '';
|
|
3400
|
+
return `<li><a href="${href}">${text}</a>${child}</li>`;
|
|
3401
|
+
}).join('');
|
|
3402
|
+
return `<ul>${lis}</ul>`;
|
|
3403
|
+
}
|
|
3404
|
+
|
|
3405
|
+
function capDepth(items, max, depth = 1) {
|
|
3406
|
+
return items.map(it => ({
|
|
3407
|
+
...it,
|
|
3408
|
+
items: depth < max && Array.isArray(it.items) ? capDepth(it.items, max, depth + 1) : []
|
|
3409
|
+
}));
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
function filterMenuForUser(items, user) {
|
|
3413
|
+
const out = [];
|
|
3414
|
+
for (const item of items) {
|
|
3415
|
+
if (item.hidden) continue;
|
|
3416
|
+
if (item.visibility != null && !checkVisibility(user, item.visibility)) continue;
|
|
3417
|
+
out.push({...item, items: Array.isArray(item.items) ? filterMenuForUser(item.items, user) : []});
|
|
3418
|
+
}
|
|
3419
|
+
return out;
|
|
3420
|
+
}
|
|
3421
|
+
|
|
2850
3422
|
function processDConfigBlocks(markdown) {
|
|
2851
3423
|
const {scrubbed, restore} = scrubCodeRegions(markdown);
|
|
2852
3424
|
const processed = scrubbed.replace(
|
|
@@ -2913,14 +3485,18 @@ function processCtaBlocks(markdown) {
|
|
|
2913
3485
|
* Parse a Markdown file string into frontmatter data and rendered HTML.
|
|
2914
3486
|
*
|
|
2915
3487
|
* @param {string} raw - Raw file content (frontmatter + Markdown body)
|
|
3488
|
+
* @param {object} [opts]
|
|
3489
|
+
* @param {object|null} [opts.user] - Authenticated user (`{role, additionalRoles}`)
|
|
3490
|
+
* or null for anonymous. Used to filter menu items gated by `visibility` in
|
|
3491
|
+
* the `[menu]` shortcode. Backwards compatible — defaults to anonymous.
|
|
2916
3492
|
* @returns {{ data: object, content: string, html: string }}
|
|
2917
3493
|
*/
|
|
2918
|
-
export async function parseMarkdown(raw) {
|
|
3494
|
+
export async function parseMarkdown(raw, opts = {}) {
|
|
2919
3495
|
const {data, content} = matter(raw);
|
|
2920
3496
|
const extensions = getSanitizeExtensions();
|
|
2921
3497
|
|
|
2922
3498
|
// Pipeline:
|
|
2923
|
-
// beforeParse → collection → view → staticBlock → dconfig → effects → plugin shortcodes → tabs → accordion → carousel
|
|
3499
|
+
// beforeParse → collection → view → staticBlock → menu → dconfig → effects → plugin shortcodes → tabs → accordion → carousel
|
|
2924
3500
|
// → countdown → timeline → spacer → center → icon → form → hero → table → badge → button → link → cta
|
|
2925
3501
|
// → grid → card → slideover → marked → sanitize → afterParse
|
|
2926
3502
|
const preprocessed = applyTransforms('markdown:beforeParse', content);
|
|
@@ -2929,7 +3505,8 @@ export async function parseMarkdown(raw) {
|
|
|
2929
3505
|
const withCollection = await processCollectionBlocks(withComponents, tagSet);
|
|
2930
3506
|
const withView = await processViewBlocks(withCollection, tagSet);
|
|
2931
3507
|
const withStaticBlock = await processStaticBlocks(withView, tagSet);
|
|
2932
|
-
const
|
|
3508
|
+
const withMenu = await processMenuBlocks(withStaticBlock, opts.user || null);
|
|
3509
|
+
const withDconfig = processDConfigBlocks(withMenu);
|
|
2933
3510
|
const withEffects = processEffectsBlocks(withDconfig);
|
|
2934
3511
|
const withPluginShortcodes = await processPluginShortcodes(withEffects);
|
|
2935
3512
|
const withTabs = processTabsBlocks(withPluginShortcodes);
|