refactor(markdown): remove goldmark-highlighting (#39751)

Render fenced code blocks with Gitea's own code block renderer instead
of `goldmark-highlighting`, using the managed lexer lookup from
`modules/highlight`. Indented and fenced blocks now share one rendering
path.

Rendered output is unchanged. The trailing newline is stripped on copy
in the frontend since https://github.com/go-gitea/gitea/pull/39755.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
silverwindandwxiaoguang authored and GitHub committed 2026-11-03 12:25:31 +00:00
1 parent 12b20c7c99
commit 5c723ee0fd
11 files changed
+49 -85

No files matched your search

-5
View File
@@ -964,11 +964,6 @@
"path": "github.com/yohcop/openid-go/LICENSE",
"licenseText": "Copyright 2015 Yohann Coppel\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n"
},
{
"name": "github.com/yuin/goldmark-highlighting/v2",
"path": "github.com/yuin/goldmark-highlighting/v2/LICENSE",
"licenseText": "MIT License\n\nCopyright (c) 2019 Yusuke Inuzuka\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
},
{
"name": "github.com/yuin/goldmark",
"path": "github.com/yuin/goldmark/LICENSE",
-1
View File
@@ -84,7 +84,6 @@ require (
github.com/wneessen/go-mail v0.8.1
github.com/yohcop/openid-go v1.0.1
github.com/yuin/goldmark v1.8.6
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
gitlab.com/gitlab-org/api/client-go/v3 v3.39.1
go.yaml.in/yaml/v4 v4.0.0-rc.6.0.20261013184558-04dd63131525
golang.org/x/crypto v0.58.0
-8
View File
@@ -48,10 +48,8 @@ github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.10.0 h1:LvK7+C6qgz8BPnmn7x
github.com/SaveTheRbtz/zstd-seekable-format-go/pkg v0.10.0/go.mod h1:I28hc9eaiqKCoOB+9Wh/P1IOScfm0xCgyWC6DAo0lmo=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
@@ -162,8 +160,6 @@ github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454Wv
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2/v2 v2.11.0 h1:z5uRPHav+eTVpoffDSuUP2KPS4f1ONZaPDK73S8HA0w=
@@ -528,7 +524,6 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
@@ -559,11 +554,8 @@ github.com/yohcop/openid-go v1.0.1 h1:DPRd3iPO5F6O5zX2e62XpVAbPT6wV51cuucH0z9g3j
github.com/yohcop/openid-go v1.0.1/go.mod h1:b/AvD03P0KHj4yuihb+VtLD6bYYgsy0zqBzPCRjkCNs=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
+14 -1
View File
@@ -18,6 +18,7 @@ import (
"github.com/alecthomas/chroma/v2"
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
)
@@ -82,6 +83,13 @@ func htmlEscape(code string) template.HTML {
return template.HTML(gohtml.EscapeString(code))
}
func normalizeLexer(lexer chroma.Lexer) chroma.Lexer {
if lexer != lexers.Fallback {
lexer = chroma.Coalesce(lexer)
}
return lexer
}
// RenderCodeSlowGuess tries to get a lexer by file name and language first,
// if not found, it will try to guess the lexer by code content, which is slow (more than several hundreds of milliseconds).
func RenderCodeSlowGuess(fileName, language, code string) (output template.HTML, lexer chroma.Lexer, lexerDisplayName string) {
@@ -96,11 +104,15 @@ func RenderCodeSlowGuess(fileName, language, code string) (output template.HTML,
}
lexer = detectChromaLexerWithAnalyze(fileName, language, util.UnsafeStringToBytes(code)) // it is also slow
lexer = normalizeLexer(lexer)
return RenderCodeByLexer(lexer, code), lexer, formatLexerName(lexer.Config().Name)
}
// RenderCodeByLexer returns a HTML version of code string with chroma syntax highlighting classes
// RenderCodeByLexer returns an HTML version of code string with chroma syntax highlighting classes
func RenderCodeByLexer(lexer chroma.Lexer, code string) template.HTML {
if lexer == lexers.Fallback {
return htmlEscape(code) // chroma is 3x slower for plain text
}
formatter := chromahtml.New(chromahtml.WithClasses(true),
chromahtml.WithLineNumbers(false),
chromahtml.PreventSurroundingPre(true),
@@ -128,6 +140,7 @@ func RenderFullFile(fileName, language string, code []byte) ([]template.HTML, st
return renderPlainText(code), formatLexerName(LanguagePlaintext)
}
lexer := detectChromaLexerWithAnalyze(fileName, language, code)
lexer = normalizeLexer(lexer)
lexerName := formatLexerName(lexer.Config().Name)
rendered := RenderCodeByLexer(lexer, util.UnsafeBytesToString(code))
unsafeLines := UnsafeSplitHighlightedLines(rendered)
+4 -4
View File
@@ -215,7 +215,7 @@ func normalizeFileNameLang(fileName, fileLang string) (string, string) {
func DetectChromaLexerByFileName(fileName, fileLang string) chroma.Lexer {
lexer, _ := detectChromaLexerByFileName(fileName, fileLang)
return lexer
return normalizeLexer(lexer)
}
func detectChromaLexerByFileName(fileName, fileLang string) (_ chroma.Lexer, byLang bool) {
@@ -289,14 +289,14 @@ func detectChromaLexerWithAnalyze(fileName, lang string, code []byte) chroma.Lex
// if lang is provided, and it matches a lexer, use it directly
if byLang {
return chroma.Coalesce(lexer)
return lexer
}
// if a lexer is detected and there is no conflict for the file extension, use it directly
fileExt := path.Ext(fileName)
_, hasConflicts := chromaLexers().conflictingExtLangMap[fileExt]
if !hasConflicts && lexer != lexers.Fallback {
return chroma.Coalesce(lexer)
return lexer
}
// try to detect language by content, for best guessing for the language
@@ -308,5 +308,5 @@ func detectChromaLexerWithAnalyze(fileName, lang string, code []byte) chroma.Lex
log.Warn("No chroma lexer found for enry detected language: %s (file: %s), need to fix the language mapping between enry and chroma.", analyzedLanguage, fileName)
}
}
return chroma.Coalesce(lexer)
return lexer
}
+1 -1
View File
@@ -70,7 +70,7 @@ func (r *RenderInternal) ProtectSafeAttrs(content template.HTML) template.HTML {
return template.HTML(reAttrClass().ReplaceAllString(string(content), `$1 data-attr-class="`+r.secureIDPrefix+`$2"$3`))
}
func (r *RenderInternal) FormatWithSafeAttrs(w io.Writer, fmt template.HTML, a ...any) error {
func (r *RenderInternal) FormatWithSafeAttrsf(w io.Writer, fmt template.HTML, a ...any) error {
htmlStr := r.ProtectSafeAttrs(htmlutil.HTMLFormat(fmt, a...))
_, err := io.WriteString(w, string(htmlStr))
return err
+14 -9
View File
@@ -8,7 +8,6 @@ import (
"gitea.dev/modules/container"
"gitea.dev/modules/highlight"
"gitea.dev/modules/htmlutil"
"gitea.dev/modules/markup"
"gitea.dev/modules/markup/internal"
"gitea.dev/modules/markup/markdown/math"
@@ -145,23 +144,29 @@ func (r *HTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(KindSummary, r.renderSummary)
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
reg.Register(ast.KindFencedCodeBlock, r.renderCodeBlock)
reg.Register(KindAttention, r.renderAttention)
reg.Register(east.KindTaskCheckBox, r.renderTaskCheckBox)
reg.Register(KindRawHTML, r.renderRawHTML)
}
// renderCodeBlock wraps indented code blocks like the fenced renderer
func (r *HTMLRenderer) renderCodeBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
preAttrs, codeAttrs := highlight.CodeBlockAttributes("") // no language
opening := r.renderInternal.ProtectSafeAttrs(htmlutil.HTMLFormat(`<div class="code-block-container code-overflow-scroll"><pre %s><code %s>`, preAttrs, codeAttrs))
if _, err := w.WriteString(string(opening)); err != nil {
var language string
if fenced, ok := n.(*ast.FencedCodeBlock); ok {
language = string(fenced.Language(source))
}
preAttrs, codeAttrs := highlight.CodeBlockAttributes(language)
err := r.renderInternal.FormatWithSafeAttrsf(w, `<div class="code-block-container code-overflow-scroll"><pre %s><code %s>`, preAttrs, codeAttrs)
if err != nil {
return ast.WalkStop, err
}
lines := n.Lines()
for i := 0; i < lines.Len(); i++ {
line := lines.At(i)
r.Writer.RawWrite(w, line.Value(source))
code := n.Lines().Value(source)
lexer := highlight.DetectChromaLexerByFileName("", language)
renderedHTML := highlight.RenderCodeByLexer(lexer, string(code))
if _, err = w.WriteString(string(renderedHTML)); err != nil {
return ast.WalkStop, err
}
} else {
if _, err := w.WriteString("</code></pre></div>"); err != nil {
+12 -49
View File
@@ -13,7 +13,6 @@ import (
"slices"
"strings"
"gitea.dev/modules/highlight"
"gitea.dev/modules/htmlutil"
"gitea.dev/modules/log"
"gitea.dev/modules/markup"
@@ -22,9 +21,7 @@ import (
"gitea.dev/modules/setting"
giteautil "gitea.dev/modules/util"
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/yuin/goldmark"
highlighting "github.com/yuin/goldmark-highlighting/v2"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
@@ -68,32 +65,6 @@ func newParserContext(ctx *markup.RenderContext) parser.Context {
return pc
}
type GoldmarkRender struct {
ctx *markup.RenderContext
goldmarkMarkdown goldmark.Markdown
}
func (r *GoldmarkRender) Convert(source []byte, writer io.Writer, opts ...parser.ParseOption) error {
return r.goldmarkMarkdown.Convert(source, writer, opts...)
}
func (r *GoldmarkRender) highlightingRenderer(w util.BufWriter, c highlighting.CodeBlockContext, entering bool) {
if entering {
languageBytes, _ := c.Language()
preAttrs, codeAttrs := highlight.CodeBlockAttributes(string(languageBytes))
err := r.ctx.RenderInternal.FormatWithSafeAttrs(w, `<div class="code-block-container code-overflow-scroll"><pre %s><code %s>`, preAttrs, codeAttrs)
if err != nil {
return
}
} else {
_, err := w.WriteString("</code></pre></div>")
if err != nil {
return
}
}
}
type goldmarkEmphasisParser struct {
parser.InlineParser
}
@@ -130,11 +101,10 @@ func goldmarkDefaultParser() parser.Parser {
}
// SpecializedMarkdown sets up the Gitea specific markdown extensions
func SpecializedMarkdown(ctx *markup.RenderContext) *GoldmarkRender {
func SpecializedMarkdown(ctx *markup.RenderContext) goldmark.Markdown {
// TODO: it could use a pool to cache the renderers to reuse them with different contexts
// at the moment it is fast enough (see the benchmarks)
r := &GoldmarkRender{ctx: ctx}
r.goldmarkMarkdown = goldmark.New(
return goldmark.New(
goldmark.WithParser(goldmarkDefaultParser()),
goldmark.WithExtensions(
extension.NewTable(extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute)),
@@ -142,13 +112,6 @@ func SpecializedMarkdown(ctx *markup.RenderContext) *GoldmarkRender {
extension.TaskList,
extension.DefinitionList,
common.FootnoteExtension,
highlighting.NewHighlighting(
highlighting.WithFormatOptions(
chromahtml.WithClasses(true),
chromahtml.PreventSurroundingPre(true),
),
highlighting.WithWrapperRenderer(r.highlightingRenderer),
),
math.NewExtension(&ctx.RenderInternal, math.Options{
Enabled: setting.Markdown.EnableMath,
ParseInlineDollar: setting.Markdown.MathCodeBlockOptions.ParseInlineDollar,
@@ -161,15 +124,11 @@ func SpecializedMarkdown(ctx *markup.RenderContext) *GoldmarkRender {
parser.WithAttribute(),
parser.WithASTTransformers(util.Prioritized(NewASTTransformer(&ctx.RenderInternal), 10000)),
),
goldmark.WithRendererOptions(html.WithUnsafe()),
goldmark.WithRendererOptions(
html.WithUnsafe(),
renderer.WithNodeRenderers(util.Prioritized(NewHTMLRenderer(&ctx.RenderInternal), 10)),
),
)
// Override the original Tasklist renderer!
r.goldmarkMarkdown.Renderer().AddOptions(
renderer.WithNodeRenderers(util.Prioritized(NewHTMLRenderer(&ctx.RenderInternal), 10)),
)
return r
}
// render calls goldmark render to convert Markdown to HTML
@@ -275,14 +234,18 @@ func FeedExcerpt(ctx context.Context, content string) string {
pc := newParserContext(rctx)
pc.Set(renderConfigKey, &RenderConfig{})
start, texts := len(content), []text.Segment(nil)
_ = ast.Walk(SpecializedMarkdown(rctx).goldmarkMarkdown.Parser().Parse(text.NewReader([]byte(content)), parser.WithContext(pc)), func(node ast.Node, entering bool) (ast.WalkStatus, error) {
mdParser := SpecializedMarkdown(rctx).Parser()
mdReader := text.NewReader([]byte(content))
mdWalker := func(node ast.Node, entering bool) (ast.WalkStatus, error) {
if entering && node.Type() == ast.TypeBlock && node.Lines().Len() > 0 {
start = min(start, strings.LastIndexByte(content[:node.Lines().At(0).Start], '\n')+1)
} else if textNode, ok := node.(*ast.Text); ok && entering && node.Parent().Type() == ast.TypeBlock {
texts = append(texts, textNode.Segment)
}
return ast.WalkContinue, nil
})
}
_ = ast.Walk(mdParser.Parse(mdReader, parser.WithContext(pc)), mdWalker)
excerpt := giteautil.EllipsisDisplayString(content[start:], 200)
kept, ok := strings.CutSuffix(excerpt, "…")
@@ -17,8 +17,8 @@ import (
// Block render output:
// <pre class="code-block is-loading"><code class="language-math">...</code></pre>
//
// Keep in mind that there is another "code block" render in "func (r *GoldmarkRender) highlightingRenderer"
// "highlightingRenderer" outputs the math block with extra "chroma" class:
// Keep in mind that there is another "code block" render in "func (r *HTMLRenderer) renderCodeBlock"
// "renderCodeBlock" outputs the math block with extra "chroma" class:
// <pre class="code-block is-loading"><code class="chroma language-math">...</code></pre>
//
// Special classes:
@@ -39,7 +39,7 @@ func (r *HTMLRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Nod
r.Writer.RawWrite(w, value)
}
case *ColorPreview:
_ = r.renderInternal.FormatWithSafeAttrs(w, `<span class="color-preview" style="background-color: %s"></span>`, v.Color)
_ = r.renderInternal.FormatWithSafeAttrsf(w, `<span class="color-preview" style="background-color: %s"></span>`, v.Color)
}
}
return ast.WalkSkipChildren, nil
+1 -4
View File
@@ -15,7 +15,6 @@ import (
"gitea.dev/modules/markup"
"gitea.dev/modules/setting"
"github.com/alecthomas/chroma/v2"
"github.com/niklasfasching/go-org/org"
)
@@ -58,10 +57,8 @@ func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error
preAttrs, codeAttrs := highlight.CodeBlockAttributes(lang)
lexer := highlight.DetectChromaLexerByFileName("", lang) // don't use content to detect, it is too slow
lexer = chroma.Coalesce(lexer)
sb := &strings.Builder{}
_ = ctx.RenderInternal.FormatWithSafeAttrs(sb, `<pre %s><code %s>`, preAttrs, codeAttrs)
_ = ctx.RenderInternal.FormatWithSafeAttrsf(sb, `<pre %s><code %s>`, preAttrs, codeAttrs)
_, _ = sb.WriteString(string(highlight.RenderCodeByLexer(lexer, source)))
_, _ = sb.WriteString("</code></pre>")
return sb.String()