Commit 2d170a20 authored by Munken's avatar Munken

Render math in Asciidoc and Markdown with KaTeX using code blocks

parent e3f5c4c5
...@@ -57,7 +57,7 @@ ...@@ -57,7 +57,7 @@
content: this.editor.getValue() content: this.editor.getValue()
}, function(response) { }, function(response) {
currentPane.empty().append(response); currentPane.empty().append(response);
return currentPane.syntaxHighlight(); return currentPane.renderGFM();
}); });
} else { } else {
this.$toggleButton.show(); this.$toggleButton.show();
......
...@@ -305,7 +305,7 @@ ...@@ -305,7 +305,7 @@
} }
row = form.closest("tr"); row = form.closest("tr");
note_html = $(note.html); note_html = $(note.html);
note_html.syntaxHighlight(); note_html.renderGFM();
// is this the first note of discussion? // is this the first note of discussion?
discussionContainer = $(".notes[data-discussion-id='" + note.discussion_id + "']"); discussionContainer = $(".notes[data-discussion-id='" + note.discussion_id + "']");
if ((note.original_discussion_id != null) && discussionContainer.length === 0) { if ((note.original_discussion_id != null) && discussionContainer.length === 0) {
...@@ -322,7 +322,7 @@ ...@@ -322,7 +322,7 @@
discussionContainer.append(note_html); discussionContainer.append(note_html);
// Init discussion on 'Discussion' page if it is merge request page // Init discussion on 'Discussion' page if it is merge request page
if ($('body').attr('data-page').indexOf('projects:merge_request') === 0) { 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 { } else {
// append new note to all matching discussions // append new note to all matching discussions
...@@ -463,7 +463,7 @@ ...@@ -463,7 +463,7 @@
// Convert returned HTML to a jQuery object so we can modify it further // Convert returned HTML to a jQuery object so we can modify it further
$html = $(note.html); $html = $(note.html);
gl.utils.localTimeAgo($('.js-timeago', $html)); gl.utils.localTimeAgo($('.js-timeago', $html));
$html.syntaxHighlight(); $html.renderGFM();
$html.find('.js-task-list-container').taskList('enable'); $html.find('.js-task-list-container').taskList('enable');
// Find the note's `li` element by ID and replace it with the updated HTML // Find the note's `li` element by ID and replace it with the updated HTML
$note_li = $('.note-row-' + note.id); $note_li = $('.note-row-' + note.id);
......
...@@ -27,7 +27,7 @@ ...@@ -27,7 +27,7 @@
return this.renderMarkdown(mdText, (function(_this) { return this.renderMarkdown(mdText, (function(_this) {
return function(response) { return function(response) {
preview.html(response.body); preview.html(response.body);
preview.syntaxHighlight(); preview.renderGFM();
return _this.renderReferencedUsers(response.references.users, form); return _this.renderReferencedUsers(response.references.users, form);
}; };
})(this)); })(this));
......
/* 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);
/* 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);
...@@ -9,57 +9,10 @@ ...@@ -9,57 +9,10 @@
// <div class="js-syntax-highlight"></div> // <div class="js-syntax-highlight"></div>
// //
(function() { (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() { $.fn.syntaxHighlight = function() {
var $children; var $children;
handleMath();
if ($(this).hasClass('js-syntax-highlight')) { if ($(this).hasClass('js-syntax-highlight')) {
// Given the element itself, apply highlighting // Given the element itself, apply highlighting
return $(this).addClass(gon.user_color_scheme); return $(this).addClass(gon.user_color_scheme);
...@@ -72,8 +25,4 @@ ...@@ -72,8 +25,4 @@
} }
}; };
$(document).on('ready page:load', function() {
return $('.js-syntax-highlight').syntaxHighlight();
});
}).call(this); }).call(this);
---
title: Added support for math rendering, using KaTeX, in Markdown and asciidoc
merge_request: 8003
author: Munken
...@@ -84,6 +84,8 @@ module Gitlab ...@@ -84,6 +84,8 @@ module Gitlab
config.assets.precompile << "print.css" config.assets.precompile << "print.css"
config.assets.precompile << "notify.css" config.assets.precompile << "notify.css"
config.assets.precompile << "mailers/*.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 << "graphs/graphs_bundle.js"
config.assets.precompile << "users/users_bundle.js" config.assets.precompile << "users/users_bundle.js"
config.assets.precompile << "network/network_bundle.js" config.assets.precompile << "network/network_bundle.js"
......
# Touch the lexers so it is registered with Rouge
Rouge::Lexers::Math
...@@ -319,6 +319,40 @@ Here's a sample video: ...@@ -319,6 +319,40 @@ Here's a sample video:
![Sample Video](img/markdown_video.mp4) ![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 ## Standard Markdown
### Headers ### Headers
...@@ -764,3 +798,6 @@ A link starting with a `/` is relative to the wiki root. ...@@ -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 [markdown.md]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/user/markdown.md
[rouge]: http://rouge.jneen.net/ "Rouge website" [rouge]: http://rouge.jneen.net/ "Rouge website"
[redcarpet]: https://github.com/vmg/redcarpet "Redcarpet 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"
\ No newline at end of file
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
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
...@@ -48,9 +48,6 @@ module Banzai ...@@ -48,9 +48,6 @@ module Banzai
end end
def lexer_for(language) def lexer_for(language)
if language == 'math'
return Rouge::Lexers::Math.new
end
(Rouge::Lexer.find(language) || Rouge::Lexers::PlainText).new (Rouge::Lexer.find(language) || Rouge::Lexers::PlainText).new
end end
......
...@@ -6,7 +6,7 @@ module Banzai ...@@ -6,7 +6,7 @@ module Banzai
Filter::SyntaxHighlightFilter, Filter::SyntaxHighlightFilter,
Filter::SanitizationFilter, Filter::SanitizationFilter,
Filter::InlineMathFilter, Filter::MathFilter,
Filter::UploadLinkFilter, Filter::UploadLinkFilter,
Filter::VideoLinkFilter, Filter::VideoLinkFilter,
Filter::ImageLinkFilter, Filter::ImageLinkFilter,
......
require 'asciidoctor' require 'asciidoctor'
require 'asciidoctor/converter/html5'
module Gitlab module Gitlab
# Parser/renderer for the AsciiDoc format that uses Asciidoctor and filters # Parser/renderer for the AsciiDoc format that uses Asciidoctor and filters
...@@ -23,7 +24,7 @@ module Gitlab ...@@ -23,7 +24,7 @@ module Gitlab
def self.render(input, context, asciidoc_opts = {}) def self.render(input, context, asciidoc_opts = {})
asciidoc_opts.reverse_merge!( asciidoc_opts.reverse_merge!(
safe: :secure, safe: :secure,
backend: :html5, backend: :gitlab_html5,
attributes: [] attributes: []
) )
asciidoc_opts[:attributes].unshift(*DEFAULT_ADOC_ATTRS) asciidoc_opts[:attributes].unshift(*DEFAULT_ADOC_ATTRS)
...@@ -36,3 +37,31 @@ module Gitlab ...@@ -36,3 +37,31 @@ module Gitlab
end end
end 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
...@@ -8,6 +8,8 @@ module Gitlab ...@@ -8,6 +8,8 @@ module Gitlab
gon.shortcuts_path = help_page_path('shortcuts') gon.shortcuts_path = help_page_path('shortcuts')
gon.user_color_scheme = Gitlab::ColorSchemes.for_user(current_user).css_class gon.user_color_scheme = Gitlab::ColorSchemes.for_user(current_user).css_class
gon.award_menu_url = emojis_path 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 if current_user
gon.current_user_id = current_user.id gon.current_user_id = current_user.id
......
module Rouge module Rouge
module Lexers module Lexers
class Math < Lexer class Math < Lexer
title "Plain Text" title "A passthrough lexer used for LaTeX input"
desc "A boring lexer that doesn't highlight anything" desc "A boring lexer that doesn't highlight anything"
tag 'math' tag 'math'
mimetypes 'text/plain' mimetypes 'text/plain'
default_options :token => 'Text' default_options token: 'Text'
def token def token
@token ||= Token[option :token] @token ||= Token[option :token]
......
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
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
...@@ -11,7 +11,7 @@ module Gitlab ...@@ -11,7 +11,7 @@ module Gitlab
it "converts the input using Asciidoctor and default options" do it "converts the input using Asciidoctor and default options" do
expected_asciidoc_opts = { expected_asciidoc_opts = {
safe: :secure, safe: :secure,
backend: :html5, backend: :gitlab_html5,
attributes: described_class::DEFAULT_ADOC_ATTRS attributes: described_class::DEFAULT_ADOC_ATTRS
} }
...@@ -27,7 +27,7 @@ module Gitlab ...@@ -27,7 +27,7 @@ module Gitlab
it "merges the options with default ones" do it "merges the options with default ones" do
expected_asciidoc_opts = { expected_asciidoc_opts = {
safe: :safe, safe: :safe,
backend: :html5, backend: :gitlab_html5,
attributes: described_class::DEFAULT_ADOC_ATTRS + ['foo'] attributes: described_class::DEFAULT_ADOC_ATTRS + ['foo']
} }
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment