Basic markdown renderer (#197)

* Add basic markdown it rendering
* Add markdown preview
* Add embedings for vimeo, youtube, gist
* Add support for legacy shortcodes and link detection
* Set "both" as editor default
* Add markdown-it-task-lists
* Add twemoji
* Changed SlideShare short-code behaviour from embedding to generating a link
* Extract markdown it parser debugger into separate component
* Deactivate markdown it linkify for now
* Add link safety attributes
* Add one-click-embedding component and use it
* Added embedding changes and deprecations to CHANGELOG.md

Signed-off-by: Tilman Vatteroth <tilman.vatteroth@tu-dortmund.de>
Co-authored-by: Philip Molares <philip@mauricedoepke.de>
Co-authored-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
mrdrogdrog 2020-06-20 00:44:18 +02:00 committed by GitHub
parent 8ba2be7c70
commit 7189a63618
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 637 additions and 16 deletions

View file

@ -11,18 +11,22 @@ import 'codemirror/addon/search/match-highlighter'
import 'codemirror/addon/selection/active-line'
import 'codemirror/keymap/sublime.js'
import 'codemirror/mode/gfm/gfm.js'
import React, { useState } from 'react'
import React from 'react'
import { Controlled as ControlledCodeMirror } from 'react-codemirror2'
import { useTranslation } from 'react-i18next'
import './editor-window.scss'
const EditorWindow: React.FC = () => {
export interface EditorWindowProps {
onContentChange: (content: string) => void
content: string
}
const EditorWindow: React.FC<EditorWindowProps> = ({ onContentChange, content }) => {
const { t } = useTranslation()
const [content, setContent] = useState<string>('')
return (
<ControlledCodeMirror
className="h-100 w-100"
className="h-100 w-100 flex-fill"
value={content}
options={{
mode: 'gfm',
@ -58,10 +62,7 @@ const EditorWindow: React.FC = () => {
}
}
onBeforeChange={(editor, data, value) => {
setContent(value)
}}
onChange={(editor, data, value) => {
console.log('change!')
onContentChange(value)
}}
/>
)

View file

@ -12,6 +12,7 @@ import { TaskBar } from './task-bar/task-bar'
const Editor: React.FC = () => {
const editorMode: EditorMode = useSelector((state: ApplicationState) => state.editorConfig.editorMode)
const [markdownContent, setMarkdownContent] = useState('# Embedding demo\n\n## Slideshare\n{%slideshare mazlan1/internet-of-things-the-tip-of-an-iceberg %}\n\n## Gist\nhttps://gist.github.com/schacon/1\n\n## YouTube\nhttps://www.youtube.com/watch?v=KgMpKsp23yY\n\n## Vimeo\nhttps://vimeo.com/23237102')
const isWide = useMedia({ minWidth: 576 })
const [firstDraw, setFirstDraw] = useState(true)
@ -32,9 +33,9 @@ const Editor: React.FC = () => {
<TaskBar/>
<Splitter
showLeft={editorMode === EditorMode.EDITOR || editorMode === EditorMode.BOTH}
left={<EditorWindow/>}
left={<EditorWindow onContentChange={content => setMarkdownContent(content)} content={markdownContent}/>}
showRight={editorMode === EditorMode.PREVIEW || (editorMode === EditorMode.BOTH)}
right={<MarkdownPreview/>}
right={<MarkdownPreview content={markdownContent}/>}
containerClassName={'overflow-hidden'}/>
</div>
</Fragment>

View file

@ -0,0 +1,8 @@
import MarkdownIt from 'markdown-it/lib'
export const MarkdownItParserDebugger: MarkdownIt.PluginSimple = (md: MarkdownIt) => {
md.core.ruler.push('test', (state) => {
console.log(state)
return true
})
}

View file

@ -0,0 +1,6 @@
@import '../../../../node_modules/github-markdown-css/github-markdown.css';
.markdown-body {
max-width: 758px;
font-family: 'Source Sans Pro', "twemoji", sans-serif;
}

View file

@ -1,9 +1,81 @@
import React from 'react'
import MarkdownIt from 'markdown-it'
import emoji from 'markdown-it-emoji'
import markdownItRegex from 'markdown-it-regex'
import taskList from 'markdown-it-task-lists'
import React, { ReactElement, useMemo } from 'react'
import ReactHtmlParser, { convertNodeToElement, Transform } from 'react-html-parser'
import { MarkdownItParserDebugger } from './markdown-it-plugins/parser-debugger'
import './markdown-preview.scss'
import { replaceGistLink } from './regex-plugins/replace-gist-link'
import { replaceLegacyGistShortCode } from './regex-plugins/replace-legacy-gist-short-code'
import { replaceLegacySlideshareShortCode } from './regex-plugins/replace-legacy-slideshare-short-code'
import { replaceLegacySpeakerdeckShortCode } from './regex-plugins/replace-legacy-speakerdeck-short-code'
import { replaceLegacyVimeoShortCode } from './regex-plugins/replace-legacy-vimeo-short-code'
import { replaceLegacyYoutubeShortCode } from './regex-plugins/replace-legacy-youtube-short-code'
import { replaceVimeoLink } from './regex-plugins/replace-vimeo-link'
import { replaceYouTubeLink } from './regex-plugins/replace-youtube-link'
import { getGistReplacement } from './replace-components/gist/gist-frame'
import { getVimeoReplacement } from './replace-components/vimeo/vimeo-frame'
import { getYouTubeReplacement } from './replace-components/youtube/youtube-frame'
export interface MarkdownPreviewProps {
content: string
}
const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({ content }) => {
const markdownIt = useMemo(() => {
const md = new MarkdownIt('default', {
html: true,
breaks: true,
langPrefix: '',
linkify: false,
typographer: true
})
md.use(taskList)
md.use(emoji)
md.use(markdownItRegex, replaceLegacyYoutubeShortCode)
md.use(markdownItRegex, replaceLegacyVimeoShortCode)
md.use(markdownItRegex, replaceLegacyGistShortCode)
md.use(markdownItRegex, replaceLegacySlideshareShortCode)
md.use(markdownItRegex, replaceLegacySpeakerdeckShortCode)
md.use(markdownItRegex, replaceYouTubeLink)
md.use(markdownItRegex, replaceVimeoLink)
md.use(markdownItRegex, replaceGistLink)
md.use(MarkdownItParserDebugger)
return md
}, [])
const result: ReactElement[] = useMemo(() => {
const youtubeIdCounterMap = new Map<string, number>()
const vimeoIdCounterMap = new Map<string, number>()
const gistIdCounterMap = new Map<string, number>()
const html: string = markdownIt.render(content)
const transform: Transform = (node, index) => {
const resultYT = getYouTubeReplacement(node, youtubeIdCounterMap)
if (resultYT) {
return resultYT
}
const resultVimeo = getVimeoReplacement(node, vimeoIdCounterMap)
if (resultVimeo) {
return resultVimeo
}
const resultGist = getGistReplacement(node, gistIdCounterMap)
if (resultGist) {
return resultGist
}
return convertNodeToElement(node, index, transform)
}
const ret: ReactElement[] = ReactHtmlParser(html, { transform: transform })
return ret
}, [content, markdownIt])
const MarkdownPreview: React.FC = () => {
return (
<div className='h-100 px-2 py-1 bg-white'>
Hello, MarkdownPreview!
<div className={'bg-light container-fluid flex-fill h-100 overflow-y-scroll pb-5'}>
<div className={'markdown-body container-fluid'}>{result}</div>
</div>
)
}

View file

@ -0,0 +1,18 @@
import { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
const protocolRegex = /(?:http(?:s)?:\/\/)?/
const domainRegex = /(?:gist\.github\.com\/)/
const idRegex = /(\w+\/\w+)/
const tailRegex = /(?:[./?#].*)?/
const gistUrlRegex = new RegExp(`(?:${protocolRegex.source}${domainRegex.source}${idRegex.source}${tailRegex.source})`)
const linkRegex = new RegExp(`^${gistUrlRegex.source}$`, 'i')
export const replaceGistLink: RegexOptions = {
name: 'gist-link',
regex: linkRegex,
replace: (match) => {
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
// noinspection CheckTagEmptyBody
return `<codimd-gist id="${match}"></codimd-gist>`
}
}

View file

@ -0,0 +1,13 @@
import { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
const finalRegex = /^{%gist (\w+\/\w+) ?%}$/
export const replaceLegacyGistShortCode: RegexOptions = {
name: 'legacy-gist-short-code',
regex: finalRegex,
replace: (match) => {
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
// noinspection CheckTagEmptyBody
return `<codimd-gist id="${match}"></codimd-gist>`
}
}

View file

@ -0,0 +1,11 @@
import { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
const finalRegex = /^{%slideshare (\w+\/[\w-]+) ?%}$/
export const replaceLegacySlideshareShortCode: RegexOptions = {
name: 'legacy-slideshare-short-code',
regex: finalRegex,
replace: (match) => {
return `<a target="_blank" rel="noopener noreferrer" href="https://www.slideshare.net/${match}">https://www.slideshare.net/${match}</a>`
}
}

View file

@ -0,0 +1,11 @@
import { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
const finalRegex = /^{%speakerdeck (\w+\/[\w-]+) ?%}$/
export const replaceLegacySpeakerdeckShortCode: RegexOptions = {
name: 'legacy-speakerdeck-short-code',
regex: finalRegex,
replace: (match) => {
return `<a target="_blank" rel="noopener noreferrer" href="https://speakerdeck.com//${match}">https://speakerdeck.com/${match}</a>`
}
}

View file

@ -0,0 +1,11 @@
import { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
export const replaceLegacyVimeoShortCode: RegexOptions = {
name: 'legacy-vimeo-short-code',
regex: /^{%vimeo ([\d]{6,11}) ?%}$/,
replace: (match) => {
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
// noinspection CheckTagEmptyBody
return `<codimd-vimeo id="${match}"></codimd-vimeo>`
}
}

View file

@ -0,0 +1,11 @@
import { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
export const replaceLegacyYoutubeShortCode: RegexOptions = {
name: 'legacy-youtube-short-code',
regex: /^{%youtube ([^"&?\\/\s]{11}) ?%}$/,
replace: (match) => {
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
// noinspection CheckTagEmptyBody
return `<codimd-youtube id="${match}"></codimd-youtube>`
}
}

View file

@ -0,0 +1,18 @@
import { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
const protocolRegex = /(?:http(?:s)?:\/\/)?/
const domainRegex = /(?:player\.)?(?:vimeo\.com\/)(?:(?:channels|album|ondemand|groups)\/\w+\/)?(?:video\/)?/
const idRegex = /([\d]{6,11})/
const tailRegex = /(?:[?#].*)?/
const vimeoVideoUrlRegex = new RegExp(`(?:${protocolRegex.source}${domainRegex.source}${idRegex.source}${tailRegex.source})`)
const linkRegex = new RegExp(`^${vimeoVideoUrlRegex.source}$`, 'i')
export const replaceVimeoLink: RegexOptions = {
name: 'vimeo-link',
regex: linkRegex,
replace: (match) => {
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
// noinspection CheckTagEmptyBody
return `<codimd-vimeo id="${match}"></codimd-vimeo>`
}
}

View file

@ -0,0 +1,19 @@
import { RegexOptions } from '../../../../external-types/markdown-it-regex/interface'
const protocolRegex = /(?:http(?:s)?:\/\/)?/
const subdomainRegex = /(?:www.)?/
const pathRegex = /(?:youtube(?:-nocookie)?\.com\/(?:[^\\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)/
const idRegex = /([^"&?\\/\s]{11})/
const tailRegex = /(?:[?&#].*)?/
const youtubeVideoUrlRegex = new RegExp(`(?:${protocolRegex.source}${subdomainRegex.source}${pathRegex.source}${idRegex.source}${tailRegex.source})`)
const linkRegex = new RegExp(`^${youtubeVideoUrlRegex.source}$`, 'i')
export const replaceYouTubeLink: RegexOptions = {
name: 'youtube-link',
regex: linkRegex,
replace: (match) => {
// ESLint wants to collapse this tag, but then the tag won't be valid html anymore.
// noinspection CheckTagEmptyBody
return `<codimd-youtube id="${match}"></codimd-youtube>`
}
}

View file

@ -0,0 +1,5 @@
.gist-frame {
.one-click-embedding-preview {
filter: blur(3px);
}
}

View file

@ -0,0 +1,88 @@
import { DomElement } from 'domhandler'
import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react'
import { OneClickEmbedding } from '../one-click-frame/one-click-embedding'
import { getIdFromCodiMdTag } from '../video-util'
import './gist-frame.scss'
import preview from './gist-preview.png'
export interface GistFrameProps {
id: string
}
interface resizeEvent {
size: number
id: string
}
const getElementReplacement = (node: DomElement, counterMap: Map<string, number>): (ReactElement | undefined) => {
const gistId = getIdFromCodiMdTag(node, 'gist')
if (gistId) {
const count = (counterMap.get(gistId) || 0) + 1
counterMap.set(gistId, count)
return (
<OneClickEmbedding previewContainerClassName={'gist-frame'} key={`gist_${gistId}_${count}`} loadingImageUrl={preview} hoverIcon={'github'} tooltip={'click to load gist'}>
<GistFrame id={gistId}/>
</OneClickEmbedding>
)
}
}
export const GistFrame: React.FC<GistFrameProps> = ({ id }) => {
const iframeHtml = useMemo(() => {
return (`
<html lang="en">
<head>
<base target="_parent">
<title>gist</title>
<style>
* { font-size:12px; }
body{ overflow:hidden; margin: 0;}
</style>
<script type="text/javascript">
function doLoad() {
window.parent.postMessage({eventType: 'gistResize', size: document.body.scrollHeight, id: '${id}'}, '*')
tweakLinks();
}
function tweakLinks() {
document.querySelectorAll(".gist-meta > a").forEach((link) => {
link.rel="noopener noreferer"
link.target="_blank"
})
}
</script>
</head>
<body onload="doLoad()">
<script type="text/javascript" src="https://gist.github.com/${id}.js"></script>
</body>
</html>`)
}, [id])
const [frameHeight, setFrameHeight] = useState(0)
const sizeMessage = useCallback((message: MessageEvent) => {
const data = message.data as resizeEvent
if (data.id !== id) {
return
}
setFrameHeight(data.size)
}, [id])
useEffect(() => {
window.addEventListener('message', sizeMessage)
return () => {
window.removeEventListener('message', sizeMessage)
}
}, [sizeMessage])
return (
<iframe
sandbox="allow-scripts allow-top-navigation-by-user-activation allow-popups"
width='100%'
height={`${frameHeight}px`}
frameBorder='0'
title={`gist ${id}`}
src={`data:text/html;base64,${btoa(iframeHtml)}`}/>
)
}
export { getElementReplacement as getGistReplacement }

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View file

@ -0,0 +1,30 @@
.one-click-embedding {
position: relative;
cursor: pointer;
width: 100%;
overflow: hidden;
.one-click-embedding-icon {
display: inline;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
opacity: 0.4;
transition: opacity 0.2s;
text-shadow: #000000 0 0 5px;
}
&:hover > i {
opacity: 0.8;
text-shadow: #000000 0 0 10px;
}
.one-click-embedding-preview {
background: none;
height: 100%;
object-fit: contain;
}
}

View file

@ -0,0 +1,49 @@
import React, { useEffect, useState } from 'react'
import { IconName } from '../../../../common/fork-awesome/fork-awesome-icon'
import { ShowIf } from '../../../../common/show-if/show-if'
import './one-click-embedding.scss'
interface OneClickFrameProps {
onImageFetch?: () => Promise<string>
loadingImageUrl: string
hoverIcon?: IconName
tooltip?: string
containerClassName?: string
previewContainerClassName?: string
}
export const OneClickEmbedding: React.FC<OneClickFrameProps> = ({ previewContainerClassName, containerClassName, onImageFetch, loadingImageUrl, children, tooltip, hoverIcon }) => {
const [showFrame, setShowFrame] = useState(false)
const [previewImageLink, setPreviewImageLink] = useState<string>(loadingImageUrl)
const showChildren = () => {
setShowFrame(true)
}
useEffect(() => {
if (!onImageFetch) {
return
}
onImageFetch().then((imageLink) => {
setPreviewImageLink(imageLink)
}).catch((message) => {
console.error(message)
})
}, [onImageFetch])
return (
<span className={ containerClassName }>
<ShowIf condition={showFrame}>
{children}
</ShowIf>
<ShowIf condition={!showFrame}>
<span className={`one-click-embedding ${previewContainerClassName || ''}`} onClick={showChildren}>
<img className={'one-click-embedding-preview'} src={previewImageLink} alt={tooltip || ''} title={tooltip || ''}/>
<ShowIf condition={!!hoverIcon}>
<i className={`one-click-embedding-icon fa fa-${hoverIcon as string} fa-5x`}/>
</ShowIf>
</span>
</ShowIf>
</span>
)
}

View file

@ -0,0 +1,12 @@
import { DomElement } from 'domhandler'
export const getIdFromCodiMdTag = (node: DomElement, tagName: string): (string | undefined) => {
if (node.name !== `codimd-${tagName}` || !node.attribs || !node.attribs.id) {
return
}
return node.attribs.id
}
export interface VideoFrameProps {
id: string
}

View file

@ -0,0 +1,49 @@
import { DomElement } from 'domhandler'
import React, { ReactElement, useCallback } from 'react'
import { OneClickEmbedding } from '../one-click-frame/one-click-embedding'
import { getIdFromCodiMdTag, VideoFrameProps } from '../video-util'
const getElementReplacement = (node: DomElement, counterMap: Map<string, number>): (ReactElement | undefined) => {
const videoId = getIdFromCodiMdTag(node, 'vimeo')
if (videoId) {
const count = (counterMap.get(videoId) || 0) + 1
counterMap.set(videoId, count)
return <VimeoFrame key={`vimeo_${videoId}_${count}`} id={videoId}/>
}
}
interface VimeoApiResponse {
// Vimeo uses strange names for their fields. ESLint doesn't like that.
// eslint-disable-next-line camelcase
thumbnail_large?: string
}
export const VimeoFrame: React.FC<VideoFrameProps> = ({ id }) => {
const getPreviewImageLink = useCallback(async () => {
const response = await fetch(`https://vimeo.com/api/v2/video/${id}.json`, {
credentials: 'omit',
referrerPolicy: 'no-referrer'
})
if (response.status !== 200) {
throw new Error('Error while loading data from vimeo api')
}
const vimeoResponse: VimeoApiResponse[] = await response.json() as VimeoApiResponse[]
if (vimeoResponse[0] && vimeoResponse[0].thumbnail_large) {
return vimeoResponse[0].thumbnail_large
} else {
throw new Error('Invalid vimeo response')
}
}, [id])
return (
<OneClickEmbedding containerClassName={'embed-responsive embed-responsive-16by9'} previewContainerClassName={'embed-responsive-item'} loadingImageUrl={'https://i.vimeocdn.com/video/'} hoverIcon={'vimeo-square'}
onImageFetch={getPreviewImageLink}>
<iframe className='embed-responsive-item' title={`vimeo video of ${id}`}
src={`https://player.vimeo.com/video/${id}?autoplay=1`}
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"/>
</OneClickEmbedding>
)
}
export { getElementReplacement as getVimeoReplacement }

View file

@ -0,0 +1,25 @@
import { DomElement } from 'domhandler'
import React, { ReactElement } from 'react'
import { OneClickEmbedding } from '../one-click-frame/one-click-embedding'
import { getIdFromCodiMdTag, VideoFrameProps } from '../video-util'
const getElementReplacement = (node: DomElement, counterMap: Map<string, number>): (ReactElement | undefined) => {
const videoId = getIdFromCodiMdTag(node, 'youtube')
if (videoId) {
const count = (counterMap.get(videoId) || 0) + 1
counterMap.set(videoId, count)
return <YouTubeFrame key={`youtube_${videoId}_${count}`} id={videoId}/>
}
}
export const YouTubeFrame: React.FC<VideoFrameProps> = ({ id }) => {
return (
<OneClickEmbedding containerClassName={'embed-responsive embed-responsive-16by9'} previewContainerClassName={'embed-responsive-item'} hoverIcon={'youtube-play'} loadingImageUrl={`//i.ytimg.com/vi/${id}/maxresdefault.jpg`}>
<iframe className='embed-responsive-item' title={`youtube video of ${id}`}
src={`//www.youtube-nocookie.com/embed/${id}?autoplay=1`}
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"/>
</OneClickEmbedding>
)
}
export { getElementReplacement as getYouTubeReplacement }

View file

@ -0,0 +1,6 @@
declare module 'markdown-it-emoji' {
import MarkdownIt from 'markdown-it/lib'
const markdownItEmoji: MarkdownIt.PluginSimple
export = markdownItEmoji
}

View file

@ -0,0 +1,6 @@
declare module 'markdown-it-regex' {
import MarkdownIt from 'markdown-it/lib'
import { RegexOptions } from './interface'
const markdownItRegex: MarkdownIt.PluginWithOptions<RegexOptions>
export = markdownItRegex
}

View file

@ -0,0 +1,5 @@
export interface RegexOptions {
name: string,
regex: RegExp,
replace: (match: string) => string
}

View file

@ -0,0 +1,6 @@
declare module 'markdown-it-task-lists' {
import MarkdownIt from 'markdown-it/lib'
const markdownItTaskLists: MarkdownIt.PluginSimple
export = markdownItTaskLists
}

Binary file not shown.

View file

@ -31,3 +31,12 @@ body {
.mvh-100 {
min-height: 100vh;
}
.overflow-y-scroll {
overflow-y: scroll;
}
@font-face {
font-family: "twemoji";
src: url("TwemojiMozilla.ttf") format("truetype");
}

View file

@ -3,7 +3,7 @@ import { EditorMode } from '../../components/editor/task-bar/editor-view-mode'
import { EditorConfig, EditorConfigActions, EditorConfigActionType, SetEditorConfigAction } from './types'
export const initialState: EditorConfig = {
editorMode: EditorMode.EDITOR
editorMode: EditorMode.BOTH
}
export const EditorConfigReducer: Reducer<EditorConfig, EditorConfigActions> = (state: EditorConfig = initialState, action: EditorConfigActions) => {