Skip to content

Refactor solution webview to reuse markdown engine #224

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Mar 21, 2019
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 29 additions & 50 deletions src/leetCodeSolutionProvider.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,31 @@
// Copyright (c) jdneo. All rights reserved.
// Licensed under the MIT license.

import * as hljs from "highlight.js";
import * as MarkdownIt from "markdown-it";
import * as path from "path";
import * as vscode from "vscode";
import { TokenRender } from "markdown-it";
import { Disposable, ExtensionContext, ViewColumn, WebviewPanel, window } from "vscode";
import { leetCodeChannel } from "./leetCodeChannel";
import { IProblem } from "./shared";
import { MarkdownEngine } from "./webview/markdownEngine";

class LeetCodeSolutionProvider implements Disposable {

private context: ExtensionContext;
private panel: WebviewPanel | undefined;
private markdown: MarkdownIt;
private markdownPath: string; // path of vscode built-in markdown extension
private markdown: MarkdownEngine;
private solution: Solution;

public initialize(context: ExtensionContext): void {
this.context = context;
this.markdown = new MarkdownIt({
linkify: true,
typographer: true,
highlight: this.codeHighlighter.bind(this),
});
this.markdownPath = path.join(vscode.env.appRoot, "extensions", "markdown-language-features");

// Override code_block rule for highlighting in solution language
// tslint:disable-next-line:typedef
this.markdown.renderer.rules["code_block"] = (tokens, idx, options, _, self) => {
const highlight: string = options.highlight(tokens[idx].content, undefined);
return [
`<pre><code ${self.renderAttrs(tokens[idx])} >`,
highlight || this.markdown.utils.escapeHtml(tokens[idx].content),
"</code></pre>",
].join("\n");
};
this.markdown = new MarkdownEngine();
this.addMdDefaultHighlight(); // use solution language if highting block lang is undefined
this.addMdImageUrlCompletion(); // complete the image path url with leetcode hostname
}

public async show(solutionString: string, problem: IProblem): Promise<void> {
if (!this.panel) {
this.panel = window.createWebviewPanel("leetCode.solution", "Top Voted Solution", ViewColumn.Active, {
retainContextWhenHidden: true,
enableFindWidget: true,
localResourceRoots: [vscode.Uri.file(path.join(this.markdownPath, "media"))],
localResourceRoots: this.markdown.localResourceRoots,
});

this.panel.onDidDispose(() => {
Expand All @@ -63,6 +45,27 @@ class LeetCodeSolutionProvider implements Disposable {
}
}

private addMdDefaultHighlight(): void {
// The @types typedef of `highlight` is wrong, which should return a string.
// tslint:disable-next-line:typedef
const highlight = this.markdown.options.highlight as (code: string, lang?: string) => string;
this.markdown.options.highlight = (code: string, lang?: string): string => {
return highlight(code, lang || this.solution.lang);
};
}

private addMdImageUrlCompletion(): void {
const image: TokenRender = this.markdown.engine.renderer.rules["image"];
// tslint:disable-next-line:typedef
this.markdown.engine.renderer.rules["image"] = (tokens, idx, options, env, self) => {
const imageSrc: string[] | undefined = tokens[idx].attrs.find((value: string[]) => value[0] === "src");
if (imageSrc && imageSrc[1].startsWith("/")) {
imageSrc[1] = `https://discuss.leetcode.com${imageSrc[1]}`;
}
return image(tokens, idx, options, env, self);
};
}

private parseSolution(raw: string): Solution {
const solution: Solution = new Solution();
// [^] matches everything including \n, yet can be replaced by . in ES2018's `m` flag
Expand All @@ -76,32 +79,8 @@ class LeetCodeSolutionProvider implements Disposable {
return solution;
}

private codeHighlighter(code: string, lang: string | undefined): string {
if (!lang) {
lang = this.solution.lang;
}
if (hljs.getLanguage(lang)) {
try {
return hljs.highlight(lang, code, true).value;
} catch (error) { /* do not highlight */ }
}
return ""; // use external default escaping
}

private getMarkdownStyles(): vscode.Uri[] {
try {
const stylePaths: string[] = require(path.join(this.markdownPath, "package.json"))["contributes"]["markdown.previewStyles"];
return stylePaths.map((p: string) => vscode.Uri.file(path.join(this.markdownPath, p)).with({ scheme: "vscode-resource" }));
} catch (error) {
leetCodeChannel.appendLine("[Error] Fail to load built-in markdown style file.");
return [];
}
}

private getWebViewContent(solution: Solution): string {
const styles: string = this.getMarkdownStyles()
.map((style: vscode.Uri) => `<link rel="stylesheet" type="text/css" href="${style.toString()}">`)
.join("\n");
const styles: string = this.markdown.getStylesHTML();
const { title, url, lang, author, votes } = solution;
const head: string = this.markdown.render(`# [${title}](${url})`);
const auth: string = `[${author}](https://leetcode.com/${author}/)`;
Expand Down
99 changes: 99 additions & 0 deletions src/webview/markdownEngine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import * as hljs from "highlight.js";
import * as MarkdownIt from "markdown-it";
import * as path from "path";
import * as vscode from "vscode";
import { leetCodeChannel } from "../leetCodeChannel";

export class MarkdownEngine {

public readonly engine: MarkdownIt;
public readonly extRoot: string; // root path of vscode built-in markdown extension

public constructor() {
this.engine = this.initEngine();
this.extRoot = path.join(vscode.env.appRoot, "extensions", "markdown-language-features");
}

public get localResourceRoots(): vscode.Uri[] {
return [vscode.Uri.file(path.join(this.extRoot, "media"))];
}

public get styles(): vscode.Uri[] {
try {
const stylePaths: string[] = require(path.join(this.extRoot, "package.json"))["contributes"]["markdown.previewStyles"];
return stylePaths.map((p: string) => vscode.Uri.file(path.join(this.extRoot, p)).with({ scheme: "vscode-resource" }));
} catch (error) {
leetCodeChannel.appendLine("[Error] Fail to load built-in markdown style file.");
return [];
}
}

public get options(): MarkdownIt.Options {
return (this.engine as any).options;
}

public getStylesHTML(): string {
return this.styles.map((style: vscode.Uri) => `<link rel="stylesheet" type="text/css" href="${style.toString()}">`).join("\n");
}

public render(md: string, env?: any): string {
return this.engine.render(md, env);
}

private initEngine(): MarkdownIt {
const md: MarkdownIt = new MarkdownIt({
linkify: true,
typographer: true,
highlight: (code: string, lang?: string): string => {
if (lang && ["tsx", "typescriptreact"].indexOf(lang.toLocaleLowerCase()) >= 0) {
lang = "jsx";
}
if (lang && lang.toLocaleLowerCase() === "python3") {
lang = "python";
}
if (lang && lang.toLocaleLowerCase() === "c#") {
lang = "cs";
}
if (lang && lang.toLocaleLowerCase() === "json5") {
lang = "json";
}
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(lang, code, true).value;
} catch (error) { /* do not highlight */ }
}
return ""; // use external default escaping
},
});

this.addCodeBlockHighlight(md);
this.addLinkValidator(md);
return md;
}

private addCodeBlockHighlight(md: MarkdownIt): void {
const origin: MarkdownIt.TokenRender = md.renderer.rules["code_block"];
// tslint:disable-next-line:typedef
md.renderer.rules["code_block"] = (tokens, idx, options, env, self) => {
// if any token uses lang-specified code fence, then do not highlight code block
if (tokens.some((token: any) => token.type === "fence")) {
return origin(tokens, idx, options, env, self);
}
// otherwise, highlight with undefined lang, which could be handled in outside logic.
const highlighted: string = options.highlight(tokens[idx].content, undefined);
return [
`<pre><code ${self.renderAttrs(tokens[idx])} >`,
highlighted || md.utils.escapeHtml(tokens[idx].content),
"</code></pre>",
].join("\n");
};
}

private addLinkValidator(md: MarkdownIt): void {
const validateLink: (link: string) => boolean = md.validateLink;
md.validateLink = (link: string): boolean => {
// support file:// protocal link
return validateLink(link) || link.startsWith("file:");
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you explain more why we need the validator here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the routine adopted by built-in markdown exntesion's MarkdownEngine, I think it may enables us to validate local file link, which may be useful in local debugger.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok... Actually still not quite understand... I think it's a worth investigating topic. It's fine to leave it here in this PR...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Document from markdown-it:

MarkdownIt#validateLink(url)Boolean

Link validation function. CommonMark allows too much in links. By default we disable javascript:, vbscript:, file: schemas, and almost all data:... schemas except some embedded image types.

You can change this behaviour:

var md = require('markdown-it')();
// enable everything
md.validateLink = function () { return true; }

Since we will deal with local files in WebView, chances are that it will be helpful to enable file: link.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked into the source code, and found that it was made to prevent XSS attack. Here are some examples:

  • When parsing image and link:
    if (res.ok) {
      href = state.md.normalizeLink(res.str);
      if (state.md.validateLink(href)) {
        pos = res.pos;
      } else {
        href = '';
      }
    }

If validateLink does not pass, then href is screened out.

  • The same goes on for reference-link or auto link:
  href = state.md.normalizeLink(res.str);
  if (!state.md.validateLink(href)) { return false; }

So, it is indeed necessary to loose the restriction to allow file:// protocol.

};
}
}