Notebooks: Support for In-Proc Markdown Renderer (#6164)

* NB improve startup using built-in markdown render

This is a sample branch showing perf improvements if we load content using built-in markdown rendering
- Has issues where images aren't correctly rendered due to sanitization, need to copy renderer code and update settings
- Moves content load up before anythign to do with providers since we can render without knowing about these things

# Conflicts:
#	src/sql/workbench/parts/notebook/cellViews/textCell.component.ts

* Re-enable logging of each cell's rendering time

* Fix test issue

* Kernel loading working with new markdown renderer

# Conflicts:
#	src/sql/workbench/parts/notebook/cellViews/textCell.component.ts

* Fixed tests, cleaned up code

* markdownOutput component integration

* PR Comments

* PR feedback 2

* PR feedback again
This commit is contained in:
Chris LaFreniere
2019-06-27 20:55:50 -07:00
committed by GitHub
parent b34e3cbe90
commit 8cf4120c27
10 changed files with 409 additions and 77 deletions

View File

@@ -24,11 +24,17 @@ import { ICellModel } from 'sql/workbench/parts/notebook/models/modelInterfaces'
import { ISanitizer, defaultSanitizer } from 'sql/workbench/parts/notebook/outputs/sanitizer';
import { NotebookModel } from 'sql/workbench/parts/notebook/models/notebookModel';
import { CellToggleMoreActions } from 'sql/workbench/parts/notebook/cellToggleMoreActions';
import { convertVscodeResourceToFileInSubDirectories } from 'sql/workbench/parts/notebook/notebookUtils';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { toDisposable } from 'vs/base/common/lifecycle';
import { IMarkdownRenderResult } from 'vs/editor/contrib/markdown/markdownRenderer';
import { IOpenerService } from 'vs/platform/opener/common/opener';
import { NotebookMarkdownRenderer } from 'sql/workbench/parts/notebook/outputs/notebookMarkdown';
import { convertVscodeResourceToFileInSubDirectories, useInProcMarkdown } from 'sql/workbench/parts/notebook/notebookUtils';
export const TEXT_SELECTOR: string = 'text-cell-component';
const USER_SELECT_CLASS = 'actionselect';
@Component({
selector: TEXT_SELECTOR,
templateUrl: decodeURI(require.toUrl('./textCell.component.html'))
@@ -73,17 +79,28 @@ export class TextCellComponent extends CellView implements OnInit, OnChanges {
public readonly onDidClickLink = this._onDidClickLink.event;
private _cellToggleMoreActions: CellToggleMoreActions;
private _hover: boolean;
private markdownRenderer: NotebookMarkdownRenderer;
private markdownResult: IMarkdownRenderResult;
constructor(
@Inject(forwardRef(() => CommonServiceInterface)) private _bootstrapService: CommonServiceInterface,
@Inject(forwardRef(() => ChangeDetectorRef)) private _changeRef: ChangeDetectorRef,
@Inject(IInstantiationService) private _instantiationService: IInstantiationService,
@Inject(IWorkbenchThemeService) private themeService: IWorkbenchThemeService,
@Inject(ICommandService) private _commandService: ICommandService
@Inject(ICommandService) private _commandService: ICommandService,
@Inject(IOpenerService) private readonly openerService: IOpenerService,
@Inject(IConfigurationService) private configurationService: IConfigurationService,
) {
super();
this.isEditMode = true;
this._cellToggleMoreActions = this._instantiationService.createInstance(CellToggleMoreActions);
this.markdownRenderer = this._instantiationService.createInstance(NotebookMarkdownRenderer);
this._register(toDisposable(() => {
if (this.markdownResult) {
this.markdownResult.dispose();
}
}));
}
//Gets sanitizer from ISanitizer interface
@@ -142,7 +159,7 @@ export class TextCellComponent extends CellView implements OnInit, OnChanges {
* If content is empty and in non-edit mode, default it to 'Double-click to edit'
* Sanitizes the data to be shown in markdown cell
*/
private updatePreview() {
private updatePreview(): void {
let trustedChanged = this.cellModel && this._lastTrustedMode !== this.cellModel.trustedMode;
let contentChanged = this._content !== this.cellModel.source || this.cellModel.source.length === 0;
if (trustedChanged || contentChanged) {
@@ -153,13 +170,24 @@ export class TextCellComponent extends CellView implements OnInit, OnChanges {
this._content = this.cellModel.source;
}
this._commandService.executeCommand<string>('notebook.showPreview', this.cellModel.notebookModel.notebookUri, this._content).then((htmlcontent) => {
htmlcontent = convertVscodeResourceToFileInSubDirectories(htmlcontent, this.cellModel);
htmlcontent = this.sanitizeContent(htmlcontent);
let outputElement = <HTMLElement>this.output.nativeElement;
outputElement.innerHTML = htmlcontent;
if (useInProcMarkdown(this.configurationService)) {
this.markdownRenderer.setNotebookURI(this.cellModel.notebookModel.notebookUri);
this.markdownResult = this.markdownRenderer.render({
isTrusted: this.cellModel.trustedMode,
value: this._content
});
this.setLoading(false);
});
let outputElement = <HTMLElement>this.output.nativeElement;
outputElement.innerHTML = this.markdownResult.element.innerHTML;
} else {
this._commandService.executeCommand<string>('notebook.showPreview', this.cellModel.notebookModel.notebookUri, this._content).then((htmlcontent) => {
htmlcontent = convertVscodeResourceToFileInSubDirectories(htmlcontent, this.cellModel);
htmlcontent = this.sanitizeContent(htmlcontent);
let outputElement = <HTMLElement>this.output.nativeElement;
outputElement.innerHTML = htmlcontent;
this.setLoading(false);
});
}
}
}

View File

@@ -405,6 +405,7 @@ export interface INotebookModel {
serializationStateChanged(changeType: NotebookChangeType): void;
standardKernels: IStandardKernelWithProvider[];
}
export interface NotebookContentChange {
@@ -502,7 +503,6 @@ export interface INotebookModelOptions {
contentManager: IContentManager;
notebookManagers: INotebookManager[];
providerId: string;
standardKernels: IStandardKernelWithProvider[];
defaultKernel: nb.IKernelSpec;
cellMagicMapper: ICellMagicMapper;

View File

@@ -75,6 +75,7 @@ export class NotebookModel extends Disposable implements INotebookModel {
private _clientSessionListeners: IDisposable[] = [];
private _connectionUrisToDispose: string[] = [];
private _textCellsLoading: number = 0;
private _standardKernels: notebookUtils.IStandardKernelWithProvider[];
public requestConnectionHandler: () => Promise<boolean>;
@@ -92,14 +93,6 @@ export class NotebookModel extends Disposable implements INotebookModel {
this._trustedMode = false;
this._providerId = _notebookOptions.providerId;
this._onProviderIdChanged.fire(this._providerId);
this._notebookOptions.standardKernels.forEach(kernel => {
let displayName = kernel.displayName;
if (!displayName) {
displayName = kernel.name;
}
this._kernelDisplayNameToConnectionProviderIds.set(displayName, kernel.connectionProviderIds);
this._kernelDisplayNameToNotebookProviderIds.set(displayName, kernel.notebookProvider);
});
if (this._notebookOptions.layoutChanged) {
this._notebookOptions.layoutChanged(() => this._layoutChanged.fire());
}
@@ -274,6 +267,15 @@ export class NotebookModel extends Disposable implements INotebookModel {
return this._onValidConnectionSelected.event;
}
public get standardKernels(): notebookUtils.IStandardKernelWithProvider[] {
return this._standardKernels;
}
public set standardKernels(kernels) {
this._standardKernels = kernels;
this.setKernelDisplayNameMapsWithStandardKernels();
}
public getApplicableConnectionProviderIds(kernelDisplayName: string): string[] {
let ids = [];
if (kernelDisplayName) {
@@ -282,7 +284,7 @@ export class NotebookModel extends Disposable implements INotebookModel {
return !ids ? [] : ids;
}
public async requestModelLoad(isTrusted: boolean = false): Promise<void> {
public async loadContents(isTrusted: boolean = false): Promise<void> {
try {
this._trustedMode = isTrusted;
let contents = null;
@@ -294,7 +296,7 @@ export class NotebookModel extends Disposable implements INotebookModel {
// if cells already exist, create them with language info (if it is saved)
this._cells = [];
if (contents) {
this._defaultLanguageInfo = this.getDefaultLanguageInfo(contents);
this._defaultLanguageInfo = contents && contents.metadata && contents.metadata.language_info;
this._savedKernelInfo = this.getSavedKernelInfo(contents);
if (contents.cells && contents.cells.length > 0) {
this._cells = contents.cells.map(c => {
@@ -304,6 +306,13 @@ export class NotebookModel extends Disposable implements INotebookModel {
});
}
}
} catch (error) {
this._inErrorState = true;
throw error;
}
}
public async requestModelLoad(): Promise<void> {
try {
this.setDefaultKernelAndProviderId();
this.trySetLanguageFromLangInfo();
} catch (error) {
@@ -421,8 +430,8 @@ export class NotebookModel extends Disposable implements INotebookModel {
}
public async startSession(manager: INotebookManager, displayName?: string, setErrorStateOnFail?: boolean): Promise<void> {
if (displayName) {
let standardKernel = this._notebookOptions.standardKernels.find(kernel => kernel.displayName === displayName);
if (displayName && this._standardKernels) {
let standardKernel = this._standardKernels.find(kernel => kernel.displayName === displayName);
this._defaultKernel = displayName ? { name: standardKernel.name, display_name: standardKernel.displayName } : this._defaultKernel;
}
if (this._defaultKernel) {
@@ -506,30 +515,35 @@ export class NotebookModel extends Disposable implements INotebookModel {
this._defaultKernel = notebookConstants.sqlKernelSpec;
this._providerId = SQL_NOTEBOOK_PROVIDER;
}
// update default language
this._defaultLanguageInfo = {
name: this._providerId === SQL_NOTEBOOK_PROVIDER ? 'sql' : 'python',
version: ''
};
if (!this._defaultLanguageInfo || this._defaultLanguageInfo.name) {
// update default language
this._defaultLanguageInfo = {
name: this._providerId === SQL_NOTEBOOK_PROVIDER ? 'sql' : 'python',
version: ''
};
}
}
private isValidConnection(profile: IConnectionProfile | connection.Connection) {
let standardKernels = this._notebookOptions.standardKernels.find(kernel => this._defaultKernel && kernel.displayName === this._defaultKernel.display_name);
let connectionProviderIds = standardKernels ? standardKernels.connectionProviderIds : undefined;
return profile && connectionProviderIds && connectionProviderIds.find(provider => provider === profile.providerName) !== undefined;
if (this._standardKernels) {
let standardKernels = this._standardKernels.find(kernel => this._defaultKernel && kernel.displayName === this._defaultKernel.display_name);
let connectionProviderIds = standardKernels ? standardKernels.connectionProviderIds : undefined;
return profile && connectionProviderIds && connectionProviderIds.find(provider => provider === profile.providerName) !== undefined;
}
return false;
}
public getStandardKernelFromName(name: string): notebookUtils.IStandardKernelWithProvider {
if (name) {
let kernel = this._notebookOptions.standardKernels.find(kernel => kernel.name.toLowerCase() === name.toLowerCase());
if (name && this._standardKernels) {
let kernel = this._standardKernels.find(kernel => kernel.name.toLowerCase() === name.toLowerCase());
return kernel;
}
return undefined;
}
public getStandardKernelFromDisplayName(displayName: string): notebookUtils.IStandardKernelWithProvider {
if (displayName) {
let kernel = this._notebookOptions.standardKernels.find(kernel => kernel.displayName.toLowerCase() === displayName.toLowerCase());
if (displayName && this._standardKernels) {
let kernel = this._standardKernels.find(kernel => kernel.displayName.toLowerCase() === displayName.toLowerCase());
return kernel;
}
return undefined;
@@ -713,15 +727,6 @@ export class NotebookModel extends Disposable implements INotebookModel {
}
}
// Get default language if saved in notebook file
// Otherwise, default to python
private getDefaultLanguageInfo(notebook: nb.INotebookContents): nb.ILanguageInfo {
return (notebook && notebook.metadata && notebook.metadata.language_info) ? notebook.metadata.language_info : {
name: this._providerId === SQL_NOTEBOOK_PROVIDER ? 'sql' : 'python',
version: '',
mimetype: this._providerId === SQL_NOTEBOOK_PROVIDER ? 'x-sql' : 'x-python'
};
}
// Get default kernel info if saved in notebook file
private getSavedKernelInfo(notebook: nb.INotebookContents): nb.IKernelInfo {
@@ -746,11 +751,12 @@ export class NotebookModel extends Disposable implements INotebookModel {
if (this._savedKernelInfo.display_name !== displayName) {
this._savedKernelInfo.display_name = displayName;
}
let standardKernel = this._notebookOptions.standardKernels.find(kernel => kernel.displayName === displayName || displayName.startsWith(kernel.displayName));
if (standardKernel && this._savedKernelInfo.name && this._savedKernelInfo.name !== standardKernel.name) {
this._savedKernelInfo.name = standardKernel.name;
this._savedKernelInfo.display_name = standardKernel.displayName;
if (this._standardKernels) {
let standardKernel = this._standardKernels.find(kernel => kernel.displayName === displayName || displayName.startsWith(kernel.displayName));
if (standardKernel && this._savedKernelInfo.name && this._savedKernelInfo.name !== standardKernel.name) {
this._savedKernelInfo.name = standardKernel.name;
this._savedKernelInfo.display_name = standardKernel.displayName;
}
}
}
}
@@ -951,6 +957,23 @@ export class NotebookModel extends Disposable implements INotebookModel {
}));
}
/**
* Set maps with values to have a way to determine the connection
* provider and notebook provider ids from a kernel display name
*/
private setKernelDisplayNameMapsWithStandardKernels(): void {
if (this._standardKernels) {
this._standardKernels.forEach(kernel => {
let displayName = kernel.displayName;
if (!displayName) {
displayName = kernel.name;
}
this._kernelDisplayNameToConnectionProviderIds.set(displayName, kernel.connectionProviderIds);
this._kernelDisplayNameToNotebookProviderIds.set(displayName, kernel.notebookProvider);
});
}
}
/**
* Serialize the model to JSON.
*/

View File

@@ -69,7 +69,6 @@ export class NotebookComponent extends AngularDisposable implements OnInit, OnDe
protected isLoading: boolean;
private notebookManagers: INotebookManager[] = [];
private _modelReadyDeferred = new Deferred<NotebookModel>();
private _modelRegisteredDeferred = new Deferred<NotebookModel>();
private profile: IConnectionProfile;
private _trustedAction: TrustedAction;
private _runAllCellsAction: RunAllCellsAction;
@@ -146,10 +145,6 @@ export class NotebookComponent extends AngularDisposable implements OnInit, OnDe
return this._model && this._model.activeCell ? this._model.activeCell.id : '';
}
public get modelRegistered(): Promise<NotebookModel> {
return this._modelRegisteredDeferred.promise;
}
public get cells(): ICellModel[] {
return this._model ? this._model.cells : [];
}
@@ -222,6 +217,7 @@ export class NotebookComponent extends AngularDisposable implements OnInit, OnDe
private async doLoad(): Promise<void> {
try {
await this.createModelAndLoadContents();
await this.setNotebookManager();
await this.loadModel();
this._modelReadyDeferred.resolve(this._model);
@@ -268,7 +264,17 @@ export class NotebookComponent extends AngularDisposable implements OnInit, OnDe
}
private async loadModel(): Promise<void> {
// Wait on provider information to be available before loading kernel and other information
await this.awaitNonDefaultProvider();
await this._model.requestModelLoad();
this.detectChanges();
await this._model.startSession(this._model.notebookManager, undefined, true);
this.setContextKeyServiceWithProviderId(this._model.providerId);
this.fillInActionsForCurrentContext();
this.detectChanges();
}
private async createModelAndLoadContents(): Promise<void> {
let model = new NotebookModel({
factory: this.modelFactory,
notebookUri: this._notebookParams.notebookUri,
@@ -276,27 +282,22 @@ export class NotebookComponent extends AngularDisposable implements OnInit, OnDe
notificationService: this.notificationService,
notebookManagers: this.notebookManagers,
contentManager: this._notebookParams.input.contentManager,
standardKernels: this._notebookParams.input.standardKernels,
cellMagicMapper: new CellMagicMapper(this.notebookService.languageMagics),
providerId: 'sql', // this is tricky; really should also depend on the connection profile
providerId: 'sql',
defaultKernel: this._notebookParams.input.defaultKernel,
layoutChanged: this._notebookParams.input.layoutChanged,
capabilitiesService: this.capabilitiesService,
editorLoadedTimestamp: this._notebookParams.input.editorOpenedTimestamp
}, this.profile, this.logService, this.notificationService, this.telemetryService);
model.onError((errInfo: INotification) => this.handleModelError(errInfo));
let trusted = await this.notebookService.isNotebookTrustCached(this._notebookParams.notebookUri, this.isDirty());
await model.requestModelLoad(trusted);
model.onError((errInfo: INotification) => this.handleModelError(errInfo));
model.contentChanged((change) => this.handleContentChanged(change));
model.onProviderIdChange((provider) => this.handleProviderIdChanged(provider));
model.kernelChanged((kernelArgs) => this.handleKernelChanged(kernelArgs));
this._model = this._register(model);
this.updateToolbarComponents(this._model.trustedMode);
this._modelRegisteredDeferred.resolve(this._model);
await this._model.loadContents(trusted);
this.setLoading(false);
await model.startSession(this.model.notebookManager, undefined, true);
this.setContextKeyServiceWithProviderId(model.providerId);
this.fillInActionsForCurrentContext();
this.updateToolbarComponents(this._model.trustedMode);
this.detectChanges();
}
@@ -311,6 +312,7 @@ export class NotebookComponent extends AngularDisposable implements OnInit, OnDe
private async awaitNonDefaultProvider(): Promise<void> {
// Wait on registration for now. Long-term would be good to cache and refresh
await this.notebookService.registrationComplete;
this.model.standardKernels = this._notebookParams.input.standardKernels;
// Refresh the provider if we had been using default
let providerInfo = await this._notebookParams.providerInfo;

View File

@@ -13,6 +13,9 @@ import { NotebookEditor } from 'sql/workbench/parts/notebook/notebookEditor';
import { NewNotebookAction } from 'sql/workbench/parts/notebook/notebookActions';
import { KeyMod } from 'vs/editor/common/standalone/standaloneBase';
import { KeyCode } from 'vs/base/common/keyCodes';
import { IConfigurationRegistry, Extensions as ConfigExtensions } from 'vs/platform/configuration/common/configurationRegistry';
import { localize } from 'vs/nls';
import product from 'vs/platform/product/node/product';
import { registerComponentType } from 'sql/workbench/parts/notebook/outputs/mimeRegistry';
import { MimeRendererComponent as MimeRendererComponent } from 'sql/workbench/parts/notebook/outputs/mimeRenderer.component';
import { MarkdownOutputComponent } from 'sql/workbench/parts/notebook/outputs/markdownOutput.component';
@@ -40,6 +43,19 @@ actionRegistry.registerWorkbenchAction(
),
NewNotebookAction.LABEL
);
const configurationRegistry = <IConfigurationRegistry>Registry.as(ConfigExtensions.Configuration);
configurationRegistry.registerConfiguration({
'id': 'notebook',
'title': 'Notebook',
'type': 'object',
'properties': {
'notebook.useInProcMarkdown': {
'type': 'boolean',
'default': product.quality === 'stable' ? false : true,
'description': localize('notebook.inProcMarkdown', 'Use in-process markdown viewer to render text cells more quickly (Experimental).')
}
}
});
/* *************** Output components *************** */
// Note: most existing types use the same component to render. In order to

View File

@@ -12,6 +12,7 @@ import { DEFAULT_NOTEBOOK_PROVIDER, DEFAULT_NOTEBOOK_FILETYPE, INotebookService
import { ConnectionProfile } from 'sql/platform/connection/common/connectionProfile';
import { IOutputChannel } from 'vs/workbench/contrib/output/common/output';
import { ICellModel } from 'sql/workbench/parts/notebook/models/modelInterfaces';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
/**
@@ -142,3 +143,7 @@ export function convertVscodeResourceToFileInSubDirectories(htmlContent: string,
}
return htmlContent;
}
export function useInProcMarkdown(configurationService: IConfigurationService): boolean {
return configurationService.getValue('notebook.useInProcMarkdown');
}

View File

@@ -15,7 +15,10 @@ import { IMimeComponent } from 'sql/workbench/parts/notebook/outputs/mimeRegistr
import { INotebookService } from 'sql/workbench/services/notebook/common/notebookService';
import { MimeModel } from 'sql/workbench/parts/notebook/outputs/common/mimemodel';
import { ICellModel } from 'sql/workbench/parts/notebook/models/modelInterfaces';
import { convertVscodeResourceToFileInSubDirectories } from 'sql/workbench/parts/notebook/notebookUtils';
import { convertVscodeResourceToFileInSubDirectories, useInProcMarkdown } from 'sql/workbench/parts/notebook/notebookUtils';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { NotebookMarkdownRenderer } from 'sql/workbench/parts/notebook/outputs/notebookMarkdown';
@Component({
selector: MarkdownOutputComponent.SELECTOR,
@@ -33,15 +36,19 @@ export class MarkdownOutputComponent extends AngularDisposable implements IMimeC
private _initialized: boolean = false;
public loading: boolean = false;
private _cellModel: ICellModel;
private _markdownRenderer: NotebookMarkdownRenderer;
constructor(
@Inject(forwardRef(() => ChangeDetectorRef)) private _changeRef: ChangeDetectorRef,
@Inject(ICommandService) private _commandService: ICommandService,
@Inject(INotebookService) private _notebookService: INotebookService
@Inject(INotebookService) private _notebookService: INotebookService,
@Inject(IConfigurationService) private _configurationService: IConfigurationService,
@Inject(IInstantiationService) private _instantiationService: IInstantiationService
) {
super();
this._sanitizer = this._notebookService.getMimeRegistry().sanitizer;
this._markdownRenderer = this._instantiationService.createInstance(NotebookMarkdownRenderer);
}
@Input() set bundleOptions(value: MimeModel.IOptions) {
@@ -95,16 +102,26 @@ export class MarkdownOutputComponent extends AngularDisposable implements IMimeC
if (trustedChanged || !this._initialized) {
this._lastTrustedMode = this.isTrusted;
let content = this._bundleOptions.data['text/markdown'];
if (!content) {
} else {
this._commandService.executeCommand<string>('notebook.showPreview', this._cellModel.notebookModel.notebookUri, content).then((htmlcontent) => {
htmlcontent = convertVscodeResourceToFileInSubDirectories(htmlcontent, this._cellModel);
htmlcontent = this.sanitizeContent(htmlcontent);
let outputElement = <HTMLElement>this.output.nativeElement;
outputElement.innerHTML = htmlcontent;
this.setLoading(false);
if (useInProcMarkdown(this._configurationService)) {
this._markdownRenderer.setNotebookURI(this.cellModel.notebookModel.notebookUri);
let markdownResult = this._markdownRenderer.render({
isTrusted: this.cellModel.trustedMode,
value: content.toString()
});
let outputElement = <HTMLElement>this.output.nativeElement;
outputElement.innerHTML = markdownResult.element.innerHTML;
} else {
if (!content) {
} else {
this._commandService.executeCommand<string>('notebook.showPreview', this._cellModel.notebookModel.notebookUri, content).then((htmlcontent) => {
htmlcontent = convertVscodeResourceToFileInSubDirectories(htmlcontent, this._cellModel);
htmlcontent = this.sanitizeContent(htmlcontent);
let outputElement = <HTMLElement>this.output.nativeElement;
outputElement.innerHTML = htmlcontent;
this.setLoading(false);
});
}
}
this._initialized = true;
}

View File

@@ -0,0 +1,240 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the Source EULA. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as path from 'path';
import { URI } from 'vs/base/common/uri';
import { dispose } from 'vs/base/common/lifecycle';
import { RenderOptions } from 'vs/base/browser/htmlContentRenderer';
import { IMarkdownString, removeMarkdownEscapes } from 'vs/base/common/htmlContent';
import { IMarkdownRenderResult } from 'vs/editor/contrib/markdown/markdownRenderer';
import marked = require('vs/base/common/marked/marked');
import { defaultGenerator } from 'vs/base/common/idGenerator';
import { revive } from 'vs/base/common/marshalling';
import * as fs from 'fs';
// Based off of HtmlContentRenderer
export class NotebookMarkdownRenderer {
private _notebookURI: URI;
private _baseUrls: string[] = [];
constructor() {
}
render(markdown: IMarkdownString): IMarkdownRenderResult {
const element: HTMLElement = markdown ? this.renderMarkdown(markdown, undefined) : document.createElement('span');
return {
element,
dispose: () => dispose()
};
}
createElement(options: RenderOptions): HTMLElement {
const tagName = options.inline ? 'span' : 'div';
const element = document.createElement(tagName);
if (options.className) {
element.className = options.className;
}
return element;
}
parse(text: string): any {
let data = JSON.parse(text);
data = revive(data, 0);
return data;
}
/**
* Create html nodes for the given content element.
* Adapted from htmlContentRenderer. Ensures that the markdown renderer
* gets passed in the correct baseUrl for the notebook's saved location,
* respects the trusted state of a notebook, and allows command links to
* be clickable.
*/
renderMarkdown(markdown: IMarkdownString, options: RenderOptions = {}): HTMLElement {
const element = this.createElement(options);
// signal to code-block render that the element has been created
let signalInnerHTML: () => void;
const withInnerHTML = new Promise(c => signalInnerHTML = c);
let notebookFolder = path.dirname(this._notebookURI.path) + '/';
if (!this._baseUrls.includes(notebookFolder)) {
this._baseUrls.push(notebookFolder);
}
const renderer = new marked.Renderer({ baseUrl: notebookFolder });
renderer.image = (href: string, title: string, text: string) => {
href = this.cleanUrl(!markdown.isTrusted, notebookFolder, href);
let dimensions: string[] = [];
if (href) {
const splitted = href.split('|').map(s => s.trim());
href = splitted[0];
const parameters = splitted[1];
if (parameters) {
const heightFromParams = /height=(\d+)/.exec(parameters);
const widthFromParams = /width=(\d+)/.exec(parameters);
const height = heightFromParams ? heightFromParams[1] : '';
const width = widthFromParams ? widthFromParams[1] : '';
const widthIsFinite = isFinite(parseInt(width));
const heightIsFinite = isFinite(parseInt(height));
if (widthIsFinite) {
dimensions.push(`width="${width}"`);
}
if (heightIsFinite) {
dimensions.push(`height="${height}"`);
}
}
}
let attributes: string[] = [];
if (href) {
attributes.push(`src="${href}"`);
}
if (text) {
attributes.push(`alt="${text}"`);
}
if (title) {
attributes.push(`title="${title}"`);
}
if (dimensions.length) {
attributes = attributes.concat(dimensions);
}
return '<img ' + attributes.join(' ') + '>';
};
renderer.link = (href: string, title: string, text: string): string => {
href = this.cleanUrl(!markdown.isTrusted, notebookFolder, href);
if (href === null) {
return text;
}
// Remove markdown escapes. Workaround for https://github.com/chjj/marked/issues/829
if (href === text) { // raw link case
text = removeMarkdownEscapes(text);
}
title = removeMarkdownEscapes(title);
href = removeMarkdownEscapes(href);
if (
!href
|| !markdown.isTrusted
|| href.match(/^data:|javascript:/i)
|| href.match(/^command:(\/\/\/)?_workbench\.downloadResource/i)
) {
// drop the link
return text;
} else {
// HTML Encode href
href = href.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
return `<a href=${href} data-href="${href}" title="${title || href}">${text}</a>`;
}
};
renderer.paragraph = (text): string => {
return `<p>${text}</p>`;
};
if (options.codeBlockRenderer) {
renderer.code = (code, lang) => {
const value = options.codeBlockRenderer!(lang, code);
// when code-block rendering is async we return sync
// but update the node with the real result later.
const id = defaultGenerator.nextId();
const promise = value.then(strValue => {
withInnerHTML.then(e => {
const span = element.querySelector(`div[data-code="${id}"]`);
if (span) {
span.innerHTML = strValue;
}
}).catch(err => {
// ignore
});
});
if (options.codeBlockRenderCallback) {
promise.then(options.codeBlockRenderCallback);
}
return `<div class="code" data-code="${id}">${escape(code)}</div>`;
};
}
const markedOptions: marked.MarkedOptions = {
sanitize: !markdown.isTrusted,
renderer,
baseUrl: notebookFolder
};
element.innerHTML = marked.parse(markdown.value, markedOptions);
signalInnerHTML!();
return element;
}
// This following methods have been adapted from marked.js
// Copyright (c) 2011-2014, Christopher Jeffrey (https://github.com/chjj/)
cleanUrl(sanitize: boolean, base: string, href: string) {
if (sanitize) {
let prot: string;
try {
prot = decodeURIComponent(unescape(href))
.replace(/[^\w:]/g, '')
.toLowerCase();
} catch (e) {
return null;
}
if (prot.indexOf('javascript:') === 0 || prot.indexOf('vbscript:') === 0 || prot.indexOf('data:') === 0) {
return null;
}
}
try {
if (URI.parse(href)) {
return href;
}
} catch {
// ignore
}
let originIndependentUrl = /^$|^[a-z][a-z0-9+.-]*:|^[?#]/i;
if (base && !originIndependentUrl.test(href) && !fs.existsSync(href)) {
href = this.resolveUrl(base, href);
}
try {
href = encodeURI(href).replace(/%25/g, '%');
} catch (e) {
return null;
}
return href;
}
resolveUrl(base: string, href: string) {
if (!this._baseUrls[' ' + base]) {
// we can ignore everything in base after the last slash of its path component,
// but we might need to add _that_
// https://tools.ietf.org/html/rfc3986#section-3
if (/^[^:]+:\/*[^/]*$/.test(base)) {
this._baseUrls[' ' + base] = base + '/';
} else {
// Remove trailing 'c's. /c*$/ is vulnerable to REDOS.
this._baseUrls[' ' + base] = base.replace(/c*$/, '');
}
}
base = this._baseUrls[' ' + base];
if (href.slice(0, 2) === '//') {
return base.replace(/:[\s\S]*/, ':') + href;
} else if (href.charAt(0) === '/') {
return base.replace(/(:\/*[^/]*)[\s\S]*/, '$1') + href;
} else {
return base + href;
}
}
// end marked.js adaptation
setNotebookURI(val: URI) {
this._notebookURI = val;
}
}