autumn-web 0.7.0

An opinionated, convention-over-configuration web framework for Rust
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
3110
3111
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
3149
3150
3151
3152
3153
3154
3155
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
//! A small, tolerant HTML-subset parser.
//!
//! This is **not** a general HTML5 parser: it is just enough tag/text/entity
//! handling to walk a Maud-rendered (or hand-written) HTML string into a tree
//! [`crate::pdf::layout`] can lay out as PDF text. It never panics on
//! malformed input — unmatched closing tags are ignored, unclosed tags are
//! auto-closed at end of input, and it parses iteratively (no recursion) so
//! adversarially deep nesting can't blow the stack.

/// A parsed node: either a run of text or an element with children.
#[derive(Debug, PartialEq)]
pub(super) enum Node {
    Text(String),
    Element { tag: String, children: Vec<Self> },
}

impl Drop for Node {
    /// Drop a (possibly very deep) tree iteratively.
    ///
    /// The compiler-generated `Drop` glue for a recursive type like this one
    /// would drop each level's children by recursing — for a pathologically
    /// deep tree (see the parser's own stack-safety test) that overflows the
    /// stack even though *parsing* never recurses. This flattens the tree
    /// into an explicit heap-allocated work list instead: each popped node's
    /// children are moved out onto the list before the (now childless, so
    /// trivially-dropped) node itself goes out of scope.
    fn drop(&mut self) {
        let mut stack: Vec<Self> = if let Self::Element { children, .. } = self {
            std::mem::take(children)
        } else {
            return;
        };
        while let Some(mut node) = stack.pop() {
            if let Self::Element { children, .. } = &mut node {
                stack.append(children);
            }
        }
    }
}

/// HTML elements with no content and no closing tag.
fn is_void_element(tag: &str) -> bool {
    matches!(
        tag,
        "br" | "hr"
            | "img"
            | "input"
            | "meta"
            | "link"
            | "area"
            | "base"
            | "col"
            | "embed"
            | "source"
            | "track"
            | "wbr"
    )
}

/// Whether opening `new_tag` implicitly closes an `open_tag` still open at
/// the top of the stack — HTML5's "optional end tag" rule, scoped to the
/// tags this renderer gives special (marker/cell/row/block) treatment to.
/// Real-world or hand-written HTML commonly omits these closing tags
/// (`<ul><li>One<li>Two</ul>`, `<tr><td>A<td>B</table>`,
/// `<p>Intro<table>...</table>`); without this, the new tag nests *inside*
/// the still-open previous one instead of becoming its sibling.
///
/// For `li`/`dt`/`dd`/`tr`/`td`/`th`, `extract_list_items`/
/// `extract_table_rows` (in `layout.rs`) only look at *direct* children, so
/// a nested-instead-of-sibling element's marker/cell/row boundary becomes
/// invisible to them (a second bullet silently disappears, a second cell
/// silently merges into the first). For `p`, the failure mode is
/// different but just as real: a still-open `<p>` swallows the next
/// supported block element as inline content instead of letting it become
/// its own top-level [`Block`](super::layout) — `<p>Intro<table>...</table>`
/// flattens the table's rows/cells into bare inline text (`IntroAB`
/// instead of a real table) since `inline_spans` has no notion of a table.
/// For `head`, the failure mode is the most severe of all: `head` is in
/// `is_non_rendered` (in `layout.rs`), so nesting anything under a
/// still-open `head` discards the *entire visible document* wholesale, not
/// just one element's structure. HTML5 permits omitting *both* `</head>`
/// (`<head><title>X</title><body>...`) *and* the `<body>` start tag itself
/// (`<head><title>X</title><p>...`, or even bare text with no wrapper tag
/// at all) — either way, the first tag or non-whitespace text that isn't
/// valid content for `<head>` (see [`is_valid_in_head`]) implicitly closes
/// it, matching the "in head" insertion mode's behavior for any
/// unexpected token, not only an explicit `<body>`.
///
/// Doesn't need to cover every HTML5 optional-end-tag rule, only the ones
/// for tags this renderer actually gives that special treatment to.
fn implicitly_closes(open_tag: &str, new_tag: &str) -> bool {
    (open_tag == "p" && closes_open_paragraph(new_tag))
        || (open_tag == "head" && !is_valid_in_head(new_tag))
        || matches!(
            (open_tag, new_tag),
            ("li", "li")
                | ("dt" | "dd", "dt" | "dd")
                // A new table section (`<thead>`/`<tbody>`/`<tfoot>`)
                // closes an open cell/row/section the same way a new
                // `<tr>` does — `<table><thead><tr><th>H<tbody>...` omits
                // `</th>`, `</tr>`, *and* `</thead>` together, and without
                // this the whole `<tbody>` (and its row/cell) nested
                // *inside* the still-open header cell, so
                // `extract_table_rows` (which only reads a `<tr>`'s
                // *direct* `<td>`/`<th>` children) flattened the body
                // row's text into the header cell instead of emitting it
                // as a separate row.
                | ("tr", "tr" | "thead" | "tbody" | "tfoot")
                | ("td" | "th", "td" | "th" | "tr" | "thead" | "tbody" | "tfoot")
                | ("thead" | "tbody" | "tfoot", "thead" | "tbody" | "tfoot")
        )
}

/// Tags HTML5 permits directly inside `<head>` — anything else implies
/// `</head>` before it opens; see [`implicitly_closes`].
fn is_valid_in_head(tag: &str) -> bool {
    matches!(
        tag,
        "head" | "title" | "base" | "link" | "meta" | "style" | "script" | "noscript" | "template"
    )
}

/// Tags that, per HTML5's `<p>` implied-end-tag rule, close an open `<p>`
/// when they start — restricted to headings and the block-level container
/// tags this renderer gives real block/list/table structure to, since only
/// those have the "swallowed as inline content" failure mode
/// [`implicitly_closes`] exists to prevent.
fn closes_open_paragraph(new_tag: &str) -> bool {
    matches!(
        new_tag,
        "p" | "div"
            | "blockquote"
            | "dl"
            | "dt"
            | "dd"
            | "hr"
            | "table"
            | "ul"
            | "ol"
            | "li"
            | "section"
            | "article"
            | "main"
            | "header"
            | "footer"
            | "nav"
            | "aside"
            | "h1"
            | "h2"
            | "h3"
            | "h4"
            | "h5"
            | "h6"
    )
}

/// "Raw text" elements per the HTML5 parsing spec: their content is never
/// tokenized as markup at all, even if it contains characters that look
/// like tags (e.g. a JS comparison `a<b` inside `<script>`, or a CSS `>`
/// combinator inside `<style>`) — only the literal closing tag ends them.
/// Without this, such a `<` can be parsed as a bogus opening tag that
/// swallows the real `</script>`, leaving the element unclosed and nesting
/// (and, since `is_non_rendered` in `layout.rs` skips its subtree, hiding)
/// everything that follows.
///
/// `title` and `textarea` are technically RCDATA elements (character
/// references still decode, unlike true raw text such as `script`/`style`)
/// rather than raw-text ones, but both are scanned the same way here: the
/// caller in [`parse`] runs [`decode_entities`] over whatever text this
/// returns, which is a no-op for `script`/`style` (their content is
/// discarded wholesale by `is_non_rendered` in `layout.rs` regardless) and
/// correct for `title`/`textarea` (real content that still needs its
/// entities decoded). What matters for all four is that their content must
/// not be tokenized as markup: a tag-looking sequence such as
/// `<title>a<b</title>` or `<textarea>a<b</textarea>` would otherwise let
/// `<b` consume the real closing tag the same way an unhandled `<script>`
/// body could, leaving everything after it nested (and, for `title`,
/// hidden) inside an unclosed element — or, for `textarea`, simply lost.
fn is_raw_text_element(tag: &str) -> bool {
    matches!(tag, "script" | "style" | "title" | "textarea")
}

/// If `s` (starting with `<`) looks like the start of an opening tag for
/// one of the six tags `layout.rs`'s `is_non_rendered` hides wholesale
/// (`script`, `style`, `noscript`, `template`, `head`, `title`), or for
/// `textarea` — case-insensitively, and only when the tag name is
/// immediately followed by a real tag-name boundary (whitespace, `/`, or
/// `>`), not merely a shared prefix (`<scripted>` doesn't count) — returns
/// its canonical lowercase tag name.
///
/// Used only as a fallback once [`parse_open_tag`] has already failed (its
/// bounded search found no `>` within [`MAX_TAG_SCAN`]) — e.g. one of these
/// seven tags carrying an attribute long enough to push its own `>` past
/// that bound. Without this, [`parse`]'s normal "unrecognized tag"
/// fallback treats the lone `<` as literal text and re-parses everything
/// after it one byte at a time — meaning content that's supposed to be
/// hidden (`layout.rs` skips the six `is_non_rendered` tags' subtrees
/// entirely, and `script`/`style`/`title`/`textarea` content additionally
/// isn't even meant to be *tokenized* as markup, see
/// [`is_raw_text_element`]) leaks into the visible document instead — for
/// `<title>` inside a still-open `<head>`, the non-whitespace fallback
/// text also implicitly closes the head (see [`push_text`]), so both the
/// oversized attribute *and* the title text leak in; for `<textarea>`
/// specifically, the fallback also means its real content (meant to
/// render, unlike the other six) gets tokenized as markup instead of
/// staying raw text — a stray `<b>`-looking sequence inside it would
/// wrongly become a real element instead of literal text.
///
/// Mostly mirrors `is_non_rendered`'s tag set (plus `textarea`, which
/// isn't `is_non_rendered` — see below) rather than importing it: this
/// small parser has no dependency on `layout.rs`'s rendering decisions,
/// same as [`is_structural_tag`] mirrors (rather than calls into) the
/// tag sets it's related to elsewhere in this file.
///
/// The caller in [`parse`] gives three of the six `is_non_rendered` tags
/// — `head`, `noscript`, and `template` — different handling than
/// `script`/`style`/`title`: those three are [structural,
/// generally-parsed elements](is_structural_tag) even when normally
/// sized (their content is real markup, only hidden at render time by
/// `is_non_rendered`), so discarding straight through to a literal
/// closing tag the way the true [raw-text elements](is_raw_text_element)
/// do would be wrong two different ways. For `head`, whose closing tag is
/// optional (implicitly closed by later non-head content, see
/// [`implicitly_closes`]), it would swallow the rest of the document —
/// including `<body>` — whenever `</head>` is omitted, which is legal
/// HTML. For `noscript`/`template`, it would stop at the first *nested*
/// same-name closing tag rather than the correctly-matching outer one —
/// e.g. `<template>` containing another `<template>` — leaking whatever
/// sits between the inner and outer closing tags into the visible
/// document instead of keeping it inside the (fully hidden) outer
/// element's subtree. See the call site for how all three instead get a
/// real stack frame and fall through to normal parsing, exactly like a
/// normally-sized instance of the same tag already does.
///
/// `textarea` gets a fourth kind of handling, different again: like
/// `script`/`style`/`title` it's a genuine [raw-text
/// element](is_raw_text_element) (its content must not be tokenized as
/// markup), so it reuses the same [`consume_raw_text`] scan those three
/// do — but unlike them, its content is real, rendered text (a form
/// default value, say), not something `is_non_rendered` discards, so the
/// scanned text is emitted as a real child node instead of thrown away —
/// see the call site's `push_oversized_raw_text_content_tag`. Nested
/// same-tag content still closes at the first matching closing tag rather
/// than the correctly-nested one for all four genuinely raw-text tags
/// (`script`/`style`/`title`/`textarea`), the same simplification
/// [`consume_raw_text`] already accepts for a normally-sized `<script>`/
/// `<style>`/`<textarea>` — acceptable here too since real HTML doesn't
/// nest these tags either (a literal `<textarea>` inside a `<textarea>`
/// is just more raw text up to the first `</textarea>`, not a nested
/// element). `head`/`noscript`/`template` don't get that simplification
/// (see the call site in [`parse`]): their content is real,
/// generally-parsed markup that's only hidden at render time, so nested
/// same-name tags must close in the correct (innermost-first) order the
/// way normal parsing already guarantees.
fn oversized_raw_text_tag_name(s: &str) -> Option<&'static str> {
    debug_assert!(s.starts_with('<'));
    let rest = &s[1..];
    for name in [
        "script", "style", "title", "head", "noscript", "template", "textarea",
    ] {
        if rest.len() < name.len()
            || !rest.as_bytes()[..name.len()].eq_ignore_ascii_case(name.as_bytes())
        {
            continue;
        }
        let is_boundary = rest[name.len()..]
            .chars()
            .next()
            .is_none_or(|c| c == '>' || c == '/' || c.is_whitespace());
        if is_boundary {
            return Some(name);
        }
    }
    None
}

/// Locates the byte position right after an oversized tag's own `>` —
/// `after_name` is the position right after the tag name (e.g. right after
/// `<script`, before any attributes), and this searches the still-unparsed
/// attribute list for the real closing `>` via the same quote-aware
/// [`find_tag_end`] [`parse_open_tag`] itself uses, just unbounded this
/// time. Falls back to `len` (EOF) if no real `>` is ever found, matching
/// this parser's usual auto-close-at-EOF tolerance.
///
/// Unbounded is safe here (a bounded-window fallback used to exist and was
/// removed — see [`find_tag_end`]'s own docs for why an earlier version of
/// *it* wasn't safe unbounded, and how that was fixed at the root instead
/// of by adding a window here): scoping the search to a fixed-size window
/// and giving up beyond it doesn't just risk a *slower* search, it's
/// outright wrong — it can never distinguish "the real `>` is genuinely
/// far away" (e.g. a large but legitimate base64 attribute) from "there
/// is no real `>` at all," so it either has to give up early on valid
/// content or, if it instead treats the window boundary as a fake body
/// start, leaks whatever's left of the attribute value (and the literal
/// `">`) as visible text once normal parsing resumes mid-attribute. Now
/// that `find_tag_end` itself costs O(distance to the real `>`, or to EOF
/// if there is none) rather than O(window size), removing the window
/// entirely is both simpler and strictly more correct.
///
/// Quote-awareness matters here specifically: a naive scan for `>` (or,
/// worse, skipping straight to a raw-text-style scan for `</name` from
/// `after_name` without finding the real `>` at all) can be fooled by a
/// quoted attribute value that itself contains a `</name`-looking
/// substring appearing *before* the tag's actual closing `>` — e.g.
/// `<script data-x="</script>` followed by more oversized attribute data
/// and then `">Secret</script>` — mistaking that quoted text for the real
/// closing tag and leaking everything after it (the rest of the attribute
/// value, and the element's real content) as visible text.
fn oversized_tag_body_start(input: &str, after_name: usize, len: usize) -> usize {
    find_tag_end(&input[after_name..]).map_or(len, |i| after_name + i + 1)
}

/// Handles an oversized `<head ...>`/`<noscript ...>`/`<template ...>`
/// opening tag once [`oversized_raw_text_tag_name`] has already recognized
/// it (and confirmed it isn't one of the three genuinely
/// [raw-text](is_raw_text_element) tags) and [`oversized_tag_body_start`]
/// has located where its content begins — see [`oversized_raw_text_tag_name`]'s
/// docs for why these three can't reuse the same discard-to-literal-
/// closing-tag treatment as `script`/`style`/`title`. Pushes a real frame
/// for `name` (exactly as a normally-sized instance of the same tag would
/// get) and returns `body_start` unchanged (accepted as a parameter purely
/// so the caller doesn't have to juggle two different "new pos" values
/// across the `if`). Extracted out of [`parse`] purely to keep that
/// function's line count down — this has no state of its own beyond
/// `stack`.
fn push_oversized_nested_tag(
    stack: &mut Vec<(String, Vec<Node>, usize)>,
    name: &str,
    body_start: usize,
) -> usize {
    let structural_idx = nearest_structural_idx(stack, name);
    stack.push((name.to_owned(), Vec::new(), structural_idx));
    body_start
}

/// Handles an oversized `<textarea ...>` opening tag once
/// [`oversized_raw_text_tag_name`] has already recognized it and
/// [`oversized_tag_body_start`] has located where its content begins — see
/// that function's docs for why `textarea` can't reuse either of the
/// other two treatments: it's a genuine [raw-text element](is_raw_text_element)
/// (its content must not be tokenized as markup, so it needs the same
/// [`consume_raw_text`] scan `script`/`style`/`title` use), but unlike
/// those three its content is real, rendered text rather than something
/// `is_non_rendered` discards, so — mirroring the normal-sized `textarea`
/// handling in [`parse`] — the scanned text is decoded and emitted as a
/// real child node instead of thrown away. Extracted out of [`parse`]
/// purely to keep that function's line count down.
fn push_oversized_raw_text_content_tag(
    stack: &mut [(String, Vec<Node>, usize)],
    input: &str,
    body_start: usize,
    name: &str,
) -> usize {
    let (text, new_pos) = consume_raw_text(input, body_start, name);
    let mut children = Vec::new();
    if !text.is_empty() {
        children.push(Node::Text(decode_entities(text)));
    }
    stack
        .last_mut()
        .expect("root frame is never popped")
        .1
        .push(Node::Element {
            tag: name.to_owned(),
            children,
        });
    new_pos
}

/// Bound on how far [`push_oversized_generic_tag`] looks for its tag
/// name's own end (`>`, `/`, or whitespace) before giving up. Real tag
/// names — including verbose custom-element ones (`<my-custom-widget>`)
/// — are always short, so this only needs to be generous, not large; the
/// point is to *reject* a run of bare `<` characters with no real tag
/// structure at all (e.g. `"<a".repeat(n)`, already covered by
/// [`long_run_of_unterminated_open_tags_is_linear_not_quadratic`]) rather
/// than swallow the *entire remaining document* as one bogus tag name —
/// which, besides being wrong, would also make `close_implied_tags`
/// (called with that name below) and the eventual tag-name allocation
/// cost proportional to document size on every such `<`, reopening the
/// same O(n^2) shape `MAX_TAG_SCAN` exists to prevent for the same
/// pattern in `parse_open_tag`.
const MAX_TAG_NAME_SCAN: usize = 128;

/// Handles an oversized *ordinary* opening tag at `input[pos..]` — one
/// [`oversized_raw_text_tag_name`] doesn't recognize, e.g.
/// `<div data-state="...more than 4 KiB...">` — once
/// [`parse_open_tag`]'s own [`MAX_TAG_SCAN`]-bounded search has already
/// failed to find its `>`. Without this, `parse`'s final fallback (a lone
/// `<` treated as literal text, re-parsed one byte at a time) rendered the
/// entire oversized attribute list as visible text instead of it being
/// invisible the way any other tag's attributes already are.
///
/// Mirrors the normal (non-oversized) tag-push logic in [`parse`]
/// (`close_implied_tags`, then push an empty element for a self-closing
/// or [void](is_void_element) tag, or a real frame otherwise) — just
/// locating the real `>` the same unbounded quote-aware way
/// [`oversized_tag_body_start`] does for the seven tags that function
/// covers — see its docs for why unbounded is both simpler and more
/// correct than a bounded-window fallback. Returns `None` if `input[pos..]`
/// doesn't even look like the start of a tag name — no leading
/// ASCII-alphabetic character, or no tag-name boundary within
/// [`MAX_TAG_NAME_SCAN`] — so the caller can fall through to its plain
/// literal-text handling for a genuinely bogus `<`.
///
/// A trailing XHTML-style `/` (self-closing syntax) is not treated as
/// meaningful here, matching the normal (non-oversized) tag-push path —
/// see its docs for why: real browsers ignore that flag on every ordinary
/// (non-void, non-foreign) HTML element, so honoring it here would push an
/// empty element and let whatever follows become a sibling instead of the
/// element's real content.
fn push_oversized_generic_tag(
    stack: &mut Vec<(String, Vec<Node>, usize)>,
    input: &str,
    pos: usize,
    len: usize,
) -> Option<usize> {
    let rest = &input[pos + 1..];
    let first = rest.chars().next()?;
    if !first.is_ascii_alphabetic() {
        return None;
    }
    let name_end = bounded_prefix(rest, MAX_TAG_NAME_SCAN)
        .find(|c: char| c == '>' || c == '/' || c.is_whitespace())?;
    let tag = rest[..name_end].to_ascii_lowercase();
    let after_name = pos + 1 + name_end;
    let gt = find_tag_end(&input[after_name..]);
    let body_start = gt.map_or(len, |i| after_name + i + 1);

    close_implied_tags(stack, &tag);
    if is_void_element(&tag) {
        stack
            .last_mut()
            .expect("root frame is never popped")
            .1
            .push(Node::Element {
                tag,
                children: Vec::new(),
            });
    } else {
        let structural_idx = nearest_structural_idx(stack, &tag);
        stack.push((tag, Vec::new(), structural_idx));
    }
    Some(body_start)
}

/// Scan `input[pos..]` for the literal, case-insensitive closing tag for a
/// [raw text element](is_raw_text_element) (e.g. `</script>`, allowing
/// whitespace before the `>`), returning the text before it and the
/// position just past the closing tag. If no closing tag is found, all of
/// `input[pos..]` is returned as text with the position at end of input —
/// matching this parser's usual "auto-close at EOF" tolerance for
/// unterminated tags.
fn consume_raw_text<'a>(input: &'a str, pos: usize, tag: &str) -> (&'a str, usize) {
    let rest = &input[pos..];
    for (i, _) in rest.match_indices('<') {
        let Some(after_slash) = rest[i + 1..].strip_prefix('/') else {
            continue;
        };
        if after_slash.len() < tag.len()
            || !after_slash.as_bytes()[..tag.len()].eq_ignore_ascii_case(tag.as_bytes())
        {
            continue;
        }
        let after_tag = &after_slash[tag.len()..];
        // Must be immediately followed by whitespace, '/', or '>' — not
        // e.g. "</scripty>" merely starting with "script". '/' covers the
        // browser-tolerated XHTML-style self-closing spelling of a closing
        // tag, `</script/>` — without it here, that slash fails this
        // boundary check, the candidate is skipped, and the scan runs to
        // EOF instead, dropping all following visible content.
        let is_boundary = after_tag
            .chars()
            .next()
            .is_none_or(|c| c == '>' || c == '/' || c.is_whitespace());
        if !is_boundary {
            continue;
        }
        let Some(gt) = raw_close_tag_end(after_tag) else {
            continue;
        };
        let consumed = i + 2 + tag.len() + gt + 1;
        return (&rest[..i], pos + consumed);
    }
    (rest, input.len())
}

/// Locates the `>` that ends a raw-text closing tag candidate, given
/// `after_tag` (the text right after the tag name, e.g. right after
/// `</script`). A forward scan that tracks HTML5 attribute-value tokenizer
/// state via [`consume_attr_value`] (see its docs for why this needs to be
/// real forward state, not a backward-looking heuristic), stopping at the
/// first `>` or unquoted `<` — an unquoted `<` ends the scan with `None`
/// (this candidate never closes; some other tag starts here instead), a
/// genuinely quoted value's contents are skipped over regardless of what
/// they contain, and `>` outside any value is the real answer.
///
/// Unbounded by any fixed byte count — unlike a fixed window, this can
/// never reject a genuine closing tag just because its attribute list (or
/// a run of whitespace before `>`, legal per HTML5's end-tag grammar) is
/// long (mirroring why [`oversized_tag_body_start`] moved away from a
/// fixed window) — while still never reintroducing the O(n^2) risk a naive
/// fully-unbounded *quote-blind* search would have here specifically:
/// [`consume_raw_text`]'s outer `match_indices('<')` loop calls this once
/// per candidate and, on failure, moves on to try the *next* one rather
/// than stopping (unlike [`handle_closing_tag`], where a failed unbounded
/// search jumps straight to EOF and ends the whole parse) — so a raw-text
/// body containing many `"</script "` fragments with no `>` anywhere would
/// make a search that ignored `<` entirely pay a full remaining-input scan
/// on every single failed candidate, genuinely O(n^2). Stopping at the
/// first *unquoted* `<` instead makes each candidate's own share of the
/// work bounded by the gap up to that `<` (or, if a genuinely quoted value
/// is open, by the gap to its matching close quote) — every such gap is
/// disjoint from every other candidate's, so the total across every
/// candidate in one [`consume_raw_text`] call stays O(`rest.len()`), not
/// O(candidates × `rest.len()`).
///
/// Quote-awareness matters for two distinct reasons here. First, the same
/// one [`parse_open_tag`]'s own `>` search has: real browsers' end-tag
/// tokenizer *does* enter a quote-aware, attribute-value-like state once
/// whitespace follows the tag name (by which point [`consume_raw_text`]'s
/// `is_boundary` check has already confirmed we're here), so e.g.
/// `</script data-x=">secret">` must skip the quoted `>` and end at the
/// real one after it, not treat the quoted `>` as the close and leak
/// `secret">` as visible text. Second, and specific to this function: a
/// quoted attribute value can itself legally contain a literal `<` (e.g.
/// `</script data-x="<">Visible`) — without quote-awareness on the `<`
/// stop condition too, that embedded `<` would be mistaken for the start
/// of a new tag, ending the scan with `None` even though a real `>`
/// follows right after the value closes, and hiding `Visible` (or, for
/// `title`/`textarea`, being unable to recognize the close at all) as a
/// result.
fn raw_close_tag_end(after_tag: &str) -> Option<usize> {
    let bytes = after_tag.as_bytes();
    let mut i = 0;
    let mut in_unquoted_value = false;
    loop {
        let rel = bytes
            .get(i..)?
            .iter()
            .position(|&b| b == b'>' || b == b'<' || b == b'=' || b.is_ascii_whitespace())?;
        let idx = i + rel;
        match bytes[idx] {
            b'>' => return Some(idx),
            b'<' if !in_unquoted_value => return None,
            b'=' if !in_unquoted_value => {
                let (next_i, quoted) = consume_attr_value(bytes, idx)?;
                in_unquoted_value = !quoted;
                i = next_i;
            }
            b if b.is_ascii_whitespace() => {
                in_unquoted_value = false;
                i = idx + 1;
            }
            _ => {
                // '<' or '=' encountered while already inside an unquoted
                // value — literal content of that value (HTML5 tolerates
                // both inside an unquoted value the same way it tolerates
                // them inside a quoted one), not a fresh stop condition.
                i = idx + 1;
            }
        }
    }
}

/// Bound on how many frames back [`parse`]'s closing-tag matcher scans
/// looking for the nearest open tag with a given name. Well-formed HTML
/// (even deeply nested) closes tags in the order they were opened, so a
/// match is almost always found in the last frame or two; scanning the
/// *entire* stack on every close only matters for malformed input that
/// closes an ancestor while many descendants are still open. Without a
/// bound, a long run of opens followed by a long run of non-matching closes
/// (e.g. `"<a>".repeat(n) + "</x>".repeat(n)`, where "x" never appears on
/// the stack so no close ever pops it) costs O(n) per close for O(n^2)
/// overall — the same shape of bug already fixed for `decode_entities` and
/// `parse_open_tag`. A close whose matching opener is further back than
/// this is treated the same as a close with no matching opener at all:
/// ignored, rather than auto-closing a deep run of intervening tags.
const MAX_CLOSE_SCAN: usize = 512;

/// Handles a `</tag>` closing sequence at `input[pos..]` (the caller has
/// already confirmed it starts with `"</"`): matches it against the
/// nearest open frame with that name within [`MAX_CLOSE_SCAN`] frames of
/// the stack, closing everything down to and including it. A stray/
/// mismatched close tag with no matching opener nearby is ignored rather
/// than corrupting the tree — this also covers an empty tag name (`</>`),
/// which must never match: the implicit root frame is *also* keyed by an
/// empty string (it has no tag), and popping it would leave `stack`
/// empty. Returns the new `pos`, past the closing tag. Extracted out of
/// [`parse`] purely to keep that function's line count down — this has no
/// state of its own beyond `stack`.
///
/// The tag name is the text up to the first real tag-name boundary
/// (whitespace, `/`, or `>`) — *not* everything up to the first `>`, and
/// the `>` search itself is the same quote-aware [`find_tag_end`] an
/// opening tag's own `>` uses, bounded to [`MAX_TAG_SCAN`] to stay cheap
/// on every single `</`, not just oversized ones. Real browsers *do*
/// enter a quote-aware, attribute-value-like state for a closing tag once
/// whitespace follows the tag name (see [`raw_close_tag_end`]'s docs for
/// the same rule applied to a raw-text closing tag) — without both fixes,
/// a browser-tolerated attribute on an ordinary end tag broke this two
/// ways: `</div data-x="...">` took the whole `div data-x="` blob as the
/// "name" (never matching the real open `div`), and if that attribute
/// value itself contained a literal `>` (e.g. `</div data-x=">secret">`),
/// the naive un-quote-aware search stopped there and leaked
/// `secret">` — plus everything after — as visible text instead of the
/// genuine content following the real closing `>`.
fn handle_closing_tag(
    stack: &mut Vec<(String, Vec<Node>, usize)>,
    input: &str,
    pos: usize,
) -> usize {
    let rest = &input[pos + 2..];
    let name_end = rest
        .find(|c: char| c == '>' || c == '/' || c.is_whitespace())
        .unwrap_or(rest.len());
    let name = rest[..name_end].to_ascii_lowercase();
    // Two tiers: the cheap `MAX_TAG_SCAN`-bounded search handles every
    // realistic closing tag (attributes are already rare there, let alone
    // oversized ones) in O(1), so it's tried first on every single `</` in
    // the document — essential here (unlike the *unconditionally* unbounded
    // search `oversized_tag_body_start`/`push_oversized_generic_tag` use),
    // since without this cheap gate, many consecutive ordinary closing tags
    // (say `</div></span></div>...`, no attributes at all) would each pay
    // an unbounded `find_tag_end` call's initial quote-presence scan
    // reaching toward whatever quote character comes next in the document —
    // redone by every one of them, the same O(closing tags × distance)
    // shape the bound elsewhere in this module exists to prevent. Once a
    // closing tag's own attribute list *itself* exceeds `MAX_TAG_SCAN`
    // (already an unusual, `MAX_TAG_SCAN`-bytes-costly-to-trigger case),
    // the second attempt is unbounded — safe there for the same reason
    // `oversized_tag_body_start` doesn't need a window either. `rest.len()`
    // (EOF) is the correct fallback if even that never finds a real `>`,
    // since an unbounded search means "not found" genuinely means "nowhere
    // in the rest of the document," not "gave up early."
    let tag_end = find_tag_end(bounded_prefix(rest, MAX_TAG_SCAN)).or_else(|| find_tag_end(rest));
    let new_pos = pos + 2 + tag_end.map_or(rest.len(), |i| i + 1);

    let matching_depth = if name.is_empty() {
        None
    } else {
        let window_start = stack.len().saturating_sub(MAX_CLOSE_SCAN);
        stack[window_start..]
            .iter()
            .rposition(|(tag, _, _)| *tag == name)
            .map(|i| window_start + i)
    };
    if let Some(depth) = matching_depth {
        while stack.len() > depth {
            let (tag, children, _) = stack.pop().expect("depth <= stack.len()");
            stack
                .last_mut()
                .expect("root frame is never popped")
                .1
                .push(Node::Element { tag, children });
        }
    }
    new_pos
}

/// Locates the position right after an HTML comment's closing delimiter,
/// given `pos` pointing at its opening `<!--`. Real browsers accept two
/// different closing delimiters — the standard `-->`, and the
/// parse-error-tolerant `--!>` (WHATWG's "comment end bang state", reached
/// after `--` is followed by `!` instead of `>`) — and close the comment
/// at whichever one is written, not just the standard one. Without
/// recognizing the second form, `<!-- hidden --!><p>Visible</p>` searched
/// only for a literal `-->` that never appears, so the comment (and the
/// bounded `find` failing) fell back to swallowing the *entire rest of
/// the document* as one unterminated comment — discarding `<p>Visible</p>`
/// along with it, not just the comment's own content. Falls back to `len`
/// (EOF) if *neither* delimiter is ever found, matching this parser's
/// usual auto-close-at-EOF tolerance.
fn comment_end(input: &str, pos: usize, len: usize) -> usize {
    let rest = &input[pos..];
    [
        rest.find("-->").map(|i| i + 3),
        rest.find("--!>").map(|i| i + 4),
    ]
    .into_iter()
    .flatten()
    .min()
    .map_or(len, |i| pos + i)
}

/// Parse `input` into a forest of top-level [`Node`]s.
pub(super) fn parse(input: &str) -> Vec<Node> {
    let bytes = input.as_bytes();
    let len = bytes.len();
    let mut pos = 0usize;

    // Stack of (tag_name, children-so-far, nearest_structural_idx — see
    // that function's docs). The implicit root is index 0 with an empty tag
    // name; it is never popped, and its own nearest_structural_idx is
    // itself (0), same as any other structural frame.
    let mut stack: Vec<(String, Vec<Node>, usize)> = vec![(String::new(), Vec::new(), 0)];

    while pos < len {
        if bytes[pos] == b'<' {
            if input[pos..].starts_with("<!--") {
                pos = comment_end(input, pos, len);
                continue;
            }
            if input[pos..].starts_with("<!") || input[pos..].starts_with("<?") {
                // Doctype / processing-instruction-like: skip to the next '>'.
                let end = input[pos..].find('>').map_or(len, |i| pos + i + 1);
                pos = end;
                continue;
            }
            if input[pos..].starts_with("</") {
                pos = handle_closing_tag(&mut stack, input, pos);
                continue;
            }
            if let Some((tag, tag_end)) = parse_open_tag(&input[pos..]) {
                pos += tag_end;
                close_implied_tags(&mut stack, &tag);

                if is_raw_text_element(&tag) {
                    // `<script>`/`<style>`/`<title>`/`<textarea>` content is
                    // never tokenized as markup — see `consume_raw_text` —
                    // so a `<` that merely *looks* like the start of a tag
                    // (a JS comparison, a CSS selector combinator, a `<`
                    // typed into a textarea's default value) can't swallow
                    // the real closing tag. Entities are still decoded
                    // (matching the normal text-node path below) — a no-op
                    // for the two tags whose content is discarded anyway,
                    // and required for `title`/`textarea`'s real content.
                    let (text, new_pos) = consume_raw_text(input, pos, &tag);
                    pos = new_pos;
                    let mut children = Vec::new();
                    if !text.is_empty() {
                        children.push(Node::Text(decode_entities(text)));
                    }
                    stack
                        .last_mut()
                        .expect("root frame is never popped")
                        .1
                        .push(Node::Element { tag, children });
                    continue;
                }
                // A trailing XHTML-style `/` (self-closing syntax) is not
                // treated as meaningful here — real browsers ignore that
                // flag entirely on every ordinary (non-void, non-foreign)
                // HTML element: `<li/>One<li/>Two` still opens two real,
                // non-empty `<li>` elements, not two empty ones with "One"/
                // "Two" as stray siblings. Only whether the tag is
                // genuinely void decides whether it gets real children.
                if is_void_element(&tag) {
                    stack
                        .last_mut()
                        .expect("root frame is never popped")
                        .1
                        .push(Node::Element {
                            tag,
                            children: Vec::new(),
                        });
                } else {
                    let structural_idx = nearest_structural_idx(&stack, &tag);
                    stack.push((tag, Vec::new(), structural_idx));
                }
                continue;
            }
            // `parse_open_tag` found no `>` within `MAX_TAG_SCAN` — normally
            // that just means an unterminated/malformed tag, handled below
            // by falling back to literal text one byte at a time. But for
            // the seven tags `oversized_raw_text_tag_name` recognizes
            // (their own `>` pushed past the bound by e.g. an oversized
            // attribute), that fallback would leak their content into the
            // visible document — see that function's docs for the full
            // three-way (four, counting `textarea`) split in how each
            // group is handled below. First skip past the oversized tag's
            // *own* attribute list (its content must never be scanned
            // before its real `>` is found — see `oversized_tag_body_start`),
            // then: for `script`/`style`/`title`, discard straight through
            // to the closing tag (or EOF) via the same raw-text scan
            // already used for a normally-parsed instance, without
            // emitting any node for the unparseable opening tag itself;
            // for `textarea`, the same scan but keeping (not discarding)
            // the real content, see `push_oversized_raw_text_content_tag`;
            // for `head`/`noscript`/`template` — whose content is real,
            // generally-parsed markup rather than raw text, see
            // `push_oversized_nested_tag` — push a real frame and fall
            // through to normal parsing instead.
            if let Some(name) = oversized_raw_text_tag_name(&input[pos..]) {
                let after_name = pos + 1 + name.len();
                let body_start = oversized_tag_body_start(input, after_name, len);
                pos = if name == "textarea" {
                    push_oversized_raw_text_content_tag(&mut stack, input, body_start, name)
                } else if is_raw_text_element(name) {
                    consume_raw_text(input, body_start, name).1
                } else {
                    push_oversized_nested_tag(&mut stack, name, body_start)
                };
                continue;
            }
            // Not one of those seven, but still a well-formed (if
            // oversized) *ordinary* tag — `<div data-state="...4KiB+...">`
            // — gets the same fallback treatment, just pushed as a normal
            // element instead of the seven's special handling. See
            // `push_oversized_generic_tag`'s docs.
            if let Some(new_pos) = push_oversized_generic_tag(&mut stack, input, pos, len) {
                pos = new_pos;
                continue;
            }
            // A lone '<' that isn't a recognizable tag: treat as literal text.
            push_text(&mut stack, "<");
            pos += 1;
            continue;
        }

        let next_lt = input[pos..].find('<').map_or(len, |i| pos + i);
        let raw = &input[pos..next_lt];
        if !raw.is_empty() {
            push_text(&mut stack, &decode_entities(raw));
        }
        pos = next_lt;
    }

    // Auto-close any still-open tags at end of input.
    while stack.len() > 1 {
        let (tag, children, _) = stack.pop().expect("stack.len() > 1");
        stack
            .last_mut()
            .expect("root frame is never popped")
            .1
            .push(Node::Element { tag, children });
    }

    stack.pop().expect("root frame always present").1
}

/// Tags [`close_implied_tags`] must never search *past* when deciding
/// whether `new_tag`'s opening should reach down and close something
/// further below on `stack`. Two different reasons a tag ends up in this
/// set:
///
/// - It's itself a possible `open_tag` in [`implicitly_closes`] (`p`,
///   `head`, `li`, `dt`, `dd`, `tr`, `td`, `th`, `thead`, `tbody`,
///   `tfoot`) — the search has to be *able* to stop exactly there for a
///   match to ever fire.
/// - It's a genuine HTML5 scope boundary (`ul`, `ol`, `table`, and `html`/
///   `body` at the root) that must block the search even when it doesn't
///   match anything itself — `<ul><li>Parent<ul><li>Child` must leave the
///   inner `<li>` inside the inner `<ul>` rather than reaching past it to
///   close the outer `<li>` two levels up.
///
/// Everything else — including tags `layout.rs` gives real block-level
/// *rendering* treatment to, like `div`/headings/`section`/`blockquote`
/// (see `is_block_boundary_in_inline_context`) — is transparent here, the
/// same as a plain inline-formatting tag (`span`, `mark`, ...): a
/// still-open `<li>`/`<td>`/`<p>` beneath one of these doesn't stop being
/// reachable just because an ordinary block wrapper sits in between, the
/// same way real HTML5 closes enclosing elements along with whatever
/// they're inside when an ancestor implicitly closes. Rendering-level
/// block-boundary-ness and parsing-level scope-boundary-ness are different
/// questions with different answers for these tags — conflating them was
/// itself a bug (see below).
///
/// Regression: this used to be `closes_open_paragraph(tag) || ...`, reusing
/// that function's tag list as a shortcut on the (incorrect) assumption
/// that "closes an open `<p>`" and "is a scope boundary" were the same
/// set. They're not — `div` (among others in that list) closes an open
/// `<p>` but was never meant to block this search. Reported repro:
/// `<ul><li><div>One<li>Two</ul>` — the ordinary (not oversized) `<div>`
/// wrapper (wrongly treated as structural via the old
/// `closes_open_paragraph` shortcut) blocked `close_implied_tags` from
/// ever reaching the enclosing `<li>`, so the second `<li>` nested inside
/// the first instead of closing it and `extract_list_items` emitted only
/// one marker. An earlier fix already made purely inline-formatting tags
/// (`mark`, `time`, `cite`, ...) transparent here — this extends the same
/// treatment to `div`-shaped block tags that, like those, have no implied-
/// close semantics of their own and aren't a real HTML5 scope boundary.
fn is_structural_tag(tag: &str) -> bool {
    is_valid_in_head(tag)
        || matches!(
            tag,
            "thead"
                | "tbody"
                | "tfoot"
                | "tr"
                | "td"
                | "th"
                | "html"
                | "body"
                | "p"
                | "li"
                | "dt"
                | "dd"
                | "ul"
                | "ol"
                | "table"
        )
}

/// The index (within `stack`, *before* the new frame for `tag` is pushed)
/// that [`close_implied_tags`] should treat as this new frame's search
/// target once it becomes the top of the stack: `stack.len()` (i.e. its own
/// eventual index) if `tag` is a [structural tag](is_structural_tag) —
/// searching should stop here — otherwise whatever the *current* top
/// already resolves to, inherited unchanged since a transparent frame
/// doesn't change what's searchable beneath it.
///
/// Computed once here and cached in the pushed frame's third tuple field
/// for the rest of its life (see [`close_implied_tags`]) rather than
/// re-walked on every call: `is_structural_tag` inverting a short
/// "known-transparent" allowlist into its much longer closed complement
/// made a per-call *search* back through a run of transparent frames
/// (even one bounded via `MAX_CLOSE_SCAN` to stay linear) measurably
/// costlier — a stack of nothing-but-`<a>` tags pays that walk, and the
/// larger tag-classification check inside it, on every single tag open.
/// Caching removes the walk (and its bound) entirely: each frame already
/// knows its own answer the instant it's pushed, so `close_implied_tags`
/// never inspects more than the current top frame plus its cached target.
fn nearest_structural_idx(stack: &[(String, Vec<Node>, usize)], tag: &str) -> usize {
    if is_structural_tag(tag) {
        stack.len()
    } else {
        stack.last().map_or(0, |frame| frame.2)
    }
}

/// Cascades [`implicitly_closes`] up `stack`: repeatedly finds the closest
/// still-open frame that opening `new_tag` implicitly closes and pops down
/// to it, so e.g. a new `<tr>` closes both an open `<td>` *and* the `<tr>`
/// above it (found and closed one loop iteration apart), not just the
/// immediate stack top. Extracted out of [`parse`] purely to keep that
/// function's line count down — this has no state of its own beyond
/// `stack`.
///
/// Looks *past* a run of non-[structural](is_structural_tag) frames at the
/// top, not just at the top itself: `<p><strong>Intro<table>` has `strong`
/// sitting directly above the still-open `p` when `<table>` opens, and
/// `strong` has no implied-close rule of its own against `table` —
/// checking only `stack.last()` would stop right there and never see the
/// `p` beneath, leaving the table nested inside it (and flattened through
/// `inline_spans`, which has no notion of a table, instead of becoming a
/// real `Block::Table`). Only non-structural frames are skipped this way —
/// a genuine block container (`ul`, `table`, ...) sitting at the top always
/// stops the search at that frame, matching or not, so e.g.
/// `<ul><li>Parent<ul><li>Child` correctly leaves the inner `<li>` opening
/// *inside* the inner `<ul>` rather than reaching past it to close the
/// outer `<li>` two levels up.
///
/// This "look past" costs nothing at call time: the top frame's third
/// tuple field is the [`nearest_structural_idx`] computed (and cached) when
/// that frame was pushed, so finding the target is one index lookup rather
/// than a walk back through `stack` — see that function's docs for why a
/// per-call search (even one bounded to stay linear) stopped being cheap
/// enough once [`is_structural_tag`] grew from a short "known-transparent"
/// allowlist's negation into its own much larger closed set.
fn close_implied_tags(stack: &mut Vec<(String, Vec<Node>, usize)>, new_tag: &str) {
    loop {
        if stack.len() <= 1 {
            break;
        }
        let target = stack[stack.len() - 1].2;
        if !implicitly_closes(&stack[target].0, new_tag) {
            break;
        }
        while stack.len() > target {
            let (closed_tag, children, _) = stack.pop().expect("stack.len() > target >= 1");
            stack
                .last_mut()
                .expect("root frame is never popped")
                .1
                .push(Node::Element {
                    tag: closed_tag,
                    children,
                });
        }
    }
}

fn push_text(stack: &mut Vec<(String, Vec<Node>, usize)>, text: &str) {
    if text.is_empty() {
        return;
    }
    // Non-whitespace text is just as invalid inside `<head>` as an
    // unexpected tag — same implied close as `implicitly_closes`'s `head`
    // case, just triggered by content instead of a new element (e.g.
    // `<head><title>X</title>Visible</head>`, or the same with no closing
    // tag at all). Whitespace-only text (formatting indentation between
    // tags) doesn't count — it's not real content.
    if stack.last().is_some_and(|(tag, _, _)| tag == "head")
        && text.contains(|c: char| !c.is_whitespace())
    {
        let (closed_tag, children, _) = stack.pop().expect("just checked stack.last() above");
        stack
            .last_mut()
            .expect("root frame is never popped")
            .1
            .push(Node::Element {
                tag: closed_tag,
                children,
            });
    }
    let top = stack.last_mut().expect("root frame is never popped");
    if let Some(Node::Text(prev)) = top.1.last_mut() {
        prev.push_str(text);
    } else {
        top.1.push(Node::Text(text.to_owned()));
    }
}

/// Parse an opening tag starting at `s[0] == '<'`.
///
/// Returns `(tag_name, total_consumed_len)`, or `None` if `s` doesn't start
/// with a well-formed tag (e.g. `< foo>` with a space, or an unterminated
/// `<foo`). A trailing XHTML-style `/` is consumed as part of the tag but
/// not reported separately — see [`parse`]'s call site for why it isn't
/// meaningful to this parser.
///
/// Attributes are ignored entirely (no CSS support), but a literal `>`
/// inside a quoted attribute value is still handled correctly — see
/// [`find_tag_end`] — rather than being mistaken for the tag's own closing
/// delimiter.
/// Bound on how far [`parse_open_tag`] scans looking for the closing `>`. A
/// long run of unterminated `<tag` fragments with no `>` anywhere
/// (adversarial or just malformed input, e.g. `"<a".repeat(n)`) would
/// otherwise make that scan cover the *entire remainder* of the document —
/// and because a failed parse doesn't consume any input (the caller falls
/// back to treating just the `<` as literal text and retries at the very
/// next byte), that unbounded cost gets paid again at every subsequent `<` —
/// O(n^2) overall, the same shape of bug `decode_entities` was fixed for.
/// This bound must stay in place (an earlier draft that dropped it entirely
/// reintroduced the O(n^2) scan cost) but doesn't need to be tight: the
/// tag-name allocation below only happens *after* `>` is found, so a large
/// window costs nothing extra on the (bounded-scan, no-allocation) failure
/// path — only real matches pay for the window size, and a real match is
/// one allocation per tag in the document, not per byte scanned. Sized
/// generously (4 KiB) so a long but genuine attribute list — a Tailwind
/// utility-class soup, several `data-*`/`aria-*` attributes — still parses
/// instead of being rejected and leaked into the PDF as literal text.
const MAX_TAG_SCAN: usize = 4096;

/// Take a byte-length-bounded, UTF-8-safe prefix of `s` — cheap regardless
/// of `max_len`'s size (no per-char iterator overhead, unlike walking
/// `s.char_indices()` up to the bound), nudged back to the nearest char
/// boundary so the result can't split a multi-byte character. Used to cap
/// a scan for a delimiter (`>`) at a fixed cost instead of the full
/// remaining input length — see [`MAX_TAG_SCAN`] for why that bound
/// matters.
fn bounded_prefix(s: &str, max_len: usize) -> &str {
    let mut end = s.len().min(max_len);
    while end > 0 && !s.is_char_boundary(end) {
        end -= 1;
    }
    &s[..end]
}

/// Given `bytes[eq_idx] == b'='`, consumes what follows per HTML5's "before
/// attribute value state": skips whitespace (permitted between `=` and the
/// value — `title = "x"` is valid, not just `title="x"`), then either
/// enters a quoted value if the next byte is `"`/`'` — skipping to its
/// matching close quote, wherever it is, regardless of what it contains —
/// or starts an unquoted value otherwise (any other byte, including EOF/`>`
/// immediately, i.e. an empty value). Returns the byte offset to resume
/// scanning from, and whether the value was quoted.
///
/// Determining "is this quote/`=` genuine" by looking only at what
/// *immediately precedes* it (a byte or two of backward lookback) cannot
/// work in general — a stray `=` or quote character inside an already-
/// active *unquoted* value (`title=x=">`, a parse-error condition real
/// browsers still tolerate as literal content) is byte-for-byte
/// indistinguishable, looking only backward from that point, from the
/// closing quote of a *properly completed* adjacent quoted attribute with
/// no separating whitespace (`a="x"b="y"` — also valid, unremarkable HTML,
/// since ending a quoted value returns the tokenizer straight to
/// "before attribute name state" with no whitespace required). Both are a
/// quote character immediately followed by more name-like bytes then `=`.
/// The only way to tell them apart is genuine forward state: did the
/// scanner reach this point by properly finishing a quoted value (real
/// name=value boundary, safe to start fresh), or by drifting through an
/// unquoted value that never terminated (not safe — everything in it,
/// `=`/quotes included, is just literal content)? [`find_tag_end`] and
/// [`raw_close_tag_end`] each track this themselves as `in_unquoted_value`
/// state while scanning forward, and only call this function when that
/// state is `false` — i.e. only at a position forward-scanning has already
/// established is a genuine value start.
///
/// Returns `None` only if a quoted value's matching close quote is never
/// found (the whole tag never closes — matches this parser's usual
/// auto-close-at-EOF tolerance for a genuinely unterminated quoted
/// attribute); an unquoted value can't fail to "close" here since the
/// caller keeps scanning through its content until real termination
/// (whitespace or `>`/`<`) on its own.
fn consume_attr_value(bytes: &[u8], eq_idx: usize) -> Option<(usize, bool)> {
    let mut j = eq_idx + 1;
    while j < bytes.len() && bytes[j].is_ascii_whitespace() {
        j += 1;
    }
    match bytes.get(j) {
        Some(&quote @ (b'"' | b'\'')) => {
            let after_quote = j + 1;
            let close_off = bytes.get(after_quote..)?.iter().position(|&b| b == quote)?;
            Some((after_quote + close_off + 1, true))
        }
        _ => Some((j, false)),
    }
}

/// Find the byte offset of the `>` that ends an opening tag within `window`,
/// skipping any `>` that appears inside a *genuinely quoted or unquoted*
/// attribute value (e.g. `<div title="Balance > 100">` or, per HTML5's
/// parse-error-tolerant unquoted-value handling, `<div title=x=">`) —
/// otherwise such a `>` is mistaken for the tag's real closing delimiter,
/// truncating the tag early and leaking the rest of the attribute value
/// into the document as literal text.
///
/// Two tiers. First, the fast, heavily-optimized [`str::find`] path: locate
/// the naive first `>`, and if no quote character appears anywhere before
/// it, that naive `>` genuinely is the answer — an unquoted value can't
/// hide a `>` from a naive scan the way a quoted one can, since (per
/// HTML5's own tokenizer) an unquoted value terminates *at* the first `>`
/// it meets, exactly where the naive scan would already stop. This covers
/// the overwhelmingly common case (no attributes, or attributes with no
/// quote character at all) in O(distance to the real `>`, or to EOF if
/// there is none) — essential since `window` is sometimes
/// [`MAX_TAG_SCAN`]-bounded (cheap either way) and sometimes unbounded,
/// straight from
/// [`oversized_tag_body_start`]/[`push_oversized_generic_tag`] on the
/// entire remaining document, where a slower per-byte scan would cost far
/// more (confirmed by timing: this tier alone, not the second, is what
/// keeps `"<a".repeat(100_000)` linear — a manual byte-by-byte scan here
/// instead measured over 3s for that input, an easy trap since it's still
/// O(n) asymptotically, just with a much larger constant).
///
/// Second, only once a quote is confirmed present before the naive `>`: a
/// forward scan tracking real HTML5 attribute-value tokenizer state via
/// [`consume_attr_value`] — see its docs for why this needs to be genuine
/// forward state, not a backward-looking heuristic, to correctly
/// distinguish a quote that's a genuine value opener from one that isn't.
/// Bounded the same way the pre-check above already established a quote
/// exists before some point — this tier existing at all is gated on that,
/// so it doesn't reopen the "prove a quote style is absent over a huge
/// span" O(n^2) shape a previous version of this pre-check had (confirmed
/// by timing: 16,000 single-quoted 5 KiB tags took over two minutes before
/// *that* fix) — the gate here checks presence within a bounded prefix
/// (`window[..naive_gt]`), not the whole window.
fn find_tag_end(window: &str) -> Option<usize> {
    let naive_gt = window.find('>')?;
    if !window[..naive_gt].contains('"') && !window[..naive_gt].contains('\'') {
        return Some(naive_gt);
    }
    let bytes = window.as_bytes();
    let mut i = 0;
    let mut in_unquoted_value = false;
    loop {
        let rel = bytes
            .get(i..)?
            .iter()
            .position(|&b| b == b'>' || b == b'=' || b.is_ascii_whitespace())?;
        let idx = i + rel;
        match bytes[idx] {
            b'>' => return Some(idx),
            b'=' if !in_unquoted_value => {
                let (next_i, quoted) = consume_attr_value(bytes, idx)?;
                in_unquoted_value = !quoted;
                i = next_i;
            }
            b if b.is_ascii_whitespace() => {
                in_unquoted_value = false;
                i = idx + 1;
            }
            _ => {
                // '=' encountered while already inside an unquoted value —
                // literal content of that value, not a fresh value-starter.
                i = idx + 1;
            }
        }
    }
}

fn parse_open_tag(s: &str) -> Option<(String, usize)> {
    debug_assert!(s.starts_with('<'));
    let rest = &s[1..];
    let first = rest.chars().next()?;
    if !first.is_ascii_alphabetic() {
        return None;
    }

    let window = bounded_prefix(rest, MAX_TAG_SCAN);

    // Find `>` first and bail before doing any allocation if it's not in the
    // window — the common failure case (a malformed/unterminated `<`) then
    // costs only the bounded scan above, never a string allocation.
    let gt = find_tag_end(window)?;

    let name_end = window[..gt]
        .find(|c: char| c.is_whitespace() || c == '/')
        .unwrap_or(gt);
    let tag = window[..name_end].to_ascii_lowercase();
    Some((tag, 1 + gt + 1))
}

/// Decode the small set of entities likely to appear in developer-authored
/// (Maud-escaped) HTML: the five predefined XML entities, `&nbsp;` and a
/// handful of common typographic entities, and numeric character references.
/// Anything unrecognized is passed through unchanged (including the leading
/// `&`) rather than dropped, so malformed input never loses data.
fn decode_entities(raw: &str) -> String {
    if !raw.contains('&') {
        return raw.to_owned();
    }
    let mut out = String::with_capacity(raw.len());
    let mut chars = raw.char_indices();
    while let Some((i, ch)) = chars.next() {
        if ch != '&' {
            out.push(ch);
            continue;
        }
        let rest = &raw[i..];
        // Bound the semicolon search to a small *character* window before
        // scanning, not after: searching the unbounded remainder for a `;`
        // and only checking the offset afterward means a long run of `&`
        // with no nearby `;` rescans the whole rest of `raw` for every `&`
        // — O(n^2) on adversarial input (e.g. thousands of bare `&`
        // characters). All supported entity names are ASCII and at most 6
        // characters, so an 11-character window (`&` + up to 10 name chars)
        // is ample headroom while keeping each `&` O(1) to resolve.
        let window_end = rest
            .char_indices()
            .nth(11)
            .map_or(rest.len(), |(off, _)| off);
        let Some(semi) = rest[..window_end].find(';') else {
            out.push('&');
            continue;
        };
        let entity = &rest[1..semi];
        let decoded = decode_one_entity(entity);
        match decoded {
            Some(c) => {
                out.push(c);
                // Advance the outer iterator past the consumed entity body.
                for _ in 0..semi {
                    chars.next();
                }
            }
            None => out.push('&'),
        }
    }
    out
}

fn decode_one_entity(entity: &str) -> Option<char> {
    match entity {
        "amp" => Some('&'),
        "lt" => Some('<'),
        "gt" => Some('>'),
        "quot" => Some('"'),
        "apos" => Some('\''),
        "nbsp" => Some('\u{00A0}'),
        "mdash" => Some(''),
        "ndash" => Some(''),
        "hellip" => Some(''),
        "copy" => Some('©'),
        _ => {
            let dec = entity.strip_prefix('#')?;
            let value = if let Some(hex) = dec.strip_prefix('x').or_else(|| dec.strip_prefix('X')) {
                u32::from_str_radix(hex, 16).ok()?
            } else {
                dec.parse::<u32>().ok()?
            };
            char::from_u32(value)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn text(children: &[Node]) -> String {
        children
            .iter()
            .map(|n| match n {
                Node::Text(t) => t.clone(),
                Node::Element { children, .. } => text(children),
            })
            .collect()
    }

    #[test]
    fn plain_text_round_trips() {
        let nodes = parse("hello world");
        assert_eq!(nodes, vec![Node::Text("hello world".to_owned())]);
    }

    #[test]
    fn nested_elements_build_a_tree() {
        let nodes = parse("<p>Hello <strong>bold</strong> world</p>");
        assert_eq!(nodes.len(), 1);
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(tag, "p");
        assert_eq!(text(children), "Hello bold world");
        assert!(matches!(&children[1], Node::Element { tag, .. } if tag == "strong"));
    }

    #[test]
    fn an_omitted_li_closing_tag_is_implied_by_the_next_li() {
        // Regression: `<li>` is an HTML5 "optional end tag" element — real
        // or hand-written HTML commonly omits `</li>` before the next
        // `<li>` starts. Without implied-close handling, the second `<li>`
        // nested *inside* the first instead of becoming its sibling.
        let nodes = parse("<ul><li>One<li>Two</ul>");
        assert_eq!(nodes.len(), 1);
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(tag, "ul");
        assert_eq!(
            children.len(),
            2,
            "expected two sibling <li>s, got {children:?}"
        );
        for (child, expected_text) in children.iter().zip(["One", "Two"]) {
            let Node::Element { tag, children } = child else {
                panic!("expected an element")
            };
            assert_eq!(tag, "li");
            assert_eq!(text(children), expected_text);
        }
    }

    #[test]
    fn an_omitted_li_closing_tag_is_implied_through_a_transparent_inline_wrapper() {
        // Regression: `close_implied_tags`'s look-through only recognized
        // `strong`/`b`/`em`/`i` as skippable phrasing wrappers — `span`/`a`
        // are just as transparent to `inline_spans` (its generic
        // passthrough case treats them identically), but weren't in the
        // skip set, so `<ul><li><span>One<li>Two</ul>` left the second
        // `<li>` nested under the first (stopping the search at `span`)
        // instead of closing it.
        let nodes = parse("<ul><li><span>One<li>Two</ul>");
        assert_eq!(nodes.len(), 1);
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(tag, "ul");
        assert_eq!(
            children.len(),
            2,
            "expected two sibling <li>s, got {children:?}"
        );
        let Node::Element {
            tag,
            children: first_li,
        } = &children[0]
        else {
            panic!("expected an element")
        };
        assert_eq!(tag, "li");
        assert_eq!(first_li.len(), 1, "expected one <span> child");
        assert!(matches!(&first_li[0], Node::Element { tag, .. } if tag == "span"));
        assert_eq!(text(first_li), "One");
        let Node::Element {
            tag,
            children: second_li,
        } = &children[1]
        else {
            panic!("expected an element")
        };
        assert_eq!(tag, "li");
        assert_eq!(text(second_li), "Two");
    }

    #[test]
    fn an_omitted_li_closing_tag_is_implied_through_the_remaining_transparent_wrappers() {
        // Regression: after the `span`/`a` fix, other standard phrasing
        // wrappers `inline_spans` also treats transparently (`small`,
        // `code`, `abbr`, `label`, ...) still stopped the implied-close
        // search — same bug, different tag.
        for wrapper in ["small", "code", "abbr", "label"] {
            let html = format!("<ul><li><{wrapper}>One<li>Two</ul>");
            let nodes = parse(&html);
            assert_eq!(nodes.len(), 1, "wrapper {wrapper:?}");
            let Node::Element { tag, children } = &nodes[0] else {
                panic!("expected an element ({wrapper:?})")
            };
            assert_eq!(tag, "ul");
            assert_eq!(
                children.len(),
                2,
                "expected two sibling <li>s for wrapper {wrapper:?}, got {children:?}"
            );
            let Node::Element { tag, .. } = &children[1] else {
                panic!("expected an element ({wrapper:?})")
            };
            assert_eq!(tag, "li", "wrapper {wrapper:?}");
        }
    }

    #[test]
    fn an_omitted_li_closing_tag_is_implied_through_any_unrecognized_wrapper_tag() {
        // Regression: `is_phrasing_wrapper` used to be its own allowlist of
        // "known transparent tags", one step behind `inline_spans`'s
        // actually-exhaustive "anything unrecognized is transparent" rule —
        // `mark`/`time`/`cite` (and any other tag `layout.rs` doesn't
        // specially recognize) are just as transparent as `span`, but
        // weren't in the allowlist, so `<ul><li><mark>One<li>Two</ul>` left
        // the second `<li>` nested under the first instead of closing it.
        // Now that the check is inverted against the closed set of tags the
        // renderer *does* special-case, an arbitrary never-listed tag
        // (`made-up-tag`) is covered too, not just these three.
        for wrapper in ["mark", "time", "cite", "made-up-tag"] {
            let html = format!("<ul><li><{wrapper}>One<li>Two</ul>");
            let nodes = parse(&html);
            assert_eq!(nodes.len(), 1, "wrapper {wrapper:?}");
            let Node::Element { tag, children } = &nodes[0] else {
                panic!("expected an element ({wrapper:?})")
            };
            assert_eq!(tag, "ul");
            assert_eq!(
                children.len(),
                2,
                "expected two sibling <li>s for wrapper {wrapper:?}, got {children:?}"
            );
            let Node::Element { tag, .. } = &children[1] else {
                panic!("expected an element ({wrapper:?})")
            };
            assert_eq!(tag, "li", "wrapper {wrapper:?}");
        }
    }

    #[test]
    fn an_omitted_li_closing_tag_is_implied_through_an_ordinary_block_wrapper() {
        // Regression: `is_structural_tag` used to reuse `closes_open_paragraph`'s
        // tag list wholesale, wrongly treating `div` (and other ordinary
        // block tags with no implied-close semantics of their own, like
        // headings/`section`/`blockquote`) as a scope *barrier* the same
        // way genuine ones (`ul`/`table`) are — so `<ul><li><div>One<li>Two</ul>`
        // left the second `<li>` nested inside the `<div>` inside the
        // first `<li>` instead of closing it, and `extract_list_items`
        // emitted only one marker. `div` gets real block-level rendering
        // treatment from `layout.rs` (see `is_block_boundary_in_inline_context`),
        // but that's an orthogonal concern from parsing-level scope —
        // a still-open `<li>` beneath it must stay reachable, the same as
        // through a purely inline wrapper like `<mark>`.
        for wrapper in ["div", "h2", "section", "blockquote", "header"] {
            let html = format!("<ul><li><{wrapper}>One<li>Two</ul>");
            let nodes = parse(&html);
            assert_eq!(nodes.len(), 1, "wrapper {wrapper:?}");
            let Node::Element { tag, children } = &nodes[0] else {
                panic!("expected an element ({wrapper:?})")
            };
            assert_eq!(tag, "ul");
            assert_eq!(
                children.len(),
                2,
                "expected two sibling <li>s for wrapper {wrapper:?}, got {children:?}"
            );
            let Node::Element {
                tag,
                children: first_li,
            } = &children[0]
            else {
                panic!("expected an element ({wrapper:?})")
            };
            assert_eq!(tag, "li", "wrapper {wrapper:?}");
            assert_eq!(
                first_li.len(),
                1,
                "expected one wrapper child ({wrapper:?})"
            );
            assert!(
                matches!(&first_li[0], Node::Element { tag, .. } if tag == wrapper),
                "wrapper {wrapper:?}: expected the first <li> to still contain its wrapper, \
                 got {first_li:?}"
            );
            assert_eq!(text(first_li), "One", "wrapper {wrapper:?}");
            let Node::Element { tag, children } = &children[1] else {
                panic!("expected an element ({wrapper:?})")
            };
            assert_eq!(tag, "li", "wrapper {wrapper:?}");
            assert_eq!(text(children), "Two", "wrapper {wrapper:?}");
        }
    }

    #[test]
    fn nested_list_scope_barrier_survives_the_ordinary_block_wrapper_fix() {
        // Guard against overcorrecting the fix above: `ul`/`ol`/`table`
        // must remain genuine scope barriers — a `<div>` no longer
        // blocking `close_implied_tags`' search must not somehow let it
        // reach *past* a nested `<ul>` too. `<ul><li>Parent<div><ul><li>Child</ul></div></li></ul>`
        // must still leave "Child" as its own nested list, not merge it
        // into the outer list's second item.
        let nodes = parse("<ul><li>Parent<div><ul><li>Child</ul></div><li>Sibling</ul>");
        assert_eq!(nodes.len(), 1);
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(tag, "ul");
        assert_eq!(
            children.len(),
            2,
            "expected two sibling top-level <li>s, got {children:?}"
        );
        let Node::Element {
            tag,
            children: first_li,
        } = &children[0]
        else {
            panic!("expected an element")
        };
        assert_eq!(tag, "li");
        assert_eq!(first_li.len(), 2, "expected text then <div>: {first_li:?}");
        assert!(matches!(&first_li[0], Node::Text(t) if t == "Parent"));
        let Node::Element {
            tag,
            children: div_children,
        } = &first_li[1]
        else {
            panic!("expected the <div>")
        };
        assert_eq!(tag, "div");
        assert_eq!(div_children.len(), 1, "expected the nested <ul>");
        let Node::Element {
            tag,
            children: inner_ul,
        } = &div_children[0]
        else {
            panic!("expected the nested <ul>")
        };
        assert_eq!(tag, "ul");
        assert_eq!(inner_ul.len(), 1, "expected the inner <li>");
        assert_eq!(text(inner_ul), "Child");
        let Node::Element {
            tag,
            children: second_li,
        } = &children[1]
        else {
            panic!("expected an element")
        };
        assert_eq!(tag, "li");
        assert_eq!(text(second_li), "Sibling");
    }

    #[test]
    fn an_omitted_td_closing_tag_is_implied_by_the_next_td_or_tr() {
        // Same bug, table-cell/row variant: `<td>`/`<th>`/`<tr>` are also
        // optional-end-tag elements, and a new `<tr>` must close both an
        // open `<td>` *and* the `<tr>` above it, not just the cell.
        let nodes = parse("<table><tr><td>A<td>B<tr><td>C</table>");
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(tag, "table");
        assert_eq!(
            children.len(),
            2,
            "expected two sibling <tr>s, got {children:?}"
        );
        let Node::Element {
            tag,
            children: row1,
        } = &children[0]
        else {
            panic!("expected an element")
        };
        assert_eq!(tag, "tr");
        assert_eq!(row1.len(), 2, "expected two sibling <td>s, got {row1:?}");
        assert_eq!(text(std::slice::from_ref(&row1[0])), "A");
        assert_eq!(text(std::slice::from_ref(&row1[1])), "B");
        let Node::Element {
            tag,
            children: row2,
        } = &children[1]
        else {
            panic!("expected an element")
        };
        assert_eq!(tag, "tr");
        assert_eq!(row2.len(), 1);
        assert_eq!(text(row2), "C");
    }

    #[test]
    fn omitted_th_tr_and_thead_closing_tags_are_implied_by_a_following_tbody() {
        // Regression: valid HTML can omit `</th>`, `</tr>`, *and* `</thead>`
        // together before a following `<tbody>` — none of `td`/`th`'s,
        // `tr`'s, or `thead`'s implied-close rules covered a new table
        // section starting, only a new cell/row within the *same* section,
        // so the whole `<tbody>` (and its row/cell) nested inside the
        // still-open header cell instead of becoming `<thead>`'s sibling.
        let nodes = parse("<table><thead><tr><th>H<tbody><tr><td>A</table>");
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(tag, "table");
        assert_eq!(
            children.len(),
            2,
            "expected <thead> and <tbody> as siblings, got {children:?}"
        );
        let Node::Element {
            tag,
            children: thead_children,
        } = &children[0]
        else {
            panic!("expected an element")
        };
        assert_eq!(tag, "thead");
        assert_eq!(thead_children.len(), 1, "expected one <tr>");
        assert_eq!(text(thead_children), "H");
        let Node::Element {
            tag,
            children: tbody_children,
        } = &children[1]
        else {
            panic!("expected an element")
        };
        assert_eq!(tag, "tbody");
        assert_eq!(tbody_children.len(), 1, "expected one <tr>");
        assert_eq!(
            text(tbody_children),
            "A",
            "the body row must be a sibling row, not text flattened into the header cell"
        );
    }

    #[test]
    fn an_omitted_p_closing_tag_is_implied_by_a_following_block_element() {
        // Regression: `<p>` is also an HTML5 "optional end tag" element —
        // real/hand-written HTML commonly omits `</p>` before the next
        // block element starts. Without implied-close handling, a
        // following `<table>` nested *inside* the still-open `<p>` instead
        // of becoming its sibling, and since `<p>`'s content goes through
        // `inline_spans` (which has no notion of a table), the table's
        // rows/cells flattened into bare inline text.
        let nodes = parse("<p>Intro<table><tr><td>A</td><td>B</td></tr></table><p>After");
        assert_eq!(
            nodes.len(),
            3,
            "expected <p>, <table>, <p> as three siblings, got {nodes:?}"
        );
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(tag, "p");
        assert_eq!(text(children), "Intro");
        assert!(matches!(&nodes[1], Node::Element { tag, .. } if tag == "table"));
        let Node::Element { tag, children } = &nodes[2] else {
            panic!("expected an element")
        };
        assert_eq!(tag, "p");
        assert_eq!(text(children), "After");
    }

    #[test]
    fn an_omitted_p_closing_tag_is_implied_through_intervening_inline_formatting() {
        // Regression: `close_implied_tags` used to check only the stack's
        // *top* frame. `<p><strong>Intro<table>...` has `strong` sitting
        // directly above the still-open `<p>` when `<table>` opens, and
        // `strong` has no implied-close rule of its own against `table` —
        // checking only the top stopped right there and never saw the `<p>`
        // beneath it, so the table stayed nested inside the still-open `<p>`
        // (and `<strong>`) instead of becoming a sibling.
        let nodes = parse("<p><strong>Intro<table><tr><td>A</td><td>B</td></tr></table><p>After");
        assert_eq!(
            nodes.len(),
            3,
            "expected <p>, <table>, <p> as three siblings, got {nodes:?}"
        );
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(tag, "p");
        assert_eq!(
            children.len(),
            1,
            "expected a single <strong> child, got {children:?}"
        );
        assert!(matches!(&children[0], Node::Element { tag, .. } if tag == "strong"));
        assert_eq!(text(children), "Intro");
        assert!(matches!(&nodes[1], Node::Element { tag, .. } if tag == "table"));
        let Node::Element { tag, children } = &nodes[2] else {
            panic!("expected an element")
        };
        assert_eq!(tag, "p");
        assert_eq!(text(children), "After");
    }

    #[test]
    fn an_omitted_head_closing_tag_is_implied_by_body() {
        // Regression: `</head>` is also an HTML5 "optional end tag" —
        // omitting it is common/valid (`<html><head><title>X</title><body>...`).
        // Without implied-close handling, `<body>` nested *inside* the
        // still-open `<head>` — and since `head` is in `is_non_rendered`
        // (`layout.rs`), its entire subtree is discarded, silently dropping
        // the whole visible document.
        let nodes = parse("<html><head><title>X</title><body><p>Visible</p></body></html>");
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(tag, "html");
        assert_eq!(
            children.len(),
            2,
            "expected <head> and <body> as siblings, got {children:?}"
        );
        assert!(matches!(&children[0], Node::Element { tag, .. } if tag == "head"));
        let Node::Element {
            tag,
            children: body_children,
        } = &children[1]
        else {
            panic!("expected an element")
        };
        assert_eq!(tag, "body");
        assert_eq!(text(body_children), "Visible");
    }

    #[test]
    fn an_omitted_body_start_tag_also_implies_a_head_close() {
        // Regression: HTML5 permits omitting the `<body>` *start* tag
        // itself, not just `</head>` — `<head><title>X</title><p>...`
        // (or even bare text with no wrapper tag at all) is equally valid.
        // The previous fix only matched an explicit `("head", "body")`
        // transition, so `<p>` opening while `<head>` was still open didn't
        // close it — `<p>` (and its "Visible" text) nested inside `<head>`
        // and, since `head` is in `is_non_rendered` (`layout.rs`), vanished
        // along with the rest of the document.
        let nodes = parse("<html><head><title>X</title><p>Visible</p></html>");
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(tag, "html");
        assert_eq!(
            children.len(),
            2,
            "expected <head> and <p> as siblings, got {children:?}"
        );
        assert!(matches!(&children[0], Node::Element { tag, .. } if tag == "head"));
        let Node::Element {
            tag,
            children: p_children,
        } = &children[1]
        else {
            panic!("expected an element")
        };
        assert_eq!(tag, "p");
        assert_eq!(text(p_children), "Visible");
    }

    #[test]
    fn non_whitespace_text_also_implies_a_head_close() {
        // Regression: bare text with no wrapper tag at all is just as valid
        // body content as `<p>...` — `<head><title>X</title>Visible` (a
        // still-open `<head>`, no `<body>`/`<p>` element at all) used to
        // leave "Visible" nested inside (and, being non-rendered, hidden
        // by) `<head>` since `implicitly_closes` only fires on a new tag,
        // never on a text node.
        let nodes = parse("<html><head><title>X</title>Visible</html>");
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(tag, "html");
        assert_eq!(
            children.len(),
            2,
            "expected <head> and the bare text as siblings, got {children:?}"
        );
        assert!(matches!(&children[0], Node::Element { tag, .. } if tag == "head"));
        assert_eq!(children[1], Node::Text("Visible".to_owned()));
    }

    #[test]
    fn whitespace_only_text_does_not_close_an_open_head() {
        // Formatting whitespace (indentation/newlines between tags) between
        // `<head>`'s children must not trigger the same-as-text implied
        // close — only genuine non-whitespace content should.
        let nodes =
            parse("<html><head>\n  <title>X</title>\n</head><body><p>Visible</p></body></html>");
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(tag, "html");
        let Node::Element {
            tag: head_tag,
            children: head_children,
        } = &children[0]
        else {
            panic!("expected an element")
        };
        assert_eq!(head_tag, "head");
        assert!(
            head_children
                .iter()
                .any(|n| matches!(n, Node::Element { tag, .. } if tag == "title")),
            "the <title> must still be a child of <head>, not hoisted out by whitespace"
        );
    }

    #[test]
    fn script_content_with_a_stray_angle_bracket_does_not_swallow_later_siblings() {
        // Regression: `<script>`/`<style>` content used to be tokenized as
        // ordinary markup, so a `<` that merely *looks* like the start of a
        // tag (e.g. a JS comparison `a<b`) got parsed as a bogus opening
        // tag — which then consumed the *real* `</script>` as part of its
        // own (malformed) closing, leaving `script` unclosed and nesting
        // everything that followed (here, the `<p>`) inside it instead of
        // as a sibling.
        let nodes = parse("<script>if(a<b){}</script><p>Visible</p>");
        assert_eq!(
            nodes.len(),
            2,
            "the <p> must be a sibling of <script>, not swallowed into it"
        );
        assert!(matches!(&nodes[0], Node::Element { tag, .. } if tag == "script"));
        let Node::Element { tag, children } = &nodes[1] else {
            panic!("expected the second top-level node to be an element")
        };
        assert_eq!(tag, "p");
        assert_eq!(text(children), "Visible");
    }

    #[test]
    fn oversized_script_tag_does_not_leak_its_source_as_visible_text() {
        // Regression: `parse_open_tag`'s search for a tag's own `>` is
        // bounded by `MAX_TAG_SCAN` (4 KiB) — a `<script>` carrying an
        // attribute longer than that pushes its own `>` past the bound, so
        // `parse_open_tag` failed and the parser fell back to treating the
        // lone `<` as literal text, re-tokenizing everything after it
        // (the oversized attribute value *and* the real JS source) as
        // ordinary markup/text instead of staying non-rendered.
        let oversized_attr = "Q".repeat(5000);
        let html = format!(
            r#"<script data-x="{oversized_attr}">var secret = "should never render";</script><p>Visible</p>"#
        );
        let nodes = parse(&html);
        assert_eq!(
            nodes.len(),
            1,
            "the oversized <script> must not leak any node into the tree, got {nodes:?}"
        );
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(tag, "p");
        let rendered = text(children);
        assert_eq!(rendered, "Visible");
        assert!(
            !rendered.contains("secret") && !rendered.contains('Q'),
            "the script's source and its oversized attribute value must never appear as \
             visible text, got {rendered:?}"
        );
    }

    #[test]
    fn oversized_tag_with_a_closing_tag_look_alike_in_its_own_attribute_is_not_fooled() {
        // Regression: the oversized-tag fallback's raw-text scan started
        // right after the tag *name* — still inside the still-unparsed,
        // still-open attribute list — rather than after the tag's own
        // real `>`. A quoted attribute value containing a `</script`-
        // looking substring *before* that real `>` (e.g.
        // `<script data-x="</script>` followed by oversized attribute
        // data and then `">Secret</script>`) got mistaken for the actual
        // closing tag, after which the rest of the attribute value and
        // "Secret" were reparsed as ordinary visible markup/text.
        let oversized_attr = format!("</script>{}", "Q".repeat(5000));
        let html = format!(r#"<script data-x="{oversized_attr}">Secret</script><p>Visible</p>"#);
        let nodes = parse(&html);
        let rendered = text(&nodes);
        assert!(
            !rendered.contains("Secret") && !rendered.contains('Q'),
            "the script's source and its oversized attribute value (including the embedded \
             </script>-looking substring) must never appear as visible text, got {rendered:?}"
        );
        assert!(
            rendered.contains("Visible"),
            "the following sibling <p> must still render normally, got {rendered:?}"
        );
    }

    #[test]
    fn oversized_title_tag_does_not_leak_into_the_visible_document() {
        // Regression: `oversized_raw_text_tag_name` only recognized
        // `<script`/`<style`, not `<title` — so a `<title>` carrying an
        // oversized attribute fell through to the plain literal-text
        // fallback the same way an oversized `<script>` used to. Worse
        // than script/style: inside a still-open `<head>`, that fallback's
        // non-whitespace text also implicitly closes the head (see
        // `push_text`), so both the attribute value *and* the title text
        // leaked into the visible document despite `title` being
        // `is_non_rendered`.
        let oversized_attr = "Q".repeat(5000);
        let html = format!(
            r#"<head><title data-x="{oversized_attr}">Secret</title></head><p>Visible</p>"#
        );
        let nodes = parse(&html);
        let rendered = text(&nodes);
        assert!(
            !rendered.contains("Secret") && !rendered.contains('Q'),
            "the title's text and its oversized attribute value must never appear as visible \
             text, got {rendered:?}"
        );
        assert!(
            rendered.contains("Visible"),
            "the following sibling <p> must still render normally, got {rendered:?}"
        );
    }

    #[test]
    fn oversized_noscript_and_template_tags_do_not_leak_into_the_visible_document() {
        // Regression: `oversized_raw_text_tag_name` recognized `<script`/
        // `<style`/`<title` but missed `<noscript`/`<template` (and
        // `<head`, covered separately below since it needs different
        // handling) — all six are `is_non_rendered` in `layout.rs`, but
        // only the first three got the oversized-tag suppression fix. A
        // repro matching the reported one: an oversized `<template>`
        // leaked both its attribute value and "Secret" into the visible
        // document. Both get a real stack frame and normal parsing (see
        // `push_oversized_nested_tag`), so what matters at this (parser)
        // layer is that "Secret" and the oversized attribute value stay
        // nested *inside* the element's own subtree — never escaping as a
        // top-level sibling — since `layout.rs`'s `is_non_rendered` (tested
        // separately) is what actually hides a whole subtree at render
        // time; `text()` here has no such filter, so it isn't the right
        // tool to assert final visibility for a tag with real children.
        for tag in ["noscript", "template"] {
            let oversized_attr = "Q".repeat(5000);
            let html = format!(r#"<{tag} data-x="{oversized_attr}">Secret</{tag}><p>Visible</p>"#);
            let nodes = parse(&html);
            assert_eq!(
                nodes.len(),
                2,
                "tag {tag:?}: expected exactly the oversized element and its sibling <p> at the \
                 top level (nothing escaped as an extra sibling), got {nodes:?}"
            );
            let Node::Element { tag: first_tag, .. } = &nodes[0] else {
                panic!("tag {tag:?}: expected the first top-level node to be an element")
            };
            assert_eq!(first_tag, tag);
            let Node::Element {
                tag: second_tag,
                children,
            } = &nodes[1]
            else {
                panic!("tag {tag:?}: expected the second top-level node to be an element")
            };
            assert_eq!(second_tag, "p");
            assert_eq!(
                text(children),
                "Visible",
                "tag {tag:?}: the following sibling <p> must still render normally"
            );
        }
    }

    #[test]
    fn oversized_head_tag_does_not_swallow_the_rest_of_the_document_at_an_implicit_close() {
        // Regression: an earlier fix added `head` to the same
        // raw-text-scan-to-literal-closing-tag suppression as the other
        // five non-rendered tags — but `<head>`'s closing tag is legally
        // optional (HTML5, and this parser's own `implicitly_closes`,
        // close it as soon as non-head content begins), so a document
        // that omits `</head>` entirely — an oversized `<head data-x="...">`
        // directly followed by `<body>Visible</body>`, with no `</head>`
        // anywhere — used to have that raw-text scan search for a literal
        // `</head>` all the way to EOF, discarding the *entire rest of the
        // document* including the visible body.
        let oversized_attr = "Q".repeat(5000);
        let html = format!(r#"<head data-x="{oversized_attr}"><body>Visible</body>"#);
        let nodes = parse(&html);
        let rendered = text(&nodes);
        assert!(
            !rendered.contains('Q'),
            "the oversized attribute value must never appear as visible text, got {rendered:?}"
        );
        assert!(
            rendered.contains("Visible"),
            "the <body> content must still render even though </head> was never written, got \
             {rendered:?}"
        );
    }

    #[test]
    fn oversized_template_nested_inside_itself_hides_everything_up_to_the_outer_close() {
        // Regression: the oversized-tag fallback used to route `<template>`
        // through the same raw-text scan as `script`/`style`/`title`
        // (`consume_raw_text`), which stops at the *first* literal closing
        // tag and — for these three genuinely raw-text tags — never
        // pushes a node for the (unparseable) opening tag at all. For a
        // nested `<template><template>inner</template>leak</template>`
        // body, that first closing tag is the *inner* one, so the old code
        // discarded everything up through it, emitted no `template`
        // element whatsoever, and resumed *normal top-level* parsing
        // right on "leak" — turning it into a bare top-level text node
        // (rendered) instead of staying nested inside the outer
        // template's subtree (hidden by `layout.rs`'s `is_non_rendered`)
        // the way real HTML5 nesting requires.
        let oversized_attr = "Q".repeat(5000);
        let html = format!(
            r#"<template data-x="{oversized_attr}"><template>inner</template>leak</template><p>Visible</p>"#
        );
        let nodes = parse(&html);
        assert_eq!(
            nodes.len(),
            2,
            "expected exactly the outer template element and its sibling <p> at the top level \
             (nothing escaped as an extra sibling), got {nodes:?}"
        );
        let Node::Element { tag: first_tag, .. } = &nodes[0] else {
            panic!(
                "expected the first top-level node to be the outer template element, not a \
                 leaked text node, got {:?}",
                nodes[0]
            )
        };
        assert_eq!(first_tag, "template");
        let Node::Element {
            tag: second_tag,
            children,
        } = &nodes[1]
        else {
            panic!("expected the second top-level node to be an element")
        };
        assert_eq!(second_tag, "p");
        assert_eq!(
            text(children),
            "Visible",
            "the following sibling <p> must still render normally"
        );
    }

    #[test]
    fn oversized_textarea_tag_still_renders_its_content_as_raw_text() {
        // Regression: `textarea` was entirely missing from
        // `oversized_raw_text_tag_name`'s recognized set — an oversized
        // `<textarea data-x="...4KiB...">` (its own `>` pushed past
        // `MAX_TAG_SCAN`) fell all the way through to `parse`'s generic
        // "unrecognized tag" fallback, which (a) rendered the opening
        // tag's oversized attribute soup as literal visible text and (b)
        // tokenized the textarea's real content as ordinary markup
        // instead of raw text — a `<b>`-looking sequence inside it would
        // wrongly become a real `<b>` element instead of staying literal,
        // unlike a normal-sized `<textarea>`.
        let oversized_attr = "Q".repeat(5000);
        let html = format!(r#"<textarea data-x="{oversized_attr}">a<b</textarea><p>Visible</p>"#);
        let nodes = parse(&html);
        assert_eq!(
            nodes.len(),
            2,
            "expected the textarea element and its sibling <p>, got {nodes:?}"
        );
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected the first top-level node to be an element")
        };
        assert_eq!(tag, "textarea");
        assert_eq!(
            text(children),
            "a<b",
            "the oversized attribute must not leak, and the real content must survive as \
             literal raw text (not tokenized as markup), got {children:?}"
        );
        let Node::Element { tag, children } = &nodes[1] else {
            panic!("expected the second top-level node to be an element")
        };
        assert_eq!(tag, "p");
        assert_eq!(text(children), "Visible");
    }

    #[test]
    fn oversized_ordinary_tag_is_skipped_not_rendered_as_literal_text() {
        // Regression: `oversized_raw_text_tag_name` only recognizes seven
        // specific tags — an oversized *ordinary* tag like
        // `<div data-state="...4KiB+...">` (not one of the seven) fell
        // all the way through to `parse`'s final fallback, which treats a
        // lone `<` as literal text and re-parses everything after it one
        // byte at a time — rendering the entire oversized attribute list
        // as visible text, unlike every other tag's (invisible)
        // attributes.
        let oversized_attr = "Q".repeat(5000);
        let html = format!(r#"<div data-state="{oversized_attr}">Visible</div>"#);
        let nodes = parse(&html);
        assert_eq!(
            nodes.len(),
            1,
            "expected a single <div>, with the oversized attribute nowhere in sight, got \
             {nodes:?}"
        );
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(tag, "div");
        assert_eq!(
            text(children),
            "Visible",
            "the oversized attribute value must not leak into the div's own content"
        );
    }

    #[test]
    fn oversized_ordinary_tag_self_closing_and_void_variants_still_work() {
        // Covers the same behavior `push_oversized_generic_tag` has to
        // replicate from the normal (non-oversized) tag-push path: an
        // oversized void element must not push a real stack frame (it
        // would otherwise swallow later sibling content as its own
        // children instead of leaving it as a sibling), while an oversized
        // *non-void* self-closing tag (XHTML-style `/>`) must still open a
        // real, non-empty frame — real browsers ignore that flag entirely
        // on an ordinary HTML element, so `<my-widget ... />Visible`
        // nests "Visible" as `my-widget`'s own content, the same as a
        // normal-sized `<my-widget ...>Visible` (no closing tag) would.
        let oversized_attr = "Q".repeat(5000);
        let html = format!(r#"<my-widget data-x="{oversized_attr}" />Visible"#);
        let nodes = parse(&html);
        assert_eq!(
            nodes.len(),
            1,
            "expected a single <my-widget>, got {nodes:?}"
        );
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(tag, "my-widget");
        assert_eq!(text(children), "Visible");

        let html = format!(r#"<img data-x="{oversized_attr}">Visible"#);
        let nodes = parse(&html);
        assert_eq!(
            nodes.len(),
            2,
            "expected the void <img> and its sibling text, got {nodes:?}"
        );
        assert!(matches!(
            &nodes[0],
            Node::Element { tag, children } if tag == "img" && children.is_empty()
        ));
        assert_eq!(nodes[1], Node::Text("Visible".to_owned()));
    }

    #[test]
    fn oversized_ordinary_tag_with_a_quoted_bracket_look_alike_is_not_fooled() {
        // Same quote-awareness concern as the oversized six/seven special
        // tags: a quoted attribute value containing a literal `>` must
        // not be mistaken for the tag's own real closing `>`.
        let oversized_attr = format!("{}>", "Q".repeat(5000));
        let html = format!(r#"<div data-x="{oversized_attr}">Visible</div>"#);
        let nodes = parse(&html);
        assert_eq!(nodes.len(), 1, "got {nodes:?}");
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(tag, "div");
        assert_eq!(text(children), "Visible");
    }

    #[test]
    fn oversized_ordinary_closing_tag_beyond_the_fast_bound_still_finds_its_real_close() {
        // Regression: `handle_closing_tag`'s search for its own `>` was
        // bounded to `MAX_TAG_SCAN` (4 KiB) with no fallback — a closing
        // tag whose attribute list *itself* exceeded that (already an
        // unusual case, but a browser-tolerated one) fell straight to the
        // `rest.len()` EOF fallback, silently dropping the real
        // subsequent content ("Visible" here) instead of just this one
        // closing tag's own oversized attribute soup.
        let oversized_attr = "Q".repeat(5000);
        let html = format!(r#"<div>One</div data-x="{oversized_attr}">Visible"#);
        let nodes = parse(&html);
        assert_eq!(
            nodes.len(),
            2,
            "expected the closed <div> and the genuine trailing text, got {nodes:?}"
        );
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected the first node to be an element")
        };
        assert_eq!(tag, "div");
        assert_eq!(text(children), "One");
        assert_eq!(nodes[1], Node::Text("Visible".to_owned()));
    }

    #[test]
    fn oversized_ordinary_tag_far_beyond_any_fixed_bound_still_finds_its_real_close() {
        // Regression: an earlier fix bounded the oversized-attribute search
        // to a fixed window (`MAX_OVERSIZED_TAG_SCAN`, 256 KiB) and, once
        // beyond it, resumed parsing mid-attribute instead of at the real
        // `>` — for a base64 data URI attribute genuinely larger than that
        // window, e.g. `<img src="...over 256 KiB of base64...">Visible`,
        // this leaked the remaining attribute-value suffix (and the
        // literal `">`) as visible PDF text before "Visible", not just
        // silently dropping content but actively rendering garbage.
        // `push_oversized_generic_tag`'s search is unbounded now (safe
        // since `find_tag_end` itself is linear in distance-to-answer, not
        // window size — see its docs), so an attribute of *any* size still
        // resolves to its real `>` exactly, with nothing leaked.
        let oversized_attr = "Q".repeat(300 * 1024);
        let html = format!(r#"<img src="{oversized_attr}">Visible"#);
        let nodes = parse(&html);
        assert_eq!(
            nodes.len(),
            2,
            "expected the void <img> and its sibling text, with no leaked attribute-soup \
             siblings in between, got a tree with {} nodes",
            nodes.len()
        );
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected the first node to be an element")
        };
        assert_eq!(tag, "img");
        assert!(
            children.is_empty(),
            "img is a void element, got {children:?}"
        );
        assert_eq!(
            nodes[1],
            Node::Text("Visible".to_owned()),
            "the oversized attribute value must not leak into visible text"
        );
    }

    #[test]
    fn long_run_of_single_quoted_oversized_divs_is_linear_not_quadratic() {
        // Regression: `find_tag_end`'s old fast pre-check
        // (`!window.contains('"') && !window.contains('\'')`) was safe when
        // `window` was always `MAX_TAG_SCAN`-bounded, but `oversized_tag_body_start`/
        // `push_oversized_generic_tag` call it unbounded, on the entire
        // remaining document. For many single-quoted oversized tags in a
        // row with no `"` anywhere in the whole document,
        // `window.contains('"')` alone had to scan the *entire* remaining
        // document to confirm that absence, on every single tag — genuinely
        // O(n^2) (confirmed by timing before the fix: 16,000 such tags took
        // over two minutes, each doubling roughly quadrupling the time).
        // Removed that pre-check entirely in favor of `window.find('>')`,
        // which doesn't need to prove anything about the rest of the
        // window once it finds a match.
        let candidate = format!("<div data-x='{}'>", "A".repeat(5000));
        let html = candidate.repeat(2_000);
        let start = std::time::Instant::now();
        let nodes = parse(&html);
        assert_eq!(nodes.len(), 1, "expected one nested chain of <div>s");
        assert!(
            start.elapsed() < std::time::Duration::from_secs(5),
            "parse took {:?} — looks quadratic again",
            start.elapsed()
        );
    }

    #[test]
    fn style_content_with_a_stray_angle_bracket_does_not_swallow_later_siblings() {
        let nodes = parse("<style>/* a<b */</style><p>Visible</p>");
        assert_eq!(nodes.len(), 2);
        assert!(matches!(&nodes[0], Node::Element { tag, .. } if tag == "style"));
        let Node::Element { tag, children } = &nodes[1] else {
            panic!("expected the second top-level node to be an element")
        };
        assert_eq!(tag, "p");
        assert_eq!(text(children), "Visible");
    }

    #[test]
    fn title_content_with_a_stray_angle_bracket_does_not_swallow_later_siblings() {
        // Regression: `title` wasn't in `is_raw_text_element`'s tag list,
        // so `<title>a<b</title><p>Visible</p>` let the stray `<b` inside
        // the title get parsed as a bogus opening tag that consumed the
        // real `</title>` — same shape of bug as the `<script>`/`<style>`
        // cases above, just for the one other tag `is_non_rendered` in
        // `layout.rs` discards wholesale.
        let nodes = parse("<title>a<b</title><p>Visible</p>");
        assert_eq!(
            nodes.len(),
            2,
            "the <p> must be a sibling of <title>, not swallowed into it"
        );
        assert!(matches!(&nodes[0], Node::Element { tag, .. } if tag == "title"));
        let Node::Element { tag, children } = &nodes[1] else {
            panic!("expected the second top-level node to be an element")
        };
        assert_eq!(tag, "p");
        assert_eq!(text(children), "Visible");
    }

    #[test]
    fn textarea_content_with_a_stray_angle_bracket_is_not_dropped() {
        // Regression: same shape of bug as `title`/`script`/`style` above,
        // but for a tag whose content is real, rendered text (a form
        // default value) rather than something `is_non_rendered` discards —
        // so the failure mode is losing that text rather than hiding
        // unrelated siblings after it.
        let nodes = parse("<textarea>a<b</textarea><p>Visible</p>");
        assert_eq!(
            nodes.len(),
            2,
            "the <p> must be a sibling of <textarea>, not swallowed into it"
        );
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected the first top-level node to be an element")
        };
        assert_eq!(tag, "textarea");
        assert_eq!(
            text(children),
            "a<b",
            "the textarea's literal content must survive, not be dropped"
        );
        let Node::Element { tag, children } = &nodes[1] else {
            panic!("expected the second top-level node to be an element")
        };
        assert_eq!(tag, "p");
        assert_eq!(text(children), "Visible");
    }

    #[test]
    fn textarea_content_still_decodes_entities() {
        // `textarea`'s content is meant to render, unlike `title`/`script`/
        // `style`'s — so unlike those, it must still decode entities rather
        // than passing them through as literal `&amp;` text.
        let nodes = parse("<textarea>Ben &amp; Jerry</textarea>");
        assert_eq!(nodes.len(), 1);
        let Node::Element { children, .. } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(text(children), "Ben & Jerry");
    }

    #[test]
    fn void_elements_have_no_children_and_need_no_close() {
        let nodes = parse("a<br>b<hr/>c");
        assert_eq!(nodes.len(), 5);
        assert!(
            matches!(&nodes[1], Node::Element { tag, children } if tag == "br" && children.is_empty())
        );
        assert!(
            matches!(&nodes[3], Node::Element { tag, children } if tag == "hr" && children.is_empty())
        );
    }

    #[test]
    fn unclosed_tags_are_auto_closed_at_eof() {
        let nodes = parse("<div><p>oops");
        assert_eq!(nodes.len(), 1);
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected div")
        };
        assert_eq!(tag, "div");
        assert!(matches!(&children[0], Node::Element { tag, .. } if tag == "p"));
    }

    #[test]
    fn stray_closing_tag_is_ignored() {
        let nodes = parse("hello</p>world");
        assert_eq!(text(&nodes), "helloworld");
    }

    #[test]
    fn empty_closing_tag_does_not_panic() {
        // Regression: `</>` has an empty tag name, which used to collide
        // with the implicit root frame's own empty-string sentinel and pop
        // it, panicking on the next `stack.last_mut()`.
        assert_eq!(text(&parse("hello</>world")), "helloworld");
        assert_eq!(text(&parse("<div></></div>")), "");
        assert_eq!(text(&parse("</>")), "");
    }

    #[test]
    fn html_comments_are_never_rendered() {
        let nodes = parse("Before<!-- hidden -->After");
        assert_eq!(text(&nodes), "BeforeAfter");
    }

    #[test]
    fn abrupt_empty_comment_closes_immediately_not_at_the_next_real_close() {
        // Regression guard, not a fix: `<!-->` (the browser-tolerated
        // "abrupt empty comment" spelling) already closes correctly —
        // `comment_end`'s `rest.find("-->")` naturally matches by reusing
        // the opening `<!--`'s own trailing two dashes as the closing
        // delimiter's dashes (string search permits overlapping matches),
        // so the comment ends right there with empty content rather than
        // swallowing the rest of the document. Locking this in explicitly
        // since it wasn't covered by an existing test.
        let nodes = parse("<!--><p>Visible</p>");
        assert_eq!(nodes.len(), 1, "expected just the <p>, got {nodes:?}");
        assert!(matches!(&nodes[0], Node::Element { tag, .. } if tag == "p"));
        assert_eq!(text(&nodes), "Visible");
    }

    #[test]
    fn comment_closed_with_the_browser_tolerated_bang_terminator_does_not_swallow_the_rest_of_the_document()
     {
        // Regression: real browsers close an HTML comment at either the
        // standard `-->` or the parse-error-tolerant `--!>` (WHATWG's
        // "comment end bang state") — this parser only recognized the
        // former, so `<!-- hidden --!><p>Visible</p>` searched for a
        // literal `-->` that never appeared anywhere, falling back to
        // swallowing the *entire rest of the document* as one
        // unterminated comment, discarding `<p>Visible</p>` along with it.
        let nodes = parse("<!-- hidden --!><p>Visible</p>");
        assert_eq!(nodes.len(), 1, "got {nodes:?}");
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected the comment to be skipped and only the <p> to remain")
        };
        assert_eq!(tag, "p");
        assert_eq!(text(children), "Visible");
    }

    #[test]
    fn comment_closes_at_whichever_terminator_appears_first() {
        // Two comments in the same document, one closed each way — both
        // must close at their own (first-encountered) terminator, with the
        // literal text between them surviving as real visible content.
        let nodes = parse("<!-- a --!>b<!-- c -->d");
        assert_eq!(text(&nodes), "bd");
    }

    #[test]
    fn ordinary_closing_tag_with_a_browser_tolerated_attribute_still_matches_its_opener() {
        // Regression: `handle_closing_tag` took *everything* before the
        // first `>` as the tag name (only trimmed, never split at
        // whitespace) — so `</div data-x="...">` took the whole
        // `"div data-x=\""`-shaped blob as the "name", which never matches
        // the real open `div`, leaving it (and everything after) nested
        // inside the still-open element instead of closing it. Worse, if
        // that stray attribute value itself contained a literal `>` (real
        // browsers *do* enter a quote-aware state here, same as for a
        // raw-text closing tag), the naive un-quote-aware `>` search
        // stopped at the quoted one and leaked the rest of the attribute
        // value plus genuine subsequent content as visible text — the
        // reported repro: `<div>One</div data-x=">secret">Two`.
        let nodes = parse(r#"<div>One</div data-x=">secret">Two"#);
        assert_eq!(
            nodes.len(),
            2,
            "expected the closed <div> and the genuine trailing text as separate top-level \
             nodes, got {nodes:?}"
        );
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected the first node to be an element")
        };
        assert_eq!(tag, "div");
        assert_eq!(
            text(children),
            "One",
            "the div's own content must not include anything from its closing tag's \
             attribute soup"
        );
        assert_eq!(
            nodes[1],
            Node::Text("Two".to_owned()),
            "only the genuine trailing text may appear as a sibling — the quoted attribute \
             value must not leak, got {nodes:?}"
        );
    }

    #[test]
    fn entities_are_decoded() {
        let nodes = parse("Fish &amp; Chips &mdash; &pound;5 &#65;&#x42;");
        // `&pound;` is not in the supported set, so it (and its `&`) survives
        // literally rather than being dropped.
        assert_eq!(text(&nodes), "Fish & Chips — &pound;5 AB");
    }

    #[test]
    fn long_run_of_unterminated_ampersands_is_linear_not_quadratic() {
        // Regression: the semicolon search used to scan the *entire*
        // remainder of the string per `&` before checking how far away it
        // was, making a long run of unterminated `&` (no nearby `;`) O(n^2).
        // 200k chars comfortably reproduced multi-second blowups before the
        // fix; this should now complete near-instantly.
        let html = "&".repeat(200_000);
        let start = std::time::Instant::now();
        let nodes = parse(&html);
        assert_eq!(
            text(&nodes),
            html,
            "unterminated `&` passes through unchanged"
        );
        assert!(
            start.elapsed() < std::time::Duration::from_secs(2),
            "decode_entities took {:?} — looks quadratic again",
            start.elapsed()
        );
    }

    #[test]
    fn long_run_of_unterminated_open_tags_is_linear_not_quadratic() {
        // Regression: `parse_open_tag`'s search for the closing `>` (and the
        // tag-name search before it) scanned the entire remainder of the
        // string, and on failure returned `None` *without consuming any
        // input* — so the outer loop retried at the very next byte and paid
        // that same unbounded scan again, for every `<` in a long run of
        // unterminated fragments (e.g. `"<a".repeat(n)`, with no `>`
        // anywhere) — O(n^2) overall.
        let html = "<a".repeat(100_000);
        let start = std::time::Instant::now();
        let nodes = parse(&html);
        assert_eq!(
            text(&nodes),
            html,
            "unterminated `<a` fragments pass through unchanged as literal text"
        );
        assert!(
            start.elapsed() < std::time::Duration::from_secs(2),
            "parse_open_tag took {:?} — looks quadratic again",
            start.elapsed()
        );
    }

    #[test]
    fn long_run_of_unmatched_closing_tags_is_linear_not_quadratic() {
        // Regression: the closing-tag matcher scanned the *entire* open-tag
        // stack (`stack.iter().rposition(...)`) looking for a matching
        // opener, on every `</tag>` encountered. For a deep run of opens
        // followed by a long run of closes that never match anything on the
        // stack (e.g. `"<a>".repeat(n) + "</x>".repeat(n)` — "x" never
        // appears, so no close ever pops the stack), each of the n closes
        // pays the full O(n) scan for O(n^2) overall.
        let html = format!("{}{}", "<a>".repeat(50_000), "</x>".repeat(50_000));
        let start = std::time::Instant::now();
        let nodes = parse(&html);
        // None of the "</x>" closes match anything, so they're all ignored
        // — the tree is just 50,000 nested (empty) <a> elements.
        assert_eq!(nodes.len(), 1);
        assert!(
            start.elapsed() < std::time::Duration::from_secs(2),
            "parse took {:?} — looks quadratic again",
            start.elapsed()
        );
    }

    #[test]
    fn long_run_of_never_closing_wrapper_tags_is_linear_not_quadratic() {
        // A non-phrasing-wrapper tag (`span` isn't `strong`/`b`/`em`/`i`)
        // stops `close_implied_tags`'s walk on its very first frame, so a
        // long run of never-closing `<span>`s should cost O(1) per tag
        // regardless of stack depth — this is the cheap case; see
        // `long_run_of_never_closing_phrasing_wrappers_is_linear_not_quadratic`
        // just below for the bounded-but-not-free phrasing-wrapper case.
        let html = "<span>".repeat(100_000);
        let start = std::time::Instant::now();
        let nodes = parse(&html);
        assert_eq!(nodes.len(), 1, "expected one deeply nested <span> tree");
        assert!(
            start.elapsed() < std::time::Duration::from_secs(2),
            "parse took {:?} — looks quadratic again",
            start.elapsed()
        );
    }

    #[test]
    fn long_run_of_never_closing_phrasing_wrappers_is_linear_not_quadratic() {
        // Regression: `close_implied_tags` now walks *past* a run of open
        // phrasing-wrapper frames (`strong`/`b`/`em`/`i` — see
        // `an_omitted_p_closing_tag_is_implied_through_intervening_inline_formatting`)
        // looking for a frame beneath them that `new_tag`'s opening
        // implicitly closes. Unlike a non-wrapper tag (which stops the walk
        // immediately), a long run of *never-closing* wrappers — e.g. `n`
        // unclosed `<strong>`s, which never themselves match any
        // implied-close rule and so never stop the walk early — would walk
        // the full stack depth on every one of the n tag opens without this
        // bounded the same way `MAX_CLOSE_SCAN` bounds the closing-tag
        // matcher, for the same O(n^2) reason.
        let html = "<strong>".repeat(100_000);
        let start = std::time::Instant::now();
        let nodes = parse(&html);
        assert_eq!(nodes.len(), 1, "expected one deeply nested <strong> tree");
        assert!(
            start.elapsed() < std::time::Duration::from_secs(2),
            "parse took {:?} — looks quadratic again",
            start.elapsed()
        );
    }

    #[test]
    fn long_run_of_unterminated_raw_text_closes_is_linear_not_quadratic() {
        // Regression: `consume_raw_text`'s search for the closing `>` past
        // a candidate `</script`/`</style` prefix scanned the *entire*
        // remaining suffix on every failed candidate. A `<script>` body
        // containing many `"</script "` fragments with no `>` anywhere
        // (each one looks like it could be the real closing tag, right up
        // until the bound where a `>` should be) pays that full scan once
        // per candidate — O(n^2) overall, the same shape of bug
        // `MAX_TAG_SCAN`/`MAX_CLOSE_SCAN` were fixed for.
        let html = format!("<script>{}", "</script ".repeat(50_000));
        let start = std::time::Instant::now();
        let nodes = parse(&html);
        assert_eq!(nodes.len(), 1);
        assert!(matches!(&nodes[0], Node::Element { tag, .. } if tag == "script"));
        assert!(
            start.elapsed() < std::time::Duration::from_secs(2),
            "parse took {:?} — looks quadratic again",
            start.elapsed()
        );
    }

    #[test]
    fn raw_text_closing_tag_skips_a_quoted_bracket_look_alike() {
        // Regression: `raw_close_tag_end`'s bounded search used a plain
        // `.find('>')`, so a closing tag with a quoted attribute-like
        // value containing a literal `>` (real browsers *do* enter a
        // quote-aware, attribute-value-like state for a closing tag once
        // whitespace follows the tag name) was mistaken for the tag's own
        // end — `<script>hidden</script data-x=">secret">Visible` matched
        // the quoted `>` right after `data-x="` as the close, resuming
        // normal parsing at `secret">Visible` and leaking `secret">` as
        // visible text instead of staying inside the (still hidden)
        // script element up through the real closing `>`.
        let html = r#"<script>hidden</script data-x=">secret">Visible"#;
        let nodes = parse(html);
        assert_eq!(
            nodes.len(),
            2,
            "expected the script element and the trailing text, got {nodes:?}"
        );
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected the first node to be an element")
        };
        assert_eq!(tag, "script");
        assert_eq!(
            text(children),
            "hidden",
            "the script's own content must stay hidden inside it"
        );
        assert_eq!(
            nodes[1],
            Node::Text("Visible".to_owned()),
            "only the genuine trailing text must appear as a sibling — the quoted attribute \
             value must not leak, got {nodes:?}"
        );
    }

    #[test]
    fn raw_text_closing_tag_accepts_arbitrary_whitespace_before_the_bracket() {
        // Regression: `MAX_RAW_CLOSE_SCAN` (64 bytes) bounded the search
        // for `>` after a candidate `</script`/`</style` prefix — but
        // HTML5's end-tag grammar allows *arbitrary* whitespace between
        // the tag name and `>`. A real (if unusually padded) closing tag
        // with whitespace bytes before `>` past that bound used to fall
        // outside it entirely, so `consume_raw_text` never recognized it
        // and scanned through to EOF instead — hiding everything after it.
        let padding = " ".repeat(200);
        let html = format!("<script>alert(1)</script{padding}><p>Visible</p>");
        let nodes = parse(&html);
        assert_eq!(
            nodes.len(),
            2,
            "expected the script element and its sibling <p>, got {nodes:?}"
        );
        assert!(matches!(&nodes[0], Node::Element { tag, .. } if tag == "script"));
        let Node::Element { tag, children } = &nodes[1] else {
            panic!("expected the second top-level node to be an element")
        };
        assert_eq!(tag, "p");
        assert_eq!(text(children), "Visible");
    }

    #[test]
    fn raw_text_closing_tag_with_a_long_non_whitespace_attribute_is_still_recognized() {
        // Regression: `raw_close_tag_end`'s fixed 64-byte bound couldn't
        // see a real `>` sitting behind more than 64 bytes of non-
        // whitespace attribute content, and the whitespace-only fallback
        // rejects anything but pure whitespace before `>` — so a genuine
        // (if unusually padded) closing tag like
        // `</script data-x="...100+ bytes...">` was rejected outright,
        // hiding everything after it instead of just the attribute value.
        let value = "x".repeat(100);
        let html = format!(r#"<script>hidden</script data-x="{value}">Visible"#);
        let nodes = parse(&html);
        assert_eq!(
            nodes.len(),
            2,
            "expected script + trailing text, got {nodes:?}"
        );
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected the first node to be an element")
        };
        assert_eq!(tag, "script");
        assert_eq!(text(children), "hidden");
        assert_eq!(nodes[1], Node::Text("Visible".to_owned()));
    }

    #[test]
    fn raw_text_closing_tag_recognizes_xhtml_style_self_closing_slash() {
        // Regression: the raw-text closing-tag boundary check only
        // accepted whitespace or `>` right after the tag name, so the
        // browser-tolerated XHTML-style spelling `</script/>` (slash
        // immediately after the name) failed to match at all, and the
        // scan ran through to EOF — dropping all following visible
        // content.
        let html = "<script>hidden</script/><p>Visible</p>";
        let nodes = parse(html);
        assert_eq!(nodes.len(), 2, "expected script + <p>, got {nodes:?}");
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected the first node to be an element")
        };
        assert_eq!(tag, "script");
        assert_eq!(text(children), "hidden");
        let Node::Element { tag, children } = &nodes[1] else {
            panic!("expected the second node to be an element")
        };
        assert_eq!(tag, "p");
        assert_eq!(text(children), "Visible");
    }

    #[test]
    fn long_run_of_raw_text_closes_padded_with_whitespace_is_linear_not_quadratic() {
        // Regression guard for `raw_close_tag_end`'s arbitrary-whitespace
        // fallback: unlike the bounded search it falls back from, its
        // whitespace-skip is not itself length-bounded — verify a
        // candidate-heavy adversarial input (many `</script` fragments,
        // each followed by a long run of whitespace but never a `>`)
        // still parses in linear, not quadratic, time.
        let candidate = format!("</script{}", " ".repeat(200));
        let html = format!("<script>{}", candidate.repeat(2_000));
        let start = std::time::Instant::now();
        let nodes = parse(&html);
        assert_eq!(nodes.len(), 1);
        assert!(matches!(&nodes[0], Node::Element { tag, .. } if tag == "script"));
        assert!(
            start.elapsed() < std::time::Duration::from_secs(2),
            "parse took {:?} — looks quadratic again",
            start.elapsed()
        );
    }

    #[test]
    fn long_run_of_raw_text_closes_padded_with_long_attributes_is_linear_not_quadratic() {
        // Regression guard specifically for the fix that let
        // `raw_close_tag_end` see past its old fixed byte bound: naively
        // making that search fully unbounded (mirroring the fix already
        // applied to `oversized_tag_body_start`) would have reintroduced
        // O(n^2) here specifically, because `consume_raw_text` tries the
        // *next* `<` candidate on failure instead of stopping (unlike
        // `handle_closing_tag`, where a failed unbounded search jumps
        // straight to EOF and ends the whole parse). Each candidate below
        // carries 300+ bytes of non-whitespace attribute-like content
        // (long enough to blow well past the old 64-byte bound) and no
        // real `>` — with a naive fully-unbounded search this would pay a
        // full remaining-input scan per candidate; bounding the search to
        // the next literal `<` instead keeps each candidate's cost to its
        // own gap.
        let candidate = format!("</script data-x=\"{}", "y".repeat(300));
        let html = format!("<script>{}", candidate.repeat(2_000));
        let start = std::time::Instant::now();
        let nodes = parse(&html);
        assert_eq!(nodes.len(), 1);
        assert!(matches!(&nodes[0], Node::Element { tag, .. } if tag == "script"));
        assert!(
            start.elapsed() < std::time::Duration::from_secs(2),
            "parse took {:?} — looks quadratic again",
            start.elapsed()
        );
    }

    #[test]
    fn self_closing_syntax_on_a_raw_text_element_is_ignored_like_a_real_browser() {
        // Regression: `<script>`/`<style>`/`<title>`/`<textarea>` are
        // non-void HTML elements — real browsers ignore a stray
        // XHTML-style `/>` on them entirely, still treating everything up
        // to the next real closing tag as raw content. This parser used
        // to gate the raw-text branch on `!self_closing`, so
        // `<script src="x" />alert(1)</script>` skipped straight to the
        // generic self-closing/void-element case, emitting an empty
        // `script` element and then parsing `alert(1)` as ordinary
        // sibling markup — real script source rendered as visible text
        // instead of staying hidden.
        let html = r#"<script src="x" />alert(1)</script><p>Visible</p>"#;
        let nodes = parse(html);
        assert_eq!(
            nodes.len(),
            2,
            "expected the script element and its sibling <p>, got {nodes:?}"
        );
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected the first node to be an element")
        };
        assert_eq!(tag, "script");
        assert_eq!(
            text(children),
            "alert(1)",
            "the script source must stay inside the script element as raw content"
        );
        let Node::Element { tag, children } = &nodes[1] else {
            panic!("expected the second top-level node to be an element")
        };
        assert_eq!(tag, "p");
        assert_eq!(text(children), "Visible");
    }

    #[test]
    fn self_closing_syntax_on_an_ordinary_non_void_element_is_ignored_like_a_real_browser() {
        // Regression: the generic (non-raw-text) tag-push branch honored
        // XHTML-style `/>` on *any* element, even an ordinary non-void one
        // — but real browsers ignore that flag entirely outside void and
        // foreign (SVG/MathML) elements. `<li/>` pushed an empty `<li>`
        // instead of opening a real frame, so `<ul><li/>One<li/>Two</ul>`
        // left "One"/"Two" as stray text children of `<ul>` — invisible to
        // `extract_list_items`, which only reads `<li>` children — instead
        // of inside their own list items.
        let nodes = parse("<ul><li/>One<li/>Two</ul>");
        assert_eq!(nodes.len(), 1, "expected a single <ul>, got {nodes:?}");
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(tag, "ul");
        assert_eq!(
            children.len(),
            2,
            "expected two <li> children, got {children:?}"
        );
        for (child, expected) in children.iter().zip(["One", "Two"]) {
            let Node::Element { tag, children } = child else {
                panic!("expected an <li> element, got {child:?}")
            };
            assert_eq!(tag, "li");
            assert_eq!(text(children), expected);
        }
    }

    #[test]
    fn attribute_value_quote_separated_from_equals_by_whitespace_is_still_recognized() {
        // Regression: `is_attr_value_quote` originally required the quote
        // to be *immediately* preceded by `=`, but HTML5's attribute
        // syntax permits whitespace around `=` (`title = "x"` is valid,
        // not just `title="x"`). Without tolerating it, the quote wasn't
        // recognized as a genuine attribute-value opener, so a literal `>`
        // inside the (supposedly unquoted) value ended the tag early,
        // leaking the rest of the value as visible text.
        let nodes = parse(r#"<div title = "Balance > 100">Visible</div>"#);
        assert_eq!(nodes.len(), 1, "expected a single <div>, got {nodes:?}");
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(tag, "div");
        assert_eq!(text(children), "Visible");
    }

    #[test]
    fn stray_equals_and_quote_inside_an_already_started_unquoted_value_does_not_swallow_the_tag() {
        // Regression: an earlier version tried to answer "is this quote a
        // genuine attribute-value opener" by looking only backward from
        // it, but a second `=` (or quote) can legally occur inside a value
        // that already started *unquoted* — `title=x="` means the value
        // starts unquoted at `x`, and the following `=`/`"` are just more
        // literal (parse-error-tolerant) value content, not a fresh
        // attribute. Without distinguishing this, the `"` was mistaken
        // for a real opener, found no matching close, and the
        // oversized-tag fallback discarded the rest of the document
        // looking for one that was never real.
        let nodes = parse(r#"<div title=x=">Visible</div>"#);
        assert_eq!(nodes.len(), 1, "expected a single <div>, got {nodes:?}");
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(tag, "div");
        assert_eq!(text(children), "Visible");
    }

    #[test]
    fn adjacent_quoted_attributes_with_no_separating_whitespace_are_both_recognized() {
        // Regression: a backward-only heuristic for "is this quote a
        // genuine opener" can't distinguish a stray quote/`=` inside an
        // already-open *unquoted* value (the case above) from the
        // *closing* quote of a properly-completed, immediately-adjacent
        // quoted attribute — `a="x"b="y"` is valid HTML (ending a quoted
        // value returns straight to "before attribute name state," no
        // whitespace required), but both look identical from a purely
        // backward-looking check: a quote character immediately followed
        // by more name-like bytes then `=`. `b`'s opening quote was
        // wrongly rejected, so its quoted `>` was mistaken for the tag's
        // real delimiter, leaking `secret">` into the visible text. Only
        // genuine forward tokenizer state (tracking whether the previous
        // value was properly closed) can tell these apart correctly.
        let nodes = parse(r#"<div a="x"b=">secret">Visible</div>"#);
        assert_eq!(nodes.len(), 1, "expected a single <div>, got {nodes:?}");
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(tag, "div");
        assert_eq!(text(children), "Visible");
    }

    #[test]
    fn closing_tag_deeper_than_the_scan_window_is_ignored_not_matched() {
        // Documents the accepted precision loss from bounding the
        // closing-tag scan (MAX_CLOSE_SCAN): a close whose matching opener
        // sits further back than the window no longer auto-closes the many
        // intervening tags — it's treated the same as a close with no
        // matching opener at all (ignored), so trailing content ends up
        // nested inside the still-open tags instead of becoming a sibling
        // at the root. Real templates essentially never nest hundreds of
        // levels deep, let alone rely on this specific deep-ancestor-close
        // pattern, so this only affects pathological input.
        let html = format!("<outer>{}</outer>tail", "<a>".repeat(600));
        let nodes = parse(&html);
        assert_eq!(
            nodes.len(),
            1,
            "the out-of-window close must not split off a sibling at the root"
        );
        assert_eq!(text(&nodes), "tail");
    }

    #[test]
    fn valid_tag_with_long_attribute_list_still_parses() {
        // Regression: a real Tailwind-style `class` attribute easily runs
        // past a couple hundred bytes on its own — MAX_TAG_SCAN must stay
        // generous enough that a genuine (if verbose) opening tag doesn't
        // get rejected and leaked into the PDF as literal `<div ...>` text.
        let long_class = "flex items-center justify-between px-4 py-2 bg-white \
            dark:bg-gray-900 border border-gray-200 rounded-lg shadow-sm \
            hover:shadow-md transition-shadow duration-200 text-sm font-medium \
            text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 \
            focus:ring-offset-2 data-controller=\"dropdown\" aria-label=\"menu\"";
        assert!(
            long_class.len() > 256,
            "test fixture must exceed the old, too-tight window"
        );
        let html = format!("<div class=\"{long_class}\">Hello</div>");
        let nodes = parse(&html);
        assert_eq!(
            text(&nodes),
            "Hello",
            "a long but well-formed opening tag must parse, not leak as literal text"
        );
    }

    #[test]
    fn quoted_greater_than_inside_an_attribute_does_not_end_the_tag_early() {
        // Regression: `parse_open_tag`'s search for the closing `>` used to
        // stop at the *first* `>` anywhere in the tag, including one inside
        // a quoted attribute value — `<div title="Balance > 100">Visible</div>`
        // truncated the tag right after "Balance ", leaking ` 100">` into
        // the document as literal text ahead of "Visible".
        let nodes = parse(r#"<div title="Balance > 100">Visible</div>"#);
        assert_eq!(
            nodes.len(),
            1,
            "expected a single <div> node, got {nodes:?}"
        );
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(tag, "div");
        assert_eq!(
            text(children),
            "Visible",
            "the quoted attribute value must not leak into the rendered text"
        );
    }

    #[test]
    fn quoted_apostrophe_greater_than_inside_an_attribute_does_not_end_the_tag_early() {
        // Same bug, single-quoted attribute variant.
        let nodes = parse("<div title='Balance > 100'>Visible</div>");
        assert_eq!(nodes.len(), 1);
        let Node::Element { children, .. } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(text(children), "Visible");
    }

    #[test]
    fn literal_quote_in_an_unquoted_attribute_value_does_not_swallow_the_tag() {
        // Regression: `find_tag_end` used to treat *any* quote character as
        // opening a quoted span, regardless of what precedes it — but an
        // unquoted attribute value can legally contain a literal quote
        // (`title=it's` is a parse-error condition real browsers still
        // tolerate, ending the tag at the next real `>`). With no second
        // apostrophe anywhere in the document, the old logic searched for a
        // matching close that didn't exist and gave up, discarding an
        // already-known-valid `>` and losing "Visible" past it.
        let nodes = parse("<div title=it's>Visible</div>");
        assert_eq!(
            nodes.len(),
            1,
            "expected a single <div> node, got {nodes:?}"
        );
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(tag, "div");
        assert_eq!(text(children), "Visible");
    }

    #[test]
    fn raw_text_closing_tag_with_a_literal_angle_bracket_in_a_quoted_attribute_still_closes() {
        // Regression: `raw_close_tag_end`'s scan used to stop at the first
        // literal `<` character, even one sitting inside a genuinely quoted
        // attribute value — `</script data-x="<">Visible` has its own real
        // close right after the quoted value, but the embedded `<` was
        // mistaken for the start of a new tag, rejecting the candidate and
        // running the scan to EOF (hiding "Visible").
        let nodes = parse(r#"<script>hidden</script data-x="<">Visible"#);
        assert_eq!(
            nodes.len(),
            2,
            "expected script + trailing text, got {nodes:?}"
        );
        let Node::Element { tag, children } = &nodes[0] else {
            panic!("expected the first node to be an element")
        };
        assert_eq!(tag, "script");
        assert_eq!(text(children), "hidden");
        assert_eq!(nodes[1], Node::Text("Visible".to_owned()));
    }

    #[test]
    fn long_run_of_unterminated_quoted_attributes_is_linear_not_quadratic() {
        // Regression: `find_tag_end`'s quote-tracking slow path is only
        // reached when the scan window contains a quote character at all —
        // verify that path itself doesn't reopen the O(n^2) cost
        // `MAX_TAG_SCAN` exists to bound, the same way the plain
        // `long_run_of_unterminated_open_tags_is_linear_not_quadratic` test
        // verifies the quote-free fast path.
        //
        // The whole string is one giant unterminated `<a title="x...`
        // attribute (its quote never closes, and no `>` appears anywhere)
        // — `push_oversized_generic_tag` recognizes this as a real (if
        // malformed) `<a>` tag once its name is found (`"a"`, well within
        // `MAX_TAG_NAME_SCAN`), and its unbounded `>` search (see
        // `oversized_tag_body_start`'s docs for why unbounded is safe)
        // correctly determines no real `>` exists anywhere, auto-closing
        // the `<a>` at EOF — the same "unterminated tag/attribute swallows
        // the rest of the document, invisibly, rather than rendering as
        // literal text" tolerance already established for the seven
        // special tags (and for real browsers, whose tokenizer stays in
        // attribute-value state until EOF too).
        let html = "<a title=\"x".repeat(50_000);
        let start = std::time::Instant::now();
        let nodes = parse(&html);
        assert_eq!(
            nodes,
            vec![Node::Element {
                tag: "a".to_owned(),
                children: Vec::new(),
            }],
            "the whole unterminated attribute must be swallowed into one auto-closed <a>, \
             not leaked as visible text"
        );
        assert!(
            start.elapsed() < std::time::Duration::from_secs(2),
            "find_tag_end's quoted-attribute path took {:?} — looks quadratic",
            start.elapsed()
        );
    }

    #[test]
    fn long_run_of_stray_apostrophes_before_the_real_close_is_linear_not_quadratic() {
        // Regression guard for the fix that made `find_tag_end` skip a
        // quote character that isn't a genuine attribute-value opener:
        // verify a long run of such stray quotes (none preceded by `=`,
        // so none pair up) still resolves in linear, not quadratic, time
        // — each skip only costs the gap to the *next* quote character,
        // not a rescan of the whole remaining span.
        let html = format!("<div title={}>Visible</div>", "it's ".repeat(50_000));
        let start = std::time::Instant::now();
        let nodes = parse(&html);
        assert_eq!(
            nodes.len(),
            1,
            "expected a single <div> node, got {nodes:?}"
        );
        let Node::Element { children, .. } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(text(children), "Visible");
        assert!(
            start.elapsed() < std::time::Duration::from_secs(2),
            "find_tag_end took {:?} — looks quadratic again",
            start.elapsed()
        );
    }

    #[test]
    fn long_run_of_whitespace_separated_stray_apostrophes_is_linear_not_quadratic() {
        // Regression guard specifically for `is_attr_value_quote`'s
        // backward whitespace walk: unlike the tightly-packed test above,
        // each stray apostrophe here is itself preceded by a run of
        // whitespace (not `=`), forcing the backward walk to actually
        // traverse whitespace before concluding it isn't a genuine
        // attribute-value opener. Verify this still resolves in linear,
        // not quadratic, time — each backward walk only re-covers the
        // same gap the forward scan already paid for to reach that quote,
        // not a rescan of the whole prior span.
        let html = format!("<div title={}>Visible</div>", "x     '".repeat(50_000));
        let start = std::time::Instant::now();
        let nodes = parse(&html);
        assert_eq!(
            nodes.len(),
            1,
            "expected a single <div> node, got {nodes:?}"
        );
        let Node::Element { children, .. } = &nodes[0] else {
            panic!("expected an element")
        };
        assert_eq!(text(children), "Visible");
        assert!(
            start.elapsed() < std::time::Duration::from_secs(2),
            "find_tag_end took {:?} — looks quadratic again",
            start.elapsed()
        );
    }

    #[test]
    fn long_run_of_raw_text_closes_with_quoted_angle_brackets_is_linear_not_quadratic() {
        // Regression guard for the fix that made `raw_close_tag_end` skip
        // past a genuinely quoted `<` instead of treating it as a
        // candidate boundary: verify many candidates each carrying a
        // quoted `<` (but no real close) still resolve in linear, not
        // quadratic, time.
        let candidate = "</script data-x=\"<\" ";
        let html = format!("<script>{}", candidate.repeat(2_000));
        let start = std::time::Instant::now();
        let nodes = parse(&html);
        assert_eq!(nodes.len(), 1);
        assert!(matches!(&nodes[0], Node::Element { tag, .. } if tag == "script"));
        assert!(
            start.elapsed() < std::time::Duration::from_secs(2),
            "parse took {:?} — looks quadratic again",
            start.elapsed()
        );
    }

    #[test]
    fn find_tag_end_skips_mixed_quote_styles_and_embedded_brackets() {
        // Both quote styles in the same window, one of them (`'b>c'`)
        // containing a literal `>` that must not be mistaken for the
        // real closing delimiter.
        let w = r#" data-x="a" data-y='b>c' data-z="Balance > 100">Visible"#;
        let expected = w.find("\">Visible").unwrap() + 1;
        assert_eq!(find_tag_end(w), Some(expected));
    }

    #[test]
    fn oversized_tag_with_many_short_quote_pairs_before_its_real_close_is_linear_not_quadratic() {
        // Regression, found while investigating an unrelated report: two
        // compounding O(n^2) bugs in `find_tag_end`'s quote-tracking loop,
        // neither caught by `long_run_of_unterminated_quoted_attributes_is_linear_not_quadratic`
        // above (which never reaches a *real* closing `>` at all, so never
        // exercises this loop's steady-state behavior across many
        // completed quote pairs).
        //
        // 1. Each iteration re-derived its `>` candidate via a fresh
        //    `.find('>')`, rescanning from the (advancing) start position
        //    all the way back out to the *same* distant `>` every time —
        //    for a long chain of short quote pairs all closing well
        //    before the real `>`, that's O(pairs * distance-to-`>`) for a
        //    single call (confirmed by timing: doubling the input
        //    quadrupled the time before the fix).
        // 2. Even with that fixed, finding the *next* quote checked both
        //    quote styles via two separate `.find()` calls — since real
        //    HTML overwhelmingly uses only one style consistently, the
        //    *other* style's absence cost a full scan of the remaining
        //    span on every single iteration too, the same shape again.
        let pairs = 20_000;
        let html = format!(
            r"<script data-x={}>Secret</script><p>Visible</p>",
            r#""a""#.repeat(pairs)
        );
        let start = std::time::Instant::now();
        let nodes = parse(&html);
        // Matches the established oversized-script behavior: no node at
        // all for the (unparseable) opening tag, its source discarded —
        // just confirms parsing correctly landed right after the real
        // `>` and continued normally, not that it got lost somewhere in
        // the quote chain.
        assert_eq!(nodes.len(), 1, "got {nodes:?}");
        assert!(matches!(&nodes[0], Node::Element { tag, .. } if tag == "p"));
        assert_eq!(text(&nodes), "Visible");
        assert!(
            start.elapsed() < std::time::Duration::from_secs(2),
            "parse took {:?} — looks quadratic again",
            start.elapsed()
        );
    }

    #[test]
    fn attributes_are_ignored_but_dont_break_parsing() {
        let nodes = parse(r#"<p class="total" data-x='1'>Total</p>"#);
        assert_eq!(text(&nodes), "Total");
    }

    #[test]
    fn deeply_nested_input_does_not_overflow_the_stack() {
        let mut html = String::new();
        for _ in 0..50_000 {
            html.push_str("<div>");
        }
        html.push('x');
        for _ in 0..50_000 {
            html.push_str("</div>");
        }
        // `parse` is iterative, so it must not overflow. Deliberately does
        // *not* walk the resulting tree with a recursive helper like `text`
        // here — that would just move the overflow risk into the test
        // itself. Dropping `nodes` at the end of this test exercises
        // `Node`'s custom iterative `Drop` the same way.
        let nodes = parse(&html);
        assert_eq!(nodes.len(), 1);
    }

    #[test]
    fn malformed_lone_angle_bracket_is_literal_text() {
        let nodes = parse("a < b");
        assert_eq!(text(&nodes), "a < b");
    }
}