@flamingo-stack/openframe-frontend-core 0.0.457 → 0.0.458-snapshot.20260720215024

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 (290) 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 +192 -0
  8. package/dist/chat-protocol/events.d.ts.map +1 -0
  9. package/dist/chat-protocol/frames.d.ts +206 -0
  10. package/dist/chat-protocol/frames.d.ts.map +1 -0
  11. package/dist/chat-protocol/index.cjs +493 -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 +477 -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-SQ3CP2IV.cjs → chunk-3N3TMZ62.cjs} +7 -7
  22. package/dist/{chunk-SQ3CP2IV.cjs.map → chunk-3N3TMZ62.cjs.map} +1 -1
  23. package/dist/{chunk-I7M6KCCS.js → chunk-4R6H2U77.js} +4 -4
  24. package/dist/{chunk-CCT5VCRP.cjs → chunk-4XDWNAZW.cjs} +9 -9
  25. package/dist/{chunk-CCT5VCRP.cjs.map → chunk-4XDWNAZW.cjs.map} +1 -1
  26. package/dist/{chunk-KQ36EHE4.cjs → chunk-7KSEINAE.cjs} +15 -15
  27. package/dist/{chunk-KQ36EHE4.cjs.map → chunk-7KSEINAE.cjs.map} +1 -1
  28. package/dist/{chunk-FM5DTJPA.cjs → chunk-ADD3FPDG.cjs} +6851 -4713
  29. package/dist/chunk-ADD3FPDG.cjs.map +1 -0
  30. package/dist/{chunk-RSW42GTZ.js → chunk-APF5QBAU.js} +2 -2
  31. package/dist/{chunk-YCIVMKRS.cjs → chunk-BLZZXZNM.cjs} +26 -26
  32. package/dist/{chunk-YCIVMKRS.cjs.map → chunk-BLZZXZNM.cjs.map} +1 -1
  33. package/dist/{chunk-YHB4MNUX.cjs → chunk-BQ7PMWLM.cjs} +28 -27
  34. package/dist/chunk-BQ7PMWLM.cjs.map +1 -0
  35. package/dist/{chunk-GJ7BN4MI.js → chunk-CGNDAJ7V.js} +4 -4
  36. package/dist/{chunk-R5IQJXA3.js → chunk-F2NMWV44.js} +2 -2
  37. package/dist/{chunk-EOVQVVDJ.cjs → chunk-F7F43U7Q.cjs} +11 -11
  38. package/dist/{chunk-EOVQVVDJ.cjs.map → chunk-F7F43U7Q.cjs.map} +1 -1
  39. package/dist/{chunk-2ZW2ZPDW.js → chunk-LAOO7XHO.js} +2 -2
  40. package/dist/{chunk-O7F7RZFN.js → chunk-LJKZIIK4.js} +6 -5
  41. package/dist/chunk-LJKZIIK4.js.map +1 -0
  42. package/dist/{chunk-AJGZ3YSR.js → chunk-OR5AMXYN.js} +2 -2
  43. package/dist/{chunk-5XFJG3TC.cjs → chunk-P5JQDFRM.cjs} +60 -60
  44. package/dist/chunk-P5JQDFRM.cjs.map +1 -0
  45. package/dist/{chunk-U4APSAAR.cjs → chunk-PXZ7YQ5V.cjs} +5 -5
  46. package/dist/{chunk-U4APSAAR.cjs.map → chunk-PXZ7YQ5V.cjs.map} +1 -1
  47. package/dist/{chunk-WXDEMTLD.js → chunk-QO2XD3FR.js} +2 -2
  48. package/dist/{chunk-C2TDHJBO.cjs → chunk-QSGZE6AZ.cjs} +37 -37
  49. package/dist/{chunk-C2TDHJBO.cjs.map → chunk-QSGZE6AZ.cjs.map} +1 -1
  50. package/dist/{chunk-NSL5MNS4.js → chunk-REWRUV3K.js} +11644 -9506
  51. package/dist/chunk-REWRUV3K.js.map +1 -0
  52. package/dist/{chunk-BDTQIIJR.cjs → chunk-TA7MFVUS.cjs} +31 -31
  53. package/dist/{chunk-BDTQIIJR.cjs.map → chunk-TA7MFVUS.cjs.map} +1 -1
  54. package/dist/{chunk-4423DHRX.js → chunk-UI7CIUAR.js} +2 -2
  55. package/dist/{chunk-5BKBG653.js → chunk-VXJH5TDN.js} +10 -10
  56. package/dist/chunk-VXJH5TDN.js.map +1 -0
  57. package/dist/{chunk-AB65PWU2.cjs → chunk-WCE66C2Z.cjs} +62 -62
  58. package/dist/{chunk-AB65PWU2.cjs.map → chunk-WCE66C2Z.cjs.map} +1 -1
  59. package/dist/{chunk-KXIIUE75.js → chunk-XF5ROTMQ.js} +2 -2
  60. package/dist/components/case-studies/index.cjs +8 -8
  61. package/dist/components/case-studies/index.js +2 -2
  62. package/dist/components/chart.d.ts +1 -1
  63. package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
  64. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  65. package/dist/components/chat/hooks/index.d.ts +0 -1
  66. package/dist/components/chat/hooks/index.d.ts.map +1 -1
  67. package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
  68. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +15 -48
  69. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
  70. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +3 -47
  71. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
  72. package/dist/components/chat/index.cjs +10 -2
  73. package/dist/components/chat/index.cjs.map +1 -1
  74. package/dist/components/chat/index.d.ts +1 -0
  75. package/dist/components/chat/index.d.ts.map +1 -1
  76. package/dist/components/chat/index.js +21 -13
  77. package/dist/components/chat/stream/chat-dialog-store.d.ts +184 -0
  78. package/dist/components/chat/stream/chat-dialog-store.d.ts.map +1 -0
  79. package/dist/components/chat/stream/chat-stream-reducer.d.ts +367 -0
  80. package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -0
  81. package/dist/components/chat/stream/delta-batcher.d.ts +51 -0
  82. package/dist/components/chat/stream/delta-batcher.d.ts.map +1 -0
  83. package/dist/components/chat/stream/index.d.ts +17 -0
  84. package/dist/components/chat/stream/index.d.ts.map +1 -0
  85. package/dist/components/chat/stream/message-mutations.d.ts +77 -0
  86. package/dist/components/chat/stream/message-mutations.d.ts.map +1 -0
  87. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +22 -0
  88. package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +1 -0
  89. package/dist/components/chat/types/api.types.d.ts +1 -81
  90. package/dist/components/chat/types/api.types.d.ts.map +1 -1
  91. package/dist/components/chat/types/processing.types.d.ts +2 -90
  92. package/dist/components/chat/types/processing.types.d.ts.map +1 -1
  93. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +34 -6
  94. package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
  95. package/dist/components/chat/utils/index.d.ts +2 -3
  96. package/dist/components/chat/utils/index.d.ts.map +1 -1
  97. package/dist/components/chat/utils/process-historical-messages.d.ts +59 -11
  98. package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
  99. package/dist/components/contact/index.cjs +3 -3
  100. package/dist/components/contact/index.js +2 -2
  101. package/dist/components/docs/index.cjs +5 -5
  102. package/dist/components/docs/index.js +4 -4
  103. package/dist/components/embeds/index.cjs +3 -3
  104. package/dist/components/embeds/index.js +2 -2
  105. package/dist/components/faq/index.cjs +3 -3
  106. package/dist/components/faq/index.js +2 -2
  107. package/dist/components/features/index.cjs +2 -2
  108. package/dist/components/features/index.js +1 -1
  109. package/dist/components/features/time-tracker/time-tracker-panel.d.ts.map +1 -1
  110. package/dist/components/help-center-pages/index.cjs +20 -20
  111. package/dist/components/help-center-pages/index.js +11 -11
  112. package/dist/components/index.cjs +171 -129
  113. package/dist/components/index.cjs.map +1 -1
  114. package/dist/components/index.js +63 -21
  115. package/dist/components/index.js.map +1 -1
  116. package/dist/components/layout/page-container.d.ts.map +1 -1
  117. package/dist/components/navigation/index.cjs +2 -2
  118. package/dist/components/navigation/index.js +1 -1
  119. package/dist/components/onboarding-guides/index.cjs +5 -5
  120. package/dist/components/onboarding-guides/index.js +4 -4
  121. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
  122. package/dist/components/related-content/index.cjs +3 -3
  123. package/dist/components/related-content/index.js +2 -2
  124. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  125. package/dist/components/tickets/index.cjs +5 -5
  126. package/dist/components/tickets/index.js +4 -4
  127. package/dist/components/ui/index.cjs +44 -2
  128. package/dist/components/ui/index.cjs.map +1 -1
  129. package/dist/components/ui/index.d.ts +1 -2
  130. package/dist/components/ui/index.d.ts.map +1 -1
  131. package/dist/components/ui/index.js +55 -13
  132. package/dist/components/ui/markdown/base-components.d.ts +56 -0
  133. package/dist/components/ui/markdown/base-components.d.ts.map +1 -0
  134. package/dist/components/ui/markdown/engine.d.ts +99 -0
  135. package/dist/components/ui/markdown/engine.d.ts.map +1 -0
  136. package/dist/components/ui/markdown/heading-ids.d.ts +65 -0
  137. package/dist/components/ui/markdown/heading-ids.d.ts.map +1 -0
  138. package/dist/components/ui/markdown/index.d.ts +18 -0
  139. package/dist/components/ui/markdown/index.d.ts.map +1 -0
  140. package/dist/components/ui/markdown/mermaid-diagram.d.ts +79 -0
  141. package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +1 -0
  142. package/dist/components/ui/markdown/rich/embed-overrides.d.ts +14 -0
  143. package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -0
  144. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +40 -0
  145. package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +1 -0
  146. package/dist/components/ui/markdown/rich/shortcodes.d.ts +8 -0
  147. package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -0
  148. package/dist/components/ui/markdown/sanitize.d.ts +153 -0
  149. package/dist/components/ui/markdown/sanitize.d.ts.map +1 -0
  150. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +23 -0
  151. package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +1 -0
  152. package/dist/components/ui/markdown/streaming.d.ts +78 -0
  153. package/dist/components/ui/markdown/streaming.d.ts.map +1 -0
  154. package/dist/components/ui/markdown/text-size.d.ts +34 -0
  155. package/dist/components/ui/markdown/text-size.d.ts.map +1 -0
  156. package/dist/components/ui/modal-v2.d.ts.map +1 -1
  157. package/dist/components/ui/modal.d.ts.map +1 -1
  158. package/dist/index.cjs +60 -2
  159. package/dist/index.cjs.map +1 -1
  160. package/dist/index.js +71 -13
  161. package/dist/utils/index.cjs +169 -40
  162. package/dist/utils/index.cjs.map +1 -1
  163. package/dist/utils/index.d.ts +1 -0
  164. package/dist/utils/index.d.ts.map +1 -1
  165. package/dist/utils/index.js +162 -41
  166. package/dist/utils/index.js.map +1 -1
  167. package/dist/utils/markdown-fences.d.ts +42 -0
  168. package/dist/utils/markdown-fences.d.ts.map +1 -0
  169. package/dist/utils/markdown-heading-id.d.ts +85 -0
  170. package/dist/utils/markdown-heading-id.d.ts.map +1 -0
  171. package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
  172. package/package.json +7 -1
  173. package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +307 -0
  174. package/src/chat-protocol/__tests__/chat-protocol.test.ts +515 -0
  175. package/src/chat-protocol/__tests__/env-flag.test.ts +52 -0
  176. package/src/chat-protocol/__tests__/ip-normalize.test.ts +136 -0
  177. package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +228 -0
  178. package/src/chat-protocol/decode.ts +277 -0
  179. package/src/chat-protocol/encode.ts +71 -0
  180. package/src/chat-protocol/env-flag.ts +39 -0
  181. package/src/chat-protocol/events.ts +228 -0
  182. package/src/chat-protocol/frames.ts +238 -0
  183. package/src/chat-protocol/index.ts +21 -0
  184. package/src/chat-protocol/ip-normalize.ts +146 -0
  185. package/src/chat-protocol/nats-decoder.ts +252 -0
  186. package/src/components/chat/__tests__/chat-message-list.test.tsx +293 -5
  187. package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +207 -0
  188. package/src/components/chat/__tests__/chat-pending-turn.test.tsx +209 -0
  189. package/src/components/chat/chat-message-enhanced.tsx +111 -17
  190. package/src/components/chat/chat-message-list.tsx +367 -113
  191. package/src/components/chat/embeddable-chat.tsx +6 -0
  192. package/src/components/chat/hooks/.index.md +1 -1
  193. package/src/components/chat/hooks/.use-nats-chat-adapter.md +1 -1
  194. package/src/components/chat/hooks/__tests__/__snapshots__/persisted-state-v1-golden.test.ts.snap +297 -0
  195. package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
  196. package/src/components/chat/hooks/__tests__/persisted-state-v1-golden.test.ts +272 -0
  197. package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +318 -0
  198. package/src/components/chat/hooks/index.ts +0 -1
  199. package/src/components/chat/hooks/use-chat.ts +8 -0
  200. package/src/components/chat/hooks/use-nats-chat-adapter.ts +243 -822
  201. package/src/components/chat/hooks/use-sse-chat-adapter.ts +318 -666
  202. package/src/components/chat/index.ts +5 -0
  203. package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +937 -0
  204. package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +756 -0
  205. package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +460 -0
  206. package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +894 -0
  207. package/src/components/chat/stream/__tests__/delta-batcher.test.ts +255 -0
  208. package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +98 -0
  209. package/src/components/chat/stream/chat-dialog-store.ts +536 -0
  210. package/src/components/chat/stream/chat-stream-reducer.ts +1796 -0
  211. package/src/components/chat/stream/delta-batcher.ts +159 -0
  212. package/src/components/chat/stream/index.ts +55 -0
  213. package/src/components/chat/stream/message-mutations.ts +338 -0
  214. package/src/components/chat/stream/use-chat-stream-reducer.ts +126 -0
  215. package/src/components/chat/thinking-display.tsx +1 -1
  216. package/src/components/chat/types/.api.types.md +1 -3
  217. package/src/components/chat/types/.processing.types.md +6 -23
  218. package/src/components/chat/types/api.types.ts +6 -73
  219. package/src/components/chat/types/processing.types.ts +11 -52
  220. package/src/components/chat/utils/.index.md +0 -5
  221. package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +418 -0
  222. package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +599 -0
  223. package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +317 -0
  224. package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +270 -0
  225. package/src/components/chat/utils/extract-incomplete-message-state.ts +63 -5
  226. package/src/components/chat/utils/index.ts +5 -8
  227. package/src/components/chat/utils/process-historical-messages.ts +352 -383
  228. package/src/components/features/notifications/notification-drawer.tsx +4 -2
  229. package/src/components/features/time-tracker/time-tracker-panel.tsx +8 -2
  230. package/src/components/layout/page-container.tsx +5 -1
  231. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +22 -4
  232. package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
  233. package/src/components/shared/product-release/release-detail-page.tsx +19 -4
  234. package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +2420 -0
  235. package/src/components/ui/__tests__/markdown-parity.test.tsx +2528 -0
  236. package/src/components/ui/index.ts +1 -2
  237. package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +115 -0
  238. package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +95 -0
  239. package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +309 -0
  240. package/src/components/ui/markdown/__tests__/streaming.test.tsx +388 -0
  241. package/src/components/ui/markdown/base-components.tsx +341 -0
  242. package/src/components/ui/markdown/engine.tsx +315 -0
  243. package/src/components/ui/markdown/heading-ids.ts +239 -0
  244. package/src/components/ui/markdown/index.ts +49 -0
  245. package/src/components/ui/markdown/mermaid-diagram.tsx +291 -0
  246. package/src/components/ui/markdown/rich/embed-overrides.tsx +199 -0
  247. package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +184 -0
  248. package/src/components/ui/markdown/rich/shortcodes.ts +170 -0
  249. package/src/components/ui/markdown/sanitize.ts +3499 -0
  250. package/src/components/ui/markdown/simple-markdown-renderer.tsx +28 -0
  251. package/src/components/ui/markdown/streaming.ts +362 -0
  252. package/src/components/ui/markdown/text-size.ts +106 -0
  253. package/src/components/ui/modal-v2.tsx +6 -0
  254. package/src/components/ui/modal.tsx +3 -0
  255. package/src/components/ui/release-changelog-section.tsx +1 -1
  256. package/src/components/ui/ticket-info-section.tsx +1 -1
  257. package/src/utils/index.ts +1 -0
  258. package/src/utils/markdown-fences.ts +127 -0
  259. package/src/utils/markdown-heading-id.ts +348 -0
  260. package/src/utils/markdown-section-extractor.ts +41 -56
  261. package/dist/chunk-5BKBG653.js.map +0 -1
  262. package/dist/chunk-5XFJG3TC.cjs.map +0 -1
  263. package/dist/chunk-FM5DTJPA.cjs.map +0 -1
  264. package/dist/chunk-NSL5MNS4.js.map +0 -1
  265. package/dist/chunk-O7F7RZFN.js.map +0 -1
  266. package/dist/chunk-YHB4MNUX.cjs.map +0 -1
  267. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +0 -6
  268. package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +0 -1
  269. package/dist/components/chat/utils/chunk-parser.d.ts +0 -25
  270. package/dist/components/chat/utils/chunk-parser.d.ts.map +0 -1
  271. package/dist/components/ui/rich-markdown-renderer.d.ts +0 -34
  272. package/dist/components/ui/rich-markdown-renderer.d.ts.map +0 -1
  273. package/dist/components/ui/simple-markdown-renderer.d.ts +0 -73
  274. package/dist/components/ui/simple-markdown-renderer.d.ts.map +0 -1
  275. package/src/components/chat/hooks/.use-realtime-chunk-processor.md +0 -50
  276. package/src/components/chat/hooks/use-realtime-chunk-processor.ts +0 -479
  277. package/src/components/chat/utils/.chunk-parser.md +0 -51
  278. package/src/components/chat/utils/chunk-parser.ts +0 -255
  279. package/src/components/ui/.simple-markdown-renderer.md +0 -40
  280. package/src/components/ui/rich-markdown-renderer.tsx +0 -1223
  281. package/src/components/ui/simple-markdown-renderer.tsx +0 -965
  282. /package/dist/{chunk-I7M6KCCS.js.map → chunk-4R6H2U77.js.map} +0 -0
  283. /package/dist/{chunk-RSW42GTZ.js.map → chunk-APF5QBAU.js.map} +0 -0
  284. /package/dist/{chunk-GJ7BN4MI.js.map → chunk-CGNDAJ7V.js.map} +0 -0
  285. /package/dist/{chunk-R5IQJXA3.js.map → chunk-F2NMWV44.js.map} +0 -0
  286. /package/dist/{chunk-2ZW2ZPDW.js.map → chunk-LAOO7XHO.js.map} +0 -0
  287. /package/dist/{chunk-AJGZ3YSR.js.map → chunk-OR5AMXYN.js.map} +0 -0
  288. /package/dist/{chunk-WXDEMTLD.js.map → chunk-QO2XD3FR.js.map} +0 -0
  289. /package/dist/{chunk-4423DHRX.js.map → chunk-UI7CIUAR.js.map} +0 -0
  290. /package/dist/{chunk-KXIIUE75.js.map → chunk-XF5ROTMQ.js.map} +0 -0
@@ -1,965 +0,0 @@
1
- "use client";
2
-
3
- import React, { useEffect, useState, useRef, useMemo, useCallback, memo } from 'react';
4
- import ReactMarkdown, { type Components, defaultUrlTransform } from 'react-markdown';
5
- import type { PluggableList } from 'unified';
6
- import remarkGfm from 'remark-gfm';
7
- import remarkBreaks from 'remark-breaks';
8
- import rehypeHighlight from 'rehype-highlight';
9
- import rehypeRaw from 'rehype-raw';
10
- import { visit } from 'unist-util-visit';
11
- import Image from '../../embed-shims/next-image';
12
- import { AlertCircleIcon } from '../icons-v2-generated';
13
- import { cn } from '../../utils/cn';
14
- import type { ResolveLinkResult } from '../../types/doc-source';
15
-
16
- export type { ResolveLinkResult };
17
-
18
- // ---------------------------------------------------------------------------
19
- // rehype HAST sanitizer — runs AFTER rehype-raw to strip XSS vectors
20
- // ---------------------------------------------------------------------------
21
- /**
22
- * Minimal HAST sanitizer. Runs AFTER `rehype-raw` (which parses raw HTML
23
- * embedded in markdown) and BEFORE `rehype-highlight`. Strips the
24
- * attack surfaces that rehype-raw leaves wide open:
25
- *
26
- * - `on*` event handlers (onerror, onload, onclick, …) on ANY element
27
- * - href / src / formaction / xlink:href / poster pointing at
28
- * `javascript:` (case + whitespace tolerant)
29
- * - `iframe srcdoc` (full-document XSS)
30
- * - `<script>`, `<style>`, `<noscript>`, `<noembed>` elements (drop)
31
- * - `data:` URIs on src-ish attrs (SVG-with-embedded-JS class of bug)
32
- *
33
- * Why custom (vs `rehype-sanitize`): the OSS-lib build environment
34
- * doesn't have `rehype-sanitize` in its `node_modules` (sandbox
35
- * restriction), but `unist-util-visit` is already a transitive dep of
36
- * `rehype-raw`. This plugin is ~60 lines, ships nothing new, and is
37
- * tighter than the default sanitize schema for our threat model
38
- * (we want LLM-emitted markdown to be safe; we don't need full HTML5
39
- * fidelity).
40
- *
41
- * The text-level `escapeUnknownHtmlTags` pre-pass below is still useful
42
- * for catching `<their>`-style accidental tag emissions that React 19
43
- * rejects, but it is NOT a security boundary. THIS is.
44
- */
45
- const EVENT_HANDLER_ATTR_RE = /^on[a-z]+$/i
46
- const JAVASCRIPT_URL_RE = /^[\s\x00-\x1f]*javascript:/i
47
- const DATA_URL_RE = /^[\s\x00-\x1f]*data:/i
48
- const URL_ATTRS = new Set([
49
- 'href',
50
- 'src',
51
- // `srcset` accepts `javascript:` on legacy browsers — same guard
52
- // as the canonical hast-util-sanitize default schema's attr set.
53
- // Multi-candidate scanning lives in `srcsetHasUnsafeCandidate` below;
54
- // a single-URL check would miss a malicious second candidate like
55
- // `"https://safe.png 1x, javascript:alert(1) 2x"`.
56
- 'srcset',
57
- 'formaction',
58
- 'xlink:href',
59
- 'poster',
60
- 'data',
61
- 'action',
62
- 'background',
63
- ])
64
-
65
- /**
66
- * Returns true if any candidate in an `srcset` attribute has a dangerous
67
- * URL scheme. Per the HTML spec, srcset is a comma-separated list of
68
- * candidates; each candidate is `<url> <descriptor>?` (e.g.
69
- * `"https://x/a.png 2x"`). The single-URL `JAVASCRIPT_URL_RE.test(v)`
70
- * check only inspects the FIRST candidate — so a malicious second
71
- * candidate (`"https://safe 1x, javascript:alert(1) 2x"`) would slip
72
- * through. This helper splits on `,`, trims each candidate, takes the
73
- * leading whitespace-delimited token (the URL), and tests that.
74
- *
75
- * Splitting on every comma over-matches the rare-in-practice case of
76
- * commas inside URL paths. That's the correct error bias for a
77
- * sanitizer: over-strip is safe, under-strip is not.
78
- */
79
- function srcsetHasUnsafeCandidate(srcset: string): boolean {
80
- for (const candidate of srcset.split(',')) {
81
- const url = candidate.trim().split(/\s+/)[0] ?? ''
82
- if (JAVASCRIPT_URL_RE.test(url) || DATA_URL_RE.test(url)) return true
83
- }
84
- return false
85
- }
86
- // Element-level drop list. The text-pre-pass `escapeUnknownHtmlTags`
87
- // already escapes any `<tag>` whose name isn't on its allow-list, so
88
- // `<object>`, `<embed>`, `<applet>`, `<base>`, `<meta>` normally never
89
- // reach `rehype-raw` as elements. Re-stripping at the HAST layer
90
- // removes the cross-layer dependency: this sanitizer is sufficient
91
- // on its own even if the text pre-pass is bypassed.
92
- const STRIP_ELEMENTS = new Set([
93
- 'script',
94
- 'style',
95
- 'noscript',
96
- 'noembed',
97
- 'object',
98
- 'embed',
99
- 'applet',
100
- 'base',
101
- 'meta',
102
- ])
103
-
104
- function rehypeStripUnsafe() {
105
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
106
- return (tree: any) => {
107
- visit(tree, 'element', (node: any, index: number | undefined, parent: any) => {
108
- const tag = String(node.tagName ?? '').toLowerCase()
109
- if (STRIP_ELEMENTS.has(tag)) {
110
- if (parent && typeof index === 'number') {
111
- parent.children.splice(index, 1)
112
- // Return the numeric index (`unist-util-visit` treats it as
113
- // `[CONTINUE, index]`) so the walker resumes at the slot the
114
- // removed node vacated. Don't return `[SKIP, index]`:
115
- // skip-descendants is meaningless for a node we just removed,
116
- // and SKIP+index conflates two signals.
117
- return index
118
- }
119
- // Root-level strip element (parent undefined). Rare, but
120
- // possible if `rehype-raw` ever lifts one. Neutralize in
121
- // place by clearing children + retagging as a blank span.
122
- node.children = []
123
- node.tagName = 'span'
124
- node.properties = {}
125
- return
126
- }
127
- if (!node.properties || typeof node.properties !== 'object') return
128
- for (const key of Object.keys(node.properties)) {
129
- // 1. event handlers
130
- if (EVENT_HANDLER_ATTR_RE.test(key)) {
131
- delete node.properties[key]
132
- continue
133
- }
134
- // 2. dangerous URL schemes on URL-bearing attrs. `srcset` is
135
- // multi-candidate so it routes through `srcsetHasUnsafeCandidate`
136
- // which inspects every URL in the list (not just the first).
137
- if (URL_ATTRS.has(key.toLowerCase())) {
138
- const raw = node.properties[key]
139
- const v = Array.isArray(raw) ? raw[0] : raw
140
- if (typeof v === 'string') {
141
- const unsafe =
142
- key.toLowerCase() === 'srcset'
143
- ? srcsetHasUnsafeCandidate(v)
144
- : JAVASCRIPT_URL_RE.test(v) || DATA_URL_RE.test(v)
145
- if (unsafe) {
146
- delete node.properties[key]
147
- continue
148
- }
149
- }
150
- }
151
- // 3. iframe srcdoc — full-document XSS vector
152
- if (tag === 'iframe' && key.toLowerCase() === 'srcdoc') {
153
- delete node.properties[key]
154
- }
155
- }
156
- })
157
- }
158
- }
159
-
160
- /**
161
- * URL transformer that extends react-markdown's default safe-protocol
162
- * allowlist with the two internal schemes the chat remark plugins emit:
163
- * - `card://` — `remarkCardLinks`, for inline chat-card markers.
164
- * - `mention://` — `remarkMentionChips`, for inline `@marker:id` AI mentions.
165
- *
166
- * Without this, react-markdown 10's `defaultUrlTransform` strips the URL
167
- * to `""` before the `<a>` component override runs (the override's
168
- * `href.startsWith('card://')` / `'mention://'` check then fails and the
169
- * marker leaks through — as literal text, or as an empty-href link that the
170
- * host's `NavLinkAnchor` resolves to a base URL). All other URL schemes still
171
- * go through the default sanitizer — `javascript:`, `vbscript:`, `data:`
172
- * (non-image), etc. remain blocked.
173
- *
174
- * Scope: both schemes are allowed ONLY for `href` attributes. If the LLM
175
- * accidentally emits `![alt](card://type:id)` the URL still goes through
176
- * `defaultUrlTransform` (which strips it to `""`) so an `<img src="card://...">`
177
- * never reaches the DOM — broken request avoided. Per v6.1 §B.2.4: these
178
- * schemes are internal — never network-fetched, never written to attributes
179
- * other than `href` for renderer dispatch.
180
- */
181
- function cardAwareUrlTransform(url: string, key: string): string {
182
- if (key === 'href' && typeof url === 'string' && (url.startsWith('card://') || url.startsWith('mention://')))
183
- return url;
184
- return defaultUrlTransform(url);
185
- }
186
-
187
- // ---------------------------------------------------------------------------
188
- // LLM-output sanitizer — escape unknown HTML-style tags
189
- // ---------------------------------------------------------------------------
190
- /**
191
- * Tags `rehype-raw` is allowed to forward to React as-is. Anything
192
- * outside this set gets its angle brackets escaped so it renders as
193
- * plain text rather than reaching React as an unrecognized custom
194
- * element.
195
- *
196
- * Why this matters: chat output is LLM-generated markdown. An LLM that
197
- * accidentally wraps a word in angle brackets ("share <their> settings")
198
- * or echoes a system-prompt XML tag ("the <ticket> element above")
199
- * makes `rehype-raw` mint a `<their>` / `<ticket>` JSX element. React
200
- * 18 logs "tag is unrecognized in this browser" for every unknown tag;
201
- * React 19 throws on tags with reserved kebab-case forms. We pre-escape
202
- * to keep the renderer pristine without losing legitimate inline HTML
203
- * (details/summary, iframe, kbd, mark, etc.). `video` is deliberately
204
- * NOT allow-listed: chat strips <video> tags server-side, and all video
205
- * playback goes through the <Video> SSOT — a stray tag renders as
206
- * escaped literal text (visible + debuggable) rather than a native
207
- * player outside the unified pipeline.
208
- *
209
- * The allow-list mirrors HTML5 + the elements the chat shell wires
210
- * component overrides for. Kept lower-case; matched case-insensitively.
211
- */
212
- const SAFE_HTML_TAGS = new Set([
213
- // Block + inline text
214
- 'a', 'abbr', 'address', 'article', 'aside', 'b', 'bdi', 'bdo', 'blockquote',
215
- 'br', 'caption', 'cite', 'code', 'col', 'colgroup', 'data', 'dd', 'del',
216
- 'details', 'dfn', 'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'footer',
217
- 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'i', 'ins',
218
- 'kbd', 'li', 'main', 'mark', 'nav', 'ol', 'p', 'pre', 'q', 'rp', 'rt',
219
- 'ruby', 's', 'samp', 'section', 'small', 'span', 'strong', 'sub', 'summary',
220
- 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time', 'tr', 'u',
221
- 'ul', 'var', 'wbr',
222
- // Media ('video' intentionally excluded — see the header comment)
223
- 'img', 'picture', 'source', 'audio', 'iframe', 'track',
224
- // Forms (rehype-raw allows them; mostly harmless for chat output)
225
- 'button', 'input', 'label', 'select', 'option', 'optgroup', 'textarea', 'form', 'fieldset', 'legend',
226
- ])
227
-
228
- /**
229
- * Match an opening / closing HTML tag in markdown source. Captures:
230
- * 1 — optional `/` for closing tags
231
- * 2 — tag name (must start with a letter)
232
- * 3 — everything between the name and the closing `>` (attrs etc.)
233
- * 4 — optional `/` for void-element self-close
234
- */
235
- // ReDoS-safe shape (CodeQL polynomial-regex hardening):
236
- //
237
- // Every quantifier is hard-bounded so the engine has no possible
238
- // polynomial path. The original `[a-zA-Z0-9-]*` + `[^>]*?` admitted
239
- // O(n²) backtracking on `<A-----…` because both classes include `-`;
240
- // CodeQL flagged that as a polynomial regex. Bounding the tag-name
241
- // at 63 chars (covers every real HTML tag — `animateTransform` at 16
242
- // is the longest in practice) and the rest at 4096 chars (sane upper
243
- // bound for `<a href="…">` attribute strings) means matching is
244
- // constant-time per tag, NOT proportional to input length.
245
- //
246
- // Lazy `{0,4096}?` is deliberately lazy (not greedy) so the trailing
247
- // `(\/?)>` still captures the self-close `/` — greedy would swallow
248
- // the slash into the rest. Bounded lazy backtracking is constant-time
249
- // per match (capped at 4096 expansions), so the laziness is safe.
250
- //
251
- // Anything longer than 4159 chars per tag simply doesn't match —
252
- // falls through as plain text, the safe-degrade behavior for
253
- // HTML-in-markdown.
254
- //
255
- // Tag shapes still covered: `<a>`, `<a/>`, `<a class="x">`,
256
- // `<a class="x" />`, `</a>`, `<input type="text" />`,
257
- // `<custom-element data-foo="bar">`, `<animateTransform>`, etc.
258
- const TAG_LIKE_REGEX = /<(\/?)([a-zA-Z][a-zA-Z0-9-]{0,63})((?:\s[^>]{0,4096}?)?)(\/?)>/g
259
-
260
- function escapeUnknownHtmlTags(text: string): string {
261
- if (!text || text.indexOf('<') === -1) return text
262
- // Carve out fenced code blocks AND inline-backtick spans so we don't
263
- // corrupt `<their>` examples that legitimately live inside code. The
264
- // regex matches in priority order: triple-fence first (greediest),
265
- // then inline single-backtick. Each protected span is preserved
266
- // verbatim; everything between/around them runs through
267
- // `escapeOutsideFences`.
268
- const parts: string[] = []
269
- let cursor = 0
270
- // Triple-fence ``` … ``` OR single-backtick `…` (one line, no
271
- // embedded newlines). Backtick group is non-greedy and forbids
272
- // inner newlines per CommonMark inline-code semantics.
273
- const PROTECTED_SPAN_RE = /```[\s\S]*?```|`[^`\n]+`/g
274
- let span: RegExpExecArray | null
275
- while ((span = PROTECTED_SPAN_RE.exec(text)) !== null) {
276
- if (span.index > cursor) {
277
- parts.push(escapeOutsideFences(text.slice(cursor, span.index)))
278
- }
279
- parts.push(span[0])
280
- cursor = span.index + span[0].length
281
- }
282
- if (cursor < text.length) {
283
- parts.push(escapeOutsideFences(text.slice(cursor)))
284
- }
285
- return parts.join('')
286
- }
287
-
288
- function escapeOutsideFences(segment: string): string {
289
- return segment.replace(TAG_LIKE_REGEX, (match, slash, tag, rest, selfClose) => {
290
- const lower = (tag as string).toLowerCase()
291
- if (SAFE_HTML_TAGS.has(lower)) return match
292
- // Unknown tag — escape so it renders as text instead of reaching
293
- // React as a custom element.
294
- return `&lt;${slash}${tag}${rest}${selfClose}&gt;`
295
- })
296
- }
297
-
298
- // ---------------------------------------------------------------------------
299
- // Mermaid styles (responsive)
300
- // ---------------------------------------------------------------------------
301
- const mermaidStyles = `
302
- .mermaid-svg-container svg {
303
- max-width: 100% !important;
304
- height: auto !important;
305
- min-height: 200px;
306
- font-family: 'DM Sans', sans-serif !important;
307
- font-size: 14px !important;
308
- }
309
- @media (min-width: 1520px) {
310
- .mermaid-svg-container svg {
311
- max-width: 900px !important;
312
- max-height: 700px !important;
313
- min-height: 300px;
314
- font-size: 16px !important;
315
- }
316
- }
317
- @media (min-width: 768px) and (max-width: 1519px) {
318
- .mermaid-svg-container svg {
319
- max-width: 700px !important;
320
- max-height: 600px !important;
321
- min-height: 250px;
322
- font-size: 15px !important;
323
- }
324
- }
325
- @media (max-width: 767px) {
326
- .mermaid-svg-container svg {
327
- max-width: 90vw !important;
328
- max-height: 400px !important;
329
- min-height: 200px;
330
- font-size: 13px !important;
331
- }
332
- }
333
- .mermaid-svg-container svg[width] { width: 100% !important; }
334
- .mermaid-svg-container .node rect,
335
- .mermaid-svg-container .node circle,
336
- .mermaid-svg-container .node ellipse,
337
- .mermaid-svg-container .node polygon { stroke-width: 2px !important; }
338
- .mermaid-svg-container .edgePath path { stroke-width: 2px !important; }
339
- @media (min-width: 768px) {
340
- .mermaid-svg-container .node text,
341
- .mermaid-svg-container .edgeLabel text { font-size: 14px !important; }
342
- }
343
- @media (min-width: 1520px) {
344
- .mermaid-svg-container .node text,
345
- .mermaid-svg-container .edgeLabel text { font-size: 16px !important; }
346
- }
347
- `;
348
-
349
- // ---------------------------------------------------------------------------
350
- // MermaidDiagram
351
- // ---------------------------------------------------------------------------
352
- const MermaidDiagram: React.FC<{ chart: string }> = ({ chart }) => {
353
- const [svg, setSvg] = useState<string>('');
354
- const [error, setError] = useState<string>('');
355
- const [isLoading, setIsLoading] = useState<boolean>(true);
356
- const [mounted, setMounted] = useState(false);
357
-
358
- useEffect(() => { setMounted(true); }, []);
359
-
360
- useEffect(() => {
361
- const renderMermaid = async () => {
362
- try {
363
- setIsLoading(true);
364
- // @ts-ignore -- mermaid is an optional runtime dependency, dynamically imported by the consumer
365
- const { default: mermaid } = await import('mermaid');
366
-
367
- mermaid.initialize({
368
- startOnLoad: false,
369
- theme: 'dark' as const,
370
- themeVariables: {
371
- primaryColor: '#FFC008',
372
- primaryTextColor: '#FAFAFA',
373
- primaryBorderColor: '#3A3A3A',
374
- lineColor: '#888888',
375
- secondaryColor: '#212121',
376
- tertiaryColor: '#2A2A2A',
377
- background: 'transparent',
378
- mainBkg: 'transparent',
379
- secondBkg: 'transparent',
380
- tertiaryBkg: 'transparent',
381
- cScale0: '#FFC008', cScale1: '#4ECDC4', cScale2: '#45B7D1',
382
- cScale3: '#96CEB4', cScale4: '#FFEAA7', cScale5: '#DDA0DD',
383
- cScale6: '#98D8C8', cScale7: '#F7DC6F', cScale8: '#BB8FCE',
384
- cScale9: '#85C1E9',
385
- taskTextColor: '#FAFAFA',
386
- taskTextOutsideColor: '#FAFAFA',
387
- activeTaskTextColor: '#1A1A1A',
388
- nodeTextColor: '#FAFAFA',
389
- },
390
- flowchart: { useMaxWidth: true, htmlLabels: true, rankSpacing: 50, nodeSpacing: 30, curve: 'basis' },
391
- sequence: { useMaxWidth: true, width: 150 },
392
- pie: { useMaxWidth: true, useWidth: undefined },
393
- fontFamily: 'DM Sans, sans-serif',
394
- fontSize: 14,
395
- securityLevel: 'loose',
396
- });
397
-
398
- const { svg: renderedSvg } = await mermaid.render(`mermaid-${Date.now()}`, chart);
399
- setSvg(renderedSvg);
400
- setIsLoading(false);
401
- } catch (err) {
402
- console.error('Mermaid rendering error:', err);
403
- setError(`Failed to render diagram: ${err instanceof Error ? err.message : 'Unknown error'}`);
404
- setIsLoading(false);
405
- }
406
- };
407
-
408
- if (mounted) { renderMermaid(); }
409
- }, [chart, mounted]);
410
-
411
- if (error) {
412
- return (
413
- <div className="error-state bg-ods-card border border-ods-border rounded-lg p-6 my-6">
414
- <div className="error-icon flex justify-center mb-4">
415
- <AlertCircleIcon className="w-12 h-12 text-ods-error" />
416
- </div>
417
- <div className="error-title text-center font-sans font-semibold text-lg text-ods-error mb-2">
418
- Diagram Error
419
- </div>
420
- <div className="error-description text-center font-sans text-sm text-ods-text-secondary mb-4 break-words overflow-hidden max-w-full">
421
- <div className="overflow-x-auto">
422
- <pre className="whitespace-pre-wrap break-words text-xs">{error}</pre>
423
- </div>
424
- </div>
425
- </div>
426
- );
427
- }
428
-
429
- if (isLoading || !svg) {
430
- return (
431
- <div className="skeleton-code bg-ods-card border border-ods-border rounded-lg p-6 min-h-[120px] flex items-center justify-center">
432
- <div className="animate-pulse text-ods-text-tertiary font-sans">
433
- {isLoading ? 'Loading diagram renderer...' : 'Rendering diagram...'}
434
- </div>
435
- </div>
436
- );
437
- }
438
-
439
- return (
440
- <div className="mermaid-container rounded-lg p-4 md:p-6 lg:p-8 my-6 overflow-x-auto bg-ods-card border border-ods-border">
441
- <div className="flex justify-center items-center w-full min-h-[200px] md:min-h-[250px] lg:min-h-[300px]">
442
- <div
443
- className="mermaid-svg-container w-full flex justify-center max-w-full"
444
- style={{ fontSize: '14px' }}
445
- dangerouslySetInnerHTML={{
446
- __html: svg.replace(/<svg[^>]*>/, (match) =>
447
- match.replace(/width="[^"]*"/, 'width="100%"').replace(/height="[^"]*"/, 'height="auto"')
448
- ),
449
- }}
450
- />
451
- </div>
452
- </div>
453
- );
454
- };
455
-
456
- // ---------------------------------------------------------------------------
457
- // Utility: extract plain text from React children
458
- // ---------------------------------------------------------------------------
459
- function extractText(node: any): string {
460
- if (typeof node === 'string') return node;
461
- if (Array.isArray(node)) return node.map(extractText).join('');
462
- if (node?.props?.children) return extractText(node.props.children);
463
- return '';
464
- }
465
-
466
- // ---------------------------------------------------------------------------
467
- // Text size configuration
468
- // ---------------------------------------------------------------------------
469
- export type TextSizeElement =
470
- | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
471
- | 'p' | 'li' | 'blockquote' | 'code' | 'th' | 'td';
472
-
473
- export type TextSizeClassMap = Partial<Record<TextSizeElement, string>>;
474
- export type TextSizePreset = 'default' | 'compact' | 'large';
475
- export type TextSizeConfig =
476
- | TextSizePreset
477
- | TextSizeClassMap
478
- | { preset: TextSizePreset; overrides: TextSizeClassMap };
479
-
480
- const TEXT_SIZE_PRESETS: Record<TextSizePreset, Record<TextSizeElement, string>> = {
481
- default: {
482
- h1: 'text-heading-1',
483
- h2: 'text-heading-2',
484
- h3: 'text-2xl md:text-3xl',
485
- h4: 'text-xl',
486
- h5: 'text-lg md:text-xl',
487
- h6: 'text-base md:text-lg',
488
- p: 'md:text-h4 lg:text-h4',
489
- li: 'text-base md:text-lg',
490
- blockquote: 'text-lg',
491
- code: 'text-[14px]',
492
- th: 'text-xs md:text-sm',
493
- td: 'text-xs md:text-sm',
494
- },
495
- compact: {
496
- h1: 'text-heading-2',
497
- h2: 'text-heading-3',
498
- h3: 'text-xl md:text-2xl',
499
- h4: 'text-lg md:text-xl',
500
- h5: 'text-base md:text-lg',
501
- h6: 'text-sm md:text-base',
502
- p: 'text-base md:text-lg',
503
- li: 'text-base md:text-lg',
504
- blockquote: 'text-base md:text-lg',
505
- code: 'text-[13px]',
506
- th: 'text-xs md:text-sm',
507
- td: 'text-xs md:text-sm',
508
- },
509
- large: {
510
- h1: 'text-heading-1',
511
- h2: 'text-heading-1',
512
- h3: 'text-heading-2',
513
- h4: 'text-2xl md:text-3xl',
514
- h5: 'text-xl md:text-2xl',
515
- h6: 'text-lg md:text-xl',
516
- p: 'text-h3',
517
- li: 'text-lg md:text-xl',
518
- blockquote: 'text-xl md:text-2xl',
519
- code: 'text-[16px]',
520
- th: 'text-sm md:text-base',
521
- td: 'text-sm md:text-base',
522
- },
523
- };
524
-
525
- function resolveTextSizeConfig(config?: TextSizeConfig): Record<TextSizeElement, string> {
526
- const defaultSizes = TEXT_SIZE_PRESETS.default;
527
- if (!config) return defaultSizes;
528
- if (typeof config === 'string') return TEXT_SIZE_PRESETS[config];
529
- if ('preset' in config) return { ...TEXT_SIZE_PRESETS[config.preset], ...config.overrides };
530
- return { ...defaultSizes, ...config };
531
- }
532
-
533
- // `ResolveLinkResult` lives in `../../types/doc-source` so it's the single
534
- // canonical home shared by `DocRenderHandlers.onResolveLink`, `DocViewer`'s
535
- // resolver, and this renderer's `onResolveLink` prop. Re-exported at the top
536
- // of this file for back-compat with callers that import from here.
537
-
538
- // ---------------------------------------------------------------------------
539
- // Props
540
- // ---------------------------------------------------------------------------
541
- export interface SimpleMarkdownRendererProps {
542
- content: string;
543
- className?: string;
544
- /** Backend-provided heading IDs for deep-link anchors */
545
- sectionIds?: Array<{ id: string; title: string; level: number }>;
546
- /** When the page already has an H1, render markdown `#` as `<h2>` */
547
- demoteMarkdownH1ToH2?: boolean;
548
- /** List of broken link hrefs detected server-side (shown with [BROKEN] badge) */
549
- brokenLinks?: string[];
550
- /** Callback for internal (non-http, non-anchor) link clicks */
551
- onInternalLinkClick?: (path: string, options?: { expandFolder?: boolean; fromInternalLink?: boolean }) => void;
552
- /** Current documentation path — enables internal-link mode when set */
553
- currentPath?: string;
554
- /**
555
- * Resolve an internal link href to a navigation path.
556
- * Called when a user clicks an internal doc link.
557
- */
558
- onResolveLink?: (href: string, currentPath: string) => Promise<ResolveLinkResult>;
559
- /** Pre-process the raw markdown string before rendering (e.g. shortcode expansion) */
560
- preprocessContent?: (content: string) => string;
561
- /** Merge additional or override react-markdown component renderers */
562
- componentOverrides?: Partial<Components>;
563
- /**
564
- * Extra remark plugins appended after the built-in `remarkGfm` and
565
- * `remarkBreaks`. Used by chat consumers to inject `remarkCardLinks`,
566
- * which converts `[card://<type>:<id>]` markers into synthetic `link`
567
- * nodes; the host's `componentOverrides.a` then swaps those for whatever
568
- * inline entity-card component it provides.
569
- *
570
- * Each entry is either a Plugin reference or a [Plugin, options] tuple
571
- * — same shape as react-markdown's own `remarkPlugins` prop. Pass an
572
- * empty array (or omit) to keep the default plugin set.
573
- */
574
- additionalRemarkPlugins?: PluggableList;
575
- /**
576
- * Configure text sizing for all rendered elements.
577
- * - `"default"` — current behavior (large article-style typography)
578
- * - `"compact"` — smaller sizes for sidebars, cards, changelogs
579
- * - `"large"` — extra-large sizes for hero/landing content
580
- * - `{ p: "text-sm", h1: "text-2xl" }` — per-element overrides (merged onto default)
581
- * - `{ preset: "compact", overrides: { h1: "text-heading-1" } }` — preset + tweaks
582
- */
583
- textSize?: TextSizeConfig;
584
- }
585
-
586
- // ---------------------------------------------------------------------------
587
- // Component
588
- // ---------------------------------------------------------------------------
589
- const SimpleMarkdownRendererImpl: React.FC<SimpleMarkdownRendererProps> = ({
590
- content,
591
- className = "",
592
- sectionIds,
593
- demoteMarkdownH1ToH2 = false,
594
- brokenLinks = [],
595
- onInternalLinkClick,
596
- currentPath: propCurrentPath,
597
- onResolveLink,
598
- preprocessContent,
599
- componentOverrides,
600
- textSize,
601
- additionalRemarkPlugins,
602
- }) => {
603
- const idCountsRef = useRef<Record<string, number>>({});
604
-
605
- // ---- text sizes ----
606
- const textSizes = useMemo(() => resolveTextSizeConfig(textSize), [textSize]);
607
-
608
- // ---- section ID map ----
609
- const sectionIdMap = useMemo(() => {
610
- const map = new Map<string, string>();
611
- if (sectionIds) {
612
- sectionIds.forEach(section => {
613
- const cleanTitle = section.title
614
- .replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu, '')
615
- .trim()
616
- .toLowerCase();
617
- map.set(section.title.toLowerCase(), section.id);
618
- map.set(cleanTitle, section.id);
619
- map.set(section.title, section.id);
620
- });
621
- }
622
- return map;
623
- }, [sectionIds]);
624
-
625
- // ---- heading ID generation ----
626
- const generateHeadingId = useCallback((text: string, level: number): string => {
627
- if (sectionIds && (level === 1 || level === 2)) {
628
- const variations = [
629
- text, text.toLowerCase(),
630
- text.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu, '').trim(),
631
- text.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu, '').trim().toLowerCase(),
632
- ];
633
- for (const v of variations) {
634
- const id = sectionIdMap.get(v);
635
- if (id) return id;
636
- }
637
- }
638
- const baseId = text
639
- .replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu, '')
640
- .trim().toLowerCase()
641
- .replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').replace(/^-+|-+$/g, '');
642
- const cleanId = baseId || `section-${Object.keys(idCountsRef.current).length + 1}`;
643
- if (idCountsRef.current[cleanId]) {
644
- idCountsRef.current[cleanId]++;
645
- return `${cleanId}-${idCountsRef.current[cleanId]}`;
646
- }
647
- idCountsRef.current[cleanId] = 1;
648
- return cleanId;
649
- }, [sectionIds, sectionIdMap]);
650
-
651
- // ---- preprocess ----
652
- // Run the optional caller-supplied transform first, then defensively
653
- // escape unknown HTML-style tags so an LLM that wrote `<their>` or
654
- // accidentally echoed a system-prompt XML tag like `<ticket>` doesn't
655
- // make `rehype-raw` hand React an unrecognized custom element (which
656
- // logs a noisy "tag is unrecognized in this browser" warning AND can
657
- // crash the SimpleMarkdownRenderer when the tag has a kebab-case
658
- // form React rejects outright). Allow-listed tags pass through
659
- // unchanged so legitimate inline HTML (details/summary, iframe,
660
- // etc.) still renders ('video' is excluded — see SAFE_HTML_TAGS).
661
- const processedContent = useMemo(
662
- () => escapeUnknownHtmlTags(preprocessContent ? preprocessContent(content) : content),
663
- [preprocessContent, content],
664
- );
665
-
666
- // ---- heading factory ----
667
- const makeHeading = useCallback(
668
- (Tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6', level: number, headingClassName: string) =>
669
- ({ children }: any) => {
670
- const text = extractText(children);
671
- const effectiveLevel = Tag === 'h1' && demoteMarkdownH1ToH2 ? 2 : level;
672
- const id = generateHeadingId(text, effectiveLevel);
673
- const EffectiveTag = Tag === 'h1' && demoteMarkdownH1ToH2 ? 'h2' : Tag;
674
- return <EffectiveTag id={id} className={headingClassName}>{children}</EffectiveTag>;
675
- },
676
- [generateHeadingId, demoteMarkdownH1ToH2],
677
- );
678
-
679
- // ---- components ----
680
- const components: Components = useMemo(() => ({
681
- // --- code ---
682
- code: ({ node, inline, className: codeClassName, children, ...props }: any) => {
683
- const match = /language-(\w+)/.exec(codeClassName || '');
684
- const language = match ? match[1] : '';
685
-
686
- if (!inline && language === 'mermaid') {
687
- return <MermaidDiagram chart={String(children).replace(/\n$/, '')} />;
688
- }
689
-
690
- if (!inline && match) {
691
- return (
692
- <div className="code-block-container border rounded-lg my-6 overflow-hidden bg-ods-card border-ods-border">
693
- <div className="code-header border-b px-4 py-2 bg-ods-card border-ods-border">
694
- <span className="font-sans text-xs uppercase tracking-wide text-ods-text-tertiary">
695
- {language || 'code'}
696
- </span>
697
- </div>
698
- <div className="p-4">
699
- <pre className="overflow-x-auto">
700
- <code
701
- className={cn(`language-${language} hljs`, textSizes.code)}
702
- style={{
703
- fontFamily: "JetBrains Mono', 'SF Mono', Consolas, monospace",
704
- background: 'transparent',
705
- color: 'var(--color-text-primary)',
706
- }}
707
- {...props}
708
- >
709
- {children}
710
- </code>
711
- </pre>
712
- </div>
713
- </div>
714
- );
715
- }
716
-
717
- return (
718
- <code className="font-mono text-[0.9em] px-1.5 py-0.5 rounded border bg-ods-card text-ods-text-primary border-ods-border" {...props}>
719
- {children}
720
- </code>
721
- );
722
- },
723
-
724
- // --- div (pass-through, overridable for embeds) ---
725
- div: ({ node, className: divClassName, children, ...props }: any) => (
726
- <div className={divClassName} {...props}>{children}</div>
727
- ),
728
-
729
- // --- blockquote ---
730
- blockquote: ({ children }: any) => (
731
- <blockquote className="border-l-4 border-ods-accent ml-0 pl-6 my-8 py-4 rounded-r-lg bg-ods-bg-surface">
732
- <div className={cn('font-sans leading-relaxed text-ods-text-secondary', textSizes.blockquote)}>
733
- {children}
734
- </div>
735
- </blockquote>
736
- ),
737
-
738
- // --- headings ---
739
- h1: makeHeading('h1', 1, cn('font-sans font-bold mt-8 mb-4 first:mt-0 text-ods-text-primary', textSizes.h1)),
740
- h2: makeHeading('h2', 2, cn('font-sans font-semibold mt-8 mb-4 pb-2 border-b text-ods-text-primary border-ods-border', textSizes.h2)),
741
- h3: makeHeading('h3', 3, cn('font-sans font-semibold mt-6 mb-3 text-ods-text-primary', textSizes.h3)),
742
- h4: makeHeading('h4', 4, cn('font-sans font-semibold mt-4 mb-2 text-ods-text-primary', textSizes.h4)),
743
- h5: makeHeading('h5', 5, cn('font-sans font-semibold mt-3 mb-2 text-ods-text-primary', textSizes.h5)),
744
- h6: makeHeading('h6', 6, cn('font-sans font-semibold mt-3 mb-1 text-ods-text-primary', textSizes.h6)),
745
-
746
- // --- paragraph ---
747
- p: ({ children }: any) => (
748
- <p className={cn('leading-relaxed mb-4 first:mt-0 last:mb-0 text-ods-text-primary', textSizes.p)}>
749
- {children}
750
- </p>
751
- ),
752
-
753
- // --- links ---
754
- a: ({ href, children, className: linkClassName }: any) => {
755
- const isBroken = brokenLinks.includes(href);
756
- const isInternalDocLink =
757
- propCurrentPath !== undefined &&
758
- propCurrentPath !== null &&
759
- href &&
760
- !href.startsWith('http') &&
761
- !href.startsWith('#');
762
-
763
- if (isBroken) {
764
- return (
765
- <span className="text-ods-accent cursor-not-allowed">
766
- {children}
767
- <sup className="ml-1 text-xs font-bold text-ods-error">[BROKEN]</sup>
768
- </span>
769
- );
770
- }
771
-
772
- if (isInternalDocLink && onInternalLinkClick) {
773
- const currentPath = propCurrentPath ?? '';
774
- return (
775
- <span
776
- className="text-ods-accent no-underline relative transition-colors duration-200 hover:after:w-full after:content-[''] after:absolute after:w-0 after:h-0.5 after:-bottom-0.5 after:left-0 after:bg-ods-accent after:transition-all after:duration-300 cursor-pointer"
777
- onClick={async (e) => {
778
- e.preventDefault();
779
- e.stopPropagation();
780
- if (onResolveLink) {
781
- try {
782
- const result = await onResolveLink(href, currentPath);
783
- if (result.type === 'folder-no-readme' && result.action === 'expand_folder') {
784
- onInternalLinkClick(result.resolvedPath!, { expandFolder: true, fromInternalLink: true });
785
- } else if (result.type === 'not-found') {
786
- return;
787
- } else if (result.success && result.resolvedPath) {
788
- onInternalLinkClick(result.resolvedPath, { fromInternalLink: true });
789
- }
790
- } catch (error) {
791
- console.error('Error resolving link:', error);
792
- }
793
- } else {
794
- onInternalLinkClick(href, { fromInternalLink: true });
795
- }
796
- }}
797
- role="link"
798
- tabIndex={0}
799
- onKeyDown={(e) => {
800
- if (e.key === 'Enter' || e.key === ' ') {
801
- (e.currentTarget as HTMLElement).click();
802
- }
803
- }}
804
- >
805
- {children}
806
- </span>
807
- );
808
- }
809
-
810
- return (
811
- <a
812
- href={href}
813
- className={`text-ods-accent no-underline relative transition-colors duration-200 hover:after:w-full after:content-[''] after:absolute after:w-0 after:h-0.5 after:-bottom-0.5 after:left-0 after:bg-ods-accent after:transition-all after:duration-300 ${linkClassName || ''}`}
814
- target={href?.startsWith('http') ? '_blank' : undefined}
815
- rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
816
- >
817
- {children}
818
- </a>
819
- );
820
- },
821
-
822
- // --- images ---
823
- // Inline content image renderer. Used by blog posts, docs, AND chat
824
- // messages (where users may attach screenshots / photos via the +
825
- // attachment button).
826
- //
827
- // Sizing rules (2025-2026 best practice — Claude.ai, ChatGPT,
828
- // iMessage, Slack, Discord inline image patterns):
829
- //
830
- // - CAP max-width at 400px so inline images don't blow out the
831
- // message column on wide panels. Click-to-expand opens a
832
- // full-resolution modal for users who need detail.
833
- // - CAP max-height at 400px so portrait-orientation images
834
- // don't dominate vertical space (a 1000x3000 phone screenshot
835
- // would otherwise push the next message off-screen).
836
- // - Small images render at NATURAL pixel size — a 64x64
837
- // thumbnail stays 64x64, not stretched to fill the column.
838
- // - `object-contain` preserves aspect ratio when both dimensions
839
- // are constrained (long landscape, tall portrait).
840
- //
841
- // Implementation: Next.js `<Image>` — the project's canonical
842
- // image primitive. Gives us:
843
- // - WebP/AVIF format conversion for modern browsers (smaller
844
- // bytes for the same visual quality).
845
- // - Responsive `srcset` via the `sizes` prop (browser picks the
846
- // right variant for the viewport).
847
- // - Automatic lazy-loading (`loading="lazy"` by default, mid-
848
- // page images skipped until they near the viewport).
849
- // - Automatic `decoding="async"` so image decode doesn't block
850
- // paint.
851
- //
852
- // `width={400} height={400}` props are REQUIRED by Next.js
853
- // `<Image>` (non-`fill` mode throws without them) but they're
854
- // effectively a CEILING here, not the display size — the CSS
855
- // overrides (`w-auto h-auto max-w-full max-h-[400px]`) drive
856
- // the actual rendered size. The inline `style={{ width: 'auto',
857
- // height: 'auto' }}` is belt-and-suspenders: Next.js Image sets
858
- // matching HTML `width`/`height` attributes on the rendered
859
- // `<img>` and inline style wins over both HTML attributes AND
860
- // utility classes regardless of CSS-specificity surprises.
861
- //
862
- // Layout reservation trade-off: until image bytes arrive, the
863
- // browser may reserve a placeholder box up to 400x400 (the props'
864
- // intrinsic-ratio hint). Once loaded, the box collapses to the
865
- // natural size if smaller. This is the standard Next.js Image
866
- // behavior across the codebase — accepted for the optimizer +
867
- // responsive-srcset benefits. Chat attachments hosted in side
868
- // panels see this only on first render of a fresh attachment
869
- // (cached re-renders pop in without a perceptible shift).
870
- img: ({ src, alt }: any) => {
871
- if (!src || typeof src !== 'string' || src.trim() === '') return null;
872
- return (
873
- <Image
874
- src={src}
875
- alt={alt ?? 'No image available'}
876
- width={400}
877
- height={400}
878
- sizes="(max-width: 400px) 100vw, 400px"
879
- className="max-w-full max-h-[400px] w-auto h-auto rounded-lg object-contain"
880
- style={{ width: 'auto', height: 'auto' }}
881
- />
882
- );
883
- },
884
-
885
- // --- lists ---
886
- ul: ({ children }: any) => (
887
- <ul className="list-disc list-outside my-4 ml-8 space-y-2 text-ods-text-primary">{children}</ul>
888
- ),
889
- ol: ({ children }: any) => (
890
- <ol className="list-decimal list-outside my-4 ml-8 space-y-2 text-ods-text-primary">{children}</ol>
891
- ),
892
- li: ({ children }: any) => (
893
- <li className={cn('leading-relaxed pl-2', textSizes.li)}>{children}</li>
894
- ),
895
-
896
- // --- tables ---
897
- table: ({ children }: any) => (
898
- <div className="table-container my-6 overflow-x-auto">
899
- <div className="min-w-full border rounded-lg border-ods-border bg-ods-card">
900
- <table className="w-full table-fixed md:table-auto">{children}</table>
901
- </div>
902
- </div>
903
- ),
904
- thead: ({ children }: any) => (
905
- <thead className="bg-ods-bg-surface">{children}</thead>
906
- ),
907
- th: ({ children }: any) => (
908
- <th className={cn('px-2 md:px-4 py-3 text-left font-semibold text-ods-accent border-r last:border-r-0 break-words border-ods-border', textSizes.th)}>
909
- {children}
910
- </th>
911
- ),
912
- td: ({ children }: any) => (
913
- <td className={cn('px-2 md:px-4 py-3 border-r last:border-r-0 border-b break-words whitespace-normal text-ods-text-primary border-ods-border', textSizes.td)}>
914
- {children}
915
- </td>
916
- ),
917
-
918
- // --- horizontal rule ---
919
- hr: () => <hr className="border-0 border-t my-8 border-ods-border" />,
920
-
921
- // --- merge overrides ---
922
- ...componentOverrides,
923
- }), [makeHeading, brokenLinks, propCurrentPath, onInternalLinkClick, onResolveLink, componentOverrides, textSizes]);
924
-
925
- return (
926
- <div className={`simple-markdown-renderer ${className}`}>
927
- <style dangerouslySetInnerHTML={{ __html: mermaidStyles }} />
928
- {/* `overflow-wrap: anywhere` is inherited from `.simple-markdown-renderer`
929
- in app-globals.css — do NOT re-add `break-words` here, it would
930
- override the cascade with the weaker `break-word` for this subtree. */}
931
- <div className="content-wrapper max-w-none">
932
- <article className="prose prose-lg max-w-none">
933
- <ReactMarkdown
934
- remarkPlugins={[remarkGfm, remarkBreaks, ...(additionalRemarkPlugins ?? [])]}
935
- rehypePlugins={[
936
- // ORDER MATTERS: rehype-raw parses the raw HTML embedded
937
- // in the source markdown into HAST nodes; rehypeStripUnsafe
938
- // then walks the HAST tree and drops XSS vectors (on*
939
- // event handlers, javascript: URLs, script/style/iframe-srcdoc,
940
- // data: URIs). Reversing the order would have nothing to
941
- // sanitize (raw HTML would still be strings).
942
- rehypeRaw,
943
- rehypeStripUnsafe,
944
- [rehypeHighlight, { detect: true, ignoreMissing: true }],
945
- ]}
946
- urlTransform={cardAwareUrlTransform}
947
- components={components}
948
- >
949
- {processedContent}
950
- </ReactMarkdown>
951
- </article>
952
- </div>
953
- </div>
954
- );
955
- };
956
-
957
- /**
958
- * Memoized so a parent re-render with UNCHANGED props (same `content` string,
959
- * same memoized `componentOverrides`/plugins) does NOT re-parse the markdown.
960
- * Re-parsing rebuilds the entire react-markdown subtree, which RE-MOUNTS any
961
- * embedded inline entity cards — closing their open menus and re-triggering
962
- * their fetch on every chat re-render (streaming chunk AND scroll). With
963
- * stable props the renderer bails, so completed messages' cards stay mounted.
964
- */
965
- export const SimpleMarkdownRenderer = memo(SimpleMarkdownRendererImpl);