源码
packages/ui/src/preview/preview-file-path.ts
// Adapted from ZCode markdownFileLink.ts (Apache-2.0), commit 872ad960.
// See licenses/third-party-notices.txt and docs/internals/previews.md.
import { stripBalancedAssistantPathQuotes } from "./assistant-path-quotes.ts";
import {
decodeFilePathUriEscapes,
isAbsoluteFilePath,
joinFilePath,
normalizeWorkspaceRelativeFilePath,
parseFileUrlPath,
} from "./path.ts";
export { normalizeWorkspaceRelativeFilePath } from "./path.ts";
interface ParsedMarkdownFileLink {
path: string;
lineNumber: number | null;
columnNumber: number | null;
}
export interface MarkdownFileLinkResolveOptions {
homePath?: string;
}
const LINE_AND_COLUMN_SUFFIX_RE = /^(?<path>.+):(?<line>\d+):(?<column>\d+)$/;
const LINE_SUFFIX_RE = /^(?<path>.+):(?<line>\d+)$/;
const HASH_LINE_SUFFIX_RE = /^(?<path>.+)#L(?<line>\d+)(?:-L?\d+)?$/i;
const FILE_URL_PROTOCOL_RE = /^file:\/\//i;
const HARDEN_SAFE_WINDOWS_ABSOLUTE_PATH_RE = /^\/[a-zA-Z]:\//;
const COMMON_UNIX_ABSOLUTE_ROOT_SEGMENTS = new Set([
"Applications",
"Library",
"System",
"Users",
"Volumes",
"bin",
"boot",
"dev",
"etc",
"home",
"lib",
"lib64",
"media",
"mnt",
"opt",
"private",
"proc",
"root",
"run",
"sbin",
"srv",
"sys",
"tmp",
"usr",
"var",
]);
function isWorkspaceRelativeFileHref(href: string): boolean {
return href.startsWith("./");
}
function normalizeHomeRelativePath(path: string): string | null {
if (path.startsWith("./~/") || path.startsWith("./~\\")) {
return path.slice(2);
}
return /^~[\\/]/.test(path) ? path : null;
}
function isHomeRelativeFilePath(path: string): boolean {
return normalizeHomeRelativePath(path) !== null;
}
function isUnsupportedNamedHomePath(path: string): boolean {
return /^~[^\\/]+[\\/]/.test(path);
}
function resolveHomeRelativeFilePath(path: string, homePath: string | undefined): string | null {
const homeRelativePath = normalizeHomeRelativePath(path);
if (!homeRelativePath || !homePath || !isAbsoluteFilePath(homePath)) {
return null;
}
const separator = homePath.includes("\\") && !homePath.includes("/") ? "\\" : "/";
return joinFilePath(
homePath,
homeRelativePath
.slice(2)
.replace(/^[\\/]+/, "")
.replace(/[\\/]/g, separator),
);
}
function isBareWorkspaceRelativeFileHref(href: string): boolean {
if (
!href ||
href.startsWith("/") ||
href.startsWith("#") ||
href.startsWith("../") ||
/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(href)
) {
return false;
}
return href.includes("/") || href.includes("\\");
}
function isAbsoluteMarkdownFileHref(rawHref: string, normalizedHref: string): boolean {
return isAbsoluteFilePath(rawHref) || isAbsoluteFilePath(normalizedHref) || normalizedHref.startsWith("//");
}
function isLikelyUnixAbsoluteFilePath(path: string): boolean {
if (!path.startsWith("/") || path.startsWith("//")) {
return false;
}
const firstSegment = path.slice(1).split("/")[0];
return Boolean(firstSegment && COMMON_UNIX_ABSOLUTE_ROOT_SEGMENTS.has(firstSegment));
}
function isPathInsideWorkspaceRoot(path: string, workspacePath: string): boolean {
const normalizedPath = path.replace(/\\/g, "/").replace(/\/$/, "");
const normalizedWorkspacePath = workspacePath.replace(/\\/g, "/").replace(/\/$/, "");
return (
normalizedPath === normalizedWorkspacePath || normalizedPath.startsWith(`${normalizedWorkspacePath}/`)
);
}
function normalizeMarkdownFilePath(path: string): string {
return (FILE_URL_PROTOCOL_RE.test(path) ? parseFileUrlPath(path) : null) ?? decodeFilePathUriEscapes(path);
}
function resolveContainedWorkspaceRelativePath(workspacePath: string, relativePath: string): string | null {
const normalizedRelativePath = normalizeWorkspaceRelativeFilePath(relativePath);
if (normalizedRelativePath === null) return null;
const separator = workspacePath.includes("\\") && !workspacePath.includes("/") ? "\\" : "/";
return joinFilePath(workspacePath, normalizedRelativePath.replaceAll("/", separator));
}
export function parseMarkdownFileLinkTarget(href: string): ParsedMarkdownFileLink {
const normalizedHref = stripBalancedAssistantPathQuotes(href).replace(/\\/g, "/");
const withHashLineMatch = normalizedHref.match(HASH_LINE_SUFFIX_RE);
if (withHashLineMatch?.groups) {
const { path, line } = withHashLineMatch.groups;
if (path && line) {
return {
path: normalizeMarkdownFilePath(path),
lineNumber: Number.parseInt(line, 10),
columnNumber: null,
};
}
}
const withLineAndColumnMatch = normalizedHref.match(LINE_AND_COLUMN_SUFFIX_RE);
if (withLineAndColumnMatch?.groups) {
const { path, line, column } = withLineAndColumnMatch.groups;
if (path && line && column) {
return {
path: normalizeMarkdownFilePath(path),
lineNumber: Number.parseInt(line, 10),
columnNumber: Number.parseInt(column, 10),
};
}
}
const withLineMatch = normalizedHref.match(LINE_SUFFIX_RE);
if (withLineMatch?.groups) {
const { path, line } = withLineMatch.groups;
if (path && line) {
return {
path: normalizeMarkdownFilePath(path),
lineNumber: Number.parseInt(line, 10),
columnNumber: null,
};
}
}
return {
path: normalizeMarkdownFilePath(normalizedHref),
lineNumber: null,
columnNumber: null,
};
}
export function resolveMarkdownFileLink(
workspacePath: string | undefined,
href: string,
options: MarkdownFileLinkResolveOptions = {},
): ParsedMarkdownFileLink | null {
const parsedTarget = parseMarkdownFileLinkTarget(href);
if (isUnsupportedNamedHomePath(parsedTarget.path)) {
return null;
}
const homePath = resolveHomeRelativeFilePath(parsedTarget.path, options.homePath);
if (isHomeRelativeFilePath(parsedTarget.path)) {
return homePath
? {
...parsedTarget,
path: homePath,
}
: null;
}
if (HARDEN_SAFE_WINDOWS_ABSOLUTE_PATH_RE.test(parsedTarget.path)) {
if (!workspacePath || workspacePath.startsWith("/") || !isAbsoluteFilePath(workspacePath)) {
return null;
}
return {
...parsedTarget,
path: parsedTarget.path.slice(1),
};
}
if (isWorkspaceRelativeFileHref(parsedTarget.path) || isBareWorkspaceRelativeFileHref(parsedTarget.path)) {
if (!workspacePath) {
return null;
}
const relativePath = isWorkspaceRelativeFileHref(parsedTarget.path)
? parsedTarget.path.slice(2)
: parsedTarget.path;
if (!relativePath) {
return null;
}
const containedPath = resolveContainedWorkspaceRelativePath(workspacePath, relativePath);
if (!containedPath) return null;
return {
...parsedTarget,
path: containedPath,
};
}
if (
workspacePath &&
parsedTarget.path.startsWith("/") &&
!isLikelyUnixAbsoluteFilePath(parsedTarget.path)
) {
if (isPathInsideWorkspaceRoot(parsedTarget.path, workspacePath)) {
return parsedTarget;
}
const containedPath = resolveContainedWorkspaceRelativePath(workspacePath, parsedTarget.path.slice(1));
if (!containedPath) return null;
return {
...parsedTarget,
path: containedPath,
};
}
if (isAbsoluteMarkdownFileHref(href, parsedTarget.path)) {
return parsedTarget;
}
return null;
}