Path: blob/main/extensions/markdown-language-features/src/markdownEngine.ts
3292 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 type MarkdownIt = require('markdown-it');6import type Token = require('markdown-it/lib/token');7import * as vscode from 'vscode';8import { ILogger } from './logging';9import { MarkdownContributionProvider } from './markdownExtensions';10import { MarkdownPreviewConfiguration } from './preview/previewConfig';11import { Slugifier } from './slugify';12import { ITextDocument } from './types/textDocument';13import { WebviewResourceProvider } from './util/resources';14import { isOfScheme, Schemes } from './util/schemes';1516/**17* Adds begin line index to the output via the 'data-line' data attribute.18*/19const pluginSourceMap: MarkdownIt.PluginSimple = (md): void => {20// Set the attribute on every possible token.21md.core.ruler.push('source_map_data_attribute', (state): void => {22for (const token of state.tokens) {23if (token.map && token.type !== 'inline') {24token.attrSet('data-line', String(token.map[0]));25token.attrJoin('class', 'code-line');26token.attrJoin('dir', 'auto');27}28}29});3031// The 'html_block' renderer doesn't respect `attrs`. We need to insert a marker.32const originalHtmlBlockRenderer = md.renderer.rules['html_block'];33if (originalHtmlBlockRenderer) {34md.renderer.rules['html_block'] = (tokens, idx, options, env, self) => (35`<div ${self.renderAttrs(tokens[idx])} ></div>\n` +36originalHtmlBlockRenderer(tokens, idx, options, env, self)37);38}39};4041/**42* The markdown-it options that we expose in the settings.43*/44type MarkdownItConfig = Readonly<Required<Pick<MarkdownIt.Options, 'breaks' | 'linkify' | 'typographer'>>>;4546class TokenCache {47private _cachedDocument?: {48readonly uri: vscode.Uri;49readonly version: number;50readonly config: MarkdownItConfig;51};52private _tokens?: Token[];5354public tryGetCached(document: ITextDocument, config: MarkdownItConfig): Token[] | undefined {55if (this._cachedDocument56&& this._cachedDocument.uri.toString() === document.uri.toString()57&& document.version >= 0 && this._cachedDocument.version === document.version58&& this._cachedDocument.config.breaks === config.breaks59&& this._cachedDocument.config.linkify === config.linkify60) {61return this._tokens;62}63return undefined;64}6566public update(document: ITextDocument, config: MarkdownItConfig, tokens: Token[]) {67this._cachedDocument = {68uri: document.uri,69version: document.version,70config,71};72this._tokens = tokens;73}7475public clean(): void {76this._cachedDocument = undefined;77this._tokens = undefined;78}79}8081export interface RenderOutput {82html: string;83containingImages: Set<string>;84}8586interface RenderEnv {87containingImages: Set<string>;88currentDocument: vscode.Uri | undefined;89resourceProvider: WebviewResourceProvider | undefined;90}9192export interface IMdParser {93readonly slugifier: Slugifier;9495tokenize(document: ITextDocument): Promise<Token[]>;96}9798export class MarkdownItEngine implements IMdParser {99100private _md?: Promise<MarkdownIt>;101102private _slugCount = new Map<string, number>();103private readonly _tokenCache = new TokenCache();104105public readonly slugifier: Slugifier;106107public constructor(108private readonly _contributionProvider: MarkdownContributionProvider,109slugifier: Slugifier,110private readonly _logger: ILogger,111) {112this.slugifier = slugifier;113114_contributionProvider.onContributionsChanged(() => {115// Markdown plugin contributions may have changed116this._md = undefined;117this._tokenCache.clean();118});119}120121122public async getEngine(resource: vscode.Uri | undefined): Promise<MarkdownIt> {123const config = this._getConfig(resource);124return this._getEngine(config);125}126127private async _getEngine(config: MarkdownItConfig): Promise<MarkdownIt> {128if (!this._md) {129this._md = (async () => {130const markdownIt = await import('markdown-it');131let md: MarkdownIt = markdownIt.default(await getMarkdownOptions(() => md));132md.linkify.set({ fuzzyLink: false });133134for (const plugin of this._contributionProvider.contributions.markdownItPlugins.values()) {135try {136md = (await plugin)(md);137} catch (e) {138console.error('Could not load markdown it plugin', e);139}140}141142const frontMatterPlugin = await import('markdown-it-front-matter');143// Extract rules from front matter plugin and apply at a lower precedence144let fontMatterRule: any;145frontMatterPlugin.default(<any>{146block: {147ruler: {148before: (_id: any, _id2: any, rule: any) => { fontMatterRule = rule; }149}150}151}, () => { /* noop */ });152153md.block.ruler.before('fence', 'front_matter', fontMatterRule, {154alt: ['paragraph', 'reference', 'blockquote', 'list']155});156157this._addImageRenderer(md);158this._addFencedRenderer(md);159this._addLinkNormalizer(md);160this._addLinkValidator(md);161this._addNamedHeaders(md);162this._addLinkRenderer(md);163md.use(pluginSourceMap);164return md;165})();166}167168const md = await this._md!;169md.set(config);170return md;171}172173public reloadPlugins() {174this._md = undefined;175}176177private _tokenizeDocument(178document: ITextDocument,179config: MarkdownItConfig,180engine: MarkdownIt181): Token[] {182const cached = this._tokenCache.tryGetCached(document, config);183if (cached) {184this._resetSlugCount();185return cached;186}187188this._logger.trace('MarkdownItEngine', `tokenizeDocument - ${document.uri}`);189const tokens = this._tokenizeString(document.getText(), engine);190this._tokenCache.update(document, config, tokens);191return tokens;192}193194private _tokenizeString(text: string, engine: MarkdownIt) {195this._resetSlugCount();196197return engine.parse(text, {});198}199200private _resetSlugCount(): void {201this._slugCount = new Map<string, number>();202}203204public async render(input: ITextDocument | string, resourceProvider?: WebviewResourceProvider): Promise<RenderOutput> {205const config = this._getConfig(typeof input === 'string' ? undefined : input.uri);206const engine = await this._getEngine(config);207208const tokens = typeof input === 'string'209? this._tokenizeString(input, engine)210: this._tokenizeDocument(input, config, engine);211212const env: RenderEnv = {213containingImages: new Set<string>(),214currentDocument: typeof input === 'string' ? undefined : input.uri,215resourceProvider,216};217218const html = engine.renderer.render(tokens, {219...engine.options,220...config221}, env);222223return {224html,225containingImages: env.containingImages226};227}228229public async tokenize(document: ITextDocument): Promise<Token[]> {230const config = this._getConfig(document.uri);231const engine = await this._getEngine(config);232return this._tokenizeDocument(document, config, engine);233}234235public cleanCache(): void {236this._tokenCache.clean();237}238239private _getConfig(resource?: vscode.Uri): MarkdownItConfig {240const config = MarkdownPreviewConfiguration.getForResource(resource ?? null);241return {242breaks: config.previewLineBreaks,243linkify: config.previewLinkify,244typographer: config.previewTypographer,245};246}247248private _addImageRenderer(md: MarkdownIt): void {249const original = md.renderer.rules.image;250md.renderer.rules.image = (tokens: Token[], idx: number, options, env: RenderEnv, self) => {251const token = tokens[idx];252const src = token.attrGet('src');253if (src) {254env.containingImages?.add(src);255256if (!token.attrGet('data-src')) {257token.attrSet('src', this._toResourceUri(src, env.currentDocument, env.resourceProvider));258token.attrSet('data-src', src);259}260}261262if (original) {263return original(tokens, idx, options, env, self);264} else {265return self.renderToken(tokens, idx, options);266}267};268}269270private _addFencedRenderer(md: MarkdownIt): void {271const original = md.renderer.rules['fenced'];272md.renderer.rules['fenced'] = (tokens: Token[], idx: number, options, env, self) => {273const token = tokens[idx];274if (token.map?.length) {275token.attrJoin('class', 'hljs');276}277278if (original) {279return original(tokens, idx, options, env, self);280} else {281return self.renderToken(tokens, idx, options);282}283};284}285286private _addLinkNormalizer(md: MarkdownIt): void {287const normalizeLink = md.normalizeLink;288md.normalizeLink = (link: string) => {289try {290// Normalize VS Code schemes to target the current version291if (isOfScheme(Schemes.vscode, link) || isOfScheme(Schemes['vscode-insiders'], link)) {292return normalizeLink(vscode.Uri.parse(link).with({ scheme: vscode.env.uriScheme }).toString());293}294295} catch (e) {296// noop297}298return normalizeLink(link);299};300}301302private _addLinkValidator(md: MarkdownIt): void {303const validateLink = md.validateLink;304md.validateLink = (link: string) => {305return validateLink(link)306|| isOfScheme(Schemes.vscode, link)307|| isOfScheme(Schemes['vscode-insiders'], link)308|| /^data:image\/.*?;/.test(link);309};310}311312private _addNamedHeaders(md: MarkdownIt): void {313const original = md.renderer.rules.heading_open;314md.renderer.rules.heading_open = (tokens: Token[], idx: number, options, env, self) => {315const title = this._tokenToPlainText(tokens[idx + 1]);316let slug = this.slugifier.fromHeading(title);317318if (this._slugCount.has(slug.value)) {319const count = this._slugCount.get(slug.value)!;320this._slugCount.set(slug.value, count + 1);321slug = this.slugifier.fromHeading(slug.value + '-' + (count + 1));322} else {323this._slugCount.set(slug.value, 0);324}325326tokens[idx].attrSet('id', slug.value);327328if (original) {329return original(tokens, idx, options, env, self);330} else {331return self.renderToken(tokens, idx, options);332}333};334}335336private _tokenToPlainText(token: Token): string {337if (token.children) {338return token.children.map(x => this._tokenToPlainText(x)).join('');339}340341switch (token.type) {342case 'text':343case 'emoji':344case 'code_inline':345return token.content;346default:347return '';348}349}350351private _addLinkRenderer(md: MarkdownIt): void {352const original = md.renderer.rules.link_open;353354md.renderer.rules.link_open = (tokens: Token[], idx: number, options, env, self) => {355const token = tokens[idx];356const href = token.attrGet('href');357// A string, including empty string, may be `href`.358if (typeof href === 'string') {359token.attrSet('data-href', href);360}361if (original) {362return original(tokens, idx, options, env, self);363} else {364return self.renderToken(tokens, idx, options);365}366};367}368369private _toResourceUri(href: string, currentDocument: vscode.Uri | undefined, resourceProvider: WebviewResourceProvider | undefined): string {370try {371// Support file:// links372if (isOfScheme(Schemes.file, href)) {373const uri = vscode.Uri.parse(href);374if (resourceProvider) {375return resourceProvider.asWebviewUri(uri).toString(true);376}377// Not sure how to resolve this378return href;379}380381// If original link doesn't look like a url with a scheme, assume it must be a link to a file in workspace382if (!/^[a-z\-]+:/i.test(href)) {383// Use a fake scheme for parsing384let uri = vscode.Uri.parse('markdown-link:' + href);385386// Relative paths should be resolved correctly inside the preview but we need to387// handle absolute paths specially to resolve them relative to the workspace root388if (uri.path[0] === '/' && currentDocument) {389const root = vscode.workspace.getWorkspaceFolder(currentDocument);390if (root) {391uri = vscode.Uri.joinPath(root.uri, uri.fsPath).with({392fragment: uri.fragment,393query: uri.query,394});395396if (resourceProvider) {397return resourceProvider.asWebviewUri(uri).toString(true);398} else {399uri = uri.with({ scheme: 'markdown-link' });400}401}402}403404return uri.toString(true).replace(/^markdown-link:/, '');405}406407return href;408} catch {409return href;410}411}412}413414async function getMarkdownOptions(md: () => MarkdownIt): Promise<MarkdownIt.Options> {415const hljs = (await import('highlight.js')).default;416return {417html: true,418highlight: (str: string, lang?: string) => {419lang = normalizeHighlightLang(lang);420if (lang && hljs.getLanguage(lang)) {421try {422return hljs.highlight(str, {423language: lang,424ignoreIllegals: true,425}).value;426}427catch (error) { }428}429return md().utils.escapeHtml(str);430}431};432}433434function normalizeHighlightLang(lang: string | undefined) {435switch (lang?.toLowerCase()) {436case 'shell':437return 'sh';438439case 'py3':440return 'python';441442case 'tsx':443case 'typescriptreact':444// Workaround for highlight not supporting tsx: https://github.com/isagalaev/highlight.js/issues/1155445return 'jsx';446447case 'json5':448case 'jsonc':449return 'json';450451case 'c#':452case 'csharp':453return 'cs';454455default:456return lang;457}458}459460461