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.
|
|
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-
|
|
6
|
-
* Commit:
|
|
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.
|
|
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-
|
|
6
|
-
* Commit:
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
69
|
-
*
|
|
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
|
|
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,
|
|
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
|
-
*
|
|
81
|
-
* inline styles the sanitiser is
|
|
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
|
-
* @
|
|
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
|
-
|
|
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
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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': [/^.*$/],
|