Path: blob/main/src/vs/base/browser/markdownRenderer.ts
5240 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { onUnexpectedError } from '../common/errors.js';6import { escapeDoubleQuotes, IMarkdownString, MarkdownStringTrustedOptions, parseHrefAndDimensions, removeMarkdownEscapes } from '../common/htmlContent.js';7import { markdownEscapeEscapedIcons } from '../common/iconLabels.js';8import { defaultGenerator } from '../common/idGenerator.js';9import { KeyCode } from '../common/keyCodes.js';10import { DisposableStore, IDisposable } from '../common/lifecycle.js';11import * as marked from '../common/marked/marked.js';12import { parse } from '../common/marshalling.js';13import { FileAccess, Schemas } from '../common/network.js';14import { cloneAndChange } from '../common/objects.js';15import { basename as pathBasename } from '../common/path.js';16import { basename, dirname, resolvePath } from '../common/resources.js';17import { escape } from '../common/strings.js';18import { URI, UriComponents } from '../common/uri.js';19import * as DOM from './dom.js';20import * as domSanitize from './domSanitize.js';21import { convertTagToPlaintext } from './domSanitize.js';22import { StandardKeyboardEvent } from './keyboardEvent.js';23import { StandardMouseEvent } from './mouseEvent.js';24import { renderIcon, renderLabelWithIcons } from './ui/iconLabel/iconLabels.js';2526export type MarkdownActionHandler = (linkContent: string, mdStr: IMarkdownString) => void;2728/**29* Options for the rendering of markdown with {@link renderMarkdown}.30*/31export interface MarkdownRenderOptions {32readonly codeBlockRenderer?: (languageId: string, value: string) => Promise<HTMLElement>;33readonly codeBlockRendererSync?: (languageId: string, value: string, raw?: string) => HTMLElement;34readonly asyncRenderCallback?: () => void;3536readonly actionHandler?: MarkdownActionHandler;3738readonly fillInIncompleteTokens?: boolean;3940readonly sanitizerConfig?: MarkdownSanitizerConfig;4142readonly markedOptions?: MarkdownRendererMarkedOptions;43readonly markedExtensions?: marked.MarkedExtension[];44}4546/**47* Subset of options passed to `Marked` for rendering markdown.48*/49export interface MarkdownRendererMarkedOptions {50readonly gfm?: boolean;51readonly breaks?: boolean;52}5354export interface MarkdownSanitizerConfig {55readonly replaceWithPlaintext?: boolean;56readonly allowedTags?: {57readonly override: readonly string[];58};59readonly allowedAttributes?: {60readonly override: ReadonlyArray<string | domSanitize.SanitizeAttributeRule>;61};62readonly allowedLinkSchemes?: {63readonly augment: readonly string[];64};65readonly remoteImageIsAllowed?: (uri: URI) => boolean;66}6768const defaultMarkedRenderers = Object.freeze({69image: ({ href, title, text }: marked.Tokens.Image): string => {70let dimensions: string[] = [];71let attributes: string[] = [];72if (href) {73({ href, dimensions } = parseHrefAndDimensions(href));74attributes.push(`src="${escapeDoubleQuotes(href)}"`);75}76if (text) {77attributes.push(`alt="${escapeDoubleQuotes(text)}"`);78}79if (title) {80attributes.push(`title="${escapeDoubleQuotes(title)}"`);81}82if (dimensions.length) {83attributes = attributes.concat(dimensions);84}85return '<img ' + attributes.join(' ') + '>';86},8788paragraph(this: marked.Renderer, { tokens }: marked.Tokens.Paragraph): string {89return `<p>${this.parser.parseInline(tokens)}</p>`;90},9192link(this: marked.Renderer, { href, title, tokens }: marked.Tokens.Link): string {93let text = this.parser.parseInline(tokens);94if (typeof href !== 'string') {95return '';96}9798// Remove markdown escapes. Workaround for https://github.com/chjj/marked/issues/82999if (href === text) { // raw link case100text = removeMarkdownEscapes(text);101}102103title = typeof title === 'string' ? escapeDoubleQuotes(removeMarkdownEscapes(title)) : '';104href = removeMarkdownEscapes(href);105106// HTML Encode href107href = href.replace(/&/g, '&')108.replace(/</g, '<')109.replace(/>/g, '>')110.replace(/"/g, '"')111.replace(/'/g, ''');112113return `<a href="${href}" title="${title || href}" draggable="false">${text}</a>`;114},115});116117/**118* Blockquote renderer that processes GitHub-style alert syntax.119* Transforms blockquotes like "> [!NOTE]" into structured alert markup with icons.120*121* Based on GitHub's alert syntax: https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts122*/123function createAlertBlockquoteRenderer(fallbackRenderer: (this: marked.Renderer, token: marked.Tokens.Blockquote) => string) {124return function (this: marked.Renderer, token: marked.Tokens.Blockquote): string {125const { tokens } = token;126// Check if this blockquote starts with alert syntax [!TYPE]127const firstToken = tokens[0];128if (firstToken?.type !== 'paragraph') {129return fallbackRenderer.call(this, token);130}131132const paragraphTokens = firstToken.tokens;133if (!paragraphTokens || paragraphTokens.length === 0) {134return fallbackRenderer.call(this, token);135}136137const firstTextToken = paragraphTokens[0];138if (firstTextToken?.type !== 'text') {139return fallbackRenderer.call(this, token);140}141142const pattern = /^\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*?\n*/i;143const match = firstTextToken.raw.match(pattern);144if (!match) {145return fallbackRenderer.call(this, token);146}147148// Remove the alert marker from the token149firstTextToken.raw = firstTextToken.raw.replace(pattern, '');150firstTextToken.text = firstTextToken.text.replace(pattern, '');151152const alertIcons: Record<string, string> = {153'note': 'info',154'tip': 'light-bulb',155'important': 'comment',156'warning': 'alert',157'caution': 'stop'158};159160const type = match[1];161const typeCapitalized = type.charAt(0).toUpperCase() + type.slice(1).toLowerCase();162const severity = type.toLowerCase();163const iconHtml = renderIcon({ id: alertIcons[severity] }).outerHTML;164165// Render the remaining content166const content = this.parser.parse(tokens);167168// Return alert markup with icon and severity (skipping the first 3 characters: `<p>`)169return `<blockquote data-severity="${severity}"><p><span>${iconHtml}${typeCapitalized}</span>${content.substring(3)}</blockquote>\n`;170};171}172173export interface IRenderedMarkdown extends IDisposable {174readonly element: HTMLElement;175}176177/**178* Low-level way create a html element from a markdown string.179*180* **Note** that for most cases you should be using {@link import('../../editor/browser/widget/markdownRenderer/browser/markdownRenderer.js').MarkdownRenderer MarkdownRenderer}181* which comes with support for pretty code block rendering and which uses the default way of handling links.182*/183export function renderMarkdown(markdown: IMarkdownString, options: MarkdownRenderOptions = {}, target?: HTMLElement): IRenderedMarkdown {184const disposables = new DisposableStore();185let isDisposed = false;186187const markedInstance = new marked.Marked(...(options.markedExtensions ?? []));188const { renderer, codeBlocks, syncCodeBlocks } = createMarkdownRenderer(markedInstance, options, markdown);189const value = preprocessMarkdownString(markdown);190191let renderedMarkdown: string;192if (options.fillInIncompleteTokens) {193// The defaults are applied by parse but not lexer()/parser(), and they need to be present194const opts: marked.MarkedOptions = {195...markedInstance.defaults,196...options.markedOptions,197renderer198};199const tokens = markedInstance.lexer(value, opts);200const newTokens = fillInIncompleteTokens(tokens);201renderedMarkdown = markedInstance.parser(newTokens, opts);202} else {203renderedMarkdown = markedInstance.parse(value, { ...options?.markedOptions, renderer, async: false });204}205206// Rewrite theme icons207if (markdown.supportThemeIcons) {208const elements = renderLabelWithIcons(renderedMarkdown);209renderedMarkdown = elements.map(e => typeof e === 'string' ? e : e.outerHTML).join('');210}211212const renderedContent = document.createElement('div');213const sanitizerConfig = getDomSanitizerConfig(markdown, options.sanitizerConfig ?? {});214domSanitize.safeSetInnerHtml(renderedContent, renderedMarkdown, sanitizerConfig);215216// Rewrite links and images before potentially inserting them into the real dom217rewriteRenderedLinks(markdown, options, renderedContent);218219let outElement: HTMLElement;220if (target) {221outElement = target;222DOM.reset(target, ...renderedContent.childNodes);223} else {224outElement = renderedContent;225}226227if (codeBlocks.length > 0) {228Promise.all(codeBlocks).then((tuples) => {229if (isDisposed) {230return;231}232const renderedElements = new Map(tuples);233// eslint-disable-next-line no-restricted-syntax234const placeholderElements = outElement.querySelectorAll<HTMLDivElement>(`div[data-code]`);235for (const placeholderElement of placeholderElements) {236const renderedElement = renderedElements.get(placeholderElement.dataset['code'] ?? '');237if (renderedElement) {238DOM.reset(placeholderElement, renderedElement);239}240}241options.asyncRenderCallback?.();242});243} else if (syncCodeBlocks.length > 0) {244const renderedElements = new Map(syncCodeBlocks);245// eslint-disable-next-line no-restricted-syntax246const placeholderElements = outElement.querySelectorAll<HTMLDivElement>(`div[data-code]`);247for (const placeholderElement of placeholderElements) {248const renderedElement = renderedElements.get(placeholderElement.dataset['code'] ?? '');249if (renderedElement) {250DOM.reset(placeholderElement, renderedElement);251}252}253}254255// Signal size changes for image tags256if (options.asyncRenderCallback) {257// eslint-disable-next-line no-restricted-syntax258for (const img of outElement.getElementsByTagName('img')) {259const listener = disposables.add(DOM.addDisposableListener(img, 'load', () => {260listener.dispose();261options.asyncRenderCallback!();262}));263}264}265266// Add event listeners for links267if (options.actionHandler) {268const clickCb = (e: PointerEvent) => {269const mouseEvent = new StandardMouseEvent(DOM.getWindow(outElement), e);270if (!mouseEvent.leftButton && !mouseEvent.middleButton) {271return;272}273activateLink(markdown, options, mouseEvent);274};275disposables.add(DOM.addDisposableListener(outElement, 'click', clickCb));276disposables.add(DOM.addDisposableListener(outElement, 'auxclick', clickCb));277278disposables.add(DOM.addDisposableListener(outElement, 'keydown', (e) => {279const keyboardEvent = new StandardKeyboardEvent(e);280if (!keyboardEvent.equals(KeyCode.Space) && !keyboardEvent.equals(KeyCode.Enter)) {281return;282}283activateLink(markdown, options, keyboardEvent);284}));285}286287// Remove/disable inputs288// eslint-disable-next-line no-restricted-syntax289for (const input of [...outElement.getElementsByTagName('input')]) {290if (input.attributes.getNamedItem('type')?.value === 'checkbox') {291input.setAttribute('disabled', '');292} else {293if (options.sanitizerConfig?.replaceWithPlaintext) {294const replacement = convertTagToPlaintext(input);295if (replacement) {296input.parentElement?.replaceChild(replacement, input);297} else {298input.remove();299}300} else {301input.remove();302}303}304}305306return {307element: outElement,308dispose: () => {309isDisposed = true;310disposables.dispose();311}312};313}314315function rewriteRenderedLinks(markdown: IMarkdownString, options: MarkdownRenderOptions, root: HTMLElement) {316// eslint-disable-next-line no-restricted-syntax317for (const el of root.querySelectorAll('img, audio, video, source')) {318const src = el.getAttribute('src'); // Get the raw 'src' attribute value as text, not the resolved 'src'319if (src) {320let href = src;321try {322if (markdown.baseUri) { // absolute or relative local path, or file: uri323href = resolveWithBaseUri(URI.from(markdown.baseUri), href);324}325} catch (err) { }326327el.setAttribute('src', massageHref(markdown, href, true));328329if (options.sanitizerConfig?.remoteImageIsAllowed) {330const uri = URI.parse(href);331if (uri.scheme !== Schemas.file && uri.scheme !== Schemas.data && !options.sanitizerConfig.remoteImageIsAllowed(uri)) {332el.replaceWith(DOM.$('', undefined, el.outerHTML));333}334}335}336}337338// eslint-disable-next-line no-restricted-syntax339for (const el of root.querySelectorAll('a')) {340const href = el.getAttribute('href'); // Get the raw 'href' attribute value as text, not the resolved 'href'341el.setAttribute('href', ''); // Clear out href. We use the `data-href` for handling clicks instead342if (!href343|| /^data:|javascript:/i.test(href)344|| (/^command:/i.test(href) && !markdown.isTrusted)345|| /^command:(\/\/\/)?_workbench\.downloadResource/i.test(href)) {346// drop the link347el.replaceWith(...el.childNodes);348} else {349let resolvedHref = massageHref(markdown, href, false);350if (markdown.baseUri) {351resolvedHref = resolveWithBaseUri(URI.from(markdown.baseUri), href);352}353el.dataset.href = resolvedHref;354}355}356}357358function createMarkdownRenderer(marked: marked.Marked, options: MarkdownRenderOptions, markdown: IMarkdownString): { renderer: marked.Renderer; codeBlocks: Promise<[string, HTMLElement]>[]; syncCodeBlocks: [string, HTMLElement][] } {359const renderer = new marked.Renderer(options.markedOptions);360renderer.image = defaultMarkedRenderers.image;361renderer.link = defaultMarkedRenderers.link;362renderer.paragraph = defaultMarkedRenderers.paragraph;363364if (markdown.supportAlertSyntax) {365renderer.blockquote = createAlertBlockquoteRenderer(renderer.blockquote);366}367368// Will collect [id, renderedElement] tuples369const codeBlocks: Promise<[string, HTMLElement]>[] = [];370const syncCodeBlocks: [string, HTMLElement][] = [];371372if (options.codeBlockRendererSync) {373renderer.code = ({ text, lang, raw }: marked.Tokens.Code) => {374const id = defaultGenerator.nextId();375const value = options.codeBlockRendererSync!(postProcessCodeBlockLanguageId(lang), text, raw);376syncCodeBlocks.push([id, value]);377return `<div class="code" data-code="${id}">${escape(text)}</div>`;378};379} else if (options.codeBlockRenderer) {380renderer.code = ({ text, lang }: marked.Tokens.Code) => {381const id = defaultGenerator.nextId();382const value = options.codeBlockRenderer!(postProcessCodeBlockLanguageId(lang), text);383codeBlocks.push(value.then(element => [id, element]));384return `<div class="code" data-code="${id}">${escape(text)}</div>`;385};386}387388if (!markdown.supportHtml) {389// Note: we always pass the output through dompurify after this so that we don't rely on390// marked for real sanitization.391renderer.html = ({ text }) => {392if (options.sanitizerConfig?.replaceWithPlaintext) {393return escape(text);394}395396const match = markdown.isTrusted ? text.match(/^(<span[^>]+>)|(<\/\s*span>)$/) : undefined;397return match ? text : '';398};399}400return { renderer, codeBlocks, syncCodeBlocks };401}402403function preprocessMarkdownString(markdown: IMarkdownString) {404let value = markdown.value;405406// values that are too long will freeze the UI407if (value.length > 100_000) {408value = `${value.substr(0, 100_000)}…`;409}410411// escape theme icons412if (markdown.supportThemeIcons) {413value = markdownEscapeEscapedIcons(value);414}415416return value;417}418419function activateLink(mdStr: IMarkdownString, options: MarkdownRenderOptions, event: StandardMouseEvent | StandardKeyboardEvent): void {420const target = event.target.closest('a[data-href]');421if (!DOM.isHTMLElement(target)) {422return;423}424425try {426let href = target.dataset['href'];427if (href) {428if (mdStr.baseUri) {429href = resolveWithBaseUri(URI.from(mdStr.baseUri), href);430}431options.actionHandler?.(href, mdStr);432}433} catch (err) {434onUnexpectedError(err);435} finally {436event.preventDefault();437event.stopPropagation();438}439}440441function uriMassage(markdown: IMarkdownString, part: string): string {442let data: unknown;443try {444data = parse(decodeURIComponent(part));445} catch (e) {446// ignore447}448if (!data) {449return part;450}451data = cloneAndChange(data, value => {452if (markdown.uris && markdown.uris[value]) {453return URI.revive(markdown.uris[value]);454} else {455return undefined;456}457});458return encodeURIComponent(JSON.stringify(data));459}460461function massageHref(markdown: IMarkdownString, href: string, isDomUri: boolean): string {462const data = markdown.uris && markdown.uris[href];463let uri = URI.revive(data);464if (isDomUri) {465if (href.startsWith(Schemas.data + ':')) {466return href;467}468if (!uri) {469uri = URI.parse(href);470}471// this URI will end up as "src"-attribute of a dom node472// and because of that special rewriting needs to be done473// so that the URI uses a protocol that's understood by474// browsers (like http or https)475return FileAccess.uriToBrowserUri(uri).toString(true);476}477if (!uri) {478return href;479}480if (URI.parse(href).toString() === uri.toString()) {481return href; // no transformation performed482}483if (uri.query) {484uri = uri.with({ query: uriMassage(markdown, uri.query) });485}486return uri.toString();487}488489function postProcessCodeBlockLanguageId(lang: string | undefined): string {490if (!lang) {491return '';492}493494const parts = lang.split(/[\s+|:|,|\{|\?]/, 1);495if (parts.length) {496return parts[0];497}498return lang;499}500501function resolveWithBaseUri(baseUri: URI, href: string): string {502const hasScheme = /^\w[\w\d+.-]*:/.test(href);503if (hasScheme) {504return href;505}506507if (baseUri.path.endsWith('/')) {508return resolvePath(baseUri, href).toString();509} else {510return resolvePath(dirname(baseUri), href).toString();511}512}513514type MdStrConfig = {515readonly isTrusted?: boolean | MarkdownStringTrustedOptions;516readonly baseUri?: UriComponents;517};518519function sanitizeRenderedMarkdown(520renderedMarkdown: string,521originalMdStrConfig: MdStrConfig,522options: MarkdownSanitizerConfig = {},523): TrustedHTML {524const sanitizerConfig = getDomSanitizerConfig(originalMdStrConfig, options);525return domSanitize.sanitizeHtml(renderedMarkdown, sanitizerConfig);526}527528export const allowedMarkdownHtmlTags = Object.freeze([529...domSanitize.basicMarkupHtmlTags,530'input', // Allow inputs for rendering checkboxes. Other types of inputs are removed and the inputs are always disabled531]);532533export const allowedMarkdownHtmlAttributes = Object.freeze<Array<string | domSanitize.SanitizeAttributeRule>>([534'align',535'autoplay',536'alt',537'colspan',538'controls',539'draggable',540'height',541'href',542'loop',543'muted',544'playsinline',545'poster',546'rowspan',547'src',548'target',549'title',550'type',551'width',552'start',553554// Input (For disabled inputs)555'checked',556'disabled',557'value',558559// Custom markdown attributes560'data-code',561'data-href',562'data-severity',563564// Only allow very specific styles565{566attributeName: 'style',567shouldKeep: (element, data) => {568if (element.tagName === 'SPAN') {569if (data.attrName === 'style') {570return /^(color\:(#[0-9a-fA-F]+|var\(--vscode(-[a-zA-Z0-9]+)+\));)?(background-color\:(#[0-9a-fA-F]+|var\(--vscode(-[a-zA-Z0-9]+)+\));)?(border-radius:[0-9]+px;)?$/.test(data.attrValue);571}572}573return false;574}575},576577// Only allow codicons for classes578{579attributeName: 'class',580shouldKeep: (element, data) => {581if (element.tagName === 'SPAN') {582if (data.attrName === 'class') {583return /^codicon codicon-[a-z\-]+( codicon-modifier-[a-z\-]+)?$/.test(data.attrValue);584}585}586return false;587},588},589]);590591function getDomSanitizerConfig(mdStrConfig: MdStrConfig, options: MarkdownSanitizerConfig): domSanitize.DomSanitizerConfig {592const isTrusted = mdStrConfig.isTrusted ?? false;593const allowedLinkSchemes = [594Schemas.http,595Schemas.https,596Schemas.mailto,597Schemas.file,598Schemas.vscodeFileResource,599Schemas.vscodeRemote,600Schemas.vscodeRemoteResource,601Schemas.vscodeNotebookCell,602// For links that are handled entirely by the action handler603Schemas.internal,604];605606if (isTrusted) {607allowedLinkSchemes.push(Schemas.command);608}609610if (options.allowedLinkSchemes?.augment) {611allowedLinkSchemes.push(...options.allowedLinkSchemes.augment);612}613614return {615// allowedTags should included everything that markdown renders to.616// Since we have our own sanitize function for marked, it's possible we missed some tag so let dompurify make sure.617// HTML tags that can result from markdown are from reading https://spec.commonmark.org/0.29/618// HTML table tags that can result from markdown are from https://github.github.com/gfm/#tables-extension-619allowedTags: {620override: options.allowedTags?.override ?? allowedMarkdownHtmlTags621},622allowedAttributes: {623override: options.allowedAttributes?.override ?? allowedMarkdownHtmlAttributes,624},625allowedLinkProtocols: {626override: allowedLinkSchemes,627},628allowRelativeLinkPaths: !!mdStrConfig.baseUri,629allowedMediaProtocols: {630override: [631Schemas.http,632Schemas.https,633Schemas.data,634Schemas.file,635Schemas.vscodeFileResource,636Schemas.vscodeRemote,637Schemas.vscodeRemoteResource,638]639},640allowRelativeMediaPaths: !!mdStrConfig.baseUri,641replaceWithPlaintext: options.replaceWithPlaintext,642};643}644645/**646* Renders `str` as plaintext, stripping out Markdown syntax if it's a {@link IMarkdownString}.647*648* For example `# Header` would be output as `Header`.649*/650export function renderAsPlaintext(str: IMarkdownString | string, options?: {651/** Controls if the ``` of code blocks should be preserved in the output or not */652readonly includeCodeBlocksFences?: boolean;653/** Controls if we want to format empty links from "Link [](file)" to "Link file" */654readonly useLinkFormatter?: boolean;655}) {656if (typeof str === 'string') {657return str;658}659660// values that are too long will freeze the UI661let value = str.value ?? '';662if (value.length > 100_000) {663value = `${value.substr(0, 100_000)}…`;664}665666const renderer = createPlainTextRenderer();667if (options?.includeCodeBlocksFences) {668renderer.code = codeBlockFences;669}670if (options?.useLinkFormatter) {671renderer.link = linkFormatter;672}673674const html = marked.parse(value, { async: false, renderer });675return sanitizeRenderedMarkdown(html, { isTrusted: false }, {})676.toString()677.replace(/&(#\d+|[a-zA-Z]+);/g, m => unescapeInfo.get(m) ?? m)678.trim();679}680681const unescapeInfo = new Map<string, string>([682['"', '"'],683[' ', ' '],684['&', '&'],685[''', '\''],686['<', '<'],687['>', '>'],688]);689690function createPlainTextRenderer(): marked.Renderer {691const renderer = new marked.Renderer();692693renderer.code = ({ text }: marked.Tokens.Code): string => {694return escape(text);695};696renderer.blockquote = ({ text }: marked.Tokens.Blockquote): string => {697return text + '\n';698};699renderer.html = (_: marked.Tokens.HTML): string => {700return '';701};702renderer.heading = function ({ tokens }: marked.Tokens.Heading): string {703return this.parser.parseInline(tokens) + '\n';704};705renderer.hr = (): string => {706return '';707};708renderer.list = function ({ items }: marked.Tokens.List): string {709return items.map(x => this.listitem(x)).join('\n') + '\n';710};711renderer.listitem = ({ text }: marked.Tokens.ListItem): string => {712return text + '\n';713};714renderer.paragraph = function ({ tokens }: marked.Tokens.Paragraph): string {715return this.parser.parseInline(tokens) + '\n';716};717renderer.table = function ({ header, rows }: marked.Tokens.Table): string {718return header.map(cell => this.tablecell(cell)).join(' ') + '\n' + rows.map(cells => cells.map(cell => this.tablecell(cell)).join(' ')).join('\n') + '\n';719};720renderer.tablerow = ({ text }: marked.Tokens.TableRow): string => {721return text;722};723renderer.tablecell = function ({ tokens }: marked.Tokens.TableCell): string {724return this.parser.parseInline(tokens);725};726renderer.strong = ({ text }: marked.Tokens.Strong): string => {727return text;728};729renderer.em = ({ text }: marked.Tokens.Em): string => {730return text;731};732renderer.codespan = ({ text }: marked.Tokens.Codespan): string => {733return escape(text);734};735renderer.br = (_: marked.Tokens.Br): string => {736return '\n';737};738renderer.del = ({ text }: marked.Tokens.Del): string => {739return text;740};741renderer.image = (_: marked.Tokens.Image): string => {742return '';743};744renderer.text = ({ text }: marked.Tokens.Text): string => {745return text;746};747renderer.link = ({ text }: marked.Tokens.Link): string => {748return text;749};750return renderer;751}752753const codeBlockFences = ({ text }: marked.Tokens.Code): string => {754return `\n\`\`\`\n${escape(text)}\n\`\`\`\n`;755};756757const linkFormatter = ({ text, href }: marked.Tokens.Link): string => {758try {759if (href) {760const uri = URI.parse(href);761return text.trim() || basename(uri);762}763} catch (e) {764return text.trim() || pathBasename(href);765}766return text;767};768769function mergeRawTokenText(tokens: marked.Token[]): string {770let mergedTokenText = '';771tokens.forEach(token => {772mergedTokenText += token.raw;773});774return mergedTokenText;775}776777function completeSingleLinePattern(token: marked.Tokens.Text | marked.Tokens.Paragraph): marked.Token | undefined {778if (!token.tokens) {779return undefined;780}781782for (let i = token.tokens.length - 1; i >= 0; i--) {783const subtoken = token.tokens[i];784if (subtoken.type === 'text') {785const lines = subtoken.raw.split('\n');786const lastLine = lines[lines.length - 1];787if (lastLine.includes('`')) {788return completeCodespan(token);789}790791else if (lastLine.includes('**')) {792return completeDoublestar(token);793}794795else if (lastLine.match(/\*\w/)) {796return completeStar(token);797}798799else if (lastLine.match(/(^|\s)__\w/)) {800return completeDoubleUnderscore(token);801}802803else if (lastLine.match(/(^|\s)_\w/)) {804return completeUnderscore(token);805}806807else if (808// Text with start of link target809hasLinkTextAndStartOfLinkTarget(lastLine) ||810// This token doesn't have the link text, eg if it contains other markdown constructs that are in other subtokens.811// But some preceding token does have an unbalanced [ at least812hasStartOfLinkTargetAndNoLinkText(lastLine) && token.tokens.slice(0, i).some(t => t.type === 'text' && t.raw.match(/\[[^\]]*$/))813) {814const nextTwoSubTokens = token.tokens.slice(i + 1);815816// A markdown link can look like817// [link text](https://microsoft.com "more text")818// Where "more text" is a title for the link or an argument to a vscode command link819if (820// If the link was parsed as a link, then look for a link token and a text token with a quote821nextTwoSubTokens[0]?.type === 'link' && nextTwoSubTokens[1]?.type === 'text' && nextTwoSubTokens[1].raw.match(/^ *"[^"]*$/) ||822// And if the link was not parsed as a link (eg command link), just look for a single quote in this token823lastLine.match(/^[^"]* +"[^"]*$/)824) {825826return completeLinkTargetArg(token);827}828return completeLinkTarget(token);829}830831// Contains the start of link text, and no following tokens contain the link target832else if (lastLine.match(/(^|\s)\[\w*[^\]]*$/)) {833return completeLinkText(token);834}835}836}837838return undefined;839}840841function hasLinkTextAndStartOfLinkTarget(str: string): boolean {842return !!str.match(/(^|\s)\[.*\]\(\w*/);843}844845function hasStartOfLinkTargetAndNoLinkText(str: string): boolean {846return !!str.match(/^[^\[]*\]\([^\)]*$/);847}848849function completeListItemPattern(list: marked.Tokens.List): marked.Tokens.List | undefined {850// Patch up this one list item851const lastListItem = list.items[list.items.length - 1];852const lastListSubToken = lastListItem.tokens ? lastListItem.tokens[lastListItem.tokens.length - 1] : undefined;853854/*855Example list token structures:856857list858list_item859text860text861codespan862link863list_item864text865code // Complete indented codeblock866list_item867text868space869text870text // Incomplete indented codeblock871list_item872text873list // Nested list874list_item875text876text877878Contrast with paragraph:879paragraph880text881codespan882*/883884const listEndsInHeading = (list: marked.Tokens.List): boolean => {885// A list item can be rendered as a heading for some reason when it has a subitem where we haven't rendered the text yet like this:886// 1. list item887// -888const lastItem = list.items.at(-1);889const lastToken = lastItem?.tokens.at(-1);890return lastToken?.type === 'heading' || lastToken?.type === 'list' && listEndsInHeading(lastToken as marked.Tokens.List);891};892893let newToken: marked.Token | undefined;894if (lastListSubToken?.type === 'text' && !('inRawBlock' in lastListItem)) { // Why does Tag have a type of 'text'895newToken = completeSingleLinePattern(lastListSubToken as marked.Tokens.Text);896} else if (listEndsInHeading(list)) {897const newList = marked.lexer(list.raw.trim() + ' ')[0] as marked.Tokens.List;898if (newList.type !== 'list') {899// Something went wrong900return;901}902return newList;903}904905if (!newToken || newToken.type !== 'paragraph') { // 'text' item inside the list item turns into paragraph906// Nothing to fix, or not a pattern we were expecting907return;908}909910const previousListItemsText = mergeRawTokenText(list.items.slice(0, -1));911912// Grabbing the `- ` or `1. ` or `* ` off the list item because I can't find a better way to do this913const lastListItemLead = lastListItem.raw.match(/^(\s*(-|\d+\.|\*) +)/)?.[0];914if (!lastListItemLead) {915// Is badly formatted916return;917}918919const newListItemText = lastListItemLead +920mergeRawTokenText(lastListItem.tokens.slice(0, -1)) +921newToken.raw;922923const newList = marked.lexer(previousListItemsText + newListItemText)[0] as marked.Tokens.List;924if (newList.type !== 'list') {925// Something went wrong926return;927}928929return newList;930}931932function completeHeading(token: marked.Tokens.Heading, fullRawText: string): marked.TokensList | void {933if (token.raw.match(/-\s*$/)) {934return marked.lexer(fullRawText + ' ');935}936}937938const maxIncompleteTokensFixRounds = 3;939export function fillInIncompleteTokens(tokens: marked.TokensList): marked.TokensList {940for (let i = 0; i < maxIncompleteTokensFixRounds; i++) {941const newTokens = fillInIncompleteTokensOnce(tokens);942if (newTokens) {943tokens = newTokens;944} else {945break;946}947}948949return tokens;950}951952function fillInIncompleteTokensOnce(tokens: marked.TokensList): marked.TokensList | null {953let i: number;954let newTokens: marked.Token[] | undefined;955for (i = 0; i < tokens.length; i++) {956const token = tokens[i];957958if (token.type === 'paragraph' && token.raw.match(/(\n|^)\|/)) {959newTokens = completeTable(tokens.slice(i));960break;961}962}963964const lastToken = tokens.at(-1);965if (!newTokens && lastToken?.type === 'list') {966const newListToken = completeListItemPattern(lastToken as marked.Tokens.List);967if (newListToken) {968newTokens = [newListToken];969i = tokens.length - 1;970}971}972973if (!newTokens && lastToken?.type === 'paragraph') {974// Only operates on a single token, because any newline that follows this should break these patterns975const newToken = completeSingleLinePattern(lastToken as marked.Tokens.Paragraph);976if (newToken) {977newTokens = [newToken];978i = tokens.length - 1;979}980}981982if (newTokens) {983const newTokensList = [984...tokens.slice(0, i),985...newTokens986];987(newTokensList as marked.TokensList).links = tokens.links;988return newTokensList as marked.TokensList;989}990991if (lastToken?.type === 'heading') {992const completeTokens = completeHeading(lastToken as marked.Tokens.Heading, mergeRawTokenText(tokens));993if (completeTokens) {994return completeTokens;995}996}997998return null;999}100010011002function completeCodespan(token: marked.Token): marked.Token {1003return completeWithString(token, '`');1004}10051006function completeStar(tokens: marked.Token): marked.Token {1007return completeWithString(tokens, '*');1008}10091010function completeUnderscore(tokens: marked.Token): marked.Token {1011return completeWithString(tokens, '_');1012}10131014function completeLinkTarget(tokens: marked.Token): marked.Token {1015return completeWithString(tokens, ')', false);1016}10171018function completeLinkTargetArg(tokens: marked.Token): marked.Token {1019return completeWithString(tokens, '")', false);1020}10211022function completeLinkText(tokens: marked.Token): marked.Token {1023return completeWithString(tokens, '](https://microsoft.com)', false);1024}10251026function completeDoublestar(tokens: marked.Token): marked.Token {1027return completeWithString(tokens, '**');1028}10291030function completeDoubleUnderscore(tokens: marked.Token): marked.Token {1031return completeWithString(tokens, '__');1032}10331034function completeWithString(tokens: marked.Token[] | marked.Token, closingString: string, shouldTrim = true): marked.Token {1035const mergedRawText = mergeRawTokenText(Array.isArray(tokens) ? tokens : [tokens]);10361037// If it was completed correctly, this should be a single token.1038// Expecting either a Paragraph or a List1039const trimmedRawText = shouldTrim ? mergedRawText.trimEnd() : mergedRawText;1040return marked.lexer(trimmedRawText + closingString)[0];1041}10421043function completeTable(tokens: marked.Token[]): marked.Token[] | undefined {1044const mergedRawText = mergeRawTokenText(tokens);1045const lines = mergedRawText.split('\n');10461047let numCols: number | undefined; // The number of line1 col headers1048let hasSeparatorRow = false;1049for (let i = 0; i < lines.length; i++) {1050const line = lines[i].trim();1051if (typeof numCols === 'undefined' && line.match(/^\s*\|/)) {1052const line1Matches = line.match(/(\|[^\|]+)(?=\||$)/g);1053if (line1Matches) {1054numCols = line1Matches.length;1055}1056} else if (typeof numCols === 'number') {1057if (line.match(/^\s*\|/)) {1058if (i !== lines.length - 1) {1059// We got the line1 header row, and the line2 separator row, but there are more lines, and it wasn't parsed as a table!1060// That's strange and means that the table is probably malformed in the source, so I won't try to patch it up.1061return undefined;1062}10631064// Got a line2 separator row- partial or complete, doesn't matter, we'll replace it with a correct one1065hasSeparatorRow = true;1066} else {1067// The line after the header row isn't a valid separator row, so the table is malformed, don't fix it up1068return undefined;1069}1070}1071}10721073if (typeof numCols === 'number' && numCols > 0) {1074const prefixText = hasSeparatorRow ? lines.slice(0, -1).join('\n') : mergedRawText;1075const line1EndsInPipe = !!prefixText.match(/\|\s*$/);1076const newRawText = prefixText + (line1EndsInPipe ? '' : '|') + `\n|${' --- |'.repeat(numCols)}`;1077return marked.lexer(newRawText);1078}10791080return undefined;1081}108210831084