justhtml 2.3.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 (142) hide show
  1. {justhtml-2.3.0 → justhtml-2.4.0}/CHANGELOG.md +5 -0
  2. {justhtml-2.3.0 → justhtml-2.4.0}/PKG-INFO +3 -1
  3. {justhtml-2.3.0 → justhtml-2.4.0}/README.md +2 -0
  4. {justhtml-2.3.0 → justhtml-2.4.0}/docs/bleach-migration.md +117 -4
  5. {justhtml-2.3.0 → justhtml-2.4.0}/pyproject.toml +1 -1
  6. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/treebuilder/modes.py +7 -0
  7. {justhtml-2.3.0 → justhtml-2.4.0}/tests/test_treebuilder.py +30 -0
  8. {justhtml-2.3.0 → justhtml-2.4.0}/.github/copilot-instructions.md +0 -0
  9. {justhtml-2.3.0 → justhtml-2.4.0}/.github/workflows/ci.yml +0 -0
  10. {justhtml-2.3.0 → justhtml-2.4.0}/.github/workflows/publish.yml +0 -0
  11. {justhtml-2.3.0 → justhtml-2.4.0}/.gitignore +0 -0
  12. {justhtml-2.3.0 → justhtml-2.4.0}/.pre-commit-config.yaml +0 -0
  13. {justhtml-2.3.0 → justhtml-2.4.0}/CODE_OF_CONDUCT.md +0 -0
  14. {justhtml-2.3.0 → justhtml-2.4.0}/CONTRIBUTING.md +0 -0
  15. {justhtml-2.3.0 → justhtml-2.4.0}/LICENSE +0 -0
  16. {justhtml-2.3.0 → justhtml-2.4.0}/Makefile +0 -0
  17. {justhtml-2.3.0 → justhtml-2.4.0}/SECURITY.md +0 -0
  18. {justhtml-2.3.0 → justhtml-2.4.0}/assets/justhtml-readme-explainer.png +0 -0
  19. {justhtml-2.3.0 → justhtml-2.4.0}/benchmarks/correctness.py +0 -0
  20. {justhtml-2.3.0 → justhtml-2.4.0}/benchmarks/fuzz.py +0 -0
  21. {justhtml-2.3.0 → justhtml-2.4.0}/benchmarks/performance.py +0 -0
  22. {justhtml-2.3.0 → justhtml-2.4.0}/benchmarks/profile.py +0 -0
  23. {justhtml-2.3.0 → justhtml-2.4.0}/docs/_config.yml +0 -0
  24. {justhtml-2.3.0 → justhtml-2.4.0}/docs/_layouts/default.html +0 -0
  25. {justhtml-2.3.0 → justhtml-2.4.0}/docs/api.md +0 -0
  26. {justhtml-2.3.0 → justhtml-2.4.0}/docs/assets/search.js +0 -0
  27. {justhtml-2.3.0 → justhtml-2.4.0}/docs/building.md +0 -0
  28. {justhtml-2.3.0 → justhtml-2.4.0}/docs/cli.md +0 -0
  29. {justhtml-2.3.0 → justhtml-2.4.0}/docs/comparison.md +0 -0
  30. {justhtml-2.3.0 → justhtml-2.4.0}/docs/correctness.md +0 -0
  31. {justhtml-2.3.0 → justhtml-2.4.0}/docs/encoding.md +0 -0
  32. {justhtml-2.3.0 → justhtml-2.4.0}/docs/errors.md +0 -0
  33. {justhtml-2.3.0 → justhtml-2.4.0}/docs/fragments.md +0 -0
  34. {justhtml-2.3.0 → justhtml-2.4.0}/docs/html-cleaning.md +0 -0
  35. {justhtml-2.3.0 → justhtml-2.4.0}/docs/index.md +0 -0
  36. {justhtml-2.3.0 → justhtml-2.4.0}/docs/linkify.md +0 -0
  37. {justhtml-2.3.0 → justhtml-2.4.0}/docs/migration-examples.md +0 -0
  38. {justhtml-2.3.0 → justhtml-2.4.0}/docs/playground/app.js +0 -0
  39. {justhtml-2.3.0 → justhtml-2.4.0}/docs/playground/index.html +0 -0
  40. {justhtml-2.3.0 → justhtml-2.4.0}/docs/playground/py/__init__.py +0 -0
  41. {justhtml-2.3.0 → justhtml-2.4.0}/docs/playground/py/install_latest_justhtml.py +0 -0
  42. {justhtml-2.3.0 → justhtml-2.4.0}/docs/playground/py/render.py +0 -0
  43. {justhtml-2.3.0 → justhtml-2.4.0}/docs/playground/py/use_local_repo.py +0 -0
  44. {justhtml-2.3.0 → justhtml-2.4.0}/docs/quickstart.md +0 -0
  45. {justhtml-2.3.0 → justhtml-2.4.0}/docs/sanitization.md +0 -0
  46. {justhtml-2.3.0 → justhtml-2.4.0}/docs/selectors.md +0 -0
  47. {justhtml-2.3.0 → justhtml-2.4.0}/docs/streaming.md +0 -0
  48. {justhtml-2.3.0 → justhtml-2.4.0}/docs/text.md +0 -0
  49. {justhtml-2.3.0 → justhtml-2.4.0}/docs/transforms.md +0 -0
  50. {justhtml-2.3.0 → justhtml-2.4.0}/docs/unsafe-handling.md +0 -0
  51. {justhtml-2.3.0 → justhtml-2.4.0}/docs/url-cleaning.md +0 -0
  52. {justhtml-2.3.0 → justhtml-2.4.0}/llms.txt +0 -0
  53. {justhtml-2.3.0 → justhtml-2.4.0}/run_tests.py +0 -0
  54. {justhtml-2.3.0 → justhtml-2.4.0}/scripts/release.py +0 -0
  55. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/__init__.py +0 -0
  56. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/__main__.py +0 -0
  57. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/core/__init__.py +0 -0
  58. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/core/constants.py +0 -0
  59. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/core/entities.py +0 -0
  60. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/core/errors.py +0 -0
  61. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/core/rawtext.py +0 -0
  62. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/dom/__init__.py +0 -0
  63. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/dom/builder.py +0 -0
  64. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/parser/__init__.py +0 -0
  65. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/parser/context.py +0 -0
  66. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/parser/encoding.py +0 -0
  67. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/parser/stream.py +0 -0
  68. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/py.typed +0 -0
  69. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/sanitizer/__init__.py +0 -0
  70. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/sanitizer/css.py +0 -0
  71. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/sanitizer/dom.py +0 -0
  72. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/sanitizer/policy.py +0 -0
  73. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/sanitizer/policy_defaults.py +0 -0
  74. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/sanitizer/rawtext.py +0 -0
  75. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/sanitizer/url/__init__.py +0 -0
  76. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/sanitizer/url/policy.py +0 -0
  77. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/sanitizer/url/runtime.py +0 -0
  78. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/sanitizer/url/spec.py +0 -0
  79. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/selector/__init__.py +0 -0
  80. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/selector/core.py +0 -0
  81. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/serializer/__init__.py +0 -0
  82. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/serializer/html.py +0 -0
  83. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/serializer/markdown.py +0 -0
  84. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/tokenizer/__init__.py +0 -0
  85. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/tokenizer/html.py +0 -0
  86. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/tokenizer/tokens.py +0 -0
  87. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/transforms/__init__.py +0 -0
  88. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/transforms/compile.py +0 -0
  89. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/transforms/linkify.py +0 -0
  90. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/transforms/linkify_core.py +0 -0
  91. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/transforms/runtime.py +0 -0
  92. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/transforms/spec.py +0 -0
  93. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/treebuilder/__init__.py +0 -0
  94. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/treebuilder/core.py +0 -0
  95. {justhtml-2.3.0 → justhtml-2.4.0}/src/justhtml/treebuilder/utils.py +0 -0
  96. {justhtml-2.3.0 → justhtml-2.4.0}/test-summary.txt +0 -0
  97. {justhtml-2.3.0 → justhtml-2.4.0}/tests/README.md +0 -0
  98. {justhtml-2.3.0 → justhtml-2.4.0}/tests/__init__.py +0 -0
  99. {justhtml-2.3.0 → justhtml-2.4.0}/tests/data/wikipedia.html +0 -0
  100. {justhtml-2.3.0 → justhtml-2.4.0}/tests/harness/__init__.py +0 -0
  101. {justhtml-2.3.0 → justhtml-2.4.0}/tests/harness/encoding.py +0 -0
  102. {justhtml-2.3.0 → justhtml-2.4.0}/tests/harness/regressions.py +0 -0
  103. {justhtml-2.3.0 → justhtml-2.4.0}/tests/harness/reporter.py +0 -0
  104. {justhtml-2.3.0 → justhtml-2.4.0}/tests/harness/serializer.py +0 -0
  105. {justhtml-2.3.0 → justhtml-2.4.0}/tests/harness/tokenizer.py +0 -0
  106. {justhtml-2.3.0 → justhtml-2.4.0}/tests/harness/tree.py +0 -0
  107. {justhtml-2.3.0 → justhtml-2.4.0}/tests/justhtml-sanitize-tests/cases.json +0 -0
  108. {justhtml-2.3.0 → justhtml-2.4.0}/tests/justhtml-tests/branch_coverage.dat +0 -0
  109. {justhtml-2.3.0 → justhtml-2.4.0}/tests/justhtml-tests/coverage_gaps.test +0 -0
  110. {justhtml-2.3.0 → justhtml-2.4.0}/tests/justhtml-tests/empty_stack_edge_cases.dat +0 -0
  111. {justhtml-2.3.0 → justhtml-2.4.0}/tests/justhtml-tests/entities.test +0 -0
  112. {justhtml-2.3.0 → justhtml-2.4.0}/tests/justhtml-tests/iframe_srcdoc.dat +0 -0
  113. {justhtml-2.3.0 → justhtml-2.4.0}/tests/justhtml-tests/tokenizer_edge_cases.test +0 -0
  114. {justhtml-2.3.0 → justhtml-2.4.0}/tests/justhtml-tests/treebuilder_coverage.dat +0 -0
  115. {justhtml-2.3.0 → justhtml-2.4.0}/tests/justhtml-tests/xml_coercion.dat +0 -0
  116. {justhtml-2.3.0 → justhtml-2.4.0}/tests/justhtml-tests/xml_coercion_coverage.test +0 -0
  117. {justhtml-2.3.0 → justhtml-2.4.0}/tests/linkify-it/LICENSE.txt +0 -0
  118. {justhtml-2.3.0 → justhtml-2.4.0}/tests/linkify-it/README.md +0 -0
  119. {justhtml-2.3.0 → justhtml-2.4.0}/tests/linkify-it/fixtures/links.txt +0 -0
  120. {justhtml-2.3.0 → justhtml-2.4.0}/tests/linkify-it/fixtures/not_links.txt +0 -0
  121. {justhtml-2.3.0 → justhtml-2.4.0}/tests/test_builder.py +0 -0
  122. {justhtml-2.3.0 → justhtml-2.4.0}/tests/test_cli.py +0 -0
  123. {justhtml-2.3.0 → justhtml-2.4.0}/tests/test_docs_examples.py +0 -0
  124. {justhtml-2.3.0 → justhtml-2.4.0}/tests/test_encoding.py +0 -0
  125. {justhtml-2.3.0 → justhtml-2.4.0}/tests/test_errors.py +0 -0
  126. {justhtml-2.3.0 → justhtml-2.4.0}/tests/test_linkify_internals.py +0 -0
  127. {justhtml-2.3.0 → justhtml-2.4.0}/tests/test_linkify_it.py +0 -0
  128. {justhtml-2.3.0 → justhtml-2.4.0}/tests/test_linkify_transform.py +0 -0
  129. {justhtml-2.3.0 → justhtml-2.4.0}/tests/test_node.py +0 -0
  130. {justhtml-2.3.0 → justhtml-2.4.0}/tests/test_playground_local_repo_file_list.py +0 -0
  131. {justhtml-2.3.0 → justhtml-2.4.0}/tests/test_sanitize.py +0 -0
  132. {justhtml-2.3.0 → justhtml-2.4.0}/tests/test_sanitize_integration.py +0 -0
  133. {justhtml-2.3.0 → justhtml-2.4.0}/tests/test_sanitize_transform.py +0 -0
  134. {justhtml-2.3.0 → justhtml-2.4.0}/tests/test_selector.py +0 -0
  135. {justhtml-2.3.0 → justhtml-2.4.0}/tests/test_serialize.py +0 -0
  136. {justhtml-2.3.0 → justhtml-2.4.0}/tests/test_stream.py +0 -0
  137. {justhtml-2.3.0 → justhtml-2.4.0}/tests/test_tokenizer.py +0 -0
  138. {justhtml-2.3.0 → justhtml-2.4.0}/tests/test_transforms.py +0 -0
  139. {justhtml-2.3.0 → justhtml-2.4.0}/tests/test_transforms_compiler.py +0 -0
  140. {justhtml-2.3.0 → justhtml-2.4.0}/tests/test_transforms_edge_cases.py +0 -0
  141. {justhtml-2.3.0 → justhtml-2.4.0}/tests/test_transforms_sanitize_integration.py +0 -0
  142. {justhtml-2.3.0 → justhtml-2.4.0}/tests/test_wikipedia.py +0 -0
@@ -7,6 +7,11 @@ 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
+
10
15
  ## [2.3.0] - 2026-06-12
11
16
 
12
17
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: justhtml
3
- Version: 2.3.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.3.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" }
@@ -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
@@ -2054,6 +2060,7 @@ class TreeBuilderModesMixin:
2054
2060
  "head": _handle_body_start_head,
2055
2061
  "header": _handle_body_start_block_with_p,
2056
2062
  "hgroup": _handle_body_start_block_with_p,
2063
+ "hr": _handle_body_start_hr,
2057
2064
  "html": _handle_body_start_html,
2058
2065
  "i": _handle_body_start_formatting,
2059
2066
  "image": _handle_body_start_image,
@@ -220,6 +220,36 @@ 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
+
223
253
  def test_select_end_tags_create_p_and_close_custom_children_like_chromium(self) -> None:
224
254
  cases = {
225
255
  "<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