diff --git a/browser/lib/markdown.js b/browser/lib/markdown.js index edb33261f..0d22d05ec 100644 --- a/browser/lib/markdown.js +++ b/browser/lib/markdown.js @@ -11,516 +11,526 @@ import katex from 'katex' import { escapeHtmlCharacters, lastFindInArray } from './utils' function createGutter(str, firstLineNumber) { - if (Number.isNaN(firstLineNumber)) firstLineNumber = 1 - const lastLineNumber = (str.match(/\n/g) || []).length + firstLineNumber - 1 - const lines = [] - for (let i = firstLineNumber; i <= lastLineNumber; i++) { - lines.push('' + i + '') - } - return ( - '' + lines.join('') + '' - ) +if (Number.isNaN(firstLineNumber)) firstLineNumber = 1 +const lastLineNumber = (str.match(/\n/g) || []).length + firstLineNumber - 1 +const lines = [] +for (let i = firstLineNumber; i <= lastLineNumber; i++) { +lines.push('' + i + '') +} +return ( +'' + lines.join('') + '' +) } class Markdown { - constructor(options = {}) { - const config = ConfigManager.get() - const defaultOptions = { - typographer: config.preview.smartQuotes, - linkify: true, - html: true, - xhtmlOut: true, - breaks: config.preview.breaks, - sanitize: 'STRICT', - onFence: () => {} - } +constructor(options = {}) { +const config = ConfigManager.get() +const defaultOptions = { +typographer: config.preview.smartQuotes, +linkify: true, +html: true, +xhtmlOut: true, +breaks: config.preview.breaks, +sanitize: 'STRICT', +onFence: () => {} +} - const updatedOptions = Object.assign(defaultOptions, options) - this.md = markdownit(updatedOptions) - this.md.linkify.set({ fuzzyLink: false }) - - if (updatedOptions.sanitize !== 'NONE') { - const allowedTags = [ - 'iframe', - 'input', - 'b', - 'h1', - 'h2', - 'h3', - 'h4', - 'h5', - 'h6', - 'h7', - 'h8', - 'br', - 'b', - 'i', - 'strong', - 'em', - 'a', - 'pre', - 'code', - 'img', - 'tt', - 'div', - 'ins', - 'del', - 'sup', - 'sub', - 'p', - 'ol', - 'ul', - 'table', - 'thead', - 'tbody', - 'tfoot', - 'blockquote', - 'dl', - 'dt', - 'dd', - 'kbd', - 'q', - 'samp', - 'var', - 'hr', - 'ruby', - 'rt', - 'rp', - 'li', - 'tr', - 'td', - 'th', - 's', - 'strike', - 'summary', - 'details' - ] - const allowedAttributes = [ - 'abbr', - 'accept', - 'accept-charset', - 'accesskey', - 'action', - 'align', - 'alt', - 'axis', - 'border', - 'cellpadding', - 'cellspacing', - 'char', - 'charoff', - 'charset', - 'checked', - 'clear', - 'cols', - 'colspan', - 'color', - 'compact', - 'coords', - 'datetime', - 'dir', - 'disabled', - 'enctype', - 'for', - 'frame', - 'headers', - 'height', - 'hreflang', - 'hspace', - 'ismap', - 'label', - 'lang', - 'maxlength', - 'media', - 'method', - 'multiple', - 'name', - 'nohref', - 'noshade', - 'nowrap', - 'open', - 'prompt', - 'readonly', - 'rel', - 'rev', - 'rows', - 'rowspan', - 'rules', - 'scope', - 'selected', - 'shape', - 'size', - 'span', - 'start', - 'summary', - 'tabindex', - 'target', - 'title', - 'type', - 'usemap', - 'valign', - 'value', - 'vspace', - 'width', - 'itemprop' - ] - - if (updatedOptions.sanitize === 'ALLOW_STYLES') { - allowedTags.push('style') - allowedAttributes.push('style') - } +``` +const updatedOptions = Object.assign(defaultOptions, options) +this.md = markdownit(updatedOptions) +this.md.linkify.set({ fuzzyLink: false }) + +if (updatedOptions.sanitize !== 'NONE') { + const allowedTags = [ + 'iframe', + 'input', + 'b', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'h7', + 'h8', + 'br', + 'b', + 'i', + 'strong', + 'em', + 'a', + 'pre', + 'code', + 'img', + 'tt', + 'div', + 'ins', + 'del', + 'sup', + 'sub', + 'p', + 'ol', + 'ul', + 'table', + 'thead', + 'tbody', + 'tfoot', + 'blockquote', + 'dl', + 'dt', + 'dd', + 'kbd', + 'q', + 'samp', + 'var', + 'hr', + 'ruby', + 'rt', + 'rp', + 'li', + 'tr', + 'td', + 'th', + 's', + 'strike', + 'summary', + 'details' + ] + const allowedAttributes = [ + 'abbr', + 'accept', + 'accept-charset', + 'accesskey', + 'action', + 'align', + 'alt', + 'axis', + 'border', + 'cellpadding', + 'cellspacing', + 'char', + 'charoff', + 'charset', + 'checked', + 'clear', + 'cols', + 'colspan', + 'color', + 'compact', + 'coords', + 'datetime', + 'dir', + 'disabled', + 'enctype', + 'for', + 'frame', + 'headers', + 'height', + 'hreflang', + 'hspace', + 'ismap', + 'label', + 'lang', + 'maxlength', + 'media', + 'method', + 'multiple', + 'name', + 'nohref', + 'noshade', + 'nowrap', + 'open', + 'prompt', + 'readonly', + 'rel', + 'rev', + 'rows', + 'rowspan', + 'rules', + 'scope', + 'selected', + 'shape', + 'size', + 'span', + 'start', + 'summary', + 'tabindex', + 'target', + 'title', + 'type', + 'usemap', + 'valign', + 'value', + 'vspace', + 'width', + 'itemprop' + ] + + if (updatedOptions.sanitize === 'ALLOW_STYLES') { + allowedTags.push('style') + allowedAttributes.push('style') + } - // Sanitize use rinput before other plugins - this.md.use(sanitize, { - allowedTags, - allowedAttributes: { - '*': allowedAttributes, - a: ['href'], - div: ['itemscope', 'itemtype'], - blockquote: ['cite'], - del: ['cite'], - ins: ['cite'], - q: ['cite'], - img: ['src', 'width', 'height'], - iframe: ['src', 'width', 'height', 'frameborder', 'allowfullscreen'], - input: ['type', 'id', 'checked'] - }, - allowedIframeHostnames: ['www.youtube.com'], - selfClosing: ['img', 'br', 'hr', 'input'], - allowedSchemes: ['http', 'https', 'ftp', 'mailto'], - allowedSchemesAppliedToAttributes: ['href', 'src', 'cite'], - allowProtocolRelative: true - }) - } + // Sanitize use rinput before other plugins + this.md.use(sanitize, { + allowedTags, + allowedAttributes: { + '*': allowedAttributes, + a: ['href'], + div: ['itemscope', 'itemtype'], + blockquote: ['cite'], + del: ['cite'], + ins: ['cite'], + q: ['cite'], + img: ['src', 'width', 'height'], + iframe: ['src', 'width', 'height', 'frameborder', 'allowfullscreen'], + input: ['type', 'id', 'checked'] + }, + allowedIframeHostnames: ['www.youtube.com'], + selfClosing: ['img', 'br', 'hr', 'input'], + allowedSchemes: ['http', 'https', 'ftp', 'mailto'], + allowedSchemesAppliedToAttributes: ['href', 'src', 'cite'], + allowProtocolRelative: true + }) +} - this.md.use(emoji, { - shortcuts: {} - }) - this.md.use(math, { - inlineOpen: config.preview.latexInlineOpen, - inlineClose: config.preview.latexInlineClose, - blockOpen: config.preview.latexBlockOpen, - blockClose: config.preview.latexBlockClose, - inlineRenderer: function(str) { - let output = '' - try { - output = katex.renderToString(str.trim()) - } catch (err) { - output = `${err.message}` - } - return output - }, - blockRenderer: function(str) { - let output = '' - try { - output = katex.renderToString(str.trim(), { displayMode: true }) - } catch (err) { - output = `
${err.message}
` - } - return output +this.md.use(emoji, { + shortcuts: {} +}) +this.md.use(math, { + inlineOpen: config.preview.latexInlineOpen, + inlineClose: config.preview.latexInlineClose, + blockOpen: config.preview.latexBlockOpen, + blockClose: config.preview.latexBlockClose, + inlineRenderer: function(str) { + let output = '' + try { + output = katex.renderToString(str.trim()) + } catch (err) { + output = `${err.message}` + } + return output + }, + blockRenderer: function(str) { + let output = '' + try { + output = katex.renderToString(str.trim(), { displayMode: true }) + } catch (err) { + output = `
${err.message}
` + } + return output + } +}) +this.md.use(require('markdown-it-imsize')) +this.md.use(require('markdown-it-footnote')) +this.md.use(require('markdown-it-multimd-table')) + +// Case-folding slugify for anchors +const slugify = require('./slugify') +const caseFold = str => slugify(str.toLowerCase()) + +this.md.use(require('@enyaxu/markdown-it-anchor'), { + slugify: caseFold +}) + +this.md.use(require('markdown-it-kbd')) +this.md.use(require('markdown-it-admonition'), { + types: [ + 'note', + 'hint', + 'attention', + 'caution', + 'danger', + 'error', + 'quote', + 'abstract', + 'question' + ] +}) +this.md.use(require('markdown-it-abbr')) +this.md.use(require('markdown-it-sub')) +this.md.use(require('markdown-it-sup')) + +this.md.use(md => { + markdownItTocAndAnchor(md, { + toc: true, + tocPattern: /\[TOC\]/i, + anchorLink: false, + appendIdToHeading: false, + slugify: caseFold + }) + + md.renderer.rules.toc_open = () => '
' + md.renderer.rules.toc_close = () => '
' +}) + +this.md.use(require('./markdown-it-deflist')) +this.md.use(require('./markdown-it-frontmatter')) + +this.md.use( + require('./markdown-it-fence'), + { + chart: token => { + if (token.parameters.hasOwnProperty('yaml')) { + token.parameters.format = 'yaml' } - }) - this.md.use(require('markdown-it-imsize')) - this.md.use(require('markdown-it-footnote')) - this.md.use(require('markdown-it-multimd-table')) - this.md.use(require('@enyaxu/markdown-it-anchor'), { - slugify: require('./slugify') - }) - this.md.use(require('markdown-it-kbd')) - this.md.use(require('markdown-it-admonition'), { - types: [ - 'note', - 'hint', - 'attention', - 'caution', - 'danger', - 'error', - 'quote', - 'abstract', - 'question' - ] - }) - this.md.use(require('markdown-it-abbr')) - this.md.use(require('markdown-it-sub')) - this.md.use(require('markdown-it-sup')) - - this.md.use(md => { - markdownItTocAndAnchor(md, { - toc: true, - tocPattern: /\[TOC\]/i, - anchorLink: false, - appendIdToHeading: false - }) - - md.renderer.rules.toc_open = () => '
' - md.renderer.rules.toc_close = () => '
' - }) - - this.md.use(require('./markdown-it-deflist')) - this.md.use(require('./markdown-it-frontmatter')) - - this.md.use( - require('./markdown-it-fence'), - { - chart: token => { - if (token.parameters.hasOwnProperty('yaml')) { - token.parameters.format = 'yaml' - } - updatedOptions.onFence('chart', token.parameters.format) - - return `
-            ${token.fileName}
-            
${ - token.content - }
-
` - }, - flowchart: token => { - updatedOptions.onFence('flowchart') - - return `
-            ${token.fileName}
-            
${ - token.content - }
-
` - }, - gallery: token => { - const content = token.content - .split('\n') - .slice(0, -1) - .map(line => { - const match = /!\[[^\]]*]\(([^\)]*)\)/.exec(line) - if (match) { - return mdurl.encode(match[1]) - } else { - return mdurl.encode(line) - } - }) - .join('\n') - - return `
-              ${token.fileName}
-              
-            
` - }, - mermaid: token => { - updatedOptions.onFence('mermaid') - - return `
-            ${token.fileName}
-            
${ - token.content - }
-
` - }, - sequence: token => { - updatedOptions.onFence('sequence') - - return `
-            ${token.fileName}
-            
${ - token.content - }
-
` - } - }, - token => { - updatedOptions.onFence('code', token.langType) + updatedOptions.onFence('chart', token.parameters.format) + + return `
+        ${token.fileName}
+        
${ + token.content + }
+
` + }, + flowchart: token => { + updatedOptions.onFence('flowchart') + + return `
+        ${token.fileName}
+        
${ + token.content + }
+
` + }, + gallery: token => { + const content = token.content + .split('\n') + .slice(0, -1) + .map(line => { + const match = /!\[[^\]]*]\(([^\)]*)\)/.exec(line) + if (match) { + return mdurl.encode(match[1]) + } else { + return mdurl.encode(line) + } + }) + .join('\n') - return `
+      return `
           ${token.fileName}
-          ${createGutter(token.content, token.firstLineNumber)}
-          ${token.content}
+          
         
` - } - ) - - const deflate = require('markdown-it-plantuml/lib/deflate') - const plantuml = require('markdown-it-plantuml') - const plantUmlStripTrailingSlash = url => - url.endsWith('/') ? url.slice(0, -1) : url - const plantUmlServerAddress = plantUmlStripTrailingSlash( - config.preview.plantUMLServerAddress - ) - const parsePlantUml = function(umlCode, openMarker, closeMarker, type) { - const s = unescape(encodeURIComponent(umlCode)) - const zippedCode = deflate.encode64( - deflate.zip_deflate(`${openMarker}\n${s}\n${closeMarker}`, 9) - ) - return `${plantUmlServerAddress}/${type}/${zippedCode}` + }, + mermaid: token => { + updatedOptions.onFence('mermaid') + + return `
+        ${token.fileName}
+        
${ + token.content + }
+
` + }, + sequence: token => { + updatedOptions.onFence('sequence') + + return `
+        ${token.fileName}
+        
${ + token.content + }
+
` } + }, + token => { + updatedOptions.onFence('code', token.langType) + + return `
+      ${token.fileName}
+      ${createGutter(token.content, token.firstLineNumber)}
+      ${token.content}
+    
` + } +) + +const deflate = require('markdown-it-plantuml/lib/deflate') +const plantuml = require('markdown-it-plantuml') +const plantUmlStripTrailingSlash = url => + url.endsWith('/') ? url.slice(0, -1) : url +const plantUmlServerAddress = plantUmlStripTrailingSlash( + config.preview.plantUMLServerAddress +) +const parsePlantUml = function(umlCode, openMarker, closeMarker, type) { + const s = unescape(encodeURIComponent(umlCode)) + const zippedCode = deflate.encode64( + deflate.zip_deflate(`${openMarker}\n${s}\n${closeMarker}`, 9) + ) + return `${plantUmlServerAddress}/${type}/${zippedCode}` +} - this.md.use(plantuml, { - generateSource: umlCode => - parsePlantUml(umlCode, '@startuml', '@enduml', 'svg') - }) - - // Ditaa support. PlantUML server doesn't support Ditaa in SVG, so we set the format as PNG at the moment. - this.md.use(plantuml, { - openMarker: '@startditaa', - closeMarker: '@endditaa', - generateSource: umlCode => - parsePlantUml(umlCode, '@startditaa', '@endditaa', 'png') - }) - - // Mindmap support - this.md.use(plantuml, { - openMarker: '@startmindmap', - closeMarker: '@endmindmap', - generateSource: umlCode => - parsePlantUml(umlCode, '@startmindmap', '@endmindmap', 'svg') - }) - - // WBS support - this.md.use(plantuml, { - openMarker: '@startwbs', - closeMarker: '@endwbs', - generateSource: umlCode => - parsePlantUml(umlCode, '@startwbs', '@endwbs', 'svg') - }) - - // Gantt support - this.md.use(plantuml, { - openMarker: '@startgantt', - closeMarker: '@endgantt', - generateSource: umlCode => - parsePlantUml(umlCode, '@startgantt', '@endgantt', 'svg') - }) - - // Override task item - this.md.block.ruler.at('paragraph', function( - state, - startLine /*, endLine */ - ) { - let content, terminate, i, l, token - let nextLine = startLine + 1 - const terminatorRules = state.md.block.ruler.getRules('paragraph') - const endLine = state.lineMax - - // jump line-by-line until empty one or EOF - for (; nextLine < endLine && !state.isEmpty(nextLine); nextLine++) { - // this would be a code block normally, but after paragraph - // it's considered a lazy continuation regardless of what's there - if (state.sCount[nextLine] - state.blkIndent > 3) { - continue - } +this.md.use(plantuml, { + generateSource: umlCode => + parsePlantUml(umlCode, '@startuml', '@enduml', 'svg') +}) + +// Ditaa support. PlantUML server doesn't support Ditaa in SVG, so we set the format as PNG at the moment. +this.md.use(plantuml, { + openMarker: '@startditaa', + closeMarker: '@endditaa', + generateSource: umlCode => + parsePlantUml(umlCode, '@startditaa', '@endditaa', 'png') +}) + +// Mindmap support +this.md.use(plantuml, { + openMarker: '@startmindmap', + closeMarker: '@endmindmap', + generateSource: umlCode => + parsePlantUml(umlCode, '@startmindmap', '@endmindmap', 'svg') +}) + +// WBS support +this.md.use(plantuml, { + openMarker: '@startwbs', + closeMarker: '@endwbs', + generateSource: umlCode => + parsePlantUml(umlCode, '@startwbs', '@endwbs', 'svg') +}) + +// Gantt support +this.md.use(plantuml, { + openMarker: '@startgantt', + closeMarker: '@endgantt', + generateSource: umlCode => + parsePlantUml(umlCode, '@startgantt', '@endgantt', 'svg') +}) + +// Override task item +this.md.block.ruler.at('paragraph', function( + state, + startLine /*, endLine */ +) { + let content, terminate, i, l, token + let nextLine = startLine + 1 + const terminatorRules = state.md.block.ruler.getRules('paragraph') + const endLine = state.lineMax + + // jump line-by-line until empty one or EOF + for (; nextLine < endLine && !state.isEmpty(nextLine); nextLine++) { + // this would be a code block normally, but after paragraph + // it's considered a lazy continuation regardless of what's there + if (state.sCount[nextLine] - state.blkIndent > 3) { + continue + } - // quirk for blockquotes, this line should already be checked by that rule - if (state.sCount[nextLine] < 0) { - continue - } + // quirk for blockquotes, this line should already be checked by that rule + if (state.sCount[nextLine] < 0) { + continue + } - // Some tags can terminate paragraph without empty line. - terminate = false - for (i = 0, l = terminatorRules.length; i < l; i++) { - if (terminatorRules[i](state, nextLine, endLine, true)) { - terminate = true - break - } - } - if (terminate) { - break - } + // Some tags can terminate paragraph without empty line. + terminate = false + for (i = 0, l = terminatorRules.length; i < l; i++) { + if (terminatorRules[i](state, nextLine, endLine, true)) { + terminate = true + break } + } + if (terminate) { + break + } + } - content = state - .getLines(startLine, nextLine, state.blkIndent, false) - .trim() - - state.line = nextLine - - token = state.push('paragraph_open', 'p', 1) - token.map = [startLine, state.line] - - if (state.parentType === 'list') { - const match = content.match(/^\[( |x)\] ?(.+)/i) - if (match) { - const liToken = lastFindInArray( - state.tokens, - token => token.type === 'list_item_open' - ) - if (liToken) { - if (!liToken.attrs) { - liToken.attrs = [] - } - if (config.preview.lineThroughCheckbox) { - liToken.attrs.push([ - 'class', - `taskListItem${match[1] !== ' ' ? ' checked' : ''}` - ]) - } else { - liToken.attrs.push(['class', 'taskListItem']) - } - } - content = `` + content = state + .getLines(startLine, nextLine, state.blkIndent, false) + .trim() + + state.line = nextLine + + token = state.push('paragraph_open', 'p', 1) + token.map = [startLine, state.line] + + if (state.parentType === 'list') { + const match = content.match(/^\[( |x)\] ?(.+)/i) + if (match) { + const liToken = lastFindInArray( + state.tokens, + token => token.type === 'list_item_open' + ) + if (liToken) { + if (!liToken.attrs) { + liToken.attrs = [] + } + if (config.preview.lineThroughCheckbox) { + liToken.attrs.push([ + 'class', + `taskListItem${match[1] !== ' ' ? ' checked' : ''}` + ]) + } else { + liToken.attrs.push(['class', 'taskListItem']) } } + content = `` + } + } - token = state.push('inline', '', 0) - token.content = content - token.map = [startLine, state.line] - token.children = [] + token = state.push('inline', '', 0) + token.content = content + token.map = [startLine, state.line] + token.children = [] - token = state.push('paragraph_close', 'p', -1) + token = state.push('paragraph_close', 'p', -1) - return true - }) + return true +}) - this.md.renderer.rules.code_inline = function(tokens, idx) { - const token = tokens[idx] +this.md.renderer.rules.code_inline = function(tokens, idx) { + const token = tokens[idx] - return ( - '' + - escapeHtmlCharacters(token.content) + - '' - ) - } + return ( + '' + + escapeHtmlCharacters(token.content) + + '' + ) +} - if (config.preview.smartArrows) { - this.md.use(smartArrows) - } +if (config.preview.smartArrows) { + this.md.use(smartArrows) +} - // Add line number attribute for scrolling - const originalRender = this.md.renderer.render - this.md.renderer.render = (tokens, options, env) => { - tokens.forEach(token => { - switch (token.type) { - case 'blockquote_open': - case 'dd_open': - case 'dt_open': - case 'heading_open': - case 'list_item_open': - case 'paragraph_open': - case 'table_open': - if (token.map) { - token.attrPush(['data-line', token.map[0]]) - } +// Add line number attribute for scrolling +const originalRender = this.md.renderer.render +this.md.renderer.render = (tokens, options, env) => { + tokens.forEach(token => { + switch (token.type) { + case 'blockquote_open': + case 'dd_open': + case 'dt_open': + case 'heading_open': + case 'list_item_open': + case 'paragraph_open': + case 'table_open': + if (token.map) { + token.attrPush(['data-line', token.map[0]]) } - }) - const result = originalRender.call(this.md.renderer, tokens, options, env) - return result } - // FIXME We should not depend on global variable. - window.md = this.md - } + }) + const result = originalRender.call(this.md.renderer, tokens, options, env) + return result +} +// FIXME We should not depend on global variable. +window.md = this.md +``` - render(content) { - if (!_.isString(content)) content = '' - return this.md.render(content) - } +} + +render(content) { +if (!_.isString(content)) content = '' +return this.md.render(content) +} } export default Markdown