minisiwyg-editor 0.1.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.
@@ -0,0 +1,32 @@
1
+ import type { SanitizePolicy } from './types';
2
+
3
+ const policy: SanitizePolicy = {
4
+ tags: {
5
+ p: [],
6
+ br: [],
7
+ strong: [],
8
+ em: [],
9
+ a: ['href', 'title', 'target'],
10
+ h1: [],
11
+ h2: [],
12
+ h3: [],
13
+ ul: [],
14
+ ol: [],
15
+ li: [],
16
+ blockquote: [],
17
+ pre: [],
18
+ code: [],
19
+ },
20
+ strip: true,
21
+ maxDepth: 10,
22
+ maxLength: 100_000,
23
+ protocols: ['https', 'http', 'mailto'],
24
+ };
25
+
26
+ // Deep freeze to prevent mutation of security-critical defaults
27
+ Object.freeze(policy);
28
+ Object.freeze(policy.protocols);
29
+ for (const attrs of Object.values(policy.tags)) Object.freeze(attrs);
30
+ Object.freeze(policy.tags);
31
+
32
+ export const DEFAULT_POLICY: Readonly<SanitizePolicy> = policy;
package/src/editor.ts ADDED
@@ -0,0 +1,376 @@
1
+ import type { SanitizePolicy, EditorOptions, Editor } from './types';
2
+ import { DEFAULT_POLICY } from './defaults';
3
+ import { sanitizeToFragment } from './sanitize';
4
+ import { createPolicyEnforcer, type PolicyEnforcer } from './policy';
5
+ import { isProtocolAllowed } from './shared';
6
+
7
+ export type { Editor, EditorOptions } from './types';
8
+ export { DEFAULT_POLICY } from './defaults';
9
+
10
+ type EditorEvent = 'change' | 'paste' | 'overflow' | 'error';
11
+ type EventHandler = (...args: unknown[]) => void;
12
+
13
+ const SUPPORTED_COMMANDS = new Set([
14
+ 'bold',
15
+ 'italic',
16
+ 'heading',
17
+ 'blockquote',
18
+ 'unorderedList',
19
+ 'orderedList',
20
+ 'link',
21
+ 'unlink',
22
+ 'codeBlock',
23
+ ]);
24
+
25
+ /**
26
+ * Create a contentEditable-based editor with built-in sanitization.
27
+ *
28
+ * The paste handler is the primary security boundary — it sanitizes HTML
29
+ * before insertion via Selection/Range API. The MutationObserver-based
30
+ * policy enforcer provides defense-in-depth.
31
+ */
32
+ export function createEditor(
33
+ element: HTMLElement,
34
+ options?: EditorOptions,
35
+ ): Editor {
36
+ if (!element) {
37
+ throw new TypeError('createEditor requires an HTMLElement');
38
+ }
39
+ if (!element.ownerDocument || !element.parentNode) {
40
+ throw new TypeError('createEditor requires an element attached to the DOM');
41
+ }
42
+
43
+ const src = options?.policy ?? DEFAULT_POLICY;
44
+ const policy: SanitizePolicy = {
45
+ tags: Object.fromEntries(
46
+ Object.entries(src.tags).map(([k, v]) => [k, [...v]]),
47
+ ),
48
+ strip: src.strip,
49
+ maxDepth: src.maxDepth,
50
+ maxLength: src.maxLength,
51
+ protocols: [...src.protocols],
52
+ };
53
+
54
+ const handlers: Record<string, EventHandler[]> = {};
55
+ const doc = element.ownerDocument;
56
+
57
+ function emit(event: EditorEvent, ...args: unknown[]): void {
58
+ for (const handler of handlers[event] ?? []) {
59
+ handler(...args);
60
+ }
61
+ }
62
+
63
+ // Set up contentEditable
64
+ element.contentEditable = 'true';
65
+
66
+ // Attach policy enforcer (MutationObserver defense-in-depth)
67
+ const enforcer: PolicyEnforcer = createPolicyEnforcer(element, policy);
68
+ enforcer.on('error', (err) => emit('error', err));
69
+
70
+ // Paste handler — the primary security boundary
71
+ function onPaste(e: ClipboardEvent): void {
72
+ e.preventDefault();
73
+
74
+ const clipboard = e.clipboardData;
75
+ if (!clipboard) return;
76
+
77
+ // Inside code block: paste as plain text only
78
+ const sel = doc.getSelection();
79
+ if (sel && sel.rangeCount > 0 && sel.anchorNode) {
80
+ const pre = findAncestor(sel.anchorNode, 'PRE');
81
+ if (pre) {
82
+ const text = clipboard.getData('text/plain');
83
+ if (!text) return;
84
+ if (policy.maxLength > 0) {
85
+ const currentLen = element.textContent?.length ?? 0;
86
+ if (currentLen + text.length > policy.maxLength) {
87
+ emit('overflow', policy.maxLength);
88
+ }
89
+ }
90
+ const range = sel.getRangeAt(0);
91
+ range.deleteContents();
92
+ const textNode = doc.createTextNode(text);
93
+ range.insertNode(textNode);
94
+ range.setStartAfter(textNode);
95
+ range.collapse(true);
96
+ sel.removeAllRanges();
97
+ sel.addRange(range);
98
+ emit('paste', element.innerHTML);
99
+ emit('change', element.innerHTML);
100
+ return;
101
+ }
102
+ }
103
+
104
+ // Prefer HTML, fall back to plain text
105
+ let html = clipboard.getData('text/html');
106
+ if (!html) {
107
+ const text = clipboard.getData('text/plain');
108
+ if (!text) return;
109
+ // Escape plain text and convert newlines to <br>
110
+ html = text
111
+ .replace(/&/g, '&amp;')
112
+ .replace(/</g, '&lt;')
113
+ .replace(/>/g, '&gt;')
114
+ .replace(/"/g, '&quot;')
115
+ .replace(/'/g, '&#39;')
116
+ .replace(/\n/g, '<br>');
117
+ }
118
+
119
+ // Sanitize through policy — returns DocumentFragment directly
120
+ // to avoid the serialize→reparse mXSS vector
121
+ const fragment = sanitizeToFragment(html, policy);
122
+
123
+ // Insert via Selection/Range API (NOT execCommand('insertHTML'))
124
+ const selection = doc.getSelection();
125
+ if (!selection || selection.rangeCount === 0) return;
126
+
127
+ const range = selection.getRangeAt(0);
128
+ range.deleteContents();
129
+
130
+ // Check overflow using text content length
131
+ if (policy.maxLength > 0) {
132
+ const pasteTextLen = fragment.textContent?.length ?? 0;
133
+ const currentLen = element.textContent?.length ?? 0;
134
+ if (currentLen + pasteTextLen > policy.maxLength) {
135
+ emit('overflow', policy.maxLength);
136
+ }
137
+ }
138
+
139
+ // Remember last inserted node for cursor positioning
140
+ let lastNode: Node | null = fragment.lastChild;
141
+ range.insertNode(fragment);
142
+
143
+ // Move cursor after inserted content
144
+ if (lastNode) {
145
+ const newRange = doc.createRange();
146
+ newRange.setStartAfter(lastNode);
147
+ newRange.collapse(true);
148
+ selection.removeAllRanges();
149
+ selection.addRange(newRange);
150
+ }
151
+
152
+ emit('paste', element.innerHTML);
153
+ emit('change', element.innerHTML);
154
+ }
155
+
156
+ // Input handler for change events
157
+ function onInput(): void {
158
+ emit('change', element.innerHTML);
159
+ options?.onChange?.(element.innerHTML);
160
+ }
161
+
162
+ // Keydown handler for code block behavior
163
+ function onKeydown(e: KeyboardEvent): void {
164
+ const sel = doc.getSelection();
165
+ if (!sel || sel.rangeCount === 0) return;
166
+ const anchor = sel.anchorNode;
167
+ if (!anchor) return;
168
+
169
+ const pre = findAncestor(anchor, 'PRE');
170
+
171
+ if (e.key === 'Enter' && pre) {
172
+ // Insert newline instead of new paragraph
173
+ e.preventDefault();
174
+ const range = sel.getRangeAt(0);
175
+ range.deleteContents();
176
+ const textNode = doc.createTextNode('\n');
177
+ range.insertNode(textNode);
178
+ range.setStartAfter(textNode);
179
+ range.collapse(true);
180
+ sel.removeAllRanges();
181
+ sel.addRange(range);
182
+ emit('change', element.innerHTML);
183
+ }
184
+
185
+ if (e.key === 'Backspace' && pre) {
186
+ // At start of empty pre, convert to <p>
187
+ const text = pre.textContent || '';
188
+ const isAtStart = sel.anchorOffset === 0;
189
+ const isEmpty = text === '' || text === '\n';
190
+ if (isAtStart && isEmpty) {
191
+ e.preventDefault();
192
+ const p = doc.createElement('p');
193
+ p.appendChild(doc.createElement('br'));
194
+ pre.parentNode?.replaceChild(p, pre);
195
+ const range = doc.createRange();
196
+ range.selectNodeContents(p);
197
+ range.collapse(true);
198
+ sel.removeAllRanges();
199
+ sel.addRange(range);
200
+ emit('change', element.innerHTML);
201
+ }
202
+ }
203
+ }
204
+
205
+ element.addEventListener('keydown', onKeydown);
206
+ element.addEventListener('paste', onPaste);
207
+ element.addEventListener('input', onInput);
208
+
209
+ function findAncestor(node: Node, tagName: string): Element | null {
210
+ let current: Node | null = node;
211
+ while (current && current !== element) {
212
+ if (current.nodeType === 1 && (current as Element).tagName === tagName) return current as Element;
213
+ current = current.parentNode;
214
+ }
215
+ return null;
216
+ }
217
+
218
+ function hasAncestor(node: Node, tagName: string): boolean {
219
+ let current: Node | null = node;
220
+ while (current && current !== element) {
221
+ if (current.nodeType === 1 && (current as Element).tagName === tagName) return true;
222
+ current = current.parentNode;
223
+ }
224
+ return false;
225
+ }
226
+
227
+ const editor: Editor = {
228
+ exec(command: string, value?: string): void {
229
+ if (!SUPPORTED_COMMANDS.has(command)) {
230
+ throw new Error(`Unknown editor command: "${command}"`);
231
+ }
232
+
233
+ element.focus();
234
+
235
+ switch (command) {
236
+ case 'bold':
237
+ doc.execCommand('bold', false);
238
+ break;
239
+ case 'italic':
240
+ doc.execCommand('italic', false);
241
+ break;
242
+ case 'heading': {
243
+ const level = value ?? '1';
244
+ if (!['1', '2', '3'].includes(level)) {
245
+ throw new Error(`Invalid heading level: "${level}". Use 1, 2, or 3`);
246
+ }
247
+ doc.execCommand('formatBlock', false, `<h${level}>`);
248
+ break;
249
+ }
250
+ case 'blockquote':
251
+ doc.execCommand('formatBlock', false, '<blockquote>');
252
+ break;
253
+ case 'unorderedList':
254
+ doc.execCommand('insertUnorderedList', false);
255
+ break;
256
+ case 'orderedList':
257
+ doc.execCommand('insertOrderedList', false);
258
+ break;
259
+ case 'link': {
260
+ if (!value) {
261
+ throw new Error('Link command requires a URL value');
262
+ }
263
+ const trimmed = value.trim();
264
+ if (!isProtocolAllowed(trimmed, policy.protocols)) {
265
+ emit('error', new Error(`Protocol not allowed: ${trimmed}`));
266
+ return;
267
+ }
268
+ doc.execCommand('createLink', false, trimmed);
269
+ break;
270
+ }
271
+ case 'unlink':
272
+ doc.execCommand('unlink', false);
273
+ break;
274
+ case 'codeBlock': {
275
+ const sel = doc.getSelection();
276
+ if (!sel || sel.rangeCount === 0) break;
277
+ const anchor = sel.anchorNode;
278
+ const pre = anchor ? findAncestor(anchor, 'PRE') : null;
279
+ if (pre) {
280
+ // Toggle off: unwrap <pre><code> to <p>
281
+ const p = doc.createElement('p');
282
+ p.textContent = pre.textContent || '';
283
+ pre.parentNode?.replaceChild(p, pre);
284
+ const r = doc.createRange();
285
+ r.selectNodeContents(p);
286
+ r.collapse(false);
287
+ sel.removeAllRanges();
288
+ sel.addRange(r);
289
+ } else {
290
+ // Wrap current block in <pre><code>
291
+ const range = sel.getRangeAt(0);
292
+ let block = range.startContainer;
293
+ while (block.parentNode && block.parentNode !== element) {
294
+ block = block.parentNode;
295
+ }
296
+ const pre2 = doc.createElement('pre');
297
+ const code = doc.createElement('code');
298
+ const blockText = block.textContent || '';
299
+ code.textContent = blockText.endsWith('\n') ? blockText : blockText + '\n';
300
+ pre2.appendChild(code);
301
+ if (block.parentNode === element) {
302
+ element.replaceChild(pre2, block);
303
+ } else {
304
+ element.appendChild(pre2);
305
+ }
306
+ const r = doc.createRange();
307
+ r.selectNodeContents(code);
308
+ r.collapse(false);
309
+ sel.removeAllRanges();
310
+ sel.addRange(r);
311
+ }
312
+ emit('change', element.innerHTML);
313
+ break;
314
+ }
315
+ }
316
+ },
317
+
318
+ queryState(command: string): boolean {
319
+ if (!SUPPORTED_COMMANDS.has(command)) {
320
+ throw new Error(`Unknown editor command: "${command}"`);
321
+ }
322
+
323
+ const sel = doc.getSelection();
324
+ if (!sel || sel.rangeCount === 0) return false;
325
+
326
+ const node = sel.anchorNode;
327
+ if (!node || !element.contains(node)) return false;
328
+
329
+ switch (command) {
330
+ case 'bold':
331
+ return hasAncestor(node, 'STRONG') || hasAncestor(node, 'B');
332
+ case 'italic':
333
+ return hasAncestor(node, 'EM') || hasAncestor(node, 'I');
334
+ case 'heading':
335
+ return hasAncestor(node, 'H1') || hasAncestor(node, 'H2') || hasAncestor(node, 'H3');
336
+ case 'blockquote':
337
+ return hasAncestor(node, 'BLOCKQUOTE');
338
+ case 'unorderedList':
339
+ return hasAncestor(node, 'UL');
340
+ case 'orderedList':
341
+ return hasAncestor(node, 'OL');
342
+ case 'link':
343
+ return hasAncestor(node, 'A');
344
+ case 'unlink':
345
+ return false;
346
+ case 'codeBlock':
347
+ return hasAncestor(node, 'PRE');
348
+ default:
349
+ return false;
350
+ }
351
+ },
352
+
353
+ getHTML(): string {
354
+ return element.innerHTML;
355
+ },
356
+
357
+ getText(): string {
358
+ return element.textContent ?? '';
359
+ },
360
+
361
+ destroy(): void {
362
+ element.removeEventListener('keydown', onKeydown);
363
+ element.removeEventListener('paste', onPaste);
364
+ element.removeEventListener('input', onInput);
365
+ enforcer.destroy();
366
+ element.contentEditable = 'false';
367
+ },
368
+
369
+ on(event: string, handler: EventHandler): void {
370
+ if (!handlers[event]) handlers[event] = [];
371
+ handlers[event].push(handler);
372
+ },
373
+ };
374
+
375
+ return editor;
376
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export type {
2
+ SanitizePolicy,
3
+ EditorOptions,
4
+ Editor,
5
+ ToolbarOptions,
6
+ Toolbar,
7
+ } from './types';
8
+ export { DEFAULT_POLICY } from './defaults';
9
+ export { sanitize, sanitizeToFragment } from './sanitize';
10
+ export { createPolicyEnforcer } from './policy';
11
+ export type { PolicyEnforcer } from './policy';
12
+ export { createEditor } from './editor';
13
+ export { createToolbar } from './toolbar';
package/src/policy.ts ADDED
@@ -0,0 +1,226 @@
1
+ import type { SanitizePolicy } from './types';
2
+ import { TAG_NORMALIZE, URL_ATTRS, isProtocolAllowed } from './shared';
3
+
4
+ export { DEFAULT_POLICY } from './defaults';
5
+ export type { SanitizePolicy } from './types';
6
+
7
+ export interface PolicyEnforcer {
8
+ destroy(): void;
9
+ on(event: 'error', handler: (error: Error) => void): void;
10
+ }
11
+
12
+ /**
13
+ * Get the nesting depth of a node within a root element.
14
+ */
15
+ function getDepth(node: Node, root: Node): number {
16
+ let depth = 0;
17
+ let current = node.parentNode;
18
+ while (current && current !== root) {
19
+ if (current.nodeType === 1) depth++;
20
+ current = current.parentNode;
21
+ }
22
+ return depth;
23
+ }
24
+
25
+ /**
26
+ * Check if an element is allowed by the policy and fix it if not.
27
+ * Returns true if the node was removed/replaced.
28
+ */
29
+ function enforceElement(
30
+ el: Element,
31
+ policy: SanitizePolicy,
32
+ root: HTMLElement,
33
+ ): boolean {
34
+ let tagName = el.tagName.toLowerCase();
35
+ const normalized = TAG_NORMALIZE[tagName];
36
+ if (normalized) tagName = normalized;
37
+
38
+ // Check depth
39
+ const depth = getDepth(el, root);
40
+ if (depth >= policy.maxDepth) {
41
+ el.parentNode?.removeChild(el);
42
+ return true;
43
+ }
44
+
45
+ // Check tag whitelist
46
+ const allowedAttrs = policy.tags[tagName];
47
+ if (allowedAttrs === undefined) {
48
+ if (policy.strip) {
49
+ el.parentNode?.removeChild(el);
50
+ } else {
51
+ // Unwrap: move children up, then remove the element
52
+ const parent = el.parentNode;
53
+ if (parent) {
54
+ while (el.firstChild) {
55
+ parent.insertBefore(el.firstChild, el);
56
+ }
57
+ parent.removeChild(el);
58
+ }
59
+ }
60
+ return true;
61
+ }
62
+
63
+ // Normalize tag if needed (e.g. <b> → <strong>)
64
+ let current: Element = el;
65
+ if (normalized && el.tagName.toLowerCase() !== normalized) {
66
+ const replacement = el.ownerDocument.createElement(normalized);
67
+ while (el.firstChild) {
68
+ replacement.appendChild(el.firstChild);
69
+ }
70
+ // Copy allowed attributes
71
+ for (const attr of Array.from(el.attributes)) {
72
+ replacement.setAttribute(attr.name, attr.value);
73
+ }
74
+ el.parentNode?.replaceChild(replacement, el);
75
+ current = replacement;
76
+ }
77
+
78
+ // Strip disallowed attributes
79
+ for (const attr of Array.from(current.attributes)) {
80
+ const attrName = attr.name.toLowerCase();
81
+
82
+ if (attrName.startsWith('on')) {
83
+ current.removeAttribute(attr.name);
84
+ continue;
85
+ }
86
+
87
+ if (!allowedAttrs.includes(attrName)) {
88
+ current.removeAttribute(attr.name);
89
+ continue;
90
+ }
91
+
92
+ if (URL_ATTRS.has(attrName)) {
93
+ if (!isProtocolAllowed(attr.value, policy.protocols)) {
94
+ current.removeAttribute(attr.name);
95
+ }
96
+ }
97
+ }
98
+
99
+ return false;
100
+ }
101
+
102
+ /**
103
+ * Recursively enforce policy on all descendants of a node.
104
+ */
105
+ function enforceSubtree(node: Node, policy: SanitizePolicy, root: HTMLElement): void {
106
+ const children = Array.from(node.childNodes);
107
+ for (const child of children) {
108
+ if (child.nodeType !== 1) {
109
+ // Remove non-text, non-element nodes (comments, etc.)
110
+ if (child.nodeType !== 3) {
111
+ node.removeChild(child);
112
+ }
113
+ continue;
114
+ }
115
+ const removed = enforceElement(child as Element, policy, root);
116
+ if (!removed) {
117
+ enforceSubtree(child, policy, root);
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Create a policy enforcer that uses MutationObserver to enforce
124
+ * the sanitization policy on a live DOM element.
125
+ *
126
+ * This is defense-in-depth — the paste handler is the primary security boundary.
127
+ * The observer catches mutations from execCommand, programmatic DOM manipulation,
128
+ * and other sources.
129
+ */
130
+ export function createPolicyEnforcer(
131
+ element: HTMLElement,
132
+ policy: SanitizePolicy,
133
+ ): PolicyEnforcer {
134
+ if (!policy || !policy.tags) {
135
+ throw new TypeError('Policy must have a "tags" property');
136
+ }
137
+
138
+ let isApplyingFix = false;
139
+ const errorHandlers: Array<(error: Error) => void> = [];
140
+
141
+ function emitError(error: Error): void {
142
+ for (const handler of errorHandlers) {
143
+ handler(error);
144
+ }
145
+ }
146
+
147
+ const observer = new MutationObserver((mutations) => {
148
+ if (isApplyingFix) return;
149
+ isApplyingFix = true;
150
+
151
+ try {
152
+ for (const mutation of mutations) {
153
+ if (mutation.type === 'childList') {
154
+ for (const node of Array.from(mutation.addedNodes)) {
155
+ // Skip text nodes
156
+ if (node.nodeType === 3) continue;
157
+
158
+ // Remove non-element nodes
159
+ if (node.nodeType !== 1) {
160
+ node.parentNode?.removeChild(node);
161
+ continue;
162
+ }
163
+
164
+ const removed = enforceElement(node as Element, policy, element);
165
+ if (!removed) {
166
+ // Also enforce on all descendants of the added node
167
+ enforceSubtree(node, policy, element);
168
+ }
169
+ }
170
+ } else if (mutation.type === 'attributes') {
171
+ const target = mutation.target as Element;
172
+ if (target.nodeType !== 1) continue;
173
+
174
+ const attrName = mutation.attributeName;
175
+ if (!attrName) continue;
176
+
177
+ const tagName = target.tagName.toLowerCase();
178
+ const normalizedTag = TAG_NORMALIZE[tagName] || tagName;
179
+ const allowedAttrs = policy.tags[normalizedTag];
180
+
181
+ if (!allowedAttrs) continue;
182
+
183
+ const lowerAttr = attrName.toLowerCase();
184
+
185
+ if (lowerAttr.startsWith('on')) {
186
+ target.removeAttribute(attrName);
187
+ continue;
188
+ }
189
+
190
+ if (!allowedAttrs.includes(lowerAttr)) {
191
+ target.removeAttribute(attrName);
192
+ continue;
193
+ }
194
+
195
+ if (URL_ATTRS.has(lowerAttr)) {
196
+ const value = target.getAttribute(attrName);
197
+ if (value && !isProtocolAllowed(value, policy.protocols)) {
198
+ target.removeAttribute(attrName);
199
+ }
200
+ }
201
+ }
202
+ }
203
+ } catch (err) {
204
+ emitError(err instanceof Error ? err : new Error(String(err)));
205
+ } finally {
206
+ isApplyingFix = false;
207
+ }
208
+ });
209
+
210
+ observer.observe(element, {
211
+ childList: true,
212
+ attributes: true,
213
+ subtree: true,
214
+ });
215
+
216
+ return {
217
+ destroy() {
218
+ observer.disconnect();
219
+ },
220
+ on(event: 'error', handler: (error: Error) => void) {
221
+ if (event === 'error') {
222
+ errorHandlers.push(handler);
223
+ }
224
+ },
225
+ };
226
+ }