Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions apps/web/src/markdown-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,119 @@ describe("resolveMarkdownFileLinkTarget", () => {
});
});

it("opens a parent link outside the workspace as a host file", () => {
expect(resolveMarkdownFileLinkMeta("../other/notes.md", "/home/me/project")).toMatchObject({
filePath: "/home/me/other/notes.md",
targetPath: "/home/me/other/notes.md",
workspaceRelativePath: null,
});
expect(resolveInlineCodeFileLinkMeta("../other/notes.md", "/home/me/project")).toMatchObject({
filePath: "/home/me/other/notes.md",
workspaceRelativePath: null,
});
expect(
resolveMarkdownFileLinkMeta("/home/me/project/../other/notes.md", "/home/me/project"),
).toMatchObject({
filePath: "/home/me/other/notes.md",
workspaceRelativePath: null,
});
});

it("keeps an in-workspace parent link inside the workspace", () => {
expect(
resolveMarkdownFileLinkMeta("docs/guide/../readme.md", "/home/me/project"),
).toMatchObject({
filePath: "/home/me/project/docs/readme.md",
targetPath: "/home/me/project/docs/readme.md",
workspaceRelativePath: "docs/readme.md",
});
expect(
resolveMarkdownFileLinkMeta(
"../readme.md",
"/home/me/project",
"/home/me/project/docs/guide",
),
).toMatchObject({
filePath: "/home/me/project/docs/readme.md",
workspaceRelativePath: "docs/readme.md",
});
});

it("keeps a line suffix on a collapsed parent link", () => {
expect(resolveMarkdownFileLinkMeta("../other/notes.md:12:3", "/home/me/project")).toMatchObject(
{
filePath: "/home/me/other/notes.md",
targetPath: "/home/me/other/notes.md:12:3",
line: 12,
column: 3,
workspaceRelativePath: null,
},
);
expect(
resolveMarkdownFileLinkMeta("docs/guide/../readme.md:4", "/home/me/project"),
).toMatchObject({
filePath: "/home/me/project/docs/readme.md",
targetPath: "/home/me/project/docs/readme.md:4",
line: 4,
workspaceRelativePath: "docs/readme.md",
});
});

it("expands a home path before collapsing parent segments", () => {
expect(resolveMarkdownFileLinkMeta("~/other/../notes.md:4", "/home/me/project")).toMatchObject({
filePath: "/home/me/notes.md",
targetPath: "/home/me/notes.md:4",
line: 4,
workspaceRelativePath: null,
});
expect(
resolveMarkdownFileLinkMeta("~/other/../notes.md", "C:\\Users\\me\\project"),
).toMatchObject({
filePath: "C:\\Users\\me\\notes.md",
workspaceRelativePath: null,
});
});

it("does not climb above a Windows drive root", () => {
expect(resolveMarkdownFileLinkMeta("../notes.md", "C:\\")).toMatchObject({
filePath: "C:\\notes.md",
workspaceRelativePath: "notes.md",
});
expect(resolveMarkdownFileLinkMeta("../../notes.md", "C:\\repo")).toMatchObject({
filePath: "C:\\notes.md",
workspaceRelativePath: null,
});
expect(resolveMarkdownFileLinkMeta("C:/foo/../../bar.md", "D:\\repo")).toMatchObject({
filePath: "C:/bar.md",
workspaceRelativePath: null,
});
});

it("keeps a UNC share when a relative link climbs out of the project", () => {
expect(resolveMarkdownFileLinkMeta("../file.md", "//server/share/project")).toMatchObject({
filePath: "//server/share/file.md",
targetPath: "//server/share/file.md",
workspaceRelativePath: null,
});
expect(resolveMarkdownFileLinkMeta("../file.md", "\\\\server\\share\\project")).toMatchObject({
filePath: "\\\\server\\share\\file.md",
workspaceRelativePath: null,
});
expect(
resolveMarkdownFileLinkMeta(
"\\\\server\\share\\project\\..\\file.md",
"\\\\server\\share\\project",
),
).toMatchObject({
filePath: "\\\\server\\share\\file.md",
workspaceRelativePath: null,
});
expect(resolveMarkdownFileLinkMeta("dir/../file.md", "//server/share/project")).toMatchObject({
filePath: "//server/share/project/file.md",
workspaceRelativePath: "file.md",
});
});

it("does not classify a case-distinct POSIX sibling as a workspace file", () => {
expect(
resolveMarkdownFileLinkMeta(
Expand Down
16 changes: 13 additions & 3 deletions apps/web/src/markdown-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ import {
} from "@t3tools/client-runtime/markdown-links";

import { formatWorkspaceRelativePath } from "./filePathDisplay";
import { isTerminalLinkActivation, resolvePathLinkTarget } from "./terminal-links";
import {
collapseLexicalDotSegments,
isTerminalLinkActivation,
resolvePathLinkTarget,
} from "./terminal-links";

export { normalizeMarkdownLinkDestination };

Expand Down Expand Up @@ -63,6 +67,10 @@ export function rewriteMarkdownFileUriHref(href: string | undefined): string | n
* `baseDir` anchors relative links; it defaults to the workspace root and is the
* file's own directory when rendering a markdown file. `cwd` stays the workspace
* root so the result still knows whether the target is inside it.
*
* `.` and `..` collapse lexically on the resolved path, after `~/` expansion,
* before workspace membership is decided. A link that climbs out of the workspace
* becomes an absolute host path. A `:line` or `:line:column` suffix is kept.
*/
export function resolveMarkdownFileLinkTarget(
href: string | undefined,
Expand All @@ -74,9 +82,11 @@ export function resolveMarkdownFileLinkTarget(
if (!target) return null;

const pathWithPosition = formatFilePathPosition(target);
if (!isRelativeFilePath(pathWithPosition)) return pathWithPosition;
if (!isRelativeFilePath(pathWithPosition)) {
return collapseLexicalDotSegments(pathWithPosition);
}
if (!baseDir) return null;
return resolvePathLinkTarget(pathWithPosition, baseDir);
return collapseLexicalDotSegments(resolvePathLinkTarget(pathWithPosition, baseDir));
}

/**
Expand Down
91 changes: 91 additions & 0 deletions apps/web/src/terminal-links.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vite-plus/test";

import {
collapseLexicalDotSegments,
collectWrappedTerminalLinkLine,
extractTerminalLinks,
isTerminalLinkActivation,
Expand Down Expand Up @@ -200,6 +201,96 @@ describe("resolvePathLinkTarget", () => {
});
});

describe("collapseLexicalDotSegments", () => {
it("collapses a parent link that leaves the workspace", () => {
expect(collapseLexicalDotSegments("/home/me/project/../other/notes.md")).toBe(
"/home/me/other/notes.md",
);
});

it("collapses an in-workspace parent segment", () => {
expect(collapseLexicalDotSegments("/home/me/project/docs/guide/../readme.md")).toBe(
"/home/me/project/docs/readme.md",
);
expect(collapseLexicalDotSegments("/home/me/project/docs/./readme.md")).toBe(
"/home/me/project/docs/readme.md",
);
});

it("keeps a line and column suffix while collapsing the path", () => {
expect(collapseLexicalDotSegments("/home/me/project/../other/notes.md:12:3")).toBe(
"/home/me/other/notes.md:12:3",
);
expect(collapseLexicalDotSegments("/home/me/project/docs/guide/../readme.md:4")).toBe(
"/home/me/project/docs/readme.md:4",
);
});

it("keeps a forward-slash UNC share when collapsing parent segments", () => {
expect(collapseLexicalDotSegments("//server/share/dir/../file.md")).toBe(
"//server/share/file.md",
);
expect(collapseLexicalDotSegments("//server/share/../file.md")).toBe("//server/share/file.md");
expect(collapseLexicalDotSegments("//server/share/dir/../../file.md")).toBe(
"//server/share/file.md",
);
expect(collapseLexicalDotSegments("//server/share/dir/../file.md:12:3")).toBe(
"//server/share/file.md:12:3",
);
});

it("keeps a backslash UNC share when collapsing parent segments", () => {
expect(collapseLexicalDotSegments("\\\\server\\share\\dir\\..\\file.md")).toBe(
"\\\\server\\share\\file.md",
);
expect(collapseLexicalDotSegments("\\\\server\\share\\..\\file.md")).toBe(
"\\\\server\\share\\file.md",
);
expect(collapseLexicalDotSegments("\\\\server\\share\\dir\\..\\..\\file.md")).toBe(
"\\\\server\\share\\file.md",
);
});

it("does not climb above a POSIX root or a Windows drive root", () => {
expect(collapseLexicalDotSegments("/../file.md")).toBe("/file.md");
expect(collapseLexicalDotSegments("/../../file.md")).toBe("/file.md");
expect(collapseLexicalDotSegments("C:\\foo\\..\\..\\bar.md")).toBe("C:\\bar.md");
expect(collapseLexicalDotSegments("C:/foo/../../bar.md")).toBe("C:/bar.md");
expect(collapseLexicalDotSegments("C:\\..\\bar.md")).toBe("C:\\bar.md");
expect(collapseLexicalDotSegments("C:/../bar.md")).toBe("C:/bar.md");
});

it("leaves an incomplete UNC path unchanged", () => {
expect(collapseLexicalDotSegments("//server/../file.md")).toBe("//server/../file.md");
expect(collapseLexicalDotSegments("\\\\server\\..\\file.md")).toBe("\\\\server\\..\\file.md");
});

it("preserves separators on a path with no dot segment", () => {
expect(collapseLexicalDotSegments("/tmp/favicons/")).toBe("/tmp/favicons/");
expect(collapseLexicalDotSegments("//server/share/file.md")).toBe("//server/share/file.md");
expect(collapseLexicalDotSegments("\\\\server\\share\\file.md")).toBe(
"\\\\server\\share\\file.md",
);
expect(collapseLexicalDotSegments("C:/foo/bar.md:12")).toBe("C:/foo/bar.md:12");
expect(collapseLexicalDotSegments("C:\\foo\\bar\\")).toBe("C:\\foo\\bar\\");
expect(collapseLexicalDotSegments("docs/my.file/readme.md")).toBe("docs/my.file/readme.md");
});

it("preserves a trailing separator after collapsing", () => {
expect(collapseLexicalDotSegments("/proj/docs/guide/../")).toBe("/proj/docs/");
expect(collapseLexicalDotSegments("C:\\foo\\..\\")).toBe("C:\\");
expect(collapseLexicalDotSegments("//server/share/dir/../")).toBe("//server/share/");
expect(collapseLexicalDotSegments("\\\\server\\share\\dir\\..\\")).toBe("\\\\server\\share\\");
expect(collapseLexicalDotSegments("docs/guide/../")).toBe("docs/");
expect(collapseLexicalDotSegments("foo/../")).toBe("./");
});

it("collapses a home-relative path without climbing above ~", () => {
expect(collapseLexicalDotSegments("~/other/../notes.md:4")).toBe("~/notes.md:4");
expect(collapseLexicalDotSegments("~\\other\\..\\notes.md")).toBe("~\\notes.md");
});
});

describe("isTerminalLinkActivation", () => {
it("requires cmd on macOS", () => {
expect(
Expand Down
113 changes: 113 additions & 0 deletions apps/web/src/terminal-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,116 @@ export function resolvePathLinkTarget(rawPath: string, cwd: string): string {

return formatFilePathPosition({ ...position, path: resolvedPath });
}

/**
* Collapses `.` and `..` on a markdown file-link path before workspace membership
* is decided. The collapse is lexical and does not read the filesystem.
*
* POSIX `/`, a Windows drive root (`C:\` or `C:/`), a backslash UNC share
* (`\\server\share`), and a forward-slash UNC share (`//server/share`) stay
* intact, and `..` does not climb above them. A `:line` or `:line:column`
* suffix is preserved. A path with no `.` or `..` segment is returned unchanged.
*/
export function collapseLexicalDotSegments(pathWithPosition: string): string {
const position = splitFilePathPosition(pathWithPosition);
if (!position.path.split(/[\\/]/).some((segment) => segment === "." || segment === "..")) {
return pathWithPosition;
}
return formatFilePathPosition({
...position,
path: collapseDotSegments(position.path),
});
}

/**
* Removes `.` and `..` under the path's root. A complete UNC share is recognized
* before a single `/`, so `//server/share/dir/..` stays on that share. An
* incomplete UNC path is returned unchanged.
*/
function collapseDotSegments(path: string): string {
const unc = uncSharePrefix(path);
if ((path.startsWith("//") || path.startsWith("\\\\")) && !unc) return path;

const trailing = /[\\/]$/.test(path);
let prefix = "";
let rest = path;
let separator: "/" | "\\" = path.includes("\\") ? "\\" : "/";
let rooted = false;

if (unc) {
prefix = unc.prefix;
separator = unc.separator;
rooted = true;
rest = path.slice(prefix.length);
} else {
const drive = /^[A-Za-z]:/.exec(path);
if (drive?.[0]) {
prefix = drive[0];
const driveSeparator = path.charAt(prefix.length);
rooted = driveSeparator === "/" || driveSeparator === "\\";
separator = driveSeparator === "\\" || (!rooted && path.includes("\\")) ? "\\" : "/";
rest = path.slice(prefix.length);
} else if (path.startsWith("/")) {
prefix = "/";
separator = "/";
rooted = true;
rest = path.slice(1);
} else if (path.startsWith("~/") || path.startsWith("~\\")) {
separator = path.charAt(1) === "\\" ? "\\" : "/";
prefix = path.slice(0, 2);
rooted = true;
rest = path.slice(2);
}
}

const segments: string[] = [];
for (const segment of rest.split(/[\\/]/)) {
if (segment.length === 0 || segment === ".") continue;
if (segment === "..") {
if (segments.length > 0 && segments.at(-1) !== "..") segments.pop();
else if (!rooted) segments.push("..");
continue;
}
segments.push(segment);
}

if (segments.length === 0) {
if (prefix === "/") return "/";
if (/^[A-Za-z]:$/.test(prefix)) return rooted ? `${prefix}${separator}` : prefix;
if (prefix.startsWith("//") || prefix.startsWith("\\\\")) return `${prefix}${separator}`;
if (prefix === "~/" || prefix === "~\\") return prefix;
return prefix.length > 0 ? prefix : trailing ? `.${separator}` : ".";
}

const body = segments.join(separator);
const joined =
prefix.length === 0
? body
: prefix.endsWith("/") || prefix.endsWith("\\")
? `${prefix}${body}`
: rooted
? `${prefix}${separator}${body}`
: `${prefix}${body}`;
return trailing ? `${joined}${separator}` : joined;
}

/** `\\server\share` or `//server/share` when both names are real path segments. */
function uncSharePrefix(
path: string,
): { readonly prefix: string; readonly separator: "/" | "\\" } | null {
if (!path.startsWith("//") && !path.startsWith("\\\\")) return null;
const body = path.slice(2);
const serverEnd = body.search(/[\\/]/);
if (serverEnd <= 0) return null;
const server = body.slice(0, serverEnd);
const afterServer = body.slice(serverEnd + 1);
const shareEnd = afterServer.search(/[\\/]/);
const share = shareEnd === -1 ? afterServer : afterServer.slice(0, shareEnd);
if (server === "." || server === ".." || share.length === 0 || share === "." || share === "..") {
return null;
}
return {
prefix: path.slice(0, 2 + server.length + 1 + share.length),
separator: body.charAt(serverEnd) as "/" | "\\",
};
}
Loading