export function getFileExtension(path: string): string { const lastDot = path.lastIndexOf("."); const lastSlash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); if (lastDot <= lastSlash + 1) return ""; return path.slice(lastDot + 1); } const WINDOWS_ABSOLUTE_PATH = /^[A-Za-z]:[\\/]/; const EXPLICIT_URL_SCHEME = /^[A-Za-z][A-Za-z\d+.-]*:/; const MARKDOWN_ESCAPABLE = new Set(`!"#$%&'()*+,-./:;<=>?@[\\]^_\`{|}~ \t`.split("")); export type LinkTarget = | { kind: "internal"; path: string; anchor?: string } | { kind: "same-doc-anchor"; anchor: string } | { kind: "external-url"; url: string } | { kind: "external-path"; path: string }; function splitLinkHref(href: string): string { const hashIndex = href.indexOf("#"); const queryIndex = href.indexOf("?"); const splitIndex = hashIndex === -1 ? queryIndex : queryIndex === -1 ? hashIndex : Math.min(hashIndex, queryIndex); return splitIndex === -1 ? href : href.slice(0, splitIndex); } function extractAnchor(href: string): string | undefined { const hashIndex = href.indexOf("#"); if (hashIndex === -1) return undefined; const anchor = href.slice(hashIndex + 1); return anchor ? decodeLinkPath(anchor) : undefined; } export function decodeLinkPath(path: string): string { try { return decodeURIComponent(path); } catch { return path; } } function unescapeMarkdownDestination(path: string): string { let result = ""; for (let index = 0; index < path.length; index++) { const char = path[index]!; const next = path[index + 1]; if (char === "\\" && next && MARKDOWN_ESCAPABLE.has(next)) { result += next; index++; continue; } result += char; } return result; } export function normalizeMarkdownDestination(destination: string): string { let normalized = destination.trim(); if (normalized.startsWith("<") && normalized.endsWith(">")) { normalized = normalized.slice(1, -1).trim(); } return unescapeMarkdownDestination(normalized); } export function normalizeLocalMarkdownDestination(destination: string): string { return decodeLinkPath(normalizeMarkdownDestination(destination)); } export function formatMarkdownDestination(destination: string): string { if (!/[\s<>]/.test(destination)) return destination; const escaped = destination.replace(//g, "%3E"); return `<${escaped}>`; } export function normalizePath(path: string): string { const normalized = path.replace(/\\/g, "/"); const isWindowsAbsolute = WINDOWS_ABSOLUTE_PATH.test(normalized); const hasLeadingSlash = normalized.startsWith("/"); const parts = normalized.split("/"); const stack: string[] = []; for (const part of parts) { if (!part || part === ".") continue; if (part === "..") { const last = stack[stack.length - 1]; if (last && last !== "..") { if (isWindowsAbsolute || hasLeadingSlash || !WINDOWS_ABSOLUTE_PATH.test(last)) { stack.pop(); continue; } } if (!isWindowsAbsolute && !hasLeadingSlash) stack.push(part); continue; } stack.push(part); } if (isWindowsAbsolute) { const [drive, ...rest] = stack; return rest.length === 0 ? `${drive}/` : `${drive}/${rest.join("/")}`; } if (hasLeadingSlash) { return stack.length === 0 ? "/" : `/${stack.join("/")}`; } return stack.join("/"); } function resolvePath(baseDir: string, target: string): string { if (WINDOWS_ABSOLUTE_PATH.test(target) || target.startsWith("/")) { return normalizePath(target); } return normalizePath(`${baseDir.replace(/\/$/, "")}/${target}`); } function isPathInsideRoot(path: string, root: string): boolean { const normalizedPath = normalizePath(path); const normalizedRoot = normalizePath(root).replace(/\/$/, ""); return normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`); } export type FileExistsFn = (path: string) => boolean | Promise; export async function resolveLinkTarget( href: string, currentFilePath: string, workspaceRoot?: string | null, fileExists?: FileExistsFn, ): Promise { const trimmed = normalizeMarkdownDestination(href); if (!trimmed) return null; if (trimmed.startsWith("#")) { const anchor = extractAnchor(trimmed); return anchor ? { kind: "same-doc-anchor", anchor } : null; } if (EXPLICIT_URL_SCHEME.test(trimmed) && !WINDOWS_ABSOLUTE_PATH.test(trimmed)) { return { kind: "external-url", url: trimmed }; } const target = splitLinkHref(trimmed); if (!target) return null; const anchor = extractAnchor(trimmed); const decodedTarget = decodeLinkPath(target); const resolvedPath = resolvePath(getParentDir(currentFilePath), decodedTarget); const extension = getFileExtension(resolvedPath).toLowerCase(); const isMarkdown = extension === "md" || extension === "markdown"; if (isMarkdown && (!workspaceRoot || isPathInsideRoot(resolvedPath, workspaceRoot))) { return { kind: "internal", path: resolvedPath, ...(anchor ? { anchor } : {}) }; } // Probe for markdown files when the resolved path has no recognized extension. // For doc-corpus conventions (Hugo, Docusaurus, MDN, …), extensionless // absolute paths like `/docs/foo/bar/` are site-root-relative, so we probe // both their workspace-root mapping and their filesystem mapping. if (extension === "" && fileExists) { const bases: string[] = [resolvedPath.replace(/\/+$/, "")]; const isPosixAbsolute = decodedTarget.startsWith("/") && !WINDOWS_ABSOLUTE_PATH.test(decodedTarget); if (isPosixAbsolute && workspaceRoot) { const rootRelative = normalizePath( `${workspaceRoot.replace(/\/$/, "")}${decodedTarget}`, ).replace(/\/+$/, ""); if (!bases.includes(rootRelative)) bases.push(rootRelative); } for (const base of bases) { const candidates = [ `${base}.md`, `${base}.markdown`, `${base}/index.md`, `${base}/index.markdown`, `${base}/README.md`, ]; for (const candidate of candidates) { if (workspaceRoot && !isPathInsideRoot(candidate, workspaceRoot)) continue; if (await fileExists(candidate)) { return { kind: "internal", path: candidate, ...(anchor ? { anchor } : {}) }; } } } } return { kind: "external-path", path: resolvedPath }; } export function getFileStem(path: string): string { const name = getFileName(path); const lastDot = name.lastIndexOf("."); if (lastDot <= 0) return name; return name.slice(0, lastDot); } export function getFileName(path: string): string { const lastSlash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); return path.slice(lastSlash + 1); } export function getRelativePath(fullPath: string, root: string): string { const normalized = fullPath.replace(/\\/g, "/"); const normalizedRoot = root.replace(/\\/g, "/").replace(/\/$/, ""); if (normalized.startsWith(normalizedRoot + "/")) { return normalized.slice(normalizedRoot.length + 1); } return normalized; } export function getParentDir(path: string): string { const normalized = path.replace(/\\/g, "/"); const lastSlash = normalized.lastIndexOf("/"); if (lastSlash <= 0) return "/"; return normalized.slice(0, lastSlash); } export function resolveImagePath(imageSrc: string, markdownDir: string): string { const normalizedSrc = normalizeMarkdownDestination(imageSrc); if (normalizedSrc.startsWith("http://") || normalizedSrc.startsWith("https://")) { return normalizedSrc; } const localSrc = decodeLinkPath(normalizedSrc); if (localSrc.startsWith("/")) return localSrc; return `${markdownDir.replace(/\/$/, "")}/${localSrc}`; }