justhtml 2.1.0__tar.gz → 2.3.0__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.
- {justhtml-2.1.0 → justhtml-2.3.0}/CHANGELOG.md +29 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/PKG-INFO +3 -1
- {justhtml-2.1.0 → justhtml-2.3.0}/README.md +2 -0
- justhtml-2.3.0/assets/justhtml-readme-explainer.png +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/pyproject.toml +1 -1
- {justhtml-2.1.0 → justhtml-2.3.0}/run_tests.py +13 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/parser/__init__.py +10 -6
- justhtml-2.3.0/src/justhtml/parser/stream.py +206 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/sanitizer/url/runtime.py +2 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/serializer/html.py +4 -2
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/tokenizer/html.py +48 -4
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/treebuilder/core.py +232 -104
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/treebuilder/modes.py +126 -93
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/justhtml-tests/branch_coverage.dat +3 -3
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/test_linkify_internals.py +6 -2
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/test_sanitize.py +86 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/test_serialize.py +66 -7
- justhtml-2.3.0/tests/test_stream.py +273 -0
- justhtml-2.3.0/tests/test_treebuilder.py +774 -0
- justhtml-2.1.0/src/justhtml/parser/stream.py +0 -110
- justhtml-2.1.0/tests/test_precommit_coverage.py +0 -83
- justhtml-2.1.0/tests/test_stream.py +0 -65
- justhtml-2.1.0/tests/test_treebuilder.py +0 -249
- {justhtml-2.1.0 → justhtml-2.3.0}/.github/copilot-instructions.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/.github/workflows/ci.yml +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/.github/workflows/publish.yml +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/.gitignore +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/.pre-commit-config.yaml +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/CODE_OF_CONDUCT.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/CONTRIBUTING.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/LICENSE +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/Makefile +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/SECURITY.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/benchmarks/correctness.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/benchmarks/fuzz.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/benchmarks/performance.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/benchmarks/profile.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/_config.yml +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/_layouts/default.html +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/api.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/assets/search.js +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/bleach-migration.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/building.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/cli.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/comparison.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/correctness.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/encoding.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/errors.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/fragments.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/html-cleaning.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/index.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/linkify.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/migration-examples.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/playground/app.js +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/playground/index.html +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/playground/py/__init__.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/playground/py/install_latest_justhtml.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/playground/py/render.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/playground/py/use_local_repo.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/quickstart.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/sanitization.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/selectors.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/streaming.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/text.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/transforms.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/unsafe-handling.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/docs/url-cleaning.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/llms.txt +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/scripts/release.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/__init__.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/__main__.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/core/__init__.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/core/constants.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/core/entities.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/core/errors.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/core/rawtext.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/dom/__init__.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/dom/builder.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/parser/context.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/parser/encoding.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/py.typed +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/sanitizer/__init__.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/sanitizer/css.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/sanitizer/dom.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/sanitizer/policy.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/sanitizer/policy_defaults.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/sanitizer/rawtext.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/sanitizer/url/__init__.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/sanitizer/url/policy.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/sanitizer/url/spec.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/selector/__init__.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/selector/core.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/serializer/__init__.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/serializer/markdown.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/tokenizer/__init__.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/tokenizer/tokens.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/transforms/__init__.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/transforms/compile.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/transforms/linkify.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/transforms/linkify_core.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/transforms/runtime.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/transforms/spec.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/treebuilder/__init__.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/src/justhtml/treebuilder/utils.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/test-summary.txt +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/README.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/__init__.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/data/wikipedia.html +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/harness/__init__.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/harness/encoding.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/harness/regressions.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/harness/reporter.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/harness/serializer.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/harness/tokenizer.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/harness/tree.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/justhtml-sanitize-tests/cases.json +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/justhtml-tests/coverage_gaps.test +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/justhtml-tests/empty_stack_edge_cases.dat +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/justhtml-tests/entities.test +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/justhtml-tests/iframe_srcdoc.dat +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/justhtml-tests/tokenizer_edge_cases.test +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/justhtml-tests/treebuilder_coverage.dat +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/justhtml-tests/xml_coercion.dat +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/justhtml-tests/xml_coercion_coverage.test +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/linkify-it/LICENSE.txt +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/linkify-it/README.md +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/linkify-it/fixtures/links.txt +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/linkify-it/fixtures/not_links.txt +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/test_builder.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/test_cli.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/test_docs_examples.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/test_encoding.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/test_errors.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/test_linkify_it.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/test_linkify_transform.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/test_node.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/test_playground_local_repo_file_list.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/test_sanitize_integration.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/test_sanitize_transform.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/test_selector.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/test_tokenizer.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/test_transforms.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/test_transforms_compiler.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/test_transforms_edge_cases.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/test_transforms_sanitize_integration.py +0 -0
- {justhtml-2.1.0 → justhtml-2.3.0}/tests/test_wikipedia.py +0 -0
|
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.3.0] - 2026-06-12
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Preserve pretty-printed paragraph line breaks when sanitization is disabled and comments are present in block containers.
|
|
14
|
+
|
|
15
|
+
### Performance
|
|
16
|
+
- Speed up tree construction scope checks by tracking open elements for common scoped element names.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- Refactor tree-builder open-element bookkeeping and active-formatting entries without changing the public API.
|
|
20
|
+
|
|
21
|
+
## [2.2.0] - 2026-06-07
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- Handle `<select><selectedcontent></selectedcontent></select>` without crashing when no `<option>` is present, replace selectedcontent fallback content during parser finalization, and avoid repeated selectedcontent subtree scans.
|
|
25
|
+
- Preserve source order and tag text when escape-mode sanitization handles disallowed rawtext/RCDATA elements with attributed or self-closing end tags.
|
|
26
|
+
- Make `stream()` use namespace-aware tokenizer context for SVG/MathML CDATA, rawtext decisions, self-closing foreign tags, and foreign end-tag stack updates.
|
|
27
|
+
- Use the correct initial tokenizer states for HTML fragment contexts such as `<title>`, `<textarea>`, `<script>`, `<style>`, and scripting-disabled `<noscript>`.
|
|
28
|
+
- Use HTML rawtext/RCDATA tokenizer states for text-like elements inside SVG/MathML HTML integration points and MathML text integration points.
|
|
29
|
+
- Generate implied end tags before removing `<form>` on `</form>` so following controls do not remain inside still-open descendants.
|
|
30
|
+
- Keep `<form>` elements inside `<template>` from claiming the global form pointer, including table-template form insertion.
|
|
31
|
+
- Close open `<p>` elements correctly around `<option>`, `<optgroup>`, `<hr>`, `<p>`, and `<div>` starts in `<select>` parsing.
|
|
32
|
+
- Close `<template>` correctly when `</template>` is seen while parsing inside `<select>`.
|
|
33
|
+
- Keep `</p>` and `</br>` foreign-content breakouts inside MathML text integration points such as `<mi>` and `<mtext>`.
|
|
34
|
+
- Align customizable `<select>` parsing with Chromium for phantom `</p>` handling and generic custom child elements.
|
|
35
|
+
|
|
36
|
+
### Security
|
|
37
|
+
- (Severity: Low) Strip invisible Unicode during URL sink validation even when general invisible-Unicode stripping is disabled. Previously, custom policies using `strip_invisible_unicode=False` could preserve scheme-obfuscated values such as `javascript\u200b:` in otherwise URL-validated attributes.
|
|
38
|
+
|
|
10
39
|
## [2.1.0] - 2026-06-06
|
|
11
40
|
|
|
12
41
|
### Performance
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: justhtml
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: A pure Python HTML5 parser that just works.
|
|
5
5
|
Project-URL: Homepage, https://github.com/emilstenstrom/justhtml
|
|
6
6
|
Project-URL: Issues, https://github.com/emilstenstrom/justhtml/issues
|
|
@@ -72,6 +72,8 @@ Requires Python 3.10 or later.
|
|
|
72
72
|
|
|
73
73
|
[Documentation](https://emilstenstrom.github.io/justhtml/) | [Comparison](docs/comparison.md) | [Playground](https://emilstenstrom.github.io/justhtml/playground/) | [Security policy](SECURITY.md)
|
|
74
74
|
|
|
75
|
+

|
|
76
|
+
|
|
75
77
|
## Why Use It?
|
|
76
78
|
|
|
77
79
|
Most Python HTML libraries optimize for one part of the problem.
|
|
@@ -18,6 +18,8 @@ Requires Python 3.10 or later.
|
|
|
18
18
|
|
|
19
19
|
[Documentation](https://emilstenstrom.github.io/justhtml/) | [Comparison](docs/comparison.md) | [Playground](https://emilstenstrom.github.io/justhtml/playground/) | [Security policy](SECURITY.md)
|
|
20
20
|
|
|
21
|
+

|
|
22
|
+
|
|
21
23
|
## Why Use It?
|
|
22
24
|
|
|
23
25
|
Most Python HTML libraries optimize for one part of the problem.
|
|
Binary file
|
|
@@ -194,6 +194,7 @@ def _run_unit_tests(config):
|
|
|
194
194
|
self._test_counter = 0
|
|
195
195
|
self._test_to_index: dict[str, int] = {}
|
|
196
196
|
self.test_indices: list[tuple[str, int]] = []
|
|
197
|
+
self._failed_test_ids: set[str] = set()
|
|
197
198
|
|
|
198
199
|
def startTest(self, test): # noqa: N802 - unittest API
|
|
199
200
|
super().startTest(test)
|
|
@@ -211,10 +212,22 @@ def _run_unit_tests(config):
|
|
|
211
212
|
|
|
212
213
|
def addFailure(self, test, err): # noqa: N802 - unittest API
|
|
213
214
|
super().addFailure(test, err)
|
|
215
|
+
self._failed_test_ids.add(test.id())
|
|
214
216
|
self.test_indices.append(("fail", self._idx(test)))
|
|
215
217
|
|
|
216
218
|
def addError(self, test, err): # noqa: N802 - unittest API
|
|
217
219
|
super().addError(test, err)
|
|
220
|
+
self._failed_test_ids.add(test.id())
|
|
221
|
+
self.test_indices.append(("fail", self._idx(test)))
|
|
222
|
+
|
|
223
|
+
def addSubTest(self, test, subtest, err): # noqa: N802 - unittest API
|
|
224
|
+
super().addSubTest(test, subtest, err)
|
|
225
|
+
if err is None:
|
|
226
|
+
return
|
|
227
|
+
test_id = test.id()
|
|
228
|
+
if test_id in self._failed_test_ids:
|
|
229
|
+
return
|
|
230
|
+
self._failed_test_ids.add(test_id)
|
|
218
231
|
self.test_indices.append(("fail", self._idx(test)))
|
|
219
232
|
|
|
220
233
|
def addSkip(self, test, reason): # noqa: N802 - unittest API
|
|
@@ -156,6 +156,7 @@ class JustHTML:
|
|
|
156
156
|
iframe_srcdoc=iframe_srcdoc,
|
|
157
157
|
collect_errors=should_collect,
|
|
158
158
|
scripting_enabled=scripting_enabled,
|
|
159
|
+
track_node_locations=bool(track_node_locations),
|
|
159
160
|
track_tag_spans=track_tag_spans,
|
|
160
161
|
)
|
|
161
162
|
opts = _tokenizer_opts.copy() if _tokenizer_opts is not None else TokenizerOpts()
|
|
@@ -163,14 +164,17 @@ class JustHTML:
|
|
|
163
164
|
if needs_escape_incomplete_tags:
|
|
164
165
|
opts.emit_bogus_markup_as_text = True
|
|
165
166
|
|
|
166
|
-
# For
|
|
167
|
-
|
|
168
|
-
|
|
167
|
+
# For text-like HTML fragment contexts, set the initial tokenizer state
|
|
168
|
+
# to match the context element.
|
|
169
|
+
if fragment_context and fragment_context.namespace in {None, "html"}:
|
|
169
170
|
tag_name = fragment_context.tag_name.lower()
|
|
170
|
-
if tag_name in
|
|
171
|
+
if tag_name in {"textarea", "title"}:
|
|
172
|
+
opts.initial_state = Tokenizer.RCDATA
|
|
173
|
+
elif tag_name in {"iframe", "noembed", "noframes", "script", "style", "xmp"} or (
|
|
174
|
+
tag_name == "noscript" and opts.scripting_enabled
|
|
175
|
+
):
|
|
171
176
|
opts.initial_state = Tokenizer.RAWTEXT
|
|
172
|
-
|
|
173
|
-
elif tag_name in ("plaintext", "script"):
|
|
177
|
+
elif tag_name == "plaintext":
|
|
174
178
|
opts.initial_state = Tokenizer.PLAINTEXT
|
|
175
179
|
|
|
176
180
|
self.tokenizer = Tokenizer(
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Literal, TypeAlias, cast
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from collections.abc import Generator
|
|
7
|
+
|
|
8
|
+
from justhtml.core.constants import (
|
|
9
|
+
FOREIGN_BREAKOUT_ELEMENTS,
|
|
10
|
+
HTML_INTEGRATION_POINT_SET,
|
|
11
|
+
MATHML_TEXT_INTEGRATION_POINT_SET,
|
|
12
|
+
SVG_TAG_NAME_ADJUSTMENTS,
|
|
13
|
+
)
|
|
14
|
+
from justhtml.tokenizer import Tokenizer
|
|
15
|
+
from justhtml.tokenizer.tokens import CommentToken, DoctypeToken, Tag
|
|
16
|
+
|
|
17
|
+
from .encoding import decode_html
|
|
18
|
+
|
|
19
|
+
StartEvent: TypeAlias = tuple[Literal["start"], tuple[str, dict[str, str | None]]]
|
|
20
|
+
EndEvent: TypeAlias = tuple[Literal["end"], str]
|
|
21
|
+
TextEvent: TypeAlias = tuple[Literal["text"], str]
|
|
22
|
+
CommentEvent: TypeAlias = tuple[Literal["comment"], str]
|
|
23
|
+
DoctypeEvent: TypeAlias = tuple[Literal["doctype"], tuple[str | None, str | None, str | None]]
|
|
24
|
+
StreamEvent: TypeAlias = StartEvent | EndEvent | TextEvent | CommentEvent | DoctypeEvent
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _DummyNode:
|
|
28
|
+
__slots__ = ("attrs", "name", "namespace")
|
|
29
|
+
|
|
30
|
+
attrs: dict[str, str | None]
|
|
31
|
+
name: str
|
|
32
|
+
namespace: str
|
|
33
|
+
|
|
34
|
+
def __init__(self, name: str, namespace: str, attrs: dict[str, str | None] | None = None) -> None:
|
|
35
|
+
self.attrs = attrs or {}
|
|
36
|
+
self.name = name
|
|
37
|
+
self.namespace = namespace
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class StreamSink:
|
|
41
|
+
"""A sink that buffers tokens for the stream API."""
|
|
42
|
+
|
|
43
|
+
tokens: list[StreamEvent]
|
|
44
|
+
open_elements: list[_DummyNode]
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
self.tokens = []
|
|
48
|
+
self.open_elements = [] # Required by tokenizer for rawtext checks
|
|
49
|
+
|
|
50
|
+
def _font_breaks_out_of_foreign_content(self, attrs: dict[str, str | None]) -> bool:
|
|
51
|
+
for name in attrs:
|
|
52
|
+
if name.lower() in {"color", "face", "size"}:
|
|
53
|
+
return True
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
def _node_attribute_value(self, node: _DummyNode, name: str) -> str | None:
|
|
57
|
+
target = name.lower()
|
|
58
|
+
for attr_name, attr_value in node.attrs.items():
|
|
59
|
+
if attr_name.lower() == target:
|
|
60
|
+
return attr_value or ""
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
def _is_html_integration_point(self, node: _DummyNode) -> bool:
|
|
64
|
+
if node.namespace == "math" and node.name == "annotation-xml":
|
|
65
|
+
encoding = self._node_attribute_value(node, "encoding")
|
|
66
|
+
return encoding is not None and encoding.lower() in {"application/xhtml+xml", "text/html"}
|
|
67
|
+
return (node.namespace, node.name) in HTML_INTEGRATION_POINT_SET
|
|
68
|
+
|
|
69
|
+
def _is_mathml_text_integration_point(self, node: _DummyNode) -> bool:
|
|
70
|
+
return (node.namespace, node.name) in MATHML_TEXT_INTEGRATION_POINT_SET
|
|
71
|
+
|
|
72
|
+
def _adjusted_name_for_namespace(self, name: str, namespace: str) -> str:
|
|
73
|
+
if namespace == "svg":
|
|
74
|
+
return SVG_TAG_NAME_ADJUSTMENTS.get(name, name)
|
|
75
|
+
return name
|
|
76
|
+
|
|
77
|
+
def _namespace_from_html_context(self, name: str) -> str:
|
|
78
|
+
if name == "svg":
|
|
79
|
+
return "svg"
|
|
80
|
+
if name == "math":
|
|
81
|
+
return "math"
|
|
82
|
+
return "html"
|
|
83
|
+
|
|
84
|
+
def _namespace_for_start_tag(self, token: Tag) -> str:
|
|
85
|
+
name = token.name
|
|
86
|
+
parent = self.open_elements[-1] if self.open_elements else None
|
|
87
|
+
parent_namespace = parent.namespace if parent is not None else "html"
|
|
88
|
+
|
|
89
|
+
if parent is not None:
|
|
90
|
+
if self._is_html_integration_point(parent):
|
|
91
|
+
return self._namespace_from_html_context(name)
|
|
92
|
+
if self._is_mathml_text_integration_point(parent) and name not in {"mglyph", "malignmark"}:
|
|
93
|
+
return self._namespace_from_html_context(name)
|
|
94
|
+
if parent_namespace == "math" and parent.name == "annotation-xml" and name == "svg":
|
|
95
|
+
return "svg"
|
|
96
|
+
|
|
97
|
+
if parent_namespace not in {None, "html"}:
|
|
98
|
+
breaks_out = name in FOREIGN_BREAKOUT_ELEMENTS or (
|
|
99
|
+
name == "font" and self._font_breaks_out_of_foreign_content(token.attrs)
|
|
100
|
+
)
|
|
101
|
+
if breaks_out:
|
|
102
|
+
while self.open_elements and self.open_elements[-1].namespace not in {None, "html"}:
|
|
103
|
+
self.open_elements.pop()
|
|
104
|
+
parent_namespace = self.open_elements[-1].namespace if self.open_elements else "html"
|
|
105
|
+
else:
|
|
106
|
+
return parent_namespace
|
|
107
|
+
|
|
108
|
+
return self._namespace_from_html_context(name)
|
|
109
|
+
|
|
110
|
+
def _pop_foreign_context(self) -> None:
|
|
111
|
+
while self.open_elements and self.open_elements[-1].namespace not in {None, "html"}:
|
|
112
|
+
self.open_elements.pop()
|
|
113
|
+
|
|
114
|
+
def _pop_for_end_tag(self, name: str) -> None:
|
|
115
|
+
if not self.open_elements:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
name_lower = name.lower()
|
|
119
|
+
current = self.open_elements[-1]
|
|
120
|
+
if current.namespace not in {None, "html"} and name_lower in {"br", "p"}:
|
|
121
|
+
self._pop_foreign_context()
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
for index in range(len(self.open_elements) - 1, -1, -1):
|
|
125
|
+
node = self.open_elements[index]
|
|
126
|
+
if node.name.lower() == name_lower:
|
|
127
|
+
del self.open_elements[index:]
|
|
128
|
+
return
|
|
129
|
+
if node.namespace in {None, "html"}:
|
|
130
|
+
break
|
|
131
|
+
|
|
132
|
+
self.open_elements.pop()
|
|
133
|
+
|
|
134
|
+
def process_token(self, token: Tag | CommentToken | DoctypeToken | Any) -> int:
|
|
135
|
+
# Tokenizer reuses token objects, so we must copy data
|
|
136
|
+
if isinstance(token, Tag):
|
|
137
|
+
# Copy tag data
|
|
138
|
+
if token.kind == Tag.START:
|
|
139
|
+
self.tokens.append(("start", (token.name, token.attrs.copy())))
|
|
140
|
+
else:
|
|
141
|
+
self.tokens.append(("end", token.name))
|
|
142
|
+
# Maintain open_elements stack for tokenizer rawtext/CDATA checks.
|
|
143
|
+
if token.kind == Tag.START:
|
|
144
|
+
namespace = self._namespace_for_start_tag(token)
|
|
145
|
+
if not (token.self_closing and namespace not in {None, "html"}):
|
|
146
|
+
name = self._adjusted_name_for_namespace(token.name, namespace)
|
|
147
|
+
self.open_elements.append(_DummyNode(name, namespace, token.attrs.copy()))
|
|
148
|
+
else: # Tag.END
|
|
149
|
+
self._pop_for_end_tag(token.name)
|
|
150
|
+
|
|
151
|
+
elif isinstance(token, CommentToken):
|
|
152
|
+
self.tokens.append(("comment", token.data))
|
|
153
|
+
|
|
154
|
+
elif isinstance(token, DoctypeToken):
|
|
155
|
+
dt = token.doctype
|
|
156
|
+
self.tokens.append(("doctype", (dt.name, dt.public_id, dt.system_id)))
|
|
157
|
+
|
|
158
|
+
return 0 # TokenSinkResult.Continue
|
|
159
|
+
|
|
160
|
+
def process_characters(self, data: str) -> None:
|
|
161
|
+
"""Handle character data from tokenizer."""
|
|
162
|
+
self.tokens.append(("text", data))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def stream(
|
|
166
|
+
html: str | bytes | bytearray | memoryview,
|
|
167
|
+
*,
|
|
168
|
+
encoding: str | None = None,
|
|
169
|
+
) -> Generator[StreamEvent, None, None]:
|
|
170
|
+
"""
|
|
171
|
+
Stream HTML events from the given HTML string.
|
|
172
|
+
Yields tuples of (event_type, data).
|
|
173
|
+
"""
|
|
174
|
+
html_str: str
|
|
175
|
+
if isinstance(html, (bytes, bytearray, memoryview)):
|
|
176
|
+
html_str, _ = decode_html(bytes(html), transport_encoding=encoding)
|
|
177
|
+
else:
|
|
178
|
+
html_str = html
|
|
179
|
+
sink = StreamSink()
|
|
180
|
+
tokenizer = Tokenizer(sink)
|
|
181
|
+
tokenizer.initialize(html_str)
|
|
182
|
+
|
|
183
|
+
while True:
|
|
184
|
+
# Run one step of the tokenizer
|
|
185
|
+
is_eof = tokenizer.step()
|
|
186
|
+
|
|
187
|
+
# Yield any tokens produced by this step
|
|
188
|
+
if sink.tokens:
|
|
189
|
+
# Coalesce text tokens
|
|
190
|
+
text_buffer: list[str] = []
|
|
191
|
+
for event, data in sink.tokens:
|
|
192
|
+
if event == "text":
|
|
193
|
+
text_buffer.append(cast("str", data))
|
|
194
|
+
else:
|
|
195
|
+
if text_buffer:
|
|
196
|
+
yield ("text", "".join(text_buffer))
|
|
197
|
+
text_buffer = []
|
|
198
|
+
yield cast("StartEvent | EndEvent | CommentEvent | DoctypeEvent", (event, data))
|
|
199
|
+
|
|
200
|
+
if text_buffer:
|
|
201
|
+
yield ("text", "".join(text_buffer))
|
|
202
|
+
|
|
203
|
+
sink.tokens.clear()
|
|
204
|
+
|
|
205
|
+
if is_eof:
|
|
206
|
+
break
|
|
@@ -26,7 +26,7 @@ if TYPE_CHECKING:
|
|
|
26
26
|
# Note: This matches the logic of the previous loop-based implementation.
|
|
27
27
|
# It checks for space characters, quotes, equals sign, and greater-than.
|
|
28
28
|
_UNQUOTED_ATTR_VALUE_INVALID = re.compile(r'[ \t\n\f\r"\'=>]')
|
|
29
|
-
_LITERAL_TEXT_SERIALIZATION_ELEMENTS = frozenset({"script", "style"})
|
|
29
|
+
_LITERAL_TEXT_SERIALIZATION_ELEMENTS = frozenset({"plaintext", "script", "style"})
|
|
30
30
|
_SERIALIZABLE_TAG_NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9:_-]*$")
|
|
31
31
|
_SERIALIZABLE_ATTR_NAME_RE = re.compile(r"^[A-Za-z_:][A-Za-z0-9:._-]*$")
|
|
32
32
|
|
|
@@ -101,6 +101,8 @@ def _serialize_text_for_parent(text: str | None, parent_name: str | None) -> str
|
|
|
101
101
|
if parent_name is not None:
|
|
102
102
|
normalized_parent_name = parent_name if parent_name.islower() else parent_name.lower()
|
|
103
103
|
if normalized_parent_name in _LITERAL_TEXT_SERIALIZATION_ELEMENTS:
|
|
104
|
+
if normalized_parent_name == "plaintext":
|
|
105
|
+
return text
|
|
104
106
|
return _neutralize_rawtext_end_tag_sequences(text, normalized_parent_name)
|
|
105
107
|
return _escape_text(text)
|
|
106
108
|
|
|
@@ -754,7 +756,7 @@ def _node_to_html(node: Any, indent: int = 0, indent_size: int = 2, *, in_pre: b
|
|
|
754
756
|
for child in children:
|
|
755
757
|
if child is None:
|
|
756
758
|
continue
|
|
757
|
-
if child.name == "#
|
|
759
|
+
if child.name == "#text" and (child.data or "").strip():
|
|
758
760
|
can_indent = False
|
|
759
761
|
break
|
|
760
762
|
|
|
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
7
7
|
if TYPE_CHECKING:
|
|
8
8
|
from collections.abc import Callable
|
|
9
9
|
|
|
10
|
+
from justhtml.core.constants import HTML_INTEGRATION_POINT_SET, MATHML_TEXT_INTEGRATION_POINT_SET
|
|
10
11
|
from justhtml.core.entities import decode_entities_in_text
|
|
11
12
|
from justhtml.core.errors import generate_error_message
|
|
12
13
|
|
|
@@ -35,6 +36,7 @@ _ATTR_VALUE_UNQUOTED_FAST_BAD_PATTERN = re.compile(r"""[\x00"'<=`]""")
|
|
|
35
36
|
_TAG_NAME_RUN_PATTERN = re.compile(r"[^\t\n\f />\0]+")
|
|
36
37
|
_ATTR_NAME_RUN_PATTERN = re.compile(r"[^\t\n\f />=\0\"'<]+")
|
|
37
38
|
_COMMENT_RUN_PATTERN = re.compile(r"[^-\0]+")
|
|
39
|
+
_HTML_INTEGRATION_POINT_ENCODINGS = {"application/xhtml+xml", "text/html"}
|
|
38
40
|
|
|
39
41
|
# XML Coercion Regex
|
|
40
42
|
_xml_invalid_single_chars = []
|
|
@@ -2230,10 +2232,7 @@ class Tokenizer:
|
|
|
2230
2232
|
or (name == "noscript" and self.opts.scripting_enabled)
|
|
2231
2233
|
)
|
|
2232
2234
|
if needs_rawtext_check:
|
|
2233
|
-
|
|
2234
|
-
current_node = stack[-1] if stack else None
|
|
2235
|
-
namespace = current_node.namespace if current_node else None
|
|
2236
|
-
if namespace is None or namespace == "html":
|
|
2235
|
+
if self._current_node_uses_html_text_parsing():
|
|
2237
2236
|
if name in _RCDATA_ELEMENTS:
|
|
2238
2237
|
self.state = self.RCDATA
|
|
2239
2238
|
self.rawtext_tag_name = name
|
|
@@ -2260,6 +2259,37 @@ class Tokenizer:
|
|
|
2260
2259
|
self.current_tag_kind = Tag.START
|
|
2261
2260
|
return switched_to_rawtext
|
|
2262
2261
|
|
|
2262
|
+
def _current_node_uses_html_text_parsing(self) -> bool:
|
|
2263
|
+
stack = self.sink.open_elements
|
|
2264
|
+
current_node = stack[-1] if stack else None
|
|
2265
|
+
if current_node is None:
|
|
2266
|
+
return True
|
|
2267
|
+
|
|
2268
|
+
namespace = current_node.namespace
|
|
2269
|
+
if namespace is None or namespace == "html":
|
|
2270
|
+
return True
|
|
2271
|
+
|
|
2272
|
+
node_name = current_node.name
|
|
2273
|
+
if (namespace, node_name) in MATHML_TEXT_INTEGRATION_POINT_SET:
|
|
2274
|
+
return True
|
|
2275
|
+
|
|
2276
|
+
if namespace == "math" and node_name == "annotation-xml":
|
|
2277
|
+
encoding = self._node_attribute_value(current_node, "encoding")
|
|
2278
|
+
return encoding is not None and encoding.lower() in _HTML_INTEGRATION_POINT_ENCODINGS
|
|
2279
|
+
|
|
2280
|
+
return (namespace, node_name) in HTML_INTEGRATION_POINT_SET
|
|
2281
|
+
|
|
2282
|
+
def _node_attribute_value(self, node: Any, name: str) -> str | None:
|
|
2283
|
+
attrs = node.attrs
|
|
2284
|
+
if not attrs:
|
|
2285
|
+
return None
|
|
2286
|
+
|
|
2287
|
+
target = name.lower()
|
|
2288
|
+
for attr_name, attr_value in attrs.items():
|
|
2289
|
+
if attr_name.lower() == target:
|
|
2290
|
+
return attr_value or ""
|
|
2291
|
+
return None
|
|
2292
|
+
|
|
2263
2293
|
def _emit_incomplete_tag_as_text(self) -> None:
|
|
2264
2294
|
if not self.opts.emit_bogus_markup_as_text:
|
|
2265
2295
|
return
|
|
@@ -2528,6 +2558,7 @@ class Tokenizer:
|
|
|
2528
2558
|
else:
|
|
2529
2559
|
# lt_index == pos - the only remaining possibility
|
|
2530
2560
|
# Less-than sign - might be start of end tag
|
|
2561
|
+
self.current_token_start_pos = pos
|
|
2531
2562
|
pos += 1
|
|
2532
2563
|
self.pos = pos
|
|
2533
2564
|
self.state = self.RCDATA_LESS_THAN_SIGN
|
|
@@ -2570,6 +2601,9 @@ class Tokenizer:
|
|
|
2570
2601
|
if c == ">":
|
|
2571
2602
|
attrs: dict[str, str | None] = {}
|
|
2572
2603
|
tag = Tag(Tag.END, tag_name, attrs, False)
|
|
2604
|
+
if self.track_tag_positions:
|
|
2605
|
+
tag.start_pos = self.current_token_start_pos
|
|
2606
|
+
tag.end_pos = self.pos
|
|
2573
2607
|
self._flush_text()
|
|
2574
2608
|
self._emit_token(tag)
|
|
2575
2609
|
self.state = self.DATA
|
|
@@ -2578,6 +2612,7 @@ class Tokenizer:
|
|
|
2578
2612
|
return False
|
|
2579
2613
|
if c in (" ", "\t", "\n", "\r", "\f"):
|
|
2580
2614
|
# Whitespace after tag name - switch to BEFORE_ATTRIBUTE_NAME
|
|
2615
|
+
self._flush_text()
|
|
2581
2616
|
self.current_tag_kind = Tag.END
|
|
2582
2617
|
self.current_tag_attrs = {}
|
|
2583
2618
|
self.state = self.BEFORE_ATTRIBUTE_NAME
|
|
@@ -2647,6 +2682,7 @@ class Tokenizer:
|
|
|
2647
2682
|
if lt_index > pos:
|
|
2648
2683
|
chunk = buffer[pos:lt_index]
|
|
2649
2684
|
self._append_text(chunk)
|
|
2685
|
+
self.current_token_start_pos = lt_index
|
|
2650
2686
|
pos = lt_index + 1
|
|
2651
2687
|
self.pos = pos
|
|
2652
2688
|
# Handle script escaped transition before treating '<' as markup boundary
|
|
@@ -2701,6 +2737,9 @@ class Tokenizer:
|
|
|
2701
2737
|
if c == ">":
|
|
2702
2738
|
attrs: dict[str, str | None] = {}
|
|
2703
2739
|
tag = Tag(Tag.END, tag_name, attrs, False)
|
|
2740
|
+
if self.track_tag_positions:
|
|
2741
|
+
tag.start_pos = self.current_token_start_pos
|
|
2742
|
+
tag.end_pos = self.pos
|
|
2704
2743
|
self._flush_text()
|
|
2705
2744
|
self._emit_token(tag)
|
|
2706
2745
|
self.state = self.DATA
|
|
@@ -2709,6 +2748,7 @@ class Tokenizer:
|
|
|
2709
2748
|
return False
|
|
2710
2749
|
if c in (" ", "\t", "\n", "\r", "\f"):
|
|
2711
2750
|
# Whitespace after tag name - switch to BEFORE_ATTRIBUTE_NAME
|
|
2751
|
+
self._flush_text()
|
|
2712
2752
|
self.current_tag_kind = Tag.END
|
|
2713
2753
|
self.current_tag_attrs = {}
|
|
2714
2754
|
self.state = self.BEFORE_ATTRIBUTE_NAME
|
|
@@ -2866,6 +2906,7 @@ class Tokenizer:
|
|
|
2866
2906
|
|
|
2867
2907
|
if is_appropriate:
|
|
2868
2908
|
if c in (" ", "\t", "\n", "\r", "\f"):
|
|
2909
|
+
self._flush_text()
|
|
2869
2910
|
self.current_tag_kind = Tag.END
|
|
2870
2911
|
self.current_tag_attrs = {}
|
|
2871
2912
|
self.state = self.BEFORE_ATTRIBUTE_NAME
|
|
@@ -2880,6 +2921,9 @@ class Tokenizer:
|
|
|
2880
2921
|
self._flush_text()
|
|
2881
2922
|
attrs: dict[str, str | None] = {}
|
|
2882
2923
|
tag = Tag(Tag.END, tag_name, attrs, False)
|
|
2924
|
+
if self.track_tag_positions:
|
|
2925
|
+
tag.start_pos = self.current_token_start_pos
|
|
2926
|
+
tag.end_pos = self.pos
|
|
2883
2927
|
self._emit_token(tag)
|
|
2884
2928
|
self.state = self.DATA
|
|
2885
2929
|
self.rawtext_tag_name = None
|