@flamingo-stack/openframe-frontend-core 0.0.492-1506.3956.1 → 0.0.492
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/{chunk-KX3K2EH4.cjs → chunk-2XAAFAQT.cjs} +11 -11
- package/dist/{chunk-KX3K2EH4.cjs.map → chunk-2XAAFAQT.cjs.map} +1 -1
- package/dist/{chunk-JHEIOXXZ.js → chunk-4AWRPTKE.js} +5 -6
- package/dist/chunk-4AWRPTKE.js.map +1 -0
- package/dist/{chunk-PTXZB2KW.js → chunk-4J3CB4KO.js} +2 -2
- package/dist/{chunk-7N7YZCHR.cjs → chunk-4MLOMMDX.cjs} +5519 -7555
- package/dist/chunk-4MLOMMDX.cjs.map +1 -0
- package/dist/{chunk-MEMRQTOI.js → chunk-56J7RLMM.js} +10434 -12470
- package/dist/chunk-56J7RLMM.js.map +1 -0
- package/dist/{chunk-MEIGDK7M.cjs → chunk-5ICPQAUI.cjs} +60 -60
- package/dist/chunk-5ICPQAUI.cjs.map +1 -0
- package/dist/{chunk-BEEPU44B.js → chunk-5RHJVPL7.js} +10 -10
- package/dist/chunk-5RHJVPL7.js.map +1 -0
- package/dist/{chunk-XOFE2RT4.js → chunk-BTIRYGDT.js} +4 -4
- package/dist/{chunk-JSEJ5MH5.cjs → chunk-DY6AJNVK.cjs} +5 -5
- package/dist/{chunk-JSEJ5MH5.cjs.map → chunk-DY6AJNVK.cjs.map} +1 -1
- package/dist/{chunk-PLB6CQSO.js → chunk-FG5ALORQ.js} +2 -2
- package/dist/{chunk-QG2EBIH5.cjs → chunk-J2J36PEN.cjs} +31 -31
- package/dist/{chunk-QG2EBIH5.cjs.map → chunk-J2J36PEN.cjs.map} +1 -1
- package/dist/{chunk-A6FPYAUD.cjs → chunk-LIRXKEOE.cjs} +15 -15
- package/dist/{chunk-A6FPYAUD.cjs.map → chunk-LIRXKEOE.cjs.map} +1 -1
- package/dist/{chunk-MSL5NE3N.cjs → chunk-LVDI7R7T.cjs} +37 -37
- package/dist/{chunk-MSL5NE3N.cjs.map → chunk-LVDI7R7T.cjs.map} +1 -1
- package/dist/{chunk-MPUQFRJG.cjs → chunk-M4SCYPCR.cjs} +7 -7
- package/dist/{chunk-MPUQFRJG.cjs.map → chunk-M4SCYPCR.cjs.map} +1 -1
- package/dist/{chunk-QDGB2DKY.cjs → chunk-O6S6Q52W.cjs} +26 -26
- package/dist/{chunk-QDGB2DKY.cjs.map → chunk-O6S6Q52W.cjs.map} +1 -1
- package/dist/{chunk-W56JYDSC.cjs → chunk-OFFYASUY.cjs} +27 -28
- package/dist/chunk-OFFYASUY.cjs.map +1 -0
- package/dist/{chunk-NCAN6VUF.js → chunk-P3ITKTUE.js} +2 -2
- package/dist/{chunk-M6FEGPQJ.cjs → chunk-PI5BMKV3.cjs} +62 -62
- package/dist/{chunk-M6FEGPQJ.cjs.map → chunk-PI5BMKV3.cjs.map} +1 -1
- package/dist/{chunk-34TDA22K.js → chunk-QHRNQTW5.js} +2 -2
- package/dist/{chunk-OL3MJKUI.js → chunk-SZWMJIH3.js} +2 -2
- package/dist/{chunk-MZRIRQYV.js → chunk-U5LU56JE.js} +2 -2
- package/dist/{chunk-C65NCIMV.cjs → chunk-VML66FJQ.cjs} +9 -9
- package/dist/{chunk-C65NCIMV.cjs.map → chunk-VML66FJQ.cjs.map} +1 -1
- package/dist/{chunk-3LKB722U.js → chunk-WKWZ7LVC.js} +2 -2
- package/dist/{chunk-J3VBVR4W.js → chunk-WPFLV4QJ.js} +4 -4
- 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 +1 -0
- 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-nats-chat-adapter.d.ts +48 -15
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +6 -0
- package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +1 -0
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +57 -10
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +2 -10
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.d.ts +0 -1
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/index.js +13 -21
- package/dist/components/chat/types/api.types.d.ts +81 -1
- package/dist/components/chat/types/api.types.d.ts.map +1 -1
- package/dist/components/chat/types/processing.types.d.ts +93 -2
- package/dist/components/chat/types/processing.types.d.ts.map +1 -1
- package/dist/components/chat/utils/chunk-parser.d.ts +25 -0
- package/dist/components/chat/utils/chunk-parser.d.ts.map +1 -0
- package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +6 -34
- package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
- package/dist/components/chat/utils/index.d.ts +3 -2
- package/dist/components/chat/utils/index.d.ts.map +1 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts +11 -59
- 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/floating-walkthrough-video.d.ts.map +1 -1
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/help-center-pages/index.cjs +20 -20
- package/dist/components/help-center-pages/index.js +11 -11
- package/dist/components/index.cjs +130 -172
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +21 -63
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/navigation/navigation-sidebar-item.d.ts +23 -1
- package/dist/components/navigation/navigation-sidebar-item.d.ts.map +1 -1
- package/dist/components/navigation/navigation-sidebar.d.ts.map +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 +5 -5
- package/dist/components/tickets/index.js +4 -4
- package/dist/components/ui/data-table/data-table-column-filter.d.ts.map +1 -1
- package/dist/components/ui/data-table/data-table-header.d.ts.map +1 -1
- package/dist/components/ui/data-table/data-table-skeleton.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +2 -44
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.d.ts +2 -1
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +13 -55
- package/dist/components/ui/rich-markdown-renderer.d.ts +34 -0
- package/dist/components/ui/rich-markdown-renderer.d.ts.map +1 -0
- package/dist/components/ui/simple-markdown-renderer.d.ts +73 -0
- package/dist/components/ui/simple-markdown-renderer.d.ts.map +1 -0
- package/dist/components/ui/tab-navigation.d.ts.map +1 -1
- package/dist/index.cjs +2 -60
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +13 -71
- package/dist/utils/index.cjs +40 -169
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +0 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +41 -162
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
- package/package.json +1 -7
- package/src/components/chat/__tests__/chat-message-list.test.tsx +5 -293
- package/src/components/chat/chat-message-enhanced.tsx +20 -122
- package/src/components/chat/chat-message-list.tsx +109 -358
- package/src/components/chat/embeddable-chat.tsx +0 -6
- package/src/components/chat/hooks/.index.md +33 -30
- package/src/components/chat/hooks/.use-nats-chat-adapter.md +55 -36
- package/src/components/chat/hooks/.use-realtime-chunk-processor.md +77 -0
- package/src/components/chat/hooks/index.ts +1 -0
- package/src/components/chat/hooks/use-chat.ts +0 -8
- package/src/components/chat/hooks/use-nats-chat-adapter.ts +825 -243
- package/src/components/chat/hooks/use-realtime-chunk-processor.ts +489 -0
- package/src/components/chat/hooks/use-sse-chat-adapter.ts +684 -380
- package/src/components/chat/index.ts +0 -5
- package/src/components/chat/thinking-display.tsx +1 -1
- package/src/components/chat/types/.api.types.md +49 -39
- package/src/components/chat/types/.processing.types.md +58 -29
- package/src/components/chat/types/api.types.ts +73 -6
- package/src/components/chat/types/processing.types.ts +53 -11
- package/src/components/chat/utils/.chunk-parser.md +62 -0
- package/src/components/chat/utils/.index.md +40 -31
- package/src/components/chat/utils/chunk-parser.ts +262 -0
- package/src/components/chat/utils/extract-incomplete-message-state.ts +5 -63
- package/src/components/chat/utils/index.ts +8 -5
- package/src/components/chat/utils/process-historical-messages.ts +386 -358
- package/src/components/features/floating-walkthrough-video.tsx +3 -1
- package/src/components/navigation/navigation-sidebar-item.tsx +80 -30
- package/src/components/navigation/navigation-sidebar.tsx +106 -46
- package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +4 -22
- package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
- package/src/components/shared/product-release/release-detail-page.tsx +4 -19
- package/src/components/ui/.simple-markdown-renderer.md +52 -0
- package/src/components/ui/__tests__/tab-navigation.test.tsx +159 -0
- package/src/components/ui/data-table/data-table-column-filter.tsx +7 -1
- package/src/components/ui/data-table/data-table-header.tsx +4 -1
- package/src/components/ui/data-table/data-table-skeleton.tsx +7 -0
- package/src/components/ui/index.ts +2 -1
- package/src/components/ui/release-changelog-section.tsx +1 -1
- package/src/components/ui/rich-markdown-renderer.tsx +1223 -0
- package/src/components/ui/simple-markdown-renderer.tsx +964 -0
- package/src/components/ui/tab-navigation.tsx +370 -171
- package/src/components/ui/ticket-info-section.tsx +1 -1
- package/src/utils/index.ts +0 -1
- package/src/utils/markdown-section-extractor.ts +56 -41
- package/dist/chat-protocol/decode.d.ts +0 -58
- package/dist/chat-protocol/decode.d.ts.map +0 -1
- package/dist/chat-protocol/encode.d.ts +0 -44
- package/dist/chat-protocol/encode.d.ts.map +0 -1
- package/dist/chat-protocol/env-flag.d.ts +0 -35
- package/dist/chat-protocol/env-flag.d.ts.map +0 -1
- package/dist/chat-protocol/events.d.ts +0 -207
- package/dist/chat-protocol/events.d.ts.map +0 -1
- package/dist/chat-protocol/frames.d.ts +0 -213
- package/dist/chat-protocol/frames.d.ts.map +0 -1
- package/dist/chat-protocol/index.cjs +0 -500
- package/dist/chat-protocol/index.cjs.map +0 -1
- package/dist/chat-protocol/index.d.ts +0 -18
- package/dist/chat-protocol/index.d.ts.map +0 -1
- package/dist/chat-protocol/index.js +0 -484
- package/dist/chat-protocol/index.js.map +0 -1
- package/dist/chat-protocol/ip-normalize.d.ts +0 -44
- package/dist/chat-protocol/ip-normalize.d.ts.map +0 -1
- package/dist/chat-protocol/nats-decoder.d.ts +0 -26
- package/dist/chat-protocol/nats-decoder.d.ts.map +0 -1
- package/dist/chunk-7N7YZCHR.cjs.map +0 -1
- package/dist/chunk-BEEPU44B.js.map +0 -1
- package/dist/chunk-JHEIOXXZ.js.map +0 -1
- package/dist/chunk-MEIGDK7M.cjs.map +0 -1
- package/dist/chunk-MEMRQTOI.js.map +0 -1
- package/dist/chunk-W56JYDSC.cjs.map +0 -1
- package/dist/components/chat/stream/chat-dialog-store.d.ts +0 -184
- package/dist/components/chat/stream/chat-dialog-store.d.ts.map +0 -1
- package/dist/components/chat/stream/chat-stream-reducer.d.ts +0 -367
- package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +0 -1
- package/dist/components/chat/stream/delta-batcher.d.ts +0 -51
- package/dist/components/chat/stream/delta-batcher.d.ts.map +0 -1
- package/dist/components/chat/stream/index.d.ts +0 -17
- package/dist/components/chat/stream/index.d.ts.map +0 -1
- package/dist/components/chat/stream/message-mutations.d.ts +0 -77
- package/dist/components/chat/stream/message-mutations.d.ts.map +0 -1
- package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +0 -22
- package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +0 -1
- package/dist/components/ui/markdown/base-components.d.ts +0 -51
- package/dist/components/ui/markdown/base-components.d.ts.map +0 -1
- package/dist/components/ui/markdown/engine.d.ts +0 -99
- package/dist/components/ui/markdown/engine.d.ts.map +0 -1
- package/dist/components/ui/markdown/heading-ids.d.ts +0 -65
- package/dist/components/ui/markdown/heading-ids.d.ts.map +0 -1
- package/dist/components/ui/markdown/index.d.ts +0 -18
- package/dist/components/ui/markdown/index.d.ts.map +0 -1
- package/dist/components/ui/markdown/mermaid-diagram.d.ts +0 -79
- package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +0 -1
- package/dist/components/ui/markdown/rich/embed-overrides.d.ts +0 -14
- package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +0 -1
- package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +0 -40
- package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +0 -1
- package/dist/components/ui/markdown/rich/shortcodes.d.ts +0 -8
- package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +0 -1
- package/dist/components/ui/markdown/sanitize.d.ts +0 -153
- package/dist/components/ui/markdown/sanitize.d.ts.map +0 -1
- package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +0 -23
- package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +0 -1
- package/dist/components/ui/markdown/streaming.d.ts +0 -78
- package/dist/components/ui/markdown/streaming.d.ts.map +0 -1
- package/dist/components/ui/markdown/text-size.d.ts +0 -34
- package/dist/components/ui/markdown/text-size.d.ts.map +0 -1
- package/dist/utils/markdown-fences.d.ts +0 -42
- package/dist/utils/markdown-fences.d.ts.map +0 -1
- package/dist/utils/markdown-heading-id.d.ts +0 -85
- package/dist/utils/markdown-heading-id.d.ts.map +0 -1
- package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +0 -317
- package/src/chat-protocol/__tests__/chat-protocol.test.ts +0 -516
- package/src/chat-protocol/__tests__/env-flag.test.ts +0 -52
- package/src/chat-protocol/__tests__/ip-normalize.test.ts +0 -136
- package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +0 -232
- package/src/chat-protocol/decode.ts +0 -278
- package/src/chat-protocol/encode.ts +0 -71
- package/src/chat-protocol/env-flag.ts +0 -39
- package/src/chat-protocol/events.ts +0 -245
- package/src/chat-protocol/frames.ts +0 -245
- package/src/chat-protocol/index.ts +0 -21
- package/src/chat-protocol/ip-normalize.ts +0 -146
- package/src/chat-protocol/nats-decoder.ts +0 -258
- package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +0 -207
- package/src/components/chat/__tests__/chat-pending-turn.test.tsx +0 -230
- package/src/components/chat/hooks/__tests__/__snapshots__/conversation-id-persistence-golden.test.ts.snap +0 -37
- package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
- package/src/components/chat/hooks/__tests__/conversation-id-persistence-golden.test.ts +0 -438
- package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +0 -318
- package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +0 -940
- package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +0 -756
- package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +0 -460
- package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +0 -973
- package/src/components/chat/stream/__tests__/delta-batcher.test.ts +0 -255
- package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +0 -98
- package/src/components/chat/stream/chat-dialog-store.ts +0 -536
- package/src/components/chat/stream/chat-stream-reducer.ts +0 -1808
- package/src/components/chat/stream/delta-batcher.ts +0 -168
- package/src/components/chat/stream/index.ts +0 -55
- package/src/components/chat/stream/message-mutations.ts +0 -340
- package/src/components/chat/stream/use-chat-stream-reducer.ts +0 -126
- package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +0 -420
- package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +0 -605
- package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +0 -394
- package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +0 -270
- package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +0 -2420
- package/src/components/ui/__tests__/markdown-parity.test.tsx +0 -2528
- package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +0 -115
- package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +0 -95
- package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +0 -309
- package/src/components/ui/markdown/__tests__/streaming.test.tsx +0 -388
- package/src/components/ui/markdown/base-components.tsx +0 -360
- package/src/components/ui/markdown/engine.tsx +0 -315
- package/src/components/ui/markdown/heading-ids.ts +0 -239
- package/src/components/ui/markdown/index.ts +0 -49
- package/src/components/ui/markdown/mermaid-diagram.tsx +0 -291
- package/src/components/ui/markdown/rich/embed-overrides.tsx +0 -199
- package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +0 -184
- package/src/components/ui/markdown/rich/shortcodes.ts +0 -170
- package/src/components/ui/markdown/sanitize.ts +0 -3499
- package/src/components/ui/markdown/simple-markdown-renderer.tsx +0 -28
- package/src/components/ui/markdown/streaming.ts +0 -362
- package/src/components/ui/markdown/text-size.ts +0 -106
- package/src/utils/markdown-fences.ts +0 -127
- package/src/utils/markdown-heading-id.ts +0 -348
- /package/dist/{chunk-PTXZB2KW.js.map → chunk-4J3CB4KO.js.map} +0 -0
- /package/dist/{chunk-XOFE2RT4.js.map → chunk-BTIRYGDT.js.map} +0 -0
- /package/dist/{chunk-PLB6CQSO.js.map → chunk-FG5ALORQ.js.map} +0 -0
- /package/dist/{chunk-NCAN6VUF.js.map → chunk-P3ITKTUE.js.map} +0 -0
- /package/dist/{chunk-34TDA22K.js.map → chunk-QHRNQTW5.js.map} +0 -0
- /package/dist/{chunk-OL3MJKUI.js.map → chunk-SZWMJIH3.js.map} +0 -0
- /package/dist/{chunk-MZRIRQYV.js.map → chunk-U5LU56JE.js.map} +0 -0
- /package/dist/{chunk-3LKB722U.js.map → chunk-WKWZ7LVC.js.map} +0 -0
- /package/dist/{chunk-J3VBVR4W.js.map → chunk-WPFLV4QJ.js.map} +0 -0
|
@@ -738,7 +738,9 @@ export function FloatingWalkthroughVideo({
|
|
|
738
738
|
) : (
|
|
739
739
|
<VideoPlayBadge size="sm" className="h-5 w-5 shrink-0" />
|
|
740
740
|
)}
|
|
741
|
-
|
|
741
|
+
{/* The video's OWN title is the point of the pill; `label` is only the
|
|
742
|
+
generic fallback for a video that carries no title. */}
|
|
743
|
+
<span className="truncate">{summaryTitle || label}</span>
|
|
742
744
|
</span>
|
|
743
745
|
|
|
744
746
|
{/* BIG centred glyph — the muted-fallback prompt (bite grammar). It IS
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import { cloneElement, type ReactElement } from 'react'
|
|
3
|
+
import { cloneElement, memo, type ReactElement, type ReactNode } from 'react'
|
|
4
|
+
import Link from '../../embed-shims/next-link'
|
|
4
5
|
import { NavigationSidebarItem } from '../../types/navigation'
|
|
5
6
|
import { cn } from '../../utils'
|
|
6
7
|
|
|
@@ -11,46 +12,72 @@ interface IconProps {
|
|
|
11
12
|
|
|
12
13
|
export interface NavigationSidebarItemButtonProps {
|
|
13
14
|
item: NavigationSidebarItem
|
|
15
|
+
/** "You are on this page" — pathname-derived, and the only thing that carries `aria-current`. */
|
|
16
|
+
isActive: boolean
|
|
17
|
+
/**
|
|
18
|
+
* "You clicked this one and it has not arrived yet" — deliberately a separate,
|
|
19
|
+
* weaker state. Never promoted to active: the accent belongs to the page you
|
|
20
|
+
* are actually on.
|
|
21
|
+
*/
|
|
22
|
+
isPending: boolean
|
|
14
23
|
showLabel: boolean
|
|
15
24
|
disabled: boolean
|
|
16
25
|
onClick: (item: NavigationSidebarItem, event?: React.MouseEvent) => void
|
|
17
26
|
}
|
|
18
27
|
|
|
19
|
-
|
|
28
|
+
/**
|
|
29
|
+
* One sidebar entry.
|
|
30
|
+
*
|
|
31
|
+
* Rendered as a real anchor whenever it has a `path` — which is what buys the
|
|
32
|
+
* two things a `<button>` + `router.push` cannot: Next prefetches links that
|
|
33
|
+
* are in the viewport (so the section is usually already loaded by the time it
|
|
34
|
+
* is clicked, instead of every first visit paying a cold segment fetch), and
|
|
35
|
+
* the browser's own affordances work — middle-click, ⌘/Ctrl-click, "open in new
|
|
36
|
+
* tab", copy link, and a status-bar URL on hover.
|
|
37
|
+
*
|
|
38
|
+
* Falls back to a `<button>` for entries that only carry an `onClick` (no
|
|
39
|
+
* destination to link to) and for the disabled state, which an anchor cannot
|
|
40
|
+
* express.
|
|
41
|
+
*/
|
|
42
|
+
export const NavigationSidebarItemButton = memo(function NavigationSidebarItemButton({
|
|
20
43
|
item,
|
|
44
|
+
isActive,
|
|
45
|
+
isPending,
|
|
21
46
|
showLabel,
|
|
22
47
|
disabled,
|
|
23
48
|
onClick,
|
|
24
49
|
}: NavigationSidebarItemButtonProps) {
|
|
25
|
-
const isActive = item.isActive ?? false
|
|
26
50
|
const unreadCount = item.unreadCount ?? 0
|
|
27
51
|
const hasUnread = unreadCount > 0
|
|
28
52
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
const className = cn(
|
|
54
|
+
"w-full flex items-center justify-start relative",
|
|
55
|
+
"h-14 p-[var(--spacing-system-m)]",
|
|
56
|
+
// 150ms, not 300: the outgoing and incoming entries cross-fade, so for the
|
|
57
|
+
// whole duration TWO rows read as half-active. At 300ms that window is long
|
|
58
|
+
// enough to look like a flicker rather than a transition.
|
|
59
|
+
"transition-colors duration-150",
|
|
60
|
+
"[&_svg]:transition-colors [&_svg]:duration-150",
|
|
61
|
+
"before:content-[''] before:absolute before:left-0 before:top-0 before:bottom-0 before:w-1",
|
|
62
|
+
"before:transition-colors before:duration-150",
|
|
63
|
+
// Pending borrows the HOVER weight and nothing else — no accent bar, no
|
|
64
|
+
// accent text, no `aria-current`. It says "this row is working", not "this
|
|
65
|
+
// is where you are", and it reads as the row staying pressed under the
|
|
66
|
+
// cursor, which is exactly what happened.
|
|
67
|
+
!isActive && !disabled && "hover:bg-ods-bg-hover text-ods-text-primary [&_svg]:fill-ods-text-secondary",
|
|
68
|
+
!isActive && !disabled && isPending && "bg-ods-bg-hover",
|
|
69
|
+
!isActive && disabled && "text-ods-text-secondary [&_svg]:fill-ods-text-secondary",
|
|
70
|
+
isActive && !disabled && [
|
|
71
|
+
"bg-[var(--ods-open-yellow-light)] text-ods-accent",
|
|
72
|
+
"[&_svg]:fill-ods-accent",
|
|
73
|
+
"before:bg-ods-accent",
|
|
74
|
+
],
|
|
75
|
+
isActive && disabled && "text-ods-text-secondary [&_svg]:fill-ods-text-secondary",
|
|
76
|
+
disabled && "cursor-not-allowed opacity-50",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
const content: ReactNode = (
|
|
80
|
+
<>
|
|
54
81
|
<div className="relative flex items-center justify-center flex-shrink-0">
|
|
55
82
|
{cloneElement(item.icon as ReactElement<IconProps>, {
|
|
56
83
|
color: isActive && !disabled ? "text-ods-accent" : "text-ods-text-secondary",
|
|
@@ -77,6 +104,29 @@ export function NavigationSidebarItemButton({
|
|
|
77
104
|
</span>
|
|
78
105
|
</span>
|
|
79
106
|
)}
|
|
80
|
-
|
|
107
|
+
</>
|
|
81
108
|
)
|
|
82
|
-
|
|
109
|
+
|
|
110
|
+
const shared = {
|
|
111
|
+
className,
|
|
112
|
+
title: !showLabel ? item.label : undefined,
|
|
113
|
+
'aria-label': item.label,
|
|
114
|
+
'aria-current': isActive ? ('page' as const) : undefined,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// An entry with a custom onClick has no destination of its own, and a
|
|
118
|
+
// disabled one must not be followable — both stay buttons.
|
|
119
|
+
if (disabled || !item.path || item.onClick) {
|
|
120
|
+
return (
|
|
121
|
+
<button type="button" onClick={(event) => onClick(item, event)} disabled={disabled} {...shared}>
|
|
122
|
+
{content}
|
|
123
|
+
</button>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<Link href={item.path} onClick={(event) => onClick(item, event)} {...shared}>
|
|
129
|
+
{content}
|
|
130
|
+
</Link>
|
|
131
|
+
)
|
|
132
|
+
})
|
|
@@ -13,6 +13,10 @@ const MINIMIZED_WIDTH = 56 // 3.5rem = 56px
|
|
|
13
13
|
const EXPANDED_WIDTH = 224 // 14rem = 224px
|
|
14
14
|
const STORAGE_KEY = 'of.navigationSidebar.minimized'
|
|
15
15
|
|
|
16
|
+
/** A click the browser will resolve itself — new tab, new window, middle button. */
|
|
17
|
+
const isModifiedClick = (event?: React.MouseEvent): boolean =>
|
|
18
|
+
!!event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0)
|
|
19
|
+
|
|
16
20
|
export interface NavigationSidebarProps {
|
|
17
21
|
config: NavigationSidebarConfig
|
|
18
22
|
/**
|
|
@@ -75,17 +79,68 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
|
|
|
75
79
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
|
76
80
|
}, [isOverlayOpen, closeOverlay])
|
|
77
81
|
|
|
82
|
+
// The entry the user just clicked, held until the route commits.
|
|
83
|
+
//
|
|
84
|
+
// PENDING IS NOT ACTIVE, and keeping the two apart is the whole point. Active
|
|
85
|
+
// means "this is the page you are on" — it carries the accent bar and
|
|
86
|
+
// `aria-current="page"`, and it stays derived from the pathname, so it is
|
|
87
|
+
// never asserted before it is true. Pending only means "this is the one you
|
|
88
|
+
// clicked": a click deserves an answer, but the answer must not be a claim
|
|
89
|
+
// about where you are. Folding pending into active would light the accent on
|
|
90
|
+
// a section still loading and announce it as the current page to a screen
|
|
91
|
+
// reader — a statement that is simply false until the router says otherwise.
|
|
92
|
+
const [pendingItemId, setPendingItemId] = useState<string | null>(null)
|
|
93
|
+
const committedActiveId = useMemo(
|
|
94
|
+
() => config.items.find(item => item.isActive)?.id ?? null,
|
|
95
|
+
[config.items],
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
// Any committed change clears it — including one that landed somewhere else
|
|
99
|
+
// entirely (a redirect, a link elsewhere on the page). A navigation that never
|
|
100
|
+
// commits leaves a faint hold on the row it started from; because pending is
|
|
101
|
+
// only ever a soft hint, that costs nothing but a stale hover-weight tint,
|
|
102
|
+
// where a stale ACTIVE state would have been a lie about the current page.
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
setPendingItemId(null)
|
|
105
|
+
}, [committedActiveId])
|
|
106
|
+
|
|
78
107
|
const handleItemClick = useCallback((item: NavigationSidebarItem, event?: React.MouseEvent) => {
|
|
79
108
|
event?.stopPropagation()
|
|
80
109
|
|
|
81
110
|
if (item.onClick) {
|
|
82
111
|
item.onClick()
|
|
83
|
-
|
|
84
|
-
|
|
112
|
+
if (isTablet) setTabletMinimized(true)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!item.path) return
|
|
117
|
+
|
|
118
|
+
// ⌘/Ctrl/Shift-click and middle-click open the link somewhere else. THIS
|
|
119
|
+
// window is not going anywhere, so the browser is left to it: no
|
|
120
|
+
// preventDefault, no optimistic highlight, no closing the tablet overlay.
|
|
121
|
+
// Entries render as real anchors precisely so those gestures work at all.
|
|
122
|
+
if (isModifiedClick(event)) return
|
|
123
|
+
|
|
124
|
+
// A plain click stays the host's to perform, through `onNavigate` exactly
|
|
125
|
+
// as before — the anchor is here for the href (Next prefetches links in the
|
|
126
|
+
// viewport) and for the browser affordances, not to take over routing.
|
|
127
|
+
//
|
|
128
|
+
// `onNavigate` is optional, though, and with no host router there is nothing
|
|
129
|
+
// to hand the click to: swallowing it would leave a real anchor, with a real
|
|
130
|
+
// href, that does nothing at all. So the guard is on having somewhere to
|
|
131
|
+
// send it — otherwise the anchor navigates on its own and we only close the
|
|
132
|
+
// overlay behind it.
|
|
133
|
+
if (config.onNavigate) {
|
|
134
|
+
event?.preventDefault()
|
|
135
|
+
// Re-clicking the page you are already on starts no navigation, so there
|
|
136
|
+
// is nothing to mark — and nothing would ever clear it, since the
|
|
137
|
+
// committed active id is not about to change.
|
|
138
|
+
if (item.id !== committedActiveId) setPendingItemId(item.id)
|
|
139
|
+
config.onNavigate(item.path)
|
|
85
140
|
}
|
|
86
141
|
|
|
87
142
|
if (isTablet) setTabletMinimized(true)
|
|
88
|
-
}, [config, isTablet])
|
|
143
|
+
}, [config, isTablet, committedActiveId])
|
|
89
144
|
|
|
90
145
|
const { primaryItems, secondaryItems } = useMemo(() => ({
|
|
91
146
|
primaryItems: config.items.filter(item => item.section !== 'secondary'),
|
|
@@ -97,16 +152,21 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
|
|
|
97
152
|
[minimized],
|
|
98
153
|
)
|
|
99
154
|
|
|
100
|
-
|
|
101
|
-
|
|
155
|
+
// There used to be an `isHydrated` gate here — `isMdUp !== undefined && ...`
|
|
156
|
+
// — meant to hold the sidebar's contents back until the media queries
|
|
157
|
+
// resolved. It could never be false: the `?? false` above had already
|
|
158
|
+
// swallowed the `undefined` it tested for. Removed rather than repaired,
|
|
159
|
+
// because repairing it would be the regression: the contents are server-
|
|
160
|
+
// rendered today, and gating them behind a client-only media query would
|
|
161
|
+
// trade a first paint of the real navigation for an empty rail.
|
|
102
162
|
useLayoutEffect(() => {
|
|
103
|
-
if (
|
|
163
|
+
if (!transitionsEnabled) {
|
|
104
164
|
const id = requestAnimationFrame(() => {
|
|
105
165
|
setTransitionsEnabled(true)
|
|
106
166
|
})
|
|
107
167
|
return () => cancelAnimationFrame(id)
|
|
108
168
|
}
|
|
109
|
-
}, [
|
|
169
|
+
}, [transitionsEnabled])
|
|
110
170
|
|
|
111
171
|
return (
|
|
112
172
|
<>
|
|
@@ -148,45 +208,45 @@ export function NavigationSidebar({ config, disabled = false }: NavigationSideba
|
|
|
148
208
|
style={{ width: sidebarWidth }}
|
|
149
209
|
aria-label="Main navigation sidebar"
|
|
150
210
|
>
|
|
151
|
-
{
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
<
|
|
157
|
-
{
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
)}
|
|
181
|
-
</
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
211
|
+
<NavigationSidebarHeader minimized={minimized} />
|
|
212
|
+
|
|
213
|
+
<div className="flex-1 flex flex-col justify-between py-4 overflow-y-auto">
|
|
214
|
+
<nav className="flex flex-col" aria-label="Primary navigation">
|
|
215
|
+
{primaryItems.map(item => (
|
|
216
|
+
<NavigationSidebarItemButton
|
|
217
|
+
key={item.id}
|
|
218
|
+
item={item}
|
|
219
|
+
isActive={item.id === committedActiveId}
|
|
220
|
+
isPending={item.id === pendingItemId}
|
|
221
|
+
showLabel={showLabel}
|
|
222
|
+
disabled={disabled}
|
|
223
|
+
onClick={handleItemClick}
|
|
224
|
+
/>
|
|
225
|
+
))}
|
|
226
|
+
</nav>
|
|
227
|
+
|
|
228
|
+
{secondaryItems.length > 0 && (
|
|
229
|
+
<nav className="flex flex-col" aria-label="Secondary navigation">
|
|
230
|
+
{secondaryItems.map(item => (
|
|
231
|
+
<NavigationSidebarItemButton
|
|
232
|
+
key={item.id}
|
|
233
|
+
item={item}
|
|
234
|
+
isActive={item.id === committedActiveId}
|
|
235
|
+
isPending={item.id === pendingItemId}
|
|
236
|
+
showLabel={showLabel}
|
|
237
|
+
disabled={disabled}
|
|
238
|
+
onClick={handleItemClick}
|
|
239
|
+
/>
|
|
240
|
+
))}
|
|
241
|
+
</nav>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<NavigationSidebarToggle
|
|
246
|
+
minimized={minimized}
|
|
247
|
+
showLabel={showLabel}
|
|
248
|
+
onToggle={handleToggle}
|
|
249
|
+
/>
|
|
190
250
|
</aside>
|
|
191
251
|
</>
|
|
192
252
|
)
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* renderer with extra plugins.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import { type ComponentType, type ReactNode } from 'react'
|
|
19
19
|
import { PageLayout } from '../layout/page-layout'
|
|
20
20
|
|
|
21
21
|
import { useRouter } from '../../embed-shims'
|
|
@@ -30,7 +30,7 @@ import { VideoBitesStrip } from '../features/video-bites-strip'
|
|
|
30
30
|
import { toStripProfile } from '../features/video-bites-shared'
|
|
31
31
|
import { useVideoWarmup } from '../features/use-video-warmup'
|
|
32
32
|
import { getCaptionsUrl } from '../features/captions-url'
|
|
33
|
-
import { RichMarkdownRenderer } from '../ui/markdown'
|
|
33
|
+
import { RichMarkdownRenderer } from '../ui/rich-markdown-renderer'
|
|
34
34
|
import { EntityTagBadges } from '../features/entity-tag-badges'
|
|
35
35
|
import { LoadError } from '../ui/error-state'
|
|
36
36
|
import { ArticleAuthorByline } from '../shared/article-author-byline'
|
|
@@ -244,26 +244,8 @@ export function OnboardingGuideDetailView({
|
|
|
244
244
|
</div>
|
|
245
245
|
)}
|
|
246
246
|
|
|
247
|
-
{/*
|
|
248
|
-
|
|
249
|
-
*
|
|
250
|
-
* KEYED DELIBERATELY. This is one member of PageLayout's static children
|
|
251
|
-
* array, and the only member whose element may be created in a SERVER
|
|
252
|
-
* component and crossed over the RSC boundary. Static JSX children are
|
|
253
|
-
* normally exempt from the key warning because `jsxs` runs
|
|
254
|
-
* `validateChildKeys` and stamps `_store.validated = 1` on each element —
|
|
255
|
-
* but a slot arriving over the wire is referenced as a lazy chunk wrapper,
|
|
256
|
-
* so validation marks the WRAPPER and the fulfilled path never copies the
|
|
257
|
-
* flag onto the revived element. It reaches the reconciler with
|
|
258
|
-
* `validated: 0, key: null` and warns, blaming PageLayout (the owner of the
|
|
259
|
-
* enclosing element) rather than anything actually at fault.
|
|
260
|
-
*
|
|
261
|
-
* A keyed Fragment emits no DOM, so rendered output is byte-identical.
|
|
262
|
-
* Do NOT "fix" this in PageLayout via `React.Children.toArray` — that would
|
|
263
|
-
* auto-key every consumer's children and change reconciliation identity for
|
|
264
|
-
* all of them, and that file carries an explicit FROZEN contract.
|
|
265
|
-
*/}
|
|
266
|
-
<Fragment key="related-content">{relatedContent}</Fragment>
|
|
247
|
+
{/* Host slot — cross-type related-content / FAQ rail. */}
|
|
248
|
+
{relatedContent}
|
|
267
249
|
</PageLayout>
|
|
268
250
|
)
|
|
269
251
|
}
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import type { ComponentType } from 'react';
|
|
21
21
|
import { PageShell, PageLayout } from '../../ui';
|
|
22
|
-
import { RichMarkdownRenderer } from '../../ui/markdown';
|
|
22
|
+
import { RichMarkdownRenderer } from '../../ui/rich-markdown-renderer';
|
|
23
23
|
import { useRouter } from '../../../embed-shims/next-navigation';
|
|
24
24
|
import { useLegalDocs, type LegalDocument } from './use-legal-docs';
|
|
25
25
|
import { formatLegalDate } from '../../../utils/format';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect,
|
|
3
|
+
import { useState, useEffect, ComponentType, type ReactNode } from 'react';
|
|
4
4
|
import Link from '../../../embed-shims/next-link';
|
|
5
5
|
import { useRouter } from '../../../embed-shims/next-navigation';
|
|
6
6
|
import { Card, CardContent } from '../../ui/card';
|
|
@@ -11,7 +11,7 @@ import { PageShell } from '../../layout/article-detail-layout';
|
|
|
11
11
|
import { PageLayout } from '../../layout/page-layout';
|
|
12
12
|
import { FadePreview } from '../../ui/fade-preview';
|
|
13
13
|
import { ReleaseChangelogSection } from '../../ui/release-changelog-section';
|
|
14
|
-
import { RichMarkdownRenderer } from '../../ui/markdown';
|
|
14
|
+
import { RichMarkdownRenderer } from '../../ui/rich-markdown-renderer';
|
|
15
15
|
import { EntityTagBadges } from '../../features/entity-tag-badges';
|
|
16
16
|
import { EntityMetadataAuthorCell } from '../../chat/entity-cards/entity-author-card';
|
|
17
17
|
import type { EntityAuthor } from '../../../types/entity-author';
|
|
@@ -582,23 +582,8 @@ export function ReleaseDetailPage({
|
|
|
582
582
|
)}
|
|
583
583
|
</div>
|
|
584
584
|
|
|
585
|
-
{/*
|
|
586
|
-
|
|
587
|
-
*
|
|
588
|
-
* KEYED for the same reason as `onboarding-guide-detail-view.tsx`: this is a
|
|
589
|
-
* member of PageLayout's static children array, and a slot element built in a
|
|
590
|
-
* SERVER component crosses the RSC boundary as a lazy chunk wrapper, so
|
|
591
|
-
* `validateChildKeys` marks the wrapper and the fulfilled path never copies
|
|
592
|
-
* `_store.validated` onto the revived element — it reaches the reconciler
|
|
593
|
-
* unvalidated and warns.
|
|
594
|
-
*
|
|
595
|
-
* Today the hub's only consumer of this view is a `'use client'` wrapper that
|
|
596
|
-
* builds the slot locally, so no element actually crosses the boundary and
|
|
597
|
-
* this is pre-emptive. It is kept because the trap is invisible from here: a
|
|
598
|
-
* future server-built slot would warn with no clue pointing at this line.
|
|
599
|
-
* A keyed Fragment emits no DOM, so output is byte-identical either way.
|
|
600
|
-
*/}
|
|
601
|
-
<Fragment key="related-content">{relatedContent}</Fragment>
|
|
585
|
+
{/* Host slot — end-of-article byline + related-content / FAQ rail. */}
|
|
586
|
+
{relatedContent}
|
|
602
587
|
</PageLayout>
|
|
603
588
|
);
|
|
604
589
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<!-- source-hash: 9a47236bd9fd4f90eece958d3708b946 -->
|
|
2
|
+
A client-side React component that renders Markdown content with GFM support, syntax highlighting, raw HTML passthrough, and a layered XSS sanitization pipeline designed for safe rendering of LLM-generated output.
|
|
3
|
+
|
|
4
|
+
## Key Components
|
|
5
|
+
|
|
6
|
+
### Rehype Plugins
|
|
7
|
+
|
|
8
|
+
- **`rehypeStripUnsafe()`** — Custom HAST sanitizer that runs after `rehype-raw`. Strips dangerous elements (`<script>`, `<style>`, `<object>`, etc.), removes `on*` event handler attributes, and blocks `javascript:` / `data:` URI schemes on all URL-bearing attributes including multi-candidate `srcset`.
|
|
9
|
+
|
|
10
|
+
- **`srcsetHasUnsafeCandidate(srcset)`** — Helper that splits `srcset` attribute values by comma and validates each URL candidate individually to prevent injection via secondary candidates.
|
|
11
|
+
|
|
12
|
+
- **`escapeUnknownHtmlT...` (truncated)** — Pre-pass that escapes unrecognized HTML-like tags in raw markdown text before `rehype-raw` processes them, preventing React warnings/errors from LLM-emitted pseudo-XML tags like `<ticket>` or `<their>`.
|
|
13
|
+
|
|
14
|
+
### URL Transform
|
|
15
|
+
|
|
16
|
+
- **`cardAwareUrlTransform(url, key)`** — Extends `react-markdown`'s default URL sanitizer to allow `card://` and `mention://` internal schemes on `href` attributes only (used by `remarkCardLinks` and `remarkMentionChips` plugins), while keeping all other dangerous schemes blocked.
|
|
17
|
+
|
|
18
|
+
### Constants
|
|
19
|
+
|
|
20
|
+
- **`SAFE_HTML_TAGS`** — Allowlist of permitted HTML tag names forwarded to React. Explicitly excludes `<video>` (handled by a separate pipeline component).
|
|
21
|
+
- **`TAG_LIKE_REGEX`** — ReDoS-safe regex (CodeQL-hardened, bounded quantifiers) for matching HTML-like tags in markdown source.
|
|
22
|
+
|
|
23
|
+
## Usage Example
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import { SimpleMarkdownRenderer } from './simple-markdown-renderer';
|
|
27
|
+
|
|
28
|
+
// Basic rendering with safe LLM output
|
|
29
|
+
<SimpleMarkdownRenderer content="## Hello\n\nThis is **markdown** with `code`." />
|
|
30
|
+
|
|
31
|
+
// With authenticated image sources and link resolution
|
|
32
|
+
<SimpleMarkdownRenderer
|
|
33
|
+
content={llmMarkdownOutput}
|
|
34
|
+
resolveLink={(href) => ({ url: href, label: href })}
|
|
35
|
+
/>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Security Model
|
|
39
|
+
|
|
40
|
+
The sanitization runs in two layers:
|
|
41
|
+
|
|
42
|
+
```mermaid
|
|
43
|
+
graph TD
|
|
44
|
+
A[Raw Markdown] --> B[escapeUnknownHtmlTags pre-pass]
|
|
45
|
+
B --> C[remark-gfm / remark-breaks]
|
|
46
|
+
C --> D[rehype-raw]
|
|
47
|
+
D --> E[rehypeStripUnsafe HAST sanitizer]
|
|
48
|
+
E --> F[rehype-highlight]
|
|
49
|
+
F --> G[Safe React Output]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
> **Source:** [`simple-markdown-renderer.tsx`](https://github.com/flamingo-stack/openframe-oss-lib/blob/main/simple-markdown-renderer.tsx)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest'
|
|
2
|
+
import { render, fireEvent, screen } from '@testing-library/react'
|
|
3
|
+
import { mockReplace, setMockSearchParams } from '../../../../vitest.setup'
|
|
4
|
+
import { TabNavigation, type TabItem } from '../tab-navigation'
|
|
5
|
+
|
|
6
|
+
beforeAll(() => {
|
|
7
|
+
// jsdom ships no ResizeObserver; the tab strip observes itself on mount to
|
|
8
|
+
// keep the edge fades and the underline in sync with its own width.
|
|
9
|
+
globalThis.ResizeObserver = class {
|
|
10
|
+
observe() {}
|
|
11
|
+
unobserve() {}
|
|
12
|
+
disconnect() {}
|
|
13
|
+
} as unknown as typeof ResizeObserver
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const TABS: TabItem[] = [
|
|
17
|
+
{ id: 'general', label: 'General' },
|
|
18
|
+
{ id: 'software', label: 'Software' },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Renders with `urlSync` and reports the tab id the BODY was last rendered with
|
|
23
|
+
* — the one thing a consumer actually observes, and the value the deferred
|
|
24
|
+
* render settles on once React has flushed.
|
|
25
|
+
*/
|
|
26
|
+
function renderNav(tabs: TabItem[]) {
|
|
27
|
+
let seen = '__never-rendered__'
|
|
28
|
+
const element = (next: TabItem[]) => (
|
|
29
|
+
<TabNavigation tabs={next} urlSync>
|
|
30
|
+
{(active) => {
|
|
31
|
+
seen = active
|
|
32
|
+
return null
|
|
33
|
+
}}
|
|
34
|
+
</TabNavigation>
|
|
35
|
+
)
|
|
36
|
+
const utils = render(element(tabs))
|
|
37
|
+
return {
|
|
38
|
+
get active() {
|
|
39
|
+
return seen
|
|
40
|
+
},
|
|
41
|
+
rerenderWith(next: TabItem[]) {
|
|
42
|
+
utils.rerender(element(next))
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('TabNavigation with urlSync', () => {
|
|
48
|
+
describe('a tab list that arrives late', () => {
|
|
49
|
+
// The sync effect is guarded on the URL value changing, so that our own
|
|
50
|
+
// `router.replace` doesn't come back looking like someone else's edit. The
|
|
51
|
+
// guard must not also swallow the tabs themselves arriving — an empty list
|
|
52
|
+
// resolves to no active tab, and the URL never moves again to correct it.
|
|
53
|
+
it('applies the deep-linked tab once the tabs arrive', () => {
|
|
54
|
+
setMockSearchParams(new URLSearchParams('tab=software'))
|
|
55
|
+
const nav = renderNav([])
|
|
56
|
+
expect(nav.active).toBe('')
|
|
57
|
+
|
|
58
|
+
nav.rerenderWith(TABS)
|
|
59
|
+
expect(nav.active).toBe('software')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('falls back to the first tab once the tabs arrive', () => {
|
|
63
|
+
const nav = renderNav([])
|
|
64
|
+
expect(nav.active).toBe('')
|
|
65
|
+
|
|
66
|
+
nav.rerenderWith(TABS)
|
|
67
|
+
expect(nav.active).toBe('general')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('leaves a tab the user picked alone when the tabs array is merely re-created', () => {
|
|
71
|
+
const nav = renderNav(TABS)
|
|
72
|
+
fireEvent.click(screen.getByText('Software'))
|
|
73
|
+
expect(nav.active).toBe('software')
|
|
74
|
+
|
|
75
|
+
// Same ids, new array + new Set identity — what a consumer passing an
|
|
76
|
+
// inline literal produces on every one of its renders.
|
|
77
|
+
nav.rerenderWith([...TABS])
|
|
78
|
+
expect(nav.active).toBe('software')
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('clicking a tab', () => {
|
|
83
|
+
// The regression this component was rewritten for: state moves on click,
|
|
84
|
+
// `router.replace` lands a tick or more later, and an effect comparing the
|
|
85
|
+
// state against the URL in between would read the tab just LEFT and push
|
|
86
|
+
// the tab back to it.
|
|
87
|
+
it('does not revert before the navigation lands', () => {
|
|
88
|
+
const nav = renderNav(TABS)
|
|
89
|
+
fireEvent.click(screen.getByText('Software'))
|
|
90
|
+
|
|
91
|
+
// The URL has deliberately NOT been updated here — this is the window in
|
|
92
|
+
// which the old effect fought its own click.
|
|
93
|
+
expect(nav.active).toBe('software')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('writes the tab to the URL without scrolling to the top', () => {
|
|
97
|
+
renderNav(TABS)
|
|
98
|
+
fireEvent.click(screen.getByText('Software'))
|
|
99
|
+
|
|
100
|
+
expect(mockReplace).toHaveBeenCalledWith('/?tab=software', { scroll: false })
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('stays put when its own write finally lands', () => {
|
|
104
|
+
const nav = renderNav(TABS)
|
|
105
|
+
fireEvent.click(screen.getByText('Software'))
|
|
106
|
+
|
|
107
|
+
setMockSearchParams(new URLSearchParams('tab=software'))
|
|
108
|
+
nav.rerenderWith(TABS)
|
|
109
|
+
expect(nav.active).toBe('software')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// Two clicks inside one navigation: the FIRST write lands while the second
|
|
113
|
+
// is still in flight. Arriving out of step like that, it is indistinguishable
|
|
114
|
+
// from an external change unless the component remembers what it wrote.
|
|
115
|
+
it('ignores an earlier write landing after a second click', () => {
|
|
116
|
+
const nav = renderNav(TABS)
|
|
117
|
+
fireEvent.click(screen.getByText('Software'))
|
|
118
|
+
fireEvent.click(screen.getByText('General'))
|
|
119
|
+
expect(nav.active).toBe('general')
|
|
120
|
+
|
|
121
|
+
setMockSearchParams(new URLSearchParams('tab=software'))
|
|
122
|
+
nav.rerenderWith(TABS)
|
|
123
|
+
expect(nav.active).toBe('general')
|
|
124
|
+
|
|
125
|
+
setMockSearchParams(new URLSearchParams('tab=general'))
|
|
126
|
+
nav.rerenderWith(TABS)
|
|
127
|
+
expect(nav.active).toBe('general')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('keeps unrelated query params', () => {
|
|
131
|
+
setMockSearchParams(new URLSearchParams('page=2&tab=general'))
|
|
132
|
+
renderNav(TABS)
|
|
133
|
+
fireEvent.click(screen.getByText('Software'))
|
|
134
|
+
|
|
135
|
+
expect(mockReplace).toHaveBeenCalledWith('/?page=2&tab=software', { scroll: false })
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('an external URL change', () => {
|
|
140
|
+
it('moves the tab (back/forward, or a link into a tab)', () => {
|
|
141
|
+
setMockSearchParams(new URLSearchParams('tab=general'))
|
|
142
|
+
const nav = renderNav(TABS)
|
|
143
|
+
expect(nav.active).toBe('general')
|
|
144
|
+
|
|
145
|
+
setMockSearchParams(new URLSearchParams('tab=software'))
|
|
146
|
+
nav.rerenderWith(TABS)
|
|
147
|
+
expect(nav.active).toBe('software')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('falls back when the URL names a tab that does not exist', () => {
|
|
151
|
+
setMockSearchParams(new URLSearchParams('tab=general'))
|
|
152
|
+
const nav = renderNav(TABS)
|
|
153
|
+
|
|
154
|
+
setMockSearchParams(new URLSearchParams('tab=nonsense'))
|
|
155
|
+
nav.rerenderWith(TABS)
|
|
156
|
+
expect(nav.active).toBe('general')
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
})
|