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