justhtml 3.4.0__tar.gz → 3.6.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 (157) hide show
  1. {justhtml-3.4.0 → justhtml-3.6.0}/.github/workflows/ci.yml +5 -7
  2. {justhtml-3.4.0 → justhtml-3.6.0}/.github/workflows/publish.yml +2 -2
  3. {justhtml-3.4.0 → justhtml-3.6.0}/.pre-commit-config.yaml +1 -1
  4. justhtml-3.6.0/AGENTS.md +10 -0
  5. {justhtml-3.4.0 → justhtml-3.6.0}/CHANGELOG.md +62 -0
  6. {justhtml-3.4.0 → justhtml-3.6.0}/CONTRIBUTING.md +40 -4
  7. justhtml-3.6.0/PERFORMANCE.md +84 -0
  8. {justhtml-3.4.0 → justhtml-3.6.0}/PKG-INFO +2 -2
  9. {justhtml-3.4.0 → justhtml-3.6.0}/SECURITY.md +11 -1
  10. {justhtml-3.4.0 → justhtml-3.6.0}/benchmarks/correctness.py +117 -2
  11. {justhtml-3.4.0 → justhtml-3.6.0}/benchmarks/performance.py +57 -146
  12. {justhtml-3.4.0 → justhtml-3.6.0}/docs/comparison.md +20 -16
  13. {justhtml-3.4.0 → justhtml-3.6.0}/docs/correctness.md +3 -17
  14. justhtml-3.6.0/project_ideas.md +47 -0
  15. {justhtml-3.4.0 → justhtml-3.6.0}/pyproject.toml +2 -2
  16. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/parser/engine.py +290 -75
  17. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/parser/scanner.py +21 -12
  18. {justhtml-3.4.0 → justhtml-3.6.0}/tests/justhtml-tests/character_preprocessing.dat +11 -0
  19. {justhtml-3.4.0 → justhtml-3.6.0}/tests/justhtml-tests/document_shell_recovery.dat +49 -0
  20. {justhtml-3.4.0 → justhtml-3.6.0}/tests/justhtml-tests/foreign_content.dat +154 -0
  21. {justhtml-3.4.0 → justhtml-3.6.0}/tests/justhtml-tests/frameset_recovery.dat +10 -0
  22. {justhtml-3.4.0 → justhtml-3.6.0}/tests/justhtml-tests/head_noscript_recovery.dat +12 -0
  23. justhtml-3.6.0/tests/justhtml-tests/in_body_recovery.dat +66 -0
  24. {justhtml-3.4.0 → justhtml-3.6.0}/tests/justhtml-tests/select_recovery.dat +57 -0
  25. {justhtml-3.4.0 → justhtml-3.6.0}/tests/justhtml-tests/table_recovery.dat +14 -0
  26. justhtml-3.6.0/tests/justhtml-tests/template_recovery.dat +392 -0
  27. {justhtml-3.4.0 → justhtml-3.6.0}/tests/test_parser_engine.py +81 -3
  28. {justhtml-3.4.0 → justhtml-3.6.0}/tests/test_sanitize.py +52 -0
  29. {justhtml-3.4.0 → justhtml-3.6.0}/tests/test_stream.py +26 -1
  30. justhtml-3.4.0/tests/justhtml-tests/in_body_recovery.dat +0 -12
  31. {justhtml-3.4.0 → justhtml-3.6.0}/.github/copilot-instructions.md +0 -0
  32. {justhtml-3.4.0 → justhtml-3.6.0}/.gitignore +0 -0
  33. {justhtml-3.4.0 → justhtml-3.6.0}/CODE_OF_CONDUCT.md +0 -0
  34. {justhtml-3.4.0 → justhtml-3.6.0}/LICENSE +0 -0
  35. {justhtml-3.4.0 → justhtml-3.6.0}/Makefile +0 -0
  36. {justhtml-3.4.0 → justhtml-3.6.0}/README.md +0 -0
  37. {justhtml-3.4.0 → justhtml-3.6.0}/assets/justhtml-readme-explainer.png +0 -0
  38. {justhtml-3.4.0 → justhtml-3.6.0}/benchmarks/chromium_parser_diff.py +0 -0
  39. {justhtml-3.4.0 → justhtml-3.6.0}/benchmarks/fuzz.py +0 -0
  40. {justhtml-3.4.0 → justhtml-3.6.0}/benchmarks/html5lib_engine_diff.py +0 -0
  41. {justhtml-3.4.0 → justhtml-3.6.0}/benchmarks/parser_engine_gate.py +0 -0
  42. {justhtml-3.4.0 → justhtml-3.6.0}/benchmarks/profile.py +0 -0
  43. {justhtml-3.4.0 → justhtml-3.6.0}/docs/_config.yml +0 -0
  44. {justhtml-3.4.0 → justhtml-3.6.0}/docs/_layouts/default.html +0 -0
  45. {justhtml-3.4.0 → justhtml-3.6.0}/docs/api.md +0 -0
  46. {justhtml-3.4.0 → justhtml-3.6.0}/docs/assets/search.js +0 -0
  47. {justhtml-3.4.0 → justhtml-3.6.0}/docs/bleach-migration.md +0 -0
  48. {justhtml-3.4.0 → justhtml-3.6.0}/docs/building.md +0 -0
  49. {justhtml-3.4.0 → justhtml-3.6.0}/docs/cli.md +0 -0
  50. {justhtml-3.4.0 → justhtml-3.6.0}/docs/encoding.md +0 -0
  51. {justhtml-3.4.0 → justhtml-3.6.0}/docs/errors.md +0 -0
  52. {justhtml-3.4.0 → justhtml-3.6.0}/docs/fragments.md +0 -0
  53. {justhtml-3.4.0 → justhtml-3.6.0}/docs/html-cleaning.md +0 -0
  54. {justhtml-3.4.0 → justhtml-3.6.0}/docs/index.md +0 -0
  55. {justhtml-3.4.0 → justhtml-3.6.0}/docs/linkify.md +0 -0
  56. {justhtml-3.4.0 → justhtml-3.6.0}/docs/migration-examples.md +0 -0
  57. {justhtml-3.4.0 → justhtml-3.6.0}/docs/playground/app.js +0 -0
  58. {justhtml-3.4.0 → justhtml-3.6.0}/docs/playground/index.html +0 -0
  59. {justhtml-3.4.0 → justhtml-3.6.0}/docs/playground/py/__init__.py +0 -0
  60. {justhtml-3.4.0 → justhtml-3.6.0}/docs/playground/py/install_latest_justhtml.py +0 -0
  61. {justhtml-3.4.0 → justhtml-3.6.0}/docs/playground/py/render.py +0 -0
  62. {justhtml-3.4.0 → justhtml-3.6.0}/docs/playground/py/use_local_repo.py +0 -0
  63. {justhtml-3.4.0 → justhtml-3.6.0}/docs/quickstart.md +0 -0
  64. {justhtml-3.4.0 → justhtml-3.6.0}/docs/sanitization.md +0 -0
  65. {justhtml-3.4.0 → justhtml-3.6.0}/docs/selectors.md +0 -0
  66. {justhtml-3.4.0 → justhtml-3.6.0}/docs/streaming.md +0 -0
  67. {justhtml-3.4.0 → justhtml-3.6.0}/docs/text.md +0 -0
  68. {justhtml-3.4.0 → justhtml-3.6.0}/docs/transforms.md +0 -0
  69. {justhtml-3.4.0 → justhtml-3.6.0}/docs/unsafe-handling.md +0 -0
  70. {justhtml-3.4.0 → justhtml-3.6.0}/docs/url-cleaning.md +0 -0
  71. {justhtml-3.4.0 → justhtml-3.6.0}/llms.txt +0 -0
  72. {justhtml-3.4.0 → justhtml-3.6.0}/run_tests.py +0 -0
  73. {justhtml-3.4.0 → justhtml-3.6.0}/scripts/release.py +0 -0
  74. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/__init__.py +0 -0
  75. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/__main__.py +0 -0
  76. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/core/__init__.py +0 -0
  77. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/core/constants.py +0 -0
  78. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/core/doctype.py +0 -0
  79. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/core/entities.py +0 -0
  80. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/core/errors.py +0 -0
  81. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/core/rawtext.py +0 -0
  82. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/core/text.py +0 -0
  83. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/core/types.py +0 -0
  84. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/dom/__init__.py +0 -0
  85. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/dom/builder.py +0 -0
  86. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/parser/__init__.py +0 -0
  87. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/parser/context.py +0 -0
  88. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/parser/encoding.py +0 -0
  89. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/parser/options.py +0 -0
  90. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/parser/stream.py +0 -0
  91. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/py.typed +0 -0
  92. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/sanitizer/__init__.py +0 -0
  93. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/sanitizer/css.py +0 -0
  94. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/sanitizer/dom.py +0 -0
  95. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/sanitizer/policy.py +0 -0
  96. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/sanitizer/policy_defaults.py +0 -0
  97. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/sanitizer/rawtext.py +0 -0
  98. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/sanitizer/url/__init__.py +0 -0
  99. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/sanitizer/url/policy.py +0 -0
  100. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/sanitizer/url/runtime.py +0 -0
  101. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/sanitizer/url/spec.py +0 -0
  102. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/selector/__init__.py +0 -0
  103. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/selector/core.py +0 -0
  104. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/serializer/__init__.py +0 -0
  105. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/serializer/html.py +0 -0
  106. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/serializer/markdown.py +0 -0
  107. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/transforms/__init__.py +0 -0
  108. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/transforms/compile.py +0 -0
  109. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/transforms/linkify.py +0 -0
  110. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/transforms/linkify_core.py +0 -0
  111. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/transforms/runtime.py +0 -0
  112. {justhtml-3.4.0 → justhtml-3.6.0}/src/justhtml/transforms/spec.py +0 -0
  113. {justhtml-3.4.0 → justhtml-3.6.0}/test-summary.txt +0 -0
  114. {justhtml-3.4.0 → justhtml-3.6.0}/tests/README.md +0 -0
  115. {justhtml-3.4.0 → justhtml-3.6.0}/tests/__init__.py +0 -0
  116. {justhtml-3.4.0 → justhtml-3.6.0}/tests/data/wikipedia.html +0 -0
  117. {justhtml-3.4.0 → justhtml-3.6.0}/tests/harness/__init__.py +0 -0
  118. {justhtml-3.4.0 → justhtml-3.6.0}/tests/harness/encoding.py +0 -0
  119. {justhtml-3.4.0 → justhtml-3.6.0}/tests/harness/regressions.py +0 -0
  120. {justhtml-3.4.0 → justhtml-3.6.0}/tests/harness/reporter.py +0 -0
  121. {justhtml-3.4.0 → justhtml-3.6.0}/tests/harness/serializer.py +0 -0
  122. {justhtml-3.4.0 → justhtml-3.6.0}/tests/harness/tree.py +0 -0
  123. {justhtml-3.4.0 → justhtml-3.6.0}/tests/justhtml-sanitize-tests/cases.json +0 -0
  124. {justhtml-3.4.0 → justhtml-3.6.0}/tests/justhtml-tests/entities.test +0 -0
  125. {justhtml-3.4.0 → justhtml-3.6.0}/tests/justhtml-tests/foreign_stack_recovery.dat +0 -0
  126. {justhtml-3.4.0 → justhtml-3.6.0}/tests/justhtml-tests/iframe_srcdoc.dat +0 -0
  127. {justhtml-3.4.0 → justhtml-3.6.0}/tests/justhtml-tests/selectedcontent.dat +0 -0
  128. {justhtml-3.4.0 → justhtml-3.6.0}/tests/justhtml-tests/tokenizer_state_transitions.test +0 -0
  129. {justhtml-3.4.0 → justhtml-3.6.0}/tests/justhtml-tests/tokenizer_states.test +0 -0
  130. {justhtml-3.4.0 → justhtml-3.6.0}/tests/justhtml-tests/xml_coercion.dat +0 -0
  131. {justhtml-3.4.0 → justhtml-3.6.0}/tests/justhtml-tests/xml_coercion_invalid_characters.test +0 -0
  132. {justhtml-3.4.0 → justhtml-3.6.0}/tests/linkify-it/LICENSE.txt +0 -0
  133. {justhtml-3.4.0 → justhtml-3.6.0}/tests/linkify-it/README.md +0 -0
  134. {justhtml-3.4.0 → justhtml-3.6.0}/tests/linkify-it/fixtures/links.txt +0 -0
  135. {justhtml-3.4.0 → justhtml-3.6.0}/tests/linkify-it/fixtures/not_links.txt +0 -0
  136. {justhtml-3.4.0 → justhtml-3.6.0}/tests/test_builder.py +0 -0
  137. {justhtml-3.4.0 → justhtml-3.6.0}/tests/test_cli.py +0 -0
  138. {justhtml-3.4.0 → justhtml-3.6.0}/tests/test_docs_examples.py +0 -0
  139. {justhtml-3.4.0 → justhtml-3.6.0}/tests/test_encoding.py +0 -0
  140. {justhtml-3.4.0 → justhtml-3.6.0}/tests/test_entities.py +0 -0
  141. {justhtml-3.4.0 → justhtml-3.6.0}/tests/test_errors.py +0 -0
  142. {justhtml-3.4.0 → justhtml-3.6.0}/tests/test_linkify_internals.py +0 -0
  143. {justhtml-3.4.0 → justhtml-3.6.0}/tests/test_linkify_it.py +0 -0
  144. {justhtml-3.4.0 → justhtml-3.6.0}/tests/test_linkify_transform.py +0 -0
  145. {justhtml-3.4.0 → justhtml-3.6.0}/tests/test_node.py +0 -0
  146. {justhtml-3.4.0 → justhtml-3.6.0}/tests/test_parser_regressions.py +0 -0
  147. {justhtml-3.4.0 → justhtml-3.6.0}/tests/test_parser_tree.py +0 -0
  148. {justhtml-3.4.0 → justhtml-3.6.0}/tests/test_playground_local_repo_file_list.py +0 -0
  149. {justhtml-3.4.0 → justhtml-3.6.0}/tests/test_sanitize_integration.py +0 -0
  150. {justhtml-3.4.0 → justhtml-3.6.0}/tests/test_sanitize_transform.py +0 -0
  151. {justhtml-3.4.0 → justhtml-3.6.0}/tests/test_selector.py +0 -0
  152. {justhtml-3.4.0 → justhtml-3.6.0}/tests/test_serialize.py +0 -0
  153. {justhtml-3.4.0 → justhtml-3.6.0}/tests/test_transform_runtime.py +0 -0
  154. {justhtml-3.4.0 → justhtml-3.6.0}/tests/test_transforms.py +0 -0
  155. {justhtml-3.4.0 → justhtml-3.6.0}/tests/test_transforms_compiler.py +0 -0
  156. {justhtml-3.4.0 → justhtml-3.6.0}/tests/test_transforms_sanitize_integration.py +0 -0
  157. {justhtml-3.4.0 → justhtml-3.6.0}/tests/test_wikipedia.py +0 -0
@@ -20,10 +20,10 @@ jobs:
20
20
  PYTHONHASHSEED: "0"
21
21
 
22
22
  steps:
23
- - uses: actions/checkout@v4
23
+ - uses: actions/checkout@v5
24
24
 
25
25
  - name: Set up Python ${{ matrix.python-version }}
26
- uses: actions/setup-python@v5
26
+ uses: actions/setup-python@v6
27
27
  with:
28
28
  python-version: ${{ matrix.python-version }}
29
29
  cache: "pip"
@@ -45,8 +45,6 @@ jobs:
45
45
  run: |
46
46
  cd ..
47
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
48
  git -C wpt sparse-checkout set html/syntax/parsing/resources
51
49
  git -C wpt rev-parse HEAD
52
50
 
@@ -84,15 +82,15 @@ jobs:
84
82
  test-pyodide:
85
83
  runs-on: ubuntu-latest
86
84
  steps:
87
- - uses: actions/checkout@v4
85
+ - uses: actions/checkout@v5
88
86
 
89
87
  - name: Set up Node.js
90
- uses: actions/setup-node@v4
88
+ uses: actions/setup-node@v5
91
89
  with:
92
90
  node-version: "20"
93
91
 
94
92
  - name: Set up Python
95
- uses: actions/setup-python@v5
93
+ uses: actions/setup-python@v6
96
94
  with:
97
95
  python-version: "3.12"
98
96
 
@@ -17,10 +17,10 @@ jobs:
17
17
  contents: read
18
18
  steps:
19
19
  - name: Checkout
20
- uses: actions/checkout@v4
20
+ uses: actions/checkout@v5
21
21
 
22
22
  - name: Set up Python
23
- uses: actions/setup-python@v5
23
+ uses: actions/setup-python@v6
24
24
  with:
25
25
  python-version: "3.x"
26
26
 
@@ -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 && coverage report'
36
+ entry: bash -c 'coverage run run_tests.py --no-write-summary && coverage report'
37
37
  language: system
38
38
  pass_filenames: false
39
39
  always_run: true
@@ -0,0 +1,10 @@
1
+ # Agent Contribution Guide
2
+
3
+ Read these files before making changes:
4
+
5
+ - [CONTRIBUTING.md](CONTRIBUTING.md) for development, tests, documentation
6
+ examples, changelog, and release expectations.
7
+ - [SECURITY.md](SECURITY.md) for the untrusted-HTML threat model and security
8
+ boundaries.
9
+ - [PERFORMANCE.md](PERFORMANCE.md) for parser-speed benchmarking and
10
+ optimization work.
@@ -7,6 +7,68 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.6.0] - 2026-07-13
11
+
12
+ ### Fixed
13
+ Note: The below are all minor parsing differences found when diffing against Chromium output, that is not covered in WPT's html5 parsing tests.
14
+
15
+ - Add a formatting element that breaks out of foreign content (such as `<i>` inside `<svg>`) to the active formatting list, so it is reconstructed later. Previously, the breakout element was inserted but not tracked, so `<b><svg><i></b>z` dropped the reconstructed `<i>` around the trailing text and `<svg><code><h2></code>` lost the reconstructed `<code>`.
16
+ - Start the body for a `<noscript>` start tag in the "after head" insertion mode instead of re-entering the head. The after-head metadata list does not include noscript, so markup such as `</head><noscript>` now places the element in the body.
17
+ - Hand an end tag to the HTML rules when the foreign-content end-tag walk reaches a matching HTML element, rather than popping the foreign elements above it. Previously, `<form><math></form>` popped the `<math>` along with the form instead of splicing only the form out of the stack.
18
+ - Clear back to the template contents before opening table structure in a template with no table element, so an open caption is closed. Previously, `<template><caption><tr>` nested the row inside the caption instead of making it a sibling.
19
+ - Switch the "in template" mode into the body for a non-head raw-text or RCDATA start tag (`iframe`, `noembed`, `xmp`, `textarea`), which are dispatched before the template-mode handler. Previously, a following table-structure tag such as the `<td>` in `<template><xmp></xmp><td>` created a spurious cell instead of being dropped.
20
+ - Ignore a `</td>` or `</th>` end tag in a template cell when the matching cell type is not in table scope. Previously, `<template><td></th>x` closed the `<td>` on the mismatched `</th>`, so the following text became a sibling instead of staying in the cell.
21
+ - Insert a non-breakout HTML-named start tag inside foreign content as a foreign element without running HTML in-body recovery. Foreign elements sharing a name with an HTML scope boundary or table foster target (an SVG or MathML `td`, `tr`, `caption`, ...) no longer stop scope walks or foster their children out. Previously, markup such as `<p><svg><article>`, `<p><math><td><listing>`, and `<table><math><tr><d>` broke out of or escaped the foreign subtree.
22
+ - Ignore `</body>` and `</html>` whenever a select is open, not only when the select holds just options. Previously, an element retained inside the select (as in `<select><o></body><!--x-->`) let the end tag switch to the after-body mode, moving following content out of the select.
23
+ - Close a raw-text or RCDATA element when its end tag name is terminated by `/` or a space even without a closing `>`. Previously, `<style></style/x` kept the unterminated end tag as text and left the element open.
24
+ - Do not reconstruct the active formatting elements for `rb`, `rp`, `rt`, or `rtc` start tags. Previously, `<div><tt></div><rt>` wrapped the `<rt>` in a stale `<tt>` instead of inserting it directly.
25
+ - Ignore `</form>` when the form element is not in scope, such as behind an open `<table>`. Previously, `<form><table></form><table>` removed the form from the stack, so the following table became a sibling of the form instead of nesting inside it.
26
+ - Respect special-element blocking for generic end tags inside a template. Previously, `<template><dialog><object></dialog>e` closed the dialog through the open `<object>` instead of ignoring the end tag, so following text escaped the object.
27
+ - Place a comment after an explicit `</head>` between the head and body even when the head contains a template. Previously, `<head><template></template></head><!--c-->` kept the comment inside the head.
28
+ - Process in-body `base`, `link`, and `meta` start tags with the in-head rules without reconstructing the active formatting elements. Previously, markup such as `<p><u><section><meta>` could wrongly wrap the metadata element in a stale formatting element instead of inserting it directly.
29
+ - Check the script-data-double-escape-start terminator against the real following character. Previously, a `<script` immediately followed by `</script>` inside a script (for example `<script><!--<script</script>`) entered the double-escaped state and swallowed the closing tag, leaving the script element unclosed.
30
+ - Pop foreign-content ancestors before applying the heading start-tag rule so a heading that breaks out of MathML or SVG closes an open heading. Previously, markup such as `<h1><math><h3>` nested the second heading inside the first instead of making it a sibling.
31
+ - Ignore `</html>` and `</body>` end tags in the "in select" insertion mode instead of switching to the after-body/after-html modes. Previously, markup such as `<select></html><!--c-->` moved trailing comments out of the still-open select to the document level.
32
+ - Restrict character foster parenting to HTML table elements. Previously, a foreign SVG or MathML element that merely shared a name with a table foster target (such as an SVG `<tr>`) had following text foster-parented out of the foreign subtree instead of kept as its child, as in `<table><svg><tr>n`.
33
+ - Force quirks mode when a doctype has trailing junk after its name without a PUBLIC or SYSTEM keyword. Previously, `<!DOCTYPE html foo>` was treated as no-quirks, so a following `<table>` incorrectly closed an open `<p>`.
34
+ - Ignore end tags other than `</template>` in the "in template" insertion mode. Previously, the `</br>` special case still ran, so `<template></br>` created a stray `<br>` inside the template contents.
35
+ - Let table-scoped end tags follow the foreign-content end-tag algorithm when the current node is a MathML or SVG element. Previously, an end tag such as `</th>` inside foreign content was routed to HTML table recovery, so `<svg><th><o></th>0` left the foreign cell open and misplaced the following text.
36
+ - Ignore `</body>` and `</html>` when no body element is in scope. Previously, an open scope marker such as `<marquee>`, `<object>`, or `<applet>` did not prevent the switch to the after-body mode, so following content such as a comment in `<marquee></body><!--x-->` was placed outside the still-open element.
37
+ - Close an escaped script comment on `<!-->`, whose `<!--` dashes already count toward the `-->`. Previously, `<script><!--><script></script>` treated the following `<script>` as a double-escape start and swallowed the closing tag, leaving the script element unclosed.
38
+ - Push active-formatting markers only for HTML scope-marker elements. A foreign element sharing a name such as an SVG or MathML `<td>` no longer pushes a marker that would linger after the element is popped and block later formatting reconstruction, as in `<p><a><math><td></td><listing>U`.
39
+
40
+ ## [3.5.0] - 2026-07-13
41
+
42
+ ### Changed
43
+ - Add contributor guidance for documentation examples, security and availability reviews, releases, and a new `PERFORMANCE.md` workflow for measuring and testing performance improvements.
44
+ - Add TurboHTML to the correctness benchmarks, and refresh the comparison guide with current Python-parser and browser-engine scores. Many dropped because of the new tests around processing instructions.
45
+ - Run CI against the latest WPT tree-construction fixtures rather than a pinned revision.
46
+
47
+ ### Fixed
48
+ Note: The below are all minor parsing differences found when diffing against Chromium output, that is not covered in WPT's html5 parsing tests.
49
+
50
+ - Ignore null-plus-whitespace character runs in the post-doctype document shell before creating the body. Previously, removing the null after classification could leave a spurious newline in `<body>`.
51
+ - Ignore late `<head>` start tags after body text even when the fast text path has not marked body mode explicitly. Previously, following metadata could be moved from the body back into the head.
52
+ - Limit doctype tokenization whitespace to the HTML ASCII whitespace set. Previously, non-ASCII spaces such as NBSP could be discarded instead of becoming part of the doctype name.
53
+ - Preserve the `frameset-ok` state across empty active-formatting elements. Previously, markup such as `<i><frameset>` could retain an empty body instead of replacing it with the frameset.
54
+ - Keep comments following implied head metadata in the head. Previously, `<meta><!--...-->` could place the comment before the document element.
55
+ - Exit column-group mode before reprocessing malformed end tags. Previously, `<table><colgroup></p>` could leave a paragraph inside the colgroup instead of foster-parenting it before the table.
56
+ - Keep foreign elements named `colgroup` out of HTML table recovery. Previously, text following SVG or MathML `<colgroup>` could become a sibling instead of its child.
57
+ - Keep form controls in template contents when a template appears inside `<select>`. Previously, an `<input>` could prematurely close the outer select.
58
+ - Keep initial-template recovery in the head insertion mode after `</template>`. Previously, following head whitespace could be discarded.
59
+ - Match the latest WPT processing-instruction tree expectations, including empty data fields and the `<?#` bogus-comment case.
60
+ - Close an open template `<colgroup>` before processing following script and style tags. Previously, those tags could be incorrectly inserted inside the column group.
61
+ - Recover table-body start tags correctly inside templates. Previously, a following `<colgroup>` could be discarded instead of closing the current table body.
62
+ - Close template table bodies before handling `</table>`. Previously, following content could remain incorrectly nested inside `<tbody>`.
63
+ - Close template rows before handling `</table>`. Previously, following nested templates could remain incorrectly nested inside `<tr>`.
64
+ - Ignore nested table starts in template table modes that have no open table. Previously, malformed input could create a spurious empty `<table>`.
65
+ - Resume template body recovery after `<title>`. Previously, invalid table-structure tags following a template title could create incorrect table nodes.
66
+ - Resume template body recovery after base and noframes head-content tags. Previously, invalid following table-structure tags could create incorrect table nodes.
67
+ - Apply phantom paragraph recovery inside templates. Previously, an unmatched `</p>` could be incorrectly ignored.
68
+ - Keep unmatched `</p>` ignored in the template initial insertion mode.
69
+ - Keep template table-body recovery scoped to an open table. Previously, a nested table token could close and duplicate synthetic table bodies.
70
+ - Preserve open template tables when closing a select. Previously, following option content could be placed outside the select.
71
+
10
72
  ## [3.4.0] - 2026-07-11
11
73
 
12
74
  ### Security
@@ -49,6 +49,28 @@ python -c 'from justhtml import JustHTML, to_test_format; print(to_test_format(J
49
49
 
50
50
  **Coverage is required to be 100%.** All new code must be fully tested.
51
51
 
52
+ ### Documentation examples
53
+
54
+ Runnable documentation examples use an explicit output marker. Place it between
55
+ a self-contained Python fence and the expected-output fence:
56
+
57
+ ````text
58
+ ```python
59
+ from justhtml import JustHTML
60
+ print(JustHTML("<p>x</p>", fragment=True).to_html())
61
+ ```
62
+
63
+ <!-- justhtml: output -->
64
+
65
+ ```html
66
+ <p>x</p>
67
+ ```
68
+ ````
69
+
70
+ The docs test executes each marked Python block independently and compares its
71
+ stdout with the following fence. Keep ordinary HTML fences unmarked so they
72
+ remain explanatory examples rather than tests.
73
+
52
74
  ## Pre-commit Hooks
53
75
 
54
76
  Pre-commit runs automatically on every commit and checks:
@@ -85,12 +107,25 @@ After making changes, verify performance impact:
85
107
 
86
108
  ```bash
87
109
  # Quick benchmark
88
- python benchmarks/performance.py --iterations 1 --parser justhtml --no-mem
110
+ python benchmarks/performance.py --parsers justhtml --iterations 1
89
111
 
90
112
  # Profile hotspots
91
113
  python benchmarks/profile.py
92
114
  ```
93
115
 
116
+ For a parser-speed improvement, measure the relevant pipeline on the web100k
117
+ corpus before and after the change; see [PERFORMANCE.md](PERFORMANCE.md). For
118
+ an availability fix, add a regression that exercises the affected hostile-input
119
+ shape and verify that growth is linear or otherwise bounded.
120
+
121
+ ## Releases
122
+
123
+ Use `python scripts/release.py --version X.Y.Z --yes` for releases. The script
124
+ runs release checks, bumps `pyproject.toml`, commits, tags, pushes, and creates
125
+ the GitHub release. Before invoking it, ensure the worktree is clean and move
126
+ the relevant Unreleased changelog entries into a dated `## [X.Y.Z]` section;
127
+ the helper requires that section to exist.
128
+
94
129
  ## Architecture Notes
95
130
 
96
131
  - **Parser engine** (`src/justhtml/parser/engine.py`): Plan-driven tokenization, tree construction, and sanitizer projection
@@ -109,8 +144,9 @@ Golden rules:
109
144
 
110
145
  1. Fork the repository
111
146
  2. Create a feature branch
112
- 3. Make your changes with tests
113
- 4. Ensure pre-commit passes
114
- 5. Submit a pull request
147
+ 3. Add an entry under the relevant section of `CHANGELOG.md`
148
+ 4. Make your changes with tests
149
+ 5. Ensure pre-commit passes
150
+ 6. Submit a pull request
115
151
 
116
152
  Questions? Open an issue on GitHub. For security vulnerabilities, please see our [Security Policy](SECURITY.md).
@@ -0,0 +1,84 @@
1
+ # Performance Guide
2
+
3
+ This guide is for improving JustHTML's parsing speed on real-world HTML while
4
+ preserving its HTML5 recovery and sanitization behavior.
5
+
6
+ ## Benchmark data and setup
7
+
8
+ The main benchmark uses the [web100k](https://github.com/EmilStenstrom/web100k)
9
+ corpus. By default it expects the dataset beside this repository:
10
+
11
+ ```text
12
+ ../web100k/
13
+ ├── html.dict
14
+ └── batches/
15
+ └── web100k-batch-001.tar.zst
16
+ ```
17
+
18
+ Set `WEB100K_DIR` to use another location, or pass `--batches-dir` and
19
+ `--dict` explicitly. Install the benchmark dependencies first:
20
+
21
+ ```bash
22
+ pip install -e ".[benchmark]"
23
+ ```
24
+
25
+ ## Measure the right pipeline
26
+
27
+ The benchmark has separate modes for parsing alone and parsing followed by
28
+ serialization. Start with the narrowest mode that covers the change:
29
+
30
+ ```bash
31
+ # Parser throughput on 100 real-world documents, without RSS sampling noise.
32
+ python benchmarks/performance.py --parsers justhtml --iterations 5
33
+
34
+ # Parse plus default HTML serialization.
35
+ python benchmarks/performance.py --parsers justhtml_to_html --iterations 5
36
+
37
+ # Compare selected installed parsers on a larger corpus sample.
38
+ python benchmarks/performance.py --parsers justhtml html5lib lxml --limit 1000 --iterations 3
39
+ ```
40
+
41
+ Use `--all-batches` for a corpus-wide measurement, `--batch PATH` for one
42
+ archive, or `--downloaded DIR` for a directory of decompressed-source files.
43
+ The benchmark reports total throughput and per-document timing. Compare the
44
+ same command before and after a change on the same machine; do not compare
45
+ absolute timings across machines.
46
+
47
+ ## Find the hot path
48
+
49
+ Profile a representative web100k batch before optimizing:
50
+
51
+ ```bash
52
+ python benchmarks/profile.py --mode parse
53
+ ```
54
+
55
+ Available modes are `parse`, `fragment`, `compact-html`, `pretty-html`, and
56
+ `text`. Use the mode closest to the public operation you are improving. Focus
57
+ on functions that dominate cumulative work across many documents, not an
58
+ isolated slow malformed sample.
59
+
60
+ ## Make a speed improvement
61
+
62
+ - Preserve parser and sanitizer semantics. Run the parser differential suite
63
+ before relying on a throughput result.
64
+ - Optimize work on the normal path: token scanning, tree construction,
65
+ attribute projection, and common serialization cases.
66
+ - Prefer direct local data access, reused compiled plans, and fewer temporary
67
+ allocations in per-character and per-node loops.
68
+ - Measure realistic HTML before and after the change. Include a focused
69
+ microbenchmark only when it explains the real-world result.
70
+ - Keep benchmarks honest: warm up once, run multiple iterations, and report
71
+ the exact command, corpus size, and pipeline mode in the pull request.
72
+
73
+ ## Validate and document
74
+
75
+ Add correctness tests for behavior affected by an optimization. Run the full
76
+ project gate before submitting:
77
+
78
+ ```bash
79
+ pre-commit run --all-files
80
+ ```
81
+
82
+ Update `CHANGELOG.md` with a `Performance` entry that states the affected
83
+ pipeline and the avoided work. If the change also prevents disproportionate
84
+ resource use from hostile input, add a `Security` entry as well.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: justhtml
3
- Version: 3.4.0
3
+ Version: 3.6.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
@@ -40,8 +40,8 @@ Requires-Dist: html5-parser; extra == 'benchmark'
40
40
  Requires-Dist: html5lib; extra == 'benchmark'
41
41
  Requires-Dist: lxml; extra == 'benchmark'
42
42
  Requires-Dist: markupever; extra == 'benchmark'
43
- Requires-Dist: psutil; extra == 'benchmark'
44
43
  Requires-Dist: selectolax>=0.4.8; extra == 'benchmark'
44
+ Requires-Dist: turbohtml>=1.3.1; extra == 'benchmark'
45
45
  Requires-Dist: zstandard; extra == 'benchmark'
46
46
  Provides-Extra: dev
47
47
  Requires-Dist: build; extra == 'dev'
@@ -26,6 +26,11 @@ bytes, and HTML-derived values supplied to parsing, sanitization, and
26
26
  serialization APIs. The promises below apply when callers use the documented
27
27
  safe defaults or a deliberately safe policy.
28
28
 
29
+ Availability is part of that boundary. Small hostile inputs must not trigger
30
+ disproportionate work or uncaught conversion errors while parsing, sanitizing,
31
+ transforming, or serializing. Applications still need their own limits for
32
+ total input size, execution time, and memory use.
33
+
29
34
  Library configuration is trusted application code. This includes custom
30
35
  sanitization policies, transform specifications and callbacks, selectors,
31
36
  glob patterns, CLI flags, and any policy or transform values assembled from
@@ -56,7 +61,8 @@ Promise:
56
61
  - JustHTML decodes bytes using HTML-style encoding detection and rejects UTF-7.
57
62
  - JustHTML parses malformed HTML into a DOM using browser-style HTML5 recovery.
58
63
  - The parser handles entity and character-reference edge cases.
59
- - The parser avoids known small-input patterns that can cause excessive work.
64
+ - The parser avoids known small-input patterns that can cause excessive work or
65
+ uncaught conversion errors.
60
66
  - JustHTML reports parse errors as data unless strict mode is enabled.
61
67
  - Fragment parsing and full-document parsing keep their different assumptions
62
68
  explicit.
@@ -140,6 +146,8 @@ Promise:
140
146
  - Serialization rejects unsafe element and attribute names created through
141
147
  custom DOM changes.
142
148
  - Serialization neutralizes rawtext end-tag sequences.
149
+ - Pretty serialization bounds indentation and avoids repeatedly copying nested
150
+ output, so deeply nested input does not cause disproportionate work.
143
151
  - `to_text()` returns text content rather than markup.
144
152
  - `to_markdown()` escapes Markdown-sensitive text by default.
145
153
  - `to_markdown()` escapes line-start Markdown markers that could change block
@@ -179,6 +187,8 @@ Promise:
179
187
  sanitization step after later transforms.
180
188
  - Built-in URL-aware transforms reuse sanitizer URL cleaning where they can.
181
189
  - Linkification operates on DOM text nodes rather than raw HTML strings.
190
+ - Built-in structural transforms avoid repeated sibling-list shifting for
191
+ common bulk operations on untrusted documents.
182
192
 
183
193
  Out of scope:
184
194
  - Safety of arbitrary user callbacks passed to transform APIs.
@@ -22,7 +22,17 @@ from justhtml.parser.context import FragmentContext
22
22
  from justhtml.serializer import to_test_format
23
23
 
24
24
  # Available parsers
25
- PARSERS = ["justhtml", "html5lib", "html5_parser", "lxml", "bs4", "html.parser", "selectolax", "markupever"]
25
+ PARSERS = [
26
+ "justhtml",
27
+ "html5lib",
28
+ "html5_parser",
29
+ "lxml",
30
+ "bs4",
31
+ "html.parser",
32
+ "selectolax",
33
+ "markupever",
34
+ "turbohtml",
35
+ ]
26
36
 
27
37
 
28
38
  class Status(Enum):
@@ -61,6 +71,7 @@ PARSER_CAPABILITIES = {
61
71
  "html.parser": ParserCapabilities(),
62
72
  "selectolax": ParserCapabilities(fragment_context=True, foreign_fragment_context=True),
63
73
  "markupever": ParserCapabilities(),
74
+ "turbohtml": ParserCapabilities(fragment_context=True, foreign_fragment_context=True),
64
75
  }
65
76
 
66
77
 
@@ -146,6 +157,15 @@ def check_parser_available(parser_name):
146
157
  return True
147
158
  except ImportError:
148
159
  return False
160
+ if parser_name == "turbohtml":
161
+ try:
162
+ from turbohtml import parse, parse_fragment
163
+
164
+ parse("<p></p>")
165
+ parse_fragment("<p></p>", "div")
166
+ return True
167
+ except (ImportError, TypeError, ValueError, AttributeError):
168
+ return False
149
169
  return False
150
170
 
151
171
 
@@ -174,6 +194,33 @@ def parse_dat_file(path):
174
194
  return tests
175
195
 
176
196
 
197
+ def decode_escapes(text):
198
+ """Decode the \"\\xXX\" and \"\\uXXXX\" sequences used by tree fixtures."""
199
+ if "\\x" not in text and "\\u" not in text:
200
+ return text
201
+
202
+ result = []
203
+ i = 0
204
+ while i < len(text):
205
+ if text[i : i + 2] == "\\x" and i + 3 < len(text):
206
+ try:
207
+ result.append(chr(int(text[i + 2 : i + 4], 16)))
208
+ i += 4
209
+ continue
210
+ except ValueError:
211
+ pass
212
+ elif text[i : i + 2] == "\\u" and i + 5 < len(text):
213
+ try:
214
+ result.append(chr(int(text[i + 2 : i + 6], 16)))
215
+ i += 6
216
+ continue
217
+ except ValueError:
218
+ pass
219
+ result.append(text[i])
220
+ i += 1
221
+ return "".join(result)
222
+
223
+
177
224
  def parse_single_test(lines):
178
225
  """Parse a single test from lines."""
179
226
  data = []
@@ -209,7 +256,7 @@ def parse_single_test(lines):
209
256
 
210
257
  if data or document:
211
258
  return {
212
- "data": "\n".join(data),
259
+ "data": decode_escapes("\n".join(data)),
213
260
  "document": "\n".join(document),
214
261
  "fragment_context": fragment_context,
215
262
  "script_directive": script_directive,
@@ -502,6 +549,23 @@ def run_test_markupever(html, fragment_context, expected, xml_coercion=False, if
502
549
  return _error(e)
503
550
 
504
551
 
552
+ def run_test_turbohtml(html, fragment_context, expected, xml_coercion=False, iframe_srcdoc=False):
553
+ """Run a single test with TurboHTML's public DOM API."""
554
+ from turbohtml import parse, parse_fragment
555
+
556
+ try:
557
+ if fragment_context:
558
+ namespace, tag_name = fragment_context
559
+ context = f"{namespace} {tag_name}" if namespace else tag_name
560
+ node = parse_fragment(html, context)
561
+ actual = _turbohtml_to_test_format(node.children, 0)
562
+ else:
563
+ actual = _turbohtml_to_test_format(parse(html).children, 0)
564
+ return _compare_result(expected, actual)
565
+ except Exception as e:
566
+ return _error(e)
567
+
568
+
505
569
  # =============================================================================
506
570
  # Test format conversion helpers
507
571
  # =============================================================================
@@ -839,6 +903,56 @@ def _markupever_to_test_format(nodes):
839
903
  return "".join(line for node in nodes for line in process(node, 0))
840
904
 
841
905
 
906
+ def _turbohtml_to_test_format(nodes, indent):
907
+ """Convert TurboHTML's public node objects to html5lib test format."""
908
+ from turbohtml import Comment, Doctype, Element, Namespace, Text
909
+
910
+ def qualified_name(namespace, name):
911
+ if namespace is Namespace.SVG:
912
+ return f"svg {name}"
913
+ if namespace is Namespace.MATHML:
914
+ return f"math {name}"
915
+ return name
916
+
917
+ def attribute_name(name):
918
+ if name.startswith("xlink:"):
919
+ return f"xlink {name.removeprefix('xlink:')}"
920
+ if name.startswith("xml:"):
921
+ return f"xml {name.removeprefix('xml:')}"
922
+ if name.startswith("xmlns:"):
923
+ return f"xmlns {name.removeprefix('xmlns:')}"
924
+ return name
925
+
926
+ def process(node, node_indent):
927
+ prefix = " " * node_indent
928
+ if isinstance(node, Doctype):
929
+ if node.public_id is not None or node.system_id is not None:
930
+ public_id = node.public_id or ""
931
+ system_id = node.system_id or ""
932
+ return [f'| <!DOCTYPE {node.name} "{public_id}" "{system_id}">']
933
+ return [f"| <!DOCTYPE {node.name}>"]
934
+ if isinstance(node, Comment):
935
+ return [f"| {prefix}<!-- {node.data} -->"]
936
+ if isinstance(node, Text):
937
+ return [f'| {prefix}"{node.text}"']
938
+ if not isinstance(node, Element):
939
+ return [line for child in node.children for line in process(child, node_indent)]
940
+
941
+ lines = [f"| {prefix}<{qualified_name(node.namespace, node.tag)}>"]
942
+ for name, value in sorted(node.attrs.items()):
943
+ lines.append(f'| {prefix} {attribute_name(name)}="{value}"')
944
+
945
+ if node.tag == "template" and node.namespace is Namespace.HTML:
946
+ lines.append(f"| {prefix} content")
947
+ content = node.children[0]
948
+ lines.extend(line for child in content.children for line in process(child, node_indent + 4))
949
+ else:
950
+ lines.extend(line for child in node.children for line in process(child, node_indent + 2))
951
+ return lines
952
+
953
+ return "\n".join(line for node in nodes for line in process(node, indent))
954
+
955
+
842
956
  # Parser dispatch
843
957
  PARSER_RUNNERS = {
844
958
  "justhtml": run_test_justhtml,
@@ -849,6 +963,7 @@ PARSER_RUNNERS = {
849
963
  "html.parser": run_test_html_parser,
850
964
  "selectolax": run_test_selectolax,
851
965
  "markupever": run_test_markupever,
966
+ "turbohtml": run_test_turbohtml,
852
967
  }
853
968
 
854
969