fix(markdown): require letters and known extensions in bare path recognition

This commit is contained in:
Mohamed Boudra
2026-10-16 07:21:40 +02:00
parent c0c187ed29
commit 3929643da9
4 changed files with 131 additions and 60 deletions
@@ -0,0 +1,57 @@
export const ASSISTANT_FILE_EXTENSIONS: ReadonlySet<string> = 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",
]);
+1 -58
View File
@@ -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;
/**
@@ -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,
});
});
+15 -2
View File
@@ -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<>]+|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${SEPARATED_PATH}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
"g",
);
function linkMetadata() {
return {