justhtml 3.1.0__tar.gz → 3.3.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.3.0}/.github/workflows/ci.yml +21 -10
  2. {justhtml-3.1.0 → justhtml-3.3.0}/.pre-commit-config.yaml +1 -1
  3. {justhtml-3.1.0 → justhtml-3.3.0}/CHANGELOG.md +33 -0
  4. {justhtml-3.1.0 → justhtml-3.3.0}/CONTRIBUTING.md +3 -3
  5. {justhtml-3.1.0 → justhtml-3.3.0}/PKG-INFO +4 -4
  6. {justhtml-3.1.0 → justhtml-3.3.0}/README.md +3 -3
  7. {justhtml-3.1.0 → justhtml-3.3.0}/docs/api.md +1 -1
  8. {justhtml-3.1.0 → justhtml-3.3.0}/docs/correctness.md +17 -14
  9. {justhtml-3.1.0 → justhtml-3.3.0}/docs/linkify.md +27 -0
  10. {justhtml-3.1.0 → justhtml-3.3.0}/docs/playground/app.js +1 -0
  11. {justhtml-3.1.0 → justhtml-3.3.0}/docs/sanitization.md +6 -4
  12. {justhtml-3.1.0 → justhtml-3.3.0}/docs/streaming.md +13 -15
  13. {justhtml-3.1.0 → justhtml-3.3.0}/pyproject.toml +1 -1
  14. {justhtml-3.1.0 → justhtml-3.3.0}/run_tests.py +13 -7
  15. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/__init__.py +13 -1
  16. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/core/entities.py +10 -1
  17. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/core/rawtext.py +2 -1
  18. justhtml-3.3.0/src/justhtml/core/text.py +30 -0
  19. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/dom/__init__.py +23 -4
  20. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/parser/encoding.py +6 -5
  21. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/parser/engine.py +212 -52
  22. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/parser/stream.py +46 -14
  23. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/sanitizer/dom.py +5 -1
  24. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/serializer/html.py +43 -1
  25. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/transforms/linkify_core.py +7 -1
  26. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/transforms/runtime.py +33 -4
  27. {justhtml-3.1.0 → justhtml-3.3.0}/test-summary.txt +18 -17
  28. justhtml-3.3.0/tests/README.md +44 -0
  29. {justhtml-3.1.0 → justhtml-3.3.0}/tests/test_encoding.py +19 -13
  30. {justhtml-3.1.0 → justhtml-3.3.0}/tests/test_entities.py +11 -0
  31. {justhtml-3.1.0 → justhtml-3.3.0}/tests/test_linkify_internals.py +20 -0
  32. {justhtml-3.1.0 → justhtml-3.3.0}/tests/test_node.py +40 -0
  33. {justhtml-3.1.0 → justhtml-3.3.0}/tests/test_parser_engine_coverage.py +42 -3
  34. {justhtml-3.1.0 → justhtml-3.3.0}/tests/test_parser_regressions.py +42 -0
  35. {justhtml-3.1.0 → justhtml-3.3.0}/tests/test_parser_tree.py +2 -4
  36. {justhtml-3.1.0 → justhtml-3.3.0}/tests/test_serialize.py +47 -1
  37. {justhtml-3.1.0 → justhtml-3.3.0}/tests/test_stream.py +62 -0
  38. {justhtml-3.1.0 → justhtml-3.3.0}/tests/test_transforms_edge_cases.py +74 -1
  39. justhtml-3.1.0/docs/parser-engine-findings.md +0 -469
  40. justhtml-3.1.0/tests/README.md +0 -40
  41. {justhtml-3.1.0 → justhtml-3.3.0}/.github/copilot-instructions.md +0 -0
  42. {justhtml-3.1.0 → justhtml-3.3.0}/.github/workflows/publish.yml +0 -0
  43. {justhtml-3.1.0 → justhtml-3.3.0}/.gitignore +0 -0
  44. {justhtml-3.1.0 → justhtml-3.3.0}/CODE_OF_CONDUCT.md +0 -0
  45. {justhtml-3.1.0 → justhtml-3.3.0}/LICENSE +0 -0
  46. {justhtml-3.1.0 → justhtml-3.3.0}/Makefile +0 -0
  47. {justhtml-3.1.0 → justhtml-3.3.0}/SECURITY.md +0 -0
  48. {justhtml-3.1.0 → justhtml-3.3.0}/assets/justhtml-readme-explainer.png +0 -0
  49. {justhtml-3.1.0 → justhtml-3.3.0}/benchmarks/chromium_parser_diff.py +0 -0
  50. {justhtml-3.1.0 → justhtml-3.3.0}/benchmarks/correctness.py +0 -0
  51. {justhtml-3.1.0 → justhtml-3.3.0}/benchmarks/fuzz.py +0 -0
  52. {justhtml-3.1.0 → justhtml-3.3.0}/benchmarks/html5lib_engine_diff.py +0 -0
  53. {justhtml-3.1.0 → justhtml-3.3.0}/benchmarks/parser_engine_gate.py +0 -0
  54. {justhtml-3.1.0 → justhtml-3.3.0}/benchmarks/performance.py +0 -0
  55. {justhtml-3.1.0 → justhtml-3.3.0}/benchmarks/profile.py +0 -0
  56. {justhtml-3.1.0 → justhtml-3.3.0}/docs/_config.yml +0 -0
  57. {justhtml-3.1.0 → justhtml-3.3.0}/docs/_layouts/default.html +0 -0
  58. {justhtml-3.1.0 → justhtml-3.3.0}/docs/assets/search.js +0 -0
  59. {justhtml-3.1.0 → justhtml-3.3.0}/docs/bleach-migration.md +0 -0
  60. {justhtml-3.1.0 → justhtml-3.3.0}/docs/building.md +0 -0
  61. {justhtml-3.1.0 → justhtml-3.3.0}/docs/cli.md +0 -0
  62. {justhtml-3.1.0 → justhtml-3.3.0}/docs/comparison.md +0 -0
  63. {justhtml-3.1.0 → justhtml-3.3.0}/docs/encoding.md +0 -0
  64. {justhtml-3.1.0 → justhtml-3.3.0}/docs/errors.md +0 -0
  65. {justhtml-3.1.0 → justhtml-3.3.0}/docs/fragments.md +0 -0
  66. {justhtml-3.1.0 → justhtml-3.3.0}/docs/html-cleaning.md +0 -0
  67. {justhtml-3.1.0 → justhtml-3.3.0}/docs/index.md +0 -0
  68. {justhtml-3.1.0 → justhtml-3.3.0}/docs/migration-examples.md +0 -0
  69. {justhtml-3.1.0 → justhtml-3.3.0}/docs/playground/index.html +0 -0
  70. {justhtml-3.1.0 → justhtml-3.3.0}/docs/playground/py/__init__.py +0 -0
  71. {justhtml-3.1.0 → justhtml-3.3.0}/docs/playground/py/install_latest_justhtml.py +0 -0
  72. {justhtml-3.1.0 → justhtml-3.3.0}/docs/playground/py/render.py +0 -0
  73. {justhtml-3.1.0 → justhtml-3.3.0}/docs/playground/py/use_local_repo.py +0 -0
  74. {justhtml-3.1.0 → justhtml-3.3.0}/docs/quickstart.md +0 -0
  75. {justhtml-3.1.0 → justhtml-3.3.0}/docs/selectors.md +0 -0
  76. {justhtml-3.1.0 → justhtml-3.3.0}/docs/text.md +0 -0
  77. {justhtml-3.1.0 → justhtml-3.3.0}/docs/transforms.md +0 -0
  78. {justhtml-3.1.0 → justhtml-3.3.0}/docs/unsafe-handling.md +0 -0
  79. {justhtml-3.1.0 → justhtml-3.3.0}/docs/url-cleaning.md +0 -0
  80. {justhtml-3.1.0 → justhtml-3.3.0}/llms.txt +0 -0
  81. {justhtml-3.1.0 → justhtml-3.3.0}/scripts/release.py +0 -0
  82. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/__main__.py +0 -0
  83. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/core/__init__.py +0 -0
  84. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/core/constants.py +0 -0
  85. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/core/doctype.py +0 -0
  86. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/core/errors.py +0 -0
  87. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/core/types.py +0 -0
  88. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/dom/builder.py +0 -0
  89. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/parser/__init__.py +0 -0
  90. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/parser/context.py +0 -0
  91. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/parser/options.py +0 -0
  92. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/parser/scanner.py +0 -0
  93. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/py.typed +0 -0
  94. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/sanitizer/__init__.py +0 -0
  95. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/sanitizer/css.py +0 -0
  96. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/sanitizer/policy.py +0 -0
  97. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/sanitizer/policy_defaults.py +0 -0
  98. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/sanitizer/rawtext.py +0 -0
  99. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/sanitizer/url/__init__.py +0 -0
  100. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/sanitizer/url/policy.py +0 -0
  101. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/sanitizer/url/runtime.py +0 -0
  102. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/sanitizer/url/spec.py +0 -0
  103. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/selector/__init__.py +0 -0
  104. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/selector/core.py +0 -0
  105. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/serializer/__init__.py +0 -0
  106. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/serializer/markdown.py +0 -0
  107. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/transforms/__init__.py +0 -0
  108. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/transforms/compile.py +0 -0
  109. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/transforms/linkify.py +0 -0
  110. {justhtml-3.1.0 → justhtml-3.3.0}/src/justhtml/transforms/spec.py +0 -0
  111. {justhtml-3.1.0 → justhtml-3.3.0}/tests/__init__.py +0 -0
  112. {justhtml-3.1.0 → justhtml-3.3.0}/tests/data/wikipedia.html +0 -0
  113. {justhtml-3.1.0 → justhtml-3.3.0}/tests/harness/__init__.py +0 -0
  114. {justhtml-3.1.0 → justhtml-3.3.0}/tests/harness/encoding.py +0 -0
  115. {justhtml-3.1.0 → justhtml-3.3.0}/tests/harness/regressions.py +0 -0
  116. {justhtml-3.1.0 → justhtml-3.3.0}/tests/harness/reporter.py +0 -0
  117. {justhtml-3.1.0 → justhtml-3.3.0}/tests/harness/serializer.py +0 -0
  118. {justhtml-3.1.0 → justhtml-3.3.0}/tests/harness/tree.py +0 -0
  119. {justhtml-3.1.0 → justhtml-3.3.0}/tests/justhtml-sanitize-tests/cases.json +0 -0
  120. {justhtml-3.1.0 → justhtml-3.3.0}/tests/justhtml-tests/branch_coverage.dat +0 -0
  121. {justhtml-3.1.0 → justhtml-3.3.0}/tests/justhtml-tests/coverage_gaps.test +0 -0
  122. {justhtml-3.1.0 → justhtml-3.3.0}/tests/justhtml-tests/empty_stack_edge_cases.dat +0 -0
  123. {justhtml-3.1.0 → justhtml-3.3.0}/tests/justhtml-tests/entities.test +0 -0
  124. {justhtml-3.1.0 → justhtml-3.3.0}/tests/justhtml-tests/iframe_srcdoc.dat +0 -0
  125. {justhtml-3.1.0 → justhtml-3.3.0}/tests/justhtml-tests/tokenizer_edge_cases.test +0 -0
  126. {justhtml-3.1.0 → justhtml-3.3.0}/tests/justhtml-tests/treebuilder_coverage.dat +0 -0
  127. {justhtml-3.1.0 → justhtml-3.3.0}/tests/justhtml-tests/xml_coercion.dat +0 -0
  128. {justhtml-3.1.0 → justhtml-3.3.0}/tests/justhtml-tests/xml_coercion_coverage.test +0 -0
  129. {justhtml-3.1.0 → justhtml-3.3.0}/tests/linkify-it/LICENSE.txt +0 -0
  130. {justhtml-3.1.0 → justhtml-3.3.0}/tests/linkify-it/README.md +0 -0
  131. {justhtml-3.1.0 → justhtml-3.3.0}/tests/linkify-it/fixtures/links.txt +0 -0
  132. {justhtml-3.1.0 → justhtml-3.3.0}/tests/linkify-it/fixtures/not_links.txt +0 -0
  133. {justhtml-3.1.0 → justhtml-3.3.0}/tests/test_builder.py +0 -0
  134. {justhtml-3.1.0 → justhtml-3.3.0}/tests/test_cli.py +0 -0
  135. {justhtml-3.1.0 → justhtml-3.3.0}/tests/test_docs_examples.py +0 -0
  136. {justhtml-3.1.0 → justhtml-3.3.0}/tests/test_errors.py +0 -0
  137. {justhtml-3.1.0 → justhtml-3.3.0}/tests/test_linkify_it.py +0 -0
  138. {justhtml-3.1.0 → justhtml-3.3.0}/tests/test_linkify_transform.py +0 -0
  139. {justhtml-3.1.0 → justhtml-3.3.0}/tests/test_playground_local_repo_file_list.py +0 -0
  140. {justhtml-3.1.0 → justhtml-3.3.0}/tests/test_sanitize.py +0 -0
  141. {justhtml-3.1.0 → justhtml-3.3.0}/tests/test_sanitize_integration.py +0 -0
  142. {justhtml-3.1.0 → justhtml-3.3.0}/tests/test_sanitize_transform.py +0 -0
  143. {justhtml-3.1.0 → justhtml-3.3.0}/tests/test_selector.py +0 -0
  144. {justhtml-3.1.0 → justhtml-3.3.0}/tests/test_transforms.py +0 -0
  145. {justhtml-3.1.0 → justhtml-3.3.0}/tests/test_transforms_compiler.py +0 -0
  146. {justhtml-3.1.0 → justhtml-3.3.0}/tests/test_transforms_sanitize_integration.py +0 -0
  147. {justhtml-3.1.0 → justhtml-3.3.0}/tests/test_wikipedia.py +0 -0
@@ -36,20 +36,31 @@ 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: Checkout WPT tree-construction tests
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
+ cd ..
47
+ git clone --filter=blob:none --sparse https://github.com/web-platform-tests/wpt.git
48
+ git -C wpt fetch origin 75b089d70448e1da425ab8cf38971b6af41f3cea
49
+ git -C wpt checkout 75b089d70448e1da425ab8cf38971b6af41f3cea
50
+ git -C wpt sparse-checkout set html/syntax/parsing/resources
51
+ git -C wpt rev-parse HEAD
52
+
53
+ - name: Setup test fixtures
54
+ run: |
55
+ cp -a ../html5lib-tests/tokenizer tests/html5lib-tests-tokenizer
56
+ cp -a ../wpt/html/syntax/parsing/resources tests/html5lib-tests-tree
57
+ cp -a ../html5lib-tests/serializer tests/html5lib-tests-serializer
58
+ cp -a ../html5lib-tests/encoding tests/html5lib-tests-encoding
49
59
 
50
60
  - name: Run pre-commit
51
61
  id: precommit
52
- uses: pre-commit/action@v3.0.1
62
+ if: matrix.python-version == '3.14'
63
+ run: pre-commit run --show-diff-on-failure --color=always --all-files
53
64
  env:
54
65
  SKIP: mypy
55
66
 
@@ -60,14 +71,14 @@ jobs:
60
71
  - name: Diagnose test failures (verbose)
61
72
  if: always()
62
73
  run: |
63
- if [ "${{ steps.precommit.outcome }}" = "success" ] && [ "${{ steps.run_tests.outcome }}" = "success" ]; then
74
+ if [ "${{ steps.precommit.outcome }}" != "failure" ] && [ "${{ steps.run_tests.outcome }}" = "success" ]; then
64
75
  echo "No failures detected; skipping verbose rerun."
65
76
  exit 0
66
77
  fi
67
78
  python run_tests.py -v --no-write-summary
68
79
 
69
80
  - name: Run mypy
70
- if: matrix.python-version == '3.12'
81
+ if: matrix.python-version == '3.14'
71
82
  run: mypy
72
83
 
73
84
  test-pyodide:
@@ -33,7 +33,7 @@ repos:
33
33
  hooks:
34
34
  - id: tests-coverage
35
35
  name: Tests & Coverage
36
- entry: bash -c 'coverage run run_tests.py --no-write-summary && coverage report'
36
+ entry: bash -c 'coverage run run_tests.py && coverage report'
37
37
  language: system
38
38
  pass_filenames: false
39
39
  always_run: true
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.3.0] - 2026-07-04
11
+
12
+ ### Changed
13
+ - Document that `Linkify(...)` must run before an explicit `Sanitize(...)` in a transform list, not after, and correct the `clean_url_value()` example to escape the returned value with `escape_attr_value()` before embedding it in HTML. Previously, the docs suggested orderings that silently skip URL-policy validation on Linkify-generated links, and separately claimed `clean_url_value()` output needed no further escaping for HTML attribute embedding.
14
+
15
+ ### Fixed
16
+ - Copy `override_attrs` when cloning a node instead of storing the caller's dict by reference. Previously, calling `clone_node(override_attrs=...)` more than once with the same dict (or holding onto that dict after cloning) produced clones that aliased the same attribute dictionary, so mutating one clone's attributes silently mutated another clone or the caller's own dict.
17
+
18
+ ### Performance
19
+ - Avoid rescanning the entire open-elements stack for every unmatched end tag inside foreign (SVG/MathML) content in the streaming parser (`stream()`), by tracking open-element name counts the same way the main parser engine does. Previously, a run of unmatched end tags deep inside foreign content was quadratic in the depth of nesting.
20
+ - Bound `Linkify(...)`'s email-candidate pattern to RFC 5321's 64-octet local-part limit instead of an unbounded match. Previously, the greedy local-part match followed by a required (and often absent) `@` forced a full-length backtrack at every candidate start position, making long "@"-free runs of otherwise email-local-part-legal text (such as repeated dotted-numeric tokens) quadratic to scan.
21
+ - Share cached sibling/ancestor lookups across nodes when matching structural selectors (`:nth-child`, sibling combinators) in custom `EditAttrs(...)`/`SetAttrs(...)`-style transform pipelines, the same way `query()` already does. Previously, each node rebuilt this data from scratch, making structural-selector transforms quadratic over documents with many siblings. Sharing is only enabled when every transform in the pipeline is provably attribute-only; a pipeline that also uses `Edit(...)`, `Decide(...)`, `Drop(...)`, `Linkify(...)`, or similar structure-changing transforms is unaffected and keeps its previous (already correct) behavior.
22
+ - Access the parser's open-elements stack through a plain attribute again instead of a property, removing a Python-level function call from one of the hottest paths in tree construction.
23
+
24
+ ### Security
25
+ - (Severity: Low) HTML-escape a literal `&` left over after percent-encoding a URL for the `HTMLContext.URL` serialization context (used for `href`/`src`-style attributes). Previously, a URL containing a legacy (no-semicolon) named HTML entity immediately after a decoded `&` could be interpreted differently once embedded in an HTML attribute and re-parsed by a browser than what was validated.
26
+
27
+ ## [3.2.0] - 2026-07-04
28
+
29
+ ### Changed
30
+ - Use the web platform html5 treebuilder tests from WPT for tree-construction compliance fixtures.
31
+
32
+ ### Fixed
33
+ - 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.
34
+ - 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.
35
+
36
+ ### Performance
37
+ - 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.
38
+
39
+ ### Security
40
+ - (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.
41
+ - (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.
42
+
10
43
  ## [3.1.0] - 2026-07-02
11
44
 
12
45
  ### 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.3.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
@@ -602,7 +602,7 @@ To override the encoding for byte input, pass `encoding=...`.
602
602
  | `"end"` | `tag_name` | Closing tag |
603
603
  | `"text"` | `text_content` | Text content |
604
604
  | `"comment"` | `comment_text` | HTML comment |
605
- | `"doctype"` | `doctype_name` | DOCTYPE declaration |
605
+ | `"doctype"` | `(name, public_id, system_id)` | DOCTYPE declaration fields |
606
606
 
607
607
  ---
608
608
 
@@ -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 ..
@@ -125,6 +125,33 @@ This matters for Linkify because sanitization policies can remove or rewrite att
125
125
  - Schemes not allowed for `a[href]` are stripped (the `<a>` remains, but `href` is removed).
126
126
  - Protocol-relative `//example.com` is resolved according to policy (default: `https://example.com`).
127
127
 
128
+ **Put `Linkify()` before `Sanitize()`, not after.** It is tempting to reach for `transforms=[Sanitize(policy), Linkify()]`, reasoning "sanitize first, then add links to the clean text." That order does the opposite of what it looks like: your explicit `Sanitize()` position becomes the sanitize point (see above), so the `<a href="...">` elements Linkify creates afterward are never checked against your `UrlPolicy`/`UrlRule` restrictions.
129
+
130
+ ```python
131
+ from justhtml import JustHTML, Linkify, Sanitize, SanitizationPolicy, UrlPolicy, UrlRule
132
+
133
+ # A policy that only allows https:// links on <a href>.
134
+ policy = SanitizationPolicy(
135
+ allowed_tags=["a"],
136
+ allowed_attributes={"a": ["href"]},
137
+ url_policy=UrlPolicy(allow_rules={("a", "href"): UrlRule(allowed_schemes={"https"})}),
138
+ )
139
+ text = "See http://example.com"
140
+
141
+ # Wrong: Linkify's generated <a href> is created after the sanitize point,
142
+ # so the https-only restriction never sees it.
143
+ doc = JustHTML(text, transforms=[Sanitize(policy), Linkify()])
144
+ print(doc.to_html(pretty=False))
145
+ # => See <a href="http://example.com">http://example.com</a>
146
+
147
+ # Right: Linkify runs first, so its output is covered by the same sanitize pass.
148
+ doc = JustHTML(text, transforms=[Linkify(), Sanitize(policy)])
149
+ print(doc.to_html(pretty=False))
150
+ # => See http://example.com
151
+ ```
152
+
153
+ If you don't need other transforms between them, the simplest safe option is to omit `Sanitize()` entirely and let `sanitize=True` (the default) append it automatically after `Linkify()`.
154
+
128
155
  If you want Linkify output without any sanitization changes (trusted input only), use `sanitize=False` and avoid adding `Sanitize(...)` in transforms.
129
156
 
130
157
  ## Provenance
@@ -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",
@@ -124,10 +124,12 @@ rule = UrlRule(allowed_schemes={"https", "mailto"})
124
124
  cleaned_url = JustHTML.clean_url_value(value=user_url, url_rule=rule)
125
125
 
126
126
  if cleaned_url:
127
- # URL is safe to use.
128
- # Note: clean_url_value performs URL encoding, so no extra escape needed if using double quotes in HTML.
129
- # <a href="${cleaned_url}">
130
- pass
127
+ # cleaned_url is a validated, percent-encoded URL, not an HTML-attribute-safe
128
+ # string: it deliberately keeps "&" literal so multi-parameter query strings
129
+ # stay usable as plain text (e.g. in a JS string). Escape it like any other
130
+ # attribute value before embedding it in HTML.
131
+ safe_url = JustHTML.escape_attr_value(cleaned_url)
132
+ # <a href="${safe_url}">
131
133
  ```
132
134
 
133
135
  ## Configuration
@@ -15,7 +15,6 @@ The streaming parser is:
15
15
 
16
16
  ```python
17
17
  from justhtml import stream
18
- from pathlib import Path
19
18
 
20
19
  html = "<html><body><p>Hello, world!</p></body></html>"
21
20
 
@@ -23,12 +22,24 @@ for event, data in stream(html):
23
22
  print(event, data)
24
23
  ```
25
24
 
25
+ Output:
26
+ ```text
27
+ start ('html', {})
28
+ start ('body', {})
29
+ start ('p', {})
30
+ text Hello, world!
31
+ end p
32
+ end body
33
+ end html
34
+ ```
35
+
26
36
  ## Byte Input and Encodings
27
37
 
28
38
  `stream()` also accepts bytes (`bytes`, `bytearray`, `memoryview`). In that case, the input is decoded using HTML encoding sniffing (including a `windows-1252` fallback for legacy documents).
29
39
 
30
40
  ```python
31
41
  from justhtml import stream
42
+ from pathlib import Path
32
43
 
33
44
  data = Path("page.html").read_bytes()
34
45
  for event, data in stream(data):
@@ -48,19 +59,6 @@ for event, data in stream(data, encoding="utf-8"):
48
59
 
49
60
  See [Encoding & Byte Input](encoding.md) for details.
50
61
 
51
- Output:
52
- ```
53
- start ('html', {})
54
- start ('head', {})
55
- end head
56
- start ('body', {})
57
- start ('p', {})
58
- text Hello, world!
59
- end p
60
- end body
61
- end html
62
- ```
63
-
64
62
  ## Events
65
63
 
66
64
  | Event | Data Type | Description |
@@ -69,7 +67,7 @@ end html
69
67
  | `"end"` | `tag_name` | Closing tag encountered |
70
68
  | `"text"` | `str` | Text content |
71
69
  | `"comment"` | `str` | HTML comment content |
72
- | `"doctype"` | `str` | DOCTYPE name (usually `"html"`) |
70
+ | `"doctype"` | `(name, public_id, system_id)` | DOCTYPE declaration fields |
73
71
 
74
72
  ## Examples
75
73
 
@@ -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.3.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:
@@ -422,7 +422,7 @@ class Node:
422
422
  Returns:
423
423
  A new node that is a copy of this node.
424
424
  """
425
- attrs = override_attrs if override_attrs is not None else (self.attrs.copy() if self.attrs else None)
425
+ attrs = override_attrs.copy() if override_attrs is not None else (self.attrs.copy() if self.attrs else None)
426
426
  clone = Node(
427
427
  self.name,
428
428
  attrs,
@@ -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",
@@ -532,7 +551,7 @@ class Element(Node):
532
551
  self._self_closing = False
533
552
 
534
553
  def clone_node(self, deep: bool = False, override_attrs: dict[str, str | None] | None = None) -> Element:
535
- attrs = override_attrs if override_attrs is not None else (self.attrs.copy() if self.attrs else {})
554
+ attrs = override_attrs.copy() if override_attrs is not None else (self.attrs.copy() if self.attrs else {})
536
555
  clone = Element(self.name, attrs, self.namespace)
537
556
  clone._source_html = self._source_html
538
557
  clone._origin_pos = self._origin_pos
@@ -567,7 +586,7 @@ class Template(Element):
567
586
  self.template_content = None
568
587
 
569
588
  def clone_node(self, deep: bool = False, override_attrs: dict[str, str | None] | None = None) -> Template:
570
- attrs = override_attrs if override_attrs is not None else (self.attrs.copy() if self.attrs else {})
589
+ attrs = override_attrs.copy() if override_attrs is not None else (self.attrs.copy() if self.attrs else {})
571
590
  clone = Template(
572
591
  self.name,
573
592
  attrs,
@@ -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