From f57fb785902f8b12f8982385cc34bc9848e12d4b Mon Sep 17 00:00:00 2001 From: Andrew Thornton Date: Thu, 22 Jul 2021 22:32:25 +0100 Subject: [PATCH] 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 --- custom/conf/app.example.ini | 6 + .../doc/advanced/config-cheat-sheet.en-us.md | 2 + modules/context/context.go | 1 + modules/markup/markdown/markdown.go | 5 + modules/markup/markdown/math/block_node.go | 36 +++++ modules/markup/markdown/math/block_parser.go | 104 ++++++++++++++ .../markup/markdown/math/block_renderer.go | 46 ++++++ modules/markup/markdown/math/inline_node.go | 49 +++++++ modules/markup/markdown/math/inline_parser.go | 103 ++++++++++++++ .../markup/markdown/math/inline_renderer.go | 50 +++++++ modules/markup/markdown/math/math.go | 134 ++++++++++++++++++ modules/markup/sanitizer.go | 2 +- modules/setting/setting.go | 4 + package-lock.json | 39 +++++ package.json | 1 + templates/base/footer.tmpl | 4 + templates/base/head.tmpl | 3 + web_src/js/katex.js | 41 ++++++ webpack.config.js | 6 + 19 files changed, 635 insertions(+), 1 deletion(-) create mode 100644 modules/markup/markdown/math/block_node.go create mode 100644 modules/markup/markdown/math/block_parser.go create mode 100644 modules/markup/markdown/math/block_renderer.go create mode 100644 modules/markup/markdown/math/inline_node.go create mode 100644 modules/markup/markdown/math/inline_parser.go create mode 100644 modules/markup/markdown/math/inline_renderer.go create mode 100644 modules/markup/markdown/math/math.go create mode 100644 web_src/js/katex.js diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index 1c6a7e3b7c61e..327effdfcaaf0 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -1225,6 +1225,12 @@ ROUTER = console ;; List of file extensions that should be rendered/edited as Markdown ;; Separate the extensions with a comma. To render files without any extension as markdown, just put a comma ;FILE_EXTENSIONS = .md,.markdown,.mdown,.mkd +;; +;; Enables math inline and block detection +;ENABLE_MATH = true +;; +;; Enables in addition inline block detection using single dollars +;ENABLE_INLINE_DOLLAR_MATH = false ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/docs/content/doc/advanced/config-cheat-sheet.en-us.md b/docs/content/doc/advanced/config-cheat-sheet.en-us.md index cb2b9526d7dbb..cae1c79bc89d8 100644 --- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md +++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md @@ -233,6 +233,8 @@ The following configuration set `Content-Type: application/vnd.android.package-a - `CUSTOM_URL_SCHEMES`: Use a comma separated list (ftp,git,svn) to indicate additional URL hyperlinks to be rendered in Markdown. URLs beginning in http and https are always displayed +- `ENABLE_MATH`: **true**: Enables detection of `\(...\)`, `\[...\]` and `$$...$$` blocks as math blocks +- `ENABLE_INLINE_DOLLAR_MATH`: **false**: In addition enables detection of `$...$` as inline math. ## Server (`server`) diff --git a/modules/context/context.go b/modules/context/context.go index 8824911619921..098a4e4e270db 100644 --- a/modules/context/context.go +++ b/modules/context/context.go @@ -710,6 +710,7 @@ func Contexter() func(next http.Handler) http.Handler { ctx.PageData = map[string]interface{}{} ctx.Data["PageData"] = ctx.PageData ctx.Data["Context"] = &ctx + ctx.Data["MathEnabled"] = setting.Markdown.EnableMath ctx.Req = WithContext(req, &ctx) ctx.csrf = PrepareCSRFProtector(csrfOpts, &ctx) diff --git a/modules/markup/markdown/markdown.go b/modules/markup/markdown/markdown.go index 4ce85dfc31878..48748eec36187 100644 --- a/modules/markup/markdown/markdown.go +++ b/modules/markup/markdown/markdown.go @@ -14,6 +14,7 @@ import ( "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/common" + "code.gitea.io/gitea/modules/markup/markdown/math" "code.gitea.io/gitea/modules/setting" giteautil "code.gitea.io/gitea/modules/util" @@ -120,6 +121,10 @@ func actualRender(ctx *markup.RenderContext, input io.Reader, output io.Writer) } }), ), + math.NewExtension( + math.Enabled(setting.Markdown.EnableMath), + math.WithInlineDollarParser(setting.Markdown.EnableInlineDollarMath), + ), meta.Meta, ), goldmark.WithParserOptions( diff --git a/modules/markup/markdown/math/block_node.go b/modules/markup/markdown/math/block_node.go new file mode 100644 index 0000000000000..535b2900f7e46 --- /dev/null +++ b/modules/markup/markdown/math/block_node.go @@ -0,0 +1,36 @@ +// 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 math Block +type Block struct { + ast.BaseBlock +} + +// KindBlock is the node kind for math blocks +var KindBlock = ast.NewNodeKind("MathBlock") + +// NewBlock creates a new math Block +func NewBlock() *Block { + return &Block{} +} + +// 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 +} diff --git a/modules/markup/markdown/math/block_parser.go b/modules/markup/markdown/math/block_parser.go new file mode 100644 index 0000000000000..9d6e16672bd77 --- /dev/null +++ b/modules/markup/markdown/math/block_parser.go @@ -0,0 +1,104 @@ +// 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/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +type blockParser struct { + parseDollars bool +} + +type blockData struct { + dollars bool + indent int +} + +var blockInfoKey = parser.NewContextKey() + +// 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, _ := 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 + } + + pc.Set(blockInfoKey, &blockData{dollars: dollars, indent: pos}) + node := NewBlock() + 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 { + line, segment := reader.PeekLine() + data := pc.Get(blockInfoKey).(*blockData) + w, pos := util.IndentWidth(line, 0) + if w < 4 { + if data.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) + 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) + return parser.Close + } + } + + pos, padding := util.IndentPosition(line, 0, data.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) { + pc.Set(blockInfoKey, nil) +} + +// 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 +} diff --git a/modules/markup/markdown/math/block_renderer.go b/modules/markup/markdown/math/block_renderer.go new file mode 100644 index 0000000000000..b17eada5abe71 --- /dev/null +++ b/modules/markup/markdown/math/block_renderer.go @@ -0,0 +1,46 @@ +// 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 { + startDelim string + endDelim string +} + +// NewBlockRenderer creates a new renderer for math Blocks +func NewBlockRenderer(start, end string) renderer.NodeRenderer { + return &BlockRenderer{start, end} +} + +// 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(`

` + r.startDelim) + r.writeLines(w, source, n) + } else { + _, _ = w.WriteString(r.endDelim + `

` + "\n") + } + return gast.WalkContinue, nil +} diff --git a/modules/markup/markdown/math/inline_node.go b/modules/markup/markdown/math/inline_node.go new file mode 100644 index 0000000000000..877a94d53cbeb --- /dev/null +++ b/modules/markup/markdown/math/inline_node.go @@ -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 +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{}, + } +} diff --git a/modules/markup/markdown/math/inline_parser.go b/modules/markup/markdown/math/inline_parser.go new file mode 100644 index 0000000000000..0222b0342433c --- /dev/null +++ b/modules/markup/markdown/math/inline_parser.go @@ -0,0 +1,103 @@ +// 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 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] +} + +// 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, startSegment := block.PeekLine() + opener := bytes.Index(line, parser.start) + if opener < 0 { + return nil + } + opener += len(parser.start) + block.Advance(opener) + l, pos := block.Position() + node := NewInline() + + for { + line, segment := block.PeekLine() + if line == nil { + block.SetPosition(l, pos) + return ast.NewTextSegment(startSegment.WithStop(startSegment.Start + opener)) + } + + closer := bytes.Index(line, parser.end) + if closer < 0 { + if !util.IsBlank(line) { + node.AppendChild(node, ast.NewRawTextSegment(segment)) + } + block.AdvanceLine() + continue + } + segment = segment.WithStop(segment.Start + closer) + if !segment.IsEmpty() { + node.AppendChild(node, ast.NewRawTextSegment(segment)) + } + block.Advance(closer + len(parser.end)) + break + } + + 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) +} diff --git a/modules/markup/markdown/math/inline_renderer.go b/modules/markup/markdown/math/inline_renderer.go new file mode 100644 index 0000000000000..ce6aafdca2a4e --- /dev/null +++ b/modules/markup/markdown/math/inline_renderer.go @@ -0,0 +1,50 @@ +// 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 { + startDelim string + endDelim string +} + +// NewInlineRenderer returns a new renderer for inline math +func NewInlineRenderer(start, end string) renderer.NodeRenderer { + return &InlineRenderer{start, end} +} + +func (r *InlineRenderer) renderInline(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + _, _ = w.WriteString(`` + r.startDelim) + 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(r.endDelim + ``) + return ast.WalkContinue, nil +} + +// RegisterFuncs registers the renderer for inline math nodes +func (r *InlineRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(KindInline, r.renderInline) +} diff --git a/modules/markup/markdown/math/math.go b/modules/markup/markdown/math/math.go new file mode 100644 index 0000000000000..6eb20b54c50d4 --- /dev/null +++ b/modules/markup/markdown/math/math.go @@ -0,0 +1,134 @@ +// 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 + inlineStartDelimRender string + inlineEndDelimRender string + blockStartDelimRender string + blockEndDelimRender string + 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 + }) +} + +// WithInlineDelimRender sets the start and end strings for the rendered inline delimiters +func WithInlineDelimRender(start, end string) Option { + return extensionFunc(func(e *Extension) { + e.inlineStartDelimRender = start + e.inlineEndDelimRender = end + }) +} + +// WithBlockDelimRender sets the start and end strings for the rendered block delimiters +func WithBlockDelimRender(start, end string) Option { + return extensionFunc(func(e *Extension) { + e.blockStartDelimRender = start + e.blockEndDelimRender = end + }) +} + +// Math represents a math extension with default rendered delimiters +var Math = &Extension{ + enabled: true, + inlineStartDelimRender: `\(`, + inlineEndDelimRender: `\)`, + blockStartDelimRender: `\[`, + blockEndDelimRender: `\]`, + parseDollarBlock: true, +} + +// NewExtension creates a new math extension with the provided options +func NewExtension(opts ...Option) *Extension { + r := &Extension{ + enabled: true, + inlineStartDelimRender: `\(`, + inlineEndDelimRender: `\)`, + blockStartDelimRender: `\[`, + blockEndDelimRender: `\]`, + parseDollarBlock: 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(e.blockStartDelimRender, e.blockEndDelimRender), 501), + util.Prioritized(NewInlineRenderer(e.inlineStartDelimRender, e.inlineEndDelimRender), 502), + )) +} diff --git a/modules/markup/sanitizer.go b/modules/markup/sanitizer.go index 57e88fdabc816..ba8ecd445c201 100644 --- a/modules/markup/sanitizer.go +++ b/modules/markup/sanitizer.go @@ -83,7 +83,7 @@ func createDefaultPolicy() *bluemonday.Policy { policy.AllowAttrs("class").Matching(regexp.MustCompile(`emoji`)).OnElements("img") // Allow icons, emojis, chroma syntax and keyword markup on span - policy.AllowAttrs("class").Matching(regexp.MustCompile(`^((icon(\s+[\p{L}\p{N}_-]+)+)|(emoji))$|^([a-z][a-z0-9]{0,2})$|^` + keywordClass + `$`)).OnElements("span") + policy.AllowAttrs("class").Matching(regexp.MustCompile(`^((icon(\s+[\p{L}\p{N}_-]+)+)|(emoji)|(math display)|(math inline))$|^([a-z][a-z0-9]{0,2})$|^` + keywordClass + `$`)).OnElements("span") // Allow 'style' attribute on text elements. policy.AllowAttrs("style").OnElements("span", "p") diff --git a/modules/setting/setting.go b/modules/setting/setting.go index 23e3280dc9f10..80b046628a1eb 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -332,10 +332,14 @@ var ( EnableHardLineBreakInDocuments bool CustomURLSchemes []string `ini:"CUSTOM_URL_SCHEMES"` FileExtensions []string + EnableMath bool + EnableInlineDollarMath bool }{ EnableHardLineBreakInComments: true, EnableHardLineBreakInDocuments: false, FileExtensions: strings.Split(".md,.markdown,.mdown,.mkd", ","), + EnableMath: true, + EnableInlineDollarMath: false, } // Admin settings diff --git a/package-lock.json b/package-lock.json index 8ebff450e2a5b..cb9e21fd2b1a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "font-awesome": "4.7.0", "jquery": "3.6.0", "jquery.are-you-sure": "1.9.0", + "katex": "0.16.0", "less": "4.1.3", "less-loader": "11.0.0", "license-checker-webpack-plugin": "0.2.1", @@ -8673,6 +8674,29 @@ "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-5.1.1.tgz", "integrity": "sha512-b+z6yF1d4EOyDgylzQo5IminlUmzSeqR1hs/bzjBNjuGras4FXq/6TrzjxfN0j+TmI0ltJzTNlqXUMCniciwKQ==" }, + "node_modules/katex": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.0.tgz", + "integrity": "sha512-wPRB4iUPysfH97wTgG5/tRLYxmKVq6Q4jRAWRVOUxXB1dsiv4cvcNjqabHkrOvJHM1Bpk3WrgmllSO1vIvP24w==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "dependencies": { + "commander": "^8.0.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "engines": { + "node": ">= 12" + } + }, "node_modules/khroma": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.0.0.tgz", @@ -19465,6 +19489,21 @@ "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-5.1.1.tgz", "integrity": "sha512-b+z6yF1d4EOyDgylzQo5IminlUmzSeqR1hs/bzjBNjuGras4FXq/6TrzjxfN0j+TmI0ltJzTNlqXUMCniciwKQ==" }, + "katex": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.0.tgz", + "integrity": "sha512-wPRB4iUPysfH97wTgG5/tRLYxmKVq6Q4jRAWRVOUxXB1dsiv4cvcNjqabHkrOvJHM1Bpk3WrgmllSO1vIvP24w==", + "requires": { + "commander": "^8.0.0" + }, + "dependencies": { + "commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==" + } + } + }, "khroma": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.0.0.tgz", diff --git a/package.json b/package.json index e4741f98fec88..2b8ef5c791a17 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "font-awesome": "4.7.0", "jquery": "3.6.0", "jquery.are-you-sure": "1.9.0", + "katex": "0.16.0", "less": "4.1.3", "less-loader": "11.0.0", "license-checker-webpack-plugin": "0.2.1", diff --git a/templates/base/footer.tmpl b/templates/base/footer.tmpl index 9bf16f8aa5b55..91263241ec88b 100644 --- a/templates/base/footer.tmpl +++ b/templates/base/footer.tmpl @@ -23,6 +23,10 @@ {{end}} {{end}} +{{if .MathEnabled}} + +{{end}} + {{template "custom/footer" .}} diff --git a/templates/base/head.tmpl b/templates/base/head.tmpl index e0d2b26f2cdb2..e811db8fa7abb 100644 --- a/templates/base/head.tmpl +++ b/templates/base/head.tmpl @@ -72,6 +72,9 @@ {{else if ne DefaultTheme "gitea"}} {{end}} +{{if .MathEnabled}} + +{{end}} {{template "custom/header" .}} diff --git a/web_src/js/katex.js b/web_src/js/katex.js new file mode 100644 index 0000000000000..7234ef10d7552 --- /dev/null +++ b/web_src/js/katex.js @@ -0,0 +1,41 @@ +import renderMathInElement from 'katex/dist/contrib/auto-render.js'; + +const mathNodes = document.querySelectorAll('.math'); + +const ourRender = (nodes) => { + for (const element of nodes) { + if (element.hasAttribute('katex-rendered') || !element.textContent) { + continue; + } + + renderMathInElement(element, { + delimiters: [ + {left: '\\[', right: '\\]', display: true}, + {left: '\\(', right: '\\)', display: false} + ], + errorCallback: (_, stack) => { + element.setAttribute('title', stack); + }, + }); + element.setAttribute('katex-rendered', 'yes'); + } +}; + +ourRender(mathNodes); + +// Options for the observer (which mutations to observe) +const config = {childList: true, subtree: true}; + +// Callback function to execute when mutations are observed +const callback = (records) => { + for (const record of records) { + const mathNodes = record.target.querySelectorAll('.math'); + ourRender(mathNodes); + } +}; + +// Create an observer instance linked to the callback function +const observer = new MutationObserver(callback); + +// Start observing the target node for configured mutations +observer.observe(document, config); diff --git a/webpack.config.js b/webpack.config.js index 5109103f7faf7..0763a26fd41b6 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -62,6 +62,12 @@ export default { 'eventsource.sharedworker': [ fileURLToPath(new URL('web_src/js/features/eventsource.sharedworker.js', import.meta.url)), ], + 'katex': [ + fileURLToPath(new URL('node_modules/katex/dist/katex.min.js', import.meta.url)), + fileURLToPath(new URL('node_modules/katex/dist/contrib/auto-render.min.js', import.meta.url)), + fileURLToPath(new URL('web_src/js/katex.js', import.meta.url)), + fileURLToPath(new URL('node_modules/katex/dist/katex.min.css', import.meta.url)), + ], ...themes, }, devtool: false,