u2ui 1.5.3 → 1.5.4
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/el/ico/ico.js +5 -5
- package/el/rte/README.md +53 -13
- package/el/rte/manifest.json +19 -4
- package/el/rte/rte.css +31 -6
- package/el/rte/rte.js +174 -44
- package/el/rte0/README.md +37 -0
- package/el/rte0/manifest.json +33 -0
- package/el/rte0/rte0.css +9 -0
- package/el/rte0/rte0.js +56 -0
- package/el/system/styler.js +2 -0
- package/el/tooltip/tooltip.css +2 -2
- package/js/Placer/Placer.js +2 -1
- package/js/rte/AGENTS.md +142 -0
- package/js/rte/PLAN.md +488 -0
- package/js/rte/README.md +319 -25
- package/js/rte/blocks.js +7 -0
- package/js/rte/breaks.js +6 -0
- package/js/rte/classes.js +7 -0
- package/js/rte/docs/usage.md +845 -0
- package/js/rte/images.js +7 -0
- package/js/rte/link.js +8 -0
- package/js/rte/playground/README.md +95 -0
- package/js/rte/playground/hosts.html +518 -0
- package/js/rte/playground/index.html +824 -0
- package/js/rte/rte.js +56 -5
- package/js/rte/source.js +12 -0
- package/js/rte/src/browser/README.md +40 -0
- package/js/rte/src/browser/range-rect.js +82 -0
- package/js/rte/src/client/README.md +378 -0
- package/js/rte/src/client/blocks.js +38 -0
- package/js/rte/src/client/breaks.js +212 -0
- package/js/rte/src/client/classes.js +48 -0
- package/js/rte/src/client/editor.js +461 -0
- package/js/rte/src/client/external-input.js +47 -0
- package/js/rte/src/client/history.js +13 -0
- package/js/rte/src/client/images.js +275 -0
- package/js/rte/src/client/link.js +572 -0
- package/js/rte/src/client/marks.js +25 -0
- package/js/rte/src/client/source.js +182 -0
- package/js/rte/src/client/structure.js +27 -0
- package/js/rte/src/client/tables.js +162 -0
- package/js/rte/src/client/unstyle.js +32 -0
- package/js/rte/src/command/README.md +517 -0
- package/js/rte/src/command/block-boundary.js +43 -0
- package/js/rte/src/command/block-style.js +120 -0
- package/js/rte/src/command/commands.js +158 -0
- package/js/rte/src/command/delete.js +112 -0
- package/js/rte/src/command/edit.js +72 -0
- package/js/rte/src/command/element.js +45 -0
- package/js/rte/src/command/enter.js +175 -0
- package/js/rte/src/command/fragment.js +76 -0
- package/js/rte/src/command/insert.js +52 -0
- package/js/rte/src/command/list.js +284 -0
- package/js/rte/src/command/mark.js +583 -0
- package/js/rte/src/command/pending-marks.js +216 -0
- package/js/rte/src/command/table.js +207 -0
- package/js/rte/src/command/unstyle.js +248 -0
- package/js/rte/src/config/README.md +96 -0
- package/js/rte/src/config/config.js +127 -0
- package/js/rte/src/core/README.md +41 -0
- package/js/rte/src/core/core.js +156 -0
- package/js/rte/src/history/README.md +83 -0
- package/js/rte/src/history/history.js +283 -0
- package/js/rte/src/input/README.md +155 -0
- package/js/rte/src/input/external-input.js +130 -0
- package/js/rte/src/input/input-pipeline.js +414 -0
- package/js/rte/src/mark/README.md +219 -0
- package/js/rte/src/mark/dom-adapter.js +100 -0
- package/js/rte/src/mark/mark.js +133 -0
- package/js/rte/src/mark/standard.js +76 -0
- package/js/rte/src/model/README.md +71 -0
- package/js/rte/src/model/content-model.js +213 -0
- package/js/rte/src/model/html/README.md +51 -0
- package/js/rte/src/model/html/html-model.js +90 -0
- package/js/rte/src/normalize/execute/README.md +33 -0
- package/js/rte/src/normalize/execute/repair-executor.js +129 -0
- package/js/rte/src/normalize/normalizer/README.md +48 -0
- package/js/rte/src/normalize/normalizer/normalizer.js +141 -0
- package/js/rte/src/normalize/repair/README.md +70 -0
- package/js/rte/src/normalize/repair/repair-planner.js +208 -0
- package/js/rte/src/sanitize/README.md +105 -0
- package/js/rte/src/sanitize/native.js +43 -0
- package/js/rte/src/sanitize/policy.js +202 -0
- package/js/rte/src/selection/README.md +39 -0
- package/js/rte/src/selection/map/README.md +53 -0
- package/js/rte/src/selection/map/point-map.js +307 -0
- package/js/rte/src/selection/ownership/README.md +32 -0
- package/js/rte/src/selection/ownership/ownership.js +42 -0
- package/js/rte/src/selection/point/README.md +34 -0
- package/js/rte/src/selection/point/point.js +83 -0
- package/js/rte/src/selection/range/README.md +38 -0
- package/js/rte/src/selection/range/edit-range.js +223 -0
- package/js/rte/src/selection/snapshot.js +64 -0
- package/js/rte/src/source/README.md +64 -0
- package/js/rte/src/source/source.js +193 -0
- package/js/rte/src/surface/README.md +36 -0
- package/js/rte/src/surface/content.js +16 -0
- package/js/rte/src/surface/surface.js +87 -0
- package/js/rte/src/transaction/README.md +35 -0
- package/js/rte/src/transaction/transaction.js +56 -0
- package/js/rte/src/ui/README.md +255 -0
- package/js/rte/src/ui/chrome.js +214 -0
- package/js/rte/src/ui/handles.js +179 -0
- package/js/rte/src/ui/place.js +25 -0
- package/js/rte/src/ui/toolbar.js +283 -0
- package/js/rte/src/unstyle/README.md +103 -0
- package/js/rte/src/unstyle/unstyle.js +140 -0
- package/js/rte/tables.js +7 -0
- package/js/rte/unstyle.js +8 -0
- package/js/rte0/README.md +37 -0
- package/js/rte0/rte.js +7 -0
- package/js/{rte → rte0}/src/behavior.js +0 -1
- package/js/{rte → rte0}/src/events.js +0 -1
- package/js/{rte → rte0}/src/range.js +0 -2
- package/js/{rte → rte0}/src/ui.items.js +0 -2
- package/js/{rte → rte0}/src/ui.js +0 -1
- package/package.json +1 -1
- package/u2/Element.js +2 -2
- package/u2/plan-scoped-refactoring.md +133 -303
- package/u2/projects.json +18 -2
- package/u2/tools/update.repos.json.js +1 -1
- package/el/qrcode/package.json +0 -22
- /package/js/{rte → rte0}/old/NodeCleaner.js +0 -0
- /package/js/{rte → rte0}/old/Rte/Rte.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/Rte.ui.items.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/Rte.ui.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/crossbrowser.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/font/tinymce.svg +0 -0
- /package/js/{rte → rte0}/old/Rte/font/tinymce.woff +0 -0
- /package/js/{rte → rte0}/old/Rte/helpers.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/htmlparser.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/index.mjs +0 -0
- /package/js/{rte → rte0}/old/Rte/main.css +0 -0
- /package/js/{rte → rte0}/old/old.rte.js +0 -0
- /package/js/{rte → rte0}/old/range-selection.old.js +0 -0
- /package/js/{rte → rte0}/src/fixes.js +0 -0
- /package/js/{rte → rte0}/src/rte.js +0 -0
- /package/js/{rte → rte0}/src/ui/items/ai.js +0 -0
- /package/js/{rte → rte0}/src/ui/items/code.js +0 -0
- /package/js/{rte → rte0}/src/ui/items/undo.js +0 -0
- /package/js/{rte → rte0}/src/util/htmlparser.js +0 -0
- /package/js/{rte → rte0}/src/util/indentHtml.js +0 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import {htmlModel} from '../../model/html/html-model.js';
|
|
2
|
+
import {isEditingBoundary} from '../../selection/ownership/ownership.js';
|
|
3
|
+
|
|
4
|
+
const LEVELS = new Set(['none', 'minimal', 'structural', 'canonical']);
|
|
5
|
+
const KEEP = Object.freeze({type: 'keep'});
|
|
6
|
+
const BOUNDARY = Object.freeze({type: 'boundary'});
|
|
7
|
+
const REMOVE = Object.freeze({type: 'remove'});
|
|
8
|
+
const REJECT = Object.freeze({type: 'reject'});
|
|
9
|
+
|
|
10
|
+
export class RepairPlanner {
|
|
11
|
+
#root;
|
|
12
|
+
#model;
|
|
13
|
+
#block;
|
|
14
|
+
#level;
|
|
15
|
+
#generic;
|
|
16
|
+
#elements = new Map();
|
|
17
|
+
#rootWrapper;
|
|
18
|
+
|
|
19
|
+
constructor(root, {model = htmlModel, block = null, level = 'structural', generic = ['div', 'span']} = {}) {
|
|
20
|
+
if (root?.nodeType !== Node.ELEMENT_NODE) throw new TypeError('A repair planner requires an element root');
|
|
21
|
+
if (typeof model?.allows !== 'function' || typeof model?.allowed !== 'function'
|
|
22
|
+
|| typeof model?.rule !== 'function' || typeof model?.block !== 'function') {
|
|
23
|
+
throw new TypeError('A repair planner requires a content model');
|
|
24
|
+
}
|
|
25
|
+
if (block !== null && (typeof block !== 'string' || !block.trim())) {
|
|
26
|
+
throw new TypeError('Default block must be a tag name or null');
|
|
27
|
+
}
|
|
28
|
+
if (!LEVELS.has(level)) throw new TypeError('Unknown normalization level');
|
|
29
|
+
if (!Array.isArray(generic) || generic.some(name => typeof name !== 'string' || !name.trim())) {
|
|
30
|
+
throw new TypeError('Generic blocks must be tag names');
|
|
31
|
+
}
|
|
32
|
+
this.#root = root;
|
|
33
|
+
this.#model = model;
|
|
34
|
+
this.#block = block?.toLowerCase() || null;
|
|
35
|
+
this.#level = level;
|
|
36
|
+
this.#generic = new Set(generic.map(name => name.toLowerCase()));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get root() { return this.#root; }
|
|
40
|
+
get model() { return this.#model; }
|
|
41
|
+
get block() { return this.#block; }
|
|
42
|
+
get level() { return this.#level; }
|
|
43
|
+
|
|
44
|
+
plan(parent, child) {
|
|
45
|
+
if (child?.parentNode !== parent) throw new RangeError('Repair planning requires a direct child');
|
|
46
|
+
if (parent !== this.#root && !this.#root.contains(parent)) throw new RangeError('Repair parent must belong to the planner root');
|
|
47
|
+
if (this.#level === 'none') return KEEP;
|
|
48
|
+
if (isEditingBoundary(child)) return BOUNDARY;
|
|
49
|
+
|
|
50
|
+
if (this.#level !== 'minimal') {
|
|
51
|
+
const plan = parent === this.#root
|
|
52
|
+
? this.#rootPlan(child)
|
|
53
|
+
: this.#genericPlan(parent, child) || this.#loosePlan(parent, child);
|
|
54
|
+
if (plan) return plan;
|
|
55
|
+
}
|
|
56
|
+
if (this.#level !== 'minimal' && this.#hollow(child)) return REMOVE;
|
|
57
|
+
if (this.#level === 'canonical') {
|
|
58
|
+
const plan = this.#canonicalPlan(parent, child);
|
|
59
|
+
if (plan) return plan;
|
|
60
|
+
}
|
|
61
|
+
if (this.#model.allows(parent, child)) return KEEP;
|
|
62
|
+
if (ignorable(child)) return REMOVE;
|
|
63
|
+
if (child.nodeType === Node.ELEMENT_NODE && !this.#model.allowed(child)) {
|
|
64
|
+
return this.#model.atomic(child) ? REMOVE : action('unwrap', {breaks: this.#model.block(child)});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const wrapper = this.#wrapper(parent);
|
|
68
|
+
if (wrapper && this.#model.allows(parent, wrapper) && this.#model.allows(wrapper, child)) {
|
|
69
|
+
return action('wrap', {tag: wrapper.localName});
|
|
70
|
+
}
|
|
71
|
+
if (child.nodeType === Node.ELEMENT_NODE && neutral(child)) {
|
|
72
|
+
const children = [...child.childNodes];
|
|
73
|
+
if (children.length && children.every(node => this.#model.allows(parent, node))) {
|
|
74
|
+
const content = children.filter(node => !ignorable(node));
|
|
75
|
+
const breaks = this.#model.block(child) && content.some(node => !this.#model.block(node));
|
|
76
|
+
return action('unwrap', {breaks});
|
|
77
|
+
}
|
|
78
|
+
if (!children.length && !this.#model.atomic(child)) return REMOVE;
|
|
79
|
+
}
|
|
80
|
+
for (let target = parent.parentElement; target && this.#root.contains(target); target = target.parentElement) {
|
|
81
|
+
if (this.#model.allows(target, child)) return action('lift', {target});
|
|
82
|
+
if (target === this.#root) break;
|
|
83
|
+
}
|
|
84
|
+
return REJECT;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#rootPlan(child) {
|
|
88
|
+
// Whether the root accepts its default block does not depend on the
|
|
89
|
+
// child, so it is decided once instead of for every one of them.
|
|
90
|
+
this.#rootWrapper ??= this.#usableRootWrapper();
|
|
91
|
+
const wrapper = this.#rootWrapper;
|
|
92
|
+
if (!wrapper) return null;
|
|
93
|
+
if (ignorable(child)) {
|
|
94
|
+
const previous = child.previousSibling;
|
|
95
|
+
return child.nodeType === Node.TEXT_NODE
|
|
96
|
+
&& previous
|
|
97
|
+
&& !ignorable(previous)
|
|
98
|
+
&& !this.#model.block(previous)
|
|
99
|
+
&& this.#model.allows(wrapper, child)
|
|
100
|
+
? action('wrap', {tag: wrapper.localName})
|
|
101
|
+
: REMOVE;
|
|
102
|
+
}
|
|
103
|
+
if (child.nodeType === Node.ELEMENT_NODE && !this.#model.allowed(child) && this.#model.block(child)) {
|
|
104
|
+
const children = [...child.childNodes];
|
|
105
|
+
if (children.every(node => this.#model.allows(wrapper, node))) {
|
|
106
|
+
return action('convert', {tag: wrapper.localName});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const plan = this.#genericPlan(this.#root, child);
|
|
110
|
+
if (plan) return plan;
|
|
111
|
+
if (!this.#model.block(child) && this.#model.allows(wrapper, child)) {
|
|
112
|
+
return action('wrap', {tag: wrapper.localName});
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A neutral generic block adds nothing to the document: it becomes the block
|
|
118
|
+
// its context expects, or dissolves into it. Depth is not a reason to keep
|
|
119
|
+
// one — a bare wrapper is as redundant four levels down as it is at the top,
|
|
120
|
+
// and valid nesting is exactly why the model alone never removes it. A
|
|
121
|
+
// generic inline element is not structure and is left to `canonical`.
|
|
122
|
+
#genericPlan(parent, child) {
|
|
123
|
+
if (child.nodeType !== Node.ELEMENT_NODE) return null;
|
|
124
|
+
if (!this.#generic.has(child.localName) || !neutral(child)) return null;
|
|
125
|
+
if (!this.#model.block(child)) return null;
|
|
126
|
+
const children = [...child.childNodes];
|
|
127
|
+
const wrapper = this.#element(this.#model.rule(parent).defaultChild || this.#block);
|
|
128
|
+
if (wrapper && wrapper.localName !== child.localName
|
|
129
|
+
&& this.#model.allows(parent, wrapper)
|
|
130
|
+
&& children.every(node => this.#model.allows(wrapper, node))) {
|
|
131
|
+
return action('convert', {tag: wrapper.localName});
|
|
132
|
+
}
|
|
133
|
+
const content = children.filter(node => !ignorable(node));
|
|
134
|
+
if (content.some(node => this.#model.block(node))
|
|
135
|
+
&& content.every(node => this.#model.allows(parent, node))) {
|
|
136
|
+
return action('unwrap', {breaks: false});
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Loose inline content standing beside blocks in a generic container is the
|
|
142
|
+
// same asymmetry as a bare wrapper: the root already gives it a block, and
|
|
143
|
+
// depth is not a reason to leave it homeless. Containers that carry their
|
|
144
|
+
// own meaning — list items, cells, quotes — keep loose text as it is.
|
|
145
|
+
#loosePlan(parent, child) {
|
|
146
|
+
if (!this.#generic.has(parent.localName) || !this.#model.block(parent)) return null;
|
|
147
|
+
if (ignorable(child) || this.#model.block(child)) return null;
|
|
148
|
+
const wrapper = this.#element(this.#model.rule(parent).defaultChild || this.#block);
|
|
149
|
+
if (!wrapper || !this.#model.allows(parent, wrapper) || !this.#model.allows(wrapper, child)) return null;
|
|
150
|
+
if (![...parent.children].some(node => this.#model.block(node))) return null;
|
|
151
|
+
return action('wrap', {tag: wrapper.localName});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Canonical goes one step further than valid: a generic wrapper carrying
|
|
155
|
+
// neither attributes nor meaning of its own is noise even where the model
|
|
156
|
+
// allows it. A semantic element is never noise, however bare it is.
|
|
157
|
+
// An inline element with neither attributes nor content says nothing at all,
|
|
158
|
+
// whatever its name, so it never has to survive being valid.
|
|
159
|
+
#hollow(child) {
|
|
160
|
+
return child.nodeType === Node.ELEMENT_NODE
|
|
161
|
+
&& neutral(child)
|
|
162
|
+
&& !child.childNodes.length
|
|
163
|
+
&& !this.#model.block(child)
|
|
164
|
+
&& !this.#model.atomic(child);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#canonicalPlan(parent, child) {
|
|
168
|
+
if (child.nodeType !== Node.ELEMENT_NODE || !neutral(child)) return null;
|
|
169
|
+
if (this.#model.block(child) || this.#model.atomic(child)) return null;
|
|
170
|
+
if (!this.#generic.has(child.localName)) return null;
|
|
171
|
+
return [...child.childNodes].every(node => this.#model.allows(parent, node))
|
|
172
|
+
? action('unwrap', {breaks: false})
|
|
173
|
+
: null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
#usableRootWrapper() {
|
|
177
|
+
const wrapper = this.#element(this.#block);
|
|
178
|
+
return wrapper && this.#model.allows(this.#root, wrapper) ? wrapper : null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
#wrapper(parent) {
|
|
182
|
+
const name = parent === this.#root && this.#block || this.#model.rule(parent).defaultChild;
|
|
183
|
+
return this.#element(name);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#element(name) {
|
|
187
|
+
if (!name) return null;
|
|
188
|
+
let element = this.#elements.get(name);
|
|
189
|
+
if (!element) {
|
|
190
|
+
element = this.#root.ownerDocument.createElement(name);
|
|
191
|
+
this.#elements.set(name, element);
|
|
192
|
+
}
|
|
193
|
+
return element;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function action(type, detail) {
|
|
198
|
+
return Object.freeze({type, ...detail});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function neutral(node) {
|
|
202
|
+
return node.attributes.length === 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function ignorable(node) {
|
|
206
|
+
return node.nodeType === Node.COMMENT_NODE
|
|
207
|
+
|| node.nodeType === Node.TEXT_NODE && !node.data.trim();
|
|
208
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# HTML sanitizing
|
|
2
|
+
|
|
3
|
+
Sanitizing is the security boundary for HTML that comes from outside the
|
|
4
|
+
editor, such as paste, drop, or an HTML source dialog. It decides which
|
|
5
|
+
elements, attributes, and URL protocols may enter a detached fragment.
|
|
6
|
+
Structural normalization runs only afterwards and has a different job: it
|
|
7
|
+
repairs editable document shape.
|
|
8
|
+
|
|
9
|
+
## Policy
|
|
10
|
+
|
|
11
|
+
`SanitizePolicy` is an immutable allowlist. Its options are:
|
|
12
|
+
|
|
13
|
+
- `elements`: element names the security policy can accept;
|
|
14
|
+
- `attributes`: global (`*`) and per-element attribute names;
|
|
15
|
+
- `protocols`: per-element rules for URL attributes;
|
|
16
|
+
- `comments` and `dataAttributes`: explicit booleans, both false by default.
|
|
17
|
+
|
|
18
|
+
The supplied `sanitizePolicy` accepts the `document` element preset, ordinary
|
|
19
|
+
editor metadata, and conservative web URLs. Inline styles, event attributes,
|
|
20
|
+
`data:` URLs, comments, and data attributes are not enabled by default.
|
|
21
|
+
Relative URLs use the explicit `relative` protocol token. Known URL attributes
|
|
22
|
+
without a matching protocol rule are removed rather than accepted implicitly.
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
const policy = new SanitizePolicy({
|
|
26
|
+
elements: ['p', 'a', 'strong'],
|
|
27
|
+
attributes: {'*': ['class'], a: ['href', 'rel']},
|
|
28
|
+
protocols: {a: {href: ['https', 'relative']}},
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`clean(root)` enforces attribute and URL rules on an already safely parsed DOM
|
|
33
|
+
tree. It is public for adapters, but it is not an HTML parser or a standalone
|
|
34
|
+
security sanitizer: it must never be paired with `innerHTML`, `DOMParser`, or
|
|
35
|
+
another unsafe sink for untrusted input.
|
|
36
|
+
|
|
37
|
+
## Native adapter
|
|
38
|
+
|
|
39
|
+
`NativeSanitizer` passes input directly to `Element.setHTML()` on a detached
|
|
40
|
+
template and returns its `DocumentFragment`. It never inserts the result into
|
|
41
|
+
an editor. `sanitize(html, {elements})` intersects the security elements with a
|
|
42
|
+
surface's structural element list. Security-safe wrappers excluded by that
|
|
43
|
+
surface are unwrapped; the sanitizer policy can never be broadened.
|
|
44
|
+
|
|
45
|
+
The native HTML Sanitizer API is not available in every target engine yet.
|
|
46
|
+
`NativeSanitizer.supported(document)` exposes that capability. `sanitize()`
|
|
47
|
+
throws `NotSupportedError` when the safe sink is absent; it deliberately has no
|
|
48
|
+
`innerHTML` fallback. RTE's normal paste/drop path therefore remains native in
|
|
49
|
+
those engines and performs presentation plus structural cleanup afterwards; it
|
|
50
|
+
does not need to parse the clipboard HTML string.
|
|
51
|
+
|
|
52
|
+
The returned fragment remains untrusted if it is serialized back to a string.
|
|
53
|
+
Consumers should insert the fragment through the editor transaction path, not
|
|
54
|
+
round-trip it through an HTML string. `ExternalInput` composes that path for
|
|
55
|
+
rich paste/drop events and direct HTML imports.
|
|
56
|
+
|
|
57
|
+
## Invariants
|
|
58
|
+
|
|
59
|
+
- External HTML reaches no unsafe DOM sink.
|
|
60
|
+
- Surface element configuration narrows security policy and never broadens it.
|
|
61
|
+
- Attribute and URL policy remains separate from structural normalization.
|
|
62
|
+
- Sanitizing creates no editor state, listeners, or markers.
|
|
63
|
+
- Policies do not mutate after construction.
|
|
64
|
+
|
|
65
|
+
## Where the policy is applied
|
|
66
|
+
|
|
67
|
+
Three paths bring external HTML in, and all three now meet the same policy:
|
|
68
|
+
|
|
69
|
+
- `Source.write()` and `ExternalInput.insert()` parse an HTML string, so they go
|
|
70
|
+
through the sanitizer's safe sink first and then `clean()`.
|
|
71
|
+
- An ordinary paste or drop is inserted by the browser itself. Nothing is parsed
|
|
72
|
+
there and no fallback parser is needed, but the result still has to obey the
|
|
73
|
+
same attribute policy — so the input pipeline applies `clean()` to the nodes
|
|
74
|
+
that arrived. Until it did, the most common import was the only one without an
|
|
75
|
+
allowlist, and layout ids, tracking `data-` attributes and inline styles came
|
|
76
|
+
straight through.
|
|
77
|
+
|
|
78
|
+
`clean(root, {preserve})` spares elements that were already there, so a paste
|
|
79
|
+
never re-cleans the document it landed in.
|
|
80
|
+
|
|
81
|
+
`narrow(root, {elements, preserve, map, skip})` reduces a subtree to the elements
|
|
82
|
+
the policy allows, keeping the content of the rest. Parsed input gets this from
|
|
83
|
+
the safe sink; markup the browser inserted itself has to be narrowed afterwards.
|
|
84
|
+
`elements` may narrow further but never past the policy, and `skip` leaves an
|
|
85
|
+
element to a later stage.
|
|
86
|
+
|
|
87
|
+
`alias` names an equivalent for an element the list does not carry. Dropping
|
|
88
|
+
`<b>` would lose the emphasis it holds — nothing rewrites it later, because the
|
|
89
|
+
bold mark recognizes `<b>` but only a mark command ever makes an element
|
|
90
|
+
canonical — so a strict list keeps the meaning by taking `<strong>` instead. An
|
|
91
|
+
alias can only ever name an element the list already allows.
|
|
92
|
+
|
|
93
|
+
## Declared content classes
|
|
94
|
+
|
|
95
|
+
`clean(root, {classes})` narrows the class attribute to the given names without
|
|
96
|
+
touching the security policy. An application declares its content classes once
|
|
97
|
+
through `--u2-rte-classes`; external HTML then cannot smuggle in classes the
|
|
98
|
+
host does not know, and the same list drives the style control and presentation
|
|
99
|
+
cleanup.
|
|
100
|
+
|
|
101
|
+
## TODO
|
|
102
|
+
|
|
103
|
+
- Add contextual parsing for table and list insertion sites.
|
|
104
|
+
- Add optional class, inline-style, and richer image URL policies.
|
|
105
|
+
- Cover mutation-XSS and pasted office markup with shared adapter fixtures.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import {sanitizePolicy} from './policy.js';
|
|
2
|
+
|
|
3
|
+
export class NativeSanitizer {
|
|
4
|
+
constructor(policy = sanitizePolicy) {
|
|
5
|
+
if (!policy || !Array.isArray(policy.elements)
|
|
6
|
+
|| !Array.isArray(policy.attributeNames) || typeof policy.clean !== 'function') {
|
|
7
|
+
throw new TypeError('A sanitize policy is required');
|
|
8
|
+
}
|
|
9
|
+
this.policy = policy;
|
|
10
|
+
Object.freeze(this);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
static supported(document = globalThis.document) {
|
|
14
|
+
return typeof document?.createElement?.('template').setHTML === 'function';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
sanitize(html, options = {}) {
|
|
18
|
+
const document = options.document ?? globalThis.document;
|
|
19
|
+
const template = document?.createElement?.('template');
|
|
20
|
+
if (typeof template?.setHTML !== 'function') {
|
|
21
|
+
throw new DOMException('Element.setHTML() is not available', 'NotSupportedError');
|
|
22
|
+
}
|
|
23
|
+
template.setHTML(html, {sanitizer: config(this.policy, options.elements)});
|
|
24
|
+
return this.policy.clean(template.content, {
|
|
25
|
+
base: options.base ?? document.baseURI,
|
|
26
|
+
classes: options.classes ?? null,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function config(policy, elements) {
|
|
32
|
+
const selected = elements == null ? null : new Set(elements.map(name => name.toLowerCase()));
|
|
33
|
+
const allowed = selected ? policy.elements.filter(name => selected.has(name)) : policy.elements;
|
|
34
|
+
const replaced = selected ? policy.elements.filter(name => !selected.has(name)) : [];
|
|
35
|
+
const result = {
|
|
36
|
+
elements: allowed,
|
|
37
|
+
attributes: policy.attributeNames,
|
|
38
|
+
comments: policy.comments,
|
|
39
|
+
dataAttributes: policy.dataAttributes,
|
|
40
|
+
};
|
|
41
|
+
if (replaced.length) result.replaceWithChildrenElements = replaced;
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import {elementPresets} from '../config/config.js';
|
|
2
|
+
|
|
3
|
+
const NAME = /^[a-z][a-z\d-]*$/;
|
|
4
|
+
const ATTRIBUTE = /^[a-z][a-z\d_.:-]*$/;
|
|
5
|
+
const PROTOCOL = /^[a-z][a-z\d+.-]*$/;
|
|
6
|
+
const URL_ATTRIBUTES = new Set(['action', 'cite', 'formaction', 'href', 'poster', 'src', 'xlink:href']);
|
|
7
|
+
const DEFAULT_ATTRIBUTES = Object.freeze({
|
|
8
|
+
'*': Object.freeze(['class', 'dir', 'lang', 'title']),
|
|
9
|
+
a: Object.freeze(['href', 'rel', 'target']),
|
|
10
|
+
blockquote: Object.freeze(['cite']),
|
|
11
|
+
img: Object.freeze(['alt', 'height', 'loading', 'src', 'width']),
|
|
12
|
+
li: Object.freeze(['value']),
|
|
13
|
+
ol: Object.freeze(['reversed', 'start', 'type']),
|
|
14
|
+
q: Object.freeze(['cite']),
|
|
15
|
+
td: Object.freeze(['colspan', 'headers', 'rowspan']),
|
|
16
|
+
th: Object.freeze(['abbr', 'colspan', 'headers', 'rowspan', 'scope']),
|
|
17
|
+
time: Object.freeze(['datetime']),
|
|
18
|
+
});
|
|
19
|
+
const DEFAULT_PROTOCOLS = Object.freeze({
|
|
20
|
+
a: Object.freeze({href: Object.freeze(['http', 'https', 'mailto', 'relative', 'tel'])}),
|
|
21
|
+
blockquote: Object.freeze({cite: Object.freeze(['http', 'https', 'relative'])}),
|
|
22
|
+
img: Object.freeze({src: Object.freeze(['http', 'https', 'relative'])}),
|
|
23
|
+
q: Object.freeze({cite: Object.freeze(['http', 'https', 'relative'])}),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const sanitizeDefaults = Object.freeze({
|
|
27
|
+
elements: elementPresets.document,
|
|
28
|
+
attributes: DEFAULT_ATTRIBUTES,
|
|
29
|
+
protocols: DEFAULT_PROTOCOLS,
|
|
30
|
+
comments: false,
|
|
31
|
+
dataAttributes: false,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export class SanitizePolicy {
|
|
35
|
+
#attributes;
|
|
36
|
+
#protocols;
|
|
37
|
+
|
|
38
|
+
constructor(options = {}) {
|
|
39
|
+
this.elements = names(options.elements ?? sanitizeDefaults.elements, NAME, 'element');
|
|
40
|
+
this.#attributes = groups(options.attributes ?? sanitizeDefaults.attributes, ATTRIBUTE, 'attribute');
|
|
41
|
+
this.#protocols = protocolGroups(options.protocols ?? sanitizeDefaults.protocols);
|
|
42
|
+
this.attributeNames = Object.freeze(unique(Object.values(this.#attributes).flat()));
|
|
43
|
+
this.comments = boolean(options.comments ?? sanitizeDefaults.comments, 'comments');
|
|
44
|
+
this.dataAttributes = boolean(options.dataAttributes ?? sanitizeDefaults.dataAttributes, 'dataAttributes');
|
|
45
|
+
Object.freeze(this);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
allowsAttribute(element, attribute) {
|
|
49
|
+
const name = element.localName.toLowerCase();
|
|
50
|
+
const attributeName = attribute.toLowerCase();
|
|
51
|
+
if (this.dataAttributes && attributeName.startsWith('data-')) return true;
|
|
52
|
+
return this.#attributes['*']?.includes(attributeName)
|
|
53
|
+
|| this.#attributes[name]?.includes(attributeName)
|
|
54
|
+
|| false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
allowsUrl(element, attribute, value, base = element.ownerDocument?.baseURI) {
|
|
58
|
+
const attributeName = attribute.toLowerCase();
|
|
59
|
+
if (!URL_ATTRIBUTES.has(attributeName)) return true;
|
|
60
|
+
const protocols = this.#protocols[element.localName.toLowerCase()]?.[attributeName]
|
|
61
|
+
|| this.#protocols['*']?.[attributeName];
|
|
62
|
+
if (!protocols) return false;
|
|
63
|
+
const protocol = urlProtocol(value, base);
|
|
64
|
+
return protocol !== null && protocols.includes(protocol);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Reduces a subtree to the elements this policy allows, keeping the content
|
|
68
|
+
// of the rest. Parsed input gets this from the safe sink; markup the browser
|
|
69
|
+
// inserted itself has to be narrowed afterwards.
|
|
70
|
+
//
|
|
71
|
+
// `skip` leaves an element to a later stage. Structural repair already
|
|
72
|
+
// removes what the content model rejects, and it does so knowing that
|
|
73
|
+
// dissolving a block into inline content needs a line break to survive.
|
|
74
|
+
//
|
|
75
|
+
// `alias` names an equivalent for an element that is not allowed. Dropping
|
|
76
|
+
// `<b>` would lose the emphasis it carries, so a strict list can still keep
|
|
77
|
+
// the meaning by taking the canonical element instead.
|
|
78
|
+
narrow(root, {elements = null, preserve = null, map = null, skip = null, alias = null} = {}) {
|
|
79
|
+
// A caller may narrow further but never past the policy itself.
|
|
80
|
+
const allowed = elements === null
|
|
81
|
+
? this.elements
|
|
82
|
+
: this.elements.filter(name => elements.includes(name));
|
|
83
|
+
const changed = [];
|
|
84
|
+
for (const element of descendants(root).reverse()) {
|
|
85
|
+
if (preserve?.has(element) || allowed.includes(element.localName)) continue;
|
|
86
|
+
if (!element.parentNode || skip?.(element)) continue;
|
|
87
|
+
const equivalent = alias?.[element.localName];
|
|
88
|
+
if (equivalent && allowed.includes(equivalent)) {
|
|
89
|
+
const replacement = element.ownerDocument.createElement(equivalent);
|
|
90
|
+
for (const attribute of element.attributes) replacement.setAttribute(attribute.name, attribute.value);
|
|
91
|
+
if (map) map.replaceWrapper(element, replacement);
|
|
92
|
+
else {
|
|
93
|
+
replacement.append(...element.childNodes);
|
|
94
|
+
element.replaceWith(replacement);
|
|
95
|
+
}
|
|
96
|
+
changed.push(replacement);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (map) map.unwrap(element);
|
|
100
|
+
else element.replaceWith(...element.childNodes);
|
|
101
|
+
changed.push(element);
|
|
102
|
+
}
|
|
103
|
+
return changed;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// `classes` narrows the class attribute to known names without touching the
|
|
107
|
+
// security policy: an application declares its content classes once and
|
|
108
|
+
// foreign ones do not survive external input.
|
|
109
|
+
clean(root, {base, classes = null, preserve = null} = {}) {
|
|
110
|
+
const known = classes === null ? null : new Set(classes);
|
|
111
|
+
for (const element of descendants(root)) {
|
|
112
|
+
if (preserve?.has(element)) continue;
|
|
113
|
+
for (const attribute of [...element.attributes]) {
|
|
114
|
+
if (!this.allowsAttribute(element, attribute.name)
|
|
115
|
+
|| !this.allowsUrl(element, attribute.name, attribute.value, base)) {
|
|
116
|
+
element.removeAttributeNode(attribute);
|
|
117
|
+
} else if (known && attribute.name.toLowerCase() === 'class') {
|
|
118
|
+
keepClasses(element, known);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return root;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const sanitizePolicy = new SanitizePolicy();
|
|
127
|
+
|
|
128
|
+
function names(values, pattern, label) {
|
|
129
|
+
if (!values || typeof values[Symbol.iterator] !== 'function' || typeof values === 'string') {
|
|
130
|
+
throw new TypeError(`${label} names must be an iterable`);
|
|
131
|
+
}
|
|
132
|
+
const result = [];
|
|
133
|
+
for (const value of values) {
|
|
134
|
+
const name = String(value).toLowerCase();
|
|
135
|
+
if (!pattern.test(name)) throw new TypeError(`Invalid ${label} name: ${value}`);
|
|
136
|
+
if (!result.includes(name)) result.push(name);
|
|
137
|
+
}
|
|
138
|
+
return Object.freeze(result);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function groups(value, pattern, label) {
|
|
142
|
+
if (!plainObject(value)) throw new TypeError(`${label} groups must be an object`);
|
|
143
|
+
const result = {};
|
|
144
|
+
for (const [name, values] of Object.entries(value)) {
|
|
145
|
+
const key = name === '*' ? name : names([name], NAME, 'element')[0];
|
|
146
|
+
result[key] = names(values, pattern, label);
|
|
147
|
+
}
|
|
148
|
+
return Object.freeze(result);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function protocolGroups(value) {
|
|
152
|
+
if (!plainObject(value)) throw new TypeError('protocol groups must be an object');
|
|
153
|
+
const result = {};
|
|
154
|
+
for (const [element, attributes] of Object.entries(value)) {
|
|
155
|
+
const name = element === '*' ? element : names([element], NAME, 'element')[0];
|
|
156
|
+
if (!plainObject(attributes)) throw new TypeError('protocol attributes must be an object');
|
|
157
|
+
const rules = {};
|
|
158
|
+
for (const [attribute, protocols] of Object.entries(attributes)) {
|
|
159
|
+
const attributeName = names([attribute], ATTRIBUTE, 'attribute')[0];
|
|
160
|
+
const values = names(protocols, PROTOCOL, 'protocol');
|
|
161
|
+
rules[attributeName] = values;
|
|
162
|
+
}
|
|
163
|
+
result[name] = Object.freeze(rules);
|
|
164
|
+
}
|
|
165
|
+
return Object.freeze(result);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function urlProtocol(value, base) {
|
|
169
|
+
const input = String(value).trim();
|
|
170
|
+
const compact = input.replace(/[\u0000-\u0020]/g, '');
|
|
171
|
+
if (!/^[a-z][a-z\d+.-]*:/i.test(compact)) return 'relative';
|
|
172
|
+
try {
|
|
173
|
+
return new URL(input, base).protocol.slice(0, -1).toLowerCase();
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function keepClasses(element, known) {
|
|
180
|
+
const kept = [...element.classList].filter(name => known.has(name));
|
|
181
|
+
if (kept.length === element.classList.length) return;
|
|
182
|
+
if (kept.length) element.setAttribute('class', kept.join(' '));
|
|
183
|
+
else element.removeAttribute('class');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function descendants(root) {
|
|
187
|
+
const result = root.nodeType === Node.ELEMENT_NODE ? [root] : [];
|
|
188
|
+
return result.concat([...root.querySelectorAll('*')]);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function unique(values) {
|
|
192
|
+
return [...new Set(values)];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function boolean(value, name) {
|
|
196
|
+
if (typeof value !== 'boolean') throw new TypeError(`${name} must be a boolean`);
|
|
197
|
+
return value;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function plainObject(value) {
|
|
201
|
+
return value && typeof value === 'object' && !Array.isArray(value);
|
|
202
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Selection snapshots
|
|
2
|
+
|
|
3
|
+
A selection snapshot remembers the current caret or selected text so the editor
|
|
4
|
+
can restore it after focus moves to a toolbar or the DOM changes. It also keeps
|
|
5
|
+
the selection direction, which matters when the user selected backward.
|
|
6
|
+
|
|
7
|
+
`snapshot.js` stores a live cloned browser `Range` plus that direction. Native
|
|
8
|
+
ranges follow many DOM mutations automatically, so no temporary marker elements
|
|
9
|
+
are inserted into the document.
|
|
10
|
+
|
|
11
|
+
## Contract
|
|
12
|
+
|
|
13
|
+
- `SelectionSnapshot.capture(selection, root)` returns `null` unless both
|
|
14
|
+
endpoints belong to the same explicit editable host.
|
|
15
|
+
- The snapshot retains collapsed, forward, and backward selections.
|
|
16
|
+
- `range()` always returns a clone; callers cannot mutate stored boundaries.
|
|
17
|
+
- `equals()` compares exact boundaries, root, and direction without serializing
|
|
18
|
+
DOM paths.
|
|
19
|
+
- `restore()` uses `Selection.setBaseAndExtent()` where available so direction
|
|
20
|
+
is not lost.
|
|
21
|
+
- `valid()` rejects boundaries moved outside the original editable host.
|
|
22
|
+
- Nested `contenteditable` elements are independent selection boundaries.
|
|
23
|
+
|
|
24
|
+
Live ranges are the first preservation strategy. Editor-owned transformations
|
|
25
|
+
that replace endpoints use `map/point-map.js` to map logical points explicitly
|
|
26
|
+
rather than inserting temporary DOM markers.
|
|
27
|
+
|
|
28
|
+
`elementOf(node)` and `isEditingBoundary(element)` are the single definitions of
|
|
29
|
+
"the element context of a node" and "this element starts a new editing host".
|
|
30
|
+
Every traversal in the engine uses them, so an invalid `contenteditable` value is
|
|
31
|
+
a boundary nowhere rather than in some traversals only. `indexOf(node)` names a
|
|
32
|
+
child boundary and lives beside `Point`.
|
|
33
|
+
|
|
34
|
+
## TODO
|
|
35
|
+
|
|
36
|
+
- Integrate point mapping with canonical normalization transactions.
|
|
37
|
+
- Define affinity at ambiguous inline and atomic-element boundaries.
|
|
38
|
+
- Cover composed ranges and browser-specific Shadow DOM selection behavior.
|
|
39
|
+
- Preserve multi-range selections if browsers expose an interoperable model.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Explicit point mapping
|
|
2
|
+
|
|
3
|
+
A point is a place where a caret or one edge of a selection can sit. When an
|
|
4
|
+
editor command splits text, wraps nodes, or moves content, that place must move
|
|
5
|
+
with the intended content. Point mapping performs the DOM change and calculates
|
|
6
|
+
the new place together.
|
|
7
|
+
|
|
8
|
+
`point-map.js` tracks `Point` objects through these editor-owned operations. At
|
|
9
|
+
an ambiguous boundary it uses the point's affinity to decide whether the point
|
|
10
|
+
follows the content before or after the change. It needs no marker nodes.
|
|
11
|
+
|
|
12
|
+
## Contract
|
|
13
|
+
|
|
14
|
+
- A map snapshots each point when it is added and returns a fresh mapped point
|
|
15
|
+
through `get()`; it never mutates the caller's point.
|
|
16
|
+
- `backward` affinity follows preceding content and `forward` affinity follows
|
|
17
|
+
following content at insertion, split, wrap, and move boundaries.
|
|
18
|
+
- Operations mutate the DOM and its mapped boundaries as one synchronous step.
|
|
19
|
+
- `insert`, `insertFragment`, `insertText`, `splitText`, `wrap`, `unwrap`,
|
|
20
|
+
`split`, `replace`, `replaceWrapper`, `move`, `mergeText`, and `remove`
|
|
21
|
+
reject ambiguous or structurally invalid inputs.
|
|
22
|
+
- `insertFragment` consumes a detached fragment as one multi-node insertion.
|
|
23
|
+
Parent boundaries shift by the inserted child count; boundaries expressed on
|
|
24
|
+
the fragment become equivalent parent boundaries among the inserted nodes.
|
|
25
|
+
Native insertion adopts nodes from a template or another document while
|
|
26
|
+
preserving their identity and mapped descendant points.
|
|
27
|
+
- `insertText` uses DOM UTF-16 offsets and affinity at the insertion point:
|
|
28
|
+
backward stays before the inserted text, forward follows it.
|
|
29
|
+
- `split` raises one boundary to a child boundary of an ancestor container by
|
|
30
|
+
splitting every element between them, and returns the resulting child offset.
|
|
31
|
+
Both halves together replace one element: no point ends up between them, and
|
|
32
|
+
every point from the boundary onward moves into the trailing half. Trailing
|
|
33
|
+
clones keep their attributes except duplicate-prone `id`. Whether the
|
|
34
|
+
container may be split at all is the caller's policy, not the map's.
|
|
35
|
+
- `replace` discards a subtree and collapses its points around the replacement.
|
|
36
|
+
`replaceWrapper` changes only an element's wrapper and preserves children
|
|
37
|
+
and points inside them.
|
|
38
|
+
- `move` preserves affinity-bound positions adjacent to the moved node, even
|
|
39
|
+
across parents.
|
|
40
|
+
- Wrapping accepts only a non-empty, contiguous, ordered sibling sequence and
|
|
41
|
+
an empty detached wrapper.
|
|
42
|
+
|
|
43
|
+
The class deliberately performs only mechanical DOM operations. HTML validity,
|
|
44
|
+
editable boundaries, atomic elements, cleanup policy, and dirty-scope selection
|
|
45
|
+
belong to the content model and transaction layers.
|
|
46
|
+
|
|
47
|
+
## TODO
|
|
48
|
+
|
|
49
|
+
- Map ranges directly without introducing a dependency from this layer to
|
|
50
|
+
`EditRange`.
|
|
51
|
+
- Extend the generated cases to `remove`, `replace`, and `move`, whose result
|
|
52
|
+
cannot be checked against the surrounding text.
|
|
53
|
+
- Compare longer operation sequences with a marker-based oracle.
|