domma-cms 1.7.0 → 1.7.2

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/CLAUDE.md +30 -0
  3. package/admin/js/app.js +2 -2
  4. package/admin/js/lib/mail-compose.js +1 -0
  5. package/admin/js/lib/wordpress-import-arrange.js +1 -0
  6. package/admin/js/templates/call-new.html +1 -1
  7. package/admin/js/templates/docs/usage-tools.html +13 -1
  8. package/admin/js/templates/wordpress-import.html +223 -0
  9. package/admin/js/views/call-new.js +1 -1
  10. package/admin/js/views/contacts.js +15 -15
  11. package/admin/js/views/form-submissions.js +2 -2
  12. package/admin/js/views/index.js +1 -1
  13. package/admin/js/views/wordpress-import.js +38 -0
  14. package/package.json +1 -1
  15. package/plugins/_lib/admin/mail/compose-window.js +3 -1
  16. package/plugins/_lib/admin/mail/identity.js +18 -3
  17. package/plugins/_lib/admin/mail/reader-view.js +32 -3
  18. package/plugins/blog/CLAUDE.md +4 -0
  19. package/plugins/blog/admin/lib/email.js +191 -0
  20. package/plugins/blog/admin/views/comments.js +3 -1
  21. package/plugins/blog/plugin.json +2 -2
  22. package/plugins/blog/plugin.public.js +1 -1
  23. package/plugins/blog/tests/email.test.js +69 -0
  24. package/plugins/booking/CLAUDE.md +4 -0
  25. package/plugins/booking/admin/lib/email.js +191 -0
  26. package/plugins/booking/admin/views/panels.js +7 -3
  27. package/plugins/booking/plugin.json +1 -1
  28. package/plugins/booking/tests/email.test.js +69 -0
  29. package/plugins/free-tier.lock.json +27 -19
  30. package/plugins/shopping-cart/CLAUDE.md +4 -0
  31. package/plugins/shopping-cart/admin/lib/email.js +191 -0
  32. package/plugins/shopping-cart/admin/views/orders.js +11 -3
  33. package/plugins/shopping-cart/plugin.json +2 -2
  34. package/plugins/shopping-cart/tests/email.test.js +69 -0
  35. package/plugins/support/CLAUDE.md +4 -0
  36. package/plugins/support/admin/lib/email.js +191 -0
  37. package/plugins/support/admin/views/desk.js +4 -1
  38. package/plugins/support/admin/views/inbox.js +6 -2
  39. package/plugins/support/plugin.json +2 -2
  40. package/plugins/support/tests/email.test.js +69 -0
  41. package/server/routes/api/wordpress-import.js +220 -0
  42. package/server/server.js +12 -0
  43. package/server/services/hooks.js +1 -1
  44. package/server/services/permissionRegistry.js +10 -0
  45. package/server/services/roles.js +1 -1
  46. package/server/services/tools.js +2 -1
  47. package/server/services/wpImport/convert.js +491 -0
  48. package/server/services/wpImport/fetch.js +264 -0
  49. package/server/services/wpImport/index.js +832 -0
  50. package/server/services/wpImport/plan.js +549 -0
  51. package/server/services/wpImport/store.js +242 -0
  52. package/server/services/wpImport/wxr.js +167 -0
  53. package/server/services/wpImport/xml.js +261 -0
  54. package/server/services/wpImport/zip.js +142 -0
@@ -0,0 +1,491 @@
1
+ /**
2
+ * WordPress content -> a Domma page body.
3
+ *
4
+ * 1. Gutenberg block comments (and every other HTML comment) go.
5
+ * 2. Shortcodes with a Domma equivalent are converted - [caption] to a
6
+ * figure, [gallery] to a [grid] of images, [embed] / bare YouTube and Vimeo
7
+ * addresses / embed blocks / <video> to [embed], [audio] to a link (the
8
+ * site's sanitiser has no audio player). Every other shortcode is left as
9
+ * visible text - with its brackets escaped, so Domma never mistakes a
10
+ * WordPress [button] for its own - and listed as "needs rebuilding".
11
+ * 3. Classic-editor text gets its paragraphs (a small wpautop).
12
+ * 4. The HTML is sanitised against a short allow-list: no scripts, styles,
13
+ * forms, event handlers, iframes (other than the [embed]s made above).
14
+ * 5. Media addresses become /media/<file>, and links to the old site's
15
+ * pages and posts become their new addresses.
16
+ * 6. Clean HTML (paragraphs, headings, simple lists, links, images, quotes,
17
+ * code) is written as Markdown; anything richer (tables, nested lists,
18
+ * figures) stays as HTML in the Markdown body - the page renderer
19
+ * sanitises it again when the page is shown.
20
+ *
21
+ * Pure - the context supplies every lookup.
22
+ *
23
+ * @module services/wpImport/convert
24
+ */
25
+ import sanitizeHtml from 'sanitize-html';
26
+
27
+ /** Shortcodes this importer converts. */
28
+ export const CONVERTED_SHORTCODES = ['caption', 'wp_caption', 'gallery', 'embed', 'video', 'audio', 'playlist'];
29
+
30
+ /** Commonly seen WordPress shortcode names, recognised even with no attributes. */
31
+ const KNOWN_WP = /^(contact-form-7|contact-form|gravityform|wpforms|ninja_form|formidable|mc4wp_form|woocommerce_\w+|products?|product_\w+|add_to_cart|recent_products|shop_messages|et_pb_\w+|vc_\w+|fl_builder_insert_layout|elementor-template|rev_slider|layerslider|smartslider3|soliloquy|envira-gallery|foogallery|metaslider|tabs?|accordion|toggle|button|row|column|col|one_half\w*|one_third\w*|two_third\w*|one_fourth\w*|spacer|divider|icon|map|google_?map|instagram-feed|custom-twitter-feeds|wpdatatable|table|tablepress|ultimate_\w+|av_\w+|fusion_\w+|x_\w+|cs_\w+|su_\w+|pdf-embedder|fluentform|forminator_form|wp_ulike|social_\w+)$/i;
32
+
33
+ const PH_OPEN = '';
34
+ const PH_CLOSE = '';
35
+ const PH_RE = /(\d+)/g;
36
+
37
+ const VIDEO_HOST = /^(?:www\.|m\.)?(?:youtube\.com|youtu\.be|youtube-nocookie\.com|vimeo\.com|player\.vimeo\.com)$/i;
38
+
39
+ export const escapeAttr = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\[/g, '&#91;').replace(/\]/g, '&#93;');
40
+ export const escapeText = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\[/g, '&#91;').replace(/\]/g, '&#93;');
41
+
42
+ /** Attributes of a shortcode, as WordPress's shortcode_parse_atts() reads them. */
43
+ export function shortcodeAttrs(text) {
44
+ const out = {};
45
+ const re = /([\w-]+)\s*=\s*"([^"]*)"(?:\s|$)|([\w-]+)\s*=\s*'([^']*)'(?:\s|$)|([\w-]+)\s*=\s*([^\s'"]+)(?:\s|$)|"([^"]*)"(?:\s|$)|'([^']*)'(?:\s|$)|(\S+)(?:\s|$)/g;
46
+ const s = String(text || '').replace(/[ ​]/g, ' ').replace(/[“”″]/g, '"').replace(/[‘’′]/g, "'");
47
+ let m;
48
+ let n = 0;
49
+ while ((m = re.exec(s)) && n++ < 50) {
50
+ if (m[1]) out[m[1].toLowerCase()] = m[2];
51
+ else if (m[3]) out[m[3].toLowerCase()] = m[4];
52
+ else if (m[5]) out[m[5].toLowerCase()] = m[6];
53
+ else if (m[7] !== undefined) out[n] = m[7];
54
+ else if (m[8] !== undefined) out[n] = m[8];
55
+ else if (m[9]) out[n] = m[9];
56
+ }
57
+ return out;
58
+ }
59
+
60
+ /** Which page builder made this item, if any - its content will need rebuilding. */
61
+ export function detectBuilder(item) {
62
+ const meta = item?.meta || {};
63
+ const c = String(item?.content || '');
64
+ if (meta._elementor_data || meta._elementor_edit_mode === 'builder') return 'Elementor';
65
+ if (/\[et_pb_/.test(c) || meta._et_pb_use_builder === 'on') return 'Divi';
66
+ if (/\[vc_row/.test(c) || meta._wpb_vc_js_status === 'true') return 'WPBakery';
67
+ if (meta._fl_builder_enabled === '1') return 'Beaver Builder';
68
+ if (/\[fusion_builder/.test(c)) return 'Avada';
69
+ if (meta.panels_data) return 'SiteOrigin';
70
+ if (/<!-- wp:kadence\/|<!-- wp:generateblocks\//.test(c)) return '';
71
+ return '';
72
+ }
73
+
74
+ /**
75
+ * Shortcodes in a body that will NOT be converted - the "needs rebuilding" list.
76
+ *
77
+ * A bare word in brackets ("[sic]", "[1]") is not counted: only names with
78
+ * attributes, a closing tag, a "_" or "-", or a commonly seen shortcode name.
79
+ *
80
+ * @param {string} content
81
+ * @returns {string[]} names, sorted, without duplicates
82
+ */
83
+ export function findShortcodes(content) {
84
+ const s = stripComments(String(content || '')).replace(/\[\[[^\]]*\]\]/g, '');
85
+ const found = new Set();
86
+ const re = /\[([A-Za-z][\w-]{0,60})(\s[^\[\]]*)?\/?\]/g;
87
+ let m;
88
+ while ((m = re.exec(s))) {
89
+ const name = m[1];
90
+ if (CONVERTED_SHORTCODES.includes(name.toLowerCase())) continue;
91
+ const hasAttrs = Boolean(m[2] && m[2].trim() && m[2].trim() !== '/');
92
+ const closes = s.includes(`[/${name}]`);
93
+ if (hasAttrs || closes || /[_-]/.test(name) || KNOWN_WP.test(name)) found.add(name);
94
+ }
95
+ return [...found].sort();
96
+ }
97
+
98
+ /** <!-- ... --> out, Gutenberg's block delimiters among them. */
99
+ export function stripComments(html) {
100
+ return String(html || '').replace(/<!--[\s\S]*?-->/g, '');
101
+ }
102
+
103
+ const BLOCK_START = /^<(?:p|div|h[1-6]|ul|ol|li|dl|blockquote|pre|table|thead|tbody|tr|figure|hr|section|article|aside|header|footer|address|details|form|fieldset|nav|style|script|iframe|video|audio)\b/i;
104
+
105
+ /** WordPress's wpautop, simplified: blank-line-separated text becomes <p>, single newlines <br>. */
106
+ export function autop(html) {
107
+ const s = String(html || '').replace(/\r\n?/g, '\n');
108
+ if (!s.trim()) return '';
109
+ // Inside <pre> nothing changes.
110
+ const pres = [];
111
+ const held = s.replace(/<pre[\s\S]*?<\/pre>/gi, (m) => { pres.push(m); return `${pres.length - 1}`; });
112
+ const out = held.split(/\n\s*\n/).map(chunk => {
113
+ const t = chunk.trim();
114
+ if (!t) return '';
115
+ if (/^\d+$/.test(t) || BLOCK_START.test(t) || /^\d+$/.test(t)) return t;
116
+ return `<p>${t.replace(/\n/g, '<br>\n')}</p>`;
117
+ }).filter(Boolean).join('\n\n');
118
+ return out.replace(/(\d+)/g, (_m, n) => pres[Number(n)]);
119
+ }
120
+
121
+ const SANITISE = {
122
+ allowedTags: ['p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'b', 'em', 'i', 'u', 's', 'del', 'ins', 'sub', 'sup',
123
+ 'mark', 'small', 'blockquote', 'q', 'cite', 'code', 'pre', 'kbd', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'a', 'img',
124
+ 'figure', 'figcaption', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'hr', 'span', 'div', 'abbr'],
125
+ allowedAttributes: {
126
+ a: ['href', 'title', 'target', 'rel'],
127
+ img: ['src', 'alt', 'title', 'width', 'height'],
128
+ th: ['colspan', 'rowspan', 'scope'],
129
+ td: ['colspan', 'rowspan'],
130
+ ol: ['start'],
131
+ abbr: ['title']
132
+ },
133
+ allowedSchemes: ['http', 'https', 'mailto', 'tel'],
134
+ allowedSchemesAppliedToAttributes: ['href', 'src'],
135
+ allowProtocolRelative: false,
136
+ disallowedTagsMode: 'discard',
137
+ nonTextTags: ['script', 'style', 'textarea', 'option', 'noscript', 'iframe', 'object', 'embed', 'svg', 'math', 'form', 'button', 'select'],
138
+ transformTags: {
139
+ a: (tagName, attribs) => {
140
+ const out = {...attribs};
141
+ if (out.target === '_blank') out.rel = 'noopener';
142
+ else delete out.target;
143
+ return {tagName, attribs: out};
144
+ },
145
+ b: 'strong',
146
+ i: 'em'
147
+ }
148
+ };
149
+
150
+ /** The imported HTML through the allow-list. */
151
+ export function sanitise(html) {
152
+ return sanitizeHtml(String(html || ''), SANITISE);
153
+ }
154
+
155
+ /**
156
+ * Convert one item's content.
157
+ *
158
+ * @param {string} content - the post's content:encoded
159
+ * @param {object} ctx
160
+ * @param {(url: string) => string|null} ctx.mediaFor - an old media address -> /media/<file>, or null
161
+ * @param {(id: number) => {url: string, alt?: string, title?: string, mime?: string}|null} ctx.mediaById
162
+ * @param {(url: string) => string|null} ctx.linkFor - an old address -> the new one (or a local path), or null to leave it
163
+ * @param {(postId: number) => number[]} [ctx.attachmentsOf] - images attached to a post (a [gallery] without ids)
164
+ * @param {number} [ctx.postId]
165
+ * @returns {{body: string, format: 'markdown'|'html', shortcodes: string[], notes: string[]}}
166
+ */
167
+ export function convertContent(content, ctx = {}) {
168
+ const notes = [];
169
+ const note = (n) => { if (!notes.includes(n)) notes.push(n); };
170
+ const snippets = [];
171
+ const hold = (snippet) => { snippets.push(snippet); return `${PH_OPEN}${snippets.length - 1}${PH_CLOSE}`; };
172
+ const mediaFor = ctx.mediaFor || (() => null);
173
+ const linkFor = ctx.linkFor || (() => null);
174
+
175
+ const raw = String(content || '').replace(/\r\n?/g, '\n').replace(/[-]/g, '');
176
+ const gutenberg = /<!--\s*wp:/.test(raw);
177
+ let s = stripComments(raw);
178
+ const shortcodes = findShortcodes(s);
179
+
180
+ // [[name]] is WordPress's way of writing a literal [name].
181
+ s = s.replace(/\[\[([^\[\]]*)\]\]/g, (_m, inner) => hold(`&#91;${escapeText(inner)}&#93;`));
182
+
183
+ const embedFor = (url) => {
184
+ const u = String(url || '').trim().replace(/&amp;/g, '&');
185
+ if (!/^https?:\/\//i.test(u)) return '';
186
+ let host = '';
187
+ try { host = new URL(u).hostname; } catch { return ''; }
188
+ if (VIDEO_HOST.test(host)) return hold(`[embed url="${escapeAttr(u)}" /]`);
189
+ const local = mediaFor(u);
190
+ if (local && /\.(mp4|webm|ogv|ogg)$/i.test(local)) return hold(`[embed url="${escapeAttr(local)}" /]`);
191
+ note(`An embed from ${host} became a link - Domma embeds YouTube, Vimeo and the site's own videos.`);
192
+ return hold(`<p><a href="${escapeAttr(local || u)}">${escapeText(u)}</a></p>`);
193
+ };
194
+
195
+ // Gutenberg embed blocks: the address sits alone inside the wrapper.
196
+ s = s.replace(/<figure[^>]*class="[^"]*wp-block-embed[^"]*"[^>]*>\s*<div[^>]*>\s*(https?:\/\/[^\s<]+)\s*<\/div>([\s\S]*?)<\/figure>/gi,
197
+ (_m, url) => embedFor(url) || '');
198
+ // <iframe> pasted in: video hosts become [embed]; anything else goes (reported).
199
+ s = s.replace(/<iframe\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>[\s\S]*?<\/iframe>/gi, (_m, src) => {
200
+ let host = '';
201
+ try { host = new URL(src.replace(/&amp;/g, '&'), 'https://x.invalid').hostname; } catch { /* not a URL */ }
202
+ if (VIDEO_HOST.test(host)) return hold(`[embed url="${escapeAttr(src.replace(/&amp;/g, '&'))}" /]`);
203
+ note(`An embedded frame from ${host || 'another site'} was removed.`);
204
+ return '';
205
+ });
206
+ // <video> and <audio> tags (the block editor writes these).
207
+ s = s.replace(/<video\b[^>]*?\bsrc\s*=\s*["']([^"']+)["'][^>]*>[\s\S]*?<\/video>/gi, (_m, src) => videoSnippet(src));
208
+ s = s.replace(/<audio\b[^>]*?\bsrc\s*=\s*["']([^"']+)["'][^>]*>[\s\S]*?<\/audio>/gi, (_m, src) => audioSnippet(src));
209
+
210
+ function videoSnippet(src) {
211
+ const local = mediaFor(src);
212
+ if (local) return hold(`[embed url="${escapeAttr(local)}" /]`);
213
+ return embedFor(src) || '';
214
+ }
215
+ function audioSnippet(src) {
216
+ const local = mediaFor(src) || src;
217
+ if (!/^(https?:\/\/|\/)/i.test(local)) return '';
218
+ note('Audio players became links to the file - Domma pages have no audio player.');
219
+ const name = decodeURIComponent(String(local).split('/').pop() || 'audio');
220
+ return hold(`<p><a href="${escapeAttr(local)}">Listen: ${escapeText(name)}</a></p>`);
221
+ }
222
+
223
+ // [caption]<img ...> Text[/caption]
224
+ s = s.replace(/\[(caption|wp_caption)([^\]]*)\]([\s\S]*?)\[\/\1\]/gi, (_m, _n, attrText, inner) => {
225
+ const attrs = shortcodeAttrs(attrText);
226
+ const img = /<img\b[^>]*>/i.exec(inner);
227
+ if (!img) return inner;
228
+ const link = /<a\b[^>]*href\s*=\s*["']([^"']+)["'][^>]*>\s*<img/i.exec(inner);
229
+ const text = (attrs.caption || inner.replace(/<a\b[^>]*>[\s\S]*?<\/a>/i, '').replace(/<img\b[^>]*>/i, '').replace(/<[^>]+>/g, '')).trim();
230
+ const src = attrOf(img[0], 'src');
231
+ const alt = attrOf(img[0], 'alt');
232
+ const local = mediaFor(src) || src;
233
+ if (!/^(https?:\/\/|\/)/i.test(local)) return '';
234
+ let pic = `<img src="${escapeAttr(local)}" alt="${escapeAttr(alt)}">`;
235
+ if (link) {
236
+ const href = mediaFor(link[1]) || linkFor(link[1]) || link[1];
237
+ if (/^(https?:\/\/|\/)/i.test(href)) pic = `<a href="${escapeAttr(href)}">${pic}</a>`;
238
+ }
239
+ return hold(`<figure>${pic}${text ? `<figcaption>${escapeText(decodeBasic(text))}</figcaption>` : ''}</figure>`);
240
+ });
241
+
242
+ // [gallery ids="1,2,3" columns="3"]
243
+ s = s.replace(/\[gallery([^\]]*)\]/gi, (_m, attrText) => {
244
+ const attrs = shortcodeAttrs(attrText);
245
+ let ids = String(attrs.ids || attrs.include || '').split(',').map(x => Number.parseInt(x, 10)).filter(Number.isFinite);
246
+ if (!ids.length && ctx.attachmentsOf) ids = ctx.attachmentsOf(Number(attrs.id) || ctx.postId || 0);
247
+ const pics = ids.map(id => ctx.mediaById?.(id)).filter(Boolean).filter(m => /\.(jpe?g|png|gif|webp|avif|bmp)$/i.test(m.url));
248
+ if (!pics.length) { note('A gallery had no images that were imported.'); return ''; }
249
+ const cols = Math.min(6, Math.max(1, Number.parseInt(attrs.columns, 10) || 3));
250
+ const cells = pics.map(m => `[col]<img src="${escapeAttr(m.url)}" alt="${escapeAttr(m.alt || m.title || '')}">[/col]`).join('\n');
251
+ return hold(`[grid cols="${cols}" gap="2"]\n${cells}\n[/grid]`);
252
+ });
253
+
254
+ // [embed]url[/embed]
255
+ s = s.replace(/\[embed([^\]]*)\]([\s\S]*?)\[\/embed\]/gi, (_m, _a, url) => embedFor(url.replace(/<[^>]+>/g, '')) || '');
256
+ // [video mp4="..."][/video] / [video src="..."]
257
+ s = s.replace(/\[video([^\]]*)\](?:[\s\S]*?\[\/video\])?/gi, (_m, attrText) => {
258
+ const a = shortcodeAttrs(attrText);
259
+ const src = a.src || a.mp4 || a.webm || a.ogv || a.m4v || '';
260
+ return src ? videoSnippet(src) : '';
261
+ });
262
+ // [audio mp3="..."][/audio]
263
+ s = s.replace(/\[audio([^\]]*)\](?:[\s\S]*?\[\/audio\])?/gi, (_m, attrText) => {
264
+ const a = shortcodeAttrs(attrText);
265
+ const src = a.src || a.mp3 || a.ogg || a.wav || a.m4a || '';
266
+ return src ? audioSnippet(src) : '';
267
+ });
268
+ s = s.replace(/\[playlist[^\]]*\]/gi, () => { note('A media playlist was removed - rebuild it with [embed] or links.'); return ''; });
269
+ // A web address alone on a line - WordPress auto-embeds those.
270
+ s = s.replace(/^[ \t]*(https?:\/\/[^\s<>"]+)[ \t]*$/gm, (m, url) => {
271
+ try { if (VIDEO_HOST.test(new URL(url).hostname)) return embedFor(url); } catch { /* not a URL */ }
272
+ return m;
273
+ });
274
+
275
+ // A converted snippet alone on its line is a block of its own.
276
+ s = s.replace(/^[ \t]*(\uE000\d+\uE001)[ \t]*$/gm, '\n\n<p>$1</p>\n\n');
277
+ if (!gutenberg) s = autop(s);
278
+ s = sanitise(s);
279
+ s = rewriteAddresses(s, {mediaFor, linkFor});
280
+ // Every bracket left is text: escaped so no Domma shortcode can match it.
281
+ s = s.replace(/\[/g, '&#91;').replace(/\]/g, '&#93;');
282
+
283
+ const md = toMarkdown(s);
284
+ let body;
285
+ let format;
286
+ if (md !== null) {
287
+ body = md;
288
+ format = 'markdown';
289
+ } else {
290
+ body = htmlForMarkdown(s);
291
+ format = 'html';
292
+ }
293
+ body = body.replace(PH_RE, (_m, n) => snippets[Number(n)] ?? '').replace(/\n{3,}/g, '\n\n').trim();
294
+ return {body: body ? `${body}\n` : '', format, shortcodes, notes};
295
+ }
296
+
297
+ function attrOf(tag, name) {
298
+ const m = new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, 'i').exec(tag);
299
+ return m ? decodeBasic(m[2] ?? m[3] ?? '') : '';
300
+ }
301
+
302
+ function decodeBasic(s) {
303
+ return String(s ?? '').replace(/&#0*39;|&apos;/g, "'").replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
304
+ .replace(/&#8217;/g, '’').replace(/&#8216;/g, '‘').replace(/&#8220;/g, '“').replace(/&#8221;/g, '”')
305
+ .replace(/&#8211;/g, '–').replace(/&#8212;/g, '—').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&');
306
+ }
307
+
308
+ /** href/src in sanitised HTML -> the new addresses. */
309
+ export function rewriteAddresses(html, {mediaFor, linkFor}) {
310
+ return String(html).replace(/\s(href|src)="([^"]*)"/g, (m, attr, value) => {
311
+ const url = value.replace(/&amp;/g, '&');
312
+ const next = (attr === 'src' ? mediaFor(url) : (mediaFor(url) || linkFor(url)));
313
+ if (!next || next === url) return m;
314
+ return ` ${attr}="${next.replace(/&/g, '&amp;').replace(/"/g, '&quot;')}"`;
315
+ });
316
+ }
317
+
318
+ // ---- Clean HTML -> Markdown ----------------------------------------------
319
+
320
+ const VOID = new Set(['br', 'img', 'hr']);
321
+ const INLINE_OK = new Set(['strong', 'em', 'a', 'img', 'br', 'code', 'span']);
322
+
323
+ /** Tokenise sanitised HTML (well-formed, double-quoted attributes) into a tree. */
324
+ function tree(html) {
325
+ const root = {tag: '#root', attrs: {}, kids: []};
326
+ const stack = [root];
327
+ const re = /<(\/?)([a-z][a-z0-9]*)((?:\s+[a-z-]+="[^"]*")*)\s*\/?>/gi;
328
+ let at = 0;
329
+ let m;
330
+ while ((m = re.exec(html))) {
331
+ if (m.index > at) stack[stack.length - 1].kids.push({text: html.slice(at, m.index)});
332
+ at = re.lastIndex;
333
+ const tag = m[2].toLowerCase();
334
+ if (m[1]) {
335
+ // close: pop to the matching tag
336
+ for (let k = stack.length - 1; k > 0; k--) {
337
+ if (stack[k].tag === tag) { stack.length = k; break; }
338
+ }
339
+ continue;
340
+ }
341
+ const attrs = {};
342
+ for (const a of m[3].matchAll(/([a-z-]+)="([^"]*)"/gi)) attrs[a[1].toLowerCase()] = a[2];
343
+ const node = {tag, attrs, kids: []};
344
+ stack[stack.length - 1].kids.push(node);
345
+ if (!VOID.has(tag)) stack.push(node);
346
+ }
347
+ if (at < html.length) stack[stack.length - 1].kids.push({text: html.slice(at)});
348
+ return root;
349
+ }
350
+
351
+ const decodeHtml = (s) => String(s).replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#0*39;/g, "'")
352
+ .replace(/&#91;/g, '[').replace(/&#93;/g, ']').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&');
353
+
354
+ /** Plain text -> Markdown text: specials escaped; brackets and angle brackets as entities. */
355
+ function mdText(s) {
356
+ return decodeHtml(s).replace(/\s+/g, ' ')
357
+ .replace(/\\/g, '\\\\').replace(/([*_`])/g, '\\$1')
358
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
359
+ .replace(/\[/g, '&#91;').replace(/\]/g, '&#93;');
360
+ }
361
+
362
+ function mdUrl(u) {
363
+ // An encoded character in an address (&#106;avascript:) is left to the HTML path and its sanitiser.
364
+ if (/&#/.test(u)) throw new NotClean('encoded address');
365
+ const s = decodeHtml(u);
366
+ if (/^\s*[a-z][a-z0-9+.-]*:/i.test(s) && !/^\s*(https?|mailto|tel):/i.test(s)) throw new NotClean('scheme');
367
+ return s.replace(/ /g, '%20').replace(/\(/g, '%28').replace(/\)/g, '%29');
368
+ }
369
+
370
+ class NotClean extends Error {}
371
+
372
+ function inline(nodes) {
373
+ let out = '';
374
+ for (const n of nodes) {
375
+ if ('text' in n) { out += mdText(n.text); continue; }
376
+ if (!INLINE_OK.has(n.tag)) throw new NotClean(n.tag);
377
+ if (n.tag === 'br') { out += ' \n'; continue; }
378
+ if (n.tag === 'img') {
379
+ const src = n.attrs.src || '';
380
+ if (!src) continue;
381
+ out += `![${mdText(n.attrs.alt || '').trim()}](${mdUrl(src)})`;
382
+ continue;
383
+ }
384
+ const inner = inline(n.kids);
385
+ if (n.tag === 'span') out += inner;
386
+ else if (n.tag === 'strong') out += inner.trim() ? `**${inner.trim()}**` : inner;
387
+ else if (n.tag === 'em') out += inner.trim() ? `*${inner.trim()}*` : inner;
388
+ else if (n.tag === 'code') {
389
+ const t = decodeHtml(n.kids.map(k => ('text' in k ? k.text : '')).join(''));
390
+ if (t.includes('`') || n.kids.some(k => !('text' in k))) throw new NotClean('code');
391
+ out += `\`${t}\``;
392
+ } else if (n.tag === 'a') {
393
+ if (!n.attrs.href) { out += inner; continue; }
394
+ out += `[${inner.trim() || mdText(n.attrs.href)}](${mdUrl(n.attrs.href)})`;
395
+ }
396
+ }
397
+ return out;
398
+ }
399
+
400
+ /** A line that would read as Markdown syntax at its start is escaped. */
401
+ const guardLine = (line) => line.replace(/^(\s*)([#>+-]|=+|\d+\.)(\s|$)/, (_m, sp, mark, after) => `${sp}\\${mark}${after}`);
402
+
403
+ function blocks(nodes, depth = 0) {
404
+ const out = [];
405
+ let loose = [];
406
+ const flush = () => {
407
+ if (!loose.length) return;
408
+ const text = inline(loose).trim();
409
+ if (text) out.push(guardLine(text));
410
+ loose = [];
411
+ };
412
+ for (const n of nodes) {
413
+ if ('text' in n || INLINE_OK.has(n.tag)) { loose.push(n); continue; }
414
+ flush();
415
+ const t = n.tag;
416
+ if (t === 'p') {
417
+ const text = inline(n.kids).trim().split('\n').map(l => l.replace(/^\s+/, '')).join('\n');
418
+ if (text) out.push(text.split('\n').map((l, k) => (k ? l : guardLine(l))).join('\n'));
419
+ } else if (/^h[1-6]$/.test(t)) {
420
+ const text = inline(n.kids).replace(/\s*\n\s*/g, ' ').trim();
421
+ if (text) out.push(`${'#'.repeat(Number(t[1]))} ${text}`);
422
+ } else if (t === 'ul' || t === 'ol') {
423
+ if (depth > 0) throw new NotClean('nested list');
424
+ let k = Number(n.attrs.start) || 1;
425
+ const lines = [];
426
+ for (const li of n.kids) {
427
+ if ('text' in li) { if (li.text.trim()) throw new NotClean('text in list'); continue; }
428
+ if (li.tag !== 'li') throw new NotClean('list child');
429
+ const text = inline(li.kids).replace(/\s*\n\s*/g, ' ').trim();
430
+ lines.push(`${t === 'ol' ? `${k++}.` : '-'} ${text}`);
431
+ }
432
+ if (lines.length) out.push(lines.join('\n'));
433
+ } else if (t === 'blockquote') {
434
+ if (depth > 0) throw new NotClean('nested quote');
435
+ const inner = blocks(n.kids, depth + 1);
436
+ if (inner.trim()) out.push(inner.split('\n').map(l => (l ? `> ${l}` : '>')).join('\n'));
437
+ } else if (t === 'div') {
438
+ // Attribute-less after sanitising: only a wrapper (Gutenberg's groups and columns).
439
+ const inner = blocks(n.kids, depth);
440
+ if (inner.trim()) out.push(inner);
441
+ } else if (t === 'figure') {
442
+ const img = n.kids.find(k => k.tag === 'img') || n.kids.find(k => k.tag === 'a' && k.kids.some(x => x.tag === 'img'));
443
+ const cap = n.kids.find(k => k.tag === 'figcaption');
444
+ if (!img || n.kids.some(k => k !== img && k !== cap && !('text' in k && !k.text.trim()))) throw new NotClean('figure');
445
+ const pic = inline([img]).trim();
446
+ const text = cap ? inline(cap.kids).replace(/\s*\n\s*/g, ' ').trim() : '';
447
+ out.push(text ? `${pic} \n*${text}*` : pic);
448
+ } else if (t === 'hr') {
449
+ out.push('---');
450
+ } else if (t === 'pre') {
451
+ const kids = n.kids.length === 1 && n.kids[0].tag === 'code' ? n.kids[0].kids : n.kids;
452
+ if (kids.some(k => !('text' in k))) throw new NotClean('pre');
453
+ const code = decodeHtml(kids.map(k => k.text).join('')).replace(/^\n/, '').replace(/\s+$/, '');
454
+ if (code.includes('```')) throw new NotClean('pre');
455
+ out.push(`\`\`\`\n${code}\n\`\`\``);
456
+ } else {
457
+ throw new NotClean(t);
458
+ }
459
+ }
460
+ flush();
461
+ return out.join('\n\n');
462
+ }
463
+
464
+ /**
465
+ * Sanitised HTML as Markdown, or null when it is richer than Markdown can say
466
+ * (tables, figures, nested lists, spans, divs, …).
467
+ *
468
+ * @param {string} html
469
+ * @returns {string|null}
470
+ */
471
+ export function toMarkdown(html) {
472
+ try {
473
+ return blocks(tree(String(html || '')).kids);
474
+ } catch (err) {
475
+ if (err instanceof NotClean) return null;
476
+ throw err;
477
+ }
478
+ }
479
+
480
+ /**
481
+ * HTML kept in a Markdown body: no blank lines inside it (a blank line ends an
482
+ * HTML block in Markdown and turns what follows into Markdown - indented lines
483
+ * into code), newlines in <pre> kept as character references. Snippets held
484
+ * as placeholders go on lines of their own.
485
+ */
486
+ export function htmlForMarkdown(html) {
487
+ const s = String(html || '')
488
+ .replace(/<pre\b[\s\S]*?<\/pre>/gi, (m) => m.replace(/\n/g, '&#10;'))
489
+ .replace(/<p>\s*(\d+)\s*<\/p>/g, '\n\n$1\n\n');
490
+ return s.split(/\n\n/).map(part => part.split('\n').map(l => l.trim()).filter(Boolean).join('\n')).filter(Boolean).join('\n\n');
491
+ }