| 1 | // Local-path linkification for chat markdown (issue #7426). |
| 2 | // |
| 3 | // AI replies frequently print local file paths as plain text (Windows drive |
| 4 | // paths, UNC paths, file:/// URLs). GFM autolink literals only recognize |
| 5 | // http(s)/www/email, so these render as inert text. This module rewrites |
| 6 | // matching text nodes into markdown links whose href is a file:/// URL; the |
| 7 | // link click handler (RichMarkdownLink) routes those to the native |
| 8 | // OpenLocalPath binding instead of the system browser. |
| 9 | |
| 10 | import { visit } from "unist-util-visit"; |
| 11 | import type { Parent, Link, Root, Text } from "mdast"; |
| 12 | import { hasDisallowedWindowsPathSyntax, isLocalFileHref } from "./localFileUrl"; |
| 13 | import { unescapeRefPath } from "./refToken"; |
| 14 | |
| 15 | // Sentence punctuation is excluded from path characters: Windows forbids `:` |
| 16 | // in file names, and the CJK/ASCII punctuation set below almost never appears |
| 17 | // inside a real path. Excluding it at match time is far more reliable than |
| 18 | // trimming a trailing marker after the fact ("D:\x\y.md。已生成" must stop at |
| 19 | // `。`). `.` is deliberately kept (extensions) and only stripped when it is |
| 20 | // actually trailing. |
| 21 | const SENT_PUNCT = ",。;、!?,;!?()"; |
| 22 | |
| 23 | // One path character: either a backslash-escaped space/tab (kept, matching the |
| 24 | // @path grammar in lib/refToken), or any non-whitespace char that is not a |
| 25 | // quote, angle bracket, pipe, `?`, `*`, colon or sentence punctuation. |
| 26 | // The `\\[ \t]` alternative must come FIRST: in a `(?:A|B)+` loop the engine |
| 27 | // backtracks by dropping repetitions, not by retrying the alternative at the |
| 28 | // same position, so a trailing `\ ` would otherwise never be consumed. |
| 29 | const PATH_CHAR = String.raw`(?:\\[ \t]|[^\s<>"|?*::${SENT_PUNCT}])`; |
| 30 | |
| 31 | // file URLs are matched whole (their `:` and `.` are legal there). Drive and |
| 32 | // UNC prefix boundaries are checked in JavaScript rather than with regular |
| 33 | // expression lookbehind: macOS 12's WebKit rejects `(?<!...)` while loading the |
| 34 | // whole lazy Markdown chunk, before any message is rendered. URL candidates are |
| 35 | // validated with the shared parser below so malformed file URLs stay inert. |
| 36 | const FILE_RE = new RegExp(String.raw`file://[^\s<>"|?*${SENT_PUNCT}]+`, "g"); |
| 37 | const DRIVE_RE = new RegExp(String.raw`[A-Za-z]:[\\/]${PATH_CHAR}+`, "g"); |
| 38 | // UNC share paths: the plugin runs on parsed markdown text nodes, where |
| 39 | // CommonMark backslash escaping has already folded `\\` into `\`. A share |
| 40 | // therefore arrives as `\nas\share\docs\report.md` (single leading |
| 41 | // backslash); linkify restores the `\\` prefix so the native opener receives |
| 42 | // the real UNC form. The leading `\` is mandatory (the fold always leaves |
| 43 | // one). The JavaScript prefix guard keeps `C:\nas` or a plain `a\b` from being |
| 44 | // mistaken for a share start without requiring WebKit lookbehind support. |
| 45 | const UNC_RE = new RegExp( |
| 46 | String.raw`\\(?!\\)[^\s\\<>"|?*::${SENT_PUNCT}]+\\${PATH_CHAR}+`, |
| 47 | "g", |
| 48 | ); |
| 49 | |
| 50 | // POSIX absolute paths. Claiming every absolute path would turn `/api/v1` or |
| 51 | // `/usr/bin/env` inside prose into a file link, so a candidate must end in a |
| 52 | // file extension and must start a token. The host still verifies it before it |
| 53 | // becomes clickable, and an unverified one renders as its original text. |
| 54 | const POSIX_RE = new RegExp( |
| 55 | String.raw`/(?:(?:\\[ \t])|[^\s<>"|?*::${SENT_PUNCT}])*\.[A-Za-z0-9]{1,12}(?![A-Za-z0-9])`, |
| 56 | "g", |
| 57 | ); |
| 58 | |
| 59 | const DRIVE_PREFIX_RE = /[:/A-Za-z]/; |
| 60 | const UNC_PREFIX_RE = /[\\/\w:;:,。、!?()]/; |
| 61 | // A scanned POSIX path must start a token: `a/b.png` is relative, and the `/y` |
| 62 | // of `https://x/y` belongs to a URL. |
| 63 | const POSIX_PREFIX_RE = /[A-Za-z0-9_./\\~:@%+-]/; |
| 64 | // A file URL must start a URI-like token. Without this guard, the matcher can |
| 65 | // start in the middle of `profile://...` or `http://file://...` and produce a |
| 66 | // clickable suffix that was never a local path. |
| 67 | const FILE_PREFIX_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_./\\:+-?#&="; |
| 68 | |
| 69 | type LocalPathKind = "file" | "drive" | "unc" | "posix"; |
| 70 | |
| 71 | function hasValidPrefixBoundary(text: string, start: number, kind: LocalPathKind): boolean { |
| 72 | if (start === 0) return true; |
| 73 | const previous = text[start - 1]; |
| 74 | if (kind === "file") return !FILE_PREFIX_CHARS.includes(previous); |
| 75 | if (kind === "posix") return !POSIX_PREFIX_RE.test(previous); |
| 76 | return kind === "drive" ? !DRIVE_PREFIX_RE.test(previous) : !UNC_PREFIX_RE.test(previous); |
| 77 | } |
| 78 | |
| 79 | // Trailing closers that are more likely sentence punctuation than file name |
| 80 | // characters. A trailing `)` is only stripped when parens are unbalanced — |
| 81 | // "C:\Program Files (x86)" keeps its closing paren, "D:\x\y.md)." loses it. |
| 82 | const TRAIL_STRIP_RE = /[.)\]]+$/; |
| 83 | |
| 84 | function stripTrailingClosers(raw: string): string { |
| 85 | const stripped = raw.replace(TRAIL_STRIP_RE, ""); |
| 86 | if (stripped === raw) return raw; |
| 87 | // A trailing `)` may be a real file-name character inside balanced parens |
| 88 | // ("C:\Program Files (x86)") — only strip it when parens are unbalanced, |
| 89 | // i.e. the removed part contains a `)` with no matching opener. |
| 90 | if (raw.slice(stripped.length).includes(")")) { |
| 91 | const open = (raw.match(/\(/g) ?? []).length; |
| 92 | const close = (raw.match(/\)/g) ?? []).length; |
| 93 | if (close <= open) return raw; |
| 94 | } |
| 95 | return stripped; |
| 96 | } |
| 97 | |
| 98 | export interface LocalPathSegment { |
| 99 | /** Raw text to render (keeps the original spelling, escapes intact). */ |
| 100 | text: string; |
| 101 | /** When present, this segment is a clickable local path. */ |
| 102 | path?: string; |
| 103 | /** Recognition rule that produced the segment, for the renderer's policy. */ |
| 104 | kind?: LocalPathKind; |
| 105 | } |
| 106 | |
| 107 | /** |
| 108 | * Splits `text` into plain segments and clickable local-path segments. |
| 109 | * Pure function — unit tests cover the full recognition matrix here. |
| 110 | */ |
| 111 | export function linkifyLocalPaths(text: string): LocalPathSegment[] { |
| 112 | const matches: Array<{ start: number; end: number; raw: string; kind: LocalPathKind }> = []; |
| 113 | const patterns: Array<[RegExp, LocalPathKind]> = [ |
| 114 | [FILE_RE, "file"], |
| 115 | [DRIVE_RE, "drive"], |
| 116 | [UNC_RE, "unc"], |
| 117 | [POSIX_RE, "posix"], |
| 118 | ]; |
| 119 | for (const [re, kind] of patterns) { |
| 120 | re.lastIndex = 0; |
| 121 | let m: RegExpExecArray | null; |
| 122 | while ((m = re.exec(text)) !== null) { |
| 123 | const match = m; |
| 124 | const matchEnd = match.index + match[0].length; |
| 125 | if (!hasValidPrefixBoundary(text, match.index, kind)) { |
| 126 | continue; |
| 127 | } |
| 128 | if (kind === "file" && !isLocalFileHref(stripTrailingClosers(match[0]))) { |
| 129 | continue; |
| 130 | } |
| 131 | // FILE_RE stops before a question mark so it cannot consume query |
| 132 | // syntax. Do not linkify the safe-looking prefix of a file URL that has |
| 133 | // a raw query suffix; localPathFromHref intentionally rejects queries. |
| 134 | if (kind === "file" && text[matchEnd] === "?") { |
| 135 | continue; |
| 136 | } |
| 137 | // Do not turn the safe-looking prefix of an alternate data stream into |
| 138 | // a link (for example, C:\\report.md in C:\\report.md:payload). |
| 139 | if (kind !== "file" && text[matchEnd] === ":" && !/\s/.test(text[matchEnd + 1] ?? "")) { |
| 140 | continue; |
| 141 | } |
| 142 | // RegExpExecArray has no start/end — compare via index/length. |
| 143 | const overlapped = matches.some((p) => !(matchEnd <= p.start || match.index >= p.end)); |
| 144 | if (!overlapped) { |
| 145 | matches.push({ start: match.index, end: matchEnd, raw: match[0], kind }); |
| 146 | } |
| 147 | if (match.index === re.lastIndex) re.lastIndex += 1; // guard against zero-width |
| 148 | } |
| 149 | } |
| 150 | matches.sort((a, b) => a.start - b.start); |
| 151 | |
| 152 | const segments: LocalPathSegment[] = []; |
| 153 | let cursor = 0; |
| 154 | for (const m of matches) { |
| 155 | if (m.start > cursor) segments.push({ text: text.slice(cursor, m.start) }); |
| 156 | // UNC text arrives with a single leading backslash (markdown folded the |
| 157 | // `\\` escape); restore the real UNC prefix for the native opener. |
| 158 | const path = m.kind === "unc" ? "\\" + stripTrailingClosers(m.raw) : stripTrailingClosers(m.raw); |
| 159 | const decodedPath = unescapeRefPath(path); |
| 160 | if (path && !hasDisallowedWindowsPathSyntax(decodedPath)) { |
| 161 | segments.push({ text: m.raw, path: decodedPath, kind: m.kind }); |
| 162 | } else { |
| 163 | segments.push({ text: m.raw }); |
| 164 | } |
| 165 | cursor = Math.max(cursor, m.end); |
| 166 | } |
| 167 | if (cursor < text.length) segments.push({ text: text.slice(cursor) }); |
| 168 | return segments; |
| 169 | } |
| 170 | |
| 171 | /** |
| 172 | * Builds the href for a clickable local path. Uses file:/// with forward |
| 173 | * slashes (the standard Windows form) and percent-encodes non-ASCII and `#`. |
| 174 | * Valid file URLs are preserved verbatim so authority-form UNC URLs are not |
| 175 | * rewritten into a different path. |
| 176 | */ |
| 177 | export function localPathHref(path: string): string { |
| 178 | if (isLocalFileHref(path)) return path; |
| 179 | if (path.startsWith("file:///")) { |
| 180 | try { |
| 181 | path = decodeURIComponent(path.slice("file:///".length)); |
| 182 | } catch { |
| 183 | // Malformed escapes: keep the literal text rather than failing. |
| 184 | } |
| 185 | } |
| 186 | const slash = path.replace(/\\/g, "/"); |
| 187 | const encoded = encodeURI(slash).replace(/#/g, "%23"); |
| 188 | // A POSIX path already starts with the separator the URL needs; adding |
| 189 | // another would turn `/tmp/a.svg` into the UNC-looking `//tmp/a.svg`. |
| 190 | return slash.startsWith("/") ? `file://${encoded}` : `file:///${encoded}`; |
| 191 | } |
| 192 | |
| 193 | /** |
| 194 | * Remark plugin: rewrites plain-text nodes containing local paths into link |
| 195 | * nodes (text + link alternation), so the markdown renderer hands them to |
| 196 | * RichMarkdownLink which routes them to the native opener. |
| 197 | * |
| 198 | * Collection and replacement are separate passes: replacing during the visit |
| 199 | * would re-visit the freshly inserted link children and nest anchors. |
| 200 | */ |
| 201 | export function remarkLocalPathLinks() { |
| 202 | return (tree: Root) => { |
| 203 | const plan: Array<{ parent: Parent; index: number; nodes: Array<Text | Link> }> = []; |
| 204 | visit(tree, "text", (node: Text, index: number | undefined, parent) => { |
| 205 | if (parent === undefined || parent === null || index === undefined || index === null) return; |
| 206 | // Skip link internals: rewriting their text would nest <a> elements |
| 207 | // (mdast forbids links inside links) and double-fire on click. |
| 208 | if (parent.type === "link" || parent.type === "linkReference") return; |
| 209 | const segments = linkifyLocalPaths(node.value); |
| 210 | if (segments.length === 1 && segments[0].path === undefined) return; |
| 211 | plan.push({ |
| 212 | parent, |
| 213 | index, |
| 214 | nodes: segments.map<Text | Link>((seg) => |
| 215 | seg.path !== undefined |
| 216 | ? { |
| 217 | type: "link", |
| 218 | url: localPathHref(seg.path), |
| 219 | title: null, |
| 220 | children: [{ type: "text", value: seg.text }], |
| 221 | // A scanned POSIX path is only a guess: the renderer keeps it |
| 222 | // as ordinary text unless the host confirms the file exists. |
| 223 | // Drive, UNC and file:// links keep their established behavior. |
| 224 | ...(seg.kind === "posix" ? { data: { hProperties: { "data-scanned-path": "posix" } } } : {}), |
| 225 | } |
| 226 | : { type: "text", value: seg.text }, |
| 227 | ), |
| 228 | }); |
| 229 | }); |
| 230 | // Back-to-front keeps earlier indices valid within the same parent. |
| 231 | for (let i = plan.length - 1; i >= 0; i--) { |
| 232 | const { parent, index, nodes } = plan[i]; |
| 233 | parent.children.splice(index, 1, ...nodes); |
| 234 | } |
| 235 | }; |
| 236 | } |
| 237 |