justhtml 3.1.0__tar.gz → 3.2.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 (147) hide show
  1. {justhtml-3.1.0 → justhtml-3.2.0}/.github/workflows/ci.yml +9 -8
  2. {justhtml-3.1.0 → justhtml-3.2.0}/CHANGELOG.md +16 -0
  3. {justhtml-3.1.0 → justhtml-3.2.0}/CONTRIBUTING.md +3 -3
  4. {justhtml-3.1.0 → justhtml-3.2.0}/PKG-INFO +4 -4
  5. {justhtml-3.1.0 → justhtml-3.2.0}/README.md +3 -3
  6. {justhtml-3.1.0 → justhtml-3.2.0}/docs/correctness.md +17 -14
  7. {justhtml-3.1.0 → justhtml-3.2.0}/docs/playground/app.js +1 -0
  8. {justhtml-3.1.0 → justhtml-3.2.0}/pyproject.toml +1 -1
  9. {justhtml-3.1.0 → justhtml-3.2.0}/run_tests.py +13 -7
  10. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/__init__.py +13 -1
  11. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/core/entities.py +10 -1
  12. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/core/rawtext.py +2 -1
  13. justhtml-3.2.0/src/justhtml/core/text.py +30 -0
  14. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/dom/__init__.py +20 -1
  15. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/parser/encoding.py +6 -5
  16. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/parser/engine.py +179 -11
  17. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/parser/stream.py +5 -1
  18. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/serializer/html.py +24 -0
  19. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/transforms/runtime.py +1 -1
  20. {justhtml-3.1.0 → justhtml-3.2.0}/test-summary.txt +14 -13
  21. justhtml-3.2.0/tests/README.md +44 -0
  22. {justhtml-3.1.0 → justhtml-3.2.0}/tests/test_encoding.py +19 -13
  23. {justhtml-3.1.0 → justhtml-3.2.0}/tests/test_entities.py +11 -0
  24. {justhtml-3.1.0 → justhtml-3.2.0}/tests/test_node.py +19 -0
  25. {justhtml-3.1.0 → justhtml-3.2.0}/tests/test_parser_engine_coverage.py +42 -3
  26. {justhtml-3.1.0 → justhtml-3.2.0}/tests/test_parser_regressions.py +42 -0
  27. {justhtml-3.1.0 → justhtml-3.2.0}/tests/test_parser_tree.py +2 -4
  28. {justhtml-3.1.0 → justhtml-3.2.0}/tests/test_serialize.py +33 -1
  29. {justhtml-3.1.0 → justhtml-3.2.0}/tests/test_stream.py +48 -0
  30. justhtml-3.1.0/tests/README.md +0 -40
  31. {justhtml-3.1.0 → justhtml-3.2.0}/.github/copilot-instructions.md +0 -0
  32. {justhtml-3.1.0 → justhtml-3.2.0}/.github/workflows/publish.yml +0 -0
  33. {justhtml-3.1.0 → justhtml-3.2.0}/.gitignore +0 -0
  34. {justhtml-3.1.0 → justhtml-3.2.0}/.pre-commit-config.yaml +0 -0
  35. {justhtml-3.1.0 → justhtml-3.2.0}/CODE_OF_CONDUCT.md +0 -0
  36. {justhtml-3.1.0 → justhtml-3.2.0}/LICENSE +0 -0
  37. {justhtml-3.1.0 → justhtml-3.2.0}/Makefile +0 -0
  38. {justhtml-3.1.0 → justhtml-3.2.0}/SECURITY.md +0 -0
  39. {justhtml-3.1.0 → justhtml-3.2.0}/assets/justhtml-readme-explainer.png +0 -0
  40. {justhtml-3.1.0 → justhtml-3.2.0}/benchmarks/chromium_parser_diff.py +0 -0
  41. {justhtml-3.1.0 → justhtml-3.2.0}/benchmarks/correctness.py +0 -0
  42. {justhtml-3.1.0 → justhtml-3.2.0}/benchmarks/fuzz.py +0 -0
  43. {justhtml-3.1.0 → justhtml-3.2.0}/benchmarks/html5lib_engine_diff.py +0 -0
  44. {justhtml-3.1.0 → justhtml-3.2.0}/benchmarks/parser_engine_gate.py +0 -0
  45. {justhtml-3.1.0 → justhtml-3.2.0}/benchmarks/performance.py +0 -0
  46. {justhtml-3.1.0 → justhtml-3.2.0}/benchmarks/profile.py +0 -0
  47. {justhtml-3.1.0 → justhtml-3.2.0}/docs/_config.yml +0 -0
  48. {justhtml-3.1.0 → justhtml-3.2.0}/docs/_layouts/default.html +0 -0
  49. {justhtml-3.1.0 → justhtml-3.2.0}/docs/api.md +0 -0
  50. {justhtml-3.1.0 → justhtml-3.2.0}/docs/assets/search.js +0 -0
  51. {justhtml-3.1.0 → justhtml-3.2.0}/docs/bleach-migration.md +0 -0
  52. {justhtml-3.1.0 → justhtml-3.2.0}/docs/building.md +0 -0
  53. {justhtml-3.1.0 → justhtml-3.2.0}/docs/cli.md +0 -0
  54. {justhtml-3.1.0 → justhtml-3.2.0}/docs/comparison.md +0 -0
  55. {justhtml-3.1.0 → justhtml-3.2.0}/docs/encoding.md +0 -0
  56. {justhtml-3.1.0 → justhtml-3.2.0}/docs/errors.md +0 -0
  57. {justhtml-3.1.0 → justhtml-3.2.0}/docs/fragments.md +0 -0
  58. {justhtml-3.1.0 → justhtml-3.2.0}/docs/html-cleaning.md +0 -0
  59. {justhtml-3.1.0 → justhtml-3.2.0}/docs/index.md +0 -0
  60. {justhtml-3.1.0 → justhtml-3.2.0}/docs/linkify.md +0 -0
  61. {justhtml-3.1.0 → justhtml-3.2.0}/docs/migration-examples.md +0 -0
  62. {justhtml-3.1.0 → justhtml-3.2.0}/docs/parser-engine-findings.md +0 -0
  63. {justhtml-3.1.0 → justhtml-3.2.0}/docs/playground/index.html +0 -0
  64. {justhtml-3.1.0 → justhtml-3.2.0}/docs/playground/py/__init__.py +0 -0
  65. {justhtml-3.1.0 → justhtml-3.2.0}/docs/playground/py/install_latest_justhtml.py +0 -0
  66. {justhtml-3.1.0 → justhtml-3.2.0}/docs/playground/py/render.py +0 -0
  67. {justhtml-3.1.0 → justhtml-3.2.0}/docs/playground/py/use_local_repo.py +0 -0
  68. {justhtml-3.1.0 → justhtml-3.2.0}/docs/quickstart.md +0 -0
  69. {justhtml-3.1.0 → justhtml-3.2.0}/docs/sanitization.md +0 -0
  70. {justhtml-3.1.0 → justhtml-3.2.0}/docs/selectors.md +0 -0
  71. {justhtml-3.1.0 → justhtml-3.2.0}/docs/streaming.md +0 -0
  72. {justhtml-3.1.0 → justhtml-3.2.0}/docs/text.md +0 -0
  73. {justhtml-3.1.0 → justhtml-3.2.0}/docs/transforms.md +0 -0
  74. {justhtml-3.1.0 → justhtml-3.2.0}/docs/unsafe-handling.md +0 -0
  75. {justhtml-3.1.0 → justhtml-3.2.0}/docs/url-cleaning.md +0 -0
  76. {justhtml-3.1.0 → justhtml-3.2.0}/llms.txt +0 -0
  77. {justhtml-3.1.0 → justhtml-3.2.0}/scripts/release.py +0 -0
  78. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/__main__.py +0 -0
  79. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/core/__init__.py +0 -0
  80. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/core/constants.py +0 -0
  81. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/core/doctype.py +0 -0
  82. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/core/errors.py +0 -0
  83. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/core/types.py +0 -0
  84. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/dom/builder.py +0 -0
  85. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/parser/__init__.py +0 -0
  86. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/parser/context.py +0 -0
  87. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/parser/options.py +0 -0
  88. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/parser/scanner.py +0 -0
  89. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/py.typed +0 -0
  90. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/sanitizer/__init__.py +0 -0
  91. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/sanitizer/css.py +0 -0
  92. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/sanitizer/dom.py +0 -0
  93. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/sanitizer/policy.py +0 -0
  94. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/sanitizer/policy_defaults.py +0 -0
  95. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/sanitizer/rawtext.py +0 -0
  96. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/sanitizer/url/__init__.py +0 -0
  97. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/sanitizer/url/policy.py +0 -0
  98. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/sanitizer/url/runtime.py +0 -0
  99. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/sanitizer/url/spec.py +0 -0
  100. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/selector/__init__.py +0 -0
  101. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/selector/core.py +0 -0
  102. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/serializer/__init__.py +0 -0
  103. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/serializer/markdown.py +0 -0
  104. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/transforms/__init__.py +0 -0
  105. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/transforms/compile.py +0 -0
  106. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/transforms/linkify.py +0 -0
  107. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/transforms/linkify_core.py +0 -0
  108. {justhtml-3.1.0 → justhtml-3.2.0}/src/justhtml/transforms/spec.py +0 -0
  109. {justhtml-3.1.0 → justhtml-3.2.0}/tests/__init__.py +0 -0
  110. {justhtml-3.1.0 → justhtml-3.2.0}/tests/data/wikipedia.html +0 -0
  111. {justhtml-3.1.0 → justhtml-3.2.0}/tests/harness/__init__.py +0 -0
  112. {justhtml-3.1.0 → justhtml-3.2.0}/tests/harness/encoding.py +0 -0
  113. {justhtml-3.1.0 → justhtml-3.2.0}/tests/harness/regressions.py +0 -0
  114. {justhtml-3.1.0 → justhtml-3.2.0}/tests/harness/reporter.py +0 -0
  115. {justhtml-3.1.0 → justhtml-3.2.0}/tests/harness/serializer.py +0 -0
  116. {justhtml-3.1.0 → justhtml-3.2.0}/tests/harness/tree.py +0 -0
  117. {justhtml-3.1.0 → justhtml-3.2.0}/tests/justhtml-sanitize-tests/cases.json +0 -0
  118. {justhtml-3.1.0 → justhtml-3.2.0}/tests/justhtml-tests/branch_coverage.dat +0 -0
  119. {justhtml-3.1.0 → justhtml-3.2.0}/tests/justhtml-tests/coverage_gaps.test +0 -0
  120. {justhtml-3.1.0 → justhtml-3.2.0}/tests/justhtml-tests/empty_stack_edge_cases.dat +0 -0
  121. {justhtml-3.1.0 → justhtml-3.2.0}/tests/justhtml-tests/entities.test +0 -0
  122. {justhtml-3.1.0 → justhtml-3.2.0}/tests/justhtml-tests/iframe_srcdoc.dat +0 -0
  123. {justhtml-3.1.0 → justhtml-3.2.0}/tests/justhtml-tests/tokenizer_edge_cases.test +0 -0
  124. {justhtml-3.1.0 → justhtml-3.2.0}/tests/justhtml-tests/treebuilder_coverage.dat +0 -0
  125. {justhtml-3.1.0 → justhtml-3.2.0}/tests/justhtml-tests/xml_coercion.dat +0 -0
  126. {justhtml-3.1.0 → justhtml-3.2.0}/tests/justhtml-tests/xml_coercion_coverage.test +0 -0
  127. {justhtml-3.1.0 → justhtml-3.2.0}/tests/linkify-it/LICENSE.txt +0 -0
  128. {justhtml-3.1.0 → justhtml-3.2.0}/tests/linkify-it/README.md +0 -0
  129. {justhtml-3.1.0 → justhtml-3.2.0}/tests/linkify-it/fixtures/links.txt +0 -0
  130. {justhtml-3.1.0 → justhtml-3.2.0}/tests/linkify-it/fixtures/not_links.txt +0 -0
  131. {justhtml-3.1.0 → justhtml-3.2.0}/tests/test_builder.py +0 -0
  132. {justhtml-3.1.0 → justhtml-3.2.0}/tests/test_cli.py +0 -0
  133. {justhtml-3.1.0 → justhtml-3.2.0}/tests/test_docs_examples.py +0 -0
  134. {justhtml-3.1.0 → justhtml-3.2.0}/tests/test_errors.py +0 -0
  135. {justhtml-3.1.0 → justhtml-3.2.0}/tests/test_linkify_internals.py +0 -0
  136. {justhtml-3.1.0 → justhtml-3.2.0}/tests/test_linkify_it.py +0 -0
  137. {justhtml-3.1.0 → justhtml-3.2.0}/tests/test_linkify_transform.py +0 -0
  138. {justhtml-3.1.0 → justhtml-3.2.0}/tests/test_playground_local_repo_file_list.py +0 -0
  139. {justhtml-3.1.0 → justhtml-3.2.0}/tests/test_sanitize.py +0 -0
  140. {justhtml-3.1.0 → justhtml-3.2.0}/tests/test_sanitize_integration.py +0 -0
  141. {justhtml-3.1.0 → justhtml-3.2.0}/tests/test_sanitize_transform.py +0 -0
  142. {justhtml-3.1.0 → justhtml-3.2.0}/tests/test_selector.py +0 -0
  143. {justhtml-3.1.0 → justhtml-3.2.0}/tests/test_transforms.py +0 -0
  144. {justhtml-3.1.0 → justhtml-3.2.0}/tests/test_transforms_compiler.py +0 -0
  145. {justhtml-3.1.0 → justhtml-3.2.0}/tests/test_transforms_edge_cases.py +0 -0
  146. {justhtml-3.1.0 → justhtml-3.2.0}/tests/test_transforms_sanitize_integration.py +0 -0
  147. {justhtml-3.1.0 → justhtml-3.2.0}/tests/test_wikipedia.py +0 -0
@@ -36,20 +36,21 @@ jobs:
36
36
  - name: Checkout html5lib-tests
37
37
  run: |
38
38
  cd ..
39
- git clone https://github.com/html5lib/html5lib-tests.git
39
+ git clone --no-checkout https://github.com/html5lib/html5lib-tests.git
40
+ git -C html5lib-tests fetch origin 9fb614afaa42ce8787840f057b32084308e76549
41
+ git -C html5lib-tests checkout 9fb614afaa42ce8787840f057b32084308e76549
40
42
  git -C html5lib-tests rev-parse HEAD
41
43
 
42
- - name: Setup test symlinks
44
+ - name: Setup test fixtures
43
45
  run: |
44
- cd tests
45
- ln -s ../../html5lib-tests/tokenizer html5lib-tests-tokenizer
46
- ln -s ../../html5lib-tests/tree-construction html5lib-tests-tree
47
- ln -s ../../html5lib-tests/serializer html5lib-tests-serializer
48
- ln -s ../../html5lib-tests/encoding html5lib-tests-encoding
46
+ cp -a ../html5lib-tests/tokenizer tests/html5lib-tests-tokenizer
47
+ cp -a ../html5lib-tests/tree-construction tests/html5lib-tests-tree
48
+ cp -a ../html5lib-tests/serializer tests/html5lib-tests-serializer
49
+ cp -a ../html5lib-tests/encoding tests/html5lib-tests-encoding
49
50
 
50
51
  - name: Run pre-commit
51
52
  id: precommit
52
- uses: pre-commit/action@v3.0.1
53
+ run: pre-commit run --show-diff-on-failure --color=always --all-files
53
54
  env:
54
55
  SKIP: mypy
55
56
 
@@ -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
+ ## [3.2.0] - 2026-07-04
11
+
12
+ ### Changed
13
+ - Use the web platform html5 treebuilder tests from WPT for tree-construction compliance fixtures.
14
+
15
+ ### Fixed
16
+ - Preserve HTML processing-instruction nodes in unsanitized parsing while keeping default-safe sanitization comment-like and conservative, and neutralize `>` in serialized processing-instruction data so a hand-built DOM or a policy that preserves comment-like nodes cannot serialize a processing instruction that a browser parses as a bogus comment terminating early and exposing following markup.
17
+ - Keep raw-text, RCDATA, doctype, and foreign-content CDATA scans aligned after Unicode characters whose Python lowercase mapping changes length or becomes ASCII. Previously, content such as `İ` before a later `<script>` close tag could make the parser miss the real end tag and treat following markup as raw text.
18
+
19
+ ### Performance
20
+ - Short-circuit open-elements-stack scope checks (`<p>`, `<li>`, `<dd>`/`<dt>`, table-cell scoping, and similar) when the target element isn't open anywhere on the stack, avoiding quadratic parse time for deeply nested common block elements such as `<div>`, `<table>`, `<section>`, and heading tags.
21
+
22
+ ### Security
23
+ - (Severity: Low) Reject numeric character references with more digits than any valid Unicode code point could have before converting them to an integer. Previously, a decimal reference such as `&#` followed by thousands of digits could crash parsing with an uncaught `ValueError` before sanitization, since Python limits decimal string-to-integer conversion length.
24
+ - (Severity: Moderate) Prioritize a byte-order mark over a caller-declared transport encoding when decoding byte input, matching the HTML encoding-sniffing algorithm. Previously, passing an explicit `encoding=` alongside untrusted bytes that began with a BOM for a different encoding could make JustHTML decode and sanitize content differently than a browser does, letting markup such as `<script>` pass through undetected while a browser executes it under the BOM-indicated encoding.
25
+
10
26
  ## [3.1.0] - 2026-07-02
11
27
 
12
28
  ### Changed
@@ -24,9 +24,9 @@ Thanks for considering contributing to JustHTML! This document explains how to s
24
24
 
25
25
  ## Running Tests
26
26
 
27
- The test suite uses the html5lib test cases plus additional tests for selector functionality.
27
+ The test suite uses the web platform html5 treebuilder tests plus additional tests for selector functionality.
28
28
 
29
- If you want to run the full html5lib test suite locally, clone `html5lib-tests` next to this repository and create the symlinks described in [tests/README.md](tests/README.md) (tokenizer, tree-construction, and serializer).
29
+ If you want to run the full external fixture suite locally, clone the web platform html5 treebuilder tests and `html5lib-tests` serializer/encoding fixtures next to this repository, then create the symlinks described in [tests/README.md](tests/README.md).
30
30
 
31
31
  ```bash
32
32
  # Run all tests
@@ -58,7 +58,7 @@ Pre-commit runs automatically on every commit and checks:
58
58
  - **Ruff check** - linting with auto-fix
59
59
  - **Ruff format** - code formatting
60
60
  - **Tests & Coverage** - full test suite with 100% coverage requirement
61
- - **Parser Differential** - exact agreement with the reference parser path across scored html5lib tree cases
61
+ - **Parser Differential** - exact agreement with the reference parser path across scored web platform html5 treebuilder cases
62
62
 
63
63
  Run manually:
64
64
  ```bash
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: justhtml
3
- Version: 3.1.0
3
+ Version: 3.2.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
@@ -82,7 +82,7 @@ Most Python HTML libraries optimize for one part of the problem.
82
82
 
83
83
  JustHTML is for applications that want a boring, inspectable, pure-Python default:
84
84
 
85
- - **Correct parsing:** browser-style HTML5 recovery, tested against the official html5lib fixtures.
85
+ - **Correct parsing:** browser-style HTML5 recovery, tested against the web platform html5 treebuilder tests.
86
86
  - **Safe by default:** `JustHTML(html)` sanitizes before you query or serialize.
87
87
  - **One DOM:** parse once, then sanitize, query, transform, serialize, extract text, or produce Markdown.
88
88
  - **Easy deployment:** zero runtime dependencies, no compiler, works on PyPy and Pyodide.
@@ -164,10 +164,10 @@ justhtml index.html --selector "article" --format markdown
164
164
 
165
165
  ## Correctness
166
166
 
167
- JustHTML is tested against the official html5lib tree-construction, serializer, and encoding fixtures, plus project-specific sanitizer, selector, transform, CLI, and regression tests.
167
+ JustHTML is tested against the web platform html5 treebuilder tests, plus html5lib serializer/encoding fixtures and project-specific sanitizer, selector, transform, CLI, and regression tests.
168
168
 
169
169
  The current test suite enforces 100% combined line and branch coverage, including the parser engine.
170
- The parser engine additionally requires exact agreement with the reference path across the html5lib tree suite.
170
+ The parser engine additionally requires exact agreement with the reference path across the web platform html5 treebuilder suite.
171
171
  See [Correctness Testing](docs/correctness.md) for details.
172
172
 
173
173
  ## Documentation
@@ -28,7 +28,7 @@ Most Python HTML libraries optimize for one part of the problem.
28
28
 
29
29
  JustHTML is for applications that want a boring, inspectable, pure-Python default:
30
30
 
31
- - **Correct parsing:** browser-style HTML5 recovery, tested against the official html5lib fixtures.
31
+ - **Correct parsing:** browser-style HTML5 recovery, tested against the web platform html5 treebuilder tests.
32
32
  - **Safe by default:** `JustHTML(html)` sanitizes before you query or serialize.
33
33
  - **One DOM:** parse once, then sanitize, query, transform, serialize, extract text, or produce Markdown.
34
34
  - **Easy deployment:** zero runtime dependencies, no compiler, works on PyPy and Pyodide.
@@ -110,10 +110,10 @@ justhtml index.html --selector "article" --format markdown
110
110
 
111
111
  ## Correctness
112
112
 
113
- JustHTML is tested against the official html5lib tree-construction, serializer, and encoding fixtures, plus project-specific sanitizer, selector, transform, CLI, and regression tests.
113
+ JustHTML is tested against the web platform html5 treebuilder tests, plus html5lib serializer/encoding fixtures and project-specific sanitizer, selector, transform, CLI, and regression tests.
114
114
 
115
115
  The current test suite enforces 100% combined line and branch coverage, including the parser engine.
116
- The parser engine additionally requires exact agreement with the reference path across the html5lib tree suite.
116
+ The parser engine additionally requires exact agreement with the reference path across the web platform html5 treebuilder suite.
117
117
  See [Correctness Testing](docs/correctness.md) for details.
118
118
 
119
119
  ## Documentation
@@ -2,18 +2,18 @@
2
2
 
3
3
  # Correctness Testing
4
4
 
5
- JustHTML is the only pure-Python HTML5 parser that passes 100% of the official html5lib test suite. This page explains how we verify and maintain that compliance.
5
+ JustHTML is tested against the web platform html5 treebuilder tests. This page explains how we verify and maintain that compliance.
6
6
 
7
- ## The html5lib Test Suite
7
+ ## The Web Platform Tests
8
8
 
9
- The [html5lib-tests](https://github.com/html5lib/html5lib-tests) repository is the gold standard for HTML5 parsing compliance. It's used by browser vendors to verify their implementations against the [WHATWG HTML5 specification](https://html.spec.whatwg.org/).
9
+ The web platform html5 treebuilder tests live in [web-platform-tests/wpt](https://github.com/web-platform-tests/wpt/tree/master/html/syntax/parsing/resources). Serializer and encoding fixtures remain in [html5lib-tests](https://github.com/html5lib/html5lib-tests).
10
10
 
11
- Our checked-in test inputs contain:
11
+ The external fixture inputs contain:
12
12
 
13
- - **56 tree-construction test files** - Testing how the parser builds the DOM tree
13
+ - **61 treebuilder test files** - Testing how the parser builds the DOM tree
14
14
  - **5 serializer fixture files** - Testing how token streams are serialized back to HTML
15
15
  - **Encoding sniffing tests** - Testing BOM/meta charset/transport overrides and legacy fallbacks
16
- - **1,791 enabled tree-construction cases** - Covering edge cases, error recovery, and spec compliance
16
+ - **1,914 enabled treebuilder cases** - Covering edge cases, error recovery, and spec compliance
17
17
 
18
18
  ### What the Tests Cover
19
19
 
@@ -54,12 +54,11 @@ This tests the adoption agency algorithm - when `</b>` is encountered inside `<p
54
54
  ## Compliance Comparison
55
55
 
56
56
  We run the same test suite against other Python parsers to compare compliance.
57
- The cross-parser snapshot below used the 1,743 cases available when it was
58
- recorded; the current JustHTML gate covers 1,791 enabled cases.
57
+ The cross-parser snapshot below used the 1,743 cases available when it was recorded.
59
58
 
60
59
  | Parser | Tests Passed | Compliance | Notes |
61
60
  |--------|-------------|------------|-------|
62
- | **JustHTML** | 1743/1743 | **100%** | Full spec compliance in this comparison snapshot; current gate: 1791/1791 |
61
+ | **JustHTML** | 1743/1743 | **100%** | Full spec compliance in this comparison snapshot |
63
62
  | selectolax | 1743/1743 | 100% | C-based (Lexbor), fast and spec-compliant with dev `html5test` output API |
64
63
  | markupever | 1545/1743 | 89% | Rust-based (html5ever), mostly correct |
65
64
  | html5lib | 1496/1743 | 86% | Reference implementation, but incomplete |
@@ -68,9 +67,9 @@ recorded; the current JustHTML gate covers 1,791 enabled cases.
68
67
  | html.parser | 6/1743 | <1% | Python stdlib, basic error recovery only |
69
68
  | lxml | 5/1743 | <1% | XML-based, not HTML5 compliant |
70
69
 
71
- *Run `python benchmarks/correctness.py` to reproduce these results. The selectolax score requires its dev `html5test` output and fragment-context APIs. These scores were refreshed against html5lib-tests `e446320`.*
70
+ *Run `python benchmarks/correctness.py` to reproduce these results. The selectolax score requires its dev `html5test` output and fragment-context APIs. These historical scores were refreshed against html5lib-tests `e446320`.*
72
71
 
73
- These numbers come from a strict tree comparison against the expected output in the `html5lib-tests` tree-construction fixtures (excluding `#script-on` / `#script-off` cases). Unsupported parser capabilities count as failures for this compliance table. The numbers will not match the `html5lib` project’s own reported totals, because `html5lib` runs the suite in multiple configurations and also has its own skip/xfail lists.
72
+ These numbers come from a strict tree comparison against the expected output in the treebuilder fixtures available at the time (excluding `#script-on` / `#script-off` cases). Unsupported parser capabilities count as failures for this compliance table. The numbers will not match the `html5lib` project’s own reported totals, because `html5lib` runs the suite in multiple configurations and also has its own skip/xfail lists.
74
73
 
75
74
  ## Our Testing Strategy
76
75
 
@@ -123,7 +122,7 @@ PYTHONPATH=src python benchmarks/html5lib_engine_diff.py \
123
122
  --fail-on-current-exceptions
124
123
  ```
125
124
 
126
- This requires exact agreement with the reference parser path across every scored html5lib tree-construction case.
125
+ This requires exact agreement with the reference parser path across every scored web platform html5 treebuilder case.
127
126
 
128
127
  ### 3. Fuzz Testing (millions of cases)
129
128
 
@@ -169,14 +168,18 @@ We maintain additional tests in `tests/justhtml-tests/` for:
169
168
  ### Quick Start
170
169
 
171
170
  ```bash
172
- # Clone the test suite (one-time setup)
171
+ # Clone the test suites (one-time setup)
172
+ cd ..
173
+ git clone --filter=blob:none --sparse https://github.com/web-platform-tests/wpt.git
174
+ cd wpt
175
+ git sparse-checkout set html/syntax/parsing/resources
173
176
  cd ..
174
177
  git clone https://github.com/html5lib/html5lib-tests.git
175
178
  cd justhtml
176
179
 
177
180
  # Create symlinks
178
181
  cd tests
179
- ln -s ../../html5lib-tests/tree-construction html5lib-tests-tree
182
+ ln -s ../../wpt/html/syntax/parsing/resources html5lib-tests-tree
180
183
  ln -s ../../html5lib-tests/serializer html5lib-tests-serializer
181
184
  ln -s ../../html5lib-tests/encoding html5lib-tests-encoding
182
185
  cd ..
@@ -65,6 +65,7 @@ async function installJusthtmlFromLocalRepo(pyodideInstance) {
65
65
  "parser/encoding.py",
66
66
  "core/entities.py",
67
67
  "core/errors.py",
68
+ "core/text.py",
68
69
  "core/types.py",
69
70
  "core/doctype.py",
70
71
  "serializer/markdown.py",
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "justhtml"
3
3
  authors = [{ name = "Emil Stenström", email = "emil@emilstenstrom.se" }]
4
- version = "3.1.0"
4
+ version = "3.2.0"
5
5
  description = "A pure Python HTML5 parser that just works."
6
6
  readme = "README.md"
7
7
  license = { file = "LICENSE" }
@@ -114,7 +114,7 @@ def parse_args():
114
114
  "--check-errors",
115
115
  action="store_true",
116
116
  help=(
117
- "Enable additional error validation. For html5lib tree-construction .dat tests, "
117
+ "Enable additional error validation. For web platform html5 treebuilder .dat tests, "
118
118
  "validates the number of parse errors; for tests/justhtml-tests, validates the exact "
119
119
  "ordered list of error codes."
120
120
  "parse errors (code+line+col) when provided by the fixture."
@@ -295,7 +295,7 @@ def main():
295
295
  run_encoding = suite in {"all", "encoding"}
296
296
  run_unit = suite in {"all", "unit"}
297
297
 
298
- # Check that html5lib-tests symlinks exist (only for the selected suites)
298
+ # Check that external fixture symlinks exist (only for the selected suites)
299
299
  tree_tests = test_dir / "html5lib-tests-tree"
300
300
  serializer_tests = test_dir / "html5lib-tests-serializer"
301
301
  encoding_tests = test_dir / "html5lib-tests-encoding"
@@ -307,13 +307,19 @@ def main():
307
307
  if run_encoding and not encoding_tests.exists():
308
308
  missing.append(str(encoding_tests))
309
309
  if len(missing) > 0:
310
- print("ERROR: html5lib-tests not found. Please create symlinks:", file=sys.stderr)
310
+ print("ERROR: external test fixtures not found. Please create symlinks:", file=sys.stderr)
311
311
  for path in missing:
312
312
  print(f" {path}", file=sys.stderr)
313
- print("\nTo set up, clone html5lib-tests and create symlinks:", file=sys.stderr)
314
- print(" git clone https://github.com/html5lib/html5lib-tests.git ../html5lib-tests", file=sys.stderr)
313
+ print("\nTo set up, clone the fixture repositories and create symlinks:", file=sys.stderr)
315
314
  if run_tree:
316
- print(" ln -s ../../html5lib-tests/tree-construction tests/html5lib-tests-tree", file=sys.stderr)
315
+ print(
316
+ " git clone --filter=blob:none --sparse https://github.com/web-platform-tests/wpt.git ../wpt",
317
+ file=sys.stderr,
318
+ )
319
+ print(" (cd ../wpt && git sparse-checkout set html/syntax/parsing/resources)", file=sys.stderr)
320
+ print(" ln -s ../../wpt/html/syntax/parsing/resources tests/html5lib-tests-tree", file=sys.stderr)
321
+ if run_serializer or run_encoding:
322
+ print(" git clone https://github.com/html5lib/html5lib-tests.git ../html5lib-tests", file=sys.stderr)
317
323
  if run_serializer:
318
324
  print(" ln -s ../../html5lib-tests/serializer tests/html5lib-tests-serializer", file=sys.stderr)
319
325
  if run_encoding:
@@ -343,7 +349,7 @@ def main():
343
349
  combined_results.update(runner.file_results)
344
350
 
345
351
  if run_justhtml_tree:
346
- # Run JustHTML-specific tree-construction tests (custom .dat fixtures).
352
+ # Run JustHTML-specific treebuilder tests (custom .dat fixtures).
347
353
  # These live outside the upstream html5lib-tests checkout.
348
354
  justhtml_tree_tests = test_dir / "justhtml-tests"
349
355
  justhtml_runner = TestRunner(justhtml_tree_tests, config)
@@ -1,5 +1,16 @@
1
1
  from .core.types import ParseError
2
- from .dom import Comment, Document, DocumentFragment, Element, Node, NodeType, QueryMatch, Template, Text
2
+ from .dom import (
3
+ Comment,
4
+ Document,
5
+ DocumentFragment,
6
+ Element,
7
+ Node,
8
+ NodeType,
9
+ ProcessingInstruction,
10
+ QueryMatch,
11
+ Template,
12
+ Text,
13
+ )
3
14
  from .parser import JustHTML, StrictModeError
4
15
  from .parser.stream import stream
5
16
  from .sanitizer import (
@@ -70,6 +81,7 @@ __all__ = [
70
81
  "Node",
71
82
  "NodeType",
72
83
  "ParseError",
84
+ "ProcessingInstruction",
73
85
  "PruneEmpty",
74
86
  "QueryMatch",
75
87
  "SanitizationPolicy",
@@ -199,7 +199,16 @@ def decode_numeric_entity(
199
199
  The decoded character, or None if invalid
200
200
  """
201
201
  base = 16 if is_hex else 10
202
- codepoint = int(text, base)
202
+
203
+ # A numeric character reference this long is already guaranteed to exceed
204
+ # the valid Unicode range, so treat it as out-of-range without calling
205
+ # int() on an attacker-controlled digit string long enough to exceed
206
+ # Python's integer-string conversion limit (see sys.set_int_max_str_digits).
207
+ digits = text.lstrip("0") or "0"
208
+ if len(digits) > 16:
209
+ return "\ufffd" # REPLACEMENT CHARACTER
210
+
211
+ codepoint = int(digits, base)
203
212
 
204
213
  # Invalid ranges per HTML5 spec
205
214
  if codepoint > 0x10FFFF:
@@ -1,13 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from .constants import HTML_SPACE_OR_TAG_END_CHARACTERS
4
+ from .text import ascii_lower
4
5
 
5
6
 
6
7
  def neutralize_rawtext_end_tag_sequences(text: str, tag_name: str) -> tuple[str, bool]:
7
8
  if not text:
8
9
  return text, False
9
10
 
10
- lower_text = text.lower()
11
+ lower_text = ascii_lower(text)
11
12
  needle = f"</{tag_name}"
12
13
  needle_len = len(needle)
13
14
  out: list[str] = []
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ import string
4
+
5
+ _ASCII_LOWER_TABLE = str.maketrans(string.ascii_uppercase, string.ascii_lowercase)
6
+
7
+
8
+ def ascii_lower(text: str) -> str:
9
+ """Fold A-Z for ASCII marker searches that reuse original offsets.
10
+
11
+ Some parser and serializer paths search a folded copy of full text for
12
+ ASCII HTML markers such as ``</script``, ``<!doctype``, and ``<![cdata[``.
13
+ Match offsets are then reused against the original string, so the folded
14
+ copy must preserve length, and it must not invent ASCII marker characters
15
+ from non-ASCII text content. HTML markup matching is ASCII
16
+ case-insensitive; it is not Unicode case-insensitive.
17
+
18
+ ``str.lower()`` is still the fast path when it is provably equivalent for
19
+ these searches. In Python's Unicode data, U+0130 LATIN CAPITAL LETTER I
20
+ WITH DOT ABOVE is the only character whose lowercase mapping changes
21
+ length, and U+212A KELVIN SIGN is the only non-ASCII character whose
22
+ lowercase mapping becomes an ASCII letter (``k``). Inputs containing either
23
+ shape take the strict A-Z translation path. That path deliberately keeps
24
+ U+212A as U+212A; it is not a tag-name fold.
25
+ """
26
+ if text.isascii():
27
+ return text.lower()
28
+ if "\u0130" in text or "\u212a" in text:
29
+ return text.translate(_ASCII_LOWER_TABLE)
30
+ return text.lower()
@@ -172,7 +172,7 @@ class Node:
172
172
 
173
173
  if name.startswith("#") or name == "!doctype":
174
174
  self.namespace = namespace
175
- if name == "#comment" or name == "!doctype":
175
+ if name in {"#comment", "#processing-instruction"} or name == "!doctype":
176
176
  self.children = None
177
177
  self.attrs = None
178
178
  else:
@@ -491,6 +491,25 @@ class Comment(Node):
491
491
  return clone
492
492
 
493
493
 
494
+ class ProcessingInstruction(Node):
495
+ __slots__ = ()
496
+
497
+ def __init__(self, data: str | None = None) -> None:
498
+ super().__init__("#processing-instruction", data=data)
499
+
500
+ def clone_node(
501
+ self, deep: bool = False, override_attrs: dict[str, str | None] | None = None
502
+ ) -> ProcessingInstruction:
503
+ _ = override_attrs
504
+ _ = deep
505
+ clone = ProcessingInstruction(self.data if isinstance(self.data, str) else None)
506
+ clone._source_html = self._source_html
507
+ clone._origin_pos = self._origin_pos
508
+ clone._origin_line = self._origin_line
509
+ clone._origin_col = self._origin_col
510
+ return clone
511
+
512
+
494
513
  class Element(Node):
495
514
  __slots__ = (
496
515
  "_end_tag_end",
@@ -345,15 +345,16 @@ def _prescan_for_meta_charset(data: bytes) -> str | None:
345
345
 
346
346
 
347
347
  def sniff_html_encoding(data: bytes, transport_encoding: str | None = None) -> tuple[str, int]:
348
- # Transport overrides everything.
349
- transport = normalize_encoding_label(transport_encoding)
350
- if transport:
351
- return transport, 0
352
-
348
+ # Per the HTML encoding-sniffing algorithm, a byte-order mark takes
349
+ # priority over a transport-layer declared encoding.
353
350
  bom_enc, bom_len = _sniff_bom(data)
354
351
  if bom_enc:
355
352
  return bom_enc, bom_len
356
353
 
354
+ transport = normalize_encoding_label(transport_encoding)
355
+ if transport:
356
+ return transport, 0
357
+
357
358
  meta_enc = _prescan_for_meta_charset(data)
358
359
  if meta_enc:
359
360
  return meta_enc, 0