Add KaTeX rendering to Markdown. (#20571)
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 <art27@cantab.net> Signed-off-by: Andrew Thornton <art27@cantab.net> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>tokarchuk/v1.18
parent
eaa561145a
commit
88c2e24360
@ -0,0 +1,84 @@ |
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package markdown |
||||||
|
|
||||||
|
import ( |
||||||
|
"github.com/yuin/goldmark/ast" |
||||||
|
east "github.com/yuin/goldmark/extension/ast" |
||||||
|
"gopkg.in/yaml.v3" |
||||||
|
) |
||||||
|
|
||||||
|
func nodeToTable(meta *yaml.Node) ast.Node { |
||||||
|
for { |
||||||
|
if meta == nil { |
||||||
|
return nil |
||||||
|
} |
||||||
|
switch meta.Kind { |
||||||
|
case yaml.DocumentNode: |
||||||
|
meta = meta.Content[0] |
||||||
|
continue |
||||||
|
default: |
||||||
|
} |
||||||
|
break |
||||||
|
} |
||||||
|
switch meta.Kind { |
||||||
|
case yaml.MappingNode: |
||||||
|
return mappingNodeToTable(meta) |
||||||
|
case yaml.SequenceNode: |
||||||
|
return sequenceNodeToTable(meta) |
||||||
|
default: |
||||||
|
return ast.NewString([]byte(meta.Value)) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func mappingNodeToTable(meta *yaml.Node) ast.Node { |
||||||
|
table := east.NewTable() |
||||||
|
alignments := []east.Alignment{} |
||||||
|
for i := 0; i < len(meta.Content); i += 2 { |
||||||
|
alignments = append(alignments, east.AlignNone) |
||||||
|
} |
||||||
|
|
||||||
|
headerRow := east.NewTableRow(alignments) |
||||||
|
valueRow := east.NewTableRow(alignments) |
||||||
|
for i := 0; i < len(meta.Content); i += 2 { |
||||||
|
cell := east.NewTableCell() |
||||||
|
|
||||||
|
cell.AppendChild(cell, nodeToTable(meta.Content[i])) |
||||||
|
headerRow.AppendChild(headerRow, cell) |
||||||
|
|
||||||
|
if i+1 < len(meta.Content) { |
||||||
|
cell = east.NewTableCell() |
||||||
|
cell.AppendChild(cell, nodeToTable(meta.Content[i+1])) |
||||||
|
valueRow.AppendChild(valueRow, cell) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
table.AppendChild(table, east.NewTableHeader(headerRow)) |
||||||
|
table.AppendChild(table, valueRow) |
||||||
|
return table |
||||||
|
} |
||||||
|
|
||||||
|
func sequenceNodeToTable(meta *yaml.Node) ast.Node { |
||||||
|
table := east.NewTable() |
||||||
|
alignments := []east.Alignment{east.AlignNone} |
||||||
|
for _, item := range meta.Content { |
||||||
|
row := east.NewTableRow(alignments) |
||||||
|
cell := east.NewTableCell() |
||||||
|
cell.AppendChild(cell, nodeToTable(item)) |
||||||
|
row.AppendChild(row, cell) |
||||||
|
table.AppendChild(table, row) |
||||||
|
} |
||||||
|
return table |
||||||
|
} |
||||||
|
|
||||||
|
func nodeToDetails(meta *yaml.Node, icon string) ast.Node { |
||||||
|
details := NewDetails() |
||||||
|
summary := NewSummary() |
||||||
|
summary.AppendChild(summary, NewIcon(icon)) |
||||||
|
details.AppendChild(details, summary) |
||||||
|
details.AppendChild(details, nodeToTable(meta)) |
||||||
|
|
||||||
|
return details |
||||||
|
} |
@ -0,0 +1,42 @@ |
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package math |
||||||
|
|
||||||
|
import "github.com/yuin/goldmark/ast" |
||||||
|
|
||||||
|
// Block represents a display math block e.g. $$...$$ or \[...\]
|
||||||
|
type Block struct { |
||||||
|
ast.BaseBlock |
||||||
|
Dollars bool |
||||||
|
Indent int |
||||||
|
Closed bool |
||||||
|
} |
||||||
|
|
||||||
|
// KindBlock is the node kind for math blocks
|
||||||
|
var KindBlock = ast.NewNodeKind("MathBlock") |
||||||
|
|
||||||
|
// NewBlock creates a new math Block
|
||||||
|
func NewBlock(dollars bool, indent int) *Block { |
||||||
|
return &Block{ |
||||||
|
Dollars: dollars, |
||||||
|
Indent: indent, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Dump dumps the block to a string
|
||||||
|
func (n *Block) Dump(source []byte, level int) { |
||||||
|
m := map[string]string{} |
||||||
|
ast.DumpHelper(n, source, level, m, nil) |
||||||
|
} |
||||||
|
|
||||||
|
// Kind returns KindBlock for math Blocks
|
||||||
|
func (n *Block) Kind() ast.NodeKind { |
||||||
|
return KindBlock |
||||||
|
} |
||||||
|
|
||||||
|
// IsRaw returns true as this block should not be processed further
|
||||||
|
func (n *Block) IsRaw() bool { |
||||||
|
return true |
||||||
|
} |
@ -0,0 +1,123 @@ |
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package math |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
|
||||||
|
"github.com/yuin/goldmark/ast" |
||||||
|
"github.com/yuin/goldmark/parser" |
||||||
|
"github.com/yuin/goldmark/text" |
||||||
|
"github.com/yuin/goldmark/util" |
||||||
|
) |
||||||
|
|
||||||
|
type blockParser struct { |
||||||
|
parseDollars bool |
||||||
|
} |
||||||
|
|
||||||
|
// NewBlockParser creates a new math BlockParser
|
||||||
|
func NewBlockParser(parseDollarBlocks bool) parser.BlockParser { |
||||||
|
return &blockParser{ |
||||||
|
parseDollars: parseDollarBlocks, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Open parses the current line and returns a result of parsing.
|
||||||
|
func (b *blockParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) { |
||||||
|
line, segment := reader.PeekLine() |
||||||
|
pos := pc.BlockOffset() |
||||||
|
if pos == -1 || len(line[pos:]) < 2 { |
||||||
|
return nil, parser.NoChildren |
||||||
|
} |
||||||
|
|
||||||
|
dollars := false |
||||||
|
if b.parseDollars && line[pos] == '$' && line[pos+1] == '$' { |
||||||
|
dollars = true |
||||||
|
} else if line[pos] != '\\' || line[pos+1] != '[' { |
||||||
|
return nil, parser.NoChildren |
||||||
|
} |
||||||
|
|
||||||
|
node := NewBlock(dollars, pos) |
||||||
|
|
||||||
|
// Now we need to check if the ending block is on the segment...
|
||||||
|
endBytes := []byte{'\\', ']'} |
||||||
|
if dollars { |
||||||
|
endBytes = []byte{'$', '$'} |
||||||
|
} |
||||||
|
idx := bytes.Index(line[pos+2:], endBytes) |
||||||
|
if idx >= 0 { |
||||||
|
segment.Stop = segment.Start + idx + 2 |
||||||
|
reader.Advance(segment.Len() - 1) |
||||||
|
segment.Start += 2 |
||||||
|
node.Lines().Append(segment) |
||||||
|
node.Closed = true |
||||||
|
return node, parser.Close | parser.NoChildren |
||||||
|
} |
||||||
|
|
||||||
|
reader.Advance(segment.Len() - 1) |
||||||
|
segment.Start += 2 |
||||||
|
node.Lines().Append(segment) |
||||||
|
return node, parser.NoChildren |
||||||
|
} |
||||||
|
|
||||||
|
// Continue parses the current line and returns a result of parsing.
|
||||||
|
func (b *blockParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State { |
||||||
|
block := node.(*Block) |
||||||
|
if block.Closed { |
||||||
|
return parser.Close |
||||||
|
} |
||||||
|
|
||||||
|
line, segment := reader.PeekLine() |
||||||
|
w, pos := util.IndentWidth(line, 0) |
||||||
|
if w < 4 { |
||||||
|
if block.Dollars { |
||||||
|
i := pos |
||||||
|
for ; i < len(line) && line[i] == '$'; i++ { |
||||||
|
} |
||||||
|
length := i - pos |
||||||
|
if length >= 2 && util.IsBlank(line[i:]) { |
||||||
|
reader.Advance(segment.Stop - segment.Start - segment.Padding) |
||||||
|
block.Closed = true |
||||||
|
return parser.Close |
||||||
|
} |
||||||
|
} else if len(line[pos:]) > 1 && line[pos] == '\\' && line[pos+1] == ']' && util.IsBlank(line[pos+2:]) { |
||||||
|
reader.Advance(segment.Stop - segment.Start - segment.Padding) |
||||||
|
block.Closed = true |
||||||
|
return parser.Close |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
pos, padding := util.IndentPosition(line, 0, block.Indent) |
||||||
|
seg := text.NewSegmentPadding(segment.Start+pos, segment.Stop, padding) |
||||||
|
node.Lines().Append(seg) |
||||||
|
reader.AdvanceAndSetPadding(segment.Stop-segment.Start-pos-1, padding) |
||||||
|
return parser.Continue | parser.NoChildren |
||||||
|
} |
||||||
|
|
||||||
|
// Close will be called when the parser returns Close.
|
||||||
|
func (b *blockParser) Close(node ast.Node, reader text.Reader, pc parser.Context) { |
||||||
|
// noop
|
||||||
|
} |
||||||
|
|
||||||
|
// CanInterruptParagraph returns true if the parser can interrupt paragraphs,
|
||||||
|
// otherwise false.
|
||||||
|
func (b *blockParser) CanInterruptParagraph() bool { |
||||||
|
return true |
||||||
|
} |
||||||
|
|
||||||
|
// CanAcceptIndentedLine returns true if the parser can open new node when
|
||||||
|
// the given line is being indented more than 3 spaces.
|
||||||
|
func (b *blockParser) CanAcceptIndentedLine() bool { |
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
// Trigger returns a list of characters that triggers Parse method of
|
||||||
|
// this parser.
|
||||||
|
// If Trigger returns a nil, Open will be called with any lines.
|
||||||
|
//
|
||||||
|
// We leave this as nil as our parse method is quick enough
|
||||||
|
func (b *blockParser) Trigger() []byte { |
||||||
|
return nil |
||||||
|
} |
@ -0,0 +1,43 @@ |
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package math |
||||||
|
|
||||||
|
import ( |
||||||
|
gast "github.com/yuin/goldmark/ast" |
||||||
|
"github.com/yuin/goldmark/renderer" |
||||||
|
"github.com/yuin/goldmark/util" |
||||||
|
) |
||||||
|
|
||||||
|
// BlockRenderer represents a renderer for math Blocks
|
||||||
|
type BlockRenderer struct{} |
||||||
|
|
||||||
|
// NewBlockRenderer creates a new renderer for math Blocks
|
||||||
|
func NewBlockRenderer() renderer.NodeRenderer { |
||||||
|
return &BlockRenderer{} |
||||||
|
} |
||||||
|
|
||||||
|
// RegisterFuncs registers the renderer for math Blocks
|
||||||
|
func (r *BlockRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { |
||||||
|
reg.Register(KindBlock, r.renderBlock) |
||||||
|
} |
||||||
|
|
||||||
|
func (r *BlockRenderer) writeLines(w util.BufWriter, source []byte, n gast.Node) { |
||||||
|
l := n.Lines().Len() |
||||||
|
for i := 0; i < l; i++ { |
||||||
|
line := n.Lines().At(i) |
||||||
|
_, _ = w.Write(util.EscapeHTML(line.Value(source))) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func (r *BlockRenderer) renderBlock(w util.BufWriter, source []byte, node gast.Node, entering bool) (gast.WalkStatus, error) { |
||||||
|
n := node.(*Block) |
||||||
|
if entering { |
||||||
|
_, _ = w.WriteString(`<pre class="code-block is-loading"><code class="chroma language-math display">`) |
||||||
|
r.writeLines(w, source, n) |
||||||
|
} else { |
||||||
|
_, _ = w.WriteString(`</code></pre>` + "\n") |
||||||
|
} |
||||||
|
return gast.WalkContinue, nil |
||||||
|
} |
@ -0,0 +1,49 @@ |
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package math |
||||||
|
|
||||||
|
import ( |
||||||
|
"github.com/yuin/goldmark/ast" |
||||||
|
"github.com/yuin/goldmark/util" |
||||||
|
) |
||||||
|
|
||||||
|
// Inline represents inline math e.g. $...$ or \(...\)
|
||||||
|
type Inline struct { |
||||||
|
ast.BaseInline |
||||||
|
} |
||||||
|
|
||||||
|
// Inline implements Inline.Inline.
|
||||||
|
func (n *Inline) Inline() {} |
||||||
|
|
||||||
|
// IsBlank returns if this inline node is empty
|
||||||
|
func (n *Inline) IsBlank(source []byte) bool { |
||||||
|
for c := n.FirstChild(); c != nil; c = c.NextSibling() { |
||||||
|
text := c.(*ast.Text).Segment |
||||||
|
if !util.IsBlank(text.Value(source)) { |
||||||
|
return false |
||||||
|
} |
||||||
|
} |
||||||
|
return true |
||||||
|
} |
||||||
|
|
||||||
|
// Dump renders this inline math as debug
|
||||||
|
func (n *Inline) Dump(source []byte, level int) { |
||||||
|
ast.DumpHelper(n, source, level, nil, nil) |
||||||
|
} |
||||||
|
|
||||||
|
// KindInline is the kind for math inline
|
||||||
|
var KindInline = ast.NewNodeKind("MathInline") |
||||||
|
|
||||||
|
// Kind returns KindInline
|
||||||
|
func (n *Inline) Kind() ast.NodeKind { |
||||||
|
return KindInline |
||||||
|
} |
||||||
|
|
||||||
|
// NewInline creates a new ast math inline node
|
||||||
|
func NewInline() *Inline { |
||||||
|
return &Inline{ |
||||||
|
BaseInline: ast.BaseInline{}, |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,99 @@ |
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package math |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
|
||||||
|
"github.com/yuin/goldmark/ast" |
||||||
|
"github.com/yuin/goldmark/parser" |
||||||
|
"github.com/yuin/goldmark/text" |
||||||
|
) |
||||||
|
|
||||||
|
type inlineParser struct { |
||||||
|
start []byte |
||||||
|
end []byte |
||||||
|
} |
||||||
|
|
||||||
|
var defaultInlineDollarParser = &inlineParser{ |
||||||
|
start: []byte{'$'}, |
||||||
|
end: []byte{'$'}, |
||||||
|
} |
||||||
|
|
||||||
|
// NewInlineDollarParser returns a new inline parser
|
||||||
|
func NewInlineDollarParser() parser.InlineParser { |
||||||
|
return defaultInlineDollarParser |
||||||
|
} |
||||||
|
|
||||||
|
var defaultInlineBracketParser = &inlineParser{ |
||||||
|
start: []byte{'\\', '('}, |
||||||
|
end: []byte{'\\', ')'}, |
||||||
|
} |
||||||
|
|
||||||
|
// NewInlineDollarParser returns a new inline parser
|
||||||
|
func NewInlineBracketParser() parser.InlineParser { |
||||||
|
return defaultInlineBracketParser |
||||||
|
} |
||||||
|
|
||||||
|
// Trigger triggers this parser on $
|
||||||
|
func (parser *inlineParser) Trigger() []byte { |
||||||
|
return parser.start[0:1] |
||||||
|
} |
||||||
|
|
||||||
|
func isAlphanumeric(b byte) bool { |
||||||
|
// Github only cares about 0-9A-Za-z
|
||||||
|
return (b >= '0' && b <= '9') || (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') |
||||||
|
} |
||||||
|
|
||||||
|
// Parse parses the current line and returns a result of parsing.
|
||||||
|
func (parser *inlineParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { |
||||||
|
line, _ := block.PeekLine() |
||||||
|
opener := bytes.Index(line, parser.start) |
||||||
|
if opener < 0 { |
||||||
|
return nil |
||||||
|
} |
||||||
|
if opener != 0 && isAlphanumeric(line[opener-1]) { |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
opener += len(parser.start) |
||||||
|
ender := bytes.Index(line[opener:], parser.end) |
||||||
|
if ender < 0 { |
||||||
|
return nil |
||||||
|
} |
||||||
|
if len(line) > opener+ender+len(parser.end) && isAlphanumeric(line[opener+ender+len(parser.end)]) { |
||||||
|
return nil |
||||||
|
} |
||||||
|
|
||||||
|
block.Advance(opener) |
||||||
|
_, pos := block.Position() |
||||||
|
node := NewInline() |
||||||
|
segment := pos.WithStop(pos.Start + ender) |
||||||
|
node.AppendChild(node, ast.NewRawTextSegment(segment)) |
||||||
|
block.Advance(ender + len(parser.end)) |
||||||
|
|
||||||
|
trimBlock(node, block) |
||||||
|
return node |
||||||
|
} |
||||||
|
|
||||||
|
func trimBlock(node *Inline, block text.Reader) { |
||||||
|
if node.IsBlank(block.Source()) { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
// trim first space and last space
|
||||||
|
first := node.FirstChild().(*ast.Text) |
||||||
|
if !(!first.Segment.IsEmpty() && block.Source()[first.Segment.Start] == ' ') { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
last := node.LastChild().(*ast.Text) |
||||||
|
if !(!last.Segment.IsEmpty() && block.Source()[last.Segment.Stop-1] == ' ') { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
first.Segment = first.Segment.WithStart(first.Segment.Start + 1) |
||||||
|
last.Segment = last.Segment.WithStop(last.Segment.Stop - 1) |
||||||
|
} |
@ -0,0 +1,47 @@ |
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package math |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
|
||||||
|
"github.com/yuin/goldmark/ast" |
||||||
|
"github.com/yuin/goldmark/renderer" |
||||||
|
"github.com/yuin/goldmark/util" |
||||||
|
) |
||||||
|
|
||||||
|
// InlineRenderer is an inline renderer
|
||||||
|
type InlineRenderer struct{} |
||||||
|
|
||||||
|
// NewInlineRenderer returns a new renderer for inline math
|
||||||
|
func NewInlineRenderer() renderer.NodeRenderer { |
||||||
|
return &InlineRenderer{} |
||||||
|
} |
||||||
|
|
||||||
|
func (r *InlineRenderer) renderInline(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { |
||||||
|
if entering { |
||||||
|
_, _ = w.WriteString(`<code class="language-math is-loading">`) |
||||||
|
for c := n.FirstChild(); c != nil; c = c.NextSibling() { |
||||||
|
segment := c.(*ast.Text).Segment |
||||||
|
value := util.EscapeHTML(segment.Value(source)) |
||||||
|
if bytes.HasSuffix(value, []byte("\n")) { |
||||||
|
_, _ = w.Write(value[:len(value)-1]) |
||||||
|
if c != n.LastChild() { |
||||||
|
_, _ = w.Write([]byte(" ")) |
||||||
|
} |
||||||
|
} else { |
||||||
|
_, _ = w.Write(value) |
||||||
|
} |
||||||
|
} |
||||||
|
return ast.WalkSkipChildren, nil |
||||||
|
} |
||||||
|
_, _ = w.WriteString(`</code>`) |
||||||
|
return ast.WalkContinue, nil |
||||||
|
} |
||||||
|
|
||||||
|
// RegisterFuncs registers the renderer for inline math nodes
|
||||||
|
func (r *InlineRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { |
||||||
|
reg.Register(KindInline, r.renderInline) |
||||||
|
} |
@ -0,0 +1,108 @@ |
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package math |
||||||
|
|
||||||
|
import ( |
||||||
|
"github.com/yuin/goldmark" |
||||||
|
"github.com/yuin/goldmark/parser" |
||||||
|
"github.com/yuin/goldmark/renderer" |
||||||
|
"github.com/yuin/goldmark/util" |
||||||
|
) |
||||||
|
|
||||||
|
// Extension is a math extension
|
||||||
|
type Extension struct { |
||||||
|
enabled bool |
||||||
|
parseDollarInline bool |
||||||
|
parseDollarBlock bool |
||||||
|
} |
||||||
|
|
||||||
|
// Option is the interface Options should implement
|
||||||
|
type Option interface { |
||||||
|
SetOption(e *Extension) |
||||||
|
} |
||||||
|
|
||||||
|
type extensionFunc func(e *Extension) |
||||||
|
|
||||||
|
func (fn extensionFunc) SetOption(e *Extension) { |
||||||
|
fn(e) |
||||||
|
} |
||||||
|
|
||||||
|
// Enabled enables or disables this extension
|
||||||
|
func Enabled(enable ...bool) Option { |
||||||
|
value := true |
||||||
|
if len(enable) > 0 { |
||||||
|
value = enable[0] |
||||||
|
} |
||||||
|
return extensionFunc(func(e *Extension) { |
||||||
|
e.enabled = value |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// WithInlineDollarParser enables or disables the parsing of $...$
|
||||||
|
func WithInlineDollarParser(enable ...bool) Option { |
||||||
|
value := true |
||||||
|
if len(enable) > 0 { |
||||||
|
value = enable[0] |
||||||
|
} |
||||||
|
return extensionFunc(func(e *Extension) { |
||||||
|
e.parseDollarInline = value |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// WithBlockDollarParser enables or disables the parsing of $$...$$
|
||||||
|
func WithBlockDollarParser(enable ...bool) Option { |
||||||
|
value := true |
||||||
|
if len(enable) > 0 { |
||||||
|
value = enable[0] |
||||||
|
} |
||||||
|
return extensionFunc(func(e *Extension) { |
||||||
|
e.parseDollarBlock = value |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// Math represents a math extension with default rendered delimiters
|
||||||
|
var Math = &Extension{ |
||||||
|
enabled: true, |
||||||
|
parseDollarBlock: true, |
||||||
|
parseDollarInline: true, |
||||||
|
} |
||||||
|
|
||||||
|
// NewExtension creates a new math extension with the provided options
|
||||||
|
func NewExtension(opts ...Option) *Extension { |
||||||
|
r := &Extension{ |
||||||
|
enabled: true, |
||||||
|
parseDollarBlock: true, |
||||||
|
parseDollarInline: true, |
||||||
|
} |
||||||
|
|
||||||
|
for _, o := range opts { |
||||||
|
o.SetOption(r) |
||||||
|
} |
||||||
|
return r |
||||||
|
} |
||||||
|
|
||||||
|
// Extend extends goldmark with our parsers and renderers
|
||||||
|
func (e *Extension) Extend(m goldmark.Markdown) { |
||||||
|
if !e.enabled { |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
m.Parser().AddOptions(parser.WithBlockParsers( |
||||||
|
util.Prioritized(NewBlockParser(e.parseDollarBlock), 701), |
||||||
|
)) |
||||||
|
|
||||||
|
inlines := []util.PrioritizedValue{ |
||||||
|
util.Prioritized(NewInlineBracketParser(), 501), |
||||||
|
} |
||||||
|
if e.parseDollarInline { |
||||||
|
inlines = append(inlines, util.Prioritized(NewInlineDollarParser(), 501)) |
||||||
|
} |
||||||
|
m.Parser().AddOptions(parser.WithInlineParsers(inlines...)) |
||||||
|
|
||||||
|
m.Renderer().AddOptions(renderer.WithNodeRenderers( |
||||||
|
util.Prioritized(NewBlockRenderer(), 501), |
||||||
|
util.Prioritized(NewInlineRenderer(), 502), |
||||||
|
)) |
||||||
|
} |
@ -0,0 +1,162 @@ |
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package markdown |
||||||
|
|
||||||
|
import ( |
||||||
|
"testing" |
||||||
|
|
||||||
|
"gopkg.in/yaml.v3" |
||||||
|
) |
||||||
|
|
||||||
|
func TestRenderConfig_UnmarshalYAML(t *testing.T) { |
||||||
|
tests := []struct { |
||||||
|
name string |
||||||
|
expected *RenderConfig |
||||||
|
args string |
||||||
|
}{ |
||||||
|
{ |
||||||
|
"empty", &RenderConfig{ |
||||||
|
Meta: "table", |
||||||
|
Icon: "table", |
||||||
|
Lang: "", |
||||||
|
}, "", |
||||||
|
}, |
||||||
|
{ |
||||||
|
"lang", &RenderConfig{ |
||||||
|
Meta: "table", |
||||||
|
Icon: "table", |
||||||
|
Lang: "test", |
||||||
|
}, "lang: test", |
||||||
|
}, |
||||||
|
{ |
||||||
|
"metatable", &RenderConfig{ |
||||||
|
Meta: "table", |
||||||
|
Icon: "table", |
||||||
|
Lang: "", |
||||||
|
}, "gitea: table", |
||||||
|
}, |
||||||
|
{ |
||||||
|
"metanone", &RenderConfig{ |
||||||
|
Meta: "none", |
||||||
|
Icon: "table", |
||||||
|
Lang: "", |
||||||
|
}, "gitea: none", |
||||||
|
}, |
||||||
|
{ |
||||||
|
"metadetails", &RenderConfig{ |
||||||
|
Meta: "details", |
||||||
|
Icon: "table", |
||||||
|
Lang: "", |
||||||
|
}, "gitea: details", |
||||||
|
}, |
||||||
|
{ |
||||||
|
"metawrong", &RenderConfig{ |
||||||
|
Meta: "details", |
||||||
|
Icon: "table", |
||||||
|
Lang: "", |
||||||
|
}, "gitea: wrong", |
||||||
|
}, |
||||||
|
{ |
||||||
|
"toc", &RenderConfig{ |
||||||
|
TOC: true, |
||||||
|
Meta: "table", |
||||||
|
Icon: "table", |
||||||
|
Lang: "", |
||||||
|
}, "include_toc: true", |
||||||
|
}, |
||||||
|
{ |
||||||
|
"tocfalse", &RenderConfig{ |
||||||
|
TOC: false, |
||||||
|
Meta: "table", |
||||||
|
Icon: "table", |
||||||
|
Lang: "", |
||||||
|
}, "include_toc: false", |
||||||
|
}, |
||||||
|
{ |
||||||
|
"toclang", &RenderConfig{ |
||||||
|
Meta: "table", |
||||||
|
Icon: "table", |
||||||
|
TOC: true, |
||||||
|
Lang: "testlang", |
||||||
|
}, ` |
||||||
|
include_toc: true |
||||||
|
lang: testlang |
||||||
|
`, |
||||||
|
}, |
||||||
|
{ |
||||||
|
"complexlang", &RenderConfig{ |
||||||
|
Meta: "table", |
||||||
|
Icon: "table", |
||||||
|
Lang: "testlang", |
||||||
|
}, ` |
||||||
|
gitea: |
||||||
|
lang: testlang |
||||||
|
`, |
||||||
|
}, |
||||||
|
{ |
||||||
|
"complexlang2", &RenderConfig{ |
||||||
|
Meta: "table", |
||||||
|
Icon: "table", |
||||||
|
Lang: "testlang", |
||||||
|
}, ` |
||||||
|
lang: notright |
||||||
|
gitea: |
||||||
|
lang: testlang |
||||||
|
`, |
||||||
|
}, |
||||||
|
{ |
||||||
|
"complexlang", &RenderConfig{ |
||||||
|
Meta: "table", |
||||||
|
Icon: "table", |
||||||
|
Lang: "testlang", |
||||||
|
}, ` |
||||||
|
gitea: |
||||||
|
lang: testlang |
||||||
|
`, |
||||||
|
}, |
||||||
|
{ |
||||||
|
"complex2", &RenderConfig{ |
||||||
|
Lang: "two", |
||||||
|
Meta: "table", |
||||||
|
TOC: true, |
||||||
|
Icon: "smiley", |
||||||
|
}, ` |
||||||
|
lang: one |
||||||
|
include_toc: true |
||||||
|
gitea: |
||||||
|
details_icon: smiley |
||||||
|
meta: table |
||||||
|
include_toc: true |
||||||
|
lang: two |
||||||
|
`, |
||||||
|
}, |
||||||
|
} |
||||||
|
for _, tt := range tests { |
||||||
|
t.Run(tt.name, func(t *testing.T) { |
||||||
|
got := &RenderConfig{ |
||||||
|
Meta: "table", |
||||||
|
Icon: "table", |
||||||
|
Lang: "", |
||||||
|
} |
||||||
|
if err := yaml.Unmarshal([]byte(tt.args), got); err != nil { |
||||||
|
t.Errorf("RenderConfig.UnmarshalYAML() error = %v", err) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
if got.Meta != tt.expected.Meta { |
||||||
|
t.Errorf("Meta Expected %s Got %s", tt.expected.Meta, got.Meta) |
||||||
|
} |
||||||
|
if got.Icon != tt.expected.Icon { |
||||||
|
t.Errorf("Icon Expected %s Got %s", tt.expected.Icon, got.Icon) |
||||||
|
} |
||||||
|
if got.Lang != tt.expected.Lang { |
||||||
|
t.Errorf("Lang Expected %s Got %s", tt.expected.Lang, got.Lang) |
||||||
|
} |
||||||
|
if got.TOC != tt.expected.TOC { |
||||||
|
t.Errorf("TOC Expected %t Got %t", tt.expected.TOC, got.TOC) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,37 @@ |
|||||||
|
function displayError(el, err) { |
||||||
|
const target = targetElement(el); |
||||||
|
target.remove('is-loading'); |
||||||
|
const errorNode = document.createElement('div'); |
||||||
|
errorNode.setAttribute('class', 'ui message error markup-block-error mono'); |
||||||
|
errorNode.textContent = err.str || err.message || String(err); |
||||||
|
target.before(errorNode); |
||||||
|
} |
||||||
|
|
||||||
|
function targetElement(el) { |
||||||
|
// The target element is either the current element if it has the `is-loading` class or the pre that contains it
|
||||||
|
return el.classList.contains('is-loading') ? el : el.closest('pre'); |
||||||
|
} |
||||||
|
|
||||||
|
export async function renderMath() { |
||||||
|
const els = document.querySelectorAll('.markup code.language-math'); |
||||||
|
if (!els.length) return; |
||||||
|
|
||||||
|
const [{default: katex}] = await Promise.all([ |
||||||
|
import(/* webpackChunkName: "katex" */'katex'), |
||||||
|
import(/* webpackChunkName: "katex" */'katex/dist/katex.css'), |
||||||
|
]); |
||||||
|
|
||||||
|
for (const el of els) { |
||||||
|
const source = el.textContent; |
||||||
|
const options = {display: el.classList.contains('display')}; |
||||||
|
|
||||||
|
try { |
||||||
|
const markup = katex.renderToString(source, options); |
||||||
|
const tempEl = document.createElement(options.display ? 'p' : 'span'); |
||||||
|
tempEl.innerHTML = markup; |
||||||
|
targetElement(el).replaceWith(tempEl); |
||||||
|
} catch (error) { |
||||||
|
displayError(el, error); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue