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}|(?()"'\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) { // 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, commandNames?: ReadonlySet, ): ReadonlySet { const paths = new Set(); const previousEnds = new Map(); // 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") || (value.startsWith("/") && commandNames?.has(value.slice(1))) ) 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; commandNames?: ReadonlySet } = {}, ) { 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, options.commandNames), ); const edits: { start: number; end: number; text: string }[] = []; if ( !source.includes("![") && !source.includes("||") && !source.includes("&") && !(workspaceRoot && source.includes("](")) ) { return { images, ...metadata, render: () => source }; } const definitions = new Map(); 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, 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(); 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(/(? decodeString(entity).replace(/[!-/:-@[-`{-~]/g, "\\$&"), ) .replace(/(? = new Map(), unavailableImages: ReadonlySet = new Set(), pendingImages: ReadonlySet = 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, "\\$&"); }