domma-cms 0.36.19 → 0.38.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/admin/js/lib/shortcode-context-menu.js +4 -4
- package/admin/js/templates/collection-entries.html +1 -0
- package/admin/js/views/collection-entries.js +1 -1
- package/admin/js/views/form-editor.js +6 -6
- package/admin/js/views/settings.js +1 -1
- package/admin/js/views/user-editor.js +1 -1
- package/admin/js/views/users.js +2 -2
- package/package.json +1 -1
- package/public/js/collection-context.js +2 -0
- package/public/js/forms.js +1 -1
- package/public/js/site.js +1 -1
- package/server/routes/api/settings.js +10 -8
- package/server/routes/api/users.js +12 -5
- package/server/services/markdown.js +302 -40
- package/server/templates/page.html +3 -2
|
@@ -173,12 +173,77 @@ function formatDate(input, fmt) {
|
|
|
173
173
|
t => String(tokens[t]()));
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Build the `data-ctx` attribute that powers the public-site right-click
|
|
178
|
+
* search/filter menu (public/js/collection-context.js). The payload carries
|
|
179
|
+
* ONLY the fields the display actually renders — never the full entry data —
|
|
180
|
+
* so hidden/allowlisted-away fields can't leak through the attribute.
|
|
181
|
+
*
|
|
182
|
+
* @param {Array<{name: string, label?: string}>} fields - displayed fields
|
|
183
|
+
* @param {object[]} entries - collection entries {id, data}
|
|
184
|
+
* @returns {string} ` data-ctx="<base64 JSON>"` (leading space included)
|
|
185
|
+
*/
|
|
186
|
+
function buildCtxAttr(fields, entries) {
|
|
187
|
+
const names = [...new Set(fields.map(f => f?.name).filter(Boolean))];
|
|
188
|
+
if (!names.length || !entries.length) return '';
|
|
189
|
+
const payload = {
|
|
190
|
+
fields: names.map(n => ({name: n, label: fields.find(f => f.name === n)?.label || n})),
|
|
191
|
+
entries: entries.map(e => ({
|
|
192
|
+
id: String(e.id ?? ''),
|
|
193
|
+
data: Object.fromEntries(names.map(n => [n, e.data?.[n] ?? '']))
|
|
194
|
+
}))
|
|
195
|
+
};
|
|
196
|
+
return ` data-ctx="${Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')}"`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Fields referenced by a block template's {{placeholders}} (system keys excluded). */
|
|
200
|
+
function blockTemplateFields(tpl) {
|
|
201
|
+
const names = new Set();
|
|
202
|
+
for (const m of String(tpl || '').matchAll(/\{\{\{?([\w]+)/g)) {
|
|
203
|
+
if (!['_id', '_createdAt', '_updatedAt'].includes(m[1])) names.add(m[1]);
|
|
204
|
+
}
|
|
205
|
+
return [...names].map(n => ({name: n, label: n}));
|
|
206
|
+
}
|
|
207
|
+
|
|
176
208
|
function buildBlockStyleTag(blockName, css) {
|
|
177
209
|
if (!css || !css.trim()) return '';
|
|
178
210
|
const body = String(css).replace(/<\/style/gi, '<\\/style');
|
|
179
211
|
return `<style>[data-block="${escapeAttr(blockName)}"] {\n${body}\n}</style>\n`;
|
|
180
212
|
}
|
|
181
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Interpolate a block template against a single collection entry.
|
|
216
|
+
*
|
|
217
|
+
* Triple-brace `{{{field}}}` — raw HTML pass-through (no escape). Use for
|
|
218
|
+
* fields that contain pre-rendered HTML. Must be processed BEFORE double-brace
|
|
219
|
+
* so the outer `{`/`}` of a triple match aren't consumed by the double-brace
|
|
220
|
+
* regex. Double-brace `{{field}}` — HTML-escaped (default, safe), with an
|
|
221
|
+
* optional `:format` suffix (e.g. `{{_createdAt:DD MMM YYYY}}`) that applies
|
|
222
|
+
* moment-style date formatting when the value parses as a Date.
|
|
223
|
+
*
|
|
224
|
+
* @param {string} blockTemplate - Raw HTML template with {{placeholders}}
|
|
225
|
+
* @param {object} e - Collection entry { id, data, meta }
|
|
226
|
+
* @returns {string}
|
|
227
|
+
*/
|
|
228
|
+
function interpolateBlockTemplate(blockTemplate, e) {
|
|
229
|
+
let html = blockTemplate.replace(/\{\{\{(\w+)\}\}\}/g, (_, key) => {
|
|
230
|
+
return String(e.data?.[key] ?? '');
|
|
231
|
+
});
|
|
232
|
+
html = html.replace(/\{\{([\w_]+)(?::([^{}]+))?\}\}/g, (_, key, fmt) => {
|
|
233
|
+
let value;
|
|
234
|
+
if (key === '_id') value = e.id ?? '';
|
|
235
|
+
else if (key === '_createdAt') value = e.meta?.createdAt ?? '';
|
|
236
|
+
else if (key === '_updatedAt') value = e.meta?.updatedAt ?? '';
|
|
237
|
+
else value = e.data?.[key] ?? '';
|
|
238
|
+
if (fmt) {
|
|
239
|
+
const formatted = formatDate(value, fmt);
|
|
240
|
+
if (formatted !== null) return escapeHtmlText(formatted);
|
|
241
|
+
}
|
|
242
|
+
return escapeHtmlText(value);
|
|
243
|
+
});
|
|
244
|
+
return html;
|
|
245
|
+
}
|
|
246
|
+
|
|
182
247
|
/**
|
|
183
248
|
* Render collection entries using a reusable HTML block template.
|
|
184
249
|
* Replaces {{fieldName}}, {{_id}}, {{_createdAt}}, {{_updatedAt}} placeholders.
|
|
@@ -194,38 +259,16 @@ function renderCollectionBlocks(entries, blockTemplate, emptyMsg, ctaOpts, cols,
|
|
|
194
259
|
return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
|
|
195
260
|
}
|
|
196
261
|
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
262
|
+
// Every entry gets a neutral wrapper carrying data-entry-id (needed by the
|
|
263
|
+
// public right-click filter menu); the data-block attribute — which scopes
|
|
264
|
+
// the block's CSS — is only added when the block actually carries CSS.
|
|
200
265
|
const hasCss = !!(blockCss && blockCss.trim());
|
|
201
|
-
const
|
|
202
|
-
const wrapClose = hasCss && blockName ? `</div>` : '';
|
|
266
|
+
const blockAttr = hasCss && blockName ? ` data-block="${escapeAttr(blockName)}"` : '';
|
|
203
267
|
|
|
204
268
|
const items = entries.map(e => {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
// so the outer `{` and `}` of a triple match aren't consumed by the
|
|
209
|
-
// double-brace regex.
|
|
210
|
-
let html = blockTemplate.replace(/\{\{\{(\w+)\}\}\}/g, (_, key) => {
|
|
211
|
-
return String(e.data?.[key] ?? '');
|
|
212
|
-
});
|
|
213
|
-
// Double-brace `{{field}}` — HTML-escaped (default, safe).
|
|
214
|
-
// Optional `:format` suffix (e.g. `{{_createdAt:DD MMM YYYY}}`) applies
|
|
215
|
-
// moment-style date formatting when the value parses as a Date; any
|
|
216
|
-
// field that doesn't parse falls back to its raw value.
|
|
217
|
-
html = html.replace(/\{\{([\w_]+)(?::([^{}]+))?\}\}/g, (_, key, fmt) => {
|
|
218
|
-
let value;
|
|
219
|
-
if (key === '_id') value = e.id ?? '';
|
|
220
|
-
else if (key === '_createdAt') value = e.meta?.createdAt ?? '';
|
|
221
|
-
else if (key === '_updatedAt') value = e.meta?.updatedAt ?? '';
|
|
222
|
-
else value = e.data?.[key] ?? '';
|
|
223
|
-
if (fmt) {
|
|
224
|
-
const formatted = formatDate(value, fmt);
|
|
225
|
-
if (formatted !== null) return escapeHtmlText(formatted);
|
|
226
|
-
}
|
|
227
|
-
return escapeHtmlText(value);
|
|
228
|
-
});
|
|
269
|
+
const wrapOpen = `<div${blockAttr} data-entry-id="${escapeAttr(String(e.id ?? ''))}">`;
|
|
270
|
+
const wrapClose = `</div>`;
|
|
271
|
+
let html = interpolateBlockTemplate(blockTemplate, e);
|
|
229
272
|
|
|
230
273
|
if (ctaOpts) {
|
|
231
274
|
const ctaStyle = escapeAttr(ctaOpts.style || 'primary');
|
|
@@ -243,8 +286,9 @@ function renderCollectionBlocks(entries, blockTemplate, emptyMsg, ctaOpts, cols,
|
|
|
243
286
|
? `dm-collection-display dm-collection-blocks grid ${responsiveGridCols(validCols).join(' ')} gap-4`
|
|
244
287
|
: 'dm-collection-display dm-collection-blocks';
|
|
245
288
|
|
|
289
|
+
const ctx = buildCtxAttr(blockTemplateFields(blockTemplate), entries);
|
|
246
290
|
const styleTag = buildBlockStyleTag(blockName, blockCss);
|
|
247
|
-
return `${styleTag}<div class="${wrapperClass}">\n${items.join('\n')}\n</div>`;
|
|
291
|
+
return `${styleTag}<div class="${wrapperClass}"${ctx}>\n${items.join('\n')}\n</div>`;
|
|
248
292
|
}
|
|
249
293
|
|
|
250
294
|
function renderBlockFromAttrs(tmpl, attrs, blockName = '') {
|
|
@@ -459,9 +503,9 @@ function renderCollectionCards(entries, visibleFields, titleField, columns, empt
|
|
|
459
503
|
const iconHtml = ctaOpts.icon ? `<span data-icon="${escapeAttr(ctaOpts.icon)}"></span> ` : '';
|
|
460
504
|
footer = `<div class="card-footer"><button class="${btnCls}" ${btnData}>${iconHtml}${escapeHtmlText(ctaOpts.label || 'Run')}</button></div>`;
|
|
461
505
|
}
|
|
462
|
-
return `<div class="card">${title ? `<div class="card-header">${title}</div>` : ''}<div class="card-body">${body || ' '}</div>${footer}</div>`;
|
|
506
|
+
return `<div class="card" data-entry-id="${escapeAttr(String(e.id ?? ''))}">${title ? `<div class="card-header">${title}</div>` : ''}<div class="card-body">${body || ' '}</div>${footer}</div>`;
|
|
463
507
|
}).join('\n');
|
|
464
|
-
return `<div class="dm-collection-display grid ${responsiveGridCols(cols).join(' ')} gap-4">\n${cards}\n</div>`;
|
|
508
|
+
return `<div class="dm-collection-display grid ${responsiveGridCols(cols).join(' ')} gap-4"${buildCtxAttr(visibleFields, entries)}>\n${cards}\n</div>`;
|
|
465
509
|
}
|
|
466
510
|
|
|
467
511
|
function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaOpts) {
|
|
@@ -486,9 +530,9 @@ function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaO
|
|
|
486
530
|
const iconHtml = ctaOpts.icon ? `<span data-icon="${escapeAttr(ctaOpts.icon)}"></span> ` : '';
|
|
487
531
|
ctaHtml = `<button class="${btnCls}" ${btnData}>${iconHtml}${escapeHtmlText(ctaOpts.label || 'Run')}</button>`;
|
|
488
532
|
}
|
|
489
|
-
return `<div class="dm-collection-list-item">${title ? `<strong>${title}</strong>` : ''}${rest}${ctaHtml}</div>`;
|
|
533
|
+
return `<div class="dm-collection-list-item" data-entry-id="${escapeAttr(String(e.id ?? ''))}">${title ? `<strong>${title}</strong>` : ''}${rest}${ctaHtml}</div>`;
|
|
490
534
|
}).join('\n');
|
|
491
|
-
return `<div class="dm-collection-display dm-collection-list">\n${items}\n</div>`;
|
|
535
|
+
return `<div class="dm-collection-display dm-collection-list"${buildCtxAttr(visibleFields, entries)}>\n${items}\n</div>`;
|
|
492
536
|
}
|
|
493
537
|
|
|
494
538
|
/**
|
|
@@ -511,7 +555,7 @@ function renderCollectionAccordion(entries, titleField, bodyField, multiple, emp
|
|
|
511
555
|
const bodyVal = String(e.data?.[bodyField] ?? '');
|
|
512
556
|
const bodyHtml = marked.parse(bodyVal);
|
|
513
557
|
return (
|
|
514
|
-
`<div class="accordion-item">\n` +
|
|
558
|
+
`<div class="accordion-item" data-entry-id="${escapeAttr(String(e.id ?? ''))}">\n` +
|
|
515
559
|
` <h3 class="accordion-header"><button class="accordion-button" type="button">${title}` +
|
|
516
560
|
`<span class="accordion-icon" data-icon="chevron-down"></span></button></h3>\n` +
|
|
517
561
|
` <div class="accordion-body"><div class="accordion-content">${bodyHtml}</div></div>\n` +
|
|
@@ -519,7 +563,8 @@ function renderCollectionAccordion(entries, titleField, bodyField, multiple, emp
|
|
|
519
563
|
);
|
|
520
564
|
}).join('\n');
|
|
521
565
|
|
|
522
|
-
|
|
566
|
+
const ctx = buildCtxAttr([{name: titleField}, {name: bodyField}], entries);
|
|
567
|
+
return `<div class="dm-collection-display accordion"${multiAttr}${ctx}>\n${items}\n</div>`;
|
|
523
568
|
}
|
|
524
569
|
|
|
525
570
|
/**
|
|
@@ -553,10 +598,167 @@ function renderCollectionTimeline(entries, opts) {
|
|
|
553
598
|
const bodyHtml = bodyField
|
|
554
599
|
? marked.parse(String(e.data?.[bodyField] ?? ''))
|
|
555
600
|
: '';
|
|
556
|
-
return `<div class="dm-progression-item"${date}${status}${icon}><div class="dm-progression-item-title">${title}</div><div class="dm-progression-item-body">${bodyHtml}</div></div>`;
|
|
601
|
+
return `<div class="dm-progression-item" data-entry-id="${escapeAttr(String(e.id ?? ''))}"${date}${status}${icon}><div class="dm-progression-item-title">${title}</div><div class="dm-progression-item-body">${bodyHtml}</div></div>`;
|
|
602
|
+
}).join('\n');
|
|
603
|
+
|
|
604
|
+
const ctx = buildCtxAttr(
|
|
605
|
+
[{name: titleField}, {name: dateField}, {name: statusField}, {name: bodyField}].filter(f => f.name),
|
|
606
|
+
entries
|
|
607
|
+
);
|
|
608
|
+
return `<div class="dm-collection-display dm-progression" data-layout="${layout}" data-theme="${theme}" data-mode="${mode}"${ctx}>\n${items}\n</div>`;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Render a collection as a Domma carousel — one slide per entry.
|
|
613
|
+
* Output mirrors processCarouselBlocks so site.js's `.carousel` auto-init
|
|
614
|
+
* (Domma.elements.carousel) picks it up unchanged.
|
|
615
|
+
*
|
|
616
|
+
* Per-slide content comes either from a block template (opts.blockTemplate,
|
|
617
|
+
* interpolated per entry like display="block") or from image/title/body
|
|
618
|
+
* fields rendered in the standard carousel-slide structure.
|
|
619
|
+
*
|
|
620
|
+
* @param {object[]} entries
|
|
621
|
+
* @param {object} opts
|
|
622
|
+
* @param {string} [opts.imageField] - field holding the slide image URL
|
|
623
|
+
* @param {string} [opts.titleField] - field for the slide title
|
|
624
|
+
* @param {string} [opts.bodyField] - Markdown body field
|
|
625
|
+
* @param {string} [opts.blockTemplate] - optional block template (wins over fields)
|
|
626
|
+
* @param {string} [opts.blockName]
|
|
627
|
+
* @param {string} [opts.blockCss]
|
|
628
|
+
* @param {object} [opts.dataAttrs] - autoplay/interval/loop/animation passthrough
|
|
629
|
+
* @param {string} opts.emptyMsg
|
|
630
|
+
* @returns {string}
|
|
631
|
+
*/
|
|
632
|
+
function renderCollectionCarousel(entries, opts) {
|
|
633
|
+
const {imageField, titleField, bodyField, blockTemplate, blockName, blockCss, dataAttrs = {}, emptyMsg} = opts;
|
|
634
|
+
if (!entries.length) {
|
|
635
|
+
return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const passthrough = ['autoplay', 'interval', 'loop', 'animation', 'animation-duration', 'animation-easing']
|
|
639
|
+
.map(k => dataAttrs[k] ? ` data-${k}="${escapeAttr(String(dataAttrs[k]))}"` : '')
|
|
640
|
+
.join('');
|
|
641
|
+
|
|
642
|
+
const hasCss = !!(blockCss && blockCss.trim());
|
|
643
|
+
const slides = entries.map(e => {
|
|
644
|
+
let inner = '';
|
|
645
|
+
if (blockTemplate) {
|
|
646
|
+
const html = interpolateBlockTemplate(blockTemplate, e);
|
|
647
|
+
inner = hasCss && blockName ? `<div data-block="${escapeAttr(blockName)}">${html}</div>` : html;
|
|
648
|
+
} else {
|
|
649
|
+
const img = imageField && e.data?.[imageField]
|
|
650
|
+
? `<img src="${escapeAttr(String(e.data[imageField]))}" alt="${escapeAttr(String(e.data?.[titleField] ?? ''))}">\n`
|
|
651
|
+
: '';
|
|
652
|
+
const title = titleField && e.data?.[titleField] != null
|
|
653
|
+
? `<h2 class="carousel-slide-title">${escapeHtmlText(String(e.data[titleField]))}</h2>`
|
|
654
|
+
: '';
|
|
655
|
+
const bodyHtml = bodyField && e.data?.[bodyField] != null
|
|
656
|
+
? `<div class="carousel-slide-description">${marked.parse(String(e.data[bodyField]))}</div>`
|
|
657
|
+
: '';
|
|
658
|
+
inner = img + (title || bodyHtml ? `<div class="carousel-slide-content">${title}${bodyHtml}</div>` : '');
|
|
659
|
+
}
|
|
660
|
+
return `<div class="carousel-slide" data-entry-id="${escapeAttr(String(e.id ?? ''))}">${inner}</div>`;
|
|
661
|
+
}).join('\n');
|
|
662
|
+
|
|
663
|
+
const ctxFields = blockTemplate
|
|
664
|
+
? blockTemplateFields(blockTemplate)
|
|
665
|
+
: [{name: titleField}, {name: bodyField}].filter(f => f.name);
|
|
666
|
+
const ctx = buildCtxAttr(ctxFields, entries);
|
|
667
|
+
const styleTag = blockTemplate ? buildBlockStyleTag(blockName, blockCss) : '';
|
|
668
|
+
return `${styleTag}<div class="dm-collection-display carousel"${passthrough}${ctx}><div class="carousel-track">${slides}</div></div>`;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Render a collection as a Domma list group — one item per entry.
|
|
673
|
+
* Output mirrors processListGroupBlocks (pure CSS component, no JS init).
|
|
674
|
+
*
|
|
675
|
+
* Per-item content comes either from a block template (interpolated per
|
|
676
|
+
* entry) or from title/body fields.
|
|
677
|
+
*
|
|
678
|
+
* @param {object[]} entries
|
|
679
|
+
* @param {object} opts
|
|
680
|
+
* @param {string} [opts.titleField] - field for the item's leading text
|
|
681
|
+
* @param {string} [opts.bodyField] - inline-Markdown body field
|
|
682
|
+
* @param {string} [opts.variant] - flush | numbered | horizontal
|
|
683
|
+
* @param {string} [opts.size] - sm | lg
|
|
684
|
+
* @param {string} [opts.itemVariant] - primary | secondary | success | danger | warning | info
|
|
685
|
+
* @param {boolean} [opts.action] - add .list-group-item-action per item
|
|
686
|
+
* @param {string} [opts.blockTemplate] - optional block template (wins over fields)
|
|
687
|
+
* @param {string} [opts.blockName]
|
|
688
|
+
* @param {string} [opts.blockCss]
|
|
689
|
+
* @param {string} opts.emptyMsg
|
|
690
|
+
* @returns {string}
|
|
691
|
+
*/
|
|
692
|
+
/** Standard "block template not found" fragment. */
|
|
693
|
+
function blockNotFoundHtml(blockName) {
|
|
694
|
+
return `<div class="dm-collection-display dm-collection-empty"><p>Block template “${escapeHtmlText(blockName || '')}” not found.</p></div>`;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Load a block's template + CSS when a block attr is present.
|
|
699
|
+
* Returns {tpl: '', css: '', missing: false} when no block was requested,
|
|
700
|
+
* and {missing: true} when the named block can't be loaded — callers show
|
|
701
|
+
* blockNotFoundHtml() rather than silently falling back to field rendering.
|
|
702
|
+
*
|
|
703
|
+
* @param {string} [blockName]
|
|
704
|
+
* @returns {Promise<{tpl: string, css: string, missing: boolean}>}
|
|
705
|
+
*/
|
|
706
|
+
async function loadOptionalBlockAssets(blockName) {
|
|
707
|
+
if (!blockName) return {tpl: '', css: '', missing: false};
|
|
708
|
+
try {
|
|
709
|
+
const [tpl, css] = await Promise.all([
|
|
710
|
+
loadBlockTemplate(blockName),
|
|
711
|
+
loadBlockCss(blockName),
|
|
712
|
+
]);
|
|
713
|
+
return {tpl, css, missing: false};
|
|
714
|
+
} catch {
|
|
715
|
+
return {tpl: '', css: '', missing: true};
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function renderCollectionListgroup(entries, opts) {
|
|
720
|
+
const {titleField, bodyField, variant, size, itemVariant, action, blockTemplate, blockName, blockCss, emptyMsg} = opts;
|
|
721
|
+
if (!entries.length) {
|
|
722
|
+
return `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
const variantMap = {flush: 'list-group-flush', numbered: 'list-group-numbered', horizontal: 'list-group-horizontal'};
|
|
726
|
+
const sizeMap = {sm: 'list-group-sm', lg: 'list-group-lg'};
|
|
727
|
+
const wrapperClasses = ['dm-collection-display', 'list-group'];
|
|
728
|
+
if (variantMap[variant]) wrapperClasses.push(variantMap[variant]);
|
|
729
|
+
if (sizeMap[size]) wrapperClasses.push(sizeMap[size]);
|
|
730
|
+
|
|
731
|
+
const itemClasses = ['list-group-item'];
|
|
732
|
+
if (action) itemClasses.push('list-group-item-action');
|
|
733
|
+
if (itemVariant && /^(primary|secondary|success|danger|warning|info)$/.test(itemVariant)) {
|
|
734
|
+
itemClasses.push(`list-group-item-${itemVariant}`);
|
|
735
|
+
}
|
|
736
|
+
const itemClassStr = itemClasses.join(' ');
|
|
737
|
+
|
|
738
|
+
const hasCss = !!(blockCss && blockCss.trim());
|
|
739
|
+
const items = entries.map(e => {
|
|
740
|
+
let inner;
|
|
741
|
+
if (blockTemplate) {
|
|
742
|
+
const html = interpolateBlockTemplate(blockTemplate, e);
|
|
743
|
+
inner = hasCss && blockName ? `<div data-block="${escapeAttr(blockName)}">${html}</div>` : html;
|
|
744
|
+
} else {
|
|
745
|
+
const title = titleField && e.data?.[titleField] != null
|
|
746
|
+
? `<strong>${escapeHtmlText(String(e.data[titleField]))}</strong>`
|
|
747
|
+
: '';
|
|
748
|
+
const body = bodyField && e.data?.[bodyField] != null
|
|
749
|
+
? `${title ? ' ' : ''}${marked.parseInline(String(e.data[bodyField]))}`
|
|
750
|
+
: '';
|
|
751
|
+
inner = (title + body) || escapeHtmlText(String(e.id ?? ''));
|
|
752
|
+
}
|
|
753
|
+
return `<div class="${itemClassStr}" data-entry-id="${escapeAttr(String(e.id ?? ''))}">${inner}</div>`;
|
|
557
754
|
}).join('\n');
|
|
558
755
|
|
|
559
|
-
|
|
756
|
+
const ctxFields = blockTemplate
|
|
757
|
+
? blockTemplateFields(blockTemplate)
|
|
758
|
+
: [{name: titleField}, {name: bodyField}].filter(f => f.name);
|
|
759
|
+
const ctx = buildCtxAttr(ctxFields, entries);
|
|
760
|
+
const styleTag = blockTemplate ? buildBlockStyleTag(blockName, blockCss) : '';
|
|
761
|
+
return `${styleTag}<div class="${wrapperClasses.join(' ')}"${ctx}>\n${items}\n</div>`;
|
|
560
762
|
}
|
|
561
763
|
|
|
562
764
|
/**
|
|
@@ -661,6 +863,36 @@ async function processViewBlocks(markdown, tagSet) {
|
|
|
661
863
|
mode: timelineMode,
|
|
662
864
|
emptyMsg,
|
|
663
865
|
});
|
|
866
|
+
} else if (display === 'carousel') {
|
|
867
|
+
const {tpl, css, missing} = await loadOptionalBlockAssets(attrs.block);
|
|
868
|
+
replacement = missing
|
|
869
|
+
? blockNotFoundHtml(attrs.block)
|
|
870
|
+
: renderCollectionCarousel(entries, {
|
|
871
|
+
imageField: attrs['image-field'] || '',
|
|
872
|
+
titleField: attrs['title-field'] || 'title',
|
|
873
|
+
bodyField: attrs['body-field'] || '',
|
|
874
|
+
blockTemplate: tpl,
|
|
875
|
+
blockName: attrs.block || '',
|
|
876
|
+
blockCss: css,
|
|
877
|
+
dataAttrs: attrs,
|
|
878
|
+
emptyMsg,
|
|
879
|
+
});
|
|
880
|
+
} else if (display === 'listgroup') {
|
|
881
|
+
const {tpl, css, missing} = await loadOptionalBlockAssets(attrs.block);
|
|
882
|
+
replacement = missing
|
|
883
|
+
? blockNotFoundHtml(attrs.block)
|
|
884
|
+
: renderCollectionListgroup(entries, {
|
|
885
|
+
titleField: attrs['title-field'] || 'title',
|
|
886
|
+
bodyField: attrs['body-field'] || '',
|
|
887
|
+
variant: attrs.variant || '',
|
|
888
|
+
size: attrs.size || '',
|
|
889
|
+
itemVariant: attrs.itemvariant || '',
|
|
890
|
+
action: attrs.action === 'true',
|
|
891
|
+
blockTemplate: tpl,
|
|
892
|
+
blockName: attrs.block || '',
|
|
893
|
+
blockCss: css,
|
|
894
|
+
emptyMsg,
|
|
895
|
+
});
|
|
664
896
|
} else if (display === 'block') {
|
|
665
897
|
const blockName = attrs.block || viewConfig?.display?.block || '';
|
|
666
898
|
if (blockName) {
|
|
@@ -671,7 +903,7 @@ async function processViewBlocks(markdown, tagSet) {
|
|
|
671
903
|
]);
|
|
672
904
|
replacement = renderCollectionBlocks(entries, tpl, emptyMsg, ctaOpts, '', blockName, css);
|
|
673
905
|
} catch {
|
|
674
|
-
replacement =
|
|
906
|
+
replacement = blockNotFoundHtml(blockName);
|
|
675
907
|
}
|
|
676
908
|
}
|
|
677
909
|
} else {
|
|
@@ -1118,6 +1350,36 @@ export async function renderCollectionFragment(attrs, { extraFilter = null } = {
|
|
|
1118
1350
|
mode: timelineMode,
|
|
1119
1351
|
emptyMsg,
|
|
1120
1352
|
});
|
|
1353
|
+
} else if (display === 'carousel') {
|
|
1354
|
+
const {tpl, css, missing} = await loadOptionalBlockAssets(attrs.block);
|
|
1355
|
+
replacement = missing
|
|
1356
|
+
? blockNotFoundHtml(attrs.block)
|
|
1357
|
+
: renderCollectionCarousel(entries, {
|
|
1358
|
+
imageField: attrs['image-field'] || '',
|
|
1359
|
+
titleField: attrs['title-field'] || 'title',
|
|
1360
|
+
bodyField: attrs['body-field'] || '',
|
|
1361
|
+
blockTemplate: tpl,
|
|
1362
|
+
blockName: attrs.block || '',
|
|
1363
|
+
blockCss: css,
|
|
1364
|
+
dataAttrs: attrs,
|
|
1365
|
+
emptyMsg,
|
|
1366
|
+
});
|
|
1367
|
+
} else if (display === 'listgroup') {
|
|
1368
|
+
const {tpl, css, missing} = await loadOptionalBlockAssets(attrs.block);
|
|
1369
|
+
replacement = missing
|
|
1370
|
+
? blockNotFoundHtml(attrs.block)
|
|
1371
|
+
: renderCollectionListgroup(entries, {
|
|
1372
|
+
titleField: attrs['title-field'] || 'title',
|
|
1373
|
+
bodyField: attrs['body-field'] || '',
|
|
1374
|
+
variant: attrs.variant || '',
|
|
1375
|
+
size: attrs.size || '',
|
|
1376
|
+
itemVariant: attrs.itemvariant || '',
|
|
1377
|
+
action: attrs.action === 'true',
|
|
1378
|
+
blockTemplate: tpl,
|
|
1379
|
+
blockName: attrs.block || '',
|
|
1380
|
+
blockCss: css,
|
|
1381
|
+
emptyMsg,
|
|
1382
|
+
});
|
|
1121
1383
|
} else if (display === 'block') {
|
|
1122
1384
|
const blockName = attrs.block || '';
|
|
1123
1385
|
if (blockName) {
|
|
@@ -1129,7 +1391,7 @@ export async function renderCollectionFragment(attrs, { extraFilter = null } = {
|
|
|
1129
1391
|
const cols = attrs.cols || '';
|
|
1130
1392
|
replacement = renderCollectionBlocks(entries, tpl, emptyMsg, ctaOpts, cols, blockName, css);
|
|
1131
1393
|
} catch {
|
|
1132
|
-
replacement =
|
|
1394
|
+
replacement = blockNotFoundHtml(blockName);
|
|
1133
1395
|
}
|
|
1134
1396
|
}
|
|
1135
1397
|
} else {
|
|
@@ -116,7 +116,8 @@
|
|
|
116
116
|
</script>
|
|
117
117
|
|
|
118
118
|
<!-- Site initialisation -->
|
|
119
|
-
<script src="/public/js/site.js?v=
|
|
119
|
+
<script src="/public/js/site.js?v=20260722-collection-ctx" type="module"></script>
|
|
120
|
+
<script src="/public/js/collection-context.js?v=20260722-collection-ctx" defer></script>
|
|
120
121
|
|
|
121
122
|
<!-- Interactive Collection Browser (used by [collection] shortcodes with
|
|
122
123
|
searchable / filterable / sortable / paginate attributes) -->
|
|
@@ -124,7 +125,7 @@
|
|
|
124
125
|
|
|
125
126
|
<!-- Core Forms (logic engine must load before renderer) -->
|
|
126
127
|
<script src="/public/js/form-logic-engine.js?v=20260704-chooser-events"></script>
|
|
127
|
-
<script src="/public/js/forms.js?v=
|
|
128
|
+
<script src="/public/js/forms.js?v=20260722-pickers" type="module"></script>
|
|
128
129
|
|
|
129
130
|
<!-- Core effects runtime -->
|
|
130
131
|
<script src="/public/js/effects.js"></script>
|