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("
`)
+ 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" .}}