From e808416c0496ad42da42f0e9dad523ae772e2784 Mon Sep 17 00:00:00 2001 From: Daniel Hollas Date: Sat, 7 Mar 2020 20:09:21 +0100 Subject: [PATCH 1/4] FEATURE: custom inline and block math delimiters --- .../discourse-markdown/discourse-math.js.es6 | 77 ++++++++++--------- config/locales/server.en.yml | 2 + config/settings.yml | 10 +++ plugin.rb | 2 +- 4 files changed, 55 insertions(+), 36 deletions(-) diff --git a/assets/javascripts/lib/discourse-markdown/discourse-math.js.es6 b/assets/javascripts/lib/discourse-markdown/discourse-math.js.es6 index 0bc35dd..b5bb97a 100644 --- a/assets/javascripts/lib/discourse-markdown/discourse-math.js.es6 +++ b/assets/javascripts/lib/discourse-markdown/discourse-math.js.es6 @@ -23,7 +23,8 @@ function isSafeBoundary(character_code, delimiter_code, md) { return false; } -function math_input(state, silent, delimiter_code) { +let inlineMath = delimiter => (state, silent) => { + let delimiter_code = delimiter.charCodeAt(0); let pos = state.pos, posMax = state.posMax; @@ -71,35 +72,20 @@ function math_input(state, silent, delimiter_code) { let token = state.push("html_raw", "", 0); const escaped = state.md.utils.escapeHtml(data); - let math_class = delimiter_code === 36 ? "'math'" : "'asciimath'"; + let math_class = delimiter_code === 37 /* % */ ? "'asciimath'" : "'math'"; token.content = `${escaped}`; state.pos = found + 1; return true; } -function inlineMath(state, silent) { - return math_input(state, silent, 36 /* $ */); -} - -function asciiMath(state, silent) { - return math_input(state, silent, 37 /* % */); -} - -function isBlockMarker(state, start, max, md) { - if (state.src.charCodeAt(start) !== 36 /* $ */) { - return false; - } +function isBlockMarker(state, start, max, md, blockMarker) { - start++; - - if (state.src.charCodeAt(start) !== 36 /* $ */) { + if (!state.src.startsWith(blockMarker, start)) { return false; } - start++; - - // ensure we only have newlines after our $$ - for (let i = start; i < max; i++) { + // ensure we only have spaces and newline after blockmarker + for (let i = start + blockMarker.length; i < max; i++) { if (!md.utils.isSpace(state.src.charCodeAt(i))) { return false; } @@ -108,11 +94,11 @@ function isBlockMarker(state, start, max, md) { return true; } -function blockMath(state, startLine, endLine, silent) { +let blockMath = blockMarker => (state, startLine, endLine, silent) => { let start = state.bMarks[startLine] + state.tShift[startLine], max = state.eMarks[startLine]; - if (!isBlockMarker(state, start, max, state.md)) { + if (!isBlockMarker(state, start, max, state.md, blockMarker)) { return false; } @@ -125,7 +111,7 @@ function blockMath(state, startLine, endLine, silent) { for (;;) { nextLine++; - // unclosed $$ is considered math + // Unclosed blockmarker is considered math if (nextLine >= endLine) { break; } @@ -135,7 +121,8 @@ function blockMath(state, startLine, endLine, silent) { state, state.bMarks[nextLine] + state.tShift[nextLine], state.eMarks[nextLine], - state.md + state.md, + blockMarker.replace('\\begin{', '\\end{') ) ) { closed = true; @@ -145,11 +132,15 @@ function blockMath(state, startLine, endLine, silent) { let token = state.push("html_raw", "", 0); - let endContent = closed ? state.eMarks[nextLine - 1] : state.eMarks[nextLine]; - let content = state.src.slice( - state.bMarks[startLine + 1] + state.tShift[startLine + 1], - endContent - ); + // Blockmarker starting with \begin{ end ending with '\end{' + // needs to be passed to the TeX engine + let endContent = blockMarker.startsWith('\\begin{') || !closed ? + state.eMarks[nextLine] : state.eMarks[nextLine - 1]; + + let startContent = blockMarker.startsWith('\\begin{') ? + state.bMarks[startLine] : state.bMarks[startLine + 1] + state.tShift[startLine + 1]; + + let content = state.src.slice(startContent, endContent); const escaped = state.md.utils.escapeHtml(content); token.content = `
\n${escaped}\n
\n`; @@ -165,18 +156,34 @@ export function setup(helper) { } let enable_asciimath; + let inlineDelimiters, blockDelimiters; helper.registerOptions((opts, siteSettings) => { opts.features.math = siteSettings.discourse_math_enabled; enable_asciimath = siteSettings.discourse_math_enable_asciimath; + inlineDelimiters = siteSettings.discourse_math_inline_delimiters; + blockDelimiters = siteSettings.discourse_math_block_delimiters; }); helper.registerPlugin(md => { if (enable_asciimath) { - md.inline.ruler.after("escape", "asciimath", asciiMath); + md.inline.ruler.after("escape", "asciimath", inlineMath('%')); + } + if (inlineDelimiters) { + inlineDelimiters.split('|').forEach(delim => { + // We expect only one character + // for inline math delimiter + let d = delim.trim(); + if (d.length !== 1) return; + md.inline.ruler.after("escape", "math", inlineMath(d)); + }); + } + if (blockDelimiters) { + blockDelimiters.split('|').forEach(delim => { + let d = delim.trim(); + md.block.ruler.after("code", "math", blockMath(delim), { + alt: ["paragraph", "reference", "blockquote", "list"] + }); + }); } - md.inline.ruler.after("escape", "math", inlineMath); - md.block.ruler.after("code", "math", blockMath, { - alt: ["paragraph", "reference", "blockquote", "list"] - }); }); } diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index cc66392..0d9c630 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -8,3 +8,5 @@ en: discourse_math_zoom_on_hover: 'Zoom 200% on hover (MathJax only)' discourse_math_enable_accessibility: 'Enable accessibility features (MathJax only)' discourse_math_enable_asciimath: 'Enable asciimath (will add special processing to % delimited input) (MathJax only)' + discourse_math_inline_delimiters: 'You can have multiple delimiters for inline math, but each must be single character' + discourse_math_block_delimiters: 'You can have multiple delimiters for block math. Delimiters starting with "\begin{" will be passed to the LaTeX engine and are expected to end with the corresponding \end{} command' diff --git a/config/settings.yml b/config/settings.yml index b8b8616..edb552e 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -9,6 +9,16 @@ plugins: choices: - mathjax - katex + discourse_math_inline_delimiters: + default: '$' + client: true + type: list + list_type: compact + discourse_math_block_delimiters: + default: '$$|\\begin{align}' + client: true + type: list + list_type: compact discourse_math_zoom_on_hover: default: false client: true diff --git a/plugin.rb b/plugin.rb index 22b9407..37a670c 100644 --- a/plugin.rb +++ b/plugin.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true # name: discourse-math -# about: Official mathjax plugin for Discourse +# about: Official LaTeX plugin for Discourse # version: 0.9 # authors: Sam Saffron (sam) # url: https://github.com/discourse/discourse-math From e322aee986aa2df7b813b7cd86bb5f01213c9607 Mon Sep 17 00:00:00 2001 From: Daniel Hollas Date: Tue, 10 Mar 2020 17:48:26 +0100 Subject: [PATCH 2/4] Revamp options --- .../discourse-markdown/discourse-math.js.es6 | 99 +++++++++++++------ config/locales/server.en.yml | 5 +- config/settings.yml | 9 +- 3 files changed, 75 insertions(+), 38 deletions(-) diff --git a/assets/javascripts/lib/discourse-markdown/discourse-math.js.es6 b/assets/javascripts/lib/discourse-markdown/discourse-math.js.es6 index b5bb97a..f049f98 100644 --- a/assets/javascripts/lib/discourse-markdown/discourse-math.js.es6 +++ b/assets/javascripts/lib/discourse-markdown/discourse-math.js.es6 @@ -23,8 +23,11 @@ function isSafeBoundary(character_code, delimiter_code, md) { return false; } -let inlineMath = delimiter => (state, silent) => { - let delimiter_code = delimiter.charCodeAt(0); +let inlineMath = (startDelimiter, endDelimiter) => (state, silent) => { + // DH Hack for now + // TODO: support multiple-char delimiters + let delimiter_code = startDelimiter.charCodeAt(0); + let end_delimiter_code = endDelimiter.charCodeAt(0); let pos = state.pos, posMax = state.posMax; @@ -37,7 +40,7 @@ let inlineMath = delimiter => (state, silent) => { } // too short - if (state.src.charCodeAt(pos + 1) === delimiter_code) { + if (state.src.charCodeAt(pos + 1) === end_delimiter_code) { return false; } @@ -51,7 +54,7 @@ let inlineMath = delimiter => (state, silent) => { let found; for (let i = pos + 1; i < posMax; i++) { let code = state.src.charCodeAt(i); - if (code === delimiter_code && state.src.charCodeAt(i - 1) !== 92 /* \ */) { + if (code === end_delimiter_code && state.src.charCodeAt(i - 1) !== 92 /* \ */) { found = i; break; } @@ -63,7 +66,7 @@ let inlineMath = delimiter => (state, silent) => { if (found + 1 <= posMax) { let next = state.src.charCodeAt(found + 1); - if (next && !isSafeBoundary(next, delimiter_code, state.md)) { + if (next && !isSafeBoundary(next, end_delimiter_code, state.md)) { return false; } } @@ -72,7 +75,7 @@ let inlineMath = delimiter => (state, silent) => { let token = state.push("html_raw", "", 0); const escaped = state.md.utils.escapeHtml(data); - let math_class = delimiter_code === 37 /* % */ ? "'asciimath'" : "'math'"; + let math_class = startDelimiter === endDelimiter === '%' ? "'asciimath'" : "'math'"; token.content = `${escaped}`; state.pos = found + 1; return true; @@ -83,8 +86,8 @@ function isBlockMarker(state, start, max, md, blockMarker) { if (!state.src.startsWith(blockMarker, start)) { return false; } - - // ensure we only have spaces and newline after blockmarker + + // ensure we only have spaces and newlines after block math marker for (let i = start + blockMarker.length; i < max; i++) { if (!md.utils.isSpace(state.src.charCodeAt(i))) { return false; @@ -94,11 +97,26 @@ function isBlockMarker(state, start, max, md, blockMarker) { return true; } -let blockMath = blockMarker => (state, startLine, endLine, silent) => { +let blockMath = (startBlockMathMarker, endBlockMathMarker) => (state, startLine, endLine, silent) => { let start = state.bMarks[startLine] + state.tShift[startLine], max = state.eMarks[startLine]; - if (!isBlockMarker(state, start, max, state.md, blockMarker)) { + let startBlockMarker = startBlockMathMarker; + let endBlockMarker = endBlockMathMarker; + + // Special processing for /\begin{[a-z]+}/ + if (startBlockMarker instanceof RegExp) { + let substr = state.src.substring(start, max); + let match = substr.match(startBlockMarker); + if(!match) { + return false; + } + let mathEnv = match[1]; + startBlockMarker = `\\begin{${mathEnv}}`; + endBlockMarker = `\\end{${mathEnv}}`; + } + + if (!isBlockMarker(state, start, max, state.md, startBlockMarker)) { return false; } @@ -122,7 +140,7 @@ let blockMath = blockMarker => (state, startLine, endLine, silent) => { state.bMarks[nextLine] + state.tShift[nextLine], state.eMarks[nextLine], state.md, - blockMarker.replace('\\begin{', '\\end{') + endBlockMarker ) ) { closed = true; @@ -132,12 +150,12 @@ let blockMath = blockMarker => (state, startLine, endLine, silent) => { let token = state.push("html_raw", "", 0); - // Blockmarker starting with \begin{ end ending with '\end{' + // Math environment blockmarkers '\begin{}' and '\end{}' // needs to be passed to the TeX engine - let endContent = blockMarker.startsWith('\\begin{') || !closed ? + let endContent = endBlockMarker.startsWith('\\end{') || !closed ? state.eMarks[nextLine] : state.eMarks[nextLine - 1]; - let startContent = blockMarker.startsWith('\\begin{') ? + let startContent = startBlockMarker.startsWith('\\begin{') ? state.bMarks[startLine] : state.bMarks[startLine + 1] + state.tShift[startLine + 1]; let content = state.src.slice(startContent, endContent); @@ -155,35 +173,52 @@ export function setup(helper) { return; } - let enable_asciimath; + let enableAsciiMath, enableMathEnvs; let inlineDelimiters, blockDelimiters; helper.registerOptions((opts, siteSettings) => { opts.features.math = siteSettings.discourse_math_enabled; - enable_asciimath = siteSettings.discourse_math_enable_asciimath; + enableAsciiMath = siteSettings.discourse_math_enable_asciimath; + enableMathEnvs = siteSettings.discourse_math_process_tex_environments; inlineDelimiters = siteSettings.discourse_math_inline_delimiters; blockDelimiters = siteSettings.discourse_math_block_delimiters; }); helper.registerPlugin(md => { - if (enable_asciimath) { - md.inline.ruler.after("escape", "asciimath", inlineMath('%')); - } - if (inlineDelimiters) { - inlineDelimiters.split('|').forEach(delim => { - // We expect only one character - // for inline math delimiter - let d = delim.trim(); - if (d.length !== 1) return; - md.inline.ruler.after("escape", "math", inlineMath(d)); - }); + if (enableAsciiMath) { + md.inline.ruler.after("escape", "asciimath", inlineMath('%', '%')); } - if (blockDelimiters) { - blockDelimiters.split('|').forEach(delim => { - let d = delim.trim(); - md.block.ruler.after("code", "math", blockMath(delim), { + + if (enableMathEnvs) { + md.block.ruler.after("code", "math", + blockMath(/\\begin\{([a-z]+)\}/, /\\end\{([a-z]+)\}/), { alt: ["paragraph", "reference", "blockquote", "list"] }); - }); } + // Helper function for checking input + const isEmptyStr = elem => elem.trim() === ''; + + inlineDelimiters.split('|').forEach(d => { + let delims = d.split(','); + if (delims.length !== 2 || delims.some(isEmptyStr)) { + console.error('Invalid input in discourse_math_inline_delimiters!'); + return; + } + let startDelim = delims[0].trim(); + let endDelim = delims[1].trim(); + md.inline.ruler.after("escape", "math", inlineMath(startDelim, endDelim)); + }); + + blockDelimiters.split('|').forEach(d => { + let delims = d.split(','); + if (delims.length !== 2 || delims.some(isEmptyStr)) { + console.error('Invalid input in discourse_math_block_delimiters!'); + return; + } + let startDelim = delims[0].trim(); + let endDelim = delims[1].trim(); + md.block.ruler.after("code", "math", blockMath(startDelim, endDelim), { + alt: ["paragraph", "reference", "blockquote", "list"] + }); + }); }); } diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 0d9c630..07cba92 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -8,5 +8,6 @@ en: discourse_math_zoom_on_hover: 'Zoom 200% on hover (MathJax only)' discourse_math_enable_accessibility: 'Enable accessibility features (MathJax only)' discourse_math_enable_asciimath: 'Enable asciimath (will add special processing to % delimited input) (MathJax only)' - discourse_math_inline_delimiters: 'You can have multiple delimiters for inline math, but each must be single character' - discourse_math_block_delimiters: 'You can have multiple delimiters for block math. Delimiters starting with "\begin{" will be passed to the LaTeX engine and are expected to end with the corresponding \end{} command' + discourse_math_inline_delimiters: 'You can have multiple delimiters for inline math' + discourse_math_block_delimiters: 'You can have multiple delimiters for block math. Delimiters starting with "\begin{}" will be passed to the LaTeX engine and should to end with the corresponding \end{} command' + discourse_math_process_tex_environments: 'Enable processing of "\begin{*}...\end{*}" environments (MathJax only)' diff --git a/config/settings.yml b/config/settings.yml index edb552e..1a19656 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -10,15 +10,16 @@ plugins: - mathjax - katex discourse_math_inline_delimiters: - default: '$' + default: '$,$' client: true type: list - list_type: compact discourse_math_block_delimiters: - default: '$$|\\begin{align}' + default: '$$,$$' client: true type: list - list_type: compact + discourse_math_process_tex_environments: + default: false + client: true discourse_math_zoom_on_hover: default: false client: true From 133a9b5f43a4437fc852e8b4a83cb56fac929eee Mon Sep 17 00:00:00 2001 From: Daniel Hollas Date: Tue, 10 Mar 2020 21:20:36 +0100 Subject: [PATCH 3/4] Enable multichar math inline delimiters I am unsure about this. Needs testing... To make \( \) work, I needed to change the inline ruler from inline.ruler.after('escape',...) to inline.ruler.before('escape,...) I have to make sure this does not break anything. --- .../discourse-markdown/discourse-math.js.es6 | 81 ++++++++++--------- 1 file changed, 45 insertions(+), 36 deletions(-) diff --git a/assets/javascripts/lib/discourse-markdown/discourse-math.js.es6 b/assets/javascripts/lib/discourse-markdown/discourse-math.js.es6 index f049f98..fce1402 100644 --- a/assets/javascripts/lib/discourse-markdown/discourse-math.js.es6 +++ b/assets/javascripts/lib/discourse-markdown/discourse-math.js.es6 @@ -3,60 +3,66 @@ // // -function isSafeBoundary(character_code, delimiter_code, md) { - if (character_code === delimiter_code) { - return false; - } - - if (md.utils.isWhiteSpace(character_code)) { - return true; - } +function isSafeBoundary(character, delimiter) { - if (md.utils.isMdAsciiPunct(character_code)) { - return true; + let characterCode = character.charCodeAt(0); + // 0-9 + if (characterCode > 47 && characterCode < 58) { + return false; } - if (md.utils.isPunctChar(character_code)) { - return true; + // Need to distinguish $ from $$ + if (delimiter.length == 1 && delimiter.charCodeAt(0) === characterCode) { + return false; } - return false; + return true; } let inlineMath = (startDelimiter, endDelimiter) => (state, silent) => { - // DH Hack for now - // TODO: support multiple-char delimiters - let delimiter_code = startDelimiter.charCodeAt(0); - let end_delimiter_code = endDelimiter.charCodeAt(0); let pos = state.pos, posMax = state.posMax; if ( silent || - state.src.charCodeAt(pos) !== delimiter_code || - posMax < pos + 2 + !state.src.startsWith(startDelimiter, pos) || + posMax < pos + startDelimiter.length + endDelimiter.length ) { return false; } // too short - if (state.src.charCodeAt(pos + 1) === end_delimiter_code) { + if (state.src.startsWith(endDelimiter, pos + startDelimiter.length)) { return false; } if (pos > 0) { - let prev = state.src.charCodeAt(pos - 1); - if (!isSafeBoundary(prev, delimiter_code, state.md)) { + let prev = state.src[pos - 1]; + if (!isSafeBoundary(prev, startDelimiter)) { return false; } } let found; - for (let i = pos + 1; i < posMax; i++) { - let code = state.src.charCodeAt(i); - if (code === end_delimiter_code && state.src.charCodeAt(i - 1) !== 92 /* \ */) { - found = i; - break; + if (endDelimiter.length === 1) { + // Faster iterations, comparing numbers instead of characters + // and respecting character escaping with `\` + let endDelimCode = endDelimiter.charCodeAt(0); + for (let i = pos + 1; i < posMax; i++) { + let code = state.src.charCodeAt(i); + if (code === endDelimCode && state.src.charCodeAt(i - 1) !== 92 /* \ */) { + found = i; + break; + } + } + } else { + for (let i = pos + 1; i <= posMax - endDelimiter.length; i++) { + // we do not respect escaping here because we need to allow for + // \(...\) TeX inline delimiters + if (state.src.startsWith(endDelimiter, i)) { + found = i; + break; + } } } @@ -64,20 +70,20 @@ let inlineMath = (startDelimiter, endDelimiter) => (state, silent) => { return false; } - if (found + 1 <= posMax) { - let next = state.src.charCodeAt(found + 1); - if (next && !isSafeBoundary(next, end_delimiter_code, state.md)) { + if (found + endDelimiter.length <= posMax) { + let next = state.src[found + endDelimiter.length]; + if (!isSafeBoundary(next, endDelimiter)) { return false; } } - let data = state.src.slice(pos + 1, found); + let data = state.src.slice(pos + startDelimiter.length, found); let token = state.push("html_raw", "", 0); const escaped = state.md.utils.escapeHtml(data); let math_class = startDelimiter === endDelimiter === '%' ? "'asciimath'" : "'math'"; token.content = `${escaped}`; - state.pos = found + 1; + state.pos = found + endDelimiter.length; return true; } @@ -104,7 +110,7 @@ let blockMath = (startBlockMathMarker, endBlockMathMarker) => (state, startLine, let startBlockMarker = startBlockMathMarker; let endBlockMarker = endBlockMathMarker; - // Special processing for /\begin{[a-z]+}/ + // Special processing for /\begin{([a-z]+)}/ if (startBlockMarker instanceof RegExp) { let substr = state.src.substring(start, max); let match = substr.match(startBlockMarker); @@ -175,20 +181,23 @@ export function setup(helper) { let enableAsciiMath, enableMathEnvs; let inlineDelimiters, blockDelimiters; + let texRenderer; helper.registerOptions((opts, siteSettings) => { opts.features.math = siteSettings.discourse_math_enabled; enableAsciiMath = siteSettings.discourse_math_enable_asciimath; enableMathEnvs = siteSettings.discourse_math_process_tex_environments; inlineDelimiters = siteSettings.discourse_math_inline_delimiters; blockDelimiters = siteSettings.discourse_math_block_delimiters; + texRenderer = siteSettings.discourse_math_provider; }); helper.registerPlugin(md => { - if (enableAsciiMath) { + let mathjax = texRenderer === 'mathjax'; + if (enableAsciiMath && mathjax) { md.inline.ruler.after("escape", "asciimath", inlineMath('%', '%')); } - if (enableMathEnvs) { + if (enableMathEnvs && mathjax) { md.block.ruler.after("code", "math", blockMath(/\\begin\{([a-z]+)\}/, /\\end\{([a-z]+)\}/), { alt: ["paragraph", "reference", "blockquote", "list"] @@ -205,7 +214,7 @@ export function setup(helper) { } let startDelim = delims[0].trim(); let endDelim = delims[1].trim(); - md.inline.ruler.after("escape", "math", inlineMath(startDelim, endDelim)); + md.inline.ruler.before("escape", "math", inlineMath(startDelim, endDelim)); }); blockDelimiters.split('|').forEach(d => { From 1afa262eece6a3f547664a70aa275f22cee846ef Mon Sep 17 00:00:00 2001 From: Daniel Hollas Date: Tue, 10 Mar 2020 21:29:46 +0100 Subject: [PATCH 4/4] Whoops, this is needed apparently, don't know why --- assets/javascripts/lib/discourse-markdown/discourse-math.js.es6 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/javascripts/lib/discourse-markdown/discourse-math.js.es6 b/assets/javascripts/lib/discourse-markdown/discourse-math.js.es6 index fce1402..cd3ffdf 100644 --- a/assets/javascripts/lib/discourse-markdown/discourse-math.js.es6 +++ b/assets/javascripts/lib/discourse-markdown/discourse-math.js.es6 @@ -72,7 +72,7 @@ let inlineMath = (startDelimiter, endDelimiter) => (state, silent) => { if (found + endDelimiter.length <= posMax) { let next = state.src[found + endDelimiter.length]; - if (!isSafeBoundary(next, endDelimiter)) { + if (next && !isSafeBoundary(next, endDelimiter)) { return false; } }