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.
Files changed (142) hide show
  1. package/el/ico/ico.js +5 -5
  2. package/el/rte/README.md +53 -13
  3. package/el/rte/manifest.json +19 -4
  4. package/el/rte/rte.css +31 -6
  5. package/el/rte/rte.js +174 -44
  6. package/el/rte0/README.md +37 -0
  7. package/el/rte0/manifest.json +33 -0
  8. package/el/rte0/rte0.css +9 -0
  9. package/el/rte0/rte0.js +56 -0
  10. package/el/system/styler.js +2 -0
  11. package/el/tooltip/tooltip.css +2 -2
  12. package/js/Placer/Placer.js +2 -1
  13. package/js/rte/AGENTS.md +142 -0
  14. package/js/rte/PLAN.md +488 -0
  15. package/js/rte/README.md +319 -25
  16. package/js/rte/blocks.js +7 -0
  17. package/js/rte/breaks.js +6 -0
  18. package/js/rte/classes.js +7 -0
  19. package/js/rte/docs/usage.md +845 -0
  20. package/js/rte/images.js +7 -0
  21. package/js/rte/link.js +8 -0
  22. package/js/rte/playground/README.md +95 -0
  23. package/js/rte/playground/hosts.html +518 -0
  24. package/js/rte/playground/index.html +824 -0
  25. package/js/rte/rte.js +56 -5
  26. package/js/rte/source.js +12 -0
  27. package/js/rte/src/browser/README.md +40 -0
  28. package/js/rte/src/browser/range-rect.js +82 -0
  29. package/js/rte/src/client/README.md +378 -0
  30. package/js/rte/src/client/blocks.js +38 -0
  31. package/js/rte/src/client/breaks.js +212 -0
  32. package/js/rte/src/client/classes.js +48 -0
  33. package/js/rte/src/client/editor.js +461 -0
  34. package/js/rte/src/client/external-input.js +47 -0
  35. package/js/rte/src/client/history.js +13 -0
  36. package/js/rte/src/client/images.js +275 -0
  37. package/js/rte/src/client/link.js +572 -0
  38. package/js/rte/src/client/marks.js +25 -0
  39. package/js/rte/src/client/source.js +182 -0
  40. package/js/rte/src/client/structure.js +27 -0
  41. package/js/rte/src/client/tables.js +162 -0
  42. package/js/rte/src/client/unstyle.js +32 -0
  43. package/js/rte/src/command/README.md +517 -0
  44. package/js/rte/src/command/block-boundary.js +43 -0
  45. package/js/rte/src/command/block-style.js +120 -0
  46. package/js/rte/src/command/commands.js +158 -0
  47. package/js/rte/src/command/delete.js +112 -0
  48. package/js/rte/src/command/edit.js +72 -0
  49. package/js/rte/src/command/element.js +45 -0
  50. package/js/rte/src/command/enter.js +175 -0
  51. package/js/rte/src/command/fragment.js +76 -0
  52. package/js/rte/src/command/insert.js +52 -0
  53. package/js/rte/src/command/list.js +284 -0
  54. package/js/rte/src/command/mark.js +583 -0
  55. package/js/rte/src/command/pending-marks.js +216 -0
  56. package/js/rte/src/command/table.js +207 -0
  57. package/js/rte/src/command/unstyle.js +248 -0
  58. package/js/rte/src/config/README.md +96 -0
  59. package/js/rte/src/config/config.js +127 -0
  60. package/js/rte/src/core/README.md +41 -0
  61. package/js/rte/src/core/core.js +156 -0
  62. package/js/rte/src/history/README.md +83 -0
  63. package/js/rte/src/history/history.js +283 -0
  64. package/js/rte/src/input/README.md +155 -0
  65. package/js/rte/src/input/external-input.js +130 -0
  66. package/js/rte/src/input/input-pipeline.js +414 -0
  67. package/js/rte/src/mark/README.md +219 -0
  68. package/js/rte/src/mark/dom-adapter.js +100 -0
  69. package/js/rte/src/mark/mark.js +133 -0
  70. package/js/rte/src/mark/standard.js +76 -0
  71. package/js/rte/src/model/README.md +71 -0
  72. package/js/rte/src/model/content-model.js +213 -0
  73. package/js/rte/src/model/html/README.md +51 -0
  74. package/js/rte/src/model/html/html-model.js +90 -0
  75. package/js/rte/src/normalize/execute/README.md +33 -0
  76. package/js/rte/src/normalize/execute/repair-executor.js +129 -0
  77. package/js/rte/src/normalize/normalizer/README.md +48 -0
  78. package/js/rte/src/normalize/normalizer/normalizer.js +141 -0
  79. package/js/rte/src/normalize/repair/README.md +70 -0
  80. package/js/rte/src/normalize/repair/repair-planner.js +208 -0
  81. package/js/rte/src/sanitize/README.md +105 -0
  82. package/js/rte/src/sanitize/native.js +43 -0
  83. package/js/rte/src/sanitize/policy.js +202 -0
  84. package/js/rte/src/selection/README.md +39 -0
  85. package/js/rte/src/selection/map/README.md +53 -0
  86. package/js/rte/src/selection/map/point-map.js +307 -0
  87. package/js/rte/src/selection/ownership/README.md +32 -0
  88. package/js/rte/src/selection/ownership/ownership.js +42 -0
  89. package/js/rte/src/selection/point/README.md +34 -0
  90. package/js/rte/src/selection/point/point.js +83 -0
  91. package/js/rte/src/selection/range/README.md +38 -0
  92. package/js/rte/src/selection/range/edit-range.js +223 -0
  93. package/js/rte/src/selection/snapshot.js +64 -0
  94. package/js/rte/src/source/README.md +64 -0
  95. package/js/rte/src/source/source.js +193 -0
  96. package/js/rte/src/surface/README.md +36 -0
  97. package/js/rte/src/surface/content.js +16 -0
  98. package/js/rte/src/surface/surface.js +87 -0
  99. package/js/rte/src/transaction/README.md +35 -0
  100. package/js/rte/src/transaction/transaction.js +56 -0
  101. package/js/rte/src/ui/README.md +255 -0
  102. package/js/rte/src/ui/chrome.js +214 -0
  103. package/js/rte/src/ui/handles.js +179 -0
  104. package/js/rte/src/ui/place.js +25 -0
  105. package/js/rte/src/ui/toolbar.js +283 -0
  106. package/js/rte/src/unstyle/README.md +103 -0
  107. package/js/rte/src/unstyle/unstyle.js +140 -0
  108. package/js/rte/tables.js +7 -0
  109. package/js/rte/unstyle.js +8 -0
  110. package/js/rte0/README.md +37 -0
  111. package/js/rte0/rte.js +7 -0
  112. package/js/{rte → rte0}/src/behavior.js +0 -1
  113. package/js/{rte → rte0}/src/events.js +0 -1
  114. package/js/{rte → rte0}/src/range.js +0 -2
  115. package/js/{rte → rte0}/src/ui.items.js +0 -2
  116. package/js/{rte → rte0}/src/ui.js +0 -1
  117. package/package.json +1 -1
  118. package/u2/Element.js +2 -2
  119. package/u2/plan-scoped-refactoring.md +133 -303
  120. package/u2/projects.json +18 -2
  121. package/u2/tools/update.repos.json.js +1 -1
  122. package/el/qrcode/package.json +0 -22
  123. /package/js/{rte → rte0}/old/NodeCleaner.js +0 -0
  124. /package/js/{rte → rte0}/old/Rte/Rte.mjs +0 -0
  125. /package/js/{rte → rte0}/old/Rte/Rte.ui.items.mjs +0 -0
  126. /package/js/{rte → rte0}/old/Rte/Rte.ui.mjs +0 -0
  127. /package/js/{rte → rte0}/old/Rte/crossbrowser.mjs +0 -0
  128. /package/js/{rte → rte0}/old/Rte/font/tinymce.svg +0 -0
  129. /package/js/{rte → rte0}/old/Rte/font/tinymce.woff +0 -0
  130. /package/js/{rte → rte0}/old/Rte/helpers.mjs +0 -0
  131. /package/js/{rte → rte0}/old/Rte/htmlparser.mjs +0 -0
  132. /package/js/{rte → rte0}/old/Rte/index.mjs +0 -0
  133. /package/js/{rte → rte0}/old/Rte/main.css +0 -0
  134. /package/js/{rte → rte0}/old/old.rte.js +0 -0
  135. /package/js/{rte → rte0}/old/range-selection.old.js +0 -0
  136. /package/js/{rte → rte0}/src/fixes.js +0 -0
  137. /package/js/{rte → rte0}/src/rte.js +0 -0
  138. /package/js/{rte → rte0}/src/ui/items/ai.js +0 -0
  139. /package/js/{rte → rte0}/src/ui/items/code.js +0 -0
  140. /package/js/{rte → rte0}/src/ui/items/undo.js +0 -0
  141. /package/js/{rte → rte0}/src/util/htmlparser.js +0 -0
  142. /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.