@miaskiewicz/turbo-dom 0.5.8 → 0.7.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.
@@ -2,7 +2,7 @@
2
2
  "name": "turbo-dom-parser",
3
3
  "type": "module",
4
4
  "description": "html5ever-backed native parser for turbo-dom (Layer 1)",
5
- "version": "0.5.8",
5
+ "version": "0.7.0",
6
6
  "files": [
7
7
  "turbo_dom_parser_bg.wasm",
8
8
  "turbo_dom_parser.js",
Binary file
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::{ns, parse_document, parse_fragment, LocalName, QualName};
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(&copy, 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 = parse_document(RcDom::default(), opts())
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 { target, contents } => {
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 = parse_document(RcDom::default(), opts())
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 = parse_document(RcDom::default(), opts())
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
- // parse_fragment wraps results under a synthetic <html> element; unwrap it.
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 mut frag = Node {
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: Vec::new(),
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 { target, contents } => (
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
- /// Drain nested parser intermediates without recursive destructor calls.
532
- /// This leaves the public marshaled Node representation unchanged.
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, &#0; 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;&not\0in;", "title"),
833
+ [(TEXT_NODE, "x&amp;\u{ac}in;".into())]
834
+ );
835
+ assert_eq!(
836
+ text("a&#0;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 { name: a.name, value: a.value, prefix: a.prefix })
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] { for v in col { buf.extend_from_slice(&v.to_le_bytes()); } }
109
- for col in [&s.parent, &s.first_child, &s.next_sib, &s.text_id, &s.pub_id, &s.sys_id, &s.attr_start] {
110
- for v in col { buf.extend_from_slice(&v.to_le_bytes()); }
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 { buf.extend_from_slice(&v.to_le_bytes()); }
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
- [&s.tag_names, &s.attr_names, &s.attr_prefixes, &s.attr_values, &s.strings];
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.iter().flat_map(|t| t.iter()).map(std::string::String::len).sum();
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 { str_meta.push(t.len() as u32); }
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);
@@ -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]; }