domma-cms 1.10.11 → 1.10.13

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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.10.13] - 2026-10-24
4
+
5
+ - **Built against domma-js 0.57.0** - see [its release notes](https://github.com/pinpointzero73/domma/releases/tag/v0.57.0).
6
+ - **Tab stays inside open slideovers, modals and dialogs** in the admin and on the site, and focus goes back where it was when they close. The page editor's Insert buttons still reach the editor behind their slideover.
7
+
8
+ ## [1.10.12] - 2026-10-24
9
+
10
+ - **Email with untidy HTML looks as it should.** The reader now reads a message with the same HTML5 rules a browser uses, so badly nested tables (a `<div>` between a table and its rows - British Gas, BT, Ocean Finance) keep their coloured panels instead of spilling out of them. One broken declaration in an inline style no longer throws the rest of that style away. Background pictures count as blocked images: the "Load images" notice now offers them and loads them, where before they were always stripped. Across a 15,600-message mailbox, text the sender's own design shows went missing in 16 messages, down from 3,481 before 1.10.8.
11
+
3
12
  ## [1.10.11] - 2026-10-24
4
13
 
5
14
  - Load admin screens and plugin views only when opened, and let the first screen render while navigation data loads. Sidebar badges refresh in the background, reuse recent account-scoped results and share one authenticated request instead of a request per Tool.
@@ -1,9 +1,9 @@
1
1
  /*!
2
- * Domma Tools CSS v0.56.0
2
+ * Domma Tools CSS v0.57.0
3
3
  * Dynamic Object Manipulation & Modeling API
4
4
  * (c) 2026 Darryl Waterhouse & DCBW-IT
5
- * Built: 2026-10-24T16:15:31.974Z
6
- * Commit: f768320
5
+ * Built: 2026-10-24T20:07:52.274Z
6
+ * Commit: 8ca8224
7
7
  */
8
8
 
9
9
  /*!
@@ -1,9 +1,9 @@
1
1
  /*!
2
- * Domma Tools v0.56.0
2
+ * Domma Tools v0.57.0
3
3
  * Developer tools: Theme Roller & Page Roller
4
4
  * (c) 2026 Darryl Waterhouse & DCBW-IT
5
- * Built: 2026-10-24T16:15:16.245Z
6
- * Commit: f768320
5
+ * Built: 2026-10-24T20:07:34.562Z
6
+ * Commit: 8ca8224
7
7
  *
8
8
  * Requires: domma.min.js
9
9
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.10.11",
3
+ "version": "1.10.13",
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",
@@ -86,7 +86,7 @@
86
86
  "bcryptjs": "^3.0.3",
87
87
  "css-select": "^7.0.0",
88
88
  "dom-serializer": "^3.1.1",
89
- "domma-js": "0.56.0",
89
+ "domma-js": "0.57.0",
90
90
  "dotenv": "^17.2.3",
91
91
  "fastify": "5.12.7",
92
92
  "gray-matter": "^4.0.3",
@@ -95,6 +95,8 @@
95
95
  "mailparser": "^3.12.2",
96
96
  "marked": "^15.0.0",
97
97
  "nodemailer": "10.2.0",
98
+ "parse5": "^8.0.1",
99
+ "parse5-htmlparser2-tree-adapter": "^8.0.1",
98
100
  "postcss": "^8.5.31",
99
101
  "sanitize-html": "^2.18.1",
100
102
  "sharp": "^0.35.5",
@@ -29,6 +29,8 @@ import {createHash} from 'node:crypto';
29
29
  import {selectAll} from 'css-select';
30
30
  import {render as renderDom} from 'dom-serializer';
31
31
  import {DomUtils, parseDocument} from 'htmlparser2';
32
+ import {parse as parseHtml5} from 'parse5';
33
+ import {adapter as domAdapter} from 'parse5-htmlparser2-tree-adapter';
32
34
  import postcss from 'postcss';
33
35
  import sanitizeHtml from 'sanitize-html';
34
36
 
@@ -65,20 +67,79 @@ function specificity(selector) {
65
67
  }
66
68
 
67
69
  /**
68
- * Drop the parts of a `background` shorthand that load something, keeping its
69
- * colour. `background:#d97757 url(x.png) no-repeat` becomes `#d97757 no-repeat`.
70
+ * An inline style reduced to well-formed `name:value` declarations, names in
71
+ * lower case. Splits on `;` outside quotes and brackets (a data: URI has one
72
+ * inside), and drops any piece with no name or no colon.
73
+ *
74
+ * `background` and `background-image` lose whatever would load, unless remote
75
+ * images are allowed, when a plain http(s) `url()` stays - so a design drawn
76
+ * on a background picture comes back with "Load images" like any other.
77
+ *
78
+ * @param {string} style
79
+ * @param {{allowRemoteImages: boolean, blocked: {count: number}}} options
80
+ * @returns {string}
81
+ */
82
+ function tidyStyle(style, options) {
83
+ const pieces = [];
84
+ let current = '', quote = '', depth = 0;
85
+ for (const ch of style) {
86
+ if (quote) {
87
+ if (ch === quote) quote = '';
88
+ } else if (ch === '"' || ch === "'") {
89
+ quote = ch;
90
+ } else if (ch === '(') {
91
+ depth += 1;
92
+ } else if (ch === ')') {
93
+ depth = Math.max(0, depth - 1);
94
+ } else if (ch === ';' && depth === 0) {
95
+ pieces.push(current);
96
+ current = '';
97
+ continue;
98
+ }
99
+ current += ch;
100
+ }
101
+ pieces.push(current);
102
+ return pieces
103
+ .map((piece) => piece.match(/^\s*([a-zA-Z-][\w-]*)\s*:([\s\S]*)$/))
104
+ .filter((match) => match && match[2].trim())
105
+ .map((match) => {
106
+ const prop = match[1].toLowerCase();
107
+ let value = match[2].trim();
108
+ if (prop === 'background' || prop === 'background-image') {
109
+ value = backgroundValue(value, options);
110
+ }
111
+ return value ? `${prop}:${value}` : '';
112
+ })
113
+ .filter(Boolean)
114
+ .join(';');
115
+ }
116
+
117
+ /** A `url()` the reader may load once remote images are allowed: plain http(s), nothing nested. */
118
+ const REMOTE_URL = /^url\(\s*(['"]?)https?:\/\/[^'"()\s\\]+\1\s*\)$/i;
119
+
120
+ /**
121
+ * A background value with the parts that load something cut out, keeping its
122
+ * colour: `#d97757 url(x.png) no-repeat` becomes `#d97757 no-repeat`. With
123
+ * remote images allowed, a plain http(s) `url()` is kept instead. Each one cut
124
+ * counts as a blocked image, so the "Load images" notice offers it.
70
125
  *
71
126
  * @param {string} value
127
+ * @param {{allowRemoteImages: boolean, blocked: {count: number}}} options
72
128
  * @returns {string}
73
129
  */
74
- function stripBackgroundLoads(value) {
130
+ function backgroundValue(value, options) {
75
131
  if (value.includes('\\')) return '';
76
- return value.replace(/\b(url|image|image-set|element|cross-fade|expression)\s*\((?:[^()]|\([^()]*\))*\)/gi, '').trim();
132
+ return value.replace(/\b(url|image|image-set|element|cross-fade|expression)\s*\((?:[^()]|\([^()]*\))*\)/gi, (loader) => {
133
+ if (options.allowRemoteImages && REMOTE_URL.test(loader)) return loader;
134
+ if (/^url/i.test(loader)) options.blocked.count += 1;
135
+ return '';
136
+ }).replace(/\s{2,}/g, ' ').trim();
77
137
  }
78
138
 
79
139
  /**
80
- * Move a message's `<style>` rules onto the elements they match, and tidy the
81
- * inline styles the sanitiser is about to judge.
140
+ * Repair a message's markup the way a browser would, move its `<style>` rules
141
+ * onto the elements they match, and tidy the inline styles the sanitiser is
142
+ * about to judge.
82
143
  *
83
144
  * Mail clients have done this for years because so much mail needs it: a
84
145
  * button is often `<a class="button">` with its colours in the head, plus a
@@ -92,14 +153,23 @@ function stripBackgroundLoads(value) {
92
153
  * sanitiser's allowlist afterwards.
93
154
  *
94
155
  * @param {string} html
95
- * @returns {string}
156
+ * @param {{allowRemoteImages: boolean}} options
157
+ * @returns {{html: string, blockedBackgrounds: number}}
96
158
  */
97
- function inlineStyles(html) {
159
+ function inlineStyles(html, {allowRemoteImages}) {
160
+ const blocked = {count: 0};
98
161
  let dom;
99
162
  try {
100
- dom = parseDocument(html);
163
+ // The browser's own HTML5 parser, not htmlparser2's. A lot of mail is
164
+ // malformed - a <div> between a <table> and its rows - and browsers
165
+ // repair it by moving the stray element out. htmlparser2 keeps it
166
+ // where it was, the sanitiser then drops the table tags around it,
167
+ // and the coloured cells a design is built from end up outside any
168
+ // table. Repaired here, the sanitiser reads the same tree a browser
169
+ // would have drawn.
170
+ dom = parseHtml5(html, {treeAdapter: domAdapter});
101
171
  } catch {
102
- return html;
172
+ return {html, blockedBackgrounds: 0};
103
173
  }
104
174
 
105
175
  const pending = new Map();
@@ -176,32 +246,24 @@ function inlineStyles(html) {
176
246
  DomUtils.appendChild(body, div);
177
247
  }
178
248
 
179
- // CSS property names are case-insensitive, the sanitiser's allowlist is
180
- // not: `COLOR: #000000` (Outlook-era templates) was dropped, and a sheet
181
- // rule we had just inlined then won instead of it.
182
- const shouted = DomUtils.findAll((node) => /(^|;)\s*[a-z-]*[A-Z][\w-]*\s*:/.test(node.attribs?.style ?? ''), dom.children);
183
- for (const element of shouted) {
184
- element.attribs.style = element.attribs.style.replace(
185
- /(^|;)(\s*)([\w-]+)(\s*:)/g,
186
- (match, lead, space, prop, colon) => `${lead}${space}${prop.toLowerCase()}${colon}`
187
- );
249
+ // Read every inline style the way a browser does before the sanitiser
250
+ // judges it. Property names are case-insensitive but its allowlist is not
251
+ // (`COLOR: #000000` was dropped, and an inlined sheet colour won), and one
252
+ // broken declaration (`color:#fff; centre; ...`) made it throw the whole
253
+ // attribute away, where a browser skips just that one.
254
+ // The legacy `background="picture.jpg"` attribute becomes the style it
255
+ // means, so it is judged - and offered by "Load images" - the same way.
256
+ for (const element of DomUtils.findAll((node) => Boolean(node.attribs?.background), dom.children)) {
257
+ const src = element.attribs.background.trim();
258
+ delete element.attribs.background;
259
+ if (!src) continue;
260
+ element.attribs.style = [`background-image:url("${src.replace(/"/g, '')}")`, element.attribs.style ?? ''].filter(Boolean).join(';');
188
261
  }
189
-
190
- // A background shorthand loses only what would load; its colour stays.
191
- const backgrounds = DomUtils.findAll((node) => /background\s*:/i.test(node.attribs?.style ?? ''), dom.children);
192
- for (const element of backgrounds) {
193
- element.attribs.style = element.attribs.style.replace(
194
- /(^|;)(\s*background\s*:)([^;]*)/gi,
195
- (match, lead, prop, value) => {
196
- const kept = stripBackgroundLoads(value);
197
- return kept ? `${lead}${prop}${kept}` : lead;
198
- }
199
- );
262
+ for (const element of DomUtils.findAll((node) => Boolean(node.attribs?.style), dom.children)) {
263
+ element.attribs.style = tidyStyle(element.attribs.style, {allowRemoteImages, blocked});
200
264
  }
201
265
 
202
- // Untouched mail goes to the sanitiser exactly as it arrived.
203
- if (!pending.size && !bodyStyle && !shouted.length && !backgrounds.length) return html;
204
- return renderDom(dom);
266
+ return {html: renderDom(dom), blockedBackgrounds: blocked.count};
205
267
  }
206
268
 
207
269
  /**
@@ -234,9 +296,16 @@ function inlineImageMap(attachments, maxBytes) {
234
296
  * @returns {{html: string, blockedImages: number}}
235
297
  */
236
298
  function sanitiseBody(html, options) {
237
- let blockedImages = 0;
238
-
239
- const clean = sanitizeHtml(inlineStyles(html), {
299
+ const {html: repaired, blockedBackgrounds} = inlineStyles(html, {allowRemoteImages: options.allowRemoteImages});
300
+ let blockedImages = blockedBackgrounds;
301
+ // inlineStyles() has already cut out anything that loads, bar the plain
302
+ // http(s) url() it keeps when remote images are allowed; this refuses
303
+ // whatever got past it.
304
+ const background = options.allowRemoteImages
305
+ ? /^(?![\s\S]*(\\|\b(image|image-set|element|cross-fade|expression)\s*\(|\burl\s*\(\s*(?!['"]?https?:\/\/)))[\s\S]*$/i
306
+ : /^(?![\s\S]*(\\|\b(url|image|image-set|element|cross-fade|expression)\s*\())[\s\S]*$/i;
307
+
308
+ const clean = sanitizeHtml(repaired, {
240
309
  allowedTags: ALLOWED_TAGS,
241
310
  allowedAttributes: {
242
311
  // target/rel are added by transformTags below, and attribute
@@ -262,9 +331,8 @@ function sanitiseBody(html, options) {
262
331
  'color': [/^.*$/],
263
332
  'background-color': [/^.*$/],
264
333
  // The shorthand is how a lot of mail colours its buttons.
265
- // inlineStyles() has already cut out anything that loads; this
266
- // refuses whatever got past it.
267
- 'background': [/^(?![\s\S]*(\\|\b(url|image|image-set|element|cross-fade|expression)\s*\())[\s\S]*$/i],
334
+ 'background': [background],
335
+ ...(options.allowRemoteImages ? {'background-image': [background]} : {}),
268
336
  'border-radius': [/^.*$/],
269
337
  'border-top': [/^.*$/],
270
338
  'border-right': [/^.*$/],