diff --git a/modules/markup/markdown/markdown.go b/modules/markup/markdown/markdown.go index f77db9eb38..a14c0cad59 100644 --- a/modules/markup/markdown/markdown.go +++ b/modules/markup/markdown/markdown.go @@ -78,26 +78,23 @@ func (r *GlodmarkRender) Renderer() renderer.Renderer { func (r *GlodmarkRender) highlightingRenderer(w util.BufWriter, c highlighting.CodeBlockContext, entering bool) { if entering { - language, _ := c.Language() - if language == nil { - language = []byte("text") - } + languageBytes, _ := c.Language() + languageStr := giteautil.IfZero(string(languageBytes), "text") - languageStr := string(language) - - preClasses := []string{"code-block"} + preClasses := "code-block" if languageStr == "mermaid" || languageStr == "math" { - preClasses = append(preClasses, "is-loading") + preClasses += " is-loading" } - err := r.ctx.RenderInternal.FormatWithSafeAttrs(w, `
`, strings.Join(preClasses, " ")) + err := r.ctx.RenderInternal.FormatWithSafeAttrs(w, ``, preClasses) if err != nil { return } - // include language-x class as part of commonmark spec - // the "display" class is used by "js/markup/math.js" to render the code element as a block - err = r.ctx.RenderInternal.FormatWithSafeAttrs(w, ``, string(language)) + // include language-x class as part of commonmark spec, "chroma" class is used to highlight the code + // the "display" class is used by "js/markup/math.ts" to render the code element as a block + // the "math.ts" strictly depends on the structure:
+ err = r.ctx.RenderInternal.FormatWithSafeAttrs(w, `...
`, languageStr) if err != nil { return } @@ -128,7 +125,12 @@ func SpecializedMarkdown(ctx *markup.RenderContext) *GlodmarkRender { ), highlighting.WithWrapperRenderer(r.highlightingRenderer), ), - math.NewExtension(&ctx.RenderInternal, math.Enabled(setting.Markdown.EnableMath)), + math.NewExtension(&ctx.RenderInternal, math.Options{ + Enabled: setting.Markdown.EnableMath, + ParseDollarInline: true, + ParseDollarBlock: true, + ParseSquareBlock: true, // TODO: this is a bad syntax, it should be deprecated in the future (by some config options) + }), meta.Meta, ), goldmark.WithParserOptions( diff --git a/modules/markup/markdown/markdown_math_test.go b/modules/markup/markdown/markdown_math_test.go index a2213b2ce7..813f050965 100644 --- a/modules/markup/markdown/markdown_math_test.go +++ b/modules/markup/markdown/markdown_math_test.go @@ -12,8 +12,9 @@ import ( "github.com/stretchr/testify/assert" ) +const nl = "\n" + func TestMathRender(t *testing.T) { - const nl = "\n" testcases := []struct { testcase string expected string @@ -86,6 +87,18 @@ func TestMathRender(t *testing.T) { `$\text{$b$}$`, `
` + nl, }, + { + "a$`b`$c", + `
\text{$b$}
a
` + nl, + }, + { + "a $`b`$ c", + `b
ca
` + nl, + }, + { + "a$``b``$c x$```y```$z", + `b
ca
` + nl, + }, } for _, test := range testcases { @@ -215,6 +228,11 @@ x `, }, + { + "inline-non-math", + `\[x]`, + `b
c xy
z[x]
` + nl, + }, } for _, test := range testcases { diff --git a/modules/markup/markdown/math/block_parser.go b/modules/markup/markdown/math/block_parser.go index 3f37ce8333..2c5553550a 100644 --- a/modules/markup/markdown/math/block_parser.go +++ b/modules/markup/markdown/math/block_parser.go @@ -16,16 +16,18 @@ import ( type blockParser struct { parseDollars bool + parseSquare bool endBytesDollars []byte - endBytesBracket []byte + endBytesSquare []byte } // NewBlockParser creates a new math BlockParser -func NewBlockParser(parseDollarBlocks bool) parser.BlockParser { +func NewBlockParser(parseDollars, parseSquare bool) parser.BlockParser { return &blockParser{ - parseDollars: parseDollarBlocks, + parseDollars: parseDollars, + parseSquare: parseSquare, endBytesDollars: []byte{'$', '$'}, - endBytesBracket: []byte{'\\', ']'}, + endBytesSquare: []byte{'\\', ']'}, } } @@ -40,7 +42,7 @@ func (b *blockParser) Open(parent ast.Node, reader text.Reader, pc parser.Contex var dollars bool if b.parseDollars && line[pos] == '$' && line[pos+1] == '$' { dollars = true - } else if line[pos] == '\\' && line[pos+1] == '[' { + } else if b.parseSquare && line[pos] == '\\' && line[pos+1] == '[' { if len(line[pos:]) >= 3 && line[pos+2] == '!' && bytes.Contains(line[pos:], []byte(`\]`)) { // do not process escaped attention block: "> \[!NOTE\]" return nil, parser.NoChildren @@ -53,10 +55,10 @@ func (b *blockParser) Open(parent ast.Node, reader text.Reader, pc parser.Contex node := NewBlock(dollars, pos) // Now we need to check if the ending block is on the segment... - endBytes := giteaUtil.Iif(dollars, b.endBytesDollars, b.endBytesBracket) + endBytes := giteaUtil.Iif(dollars, b.endBytesDollars, b.endBytesSquare) idx := bytes.Index(line[pos+2:], endBytes) if idx >= 0 { - // for case $$ ... $$ any other text + // for case: "$$ ... $$ any other text" (this case will be handled by the inline parser) for i := pos + 2 + idx + 2; i < len(line); i++ { if line[i] != ' ' && line[i] != '\n' { return nil, parser.NoChildren @@ -70,6 +72,13 @@ func (b *blockParser) Open(parent ast.Node, reader text.Reader, pc parser.Contex return node, parser.Close | parser.NoChildren } + // for case "\[ ... ]" (no close marker on the same line) + for i := pos + 2 + idx + 2; i < len(line); i++ { + if line[i] != ' ' && line[i] != '\n' { + return nil, parser.NoChildren + } + } + segment.Start += pos + 2 node.Lines().Append(segment) return node, parser.NoChildren @@ -85,7 +94,7 @@ func (b *blockParser) Continue(node ast.Node, reader text.Reader, pc parser.Cont line, segment := reader.PeekLine() w, pos := util.IndentWidth(line, reader.LineOffset()) if w < 4 { - endBytes := giteaUtil.Iif(block.Dollars, b.endBytesDollars, b.endBytesBracket) + endBytes := giteaUtil.Iif(block.Dollars, b.endBytesDollars, b.endBytesSquare) if bytes.HasPrefix(line[pos:], endBytes) && util.IsBlank(line[pos+len(endBytes):]) { if util.IsBlank(line[pos+len(endBytes):]) { newline := giteaUtil.Iif(line[len(line)-1] != '\n', 0, 1) diff --git a/modules/markup/markdown/math/inline_block_node.go b/modules/markup/markdown/math/inline_block_node.go deleted file mode 100644 index c92d0c8d84..0000000000 --- a/modules/markup/markdown/math/inline_block_node.go +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright 2024 The Gitea Authors. All rights reserved. -// SPDX-License-Identifier: MIT - -package math - -import ( - "github.com/yuin/goldmark/ast" -) - -// InlineBlock represents inline math e.g. $$...$$ -type InlineBlock struct { - Inline -} - -// InlineBlock implements InlineBlock. -func (n *InlineBlock) InlineBlock() {} - -// KindInlineBlock is the kind for math inline block -var KindInlineBlock = ast.NewNodeKind("MathInlineBlock") - -// Kind returns KindInlineBlock -func (n *InlineBlock) Kind() ast.NodeKind { - return KindInlineBlock -} - -// NewInlineBlock creates a new ast math inline block node -func NewInlineBlock() *InlineBlock { - return &InlineBlock{ - Inline{}, - } -} diff --git a/modules/markup/markdown/math/inline_node.go b/modules/markup/markdown/math/inline_node.go index 2221a251bf..1e4034d54b 100644 --- a/modules/markup/markdown/math/inline_node.go +++ b/modules/markup/markdown/math/inline_node.go @@ -8,7 +8,7 @@ import ( "github.com/yuin/goldmark/util" ) -// Inline represents inline math e.g. $...$ or \(...\) +// Inline struct represents inline math e.g. $...$ or \(...\) type Inline struct { ast.BaseInline } diff --git a/modules/markup/markdown/math/inline_parser.go b/modules/markup/markdown/math/inline_parser.go index 191d1e5a31..a57abe9f9b 100644 --- a/modules/markup/markdown/math/inline_parser.go +++ b/modules/markup/markdown/math/inline_parser.go @@ -12,31 +12,25 @@ import ( ) type inlineParser struct { - start []byte - end []byte + trigger []byte + endBytesSingleDollar []byte + endBytesDoubleDollar []byte + endBytesBracket []byte } var defaultInlineDollarParser = &inlineParser{ - start: []byte{'$'}, - end: []byte{'$'}, -} - -var defaultDualDollarParser = &inlineParser{ - start: []byte{'$', '$'}, - end: []byte{'$', '$'}, + trigger: []byte{'$'}, + endBytesSingleDollar: []byte{'$'}, + endBytesDoubleDollar: []byte{'$', '$'}, } func NewInlineDollarParser() parser.InlineParser { return defaultInlineDollarParser } -func NewInlineDualDollarParser() parser.InlineParser { - return defaultDualDollarParser -} - var defaultInlineBracketParser = &inlineParser{ - start: []byte{'\\', '('}, - end: []byte{'\\', ')'}, + trigger: []byte{'\\', '('}, + endBytesBracket: []byte{'\\', ')'}, } func NewInlineBracketParser() parser.InlineParser { @@ -45,7 +39,7 @@ func NewInlineBracketParser() parser.InlineParser { // Trigger triggers this parser on $ or \ func (parser *inlineParser) Trigger() []byte { - return parser.start + return parser.trigger } func isPunctuation(b byte) bool { @@ -64,33 +58,60 @@ func isAlphanumeric(b byte) bool { func (parser *inlineParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { line, _ := block.PeekLine() - if !bytes.HasPrefix(line, parser.start) { + if !bytes.HasPrefix(line, parser.trigger) { // We'll catch this one on the next time round return nil } - precedingCharacter := block.PrecendingCharacter() - if precedingCharacter < 256 && (isAlphanumeric(byte(precedingCharacter)) || isPunctuation(byte(precedingCharacter))) { - // need to exclude things like `a$` from being considered a start - return nil + var startMarkLen int + var stopMark []byte + checkSurrounding := true + if line[0] == '$' { + startMarkLen = 1 + stopMark = parser.endBytesSingleDollar + if len(line) > 1 { + if line[1] == '$' { + startMarkLen = 2 + stopMark = parser.endBytesDoubleDollar + } else if line[1] == '`' { + pos := 1 + for ; pos < len(line) && line[pos] == '`'; pos++ { + } + startMarkLen = pos + stopMark = bytes.Repeat([]byte{'`'}, pos) + stopMark[len(stopMark)-1] = '$' + checkSurrounding = false + } + } + } else { + startMarkLen = 2 + stopMark = parser.endBytesBracket + } + + if checkSurrounding { + precedingCharacter := block.PrecendingCharacter() + if precedingCharacter < 256 && (isAlphanumeric(byte(precedingCharacter)) || isPunctuation(byte(precedingCharacter))) { + // need to exclude things like `a$` from being considered a start + return nil + } } // move the opener marker point at the start of the text - opener := len(parser.start) + opener := startMarkLen // Now look for an ending line depth := 0 ender := -1 for i := opener; i < len(line); i++ { - if depth == 0 && bytes.HasPrefix(line[i:], parser.end) { + if depth == 0 && bytes.HasPrefix(line[i:], stopMark) { succeedingCharacter := byte(0) - if i+len(parser.end) < len(line) { - succeedingCharacter = line[i+len(parser.end)] + if i+len(stopMark) < len(line) { + succeedingCharacter = line[i+len(stopMark)] } // check valid ending character isValidEndingChar := isPunctuation(succeedingCharacter) || isBracket(succeedingCharacter) || succeedingCharacter == ' ' || succeedingCharacter == '\n' || succeedingCharacter == 0 - if !isValidEndingChar { + if checkSurrounding && !isValidEndingChar { break } ender = i @@ -112,21 +133,12 @@ func (parser *inlineParser) Parse(parent ast.Node, block text.Reader, pc parser. block.Advance(opener) _, pos := block.Position() - var node ast.Node - if parser == defaultDualDollarParser { - node = NewInlineBlock() - } else { - node = NewInline() - } + node := NewInline() + segment := pos.WithStop(pos.Start + ender - opener) node.AppendChild(node, ast.NewRawTextSegment(segment)) - block.Advance(ender - opener + len(parser.end)) - - if parser == defaultDualDollarParser { - trimBlock(&(node.(*InlineBlock)).Inline, block) - } else { - trimBlock(node.(*Inline), block) - } + block.Advance(ender - opener + len(stopMark)) + trimBlock(node, block) return node } diff --git a/modules/markup/markdown/math/inline_renderer.go b/modules/markup/markdown/math/inline_renderer.go index 4e0531cf40..d000a7b317 100644 --- a/modules/markup/markdown/math/inline_renderer.go +++ b/modules/markup/markdown/math/inline_renderer.go @@ -50,5 +50,4 @@ func (r *InlineRenderer) renderInline(w util.BufWriter, source []byte, n ast.Nod // RegisterFuncs registers the renderer for inline math nodes func (r *InlineRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { reg.Register(KindInline, r.renderInline) - reg.Register(KindInlineBlock, r.renderInline) } diff --git a/modules/markup/markdown/math/math.go b/modules/markup/markdown/math/math.go index 7e8defcd4a..a6ff593d62 100644 --- a/modules/markup/markdown/math/math.go +++ b/modules/markup/markdown/math/math.go @@ -5,6 +5,7 @@ package math import ( "code.gitea.io/gitea/modules/markup/internal" + giteaUtil "code.gitea.io/gitea/modules/util" "github.com/yuin/goldmark" "github.com/yuin/goldmark/parser" @@ -12,70 +13,45 @@ import ( "github.com/yuin/goldmark/util" ) +type Options struct { + Enabled bool + ParseDollarInline bool + ParseDollarBlock bool + ParseSquareBlock bool +} + // Extension is a math extension type Extension struct { - renderInternal *internal.RenderInternal - enabled bool - parseDollarInline bool - parseDollarBlock bool -} - -// Option is the interface Options should implement -type Option interface { - SetOption(e *Extension) -} - -type extensionFunc func(e *Extension) - -func (fn extensionFunc) SetOption(e *Extension) { - fn(e) -} - -// Enabled enables or disables this extension -func Enabled(enable ...bool) Option { - value := true - if len(enable) > 0 { - value = enable[0] - } - return extensionFunc(func(e *Extension) { - e.enabled = value - }) + renderInternal *internal.RenderInternal + options Options } // NewExtension creates a new math extension with the provided options -func NewExtension(renderInternal *internal.RenderInternal, opts ...Option) *Extension { +func NewExtension(renderInternal *internal.RenderInternal, opts ...Options) *Extension { + opt := giteaUtil.OptionalArg(opts) r := &Extension{ - renderInternal: renderInternal, - enabled: true, - parseDollarBlock: true, - parseDollarInline: true, - } - - for _, o := range opts { - o.SetOption(r) + renderInternal: renderInternal, + options: opt, } return r } // Extend extends goldmark with our parsers and renderers func (e *Extension) Extend(m goldmark.Markdown) { - if !e.enabled { + if !e.options.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(), 503), - util.Prioritized(NewInlineDualDollarParser(), 502)) + inlines := []util.PrioritizedValue{util.Prioritized(NewInlineBracketParser(), 501)} + if e.options.ParseDollarInline { + inlines = append(inlines, util.Prioritized(NewInlineDollarParser(), 502)) } m.Parser().AddOptions(parser.WithInlineParsers(inlines...)) + m.Parser().AddOptions(parser.WithBlockParsers( + util.Prioritized(NewBlockParser(e.options.ParseDollarBlock, e.options.ParseSquareBlock), 701), + )) + m.Renderer().AddOptions(renderer.WithNodeRenderers( util.Prioritized(NewBlockRenderer(e.renderInternal), 501), util.Prioritized(NewInlineRenderer(e.renderInternal), 502), diff --git a/web_src/css/repo/home-file-list.css b/web_src/css/repo/home-file-list.css index ecb26fa662..285b823d57 100644 --- a/web_src/css/repo/home-file-list.css +++ b/web_src/css/repo/home-file-list.css @@ -29,7 +29,7 @@ #repo-files-table .repo-file-line, #repo-files-table .repo-file-cell { border-top: 1px solid var(--color-light-border); - padding: 6px 10px; + padding: 8px 10px; } #repo-files-table .repo-file-line:first-child { @@ -41,7 +41,6 @@ display: flex; align-items: center; gap: 0.5em; - padding: 6px 10px; } #repo-files-table .repo-file-last-commit { diff --git a/web_src/js/markup/math.ts b/web_src/js/markup/math.ts index 22a4de38e9..4777805e3c 100644 --- a/web_src/js/markup/math.ts +++ b/web_src/js/markup/math.ts @@ -1,8 +1,14 @@ import {displayError} from './common.ts'; -function targetElement(el: Element) { +function targetElement(el: Element): {target: Element, displayAsBlock: boolean} { // The target element is either the parent "code block with loading indicator", or itself - return el.closest('.code-block.is-loading') ?? el; + // It is designed to work for 2 cases (guaranteed by backend code): + // *+ // *...
...
+ return { + target: el.closest('.code-block.is-loading') ?? el, + displayAsBlock: el.classList.contains('display'), + }; } export async function renderMath(): Promise{ @@ -19,7 +25,7 @@ export async function renderMath(): Promise { const MAX_EXPAND = 1000; for (const el of els) { - const target = targetElement(el); + const {target, displayAsBlock} = targetElement(el); if (target.hasAttribute('data-render-done')) continue; const source = el.textContent; @@ -27,16 +33,12 @@ export async function renderMath(): Promise { displayError(target, new Error(`Math source of ${source.length} characters exceeds the maximum allowed length of ${MAX_CHARS}.`)); continue; } - - const displayMode = el.classList.contains('display'); - const nodeName = displayMode ? 'p' : 'span'; - try { - const tempEl = document.createElement(nodeName); + const tempEl = document.createElement(displayAsBlock ? 'p' : 'span'); katex.render(source, tempEl, { maxSize: MAX_SIZE, maxExpand: MAX_EXPAND, - displayMode, + displayMode: displayAsBlock, // katex: true for display (block) mode, false for inline mode }); target.replaceWith(tempEl); } catch (error) {