justhtml 2.2.0__tar.gz → 2.3.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 (143) hide show
  1. {justhtml-2.2.0 → justhtml-2.3.0}/CHANGELOG.md +11 -0
  2. {justhtml-2.2.0 → justhtml-2.3.0}/PKG-INFO +1 -1
  3. {justhtml-2.2.0 → justhtml-2.3.0}/pyproject.toml +1 -1
  4. {justhtml-2.2.0 → justhtml-2.3.0}/run_tests.py +13 -0
  5. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/parser/__init__.py +1 -0
  6. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/serializer/html.py +1 -1
  7. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/treebuilder/core.py +149 -53
  8. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/treebuilder/modes.py +23 -35
  9. {justhtml-2.2.0 → justhtml-2.3.0}/tests/test_linkify_internals.py +6 -2
  10. {justhtml-2.2.0 → justhtml-2.3.0}/tests/test_serialize.py +66 -7
  11. {justhtml-2.2.0 → justhtml-2.3.0}/tests/test_treebuilder.py +138 -4
  12. justhtml-2.2.0/tests/test_precommit_coverage.py +0 -83
  13. {justhtml-2.2.0 → justhtml-2.3.0}/.github/copilot-instructions.md +0 -0
  14. {justhtml-2.2.0 → justhtml-2.3.0}/.github/workflows/ci.yml +0 -0
  15. {justhtml-2.2.0 → justhtml-2.3.0}/.github/workflows/publish.yml +0 -0
  16. {justhtml-2.2.0 → justhtml-2.3.0}/.gitignore +0 -0
  17. {justhtml-2.2.0 → justhtml-2.3.0}/.pre-commit-config.yaml +0 -0
  18. {justhtml-2.2.0 → justhtml-2.3.0}/CODE_OF_CONDUCT.md +0 -0
  19. {justhtml-2.2.0 → justhtml-2.3.0}/CONTRIBUTING.md +0 -0
  20. {justhtml-2.2.0 → justhtml-2.3.0}/LICENSE +0 -0
  21. {justhtml-2.2.0 → justhtml-2.3.0}/Makefile +0 -0
  22. {justhtml-2.2.0 → justhtml-2.3.0}/README.md +0 -0
  23. {justhtml-2.2.0 → justhtml-2.3.0}/SECURITY.md +0 -0
  24. {justhtml-2.2.0 → justhtml-2.3.0}/assets/justhtml-readme-explainer.png +0 -0
  25. {justhtml-2.2.0 → justhtml-2.3.0}/benchmarks/correctness.py +0 -0
  26. {justhtml-2.2.0 → justhtml-2.3.0}/benchmarks/fuzz.py +0 -0
  27. {justhtml-2.2.0 → justhtml-2.3.0}/benchmarks/performance.py +0 -0
  28. {justhtml-2.2.0 → justhtml-2.3.0}/benchmarks/profile.py +0 -0
  29. {justhtml-2.2.0 → justhtml-2.3.0}/docs/_config.yml +0 -0
  30. {justhtml-2.2.0 → justhtml-2.3.0}/docs/_layouts/default.html +0 -0
  31. {justhtml-2.2.0 → justhtml-2.3.0}/docs/api.md +0 -0
  32. {justhtml-2.2.0 → justhtml-2.3.0}/docs/assets/search.js +0 -0
  33. {justhtml-2.2.0 → justhtml-2.3.0}/docs/bleach-migration.md +0 -0
  34. {justhtml-2.2.0 → justhtml-2.3.0}/docs/building.md +0 -0
  35. {justhtml-2.2.0 → justhtml-2.3.0}/docs/cli.md +0 -0
  36. {justhtml-2.2.0 → justhtml-2.3.0}/docs/comparison.md +0 -0
  37. {justhtml-2.2.0 → justhtml-2.3.0}/docs/correctness.md +0 -0
  38. {justhtml-2.2.0 → justhtml-2.3.0}/docs/encoding.md +0 -0
  39. {justhtml-2.2.0 → justhtml-2.3.0}/docs/errors.md +0 -0
  40. {justhtml-2.2.0 → justhtml-2.3.0}/docs/fragments.md +0 -0
  41. {justhtml-2.2.0 → justhtml-2.3.0}/docs/html-cleaning.md +0 -0
  42. {justhtml-2.2.0 → justhtml-2.3.0}/docs/index.md +0 -0
  43. {justhtml-2.2.0 → justhtml-2.3.0}/docs/linkify.md +0 -0
  44. {justhtml-2.2.0 → justhtml-2.3.0}/docs/migration-examples.md +0 -0
  45. {justhtml-2.2.0 → justhtml-2.3.0}/docs/playground/app.js +0 -0
  46. {justhtml-2.2.0 → justhtml-2.3.0}/docs/playground/index.html +0 -0
  47. {justhtml-2.2.0 → justhtml-2.3.0}/docs/playground/py/__init__.py +0 -0
  48. {justhtml-2.2.0 → justhtml-2.3.0}/docs/playground/py/install_latest_justhtml.py +0 -0
  49. {justhtml-2.2.0 → justhtml-2.3.0}/docs/playground/py/render.py +0 -0
  50. {justhtml-2.2.0 → justhtml-2.3.0}/docs/playground/py/use_local_repo.py +0 -0
  51. {justhtml-2.2.0 → justhtml-2.3.0}/docs/quickstart.md +0 -0
  52. {justhtml-2.2.0 → justhtml-2.3.0}/docs/sanitization.md +0 -0
  53. {justhtml-2.2.0 → justhtml-2.3.0}/docs/selectors.md +0 -0
  54. {justhtml-2.2.0 → justhtml-2.3.0}/docs/streaming.md +0 -0
  55. {justhtml-2.2.0 → justhtml-2.3.0}/docs/text.md +0 -0
  56. {justhtml-2.2.0 → justhtml-2.3.0}/docs/transforms.md +0 -0
  57. {justhtml-2.2.0 → justhtml-2.3.0}/docs/unsafe-handling.md +0 -0
  58. {justhtml-2.2.0 → justhtml-2.3.0}/docs/url-cleaning.md +0 -0
  59. {justhtml-2.2.0 → justhtml-2.3.0}/llms.txt +0 -0
  60. {justhtml-2.2.0 → justhtml-2.3.0}/scripts/release.py +0 -0
  61. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/__init__.py +0 -0
  62. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/__main__.py +0 -0
  63. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/core/__init__.py +0 -0
  64. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/core/constants.py +0 -0
  65. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/core/entities.py +0 -0
  66. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/core/errors.py +0 -0
  67. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/core/rawtext.py +0 -0
  68. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/dom/__init__.py +0 -0
  69. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/dom/builder.py +0 -0
  70. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/parser/context.py +0 -0
  71. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/parser/encoding.py +0 -0
  72. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/parser/stream.py +0 -0
  73. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/py.typed +0 -0
  74. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/sanitizer/__init__.py +0 -0
  75. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/sanitizer/css.py +0 -0
  76. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/sanitizer/dom.py +0 -0
  77. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/sanitizer/policy.py +0 -0
  78. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/sanitizer/policy_defaults.py +0 -0
  79. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/sanitizer/rawtext.py +0 -0
  80. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/sanitizer/url/__init__.py +0 -0
  81. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/sanitizer/url/policy.py +0 -0
  82. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/sanitizer/url/runtime.py +0 -0
  83. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/sanitizer/url/spec.py +0 -0
  84. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/selector/__init__.py +0 -0
  85. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/selector/core.py +0 -0
  86. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/serializer/__init__.py +0 -0
  87. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/serializer/markdown.py +0 -0
  88. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/tokenizer/__init__.py +0 -0
  89. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/tokenizer/html.py +0 -0
  90. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/tokenizer/tokens.py +0 -0
  91. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/transforms/__init__.py +0 -0
  92. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/transforms/compile.py +0 -0
  93. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/transforms/linkify.py +0 -0
  94. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/transforms/linkify_core.py +0 -0
  95. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/transforms/runtime.py +0 -0
  96. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/transforms/spec.py +0 -0
  97. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/treebuilder/__init__.py +0 -0
  98. {justhtml-2.2.0 → justhtml-2.3.0}/src/justhtml/treebuilder/utils.py +0 -0
  99. {justhtml-2.2.0 → justhtml-2.3.0}/test-summary.txt +0 -0
  100. {justhtml-2.2.0 → justhtml-2.3.0}/tests/README.md +0 -0
  101. {justhtml-2.2.0 → justhtml-2.3.0}/tests/__init__.py +0 -0
  102. {justhtml-2.2.0 → justhtml-2.3.0}/tests/data/wikipedia.html +0 -0
  103. {justhtml-2.2.0 → justhtml-2.3.0}/tests/harness/__init__.py +0 -0
  104. {justhtml-2.2.0 → justhtml-2.3.0}/tests/harness/encoding.py +0 -0
  105. {justhtml-2.2.0 → justhtml-2.3.0}/tests/harness/regressions.py +0 -0
  106. {justhtml-2.2.0 → justhtml-2.3.0}/tests/harness/reporter.py +0 -0
  107. {justhtml-2.2.0 → justhtml-2.3.0}/tests/harness/serializer.py +0 -0
  108. {justhtml-2.2.0 → justhtml-2.3.0}/tests/harness/tokenizer.py +0 -0
  109. {justhtml-2.2.0 → justhtml-2.3.0}/tests/harness/tree.py +0 -0
  110. {justhtml-2.2.0 → justhtml-2.3.0}/tests/justhtml-sanitize-tests/cases.json +0 -0
  111. {justhtml-2.2.0 → justhtml-2.3.0}/tests/justhtml-tests/branch_coverage.dat +0 -0
  112. {justhtml-2.2.0 → justhtml-2.3.0}/tests/justhtml-tests/coverage_gaps.test +0 -0
  113. {justhtml-2.2.0 → justhtml-2.3.0}/tests/justhtml-tests/empty_stack_edge_cases.dat +0 -0
  114. {justhtml-2.2.0 → justhtml-2.3.0}/tests/justhtml-tests/entities.test +0 -0
  115. {justhtml-2.2.0 → justhtml-2.3.0}/tests/justhtml-tests/iframe_srcdoc.dat +0 -0
  116. {justhtml-2.2.0 → justhtml-2.3.0}/tests/justhtml-tests/tokenizer_edge_cases.test +0 -0
  117. {justhtml-2.2.0 → justhtml-2.3.0}/tests/justhtml-tests/treebuilder_coverage.dat +0 -0
  118. {justhtml-2.2.0 → justhtml-2.3.0}/tests/justhtml-tests/xml_coercion.dat +0 -0
  119. {justhtml-2.2.0 → justhtml-2.3.0}/tests/justhtml-tests/xml_coercion_coverage.test +0 -0
  120. {justhtml-2.2.0 → justhtml-2.3.0}/tests/linkify-it/LICENSE.txt +0 -0
  121. {justhtml-2.2.0 → justhtml-2.3.0}/tests/linkify-it/README.md +0 -0
  122. {justhtml-2.2.0 → justhtml-2.3.0}/tests/linkify-it/fixtures/links.txt +0 -0
  123. {justhtml-2.2.0 → justhtml-2.3.0}/tests/linkify-it/fixtures/not_links.txt +0 -0
  124. {justhtml-2.2.0 → justhtml-2.3.0}/tests/test_builder.py +0 -0
  125. {justhtml-2.2.0 → justhtml-2.3.0}/tests/test_cli.py +0 -0
  126. {justhtml-2.2.0 → justhtml-2.3.0}/tests/test_docs_examples.py +0 -0
  127. {justhtml-2.2.0 → justhtml-2.3.0}/tests/test_encoding.py +0 -0
  128. {justhtml-2.2.0 → justhtml-2.3.0}/tests/test_errors.py +0 -0
  129. {justhtml-2.2.0 → justhtml-2.3.0}/tests/test_linkify_it.py +0 -0
  130. {justhtml-2.2.0 → justhtml-2.3.0}/tests/test_linkify_transform.py +0 -0
  131. {justhtml-2.2.0 → justhtml-2.3.0}/tests/test_node.py +0 -0
  132. {justhtml-2.2.0 → justhtml-2.3.0}/tests/test_playground_local_repo_file_list.py +0 -0
  133. {justhtml-2.2.0 → justhtml-2.3.0}/tests/test_sanitize.py +0 -0
  134. {justhtml-2.2.0 → justhtml-2.3.0}/tests/test_sanitize_integration.py +0 -0
  135. {justhtml-2.2.0 → justhtml-2.3.0}/tests/test_sanitize_transform.py +0 -0
  136. {justhtml-2.2.0 → justhtml-2.3.0}/tests/test_selector.py +0 -0
  137. {justhtml-2.2.0 → justhtml-2.3.0}/tests/test_stream.py +0 -0
  138. {justhtml-2.2.0 → justhtml-2.3.0}/tests/test_tokenizer.py +0 -0
  139. {justhtml-2.2.0 → justhtml-2.3.0}/tests/test_transforms.py +0 -0
  140. {justhtml-2.2.0 → justhtml-2.3.0}/tests/test_transforms_compiler.py +0 -0
  141. {justhtml-2.2.0 → justhtml-2.3.0}/tests/test_transforms_edge_cases.py +0 -0
  142. {justhtml-2.2.0 → justhtml-2.3.0}/tests/test_transforms_sanitize_integration.py +0 -0
  143. {justhtml-2.2.0 → justhtml-2.3.0}/tests/test_wikipedia.py +0 -0
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.3.0] - 2026-06-12
11
+
12
+ ### Fixed
13
+ - Preserve pretty-printed paragraph line breaks when sanitization is disabled and comments are present in block containers.
14
+
15
+ ### Performance
16
+ - Speed up tree construction scope checks by tracking open elements for common scoped element names.
17
+
18
+ ### Changed
19
+ - Refactor tree-builder open-element bookkeeping and active-formatting entries without changing the public API.
20
+
10
21
  ## [2.2.0] - 2026-06-07
11
22
 
12
23
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: justhtml
3
- Version: 2.2.0
3
+ Version: 2.3.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
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "justhtml"
3
3
  authors = [{ name = "Emil Stenström", email = "emil@emilstenstrom.se" }]
4
- version = "2.2.0"
4
+ version = "2.3.0"
5
5
  description = "A pure Python HTML5 parser that just works."
6
6
  readme = "README.md"
7
7
  license = { file = "LICENSE" }
@@ -194,6 +194,7 @@ def _run_unit_tests(config):
194
194
  self._test_counter = 0
195
195
  self._test_to_index: dict[str, int] = {}
196
196
  self.test_indices: list[tuple[str, int]] = []
197
+ self._failed_test_ids: set[str] = set()
197
198
 
198
199
  def startTest(self, test): # noqa: N802 - unittest API
199
200
  super().startTest(test)
@@ -211,10 +212,22 @@ def _run_unit_tests(config):
211
212
 
212
213
  def addFailure(self, test, err): # noqa: N802 - unittest API
213
214
  super().addFailure(test, err)
215
+ self._failed_test_ids.add(test.id())
214
216
  self.test_indices.append(("fail", self._idx(test)))
215
217
 
216
218
  def addError(self, test, err): # noqa: N802 - unittest API
217
219
  super().addError(test, err)
220
+ self._failed_test_ids.add(test.id())
221
+ self.test_indices.append(("fail", self._idx(test)))
222
+
223
+ def addSubTest(self, test, subtest, err): # noqa: N802 - unittest API
224
+ super().addSubTest(test, subtest, err)
225
+ if err is None:
226
+ return
227
+ test_id = test.id()
228
+ if test_id in self._failed_test_ids:
229
+ return
230
+ self._failed_test_ids.add(test_id)
218
231
  self.test_indices.append(("fail", self._idx(test)))
219
232
 
220
233
  def addSkip(self, test, reason): # noqa: N802 - unittest API
@@ -156,6 +156,7 @@ class JustHTML:
156
156
  iframe_srcdoc=iframe_srcdoc,
157
157
  collect_errors=should_collect,
158
158
  scripting_enabled=scripting_enabled,
159
+ track_node_locations=bool(track_node_locations),
159
160
  track_tag_spans=track_tag_spans,
160
161
  )
161
162
  opts = _tokenizer_opts.copy() if _tokenizer_opts is not None else TokenizerOpts()
@@ -756,7 +756,7 @@ def _node_to_html(node: Any, indent: int = 0, indent_size: int = 2, *, in_pre: b
756
756
  for child in children:
757
757
  if child is None:
758
758
  continue
759
- if child.name == "#comment" or (child.name == "#text" and (child.data or "").strip()):
759
+ if child.name == "#text" and (child.data or "").strip():
760
760
  can_indent = False
761
761
  break
762
762
 
@@ -54,6 +54,46 @@ class _SelectedContentWalkItem(NamedTuple):
54
54
  in_datalist: bool
55
55
 
56
56
 
57
+ class _FormattingEntry:
58
+ __slots__ = ("attrs", "name", "node", "signature")
59
+
60
+ name: str
61
+ attrs: dict[str, str | None]
62
+ node: Any
63
+ signature: tuple[tuple[str, str], ...]
64
+
65
+ def __init__(
66
+ self,
67
+ name: str,
68
+ attrs: dict[str, str | None],
69
+ node: Any,
70
+ signature: tuple[tuple[str, str], ...],
71
+ ) -> None:
72
+ self.name = name
73
+ self.attrs = attrs
74
+ self.node = node
75
+ self.signature = signature
76
+
77
+
78
+ _OPEN_ELEMENT_SCOPE_COUNT_NAMES = frozenset(
79
+ (
80
+ "button",
81
+ "caption",
82
+ "dd",
83
+ "dt",
84
+ "form",
85
+ "li",
86
+ "table",
87
+ "tbody",
88
+ "td",
89
+ "tfoot",
90
+ "th",
91
+ "thead",
92
+ "tr",
93
+ )
94
+ )
95
+
96
+
57
97
  class TreeBuilder(TreeBuilderModesMixin):
58
98
  __slots__ = (
59
99
  "_body_end_handlers",
@@ -61,6 +101,8 @@ class TreeBuilder(TreeBuilderModesMixin):
61
101
  "_body_token_handlers",
62
102
  "_mode_handlers",
63
103
  "_open_p_elements",
104
+ "_open_scope_name_counts",
105
+ "_open_template_elements",
64
106
  "_pending_end_tag_end",
65
107
  "_pending_end_tag_name",
66
108
  "_pending_end_tag_start",
@@ -88,6 +130,7 @@ class TreeBuilder(TreeBuilderModesMixin):
88
130
  "template_modes",
89
131
  "tokenizer",
90
132
  "tokenizer_state_override",
133
+ "track_node_locations",
91
134
  "track_tag_spans",
92
135
  )
93
136
 
@@ -98,6 +141,7 @@ class TreeBuilder(TreeBuilderModesMixin):
98
141
  _pending_end_tag_name: str | None
99
142
  _pending_end_tag_start: int | None
100
143
  _pending_end_tag_end: int | None
144
+ track_node_locations: bool
101
145
  track_tag_spans: bool
102
146
  active_formatting: list[Any]
103
147
  collect_errors: bool
@@ -115,6 +159,8 @@ class TreeBuilder(TreeBuilderModesMixin):
115
159
  open_elements: list[Any]
116
160
  original_mode: InsertionMode | None # type: ignore[assignment]
117
161
  _open_p_elements: int
162
+ _open_scope_name_counts: dict[str, int]
163
+ _open_template_elements: int
118
164
  _saw_select_element: bool
119
165
  pending_table_text: list[str]
120
166
  pending_table_text_should_error: bool
@@ -131,12 +177,14 @@ class TreeBuilder(TreeBuilderModesMixin):
131
177
  iframe_srcdoc: bool = False,
132
178
  collect_errors: bool = False,
133
179
  scripting_enabled: bool = True,
180
+ track_node_locations: bool = False,
134
181
  track_tag_spans: bool = False,
135
182
  ) -> None:
136
183
  self.fragment_context = fragment_context
137
184
  self.iframe_srcdoc = iframe_srcdoc
138
185
  self.collect_errors = collect_errors
139
186
  self.scripting_enabled = bool(scripting_enabled)
187
+ self.track_node_locations = bool(track_node_locations)
140
188
  self.track_tag_spans = bool(track_tag_spans)
141
189
  self.errors = []
142
190
  self.tokenizer = None # Set by parser after tokenizer is created
@@ -150,6 +198,8 @@ class TreeBuilder(TreeBuilderModesMixin):
150
198
  self.table_text_original_mode = None
151
199
  self.open_elements = []
152
200
  self._open_p_elements = 0
201
+ self._open_scope_name_counts = {}
202
+ self._open_template_elements = 0
153
203
  self._saw_select_element = False
154
204
  self._pending_end_tag_name = None
155
205
  self._pending_end_tag_start = None
@@ -263,6 +313,8 @@ class TreeBuilder(TreeBuilderModesMixin):
263
313
  ) -> bool:
264
314
  if target == "p" and self._open_p_elements == 0:
265
315
  return False
316
+ if target in _OPEN_ELEMENT_SCOPE_COUNT_NAMES and self._open_scope_name_counts.get(target, 0) == 0:
317
+ return False
266
318
  if terminators is None:
267
319
  terminators = DEFAULT_SCOPE_TERMINATORS
268
320
  open_elements = self.open_elements
@@ -294,17 +346,59 @@ class TreeBuilder(TreeBuilderModesMixin):
294
346
  break
295
347
 
296
348
  def _note_open_element_pushed(self, node: Any) -> None:
297
- if node is not None and node.name == "p":
349
+ if node is None:
350
+ return
351
+ name = node.name
352
+ if name == "p":
298
353
  self._open_p_elements += 1
354
+ elif name == "template":
355
+ self._open_template_elements += 1
356
+ elif name in _OPEN_ELEMENT_SCOPE_COUNT_NAMES:
357
+ self._open_scope_name_counts[name] = self._open_scope_name_counts.get(name, 0) + 1
299
358
 
300
359
  def _note_open_element_removed(self, node: Any) -> None:
301
- if node is not None and node.name == "p":
360
+ if node is None:
361
+ return
362
+ name = node.name
363
+ if name == "p":
302
364
  self._open_p_elements -= 1
365
+ elif name == "template":
366
+ self._open_template_elements -= 1
367
+ elif name in _OPEN_ELEMENT_SCOPE_COUNT_NAMES:
368
+ count = self._open_scope_name_counts.get(name, 0)
369
+ if count <= 1:
370
+ self._open_scope_name_counts.pop(name, None)
371
+ else:
372
+ self._open_scope_name_counts[name] = count - 1
303
373
 
304
374
  def _note_open_elements_removed(self, nodes: list[Any]) -> None:
305
375
  for node in nodes:
306
376
  self._note_open_element_removed(node)
307
377
 
378
+ def _has_template_on_stack(self) -> bool:
379
+ return self._open_template_elements > 0
380
+
381
+ def _delete_open_element_at(self, index: int) -> None:
382
+ self._note_open_element_removed(self.open_elements[index])
383
+ del self.open_elements[index]
384
+
385
+ def _replace_open_element_at(self, index: int, node: Any) -> None:
386
+ self._note_open_element_removed(self.open_elements[index])
387
+ self.open_elements[index] = node
388
+ self._note_open_element_pushed(node)
389
+
390
+ def _remove_open_element(self, node: Any) -> None:
391
+ self._note_open_element_removed(node)
392
+ self.open_elements.remove(node)
393
+
394
+ def _insert_open_element_at(self, index: int, node: Any) -> None:
395
+ self.open_elements.insert(index, node)
396
+ self._note_open_element_pushed(node)
397
+
398
+ def _truncate_open_elements_from(self, index: int) -> None:
399
+ self._note_open_elements_removed(self.open_elements[index:])
400
+ del self.open_elements[index:]
401
+
308
402
  def _close_p_element(self) -> bool:
309
403
  if self._has_element_in_button_scope("p"):
310
404
  self._generate_implied_end_tags("p")
@@ -545,10 +639,11 @@ class TreeBuilder(TreeBuilderModesMixin):
545
639
 
546
640
  def _append_comment_to_document(self, text: str) -> None:
547
641
  node = Comment(data=text)
548
- if self.tokenizer is not None and self.tokenizer.track_node_locations:
549
- node._origin_pos = self.tokenizer.last_token_start_pos
642
+ tokenizer = self.tokenizer
643
+ if self.track_node_locations and tokenizer is not None:
644
+ node._origin_pos = tokenizer.last_token_start_pos
550
645
  if node._origin_pos is not None:
551
- node._origin_line, node._origin_col = self.tokenizer.location_at_pos(node._origin_pos)
646
+ node._origin_line, node._origin_col = tokenizer.location_at_pos(node._origin_pos)
552
647
  self.document.append_child(node)
553
648
 
554
649
  def _append_comment(self, text: str, parent: Any | None = None) -> None:
@@ -558,10 +653,11 @@ class TreeBuilder(TreeBuilderModesMixin):
558
653
  if type(parent) is Template and parent.template_content:
559
654
  parent = parent.template_content
560
655
  node = Comment(data=text)
561
- if self.tokenizer is not None and self.tokenizer.track_node_locations:
562
- node._origin_pos = self.tokenizer.last_token_start_pos
656
+ tokenizer = self.tokenizer
657
+ if self.track_node_locations and tokenizer is not None:
658
+ node._origin_pos = tokenizer.last_token_start_pos
563
659
  if node._origin_pos is not None:
564
- node._origin_line, node._origin_col = self.tokenizer.location_at_pos(node._origin_pos)
660
+ node._origin_line, node._origin_col = tokenizer.location_at_pos(node._origin_pos)
565
661
  parent.append_child(node)
566
662
 
567
663
  def _append_text(self, text: str) -> None:
@@ -591,10 +687,11 @@ class TreeBuilder(TreeBuilderModesMixin):
591
687
  return
592
688
 
593
689
  node = Text(text)
594
- if self.tokenizer is not None and self.tokenizer.track_node_locations:
595
- node._origin_pos = self.tokenizer.last_token_start_pos
690
+ tokenizer = self.tokenizer
691
+ if self.track_node_locations and tokenizer is not None:
692
+ node._origin_pos = tokenizer.last_token_start_pos
596
693
  if node._origin_pos is not None:
597
- node._origin_line, node._origin_col = self.tokenizer.location_at_pos(node._origin_pos)
694
+ node._origin_line, node._origin_col = tokenizer.location_at_pos(node._origin_pos)
598
695
  children.append(node)
599
696
  node.parent = target
600
697
  return
@@ -615,10 +712,11 @@ class TreeBuilder(TreeBuilderModesMixin):
615
712
  return
616
713
 
617
714
  node = Text(text)
618
- if self.tokenizer is not None and self.tokenizer.track_node_locations:
619
- node._origin_pos = self.tokenizer.last_token_start_pos
715
+ tokenizer = self.tokenizer
716
+ if self.track_node_locations and tokenizer is not None:
717
+ node._origin_pos = tokenizer.last_token_start_pos
620
718
  if node._origin_pos is not None:
621
- node._origin_line, node._origin_col = self.tokenizer.location_at_pos(node._origin_pos)
719
+ node._origin_line, node._origin_col = tokenizer.location_at_pos(node._origin_pos)
622
720
  children = parent.children
623
721
  if position < len(children):
624
722
  children.insert(position, node)
@@ -662,10 +760,11 @@ class TreeBuilder(TreeBuilderModesMixin):
662
760
  node._start_tag_end = tag.end_pos
663
761
  node._self_closing = tag.self_closing
664
762
 
665
- if self.tokenizer is not None and self.tokenizer.track_node_locations:
763
+ tokenizer = self.tokenizer
764
+ if self.track_node_locations and tokenizer is not None:
666
765
  node._origin_pos = tag.start_pos
667
766
  if node._origin_pos is not None:
668
- node._origin_line, node._origin_col = self.tokenizer.location_at_pos(node._origin_pos)
767
+ node._origin_line, node._origin_col = tokenizer.location_at_pos(node._origin_pos)
669
768
 
670
769
  # Fast path for common case: not inserting from table
671
770
  if not self.insert_from_table:
@@ -747,9 +846,7 @@ class TreeBuilder(TreeBuilderModesMixin):
747
846
  node = self.open_elements[index]
748
847
  if node is not None and node.name == name:
749
848
  self._maybe_mark_end_tag(node)
750
- removed = self.open_elements[index:]
751
- self._note_open_elements_removed(removed)
752
- del self.open_elements[index:]
849
+ self._truncate_open_elements_from(index)
753
850
  return
754
851
  index -= 1
755
852
 
@@ -772,9 +869,7 @@ class TreeBuilder(TreeBuilderModesMixin):
772
869
  self._parse_error("end-tag-too-early")
773
870
  self._maybe_mark_end_tag(node)
774
871
  # Pop all elements from this node onwards
775
- removed = self.open_elements[index:]
776
- self._note_open_elements_removed(removed)
777
- del self.open_elements[index:]
872
+ self._truncate_open_elements_from(index)
778
873
  return
779
874
 
780
875
  # If node is a special element, parse error and ignore the tag
@@ -796,8 +891,7 @@ class TreeBuilder(TreeBuilderModesMixin):
796
891
  def _remove_from_open_elements(self, node: Any) -> None:
797
892
  index = self.open_elements.index(node)
798
893
  self._maybe_mark_end_tag(node)
799
- self._note_open_element_removed(node)
800
- del self.open_elements[index]
894
+ self._delete_open_element_at(index)
801
895
 
802
896
  def _is_special_element(self, node: Any) -> bool:
803
897
  if node.namespace not in {None, "html"}:
@@ -809,14 +903,14 @@ class TreeBuilder(TreeBuilderModesMixin):
809
903
  entry = self.active_formatting[index]
810
904
  if entry is FORMAT_MARKER:
811
905
  break
812
- if entry["name"] == name:
906
+ if entry.name == name:
813
907
  return index
814
908
  return None
815
909
 
816
910
  def _find_active_formatting_index_by_node(self, node: Any) -> int | None:
817
911
  for index in range(len(self.active_formatting) - 1, -1, -1):
818
912
  entry = self.active_formatting[index]
819
- if entry is not FORMAT_MARKER and entry["node"] is node:
913
+ if entry is not FORMAT_MARKER and entry.node is node:
820
914
  return index
821
915
  return None
822
916
 
@@ -826,6 +920,18 @@ class TreeBuilder(TreeBuilderModesMixin):
826
920
  def _attrs_signature(self, attrs: dict[str, str | None]) -> tuple[tuple[str, str], ...]:
827
921
  if not attrs:
828
922
  return ()
923
+ if len(attrs) == 1:
924
+ name, value = next(iter(attrs.items()))
925
+ return ((name, value or ""),)
926
+ if len(attrs) == 2:
927
+ iterator = iter(attrs.items())
928
+ name1, value1 = next(iterator)
929
+ name2, value2 = next(iterator)
930
+ item1 = (name1, value1 or "")
931
+ item2 = (name2, value2 or "")
932
+ if name1 <= name2:
933
+ return (item1, item2)
934
+ return (item2, item1)
829
935
  items: list[tuple[str, str]] = []
830
936
  for name, value in attrs.items():
831
937
  items.append((name, value or ""))
@@ -839,8 +945,8 @@ class TreeBuilder(TreeBuilderModesMixin):
839
945
  if entry is FORMAT_MARKER:
840
946
  matches.clear()
841
947
  continue
842
- existing_signature = entry["signature"]
843
- if entry["name"] == name and existing_signature == signature:
948
+ existing_signature = entry.signature
949
+ if entry.name == name and existing_signature == signature:
844
950
  matches.append(index)
845
951
  if len(matches) >= 3:
846
952
  return matches[0]
@@ -851,7 +957,7 @@ class TreeBuilder(TreeBuilderModesMixin):
851
957
  entry = self.active_formatting[index]
852
958
  if entry is FORMAT_MARKER:
853
959
  break
854
- if entry["name"] == name:
960
+ if entry.name == name:
855
961
  return True
856
962
  return False
857
963
 
@@ -860,8 +966,8 @@ class TreeBuilder(TreeBuilderModesMixin):
860
966
  entry = self.active_formatting[index]
861
967
  if entry is FORMAT_MARKER:
862
968
  break
863
- if entry["name"] == "a":
864
- return entry["node"] not in self.open_elements
969
+ if entry.name == "a":
970
+ return entry.node not in self.open_elements
865
971
  return False
866
972
 
867
973
  def _remove_last_active_formatting_by_name(self, name: str) -> None:
@@ -869,7 +975,7 @@ class TreeBuilder(TreeBuilderModesMixin):
869
975
  entry = self.active_formatting[index]
870
976
  if entry is FORMAT_MARKER:
871
977
  break
872
- if entry["name"] == name:
978
+ if entry.name == name:
873
979
  del self.active_formatting[index]
874
980
  return
875
981
 
@@ -878,21 +984,13 @@ class TreeBuilder(TreeBuilderModesMixin):
878
984
  if self.open_elements[index].name == name:
879
985
  node = self.open_elements[index]
880
986
  self._maybe_mark_end_tag(node)
881
- self._note_open_element_removed(node)
882
- del self.open_elements[index]
987
+ self._delete_open_element_at(index)
883
988
  return
884
989
 
885
990
  def _append_active_formatting_entry(self, name: str, attrs: dict[str, str | None], node: Any) -> None:
886
991
  entry_attrs = self._clone_attributes(attrs)
887
992
  signature = self._attrs_signature(entry_attrs)
888
- self.active_formatting.append(
889
- {
890
- "name": name,
891
- "attrs": entry_attrs,
892
- "node": node,
893
- "signature": signature,
894
- },
895
- )
993
+ self.active_formatting.append(_FormattingEntry(name, entry_attrs, node, signature))
896
994
 
897
995
  def _clear_active_formatting_up_to_marker(self) -> None:
898
996
  while self.active_formatting:
@@ -911,7 +1009,7 @@ class TreeBuilder(TreeBuilderModesMixin):
911
1009
  if not self.active_formatting:
912
1010
  return
913
1011
  last_entry = self.active_formatting[-1]
914
- if last_entry is FORMAT_MARKER or last_entry["node"] in self.open_elements:
1012
+ if last_entry is FORMAT_MARKER or last_entry.node in self.open_elements:
915
1013
  return
916
1014
 
917
1015
  index = len(self.active_formatting) - 1
@@ -920,20 +1018,20 @@ class TreeBuilder(TreeBuilderModesMixin):
920
1018
  if index < 0:
921
1019
  break
922
1020
  entry = self.active_formatting[index]
923
- if entry is FORMAT_MARKER or entry["node"] in self.open_elements:
1021
+ if entry is FORMAT_MARKER or entry.node in self.open_elements:
924
1022
  index += 1
925
1023
  break
926
1024
  if index < 0:
927
1025
  index = 0
928
1026
  while index < len(self.active_formatting):
929
1027
  entry = self.active_formatting[index]
930
- tag = Tag(Tag.START, entry["name"], self._clone_attributes(entry["attrs"]), False)
1028
+ tag = Tag(Tag.START, entry.name, self._clone_attributes(entry.attrs), False)
931
1029
  new_node = self._insert_element(tag, push=True)
932
- if self.tokenizer is not None and self.tokenizer.track_node_locations:
933
- new_node._origin_pos = entry["node"].origin_offset
934
- new_node._origin_line = entry["node"].origin_line
935
- new_node._origin_col = entry["node"].origin_col
936
- entry["node"] = new_node
1030
+ if self.track_node_locations:
1031
+ new_node._origin_pos = entry.node.origin_offset
1032
+ new_node._origin_line = entry.node.origin_line
1033
+ new_node._origin_col = entry.node.origin_col
1034
+ entry.node = new_node
937
1035
  index += 1
938
1036
 
939
1037
  def _insert_node_at(self, parent: Any, index: int, node: Any) -> None:
@@ -1263,9 +1361,7 @@ class TreeBuilder(TreeBuilderModesMixin):
1263
1361
  return ("reprocess", self.mode, token, True)
1264
1362
  # Otherwise it's a foreign element - pop everything from this point up
1265
1363
  self._maybe_mark_end_tag(node)
1266
- removed = self.open_elements[idx:]
1267
- self._note_open_elements_removed(removed)
1268
- del self.open_elements[idx:]
1364
+ self._truncate_open_elements_from(idx)
1269
1365
  return None
1270
1366
 
1271
1367
  # Per HTML5 spec: if first node doesn't match, it's a parse error
@@ -199,9 +199,7 @@ class TreeBuilderModesMixin:
199
199
  self.template_modes.append(InsertionMode.IN_TEMPLATE)
200
200
  return None
201
201
  if token.kind == Tag.END and token.name == "template":
202
- # Check if template is on the stack (don't use scope check as table blocks it)
203
- has_template = any(node is not None and node.name == "template" for node in self.open_elements)
204
- if not has_template:
202
+ if not self._has_template_on_stack():
205
203
  return None
206
204
  self._generate_implied_end_tags()
207
205
  self._pop_until_inclusive("template")
@@ -357,8 +355,7 @@ class TreeBuilderModesMixin:
357
355
  self.mode = InsertionMode.IN_HEAD
358
356
  return ("reprocess", InsertionMode.IN_HEAD, token)
359
357
  if token.kind == Tag.END and token.name == "template":
360
- has_template = any(node is not None and node.name == "template" for node in self.open_elements)
361
- if not has_template:
358
+ if not self._has_template_on_stack():
362
359
  self._parse_error("unexpected-end-tag", tag_name=token.name)
363
360
  return None
364
361
  return self._mode_in_head(token)
@@ -617,7 +614,7 @@ class TreeBuilderModesMixin:
617
614
  return
618
615
 
619
616
  formatting_element_entry = self.active_formatting[formatting_element_index]
620
- formatting_element = formatting_element_entry["node"]
617
+ formatting_element = formatting_element_entry.node
621
618
 
622
619
  # 4. If formatting element is not in open elements
623
620
  if formatting_element not in self.open_elements:
@@ -686,21 +683,18 @@ class TreeBuilderModesMixin:
686
683
  node_formatting_index = None
687
684
 
688
685
  if node_formatting_index is None:
689
- self._note_open_element_removed(self.open_elements[node_index])
690
- del self.open_elements[node_index]
686
+ self._delete_open_element_at(node_index)
691
687
  continue
692
688
 
693
689
  # 10.4 Replace entry with new element
694
690
  entry = self.active_formatting[node_formatting_index]
695
- new_element = self._create_element(entry["name"], entry["node"].namespace, entry["attrs"])
696
- if self.tokenizer is not None and self.tokenizer.track_node_locations:
697
- new_element._origin_pos = entry["node"].origin_offset
698
- new_element._origin_line = entry["node"].origin_line
699
- new_element._origin_col = entry["node"].origin_col
700
- entry["node"] = new_element
701
- self._note_open_element_removed(self.open_elements[node_index])
702
- self.open_elements[node_index] = new_element
703
- self._note_open_element_pushed(new_element)
691
+ new_element = self._create_element(entry.name, entry.node.namespace, entry.attrs)
692
+ if self.track_node_locations:
693
+ new_element._origin_pos = entry.node.origin_offset
694
+ new_element._origin_line = entry.node.origin_line
695
+ new_element._origin_col = entry.node.origin_col
696
+ entry.node = new_element
697
+ self._replace_open_element_at(node_index, new_element)
704
698
  node = new_element
705
699
 
706
700
  # 10.5 If last node is furthest block, update bookmark
@@ -731,12 +725,12 @@ class TreeBuilderModesMixin:
731
725
 
732
726
  # 12. Create new formatting element
733
727
  entry = self.active_formatting[formatting_element_index]
734
- new_formatting_element = self._create_element(entry["name"], entry["node"].namespace, entry["attrs"])
735
- if self.tokenizer is not None and self.tokenizer.track_node_locations:
736
- new_formatting_element._origin_pos = entry["node"].origin_offset
737
- new_formatting_element._origin_line = entry["node"].origin_line
738
- new_formatting_element._origin_col = entry["node"].origin_col
739
- entry["node"] = new_formatting_element
728
+ new_formatting_element = self._create_element(entry.name, entry.node.namespace, entry.attrs)
729
+ if self.track_node_locations:
730
+ new_formatting_element._origin_pos = entry.node.origin_offset
731
+ new_formatting_element._origin_line = entry.node.origin_line
732
+ new_formatting_element._origin_col = entry.node.origin_col
733
+ entry.node = new_formatting_element
740
734
 
741
735
  # 13. Move children of furthest block
742
736
  moved_children = furthest_block.children
@@ -754,11 +748,9 @@ class TreeBuilderModesMixin:
754
748
  self.active_formatting.insert(bookmark, entry)
755
749
 
756
750
  # 15. Remove formatting element from open elements and insert new one
757
- self._note_open_element_removed(formatting_element)
758
- self.open_elements.remove(formatting_element)
751
+ self._remove_open_element(formatting_element)
759
752
  furthest_block_index = self.open_elements.index(furthest_block)
760
- self.open_elements.insert(furthest_block_index + 1, new_formatting_element)
761
- self._note_open_element_pushed(new_formatting_element)
753
+ self._insert_open_element_at(furthest_block_index + 1, new_formatting_element)
762
754
 
763
755
  def _handle_body_start_a(self, token: Tag) -> None:
764
756
  if self._has_active_formatting_entry("a"):
@@ -815,8 +807,7 @@ class TreeBuilderModesMixin:
815
807
  return
816
808
  body_elem = self.open_elements[body_index]
817
809
  body_elem.parent.remove_child(body_elem)
818
- self._note_open_elements_removed(self.open_elements[body_index:])
819
- self.open_elements = self.open_elements[:body_index]
810
+ self._truncate_open_elements_from(body_index)
820
811
  self._insert_element(token, push=True)
821
812
  self.mode = InsertionMode.IN_FRAMESET
822
813
  return
@@ -922,8 +913,7 @@ class TreeBuilderModesMixin:
922
913
  return
923
914
 
924
915
  def _handle_body_end_template(self, token: Tag) -> None:
925
- has_template = any(node is not None and node.name == "template" for node in self.open_elements)
926
- if not has_template:
916
+ if not self._has_template_on_stack():
927
917
  self._parse_error("unexpected-end-tag", tag_name=token.name)
928
918
  return
929
919
  self._generate_implied_end_tags()
@@ -1716,7 +1706,7 @@ class TreeBuilderModesMixin:
1716
1706
  if fmt_index is None:
1717
1707
  self._parse_error("unexpected-end-tag-in-select", tag_name=name)
1718
1708
  return None
1719
- target = self.active_formatting[fmt_index]["node"]
1709
+ target = self.active_formatting[fmt_index].node
1720
1710
  if target in self.open_elements: # pragma: no branch
1721
1711
  select_index = self.open_elements.index(select_node)
1722
1712
  target_index = self.open_elements.index(target)
@@ -1816,9 +1806,7 @@ class TreeBuilderModesMixin:
1816
1806
  }:
1817
1807
  return self._mode_in_head(token)
1818
1808
  if isinstance(token, EOFToken):
1819
- # Check if template is on the stack (don't use _in_scope as table blocks it)
1820
- has_template = any(node is not None and node.name == "template" for node in self.open_elements)
1821
- if not has_template:
1809
+ if not self._has_template_on_stack():
1822
1810
  return None
1823
1811
  # Parse error for EOF in template
1824
1812
  self._parse_error("expected-closing-tag-but-got-eof", tag_name="template")
@@ -69,15 +69,19 @@ class TestLinkifyInternals(unittest.TestCase):
69
69
  def test_punctuation_runs_do_not_trigger_quadratic_email_scan(self) -> None:
70
70
  for ch in ('"', "-", ".", "!"):
71
71
  with self.subTest(ch=ch):
72
+ text = ch * 20_000
73
+ find_links_with_config(text, LinkifyConfig())
72
74
  start = perf_counter()
73
- assert find_links_with_config(ch * 20_000, LinkifyConfig()) == []
75
+ assert find_links_with_config(text, LinkifyConfig()) == []
74
76
  assert perf_counter() - start < 0.25
75
77
 
76
78
  def test_unmatched_closing_brackets_do_not_trigger_quadratic_trimming(self) -> None:
77
79
  for ch in (")", "]", "}", ">"):
78
80
  with self.subTest(ch=ch):
81
+ text = "http://example.com/" + ch * 20_000
82
+ find_links_with_config(text, LinkifyConfig())
79
83
  start = perf_counter()
80
- out = find_links_with_config("http://example.com/" + ch * 20_000, LinkifyConfig())
84
+ out = find_links_with_config(text, LinkifyConfig())
81
85
  assert len(out) == 1
82
86
  assert out[0].text == "http://example.com/"
83
87
  assert perf_counter() - start < 0.25