Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ end
group :docs do
gem 'image_optim_pack', platforms: :ruby
gem 'image_optim'
gem 'katex', '~> 0.11', require: false
gem 'kramdown'
gem 'kramdown-parser-gfm'
gem 'redcarpet'
Expand Down
4 changes: 4 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ GEM
rdoc (>= 4.0.0)
reline (>= 0.4.2)
json (2.19.4)
katex (0.11.0)
execjs (~> 2.8)
kramdown (2.5.2)
rexml (>= 3.4.4)
kramdown-parser-gfm (1.1.0)
Expand Down Expand Up @@ -315,6 +317,7 @@ DEPENDENCIES
html-pipeline (~> 2.14)
image_optim
image_optim_pack
katex (~> 0.11)
kramdown
kramdown-parser-gfm
minitest
Expand Down Expand Up @@ -396,6 +399,7 @@ CHECKSUMS
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
json (2.19.4) sha256=670a7d333fb3b18ca5b29cb255eb7bef099e40d88c02c80bd42a3f30fe5239ac
katex (0.11.0) sha256=6f9fa9a13e2ba616a0f8d783cc9b30979f0eefb0f5f1e04e66fe7dd8eb682e78
kramdown (2.5.2) sha256=1ba542204c66b6f9111ff00dcc26075b95b220b07f2905d8261740c82f7f02fa
kramdown-parser-gfm (1.1.0) sha256=fb39745516427d2988543bf01fc4cf0ab1149476382393e0e9c48592f6581729
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ Modifications made to each document include:
* replacing all internal (scraped) URLs with their unqualified and relative counterpart
* adding content, such as a title and link to the original document
* ensuring correct syntax highlighting using [Prism](http://prismjs.com/)
* converting math markup to native MathML using [KaTeX](https://katex.org/)

These modifications are applied via a set of filters using the [HTML::Pipeline](https://github.com/jch/html-pipeline) library. Each scraper includes filters specific to itself, one of which is tasked with figuring out the pages' metadata.

Expand Down
3 changes: 3 additions & 0 deletions assets/stylesheets/global/_base.scss
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,9 @@ table {
max-width: 100%;
}

// No overflow rule here: a scroll container on a <math> element leaves it blank in Chrome.
math[display="block"] { margin: 1.5em 0; }

caption {
font-weight: var(--boldFontWeight);
padding: 0 .7em .3em;
Expand Down
1 change: 1 addition & 0 deletions docs/filter-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ The `call` method must return either `doc` or `html`, depending on the type of f
## Core filters

* [`ContainerFilter`](https://github.com/freeCodeCamp/devdocs/blob/main/lib/docs/filters/core/container.rb) — changes the root node of the document (remove everything outside)
* [`MathFilter`](https://github.com/freeCodeCamp/devdocs/blob/main/lib/docs/filters/core/math.rb) — converts math markup (Sphinx/MathJax `\(...\)` and `\[...\]`, MathJax `<script type="math/tex">` and `<mjx-container>`, pre-rendered KaTeX) to native MathML using [KaTeX](https://katex.org/), which requires a JavaScript runtime such as Node.js; set `options[:math_dollars] = true` to also convert `$...$` and `$$...$$` in text
* [`CleanHtmlFilter`](https://github.com/freeCodeCamp/devdocs/blob/main/lib/docs/filters/core/clean_html.rb) — removes HTML comments, `<script>`, `<style>`, etc.
* [`NormalizeUrlsFilter`](https://github.com/freeCodeCamp/devdocs/blob/main/lib/docs/filters/core/normalize_urls.rb) — replaces all URLs with their fully qualified counterpart
* [`InternalUrlsFilter`](https://github.com/freeCodeCamp/devdocs/blob/main/lib/docs/filters/core/internal_urls.rb) — detects internal URLs (the ones to scrape) and replaces them with their unqualified, relative counterpart
Expand Down
94 changes: 94 additions & 0 deletions lib/docs/core/math_renderer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# frozen_string_literal: true

require 'execjs'
require 'katex'

module Docs
module MathRenderer
class Error < StandardError; end

class RuntimeUnavailable < SetupError
def initialize
super "Rendering math requires a JavaScript runtime (e.g. Node.js) to be installed. " \
"See https://github.com/rails/execjs#readme for the list of supported runtimes."
end
end

# Options passed to katex.renderToString (https://katex.org/docs/options).
OPTIONS = {
output: 'mathml',
throwOnError: true,
strict: false, # don't warn about non-strict LaTeX like Unicode text
trust: false
}.freeze

BATCH_JS = <<~JS
function renderMathBatch(items, options) {
return items.map(function(item) {
try {
return { html: katex.renderToString(item.tex, Object.assign({ displayMode: item.display }, options)) };
} catch (error) {
return { error: String(error.message || error) };
}
});
}
JS

MATH_RGX = /<math\b.*<\/math>/m

# MathJax tolerates bare underscores in text mode (e.g. \text{log_loss}),
# which KaTeX rejects, so escape them before rendering.
TEXT_COMMAND_RGX = /\\text(?:rm|bf|it|sf|tt|normal)?\{[^{}]*\}/
BARE_UNDERSCORE_RGX = /(?<!\\)_/

class << self
# Renders a single expression to a <math> element.
def to_mathml(tex, display: false)
result = render_all([[tex, display]]).first
raise result[:error] if result[:error]
result[:mathml]
end

# Renders many expressions in a single JavaScript call.
def render_all(items)
return [] if items.empty?

payload = items.map { |tex, display| { tex: normalize(tex.to_s), display: display ? true : false } }
context.call('renderMathBatch', payload, OPTIONS).map do |result|
if result['error']
{ error: Error.new(result['error']) }
else
{ mathml: result['html'][MATH_RGX] }
end
end
rescue ExecJS::RuntimeUnavailable
raise RuntimeUnavailable
end

def render(tex, display: false)
to_mathml(tex, display: display)
rescue Error
fallback(tex, display: display)
end

def normalize(tex)
tex.gsub(TEXT_COMMAND_RGX) { |text| text.gsub(BARE_UNDERSCORE_RGX, '\\_') }
end

def fallback(tex, display: false)
tag = display ? 'pre' : 'code'
%(<#{tag} class="_math-fallback">#{CGI.escapeHTML(tex)}</#{tag}>)
end

def context
@context ||= ExecJS.compile(File.read(Katex.katex_js_path) + BATCH_JS)
rescue ExecJS::RuntimeUnavailable
raise RuntimeUnavailable
end

def reset!
@context = nil
end
end
end
end
2 changes: 1 addition & 1 deletion lib/docs/core/scraper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def stub(path, &block)
self.html_filters = FilterStack.new
self.text_filters = FilterStack.new

html_filters.push 'apply_base_url', 'container', 'clean_html', 'normalize_urls', 'internal_urls', 'normalize_paths', 'parse_cf_email'
html_filters.push 'apply_base_url', 'container', 'math', 'clean_html', 'normalize_urls', 'internal_urls', 'normalize_paths', 'parse_cf_email'
text_filters.push 'images' # ensure the images filter runs after all html filters
text_filters.push 'inner_html', 'clean_text', 'attribution'

Expand Down
2 changes: 1 addition & 1 deletion lib/docs/filters/core/clean_text.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

module Docs
class CleanTextFilter < Filter
EMPTY_NODES_RGX = /<(?!td|th|iframe|mspace|rect|path|ellipse|line|polyline)(\w+)[^>]*>[[:space:]]*<\/\1>/
EMPTY_NODES_RGX = /<(?!td|th|iframe|mspace|mtext|mtd|mrow|mphantom|rect|path|ellipse|line|polyline)(\w+)[^>]*>[[:space:]]*<\/\1>/

def call
return html if context[:clean_text] == false
Expand Down
130 changes: 130 additions & 0 deletions lib/docs/filters/core/math.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# frozen_string_literal: true

module Docs
# Replaces the math markup emitted by documentation generators with native
# MathML, rendered at scrape time by MathRenderer (KaTeX).
#
# Only explicit markup is recognized:
# - Sphinx/MathJax: <span class="math">\(...\)</span> and <div class="math">\[...\]</div>
# - MathJax 2: <script type="math/tex">...</script> and type="math/tex; mode=display"
# - MathJax 3: <mjx-container> (keeps its assistive MathML, if any)
# - KaTeX: pre-rendered <span class="katex"> nodes (re-rendered from their TeX source)
# - Dollar delimiters ($...$ and $$...$$) in text, only when context[:math_dollars] is true
# Existing <math> elements are left untouched.
#
# This filter must run before CleanHtmlFilter, which removes <script> nodes.
class MathFilter < Filter
TEX_DELIMITERS_RGX = /\\\((.*?)\\\)|\\\[(.*?)\\\]/m
DOLLARS_RGX = /\$\$(?<display>[^$]+?)\$\$|(?<![\\$\w])\$(?<inline>[^\s$](?:[^$\n]*?[^\s$\\])?)\$(?![\d$])/
TEX_ANNOTATION = 'annotation[encoding="application/x-tex"]'

def call
@items = []
extract_sphinx
extract_mathjax2
extract_mathjax3
extract_katex
extract_dollars if context[:math_dollars]
render
doc
end

private

def extract_sphinx
css('span.math', 'div.math').each do |node|
node.css('.eqno').remove
content = node.content
next unless content =~ TEX_DELIMITERS_RGX

pieces = []
last = 0
content.scan(TEX_DELIMITERS_RGX) do
match = Regexp.last_match
pieces << match.pre_match[last..] unless match.begin(0) == last
pieces << [match[1] || match[2], !match[1]]
last = match.end(0)
end
pieces << content[last..] if last < content.length

queue(node, pieces, id: node['id'])
end
end

def extract_mathjax2
css('.MathJax_Preview').remove
css('script[type^="math/tex"]').each do |node|
display = node['type'].include?('mode=display')
queue(node, [[node.content, display]])
end
end

def extract_mathjax3
css('mjx-container').each do |node|
math = node.at_css('mjx-assistive-mml > math')
next unless math
math['display'] = 'block' if node['display'] == 'true'
node.replace(math)
end
end

def extract_katex
css('.katex').each do |node|
wrapper = node.parent if node.parent.try(:[], 'class').to_s.split.include?('katex-display')
target = wrapper || node
annotation = node.at_css(TEX_ANNOTATION)
math = node.at_css('math')
display = !wrapper.nil? || (math && math['display'] == 'block')

if annotation
queue(target, [[annotation.content, display]])
elsif math
target.replace(math)
end
end
end

def extract_dollars
xpath('.//text()[not(ancestor::pre or ancestor::code or ancestor::script or ancestor::style or ancestor::math)]').each do |node|
content = node.content
next unless content =~ DOLLARS_RGX

pieces = []
last = 0
content.scan(DOLLARS_RGX) do
match = Regexp.last_match
pieces << match.pre_match[last..] unless match.begin(0) == last
pieces << [match[:display] || match[:inline], !match[:display].nil?]
last = match.end(0)
end
pieces << content[last..] if last < content.length

queue(node, pieces)
end
end

def queue(node, pieces, id: nil)
@items << [node, pieces, id]
end

def render
expressions = @items.flat_map { |_, pieces, _| pieces.grep(Array) }
return if expressions.empty?

rendered = MathRenderer.render_all(expressions)

@items.each do |node, pieces, id|
html = pieces.map do |piece|
next CGI.escapeHTML(piece) if piece.is_a?(String)
tex, display = piece
result = rendered.shift
result[:mathml] || MathRenderer.fallback(tex, display: display)
end.join

fragment = doc.document.fragment(html)
fragment.at_css('math')['id'] = id if id && fragment.at_css('math')
node.replace(fragment)
end
end
end
end
2 changes: 0 additions & 2 deletions lib/docs/filters/pytorch/clean_html.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ class CleanHtmlFilter < Filter
def call
if root = at_css('#pytorch-article')
@doc = root
# Show katex-mathml nodes and remove katex-html nodes
css('.katex-html').remove
end
doc
end
Expand Down
74 changes: 74 additions & 0 deletions test/lib/docs/core/math_renderer_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
require_relative '../../../test_helper'
require_relative '../../../../lib/docs'

class DocsMathRendererTest < Minitest::Spec
let(:renderer) { Docs::MathRenderer }

describe ".to_mathml" do
it "returns a <math> element" do
output = renderer.to_mathml('x^2')
assert_match %r{\A<math [^>]*>.*</math>\z}m, output
refute_includes output, 'class="katex"'
end

it "keeps the TeX source as an annotation" do
assert_includes renderer.to_mathml('x \in \mathbb{R}'),
'<annotation encoding="application/x-tex">x \in \mathbb{R}</annotation>'
end

it "renders inline math by default" do
refute_includes renderer.to_mathml('x^2'), 'display="block"'
end

it "renders display math when display is true" do
assert_includes renderer.to_mathml('x^2', display: true), '<math xmlns="http://www.w3.org/1998/Math/MathML" display="block">'
end

it "accepts bare underscores in text mode, like MathJax does" do
output = renderer.to_mathml('\text{log_loss}(y) + n_{\text{nonzero\_coefs}}')
assert_includes output, '<mtext>log_loss</mtext>'
assert_includes output, '<mtext>nonzero_coefs</mtext>'
end

it "raises an Error when the TeX can't be parsed" do
error = assert_raises(Docs::MathRenderer::Error) { renderer.to_mathml('\frac{a') }
assert_includes error.message, 'KaTeX parse error'
end

it "raises a SetupError when no JavaScript runtime is available" do
renderer.reset!
stub(ExecJS).compile { raise ExecJS::RuntimeUnavailable }
assert_raises(Docs::SetupError) { renderer.to_mathml('x') }
renderer.reset!
end
end

describe ".render_all" do
it "renders many expressions at once, in order" do
results = renderer.render_all([['a', false], ['\frac{', true], ['b', true]])
assert_equal 3, results.length
assert_includes results[0][:mathml], '<mi>a</mi>'
assert_kind_of Docs::MathRenderer::Error, results[1][:error]
assert_includes results[2][:mathml], 'display="block"'
assert_includes results[2][:mathml], '<mi>b</mi>'
end

it "returns an empty array when given nothing" do
assert_equal [], renderer.render_all([])
end
end

describe ".render" do
it "returns MathML for valid TeX" do
assert_match %r{\A<math}, renderer.render('x')
end

it "falls back to the escaped TeX source for invalid inline TeX" do
assert_equal '<code class="_math-fallback">\frac{a &lt; b</code>', renderer.render('\frac{a < b')
end

it "falls back to a <pre> for invalid display TeX" do
assert_equal '<pre class="_math-fallback">\frac{a</pre>', renderer.render('\frac{a', display: true)
end
end
end
5 changes: 5 additions & 0 deletions test/lib/docs/filters/core/clean_text_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ class CleanTextFilterTest < Minitest::Spec
assert_equal @body, filter_output
end

it "doesn't remove empty MathML nodes" do
@body = "<mspace width=\"1em\"></mspace><mtext> </mtext><mtd></mtd><mrow></mrow>"
assert_equal @body, filter_output
end

it "strips leading and trailing whitespace" do
@body = "\n\r Test \r\n"
assert_equal 'Test', filter_output
Expand Down
Loading