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