justhtml 2.3.0__tar.gz → 2.4.1__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.3.0 → justhtml-2.4.1}/CHANGELOG.md +12 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/PKG-INFO +3 -1
- {justhtml-2.3.0 → justhtml-2.4.1}/README.md +2 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/bleach-migration.md +117 -4
- {justhtml-2.3.0 → justhtml-2.4.1}/pyproject.toml +1 -1
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/treebuilder/core.py +5 -3
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/treebuilder/modes.py +11 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/justhtml-tests/branch_coverage.dat +1 -1
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/justhtml-tests/treebuilder_coverage.dat +3 -3
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/test_treebuilder.py +87 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/.github/copilot-instructions.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/.github/workflows/ci.yml +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/.github/workflows/publish.yml +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/.gitignore +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/.pre-commit-config.yaml +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/CODE_OF_CONDUCT.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/CONTRIBUTING.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/LICENSE +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/Makefile +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/SECURITY.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/assets/justhtml-readme-explainer.png +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/benchmarks/correctness.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/benchmarks/fuzz.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/benchmarks/performance.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/benchmarks/profile.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/_config.yml +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/_layouts/default.html +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/api.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/assets/search.js +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/building.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/cli.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/comparison.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/correctness.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/encoding.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/errors.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/fragments.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/html-cleaning.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/index.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/linkify.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/migration-examples.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/playground/app.js +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/playground/index.html +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/playground/py/__init__.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/playground/py/install_latest_justhtml.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/playground/py/render.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/playground/py/use_local_repo.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/quickstart.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/sanitization.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/selectors.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/streaming.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/text.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/transforms.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/unsafe-handling.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/docs/url-cleaning.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/llms.txt +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/run_tests.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/scripts/release.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/__init__.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/__main__.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/core/__init__.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/core/constants.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/core/entities.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/core/errors.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/core/rawtext.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/dom/__init__.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/dom/builder.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/parser/__init__.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/parser/context.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/parser/encoding.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/parser/stream.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/py.typed +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/sanitizer/__init__.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/sanitizer/css.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/sanitizer/dom.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/sanitizer/policy.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/sanitizer/policy_defaults.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/sanitizer/rawtext.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/sanitizer/url/__init__.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/sanitizer/url/policy.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/sanitizer/url/runtime.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/sanitizer/url/spec.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/selector/__init__.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/selector/core.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/serializer/__init__.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/serializer/html.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/serializer/markdown.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/tokenizer/__init__.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/tokenizer/html.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/tokenizer/tokens.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/transforms/__init__.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/transforms/compile.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/transforms/linkify.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/transforms/linkify_core.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/transforms/runtime.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/transforms/spec.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/treebuilder/__init__.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/src/justhtml/treebuilder/utils.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/test-summary.txt +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/README.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/__init__.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/data/wikipedia.html +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/harness/__init__.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/harness/encoding.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/harness/regressions.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/harness/reporter.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/harness/serializer.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/harness/tokenizer.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/harness/tree.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/justhtml-sanitize-tests/cases.json +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/justhtml-tests/coverage_gaps.test +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/justhtml-tests/empty_stack_edge_cases.dat +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/justhtml-tests/entities.test +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/justhtml-tests/iframe_srcdoc.dat +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/justhtml-tests/tokenizer_edge_cases.test +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/justhtml-tests/xml_coercion.dat +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/justhtml-tests/xml_coercion_coverage.test +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/linkify-it/LICENSE.txt +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/linkify-it/README.md +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/linkify-it/fixtures/links.txt +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/linkify-it/fixtures/not_links.txt +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/test_builder.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/test_cli.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/test_docs_examples.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/test_encoding.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/test_errors.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/test_linkify_internals.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/test_linkify_it.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/test_linkify_transform.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/test_node.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/test_playground_local_repo_file_list.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/test_sanitize.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/test_sanitize_integration.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/test_sanitize_transform.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/test_selector.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/test_serialize.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/test_stream.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/test_tokenizer.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/test_transforms.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/test_transforms_compiler.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/test_transforms_edge_cases.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/test_transforms_sanitize_integration.py +0 -0
- {justhtml-2.3.0 → justhtml-2.4.1}/tests/test_wikipedia.py +0 -0
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.4.1] - 2026-06-21
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Prevent the leading-line-feed exception for `<pre>`, `<listing>`, and `<textarea>` from leaking past intervening non-character tokens and removing a later newline.
|
|
14
|
+
- Apply paragraph closing and leading-line-feed handling to `<pre>` and `<listing>` inside customizable `<select>` content.
|
|
15
|
+
- Preserve U+000C FORM FEED in HTML text like browsers do, while retaining its intentional conversion to a space in XML-coercion mode.
|
|
16
|
+
|
|
17
|
+
## [2.4.0] - 2026-06-20
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- 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.
|
|
21
|
+
|
|
10
22
|
## [2.3.0] - 2026-06-12
|
|
11
23
|
|
|
12
24
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: justhtml
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.1
|
|
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).
|
|
@@ -411,6 +411,11 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
411
411
|
def process_token(self, token: Any) -> Any:
|
|
412
412
|
# Optimization: Use type() identity check instead of isinstance
|
|
413
413
|
token_type = type(token)
|
|
414
|
+
# The leading-LF exception for <pre>, <listing>, and <textarea>
|
|
415
|
+
# applies only to the immediately following token. Character tokens
|
|
416
|
+
# consume it in _append_text; any other token cancels it.
|
|
417
|
+
if self.ignore_lf and token_type is not CharacterTokens:
|
|
418
|
+
self.ignore_lf = False
|
|
414
419
|
if token_type is DoctypeToken:
|
|
415
420
|
# Check for foreign content first - DOCTYPE in SVG/MathML is a parse error
|
|
416
421
|
if self.open_elements:
|
|
@@ -668,9 +673,6 @@ class TreeBuilder(TreeBuilderModesMixin):
|
|
|
668
673
|
if not text:
|
|
669
674
|
return
|
|
670
675
|
|
|
671
|
-
if "\f" in text:
|
|
672
|
-
text = text.replace("\f", " ")
|
|
673
|
-
|
|
674
676
|
# Guard against empty stack
|
|
675
677
|
if not self.open_elements: # pragma: no cover
|
|
676
678
|
return
|
|
@@ -954,6 +954,12 @@ class TreeBuilderModesMixin:
|
|
|
954
954
|
self._insert_element(token, push=False)
|
|
955
955
|
return
|
|
956
956
|
|
|
957
|
+
def _handle_body_start_hr(self, token: Tag) -> None:
|
|
958
|
+
self._close_p_element()
|
|
959
|
+
self._insert_element(token, push=False)
|
|
960
|
+
self.frameset_ok = False
|
|
961
|
+
return
|
|
962
|
+
|
|
957
963
|
def _handle_body_start_input(self, token: Tag) -> None:
|
|
958
964
|
if (
|
|
959
965
|
self.fragment_context is not None
|
|
@@ -1671,6 +1677,10 @@ class TreeBuilderModesMixin:
|
|
|
1671
1677
|
self.ignore_lf = True
|
|
1672
1678
|
self.frameset_ok = False
|
|
1673
1679
|
return None
|
|
1680
|
+
if name in {"pre", "listing"}:
|
|
1681
|
+
self._parse_error("unexpected-start-tag-in-select", tag_name=name)
|
|
1682
|
+
self._handle_body_start_pre_listing(token)
|
|
1683
|
+
return None
|
|
1674
1684
|
# Any other start tag is part of customizable select content.
|
|
1675
1685
|
self._parse_error("unexpected-start-tag-in-select", tag_name=name)
|
|
1676
1686
|
self._reconstruct_active_formatting_elements()
|
|
@@ -2054,6 +2064,7 @@ class TreeBuilderModesMixin:
|
|
|
2054
2064
|
"head": _handle_body_start_head,
|
|
2055
2065
|
"header": _handle_body_start_block_with_p,
|
|
2056
2066
|
"hgroup": _handle_body_start_block_with_p,
|
|
2067
|
+
"hr": _handle_body_start_hr,
|
|
2057
2068
|
"html": _handle_body_start_html,
|
|
2058
2069
|
"i": _handle_body_start_formatting,
|
|
2059
2070
|
"image": _handle_body_start_image,
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
| <body>
|
|
22
22
|
| <math math>
|
|
23
23
|
| <math mi>
|
|
24
|
-
| "
|
|
24
|
+
| "text"
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
#data
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
| <head>
|
|
76
76
|
| <body>
|
|
77
77
|
| <select>
|
|
78
|
-
| "
|
|
78
|
+
| "text"
|
|
79
79
|
|
|
80
80
|
|
|
81
81
|
#data
|
|
@@ -175,7 +175,7 @@
|
|
|
175
175
|
| <head>
|
|
176
176
|
| <body>
|
|
177
177
|
| <table>
|
|
178
|
-
| "
|
|
178
|
+
| ""
|
|
179
179
|
|
|
180
180
|
|
|
181
181
|
#data
|
|
@@ -220,6 +220,93 @@ class TestTreeBuilder(unittest.TestCase):
|
|
|
220
220
|
doc = JustHTML(html, fragment=True, sanitize=False)
|
|
221
221
|
self.assertEqual(doc.to_html(pretty=False), expected)
|
|
222
222
|
|
|
223
|
+
def test_hr_in_table_context_uses_in_body_void_element_rules(self) -> None:
|
|
224
|
+
cases = {
|
|
225
|
+
"<table><tr><td>before<hr>after</td></tr></table>": (
|
|
226
|
+
"<table><tbody><tr><td>before<hr>after</td></tr></tbody></table>"
|
|
227
|
+
),
|
|
228
|
+
"<table><tr><th>a<hr>b</th></tr></table>": ("<table><tbody><tr><th>a<hr>b</th></tr></tbody></table>"),
|
|
229
|
+
"<table><caption>a<hr>b</caption></table>": "<table><caption>a<hr>b</caption></table>",
|
|
230
|
+
"<table><tr><td><p>a<hr>b</td></tr></table>": (
|
|
231
|
+
"<table><tbody><tr><td><p>a</p><hr>b</td></tr></tbody></table>"
|
|
232
|
+
),
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
for html, expected in cases.items():
|
|
236
|
+
with self.subTest(html=html):
|
|
237
|
+
doc = JustHTML(html, fragment=True, sanitize=False)
|
|
238
|
+
self.assertEqual(doc.to_html(pretty=False), expected)
|
|
239
|
+
|
|
240
|
+
def test_self_closing_hr_in_table_context_is_acknowledged(self) -> None:
|
|
241
|
+
doc = JustHTML(
|
|
242
|
+
"<table><tr><td><hr/></td></tr></table>",
|
|
243
|
+
fragment=True,
|
|
244
|
+
sanitize=False,
|
|
245
|
+
collect_errors=True,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
self.assertNotIn(
|
|
249
|
+
"non-void-html-element-start-tag-with-trailing-solidus",
|
|
250
|
+
[error.code for error in doc.errors],
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def test_leading_lf_skip_does_not_leak_past_non_character_tokens(self) -> None:
|
|
254
|
+
cases = {
|
|
255
|
+
"<div><pre></pre>\nX</div>": "<div><pre></pre>\nX</div>",
|
|
256
|
+
"<div><pre><code></code></pre>\nX</div>": "<div><pre><code></code></pre>\nX</div>",
|
|
257
|
+
"<div><pre><!--x--></pre>\nX</div>": "<div><pre><!--x--></pre>\nX</div>",
|
|
258
|
+
"<div><listing></listing>\nX</div>": "<div><listing></listing>\nX</div>",
|
|
259
|
+
"<div><textarea></textarea>\nX</div>": "<div><textarea></textarea>\nX</div>",
|
|
260
|
+
"<div><pre>\nX</pre></div>": "<div><pre>X</pre></div>",
|
|
261
|
+
"<div><listing>\nX</listing></div>": "<div><listing>X</listing></div>",
|
|
262
|
+
"<div><textarea>\nX</textarea></div>": "<div><textarea>X</textarea></div>",
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
for html, expected in cases.items():
|
|
266
|
+
with self.subTest(html=html):
|
|
267
|
+
doc = JustHTML(html, fragment=True, sanitize=False)
|
|
268
|
+
self.assertEqual(doc.to_html(pretty=False), expected)
|
|
269
|
+
|
|
270
|
+
def test_pre_and_listing_in_select_use_their_in_body_start_rules(self) -> None:
|
|
271
|
+
cases = {
|
|
272
|
+
"<select><pre>\nX</pre></select>": "<select><pre>X</pre></select>",
|
|
273
|
+
"<select><listing>\nX</listing></select>": "<select><listing>X</listing></select>",
|
|
274
|
+
"<select><p>P<pre>\nX</pre></select>": "<select><p>P</p><pre>X</pre></select>",
|
|
275
|
+
"<select><p>P<listing>\nX</listing></select>": "<select><p>P</p><listing>X</listing></select>",
|
|
276
|
+
"<select><pre><code></code>\nX</pre></select>": ("<select><pre><code></code>\nX</pre></select>"),
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
for html, expected in cases.items():
|
|
280
|
+
with self.subTest(html=html):
|
|
281
|
+
doc = JustHTML(html, fragment=True, sanitize=False)
|
|
282
|
+
self.assertEqual(doc.to_html(pretty=False), expected)
|
|
283
|
+
|
|
284
|
+
def test_form_feed_is_preserved_in_html_text_like_chromium(self) -> None:
|
|
285
|
+
cases = {
|
|
286
|
+
"<div>A\fB</div>": "<div>A\fB</div>",
|
|
287
|
+
"<pre>A\fB</pre>": "<pre>A\fB</pre>",
|
|
288
|
+
"<textarea>A\fB</textarea>": "<textarea>A\fB</textarea>",
|
|
289
|
+
"<style>A\fB</style>": "<style>A\fB</style>",
|
|
290
|
+
"<script>A\fB</script>": "<script>A\fB</script>",
|
|
291
|
+
"<table>A\fB<tr><td>C\fD</td></tr></table>": ("A\fB<table><tbody><tr><td>C\fD</td></tr></tbody></table>"),
|
|
292
|
+
"<svg><text>A\fB</text></svg>": "<svg><text>A\fB</text></svg>",
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
for html, expected in cases.items():
|
|
296
|
+
with self.subTest(html=html):
|
|
297
|
+
doc = JustHTML(html, fragment=True, sanitize=False)
|
|
298
|
+
self.assertEqual(doc.to_html(pretty=False), expected)
|
|
299
|
+
|
|
300
|
+
def test_form_feed_is_still_coerced_for_xml_output(self) -> None:
|
|
301
|
+
doc = JustHTML(
|
|
302
|
+
"<div>A\fB</div>",
|
|
303
|
+
fragment=True,
|
|
304
|
+
sanitize=False,
|
|
305
|
+
_tokenizer_opts=TokenizerOpts(xml_coercion=True),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
self.assertEqual(doc.to_html(pretty=False), "<div>A B</div>")
|
|
309
|
+
|
|
223
310
|
def test_select_end_tags_create_p_and_close_custom_children_like_chromium(self) -> None:
|
|
224
311
|
cases = {
|
|
225
312
|
"<select>x</p>": "<select>x<p></p></select>",
|
|
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
|
|
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
|