domma-cms 1.8.5 → 1.8.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.8.6] - 2026-10-16
4
+
5
+ - **Security: what is stored in a collection no longer runs as a shortcode.** A `[collection]` or `[view]` used to pass its entries on to the later shortcode steps, so an entry - a public form submission, say - holding `[form]`, `[button]`, `[embed]`, `[slideover]` or another `[collection]` was carried out on the page. Entry values now show exactly as typed, in every display type and in `{{field}}` / `{{{field}}}` block placeholders, while the page's own shortcodes and those written into a block template work as before. Filter, search and export still see the stored values. A value containing `$&` or `` $` `` is also shown as written instead of pulling in other parts of the page.
6
+
3
7
  ## [1.8.5] - 2026-10-16
4
8
 
5
9
  - **Import from WordPress brings custom post types across.** Content a theme or plugin added to WordPress - events, team members, portfolio pieces - now has its own Custom types section in the preview, with how many there are, a few titles, and what each will become. By default a type becomes a collection (wp-event, wp-team-member…) holding each item's title, address, text, excerpt, date, status and featured image, its categories, and every custom field it had, Advanced Custom Fields included - dates, numbers, links and pictures recognised as such. A list page at the type's old address (/events) shows the published ones, and the old single-item addresses and `?p=` links lead there; drafts come across but are never shown or readable from the site. Or choose Pages - each item a page at its old address, its categories as tags - or Skip. The type's address is read from the export's own links, never guessed. WordPress's and plugins' own housekeeping (revisions, reusable blocks, templates, form definitions, orders…) is listed once with why it is not imported. Run it again and entries you edited here are left alone unless you choose Overwrite, and a collection only ever gains fields - your own fields and labels stay.
package/CLAUDE.md CHANGED
@@ -80,6 +80,13 @@ Containers that render their own body (tabs, accordion, carousel, timeline, hero
80
80
  wrappers) run the inline tags plus `[grid]`/`[card]` over it first - keep that set in step with
81
81
  `processInlineShortcodes()` when adding an inline tag. The full order is in `docs/markdown-shortcodes.md`.
82
82
 
83
+ **Entry data never runs as a shortcode.** `[collection]` / `[view]` output carries entry values into stages 3-10,
84
+ so every value they draw goes through `inertShortcodes()` (the character after each `[` becomes an entity, so
85
+ `[form` is `[form` and nothing matches, while Markdown links in values still work): displays with no authored
86
+ markup encode their whole output, block templates encode each `{{field}}` / `{{{field}}}` value and leave the
87
+ template's own shortcodes live. A new display or placeholder must do the same. The base64 `data-ctx` / table
88
+ payloads stay raw (no brackets in base64), so filter, search and export see the stored value.
89
+
83
90
  Safe nesting: `[card]` inside `[slideover]`, `[grid]` inside `[slideover]`, `[col]` inside `[grid]`, `[card]` inside
84
91
  `[box]` and `[box]` inside `[card]`.
85
92
  Do NOT nest `[slideover]` inside another `[slideover]`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.8.5",
3
+ "version": "1.8.6",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -57,6 +57,37 @@ marked.setOptions({
57
57
  // Collection shortcode helpers
58
58
  // ---------------------------------------------------------------------------
59
59
 
60
+ /**
61
+ * Make text from a collection entry inert to the shortcode stages that run
62
+ * after `[collection]` / `[view]`.
63
+ *
64
+ * Entry values are DATA - public form submissions among them - and the
65
+ * rendered display is spliced into the page before tabs, form, embed, badge,
66
+ * button, card, slideover and the rest run, so a stored `[form id="x" /]`
67
+ * would otherwise be executed on the public page. Applied to the HTML that
68
+ * carries entry values (escaped text, attribute values, a rendered Markdown
69
+ * body, a `{{{raw}}}` placeholder), never to authored markup: a block
70
+ * template's own shortcodes still run.
71
+ *
72
+ * Every shortcode pattern starts `[name`, `[/name` or `[ name`, so the
73
+ * character after each `[` is written as a numeric entity (`[form` becomes
74
+ * `[form`) and a trailing `[` as `[`. Nothing can match, yet the
75
+ * brackets themselves stay, so a Markdown link in a value that a block
76
+ * template leaves to marked (`[text](url)`) is still a link. Entities read
77
+ * back as the same characters in text and attribute values, and the final
78
+ * sanitiser decodes them, so nothing visible changes; the base64 `data-ctx`
79
+ * / table payloads carry no brackets and are left as is, so filter, search
80
+ * and export see the stored values.
81
+ *
82
+ * @param {string} html - already-escaped HTML or text
83
+ * @returns {string}
84
+ */
85
+ export function inertShortcodes(html) {
86
+ return String(html ?? '')
87
+ .replace(/\[([\w\s\/-])/g, (_, c) => `[&#${c.codePointAt(0)};`)
88
+ .replace(/\[$/, '[');
89
+ }
90
+
60
91
  function escapeHtmlText(str) {
61
92
  return String(str ?? '')
62
93
  .replace(/&/g, '&')
@@ -349,7 +380,9 @@ function interpolateBlockTemplate(blockTemplate, e) {
349
380
  // One pass: a raw value put in (pre-rendered HTML) is never read again for
350
381
  // placeholders - text that shows "{{title}}" keeps it.
351
382
  return blockTemplate.replace(/\{\{\{(\w+)\}\}\}|\{\{([\w_]+)(?::([^{}]+))?\}\}/g, (_, raw, key, fmt) => {
352
- if (raw !== undefined) return String(e.data?.[raw] ?? '');
383
+ // Entry values never carry a shortcode into the page (inertShortcodes);
384
+ // the template's own markup is untouched.
385
+ if (raw !== undefined) return inertShortcodes(String(e.data?.[raw] ?? ''));
353
386
  let value;
354
387
  if (key === '_id') value = e.id ?? '';
355
388
  else if (key === '_createdAt') value = e.meta?.createdAt ?? '';
@@ -357,9 +390,9 @@ function interpolateBlockTemplate(blockTemplate, e) {
357
390
  else value = e.data?.[key] ?? '';
358
391
  if (fmt) {
359
392
  const formatted = formatDate(value, fmt);
360
- if (formatted !== null) return escapeHtmlText(formatted);
393
+ if (formatted !== null) return inertShortcodes(escapeHtmlText(formatted));
361
394
  }
362
- return escapeHtmlText(value);
395
+ return inertShortcodes(escapeHtmlText(value));
363
396
  });
364
397
  }
365
398
 
@@ -401,14 +434,14 @@ function renderCollectionBlocks(entries, blockTemplate, emptyMsg, ctaOpts, cols,
401
434
 
402
435
  const items = entries.map(e => {
403
436
  const entryCls = entryClassSuffix(ctxOpts).trim();
404
- const wrapOpen = `<div${entryCls ? ` class="${entryCls}"` : ''}${blockAttr} data-entry-id="${escapeAttr(String(e.id ?? ''))}">`;
437
+ const wrapOpen = `<div${entryCls ? ` class="${entryCls}"` : ''}${blockAttr} data-entry-id="${inertShortcodes(escapeAttr(String(e.id ?? '')))}">`;
405
438
  const wrapClose = `</div>`;
406
439
  let html = interpolateBlockTemplate(blockTemplate, e);
407
440
 
408
441
  if (ctaOpts) {
409
442
  const ctaStyle = escapeAttr(ctaOpts.style || 'primary');
410
443
  let btnCls = `btn btn-${ctaStyle} dm-cta-trigger`;
411
- let btnData = `data-action="${escapeAttr(ctaOpts.action)}" data-entry="${escapeAttr(e.id || '')}"`;
444
+ let btnData = `data-action="${escapeAttr(ctaOpts.action)}" data-entry="${inertShortcodes(escapeAttr(e.id || ''))}"`;
412
445
  if (ctaOpts.confirm) btnData += ` data-confirm="${escapeAttr(ctaOpts.confirm)}"`;
413
446
  if (ctaOpts.scope) btnData += ` data-scope="${escapeAttr(ctaOpts.scope)}"`;
414
447
  const iconHtml = ctaOpts.icon ? `<span data-icon="${escapeAttr(ctaOpts.icon)}"></span> ` : '';
@@ -636,15 +669,16 @@ function renderCollectionCards(entries, visibleFields, titleField, columns, empt
636
669
  if (ctaOpts) {
637
670
  const ctaStyle = escapeAttr(ctaOpts.style || 'primary');
638
671
  let btnCls = `btn btn-${ctaStyle} dm-cta-trigger`;
639
- let btnData = `data-action="${escapeAttr(ctaOpts.action)}" data-entry="${escapeAttr(e.id || '')}"`;
672
+ let btnData = `data-action="${escapeAttr(ctaOpts.action)}" data-entry="${inertShortcodes(escapeAttr(e.id || ''))}"`;
640
673
  if (ctaOpts.confirm) btnData += ` data-confirm="${escapeAttr(ctaOpts.confirm)}"`;
641
674
  if (ctaOpts.scope) btnData += ` data-scope="${escapeAttr(ctaOpts.scope)}"`;
642
675
  const iconHtml = ctaOpts.icon ? `<span data-icon="${escapeAttr(ctaOpts.icon)}"></span> ` : '';
643
676
  footer = `<div class="card-footer"><button class="${btnCls}" ${btnData}>${iconHtml}${escapeHtmlText(ctaOpts.label || 'Run')}</button></div>`;
644
677
  }
645
- return `<div class="card${entryClassSuffix(ctxOpts)}" data-entry-id="${escapeAttr(String(e.id ?? ''))}">${title ? `<div class="card-header">${title}</div>` : ''}<div class="card-body">${body || '&nbsp;'}</div>${footer}</div>`;
678
+ return `<div class="card${entryClassSuffix(ctxOpts)}" data-entry-id="${inertShortcodes(escapeAttr(String(e.id ?? '')))}">${title ? `<div class="card-header">${title}</div>` : ''}<div class="card-body">${body || '&nbsp;'}</div>${footer}</div>`;
646
679
  }).join('\n');
647
- return `<div class="dm-collection-display grid ${responsiveGridCols(cols).join(' ')} gap-4"${buildCtxAttr(visibleFields, entries, ctxOpts)}>\n${cards}\n</div>`;
680
+ // No authored markup in this display - everything bracketed in it is entry data.
681
+ return inertShortcodes(`<div class="dm-collection-display grid ${responsiveGridCols(cols).join(' ')} gap-4"${buildCtxAttr(visibleFields, entries, ctxOpts)}>\n${cards}\n</div>`);
648
682
  }
649
683
 
650
684
  function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaOpts, ctxOpts = {}) {
@@ -664,15 +698,16 @@ function renderCollectionList(entries, visibleFields, titleField, emptyMsg, ctaO
664
698
  if (ctaOpts) {
665
699
  const ctaStyle = escapeAttr(ctaOpts.style || 'primary');
666
700
  let btnCls = `btn btn-${ctaStyle} dm-cta-trigger`;
667
- let btnData = `data-action="${escapeAttr(ctaOpts.action)}" data-entry="${escapeAttr(e.id || '')}"`;
701
+ let btnData = `data-action="${escapeAttr(ctaOpts.action)}" data-entry="${inertShortcodes(escapeAttr(e.id || ''))}"`;
668
702
  if (ctaOpts.confirm) btnData += ` data-confirm="${escapeAttr(ctaOpts.confirm)}"`;
669
703
  if (ctaOpts.scope) btnData += ` data-scope="${escapeAttr(ctaOpts.scope)}"`;
670
704
  const iconHtml = ctaOpts.icon ? `<span data-icon="${escapeAttr(ctaOpts.icon)}"></span> ` : '';
671
705
  ctaHtml = `<button class="${btnCls}" ${btnData}>${iconHtml}${escapeHtmlText(ctaOpts.label || 'Run')}</button>`;
672
706
  }
673
- return `<div class="dm-collection-list-item${entryClassSuffix(ctxOpts)}" data-entry-id="${escapeAttr(String(e.id ?? ''))}">${title ? `<strong>${title}</strong>` : ''}${rest}${ctaHtml}</div>`;
707
+ return `<div class="dm-collection-list-item${entryClassSuffix(ctxOpts)}" data-entry-id="${inertShortcodes(escapeAttr(String(e.id ?? '')))}">${title ? `<strong>${title}</strong>` : ''}${rest}${ctaHtml}</div>`;
674
708
  }).join('\n');
675
- return `<div class="dm-collection-display dm-collection-list"${buildCtxAttr(visibleFields, entries, ctxOpts)}>\n${items}\n</div>`;
709
+ // No authored markup in this display - everything bracketed in it is entry data.
710
+ return inertShortcodes(`<div class="dm-collection-display dm-collection-list"${buildCtxAttr(visibleFields, entries, ctxOpts)}>\n${items}\n</div>`);
676
711
  }
677
712
 
678
713
  /**
@@ -695,7 +730,7 @@ function renderCollectionAccordion(entries, titleField, bodyField, multiple, emp
695
730
  const bodyVal = String(e.data?.[bodyField] ?? '');
696
731
  const bodyHtml = marked.parse(bodyVal);
697
732
  return (
698
- `<div class="accordion-item${entryClassSuffix(ctxOpts)}" data-entry-id="${escapeAttr(String(e.id ?? ''))}">\n` +
733
+ `<div class="accordion-item${entryClassSuffix(ctxOpts)}" data-entry-id="${inertShortcodes(escapeAttr(String(e.id ?? '')))}">\n` +
699
734
  ` <h3 class="accordion-header"><button class="accordion-button" type="button">${title}` +
700
735
  `<span class="accordion-icon" data-icon="chevron-down"></span></button></h3>\n` +
701
736
  ` <div class="accordion-body"><div class="accordion-content">${bodyHtml}</div></div>\n` +
@@ -704,7 +739,8 @@ function renderCollectionAccordion(entries, titleField, bodyField, multiple, emp
704
739
  }).join('\n');
705
740
 
706
741
  const ctx = buildCtxAttr([{name: titleField}, {name: bodyField}], entries, ctxOpts);
707
- return `<div class="dm-collection-display accordion"${multiAttr}${ctx}>\n${items}\n</div>`;
742
+ // No authored markup in this display - everything bracketed in it is entry data.
743
+ return inertShortcodes(`<div class="dm-collection-display accordion"${multiAttr}${ctx}>\n${items}\n</div>`);
708
744
  }
709
745
 
710
746
  /**
@@ -740,7 +776,7 @@ function renderCollectionTimeline(entries, opts, ctxOpts = {}) {
740
776
  const bodyHtml = bodyField
741
777
  ? marked.parse(String(e.data?.[bodyField] ?? ''))
742
778
  : '';
743
- return `<div class="dm-progression-item${entryClassSuffix(ctxOpts)}" 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>`;
779
+ return `<div class="dm-progression-item${entryClassSuffix(ctxOpts)}" data-entry-id="${inertShortcodes(escapeAttr(String(e.id ?? '')))}"${date}${status}${icon}><div class="dm-progression-item-title">${title}</div><div class="dm-progression-item-body">${bodyHtml}</div></div>`;
744
780
  }).join('\n');
745
781
 
746
782
  const ctx = buildCtxAttr(
@@ -748,7 +784,8 @@ function renderCollectionTimeline(entries, opts, ctxOpts = {}) {
748
784
  entries,
749
785
  ctxOpts
750
786
  );
751
- return `<div class="dm-collection-display dm-progression" data-layout="${layout}" data-theme="${theme}" data-mode="${mode}"${ctx}>\n${items}\n</div>`;
787
+ // No authored markup in this display - everything bracketed in it is entry data.
788
+ return inertShortcodes(`<div class="dm-collection-display dm-progression" data-layout="${layout}" data-theme="${theme}" data-mode="${mode}"${ctx}>\n${items}\n</div>`);
752
789
  }
753
790
 
754
791
  /**
@@ -799,9 +836,9 @@ function renderCollectionCarousel(entries, opts, ctxOpts = {}) {
799
836
  const bodyHtml = bodyField && e.data?.[bodyField] != null
800
837
  ? `<div class="carousel-slide-description">${marked.parse(String(e.data[bodyField]))}</div>`
801
838
  : '';
802
- inner = img + (title || bodyHtml ? `<div class="carousel-slide-content">${title}${bodyHtml}</div>` : '');
839
+ inner = inertShortcodes(img + (title || bodyHtml ? `<div class="carousel-slide-content">${title}${bodyHtml}</div>` : ''));
803
840
  }
804
- return `<div class="carousel-slide${entryClassSuffix(ctxOpts)}" data-entry-id="${escapeAttr(String(e.id ?? ''))}">${inner}</div>`;
841
+ return `<div class="carousel-slide${entryClassSuffix(ctxOpts)}" data-entry-id="${inertShortcodes(escapeAttr(String(e.id ?? '')))}">${inner}</div>`;
805
842
  }).join('\n');
806
843
 
807
844
  const ctxFields = blockTemplate
@@ -892,9 +929,9 @@ function renderCollectionListgroup(entries, opts, ctxOpts = {}) {
892
929
  const body = bodyField && e.data?.[bodyField] != null
893
930
  ? `${title ? ' ' : ''}${marked.parseInline(String(e.data[bodyField]))}`
894
931
  : '';
895
- inner = (title + body) || escapeHtmlText(String(e.id ?? ''));
932
+ inner = inertShortcodes((title + body) || escapeHtmlText(String(e.id ?? '')));
896
933
  }
897
- return `<div class="${itemClassStr}${entryClassSuffix(ctxOpts)}" data-entry-id="${escapeAttr(String(e.id ?? ''))}">${inner}</div>`;
934
+ return `<div class="${itemClassStr}${entryClassSuffix(ctxOpts)}" data-entry-id="${inertShortcodes(escapeAttr(String(e.id ?? '')))}">${inner}</div>`;
898
935
  }).join('\n');
899
936
 
900
937
  const ctxFields = blockTemplate
@@ -954,7 +991,7 @@ async function processViewBlocks(markdown, tagSet, user = null, flags = {}) {
954
991
  // browsing happens without the shortcode itself having to learn joins.
955
992
  if (isInteractiveCollection(attrs)) {
956
993
  const shell = await renderViewBrowserShell(attrs, {viewConfig, allowed, user});
957
- result = result.replace(fullMatch, shell);
994
+ result = result.replace(fullMatch, () => shell);
958
995
  continue;
959
996
  }
960
997
 
@@ -1097,7 +1134,7 @@ async function processViewBlocks(markdown, tagSet, user = null, flags = {}) {
1097
1134
  replacement = `<div class="dm-view-wrapper${cls}"${id}>${replacement}</div>`;
1098
1135
  }
1099
1136
 
1100
- result = result.replace(fullMatch, replacement);
1137
+ result = result.replace(fullMatch, () => replacement);
1101
1138
  }
1102
1139
 
1103
1140
  return restore(result);
@@ -1154,12 +1191,12 @@ async function processCollectionBlocks(markdown, tagSet) {
1154
1191
  // browser hydrates and replaces it).
1155
1192
  if (isInteractiveCollection(attrs)) {
1156
1193
  const shell = await renderCollectionBrowserShell(attrs);
1157
- result = result.replace(fullMatch, shell);
1194
+ result = result.replace(fullMatch, () => shell);
1158
1195
  continue;
1159
1196
  }
1160
1197
 
1161
1198
  const replacement = await renderCollectionFragment(attrs);
1162
- result = result.replace(fullMatch, replacement);
1199
+ result = result.replace(fullMatch, () => replacement);
1163
1200
  }
1164
1201
 
1165
1202
  return restore(result);