Files
paseo/packages/app/src/components/markdown/source.ts
T

191 lines
7.3 KiB
TypeScript

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 SEPARATED_PATH = String.raw`(?:[A-Za-z]:[\\/]|\.{0,2}\/|~\/)?(?=${PATH_CHARACTER}*(?:[\\/]${PATH_CHARACTER}*)*${PATH_LETTER})${PATH_SEGMENT}(?:[\\/]${PATH_SEGMENT})+`;
const FILE_NAME = String.raw`${PATH_SEGMENT}\.(?:${FILE_EXTENSIONS})`;
// The native adapter mistakes a literal + in lookbehind for a quantifier; use its hex escape.
const FILE_PATTERN = new RegExp(
String.raw`(?:https?://[^\s<>]+|(?<![a-zA-Z0-9\u0080-\uFFFF_@.%\x2b~:/\\-])(?:${SEPARATED_PATH}|${FILE_NAME})${LINE_SUFFIX}(?![a-zA-Z0-9\u0080-\uFFFF_@%+:/\\-]|\.${PATH_CHARACTER}))`,
"g",
);
function linkMetadata() {
return {
linkRecognition: {
text: FILE_PATTERN,
inlineCode: new RegExp(
String.raw`^(?:${FILE_PATTERN.source})(?:\(\d+(?:,\d+)?(?:-\d+(?:,\d+)?)?\)| lines? \d+(?:-\d+)?)?$`,
),
},
linkSource: markdownLinkSource,
};
}
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 };
}
// A decoded transparent pixel reserves the library's normal block/inline image box.
export const PENDING_MARKDOWN_IMAGE =
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
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 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<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,
});
}
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(/(?<!\\)\|(?=\|)/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,
...linkMetadata(),
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, "\\$&");
}