@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.
- package/dist/chat-protocol/decode.d.ts +58 -0
- package/dist/chat-protocol/decode.d.ts.map +1 -0
- package/dist/chat-protocol/encode.d.ts +44 -0
- package/dist/chat-protocol/encode.d.ts.map +1 -0
- package/dist/chat-protocol/env-flag.d.ts +35 -0
- package/dist/chat-protocol/env-flag.d.ts.map +1 -0
- package/dist/chat-protocol/events.d.ts +216 -0
- package/dist/chat-protocol/events.d.ts.map +1 -0
- package/dist/chat-protocol/frames.d.ts +213 -0
- package/dist/chat-protocol/frames.d.ts.map +1 -0
- package/dist/chat-protocol/index.cjs +506 -0
- package/dist/chat-protocol/index.cjs.map +1 -0
- package/dist/chat-protocol/index.d.ts +18 -0
- package/dist/chat-protocol/index.d.ts.map +1 -0
- package/dist/chat-protocol/index.js +490 -0
- package/dist/chat-protocol/index.js.map +1 -0
- package/dist/chat-protocol/ip-normalize.d.ts +44 -0
- package/dist/chat-protocol/ip-normalize.d.ts.map +1 -0
- package/dist/chat-protocol/nats-decoder.d.ts +26 -0
- package/dist/chat-protocol/nats-decoder.d.ts.map +1 -0
- package/dist/{chunk-3SQ5KXHQ.cjs → chunk-2T4GTV25.cjs} +9 -9
- package/dist/{chunk-3SQ5KXHQ.cjs.map → chunk-2T4GTV25.cjs.map} +1 -1
- package/dist/{chunk-CWAOV2FK.cjs → chunk-3CGZPAGR.cjs} +3 -3
- package/dist/{chunk-CWAOV2FK.cjs.map → chunk-3CGZPAGR.cjs.map} +1 -1
- package/dist/{chunk-TLMJHMXJ.js → chunk-3QSR22IC.js} +12 -12
- package/dist/chunk-3QSR22IC.js.map +1 -0
- package/dist/{chunk-TWYNR4TZ.cjs → chunk-4G5TLHJD.cjs} +7141 -4799
- package/dist/chunk-4G5TLHJD.cjs.map +1 -0
- package/dist/{chunk-ADPMHWOE.js → chunk-72XAID7Y.js} +4 -4
- package/dist/{chunk-PAGKRNWK.js → chunk-7WZHBQ4J.js} +2 -2
- package/dist/{chunk-OCEKO5CW.js → chunk-AEELJC4N.js} +12106 -9764
- package/dist/chunk-AEELJC4N.js.map +1 -0
- package/dist/{chunk-Z42BGM6Q.cjs → chunk-BT2WWDFP.cjs} +5 -5
- package/dist/{chunk-Z42BGM6Q.cjs.map → chunk-BT2WWDFP.cjs.map} +1 -1
- package/dist/{chunk-7LEL3RIX.cjs → chunk-C7OY3PQP.cjs} +11 -11
- package/dist/{chunk-7LEL3RIX.cjs.map → chunk-C7OY3PQP.cjs.map} +1 -1
- package/dist/{chunk-RHGGEKPQ.cjs → chunk-CIOLOQKQ.cjs} +4 -4
- package/dist/{chunk-RHGGEKPQ.cjs.map → chunk-CIOLOQKQ.cjs.map} +1 -1
- package/dist/{chunk-TSZHM74B.js → chunk-DC2TKS7C.js} +2 -2
- package/dist/{chunk-M2GOR3XQ.cjs → chunk-DYPYR7FR.cjs} +87 -87
- package/dist/{chunk-M2GOR3XQ.cjs.map → chunk-DYPYR7FR.cjs.map} +1 -1
- package/dist/{chunk-I64ABCDX.js → chunk-I2B6X77L.js} +2 -2
- package/dist/{chunk-NIZDKTGL.cjs → chunk-K5MSCMDK.cjs} +37 -37
- package/dist/{chunk-NIZDKTGL.cjs.map → chunk-K5MSCMDK.cjs.map} +1 -1
- package/dist/{chunk-DTYRYB2N.js → chunk-K6QTZ7CZ.js} +2 -2
- package/dist/{chunk-4LTMXDUS.js → chunk-KBN7PMFT.js} +2 -2
- package/dist/{chunk-TBMV7I5N.js → chunk-KXZO2WZX.js} +2 -2
- package/dist/{chunk-QTYZMP6D.cjs → chunk-LBAKLT6T.cjs} +62 -62
- package/dist/chunk-LBAKLT6T.cjs.map +1 -0
- package/dist/{chunk-KEBYLU3U.js → chunk-LFUPI7VX.js} +2 -2
- package/dist/{chunk-LDTR4IVZ.cjs → chunk-M37PBG4N.cjs} +31 -31
- package/dist/{chunk-LDTR4IVZ.cjs.map → chunk-M37PBG4N.cjs.map} +1 -1
- package/dist/{chunk-WA7RR64F.js → chunk-PO5O654H.js} +6 -5
- package/dist/chunk-PO5O654H.js.map +1 -0
- package/dist/{chunk-WK4N5VBX.cjs → chunk-QJJISLPA.cjs} +28 -27
- package/dist/chunk-QJJISLPA.cjs.map +1 -0
- package/dist/{chunk-2F44PLTV.cjs → chunk-T7MCSA55.cjs} +26 -26
- package/dist/{chunk-2F44PLTV.cjs.map → chunk-T7MCSA55.cjs.map} +1 -1
- package/dist/{chunk-WJQHJD7J.js → chunk-TWSKTJHW.js} +2 -2
- package/dist/{chunk-ZSQHZYCO.cjs → chunk-UQDMJ6DH.cjs} +13 -13
- package/dist/{chunk-ZSQHZYCO.cjs.map → chunk-UQDMJ6DH.cjs.map} +1 -1
- package/dist/{chunk-TZRUCD56.js → chunk-V3ZAAFE3.js} +5 -5
- package/dist/{chunk-LRGHJPET.js → chunk-XGIFOHKE.js} +2 -2
- package/dist/{chunk-POOMO3PA.cjs → chunk-YQEPRYT5.cjs} +7 -7
- package/dist/{chunk-POOMO3PA.cjs.map → chunk-YQEPRYT5.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
- package/dist/components/chat/chat-message-list.d.ts.map +1 -1
- package/dist/components/chat/hooks/index.d.ts +0 -1
- package/dist/components/chat/hooks/index.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-chunk-catchup.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +15 -48
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +10 -57
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +10 -2
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.d.ts +1 -0
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/index.js +21 -13
- package/dist/components/chat/stream/chat-dialog-store.d.ts +184 -0
- package/dist/components/chat/stream/chat-dialog-store.d.ts.map +1 -0
- package/dist/components/chat/stream/chat-stream-reducer.d.ts +380 -0
- package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -0
- package/dist/components/chat/stream/delta-batcher.d.ts +51 -0
- package/dist/components/chat/stream/delta-batcher.d.ts.map +1 -0
- package/dist/components/chat/stream/index.d.ts +17 -0
- package/dist/components/chat/stream/index.d.ts.map +1 -0
- package/dist/components/chat/stream/message-mutations.d.ts +77 -0
- package/dist/components/chat/stream/message-mutations.d.ts.map +1 -0
- package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +22 -0
- package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +1 -0
- package/dist/components/chat/types/api.types.d.ts +1 -81
- package/dist/components/chat/types/api.types.d.ts.map +1 -1
- package/dist/components/chat/types/processing.types.d.ts +2 -93
- package/dist/components/chat/types/processing.types.d.ts.map +1 -1
- package/dist/components/chat/types/unified-chat-state.types.d.ts +15 -0
- package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
- package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +34 -6
- package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
- package/dist/components/chat/utils/history-merge.d.ts.map +1 -1
- package/dist/components/chat/utils/index.d.ts +2 -3
- package/dist/components/chat/utils/index.d.ts.map +1 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts +59 -11
- package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +5 -5
- package/dist/components/docs/index.js +4 -4
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/help-center-pages/index.cjs +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +174 -132
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +65 -23
- package/dist/components/index.js.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +34 -34
- package/dist/components/meeting-scheduler/index.js +3 -3
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/index.cjs +44 -2
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.d.ts +1 -2
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +55 -13
- package/dist/components/ui/markdown/base-components.d.ts +51 -0
- package/dist/components/ui/markdown/base-components.d.ts.map +1 -0
- package/dist/components/ui/markdown/engine.d.ts +99 -0
- package/dist/components/ui/markdown/engine.d.ts.map +1 -0
- package/dist/components/ui/markdown/heading-ids.d.ts +65 -0
- package/dist/components/ui/markdown/heading-ids.d.ts.map +1 -0
- package/dist/components/ui/markdown/index.d.ts +18 -0
- package/dist/components/ui/markdown/index.d.ts.map +1 -0
- package/dist/components/ui/markdown/mermaid-diagram.d.ts +79 -0
- package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +1 -0
- package/dist/components/ui/markdown/rich/embed-overrides.d.ts +14 -0
- package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -0
- package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +40 -0
- package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +1 -0
- package/dist/components/ui/markdown/rich/shortcodes.d.ts +8 -0
- package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -0
- package/dist/components/ui/markdown/sanitize.d.ts +153 -0
- package/dist/components/ui/markdown/sanitize.d.ts.map +1 -0
- package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +23 -0
- package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +1 -0
- package/dist/components/ui/markdown/streaming.d.ts +78 -0
- package/dist/components/ui/markdown/streaming.d.ts.map +1 -0
- package/dist/components/ui/markdown/text-size.d.ts +34 -0
- package/dist/components/ui/markdown/text-size.d.ts.map +1 -0
- package/dist/index.cjs +60 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +71 -13
- package/dist/utils/index.cjs +169 -40
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +162 -41
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/markdown-fences.d.ts +42 -0
- package/dist/utils/markdown-fences.d.ts.map +1 -0
- package/dist/utils/markdown-heading-id.d.ts +85 -0
- package/dist/utils/markdown-heading-id.d.ts.map +1 -0
- package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
- package/package.json +7 -1
- package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +319 -0
- package/src/chat-protocol/__tests__/chat-protocol.test.ts +516 -0
- package/src/chat-protocol/__tests__/env-flag.test.ts +52 -0
- package/src/chat-protocol/__tests__/ip-normalize.test.ts +136 -0
- package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +237 -0
- package/src/chat-protocol/decode.ts +278 -0
- package/src/chat-protocol/encode.ts +71 -0
- package/src/chat-protocol/env-flag.ts +39 -0
- package/src/chat-protocol/events.ts +254 -0
- package/src/chat-protocol/frames.ts +245 -0
- package/src/chat-protocol/index.ts +21 -0
- package/src/chat-protocol/ip-normalize.ts +146 -0
- package/src/chat-protocol/nats-decoder.ts +264 -0
- package/src/components/chat/.chat-message-list.md +10 -6
- package/src/components/chat/__tests__/chat-message-list.test.tsx +450 -9
- package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +207 -0
- package/src/components/chat/__tests__/chat-pending-turn.test.tsx +250 -0
- package/src/components/chat/chat-message-enhanced.tsx +122 -20
- package/src/components/chat/chat-message-list.tsx +515 -133
- package/src/components/chat/embeddable-chat.tsx +6 -0
- package/src/components/chat/hooks/.index.md +30 -33
- package/src/components/chat/hooks/.use-nats-chat-adapter.md +36 -55
- package/src/components/chat/hooks/__tests__/__snapshots__/conversation-id-persistence-golden.test.ts.snap +37 -0
- package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
- package/src/components/chat/hooks/__tests__/chunk-catchup-dialog-staleness.test.ts +92 -0
- package/src/components/chat/hooks/__tests__/conversation-id-persistence-golden.test.ts +438 -0
- package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +318 -0
- package/src/components/chat/hooks/index.ts +0 -1
- package/src/components/chat/hooks/use-chat.ts +8 -0
- package/src/components/chat/hooks/use-chunk-catchup.ts +26 -1
- package/src/components/chat/hooks/use-nats-chat-adapter.ts +254 -825
- package/src/components/chat/hooks/use-sse-chat-adapter.ts +380 -684
- package/src/components/chat/index.ts +5 -0
- package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +944 -0
- package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +806 -0
- package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +467 -0
- package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +1330 -0
- package/src/components/chat/stream/__tests__/delta-batcher.test.ts +255 -0
- package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +105 -0
- package/src/components/chat/stream/chat-dialog-store.ts +536 -0
- package/src/components/chat/stream/chat-stream-reducer.ts +1928 -0
- package/src/components/chat/stream/delta-batcher.ts +168 -0
- package/src/components/chat/stream/index.ts +55 -0
- package/src/components/chat/stream/message-mutations.ts +340 -0
- package/src/components/chat/stream/use-chat-stream-reducer.ts +126 -0
- package/src/components/chat/thinking-display.tsx +1 -1
- package/src/components/chat/types/.api.types.md +39 -49
- package/src/components/chat/types/.processing.types.md +29 -58
- package/src/components/chat/types/.unified-chat-state.types.md +2 -2
- package/src/components/chat/types/api.types.ts +6 -73
- package/src/components/chat/types/processing.types.ts +11 -53
- package/src/components/chat/types/unified-chat-state.types.ts +16 -0
- package/src/components/chat/utils/.extract-incomplete-message-state.md +21 -3
- package/src/components/chat/utils/.history-merge.md +44 -3
- package/src/components/chat/utils/.index.md +31 -40
- package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +420 -0
- package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +605 -0
- package/src/components/chat/utils/__tests__/extract-incomplete-message-state.test.ts +106 -0
- package/src/components/chat/utils/__tests__/history-merge.test.ts +160 -0
- package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +61 -0
- package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +394 -0
- package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +270 -0
- package/src/components/chat/utils/extract-incomplete-message-state.ts +93 -6
- package/src/components/chat/utils/history-merge.ts +85 -5
- package/src/components/chat/utils/index.ts +5 -8
- package/src/components/chat/utils/process-historical-messages.ts +361 -385
- package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +22 -4
- package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
- package/src/components/shared/product-release/release-detail-page.tsx +19 -4
- package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +2420 -0
- package/src/components/ui/__tests__/markdown-parity.test.tsx +2540 -0
- package/src/components/ui/index.ts +1 -2
- package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +176 -0
- package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +151 -0
- package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +354 -0
- package/src/components/ui/markdown/__tests__/sanitize-render.test.tsx +118 -0
- package/src/components/ui/markdown/__tests__/streaming.test.tsx +436 -0
- package/src/components/ui/markdown/base-components.tsx +360 -0
- package/src/components/ui/markdown/engine.tsx +315 -0
- package/src/components/ui/markdown/heading-ids.ts +239 -0
- package/src/components/ui/markdown/index.ts +49 -0
- package/src/components/ui/markdown/mermaid-diagram.tsx +323 -0
- package/src/components/ui/markdown/rich/embed-overrides.tsx +199 -0
- package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +184 -0
- package/src/components/ui/markdown/rich/shortcodes.ts +170 -0
- package/src/components/ui/markdown/sanitize.ts +3584 -0
- package/src/components/ui/markdown/simple-markdown-renderer.tsx +28 -0
- package/src/components/ui/markdown/streaming.ts +390 -0
- package/src/components/ui/markdown/text-size.ts +106 -0
- package/src/components/ui/release-changelog-section.tsx +1 -1
- package/src/components/ui/ticket-info-section.tsx +1 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/markdown-fences.ts +127 -0
- package/src/utils/markdown-heading-id.ts +348 -0
- package/src/utils/markdown-section-extractor.ts +41 -56
- package/dist/chunk-OCEKO5CW.js.map +0 -1
- package/dist/chunk-QTYZMP6D.cjs.map +0 -1
- package/dist/chunk-TLMJHMXJ.js.map +0 -1
- package/dist/chunk-TWYNR4TZ.cjs.map +0 -1
- package/dist/chunk-WA7RR64F.js.map +0 -1
- package/dist/chunk-WK4N5VBX.cjs.map +0 -1
- package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +0 -6
- package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +0 -1
- package/dist/components/chat/utils/chunk-parser.d.ts +0 -25
- package/dist/components/chat/utils/chunk-parser.d.ts.map +0 -1
- package/dist/components/ui/rich-markdown-renderer.d.ts +0 -34
- package/dist/components/ui/rich-markdown-renderer.d.ts.map +0 -1
- package/dist/components/ui/simple-markdown-renderer.d.ts +0 -73
- package/dist/components/ui/simple-markdown-renderer.d.ts.map +0 -1
- package/src/components/chat/hooks/.use-realtime-chunk-processor.md +0 -77
- package/src/components/chat/hooks/use-realtime-chunk-processor.ts +0 -489
- package/src/components/chat/utils/.chunk-parser.md +0 -62
- package/src/components/chat/utils/chunk-parser.ts +0 -262
- package/src/components/ui/.simple-markdown-renderer.md +0 -52
- package/src/components/ui/rich-markdown-renderer.tsx +0 -1223
- package/src/components/ui/simple-markdown-renderer.tsx +0 -964
- /package/dist/{chunk-ADPMHWOE.js.map → chunk-72XAID7Y.js.map} +0 -0
- /package/dist/{chunk-PAGKRNWK.js.map → chunk-7WZHBQ4J.js.map} +0 -0
- /package/dist/{chunk-TSZHM74B.js.map → chunk-DC2TKS7C.js.map} +0 -0
- /package/dist/{chunk-I64ABCDX.js.map → chunk-I2B6X77L.js.map} +0 -0
- /package/dist/{chunk-DTYRYB2N.js.map → chunk-K6QTZ7CZ.js.map} +0 -0
- /package/dist/{chunk-4LTMXDUS.js.map → chunk-KBN7PMFT.js.map} +0 -0
- /package/dist/{chunk-TBMV7I5N.js.map → chunk-KXZO2WZX.js.map} +0 -0
- /package/dist/{chunk-KEBYLU3U.js.map → chunk-LFUPI7VX.js.map} +0 -0
- /package/dist/{chunk-WJQHJD7J.js.map → chunk-TWSKTJHW.js.map} +0 -0
- /package/dist/{chunk-TZRUCD56.js.map → chunk-V3ZAAFE3.js.map} +0 -0
- /package/dist/{chunk-LRGHJPET.js.map → chunk-XGIFOHKE.js.map} +0 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heading-id derivation for the unified markdown engine.
|
|
3
|
+
*
|
|
4
|
+
* PURE BY CONSTRUCTION. The previous design was a stateful generator whose
|
|
5
|
+
* per-pass counters were reset in the engine's render body, and it was
|
|
6
|
+
* unsound in two independent ways:
|
|
7
|
+
* - memoized streaming blocks do NOT re-render on the pass that resets the
|
|
8
|
+
* counter, so mid-stream a document with two `## Setup` headings emitted
|
|
9
|
+
* `id="setup"` TWICE (block 0 kept its id; the re-rendered tail
|
|
10
|
+
* re-derived from an empty counter) — invalid HTML, and `#setup`
|
|
11
|
+
* resolved to the wrong node for the rest of the stream;
|
|
12
|
+
* - React StrictMode double-invokes render, and heading renderers are
|
|
13
|
+
* element types re-rendered independently of the parent that did the
|
|
14
|
+
* reset, so every id gained a `-2` suffix in dev and extractor↔renderer
|
|
15
|
+
* parity broke.
|
|
16
|
+
*
|
|
17
|
+
* Instead the ids are computed ONCE from the processed markdown source, in a
|
|
18
|
+
* `useMemo`, into a `line → id` map. The heading renderer is a pure lookup
|
|
19
|
+
* by `node.position.start.line`; nothing is derived from render order, so
|
|
20
|
+
* memo bails and StrictMode double-invocation are both no-ops.
|
|
21
|
+
*
|
|
22
|
+
* The slug algorithm itself lives in the server-safe SSOT
|
|
23
|
+
* `utils/markdown-heading-id` (shared with `utils/markdown-section-extractor`,
|
|
24
|
+
* the producer of `sectionIds`); the title normalization mirrors the
|
|
25
|
+
* extractor's `stripInlineMarkdown` so `## **Setup**` slugs identically on
|
|
26
|
+
* both sides.
|
|
27
|
+
*/
|
|
28
|
+
import { createContext, useContext, useMemo, useRef } from 'react'
|
|
29
|
+
import {
|
|
30
|
+
createHeadingIdDeduper,
|
|
31
|
+
scanHeadings,
|
|
32
|
+
stripHeadingEmojis,
|
|
33
|
+
slugifyHeadingText,
|
|
34
|
+
} from '../../../utils/markdown-heading-id'
|
|
35
|
+
import { stripInlineMarkdown } from '../../../utils/markdown-to-plain'
|
|
36
|
+
|
|
37
|
+
export interface HeadingSection {
|
|
38
|
+
id: string
|
|
39
|
+
title: string
|
|
40
|
+
level: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `line (1-based, document-wide) → heading id`. */
|
|
44
|
+
export type HeadingIdMap = ReadonlyMap<number, string>
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Heading levels the section extractor sees (`extractSections`'s default
|
|
48
|
+
* `maxLevel`). The `section-N` fallback ordinal counts only these, so
|
|
49
|
+
* renderer and extractor agree on the fallback slug for symbol-only
|
|
50
|
+
* headings (see the fallback comment below).
|
|
51
|
+
*/
|
|
52
|
+
const EXTRACTOR_MAX_LEVEL = 2
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Line offset added to unit-relative hast positions. Non-zero only on the
|
|
56
|
+
* streaming path, where each atomic unit is parsed by its own
|
|
57
|
+
* `ReactMarkdown` (see `StreamingBlock.startLine`).
|
|
58
|
+
*/
|
|
59
|
+
export const HeadingLineOffsetContext = createContext(0)
|
|
60
|
+
|
|
61
|
+
const EMPTY_HEADING_IDS: HeadingIdMap = new Map()
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The document's heading-id map, delivered by CONTEXT rather than baked into
|
|
65
|
+
* the react-markdown `components` map on purpose: the map is rebuilt on every
|
|
66
|
+
* streamed token (its input is the growing `processedContent`), so listing it
|
|
67
|
+
* as a `components` memo dep would change that map's identity each token and
|
|
68
|
+
* make every completed streaming block's `memo` bail — the exact regression
|
|
69
|
+
* `NO_BROKEN_LINKS` was introduced to fix. Through context only the heading
|
|
70
|
+
* renderers re-render.
|
|
71
|
+
*/
|
|
72
|
+
export const HeadingIdMapContext = createContext<HeadingIdMap>(EMPTY_HEADING_IDS)
|
|
73
|
+
|
|
74
|
+
/** Look up the pre-computed id for a heading hast node. Pure. */
|
|
75
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
76
|
+
export function useHeadingId(node: any): string | undefined {
|
|
77
|
+
const map = useContext(HeadingIdMapContext)
|
|
78
|
+
const offset = useContext(HeadingLineOffsetContext)
|
|
79
|
+
const line = node?.position?.start?.line
|
|
80
|
+
return typeof line === 'number' ? map.get(line + offset) : undefined
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the document's `line → id` map. Pure: same content + same
|
|
85
|
+
* `sectionIds` always yields the same map.
|
|
86
|
+
*/
|
|
87
|
+
export function buildHeadingIdMap(
|
|
88
|
+
content: string,
|
|
89
|
+
sectionIds?: HeadingSection[],
|
|
90
|
+
): HeadingIdMap {
|
|
91
|
+
const sectionIdMap = new Map<string, string>()
|
|
92
|
+
if (sectionIds) {
|
|
93
|
+
for (const section of sectionIds) {
|
|
94
|
+
sectionIdMap.set(section.title, section.id)
|
|
95
|
+
sectionIdMap.set(section.title.toLowerCase(), section.id)
|
|
96
|
+
sectionIdMap.set(stripHeadingEmojis(section.title).toLowerCase(), section.id)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Deduper AND scanner are the shared SSOT primitives the extractor also
|
|
101
|
+
// instantiates — see utils/markdown-heading-id.ts.
|
|
102
|
+
const dedupe = createHeadingIdDeduper()
|
|
103
|
+
|
|
104
|
+
const map = new Map<number, string>()
|
|
105
|
+
let sectionOrdinal = 0
|
|
106
|
+
let headingOrdinal = 0
|
|
107
|
+
|
|
108
|
+
for (const { line, level, text: rawText } of scanHeadings(content)) {
|
|
109
|
+
// Match what `extractText(children)` yields from the RENDERED inline
|
|
110
|
+
// nodes: emphasis/code/link syntax is markup, not text. (The extractor
|
|
111
|
+
// applies the identical strip via `stripFormattingMarkers`.)
|
|
112
|
+
const text = stripInlineMarkdown(rawText).trim()
|
|
113
|
+
headingOrdinal++
|
|
114
|
+
if (level <= EXTRACTOR_MAX_LEVEL) sectionOrdinal++
|
|
115
|
+
|
|
116
|
+
// Backend-provided ids win for H1/H2 (deep-link anchors). They go
|
|
117
|
+
// through the SAME dedup rather than trusting the backend to own
|
|
118
|
+
// uniqueness: `sectionIdMap` is keyed by heading TITLE, so two identical
|
|
119
|
+
// `## Setup` headings look up the same entry and would emit duplicate
|
|
120
|
+
// DOM ids. The dedup makes the second one `setup-2`, exactly matching
|
|
121
|
+
// what `extractSections` produces for the same document.
|
|
122
|
+
let assigned: string | undefined
|
|
123
|
+
if (sectionIds && level <= EXTRACTOR_MAX_LEVEL) {
|
|
124
|
+
for (const variation of [
|
|
125
|
+
text,
|
|
126
|
+
text.toLowerCase(),
|
|
127
|
+
stripHeadingEmojis(text),
|
|
128
|
+
stripHeadingEmojis(text).toLowerCase(),
|
|
129
|
+
]) {
|
|
130
|
+
const id = sectionIdMap.get(variation)
|
|
131
|
+
if (id) {
|
|
132
|
+
assigned = dedupe(id)
|
|
133
|
+
break
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (assigned === undefined) {
|
|
139
|
+
const baseId = slugifyHeadingText(text)
|
|
140
|
+
// Symbol-only heading (slug collapses to ''). `extractSections` falls
|
|
141
|
+
// back to `section-${sections.length + 1}` — its ordinal among the
|
|
142
|
+
// headings it SEES, i.e. levels ≤ maxLevel. Mirror that exactly for
|
|
143
|
+
// H1/H2 so extractor↔renderer slugs stay in total agreement; deeper
|
|
144
|
+
// headings, which the extractor never emits, get their own
|
|
145
|
+
// `heading-N` namespace so they can never collide with a `section-N`.
|
|
146
|
+
assigned = dedupe(
|
|
147
|
+
baseId ||
|
|
148
|
+
(level <= EXTRACTOR_MAX_LEVEL
|
|
149
|
+
? `section-${sectionOrdinal}`
|
|
150
|
+
: `heading-${headingOrdinal}`),
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
map.set(line, assigned)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return map
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** True when two id maps have identical entries (order-insensitive). */
|
|
161
|
+
function sameHeadingIdMap(a: HeadingIdMap, b: HeadingIdMap): boolean {
|
|
162
|
+
if (a === b) return true
|
|
163
|
+
if (a.size !== b.size) return false
|
|
164
|
+
for (const [line, id] of a) {
|
|
165
|
+
if (b.get(line) !== id) return false
|
|
166
|
+
}
|
|
167
|
+
return true
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Memoized `buildHeadingIdMap` for the engine, with the map's IDENTITY held
|
|
172
|
+
* stable across content changes that don't touch any heading.
|
|
173
|
+
*
|
|
174
|
+
* `content` changes on every streamed token, so the `useMemo` alone rebuilt
|
|
175
|
+
* a fresh `Map` per token; that map is the context value, so EVERY heading
|
|
176
|
+
* consumer in every already-completed block re-rendered on every token —
|
|
177
|
+
* the same identity-churn class `NO_BROKEN_LINKS` exists to prevent, just
|
|
178
|
+
* one layer down. Rebuilding is cheap; re-rendering the consumers is not, so
|
|
179
|
+
* the newly built map is compared to the previous one and the previous
|
|
180
|
+
* object is returned when the entries match.
|
|
181
|
+
*/
|
|
182
|
+
export function useHeadingIdMap(
|
|
183
|
+
content: string,
|
|
184
|
+
sectionIds?: HeadingSection[],
|
|
185
|
+
): HeadingIdMap {
|
|
186
|
+
const previous = useRef<HeadingIdMap | null>(null)
|
|
187
|
+
return useMemo(() => {
|
|
188
|
+
const next = buildHeadingIdMap(content, sectionIds)
|
|
189
|
+
if (previous.current && sameHeadingIdMap(previous.current, next)) return previous.current
|
|
190
|
+
previous.current = next
|
|
191
|
+
return next
|
|
192
|
+
}, [content, sectionIds])
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Fallback id for a heading the source scan could NOT see — in practice only
|
|
197
|
+
* a heading synthesized by a caller remark/rehype plugin, which carries no
|
|
198
|
+
* source position. (Setext, blockquote/list-nested ATX and multiple raw
|
|
199
|
+
* `<hN>`s on one line are all scanned now, so they no longer land here.)
|
|
200
|
+
*
|
|
201
|
+
* Routed through the SAME dedupe shape as the map so a fallback id can never
|
|
202
|
+
* collide with an assigned one. It stays PURE — `taken` is derived from the
|
|
203
|
+
* map, not from render order — which is why two position-less headings with
|
|
204
|
+
* IDENTICAL text still collide with each other: disambiguating those would
|
|
205
|
+
* require the shared mutable counter this module deleted.
|
|
206
|
+
*/
|
|
207
|
+
export function resolveFallbackHeadingId(base: string, taken: ReadonlySet<string>): string {
|
|
208
|
+
if (!base || !taken.has(base)) return base
|
|
209
|
+
for (let n = 2; ; n++) {
|
|
210
|
+
const candidate = `${base}-${n}`
|
|
211
|
+
if (!taken.has(candidate)) return candidate
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Cached `Set` of a map's assigned ids (one per map identity). */
|
|
216
|
+
const TAKEN_IDS_CACHE = new WeakMap<object, ReadonlySet<string>>()
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The ids already assigned by the document's heading-id map. Cached per map
|
|
220
|
+
* IDENTITY, which `useHeadingIdMap` now keeps stable across heading-free
|
|
221
|
+
* token growth — so this is O(headings) once, not per heading per token.
|
|
222
|
+
*/
|
|
223
|
+
export function useAssignedHeadingIds(): ReadonlySet<string> {
|
|
224
|
+
const map = useContext(HeadingIdMapContext)
|
|
225
|
+
const cached = TAKEN_IDS_CACHE.get(map as object)
|
|
226
|
+
if (cached) return cached
|
|
227
|
+
const set = new Set(map.values())
|
|
228
|
+
TAKEN_IDS_CACHE.set(map as object, set)
|
|
229
|
+
return set
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Extract plain text from React children (headings receive mixed nodes). */
|
|
233
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
234
|
+
export function extractText(node: any): string {
|
|
235
|
+
if (typeof node === 'string') return node
|
|
236
|
+
if (Array.isArray(node)) return node.map(extractText).join('')
|
|
237
|
+
if (node?.props?.children) return extractText(node.props.children)
|
|
238
|
+
return ''
|
|
239
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified markdown module — public surface.
|
|
3
|
+
*
|
|
4
|
+
* ONE engine (`MarkdownEngine`), two named compositions:
|
|
5
|
+
* - `SimpleMarkdownRenderer` — chat/doc surfaces (embed-free bundle)
|
|
6
|
+
* - `RichMarkdownRenderer` — authored-content surfaces (shortcodes,
|
|
7
|
+
* social embeds, Video SSOT, OG previews)
|
|
8
|
+
*/
|
|
9
|
+
export { MarkdownEngine, type MarkdownEngineProps } from './engine';
|
|
10
|
+
export {
|
|
11
|
+
SimpleMarkdownRenderer,
|
|
12
|
+
type SimpleMarkdownRendererProps,
|
|
13
|
+
type ResolveLinkResult,
|
|
14
|
+
} from './simple-markdown-renderer';
|
|
15
|
+
export {
|
|
16
|
+
RichMarkdownRenderer,
|
|
17
|
+
type RichMarkdownRendererProps,
|
|
18
|
+
} from './rich/rich-markdown-renderer';
|
|
19
|
+
export { processShortcodes } from './rich/shortcodes';
|
|
20
|
+
export {
|
|
21
|
+
SAFE_HTML_TAGS,
|
|
22
|
+
SVG_TAGS,
|
|
23
|
+
buildEffectiveTagSet,
|
|
24
|
+
buildSanitizeSchema,
|
|
25
|
+
cardAwareUrlTransform,
|
|
26
|
+
escapeUnknownHtmlTags,
|
|
27
|
+
rehypeStripUnsafe,
|
|
28
|
+
} from './sanitize';
|
|
29
|
+
export {
|
|
30
|
+
TEXT_SIZE_PRESETS,
|
|
31
|
+
resolveTextSizeConfig,
|
|
32
|
+
type TextSizeConfig,
|
|
33
|
+
type TextSizeClassMap,
|
|
34
|
+
type TextSizeElement,
|
|
35
|
+
type TextSizePreset,
|
|
36
|
+
} from './text-size';
|
|
37
|
+
export { MermaidDiagram } from './mermaid-diagram';
|
|
38
|
+
export {
|
|
39
|
+
buildHeadingIdMap,
|
|
40
|
+
useHeadingIdMap,
|
|
41
|
+
extractText,
|
|
42
|
+
type HeadingSection,
|
|
43
|
+
type HeadingIdMap,
|
|
44
|
+
} from './heading-ids';
|
|
45
|
+
// NOT exported: `mermaidStyles` (single-consumer, owned by MermaidDiagram)
|
|
46
|
+
// and `buildStandardLeafRenderers` (an internal SSOT — exporting it invites
|
|
47
|
+
// exactly the renderer-map fork this module exists to prevent; the rich
|
|
48
|
+
// composition imports it directly from ./base-components).
|
|
49
|
+
export { splitStreamingBlocks, completeStreamingTail, type StreamingBlock } from './streaming';
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Single MermaidDiagram for the unified markdown engine (dark theme only —
|
|
5
|
+
* the old RichMarkdownRenderer's light-theme branch was dead code behind a
|
|
6
|
+
* hardcoded `isDarkMode = true` and is deleted, not carried over).
|
|
7
|
+
*
|
|
8
|
+
* `mermaid` stays a dynamic import so neither chat nor content bundles pay
|
|
9
|
+
* for it unless a diagram is actually rendered.
|
|
10
|
+
*/
|
|
11
|
+
import React, { useEffect, useState } from 'react';
|
|
12
|
+
import type { MermaidConfig } from 'mermaid';
|
|
13
|
+
import { AlertCircleIcon } from '../../icons-v2-generated';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* SECURITY SSOT for the mermaid renderer — the ONLY place these knobs are
|
|
17
|
+
* written. `./__tests__/mermaid-security.test.ts` imports THIS constant and
|
|
18
|
+
* spreads it into its own `mermaid.initialize`, so the fixture and the
|
|
19
|
+
* component can no longer drift: flipping `securityLevel` to `'loose'` here
|
|
20
|
+
* fails the suite (verified by doing exactly that).
|
|
21
|
+
*
|
|
22
|
+
* This renderer sits on the CHAT path, so the diagram source is MODEL output
|
|
23
|
+
* — untrusted by construction — and the rendered SVG goes through
|
|
24
|
+
* `dangerouslySetInnerHTML`. The pre-unification renderer used `'loose'`,
|
|
25
|
+
* which permits raw HTML inside labels and enables mermaid's `click`
|
|
26
|
+
* interaction directive: a node label like `A["<img src=x onerror=...>"]`
|
|
27
|
+
* would have produced LIVE HTML.
|
|
28
|
+
*
|
|
29
|
+
* - `securityLevel: 'strict'` encodes HTML tags in text and disables click
|
|
30
|
+
* handlers.
|
|
31
|
+
* - `htmlLabels: false` renders labels as SVG `<text>` instead of a
|
|
32
|
+
* foreignObject HTML subtree, so there is no HTML surface at all. Verified:
|
|
33
|
+
* no authored diagram in this repo or in the consuming hub's markdown uses
|
|
34
|
+
* HTML labels (not even `<br/>`), so 'antiscript' (which still allows tags)
|
|
35
|
+
* is not needed. (`htmlLabels` is set at the ROOT — `flowchart.htmlLabels`
|
|
36
|
+
* is deprecated in mermaid 11 and the root value takes precedence.)
|
|
37
|
+
* - `secure` is the allowlist of config keys a `%%{init}%%` directive in the
|
|
38
|
+
* diagram SOURCE may NOT override. mermaid's default list covers
|
|
39
|
+
* `securityLevel` but NOT `htmlLabels`, so model-authored source could
|
|
40
|
+
* otherwise re-enable HTML labels while `securityLevel` stayed locked.
|
|
41
|
+
* Adding `htmlLabels` (plus `secure` itself and the resource limits) closes
|
|
42
|
+
* that hole.
|
|
43
|
+
*
|
|
44
|
+
* jsdom NOTE: with `htmlLabels` unlocked, a `%%{init: {"htmlLabels": true}}%%`
|
|
45
|
+
* directive was observed to make `mermaid.render` never settle under jsdom
|
|
46
|
+
* (>60s, against a passing two-render control). This was NOT reproduced in a
|
|
47
|
+
* real browser and may well be an artifact of jsdom having no layout — do not
|
|
48
|
+
* read it as a confirmed browser DoS. Either way the render below is wrapped
|
|
49
|
+
* in a timeout so a non-settling render surfaces the error state instead of
|
|
50
|
+
* sitting on "Rendering diagram…" forever.
|
|
51
|
+
*/
|
|
52
|
+
export const MERMAID_SECURITY_OPTIONS = {
|
|
53
|
+
htmlLabels: false,
|
|
54
|
+
securityLevel: 'strict',
|
|
55
|
+
secure: [
|
|
56
|
+
'securityLevel',
|
|
57
|
+
'htmlLabels',
|
|
58
|
+
'secure',
|
|
59
|
+
'startOnLoad',
|
|
60
|
+
'maxTextSize',
|
|
61
|
+
'maxEdges',
|
|
62
|
+
// `themeCSS` is RAW CSS that mermaid emits into a `<style>` inside the
|
|
63
|
+
// SVG this component injects with `dangerouslySetInnerHTML`. Locking
|
|
64
|
+
// `securityLevel`/`htmlLabels` closes the HTML surface but left CSS wide
|
|
65
|
+
// open, and mermaid's own directive sanitizer does not help: `themeCSS`
|
|
66
|
+
// is a normal config key, and `sanitizeCss` only brace-balances it.
|
|
67
|
+
// Two demonstrated payloads (verified against real mermaid 11.14.0):
|
|
68
|
+
// - `%%{init:{"themeCSS":"position:fixed;top:0;left:0;width:100vw;
|
|
69
|
+
// height:100vh;z-index:2147483647;background:#fff"}}%%` — bare
|
|
70
|
+
// declarations land on the `#svgId` selector itself, so the diagram
|
|
71
|
+
// becomes an opaque, top-most, viewport-filling overlay whose visible
|
|
72
|
+
// text the author controls via node labels (UI redress / phishing);
|
|
73
|
+
// - `themeCSS: "@font-face{src:url(https://evil.example/f.woff)}"` —
|
|
74
|
+
// at-rules escape the `#svgId` prefix entirely and land
|
|
75
|
+
// document-global, i.e. an unconditional outbound request to an
|
|
76
|
+
// attacker host on every render.
|
|
77
|
+
// The diagram source on the chat path is MODEL output, so both are
|
|
78
|
+
// reachable from untrusted input. Nothing in this repo authors `themeCSS`.
|
|
79
|
+
'themeCSS',
|
|
80
|
+
],
|
|
81
|
+
} satisfies Pick<MermaidConfig, 'htmlLabels' | 'securityLevel' | 'secure'>;
|
|
82
|
+
|
|
83
|
+
/** Upper bound on a single `mermaid.render`. Generous enough that no honest
|
|
84
|
+
* diagram hits it; short enough that a wedged render becomes a visible error
|
|
85
|
+
* instead of a permanent skeleton. */
|
|
86
|
+
export const MERMAID_RENDER_TIMEOUT_MS = 15_000;
|
|
87
|
+
|
|
88
|
+
/** `Promise.race` with a rejecting timer, timer always cleared. Kept local —
|
|
89
|
+
* the only caller is the render below. */
|
|
90
|
+
async function withRenderTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
91
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
92
|
+
try {
|
|
93
|
+
return await Promise.race([
|
|
94
|
+
promise,
|
|
95
|
+
new Promise<never>((_resolve, reject) => {
|
|
96
|
+
timer = setTimeout(() => reject(new Error(`Diagram rendering timed out after ${ms}ms`)), ms);
|
|
97
|
+
}),
|
|
98
|
+
]);
|
|
99
|
+
} finally {
|
|
100
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* ODS-TOKENS FLAG (ODS_TOKEN_RULES §Colors / §Typography / §General):
|
|
106
|
+
* `mermaidStyles` below and the `themeVariables` / `fontFamily` / `fontSize`
|
|
107
|
+
* block in `MermaidDiagram` carry RAW hex colors, a literal font-family and
|
|
108
|
+
* literal px font sizes.
|
|
109
|
+
*
|
|
110
|
+
* Reason: mermaid's configuration is not CSS — `themeVariables` is a
|
|
111
|
+
* JavaScript API whose values are baked into the generated SVG as literal
|
|
112
|
+
* attribute strings, so a `var(--color-…)` reference resolves to nothing
|
|
113
|
+
* there. On top of that, ODS has NO 10-step categorical color ramp, and
|
|
114
|
+
* mermaid's `cScale0…cScale9` requires exactly one. The palette is carried
|
|
115
|
+
* over VERBATIM from the pre-unification RichMarkdownRenderer, so this is
|
|
116
|
+
* parity, not new divergence; the missing categorical-ramp tokens are
|
|
117
|
+
* flagged here for addition to ODS.
|
|
118
|
+
*
|
|
119
|
+
* Scope: this exemption covers THIS file only, and only the mermaid config
|
|
120
|
+
* surface. Do NOT copy this pattern — every other style in the markdown
|
|
121
|
+
* module uses ODS semantic classes.
|
|
122
|
+
*/
|
|
123
|
+
export const mermaidStyles = `
|
|
124
|
+
.mermaid-svg-container svg {
|
|
125
|
+
max-width: 100% !important;
|
|
126
|
+
height: auto !important;
|
|
127
|
+
min-height: 200px;
|
|
128
|
+
font-family: 'DM Sans', sans-serif !important;
|
|
129
|
+
font-size: 14px !important;
|
|
130
|
+
}
|
|
131
|
+
@media (min-width: 1520px) {
|
|
132
|
+
.mermaid-svg-container svg {
|
|
133
|
+
max-width: 900px !important;
|
|
134
|
+
max-height: 700px !important;
|
|
135
|
+
min-height: 300px;
|
|
136
|
+
font-size: 16px !important;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
@media (min-width: 768px) and (max-width: 1519px) {
|
|
140
|
+
.mermaid-svg-container svg {
|
|
141
|
+
max-width: 700px !important;
|
|
142
|
+
max-height: 600px !important;
|
|
143
|
+
min-height: 250px;
|
|
144
|
+
font-size: 15px !important;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
@media (max-width: 767px) {
|
|
148
|
+
.mermaid-svg-container svg {
|
|
149
|
+
max-width: 90vw !important;
|
|
150
|
+
max-height: 400px !important;
|
|
151
|
+
min-height: 200px;
|
|
152
|
+
font-size: 13px !important;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
.mermaid-svg-container svg[width] { width: 100% !important; }
|
|
156
|
+
.mermaid-svg-container .node rect,
|
|
157
|
+
.mermaid-svg-container .node circle,
|
|
158
|
+
.mermaid-svg-container .node ellipse,
|
|
159
|
+
.mermaid-svg-container .node polygon { stroke-width: 2px !important; }
|
|
160
|
+
.mermaid-svg-container .edgePath path { stroke-width: 2px !important; }
|
|
161
|
+
@media (min-width: 768px) {
|
|
162
|
+
.mermaid-svg-container .node text,
|
|
163
|
+
.mermaid-svg-container .edgeLabel text { font-size: 14px !important; }
|
|
164
|
+
}
|
|
165
|
+
@media (min-width: 1520px) {
|
|
166
|
+
.mermaid-svg-container .node text,
|
|
167
|
+
.mermaid-svg-container .edgeLabel text { font-size: 16px !important; }
|
|
168
|
+
}
|
|
169
|
+
`;
|
|
170
|
+
|
|
171
|
+
/** Monotonic render id. `Date.now()` was ambiguous: two renders started in the
|
|
172
|
+
* same millisecond (routine while a diagram streams in) share an id, and
|
|
173
|
+
* mermaid removes any pre-existing element with that id at the start of a
|
|
174
|
+
* render — so one render would delete the other's working container. */
|
|
175
|
+
let mermaidRenderSeq = 0;
|
|
176
|
+
|
|
177
|
+
export const MermaidDiagram: React.FC<{ chart: string }> = ({ chart }) => {
|
|
178
|
+
const [svg, setSvg] = useState<string>('');
|
|
179
|
+
const [error, setError] = useState<string>('');
|
|
180
|
+
const [isLoading, setIsLoading] = useState<boolean>(true);
|
|
181
|
+
const [mounted, setMounted] = useState(false);
|
|
182
|
+
|
|
183
|
+
useEffect(() => { setMounted(true); }, []);
|
|
184
|
+
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
// This effect re-runs on every `chart` change, and during STREAMING the
|
|
187
|
+
// chart text grows chunk by chunk — so several `mermaid.render` calls are
|
|
188
|
+
// in flight at once. Without this flag a slower EARLIER render can resolve
|
|
189
|
+
// last and overwrite the newer output (or paint an error for a chart that
|
|
190
|
+
// is no longer displayed). The timeout above bounds that window at 15s but
|
|
191
|
+
// cannot close it: `Promise.race` rejects, it does not abort the render.
|
|
192
|
+
let cancelled = false;
|
|
193
|
+
const renderId = `mermaid-${(mermaidRenderSeq += 1)}`;
|
|
194
|
+
|
|
195
|
+
const renderMermaid = async () => {
|
|
196
|
+
try {
|
|
197
|
+
setIsLoading(true);
|
|
198
|
+
// `mermaid` is a declared `dependencies` entry and ships its own
|
|
199
|
+
// types, so this specifier resolves — the suppression that used to sit
|
|
200
|
+
// here (`@ts-ignore`, justified as "optional runtime dependency") was
|
|
201
|
+
// dead AND factually wrong. It is kept as a DYNAMIC import purely for
|
|
202
|
+
// bundle size: neither the chat nor the content bundle should pay for
|
|
203
|
+
// mermaid unless a diagram actually renders. Import failures surface
|
|
204
|
+
// through the surrounding try/catch as the diagram error state.
|
|
205
|
+
const { default: mermaid } = await import('mermaid');
|
|
206
|
+
|
|
207
|
+
mermaid.initialize({
|
|
208
|
+
startOnLoad: false,
|
|
209
|
+
theme: 'dark' as const,
|
|
210
|
+
themeVariables: {
|
|
211
|
+
primaryColor: '#FFC008',
|
|
212
|
+
primaryTextColor: '#FAFAFA',
|
|
213
|
+
primaryBorderColor: '#3A3A3A',
|
|
214
|
+
lineColor: '#888888',
|
|
215
|
+
secondaryColor: '#212121',
|
|
216
|
+
tertiaryColor: '#2A2A2A',
|
|
217
|
+
background: 'transparent',
|
|
218
|
+
mainBkg: 'transparent',
|
|
219
|
+
secondBkg: 'transparent',
|
|
220
|
+
tertiaryBkg: 'transparent',
|
|
221
|
+
cScale0: '#FFC008', cScale1: '#4ECDC4', cScale2: '#45B7D1',
|
|
222
|
+
cScale3: '#96CEB4', cScale4: '#FFEAA7', cScale5: '#DDA0DD',
|
|
223
|
+
cScale6: '#98D8C8', cScale7: '#F7DC6F', cScale8: '#BB8FCE',
|
|
224
|
+
cScale9: '#85C1E9',
|
|
225
|
+
taskTextColor: '#FAFAFA',
|
|
226
|
+
taskTextOutsideColor: '#FAFAFA',
|
|
227
|
+
activeTaskTextColor: '#1A1A1A',
|
|
228
|
+
nodeTextColor: '#FAFAFA',
|
|
229
|
+
},
|
|
230
|
+
flowchart: { useMaxWidth: true, rankSpacing: 50, nodeSpacing: 30, curve: 'basis' },
|
|
231
|
+
sequence: { useMaxWidth: true, width: 150 },
|
|
232
|
+
pie: { useMaxWidth: true, useWidth: undefined },
|
|
233
|
+
fontFamily: 'DM Sans, sans-serif',
|
|
234
|
+
fontSize: 14,
|
|
235
|
+
// SECURITY: single source of truth, shared with the fixture.
|
|
236
|
+
// See MERMAID_SECURITY_OPTIONS above and
|
|
237
|
+
// ./__tests__/mermaid-security.test.ts.
|
|
238
|
+
...MERMAID_SECURITY_OPTIONS,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const { svg: renderedSvg } = await withRenderTimeout(
|
|
242
|
+
mermaid.render(renderId, chart),
|
|
243
|
+
MERMAID_RENDER_TIMEOUT_MS,
|
|
244
|
+
);
|
|
245
|
+
if (cancelled) return;
|
|
246
|
+
setSvg(renderedSvg);
|
|
247
|
+
// CLEAR the previous failure. The render body checks `error` BEFORE
|
|
248
|
+
// `svg`, so a stale message pins the "Diagram Error" card forever and
|
|
249
|
+
// the diagram that just rendered successfully never appears. Transient
|
|
250
|
+
// failures are ROUTINE on this component's own hot path: every
|
|
251
|
+
// streaming frame re-renders a partial chart, and a partial chart is a
|
|
252
|
+
// mermaid parse error until the closing tokens arrive.
|
|
253
|
+
setError('');
|
|
254
|
+
setIsLoading(false);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
if (cancelled) return;
|
|
257
|
+
console.error('Mermaid rendering error:', err);
|
|
258
|
+
setError(`Failed to render diagram: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
|
259
|
+
setIsLoading(false);
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
if (mounted) { renderMermaid(); }
|
|
264
|
+
return () => {
|
|
265
|
+
cancelled = true;
|
|
266
|
+
// An abandoned render (superseded chart, or one wedged past the timeout)
|
|
267
|
+
// keeps mermaid's temporary `#d<id>` working container attached to
|
|
268
|
+
// <body> forever, since mermaid only removes it on a successful finish.
|
|
269
|
+
// Cheap to reap here because the id is ours. If the render is still live
|
|
270
|
+
// it will fail on the missing node — caught, and discarded by `cancelled`.
|
|
271
|
+
document.getElementById(`d${renderId}`)?.remove();
|
|
272
|
+
document.getElementById(renderId)?.remove();
|
|
273
|
+
};
|
|
274
|
+
}, [chart, mounted]);
|
|
275
|
+
|
|
276
|
+
if (error) {
|
|
277
|
+
return (
|
|
278
|
+
<div className="error-state bg-ods-card border border-ods-border rounded-lg p-6 my-6">
|
|
279
|
+
<div className="error-icon flex justify-center mb-4">
|
|
280
|
+
<AlertCircleIcon className="w-12 h-12 text-ods-error" />
|
|
281
|
+
</div>
|
|
282
|
+
<div className="error-title text-center font-sans font-semibold text-lg text-ods-error mb-2">
|
|
283
|
+
Diagram Error
|
|
284
|
+
</div>
|
|
285
|
+
<div className="error-description text-center font-sans text-sm text-ods-text-secondary mb-4 break-words overflow-hidden max-w-full">
|
|
286
|
+
<div className="overflow-x-auto">
|
|
287
|
+
<pre className="whitespace-pre-wrap break-words text-xs">{error}</pre>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (isLoading || !svg) {
|
|
295
|
+
return (
|
|
296
|
+
<div className="skeleton-code bg-ods-card border border-ods-border rounded-lg p-6 min-h-[120px] flex items-center justify-center">
|
|
297
|
+
<div className="animate-pulse text-ods-text-tertiary font-sans">
|
|
298
|
+
{isLoading ? 'Loading diagram renderer...' : 'Rendering diagram...'}
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return (
|
|
305
|
+
<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">
|
|
306
|
+
{/* Scoped to `.mermaid-svg-container`, so it only needs to exist when a
|
|
307
|
+
diagram is actually mounted. The engine used to emit this once per
|
|
308
|
+
instance — i.e. once per chat segment, almost never with a diagram. */}
|
|
309
|
+
<style dangerouslySetInnerHTML={{ __html: mermaidStyles }} />
|
|
310
|
+
<div className="flex justify-center items-center w-full min-h-[200px] md:min-h-[250px] lg:min-h-[300px]">
|
|
311
|
+
<div
|
|
312
|
+
className="mermaid-svg-container w-full flex justify-center max-w-full"
|
|
313
|
+
style={{ fontSize: '14px' }}
|
|
314
|
+
dangerouslySetInnerHTML={{
|
|
315
|
+
__html: svg.replace(/<svg[^>]*>/, (match) =>
|
|
316
|
+
match.replace(/width="[^"]*"/, 'width="100%"').replace(/height="[^"]*"/, 'height="auto"')
|
|
317
|
+
),
|
|
318
|
+
}}
|
|
319
|
+
/>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
323
|
+
};
|