autumnnote 1.0.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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +874 -0
  3. package/dist/autumnnote.css +1 -0
  4. package/dist/autumnnote.es.js +5888 -0
  5. package/dist/autumnnote.es.js.map +1 -0
  6. package/dist/autumnnote.umd.js +74 -0
  7. package/dist/autumnnote.umd.js.map +1 -0
  8. package/package.json +55 -0
  9. package/src/js/Context.js +497 -0
  10. package/src/js/core/dom.js +315 -0
  11. package/src/js/core/env.js +25 -0
  12. package/src/js/core/func.js +153 -0
  13. package/src/js/core/key.js +66 -0
  14. package/src/js/core/lists.js +121 -0
  15. package/src/js/core/markdown.js +294 -0
  16. package/src/js/core/range.js +194 -0
  17. package/src/js/core/sanitise.js +78 -0
  18. package/src/js/editing/History.js +205 -0
  19. package/src/js/editing/Style.js +329 -0
  20. package/src/js/editing/Table.js +59 -0
  21. package/src/js/editing/Typing.js +142 -0
  22. package/src/js/index.js +126 -0
  23. package/src/js/module/Buttons.js +300 -0
  24. package/src/js/module/Clipboard.js +460 -0
  25. package/src/js/module/CodeTooltip.js +428 -0
  26. package/src/js/module/Codeview.js +122 -0
  27. package/src/js/module/ContextMenu.js +470 -0
  28. package/src/js/module/Editor.js +528 -0
  29. package/src/js/module/EmojiDialog.js +726 -0
  30. package/src/js/module/FindReplace.js +440 -0
  31. package/src/js/module/Fullscreen.js +80 -0
  32. package/src/js/module/IconDialog.js +620 -0
  33. package/src/js/module/ImageDialog.js +208 -0
  34. package/src/js/module/ImageResizer.js +216 -0
  35. package/src/js/module/ImageTooltip.js +286 -0
  36. package/src/js/module/LinkDialog.js +204 -0
  37. package/src/js/module/LinkTooltip.js +242 -0
  38. package/src/js/module/Placeholder.js +44 -0
  39. package/src/js/module/ShortcutsDialog.js +141 -0
  40. package/src/js/module/Statusbar.js +238 -0
  41. package/src/js/module/TableTooltip.js +568 -0
  42. package/src/js/module/Toolbar.js +562 -0
  43. package/src/js/module/VideoDialog.js +263 -0
  44. package/src/js/module/VideoResizer.js +227 -0
  45. package/src/js/module/VideoTooltip.js +252 -0
  46. package/src/js/renderer.js +107 -0
  47. package/src/js/settings.js +134 -0
  48. package/src/styles/_variables.scss +48 -0
  49. package/src/styles/autumnnote.scss +1740 -0
  50. package/types/index.d.ts +324 -0
@@ -0,0 +1,294 @@
1
+ /**
2
+ * markdown.js - Lightweight Markdown → HTML converter for paste handling.
3
+ *
4
+ * Handles: headings H1–H6, bold/italic/strikethrough/inline-code, fenced code
5
+ * blocks (with language), blockquotes, unordered/ordered lists, horizontal
6
+ * rules, links, images, and plain paragraphs.
7
+ *
8
+ * The HTML output MUST be passed through sanitiseHTML() before insertion.
9
+ */
10
+
11
+ /**
12
+ * Converts an HTML string to Markdown.
13
+ * Handles: headings, paragraphs, bold/italic/del/code, links, images,
14
+ * unordered/ordered lists, blockquote, pre/code blocks, tables, hr.
15
+ * @param {string} html
16
+ * @returns {string}
17
+ */
18
+ export function htmlToMarkdown(html) {
19
+ const doc = new DOMParser().parseFromString(`<body>${html || ''}</body>`, 'text/html');
20
+ return _domToMd(doc.body).replace(/\n{3,}/g, '\n\n').trim();
21
+ }
22
+
23
+ function _domToMd(node) {
24
+ if (node.nodeType === 3) {
25
+ return node.textContent.replace(/\s+/g, ' ');
26
+ }
27
+ if (node.nodeType !== 1) return '';
28
+
29
+ const tag = node.nodeName.toLowerCase();
30
+ const inner = () => Array.from(node.childNodes).map(_domToMd).join('');
31
+
32
+ switch (tag) {
33
+ case 'p':
34
+ case 'div': return `\n\n${inner()}\n\n`;
35
+ case 'br': return ' \n';
36
+ case 'h1': return `\n\n# ${inner()}\n\n`;
37
+ case 'h2': return `\n\n## ${inner()}\n\n`;
38
+ case 'h3': return `\n\n### ${inner()}\n\n`;
39
+ case 'h4': return `\n\n#### ${inner()}\n\n`;
40
+ case 'h5': return `\n\n##### ${inner()}\n\n`;
41
+ case 'h6': return `\n\n###### ${inner()}\n\n`;
42
+ case 'strong':
43
+ case 'b': return `**${inner()}**`;
44
+ case 'em':
45
+ case 'i': return `*${inner()}*`;
46
+ case 'del':
47
+ case 's':
48
+ case 'strike': return `~~${inner()}~~`;
49
+ case 'sup': return `^${inner()}`;
50
+ case 'sub': return `~${inner()}`;
51
+ case 'code': {
52
+ // Inside <pre> we emit raw text; outside we wrap in backticks
53
+ if (node.closest('pre')) return inner();
54
+ return `\`${inner()}\``;
55
+ }
56
+ case 'pre': {
57
+ const codeEl = node.querySelector('code');
58
+ const langMatch = ((codeEl && codeEl.className) || '').match(/language-(\S+)/);
59
+ const lang = langMatch ? langMatch[1] : '';
60
+ const content = (codeEl || node).textContent || '';
61
+ return `\n\n\`\`\`${lang}\n${content}\n\`\`\`\n\n`;
62
+ }
63
+ case 'blockquote': {
64
+ const lines = inner().trim().split('\n');
65
+ return `\n\n${lines.map((l) => `> ${l}`).join('\n')}\n\n`;
66
+ }
67
+ case 'a': {
68
+ const href = node.getAttribute('href') || '';
69
+ return `[${inner()}](${href})`;
70
+ }
71
+ case 'img': {
72
+ const src = node.getAttribute('src') || '';
73
+ const alt = node.getAttribute('alt') || '';
74
+ return `![${alt}](${src})`;
75
+ }
76
+ case 'ul': {
77
+ const items = Array.from(node.querySelectorAll(':scope > li'));
78
+ if (!items.length) return inner();
79
+ return `\n\n${items.map((li) => `- ${_domToMd(li).trim()}`).join('\n')}\n\n`;
80
+ }
81
+ case 'ol': {
82
+ const items = Array.from(node.querySelectorAll(':scope > li'));
83
+ if (!items.length) return inner();
84
+ return `\n\n${items.map((li, i) => `${i + 1}. ${_domToMd(li).trim()}`).join('\n')}\n\n`;
85
+ }
86
+ case 'li': return inner();
87
+ case 'hr': return '\n\n---\n\n';
88
+ case 'table': {
89
+ const rows = Array.from(node.querySelectorAll('tr'));
90
+ if (!rows.length) return inner();
91
+ const cellTexts = rows.map((tr) =>
92
+ Array.from(tr.querySelectorAll('th, td')).map((c) => c.textContent.trim().replace(/\|/g, '\\|')),
93
+ );
94
+ const cols = Math.max(...cellTexts.map((r) => r.length));
95
+ const padRow = (row) => { const r = [...row]; while (r.length < cols) r.push(''); return r; };
96
+ let md = '\n\n';
97
+ md += `| ${padRow(cellTexts[0]).join(' | ')} |\n`;
98
+ md += `| ${Array(cols).fill('---').join(' | ')} |\n`;
99
+ for (let r = 1; r < cellTexts.length; r++) {
100
+ md += `| ${padRow(cellTexts[r]).join(' | ')} |\n`;
101
+ }
102
+ return md + '\n';
103
+ }
104
+ default: return inner();
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Returns true if the text contains recognisable Markdown patterns.
110
+ * @param {string} text
111
+ * @returns {boolean}
112
+ */
113
+ export function isMarkdown(text) {
114
+ return /^#{1,6} \S|^\s*[-*+] \S|^\s*\d+\. \S|^> \S|\*{2}.+?\*{2}|^```/m.test(text);
115
+ }
116
+
117
+ /**
118
+ * Converts a Markdown string to an HTML string.
119
+ * @param {string} text
120
+ * @returns {string}
121
+ */
122
+ export function markdownToHTML(text) {
123
+ const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
124
+ const out = [];
125
+ let i = 0;
126
+
127
+ while (i < lines.length) {
128
+ const line = lines[i];
129
+
130
+ // ---- Fenced code block ``` lang ... ``` -----------------------------------
131
+ const fenceMatch = line.match(/^```(\S*)$/);
132
+ if (fenceMatch) {
133
+ const lang = fenceMatch[1];
134
+ const codeLines = [];
135
+ i++;
136
+ while (i < lines.length && !lines[i].startsWith('```')) {
137
+ codeLines.push(_esc(lines[i]));
138
+ i++;
139
+ }
140
+ const langAttr = lang ? ` class="language-${_escAttr(lang)}"` : '';
141
+ out.push(`<pre><code${langAttr}>${codeLines.join('\n')}</code></pre>`);
142
+ i++; // skip closing ```
143
+ continue;
144
+ }
145
+
146
+ // ---- Horizontal rule --- / *** / _________________________________________
147
+ if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
148
+ out.push('<hr>');
149
+ i++;
150
+ continue;
151
+ }
152
+
153
+ // ---- ATX Headings # – ###### -------------------------------------------
154
+ const hMatch = line.match(/^(#{1,6})\s+(.+)$/);
155
+ if (hMatch) {
156
+ const level = hMatch[1].length;
157
+ out.push(`<h${level}>${_inline(hMatch[2])}</h${level}>`);
158
+ i++;
159
+ continue;
160
+ }
161
+
162
+ // ---- Blockquote > text --------------------------------------------------
163
+ if (line.startsWith('> ')) {
164
+ const bqLines = [];
165
+ while (i < lines.length && lines[i].startsWith('> ')) {
166
+ bqLines.push(lines[i].slice(2));
167
+ i++;
168
+ }
169
+ out.push(`<blockquote>${bqLines.map(_inline).join('<br>')}</blockquote>`);
170
+ continue;
171
+ }
172
+
173
+ // ---- Unordered list - / * / + item ------------------------------------
174
+ if (/^[-*+] /.test(line)) {
175
+ const items = [];
176
+ while (i < lines.length && /^[-*+] /.test(lines[i])) {
177
+ items.push(`<li>${_inline(lines[i].slice(2))}</li>`);
178
+ i++;
179
+ }
180
+ out.push(`<ul>${items.join('')}</ul>`);
181
+ continue;
182
+ }
183
+
184
+ // ---- Ordered list 1. item ----------------------------------------------
185
+ if (/^\d+\. /.test(line)) {
186
+ const items = [];
187
+ while (i < lines.length && /^\d+\. /.test(lines[i])) {
188
+ items.push(`<li>${_inline(lines[i].replace(/^\d+\. /, ''))}</li>`);
189
+ i++;
190
+ }
191
+ out.push(`<ol>${items.join('')}</ol>`);
192
+ continue;
193
+ }
194
+
195
+ // ---- Blank line ----------------------------------------------------------
196
+ if (line.trim() === '') {
197
+ i++;
198
+ continue;
199
+ }
200
+
201
+ // ---- GFM Table | col | col | -------------------------------------------
202
+ // A table starts with a pipe-prefixed or pipe-containing line followed by
203
+ // a separator row (| --- | --- |). We detect and collect all rows.
204
+ if (/^\|.+\|/.test(line) && i + 1 < lines.length && /^\|[\s|:-]+\|/.test(lines[i + 1])) {
205
+ const headerCells = _parseTableRow(line);
206
+ i += 2; // skip header + separator
207
+ const bodyRows = [];
208
+ while (i < lines.length && /^\|.+\|/.test(lines[i])) {
209
+ bodyRows.push(_parseTableRow(lines[i]));
210
+ i++;
211
+ }
212
+ const thead = `<thead><tr>${headerCells.map((c) => `<th>${_inline(c)}</th>`).join('')}</tr></thead>`;
213
+ const tbody = bodyRows.length
214
+ ? `<tbody>${bodyRows.map((row) => `<tr>${row.map((c) => `<td>${_inline(c)}</td>`).join('')}</tr>`).join('')}</tbody>`
215
+ : '';
216
+ out.push(`<table>${thead}${tbody}</table>`);
217
+ continue;
218
+ }
219
+
220
+ // ---- Paragraph: collect consecutive non-block lines ---------------------
221
+ const paraLines = [];
222
+ while (
223
+ i < lines.length &&
224
+ lines[i].trim() !== '' &&
225
+ !/^(#{1,6} |> |[-*+] |\d+\. |```|---\s*$|\*{3}\s*$|_{3}\s*$)/.test(lines[i]) &&
226
+ !/^\|.+\|/.test(lines[i])
227
+ ) {
228
+ paraLines.push(lines[i]);
229
+ i++;
230
+ }
231
+ if (paraLines.length) {
232
+ out.push(`<p>${_inline(paraLines.join(' '))}</p>`);
233
+ }
234
+ }
235
+
236
+ return out.join('');
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Inline formatting
241
+ // ---------------------------------------------------------------------------
242
+
243
+ /**
244
+ * Splits a GFM table row string into trimmed cell strings.
245
+ * '| a | b | c |' → ['a', 'b', 'c']
246
+ * @param {string} row
247
+ * @returns {string[]}
248
+ */
249
+ function _parseTableRow(row) {
250
+ return row
251
+ .replace(/^\|/, '')
252
+ .replace(/\|$/, '')
253
+ .split('|')
254
+ .map((c) => c.trim());
255
+ }
256
+
257
+ function _inline(text) {
258
+ // Images before links (they share [] syntax)
259
+ text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) =>
260
+ `<img src="${_escAttr(src)}" alt="${_escAttr(alt)}" class="an-image">`);
261
+ // Links
262
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) =>
263
+ `<a href="${_escAttr(href)}">${_esc(label)}</a>`);
264
+ // Bold + italic ***text***
265
+ text = text.replace(/\*{3}(.+?)\*{3}/g, (_, c) => `<strong><em>${_esc(c)}</em></strong>`);
266
+ text = text.replace(/_{3}(.+?)_{3}/g, (_, c) => `<strong><em>${_esc(c)}</em></strong>`);
267
+ // Bold **text**
268
+ text = text.replace(/\*{2}(.+?)\*{2}/g, (_, c) => `<strong>${_esc(c)}</strong>`);
269
+ text = text.replace(/_{2}(.+?)_{2}/g, (_, c) => `<strong>${_esc(c)}</strong>`);
270
+ // Italic *text* _text_
271
+ text = text.replace(/\*([^*\n]+?)\*/g, (_, c) => `<em>${_esc(c)}</em>`);
272
+ text = text.replace(/_([^_\n]+?)_/g, (_, c) => `<em>${_esc(c)}</em>`);
273
+ // Strikethrough ~~text~~
274
+ text = text.replace(/~~(.+?)~~/g, (_, c) => `<del>${_esc(c)}</del>`);
275
+ // Inline code `code`
276
+ text = text.replace(/`([^`]+)`/g, (_, c) => `<code>${_esc(c)}</code>`);
277
+ return text;
278
+ }
279
+
280
+ function _esc(v) {
281
+ return String(v)
282
+ .replace(/&/g, '&amp;')
283
+ .replace(/</g, '&lt;')
284
+ .replace(/>/g, '&gt;');
285
+ }
286
+
287
+ function _escAttr(v) {
288
+ return String(v)
289
+ .replace(/&/g, '&amp;')
290
+ .replace(/"/g, '&quot;')
291
+ .replace(/'/g, '&#39;')
292
+ .replace(/</g, '&lt;')
293
+ .replace(/>/g, '&gt;');
294
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * range.js - Selection and Range utilities
3
+ * Inspired by Summernote's range.js — rewritten as vanilla JS
4
+ */
5
+
6
+ import { isElement, closest } from './dom.js';
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // WrappedRange — a convenience wrapper over the native Range API
10
+ // ---------------------------------------------------------------------------
11
+
12
+ export class WrappedRange {
13
+ /**
14
+ * @param {Node} sc - start container
15
+ * @param {number} so - start offset
16
+ * @param {Node} ec - end container
17
+ * @param {number} eo - end offset
18
+ */
19
+ constructor(sc, so, ec, eo) {
20
+ this.sc = sc;
21
+ this.so = so;
22
+ this.ec = ec;
23
+ this.eo = eo;
24
+ }
25
+
26
+ /** @returns {boolean} */
27
+ isCollapsed() {
28
+ return this.sc === this.ec && this.so === this.eo;
29
+ }
30
+
31
+ /** @returns {Range} */
32
+ toNativeRange() {
33
+ const range = document.createRange();
34
+ try {
35
+ range.setStart(this.sc, this.so);
36
+ range.setEnd(this.ec, this.eo);
37
+ } catch (_e) {
38
+ // Guard against detached nodes
39
+ }
40
+ return range;
41
+ }
42
+
43
+ /**
44
+ * Select this wrapped range in the window.
45
+ */
46
+ select() {
47
+ const sel = window.getSelection();
48
+ if (!sel) return;
49
+ sel.removeAllRanges();
50
+ sel.addRange(this.toNativeRange());
51
+ }
52
+
53
+ /**
54
+ * Returns the common ancestor element of this range.
55
+ * @returns {Element|null}
56
+ */
57
+ commonAncestor() {
58
+ const native = this.toNativeRange();
59
+ const ancestor = native.commonAncestorContainer;
60
+ return isElement(ancestor) ? ancestor : ancestor.parentElement;
61
+ }
62
+
63
+ /**
64
+ * Returns the nearest paragraph/block ancestor within the editable area.
65
+ * @param {HTMLElement} editable
66
+ * @returns {Element|null}
67
+ */
68
+ blockNode(editable) {
69
+ return closest(this.sc, (n) => isElement(n) && n !== editable, editable);
70
+ }
71
+
72
+ /**
73
+ * Returns either the selected text string or empty string.
74
+ * @returns {string}
75
+ */
76
+ toString() {
77
+ return this.toNativeRange().toString();
78
+ }
79
+
80
+ /**
81
+ * Returns the bounding DOMRect of the range (or null).
82
+ * @returns {DOMRect|null}
83
+ */
84
+ getClientRects() {
85
+ const rects = this.toNativeRange().getClientRects();
86
+ return rects.length > 0 ? rects[rects.length - 1] : null;
87
+ }
88
+
89
+ /**
90
+ * Inserts a node at the start of this range.
91
+ * @param {Node} node
92
+ */
93
+ insertNode(node) {
94
+ const native = this.toNativeRange();
95
+ native.insertNode(node);
96
+ }
97
+
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Factory helpers
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /**
105
+ * Creates a WrappedRange from a native Range object.
106
+ * @param {Range} range
107
+ * @returns {WrappedRange}
108
+ */
109
+ export function fromNativeRange(range) {
110
+ return new WrappedRange(
111
+ range.startContainer,
112
+ range.startOffset,
113
+ range.endContainer,
114
+ range.endOffset,
115
+ );
116
+ }
117
+
118
+ /**
119
+ * Returns a WrappedRange for the current window selection,
120
+ * optionally restricted to a given editable element.
121
+ * @param {HTMLElement} [editable]
122
+ * @returns {WrappedRange|null}
123
+ */
124
+ export function currentRange(editable) {
125
+ const sel = window.getSelection();
126
+ if (!sel || sel.rangeCount === 0) return null;
127
+ const native = sel.getRangeAt(0);
128
+ // Optionally check that the selection is inside the editable element
129
+ if (editable && !editable.contains(native.commonAncestorContainer)) {
130
+ return null;
131
+ }
132
+ return fromNativeRange(native);
133
+ }
134
+
135
+ /**
136
+ * Creates a WrappedRange that covers the entire content of an element.
137
+ * @param {HTMLElement} el
138
+ * @returns {WrappedRange}
139
+ */
140
+ export function rangeFromElement(el) {
141
+ return new WrappedRange(el, 0, el, el.childNodes.length);
142
+ }
143
+
144
+ /**
145
+ * Creates a collapsed range (cursor) at the given node / offset.
146
+ * @param {Node} node
147
+ * @param {number} offset
148
+ * @returns {WrappedRange}
149
+ */
150
+ export function collapsedRange(node, offset = 0) {
151
+ return new WrappedRange(node, offset, node, offset);
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Utility helpers
156
+ // ---------------------------------------------------------------------------
157
+
158
+ /**
159
+ * Returns true if the current selection is inside the given element.
160
+ * @param {HTMLElement} el
161
+ * @returns {boolean}
162
+ */
163
+ export function isSelectionInside(el) {
164
+ const sel = window.getSelection();
165
+ if (!sel || sel.rangeCount === 0) return false;
166
+ return el.contains(sel.getRangeAt(0).commonAncestorContainer);
167
+ }
168
+
169
+ /**
170
+ * Saves the current selection, executes fn, then restores the selection.
171
+ * @param {Function} fn
172
+ */
173
+ export function withSavedRange(fn) {
174
+ const sel = window.getSelection();
175
+ if (!sel || sel.rangeCount === 0) {
176
+ fn(null);
177
+ return;
178
+ }
179
+ const saved = sel.getRangeAt(0).cloneRange();
180
+ fn(fromNativeRange(saved));
181
+ sel.removeAllRanges();
182
+ sel.addRange(saved);
183
+ }
184
+
185
+ /**
186
+ * Splits the text node at the given offset and returns the two halves.
187
+ * @param {Text} textNode
188
+ * @param {number} offset
189
+ * @returns {[Text, Text]}
190
+ */
191
+ export function splitText(textNode, offset) {
192
+ const after = textNode.splitText(offset);
193
+ return [textNode, after];
194
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * sanitise.js - Shared HTML and URL sanitisation utilities
3
+ *
4
+ * Single source of truth used by Editor, Clipboard, Codeview, and renderer.
5
+ * DOM-parser based — no regex-based stripping of HTML (avoids bypass tricks).
6
+ */
7
+
8
+ /** Tags that are unconditionally removed from editor content. */
9
+ const PROHIBITED_TAGS = ['script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button'];
10
+
11
+ /** Attributes whose values must be sanitised as URLs. */
12
+ const URL_ATTRS = ['href', 'src', 'action', 'formaction'];
13
+
14
+ /**
15
+ * Sanitises an HTML string by removing dangerous elements and attributes.
16
+ * Uses DOMParser so the sanitisation follows normal browser parsing rules —
17
+ * no regex shortcuts that can be bypassed by encoding tricks.
18
+ *
19
+ * - Strips PROHIBITED_TAGS (script, style, iframe, object, embed, form, input, button)
20
+ * - Removes all on* event-handler attributes
21
+ * - Rejects javascript: and vbscript: URLs in URL attributes
22
+ * - Rejects data: URIs everywhere except img[src] (base64 uploads)
23
+ *
24
+ * @param {string} html
25
+ * @returns {string}
26
+ */
27
+ export function sanitiseHTML(html, { allowIframes = false } = {}) {
28
+ const doc = new DOMParser().parseFromString(`<body>${html || ''}</body>`, 'text/html');
29
+
30
+ // Remove outright dangerous elements (optionally preserve iframes for video embeds)
31
+ const tags = allowIframes ? PROHIBITED_TAGS.filter((t) => t !== 'iframe') : PROHIBITED_TAGS;
32
+ tags.forEach((tag) => {
33
+ doc.querySelectorAll(tag).forEach((el) => el.remove());
34
+ });
35
+
36
+ // Strip dangerous attributes from remaining elements
37
+ doc.querySelectorAll('*').forEach((el) => {
38
+ Array.from(el.attributes).forEach((attr) => {
39
+ // Remove all event handlers (onclick, onload, onerror, …)
40
+ if (attr.name.startsWith('on')) {
41
+ el.removeAttribute(attr.name);
42
+ return;
43
+ }
44
+ // Sanitise URL attributes
45
+ if (URL_ATTRS.includes(attr.name)) {
46
+ const val = attr.value.trim();
47
+ // Block javascript: and vbscript: protocols
48
+ if (/^(javascript|vbscript):/i.test(val)) {
49
+ el.removeAttribute(attr.name);
50
+ return;
51
+ }
52
+ // Allow data: URIs only on img[src] (base64 image uploads); block elsewhere
53
+ if (/^data:/i.test(val) && !(attr.name === 'src' && el.tagName === 'IMG')) {
54
+ el.removeAttribute(attr.name);
55
+ }
56
+ }
57
+ });
58
+ });
59
+
60
+ return doc.body.innerHTML;
61
+ }
62
+
63
+ /**
64
+ * Sanitises a URL string, rejecting dangerous protocols.
65
+ *
66
+ * Blocked protocols: javascript:, vbscript:
67
+ * Optionally blocked: data: (safe to allow for img/src base64 embeds)
68
+ *
69
+ * @param {string} url
70
+ * @param {{ allowData?: boolean }} [opts]
71
+ * @returns {string|null} The original URL if safe, null if rejected.
72
+ */
73
+ export function sanitiseUrl(url, { allowData = false } = {}) {
74
+ const trimmed = (url || '').trim();
75
+ if (/^(javascript|vbscript):/i.test(trimmed)) return null;
76
+ if (!allowData && /^data:/i.test(trimmed)) return null;
77
+ return url;
78
+ }