justhtml 2.2.0__tar.gz → 2.4.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.2.0 → justhtml-2.4.0}/CHANGELOG.md +16 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/PKG-INFO +3 -1
- {justhtml-2.2.0 → justhtml-2.4.0}/README.md +2 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/bleach-migration.md +117 -4
- {justhtml-2.2.0 → justhtml-2.4.0}/pyproject.toml +1 -1
- {justhtml-2.2.0 → justhtml-2.4.0}/run_tests.py +13 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/parser/__init__.py +1 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/serializer/html.py +1 -1
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/treebuilder/core.py +149 -53
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/treebuilder/modes.py +30 -35
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_linkify_internals.py +6 -2
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_serialize.py +66 -7
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_treebuilder.py +168 -4
- justhtml-2.2.0/tests/test_precommit_coverage.py +0 -83
- {justhtml-2.2.0 → justhtml-2.4.0}/.github/copilot-instructions.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/.github/workflows/ci.yml +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/.github/workflows/publish.yml +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/.gitignore +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/.pre-commit-config.yaml +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/CODE_OF_CONDUCT.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/CONTRIBUTING.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/LICENSE +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/Makefile +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/SECURITY.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/assets/justhtml-readme-explainer.png +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/benchmarks/correctness.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/benchmarks/fuzz.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/benchmarks/performance.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/benchmarks/profile.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/_config.yml +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/_layouts/default.html +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/api.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/assets/search.js +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/building.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/cli.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/comparison.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/correctness.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/encoding.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/errors.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/fragments.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/html-cleaning.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/index.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/linkify.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/migration-examples.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/playground/app.js +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/playground/index.html +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/playground/py/__init__.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/playground/py/install_latest_justhtml.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/playground/py/render.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/playground/py/use_local_repo.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/quickstart.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/sanitization.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/selectors.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/streaming.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/text.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/transforms.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/unsafe-handling.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/docs/url-cleaning.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/llms.txt +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/scripts/release.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/__init__.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/__main__.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/core/__init__.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/core/constants.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/core/entities.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/core/errors.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/core/rawtext.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/dom/__init__.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/dom/builder.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/parser/context.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/parser/encoding.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/parser/stream.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/py.typed +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/sanitizer/__init__.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/sanitizer/css.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/sanitizer/dom.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/sanitizer/policy.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/sanitizer/policy_defaults.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/sanitizer/rawtext.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/sanitizer/url/__init__.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/sanitizer/url/policy.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/sanitizer/url/runtime.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/sanitizer/url/spec.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/selector/__init__.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/selector/core.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/serializer/__init__.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/serializer/markdown.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/tokenizer/__init__.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/tokenizer/html.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/tokenizer/tokens.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/transforms/__init__.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/transforms/compile.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/transforms/linkify.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/transforms/linkify_core.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/transforms/runtime.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/transforms/spec.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/treebuilder/__init__.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/treebuilder/utils.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/test-summary.txt +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/README.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/__init__.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/data/wikipedia.html +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/harness/__init__.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/harness/encoding.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/harness/regressions.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/harness/reporter.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/harness/serializer.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/harness/tokenizer.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/harness/tree.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/justhtml-sanitize-tests/cases.json +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/justhtml-tests/branch_coverage.dat +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/justhtml-tests/coverage_gaps.test +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/justhtml-tests/empty_stack_edge_cases.dat +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/justhtml-tests/entities.test +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/justhtml-tests/iframe_srcdoc.dat +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/justhtml-tests/tokenizer_edge_cases.test +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/justhtml-tests/treebuilder_coverage.dat +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/justhtml-tests/xml_coercion.dat +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/justhtml-tests/xml_coercion_coverage.test +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/linkify-it/LICENSE.txt +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/linkify-it/README.md +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/linkify-it/fixtures/links.txt +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/linkify-it/fixtures/not_links.txt +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_builder.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_cli.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_docs_examples.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_encoding.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_errors.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_linkify_it.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_linkify_transform.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_node.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_playground_local_repo_file_list.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_sanitize.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_sanitize_integration.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_sanitize_transform.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_selector.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_stream.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_tokenizer.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_transforms.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_transforms_compiler.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_transforms_edge_cases.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_transforms_sanitize_integration.py +0 -0
- {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_wikipedia.py +0 -0
|
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.4.0] - 2026-06-20
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Handle `<hr>` using the HTML in-body void-element rules in delegated insertion modes, preserving following content in table cells and captions, closing open paragraphs, and acknowledging self-closing syntax.
|
|
14
|
+
|
|
15
|
+
## [2.3.0] - 2026-06-12
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- Preserve pretty-printed paragraph line breaks when sanitization is disabled and comments are present in block containers.
|
|
19
|
+
|
|
20
|
+
### Performance
|
|
21
|
+
- Speed up tree construction scope checks by tracking open elements for common scoped element names.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- Refactor tree-builder open-element bookkeeping and active-formatting entries without changing the public API.
|
|
25
|
+
|
|
10
26
|
## [2.2.0] - 2026-06-07
|
|
11
27
|
|
|
12
28
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: justhtml
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.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
|
|
@@ -88,6 +88,8 @@ JustHTML is for applications that want a boring, inspectable, pure-Python defaul
|
|
|
88
88
|
- **Easy deployment:** zero runtime dependencies, no compiler, works on PyPy and Pyodide.
|
|
89
89
|
- **Honest tradeoff:** if you are parsing terabytes of trusted HTML, use a C/Rust parser. If you need reliable handling of untrusted or malformed HTML inside a Python app, use JustHTML.
|
|
90
90
|
|
|
91
|
+
Real-world signal: [Mozilla Support migrated from Bleach to JustHTML](https://github.com/mozilla/kitsune/pull/7236) in Kitsune, the Django application behind support.mozilla.org.
|
|
92
|
+
|
|
91
93
|
## Quick Start
|
|
92
94
|
|
|
93
95
|
```python
|
|
@@ -34,6 +34,8 @@ JustHTML is for applications that want a boring, inspectable, pure-Python defaul
|
|
|
34
34
|
- **Easy deployment:** zero runtime dependencies, no compiler, works on PyPy and Pyodide.
|
|
35
35
|
- **Honest tradeoff:** if you are parsing terabytes of trusted HTML, use a C/Rust parser. If you need reliable handling of untrusted or malformed HTML inside a Python app, use JustHTML.
|
|
36
36
|
|
|
37
|
+
Real-world signal: [Mozilla Support migrated from Bleach to JustHTML](https://github.com/mozilla/kitsune/pull/7236) in Kitsune, the Django application behind support.mozilla.org.
|
|
38
|
+
|
|
37
39
|
## Quick Start
|
|
38
40
|
|
|
39
41
|
```python
|
|
@@ -10,6 +10,107 @@ In 2023, Bleach’s maintainer announced that **Bleach is deprecated** (but will
|
|
|
10
10
|
|
|
11
11
|
This guide covers common migration patterns.
|
|
12
12
|
|
|
13
|
+
## Real-world migration pattern: add a local wrapper first
|
|
14
|
+
|
|
15
|
+
A large application migration often works best in two steps:
|
|
16
|
+
|
|
17
|
+
1. Add a small application-local `clean(...)` wrapper that accepts the Bleach-shaped options your codebase already uses.
|
|
18
|
+
2. Migrate call sites from `bleach.clean(...)` / `bleach.linkify(...)` to that wrapper.
|
|
19
|
+
|
|
20
|
+
That keeps most call-site changes mechanical while leaving the security policy in one place.
|
|
21
|
+
|
|
22
|
+
Example wrapper:
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
import re
|
|
26
|
+
from collections.abc import Collection, Mapping
|
|
27
|
+
|
|
28
|
+
from justhtml import JustHTML, Linkify, SanitizationPolicy, SetAttrs, UrlPolicy, UrlRule
|
|
29
|
+
|
|
30
|
+
URL_LIKE_ATTRS = {
|
|
31
|
+
"href",
|
|
32
|
+
"src",
|
|
33
|
+
"srcset",
|
|
34
|
+
"poster",
|
|
35
|
+
"action",
|
|
36
|
+
"formaction",
|
|
37
|
+
"data",
|
|
38
|
+
"cite",
|
|
39
|
+
"background",
|
|
40
|
+
"ping",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_url_policy(
|
|
45
|
+
allowed_tags: Collection[str],
|
|
46
|
+
allowed_attributes: Mapping[str, Collection[str]],
|
|
47
|
+
) -> UrlPolicy:
|
|
48
|
+
rules = {}
|
|
49
|
+
|
|
50
|
+
global_attrs = allowed_attributes.get("*", ())
|
|
51
|
+
for tag in allowed_tags:
|
|
52
|
+
for attr in global_attrs:
|
|
53
|
+
if attr in URL_LIKE_ATTRS:
|
|
54
|
+
rules[(tag, attr)] = UrlRule(
|
|
55
|
+
allowed_schemes={"http", "https", "mailto", "tel"},
|
|
56
|
+
allow_relative=True,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
for tag, attrs in allowed_attributes.items():
|
|
60
|
+
if tag == "*":
|
|
61
|
+
continue
|
|
62
|
+
for attr in attrs:
|
|
63
|
+
if attr in URL_LIKE_ATTRS:
|
|
64
|
+
rules[(tag, attr)] = UrlRule(
|
|
65
|
+
allowed_schemes={"http", "https", "mailto", "tel"},
|
|
66
|
+
allow_relative=True,
|
|
67
|
+
)
|
|
68
|
+
return UrlPolicy(allow_rules=rules)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def clean(
|
|
72
|
+
html: str,
|
|
73
|
+
*,
|
|
74
|
+
tags: Collection[str] = (),
|
|
75
|
+
attributes: Mapping[str, Collection[str]] | None = None,
|
|
76
|
+
css_properties: Collection[str] = (),
|
|
77
|
+
strip: bool = True,
|
|
78
|
+
strip_comments: bool = True,
|
|
79
|
+
) -> str:
|
|
80
|
+
attrs = dict(attributes or {})
|
|
81
|
+
disallowed_tag_handling = "unwrap" if strip else "escape"
|
|
82
|
+
|
|
83
|
+
# Bleach defaults to strip_comments=True. If you use escape mode
|
|
84
|
+
# (`strip=False`) and still want comments removed rather than displayed,
|
|
85
|
+
# remove comments before parsing.
|
|
86
|
+
if strip_comments and disallowed_tag_handling == "escape":
|
|
87
|
+
html = re.sub(r"<!--.*?-->", "", html, flags=re.DOTALL)
|
|
88
|
+
|
|
89
|
+
policy = SanitizationPolicy(
|
|
90
|
+
allowed_tags=tags,
|
|
91
|
+
allowed_attributes=attrs,
|
|
92
|
+
allowed_css_properties=css_properties,
|
|
93
|
+
url_policy=build_url_policy(tags, attrs),
|
|
94
|
+
drop_comments=strip_comments,
|
|
95
|
+
disallowed_tag_handling=disallowed_tag_handling,
|
|
96
|
+
)
|
|
97
|
+
return JustHTML(html, fragment=True, policy=policy).to_html(pretty=False)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def linkify(text: str, *, nofollow: bool = False) -> str:
|
|
101
|
+
transforms = [Linkify()]
|
|
102
|
+
if nofollow:
|
|
103
|
+
transforms.append(SetAttrs("a", rel="nofollow"))
|
|
104
|
+
return JustHTML(
|
|
105
|
+
text,
|
|
106
|
+
fragment=True,
|
|
107
|
+
sanitize=False,
|
|
108
|
+
transforms=transforms,
|
|
109
|
+
).to_html(pretty=False)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Treat this as a compatibility shim, not a universal policy. Tighten `build_url_policy(...)` for your application, especially for attributes that load remote resources such as `img[src]`.
|
|
113
|
+
|
|
13
114
|
## Mental model differences
|
|
14
115
|
|
|
15
116
|
- Bleach takes a string and returns a cleaned string.
|
|
@@ -97,6 +198,7 @@ print(doc.to_html(pretty=False))
|
|
|
97
198
|
Bleach’s `protocols=[...]` concept maps to JustHTML’s URL policy rules.
|
|
98
199
|
|
|
99
200
|
- Configure allowed schemes per attribute via `UrlRule(allowed_schemes=[...])`.
|
|
201
|
+
- URL-like attributes need explicit `(tag, attr)` allow rules even when the attribute itself is allowed.
|
|
100
202
|
- Under `sanitize=True`, URLs can be rewritten or stripped according to policy (see [URL Cleaning](url-cleaning.md)).
|
|
101
203
|
|
|
102
204
|
## What to do with “strip=True/False”
|
|
@@ -118,9 +220,20 @@ If you need to display untrusted HTML with no HTML in the output, prefer `to_tex
|
|
|
118
220
|
|
|
119
221
|
If you need additional structural cleanup beyond policy decisions, prefer doing it explicitly with transforms.
|
|
120
222
|
|
|
223
|
+
## Migration test expectations
|
|
224
|
+
|
|
225
|
+
Expect some output differences even when the security behavior is equivalent:
|
|
226
|
+
|
|
227
|
+
- Inline styles may be normalized, for example `vertical-align:top;` can serialize as `vertical-align: top`.
|
|
228
|
+
- Malformed HTML can serialize differently because JustHTML parses with its own HTML5 tree builder.
|
|
229
|
+
- `strip=False` maps to escaped disallowed tags. If your old Bleach call also relied on `strip_comments=True`, add an explicit test for comments.
|
|
230
|
+
- Linkification should be tested separately from sanitization when your old code used both `bleach.linkify(...)` and `bleach.clean(...)`.
|
|
231
|
+
|
|
121
232
|
## Suggested migration approach
|
|
122
233
|
|
|
123
|
-
1.
|
|
124
|
-
2.
|
|
125
|
-
3.
|
|
126
|
-
4. Add
|
|
234
|
+
1. Inventory existing `bleach.clean(...)`, `bleach.Cleaner(...)`, and `bleach.linkify(...)` call shapes.
|
|
235
|
+
2. For a large app, add a local compatibility wrapper so most call sites can move mechanically.
|
|
236
|
+
3. Start by using JustHTML’s default sanitizer (`JustHTML(...).to_html()`) for new code.
|
|
237
|
+
4. Add a `SanitizationPolicy` that matches your allowlist and URL requirements.
|
|
238
|
+
5. Add transforms to replace any Bleach filter/linkify behavior.
|
|
239
|
+
6. Add tests for your exact input corpus and output expectations (especially around URLs, allowed attributes, comments, and malformed HTML).
|
|
@@ -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()
|
|
@@ -756,7 +756,7 @@ def _node_to_html(node: Any, indent: int = 0, indent_size: int = 2, *, in_pre: b
|
|
|
756
756
|
for child in children:
|
|
757
757
|
if child is None:
|
|
758
758
|
continue
|
|
759
|
-
if child.name == "#
|
|
759
|
+
if child.name == "#text" and (child.data or "").strip():
|
|
760
760
|
can_indent = False
|
|
761
761
|
break
|
|
762
762
|
|
|
@@ -54,6 +54,46 @@ class _SelectedContentWalkItem(NamedTuple):
|
|
|
54
54
|
in_datalist: bool
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
class _FormattingEntry:
|
|
58
|
+
__slots__ = ("attrs", "name", "node", "signature")
|
|
59
|
+
|
|
60
|
+
name: str
|
|
61
|
+
attrs: dict[str, str | None]
|
|
62
|
+
node: Any
|
|
63
|
+
signature: tuple[tuple[str, str], ...]
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
name: str,
|
|
68
|
+
attrs: dict[str, str | None],
|
|
69
|
+
node: Any,
|
|
70
|
+
signature: tuple[tuple[str, str], ...],
|
|
71
|
+
) -> None:
|
|
72
|
+
self.name = name
|
|
73
|
+
self.attrs = attrs
|
|
74
|
+
self.node = node
|
|
75
|
+
self.signature = signature
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_OPEN_ELEMENT_SCOPE_COUNT_NAMES = frozenset(
|
|
79
|
+
(
|
|
80
|
+
"button",
|
|
81
|
+
"caption",
|
|
82
|
+
"dd",
|
|
83
|
+
"dt",
|
|
84
|
+
"form",
|
|
85
|
+
"li",
|
|
86
|
+
"table",
|
|
87
|
+
"tbody",
|
|
88
|
+
"td",
|
|
89
|
+
"tfoot",
|
|
90
|
+
"th",
|
|
91
|
+
"thead",
|
|
92
|
+
"tr",
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
57
97
|
class TreeBuilder(TreeBuilderModesMixin):
|
|
58
98
|
__slots__ = (
|
|
59
99
|
"_body_end_handlers",
|
|
@@ -61,6 +101,8 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
61
101
|
"_body_token_handlers",
|
|
62
102
|
"_mode_handlers",
|
|
63
103
|
"_open_p_elements",
|
|
104
|
+
"_open_scope_name_counts",
|
|
105
|
+
"_open_template_elements",
|
|
64
106
|
"_pending_end_tag_end",
|
|
65
107
|
"_pending_end_tag_name",
|
|
66
108
|
"_pending_end_tag_start",
|
|
@@ -88,6 +130,7 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
88
130
|
"template_modes",
|
|
89
131
|
"tokenizer",
|
|
90
132
|
"tokenizer_state_override",
|
|
133
|
+
"track_node_locations",
|
|
91
134
|
"track_tag_spans",
|
|
92
135
|
)
|
|
93
136
|
|
|
@@ -98,6 +141,7 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
98
141
|
_pending_end_tag_name: str | None
|
|
99
142
|
_pending_end_tag_start: int | None
|
|
100
143
|
_pending_end_tag_end: int | None
|
|
144
|
+
track_node_locations: bool
|
|
101
145
|
track_tag_spans: bool
|
|
102
146
|
active_formatting: list[Any]
|
|
103
147
|
collect_errors: bool
|
|
@@ -115,6 +159,8 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
115
159
|
open_elements: list[Any]
|
|
116
160
|
original_mode: InsertionMode | None # type: ignore[assignment]
|
|
117
161
|
_open_p_elements: int
|
|
162
|
+
_open_scope_name_counts: dict[str, int]
|
|
163
|
+
_open_template_elements: int
|
|
118
164
|
_saw_select_element: bool
|
|
119
165
|
pending_table_text: list[str]
|
|
120
166
|
pending_table_text_should_error: bool
|
|
@@ -131,12 +177,14 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
131
177
|
iframe_srcdoc: bool = False,
|
|
132
178
|
collect_errors: bool = False,
|
|
133
179
|
scripting_enabled: bool = True,
|
|
180
|
+
track_node_locations: bool = False,
|
|
134
181
|
track_tag_spans: bool = False,
|
|
135
182
|
) -> None:
|
|
136
183
|
self.fragment_context = fragment_context
|
|
137
184
|
self.iframe_srcdoc = iframe_srcdoc
|
|
138
185
|
self.collect_errors = collect_errors
|
|
139
186
|
self.scripting_enabled = bool(scripting_enabled)
|
|
187
|
+
self.track_node_locations = bool(track_node_locations)
|
|
140
188
|
self.track_tag_spans = bool(track_tag_spans)
|
|
141
189
|
self.errors = []
|
|
142
190
|
self.tokenizer = None # Set by parser after tokenizer is created
|
|
@@ -150,6 +198,8 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
150
198
|
self.table_text_original_mode = None
|
|
151
199
|
self.open_elements = []
|
|
152
200
|
self._open_p_elements = 0
|
|
201
|
+
self._open_scope_name_counts = {}
|
|
202
|
+
self._open_template_elements = 0
|
|
153
203
|
self._saw_select_element = False
|
|
154
204
|
self._pending_end_tag_name = None
|
|
155
205
|
self._pending_end_tag_start = None
|
|
@@ -263,6 +313,8 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
263
313
|
) -> bool:
|
|
264
314
|
if target == "p" and self._open_p_elements == 0:
|
|
265
315
|
return False
|
|
316
|
+
if target in _OPEN_ELEMENT_SCOPE_COUNT_NAMES and self._open_scope_name_counts.get(target, 0) == 0:
|
|
317
|
+
return False
|
|
266
318
|
if terminators is None:
|
|
267
319
|
terminators = DEFAULT_SCOPE_TERMINATORS
|
|
268
320
|
open_elements = self.open_elements
|
|
@@ -294,17 +346,59 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
294
346
|
break
|
|
295
347
|
|
|
296
348
|
def _note_open_element_pushed(self, node: Any) -> None:
|
|
297
|
-
if node is
|
|
349
|
+
if node is None:
|
|
350
|
+
return
|
|
351
|
+
name = node.name
|
|
352
|
+
if name == "p":
|
|
298
353
|
self._open_p_elements += 1
|
|
354
|
+
elif name == "template":
|
|
355
|
+
self._open_template_elements += 1
|
|
356
|
+
elif name in _OPEN_ELEMENT_SCOPE_COUNT_NAMES:
|
|
357
|
+
self._open_scope_name_counts[name] = self._open_scope_name_counts.get(name, 0) + 1
|
|
299
358
|
|
|
300
359
|
def _note_open_element_removed(self, node: Any) -> None:
|
|
301
|
-
if node is
|
|
360
|
+
if node is None:
|
|
361
|
+
return
|
|
362
|
+
name = node.name
|
|
363
|
+
if name == "p":
|
|
302
364
|
self._open_p_elements -= 1
|
|
365
|
+
elif name == "template":
|
|
366
|
+
self._open_template_elements -= 1
|
|
367
|
+
elif name in _OPEN_ELEMENT_SCOPE_COUNT_NAMES:
|
|
368
|
+
count = self._open_scope_name_counts.get(name, 0)
|
|
369
|
+
if count <= 1:
|
|
370
|
+
self._open_scope_name_counts.pop(name, None)
|
|
371
|
+
else:
|
|
372
|
+
self._open_scope_name_counts[name] = count - 1
|
|
303
373
|
|
|
304
374
|
def _note_open_elements_removed(self, nodes: list[Any]) -> None:
|
|
305
375
|
for node in nodes:
|
|
306
376
|
self._note_open_element_removed(node)
|
|
307
377
|
|
|
378
|
+
def _has_template_on_stack(self) -> bool:
|
|
379
|
+
return self._open_template_elements > 0
|
|
380
|
+
|
|
381
|
+
def _delete_open_element_at(self, index: int) -> None:
|
|
382
|
+
self._note_open_element_removed(self.open_elements[index])
|
|
383
|
+
del self.open_elements[index]
|
|
384
|
+
|
|
385
|
+
def _replace_open_element_at(self, index: int, node: Any) -> None:
|
|
386
|
+
self._note_open_element_removed(self.open_elements[index])
|
|
387
|
+
self.open_elements[index] = node
|
|
388
|
+
self._note_open_element_pushed(node)
|
|
389
|
+
|
|
390
|
+
def _remove_open_element(self, node: Any) -> None:
|
|
391
|
+
self._note_open_element_removed(node)
|
|
392
|
+
self.open_elements.remove(node)
|
|
393
|
+
|
|
394
|
+
def _insert_open_element_at(self, index: int, node: Any) -> None:
|
|
395
|
+
self.open_elements.insert(index, node)
|
|
396
|
+
self._note_open_element_pushed(node)
|
|
397
|
+
|
|
398
|
+
def _truncate_open_elements_from(self, index: int) -> None:
|
|
399
|
+
self._note_open_elements_removed(self.open_elements[index:])
|
|
400
|
+
del self.open_elements[index:]
|
|
401
|
+
|
|
308
402
|
def _close_p_element(self) -> bool:
|
|
309
403
|
if self._has_element_in_button_scope("p"):
|
|
310
404
|
self._generate_implied_end_tags("p")
|
|
@@ -545,10 +639,11 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
545
639
|
|
|
546
640
|
def _append_comment_to_document(self, text: str) -> None:
|
|
547
641
|
node = Comment(data=text)
|
|
548
|
-
|
|
549
|
-
|
|
642
|
+
tokenizer = self.tokenizer
|
|
643
|
+
if self.track_node_locations and tokenizer is not None:
|
|
644
|
+
node._origin_pos = tokenizer.last_token_start_pos
|
|
550
645
|
if node._origin_pos is not None:
|
|
551
|
-
node._origin_line, node._origin_col =
|
|
646
|
+
node._origin_line, node._origin_col = tokenizer.location_at_pos(node._origin_pos)
|
|
552
647
|
self.document.append_child(node)
|
|
553
648
|
|
|
554
649
|
def _append_comment(self, text: str, parent: Any | None = None) -> None:
|
|
@@ -558,10 +653,11 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
558
653
|
if type(parent) is Template and parent.template_content:
|
|
559
654
|
parent = parent.template_content
|
|
560
655
|
node = Comment(data=text)
|
|
561
|
-
|
|
562
|
-
|
|
656
|
+
tokenizer = self.tokenizer
|
|
657
|
+
if self.track_node_locations and tokenizer is not None:
|
|
658
|
+
node._origin_pos = tokenizer.last_token_start_pos
|
|
563
659
|
if node._origin_pos is not None:
|
|
564
|
-
node._origin_line, node._origin_col =
|
|
660
|
+
node._origin_line, node._origin_col = tokenizer.location_at_pos(node._origin_pos)
|
|
565
661
|
parent.append_child(node)
|
|
566
662
|
|
|
567
663
|
def _append_text(self, text: str) -> None:
|
|
@@ -591,10 +687,11 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
591
687
|
return
|
|
592
688
|
|
|
593
689
|
node = Text(text)
|
|
594
|
-
|
|
595
|
-
|
|
690
|
+
tokenizer = self.tokenizer
|
|
691
|
+
if self.track_node_locations and tokenizer is not None:
|
|
692
|
+
node._origin_pos = tokenizer.last_token_start_pos
|
|
596
693
|
if node._origin_pos is not None:
|
|
597
|
-
node._origin_line, node._origin_col =
|
|
694
|
+
node._origin_line, node._origin_col = tokenizer.location_at_pos(node._origin_pos)
|
|
598
695
|
children.append(node)
|
|
599
696
|
node.parent = target
|
|
600
697
|
return
|
|
@@ -615,10 +712,11 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
615
712
|
return
|
|
616
713
|
|
|
617
714
|
node = Text(text)
|
|
618
|
-
|
|
619
|
-
|
|
715
|
+
tokenizer = self.tokenizer
|
|
716
|
+
if self.track_node_locations and tokenizer is not None:
|
|
717
|
+
node._origin_pos = tokenizer.last_token_start_pos
|
|
620
718
|
if node._origin_pos is not None:
|
|
621
|
-
node._origin_line, node._origin_col =
|
|
719
|
+
node._origin_line, node._origin_col = tokenizer.location_at_pos(node._origin_pos)
|
|
622
720
|
children = parent.children
|
|
623
721
|
if position < len(children):
|
|
624
722
|
children.insert(position, node)
|
|
@@ -662,10 +760,11 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
662
760
|
node._start_tag_end = tag.end_pos
|
|
663
761
|
node._self_closing = tag.self_closing
|
|
664
762
|
|
|
665
|
-
|
|
763
|
+
tokenizer = self.tokenizer
|
|
764
|
+
if self.track_node_locations and tokenizer is not None:
|
|
666
765
|
node._origin_pos = tag.start_pos
|
|
667
766
|
if node._origin_pos is not None:
|
|
668
|
-
node._origin_line, node._origin_col =
|
|
767
|
+
node._origin_line, node._origin_col = tokenizer.location_at_pos(node._origin_pos)
|
|
669
768
|
|
|
670
769
|
# Fast path for common case: not inserting from table
|
|
671
770
|
if not self.insert_from_table:
|
|
@@ -747,9 +846,7 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
747
846
|
node = self.open_elements[index]
|
|
748
847
|
if node is not None and node.name == name:
|
|
749
848
|
self._maybe_mark_end_tag(node)
|
|
750
|
-
|
|
751
|
-
self._note_open_elements_removed(removed)
|
|
752
|
-
del self.open_elements[index:]
|
|
849
|
+
self._truncate_open_elements_from(index)
|
|
753
850
|
return
|
|
754
851
|
index -= 1
|
|
755
852
|
|
|
@@ -772,9 +869,7 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
772
869
|
self._parse_error("end-tag-too-early")
|
|
773
870
|
self._maybe_mark_end_tag(node)
|
|
774
871
|
# Pop all elements from this node onwards
|
|
775
|
-
|
|
776
|
-
self._note_open_elements_removed(removed)
|
|
777
|
-
del self.open_elements[index:]
|
|
872
|
+
self._truncate_open_elements_from(index)
|
|
778
873
|
return
|
|
779
874
|
|
|
780
875
|
# If node is a special element, parse error and ignore the tag
|
|
@@ -796,8 +891,7 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
796
891
|
def _remove_from_open_elements(self, node: Any) -> None:
|
|
797
892
|
index = self.open_elements.index(node)
|
|
798
893
|
self._maybe_mark_end_tag(node)
|
|
799
|
-
self.
|
|
800
|
-
del self.open_elements[index]
|
|
894
|
+
self._delete_open_element_at(index)
|
|
801
895
|
|
|
802
896
|
def _is_special_element(self, node: Any) -> bool:
|
|
803
897
|
if node.namespace not in {None, "html"}:
|
|
@@ -809,14 +903,14 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
809
903
|
entry = self.active_formatting[index]
|
|
810
904
|
if entry is FORMAT_MARKER:
|
|
811
905
|
break
|
|
812
|
-
if entry
|
|
906
|
+
if entry.name == name:
|
|
813
907
|
return index
|
|
814
908
|
return None
|
|
815
909
|
|
|
816
910
|
def _find_active_formatting_index_by_node(self, node: Any) -> int | None:
|
|
817
911
|
for index in range(len(self.active_formatting) - 1, -1, -1):
|
|
818
912
|
entry = self.active_formatting[index]
|
|
819
|
-
if entry is not FORMAT_MARKER and entry
|
|
913
|
+
if entry is not FORMAT_MARKER and entry.node is node:
|
|
820
914
|
return index
|
|
821
915
|
return None
|
|
822
916
|
|
|
@@ -826,6 +920,18 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
826
920
|
def _attrs_signature(self, attrs: dict[str, str | None]) -> tuple[tuple[str, str], ...]:
|
|
827
921
|
if not attrs:
|
|
828
922
|
return ()
|
|
923
|
+
if len(attrs) == 1:
|
|
924
|
+
name, value = next(iter(attrs.items()))
|
|
925
|
+
return ((name, value or ""),)
|
|
926
|
+
if len(attrs) == 2:
|
|
927
|
+
iterator = iter(attrs.items())
|
|
928
|
+
name1, value1 = next(iterator)
|
|
929
|
+
name2, value2 = next(iterator)
|
|
930
|
+
item1 = (name1, value1 or "")
|
|
931
|
+
item2 = (name2, value2 or "")
|
|
932
|
+
if name1 <= name2:
|
|
933
|
+
return (item1, item2)
|
|
934
|
+
return (item2, item1)
|
|
829
935
|
items: list[tuple[str, str]] = []
|
|
830
936
|
for name, value in attrs.items():
|
|
831
937
|
items.append((name, value or ""))
|
|
@@ -839,8 +945,8 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
839
945
|
if entry is FORMAT_MARKER:
|
|
840
946
|
matches.clear()
|
|
841
947
|
continue
|
|
842
|
-
existing_signature = entry
|
|
843
|
-
if entry
|
|
948
|
+
existing_signature = entry.signature
|
|
949
|
+
if entry.name == name and existing_signature == signature:
|
|
844
950
|
matches.append(index)
|
|
845
951
|
if len(matches) >= 3:
|
|
846
952
|
return matches[0]
|
|
@@ -851,7 +957,7 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
851
957
|
entry = self.active_formatting[index]
|
|
852
958
|
if entry is FORMAT_MARKER:
|
|
853
959
|
break
|
|
854
|
-
if entry
|
|
960
|
+
if entry.name == name:
|
|
855
961
|
return True
|
|
856
962
|
return False
|
|
857
963
|
|
|
@@ -860,8 +966,8 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
860
966
|
entry = self.active_formatting[index]
|
|
861
967
|
if entry is FORMAT_MARKER:
|
|
862
968
|
break
|
|
863
|
-
if entry
|
|
864
|
-
return entry
|
|
969
|
+
if entry.name == "a":
|
|
970
|
+
return entry.node not in self.open_elements
|
|
865
971
|
return False
|
|
866
972
|
|
|
867
973
|
def _remove_last_active_formatting_by_name(self, name: str) -> None:
|
|
@@ -869,7 +975,7 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
869
975
|
entry = self.active_formatting[index]
|
|
870
976
|
if entry is FORMAT_MARKER:
|
|
871
977
|
break
|
|
872
|
-
if entry
|
|
978
|
+
if entry.name == name:
|
|
873
979
|
del self.active_formatting[index]
|
|
874
980
|
return
|
|
875
981
|
|
|
@@ -878,21 +984,13 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
878
984
|
if self.open_elements[index].name == name:
|
|
879
985
|
node = self.open_elements[index]
|
|
880
986
|
self._maybe_mark_end_tag(node)
|
|
881
|
-
self.
|
|
882
|
-
del self.open_elements[index]
|
|
987
|
+
self._delete_open_element_at(index)
|
|
883
988
|
return
|
|
884
989
|
|
|
885
990
|
def _append_active_formatting_entry(self, name: str, attrs: dict[str, str | None], node: Any) -> None:
|
|
886
991
|
entry_attrs = self._clone_attributes(attrs)
|
|
887
992
|
signature = self._attrs_signature(entry_attrs)
|
|
888
|
-
self.active_formatting.append(
|
|
889
|
-
{
|
|
890
|
-
"name": name,
|
|
891
|
-
"attrs": entry_attrs,
|
|
892
|
-
"node": node,
|
|
893
|
-
"signature": signature,
|
|
894
|
-
},
|
|
895
|
-
)
|
|
993
|
+
self.active_formatting.append(_FormattingEntry(name, entry_attrs, node, signature))
|
|
896
994
|
|
|
897
995
|
def _clear_active_formatting_up_to_marker(self) -> None:
|
|
898
996
|
while self.active_formatting:
|
|
@@ -911,7 +1009,7 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
911
1009
|
if not self.active_formatting:
|
|
912
1010
|
return
|
|
913
1011
|
last_entry = self.active_formatting[-1]
|
|
914
|
-
if last_entry is FORMAT_MARKER or last_entry
|
|
1012
|
+
if last_entry is FORMAT_MARKER or last_entry.node in self.open_elements:
|
|
915
1013
|
return
|
|
916
1014
|
|
|
917
1015
|
index = len(self.active_formatting) - 1
|
|
@@ -920,20 +1018,20 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
920
1018
|
if index < 0:
|
|
921
1019
|
break
|
|
922
1020
|
entry = self.active_formatting[index]
|
|
923
|
-
if entry is FORMAT_MARKER or entry
|
|
1021
|
+
if entry is FORMAT_MARKER or entry.node in self.open_elements:
|
|
924
1022
|
index += 1
|
|
925
1023
|
break
|
|
926
1024
|
if index < 0:
|
|
927
1025
|
index = 0
|
|
928
1026
|
while index < len(self.active_formatting):
|
|
929
1027
|
entry = self.active_formatting[index]
|
|
930
|
-
tag = Tag(Tag.START, entry
|
|
1028
|
+
tag = Tag(Tag.START, entry.name, self._clone_attributes(entry.attrs), False)
|
|
931
1029
|
new_node = self._insert_element(tag, push=True)
|
|
932
|
-
if self.
|
|
933
|
-
new_node._origin_pos = entry
|
|
934
|
-
new_node._origin_line = entry
|
|
935
|
-
new_node._origin_col = entry
|
|
936
|
-
entry
|
|
1030
|
+
if self.track_node_locations:
|
|
1031
|
+
new_node._origin_pos = entry.node.origin_offset
|
|
1032
|
+
new_node._origin_line = entry.node.origin_line
|
|
1033
|
+
new_node._origin_col = entry.node.origin_col
|
|
1034
|
+
entry.node = new_node
|
|
937
1035
|
index += 1
|
|
938
1036
|
|
|
939
1037
|
def _insert_node_at(self, parent: Any, index: int, node: Any) -> None:
|
|
@@ -1263,9 +1361,7 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
1263
1361
|
return ("reprocess", self.mode, token, True)
|
|
1264
1362
|
# Otherwise it's a foreign element - pop everything from this point up
|
|
1265
1363
|
self._maybe_mark_end_tag(node)
|
|
1266
|
-
|
|
1267
|
-
self._note_open_elements_removed(removed)
|
|
1268
|
-
del self.open_elements[idx:]
|
|
1364
|
+
self._truncate_open_elements_from(idx)
|
|
1269
1365
|
return None
|
|
1270
1366
|
|
|
1271
1367
|
# Per HTML5 spec: if first node doesn't match, it's a parse error
|