Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/markdown-language-features/src/markdownEngine.ts
3292 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import type MarkdownIt = require('markdown-it');
7
import type Token = require('markdown-it/lib/token');
8
import * as vscode from 'vscode';
9
import { ILogger } from './logging';
10
import { MarkdownContributionProvider } from './markdownExtensions';
11
import { MarkdownPreviewConfiguration } from './preview/previewConfig';
12
import { Slugifier } from './slugify';
13
import { ITextDocument } from './types/textDocument';
14
import { WebviewResourceProvider } from './util/resources';
15
import { isOfScheme, Schemes } from './util/schemes';
16
17
/**
18
* Adds begin line index to the output via the 'data-line' data attribute.
19
*/
20
const pluginSourceMap: MarkdownIt.PluginSimple = (md): void => {
21
// Set the attribute on every possible token.
22
md.core.ruler.push('source_map_data_attribute', (state): void => {
23
for (const token of state.tokens) {
24
if (token.map && token.type !== 'inline') {
25
token.attrSet('data-line', String(token.map[0]));
26
token.attrJoin('class', 'code-line');
27
token.attrJoin('dir', 'auto');
28
}
29
}
30
});
31
32
// The 'html_block' renderer doesn't respect `attrs`. We need to insert a marker.
33
const originalHtmlBlockRenderer = md.renderer.rules['html_block'];
34
if (originalHtmlBlockRenderer) {
35
md.renderer.rules['html_block'] = (tokens, idx, options, env, self) => (
36
`<div ${self.renderAttrs(tokens[idx])} ></div>\n` +
37
originalHtmlBlockRenderer(tokens, idx, options, env, self)
38
);
39
}
40
};
41
42
/**
43
* The markdown-it options that we expose in the settings.
44
*/
45
type MarkdownItConfig = Readonly<Required<Pick<MarkdownIt.Options, 'breaks' | 'linkify' | 'typographer'>>>;
46
47
class TokenCache {
48
private _cachedDocument?: {
49
readonly uri: vscode.Uri;
50
readonly version: number;
51
readonly config: MarkdownItConfig;
52
};
53
private _tokens?: Token[];
54
55
public tryGetCached(document: ITextDocument, config: MarkdownItConfig): Token[] | undefined {
56
if (this._cachedDocument
57
&& this._cachedDocument.uri.toString() === document.uri.toString()
58
&& document.version >= 0 && this._cachedDocument.version === document.version
59
&& this._cachedDocument.config.breaks === config.breaks
60
&& this._cachedDocument.config.linkify === config.linkify
61
) {
62
return this._tokens;
63
}
64
return undefined;
65
}
66
67
public update(document: ITextDocument, config: MarkdownItConfig, tokens: Token[]) {
68
this._cachedDocument = {
69
uri: document.uri,
70
version: document.version,
71
config,
72
};
73
this._tokens = tokens;
74
}
75
76
public clean(): void {
77
this._cachedDocument = undefined;
78
this._tokens = undefined;
79
}
80
}
81
82
export interface RenderOutput {
83
html: string;
84
containingImages: Set<string>;
85
}
86
87
interface RenderEnv {
88
containingImages: Set<string>;
89
currentDocument: vscode.Uri | undefined;
90
resourceProvider: WebviewResourceProvider | undefined;
91
}
92
93
export interface IMdParser {
94
readonly slugifier: Slugifier;
95
96
tokenize(document: ITextDocument): Promise<Token[]>;
97
}
98
99
export class MarkdownItEngine implements IMdParser {
100
101
private _md?: Promise<MarkdownIt>;
102
103
private _slugCount = new Map<string, number>();
104
private readonly _tokenCache = new TokenCache();
105
106
public readonly slugifier: Slugifier;
107
108
public constructor(
109
private readonly _contributionProvider: MarkdownContributionProvider,
110
slugifier: Slugifier,
111
private readonly _logger: ILogger,
112
) {
113
this.slugifier = slugifier;
114
115
_contributionProvider.onContributionsChanged(() => {
116
// Markdown plugin contributions may have changed
117
this._md = undefined;
118
this._tokenCache.clean();
119
});
120
}
121
122
123
public async getEngine(resource: vscode.Uri | undefined): Promise<MarkdownIt> {
124
const config = this._getConfig(resource);
125
return this._getEngine(config);
126
}
127
128
private async _getEngine(config: MarkdownItConfig): Promise<MarkdownIt> {
129
if (!this._md) {
130
this._md = (async () => {
131
const markdownIt = await import('markdown-it');
132
let md: MarkdownIt = markdownIt.default(await getMarkdownOptions(() => md));
133
md.linkify.set({ fuzzyLink: false });
134
135
for (const plugin of this._contributionProvider.contributions.markdownItPlugins.values()) {
136
try {
137
md = (await plugin)(md);
138
} catch (e) {
139
console.error('Could not load markdown it plugin', e);
140
}
141
}
142
143
const frontMatterPlugin = await import('markdown-it-front-matter');
144
// Extract rules from front matter plugin and apply at a lower precedence
145
let fontMatterRule: any;
146
frontMatterPlugin.default(<any>{
147
block: {
148
ruler: {
149
before: (_id: any, _id2: any, rule: any) => { fontMatterRule = rule; }
150
}
151
}
152
}, () => { /* noop */ });
153
154
md.block.ruler.before('fence', 'front_matter', fontMatterRule, {
155
alt: ['paragraph', 'reference', 'blockquote', 'list']
156
});
157
158
this._addImageRenderer(md);
159
this._addFencedRenderer(md);
160
this._addLinkNormalizer(md);
161
this._addLinkValidator(md);
162
this._addNamedHeaders(md);
163
this._addLinkRenderer(md);
164
md.use(pluginSourceMap);
165
return md;
166
})();
167
}
168
169
const md = await this._md!;
170
md.set(config);
171
return md;
172
}
173
174
public reloadPlugins() {
175
this._md = undefined;
176
}
177
178
private _tokenizeDocument(
179
document: ITextDocument,
180
config: MarkdownItConfig,
181
engine: MarkdownIt
182
): Token[] {
183
const cached = this._tokenCache.tryGetCached(document, config);
184
if (cached) {
185
this._resetSlugCount();
186
return cached;
187
}
188
189
this._logger.trace('MarkdownItEngine', `tokenizeDocument - ${document.uri}`);
190
const tokens = this._tokenizeString(document.getText(), engine);
191
this._tokenCache.update(document, config, tokens);
192
return tokens;
193
}
194
195
private _tokenizeString(text: string, engine: MarkdownIt) {
196
this._resetSlugCount();
197
198
return engine.parse(text, {});
199
}
200
201
private _resetSlugCount(): void {
202
this._slugCount = new Map<string, number>();
203
}
204
205
public async render(input: ITextDocument | string, resourceProvider?: WebviewResourceProvider): Promise<RenderOutput> {
206
const config = this._getConfig(typeof input === 'string' ? undefined : input.uri);
207
const engine = await this._getEngine(config);
208
209
const tokens = typeof input === 'string'
210
? this._tokenizeString(input, engine)
211
: this._tokenizeDocument(input, config, engine);
212
213
const env: RenderEnv = {
214
containingImages: new Set<string>(),
215
currentDocument: typeof input === 'string' ? undefined : input.uri,
216
resourceProvider,
217
};
218
219
const html = engine.renderer.render(tokens, {
220
...engine.options,
221
...config
222
}, env);
223
224
return {
225
html,
226
containingImages: env.containingImages
227
};
228
}
229
230
public async tokenize(document: ITextDocument): Promise<Token[]> {
231
const config = this._getConfig(document.uri);
232
const engine = await this._getEngine(config);
233
return this._tokenizeDocument(document, config, engine);
234
}
235
236
public cleanCache(): void {
237
this._tokenCache.clean();
238
}
239
240
private _getConfig(resource?: vscode.Uri): MarkdownItConfig {
241
const config = MarkdownPreviewConfiguration.getForResource(resource ?? null);
242
return {
243
breaks: config.previewLineBreaks,
244
linkify: config.previewLinkify,
245
typographer: config.previewTypographer,
246
};
247
}
248
249
private _addImageRenderer(md: MarkdownIt): void {
250
const original = md.renderer.rules.image;
251
md.renderer.rules.image = (tokens: Token[], idx: number, options, env: RenderEnv, self) => {
252
const token = tokens[idx];
253
const src = token.attrGet('src');
254
if (src) {
255
env.containingImages?.add(src);
256
257
if (!token.attrGet('data-src')) {
258
token.attrSet('src', this._toResourceUri(src, env.currentDocument, env.resourceProvider));
259
token.attrSet('data-src', src);
260
}
261
}
262
263
if (original) {
264
return original(tokens, idx, options, env, self);
265
} else {
266
return self.renderToken(tokens, idx, options);
267
}
268
};
269
}
270
271
private _addFencedRenderer(md: MarkdownIt): void {
272
const original = md.renderer.rules['fenced'];
273
md.renderer.rules['fenced'] = (tokens: Token[], idx: number, options, env, self) => {
274
const token = tokens[idx];
275
if (token.map?.length) {
276
token.attrJoin('class', 'hljs');
277
}
278
279
if (original) {
280
return original(tokens, idx, options, env, self);
281
} else {
282
return self.renderToken(tokens, idx, options);
283
}
284
};
285
}
286
287
private _addLinkNormalizer(md: MarkdownIt): void {
288
const normalizeLink = md.normalizeLink;
289
md.normalizeLink = (link: string) => {
290
try {
291
// Normalize VS Code schemes to target the current version
292
if (isOfScheme(Schemes.vscode, link) || isOfScheme(Schemes['vscode-insiders'], link)) {
293
return normalizeLink(vscode.Uri.parse(link).with({ scheme: vscode.env.uriScheme }).toString());
294
}
295
296
} catch (e) {
297
// noop
298
}
299
return normalizeLink(link);
300
};
301
}
302
303
private _addLinkValidator(md: MarkdownIt): void {
304
const validateLink = md.validateLink;
305
md.validateLink = (link: string) => {
306
return validateLink(link)
307
|| isOfScheme(Schemes.vscode, link)
308
|| isOfScheme(Schemes['vscode-insiders'], link)
309
|| /^data:image\/.*?;/.test(link);
310
};
311
}
312
313
private _addNamedHeaders(md: MarkdownIt): void {
314
const original = md.renderer.rules.heading_open;
315
md.renderer.rules.heading_open = (tokens: Token[], idx: number, options, env, self) => {
316
const title = this._tokenToPlainText(tokens[idx + 1]);
317
let slug = this.slugifier.fromHeading(title);
318
319
if (this._slugCount.has(slug.value)) {
320
const count = this._slugCount.get(slug.value)!;
321
this._slugCount.set(slug.value, count + 1);
322
slug = this.slugifier.fromHeading(slug.value + '-' + (count + 1));
323
} else {
324
this._slugCount.set(slug.value, 0);
325
}
326
327
tokens[idx].attrSet('id', slug.value);
328
329
if (original) {
330
return original(tokens, idx, options, env, self);
331
} else {
332
return self.renderToken(tokens, idx, options);
333
}
334
};
335
}
336
337
private _tokenToPlainText(token: Token): string {
338
if (token.children) {
339
return token.children.map(x => this._tokenToPlainText(x)).join('');
340
}
341
342
switch (token.type) {
343
case 'text':
344
case 'emoji':
345
case 'code_inline':
346
return token.content;
347
default:
348
return '';
349
}
350
}
351
352
private _addLinkRenderer(md: MarkdownIt): void {
353
const original = md.renderer.rules.link_open;
354
355
md.renderer.rules.link_open = (tokens: Token[], idx: number, options, env, self) => {
356
const token = tokens[idx];
357
const href = token.attrGet('href');
358
// A string, including empty string, may be `href`.
359
if (typeof href === 'string') {
360
token.attrSet('data-href', href);
361
}
362
if (original) {
363
return original(tokens, idx, options, env, self);
364
} else {
365
return self.renderToken(tokens, idx, options);
366
}
367
};
368
}
369
370
private _toResourceUri(href: string, currentDocument: vscode.Uri | undefined, resourceProvider: WebviewResourceProvider | undefined): string {
371
try {
372
// Support file:// links
373
if (isOfScheme(Schemes.file, href)) {
374
const uri = vscode.Uri.parse(href);
375
if (resourceProvider) {
376
return resourceProvider.asWebviewUri(uri).toString(true);
377
}
378
// Not sure how to resolve this
379
return href;
380
}
381
382
// If original link doesn't look like a url with a scheme, assume it must be a link to a file in workspace
383
if (!/^[a-z\-]+:/i.test(href)) {
384
// Use a fake scheme for parsing
385
let uri = vscode.Uri.parse('markdown-link:' + href);
386
387
// Relative paths should be resolved correctly inside the preview but we need to
388
// handle absolute paths specially to resolve them relative to the workspace root
389
if (uri.path[0] === '/' && currentDocument) {
390
const root = vscode.workspace.getWorkspaceFolder(currentDocument);
391
if (root) {
392
uri = vscode.Uri.joinPath(root.uri, uri.fsPath).with({
393
fragment: uri.fragment,
394
query: uri.query,
395
});
396
397
if (resourceProvider) {
398
return resourceProvider.asWebviewUri(uri).toString(true);
399
} else {
400
uri = uri.with({ scheme: 'markdown-link' });
401
}
402
}
403
}
404
405
return uri.toString(true).replace(/^markdown-link:/, '');
406
}
407
408
return href;
409
} catch {
410
return href;
411
}
412
}
413
}
414
415
async function getMarkdownOptions(md: () => MarkdownIt): Promise<MarkdownIt.Options> {
416
const hljs = (await import('highlight.js')).default;
417
return {
418
html: true,
419
highlight: (str: string, lang?: string) => {
420
lang = normalizeHighlightLang(lang);
421
if (lang && hljs.getLanguage(lang)) {
422
try {
423
return hljs.highlight(str, {
424
language: lang,
425
ignoreIllegals: true,
426
}).value;
427
}
428
catch (error) { }
429
}
430
return md().utils.escapeHtml(str);
431
}
432
};
433
}
434
435
function normalizeHighlightLang(lang: string | undefined) {
436
switch (lang?.toLowerCase()) {
437
case 'shell':
438
return 'sh';
439
440
case 'py3':
441
return 'python';
442
443
case 'tsx':
444
case 'typescriptreact':
445
// Workaround for highlight not supporting tsx: https://github.com/isagalaev/highlight.js/issues/1155
446
return 'jsx';
447
448
case 'json5':
449
case 'jsonc':
450
return 'json';
451
452
case 'c#':
453
case 'csharp':
454
return 'cs';
455
456
default:
457
return lang;
458
}
459
}
460
461