@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,360 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default react-markdown component map for the unified markdown engine.
|
|
5
|
+
* ONE implementation of code blocks, headings, links, lists, tables etc.
|
|
6
|
+
* Compositions layer on top via `componentOverrides` (spread LAST by the
|
|
7
|
+
* engine, so caller overrides always win).
|
|
8
|
+
*/
|
|
9
|
+
import React from 'react';
|
|
10
|
+
import type { Components } from 'react-markdown';
|
|
11
|
+
import Image from '../../../embed-shims/next-image';
|
|
12
|
+
import { useAuthedImageSrc } from '../../../hooks/use-authed-image-src';
|
|
13
|
+
import { cn } from '../../../utils/cn';
|
|
14
|
+
import type { ResolveLinkResult } from '../../../types/doc-source';
|
|
15
|
+
import type { TextSizeElement } from './text-size';
|
|
16
|
+
import { MermaidDiagram } from './mermaid-diagram';
|
|
17
|
+
import {
|
|
18
|
+
extractText,
|
|
19
|
+
resolveFallbackHeadingId,
|
|
20
|
+
useAssignedHeadingIds,
|
|
21
|
+
useHeadingId,
|
|
22
|
+
} from './heading-ids';
|
|
23
|
+
import { slugifyHeadingText } from '../../../utils/markdown-heading-id';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* True when an image `src` is worth rendering. Shared by the base `img` and
|
|
27
|
+
* the rich composition's `img` / `video` overrides so the empty-`![]()`
|
|
28
|
+
* guard has exactly one definition.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* Inline content image (blog / docs / chat attachments). A standalone
|
|
32
|
+
* component — NOT inline JSX in the `img` renderer — because it must call
|
|
33
|
+
* `useAuthedImageSrc`: in bearer-mode native shells (`capacitor://`,
|
|
34
|
+
* `tauri://`) a gateway-hosted image can't load through a plain `<img>` (no
|
|
35
|
+
* Authorization header on native asset loads), so the hook swaps in an authed
|
|
36
|
+
* blob URL; everywhere else it returns `src` untouched. While the blob fetch
|
|
37
|
+
* is in flight `resolvedSrc` is null and the component renders nothing — same
|
|
38
|
+
* as the no-src case. Ported from #1548 into the unified engine (the original
|
|
39
|
+
* lived in the now-deleted `ui/simple-markdown-renderer.tsx`). Sizing matches
|
|
40
|
+
* the engine's cap: 400x400 intrinsic, `w-auto h-auto max-h-[400px]` drives
|
|
41
|
+
* the actual rendered box; click-to-expand surfaces provide full resolution.
|
|
42
|
+
*/
|
|
43
|
+
const MarkdownContentImage: React.FC<{ src: string; alt?: string }> = ({ src, alt }) => {
|
|
44
|
+
const resolvedSrc = useAuthedImageSrc(src);
|
|
45
|
+
if (!resolvedSrc) return null;
|
|
46
|
+
return (
|
|
47
|
+
<Image
|
|
48
|
+
src={resolvedSrc}
|
|
49
|
+
alt={alt ?? 'No image available'}
|
|
50
|
+
width={400}
|
|
51
|
+
height={400}
|
|
52
|
+
sizes="(max-width: 400px) 100vw, 400px"
|
|
53
|
+
className="max-w-full max-h-[400px] w-auto h-auto rounded-lg object-contain"
|
|
54
|
+
style={{ width: 'auto', height: 'auto' }}
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function hasRenderableSrc(src: unknown): src is string {
|
|
60
|
+
return typeof src === 'string' && src.trim() !== '';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface BuildBaseComponentsOptions {
|
|
64
|
+
textSizes: Record<TextSizeElement, string>;
|
|
65
|
+
demoteMarkdownH1ToH2: boolean;
|
|
66
|
+
brokenLinks: readonly string[];
|
|
67
|
+
currentPath?: string;
|
|
68
|
+
onInternalLinkClick?: (path: string, options?: { expandFolder?: boolean; fromInternalLink?: boolean }) => void;
|
|
69
|
+
onResolveLink?: (href: string, currentPath: string) => Promise<ResolveLinkResult>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The standard leaf renderers (`code` block + inline, `blockquote`, `div`
|
|
74
|
+
* pass-through) as standalone functions.
|
|
75
|
+
*
|
|
76
|
+
* SSOT for compositions that must OVERRIDE these renderers for a narrow
|
|
77
|
+
* special case and then fall through: the rich composition intercepts embed
|
|
78
|
+
* fence languages, shortcode-expanded `div`s and Reddit's `reddit-embed-bq`
|
|
79
|
+
* blockquote, and delegates everything else here. Previously it reproduced
|
|
80
|
+
* these renderers byte-for-byte, so any class change here silently drifted
|
|
81
|
+
* on content surfaces.
|
|
82
|
+
*
|
|
83
|
+
* THESE MUST STAY HOOK-FREE: the rich composition calls them as plain
|
|
84
|
+
* functions from inside its own renderers (`standard.code(props)`), which is
|
|
85
|
+
* not a React render of a component and would break the rules of hooks.
|
|
86
|
+
* Hook-using renderers (the headings, which read the heading-line offset
|
|
87
|
+
* from context) live in `buildBaseComponents` below and are never delegated
|
|
88
|
+
* to this way.
|
|
89
|
+
*
|
|
90
|
+
* ODS-TOKENS FLAG (ODS_TOKEN_RULES §Typography / §General): inline code keeps
|
|
91
|
+
* an inline `text-[0.9em]`, carried over verbatim from the pre-unification
|
|
92
|
+
* renderers — ODS has no relative-to-parent code size, so mapping it onto an
|
|
93
|
+
* existing token would visibly change every inline code span. Same shape as
|
|
94
|
+
* the flag in ./text-size.ts — flagged for addition to ODS, not copied
|
|
95
|
+
* anywhere else.
|
|
96
|
+
*
|
|
97
|
+
* The code BLOCK's font is NOT flagged: the raw "JetBrains Mono", "SF Mono",
|
|
98
|
+
* Consolas stack the old renderer inlined is gone, replaced by Tailwind's
|
|
99
|
+
* `font-mono` (→ `var(--font-family-heading)`, the Azeret Mono ODS stack) —
|
|
100
|
+
* the same class the inline-code branch below already used, so the two code
|
|
101
|
+
* surfaces no longer disagree about their family.
|
|
102
|
+
*/
|
|
103
|
+
export function buildStandardLeafRenderers({
|
|
104
|
+
textSizes,
|
|
105
|
+
}: {
|
|
106
|
+
textSizes: Record<TextSizeElement, string>;
|
|
107
|
+
}): Pick<Components, 'code' | 'blockquote' | 'div'> {
|
|
108
|
+
return {
|
|
109
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
110
|
+
code: ({ node, inline, className: codeClassName, children, ...props }: any) => {
|
|
111
|
+
const match = /language-(\w+)/.exec(codeClassName || '');
|
|
112
|
+
const language = match ? match[1] : '';
|
|
113
|
+
|
|
114
|
+
if (!inline && language === 'mermaid') {
|
|
115
|
+
return <MermaidDiagram chart={String(children).replace(/\n$/, '')} />;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!inline && match) {
|
|
119
|
+
return (
|
|
120
|
+
<div className="code-block-container border rounded-lg my-6 overflow-hidden bg-ods-card border-ods-border">
|
|
121
|
+
<div className="code-header border-b px-4 py-2 bg-ods-card border-ods-border">
|
|
122
|
+
<span className="font-sans text-xs uppercase tracking-wide text-ods-text-tertiary">
|
|
123
|
+
{language || 'code'}
|
|
124
|
+
</span>
|
|
125
|
+
</div>
|
|
126
|
+
<div className="p-4">
|
|
127
|
+
<pre className="overflow-x-auto">
|
|
128
|
+
<code
|
|
129
|
+
className={cn(`language-${language} hljs font-mono`, textSizes.code)}
|
|
130
|
+
style={{
|
|
131
|
+
background: 'transparent',
|
|
132
|
+
color: 'var(--color-text-primary)',
|
|
133
|
+
}}
|
|
134
|
+
{...props}
|
|
135
|
+
>
|
|
136
|
+
{children}
|
|
137
|
+
</code>
|
|
138
|
+
</pre>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<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}>
|
|
146
|
+
{children}
|
|
147
|
+
</code>
|
|
148
|
+
);
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
152
|
+
blockquote: ({ children }: any) => (
|
|
153
|
+
<blockquote className="border-l-4 border-ods-accent ml-0 pl-6 my-8 py-4 rounded-r-lg bg-ods-bg-surface">
|
|
154
|
+
<div className={cn('font-sans leading-relaxed text-ods-text-secondary', textSizes.blockquote)}>
|
|
155
|
+
{children}
|
|
156
|
+
</div>
|
|
157
|
+
</blockquote>
|
|
158
|
+
),
|
|
159
|
+
|
|
160
|
+
// Pass-through `div` (overridable for embeds; `node` is dropped so it
|
|
161
|
+
// never reaches the DOM).
|
|
162
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
163
|
+
div: ({ node, className: divClassName, children, ...props }: any) => (
|
|
164
|
+
<div className={divClassName} {...props}>{children}</div>
|
|
165
|
+
),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function buildBaseComponents({
|
|
170
|
+
textSizes,
|
|
171
|
+
demoteMarkdownH1ToH2,
|
|
172
|
+
brokenLinks,
|
|
173
|
+
currentPath: propCurrentPath,
|
|
174
|
+
onInternalLinkClick,
|
|
175
|
+
onResolveLink,
|
|
176
|
+
}: BuildBaseComponentsOptions): Components {
|
|
177
|
+
const makeHeading = (
|
|
178
|
+
Tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6',
|
|
179
|
+
headingClassName: string,
|
|
180
|
+
) =>
|
|
181
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
182
|
+
({ node, children }: any) => {
|
|
183
|
+
// PURE LOOKUP — no counters, no render-order dependency. The map is
|
|
184
|
+
// built once from the processed source and reaches this renderer via
|
|
185
|
+
// context (see ./heading-ids.ts for why not via this options object).
|
|
186
|
+
const mapped = useHeadingId(node);
|
|
187
|
+
const taken = useAssignedHeadingIds();
|
|
188
|
+
// An AUTHORED anchor wins over everything. The sanitize schema allows
|
|
189
|
+
// `id` on every element (and disables clobbering) precisely so
|
|
190
|
+
// `<h2 id="pricing-faq">` keeps its hand-picked anchor; overwriting it
|
|
191
|
+
// with the slug of its text silently broke every existing deep link
|
|
192
|
+
// pointing at it.
|
|
193
|
+
const explicit =
|
|
194
|
+
typeof node?.properties?.id === 'string' && node.properties.id !== ''
|
|
195
|
+
? node.properties.id
|
|
196
|
+
: undefined;
|
|
197
|
+
// Fallback for headings the source scan cannot see — in practice only
|
|
198
|
+
// caller-plugin-synthesized nodes, which carry no source position.
|
|
199
|
+
// Deduped against the ids the map already assigned (pure — see
|
|
200
|
+
// resolveFallbackHeadingId).
|
|
201
|
+
const id =
|
|
202
|
+
explicit ??
|
|
203
|
+
mapped ??
|
|
204
|
+
resolveFallbackHeadingId(slugifyHeadingText(extractText(children)), taken);
|
|
205
|
+
const EffectiveTag = Tag === 'h1' && demoteMarkdownH1ToH2 ? 'h2' : Tag;
|
|
206
|
+
return <EffectiveTag id={id || undefined} className={headingClassName}>{children}</EffectiveTag>;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
// --- code + blockquote + div (shared leaf renderers, see above) ---
|
|
211
|
+
...buildStandardLeafRenderers({ textSizes }),
|
|
212
|
+
|
|
213
|
+
// --- headings ---
|
|
214
|
+
h1: makeHeading('h1', cn('font-sans font-bold mt-8 mb-4 first:mt-0 text-ods-text-primary', textSizes.h1)),
|
|
215
|
+
h2: makeHeading('h2', cn('font-sans font-semibold mt-8 mb-4 pb-2 border-b text-ods-text-primary border-ods-border', textSizes.h2)),
|
|
216
|
+
h3: makeHeading('h3', cn('font-sans font-semibold mt-6 mb-3 text-ods-text-primary', textSizes.h3)),
|
|
217
|
+
h4: makeHeading('h4', cn('font-sans font-semibold mt-4 mb-2 text-ods-text-primary', textSizes.h4)),
|
|
218
|
+
h5: makeHeading('h5', cn('font-sans font-semibold mt-3 mb-2 text-ods-text-primary', textSizes.h5)),
|
|
219
|
+
h6: makeHeading('h6', cn('font-sans font-semibold mt-3 mb-1 text-ods-text-primary', textSizes.h6)),
|
|
220
|
+
|
|
221
|
+
// --- paragraph ---
|
|
222
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
223
|
+
p: ({ children }: any) => (
|
|
224
|
+
<p className={cn('leading-relaxed mb-4 first:mt-0 last:mb-0 text-ods-text-primary', textSizes.p)}>
|
|
225
|
+
{children}
|
|
226
|
+
</p>
|
|
227
|
+
),
|
|
228
|
+
|
|
229
|
+
// --- links ---
|
|
230
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
231
|
+
a: ({ href, children, className: linkClassName }: any) => {
|
|
232
|
+
const isBroken = brokenLinks.includes(href);
|
|
233
|
+
const isInternalDocLink =
|
|
234
|
+
propCurrentPath !== undefined &&
|
|
235
|
+
propCurrentPath !== null &&
|
|
236
|
+
href &&
|
|
237
|
+
!href.startsWith('http') &&
|
|
238
|
+
!href.startsWith('#');
|
|
239
|
+
|
|
240
|
+
if (isBroken) {
|
|
241
|
+
return (
|
|
242
|
+
<span className="text-ods-accent cursor-not-allowed">
|
|
243
|
+
{children}
|
|
244
|
+
<sup className="ml-1 text-xs font-bold text-ods-error">[BROKEN]</sup>
|
|
245
|
+
</span>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (isInternalDocLink && onInternalLinkClick) {
|
|
250
|
+
const currentPath = propCurrentPath ?? '';
|
|
251
|
+
return (
|
|
252
|
+
<span
|
|
253
|
+
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"
|
|
254
|
+
onClick={async (e) => {
|
|
255
|
+
e.preventDefault();
|
|
256
|
+
e.stopPropagation();
|
|
257
|
+
if (onResolveLink) {
|
|
258
|
+
try {
|
|
259
|
+
const result = await onResolveLink(href, currentPath);
|
|
260
|
+
if (result.type === 'folder-no-readme' && result.action === 'expand_folder') {
|
|
261
|
+
onInternalLinkClick(result.resolvedPath!, { expandFolder: true, fromInternalLink: true });
|
|
262
|
+
} else if (result.type === 'not-found') {
|
|
263
|
+
return;
|
|
264
|
+
} else if (result.success && result.resolvedPath) {
|
|
265
|
+
onInternalLinkClick(result.resolvedPath, { fromInternalLink: true });
|
|
266
|
+
}
|
|
267
|
+
} catch (error) {
|
|
268
|
+
console.error('Error resolving link:', error);
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
onInternalLinkClick(href, { fromInternalLink: true });
|
|
272
|
+
}
|
|
273
|
+
}}
|
|
274
|
+
role="link"
|
|
275
|
+
tabIndex={0}
|
|
276
|
+
onKeyDown={(e) => {
|
|
277
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
278
|
+
(e.currentTarget as HTMLElement).click();
|
|
279
|
+
}
|
|
280
|
+
}}
|
|
281
|
+
>
|
|
282
|
+
{children}
|
|
283
|
+
</span>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return (
|
|
288
|
+
<a
|
|
289
|
+
href={href}
|
|
290
|
+
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 || ''}`}
|
|
291
|
+
target={href?.startsWith('http') ? '_blank' : undefined}
|
|
292
|
+
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
|
|
293
|
+
>
|
|
294
|
+
{children}
|
|
295
|
+
</a>
|
|
296
|
+
);
|
|
297
|
+
},
|
|
298
|
+
|
|
299
|
+
// --- images ---
|
|
300
|
+
// Inline content image renderer (blog, docs, chat attachments) —
|
|
301
|
+
// delegates to `MarkdownContentImage` (above), which handles bearer-mode
|
|
302
|
+
// authed loading for native shells and the 400x400 cap with click-to-
|
|
303
|
+
// expand for full resolution.
|
|
304
|
+
//
|
|
305
|
+
// TODO(security): LLM-rendered surfaces still auto-load ANY image origin,
|
|
306
|
+
// so a prompt-injected `` exfiltrates
|
|
307
|
+
// silently. An image-origin allowlist is a tracked follow-up: it requires
|
|
308
|
+
// a host-supplied origin list threaded from each chat composition (there
|
|
309
|
+
// is no safe default this library can pick), so it is deliberately absent
|
|
310
|
+
// rather than shipped unwired.
|
|
311
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
312
|
+
img: ({ src, alt }: any) => {
|
|
313
|
+
if (!hasRenderableSrc(src)) return null;
|
|
314
|
+
return <MarkdownContentImage src={src} alt={alt} />;
|
|
315
|
+
},
|
|
316
|
+
|
|
317
|
+
// --- lists ---
|
|
318
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
319
|
+
ul: ({ children }: any) => (
|
|
320
|
+
<ul className="list-disc list-outside my-4 ml-8 space-y-2 text-ods-text-primary">{children}</ul>
|
|
321
|
+
),
|
|
322
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
323
|
+
ol: ({ children }: any) => (
|
|
324
|
+
<ol className="list-decimal list-outside my-4 ml-8 space-y-2 text-ods-text-primary">{children}</ol>
|
|
325
|
+
),
|
|
326
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
327
|
+
li: ({ children }: any) => (
|
|
328
|
+
<li className={cn('leading-relaxed pl-2', textSizes.li)}>{children}</li>
|
|
329
|
+
),
|
|
330
|
+
|
|
331
|
+
// --- tables ---
|
|
332
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
333
|
+
table: ({ children }: any) => (
|
|
334
|
+
<div className="table-container my-6 overflow-x-auto">
|
|
335
|
+
<div className="min-w-full border rounded-lg border-ods-border bg-ods-card">
|
|
336
|
+
<table className="w-full table-fixed md:table-auto">{children}</table>
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
),
|
|
340
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
341
|
+
thead: ({ children }: any) => (
|
|
342
|
+
<thead className="bg-ods-bg-surface">{children}</thead>
|
|
343
|
+
),
|
|
344
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
345
|
+
th: ({ children }: any) => (
|
|
346
|
+
<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)}>
|
|
347
|
+
{children}
|
|
348
|
+
</th>
|
|
349
|
+
),
|
|
350
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
351
|
+
td: ({ children }: any) => (
|
|
352
|
+
<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)}>
|
|
353
|
+
{children}
|
|
354
|
+
</td>
|
|
355
|
+
),
|
|
356
|
+
|
|
357
|
+
// --- horizontal rule ---
|
|
358
|
+
hr: () => <hr className="border-0 border-t my-8 border-ods-border" />,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MarkdownEngine — THE one react-markdown pipeline for every surface
|
|
5
|
+
* (chat messages, blog, docs, KB articles, legal, releases, admin previews).
|
|
6
|
+
*
|
|
7
|
+
* `SimpleMarkdownRenderer` and `RichMarkdownRenderer` are thin compositions
|
|
8
|
+
* over this engine (see ./simple-markdown-renderer.tsx and ./rich/) — there
|
|
9
|
+
* is exactly ONE parse path, ONE sanitizer stack, ONE mermaid, ONE
|
|
10
|
+
* heading-id algorithm, ONE link-click model. Do not fork this pipeline;
|
|
11
|
+
* extend via `componentOverrides` / `additionalRemarkPlugins` /
|
|
12
|
+
* `preprocessContent` / `extraAllowedHtmlTags`.
|
|
13
|
+
*
|
|
14
|
+
* Pipeline order (each stage documented in ./sanitize.ts):
|
|
15
|
+
* preprocessContent (composition hook, e.g. shortcodes)
|
|
16
|
+
* → completeStreamingTail (streaming only — MUST precede the escape pass)
|
|
17
|
+
* → escapeUnknownHtmlTags (text pre-pass, React 19 crash guard)
|
|
18
|
+
* → remark: remarkGfm, remarkBreaks, ...additionalRemarkPlugins
|
|
19
|
+
* → rehype: rehypeRaw → rehypeSanitize(schema) → rehypeStripUnsafe
|
|
20
|
+
* → rehypeHighlight
|
|
21
|
+
* → urlTransform: cardAwareUrlTransform
|
|
22
|
+
* → components: buildBaseComponents(...) spread-last componentOverrides
|
|
23
|
+
*/
|
|
24
|
+
import React, { memo, useMemo } from 'react';
|
|
25
|
+
import ReactMarkdown, { type Components } from 'react-markdown';
|
|
26
|
+
import type { PluggableList } from 'unified';
|
|
27
|
+
import remarkGfm from 'remark-gfm';
|
|
28
|
+
import remarkBreaks from 'remark-breaks';
|
|
29
|
+
import rehypeHighlight from 'rehype-highlight';
|
|
30
|
+
import rehypeRaw from 'rehype-raw';
|
|
31
|
+
import rehypeSanitize from 'rehype-sanitize';
|
|
32
|
+
import type { ResolveLinkResult } from '../../../types/doc-source';
|
|
33
|
+
import {
|
|
34
|
+
buildEffectiveTagSet,
|
|
35
|
+
buildSanitizeSchema,
|
|
36
|
+
cardAwareUrlTransform,
|
|
37
|
+
escapeUnknownHtmlTags,
|
|
38
|
+
rehypeStripUnsafe,
|
|
39
|
+
} from './sanitize';
|
|
40
|
+
import { resolveTextSizeConfig, type TextSizeConfig } from './text-size';
|
|
41
|
+
import {
|
|
42
|
+
HeadingIdMapContext,
|
|
43
|
+
HeadingLineOffsetContext,
|
|
44
|
+
useHeadingIdMap,
|
|
45
|
+
type HeadingSection,
|
|
46
|
+
} from './heading-ids';
|
|
47
|
+
import { buildBaseComponents } from './base-components';
|
|
48
|
+
import { completeStreamingTail, splitStreamingBlocks } from './streaming';
|
|
49
|
+
|
|
50
|
+
export type { ResolveLinkResult };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Module-scope empty default for `brokenLinks`.
|
|
54
|
+
*
|
|
55
|
+
* A `brokenLinks = []` DEFAULT PARAMETER allocates a fresh array on every
|
|
56
|
+
* render, so the `components` useMemo (which lists it as a dep) recomputed
|
|
57
|
+
* every render, which changed `StreamingBlockRenderer`'s `components` prop
|
|
58
|
+
* identity, which made its `memo` bail EVERY time — every completed block
|
|
59
|
+
* re-parsed on every streamed token, defeating the entire atomic-block
|
|
60
|
+
* optimization. Any new default that is an object/array/function MUST be
|
|
61
|
+
* hoisted here for the same reason.
|
|
62
|
+
*
|
|
63
|
+
* Exported so the compositions that apply the SAME default before handing
|
|
64
|
+
* props down (rich) share this ONE identity instead of declaring a second
|
|
65
|
+
* module-scope empty array with a copy of this rationale. Deliberately NOT
|
|
66
|
+
* re-exported from ./index — it is an internal identity contract between the
|
|
67
|
+
* engine and its compositions, not public API.
|
|
68
|
+
*/
|
|
69
|
+
export const NO_BROKEN_LINKS: readonly string[] = [];
|
|
70
|
+
|
|
71
|
+
export interface MarkdownEngineProps {
|
|
72
|
+
content: string;
|
|
73
|
+
className?: string;
|
|
74
|
+
/** Backend-provided heading IDs for deep-link anchors */
|
|
75
|
+
sectionIds?: HeadingSection[];
|
|
76
|
+
/** When the page already has an H1, render markdown `#` as `<h2>` */
|
|
77
|
+
demoteMarkdownH1ToH2?: boolean;
|
|
78
|
+
/** List of broken link hrefs detected server-side (shown with [BROKEN] badge) */
|
|
79
|
+
brokenLinks?: readonly string[];
|
|
80
|
+
/** Callback for internal (non-http, non-anchor) link clicks */
|
|
81
|
+
onInternalLinkClick?: (path: string, options?: { expandFolder?: boolean; fromInternalLink?: boolean }) => void;
|
|
82
|
+
/** Current documentation path — enables internal-link mode when set */
|
|
83
|
+
currentPath?: string;
|
|
84
|
+
/** Resolve an internal link href to a navigation path. */
|
|
85
|
+
onResolveLink?: (href: string, currentPath: string) => Promise<ResolveLinkResult>;
|
|
86
|
+
/** Pre-process the raw markdown string before rendering (e.g. shortcode expansion) */
|
|
87
|
+
preprocessContent?: (content: string) => string;
|
|
88
|
+
/** Merge additional or override react-markdown component renderers (spread LAST — caller wins) */
|
|
89
|
+
componentOverrides?: Partial<Components>;
|
|
90
|
+
/** Extra remark plugins appended after the built-in remarkGfm + remarkBreaks */
|
|
91
|
+
additionalRemarkPlugins?: PluggableList;
|
|
92
|
+
/** Text sizing preset / per-element overrides */
|
|
93
|
+
textSize?: TextSizeConfig;
|
|
94
|
+
/**
|
|
95
|
+
* Extra raw-HTML tags this composition admits. Unioned into BOTH the
|
|
96
|
+
* text pre-pass allowlist AND the sanitize schema together (the
|
|
97
|
+
* coupled-allowlist invariant — see ./sanitize.ts).
|
|
98
|
+
*/
|
|
99
|
+
extraAllowedHtmlTags?: string[];
|
|
100
|
+
/**
|
|
101
|
+
* Set true for the actively streaming message ONLY. Enables atomic-block
|
|
102
|
+
* memoization + fence tail-completion + an aria-live wrapper. The caller
|
|
103
|
+
* MUST flip back to false on completion — that final render is one
|
|
104
|
+
* authoritative whole-document parse (block cache discarded), so
|
|
105
|
+
* streaming can never permanently diverge from the settled output.
|
|
106
|
+
*/
|
|
107
|
+
streaming?: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One memoized completed block of a streaming message. Parent-level memos
|
|
112
|
+
* keep every prop except `text` referentially stable, so a completed block
|
|
113
|
+
* re-renders only if its own text changes (i.e. the splitter re-cut).
|
|
114
|
+
* React key = position index — identical blocks at different positions
|
|
115
|
+
* never alias (see ./streaming.ts).
|
|
116
|
+
*/
|
|
117
|
+
const StreamingBlockRenderer = memo(function StreamingBlockRenderer({
|
|
118
|
+
text,
|
|
119
|
+
remarkPlugins,
|
|
120
|
+
rehypePlugins,
|
|
121
|
+
components,
|
|
122
|
+
}: {
|
|
123
|
+
text: string;
|
|
124
|
+
remarkPlugins: PluggableList;
|
|
125
|
+
rehypePlugins: PluggableList;
|
|
126
|
+
components: Components;
|
|
127
|
+
}) {
|
|
128
|
+
return (
|
|
129
|
+
<ReactMarkdown
|
|
130
|
+
remarkPlugins={remarkPlugins}
|
|
131
|
+
rehypePlugins={rehypePlugins}
|
|
132
|
+
urlTransform={cardAwareUrlTransform}
|
|
133
|
+
components={components}
|
|
134
|
+
>
|
|
135
|
+
{text}
|
|
136
|
+
</ReactMarkdown>
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const MarkdownEngineImpl: React.FC<MarkdownEngineProps> = ({
|
|
141
|
+
content,
|
|
142
|
+
className = '',
|
|
143
|
+
sectionIds,
|
|
144
|
+
demoteMarkdownH1ToH2 = false,
|
|
145
|
+
brokenLinks = NO_BROKEN_LINKS,
|
|
146
|
+
onInternalLinkClick,
|
|
147
|
+
currentPath,
|
|
148
|
+
onResolveLink,
|
|
149
|
+
preprocessContent,
|
|
150
|
+
componentOverrides,
|
|
151
|
+
additionalRemarkPlugins,
|
|
152
|
+
textSize,
|
|
153
|
+
extraAllowedHtmlTags,
|
|
154
|
+
streaming = false,
|
|
155
|
+
}) => {
|
|
156
|
+
const textSizes = useMemo(() => resolveTextSizeConfig(textSize), [textSize]);
|
|
157
|
+
|
|
158
|
+
// Stable identity key for the caller's tag array — a fresh array with the
|
|
159
|
+
// same contents must not bust these memos.
|
|
160
|
+
const extraTagsKey = extraAllowedHtmlTags?.join('|') ?? '';
|
|
161
|
+
|
|
162
|
+
// Effective tag allowlist — shared source for pre-pass AND schema.
|
|
163
|
+
// Both memos consume ONLY `extraTagsKey`, so the dep list is honest and
|
|
164
|
+
// needs no eslint suppression.
|
|
165
|
+
const effectiveTags = useMemo(
|
|
166
|
+
() => buildEffectiveTagSet(extraTagsKey ? extraTagsKey.split('|') : undefined),
|
|
167
|
+
[extraTagsKey],
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const processedContent = useMemo(() => {
|
|
171
|
+
const preprocessed = preprocessContent ? preprocessContent(content) : content;
|
|
172
|
+
// ORDER IS LOAD-BEARING: complete the streaming tail FIRST, then escape.
|
|
173
|
+
//
|
|
174
|
+
// Escaping first failed OPEN on the most common streaming shape. During a
|
|
175
|
+
// stream every partially-emitted fence is unclosed, and both of the
|
|
176
|
+
// pre-pass's fence notions (`PROTECTED_SPAN_RE` for the carve AND the
|
|
177
|
+
// mask) only recognize CLOSED fences — so a `</textarea>` inside the
|
|
178
|
+
// still-open fence satisfied "is closed later", the prose opener above it
|
|
179
|
+
// stayed live, and parse5 swallowed the rest of the message until the
|
|
180
|
+
// fence closed (an LLM explaining `<textarea>` then streaming a fenced
|
|
181
|
+
// HTML sample hits this exactly). The same ordering also fired the carve's
|
|
182
|
+
// documented "unclosed fence body is escaped" tradeoff on EVERY streaming
|
|
183
|
+
// frame, so a `<their>` mid-fence rendered as `<their>` until the
|
|
184
|
+
// closer arrived.
|
|
185
|
+
//
|
|
186
|
+
// `completeStreamingTail` only APPENDS a fence delimiter run, so escaping
|
|
187
|
+
// afterwards is safe (nothing it adds is escapable).
|
|
188
|
+
const completed = streaming ? completeStreamingTail(preprocessed) : preprocessed;
|
|
189
|
+
return escapeUnknownHtmlTags(completed, effectiveTags);
|
|
190
|
+
}, [preprocessContent, content, effectiveTags, streaming]);
|
|
191
|
+
|
|
192
|
+
// Heading ids: derived ONCE from the processed source, never from render
|
|
193
|
+
// order. See ./heading-ids.ts for why the old stateful generator was
|
|
194
|
+
// unsound under block memoization AND StrictMode.
|
|
195
|
+
const headingIds = useHeadingIdMap(processedContent, sectionIds);
|
|
196
|
+
|
|
197
|
+
const remarkPlugins = useMemo<PluggableList>(
|
|
198
|
+
() => [remarkGfm, remarkBreaks, ...(additionalRemarkPlugins ?? [])],
|
|
199
|
+
[additionalRemarkPlugins],
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const rehypePlugins = useMemo<PluggableList>(() => {
|
|
203
|
+
const schema = buildSanitizeSchema({
|
|
204
|
+
extraAllowedHtmlTags: extraTagsKey ? extraTagsKey.split('|') : undefined,
|
|
205
|
+
});
|
|
206
|
+
return [
|
|
207
|
+
// ORDER MATTERS: rehype-raw parses embedded raw HTML into HAST;
|
|
208
|
+
// rehypeSanitize is the allow-list boundary; rehypeStripUnsafe is
|
|
209
|
+
// defense-in-depth (srcset scanning, iframe[srcdoc]); highlight last.
|
|
210
|
+
rehypeRaw,
|
|
211
|
+
[rehypeSanitize, schema],
|
|
212
|
+
rehypeStripUnsafe,
|
|
213
|
+
[rehypeHighlight, { detect: true, ignoreMissing: true }],
|
|
214
|
+
];
|
|
215
|
+
}, [extraTagsKey]);
|
|
216
|
+
|
|
217
|
+
const components: Components = useMemo(
|
|
218
|
+
() => ({
|
|
219
|
+
...buildBaseComponents({
|
|
220
|
+
textSizes,
|
|
221
|
+
demoteMarkdownH1ToH2,
|
|
222
|
+
brokenLinks,
|
|
223
|
+
currentPath,
|
|
224
|
+
onInternalLinkClick,
|
|
225
|
+
onResolveLink,
|
|
226
|
+
}),
|
|
227
|
+
// Caller overrides spread LAST — chat's card/mention <a> override,
|
|
228
|
+
// rich's embed overrides, etc. always win over the base map.
|
|
229
|
+
...componentOverrides,
|
|
230
|
+
}),
|
|
231
|
+
[
|
|
232
|
+
textSizes,
|
|
233
|
+
demoteMarkdownH1ToH2,
|
|
234
|
+
brokenLinks,
|
|
235
|
+
currentPath,
|
|
236
|
+
onInternalLinkClick,
|
|
237
|
+
onResolveLink,
|
|
238
|
+
componentOverrides,
|
|
239
|
+
],
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// Streaming block plan (only computed on the streaming path).
|
|
243
|
+
const streamingBlocks = useMemo(
|
|
244
|
+
() => (streaming ? splitStreamingBlocks(processedContent) : null),
|
|
245
|
+
[streaming, processedContent],
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const body = streamingBlocks ? (
|
|
249
|
+
// aria-live: readers announce coherent appended chunks, not per-token
|
|
250
|
+
// spam (polite + additions). Streaming caret/pulse affordances live in
|
|
251
|
+
// chat components and honor prefers-reduced-motion there.
|
|
252
|
+
<div aria-live="polite" aria-relevant="additions text">
|
|
253
|
+
{streamingBlocks.map((block) => (
|
|
254
|
+
// Each unit is parsed on its own, so hast positions restart at 1;
|
|
255
|
+
// the offset maps them back onto the document-wide heading-id map.
|
|
256
|
+
<HeadingLineOffsetContext.Provider key={block.index} value={block.startLine - 1}>
|
|
257
|
+
{block.memoizable ? (
|
|
258
|
+
<StreamingBlockRenderer
|
|
259
|
+
text={block.text}
|
|
260
|
+
remarkPlugins={remarkPlugins}
|
|
261
|
+
rehypePlugins={rehypePlugins}
|
|
262
|
+
components={components}
|
|
263
|
+
/>
|
|
264
|
+
) : (
|
|
265
|
+
<ReactMarkdown
|
|
266
|
+
remarkPlugins={remarkPlugins}
|
|
267
|
+
rehypePlugins={rehypePlugins}
|
|
268
|
+
urlTransform={cardAwareUrlTransform}
|
|
269
|
+
components={components}
|
|
270
|
+
>
|
|
271
|
+
{block.text}
|
|
272
|
+
</ReactMarkdown>
|
|
273
|
+
)}
|
|
274
|
+
</HeadingLineOffsetContext.Provider>
|
|
275
|
+
))}
|
|
276
|
+
</div>
|
|
277
|
+
) : (
|
|
278
|
+
<ReactMarkdown
|
|
279
|
+
remarkPlugins={remarkPlugins}
|
|
280
|
+
rehypePlugins={rehypePlugins}
|
|
281
|
+
urlTransform={cardAwareUrlTransform}
|
|
282
|
+
components={components}
|
|
283
|
+
>
|
|
284
|
+
{processedContent}
|
|
285
|
+
</ReactMarkdown>
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
return (
|
|
289
|
+
<div className={`simple-markdown-renderer ${className}`}>
|
|
290
|
+
{/* Mermaid's <style> is rendered by MermaidDiagram itself — one tag
|
|
291
|
+
per mounted diagram instead of one per engine instance (i.e. per
|
|
292
|
+
chat segment), where the overwhelming majority render no diagram. */}
|
|
293
|
+
{/* `overflow-wrap: anywhere` is inherited from `.simple-markdown-renderer`
|
|
294
|
+
in app-globals.css — do NOT re-add `break-words` here, it would
|
|
295
|
+
override the cascade with the weaker `break-word` for this subtree. */}
|
|
296
|
+
<div className="content-wrapper max-w-none">
|
|
297
|
+
<article className="prose prose-lg max-w-none">
|
|
298
|
+
<HeadingIdMapContext.Provider value={headingIds}>{body}</HeadingIdMapContext.Provider>
|
|
299
|
+
</article>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
);
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Memoized so a parent re-render with UNCHANGED props (same `content`
|
|
307
|
+
* string, same memoized `componentOverrides`/plugins) does NOT re-parse the
|
|
308
|
+
* markdown. Re-parsing rebuilds the entire react-markdown subtree, which
|
|
309
|
+
* RE-MOUNTS any embedded inline entity cards — closing their open menus and
|
|
310
|
+
* re-triggering their fetch on every chat re-render (streaming chunk AND
|
|
311
|
+
* scroll). With stable props the renderer bails, so completed messages'
|
|
312
|
+
* cards stay mounted. The streaming path additionally memoizes completed
|
|
313
|
+
* atomic blocks so the in-flight message re-parses only its live tail.
|
|
314
|
+
*/
|
|
315
|
+
export const MarkdownEngine = memo(MarkdownEngineImpl);
|