justhtml 3.2.0__tar.gz → 3.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 (146) hide show
  1. {justhtml-3.2.0 → justhtml-3.3.0}/.github/workflows/ci.yml +13 -3
  2. {justhtml-3.2.0 → justhtml-3.3.0}/.pre-commit-config.yaml +1 -1
  3. {justhtml-3.2.0 → justhtml-3.3.0}/CHANGELOG.md +17 -0
  4. {justhtml-3.2.0 → justhtml-3.3.0}/PKG-INFO +1 -1
  5. {justhtml-3.2.0 → justhtml-3.3.0}/docs/api.md +1 -1
  6. {justhtml-3.2.0 → justhtml-3.3.0}/docs/linkify.md +27 -0
  7. {justhtml-3.2.0 → justhtml-3.3.0}/docs/sanitization.md +6 -4
  8. {justhtml-3.2.0 → justhtml-3.3.0}/docs/streaming.md +13 -15
  9. {justhtml-3.2.0 → justhtml-3.3.0}/pyproject.toml +1 -1
  10. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/dom/__init__.py +3 -3
  11. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/parser/engine.py +44 -52
  12. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/parser/stream.py +41 -13
  13. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/sanitizer/dom.py +5 -1
  14. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/serializer/html.py +19 -1
  15. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/transforms/linkify_core.py +7 -1
  16. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/transforms/runtime.py +32 -3
  17. {justhtml-3.2.0 → justhtml-3.3.0}/test-summary.txt +7 -7
  18. {justhtml-3.2.0 → justhtml-3.3.0}/tests/test_linkify_internals.py +20 -0
  19. {justhtml-3.2.0 → justhtml-3.3.0}/tests/test_node.py +21 -0
  20. {justhtml-3.2.0 → justhtml-3.3.0}/tests/test_serialize.py +14 -0
  21. {justhtml-3.2.0 → justhtml-3.3.0}/tests/test_stream.py +14 -0
  22. {justhtml-3.2.0 → justhtml-3.3.0}/tests/test_transforms_edge_cases.py +74 -1
  23. justhtml-3.2.0/docs/parser-engine-findings.md +0 -469
  24. {justhtml-3.2.0 → justhtml-3.3.0}/.github/copilot-instructions.md +0 -0
  25. {justhtml-3.2.0 → justhtml-3.3.0}/.github/workflows/publish.yml +0 -0
  26. {justhtml-3.2.0 → justhtml-3.3.0}/.gitignore +0 -0
  27. {justhtml-3.2.0 → justhtml-3.3.0}/CODE_OF_CONDUCT.md +0 -0
  28. {justhtml-3.2.0 → justhtml-3.3.0}/CONTRIBUTING.md +0 -0
  29. {justhtml-3.2.0 → justhtml-3.3.0}/LICENSE +0 -0
  30. {justhtml-3.2.0 → justhtml-3.3.0}/Makefile +0 -0
  31. {justhtml-3.2.0 → justhtml-3.3.0}/README.md +0 -0
  32. {justhtml-3.2.0 → justhtml-3.3.0}/SECURITY.md +0 -0
  33. {justhtml-3.2.0 → justhtml-3.3.0}/assets/justhtml-readme-explainer.png +0 -0
  34. {justhtml-3.2.0 → justhtml-3.3.0}/benchmarks/chromium_parser_diff.py +0 -0
  35. {justhtml-3.2.0 → justhtml-3.3.0}/benchmarks/correctness.py +0 -0
  36. {justhtml-3.2.0 → justhtml-3.3.0}/benchmarks/fuzz.py +0 -0
  37. {justhtml-3.2.0 → justhtml-3.3.0}/benchmarks/html5lib_engine_diff.py +0 -0
  38. {justhtml-3.2.0 → justhtml-3.3.0}/benchmarks/parser_engine_gate.py +0 -0
  39. {justhtml-3.2.0 → justhtml-3.3.0}/benchmarks/performance.py +0 -0
  40. {justhtml-3.2.0 → justhtml-3.3.0}/benchmarks/profile.py +0 -0
  41. {justhtml-3.2.0 → justhtml-3.3.0}/docs/_config.yml +0 -0
  42. {justhtml-3.2.0 → justhtml-3.3.0}/docs/_layouts/default.html +0 -0
  43. {justhtml-3.2.0 → justhtml-3.3.0}/docs/assets/search.js +0 -0
  44. {justhtml-3.2.0 → justhtml-3.3.0}/docs/bleach-migration.md +0 -0
  45. {justhtml-3.2.0 → justhtml-3.3.0}/docs/building.md +0 -0
  46. {justhtml-3.2.0 → justhtml-3.3.0}/docs/cli.md +0 -0
  47. {justhtml-3.2.0 → justhtml-3.3.0}/docs/comparison.md +0 -0
  48. {justhtml-3.2.0 → justhtml-3.3.0}/docs/correctness.md +0 -0
  49. {justhtml-3.2.0 → justhtml-3.3.0}/docs/encoding.md +0 -0
  50. {justhtml-3.2.0 → justhtml-3.3.0}/docs/errors.md +0 -0
  51. {justhtml-3.2.0 → justhtml-3.3.0}/docs/fragments.md +0 -0
  52. {justhtml-3.2.0 → justhtml-3.3.0}/docs/html-cleaning.md +0 -0
  53. {justhtml-3.2.0 → justhtml-3.3.0}/docs/index.md +0 -0
  54. {justhtml-3.2.0 → justhtml-3.3.0}/docs/migration-examples.md +0 -0
  55. {justhtml-3.2.0 → justhtml-3.3.0}/docs/playground/app.js +0 -0
  56. {justhtml-3.2.0 → justhtml-3.3.0}/docs/playground/index.html +0 -0
  57. {justhtml-3.2.0 → justhtml-3.3.0}/docs/playground/py/__init__.py +0 -0
  58. {justhtml-3.2.0 → justhtml-3.3.0}/docs/playground/py/install_latest_justhtml.py +0 -0
  59. {justhtml-3.2.0 → justhtml-3.3.0}/docs/playground/py/render.py +0 -0
  60. {justhtml-3.2.0 → justhtml-3.3.0}/docs/playground/py/use_local_repo.py +0 -0
  61. {justhtml-3.2.0 → justhtml-3.3.0}/docs/quickstart.md +0 -0
  62. {justhtml-3.2.0 → justhtml-3.3.0}/docs/selectors.md +0 -0
  63. {justhtml-3.2.0 → justhtml-3.3.0}/docs/text.md +0 -0
  64. {justhtml-3.2.0 → justhtml-3.3.0}/docs/transforms.md +0 -0
  65. {justhtml-3.2.0 → justhtml-3.3.0}/docs/unsafe-handling.md +0 -0
  66. {justhtml-3.2.0 → justhtml-3.3.0}/docs/url-cleaning.md +0 -0
  67. {justhtml-3.2.0 → justhtml-3.3.0}/llms.txt +0 -0
  68. {justhtml-3.2.0 → justhtml-3.3.0}/run_tests.py +0 -0
  69. {justhtml-3.2.0 → justhtml-3.3.0}/scripts/release.py +0 -0
  70. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/__init__.py +0 -0
  71. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/__main__.py +0 -0
  72. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/core/__init__.py +0 -0
  73. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/core/constants.py +0 -0
  74. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/core/doctype.py +0 -0
  75. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/core/entities.py +0 -0
  76. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/core/errors.py +0 -0
  77. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/core/rawtext.py +0 -0
  78. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/core/text.py +0 -0
  79. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/core/types.py +0 -0
  80. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/dom/builder.py +0 -0
  81. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/parser/__init__.py +0 -0
  82. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/parser/context.py +0 -0
  83. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/parser/encoding.py +0 -0
  84. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/parser/options.py +0 -0
  85. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/parser/scanner.py +0 -0
  86. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/py.typed +0 -0
  87. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/sanitizer/__init__.py +0 -0
  88. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/sanitizer/css.py +0 -0
  89. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/sanitizer/policy.py +0 -0
  90. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/sanitizer/policy_defaults.py +0 -0
  91. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/sanitizer/rawtext.py +0 -0
  92. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/sanitizer/url/__init__.py +0 -0
  93. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/sanitizer/url/policy.py +0 -0
  94. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/sanitizer/url/runtime.py +0 -0
  95. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/sanitizer/url/spec.py +0 -0
  96. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/selector/__init__.py +0 -0
  97. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/selector/core.py +0 -0
  98. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/serializer/__init__.py +0 -0
  99. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/serializer/markdown.py +0 -0
  100. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/transforms/__init__.py +0 -0
  101. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/transforms/compile.py +0 -0
  102. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/transforms/linkify.py +0 -0
  103. {justhtml-3.2.0 → justhtml-3.3.0}/src/justhtml/transforms/spec.py +0 -0
  104. {justhtml-3.2.0 → justhtml-3.3.0}/tests/README.md +0 -0
  105. {justhtml-3.2.0 → justhtml-3.3.0}/tests/__init__.py +0 -0
  106. {justhtml-3.2.0 → justhtml-3.3.0}/tests/data/wikipedia.html +0 -0
  107. {justhtml-3.2.0 → justhtml-3.3.0}/tests/harness/__init__.py +0 -0
  108. {justhtml-3.2.0 → justhtml-3.3.0}/tests/harness/encoding.py +0 -0
  109. {justhtml-3.2.0 → justhtml-3.3.0}/tests/harness/regressions.py +0 -0
  110. {justhtml-3.2.0 → justhtml-3.3.0}/tests/harness/reporter.py +0 -0
  111. {justhtml-3.2.0 → justhtml-3.3.0}/tests/harness/serializer.py +0 -0
  112. {justhtml-3.2.0 → justhtml-3.3.0}/tests/harness/tree.py +0 -0
  113. {justhtml-3.2.0 → justhtml-3.3.0}/tests/justhtml-sanitize-tests/cases.json +0 -0
  114. {justhtml-3.2.0 → justhtml-3.3.0}/tests/justhtml-tests/branch_coverage.dat +0 -0
  115. {justhtml-3.2.0 → justhtml-3.3.0}/tests/justhtml-tests/coverage_gaps.test +0 -0
  116. {justhtml-3.2.0 → justhtml-3.3.0}/tests/justhtml-tests/empty_stack_edge_cases.dat +0 -0
  117. {justhtml-3.2.0 → justhtml-3.3.0}/tests/justhtml-tests/entities.test +0 -0
  118. {justhtml-3.2.0 → justhtml-3.3.0}/tests/justhtml-tests/iframe_srcdoc.dat +0 -0
  119. {justhtml-3.2.0 → justhtml-3.3.0}/tests/justhtml-tests/tokenizer_edge_cases.test +0 -0
  120. {justhtml-3.2.0 → justhtml-3.3.0}/tests/justhtml-tests/treebuilder_coverage.dat +0 -0
  121. {justhtml-3.2.0 → justhtml-3.3.0}/tests/justhtml-tests/xml_coercion.dat +0 -0
  122. {justhtml-3.2.0 → justhtml-3.3.0}/tests/justhtml-tests/xml_coercion_coverage.test +0 -0
  123. {justhtml-3.2.0 → justhtml-3.3.0}/tests/linkify-it/LICENSE.txt +0 -0
  124. {justhtml-3.2.0 → justhtml-3.3.0}/tests/linkify-it/README.md +0 -0
  125. {justhtml-3.2.0 → justhtml-3.3.0}/tests/linkify-it/fixtures/links.txt +0 -0
  126. {justhtml-3.2.0 → justhtml-3.3.0}/tests/linkify-it/fixtures/not_links.txt +0 -0
  127. {justhtml-3.2.0 → justhtml-3.3.0}/tests/test_builder.py +0 -0
  128. {justhtml-3.2.0 → justhtml-3.3.0}/tests/test_cli.py +0 -0
  129. {justhtml-3.2.0 → justhtml-3.3.0}/tests/test_docs_examples.py +0 -0
  130. {justhtml-3.2.0 → justhtml-3.3.0}/tests/test_encoding.py +0 -0
  131. {justhtml-3.2.0 → justhtml-3.3.0}/tests/test_entities.py +0 -0
  132. {justhtml-3.2.0 → justhtml-3.3.0}/tests/test_errors.py +0 -0
  133. {justhtml-3.2.0 → justhtml-3.3.0}/tests/test_linkify_it.py +0 -0
  134. {justhtml-3.2.0 → justhtml-3.3.0}/tests/test_linkify_transform.py +0 -0
  135. {justhtml-3.2.0 → justhtml-3.3.0}/tests/test_parser_engine_coverage.py +0 -0
  136. {justhtml-3.2.0 → justhtml-3.3.0}/tests/test_parser_regressions.py +0 -0
  137. {justhtml-3.2.0 → justhtml-3.3.0}/tests/test_parser_tree.py +0 -0
  138. {justhtml-3.2.0 → justhtml-3.3.0}/tests/test_playground_local_repo_file_list.py +0 -0
  139. {justhtml-3.2.0 → justhtml-3.3.0}/tests/test_sanitize.py +0 -0
  140. {justhtml-3.2.0 → justhtml-3.3.0}/tests/test_sanitize_integration.py +0 -0
  141. {justhtml-3.2.0 → justhtml-3.3.0}/tests/test_sanitize_transform.py +0 -0
  142. {justhtml-3.2.0 → justhtml-3.3.0}/tests/test_selector.py +0 -0
  143. {justhtml-3.2.0 → justhtml-3.3.0}/tests/test_transforms.py +0 -0
  144. {justhtml-3.2.0 → justhtml-3.3.0}/tests/test_transforms_compiler.py +0 -0
  145. {justhtml-3.2.0 → justhtml-3.3.0}/tests/test_transforms_sanitize_integration.py +0 -0
  146. {justhtml-3.2.0 → justhtml-3.3.0}/tests/test_wikipedia.py +0 -0
@@ -41,15 +41,25 @@ jobs:
41
41
  git -C html5lib-tests checkout 9fb614afaa42ce8787840f057b32084308e76549
42
42
  git -C html5lib-tests rev-parse HEAD
43
43
 
44
+ - name: Checkout WPT tree-construction tests
45
+ run: |
46
+ cd ..
47
+ git clone --filter=blob:none --sparse https://github.com/web-platform-tests/wpt.git
48
+ git -C wpt fetch origin 75b089d70448e1da425ab8cf38971b6af41f3cea
49
+ git -C wpt checkout 75b089d70448e1da425ab8cf38971b6af41f3cea
50
+ git -C wpt sparse-checkout set html/syntax/parsing/resources
51
+ git -C wpt rev-parse HEAD
52
+
44
53
  - name: Setup test fixtures
45
54
  run: |
46
55
  cp -a ../html5lib-tests/tokenizer tests/html5lib-tests-tokenizer
47
- cp -a ../html5lib-tests/tree-construction tests/html5lib-tests-tree
56
+ cp -a ../wpt/html/syntax/parsing/resources tests/html5lib-tests-tree
48
57
  cp -a ../html5lib-tests/serializer tests/html5lib-tests-serializer
49
58
  cp -a ../html5lib-tests/encoding tests/html5lib-tests-encoding
50
59
 
51
60
  - name: Run pre-commit
52
61
  id: precommit
62
+ if: matrix.python-version == '3.14'
53
63
  run: pre-commit run --show-diff-on-failure --color=always --all-files
54
64
  env:
55
65
  SKIP: mypy
@@ -61,14 +71,14 @@ jobs:
61
71
  - name: Diagnose test failures (verbose)
62
72
  if: always()
63
73
  run: |
64
- if [ "${{ steps.precommit.outcome }}" = "success" ] && [ "${{ steps.run_tests.outcome }}" = "success" ]; then
74
+ if [ "${{ steps.precommit.outcome }}" != "failure" ] && [ "${{ steps.run_tests.outcome }}" = "success" ]; then
65
75
  echo "No failures detected; skipping verbose rerun."
66
76
  exit 0
67
77
  fi
68
78
  python run_tests.py -v --no-write-summary
69
79
 
70
80
  - name: Run mypy
71
- if: matrix.python-version == '3.12'
81
+ if: matrix.python-version == '3.14'
72
82
  run: mypy
73
83
 
74
84
  test-pyodide:
@@ -33,7 +33,7 @@ repos:
33
33
  hooks:
34
34
  - id: tests-coverage
35
35
  name: Tests & Coverage
36
- entry: bash -c 'coverage run run_tests.py --no-write-summary && coverage report'
36
+ entry: bash -c 'coverage run run_tests.py && coverage report'
37
37
  language: system
38
38
  pass_filenames: false
39
39
  always_run: true
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.3.0] - 2026-07-04
11
+
12
+ ### Changed
13
+ - Document that `Linkify(...)` must run before an explicit `Sanitize(...)` in a transform list, not after, and correct the `clean_url_value()` example to escape the returned value with `escape_attr_value()` before embedding it in HTML. Previously, the docs suggested orderings that silently skip URL-policy validation on Linkify-generated links, and separately claimed `clean_url_value()` output needed no further escaping for HTML attribute embedding.
14
+
15
+ ### Fixed
16
+ - Copy `override_attrs` when cloning a node instead of storing the caller's dict by reference. Previously, calling `clone_node(override_attrs=...)` more than once with the same dict (or holding onto that dict after cloning) produced clones that aliased the same attribute dictionary, so mutating one clone's attributes silently mutated another clone or the caller's own dict.
17
+
18
+ ### Performance
19
+ - Avoid rescanning the entire open-elements stack for every unmatched end tag inside foreign (SVG/MathML) content in the streaming parser (`stream()`), by tracking open-element name counts the same way the main parser engine does. Previously, a run of unmatched end tags deep inside foreign content was quadratic in the depth of nesting.
20
+ - Bound `Linkify(...)`'s email-candidate pattern to RFC 5321's 64-octet local-part limit instead of an unbounded match. Previously, the greedy local-part match followed by a required (and often absent) `@` forced a full-length backtrack at every candidate start position, making long "@"-free runs of otherwise email-local-part-legal text (such as repeated dotted-numeric tokens) quadratic to scan.
21
+ - Share cached sibling/ancestor lookups across nodes when matching structural selectors (`:nth-child`, sibling combinators) in custom `EditAttrs(...)`/`SetAttrs(...)`-style transform pipelines, the same way `query()` already does. Previously, each node rebuilt this data from scratch, making structural-selector transforms quadratic over documents with many siblings. Sharing is only enabled when every transform in the pipeline is provably attribute-only; a pipeline that also uses `Edit(...)`, `Decide(...)`, `Drop(...)`, `Linkify(...)`, or similar structure-changing transforms is unaffected and keeps its previous (already correct) behavior.
22
+ - Access the parser's open-elements stack through a plain attribute again instead of a property, removing a Python-level function call from one of the hottest paths in tree construction.
23
+
24
+ ### Security
25
+ - (Severity: Low) HTML-escape a literal `&` left over after percent-encoding a URL for the `HTMLContext.URL` serialization context (used for `href`/`src`-style attributes). Previously, a URL containing a legacy (no-semicolon) named HTML entity immediately after a decoded `&` could be interpreted differently once embedded in an HTML attribute and re-parsed by a browser than what was validated.
26
+
10
27
  ## [3.2.0] - 2026-07-04
11
28
 
12
29
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: justhtml
3
- Version: 3.2.0
3
+ Version: 3.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
@@ -602,7 +602,7 @@ To override the encoding for byte input, pass `encoding=...`.
602
602
  | `"end"` | `tag_name` | Closing tag |
603
603
  | `"text"` | `text_content` | Text content |
604
604
  | `"comment"` | `comment_text` | HTML comment |
605
- | `"doctype"` | `doctype_name` | DOCTYPE declaration |
605
+ | `"doctype"` | `(name, public_id, system_id)` | DOCTYPE declaration fields |
606
606
 
607
607
  ---
608
608
 
@@ -125,6 +125,33 @@ This matters for Linkify because sanitization policies can remove or rewrite att
125
125
  - Schemes not allowed for `a[href]` are stripped (the `<a>` remains, but `href` is removed).
126
126
  - Protocol-relative `//example.com` is resolved according to policy (default: `https://example.com`).
127
127
 
128
+ **Put `Linkify()` before `Sanitize()`, not after.** It is tempting to reach for `transforms=[Sanitize(policy), Linkify()]`, reasoning "sanitize first, then add links to the clean text." That order does the opposite of what it looks like: your explicit `Sanitize()` position becomes the sanitize point (see above), so the `<a href="...">` elements Linkify creates afterward are never checked against your `UrlPolicy`/`UrlRule` restrictions.
129
+
130
+ ```python
131
+ from justhtml import JustHTML, Linkify, Sanitize, SanitizationPolicy, UrlPolicy, UrlRule
132
+
133
+ # A policy that only allows https:// links on <a href>.
134
+ policy = SanitizationPolicy(
135
+ allowed_tags=["a"],
136
+ allowed_attributes={"a": ["href"]},
137
+ url_policy=UrlPolicy(allow_rules={("a", "href"): UrlRule(allowed_schemes={"https"})}),
138
+ )
139
+ text = "See http://example.com"
140
+
141
+ # Wrong: Linkify's generated <a href> is created after the sanitize point,
142
+ # so the https-only restriction never sees it.
143
+ doc = JustHTML(text, transforms=[Sanitize(policy), Linkify()])
144
+ print(doc.to_html(pretty=False))
145
+ # => See <a href="http://example.com">http://example.com</a>
146
+
147
+ # Right: Linkify runs first, so its output is covered by the same sanitize pass.
148
+ doc = JustHTML(text, transforms=[Linkify(), Sanitize(policy)])
149
+ print(doc.to_html(pretty=False))
150
+ # => See http://example.com
151
+ ```
152
+
153
+ 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()`.
154
+
128
155
  If you want Linkify output without any sanitization changes (trusted input only), use `sanitize=False` and avoid adding `Sanitize(...)` in transforms.
129
156
 
130
157
  ## Provenance
@@ -124,10 +124,12 @@ rule = UrlRule(allowed_schemes={"https", "mailto"})
124
124
  cleaned_url = JustHTML.clean_url_value(value=user_url, url_rule=rule)
125
125
 
126
126
  if cleaned_url:
127
- # URL is safe to use.
128
- # Note: clean_url_value performs URL encoding, so no extra escape needed if using double quotes in HTML.
129
- # <a href="${cleaned_url}">
130
- pass
127
+ # cleaned_url is a validated, percent-encoded URL, not an HTML-attribute-safe
128
+ # string: it deliberately keeps "&" literal so multi-parameter query strings
129
+ # stay usable as plain text (e.g. in a JS string). Escape it like any other
130
+ # attribute value before embedding it in HTML.
131
+ safe_url = JustHTML.escape_attr_value(cleaned_url)
132
+ # <a href="${safe_url}">
131
133
  ```
132
134
 
133
135
  ## Configuration
@@ -15,7 +15,6 @@ The streaming parser is:
15
15
 
16
16
  ```python
17
17
  from justhtml import stream
18
- from pathlib import Path
19
18
 
20
19
  html = "<html><body><p>Hello, world!</p></body></html>"
21
20
 
@@ -23,12 +22,24 @@ for event, data in stream(html):
23
22
  print(event, data)
24
23
  ```
25
24
 
25
+ Output:
26
+ ```text
27
+ start ('html', {})
28
+ start ('body', {})
29
+ start ('p', {})
30
+ text Hello, world!
31
+ end p
32
+ end body
33
+ end html
34
+ ```
35
+
26
36
  ## Byte Input and Encodings
27
37
 
28
38
  `stream()` also accepts bytes (`bytes`, `bytearray`, `memoryview`). In that case, the input is decoded using HTML encoding sniffing (including a `windows-1252` fallback for legacy documents).
29
39
 
30
40
  ```python
31
41
  from justhtml import stream
42
+ from pathlib import Path
32
43
 
33
44
  data = Path("page.html").read_bytes()
34
45
  for event, data in stream(data):
@@ -48,19 +59,6 @@ for event, data in stream(data, encoding="utf-8"):
48
59
 
49
60
  See [Encoding & Byte Input](encoding.md) for details.
50
61
 
51
- Output:
52
- ```
53
- start ('html', {})
54
- start ('head', {})
55
- end head
56
- start ('body', {})
57
- start ('p', {})
58
- text Hello, world!
59
- end p
60
- end body
61
- end html
62
- ```
63
-
64
62
  ## Events
65
63
 
66
64
  | Event | Data Type | Description |
@@ -69,7 +67,7 @@ end html
69
67
  | `"end"` | `tag_name` | Closing tag encountered |
70
68
  | `"text"` | `str` | Text content |
71
69
  | `"comment"` | `str` | HTML comment content |
72
- | `"doctype"` | `str` | DOCTYPE name (usually `"html"`) |
70
+ | `"doctype"` | `(name, public_id, system_id)` | DOCTYPE declaration fields |
73
71
 
74
72
  ## Examples
75
73
 
@@ -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.2.0"
4
+ version = "3.3.0"
5
5
  description = "A pure Python HTML5 parser that just works."
6
6
  readme = "README.md"
7
7
  license = { file = "LICENSE" }
@@ -422,7 +422,7 @@ class Node:
422
422
  Returns:
423
423
  A new node that is a copy of this node.
424
424
  """
425
- attrs = override_attrs if override_attrs is not None else (self.attrs.copy() if self.attrs else None)
425
+ attrs = override_attrs.copy() if override_attrs is not None else (self.attrs.copy() if self.attrs else None)
426
426
  clone = Node(
427
427
  self.name,
428
428
  attrs,
@@ -551,7 +551,7 @@ class Element(Node):
551
551
  self._self_closing = False
552
552
 
553
553
  def clone_node(self, deep: bool = False, override_attrs: dict[str, str | None] | None = None) -> Element:
554
- attrs = override_attrs if override_attrs is not None else (self.attrs.copy() if self.attrs else {})
554
+ attrs = override_attrs.copy() if override_attrs is not None else (self.attrs.copy() if self.attrs else {})
555
555
  clone = Element(self.name, attrs, self.namespace)
556
556
  clone._source_html = self._source_html
557
557
  clone._origin_pos = self._origin_pos
@@ -586,7 +586,7 @@ class Template(Element):
586
586
  self.template_content = None
587
587
 
588
588
  def clone_node(self, deep: bool = False, override_attrs: dict[str, str | None] | None = None) -> Template:
589
- attrs = override_attrs if override_attrs is not None else (self.attrs.copy() if self.attrs else {})
589
+ attrs = override_attrs.copy() if override_attrs is not None else (self.attrs.copy() if self.attrs else {})
590
590
  clone = Template(
591
591
  self.name,
592
592
  attrs,
@@ -722,7 +722,7 @@ class ParseEngine:
722
722
  "_rcdata_tags",
723
723
  "_skip_escaped_comment_space",
724
724
  "_special_elements",
725
- "_stack_impl",
725
+ "_stack",
726
726
  "_strip_invisible_unicode",
727
727
  "_table_allowed_children",
728
728
  "_table_cell_tags",
@@ -847,20 +847,12 @@ class ParseEngine:
847
847
  self._nodes_to_drop: list[Element] = []
848
848
  self._nodes_to_unwrap: list[Element] = []
849
849
  self._template_modes: list[str] = []
850
- self._stack_impl: _CountingStack = _CountingStack()
850
+ self._stack: _CountingStack = _CountingStack()
851
851
 
852
852
  @property
853
853
  def errors(self) -> list[ParseError]:
854
854
  return self._errors
855
855
 
856
- @property
857
- def _stack(self) -> _CountingStack:
858
- return self._stack_impl
859
-
860
- @_stack.setter
861
- def _stack(self, value: list[Node]) -> None:
862
- self._stack_impl = value if isinstance(value, _CountingStack) else _CountingStack(value)
863
-
864
856
  def _line_col_at_pos(self, pos: int) -> tuple[int, int]:
865
857
  if pos < 0: # pragma: no cover - parser offsets are non-negative
866
858
  pos = 0
@@ -1104,7 +1096,7 @@ class ParseEngine:
1104
1096
  html_context = self._fragment_context_namespace in {None, "html"}
1105
1097
  if html_context and context_name in self._rcdata_tags:
1106
1098
  self._body = root
1107
- self._stack = [root]
1099
+ self._stack = _CountingStack([root])
1108
1100
  text = self._clean_text(self._html_input, replace_null=True)
1109
1101
  if context_name == "textarea" and text.startswith("\n"):
1110
1102
  text = text[1:]
@@ -1114,7 +1106,7 @@ class ParseEngine:
1114
1106
 
1115
1107
  if html_context and context_name in self._plaintext_tags:
1116
1108
  self._body = root
1117
- self._stack = [root]
1109
+ self._stack = _CountingStack([root])
1118
1110
  self._append_raw_literal_text(self._html_input, 0)
1119
1111
  return root
1120
1112
 
@@ -1124,7 +1116,7 @@ class ParseEngine:
1124
1116
  or context_name in self._rawtext_element_tags
1125
1117
  ):
1126
1118
  self._body = root
1127
- self._stack = [root]
1119
+ self._stack = _CountingStack([root])
1128
1120
  self._append_raw_literal_text(self._html_input, 0)
1129
1121
  return root
1130
1122
 
@@ -1139,7 +1131,7 @@ class ParseEngine:
1139
1131
  self._html = context
1140
1132
  self._head = head
1141
1133
  self._body = body
1142
- self._stack = [root, context, body]
1134
+ self._stack = _CountingStack([root, context, body])
1143
1135
  if not self._raw_mode:
1144
1136
  if (
1145
1137
  "head" not in self._allowed_tags
@@ -1154,10 +1146,10 @@ class ParseEngine:
1154
1146
  self._append(root, context)
1155
1147
  self._fragment_context_node = context
1156
1148
  self._body = context
1157
- self._stack = [root, context]
1149
+ self._stack = _CountingStack([root, context])
1158
1150
  else:
1159
1151
  self._body = root
1160
- self._stack = [root]
1152
+ self._stack = _CountingStack([root])
1161
1153
  else:
1162
1154
  doc = Document()
1163
1155
  if self._track_tag_spans:
@@ -1172,7 +1164,7 @@ class ParseEngine:
1172
1164
  self._html = html_el
1173
1165
  self._head = head
1174
1166
  self._body = body
1175
- self._stack = [doc, html_el, body]
1167
+ self._stack = _CountingStack([doc, html_el, body])
1176
1168
 
1177
1169
  self._parse_range(0, self._length)
1178
1170
  self._finish_document_shell()
@@ -1408,7 +1400,7 @@ class ParseEngine:
1408
1400
  and not self._body_has_content()
1409
1401
  and not self._has_open_parser_only_template()
1410
1402
  ):
1411
- self._stack = [self._doc, self._html, self._head] # type: ignore[list-item]
1403
+ self._stack = _CountingStack([self._doc, self._html, self._head]) # type: ignore[list-item]
1412
1404
  self._after_head = False
1413
1405
  self._explicit_head = True
1414
1406
  else:
@@ -1818,7 +1810,7 @@ class ParseEngine:
1818
1810
  if source_pos is not None: # pragma: no branch - opposite edge requires invalid parser state
1819
1811
  source_pos += leading_len
1820
1812
  raw = stripped
1821
- self._stack = [self._doc, self._html, self._body] # type: ignore[list-item]
1813
+ self._stack = _CountingStack([self._doc, self._html, self._body]) # type: ignore[list-item]
1822
1814
  self._after_head = False
1823
1815
  self._body_mode_seen = True
1824
1816
  parent = self._body
@@ -1826,7 +1818,7 @@ class ParseEngine:
1826
1818
  if raw_is_space is None: # pragma: no branch - opposite edge requires invalid parser state
1827
1819
  raw_is_space = raw.strip(_SPACE) == ""
1828
1820
  if not raw_is_space:
1829
- self._stack = [self._doc, self._html, self._body] # type: ignore[list-item]
1821
+ self._stack = _CountingStack([self._doc, self._html, self._body]) # type: ignore[list-item]
1830
1822
  self._after_head = False
1831
1823
  self._body_mode_seen = True
1832
1824
  parent = self._body
@@ -1835,7 +1827,7 @@ class ParseEngine:
1835
1827
  raw_is_space = raw.strip(_SPACE) == ""
1836
1828
  if not raw_is_space:
1837
1829
  if self._find_open_html_index("body") is None:
1838
- self._stack = [self._doc, self._html, self._body] # type: ignore[list-item]
1830
+ self._stack = _CountingStack([self._doc, self._html, self._body]) # type: ignore[list-item]
1839
1831
  self._after_body = False
1840
1832
  self._after_document = False
1841
1833
  self._after_html = False
@@ -2068,9 +2060,9 @@ class ParseEngine:
2068
2060
  if (
2069
2061
  self._frameset_seen and not self._body_explicit
2070
2062
  ): # pragma: no cover - later state normalization restores body mode
2071
- self._stack = [self._doc, self._html] # type: ignore[list-item]
2063
+ self._stack = _CountingStack([self._doc, self._html]) # type: ignore[list-item]
2072
2064
  else:
2073
- self._stack = [self._doc, self._html, self._body] # type: ignore[list-item]
2065
+ self._stack = _CountingStack([self._doc, self._html, self._body]) # type: ignore[list-item]
2074
2066
  self._after_head = False
2075
2067
  self._body_mode_seen = True
2076
2068
  return pos
@@ -2078,7 +2070,7 @@ class ParseEngine:
2078
2070
  self._in_colgroup = False
2079
2071
  if self._body_mode_seen and self._stack[-1] is not self._head:
2080
2072
  return pos
2081
- self._stack = [self._doc, self._html] # type: ignore[list-item]
2073
+ self._stack = _CountingStack([self._doc, self._html]) # type: ignore[list-item]
2082
2074
  self._after_head = True
2083
2075
  return pos
2084
2076
  if name == "colgroup" and not self._parser_only_template_depth:
@@ -2119,7 +2111,7 @@ class ParseEngine:
2119
2111
  and self._stack[-1] is self._head
2120
2112
  and self._html is not None
2121
2113
  ):
2122
- self._stack = [self._doc, self._html, self._body] # type: ignore[list-item]
2114
+ self._stack = _CountingStack([self._doc, self._html, self._body]) # type: ignore[list-item]
2123
2115
  self._after_head = False
2124
2116
  self._body_mode_seen = True
2125
2117
  if self._in_head_noscript:
@@ -2279,7 +2271,7 @@ class ParseEngine:
2279
2271
  if (
2280
2272
  self._find_open_html_index("body") is None
2281
2273
  ): # pragma: no cover - body-less after-document tags return earlier
2282
- self._stack = [self._doc, self._html, self._body] # type: ignore[list-item]
2274
+ self._stack = _CountingStack([self._doc, self._html, self._body]) # type: ignore[list-item]
2283
2275
  self._after_body = False
2284
2276
  self._after_document = False
2285
2277
  self._after_html = False
@@ -2291,9 +2283,9 @@ class ParseEngine:
2291
2283
  return pos
2292
2284
  self._in_colgroup = False
2293
2285
  if self._head is not None and self._stack[-1] is self._head:
2294
- self._stack = [self._doc, self._html, self._body] # type: ignore[list-item]
2286
+ self._stack = _CountingStack([self._doc, self._html, self._body]) # type: ignore[list-item]
2295
2287
  if self._frameset_seen and not self._body_explicit:
2296
- self._stack = [self._doc, self._html] # type: ignore[list-item] # pragma: no cover - frameset state keeps later body reconstruction unreachable here
2288
+ self._stack = _CountingStack([self._doc, self._html]) # type: ignore[list-item] # pragma: no cover - frameset state keeps later body reconstruction unreachable here
2297
2289
  self._after_head = False
2298
2290
  self._body_mode_seen = (
2299
2291
  True # pragma: no cover - frameset end-tag paths return before re-entering body mode
@@ -2311,7 +2303,7 @@ class ParseEngine:
2311
2303
  self._set_end_span(self._html, name, tag_start, tag_end)
2312
2304
  return pos
2313
2305
  if self._fragment and self._fragment_context_name == "html" and name == "html":
2314
- self._stack = [self._doc]
2306
+ self._stack = _CountingStack([self._doc])
2315
2307
  return pos
2316
2308
  if not self._fragment and name == "head":
2317
2309
  self._in_colgroup = False
@@ -2320,7 +2312,7 @@ class ParseEngine:
2320
2312
  if self._head is not None: # pragma: no branch - opposite edge requires invalid parser state
2321
2313
  if self._track_tag_spans:
2322
2314
  self._set_end_span(self._head, name, tag_start, tag_end)
2323
- self._stack = [self._doc, self._html] # type: ignore[list-item]
2315
+ self._stack = _CountingStack([self._doc, self._html]) # type: ignore[list-item]
2324
2316
  self._after_head = True
2325
2317
  return pos
2326
2318
  if name == "colgroup" and not self._parser_only_template_depth:
@@ -2383,7 +2375,7 @@ class ParseEngine:
2383
2375
  and self._stack[-1] is self._head
2384
2376
  and self._html is not None
2385
2377
  ):
2386
- self._stack = [self._doc, self._html, self._body] # type: ignore[list-item]
2378
+ self._stack = _CountingStack([self._doc, self._html, self._body]) # type: ignore[list-item]
2387
2379
  self._after_head = False
2388
2380
  self._body_mode_seen = True
2389
2381
  if self._in_head_noscript:
@@ -2551,7 +2543,7 @@ class ParseEngine:
2551
2543
  if self._body_mode_seen:
2552
2544
  return pos
2553
2545
  if self._head is not None: # pragma: no branch - opposite edge requires invalid parser state
2554
- self._stack = [self._doc, self._html, self._head] # type: ignore[list-item]
2546
+ self._stack = _CountingStack([self._doc, self._html, self._head]) # type: ignore[list-item]
2555
2547
  self._after_head = False
2556
2548
  return pos
2557
2549
  if name == "body":
@@ -2567,7 +2559,7 @@ class ParseEngine:
2567
2559
  if self._body_mode_seen:
2568
2560
  return pos
2569
2561
  self._body_mode_seen = True
2570
- self._stack = [self._doc, self._html, self._body] # type: ignore[list-item]
2562
+ self._stack = _CountingStack([self._doc, self._html, self._body]) # type: ignore[list-item]
2571
2563
  self._after_head = False
2572
2564
  return pos
2573
2565
  if not self._body_mode_seen and not (action.head_content if action is not None else False):
@@ -2581,7 +2573,7 @@ class ParseEngine:
2581
2573
  and current_top is self._head
2582
2574
  and not (action.head_content if action is not None else False)
2583
2575
  ):
2584
- self._stack = [self._doc, self._html, self._body] # type: ignore[list-item]
2576
+ self._stack = _CountingStack([self._doc, self._html, self._body]) # type: ignore[list-item]
2585
2577
  self._after_head = False
2586
2578
  self._body_mode_seen = True
2587
2579
  current_top = self._body
@@ -2601,11 +2593,11 @@ class ParseEngine:
2601
2593
  and not self._body_mode_seen
2602
2594
  and not self._body_has_content()
2603
2595
  ):
2604
- self._stack = [self._doc, self._html, self._head] # type: ignore[list-item]
2596
+ self._stack = _CountingStack([self._doc, self._html, self._head]) # type: ignore[list-item]
2605
2597
  self._after_head = False
2606
2598
  current_top = self._head
2607
2599
  else:
2608
- self._stack = [self._doc, self._html, self._body] # type: ignore[list-item]
2600
+ self._stack = _CountingStack([self._doc, self._html, self._body]) # type: ignore[list-item]
2609
2601
  self._after_head = False
2610
2602
  self._body_mode_seen = True
2611
2603
  current_top = self._body
@@ -2644,7 +2636,7 @@ class ParseEngine:
2644
2636
 
2645
2637
  if action is not None and action.drop_content:
2646
2638
  if self._raw_head_text_parent(name) and self._head is not None:
2647
- self._stack = [self._doc, self._html, self._head] # type: ignore[list-item]
2639
+ self._stack = _CountingStack([self._doc, self._html, self._head]) # type: ignore[list-item]
2648
2640
  return self._skip_rawtext(name, pos, end)
2649
2641
  if action is not None and action.drop_subtree:
2650
2642
  if action.allowed: # pragma: no branch - opposite edge requires invalid parser state
@@ -2823,7 +2815,7 @@ class ParseEngine:
2823
2815
  in_template_content = bool(self._template_modes)
2824
2816
  if not self._fragment and self._after_document and name != "html" and not self._frameset_seen:
2825
2817
  if self._find_open_html_index("body") is None:
2826
- self._stack = [self._doc, self._html, self._body] # type: ignore[list-item]
2818
+ self._stack = _CountingStack([self._doc, self._html, self._body]) # type: ignore[list-item]
2827
2819
  self._after_body = False
2828
2820
  self._after_document = False
2829
2821
  self._after_html = False
@@ -2873,7 +2865,7 @@ class ParseEngine:
2873
2865
  self._head.attrs.setdefault(attr_name, attr_value)
2874
2866
  self._set_origin(self._head, tag_start)
2875
2867
  self._set_source_span(self._head, tag_start, tag_end)
2876
- self._stack = [self._doc, self._html, self._head] # type: ignore[list-item]
2868
+ self._stack = _CountingStack([self._doc, self._html, self._head]) # type: ignore[list-item]
2877
2869
  self._after_head = False
2878
2870
  return pos
2879
2871
  if name == "body":
@@ -2891,7 +2883,7 @@ class ParseEngine:
2891
2883
  if self._body_mode_seen:
2892
2884
  return pos
2893
2885
  self._body_mode_seen = True
2894
- self._stack = [self._doc, self._html, self._body] # type: ignore[list-item]
2886
+ self._stack = _CountingStack([self._doc, self._html, self._body]) # type: ignore[list-item]
2895
2887
  self._after_head = False
2896
2888
  return pos
2897
2889
  if not self._body_mode_seen and not (action.head_content if action is not None else False):
@@ -2905,7 +2897,7 @@ class ParseEngine:
2905
2897
  and current_top is self._head
2906
2898
  and not (action.head_content if action is not None else False)
2907
2899
  ):
2908
- self._stack = [self._doc, self._html, self._body] # type: ignore[list-item]
2900
+ self._stack = _CountingStack([self._doc, self._html, self._body]) # type: ignore[list-item]
2909
2901
  self._after_head = False
2910
2902
  self._body_mode_seen = True
2911
2903
  current_top = self._body
@@ -2926,11 +2918,11 @@ class ParseEngine:
2926
2918
  and not self._body_mode_seen
2927
2919
  and not self._body_has_content()
2928
2920
  ):
2929
- self._stack = [self._doc, self._html, self._head] # type: ignore[list-item]
2921
+ self._stack = _CountingStack([self._doc, self._html, self._head]) # type: ignore[list-item]
2930
2922
  self._head_reentry = True
2931
2923
  current_top = self._head
2932
2924
  else:
2933
- self._stack = [self._doc, self._html, self._body] # type: ignore[list-item]
2925
+ self._stack = _CountingStack([self._doc, self._html, self._body]) # type: ignore[list-item]
2934
2926
  self._after_head = False
2935
2927
  self._body_mode_seen = True
2936
2928
  current_top = self._body
@@ -3071,7 +3063,7 @@ class ParseEngine:
3071
3063
  return self._parse_rawtext_element(name, attrs, self_closing, pos, end, tag_start, tag_end)
3072
3064
  if action is not None and not raw_mode and action.drop_content:
3073
3065
  if self._raw_head_text_parent(name) and self._head is not None:
3074
- self._stack = [self._doc, self._html, self._head] # type: ignore[list-item]
3066
+ self._stack = _CountingStack([self._doc, self._html, self._head]) # type: ignore[list-item]
3075
3067
  return self._skip_rawtext(name, pos, end)
3076
3068
  if action is not None and action.rawtext_as_text:
3077
3069
  return self._parse_rawtext_as_text(name, pos, end)
@@ -3086,7 +3078,7 @@ class ParseEngine:
3086
3078
  or (not self._body_explicit and not self._body_mode_seen and not self._body_has_content())
3087
3079
  )
3088
3080
  ):
3089
- self._stack = [self._doc, self._html, self._head] # type: ignore[list-item]
3081
+ self._stack = _CountingStack([self._doc, self._html, self._head]) # type: ignore[list-item]
3090
3082
  if raw_mode:
3091
3083
  self._insert_sanitized_element(
3092
3084
  name, attrs, self_closing, self._head, tag_start=tag_start, tag_end=tag_end
@@ -3149,7 +3141,7 @@ class ParseEngine:
3149
3141
  if (
3150
3142
  not self._fragment and self._html is not None
3151
3143
  ): # pragma: no branch - opposite edge requires invalid parser state
3152
- self._stack = [self._doc, self._html]
3144
+ self._stack = _CountingStack([self._doc, self._html])
3153
3145
  else:
3154
3146
  for idx, stack_node in enumerate(
3155
3147
  self._stack[1:], start=1
@@ -3677,7 +3669,7 @@ class ParseEngine:
3677
3669
  self._in_head_noscript = False
3678
3670
  if self._fragment or self._html is None: # pragma: no branch - opposite edge requires invalid parser state
3679
3671
  return # pragma: no cover - unreachable after parser-state guards
3680
- self._stack = [self._doc, self._html, self._body] # type: ignore[list-item]
3672
+ self._stack = _CountingStack([self._doc, self._html, self._body]) # type: ignore[list-item]
3681
3673
  self._after_head = False
3682
3674
  self._body_mode_seen = True
3683
3675
 
@@ -3687,7 +3679,7 @@ class ParseEngine:
3687
3679
  if self._open_template_index() is not None: # pragma: no branch - reentry finishes after templates close
3688
3680
  return # pragma: no cover - guarded by template close ordering
3689
3681
  self._head_reentry = False
3690
- self._stack = [self._doc, self._html]
3682
+ self._stack = _CountingStack([self._doc, self._html])
3691
3683
  self._after_head = True
3692
3684
 
3693
3685
  def _close_to_fragment_context(self) -> bool:
@@ -4468,7 +4460,7 @@ class ParseEngine:
4468
4460
  self._set_end_span(node, name, close_start, pos)
4469
4461
  self._stack.pop()
4470
4462
  if parsed_in_initial_head and self._html is not None and self._head is not None:
4471
- self._stack = [self._doc, self._html, self._head] # type: ignore[list-item]
4463
+ self._stack = _CountingStack([self._doc, self._html, self._head]) # type: ignore[list-item]
4472
4464
  self._finish_head_reentry()
4473
4465
  return pos
4474
4466
 
@@ -4520,7 +4512,7 @@ class ParseEngine:
4520
4512
  if self._stack and self._stack[-1] is node: # pragma: no branch - opposite edge requires invalid parser state
4521
4513
  self._stack.pop()
4522
4514
  if parsed_in_initial_head and self._html is not None and self._head is not None:
4523
- self._stack = [self._doc, self._html, self._head] # type: ignore[list-item]
4515
+ self._stack = _CountingStack([self._doc, self._html, self._head]) # type: ignore[list-item]
4524
4516
  self._finish_head_reentry()
4525
4517
  return pos
4526
4518
 
@@ -5624,7 +5616,7 @@ class ParseEngine:
5624
5616
  children.clear()
5625
5617
  self._frameset_seen = True
5626
5618
  self._mark_active_formatting_dirty()
5627
- self._stack = [self._doc, self._html] # type: ignore[list-item]
5619
+ self._stack = _CountingStack([self._doc, self._html]) # type: ignore[list-item]
5628
5620
  self._after_head = False
5629
5621
  return True
5630
5622
 
@@ -5642,7 +5634,7 @@ class ParseEngine:
5642
5634
  self._body, Element
5643
5635
  ): # pragma: no branch - opposite edge requires invalid parser state
5644
5636
  self._remove_child(self._html, self._body)
5645
- self._stack = [self._doc, self._html]
5637
+ self._stack = _CountingStack([self._doc, self._html])
5646
5638
  self._frameset_seen = True
5647
5639
  self._mark_active_formatting_dirty()
5648
5640
  return True