Files
pi-agent-integrated/pi-web/lib/file-links.ts
T

120 lines
4.1 KiB
TypeScript

function safeDecode(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function normalizeFilePathSlashes(filePath: string): string {
if (/^[a-zA-Z]:[\\/]/.test(filePath) || filePath.startsWith("\\\\")) {
return filePath.replace(/\\/g, "/");
}
return filePath;
}
function stripLineSuffix(filePath: string): string {
return filePath.replace(/:\d+(?::\d+)?$/, "");
}
function normalizeLocalPath(filePath: string): string {
const normalized = normalizeFilePathSlashes(filePath);
const isWindowsDrive = /^[a-zA-Z]:\//.test(normalized);
const isUnc = normalized.startsWith("//");
const leadingSlash = normalized.startsWith("/") && !isWindowsDrive && !isUnc;
const parts: string[] = [];
for (const part of normalized.split("/")) {
if (!part || part === ".") continue;
if (part === "..") {
if (parts.length > 0 && parts[parts.length - 1] !== "..") {
parts.pop();
} else if (!leadingSlash && !isWindowsDrive && !isUnc) {
parts.push(part);
}
continue;
}
parts.push(part);
}
const joined = parts.join("/");
if (isWindowsDrive) return joined;
if (isUnc) return `//${joined}`;
return leadingSlash ? `/${joined}` : joined;
}
function isPathInside(candidate: string, root: string): boolean {
const normalizedCandidate = normalizeLocalPath(candidate).replace(/\/+$/, "");
const normalizedRoot = normalizeLocalPath(root).replace(/\/+$/, "");
const useCaseInsensitive = /^[a-zA-Z]:\//.test(normalizedCandidate) || /^[a-zA-Z]:\//.test(normalizedRoot);
const filePath = useCaseInsensitive ? normalizedCandidate.toLowerCase() : normalizedCandidate;
const rootPath = useCaseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot;
return filePath === rootPath || filePath.startsWith(`${rootPath}/`);
}
function looksLikeRelativeFileHref(href: string): boolean {
if (href.startsWith("#") || href.startsWith("?")) return false;
if (href.startsWith("./") || href.startsWith("../")) return true;
if (href.includes("/")) return true;
return /(^|\/)\.?[^/]+\.[^/.]+$/.test(href);
}
function fileUrlToPath(href: string): string | null {
try {
const url = new URL(href);
if (url.protocol !== "file:") return null;
const pathname = safeDecode(url.pathname);
if (url.hostname) {
return `//${url.hostname}${pathname.startsWith("/") ? pathname : `/${pathname}`}`;
}
if (/^\/[a-zA-Z]:\//.test(pathname)) return pathname.slice(1);
return pathname;
} catch {
return null;
}
}
export function resolveLocalFileHref(
href: string | undefined,
baseDir?: string,
relativeRoot = baseDir,
): string | null {
if (!href) return null;
const cleanHref = href.split("#", 1)[0].split("?", 1)[0].trim();
if (!cleanHref) return null;
let candidate: string | null = null;
let candidateKind: "absolute" | "relative" | null = null;
const decodedHref = safeDecode(cleanHref);
const isBackslashUncPath = decodedHref.startsWith("\\\\");
const normalizedHref = normalizeFilePathSlashes(decodedHref);
const lowerHref = normalizedHref.toLowerCase();
if (lowerHref.startsWith("/api/") || lowerHref.startsWith("/_next/")) return null;
if (!isBackslashUncPath && normalizedHref.startsWith("//")) return null;
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/i.test(normalizedHref) && !lowerHref.startsWith("file:") && !/^[a-zA-Z]:\//.test(normalizedHref)) {
return null;
}
if (lowerHref.startsWith("file:")) {
candidate = fileUrlToPath(normalizedHref);
candidateKind = candidate ? "absolute" : null;
} else if (/^[a-zA-Z]:\//.test(normalizedHref)) {
candidate = normalizedHref;
candidateKind = "absolute";
} else if (normalizedHref.startsWith("/")) {
candidate = normalizedHref;
candidateKind = "absolute";
} else if (baseDir && looksLikeRelativeFileHref(normalizedHref)) {
candidate = `${normalizeFilePathSlashes(baseDir).replace(/\/+$/, "")}/${normalizedHref}`;
candidateKind = "relative";
}
if (!candidate) return null;
const filePath = stripLineSuffix(normalizeLocalPath(candidate));
if (candidateKind === "relative" && relativeRoot && !isPathInside(filePath, relativeRoot)) return null;
return filePath;
}