@flamingo-stack/openframe-frontend-core 0.0.508 → 0.0.509

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (306) hide show
  1. package/dist/chat-protocol/decode.d.ts +58 -0
  2. package/dist/chat-protocol/decode.d.ts.map +1 -0
  3. package/dist/chat-protocol/encode.d.ts +44 -0
  4. package/dist/chat-protocol/encode.d.ts.map +1 -0
  5. package/dist/chat-protocol/env-flag.d.ts +35 -0
  6. package/dist/chat-protocol/env-flag.d.ts.map +1 -0
  7. package/dist/chat-protocol/events.d.ts +216 -0
  8. package/dist/chat-protocol/events.d.ts.map +1 -0
  9. package/dist/chat-protocol/frames.d.ts +213 -0
  10. package/dist/chat-protocol/frames.d.ts.map +1 -0
  11. package/dist/chat-protocol/index.cjs +506 -0
  12. package/dist/chat-protocol/index.cjs.map +1 -0
  13. package/dist/chat-protocol/index.d.ts +18 -0
  14. package/dist/chat-protocol/index.d.ts.map +1 -0
  15. package/dist/chat-protocol/index.js +490 -0
  16. package/dist/chat-protocol/index.js.map +1 -0
  17. package/dist/chat-protocol/ip-normalize.d.ts +44 -0
  18. package/dist/chat-protocol/ip-normalize.d.ts.map +1 -0
  19. package/dist/chat-protocol/nats-decoder.d.ts +26 -0
  20. package/dist/chat-protocol/nats-decoder.d.ts.map +1 -0
  21. package/dist/{chunk-3SQ5KXHQ.cjs → chunk-2T4GTV25.cjs} +9 -9
  22. package/dist/{chunk-3SQ5KXHQ.cjs.map → chunk-2T4GTV25.cjs.map} +1 -1
  23. package/dist/{chunk-CWAOV2FK.cjs → chunk-3CGZPAGR.cjs} +3 -3
  24. package/dist/{chunk-CWAOV2FK.cjs.map → chunk-3CGZPAGR.cjs.map} +1 -1
  25. package/dist/{chunk-TLMJHMXJ.js → chunk-3QSR22IC.js} +12 -12
  26. package/dist/chunk-3QSR22IC.js.map +1 -0
  27. package/dist/{chunk-TWYNR4TZ.cjs → chunk-4G5TLHJD.cjs} +7141 -4799
  28. package/dist/chunk-4G5TLHJD.cjs.map +1 -0
  29. package/dist/{chunk-ADPMHWOE.js → chunk-72XAID7Y.js} +4 -4
  30. package/dist/{chunk-PAGKRNWK.js → chunk-7WZHBQ4J.js} +2 -2
  31. package/dist/{chunk-OCEKO5CW.js → chunk-AEELJC4N.js} +12106 -9764
  32. package/dist/chunk-AEELJC4N.js.map +1 -0
  33. package/dist/{chunk-Z42BGM6Q.cjs → chunk-BT2WWDFP.cjs} +5 -5
  34. package/dist/{chunk-Z42BGM6Q.cjs.map → chunk-BT2WWDFP.cjs.map} +1 -1
  35. package/dist/{chunk-7LEL3RIX.cjs → chunk-C7OY3PQP.cjs} +11 -11
  36. package/dist/{chunk-7LEL3RIX.cjs.map → chunk-C7OY3PQP.cjs.map} +1 -1
  37. package/dist/{chunk-RHGGEKPQ.cjs → chunk-CIOLOQKQ.cjs} +4 -4
  38. package/dist/{chunk-RHGGEKPQ.cjs.map → chunk-CIOLOQKQ.cjs.map} +1 -1
  39. package/dist/{chunk-TSZHM74B.js → chunk-DC2TKS7C.js} +2 -2
  40. package/dist/{chunk-M2GOR3XQ.cjs → chunk-DYPYR7FR.cjs} +87 -87
  41. package/dist/{chunk-M2GOR3XQ.cjs.map → chunk-DYPYR7FR.cjs.map} +1 -1
  42. package/dist/{chunk-I64ABCDX.js → chunk-I2B6X77L.js} +2 -2
  43. package/dist/{chunk-NIZDKTGL.cjs → chunk-K5MSCMDK.cjs} +37 -37
  44. package/dist/{chunk-NIZDKTGL.cjs.map → chunk-K5MSCMDK.cjs.map} +1 -1
  45. package/dist/{chunk-DTYRYB2N.js → chunk-K6QTZ7CZ.js} +2 -2
  46. package/dist/{chunk-4LTMXDUS.js → chunk-KBN7PMFT.js} +2 -2
  47. package/dist/{chunk-TBMV7I5N.js → chunk-KXZO2WZX.js} +2 -2
  48. package/dist/{chunk-QTYZMP6D.cjs → chunk-LBAKLT6T.cjs} +62 -62
  49. package/dist/chunk-LBAKLT6T.cjs.map +1 -0
  50. package/dist/{chunk-KEBYLU3U.js → chunk-LFUPI7VX.js} +2 -2
  51. package/dist/{chunk-LDTR4IVZ.cjs → chunk-M37PBG4N.cjs} +31 -31
  52. package/dist/{chunk-LDTR4IVZ.cjs.map → chunk-M37PBG4N.cjs.map} +1 -1
  53. package/dist/{chunk-WA7RR64F.js → chunk-PO5O654H.js} +6 -5
  54. package/dist/chunk-PO5O654H.js.map +1 -0
  55. package/dist/{chunk-WK4N5VBX.cjs → chunk-QJJISLPA.cjs} +28 -27
  56. package/dist/chunk-QJJISLPA.cjs.map +1 -0
  57. package/dist/{chunk-2F44PLTV.cjs → chunk-T7MCSA55.cjs} +26 -26
  58. package/dist/{chunk-2F44PLTV.cjs.map → chunk-T7MCSA55.cjs.map} +1 -1
  59. package/dist/{chunk-WJQHJD7J.js → chunk-TWSKTJHW.js} +2 -2
  60. package/dist/{chunk-ZSQHZYCO.cjs → chunk-UQDMJ6DH.cjs} +13 -13
  61. package/dist/{chunk-ZSQHZYCO.cjs.map → chunk-UQDMJ6DH.cjs.map} +1 -1
  62. package/dist/{chunk-TZRUCD56.js → chunk-V3ZAAFE3.js} +5 -5
  63. package/dist/{chunk-LRGHJPET.js → chunk-XGIFOHKE.js} +2 -2
  64. package/dist/{chunk-POOMO3PA.cjs → chunk-YQEPRYT5.cjs} +7 -7
  65. package/dist/{chunk-POOMO3PA.cjs.map → chunk-YQEPRYT5.cjs.map} +1 -1
  66. package/dist/components/case-studies/index.cjs +8 -8
  67. package/dist/components/case-studies/index.js +2 -2
  68. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  69. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  70. package/dist/components/chat/hooks/index.d.ts +0 -1
  71. package/dist/components/chat/hooks/index.d.ts.map +1 -1
  72. package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
  73. package/dist/components/chat/hooks/use-chunk-catchup.d.ts.map +1 -1
  74. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +15 -48
  75. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  76. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +10 -57
  77. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  78. package/dist/components/chat/index.cjs +10 -2
  79. package/dist/components/chat/index.cjs.map +1 -1
  80. package/dist/components/chat/index.d.ts +1 -0
  81. package/dist/components/chat/index.d.ts.map +1 -1
  82. package/dist/components/chat/index.js +21 -13
  83. package/dist/components/chat/stream/chat-dialog-store.d.ts +184 -0
  84. package/dist/components/chat/stream/chat-dialog-store.d.ts.map +1 -0
  85. package/dist/components/chat/stream/chat-stream-reducer.d.ts +380 -0
  86. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -0
  87. package/dist/components/chat/stream/delta-batcher.d.ts +51 -0
  88. package/dist/components/chat/stream/delta-batcher.d.ts.map +1 -0
  89. package/dist/components/chat/stream/index.d.ts +17 -0
  90. package/dist/components/chat/stream/index.d.ts.map +1 -0
  91. package/dist/components/chat/stream/message-mutations.d.ts +77 -0
  92. package/dist/components/chat/stream/message-mutations.d.ts.map +1 -0
  93. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +22 -0
  94. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +1 -0
  95. package/dist/components/chat/types/api.types.d.ts +1 -81
  96. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  97. package/dist/components/chat/types/processing.types.d.ts +2 -93
  98. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  99. package/dist/components/chat/types/unified-chat-state.types.d.ts +15 -0
  100. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
  101. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +34 -6
  102. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
  103. package/dist/components/chat/utils/history-merge.d.ts.map +1 -1
  104. package/dist/components/chat/utils/index.d.ts +2 -3
  105. package/dist/components/chat/utils/index.d.ts.map +1 -1
  106. package/dist/components/chat/utils/process-historical-messages.d.ts +59 -11
  107. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  108. package/dist/components/contact/index.cjs +3 -3
  109. package/dist/components/contact/index.js +2 -2
  110. package/dist/components/docs/index.cjs +5 -5
  111. package/dist/components/docs/index.js +4 -4
  112. package/dist/components/embeds/index.cjs +3 -3
  113. package/dist/components/embeds/index.js +2 -2
  114. package/dist/components/faq/index.cjs +3 -3
  115. package/dist/components/faq/index.js +2 -2
  116. package/dist/components/features/index.cjs +2 -2
  117. package/dist/components/features/index.js +1 -1
  118. package/dist/components/help-center-pages/index.cjs +22 -22
  119. package/dist/components/help-center-pages/index.js +13 -13
  120. package/dist/components/index.cjs +174 -132
  121. package/dist/components/index.cjs.map +1 -1
  122. package/dist/components/index.js +65 -23
  123. package/dist/components/index.js.map +1 -1
  124. package/dist/components/meeting-scheduler/index.cjs +34 -34
  125. package/dist/components/meeting-scheduler/index.js +3 -3
  126. package/dist/components/navigation/index.cjs +2 -2
  127. package/dist/components/navigation/index.js +1 -1
  128. package/dist/components/onboarding-guides/index.cjs +5 -5
  129. package/dist/components/onboarding-guides/index.js +4 -4
  130. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
  131. package/dist/components/related-content/index.cjs +3 -3
  132. package/dist/components/related-content/index.js +2 -2
  133. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  134. package/dist/components/tickets/index.cjs +6 -6
  135. package/dist/components/tickets/index.js +5 -5
  136. package/dist/components/ui/index.cjs +44 -2
  137. package/dist/components/ui/index.cjs.map +1 -1
  138. package/dist/components/ui/index.d.ts +1 -2
  139. package/dist/components/ui/index.d.ts.map +1 -1
  140. package/dist/components/ui/index.js +55 -13
  141. package/dist/components/ui/markdown/base-components.d.ts +51 -0
  142. package/dist/components/ui/markdown/base-components.d.ts.map +1 -0
  143. package/dist/components/ui/markdown/engine.d.ts +99 -0
  144. package/dist/components/ui/markdown/engine.d.ts.map +1 -0
  145. package/dist/components/ui/markdown/heading-ids.d.ts +65 -0
  146. package/dist/components/ui/markdown/heading-ids.d.ts.map +1 -0
  147. package/dist/components/ui/markdown/index.d.ts +18 -0
  148. package/dist/components/ui/markdown/index.d.ts.map +1 -0
  149. package/dist/components/ui/markdown/mermaid-diagram.d.ts +79 -0
  150. package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +1 -0
  151. package/dist/components/ui/markdown/rich/embed-overrides.d.ts +14 -0
  152. package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -0
  153. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +40 -0
  154. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +1 -0
  155. package/dist/components/ui/markdown/rich/shortcodes.d.ts +8 -0
  156. package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -0
  157. package/dist/components/ui/markdown/sanitize.d.ts +153 -0
  158. package/dist/components/ui/markdown/sanitize.d.ts.map +1 -0
  159. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +23 -0
  160. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +1 -0
  161. package/dist/components/ui/markdown/streaming.d.ts +78 -0
  162. package/dist/components/ui/markdown/streaming.d.ts.map +1 -0
  163. package/dist/components/ui/markdown/text-size.d.ts +34 -0
  164. package/dist/components/ui/markdown/text-size.d.ts.map +1 -0
  165. package/dist/index.cjs +60 -2
  166. package/dist/index.cjs.map +1 -1
  167. package/dist/index.js +71 -13
  168. package/dist/utils/index.cjs +169 -40
  169. package/dist/utils/index.cjs.map +1 -1
  170. package/dist/utils/index.d.ts +1 -0
  171. package/dist/utils/index.d.ts.map +1 -1
  172. package/dist/utils/index.js +162 -41
  173. package/dist/utils/index.js.map +1 -1
  174. package/dist/utils/markdown-fences.d.ts +42 -0
  175. package/dist/utils/markdown-fences.d.ts.map +1 -0
  176. package/dist/utils/markdown-heading-id.d.ts +85 -0
  177. package/dist/utils/markdown-heading-id.d.ts.map +1 -0
  178. package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
  179. package/package.json +7 -1
  180. package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +319 -0
  181. package/src/chat-protocol/__tests__/chat-protocol.test.ts +516 -0
  182. package/src/chat-protocol/__tests__/env-flag.test.ts +52 -0
  183. package/src/chat-protocol/__tests__/ip-normalize.test.ts +136 -0
  184. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +237 -0
  185. package/src/chat-protocol/decode.ts +278 -0
  186. package/src/chat-protocol/encode.ts +71 -0
  187. package/src/chat-protocol/env-flag.ts +39 -0
  188. package/src/chat-protocol/events.ts +254 -0
  189. package/src/chat-protocol/frames.ts +245 -0
  190. package/src/chat-protocol/index.ts +21 -0
  191. package/src/chat-protocol/ip-normalize.ts +146 -0
  192. package/src/chat-protocol/nats-decoder.ts +264 -0
  193. package/src/components/chat/.chat-message-list.md +10 -6
  194. package/src/components/chat/__tests__/chat-message-list.test.tsx +450 -9
  195. package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +207 -0
  196. package/src/components/chat/__tests__/chat-pending-turn.test.tsx +250 -0
  197. package/src/components/chat/chat-message-enhanced.tsx +122 -20
  198. package/src/components/chat/chat-message-list.tsx +515 -133
  199. package/src/components/chat/embeddable-chat.tsx +6 -0
  200. package/src/components/chat/hooks/.index.md +30 -33
  201. package/src/components/chat/hooks/.use-nats-chat-adapter.md +36 -55
  202. package/src/components/chat/hooks/__tests__/__snapshots__/conversation-id-persistence-golden.test.ts.snap +37 -0
  203. package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
  204. package/src/components/chat/hooks/__tests__/chunk-catchup-dialog-staleness.test.ts +92 -0
  205. package/src/components/chat/hooks/__tests__/conversation-id-persistence-golden.test.ts +438 -0
  206. package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +318 -0
  207. package/src/components/chat/hooks/index.ts +0 -1
  208. package/src/components/chat/hooks/use-chat.ts +8 -0
  209. package/src/components/chat/hooks/use-chunk-catchup.ts +26 -1
  210. package/src/components/chat/hooks/use-nats-chat-adapter.ts +254 -825
  211. package/src/components/chat/hooks/use-sse-chat-adapter.ts +380 -684
  212. package/src/components/chat/index.ts +5 -0
  213. package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +944 -0
  214. package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +806 -0
  215. package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +467 -0
  216. package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +1330 -0
  217. package/src/components/chat/stream/__tests__/delta-batcher.test.ts +255 -0
  218. package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +105 -0
  219. package/src/components/chat/stream/chat-dialog-store.ts +536 -0
  220. package/src/components/chat/stream/chat-stream-reducer.ts +1928 -0
  221. package/src/components/chat/stream/delta-batcher.ts +168 -0
  222. package/src/components/chat/stream/index.ts +55 -0
  223. package/src/components/chat/stream/message-mutations.ts +340 -0
  224. package/src/components/chat/stream/use-chat-stream-reducer.ts +126 -0
  225. package/src/components/chat/thinking-display.tsx +1 -1
  226. package/src/components/chat/types/.api.types.md +39 -49
  227. package/src/components/chat/types/.processing.types.md +29 -58
  228. package/src/components/chat/types/.unified-chat-state.types.md +2 -2
  229. package/src/components/chat/types/api.types.ts +6 -73
  230. package/src/components/chat/types/processing.types.ts +11 -53
  231. package/src/components/chat/types/unified-chat-state.types.ts +16 -0
  232. package/src/components/chat/utils/.extract-incomplete-message-state.md +21 -3
  233. package/src/components/chat/utils/.history-merge.md +44 -3
  234. package/src/components/chat/utils/.index.md +31 -40
  235. package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +420 -0
  236. package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +605 -0
  237. package/src/components/chat/utils/__tests__/extract-incomplete-message-state.test.ts +106 -0
  238. package/src/components/chat/utils/__tests__/history-merge.test.ts +160 -0
  239. package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +61 -0
  240. package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +394 -0
  241. package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +270 -0
  242. package/src/components/chat/utils/extract-incomplete-message-state.ts +93 -6
  243. package/src/components/chat/utils/history-merge.ts +85 -5
  244. package/src/components/chat/utils/index.ts +5 -8
  245. package/src/components/chat/utils/process-historical-messages.ts +361 -385
  246. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +22 -4
  247. package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
  248. package/src/components/shared/product-release/release-detail-page.tsx +19 -4
  249. package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +2420 -0
  250. package/src/components/ui/__tests__/markdown-parity.test.tsx +2540 -0
  251. package/src/components/ui/index.ts +1 -2
  252. package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +176 -0
  253. package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +151 -0
  254. package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +354 -0
  255. package/src/components/ui/markdown/__tests__/sanitize-render.test.tsx +118 -0
  256. package/src/components/ui/markdown/__tests__/streaming.test.tsx +436 -0
  257. package/src/components/ui/markdown/base-components.tsx +360 -0
  258. package/src/components/ui/markdown/engine.tsx +315 -0
  259. package/src/components/ui/markdown/heading-ids.ts +239 -0
  260. package/src/components/ui/markdown/index.ts +49 -0
  261. package/src/components/ui/markdown/mermaid-diagram.tsx +323 -0
  262. package/src/components/ui/markdown/rich/embed-overrides.tsx +199 -0
  263. package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +184 -0
  264. package/src/components/ui/markdown/rich/shortcodes.ts +170 -0
  265. package/src/components/ui/markdown/sanitize.ts +3584 -0
  266. package/src/components/ui/markdown/simple-markdown-renderer.tsx +28 -0
  267. package/src/components/ui/markdown/streaming.ts +390 -0
  268. package/src/components/ui/markdown/text-size.ts +106 -0
  269. package/src/components/ui/release-changelog-section.tsx +1 -1
  270. package/src/components/ui/ticket-info-section.tsx +1 -1
  271. package/src/utils/index.ts +1 -0
  272. package/src/utils/markdown-fences.ts +127 -0
  273. package/src/utils/markdown-heading-id.ts +348 -0
  274. package/src/utils/markdown-section-extractor.ts +41 -56
  275. package/dist/chunk-OCEKO5CW.js.map +0 -1
  276. package/dist/chunk-QTYZMP6D.cjs.map +0 -1
  277. package/dist/chunk-TLMJHMXJ.js.map +0 -1
  278. package/dist/chunk-TWYNR4TZ.cjs.map +0 -1
  279. package/dist/chunk-WA7RR64F.js.map +0 -1
  280. package/dist/chunk-WK4N5VBX.cjs.map +0 -1
  281. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +0 -6
  282. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +0 -1
  283. package/dist/components/chat/utils/chunk-parser.d.ts +0 -25
  284. package/dist/components/chat/utils/chunk-parser.d.ts.map +0 -1
  285. package/dist/components/ui/rich-markdown-renderer.d.ts +0 -34
  286. package/dist/components/ui/rich-markdown-renderer.d.ts.map +0 -1
  287. package/dist/components/ui/simple-markdown-renderer.d.ts +0 -73
  288. package/dist/components/ui/simple-markdown-renderer.d.ts.map +0 -1
  289. package/src/components/chat/hooks/.use-realtime-chunk-processor.md +0 -77
  290. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +0 -489
  291. package/src/components/chat/utils/.chunk-parser.md +0 -62
  292. package/src/components/chat/utils/chunk-parser.ts +0 -262
  293. package/src/components/ui/.simple-markdown-renderer.md +0 -52
  294. package/src/components/ui/rich-markdown-renderer.tsx +0 -1223
  295. package/src/components/ui/simple-markdown-renderer.tsx +0 -964
  296. /package/dist/{chunk-ADPMHWOE.js.map → chunk-72XAID7Y.js.map} +0 -0
  297. /package/dist/{chunk-PAGKRNWK.js.map → chunk-7WZHBQ4J.js.map} +0 -0
  298. /package/dist/{chunk-TSZHM74B.js.map → chunk-DC2TKS7C.js.map} +0 -0
  299. /package/dist/{chunk-I64ABCDX.js.map → chunk-I2B6X77L.js.map} +0 -0
  300. /package/dist/{chunk-DTYRYB2N.js.map → chunk-K6QTZ7CZ.js.map} +0 -0
  301. /package/dist/{chunk-4LTMXDUS.js.map → chunk-KBN7PMFT.js.map} +0 -0
  302. /package/dist/{chunk-TBMV7I5N.js.map → chunk-KXZO2WZX.js.map} +0 -0
  303. /package/dist/{chunk-KEBYLU3U.js.map → chunk-LFUPI7VX.js.map} +0 -0
  304. /package/dist/{chunk-WJQHJD7J.js.map → chunk-TWSKTJHW.js.map} +0 -0
  305. /package/dist/{chunk-TZRUCD56.js.map → chunk-V3ZAAFE3.js.map} +0 -0
  306. /package/dist/{chunk-LRGHJPET.js.map → chunk-XGIFOHKE.js.map} +0 -0
@@ -0,0 +1,2540 @@
1
+ /**
2
+ * Phase-0 GOLDEN CHARACTERIZATION TESTS for the markdown renderer unification.
3
+ *
4
+ * These snapshots capture the CURRENT output of `SimpleMarkdownRenderer` and
5
+ * `RichMarkdownRenderer` over a fixture corpus BEFORE the unified engine
6
+ * refactor. After the refactor the same tests must produce identical
7
+ * snapshots, except for deltas explicitly reviewed in the migration plan
8
+ * (blockquote bg token, ODS `article` typography preset, sanitizer applied
9
+ * to Rich, and the code-block font: the old inline "JetBrains Mono", "SF
10
+ * Mono", Consolas stack is now the ODS `font-mono` class — see the
11
+ * ODS-TOKENS note in ../markdown/base-components.tsx).
12
+ *
13
+ * Leaf embed components (Video, Reddit/Twitter/LinkedIn embeds,
14
+ * OGLinkPreview, FigmaEmbed, MarkdownImage) and `mermaid` are mocked to
15
+ * cheap deterministic stubs — the parity target is the RENDERER PIPELINE
16
+ * (remark/rehype plugins, preprocessing, component map, heading ids,
17
+ * sanitization), not the leaves. The same mocks apply pre- and
18
+ * post-refactor, so snapshots stay comparable.
19
+ */
20
+ import React from 'react'
21
+ import { describe, it, expect, vi } from 'vitest'
22
+ import { render, act } from '@testing-library/react'
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Leaf mocks (stable across the refactor)
26
+ // ---------------------------------------------------------------------------
27
+ vi.mock('mermaid', () => ({
28
+ default: {
29
+ initialize: vi.fn(),
30
+ render: vi.fn(async () => ({ svg: '<svg data-mock-mermaid="true" width="100" height="50"></svg>' })),
31
+ },
32
+ }))
33
+
34
+ vi.mock('@/components/features/video', () => ({
35
+ Video: ({ kind, url, poster }: any) => (
36
+ <div data-mock="video" data-kind={kind} data-url={url} data-poster={poster ?? ''} />
37
+ ),
38
+ }))
39
+
40
+ vi.mock('@/components/embeds/reddit-embed-client', () => ({
41
+ RedditEmbedClient: ({ url }: any) => <div data-mock="reddit" data-url={url} />,
42
+ }))
43
+
44
+ vi.mock('@/components/embeds/twitter-embed-client', () => ({
45
+ TwitterEmbedClient: ({ url }: any) => <div data-mock="twitter" data-url={url} />,
46
+ }))
47
+
48
+ vi.mock('@/components/embeds/linkedin-embed-client', () => ({
49
+ LinkedInEmbedClient: ({ url }: any) => <div data-mock="linkedin" data-url={url} />,
50
+ }))
51
+
52
+ vi.mock('@/components/embeds/og-link-preview', () => ({
53
+ OGLinkPreview: ({ url }: any) => <div data-mock="og-preview" data-url={url} />,
54
+ OGLinkErrorBoundary: ({ children }: any) => <>{children}</>,
55
+ }))
56
+
57
+ vi.mock('@/components/embeds/figma-embed', () => ({
58
+ FigmaEmbed: ({ url }: any) => <div data-mock="figma" data-url={url} />,
59
+ }))
60
+
61
+ vi.mock('@/components/embeds/markdown-image', () => ({
62
+ MarkdownImage: ({ src, alt }: any) => <img data-mock="markdown-image" src={src} alt={alt ?? ''} />,
63
+ }))
64
+
65
+ import { SimpleMarkdownRenderer } from '../markdown'
66
+ import { RichMarkdownRenderer } from '../markdown'
67
+ import { remarkCardLinks } from '../../chat/remark-card-links'
68
+ import { remarkMentionChips } from '../../chat/remark-mention-chips'
69
+ import { extractSections } from '../../../utils/markdown-section-extractor'
70
+ import { scanHeadings } from '../../../utils/markdown-heading-id'
71
+ import {
72
+ __buildCloserHaystackForTest,
73
+ __findInlineCodeRangesForTest,
74
+ escapeUnknownHtmlTags,
75
+ } from '../markdown/sanitize'
76
+ import { splitStreamingBlocks } from '../markdown/streaming'
77
+ import { isBlankLine } from '../../../utils/markdown-fences'
78
+ import { unified } from 'unified'
79
+ import remarkParse from 'remark-parse'
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Fixture corpus (per migration plan §D1 parity verification)
83
+ // ---------------------------------------------------------------------------
84
+ const SHARED_FIXTURES: Record<string, string> = {
85
+ 'gfm-table': `
86
+ | Col A | Col B |
87
+ |-------|-------|
88
+ | a1 | b1 |
89
+ | a2 | b2 |
90
+ `,
91
+ 'task-list': `
92
+ - [x] done item
93
+ - [ ] open item
94
+ `,
95
+ 'nested-lists': `
96
+ 1. first
97
+ - nested a
98
+ - nested b
99
+ 2. second
100
+ 1. sub one
101
+ 2. sub two
102
+ `,
103
+ 'loose-ordered-list': `
104
+ 1. first paragraph item
105
+
106
+ 2. second paragraph item
107
+
108
+ 3. third paragraph item
109
+ `,
110
+ 'fenced-code-js': '```js\nconst x = 1;\nconsole.log(x < 2 && x > 0);\n```',
111
+ 'fenced-code-unknown-lang': '```qwerty-lang\nsome text < with > angles\n```',
112
+ 'inline-code': 'Use `<their>` and `npm install` inline.',
113
+ mermaid: '```mermaid\ngraph TD;\nA-->B;\n```',
114
+ 'headings-with-emoji-and-dupes': `
115
+ # 🚀 Getting Started
116
+
117
+ ## Setup
118
+
119
+ ## Setup
120
+
121
+ ### Deep Dive 🔧
122
+
123
+ #### H4 level
124
+
125
+ ##### H5 level
126
+
127
+ ###### H6 level
128
+ `,
129
+ links: `
130
+ [external](https://example.com/page) and [anchor](#setup) and plain text.
131
+ `,
132
+ 'reference-style-link': `
133
+ See [the docs][ref] for details.
134
+
135
+ [ref]: https://example.com/reference
136
+ `,
137
+ images: `
138
+ ![alt text](https://example.com/pic.png)
139
+
140
+ ![](https://example.com/no-alt.png)
141
+ `,
142
+ 'empty-image': '![empty]()',
143
+ blockquote: `
144
+ > A quoted paragraph
145
+ > spanning two lines.
146
+ `,
147
+ 'blockquote-with-blank-line': `
148
+ > first quoted para
149
+ >
150
+ > second quoted para
151
+ `,
152
+ hr: 'above\n\n---\n\nbelow',
153
+ 'raw-html-safe': `
154
+ <details><summary>More</summary>
155
+
156
+ hidden body
157
+
158
+ </details>
159
+
160
+ Line<br>break and <kbd>Ctrl</kbd>+<mark>C</mark>.
161
+ `,
162
+ 'raw-html-unknown-tag': 'Share <their> settings and the <ticket> element.',
163
+ // The pre-pass must not escape tags the sanitizer keeps: `strike` lives in
164
+ // `defaultSchema.tagNames` but was absent from SAFE_HTML_TAGS, so authored
165
+ // legacy markup regressed into visible `&lt;strike&gt;` source text.
166
+ 'legacy-strike-tag': 'A <strike>struck</strike> phrase and <tt>teletype</tt>.',
167
+ // Round-2: `center`/`font`/`big` were in NONE of the three tag lists, so
168
+ // they escaped to visible source text even though both pre-unification
169
+ // renderers rendered them.
170
+ 'legacy-center-tag': '<center>centered</center>\n\n<font color="red" size="3">colored</font> and <big>big</big>.',
171
+ // Round-2 SECURITY: `title` (and `text`/`desc`/`g`/…) are SVG element names
172
+ // that are ALSO HTML elements. Unconstrained they let any post or chat
173
+ // message emit a live `<title>`, which React 19 hoists into <head> —
174
+ // rewriting the browser tab + SEO title. They are now pinned to an `svg`
175
+ // ancestor, so a bare one is dropped and only its text remains.
176
+ 'bare-title-tag-is-inert': '# Real Post\n\n<title>Buy cheap pills</title>\n\nbody',
177
+ // Round-3 SECURITY/CORRECTNESS: RAWTEXT tokenization runs BEFORE the
178
+ // sanitizer, so an UNCLOSED `<textarea>` / `<iframe>` / `<title>` swallows
179
+ // the whole rest of the message during parsing — the round-2 `ancestors`
180
+ // pin cannot help. Any message that merely MENTIONS these in prose (an LLM
181
+ // explaining HTML forms will) mangled everything after it. The text
182
+ // pre-pass now escapes unclosed openers; the heading after must survive.
183
+ 'unclosed-textarea-does-not-swallow': 'Hello\n\n<textarea>\n\n## After heading\n\nmore',
184
+ 'unclosed-iframe-does-not-swallow': 'Hello\n\n<iframe>\n\n## After heading\n\nmore',
185
+ 'unclosed-title-does-not-swallow': 'Hello\n\n<title>\n\n## After heading\n\nmore',
186
+ // Round-4 SECURITY: the round-3 closer test searched the RAW document, so a
187
+ // `</textarea>` that is only a CODE SAMPLE (fenced or inline) or only
188
+ // ATTRIBUTE TEXT satisfied "is closed later" and left the prose opener LIVE
189
+ // — parse5's RAWTEXT span then swallowed the rest of the message anyway.
190
+ // An LLM answer about HTML forms is exactly a prose mention plus a code
191
+ // sample, so the fix was one realistic message away from being bypassed.
192
+ // The closer search now runs over a length-preserving MASKED copy (code
193
+ // spans + attribute regions blanked), built from the SAME regex that drives
194
+ // the escaping carve. In all three the opener must escape and the heading
195
+ // after it must survive as a real <h1>.
196
+ 'rawtext-closer-in-fence-does-not-unescape':
197
+ '<textarea>\n\n# Heading\n\npara2\n\n```html\n</textarea>\n```',
198
+ 'rawtext-closer-in-inline-code-does-not-unescape':
199
+ '<textarea>\n\n# Heading\n\npara2 with `</textarea>` inline',
200
+ 'rawtext-closer-in-attribute-does-not-unescape':
201
+ '<textarea>\n\n# Heading\n\n<div title="</textarea>">x</div>',
202
+ 'rawtext-title-closer-in-fence-does-not-unescape':
203
+ '<title>\n\n# Heading\n\n```html\n</title>\n```',
204
+ // Round-5 SECURITY: the round-4 mask claimed every index matched the
205
+ // original string, but it started with `text.toLowerCase()` — and
206
+ // `toLowerCase()` EXPANDS U+0130 (Turkish dotted capital `İ`, ordinary
207
+ // prose: İstanbul, İzmir) into `i` + U+0307, 1 code unit → 2. Each one
208
+ // before an opener shifted the haystack later than the offset the caller
209
+ // computes from the ORIGINAL text, so `hasLaterCloser` started scanning
210
+ // BEFORE the opener, matched the already-consumed `</textarea>`, and left
211
+ // the prose opener LIVE — the RAWTEXT swallow, reopened. At n≤20 the opener
212
+ // escaped correctly; at n≥25 the heading and list below became the editable
213
+ // value of a live textarea. The mask now folds ASCII only (tag names are
214
+ // ASCII by definition); the length invariant below is the real guard.
215
+ 'rawtext-mask-survives-turkish-dotted-i':
216
+ `${'İ'.repeat(25)} <textarea>a</textarea>\n\n<textarea>\n\n## Later heading\n\n- item one\n`,
217
+ // Round-5 LOGIC: the escape pre-pass used to run BEFORE the streaming tail
218
+ // was completed. Mid-stream every partially-emitted fence is unclosed, and
219
+ // both fence notions in the pre-pass only recognize CLOSED fences — so a
220
+ // `</textarea>` inside the still-open fence satisfied "is closed later" and
221
+ // the prose opener above stayed live. The engine now completes the tail
222
+ // first; this AUTHORED (never-closed) shape is covered by the mask's
223
+ // `blankUnclosedFence` pass, and the streaming shape by the test below.
224
+ 'rawtext-closer-in-unclosed-fence-does-not-unescape':
225
+ 'Explaining <textarea> in HTML.\n\n## Heading\n\n```html\n</textarea>\n',
226
+ // Round-6 SECURITY: the mask understood ONLY column-0..3 ```/~~~ fences,
227
+ // inline code and attribute runs — so a `</textarea>` in ANY other ordinary
228
+ // form of code satisfied "is closed later" and the prose opener stayed live.
229
+ // All five shapes below were reproduced end-to-end as a live editable
230
+ // textarea containing the rest of the message. The mask's block-level code
231
+ // regions are now derived from `createFenceTracker` (plus indented,
232
+ // blockquoted and commented code) instead of from a flat regex.
233
+ //
234
+ // (a) a blockquoted fence — an utterly ordinary chat answer.
235
+ 'rawtext-closer-in-blockquoted-fence-does-not-unescape':
236
+ 'The <textarea> element is a form control whose closing tag matters.\n\n> ```html\n> <textarea name="bio">hello</textarea>\n> ```\n\n## After heading\n',
237
+ // (b) an indented (4-space / tab) code block. `FENCE_RE` caps fence indent at
238
+ // 3 spaces by design, so the tracker never sees these lines at all.
239
+ 'rawtext-closer-in-indented-code-does-not-unescape':
240
+ 'Explaining <textarea> in prose.\n\n## After heading\n\n </textarea>\n\ntail\n',
241
+ 'rawtext-closer-in-tab-indented-code-does-not-unescape':
242
+ 'Explaining <textarea> in prose.\n\n## After heading\n\n\t</textarea>\n\ntail\n',
243
+ // (c) a fence indented into a list-item content column.
244
+ 'rawtext-closer-in-list-indented-fence-does-not-unescape':
245
+ 'Explaining <textarea> in prose.\n\n## After heading\n\n- item\n\n ```html\n </textarea>\n ```\n\ntail\n',
246
+ // (c2) Round-17 SECURITY: the SAME fence, one container deeper — a list item
247
+ // INSIDE A BLOCKQUOTE. `blankQuotedCode`'s flush ran the fence + indented
248
+ // passes over the quote-stripped run but NOT `blankListItemCode`, while
249
+ // `blankListItemCode` DID call `blankQuotedCode`; that asymmetry was the
250
+ // hole. `FENCE_RE` caps fence indent at 3 ABSOLUTE columns, so at a
251
+ // quote-relative content column of 4 the nested tracker misses the fence,
252
+ // and `blankIndentedCode`'s list-aware threshold (`contentCol + 4` = 8)
253
+ // does not reach it either — the fence was seen by NO pass. Reproduced
254
+ // live: one editable `<textarea>` swallowing the quoted example, with
255
+ // `escapeUnknownHtmlTags` returning the input BYTE-IDENTICAL. The same
256
+ // shape reproduced for `<iframe>`, for `> - ` and `> -\t`, and at
257
+ // quote depth 2 — all covered by the container × container sweep below.
258
+ 'rawtext-closer-in-quoted-list-indented-fence-does-not-unescape':
259
+ 'The <textarea> element explained.\n\n> 1. Example:\n>\n> ```html\n> </textarea>\n> ```\n\n## After heading\n\nsecret tail\n',
260
+ // (c3) Round-18 SECURITY, the EIGHTH instance and the SHALLOWEST yet — ZERO
261
+ // nesting depth. `blankListItemCode` read the content-column stack BEFORE
262
+ // pushing the current line's own marker, so the MARKER LINE never entered
263
+ // a run and a fence opened ON it was invisible to every pass: `FENCE_RE`'s
264
+ // 3-column absolute cap misses it and `blankIndentedCode`'s
265
+ // `contentCol + 4` threshold overshoots it. The run then began AFTER the
266
+ // opener, so the item's CLOSING fence read as an `open` to the nested
267
+ // tracker, which blanked to EOF while leaving the code BODY live.
268
+ // `escapeUnknownHtmlTags` returned the input BYTE-IDENTICAL and the
269
+ // renderer emitted a live editable `<textarea>` swallowing the prose.
270
+ // Reproduced at every marker spelling and for `<iframe>`.
271
+ 'rawtext-closer-in-marker-line-fence-does-not-unescape':
272
+ 'The <textarea> element explained.\n\n- ```html\n </textarea>\n ```\n\n## After heading\n\nsecret tail\n',
273
+ 'rawtext-closer-in-marker-line-ordered-fence-does-not-unescape':
274
+ 'The <textarea> element explained.\n\n1. ```html\n </textarea>\n ```\n\n## After heading\n\nsecret tail\n',
275
+ 'rawtext-closer-in-marker-line-tab-fence-does-not-unescape':
276
+ 'The <textarea> element explained.\n\n-\t```html\n </textarea>\n ```\n\n## After heading\n\nsecret tail\n',
277
+ 'rawtext-closer-in-quoted-marker-line-fence-does-not-unescape':
278
+ 'The <textarea> element explained.\n\n> - ```html\n> </textarea>\n> ```\n\n## After heading\n\nsecret tail\n',
279
+ 'rawtext-iframe-closer-in-marker-line-fence-does-not-unescape':
280
+ 'The <iframe> element explained.\n\n- ```html\n </iframe>\n ```\n\n## After heading\n\nsecret tail\n',
281
+ // CONTROL for the shape above: the SAME fence one line LOWER (a continuation
282
+ // line) was always masked correctly. It isolates the defect to "a block opened
283
+ // ON the marker line", and it must keep working after the reorder.
284
+ 'rawtext-closer-in-continuation-line-fence-does-not-unescape':
285
+ 'The <textarea> element explained.\n\n- item\n\n ```html\n </textarea>\n ```\n\n## After heading\n\nsecret tail\n',
286
+ // Round-18 SECURITY: a CommonMark code span CROSSES LINE BREAKS, and neither
287
+ // the mask's inline scan nor `PROTECTED_SPAN_RE` did — both were strictly
288
+ // per-line. So `` `foo\n</textarea>` `` left its closer visible in the
289
+ // haystack, `hasLaterCloser` returned true and the prose opener stayed LIVE.
290
+ // This is the shape that is not a CONTAINER at all, so no container sweep
291
+ // could reach it; the scan unit is now the paragraph segment. The renderer
292
+ // emitting `<code>foo </textarea></code>` is the proof the closer is a sample.
293
+ 'rawtext-closer-in-multiline-code-span-does-not-unescape':
294
+ 'The <textarea> element explained.\n\nUse `foo\n</textarea>` today.\n\n## After heading\n\nsecret tail\n',
295
+ 'rawtext-iframe-closer-in-multiline-code-span-does-not-unescape':
296
+ 'The <iframe> element explained.\n\nUse `foo\n</iframe>` today.\n\n## After heading\n\nsecret tail\n',
297
+ // Round-18 SECURITY: remark consumes a LINK REFERENCE DEFINITION entirely and
298
+ // emits no node, so a `</textarea>` in its destination or title is not a real
299
+ // closer — but it survived into the haystack and kept the prose opener live
300
+ // (byte-identical output in both spellings).
301
+ 'rawtext-closer-in-link-definition-title-does-not-unescape':
302
+ 'The <textarea> element explained.\n\n[a]: /x "</textarea>"\n\n## After heading\n\nsecret tail\n',
303
+ 'rawtext-closer-in-link-definition-destination-does-not-unescape':
304
+ 'The <textarea> element explained.\n\n[a]: </textarea>\n\n## After heading\n\nsecret tail\n',
305
+ // ---------------------------------------------------------------------------
306
+ // Round-19 SECURITY. Five findings, all authored here rather than left to the
307
+ // sweep — the sweep is what MISSED them (see `markerLineListWrap`).
308
+ // ---------------------------------------------------------------------------
309
+ // (i) The NINTH instance: `blankListItemCode`'s `top >= 4` gate, justified by
310
+ // a table entry claiming "below column 4 the top-level passes already cover
311
+ // the line at the right column". True of a CONTINUATION line (absolute
312
+ // indent 2-3 is inside `FENCE_RE`'s cap) and FALSE of the MARKER line,
313
+ // which is examined only at column 0 where the leading `- ` / `1. ` is not
314
+ // whitespace. So at the two MOST COMMON marker spellings the marker line
315
+ // got no run at all. The fence is EOF-TERMINATED on purpose: the closed
316
+ // spelling is rescued only INCIDENTALLY by `findInlineCodeRanges` matching
317
+ // the two backtick runs, so the live hole is the unclosed one — the state
318
+ // every fence passes through mid-stream.
319
+ 'rawtext-closer-in-narrow-marker-line-fence-does-not-unescape':
320
+ 'The <textarea> element explained.\n\n- ```html\n </textarea>\n',
321
+ 'rawtext-closer-in-narrow-ordered-marker-line-fence-does-not-unescape':
322
+ 'The <textarea> element explained.\n\n1. ```html\n </textarea>\n',
323
+ 'rawtext-iframe-closer-in-narrow-marker-line-fence-does-not-unescape':
324
+ 'The <iframe> element explained.\n\n- ```html\n </iframe>\n',
325
+ // (ii) The TENTH instance: `LIST_MARKER_RE` matches only the FIRST marker on a
326
+ // line, so an INNER item's content column was never pushed and the fix
327
+ // above does not reach these. `blankListItemCode` now recurses into
328
+ // ITSELF on the stripped run. The link-definition spelling also falsifies
329
+ // the table's "absorbs … ONE list marker" claim.
330
+ 'rawtext-closer-in-nested-marker-line-fence-does-not-unescape':
331
+ 'The <textarea> element explained.\n\n- - ```html\n </textarea>\n',
332
+ 'rawtext-closer-in-nested-marker-line-link-definition-does-not-unescape':
333
+ 'The <textarea> element explained.\n\n- - [a]: /x "</textarea>"\n',
334
+ // (iii) A whole UNCOVERED SHELTER CLASS: the INLINE link/image destination and
335
+ // title. remark consumes them into href/title exactly as it consumes a
336
+ // reference definition's, so the closer is fake — but only the
337
+ // DEFINITION spelling had a pass. All eight spellings reproduced live.
338
+ 'rawtext-closer-in-inline-link-title-does-not-unescape':
339
+ 'The <textarea> element explained.\n\n[a](/x "</textarea>")\n\n## After heading\n\nsecret tail\n',
340
+ 'rawtext-closer-in-inline-link-single-quoted-title-does-not-unescape':
341
+ "The <textarea> element explained.\n\n[a](/x '</textarea>')\n\n## After heading\n\nsecret tail\n",
342
+ 'rawtext-closer-in-inline-link-paren-title-does-not-unescape':
343
+ 'The <textarea> element explained.\n\n[a](/x (</textarea>))\n\n## After heading\n\nsecret tail\n',
344
+ 'rawtext-closer-in-inline-image-title-does-not-unescape':
345
+ 'The <textarea> element explained.\n\n![a](/x "</textarea>")\n\n## After heading\n\nsecret tail\n',
346
+ 'rawtext-closer-in-inline-link-angle-destination-does-not-unescape':
347
+ 'The <textarea> element explained.\n\n[a](</textarea>)\n\n## After heading\n\nsecret tail\n',
348
+ 'rawtext-closer-in-quoted-inline-link-title-does-not-unescape':
349
+ 'The <textarea> element explained.\n\n> [a](/x "</textarea>")\n\n## After heading\n\nsecret tail\n',
350
+ 'rawtext-closer-in-listed-inline-link-title-does-not-unescape':
351
+ 'The <textarea> element explained.\n\n- [a](/x "</textarea>")\n\n## After heading\n\nsecret tail\n',
352
+ 'rawtext-closer-in-mid-paragraph-inline-link-title-does-not-unescape':
353
+ 'The <textarea> element explained.\n\nSee [a](/x "</textarea>") for more.\n\n## After heading\n\nsecret tail\n',
354
+ 'rawtext-iframe-closer-in-inline-link-title-does-not-unescape':
355
+ 'The <iframe> element explained.\n\n[a](/x "</iframe>")\n\n## After heading\n\nsecret tail\n',
356
+ // (iv) `blankLinkDefinitions` missed the MULTI-LINE definition spelling —
357
+ // CommonMark allows destination and/or title on FOLLOWING lines, and the
358
+ // continuation state only accepted a BARE QUOTED TITLE. The `[a]:` line
359
+ // blanked; the `destination + title` line stayed fully visible.
360
+ 'rawtext-closer-in-multiline-link-definition-does-not-unescape':
361
+ 'The <textarea> element explained.\n\n[a]:\n/x "</textarea>"\n\n## After heading\n\nsecret tail\n',
362
+ 'rawtext-closer-in-multiline-link-definition-destination-does-not-unescape':
363
+ 'The <textarea> element explained.\n\n[a]:\n</textarea>\n\n## After heading\n\nsecret tail\n',
364
+ // (v) ROUND 20 — the BRACKET half of the very class (iii) opened. Round 19
365
+ // wrote the generalization ("every region CommonMark turns into an
366
+ // ATTRIBUTE rather than document text is a shelter of the same kind") and
367
+ // then implemented it for the `(…)` payload only, on a rationale that is
368
+ // true of an INLINE LINK's `[…]` and false of every other bracket
369
+ // spelling: an image's alt is a string ATTRIBUTE, and a reference or
370
+ // footnote label is an IDENTIFIER remark never renders. All seven
371
+ // reproduced live in BOTH renderers (`escapeUnknownHtmlTags` byte-
372
+ // identical, a live `<textarea>` swallowing the rest of the paragraph).
373
+ 'rawtext-closer-in-inline-image-alt-does-not-unescape':
374
+ 'The <textarea> element explained.\n\n![</textarea>](/x)\n\n## After heading\n\nsecret tail\n',
375
+ 'rawtext-closer-in-reference-image-alt-does-not-unescape':
376
+ 'The <textarea> element explained.\n\n![</textarea>][r]\n\n[r]: /x\n\n## After heading\n\nsecret tail\n',
377
+ 'rawtext-closer-in-reference-label-does-not-unescape':
378
+ 'The <textarea> element explained.\n\n[a][</textarea>]\n\n[</textarea>]: /x\n\n## After heading\n\nsecret tail\n',
379
+ 'rawtext-closer-in-collapsed-reference-label-does-not-unescape':
380
+ 'The <textarea> element explained.\n\n[</textarea>][]\n\n[</textarea>]: /x\n\n## After heading\n\nsecret tail\n',
381
+ 'rawtext-closer-in-footnote-label-does-not-unescape':
382
+ 'The <textarea> element explained.\n\nSee[^</textarea>]\n\n[^</textarea>]: note\n\n## After heading\n\nsecret tail\n',
383
+ 'rawtext-closer-in-quoted-image-alt-does-not-unescape':
384
+ 'The <textarea> element explained.\n\n> ![</textarea>](/x)\n\n## After heading\n\nsecret tail\n',
385
+ 'rawtext-closer-in-listed-image-alt-does-not-unescape':
386
+ 'The <textarea> element explained.\n\n- ![</textarea>](/x)\n\n## After heading\n\nsecret tail\n',
387
+ 'rawtext-iframe-closer-in-inline-image-alt-does-not-unescape':
388
+ 'The <iframe> element explained.\n\n![</iframe>](/x)\n\n## After heading\n\nsecret tail\n',
389
+ // (vi) ROUND 21 — the SAME pass, the SAME round, the OTHER half. Round 20
390
+ // converted this pass's LENGTH caps to blank-through and left its SHAPE
391
+ // regexes as fail-open declines. Two dimensions were uncovered:
392
+ //
393
+ // (a) BACKSLASH-BLINDNESS. `LINK_DEF_TITLE` / `LINK_DEF_DEST` and the
394
+ // label class `[^\]\n]*` stop at the FIRST delimiter, escaped or
395
+ // not, while CommonMark permits `\"` in a `"…"` title, `\'` in
396
+ // `'…'`, `\)` in `(…)` and `\]` in a label. The class stopped early,
397
+ // the full-line anchor then failed, and the line stayed VISIBLE —
398
+ // while remark still consumed the definition and emitted NOTHING.
399
+ // The unescaped CONTROL (`rawtext-closer-in-link-definition-title-…`
400
+ // above) blanks correctly, which is what makes the ESCAPE the cause.
401
+ 'rawtext-closer-in-escaped-quote-definition-title-does-not-unescape':
402
+ 'The <textarea> element explained.\n\n[a]: /x "a\\"</textarea>"\n\n## After heading\n\nsecret tail\n',
403
+ 'rawtext-closer-in-escaped-apostrophe-definition-title-does-not-unescape':
404
+ "The <textarea> element explained.\n\n[a]: /x 'it\\'s </textarea>'\n\n## After heading\n\nsecret tail\n",
405
+ 'rawtext-closer-in-escaped-paren-definition-title-does-not-unescape':
406
+ 'The <textarea> element explained.\n\n[a]: /x (a\\)</textarea>)\n\n## After heading\n\nsecret tail\n',
407
+ 'rawtext-closer-in-escaped-bracket-definition-label-does-not-unescape':
408
+ 'The <textarea> element explained.\n\n[a\\]b]: /x "</textarea>"\n\n## After heading\n\nsecret tail\n',
409
+ 'rawtext-closer-in-escaped-bracket-definition-destination-does-not-unescape':
410
+ 'The <textarea> element explained.\n\n[a\\]b]: </textarea>\n\n## After heading\n\nsecret tail\n',
411
+ // (b) A LABEL or TITLE that opens on one line and closes on a LATER one
412
+ // was examined by NOBODY. The `'none' | 'needDest' | 'needTitle'`
413
+ // state modelled continuation only AFTER the `]:`, and
414
+ // `blankBracketLabels` deliberately excludes a bare `[…]`, so the
415
+ // multi-line label had no pass at all.
416
+ 'rawtext-closer-in-multiline-definition-label-does-not-unescape':
417
+ 'The <textarea> element explained.\n\n[foo\n</textarea>]: /x\n\n## After heading\n\nsecret tail\n',
418
+ 'rawtext-closer-in-multiline-definition-label-first-line-does-not-unescape':
419
+ 'The <textarea> element explained.\n\n[</textarea>\nfoo]: /x\n\n## After heading\n\nsecret tail\n',
420
+ 'rawtext-closer-in-three-line-definition-label-does-not-unescape':
421
+ 'The <textarea> element explained.\n\n[foo\n</textarea>\nbar]: /x\n\n## After heading\n\nsecret tail\n',
422
+ 'rawtext-closer-in-quoted-multiline-definition-label-does-not-unescape':
423
+ 'The <textarea> element explained.\n\n> [foo\n> </textarea>]: /x\n\n## After heading\n\nsecret tail\n',
424
+ 'rawtext-closer-in-multiline-definition-title-does-not-unescape':
425
+ 'The <textarea> element explained.\n\n[a]: /x "line1\n</textarea>"\n\n## After heading\n\nsecret tail\n',
426
+ 'rawtext-iframe-closer-in-multiline-definition-label-does-not-unescape':
427
+ 'The <iframe> element explained.\n\n[foo\n</iframe>]: /x\n\n## After heading\n\nsecret tail\n',
428
+ // (d) an HTML comment. parse5 consumes it as comment data; it is not a closer.
429
+ 'rawtext-closer-in-html-comment-does-not-unescape':
430
+ 'Explaining <textarea> in prose.\n\n## After heading\n\n<!-- </textarea> -->\n\ntail\n',
431
+ // (e) an INFO-STRING closer. CommonMark forbids an info string on a closing
432
+ // fence, but `PROTECTED_SPAN_RE`'s closer alternative allowed `[^\n]*` —
433
+ // so the masked span ended at the ```html line and the real code content
434
+ // after it went unmasked. `createFenceTracker` gets this right.
435
+ 'rawtext-closer-after-info-string-closer-does-not-unescape':
436
+ 'Explaining <textarea> in prose.\n\n## After heading\n\n```js\nx\n```html\n</textarea>\n```\n\ntail\n',
437
+ // Round-7 SECURITY (the class-closing one): the carve (`PROTECTED_SPAN_RE`)
438
+ // and the mask (tracker-derived) run DIFFERENT engines, so the carve can
439
+ // protect a region the mask correctly blanked — and a protected span is
440
+ // pushed through VERBATIM, so a live RAWTEXT opener inside it never reaches
441
+ // `escapeOutsideFences` at all. Here the regex's closer alternative accepts an
442
+ // info string, so its first span ends early at the ```js line, it desyncs, and
443
+ // it opens a NEW span at the `~~~` line (ordinary text to CommonMark) that
444
+ // runs past remark's real closer and shelters the `<textarea>`. parse5 then
445
+ // swallowed `hello`, `~~~` and `after`. The carve is now the INTERSECTION of
446
+ // carve and mask, so over-detection by EITHER engine can only cause escaping.
447
+ 'mismatched-fence-carve-does-not-shelter-opener':
448
+ '```\n```js\n~~~\n```\n<textarea>\nhello\n~~~\nafter\n',
449
+ // Round-7 REGRESSION: `blankComments` scanned the UNMASKED copy, so a `<!--`
450
+ // written INSIDE code blanked everything after it to EOF. Every other pass
451
+ // scans the unmasked copy on purpose (`INLINE_CODE_RE` would corrupt a fence
452
+ // scan), but for comments that reasoning inverts: a `<!--` inside a code
453
+ // region is not a comment start. Both shapes rendered a REAL `<textarea>`
454
+ // before the round-6 mask work, and must keep doing so.
455
+ 'comment-marker-in-inline-code-does-not-blank-to-eof':
456
+ 'Use `<!--` to start a comment.\n\n<textarea>hi</textarea>\n',
457
+ 'truncated-comment-in-fence-does-not-blank-to-eof':
458
+ '```html\n<!-- todo\n```\n\n<textarea>a</textarea>\n',
459
+ // Round-7: `blankIndentedCode` applied a flat 4-space threshold, so a LIST
460
+ // ITEM's continuation paragraph (content indent 4 under `1. `, 2 under `- `)
461
+ // was blanked as code — the closer vanished from the haystack and the opener
462
+ // escaped. Under CommonMark these are paragraph lines and the element is real.
463
+ 'list-continuation-textarea-stays-live-ordered':
464
+ '1. Here is a form:\n\n <textarea>\n </textarea>\n',
465
+ 'list-continuation-textarea-stays-live-bullet': '- item\n\n <textarea>x</textarea>\n',
466
+ // Round-9 SECURITY (the residual fail-open of the round-7 intersection
467
+ // guard): the guard only reconciles DISAGREEMENT between carve and mask.
468
+ // When BOTH engines over-detect the SAME region it is a no-op —
469
+ // `isMaskedBlank` is true, the span is pushed VERBATIM, and a live RAWTEXT
470
+ // opener inside it never reaches `escapeOutsideFences`. CommonMark says an
471
+ // HTML block (type 1 `<pre>`/`<details>`, type 6 `<div>`) runs to its
472
+ // terminator, so a ``` line inside one is HTML CONTENT, not a fence — and
473
+ // neither engine models HTML blocks. Both opened a bogus fence at the same
474
+ // line, so `<textarea>` was sheltered and the rest of the message became its
475
+ // editable value. The carve now additionally requires that a protected span
476
+ // contain no UNBALANCED RAWTEXT opener (a span is by definition
477
+ // self-contained code, so any opener inside must be balanced within it).
478
+ 'rawtext-opener-in-html-block-div-not-sheltered':
479
+ 'intro\n\n<div>\n```\n<textarea>\n```\n</div>\n\n## After heading\n\nsecret tail\n',
480
+ 'rawtext-opener-in-html-block-pre-not-sheltered':
481
+ 'intro\n\n<pre>\n```\n<textarea>\n```\n</pre>\n\n## After heading\n\nsecret tail\n',
482
+ 'rawtext-opener-in-html-block-details-not-sheltered':
483
+ 'intro\n\n<details>\n```\n<textarea>\n```\n</details>\n\n## After heading\n\nsecret tail\n',
484
+ // Round-12 SECURITY (the round-9 hole, reopened through INLINE CODE): round 11
485
+ // scoped the balance guard to the FENCE alternative on the reasoning that an
486
+ // inline span "can shelter nothing, because remark emits it as an `inlineCode`
487
+ // TEXT node". That invariant is exactly what fails INSIDE AN HTML BLOCK, where
488
+ // remark emits raw HTML and backticks are not code at all — so `` `<textarea>` ``
489
+ // on its own line inside `<div>` / `<pre>` / `<details>` was pushed VERBATIM
490
+ // and swallowed the rest of the message (reproduced end-to-end: textarea 1,
491
+ // `h2` undefined). The guard is now gated on HTML-BLOCK MEMBERSHIP rather than
492
+ // on the span's flavor, which covers both spellings with one property.
493
+ 'rawtext-opener-in-inline-code-in-html-block-div-not-sheltered':
494
+ 'intro\n\n<div>\n`<textarea>`\n</div>\n\n## After heading\n\nsecret tail\n',
495
+ 'rawtext-opener-in-inline-code-in-html-block-pre-not-sheltered':
496
+ 'intro\n\n<pre>\n`<textarea>`\n</pre>\n\n## After heading\n\nsecret tail\n',
497
+ 'rawtext-opener-in-inline-code-in-html-block-details-not-sheltered':
498
+ 'intro\n\n<details>\n`<textarea>`\n</details>\n\n## After heading\n\nsecret tail\n',
499
+ // …and the type-7 start condition (a complete tag ALONE on a line, tag name
500
+ // outside the type-1/type-6 lists) is the same hole with a different opener.
501
+ 'rawtext-opener-in-inline-code-in-html-block-span-not-sheltered':
502
+ 'intro\n\n<span>\n`<textarea>`\n</span>\n\n## After heading\n\nsecret tail\n',
503
+ // Round-15 SECURITY: the SAME shelter, reopened by ONE INVISIBLE CHARACTER.
504
+ // `computeHtmlBlockRanges` decided "blank line" (which ends a type-6/7 block)
505
+ // with `String.trim()`, and JS `trim()` strips the whole Unicode White_Space
506
+ // set — U+00A0 NBSP, U+000B VT, U+000C FF, U+FEFF BOM — while CommonMark's
507
+ // blank line is spaces and tabs ONLY. So a filler line holding just an NBSP
508
+ // terminated the TRACKED range while remark kept the block open: the
509
+ // inline-code shelter below it was no longer "inside a tracked block", the
510
+ // balance guard went blind, and `escapeUnknownHtmlTags` returned the input
511
+ // BYTE-IDENTICAL with a LIVE `<textarea>` in the DOM swallowing the tail.
512
+ // Fixed by `isBlankLine` (utils/markdown-fences). The whitespace-spelling
513
+ // DIMENSION is swept over the whole corpus below (`EXOTIC_BLANK_WRAPPERS`).
514
+ 'rawtext-opener-in-nbsp-filler-html-block-not-sheltered':
515
+ '<div>\n \n`<textarea>`\n\nrest of the answer\n\n## After heading\n\nsecret tail\n',
516
+ 'rawtext-iframe-in-nbsp-filler-html-block-not-sheltered':
517
+ '<div>\n \n`<iframe>`\n\nrest of the answer\n',
518
+ // Round-13 SECURITY (the same hole, third spelling): `computeHtmlBlockRanges`
519
+ // matched every start/end condition against the RAW line, anchored `^ {0,3}<`.
520
+ // Inside a CONTAINER (blockquote / list item) the line begins with the
521
+ // container marker, so NO start condition ever fired — yet CommonMark opens
522
+ // the type-6 block INSIDE the container and every following line is HTML
523
+ // content. `spanInsideHtmlBlock` therefore returned false, the balance guard
524
+ // did not fire, and `` `<textarea>` `` was pushed through VERBATIM:
525
+ // `escapeUnknownHtmlTags` returned the input BYTE-IDENTICAL and the heading +
526
+ // tail below became the editable value of a live textarea. Reproduced across
527
+ // the whole cross-product (`>` depth 1 and 2, `-` and `1.` items, div /
528
+ // details shelters, textarea / title / iframe openers). The walk now strips
529
+ // the container prefix before matching.
530
+ 'rawtext-opener-in-inline-code-in-blockquoted-html-block-not-sheltered':
531
+ '> intro\n>\n> <div>\n> `<textarea>`\n> </div>\n>\n> ## After heading\n>\n> secret tail\n',
532
+ 'rawtext-opener-in-inline-code-in-nested-blockquoted-html-block-not-sheltered':
533
+ '> > intro\n> >\n> > <details>\n> > `<textarea>`\n> > </details>\n> >\n> > ## After heading\n> >\n> > secret tail\n',
534
+ 'rawtext-opener-in-inline-code-in-list-item-html-block-not-sheltered':
535
+ '- intro\n\n <div>\n `<textarea>`\n </div>\n\n ## After heading\n\n secret tail\n',
536
+ 'rawtext-opener-in-inline-code-in-ordered-item-html-block-not-sheltered':
537
+ '1. intro\n\n <div>\n `<textarea>`\n </div>\n\n ## After heading\n\n secret tail\n',
538
+ // …and the same shape with an `<iframe>` opener, which puts a LIVE iframe in
539
+ // the DOM rather than merely swallowing the tail.
540
+ 'rawtext-iframe-in-inline-code-in-blockquoted-html-block-not-sheltered':
541
+ '> intro\n>\n> <div>\n> `<iframe>`\n> </div>\n>\n> ## After heading\n>\n> secret tail\n',
542
+ // Round-14 SECURITY (the same hole, FOURTH spelling — TAB COLUMNS). The
543
+ // container walk measured the list content column in CHARACTERS, but
544
+ // CommonMark measures COLUMNS and a tab advances to the next multiple of 4.
545
+ // `-\t-\t` is 4 characters and 8 columns, so a continuation line indented 8
546
+ // spaces was stripped by only 4 and still looked indented by 4 — every
547
+ // `^ {0,3}<\u2026` start condition missed, no range was recorded,
548
+ // `spanInsideHtmlBlock` returned false, the balance guard never fired, and the
549
+ // protected span was pushed through VERBATIM (`escapeUnknownHtmlTags`
550
+ // returned the input BYTE-IDENTICAL). Reproduced in BOTH renderers at 2 and 3
551
+ // markers, with the `<textarea>` spelling SWALLOWING the tail and the
552
+ // `<iframe>` spelling putting a LIVE `<iframe src=\u2026>` in the DOM.
553
+ 'rawtext-opener-in-inline-code-in-tab-list-item-html-block-not-sheltered':
554
+ '-\t-\tintro\n <div>\n `<textarea>`\n more\n\n## After heading\n\nsecret tail\n',
555
+ 'rawtext-opener-in-inline-code-in-deep-tab-list-item-html-block-not-sheltered':
556
+ '-\t-\t-\tintro\n <div>\n `<textarea>`\n more\n\n## After heading\n\nsecret tail\n',
557
+ 'rawtext-iframe-in-inline-code-in-tab-list-item-html-block-not-sheltered':
558
+ '-\t-\tintro\n <div>\n `<iframe>`\n more\n\n## After heading\n\nsecret tail\n',
559
+ // Round-14 SECURITY (FIFTH spelling — a FOREIGN container's filler line).
560
+ // A type-6/7 block ends at the first blank line, and inside a container the
561
+ // container's own filler IS that blank line. The termination test used the
562
+ // FULLY-stripped line, and the strip consumes ANY container markers rather
563
+ // than the ones that were open \u2014 so a line holding a DIFFERENT container's
564
+ // opener (` >` inside a `- <div>` item, `> -` inside a `> <div>` quote)
565
+ // normalized to empty, read as blank, and ENDED the range early, re-opening
566
+ // the shelter. Under CommonMark that line is HTML content and the block
567
+ // continues. The control without the filler line renders 0, so the filler
568
+ // line is the entire difference.
569
+ 'rawtext-opener-after-foreign-filler-in-list-item-html-block-not-sheltered':
570
+ '- <div>\n >\n `<textarea>`\n\n## After heading\n\nsecret tail\n',
571
+ 'rawtext-opener-after-foreign-filler-in-blockquoted-html-block-not-sheltered':
572
+ '> <div>\n> -\n> `<textarea>`\n\n## After heading\n\nsecret tail\n',
573
+ // Round-12 REGRESSION (found independently by both reviewers): the round-11
574
+ // cap-overflow fallback `escapeLeftoverTagStarts` escaped ANY `<tag` start in
575
+ // a gap, including gaps the MASK already knows are code — and entity refs are
576
+ // not decoded inside code, so the reader saw the literal `&lt;`. Ordinary
577
+ // pseudo-code in an indented block is the common shape.
578
+ 'indented-code-angle-bracket-not-escaped': 'Para\n\n if a <b then\n done\n',
579
+ // Round-12 LATENT COUPLING (safe today, pinned so it stays that way): none of
580
+ // these are matched by `TAG_LIKE_REGEX` or `LEFTOVER_TAG_START_RE`, and none
581
+ // needs to be — CommonMark's open-tag production requires `/` to be
582
+ // IMMEDIATELY followed by `>`, so remark never emits an `html` node for them
583
+ // and parse5 never sees a start tag. That safety rests on remark's tag grammar
584
+ // continuing to agree with the sanitizer's, which is what this fixture pins.
585
+ 'rawtext-slash-not-immediately-before-gt-is-inert':
586
+ 'Hello\n\n<textarea/ >\n\n<textarea/foo>\n\n<textarea//>\n\n## After heading\n\n- item\n',
587
+ // Round-11 SECURITY (the one that defeated the ENTIRE swallow defense): HTML
588
+ // IGNORES the self-closing flag on non-void, non-foreign elements, so parse5
589
+ // tokenizes `<textarea/>` as a START tag and enters RAWTEXT exactly like
590
+ // `<textarea>`. Both RAWTEXT guards nevertheless keyed on `selfClose === ''`
591
+ // and treated the self-closed spelling as "not an opener", so
592
+ // `escapeUnknownHtmlTags` returned every input below COMPLETELY untouched —
593
+ // no `&lt;` anywhere. Reproduced end-to-end through `SimpleMarkdownRenderer`:
594
+ // `<textarea/>` rendered a live textarea and the `## After heading` below it
595
+ // stopped being a heading; the spaced form swallowed the list too. It also
596
+ // defeated the round-9 HTML-block fix, whose fixtures only ever used the
597
+ // non-self-closing spelling. `RAWTEXT_TAGS` has no void members, so nothing
598
+ // legitimate self-closes — both guards now count the self-closed form as an
599
+ // opener.
600
+ 'rawtext-self-closing-textarea-escaped':
601
+ 'Hello\n\n<textarea/>\n\n## After heading\n\n- item\n',
602
+ 'rawtext-self-closing-spaced-textarea-escaped':
603
+ 'Hello\n\n<textarea />\n\n## After heading\n\n- item\n',
604
+ 'rawtext-self-closing-iframe-escaped': 'Hello\n\n<iframe/>\n\n## After heading\n\n- item\n',
605
+ 'rawtext-self-closing-title-escaped': 'Hello\n\n<title/>\n\n## After heading\n\n- item\n',
606
+ 'rawtext-self-closing-textarea-inline-escaped':
607
+ 'Hello <textarea/> world\n\n## After heading\n',
608
+ 'rawtext-self-closing-opener-in-html-block-div-not-sheltered':
609
+ 'intro\n\n<div>\n```\n<textarea/>\n```\n</div>\n\n## After heading\n\nsecret tail\n',
610
+ // Round-11 REGRESSION (user-visible, introduced by round-9): the CARVE
611
+ // BALANCE GUARD was gated on BOTH `PROTECTED_SPAN_RE` alternatives, including
612
+ // INLINE CODE — where it can close no fail-open at all (remark emits an inline
613
+ // span as an `inlineCode` TEXT node, so parse5 never tokenizes it) and does
614
+ // real damage: entity refs are not recognized inside code spans, so the
615
+ // escaped `&lt;title&gt;` is shown to the reader LITERALLY. Naming a RAWTEXT
616
+ // tag in inline code is the single most common way a docs answer mentions one,
617
+ // and it never closes. The guard is now scoped to the FENCE alternative.
618
+ 'inline-code-rawtext-title-not-escaped': 'Use the `<title>` element for page titles.',
619
+ 'inline-code-rawtext-textarea-not-escaped': 'The `<textarea>` element is a form control.',
620
+ // Round-9 REGRESSION (introduced by round-7's list-aware indent threshold):
621
+ // `blankIndentedCode` walked lines built from the UNMASKED copy, so a `- x`
622
+ // line INSIDE a fence still pushed a list content column. A later top-level
623
+ // indented-code line at column 4 then failed `indent >= top + 4`, was NOT
624
+ // blanked, and its code-sample `</textarea>` satisfied `hasLaterCloser` —
625
+ // leaving the prose opener LIVE. The walk now re-derives its lines from the
626
+ // CURRENT mask, so fence-blanked lines are all-spaces and push nothing.
627
+ 'list-marker-inside-fence-does-not-shift-indent-columns':
628
+ 'A <textarea>\n\n```\n- x\n ```\n\n </textarea>\n',
629
+ 'list-marker-inside-fence-html-block-combo':
630
+ '<div>\n<textarea>\n</div>\n\n```\n- x\n ```\n\n </textarea>\n\n# Heading after\n\nBody after.\n',
631
+ // Round-9 CORRECTNESS: CommonMark clamps a list item's content column to
632
+ // `markerEnd + 1` when the first block starts MORE than 4 spaces after the
633
+ // marker (the remainder is indented code INSIDE the item). The walk took the
634
+ // literal column, so under `-` + six spaces a column-7 indented-code line
635
+ // was not blanked and its `</textarea>` kept the prose opener live.
636
+ 'wide-list-marker-gap-clamps-content-column':
637
+ 'Explaining <textarea> in HTML.\n\n- foo\n\n </textarea>\n\n## After heading\n',
638
+ // Round-6 CARVE TRADEOFF (pinned, not fixed): the mask's tracker-derived
639
+ // regions are deliberately NOT fed to the escaping carve — the tracker
640
+ // OVER-detects a ``` line inside an open raw-HTML block, which in the carve
641
+ // means "not escaped", a fail-OPEN. So an AUTHORED EOF-terminated fence body
642
+ // is still unprotected and its `<their>` renders as escaped source text. That
643
+ // is cosmetic; the alternative is a swallow. The snapshot makes it visible.
644
+ 'unclosed-fence-body-renders-escaped': 'intro\n\n```html\n<their>x</their>\n',
645
+ // Round-4 POLISH: the escaping carve's fence notion was naive triple
646
+ // backticks, so a `~~~`-fenced block's `<textarea>` was NOT protected and
647
+ // rendered as visible escaped text inside the code block.
648
+ 'tilde-fence-protects-html-sample': 'intro\n\n~~~html\n<textarea>sample</textarea>\n~~~',
649
+ // …and the PROPERLY CLOSED forms must still render as real elements.
650
+ 'closed-textarea-still-renders': 'Hello\n\n<textarea>edit me</textarea>\n\n## After heading\n\nmore',
651
+ 'closed-iframe-still-renders':
652
+ 'Hello\n\n<iframe src="https://example.com/frame" width="300"></iframe>\n\n## After heading\n\nmore',
653
+ // …while the legitimate in-SVG accessible-name element still renders.
654
+ 'svg-title-a11y-label':
655
+ '<svg viewBox="0 0 24 24" role="img"><title>Coverage chart</title><desc>Four quarters</desc><circle cx="12" cy="12" r="10"/></svg>',
656
+ // Round-2: SVG presentation attributes must survive with their hast
657
+ // (camelCase) spellings — `font-size`/`text-anchor`/`stroke-dasharray`/
658
+ // `style` were all being silently stripped.
659
+ 'inline-svg-presentation-attrs':
660
+ '<svg viewBox="0 0 40 20"><rect x="0" y="0" width="40" height="20" fill="none" stroke-dasharray="2 2" style="opacity:0.5"/><text x="20" y="12" font-size="9" text-anchor="middle" dominant-baseline="middle">42</text></svg>',
661
+ // Round-2: `required.input` force-rewrote EVERY authored input into a
662
+ // disabled checkbox. Now a text input degrades to a bare <input> and the
663
+ // GFM task-list contract (see `task-list`) is unaffected.
664
+ 'authored-text-input': '<form><input type="text" placeholder="email"><button>go</button></form>',
665
+ // Inline SVG renders in published posts; the pre-unification Rich renderer
666
+ // had no pre-pass and no sanitizer, so it always survived.
667
+ 'inline-svg': '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/><path d="M8 12l3 3 5-6"/></svg>',
668
+ 'hostile-script': 'before\n\n<script>alert(1)</script>\n\nafter',
669
+ 'hostile-onerror': '<img src="https://example.com/x.png" onerror="alert(1)">',
670
+ 'hostile-js-href': '<a href="javascript:alert(1)">click</a>',
671
+ 'hostile-srcset': '<img src="https://safe.example/a.png" srcset="https://safe.example/a.png 1x, javascript:alert(1) 2x">',
672
+ 'hostile-iframe-srcdoc': '<iframe srcdoc="<script>alert(1)</script>" src="https://example.com/frame"></iframe>',
673
+ 'hostile-style-block': 'text\n\n<style>body{display:none}</style>\n\nmore',
674
+ }
675
+
676
+ const RICH_ONLY_FIXTURES: Record<string, string> = {
677
+ 'shortcode-youtube': 'Intro\n\n{{youtube:abc123XYZ_-}}\n\nOutro',
678
+ 'shortcode-youtube-markdoc': '{% youtube id="vid42" /%}',
679
+ 'shortcode-youtube-thumbnail-link':
680
+ '[![Demo](https://img.youtube.com/vi/awc-yAnkhIo/maxresdefault.jpg)](https://www.youtube.com/watch?v=awc-yAnkhIo)',
681
+ 'shortcode-reddit': '{{reddit:https://reddit.com/r/msp/comments/1/post}}',
682
+ 'shortcode-tweet': '{{tweet:https://twitter.com/user/status/12345}}',
683
+ 'shortcode-figma': '{{figma:https://www.figma.com/design/abc/My-File}}',
684
+ 'shortcode-linkedin': '{{linkedin:https://www.linkedin.com/posts/someone_activity-1}}',
685
+ 'shortcode-link-preview': '{{link:https://example.com/article}}',
686
+ 'auto-url-youtube': 'watch this https://www.youtube.com/watch?v=dQw4w9WgXcQ now',
687
+ 'auto-url-reddit': 'thread https://www.reddit.com/r/msp/comments/9/thread here',
688
+ 'auto-url-tweet': 'see https://x.com/user/status/999888 today',
689
+ 'auto-url-figma': 'design https://www.figma.com/proto/xyz/Proto-File end',
690
+ 'auto-url-generic-link-preview': 'read https://blog.example.com/post-1 tonight',
691
+ 'auto-url-inside-md-link-not-embedded': 'already [a link](https://blog.example.com/post-2) inline',
692
+ 'auto-url-inside-code-not-embedded': 'code `https://blog.example.com/post-3` inline\n\n```\nhttps://blog.example.com/post-4\n```',
693
+ 'auto-url-inside-table-not-embedded': `
694
+ | Site |
695
+ |------|
696
+ | https://blog.example.com/post-5 |
697
+ `,
698
+ 'fence-youtube-embed': '```youtube-embed\nvidFENCE1\n```',
699
+ 'fence-reddit-embed': '```reddit-embed\nhttps://reddit.com/r/x/comments/2/y\n```',
700
+ 'fence-tweet-embed': '```tweet-embed\nhttps://twitter.com/a/status/777\n```',
701
+ 'fence-link-preview': '```link-preview\nhttps://example.com/fenced\n```',
702
+ 'fence-figma-embed': '```figma-embed\nhttps://www.figma.com/deck/q/Deck\n```',
703
+ 'fence-linkedin-embed': '```linkedin-embed\nhttps://www.linkedin.com/posts/p_activity-2\n```',
704
+ 'raw-video-tag': '<video src="https://stream.example.com/v.mp4" poster="https://example.com/p.jpg" controls class="w-full my-8 rounded-lg"></video>',
705
+ // 2026-07 content-store audit: real blog posts carry inline style attrs
706
+ // (div.takeaway, table styling) — must survive the sanitize schema.
707
+ 'audit-style-attrs': '<div class="takeaway" style="padding:8px">Key point</div>\n\n<p style="color:red">styled para</p>',
708
+ // 2026-07 content-store audit: 58 posts use Reddit's own embed markup
709
+ // (script stripped by sanitize; blockquote rehydrated to RedditEmbedClient).
710
+ 'audit-reddit-embed-bq':
711
+ '<blockquote class="reddit-embed-bq" data-embed-height="500"><p>Post title</p><a href="https://www.reddit.com/r/msp/comments/abc/post/">view</a></blockquote>\n<script async src="https://embed.reddit.com/widgets.js" charset="UTF-8"></script>',
712
+ }
713
+
714
+ const CHAT_FIXTURES: Record<string, string> = {
715
+ 'card-marker': 'Here is the record: [card://blog:hello-world] inline.',
716
+ 'mention-chip': 'Check @device:router-9 status.',
717
+ 'card-and-mention-mixed': 'See [card://vendor:acme] and @ticket:T-100 together.',
718
+ }
719
+
720
+ const SECTION_IDS = [
721
+ { id: 'getting-started', title: '🚀 Getting Started', level: 1 },
722
+ { id: 'setup', title: 'Setup', level: 2 },
723
+ ]
724
+
725
+ // ---------------------------------------------------------------------------
726
+ // Helpers
727
+ // ---------------------------------------------------------------------------
728
+ async function renderStable(ui: React.ReactElement): Promise<HTMLElement> {
729
+ let container: HTMLElement
730
+ await act(async () => {
731
+ const result = render(ui)
732
+ container = result.container
733
+ })
734
+ // Settle the mermaid mount → dynamic-import → setSvg chain by polling for a
735
+ // STABLE DOM rather than spending a fixed number of ticks. How many turns
736
+ // `await import('mermaid')` needs is Node/vitest-version dependent, so the
737
+ // old two-macrotask budget snapshotted the LOADING SKELETON instead of the
738
+ // rendered diagram on newer Node (the `mermaid` fixture's golden mismatched
739
+ // on Node 25 while passing on 22). Two consecutive identical reads mean no
740
+ // further state update is queued.
741
+ let previous = ''
742
+ for (let i = 0; i < 20; i++) {
743
+ await act(async () => {
744
+ await new Promise((r) => setTimeout(r, 0))
745
+ })
746
+ const html = container!.innerHTML
747
+ if (html === previous) break
748
+ previous = html
749
+ }
750
+ return container!
751
+ }
752
+
753
+ function normalize(html: string): string {
754
+ // The mermaid <style> block is a constant — collapse whitespace only.
755
+ return html.replace(/\s+\n/g, '\n')
756
+ }
757
+
758
+ // ---------------------------------------------------------------------------
759
+ // SimpleMarkdownRenderer parity
760
+ // ---------------------------------------------------------------------------
761
+ describe('SimpleMarkdownRenderer golden parity', () => {
762
+ for (const [name, md] of Object.entries(SHARED_FIXTURES)) {
763
+ it(`renders fixture: ${name}`, async () => {
764
+ const container = await renderStable(<SimpleMarkdownRenderer content={md} />)
765
+ expect(normalize(container.innerHTML)).toMatchSnapshot()
766
+ })
767
+ }
768
+
769
+ it('renders headings with backend sectionIds + demoteMarkdownH1ToH2', async () => {
770
+ const container = await renderStable(
771
+ <SimpleMarkdownRenderer
772
+ content={SHARED_FIXTURES['headings-with-emoji-and-dupes']}
773
+ sectionIds={SECTION_IDS}
774
+ demoteMarkdownH1ToH2
775
+ />,
776
+ )
777
+ expect(normalize(container.innerHTML)).toMatchSnapshot()
778
+ })
779
+
780
+ it('renders broken links with badge', async () => {
781
+ const container = await renderStable(
782
+ <SimpleMarkdownRenderer
783
+ content={'[good](./ok.md) and [bad](./missing.md)'}
784
+ brokenLinks={['./missing.md']}
785
+ currentPath="docs/index.md"
786
+ onInternalLinkClick={() => {}}
787
+ />,
788
+ )
789
+ expect(normalize(container.innerHTML)).toMatchSnapshot()
790
+ })
791
+
792
+ it('renders each text-size preset', async () => {
793
+ for (const preset of ['default', 'compact', 'large'] as const) {
794
+ const container = await renderStable(
795
+ <SimpleMarkdownRenderer content={'# H\n\npara\n\n- li'} textSize={preset} />,
796
+ )
797
+ expect(normalize(container.innerHTML)).toMatchSnapshot(`textSize-${preset}`)
798
+ }
799
+ })
800
+
801
+ for (const [name, md] of Object.entries(CHAT_FIXTURES)) {
802
+ it(`renders chat fixture with card/mention plugins: ${name}`, async () => {
803
+ const container = await renderStable(
804
+ <SimpleMarkdownRenderer
805
+ content={md}
806
+ textSize="compact"
807
+ additionalRemarkPlugins={[remarkCardLinks, remarkMentionChips]}
808
+ />,
809
+ )
810
+ expect(normalize(container.innerHTML)).toMatchSnapshot()
811
+ })
812
+ }
813
+ })
814
+
815
+ // ---------------------------------------------------------------------------
816
+ // RAWTEXT mask invariants (round-5)
817
+ // ---------------------------------------------------------------------------
818
+ describe('closer-search mask', () => {
819
+ // THE invariant the whole RAWTEXT guard rests on. `escapeOutsideFences`
820
+ // computes `segmentOffset + index + match.length` from the ORIGINAL text and
821
+ // indexes into the mask with it, so any length drift makes `hasLaterCloser`
822
+ // search the wrong window — silently, and in the fail-OPEN direction.
823
+ it('is LENGTH-PRESERVING over the whole fixture corpus', () => {
824
+ for (const [name, md] of Object.entries({
825
+ ...SHARED_FIXTURES,
826
+ ...RICH_ONLY_FIXTURES,
827
+ ...CHAT_FIXTURES,
828
+ })) {
829
+ expect(__buildCloserHaystackForTest(md).length, name).toBe(md.length)
830
+ }
831
+ })
832
+
833
+ it('is LENGTH-PRESERVING for U+0130 (the one BMP char toLowerCase expands)', () => {
834
+ // `'İ'.toLowerCase()` is 'i' + U+0307 — 1 code unit becomes 2. This is the
835
+ // only such BMP character, and it is ordinary Turkish prose.
836
+ expect('İ'.toLowerCase().length).toBe(2)
837
+ for (const src of ['İ', 'İstanbul', `${'İ'.repeat(25)} <textarea>`, 'İ<TEXTAREA>İ</TEXTAREA>']) {
838
+ expect(__buildCloserHaystackForTest(src).length, JSON.stringify(src)).toBe(src.length)
839
+ }
840
+ })
841
+
842
+ it('still folds ASCII case, so an UPPERCASE closer is found', () => {
843
+ expect(__buildCloserHaystackForTest('<TEXTAREA>x</TEXTAREA>')).toContain('</textarea>')
844
+ })
845
+
846
+ // Round-6: every ordinary form of CODE must hide a `</textarea>` from the
847
+ // closer search. Each of these rendered a LIVE editable textarea containing
848
+ // the rest of the message before the mask became tracker-derived.
849
+ it.each([
850
+ 'rawtext-closer-in-blockquoted-fence-does-not-unescape',
851
+ 'rawtext-closer-in-indented-code-does-not-unescape',
852
+ 'rawtext-closer-in-tab-indented-code-does-not-unescape',
853
+ 'rawtext-closer-in-list-indented-fence-does-not-unescape',
854
+ 'rawtext-closer-in-quoted-list-indented-fence-does-not-unescape',
855
+ 'rawtext-closer-in-html-comment-does-not-unescape',
856
+ 'rawtext-closer-after-info-string-closer-does-not-unescape',
857
+ ])('escapes the prose opener when the only closer is code: %s', async (name) => {
858
+ const md = SHARED_FIXTURES[name]
859
+ const container = await renderStable(<SimpleMarkdownRenderer content={md} />)
860
+ expect(container.querySelectorAll('textarea').length, name).toBe(0)
861
+ // …and the document after the opener is still structured markdown.
862
+ expect(container.querySelector('h2')?.textContent, name).toBe('After heading')
863
+ })
864
+
865
+ // Round-7: the carve must never shelter an opener the mask blanked. The
866
+ // protected set is the INTERSECTION of carve and mask, so a span the mask
867
+ // disagrees with is routed through `escapeOutsideFences` instead of being
868
+ // pushed verbatim.
869
+ it('does not shelter a live opener inside a mismatched carve span', async () => {
870
+ const md = SHARED_FIXTURES['mismatched-fence-carve-does-not-shelter-opener']
871
+ const container = await renderStable(<SimpleMarkdownRenderer content={md} />)
872
+ expect(container.querySelectorAll('textarea').length).toBe(0)
873
+ // The text after the opener survives instead of becoming a textarea value.
874
+ expect(container.textContent).toContain('after')
875
+ })
876
+
877
+ // Round-9: BOTH engines over-detect a fence inside an HTML block, so the
878
+ // intersection guard is a no-op there. A protected span is self-contained
879
+ // code by definition, so an UNBALANCED RAWTEXT opener inside one means the
880
+ // span is not really code — route it through the escaper.
881
+ it.each([
882
+ 'rawtext-opener-in-html-block-div-not-sheltered',
883
+ 'rawtext-opener-in-html-block-pre-not-sheltered',
884
+ 'rawtext-opener-in-html-block-details-not-sheltered',
885
+ ])('does not shelter an opener inside an HTML block: %s', async (name) => {
886
+ const container = await renderStable(<SimpleMarkdownRenderer content={SHARED_FIXTURES[name]} />)
887
+ expect(container.querySelectorAll('textarea').length, name).toBe(0)
888
+ expect(container.querySelector('h2')?.textContent, name).toBe('After heading')
889
+ expect(container.textContent, name).toContain('secret tail')
890
+ })
891
+
892
+ // Round-12: the SAME hole, sheltered by INLINE CODE instead of a fence. Round
893
+ // 11 scoped the balance guard to fences because an inline span "cannot shelter
894
+ // anything" — true in ordinary prose, false inside an HTML block, where remark
895
+ // emits raw HTML and the backticks are not code. The guard is now gated on
896
+ // HTML-BLOCK MEMBERSHIP, which is the property BOTH spellings share.
897
+ it.each([
898
+ 'rawtext-opener-in-inline-code-in-html-block-div-not-sheltered',
899
+ 'rawtext-opener-in-inline-code-in-html-block-pre-not-sheltered',
900
+ 'rawtext-opener-in-inline-code-in-html-block-details-not-sheltered',
901
+ 'rawtext-opener-in-inline-code-in-html-block-span-not-sheltered',
902
+ ])('does not shelter an opener in INLINE CODE inside an HTML block: %s', async (name) => {
903
+ const container = await renderStable(<SimpleMarkdownRenderer content={SHARED_FIXTURES[name]} />)
904
+ expect(container.querySelectorAll('textarea').length, name).toBe(0)
905
+ expect(container.querySelector('h2')?.textContent, name).toBe('After heading')
906
+ expect(container.textContent, name).toContain('secret tail')
907
+ })
908
+
909
+ // Round-12: `/` not IMMEDIATELY followed by `>` is not a valid open tag under
910
+ // CommonMark, so remark emits no `html` node and neither sanitizer regex needs
911
+ // to match. Pinned because that safety is a coupling, not a local property.
912
+ it('is inert for a slash not immediately before the closing bracket', async () => {
913
+ const container = await renderStable(
914
+ <SimpleMarkdownRenderer
915
+ content={SHARED_FIXTURES['rawtext-slash-not-immediately-before-gt-is-inert']}
916
+ />,
917
+ )
918
+ expect(container.querySelectorAll('textarea').length).toBe(0)
919
+ expect(container.querySelector('h2')?.textContent).toBe('After heading')
920
+ expect(container.querySelectorAll('li').length).toBe(1)
921
+ })
922
+
923
+ // Round-12 REGRESSION: the cap-overflow fallback must not escape inside code.
924
+ // Entity refs are not decoded there, so the reader sees a literal `&lt;`.
925
+ it('does not escape a bare `<` inside an indented code block', async () => {
926
+ const md = SHARED_FIXTURES['indented-code-angle-bracket-not-escaped']
927
+ expect(escapeUnknownHtmlTags(md)).toBe(md)
928
+ const container = await renderStable(<SimpleMarkdownRenderer content={md} />)
929
+ expect(container.querySelector('code')?.textContent).toBe('if a <b then\ndone\n')
930
+ })
931
+
932
+ // …including an over-long tag, which is what the fallback was written for: in
933
+ // a region the MASK calls code it is a code sample, not a live opener. (Kept
934
+ // out of SHARED_FIXTURES to spare the snapshots ~15KB of filler.)
935
+ it.each([
936
+ ['indented', 'Para\n\n <div ' + 'b'.repeat(5000) + '>\n'],
937
+ ['fenced', 'Para\n\n```\n<div ' + 'b'.repeat(5000) + '>\n```\n'],
938
+ ['inline', 'Hi `<div ' + 'b'.repeat(3000) + '>` there'],
939
+ ])('leaves an over-long tag inside code unescaped: %s', (_name, md) => {
940
+ expect(escapeUnknownHtmlTags(md)).not.toContain('&lt;div')
941
+ })
942
+
943
+ // …while the SAME shape in PROSE still fails closed (the round-11 fix).
944
+ it('still escapes an over-long tag written in prose', () => {
945
+ const md = 'Hello\n\n<iframe src="' + 'b'.repeat(5000) + '">\n\n## After heading\n'
946
+ expect(escapeUnknownHtmlTags(md)).toContain('&lt;iframe')
947
+ })
948
+
949
+ // Round-11: HTML ignores the self-closing flag on non-void, non-foreign
950
+ // elements, so `<textarea/>` is a START tag and enters RAWTEXT exactly like
951
+ // `<textarea>`. Every RAWTEXT fixture must therefore hold in BOTH spellings.
952
+ it.each([
953
+ 'rawtext-self-closing-textarea-escaped',
954
+ 'rawtext-self-closing-spaced-textarea-escaped',
955
+ 'rawtext-self-closing-iframe-escaped',
956
+ 'rawtext-self-closing-title-escaped',
957
+ ])('escapes a SELF-CLOSING rawtext opener: %s', async (name) => {
958
+ const container = await renderStable(<SimpleMarkdownRenderer content={SHARED_FIXTURES[name]} />)
959
+ expect(container.querySelectorAll('textarea').length, name).toBe(0)
960
+ expect(container.querySelectorAll('iframe').length, name).toBe(0)
961
+ expect(container.querySelector('h2')?.textContent, name).toBe('After heading')
962
+ expect(container.querySelectorAll('li').length, name).toBe(1)
963
+ })
964
+
965
+ it('escapes a self-closing rawtext opener written INLINE in prose', async () => {
966
+ const container = await renderStable(
967
+ <SimpleMarkdownRenderer content={SHARED_FIXTURES['rawtext-self-closing-textarea-inline-escaped']} />,
968
+ )
969
+ expect(container.querySelectorAll('textarea').length).toBe(0)
970
+ expect(container.textContent).toContain('<textarea/>')
971
+ expect(container.querySelector('h2')?.textContent).toBe('After heading')
972
+ })
973
+
974
+ it('does not shelter a SELF-CLOSING opener inside an HTML block', async () => {
975
+ const container = await renderStable(
976
+ <SimpleMarkdownRenderer
977
+ content={SHARED_FIXTURES['rawtext-self-closing-opener-in-html-block-div-not-sheltered']}
978
+ />,
979
+ )
980
+ expect(container.querySelectorAll('textarea').length).toBe(0)
981
+ expect(container.querySelector('h2')?.textContent).toBe('After heading')
982
+ expect(container.textContent).toContain('secret tail')
983
+ })
984
+
985
+ // Round-13: an HTML block opened INSIDE a container (blockquote / list item).
986
+ // `computeHtmlBlockRanges` matched `^ {0,3}<…` against the RAW line, so the
987
+ // container marker made every start condition miss and the balance guard went
988
+ // blind — `escapeUnknownHtmlTags` returned these inputs BYTE-IDENTICAL.
989
+ it.each([
990
+ ['rawtext-opener-in-inline-code-in-blockquoted-html-block-not-sheltered', 'textarea'],
991
+ ['rawtext-opener-in-inline-code-in-nested-blockquoted-html-block-not-sheltered', 'textarea'],
992
+ ['rawtext-opener-in-inline-code-in-list-item-html-block-not-sheltered', 'textarea'],
993
+ ['rawtext-opener-in-inline-code-in-ordered-item-html-block-not-sheltered', 'textarea'],
994
+ ['rawtext-iframe-in-inline-code-in-blockquoted-html-block-not-sheltered', 'iframe'],
995
+ ])('does not shelter an opener inside a CONTAINER-nested HTML block: %s', async (name, tag) => {
996
+ const md = SHARED_FIXTURES[name]
997
+ // The sanitizer must actually act on it (the round-13 symptom was a no-op).
998
+ expect(escapeUnknownHtmlTags(md), name).not.toBe(md)
999
+ const container = await renderStable(<SimpleMarkdownRenderer content={md} />)
1000
+ expect(container.querySelectorAll(tag).length, name).toBe(0)
1001
+ expect(container.querySelector('h2')?.textContent, name).toBe('After heading')
1002
+ expect(container.textContent, name).toContain('secret tail')
1003
+ })
1004
+
1005
+ // Round-14: the container walk measured the list content column in CHARACTERS
1006
+ // where CommonMark measures COLUMNS, so a TAB-spelled list prefix
1007
+ // under-consumed the continuation indent and every start condition missed.
1008
+ // `escapeUnknownHtmlTags` was a total no-op on these and the DOM carried a
1009
+ // live RAWTEXT element. Asserted in BOTH renderers: the hole was in the
1010
+ // shared sanitizer, so a Simple-only test would have pinned half of it.
1011
+ it.each([
1012
+ ['rawtext-opener-in-inline-code-in-tab-list-item-html-block-not-sheltered', 'textarea'],
1013
+ ['rawtext-opener-in-inline-code-in-deep-tab-list-item-html-block-not-sheltered', 'textarea'],
1014
+ ['rawtext-iframe-in-inline-code-in-tab-list-item-html-block-not-sheltered', 'iframe'],
1015
+ // …and the FOREIGN-FILLER shape: a line that a greedy container strip reads
1016
+ // as blank (so the block terminated early) but CommonMark reads as HTML
1017
+ // content. Termination is now scoped to the OPENING line's prefix width.
1018
+ ['rawtext-opener-after-foreign-filler-in-list-item-html-block-not-sheltered', 'textarea'],
1019
+ ['rawtext-opener-after-foreign-filler-in-blockquoted-html-block-not-sheltered', 'textarea'],
1020
+ ])('does not shelter an opener behind a column-miscounted container: %s', async (name, tag) => {
1021
+ const md = SHARED_FIXTURES[name]
1022
+ // The sanitizer must actually act on it (the symptom was a byte-identical no-op).
1023
+ expect(escapeUnknownHtmlTags(md), name).not.toBe(md)
1024
+ for (const [label, Renderer] of [
1025
+ ['simple', SimpleMarkdownRenderer],
1026
+ ['rich', RichMarkdownRenderer],
1027
+ ] as const) {
1028
+ const container = await renderStable(<Renderer content={md} />)
1029
+ expect(container.querySelectorAll(tag).length, `${name} / ${label}`).toBe(0)
1030
+ expect(container.textContent, `${name} / ${label}`).toContain('secret tail')
1031
+ }
1032
+ })
1033
+
1034
+ // Round-14 CONTROL: the foreign-filler fixtures must differ from their
1035
+ // filler-less twins ONLY by that line — otherwise they would prove nothing.
1036
+ it.each([
1037
+ ['- <div>\n `<textarea>`\n\n## After heading\n\nsecret tail\n'],
1038
+ ['> <div>\n> `<textarea>`\n\n## After heading\n\nsecret tail\n'],
1039
+ ])('the filler-less control was already safe: %#', async (md) => {
1040
+ const container = await renderStable(<SimpleMarkdownRenderer content={md} />)
1041
+ expect(container.querySelectorAll('textarea').length).toBe(0)
1042
+ })
1043
+
1044
+ // Round-14: a GENUINE filler (the opening container's own) must still
1045
+ // TERMINATE the block — the fix scopes termination, it must not disable it.
1046
+ it.each([
1047
+ ['blockquote', '> <div>\n>\n> after\n'],
1048
+ ['nested-blockquote', '> > <div>\n> >\n> > after\n'],
1049
+ ['list-item', '- <div>\n\n after\n'],
1050
+ ])('still terminates an HTML block at its OWN container filler: %s', (_n, md) => {
1051
+ // The line after the filler is outside the block, so a protected span there
1052
+ // is ordinary inline code and is left verbatim.
1053
+ const withSpan = md.replace('after', '`<textarea>`')
1054
+ expect(escapeUnknownHtmlTags(withSpan)).toBe(withSpan)
1055
+ })
1056
+
1057
+ // Round-11: the WHOLE swallow corpus, re-run in the SELF-CLOSING spelling.
1058
+ // Every fixture below is one whose defense is "the prose opener must escape";
1059
+ // rewriting each bare RAWTEXT opener to `<tag/>` must not change that, since
1060
+ // parse5 treats the two spellings identically. This is the sweep that would
1061
+ // have caught the round-9 HTML-block fixtures passing for the wrong reason.
1062
+ const SWALLOW_CORPUS = [
1063
+ 'rawtext-closer-in-fence-does-not-unescape',
1064
+ 'rawtext-closer-in-inline-code-does-not-unescape',
1065
+ 'rawtext-closer-in-attribute-does-not-unescape',
1066
+ 'rawtext-title-closer-in-fence-does-not-unescape',
1067
+ 'rawtext-mask-survives-turkish-dotted-i',
1068
+ 'rawtext-closer-in-unclosed-fence-does-not-unescape',
1069
+ 'rawtext-closer-in-blockquoted-fence-does-not-unescape',
1070
+ 'rawtext-closer-in-indented-code-does-not-unescape',
1071
+ 'rawtext-closer-in-tab-indented-code-does-not-unescape',
1072
+ 'rawtext-closer-in-list-indented-fence-does-not-unescape',
1073
+ 'rawtext-closer-in-quoted-list-indented-fence-does-not-unescape',
1074
+ 'rawtext-closer-in-html-comment-does-not-unescape',
1075
+ 'rawtext-closer-after-info-string-closer-does-not-unescape',
1076
+ 'mismatched-fence-carve-does-not-shelter-opener',
1077
+ 'rawtext-opener-in-html-block-div-not-sheltered',
1078
+ 'rawtext-opener-in-html-block-pre-not-sheltered',
1079
+ 'rawtext-opener-in-html-block-details-not-sheltered',
1080
+ // …and the same four shapes in the INLINE-CODE shelter spelling (round 12),
1081
+ // so the sweep covers BOTH shelter spellings × BOTH tag spellings.
1082
+ 'rawtext-opener-in-inline-code-in-html-block-div-not-sheltered',
1083
+ 'rawtext-opener-in-inline-code-in-html-block-pre-not-sheltered',
1084
+ 'rawtext-opener-in-inline-code-in-html-block-details-not-sheltered',
1085
+ 'rawtext-opener-in-inline-code-in-html-block-span-not-sheltered',
1086
+ // …and the same hole in the CONTAINER-NESTED spelling (round 13).
1087
+ 'rawtext-opener-in-inline-code-in-blockquoted-html-block-not-sheltered',
1088
+ 'rawtext-opener-in-inline-code-in-nested-blockquoted-html-block-not-sheltered',
1089
+ 'rawtext-opener-in-inline-code-in-list-item-html-block-not-sheltered',
1090
+ 'rawtext-opener-in-inline-code-in-ordered-item-html-block-not-sheltered',
1091
+ 'rawtext-iframe-in-inline-code-in-blockquoted-html-block-not-sheltered',
1092
+ 'list-marker-inside-fence-does-not-shift-indent-columns',
1093
+ 'list-marker-inside-fence-html-block-combo',
1094
+ 'wide-list-marker-gap-clamps-content-column',
1095
+ // …and the round-15 blank-line-semantics shelter.
1096
+ 'rawtext-opener-in-nbsp-filler-html-block-not-sheltered',
1097
+ 'rawtext-iframe-in-nbsp-filler-html-block-not-sheltered',
1098
+ // …and the round-18 shapes: a block opened ON the list-marker line, a code
1099
+ // span crossing a line break, and a link reference definition.
1100
+ 'rawtext-closer-in-marker-line-fence-does-not-unescape',
1101
+ 'rawtext-closer-in-marker-line-ordered-fence-does-not-unescape',
1102
+ 'rawtext-closer-in-marker-line-tab-fence-does-not-unescape',
1103
+ 'rawtext-closer-in-quoted-marker-line-fence-does-not-unescape',
1104
+ 'rawtext-iframe-closer-in-marker-line-fence-does-not-unescape',
1105
+ 'rawtext-closer-in-continuation-line-fence-does-not-unescape',
1106
+ 'rawtext-closer-in-multiline-code-span-does-not-unescape',
1107
+ 'rawtext-iframe-closer-in-multiline-code-span-does-not-unescape',
1108
+ 'rawtext-closer-in-link-definition-title-does-not-unescape',
1109
+ 'rawtext-closer-in-link-definition-destination-does-not-unescape',
1110
+ // …and the round-19 shapes: NARROW and NESTED list-marker lines, the whole
1111
+ // INLINE link/image payload shelter class, and multi-line definitions.
1112
+ 'rawtext-closer-in-narrow-marker-line-fence-does-not-unescape',
1113
+ 'rawtext-closer-in-narrow-ordered-marker-line-fence-does-not-unescape',
1114
+ 'rawtext-iframe-closer-in-narrow-marker-line-fence-does-not-unescape',
1115
+ 'rawtext-closer-in-nested-marker-line-fence-does-not-unescape',
1116
+ 'rawtext-closer-in-nested-marker-line-link-definition-does-not-unescape',
1117
+ 'rawtext-closer-in-inline-link-title-does-not-unescape',
1118
+ 'rawtext-closer-in-inline-link-single-quoted-title-does-not-unescape',
1119
+ 'rawtext-closer-in-inline-link-paren-title-does-not-unescape',
1120
+ 'rawtext-closer-in-inline-image-title-does-not-unescape',
1121
+ 'rawtext-closer-in-inline-link-angle-destination-does-not-unescape',
1122
+ 'rawtext-closer-in-quoted-inline-link-title-does-not-unescape',
1123
+ 'rawtext-closer-in-listed-inline-link-title-does-not-unescape',
1124
+ 'rawtext-closer-in-mid-paragraph-inline-link-title-does-not-unescape',
1125
+ 'rawtext-iframe-closer-in-inline-link-title-does-not-unescape',
1126
+ 'rawtext-closer-in-multiline-link-definition-does-not-unescape',
1127
+ 'rawtext-closer-in-multiline-link-definition-destination-does-not-unescape',
1128
+ // …and the round-21 shapes: BACKSLASH-ESCAPED delimiters in a definition's
1129
+ // label/destination/title, and a label or title that spans LINES.
1130
+ 'rawtext-closer-in-escaped-quote-definition-title-does-not-unescape',
1131
+ 'rawtext-closer-in-escaped-apostrophe-definition-title-does-not-unescape',
1132
+ 'rawtext-closer-in-escaped-paren-definition-title-does-not-unescape',
1133
+ 'rawtext-closer-in-escaped-bracket-definition-label-does-not-unescape',
1134
+ 'rawtext-closer-in-escaped-bracket-definition-destination-does-not-unescape',
1135
+ 'rawtext-closer-in-multiline-definition-label-does-not-unescape',
1136
+ 'rawtext-closer-in-multiline-definition-label-first-line-does-not-unescape',
1137
+ 'rawtext-closer-in-three-line-definition-label-does-not-unescape',
1138
+ 'rawtext-closer-in-quoted-multiline-definition-label-does-not-unescape',
1139
+ 'rawtext-closer-in-multiline-definition-title-does-not-unescape',
1140
+ 'rawtext-iframe-closer-in-multiline-definition-label-does-not-unescape',
1141
+ ]
1142
+ // Bare opener → self-closed opener. Closers (`</tag>`), openers that already
1143
+ // carry attributes, and openers whose closer sits on the SAME line (a paired
1144
+ // inline element, as in the Turkish-`İ` fixture) are left alone — rewriting
1145
+ // those would change what the fixture is about rather than its spelling.
1146
+ const selfClose = (md: string) =>
1147
+ md.replace(
1148
+ /<(title|textarea|iframe|xmp|noembed|noframes|plaintext)>(?![^\n]*<\/\1>)/gi,
1149
+ '<$1/>',
1150
+ )
1151
+
1152
+ const RAWTEXT_SELECTORS = ['textarea', 'iframe', 'title', 'xmp', 'noembed', 'noframes', 'plaintext']
1153
+ const rawtextCounts = (el: HTMLElement) =>
1154
+ RAWTEXT_SELECTORS.map((t) => `${t}:${el.querySelectorAll(t).length}`).join(' ')
1155
+
1156
+ // Round-12: the sweep's invariant used to be purely DIFFERENTIAL (`selfEl`
1157
+ // counts === `bareEl` counts), so a common-mode regression that broke BOTH
1158
+ // spellings passed it — two entries were pinned by snapshot alone. Every
1159
+ // corpus entry is a "the prose opener must escape" shape, so the ABSOLUTE
1160
+ // truth is zero live RAWTEXT elements, with one deliberate exception: the
1161
+ // Turkish-`İ` fixture opens with a PROPERLY CLOSED `<textarea>a</textarea>`
1162
+ // before its bare opener, and that one must keep rendering.
1163
+ const LIVE_RAWTEXT_EXPECTED: Record<string, number> = {
1164
+ 'rawtext-mask-survives-turkish-dotted-i': 1,
1165
+ }
1166
+
1167
+ // -------------------------------------------------------------------------
1168
+ // Round-15: LINE ENDINGS as a first-class corpus DIMENSION.
1169
+ // -------------------------------------------------------------------------
1170
+ // The corpus is authored LF-only, so every `$`-anchored line regex in the
1171
+ // pipeline was swept in exactly one spelling. `FENCE_RE` ended `(.*)$` — `.`
1172
+ // excludes `\r` and `$` (no `m` flag) anchors at end of INPUT — so on a CRLF
1173
+ // document the shared fence tracker matched NOTHING and was completely
1174
+ // fence-blind: `rawtext-closer-in-fence-does-not-unescape`, respelled CRLF,
1175
+ // left a LIVE `<textarea>` while its LF twin escaped correctly. CRLF composes
1176
+ // with every wrapper below for free, so it is applied LAST (after the
1177
+ // container wrap and the tag-spelling rewrite).
1178
+ const LINE_ENDINGS = {
1179
+ lf: (md: string) => md,
1180
+ crlf: (md: string) => md.replace(/\n/g, '\r\n'),
1181
+ } as const
1182
+ const LINE_ENDING_NAMES = Object.keys(LINE_ENDINGS) as (keyof typeof LINE_ENDINGS)[]
1183
+
1184
+ const SELF_CLOSING_CASES: [string, keyof typeof LINE_ENDINGS][] = []
1185
+ for (const name of SWALLOW_CORPUS)
1186
+ for (const eol of LINE_ENDING_NAMES) SELF_CLOSING_CASES.push([name, eol])
1187
+
1188
+ it.each(SELF_CLOSING_CASES)(
1189
+ 'swallow corpus holds in the self-closing spelling: %s / %s',
1190
+ async (name, eol) => {
1191
+ const eolize = LINE_ENDINGS[eol]
1192
+ const bare = eolize(SHARED_FIXTURES[name])
1193
+ const md = eolize(selfClose(SHARED_FIXTURES[name]))
1194
+ expect(md, name).not.toBe(bare) // the rewrite actually applied
1195
+ const bareEl = await renderStable(<SimpleMarkdownRenderer content={bare} />)
1196
+ const selfEl = await renderStable(<SimpleMarkdownRenderer content={md} />)
1197
+ // ABSOLUTE: no swallow in EITHER spelling, independent of the comparison.
1198
+ const expected = LIVE_RAWTEXT_EXPECTED[name] ?? 0
1199
+ for (const [label, el] of [
1200
+ ['bare', bareEl],
1201
+ ['self-closing', selfEl],
1202
+ ] as const) {
1203
+ expect(el.querySelectorAll(RAWTEXT_SELECTORS.join(',')).length, `${name} ${label}`).toBe(
1204
+ expected,
1205
+ )
1206
+ }
1207
+ // Spelling-independence: parse5 treats `<tag/>` as a start tag, so the two
1208
+ // spellings must yield the same live RAWTEXT elements…
1209
+ expect(rawtextCounts(selfEl), name).toBe(rawtextCounts(bareEl))
1210
+ // …and the same document structure below the opener (no swallow).
1211
+ expect(selfEl.querySelectorAll('h1,h2,h3,li,p').length, name).toBe(
1212
+ bareEl.querySelectorAll('h1,h2,h3,li,p').length,
1213
+ )
1214
+ },
1215
+ )
1216
+
1217
+ // -------------------------------------------------------------------------
1218
+ // Round-13: CONTAINER NESTING as a first-class corpus DIMENSION.
1219
+ // -------------------------------------------------------------------------
1220
+ // The 294-case round-12 matrix missed the container hole entirely because
1221
+ // EVERY case was authored at column 0. Container nesting is now swept exactly
1222
+ // like the bare-vs-`<tag/>` spelling dimension: each corpus entry is also
1223
+ // rendered inside a blockquote (depth 1 and 2) and inside a bullet / ordered
1224
+ // list item, in BOTH tag spellings, and must satisfy the SAME absolute
1225
+ // invariant (zero live RAWTEXT elements, per-entry `LIVE_RAWTEXT_EXPECTED`)
1226
+ // plus the spelling-differential one. Any future shelter shape is therefore
1227
+ // caught in every container spelling automatically.
1228
+ const perLine = (f: (line: string) => string) => (md: string) =>
1229
+ md.split('\n').map(f).join('\n')
1230
+
1231
+ /** Tabs to 4-column stops \u2014 mirrors the sanitizer's `expandTabs`, so a
1232
+ * TAB-spelled marker's continuation pad lands on the real content column. */
1233
+ const expandTabs = (str: string) => {
1234
+ let out = ''
1235
+ for (const ch of str) out += ch === '\t' ? ' '.repeat(4 - (out.length % 4)) : ch
1236
+ return out
1237
+ }
1238
+
1239
+ /** A list item: the marker on the first line, the content column on the rest. */
1240
+ const listWrap = (marker: string) => (md: string) => {
1241
+ const pad = ' '.repeat(expandTabs(marker).length)
1242
+ let seen = false
1243
+ return md
1244
+ .split('\n')
1245
+ .map((l) => {
1246
+ if (l === '') return ''
1247
+ if (!seen) {
1248
+ seen = true
1249
+ return marker + l
1250
+ }
1251
+ return pad + l
1252
+ })
1253
+ .join('\n')
1254
+ }
1255
+
1256
+ /**
1257
+ * ROUND-18 — THE MARKER-LINE VARIANT, the dimension 1032 container cases
1258
+ * missed. `listWrap` puts the marker on the FIRST non-blank line, and every
1259
+ * corpus fixture BEGINS WITH PROSE, so every container case placed its block
1260
+ * on a CONTINUATION line — the one position `blankListItemCode` handled. A
1261
+ * block opened ON the marker line itself was never swept, and that is exactly
1262
+ * where the eighth instance of the fail-open class lived.
1263
+ *
1264
+ * This wrapper leaves the fixture's leading prose at top level (so the prose
1265
+ * RAWTEXT opener still sits outside the item) and starts the list item at the
1266
+ * line that OPENS A BLOCK. Everything below is padded to the content column
1267
+ * exactly as in `listWrap`, so the item's shape is otherwise identical.
1268
+ *
1269
+ * ROUND-19 — THE WRAPPER WAS GREEN FOR THE WRONG REASON, WHICH IS WHY 2855
1270
+ * TESTS MISSED THREE LIVE INSTANCES. It used to mark the first non-blank line
1271
+ * AFTER the first prose line. In every `SWALLOW_CORPUS` fixture that line is
1272
+ * MORE PROSE or a `## heading`, never the fence — so the construct always
1273
+ * landed on a padded CONTINUATION line at absolute indent 2-3, INSIDE
1274
+ * `FENCE_RE`'s 0..3 cap, i.e. the one position that was already covered. The
1275
+ * narrow-marker wrappers (`- ` / `1. `) were added specifically to sweep the
1276
+ * marker-line position independently of the column and STRUCTURALLY COULD NOT
1277
+ * land a block construct there. It now targets the first line that actually
1278
+ * opens a block — a code fence or a link reference definition — falling back
1279
+ * to the old rule for fixtures whose shelter is not a block construct (inline
1280
+ * code spans, attribute strings, HTML blocks), which keeps their coverage
1281
+ * unchanged. `wrapper marks a block-opening line` below pins the targeting.
1282
+ */
1283
+ /** A line that OPENS A BLOCK: a code fence or a link reference definition. */
1284
+ const BLOCK_OPENER_RE = /^[ \t]{0,3}(?:```|~~~|\[[^\]\n]{0,99}\]:)/
1285
+ /** Index of the line `markerLineListWrap` puts the marker on, or -1. */
1286
+ const markerLineTarget = (lines: string[]) => {
1287
+ const opener = lines.findIndex((l, i) => i > 0 && l !== '' && BLOCK_OPENER_RE.test(l))
1288
+ if (opener > 0) return opener
1289
+ const first = lines.findIndex((l) => l !== '')
1290
+ return first === -1 ? -1 : lines.findIndex((l, i) => i > first && l !== '')
1291
+ }
1292
+ const markerLineListWrap = (marker: string) => (md: string) => {
1293
+ const pad = ' '.repeat(expandTabs(marker).length)
1294
+ const lines = md.split('\n')
1295
+ const target = markerLineTarget(lines)
1296
+ if (target <= 0) return md
1297
+ return lines
1298
+ .map((l, i) => (l === '' ? '' : i < target ? l : i === target ? marker + l : pad + l))
1299
+ .join('\n')
1300
+ }
1301
+
1302
+ /** A list item whose BLANK separators carry a FOREIGN container's opener at
1303
+ * the content column. Those lines look blank to a greedy container strip but
1304
+ * are HTML content under CommonMark \u2014 the round-14 early-termination shape. */
1305
+ const foreignFillerListWrap = (marker: string, filler: string) => (md: string) => {
1306
+ const pad = ' '.repeat(expandTabs(marker).length)
1307
+ let seen = false
1308
+ return md
1309
+ .split('\n')
1310
+ .map((l) => {
1311
+ if (l === '') return pad + filler
1312
+ if (!seen) {
1313
+ seen = true
1314
+ return marker + l
1315
+ }
1316
+ return pad + l
1317
+ })
1318
+ .join('\n')
1319
+ }
1320
+
1321
+ /**
1322
+ * `preservesColumns` says whether the wrapper keeps each line's indentation
1323
+ * RELATIVE to its container's content column. Only the SPACE blockquotes do:
1324
+ * `> ` is exactly the two columns the block-quote parser consumes, so the
1325
+ * remainder is byte-for-byte the original line. Every list wrapper shifts by
1326
+ * its content column, and the TAB blockquote (`>\t`) consumes `>` plus ONE
1327
+ * column of the tab and leaves the other two as indentation \u2014 both re-parse a
1328
+ * shape whose MEANING is a column into something else (see
1329
+ * `COLUMN_SENSITIVE_ENTRIES`). The round-13 sweep decided this with a
1330
+ * `wrapper.startsWith('blockquote')` string test, which would have silently
1331
+ * (and wrongly) admitted `blockquote-tab`.
1332
+ *
1333
+ * ROUND-17: CONTAINER NESTING IS NOW SWEPT TO DEPTH 2+. Every wrapper above is
1334
+ * a SINGLE container, so the round-13 axis only ever swept one level deep:
1335
+ * every quoted case put its fence at quote-relative column 0, and every
1336
+ * list-nested case was at top level. The PRODUCT of two containers was never
1337
+ * tested — and that product is exactly where the seventh instance of the
1338
+ * fail-open class lived (a fenced sample in a list item INSIDE a blockquote
1339
+ * was masked by NO pass, because `blankQuotedCode` did not call
1340
+ * `blankListItemCode` while `blankListItemCode` did call `blankQuotedCode`).
1341
+ * The `nest()` compositions below add that dimension: quote⊗list,
1342
+ * quote⊗quote⊗list and list⊗quote, each in the three content-column spellings
1343
+ * (`1. ` / `- ` / `-\t`) that clear the 3-column `FENCE_RE` indent cap.
1344
+ * They compose with the tag-spelling, line-ending and exotic-blank dimensions
1345
+ * for free, exactly like the single-container wrappers.
1346
+ */
1347
+ /** Compose two wrappers: `nest(outer, inner)` puts `inner` INSIDE `outer`.
1348
+ * Columns survive only when BOTH layers preserve them — one list layer
1349
+ * anywhere in the stack shifts every line by its content column. */
1350
+ const nest = (
1351
+ outer: { wrap: (md: string) => string; preservesColumns: boolean },
1352
+ inner: { wrap: (md: string) => string; preservesColumns: boolean },
1353
+ ) => ({
1354
+ wrap: (md: string) => outer.wrap(inner.wrap(md)),
1355
+ preservesColumns: outer.preservesColumns && inner.preservesColumns,
1356
+ })
1357
+ const BQ1 = {
1358
+ wrap: perLine((l: string) => (l === '' ? '>' : `> ${l}`)),
1359
+ preservesColumns: true,
1360
+ }
1361
+ const BQ2 = {
1362
+ wrap: perLine((l: string) => (l === '' ? '> >' : `> > ${l}`)),
1363
+ preservesColumns: true,
1364
+ }
1365
+ /** List markers whose CONTENT COLUMN is 4 — past `FENCE_RE`'s absolute
1366
+ * 3-column indent cap, which is what makes the nested fence invisible. */
1367
+ const WIDE_LIST_MARKERS = { ordered: '1. ', bullet: '- ', tab: '-\t' } as const
1368
+ const CONTAINER_WRAPPERS = {
1369
+ 'blockquote-depth-1': {
1370
+ wrap: perLine((l: string) => (l === '' ? '>' : `> ${l}`)),
1371
+ preservesColumns: true,
1372
+ },
1373
+ 'blockquote-depth-2': {
1374
+ wrap: perLine((l: string) => (l === '' ? '> >' : `> > ${l}`)),
1375
+ preservesColumns: true,
1376
+ },
1377
+ 'bullet-item': { wrap: listWrap('- '), preservesColumns: false },
1378
+ 'ordered-item': { wrap: listWrap('1. '), preservesColumns: false },
1379
+ // Round-14: the WHITESPACE SPELLING is its own dimension. Every wrapper
1380
+ // above is space-built, so the sweep covered exactly one spelling and a
1381
+ // tab-spelled container prefix \u2014 whose content column is a tab STOP, not a
1382
+ // character count \u2014 was invisible to it.
1383
+ 'bullet-item-tab': { wrap: listWrap('-\t'), preservesColumns: false },
1384
+ 'blockquote-tab': {
1385
+ wrap: perLine((l: string) => (l === '' ? '>' : `>\t${l}`)),
1386
+ preservesColumns: false,
1387
+ },
1388
+ // Round-14: a filler line that a greedy strip reads as blank but CommonMark
1389
+ // reads as HTML content, which used to TERMINATE the block early.
1390
+ 'bullet-item-foreign-filler': {
1391
+ wrap: foreignFillerListWrap('- ', '>'),
1392
+ preservesColumns: false,
1393
+ },
1394
+ 'blockquote-foreign-filler': {
1395
+ wrap: perLine((l: string) => (l === '' ? '> -' : `> ${l}`)),
1396
+ preservesColumns: false,
1397
+ },
1398
+ // Round-15: the EXOTIC-WHITESPACE spelling of a blank line is its own
1399
+ // dimension. Every separator in the corpus is a truly empty line, so the
1400
+ // sweep only ever exercised one spelling of "blank" — and the pipeline
1401
+ // decided blankness with `String.trim()`, which strips U+00A0 / U+000B /
1402
+ // U+000C / U+FEFF while CommonMark's blank line is spaces and tabs only.
1403
+ // A filler line holding one of those is CONTENT to remark and BLANK to the
1404
+ // tracked-range walk, which is exactly how the HTML-block shelter reopened
1405
+ // (see `rawtext-opener-in-nbsp-filler-html-block-not-sheltered`). These
1406
+ // wrappers preserve every non-blank line verbatim, so column-sensitive
1407
+ // entries are safe to sweep in them.
1408
+ ...(Object.fromEntries(
1409
+ (
1410
+ [
1411
+ ['nbsp', '\u00a0'],
1412
+ ['vertical-tab', '\u000b'],
1413
+ ['form-feed', '\u000c'],
1414
+ ['bom', '\ufeff'],
1415
+ ] as const
1416
+ ).map(([label, ch]) => [
1417
+ `exotic-blank-${label}`,
1418
+ { wrap: perLine((l: string) => (l === '' ? ch : l)), preservesColumns: true },
1419
+ ]),
1420
+ ) as Record<string, { wrap: (md: string) => string; preservesColumns: boolean }>),
1421
+ // Round-17: CONTAINER × CONTAINER. `quote-x-*` is a wide list item inside a
1422
+ // blockquote (the reported hole), `nested-quote-x-*` the same at quote depth
1423
+ // 2, and `list-x-quote` the MIRROR direction (a blockquote inside a list
1424
+ // item) so neither leg of the mutual recursion is swept alone.
1425
+ ...(Object.fromEntries(
1426
+ Object.entries(WIDE_LIST_MARKERS).flatMap(([label, marker]) => {
1427
+ const item = { wrap: listWrap(marker), preservesColumns: false }
1428
+ return [
1429
+ [`quote-x-${label}-item`, nest(BQ1, item)],
1430
+ [`nested-quote-x-${label}-item`, nest(BQ2, item)],
1431
+ [`${label}-item-x-quote`, nest(item, BQ1)],
1432
+ ]
1433
+ }),
1434
+ ) as Record<string, { wrap: (md: string) => string; preservesColumns: boolean }>),
1435
+ // Round-18: the MARKER-LINE variant of every list-bearing wrapper above —
1436
+ // the block opened ON the marker line rather than on a continuation line.
1437
+ ...(Object.fromEntries(
1438
+ Object.entries(WIDE_LIST_MARKERS).flatMap(([label, marker]) => {
1439
+ const item = { wrap: markerLineListWrap(marker), preservesColumns: false }
1440
+ return [
1441
+ [`marker-line-${label}-item`, item],
1442
+ [`quote-x-marker-line-${label}-item`, nest(BQ1, item)],
1443
+ [`nested-quote-x-marker-line-${label}-item`, nest(BQ2, item)],
1444
+ [`marker-line-${label}-item-x-quote`, nest(item, BQ1)],
1445
+ ]
1446
+ }),
1447
+ ) as Record<string, { wrap: (md: string) => string; preservesColumns: boolean }>),
1448
+ // …and the NARROW markers too (content column 2/3, under the `FENCE_RE`
1449
+ // cap), so the marker-line position is swept independently of the column.
1450
+ 'marker-line-bullet-item': { wrap: markerLineListWrap('- '), preservesColumns: false },
1451
+ 'marker-line-ordered-item': { wrap: markerLineListWrap('1. '), preservesColumns: false },
1452
+ } as const
1453
+
1454
+ // Entries whose MEANING is a column: an indented-code block at exactly 4, a
1455
+ // fence indented into a list content column, a marker-gap clamp. A list
1456
+ // wrapper shifts every line right by the content column, which re-parses those
1457
+ // shapes as something else (the indented code stops being at +4 relative to
1458
+ // the item, the clamp fixture gets a SECOND marker). They are swept in the
1459
+ // blockquote wrappers only — a `> ` prefix is consumed by the block-quote
1460
+ // parser and RESETS the content column, so relative indentation is preserved.
1461
+ const COLUMN_SENSITIVE_ENTRIES = new Set([
1462
+ 'rawtext-closer-in-indented-code-does-not-unescape',
1463
+ 'rawtext-closer-in-tab-indented-code-does-not-unescape',
1464
+ 'rawtext-closer-in-list-indented-fence-does-not-unescape',
1465
+ 'rawtext-closer-in-quoted-list-indented-fence-does-not-unescape',
1466
+ 'list-marker-inside-fence-does-not-shift-indent-columns',
1467
+ 'list-marker-inside-fence-html-block-combo',
1468
+ 'wide-list-marker-gap-clamps-content-column',
1469
+ // Already authored INSIDE a list item; re-wrapping in another one only
1470
+ // re-tests the wrapper.
1471
+ 'rawtext-opener-in-inline-code-in-list-item-html-block-not-sheltered',
1472
+ 'rawtext-opener-in-inline-code-in-ordered-item-html-block-not-sheltered',
1473
+ // Round-18: the marker-line shapes ARE a list-item column by construction —
1474
+ // a list wrapper pads them into a different one and re-parses the fence.
1475
+ 'rawtext-closer-in-marker-line-fence-does-not-unescape',
1476
+ 'rawtext-closer-in-marker-line-ordered-fence-does-not-unescape',
1477
+ 'rawtext-closer-in-marker-line-tab-fence-does-not-unescape',
1478
+ 'rawtext-closer-in-quoted-marker-line-fence-does-not-unescape',
1479
+ 'rawtext-iframe-closer-in-marker-line-fence-does-not-unescape',
1480
+ 'rawtext-closer-in-continuation-line-fence-does-not-unescape',
1481
+ // Round-19: same reason — these ARE list-item columns by construction.
1482
+ 'rawtext-closer-in-narrow-marker-line-fence-does-not-unescape',
1483
+ 'rawtext-closer-in-narrow-ordered-marker-line-fence-does-not-unescape',
1484
+ 'rawtext-iframe-closer-in-narrow-marker-line-fence-does-not-unescape',
1485
+ 'rawtext-closer-in-nested-marker-line-fence-does-not-unescape',
1486
+ 'rawtext-closer-in-nested-marker-line-link-definition-does-not-unescape',
1487
+ 'rawtext-closer-in-listed-inline-link-title-does-not-unescape',
1488
+ ])
1489
+
1490
+ const CONTAINER_CASES: [string, string, keyof typeof LINE_ENDINGS][] = []
1491
+ for (const name of SWALLOW_CORPUS)
1492
+ for (const [wrapper, spec] of Object.entries(CONTAINER_WRAPPERS))
1493
+ if (!COLUMN_SENSITIVE_ENTRIES.has(name) || spec.preservesColumns)
1494
+ // …and every (entry × wrapper) pair in BOTH line-ending spellings, so
1495
+ // the CRLF dimension composes with the container one for free.
1496
+ for (const eol of LINE_ENDING_NAMES) CONTAINER_CASES.push([name, wrapper, eol])
1497
+
1498
+ it.each(CONTAINER_CASES)(
1499
+ 'swallow corpus holds inside a container: %s / %s / %s',
1500
+ async (name, wrapper, eol) => {
1501
+ const { wrap } = CONTAINER_WRAPPERS[wrapper as keyof typeof CONTAINER_WRAPPERS]
1502
+ const eolize = LINE_ENDINGS[eol]
1503
+ const bare = eolize(wrap(SHARED_FIXTURES[name]))
1504
+ const md = eolize(selfClose(wrap(SHARED_FIXTURES[name])))
1505
+ const bareEl = await renderStable(<SimpleMarkdownRenderer content={bare} />)
1506
+ const selfEl = await renderStable(<SimpleMarkdownRenderer content={md} />)
1507
+ const expected = LIVE_RAWTEXT_EXPECTED[name] ?? 0
1508
+ for (const [label, el] of [
1509
+ ['bare', bareEl],
1510
+ ['self-closing', selfEl],
1511
+ ] as const) {
1512
+ expect(
1513
+ el.querySelectorAll(RAWTEXT_SELECTORS.join(',')).length,
1514
+ `${name} / ${wrapper} / ${eol} / ${label}`,
1515
+ ).toBe(expected)
1516
+ }
1517
+ expect(rawtextCounts(selfEl), `${name} / ${wrapper} / ${eol}`).toBe(rawtextCounts(bareEl))
1518
+ expect(
1519
+ selfEl.querySelectorAll('h1,h2,h3,li,p').length,
1520
+ `${name} / ${wrapper} / ${eol}`,
1521
+ ).toBe(bareEl.querySelectorAll('h1,h2,h3,li,p').length)
1522
+ },
1523
+ )
1524
+
1525
+ // Round-19 META-TEST: `markerLineListWrap` must actually put the marker on a
1526
+ // line that OPENS A BLOCK. Its predecessor could not, and that is the whole
1527
+ // reason the narrow-marker wrappers passed while three live instances sat
1528
+ // uncovered. Every corpus entry carrying a fence or a link definition is
1529
+ // checked; a wrapper that regresses to "first non-blank after the prose"
1530
+ // fails here rather than passing silently for the wrong reason.
1531
+ it('markerLineListWrap marks a block-opening line', () => {
1532
+ const blockBearing = SWALLOW_CORPUS.filter((name) =>
1533
+ SHARED_FIXTURES[name]
1534
+ .split('\n')
1535
+ .some((l, i) => i > 0 && l !== '' && BLOCK_OPENER_RE.test(l)),
1536
+ )
1537
+ // The corpus must actually contain such entries, or the assertion is vacuous.
1538
+ expect(blockBearing.length).toBeGreaterThan(10)
1539
+ for (const name of blockBearing)
1540
+ for (const marker of ['- ', '1. ', '- ', '1. ', '-\t']) {
1541
+ const wrapped = markerLineListWrap(marker)(SHARED_FIXTURES[name])
1542
+ const markerLine = wrapped.split('\n').find((l) => l.startsWith(marker))
1543
+ expect(markerLine, `${name} / ${JSON.stringify(marker)}`).toBeDefined()
1544
+ // The marker is IMMEDIATELY followed by the block opener — the construct
1545
+ // is ON the marker line, not on a padded continuation line below it.
1546
+ expect(
1547
+ BLOCK_OPENER_RE.test(markerLine!.slice(marker.length)),
1548
+ `${name} / ${JSON.stringify(marker)} / ${JSON.stringify(markerLine)}`,
1549
+ ).toBe(true)
1550
+ }
1551
+ })
1552
+
1553
+ // Round-19: CONTAINER NESTING DEPTH. The round-17 termination note claimed
1554
+ // `> - ` alternation at 8000 levels ran with "no throw, ≤4 ms"; it did not —
1555
+ // HEAD raises `RangeError: Maximum call stack size exceeded` from ~2000 levels
1556
+ // up, i.e. a 24 KB message crashes the renderer. The recursion is finite but
1557
+ // its DEPTH is bounded only by input length. `CONTAINER_NEST_LIMIT` blanks an
1558
+ // over-deep run WHOLE (fail-closed) instead of recursing, so the mask stays
1559
+ // total, length-preserving and closer-free at every depth.
1560
+ it.each([
1561
+ ['alternation', (d: number) => '> - '.repeat(d) + '```html\n' + '> - '.repeat(d) + '</textarea>\n'],
1562
+ // The closer is padded to the INNERMOST item's content column, so it really
1563
+ // is fence content. (Padded to a SHALLOWER column it would close the inner
1564
+ // items and be a genuine top-level closer, which must stay visible.)
1565
+ ['list-markers', (d: number) => '- '.repeat(d) + '```html\n' + ' '.repeat(2 * d) + '</textarea>\n'],
1566
+ ])('masks arbitrarily deep container nesting without throwing: %s', (_label, mk) => {
1567
+ for (const depth of [1, 63, 64, 65, 2000, 8000, 40000]) {
1568
+ const doc = mk(depth)
1569
+ const masked = __buildCloserHaystackForTest(doc)
1570
+ expect(masked.length, `depth ${depth}`).toBe(doc.length)
1571
+ // Fail-CLOSED: the sheltered closer is gone from the haystack.
1572
+ expect(masked.includes('</textarea>'), `depth ${depth}`).toBe(false)
1573
+ }
1574
+ })
1575
+
1576
+ // Round-19 ESCALATION: the inline-title shelter with an ATTRIBUTE-BEARING
1577
+ // opener. Not privilege escalation (`iframe`/`src` are allowlisted) but
1578
+ // exactly the content-swallow this module prevents — a live iframe retaining
1579
+ // both attributes, with the document below it consumed.
1580
+ it('does not shelter an attribute-bearing opener behind an inline link title', async () => {
1581
+ const md =
1582
+ 'Embedding <iframe src="https://evil.example/x" width="600"> explained.\n\n' +
1583
+ '[a](/x "</iframe>")\n\n## After heading\n\nsecret tail\n'
1584
+ expect(escapeUnknownHtmlTags(md)).not.toBe(md)
1585
+ const el = await renderStable(<SimpleMarkdownRenderer content={md} />)
1586
+ expect(el.querySelectorAll('iframe').length).toBe(0)
1587
+ expect(el.textContent).toContain('secret tail')
1588
+ })
1589
+
1590
+ // -------------------------------------------------------------------------
1591
+ // Round-20: the BRACKET half of the attribute-shelter class.
1592
+ // -------------------------------------------------------------------------
1593
+ // ESCALATION, the same shape round 19 pinned for the payload: an
1594
+ // attribute-bearing opener behind an IMAGE ALT shelter kept `src`, `width` and
1595
+ // `height` on a LIVE iframe, with the paragraph below it consumed.
1596
+ it('does not shelter an attribute-bearing opener behind an image alt', async () => {
1597
+ const md =
1598
+ 'Embed: <iframe src="https://evil.example/x" width="600" height="400">\n\n' +
1599
+ '![</iframe>](/x)\n\n## After heading\n\nsecret tail\n'
1600
+ expect(escapeUnknownHtmlTags(md)).not.toBe(md)
1601
+ const el = await renderStable(<SimpleMarkdownRenderer content={md} />)
1602
+ expect(el.querySelectorAll('iframe').length).toBe(0)
1603
+ expect(el.textContent).toContain('secret tail')
1604
+ })
1605
+
1606
+ // The COMPLEMENT of the same rule, and the reason the pass may not simply
1607
+ // blank every `[…]`: remark EMITS an inline link's text and a bare SHORTCUT
1608
+ // reference's text as HTML, so a closer written there is REAL and the opener
1609
+ // above it is correctly left live. Blanking these would only over-escape, but
1610
+ // the boundary is the whole point of the pass — pin it.
1611
+ it.each([
1612
+ ['inline link text', 'The <textarea> element.\n\n[<textarea>hi</textarea>](/x)\n'],
1613
+ ['shortcut reference', 'The <textarea> element.\n\n[</textarea>]\n\n[</textarea>]: /x\n'],
1614
+ ])('leaves REMARK-EMITTED bracket text visible: %s', (_label, md) => {
1615
+ expect(escapeUnknownHtmlTags(md)).toBe(md)
1616
+ })
1617
+
1618
+ // -------------------------------------------------------------------------
1619
+ // Round-20: the length caps must BLANK, not SKIP.
1620
+ // -------------------------------------------------------------------------
1621
+ // `INLINE_LINK_PAYLOAD_MAX` and the link-definition regexes' `{0,999}` bounds
1622
+ // each spent an over-limit exit as "not a link, leave visible" — the same
1623
+ // fail-OPEN shape `ba4a526b` closed for over-cap inline code spans, in code
1624
+ // written after that fix. CommonMark bounds neither a destination nor a title,
1625
+ // so these are ordinary documents. Every row was a byte-identical no-op with a
1626
+ // live RAWTEXT element before the fix; the 900-char controls below already
1627
+ // escaped correctly, which is what makes the cap (not the shape) the cause.
1628
+ const capFiller = 'y'.repeat(1100)
1629
+ const okFiller = 'y'.repeat(900)
1630
+ it.each([
1631
+ ['inline title', `[a](/x "${capFiller}</textarea>")`],
1632
+ ['inline destination', `[a](/${capFiller}</textarea>)`],
1633
+ ['inline angle destination', `[a](</${capFiller}</textarea>>)`],
1634
+ ['definition title', `[a]: /x "${capFiller}</textarea>"`],
1635
+ ['definition destination', `[a]: /${capFiller}</textarea>`],
1636
+ ['definition label', `[${capFiller}]: /x "</textarea>"`],
1637
+ ])('an OVER-CAP shelter still blanks: %s', async (_label, shelter) => {
1638
+ const md =
1639
+ 'The <textarea> element explained.\n\n' +
1640
+ shelter +
1641
+ '\n\n## After heading\n\nsecret tail\n'
1642
+ expect(escapeUnknownHtmlTags(md)).toContain('&lt;textarea&gt;')
1643
+ const el = await renderStable(<SimpleMarkdownRenderer content={md} />)
1644
+ expect(el.querySelectorAll('textarea').length).toBe(0)
1645
+ expect(el.textContent).toContain('secret tail')
1646
+ })
1647
+
1648
+ it.each([
1649
+ ['inline title', `[a](/x "${okFiller}</textarea>")`],
1650
+ ['definition title', `[a]: /x "${okFiller}</textarea>"`],
1651
+ ])('…and the UNDER-cap control behaves identically: %s', (_label, shelter) => {
1652
+ const md =
1653
+ 'The <textarea> element explained.\n\n' +
1654
+ shelter +
1655
+ '\n\n## After heading\n\nsecret tail\n'
1656
+ expect(escapeUnknownHtmlTags(md)).toContain('&lt;textarea&gt;')
1657
+ })
1658
+
1659
+ // The over-cap escalation, with attributes.
1660
+ it('does not shelter an attribute-bearing opener behind an over-cap title', async () => {
1661
+ const md =
1662
+ 'Embed: <iframe src="https://evil.example/x" width="600">\n\n' +
1663
+ `[a](/x "${capFiller}</iframe>")\n\n## After heading\n\nsecret tail\n`
1664
+ expect(escapeUnknownHtmlTags(md)).not.toBe(md)
1665
+ const el = await renderStable(<SimpleMarkdownRenderer content={md} />)
1666
+ expect(el.querySelectorAll('iframe').length).toBe(0)
1667
+ })
1668
+
1669
+ // …and a stray `](` still blanks NOTHING: the cap decides when to stop
1670
+ // parsing, never how little to blank, so an unparseable shape declines and
1671
+ // ordinary prose containing `](` is untouched.
1672
+ it('a stray `](` in prose blanks nothing', () => {
1673
+ const md = 'The <textarea> element.\n\nsee foo](bar baz and then </textarea> later\n'
1674
+ const masked = __buildCloserHaystackForTest(md)
1675
+ expect(masked.length).toBe(md.length)
1676
+ expect(masked).toContain('</textarea>')
1677
+ })
1678
+
1679
+ // -------------------------------------------------------------------------
1680
+ // Round-21: the definition parser is ESCAPE-AWARE, and its exits are
1681
+ // CLASSIFIED — consumption blanks, recognition declines.
1682
+ // -------------------------------------------------------------------------
1683
+ // ESCALATION, same shape rounds 19/20 pinned: a multi-line LABEL shelter kept
1684
+ // `src`, `width` and `height` on a LIVE iframe with the paragraph consumed.
1685
+ it('does not shelter an attribute-bearing opener behind a multi-line label', async () => {
1686
+ const md =
1687
+ 'See <iframe src="https://evil.example/x" width="600" height="400">.\n\n' +
1688
+ '[foo\n</iframe>]: /x\n\n## After heading\n\nsecret tail\n'
1689
+ expect(escapeUnknownHtmlTags(md)).not.toBe(md)
1690
+ const el = await renderStable(<SimpleMarkdownRenderer content={md} />)
1691
+ expect(el.querySelectorAll('iframe').length).toBe(0)
1692
+ expect(el.textContent).toContain('secret tail')
1693
+ })
1694
+
1695
+ // THE RECOGNITION BOUNDARY — the controls that keep the multi-line label from
1696
+ // swallowing ordinary prose. In each of these remark EMITS the text (no `]:`
1697
+ // arrives; the `]` is not followed by `:`; the label holds an unescaped `[`;
1698
+ // the line carries trailing content so it is not a definition), so the closer
1699
+ // written there is REAL and the mask must leave it VISIBLE. A pass that
1700
+ // blanked here would over-escape genuine elements — this is the same boundary
1701
+ // `blankBracketLabels` draws around an inline link's `[…]`.
1702
+ it.each([
1703
+ ['no `]:` in the paragraph', 'The <textarea> element.\n\n[foo\n</textarea> bar\n'],
1704
+ ['`]` without `:`', 'The <textarea> element.\n\n[foo\n</textarea>] bar\n'],
1705
+ ['unescaped `[` in the label', 'The <textarea> element.\n\n[foo[bar\n</textarea>]: /x\n'],
1706
+ ['trailing content after a title', 'The <textarea> element.\n\n[a]: /x "t" </textarea>\n'],
1707
+ ['trailing content after a destination', 'The <textarea> element.\n\n[a]: /x </textarea>\n'],
1708
+ ['continuation line that is not a title', 'The <textarea> element.\n\n[a]: /x\n</textarea> is real\n'],
1709
+ ])('leaves a NON-definition visible: %s', (_label, md) => {
1710
+ const masked = __buildCloserHaystackForTest(md)
1711
+ expect(masked.length).toBe(md.length)
1712
+ expect(masked).toContain('</textarea>')
1713
+ expect(escapeUnknownHtmlTags(md)).toBe(md)
1714
+ })
1715
+
1716
+ // …and the consumption side of the same boundary: a title that OPENS and
1717
+ // never closes is blanked TO THE PARAGRAPH BOUND rather than declined, and the
1718
+ // blanking stops there (the heading below it stays visible in the haystack).
1719
+ it('blanks an unterminated definition title to the paragraph bound', () => {
1720
+ const md =
1721
+ 'The <textarea> element explained.\n\n[a]: /x "never closes\n</textarea>\nstill inside\n\n## After heading\n'
1722
+ const masked = __buildCloserHaystackForTest(md)
1723
+ expect(masked.length).toBe(md.length)
1724
+ expect(masked).not.toContain('</textarea>')
1725
+ expect(masked).toContain('## after heading') // the haystack is case-folded
1726
+ })
1727
+
1728
+ // Round-19 (LOW, cosmetic): `blankLinkDefinitions`' label pattern also matched
1729
+ // a GFM FOOTNOTE definition, whose content is BLOCK-parsed and may hold REAL
1730
+ // html. The pair below rendered as escaped visible source while the
1731
+ // byte-identical pair in plain prose rendered correctly. Fail-closed, so not a
1732
+ // security defect — but the two must now agree.
1733
+ it('does not blank a GFM footnote definition', async () => {
1734
+ const md = 'Body[^a].\n\n[^a]: <textarea>hi</textarea>\n'
1735
+ expect(escapeUnknownHtmlTags(md)).toBe(md)
1736
+ const el = await renderStable(<SimpleMarkdownRenderer content={md} />)
1737
+ const plain = await renderStable(
1738
+ <SimpleMarkdownRenderer content={'Body.\n\nSee <textarea>hi</textarea>\n'} />,
1739
+ )
1740
+ expect(el.querySelectorAll('textarea').length).toBe(
1741
+ plain.querySelectorAll('textarea').length,
1742
+ )
1743
+ expect(el.textContent).not.toContain('<textarea>')
1744
+ })
1745
+
1746
+ // …while a real link reference definition next to it is STILL blanked — the
1747
+ // footnote carve-out must not reopen the round-18 shelter.
1748
+ it('still blanks a link definition sharing the document with a footnote', () => {
1749
+ const md =
1750
+ 'The <textarea> element[^a] explained.\n\n[^a]: note\n\n[a]: /x "</textarea>"\n\n' +
1751
+ '## After heading\n\nsecret tail\n'
1752
+ expect(escapeUnknownHtmlTags(md)).toContain('&lt;textarea&gt;')
1753
+ })
1754
+
1755
+ // -------------------------------------------------------------------------
1756
+ // Round-16: INLINE-SPAN LENGTH as a first-class corpus DIMENSION.
1757
+ // -------------------------------------------------------------------------
1758
+ // Every inline-code shelter in the corpus above is a SHORT span, so the whole
1759
+ // six-dimension matrix only ever exercised spans the mask's 4096-char regex
1760
+ // cap could match. Above the cap the span matched NEITHER the mask regex nor
1761
+ // the carve regex, the mask left the sheltered `</textarea>` VISIBLE in the
1762
+ // closer haystack, `hasLaterCloser` returned true, the prose opener stayed
1763
+ // LIVE and parse5 swallowed the rest of the message as the textarea's value.
1764
+ // A clean cliff with padding as the only variable — span content ≤4094 chars:
1765
+ // 0 live textareas; ≥4099: 1. The mask no longer uses a capped regex
1766
+ // (`findInlineCodeRanges`), and this axis pins the dimension: every shelter
1767
+ // spelling is now swept at cap−k AND cap+k.
1768
+ const INLINE_SPAN_PADS = { 'under-cap': 4080, 'over-cap': 4085 } as const
1769
+
1770
+ /** Shelter spellings, parameterized by RAWTEXT tag and inline-span padding. */
1771
+ const SPAN_LENGTH_SHAPES: Record<string, (tag: string, pad: number) => string> = {
1772
+ // The reported hole: the CLOSER hides inside an over-long span, so the
1773
+ // prose opener above it must still be escaped.
1774
+ 'closer-in-inline-code': (tag, pad) =>
1775
+ `intro\n\n<${tag}>\n\n\`${'x'.repeat(pad)} </${tag}> \`\n\n## After heading\n\nsecret tail\n`,
1776
+ // …and the mirror shape: the OPENER hides inside an over-long span that
1777
+ // sits inside an HTML block, where the span is not really code.
1778
+ 'opener-in-inline-code-in-html-block-div': (tag, pad) =>
1779
+ `intro\n\n<div>\n\`${'x'.repeat(pad)} <${tag}> \`\n</div>\n\n## After heading\n\nsecret tail\n`,
1780
+ 'opener-in-inline-code-in-html-block-pre': (tag, pad) =>
1781
+ `intro\n\n<pre>\n\`${'x'.repeat(pad)} <${tag}> \`\n</pre>\n\n## After heading\n\nsecret tail\n`,
1782
+ 'opener-in-inline-code-in-html-block-details': (tag, pad) =>
1783
+ `intro\n\n<details>\n\`${'x'.repeat(pad)} <${tag}> \`\n</details>\n\n## After heading\n\nsecret tail\n`,
1784
+ 'opener-in-inline-code-in-blockquoted-html-block': (tag, pad) =>
1785
+ `> intro\n>\n> <div>\n> \`${'x'.repeat(pad)} <${tag}> \`\n> </div>\n>\n> ## After heading\n>\n> secret tail\n`,
1786
+ }
1787
+
1788
+ const SPAN_LENGTH_CASES: [string, string, keyof typeof INLINE_SPAN_PADS][] = []
1789
+ for (const shape of Object.keys(SPAN_LENGTH_SHAPES))
1790
+ for (const tag of ['textarea', 'iframe'])
1791
+ for (const len of Object.keys(INLINE_SPAN_PADS) as (keyof typeof INLINE_SPAN_PADS)[])
1792
+ SPAN_LENGTH_CASES.push([shape, tag, len])
1793
+
1794
+ it.each(SPAN_LENGTH_CASES)(
1795
+ 'shelter holds at every inline-span length: %s / %s / %s',
1796
+ async (shape, tag, len) => {
1797
+ const build = SPAN_LENGTH_SHAPES[shape]
1798
+ const bare = build(tag, INLINE_SPAN_PADS[len])
1799
+ const md = selfClose(bare)
1800
+ const label = `${shape} / ${tag} / ${len}`
1801
+ for (const [spelling, src] of [
1802
+ ['bare', bare],
1803
+ ['self-closing', md],
1804
+ ] as const) {
1805
+ const el = await renderStable(<SimpleMarkdownRenderer content={src} />)
1806
+ // Nothing RAWTEXT may be live, in EITHER tag spelling and at EITHER
1807
+ // side of the retired cap.
1808
+ expect(
1809
+ el.querySelectorAll(RAWTEXT_SELECTORS.join(',')).length,
1810
+ `${label} / ${spelling}`,
1811
+ ).toBe(0)
1812
+ // …and the document below the shelter must survive intact: a swallow
1813
+ // leaves the tail alive only as a textarea VALUE, invisible to the
1814
+ // element count above.
1815
+ expect(el.textContent, `${label} / ${spelling}`).toContain('secret tail')
1816
+ }
1817
+ },
1818
+ )
1819
+
1820
+ // Round-16: the mask's inline-code pass stopped being a regex, so the claim
1821
+ // "same match semantics, minus the cap and the backtracking" needs a proof.
1822
+ // Differential fuzz against the retired regex in its UNCAPPED spelling over a
1823
+ // backtick-dense alphabet — the shapes that exercise every branch (opener run
1824
+ // giving back backticks, a closer inside the SAME run, a resume landing
1825
+ // MID-RUN, and no-closer-at-all).
1826
+ //
1827
+ // ROUND-18 — THE ORACLE'S UNIT CHANGED FROM A LINE TO A PARAGRAPH SEGMENT.
1828
+ // A CommonMark code span crosses line breaks, so the retired per-line regex
1829
+ // was not merely slow, it was WRONG in the fail-OPEN direction (`` `foo\n
1830
+ // </textarea>` `` left the closer visible in the haystack — see
1831
+ // `findInlineCodeRanges`). The oracle therefore changes shape with the scan:
1832
+ // the SAME regex — greedy opener run, lazy body, backreference closer — with
1833
+ // its body class widened from `[^\n]` to `[\s\S]`, applied INDEPENDENTLY to
1834
+ // each maximal run of non-blank lines and offset back into the document.
1835
+ //
1836
+ // WHY IT IS STILL A VALID ORACLE: it is derived from the specification, not
1837
+ // from the implementation. CommonMark's code-span rule is exactly "matching
1838
+ // backtick strings within one paragraph", so the regex expresses the closer
1839
+ // semantics and the segmentation expresses the paragraph bound; the two are
1840
+ // written here with completely different machinery (regex backtracking +
1841
+ // `split`) from the scan's suffix-max index walk. `isBlankLine` is shared on
1842
+ // purpose — the definition of a paragraph break is one fact, not two — and it
1843
+ // is itself pinned by the exotic-blank dimension of the sweep. The alphabet
1844
+ // already contains `\n` and ' ', so blank lines, one-line segments and
1845
+ // multi-line segments are all generated.
1846
+ it('the inline-code scan matches the retired regex, per paragraph segment', () => {
1847
+ const TICK = String.fromCharCode(96)
1848
+ const RE = new RegExp('(' + TICK + '+)[\\s\\S]*?\\1', 'g')
1849
+ const alphabet = [TICK, TICK, TICK, 'a', 'b', '\n', ' ', '<', '>']
1850
+ let seed = 12345
1851
+ const rnd = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff)
1852
+ /** Maximal runs of non-blank lines, as `[start, end)` offsets. */
1853
+ const segments = (src: string): [number, number][] => {
1854
+ const out: [number, number][] = []
1855
+ let offset = 0
1856
+ let start = -1
1857
+ let end = -1
1858
+ for (const line of src.split('\n')) {
1859
+ if (isBlankLine(line)) {
1860
+ if (start !== -1) out.push([start, end])
1861
+ start = -1
1862
+ } else {
1863
+ if (start === -1) start = offset
1864
+ end = offset + line.length
1865
+ }
1866
+ offset += line.length + 1
1867
+ }
1868
+ if (start !== -1) out.push([start, end])
1869
+ return out
1870
+ }
1871
+ for (let iter = 0; iter < 20000; iter++) {
1872
+ let src = ''
1873
+ const n = 1 + Math.floor(rnd() * 24)
1874
+ for (let i = 0; i < n; i++) src += alphabet[Math.floor(rnd() * alphabet.length)]
1875
+ const want: [number, number][] = []
1876
+ for (const [from, to] of segments(src)) {
1877
+ const seg = src.slice(from, to)
1878
+ RE.lastIndex = 0
1879
+ let m: RegExpExecArray | null
1880
+ while ((m = RE.exec(seg)) !== null)
1881
+ want.push([from + m.index, from + m.index + m[0].length])
1882
+ }
1883
+ expect(__findInlineCodeRangesForTest(src), JSON.stringify(src)).toEqual(want)
1884
+ }
1885
+ })
1886
+
1887
+ // -------------------------------------------------------------------------
1888
+ // Round-16: the sweep also drives `splitStreamingBlocks`.
1889
+ // -------------------------------------------------------------------------
1890
+ // Every arm above renders through `SimpleMarkdownRenderer`, so NEITHER the
1891
+ // exotic-blank NOR the CRLF dimension ever touched the streaming splitter —
1892
+ // which is exactly how four hand-rolled `line.trim() === ''` tests survived
1893
+ // in `streaming.ts` after `isBlankLine` was minted as THE blank-line SSOT.
1894
+ // `trim()` strips U+00A0 / U+000B / U+000C / U+FEFF, none of which CommonMark
1895
+ // calls blank, so one such line cut a paragraph into two `ReactMarkdown`
1896
+ // units on the streaming path (two `<p>` where the non-streaming and SEO
1897
+ // twins render one). The control is the SAME document with the exotic filler
1898
+ // replaced by an ordinary non-blank character: both are non-blank under
1899
+ // CommonMark, so both must split identically.
1900
+ it('the splitter is swept across the corpus x containers x line endings', () => {
1901
+ const EXOTIC_FILLERS: Record<string, string> = {
1902
+ 'exotic-blank-nbsp': '\u00a0',
1903
+ 'exotic-blank-vertical-tab': '\u000b',
1904
+ 'exotic-blank-form-feed': '\u000c',
1905
+ 'exotic-blank-bom': '\ufeff',
1906
+ }
1907
+ let checked = 0
1908
+ let controls = 0
1909
+ for (const name of SWALLOW_CORPUS) {
1910
+ for (const [wrapper, spec] of Object.entries(CONTAINER_WRAPPERS)) {
1911
+ if (COLUMN_SENSITIVE_ENTRIES.has(name) && !spec.preservesColumns) continue
1912
+ const wrapped = spec.wrap(SHARED_FIXTURES[name])
1913
+ const lfBlocks = splitStreamingBlocks(wrapped)
1914
+ for (const eol of LINE_ENDING_NAMES) {
1915
+ const md = LINE_ENDINGS[eol](wrapped)
1916
+ const blocks = splitStreamingBlocks(md)
1917
+ const label = `${name} / ${wrapper} / ${eol}`
1918
+ // LOSSLESS: the units are a partition of the source, so the engine's
1919
+ // per-unit parses cover the document exactly once.
1920
+ expect(blocks.map((b) => b.text).join('\n'), label).toBe(md)
1921
+ // …and `startLine` stays a running line count over that partition.
1922
+ let line = 1
1923
+ for (const b of blocks) {
1924
+ expect(b.startLine, `${label} #${b.index}`).toBe(line)
1925
+ line += b.text.split('\n').length
1926
+ }
1927
+ // The line ending is not a block boundary: CRLF must split exactly
1928
+ // like its LF twin.
1929
+ expect(blocks.length, label).toBe(lfBlocks.length)
1930
+ checked++
1931
+ }
1932
+ // EXOTIC BLANKS ARE NOT BLANK. Compare against the non-blank control.
1933
+ const filler = EXOTIC_FILLERS[wrapper]
1934
+ if (filler !== undefined) {
1935
+ const control = SHARED_FIXTURES[name]
1936
+ .split('\n')
1937
+ .map((l) => (l === '' ? 'x' : l))
1938
+ .join('\n')
1939
+ expect(
1940
+ splitStreamingBlocks(wrapped).length,
1941
+ `${name} / ${wrapper} vs non-blank control`,
1942
+ ).toBe(splitStreamingBlocks(control).length)
1943
+ controls++
1944
+ }
1945
+ }
1946
+ }
1947
+ // Pinned, not "greater than": the splitter arm must stay in lockstep with
1948
+ // the renderer arm's (entry x wrapper x line-ending) matrix above.
1949
+ expect(checked).toBe(CONTAINER_CASES.length)
1950
+ expect(controls).toBe(SWALLOW_CORPUS.length * 4)
1951
+ })
1952
+
1953
+ // Round-11: the balance guard must NOT touch inline code. An inline span can
1954
+ // shelter nothing (remark emits it as an `inlineCode` text node), and entity
1955
+ // refs are not recognized inside code spans — so escaping there is always
1956
+ // visible to the reader as the literal text `&lt;title&gt;`.
1957
+ it.each([
1958
+ ['inline-code-rawtext-title-not-escaped', '<title>'],
1959
+ ['inline-code-rawtext-textarea-not-escaped', '<textarea>'],
1960
+ ])('keeps a rawtext tag named in inline code verbatim: %s', async (name, tag) => {
1961
+ const container = await renderStable(<SimpleMarkdownRenderer content={SHARED_FIXTURES[name]} />)
1962
+ const code = container.querySelector('code')
1963
+ expect(code?.textContent, name).toBe(tag)
1964
+ expect(container.textContent, name).not.toContain('&lt;')
1965
+ // …and the inline span still shelters nothing live.
1966
+ expect(container.querySelectorAll('textarea').length, name).toBe(0)
1967
+ })
1968
+
1969
+ // Round-11 SECURITY: `TAG_LIKE_REGEX` hard-bounds its attribute run at 4096
1970
+ // chars (ReDoS hardening). An attribute run LONGER than that made the tag
1971
+ // fail to match AT ALL, so neither the allowlist nor the RAWTEXT closer check
1972
+ // ever ran and a live opener reached the DOM verbatim. Kept out of
1973
+ // SHARED_FIXTURES only to spare the snapshots ~20KB of filler.
1974
+ it.each([
1975
+ ['iframe', `Hello\n\n<iframe src="${'b'.repeat(5000)}">\n\n## After heading\n\n- item\n`],
1976
+ ['textarea', `Hello\n\n<textarea ${'a'.repeat(5000)}>\n\n## After heading\n\n- item\n`],
1977
+ ['unknown', `Hello\n\n<their ${'a'.repeat(5000)}>\n\n## After heading\n\n- item\n`],
1978
+ ])('escapes a tag whose attribute run exceeds the regex cap: %s', async (tag, md) => {
1979
+ const container = await renderStable(<SimpleMarkdownRenderer content={md} />)
1980
+ expect(container.querySelectorAll(tag).length, tag).toBe(0)
1981
+ expect(container.querySelector('h2')?.textContent, tag).toBe('After heading')
1982
+ expect(container.querySelectorAll('li').length, tag).toBe(1)
1983
+ })
1984
+
1985
+ // …and the fallback must not double-escape what the main pass already handled.
1986
+ it('the over-long-tag fallback does not double-escape ordinary tags', () => {
1987
+ for (const src of [
1988
+ 'a <their>b</their> c',
1989
+ 'a <textarea>b</textarea> c',
1990
+ '<div class="x">y</div>',
1991
+ 'Hello <textarea/> world',
1992
+ 'plain text with no tags at all',
1993
+ '`<title>` in code',
1994
+ ]) {
1995
+ expect(escapeUnknownHtmlTags(src), src).not.toContain('&amp;lt;')
1996
+ }
1997
+ })
1998
+
1999
+ // Round-9: the indented-code walk must see the CURRENT mask, so a list marker
2000
+ // written inside a fence cannot shift the content-column stack.
2001
+ it('a list marker inside a fence does not unblank later indented code', async () => {
2002
+ const container = await renderStable(
2003
+ <SimpleMarkdownRenderer
2004
+ content={SHARED_FIXTURES['list-marker-inside-fence-does-not-shift-indent-columns']}
2005
+ />,
2006
+ )
2007
+ expect(container.querySelectorAll('textarea').length).toBe(0)
2008
+ })
2009
+
2010
+ it('survives the HTML-block + in-fence-list-marker combination', async () => {
2011
+ const container = await renderStable(
2012
+ <SimpleMarkdownRenderer content={SHARED_FIXTURES['list-marker-inside-fence-html-block-combo']} />,
2013
+ )
2014
+ expect(container.querySelectorAll('textarea').length).toBe(0)
2015
+ expect(container.querySelector('h1')?.textContent).toBe('Heading after')
2016
+ expect(container.textContent).toContain('Body after.')
2017
+ })
2018
+
2019
+ // Round-9: CommonMark clamps the item content column when the marker is
2020
+ // followed by more than 4 spaces.
2021
+ it('clamps a list content column when the marker gap exceeds 4 spaces', async () => {
2022
+ const container = await renderStable(
2023
+ <SimpleMarkdownRenderer content={SHARED_FIXTURES['wide-list-marker-gap-clamps-content-column']} />,
2024
+ )
2025
+ expect(container.querySelectorAll('textarea').length).toBe(0)
2026
+ expect(container.querySelector('h2')?.textContent).toBe('After heading')
2027
+ })
2028
+
2029
+ // Round-7: a `<!--` inside code is not a comment start. Both of these blanked
2030
+ // the rest of the document (and escaped a perfectly valid element) when
2031
+ // `blankComments` scanned the unmasked copy.
2032
+ it.each([
2033
+ 'comment-marker-in-inline-code-does-not-blank-to-eof',
2034
+ 'truncated-comment-in-fence-does-not-blank-to-eof',
2035
+ ])('a `<!--` inside code does not disable later closers: %s', async (name) => {
2036
+ const container = await renderStable(<SimpleMarkdownRenderer content={SHARED_FIXTURES[name]} />)
2037
+ expect(container.querySelectorAll('textarea').length, name).toBe(1)
2038
+ })
2039
+
2040
+ // Round-7: list-item continuation lines are paragraphs, not indented code.
2041
+ it.each([
2042
+ 'list-continuation-textarea-stays-live-ordered',
2043
+ 'list-continuation-textarea-stays-live-bullet',
2044
+ ])('keeps a list-continuation <textarea> live: %s', async (name) => {
2045
+ const container = await renderStable(<SimpleMarkdownRenderer content={SHARED_FIXTURES[name]} />)
2046
+ expect(container.querySelectorAll('textarea').length, name).toBe(1)
2047
+ })
2048
+
2049
+ // The blockquote pass strips the `>` prefix and runs a NESTED tracker rather
2050
+ // than blanking every quoted line, precisely so a legitimately PAIRED
2051
+ // `<textarea>` inside a blockquote keeps rendering as a real element.
2052
+ it('keeps a properly paired <textarea> inside a blockquote live', async () => {
2053
+ const md = '> quoted intro\n>\n> <textarea>edit me</textarea>\n\n## After heading\n'
2054
+ const container = await renderStable(<SimpleMarkdownRenderer content={md} />)
2055
+ expect(container.querySelectorAll('textarea').length).toBe(1)
2056
+ expect(container.querySelector('h2')?.textContent).toBe('After heading')
2057
+ })
2058
+
2059
+ it('leaves the opener live when a REAL closer follows, regardless of İ count', async () => {
2060
+ for (const n of [0, 20, 25, 60]) {
2061
+ const md = `${'İ'.repeat(n)} <textarea>a</textarea>\n\n<textarea>\n\n## Later heading\n\n- item one\n`
2062
+ const container = await renderStable(<SimpleMarkdownRenderer content={md} />)
2063
+ // Exactly ONE textarea: the properly closed one. The bare opener must be
2064
+ // escaped, so the heading and the list below survive as real elements.
2065
+ expect(container.querySelectorAll('textarea').length, `n=${n}`).toBe(1)
2066
+ expect(container.querySelectorAll('h2').length, `n=${n}`).toBe(1)
2067
+ expect(container.querySelectorAll('li').length, `n=${n}`).toBe(1)
2068
+ }
2069
+ })
2070
+
2071
+ // Round-22 (SECURITY): the GFM-footnote RECOGNITION decline was fail-OPEN for
2072
+ // an UNREFERENCED definition. The decline's justification — "a footnote body
2073
+ // is BLOCK-parsed and may hold REAL html" — holds only when the footnote is
2074
+ // REFERENCED. `remark-gfm` DROPS an unreferenced definition entirely, so
2075
+ // nothing in its body reaches the document, yet the whole line stayed live in
2076
+ // the closer haystack and the prose opener above it was left unescaped.
2077
+ // Reproduced end-to-end: a LIVE `<iframe>` keeping `src` and `width`,
2078
+ // swallowing the prose above it.
2079
+ it.each([
2080
+ ['plain', 'Secret prose.\n\n<iframe src="https://evil.example/x" width="600">\n\nvisible text\n\n[^f]: note body </iframe>\n'],
2081
+ ['textarea', 'Secret prose.\n\n<textarea>\n\nvisible text\n\n[^f]: note body </textarea>\n'],
2082
+ ['blockquoted', 'Secret prose.\n\n<textarea>\n\nvisible text\n\n> [^f]: note body </textarea>\n'],
2083
+ ['list-marker', 'Secret prose.\n\n<textarea>\n\nvisible text\n\n- [^f]: note body </textarea>\n'],
2084
+ ['lazy-continuation', 'Secret prose.\n\n<textarea>\n\nvisible text\n\n[^f]: note body\nstill the note </textarea>\n'],
2085
+ ['second-paragraph', 'Secret prose.\n\n<textarea>\n\nvisible text\n\n[^f]: note body\n\n second para </textarea>\n'],
2086
+ ['other-label-referenced', 'Secret prose.\n\n<textarea>\n\nvisible text [^Other]\n\n[^f]: note body </textarea>\n'],
2087
+ ])('escapes the opener when the only closer is an UNREFERENCED footnote body: %s', async (name, md) => {
2088
+ expect(escapeUnknownHtmlTags(md), name).toContain('&lt;')
2089
+ const container = await renderStable(<SimpleMarkdownRenderer content={md} />)
2090
+ expect(container.querySelectorAll('iframe').length, name).toBe(0)
2091
+ expect(container.querySelectorAll('textarea').length, name).toBe(0)
2092
+ expect(container.textContent, name).toContain('visible text')
2093
+ })
2094
+
2095
+ // …and the decline is KEPT where its justification actually holds: a
2096
+ // REFERENCED footnote's body IS block-parsed and reaches the document, so a
2097
+ // closer written there is REAL and blanking it would over-escape.
2098
+ it('keeps a REFERENCED footnote body live (the round-19 decline, narrowed not dropped)', async () => {
2099
+ const md = 'See [^f] here.\n\n[^f]: note <textarea>edit me</textarea>\n'
2100
+ const container = await renderStable(<SimpleMarkdownRenderer content={md} />)
2101
+ expect(container.querySelectorAll('textarea').length).toBe(1)
2102
+ })
2103
+
2104
+ // Label matching follows micromark's `normalizeIdentifier`: whitespace runs
2105
+ // collapse to one space, the label is trimmed, and case is folded. A
2106
+ // reference spelled differently but NORMALIZING to the definition's label
2107
+ // still counts as a reference (so the body stays live).
2108
+ it.each([
2109
+ ['case', 'See [^Foo] here.\n\n[^foo]: note <textarea>edit me</textarea>\n'],
2110
+ ['whitespace', 'See [^a b] here.\n\n[^a b]: note <textarea>edit me</textarea>\n'],
2111
+ ])('normalizes footnote labels when matching reference to definition: %s', async (name, md) => {
2112
+ const container = await renderStable(<SimpleMarkdownRenderer content={md} />)
2113
+ expect(container.querySelectorAll('textarea').length, name).toBe(1)
2114
+ })
2115
+
2116
+ // Round-23 (SECURITY): the round-22 pass above counted PHANTOM references.
2117
+ // Its reference collection ran over the current mask, which at that point in
2118
+ // the pipeline has NOT yet had inline link payloads, bracket labels, HTML
2119
+ // comments, HTML blocks, tag attributes or autolinks blanked. Every `[^…]`
2120
+ // remark consumes into an ATTRIBUTE or drops therefore counted as a live
2121
+ // reference, the definition it pointed at was treated as REFERENCED, its
2122
+ // `</textarea>` stayed in the closer haystack and the prose opener above it
2123
+ // was left LIVE — the very fail-open round 22 existed to close, reopened in
2124
+ // ten spellings. Phantom-ness is independently checkable: with remark alone,
2125
+ // `visible text ![x [^f] y](/i.png)` + `[^f]: body` emits NO `data-footnotes`
2126
+ // section, while a real reference does.
2127
+ it.each([
2128
+ ['image alt', 'S.\n\n<textarea>\n\nvisible text ![[^f]](/i.png) t\n\n[^f]: b </textarea>\n'],
2129
+ ['full-reference label', 'S.\n\n<textarea>\n\nvisible text [txt][[^f]] t\n\n[^f]: b </textarea>\n'],
2130
+ ['raw HTML block', 'S.\n\n<textarea>\n\nvisible text\n\n<div>\n[^f]\n</div>\n\n[^f]: b </textarea>\n'],
2131
+ ['inline link title', 'S.\n\n<textarea>\n\nvisible text [a](/x "[^f]") t\n\n[^f]: b </textarea>\n'],
2132
+ ['image title', "S.\n\n<textarea>\n\nvisible text ![a](/x '[^f]') t\n\n[^f]: b </textarea>\n"],
2133
+ ['angle destination', 'S.\n\n<textarea>\n\nvisible text [a](<[^f]>) t\n\n[^f]: b </textarea>\n'],
2134
+ ['HTML comment', 'S.\n\n<textarea>\n\nvisible text\n\n<!-- [^f] -->\n\n[^f]: b </textarea>\n'],
2135
+ ['inline tag attribute', 'S.\n\n<textarea>\n\nvisible text <span title="[^f]">x</span>\n\n[^f]: b </textarea>\n'],
2136
+ ['angle autolink', 'S.\n\n<textarea>\n\nvisible text <https://e.example/[^f]>\n\n[^f]: b </textarea>\n'],
2137
+ ['literal autolink', 'S.\n\n<textarea>\n\nvisible text https://e.example/x[^f]y\n\n[^f]: b </textarea>\n'],
2138
+ ])('escapes the opener when the only "reference" is a PHANTOM: %s', async (name, md) => {
2139
+ expect(escapeUnknownHtmlTags(md), name).toContain('&lt;')
2140
+ const container = await renderStable(<SimpleMarkdownRenderer content={md} />)
2141
+ expect(container.querySelectorAll('textarea').length, name).toBe(0)
2142
+ expect(container.textContent, name).toContain('visible text')
2143
+ })
2144
+
2145
+ // …and the same escalation, with an ATTRIBUTE-BEARING opener: the phantom used
2146
+ // to leave a live third-party iframe keeping `src` and `width`.
2147
+ it('escapes an attribute-bearing iframe opener behind a phantom reference', async () => {
2148
+ const md =
2149
+ 'S.\n\n<iframe src="https://evil.example/x" width="600">\n\nvisible text ![[^f]](/i.png)\n\n[^f]: b </iframe>\n'
2150
+ const container = await renderStable(<SimpleMarkdownRenderer content={md} />)
2151
+ expect(container.querySelectorAll('iframe').length).toBe(0)
2152
+ expect(container.textContent).toContain('visible text')
2153
+ })
2154
+
2155
+ // THE OVER-BLANK DIRECTION, which the narrowing must NOT cross: where remark
2156
+ // really does resolve the reference, the definition body is document text and
2157
+ // its closer is REAL, so the element must keep rendering. The opener here is
2158
+ // `<iframe …>` (a CommonMark type-6 block that ends at the blank line) rather
2159
+ // than `<textarea>` (type 1, which would run to `</textarea>` and make the
2160
+ // whole middle raw HTML — that shape is confounded, not a control).
2161
+ it.each([
2162
+ ['plain reference', 'v [^f] t'],
2163
+ ['inline link text', 'v [[^f]](/x) t'],
2164
+ ['shortcut reference text', 'v [[^f]] t'],
2165
+ ['table cell', '| h |\n| - |\n| [^f] |'],
2166
+ ['after an autolink', 'v https://e.example/x [^f] t'],
2167
+ ['email autolink (the `[` voids it)', 'v <a[^f]b@e.example> t'],
2168
+ ])('keeps the element live for a GENUINE reference: %s', async (name, body) => {
2169
+ const md = `S.\n\n<iframe src="https://evil.example/x" width="600">\n\n${body}\n\n[^f]: b </iframe>\n`
2170
+ const container = await renderStable(<SimpleMarkdownRenderer content={md} />)
2171
+ expect(container.querySelectorAll('iframe').length, name).toBe(1)
2172
+ })
2173
+
2174
+ // THE ONE NAMED RESIDUAL, pinned so it stays visible rather than becoming the
2175
+ // next round's surprise: a reference living ONLY inside another, itself
2176
+ // UNREFERENCED, definition's body. remark drops both, so this is a phantom
2177
+ // too, but seeing it needs a FIXPOINT (blank, rebuild the ref-mask, recount)
2178
+ // rather than one pass. Deliberately not built — see the pass docblock.
2179
+ it('KNOWN RESIDUAL: a reference inside another dead definition still counts', async () => {
2180
+ const md =
2181
+ 'S.\n\n<iframe src="https://evil.example/x" width="600">\n\nvisible text\n\n[^a]: see [^f]\n\n[^f]: b </iframe>\n'
2182
+ const container = await renderStable(<SimpleMarkdownRenderer content={md} />)
2183
+ expect(container.querySelectorAll('iframe').length).toBe(1)
2184
+ })
2185
+
2186
+ // Round-22: a payload that RUNS OUT OF INPUT is a SHAPE decline, not a cap
2187
+ // exit — the common streaming shape (`see [a](/x` as the last token). It used
2188
+ // to return `limit`, which the caller widened to `paragraphEnd`, blanking the
2189
+ // paragraph tail and escaping an earlier opener mid-stream that unescaped
2190
+ // again on completion (a visible flicker).
2191
+ it('does not blank the paragraph tail for a link payload cut off at end of input', () => {
2192
+ expect(__buildCloserHaystackForTest('see [a](/x')).toBe('see [a](/x')
2193
+ expect(__buildCloserHaystackForTest('see [a](/x "t')).toBe('see [a](/x "t')
2194
+ })
2195
+
2196
+ // …while the CAP path must still BLANK THROUGH (the round-20 fix).
2197
+ it('still blanks through the cap for an over-long payload', () => {
2198
+ const md = `[a](/x "<${'y'.repeat(1100)}></textarea>")\n\ntail\n`
2199
+ expect(__buildCloserHaystackForTest(md)).not.toContain('</textarea>')
2200
+ })
2201
+ })
2202
+
2203
+ // ---------------------------------------------------------------------------
2204
+ // Streaming pre-pass ordering (round-5)
2205
+ // ---------------------------------------------------------------------------
2206
+ describe('streaming completes the tail BEFORE escaping', () => {
2207
+ it('a `</textarea>` inside a still-open fence does not unescape the prose opener', async () => {
2208
+ // The canonical shape: an LLM explains `<textarea>`, then streams a fenced
2209
+ // HTML sample. Mid-emission the fence is unclosed, so escaping first left
2210
+ // the prose opener live and parse5 swallowed the rest of the message.
2211
+ const md = 'Explaining <textarea> in HTML.\n\n## Heading\n\n```html\n</textarea>\n'
2212
+ const container = await renderStable(<SimpleMarkdownRenderer content={md} streaming />)
2213
+ expect(container.querySelectorAll('textarea').length).toBe(0)
2214
+ expect(container.querySelector('h2')?.textContent).toBe('Heading')
2215
+ expect(container.textContent).toContain('Explaining <textarea> in HTML.')
2216
+ })
2217
+
2218
+ it('does NOT escape an unknown tag inside a mid-emission fence', async () => {
2219
+ // The carve's "unclosed fence body is unprotected" tradeoff used to fire on
2220
+ // EVERY streaming frame: a `<their>` mid-fence rendered as literal
2221
+ // `&lt;their&gt;` until the closer arrived, then snapped back.
2222
+ const md = 'intro\n\n```html\n<their>x</their>\n'
2223
+ const container = await renderStable(<SimpleMarkdownRenderer content={md} streaming />)
2224
+ expect(container.textContent).toContain('<their>x</their>')
2225
+ expect(container.textContent).not.toContain('&lt;their&gt;')
2226
+ })
2227
+
2228
+ it('token-by-token growth through an open fence never swallows the document', async () => {
2229
+ const full = 'Explaining <textarea> in HTML.\n\n## Heading\n\n```html\n<textarea>v</textarea>\n```\n'
2230
+ // Start once `## Heading\n` has fully arrived (earlier cuts legitimately
2231
+ // show a partial title) and walk the rest of the fence in small steps.
2232
+ const from = full.indexOf('## Heading') + '## Heading\n'.length
2233
+ for (let cut = from; cut <= full.length; cut += 3) {
2234
+ const container = await renderStable(
2235
+ <SimpleMarkdownRenderer content={full.slice(0, cut)} streaming />,
2236
+ )
2237
+ expect(container.querySelector('h2')?.textContent, `cut=${cut}`).toBe('Heading')
2238
+ // The h2 assertion alone is NOT enough: a swallow that starts after the
2239
+ // heading leaves the <h2> intact while the rest of the message becomes a
2240
+ // live textarea value. Pin the absence of the swallow itself.
2241
+ const textareas = Array.from(container.querySelectorAll('textarea'))
2242
+ expect(textareas.length, `cut=${cut}`).toBe(0)
2243
+ }
2244
+ })
2245
+ })
2246
+
2247
+ // ---------------------------------------------------------------------------
2248
+ // RichMarkdownRenderer parity
2249
+ // ---------------------------------------------------------------------------
2250
+ describe('RichMarkdownRenderer golden parity', () => {
2251
+ for (const [name, md] of Object.entries({ ...SHARED_FIXTURES, ...RICH_ONLY_FIXTURES })) {
2252
+ it(`renders fixture: ${name}`, async () => {
2253
+ const container = await renderStable(<RichMarkdownRenderer content={md} />)
2254
+ expect(normalize(container.innerHTML)).toMatchSnapshot()
2255
+ })
2256
+ }
2257
+
2258
+ it('renders headings with backend sectionIds + demoteMarkdownH1ToH2', async () => {
2259
+ const container = await renderStable(
2260
+ <RichMarkdownRenderer
2261
+ content={SHARED_FIXTURES['headings-with-emoji-and-dupes']}
2262
+ sectionIds={SECTION_IDS}
2263
+ demoteMarkdownH1ToH2
2264
+ />,
2265
+ )
2266
+ expect(normalize(container.innerHTML)).toMatchSnapshot()
2267
+ })
2268
+ })
2269
+
2270
+ // ---------------------------------------------------------------------------
2271
+ // Heading-id ↔ section-extractor agreement (three slug copies today —
2272
+ // this pins the CURRENT agreement so the Phase-1 shared helper can prove
2273
+ // it changed nothing)
2274
+ // ---------------------------------------------------------------------------
2275
+ describe('heading-id and section-extractor slug agreement', () => {
2276
+ // Includes a THIRD duplicate (`setup-3` — the suffix counter must keep
2277
+ // counting, not reset per pair) and a SYMBOL-ONLY heading (`## !!!`, whose
2278
+ // slug collapses to '' and hits the `section-N` fallback, where the two
2279
+ // implementations previously used different counters).
2280
+ const AGREEMENT_MD =
2281
+ `# 🚀 Getting Started\n\n## Setup\n\n## Setup\n\n## Setup\n\n## !!!\n\n## Weird — punct!, chars?\n`
2282
+
2283
+ it('renderer-generated heading ids equal extractSections ids for the same markdown', async () => {
2284
+ const sections = extractSections(AGREEMENT_MD, { maxLevel: 2 })
2285
+ const container = await renderStable(<SimpleMarkdownRenderer content={AGREEMENT_MD} />)
2286
+ const renderedIds = Array.from(container.querySelectorAll('h1, h2')).map((el) => el.id)
2287
+ expect(renderedIds).toEqual(sections.map((s) => s.id))
2288
+ expect(sections).toMatchSnapshot('extracted-sections')
2289
+ })
2290
+
2291
+ // --- round-15 REGRESSION vs `main`: CRLF documents ------------------------
2292
+ // `main`'s extractor pattern was `^(#{1,N})\\s+(.+)` — no end anchor at all,
2293
+ // so the trailing `\\r` of a CRLF line landed in the title and was trimmed off
2294
+ // afterwards. The unified `ATX_HEADING_RE` ends `[ \\t]*$`, which `\\r` does not
2295
+ // satisfy, so a CRLF document produced ZERO sections and ZERO heading anchors
2296
+ // where `main` produced them: every CRLF-stored doc / blog / release body
2297
+ // silently lost its TOC, its in-page anchors and its doc-SEO heading links.
2298
+ // (Direct A/B over this fixture: `main` → ["Real","Second"], branch → [].)
2299
+ const CRLF_HEADINGS_MD =
2300
+ '# Real\r\n\r\nbody\r\n\r\n## Second\r\n\r\n```\r\n# In fence\r\n```\r\n'
2301
+
2302
+ it('CRLF documents scan the same headings as their LF twin', () => {
2303
+ const lf = CRLF_HEADINGS_MD.replace(/\r/g, '')
2304
+ expect(scanHeadings(CRLF_HEADINGS_MD)).toEqual(scanHeadings(lf))
2305
+ expect(extractSections(CRLF_HEADINGS_MD, { maxLevel: 2 }).map((sec) => sec.title)).toEqual([
2306
+ 'Real',
2307
+ 'Second',
2308
+ ])
2309
+ // …and the `# In fence` line is still fence content in BOTH spellings —
2310
+ // the fence tracker used to match no CRLF line at all.
2311
+ expect(extractSections(CRLF_HEADINGS_MD, { maxLevel: 2 })).toEqual(
2312
+ extractSections(lf, { maxLevel: 2 }),
2313
+ )
2314
+ })
2315
+
2316
+ it('CRLF renderer heading ids equal extractSections ids', async () => {
2317
+ const sections = extractSections(CRLF_HEADINGS_MD, { maxLevel: 2 })
2318
+ const container = await renderStable(<SimpleMarkdownRenderer content={CRLF_HEADINGS_MD} />)
2319
+ const renderedIds = Array.from(container.querySelectorAll('h1, h2')).map((el) => el.id)
2320
+ expect(renderedIds).toEqual(sections.map((sec) => sec.id))
2321
+ expect(renderedIds).toEqual(['real', 'second'])
2322
+ })
2323
+
2324
+ it('heading ids are STABLE across re-renders of the same content', async () => {
2325
+ const idsOf = (c: HTMLElement) =>
2326
+ Array.from(c.querySelectorAll('h1, h2')).map((el) => el.id)
2327
+
2328
+ // Two independent mounts must agree…
2329
+ const first = await renderStable(<SimpleMarkdownRenderer content={AGREEMENT_MD} />)
2330
+ const firstIds = idsOf(first)
2331
+ const second = await renderStable(<SimpleMarkdownRenderer content={AGREEMENT_MD} />)
2332
+ expect(idsOf(second)).toEqual(firstIds)
2333
+
2334
+ // …and so must repeated render passes of the SAME instance. Before the
2335
+ // per-pass counter reset this produced `setup-4`, `setup-5`, … on every
2336
+ // re-render, silently breaking every `#anchor` deep link.
2337
+ let container!: HTMLElement
2338
+ let rerender!: (ui: React.ReactElement) => void
2339
+ await act(async () => {
2340
+ const result = render(<SimpleMarkdownRenderer content={AGREEMENT_MD} />)
2341
+ container = result.container
2342
+ rerender = result.rerender
2343
+ await new Promise((r) => setTimeout(r, 0))
2344
+ })
2345
+ expect(idsOf(container)).toEqual(firstIds)
2346
+
2347
+ for (let pass = 0; pass < 3; pass++) {
2348
+ await act(async () => {
2349
+ // A fresh `brokenLinks` array each time is exactly what a parent
2350
+ // re-render looks like — it must not renumber anything.
2351
+ rerender(<SimpleMarkdownRenderer content={AGREEMENT_MD} brokenLinks={[]} />)
2352
+ await new Promise((r) => setTimeout(r, 0))
2353
+ })
2354
+ expect(idsOf(container), `pass ${pass}`).toEqual(firstIds)
2355
+ }
2356
+ })
2357
+
2358
+ // --- round-2: the ids must be PURE ---------------------------------------
2359
+ it('ids are identical under a StrictMode double render', async () => {
2360
+ // Heading renderers are element types, re-rendered INDEPENDENTLY of the
2361
+ // parent that used to `reset()` the counter — so StrictMode's second
2362
+ // invocation suffixed every id with `-2` in dev and broke
2363
+ // extractor↔renderer parity. The pure line-keyed map is immune.
2364
+ const plain = await renderStable(<SimpleMarkdownRenderer content={AGREEMENT_MD} />)
2365
+ const plainIds = Array.from(plain.querySelectorAll('h1, h2')).map((el) => el.id)
2366
+
2367
+ const strict = await renderStable(
2368
+ <React.StrictMode>
2369
+ <SimpleMarkdownRenderer content={AGREEMENT_MD} />
2370
+ </React.StrictMode>,
2371
+ )
2372
+ const strictIds = Array.from(strict.querySelectorAll('h1, h2')).map((el) => el.id)
2373
+ expect(strictIds).toEqual(plainIds)
2374
+ })
2375
+
2376
+ it('emits NO duplicate ids mid-stream, where completed blocks are memoized', async () => {
2377
+ // A memoized completed block does NOT re-render on the pass that used to
2378
+ // reset the counter, so block 0 kept `id="setup"` while the re-rendered
2379
+ // tail re-derived `setup` from an empty counter — two live `id="setup"`
2380
+ // in the DOM, and `#setup` resolving to the wrong node for the whole
2381
+ // stream.
2382
+ const STREAM_MD = '## Setup\n\nfirst body\n\n## Setup\n\nsecond body\n\ntail text'
2383
+
2384
+ let container!: HTMLElement
2385
+ let rerender!: (ui: React.ReactElement) => void
2386
+ await act(async () => {
2387
+ const result = render(<SimpleMarkdownRenderer content={STREAM_MD} streaming />)
2388
+ container = result.container
2389
+ rerender = result.rerender
2390
+ await new Promise((r) => setTimeout(r, 0))
2391
+ })
2392
+
2393
+ const idsOf = () => Array.from(container.querySelectorAll('h1, h2, h3')).map((el) => el.id)
2394
+ expect(idsOf()).toEqual(['setup', 'setup-2'])
2395
+
2396
+ // Grow the tail token-by-token; the completed blocks stay memoized.
2397
+ for (const suffix of [' and', ' and more', ' and more words']) {
2398
+ await act(async () => {
2399
+ rerender(<SimpleMarkdownRenderer content={`${STREAM_MD}${suffix}`} streaming />)
2400
+ await new Promise((r) => setTimeout(r, 0))
2401
+ })
2402
+ const ids = idsOf()
2403
+ expect(ids, `after "${suffix}"`).toEqual(['setup', 'setup-2'])
2404
+ expect(new Set(ids).size, 'no duplicate DOM ids').toBe(ids.length)
2405
+ }
2406
+
2407
+ // …and the settled whole-document parse agrees with the extractor.
2408
+ const done = await renderStable(<SimpleMarkdownRenderer content={STREAM_MD} />)
2409
+ expect(Array.from(done.querySelectorAll('h1, h2')).map((el) => el.id)).toEqual(
2410
+ extractSections(STREAM_MD, { maxLevel: 2 }).map((s) => s.id),
2411
+ )
2412
+ })
2413
+
2414
+ // --- round-3: producer/consumer scanner parity -------------------------
2415
+ it('agrees with the extractor across fence, container and setext shapes', async () => {
2416
+ // Every line here was a DRIFT between the two implementations:
2417
+ // - a `~~~` fence: the extractor toggled only on '```', so `## Fenced`
2418
+ // became a phantom section with no matching renderer id;
2419
+ // - an indented / longer-run fence: same class;
2420
+ // - `> ## Quoted` and `- ## Listed`: real <h2>s in mdast that the
2421
+ // renderer's column-0..3 scan missed entirely, so BOTH of two
2422
+ // identical ones fell to the suffix-free fallback and emitted
2423
+ // DUPLICATE DOM ids;
2424
+ // - `Setext Title\n---`: a real <h2> the renderer never scanned AND
2425
+ // which flipped the extractor into a phantom "YAML block" that
2426
+ // swallowed every heading after it.
2427
+ const DRIFT_MD = [
2428
+ '# Top',
2429
+ '',
2430
+ '~~~text',
2431
+ '## Fenced Not A Heading',
2432
+ '~~~',
2433
+ '',
2434
+ ' ```',
2435
+ '## Also Fenced',
2436
+ ' ```',
2437
+ '',
2438
+ 'Setext Title',
2439
+ '---',
2440
+ '',
2441
+ '> ## Quoted',
2442
+ '',
2443
+ '- ## Listed',
2444
+ '',
2445
+ '> ## Quoted',
2446
+ '',
2447
+ '## Tail',
2448
+ '',
2449
+ ].join('\n')
2450
+
2451
+ const sections = extractSections(DRIFT_MD, { maxLevel: 2 })
2452
+ const container = await renderStable(<SimpleMarkdownRenderer content={DRIFT_MD} />)
2453
+ const renderedIds = Array.from(container.querySelectorAll('h1, h2')).map((el) => el.id)
2454
+
2455
+ expect(renderedIds).toEqual(sections.map((s) => s.id))
2456
+ expect(new Set(renderedIds).size, 'no duplicate DOM ids').toBe(renderedIds.length)
2457
+ expect(renderedIds, 'fenced headings contribute NO id').not.toContain('fenced-not-a-heading')
2458
+ expect(sections).toMatchSnapshot('drift-sections')
2459
+ })
2460
+
2461
+ // --- round-4: the setext scan must match mdast EXACTLY ------------------
2462
+ // The scanner is a line-based approximation of what remark will parse. Where
2463
+ // it diverges, the renderer's line-keyed id lookup misses (dead deep link)
2464
+ // or the extractor publishes a phantom TOC entry. These pin the three
2465
+ // divergences against remark itself rather than against a hand-written
2466
+ // expectation.
2467
+ describe('setext scanning matches mdast', () => {
2468
+ const mdastHeadings = (md: string) => {
2469
+ const tree = unified().use(remarkParse).parse(md) as any
2470
+ const out: Array<{ line: number; level: number }> = []
2471
+ const walk = (n: any) => {
2472
+ if (n.type === 'heading') out.push({ line: n.position.start.line, level: n.depth })
2473
+ for (const child of n.children ?? []) walk(child)
2474
+ }
2475
+ walk(tree)
2476
+ return out
2477
+ }
2478
+ const scanned = (md: string) =>
2479
+ scanHeadings(md).map((h) => ({ line: h.line, level: h.level }))
2480
+
2481
+ it('multi-line setext is ONE heading at the run\'s FIRST line', () => {
2482
+ const md = 'Foo\nbar\n===\n'
2483
+ // mdast: {line:1, depth:1, text:"Foo\nbar"}. The scanner used to record
2484
+ // only the LAST paragraph line ({line:2, text:"bar"}), so the renderer's
2485
+ // lookup missed and fell back to a slug of the rendered children
2486
+ // (`foo-bar`) while the extractor published `bar`.
2487
+ expect(scanned(md)).toEqual(mdastHeadings(md))
2488
+ expect(scanHeadings(md)[0].text).toBe('Foo\nbar')
2489
+ expect(extractSections(md).map((s) => s.id)).toEqual(['foo-bar'])
2490
+ })
2491
+
2492
+ it('container-nested setext IS a heading', () => {
2493
+ const md = '> Quote title\n> ---\n'
2494
+ // A real h2 in mdast; the container-prefix guard used to kill the
2495
+ // candidate, so it was invisible to the TOC.
2496
+ expect(scanned(md)).toEqual(mdastHeadings(md))
2497
+ expect(extractSections(md).map((s) => s.id)).toEqual(['quote-title'])
2498
+ })
2499
+
2500
+ it('a `---` inside a raw HTML block is NOT a setext underline', () => {
2501
+ const md = '<div>\nText\n---\n</div>\n'
2502
+ // mdast emits nothing (the whole thing is one HTML block); the scanner
2503
+ // used to publish a phantom `Text` h2.
2504
+ expect(mdastHeadings(md)).toEqual([])
2505
+ expect(scanned(md)).toEqual([])
2506
+ expect(extractSections(md)).toEqual([])
2507
+ })
2508
+
2509
+ it('an underline with a DIFFERENT container prefix is a thematic break', () => {
2510
+ const md = '> Quote\n\n---\n'
2511
+ expect(scanned(md)).toEqual(mdastHeadings(md))
2512
+ expect(scanned(md)).toEqual([])
2513
+ })
2514
+
2515
+ it('renderer ids agree with the extractor for multi-line setext', async () => {
2516
+ const md = 'Foo\nbar\n===\n\nbody\n'
2517
+ const container = await renderStable(<SimpleMarkdownRenderer content={md} />)
2518
+ expect(Array.from(container.querySelectorAll('h1, h2')).map((el) => el.id)).toEqual(
2519
+ extractSections(md, { maxLevel: 2 }).map((s) => s.id),
2520
+ )
2521
+ })
2522
+ })
2523
+
2524
+ it('honors an AUTHORED anchor on a raw-HTML heading', async () => {
2525
+ // The sanitize schema allows `id` and disables clobbering precisely so
2526
+ // hand-picked anchors survive; the renderer then overwrote them with the
2527
+ // slug of the heading text, silently breaking every deep link to them.
2528
+ const md = '<h2 id="pricing-faq">Pricing</h2>\n\nbody'
2529
+ const container = await renderStable(<SimpleMarkdownRenderer content={md} />)
2530
+ expect(container.querySelector('h2')?.id).toBe('pricing-faq')
2531
+ })
2532
+
2533
+ it('strips inline markdown from heading text before slugifying (extractor parity)', async () => {
2534
+ const md = '## **Bold** Setup\n\n## `code` Heading\n'
2535
+ const container = await renderStable(<SimpleMarkdownRenderer content={md} />)
2536
+ expect(Array.from(container.querySelectorAll('h2')).map((el) => el.id)).toEqual(
2537
+ extractSections(md, { maxLevel: 2 }).map((s) => s.id),
2538
+ )
2539
+ })
2540
+ })