From 3929643da99692dca9bd9eddac5c0d333b3aa679 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 16 Oct 2026 07:21:40 +0200 Subject: [PATCH] fix(markdown): require letters and known extensions in bare path recognition --- .../src/assistant-file-links/extensions.ts | 57 ++++++++++++++++++ .../app/src/assistant-file-links/parse.ts | 59 +------------------ .../src/components/markdown/renderer.test.ts | 58 ++++++++++++++++++ .../app/src/components/markdown/source.ts | 17 +++++- 4 files changed, 131 insertions(+), 60 deletions(-) create mode 100644 packages/app/src/assistant-file-links/extensions.ts diff --git a/packages/app/src/assistant-file-links/extensions.ts b/packages/app/src/assistant-file-links/extensions.ts new file mode 100644 index 0000000000..b32bf6aad9 --- /dev/null +++ b/packages/app/src/assistant-file-links/extensions.ts @@ -0,0 +1,57 @@ +export const ASSISTANT_FILE_EXTENSIONS: ReadonlySet = new Set([ + "astro", + "bash", + "c", + "cc", + "cjs", + "cpp", + "cs", + "css", + "cts", + "cxx", + "env", + "fish", + "go", + "gql", + "gradle", + "graphql", + "h", + "hpp", + "htm", + "html", + "ini", + "java", + "js", + "json", + "jsonc", + "jsx", + "kt", + "kts", + "less", + "lock", + "lua", + "md", + "mdx", + "mjs", + "mts", + "php", + "proto", + "py", + "rb", + "rs", + "sass", + "scss", + "sh", + "sql", + "svelte", + "swift", + "toml", + "ts", + "tsx", + "txt", + "vue", + "xml", + "yaml", + "yml", + "zsh", +]); diff --git a/packages/app/src/assistant-file-links/parse.ts b/packages/app/src/assistant-file-links/parse.ts index 0a8089acca..1904c11907 100644 --- a/packages/app/src/assistant-file-links/parse.ts +++ b/packages/app/src/assistant-file-links/parse.ts @@ -1,3 +1,4 @@ +import { ASSISTANT_FILE_EXTENSIONS } from "./extensions"; import { isAbsolutePath } from "@/utils/path"; function safeDecodeURIComponent(value: string): string { @@ -20,64 +21,6 @@ const INLINE_LINE_FRAGMENT = /^L([0-9]+)(?:C[0-9]+)?(?:-L?([0-9]+)(?:C[0-9]+)?)? const INLINE_COLON_LINE_SUFFIX = /^(.+?):([0-9]+)(?::[0-9]+)?(?:-([0-9]+)(?::[0-9]+)?)?$/; const INLINE_PAREN_LINE_SUFFIX = /^(.+?)\(([0-9]+)(?:,[0-9]+)?(?:-([0-9]+)(?:,[0-9]+)?)?\)$/; const INLINE_WORD_LINE_SUFFIX = /^(.+?)\s+lines?\s+([0-9]+)(?:-([0-9]+))?$/i; -const ASSISTANT_FILE_EXTENSIONS = new Set([ - "astro", - "bash", - "c", - "cc", - "cjs", - "cpp", - "cs", - "css", - "cts", - "cxx", - "env", - "fish", - "go", - "gql", - "gradle", - "graphql", - "h", - "hpp", - "htm", - "html", - "ini", - "java", - "js", - "json", - "jsonc", - "jsx", - "kt", - "kts", - "less", - "lock", - "lua", - "md", - "mdx", - "mjs", - "mts", - "php", - "proto", - "py", - "rb", - "rs", - "sass", - "scss", - "sh", - "sql", - "svelte", - "swift", - "toml", - "ts", - "tsx", - "txt", - "vue", - "xml", - "yaml", - "yml", - "zsh", -]); - export interface AssistantHrefParseOptions { workspaceRoot?: string; /** diff --git a/packages/app/src/components/markdown/renderer.test.ts b/packages/app/src/components/markdown/renderer.test.ts index b32489b91f..387b7629a5 100644 --- a/packages/app/src/components/markdown/renderer.test.ts +++ b/packages/app/src/components/markdown/renderer.test.ts @@ -1,3 +1,4 @@ +import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser"; import { QueryClient } from "@tanstack/react-query"; import { decodeMarkdownImage } from "./image-decoding"; import { describe, expect, it } from "vitest"; @@ -412,3 +413,60 @@ it("normalizes a large HTML table into one block per row", () => { expect(rendered.startsWith(`${cell} ${cell}`)).toBe(true); expect(rendered.endsWith(`${cell} ${cell}`)).toBe(true); }); + +it.each([ + "3/4", + "1.5", + "v1.2", + "10/10", + "2024/10/16", + "10.0.0.1", + "word.word", + "example.com", + "file.tsxextra", + "v1.2.3-beta", + "123/456.789", + "×/÷", + "2024/10/16", +])("leaves non-path tokens unlinked in prose and inline code: %s", (token) => { + const { linkRecognition } = prepareMarkdownSource(`Value ${token}, then continue.`); + expect([...`Value ${token}, then continue.`.matchAll(linkRecognition.text)]).toEqual([]); + expect(linkRecognition.inlineCode.test(token)).toBe(false); +}); + +it.each([ + "src/index.ts", + "README.md", + "packages/app", + "./scripts/run", + "~/dev/paseo", + String.raw`C:\Users\x\file.txt`, + "file.ts:12", + "file.ts:12-20", + "123/file", + "src/123", + "123.ts", + "FILE.TS", + "https://example.com/path", +])("recognizes complete paths and URLs in prose and inline code: %s", (token) => { + const { linkRecognition } = prepareMarkdownSource(`Open ${token} then continue.`); + expect( + [...`Open ${token} then continue.`.matchAll(linkRecognition.text)].map(([text]) => text), + ).toEqual([token]); + expect(linkRecognition.inlineCode.test(token)).toBe(true); +}); + +it("keeps prose and inline-code recognition enabled through the native regex adapter", () => { + const { linkRecognition } = prepareMarkdownSource("3/4 src/index.ts"); + const native = toNativeLinkRecognition(linkRecognition); + expect(native.text).toMatchObject({ + pattern: linkRecognition.text.source, + isDisabled: false, + isDefault: false, + }); + expect(native.inlineCode).toMatchObject({ + pattern: linkRecognition.inlineCode.source, + isDisabled: false, + isDefault: false, + }); +}); diff --git a/packages/app/src/components/markdown/source.ts b/packages/app/src/components/markdown/source.ts index 7fe28d1d18..166f55a2f5 100644 --- a/packages/app/src/components/markdown/source.ts +++ b/packages/app/src/components/markdown/source.ts @@ -1,3 +1,4 @@ +import { ASSISTANT_FILE_EXTENSIONS } from "@/assistant-file-links/extensions"; import { normalizeMarkdownHtml } from "./html"; import { gfmTable } from "micromark-extension-gfm-table"; import { gfmTableFromMarkdown } from "mdast-util-gfm-table"; @@ -8,8 +9,20 @@ import type { Link, Nodes, Root } from "mdast"; // Keep this regex in the syntax shared by JavaScript, ICU and java.util.regex. // The Unicode flag makes Metro expand it to escapes rejected by native engines. -const FILE_PATTERN = - /(?:https?:\/\/[^\s<>]+|(?:[A-Za-z]:[\\/]|\.{0,2}\/|~\/)?[a-zA-Z0-9\u0080-\uFFFF_@.%+-]+(?:[\\/][a-zA-Z0-9\u0080-\uFFFF_@.%+-]+)+(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?|[a-zA-Z0-9\u0080-\uFFFF_@%-]+\.[a-zA-Z0-9\u0080-\uFFFF_%-]+(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?)/g; +const PATH_CHARACTER = String.raw`[a-zA-Z0-9\u0080-\uFFFF_@.%+-]`; +const PATH_LETTER = String.raw`[a-zA-Z]`; +const PATH_SEGMENT = String.raw`${PATH_CHARACTER}*[a-zA-Z0-9\u0080-\uFFFF_@%+-]`; +const LINE_SUFFIX = String.raw`(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?`; +const FILE_EXTENSIONS = [...ASSISTANT_FILE_EXTENSIONS] + .map((extension) => extension.replace(/[a-z]/g, (letter) => `[${letter}${letter.toUpperCase()}]`)) + .join("|"); +const SEPARATED_PATH = String.raw`(?:[A-Za-z]:[\\/]|\.{0,2}\/|~\/)?(?=${PATH_CHARACTER}*(?:[\\/]${PATH_CHARACTER}*)*${PATH_LETTER})${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT})+`; +const FILE_NAME = String.raw`${PATH_SEGMENT}\.(?:${FILE_EXTENSIONS})`; +// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape. +const FILE_PATTERN = new RegExp( + String.raw`(?:https?://[^\s<>]+|(?