| author | |
| committer | |
| log | 77c9d1eb5b6a48d38f6f0a2bb4a8cff8e4752ade |
| tree | 6c1af58206584dd7cc4941cacf9fd167f6dd5ac6 |
| parent | dedcd99a9d16240327ce763b885b326aff607bdb |
| signature | Signed by PGP key B5690EEEBB952194 |
15 files changed, 678 insertions(+), 45 deletions(-)
apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx+17-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { useEffect, useState } from "react"; | |
| 1 | import { createContext, useContext, useEffect, useState } from "react"; | |
| 2 | 2 | import { Image, ScrollView, Text, useColorScheme, View } from "react-native"; |
| 3 | 3 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; |
| 4 | 4 | |
| ... | ... | @@ -9,10 +9,14 @@ import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios |
| 9 | 9 | import type { |
| 10 | 10 | MarkdownCodeHighlighter, |
| 11 | 11 | MarkdownHighlightedToken, |
| 12 | MarkdownImageRenderer, | |
| 12 | 13 | NativeMarkdownTextStyle, |
| 13 | 14 | SelectableMarkdownSkill, |
| 14 | 15 | } from "./SelectableMarkdownText.types"; |
| 15 | 16 | |
| 17 | /** Set by SelectableMarkdownText so images anywhere in the block tree can use it. */ | |
| 18 | export const MarkdownImageRendererContext = createContext<MarkdownImageRenderer | null>(null); | |
| 19 | ||
| 16 | 20 | type HighlightedCode = ReadonlyArray<ReadonlyArray<MarkdownHighlightedToken>>; |
| 17 | 21 | |
| 18 | 22 | const highlightedCodeCache = new Map<string, HighlightedCode>(); |
| ... | ... | @@ -379,6 +383,7 @@ function NativeMarkdownImage(props: { |
| 379 | 383 | readonly textStyle: NativeMarkdownTextStyle; |
| 380 | 384 | readonly onLinkPress?: (href: string) => void; |
| 381 | 385 | }) { |
| 386 | const renderImage = useContext(MarkdownImageRendererContext); | |
| 382 | 387 | const href = props.node.href; |
| 383 | 388 | if (!href) { |
| 384 | 389 | return ( |
| ... | ... | @@ -391,6 +396,17 @@ function NativeMarkdownImage(props: { |
| 391 | 396 | ); |
| 392 | 397 | } |
| 393 | 398 | |
| 399 | if (renderImage) { | |
| 400 | const rendered = renderImage({ | |
| 401 | href, | |
| 402 | alt: props.node.alt ?? null, | |
| 403 | title: props.node.title ?? null, | |
| 404 | }); | |
| 405 | if (rendered != null) { | |
| 406 | return <>{rendered}</>; | |
| 407 | } | |
| 408 | } | |
| 409 | ||
| 394 | 410 | return ( |
| 395 | 411 | <View style={{ gap: 6 }}> |
| 396 | 412 | <Image |
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx+38-33| ... | ... | @@ -8,7 +8,7 @@ import { |
| 8 | 8 | nativeMarkdownDocumentRuns, |
| 9 | 9 | nativeMarkdownWithPreservedSoftBreaks, |
| 10 | 10 | } from "./nativeMarkdownText"; |
| 11 | import { NativeMarkdownBlock } from "./NativeMarkdownBlock.ios"; | |
| 11 | import { MarkdownImageRendererContext, NativeMarkdownBlock } from "./NativeMarkdownBlock.ios"; | |
| 12 | 12 | import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; |
| 13 | 13 | import type { |
| 14 | 14 | SelectableMarkdownSkill, |
| ... | ... | @@ -20,6 +20,8 @@ const EMPTY_SKILLS: ReadonlyArray<SelectableMarkdownSkill> = []; |
| 20 | 20 | export type { |
| 21 | 21 | MarkdownCodeHighlighter, |
| 22 | 22 | MarkdownHighlightedToken, |
| 23 | MarkdownImageRenderer, | |
| 24 | MarkdownImageRequest, | |
| 23 | 25 | NativeMarkdownTextStyle, |
| 24 | 26 | SelectableMarkdownSkill, |
| 25 | 27 | SelectableMarkdownTextProps, |
| ... | ... | @@ -36,6 +38,7 @@ export function SelectableMarkdownText({ |
| 36 | 38 | highlightCode, |
| 37 | 39 | preserveSoftBreaks = false, |
| 38 | 40 | onLinkPress, |
| 41 | renderImage, | |
| 39 | 42 | marginTop = 0, |
| 40 | 43 | marginBottom = 0, |
| 41 | 44 | }: SelectableMarkdownTextProps) { |
| ... | ... | @@ -59,38 +62,40 @@ export function SelectableMarkdownText({ |
| 59 | 62 | }, [markdown, preserveSoftBreaks, skills]); |
| 60 | 63 | |
| 61 | 64 | return ( |
| 62 | // A percentage width here creates a cyclic intrinsic measurement inside | |
| 63 | // shrink-to-fit containers such as user-message bubbles. Yoga then gives | |
| 64 | // the native text node an unbounded second pass and the parent only clips | |
| 65 | // the resulting single-line width instead of reflowing it. | |
| 66 | <View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}> | |
| 67 | {chunks.map((chunk, index) => { | |
| 68 | const content = | |
| 69 | chunk.kind === "rich" ? ( | |
| 70 | <NativeMarkdownBlock | |
| 71 | node={chunk.node} | |
| 72 | skills={skills} | |
| 73 | textStyle={textStyle} | |
| 74 | highlightCode={highlightCode} | |
| 75 | onLinkPress={onLinkPress} | |
| 76 | /> | |
| 77 | ) : ( | |
| 78 | <NativeMarkdownSelectableText | |
| 79 | runs={chunk.runs} | |
| 80 | textStyle={textStyle} | |
| 81 | onLinkPress={onLinkPress} | |
| 82 | /> | |
| 83 | ); | |
| 65 | <MarkdownImageRendererContext.Provider value={renderImage ?? null}> | |
| 66 | {/* A percentage width here creates a cyclic intrinsic measurement inside | |
| 67 | shrink-to-fit containers such as user-message bubbles. Yoga then gives | |
| 68 | the native text node an unbounded second pass and the parent only clips | |
| 69 | the resulting single-line width instead of reflowing it. */} | |
| 70 | <View style={{ flexShrink: 1, minWidth: 0, marginTop, marginBottom }}> | |
| 71 | {chunks.map((chunk, index) => { | |
| 72 | const content = | |
| 73 | chunk.kind === "rich" ? ( | |
| 74 | <NativeMarkdownBlock | |
| 75 | node={chunk.node} | |
| 76 | skills={skills} | |
| 77 | textStyle={textStyle} | |
| 78 | highlightCode={highlightCode} | |
| 79 | onLinkPress={onLinkPress} | |
| 80 | /> | |
| 81 | ) : ( | |
| 82 | <NativeMarkdownSelectableText | |
| 83 | runs={chunk.runs} | |
| 84 | textStyle={textStyle} | |
| 85 | onLinkPress={onLinkPress} | |
| 86 | /> | |
| 87 | ); | |
| 84 | 88 | |
| 85 | return ( | |
| 86 | <View | |
| 87 | key={chunk.key} | |
| 88 | style={{ paddingTop: nativeMarkdownChunkSpacing(chunks[index - 1], chunk) }} | |
| 89 | > | |
| 90 | {content} | |
| 91 | </View> | |
| 92 | ); | |
| 93 | })} | |
| 94 | </View> | |
| 89 | return ( | |
| 90 | <View | |
| 91 | key={chunk.key} | |
| 92 | style={{ paddingTop: nativeMarkdownChunkSpacing(chunks[index - 1], chunk) }} | |
| 93 | > | |
| 94 | {content} | |
| 95 | </View> | |
| 96 | ); | |
| 97 | })} | |
| 98 | </View> | |
| 99 | </MarkdownImageRendererContext.Provider> | |
| 95 | 100 | ); |
| 96 | 101 | } |
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.tsx+2| ... | ... | @@ -3,6 +3,8 @@ import type { SelectableMarkdownTextProps } from "./SelectableMarkdownText.types |
| 3 | 3 | export type { |
| 4 | 4 | MarkdownCodeHighlighter, |
| 5 | 5 | MarkdownHighlightedToken, |
| 6 | MarkdownImageRenderer, | |
| 7 | MarkdownImageRequest, | |
| 6 | 8 | NativeMarkdownTextStyle, |
| 7 | 9 | SelectableMarkdownSkill, |
| 8 | 10 | SelectableMarkdownTextProps, |
apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.types.ts+15| ... | ... | @@ -36,6 +36,20 @@ export interface SelectableMarkdownSkill { |
| 36 | 36 | readonly displayName?: string | null; |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | export interface MarkdownImageRequest { | |
| 40 | readonly href: string; | |
| 41 | readonly alt: string | null; | |
| 42 | readonly title: string | null; | |
| 43 | } | |
| 44 | ||
| 45 | /** | |
| 46 | * App-supplied renderer for markdown images. The module cannot load | |
| 47 | * workspace-relative image paths itself — the host app resolves them (for | |
| 48 | * example through a signed asset URL) and returns the element to show. | |
| 49 | * Returning null falls back to the module's plain remote-URI rendering. | |
| 50 | */ | |
| 51 | export type MarkdownImageRenderer = (image: MarkdownImageRequest) => import("react").ReactNode; | |
| 52 | ||
| 39 | 53 | export interface SelectableMarkdownTextProps { |
| 40 | 54 | readonly markdown: string; |
| 41 | 55 | readonly textStyle: NativeMarkdownTextStyle; |
| ... | ... | @@ -43,6 +57,7 @@ export interface SelectableMarkdownTextProps { |
| 43 | 57 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; |
| 44 | 58 | readonly preserveSoftBreaks?: boolean; |
| 45 | 59 | readonly onLinkPress?: (href: string) => void; |
| 60 | readonly renderImage?: MarkdownImageRenderer; | |
| 46 | 61 | readonly marginTop?: number; |
| 47 | 62 | readonly marginBottom?: number; |
| 48 | 63 | } |
apps/mobile/src/features/threads/ThreadFeed.tsx+138-3| ... | ... | @@ -2,6 +2,7 @@ import * as Haptics from "expo-haptics"; |
| 2 | 2 | import { KeyboardAwareLegendList } from "@legendapp/list/keyboard"; |
| 3 | 3 | import { type LegendListRef } from "@legendapp/list/react-native"; |
| 4 | 4 | import type { EnvironmentId, MessageId, ThreadId, TurnId } from "@t3tools/contracts"; |
| 5 | import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; | |
| 5 | 6 | import { CHAT_LIST_ANCHOR_OFFSET, resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; |
| 6 | 7 | import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; |
| 7 | 8 | import { SymbolView } from "../../components/AppSymbol"; |
| ... | ... | @@ -54,6 +55,7 @@ import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; |
| 54 | 55 | import { |
| 55 | 56 | hasNativeSelectableMarkdownText, |
| 56 | 57 | SelectableMarkdownText, |
| 58 | type MarkdownImageRenderer, | |
| 57 | 59 | type NativeMarkdownTextStyle, |
| 58 | 60 | type SelectableMarkdownSkill, |
| 59 | 61 | } from "../../native/SelectableMarkdownText"; |
| ... | ... | @@ -101,7 +103,7 @@ import { |
| 101 | 103 | WORK_GROUP_TOGGLE_HEIGHT, |
| 102 | 104 | } from "./thread-work-log"; |
| 103 | 105 | import { useMarkdownCodeHighlight } from "./markdownCodeHighlightState"; |
| 104 | import { useAssetUrl } from "../../state/assets"; | |
| 106 | import { useAssetUrl, useAssetUrlState } from "../../state/assets"; | |
| 105 | 107 | import { resolveWorkspaceRelativeFilePath } from "../files/filePath"; |
| 106 | 108 | |
| 107 | 109 | const WIDE_MARKDOWN_BLOCK_OPTIONS = { |
| ... | ... | @@ -194,6 +196,98 @@ function MessageAttachmentImage(props: { |
| 194 | 196 | ); |
| 195 | 197 | } |
| 196 | 198 | |
| 199 | /** Markdown image whose src is a workspace file — loads through a signed asset URL. */ | |
| 200 | function ThreadMarkdownImage(props: { | |
| 201 | readonly environmentId: EnvironmentId; | |
| 202 | readonly threadId: ThreadId; | |
| 203 | readonly path: string; | |
| 204 | readonly alt: string | null; | |
| 205 | readonly onPressImage: (uri: string) => void; | |
| 206 | }) { | |
| 207 | const codeBackground = useThemeColor("--color-md-code-bg"); | |
| 208 | const [failedUri, setFailedUri] = useState<string | null>(null); | |
| 209 | const assetUrl = useAssetUrlState(props.environmentId, { | |
| 210 | _tag: "workspace-file", | |
| 211 | threadId: props.threadId, | |
| 212 | path: props.path, | |
| 213 | }); | |
| 214 | const uri = assetUrl._tag === "Success" ? assetUrl.url : null; | |
| 215 | const failed = assetUrl._tag === "Failure" || (uri !== null && failedUri === uri); | |
| 216 | ||
| 217 | return ( | |
| 218 | <View style={{ gap: 6 }}> | |
| 219 | {uri === null || failed ? ( | |
| 220 | <View | |
| 221 | style={{ | |
| 222 | width: "100%", | |
| 223 | aspectRatio: 16 / 9, | |
| 224 | borderRadius: 10, | |
| 225 | backgroundColor: codeBackground, | |
| 226 | alignItems: "center", | |
| 227 | justifyContent: "center", | |
| 228 | }} | |
| 229 | > | |
| 230 | {failed ? ( | |
| 231 | <Text className="text-xs text-foreground-muted">Image unavailable</Text> | |
| 232 | ) : ( | |
| 233 | <ActivityIndicator /> | |
| 234 | )} | |
| 235 | </View> | |
| 236 | ) : ( | |
| 237 | <TouchableOpacity | |
| 238 | accessibilityRole="imagebutton" | |
| 239 | accessibilityLabel={props.alt ?? "Markdown image"} | |
| 240 | activeOpacity={0.7} | |
| 241 | onPress={() => props.onPressImage(uri)} | |
| 242 | > | |
| 243 | <Image | |
| 244 | source={{ uri }} | |
| 245 | resizeMode="contain" | |
| 246 | accessible={false} | |
| 247 | onError={() => setFailedUri(uri)} | |
| 248 | style={{ | |
| 249 | width: "100%", | |
| 250 | aspectRatio: 16 / 9, | |
| 251 | borderRadius: 10, | |
| 252 | backgroundColor: codeBackground, | |
| 253 | }} | |
| 254 | /> | |
| 255 | </TouchableOpacity> | |
| 256 | )} | |
| 257 | {props.alt ? ( | |
| 258 | <Text selectable className="text-xs text-foreground-muted"> | |
| 259 | {props.alt} | |
| 260 | </Text> | |
| 261 | ) : null} | |
| 262 | </View> | |
| 263 | ); | |
| 264 | } | |
| 265 | ||
| 266 | function ThreadMarkdownImageUnavailable(props: { readonly alt: string | null }) { | |
| 267 | const codeBackground = useThemeColor("--color-md-code-bg"); | |
| 268 | return ( | |
| 269 | <View style={{ gap: 6 }}> | |
| 270 | <View | |
| 271 | style={{ | |
| 272 | width: "100%", | |
| 273 | aspectRatio: 16 / 9, | |
| 274 | borderRadius: 10, | |
| 275 | backgroundColor: codeBackground, | |
| 276 | alignItems: "center", | |
| 277 | justifyContent: "center", | |
| 278 | }} | |
| 279 | > | |
| 280 | <Text className="text-xs text-foreground-muted">Image unavailable</Text> | |
| 281 | </View> | |
| 282 | {props.alt ? ( | |
| 283 | <Text selectable className="text-xs text-foreground-muted"> | |
| 284 | {props.alt} | |
| 285 | </Text> | |
| 286 | ) : null} | |
| 287 | </View> | |
| 288 | ); | |
| 289 | } | |
| 290 | ||
| 197 | 291 | const MARKDOWN_MONO_FONT = Platform.select({ |
| 198 | 292 | ios: "ui-monospace", |
| 199 | 293 | android: "monospace", |
| ... | ... | @@ -409,7 +503,10 @@ function useReviewCommentColors(): ReviewCommentColors { |
| 409 | 503 | ); |
| 410 | 504 | } |
| 411 | 505 | |
| 412 | function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSets { | |
| 506 | function useMarkdownStyles( | |
| 507 | onLinkPress: (href: string) => void, | |
| 508 | renderImage: MarkdownImageRenderer, | |
| 509 | ): MarkdownStyleSets { | |
| 413 | 510 | const { appearance, themeAppearance } = useAppearancePreferences(); |
| 414 | 511 | const markdownFontSizes = useMemo( |
| 415 | 512 | () => resolveMarkdownFontSizes(appearance.baseFontSize), |
| ... | ... | @@ -614,6 +711,14 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe |
| 614 | 711 | })} |
| 615 | 712 | </View> |
| 616 | 713 | ), |
| 714 | image: ({ node }) => | |
| 715 | node.href | |
| 716 | ? (renderImage({ | |
| 717 | href: node.href, | |
| 718 | alt: node.alt ?? null, | |
| 719 | title: node.title ?? null, | |
| 720 | }) ?? undefined) | |
| 721 | : undefined, | |
| 617 | 722 | code_inline: ({ content }) => { |
| 618 | 723 | const value = content ?? ""; |
| 619 | 724 | return ( |
| ... | ... | @@ -787,6 +892,7 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe |
| 787 | 892 | nativeMarkdownTypography, |
| 788 | 893 | onLinkPress, |
| 789 | 894 | regularFontFamily, |
| 895 | renderImage, | |
| 790 | 896 | themeMode, |
| 791 | 897 | userBubbleForegroundMuted, |
| 792 | 898 | userBubbleSkillForeground, |
| ... | ... | @@ -806,6 +912,7 @@ function renderFeedEntry( |
| 806 | 912 | readonly onToggleTurnFold: (turnId: TurnId) => void; |
| 807 | 913 | readonly onPressImage: (uri: string, headers?: Record<string, string>) => void; |
| 808 | 914 | readonly onMarkdownLinkPress: (href: string) => void; |
| 915 | readonly renderMarkdownImage: MarkdownImageRenderer; | |
| 809 | 916 | readonly iconSubtleColor: string | import("react-native").ColorValue; |
| 810 | 917 | readonly userBubbleColor: string | import("react-native").ColorValue; |
| 811 | 918 | readonly markdownStyles: MarkdownStyleSets; |
| ... | ... | @@ -904,6 +1011,7 @@ function renderFeedEntry( |
| 904 | 1011 | reviewCommentColors={props.reviewCommentColors} |
| 905 | 1012 | skills={props.skills} |
| 906 | 1013 | onLinkPress={props.onMarkdownLinkPress} |
| 1014 | renderImage={props.renderMarkdownImage} | |
| 907 | 1015 | /> |
| 908 | 1016 | ) : null} |
| 909 | 1017 | {attachments.map((attachment) => { |
| ... | ... | @@ -955,6 +1063,7 @@ function renderFeedEntry( |
| 955 | 1063 | skills={props.skills} |
| 956 | 1064 | textStyle={styles.nativeTextStyle} |
| 957 | 1065 | onLinkPress={props.onMarkdownLinkPress} |
| 1066 | renderImage={props.renderMarkdownImage} | |
| 958 | 1067 | /> |
| 959 | 1068 | ) : ( |
| 960 | 1069 | <Markdown |
| ... | ... | @@ -1040,6 +1149,7 @@ function UserMessageContent(props: { |
| 1040 | 1149 | readonly reviewCommentColors: ReviewCommentColors; |
| 1041 | 1150 | readonly skills?: ReadonlyArray<SelectableMarkdownSkill>; |
| 1042 | 1151 | readonly onLinkPress: (href: string) => void; |
| 1152 | readonly renderImage: MarkdownImageRenderer; | |
| 1043 | 1153 | }) { |
| 1044 | 1154 | const segments = parseReviewCommentMessageSegments(props.text); |
| 1045 | 1155 | const hasReviewComment = segments.some((segment) => segment.kind === "review-comment"); |
| ... | ... | @@ -1052,6 +1162,7 @@ function UserMessageContent(props: { |
| 1052 | 1162 | textStyle={props.markdownStyles.nativeTextStyle} |
| 1053 | 1163 | preserveSoftBreaks |
| 1054 | 1164 | onLinkPress={props.onLinkPress} |
| 1165 | renderImage={props.renderImage} | |
| 1055 | 1166 | /> |
| 1056 | 1167 | ); |
| 1057 | 1168 | } |
| ... | ... | @@ -1093,6 +1204,7 @@ function UserMessageContent(props: { |
| 1093 | 1204 | textStyle={props.markdownStyles.nativeTextStyle} |
| 1094 | 1205 | preserveSoftBreaks |
| 1095 | 1206 | onLinkPress={props.onLinkPress} |
| 1207 | renderImage={props.renderImage} | |
| 1096 | 1208 | /> |
| 1097 | 1209 | ) : ( |
| 1098 | 1210 | <Markdown |
| ... | ... | @@ -1414,7 +1526,28 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1414 | 1526 | }, |
| 1415 | 1527 | [props.environmentId, props.threadId, props.workspaceRoot, navigation], |
| 1416 | 1528 | ); |
| 1417 | const markdownStyles = useMarkdownStyles(onMarkdownLinkPress); | |
| 1529 | const renderMarkdownImage = useCallback<MarkdownImageRenderer>( | |
| 1530 | (image) => { | |
| 1531 | const imageSource = classifyMarkdownImageSource(image.href, props.workspaceRoot ?? null); | |
| 1532 | if (imageSource._tag === "Direct") { | |
| 1533 | return null; | |
| 1534 | } | |
| 1535 | if (imageSource._tag === "Blocked") { | |
| 1536 | return <ThreadMarkdownImageUnavailable alt={image.alt} />; | |
| 1537 | } | |
| 1538 | return ( | |
| 1539 | <ThreadMarkdownImage | |
| 1540 | environmentId={props.environmentId} | |
| 1541 | threadId={props.threadId} | |
| 1542 | path={imageSource.path} | |
| 1543 | alt={image.alt} | |
| 1544 | onPressImage={(uri) => setExpandedImage({ uri })} | |
| 1545 | /> | |
| 1546 | ); | |
| 1547 | }, | |
| 1548 | [props.environmentId, props.threadId, props.workspaceRoot], | |
| 1549 | ); | |
| 1550 | const markdownStyles = useMarkdownStyles(onMarkdownLinkPress, renderMarkdownImage); | |
| 1418 | 1551 | const reviewCommentColors = useReviewCommentColors(); |
| 1419 | 1552 | // LegendList does not invalidate visible rows when only the renderItem closure changes. |
| 1420 | 1553 | // Keep row-local interaction props in extraData so disclosures and copy feedback repaint. |
| ... | ... | @@ -1805,6 +1938,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1805 | 1938 | onToggleTurnFold, |
| 1806 | 1939 | onPressImage, |
| 1807 | 1940 | onMarkdownLinkPress, |
| 1941 | renderMarkdownImage, | |
| 1808 | 1942 | iconSubtleColor, |
| 1809 | 1943 | userBubbleColor, |
| 1810 | 1944 | markdownStyles, |
| ... | ... | @@ -1832,6 +1966,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1832 | 1966 | onToggleWorkRow, |
| 1833 | 1967 | props.environmentId, |
| 1834 | 1968 | props.skills, |
| 1969 | renderMarkdownImage, | |
| 1835 | 1970 | ], |
| 1836 | 1971 | ); |
| 1837 | 1972 |
apps/mobile/src/native/SelectableMarkdownText.ios.tsx+2| ... | ... | @@ -8,6 +8,8 @@ import { highlightCodeSnippet } from "../features/review/shikiReviewHighlighter" |
| 8 | 8 | type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">; |
| 9 | 9 | |
| 10 | 10 | export type { |
| 11 | MarkdownImageRenderer, | |
| 12 | MarkdownImageRequest, | |
| 11 | 13 | NativeMarkdownTextStyle, |
| 12 | 14 | SelectableMarkdownSkill, |
| 13 | 15 | } from "@t3tools/mobile-markdown-text/types"; |
apps/mobile/src/native/SelectableMarkdownText.tsx+2| ... | ... | @@ -3,6 +3,8 @@ import type { SelectableMarkdownTextProps } from "@t3tools/mobile-markdown-text/ |
| 3 | 3 | type MobileSelectableMarkdownTextProps = Omit<SelectableMarkdownTextProps, "highlightCode">; |
| 4 | 4 | |
| 5 | 5 | export type { |
| 6 | MarkdownImageRenderer, | |
| 7 | MarkdownImageRequest, | |
| 6 | 8 | NativeMarkdownTextStyle, |
| 7 | 9 | SelectableMarkdownSkill, |
| 8 | 10 | } from "@t3tools/mobile-markdown-text/types"; |
apps/mobile/src/state/assets.ts+21-4| ... | ... | @@ -12,18 +12,35 @@ const EMPTY_ASSET_URL_ATOM = Atom.make(AsyncResult.initial<never, never>(false)) |
| 12 | 12 | Atom.withLabel("mobile-asset-url:empty"), |
| 13 | 13 | ); |
| 14 | 14 | |
| 15 | export function useAssetUrl( | |
| 15 | export type AssetUrlState = | |
| 16 | | { readonly _tag: "Loading" } | |
| 17 | | { readonly _tag: "Failure" } | |
| 18 | | { readonly _tag: "Success"; readonly url: string }; | |
| 19 | ||
| 20 | export function useAssetUrlState( | |
| 16 | 21 | environmentId: EnvironmentId | null, |
| 17 | 22 | resource: AssetResource | null, |
| 18 | ): string | null { | |
| 23 | ): AssetUrlState { | |
| 19 | 24 | const preparedConnection = usePreparedConnection(environmentId); |
| 20 | 25 | const result = useAtomValue( |
| 21 | 26 | environmentId === null || resource === null |
| 22 | 27 | ? EMPTY_ASSET_URL_ATOM |
| 23 | 28 | : assetEnvironment.createUrl({ environmentId, input: { resource } }), |
| 24 | 29 | ); |
| 30 | if (result._tag === "Failure") { | |
| 31 | return { _tag: "Failure" }; | |
| 32 | } | |
| 25 | 33 | if (preparedConnection._tag === "None" || result._tag !== "Success") { |
| 26 | return null; | |
| 34 | return { _tag: "Loading" }; | |
| 27 | 35 | } |
| 28 | return resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); | |
| 36 | const url = resolveAssetUrl(preparedConnection.value.httpBaseUrl, result.value.relativeUrl); | |
| 37 | return url === null ? { _tag: "Failure" } : { _tag: "Success", url }; | |
| 38 | } | |
| 39 | ||
| 40 | export function useAssetUrl( | |
| 41 | environmentId: EnvironmentId | null, | |
| 42 | resource: AssetResource | null, | |
| 43 | ): string | null { | |
| 44 | const state = useAssetUrlState(environmentId, resource); | |
| 45 | return state._tag === "Success" ? state.url : null; | |
| 29 | 46 | } |
apps/web/src/components/ChatMarkdown.tsx+116-3| ... | ... | @@ -19,6 +19,7 @@ import { |
| 19 | 19 | squashAtomCommandFailure, |
| 20 | 20 | type AtomCommandResult, |
| 21 | 21 | } from "@t3tools/client-runtime/state/runtime"; |
| 22 | import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images"; | |
| 22 | 23 | import * as Cause from "effect/Cause"; |
| 23 | 24 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 24 | 25 | import React, { |
| ... | ... | @@ -83,6 +84,7 @@ import { |
| 83 | 84 | type MarkdownFileLinkMeta, |
| 84 | 85 | } from "../markdown-links"; |
| 85 | 86 | import { readLocalApi } from "../localApi"; |
| 87 | import { useAssetUrlState } from "../assets/assetUrls"; | |
| 86 | 88 | import { cn } from "../lib/utils"; |
| 87 | 89 | import { useRightPanelStore } from "../rightPanelStore"; |
| 88 | 90 | import { useActiveEnvironmentId } from "../state/entities"; |
| ... | ... | @@ -179,6 +181,36 @@ export function orderedListGutterStyle( |
| 179 | 181 | return { "--list-gutter": `${markerWidth + 1}ch` }; |
| 180 | 182 | } |
| 181 | 183 | |
| 184 | type MarkdownHtmlAstNode = { | |
| 185 | type?: string; | |
| 186 | tagName?: string; | |
| 187 | properties?: Record<string, unknown>; | |
| 188 | children?: MarkdownHtmlAstNode[]; | |
| 189 | }; | |
| 190 | ||
| 191 | /** Preserve Windows drive paths through the protocol allowlist in rehype-sanitize. */ | |
| 192 | function rehypeNormalizeWindowsImageSrc() { | |
| 193 | return (tree: MarkdownHtmlAstNode) => { | |
| 194 | const visit = (node: MarkdownHtmlAstNode) => { | |
| 195 | const src = node.properties?.src; | |
| 196 | if ( | |
| 197 | node.type === "element" && | |
| 198 | node.tagName === "img" && | |
| 199 | typeof src === "string" && | |
| 200 | /^[A-Za-z]:[\\/]/.test(src) | |
| 201 | ) { | |
| 202 | node.properties = { | |
| 203 | ...node.properties, | |
| 204 | src: `file:///${src.replaceAll("\\", "/")}`, | |
| 205 | }; | |
| 206 | } | |
| 207 | node.children?.forEach(visit); | |
| 208 | }; | |
| 209 | ||
| 210 | visit(tree); | |
| 211 | }; | |
| 212 | } | |
| 213 | ||
| 182 | 214 | const CHAT_MARKDOWN_SANITIZE_SCHEMA = { |
| 183 | 215 | ...defaultSchema, |
| 184 | 216 | attributes: { |
| ... | ... | @@ -190,6 +222,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { |
| 190 | 222 | protocols: { |
| 191 | 223 | ...defaultSchema.protocols, |
| 192 | 224 | href: [...(defaultSchema.protocols?.href ?? []), "file"], |
| 225 | src: [...(defaultSchema.protocols?.src ?? []), "file"], | |
| 193 | 226 | }, |
| 194 | 227 | } satisfies Parameters<typeof rehypeSanitize>[0]; |
| 195 | 228 | |
| ... | ... | @@ -212,6 +245,7 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ |
| 212 | 245 | |
| 213 | 246 | const CHAT_MARKDOWN_REHYPE_PLUGINS = [ |
| 214 | 247 | rehypeRaw, |
| 248 | rehypeNormalizeWindowsImageSrc, | |
| 215 | 249 | [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], |
| 216 | 250 | ] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>; |
| 217 | 251 | |
| ... | ... | @@ -943,6 +977,62 @@ const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: |
| 943 | 977 | ); |
| 944 | 978 | }); |
| 945 | 979 | |
| 980 | const CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME = | |
| 981 | "h-auto w-auto max-h-[30rem] max-w-[min(100%,30rem)] object-contain"; | |
| 982 | ||
| 983 | // block! outranks the unlayered `.chat-markdown img { display: inline-block }` | |
| 984 | // rule, keeping workspace images on the same block layout as their placeholder. | |
| 985 | const CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME = cn( | |
| 986 | CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME, | |
| 987 | "my-1 block! rounded-lg border border-border/40", | |
| 988 | ); | |
| 989 | ||
| 990 | function ChatMarkdownImageFallback(props: { readonly alt: string }) { | |
| 991 | return ( | |
| 992 | <span className="my-1 inline-flex items-center gap-1.5 rounded-md border border-border/40 bg-muted/40 px-2 py-1 text-xs text-muted-foreground"> | |
| 993 | <TriangleAlertIcon aria-hidden className="size-3.5 shrink-0" /> | |
| 994 | {props.alt.length > 0 ? `Image unavailable · ${props.alt}` : "Image unavailable"} | |
| 995 | </span> | |
| 996 | ); | |
| 997 | } | |
| 998 | ||
| 999 | /** Markdown images whose src is a workspace file path load through a signed asset URL. */ | |
| 1000 | const ChatMarkdownWorkspaceImage = memo(function ChatMarkdownWorkspaceImage(props: { | |
| 1001 | readonly threadRef: ScopedThreadRef; | |
| 1002 | readonly path: string; | |
| 1003 | readonly alt: string; | |
| 1004 | }) { | |
| 1005 | const assetUrl = useAssetUrlState(props.threadRef.environmentId, { | |
| 1006 | _tag: "workspace-file", | |
| 1007 | threadId: props.threadRef.threadId, | |
| 1008 | path: props.path, | |
| 1009 | }); | |
| 1010 | const [failedUrl, setFailedUrl] = useState<string | null>(null); | |
| 1011 | ||
| 1012 | if (assetUrl._tag === "Failure" || (assetUrl._tag === "Success" && failedUrl === assetUrl.url)) { | |
| 1013 | return <ChatMarkdownImageFallback alt={props.alt} />; | |
| 1014 | } | |
| 1015 | if (assetUrl._tag !== "Success") { | |
| 1016 | return ( | |
| 1017 | <span | |
| 1018 | role="status" | |
| 1019 | aria-label="Loading image" | |
| 1020 | className="my-1 block aspect-video w-full max-w-[30rem] rounded-lg bg-muted/60" | |
| 1021 | /> | |
| 1022 | ); | |
| 1023 | } | |
| 1024 | return ( | |
| 1025 | <img | |
| 1026 | src={assetUrl.url} | |
| 1027 | alt={props.alt} | |
| 1028 | loading="lazy" | |
| 1029 | draggable={false} | |
| 1030 | className={CHAT_MARKDOWN_WORKSPACE_IMAGE_CLASS_NAME} | |
| 1031 | onError={() => setFailedUrl(assetUrl.url)} | |
| 1032 | /> | |
| 1033 | ); | |
| 1034 | }); | |
| 1035 | ||
| 946 | 1036 | function leadingExternalLinkTextLength(text: string): number { |
| 947 | 1037 | const protocol = /^(?:https?:\/\/)/i.exec(text)?.[0]; |
| 948 | 1038 | if (protocol) return protocol.length; |
| ... | ... | @@ -1712,9 +1802,6 @@ function ChatMarkdown({ |
| 1712 | 1802 | props.className, |
| 1713 | 1803 | ); |
| 1714 | 1804 | }, |
| 1715 | img({ node: _node, title: _title, ...props }) { | |
| 1716 | return <img {...props} />; | |
| 1717 | }, | |
| 1718 | 1805 | code({ node, children, className, ...props }) { |
| 1719 | 1806 | if (node?.properties?.dataInlineCode != null) { |
| 1720 | 1807 | const codeText = nodeToPlainText(children); |
| ... | ... | @@ -1731,6 +1818,32 @@ function ChatMarkdown({ |
| 1731 | 1818 | </code> |
| 1732 | 1819 | ); |
| 1733 | 1820 | }, |
| 1821 | img({ node: _node, title: _title, src, alt, ...props }) { | |
| 1822 | const srcString = typeof src === "string" ? normalizeMarkdownLinkDestination(src) : ""; | |
| 1823 | const altText = alt ?? ""; | |
| 1824 | const imageSource = classifyMarkdownImageSource(srcString, cwd); | |
| 1825 | if (imageSource._tag === "Direct") { | |
| 1826 | return ( | |
| 1827 | <img | |
| 1828 | {...props} | |
| 1829 | src={imageSource.uri} | |
| 1830 | alt={altText} | |
| 1831 | loading="lazy" | |
| 1832 | className={cn(props.className, CHAT_MARKDOWN_IMAGE_SIZE_CLASS_NAME)} | |
| 1833 | /> | |
| 1834 | ); | |
| 1835 | } | |
| 1836 | if (imageSource._tag === "WorkspaceFile" && threadRef) { | |
| 1837 | return ( | |
| 1838 | <ChatMarkdownWorkspaceImage | |
| 1839 | threadRef={threadRef} | |
| 1840 | path={imageSource.path} | |
| 1841 | alt={altText} | |
| 1842 | /> | |
| 1843 | ); | |
| 1844 | } | |
| 1845 | return <ChatMarkdownImageFallback alt={altText} />; | |
| 1846 | }, | |
| 1734 | 1847 | table({ node: _node, ...props }) { |
| 1735 | 1848 | return <MarkdownTable {...props} />; |
| 1736 | 1849 | }, |
apps/web/src/components/ChatMarkdown.workspace-images.test.tsx created+135| ... | ... | @@ -0,0 +1,135 @@ |
| 1 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | |
| 3 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 4 | ||
| 5 | const testState = vi.hoisted(() => ({ | |
| 6 | resources: [] as Array<unknown>, | |
| 7 | assetState: "success" as "success" | "loading", | |
| 8 | })); | |
| 9 | ||
| 10 | vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); | |
| 11 | vi.mock("../assets/assetUrls", () => ({ | |
| 12 | useAssetUrlState: (_environmentId: unknown, resource: unknown) => { | |
| 13 | testState.resources.push(resource); | |
| 14 | return testState.assetState === "loading" | |
| 15 | ? { _tag: "Loading" } | |
| 16 | : { _tag: "Success", url: "https://signed.test/workspace-image.svg" }; | |
| 17 | }, | |
| 18 | })); | |
| 19 | vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); | |
| 20 | vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); | |
| 21 | vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); | |
| 22 | vi.mock("../state/session", async (importOriginal) => ({ | |
| 23 | ...(await importOriginal<typeof import("../state/session")>()), | |
| 24 | usePreparedConnection: () => ({ _tag: "Loading" }), | |
| 25 | })); | |
| 26 | vi.mock("../state/entities", () => ({ | |
| 27 | useActiveEnvironmentId: () => EnvironmentId.make("env-windows"), | |
| 28 | })); | |
| 29 | vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() })); | |
| 30 | vi.mock("~/lib/openPullRequestLink", () => ({ useOpenChangeRequestLink: () => vi.fn() })); | |
| 31 | ||
| 32 | import ChatMarkdown from "./ChatMarkdown"; | |
| 33 | ||
| 34 | const threadRef = { | |
| 35 | environmentId: EnvironmentId.make("env-windows"), | |
| 36 | threadId: ThreadId.make("thread-windows"), | |
| 37 | }; | |
| 38 | ||
| 39 | function render(markdown: string): string { | |
| 40 | return renderToStaticMarkup( | |
| 41 | <ChatMarkdown cwd={"C:\\Users\\shawn\\project"} threadRef={threadRef} text={markdown} />, | |
| 42 | ); | |
| 43 | } | |
| 44 | ||
| 45 | function renderWithoutThread(markdown: string): string { | |
| 46 | return renderToStaticMarkup(<ChatMarkdown cwd={"C:\\Users\\shawn\\project"} text={markdown} />); | |
| 47 | } | |
| 48 | ||
| 49 | describe("ChatMarkdown workspace images", () => { | |
| 50 | beforeEach(() => { | |
| 51 | testState.resources = []; | |
| 52 | testState.assetState = "success"; | |
| 53 | }); | |
| 54 | ||
| 55 | it("loads every Windows workspace path form through a signed asset URL", () => { | |
| 56 | const imagePath = "C:/Users/shawn/project/.t3/workspace-image.svg"; | |
| 57 | const html = render( | |
| 58 | [ | |
| 59 | "", | |
| 60 | ``, | |
| 61 | ``, | |
| 62 | "", | |
| 63 | ].join("\n\n"), | |
| 64 | ); | |
| 65 | ||
| 66 | expect(testState.resources).toEqual([ | |
| 67 | { | |
| 68 | _tag: "workspace-file", | |
| 69 | threadId: threadRef.threadId, | |
| 70 | path: "C:\\Users\\shawn\\project\\.t3\\workspace-image.svg", | |
| 71 | }, | |
| 72 | { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath }, | |
| 73 | { _tag: "workspace-file", threadId: threadRef.threadId, path: imagePath }, | |
| 74 | { | |
| 75 | _tag: "workspace-file", | |
| 76 | threadId: threadRef.threadId, | |
| 77 | path: "\\\\server\\share\\workspace-image.svg", | |
| 78 | }, | |
| 79 | ]); | |
| 80 | expect(html.match(/https:\/\/signed\.test\/workspace-image\.svg/g)).toHaveLength(4); | |
| 81 | expect(html.match(/max-w-\[min\(100%,30rem\)\]/g)).toHaveLength(4); | |
| 82 | expect(html.match(/max-h-\[30rem\]/g)).toHaveLength(4); | |
| 83 | expect(html).not.toContain("Image unavailable"); | |
| 84 | }); | |
| 85 | ||
| 86 | it("normalizes a drive-absolute src in raw image HTML", () => { | |
| 87 | const html = render(String.raw`<img src="D:\screens\workspace-image.svg" alt="raw">`); | |
| 88 | ||
| 89 | expect(testState.resources).toEqual([ | |
| 90 | { | |
| 91 | _tag: "workspace-file", | |
| 92 | threadId: threadRef.threadId, | |
| 93 | path: "D:/screens/workspace-image.svg", | |
| 94 | }, | |
| 95 | ]); | |
| 96 | expect(html).toContain("https://signed.test/workspace-image.svg"); | |
| 97 | }); | |
| 98 | ||
| 99 | it("uses a static placeholder while a signed asset URL loads", () => { | |
| 100 | testState.assetState = "loading"; | |
| 101 | ||
| 102 | const html = render(""); | |
| 103 | ||
| 104 | expect(html).toContain('aria-label="Loading image"'); | |
| 105 | expect(html).not.toContain("animate-pulse"); | |
| 106 | }); | |
| 107 | ||
| 108 | it("never passes a workspace source to a raw image when thread context is unavailable", () => { | |
| 109 | const html = renderWithoutThread( | |
| 110 | "", | |
| 111 | ); | |
| 112 | ||
| 113 | expect(testState.resources).toEqual([]); | |
| 114 | expect(html).toContain("Image unavailable"); | |
| 115 | expect(html).not.toContain("file://"); | |
| 116 | }); | |
| 117 | ||
| 118 | it("blocks unsupported image schemes instead of passing them to a raw image", () => { | |
| 119 | const html = render(""); | |
| 120 | ||
| 121 | expect(testState.resources).toEqual([]); | |
| 122 | expect(html).toContain("Image unavailable"); | |
| 123 | expect(html).not.toContain("content://"); | |
| 124 | }); | |
| 125 | ||
| 126 | it("keeps remote images directly loadable", () => { | |
| 127 | const html = render(""); | |
| 128 | ||
| 129 | expect(testState.resources).toEqual([]); | |
| 130 | expect(html).toContain('src="https://example.com/image.png"'); | |
| 131 | expect(html).toContain("max-w-[min(100%,30rem)]"); | |
| 132 | expect(html).toContain("max-h-[30rem]"); | |
| 133 | expect(html).not.toContain("Image unavailable"); | |
| 134 | }); | |
| 135 | }); |
apps/web/src/markdown-links.test.ts+24| ... | ... | @@ -90,6 +90,18 @@ describe("rewriteMarkdownFileUriHref", () => { |
| 90 | 90 | ).toBe("D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx#L69"); |
| 91 | 91 | }); |
| 92 | 92 | |
| 93 | it("preserves file uri authorities as windows UNC paths", () => { | |
| 94 | expect(rewriteMarkdownFileUriHref("file://server/share/workspace-image.svg")).toBe( | |
| 95 | "\\\\server\\share\\workspace-image.svg", | |
| 96 | ); | |
| 97 | }); | |
| 98 | ||
| 99 | it("treats a localhost file uri as a local path", () => { | |
| 100 | expect(rewriteMarkdownFileUriHref("file://localhost/home/me/notes.md")).toBe( | |
| 101 | "/home/me/notes.md", | |
| 102 | ); | |
| 103 | }); | |
| 104 | ||
| 93 | 105 | it("unwraps angle-bracketed file uri hrefs", () => { |
| 94 | 106 | expect( |
| 95 | 107 | rewriteMarkdownFileUriHref(" <file:///D:/Programme/t3code/apps/web/src/markdown-links.ts> "), |
| ... | ... | @@ -138,6 +150,18 @@ describe("resolveMarkdownFileLinkTarget", () => { |
| 138 | 150 | ); |
| 139 | 151 | }); |
| 140 | 152 | |
| 153 | it("resolves file uri authorities as windows UNC paths", () => { | |
| 154 | expect(resolveMarkdownFileLinkTarget("file://server/share/workspace-image.svg")).toBe( | |
| 155 | "\\\\server\\share\\workspace-image.svg", | |
| 156 | ); | |
| 157 | }); | |
| 158 | ||
| 159 | it("resolves a localhost file uri as a local path", () => { | |
| 160 | expect(resolveMarkdownFileLinkTarget("file://localhost/home/me/notes.md")).toBe( | |
| 161 | "/home/me/notes.md", | |
| 162 | ); | |
| 163 | }); | |
| 164 | ||
| 141 | 165 | it("formats tooltip display paths relative to the cwd when possible", () => { |
| 142 | 166 | expect( |
| 143 | 167 | resolveMarkdownFileLinkMeta( |
apps/web/src/markdown-links.ts+4-1| ... | ... | @@ -108,7 +108,10 @@ function parseFileUrlHref( |
| 108 | 108 | const parsed = new URL(href); |
| 109 | 109 | if (parsed.protocol.toLowerCase() !== "file:") return null; |
| 110 | 110 | |
| 111 | const rawPath = parsed.pathname; | |
| 111 | const uncHostname = parsed.hostname.toLowerCase() === "localhost" ? "" : parsed.hostname; | |
| 112 | const rawPath = uncHostname | |
| 113 | ? `\\\\${uncHostname}${parsed.pathname.replaceAll("/", "\\")}` | |
| 114 | : parsed.pathname; | |
| 112 | 115 | if (rawPath.length === 0) return null; |
| 113 | 116 | |
| 114 | 117 | // Browser URL parser encodes "C:/foo" as "/C:/foo" for file URLs. |
packages/client-runtime/package.json+4| ... | ... | @@ -15,6 +15,10 @@ |
| 15 | 15 | "types": "./src/environment/index.ts", |
| 16 | 16 | "default": "./src/environment/index.ts" |
| 17 | 17 | }, |
| 18 | "./markdown-images": { | |
| 19 | "types": "./src/markdownImages.ts", | |
| 20 | "default": "./src/markdownImages.ts" | |
| 21 | }, | |
| 18 | 22 | "./errors": { |
| 19 | 23 | "types": "./src/errors/index.ts", |
| 20 | 24 | "default": "./src/errors/index.ts" |
packages/client-runtime/src/markdownImages.test.ts created+62| ... | ... | @@ -0,0 +1,62 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { classifyMarkdownImageSource } from "./markdownImages.js"; | |
| 4 | ||
| 5 | describe("classifyMarkdownImageSource", () => { | |
| 6 | it.each([ | |
| 7 | "https://example.com/image.png", | |
| 8 | "HTTP://example.com/image.png", | |
| 9 | "data:image/png;base64,AAAA", | |
| 10 | "blob:https://app.t3.codes/image-id", | |
| 11 | "//cdn.example.com/image.png", | |
| 12 | ])("keeps %s directly loadable", (uri) => { | |
| 13 | expect(classifyMarkdownImageSource(uri, "/workspace/project")).toEqual({ | |
| 14 | _tag: "Direct", | |
| 15 | uri, | |
| 16 | }); | |
| 17 | }); | |
| 18 | ||
| 19 | it.each([ | |
| 20 | ["images/result.png", "/workspace/project", "/workspace/project/images/result.png"], | |
| 21 | ["./images/result.png", "/workspace/project", "/workspace/project/./images/result.png"], | |
| 22 | [ | |
| 23 | "images/result.png", | |
| 24 | "C:\\Users\\dara\\project", | |
| 25 | "C:\\Users\\dara\\project\\images\\result.png", | |
| 26 | ], | |
| 27 | [ | |
| 28 | "images\\result.png", | |
| 29 | "C:\\Users\\dara\\project", | |
| 30 | "C:\\Users\\dara\\project\\images\\result.png", | |
| 31 | ], | |
| 32 | ["/workspace/project/image.png", null, "/workspace/project/image.png"], | |
| 33 | ["/C:/Users/dara/project/image.png", null, "C:/Users/dara/project/image.png"], | |
| 34 | ["C:/Users/dara/project/image.png", null, "C:/Users/dara/project/image.png"], | |
| 35 | ["\\\\server\\share\\image.png", null, "\\\\server\\share\\image.png"], | |
| 36 | ["file:///workspace/project/image%20one.png", null, "/workspace/project/image one.png"], | |
| 37 | ["file:///C:/Users/dara/project/image.png", null, "C:/Users/dara/project/image.png"], | |
| 38 | ["file://localhost/C:/Users/dara/project/image.png", null, "C:/Users/dara/project/image.png"], | |
| 39 | ["file://server/share/image.png", null, "\\\\server\\share\\image.png"], | |
| 40 | ])("maps %s to a workspace file", (source, workspaceRoot, path) => { | |
| 41 | expect(classifyMarkdownImageSource(source, workspaceRoot)).toEqual({ | |
| 42 | _tag: "WorkspaceFile", | |
| 43 | path, | |
| 44 | }); | |
| 45 | }); | |
| 46 | ||
| 47 | it.each([ | |
| 48 | null, | |
| 49 | "", | |
| 50 | "#image", | |
| 51 | "?image=1", | |
| 52 | "image.png", | |
| 53 | "~/image.png", | |
| 54 | "javascript:alert(1)", | |
| 55 | "ftp://example.com/image.png", | |
| 56 | "content://media/image/1", | |
| 57 | "custom:image.png", | |
| 58 | "file://%", | |
| 59 | ])("blocks unsupported or unresolved source %s", (source) => { | |
| 60 | expect(classifyMarkdownImageSource(source)).toEqual({ _tag: "Blocked" }); | |
| 61 | }); | |
| 62 | }); |
packages/client-runtime/src/markdownImages.ts created+98| ... | ... | @@ -0,0 +1,98 @@ |
| 1 | const DIRECT_IMAGE_SOURCE_PATTERN = /^(?:https?:|data:|blob:|\/\/)/i; | |
| 2 | const URI_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/; | |
| 3 | const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; | |
| 4 | ||
| 5 | export type MarkdownImageSource = | |
| 6 | | { readonly _tag: "Direct"; readonly uri: string } | |
| 7 | | { readonly _tag: "WorkspaceFile"; readonly path: string } | |
| 8 | | { readonly _tag: "Blocked" }; | |
| 9 | ||
| 10 | function safeDecode(value: string): string { | |
| 11 | try { | |
| 12 | return decodeURIComponent(value); | |
| 13 | } catch { | |
| 14 | return value; | |
| 15 | } | |
| 16 | } | |
| 17 | ||
| 18 | function normalizeSource(value: string): string { | |
| 19 | const trimmed = value.trim(); | |
| 20 | return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed; | |
| 21 | } | |
| 22 | ||
| 23 | function normalizeWindowsDrivePath(value: string): string { | |
| 24 | return /^\/[A-Za-z]:[\\/]/.test(value) ? value.slice(1) : value; | |
| 25 | } | |
| 26 | ||
| 27 | function parseFileUrl(value: string): string | null { | |
| 28 | try { | |
| 29 | const parsed = new URL(value); | |
| 30 | if (parsed.protocol.toLowerCase() !== "file:") return null; | |
| 31 | ||
| 32 | if (parsed.hostname.length > 0 && parsed.hostname.toLowerCase() !== "localhost") { | |
| 33 | const pathname = safeDecode(parsed.pathname).replaceAll("/", "\\"); | |
| 34 | return `\\\\${safeDecode(parsed.hostname)}${pathname}`; | |
| 35 | } | |
| 36 | ||
| 37 | const pathname = safeDecode(parsed.pathname); | |
| 38 | if (pathname.length === 0) return null; | |
| 39 | return normalizeWindowsDrivePath(pathname); | |
| 40 | } catch { | |
| 41 | return null; | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | function stripSearchAndHash(value: string): string { | |
| 46 | const searchIndex = value.indexOf("?"); | |
| 47 | const hashIndex = value.indexOf("#"); | |
| 48 | const end = [searchIndex, hashIndex] | |
| 49 | .filter((index) => index >= 0) | |
| 50 | .reduce((lowest, index) => Math.min(lowest, index), value.length); | |
| 51 | return value.slice(0, end); | |
| 52 | } | |
| 53 | ||
| 54 | function joinWorkspacePath(workspaceRoot: string, relativePath: string): string { | |
| 55 | const windows = | |
| 56 | WINDOWS_DRIVE_PATH_PATTERN.test(workspaceRoot) || workspaceRoot.startsWith("\\\\"); | |
| 57 | const separator = windows ? "\\" : "/"; | |
| 58 | const root = workspaceRoot.replace(/[\\/]+$/, ""); | |
| 59 | const path = relativePath.replace(/[\\/]/g, separator).replace(/^[\\/]+/, ""); | |
| 60 | return `${root}${separator}${path}`; | |
| 61 | } | |
| 62 | ||
| 63 | /** | |
| 64 | * Classifies a markdown image source by where its bytes must be loaded from. | |
| 65 | * Filesystem paths belong to the environment host and must never reach a | |
| 66 | * browser or native image component without first becoming a signed asset URL. | |
| 67 | */ | |
| 68 | export function classifyMarkdownImageSource( | |
| 69 | value: string | null | undefined, | |
| 70 | workspaceRoot?: string | null, | |
| 71 | ): MarkdownImageSource { | |
| 72 | if (value === null || value === undefined) return { _tag: "Blocked" }; | |
| 73 | ||
| 74 | const source = normalizeSource(value); | |
| 75 | if (source.length === 0 || source.startsWith("#") || source.startsWith("?")) { | |
| 76 | return { _tag: "Blocked" }; | |
| 77 | } | |
| 78 | if (DIRECT_IMAGE_SOURCE_PATTERN.test(source)) { | |
| 79 | return { _tag: "Direct", uri: source }; | |
| 80 | } | |
| 81 | ||
| 82 | if (/^file:/i.test(source)) { | |
| 83 | const path = parseFileUrl(source); | |
| 84 | return path === null ? { _tag: "Blocked" } : { _tag: "WorkspaceFile", path }; | |
| 85 | } | |
| 86 | ||
| 87 | const path = normalizeWindowsDrivePath(safeDecode(stripSearchAndHash(source))); | |
| 88 | if (path.length === 0) return { _tag: "Blocked" }; | |
| 89 | if (path.startsWith("/") || WINDOWS_DRIVE_PATH_PATTERN.test(path) || path.startsWith("\\\\")) { | |
| 90 | return { _tag: "WorkspaceFile", path }; | |
| 91 | } | |
| 92 | if (URI_SCHEME_PATTERN.test(path) || path.startsWith("~/") || path.startsWith("~\\")) { | |
| 93 | return { _tag: "Blocked" }; | |
| 94 | } | |
| 95 | if (!workspaceRoot) return { _tag: "Blocked" }; | |
| 96 | ||
| 97 | return { _tag: "WorkspaceFile", path: joinWorkspacePath(workspaceRoot, path) }; | |
| 98 | } |