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.
- {frostwork-0.2.0 → frostwork-0.2.2}/CHANGELOG.md +22 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/Cargo.lock +1 -1
- {frostwork-0.2.0 → frostwork-0.2.2}/Cargo.toml +1 -1
- {frostwork-0.2.0 → frostwork-0.2.2}/PKG-INFO +1 -1
- {frostwork-0.2.0 → frostwork-0.2.2}/pyproject.toml +2 -2
- {frostwork-0.2.0 → frostwork-0.2.2}/python/frostwork/encoding.py +5 -4
- {frostwork-0.2.0 → frostwork-0.2.2}/src/lib.rs +24 -11
- {frostwork-0.2.0 → frostwork-0.2.2}/src/matcher/mod.rs +3 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/src/matcher/navigation.rs +123 -12
- {frostwork-0.2.0 → frostwork-0.2.2}/src/tokenizer.rs +22 -10
- {frostwork-0.2.0 → frostwork-0.2.2}/.github/actions/oracle/action.yml +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/.github/dependabot.yml +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/.github/workflows/bench-migration.yml +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/.github/workflows/ci.yml +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/.github/workflows/publish.yml +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/.github/workflows/scheduled.yml +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/.gitignore +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/.pre-commit-config.yaml +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/AGENTS.md +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/CLAUDE.md +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/LICENSE +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/Makefile +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/README.md +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/SECURITY.md +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/docs/BENCHMARKS.md +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/docs/COMPATIBILITY.md +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/docs/DESIGN.md +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/docs/MIGRATION.md +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/docs/PYTHON.md +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/docs/RELEASING.md +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/docs/SUPPORT_SNAPSHOT.md +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/docs/TESTING.md +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/docs/WEBPOET_SURFACE.md +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/examples/basic.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/examples/basic.rs +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/python/AGENTS.md +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/python/CLAUDE.md +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/python/frostwork/__init__.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/python/frostwork/_frostwork.pyi +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/python/frostwork/_navigation.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/python/frostwork/audit.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/python/frostwork/page.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/python/frostwork/py.typed +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/python/frostwork/scan.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/python/frostwork/validation.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/python/frostwork/webpoet.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/requirements-bench.txt +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/requirements-release.txt +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/requirements-test.txt +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/src/bin/bench.rs +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/src/bin/differ.rs +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/src/diagnostics.rs +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/src/encoding.rs +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/src/entities.rs +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/src/implied_close/generated.rs +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/src/implied_close/mod.rs +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/src/matcher/compile.rs +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/src/matcher/decode.rs +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/src/matcher/deferred.rs +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/src/matcher/frame.rs +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/src/matcher/matching.rs +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/src/matcher/sig.rs +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/src/mutate.rs +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/src/page.rs +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/src/python.rs +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/src/selector.rs +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/src/xpath.rs +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/conftest.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/corpus/README.md +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/corpus/catalog/pages/test-listing.html +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/corpus/catalog/selectors.json +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/corpus/docgen/pages/test-api.html +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/corpus/docgen/selectors.json +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/corpus/tabular/pages/test-report.html +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/corpus/tabular/selectors.json +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/migration/article.html +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/migration/listing.html +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/migration/manifest.json +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/migration/pages.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/migration/product.html +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_audit_context.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_composition.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_doc_examples.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_docs_check.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_encoding.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_gates.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_make_gates.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_migration.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_navigation.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_python.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_release.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_schema.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_typing.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/test_validation.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tests/typing_fixture.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/AGENTS.md +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/CLAUDE.md +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/ab_bench.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/abi3_smoke.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/audit_tree_rules.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/bench_corpus.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/bench_encoding.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/bench_engines.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/bench_matrix.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/bench_mem.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/bench_navigation.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/bench_webpoet.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/check_docs.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/conformant.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/corpus_fetch.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/decoder_sweep.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/diff_fuzz.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/diff_lxml.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/diff_webpoet.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/enc_cases.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/enc_check.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/encpages.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/families.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/foreign.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/gen_tree_rules.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/mutate_rules.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/mutate_webpoet.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/oracle.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/release_check.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/sel_fuzz.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/seq_sweep.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/soak.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/support_snapshot.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/verify_migration.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/verify_pypi.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/webpoet_cases.py +0 -0
- {frostwork-0.2.0 → frostwork-0.2.2}/tools/webpoet_structure.py +0 -0
- {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
|
|
@@ -5,7 +5,7 @@ build-backend = "maturin"
|
|
|
5
5
|
|
|
6
6
|
[project]
|
|
7
7
|
name = "frostwork"
|
|
8
|
-
version = "0.2.
|
|
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.
|
|
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 =
|
|
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 =
|
|
128
|
+
name = _cached_resolve_label(label)
|
|
129
129
|
if name is None:
|
|
130
130
|
raise LookupError(f"unknown encoding: {label}")
|
|
131
|
-
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
|
|
737
|
-
//
|
|
738
|
-
|
|
739
|
-
|
|
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
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
|
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
|
-
|
|
100
|
-
|
|
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.
|
|
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
|
|
214
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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=refresh 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
|
-
|
|
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
|
|
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
|
|
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
|