Skip to content
Source
Sign inSign up
Open History to see the last commit

markdownLinks.ts

510 lines16.5 KB

1import {
2 isNetworkPath,
3 isWindowsAbsolutePath,
4 stripSlashPrefixedWindowsDrive,
5 workspaceRelativeFilePath,
6} from "./path.ts";
7import {
8 type FilePathPosition,
9 formatFilePathPosition,
10 parseFileUrlHref,
11 resolvePathLinkTarget,
12 splitFilePathPosition,
13} from "./fileLinks.ts";
14​
15const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;
16​
17// Names may use any script (`線性代數/考試.pdf`) and routing punctuation (`posts.$id.tsx`,
18// `(group)/[id]/+page.svelte`, `@scope/pkg`). The final segment is optional so an explicit
19// folder link may end in `/` (`patches/`, `docs/My Folder/`). A bare name with no slash still fails.
20const RELATIVE_FILE_PATH_PATTERN =
21 /^(?:[\p{L}\p{M}\p{N}._$+@()[\]-]+(?: +[\p{L}\p{M}\p{N}._$+@()[\]-]+)*\/)+(?:[\p{L}\p{M}\p{N}._$+@()[\]-]+(?: +[\p{L}\p{M}\p{N}._$+@()[\]-]+)*(?::\d+(?::\d+|-\d+)?)?)?$/u;
22​
23// No `@` here: `me@example.com` is an address, not a file.
24const RELATIVE_FILE_NAME_PATTERN =
25 /^[\p{L}\p{M}\p{N}._$+()[\]-]+(?: +[\p{L}\p{M}\p{N}._$+()[\]-]+)*\.[\p{L}\p{M}\p{N}_-]+(?::\d+(?::\d+|-\d+)?)?$/u;
26​
27const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/;
28​
29const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+|-\d+)?$/;
30​
31const POSITION_ONLY_PATTERN = /^\d+(?::\d+|-\d+)?$/;
32​
33const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/;
34​
35const PATH_SEPARATOR_PATTERN = /[\\/]/;
36// Agents shorten long paths with a `...` or `…` segment, which no file chip can open.
37const ELIDED_PATH_SEGMENT_PATTERN = /(?:^|[\\/])(?:\.{3,}|…)(?:[\\/]|$)/;
38​
39const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/;
40const ANY_FILE_EXTENSION_PATTERN = /\.[^./\\]+$/;
41const VERSION_SUFFIX_PATTERN = /\d\.\d[^.]*$/;
42const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/;
43​
44const POSIX_FILE_ROOT_PREFIXES = [
45 "/Users/",
46 "/home/",
47 "/tmp/",
48 "/var/",
49 "/etc/",
50 "/opt/",
51 "/mnt/",
52 "/Volumes/",
53 "/private/",
54 "/root/",
55 "/usr/",
56 "/bin/",
57 "/sbin/",
58 "/lib/",
59 "/lib64/",
60 "/srv/",
61 "/dev/",
62 "/proc/",
63 "/sys/",
64 "/run/",
65 "/boot/",
66 "/media/",
67 "/workspace/",
68 "/workspaces/",
69] as const;
70​
71const EXTENSIONLESS_FILE_NAMES = new Set([
72 "Makefile",
73 "makefile",
74 "GNUmakefile",
75 "Dockerfile",
76 "Containerfile",
77 "Justfile",
78 "justfile",
79 "Rakefile",
80 "Gemfile",
81 "Procfile",
82 "Brewfile",
83 "Caddyfile",
84 "Vagrantfile",
85 "Jenkinsfile",
86 "Podfile",
87 "Fastfile",
88 "BUILD",
89 "WORKSPACE",
90 "LICENSE",
91 "LICENCE",
92 "COPYING",
93 "NOTICE",
94 "AUTHORS",
95 "CONTRIBUTORS",
96 "CHANGELOG",
97 "README",
98 "CODEOWNERS",
99]);
100​
101const SINGLE_LABEL_HOSTNAMES = new Set(["localhost"]);
102​
103const GENERIC_HOSTNAME_TLDS = new Set([
104 "com",
105 "net",
106 "org",
107 "io",
108 "dev",
109 "app",
110 "ai",
111 "co",
112 "edu",
113 "gov",
114 "mil",
115 "info",
116 "biz",
117 "xyz",
118 "me",
119 "tv",
120 "gg",
121 "chat",
122 "cloud",
123 "site",
124 "online",
125 "tech",
126 "store",
127 "link",
128]);
129​
130const COUNTRY_HOSTNAME_TLDS = new Set([
131 "uk",
132 "de",
133 "fr",
134 "nl",
135 "se",
136 "no",
137 "fi",
138 "dk",
139 "pl",
140 "ch",
141 "at",
142 "be",
143 "es",
144 "it",
145 "pt",
146 "eu",
147 "us",
148 "ca",
149 "au",
150 "nz",
151 "jp",
152 "kr",
153 "cn",
154 "br",
155 "ru",
156 "mx",
157 "ie",
158 "cz",
159 "tr",
160 "sg",
161 "hk",
162 // C++ sources end in `.cc`, so `allocator.cc:217` names a file, not a host and port.
163 "cc",
164]);
165​
166function looksLikeHostname(segment: string, hasPosition: boolean): boolean {
167 if (segment.startsWith(".")) return false;
168 const lowered = segment.toLowerCase();
169 if (SINGLE_LABEL_HOSTNAMES.has(lowered)) return true;
170 if (NUMERIC_DOTTED_PATTERN.test(segment)) return true;
171 const labels = lowered.split(".");
172 const lastLabel = labels.at(-1);
173 if (labels.length < 2 || lastLabel === undefined) return false;
174 if (GENERIC_HOSTNAME_TLDS.has(lastLabel)) return true;
175 return !hasPosition && COUNTRY_HOSTNAME_TLDS.has(lastLabel);
177​
178export function inlineCodeFilePathCandidate(codeText: string): string | null {
179 const trimmed = codeText.trim();
180 if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null;
181​
182 const candidate = isWindowsAbsolutePath(trimmed) ? trimmed : trimmed.replaceAll("\\", "/");
183 const hasPosition = POSITION_SUFFIX_PATTERN.test(candidate);
184 if (!hasPosition && !PATH_SEPARATOR_PATTERN.test(candidate)) return null;
185 if (ELIDED_PATH_SEGMENT_PATTERN.test(candidate.replace(POSITION_SUFFIX_PATTERN, ""))) return null;
186​
187 const hasExplicitPathShape =
188 RELATIVE_PATH_PREFIX_PATTERN.test(candidate) ||
189 candidate.startsWith("/") ||
190 isWindowsAbsolutePath(candidate);
191 if (!hasExplicitPathShape) {
192 const withoutPosition = candidate.replace(POSITION_SUFFIX_PATTERN, "");
193 // Markdown destinations accept `patches/`. Inline code still needs a
194 // file-shaped final segment, so a trailing slash stays plain code.
195 if (withoutPosition.endsWith("/")) return null;
196 const firstSegment = withoutPosition.split("/")[0] ?? withoutPosition;
197 if (looksLikeHostname(firstSegment, hasPosition)) return null;
198 const basename =
199 withoutPosition
200 .replace(/[/\\]+$/, "")
201 .split(/[\\/]/)
202 .at(-1) ?? "";
203 if (VERSION_SUFFIX_PATTERN.test(basename)) return null;
204 if (!FILE_EXTENSION_PATTERN.test(basename)) {
205 if (!hasPosition) return null;
206 // A `:line` suffix alone does not make `I/W217:220` a file reference.
207 if (
208 withoutPosition.includes("/") &&
209 !ANY_FILE_EXTENSION_PATTERN.test(basename) &&
210 !EXTENSIONLESS_FILE_NAMES.has(basename)
211 ) {
212 return null;
213 }
214 }
215 }
216 return candidate;
218​
219export function safeDecodeURIComponent(value: string): string {
220 try {
221 return decodeURIComponent(value);
222 } catch {
223 return value;
224 }
226​
227export function normalizeMarkdownLinkDestination(value: string): string {
228 const trimmed = value.trim();
229 return trimmed.startsWith("<") && trimmed.endsWith(">") ? trimmed.slice(1, -1) : trimmed;
231​
232export function splitMarkdownLinkSearchAndHash(value: string): {
233 readonly path: string;
234 readonly hash: string;
235} {
236 const hashIndex = value.indexOf("#");
237 const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value;
238 const hash = hashIndex >= 0 ? value.slice(hashIndex) : "";
239 const queryIndex = pathWithSearch.indexOf("?");
240 return {
241 path: queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch,
242 hash,
243 };
245​
246/**
247 * The decoded path a markdown destination names on the host, before any workspace resolution, or
248 * null for a `file:` URL that does not parse. Clients and the guest media route both read it here.
249 */
250export function markdownDestinationHostPath(source: string): string | null {
251 if (/^file:/i.test(source)) {
252 const target = parseFileUrlHref(source);
253 return target === null
254 ? null
255 : stripSlashPrefixedWindowsDrive(safeDecodeURIComponent(target.path));
256 }
257 return stripSlashPrefixedWindowsDrive(
258 safeDecodeURIComponent(splitMarkdownLinkSearchAndHash(source).path),
259 );
261​
262export function isMarkdownFileLinkLabel(label: string, href: string): boolean {
263 const destination = parseMarkdownFileLink(href);
264 if (!destination) return false;
265 const normalize = (path: string) =>
266 path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$/, "");
267 const labelPosition = splitFilePathPosition(label.trim());
268 if (
269 (labelPosition.line !== undefined && labelPosition.line !== destination.line) ||
270 (labelPosition.column !== undefined && labelPosition.column !== destination.column) ||
271 (labelPosition.endLine !== undefined && labelPosition.endLine !== destination.endLine)
272 ) {
273 return false;
274 }
275 let labelPath = normalize(labelPosition.path);
276 let destinationPath = normalize(destination.path);
277 if (labelPath.length === 0) return true;
278 if (isWindowsAbsolutePath(destination.path)) {
279 labelPath = labelPath.toLowerCase();
280 destinationPath = destinationPath.toLowerCase();
281 }
282 return destinationPath === labelPath || destinationPath.endsWith(`/${labelPath}`);
284​
285export function isRelativeFilePath(path: string): boolean {
286 return (
287 RELATIVE_PATH_PREFIX_PATTERN.test(path) ||
288 (!path.startsWith("/") && !isWindowsAbsolutePath(path))
289 );
291​
292function looksLikePosixFilesystemPath(path: string): boolean {
293 if (!path.startsWith("/")) return false;
294 if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true;
295 if (POSITION_SUFFIX_PATTERN.test(path)) return true;
296 const basename = path.slice(path.lastIndexOf("/") + 1);
297 return EXTENSIONLESS_FILE_NAMES.has(basename) || FILE_EXTENSION_PATTERN.test(basename);
299​
300function looksLikeFilePath(path: string, authoredPath: string): boolean {
301 if (isWindowsAbsolutePath(path) || RELATIVE_PATH_PREFIX_PATTERN.test(path)) return true;
302 if (path.startsWith("/")) return looksLikePosixFilesystemPath(authoredPath);
303 if (EXTENSIONLESS_FILE_NAMES.has(path)) return true;
304 return RELATIVE_FILE_PATH_PATTERN.test(authoredPath) || RELATIVE_FILE_NAME_PATTERN.test(path);
306​
307function hasExternalScheme(path: string): boolean {
308 if (isWindowsAbsolutePath(path)) return false;
309 const match = path.match(EXTERNAL_SCHEME_PATTERN);
310 if (!match) return false;
311 const rest = match[2] ?? "";
312 if (rest.startsWith("//")) return true;
313 return !POSITION_ONLY_PATTERN.test(rest);
315​
316export function parseMarkdownFileLink(href: string): FilePathPosition | null {
317 const normalized = normalizeMarkdownLinkDestination(href);
318 if (normalized.length === 0 || normalized.startsWith("#") || normalized.startsWith("//")) {
319 return null;
320 }
321​
322 const source =
323 (normalized.toLowerCase().startsWith("file:") ? parseFileUrlHref(normalized) : null) ??
324 splitMarkdownLinkSearchAndHash(normalized);
325 const path = stripSlashPrefixedWindowsDrive(safeDecodeURIComponent(source.path.trim()));
326 const hash = safeDecodeURIComponent(source.hash.trim());
327 if (path.length === 0 || hasExternalScheme(path)) return null;
328​
329 const position = parseFilePathLink(path, hash);
330 // `src\a.ts` is Windows-relative. A link that already parses keeps its spelling, since POSIX
331 // names may contain `\`; a rooted `\\host` or `\foo` would change root if rewritten.
332 if (position || /^[\\/]/.test(path) || isWindowsAbsolutePath(path)) return position;
333 return path.includes("\\") ? parseFilePathLink(path.replaceAll("\\", "/"), hash) : null;
335​
336function parseFilePathLink(path: string, hash: string): FilePathPosition | null {
337 const position = splitFilePathPosition(path, hash);
338 return !ELIDED_PATH_SEGMENT_PATTERN.test(position.path) && looksLikeFilePath(position.path, path)
339 ? position
340 : null;
342​
343const FENCED_CODE_SEGMENT_PATTERN = /(```[\s\S]*?(?:```|$))/;
344​
345const INLINE_CODE_SPAN_PATTERN = /`([^`\n]+)`/g;
346​
347export function extractInlineCodeSpans(text: string): string[] {
348 const spans: string[] = [];
349 const segments = text.split(FENCED_CODE_SEGMENT_PATTERN);
350 for (let index = 0; index < segments.length; index += 2) {
351 for (const match of (segments[index] ?? "").matchAll(INLINE_CODE_SPAN_PATTERN)) {
352 const span = match[1]?.trim();
353 if (span) spans.push(span);
354 }
355 }
356 return spans;
358​
359function markdownRunEndFinder(markdown: string, delimiter: RegExp) {
360 let runStart = -1;
361 let runEnd = -1;
362 return (start: number) => {
363 if (start >= runStart && start <= runEnd) return runEnd;
364 delimiter.lastIndex = start;
365 runStart = start;
366 runEnd = delimiter.exec(markdown)?.index ?? markdown.length;
367 return runEnd;
368 };
370​
371function markdownLinkTailScanner(markdown: string) {
372 const whitespaceEnd = markdownRunEndFinder(markdown, /\S/g);
373 const titleEnd = markdownRunEndFinder(markdown, /["']/g);
374 const titleWhitespaceEnd = markdownRunEndFinder(markdown, /\S/g);
375 return (start: number) => {
376 const end = whitespaceEnd(start);
377 if (markdown[end] === ")") return end + 1;
378 if (end === start || (markdown[end] !== '"' && markdown[end] !== "'")) return null;
379 const quoteEnd = titleEnd(end + 1);
380 if (quoteEnd === markdown.length) return null;
381 const close = titleWhitespaceEnd(quoteEnd + 1);
382 return markdown[close] === ")" ? close + 1 : null;
383 };
385​
386function markdownLinkDestinationScanner(markdown: string) {
387 const whitespaceEnd = markdownRunEndFinder(markdown, /\S/g);
388 const angleEnd = markdownRunEndFinder(markdown, /[>\n]/g);
389 const bareEnd = markdownRunEndFinder(markdown, /[\s)]/g);
390 // Separate caches keep scans moving forward when angle destinations fall back to bare ones.
391 const angleTail = markdownLinkTailScanner(markdown);
392 const bareTail = markdownLinkTailScanner(markdown);
393 return (start: number) => {
394 if (markdown[start] !== "(") return null;
395 const hrefStart = whitespaceEnd(start + 1);
396 if (markdown[hrefStart] === "<") {
397 const hrefEnd = angleEnd(hrefStart + 1);
398 if (hrefEnd > hrefStart + 1 && markdown[hrefEnd] === ">") {
399 const end = angleTail(hrefEnd + 1);
400 if (end !== null) return { hrefStart: hrefStart + 1, hrefEnd, end };
401 }
402 }
403 const hrefEnd = bareEnd(hrefStart);
404 if (hrefEnd === hrefStart) return null;
405 const end = bareTail(hrefEnd);
406 return end === null ? null : { hrefStart, hrefEnd, end };
407 };
409​
410// The composer escapes brackets in labels (`[\[id\].tsx](...)`), and `\]` does not close one.
411function unescapedLabelEnd(markdown: string, start: number): number {
412 let end = markdown.indexOf("]", start);
413 while (end !== -1) {
414 let backslashes = 0;
415 while (end - backslashes > start && markdown[end - backslashes - 1] === "\\") backslashes += 1;
416 if (backslashes % 2 === 0) return end;
417 end = markdown.indexOf("]", end + 1);
418 }
419 return -1;
421​
422export function extractMarkdownLinkHrefs(markdown: string): string[] {
423 const hrefs: string[] = [];
424 const scanDestination = markdownLinkDestinationScanner(markdown);
425 let cursor = 0;
426 while (cursor < markdown.length) {
427 const labelStart = markdown.indexOf("[", cursor);
428 if (labelStart === -1) break;
429 const labelEnd = unescapedLabelEnd(markdown, labelStart + 1);
430 if (labelEnd === -1) break;
431​
432 // Skip a failed label once; nested openings share its closing bracket.
433 cursor = labelEnd + 1;
434 const destination = scanDestination(cursor);
435 if (!destination) continue;
436 cursor = destination.end;
437 const href = markdown.slice(destination.hrefStart, destination.hrefEnd).trim();
438 if (href) hrefs.push(href);
439 }
440 return hrefs;
442​
443export function resolveMarkdownFileLinkTarget(
444 href: string | undefined,
445 cwd?: string,
446 baseDir: string | undefined = cwd,
447): string | null {
448 if (!href) return null;
449 const target = parseMarkdownFileLink(href);
450 if (!target) return null;
451​
452 const pathWithPosition = formatFilePathPosition(target);
453 if (!isRelativeFilePath(pathWithPosition)) {
454 // Opening a network path makes Windows sign in to its host with the user's
455 // NTLM credentials, so only the project's own share becomes a file link.
456 return isNetworkPath(target.path) && workspaceRelativeFilePath(target.path, cwd) === null
457 ? null
458 : pathWithPosition;
459 }
460 if (!baseDir) return null;
461 return resolvePathLinkTarget(pathWithPosition, baseDir);
463​
464export function isWindowsDrivePathHref(href: string): boolean {
465 return /^[A-Za-z]:[\\/]/.test(safeDecodeURIComponent(href));
467​
468// Bare prose paths need a filesystem root or a position, so `/api/users.json` stays a route,
469// and a file-shaped last segment.
470export function absoluteFilePathMatches(text: string): Array<{ index: number; path: string }> {
471 const paths: Array<{ index: number; path: string }> = [];
472 for (const match of text.matchAll(/(^|[\s(["'])(\/[^\s<>"'`]+)/g)) {
473 const source = match[2]!;
474 let parentheses = 0;
475 let brackets = 0;
476 for (const character of source) {
477 if (character === "(") parentheses += 1;
478 if (character === ")") parentheses -= 1;
479 if (character === "[") brackets += 1;
480 if (character === "]") brackets -= 1;
481 }
482 let end = source.length;
483 while (end > 0) {
484 const character = source[end - 1]!;
485 if (character === ")" && parentheses < 0) parentheses += 1;
486 else if (character === "]" && brackets < 0) brackets += 1;
487 else if (!".,;!?:".includes(character)) break;
488 end -= 1;
489 }
490 const path = source.slice(0, end);
491 // Trimming `/tmp/a/....` to `/tmp/a/` would hide its elided segment.
492 if (path.endsWith("/") && source.startsWith("...", end)) continue;
493 if (
494 !POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix)) &&
495 !POSITION_SUFFIX_PATTERN.test(path)
496 )
497 continue;
498 // Prose ends a path at whitespace, so `/Users/me/Library/Application Support/...` must not link.
499 const basename = path.slice(path.lastIndexOf("/") + 1);
500 if (
501 !POSITION_SUFFIX_PATTERN.test(path) &&
502 !FILE_EXTENSION_PATTERN.test(basename) &&
503 !EXTENSIONLESS_FILE_NAMES.has(basename)
504 )
505 continue;
506 if (!parseMarkdownFileLink(path)) continue;
507 paths.push({ index: match.index + match[1]!.length, path });
508 }
509 return paths;