404 lines · 12.3 KB · TypeScript
RawDownload
import { formatWorkspaceRelativePath } from "./filePathDisplay";import { resolvePathLinkTarget, splitPathAndPosition } from "./terminal-links"; const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;const EXTERNAL_SCHEME_PATTERN = /^([A-Za-z][A-Za-z0-9+.-]*):(.*)$/;const RELATIVE_PATH_PREFIX_PATTERN = /^(~\/|\.{1,2}\/)/;const RELATIVE_FILE_PATH_PATTERN = /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}$/;const RELATIVE_FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+){0,2}$/;const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/;// Standard OS and dev-container roots; deliberately excludes app-route-ish// prefixes like /app/ or /chat/ so SPA routes never read as files.const POSIX_FILE_ROOT_PREFIXES = [  "/Users/",  "/home/",  "/tmp/",  "/var/",  "/etc/",  "/opt/",  "/mnt/",  "/Volumes/",  "/private/",  "/root/",  "/usr/",  "/bin/",  "/sbin/",  "/lib/",  "/lib64/",  "/srv/",  "/dev/",  "/proc/",  "/sys/",  "/run/",  "/boot/",  "/media/",  "/workspace/",  "/workspaces/",] as const; export interface MarkdownFileLinkMeta {  filePath: string;  targetPath: string;  displayPath: string;  workspaceRelativePath: string | null;  basename: string;  line?: number;  column?: number;} function safeDecode(value: string): string {  try {    return decodeURIComponent(value);  } catch {    return value;  }} function unwrapMarkdownLinkDestination(value: string): string {  return value.startsWith("<") && value.endsWith(">") ? value.slice(1, -1) : value;} export function normalizeMarkdownLinkDestination(value: string): string {  return unwrapMarkdownLinkDestination(value.trim());} function stripSearchAndHash(value: string): { path: string; hash: string } {  const hashIndex = value.indexOf("#");  const pathWithSearch = hashIndex >= 0 ? value.slice(0, hashIndex) : value;  const rawHash = hashIndex >= 0 ? value.slice(hashIndex) : "";  const queryIndex = pathWithSearch.indexOf("?");  const path = queryIndex >= 0 ? pathWithSearch.slice(0, queryIndex) : pathWithSearch;  return { path, hash: rawHash };} function normalizeWindowsDrivePath(path: string): string {  return /^\/[A-Za-z]:[\\/]/.test(path) ? path.slice(1) : path;} function parseFileUrlHref(  href: string,  options?: { readonly decodePath?: boolean },): { path: string; hash: string } | null {  try {    const parsed = new URL(href);    if (parsed.protocol.toLowerCase() !== "file:") return null;     const rawPath = parsed.pathname;    if (rawPath.length === 0) return null;     // Browser URL parser encodes "C:/foo" as "/C:/foo" for file URLs.    const normalizedPath = normalizeWindowsDrivePath(rawPath);     return {      path: options?.decodePath === false ? normalizedPath : safeDecode(normalizedPath),      hash: parsed.hash,    };  } catch {    return null;  }} export function rewriteMarkdownFileUriHref(href: string | undefined): string | null {  if (!href) return null;  const normalizedHref = normalizeMarkdownLinkDestination(href);  const target = parseFileUrlHref(normalizedHref, { decodePath: false });  if (!target) return null;  return `${target.path}${target.hash}`;} function looksLikePosixFilesystemPath(path: string): boolean {  if (!path.startsWith("/")) return false;  if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true;  if (POSITION_SUFFIX_PATTERN.test(path)) return true;  const basename = path.slice(path.lastIndexOf("/") + 1);  return /\.[A-Za-z0-9_-]+$/.test(basename);} function appendLineColumnFromHash(path: string, hash: string): string {  if (!hash || POSITION_SUFFIX_PATTERN.test(path)) return path;  const match = hash.match(/^#L(\d+)(?:C(\d+))?$/i);  if (!match?.[1]) return path;  const line = match[1];  const column = match[2];  return `${path}:${line}${column ? `:${column}` : ""}`;} function isLikelyPathCandidate(path: string): boolean {  if (WINDOWS_DRIVE_PATH_PATTERN.test(path) || WINDOWS_UNC_PATH_PATTERN.test(path)) return true;  if (RELATIVE_PATH_PREFIX_PATTERN.test(path)) return true;  if (path.startsWith("/")) return looksLikePosixFilesystemPath(path);  return RELATIVE_FILE_PATH_PATTERN.test(path) || RELATIVE_FILE_NAME_PATTERN.test(path);} function isRelativePath(path: string): boolean {  return (    RELATIVE_PATH_PREFIX_PATTERN.test(path) ||    (!path.startsWith("/") &&      !WINDOWS_DRIVE_PATH_PATTERN.test(path) &&      !WINDOWS_UNC_PATH_PATTERN.test(path))  );} function hasExternalScheme(path: string): boolean {  const match = path.match(EXTERNAL_SCHEME_PATTERN);  if (!match) return false;  const rest = match[2] ?? "";  if (rest.startsWith("//")) return true;  return !POSITION_ONLY_PATTERN.test(rest);} export function resolveMarkdownFileLinkTarget(  href: string | undefined,  cwd?: string,): string | null {  if (!href) return null;  const rawHref = normalizeMarkdownLinkDestination(href);  if (rawHref.length === 0 || rawHref.startsWith("#")) return null;   const fileUrlTarget = rawHref.toLowerCase().startsWith("file:")    ? parseFileUrlHref(rawHref)    : null;  const source = fileUrlTarget ?? stripSearchAndHash(rawHref);  const decodedPath = normalizeWindowsDrivePath(    fileUrlTarget ? source.path.trim() : safeDecode(source.path.trim()),  );  const decodedHash = safeDecode(source.hash.trim());   if (decodedPath.length === 0) return null;  if (    !WINDOWS_DRIVE_PATH_PATTERN.test(decodedPath) &&    !WINDOWS_UNC_PATH_PATTERN.test(decodedPath) &&    hasExternalScheme(decodedPath)  ) {    return null;  }   if (!isLikelyPathCandidate(decodedPath)) return null;   const pathWithPosition = appendLineColumnFromHash(decodedPath, decodedHash);  if (!isRelativePath(pathWithPosition)) {    return pathWithPosition;  }   if (!cwd) return null;  return resolvePathLinkTarget(pathWithPosition, cwd);} const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/;const PATH_SEPARATOR_PATTERN = /[\\/]/;const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/;const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/;const BARE_EXTENSIONLESS_POSITION_PATTERN = /^[A-Za-z0-9_-]+(?::\d+){1,2}$/;// Any `Name:digits` shape also matches `error:1`, `port:3000`, `TODO:12`, so// extensionless linking is limited to conventional filenames.const EXTENSIONLESS_FILE_NAMES = new Set([  "Makefile",  "makefile",  "GNUmakefile",  "Dockerfile",  "Containerfile",  "Justfile",  "justfile",  "Rakefile",  "Gemfile",  "Procfile",  "Brewfile",  "Caddyfile",  "Vagrantfile",  "Jenkinsfile",  "Podfile",  "Fastfile",  "BUILD",  "WORKSPACE",  "LICENSE",  "LICENCE",  "COPYING",  "NOTICE",  "AUTHORS",  "CONTRIBUTORS",  "CHANGELOG",  "README",  "CODEOWNERS",]);const SINGLE_LABEL_HOSTNAMES = new Set(["localhost"]);// Allowlists, not full public-suffix detection: treating every dotted first// segment as a host would swallow real paths like `conf.d/x.conf` or// `Makefile.in:12`. Extensions that double as filename suffixes (`sh`, `md`,// `ts`, `rs`, `in`, ...) are deliberately absent from both sets.const GENERIC_HOSTNAME_TLDS = new Set([  "com",  "net",  "org",  "io",  "dev",  "app",  "ai",  "co",  "edu",  "gov",  "mil",  "info",  "biz",  "xyz",  "me",  "tv",  "cc",  "gg",  "chat",  "cloud",  "site",  "online",  "tech",  "store",  "link",]);// Country codes collide with file extensions (`.pl` Perl, `.pt` PyTorch,// `.es` ES modules), so they only count as host evidence when the candidate// lacks a :line suffix — an explicit line reference marks a file and wins.const COUNTRY_HOSTNAME_TLDS = new Set([  "uk",  "de",  "fr",  "nl",  "se",  "no",  "fi",  "dk",  "pl",  "ch",  "at",  "be",  "es",  "it",  "pt",  "eu",  "us",  "ca",  "au",  "nz",  "jp",  "kr",  "cn",  "br",  "ru",  "mx",  "ie",  "cz",  "tr",  "sg",  "hk",]); /** `127.0.0.1`, `localhost`, `example.com`, `1.2.3` — hosts and versions, not files. */function looksLikeHostname(segment: string, hasPosition: boolean): boolean {  if (segment.startsWith(".")) return false;  const lowered = segment.toLowerCase();  if (SINGLE_LABEL_HOSTNAMES.has(lowered)) return true;  if (NUMERIC_DOTTED_PATTERN.test(segment)) return true;  const labels = lowered.split(".");  const lastLabel = labels[labels.length - 1];  if (labels.length < 2 || lastLabel === undefined) return false;  if (GENERIC_HOSTNAME_TLDS.has(lastLabel)) return true;  return !hasPosition && COUNTRY_HOSTNAME_TLDS.has(lastLabel);} /** * Inline code spans mostly hold identifiers, commands, and refs (`node.meta`, * `origin/main`) rather than deliberate link destinations, so auto-linking * them demands stronger path evidence than an explicit markdown link does: * an unambiguous path prefix, a file extension, or a :line suffix. */export function resolveInlineCodeFileLinkMeta(  codeText: string,  cwd?: string,): MarkdownFileLinkMeta | null {  const trimmed = codeText.trim();  if (trimmed.length === 0 || INLINE_CODE_DISQUALIFIER_PATTERN.test(trimmed)) return null;   // Windows drive/UNC paths keep their backslashes; any other backslashes are  // relative Windows-style paths, which neither the shape checks nor the  // downstream resolver understand — normalize them to forward slashes.  const candidate =    WINDOWS_DRIVE_PATH_PATTERN.test(trimmed) || WINDOWS_UNC_PATH_PATTERN.test(trimmed)      ? trimmed      : trimmed.replaceAll("\\", "/");   const hasPosition = POSITION_SUFFIX_PATTERN.test(candidate);  if (!hasPosition && !PATH_SEPARATOR_PATTERN.test(candidate)) return null;   const hasExplicitPathShape =    RELATIVE_PATH_PREFIX_PATTERN.test(candidate) ||    candidate.startsWith("/") ||    WINDOWS_DRIVE_PATH_PATTERN.test(candidate) ||    WINDOWS_UNC_PATH_PATTERN.test(candidate);  if (!hasExplicitPathShape) {    const withoutPosition = candidate.replace(POSITION_SUFFIX_PATTERN, "");    const firstSegment = withoutPosition.split("/")[0] ?? withoutPosition;    if (looksLikeHostname(firstSegment, hasPosition)) return null;    if (!hasPosition && !FILE_EXTENSION_PATTERN.test(basenameOfPath(withoutPosition))) {      return null;    }  }   const resolved = resolveMarkdownFileLinkMeta(candidate, cwd);  if (resolved) return resolved;   // `Makefile:12` — conventional extensionless names fail the generic  // markdown-link candidate patterns, but here the :line suffix already  // marked the span as a file reference.  if (    cwd &&    BARE_EXTENSIONLESS_POSITION_PATTERN.test(candidate) &&    EXTENSIONLESS_FILE_NAMES.has(candidate.replace(POSITION_SUFFIX_PATTERN, ""))  ) {    return buildFileLinkMetaFromTarget(resolvePathLinkTarget(candidate, cwd), cwd);  }  return null;} function basenameOfPath(path: string): string {  const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));  return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path;} function workspaceRelativePath(path: string, workspaceRoot: string | undefined): string | null {  if (!workspaceRoot) return null;  const normalizedPath = normalizeWindowsDrivePath(path.replaceAll("\\", "/"));  const normalizedRoot = normalizeWindowsDrivePath(workspaceRoot.replaceAll("\\", "/")).replace(    /\/+$/,    "",  );  const pathForCompare = normalizedPath.toLowerCase();  const rootForCompare = normalizedRoot.toLowerCase();  if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null;  return normalizedPath.slice(normalizedRoot.length + 1);} export function resolveMarkdownFileLinkMeta(  href: string | undefined,  cwd?: string,): MarkdownFileLinkMeta | null {  const targetPath = resolveMarkdownFileLinkTarget(href, cwd);  if (!targetPath) return null;  return buildFileLinkMetaFromTarget(targetPath, cwd);} function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): MarkdownFileLinkMeta {  const { path, line, column } = splitPathAndPosition(targetPath);  const parsedLine = line ? Number.parseInt(line, 10) : Number.NaN;  const parsedColumn = column ? Number.parseInt(column, 10) : Number.NaN;  const lineNumber = Number.isFinite(parsedLine) ? parsedLine : undefined;  const columnNumber = Number.isFinite(parsedColumn) ? parsedColumn : undefined;   return {    filePath: path,    targetPath,    displayPath: formatWorkspaceRelativePath(targetPath, cwd),    workspaceRelativePath: workspaceRelativePath(path, cwd),    basename: basenameOfPath(path),    ...(lineNumber !== undefined ? { line: lineNumber } : {}),    ...(columnNumber !== undefined ? { column: columnNumber } : {}),  };}