diff --git a/src/leetCodeSolutionProvider.ts b/src/leetCodeSolutionProvider.ts index ddf32163..f78abc43 100644 --- a/src/leetCodeSolutionProvider.ts +++ b/src/leetCodeSolutionProvider.ts @@ -1,41 +1,20 @@ // 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 { 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 mdEngine: 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 [ - `
`,
-                highlight || this.markdown.utils.escapeHtml(tokens[idx].content),
-                "
", - ].join("\n"); - }; + this.mdEngine = new MarkdownEngine(); } public async show(solutionString: string, problem: IProblem): Promise { @@ -43,7 +22,7 @@ class LeetCodeSolutionProvider implements Disposable { 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.mdEngine.localResourceRoots, }); this.panel.onDidDispose(() => { @@ -76,41 +55,20 @@ 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) => ``) - .join("\n"); + const styles: string = this.mdEngine.getStylesHTML(); const { title, url, lang, author, votes } = solution; - const head: string = this.markdown.render(`# [${title}](${url})`); + const head: string = this.mdEngine.render(`# [${title}](${url})`); const auth: string = `[${author}](https://leetcode.com/${author}/)`; - const info: string = this.markdown.render([ + const info: string = this.mdEngine.render([ `| Language | Author | Votes |`, `| :------: | :------: | :------: |`, `| ${lang} | ${auth} | ${votes} |`, ].join("\n")); - const body: string = this.markdown.render(solution.body); + const body: string = this.mdEngine.render(solution.body, { + lang: this.solution.lang, + host: "https://discuss.leetcode.com/", + }); return ` diff --git a/src/webview/MarkdownEngine.ts b/src/webview/MarkdownEngine.ts new file mode 100644 index 00000000..bfff64d8 --- /dev/null +++ b/src/webview/MarkdownEngine.ts @@ -0,0 +1,105 @@ +import * as hljs from "highlight.js"; +import * as MarkdownIt from "markdown-it"; +import * as os from "os"; +import * as path from "path"; +import * as vscode from "vscode"; +import { leetCodeChannel } from "../leetCodeChannel"; + +export class MarkdownEngine { + + private readonly engine: MarkdownIt; + private 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 getStylesHTML(): string { + return this.styles.map((style: vscode.Uri) => ``).join(os.EOL); + } + + 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 => { + switch (lang && lang.toLowerCase()) { + case "mysql": + lang = "sql"; break; + case "json5": + lang = "json"; break; + case "python3": + lang = "python"; break; + } + 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.addImageUrlCompletion(md); + this.addLinkValidator(md); + return md; + } + + private addCodeBlockHighlight(md: MarkdownIt): void { + const codeBlock: 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 codeBlock(tokens, idx, options, env, self); + } + // otherwise, highlight with default lang in env object. + const highlighted: string = options.highlight(tokens[idx].content, env.lang); + return [ + `
`,
+                highlighted || md.utils.escapeHtml(tokens[idx].content),
+                "
", + ].join(os.EOL); + }; + } + + private addImageUrlCompletion(md: MarkdownIt): void { + const image: MarkdownIt.TokenRender = md.renderer.rules["image"]; + // tslint:disable-next-line:typedef + md.renderer.rules["image"] = (tokens, idx, options, env, self) => { + const imageSrc: string[] | undefined = tokens[idx].attrs.find((value: string[]) => value[0] === "src"); + if (env.host && imageSrc && imageSrc[1].startsWith("/")) { + imageSrc[1] = `${env.host}${imageSrc[1]}`; + } + return image(tokens, idx, options, env, self); + }; + } + + 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:"); + }; + } +}