From 830f51664c33daa7b79d58ee06af1a57abc7db06 Mon Sep 17 00:00:00 2001 From: NGPixel Date: Fri, 6 Mar 2020 14:31:05 -0500 Subject: [PATCH] feat: katex in markdown preview + xss fix for svg --- client/components/editor/common/katex.js | 140 ++++++++++++++++++ client/components/editor/editor-markdown.vue | 33 +++++ .../rendering/html-security/renderer.js | 4 +- 3 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 client/components/editor/common/katex.js diff --git a/client/components/editor/common/katex.js b/client/components/editor/common/katex.js new file mode 100644 index 00000000..3c17b1fb --- /dev/null +++ b/client/components/editor/common/katex.js @@ -0,0 +1,140 @@ +// Test if potential opening or closing delimieter +// Assumes that there is a "$" at state.src[pos] +function isValidDelim (state, pos) { + let prevChar + let nextChar + let max = state.posMax + let canOpen = true + let canClose = true + + prevChar = pos > 0 ? state.src.charCodeAt(pos - 1) : -1 + nextChar = pos + 1 <= max ? state.src.charCodeAt(pos + 1) : -1 + + // Check non-whitespace conditions for opening and closing, and + // check that closing delimeter isn't followed by a number + if (prevChar === 0x20/* " " */ || prevChar === 0x09/* \t */ || + (nextChar >= 0x30/* "0" */ && nextChar <= 0x39/* "9" */)) { + canClose = false + } + if (nextChar === 0x20/* " " */ || nextChar === 0x09/* \t */) { + canOpen = false + } + + return { + canOpen: canOpen, + canClose: canClose + } +} + +export default { + katexInline (state, silent) { + let start, match, token, res, pos + + if (state.src[state.pos] !== '$') { return false } + + res = isValidDelim(state, state.pos) + if (!res.canOpen) { + if (!silent) { state.pending += '$' } + state.pos += 1 + return true + } + + // First check for and bypass all properly escaped delimieters + // This loop will assume that the first leading backtick can not + // be the first character in state.src, which is known since + // we have found an opening delimieter already. + start = state.pos + 1 + match = start + while ((match = state.src.indexOf('$', match)) !== -1) { + // Found potential $, look for escapes, pos will point to + // first non escape when complete + pos = match - 1 + while (state.src[pos] === '\\') { pos -= 1 } + + // Even number of escapes, potential closing delimiter found + if (((match - pos) % 2) === 1) { break } + match += 1 + } + + // No closing delimter found. Consume $ and continue. + if (match === -1) { + if (!silent) { state.pending += '$' } + state.pos = start + return true + } + + // Check if we have empty content, ie: $$. Do not parse. + if (match - start === 0) { + if (!silent) { state.pending += '$$' } + state.pos = start + 1 + return true + } + + // Check for valid closing delimiter + res = isValidDelim(state, match) + if (!res.canClose) { + if (!silent) { state.pending += '$' } + state.pos = start + return true + } + + if (!silent) { + token = state.push('katex_inline', 'math', 0) + token.markup = '$' + token.content = state.src.slice(start, match) + } + + state.pos = match + 1 + return true + }, + + katexBlock (state, start, end, silent) { + let firstLine; let lastLine; let next; let lastPos; let found = false; let token + let pos = state.bMarks[start] + state.tShift[start] + let max = state.eMarks[start] + + if (pos + 2 > max) { return false } + if (state.src.slice(pos, pos + 2) !== '$$') { return false } + + pos += 2 + firstLine = state.src.slice(pos, max) + + if (silent) { return true } + if (firstLine.trim().slice(-2) === '$$') { + // Single line expression + firstLine = firstLine.trim().slice(0, -2) + found = true + } + + for (next = start; !found;) { + next++ + + if (next >= end) { break } + + pos = state.bMarks[next] + state.tShift[next] + max = state.eMarks[next] + + if (pos < max && state.tShift[next] < state.blkIndent) { + // non-empty line with negative indent should stop the list: + break + } + + if (state.src.slice(pos, max).trim().slice(-2) === '$$') { + lastPos = state.src.slice(0, max).lastIndexOf('$$') + lastLine = state.src.slice(pos, lastPos) + found = true + } + } + + state.line = next + 1 + + token = state.push('katex_block', 'math', 0) + token.block = true + token.content = (firstLine && firstLine.trim() ? firstLine + '\n' : '') + + state.getLines(start + 1, next, state.tShift[start], true) + + (lastLine && lastLine.trim() ? lastLine : '') + token.map = [ start, state.line ] + token.markup = '$$' + return true + } +} diff --git a/client/components/editor/editor-markdown.vue b/client/components/editor/editor-markdown.vue index edda7793..6c81a596 100644 --- a/client/components/editor/editor-markdown.vue +++ b/client/components/editor/editor-markdown.vue @@ -214,10 +214,14 @@ import mdSup from 'markdown-it-sup' import mdSub from 'markdown-it-sub' import mdMark from 'markdown-it-mark' import mdImsize from 'markdown-it-imsize' +import katex from 'katex' // Prism (Syntax Highlighting) import Prism from 'prismjs' +// Helpers +import katexHelper from './common/katex' + // ======================================== // INIT // ======================================== @@ -278,6 +282,35 @@ md.renderer.rules.paragraph_open = injectLineNumbers md.renderer.rules.heading_open = injectLineNumbers md.renderer.rules.blockquote_open = injectLineNumbers +// ======================================== +// KATEX +// ======================================== + +md.inline.ruler.after('escape', 'katex_inline', katexHelper.katexInline) +md.renderer.rules.katex_inline = (tokens, idx) => { + try { + return katex.renderToString(tokens[idx].content, { + displayMode: false + }) + } catch (err) { + console.warn(err) + return tokens[idx].content + } +} +md.block.ruler.after('blockquote', 'katex_block', katexHelper.katexBlock, { + alt: [ 'paragraph', 'reference', 'blockquote', 'list' ] +}) +md.renderer.rules.katex_block = (tokens, idx) => { + try { + return `

` + katex.renderToString(tokens[idx].content, { + displayMode: true + }) + `

` + } catch (err) { + console.warn(err) + return tokens[idx].content + } +} + // ======================================== // Vue Component // ======================================== diff --git a/server/modules/rendering/html-security/renderer.js b/server/modules/rendering/html-security/renderer.js index 7149c310..8ef20261 100644 --- a/server/modules/rendering/html-security/renderer.js +++ b/server/modules/rendering/html-security/renderer.js @@ -29,10 +29,10 @@ module.exports = { path: ['d', 'style'], pre: ['class', 'style'], section: ['class', 'style'], - span: ['class', 'style'], + span: ['class', 'style', 'aria-hidden'], strong: ['class', 'style'], summary: ['class', 'style'], - svg: ['width', 'height', 'viewBox', 'preserveAspectRatio', 'style'], + svg: ['width', 'height', 'viewbox', 'preserveaspectratio', 'style'], table: ['border', 'class', 'id', 'style', 'width'], tbody: ['class', 'style'], td: ['align', 'class', 'colspan', 'rowspan', 'style', 'valign'],