From db2e16c4620e0356a2b8160b587694ea67026b35 Mon Sep 17 00:00:00 2001 From: Junyoung Choi Date: Mon, 23 Sep 2019 18:00:54 +0900 Subject: [PATCH] Introduce MarkdownPreviewer --- src/components/atoms/MarkdownPreviewer.tsx | 163 +++++++++++++++++++++ src/lib/CodeMirror.ts | 26 +++- typings/unified.d.ts | 13 +- 3 files changed, 190 insertions(+), 12 deletions(-) create mode 100644 src/components/atoms/MarkdownPreviewer.tsx diff --git a/src/components/atoms/MarkdownPreviewer.tsx b/src/components/atoms/MarkdownPreviewer.tsx new file mode 100644 index 0000000000..41801081ba --- /dev/null +++ b/src/components/atoms/MarkdownPreviewer.tsx @@ -0,0 +1,163 @@ +import React, { useEffect } from 'react' +import unified from 'unified' +import remarkParse from 'remark-parse' +import remarkRehype from 'remark-rehype' +import rehypeRaw from 'rehype-raw' +import rehypeSanitize from 'rehype-sanitize' +import rehypeReact from 'rehype-react' +import gh from 'hast-util-sanitize/lib/github.json' +import { mergeDeepRight } from 'ramda' +import visit from 'unist-util-visit' +import { Node, Parent } from 'unist' +import CodeMirror from '../../lib/CodeMirror' +import toText from 'hast-util-to-text' +import h from 'hastscript' +import useForceUpdate from 'use-force-update' + +const schema = mergeDeepRight(gh, { attributes: { '*': ['className'] } }) + +interface Element extends Node { + type: 'element' + properties: { [key: string]: any } +} + +function getMime(name: string) { + const modeInfo = CodeMirror.findModeByName(name) + if (modeInfo == null) return null + return modeInfo.mime || modeInfo.mimes![0] +} + +const markdownProcessor = unified() + .use(remarkParse) + .use(remarkRehype, { allowDangerousHTML: false }) + .use((options: any) => { + const settings = options || {} + const detect = settings.subset !== false + const ignoreMissing = settings.ignoreMissing + const plainText = settings.plainText || [] + return function(tree: Parent) { + visit(tree, 'element', visitor) + + function visitor(node: Element, _index: number, parent: Node) { + const props = node.properties + let result + + if (!parent || parent.tagName !== 'pre' || node.tagName !== 'code') { + return + } + + const lang = language(node) + + if ( + lang === false || + (!lang && !detect) || + plainText.indexOf(lang) !== -1 + ) { + return + } + + if (!props.className) { + props.className = [] + } + + if (props.className.indexOf(name) === -1) { + props.className.unshift(name) + } + + try { + const text = toText(parent) + const cmResult = [] as Node[] + if (lang != null) { + const mime = getMime(lang) + if (mime != null) { + CodeMirror.runMode(text, mime, (text, style) => { + cmResult.push( + h( + 'span', + { + className: style + ? 'cm-' + style.replace(/ +/g, ' cm-') + : undefined + }, + text + ) + ) + }) + } + } + result = { + language: lang, + value: cmResult + } + } catch (error) { + if ( + error && + ignoreMissing && + /Unknown language/.test(error.message) + ) { + return + } + + throw error + } + + props.className.push('cm-s-default') + if (!lang && result.language) { + props.className.push('language-' + result.language) + } + + node.children = result.value + } + + // Get the programming language of `node`. + function language(node: Element) { + const className = node.properties.className || [] + const length = className.length + let index = -1 + let value + + while (++index < length) { + value = className[index] + + if (value === 'no-highlight' || value === 'nohighlight') { + return false + } + + if (value.slice(0, 5) === 'lang-') { + return value.slice(5) + } + + if (value.slice(0, 9) === 'language-') { + return value.slice(9) + } + } + + return null + } + } + }) + .use(rehypeRaw) + .use(rehypeSanitize, schema) + .use(rehypeReact, { createElement: React.createElement }) + +interface MarkdownPreviewerProps { + content: string +} + +const MarkdownPreviewer = ({ content }: MarkdownPreviewerProps) => { + const forceUpdate = useForceUpdate() + + useEffect( + () => { + window.addEventListener('codemirror-mode-load', forceUpdate) + return () => { + window.removeEventListener('codemirror-mode-load', forceUpdate) + } + }, + [forceUpdate] + ) + + return
{markdownProcessor.processSync(content).contents}
+} + +export default MarkdownPreviewer diff --git a/src/lib/CodeMirror.ts b/src/lib/CodeMirror.ts index 4d6856beb8..9279aba38e 100644 --- a/src/lib/CodeMirror.ts +++ b/src/lib/CodeMirror.ts @@ -1,6 +1,8 @@ import CodeMirror from 'codemirror' +import 'codemirror/addon/runmode/runmode' import 'codemirror/addon/mode/overlay' import 'codemirror/mode/markdown/markdown' +import debounce from 'lodash/debounce' window.CodeMirror = CodeMirror @@ -22,6 +24,23 @@ declare module 'codemirror' { } function findModeByMIME(mime: string): ModeInfo | undefined + function findModeByName(name: string): ModeInfo | undefined + + function runMode( + text: string, + modespec: any, + callback: HTMLElement | ((text: string, style: string | null) => void), + options?: { tabSize?: number; state?: any } + ): void +} + +const dispatchModeLoad = debounce(() => { + window.dispatchEvent(new CustomEvent('codemirror-mode-load')) +}, 300) + +export async function requireMode(mode: string) { + await import(`codemirror/mode/${mode}/${mode}.js`) + dispatchModeLoad() } function loadMode(_CodeMirror: any) { @@ -35,15 +54,10 @@ function loadMode(_CodeMirror: any) { return result } - async function requireMode(mode: string) { - await import(`codemirror/mode/${mode}/${mode}.js`) - window.dispatchEvent(new CustomEvent('codemirror-mode-load')) - } - const originalGetMode = CodeMirror.getMode _CodeMirror.getMode = (config: CodeMirror.EditorConfiguration, mime: any) => { const modeObj = originalGetMode(config, mime) - if (modeObj.name === 'null') { + if (modeObj.name === 'null' && typeof mime === 'string') { const mode = findModeByMIME(mime) if (mode != null) { requireMode(mode.mode) diff --git a/typings/unified.d.ts b/typings/unified.d.ts index c466c59460..c4164b0d64 100644 --- a/typings/unified.d.ts +++ b/typings/unified.d.ts @@ -1,6 +1,7 @@ -declare module 'unified' -declare module 'unified-*' -declare module 'unist-util-*' -declare module 'remark' -declare module 'remark-*' -declare module 'mdast-util-*' +declare module 'remark-rehype' +declare module 'rehype-raw' +declare module 'rehype-sanitize' +declare module 'rehype-react' +declare module 'hast-util-sanitize/lib/github.json' +declare module 'hast-util-to-text' +declare module 'hastscript'