justhtml 3.11.0__tar.gz → 3.11.2__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.0 → justhtml-3.11.2}/CHANGELOG.md +14 -0
  2. {justhtml-3.11.0 → justhtml-3.11.2}/PKG-INFO +2 -2
  3. {justhtml-3.11.0 → justhtml-3.11.2}/benchmarks/correctness.py +27 -10
  4. {justhtml-3.11.0 → justhtml-3.11.2}/docs/comparison.md +1 -1
  5. {justhtml-3.11.0 → justhtml-3.11.2}/pyproject.toml +1 -1
  6. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/parser/engine.py +13 -6
  7. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/serializer/html.py +36 -7
  8. {justhtml-3.11.0 → justhtml-3.11.2}/tests/test_parser_engine.py +4 -0
  9. {justhtml-3.11.0 → justhtml-3.11.2}/tests/test_sanitize.py +35 -0
  10. {justhtml-3.11.0 → justhtml-3.11.2}/tests/test_serialize.py +10 -0
  11. {justhtml-3.11.0 → justhtml-3.11.2}/.github/workflows/ci.yml +0 -0
  12. {justhtml-3.11.0 → justhtml-3.11.2}/.github/workflows/publish.yml +0 -0
  13. {justhtml-3.11.0 → justhtml-3.11.2}/.gitignore +0 -0
  14. {justhtml-3.11.0 → justhtml-3.11.2}/.pre-commit-config.yaml +0 -0
  15. {justhtml-3.11.0 → justhtml-3.11.2}/AGENTS.md +0 -0
  16. {justhtml-3.11.0 → justhtml-3.11.2}/ARCHITECTURE.md +0 -0
  17. {justhtml-3.11.0 → justhtml-3.11.2}/CODE_OF_CONDUCT.md +0 -0
  18. {justhtml-3.11.0 → justhtml-3.11.2}/CONTRIBUTING.md +0 -0
  19. {justhtml-3.11.0 → justhtml-3.11.2}/LICENSE +0 -0
  20. {justhtml-3.11.0 → justhtml-3.11.2}/Makefile +0 -0
  21. {justhtml-3.11.0 → justhtml-3.11.2}/PERFORMANCE.md +0 -0
  22. {justhtml-3.11.0 → justhtml-3.11.2}/README.md +0 -0
  23. {justhtml-3.11.0 → justhtml-3.11.2}/SECURITY.md +0 -0
  24. {justhtml-3.11.0 → justhtml-3.11.2}/assets/justhtml-readme-explainer.png +0 -0
  25. {justhtml-3.11.0 → justhtml-3.11.2}/benchmarks/chromium_parser_diff.py +0 -0
  26. {justhtml-3.11.0 → justhtml-3.11.2}/benchmarks/fuzz.py +0 -0
  27. {justhtml-3.11.0 → justhtml-3.11.2}/benchmarks/html5lib_engine_diff.py +0 -0
  28. {justhtml-3.11.0 → justhtml-3.11.2}/benchmarks/parser_engine_gate.py +0 -0
  29. {justhtml-3.11.0 → justhtml-3.11.2}/benchmarks/performance.py +0 -0
  30. {justhtml-3.11.0 → justhtml-3.11.2}/benchmarks/profile.py +0 -0
  31. {justhtml-3.11.0 → justhtml-3.11.2}/docs/_config.yml +0 -0
  32. {justhtml-3.11.0 → justhtml-3.11.2}/docs/_layouts/default.html +0 -0
  33. {justhtml-3.11.0 → justhtml-3.11.2}/docs/api.md +0 -0
  34. {justhtml-3.11.0 → justhtml-3.11.2}/docs/assets/search.js +0 -0
  35. {justhtml-3.11.0 → justhtml-3.11.2}/docs/bleach-migration.md +0 -0
  36. {justhtml-3.11.0 → justhtml-3.11.2}/docs/building.md +0 -0
  37. {justhtml-3.11.0 → justhtml-3.11.2}/docs/cli.md +0 -0
  38. {justhtml-3.11.0 → justhtml-3.11.2}/docs/correctness.md +0 -0
  39. {justhtml-3.11.0 → justhtml-3.11.2}/docs/encoding.md +0 -0
  40. {justhtml-3.11.0 → justhtml-3.11.2}/docs/errors.md +0 -0
  41. {justhtml-3.11.0 → justhtml-3.11.2}/docs/fragments.md +0 -0
  42. {justhtml-3.11.0 → justhtml-3.11.2}/docs/html-cleaning.md +0 -0
  43. {justhtml-3.11.0 → justhtml-3.11.2}/docs/index.md +0 -0
  44. {justhtml-3.11.0 → justhtml-3.11.2}/docs/linkify.md +0 -0
  45. {justhtml-3.11.0 → justhtml-3.11.2}/docs/migration-examples.md +0 -0
  46. {justhtml-3.11.0 → justhtml-3.11.2}/docs/playground/app.js +0 -0
  47. {justhtml-3.11.0 → justhtml-3.11.2}/docs/playground/index.html +0 -0
  48. {justhtml-3.11.0 → justhtml-3.11.2}/docs/playground/py/__init__.py +0 -0
  49. {justhtml-3.11.0 → justhtml-3.11.2}/docs/playground/py/install_latest_justhtml.py +0 -0
  50. {justhtml-3.11.0 → justhtml-3.11.2}/docs/playground/py/render.py +0 -0
  51. {justhtml-3.11.0 → justhtml-3.11.2}/docs/playground/py/use_local_repo.py +0 -0
  52. {justhtml-3.11.0 → justhtml-3.11.2}/docs/quickstart.md +0 -0
  53. {justhtml-3.11.0 → justhtml-3.11.2}/docs/sanitization.md +0 -0
  54. {justhtml-3.11.0 → justhtml-3.11.2}/docs/selectors.md +0 -0
  55. {justhtml-3.11.0 → justhtml-3.11.2}/docs/streaming.md +0 -0
  56. {justhtml-3.11.0 → justhtml-3.11.2}/docs/text.md +0 -0
  57. {justhtml-3.11.0 → justhtml-3.11.2}/docs/transforms.md +0 -0
  58. {justhtml-3.11.0 → justhtml-3.11.2}/docs/unsafe-handling.md +0 -0
  59. {justhtml-3.11.0 → justhtml-3.11.2}/docs/url-cleaning.md +0 -0
  60. {justhtml-3.11.0 → justhtml-3.11.2}/llms.txt +0 -0
  61. {justhtml-3.11.0 → justhtml-3.11.2}/project_ideas.md +0 -0
  62. {justhtml-3.11.0 → justhtml-3.11.2}/run_tests.py +0 -0
  63. {justhtml-3.11.0 → justhtml-3.11.2}/scripts/release.py +0 -0
  64. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/__init__.py +0 -0
  65. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/__main__.py +0 -0
  66. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/core/__init__.py +0 -0
  67. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/core/constants.py +0 -0
  68. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/core/doctype.py +0 -0
  69. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/core/entities.py +0 -0
  70. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/core/errors.py +0 -0
  71. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/core/rawtext.py +0 -0
  72. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/core/text.py +0 -0
  73. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/core/types.py +0 -0
  74. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/dom/__init__.py +0 -0
  75. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/dom/builder.py +0 -0
  76. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/parser/__init__.py +0 -0
  77. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/parser/context.py +0 -0
  78. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/parser/encoding.py +0 -0
  79. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/parser/options.py +0 -0
  80. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/parser/scanner.py +0 -0
  81. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/parser/stream.py +0 -0
  82. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/py.typed +0 -0
  83. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/sanitizer/__init__.py +0 -0
  84. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/sanitizer/css.py +0 -0
  85. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/sanitizer/dom.py +0 -0
  86. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/sanitizer/policy.py +0 -0
  87. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/sanitizer/policy_defaults.py +0 -0
  88. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/sanitizer/rawtext.py +0 -0
  89. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/sanitizer/url/__init__.py +0 -0
  90. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/sanitizer/url/policy.py +0 -0
  91. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/sanitizer/url/runtime.py +0 -0
  92. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/sanitizer/url/spec.py +0 -0
  93. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/selector/__init__.py +0 -0
  94. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/selector/core.py +0 -0
  95. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/serializer/__init__.py +0 -0
  96. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/serializer/markdown.py +0 -0
  97. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/transforms/__init__.py +0 -0
  98. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/transforms/compile.py +0 -0
  99. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/transforms/linkify.py +0 -0
  100. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/transforms/linkify_core.py +0 -0
  101. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/transforms/runtime.py +0 -0
  102. {justhtml-3.11.0 → justhtml-3.11.2}/src/justhtml/transforms/spec.py +0 -0
  103. {justhtml-3.11.0 → justhtml-3.11.2}/test-summary.txt +0 -0
  104. {justhtml-3.11.0 → justhtml-3.11.2}/tests/README.md +0 -0
  105. {justhtml-3.11.0 → justhtml-3.11.2}/tests/__init__.py +0 -0
  106. {justhtml-3.11.0 → justhtml-3.11.2}/tests/data/wikipedia.html +0 -0
  107. {justhtml-3.11.0 → justhtml-3.11.2}/tests/harness/__init__.py +0 -0
  108. {justhtml-3.11.0 → justhtml-3.11.2}/tests/harness/encoding.py +0 -0
  109. {justhtml-3.11.0 → justhtml-3.11.2}/tests/harness/regressions.py +0 -0
  110. {justhtml-3.11.0 → justhtml-3.11.2}/tests/harness/reporter.py +0 -0
  111. {justhtml-3.11.0 → justhtml-3.11.2}/tests/harness/scaling.py +0 -0
  112. {justhtml-3.11.0 → justhtml-3.11.2}/tests/harness/serializer.py +0 -0
  113. {justhtml-3.11.0 → justhtml-3.11.2}/tests/harness/tree.py +0 -0
  114. {justhtml-3.11.0 → justhtml-3.11.2}/tests/justhtml-sanitize-tests/cases.json +0 -0
  115. {justhtml-3.11.0 → justhtml-3.11.2}/tests/justhtml-tests/character_preprocessing.dat +0 -0
  116. {justhtml-3.11.0 → justhtml-3.11.2}/tests/justhtml-tests/document_shell_recovery.dat +0 -0
  117. {justhtml-3.11.0 → justhtml-3.11.2}/tests/justhtml-tests/entities.test +0 -0
  118. {justhtml-3.11.0 → justhtml-3.11.2}/tests/justhtml-tests/foreign_content.dat +0 -0
  119. {justhtml-3.11.0 → justhtml-3.11.2}/tests/justhtml-tests/foreign_stack_recovery.dat +0 -0
  120. {justhtml-3.11.0 → justhtml-3.11.2}/tests/justhtml-tests/frameset_recovery.dat +0 -0
  121. {justhtml-3.11.0 → justhtml-3.11.2}/tests/justhtml-tests/head_noscript_recovery.dat +0 -0
  122. {justhtml-3.11.0 → justhtml-3.11.2}/tests/justhtml-tests/iframe_srcdoc.dat +0 -0
  123. {justhtml-3.11.0 → justhtml-3.11.2}/tests/justhtml-tests/in_body_recovery.dat +0 -0
  124. {justhtml-3.11.0 → justhtml-3.11.2}/tests/justhtml-tests/raw_text_recovery.dat +0 -0
  125. {justhtml-3.11.0 → justhtml-3.11.2}/tests/justhtml-tests/select_recovery.dat +0 -0
  126. {justhtml-3.11.0 → justhtml-3.11.2}/tests/justhtml-tests/selectedcontent.dat +0 -0
  127. {justhtml-3.11.0 → justhtml-3.11.2}/tests/justhtml-tests/table_recovery.dat +0 -0
  128. {justhtml-3.11.0 → justhtml-3.11.2}/tests/justhtml-tests/template_recovery.dat +0 -0
  129. {justhtml-3.11.0 → justhtml-3.11.2}/tests/justhtml-tests/tokenizer_recovery.dat +0 -0
  130. {justhtml-3.11.0 → justhtml-3.11.2}/tests/justhtml-tests/tokenizer_state_transitions.test +0 -0
  131. {justhtml-3.11.0 → justhtml-3.11.2}/tests/justhtml-tests/tokenizer_states.test +0 -0
  132. {justhtml-3.11.0 → justhtml-3.11.2}/tests/justhtml-tests/xml_coercion.dat +0 -0
  133. {justhtml-3.11.0 → justhtml-3.11.2}/tests/justhtml-tests/xml_coercion_invalid_characters.test +0 -0
  134. {justhtml-3.11.0 → justhtml-3.11.2}/tests/linkify-it/LICENSE.txt +0 -0
  135. {justhtml-3.11.0 → justhtml-3.11.2}/tests/linkify-it/README.md +0 -0
  136. {justhtml-3.11.0 → justhtml-3.11.2}/tests/linkify-it/fixtures/links.txt +0 -0
  137. {justhtml-3.11.0 → justhtml-3.11.2}/tests/linkify-it/fixtures/not_links.txt +0 -0
  138. {justhtml-3.11.0 → justhtml-3.11.2}/tests/test_builder.py +0 -0
  139. {justhtml-3.11.0 → justhtml-3.11.2}/tests/test_cli.py +0 -0
  140. {justhtml-3.11.0 → justhtml-3.11.2}/tests/test_docs_examples.py +0 -0
  141. {justhtml-3.11.0 → justhtml-3.11.2}/tests/test_encoding.py +0 -0
  142. {justhtml-3.11.0 → justhtml-3.11.2}/tests/test_entities.py +0 -0
  143. {justhtml-3.11.0 → justhtml-3.11.2}/tests/test_errors.py +0 -0
  144. {justhtml-3.11.0 → justhtml-3.11.2}/tests/test_linkify_internals.py +0 -0
  145. {justhtml-3.11.0 → justhtml-3.11.2}/tests/test_linkify_it.py +0 -0
  146. {justhtml-3.11.0 → justhtml-3.11.2}/tests/test_linkify_transform.py +0 -0
  147. {justhtml-3.11.0 → justhtml-3.11.2}/tests/test_node.py +0 -0
  148. {justhtml-3.11.0 → justhtml-3.11.2}/tests/test_parser_regressions.py +0 -0
  149. {justhtml-3.11.0 → justhtml-3.11.2}/tests/test_parser_tree.py +0 -0
  150. {justhtml-3.11.0 → justhtml-3.11.2}/tests/test_playground_local_repo_file_list.py +0 -0
  151. {justhtml-3.11.0 → justhtml-3.11.2}/tests/test_sanitize_integration.py +0 -0
  152. {justhtml-3.11.0 → justhtml-3.11.2}/tests/test_sanitize_transform.py +0 -0
  153. {justhtml-3.11.0 → justhtml-3.11.2}/tests/test_selector.py +0 -0
  154. {justhtml-3.11.0 → justhtml-3.11.2}/tests/test_stream.py +0 -0
  155. {justhtml-3.11.0 → justhtml-3.11.2}/tests/test_transform_runtime.py +0 -0
  156. {justhtml-3.11.0 → justhtml-3.11.2}/tests/test_transforms.py +0 -0
  157. {justhtml-3.11.0 → justhtml-3.11.2}/tests/test_transforms_compiler.py +0 -0
  158. {justhtml-3.11.0 → justhtml-3.11.2}/tests/test_transforms_sanitize_integration.py +0 -0
  159. {justhtml-3.11.0 → justhtml-3.11.2}/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.11.2] - 2026-08-27
11
+
12
+ ### Fixed
13
+
14
+ - Preserve content following closed `script` or `style` elements in dropped SVG and MathML subtrees when parsing
15
+ fragments with a custom sanitization policy.
16
+ - WPT update: Foster invalid row content correctly when a disallowed template appears inside a table.
17
+
18
+ ## [3.11.1] - 2026-08-16
19
+
20
+ ### Fixed
21
+
22
+ - Preserve rendered word separation when pretty HTML serialization normalizes a newline next to an inline element.
23
+
10
24
  ## [3.11.0] - 2026-08-07
11
25
 
12
26
  ### Performance
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: justhtml
3
- Version: 3.11.0
3
+ Version: 3.11.2
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
@@ -16,6 +16,7 @@ import sys
16
16
  from dataclasses import dataclass
17
17
  from enum import Enum
18
18
  from pathlib import Path
19
+ from typing import Final
19
20
 
20
21
  from justhtml import JustHTML
21
22
  from justhtml.parser.context import FragmentContext
@@ -577,6 +578,19 @@ NS_MATHML = "http://www.w3.org/1998/Math/MathML"
577
578
  NS_XLINK = "http://www.w3.org/1999/xlink"
578
579
  NS_XML = "http://www.w3.org/XML/1998/namespace"
579
580
  NS_XMLNS = "http://www.w3.org/2000/xmlns/"
581
+ _TURBOHTML_FOREIGN_ATTRIBUTE_NAMES: Final[dict[str, str]] = {
582
+ "xlink:actuate": "xlink actuate",
583
+ "xlink:arcrole": "xlink arcrole",
584
+ "xlink:href": "xlink href",
585
+ "xlink:role": "xlink role",
586
+ "xlink:show": "xlink show",
587
+ "xlink:title": "xlink title",
588
+ "xlink:type": "xlink type",
589
+ "xml:lang": "xml lang",
590
+ "xml:space": "xml space",
591
+ "xmlns": "xmlns xmlns",
592
+ "xmlns:xlink": "xmlns xlink",
593
+ }
580
594
 
581
595
 
582
596
  def _serialize_lxml_document_siblings(root, etree):
@@ -914,14 +928,15 @@ def _turbohtml_to_test_format(nodes, indent):
914
928
  return f"math {name}"
915
929
  return name
916
930
 
917
- def attribute_name(name):
918
- if name.startswith("xlink:"):
919
- return f"xlink {name.removeprefix('xlink:')}"
920
- if name.startswith("xml:"):
921
- return f"xml {name.removeprefix('xml:')}"
922
- if name.startswith("xmlns:"):
923
- return f"xmlns {name.removeprefix('xmlns:')}"
924
- return name
931
+ def attribute_name(namespace, name):
932
+ if namespace not in {Namespace.SVG, Namespace.MATHML}:
933
+ return name
934
+ return _TURBOHTML_FOREIGN_ATTRIBUTE_NAMES.get(name, name)
935
+
936
+ def attribute_value(value):
937
+ if isinstance(value, list):
938
+ return " ".join(value)
939
+ return value or ""
925
940
 
926
941
  def process(node, node_indent):
927
942
  prefix = " " * node_indent
@@ -939,8 +954,10 @@ def _turbohtml_to_test_format(nodes, indent):
939
954
  return [line for child in node.children for line in process(child, node_indent)]
940
955
 
941
956
  lines = [f"| {prefix}<{qualified_name(node.namespace, node.tag)}>"]
942
- for name, value in sorted(node.attrs.items()):
943
- lines.append(f'| {prefix} {attribute_name(name)}="{value}"')
957
+ for name, value in sorted(
958
+ (attribute_name(node.namespace, name), attribute_value(value)) for name, value in node.attrs.items()
959
+ ):
960
+ lines.append(f'| {prefix} {name}="{value}"')
944
961
 
945
962
  if node.tag == "template" and node.namespace is Namespace.HTML:
946
963
  lines.append(f"| {prefix} content")
@@ -13,7 +13,7 @@ Use a different tool when one narrow requirement matters more than the whole pip
13
13
  | **JustHTML**<br>Pure Python | ✅ 100% | ⚡ Fast | ✅ CSS selectors | ✅ `element()` | ✅ Built-in | Correct, secure, easy to install, and fast enough. |
14
14
  | **`selectolax`**<br>Python wrapper of C-based Lexbor | ✅ 100% | 🚀 Very Fast | ✅ CSS selectors | ✅ `create_node()` | ❌ Needs sanitization | Very fast. |
15
15
  | **Chromium**<br>browser engine | 🟡 95.0% [2] | 🚀 Very Fast | — | — | — | Current browser-harness result. |
16
- | **`turbohtml`**<br>Python wrapper of a C core | 🟡 94.2% | 🚀 Very Fast | ✅ CSS selectors, XPath | ✅ `E.*` builder | ✅ Built-in | Broad, compiled alternative with parsing, querying, and sanitization. |
16
+ | **`turbohtml`**<br>Python wrapper of a C core | 🟡 94.8% | 🚀 Very Fast | ✅ CSS selectors, XPath | ✅ `E.*` builder | ✅ Built-in | Broad, compiled alternative with parsing, querying, and sanitization. |
17
17
  | **WebKit**<br>browser engine | 🟡 93.9% [2] | 🚀 Very Fast | — | — | — | Current browser-harness result. |
18
18
  | **Firefox**<br>browser engine | 🟡 93.1% [2] | 🚀 Very Fast | — | — | — | Current browser-harness result. |
19
19
  | **`html5lib`**<br>Pure Python | 🟡 82.3% | 🐢 Slow | 🟡 XPath (lxml) | 🟡 Tree API | 🔴 [Deprecated](https://github.com/html5lib/html5lib-python/issues/443) | Unmaintained reference implementation; incomplete coverage of the tree-construction fixtures. |
@@ -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.0"
4
+ version = "3.11.2"
5
5
  description = "A pure Python HTML5 parser that just works."
6
6
  readme = "README.md"
7
7
  license = { file = "LICENSE" }
@@ -1435,8 +1435,7 @@ class ParseEngine:
1435
1435
  # rules, which may splice a form or run adoption, rather than
1436
1436
  # popping the foreign elements sitting above it.
1437
1437
  return False
1438
- if self._track_tag_spans:
1439
- self._set_end_span(node, name, tag_start, tag_end)
1438
+ self._set_end_span(node, name, tag_start, tag_end)
1440
1439
  del stack[target_index:]
1441
1440
  return True
1442
1441
 
@@ -5250,14 +5249,22 @@ class ParseEngine:
5250
5249
  ): # pragma: no branch - opposite edge requires invalid parser state
5251
5250
  return None # pragma: no cover - unreachable after parser-state guards
5252
5251
  table_idx = self._find_open_index("table")
5253
- # Fostering runs once per node placed inside a table, and no template is
5254
- # open for the overwhelming majority of them. Both counters below are
5255
- # maintained anyway, so consulting them skips two stack lookups.
5256
5252
  parser_only_template_idx = (
5257
5253
  self._open_parser_only_template_index() if self._parser_only_template_depth else None
5258
5254
  )
5259
- if table_idx is not None and parser_only_template_idx is not None and table_idx < parser_only_template_idx:
5255
+ # A parser-only template projects direct children into its rendered
5256
+ # table parent. Descendants of those children still foster within the
5257
+ # projected template contents, for example a <div> inside a <tr>.
5258
+ if (
5259
+ table_idx is not None
5260
+ and parser_only_template_idx is not None
5261
+ and table_idx < parser_only_template_idx
5262
+ and self._stack[table_idx] is parent
5263
+ ):
5260
5264
  return None
5265
+ # Fostering runs once per node placed inside a table, and no template is
5266
+ # open for the overwhelming majority of them. Both counters below are
5267
+ # maintained anyway, so consulting them skips two stack lookups.
5261
5268
  template_idx = self._open_template_index() if self._template_modes else None
5262
5269
  if table_idx is not None and template_idx is not None and table_idx < template_idx:
5263
5270
  table_idx = None
@@ -495,7 +495,12 @@ def _collapse_html_whitespace(text: str) -> str:
495
495
  return " ".join(text.split())
496
496
 
497
497
 
498
- def _normalize_formatting_whitespace(text: str) -> str:
498
+ def _normalize_formatting_whitespace(
499
+ text: str,
500
+ *,
501
+ preserve_leading: bool = False,
502
+ preserve_trailing: bool = False,
503
+ ) -> str:
499
504
  """Normalize formatting whitespace within a text node.
500
505
 
501
506
  Converts newlines/tabs/CR/FF to regular spaces and collapses runs that
@@ -543,9 +548,9 @@ def _normalize_formatting_whitespace(text: str) -> str:
543
548
  out.append(ch)
544
549
 
545
550
  normalized = "".join(out)
546
- if starts_with_formatting and normalized.startswith(" "):
551
+ if starts_with_formatting and normalized.startswith(" ") and not preserve_leading:
547
552
  normalized = normalized[1:]
548
- if ends_with_formatting and normalized.endswith(" "):
553
+ if ends_with_formatting and normalized.endswith(" ") and not preserve_trailing:
549
554
  normalized = normalized[:-1]
550
555
  return normalized
551
556
 
@@ -1025,14 +1030,23 @@ def _node_to_html(node: Any, indent: int = 0, indent_size: int = 2, *, in_pre: b
1025
1030
  continue
1026
1031
 
1027
1032
  parts_template: list[tuple[str, Any]] = []
1028
- for child in run:
1033
+ for run_index, child in enumerate(run):
1029
1034
  if child.name == "#text":
1030
1035
  data = child.data or ""
1031
1036
  if not data.strip():
1032
1037
  parts_template.append(("lit", data))
1033
1038
  else:
1034
1039
  parts_template.append(
1035
- ("lit", _escape_text(_normalize_formatting_whitespace(data)))
1040
+ (
1041
+ "lit",
1042
+ _escape_text(
1043
+ _normalize_formatting_whitespace(
1044
+ data,
1045
+ preserve_leading=run_index > 0,
1046
+ preserve_trailing=run_index < len(run) - 1,
1047
+ )
1048
+ ),
1049
+ )
1036
1050
  )
1037
1051
  continue
1038
1052
 
@@ -1115,7 +1129,18 @@ def _node_to_html(node: Any, indent: int = 0, indent_size: int = 2, *, in_pre: b
1115
1129
  inline_parts.append(("lit", data))
1116
1130
  continue
1117
1131
 
1118
- inline_parts.append(("lit", _escape_text(_normalize_formatting_whitespace(data))))
1132
+ inline_parts.append(
1133
+ (
1134
+ "lit",
1135
+ _escape_text(
1136
+ _normalize_formatting_whitespace(
1137
+ data,
1138
+ preserve_leading=i != first_non_none_index,
1139
+ preserve_trailing=i != last_non_none_index,
1140
+ )
1141
+ ),
1142
+ )
1143
+ )
1119
1144
  continue
1120
1145
 
1121
1146
  if _is_layout_blocky_element(child, memo):
@@ -1249,7 +1274,11 @@ def _node_to_html(node: Any, indent: int = 0, indent_size: int = 2, *, in_pre: b
1249
1274
  if "\n" in data or "\r" in data or "\t" in data or len(data) > 2:
1250
1275
  compact_parts_template.append(("lit", " "))
1251
1276
  continue
1252
- data = _normalize_formatting_whitespace(data)
1277
+ data = _normalize_formatting_whitespace(
1278
+ data,
1279
+ preserve_leading=i != first_non_none_index,
1280
+ preserve_trailing=i != last_non_none_index,
1281
+ )
1253
1282
  compact_parts_template.append(("lit", _escape_text(data)))
1254
1283
  continue
1255
1284
 
@@ -1451,6 +1451,10 @@ class TestParserRecoveryRegressions(_ParserEngineTestCase):
1451
1451
  default_cases = [
1452
1452
  ("<script>x", "<html><head></head><body></body></html>"),
1453
1453
  ("<table><template>x</table></template>", "<html><head></head><body><table>x</table></body></html>"),
1454
+ (
1455
+ "<table><template><tr><div></div></tr></template>",
1456
+ "<html><head></head><body><table><tr></tr><div></div></table></body></html>",
1457
+ ),
1454
1458
  ("<svg><noframes>x</svg></noframes>", "<html><head></head><body></body></html>"),
1455
1459
  ("<template><colgroup>x</colgroup></template>", "<html><head></head><body></body></html>"),
1456
1460
  ("<table><colgroup>x</table></colgroup>", "<html><head></head><body>x<table></table></body></html>"),
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import unittest
4
+ from dataclasses import replace
4
5
 
5
6
  import justhtml
6
7
  from justhtml import JustHTML, Sanitize, SetAttrs
@@ -355,6 +356,40 @@ class TestSanitizePlumbing(unittest.TestCase):
355
356
 
356
357
 
357
358
  class TestSanitizeDom(unittest.TestCase):
359
+ def test_custom_policy_preserves_content_after_closed_foreign_rawtext(self) -> None:
360
+ policies = (
361
+ replace(DEFAULT_POLICY),
362
+ SanitizationPolicy(
363
+ allowed_tags={"p"},
364
+ allowed_attributes={"p": set()},
365
+ url_policy=UrlPolicy(allow_rules={}),
366
+ ),
367
+ )
368
+
369
+ for policy in policies:
370
+ for foreign_tag in ("math", "svg"):
371
+ for rawtext_tag in ("script", "style"):
372
+ html = f"<{foreign_tag}><{rawtext_tag}></{rawtext_tag}></{foreign_tag}>tail<p>end</p>"
373
+ with self.subTest(policy=policy, foreign_tag=foreign_tag, rawtext_tag=rawtext_tag):
374
+ assert JustHTML(html, fragment=True, policy=policy).to_html(pretty=False) == "tail<p>end</p>"
375
+ assert (
376
+ JustHTML(
377
+ html,
378
+ fragment=True,
379
+ transforms=[Sanitize(policy=policy)],
380
+ ).to_html(pretty=False)
381
+ == "tail<p>end</p>"
382
+ )
383
+
384
+ def test_custom_policy_drops_content_after_unclosed_foreign_rawtext(self) -> None:
385
+ policy = replace(DEFAULT_POLICY)
386
+
387
+ for foreign_tag in ("math", "svg"):
388
+ for rawtext_tag in ("script", "style"):
389
+ html = f"<{foreign_tag}><{rawtext_tag}>unsafe</{foreign_tag}>tail<p>end</p>"
390
+ with self.subTest(foreign_tag=foreign_tag, rawtext_tag=rawtext_tag):
391
+ assert JustHTML(html, fragment=True, policy=policy).to_html(pretty=False) == ""
392
+
358
393
  def test_sanitize_dom_document_fragment(self) -> None:
359
394
  root = DocumentFragment()
360
395
  root.append_child(Node("script"))
@@ -820,6 +820,16 @@ class TestSerialize(unittest.TestCase):
820
820
  rendered_text = JustHTML(pretty_html).to_text(separator="", strip=False)
821
821
  assert rendered_text == "BApplication(const char* signature);"
822
822
 
823
+ def test_pretty_print_preserves_formatting_whitespace_before_inline_element(self):
824
+ doc = JustHTML("<p>hello\n<code>world</code></p>", fragment=True)
825
+
826
+ assert doc.to_html() == "<p>hello <code>world</code></p>"
827
+
828
+ def test_pretty_print_preserves_formatting_whitespace_after_inline_element(self):
829
+ doc = JustHTML("<p><code>hello</code>\nworld</p>", fragment=True)
830
+
831
+ assert doc.to_html() == "<p><code>hello</code> world</p>"
832
+
823
833
  def test_empty_attributes(self):
824
834
  html = "<input disabled>"
825
835
  doc = JustHTML(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