justhtml 3.5.0__tar.gz → 3.6.1__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 (165) hide show
  1. {justhtml-3.5.0 → justhtml-3.6.1}/CHANGELOG.md +40 -0
  2. {justhtml-3.5.0 → justhtml-3.6.1}/PKG-INFO +1 -1
  3. justhtml-3.6.1/project_ideas.md +47 -0
  4. {justhtml-3.5.0 → justhtml-3.6.1}/pyproject.toml +1 -1
  5. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/parser/engine.py +205 -57
  6. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/parser/scanner.py +21 -12
  7. justhtml-3.6.1/tests/justhtml-tests/character_preprocessing.dat +252 -0
  8. justhtml-3.6.1/tests/justhtml-tests/document_shell_recovery.dat +366 -0
  9. justhtml-3.6.1/tests/justhtml-tests/foreign_content.dat +394 -0
  10. justhtml-3.6.1/tests/justhtml-tests/frameset_recovery.dat +229 -0
  11. justhtml-3.6.1/tests/justhtml-tests/head_noscript_recovery.dat +61 -0
  12. justhtml-3.6.1/tests/justhtml-tests/in_body_recovery.dat +175 -0
  13. justhtml-3.6.1/tests/justhtml-tests/raw_text_recovery.dat +166 -0
  14. {justhtml-3.5.0 → justhtml-3.6.1}/tests/justhtml-tests/select_recovery.dat +109 -0
  15. {justhtml-3.5.0 → justhtml-3.6.1}/tests/justhtml-tests/selectedcontent.dat +10 -0
  16. {justhtml-3.5.0 → justhtml-3.6.1}/tests/justhtml-tests/table_recovery.dat +107 -0
  17. justhtml-3.6.1/tests/justhtml-tests/template_recovery.dat +642 -0
  18. justhtml-3.6.1/tests/justhtml-tests/tokenizer_recovery.dat +238 -0
  19. {justhtml-3.5.0 → justhtml-3.6.1}/tests/justhtml-tests/xml_coercion.dat +24 -0
  20. {justhtml-3.5.0 → justhtml-3.6.1}/tests/test_parser_engine.py +18 -128
  21. {justhtml-3.5.0 → justhtml-3.6.1}/tests/test_parser_regressions.py +0 -37
  22. {justhtml-3.5.0 → justhtml-3.6.1}/tests/test_parser_tree.py +1 -372
  23. {justhtml-3.5.0 → justhtml-3.6.1}/tests/test_stream.py +26 -1
  24. justhtml-3.5.0/tests/justhtml-tests/character_preprocessing.dat +0 -110
  25. justhtml-3.5.0/tests/justhtml-tests/document_shell_recovery.dat +0 -93
  26. justhtml-3.5.0/tests/justhtml-tests/foreign_content.dat +0 -135
  27. justhtml-3.5.0/tests/justhtml-tests/frameset_recovery.dat +0 -60
  28. justhtml-3.5.0/tests/justhtml-tests/head_noscript_recovery.dat +0 -25
  29. justhtml-3.5.0/tests/justhtml-tests/in_body_recovery.dat +0 -12
  30. justhtml-3.5.0/tests/justhtml-tests/template_recovery.dat +0 -294
  31. {justhtml-3.5.0 → justhtml-3.6.1}/.github/copilot-instructions.md +0 -0
  32. {justhtml-3.5.0 → justhtml-3.6.1}/.github/workflows/ci.yml +0 -0
  33. {justhtml-3.5.0 → justhtml-3.6.1}/.github/workflows/publish.yml +0 -0
  34. {justhtml-3.5.0 → justhtml-3.6.1}/.gitignore +0 -0
  35. {justhtml-3.5.0 → justhtml-3.6.1}/.pre-commit-config.yaml +0 -0
  36. {justhtml-3.5.0 → justhtml-3.6.1}/AGENTS.md +0 -0
  37. {justhtml-3.5.0 → justhtml-3.6.1}/CODE_OF_CONDUCT.md +0 -0
  38. {justhtml-3.5.0 → justhtml-3.6.1}/CONTRIBUTING.md +0 -0
  39. {justhtml-3.5.0 → justhtml-3.6.1}/LICENSE +0 -0
  40. {justhtml-3.5.0 → justhtml-3.6.1}/Makefile +0 -0
  41. {justhtml-3.5.0 → justhtml-3.6.1}/PERFORMANCE.md +0 -0
  42. {justhtml-3.5.0 → justhtml-3.6.1}/README.md +0 -0
  43. {justhtml-3.5.0 → justhtml-3.6.1}/SECURITY.md +0 -0
  44. {justhtml-3.5.0 → justhtml-3.6.1}/assets/justhtml-readme-explainer.png +0 -0
  45. {justhtml-3.5.0 → justhtml-3.6.1}/benchmarks/chromium_parser_diff.py +0 -0
  46. {justhtml-3.5.0 → justhtml-3.6.1}/benchmarks/correctness.py +0 -0
  47. {justhtml-3.5.0 → justhtml-3.6.1}/benchmarks/fuzz.py +0 -0
  48. {justhtml-3.5.0 → justhtml-3.6.1}/benchmarks/html5lib_engine_diff.py +0 -0
  49. {justhtml-3.5.0 → justhtml-3.6.1}/benchmarks/parser_engine_gate.py +0 -0
  50. {justhtml-3.5.0 → justhtml-3.6.1}/benchmarks/performance.py +0 -0
  51. {justhtml-3.5.0 → justhtml-3.6.1}/benchmarks/profile.py +0 -0
  52. {justhtml-3.5.0 → justhtml-3.6.1}/docs/_config.yml +0 -0
  53. {justhtml-3.5.0 → justhtml-3.6.1}/docs/_layouts/default.html +0 -0
  54. {justhtml-3.5.0 → justhtml-3.6.1}/docs/api.md +0 -0
  55. {justhtml-3.5.0 → justhtml-3.6.1}/docs/assets/search.js +0 -0
  56. {justhtml-3.5.0 → justhtml-3.6.1}/docs/bleach-migration.md +0 -0
  57. {justhtml-3.5.0 → justhtml-3.6.1}/docs/building.md +0 -0
  58. {justhtml-3.5.0 → justhtml-3.6.1}/docs/cli.md +0 -0
  59. {justhtml-3.5.0 → justhtml-3.6.1}/docs/comparison.md +0 -0
  60. {justhtml-3.5.0 → justhtml-3.6.1}/docs/correctness.md +0 -0
  61. {justhtml-3.5.0 → justhtml-3.6.1}/docs/encoding.md +0 -0
  62. {justhtml-3.5.0 → justhtml-3.6.1}/docs/errors.md +0 -0
  63. {justhtml-3.5.0 → justhtml-3.6.1}/docs/fragments.md +0 -0
  64. {justhtml-3.5.0 → justhtml-3.6.1}/docs/html-cleaning.md +0 -0
  65. {justhtml-3.5.0 → justhtml-3.6.1}/docs/index.md +0 -0
  66. {justhtml-3.5.0 → justhtml-3.6.1}/docs/linkify.md +0 -0
  67. {justhtml-3.5.0 → justhtml-3.6.1}/docs/migration-examples.md +0 -0
  68. {justhtml-3.5.0 → justhtml-3.6.1}/docs/playground/app.js +0 -0
  69. {justhtml-3.5.0 → justhtml-3.6.1}/docs/playground/index.html +0 -0
  70. {justhtml-3.5.0 → justhtml-3.6.1}/docs/playground/py/__init__.py +0 -0
  71. {justhtml-3.5.0 → justhtml-3.6.1}/docs/playground/py/install_latest_justhtml.py +0 -0
  72. {justhtml-3.5.0 → justhtml-3.6.1}/docs/playground/py/render.py +0 -0
  73. {justhtml-3.5.0 → justhtml-3.6.1}/docs/playground/py/use_local_repo.py +0 -0
  74. {justhtml-3.5.0 → justhtml-3.6.1}/docs/quickstart.md +0 -0
  75. {justhtml-3.5.0 → justhtml-3.6.1}/docs/sanitization.md +0 -0
  76. {justhtml-3.5.0 → justhtml-3.6.1}/docs/selectors.md +0 -0
  77. {justhtml-3.5.0 → justhtml-3.6.1}/docs/streaming.md +0 -0
  78. {justhtml-3.5.0 → justhtml-3.6.1}/docs/text.md +0 -0
  79. {justhtml-3.5.0 → justhtml-3.6.1}/docs/transforms.md +0 -0
  80. {justhtml-3.5.0 → justhtml-3.6.1}/docs/unsafe-handling.md +0 -0
  81. {justhtml-3.5.0 → justhtml-3.6.1}/docs/url-cleaning.md +0 -0
  82. {justhtml-3.5.0 → justhtml-3.6.1}/llms.txt +0 -0
  83. {justhtml-3.5.0 → justhtml-3.6.1}/run_tests.py +0 -0
  84. {justhtml-3.5.0 → justhtml-3.6.1}/scripts/release.py +0 -0
  85. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/__init__.py +0 -0
  86. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/__main__.py +0 -0
  87. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/core/__init__.py +0 -0
  88. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/core/constants.py +0 -0
  89. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/core/doctype.py +0 -0
  90. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/core/entities.py +0 -0
  91. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/core/errors.py +0 -0
  92. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/core/rawtext.py +0 -0
  93. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/core/text.py +0 -0
  94. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/core/types.py +0 -0
  95. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/dom/__init__.py +0 -0
  96. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/dom/builder.py +0 -0
  97. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/parser/__init__.py +0 -0
  98. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/parser/context.py +0 -0
  99. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/parser/encoding.py +0 -0
  100. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/parser/options.py +0 -0
  101. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/parser/stream.py +0 -0
  102. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/py.typed +0 -0
  103. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/sanitizer/__init__.py +0 -0
  104. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/sanitizer/css.py +0 -0
  105. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/sanitizer/dom.py +0 -0
  106. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/sanitizer/policy.py +0 -0
  107. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/sanitizer/policy_defaults.py +0 -0
  108. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/sanitizer/rawtext.py +0 -0
  109. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/sanitizer/url/__init__.py +0 -0
  110. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/sanitizer/url/policy.py +0 -0
  111. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/sanitizer/url/runtime.py +0 -0
  112. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/sanitizer/url/spec.py +0 -0
  113. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/selector/__init__.py +0 -0
  114. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/selector/core.py +0 -0
  115. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/serializer/__init__.py +0 -0
  116. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/serializer/html.py +0 -0
  117. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/serializer/markdown.py +0 -0
  118. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/transforms/__init__.py +0 -0
  119. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/transforms/compile.py +0 -0
  120. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/transforms/linkify.py +0 -0
  121. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/transforms/linkify_core.py +0 -0
  122. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/transforms/runtime.py +0 -0
  123. {justhtml-3.5.0 → justhtml-3.6.1}/src/justhtml/transforms/spec.py +0 -0
  124. {justhtml-3.5.0 → justhtml-3.6.1}/test-summary.txt +0 -0
  125. {justhtml-3.5.0 → justhtml-3.6.1}/tests/README.md +0 -0
  126. {justhtml-3.5.0 → justhtml-3.6.1}/tests/__init__.py +0 -0
  127. {justhtml-3.5.0 → justhtml-3.6.1}/tests/data/wikipedia.html +0 -0
  128. {justhtml-3.5.0 → justhtml-3.6.1}/tests/harness/__init__.py +0 -0
  129. {justhtml-3.5.0 → justhtml-3.6.1}/tests/harness/encoding.py +0 -0
  130. {justhtml-3.5.0 → justhtml-3.6.1}/tests/harness/regressions.py +0 -0
  131. {justhtml-3.5.0 → justhtml-3.6.1}/tests/harness/reporter.py +0 -0
  132. {justhtml-3.5.0 → justhtml-3.6.1}/tests/harness/serializer.py +0 -0
  133. {justhtml-3.5.0 → justhtml-3.6.1}/tests/harness/tree.py +0 -0
  134. {justhtml-3.5.0 → justhtml-3.6.1}/tests/justhtml-sanitize-tests/cases.json +0 -0
  135. {justhtml-3.5.0 → justhtml-3.6.1}/tests/justhtml-tests/entities.test +0 -0
  136. {justhtml-3.5.0 → justhtml-3.6.1}/tests/justhtml-tests/foreign_stack_recovery.dat +0 -0
  137. {justhtml-3.5.0 → justhtml-3.6.1}/tests/justhtml-tests/iframe_srcdoc.dat +0 -0
  138. {justhtml-3.5.0 → justhtml-3.6.1}/tests/justhtml-tests/tokenizer_state_transitions.test +0 -0
  139. {justhtml-3.5.0 → justhtml-3.6.1}/tests/justhtml-tests/tokenizer_states.test +0 -0
  140. {justhtml-3.5.0 → justhtml-3.6.1}/tests/justhtml-tests/xml_coercion_invalid_characters.test +0 -0
  141. {justhtml-3.5.0 → justhtml-3.6.1}/tests/linkify-it/LICENSE.txt +0 -0
  142. {justhtml-3.5.0 → justhtml-3.6.1}/tests/linkify-it/README.md +0 -0
  143. {justhtml-3.5.0 → justhtml-3.6.1}/tests/linkify-it/fixtures/links.txt +0 -0
  144. {justhtml-3.5.0 → justhtml-3.6.1}/tests/linkify-it/fixtures/not_links.txt +0 -0
  145. {justhtml-3.5.0 → justhtml-3.6.1}/tests/test_builder.py +0 -0
  146. {justhtml-3.5.0 → justhtml-3.6.1}/tests/test_cli.py +0 -0
  147. {justhtml-3.5.0 → justhtml-3.6.1}/tests/test_docs_examples.py +0 -0
  148. {justhtml-3.5.0 → justhtml-3.6.1}/tests/test_encoding.py +0 -0
  149. {justhtml-3.5.0 → justhtml-3.6.1}/tests/test_entities.py +0 -0
  150. {justhtml-3.5.0 → justhtml-3.6.1}/tests/test_errors.py +0 -0
  151. {justhtml-3.5.0 → justhtml-3.6.1}/tests/test_linkify_internals.py +0 -0
  152. {justhtml-3.5.0 → justhtml-3.6.1}/tests/test_linkify_it.py +0 -0
  153. {justhtml-3.5.0 → justhtml-3.6.1}/tests/test_linkify_transform.py +0 -0
  154. {justhtml-3.5.0 → justhtml-3.6.1}/tests/test_node.py +0 -0
  155. {justhtml-3.5.0 → justhtml-3.6.1}/tests/test_playground_local_repo_file_list.py +0 -0
  156. {justhtml-3.5.0 → justhtml-3.6.1}/tests/test_sanitize.py +0 -0
  157. {justhtml-3.5.0 → justhtml-3.6.1}/tests/test_sanitize_integration.py +0 -0
  158. {justhtml-3.5.0 → justhtml-3.6.1}/tests/test_sanitize_transform.py +0 -0
  159. {justhtml-3.5.0 → justhtml-3.6.1}/tests/test_selector.py +0 -0
  160. {justhtml-3.5.0 → justhtml-3.6.1}/tests/test_serialize.py +0 -0
  161. {justhtml-3.5.0 → justhtml-3.6.1}/tests/test_transform_runtime.py +0 -0
  162. {justhtml-3.5.0 → justhtml-3.6.1}/tests/test_transforms.py +0 -0
  163. {justhtml-3.5.0 → justhtml-3.6.1}/tests/test_transforms_compiler.py +0 -0
  164. {justhtml-3.5.0 → justhtml-3.6.1}/tests/test_transforms_sanitize_integration.py +0 -0
  165. {justhtml-3.5.0 → justhtml-3.6.1}/tests/test_wikipedia.py +0 -0
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.6.1] - 2026-07-13
11
+
12
+ ### Fixed
13
+
14
+ - Keep processing instructions inside a scripting-disabled head `noscript`, and process them in body mode inside templates as required by the latest WHATWG parsing rules.
15
+
16
+ ### Changed
17
+
18
+ - Move raw parser input/output regressions from Python unit tests into the shared tree-construction fixture format.
19
+
20
+ ## [3.6.0] - 2026-07-13
21
+
22
+ ### Fixed
23
+ Note: The below are all minor parsing differences found when diffing against Chromium output, that is not covered in WPT's html5 parsing tests.
24
+
25
+ - 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>`.
26
+ - 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.
27
+ - 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.
28
+ - 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.
29
+ - 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.
30
+ - 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.
31
+ - 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.
32
+ - 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.
33
+ - 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.
34
+ - 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.
35
+ - 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.
36
+ - 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.
37
+ - 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.
38
+ - 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.
39
+ - 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.
40
+ - 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.
41
+ - 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.
42
+ - 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`.
43
+ - 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>`.
44
+ - 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.
45
+ - 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.
46
+ - 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.
47
+ - 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.
48
+ - 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`.
49
+
10
50
  ## [3.5.0] - 2026-07-13
11
51
 
12
52
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: justhtml
3
- Version: 3.5.0
3
+ Version: 3.6.1
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.1"
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:]
@@ -1226,8 +1253,32 @@ class ParseEngine:
1226
1253
  if self._has_null and "\0" in data:
1227
1254
  data = data.replace("\0", "\ufffd")
1228
1255
  node = ProcessingInstruction(data=data)
1256
+ self._move_head_template_to_body_for_processing_instruction()
1229
1257
  self._append_misc_node(node, source_pos)
1230
1258
 
1259
+ def _move_head_template_to_body_for_processing_instruction(self) -> None:
1260
+ if self._fragment or self._head is None or self._html is None:
1261
+ return
1262
+ stack = self._stack
1263
+ template_idx = self._open_template_index()
1264
+ if template_idx is None:
1265
+ return
1266
+ outer_template_idx = template_idx
1267
+ for idx in range(template_idx, 0, -1):
1268
+ candidate = stack[idx]
1269
+ if type(candidate) is Template and candidate.parent is self._head:
1270
+ outer_template_idx = idx
1271
+ template = stack[outer_template_idx]
1272
+ if type(template) is not Template or template.parent is not self._head:
1273
+ return
1274
+ head_children = self._head.children
1275
+ if head_children is not None: # pragma: no branch - Document head always owns a children list
1276
+ head_children.remove(template)
1277
+ self._append(self._body, template)
1278
+ self._stack = _CountingStack([self._doc, self._html, self._body, *stack[outer_template_idx:]]) # type: ignore[list-item]
1279
+ self._body_mode_seen = True
1280
+ self._after_head = False
1281
+
1231
1282
  def _processing_instruction_data(self, content_start: int, content_end: int) -> str | None:
1232
1283
  html = self._html_input
1233
1284
  if content_start >= content_end or not _is_processing_instruction_name_start(html[content_start]):
@@ -1279,6 +1330,7 @@ class ParseEngine:
1279
1330
  parent: Node
1280
1331
  if self._current_parent() is self._head or (
1281
1332
  not self._fragment
1333
+ and not self._after_head
1282
1334
  and self._head is not None
1283
1335
  and not self._body_mode_seen
1284
1336
  and not self._body_has_content()
@@ -1604,7 +1656,7 @@ class ParseEngine:
1604
1656
  append_text(html[lt:end] if gt == -1 else html[lt : gt + 1], lt)
1605
1657
  pos = end if gt == -1 else gt + 1
1606
1658
  continue
1607
- if self._raw_mode and len(self._stack) > 1 and self._stack[-1].name in _RAWTEXT_ELEMENT_TAGS:
1659
+ if self._raw_mode and len(self._stack) > 1 and self._stack[-1].name in self._rawtext_element_tags:
1608
1660
  comment_end = end if gt == -1 else gt
1609
1661
  append_text(html[lt:comment_end] if gt == -1 else html[lt : gt + 1], lt)
1610
1662
  pos = end if gt == -1 else gt + 1
@@ -1931,7 +1983,14 @@ class ParseEngine:
1931
1983
  self._insert_at(parent, position, node)
1932
1984
  return
1933
1985
  foster = None
1934
- if parent.name in self._table_foster_targets:
1986
+ # Foster parenting only applies in HTML table insertion modes. A
1987
+ # foreign element that merely shares a name with a table foster
1988
+ # target (e.g. an SVG or MathML "tr") keeps character data as its
1989
+ # own child instead.
1990
+ parent_is_table_foster = parent.name in self._table_foster_targets and getattr(
1991
+ parent, "namespace", None
1992
+ ) in {None, "html", _PARSER_ONLY_NAMESPACE}
1993
+ if parent_is_table_foster:
1935
1994
  is_table_space = text_is_space if text_is_space is not None else text.strip(_SPACE) == ""
1936
1995
  if not is_table_space:
1937
1996
  if pending_table_whitespace:
@@ -1961,7 +2020,7 @@ class ParseEngine:
1961
2020
  node = self._new_text(text, source_pos) if self._track_node_locations else Text(text)
1962
2021
  children.append(node)
1963
2022
  node.parent = parent
1964
- if parent.name in self._table_foster_targets and is_table_space:
2023
+ if parent_is_table_foster and is_table_space:
1965
2024
  self._foster_next_table_whitespace = pending_table_whitespace + len(text)
1966
2025
  else:
1967
2026
  foster_parent, position = foster
@@ -2002,9 +2061,15 @@ class ParseEngine:
2002
2061
  else:
2003
2062
  public_id = None
2004
2063
  system_id = None
2005
- doctype = Doctype(name, public_id, system_id, force_quirks=name is None)
2064
+ # Junk after the name with no PUBLIC/SYSTEM keyword lands in the
2065
+ # "after DOCTYPE name" state, which sets the force-quirks flag
2066
+ # (§13.2.5.55). The regex tolerates that trailing text, so detect
2067
+ # it here to reproduce the quirks-mode transition.
2068
+ trailing = raw[match.end() :].strip("\t\n\f\r ")
2069
+ bogus = kind is None and bool(trailing)
2070
+ doctype = Doctype(name, public_id, system_id, force_quirks=name is None or bogus)
2006
2071
  doctype_error, self._quirks_mode = doctype_error_and_quirks(doctype, self._iframe_srcdoc)
2007
- if doctype_error and self._collect_errors:
2072
+ if (doctype_error or bogus) and self._collect_errors:
2008
2073
  self._emit_error(
2009
2074
  "unknown-doctype",
2010
2075
  doctype_end if gt != -1 else max(0, end - 1),
@@ -2297,8 +2362,21 @@ class ParseEngine:
2297
2362
  self._body_mode_seen = True
2298
2363
 
2299
2364
  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"}:
2365
+ if self._find_open_html_index("select") is not None:
2366
+ # In the "in select" insertion mode, </html> and </body> are
2367
+ # unhandled end tags and must be ignored rather than switching to
2368
+ # the after-body/after-html modes. Any elements kept inside the
2369
+ # open select remain part of that insertion mode.
2370
+ return pos
2371
+ if (
2372
+ self._find_open_html_index("body") is not None
2373
+ and self._find_open_index_before_boundary("body", _DEFAULT_SCOPE_BOUNDARIES) is None
2374
+ ):
2375
+ # With body on the open stack, </body> and </html> are ignored
2376
+ # unless body is in scope (§13.2.6.4.7); an open scope marker such
2377
+ # as marquee, object, or applet keeps it out of scope. When body
2378
+ # is not on the stack yet (in head/after head), fall through so
2379
+ # the head-to-body transition still runs.
2302
2380
  return pos
2303
2381
  self._in_colgroup = False
2304
2382
  if self._head is not None and self._stack[-1] is self._head:
@@ -2369,11 +2447,16 @@ class ParseEngine:
2369
2447
  self._form_element = None
2370
2448
  if form_node is None:
2371
2449
  return pos
2450
+ # §13.2.6.4.7: ignore </form> when the form element is not in scope,
2451
+ # e.g. behind an open <table>. Without this the form is removed from
2452
+ # the stack and following content escapes it.
2453
+ form_idx = self._find_open_index_before_boundary("form", _DEFAULT_SCOPE_BOUNDARIES)
2454
+ if form_idx is None or self._stack[form_idx] is not form_node:
2455
+ return pos
2456
+ # form_node is confirmed in scope on the stack and implied end tags
2457
+ # never pop a form, so the removal below always succeeds.
2372
2458
  self._generate_implied_end_tags()
2373
- try:
2374
- self._stack.remove(form_node)
2375
- except ValueError:
2376
- pass
2459
+ self._stack.remove(form_node)
2377
2460
  return pos
2378
2461
  select_idx = self._find_open_html_index("select")
2379
2462
  if select_idx is not None and name not in {"optgroup", "option", "select", "selectedcontent", "template"}:
@@ -2494,7 +2577,7 @@ class ParseEngine:
2494
2577
  elif name == "summary":
2495
2578
  idx = self._find_open_index_before_boundary(name, _DEFAULT_SCOPE_BOUNDARIES)
2496
2579
  elif self._template_modes:
2497
- idx = self._find_open_index_in_current_scope(name)
2580
+ idx = self._find_open_index_before_boundary(name, self._template_end_tag_boundaries(name, action))
2498
2581
  elif name not in self._special_elements and (action is None or not action.p_closing):
2499
2582
  idx = self._find_open_index_before_boundary(name, _GENERAL_END_TAG_BOUNDARIES)
2500
2583
  else:
@@ -2671,12 +2754,9 @@ class ParseEngine:
2671
2754
  self._close_until("select")
2672
2755
  return pos
2673
2756
 
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
2757
+ if (
2758
+ self._current_template_mode() == _TEMPLATE_MODE_INITIAL and name in _TEMPLATE_INITIAL_BODY_SWITCH_TAGS
2759
+ ): # pragma: no cover - compiled sanitizer drops parser-only template contents
2680
2760
  self._set_current_template_mode(_TEMPLATE_MODE_BODY) # pragma: no cover
2681
2761
 
2682
2762
  if in_parser_only_template and self._current_template_mode() == _TEMPLATE_MODE_COLGROUP:
@@ -2981,6 +3061,9 @@ class ParseEngine:
2981
3061
  action is not None
2982
3062
  and allowed
2983
3063
  and action.head_content
3064
+ # "after head" re-enters the head only for the metadata start
3065
+ # tags it lists; noscript is not among them and starts the body.
3066
+ and name != "noscript"
2984
3067
  and self._head is not None
2985
3068
  and not self._in_head_noscript
2986
3069
  and not self._body_mode_seen
@@ -3088,12 +3171,7 @@ class ParseEngine:
3088
3171
  ):
3089
3172
  self._close_html_until("select")
3090
3173
 
3091
- if self._current_template_mode() == _TEMPLATE_MODE_INITIAL and name in {
3092
- "base",
3093
- "basefont",
3094
- "bgsound",
3095
- "noframes",
3096
- }:
3174
+ if self._current_template_mode() == _TEMPLATE_MODE_INITIAL and name in _TEMPLATE_INITIAL_BODY_SWITCH_TAGS:
3097
3175
  self._set_current_template_mode(_TEMPLATE_MODE_BODY)
3098
3176
 
3099
3177
  if html_text_parsing and self._current_template_mode() == _TEMPLATE_MODE_COLGROUP:
@@ -3202,7 +3280,14 @@ class ParseEngine:
3202
3280
  )
3203
3281
  return pos
3204
3282
 
3205
- if html_text_parsing and action is not None and action.active_formatting:
3283
+ if (
3284
+ action is not None
3285
+ and action.active_formatting
3286
+ and (html_text_parsing or self._is_foreign_breakout_start(name, attrs))
3287
+ ):
3288
+ # A formatting element that breaks out of foreign content (e.g. <i>
3289
+ # inside <svg>) becomes an HTML formatting element and must join the
3290
+ # active formatting list so it is reconstructed later.
3206
3291
  return self._parse_formatting_start(name, attrs, pos, tag_start=tag_start, tag_end=tag_end)
3207
3292
 
3208
3293
  if html_text_parsing and name == "menuitem" and self._active_formatting_dirty:
@@ -3270,6 +3355,11 @@ class ParseEngine:
3270
3355
  and not self._body_has_content()
3271
3356
  ):
3272
3357
  parent = self._head
3358
+ elif not html_text_parsing and not self._is_foreign_breakout_start(name, attrs):
3359
+ # A non-breakout start tag inside foreign content is inserted as a
3360
+ # foreign element in the current namespace; the HTML in-body repair
3361
+ # (p-closing, heading/list handling, ...) must not run.
3362
+ parent = self._current_parent()
3273
3363
  else:
3274
3364
  self._repair_stack_for_start(name)
3275
3365
  parent = self._current_parent()
@@ -3312,8 +3402,9 @@ class ParseEngine:
3312
3402
 
3313
3403
  reconstruct_before_insert = (
3314
3404
  name not in _TABLE_STRUCTURE_START_TAGS
3315
- and name not in _HEAD_ONLY_VOID_START_TAGS
3405
+ and name not in _INLINE_HEAD_VOID_START_TAGS
3316
3406
  and name not in {"param", "source", "track"}
3407
+ and name not in _RUBY_START_TAGS
3317
3408
  and name != "template"
3318
3409
  and (action is None or not action.p_closing)
3319
3410
  )
@@ -3484,9 +3575,11 @@ class ParseEngine:
3484
3575
  "html",
3485
3576
  }: # pragma: no branch - opposite edge requires invalid parser state
3486
3577
  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:
3578
+ # Active-formatting markers are pushed only for the HTML scope-marker
3579
+ # elements. A foreign element that merely shares a name (e.g. an SVG
3580
+ # or MathML "td") must not push one, or the stray marker would block
3581
+ # later reconstruction once the foreign element is popped.
3582
+ if is_html_namespace and (name in self._table_cell_tags or name in _ACTIVE_FORMATTING_MARKER_TAGS):
3490
3583
  self._push_active_formatting_marker()
3491
3584
  return node
3492
3585
 
@@ -3540,9 +3633,10 @@ class ParseEngine:
3540
3633
  self._stack.append(node)
3541
3634
  if type(node) is Template and node.namespace in {None, "html"}:
3542
3635
  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:
3636
+ # Only HTML scope-marker elements push an active-formatting marker; a
3637
+ # foreign element sharing a name (e.g. an SVG or MathML "td") must not,
3638
+ # or the stray marker blocks reconstruction once it is popped.
3639
+ if is_html_namespace and (name in self._table_cell_tags or name in _ACTIVE_FORMATTING_MARKER_TAGS):
3546
3640
  self._push_active_formatting_marker()
3547
3641
  return node
3548
3642
 
@@ -3558,6 +3652,25 @@ class ParseEngine:
3558
3652
  attrs = self._prepare_foreign_attrs(namespace, attrs)
3559
3653
  return name, attrs, namespace
3560
3654
 
3655
+ def _pop_foreign_for_breakout(self) -> None:
3656
+ # Pop the foreign-content elements a breakout start tag returns through,
3657
+ # stopping at the fragment context node and at integration points where
3658
+ # HTML content resumes.
3659
+ while (
3660
+ len(self._stack) > 1
3661
+ and self._stack[-1].namespace not in {None, "html", _PARSER_ONLY_NAMESPACE}
3662
+ and self._stack[-1] is not self._fragment_context_node
3663
+ and not self._is_html_integration_point(self._stack[-1])
3664
+ and not self._is_mathml_text_integration_point(self._stack[-1])
3665
+ ):
3666
+ self._stack.pop()
3667
+
3668
+ def _is_foreign_breakout_start(self, name: str, attrs: dict[str, str | None]) -> bool:
3669
+ # Start tags that break out of foreign content back to HTML (§13.2.6.5).
3670
+ return name in FOREIGN_BREAKOUT_ELEMENTS or (
3671
+ name == "font" and any(attr.lower() in {"color", "face", "size"} for attr in attrs)
3672
+ )
3673
+
3561
3674
  def _namespace_for_raw_start(self, name: str, attrs: dict[str, str | None]) -> str:
3562
3675
  current = self._stack[-1] if self._stack else None
3563
3676
  current_ns = getattr(current, "namespace", None)
@@ -3568,19 +3681,9 @@ class ParseEngine:
3568
3681
  return "svg" if name == "svg" else "math" if name == "math" else "html"
3569
3682
  if current_ns == "math" and current.name == "annotation-xml" and name == "svg":
3570
3683
  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:
3684
+ if not self._is_foreign_breakout_start(name, attrs):
3575
3685
  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()
3686
+ self._pop_foreign_for_breakout()
3584
3687
 
3585
3688
  if name == "svg":
3586
3689
  return "svg"
@@ -3732,6 +3835,15 @@ class ParseEngine:
3732
3835
  return None
3733
3836
  return None
3734
3837
 
3838
+ def _template_end_tag_boundaries(self, name: str, action: TagAction | None) -> frozenset[str]:
3839
+ # Generic in-body end tags inside a template still obey scope: block and
3840
+ # special names close within the default scope, while other names stop at
3841
+ # any special element (§13.2.6.4.7 "any other end tag"). The template
3842
+ # element is a member of both sets, so the walk cannot escape it.
3843
+ if name in self._special_elements or (action is not None and action.p_closing):
3844
+ return _DEFAULT_SCOPE_BOUNDARIES
3845
+ return _GENERAL_END_TAG_BOUNDARIES
3846
+
3735
3847
  def _find_open_heading_index(self) -> int | None:
3736
3848
  stack = self._stack
3737
3849
  for idx in range(len(stack) - 1, 0, -1): # pragma: no branch
@@ -3994,7 +4106,7 @@ class ParseEngine:
3994
4106
  del self._stack[table_idx:]
3995
4107
  self._insert_template_mode_element(name, attrs, self_closing)
3996
4108
  return pos
3997
- if table_idx is not None and name in {
4109
+ if name in {
3998
4110
  "caption",
3999
4111
  "col",
4000
4112
  "colgroup",
@@ -4005,7 +4117,12 @@ class ParseEngine:
4005
4117
  "thead",
4006
4118
  "tr",
4007
4119
  }:
4008
- del self._stack[table_idx + 1 :]
4120
+ # Clear back to the table context before opening table structure.
4121
+ # In a template with no table element, that context is the
4122
+ # template contents, so an open caption is popped first.
4123
+ clear_idx = table_idx if table_idx is not None else template_idx
4124
+ if clear_idx is not None: # pragma: no branch - a template is always open in this mode
4125
+ del self._stack[clear_idx + 1 :]
4009
4126
  if name in {"tbody", "tfoot", "thead"}:
4010
4127
  self._set_current_template_mode(_TEMPLATE_MODE_TABLE_BODY)
4011
4128
  self._insert_template_mode_element(name, attrs, self_closing)
@@ -4134,9 +4251,20 @@ class ParseEngine:
4134
4251
  return False # pragma: no cover - unreachable after parser-state guards
4135
4252
  if name in {"html", "head", "body"}:
4136
4253
  return True
4254
+ if mode == _TEMPLATE_MODE_INITIAL:
4255
+ # "In template" handles only </template>; every other end tag with
4256
+ # nothing to close is a parse error that is ignored (§13.2.6.4.18),
4257
+ # including the </br> special case. An element left open in the
4258
+ # template (e.g. a scripting-disabled <noscript>) is still closeable
4259
+ # through the normal in-body handling.
4260
+ if name == "template":
4261
+ return False
4262
+ return self._find_open_index_before_boundary(name, _TEMPLATE_SCOPE_BOUNDARIES) is None
4137
4263
  if mode == _TEMPLATE_MODE_CELL:
4138
4264
  if name in self._table_cell_tags:
4139
- return self._close_template_cell()
4265
+ # Only the matching cell type closes; </th> with a <td> open (or
4266
+ # vice versa) leaves the cell open.
4267
+ return self._close_template_cell(name)
4140
4268
  if name in {"table", "tbody", "tfoot", "thead", "tr"}:
4141
4269
  if self._find_open_index_before_boundary(name, _TEMPLATE_SCOPE_BOUNDARIES) is None:
4142
4270
  return True
@@ -4200,11 +4328,16 @@ class ParseEngine:
4200
4328
  return False
4201
4329
  return False # pragma: no cover - template scope always terminates the scan
4202
4330
 
4203
- def _close_template_cell(self) -> bool:
4331
+ def _close_template_cell(self, name: str | None = None) -> bool:
4204
4332
  stack = self._stack
4205
4333
  for idx in range(len(stack) - 1, 0, -1):
4206
4334
  node_name = stack[idx].name
4207
4335
  if node_name in self._table_cell_tags:
4336
+ # A </td>/</th> end tag only closes the matching cell type; a
4337
+ # mismatched end tag (e.g. </th> for an open <td>) is left for
4338
+ # the generic handling, which ignores it.
4339
+ if name is not None and node_name != name:
4340
+ return False
4208
4341
  self._mark_active_formatting_dirty()
4209
4342
  self._clear_active_formatting_to_marker()
4210
4343
  del stack[idx:]
@@ -4229,7 +4362,8 @@ class ParseEngine:
4229
4362
  def _close_until_before_boundary(self, name: str, boundaries: frozenset[str]) -> bool:
4230
4363
  stack = self._stack
4231
4364
  for idx in range(len(stack) - 1, 0, -1):
4232
- node_name = getattr(stack[idx], "name", None)
4365
+ node = stack[idx]
4366
+ node_name = getattr(node, "name", None)
4233
4367
  if node_name == name:
4234
4368
  if (
4235
4369
  self._fragment_context_node is not None and stack[idx] is self._fragment_context_node
@@ -4238,7 +4372,11 @@ class ParseEngine:
4238
4372
  self._mark_active_formatting_dirty()
4239
4373
  del stack[idx:]
4240
4374
  return True
4241
- if node_name in boundaries:
4375
+ # Scope boundaries are HTML elements. A foreign element that merely
4376
+ # shares a name with a boundary (e.g. an SVG or MathML "td") must not
4377
+ # stop the scan; callers guard this walk with an in-scope check that
4378
+ # already accounts for foreign integration points.
4379
+ if getattr(node, "namespace", None) in {None, "html", _PARSER_ONLY_NAMESPACE} and node_name in boundaries:
4242
4380
  return False
4243
4381
  return False
4244
4382
 
@@ -4301,9 +4439,14 @@ class ParseEngine:
4301
4439
  self._stack.pop()
4302
4440
  return
4303
4441
 
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()
4442
+ if name in HEADING_ELEMENTS:
4443
+ # Headings are foreign-content breakout elements; pop the foreign
4444
+ # ancestors first so the "current node is a heading" check runs
4445
+ # against the HTML element the breakout returns to (§13.2.6.5).
4446
+ self._pop_foreign_for_breakout()
4447
+ if getattr(self._stack[-1], "name", None) in HEADING_ELEMENTS:
4448
+ self._mark_active_formatting_dirty()
4449
+ self._stack.pop()
4307
4450
 
4308
4451
  def _repair_table_for_start(self, name: str) -> None:
4309
4452
  table_idx = self._find_open_index("table")
@@ -4427,7 +4570,12 @@ class ParseEngine:
4427
4570
  self._refresh_active_formatting_dirty()
4428
4571
 
4429
4572
  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:
4573
+ # Foster parenting only applies within an HTML table. A foreign element
4574
+ # sharing a name with a foster target (e.g. an SVG or MathML "tr") keeps
4575
+ # its children rather than fostering them out.
4576
+ if getattr(parent, "name", None) not in self._table_foster_targets or getattr(
4577
+ parent, "namespace", None
4578
+ ) not in {None, "html", _PARSER_ONLY_NAMESPACE}:
4431
4579
  return None
4432
4580
  if (
4433
4581
  for_tag is not None and for_tag in self._table_allowed_children
@@ -4481,7 +4629,7 @@ class ParseEngine:
4481
4629
 
4482
4630
  def _find_script_start_marker(self, pos: int, end: int) -> int:
4483
4631
  return _scanner.find_script_start_marker(
4484
- self._html_input, self._lower_input, pos, end
4632
+ self._html_input, self._lower_input, pos, end, end
4485
4633
  ) # pragma: no cover - unreachable after parser-state guards
4486
4634
 
4487
4635
  def _find_tag_end(self, pos: int, end: int) -> int: