Path: blob/main/extensions/markdown-language-features/src/preview/documentRenderer.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 * as vscode from 'vscode';6import * as uri from 'vscode-uri';7import { ILogger } from '../logging';8import { MarkdownItEngine } from '../markdownEngine';9import { MarkdownContributionProvider } from '../markdownExtensions';10import { escapeAttribute, getNonce } from '../util/dom';11import { WebviewResourceProvider } from '../util/resources';12import { MarkdownPreviewConfiguration, MarkdownPreviewConfigurationManager } from './previewConfig';13import { ContentSecurityPolicyArbiter, MarkdownPreviewSecurityLevel } from './security';141516/**17* Strings used inside the markdown preview.18*19* Stored here and then injected in the preview so that they20* can be localized using our normal localization process.21*/22const previewStrings = {23cspAlertMessageText: vscode.l10n.t("Some content has been disabled in this document"),2425cspAlertMessageTitle: vscode.l10n.t("Potentially unsafe or insecure content has been disabled in the Markdown preview. Change the Markdown preview security setting to allow insecure content or enable scripts"),2627cspAlertMessageLabel: vscode.l10n.t("Content Disabled Security Warning")28};2930export interface MarkdownContentProviderOutput {31html: string;32containingImages: Set<string>;33}3435export interface ImageInfo {36readonly id: string;37readonly width: number;38readonly height: number;39}4041export class MdDocumentRenderer {42constructor(43private readonly _engine: MarkdownItEngine,44private readonly _context: vscode.ExtensionContext,45private readonly _cspArbiter: ContentSecurityPolicyArbiter,46private readonly _contributionProvider: MarkdownContributionProvider,47private readonly _logger: ILogger48) {49this.iconPath = {50dark: vscode.Uri.joinPath(this._context.extensionUri, 'media', 'preview-dark.svg'),51light: vscode.Uri.joinPath(this._context.extensionUri, 'media', 'preview-light.svg'),52};53}5455public readonly iconPath: { light: vscode.Uri; dark: vscode.Uri };5657public async renderDocument(58markdownDocument: vscode.TextDocument,59resourceProvider: WebviewResourceProvider,60previewConfigurations: MarkdownPreviewConfigurationManager,61initialLine: number | undefined,62selectedLine: number | undefined,63state: any | undefined,64imageInfo: readonly ImageInfo[],65token: vscode.CancellationToken66): Promise<MarkdownContentProviderOutput> {67const sourceUri = markdownDocument.uri;68const config = previewConfigurations.loadAndCacheConfiguration(sourceUri);69const initialData = {70source: sourceUri.toString(),71fragment: state?.fragment || markdownDocument.uri.fragment || undefined,72line: initialLine,73selectedLine,74scrollPreviewWithEditor: config.scrollPreviewWithEditor,75scrollEditorWithPreview: config.scrollEditorWithPreview,76doubleClickToSwitchToEditor: config.doubleClickToSwitchToEditor,77disableSecurityWarnings: this._cspArbiter.shouldDisableSecurityWarnings(),78webviewResourceRoot: resourceProvider.asWebviewUri(markdownDocument.uri).toString(),79};8081this._logger.trace('DocumentRenderer', `provideTextDocumentContent - ${markdownDocument.uri}`, initialData);8283// Content Security Policy84const nonce = getNonce();85const csp = this._getCsp(resourceProvider, sourceUri, nonce);8687const body = await this.renderBody(markdownDocument, resourceProvider);88if (token.isCancellationRequested) {89return { html: '', containingImages: new Set() };90}9192const html = `<!DOCTYPE html>93<html style="${escapeAttribute(this._getSettingsOverrideStyles(config))}">94<head>95<meta http-equiv="Content-type" content="text/html;charset=UTF-8">96<meta http-equiv="Content-Security-Policy" content="${escapeAttribute(csp)}">97<meta id="vscode-markdown-preview-data"98data-settings="${escapeAttribute(JSON.stringify(initialData))}"99data-strings="${escapeAttribute(JSON.stringify(previewStrings))}"100data-state="${escapeAttribute(JSON.stringify(state || {}))}"101data-initial-md-content="${escapeAttribute(body.html)}">102<script src="${this._extensionResourcePath(resourceProvider, 'pre.js')}" nonce="${nonce}"></script>103${this._getStyles(resourceProvider, sourceUri, config, imageInfo)}104<base href="${resourceProvider.asWebviewUri(markdownDocument.uri)}">105</head>106<body class="vscode-body ${config.scrollBeyondLastLine ? 'scrollBeyondLastLine' : ''} ${config.wordWrap ? 'wordWrap' : ''} ${config.markEditorSelection ? 'showEditorSelection' : ''}">107${this._getScripts(resourceProvider, nonce)}108</body>109</html>`;110return {111html,112containingImages: body.containingImages,113};114}115116public async renderBody(117markdownDocument: vscode.TextDocument,118resourceProvider: WebviewResourceProvider,119): Promise<MarkdownContentProviderOutput> {120const rendered = await this._engine.render(markdownDocument, resourceProvider);121const html = `<div class="markdown-body" dir="auto">${rendered.html}<div class="code-line" data-line="${markdownDocument.lineCount}"></div></div>`;122return {123html,124containingImages: rendered.containingImages125};126}127128public renderFileNotFoundDocument(resource: vscode.Uri): string {129const resourcePath = uri.Utils.basename(resource);130const body = vscode.l10n.t('{0} cannot be found', resourcePath);131return `<!DOCTYPE html>132<html>133<body class="vscode-body">134${body}135</body>136</html>`;137}138139private _extensionResourcePath(resourceProvider: WebviewResourceProvider, mediaFile: string): string {140const webviewResource = resourceProvider.asWebviewUri(141vscode.Uri.joinPath(this._context.extensionUri, 'media', mediaFile));142return webviewResource.toString();143}144145private _fixHref(resourceProvider: WebviewResourceProvider, resource: vscode.Uri, href: string): string {146if (!href) {147return href;148}149150if (href.startsWith('http:') || href.startsWith('https:') || href.startsWith('file:')) {151return href;152}153154// Assume it must be a local file155if (href.startsWith('/') || /^[a-z]:\\/i.test(href)) {156return resourceProvider.asWebviewUri(vscode.Uri.file(href)).toString();157}158159// Use a workspace relative path if there is a workspace160const root = vscode.workspace.getWorkspaceFolder(resource);161if (root) {162return resourceProvider.asWebviewUri(vscode.Uri.joinPath(root.uri, href)).toString();163}164165// Otherwise look relative to the markdown file166return resourceProvider.asWebviewUri(vscode.Uri.joinPath(uri.Utils.dirname(resource), href)).toString();167}168169private _computeCustomStyleSheetIncludes(resourceProvider: WebviewResourceProvider, resource: vscode.Uri, config: MarkdownPreviewConfiguration): string {170if (!Array.isArray(config.styles)) {171return '';172}173const out: string[] = [];174for (const style of config.styles) {175out.push(`<link rel="stylesheet" class="code-user-style" data-source="${escapeAttribute(style)}" href="${escapeAttribute(this._fixHref(resourceProvider, resource, style))}" type="text/css" media="screen">`);176}177return out.join('\n');178}179180private _getSettingsOverrideStyles(config: MarkdownPreviewConfiguration): string {181return [182config.fontFamily ? `--markdown-font-family: ${config.fontFamily};` : '',183isNaN(config.fontSize) ? '' : `--markdown-font-size: ${config.fontSize}px;`,184isNaN(config.lineHeight) ? '' : `--markdown-line-height: ${config.lineHeight};`,185].join(' ');186}187188private _getImageStabilizerStyles(imageInfo: readonly ImageInfo[]): string {189if (!imageInfo.length) {190return '';191}192193let ret = '<style>\n';194for (const imgInfo of imageInfo) {195ret += `#${imgInfo.id}.loading {196height: ${imgInfo.height}px;197width: ${imgInfo.width}px;198}\n`;199}200ret += '</style>\n';201202return ret;203}204205private _getStyles(resourceProvider: WebviewResourceProvider, resource: vscode.Uri, config: MarkdownPreviewConfiguration, imageInfo: readonly ImageInfo[]): string {206const baseStyles: string[] = [];207for (const resource of this._contributionProvider.contributions.previewStyles) {208baseStyles.push(`<link rel="stylesheet" type="text/css" href="${escapeAttribute(resourceProvider.asWebviewUri(resource))}">`);209}210211return `${baseStyles.join('\n')}212${this._computeCustomStyleSheetIncludes(resourceProvider, resource, config)}213${this._getImageStabilizerStyles(imageInfo)}`;214}215216private _getScripts(resourceProvider: WebviewResourceProvider, nonce: string): string {217const out: string[] = [];218for (const resource of this._contributionProvider.contributions.previewScripts) {219out.push(`<script async220src="${escapeAttribute(resourceProvider.asWebviewUri(resource))}"221nonce="${nonce}"222charset="UTF-8"></script>`);223}224return out.join('\n');225}226227private _getCsp(228provider: WebviewResourceProvider,229resource: vscode.Uri,230nonce: string231): string {232const rule = provider.cspSource.split(';')[0];233switch (this._cspArbiter.getSecurityLevelForResource(resource)) {234case MarkdownPreviewSecurityLevel.AllowInsecureContent:235return `default-src 'none'; img-src 'self' ${rule} http: https: data:; media-src 'self' ${rule} http: https: data:; script-src 'nonce-${nonce}'; style-src 'self' ${rule} 'unsafe-inline' http: https: data:; font-src 'self' ${rule} http: https: data:;`;236237case MarkdownPreviewSecurityLevel.AllowInsecureLocalContent:238return `default-src 'none'; img-src 'self' ${rule} https: data: http://localhost:* http://127.0.0.1:*; media-src 'self' ${rule} https: data: http://localhost:* http://127.0.0.1:*; script-src 'nonce-${nonce}'; style-src 'self' ${rule} 'unsafe-inline' https: data: http://localhost:* http://127.0.0.1:*; font-src 'self' ${rule} https: data: http://localhost:* http://127.0.0.1:*;`;239240case MarkdownPreviewSecurityLevel.AllowScriptsAndAllContent:241return ``;242243case MarkdownPreviewSecurityLevel.Strict:244default:245return `default-src 'none'; img-src 'self' ${rule} https: data:; media-src 'self' ${rule} https: data:; script-src 'nonce-${nonce}'; style-src 'self' ${rule} 'unsafe-inline' https: data:; font-src 'self' ${rule} https: data:;`;246}247}248}249250251