Render math in Asciidoc and Markdown with KaTeX using code blocks

This commit is contained in:
Munken 2016-12-09 00:15:08 +00:00
parent e3f5c4c5f6
commit 2d170a20dc
21 changed files with 358 additions and 166 deletions

View file

@ -57,7 +57,7 @@
content: this.editor.getValue()
}, function(response) {
currentPane.empty().append(response);
return currentPane.syntaxHighlight();
return currentPane.renderGFM();
});
} else {
this.$toggleButton.show();

View file

@ -305,7 +305,7 @@
}
row = form.closest("tr");
note_html = $(note.html);
note_html.syntaxHighlight();
note_html.renderGFM();
// is this the first note of discussion?
discussionContainer = $(".notes[data-discussion-id='" + note.discussion_id + "']");
if ((note.original_discussion_id != null) && discussionContainer.length === 0) {
@ -322,7 +322,7 @@
discussionContainer.append(note_html);
// Init discussion on 'Discussion' page if it is merge request page
if ($('body').attr('data-page').indexOf('projects:merge_request') === 0) {
$('ul.main-notes-list').append(note.discussion_html).syntaxHighlight();
$('ul.main-notes-list').append(note.discussion_html).renderGFM();
}
} else {
// append new note to all matching discussions
@ -463,7 +463,7 @@
// Convert returned HTML to a jQuery object so we can modify it further
$html = $(note.html);
gl.utils.localTimeAgo($('.js-timeago', $html));
$html.syntaxHighlight();
$html.renderGFM();
$html.find('.js-task-list-container').taskList('enable');
// Find the note's `li` element by ID and replace it with the updated HTML
$note_li = $('.note-row-' + note.id);

View file

@ -27,7 +27,7 @@
return this.renderMarkdown(mdText, (function(_this) {
return function(response) {
preview.html(response.body);
preview.syntaxHighlight();
preview.renderGFM();
return _this.renderReferencedUsers(response.references.users, form);
};
})(this));

View file

@ -0,0 +1,16 @@
/* eslint-disable func-names, space-before-function-paren, consistent-return, no-var, no-undef, no-else-return, prefer-arrow-callback, padded-blocks, max-len */
// Render Gitlab flavoured Markdown
//
// Delegates to syntax highlight and render math
//
(function() {
$.fn.renderGFM = function() {
this.find('.js-syntax-highlight').syntaxHighlight();
this.find('.js-render-math').renderMath();
};
$(document).on('ready page:load', function() {
return $('body').renderGFM();
});
}).call(this);

View file

@ -0,0 +1,55 @@
/* eslint-disable func-names, space-before-function-paren, consistent-return, no-var, no-undef, no-else-return, prefer-arrow-callback, padded-blocks, max-len */
// Renders math using KaTeX in any element with the
// `js-render-math` class
//
// ### Example Markup
//
// <code class="js-render-math"></div>
//
(function() {
// Only load once
var katexLoaded = false;
// Loop over all math elements and render math
var renderWithKaTeX = function (elements) {
elements.each(function () {
var mathNode = $('<span></span>');
var $this = $(this);
var display = $this.attr('data-math-style') === 'display';
try {
katex.render($this.text(), mathNode.get(0), { displayMode: display });
mathNode.insertAfter($this);
$this.remove();
} catch (err) {
// What can we do??
console.log(err.message);
}
});
};
$.fn.renderMath = function() {
var $this = this;
if ($this.length === 0) return;
if (katexLoaded) renderWithKaTeX($this);
else {
// Request CSS file so it is in the cache
$.get(gon.katex_css_url, function() {
var css = $('<link>',
{ rel: 'stylesheet',
type: 'text/css',
href: gon.katex_css_url,
});
css.appendTo('head');
// Load KaTeX js
$.getScript(gon.katex_js_url, function() {
katexLoaded = true;
renderWithKaTeX($this); // Run KaTeX
});
});
}
};
}).call(this);

View file

@ -0,0 +1,28 @@
/* eslint-disable func-names, space-before-function-paren, consistent-return, no-var, no-undef, no-else-return, prefer-arrow-callback, padded-blocks, max-len */
// Syntax Highlighter
//
// Applies a syntax highlighting color scheme CSS class to any element with the
// `js-syntax-highlight` class
//
// ### Example Markup
//
// <div class="js-syntax-highlight"></div>
//
(function() {
$.fn.syntaxHighlight = function() {
var $children;
if ($(this).hasClass('js-syntax-highlight')) {
// Given the element itself, apply highlighting
return $(this).addClass(gon.user_color_scheme);
} else {
// Given a parent element, recurse to any of its applicable children
$children = $(this).find('.js-syntax-highlight');
if ($children.length) {
return $children.syntaxHighlight();
}
}
};
}).call(this);

View file

@ -1,79 +0,0 @@
/* eslint-disable func-names, space-before-function-paren, consistent-return, no-var, no-undef, no-else-return, prefer-arrow-callback, padded-blocks, max-len */
// Syntax Highlighter
//
// Applies a syntax highlighting color scheme CSS class to any element with the
// `js-syntax-highlight` class
//
// ### Example Markup
//
// <div class="js-syntax-highlight"></div>
//
(function() {
// CSS and JS for KaTeX
CSS_PATH = "<%= asset_path('katex.css') %>";
JS_PATH = "<%= asset_path('katex.js') %>";
// Only load once
var katexLoaded = false;
// Loop over all math elements and render math
var renderWithKaTeX = function (elements) {
elements.each(function () {
if (!!$(this).attr('rendered')) return;
$(this).attr('rendered', true);
$(this).hide();
var mathNode = $( "<math>Test</math>" );
mathNode.insertAfter($(this));
var display = $(this).hasClass('highlight');
katex.render($(this).text(), mathNode.get(0), { displayMode: display })
})
};
var handleMath = function () {
var mathElements = $('.code.math');
if (mathElements.length == 0) return;
if (katexLoaded) renderWithKaTeX(mathElements);
else {
// Request CSS file so it is in the cache
$.get(CSS_PATH, function(){
var css = $('<link>',
{rel:'stylesheet',
type:'text/css',
href: CSS_PATH
});
css.appendTo('head');
// Load KaTeX js
$.getScript(JS_PATH, function() {
katexLoaded = true;
renderWithKaTeX(mathElements); // Run KaTeX
})
});
}
};
$.fn.syntaxHighlight = function() {
var $children;
handleMath();
if ($(this).hasClass('js-syntax-highlight')) {
// Given the element itself, apply highlighting
return $(this).addClass(gon.user_color_scheme);
} else {
// Given a parent element, recurse to any of its applicable children
$children = $(this).find('.js-syntax-highlight');
if ($children.length) {
return $children.syntaxHighlight();
}
}
};
$(document).on('ready page:load', function() {
return $('.js-syntax-highlight').syntaxHighlight();
});
}).call(this);

View file

@ -0,0 +1,4 @@
---
title: Added support for math rendering, using KaTeX, in Markdown and asciidoc
merge_request: 8003
author: Munken

View file

@ -84,6 +84,8 @@ module Gitlab
config.assets.precompile << "print.css"
config.assets.precompile << "notify.css"
config.assets.precompile << "mailers/*.css"
config.assets.precompile << "katex.css"
config.assets.precompile << "katex.js"
config.assets.precompile << "graphs/graphs_bundle.js"
config.assets.precompile << "users/users_bundle.js"
config.assets.precompile << "network/network_bundle.js"

View file

@ -0,0 +1,2 @@
# Touch the lexers so it is registered with Rouge
Rouge::Lexers::Math

View file

@ -319,6 +319,40 @@ Here's a sample video:
![Sample Video](img/markdown_video.mp4)
### Math
> If this is not rendered correctly, see
https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md#math
It is possible to have math written with the LaTeX syntax rendered using [KaTeX][katex].
Math written inside ```$``$``` will be rendered inline with the text.
Math written inside triple back quotes, with the language declared as `math`, will be rendered on a separate line.
Example:
This math is inline $`a^2+b^2=c^2`$.
This is on a separate line
```math
a^2+b^2=c^2
```
Becomes:
This math is inline $`a^2+b^2=c^2`$.
This is on a separate line
```math
a^2+b^2=c^2
```
_Be advised that KaTeX only supports a [subset][katex-subset] of LaTeX._
>**Note:**
This also works for the asciidoctor `:stem: latexmath`. For details see the [asciidoctor user manual][asciidoctor-manual].
## Standard Markdown
### Headers
@ -764,3 +798,6 @@ A link starting with a `/` is relative to the wiki root.
[markdown.md]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md
[rouge]: http://rouge.jneen.net/ "Rouge website"
[redcarpet]: https://github.com/vmg/redcarpet "Redcarpet website"
[katex]: https://github.com/Khan/KaTeX "KaTeX website"
[katex-subset]: https://github.com/Khan/KaTeX/wiki/Function-Support-in-KaTeX "Macros supported by KaTeX"
[asciidoctor-manual]: http://asciidoctor.org/docs/user-manual/#activating-stem-support "Asciidoctor user manual"

View file

@ -1,27 +0,0 @@
require 'uri'
module Banzai
module Filter
# HTML filter that adds class="code math" and removes the dolar sign in $`2+2`$.
#
class InlineMathFilter < HTML::Pipeline::Filter
def call
doc.xpath("descendant-or-self::text()[substring(., string-length(.)) = '$']"\
"/following-sibling::*[name() = 'code']"\
"/following-sibling::text()[starts-with(.,'$')]").each do |el|
closing = el
code = el.previous
code[:class] = 'code math'
opening = code.previous
closing.content = closing.content[1..-1]
opening.content = opening.content[0..-2]
closing
end
doc
end
end
end
end

View file

@ -0,0 +1,51 @@
require 'uri'
module Banzai
module Filter
# HTML filter that adds class="code math" and removes the dollar sign in $`2+2`$.
#
class MathFilter < HTML::Pipeline::Filter
# This picks out <code>...</code>.
INLINE_MATH = 'descendant-or-self::code'.freeze
# Pick out a code block which is declared math
DISPLAY_MATH = "descendant-or-self::pre[contains(@class, 'math') and contains(@class, 'code')]".freeze
# Attribute indicating inline or display math.
STYLE_ATTRIBUTE = 'data-math-style'.freeze
# Class used for tagging elements that should be rendered
TAG_CLASS = 'js-render-math'.freeze
INLINE_CLASSES = "code math #{TAG_CLASS}".freeze
DOLLAR_SIGN = '$'.freeze
def call
doc.xpath(INLINE_MATH).each do |code|
closing = code.next
opening = code.previous
# We need a sibling before and after.
# They should end and start with $ respectively.
if closing && opening &&
closing.content.first == DOLLAR_SIGN &&
opening.content.last == DOLLAR_SIGN
code[:class] = INLINE_CLASSES
code[STYLE_ATTRIBUTE] = 'inline'
closing.content = closing.content[1..-1]
opening.content = opening.content[0..-2]
end
end
doc.xpath(DISPLAY_MATH).each do |el|
el[STYLE_ATTRIBUTE] = 'display'
el[:class] += " #{TAG_CLASS}"
end
doc
end
end
end
end

View file

@ -48,9 +48,6 @@ module Banzai
end
def lexer_for(language)
if language == 'math'
return Rouge::Lexers::Math.new
end
(Rouge::Lexer.find(language) || Rouge::Lexers::PlainText).new
end

View file

@ -6,7 +6,7 @@ module Banzai
Filter::SyntaxHighlightFilter,
Filter::SanitizationFilter,
Filter::InlineMathFilter,
Filter::MathFilter,
Filter::UploadLinkFilter,
Filter::VideoLinkFilter,
Filter::ImageLinkFilter,

View file

@ -1,4 +1,5 @@
require 'asciidoctor'
require 'asciidoctor/converter/html5'
module Gitlab
# Parser/renderer for the AsciiDoc format that uses Asciidoctor and filters
@ -23,7 +24,7 @@ module Gitlab
def self.render(input, context, asciidoc_opts = {})
asciidoc_opts.reverse_merge!(
safe: :secure,
backend: :html5,
backend: :gitlab_html5,
attributes: []
)
asciidoc_opts[:attributes].unshift(*DEFAULT_ADOC_ATTRS)
@ -36,3 +37,31 @@ module Gitlab
end
end
end
module Gitlab
module Asciidoc
class Html5Converter < Asciidoctor::Converter::Html5Converter
extend Asciidoctor::Converter::Config
register_for 'gitlab_html5'
def stem(node)
return super unless node.style.to_sym == :latexmath
%(<pre#{id_attribute(node)} class="code math js-render-math #{node.role}" data-math-style="display"><code>#{node.content}</code></pre>)
end
def inline_quoted(node)
return super unless node.type.to_sym == :latexmath
%(<code#{id_attribute(node)} class="code math js-render-math #{node.role}" data-math-style="inline">#{node.text}</code>)
end
private
def id_attribute(node)
node.id ? %( id="#{node.id}") : nil
end
end
end
end

View file

@ -8,6 +8,8 @@ module Gitlab
gon.shortcuts_path = help_page_path('shortcuts')
gon.user_color_scheme = Gitlab::ColorSchemes.for_user(current_user).css_class
gon.award_menu_url = emojis_path
gon.katex_css_url = ActionController::Base.helpers.asset_path('katex.css')
gon.katex_js_url = ActionController::Base.helpers.asset_path('katex.js')
if current_user
gon.current_user_id = current_user.id

View file

@ -1,13 +1,13 @@
module Rouge
module Lexers
class Math < Lexer
title "Plain Text"
title "A passthrough lexer used for LaTeX input"
desc "A boring lexer that doesn't highlight anything"
tag 'math'
mimetypes 'text/plain'
default_options :token => 'Text'
default_options token: 'Text'
def token
@token ||= Token[option :token]
@ -18,4 +18,4 @@ module Rouge
end
end
end
end
end

View file

@ -1,45 +0,0 @@
require 'spec_helper'
describe Banzai::Filter::InlineMathFilter, lib: true do
include FilterSpecHelper
it 'leaves regular inline code unchanged' do
input = "<code>2+2</code>"
doc = filter(input)
expect(doc.to_s).to eq input
end
it 'removes surrounding dollar signs and adds class' do
doc = filter("$<code>2+2</code>$")
expect(doc.to_s).to eq '<code class="code math">2+2</code>'
end
it 'only removes surrounding dollar signs' do
doc = filter("test $<code>2+2</code>$ test")
expect(doc.to_s).to eq 'test <code class="code math">2+2</code> test'
end
it 'only removes surrounding single dollar sign' do
doc = filter("test $$<code>2+2</code>$$ test")
expect(doc.to_s).to eq 'test $<code class="code math">2+2</code>$ test'
end
it 'ignores cases with missing dolar sign at the end' do
input = "test $<code>2+2</code> test"
doc = filter(input)
expect(doc.to_s).to eq input
end
it 'ignores cases with missing dolar sign at the beginning' do
input = "test <code>2+2</code>$ test"
doc = filter(input)
expect(doc.to_s).to eq input
end
it 'ignores dollar signs if it is not adjacent' do
input = '<p>We check strictly $<code>2+2</code> and <code>2+2</code>$ </p>'
doc = filter(input)
expect(doc.to_s).to eq input
end
end

View file

@ -0,0 +1,120 @@
require 'spec_helper'
describe Banzai::Filter::MathFilter, lib: true do
include FilterSpecHelper
it 'leaves regular inline code unchanged' do
input = "<code>2+2</code>"
doc = filter(input)
expect(doc.to_s).to eq input
end
it 'removes surrounding dollar signs and adds class code, math and js-render-math' do
doc = filter("$<code>2+2</code>$")
expect(doc.to_s).to eq '<code class="code math js-render-math" data-math-style="inline">2+2</code>'
end
it 'only removes surrounding dollar signs' do
doc = filter("test $<code>2+2</code>$ test")
before = doc.xpath('descendant-or-self::text()[1]').first
after = doc.xpath('descendant-or-self::text()[3]').first
expect(before.to_s).to eq 'test '
expect(after.to_s).to eq ' test'
end
it 'only removes surrounding single dollar sign' do
doc = filter("test $$<code>2+2</code>$$ test")
before = doc.xpath('descendant-or-self::text()[1]').first
after = doc.xpath('descendant-or-self::text()[3]').first
expect(before.to_s).to eq 'test $'
expect(after.to_s).to eq '$ test'
end
it 'adds data-math-style inline attribute to inline math' do
doc = filter('$<code>2+2</code>$')
code = doc.xpath('descendant-or-self::code').first
expect(code['data-math-style']).to eq 'inline'
end
it 'adds class code and math to inline math' do
doc = filter('$<code>2+2</code>$')
code = doc.xpath('descendant-or-self::code').first
expect(code[:class]).to include("code")
expect(code[:class]).to include("math")
end
it 'adds js-render-math class to inline math' do
doc = filter('$<code>2+2</code>$')
code = doc.xpath('descendant-or-self::code').first
expect(code[:class]).to include("js-render-math")
end
# Cases with faulty syntax. Should be a no-op
it 'ignores cases with missing dolar sign at the end' do
input = "test $<code>2+2</code> test"
doc = filter(input)
expect(doc.to_s).to eq input
end
it 'ignores cases with missing dolar sign at the beginning' do
input = "test <code>2+2</code>$ test"
doc = filter(input)
expect(doc.to_s).to eq input
end
it 'ignores dollar signs if it is not adjacent' do
input = '<p>We check strictly $<code>2+2</code> and <code>2+2</code>$ </p>'
doc = filter(input)
expect(doc.to_s).to eq input
end
# Display math
it 'adds data-math-style display attribute to display math' do
doc = filter('<pre class="code highlight js-syntax-highlight math" v-pre="true"><code>2+2</code></pre>')
pre = doc.xpath('descendant-or-self::pre').first
expect(pre['data-math-style']).to eq 'display'
end
it 'adds js-render-math class to display math' do
doc = filter('<pre class="code highlight js-syntax-highlight math" v-pre="true"><code>2+2</code></pre>')
pre = doc.xpath('descendant-or-self::pre').first
expect(pre[:class]).to include("js-render-math")
end
it 'ignores code blocks that are not math' do
input = '<pre class="code highlight js-syntax-highlight plaintext" v-pre="true"><code>2+2</code></pre>'
doc = filter(input)
expect(doc.to_s).to eq input
end
it 'requires the pre to contain both code and math' do
input = '<pre class="highlight js-syntax-highlight plaintext math" v-pre="true"><code>2+2</code></pre>'
doc = filter(input)
expect(doc.to_s).to eq input
end
it 'dollar signs around to display math' do
doc = filter('$<pre class="code highlight js-syntax-highlight math" v-pre="true"><code>2+2</code></pre>$')
before = doc.xpath('descendant-or-self::text()[1]').first
after = doc.xpath('descendant-or-self::text()[3]').first
expect(before.to_s).to eq '$'
expect(after.to_s).to eq '$'
end
end

View file

@ -11,7 +11,7 @@ module Gitlab
it "converts the input using Asciidoctor and default options" do
expected_asciidoc_opts = {
safe: :secure,
backend: :html5,
backend: :gitlab_html5,
attributes: described_class::DEFAULT_ADOC_ATTRS
}
@ -27,7 +27,7 @@ module Gitlab
it "merges the options with default ones" do
expected_asciidoc_opts = {
safe: :safe,
backend: :html5,
backend: :gitlab_html5,
attributes: described_class::DEFAULT_ADOC_ATTRS + ['foo']
}