Skip to content
Open
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
118 changes: 73 additions & 45 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation
import {
normalizeMarkdownLinkDestination,
resolveMarkdownFileLinkMeta,
resolveMarkdownFileLinkTarget,
rewriteMarkdownFileUriHref,
} from "../markdown-links";
import { readLocalApi } from "../localApi";
Expand Down Expand Up @@ -122,6 +123,8 @@ const EMPTY_MARKDOWN_SKILLS: ReadonlyArray<Pick<ServerProviderSkill, "name" | "d
const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/;
const MAX_HIGHLIGHT_CACHE_ENTRIES = 500;
const MAX_HIGHLIGHT_CACHE_MEMORY_BYTES = 50 * 1024 * 1024;
const FILE_LINK_PARENT_SUFFIX_DATA_ATTRIBUTE = "data-file-link-parent-suffix";
const FILE_LINK_PARENT_SUFFIX_PROPERTY = "dataFileLinkParentSuffix";

interface MarkdownActionFailureContext {
readonly operation: string;
Expand Down Expand Up @@ -156,7 +159,10 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
"*": (defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"),
"*": [
...(defaultSchema.attributes?.["*"] ?? []).filter((attribute) => attribute !== "title"),
FILE_LINK_PARENT_SUFFIX_PROPERTY,
],
code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta"],
},
protocols: {
Expand Down Expand Up @@ -222,6 +228,7 @@ function extractPreCodeMeta(node: unknown): string | undefined {

type MarkdownAstNode = {
type?: string;
url?: string;
meta?: unknown;
data?: {
hProperties?: Record<string, unknown>;
Expand Down Expand Up @@ -747,7 +754,6 @@ interface MarkdownFileLinkProps {
className?: string | undefined;
}

const MARKDOWN_LINK_HREF_PATTERN = /\[[^\]]*]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g;
const MARKDOWN_FILE_LINK_CLASS_NAME =
"chat-markdown-file-link cursor-pointer transition-colors hover:bg-accent/70";

Expand Down Expand Up @@ -811,19 +817,42 @@ function buildFileLinkParentSuffixByPath(filePaths: ReadonlyArray<string>): Map<
return suffixByPath;
}

function extractMarkdownLinkHrefs(text: string): string[] {
const hrefs: string[] = [];
for (const match of text.matchAll(MARKDOWN_LINK_HREF_PATTERN)) {
const href = match[1]?.trim();
if (!href) continue;
hrefs.push(href);
}
return hrefs;
}
function createFileLinkDisambiguationPlugin(cwd: string | undefined) {
return () => (tree: MarkdownAstNode) => {
const fileLinks: Array<{
node: MarkdownAstNode;
meta: NonNullable<ReturnType<typeof resolveMarkdownFileLinkMeta>>;
}> = [];

const visit = (node: MarkdownAstNode) => {
if (node.type === "link" && typeof node.url === "string") {
const meta = resolveMarkdownFileLinkMeta(node.url, cwd);
if (meta) {
fileLinks.push({ node, meta });
}
}
for (const child of node.children ?? []) {
visit(child);
}
};

function normalizeMarkdownLinkHrefKey(href: string): string {
const normalizedHref = normalizeMarkdownLinkDestination(href);
return rewriteMarkdownFileUriHref(normalizedHref) ?? normalizedHref;
visit(tree);

const suffixByPath = buildFileLinkParentSuffixByPath(
fileLinks.map((fileLink) => fileLink.meta.filePath),
);
for (const { node, meta } of fileLinks) {
const parentSuffix = suffixByPath.get(meta.filePath);
if (!parentSuffix) continue;
node.data = {
...node.data,
hProperties: {
...node.data?.hProperties,
[FILE_LINK_PARENT_SUFFIX_PROPERTY]: parentSuffix,
},
};
}
};
}

const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none";
Expand Down Expand Up @@ -1272,28 +1301,22 @@ function ChatMarkdown({
serverConfig?.availableEditors ?? [],
);
const diffThemeName = resolveDiffThemeName(resolvedTheme);
const markdownFileLinkMetaByHref = useMemo(() => {
const metaByHref = new Map<
string,
NonNullable<ReturnType<typeof resolveMarkdownFileLinkMeta>>
>();
for (const href of extractMarkdownLinkHrefs(text)) {
const normalizedHref = normalizeMarkdownLinkHrefKey(href);
if (metaByHref.has(normalizedHref)) continue;
const meta = resolveMarkdownFileLinkMeta(normalizedHref, cwd);
if (meta) {
metaByHref.set(normalizedHref, meta);
}
}
return metaByHref;
}, [cwd, text]);
const fileLinkParentSuffixByPath = useMemo(() => {
const filePaths = [...markdownFileLinkMetaByHref.values()].map((meta) => meta.filePath);
return buildFileLinkParentSuffixByPath(filePaths);
}, [markdownFileLinkMetaByHref]);
const markdownUrlTransform = useCallback((href: string) => {
return rewriteMarkdownFileUriHref(href) ?? defaultUrlTransform(href);
}, []);
const remarkPlugins = useMemo(
() => [
...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS),
createFileLinkDisambiguationPlugin(cwd),
],
[cwd, lineBreaks],
);
const markdownUrlTransform = useCallback(
(href: string) => {
return (
rewriteMarkdownFileUriHref(href) ??
(resolveMarkdownFileLinkTarget(href, cwd) ? href : defaultUrlTransform(href))
);
},
[cwd],
);
// Re-emit highlighted content as markdown so copying out of the rendered
// view keeps links, emphasis, lists, and code fences intact.
const handleCopy = useCallback((event: ReactClipboardEvent<HTMLDivElement>) => {
Expand Down Expand Up @@ -1390,8 +1413,12 @@ function ChatMarkdown({
);
},
a({ node, href, children, ...props }) {
const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : "";
const fileLinkMeta = normalizedHref ? markdownFileLinkMetaByHref.get(normalizedHref) : null;
const normalizedHref = href
? (rewriteMarkdownFileUriHref(href) ?? normalizeMarkdownLinkDestination(href))
: "";
const fileLinkMeta = normalizedHref
? resolveMarkdownFileLinkMeta(normalizedHref, cwd)
: null;
if (!fileLinkMeta) {
const faviconHost = resolveExternalLinkHost(href);
const isSameDocumentLink = href?.startsWith("#") ?? false;
Expand Down Expand Up @@ -1468,9 +1495,13 @@ function ChatMarkdown({
);
}

const parentSuffix = fileLinkParentSuffixByPath.get(fileLinkMeta.filePath);
const anchorProps = props as typeof props & Record<string, unknown>;
const parentSuffix =
typeof anchorProps[FILE_LINK_PARENT_SUFFIX_DATA_ATTRIBUTE] === "string"
? anchorProps[FILE_LINK_PARENT_SUFFIX_DATA_ATTRIBUTE]
: undefined;
Comment thread
Yash-Singh1 marked this conversation as resolved.
const labelParts = [fileLinkMeta.basename];
if (typeof parentSuffix === "string" && parentSuffix.length > 0) {
if (parentSuffix) {
labelParts.push(parentSuffix);
}
if (fileLinkMeta.line) {
Expand Down Expand Up @@ -1539,10 +1570,9 @@ function ChatMarkdown({
},
}),
[
cwd,
diffThemeName,
fileLinkParentSuffixByPath,
isStreaming,
markdownFileLinkMetaByHref,
onTaskListChange,
openInPreferredEditor,
openExternalLinkInPreview,
Expand All @@ -1563,9 +1593,7 @@ function ChatMarkdown({
onCopy={handleCopy}
>
<ReactMarkdown
remarkPlugins={
lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS
}
remarkPlugins={remarkPlugins}
rehypePlugins={CHAT_MARKDOWN_REHYPE_PLUGINS}
components={markdownComponents}
urlTransform={markdownUrlTransform}
Expand Down
53 changes: 39 additions & 14 deletions apps/web/src/markdown-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ import {
rewriteMarkdownFileUriHref,
} from "./markdown-links";

describe("normalizeMarkdownLinkDestination", () => {
it("unescapes markdown punctuation when normalizing destinations", () => {
expect(
resolveMarkdownFileLinkTarget("apps/web/src/routes/\\(chat\\)/\\[id\\].tsx", "/repo"),
).toBe("/repo/apps/web/src/routes/(chat)/[id].tsx");
});

it("preserves windows path separators before punctuation", () => {
expect(resolveMarkdownFileLinkTarget(String.raw`C:\src\(group)\page.tsx`)).toBe(
String.raw`C:\src\(group)\page.tsx`,
);
});
});

describe("rewriteMarkdownFileUriHref", () => {
it("rewrites file uri hrefs into direct path hrefs", () => {
expect(rewriteMarkdownFileUriHref("file:///Users/julius/project/src/main.ts#L42")).toBe(
Expand All @@ -26,12 +40,6 @@ describe("rewriteMarkdownFileUriHref", () => {
),
).toBe("D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx#L69");
});

it("unwraps angle-bracketed file uri hrefs", () => {
expect(
rewriteMarkdownFileUriHref(" <file:///D:/Programme/t3code/apps/web/src/markdown-links.ts> "),
).toBe("D:/Programme/t3code/apps/web/src/markdown-links.ts");
});
});

describe("resolveMarkdownFileLinkTarget", () => {
Expand Down Expand Up @@ -59,6 +67,31 @@ describe("resolveMarkdownFileLinkTarget", () => {
);
});

it("resolves relative paths with route group and dynamic segment characters", () => {
expect(
resolveMarkdownFileLinkTarget("apps/web/src/routes/(chat)/[threadId].tsx", "/repo/project"),
).toBe("/repo/project/apps/web/src/routes/(chat)/[threadId].tsx");
});

it("resolves encoded route group and dynamic segment characters", () => {
expect(
resolveMarkdownFileLinkTarget(
"apps/web/src/routes/%28chat%29/%5BthreadId%5D.tsx",
"/repo/project",
),
).toBe("/repo/project/apps/web/src/routes/(chat)/[threadId].tsx");
});

it("preserves support for conservative extensionless relative file paths", () => {
expect(resolveMarkdownFileLinkTarget("scripts/release", "/repo/project")).toBe(
"/repo/project/scripts/release",
);
});

it("does not treat ambiguous encoded relative web links as file paths", () => {
expect(resolveMarkdownFileLinkTarget("docs/user%20guide", "/repo/project")).toBeNull();
});

it("maps #L line anchors to editor line suffixes", () => {
expect(resolveMarkdownFileLinkTarget("/Users/julius/project/src/main.ts#L42C7")).toBe(
"/Users/julius/project/src/main.ts:42:7",
Expand Down Expand Up @@ -115,14 +148,6 @@ describe("resolveMarkdownFileLinkTarget", () => {
).toBe("D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx:69");
});

it("resolves angle-bracketed windows drive paths", () => {
expect(
resolveMarkdownFileLinkTarget(
"</D:/Programme/t3code/apps/web/src/components/ChatMarkdown.tsx:1>",
),
).toBe("D:/Programme/t3code/apps/web/src/components/ChatMarkdown.tsx:1");
});

it("does not treat app routes as file links", () => {
expect(resolveMarkdownFileLinkTarget("/chat/settings")).toBeNull();
});
Expand Down
33 changes: 26 additions & 7 deletions apps/web/src/markdown-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { resolvePathLinkTarget, splitPathAndPosition } from "./terminal-links";

const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
const WINDOWS_UNC_PATH_PATTERN = /^\\\\/;
const WINDOWS_BACKSLASH_PATH_PATTERN = /^(?:[A-Za-z]:\\|\\\\)/;
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 CONSERVATIVE_RELATIVE_PATH_SEGMENT_PATTERN = /^[A-Za-z0-9._-]+$/;
const POSITION_SUFFIX_PATTERN = /:\d+(?::\d+)?$/;
const POSITION_ONLY_PATTERN = /^\d+(?::\d+)?$/;
const POSIX_FILE_ROOT_PREFIXES = [
Expand Down Expand Up @@ -40,12 +40,14 @@ function safeDecode(value: string): string {
}
}

function unwrapMarkdownLinkDestination(value: string): string {
return value.startsWith("<") && value.endsWith(">") ? value.slice(1, -1) : value;
}
const MARKDOWN_ESCAPE_PATTERN = /\\([!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])/g;

export function normalizeMarkdownLinkDestination(value: string): string {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
return unwrapMarkdownLinkDestination(value.trim());
const trimmedValue = value.trim();
if (WINDOWS_BACKSLASH_PATH_PATTERN.test(trimmedValue)) {
return trimmedValue;
}
return trimmedValue.replace(MARKDOWN_ESCAPE_PATTERN, "$1");
}

function stripSearchAndHash(value: string): { path: string; hash: string } {
Expand Down Expand Up @@ -113,7 +115,24 @@ 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);
const { path: actualPath } = splitPathAndPosition(path);
if (actualPath.length === 0) return false;
if (["\0", "\r", "\n", '"', "'", "`", "<", ">"].some((char) => actualPath.includes(char))) {
return false;
}

const normalizedPath = actualPath.replaceAll("\\", "/");
const segments = normalizedPath.split("/");
if (segments.length > 1) {
if (segments.some((segment) => segment.length === 0)) return false;
const basename = segments.at(-1) ?? "";
return (
/\.[A-Za-z0-9_-]+$/.test(basename) ||
segments.every((segment) => CONSERVATIVE_RELATIVE_PATH_SEGMENT_PATTERN.test(segment))
);
}
Comment thread
Yash-Singh1 marked this conversation as resolved.

return /\.[A-Za-z0-9_-]+$/.test(actualPath);
}

function isRelativePath(path: string): boolean {
Expand Down
Loading