mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 04:03:05 +00:00
MD4C's permissive URL autolink read the host, never a ":port", and accepted ":" as a link boundary. "http://127.0.0.1:8059/?token=abc" linked only "http://127.0.0.1". A colon in the path ended the link the same way. Port the upstream md4c fix into the dependency patch: - 3da1937: accept single-component hosts and a numeric port after an explicit scheme - f895c2b: scan the URL tail continuously and trim terminal punctuation and excess closing parentheses - 1645761: refuse an empty hostname - f9e5eb2: stop the tail rescan from going quadratic on long lines Also refuse the autolink when a colon after the host continues the URL ("user:pass@host"). Upstream does not parse user info, and the single-component host rule would link only "http://user". The text-link pattern links these URLs whole. Rebuild the web parser with emsdk 4.0.10, which reproduces the existing glue. That version has no GROWABLE_ARRAYBUFFERS setting, and the existing artifact was built without it. The fallback text-link pattern handles URLs MD4C rejects, like user@host and IPv6 hosts. It now stops at quotes and unbalanced parentheses and leaves trailing sentence punctuation outside the link. Closes #7440 Co-authored-by: Mariotaku <git@mariotaku.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
275 lines
15 KiB
TypeScript
275 lines
15 KiB
TypeScript
import { classifyAssistantFileLink } from "@/assistant-file-links";
|
|
import { ASSISTANT_FILE_EXTENSIONS } from "@/assistant-file-links/extensions";
|
|
import { normalizeMarkdownHtml } from "./html";
|
|
import { gfmTable } from "micromark-extension-gfm-table";
|
|
import { gfmTableFromMarkdown } from "mdast-util-gfm-table";
|
|
import type { AssistantFileLinkSource } from "@/assistant-file-links";
|
|
import { decodeString } from "micromark-util-decode-string";
|
|
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
import type { Link, Nodes, Paragraph, PhrasingContent, Root } from "mdast";
|
|
|
|
// Keep this regex in the syntax shared by JavaScript, ICU and java.util.regex.
|
|
// The Unicode flag makes Metro expand it to escapes rejected by native engines.
|
|
const PATH_CHARACTER = String.raw`[a-zA-Z0-9\u0080-\uFFFF_@.%+-]`;
|
|
const PATH_LETTER = String.raw`[a-zA-Z]`;
|
|
const PATH_SEGMENT = String.raw`${PATH_CHARACTER}*[a-zA-Z0-9\u0080-\uFFFF_@%+-]`;
|
|
const LINE_SUFFIX = String.raw`(?::\d+(?::\d+)?(?:-\d+(?::\d+)?)?)?`;
|
|
const FILE_EXTENSIONS = [...ASSISTANT_FILE_EXTENSIONS]
|
|
.map((extension) => extension.replace(/[a-z]/g, (letter) => `[${letter}${letter.toUpperCase()}]`))
|
|
.join("|");
|
|
const PATH_PREFIX = String.raw`(?:[A-Za-z]:[\\/]|\.{0,2}\/|~\/)`;
|
|
const HAS_PATH_LETTER = String.raw`(?=${PATH_CHARACTER}*(?:[\\/]${PATH_CHARACTER}*)*${PATH_LETTER})`;
|
|
const SEPARATED_PATH = String.raw`${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT})+`;
|
|
const FILE_NAME = String.raw`${PATH_SEGMENT}\.(?:${FILE_EXTENSIONS})`;
|
|
// Bare prose paths need a known extension, a line suffix, or at least two separators.
|
|
const PROSE_PATH = String.raw`(?:${PATH_PREFIX}${HAS_PATH_LETTER}${SEPARATED_PATH}|${HAS_PATH_LETTER}(?:${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT})*[\\/]${FILE_NAME}|${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT}){2,}|${SEPARATED_PATH}(?=:)))`;
|
|
|
|
function createFilePattern(path: string): RegExp {
|
|
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
|
|
return new RegExp(
|
|
String.raw`(?:${BARE_URL}|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${path}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
|
|
"g",
|
|
);
|
|
}
|
|
|
|
// Matches bare URLs that MD4C does not autolink, like single-label or bracketed hosts.
|
|
// Quotes and unbalanced closing parentheses end the URL. Trailing sentence punctuation stays outside.
|
|
const URL_CHARACTER = String.raw`[^\s<>()"'\x60]`;
|
|
const URL_GROUP = String.raw`\(${URL_CHARACTER}*\)`;
|
|
const BARE_URL = String.raw`https?://(?:${URL_CHARACTER}|${URL_GROUP})*(?:[^\s<>()"'\x60.,;:!?]|${URL_GROUP})`;
|
|
|
|
const FILE_PATTERN = createFilePattern(PROSE_PATH);
|
|
function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
|
|
// The engine accepts regexes on both platforms. Classify complete code spans
|
|
// first, then pass literal matches rather than maintaining a second path grammar.
|
|
const alternatives = [...inlineCodePaths].map((path) =>
|
|
path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
|
);
|
|
return {
|
|
linkRecognition: {
|
|
text: FILE_PATTERN,
|
|
inlineCode: new RegExp(
|
|
String.raw`^(?:https?://[^\s<>]+${alternatives.length ? `|${alternatives.join("|")}` : ""})$`,
|
|
),
|
|
},
|
|
linkSource: markdownLinkSource,
|
|
};
|
|
}
|
|
|
|
function collectInlineCodePaths(source: string, workspaceRoot?: string): ReadonlySet<string> {
|
|
const paths = new Set<string>();
|
|
const previousEnds = new Map<number, number>();
|
|
// Collect candidates between consecutive runs of equal length. The engine owns
|
|
// Markdown context (escapes, fences, authored links); extra candidates are harmless
|
|
// because recognition only applies to complete Code nodes. This avoids an AST
|
|
// parse on every streaming reveal, including updates inside long code fences.
|
|
for (const match of source.matchAll(/`+/g)) {
|
|
const length = match[0].length;
|
|
const previousEnd = previousEnds.get(length);
|
|
previousEnds.set(length, match.index + length);
|
|
if (previousEnd === undefined) continue;
|
|
let value = source.slice(previousEnd, match.index).replace(/\r\n?|\n/g, " ");
|
|
// CommonMark code spans replace line endings and strip one surrounding space.
|
|
if (value.startsWith(" ") && value.endsWith(" ") && /[^ ]/.test(value)) {
|
|
value = value.slice(1, -1);
|
|
}
|
|
const classification = classifyAssistantFileLink(value, { workspaceRoot });
|
|
if (classification && classification.kind !== "external") paths.add(value);
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
export function markdownLinkSource(
|
|
url: string,
|
|
sourceType?: "markdown" | "text" | "inline-code",
|
|
): AssistantFileLinkSource {
|
|
if (sourceType === "inline-code") return { href: url, text: url, sourceType };
|
|
if (sourceType === "text") return { href: url, text: url, markup: "linkify" };
|
|
return { href: url, text: url };
|
|
}
|
|
|
|
// Generated by scripts/generate-markdown-image-placeholder.mjs; reserves the image box.
|
|
export const PENDING_MARKDOWN_IMAGE =
|
|
"data:image/png;base64,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";
|
|
|
|
export interface MarkdownImage {
|
|
source: string;
|
|
alt: string;
|
|
title: string | null;
|
|
start: number;
|
|
end: number;
|
|
}
|
|
|
|
function workspaceHoverTitle(link: Link, markup: string, workspaceRoot?: string): string | null {
|
|
if (link.title !== null || !workspaceRoot || !link.url.startsWith(`${workspaceRoot}/`))
|
|
return null;
|
|
// mdast represents both missing titles and authored empty titles as null.
|
|
if (/\s(?:""|''|\(\))\s*\)$/.test(markup)) return null;
|
|
return link.url.slice(workspaceRoot.length + 1);
|
|
}
|
|
|
|
/** Preserve source spelling; adapt workspace images, spoiler delimiters, and entity text. */
|
|
export function prepareMarkdownSource(
|
|
source: string,
|
|
workspaceRoot?: string,
|
|
options: { supportsSvg?: boolean } = {},
|
|
) {
|
|
source = normalizeMarkdownHtml(source);
|
|
const images: MarkdownImage[] = [];
|
|
let tree: Root | undefined;
|
|
const getTree = () =>
|
|
(tree ??= fromMarkdown(source, {
|
|
extensions: [gfmTable()],
|
|
mdastExtensions: [gfmTableFromMarkdown()],
|
|
}));
|
|
const metadata = linkMetadata(collectInlineCodePaths(source, workspaceRoot));
|
|
const edits: { start: number; end: number; text: string }[] = [];
|
|
if (
|
|
!source.includes(")
|
|
) {
|
|
return { images, ...metadata, render: () => source };
|
|
}
|
|
|
|
const definitions = new Map<string, { url: string; title?: string | null }>();
|
|
function definitionsIn(node: Nodes) {
|
|
if (node.type === "definition" && !definitions.has(node.identifier))
|
|
definitions.set(node.identifier, node);
|
|
if ("children" in node) node.children.forEach(definitionsIn);
|
|
}
|
|
definitionsIn(getTree());
|
|
function collectImage(
|
|
node: Extract<Nodes, { type: "image" | "imageReference" }>,
|
|
start: number,
|
|
end: number,
|
|
) {
|
|
const target = node.type === "image" ? node : definitions.get(node.identifier);
|
|
if (
|
|
target &&
|
|
options.supportsSvg === false &&
|
|
/(?:\.svg(?:[?#]|$)|^data:image\/svg\+xml)/i.test(target.url)
|
|
) {
|
|
edits.push({ start, end, text: escapeMarkdownText(node.alt ?? "") });
|
|
return;
|
|
}
|
|
if (target)
|
|
images.push({
|
|
source: target.url,
|
|
alt: node.alt ?? "",
|
|
title: target.title ?? null,
|
|
start,
|
|
end,
|
|
});
|
|
}
|
|
// Line boundaries already turned into paragraph breaks, by offset of their newline.
|
|
const splitBoundaries = new Set<number>();
|
|
function isImageLike(node: PhrasingContent): boolean {
|
|
if (node.type === "image" || node.type === "imageReference") return true;
|
|
return (
|
|
(node.type === "link" || node.type === "linkReference") &&
|
|
node.children.length === 1 &&
|
|
isImageLike(node.children[0])
|
|
);
|
|
}
|
|
// An image alone on its line becomes its own paragraph, so every platform renders
|
|
// it as a block. The library only treats sole-child paragraph images as blocks;
|
|
// with hardSoftBreaks every newline is a break node, which made these inline.
|
|
function splitStandaloneImages(paragraph: Paragraph, start: number, end: number) {
|
|
for (const child of paragraph.children) {
|
|
const childStart = child.position?.start.offset;
|
|
const childEnd = child.position?.end.offset;
|
|
if (childStart === undefined || childEnd === undefined || !isImageLike(child)) continue;
|
|
const lineStart = source.lastIndexOf("\n", childStart - 1) + 1;
|
|
const newline = source.indexOf("\n", childEnd);
|
|
const lineEnd = newline === -1 ? source.length : newline;
|
|
const prefix = source.slice(lineStart, childStart);
|
|
const suffix = source.slice(childEnd, lineEnd);
|
|
const aloneBefore = childStart === start || /^[\s>]*$/.test(prefix);
|
|
const aloneAfter = /^\s*\\?$/.test(suffix);
|
|
if (!aloneBefore || !aloneAfter) continue;
|
|
// Continuation lines inside quotes and lists keep their quote markers on the blank line.
|
|
const blank = prefix.replace(/[^>]/g, " ").trimEnd();
|
|
const split = (boundary: number) => {
|
|
if (splitBoundaries.has(boundary)) return;
|
|
splitBoundaries.add(boundary);
|
|
// A trailing backslash was a hard break; at a paragraph end it would render literally.
|
|
const cut = source[boundary - 1] === "\\" ? boundary - 1 : boundary;
|
|
edits.push({ start: cut, end: boundary + 1, text: `\n${blank}\n` });
|
|
};
|
|
if (lineStart > start) split(lineStart - 1);
|
|
if (lineEnd < end) split(lineEnd);
|
|
}
|
|
}
|
|
function visit(node: Nodes) {
|
|
const start = node.position?.start.offset;
|
|
const end = node.position?.end.offset;
|
|
if (start !== undefined && end !== undefined) {
|
|
if (node.type === "paragraph") splitStandaloneImages(node, start, end);
|
|
if (node.type === "link") {
|
|
const title = workspaceHoverTitle(node, source.slice(start, end), workspaceRoot);
|
|
if (title !== null)
|
|
edits.push({ start: end - 1, end: end - 1, text: ` ${JSON.stringify(title)}` });
|
|
}
|
|
if (node.type === "tableRow") {
|
|
// MD4C's always-enabled spoiler extension otherwise consumes adjacent cell
|
|
// delimiters. Spaces keep empty GFM cells empty without producing spoilers.
|
|
const row = source.slice(start, end);
|
|
for (const match of row.matchAll(/(?<!\\)\|(?=\|)/g)) {
|
|
edits.push({ start: start + match.index + 1, end: start + match.index + 1, text: " " });
|
|
}
|
|
}
|
|
if (node.type === "image" || node.type === "imageReference") {
|
|
collectImage(node, start, end);
|
|
return;
|
|
}
|
|
if (node.type === "text") {
|
|
const text = source
|
|
.slice(start, end)
|
|
.replace(/(?<!\\)&(?:#(?:[xX][\da-fA-F]+|\d+)|[a-zA-Z][a-zA-Z\d]+);/g, (entity) =>
|
|
decodeString(entity).replace(/[!-/:-@[-`{-~]/g, "\\$&"),
|
|
)
|
|
.replace(/(?<!\\)\|\|/g, "\\|\\|");
|
|
if (text !== source.slice(start, end)) edits.push({ start, end, text });
|
|
}
|
|
}
|
|
if ("children" in node) node.children.forEach(visit);
|
|
}
|
|
visit(getTree());
|
|
return {
|
|
images,
|
|
...metadata,
|
|
render(
|
|
urls: ReadonlyMap<string, string> = new Map(),
|
|
unavailableImages: ReadonlySet<string> = new Set(),
|
|
pendingImages: ReadonlySet<string> = new Set(),
|
|
) {
|
|
const replacements = [...edits];
|
|
for (const image of images) {
|
|
const unavailable = unavailableImages.has(image.source);
|
|
const pending = pendingImages.has(image.source);
|
|
if (!unavailable && !pending && /^(https?:|data:|blob:)/i.test(image.source)) continue;
|
|
let url = unavailable ? undefined : urls.get(image.source);
|
|
if (pending && !unavailable) url = PENDING_MARKDOWN_IMAGE;
|
|
replacements.push({
|
|
start: image.start,
|
|
end: image.end,
|
|
text: url
|
|
? `![${image.alt.replace(/[[\]\\]/g, "\\$&")}](<${url}>${image.title ? ` ${JSON.stringify(image.title)}` : ""})`
|
|
: escapeMarkdownText(image.alt),
|
|
});
|
|
}
|
|
let result = source;
|
|
for (const edit of replacements.sort((a, b) => b.start - a.start)) {
|
|
result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
|
|
}
|
|
return result;
|
|
},
|
|
};
|
|
}
|
|
|
|
function escapeMarkdownText(text: string): string {
|
|
return text.replace(/[!-/:-@[-`{-~]/g, "\\$&");
|
|
}
|