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, 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}(?=:)))`; const INLINE_CODE_PATH = String.raw`${PATH_PREFIX}?${HAS_PATH_LETTER}${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`(?:https?://[^\s<>]+|(? (tree ??= fromMarkdown(source, { extensions: [gfmTable()], mdastExtensions: [gfmTableFromMarkdown()], })); const edits: { start: number; end: number; text: string }[] = []; if ( !source.includes("![") && !source.includes("||") && !source.includes("&") && !(workspaceRoot && source.includes("](")) ) { return { images, ...linkMetadata(), 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, }); } function visit(node: Nodes) { const start = node.position?.start.offset; const end = node.position?.end.offset; if (start !== undefined && end !== undefined) { 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, "\\$&"); }