justhtml 3.3.0__tar.gz → 3.5.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.3.0 → justhtml-3.5.0}/.github/workflows/ci.yml +5 -7
  2. {justhtml-3.3.0 → justhtml-3.5.0}/.github/workflows/publish.yml +2 -2
  3. {justhtml-3.3.0 → justhtml-3.5.0}/.pre-commit-config.yaml +1 -1
  4. justhtml-3.5.0/AGENTS.md +10 -0
  5. {justhtml-3.3.0 → justhtml-3.5.0}/CHANGELOG.md +42 -0
  6. {justhtml-3.3.0 → justhtml-3.5.0}/CONTRIBUTING.md +40 -4
  7. justhtml-3.5.0/PERFORMANCE.md +84 -0
  8. {justhtml-3.3.0 → justhtml-3.5.0}/PKG-INFO +2 -2
  9. {justhtml-3.3.0 → justhtml-3.5.0}/SECURITY.md +25 -1
  10. {justhtml-3.3.0 → justhtml-3.5.0}/benchmarks/correctness.py +117 -2
  11. {justhtml-3.3.0 → justhtml-3.5.0}/benchmarks/performance.py +57 -146
  12. {justhtml-3.3.0 → justhtml-3.5.0}/docs/bleach-migration.md +44 -52
  13. {justhtml-3.3.0 → justhtml-3.5.0}/docs/cli.md +1 -1
  14. {justhtml-3.3.0 → justhtml-3.5.0}/docs/comparison.md +20 -16
  15. {justhtml-3.3.0 → justhtml-3.5.0}/docs/correctness.md +5 -19
  16. {justhtml-3.3.0 → justhtml-3.5.0}/docs/errors.md +5 -0
  17. {justhtml-3.3.0 → justhtml-3.5.0}/docs/fragments.md +5 -5
  18. {justhtml-3.3.0 → justhtml-3.5.0}/docs/html-cleaning.md +66 -96
  19. {justhtml-3.3.0 → justhtml-3.5.0}/docs/quickstart.md +3 -3
  20. {justhtml-3.3.0 → justhtml-3.5.0}/docs/streaming.md +1 -1
  21. {justhtml-3.3.0 → justhtml-3.5.0}/docs/text.md +1 -1
  22. {justhtml-3.3.0 → justhtml-3.5.0}/docs/transforms.md +5 -5
  23. {justhtml-3.3.0 → justhtml-3.5.0}/docs/unsafe-handling.md +2 -2
  24. {justhtml-3.3.0 → justhtml-3.5.0}/docs/url-cleaning.md +7 -7
  25. {justhtml-3.3.0 → justhtml-3.5.0}/pyproject.toml +2 -2
  26. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/core/entities.py +13 -6
  27. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/parser/__init__.py +44 -17
  28. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/parser/engine.py +223 -68
  29. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/sanitizer/policy.py +0 -8
  30. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/sanitizer/url/runtime.py +12 -6
  31. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/serializer/html.py +63 -18
  32. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/transforms/__init__.py +1 -0
  33. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/transforms/compile.py +3 -20
  34. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/transforms/linkify_core.py +3 -1
  35. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/transforms/runtime.py +31 -0
  36. {justhtml-3.3.0 → justhtml-3.5.0}/test-summary.txt +18 -11
  37. justhtml-3.5.0/tests/justhtml-tests/character_preprocessing.dat +110 -0
  38. justhtml-3.5.0/tests/justhtml-tests/document_shell_recovery.dat +93 -0
  39. justhtml-3.5.0/tests/justhtml-tests/foreign_content.dat +135 -0
  40. justhtml-3.5.0/tests/justhtml-tests/frameset_recovery.dat +60 -0
  41. justhtml-3.5.0/tests/justhtml-tests/head_noscript_recovery.dat +25 -0
  42. justhtml-3.5.0/tests/justhtml-tests/in_body_recovery.dat +12 -0
  43. justhtml-3.5.0/tests/justhtml-tests/select_recovery.dat +152 -0
  44. justhtml-3.5.0/tests/justhtml-tests/selectedcontent.dat +76 -0
  45. justhtml-3.5.0/tests/justhtml-tests/table_recovery.dat +88 -0
  46. justhtml-3.5.0/tests/justhtml-tests/template_recovery.dat +294 -0
  47. justhtml-3.3.0/tests/justhtml-tests/tokenizer_edge_cases.test → justhtml-3.5.0/tests/justhtml-tests/tokenizer_states.test +1 -1
  48. {justhtml-3.3.0 → justhtml-3.5.0}/tests/test_docs_examples.py +1 -1
  49. {justhtml-3.3.0 → justhtml-3.5.0}/tests/test_entities.py +5 -0
  50. {justhtml-3.3.0 → justhtml-3.5.0}/tests/test_errors.py +28 -1
  51. justhtml-3.3.0/tests/test_parser_engine_coverage.py → justhtml-3.5.0/tests/test_parser_engine.py +133 -8
  52. {justhtml-3.3.0 → justhtml-3.5.0}/tests/test_sanitize.py +72 -0
  53. {justhtml-3.3.0 → justhtml-3.5.0}/tests/test_serialize.py +1 -0
  54. justhtml-3.3.0/tests/test_transforms_edge_cases.py → justhtml-3.5.0/tests/test_transform_runtime.py +30 -45
  55. {justhtml-3.3.0 → justhtml-3.5.0}/tests/test_transforms.py +40 -0
  56. {justhtml-3.3.0 → justhtml-3.5.0}/tests/test_transforms_sanitize_integration.py +16 -0
  57. justhtml-3.3.0/tests/justhtml-tests/branch_coverage.dat +0 -450
  58. justhtml-3.3.0/tests/justhtml-tests/treebuilder_coverage.dat +0 -254
  59. {justhtml-3.3.0 → justhtml-3.5.0}/.github/copilot-instructions.md +0 -0
  60. {justhtml-3.3.0 → justhtml-3.5.0}/.gitignore +0 -0
  61. {justhtml-3.3.0 → justhtml-3.5.0}/CODE_OF_CONDUCT.md +0 -0
  62. {justhtml-3.3.0 → justhtml-3.5.0}/LICENSE +0 -0
  63. {justhtml-3.3.0 → justhtml-3.5.0}/Makefile +0 -0
  64. {justhtml-3.3.0 → justhtml-3.5.0}/README.md +0 -0
  65. {justhtml-3.3.0 → justhtml-3.5.0}/assets/justhtml-readme-explainer.png +0 -0
  66. {justhtml-3.3.0 → justhtml-3.5.0}/benchmarks/chromium_parser_diff.py +0 -0
  67. {justhtml-3.3.0 → justhtml-3.5.0}/benchmarks/fuzz.py +0 -0
  68. {justhtml-3.3.0 → justhtml-3.5.0}/benchmarks/html5lib_engine_diff.py +0 -0
  69. {justhtml-3.3.0 → justhtml-3.5.0}/benchmarks/parser_engine_gate.py +0 -0
  70. {justhtml-3.3.0 → justhtml-3.5.0}/benchmarks/profile.py +0 -0
  71. {justhtml-3.3.0 → justhtml-3.5.0}/docs/_config.yml +0 -0
  72. {justhtml-3.3.0 → justhtml-3.5.0}/docs/_layouts/default.html +0 -0
  73. {justhtml-3.3.0 → justhtml-3.5.0}/docs/api.md +0 -0
  74. {justhtml-3.3.0 → justhtml-3.5.0}/docs/assets/search.js +0 -0
  75. {justhtml-3.3.0 → justhtml-3.5.0}/docs/building.md +0 -0
  76. {justhtml-3.3.0 → justhtml-3.5.0}/docs/encoding.md +0 -0
  77. {justhtml-3.3.0 → justhtml-3.5.0}/docs/index.md +0 -0
  78. {justhtml-3.3.0 → justhtml-3.5.0}/docs/linkify.md +0 -0
  79. {justhtml-3.3.0 → justhtml-3.5.0}/docs/migration-examples.md +0 -0
  80. {justhtml-3.3.0 → justhtml-3.5.0}/docs/playground/app.js +0 -0
  81. {justhtml-3.3.0 → justhtml-3.5.0}/docs/playground/index.html +0 -0
  82. {justhtml-3.3.0 → justhtml-3.5.0}/docs/playground/py/__init__.py +0 -0
  83. {justhtml-3.3.0 → justhtml-3.5.0}/docs/playground/py/install_latest_justhtml.py +0 -0
  84. {justhtml-3.3.0 → justhtml-3.5.0}/docs/playground/py/render.py +0 -0
  85. {justhtml-3.3.0 → justhtml-3.5.0}/docs/playground/py/use_local_repo.py +0 -0
  86. {justhtml-3.3.0 → justhtml-3.5.0}/docs/sanitization.md +0 -0
  87. {justhtml-3.3.0 → justhtml-3.5.0}/docs/selectors.md +0 -0
  88. {justhtml-3.3.0 → justhtml-3.5.0}/llms.txt +0 -0
  89. {justhtml-3.3.0 → justhtml-3.5.0}/run_tests.py +0 -0
  90. {justhtml-3.3.0 → justhtml-3.5.0}/scripts/release.py +0 -0
  91. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/__init__.py +0 -0
  92. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/__main__.py +0 -0
  93. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/core/__init__.py +0 -0
  94. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/core/constants.py +0 -0
  95. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/core/doctype.py +0 -0
  96. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/core/errors.py +0 -0
  97. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/core/rawtext.py +0 -0
  98. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/core/text.py +0 -0
  99. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/core/types.py +0 -0
  100. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/dom/__init__.py +0 -0
  101. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/dom/builder.py +0 -0
  102. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/parser/context.py +0 -0
  103. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/parser/encoding.py +0 -0
  104. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/parser/options.py +0 -0
  105. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/parser/scanner.py +0 -0
  106. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/parser/stream.py +0 -0
  107. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/py.typed +0 -0
  108. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/sanitizer/__init__.py +0 -0
  109. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/sanitizer/css.py +0 -0
  110. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/sanitizer/dom.py +0 -0
  111. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/sanitizer/policy_defaults.py +0 -0
  112. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/sanitizer/rawtext.py +0 -0
  113. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/sanitizer/url/__init__.py +0 -0
  114. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/sanitizer/url/policy.py +0 -0
  115. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/sanitizer/url/spec.py +0 -0
  116. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/selector/__init__.py +0 -0
  117. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/selector/core.py +0 -0
  118. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/serializer/__init__.py +0 -0
  119. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/serializer/markdown.py +0 -0
  120. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/transforms/linkify.py +0 -0
  121. {justhtml-3.3.0 → justhtml-3.5.0}/src/justhtml/transforms/spec.py +0 -0
  122. {justhtml-3.3.0 → justhtml-3.5.0}/tests/README.md +0 -0
  123. {justhtml-3.3.0 → justhtml-3.5.0}/tests/__init__.py +0 -0
  124. {justhtml-3.3.0 → justhtml-3.5.0}/tests/data/wikipedia.html +0 -0
  125. {justhtml-3.3.0 → justhtml-3.5.0}/tests/harness/__init__.py +0 -0
  126. {justhtml-3.3.0 → justhtml-3.5.0}/tests/harness/encoding.py +0 -0
  127. {justhtml-3.3.0 → justhtml-3.5.0}/tests/harness/regressions.py +0 -0
  128. {justhtml-3.3.0 → justhtml-3.5.0}/tests/harness/reporter.py +0 -0
  129. {justhtml-3.3.0 → justhtml-3.5.0}/tests/harness/serializer.py +0 -0
  130. {justhtml-3.3.0 → justhtml-3.5.0}/tests/harness/tree.py +0 -0
  131. {justhtml-3.3.0 → justhtml-3.5.0}/tests/justhtml-sanitize-tests/cases.json +0 -0
  132. {justhtml-3.3.0 → justhtml-3.5.0}/tests/justhtml-tests/entities.test +0 -0
  133. /justhtml-3.3.0/tests/justhtml-tests/empty_stack_edge_cases.dat → /justhtml-3.5.0/tests/justhtml-tests/foreign_stack_recovery.dat +0 -0
  134. {justhtml-3.3.0 → justhtml-3.5.0}/tests/justhtml-tests/iframe_srcdoc.dat +0 -0
  135. /justhtml-3.3.0/tests/justhtml-tests/coverage_gaps.test → /justhtml-3.5.0/tests/justhtml-tests/tokenizer_state_transitions.test +0 -0
  136. {justhtml-3.3.0 → justhtml-3.5.0}/tests/justhtml-tests/xml_coercion.dat +0 -0
  137. /justhtml-3.3.0/tests/justhtml-tests/xml_coercion_coverage.test → /justhtml-3.5.0/tests/justhtml-tests/xml_coercion_invalid_characters.test +0 -0
  138. {justhtml-3.3.0 → justhtml-3.5.0}/tests/linkify-it/LICENSE.txt +0 -0
  139. {justhtml-3.3.0 → justhtml-3.5.0}/tests/linkify-it/README.md +0 -0
  140. {justhtml-3.3.0 → justhtml-3.5.0}/tests/linkify-it/fixtures/links.txt +0 -0
  141. {justhtml-3.3.0 → justhtml-3.5.0}/tests/linkify-it/fixtures/not_links.txt +0 -0
  142. {justhtml-3.3.0 → justhtml-3.5.0}/tests/test_builder.py +0 -0
  143. {justhtml-3.3.0 → justhtml-3.5.0}/tests/test_cli.py +0 -0
  144. {justhtml-3.3.0 → justhtml-3.5.0}/tests/test_encoding.py +0 -0
  145. {justhtml-3.3.0 → justhtml-3.5.0}/tests/test_linkify_internals.py +0 -0
  146. {justhtml-3.3.0 → justhtml-3.5.0}/tests/test_linkify_it.py +0 -0
  147. {justhtml-3.3.0 → justhtml-3.5.0}/tests/test_linkify_transform.py +0 -0
  148. {justhtml-3.3.0 → justhtml-3.5.0}/tests/test_node.py +0 -0
  149. {justhtml-3.3.0 → justhtml-3.5.0}/tests/test_parser_regressions.py +0 -0
  150. {justhtml-3.3.0 → justhtml-3.5.0}/tests/test_parser_tree.py +0 -0
  151. {justhtml-3.3.0 → justhtml-3.5.0}/tests/test_playground_local_repo_file_list.py +0 -0
  152. {justhtml-3.3.0 → justhtml-3.5.0}/tests/test_sanitize_integration.py +0 -0
  153. {justhtml-3.3.0 → justhtml-3.5.0}/tests/test_sanitize_transform.py +0 -0
  154. {justhtml-3.3.0 → justhtml-3.5.0}/tests/test_selector.py +0 -0
  155. {justhtml-3.3.0 → justhtml-3.5.0}/tests/test_stream.py +0 -0
  156. {justhtml-3.3.0 → justhtml-3.5.0}/tests/test_transforms_compiler.py +0 -0
  157. {justhtml-3.3.0 → justhtml-3.5.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,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.5.0] - 2026-07-13
11
+
12
+ ### Changed
13
+ - Add contributor guidance for documentation examples, security and availability reviews, releases, and a new `PERFORMANCE.md` workflow for measuring and testing performance improvements.
14
+ - 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.
15
+ - Run CI against the latest WPT tree-construction fixtures rather than a pinned revision.
16
+
17
+ ### Fixed
18
+ Note: The below are all minor parsing differences found when diffing against Chromium output, that is not covered in WPT's html5 parsing tests.
19
+
20
+ - 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>`.
21
+ - 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.
22
+ - 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.
23
+ - 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.
24
+ - Keep comments following implied head metadata in the head. Previously, `<meta><!--...-->` could place the comment before the document element.
25
+ - 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.
26
+ - 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.
27
+ - Keep form controls in template contents when a template appears inside `<select>`. Previously, an `<input>` could prematurely close the outer select.
28
+ - Keep initial-template recovery in the head insertion mode after `</template>`. Previously, following head whitespace could be discarded.
29
+ - Match the latest WPT processing-instruction tree expectations, including empty data fields and the `<?#` bogus-comment case.
30
+ - Close an open template `<colgroup>` before processing following script and style tags. Previously, those tags could be incorrectly inserted inside the column group.
31
+ - Recover table-body start tags correctly inside templates. Previously, a following `<colgroup>` could be discarded instead of closing the current table body.
32
+ - Close template table bodies before handling `</table>`. Previously, following content could remain incorrectly nested inside `<tbody>`.
33
+ - Close template rows before handling `</table>`. Previously, following nested templates could remain incorrectly nested inside `<tr>`.
34
+ - Ignore nested table starts in template table modes that have no open table. Previously, malformed input could create a spurious empty `<table>`.
35
+ - Resume template body recovery after `<title>`. Previously, invalid table-structure tags following a template title could create incorrect table nodes.
36
+ - Resume template body recovery after base and noframes head-content tags. Previously, invalid following table-structure tags could create incorrect table nodes.
37
+ - Apply phantom paragraph recovery inside templates. Previously, an unmatched `</p>` could be incorrectly ignored.
38
+ - Keep unmatched `</p>` ignored in the template initial insertion mode.
39
+ - Keep template table-body recovery scoped to an open table. Previously, a nested table token could close and duplicate synthetic table bodies.
40
+ - Preserve open template tables when closing a select. Previously, following option content could be placed outside the select.
41
+
42
+ ## [3.4.0] - 2026-07-11
43
+
44
+ ### Security
45
+ - (Severity: Low) Bound named character-reference lookup to the finite HTML entity tables. Previously, a malformed `&` followed by a long alphanumeric run caused repeated shrinking-prefix allocations and lookups, making parsing quadratic and allowing denial of service before sanitization.
46
+ - (Severity: Low) Cap diagnostics retained by `JustHTML(...)` with the new `max_errors` option (default: 1000), including parser, sanitizer, raw-text, and transform findings. Previously, input containing many independently malformed characters or unsafe constructs could allocate an unbounded number of `ParseError` objects.
47
+ - (Severity: Low) Index active-formatting entries by signature within each HTML5 Noah's Ark marker segment, retiring superseded entries lazily and compacting in bounded batches. Previously, repeated fourth occurrences across many formatting signatures rescanned and shifted the active-formatting list, allowing quadratic parsing of crafted HTML.
48
+ - (Severity: Low) Avoid unbounded integer conversion for untrusted `srcset` width descriptors, URL host validation, and `Linkify(...)` URL ports. Previously, inputs with more decimal digits than Python permits in `int()` could raise an uncaught `ValueError` during sanitization or linkification.
49
+ - (Severity: Low) Bound pretty-print indentation and avoid repeatedly copying nested serialized output. Previously, serializing deeply nested untrusted HTML with the default `to_html()` settings could consume quadratic time and memory.
50
+ - (Severity: Low) Compact sibling lists when applying a tag-only `Drop(...)` transform. Previously, dropping many matching untrusted sibling elements shifted the remaining list after every removal, making transform execution quadratic.
51
+
10
52
  ## [3.3.0] - 2026-07-04
11
53
 
12
54
  ### Changed
@@ -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.3.0
3
+ Version: 3.5.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'
@@ -19,6 +19,25 @@
19
19
  | 1.x | :x: |
20
20
  | < 1.0 | :x: |
21
21
 
22
+ ## Threat Model
23
+
24
+ JustHTML's primary security boundary is **untrusted HTML input**: markup,
25
+ bytes, and HTML-derived values supplied to parsing, sanitization, and
26
+ serialization APIs. The promises below apply when callers use the documented
27
+ safe defaults or a deliberately safe policy.
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
+
34
+ Library configuration is trusted application code. This includes custom
35
+ sanitization policies, transform specifications and callbacks, selectors,
36
+ glob patterns, CLI flags, and any policy or transform values assembled from
37
+ configuration. Applications that accept those values from untrusted users must
38
+ validate and constrain them before passing them to JustHTML; transform APIs are
39
+ not a sandbox.
40
+
22
41
  ## Security Domains
23
42
 
24
43
  This section describes the security areas we use when reviewing `justhtml`.
@@ -42,7 +61,8 @@ Promise:
42
61
  - JustHTML decodes bytes using HTML-style encoding detection and rejects UTF-7.
43
62
  - JustHTML parses malformed HTML into a DOM using browser-style HTML5 recovery.
44
63
  - The parser handles entity and character-reference edge cases.
45
- - 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.
46
66
  - JustHTML reports parse errors as data unless strict mode is enabled.
47
67
  - Fragment parsing and full-document parsing keep their different assumptions
48
68
  explicit.
@@ -126,6 +146,8 @@ Promise:
126
146
  - Serialization rejects unsafe element and attribute names created through
127
147
  custom DOM changes.
128
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.
129
151
  - `to_text()` returns text content rather than markup.
130
152
  - `to_markdown()` escapes Markdown-sensitive text by default.
131
153
  - `to_markdown()` escapes line-start Markdown markers that could change block
@@ -165,6 +187,8 @@ Promise:
165
187
  sanitization step after later transforms.
166
188
  - Built-in URL-aware transforms reuse sanitizer URL cleaning where they can.
167
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.
168
192
 
169
193
  Out of scope:
170
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