frostwork 0.2.0__tar.gz → 0.2.2__tar.gz

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 (133) hide show
  1. {frostwork-0.2.0 → frostwork-0.2.2}/CHANGELOG.md +22 -0
  2. {frostwork-0.2.0 → frostwork-0.2.2}/Cargo.lock +1 -1
  3. {frostwork-0.2.0 → frostwork-0.2.2}/Cargo.toml +1 -1
  4. {frostwork-0.2.0 → frostwork-0.2.2}/PKG-INFO +1 -1
  5. {frostwork-0.2.0 → frostwork-0.2.2}/pyproject.toml +2 -2
  6. {frostwork-0.2.0 → frostwork-0.2.2}/python/frostwork/encoding.py +5 -4
  7. {frostwork-0.2.0 → frostwork-0.2.2}/src/lib.rs +24 -11
  8. {frostwork-0.2.0 → frostwork-0.2.2}/src/matcher/mod.rs +3 -0
  9. {frostwork-0.2.0 → frostwork-0.2.2}/src/matcher/navigation.rs +123 -12
  10. {frostwork-0.2.0 → frostwork-0.2.2}/src/tokenizer.rs +22 -10
  11. {frostwork-0.2.0 → frostwork-0.2.2}/.github/actions/oracle/action.yml +0 -0
  12. {frostwork-0.2.0 → frostwork-0.2.2}/.github/dependabot.yml +0 -0
  13. {frostwork-0.2.0 → frostwork-0.2.2}/.github/workflows/bench-migration.yml +0 -0
  14. {frostwork-0.2.0 → frostwork-0.2.2}/.github/workflows/ci.yml +0 -0
  15. {frostwork-0.2.0 → frostwork-0.2.2}/.github/workflows/publish.yml +0 -0
  16. {frostwork-0.2.0 → frostwork-0.2.2}/.github/workflows/scheduled.yml +0 -0
  17. {frostwork-0.2.0 → frostwork-0.2.2}/.gitignore +0 -0
  18. {frostwork-0.2.0 → frostwork-0.2.2}/.pre-commit-config.yaml +0 -0
  19. {frostwork-0.2.0 → frostwork-0.2.2}/AGENTS.md +0 -0
  20. {frostwork-0.2.0 → frostwork-0.2.2}/CLAUDE.md +0 -0
  21. {frostwork-0.2.0 → frostwork-0.2.2}/LICENSE +0 -0
  22. {frostwork-0.2.0 → frostwork-0.2.2}/Makefile +0 -0
  23. {frostwork-0.2.0 → frostwork-0.2.2}/README.md +0 -0
  24. {frostwork-0.2.0 → frostwork-0.2.2}/SECURITY.md +0 -0
  25. {frostwork-0.2.0 → frostwork-0.2.2}/docs/BENCHMARKS.md +0 -0
  26. {frostwork-0.2.0 → frostwork-0.2.2}/docs/COMPATIBILITY.md +0 -0
  27. {frostwork-0.2.0 → frostwork-0.2.2}/docs/DESIGN.md +0 -0
  28. {frostwork-0.2.0 → frostwork-0.2.2}/docs/MIGRATION.md +0 -0
  29. {frostwork-0.2.0 → frostwork-0.2.2}/docs/PYTHON.md +0 -0
  30. {frostwork-0.2.0 → frostwork-0.2.2}/docs/RELEASING.md +0 -0
  31. {frostwork-0.2.0 → frostwork-0.2.2}/docs/SUPPORT_SNAPSHOT.md +0 -0
  32. {frostwork-0.2.0 → frostwork-0.2.2}/docs/TESTING.md +0 -0
  33. {frostwork-0.2.0 → frostwork-0.2.2}/docs/WEBPOET_SURFACE.md +0 -0
  34. {frostwork-0.2.0 → frostwork-0.2.2}/examples/basic.py +0 -0
  35. {frostwork-0.2.0 → frostwork-0.2.2}/examples/basic.rs +0 -0
  36. {frostwork-0.2.0 → frostwork-0.2.2}/python/AGENTS.md +0 -0
  37. {frostwork-0.2.0 → frostwork-0.2.2}/python/CLAUDE.md +0 -0
  38. {frostwork-0.2.0 → frostwork-0.2.2}/python/frostwork/__init__.py +0 -0
  39. {frostwork-0.2.0 → frostwork-0.2.2}/python/frostwork/_frostwork.pyi +0 -0
  40. {frostwork-0.2.0 → frostwork-0.2.2}/python/frostwork/_navigation.py +0 -0
  41. {frostwork-0.2.0 → frostwork-0.2.2}/python/frostwork/audit.py +0 -0
  42. {frostwork-0.2.0 → frostwork-0.2.2}/python/frostwork/page.py +0 -0
  43. {frostwork-0.2.0 → frostwork-0.2.2}/python/frostwork/py.typed +0 -0
  44. {frostwork-0.2.0 → frostwork-0.2.2}/python/frostwork/scan.py +0 -0
  45. {frostwork-0.2.0 → frostwork-0.2.2}/python/frostwork/validation.py +0 -0
  46. {frostwork-0.2.0 → frostwork-0.2.2}/python/frostwork/webpoet.py +0 -0
  47. {frostwork-0.2.0 → frostwork-0.2.2}/requirements-bench.txt +0 -0
  48. {frostwork-0.2.0 → frostwork-0.2.2}/requirements-release.txt +0 -0
  49. {frostwork-0.2.0 → frostwork-0.2.2}/requirements-test.txt +0 -0
  50. {frostwork-0.2.0 → frostwork-0.2.2}/src/bin/bench.rs +0 -0
  51. {frostwork-0.2.0 → frostwork-0.2.2}/src/bin/differ.rs +0 -0
  52. {frostwork-0.2.0 → frostwork-0.2.2}/src/diagnostics.rs +0 -0
  53. {frostwork-0.2.0 → frostwork-0.2.2}/src/encoding.rs +0 -0
  54. {frostwork-0.2.0 → frostwork-0.2.2}/src/entities.rs +0 -0
  55. {frostwork-0.2.0 → frostwork-0.2.2}/src/implied_close/generated.rs +0 -0
  56. {frostwork-0.2.0 → frostwork-0.2.2}/src/implied_close/mod.rs +0 -0
  57. {frostwork-0.2.0 → frostwork-0.2.2}/src/matcher/compile.rs +0 -0
  58. {frostwork-0.2.0 → frostwork-0.2.2}/src/matcher/decode.rs +0 -0
  59. {frostwork-0.2.0 → frostwork-0.2.2}/src/matcher/deferred.rs +0 -0
  60. {frostwork-0.2.0 → frostwork-0.2.2}/src/matcher/frame.rs +0 -0
  61. {frostwork-0.2.0 → frostwork-0.2.2}/src/matcher/matching.rs +0 -0
  62. {frostwork-0.2.0 → frostwork-0.2.2}/src/matcher/sig.rs +0 -0
  63. {frostwork-0.2.0 → frostwork-0.2.2}/src/mutate.rs +0 -0
  64. {frostwork-0.2.0 → frostwork-0.2.2}/src/page.rs +0 -0
  65. {frostwork-0.2.0 → frostwork-0.2.2}/src/python.rs +0 -0
  66. {frostwork-0.2.0 → frostwork-0.2.2}/src/selector.rs +0 -0
  67. {frostwork-0.2.0 → frostwork-0.2.2}/src/xpath.rs +0 -0
  68. {frostwork-0.2.0 → frostwork-0.2.2}/tests/conftest.py +0 -0
  69. {frostwork-0.2.0 → frostwork-0.2.2}/tests/corpus/README.md +0 -0
  70. {frostwork-0.2.0 → frostwork-0.2.2}/tests/corpus/catalog/pages/test-listing.html +0 -0
  71. {frostwork-0.2.0 → frostwork-0.2.2}/tests/corpus/catalog/selectors.json +0 -0
  72. {frostwork-0.2.0 → frostwork-0.2.2}/tests/corpus/docgen/pages/test-api.html +0 -0
  73. {frostwork-0.2.0 → frostwork-0.2.2}/tests/corpus/docgen/selectors.json +0 -0
  74. {frostwork-0.2.0 → frostwork-0.2.2}/tests/corpus/tabular/pages/test-report.html +0 -0
  75. {frostwork-0.2.0 → frostwork-0.2.2}/tests/corpus/tabular/selectors.json +0 -0
  76. {frostwork-0.2.0 → frostwork-0.2.2}/tests/migration/article.html +0 -0
  77. {frostwork-0.2.0 → frostwork-0.2.2}/tests/migration/listing.html +0 -0
  78. {frostwork-0.2.0 → frostwork-0.2.2}/tests/migration/manifest.json +0 -0
  79. {frostwork-0.2.0 → frostwork-0.2.2}/tests/migration/pages.py +0 -0
  80. {frostwork-0.2.0 → frostwork-0.2.2}/tests/migration/product.html +0 -0
  81. {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_audit_context.py +0 -0
  82. {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_composition.py +0 -0
  83. {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_doc_examples.py +0 -0
  84. {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_docs_check.py +0 -0
  85. {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_encoding.py +0 -0
  86. {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_gates.py +0 -0
  87. {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_make_gates.py +0 -0
  88. {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_migration.py +0 -0
  89. {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_navigation.py +0 -0
  90. {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_python.py +0 -0
  91. {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_release.py +0 -0
  92. {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_schema.py +0 -0
  93. {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_typing.py +0 -0
  94. {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_validation.py +0 -0
  95. {frostwork-0.2.0 → frostwork-0.2.2}/tests/typing_fixture.py +0 -0
  96. {frostwork-0.2.0 → frostwork-0.2.2}/tools/AGENTS.md +0 -0
  97. {frostwork-0.2.0 → frostwork-0.2.2}/tools/CLAUDE.md +0 -0
  98. {frostwork-0.2.0 → frostwork-0.2.2}/tools/ab_bench.py +0 -0
  99. {frostwork-0.2.0 → frostwork-0.2.2}/tools/abi3_smoke.py +0 -0
  100. {frostwork-0.2.0 → frostwork-0.2.2}/tools/audit_tree_rules.py +0 -0
  101. {frostwork-0.2.0 → frostwork-0.2.2}/tools/bench_corpus.py +0 -0
  102. {frostwork-0.2.0 → frostwork-0.2.2}/tools/bench_encoding.py +0 -0
  103. {frostwork-0.2.0 → frostwork-0.2.2}/tools/bench_engines.py +0 -0
  104. {frostwork-0.2.0 → frostwork-0.2.2}/tools/bench_matrix.py +0 -0
  105. {frostwork-0.2.0 → frostwork-0.2.2}/tools/bench_mem.py +0 -0
  106. {frostwork-0.2.0 → frostwork-0.2.2}/tools/bench_navigation.py +0 -0
  107. {frostwork-0.2.0 → frostwork-0.2.2}/tools/bench_webpoet.py +0 -0
  108. {frostwork-0.2.0 → frostwork-0.2.2}/tools/check_docs.py +0 -0
  109. {frostwork-0.2.0 → frostwork-0.2.2}/tools/conformant.py +0 -0
  110. {frostwork-0.2.0 → frostwork-0.2.2}/tools/corpus_fetch.py +0 -0
  111. {frostwork-0.2.0 → frostwork-0.2.2}/tools/decoder_sweep.py +0 -0
  112. {frostwork-0.2.0 → frostwork-0.2.2}/tools/diff_fuzz.py +0 -0
  113. {frostwork-0.2.0 → frostwork-0.2.2}/tools/diff_lxml.py +0 -0
  114. {frostwork-0.2.0 → frostwork-0.2.2}/tools/diff_webpoet.py +0 -0
  115. {frostwork-0.2.0 → frostwork-0.2.2}/tools/enc_cases.py +0 -0
  116. {frostwork-0.2.0 → frostwork-0.2.2}/tools/enc_check.py +0 -0
  117. {frostwork-0.2.0 → frostwork-0.2.2}/tools/encpages.py +0 -0
  118. {frostwork-0.2.0 → frostwork-0.2.2}/tools/families.py +0 -0
  119. {frostwork-0.2.0 → frostwork-0.2.2}/tools/foreign.py +0 -0
  120. {frostwork-0.2.0 → frostwork-0.2.2}/tools/gen_tree_rules.py +0 -0
  121. {frostwork-0.2.0 → frostwork-0.2.2}/tools/mutate_rules.py +0 -0
  122. {frostwork-0.2.0 → frostwork-0.2.2}/tools/mutate_webpoet.py +0 -0
  123. {frostwork-0.2.0 → frostwork-0.2.2}/tools/oracle.py +0 -0
  124. {frostwork-0.2.0 → frostwork-0.2.2}/tools/release_check.py +0 -0
  125. {frostwork-0.2.0 → frostwork-0.2.2}/tools/sel_fuzz.py +0 -0
  126. {frostwork-0.2.0 → frostwork-0.2.2}/tools/seq_sweep.py +0 -0
  127. {frostwork-0.2.0 → frostwork-0.2.2}/tools/soak.py +0 -0
  128. {frostwork-0.2.0 → frostwork-0.2.2}/tools/support_snapshot.py +0 -0
  129. {frostwork-0.2.0 → frostwork-0.2.2}/tools/verify_migration.py +0 -0
  130. {frostwork-0.2.0 → frostwork-0.2.2}/tools/verify_pypi.py +0 -0
  131. {frostwork-0.2.0 → frostwork-0.2.2}/tools/webpoet_cases.py +0 -0
  132. {frostwork-0.2.0 → frostwork-0.2.2}/tools/webpoet_structure.py +0 -0
  133. {frostwork-0.2.0 → frostwork-0.2.2}/tools/webpoet_surface.py +0 -0
@@ -3,6 +3,28 @@
3
3
  All notable user-facing changes will be recorded here. Frostwork will follow semantic versioning after its
4
4
  first public release.
5
5
 
6
+ ## 0.2.2 (2026-11-05)
7
+
8
+ ### Performance
9
+
10
+ - `navigation()` no longer takes quadratic time on a document with many unclosed `<meta` tags, e.g.
11
+ `"<meta " * 80000`, which took over 30 seconds in 0.2.1.
12
+
13
+ ## 0.2.1 (2026-11-05)
14
+
15
+ ### Correctness
16
+
17
+ - `navigation()` no longer reads a `<base>` or meta refresh written after a self-closed element whose
18
+ content browsers read as text, e.g. `<script/><base href="…">`: unless it is an SVG or MathML
19
+ element, browsers ignore the `/` of `<script/>`, `<style/>`, `<title/>`, `<textarea/>`,
20
+ `<noscript/>` (with scripting enabled), … and read the markup up to the matching end tag as text.
21
+
22
+ ### Performance
23
+
24
+ - `navigation()` no longer tokenizes a document that cannot hold what was asked for: a `<base>` for
25
+ the base URL, a `<meta>` with an `http-equiv` attribute for the meta refresh.
26
+ - `frostwork.encoding.bom_encoding()`, `codec_name()` and `decode()` have less call overhead.
27
+
6
28
  ## 0.2.0 (2026-11-05)
7
29
 
8
30
  ### Backward-incompatible changes
@@ -50,7 +50,7 @@ dependencies = [
50
50
 
51
51
  [[package]]
52
52
  name = "frostwork"
53
- version = "0.2.0"
53
+ version = "0.2.2"
54
54
  dependencies = [
55
55
  "chardetng",
56
56
  "encoding_rs",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "frostwork"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  edition = "2021"
5
5
  description = "Treeless, one-pass HTML extraction — CSS/XPath selectors matched on a streaming scan, no DOM."
6
6
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: frostwork
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Programming Language :: Rust
6
6
  Classifier: Programming Language :: Python :: 3 :: Only
@@ -5,7 +5,7 @@ build-backend = "maturin"
5
5
 
6
6
  [project]
7
7
  name = "frostwork"
8
- version = "0.2.0"
8
+ version = "0.2.2"
9
9
  description = "Treeless, one-pass HTML extraction — CSS/XPath selectors matched on a streaming scan, no DOM."
10
10
  readme = "README.md"
11
11
  license = "Apache-2.0"
@@ -89,7 +89,7 @@ module-name = "frostwork._frostwork"
89
89
  python-source = "python"
90
90
 
91
91
  [tool.bumpversion]
92
- current_version = "0.2.0"
92
+ current_version = "0.2.2"
93
93
  commit = true
94
94
  tag = true
95
95
  tag_name = "{new_version}"
@@ -77,7 +77,7 @@ def codec_name(label: str) -> Optional[str]:
77
77
  :func:`decode` to decode with it. The name of ``ISO-8859-8-I`` resolves back to ``ISO-8859-8``,
78
78
  which decodes the same.
79
79
  """
80
- name = resolve_label(label)
80
+ name = _cached_resolve_label(label)
81
81
  return None if name is None else _CODEC_NAMES[name]
82
82
 
83
83
 
@@ -100,7 +100,7 @@ def bom_encoding(body: Buffer) -> Optional[Tuple[str, int]]:
100
100
  A UTF-16 document that starts with an XML declaration and no BOM is recognized too, with length
101
101
  0. A UTF-32LE BOM reads as a UTF-16LE one, as in browsers.
102
102
  """
103
- return _bom_encoding(bytes(body))
103
+ return _bom_encoding(body if type(body) is bytes else bytes(body))
104
104
 
105
105
 
106
106
  def declared_encoding(body: Buffer) -> Optional[str]:
@@ -125,10 +125,11 @@ def decode(body: Buffer, label: str) -> str:
125
125
  A BOM is decoded like any other bytes; strip the length :func:`bom_encoding` returns first. Raises
126
126
  :exc:`LookupError` if :func:`resolve_label` cannot resolve *label*.
127
127
  """
128
- name = resolve_label(label)
128
+ name = _cached_resolve_label(label)
129
129
  if name is None:
130
130
  raise LookupError(f"unknown encoding: {label}")
131
- body = bytes(body)
131
+ if type(body) is not bytes:
132
+ body = bytes(body)
132
133
  if name == "UTF-8":
133
134
  return body.decode("utf-8", "replace")
134
135
  if name in _SINGLE_BYTE:
@@ -733,12 +733,10 @@ impl Plan {
733
733
  ) -> (FlatColumns, Vec<GroupRows>, Navigation) {
734
734
  let doc_url = url::Url::parse(url).ok();
735
735
  let (bytes, value_enc, doc_enc) = prepare_bytes(html, encoding, Some(url));
736
- // With no fields to extract, a document that never spells a tag that can change the answer
737
- // needs no scan. Checked on the prepared bytes, where NUL deletion can have joined a tag name.
738
- if self.schema.is_empty()
739
- && !has_start_tag(&bytes, b"base")
740
- && !(options.meta_refresh && has_start_tag(&bytes, b"meta"))
741
- {
736
+ // With no fields to extract, a document that never spells what can change the answer needs no
737
+ // scan. Checked on the prepared bytes, where NUL deletion can have joined a name, and which are
738
+ // ASCII-compatible. A `<base>` only matters to the refresh when there is one.
739
+ if self.schema.is_empty() && !may_change(&bytes, options.base_url, options.meta_refresh) {
742
740
  let base_url = options.base_url.then(|| matcher::document_url(doc_url.as_ref(), url));
743
741
  let nav = Navigation { base_url, meta_refresh: None };
744
742
  return (Vec::new(), Vec::new(), nav);
@@ -750,11 +748,26 @@ impl Plan {
750
748
  }
751
749
  }
752
750
 
753
- /// Whether `<name` occurs in `bytes`, ASCII-case-insensitively: a superset of the start tags named `name`.
754
- fn has_start_tag(bytes: &[u8], name: &[u8]) -> bool {
755
- memchr::memchr_iter(b'<', bytes).any(|i| {
756
- bytes.get(i + 1..i + 1 + name.len()).is_some_and(|candidate| candidate.eq_ignore_ascii_case(name))
757
- })
751
+ /// Whether `bytes` can hold a `<base>`, when `base`, or a `<meta>` with an `http-equiv` attribute,
752
+ /// which every meta refresh needs, when `refresh`. Tags are matched ASCII-case-insensitively wherever
753
+ /// `<name` is followed by the end of a tag name, a superset of the start tags named `name`, and
754
+ /// `http-equiv` anywhere after the first `<meta`, a superset of its attributes. Linear in `bytes`.
755
+ fn may_change(bytes: &[u8], base: bool, refresh: bool) -> bool {
756
+ let tag_at = |i: usize, name: &[u8]| {
757
+ let end = i + 1 + name.len();
758
+ bytes.get(i + 1..end).is_some_and(|candidate| candidate.eq_ignore_ascii_case(name))
759
+ && bytes.get(end).is_some_and(|&c| !tokenizer::is_name_char(c))
760
+ };
761
+ let tags = || memchr::memchr_iter(b'<', bytes);
762
+ (base && tags().any(|i| tag_at(i, b"base")))
763
+ || (refresh
764
+ && tags().find(|&i| tag_at(i, b"meta")).is_some_and(|i| {
765
+ // `q` is the rarest byte of `http-equiv` in markup, so few candidates reach the comparison.
766
+ let rest = &bytes[i..];
767
+ memchr::memchr2_iter(b'q', b'Q', rest).any(|q| {
768
+ q >= 6 && rest.get(q - 6..q + 4).is_some_and(|w| w.eq_ignore_ascii_case(b"http-equiv"))
769
+ })
770
+ }))
758
771
  }
759
772
 
760
773
  /// The base URL and meta refresh of `html`, as a browser reads them; see
@@ -2636,6 +2636,9 @@ impl<'a> TokenSink<'a> for Matcher<'a> {
2636
2636
  self.materialize_attrs::<false>(raw_attrs)
2637
2637
  };
2638
2638
  if let Some(nav) = self.nav.as_mut() {
2639
+ if self_closing {
2640
+ nav.self_closed(name, &self.stack, span_start, open_end, self.input.bytes());
2641
+ }
2639
2642
  if nav.wants(name) {
2640
2643
  nav.start_tag(name, raw_attrs, &self.stack, span_start, self.input.encoding());
2641
2644
  }
@@ -17,6 +17,7 @@ use encoding_rs::Encoding;
17
17
  use url::Url;
18
18
 
19
19
  use super::OpenElem;
20
+ use crate::tokenizer::DataMode;
20
21
 
21
22
  /// What [`crate::navigation`] and [`crate::Plan::extract_with_navigation`] collect.
22
23
  #[derive(Clone, Copy, Debug)]
@@ -55,8 +56,11 @@ pub(crate) struct NavCollector {
55
56
  /// The refresh to follow, with `None` for the document itself.
56
57
  refresh: Option<(u64, Option<Url>)>,
57
58
  /// The open elements that start or end foreign content for a browser, outermost first, tracked
58
- /// until the base URL is settled.
59
+ /// until the answer is settled.
59
60
  scopes: Vec<OpenScope>,
61
+ /// Where the text a browser reads after a self-closed `<script/>`, `<title/>`… ends; the tags
62
+ /// before it are part of that text.
63
+ text_until: usize,
60
64
  }
61
65
 
62
66
  /// What a browser parses the content of an element as. Inside SVG or MathML, a `<base>` is a foreign
@@ -90,19 +94,23 @@ impl NavCollector {
90
94
  base: None,
91
95
  refresh: None,
92
96
  scopes: Vec::new(),
97
+ text_until: 0,
93
98
  }
94
99
  }
95
100
 
96
101
  /// Whether a start tag named `name` can change the answer. Checked before anything else, so outside
97
102
  /// foreign content every other element costs a few comparisons of its name length.
98
103
  pub(crate) fn wants(&self, name: &[u8]) -> bool {
99
- (!self.base_seen && (!self.scopes.is_empty() || is_any(name, &[b"base", b"svg", b"math"])))
100
- || (self.opts.meta_refresh && name.eq_ignore_ascii_case(b"meta"))
104
+ !self.done()
105
+ && (!self.scopes.is_empty()
106
+ || is_any(name, &[b"svg", b"math"])
107
+ || (!self.base_seen && name.eq_ignore_ascii_case(b"base"))
108
+ || (self.opts.meta_refresh && name.eq_ignore_ascii_case(b"meta")))
101
109
  }
102
110
 
103
111
  /// An end tag, which in foreign content breaks out of it if it is `</p>` or `</br>`.
104
112
  pub(crate) fn end_tag(&mut self, name: &[u8], stack: &[OpenElem]) {
105
- if self.base_seen || self.scopes.is_empty() || !is_any(name, &[b"p", b"br"]) {
113
+ if self.done() || self.scopes.is_empty() || !is_any(name, &[b"p", b"br"]) {
106
114
  return;
107
115
  }
108
116
  self.prune(stack);
@@ -189,6 +197,41 @@ impl NavCollector {
189
197
  self.scopes.push(OpenScope { depth, start, scope, broken: false });
190
198
  }
191
199
 
200
+ /// A start tag `name` spanning `start..open_end` of `bytes` and ending in `/>`, below the open
201
+ /// elements `stack`. Called before [`Self::start_tag`].
202
+ ///
203
+ /// Outside foreign content, a browser ignores the slash, so the markup up to the element's end tag is
204
+ /// its text. libxml2 honours the slash, and its raw-text elements are the browser's, less
205
+ /// `<noscript>`, which is raw text to a browser running scripts.
206
+ pub(crate) fn self_closed(
207
+ &mut self,
208
+ name: &[u8],
209
+ stack: &[OpenElem],
210
+ start: usize,
211
+ open_end: usize,
212
+ bytes: &[u8],
213
+ ) {
214
+ if self.done() || start < self.text_until || self.in_noscript_text(stack) {
215
+ return;
216
+ }
217
+ let mode = match crate::implied_close::data_mode_of(name) {
218
+ DataMode::Normal if self.opts.scripting && name.eq_ignore_ascii_case(b"noscript") => {
219
+ DataMode::Rawtext
220
+ }
221
+ DataMode::Normal => return,
222
+ mode => mode,
223
+ };
224
+ self.prune(stack);
225
+ if !self.in_foreign_content() {
226
+ self.text_until = crate::tokenizer::raw_text_end(bytes, open_end, name, mode, &mut Vec::new()).0;
227
+ }
228
+ }
229
+
230
+ /// Whether `stack` has a `<noscript>`, whose content is text to a browser running scripts.
231
+ fn in_noscript_text(&self, stack: &[OpenElem]) -> bool {
232
+ self.opts.scripting && stack.iter().any(|e| e.tag.eq_ignore_ascii_case(b"noscript"))
233
+ }
234
+
192
235
  /// Whether the rest of the document can no longer change the answer.
193
236
  pub(crate) fn done(&self) -> bool {
194
237
  !self.opts.meta_refresh && self.base_seen
@@ -210,21 +253,21 @@ impl NavCollector {
210
253
  .find(|(n, _)| n.eq_ignore_ascii_case(wanted))
211
254
  .map(|&(_, v)| v.map_or(Cow::Borrowed(""), |v| super::decode::decode_attr(v, value_encoding)))
212
255
  };
213
- if !self.base_seen {
214
- self.prune(stack);
215
- self.track_scope(name, attr, stack.len(), start);
256
+ if start < self.text_until {
257
+ return;
216
258
  }
259
+ self.prune(stack);
260
+ self.track_scope(name, attr, stack.len(), start);
217
261
  let is_base = name.eq_ignore_ascii_case(b"base");
218
262
  if !is_base && !name.eq_ignore_ascii_case(b"meta") {
219
263
  return;
220
264
  }
221
265
  // `<template>` content is not part of the document, and a `<noscript>` is text to a browser
222
266
  // running scripts. A `<meta>` breaks out of foreign content and stays an HTML element.
223
- let ignored = |e: &OpenElem| {
224
- e.tag.eq_ignore_ascii_case(b"template")
225
- || (self.opts.scripting && e.tag.eq_ignore_ascii_case(b"noscript"))
226
- };
227
- if stack.iter().any(ignored) || (is_base && self.in_foreign_content()) {
267
+ if stack.iter().any(|e| e.tag.eq_ignore_ascii_case(b"template"))
268
+ || self.in_noscript_text(stack)
269
+ || (is_base && self.in_foreign_content())
270
+ {
228
271
  return;
229
272
  }
230
273
  if is_base {
@@ -469,6 +512,45 @@ mod tests {
469
512
  }
470
513
  }
471
514
 
515
+ /// Whether Chromium 143 and Firefox 144 read a `<base>` after a self-closed element whose content
516
+ /// is text as the base URL (2026-11-05). They agree on every case.
517
+ #[test]
518
+ fn navigation_browser_table_self_closed_text() {
519
+ const B: &str = "<base href='/s/'>";
520
+ let names = ["script", "style", "title", "textarea", "xmp", "iframe", "noembed", "noframes", "noscript"];
521
+ for name in names.iter().chain(&["plaintext"]) {
522
+ let ends = *name != "plaintext";
523
+ let cases: &[(&str, String, bool)] = &[
524
+ ("head", page(&format!("<{name}/>{B}"), ""), false),
525
+ ("body", page("", &format!("<{name}/>{B}")), false),
526
+ ("closed", page("", &format!("<{name}/>x</{name}>{B}")), ends),
527
+ ("closed, upper case", page("", &format!("<{}/>x</{name}>{B}", name.to_uppercase())), ends),
528
+ ("svg", page("", &format!("<svg><{name}/></svg>{B}")), true),
529
+ ("foreignObject", page("", &format!("<svg><foreignObject><{name}/>{B}")), false),
530
+ ("mi", page("", &format!("<math><mi><{name}/></mi></math>{B}")), false),
531
+ ];
532
+ for (case, html, counts) in cases {
533
+ let expected = if *counts { "http://h/s/" } else { DOC };
534
+ assert_eq!(nav(html.as_bytes(), None, true).base_url.as_deref(), Some(expected), "{name}, {case}");
535
+ }
536
+ }
537
+ let cases: &[(&str, &str, bool, bool)] = &[
538
+ ("noscript, no scripting", "<noscript/>{B}", false, true),
539
+ ("script in noscript", "<noscript><script/></noscript>{B}", true, true),
540
+ ("script in noscript, no scripting", "<noscript><script/></noscript>{B}", false, false),
541
+ ("svg in the text", "<title/><svg></title>{B}", true, true),
542
+ ("escaped script", "<script/><!--<script></script>{B}</script>", true, false),
543
+ ("template", "<template><script/></template>{B}", true, false),
544
+ ];
545
+ for (case, body, scripting, counts) in cases {
546
+ let html = page("", &body.replace("{B}", B));
547
+ let expected = if *counts { "http://h/s/" } else { DOC };
548
+ assert_eq!(nav(html.as_bytes(), None, *scripting).base_url.as_deref(), Some(expected), "{case}");
549
+ }
550
+ let html = page("<script/><meta http-equiv=refresh content='5;url=/r'>", "");
551
+ assert_eq!(nav(html.as_bytes(), None, true).meta_refresh, None);
552
+ }
553
+
472
554
  /// The query of a `<base href>` is encoded in the document encoding, as is that of every link
473
555
  /// resolved against it (measured on the links; the base URL itself is not observable).
474
556
  #[test]
@@ -609,6 +691,35 @@ mod tests {
609
691
  assert_eq!(reload.meta_refresh, Some((5, "http://h/".into())));
610
692
  }
611
693
 
694
+ /// A document is not scanned when a byte search rules out what was asked for, so the spellings such a
695
+ /// search could miss still count, and the near misses it must not take for a match answer the same.
696
+ #[test]
697
+ fn what_the_skipped_scan_could_miss_still_counts() {
698
+ let refresh = |html: &[u8]| nav(html, None, true).meta_refresh;
699
+ let to = |delay, url: &str| Some((delay, url.to_owned()));
700
+ assert_eq!(refresh(b"<META HTTP-EQUIV=REFRESH CONTENT=1>"), to(1, DOC));
701
+ assert_eq!(refresh(b"<meta http-equiv=r&#101;fresh content=2>"), to(2, DOC));
702
+ let utf16: Vec<u8> = "\u{feff}<meta http-equiv=refresh content=3>".encode_utf16().flat_map(u16::to_le_bytes).collect();
703
+ assert_eq!(refresh(&utf16), to(3, DOC));
704
+ assert_eq!(refresh(br#"<meta a=">" http-equiv=refresh content=5>"#), to(5, DOC));
705
+ assert_eq!(refresh(b"<script>'<meta a=\"</script><meta http-equiv=refresh content=6>\"'</script>"), to(6, DOC));
706
+ assert_eq!(refresh(b"<meta name=refresh content=4><p http-equiv=refresh>http-equiv</p>"), None);
707
+ let base = |html: &[u8]| nav(html, None, true).base_url;
708
+ assert_eq!(base(b"<BASE\thref=/a/>"), Some("http://h/a/".into()));
709
+ assert_eq!(base(b"<base/href=/b/>"), Some("http://h/b/".into()));
710
+ assert_eq!(base(b"<basefont href=/c/>"), Some(DOC.into()));
711
+ }
712
+
713
+ #[test]
714
+ fn many_unclosed_meta_tags_take_linear_time() {
715
+ let evil = "<meta ".repeat(80000);
716
+ let start = std::time::Instant::now();
717
+ assert_eq!(nav(evil.as_bytes(), None, true).meta_refresh, None);
718
+ let good = evil + r#"<meta http-equiv="refresh" content="3;url=/next">"#;
719
+ assert_eq!(nav(good.as_bytes(), None, true).meta_refresh, Some((3, "http://h/next".into())));
720
+ assert!(start.elapsed().as_secs() < 2, "{:?}", start.elapsed());
721
+ }
722
+
612
723
  #[test]
613
724
  fn delay_is_whole_seconds() {
614
725
  let delay = |content: &str| nav(refresh(content).as_bytes(), None, true).meta_refresh.map(|(d, _)| d);
@@ -113,7 +113,7 @@ fn is_ws(c: u8) -> bool {
113
113
  ///
114
114
  /// `find_raw_end` ends a rawtext close tag on exactly this set, so anything narrower here leaves the
115
115
  /// tokenizer's two name scanners disagreeing with each other as well as with the oracle.
116
- fn is_name_char(c: u8) -> bool {
116
+ pub(crate) fn is_name_char(c: u8) -> bool {
117
117
  !cls::is(c, cls::TAG_NAME_END)
118
118
  }
119
119
 
@@ -235,6 +235,26 @@ fn find_raw_end<'a>(
235
235
  }
236
236
  }
237
237
 
238
+ /// Where the character data of a `name` element in `mode`, starting at `from`, ends. Returns
239
+ /// (text_end, after_end_tag).
240
+ pub(crate) fn raw_text_end<'a>(
241
+ b: &'a [u8],
242
+ from: usize,
243
+ name: &[u8],
244
+ mode: DataMode,
245
+ attr_buf: &mut Vec<(&'a [u8], Option<&'a [u8]>)>,
246
+ ) -> (usize, usize) {
247
+ match mode {
248
+ // PLAINTEXT has no end: the rest of the document is its text, and libxml2 emits no end tag
249
+ // (the element is closed by end-of-document, like any element left open).
250
+ DataMode::Plaintext => (b.len(), b.len()),
251
+ // `script` needs the escaped/double-escaped states on top of "find the end tag"; every
252
+ // other mode ends at the first matching end tag.
253
+ _ if name.eq_ignore_ascii_case(b"script") => find_script_end(b, from, attr_buf),
254
+ _ => find_raw_end(b, from, name, attr_buf),
255
+ }
256
+ }
257
+
238
258
  /// Case-insensitive tag-name match at `p`, including the delimiter required after the name.
239
259
  fn tag_name_at(b: &[u8], p: usize, prefix: &[u8], name: &[u8]) -> Option<usize> {
240
260
  if p + prefix.len() + name.len() > b.len()
@@ -575,15 +595,7 @@ fn handle_start<'a, S: TokenSink<'a>>(
575
595
  sink.start_tag(name, attr_buf, self_closing, p, i);
576
596
 
577
597
  if mode != DataMode::Normal && !self_closing {
578
- // PLAINTEXT has no end: the rest of the document is its text, and libxml2 emits no end tag
579
- // (the element is closed by end-of-document, like any element left open).
580
- let (text_end, after) = match mode {
581
- DataMode::Plaintext => (n, n),
582
- // `script` needs the escaped/double-escaped states on top of "find the end tag"; every
583
- // other mode ends at the first matching end tag.
584
- _ if name.eq_ignore_ascii_case(b"script") => find_script_end(b, i, attr_buf),
585
- _ => find_raw_end(b, i, name, attr_buf),
586
- };
598
+ let (text_end, after) = raw_text_end(b, i, name, mode, attr_buf);
587
599
  if text_end > i {
588
600
  sink.text(&b[i..text_end], mode == DataMode::Rcdata, i);
589
601
  }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes