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
|
@@ -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
|
/**
|
|
@@ -247,7 +285,7 @@ async function getBlockShortTagNames() {
|
|
|
247
285
|
* @param {string} content
|
|
248
286
|
* @returns {Promise<string>}
|
|
249
287
|
*/
|
|
250
|
-
async function processStaticBlocks(content) {
|
|
288
|
+
async function processStaticBlocks(content, tagSet) {
|
|
251
289
|
// --- [block template="name" .../] ---
|
|
252
290
|
const pattern = /\[block\s+([\s\S]+?)\/\]/g;
|
|
253
291
|
const matches = [...content.matchAll(pattern)];
|
|
@@ -262,6 +300,7 @@ async function processStaticBlocks(content) {
|
|
|
262
300
|
output = output.slice(0, match.index) + output.slice(match.index + match[0].length);
|
|
263
301
|
continue;
|
|
264
302
|
}
|
|
303
|
+
tagSet?.add(`block:${attrs.template}`);
|
|
265
304
|
let replacement = '';
|
|
266
305
|
try {
|
|
267
306
|
const [tpl, css] = await Promise.all([
|
|
@@ -287,6 +326,7 @@ async function processStaticBlocks(content) {
|
|
|
287
326
|
for (const [, key, dq, sq] of (match[1] ?? '').matchAll(/([\w-]+)=(?:"([^"]*)"|'([^']*)')/g)) {
|
|
288
327
|
attrs[key] = dq ?? sq ?? '';
|
|
289
328
|
}
|
|
329
|
+
tagSet?.add(`block:${tagName}`);
|
|
290
330
|
let replacement = '';
|
|
291
331
|
try {
|
|
292
332
|
const [tpl, css] = await Promise.all([
|
|
@@ -304,12 +344,79 @@ async function processStaticBlocks(content) {
|
|
|
304
344
|
return output;
|
|
305
345
|
}
|
|
306
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
|
+
|
|
307
414
|
function renderCollectionTable(slug, entries, visibleFields, attrs, ctaOpts) {
|
|
308
415
|
const columns = visibleFields.map(f => ({key: f.name, title: f.label || f.name}));
|
|
309
416
|
const rows = entries.map(e => {
|
|
310
417
|
const row = {};
|
|
311
418
|
visibleFields.forEach(f => {
|
|
312
|
-
row[f.name] = e
|
|
419
|
+
row[f.name] = displayValue(e, f);
|
|
313
420
|
});
|
|
314
421
|
if (ctaOpts) row._entryId = e.id || '';
|
|
315
422
|
return row;
|
|
@@ -335,11 +442,12 @@ function renderCollectionCards(entries, visibleFields, titleField, columns, empt
|
|
|
335
442
|
return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
|
|
336
443
|
}
|
|
337
444
|
const cards = entries.map(e => {
|
|
338
|
-
const
|
|
445
|
+
const titleFld = visibleFields.find(f => f.name === titleField);
|
|
446
|
+
const title = titleFld ? displayValueHtml(e, titleFld) : (titleField ? escapeHtmlText(e.data?.[titleField] ?? '') : '');
|
|
339
447
|
const body = visibleFields
|
|
340
448
|
.filter(f => f.name !== titleField)
|
|
341
449
|
.map(f => {
|
|
342
|
-
const val =
|
|
450
|
+
const val = displayValueHtml(e, f);
|
|
343
451
|
return val ? `<p><strong>${escapeHtmlText(f.label || f.name)}:</strong> ${val}</p>` : '';
|
|
344
452
|
}).join('');
|
|
345
453
|
let footer = '';
|
|
@@ -361,11 +469,12 @@ function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaO
|
|
|
361
469
|
return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
|
|
362
470
|
}
|
|
363
471
|
const items = entries.map(e => {
|
|
364
|
-
const
|
|
472
|
+
const titleFld = visibleFields.find(f => f.name === titleField);
|
|
473
|
+
const title = titleFld ? displayValueHtml(e, titleFld) : (titleField ? escapeHtmlText(e.data?.[titleField] ?? '') : '');
|
|
365
474
|
const rest = visibleFields
|
|
366
475
|
.filter(f => f.name !== titleField)
|
|
367
476
|
.map(f => {
|
|
368
|
-
const val =
|
|
477
|
+
const val = displayValueHtml(e, f);
|
|
369
478
|
return val ? `<p>${val}</p>` : '';
|
|
370
479
|
}).join('');
|
|
371
480
|
let ctaHtml = '';
|
|
@@ -458,7 +567,7 @@ function renderCollectionTimeline(entries, opts) {
|
|
|
458
567
|
* @param {string} markdown
|
|
459
568
|
* @returns {Promise<string>}
|
|
460
569
|
*/
|
|
461
|
-
async function processViewBlocks(markdown) {
|
|
570
|
+
async function processViewBlocks(markdown, tagSet) {
|
|
462
571
|
const {scrubbed, restore} = scrubCodeRegions(markdown);
|
|
463
572
|
const pattern = /\[view([^\]]*?)\/\]/gi;
|
|
464
573
|
const matches = [...scrubbed.matchAll(pattern)];
|
|
@@ -474,6 +583,18 @@ async function processViewBlocks(markdown) {
|
|
|
474
583
|
result = result.replace(fullMatch, '');
|
|
475
584
|
continue;
|
|
476
585
|
}
|
|
586
|
+
tagSet?.add(`view:${slug}`);
|
|
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
|
+
}
|
|
477
598
|
|
|
478
599
|
const displayAttr = attrs.display || '';
|
|
479
600
|
const emptyMsg = attrs.empty || 'No results found';
|
|
@@ -579,7 +700,7 @@ async function processViewBlocks(markdown) {
|
|
|
579
700
|
* @param {string} markdown
|
|
580
701
|
* @returns {Promise<string>}
|
|
581
702
|
*/
|
|
582
|
-
async function processCollectionBlocks(markdown) {
|
|
703
|
+
async function processCollectionBlocks(markdown, tagSet) {
|
|
583
704
|
const {scrubbed, restore} = scrubCodeRegions(markdown);
|
|
584
705
|
// Find all [collection ...] shortcodes
|
|
585
706
|
const pattern = /\[collection([^\]]*?)\/\]/gi;
|
|
@@ -596,115 +717,435 @@ async function processCollectionBlocks(markdown) {
|
|
|
596
717
|
result = result.replace(fullMatch, '');
|
|
597
718
|
continue;
|
|
598
719
|
}
|
|
720
|
+
tagSet?.add(`collection:${slug}`);
|
|
721
|
+
|
|
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
|
+
}
|
|
599
739
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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,
|
|
611
896
|
label: attrs['cta-label'] || 'Run',
|
|
612
897
|
icon: attrs['cta-icon'] || '',
|
|
613
898
|
style: attrs['cta-style'] || 'primary',
|
|
614
899
|
confirm: attrs['cta-confirm'] || ''
|
|
615
|
-
} : 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
|
+
};
|
|
616
905
|
|
|
617
|
-
|
|
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);
|
|
618
909
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
let {entries} = await listEntries(slug);
|
|
624
|
-
|
|
625
|
-
// Row-level filter: where="field=value" (simple equality only).
|
|
626
|
-
// Comma-separate multiple predicates, all AND'd together.
|
|
627
|
-
// Example: where="tab=developers" or where="tab=developers,status=live"
|
|
628
|
-
const whereAttr = typeof attrs.where === 'string' ? attrs.where.trim() : '';
|
|
629
|
-
if (whereAttr) {
|
|
630
|
-
const predicates = whereAttr.split(',').map(p => p.trim()).filter(Boolean).map(p => {
|
|
631
|
-
const eq = p.indexOf('=');
|
|
632
|
-
if (eq === -1) return null;
|
|
633
|
-
return { key: p.slice(0, eq).trim(), val: p.slice(eq + 1).trim() };
|
|
634
|
-
}).filter(Boolean);
|
|
635
|
-
if (predicates.length) {
|
|
636
|
-
entries = entries.filter(e => predicates.every(({key, val}) => String(e.data?.[key] ?? '') === val));
|
|
637
|
-
}
|
|
638
|
-
}
|
|
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');
|
|
639
913
|
|
|
640
|
-
|
|
641
|
-
|
|
914
|
+
const wrapperCls = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
|
|
915
|
+
const wrapperId = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : ` id="${escapeAttr(config.id)}"`;
|
|
642
916
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
fields = Object.keys(entries[0].data || {}).map(k => ({ name: k, label: k }));
|
|
651
|
-
}
|
|
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
|
+
}
|
|
652
924
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
} catch {
|
|
688
|
-
replacement = `<div class="dm-collection-display dm-collection-empty"><p>Block template “${escapeHtmlText(blockName)}” not found.</p></div>`;
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
} else {
|
|
692
|
-
replacement = renderCollectionTable(slug, entries, fields, attrs, ctaOpts);
|
|
693
|
-
}
|
|
694
|
-
} catch {
|
|
695
|
-
// 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;
|
|
696
959
|
}
|
|
960
|
+
} catch {
|
|
961
|
+
// executeView errored (no Pro / view missing) — emit empty shell.
|
|
962
|
+
}
|
|
697
963
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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 }));
|
|
702
1095
|
}
|
|
703
1096
|
|
|
704
|
-
|
|
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
|
|
705
1140
|
}
|
|
706
1141
|
|
|
707
|
-
|
|
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;
|
|
708
1149
|
}
|
|
709
1150
|
|
|
710
1151
|
/**
|
|
@@ -1947,11 +2388,13 @@ function processAccordionBlocks(markdown) {
|
|
|
1947
2388
|
* [/carousel]
|
|
1948
2389
|
*
|
|
1949
2390
|
* Supported attributes on [carousel]:
|
|
1950
|
-
* autoplay
|
|
1951
|
-
* interval
|
|
1952
|
-
* loop
|
|
1953
|
-
* animation
|
|
1954
|
-
*
|
|
2391
|
+
* autoplay - "true" to auto-advance slides
|
|
2392
|
+
* interval - milliseconds between slides (default 5000)
|
|
2393
|
+
* loop - "false" to disable loop (default true)
|
|
2394
|
+
* animation - "slide" | "fade" | "crossfade" (default slide)
|
|
2395
|
+
* animation-duration - transition length in milliseconds (default 500)
|
|
2396
|
+
* animation-easing - CSS timing function (e.g. ease, linear, ease-in-out, cubic-bezier(...))
|
|
2397
|
+
* id - optional id on the wrapper
|
|
1955
2398
|
*
|
|
1956
2399
|
* Supported attributes on [slide]:
|
|
1957
2400
|
* image - URL of a background/header image
|
|
@@ -1970,7 +2413,9 @@ function processCarouselBlocks(markdown) {
|
|
|
1970
2413
|
attrs.autoplay ? ` data-autoplay="${escapeAttr(attrs.autoplay)}"` : '',
|
|
1971
2414
|
attrs.interval ? ` data-interval="${escapeAttr(attrs.interval)}"` : '',
|
|
1972
2415
|
attrs.loop ? ` data-loop="${escapeAttr(attrs.loop)}"` : '',
|
|
1973
|
-
attrs.animation ? ` data-animation="${escapeAttr(attrs.animation)}"` : ''
|
|
2416
|
+
attrs.animation ? ` data-animation="${escapeAttr(attrs.animation)}"` : '',
|
|
2417
|
+
attrs['animation-duration'] ? ` data-animation-duration="${escapeAttr(attrs['animation-duration'])}"` : '',
|
|
2418
|
+
attrs['animation-easing'] ? ` data-animation-easing="${escapeAttr(attrs['animation-easing'])}"` : ''
|
|
1974
2419
|
].join('');
|
|
1975
2420
|
const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
|
|
1976
2421
|
|
|
@@ -2617,7 +3062,69 @@ function processIconBlocks(markdown) {
|
|
|
2617
3062
|
*/
|
|
2618
3063
|
const FORMS_DIR = path.resolve(__dirname_md, '../../content/forms');
|
|
2619
3064
|
|
|
2620
|
-
|
|
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
|
+
|
|
3127
|
+
async function processFormBlocks(markdown, tagSet) {
|
|
2621
3128
|
const {scrubbed, restore} = scrubCodeRegions(markdown);
|
|
2622
3129
|
const regex = /\[form([^\]]*?)\/\]/gi;
|
|
2623
3130
|
let result = scrubbed;
|
|
@@ -2638,12 +3145,14 @@ async function processFormBlocks(markdown) {
|
|
|
2638
3145
|
result = result.slice(0, index) + `<div class="cms-form-error">Invalid form slug: ${escapeAttr(slug)}</div>` + result.slice(index + full.length);
|
|
2639
3146
|
continue;
|
|
2640
3147
|
}
|
|
3148
|
+
tagSet?.add(`form:${slug}`);
|
|
2641
3149
|
let replacement;
|
|
2642
3150
|
try {
|
|
2643
3151
|
const filePath = path.resolve(FORMS_DIR, `${slug}.json`);
|
|
2644
3152
|
if (!filePath.startsWith(FORMS_DIR + path.sep)) throw new Error('Invalid slug');
|
|
2645
3153
|
const raw = await readFile(filePath, 'utf8');
|
|
2646
3154
|
const form = JSON.parse(raw);
|
|
3155
|
+
await expandReferenceFields(form);
|
|
2647
3156
|
const encoded = Buffer.from(JSON.stringify(form)).toString('base64');
|
|
2648
3157
|
const extraClass = attrs.class ? ` ${escapeAttr(attrs.class)}` : '';
|
|
2649
3158
|
const idAttr = attrs.id ? ` id="${escapeAttr(attrs.id)}"` : '';
|
|
@@ -2838,6 +3347,78 @@ function processSlideoverBlocks(markdown) {
|
|
|
2838
3347
|
* @param {string} markdown
|
|
2839
3348
|
* @returns {string}
|
|
2840
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
|
+
|
|
2841
3422
|
function processDConfigBlocks(markdown) {
|
|
2842
3423
|
const {scrubbed, restore} = scrubCodeRegions(markdown);
|
|
2843
3424
|
const processed = scrubbed.replace(
|
|
@@ -2904,22 +3485,28 @@ function processCtaBlocks(markdown) {
|
|
|
2904
3485
|
* Parse a Markdown file string into frontmatter data and rendered HTML.
|
|
2905
3486
|
*
|
|
2906
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.
|
|
2907
3492
|
* @returns {{ data: object, content: string, html: string }}
|
|
2908
3493
|
*/
|
|
2909
|
-
export async function parseMarkdown(raw) {
|
|
3494
|
+
export async function parseMarkdown(raw, opts = {}) {
|
|
2910
3495
|
const {data, content} = matter(raw);
|
|
2911
3496
|
const extensions = getSanitizeExtensions();
|
|
2912
3497
|
|
|
2913
3498
|
// Pipeline:
|
|
2914
|
-
// beforeParse → collection → view → staticBlock → dconfig → effects → plugin shortcodes → tabs → accordion → carousel
|
|
3499
|
+
// beforeParse → collection → view → staticBlock → menu → dconfig → effects → plugin shortcodes → tabs → accordion → carousel
|
|
2915
3500
|
// → countdown → timeline → spacer → center → icon → form → hero → table → badge → button → link → cta
|
|
2916
3501
|
// → grid → card → slideover → marked → sanitize → afterParse
|
|
2917
3502
|
const preprocessed = applyTransforms('markdown:beforeParse', content);
|
|
2918
3503
|
const {output: withComponents, used: usedComponents} = collectAndRewriteComponents(preprocessed);
|
|
2919
|
-
const
|
|
2920
|
-
const
|
|
2921
|
-
const
|
|
2922
|
-
const
|
|
3504
|
+
const tagSet = new Set();
|
|
3505
|
+
const withCollection = await processCollectionBlocks(withComponents, tagSet);
|
|
3506
|
+
const withView = await processViewBlocks(withCollection, tagSet);
|
|
3507
|
+
const withStaticBlock = await processStaticBlocks(withView, tagSet);
|
|
3508
|
+
const withMenu = await processMenuBlocks(withStaticBlock, opts.user || null);
|
|
3509
|
+
const withDconfig = processDConfigBlocks(withMenu);
|
|
2923
3510
|
const withEffects = processEffectsBlocks(withDconfig);
|
|
2924
3511
|
const withPluginShortcodes = await processPluginShortcodes(withEffects);
|
|
2925
3512
|
const withTabs = processTabsBlocks(withPluginShortcodes);
|
|
@@ -2931,7 +3518,7 @@ export async function parseMarkdown(raw) {
|
|
|
2931
3518
|
const withSpacer = processSpacerBlocks(withListGroup);
|
|
2932
3519
|
const withCenter = processCenterBlocks(withSpacer);
|
|
2933
3520
|
const withIcon = processIconBlocks(withCenter);
|
|
2934
|
-
const withForm = await processFormBlocks(withIcon);
|
|
3521
|
+
const withForm = await processFormBlocks(withIcon, tagSet);
|
|
2935
3522
|
const withHero = processHeroBlocks(withForm);
|
|
2936
3523
|
const withTable = processTableBlocks(withHero);
|
|
2937
3524
|
const withBadge = processBadgeBlocks(withTable);
|
|
@@ -2997,7 +3584,7 @@ export async function parseMarkdown(raw) {
|
|
|
2997
3584
|
const allowed = new Set(_dmTagAllowlist.map(t => t.replace(/^dm-/, '')));
|
|
2998
3585
|
const filteredUsed = [...usedComponents].filter(name => allowed.has(name));
|
|
2999
3586
|
|
|
3000
|
-
return {data, content, html, usedComponents: filteredUsed};
|
|
3587
|
+
return {data, content, html, usedComponents: filteredUsed, tags: [...tagSet]};
|
|
3001
3588
|
}
|
|
3002
3589
|
|
|
3003
3590
|
/**
|