diff --git a/package-lock.json b/package-lock.json index 362802882ef..4e96cec8331 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", + "katex": "^0.16.22", "kerberos": "2.1.1", "minimist": "^1.2.6", "native-is-elevated": "0.7.0", @@ -10958,6 +10959,31 @@ "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", "dev": true }, + "node_modules/katex": { + "version": "0.16.22", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz", + "integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/kerberos": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/kerberos/-/kerberos-2.1.1.tgz", diff --git a/package.json b/package.json index c8151ee376a..0191cbf1677 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", + "katex": "^0.16.22", "kerberos": "2.1.1", "minimist": "^1.2.6", "native-is-elevated": "0.7.0", diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index 553c584a63d..925b9f2a5dd 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -1771,6 +1771,80 @@ export const basicMarkupHtmlTags = Object.freeze([ 'wbr', ]); +export const trustedMathMlTags = Object.freeze([ + 'math', + 'menclose', + 'merror', + 'mfenced', + 'mfrac', + 'mglyph', + 'mi', + 'mlabeledtr', + 'mmultiscripts', + 'mn', + 'mo', + 'mover', + 'mpadded', + 'mphantom', + 'mroot', + 'mrow', + 'ms', + 'mspace', + 'msqrt', + 'mstyle', + 'msub', + 'msup', + 'msubsup', + 'mtable', + 'mtd', + 'mtext', + 'mtr', + 'munder', + 'munderover', + 'mprescripts', + + // svg tags + 'svg', + 'altglyph', + 'altglyphdef', + 'altglyphitem', + 'circle', + 'clippath', + 'defs', + 'desc', + 'ellipse', + 'filter', + 'font', + 'g', + 'glyph', + 'glyphref', + 'hkern', + 'line', + 'lineargradient', + 'marker', + 'mask', + 'metadata', + 'mpath', + 'path', + 'pattern', + 'polygon', + 'polyline', + 'radialgradient', + 'rect', + 'stop', + 'style', + 'switch', + 'symbol', + 'text', + 'textpath', + 'title', + 'tref', + 'tspan', + 'view', + 'vkern', +]); + + const defaultDomPurifyConfig = Object.freeze({ ALLOWED_TAGS: ['a', 'button', 'blockquote', 'code', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'input', 'label', 'li', 'p', 'pre', 'select', 'small', 'span', 'strong', 'textarea', 'ul', 'ol'], ALLOWED_ATTR: ['href', 'data-href', 'data-command', 'target', 'title', 'name', 'src', 'alt', 'class', 'id', 'role', 'tabindex', 'style', 'data-code', 'width', 'height', 'align', 'x-dispatch', 'required', 'checked', 'placeholder', 'type', 'start'], diff --git a/src/vs/base/browser/markdownRenderer.ts b/src/vs/base/browser/markdownRenderer.ts index 2cb748afcdf..51fad18eafc 100644 --- a/src/vs/base/browser/markdownRenderer.ts +++ b/src/vs/base/browser/markdownRenderer.ts @@ -40,8 +40,9 @@ export interface MarkdownRenderOptions extends FormattedTextRenderOptions { } export interface ISanitizerOptions { - replaceWithPlaintext?: boolean; - allowedTags?: string[]; + readonly replaceWithPlaintext?: boolean; + readonly allowedTags?: readonly string[]; + readonly preserveClassAndStyleAttrs?: boolean; } const defaultMarkedRenderers = Object.freeze({ @@ -106,14 +107,14 @@ export function renderMarkdown(markdown: IMarkdownString, options: MarkdownRende const element = createElement(options); const markedInstance = new marked.Marked(...(markedOptions.markedExtensions ?? [])); - const { renderer, codeBlocks, syncCodeBlocks } = createMarkdownRenderer(markedInstance, options, markdown); + const { renderer, codeBlocks, syncCodeBlocks } = createMarkdownRenderer(markedInstance, options, markedOptions, markdown); const value = preprocessMarkdownString(markdown); let renderedMarkdown: string; if (options.fillInIncompleteTokens) { // The defaults are applied by parse but not lexer()/parser(), and they need to be present const opts: MarkedOptions = { - ...marked.defaults, + ...markedInstance.defaults, ...markedOptions, renderer }; @@ -244,8 +245,8 @@ function rewriteRenderedLinks(markdown: IMarkdownString, options: MarkdownRender } } -function createMarkdownRenderer(marked: marked.Marked, options: MarkdownRenderOptions, markdown: IMarkdownString): { renderer: marked.Renderer; codeBlocks: Promise<[string, HTMLElement]>[]; syncCodeBlocks: [string, HTMLElement][] } { - const renderer = new marked.Renderer(); +function createMarkdownRenderer(marked: marked.Marked, options: MarkdownRenderOptions, markedOptions: MarkedOptions, markdown: IMarkdownString): { renderer: marked.Renderer; codeBlocks: Promise<[string, HTMLElement]>[]; syncCodeBlocks: [string, HTMLElement][] } { + const renderer = new marked.Renderer(markedOptions); renderer.image = defaultMarkedRenderers.image; renderer.link = defaultMarkedRenderers.link; renderer.paragraph = defaultMarkedRenderers.paragraph; @@ -396,7 +397,7 @@ function resolveWithBaseUri(baseUri: URI, href: string): string { } interface IInternalSanitizerOptions extends ISanitizerOptions { - isTrusted?: boolean | MarkdownStringTrustedOptions; + readonly isTrusted?: boolean | MarkdownStringTrustedOptions; } const selfClosingTags = ['area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr']; @@ -409,6 +410,11 @@ function sanitizeRenderedMarkdown( const store = new DisposableStore(); store.add(addDompurifyHook('uponSanitizeAttribute', (element, e) => { if (e.attrName === 'style' || e.attrName === 'class') { + if (options.preserveClassAndStyleAttrs) { + e.keepAttr = true; + return; + } + if (element.tagName === 'SPAN') { if (e.attrName === 'style') { e.keepAttr = /^(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(e.attrValue); @@ -418,6 +424,7 @@ function sanitizeRenderedMarkdown( return; } } + e.keepAttr = false; return; } else if (element.tagName === 'INPUT' && element.attributes.getNamedItem('type')?.value === 'checkbox') { @@ -484,7 +491,8 @@ function sanitizeRenderedMarkdown( store.add(DOM.hookDomPurifyHrefAndSrcSanitizer(allowedSchemes)); try { - return dompurify.sanitize(renderedMarkdown, { ...config, RETURN_TRUSTED_TYPE: true }); + const a = dompurify.sanitize(renderedMarkdown, { ...config, RETURN_TRUSTED_TYPE: true }); + return a; } finally { store.dispose(); } @@ -541,8 +549,8 @@ function getSanitizerOptions(options: IInternalSanitizerOptions): { config: domp // Since we have our own sanitize function for marked, it's possible we missed some tag so let dompurify make sure. // HTML tags that can result from markdown are from reading https://spec.commonmark.org/0.29/ // HTML table tags that can result from markdown are from https://github.github.com/gfm/#tables-extension- - ALLOWED_TAGS: options.allowedTags ?? [...DOM.basicMarkupHtmlTags], - ALLOWED_ATTR: allowedMarkdownAttr, + ALLOWED_TAGS: options.allowedTags ? [...options.allowedTags] : [...DOM.basicMarkupHtmlTags], + ALLOWED_ATTR: [...allowedMarkdownAttr, ...(options.preserveClassAndStyleAttrs ? ['class'] : [])], ALLOW_UNKNOWN_PROTOCOLS: true, }, allowedSchemes diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatMarkdownContentPart.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatMarkdownContentPart.ts index 295e2a0223a..c4c5fdec385 100644 --- a/src/vs/workbench/contrib/chat/browser/chatContentParts/chatMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/chatMarkdownContentPart.ts @@ -4,8 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from '../../../../../base/browser/dom.js'; +import { MarkedOptions } from '../../../../../base/browser/markdownRenderer.js'; import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js'; import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; +import { coalesce } from '../../../../../base/common/arrays.js'; import { findLast } from '../../../../../base/common/arraysFind.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; @@ -46,6 +48,8 @@ import '../media/chatCodeBlockPill.css'; import { IDisposableReference, ResourcePool } from './chatCollections.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; import { ChatExtensionsContentPart } from './chatExtensionsContentPart.js'; +import { MarkedKatexSupport } from './markedKatexSupport.js'; +import './media/chatMarkdownPart.css'; const $ = dom.$; @@ -55,6 +59,7 @@ export interface IChatMarkdownContentPartOptions { export class ChatMarkdownContentPart extends Disposable implements IChatContentPart { private static idPool = 0; + public readonly codeblocksPartId = String(++ChatMarkdownContentPart.idPool); public readonly domNode: HTMLElement; private readonly allRefs: IDisposableReference[] = []; @@ -91,13 +96,27 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP let globalCodeBlockIndexStart = codeBlockStartIndex; let thisPartCodeBlockIndexStart = 0; + const markedExtensions = coalesce([ + MarkedKatexSupport.getExtension(context.container), + ]); + // Don't set to 'false' for responses, respect defaults - const markedOpts = isRequestVM(element) ? { + const markedOpts: MarkedOptions = isRequestVM(element) || true ? { gfm: true, breaks: true, - } : undefined; + markedExtensions, + } : { + markedExtensions, + }; const result = this._register(renderer.render(markdown.content, { + sanitizerOptions: { + allowedTags: [ + ...dom.basicMarkupHtmlTags, + ...dom.trustedMathMlTags, + ], + preserveClassAndStyleAttrs: true, + }, fillInIncompleteTokens, codeBlockRendererSync: (languageId, text, raw) => { const isCodeBlockComplete = !isResponseVM(context.element) || context.element.isComplete || !raw || codeblockHasClosingBackticks(raw); @@ -212,6 +231,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP this._register(markdownDecorationsRenderer.walkTreeAndAnnotateReferenceLinks(markdown, result.element)); orderedDisposablesList.reverse().forEach(d => this._register(d)); + result.element.classList.add('chat-markdown-part'); this.domNode = result.element; } @@ -306,7 +326,7 @@ function codeblockHasClosingBackticks(str: string): boolean { return !!str.match(/\n```+$/); } -class CollapsedCodeBlock extends Disposable { +export class CollapsedCodeBlock extends Disposable { public readonly element: HTMLElement; diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/markedKatexSupport.ts b/src/vs/workbench/contrib/chat/browser/chatContentParts/markedKatexSupport.ts new file mode 100644 index 00000000000..b8a5b00da1b --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/markedKatexSupport.ts @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { importAMDNodeModule, resolveAmdNodeModulePath } from '../../../../../amdX.js'; +import * as dom from '../../../../../base/browser/dom.js'; +import type * as marked from '../../../../../base/common/marked/marked.js'; + +export class MarkedKatexSupport { + + public static _katex?: typeof import('katex').default; + + static { + importAMDNodeModule('katex', 'dist/katex.js').then(katex => { + this._katex = katex; + }); + } + + public static getExtension(container: HTMLElement): marked.MarkedExtension | undefined { + if (!this._katex) { + return undefined; + } + + this.ensureKatexStyles(container); + return MarkedKatexExtension.extension(this._katex); + } + + public static ensureKatexStyles(container: HTMLElement) { + const doc = dom.getWindow(container).document; + if (!doc.querySelector('link.katex')) { + const katexStyle = document.createElement('link'); + katexStyle.classList.add('katex'); + katexStyle.rel = 'stylesheet'; + katexStyle.href = resolveAmdNodeModulePath('katex', 'dist/katex.min.css'); + doc.head.appendChild(katexStyle); + } + } +} + + +namespace MarkedKatexExtension { + + // From https://github.com/UziTech/marked-katex-extension/blob/main/src/index.js + export interface MarkedKatexOptions { + nonStandard?: boolean; + } + + // allow-any-unicode-next-line + const inlineRule = /^(\${1,2})(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n\$]))\1(?=[\s?!\.,:?!。,:]|$)/; + const inlineRuleNonStandard = /^(\${1,2})(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n\$]))\1/; // Non-standard, even if there are no spaces before and after $ or $$, try to parse + + const blockRule = /^(\${1,2})\n((?:\\[^]|[^\\])+?)\n\1(?:\n|$)/; + + export function extension(katex: typeof import('katex').default, options = {}): marked.MarkedExtension { + return { + extensions: [ + inlineKatex(options, createRenderer(katex, options, false)), + blockKatex(options, createRenderer(katex, options, true)), + ], + }; + } + + function createRenderer(katex: typeof import('katex').default, options: MarkedKatexOptions, newlineAfter: boolean): marked.RendererExtensionFunction { + return (token: marked.Tokens.Generic) => katex.renderToString(token.text, { ...options, displayMode: token.displayMode }) + (newlineAfter ? '\n' : ''); + } + + function inlineKatex(options: MarkedKatexOptions, renderer: marked.RendererExtensionFunction): marked.TokenizerAndRendererExtension { + const nonStandard = options && options.nonStandard; + const ruleReg = nonStandard ? inlineRuleNonStandard : inlineRule; + return { + name: 'inlineKatex', + level: 'inline', + start(src: string) { + let index; + let indexSrc = src; + + while (indexSrc) { + index = indexSrc.indexOf('$'); + if (index === -1) { + return; + } + const f = nonStandard ? index > -1 : index === 0 || indexSrc.charAt(index - 1) === ' '; + if (f) { + const possibleKatex = indexSrc.substring(index); + + if (possibleKatex.match(ruleReg)) { + return index; + } + } + + indexSrc = indexSrc.substring(index + 1).replace(/^\$+/, ''); + } + return; + }, + tokenizer(src: string, tokens: marked.Token[]) { + const match = src.match(ruleReg); + if (match) { + return { + type: 'inlineKatex', + raw: match[0], + text: match[2].trim(), + displayMode: match[1].length === 2, + }; + } + return; + }, + renderer, + }; + } + + function blockKatex(options: MarkedKatexOptions, renderer: marked.RendererExtensionFunction): marked.TokenizerAndRendererExtension { + return { + name: 'blockKatex', + level: 'block', + tokenizer(src: string, tokens: marked.Token[]) { + const match = src.match(blockRule); + if (match) { + return { + type: 'blockKatex', + raw: match[0], + text: match[2].trim(), + displayMode: match[1].length === 2, + }; + } + return; + }, + renderer, + }; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/chatContentParts/media/chatMarkdownPart.css b/src/vs/workbench/contrib/chat/browser/chatContentParts/media/chatMarkdownPart.css new file mode 100644 index 00000000000..a36ae69bc02 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatContentParts/media/chatMarkdownPart.css @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.chat-markdown-part .katex-display { + overflow-x: scroll; +} diff --git a/src/vs/workbench/contrib/chat/browser/chatMarkdownRenderer.ts b/src/vs/workbench/contrib/chat/browser/chatMarkdownRenderer.ts index 858bc89d5f5..051f535f9f6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatMarkdownRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/chatMarkdownRenderer.ts @@ -76,6 +76,7 @@ export class ChatMarkdownRenderer extends MarkdownRenderer { sanitizerOptions: { replaceWithPlaintext: true, allowedTags: allowedHtmlTags, + ...options?.sanitizerOptions, } };