| author | |
| committer | |
| log | 1d013c7ca1edc843bd3b5e23fd6573aeeaf9b11d |
| tree | f11ffc4be0a4f74d81fd22f4baef7b3a60b17832 |
| parent | d3bec62ec167e783412effbc929da77167f08c4d |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 278 insertions(+), 34 deletions(-)
apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts+72-6| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; | 1 | import type { MarkdownNode } from "react-native-nitro-markdown/headless"; |
| 2 | import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links"; | ||
| 2 | import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; | 3 | import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; |
| 3 | import { imageMimeType } from "@t3tools/shared/image"; | 4 | import { imageMimeType } from "@t3tools/shared/image"; |
| 4 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; | 5 | import { isWindowsAbsolutePath } from "@t3tools/shared/path"; |
| ... | @@ -109,7 +110,8 @@ export function nativeMarkdownContextCopyRanges( | ... | @@ -109,7 +110,8 @@ export function nativeMarkdownContextCopyRanges( |
| 109 | }>, | 110 | }>, |
| 110 | ) { | 111 | ) { |
| 111 | let offset = 0; | 112 | let offset = 0; |
| 112 | return runs.flatMap(({ run, text, inlineImageLength }) => { | 113 | const ranges: Array<{ start: number; end: number; text: string }> = []; |
| 114 | for (const [index, { run, text, inlineImageLength }] of runs.entries()) { | ||
| 113 | const start = offset; | 115 | const start = offset; |
| 114 | offset += text.length + inlineImageLength; | 116 | offset += text.length + inlineImageLength; |
| 115 | const reference = parseComposerContextHref(run.href ?? ""); | 117 | const reference = parseComposerContextHref(run.href ?? ""); |
| ... | @@ -117,11 +119,25 @@ export function nativeMarkdownContextCopyRanges( | ... | @@ -117,11 +119,25 @@ export function nativeMarkdownContextCopyRanges( |
| 117 | ? formatComposerContextReference({ ...reference, label: run.text }) | 119 | ? formatComposerContextReference({ ...reference, label: run.text }) |
| 118 | : run.skillName | 120 | : run.skillName |
| 119 | ? `$${run.skillName}` | 121 | ? `$${run.skillName}` |
| 120 | : run.fileIcon && run.href | 122 | : run.sourceText !== undefined |
| 121 | ? (run.sourceText ?? `[${run.text}](<${run.href}>)`) | 123 | ? run.sourceText |
| 122 | : null; | 124 | : run.fileIcon && run.href |
| 123 | return source === null ? [] : [{ start, end: offset, text: source }]; | 125 | ? `[${run.text}](<${run.href}>)` |
| 124 | }); | 126 | : null; |
| 127 | if (source === null) continue; | ||
| 128 | const previous = ranges.at(-1); | ||
| 129 | if ( | ||
| 130 | run.sourceText !== undefined && | ||
| 131 | !runs[index - 1]?.run.fileIcon && | ||
| 132 | previous?.end === start && | ||
| 133 | previous.text === source | ||
| 134 | ) { | ||
| 135 | previous.end = offset; | ||
| 136 | } else { | ||
| 137 | ranges.push({ start, end: offset, text: source }); | ||
| 138 | } | ||
| 139 | } | ||
| 140 | return ranges; | ||
| 125 | } | 141 | } |
| 126 | 142 | ||
| 127 | import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types"; | 143 | import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types"; |
| ... | @@ -181,6 +197,7 @@ interface RunContext { | ... | @@ -181,6 +197,7 @@ interface RunContext { |
| 181 | readonly href?: string; | 197 | readonly href?: string; |
| 182 | readonly externalHost?: string; | 198 | readonly externalHost?: string; |
| 183 | readonly fileIcon?: MarkdownFileIcon; | 199 | readonly fileIcon?: MarkdownFileIcon; |
| 200 | readonly sourceText?: string; | ||
| 184 | readonly role?: NativeMarkdownTextRun["role"]; | 201 | readonly role?: NativeMarkdownTextRun["role"]; |
| 185 | readonly headingLevel?: number; | 202 | readonly headingLevel?: number; |
| 186 | readonly depth?: number; | 203 | readonly depth?: number; |
| ... | @@ -268,6 +285,7 @@ function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun) | ... | @@ -268,6 +285,7 @@ function sameRunStyle(left: NativeMarkdownTextRun, right: NativeMarkdownTextRun) |
| 268 | left.href === right.href && | 285 | left.href === right.href && |
| 269 | left.externalHost === right.externalHost && | 286 | left.externalHost === right.externalHost && |
| 270 | left.fileIcon === right.fileIcon && | 287 | left.fileIcon === right.fileIcon && |
| 288 | left.sourceText === right.sourceText && | ||
| 271 | left.skillName === right.skillName && | 289 | left.skillName === right.skillName && |
| 272 | left.skillLabel === right.skillLabel && | 290 | left.skillLabel === right.skillLabel && |
| 273 | left.role === right.role && | 291 | left.role === right.role && |
| ... | @@ -298,6 +316,7 @@ function appendRun( | ... | @@ -298,6 +316,7 @@ function appendRun( |
| 298 | ...(context.href ? { href: context.href } : {}), | 316 | ...(context.href ? { href: context.href } : {}), |
| 299 | ...(context.externalHost ? { externalHost: context.externalHost } : {}), | 317 | ...(context.externalHost ? { externalHost: context.externalHost } : {}), |
| 300 | ...(context.fileIcon ? { fileIcon: context.fileIcon } : {}), | 318 | ...(context.fileIcon ? { fileIcon: context.fileIcon } : {}), |
| 319 | ...(context.sourceText !== undefined ? { sourceText: context.sourceText } : {}), | ||
| 301 | ...(context.role ? { role: context.role } : {}), | 320 | ...(context.role ? { role: context.role } : {}), |
| 302 | ...(context.headingLevel ? { headingLevel: context.headingLevel } : {}), | 321 | ...(context.headingLevel ? { headingLevel: context.headingLevel } : {}), |
| 303 | ...(context.depth ? { depth: context.depth } : {}), | 322 | ...(context.depth ? { depth: context.depth } : {}), |
| ... | @@ -432,6 +451,36 @@ function nodeTextContent(node: MarkdownNode): string { | ... | @@ -432,6 +451,36 @@ function nodeTextContent(node: MarkdownNode): string { |
| 432 | return (node.children ?? []).map(nodeTextContent).join(""); | 451 | return (node.children ?? []).map(nodeTextContent).join(""); |
| 433 | } | 452 | } |
| 434 | 453 | ||
| 454 | function fileLinkLabelMarkdown(node: MarkdownNode): string { | ||
| 455 | const children = (node.children ?? []).map(fileLinkLabelMarkdown).join(""); | ||
| 456 | switch (node.type) { | ||
| 457 | case "bold": | ||
| 458 | return `**${children}**`; | ||
| 459 | case "italic": | ||
| 460 | return `*${children}*`; | ||
| 461 | case "strikethrough": | ||
| 462 | return `~~${children}~~`; | ||
| 463 | case "code_inline": { | ||
| 464 | const content = nodeTextContent(node); | ||
| 465 | const fence = "`".repeat( | ||
| 466 | Math.max(0, ...(content.match(/`+/g) ?? []).map((run) => run.length)) + 1, | ||
| 467 | ); | ||
| 468 | const padding = /^`|`$|^ .* $/.test(content) && content.trim().length > 0 ? " " : ""; | ||
| 469 | return `${fence}${padding}${content}${padding}${fence}`; | ||
| 470 | } | ||
| 471 | case "soft_break": | ||
| 472 | return "\n"; | ||
| 473 | case "line_break": | ||
| 474 | return " \n"; | ||
| 475 | case "text": | ||
| 476 | return textNodeContent(nodeTextContent(node)).replace(/[\\[\]*_`~]/g, "\\$&"); | ||
| 477 | case "image": | ||
| 478 | return `![${(node.alt ?? "").replace(/[\\[\]]/g, "\\$&")}](<${node.href ?? ""}>)`; | ||
| 479 | default: | ||
| 480 | return node.content ?? children; | ||
| 481 | } | ||
| 482 | } | ||
| 483 | |||
| 435 | function appendNode( | 484 | function appendNode( |
| 436 | runs: NativeMarkdownTextRun[], | 485 | runs: NativeMarkdownTextRun[], |
| 437 | node: MarkdownNode, | 486 | node: MarkdownNode, |
| ... | @@ -482,6 +531,23 @@ function appendNode( | ... | @@ -482,6 +531,23 @@ function appendNode( |
| 482 | } | 531 | } |
| 483 | const presentation = resolveMarkdownLinkPresentation(node.href ?? ""); | 532 | const presentation = resolveMarkdownLinkPresentation(node.href ?? ""); |
| 484 | if (presentation.kind === "file") { | 533 | if (presentation.kind === "file") { |
| 534 | const label = textNodeContent(nodeTextContent(node)); | ||
| 535 | if (!isMarkdownFileLinkLabel(label, presentation.href)) { | ||
| 536 | const fileRuns: NativeMarkdownTextRun[] = []; | ||
| 537 | const fileContext = { | ||
| 538 | ...context, | ||
| 539 | href: presentation.href, | ||
| 540 | sourceText: `[${fileLinkLabelMarkdown(node)}](<${presentation.href}>)`, | ||
| 541 | }; | ||
| 542 | appendChildren(fileRuns, node, fileContext); | ||
| 543 | appendRun(fileRuns, " ", fileContext); | ||
| 544 | appendRun(fileRuns, presentation.label, { | ||
| 545 | ...fileContext, | ||
| 546 | fileIcon: presentation.icon, | ||
| 547 | }); | ||
| 548 | runs.push(...fileRuns); | ||
| 549 | return runs; | ||
| 550 | } | ||
| 485 | return appendRun(runs, presentation.label, { | 551 | return appendRun(runs, presentation.label, { |
| 486 | ...context, | 552 | ...context, |
| 487 | href: presentation.href, | 553 | href: presentation.href, |
apps/mobile/src/features/threads/ThreadFeed.tsx+39-23| ... | @@ -36,6 +36,8 @@ import { | ... | @@ -36,6 +36,8 @@ import { |
| 36 | type CodexArtifactTemplate, | 36 | type CodexArtifactTemplate, |
| 37 | } from "@t3tools/client-runtime/codex-artifact-templates"; | 37 | } from "@t3tools/client-runtime/codex-artifact-templates"; |
| 38 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; | 38 | import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; |
| 39 | import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links"; | ||
| 40 | import { getTextContent, type MarkdownNode } from "react-native-nitro-markdown/headless"; | ||
| 39 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; | 41 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; |
| 40 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 42 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 41 | import { | 43 | import { |
| ... | @@ -721,7 +723,7 @@ interface MarkdownStyleSet { | ... | @@ -721,7 +723,7 @@ interface MarkdownStyleSet { |
| 721 | } | 723 | } |
| 722 | 724 | ||
| 723 | const failedMarkdownFaviconHosts = new Set<string>(); | 725 | const failedMarkdownFaviconHosts = new Set<string>(); |
| 724 | const MarkdownLinkLabelContext = createContext(false); | 726 | const MarkdownLinkLabelContext = createContext<"file" | "other" | null>(null); |
| 725 | const markdownLinkStyles = StyleSheet.create({ | 727 | const markdownLinkStyles = StyleSheet.create({ |
| 726 | inlineIcon: { | 728 | inlineIcon: { |
| 727 | width: 14, | 729 | width: 14, |
| ... | @@ -813,6 +815,22 @@ function MarkdownInlineCode(props: { | ... | @@ -813,6 +815,22 @@ function MarkdownInlineCode(props: { |
| 813 | ); | 815 | ); |
| 814 | } | 816 | } |
| 815 | 817 | ||
| 818 | function MarkdownImage(props: { | ||
| 819 | readonly node: MarkdownNode; | ||
| 820 | readonly renderImage: MarkdownImageRenderer; | ||
| 821 | }) { | ||
| 822 | const insideLink = useContext(MarkdownLinkLabelContext); | ||
| 823 | if (insideLink === "file") | ||
| 824 | return <NativeText>{props.node.alt ?? props.node.title ?? ""}</NativeText>; | ||
| 825 | return props.node.href | ||
| 826 | ? props.renderImage({ | ||
| 827 | href: props.node.href, | ||
| 828 | alt: props.node.alt ?? null, | ||
| 829 | title: props.node.title ?? null, | ||
| 830 | }) | ||
| 831 | : null; | ||
| 832 | } | ||
| 833 | |||
| 816 | const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record< | 834 | const ARTIFACT_TEMPLATE_SYMBOL_BY_KIND: Record< |
| 817 | CodexArtifactTemplate["artifactKind"], | 835 | CodexArtifactTemplate["artifactKind"], |
| 818 | AppSymbolName | 836 | AppSymbolName |
| ... | @@ -1183,26 +1201,31 @@ function useMarkdownStyles( | ... | @@ -1183,26 +1201,31 @@ function useMarkdownStyles( |
| 1183 | preserveSoftBreaks: boolean, | 1201 | preserveSoftBreaks: boolean, |
| 1184 | highlightCode: boolean, | 1202 | highlightCode: boolean, |
| 1185 | ): CustomRenderers => ({ | 1203 | ): CustomRenderers => ({ |
| 1186 | link: ({ children, href = "" }) => { | 1204 | link: ({ children, node, href = "" }) => { |
| 1187 | const presentation = resolveMarkdownLinkPresentation(href); | 1205 | const presentation = resolveMarkdownLinkPresentation(href); |
| 1188 | if (presentation.kind === "file") { | 1206 | if (presentation.kind === "file") { |
| 1189 | return ( | 1207 | return ( |
| 1190 | <NativeText | 1208 | <MarkdownLinkLabelContext.Provider value="file"> |
| 1191 | className="font-t3-bold" | 1209 | <NativeText onPress={() => onLinkPress(href)} style={{ color: inlineTextColor }}> |
| 1192 | onPress={() => onLinkPress(href)} | 1210 | {!isMarkdownFileLinkLabel(getTextContent(node), href) && <>{children} </>} |
| 1193 | style={{ color: inlineTextColor }} | 1211 | <NativeText |
| 1194 | > | 1212 | className="font-t3-bold" |
| 1195 | <Image | 1213 | onPress={() => onLinkPress(href)} |
| 1196 | source={markdownFileIconSource(presentation.icon)} | 1214 | style={{ color: inlineTextColor }} |
| 1197 | style={markdownLinkStyles.inlineIcon} | 1215 | > |
| 1198 | /> | 1216 | <Image |
| 1199 | {presentation.label} | 1217 | source={markdownFileIconSource(presentation.icon)} |
| 1200 | </NativeText> | 1218 | style={markdownLinkStyles.inlineIcon} |
| 1219 | /> | ||
| 1220 | {presentation.label} | ||
| 1221 | </NativeText> | ||
| 1222 | </NativeText> | ||
| 1223 | </MarkdownLinkLabelContext.Provider> | ||
| 1201 | ); | 1224 | ); |
| 1202 | } | 1225 | } |
| 1203 | if (presentation.kind === "external") { | 1226 | if (presentation.kind === "external") { |
| 1204 | return ( | 1227 | return ( |
| 1205 | <MarkdownLinkLabelContext.Provider value> | 1228 | <MarkdownLinkLabelContext.Provider value="other"> |
| 1206 | <MarkdownExternalLink | 1229 | <MarkdownExternalLink |
| 1207 | href={presentation.href} | 1230 | href={presentation.href} |
| 1208 | host={presentation.host} | 1231 | host={presentation.host} |
| ... | @@ -1216,7 +1239,7 @@ function useMarkdownStyles( | ... | @@ -1216,7 +1239,7 @@ function useMarkdownStyles( |
| 1216 | } | 1239 | } |
| 1217 | const linkHref = presentation.href; | 1240 | const linkHref = presentation.href; |
| 1218 | return ( | 1241 | return ( |
| 1219 | <MarkdownLinkLabelContext.Provider value> | 1242 | <MarkdownLinkLabelContext.Provider value="other"> |
| 1220 | <NativeText | 1243 | <NativeText |
| 1221 | className="underline" | 1244 | className="underline" |
| 1222 | onPress={ | 1245 | onPress={ |
| ... | @@ -1265,14 +1288,7 @@ function useMarkdownStyles( | ... | @@ -1265,14 +1288,7 @@ function useMarkdownStyles( |
| 1265 | })} | 1288 | })} |
| 1266 | </View> | 1289 | </View> |
| 1267 | ), | 1290 | ), |
| 1268 | image: ({ node }) => | 1291 | image: ({ node }) => <MarkdownImage node={node} renderImage={renderImage} />, |
| 1269 | node.href | ||
| 1270 | ? (renderImage({ | ||
| 1271 | href: node.href, | ||
| 1272 | alt: node.alt ?? null, | ||
| 1273 | title: node.title ?? null, | ||
| 1274 | }) ?? undefined) | ||
| 1275 | : undefined, | ||
| 1276 | code_inline: ({ content }) => ( | 1292 | code_inline: ({ content }) => ( |
| 1277 | <MarkdownInlineCode | 1293 | <MarkdownInlineCode |
| 1278 | content={content ?? ""} | 1294 | content={content ?? ""} |
apps/mobile/src/lib/nativeMarkdownText.test.ts+103-1| ... | @@ -138,7 +138,7 @@ describe("nativeMarkdownTextRuns", () => { | ... | @@ -138,7 +138,7 @@ describe("nativeMarkdownTextRuns", () => { |
| 138 | { | 138 | { |
| 139 | type: "link", | 139 | type: "link", |
| 140 | href: "file:///repo/README.md#L12", | 140 | href: "file:///repo/README.md#L12", |
| 141 | children: [{ type: "text", content: "ignored label" }], | 141 | children: [{ type: "text", content: "validates the input" }], |
| 142 | }, | 142 | }, |
| 143 | ], | 143 | ], |
| 144 | }; | 144 | }; |
| ... | @@ -150,14 +150,116 @@ describe("nativeMarkdownTextRuns", () => { | ... | @@ -150,14 +150,116 @@ describe("nativeMarkdownTextRuns", () => { |
| 150 | externalHost: "example.com", | 150 | externalHost: "example.com", |
| 151 | }, | 151 | }, |
| 152 | { text: " " }, | 152 | { text: " " }, |
| 153 | { | ||
| 154 | text: "validates the input ", | ||
| 155 | href: "file:///repo/README.md#L12", | ||
| 156 | sourceText: "[validates the input](<file:///repo/README.md#L12>)", | ||
| 157 | }, | ||
| 153 | { | 158 | { |
| 154 | text: "README.md:12", | 159 | text: "README.md:12", |
| 155 | href: "file:///repo/README.md#L12", | 160 | href: "file:///repo/README.md#L12", |
| 156 | fileIcon: "markdown", | 161 | fileIcon: "markdown", |
| 162 | sourceText: "[validates the input](<file:///repo/README.md#L12>)", | ||
| 163 | }, | ||
| 164 | ]); | ||
| 165 | }); | ||
| 166 | |||
| 167 | it.each([true, false])("copies descriptive file links with collapsed chips=%s", (collapsed) => { | ||
| 168 | const link: MarkdownNode = { | ||
| 169 | type: "link", | ||
| 170 | href: "/repo/src/example.ts:12", | ||
| 171 | children: [ | ||
| 172 | { type: "text", content: "validates " }, | ||
| 173 | { type: "bold", children: [{ type: "text", content: "the input" }] }, | ||
| 174 | ], | ||
| 175 | }; | ||
| 176 | const runs = nativeMarkdownTextRuns({ type: "paragraph", children: [link, link] }); | ||
| 177 | expect(runs.map((run) => run.text).join("")).toBe( | ||
| 178 | "validates the input example.ts:12validates the input example.ts:12", | ||
| 179 | ); | ||
| 180 | expect(runs.filter((run) => run.bold).map((run) => run.text)).toEqual([ | ||
| 181 | "the input", | ||
| 182 | "the input", | ||
| 183 | ]); | ||
| 184 | const ranges = nativeMarkdownContextCopyRanges( | ||
| 185 | runs.map((run) => ({ | ||
| 186 | run, | ||
| 187 | text: collapsed && run.fileIcon ? "" : run.text, | ||
| 188 | inlineImageLength: run.fileIcon ? 1 : 0, | ||
| 189 | })), | ||
| 190 | ); | ||
| 191 | const length = "validates the input ".length + (collapsed ? 1 : "example.ts:12".length + 1); | ||
| 192 | expect(ranges).toEqual([ | ||
| 193 | { start: 0, end: length, text: "[validates **the input**](</repo/src/example.ts:12>)" }, | ||
| 194 | { | ||
| 195 | start: length, | ||
| 196 | end: length * 2, | ||
| 197 | text: "[validates **the input**](</repo/src/example.ts:12>)", | ||
| 157 | }, | 198 | }, |
| 158 | ]); | 199 | ]); |
| 159 | }); | 200 | }); |
| 160 | 201 | ||
| 202 | it("keeps filename-labelled links as chips", () => { | ||
| 203 | expect( | ||
| 204 | nativeMarkdownTextRuns({ | ||
| 205 | type: "paragraph", | ||
| 206 | children: [ | ||
| 207 | { | ||
| 208 | type: "link", | ||
| 209 | href: "/repo/src/example.ts:12", | ||
| 210 | children: [{ type: "code_inline", content: "src/example.ts:12" }], | ||
| 211 | }, | ||
| 212 | ], | ||
| 213 | }), | ||
| 214 | ).toEqual([{ text: "example.ts:12", href: "/repo/src/example.ts:12", fileIcon: "typescript" }]); | ||
| 215 | }); | ||
| 216 | |||
| 217 | it.each([ | ||
| 218 | [{ type: "italic", children: [{ type: "text", content: "details" }] }, "*details*"], | ||
| 219 | [{ type: "strikethrough", children: [{ type: "text", content: "details" }] }, "~~details~~"], | ||
| 220 | [{ type: "code_inline", content: "`details`" }, "`` `details` ``"], | ||
| 221 | [{ type: "code_inline", content: " details " }, "` details `"], | ||
| 222 | [{ type: "text", content: "[details] *literal*" }, "\\[details\\] \\*literal\\*"], | ||
| 223 | [{ type: "soft_break" }, "\n"], | ||
| 224 | [{ type: "line_break" }, " \n"], | ||
| 225 | [ | ||
| 226 | { type: "image", alt: "details", href: "https://example.com/icon.png" }, | ||
| 227 | "", | ||
| 228 | ], | ||
| 229 | ] satisfies ReadonlyArray<readonly [MarkdownNode, string]>)( | ||
| 230 | "keeps descriptive label markup when copying %j", | ||
| 231 | (child, source) => { | ||
| 232 | const runs = nativeMarkdownTextRuns({ | ||
| 233 | type: "paragraph", | ||
| 234 | children: [ | ||
| 235 | { | ||
| 236 | type: "link", | ||
| 237 | href: "/repo/a.ts", | ||
| 238 | children: [{ type: "text", content: "see " }, child], | ||
| 239 | }, | ||
| 240 | ], | ||
| 241 | }); | ||
| 242 | expect( | ||
| 243 | nativeMarkdownContextCopyRanges( | ||
| 244 | runs.map((run) => ({ | ||
| 245 | run, | ||
| 246 | text: run.fileIcon ? "" : run.text, | ||
| 247 | inlineImageLength: run.fileIcon ? 1 : 0, | ||
| 248 | })), | ||
| 249 | ), | ||
| 250 | ).toEqual([ | ||
| 251 | { | ||
| 252 | start: 0, | ||
| 253 | end: | ||
| 254 | runs | ||
| 255 | .filter((run) => !run.fileIcon) | ||
| 256 | .reduce((length, run) => length + run.text.length, 0) + 1, | ||
| 257 | text: `[see ${source}](</repo/a.ts>)`, | ||
| 258 | }, | ||
| 259 | ]); | ||
| 260 | }, | ||
| 261 | ); | ||
| 262 | |||
| 161 | it("keeps hard breaks and collapses soft breaks", () => { | 263 | it("keeps hard breaks and collapses soft breaks", () => { |
| 162 | const node: MarkdownNode = { | 264 | const node: MarkdownNode = { |
| 163 | type: "paragraph", | 265 | type: "paragraph", |
apps/web/src/components/ChatMarkdown.tsx+17-4| ... | @@ -161,6 +161,7 @@ import { | ... | @@ -161,6 +161,7 @@ import { |
| 161 | shouldOpenMarkdownFileLinkInEditor, | 161 | shouldOpenMarkdownFileLinkInEditor, |
| 162 | type MarkdownFileLinkMeta, | 162 | type MarkdownFileLinkMeta, |
| 163 | } from "../markdown-links"; | 163 | } from "../markdown-links"; |
| 164 | import { isMarkdownFileLinkLabel } from "@t3tools/client-runtime/markdown-links"; | ||
| 164 | import { readLocalApi } from "../localApi"; | 165 | import { readLocalApi } from "../localApi"; |
| 165 | import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls"; | 166 | import { useAssetUrlRefresh, useAssetUrlState } from "../assets/assetUrls"; |
| 166 | import { cn } from "../lib/utils"; | 167 | import { cn } from "../lib/utils"; |
| ... | @@ -3022,6 +3023,7 @@ const CHAT_MARKDOWN_COMPONENTS = { | ... | @@ -3022,6 +3023,7 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 3022 | updateThreadPullRequestLink, | 3023 | updateThreadPullRequestLink, |
| 3023 | fileLinkChip, | 3024 | fileLinkChip, |
| 3024 | renderContextReference, | 3025 | renderContextReference, |
| 3026 | text, | ||
| 3025 | } = use(ChatMarkdownRendererContext); | 3027 | } = use(ChatMarkdownRendererContext); |
| 3026 | const citation = href ? parseAssistantCitationHref(href) : null; | 3028 | const citation = href ? parseAssistantCitationHref(href) : null; |
| 3027 | if (citation) return <AssistantCitationChip citation={citation} />; | 3029 | if (citation) return <AssistantCitationChip citation={citation} />; |
| ... | @@ -3233,10 +3235,21 @@ const CHAT_MARKDOWN_COMPONENTS = { | ... | @@ -3233,10 +3235,21 @@ const CHAT_MARKDOWN_COMPONENTS = { |
| 3233 | ); | 3235 | ); |
| 3234 | } | 3236 | } |
| 3235 | 3237 | ||
| 3236 | return fileLinkChip( | 3238 | const label = nodeToPlainText(children); |
| 3237 | fileLinkMeta, | 3239 | const start = node?.position?.start.offset; |
| 3238 | `[${fileLinkMeta.basename}](${normalizedHref})`, | 3240 | const end = node?.position?.end.offset; |
| 3239 | normalizedHref, | 3241 | const source = start !== undefined && end !== undefined ? text.slice(start, end) : ""; |
| 3242 | const copyMarkdown = | ||
| 3243 | source.startsWith("[") && source.includes("](") | ||
| 3244 | ? source | ||
| 3245 | : `[${(label || fileLinkMeta.basename).replace(/[\\[\]]/g, "\\$&")}](${normalizedHref})`; | ||
| 3246 | const chip = fileLinkChip(fileLinkMeta, copyMarkdown, normalizedHref); | ||
| 3247 | return isMarkdownFileLinkLabel(label, normalizedHref) ? ( | ||
| 3248 | chip | ||
| 3249 | ) : ( | ||
| 3250 | <span data-markdown-copy={copyMarkdown}> | ||
| 3251 | {children} {chip} | ||
| 3252 | </span> | ||
| 3240 | ); | 3253 | ); |
| 3241 | }, | 3254 | }, |
| 3242 | code: function MarkdownCode({ node, children, className, ...props }) { | 3255 | code: function MarkdownCode({ node, children, className, ...props }) { |
packages/client-runtime/src/markdownLinks.test.ts+24| ... | @@ -3,12 +3,36 @@ import { describe, expect, it } from "vite-plus/test"; | ... | @@ -3,12 +3,36 @@ import { describe, expect, it } from "vite-plus/test"; |
| 3 | import { | 3 | import { |
| 4 | fileBasename, | 4 | fileBasename, |
| 5 | inlineCodeFilePathCandidate, | 5 | inlineCodeFilePathCandidate, |
| 6 | isMarkdownFileLinkLabel, | ||
| 6 | parseFileUrlHref, | 7 | parseFileUrlHref, |
| 7 | parseMarkdownFileLink, | 8 | parseMarkdownFileLink, |
| 8 | splitFilePathPosition, | 9 | splitFilePathPosition, |
| 9 | workspaceRelativeFilePath, | 10 | workspaceRelativeFilePath, |
| 10 | } from "./markdownLinks.ts"; | 11 | } from "./markdownLinks.ts"; |
| 11 | 12 | ||
| 13 | describe("isMarkdownFileLinkLabel", () => { | ||
| 14 | it.each([ | ||
| 15 | ["validates the input", "/repo/src/example.ts:12", false], | ||
| 16 | ["read src/example.ts", "/repo/src/example.ts:12", false], | ||
| 17 | ["example.ts?why this matters", "/repo/src/example.ts", false], | ||
| 18 | ["example.ts", "/repo/src/example.ts:12", true], | ||
| 19 | ["example.ts:12", "/repo/src/example.ts:12", true], | ||
| 20 | ["example.ts:99", "/repo/src/example.ts:12", false], | ||
| 21 | ["example.ts:12:2", "/repo/src/example.ts:12:2", true], | ||
| 22 | ["example.ts:12:3", "/repo/src/example.ts:12:2", false], | ||
| 23 | ["example.ts:12", "/repo/src/example.ts", false], | ||
| 24 | ["src/example.ts:12", "/repo/src/example.ts:12", true], | ||
| 25 | ["./src/example.ts", "/repo/src/example.ts", true], | ||
| 26 | ["/repo/src/example.ts", "/repo/src/example.ts", true], | ||
| 27 | ["src/", "/home/me/project/src/", true], | ||
| 28 | ["EXAMPLE.TS", "C:/repo/src/example.ts:12", true], | ||
| 29 | ["file name.ts", "file:///repo/file%20name.ts", true], | ||
| 30 | ["", "/repo/src/example.ts", true], | ||
| 31 | ])("classifies %s for %s", (label, href, expected) => { | ||
| 32 | expect(isMarkdownFileLinkLabel(label, href)).toBe(expected); | ||
| 33 | }); | ||
| 34 | }); | ||
| 35 | |||
| 12 | describe("inlineCodeFilePathCandidate", () => { | 36 | describe("inlineCodeFilePathCandidate", () => { |
| 13 | it.each([ | 37 | it.each([ |
| 14 | ["src\\main.ts", "src/main.ts"], | 38 | ["src\\main.ts", "src/main.ts"], |
packages/client-runtime/src/markdownLinks.ts+23| ... | @@ -265,6 +265,29 @@ export function formatFilePathPosition(position: FilePathPosition): string { | ... | @@ -265,6 +265,29 @@ export function formatFilePathPosition(position: FilePathPosition): string { |
| 265 | return `${position.path}:${position.line}${position.column ? `:${position.column}` : ""}`; | 265 | return `${position.path}:${position.line}${position.column ? `:${position.column}` : ""}`; |
| 266 | } | 266 | } |
| 267 | 267 | ||
| 268 | /** Keeps filename and destination-path labels compact without discarding prose. */ | ||
| 269 | export function isMarkdownFileLinkLabel(label: string, href: string): boolean { | ||
| 270 | const destination = parseMarkdownFileLink(href); | ||
| 271 | if (!destination) return false; | ||
| 272 | const normalize = (path: string) => | ||
| 273 | path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, ""); | ||
| 274 | const labelPosition = splitFilePathPosition(label.trim()); | ||
| 275 | if ( | ||
| 276 | (labelPosition.line !== undefined && labelPosition.line !== destination.line) || | ||
| 277 | (labelPosition.column !== undefined && labelPosition.column !== destination.column) | ||
| 278 | ) { | ||
| 279 | return false; | ||
| 280 | } | ||
| 281 | let labelPath = normalize(labelPosition.path); | ||
| 282 | let destinationPath = normalize(destination.path); | ||
| 283 | if (labelPath.length === 0) return true; | ||
| 284 | if (isWindowsAbsolutePath(destination.path)) { | ||
| 285 | labelPath = labelPath.toLowerCase(); | ||
| 286 | destinationPath = destinationPath.toLowerCase(); | ||
| 287 | } | ||
| 288 | return destinationPath === labelPath || destinationPath.endsWith(`/${labelPath}`); | ||
| 289 | } | ||
| 290 | |||
| 268 | export function isRelativeFilePath(path: string): boolean { | 291 | export function isRelativeFilePath(path: string): boolean { |
| 269 | return ( | 292 | return ( |
| 270 | RELATIVE_PATH_PREFIX_PATTERN.test(path) || | 293 | RELATIVE_PATH_PREFIX_PATTERN.test(path) || |