diff --git a/custom/conf/app.example.ini b/custom/conf/app.example.ini index dd4d5a4cbb3fe..11d418fbd1e48 100644 --- a/custom/conf/app.example.ini +++ b/custom/conf/app.example.ini @@ -1236,6 +1236,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 2cd4795e680d3..84cf55f9312fe 100644 --- a/docs/content/doc/advanced/config-cheat-sheet.en-us.md +++ b/docs/content/doc/advanced/config-cheat-sheet.en-us.md @@ -234,6 +234,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/go.mod b/go.mod index fa6fb911db1ab..dd0435e5bfef7 100644 --- a/go.mod +++ b/go.mod @@ -102,6 +102,7 @@ require ( gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df gopkg.in/ini.v1 v1.66.4 gopkg.in/yaml.v2 v2.4.0 + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b mvdan.cc/xurls/v2 v2.4.0 strk.kbt.io/projects/go/libravatar v0.0.0-20191008002943-06d1c002b251 xorm.io/builder v0.3.11 @@ -287,7 +288,6 @@ require ( gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/cheggaaa/pb.v1 v1.0.28 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect sigs.k8s.io/yaml v1.2.0 // indirect ) diff --git a/modules/markup/markdown/config/convertyaml.go b/modules/markup/markdown/config/convertyaml.go new file mode 100644 index 0000000000000..1ddf0b7d9012f --- /dev/null +++ b/modules/markup/markdown/config/convertyaml.go @@ -0,0 +1,85 @@ +// 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 config + +import ( + "code.gitea.io/gitea/modules/markup/markdown/extension" + "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 := extension.NewDetails() + summary := extension.NewSummary() + summary.AppendChild(summary, extension.NewIcon(icon)) + details.AppendChild(details, summary) + details.AppendChild(details, nodeToTable(meta)) + + return details +} diff --git a/modules/markup/markdown/config/renderconfig.go b/modules/markup/markdown/config/renderconfig.go new file mode 100644 index 0000000000000..1d173691c4b27 --- /dev/null +++ b/modules/markup/markdown/config/renderconfig.go @@ -0,0 +1,208 @@ +// Copyright 2020 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 config + +import ( + "fmt" + "strings" + + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "gopkg.in/yaml.v3" +) + +var renderConfigKey = parser.NewContextKey() + +func GetRenderConfig(pc parser.Context) *RenderConfig { + return pc.Get(renderConfigKey).(*RenderConfig) +} + +func SetRenderConfig(pc parser.Context, rc *RenderConfig) { + pc.Set(renderConfigKey, rc) +} + +// RenderConfig represents rendering configuration for this file +type RenderConfig struct { + Meta string + Icon string + TOC bool + Lang string + Math *MathConfig + yamlNode *yaml.Node +} + +type MathConfig struct { + InlineDollar bool `yaml:"inline_dollar"` + InlineLatex bool `yaml:"inline_latex"` + DisplayDollar bool `yaml:"display_dollar"` + DisplayLatex bool `yaml:"display_latex"` +} + +// UnmarshalYAML implement yaml.v3 UnmarshalYAML +func (rc *RenderConfig) UnmarshalYAML(value *yaml.Node) error { + rc.yamlNode = value + + basic := &yamlRenderConfig{} + err := value.Decode(basic) + if err != nil { + return fmt.Errorf("failed to decode basic: %w", err) + } + + if basic.Lang != "" { + rc.Lang = basic.Lang + } + + rc.TOC = basic.TOC + + if basic.Math != nil { + rc.Math = basic.Math + } + + if basic.Gitea != nil { + if basic.Gitea.Meta != nil { + rc.Meta = *basic.Gitea.Meta + } + if basic.Gitea.Icon != nil { + rc.Icon = *basic.Gitea.Icon + } + if basic.Gitea.Lang != nil { + rc.Lang = *basic.Gitea.Lang + } + if basic.Gitea.TOC != nil { + rc.TOC = *basic.Gitea.TOC + } + if basic.Gitea.Math != nil { + rc.Math = basic.Gitea.Math + } + } + + return nil +} + +type yamlRenderConfig struct { + TOC bool `yaml:"include_toc"` + Lang string `yaml:"lang"` + Math *MathConfig `yaml:"math"` + Gitea *yamlGitea `yaml:"gitea"` +} + +type yamlGitea struct { + Meta *string + Icon *string `yaml:"details_icon"` + TOC *bool `yaml:"include_toc"` + Lang *string + Math *MathConfig +} + +func (y *yamlGitea) UnmarshalYAML(node *yaml.Node) error { + var controlString string + if err := node.Decode(&controlString); err == nil { + var meta string + switch strings.TrimSpace(strings.ToLower(controlString)) { + case "none": + meta = "none" + case "table": + meta = "table" + default: // "details" + meta = "details" + } + y.Meta = &meta + return nil + } + + type yExactType yamlGitea + yExact := (*yExactType)(y) + if err := node.Decode(yExact); err != nil { + return fmt.Errorf("unable to parse yamlGitea: %w", err) + } + + return nil +} + +func (m *MathConfig) UnmarshalYAML(node *yaml.Node) error { + var controlBool bool + if err := node.Decode(&controlBool); err == nil { + m.InlineLatex = controlBool + m.DisplayLatex = controlBool + m.DisplayDollar = controlBool + // Not InlineDollar + m.InlineDollar = false + return nil + } + + var enableMathStrs []string + if err := node.Decode(&enableMathStrs); err != nil { + var enableMathStr string + if err := node.Decode(&enableMathStr); err == nil { + m.InlineLatex = false + m.DisplayLatex = false + m.DisplayDollar = false + m.InlineDollar = false + if enableMathStr == "" { + enableMathStr = "true" + } + enableMathStrs = strings.Split(enableMathStr, ",") + } + } + if enableMathStrs != nil { + for _, value := range enableMathStrs { + value = strings.TrimSpace(strings.ToLower(value)) + set := true + if value != "" && value[0] == '!' { + set = false + value = value[1:] + } + switch strings.TrimSpace(strings.ToLower(value)) { + case "none": + fallthrough + case "false": + m.InlineLatex = !set + m.DisplayLatex = !set + m.DisplayDollar = !set + m.InlineDollar = !set + case "all": + m.InlineLatex = set + m.DisplayLatex = set + m.DisplayDollar = set + m.InlineDollar = set + return nil + case "inline_dollar": + m.InlineDollar = set + case "inline_latex": + m.InlineLatex = set + case "display_dollar": + m.DisplayDollar = set + case "display_latex": + m.DisplayLatex = set + case "true": + m.InlineLatex = set + m.DisplayLatex = set + m.DisplayDollar = set + } + } + return nil + } + + type mExactType MathConfig + mExact := (*mExactType)(m) + if err := node.Decode(mExact); err != nil { + return fmt.Errorf("unable to parse MathConfig: %w", err) + } + return nil +} + +func (rc *RenderConfig) ToMetaNode() ast.Node { + if rc.yamlNode == nil { + return nil + } + switch rc.Meta { + case "table": + return nodeToTable(rc.yamlNode) + case "details": + return nodeToDetails(rc.yamlNode, rc.Icon) + default: + return nil + } +} diff --git a/modules/markup/markdown/config/renderconfig_test.go b/modules/markup/markdown/config/renderconfig_test.go new file mode 100644 index 0000000000000..d3a8ac23569a5 --- /dev/null +++ b/modules/markup/markdown/config/renderconfig_test.go @@ -0,0 +1,288 @@ +// 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 config + +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 +`, + }, + { + "complex2", &RenderConfig{ + Lang: "two", + Meta: "table", + TOC: true, + Icon: "smiley", + }, ` + lang: one + include_toc: false + gitea: + details_icon: smiley + meta: table + include_toc: true + lang: two +`, + }, + { + "complex3", &RenderConfig{ + Lang: "two", + Meta: "table", + TOC: false, + Icon: "smiley", + }, ` + lang: one + include_toc: true + gitea: + details_icon: smiley + meta: table + include_toc: false + lang: two +`, + }, + { + "mathall", &RenderConfig{ + Meta: "table", + Icon: "table", + Lang: "testlang", + Math: &MathConfig{ + InlineDollar: true, + InlineLatex: true, + DisplayDollar: true, + DisplayLatex: true, + }, + }, ` + math: all + gitea: + lang: testlang +`, + }, + { + "mathtrue", &RenderConfig{ + Meta: "table", + Icon: "table", + Lang: "testlang", + Math: &MathConfig{ + InlineDollar: false, + InlineLatex: true, + DisplayDollar: true, + DisplayLatex: true, + }, + }, ` + math: true + gitea: + lang: testlang +`, + }, + { + "mathstrings", &RenderConfig{ + Meta: "table", + Icon: "table", + Lang: "testlang", + Math: &MathConfig{ + InlineDollar: true, + InlineLatex: false, + DisplayDollar: true, + DisplayLatex: false, + }, + }, ` + math: "display_dollar,inline_dollar" + gitea: + lang: testlang +`, + }, + { + "mathstringarray", &RenderConfig{ + Meta: "table", + Icon: "table", + Lang: "testlang", + Math: &MathConfig{ + InlineDollar: true, + InlineLatex: false, + DisplayDollar: true, + DisplayLatex: false, + }, + }, ` + math: [display_dollar,inline_dollar] + gitea: + lang: testlang +`, + }, + { + "mathstringarrayalone", &RenderConfig{ + Meta: "table", + Icon: "table", + Lang: "", + Math: &MathConfig{ + InlineDollar: true, + InlineLatex: false, + DisplayDollar: true, + DisplayLatex: false, + }, + }, `math: [display_dollar,inline_dollar]`, + }, + { + "mathstruct", &RenderConfig{ + Meta: "table", + Icon: "table", + Lang: "testlang", + Math: &MathConfig{ + InlineDollar: false, + InlineLatex: true, + DisplayDollar: true, + DisplayLatex: false, + }, + }, ` + math: + display_dollar: true + inline_latex: true + gitea: + lang: testlang +`, + }, + { + "mathoverride", &RenderConfig{ + Meta: "table", + Icon: "table", + Lang: "testlang", + Math: &MathConfig{ + InlineDollar: false, + InlineLatex: true, + DisplayDollar: true, + DisplayLatex: false, + }, + }, ` + math: + inline_dollar: true + display_latex: true + gitea: + lang: testlang + math: + display_dollar: true + inline_latex: true +`, + }, + } + 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) + } + }) + } +} diff --git a/modules/markup/markdown/ast.go b/modules/markup/markdown/extension/ast.go similarity index 99% rename from modules/markup/markdown/ast.go rename to modules/markup/markdown/extension/ast.go index 5191d94cdd85a..eb5901c87510c 100644 --- a/modules/markup/markdown/ast.go +++ b/modules/markup/markdown/extension/ast.go @@ -2,7 +2,7 @@ // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. -package markdown +package extension import ( "strconv" diff --git a/modules/markup/markdown/toc.go b/modules/markup/markdown/extension/toc.go similarity index 86% rename from modules/markup/markdown/toc.go rename to modules/markup/markdown/extension/toc.go index 103894d1abfc1..7198c49392660 100644 --- a/modules/markup/markdown/toc.go +++ b/modules/markup/markdown/extension/toc.go @@ -2,7 +2,7 @@ // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. -package markdown +package extension import ( "fmt" @@ -14,7 +14,8 @@ import ( "github.com/yuin/goldmark/ast" ) -func createTOCNode(toc []markup.Header, lang string) ast.Node { +// CreateTOCNode creates a Table of Contents node for a provided slices of Headers corresponding to the TOC +func CreateTOCNode(toc []markup.Header, lang string) ast.Node { details := NewDetails() summary := NewSummary() diff --git a/modules/markup/markdown/goldmark.go b/modules/markup/markdown/goldmark.go index 1750128dec858..edb34a793f8b4 100644 --- a/modules/markup/markdown/goldmark.go +++ b/modules/markup/markdown/goldmark.go @@ -12,10 +12,11 @@ import ( "code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup/common" + "code.gitea.io/gitea/modules/markup/markdown/config" + "code.gitea.io/gitea/modules/markup/markdown/extension" "code.gitea.io/gitea/modules/setting" giteautil "code.gitea.io/gitea/modules/util" - meta "github.com/yuin/goldmark-meta" "github.com/yuin/goldmark/ast" east "github.com/yuin/goldmark/extension/ast" "github.com/yuin/goldmark/parser" @@ -32,26 +33,14 @@ type ASTTransformer struct{} // Transform transforms the given AST tree. func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) { - metaData := meta.GetItems(pc) firstChild := node.FirstChild() - createTOC := false ctx := pc.Get(renderContextKey).(*markup.RenderContext) - rc := &RenderConfig{ - Meta: "table", - Icon: "table", - Lang: "", - } - - if metaData != nil { - rc.ToRenderConfig(metaData) - - metaNode := rc.toMetaNode(metaData) - if metaNode != nil { - node.InsertBefore(node, firstChild, metaNode) - } - createTOC = rc.TOC - ctx.TableOfContents = make([]markup.Header, 0, 100) + rc := config.GetRenderConfig(pc) + metaNode := rc.ToMetaNode() + if metaNode != nil { + node.InsertBefore(node, firstChild, metaNode) } + ctx.TableOfContents = make([]markup.Header, 0, 100) _ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { @@ -170,7 +159,7 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa v.AppendChild(v, child) continue } - newChild := NewTaskCheckBoxListItem(listItem) + newChild := extension.NewTaskCheckBoxListItem(listItem) newChild.IsChecked = taskCheckBox.IsChecked newChild.SetAttributeString("class", []byte("task-list-item")) v.AppendChild(v, newChild) @@ -190,12 +179,12 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa return ast.WalkContinue, nil }) - if createTOC && len(ctx.TableOfContents) > 0 { + if rc.TOC && len(ctx.TableOfContents) > 0 { lang := rc.Lang if len(lang) == 0 { lang = setting.Langs[0] } - tocNode := createTOCNode(ctx.TableOfContents, lang) + tocNode := extension.CreateTOCNode(ctx.TableOfContents, lang) if tocNode != nil { node.InsertBefore(node, firstChild, tocNode) } @@ -273,10 +262,10 @@ type HTMLRenderer struct { // RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs. func (r *HTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { reg.Register(ast.KindDocument, r.renderDocument) - reg.Register(KindDetails, r.renderDetails) - reg.Register(KindSummary, r.renderSummary) - reg.Register(KindIcon, r.renderIcon) - reg.Register(KindTaskCheckBoxListItem, r.renderTaskCheckBoxListItem) + reg.Register(extension.KindDetails, r.renderDetails) + reg.Register(extension.KindSummary, r.renderSummary) + reg.Register(extension.KindIcon, r.renderIcon) + reg.Register(extension.KindTaskCheckBoxListItem, r.renderTaskCheckBoxListItem) reg.Register(east.KindTaskCheckBox, r.renderTaskCheckBox) } @@ -342,7 +331,7 @@ func (r *HTMLRenderer) renderIcon(w util.BufWriter, source []byte, node ast.Node return ast.WalkContinue, nil } - n := node.(*Icon) + n := node.(*extension.Icon) name := strings.TrimSpace(strings.ToLower(string(n.Name))) @@ -367,7 +356,7 @@ func (r *HTMLRenderer) renderIcon(w util.BufWriter, source []byte, node ast.Node } func (r *HTMLRenderer) renderTaskCheckBoxListItem(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { - n := node.(*TaskCheckBoxListItem) + n := node.(*extension.TaskCheckBoxListItem) if entering { if n.Attributes() != nil { _, _ = w.WriteString("= 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..d502065259ec8 --- /dev/null +++ b/modules/markup/markdown/math/block_renderer.go @@ -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(`
`)
+		r.writeLines(w, source, n)
+	} else {
+		_, _ = w.WriteString(`
` + "\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..45c4d08ebc6a0 --- /dev/null +++ b/modules/markup/markdown/math/inline_parser.go @@ -0,0 +1,111 @@ +// 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" + + "code.gitea.io/gitea/modules/markup/markdown/config" + "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 { + rc := config.GetRenderConfig(pc) + if !rc.Math.InlineDollar && parser.start[0] == '$' { + return nil + } else if !rc.Math.InlineLatex && parser.start[0] == '\\' { + return nil + } + + 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..e4c0f3761daca --- /dev/null +++ b/modules/markup/markdown/math/inline_renderer.go @@ -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(``) + 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(``) + 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..3080b0fd591f3 --- /dev/null +++ b/modules/markup/markdown/math/math.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 ( + "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{} + +// Option is the interface Options should implement +type Option interface { + SetOption(e *Extension) +} + +// Math represents a math extension with default rendered delimiters +var Math = &Extension{} + +// NewExtension creates a new math extension with the provided options +func NewExtension(opts ...Option) *Extension { + r := &Extension{} + + for _, o := range opts { + o.SetOption(r) + } + return r +} + +// Extend extends goldmark with our parsers and renderers +func (e *Extension) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithBlockParsers( + util.Prioritized(NewBlockParser(), 701), + )) + + m.Parser().AddOptions(parser.WithInlineParsers( + util.Prioritized(NewInlineBracketParser(), 501), + util.Prioritized(NewInlineDollarParser(), 501), + )) + + m.Renderer().AddOptions(renderer.WithNodeRenderers( + util.Prioritized(NewBlockRenderer(), 501), + util.Prioritized(NewInlineRenderer(), 502), + )) +} diff --git a/modules/markup/markdown/meta.go b/modules/markup/markdown/meta.go index faf92ae2c6b78..28913fd68457f 100644 --- a/modules/markup/markdown/meta.go +++ b/modules/markup/markdown/meta.go @@ -5,47 +5,101 @@ package markdown import ( + "bytes" "errors" - "strings" + "unicode" + "unicode/utf8" - "gopkg.in/yaml.v2" + "code.gitea.io/gitea/modules/log" + "gopkg.in/yaml.v3" ) -func isYAMLSeparator(line string) bool { - line = strings.TrimSpace(line) - for i := 0; i < len(line); i++ { - if line[i] != '-' { +func isYAMLSeparator(line []byte) bool { + idx := 0 + for ; idx < len(line); idx++ { + if line[idx] >= utf8.RuneSelf { + r, sz := utf8.DecodeRune(line[idx:]) + if !unicode.IsSpace(r) { + return false + } + idx += sz + continue + } + if line[idx] != ' ' { + break + } + } + dashCount := 0 + for ; idx < len(line); idx++ { + if line[idx] != '-' { + break + } + dashCount++ + } + if dashCount < 3 { + return false + } + for ; idx < len(line); idx++ { + if line[idx] >= utf8.RuneSelf { + r, sz := utf8.DecodeRune(line[idx:]) + if !unicode.IsSpace(r) { + return false + } + idx += sz + continue + } + if line[idx] != ' ' { return false } } - return len(line) > 2 + return true } // ExtractMetadata consumes a markdown file, parses YAML frontmatter, // and returns the frontmatter metadata separated from the markdown content func ExtractMetadata(contents string, out interface{}) (string, error) { - var front, body []string - lines := strings.Split(contents, "\n") - for idx, line := range lines { - if idx == 0 { - // First line has to be a separator - if !isYAMLSeparator(line) { - return "", errors.New("frontmatter must start with a separator line") - } - continue + body, err := ExtractMetadataBytes([]byte(contents), out) + return string(body), err +} + +// ExtractMetadata consumes a markdown file, parses YAML frontmatter, +// and returns the frontmatter metadata separated from the markdown content +func ExtractMetadataBytes(contents []byte, out interface{}) ([]byte, error) { + var front, body []byte + + start, end := 0, len(contents) + idx := bytes.IndexByte(contents[start:], '\n') + if idx >= 0 { + end = start + idx + } + line := contents[start:end] + + if !isYAMLSeparator(line) { + return contents, errors.New("frontmatter must start with a separator line") + } + frontMatterStart := end + 1 + for start = frontMatterStart; start < len(contents); start = end + 1 { + end = len(contents) + idx := bytes.IndexByte(contents[start:], '\n') + if idx >= 0 { + end = start + idx } + line := contents[start:end] if isYAMLSeparator(line) { - front, body = lines[1:idx], lines[idx+1:] + front = contents[frontMatterStart:start] + body = contents[end+1:] break } } if len(front) == 0 { - return "", errors.New("could not determine metadata") + return contents, errors.New("could not determine metadata") } - if err := yaml.Unmarshal([]byte(strings.Join(front, "\n")), out); err != nil { - return "", err + log.Info("%s", string(front)) + + if err := yaml.Unmarshal(front, out); err != nil { + return contents, err } - return strings.Join(body, "\n"), nil + return body, nil } diff --git a/modules/markup/markdown/meta_test.go b/modules/markup/markdown/meta_test.go index f525777a54c15..c9bb7e1af235b 100644 --- a/modules/markup/markdown/meta_test.go +++ b/modules/markup/markdown/meta_test.go @@ -45,6 +45,38 @@ func TestExtractMetadata(t *testing.T) { }) } +func TestExtractMetadataBytes(t *testing.T) { + t.Run("ValidFrontAndBody", func(t *testing.T) { + var meta structs.IssueTemplate + body, err := ExtractMetadataBytes([]byte(fmt.Sprintf("%s\n%s\n%s\n%s", sepTest, frontTest, sepTest, bodyTest)), &meta) + assert.NoError(t, err) + assert.Equal(t, bodyTest, body) + assert.Equal(t, metaTest, meta) + assert.True(t, meta.Valid()) + }) + + t.Run("NoFirstSeparator", func(t *testing.T) { + var meta structs.IssueTemplate + _, err := ExtractMetadataBytes([]byte(fmt.Sprintf("%s\n%s\n%s", frontTest, sepTest, bodyTest)), &meta) + assert.Error(t, err) + }) + + t.Run("NoLastSeparator", func(t *testing.T) { + var meta structs.IssueTemplate + _, err := ExtractMetadataBytes([]byte(fmt.Sprintf("%s\n%s\n%s", sepTest, frontTest, bodyTest)), &meta) + assert.Error(t, err) + }) + + t.Run("NoBody", func(t *testing.T) { + var meta structs.IssueTemplate + body, err := ExtractMetadataBytes([]byte(fmt.Sprintf("%s\n%s\n%s", sepTest, frontTest, sepTest)), &meta) + assert.NoError(t, err) + assert.Equal(t, "", body) + assert.Equal(t, metaTest, meta) + assert.True(t, meta.Valid()) + }) +} + var ( sepTest = "-----" frontTest = `name: Test diff --git a/modules/markup/markdown/renderconfig.go b/modules/markup/markdown/renderconfig.go deleted file mode 100644 index bef67e9e59bf2..0000000000000 --- a/modules/markup/markdown/renderconfig.go +++ /dev/null @@ -1,163 +0,0 @@ -// Copyright 2020 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 ( - "fmt" - "strings" - - "github.com/yuin/goldmark/ast" - east "github.com/yuin/goldmark/extension/ast" - "gopkg.in/yaml.v2" -) - -// RenderConfig represents rendering configuration for this file -type RenderConfig struct { - Meta string - Icon string - TOC bool - Lang string -} - -// ToRenderConfig converts a yaml.MapSlice to a RenderConfig -func (rc *RenderConfig) ToRenderConfig(meta yaml.MapSlice) { - if meta == nil { - return - } - found := false - var giteaMetaControl yaml.MapItem - for _, item := range meta { - strKey, ok := item.Key.(string) - if !ok { - continue - } - strKey = strings.TrimSpace(strings.ToLower(strKey)) - switch strKey { - case "gitea": - giteaMetaControl = item - found = true - case "include_toc": - val, ok := item.Value.(bool) - if !ok { - continue - } - rc.TOC = val - case "lang": - val, ok := item.Value.(string) - if !ok { - continue - } - val = strings.TrimSpace(val) - if len(val) == 0 { - continue - } - rc.Lang = val - } - } - - if found { - switch v := giteaMetaControl.Value.(type) { - case string: - switch v { - case "none": - rc.Meta = "none" - case "table": - rc.Meta = "table" - default: // "details" - rc.Meta = "details" - } - case yaml.MapSlice: - for _, item := range v { - strKey, ok := item.Key.(string) - if !ok { - continue - } - strKey = strings.TrimSpace(strings.ToLower(strKey)) - switch strKey { - case "meta": - val, ok := item.Value.(string) - if !ok { - continue - } - switch strings.TrimSpace(strings.ToLower(val)) { - case "none": - rc.Meta = "none" - case "table": - rc.Meta = "table" - default: // "details" - rc.Meta = "details" - } - case "details_icon": - val, ok := item.Value.(string) - if !ok { - continue - } - rc.Icon = strings.TrimSpace(strings.ToLower(val)) - case "include_toc": - val, ok := item.Value.(bool) - if !ok { - continue - } - rc.TOC = val - case "lang": - val, ok := item.Value.(string) - if !ok { - continue - } - val = strings.TrimSpace(val) - if len(val) == 0 { - continue - } - rc.Lang = val - } - } - } - } -} - -func (rc *RenderConfig) toMetaNode(meta yaml.MapSlice) ast.Node { - switch rc.Meta { - case "table": - return metaToTable(meta) - case "details": - return metaToDetails(meta, rc.Icon) - default: - return nil - } -} - -func metaToTable(meta yaml.MapSlice) ast.Node { - table := east.NewTable() - alignments := []east.Alignment{} - for range meta { - alignments = append(alignments, east.AlignNone) - } - row := east.NewTableRow(alignments) - for _, item := range meta { - cell := east.NewTableCell() - cell.AppendChild(cell, ast.NewString([]byte(fmt.Sprintf("%v", item.Key)))) - row.AppendChild(row, cell) - } - table.AppendChild(table, east.NewTableHeader(row)) - - row = east.NewTableRow(alignments) - for _, item := range meta { - cell := east.NewTableCell() - cell.AppendChild(cell, ast.NewString([]byte(fmt.Sprintf("%v", item.Value)))) - row.AppendChild(row, cell) - } - table.AppendChild(table, row) - return table -} - -func metaToDetails(meta yaml.MapSlice, icon string) ast.Node { - details := NewDetails() - summary := NewSummary() - summary.AppendChild(summary, NewIcon(icon)) - details.AppendChild(details, summary) - details.AppendChild(details, metaToTable(meta)) - - return details -} diff --git a/modules/markup/sanitizer.go b/modules/markup/sanitizer.go index 57e88fdabc816..807a8a7892b3d 100644 --- a/modules/markup/sanitizer.go +++ b/modules/markup/sanitizer.go @@ -56,7 +56,7 @@ func createDefaultPolicy() *bluemonday.Policy { policy.AllowAttrs("class").Matching(regexp.MustCompile(`^code-block( is-loading)?$`)).OnElements("pre") // For Chroma markdown plugin - policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(chroma )?language-[\w-]+$`)).OnElements("code") + policy.AllowAttrs("class").Matching(regexp.MustCompile(`^(chroma )?language-[\w-]+( display)?( is-loading)?$`)).OnElements("code") // Checkboxes policy.AllowAttrs("type").Matching(regexp.MustCompile(`^checkbox$`)).OnElements("input") @@ -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)|(language-math display)|(language-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 0af743dd97c27..0aa6326bb94c8 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -333,10 +333,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 aabbd84fd9bc9..28b35b2320b1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,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", @@ -8914,6 +8915,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", @@ -19803,6 +19827,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 42cba24f85158..da21030218bf8 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,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..cd783d92c5379 100644 --- a/templates/base/footer.tmpl +++ b/templates/base/footer.tmpl @@ -23,6 +23,7 @@ {{end}} {{end}} + {{template "custom/footer" .}} diff --git a/web_src/js/markup/content.js b/web_src/js/markup/content.js index ef5067fd66520..5c0428c69692d 100644 --- a/web_src/js/markup/content.js +++ b/web_src/js/markup/content.js @@ -1,10 +1,12 @@ import {renderMermaid} from './mermaid.js'; +import {renderMath} from './katex.js'; import {renderCodeCopy} from './codecopy.js'; import {initMarkupTasklist} from './tasklist.js'; // code that runs for all markup content export function initMarkupContent() { renderMermaid(); + renderMath(); renderCodeCopy(); } diff --git a/web_src/js/markup/katex.js b/web_src/js/markup/katex.js new file mode 100644 index 0000000000000..35f97f69df52b --- /dev/null +++ b/web_src/js/markup/katex.js @@ -0,0 +1,46 @@ +function displayError(el, err) { + let target = el; + if (el.classList.contains('is-loading')) { + // assume no pre + el.classList.remove('is-loading'); + } else { + target = el.closest('pre'); + target.classList.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); +} + +export async function renderMath() { + const els = document.querySelectorAll('.markup code.language-math'); + if (!els.length) return; + + const {default: katex} = await import(/* webpackChunkName: "katex" */'katex'); + + for (const el of els) { + const source = el.textContent; + + const options = {}; + options.display = el.classList.contains('display'); + + try { + const markup = katex.renderToString(source, options); + let target; + if (options.display) { + target = document.createElement('p'); + } else { + target = document.createElement('span'); + } + target.innerHTML = markup; + if (el.classList.contains('is-loading')) { + el.replaceWith(target); + } else { + el.closest('pre').replaceWith(target); + } + } catch (error) { + displayError(el, error); + } + } +} diff --git a/webpack.config.js b/webpack.config.js index 5109103f7faf7..b69d276d32d73 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -50,6 +50,7 @@ export default { fileURLToPath(new URL('web_src/js/index.js', import.meta.url)), fileURLToPath(new URL('node_modules/easymde/dist/easymde.min.css', import.meta.url)), fileURLToPath(new URL('web_src/fomantic/build/semantic.css', import.meta.url)), + fileURLToPath(new URL('node_modules/katex/dist/katex.css', import.meta.url)), fileURLToPath(new URL('web_src/less/index.less', import.meta.url)), ], swagger: [