Path: blob/main/extensions/markdown-language-features/src/markdownEngine.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 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 { ISlugifier, SlugBuilder } 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 {87readonly containingImages: Set<string>;88readonly currentDocument: vscode.Uri | undefined;89readonly resourceProvider: WebviewResourceProvider | undefined;90readonly slugifier: SlugBuilder;91}9293export interface IMdParser {94readonly slugifier: ISlugifier;9596tokenize(document: ITextDocument): Promise<Token[]>;97}9899export class MarkdownItEngine implements IMdParser {100101private _md?: Promise<MarkdownIt>;102103private readonly _tokenCache = new TokenCache();104105public readonly slugifier: ISlugifier;106107public constructor(108private readonly _contributionProvider: MarkdownContributionProvider,109slugifier: ISlugifier,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;145// eslint-disable-next-line local/code-no-any-casts146frontMatterPlugin.default(<any>{147block: {148ruler: {149before: (_id: any, _id2: any, rule: any) => { fontMatterRule = rule; }150}151}152}, () => { /* noop */ });153154md.block.ruler.before('fence', 'front_matter', fontMatterRule, {155alt: ['paragraph', 'reference', 'blockquote', 'list']156});157158this._addImageRenderer(md);159this._addFencedRenderer(md);160this._addLinkNormalizer(md);161this._addLinkValidator(md);162this._addNamedHeaders(md);163this._addLinkRenderer(md);164md.use(pluginSourceMap);165return md;166})();167}168169const md = await this._md!;170md.set(config);171return md;172}173174public reloadPlugins() {175this._md = undefined;176}177178private _tokenizeDocument(179document: ITextDocument,180config: MarkdownItConfig,181engine: MarkdownIt182): Token[] {183const cached = this._tokenCache.tryGetCached(document, config);184if (cached) {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) {195const env: RenderEnv = {196currentDocument: undefined,197containingImages: new Set<string>(),198slugifier: this.slugifier.createBuilder(),199resourceProvider: undefined,200};201return engine.parse(text, env);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,216slugifier: this.slugifier.createBuilder(),217};218219const html = engine.renderer.render(tokens, {220...engine.options,221...config222}, env);223224return {225html,226containingImages: env.containingImages227};228}229230public async tokenize(document: ITextDocument): Promise<Token[]> {231const config = this._getConfig(document.uri);232const engine = await this._getEngine(config);233return this._tokenizeDocument(document, config, engine);234}235236public cleanCache(): void {237this._tokenCache.clean();238}239240private _getConfig(resource?: vscode.Uri): MarkdownItConfig {241const config = MarkdownPreviewConfiguration.getForResource(resource ?? null);242return {243breaks: config.previewLineBreaks,244linkify: config.previewLinkify,245typographer: config.previewTypographer,246};247}248249private _addImageRenderer(md: MarkdownIt): void {250const original = md.renderer.rules.image;251md.renderer.rules.image = (tokens: Token[], idx: number, options, env: RenderEnv, self) => {252const token = tokens[idx];253const src = token.attrGet('src');254if (src) {255env.containingImages?.add(src);256257if (!token.attrGet('data-src')) {258token.attrSet('src', this._toResourceUri(src, env.currentDocument, env.resourceProvider));259token.attrSet('data-src', src);260}261}262263if (original) {264return original(tokens, idx, options, env, self);265} else {266return self.renderToken(tokens, idx, options);267}268};269}270271private _addFencedRenderer(md: MarkdownIt): void {272const original = md.renderer.rules['fenced'];273md.renderer.rules['fenced'] = (tokens: Token[], idx: number, options, env, self) => {274const token = tokens[idx];275if (token.map?.length) {276token.attrJoin('class', 'hljs');277}278279if (original) {280return original(tokens, idx, options, env, self);281} else {282return self.renderToken(tokens, idx, options);283}284};285}286287private _addLinkNormalizer(md: MarkdownIt): void {288const normalizeLink = md.normalizeLink;289md.normalizeLink = (link: string) => {290try {291// Normalize VS Code schemes to target the current version292if (isOfScheme(Schemes.vscode, link) || isOfScheme(Schemes['vscode-insiders'], link)) {293return normalizeLink(vscode.Uri.parse(link).with({ scheme: vscode.env.uriScheme }).toString());294}295296} catch (e) {297// noop298}299return normalizeLink(link);300};301}302303private _addLinkValidator(md: MarkdownIt): void {304const validateLink = md.validateLink;305md.validateLink = (link: string) => {306return validateLink(link)307|| isOfScheme(Schemes.vscode, link)308|| isOfScheme(Schemes['vscode-insiders'], link)309|| /^data:image\/.*?;/.test(link);310};311}312313private _addNamedHeaders(md: MarkdownIt): void {314const original = md.renderer.rules.heading_open;315md.renderer.rules.heading_open = (tokens: Token[], idx: number, options, env: unknown, self) => {316const title = this._tokenToPlainText(tokens[idx + 1]);317const slug = (env as RenderEnv).slugifier ? (env as RenderEnv).slugifier.add(title) : this.slugifier.fromHeading(title);318tokens[idx].attrSet('id', slug.value);319320if (original) {321return original(tokens, idx, options, env, self);322} else {323return self.renderToken(tokens, idx, options);324}325};326}327328private _tokenToPlainText(token: Token): string {329if (token.children) {330return token.children.map(x => this._tokenToPlainText(x)).join('');331}332333switch (token.type) {334case 'text':335case 'emoji':336case 'code_inline':337return token.content;338default:339return '';340}341}342343private _addLinkRenderer(md: MarkdownIt): void {344const original = md.renderer.rules.link_open;345346md.renderer.rules.link_open = (tokens: Token[], idx: number, options, env, self) => {347const token = tokens[idx];348const href = token.attrGet('href');349// A string, including empty string, may be `href`.350if (typeof href === 'string') {351token.attrSet('data-href', href);352}353if (original) {354return original(tokens, idx, options, env, self);355} else {356return self.renderToken(tokens, idx, options);357}358};359}360361private _toResourceUri(href: string, currentDocument: vscode.Uri | undefined, resourceProvider: WebviewResourceProvider | undefined): string {362try {363// Support file:// links364if (isOfScheme(Schemes.file, href)) {365const uri = vscode.Uri.parse(href);366if (resourceProvider) {367return resourceProvider.asWebviewUri(uri).toString(true);368}369// Not sure how to resolve this370return href;371}372373// If original link doesn't look like a url with a scheme, assume it must be a link to a file in workspace374if (!/^[a-z\-]+:/i.test(href)) {375// Use a fake scheme for parsing376let uri = vscode.Uri.parse('markdown-link:' + href);377378// Relative paths should be resolved correctly inside the preview but we need to379// handle absolute paths specially to resolve them relative to the workspace root380if (uri.path[0] === '/' && currentDocument) {381const root = vscode.workspace.getWorkspaceFolder(currentDocument);382if (root) {383uri = vscode.Uri.joinPath(root.uri, uri.fsPath).with({384fragment: uri.fragment,385query: uri.query,386});387388if (resourceProvider) {389return resourceProvider.asWebviewUri(uri).toString(true);390} else {391uri = uri.with({ scheme: 'markdown-link' });392}393}394}395396return uri.toString(true).replace(/^markdown-link:/, '');397}398399return href;400} catch {401return href;402}403}404}405406async function getMarkdownOptions(md: () => MarkdownIt): Promise<MarkdownIt.Options> {407const hljs = (await import('highlight.js')).default;408return {409html: true,410highlight: (str: string, lang?: string) => {411lang = normalizeHighlightLang(lang);412if (lang && hljs.getLanguage(lang)) {413try {414return hljs.highlight(str, {415language: lang,416ignoreIllegals: true,417}).value;418}419catch (error) { }420}421return md().utils.escapeHtml(str);422}423};424}425426function normalizeHighlightLang(lang: string | undefined) {427switch (lang?.toLowerCase()) {428case 'shell':429return 'sh';430431case 'py3':432return 'python';433434case 'tsx':435case 'typescriptreact':436// Workaround for highlight not supporting tsx: https://github.com/isagalaev/highlight.js/issues/1155437return 'jsx';438439case 'json5':440case 'jsonc':441return 'json';442443case 'c#':444case 'csharp':445return 'cs';446447default:448return lang;449}450}451452453