justhtml 2.4.0__tar.gz → 3.0.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-2.4.0 → justhtml-3.0.0}/.github/copilot-instructions.md +10 -9
  2. {justhtml-2.4.0 → justhtml-3.0.0}/.pre-commit-config.yaml +6 -0
  3. {justhtml-2.4.0 → justhtml-3.0.0}/CHANGELOG.md +27 -0
  4. {justhtml-2.4.0 → justhtml-3.0.0}/CONTRIBUTING.md +7 -5
  5. {justhtml-2.4.0 → justhtml-3.0.0}/PKG-INFO +5 -3
  6. {justhtml-2.4.0 → justhtml-3.0.0}/README.md +4 -2
  7. justhtml-3.0.0/benchmarks/chromium_parser_diff.py +383 -0
  8. {justhtml-2.4.0 → justhtml-3.0.0}/benchmarks/correctness.py +4 -4
  9. justhtml-3.0.0/benchmarks/html5lib_engine_diff.py +288 -0
  10. justhtml-3.0.0/benchmarks/parser_engine_gate.py +112 -0
  11. {justhtml-2.4.0 → justhtml-3.0.0}/docs/correctness.md +35 -33
  12. justhtml-3.0.0/docs/errors.md +123 -0
  13. justhtml-3.0.0/docs/parser-engine-findings.md +469 -0
  14. {justhtml-2.4.0 → justhtml-3.0.0}/docs/playground/app.js +5 -7
  15. {justhtml-2.4.0 → justhtml-3.0.0}/docs/transforms.md +2 -2
  16. {justhtml-2.4.0 → justhtml-3.0.0}/pyproject.toml +1 -1
  17. {justhtml-2.4.0 → justhtml-3.0.0}/run_tests.py +4 -20
  18. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/__init__.py +1 -1
  19. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/core/constants.py +0 -1
  20. justhtml-2.4.0/src/justhtml/treebuilder/utils.py → justhtml-3.0.0/src/justhtml/core/doctype.py +1 -32
  21. justhtml-3.0.0/src/justhtml/core/errors.py +40 -0
  22. justhtml-2.4.0/src/justhtml/tokenizer/tokens.py → justhtml-3.0.0/src/justhtml/core/types.py +0 -75
  23. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/dom/__init__.py +1 -1
  24. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/dom/builder.py +1 -1
  25. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/parser/__init__.py +193 -157
  26. justhtml-3.0.0/src/justhtml/parser/engine.py +5524 -0
  27. justhtml-3.0.0/src/justhtml/parser/options.py +33 -0
  28. justhtml-3.0.0/src/justhtml/parser/scanner.py +112 -0
  29. justhtml-3.0.0/src/justhtml/parser/stream.py +492 -0
  30. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/sanitizer/dom.py +1 -1
  31. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/sanitizer/policy.py +1 -1
  32. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/sanitizer/rawtext.py +1 -1
  33. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/serializer/html.py +11 -4
  34. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/transforms/__init__.py +1 -1
  35. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/transforms/runtime.py +62 -5
  36. justhtml-3.0.0/test-summary.txt +101 -0
  37. {justhtml-2.4.0 → justhtml-3.0.0}/tests/README.md +0 -2
  38. {justhtml-2.4.0 → justhtml-3.0.0}/tests/harness/tree.py +4 -4
  39. {justhtml-2.4.0 → justhtml-3.0.0}/tests/justhtml-tests/branch_coverage.dat +1 -1
  40. {justhtml-2.4.0 → justhtml-3.0.0}/tests/justhtml-tests/treebuilder_coverage.dat +4 -4
  41. {justhtml-2.4.0 → justhtml-3.0.0}/tests/test_cli.py +6 -0
  42. {justhtml-2.4.0 → justhtml-3.0.0}/tests/test_encoding.py +39 -0
  43. justhtml-3.0.0/tests/test_entities.py +47 -0
  44. {justhtml-2.4.0 → justhtml-3.0.0}/tests/test_errors.py +57 -94
  45. {justhtml-2.4.0 → justhtml-3.0.0}/tests/test_node.py +11 -0
  46. justhtml-3.0.0/tests/test_parser_engine_coverage.py +1071 -0
  47. justhtml-3.0.0/tests/test_parser_regressions.py +81 -0
  48. justhtml-3.0.0/tests/test_parser_tree.py +955 -0
  49. {justhtml-2.4.0 → justhtml-3.0.0}/tests/test_sanitize.py +1 -1
  50. {justhtml-2.4.0 → justhtml-3.0.0}/tests/test_serialize.py +17 -6
  51. {justhtml-2.4.0 → justhtml-3.0.0}/tests/test_stream.py +136 -2
  52. {justhtml-2.4.0 → justhtml-3.0.0}/tests/test_transforms.py +7 -0
  53. {justhtml-2.4.0 → justhtml-3.0.0}/tests/test_transforms_edge_cases.py +21 -0
  54. justhtml-2.4.0/docs/errors.md +0 -334
  55. justhtml-2.4.0/src/justhtml/core/errors.py +0 -154
  56. justhtml-2.4.0/src/justhtml/parser/stream.py +0 -206
  57. justhtml-2.4.0/src/justhtml/tokenizer/__init__.py +0 -20
  58. justhtml-2.4.0/src/justhtml/tokenizer/html.py +0 -3131
  59. justhtml-2.4.0/src/justhtml/treebuilder/__init__.py +0 -6
  60. justhtml-2.4.0/src/justhtml/treebuilder/core.py +0 -1551
  61. justhtml-2.4.0/src/justhtml/treebuilder/modes.py +0 -2169
  62. justhtml-2.4.0/test-summary.txt +0 -117
  63. justhtml-2.4.0/tests/harness/tokenizer.py +0 -363
  64. justhtml-2.4.0/tests/test_tokenizer.py +0 -577
  65. justhtml-2.4.0/tests/test_treebuilder.py +0 -804
  66. {justhtml-2.4.0 → justhtml-3.0.0}/.github/workflows/ci.yml +0 -0
  67. {justhtml-2.4.0 → justhtml-3.0.0}/.github/workflows/publish.yml +0 -0
  68. {justhtml-2.4.0 → justhtml-3.0.0}/.gitignore +0 -0
  69. {justhtml-2.4.0 → justhtml-3.0.0}/CODE_OF_CONDUCT.md +0 -0
  70. {justhtml-2.4.0 → justhtml-3.0.0}/LICENSE +0 -0
  71. {justhtml-2.4.0 → justhtml-3.0.0}/Makefile +0 -0
  72. {justhtml-2.4.0 → justhtml-3.0.0}/SECURITY.md +0 -0
  73. {justhtml-2.4.0 → justhtml-3.0.0}/assets/justhtml-readme-explainer.png +0 -0
  74. {justhtml-2.4.0 → justhtml-3.0.0}/benchmarks/fuzz.py +0 -0
  75. {justhtml-2.4.0 → justhtml-3.0.0}/benchmarks/performance.py +0 -0
  76. {justhtml-2.4.0 → justhtml-3.0.0}/benchmarks/profile.py +0 -0
  77. {justhtml-2.4.0 → justhtml-3.0.0}/docs/_config.yml +0 -0
  78. {justhtml-2.4.0 → justhtml-3.0.0}/docs/_layouts/default.html +0 -0
  79. {justhtml-2.4.0 → justhtml-3.0.0}/docs/api.md +0 -0
  80. {justhtml-2.4.0 → justhtml-3.0.0}/docs/assets/search.js +0 -0
  81. {justhtml-2.4.0 → justhtml-3.0.0}/docs/bleach-migration.md +0 -0
  82. {justhtml-2.4.0 → justhtml-3.0.0}/docs/building.md +0 -0
  83. {justhtml-2.4.0 → justhtml-3.0.0}/docs/cli.md +0 -0
  84. {justhtml-2.4.0 → justhtml-3.0.0}/docs/comparison.md +0 -0
  85. {justhtml-2.4.0 → justhtml-3.0.0}/docs/encoding.md +0 -0
  86. {justhtml-2.4.0 → justhtml-3.0.0}/docs/fragments.md +0 -0
  87. {justhtml-2.4.0 → justhtml-3.0.0}/docs/html-cleaning.md +0 -0
  88. {justhtml-2.4.0 → justhtml-3.0.0}/docs/index.md +0 -0
  89. {justhtml-2.4.0 → justhtml-3.0.0}/docs/linkify.md +0 -0
  90. {justhtml-2.4.0 → justhtml-3.0.0}/docs/migration-examples.md +0 -0
  91. {justhtml-2.4.0 → justhtml-3.0.0}/docs/playground/index.html +0 -0
  92. {justhtml-2.4.0 → justhtml-3.0.0}/docs/playground/py/__init__.py +0 -0
  93. {justhtml-2.4.0 → justhtml-3.0.0}/docs/playground/py/install_latest_justhtml.py +0 -0
  94. {justhtml-2.4.0 → justhtml-3.0.0}/docs/playground/py/render.py +0 -0
  95. {justhtml-2.4.0 → justhtml-3.0.0}/docs/playground/py/use_local_repo.py +0 -0
  96. {justhtml-2.4.0 → justhtml-3.0.0}/docs/quickstart.md +0 -0
  97. {justhtml-2.4.0 → justhtml-3.0.0}/docs/sanitization.md +0 -0
  98. {justhtml-2.4.0 → justhtml-3.0.0}/docs/selectors.md +0 -0
  99. {justhtml-2.4.0 → justhtml-3.0.0}/docs/streaming.md +0 -0
  100. {justhtml-2.4.0 → justhtml-3.0.0}/docs/text.md +0 -0
  101. {justhtml-2.4.0 → justhtml-3.0.0}/docs/unsafe-handling.md +0 -0
  102. {justhtml-2.4.0 → justhtml-3.0.0}/docs/url-cleaning.md +0 -0
  103. {justhtml-2.4.0 → justhtml-3.0.0}/llms.txt +0 -0
  104. {justhtml-2.4.0 → justhtml-3.0.0}/scripts/release.py +0 -0
  105. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/__main__.py +0 -0
  106. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/core/__init__.py +0 -0
  107. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/core/entities.py +0 -0
  108. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/core/rawtext.py +0 -0
  109. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/parser/context.py +0 -0
  110. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/parser/encoding.py +0 -0
  111. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/py.typed +0 -0
  112. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/sanitizer/__init__.py +0 -0
  113. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/sanitizer/css.py +0 -0
  114. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/sanitizer/policy_defaults.py +0 -0
  115. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/sanitizer/url/__init__.py +0 -0
  116. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/sanitizer/url/policy.py +0 -0
  117. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/sanitizer/url/runtime.py +0 -0
  118. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/sanitizer/url/spec.py +0 -0
  119. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/selector/__init__.py +0 -0
  120. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/selector/core.py +0 -0
  121. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/serializer/__init__.py +0 -0
  122. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/serializer/markdown.py +0 -0
  123. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/transforms/compile.py +0 -0
  124. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/transforms/linkify.py +0 -0
  125. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/transforms/linkify_core.py +0 -0
  126. {justhtml-2.4.0 → justhtml-3.0.0}/src/justhtml/transforms/spec.py +0 -0
  127. {justhtml-2.4.0 → justhtml-3.0.0}/tests/__init__.py +0 -0
  128. {justhtml-2.4.0 → justhtml-3.0.0}/tests/data/wikipedia.html +0 -0
  129. {justhtml-2.4.0 → justhtml-3.0.0}/tests/harness/__init__.py +0 -0
  130. {justhtml-2.4.0 → justhtml-3.0.0}/tests/harness/encoding.py +0 -0
  131. {justhtml-2.4.0 → justhtml-3.0.0}/tests/harness/regressions.py +0 -0
  132. {justhtml-2.4.0 → justhtml-3.0.0}/tests/harness/reporter.py +0 -0
  133. {justhtml-2.4.0 → justhtml-3.0.0}/tests/harness/serializer.py +0 -0
  134. {justhtml-2.4.0 → justhtml-3.0.0}/tests/justhtml-sanitize-tests/cases.json +0 -0
  135. {justhtml-2.4.0 → justhtml-3.0.0}/tests/justhtml-tests/coverage_gaps.test +0 -0
  136. {justhtml-2.4.0 → justhtml-3.0.0}/tests/justhtml-tests/empty_stack_edge_cases.dat +0 -0
  137. {justhtml-2.4.0 → justhtml-3.0.0}/tests/justhtml-tests/entities.test +0 -0
  138. {justhtml-2.4.0 → justhtml-3.0.0}/tests/justhtml-tests/iframe_srcdoc.dat +0 -0
  139. {justhtml-2.4.0 → justhtml-3.0.0}/tests/justhtml-tests/tokenizer_edge_cases.test +0 -0
  140. {justhtml-2.4.0 → justhtml-3.0.0}/tests/justhtml-tests/xml_coercion.dat +0 -0
  141. {justhtml-2.4.0 → justhtml-3.0.0}/tests/justhtml-tests/xml_coercion_coverage.test +0 -0
  142. {justhtml-2.4.0 → justhtml-3.0.0}/tests/linkify-it/LICENSE.txt +0 -0
  143. {justhtml-2.4.0 → justhtml-3.0.0}/tests/linkify-it/README.md +0 -0
  144. {justhtml-2.4.0 → justhtml-3.0.0}/tests/linkify-it/fixtures/links.txt +0 -0
  145. {justhtml-2.4.0 → justhtml-3.0.0}/tests/linkify-it/fixtures/not_links.txt +0 -0
  146. {justhtml-2.4.0 → justhtml-3.0.0}/tests/test_builder.py +0 -0
  147. {justhtml-2.4.0 → justhtml-3.0.0}/tests/test_docs_examples.py +0 -0
  148. {justhtml-2.4.0 → justhtml-3.0.0}/tests/test_linkify_internals.py +0 -0
  149. {justhtml-2.4.0 → justhtml-3.0.0}/tests/test_linkify_it.py +0 -0
  150. {justhtml-2.4.0 → justhtml-3.0.0}/tests/test_linkify_transform.py +0 -0
  151. {justhtml-2.4.0 → justhtml-3.0.0}/tests/test_playground_local_repo_file_list.py +0 -0
  152. {justhtml-2.4.0 → justhtml-3.0.0}/tests/test_sanitize_integration.py +0 -0
  153. {justhtml-2.4.0 → justhtml-3.0.0}/tests/test_sanitize_transform.py +0 -0
  154. {justhtml-2.4.0 → justhtml-3.0.0}/tests/test_selector.py +0 -0
  155. {justhtml-2.4.0 → justhtml-3.0.0}/tests/test_transforms_compiler.py +0 -0
  156. {justhtml-2.4.0 → justhtml-3.0.0}/tests/test_transforms_sanitize_integration.py +0 -0
  157. {justhtml-2.4.0 → justhtml-3.0.0}/tests/test_wikipedia.py +0 -0
@@ -6,19 +6,20 @@
6
6
  - Keep preambles to a single declarative sentence ("I'm scanning the repo and then drafting a minimal fix.") — no approval requests.
7
7
 
8
8
  ### Architecture Snapshot
9
- - Tokenizer (`tokenizer.py`): HTML5 spec state machine (~60 states). Handles RCDATA, RAWTEXT, CDATA, script escaping, comments, DOCTYPE, etc.
10
- - Tree builder (`treebuilder.py`): Token sink that constructs DOM tree following HTML5 construction rules.
11
- - Node tree (`node.py`): DOM-like structure. Always use `append_child()` / `insert_before()` for tree operations.
12
- - Entities (`entities.py`): HTML5 character reference decoding (named & numeric entities).
13
- - Constants (`constants.py`): HTML5 element categories, void elements, formatting elements, etc.
9
+ - Parser engine (`src/justhtml/parser/engine.py`): Plan-driven tokenization, tree construction, sanitizer projection, and HTML5 recovery behavior.
10
+ - Parser scanner (`src/justhtml/parser/scanner.py`): Shared low-level scanning for tags, rawtext, and script end markers.
11
+ - DOM (`src/justhtml/dom/`): DOM-like node tree. Use `append_child()` / `insert_before()` for public tree operations.
12
+ - Entities (`src/justhtml/core/entities.py`): HTML5 named and numeric character-reference decoding.
13
+ - Constants (`src/justhtml/core/constants.py`): HTML5 element categories, void elements, formatting elements, and namespaces.
14
+ - Transforms (`src/justhtml/transforms/`): Compiled post-parse tree transformations.
14
15
 
15
16
  ### Golden Rules
16
17
  1. **Spec compliance first**: Follow WHATWG HTML5 spec exactly. No heuristics, no shortcuts.
17
18
  2. **No exceptions in hot paths**: Use deterministic control flow, not try/except for branching.
18
19
  3. **No reflective probing**: No `hasattr`, `getattr`, or `delattr` - all data structures used are deterministic.
19
- 4. **Minimal allocations**: Reuse buffers, avoid per-token object creation in tokenizer.
20
- 5. **Token reuse**: Create new token objects when emitting (don't reuse references).
21
- 6. **State machine purity**: Tokenizer state transitions follow spec state machine exactly.
20
+ 4. **Minimal allocations**: Reuse buffers and avoid transient objects in parser hot paths.
21
+ 5. **Node ownership**: Move nodes through DOM helpers and keep parent/child links consistent.
22
+ 6. **State-machine clarity**: Keep parser recovery and insertion-mode transitions explicit.
22
23
  7. **No test-specific code**: No references to test files in comments or code.
23
24
 
24
25
  ### Testing Workflow
@@ -75,7 +76,7 @@
75
76
  - Debug calls: `self.debug()` / `parser.debug()` - no gating needed
76
77
 
77
78
  ### Performance Mindset
78
- - Tokenizer is hot path: minimize allocations, avoid string slicing
79
+ - Parser scanning and tree construction are hot paths: minimize allocations and avoid unnecessary string slicing
79
80
  - Use `str.find()` for scanning, not regex when possible
80
81
  - Reuse buffers: `text_buffer`, `current_tag_name`, etc.
81
82
  - Infer state from structure (stacks, tree) instead of storing flags
@@ -37,3 +37,9 @@ repos:
37
37
  language: system
38
38
  pass_filenames: false
39
39
  always_run: true
40
+ - id: parser-differential
41
+ name: Parser Differential
42
+ entry: bash -c 'PYTHONPATH=src python benchmarks/html5lib_engine_diff.py --fail-under-rate 1.0 --fail-on-current-exceptions'
43
+ language: system
44
+ pass_filenames: false
45
+ always_run: true
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.0.0] - 2026-06-21
11
+
12
+ JustHTML 3.0.0 ships a new parser core. The old tokenizer and tree-builder are now one integrated system, which makes the parser much faster while keeping browser-style HTML5 recovery and default-safe behavior intact. In practice, this release is mainly about getting materially better performance without asking most users to change how they use the library.
13
+
14
+ The breaking part is narrow: if you do not depend on `doc.errors` or on the exact diagnostic details raised by `strict=True`, this release should behave like a faster, more robust JustHTML. If you do depend on parser diagnostics, expect different error codes, counts, locations, and ordering from the old implementation.
15
+
16
+ ### Changed
17
+ - Replace the legacy tokenizer/tree-builder split with one plan-driven parser engine shared across sanitized, unsanitized, fragment, streaming, diagnostic, and source-location modes. This is a new parser architecture, not just an optimization pass: scanning, tree construction, and default-safe decisions now run as one coordinated system.
18
+ - BREAKING: Rework parser diagnostics around a smaller, higher-value built-in error set. `collect_errors=True` and `strict=True` still surface parser problems, but callers should expect different error codes, counts, locations, and ordering than the old tokenizer/tree-builder pipeline.
19
+
20
+ ### Performance
21
+ - Deliver roughly a **2x parsing speedup** from the new fused engine while preserving browser-style HTML5 recovery behavior. The hot path now avoids the old tokenizer-to-treebuilder handoff, compiles default sanitizer decisions into parser tag actions, and scans projected attributes directly while parsing.
22
+
23
+ ### Fixed
24
+ - Preserve HTML5 tree-construction behavior across foreign-content integration points, select/table recovery, framesets, plaintext formatting reconstruction, form-pointer handling, malformed comments, foreign attribute parsing, adoption-agency edge cases, and scope-repair bugs found through Chromium and html5lib differentials.
25
+ - Stabilize sanitization of malformed foreign-namespace rawtext structures so trailing HTML cannot be exposed by mutation-XSS reparsing.
26
+
27
+ ### Breaking
28
+ - BREAKING: Error collection is no longer a compatibility surface for the legacy parser internals. Applications that assert on the exact contents of `doc.errors`, or on the specific exception details raised by `strict=True`, must update to the new diagnostic model. Other users should not need API changes for this release.
29
+
30
+ ## [2.4.1] - 2026-06-21
31
+
32
+ ### Fixed
33
+ - Prevent the leading-line-feed exception for `<pre>`, `<listing>`, and `<textarea>` from leaking past intervening non-character tokens and removing a later newline.
34
+ - Apply paragraph closing and leading-line-feed handling to `<pre>` and `<listing>` inside customizable `<select>` content.
35
+ - Preserve U+000C FORM FEED in HTML text like browsers do, while retaining its intentional conversion to a space in XML-coercion mode.
36
+
10
37
  ## [2.4.0] - 2026-06-20
11
38
 
12
39
  ### Fixed
@@ -58,6 +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
62
 
62
63
  Run manually:
63
64
  ```bash
@@ -92,15 +93,16 @@ python benchmarks/profile.py
92
93
 
93
94
  ## Architecture Notes
94
95
 
95
- - **Tokenizer** (`tokenizer.py`): HTML5 spec state machine
96
- - **Tree builder** (`treebuilder.py`): Constructs DOM tree following HTML5 rules
97
- - **Node tree** (`node.py`): DOM-like structure, use `append_child()` / `insert_before()`
98
- - **Selector** (`selector.py`): CSS selector matching
96
+ - **Parser engine** (`src/justhtml/parser/engine.py`): Plan-driven tokenization, tree construction, and sanitizer projection
97
+ - **Parser scanner** (`src/justhtml/parser/scanner.py`): Shared low-level tag and rawtext scanning helpers
98
+ - **DOM** (`src/justhtml/dom/`): DOM-like node tree; use `append_child()` / `insert_before()` for public tree operations
99
+ - **Selectors** (`src/justhtml/selector/`): CSS selector parsing and matching
100
+ - **Transforms** (`src/justhtml/transforms/`): Compiled post-parse tree transformations
99
101
 
100
102
  Golden rules:
101
103
  1. Follow WHATWG HTML5 spec exactly
102
104
  2. No exceptions in hot paths
103
- 3. Minimal allocations in tokenizer
105
+ 3. Minimal allocations in parser hot paths
104
106
  4. No `hasattr`/`getattr`/`delattr` - all structures are deterministic
105
107
 
106
108
  ## Submitting Changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: justhtml
3
- Version: 2.4.0
3
+ Version: 3.0.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
@@ -164,9 +164,11 @@ justhtml index.html --selector "article" --format markdown
164
164
 
165
165
  ## Correctness
166
166
 
167
- JustHTML is tested against the official html5lib tokenizer, tree-construction, serializer, and encoding fixtures, plus project-specific sanitizer, selector, transform, CLI, and regression tests.
167
+ JustHTML is tested against the official html5lib tree-construction, serializer, and encoding fixtures, plus project-specific sanitizer, selector, transform, CLI, and regression tests.
168
168
 
169
- The current test summary is 10,257 passing tests with 100% line and branch coverage. See [Correctness Testing](docs/correctness.md) for details.
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.
171
+ See [Correctness Testing](docs/correctness.md) for details.
170
172
 
171
173
  ## Documentation
172
174
 
@@ -110,9 +110,11 @@ justhtml index.html --selector "article" --format markdown
110
110
 
111
111
  ## Correctness
112
112
 
113
- JustHTML is tested against the official html5lib tokenizer, tree-construction, serializer, and encoding fixtures, plus project-specific sanitizer, selector, transform, CLI, and regression tests.
113
+ JustHTML is tested against the official html5lib tree-construction, serializer, and encoding fixtures, plus project-specific sanitizer, selector, transform, CLI, and regression tests.
114
114
 
115
- The current test summary is 10,257 passing tests with 100% line and branch coverage. See [Correctness Testing](docs/correctness.md) for details.
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.
117
+ See [Correctness Testing](docs/correctness.md) for details.
116
118
 
117
119
  ## Documentation
118
120
 
@@ -0,0 +1,383 @@
1
+ #!/usr/bin/env python3
2
+ """Compare JustHTML parse trees with headless Chromium.
3
+
4
+ Run with an environment containing Playwright, for example:
5
+
6
+ PYENV_VERSION=justhtml-html5lib-tests-bench \
7
+ python benchmarks/chromium_parser_diff.py --generated 5000
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import importlib
14
+ import inspect
15
+ import json
16
+ import random
17
+ import sys
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ _ROOT = Path(__file__).resolve().parents[1]
23
+ for _path in (str(_ROOT), str(_ROOT / "src")):
24
+ if _path not in sys.path:
25
+ sys.path.insert(0, _path)
26
+
27
+ from justhtml import JustHTML # noqa: E402
28
+ from justhtml.dom import Template # noqa: E402
29
+
30
+ _KNOWN_CHROMIUM_DIVERGENCES = frozenset(
31
+ {
32
+ "adoption01.dat:17",
33
+ "generated:4",
34
+ "generated:65",
35
+ "noscript01.dat:12",
36
+ "tests25.dat:7",
37
+ "tests_innerHTML_1.dat:75",
38
+ "webkit02.dat:11",
39
+ "webkit02.dat:15",
40
+ "webkit02.dat:16",
41
+ "webkit02.dat:17",
42
+ "generated:649",
43
+ "generated:705",
44
+ }
45
+ )
46
+ _NON_ATOMIC_FUZZ_STRATEGIES = frozenset({"fuzz_attribute", "fuzz_nested_structure", "fuzz_open_tag"})
47
+
48
+
49
+ @dataclass(slots=True)
50
+ class Case:
51
+ source: str
52
+ html: str
53
+ context_name: str | None = None
54
+ context_namespace: str | None = None
55
+ scripting_enabled: bool = True
56
+
57
+
58
+ def _runner_config() -> dict[str, object]:
59
+ return {
60
+ "fail_fast": False,
61
+ "test_specs": [],
62
+ "quiet": True,
63
+ "write_summary": False,
64
+ "exclude_errors": None,
65
+ "exclude_files": None,
66
+ "exclude_html": None,
67
+ "filter_html": None,
68
+ "filter_errors": None,
69
+ "verbosity": 0,
70
+ "regressions": False,
71
+ "check_errors": False,
72
+ "suite": "tree",
73
+ }
74
+
75
+
76
+ def _html5lib_cases(limit: int | None) -> list[Case]:
77
+ if limit == 0:
78
+ return []
79
+ tree_module = importlib.import_module("tests.harness.tree")
80
+ runner = tree_module.TestRunner(_ROOT / "tests/html5lib-tests-tree", _runner_config())
81
+ cases: list[Case] = []
82
+ for path, tests in runner.load_tests():
83
+ for index, test in enumerate(tests):
84
+ if test.script_directive == "script-on" or test.xml_coercion or test.iframe_srcdoc:
85
+ continue
86
+ context = test.fragment_context
87
+ cases.append(
88
+ Case(
89
+ source=f"{path.name}:{index}",
90
+ html=test.data,
91
+ context_name=context.tag_name if context else None,
92
+ context_namespace=context.namespace if context else None,
93
+ scripting_enabled=test.script_directive != "script-off",
94
+ )
95
+ )
96
+ if limit is not None and len(cases) >= limit:
97
+ return cases
98
+ return cases
99
+
100
+
101
+ def _generated_cases(count: int, seed: int) -> list[Case]:
102
+ fuzz = importlib.import_module("benchmarks.fuzz")
103
+ random.seed(seed)
104
+ return [
105
+ Case(source=f"generated:{index}", html=fuzz.generate_fuzzed_html(), scripting_enabled=False)
106
+ for index in range(count)
107
+ ]
108
+
109
+
110
+ def _atomic_generated_cases(count_per_strategy: int, seed: int) -> list[Case]:
111
+ fuzz = importlib.import_module("benchmarks.fuzz")
112
+ random.seed(seed)
113
+ strategies = []
114
+ for name, value in vars(fuzz).items():
115
+ if not name.startswith("fuzz_") or name in _NON_ATOMIC_FUZZ_STRATEGIES or not callable(value):
116
+ continue
117
+ signature = inspect.signature(value)
118
+ if any(
119
+ parameter.default is inspect.Parameter.empty
120
+ and parameter.kind in {inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD}
121
+ for parameter in signature.parameters.values()
122
+ ):
123
+ continue
124
+ strategies.append((name, value))
125
+
126
+ cases = []
127
+ for name, strategy in sorted(strategies):
128
+ cases.extend(
129
+ [
130
+ Case(
131
+ source=f"atomic:{name}:{index}",
132
+ html=strategy(),
133
+ scripting_enabled=False,
134
+ )
135
+ for index in range(count_per_strategy)
136
+ ]
137
+ )
138
+ return cases
139
+
140
+
141
+ def _merge_adjacent_text(nodes: list[list[Any]]) -> list[list[Any]]:
142
+ merged: list[list[Any]] = []
143
+ for node in nodes:
144
+ if node[0] == "text" and merged and merged[-1][0] == "text":
145
+ merged[-1][1] += node[1]
146
+ else:
147
+ merged.append(node)
148
+ return merged
149
+
150
+
151
+ def _canonical_node(node: Any) -> list[Any]:
152
+ name = node.name
153
+ if name == "#text":
154
+ return ["text", node.data or ""]
155
+ if name == "#comment":
156
+ return ["comment", node.data or ""]
157
+ if name == "!doctype":
158
+ doctype = node.data
159
+ return ["doctype", doctype.name or "", doctype.public_id or "", doctype.system_id or ""]
160
+
161
+ namespace = node.namespace or "html"
162
+ attrs = [[name, value or ""] for name, value in sorted((node.attrs or {}).items())]
163
+ children = _merge_adjacent_text([_canonical_node(child) for child in node.children or ()])
164
+ content = None
165
+ if type(node) is Template and node.template_content is not None:
166
+ content = _merge_adjacent_text([_canonical_node(child) for child in node.template_content.children])
167
+ return ["element", namespace, name, attrs, children, content]
168
+
169
+
170
+ def _render_justhtml(case: Case) -> list[Any]:
171
+ context = None
172
+ if case.context_name is not None:
173
+ context_module = importlib.import_module("justhtml.parser.context")
174
+ context = context_module.FragmentContext(case.context_name, case.context_namespace)
175
+ document = JustHTML(
176
+ case.html,
177
+ fragment_context=context,
178
+ scripting_enabled=case.scripting_enabled,
179
+ sanitize=False,
180
+ )
181
+ return [_canonical_node(child) for child in document.root.children or ()]
182
+
183
+
184
+ def _canonical_subtree_is_empty(node: list[Any]) -> bool:
185
+ if node[0] == "text":
186
+ return not node[1].strip()
187
+ if node[0] != "element":
188
+ return False
189
+ return all(_canonical_subtree_is_empty(child) for child in node[4])
190
+
191
+
192
+ def _is_known_divergence(case: Case, actual: list[Any], expected: list[Any]) -> bool:
193
+ if case.source in _KNOWN_CHROMIUM_DIVERGENCES:
194
+ return True
195
+ if case.html.startswith("\ufeff"):
196
+ # DOMParser receives an already-decoded string and preserves this
197
+ # character. JustHTML applies its byte-stream-compatible BOM policy.
198
+ return True
199
+ if case.html in {"&#x;", "&#X;"}:
200
+ # The HTML Standard requires an absent-digit numeric reference to be
201
+ # flushed unchanged. Chromium currently emits U+FFFD for this input.
202
+ return True
203
+ if case.html == "&#9;":
204
+ # Chromium DOMParser preserves this as a body text node even though the
205
+ # tree builder ignores the leading character before the html element.
206
+ return True
207
+ if case.html.startswith("<!D") and not case.html.lower().startswith("<!doctype"):
208
+ # Chromium drops an incomplete uppercase DOCTYPE prefix where the
209
+ # tokenizer rules instead produce a bogus comment.
210
+ return True
211
+ if "<frameset" in case.html.lower() and actual and expected:
212
+ actual_html = actual[-1]
213
+ expected_html = expected[-1]
214
+ if actual_html[:3] == ["element", "html", "html"] and expected_html[:3] == ["element", "html", "html"]:
215
+ actual_elements = [node for node in actual_html[4] if node[0] == "element"]
216
+ expected_elements = [node for node in expected_html[4] if node[0] == "element"]
217
+ if (
218
+ actual_elements
219
+ and expected_elements
220
+ and actual_elements[-1][2] == "body"
221
+ and expected_elements[-1][2] == "frameset"
222
+ and all(_canonical_subtree_is_empty(child) for child in actual_elements[-1][4])
223
+ ):
224
+ # Chromium still accepts a frameset after recursively empty
225
+ # unknown elements. The standard marks frameset-ok false.
226
+ return True
227
+ return case.source.startswith(("atomic:fuzz_scope_terminators:", "atomic:fuzz_formatting_boundary:")) and (
228
+ "<button><button>" in case.html
229
+ )
230
+
231
+
232
+ _CHROMIUM_RENDER = r"""
233
+ (cases) => {
234
+ const namespaceUri = {
235
+ html: "http://www.w3.org/1999/xhtml",
236
+ svg: "http://www.w3.org/2000/svg",
237
+ math: "http://www.w3.org/1998/Math/MathML",
238
+ mathml: "http://www.w3.org/1998/Math/MathML",
239
+ };
240
+ const namespaceName = (uri) => {
241
+ if (!uri || uri === namespaceUri.html) return "html";
242
+ if (uri === namespaceUri.svg) return "svg";
243
+ if (uri === namespaceUri.math) return "math";
244
+ return uri;
245
+ };
246
+ const canonical = (node) => {
247
+ if (node.nodeType === Node.TEXT_NODE) return ["text", node.data];
248
+ if (node.nodeType === Node.COMMENT_NODE) return ["comment", node.data];
249
+ if (node.nodeType === Node.DOCUMENT_TYPE_NODE) {
250
+ return ["doctype", node.name || "", node.publicId, node.systemId];
251
+ }
252
+ const attrs = Array.from(node.attributes || [], (attr) => [attr.name, attr.value])
253
+ .sort((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
254
+ const mergeAdjacentText = (nodes) => {
255
+ const merged = [];
256
+ for (const item of nodes) {
257
+ if (item[0] === "text" && merged.length && merged[merged.length - 1][0] === "text") {
258
+ merged[merged.length - 1][1] += item[1];
259
+ } else {
260
+ merged.push(item);
261
+ }
262
+ }
263
+ return merged;
264
+ };
265
+ const children = mergeAdjacentText(Array.from(node.childNodes, canonical));
266
+ const content = node.localName === "template" && node.namespaceURI === namespaceUri.html
267
+ ? mergeAdjacentText(Array.from(node.content.childNodes, canonical))
268
+ : null;
269
+ return ["element", namespaceName(node.namespaceURI), node.localName, attrs, children, content];
270
+ };
271
+ return cases.map((item) => {
272
+ let nodes;
273
+ if (item.context_name === null) {
274
+ const document = new DOMParser().parseFromString(item.html, "text/html");
275
+ nodes = Array.from(document.childNodes);
276
+ } else {
277
+ const owner = document.implementation.createHTMLDocument("");
278
+ const namespace = namespaceUri[item.context_namespace || "html"] || item.context_namespace;
279
+ const context = owner.createElementNS(namespace, item.context_name);
280
+ context.innerHTML = item.html;
281
+ nodes = item.context_name.toLowerCase() === "template" && namespace === namespaceUri.html
282
+ ? Array.from(context.content.childNodes)
283
+ : Array.from(context.childNodes);
284
+ }
285
+ return nodes.map(canonical);
286
+ });
287
+ }
288
+ """
289
+
290
+
291
+ def _render_chromium(cases: list[Case], executable: str, chunk_size: int) -> list[list[Any]]:
292
+ try:
293
+ from playwright.sync_api import sync_playwright # noqa: PLC0415
294
+ except ImportError as exc: # pragma: no cover - depends on benchmark environment
295
+ raise SystemExit("Playwright is required for the Chromium differential") from exc
296
+
297
+ output: list[list[Any]] = []
298
+ with sync_playwright() as playwright:
299
+ browser = playwright.chromium.launch(executable_path=executable, headless=True)
300
+ page = browser.new_page()
301
+ for start in range(0, len(cases), chunk_size):
302
+ payload = [
303
+ {
304
+ "html": case.html,
305
+ "context_name": case.context_name,
306
+ "context_namespace": case.context_namespace,
307
+ }
308
+ for case in cases[start : start + chunk_size]
309
+ ]
310
+ rendered = page.evaluate(f"(cases) => JSON.stringify(({_CHROMIUM_RENDER})(cases))", payload)
311
+ output.extend(json.loads(rendered))
312
+ browser.close()
313
+ return output
314
+
315
+
316
+ def main() -> int:
317
+ parser = argparse.ArgumentParser(description=__doc__)
318
+ parser.add_argument("--chrome", default="/usr/bin/google-chrome")
319
+ parser.add_argument("--html5lib-limit", type=int)
320
+ parser.add_argument("--generated", type=int, default=0)
321
+ parser.add_argument("--atomic-generated", type=int, default=0, help="Cases per individual fuzz strategy")
322
+ parser.add_argument("--seed", type=int, default=20260621)
323
+ parser.add_argument("--chunk-size", type=int, default=250)
324
+ parser.add_argument("--examples", type=int, default=20)
325
+ parser.add_argument("--json-output", type=Path)
326
+ args = parser.parse_args()
327
+
328
+ cases = _html5lib_cases(args.html5lib_limit)
329
+ cases.extend(_generated_cases(args.generated, args.seed))
330
+ cases.extend(_atomic_generated_cases(args.atomic_generated, args.seed))
331
+ chromium = _render_chromium(cases, args.chrome, args.chunk_size)
332
+
333
+ mismatches: list[dict[str, Any]] = []
334
+ known_divergences: list[dict[str, Any]] = []
335
+ exceptions: list[dict[str, str]] = []
336
+ for case, expected in zip(cases, chromium, strict=True):
337
+ try:
338
+ actual = _render_justhtml(case)
339
+ except Exception as exc: # noqa: BLE001 - differential must continue
340
+ exceptions.append({"source": case.source, "html": case.html, "exception": f"{type(exc).__name__}: {exc}"})
341
+ continue
342
+ if actual != expected:
343
+ mismatch = {
344
+ "source": case.source,
345
+ "html": case.html,
346
+ "context_name": case.context_name,
347
+ "context_namespace": case.context_namespace,
348
+ "justhtml": actual,
349
+ "chromium": expected,
350
+ }
351
+ (known_divergences if _is_known_divergence(case, actual, expected) else mismatches).append(mismatch)
352
+
353
+ print(f"chromium: {args.chrome}")
354
+ print(f"cases: {len(cases)}")
355
+ print(f"exact: {len(cases) - len(mismatches) - len(known_divergences) - len(exceptions)}")
356
+ print(f"known_chromium_divergences: {len(known_divergences)}")
357
+ print(f"mismatches: {len(mismatches)}")
358
+ print(f"exceptions: {len(exceptions)}")
359
+ for mismatch in mismatches[: args.examples]:
360
+ print(f"\n{mismatch['source']}: {mismatch['html']!r}")
361
+ print(f" justhtml: {json.dumps(mismatch['justhtml'], ensure_ascii=False)}")
362
+ print(f" chromium: {json.dumps(mismatch['chromium'], ensure_ascii=False)}")
363
+ for exception in exceptions[: args.examples]:
364
+ print(f"\n{exception['source']}: {exception['html']!r}")
365
+ print(f" exception: {exception['exception']}")
366
+
367
+ if args.json_output is not None:
368
+ args.json_output.write_text(
369
+ json.dumps(
370
+ {
371
+ "known_chromium_divergences": known_divergences,
372
+ "mismatches": mismatches,
373
+ "exceptions": exceptions,
374
+ },
375
+ ensure_ascii=False,
376
+ indent=2,
377
+ )
378
+ )
379
+ return int(bool(mismatches or exceptions))
380
+
381
+
382
+ if __name__ == "__main__":
383
+ raise SystemExit(main())
@@ -230,22 +230,22 @@ def compare_outputs(expected, actual):
230
230
 
231
231
  def run_test_justhtml(html, fragment_context, expected, xml_coercion=False, iframe_srcdoc=False):
232
232
  """Run a single test with JustHTML."""
233
- from justhtml.tokenizer import TokenizerOpts
233
+ from justhtml.parser.options import ParserOptions
234
234
 
235
235
  try:
236
- opts = TokenizerOpts(xml_coercion=xml_coercion)
236
+ opts = ParserOptions(xml_coercion=xml_coercion)
237
237
  if fragment_context:
238
238
  namespace, tag_name = fragment_context
239
239
  ctx = FragmentContext(tag_name, namespace)
240
240
  parser = JustHTML(
241
241
  html,
242
242
  fragment_context=ctx,
243
- _tokenizer_opts=opts,
243
+ _parser_opts=opts,
244
244
  iframe_srcdoc=iframe_srcdoc,
245
245
  sanitize=False,
246
246
  )
247
247
  else:
248
- parser = JustHTML(html, _tokenizer_opts=opts, iframe_srcdoc=iframe_srcdoc, sanitize=False)
248
+ parser = JustHTML(html, _parser_opts=opts, iframe_srcdoc=iframe_srcdoc, sanitize=False)
249
249
  actual = to_test_format(parser.root)
250
250
  return _compare_result(expected, actual)
251
251
  except Exception as e: