@miaskiewicz/turbo-dom 0.5.7 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +3177 -394
- package/Cargo.toml +53 -3
- package/README.md +176 -224
- package/package.json +5 -3
- package/pkg/package.json +1 -1
- package/pkg/turbo_dom_parser_bg.wasm +0 -0
- package/pkg-web/package.json +1 -1
- package/pkg-web/turbo_dom_parser_bg.wasm +0 -0
- package/src/core.rs +387 -44
- package/src/lib.rs +43 -10
- package/src/runtime/buffer.mjs +0 -1
- package/src/runtime/cascade.mjs +18 -24
- package/src/runtime/color.mjs +1 -1
- package/src/runtime/css-shorthand-table.mjs +238 -0
- package/src/runtime/css-shorthand.mjs +2794 -0
- package/src/runtime/cssom.mjs +3 -4
- package/src/runtime/custom-elements.mjs +398 -0
- package/src/runtime/dom.mjs +924 -238
- package/src/runtime/html-serialize.mjs +15 -7
- package/src/runtime/index.d.ts +100 -0
- package/src/runtime/index.mjs +5 -0
- package/src/runtime/parser.mjs +2 -4
- package/src/runtime/selectors.mjs +1080 -402
- package/src/runtime/stubs.mjs +1 -19
- package/src/runtime/window.mjs +15 -7
- package/src/spike.rs +26 -6
- package/turbo-dom-parser.darwin-arm64.node +0 -0
- package/turbo-dom-parser.linux-arm64-gnu.node +0 -0
- package/turbo-dom-parser.linux-x64-gnu.node +0 -0
- package/turbo-dom-parser.linux-x64-musl.node +0 -0
- package/turbo-dom-parser.win32-x64-msvc.node +0 -0
package/src/core.rs
CHANGED
|
@@ -6,9 +6,13 @@
|
|
|
6
6
|
//! marshaling proving to be the actual cost. Do not SoA-ify here yet.
|
|
7
7
|
|
|
8
8
|
use html5ever::driver::ParseOpts;
|
|
9
|
+
use html5ever::tendril::StrTendril;
|
|
9
10
|
use html5ever::tendril::TendrilSink;
|
|
11
|
+
use html5ever::tokenizer::{BufferQueue, Token, TokenSink, TokenSinkResult, Tokenizer};
|
|
12
|
+
use html5ever::tree_builder::TreeBuilder;
|
|
10
13
|
use html5ever::tree_builder::TreeBuilderOpts;
|
|
11
|
-
use html5ever::
|
|
14
|
+
use html5ever::TokenizerResult;
|
|
15
|
+
use html5ever::{ns, parse_fragment, LocalName, QualName};
|
|
12
16
|
use markup5ever_rcdom::{Handle, NodeData, RcDom};
|
|
13
17
|
|
|
14
18
|
/// DOM nodeType constants (subset html5ever can emit).
|
|
@@ -69,13 +73,194 @@ fn opts() -> ParseOpts {
|
|
|
69
73
|
}
|
|
70
74
|
}
|
|
71
75
|
|
|
76
|
+
/// Chrome 156 treats an empty DOCTYPE system identifier as missing when it picks the
|
|
77
|
+
/// document's quirks mode (miaskiewicz/turbo-dom#103): `<!DOCTYPE html PUBLIC "-//W3C//DTD
|
|
78
|
+
/// HTML 4.01 Transitional//EN" "">` is quirks (a `<table>` stays in an open `<p>`), where the
|
|
79
|
+
/// HTML spec (and html5ever) reads a present identifier. `doctype.systemId` is `""` both ways.
|
|
80
|
+
#[must_use]
|
|
81
|
+
pub fn chrome_doctype(token: Token) -> Token {
|
|
82
|
+
match token {
|
|
83
|
+
Token::DoctypeToken(mut doctype) if doctype.system_id.as_deref() == Some("") => {
|
|
84
|
+
doctype.system_id = None;
|
|
85
|
+
Token::DoctypeToken(doctype)
|
|
86
|
+
}
|
|
87
|
+
token => token,
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// A token sink that applies [`chrome_doctype`] before the wrapped sink (html5ever's tree
|
|
92
|
+
/// builder) sees a token.
|
|
93
|
+
pub struct ChromeTokens<S>(pub S);
|
|
94
|
+
|
|
95
|
+
impl<S: TokenSink> TokenSink for ChromeTokens<S> {
|
|
96
|
+
type Handle = S::Handle;
|
|
97
|
+
fn process_token(&self, token: Token, line_number: u64) -> TokenSinkResult<S::Handle> {
|
|
98
|
+
self.0.process_token(chrome_doctype(token), line_number)
|
|
99
|
+
}
|
|
100
|
+
// (html5ever's `TreeBuilder::end` only pops the open elements into the sink, and
|
|
101
|
+
// `RcDom` ignores pops, so no tree here observes this delegation; it keeps the wrapper
|
|
102
|
+
// transparent for any sink.)
|
|
103
|
+
fn end(&self) {
|
|
104
|
+
self.0.end();
|
|
105
|
+
}
|
|
106
|
+
fn adjusted_current_node_present_but_not_in_html_namespace(&self) -> bool {
|
|
107
|
+
self.0
|
|
108
|
+
.adjusted_current_node_present_but_not_in_html_namespace()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/// `html5ever::parse_document` over a whole string, with [`ChromeTokens`].
|
|
113
|
+
fn parse_document_dom(html: &str, opts: ParseOpts) -> RcDom {
|
|
114
|
+
let builder = TreeBuilder::new(RcDom::default(), opts.tree_builder);
|
|
115
|
+
let tokenizer = Tokenizer::new(ChromeTokens(builder), opts.tokenizer);
|
|
116
|
+
let input = BufferQueue::default();
|
|
117
|
+
input.push_back(StrTendril::from_slice(html));
|
|
118
|
+
while !matches!(tokenizer.feed(&input), TokenizerResult::Done) {}
|
|
119
|
+
tokenizer.end();
|
|
120
|
+
let dom = tokenizer.sink.0.sink;
|
|
121
|
+
if mentions_selectedcontent(html) {
|
|
122
|
+
fill_selectedcontent(&dom.document);
|
|
123
|
+
}
|
|
124
|
+
dom
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// Does `html` mention `<selectedcontent>` (ASCII case-insensitive)? Gates [`fill_selectedcontent`].
|
|
128
|
+
#[must_use]
|
|
129
|
+
pub fn mentions_selectedcontent(html: &str) -> bool {
|
|
130
|
+
html.as_bytes()
|
|
131
|
+
.windows(15)
|
|
132
|
+
.any(|w| w.eq_ignore_ascii_case(b"selectedcontent"))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/// Chrome 156 fills every `<selectedcontent>` of a non-`multiple` `<select>` with a clone of
|
|
136
|
+
/// the selected option's content while it parses (the "maybe clone an option into
|
|
137
|
+
/// selectedcontent" step; miaskiewicz/turbo-dom#104): the last option with `selected`, else
|
|
138
|
+
/// the first option that is not disabled (itself or through its `<optgroup>`); with none,
|
|
139
|
+
/// the element keeps its content. `markup5ever_rcdom` 0.39's step never finds the element (its
|
|
140
|
+
/// descendant search compares the select's own name) and html5ever only runs it on an
|
|
141
|
+
/// explicit `</option>`, so the parsed tree is completed here — the final state Chrome's
|
|
142
|
+
/// per-option runs leave.
|
|
143
|
+
pub fn fill_selectedcontent(root: &Handle) {
|
|
144
|
+
let mut pending = vec![root.clone()];
|
|
145
|
+
while let Some(node) = pending.pop() {
|
|
146
|
+
if html_element(&node, "select") && !has_attribute(&node, "multiple") {
|
|
147
|
+
let (options, contents) = select_parts(&node);
|
|
148
|
+
let selected = options
|
|
149
|
+
.iter()
|
|
150
|
+
.rev()
|
|
151
|
+
.find(|(option, _)| has_attribute(option, "selected"))
|
|
152
|
+
.or_else(|| options.iter().find(|(_, disabled)| !disabled));
|
|
153
|
+
if let Some((option, _)) = selected {
|
|
154
|
+
for content in &contents {
|
|
155
|
+
for old in content.children.take() {
|
|
156
|
+
old.parent.set(None);
|
|
157
|
+
}
|
|
158
|
+
for child in option.children.borrow().iter() {
|
|
159
|
+
append_handle(content, deep_clone(child));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
pending.extend(node.children.borrow().iter().cloned());
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
fn html_element(node: &Handle, local: &str) -> bool {
|
|
169
|
+
matches!(&node.data, NodeData::Element { name, .. } if name.ns == ns!(html) && &*name.local == local)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
fn has_attribute(node: &Handle, local: &str) -> bool {
|
|
173
|
+
matches!(&node.data, NodeData::Element { attrs, .. }
|
|
174
|
+
if attrs.borrow().iter().any(|a| a.name.ns == ns!() && &*a.name.local == local))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/// A select's options (with whether each is disabled) and selectedcontent elements, in tree
|
|
178
|
+
/// order: an option counts unless an option, datalist or hr, or a second optgroup, lies
|
|
179
|
+
/// between it and the select; a selectedcontent inside an option is not the select's
|
|
180
|
+
/// (Chrome 156 leaves it as parsed).
|
|
181
|
+
fn select_parts(select: &Handle) -> (Vec<(Handle, bool)>, Vec<Handle>) {
|
|
182
|
+
let (mut options, mut contents) = (Vec::new(), Vec::new());
|
|
183
|
+
// (node, blocked, inside an option, inside an optgroup, inside a disabled optgroup)
|
|
184
|
+
let mut pending: Vec<(Handle, bool, bool, bool, bool)> = select
|
|
185
|
+
.children
|
|
186
|
+
.borrow()
|
|
187
|
+
.iter()
|
|
188
|
+
.rev()
|
|
189
|
+
.map(|child| (child.clone(), false, false, false, false))
|
|
190
|
+
.collect();
|
|
191
|
+
while let Some((node, mut blocked, mut in_option, mut grouped, mut group_disabled)) =
|
|
192
|
+
pending.pop()
|
|
193
|
+
{
|
|
194
|
+
// (the parser never nests a select: a `<select>` start tag closes an open one)
|
|
195
|
+
if html_element(&node, "selectedcontent") && !in_option {
|
|
196
|
+
contents.push(node);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if html_element(&node, "option") {
|
|
200
|
+
if !blocked {
|
|
201
|
+
options.push((
|
|
202
|
+
node.clone(),
|
|
203
|
+
group_disabled || has_attribute(&node, "disabled"),
|
|
204
|
+
));
|
|
205
|
+
}
|
|
206
|
+
blocked = true;
|
|
207
|
+
in_option = true;
|
|
208
|
+
} else if html_element(&node, "datalist") || html_element(&node, "hr") {
|
|
209
|
+
blocked = true;
|
|
210
|
+
} else if html_element(&node, "optgroup") {
|
|
211
|
+
blocked |= grouped;
|
|
212
|
+
grouped = true;
|
|
213
|
+
group_disabled |= has_attribute(&node, "disabled");
|
|
214
|
+
}
|
|
215
|
+
for child in node.children.borrow().iter().rev() {
|
|
216
|
+
pending.push((child.clone(), blocked, in_option, grouped, group_disabled));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
(options, contents)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
fn append_handle(parent: &Handle, child: Handle) {
|
|
223
|
+
child.parent.set(Some(std::rc::Rc::downgrade(parent)));
|
|
224
|
+
parent.children.borrow_mut().push(child);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/// A deep copy of a parsed node (element attributes and template contents included).
|
|
228
|
+
fn deep_clone(node: &Handle) -> Handle {
|
|
229
|
+
let data = match &node.data {
|
|
230
|
+
NodeData::Element {
|
|
231
|
+
name,
|
|
232
|
+
attrs,
|
|
233
|
+
template_contents,
|
|
234
|
+
mathml_annotation_xml_integration_point,
|
|
235
|
+
} => NodeData::Element {
|
|
236
|
+
name: name.clone(),
|
|
237
|
+
attrs: std::cell::RefCell::new(attrs.borrow().clone()),
|
|
238
|
+
template_contents: std::cell::RefCell::new(
|
|
239
|
+
template_contents.borrow().as_ref().map(deep_clone),
|
|
240
|
+
),
|
|
241
|
+
mathml_annotation_xml_integration_point: *mathml_annotation_xml_integration_point,
|
|
242
|
+
},
|
|
243
|
+
NodeData::Text { contents } => NodeData::Text {
|
|
244
|
+
contents: std::cell::RefCell::new(contents.borrow().clone()),
|
|
245
|
+
},
|
|
246
|
+
NodeData::Comment { contents } => NodeData::Comment {
|
|
247
|
+
contents: contents.clone(),
|
|
248
|
+
},
|
|
249
|
+
NodeData::Document | NodeData::Doctype { .. } | NodeData::ProcessingInstruction { .. } => {
|
|
250
|
+
unreachable!("an option holds elements, text and comments only")
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
let copy = markup5ever_rcdom::Node::new(data);
|
|
254
|
+
for child in node.children.borrow().iter() {
|
|
255
|
+
append_handle(©, deep_clone(child));
|
|
256
|
+
}
|
|
257
|
+
copy
|
|
258
|
+
}
|
|
259
|
+
|
|
72
260
|
/// Parse a full HTML document. Always yields a Document root (nodeType 9).
|
|
73
261
|
#[must_use]
|
|
74
262
|
pub fn parse_html_document(html: &str) -> Node {
|
|
75
|
-
let dom =
|
|
76
|
-
.from_utf8()
|
|
77
|
-
.read_from(&mut html.as_bytes())
|
|
78
|
-
.expect("RcDom read_from is infallible over a byte slice");
|
|
263
|
+
let dom = parse_document_dom(html, opts());
|
|
79
264
|
walk(&dom.document)
|
|
80
265
|
}
|
|
81
266
|
|
|
@@ -115,6 +300,11 @@ pub struct Soa {
|
|
|
115
300
|
pub strings: Vec<String>, // text/comment/doctype data, pooled
|
|
116
301
|
}
|
|
117
302
|
|
|
303
|
+
/// Why the marshalers have no processing-instruction arm: `markup5ever_rcdom` builds a
|
|
304
|
+
/// `NodeData::ProcessingInstruction` only from `TreeSink::create_pi`, which html5ever 0.39's
|
|
305
|
+
/// tree builder never calls (only xml5ever does); HTML's `<?x?>` is a bogus comment.
|
|
306
|
+
const NO_PI: &str = "html5ever never creates a processing instruction";
|
|
307
|
+
|
|
118
308
|
struct SoaBuilder {
|
|
119
309
|
soa: Soa,
|
|
120
310
|
tag_map: rustc_hash::FxHashMap<String, u32>,
|
|
@@ -204,11 +394,7 @@ impl SoaBuilder {
|
|
|
204
394
|
self.soa.node_type[idx] = COMMENT_NODE;
|
|
205
395
|
self.soa.text_id[idx] = self.push_string(contents);
|
|
206
396
|
}
|
|
207
|
-
NodeData::ProcessingInstruction {
|
|
208
|
-
self.soa.node_type[idx] = PROCESSING_INSTRUCTION_NODE;
|
|
209
|
-
self.soa.tag_id[idx] = self.intern_tag(target);
|
|
210
|
-
self.soa.text_id[idx] = self.push_string(contents);
|
|
211
|
-
}
|
|
397
|
+
NodeData::ProcessingInstruction { .. } => unreachable!("{NO_PI}"),
|
|
212
398
|
NodeData::Element {
|
|
213
399
|
name,
|
|
214
400
|
attrs,
|
|
@@ -267,6 +453,7 @@ impl SoaBuilder {
|
|
|
267
453
|
}
|
|
268
454
|
if parent >= 0 {
|
|
269
455
|
let previous = last_child[parent as usize];
|
|
456
|
+
// (-1: no child yet; index 0 is the root, never anyone's child)
|
|
270
457
|
if previous < 0 {
|
|
271
458
|
self.soa.first_child[parent as usize] = index;
|
|
272
459
|
} else {
|
|
@@ -294,10 +481,7 @@ pub fn parse_html_soa(html: &str) -> Soa {
|
|
|
294
481
|
/// Parse once and retain html5ever's document compatibility mode.
|
|
295
482
|
#[must_use]
|
|
296
483
|
pub fn parse_html_soa_with_compat_mode(html: &str) -> (Soa, &'static str) {
|
|
297
|
-
let dom =
|
|
298
|
-
.from_utf8()
|
|
299
|
-
.read_from(&mut html.as_bytes())
|
|
300
|
-
.expect("RcDom read_from is infallible over a byte slice");
|
|
484
|
+
let dom = parse_document_dom(html, opts());
|
|
301
485
|
let mut b = SoaBuilder {
|
|
302
486
|
soa: Soa::default(),
|
|
303
487
|
// pre-reserve typical intern-table sizes so the build loop doesn't rehash:
|
|
@@ -329,10 +513,7 @@ pub fn parse_html_soa_with_compat_mode(html: &str) -> (Soa, &'static str) {
|
|
|
329
513
|
/// marshaling. Isolates raw html5ever parse cost from tree-build + boundary cost.
|
|
330
514
|
#[must_use]
|
|
331
515
|
pub fn parse_html_document_count(html: &str) -> u32 {
|
|
332
|
-
let dom =
|
|
333
|
-
.from_utf8()
|
|
334
|
-
.read_from(&mut html.as_bytes())
|
|
335
|
-
.expect("RcDom read_from is infallible over a byte slice");
|
|
516
|
+
let dom = parse_document_dom(html, opts());
|
|
336
517
|
count(&dom.document)
|
|
337
518
|
}
|
|
338
519
|
|
|
@@ -372,14 +553,50 @@ pub fn parse_html_fragment_in(html: &str, context_ns: &str, context_local: &str)
|
|
|
372
553
|
"math" => ns!(mathml),
|
|
373
554
|
_ => ns!(html),
|
|
374
555
|
};
|
|
556
|
+
// Chrome 156 drops a literal U+0000 in an RCDATA-context (textarea / title) fragment,
|
|
557
|
+
// where html5ever emits U+FFFD (miaskiewicz/turbo-dom#92). The whole input is text there
|
|
558
|
+
// and U+0000 only ends a character reference, so each NUL-free segment is parsed alone
|
|
559
|
+
// and the texts joined (the same rule as the rtdom crate's core).
|
|
560
|
+
if ns == ns!(html) && matches!(context_local, "textarea" | "title") && html.contains('\0') {
|
|
561
|
+
let value: String = html
|
|
562
|
+
.split('\0')
|
|
563
|
+
.flat_map(|segment| parse_html_fragment_in(segment, context_ns, context_local).children)
|
|
564
|
+
.map(|text| text.value)
|
|
565
|
+
.collect();
|
|
566
|
+
let text = (!value.is_empty()).then(|| Node {
|
|
567
|
+
node_type: TEXT_NODE,
|
|
568
|
+
name: "#text".to_string(),
|
|
569
|
+
value,
|
|
570
|
+
namespace: String::new(),
|
|
571
|
+
public_id: String::new(),
|
|
572
|
+
system_id: String::new(),
|
|
573
|
+
attrs: Vec::new(),
|
|
574
|
+
children: Vec::new(),
|
|
575
|
+
});
|
|
576
|
+
return fragment_root(text.into_iter().collect());
|
|
577
|
+
}
|
|
375
578
|
let ctx = QualName::new(None, ns, LocalName::from(context_local));
|
|
376
579
|
let dom = parse_fragment(RcDom::default(), opts(), ctx, vec![], false)
|
|
377
580
|
.from_utf8()
|
|
378
581
|
.read_from(&mut html.as_bytes())
|
|
379
582
|
.expect("RcDom read_from is infallible over a byte slice");
|
|
380
|
-
|
|
583
|
+
if mentions_selectedcontent(html) {
|
|
584
|
+
fill_selectedcontent(&dom.document);
|
|
585
|
+
}
|
|
586
|
+
// parse_fragment wraps results under a synthetic <html> element, the Document's only
|
|
587
|
+
// child: in the fragment case nothing is inserted into the Document itself (its comment
|
|
588
|
+
// inserts are the "after after body / frameset" modes, entered only from `</html>` in
|
|
589
|
+
// "after body" — ignored when fragment parsing — and from "after frameset", which
|
|
590
|
+
// `</frameset>` never enters when fragment parsing).
|
|
381
591
|
let root = walk(&dom.document);
|
|
382
|
-
let
|
|
592
|
+
let [html] =
|
|
593
|
+
<[Node; 1]>::try_from(root.children).expect("a parsed fragment has one <html> root");
|
|
594
|
+
fragment_root(html.children)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/// The synthetic root a fragment parse returns (nodeType 9, `#document-fragment`).
|
|
598
|
+
fn fragment_root(children: Vec<Node>) -> Node {
|
|
599
|
+
Node {
|
|
383
600
|
node_type: DOCUMENT_NODE,
|
|
384
601
|
name: "#document-fragment".to_string(),
|
|
385
602
|
value: String::new(),
|
|
@@ -387,16 +604,8 @@ pub fn parse_html_fragment_in(html: &str, context_ns: &str, context_local: &str)
|
|
|
387
604
|
public_id: String::new(),
|
|
388
605
|
system_id: String::new(),
|
|
389
606
|
attrs: Vec::new(),
|
|
390
|
-
children
|
|
391
|
-
};
|
|
392
|
-
for child in root.children {
|
|
393
|
-
if child.node_type == ELEMENT_NODE && child.name == "html" {
|
|
394
|
-
frag.children.extend(child.children);
|
|
395
|
-
} else {
|
|
396
|
-
frag.children.push(child);
|
|
397
|
-
}
|
|
607
|
+
children,
|
|
398
608
|
}
|
|
399
|
-
frag
|
|
400
609
|
}
|
|
401
610
|
|
|
402
611
|
fn shallow_node(handle: &Handle) -> Node {
|
|
@@ -440,13 +649,7 @@ fn shallow_node(handle: &Handle) -> Node {
|
|
|
440
649
|
String::new(),
|
|
441
650
|
Vec::new(),
|
|
442
651
|
),
|
|
443
|
-
NodeData::ProcessingInstruction {
|
|
444
|
-
PROCESSING_INSTRUCTION_NODE,
|
|
445
|
-
target.to_string(),
|
|
446
|
-
contents.to_string(),
|
|
447
|
-
String::new(),
|
|
448
|
-
Vec::new(),
|
|
449
|
-
),
|
|
652
|
+
NodeData::ProcessingInstruction { .. } => unreachable!("{NO_PI}"),
|
|
450
653
|
NodeData::Element { name, attrs, .. } => {
|
|
451
654
|
let a = attrs
|
|
452
655
|
.borrow()
|
|
@@ -528,14 +731,8 @@ fn walk(handle: &Handle) -> Node {
|
|
|
528
731
|
complete.pop().expect("one parsed root")
|
|
529
732
|
}
|
|
530
733
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
pub fn discard_node(mut root: Node) {
|
|
534
|
-
let mut pending = std::mem::take(&mut root.children);
|
|
535
|
-
while let Some(mut node) = pending.pop() {
|
|
536
|
-
pending.append(&mut node.children);
|
|
537
|
-
}
|
|
538
|
-
}
|
|
734
|
+
#[cfg(test)]
|
|
735
|
+
mod conformance_tests;
|
|
539
736
|
|
|
540
737
|
#[cfg(test)]
|
|
541
738
|
mod tests {
|
|
@@ -613,6 +810,152 @@ mod tests {
|
|
|
613
810
|
assert_eq!(content.children[0].value, "Hello");
|
|
614
811
|
}
|
|
615
812
|
|
|
813
|
+
#[test]
|
|
814
|
+
fn rcdata_fragments_drop_nul_like_chrome() {
|
|
815
|
+
// miaskiewicz/turbo-dom#92 (Chrome 156): a literal U+0000 is dropped, � is U+FFFD
|
|
816
|
+
let text = |html: &str, ctx: &str| {
|
|
817
|
+
let frag = parse_html_fragment_context(html, ctx);
|
|
818
|
+
assert_eq!(
|
|
819
|
+
(frag.node_type, frag.name.as_str(), frag.value.as_str()),
|
|
820
|
+
(DOCUMENT_NODE, "#document-fragment", "")
|
|
821
|
+
);
|
|
822
|
+
frag.children
|
|
823
|
+
.iter()
|
|
824
|
+
.map(|c| (c.node_type, c.value.clone()))
|
|
825
|
+
.collect::<Vec<_>>()
|
|
826
|
+
};
|
|
827
|
+
assert_eq!(
|
|
828
|
+
text("a\0b</textarea>c\0d", "textarea"),
|
|
829
|
+
[(TEXT_NODE, "ab</textarea>cd".into())]
|
|
830
|
+
);
|
|
831
|
+
assert_eq!(
|
|
832
|
+
text("x&am\0p;¬\0in;", "title"),
|
|
833
|
+
[(TEXT_NODE, "x&\u{ac}in;".into())]
|
|
834
|
+
);
|
|
835
|
+
assert_eq!(
|
|
836
|
+
text("a�b", "textarea"),
|
|
837
|
+
[(TEXT_NODE, "a\u{fffd}b".into())]
|
|
838
|
+
);
|
|
839
|
+
assert_eq!(text("\0", "title"), []);
|
|
840
|
+
// other contexts keep html5ever's answer (data state: dropped; RAWTEXT: U+FFFD)
|
|
841
|
+
assert_eq!(text("a\0b", "style"), [(TEXT_NODE, "a\u{fffd}b".into())]);
|
|
842
|
+
assert_eq!(text("a\0b", "div"), [(TEXT_NODE, "ab".into())]);
|
|
843
|
+
assert_eq!(text("a\0b", "svg title"), [(TEXT_NODE, "ab".into())]);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
#[test]
|
|
847
|
+
fn fragment_context_strings_pick_namespace_and_element() {
|
|
848
|
+
let dump = |html: &str, ctx: &str| {
|
|
849
|
+
conformance_tests::dump_node(&parse_html_fragment_context(html, ctx))
|
|
850
|
+
};
|
|
851
|
+
// body context ("" too): a stray <td> is dropped, its text kept
|
|
852
|
+
assert_eq!(dump("<td>x", ""), "| \"x\"");
|
|
853
|
+
assert_eq!(dump("<td>x", "body"), "| \"x\"");
|
|
854
|
+
// a <tr> context (bare, or with any prefix other than svg / math: an HTML element)
|
|
855
|
+
let cell = "| <td>\n| \"x\"";
|
|
856
|
+
assert_eq!(dump("<td>x", "tr"), cell);
|
|
857
|
+
assert_eq!(dump("<td>x", "html tr"), cell);
|
|
858
|
+
// foreign contexts: <mi> stays SVG under svg, is HTML in a MathML text
|
|
859
|
+
// integration point (<ms>), and MathML under math <math>
|
|
860
|
+
assert_eq!(dump("<mi/>", "svg path"), "| <svg mi>");
|
|
861
|
+
assert_eq!(dump("<mi/>", "math ms"), "| <mi>");
|
|
862
|
+
assert_eq!(dump("<mi/>", "math math"), "| <math mi>");
|
|
863
|
+
let frag = parse_html_fragment("<b>y</b>");
|
|
864
|
+
assert_eq!(
|
|
865
|
+
(frag.node_type, frag.name.as_str(), frag.children.len()),
|
|
866
|
+
(DOCUMENT_NODE, "#document-fragment", 1)
|
|
867
|
+
);
|
|
868
|
+
assert_eq!(conformance_tests::dump_node(&frag), "| <b>\n| \"y\"");
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
#[test]
|
|
872
|
+
fn soa_interns_names_values_and_prefixes_once() {
|
|
873
|
+
let soa =
|
|
874
|
+
parse_html_soa("<p class=a></p><p class=a id=b></p><svg><a xlink:href=b></a></svg>");
|
|
875
|
+
assert_eq!(
|
|
876
|
+
soa.tag_names,
|
|
877
|
+
["html", "head", "body", "p", "svg", "a"].map(String::from)
|
|
878
|
+
);
|
|
879
|
+
assert_eq!(soa.attr_names, ["class", "id", "href"].map(String::from));
|
|
880
|
+
assert_eq!(soa.attr_values, ["a", "b"].map(String::from));
|
|
881
|
+
assert_eq!(soa.attr_prefixes, ["", "xlink"].map(String::from));
|
|
882
|
+
assert_eq!(soa.attr_name_id, [0, 0, 1, 2]);
|
|
883
|
+
assert_eq!(soa.attr_value_id, [0, 0, 1, 1]);
|
|
884
|
+
assert_eq!(soa.attr_prefix_id, [0, 0, 0, 1]);
|
|
885
|
+
// document, html, head, body, p, p, svg, a
|
|
886
|
+
assert_eq!(soa.node_type, [9, 1, 1, 1, 1, 1, 1, 1]);
|
|
887
|
+
assert_eq!(soa.ns, [0, 0, 0, 0, 0, 0, 1, 1]);
|
|
888
|
+
assert_eq!(soa.tag_id, [0, 0, 1, 2, 3, 3, 4, 5]);
|
|
889
|
+
assert_eq!(soa.parent, [-1, 0, 1, 1, 3, 3, 3, 6]);
|
|
890
|
+
assert_eq!(soa.first_child, [1, 2, -1, 4, -1, -1, 7, -1]);
|
|
891
|
+
assert_eq!(soa.next_sib, [-1, -1, 3, -1, 5, 6, -1, -1]);
|
|
892
|
+
assert_eq!(soa.attr_start, [-1, -1, -1, -1, 0, 1, -1, 3]);
|
|
893
|
+
assert_eq!(soa.attr_count, [0, 0, 0, 0, 1, 2, 0, 1]);
|
|
894
|
+
assert_eq!(soa.text_id, [-1; 8]);
|
|
895
|
+
assert_eq!(soa.strings.len(), 0);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
#[test]
|
|
899
|
+
fn soa_strings_doctype_text_comment_and_template_content() {
|
|
900
|
+
let (soa, mode) = parse_html_soa_with_compat_mode(
|
|
901
|
+
"<!DOCTYPE html PUBLIC \"p\" \"s\"><!--c--><template>t</template>",
|
|
902
|
+
);
|
|
903
|
+
assert_eq!(mode, "CSS1Compat");
|
|
904
|
+
// document, doctype, comment, html, head, template, content, "t", body
|
|
905
|
+
assert_eq!(soa.node_type, [9, 10, 8, 1, 1, 1, 11, 3, 1]);
|
|
906
|
+
assert_eq!(soa.strings, ["html", "p", "s", "c", "t"].map(String::from));
|
|
907
|
+
assert_eq!(soa.text_id, [-1, 0, 3, -1, -1, -1, -1, 4, -1]);
|
|
908
|
+
assert_eq!(soa.pub_id, [-1, 1, -1, -1, -1, -1, -1, -1, -1]);
|
|
909
|
+
assert_eq!(soa.sys_id, [-1, 2, -1, -1, -1, -1, -1, -1, -1]);
|
|
910
|
+
assert_eq!(soa.tag_names[soa.tag_id[6] as usize], "content");
|
|
911
|
+
assert_eq!(soa.parent[6..8], [5, 6]);
|
|
912
|
+
assert_eq!(soa.first_child[5], 6);
|
|
913
|
+
assert_eq!(parse_html_soa_with_compat_mode("<p>").1, "BackCompat");
|
|
914
|
+
assert_eq!(parse_html_document_count("<!--c--><p>x"), 7);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
#[test]
|
|
918
|
+
fn selectedcontent_skips_options_in_a_nested_optgroup() {
|
|
919
|
+
// Chrome 156 (headless DOMParser, recorded for this test): an option below a second
|
|
920
|
+
// optgroup is not one of the select's options, so the next option fills
|
|
921
|
+
// <selectedcontent>; Chrome's tree is the same and its innerHTML is "b".
|
|
922
|
+
let html = "<select><optgroup><div><optgroup><option>a</option></optgroup></div></optgroup>\
|
|
923
|
+
<option>b</option><button><selectedcontent></selectedcontent></button></select>";
|
|
924
|
+
let want = "| <html>\n| <head>\n| <body>\n| <select>\n| <optgroup>\n\
|
|
925
|
+
| <div>\n| <optgroup>\n| <option>\n\
|
|
926
|
+
| \"a\"\n| <option>\n| \"b\"\n| <button>\n\
|
|
927
|
+
| <selectedcontent>\n| \"b\"";
|
|
928
|
+
assert_eq!(
|
|
929
|
+
conformance_tests::dump_node(&parse_html_document(html)),
|
|
930
|
+
want
|
|
931
|
+
);
|
|
932
|
+
assert_eq!(conformance_tests::dump_soa(&parse_html_soa(html)), want);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
#[test]
|
|
936
|
+
fn selectedcontent_inside_an_option_is_not_filled() {
|
|
937
|
+
// Chrome 156 (headless DOMParser): a <selectedcontent> inside the option it would
|
|
938
|
+
// copy keeps its (empty) content instead of receiving a clone of the option.
|
|
939
|
+
let html = "<select><option>a<selectedcontent></selectedcontent></option></select>";
|
|
940
|
+
let want = "| <html>\n| <head>\n| <body>\n| <select>\n| <option>\n\
|
|
941
|
+
| \"a\"\n| <selectedcontent>";
|
|
942
|
+
assert_eq!(
|
|
943
|
+
conformance_tests::dump_node(&parse_html_document(html)),
|
|
944
|
+
want
|
|
945
|
+
);
|
|
946
|
+
assert_eq!(conformance_tests::dump_soa(&parse_html_soa(html)), want);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
#[test]
|
|
950
|
+
fn mentions_selectedcontent_is_an_ascii_case_insensitive_substring_test() {
|
|
951
|
+
assert!(mentions_selectedcontent("selectedcontent"));
|
|
952
|
+
assert!(mentions_selectedcontent("<b></b><SelectedContent>"));
|
|
953
|
+
assert!(mentions_selectedcontent("xSELECTEDCONTENTx"));
|
|
954
|
+
assert!(!mentions_selectedcontent("selectedconten"));
|
|
955
|
+
assert!(!mentions_selectedcontent("<select><option>"));
|
|
956
|
+
assert!(!mentions_selectedcontent(""));
|
|
957
|
+
}
|
|
958
|
+
|
|
616
959
|
#[test]
|
|
617
960
|
fn fragment_context_namespace() {
|
|
618
961
|
let frag = parse_html_fragment_context("<rect/>", "svg path");
|
package/src/lib.rs
CHANGED
|
@@ -55,7 +55,11 @@ mod napi_front {
|
|
|
55
55
|
attrs: n
|
|
56
56
|
.attrs
|
|
57
57
|
.into_iter()
|
|
58
|
-
.map(|a| JsAttr {
|
|
58
|
+
.map(|a| JsAttr {
|
|
59
|
+
name: a.name,
|
|
60
|
+
value: a.value,
|
|
61
|
+
prefix: a.prefix,
|
|
62
|
+
})
|
|
59
63
|
.collect(),
|
|
60
64
|
children: n.children.into_iter().map(JsNode::from).collect(),
|
|
61
65
|
}
|
|
@@ -105,25 +109,54 @@ mod napi_front {
|
|
|
105
109
|
let n = s.node_type.len();
|
|
106
110
|
let m = s.attr_name_id.len();
|
|
107
111
|
let mut buf: Vec<u8> = Vec::with_capacity(36 * n + 12 * m);
|
|
108
|
-
for col in [&s.tag_id] {
|
|
109
|
-
|
|
110
|
-
|
|
112
|
+
for col in [&s.tag_id] {
|
|
113
|
+
for v in col {
|
|
114
|
+
buf.extend_from_slice(&v.to_le_bytes());
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
for col in [
|
|
118
|
+
&s.parent,
|
|
119
|
+
&s.first_child,
|
|
120
|
+
&s.next_sib,
|
|
121
|
+
&s.text_id,
|
|
122
|
+
&s.pub_id,
|
|
123
|
+
&s.sys_id,
|
|
124
|
+
&s.attr_start,
|
|
125
|
+
] {
|
|
126
|
+
for v in col {
|
|
127
|
+
buf.extend_from_slice(&v.to_le_bytes());
|
|
128
|
+
}
|
|
111
129
|
}
|
|
112
130
|
for col in [&s.attr_name_id, &s.attr_value_id, &s.attr_prefix_id] {
|
|
113
|
-
for v in col {
|
|
131
|
+
for v in col {
|
|
132
|
+
buf.extend_from_slice(&v.to_le_bytes());
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for v in &s.attr_count {
|
|
136
|
+
buf.extend_from_slice(&v.to_le_bytes());
|
|
114
137
|
}
|
|
115
|
-
for v in &s.attr_count { buf.extend_from_slice(&v.to_le_bytes()); }
|
|
116
138
|
buf.extend_from_slice(&s.node_type);
|
|
117
139
|
buf.extend_from_slice(&s.ns);
|
|
118
140
|
// pack all five string tables into one byte blob + meta (5 counts, then
|
|
119
141
|
// per-string byte lengths in table order). Zero napi string conversion.
|
|
120
|
-
let tables: [&Vec<String>; 5] =
|
|
121
|
-
|
|
142
|
+
let tables: [&Vec<String>; 5] = [
|
|
143
|
+
&s.tag_names,
|
|
144
|
+
&s.attr_names,
|
|
145
|
+
&s.attr_prefixes,
|
|
146
|
+
&s.attr_values,
|
|
147
|
+
&s.strings,
|
|
148
|
+
];
|
|
122
149
|
let total_strs: usize = tables.iter().map(|t| t.len()).sum();
|
|
123
|
-
let total_bytes: usize = tables
|
|
150
|
+
let total_bytes: usize = tables
|
|
151
|
+
.iter()
|
|
152
|
+
.flat_map(|t| t.iter())
|
|
153
|
+
.map(std::string::String::len)
|
|
154
|
+
.sum();
|
|
124
155
|
let mut str_blob: Vec<u8> = Vec::with_capacity(total_bytes);
|
|
125
156
|
let mut str_meta: Vec<u32> = Vec::with_capacity(5 + total_strs);
|
|
126
|
-
for t in &tables {
|
|
157
|
+
for t in &tables {
|
|
158
|
+
str_meta.push(t.len() as u32);
|
|
159
|
+
}
|
|
127
160
|
for t in &tables {
|
|
128
161
|
for x in *t {
|
|
129
162
|
str_meta.push(x.len() as u32);
|
package/src/runtime/buffer.mjs
CHANGED
|
@@ -72,7 +72,6 @@ export class Buffer {
|
|
|
72
72
|
nodeType(i) { return this.soa.nodeType[i]; }
|
|
73
73
|
ns(i) { return NS_SHORT[this.soa.ns[i]] || ''; }
|
|
74
74
|
tagName(i) { return this.soa.tagNames[this.soa.tagId[i]]; }
|
|
75
|
-
parent(i) { return this.soa.parent[i]; }
|
|
76
75
|
firstChild(i) { return this.soa.firstChild[i]; }
|
|
77
76
|
nextSib(i) { return this.soa.nextSib[i]; }
|
|
78
77
|
text(i) { const t = this.soa.textId[i]; return t < 0 ? '' : this.soa.strings[t]; }
|