Skip to content

Commit f57fb78

Browse files
committed
Add KaTeX rendering to Markdown.
This PR adds mathematical rendering with KaTeX. The first step is to add a Goldmark extension that detects the latex (and tex) mathematics delimiters. The second step to make this extension only run if math support is enabled. The second step is to then add KaTeX CSS and JS to the head which will load after the dom is rendered. Fix #3445 Signed-off-by: Andrew Thornton <[email protected]>
1 parent ff9b6fa commit f57fb78

19 files changed

+635
-1
lines changed

custom/conf/app.example.ini

+6
Original file line numberDiff line numberDiff line change
@@ -1225,6 +1225,12 @@ ROUTER = console
12251225
;; List of file extensions that should be rendered/edited as Markdown
12261226
;; Separate the extensions with a comma. To render files without any extension as markdown, just put a comma
12271227
;FILE_EXTENSIONS = .md,.markdown,.mdown,.mkd
1228+
;;
1229+
;; Enables math inline and block detection
1230+
;ENABLE_MATH = true
1231+
;;
1232+
;; Enables in addition inline block detection using single dollars
1233+
;ENABLE_INLINE_DOLLAR_MATH = false
12281234

12291235
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
12301236
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

docs/content/doc/advanced/config-cheat-sheet.en-us.md

+2
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,8 @@ The following configuration set `Content-Type: application/vnd.android.package-a
233233
- `CUSTOM_URL_SCHEMES`: Use a comma separated list (ftp,git,svn) to indicate additional
234234
URL hyperlinks to be rendered in Markdown. URLs beginning in http and https are
235235
always displayed
236+
- `ENABLE_MATH`: **true**: Enables detection of `\(...\)`, `\[...\]` and `$$...$$` blocks as math blocks
237+
- `ENABLE_INLINE_DOLLAR_MATH`: **false**: In addition enables detection of `$...$` as inline math.
236238

237239
## Server (`server`)
238240

modules/context/context.go

+1
Original file line numberDiff line numberDiff line change
@@ -710,6 +710,7 @@ func Contexter() func(next http.Handler) http.Handler {
710710
ctx.PageData = map[string]interface{}{}
711711
ctx.Data["PageData"] = ctx.PageData
712712
ctx.Data["Context"] = &ctx
713+
ctx.Data["MathEnabled"] = setting.Markdown.EnableMath
713714

714715
ctx.Req = WithContext(req, &ctx)
715716
ctx.csrf = PrepareCSRFProtector(csrfOpts, &ctx)

modules/markup/markdown/markdown.go

+5
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"code.gitea.io/gitea/modules/log"
1515
"code.gitea.io/gitea/modules/markup"
1616
"code.gitea.io/gitea/modules/markup/common"
17+
"code.gitea.io/gitea/modules/markup/markdown/math"
1718
"code.gitea.io/gitea/modules/setting"
1819
giteautil "code.gitea.io/gitea/modules/util"
1920

@@ -120,6 +121,10 @@ func actualRender(ctx *markup.RenderContext, input io.Reader, output io.Writer)
120121
}
121122
}),
122123
),
124+
math.NewExtension(
125+
math.Enabled(setting.Markdown.EnableMath),
126+
math.WithInlineDollarParser(setting.Markdown.EnableInlineDollarMath),
127+
),
123128
meta.Meta,
124129
),
125130
goldmark.WithParserOptions(
+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright 2022 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package math
6+
7+
import "github.com/yuin/goldmark/ast"
8+
9+
// Block represents a math Block
10+
type Block struct {
11+
ast.BaseBlock
12+
}
13+
14+
// KindBlock is the node kind for math blocks
15+
var KindBlock = ast.NewNodeKind("MathBlock")
16+
17+
// NewBlock creates a new math Block
18+
func NewBlock() *Block {
19+
return &Block{}
20+
}
21+
22+
// Dump dumps the block to a string
23+
func (n *Block) Dump(source []byte, level int) {
24+
m := map[string]string{}
25+
ast.DumpHelper(n, source, level, m, nil)
26+
}
27+
28+
// Kind returns KindBlock for math Blocks
29+
func (n *Block) Kind() ast.NodeKind {
30+
return KindBlock
31+
}
32+
33+
// IsRaw returns true as this block should not be processed further
34+
func (n *Block) IsRaw() bool {
35+
return true
36+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Copyright 2022 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package math
6+
7+
import (
8+
"github.com/yuin/goldmark/ast"
9+
"github.com/yuin/goldmark/parser"
10+
"github.com/yuin/goldmark/text"
11+
"github.com/yuin/goldmark/util"
12+
)
13+
14+
type blockParser struct {
15+
parseDollars bool
16+
}
17+
18+
type blockData struct {
19+
dollars bool
20+
indent int
21+
}
22+
23+
var blockInfoKey = parser.NewContextKey()
24+
25+
// NewBlockParser creates a new math BlockParser
26+
func NewBlockParser(parseDollarBlocks bool) parser.BlockParser {
27+
return &blockParser{
28+
parseDollars: parseDollarBlocks,
29+
}
30+
}
31+
32+
// Open parses the current line and returns a result of parsing.
33+
func (b *blockParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) {
34+
line, _ := reader.PeekLine()
35+
pos := pc.BlockOffset()
36+
if pos == -1 || len(line[pos:]) < 2 {
37+
return nil, parser.NoChildren
38+
}
39+
40+
dollars := false
41+
if b.parseDollars && line[pos] == '$' && line[pos+1] == '$' {
42+
dollars = true
43+
} else if line[pos] != '\\' || line[pos+1] != '[' {
44+
return nil, parser.NoChildren
45+
}
46+
47+
pc.Set(blockInfoKey, &blockData{dollars: dollars, indent: pos})
48+
node := NewBlock()
49+
return node, parser.NoChildren
50+
}
51+
52+
// Continue parses the current line and returns a result of parsing.
53+
func (b *blockParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State {
54+
line, segment := reader.PeekLine()
55+
data := pc.Get(blockInfoKey).(*blockData)
56+
w, pos := util.IndentWidth(line, 0)
57+
if w < 4 {
58+
if data.dollars {
59+
i := pos
60+
for ; i < len(line) && line[i] == '$'; i++ {
61+
}
62+
length := i - pos
63+
if length >= 2 && util.IsBlank(line[i:]) {
64+
reader.Advance(segment.Stop - segment.Start - segment.Padding)
65+
return parser.Close
66+
}
67+
} else if len(line[pos:]) > 1 && line[pos] == '\\' && line[pos+1] == ']' && util.IsBlank(line[pos+2:]) {
68+
reader.Advance(segment.Stop - segment.Start - segment.Padding)
69+
return parser.Close
70+
}
71+
}
72+
73+
pos, padding := util.IndentPosition(line, 0, data.indent)
74+
seg := text.NewSegmentPadding(segment.Start+pos, segment.Stop, padding)
75+
node.Lines().Append(seg)
76+
reader.AdvanceAndSetPadding(segment.Stop-segment.Start-pos-1, padding)
77+
return parser.Continue | parser.NoChildren
78+
}
79+
80+
// Close will be called when the parser returns Close.
81+
func (b *blockParser) Close(node ast.Node, reader text.Reader, pc parser.Context) {
82+
pc.Set(blockInfoKey, nil)
83+
}
84+
85+
// CanInterruptParagraph returns true if the parser can interrupt paragraphs,
86+
// otherwise false.
87+
func (b *blockParser) CanInterruptParagraph() bool {
88+
return true
89+
}
90+
91+
// CanAcceptIndentedLine returns true if the parser can open new node when
92+
// the given line is being indented more than 3 spaces.
93+
func (b *blockParser) CanAcceptIndentedLine() bool {
94+
return false
95+
}
96+
97+
// Trigger returns a list of characters that triggers Parse method of
98+
// this parser.
99+
// If Trigger returns a nil, Open will be called with any lines.
100+
//
101+
// We leave this as nil as our parse method is quick enough
102+
func (b *blockParser) Trigger() []byte {
103+
return nil
104+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright 2022 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package math
6+
7+
import (
8+
gast "github.com/yuin/goldmark/ast"
9+
"github.com/yuin/goldmark/renderer"
10+
"github.com/yuin/goldmark/util"
11+
)
12+
13+
// BlockRenderer represents a renderer for math Blocks
14+
type BlockRenderer struct {
15+
startDelim string
16+
endDelim string
17+
}
18+
19+
// NewBlockRenderer creates a new renderer for math Blocks
20+
func NewBlockRenderer(start, end string) renderer.NodeRenderer {
21+
return &BlockRenderer{start, end}
22+
}
23+
24+
// RegisterFuncs registers the renderer for math Blocks
25+
func (r *BlockRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
26+
reg.Register(KindBlock, r.renderBlock)
27+
}
28+
29+
func (r *BlockRenderer) writeLines(w util.BufWriter, source []byte, n gast.Node) {
30+
l := n.Lines().Len()
31+
for i := 0; i < l; i++ {
32+
line := n.Lines().At(i)
33+
_, _ = w.Write(util.EscapeHTML(line.Value(source)))
34+
}
35+
}
36+
37+
func (r *BlockRenderer) renderBlock(w util.BufWriter, source []byte, node gast.Node, entering bool) (gast.WalkStatus, error) {
38+
n := node.(*Block)
39+
if entering {
40+
_, _ = w.WriteString(`<p><span class="math display">` + r.startDelim)
41+
r.writeLines(w, source, n)
42+
} else {
43+
_, _ = w.WriteString(r.endDelim + `</span></p>` + "\n")
44+
}
45+
return gast.WalkContinue, nil
46+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright 2022 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package math
6+
7+
import (
8+
"github.com/yuin/goldmark/ast"
9+
"github.com/yuin/goldmark/util"
10+
)
11+
12+
// Inline represents inline math
13+
type Inline struct {
14+
ast.BaseInline
15+
}
16+
17+
// Inline implements Inline.Inline.
18+
func (n *Inline) Inline() {}
19+
20+
// IsBlank returns if this inline node is empty
21+
func (n *Inline) IsBlank(source []byte) bool {
22+
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
23+
text := c.(*ast.Text).Segment
24+
if !util.IsBlank(text.Value(source)) {
25+
return false
26+
}
27+
}
28+
return true
29+
}
30+
31+
// Dump renders this inline math as debug
32+
func (n *Inline) Dump(source []byte, level int) {
33+
ast.DumpHelper(n, source, level, nil, nil)
34+
}
35+
36+
// KindInline is the kind for math inline
37+
var KindInline = ast.NewNodeKind("MathInline")
38+
39+
// Kind returns KindInline
40+
func (n *Inline) Kind() ast.NodeKind {
41+
return KindInline
42+
}
43+
44+
// NewInline creates a new ast math inline node
45+
func NewInline() *Inline {
46+
return &Inline{
47+
BaseInline: ast.BaseInline{},
48+
}
49+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright 2022 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package math
6+
7+
import (
8+
"bytes"
9+
10+
"github.com/yuin/goldmark/ast"
11+
"github.com/yuin/goldmark/parser"
12+
"github.com/yuin/goldmark/text"
13+
"github.com/yuin/goldmark/util"
14+
)
15+
16+
type inlineParser struct {
17+
start []byte
18+
end []byte
19+
}
20+
21+
var defaultInlineDollarParser = &inlineParser{
22+
start: []byte{'$'},
23+
end: []byte{'$'},
24+
}
25+
26+
// NewInlineDollarParser returns a new inline parser
27+
func NewInlineDollarParser() parser.InlineParser {
28+
return defaultInlineDollarParser
29+
}
30+
31+
var defaultInlineBracketParser = &inlineParser{
32+
start: []byte{'\\', '('},
33+
end: []byte{'\\', ')'},
34+
}
35+
36+
// NewInlineDollarParser returns a new inline parser
37+
func NewInlineBracketParser() parser.InlineParser {
38+
return defaultInlineBracketParser
39+
}
40+
41+
// Trigger triggers this parser on $
42+
func (parser *inlineParser) Trigger() []byte {
43+
return parser.start[0:1]
44+
}
45+
46+
// Parse parses the current line and returns a result of parsing.
47+
func (parser *inlineParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
48+
line, startSegment := block.PeekLine()
49+
opener := bytes.Index(line, parser.start)
50+
if opener < 0 {
51+
return nil
52+
}
53+
opener += len(parser.start)
54+
block.Advance(opener)
55+
l, pos := block.Position()
56+
node := NewInline()
57+
58+
for {
59+
line, segment := block.PeekLine()
60+
if line == nil {
61+
block.SetPosition(l, pos)
62+
return ast.NewTextSegment(startSegment.WithStop(startSegment.Start + opener))
63+
}
64+
65+
closer := bytes.Index(line, parser.end)
66+
if closer < 0 {
67+
if !util.IsBlank(line) {
68+
node.AppendChild(node, ast.NewRawTextSegment(segment))
69+
}
70+
block.AdvanceLine()
71+
continue
72+
}
73+
segment = segment.WithStop(segment.Start + closer)
74+
if !segment.IsEmpty() {
75+
node.AppendChild(node, ast.NewRawTextSegment(segment))
76+
}
77+
block.Advance(closer + len(parser.end))
78+
break
79+
}
80+
81+
trimBlock(node, block)
82+
return node
83+
}
84+
85+
func trimBlock(node *Inline, block text.Reader) {
86+
if node.IsBlank(block.Source()) {
87+
return
88+
}
89+
90+
// trim first space and last space
91+
first := node.FirstChild().(*ast.Text)
92+
if !(!first.Segment.IsEmpty() && block.Source()[first.Segment.Start] == ' ') {
93+
return
94+
}
95+
96+
last := node.LastChild().(*ast.Text)
97+
if !(!last.Segment.IsEmpty() && block.Source()[last.Segment.Stop-1] == ' ') {
98+
return
99+
}
100+
101+
first.Segment = first.Segment.WithStart(first.Segment.Start + 1)
102+
last.Segment = last.Segment.WithStop(last.Segment.Stop - 1)
103+
}

0 commit comments

Comments
 (0)