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.
Files changed (143) hide show
  1. {justhtml-2.2.0 → justhtml-2.4.0}/CHANGELOG.md +16 -0
  2. {justhtml-2.2.0 → justhtml-2.4.0}/PKG-INFO +3 -1
  3. {justhtml-2.2.0 → justhtml-2.4.0}/README.md +2 -0
  4. {justhtml-2.2.0 → justhtml-2.4.0}/docs/bleach-migration.md +117 -4
  5. {justhtml-2.2.0 → justhtml-2.4.0}/pyproject.toml +1 -1
  6. {justhtml-2.2.0 → justhtml-2.4.0}/run_tests.py +13 -0
  7. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/parser/__init__.py +1 -0
  8. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/serializer/html.py +1 -1
  9. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/treebuilder/core.py +149 -53
  10. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/treebuilder/modes.py +30 -35
  11. {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_linkify_internals.py +6 -2
  12. {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_serialize.py +66 -7
  13. {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_treebuilder.py +168 -4
  14. justhtml-2.2.0/tests/test_precommit_coverage.py +0 -83
  15. {justhtml-2.2.0 → justhtml-2.4.0}/.github/copilot-instructions.md +0 -0
  16. {justhtml-2.2.0 → justhtml-2.4.0}/.github/workflows/ci.yml +0 -0
  17. {justhtml-2.2.0 → justhtml-2.4.0}/.github/workflows/publish.yml +0 -0
  18. {justhtml-2.2.0 → justhtml-2.4.0}/.gitignore +0 -0
  19. {justhtml-2.2.0 → justhtml-2.4.0}/.pre-commit-config.yaml +0 -0
  20. {justhtml-2.2.0 → justhtml-2.4.0}/CODE_OF_CONDUCT.md +0 -0
  21. {justhtml-2.2.0 → justhtml-2.4.0}/CONTRIBUTING.md +0 -0
  22. {justhtml-2.2.0 → justhtml-2.4.0}/LICENSE +0 -0
  23. {justhtml-2.2.0 → justhtml-2.4.0}/Makefile +0 -0
  24. {justhtml-2.2.0 → justhtml-2.4.0}/SECURITY.md +0 -0
  25. {justhtml-2.2.0 → justhtml-2.4.0}/assets/justhtml-readme-explainer.png +0 -0
  26. {justhtml-2.2.0 → justhtml-2.4.0}/benchmarks/correctness.py +0 -0
  27. {justhtml-2.2.0 → justhtml-2.4.0}/benchmarks/fuzz.py +0 -0
  28. {justhtml-2.2.0 → justhtml-2.4.0}/benchmarks/performance.py +0 -0
  29. {justhtml-2.2.0 → justhtml-2.4.0}/benchmarks/profile.py +0 -0
  30. {justhtml-2.2.0 → justhtml-2.4.0}/docs/_config.yml +0 -0
  31. {justhtml-2.2.0 → justhtml-2.4.0}/docs/_layouts/default.html +0 -0
  32. {justhtml-2.2.0 → justhtml-2.4.0}/docs/api.md +0 -0
  33. {justhtml-2.2.0 → justhtml-2.4.0}/docs/assets/search.js +0 -0
  34. {justhtml-2.2.0 → justhtml-2.4.0}/docs/building.md +0 -0
  35. {justhtml-2.2.0 → justhtml-2.4.0}/docs/cli.md +0 -0
  36. {justhtml-2.2.0 → justhtml-2.4.0}/docs/comparison.md +0 -0
  37. {justhtml-2.2.0 → justhtml-2.4.0}/docs/correctness.md +0 -0
  38. {justhtml-2.2.0 → justhtml-2.4.0}/docs/encoding.md +0 -0
  39. {justhtml-2.2.0 → justhtml-2.4.0}/docs/errors.md +0 -0
  40. {justhtml-2.2.0 → justhtml-2.4.0}/docs/fragments.md +0 -0
  41. {justhtml-2.2.0 → justhtml-2.4.0}/docs/html-cleaning.md +0 -0
  42. {justhtml-2.2.0 → justhtml-2.4.0}/docs/index.md +0 -0
  43. {justhtml-2.2.0 → justhtml-2.4.0}/docs/linkify.md +0 -0
  44. {justhtml-2.2.0 → justhtml-2.4.0}/docs/migration-examples.md +0 -0
  45. {justhtml-2.2.0 → justhtml-2.4.0}/docs/playground/app.js +0 -0
  46. {justhtml-2.2.0 → justhtml-2.4.0}/docs/playground/index.html +0 -0
  47. {justhtml-2.2.0 → justhtml-2.4.0}/docs/playground/py/__init__.py +0 -0
  48. {justhtml-2.2.0 → justhtml-2.4.0}/docs/playground/py/install_latest_justhtml.py +0 -0
  49. {justhtml-2.2.0 → justhtml-2.4.0}/docs/playground/py/render.py +0 -0
  50. {justhtml-2.2.0 → justhtml-2.4.0}/docs/playground/py/use_local_repo.py +0 -0
  51. {justhtml-2.2.0 → justhtml-2.4.0}/docs/quickstart.md +0 -0
  52. {justhtml-2.2.0 → justhtml-2.4.0}/docs/sanitization.md +0 -0
  53. {justhtml-2.2.0 → justhtml-2.4.0}/docs/selectors.md +0 -0
  54. {justhtml-2.2.0 → justhtml-2.4.0}/docs/streaming.md +0 -0
  55. {justhtml-2.2.0 → justhtml-2.4.0}/docs/text.md +0 -0
  56. {justhtml-2.2.0 → justhtml-2.4.0}/docs/transforms.md +0 -0
  57. {justhtml-2.2.0 → justhtml-2.4.0}/docs/unsafe-handling.md +0 -0
  58. {justhtml-2.2.0 → justhtml-2.4.0}/docs/url-cleaning.md +0 -0
  59. {justhtml-2.2.0 → justhtml-2.4.0}/llms.txt +0 -0
  60. {justhtml-2.2.0 → justhtml-2.4.0}/scripts/release.py +0 -0
  61. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/__init__.py +0 -0
  62. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/__main__.py +0 -0
  63. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/core/__init__.py +0 -0
  64. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/core/constants.py +0 -0
  65. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/core/entities.py +0 -0
  66. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/core/errors.py +0 -0
  67. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/core/rawtext.py +0 -0
  68. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/dom/__init__.py +0 -0
  69. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/dom/builder.py +0 -0
  70. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/parser/context.py +0 -0
  71. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/parser/encoding.py +0 -0
  72. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/parser/stream.py +0 -0
  73. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/py.typed +0 -0
  74. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/sanitizer/__init__.py +0 -0
  75. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/sanitizer/css.py +0 -0
  76. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/sanitizer/dom.py +0 -0
  77. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/sanitizer/policy.py +0 -0
  78. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/sanitizer/policy_defaults.py +0 -0
  79. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/sanitizer/rawtext.py +0 -0
  80. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/sanitizer/url/__init__.py +0 -0
  81. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/sanitizer/url/policy.py +0 -0
  82. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/sanitizer/url/runtime.py +0 -0
  83. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/sanitizer/url/spec.py +0 -0
  84. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/selector/__init__.py +0 -0
  85. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/selector/core.py +0 -0
  86. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/serializer/__init__.py +0 -0
  87. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/serializer/markdown.py +0 -0
  88. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/tokenizer/__init__.py +0 -0
  89. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/tokenizer/html.py +0 -0
  90. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/tokenizer/tokens.py +0 -0
  91. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/transforms/__init__.py +0 -0
  92. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/transforms/compile.py +0 -0
  93. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/transforms/linkify.py +0 -0
  94. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/transforms/linkify_core.py +0 -0
  95. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/transforms/runtime.py +0 -0
  96. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/transforms/spec.py +0 -0
  97. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/treebuilder/__init__.py +0 -0
  98. {justhtml-2.2.0 → justhtml-2.4.0}/src/justhtml/treebuilder/utils.py +0 -0
  99. {justhtml-2.2.0 → justhtml-2.4.0}/test-summary.txt +0 -0
  100. {justhtml-2.2.0 → justhtml-2.4.0}/tests/README.md +0 -0
  101. {justhtml-2.2.0 → justhtml-2.4.0}/tests/__init__.py +0 -0
  102. {justhtml-2.2.0 → justhtml-2.4.0}/tests/data/wikipedia.html +0 -0
  103. {justhtml-2.2.0 → justhtml-2.4.0}/tests/harness/__init__.py +0 -0
  104. {justhtml-2.2.0 → justhtml-2.4.0}/tests/harness/encoding.py +0 -0
  105. {justhtml-2.2.0 → justhtml-2.4.0}/tests/harness/regressions.py +0 -0
  106. {justhtml-2.2.0 → justhtml-2.4.0}/tests/harness/reporter.py +0 -0
  107. {justhtml-2.2.0 → justhtml-2.4.0}/tests/harness/serializer.py +0 -0
  108. {justhtml-2.2.0 → justhtml-2.4.0}/tests/harness/tokenizer.py +0 -0
  109. {justhtml-2.2.0 → justhtml-2.4.0}/tests/harness/tree.py +0 -0
  110. {justhtml-2.2.0 → justhtml-2.4.0}/tests/justhtml-sanitize-tests/cases.json +0 -0
  111. {justhtml-2.2.0 → justhtml-2.4.0}/tests/justhtml-tests/branch_coverage.dat +0 -0
  112. {justhtml-2.2.0 → justhtml-2.4.0}/tests/justhtml-tests/coverage_gaps.test +0 -0
  113. {justhtml-2.2.0 → justhtml-2.4.0}/tests/justhtml-tests/empty_stack_edge_cases.dat +0 -0
  114. {justhtml-2.2.0 → justhtml-2.4.0}/tests/justhtml-tests/entities.test +0 -0
  115. {justhtml-2.2.0 → justhtml-2.4.0}/tests/justhtml-tests/iframe_srcdoc.dat +0 -0
  116. {justhtml-2.2.0 → justhtml-2.4.0}/tests/justhtml-tests/tokenizer_edge_cases.test +0 -0
  117. {justhtml-2.2.0 → justhtml-2.4.0}/tests/justhtml-tests/treebuilder_coverage.dat +0 -0
  118. {justhtml-2.2.0 → justhtml-2.4.0}/tests/justhtml-tests/xml_coercion.dat +0 -0
  119. {justhtml-2.2.0 → justhtml-2.4.0}/tests/justhtml-tests/xml_coercion_coverage.test +0 -0
  120. {justhtml-2.2.0 → justhtml-2.4.0}/tests/linkify-it/LICENSE.txt +0 -0
  121. {justhtml-2.2.0 → justhtml-2.4.0}/tests/linkify-it/README.md +0 -0
  122. {justhtml-2.2.0 → justhtml-2.4.0}/tests/linkify-it/fixtures/links.txt +0 -0
  123. {justhtml-2.2.0 → justhtml-2.4.0}/tests/linkify-it/fixtures/not_links.txt +0 -0
  124. {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_builder.py +0 -0
  125. {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_cli.py +0 -0
  126. {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_docs_examples.py +0 -0
  127. {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_encoding.py +0 -0
  128. {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_errors.py +0 -0
  129. {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_linkify_it.py +0 -0
  130. {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_linkify_transform.py +0 -0
  131. {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_node.py +0 -0
  132. {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_playground_local_repo_file_list.py +0 -0
  133. {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_sanitize.py +0 -0
  134. {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_sanitize_integration.py +0 -0
  135. {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_sanitize_transform.py +0 -0
  136. {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_selector.py +0 -0
  137. {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_stream.py +0 -0
  138. {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_tokenizer.py +0 -0
  139. {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_transforms.py +0 -0
  140. {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_transforms_compiler.py +0 -0
  141. {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_transforms_edge_cases.py +0 -0
  142. {justhtml-2.2.0 → justhtml-2.4.0}/tests/test_transforms_sanitize_integration.py +0 -0
  143. {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.2.0
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. Start by using JustHTML’s default sanitizer (`JustHTML(...).to_html()`).
124
- 2. Add a `SanitizationPolicy` that matches your allowlist and URL requirements.
125
- 3. Add transforms to replace any Bleach filter/linkify behavior.
126
- 4. Add tests for your exact input corpus and output expectations (especially around URLs and allowed attributes).
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).
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "justhtml"
3
3
  authors = [{ name = "Emil Stenström", email = "emil@emilstenstrom.se" }]
4
- version = "2.2.0"
4
+ version = "2.4.0"
5
5
  description = "A pure Python HTML5 parser that just works."
6
6
  readme = "README.md"
7
7
  license = { file = "LICENSE" }
@@ -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 == "#comment" or (child.name == "#text" and (child.data or "").strip()):
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 not None and node.name == "p":
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 not None and node.name == "p":
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
- if self.tokenizer is not None and self.tokenizer.track_node_locations:
549
- node._origin_pos = self.tokenizer.last_token_start_pos
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 = self.tokenizer.location_at_pos(node._origin_pos)
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
- if self.tokenizer is not None and self.tokenizer.track_node_locations:
562
- node._origin_pos = self.tokenizer.last_token_start_pos
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 = self.tokenizer.location_at_pos(node._origin_pos)
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
- if self.tokenizer is not None and self.tokenizer.track_node_locations:
595
- node._origin_pos = self.tokenizer.last_token_start_pos
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 = self.tokenizer.location_at_pos(node._origin_pos)
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
- if self.tokenizer is not None and self.tokenizer.track_node_locations:
619
- node._origin_pos = self.tokenizer.last_token_start_pos
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 = self.tokenizer.location_at_pos(node._origin_pos)
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
- if self.tokenizer is not None and self.tokenizer.track_node_locations:
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 = self.tokenizer.location_at_pos(node._origin_pos)
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
- removed = self.open_elements[index:]
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
- removed = self.open_elements[index:]
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._note_open_element_removed(node)
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["name"] == name:
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["node"] is node:
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["signature"]
843
- if entry["name"] == name and existing_signature == signature:
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["name"] == name:
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["name"] == "a":
864
- return entry["node"] not in self.open_elements
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["name"] == name:
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._note_open_element_removed(node)
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["node"] in self.open_elements:
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["node"] in self.open_elements:
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["name"], self._clone_attributes(entry["attrs"]), False)
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.tokenizer is not None and self.tokenizer.track_node_locations:
933
- new_node._origin_pos = entry["node"].origin_offset
934
- new_node._origin_line = entry["node"].origin_line
935
- new_node._origin_col = entry["node"].origin_col
936
- entry["node"] = new_node
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
- removed = self.open_elements[idx:]
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