justhtml 3.5.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.5.0 → justhtml-3.6.0}/CHANGELOG.md +30 -0
  2. {justhtml-3.5.0 → justhtml-3.6.0}/PKG-INFO +1 -1
  3. justhtml-3.6.0/project_ideas.md +47 -0
  4. {justhtml-3.5.0 → justhtml-3.6.0}/pyproject.toml +1 -1
  5. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/parser/engine.py +180 -56
  6. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/parser/scanner.py +21 -12
  7. {justhtml-3.5.0 → justhtml-3.6.0}/tests/justhtml-tests/document_shell_recovery.dat +13 -0
  8. {justhtml-3.5.0 → justhtml-3.6.0}/tests/justhtml-tests/foreign_content.dat +141 -0
  9. {justhtml-3.5.0 → justhtml-3.6.0}/tests/justhtml-tests/head_noscript_recovery.dat +12 -0
  10. justhtml-3.6.0/tests/justhtml-tests/in_body_recovery.dat +66 -0
  11. {justhtml-3.5.0 → justhtml-3.6.0}/tests/justhtml-tests/select_recovery.dat +43 -0
  12. {justhtml-3.5.0 → justhtml-3.6.0}/tests/justhtml-tests/template_recovery.dat +98 -0
  13. {justhtml-3.5.0 → justhtml-3.6.0}/tests/test_parser_engine.py +18 -0
  14. {justhtml-3.5.0 → justhtml-3.6.0}/tests/test_stream.py +26 -1
  15. justhtml-3.5.0/tests/justhtml-tests/in_body_recovery.dat +0 -12
  16. {justhtml-3.5.0 → justhtml-3.6.0}/.github/copilot-instructions.md +0 -0
  17. {justhtml-3.5.0 → justhtml-3.6.0}/.github/workflows/ci.yml +0 -0
  18. {justhtml-3.5.0 → justhtml-3.6.0}/.github/workflows/publish.yml +0 -0
  19. {justhtml-3.5.0 → justhtml-3.6.0}/.gitignore +0 -0
  20. {justhtml-3.5.0 → justhtml-3.6.0}/.pre-commit-config.yaml +0 -0
  21. {justhtml-3.5.0 → justhtml-3.6.0}/AGENTS.md +0 -0
  22. {justhtml-3.5.0 → justhtml-3.6.0}/CODE_OF_CONDUCT.md +0 -0
  23. {justhtml-3.5.0 → justhtml-3.6.0}/CONTRIBUTING.md +0 -0
  24. {justhtml-3.5.0 → justhtml-3.6.0}/LICENSE +0 -0
  25. {justhtml-3.5.0 → justhtml-3.6.0}/Makefile +0 -0
  26. {justhtml-3.5.0 → justhtml-3.6.0}/PERFORMANCE.md +0 -0
  27. {justhtml-3.5.0 → justhtml-3.6.0}/README.md +0 -0
  28. {justhtml-3.5.0 → justhtml-3.6.0}/SECURITY.md +0 -0
  29. {justhtml-3.5.0 → justhtml-3.6.0}/assets/justhtml-readme-explainer.png +0 -0
  30. {justhtml-3.5.0 → justhtml-3.6.0}/benchmarks/chromium_parser_diff.py +0 -0
  31. {justhtml-3.5.0 → justhtml-3.6.0}/benchmarks/correctness.py +0 -0
  32. {justhtml-3.5.0 → justhtml-3.6.0}/benchmarks/fuzz.py +0 -0
  33. {justhtml-3.5.0 → justhtml-3.6.0}/benchmarks/html5lib_engine_diff.py +0 -0
  34. {justhtml-3.5.0 → justhtml-3.6.0}/benchmarks/parser_engine_gate.py +0 -0
  35. {justhtml-3.5.0 → justhtml-3.6.0}/benchmarks/performance.py +0 -0
  36. {justhtml-3.5.0 → justhtml-3.6.0}/benchmarks/profile.py +0 -0
  37. {justhtml-3.5.0 → justhtml-3.6.0}/docs/_config.yml +0 -0
  38. {justhtml-3.5.0 → justhtml-3.6.0}/docs/_layouts/default.html +0 -0
  39. {justhtml-3.5.0 → justhtml-3.6.0}/docs/api.md +0 -0
  40. {justhtml-3.5.0 → justhtml-3.6.0}/docs/assets/search.js +0 -0
  41. {justhtml-3.5.0 → justhtml-3.6.0}/docs/bleach-migration.md +0 -0
  42. {justhtml-3.5.0 → justhtml-3.6.0}/docs/building.md +0 -0
  43. {justhtml-3.5.0 → justhtml-3.6.0}/docs/cli.md +0 -0
  44. {justhtml-3.5.0 → justhtml-3.6.0}/docs/comparison.md +0 -0
  45. {justhtml-3.5.0 → justhtml-3.6.0}/docs/correctness.md +0 -0
  46. {justhtml-3.5.0 → justhtml-3.6.0}/docs/encoding.md +0 -0
  47. {justhtml-3.5.0 → justhtml-3.6.0}/docs/errors.md +0 -0
  48. {justhtml-3.5.0 → justhtml-3.6.0}/docs/fragments.md +0 -0
  49. {justhtml-3.5.0 → justhtml-3.6.0}/docs/html-cleaning.md +0 -0
  50. {justhtml-3.5.0 → justhtml-3.6.0}/docs/index.md +0 -0
  51. {justhtml-3.5.0 → justhtml-3.6.0}/docs/linkify.md +0 -0
  52. {justhtml-3.5.0 → justhtml-3.6.0}/docs/migration-examples.md +0 -0
  53. {justhtml-3.5.0 → justhtml-3.6.0}/docs/playground/app.js +0 -0
  54. {justhtml-3.5.0 → justhtml-3.6.0}/docs/playground/index.html +0 -0
  55. {justhtml-3.5.0 → justhtml-3.6.0}/docs/playground/py/__init__.py +0 -0
  56. {justhtml-3.5.0 → justhtml-3.6.0}/docs/playground/py/install_latest_justhtml.py +0 -0
  57. {justhtml-3.5.0 → justhtml-3.6.0}/docs/playground/py/render.py +0 -0
  58. {justhtml-3.5.0 → justhtml-3.6.0}/docs/playground/py/use_local_repo.py +0 -0
  59. {justhtml-3.5.0 → justhtml-3.6.0}/docs/quickstart.md +0 -0
  60. {justhtml-3.5.0 → justhtml-3.6.0}/docs/sanitization.md +0 -0
  61. {justhtml-3.5.0 → justhtml-3.6.0}/docs/selectors.md +0 -0
  62. {justhtml-3.5.0 → justhtml-3.6.0}/docs/streaming.md +0 -0
  63. {justhtml-3.5.0 → justhtml-3.6.0}/docs/text.md +0 -0
  64. {justhtml-3.5.0 → justhtml-3.6.0}/docs/transforms.md +0 -0
  65. {justhtml-3.5.0 → justhtml-3.6.0}/docs/unsafe-handling.md +0 -0
  66. {justhtml-3.5.0 → justhtml-3.6.0}/docs/url-cleaning.md +0 -0
  67. {justhtml-3.5.0 → justhtml-3.6.0}/llms.txt +0 -0
  68. {justhtml-3.5.0 → justhtml-3.6.0}/run_tests.py +0 -0
  69. {justhtml-3.5.0 → justhtml-3.6.0}/scripts/release.py +0 -0
  70. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/__init__.py +0 -0
  71. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/__main__.py +0 -0
  72. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/core/__init__.py +0 -0
  73. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/core/constants.py +0 -0
  74. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/core/doctype.py +0 -0
  75. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/core/entities.py +0 -0
  76. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/core/errors.py +0 -0
  77. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/core/rawtext.py +0 -0
  78. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/core/text.py +0 -0
  79. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/core/types.py +0 -0
  80. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/dom/__init__.py +0 -0
  81. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/dom/builder.py +0 -0
  82. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/parser/__init__.py +0 -0
  83. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/parser/context.py +0 -0
  84. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/parser/encoding.py +0 -0
  85. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/parser/options.py +0 -0
  86. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/parser/stream.py +0 -0
  87. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/py.typed +0 -0
  88. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/sanitizer/__init__.py +0 -0
  89. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/sanitizer/css.py +0 -0
  90. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/sanitizer/dom.py +0 -0
  91. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/sanitizer/policy.py +0 -0
  92. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/sanitizer/policy_defaults.py +0 -0
  93. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/sanitizer/rawtext.py +0 -0
  94. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/sanitizer/url/__init__.py +0 -0
  95. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/sanitizer/url/policy.py +0 -0
  96. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/sanitizer/url/runtime.py +0 -0
  97. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/sanitizer/url/spec.py +0 -0
  98. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/selector/__init__.py +0 -0
  99. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/selector/core.py +0 -0
  100. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/serializer/__init__.py +0 -0
  101. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/serializer/html.py +0 -0
  102. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/serializer/markdown.py +0 -0
  103. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/transforms/__init__.py +0 -0
  104. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/transforms/compile.py +0 -0
  105. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/transforms/linkify.py +0 -0
  106. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/transforms/linkify_core.py +0 -0
  107. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/transforms/runtime.py +0 -0
  108. {justhtml-3.5.0 → justhtml-3.6.0}/src/justhtml/transforms/spec.py +0 -0
  109. {justhtml-3.5.0 → justhtml-3.6.0}/test-summary.txt +0 -0
  110. {justhtml-3.5.0 → justhtml-3.6.0}/tests/README.md +0 -0
  111. {justhtml-3.5.0 → justhtml-3.6.0}/tests/__init__.py +0 -0
  112. {justhtml-3.5.0 → justhtml-3.6.0}/tests/data/wikipedia.html +0 -0
  113. {justhtml-3.5.0 → justhtml-3.6.0}/tests/harness/__init__.py +0 -0
  114. {justhtml-3.5.0 → justhtml-3.6.0}/tests/harness/encoding.py +0 -0
  115. {justhtml-3.5.0 → justhtml-3.6.0}/tests/harness/regressions.py +0 -0
  116. {justhtml-3.5.0 → justhtml-3.6.0}/tests/harness/reporter.py +0 -0
  117. {justhtml-3.5.0 → justhtml-3.6.0}/tests/harness/serializer.py +0 -0
  118. {justhtml-3.5.0 → justhtml-3.6.0}/tests/harness/tree.py +0 -0
  119. {justhtml-3.5.0 → justhtml-3.6.0}/tests/justhtml-sanitize-tests/cases.json +0 -0
  120. {justhtml-3.5.0 → justhtml-3.6.0}/tests/justhtml-tests/character_preprocessing.dat +0 -0
  121. {justhtml-3.5.0 → justhtml-3.6.0}/tests/justhtml-tests/entities.test +0 -0
  122. {justhtml-3.5.0 → justhtml-3.6.0}/tests/justhtml-tests/foreign_stack_recovery.dat +0 -0
  123. {justhtml-3.5.0 → justhtml-3.6.0}/tests/justhtml-tests/frameset_recovery.dat +0 -0
  124. {justhtml-3.5.0 → justhtml-3.6.0}/tests/justhtml-tests/iframe_srcdoc.dat +0 -0
  125. {justhtml-3.5.0 → justhtml-3.6.0}/tests/justhtml-tests/selectedcontent.dat +0 -0
  126. {justhtml-3.5.0 → justhtml-3.6.0}/tests/justhtml-tests/table_recovery.dat +0 -0
  127. {justhtml-3.5.0 → justhtml-3.6.0}/tests/justhtml-tests/tokenizer_state_transitions.test +0 -0
  128. {justhtml-3.5.0 → justhtml-3.6.0}/tests/justhtml-tests/tokenizer_states.test +0 -0
  129. {justhtml-3.5.0 → justhtml-3.6.0}/tests/justhtml-tests/xml_coercion.dat +0 -0
  130. {justhtml-3.5.0 → justhtml-3.6.0}/tests/justhtml-tests/xml_coercion_invalid_characters.test +0 -0
  131. {justhtml-3.5.0 → justhtml-3.6.0}/tests/linkify-it/LICENSE.txt +0 -0
  132. {justhtml-3.5.0 → justhtml-3.6.0}/tests/linkify-it/README.md +0 -0
  133. {justhtml-3.5.0 → justhtml-3.6.0}/tests/linkify-it/fixtures/links.txt +0 -0
  134. {justhtml-3.5.0 → justhtml-3.6.0}/tests/linkify-it/fixtures/not_links.txt +0 -0
  135. {justhtml-3.5.0 → justhtml-3.6.0}/tests/test_builder.py +0 -0
  136. {justhtml-3.5.0 → justhtml-3.6.0}/tests/test_cli.py +0 -0
  137. {justhtml-3.5.0 → justhtml-3.6.0}/tests/test_docs_examples.py +0 -0
  138. {justhtml-3.5.0 → justhtml-3.6.0}/tests/test_encoding.py +0 -0
  139. {justhtml-3.5.0 → justhtml-3.6.0}/tests/test_entities.py +0 -0
  140. {justhtml-3.5.0 → justhtml-3.6.0}/tests/test_errors.py +0 -0
  141. {justhtml-3.5.0 → justhtml-3.6.0}/tests/test_linkify_internals.py +0 -0
  142. {justhtml-3.5.0 → justhtml-3.6.0}/tests/test_linkify_it.py +0 -0
  143. {justhtml-3.5.0 → justhtml-3.6.0}/tests/test_linkify_transform.py +0 -0
  144. {justhtml-3.5.0 → justhtml-3.6.0}/tests/test_node.py +0 -0
  145. {justhtml-3.5.0 → justhtml-3.6.0}/tests/test_parser_regressions.py +0 -0
  146. {justhtml-3.5.0 → justhtml-3.6.0}/tests/test_parser_tree.py +0 -0
  147. {justhtml-3.5.0 → justhtml-3.6.0}/tests/test_playground_local_repo_file_list.py +0 -0
  148. {justhtml-3.5.0 → justhtml-3.6.0}/tests/test_sanitize.py +0 -0
  149. {justhtml-3.5.0 → justhtml-3.6.0}/tests/test_sanitize_integration.py +0 -0
  150. {justhtml-3.5.0 → justhtml-3.6.0}/tests/test_sanitize_transform.py +0 -0
  151. {justhtml-3.5.0 → justhtml-3.6.0}/tests/test_selector.py +0 -0
  152. {justhtml-3.5.0 → justhtml-3.6.0}/tests/test_serialize.py +0 -0
  153. {justhtml-3.5.0 → justhtml-3.6.0}/tests/test_transform_runtime.py +0 -0
  154. {justhtml-3.5.0 → justhtml-3.6.0}/tests/test_transforms.py +0 -0
  155. {justhtml-3.5.0 → justhtml-3.6.0}/tests/test_transforms_compiler.py +0 -0
  156. {justhtml-3.5.0 → justhtml-3.6.0}/tests/test_transforms_sanitize_integration.py +0 -0
  157. {justhtml-3.5.0 → justhtml-3.6.0}/tests/test_wikipedia.py +0 -0
@@ -7,6 +7,36 @@ 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
+
10
40
  ## [3.5.0] - 2026-07-13
11
41
 
12
42
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: justhtml
3
- Version: 3.5.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
@@ -0,0 +1,47 @@
1
+ # Project Ideas
2
+
3
+ ## Treebuilder Refactors
4
+
5
+ ### Open element stack helper
6
+
7
+ Replace direct `open_elements` list mutation with an `OpenElementStack` helper. The helper would own push, pop, remove, replace, and truncate operations, and it would keep cached counters for common stack queries such as open `<p>` elements, template presence, and scope-relevant names.
8
+
9
+ Expected value: high maintainability, safer future performance caches, and fewer hidden invariants around `_note_open_element_pushed()` and `_note_open_element_removed()`.
10
+
11
+ Performance requirement: no meaningful regression on the general `web100k` benchmark.
12
+
13
+ ### Active formatting list helper
14
+
15
+ Move active formatting marker and entry operations into an `ActiveFormattingList` helper. It would own marker handling, duplicate detection, lookup by name, lookup by node, removal, and clear-to-marker behavior.
16
+
17
+ Expected value: lower adoption-agency complexity and safer future optimizations around active formatting reconstruction.
18
+
19
+ ### Declarative insertion-mode dispatch
20
+
21
+ Generate or centralize start/end tag dispatch tables for major insertion modes, especially `IN_BODY`, table modes, and select mode. Keep fallback behavior explicit so tag priority remains easy to audit.
22
+
23
+ Expected value: possible hot-path speedup and clearer mode behavior. Needs before/after benchmark validation because dispatch overhead may vary.
24
+
25
+ ### Split insertion modes into smaller modules
26
+
27
+ Break `modes.py` into mode-group modules such as head, body, table, select, template, and foreign-content handling while keeping the public `TreeBuilder` API unchanged.
28
+
29
+ Expected value: large maintainability improvement and easier auditing. Performance should be neutral if done mechanically.
30
+
31
+ ### Stack index helpers
32
+
33
+ Centralize repeated reverse stack scans into helpers such as `_find_open_element_index()`, `_find_last_open_element_index()`, and `_truncate_open_elements_from()`. Add caching only where benchmarks prove it helps.
34
+
35
+ Expected value: clearer code first, optional targeted performance improvements later.
36
+
37
+ ### Separate parsing decisions from DOM mutation
38
+
39
+ Create a tighter mutation API for element insertion, text insertion, foster parenting, child movement, and stack popping. Keep mode handlers focused on parser decisions.
40
+
41
+ Expected value: maintainability and auditability. Runtime effect must be measured because extra calls could slow hot paths.
42
+
43
+ ### Narrow internal node types
44
+
45
+ Replace some `Any` usage around stack nodes and active formatting entries with protocols or narrower internal types.
46
+
47
+ Expected value: safer refactors and better mypy coverage. Runtime effect should be neutral.
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "justhtml"
3
3
  authors = [{ name = "Emil Stenström", email = "emil@emilstenstrom.se" }]
4
- version = "3.5.0"
4
+ version = "3.6.0"
5
5
  description = "A pure Python HTML5 parser that just works."
6
6
  readme = "README.md"
7
7
  license = { file = "LICENSE" }
@@ -138,6 +138,15 @@ _P_CLOSING_START_TAGS = {
138
138
  _HEAD_NOSCRIPT_ALLOWED_START_TAGS = {"basefont", "bgsound", "link", "meta", "noframes", "style"}
139
139
  _HEAD_NOSCRIPT_VOID_START_TAGS = {"basefont", "bgsound"}
140
140
  _HEAD_ONLY_VOID_START_TAGS = {"basefont", "bgsound"}
141
+ # In "in body", base/basefont/bgsound/link/meta are processed with the "in head"
142
+ # rules (§13.2.6.4.7), which insert-and-pop without reconstructing the active
143
+ # formatting elements. Reconstructing here would wrongly wrap them (and their
144
+ # following siblings) in a stale formatting element.
145
+ _INLINE_HEAD_VOID_START_TAGS = {"base", "basefont", "bgsound", "link", "meta"}
146
+ # The in-body rb/rp/rt/rtc rules (§13.2.6.4.7) only generate implied end tags
147
+ # and insert; unlike "any other start tag" they do not reconstruct the active
148
+ # formatting elements.
149
+ _RUBY_START_TAGS = {"rb", "rp", "rt", "rtc"}
141
150
  _HTML_VOID_COMPAT_TAGS = {"basefont", "bgsound", "frame", "keygen"}
142
151
  _DEFINITION_SCOPE_BOUNDARIES = frozenset(DEFINITION_SCOPE_TERMINATORS)
143
152
  _LIST_ITEM_SCOPE_BOUNDARIES = frozenset(LIST_ITEM_SCOPE_TERMINATORS)
@@ -160,6 +169,20 @@ _TEMPLATE_MODE_COLGROUP = "colgroup"
160
169
  _TEMPLATE_TABLE_CONTEXT_START_TAGS = {"caption", "col", "colgroup", "tbody", "td", "tfoot", "th", "thead", "tr"}
161
170
  _TEMPLATE_TABLE_BODY_IGNORED_START_TAGS = {"caption", "col", "colgroup", "table"}
162
171
  _TEMPLATE_ROW_STRUCTURE_START_TAGS = {"caption", "col", "colgroup", "tbody", "tfoot", "thead", "tr", "table"}
172
+ # Start tags that move the "in template" mode into the body without going through
173
+ # _handle_template_mode_start: void head elements handled inline plus the raw
174
+ # text / RCDATA elements that are not head content (iframe, noembed, xmp,
175
+ # textarea), which are dispatched before the template-mode handler runs.
176
+ _TEMPLATE_INITIAL_BODY_SWITCH_TAGS = {
177
+ "base",
178
+ "basefont",
179
+ "bgsound",
180
+ "noframes",
181
+ "iframe",
182
+ "noembed",
183
+ "xmp",
184
+ "textarea",
185
+ }
163
186
  _FRAMESET_BODY_OK_TAGS = {"div", "figure", "p", "param", "source", "track"} | FORMATTING_ELEMENTS
164
187
  _FRAMESET_BLOCKING_START_TAGS = {
165
188
  "applet",
@@ -931,8 +954,6 @@ class ParseEngine:
931
954
  return False
932
955
  if name in {"br", "p"}:
933
956
  return False
934
- if name in _TABLE_SCOPED_END_TAGS:
935
- return False
936
957
 
937
958
  crossed_integration_point = False
938
959
  for idx in range(len(stack) - 1, 0, -1):
@@ -942,6 +963,12 @@ class ParseEngine:
942
963
  return True
943
964
  if self._fragment_context_node is not None and node is self._fragment_context_node:
944
965
  return True
966
+ if node.namespace in {None, "html", _PARSER_ONLY_NAMESPACE}:
967
+ # The foreign-content walk reached a matching HTML element
968
+ # (§13.2.6.5 steps 6-7): hand the token to the HTML end-tag
969
+ # rules, which may splice a form or run adoption, rather than
970
+ # popping the foreign elements sitting above it.
971
+ return False
945
972
  if self._track_tag_spans:
946
973
  self._set_end_span(node, name, tag_start, tag_end)
947
974
  del stack[idx:]
@@ -1279,6 +1306,7 @@ class ParseEngine:
1279
1306
  parent: Node
1280
1307
  if self._current_parent() is self._head or (
1281
1308
  not self._fragment
1309
+ and not self._after_head
1282
1310
  and self._head is not None
1283
1311
  and not self._body_mode_seen
1284
1312
  and not self._body_has_content()
@@ -1931,7 +1959,14 @@ class ParseEngine:
1931
1959
  self._insert_at(parent, position, node)
1932
1960
  return
1933
1961
  foster = None
1934
- if parent.name in self._table_foster_targets:
1962
+ # Foster parenting only applies in HTML table insertion modes. A
1963
+ # foreign element that merely shares a name with a table foster
1964
+ # target (e.g. an SVG or MathML "tr") keeps character data as its
1965
+ # own child instead.
1966
+ parent_is_table_foster = parent.name in self._table_foster_targets and getattr(
1967
+ parent, "namespace", None
1968
+ ) in {None, "html", _PARSER_ONLY_NAMESPACE}
1969
+ if parent_is_table_foster:
1935
1970
  is_table_space = text_is_space if text_is_space is not None else text.strip(_SPACE) == ""
1936
1971
  if not is_table_space:
1937
1972
  if pending_table_whitespace:
@@ -1961,7 +1996,7 @@ class ParseEngine:
1961
1996
  node = self._new_text(text, source_pos) if self._track_node_locations else Text(text)
1962
1997
  children.append(node)
1963
1998
  node.parent = parent
1964
- if parent.name in self._table_foster_targets and is_table_space:
1999
+ if parent_is_table_foster and is_table_space:
1965
2000
  self._foster_next_table_whitespace = pending_table_whitespace + len(text)
1966
2001
  else:
1967
2002
  foster_parent, position = foster
@@ -2002,9 +2037,15 @@ class ParseEngine:
2002
2037
  else:
2003
2038
  public_id = None
2004
2039
  system_id = None
2005
- doctype = Doctype(name, public_id, system_id, force_quirks=name is None)
2040
+ # Junk after the name with no PUBLIC/SYSTEM keyword lands in the
2041
+ # "after DOCTYPE name" state, which sets the force-quirks flag
2042
+ # (§13.2.5.55). The regex tolerates that trailing text, so detect
2043
+ # it here to reproduce the quirks-mode transition.
2044
+ trailing = raw[match.end() :].strip("\t\n\f\r ")
2045
+ bogus = kind is None and bool(trailing)
2046
+ doctype = Doctype(name, public_id, system_id, force_quirks=name is None or bogus)
2006
2047
  doctype_error, self._quirks_mode = doctype_error_and_quirks(doctype, self._iframe_srcdoc)
2007
- if doctype_error and self._collect_errors:
2048
+ if (doctype_error or bogus) and self._collect_errors:
2008
2049
  self._emit_error(
2009
2050
  "unknown-doctype",
2010
2051
  doctype_end if gt != -1 else max(0, end - 1),
@@ -2297,8 +2338,21 @@ class ParseEngine:
2297
2338
  self._body_mode_seen = True
2298
2339
 
2299
2340
  if not self._fragment and name in {"html", "body"}:
2300
- parent_name = getattr(self._current_parent(), "name", None)
2301
- if self._find_open_index("table") is not None and parent_name not in {"body", "html"}:
2341
+ if self._find_open_html_index("select") is not None:
2342
+ # In the "in select" insertion mode, </html> and </body> are
2343
+ # unhandled end tags and must be ignored rather than switching to
2344
+ # the after-body/after-html modes. Any elements kept inside the
2345
+ # open select remain part of that insertion mode.
2346
+ return pos
2347
+ if (
2348
+ self._find_open_html_index("body") is not None
2349
+ and self._find_open_index_before_boundary("body", _DEFAULT_SCOPE_BOUNDARIES) is None
2350
+ ):
2351
+ # With body on the open stack, </body> and </html> are ignored
2352
+ # unless body is in scope (§13.2.6.4.7); an open scope marker such
2353
+ # as marquee, object, or applet keeps it out of scope. When body
2354
+ # is not on the stack yet (in head/after head), fall through so
2355
+ # the head-to-body transition still runs.
2302
2356
  return pos
2303
2357
  self._in_colgroup = False
2304
2358
  if self._head is not None and self._stack[-1] is self._head:
@@ -2369,11 +2423,16 @@ class ParseEngine:
2369
2423
  self._form_element = None
2370
2424
  if form_node is None:
2371
2425
  return pos
2426
+ # §13.2.6.4.7: ignore </form> when the form element is not in scope,
2427
+ # e.g. behind an open <table>. Without this the form is removed from
2428
+ # the stack and following content escapes it.
2429
+ form_idx = self._find_open_index_before_boundary("form", _DEFAULT_SCOPE_BOUNDARIES)
2430
+ if form_idx is None or self._stack[form_idx] is not form_node:
2431
+ return pos
2432
+ # form_node is confirmed in scope on the stack and implied end tags
2433
+ # never pop a form, so the removal below always succeeds.
2372
2434
  self._generate_implied_end_tags()
2373
- try:
2374
- self._stack.remove(form_node)
2375
- except ValueError:
2376
- pass
2435
+ self._stack.remove(form_node)
2377
2436
  return pos
2378
2437
  select_idx = self._find_open_html_index("select")
2379
2438
  if select_idx is not None and name not in {"optgroup", "option", "select", "selectedcontent", "template"}:
@@ -2494,7 +2553,7 @@ class ParseEngine:
2494
2553
  elif name == "summary":
2495
2554
  idx = self._find_open_index_before_boundary(name, _DEFAULT_SCOPE_BOUNDARIES)
2496
2555
  elif self._template_modes:
2497
- idx = self._find_open_index_in_current_scope(name)
2556
+ idx = self._find_open_index_before_boundary(name, self._template_end_tag_boundaries(name, action))
2498
2557
  elif name not in self._special_elements and (action is None or not action.p_closing):
2499
2558
  idx = self._find_open_index_before_boundary(name, _GENERAL_END_TAG_BOUNDARIES)
2500
2559
  else:
@@ -2671,12 +2730,9 @@ class ParseEngine:
2671
2730
  self._close_until("select")
2672
2731
  return pos
2673
2732
 
2674
- if self._current_template_mode() == _TEMPLATE_MODE_INITIAL and name in {
2675
- "base",
2676
- "basefont",
2677
- "bgsound",
2678
- "noframes",
2679
- }: # pragma: no cover - compiled sanitizer drops parser-only template contents
2733
+ if (
2734
+ self._current_template_mode() == _TEMPLATE_MODE_INITIAL and name in _TEMPLATE_INITIAL_BODY_SWITCH_TAGS
2735
+ ): # pragma: no cover - compiled sanitizer drops parser-only template contents
2680
2736
  self._set_current_template_mode(_TEMPLATE_MODE_BODY) # pragma: no cover
2681
2737
 
2682
2738
  if in_parser_only_template and self._current_template_mode() == _TEMPLATE_MODE_COLGROUP:
@@ -2981,6 +3037,9 @@ class ParseEngine:
2981
3037
  action is not None
2982
3038
  and allowed
2983
3039
  and action.head_content
3040
+ # "after head" re-enters the head only for the metadata start
3041
+ # tags it lists; noscript is not among them and starts the body.
3042
+ and name != "noscript"
2984
3043
  and self._head is not None
2985
3044
  and not self._in_head_noscript
2986
3045
  and not self._body_mode_seen
@@ -3088,12 +3147,7 @@ class ParseEngine:
3088
3147
  ):
3089
3148
  self._close_html_until("select")
3090
3149
 
3091
- if self._current_template_mode() == _TEMPLATE_MODE_INITIAL and name in {
3092
- "base",
3093
- "basefont",
3094
- "bgsound",
3095
- "noframes",
3096
- }:
3150
+ if self._current_template_mode() == _TEMPLATE_MODE_INITIAL and name in _TEMPLATE_INITIAL_BODY_SWITCH_TAGS:
3097
3151
  self._set_current_template_mode(_TEMPLATE_MODE_BODY)
3098
3152
 
3099
3153
  if html_text_parsing and self._current_template_mode() == _TEMPLATE_MODE_COLGROUP:
@@ -3202,7 +3256,14 @@ class ParseEngine:
3202
3256
  )
3203
3257
  return pos
3204
3258
 
3205
- if html_text_parsing and action is not None and action.active_formatting:
3259
+ if (
3260
+ action is not None
3261
+ and action.active_formatting
3262
+ and (html_text_parsing or self._is_foreign_breakout_start(name, attrs))
3263
+ ):
3264
+ # A formatting element that breaks out of foreign content (e.g. <i>
3265
+ # inside <svg>) becomes an HTML formatting element and must join the
3266
+ # active formatting list so it is reconstructed later.
3206
3267
  return self._parse_formatting_start(name, attrs, pos, tag_start=tag_start, tag_end=tag_end)
3207
3268
 
3208
3269
  if html_text_parsing and name == "menuitem" and self._active_formatting_dirty:
@@ -3270,6 +3331,11 @@ class ParseEngine:
3270
3331
  and not self._body_has_content()
3271
3332
  ):
3272
3333
  parent = self._head
3334
+ elif not html_text_parsing and not self._is_foreign_breakout_start(name, attrs):
3335
+ # A non-breakout start tag inside foreign content is inserted as a
3336
+ # foreign element in the current namespace; the HTML in-body repair
3337
+ # (p-closing, heading/list handling, ...) must not run.
3338
+ parent = self._current_parent()
3273
3339
  else:
3274
3340
  self._repair_stack_for_start(name)
3275
3341
  parent = self._current_parent()
@@ -3312,8 +3378,9 @@ class ParseEngine:
3312
3378
 
3313
3379
  reconstruct_before_insert = (
3314
3380
  name not in _TABLE_STRUCTURE_START_TAGS
3315
- and name not in _HEAD_ONLY_VOID_START_TAGS
3381
+ and name not in _INLINE_HEAD_VOID_START_TAGS
3316
3382
  and name not in {"param", "source", "track"}
3383
+ and name not in _RUBY_START_TAGS
3317
3384
  and name != "template"
3318
3385
  and (action is None or not action.p_closing)
3319
3386
  )
@@ -3484,9 +3551,11 @@ class ParseEngine:
3484
3551
  "html",
3485
3552
  }: # pragma: no branch - opposite edge requires invalid parser state
3486
3553
  self._enter_template_mode() # pragma: no cover - unreachable after parser-state guards
3487
- if name in self._table_cell_tags:
3488
- self._push_active_formatting_marker()
3489
- elif name in _ACTIVE_FORMATTING_MARKER_TAGS:
3554
+ # Active-formatting markers are pushed only for the HTML scope-marker
3555
+ # elements. A foreign element that merely shares a name (e.g. an SVG
3556
+ # or MathML "td") must not push one, or the stray marker would block
3557
+ # later reconstruction once the foreign element is popped.
3558
+ if is_html_namespace and (name in self._table_cell_tags or name in _ACTIVE_FORMATTING_MARKER_TAGS):
3490
3559
  self._push_active_formatting_marker()
3491
3560
  return node
3492
3561
 
@@ -3540,9 +3609,10 @@ class ParseEngine:
3540
3609
  self._stack.append(node)
3541
3610
  if type(node) is Template and node.namespace in {None, "html"}:
3542
3611
  self._enter_template_mode()
3543
- if name in self._table_cell_tags:
3544
- self._push_active_formatting_marker()
3545
- elif name in _ACTIVE_FORMATTING_MARKER_TAGS:
3612
+ # Only HTML scope-marker elements push an active-formatting marker; a
3613
+ # foreign element sharing a name (e.g. an SVG or MathML "td") must not,
3614
+ # or the stray marker blocks reconstruction once it is popped.
3615
+ if is_html_namespace and (name in self._table_cell_tags or name in _ACTIVE_FORMATTING_MARKER_TAGS):
3546
3616
  self._push_active_formatting_marker()
3547
3617
  return node
3548
3618
 
@@ -3558,6 +3628,25 @@ class ParseEngine:
3558
3628
  attrs = self._prepare_foreign_attrs(namespace, attrs)
3559
3629
  return name, attrs, namespace
3560
3630
 
3631
+ def _pop_foreign_for_breakout(self) -> None:
3632
+ # Pop the foreign-content elements a breakout start tag returns through,
3633
+ # stopping at the fragment context node and at integration points where
3634
+ # HTML content resumes.
3635
+ while (
3636
+ len(self._stack) > 1
3637
+ and self._stack[-1].namespace not in {None, "html", _PARSER_ONLY_NAMESPACE}
3638
+ and self._stack[-1] is not self._fragment_context_node
3639
+ and not self._is_html_integration_point(self._stack[-1])
3640
+ and not self._is_mathml_text_integration_point(self._stack[-1])
3641
+ ):
3642
+ self._stack.pop()
3643
+
3644
+ def _is_foreign_breakout_start(self, name: str, attrs: dict[str, str | None]) -> bool:
3645
+ # Start tags that break out of foreign content back to HTML (§13.2.6.5).
3646
+ return name in FOREIGN_BREAKOUT_ELEMENTS or (
3647
+ name == "font" and any(attr.lower() in {"color", "face", "size"} for attr in attrs)
3648
+ )
3649
+
3561
3650
  def _namespace_for_raw_start(self, name: str, attrs: dict[str, str | None]) -> str:
3562
3651
  current = self._stack[-1] if self._stack else None
3563
3652
  current_ns = getattr(current, "namespace", None)
@@ -3568,19 +3657,9 @@ class ParseEngine:
3568
3657
  return "svg" if name == "svg" else "math" if name == "math" else "html"
3569
3658
  if current_ns == "math" and current.name == "annotation-xml" and name == "svg":
3570
3659
  return "svg"
3571
- breaks_out = name in FOREIGN_BREAKOUT_ELEMENTS or (
3572
- name == "font" and any(attr.lower() in {"color", "face", "size"} for attr in attrs)
3573
- )
3574
- if not breaks_out:
3660
+ if not self._is_foreign_breakout_start(name, attrs):
3575
3661
  return current_ns or "html"
3576
- while (
3577
- len(self._stack) > 1
3578
- and self._stack[-1].namespace not in {None, "html", _PARSER_ONLY_NAMESPACE}
3579
- and self._stack[-1] is not self._fragment_context_node
3580
- and not self._is_html_integration_point(self._stack[-1])
3581
- and not self._is_mathml_text_integration_point(self._stack[-1])
3582
- ):
3583
- self._stack.pop()
3662
+ self._pop_foreign_for_breakout()
3584
3663
 
3585
3664
  if name == "svg":
3586
3665
  return "svg"
@@ -3732,6 +3811,15 @@ class ParseEngine:
3732
3811
  return None
3733
3812
  return None
3734
3813
 
3814
+ def _template_end_tag_boundaries(self, name: str, action: TagAction | None) -> frozenset[str]:
3815
+ # Generic in-body end tags inside a template still obey scope: block and
3816
+ # special names close within the default scope, while other names stop at
3817
+ # any special element (§13.2.6.4.7 "any other end tag"). The template
3818
+ # element is a member of both sets, so the walk cannot escape it.
3819
+ if name in self._special_elements or (action is not None and action.p_closing):
3820
+ return _DEFAULT_SCOPE_BOUNDARIES
3821
+ return _GENERAL_END_TAG_BOUNDARIES
3822
+
3735
3823
  def _find_open_heading_index(self) -> int | None:
3736
3824
  stack = self._stack
3737
3825
  for idx in range(len(stack) - 1, 0, -1): # pragma: no branch
@@ -3994,7 +4082,7 @@ class ParseEngine:
3994
4082
  del self._stack[table_idx:]
3995
4083
  self._insert_template_mode_element(name, attrs, self_closing)
3996
4084
  return pos
3997
- if table_idx is not None and name in {
4085
+ if name in {
3998
4086
  "caption",
3999
4087
  "col",
4000
4088
  "colgroup",
@@ -4005,7 +4093,12 @@ class ParseEngine:
4005
4093
  "thead",
4006
4094
  "tr",
4007
4095
  }:
4008
- del self._stack[table_idx + 1 :]
4096
+ # Clear back to the table context before opening table structure.
4097
+ # In a template with no table element, that context is the
4098
+ # template contents, so an open caption is popped first.
4099
+ clear_idx = table_idx if table_idx is not None else template_idx
4100
+ if clear_idx is not None: # pragma: no branch - a template is always open in this mode
4101
+ del self._stack[clear_idx + 1 :]
4009
4102
  if name in {"tbody", "tfoot", "thead"}:
4010
4103
  self._set_current_template_mode(_TEMPLATE_MODE_TABLE_BODY)
4011
4104
  self._insert_template_mode_element(name, attrs, self_closing)
@@ -4134,9 +4227,20 @@ class ParseEngine:
4134
4227
  return False # pragma: no cover - unreachable after parser-state guards
4135
4228
  if name in {"html", "head", "body"}:
4136
4229
  return True
4230
+ if mode == _TEMPLATE_MODE_INITIAL:
4231
+ # "In template" handles only </template>; every other end tag with
4232
+ # nothing to close is a parse error that is ignored (§13.2.6.4.18),
4233
+ # including the </br> special case. An element left open in the
4234
+ # template (e.g. a scripting-disabled <noscript>) is still closeable
4235
+ # through the normal in-body handling.
4236
+ if name == "template":
4237
+ return False
4238
+ return self._find_open_index_before_boundary(name, _TEMPLATE_SCOPE_BOUNDARIES) is None
4137
4239
  if mode == _TEMPLATE_MODE_CELL:
4138
4240
  if name in self._table_cell_tags:
4139
- return self._close_template_cell()
4241
+ # Only the matching cell type closes; </th> with a <td> open (or
4242
+ # vice versa) leaves the cell open.
4243
+ return self._close_template_cell(name)
4140
4244
  if name in {"table", "tbody", "tfoot", "thead", "tr"}:
4141
4245
  if self._find_open_index_before_boundary(name, _TEMPLATE_SCOPE_BOUNDARIES) is None:
4142
4246
  return True
@@ -4200,11 +4304,16 @@ class ParseEngine:
4200
4304
  return False
4201
4305
  return False # pragma: no cover - template scope always terminates the scan
4202
4306
 
4203
- def _close_template_cell(self) -> bool:
4307
+ def _close_template_cell(self, name: str | None = None) -> bool:
4204
4308
  stack = self._stack
4205
4309
  for idx in range(len(stack) - 1, 0, -1):
4206
4310
  node_name = stack[idx].name
4207
4311
  if node_name in self._table_cell_tags:
4312
+ # A </td>/</th> end tag only closes the matching cell type; a
4313
+ # mismatched end tag (e.g. </th> for an open <td>) is left for
4314
+ # the generic handling, which ignores it.
4315
+ if name is not None and node_name != name:
4316
+ return False
4208
4317
  self._mark_active_formatting_dirty()
4209
4318
  self._clear_active_formatting_to_marker()
4210
4319
  del stack[idx:]
@@ -4229,7 +4338,8 @@ class ParseEngine:
4229
4338
  def _close_until_before_boundary(self, name: str, boundaries: frozenset[str]) -> bool:
4230
4339
  stack = self._stack
4231
4340
  for idx in range(len(stack) - 1, 0, -1):
4232
- node_name = getattr(stack[idx], "name", None)
4341
+ node = stack[idx]
4342
+ node_name = getattr(node, "name", None)
4233
4343
  if node_name == name:
4234
4344
  if (
4235
4345
  self._fragment_context_node is not None and stack[idx] is self._fragment_context_node
@@ -4238,7 +4348,11 @@ class ParseEngine:
4238
4348
  self._mark_active_formatting_dirty()
4239
4349
  del stack[idx:]
4240
4350
  return True
4241
- if node_name in boundaries:
4351
+ # Scope boundaries are HTML elements. A foreign element that merely
4352
+ # shares a name with a boundary (e.g. an SVG or MathML "td") must not
4353
+ # stop the scan; callers guard this walk with an in-scope check that
4354
+ # already accounts for foreign integration points.
4355
+ if getattr(node, "namespace", None) in {None, "html", _PARSER_ONLY_NAMESPACE} and node_name in boundaries:
4242
4356
  return False
4243
4357
  return False
4244
4358
 
@@ -4301,9 +4415,14 @@ class ParseEngine:
4301
4415
  self._stack.pop()
4302
4416
  return
4303
4417
 
4304
- if name in HEADING_ELEMENTS and getattr(self._stack[-1], "name", None) in HEADING_ELEMENTS:
4305
- self._mark_active_formatting_dirty()
4306
- self._stack.pop()
4418
+ if name in HEADING_ELEMENTS:
4419
+ # Headings are foreign-content breakout elements; pop the foreign
4420
+ # ancestors first so the "current node is a heading" check runs
4421
+ # against the HTML element the breakout returns to (§13.2.6.5).
4422
+ self._pop_foreign_for_breakout()
4423
+ if getattr(self._stack[-1], "name", None) in HEADING_ELEMENTS:
4424
+ self._mark_active_formatting_dirty()
4425
+ self._stack.pop()
4307
4426
 
4308
4427
  def _repair_table_for_start(self, name: str) -> None:
4309
4428
  table_idx = self._find_open_index("table")
@@ -4427,7 +4546,12 @@ class ParseEngine:
4427
4546
  self._refresh_active_formatting_dirty()
4428
4547
 
4429
4548
  def _foster_parent_for(self, parent: Node, *, for_tag: str | None = None) -> tuple[Node, int] | None:
4430
- if getattr(parent, "name", None) not in self._table_foster_targets:
4549
+ # Foster parenting only applies within an HTML table. A foreign element
4550
+ # sharing a name with a foster target (e.g. an SVG or MathML "tr") keeps
4551
+ # its children rather than fostering them out.
4552
+ if getattr(parent, "name", None) not in self._table_foster_targets or getattr(
4553
+ parent, "namespace", None
4554
+ ) not in {None, "html", _PARSER_ONLY_NAMESPACE}:
4431
4555
  return None
4432
4556
  if (
4433
4557
  for_tag is not None and for_tag in self._table_allowed_children
@@ -4481,7 +4605,7 @@ class ParseEngine:
4481
4605
 
4482
4606
  def _find_script_start_marker(self, pos: int, end: int) -> int:
4483
4607
  return _scanner.find_script_start_marker(
4484
- self._html_input, self._lower_input, pos, end
4608
+ self._html_input, self._lower_input, pos, end, end
4485
4609
  ) # pragma: no cover - unreachable after parser-state guards
4486
4610
 
4487
4611
  def _find_tag_end(self, pos: int, end: int) -> int:
@@ -38,7 +38,11 @@ def find_rawtext_end_tag(html: str, lower: str, name: str, pos: int, end: int) -
38
38
  continue
39
39
  tag_end = find_tag_end(html, after_name, end)
40
40
  if tag_end == -1:
41
- if after_name < end and not html[after_name:end].strip(SPACE + "/"):
41
+ if after_name < end:
42
+ # A space or "/" terminated the end-tag name but no ">" follows.
43
+ # At EOF the end tag is still emitted (its attributes dropped), so
44
+ # the raw text ends here. Only a bare "</name" at EOF, with no
45
+ # terminator, stays raw text.
42
46
  return close, end
43
47
  search = after_name
44
48
  continue
@@ -59,10 +63,14 @@ def find_script_end_tag(html: str, lower: str, pos: int, end: int) -> tuple[int
59
63
  if comment_start == -1 or close < comment_start:
60
64
  return close, next_pos
61
65
  escaped = True
62
- search = comment_start + 4
66
+ # After "<!--" the tokenizer is in the script-data-escaped-dash-dash
67
+ # state, so the two trailing dashes already count toward a closing
68
+ # "-->". Resume the comment-end search at those dashes so "<!-->"
69
+ # leaves the escaped state instead of being treated as still open.
70
+ search = comment_start + 2
63
71
  continue
64
72
 
65
- script_start = find_script_start_marker(html, lower, search, close) if not double_escaped else -1
73
+ script_start = find_script_start_marker(html, lower, search, close, end) if not double_escaped else -1
66
74
  comment_end = lower.find("-->", search, close)
67
75
  if (
68
76
  comment_end != -1
@@ -85,20 +93,21 @@ def find_script_end_tag(html: str, lower: str, pos: int, end: int) -> tuple[int
85
93
  double_escaped = False
86
94
  search = close + 8
87
95
  continue
88
- double_end = lower.find("</script", search, close)
89
- if double_end != -1:
90
- after_name = double_end + 8
91
- if after_name >= end or html[after_name] in TAG_END_NAME_STOP:
92
- double_escaped = False
93
- search = after_name
94
- continue
96
+ # No earlier terminated "</script" can precede `close`: find_rawtext_end_tag
97
+ # already returns the first terminated end tag, so the region between
98
+ # `search` and `close` holds only non-terminated occurrences.
95
99
  double_escaped = False
96
100
  search = next_pos
97
101
  continue
98
102
  return close, next_pos
99
103
 
100
104
 
101
- def find_script_start_marker(html: str, lower: str, pos: int, end: int) -> int:
105
+ def find_script_start_marker(html: str, lower: str, pos: int, end: int, text_end: int) -> int:
106
+ # `end` bounds where a "<script" marker may start; `text_end` bounds the real
107
+ # input so the script-data-double-escape-start terminator is checked against
108
+ # the actual following character. When these differ, a marker whose name ends
109
+ # exactly at `end` still has a following character (e.g. the "<" of a trailing
110
+ # "</script>") that must be inspected rather than treated as end-of-input.
102
111
  search = pos
103
112
  needle = "<script"
104
113
  needle_len = len(needle)
@@ -107,6 +116,6 @@ def find_script_start_marker(html: str, lower: str, pos: int, end: int) -> int:
107
116
  if start == -1:
108
117
  return -1
109
118
  after_name = start + needle_len
110
- if after_name >= end or html[after_name] in TAG_END_NAME_STOP:
119
+ if after_name >= text_end or html[after_name] in TAG_END_NAME_STOP:
111
120
  return start
112
121
  search = after_name
@@ -91,3 +91,16 @@
91
91
  | <body>
92
92
  | "x"
93
93
  | <meta>
94
+
95
+ #data
96
+ <!DOCTYPE html foo><p><table>
97
+ #errors
98
+ (1,19): unknown-doctype
99
+
100
+ #document
101
+ | <!DOCTYPE html>
102
+ | <html>
103
+ | <head>
104
+ | <body>
105
+ | <p>
106
+ | <table>