justhtml 3.11.2__tar.gz → 3.12.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 (159) hide show
  1. {justhtml-3.11.2 → justhtml-3.12.0}/CHANGELOG.md +14 -0
  2. {justhtml-3.11.2 → justhtml-3.12.0}/PKG-INFO +1 -1
  3. {justhtml-3.11.2 → justhtml-3.12.0}/docs/linkify.md +3 -2
  4. {justhtml-3.11.2 → justhtml-3.12.0}/pyproject.toml +1 -1
  5. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/parser/engine.py +28 -8
  6. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/transforms/runtime.py +0 -3
  7. {justhtml-3.11.2 → justhtml-3.12.0}/tests/test_parser_engine.py +30 -1
  8. {justhtml-3.11.2 → justhtml-3.12.0}/tests/test_sanitize_integration.py +11 -0
  9. {justhtml-3.11.2 → justhtml-3.12.0}/tests/test_transforms.py +23 -0
  10. {justhtml-3.11.2 → justhtml-3.12.0}/.github/workflows/ci.yml +0 -0
  11. {justhtml-3.11.2 → justhtml-3.12.0}/.github/workflows/publish.yml +0 -0
  12. {justhtml-3.11.2 → justhtml-3.12.0}/.gitignore +0 -0
  13. {justhtml-3.11.2 → justhtml-3.12.0}/.pre-commit-config.yaml +0 -0
  14. {justhtml-3.11.2 → justhtml-3.12.0}/AGENTS.md +0 -0
  15. {justhtml-3.11.2 → justhtml-3.12.0}/ARCHITECTURE.md +0 -0
  16. {justhtml-3.11.2 → justhtml-3.12.0}/CODE_OF_CONDUCT.md +0 -0
  17. {justhtml-3.11.2 → justhtml-3.12.0}/CONTRIBUTING.md +0 -0
  18. {justhtml-3.11.2 → justhtml-3.12.0}/LICENSE +0 -0
  19. {justhtml-3.11.2 → justhtml-3.12.0}/Makefile +0 -0
  20. {justhtml-3.11.2 → justhtml-3.12.0}/PERFORMANCE.md +0 -0
  21. {justhtml-3.11.2 → justhtml-3.12.0}/README.md +0 -0
  22. {justhtml-3.11.2 → justhtml-3.12.0}/SECURITY.md +0 -0
  23. {justhtml-3.11.2 → justhtml-3.12.0}/assets/justhtml-readme-explainer.png +0 -0
  24. {justhtml-3.11.2 → justhtml-3.12.0}/benchmarks/chromium_parser_diff.py +0 -0
  25. {justhtml-3.11.2 → justhtml-3.12.0}/benchmarks/correctness.py +0 -0
  26. {justhtml-3.11.2 → justhtml-3.12.0}/benchmarks/fuzz.py +0 -0
  27. {justhtml-3.11.2 → justhtml-3.12.0}/benchmarks/html5lib_engine_diff.py +0 -0
  28. {justhtml-3.11.2 → justhtml-3.12.0}/benchmarks/parser_engine_gate.py +0 -0
  29. {justhtml-3.11.2 → justhtml-3.12.0}/benchmarks/performance.py +0 -0
  30. {justhtml-3.11.2 → justhtml-3.12.0}/benchmarks/profile.py +0 -0
  31. {justhtml-3.11.2 → justhtml-3.12.0}/docs/_config.yml +0 -0
  32. {justhtml-3.11.2 → justhtml-3.12.0}/docs/_layouts/default.html +0 -0
  33. {justhtml-3.11.2 → justhtml-3.12.0}/docs/api.md +0 -0
  34. {justhtml-3.11.2 → justhtml-3.12.0}/docs/assets/search.js +0 -0
  35. {justhtml-3.11.2 → justhtml-3.12.0}/docs/bleach-migration.md +0 -0
  36. {justhtml-3.11.2 → justhtml-3.12.0}/docs/building.md +0 -0
  37. {justhtml-3.11.2 → justhtml-3.12.0}/docs/cli.md +0 -0
  38. {justhtml-3.11.2 → justhtml-3.12.0}/docs/comparison.md +0 -0
  39. {justhtml-3.11.2 → justhtml-3.12.0}/docs/correctness.md +0 -0
  40. {justhtml-3.11.2 → justhtml-3.12.0}/docs/encoding.md +0 -0
  41. {justhtml-3.11.2 → justhtml-3.12.0}/docs/errors.md +0 -0
  42. {justhtml-3.11.2 → justhtml-3.12.0}/docs/fragments.md +0 -0
  43. {justhtml-3.11.2 → justhtml-3.12.0}/docs/html-cleaning.md +0 -0
  44. {justhtml-3.11.2 → justhtml-3.12.0}/docs/index.md +0 -0
  45. {justhtml-3.11.2 → justhtml-3.12.0}/docs/migration-examples.md +0 -0
  46. {justhtml-3.11.2 → justhtml-3.12.0}/docs/playground/app.js +0 -0
  47. {justhtml-3.11.2 → justhtml-3.12.0}/docs/playground/index.html +0 -0
  48. {justhtml-3.11.2 → justhtml-3.12.0}/docs/playground/py/__init__.py +0 -0
  49. {justhtml-3.11.2 → justhtml-3.12.0}/docs/playground/py/install_latest_justhtml.py +0 -0
  50. {justhtml-3.11.2 → justhtml-3.12.0}/docs/playground/py/render.py +0 -0
  51. {justhtml-3.11.2 → justhtml-3.12.0}/docs/playground/py/use_local_repo.py +0 -0
  52. {justhtml-3.11.2 → justhtml-3.12.0}/docs/quickstart.md +0 -0
  53. {justhtml-3.11.2 → justhtml-3.12.0}/docs/sanitization.md +0 -0
  54. {justhtml-3.11.2 → justhtml-3.12.0}/docs/selectors.md +0 -0
  55. {justhtml-3.11.2 → justhtml-3.12.0}/docs/streaming.md +0 -0
  56. {justhtml-3.11.2 → justhtml-3.12.0}/docs/text.md +0 -0
  57. {justhtml-3.11.2 → justhtml-3.12.0}/docs/transforms.md +0 -0
  58. {justhtml-3.11.2 → justhtml-3.12.0}/docs/unsafe-handling.md +0 -0
  59. {justhtml-3.11.2 → justhtml-3.12.0}/docs/url-cleaning.md +0 -0
  60. {justhtml-3.11.2 → justhtml-3.12.0}/llms.txt +0 -0
  61. {justhtml-3.11.2 → justhtml-3.12.0}/project_ideas.md +0 -0
  62. {justhtml-3.11.2 → justhtml-3.12.0}/run_tests.py +0 -0
  63. {justhtml-3.11.2 → justhtml-3.12.0}/scripts/release.py +0 -0
  64. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/__init__.py +0 -0
  65. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/__main__.py +0 -0
  66. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/core/__init__.py +0 -0
  67. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/core/constants.py +0 -0
  68. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/core/doctype.py +0 -0
  69. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/core/entities.py +0 -0
  70. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/core/errors.py +0 -0
  71. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/core/rawtext.py +0 -0
  72. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/core/text.py +0 -0
  73. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/core/types.py +0 -0
  74. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/dom/__init__.py +0 -0
  75. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/dom/builder.py +0 -0
  76. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/parser/__init__.py +0 -0
  77. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/parser/context.py +0 -0
  78. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/parser/encoding.py +0 -0
  79. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/parser/options.py +0 -0
  80. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/parser/scanner.py +0 -0
  81. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/parser/stream.py +0 -0
  82. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/py.typed +0 -0
  83. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/sanitizer/__init__.py +0 -0
  84. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/sanitizer/css.py +0 -0
  85. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/sanitizer/dom.py +0 -0
  86. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/sanitizer/policy.py +0 -0
  87. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/sanitizer/policy_defaults.py +0 -0
  88. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/sanitizer/rawtext.py +0 -0
  89. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/sanitizer/url/__init__.py +0 -0
  90. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/sanitizer/url/policy.py +0 -0
  91. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/sanitizer/url/runtime.py +0 -0
  92. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/sanitizer/url/spec.py +0 -0
  93. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/selector/__init__.py +0 -0
  94. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/selector/core.py +0 -0
  95. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/serializer/__init__.py +0 -0
  96. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/serializer/html.py +0 -0
  97. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/serializer/markdown.py +0 -0
  98. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/transforms/__init__.py +0 -0
  99. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/transforms/compile.py +0 -0
  100. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/transforms/linkify.py +0 -0
  101. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/transforms/linkify_core.py +0 -0
  102. {justhtml-3.11.2 → justhtml-3.12.0}/src/justhtml/transforms/spec.py +0 -0
  103. {justhtml-3.11.2 → justhtml-3.12.0}/test-summary.txt +0 -0
  104. {justhtml-3.11.2 → justhtml-3.12.0}/tests/README.md +0 -0
  105. {justhtml-3.11.2 → justhtml-3.12.0}/tests/__init__.py +0 -0
  106. {justhtml-3.11.2 → justhtml-3.12.0}/tests/data/wikipedia.html +0 -0
  107. {justhtml-3.11.2 → justhtml-3.12.0}/tests/harness/__init__.py +0 -0
  108. {justhtml-3.11.2 → justhtml-3.12.0}/tests/harness/encoding.py +0 -0
  109. {justhtml-3.11.2 → justhtml-3.12.0}/tests/harness/regressions.py +0 -0
  110. {justhtml-3.11.2 → justhtml-3.12.0}/tests/harness/reporter.py +0 -0
  111. {justhtml-3.11.2 → justhtml-3.12.0}/tests/harness/scaling.py +0 -0
  112. {justhtml-3.11.2 → justhtml-3.12.0}/tests/harness/serializer.py +0 -0
  113. {justhtml-3.11.2 → justhtml-3.12.0}/tests/harness/tree.py +0 -0
  114. {justhtml-3.11.2 → justhtml-3.12.0}/tests/justhtml-sanitize-tests/cases.json +0 -0
  115. {justhtml-3.11.2 → justhtml-3.12.0}/tests/justhtml-tests/character_preprocessing.dat +0 -0
  116. {justhtml-3.11.2 → justhtml-3.12.0}/tests/justhtml-tests/document_shell_recovery.dat +0 -0
  117. {justhtml-3.11.2 → justhtml-3.12.0}/tests/justhtml-tests/entities.test +0 -0
  118. {justhtml-3.11.2 → justhtml-3.12.0}/tests/justhtml-tests/foreign_content.dat +0 -0
  119. {justhtml-3.11.2 → justhtml-3.12.0}/tests/justhtml-tests/foreign_stack_recovery.dat +0 -0
  120. {justhtml-3.11.2 → justhtml-3.12.0}/tests/justhtml-tests/frameset_recovery.dat +0 -0
  121. {justhtml-3.11.2 → justhtml-3.12.0}/tests/justhtml-tests/head_noscript_recovery.dat +0 -0
  122. {justhtml-3.11.2 → justhtml-3.12.0}/tests/justhtml-tests/iframe_srcdoc.dat +0 -0
  123. {justhtml-3.11.2 → justhtml-3.12.0}/tests/justhtml-tests/in_body_recovery.dat +0 -0
  124. {justhtml-3.11.2 → justhtml-3.12.0}/tests/justhtml-tests/raw_text_recovery.dat +0 -0
  125. {justhtml-3.11.2 → justhtml-3.12.0}/tests/justhtml-tests/select_recovery.dat +0 -0
  126. {justhtml-3.11.2 → justhtml-3.12.0}/tests/justhtml-tests/selectedcontent.dat +0 -0
  127. {justhtml-3.11.2 → justhtml-3.12.0}/tests/justhtml-tests/table_recovery.dat +0 -0
  128. {justhtml-3.11.2 → justhtml-3.12.0}/tests/justhtml-tests/template_recovery.dat +0 -0
  129. {justhtml-3.11.2 → justhtml-3.12.0}/tests/justhtml-tests/tokenizer_recovery.dat +0 -0
  130. {justhtml-3.11.2 → justhtml-3.12.0}/tests/justhtml-tests/tokenizer_state_transitions.test +0 -0
  131. {justhtml-3.11.2 → justhtml-3.12.0}/tests/justhtml-tests/tokenizer_states.test +0 -0
  132. {justhtml-3.11.2 → justhtml-3.12.0}/tests/justhtml-tests/xml_coercion.dat +0 -0
  133. {justhtml-3.11.2 → justhtml-3.12.0}/tests/justhtml-tests/xml_coercion_invalid_characters.test +0 -0
  134. {justhtml-3.11.2 → justhtml-3.12.0}/tests/linkify-it/LICENSE.txt +0 -0
  135. {justhtml-3.11.2 → justhtml-3.12.0}/tests/linkify-it/README.md +0 -0
  136. {justhtml-3.11.2 → justhtml-3.12.0}/tests/linkify-it/fixtures/links.txt +0 -0
  137. {justhtml-3.11.2 → justhtml-3.12.0}/tests/linkify-it/fixtures/not_links.txt +0 -0
  138. {justhtml-3.11.2 → justhtml-3.12.0}/tests/test_builder.py +0 -0
  139. {justhtml-3.11.2 → justhtml-3.12.0}/tests/test_cli.py +0 -0
  140. {justhtml-3.11.2 → justhtml-3.12.0}/tests/test_docs_examples.py +0 -0
  141. {justhtml-3.11.2 → justhtml-3.12.0}/tests/test_encoding.py +0 -0
  142. {justhtml-3.11.2 → justhtml-3.12.0}/tests/test_entities.py +0 -0
  143. {justhtml-3.11.2 → justhtml-3.12.0}/tests/test_errors.py +0 -0
  144. {justhtml-3.11.2 → justhtml-3.12.0}/tests/test_linkify_internals.py +0 -0
  145. {justhtml-3.11.2 → justhtml-3.12.0}/tests/test_linkify_it.py +0 -0
  146. {justhtml-3.11.2 → justhtml-3.12.0}/tests/test_linkify_transform.py +0 -0
  147. {justhtml-3.11.2 → justhtml-3.12.0}/tests/test_node.py +0 -0
  148. {justhtml-3.11.2 → justhtml-3.12.0}/tests/test_parser_regressions.py +0 -0
  149. {justhtml-3.11.2 → justhtml-3.12.0}/tests/test_parser_tree.py +0 -0
  150. {justhtml-3.11.2 → justhtml-3.12.0}/tests/test_playground_local_repo_file_list.py +0 -0
  151. {justhtml-3.11.2 → justhtml-3.12.0}/tests/test_sanitize.py +0 -0
  152. {justhtml-3.11.2 → justhtml-3.12.0}/tests/test_sanitize_transform.py +0 -0
  153. {justhtml-3.11.2 → justhtml-3.12.0}/tests/test_selector.py +0 -0
  154. {justhtml-3.11.2 → justhtml-3.12.0}/tests/test_serialize.py +0 -0
  155. {justhtml-3.11.2 → justhtml-3.12.0}/tests/test_stream.py +0 -0
  156. {justhtml-3.11.2 → justhtml-3.12.0}/tests/test_transform_runtime.py +0 -0
  157. {justhtml-3.11.2 → justhtml-3.12.0}/tests/test_transforms_compiler.py +0 -0
  158. {justhtml-3.11.2 → justhtml-3.12.0}/tests/test_transforms_sanitize_integration.py +0 -0
  159. {justhtml-3.11.2 → justhtml-3.12.0}/tests/test_wikipedia.py +0 -0
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.12.0] - 2026-09-27
11
+
12
+ ### Fixed
13
+
14
+ - Match HTML5 form recovery in template fragments, including nested and misnested forms.
15
+ - Preserve frameset eligibility when sanitized parsing omits template wrappers from body content.
16
+
17
+ ## [3.11.3] - 2026-09-27
18
+
19
+ ### Fixed
20
+
21
+ - Apply earlier transforms to allowed children hoisted when sanitization removes a parent (#85).
22
+ - Drop foreign subtrees inside disallowed HTML parents in the default parsing path.
23
+
10
24
  ## [3.11.2] - 2026-08-27
11
25
 
12
26
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: justhtml
3
- Version: 3.11.2
3
+ Version: 3.12.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
@@ -136,6 +136,7 @@ policy = SanitizationPolicy(
136
136
  allowed_attributes={"a": ["href"]},
137
137
  url_policy=UrlPolicy(allow_rules={("a", "href"): UrlRule(allowed_schemes={"https"})}),
138
138
  )
139
+ # An example that uses HTTP, so it should NOT be allowed
139
140
  text = "See http://example.com"
140
141
 
141
142
  # Wrong: Linkify's generated <a href> is created after the sanitize point,
@@ -144,10 +145,10 @@ doc = JustHTML(text, transforms=[Sanitize(policy), Linkify()])
144
145
  print(doc.to_html(pretty=False))
145
146
  # => See <a href="http://example.com">http://example.com</a>
146
147
 
147
- # Right: Linkify runs first, so its output is covered by the same sanitize pass.
148
+ # Correct: Linkify runs first, so its output is covered by the same sanitize pass.
148
149
  doc = JustHTML(text, transforms=[Linkify(), Sanitize(policy)])
149
150
  print(doc.to_html(pretty=False))
150
- # => See http://example.com
151
+ # => See <a>http://example.com</a>
151
152
  ```
152
153
 
153
154
  If you don't need other transforms between them, the simplest safe option is to omit `Sanitize()` entirely and let `sanitize=True` (the default) append it automatically after `Linkify()`.
@@ -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.11.2"
4
+ version = "3.12.0"
5
5
  description = "A pure Python HTML5 parser that just works."
6
6
  readme = "README.md"
7
7
  license = { file = "LICENSE" }
@@ -1923,6 +1923,11 @@ class ParseEngine:
1923
1923
  self._after_head = False
1924
1924
  self._explicit_head = True
1925
1925
  else:
1926
+ if self._find_open_html_index("body") is not None:
1927
+ # The compiled safe path omits parser-only template wrappers
1928
+ # from the projected tree. Keep their frameset eligibility
1929
+ # effect when they occur in body content.
1930
+ self._frameset_blocked = True
1926
1931
  self._repair_stack_for_start("template")
1927
1932
  self._append_text_boundary(self._current_parent())
1928
1933
  self._stack.append(Element("template", {}, _PARSER_ONLY_NAMESPACE))
@@ -2932,7 +2937,11 @@ class ParseEngine:
2932
2937
  self._close_open_template(tag_start, tag_end)
2933
2938
  self._finish_head_reentry()
2934
2939
  return pos
2935
- if name == "form" and not self._template_modes:
2940
+ template_fragment = self._fragment_context_name == "template" and self._fragment_context_namespace in {
2941
+ None,
2942
+ "html",
2943
+ }
2944
+ if name == "form" and not self._template_modes and not template_fragment:
2936
2945
  form_node = self._form_element
2937
2946
  self._form_element = None
2938
2947
  if form_node is None:
@@ -3522,6 +3531,15 @@ class ParseEngine:
3522
3531
  elif parent_name != "tr":
3523
3532
  return pos
3524
3533
 
3534
+ template_fragment = self._fragment_context_name == "template" and self._fragment_context_namespace in {
3535
+ None,
3536
+ "html",
3537
+ }
3538
+ if template_fragment and name == "form":
3539
+ node = self._insert_compiled_safe_element(name, attrs, self_closing, parent)
3540
+ self._nodes_to_unwrap.append(node)
3541
+ return pos
3542
+
3525
3543
  if not action.allowed:
3526
3544
  if action.pre_linefeed: # pragma: no branch - opposite edge requires invalid parser state
3527
3545
  self._ignore_lf = True # pragma: no cover - unreachable after parser-state guards
@@ -3862,8 +3880,12 @@ class ParseEngine:
3862
3880
  )
3863
3881
  return pos
3864
3882
 
3883
+ template_fragment = self._fragment_context_name == "template" and self._fragment_context_namespace in {
3884
+ None,
3885
+ "html",
3886
+ }
3865
3887
  if html_text_parsing and name == "form" and not self._template_modes:
3866
- if self._form_element is not None:
3888
+ if self._form_element is not None and not template_fragment:
3867
3889
  return pos
3868
3890
  self._repair_stack_for_start(name)
3869
3891
  node = self._insert_sanitized_element(
@@ -3874,7 +3896,8 @@ class ParseEngine:
3874
3896
  tag_start=tag_start,
3875
3897
  tag_end=tag_end,
3876
3898
  )
3877
- self._form_element = node
3899
+ if not template_fragment:
3900
+ self._form_element = node
3878
3901
  table_idx = self._find_open_index("table")
3879
3902
  in_table_mode = (
3880
3903
  table_idx is not None
@@ -4212,11 +4235,8 @@ class ParseEngine:
4212
4235
  self._foreign_context_seen = True
4213
4236
  self._nodes_to_unwrap.append(node)
4214
4237
  parent_namespace = getattr(parent, "namespace", None)
4215
- if parent is self._fragment_context_node or parent_namespace in {None, "html"}:
4216
- if not self._parser_only_template_depth and (
4217
- parent is self._fragment_context_node or parent.name in self._allowed_tags
4218
- ):
4219
- self._nodes_to_drop.append(node)
4238
+ if parent is self._fragment_context_node or parent_namespace in {None, "html", _PARSER_ONLY_NAMESPACE}:
4239
+ self._nodes_to_drop.append(node)
4220
4240
  elif name not in self._allowed_tags:
4221
4241
  self._nodes_to_unwrap.append(node)
4222
4242
  if not is_void:
@@ -231,7 +231,6 @@ def apply_compiled_transforms(
231
231
 
232
232
  if moved:
233
233
  for child in moved:
234
- _mark_start(child, mark_new_start_index)
235
234
  child.parent = parent
236
235
  replacement.extend(moved)
237
236
 
@@ -294,7 +293,6 @@ def apply_compiled_transforms(
294
293
  moved_nodes = _detach_children_for_hoist(node, name)
295
294
  if moved_nodes:
296
295
  for child in moved_nodes:
297
- _mark_start(child, transform_index)
298
296
  child.parent = parent
299
297
  children[child_index : child_index + 1] = moved_nodes
300
298
  else:
@@ -865,7 +863,6 @@ def apply_compiled_transforms(
865
863
 
866
864
  if moved_nodes_unwrap:
867
865
  for child in moved_nodes_unwrap:
868
- _mark_start(child, idx + 1)
869
866
  child.parent = parent
870
867
  children[i : i + 1] = moved_nodes_unwrap
871
868
  else:
@@ -1050,6 +1050,35 @@ class TestParserFragmentsAndTextModes(_ParserEngineTestCase):
1050
1050
  with self.subTest(html=html, context=context):
1051
1051
  self.assert_parses_to(html, expected, fragment_context=context)
1052
1052
 
1053
+ def test_template_fragment_form_recovery(self) -> None:
1054
+ cases = [
1055
+ ("<form><form>", "<form><form></form></form>", ""),
1056
+ (
1057
+ "<br>BC<form>D<div>E</form>F</div>G",
1058
+ "<br>BC<form>D<div>E</div></form>FG",
1059
+ "<br>BCD<div>E</div>FG",
1060
+ ),
1061
+ ]
1062
+ for html, expected, safe_expected in cases:
1063
+ with self.subTest(html=html):
1064
+ self.assert_parses_to(
1065
+ html,
1066
+ expected,
1067
+ fragment_context=FragmentContext("template"),
1068
+ sanitize=False,
1069
+ )
1070
+ self.assert_parses_to(
1071
+ html,
1072
+ safe_expected,
1073
+ fragment_context=FragmentContext("template"),
1074
+ )
1075
+
1076
+ def test_disallowed_template_in_body_blocks_frameset_replacement(self) -> None:
1077
+ self.assert_parses_to(
1078
+ "<p><template></template><frameset>",
1079
+ "<html><head></head><body><p></p></body></html>",
1080
+ )
1081
+
1053
1082
  def test_fragment_unwraps_disallowed_template_contents(self) -> None:
1054
1083
  self.assert_parses_to("<template>x</template>", "x", fragment=True)
1055
1084
 
@@ -1745,7 +1774,7 @@ class TestParserAttributeProjection(_ParserEngineTestCase):
1745
1774
  )
1746
1775
  self.assert_parses_to(
1747
1776
  "<tr selected> <svg href=x><table href=x></table><math a=1> </svg>\n</tr></math>\n<blockquote a=1>",
1748
- "<tbody><tr> </tr></tbody><table></table> \n\n<blockquote></blockquote>",
1777
+ "<tbody><tr> </tr></tbody><table></table>\n<blockquote></blockquote>",
1749
1778
  fragment_context=FragmentContext("table"),
1750
1779
  )
1751
1780
  self.assert_parses_to(
@@ -8,6 +8,7 @@ from typing import Any
8
8
  from justhtml import DEFAULT_POLICY, JustHTML
9
9
  from justhtml.parser.context import FragmentContext
10
10
  from justhtml.sanitizer import SanitizationPolicy, UrlPolicy, UrlProxy, UrlRule
11
+ from justhtml.transforms import Sanitize
11
12
 
12
13
  _CASES_DIR = Path(__file__).with_name("justhtml-sanitize-tests")
13
14
 
@@ -89,6 +90,16 @@ def _build_policy(spec: Any) -> SanitizationPolicy:
89
90
 
90
91
 
91
92
  class TestSanitizeIntegration(unittest.TestCase):
93
+ def test_foreign_subtree_under_disallowed_parent_is_dropped(self) -> None:
94
+ for html in (
95
+ "<table><tr><td><select><math><mi>unsafe</mi><p>safe</table>",
96
+ "<template><svg><foo><template><foreignObject><div></template><div>",
97
+ ):
98
+ fused = JustHTML(html).to_html(pretty=False)
99
+ explicit = JustHTML(html, transforms=[Sanitize()]).to_html(pretty=False)
100
+ assert fused == explicit
101
+ assert "unsafe" not in fused
102
+
92
103
  def test_sanitize_cases(self) -> None:
93
104
  cases_path = _CASES_DIR / "cases.json"
94
105
  cases = json.loads(cases_path.read_text(encoding="utf-8"))
@@ -208,6 +208,29 @@ class TestTransforms(unittest.TestCase):
208
208
  doc = JustHTML("<p>Hello <span>world</span></p>", transforms=[Unwrap("span")])
209
209
  assert doc.to_html(pretty=False) == "<html><head></head><body><p>Hello world</p></body></html>"
210
210
 
211
+ def test_hoisted_children_receive_earlier_transforms(self) -> None:
212
+ for structural_transform in (Unwrap("p, span"), Escape("p, span")):
213
+ doc = JustHTML(
214
+ "<p><span><a href='https://eff.org'>EFF</a></span></p>",
215
+ fragment=True,
216
+ transforms=[SetAttrs("a", rel="noopener"), structural_transform],
217
+ )
218
+ assert '<a href="https://eff.org" rel="noopener">EFF</a>' in doc.to_html(pretty=False)
219
+
220
+ def test_sanitize_hoisted_children_receive_earlier_transforms(self) -> None:
221
+ policy = SanitizationPolicy(
222
+ allowed_tags={"a"},
223
+ allowed_attributes={"a": {"href", "rel"}},
224
+ url_policy=UrlPolicy(allow_rules={("a", "href"): UrlRule(allowed_schemes={"https"})}),
225
+ )
226
+ doc = _JustHTML(
227
+ '<p><span><a href="https://eff.org">EFF</a></span></p>',
228
+ fragment=True,
229
+ transforms=[SetAttrs("a", rel="noopener")],
230
+ policy=policy,
231
+ )
232
+ assert doc.to_html(pretty=False) == '<a href="https://eff.org" rel="noopener">EFF</a>'
233
+
211
234
  def test_unwrap_handles_empty_elements(self) -> None:
212
235
  doc = JustHTML("<div><span></span>ok</div>", transforms=[Unwrap("span")])
213
236
  assert doc.to_html(pretty=False) == "<html><head></head><body><div>ok</div></body></html>"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes