u2ui 1.5.3 → 1.5.7

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 (146) 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 +487 -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 +553 -0
  24. package/js/rte/playground/index.html +835 -0
  25. package/js/rte/rte.js +56 -5
  26. package/js/rte/run.out +17 -0
  27. package/js/rte/source.js +12 -0
  28. package/js/rte/src/browser/README.md +40 -0
  29. package/js/rte/src/browser/interactive.js +17 -0
  30. package/js/rte/src/browser/range-rect.js +82 -0
  31. package/js/rte/src/client/README.md +385 -0
  32. package/js/rte/src/client/blocks.js +38 -0
  33. package/js/rte/src/client/breaks.js +212 -0
  34. package/js/rte/src/client/classes.js +48 -0
  35. package/js/rte/src/client/editor.js +472 -0
  36. package/js/rte/src/client/external-input.js +47 -0
  37. package/js/rte/src/client/history.js +13 -0
  38. package/js/rte/src/client/images.js +285 -0
  39. package/js/rte/src/client/link.js +567 -0
  40. package/js/rte/src/client/marks.js +25 -0
  41. package/js/rte/src/client/source.js +182 -0
  42. package/js/rte/src/client/structure.js +27 -0
  43. package/js/rte/src/client/tables.js +165 -0
  44. package/js/rte/src/client/unstyle.js +32 -0
  45. package/js/rte/src/command/README.md +520 -0
  46. package/js/rte/src/command/block-boundary.js +43 -0
  47. package/js/rte/src/command/block-style.js +120 -0
  48. package/js/rte/src/command/commands.js +159 -0
  49. package/js/rte/src/command/delete.js +119 -0
  50. package/js/rte/src/command/edit.js +78 -0
  51. package/js/rte/src/command/element.js +45 -0
  52. package/js/rte/src/command/enter.js +175 -0
  53. package/js/rte/src/command/fragment.js +76 -0
  54. package/js/rte/src/command/insert.js +52 -0
  55. package/js/rte/src/command/list.js +284 -0
  56. package/js/rte/src/command/mark.js +583 -0
  57. package/js/rte/src/command/pending-marks.js +216 -0
  58. package/js/rte/src/command/table.js +207 -0
  59. package/js/rte/src/command/unstyle.js +248 -0
  60. package/js/rte/src/config/README.md +96 -0
  61. package/js/rte/src/config/config.js +134 -0
  62. package/js/rte/src/core/README.md +63 -0
  63. package/js/rte/src/core/core.js +247 -0
  64. package/js/rte/src/history/README.md +83 -0
  65. package/js/rte/src/history/history.js +283 -0
  66. package/js/rte/src/input/README.md +173 -0
  67. package/js/rte/src/input/external-input.js +130 -0
  68. package/js/rte/src/input/input-pipeline.js +459 -0
  69. package/js/rte/src/mark/README.md +219 -0
  70. package/js/rte/src/mark/dom-adapter.js +100 -0
  71. package/js/rte/src/mark/mark.js +133 -0
  72. package/js/rte/src/mark/standard.js +76 -0
  73. package/js/rte/src/model/README.md +71 -0
  74. package/js/rte/src/model/content-model.js +213 -0
  75. package/js/rte/src/model/html/README.md +51 -0
  76. package/js/rte/src/model/html/html-model.js +90 -0
  77. package/js/rte/src/normalize/execute/README.md +33 -0
  78. package/js/rte/src/normalize/execute/repair-executor.js +129 -0
  79. package/js/rte/src/normalize/normalizer/README.md +48 -0
  80. package/js/rte/src/normalize/normalizer/normalizer.js +141 -0
  81. package/js/rte/src/normalize/repair/README.md +70 -0
  82. package/js/rte/src/normalize/repair/repair-planner.js +208 -0
  83. package/js/rte/src/sanitize/README.md +108 -0
  84. package/js/rte/src/sanitize/native.js +43 -0
  85. package/js/rte/src/sanitize/policy.js +206 -0
  86. package/js/rte/src/selection/README.md +39 -0
  87. package/js/rte/src/selection/map/README.md +53 -0
  88. package/js/rte/src/selection/map/point-map.js +307 -0
  89. package/js/rte/src/selection/ownership/README.md +32 -0
  90. package/js/rte/src/selection/ownership/ownership.js +42 -0
  91. package/js/rte/src/selection/point/README.md +34 -0
  92. package/js/rte/src/selection/point/point.js +83 -0
  93. package/js/rte/src/selection/range/README.md +38 -0
  94. package/js/rte/src/selection/range/edit-range.js +223 -0
  95. package/js/rte/src/selection/snapshot.js +64 -0
  96. package/js/rte/src/source/README.md +64 -0
  97. package/js/rte/src/source/source.js +193 -0
  98. package/js/rte/src/surface/README.md +36 -0
  99. package/js/rte/src/surface/content.js +16 -0
  100. package/js/rte/src/surface/surface.js +87 -0
  101. package/js/rte/src/transaction/README.md +35 -0
  102. package/js/rte/src/transaction/transaction.js +56 -0
  103. package/js/rte/src/ui/README.md +271 -0
  104. package/js/rte/src/ui/caret.js +17 -0
  105. package/js/rte/src/ui/chrome.js +214 -0
  106. package/js/rte/src/ui/handles.js +179 -0
  107. package/js/rte/src/ui/highlight.js +86 -0
  108. package/js/rte/src/ui/place.js +25 -0
  109. package/js/rte/src/ui/toolbar.js +258 -0
  110. package/js/rte/src/unstyle/README.md +103 -0
  111. package/js/rte/src/unstyle/unstyle.js +140 -0
  112. package/js/rte/tables.js +7 -0
  113. package/js/rte/unstyle.js +8 -0
  114. package/js/rte0/README.md +37 -0
  115. package/js/rte0/rte.js +7 -0
  116. package/js/{rte → rte0}/src/behavior.js +0 -1
  117. package/js/{rte → rte0}/src/events.js +0 -1
  118. package/js/{rte → rte0}/src/range.js +0 -2
  119. package/js/{rte → rte0}/src/ui.items.js +0 -2
  120. package/js/{rte → rte0}/src/ui.js +0 -1
  121. package/package.json +1 -1
  122. package/u2/Element.js +2 -2
  123. package/u2/plan-scoped-refactoring.md +133 -303
  124. package/u2/projects.json +18 -2
  125. package/u2/tools/update.repos.json.js +1 -1
  126. package/el/qrcode/package.json +0 -22
  127. /package/js/{rte → rte0}/old/NodeCleaner.js +0 -0
  128. /package/js/{rte → rte0}/old/Rte/Rte.mjs +0 -0
  129. /package/js/{rte → rte0}/old/Rte/Rte.ui.items.mjs +0 -0
  130. /package/js/{rte → rte0}/old/Rte/Rte.ui.mjs +0 -0
  131. /package/js/{rte → rte0}/old/Rte/crossbrowser.mjs +0 -0
  132. /package/js/{rte → rte0}/old/Rte/font/tinymce.svg +0 -0
  133. /package/js/{rte → rte0}/old/Rte/font/tinymce.woff +0 -0
  134. /package/js/{rte → rte0}/old/Rte/helpers.mjs +0 -0
  135. /package/js/{rte → rte0}/old/Rte/htmlparser.mjs +0 -0
  136. /package/js/{rte → rte0}/old/Rte/index.mjs +0 -0
  137. /package/js/{rte → rte0}/old/Rte/main.css +0 -0
  138. /package/js/{rte → rte0}/old/old.rte.js +0 -0
  139. /package/js/{rte → rte0}/old/range-selection.old.js +0 -0
  140. /package/js/{rte → rte0}/src/fixes.js +0 -0
  141. /package/js/{rte → rte0}/src/rte.js +0 -0
  142. /package/js/{rte → rte0}/src/ui/items/ai.js +0 -0
  143. /package/js/{rte → rte0}/src/ui/items/code.js +0 -0
  144. /package/js/{rte → rte0}/src/ui/items/undo.js +0 -0
  145. /package/js/{rte → rte0}/src/util/htmlparser.js +0 -0
  146. /package/js/{rte → rte0}/src/util/indentHtml.js +0 -0
@@ -0,0 +1,100 @@
1
+ import {Mark, MarkType} from './mark.js';
2
+
3
+ const TRUE = () => true;
4
+
5
+ export class MarkAdapter {
6
+ #type;
7
+ #selector;
8
+ #read;
9
+ #render;
10
+ #write;
11
+ #clear;
12
+ #reuse;
13
+
14
+ constructor(type, {selector, tag, read = TRUE, write, clear, reuse = false, render} = {}) {
15
+ if (!(type instanceof MarkType)) throw new TypeError('A mark adapter requires a mark type');
16
+ if (typeof selector !== 'string' || !selector.trim()) {
17
+ throw new TypeError('A mark adapter requires a non-empty selector');
18
+ }
19
+ if (typeof read !== 'function') throw new TypeError('A mark reader must be a function');
20
+ if (write !== undefined && typeof write !== 'function') throw new TypeError('A mark writer must be a function');
21
+ if (clear !== undefined && typeof clear !== 'function') throw new TypeError('A mark clearer must be a function');
22
+ if (typeof reuse !== 'boolean' && typeof reuse !== 'function') {
23
+ throw new TypeError('Mark reuse must be a boolean or function');
24
+ }
25
+ if (reuse && !write) throw new TypeError('A reusable mark adapter requires write');
26
+ if (render !== undefined && typeof render !== 'function') throw new TypeError('A mark renderer must be a function');
27
+ if (render && tag !== undefined) throw new TypeError('A custom mark renderer cannot be combined with tag');
28
+ if (!render && (typeof tag !== 'string' || !/^[a-z][a-z0-9-]*$/.test(tag))) {
29
+ throw new TypeError('A mark adapter requires a lowercase HTML tag');
30
+ }
31
+ this.#type = type;
32
+ this.#selector = selector.trim();
33
+ this.#read = read;
34
+ this.#write = write;
35
+ this.#clear = clear;
36
+ this.#reuse = reuse;
37
+ this.#render = render || ((document, value) => {
38
+ const element = document.createElement(tag);
39
+ write?.(element, value);
40
+ return element;
41
+ });
42
+ }
43
+
44
+ get type() { return this.#type; }
45
+ get selector() { return this.#selector; }
46
+ get reusable() { return !!this.#reuse; }
47
+ get removable() { return !!this.#clear; }
48
+
49
+ parse(element) {
50
+ assertElement(element);
51
+ if (!element.matches(this.#selector)) return null;
52
+ const value = this.#read(element);
53
+ return value === undefined ? null : this.#type.create(value);
54
+ }
55
+
56
+ canReuse(element) {
57
+ assertElement(element);
58
+ return this.#reuse === true || !!this.#reuse && !!this.#reuse(element);
59
+ }
60
+
61
+ apply(element, mark) {
62
+ this.#assertMark(mark);
63
+ assertElement(element);
64
+ if (!this.#write) throw new TypeError('This mark adapter cannot decorate an existing element');
65
+ this.#write(element, mark.value);
66
+ return element;
67
+ }
68
+
69
+ clear(element, mark) {
70
+ this.#assertMark(mark);
71
+ assertElement(element);
72
+ if (!this.#clear) throw new TypeError('This mark adapter cannot remove its formatting');
73
+ const unwrap = this.#clear(element, mark.value) === true;
74
+ for (const name of ['class', 'style']) {
75
+ if (!element.getAttribute(name)?.trim()) element.removeAttribute(name);
76
+ }
77
+ return unwrap || element.localName === 'span' && !element.attributes.length;
78
+ }
79
+
80
+ render(mark, document) {
81
+ this.#assertMark(mark);
82
+ if (document?.nodeType !== Node.DOCUMENT_NODE) throw new TypeError('Mark rendering requires a document');
83
+ const element = this.#render(document, mark.value);
84
+ assertElement(element);
85
+ if (element.ownerDocument !== document || element.parentNode || element.childNodes.length) {
86
+ throw new TypeError('A rendered mark must be an empty detached element from the requested document');
87
+ }
88
+ return element;
89
+ }
90
+
91
+ #assertMark(mark) {
92
+ if (!(mark instanceof Mark) || mark.type !== this.#type) {
93
+ throw new TypeError('A mark adapter can only use its own mark type');
94
+ }
95
+ }
96
+ }
97
+
98
+ function assertElement(element) {
99
+ if (element?.nodeType !== Node.ELEMENT_NODE) throw new TypeError('A mark adapter requires an element');
100
+ }
@@ -0,0 +1,133 @@
1
+ const DEFAULT = Symbol('default mark value');
2
+ const EMPTY = Object.freeze([]);
3
+
4
+ export function markSet(marks) {
5
+ assertSet(marks);
6
+ let set = EMPTY;
7
+ for (const mark of marks) set = mark.add(set);
8
+ return set;
9
+ }
10
+
11
+ export class MarkType {
12
+ #name;
13
+ #rank;
14
+ #excludes;
15
+
16
+ constructor(name, {rank = 50, excludes = [name]} = {}) {
17
+ this.#name = nameOf(name, 'A mark type');
18
+ if (!Number.isFinite(rank)) throw new TypeError('A mark rank must be a finite number');
19
+ this.#rank = rank;
20
+ this.#excludes = names(excludes);
21
+ }
22
+
23
+ get name() { return this.#name; }
24
+ get rank() { return this.#rank; }
25
+ get exclusions() { return this.#excludes; }
26
+
27
+ create(...values) {
28
+ return new Mark(this, values.length ? values[0] : DEFAULT);
29
+ }
30
+
31
+ excludes(type) {
32
+ if (!(type instanceof MarkType)) throw new TypeError('A mark type can only exclude another mark type');
33
+ return this.#excludes.includes('*') || this.#excludes.includes(type.name);
34
+ }
35
+
36
+ remove(marks) {
37
+ assertSet(marks);
38
+ const kept = marks.filter(mark => mark.type !== this);
39
+ return kept.length === marks.length ? marks : Object.freeze(kept);
40
+ }
41
+ }
42
+
43
+ export class Mark {
44
+ #type;
45
+ #value;
46
+ #key;
47
+
48
+ constructor(type, ...values) {
49
+ if (!(type instanceof MarkType)) throw new TypeError('A mark requires a mark type');
50
+ this.#type = type;
51
+ const value = values.length ? values[0] : DEFAULT;
52
+ this.#value = data(value === DEFAULT ? true : value);
53
+ this.#key = JSON.stringify(this.#value);
54
+ Object.freeze(this);
55
+ }
56
+
57
+ get type() { return this.#type; }
58
+ get value() { return this.#value; }
59
+
60
+ equals(mark) {
61
+ return mark instanceof Mark && this.#type === mark.type && this.#key === mark.#key;
62
+ }
63
+
64
+ conflicts(mark) {
65
+ assertMark(mark);
66
+ return this.#type.excludes(mark.type) || mark.type.excludes(this.#type);
67
+ }
68
+
69
+ add(marks) {
70
+ assertSet(marks);
71
+ if (marks.some(mark => this.equals(mark))) return marks;
72
+ if (marks.some(mark => !this.#type.excludes(mark.type) && mark.type.excludes(this.#type))) return marks;
73
+ const added = marks.filter(mark => !this.#type.excludes(mark.type));
74
+ const at = added.findIndex(mark => {
75
+ const order = this.#type.rank - mark.type.rank
76
+ || compare(this.#type.name, mark.type.name)
77
+ || compare(this.#key, mark.#key);
78
+ return order < 0;
79
+ });
80
+ at < 0 ? added.push(this) : added.splice(at, 0, this);
81
+ return Object.freeze(added);
82
+ }
83
+
84
+ remove(marks) {
85
+ assertSet(marks);
86
+ const kept = marks.filter(mark => !this.equals(mark));
87
+ return kept.length === marks.length ? marks : Object.freeze(kept);
88
+ }
89
+ }
90
+
91
+ function data(value, parents = new Set()) {
92
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
93
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
94
+ if (typeof value !== 'object' || !plain(value)) throw new TypeError('A mark value must be serializable data');
95
+ if (parents.has(value)) throw new TypeError('A mark value cannot be cyclic');
96
+ parents.add(value);
97
+ if (Object.getOwnPropertySymbols(value).length) throw new TypeError('A mark value cannot have symbol keys');
98
+ const copy = Array.isArray(value)
99
+ ? Array.from(value, item => data(item, parents))
100
+ : Object.fromEntries(Object.keys(value).sort().map(key => [key, data(value[key], parents)]));
101
+ parents.delete(value);
102
+ return Object.freeze(copy);
103
+ }
104
+
105
+ function plain(value) {
106
+ if (Array.isArray(value)) return true;
107
+ const prototype = Object.getPrototypeOf(value);
108
+ return prototype === null || prototype?.constructor?.name === 'Object';
109
+ }
110
+
111
+ function compare(a, b) {
112
+ return a < b ? -1 : a > b ? 1 : 0;
113
+ }
114
+
115
+ function names(value) {
116
+ if (!Array.isArray(value)) throw new TypeError('Mark exclusions must be an array of names');
117
+ return Object.freeze([...new Set(value.map(name => name === '*' ? name : nameOf(name, 'A mark exclusion')))]);
118
+ }
119
+
120
+ function nameOf(value, subject) {
121
+ if (typeof value !== 'string' || !value.trim()) throw new TypeError(`${subject} must have a non-empty name`);
122
+ return value.trim();
123
+ }
124
+
125
+ function assertSet(marks) {
126
+ if (!Array.isArray(marks) || marks.some(mark => !(mark instanceof Mark))) {
127
+ throw new TypeError('A mark set must be an array of marks');
128
+ }
129
+ }
130
+
131
+ function assertMark(mark) {
132
+ if (!(mark instanceof Mark)) throw new TypeError('A mark is required');
133
+ }
@@ -0,0 +1,76 @@
1
+ import {MarkAdapter} from './dom-adapter.js';
2
+ import {MarkType} from './mark.js';
3
+
4
+ const LINK_ATTRIBUTES = ['href', 'target', 'rel', 'title'];
5
+
6
+ export const bold = new MarkType('bold');
7
+ export const italic = new MarkType('italic');
8
+ export const underline = new MarkType('underline');
9
+ export const strike = new MarkType('strike');
10
+ export const code = new MarkType('code');
11
+ export const link = new MarkType('link');
12
+
13
+ export const boldHtml = element(bold, 'strong, b', 'strong');
14
+ export const italicHtml = element(italic, 'em, i', 'em');
15
+ export const underlineHtml = element(underline, 'u', 'u');
16
+ export const strikeHtml = element(strike, 's, strike', 's');
17
+ export const codeHtml = element(code, 'code', 'code');
18
+ export const linkHtml = new MarkAdapter(link, {
19
+ selector: 'a[href]',
20
+ tag: 'a',
21
+ read: readLink,
22
+ write: writeLink,
23
+ clear: clearLink,
24
+ });
25
+
26
+ // One closed group of content classes as a single mark. Its value is the class
27
+ // name, so a value command drives it and the group stays mutually exclusive; an
28
+ // application that needs independent axes builds a second adapter.
29
+ export function classMark(names, {type = new MarkType('class'), tag = 'span'} = {}) {
30
+ const known = [...new Set(names)];
31
+ if (!known.length || known.some(name => typeof name !== 'string' || !name.trim())) {
32
+ throw new TypeError('A class mark requires class names');
33
+ }
34
+ return new MarkAdapter(type, {
35
+ selector: known.map(name => `.${CSS.escape(name)}`).join(','),
36
+ tag,
37
+ reuse: true,
38
+ read: element => known.find(name => element.classList.contains(name)),
39
+ write: (element, value) => {
40
+ if (!known.includes(value)) throw new RangeError(`Unknown class mark: ${value}`);
41
+ for (const name of known) element.classList.remove(name);
42
+ element.classList.add(value);
43
+ },
44
+ clear(element) {
45
+ for (const name of known) element.classList.remove(name);
46
+ if (!element.classList.length) element.removeAttribute('class');
47
+ return true;
48
+ },
49
+ });
50
+ }
51
+
52
+ function element(type, selector, tag) {
53
+ return new MarkAdapter(type, {selector, tag, clear: () => true});
54
+ }
55
+
56
+ function readLink(element) {
57
+ const value = {};
58
+ for (const name of LINK_ATTRIBUTES) {
59
+ if (element.hasAttribute(name)) value[name] = element.getAttribute(name);
60
+ }
61
+ return value;
62
+ }
63
+
64
+ function writeLink(element, value) {
65
+ if (!value || typeof value !== 'object' || Array.isArray(value)
66
+ || typeof value.href !== 'string'
67
+ || Object.keys(value).some(name => !LINK_ATTRIBUTES.includes(name) || typeof value[name] !== 'string')) {
68
+ throw new TypeError('A link mark requires href and optional target, rel, and title strings');
69
+ }
70
+ for (const name of LINK_ATTRIBUTES) if (value[name] !== undefined) element.setAttribute(name, value[name]);
71
+ }
72
+
73
+ function clearLink(element) {
74
+ for (const name of LINK_ATTRIBUTES) element.removeAttribute(name);
75
+ return true;
76
+ }
@@ -0,0 +1,71 @@
1
+ # Content model engine
2
+
3
+ A content model is the editor's rulebook for valid document structure. For
4
+ example, it can say that a `ul` contains `li` elements and that a paragraph may
5
+ contain text but not another block.
6
+
7
+ `content-model.js` stores and evaluates these rules. It only answers whether a
8
+ node is allowed directly inside another node; it never changes the DOM itself.
9
+ The generic engine does not hardcode HTML rules.
10
+
11
+ ## Rule shape
12
+
13
+ - `groups`: categories the node belongs to, such as `flow` or `phrasing`.
14
+ - `children`: accepted tag names, `#text`, `*`, or category tokens prefixed by
15
+ `@`.
16
+ - `exclude`: child tags or categories forbidden below this element, including
17
+ through transparent descendants.
18
+ - `block`, `atomic`, and `void`: editor semantics used by traversal and repair.
19
+ - `textBlock`: a paragraph-like block that Enter may split, such as a heading;
20
+ it implies `block`.
21
+ - `mergeable`: a block whose content Backspace may join into a compatible
22
+ preceding sibling. It defaults to `true` for text blocks and remains an
23
+ explicit policy for structural blocks such as list items.
24
+ - `defaultChild`: structural wrapper used when direct content needs repair.
25
+ - `transparent`: inherit the nearest concrete ancestor's child model.
26
+ - `allow(parent, child, model)`: an optional dynamic decision. Returning
27
+ `undefined` falls back to `children`.
28
+ - `elements`: an optional final allowlist for output element names. Parent
29
+ rules remain available as context even when the editing host itself is not
30
+ output content.
31
+
32
+ ## Contract
33
+
34
+ - Rules, lists, and models are immutable after construction.
35
+ - Names, categories, and category tokens are ASCII-case-insensitive.
36
+ - Text, unknown elements, and non-content DOM nodes have separate fallback
37
+ rules.
38
+ - Transparent rules require a real DOM context; a detached tag name alone is
39
+ intentionally insufficient.
40
+ - `extend()` creates an isolated model, shallowly merging named rule overrides
41
+ and allowing a rule to be removed with `null`.
42
+ - `allowed(node)` answers the element allowlist independently of its current
43
+ parent; text is still decided by the parent's `children` rule.
44
+ - `withElements(names)` returns and reuses an immutable narrowed model. This is
45
+ the inexpensive bridge from a surface's CSS element policy to cleanup and
46
+ commands.
47
+ - The model describes validity only. It never mutates DOM, chooses a repair,
48
+ crosses editing hosts, or reads browser-computed styles.
49
+ - Element validity is not HTML sanitizing. Attributes, URL schemes, and hostile
50
+ input require a sanitizer policy before insertion.
51
+
52
+ `defaultChild(node)` names the element a container fills itself with — `li` for
53
+ a list, `tr` for a table section. Structural commands use it instead of naming
54
+ tags, so the same command serves any configured list-like structure.
55
+
56
+ ## Cost
57
+
58
+ `allows()` is the engine's hottest question — normalization asks it for every
59
+ child of every element — so the model avoids work rather than caching answers.
60
+ Rules are looked up by `localName`, which is already lowercase, so no lookup
61
+ allocates a string. Ancestor exclusion is the one rule that has to walk upwards,
62
+ and the model knows which tokens any rule excludes at all: a child nothing could
63
+ exclude skips that walk entirely, and the answer is remembered per element name.
64
+
65
+ ## TODO
66
+
67
+ - Add contextual constraints that depend on sibling order and required child
68
+ sequences.
69
+ - Expose diagnostic reasons without complicating the hot `allows()` path.
70
+ - Add namespaced SVG and MathML integration points.
71
+ - Compile large policies only if profiling shows rule lookup is significant.
@@ -0,0 +1,213 @@
1
+ const EMPTY = rule({});
2
+
3
+ export class ContentModel {
4
+ #rules;
5
+ #fallback;
6
+ #text;
7
+ #elements;
8
+ #elementNames;
9
+ #elementModels = new Map();
10
+ #exclusions;
11
+ #excludable = new Map();
12
+
13
+ constructor({rules = {}, fallback = {}, text = {groups: ['flow', 'phrasing']}, elements = null} = {}) {
14
+ this.#rules = new Map();
15
+ for (const [name, value] of Object.entries(rules)) this.#rules.set(tag(name), rule(value));
16
+ this.#fallback = rule(fallback);
17
+ this.#text = rule(text);
18
+ this.#elementNames = elements === null ? null : tags(elements);
19
+ this.#elements = this.#elementNames && new Set(this.#elementNames);
20
+ // Every token any rule excludes. Ancestor exclusion is the one rule that
21
+ // has to walk, so a child no rule could ever exclude skips the walk.
22
+ const exclusions = new Set();
23
+ for (const item of [...this.#rules.values(), this.#fallback, this.#text]) {
24
+ for (const token of item.exclude) exclusions.add(token);
25
+ }
26
+ this.#exclusions = Object.freeze([...exclusions]);
27
+ Object.freeze(this);
28
+ }
29
+
30
+ get elements() { return this.#elementNames; }
31
+
32
+ // `localName` is already the lowercase name for HTML elements, so the hot
33
+ // path does no string work at all.
34
+ rule(node) {
35
+ if (typeof node === 'string') return this.#rules.get(tag(node)) || this.#fallback;
36
+ if (node?.nodeType === Node.TEXT_NODE) return this.#text;
37
+ if (node?.nodeType !== Node.ELEMENT_NODE) return EMPTY;
38
+ return this.#rules.get(node.localName) || this.#fallback;
39
+ }
40
+
41
+ groups(node) {
42
+ return this.rule(node).groups;
43
+ }
44
+
45
+ is(node, group) {
46
+ if (typeof group !== 'string' || !group.trim()) throw new TypeError('A group name must be a non-empty string');
47
+ return this.rule(node).groups.includes(group.toLowerCase());
48
+ }
49
+
50
+ // Whether any rule could exclude this kind of child. It depends only on the
51
+ // node's name, so the answer is remembered per name and the ancestor walk in
52
+ // `allows()` is skipped for everything nothing excludes.
53
+ excludable(child) {
54
+ if (!this.#exclusions.length) return false;
55
+ const key = child?.nodeType === Node.ELEMENT_NODE ? child.localName
56
+ : child?.nodeType === Node.TEXT_NODE ? '#text' : null;
57
+ if (key === null) return false;
58
+ let found = this.#excludable.get(key);
59
+ if (found === undefined) {
60
+ found = this.#exclusions.some(token => matches(this, token, child));
61
+ this.#excludable.set(key, found);
62
+ }
63
+ return found;
64
+ }
65
+
66
+ block(node) {
67
+ return this.rule(node).block;
68
+ }
69
+
70
+ textBlock(node) {
71
+ return this.rule(node).textBlock;
72
+ }
73
+
74
+ // The element a container fills itself with: `li` for a list, `tr` for a
75
+ // table section. Structural commands use it instead of naming tags.
76
+ defaultChild(node) {
77
+ return this.rule(node).defaultChild;
78
+ }
79
+
80
+ mergeable(node) {
81
+ const current = this.rule(node);
82
+ return current.mergeable ?? current.textBlock;
83
+ }
84
+
85
+ atomic(node) {
86
+ const current = this.rule(node);
87
+ return current.atomic || current.void;
88
+ }
89
+
90
+ transparent(node) {
91
+ return this.rule(node).transparent;
92
+ }
93
+
94
+ allowed(node) {
95
+ if (typeof node === 'string') return !this.#elements || this.#elements.has(tag(node));
96
+ if (node?.nodeType === Node.TEXT_NODE) return true;
97
+ return node?.nodeType === Node.ELEMENT_NODE && (!this.#elements || this.#elements.has(node.localName));
98
+ }
99
+
100
+ allows(parent, child) {
101
+ if (!this.allowed(child)) return false;
102
+ const context = transparentParent(this, parent);
103
+ if (!context) return false;
104
+ if (excluded(this, parent, child)) return false;
105
+ const parentRule = this.rule(context);
106
+ if (parentRule.allow) {
107
+ const allowed = parentRule.allow(context, child, this);
108
+ if (allowed !== undefined) return !!allowed;
109
+ }
110
+ return parentRule.children.some(token => matches(this, token, child));
111
+ }
112
+
113
+ withElements(elements) {
114
+ if (elements === null && this.#elementNames === null) return this;
115
+ const names = elements === null ? null : tags(elements);
116
+ if (same(names, this.#elementNames)) return this;
117
+ const key = names === null ? '*' : names.join('\u0000');
118
+ let model = this.#elementModels.get(key);
119
+ if (!model) {
120
+ model = this.extend({elements: names});
121
+ this.#elementModels.set(key, model);
122
+ }
123
+ return model;
124
+ }
125
+
126
+ extend({rules = {}, fallback, text, elements} = {}) {
127
+ const merged = Object.fromEntries(this.#rules);
128
+ for (const [name, value] of Object.entries(rules)) {
129
+ const key = tag(name);
130
+ if (value === null) delete merged[key];
131
+ else merged[key] = {...(merged[key] || {}), ...value};
132
+ }
133
+ return new ContentModel({
134
+ rules: merged,
135
+ fallback: fallback ? {...this.#fallback, ...fallback} : this.#fallback,
136
+ text: text ? {...this.#text, ...text} : this.#text,
137
+ elements: elements === undefined ? this.#elementNames : elements,
138
+ });
139
+ }
140
+ }
141
+
142
+ function rule(value) {
143
+ if (!value || typeof value !== 'object') throw new TypeError('A content rule must be an object');
144
+ const textBlock = !!value.textBlock;
145
+ return Object.freeze({
146
+ groups: list(value.groups),
147
+ children: list(value.children),
148
+ exclude: list(value.exclude),
149
+ block: !!value.block || textBlock,
150
+ textBlock,
151
+ mergeable: value.mergeable == null ? null : !!value.mergeable,
152
+ atomic: !!value.atomic,
153
+ void: !!value.void,
154
+ transparent: !!value.transparent,
155
+ defaultChild: value.defaultChild == null ? null : tag(value.defaultChild),
156
+ allow: callback(value.allow),
157
+ });
158
+ }
159
+
160
+ function callback(value) {
161
+ if (value == null) return null;
162
+ if (typeof value !== 'function') throw new TypeError('A dynamic content rule must be a function');
163
+ return value;
164
+ }
165
+
166
+ function list(value) {
167
+ if (value === undefined) return Object.freeze([]);
168
+ if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) {
169
+ throw new TypeError('Content rule lists must contain strings');
170
+ }
171
+ return Object.freeze([...new Set(value.map(item => item.startsWith('@') ? `@${item.slice(1).toLowerCase()}` : tag(item)))]);
172
+ }
173
+
174
+ function tags(value) {
175
+ if (!Array.isArray(value) || value.some(name => typeof name !== 'string' || !name.trim())) {
176
+ throw new TypeError('Allowed elements must be an array of tag names');
177
+ }
178
+ return Object.freeze([...new Set(value.map(tag))]);
179
+ }
180
+
181
+ function same(left, right) {
182
+ return left === right || !!left && !!right
183
+ && left.length === right.length && left.every((value, index) => value === right[index]);
184
+ }
185
+
186
+ function transparentParent(model, parent) {
187
+ let context = parent;
188
+ while (context && model.transparent(context)) {
189
+ if (typeof context === 'string') return null;
190
+ context = context.parentElement;
191
+ }
192
+ return context;
193
+ }
194
+
195
+ function excluded(model, parent, child) {
196
+ if (typeof parent === 'string' || !model.excludable(child)) return false;
197
+ for (let element = parent; element; element = element.parentElement) {
198
+ if (model.rule(element).exclude.some(token => matches(model, token, child))) return true;
199
+ }
200
+ return false;
201
+ }
202
+
203
+ function matches(model, token, child) {
204
+ if (token.charCodeAt(0) === 64) return model.rule(child).groups.includes(token.slice(1));
205
+ if (token === '*') return child?.nodeType === Node.ELEMENT_NODE || child?.nodeType === Node.TEXT_NODE;
206
+ if (token === '#text') return child?.nodeType === Node.TEXT_NODE;
207
+ return child?.nodeType === Node.ELEMENT_NODE && child.localName === token;
208
+ }
209
+
210
+ function tag(name) {
211
+ if (typeof name !== 'string' || !name.trim()) throw new TypeError('A content rule name must be a non-empty string');
212
+ return name.toLowerCase();
213
+ }
@@ -0,0 +1,51 @@
1
+ # Default HTML content model
2
+
3
+ The default HTML content model is RTE's ready-to-use rulebook for ordinary
4
+ HTML. It knows structures such as paragraphs, headings, lists, links, tables,
5
+ media, and form controls, so an application does not have to describe them all.
6
+
7
+ `html-model.js` supplies these defaults as data for `ContentModel`. Applications
8
+ may extend or replace them; commands and normalizers do not hardcode the rules.
9
+
10
+ ## Defaults
11
+
12
+ - Flow containers accept flow content; paragraph and heading-like blocks accept
13
+ phrasing content.
14
+ - Paragraphs, headings, and preformatted blocks are marked as splittable text
15
+ blocks. Structural blocks such as lists, items, tables, and layout `div`s are
16
+ not.
17
+ - Text blocks and list items are mergeable at their leading boundary. Layout
18
+ containers and table structure are not mergeable by default.
19
+ - Lists accept list items, definition lists accept their term/description
20
+ structures, and table sections accept their native structural children.
21
+ Structural children are not misclassified as general flow content.
22
+ - Lists, table sections, and rows declare their default repair child explicitly.
23
+ - Transparent elements such as `a`, `ins`, `del`, and `map` inherit their real
24
+ DOM parent's content restrictions.
25
+ - Interactive descendants are excluded from links and buttons. Nested links
26
+ are rejected through transparent wrappers.
27
+ - Replaced controls, media, line breaks, images, and horizontal rules are
28
+ editor-atomic. HTML void elements are also marked `void`.
29
+ - Script, style, and template nodes are not flow content. Security sanitizing
30
+ remains a separate mandatory stage for untrusted HTML.
31
+ - Unknown/custom elements are transparent flow/phrasing wrappers by default,
32
+ so their permitted children follow the context where they are used.
33
+
34
+ `createHtmlModel(overrides)` returns an isolated extended policy when overrides
35
+ are supplied. Shipped rules are defaults and may be narrowed or replaced by an
36
+ application.
37
+
38
+ ## Limits
39
+
40
+ This first policy describes direct parent/child validity and inherited
41
+ transparent content. It does not yet enforce sibling order, required children,
42
+ attribute-dependent categories, or every uncommon HTML element. Those become
43
+ additional constraints without changing the generic model API.
44
+
45
+ ## TODO
46
+
47
+ - Cover ruby, media fallback, picture/source order, details/summary order, and
48
+ definition-list grouping precisely.
49
+ - Add attribute-dependent interactive and embedded-content categories.
50
+ - Add SVG and MathML integration rules.
51
+ - Track relevant HTML Standard changes with focused conformance fixtures.