d8a994ef24", TestRepoURL)
- test(tmp, ""+TestOrgRepo+"@d8a994ef24", "https://localhost/forgejo/forgejo")
- test(
+ assert(tmp, "d8a994ef24", TestRepoURLWithoutSlash)
+ assert(tmp, ""+TestOrgRepo+"@d8a994ef24", "/forgejo/forgejo")
+ assert(
tmp+"#diff-2",
"d8a994ef24 (diff-2)",
TestRepoURL,
)
- test(
+ assert(
tmp+"#diff-953bb4f01b7c77fa18f0cd54211255051e647dbc",
"d8a994ef24 (diff-953bb4f01b)",
- TestRepoURL,
+ TestRepoURLWithoutSlash,
)
// render other commit URLs
tmp = "https://external-link.gitea.io/go-gitea/gitea/commit/d8a994ef243349f321568f9e36d5c3f444b99cae#diff-2"
- test(tmp, "d8a994ef24 (diff-2)", "https://external-link.gitea.io/go-gitea/gitea")
- test(tmp, "go-gitea/gitea@d8a994ef24 (diff-2)", TestRepoURL)
+ assert(tmp, "external-link.gitea.io/go-gitea/gitea@d8a994ef24 (diff-2)", TestOrgRepo)
+ defer test.MockVariableValue(&setting.AppURL, "https://external-link.gitea.io/")()
+ assert(tmp, "d8a994ef24 (diff-2)", "https://external-link.gitea.io/go-gitea/gitea")
- tmp = "http://localhost:3000/gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20"
- test(tmp, "190d949293", "http://localhost:3000/gogits/gogs")
- test(tmp, "gogits/gogs@190d949293", "https://external-link.gitea.io/go-gitea/gitea")
+ tmp = TestAppURL + "gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20"
+ assert(tmp, "localhost:3000/gogits/gogs@190d949293", "https://external-link.gitea.io/go-gitea/gitea")
+ defer test.MockVariableValue(&setting.AppURL, TestAppURL)()
+ assert(tmp, "190d949293", "http://localhost:3000/gogits/gogs")
tmp = "http://localhost:3000/sub/gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20"
- test(tmp, "190d949293", "http://localhost:3000/sub/gogits/gogs")
- test(tmp, "gogits/gogs@190d949293", "http://localhost:3000/gogits/gogs")
- test(tmp, "gogits/gogs@190d949293", "https://external-link.gitea.io/go-gitea/gitea")
+ assert(tmp, "localhost:3000/sub/gogits/gogs@190d949293", TestRepoURLWithoutSlash)
+ assert(tmp, "localhost:3000/sub/gogits/gogs@190d949293", "https://external-link.gitea.io/go-gitea/gitea")
+ defer test.MockVariableValue(&setting.AppURL, TestAppURL+"sub/")()
+ assert(tmp, "190d949293", "http://localhost:3000/sub/gogits/gogs")
tmp = "http://localhost:3000/sub1/sub2/sub3/gogits/gogs/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20"
- test(tmp, "190d949293", "http://localhost:3000/sub1/sub2/sub3/gogits/gogs")
- test(tmp, "gogits/gogs@190d949293", "http://localhost:3000/sub1/gogits/gogs")
- test(tmp, "gogits/gogs@190d949293", "https://external-link.gitea.io/go-gitea/gitea")
+ defer test.MockVariableValue(&setting.AppURL, TestAppURL+"sub1/sub2/sub3/")()
+ assert(tmp, "190d949293", "http://localhost:3000/sub1/sub2/sub3/gogits/gogs")
+ defer test.MockVariableValue(&setting.AppURL, TestAppURL)()
+ assert(tmp, "localhost:3000/sub1/sub2/sub3/gogits/gogs@190d949293", "http://localhost:3000/sub1/gogits/gogs")
+ assert(tmp, "localhost:3000/sub1/sub2/sub3/gogits/gogs@190d949293", "https://external-link.gitea.io/go-gitea/gitea")
// if the repository happens to be named like one of the known app routes (e.g. `src`),
// we can parse the URL correctly, if there is no sub path
tmp = "http://localhost:3000/gogits/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20"
- test(tmp, "gogits/src@190d949293", TestRepoURL)
+ assert(tmp, "gogits/src@190d949293", TestRepoURL)
tmp = "http://localhost:3000/gogits/src/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20"
- test(tmp, "gogits/src@190d949293", TestRepoURL)
+ assert(tmp, "gogits/src@190d949293", TestRepoURL)
// but if there is a sub path, we cannot reliably distinguish the repo name from the app route
tmp = "http://localhost:3000/sub/gogits/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20"
- test(tmp, "sub/gogits@190d949293", TestRepoURL)
+ assert(tmp, "sub/gogits@190d949293", TestRepoURL)
})
t.Run("Compare", func(t *testing.T) {
tmp := util.URLJoin(TestRepoURL, "compare", "d8a994ef243349f321568f9e36d5c3f444b99cae..190d9492934af498c3f669d6a2431dc5459e5b20")
- test(tmp, "d8a994ef24..190d949293", TestRepoURL)
- test(tmp, ""+TestOrgRepo+"@d8a994ef24..190d949293", "https://localhost/forgejo/forgejo")
+ assert(tmp, "d8a994ef24..190d949293", TestRepoURL)
+ assert(tmp, ""+TestOrgRepo+"@d8a994ef24..190d949293", "https://localhost/forgejo/forgejo")
+ defer test.MockVariableValue(&setting.AppURL, TestAppURL+"sub/")()
tmp = "http://localhost:3000/sub/gogits/gogs/compare/190d9492934af498c3f669d6a2431dc5459e5b20..d8a994ef243349f321568f9e36d5c3f444b99cae"
- test(tmp, "190d949293..d8a994ef24", "http://localhost:3000/sub/gogits/gogs")
- test(tmp, "gogits/gogs@190d949293..d8a994ef24", "http://localhost:3000/gogits/gogs")
- test(tmp, "gogits/gogs@190d949293..d8a994ef24", "https://external-link.gitea.io/go-gitea/gitea")
+ assert(tmp, "190d949293..d8a994ef24", "http://localhost:3000/sub/gogits/gogs")
+ assert(tmp, "gogits/gogs@190d949293..d8a994ef24", "http://localhost:3000/sub/gogits/gugs")
+ defer test.MockVariableValue(&setting.AppURL, "https://external-link.gitea.io/")()
+ assert(tmp, "localhost:3000/sub/gogits/gogs@190d949293..d8a994ef24", "https://external-link.gitea.io/go-gitea/gitea")
+ defer test.MockVariableValue(&setting.AppURL, TestAppURL+"sub1/sub2/sub3/")()
tmp = "http://localhost:3000/sub1/sub2/sub3/gogits/gogs/compare/190d9492934af498c3f669d6a2431dc5459e5b20..d8a994ef243349f321568f9e36d5c3f444b99cae"
- test(tmp, "190d949293..d8a994ef24", "http://localhost:3000/sub1/sub2/sub3/gogits/gogs")
- test(tmp, "gogits/gogs@190d949293..d8a994ef24", "http://localhost:3000/sub1/gogits/gogs")
- test(tmp, "gogits/gogs@190d949293..d8a994ef24", "https://external-link.gitea.io/go-gitea/gitea")
+ assert(tmp, "190d949293..d8a994ef24", "http://localhost:3000/sub1/sub2/sub3/gogits/gogs")
+ assert(tmp, "gogits/gogs@190d949293..d8a994ef24", "/gogits/gous")
+ assert(tmp, "gogits/gogs@190d949293..d8a994ef24", "https://external-link.gitea.io/go-gitea/gitea")
tmp = "https://codeberg.org/forgejo/forgejo/compare/8bbac4c679bea930c74849c355a60ed3c52f8eb5...e2278e5a38187a1dc84dc41d583ec8b44e7257c1?files=options/locale/locale_fi-FI.ini"
- test(tmp, "8bbac4c679...e2278e5a38 (options/locale/locale_fi-FI.ini)", "https://codeberg.org/forgejo/forgejo")
- test(tmp, "forgejo/forgejo@8bbac4c679...e2278e5a38 (options/locale/locale_fi-FI.ini)", TestRepoURL)
- test(tmp+".", "forgejo/forgejo@8bbac4c679...e2278e5a38 (options/locale/locale_fi-FI.ini).", TestRepoURL)
+ assert(tmp, "codeberg.org/forgejo/forgejo@8bbac4c679...e2278e5a38 (options/locale/locale_fi-FI.ini)", TestRepoURL)
+ assert(tmp+".", "codeberg.org/forgejo/forgejo@8bbac4c679...e2278e5a38 (options/locale/locale_fi-FI.ini).", TestRepoURL)
+ defer test.MockVariableValue(&setting.AppURL, "https://codeberg.org/")()
+ assert(tmp, "8bbac4c679...e2278e5a38 (options/locale/locale_fi-FI.ini)", "https://codeberg.org/forgejo/forgejo")
tmp = "https://codeberg.org/forgejo/forgejo/compare/8bbac4c679bea930c74849c355a60ed3c52f8eb5...e2278e5a38187a1dc84dc41d583ec8b44e7257c1?files=options/locale/locale_fi-FI.ini#L2"
- test(tmp, "8bbac4c679...e2278e5a38 (options/locale/locale_fi-FI.ini#L2)", "https://codeberg.org/forgejo/forgejo")
+ assert(tmp, "8bbac4c679...e2278e5a38 (options/locale/locale_fi-FI.ini#L2)", "https://codeberg.org/forgejo/forgejo")
})
t.Run("Invalid URLs", func(t *testing.T) {
tmp := "https://local host/gogits/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20"
- test(tmp, "https://local host/gogits/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20", TestRepoURL)
+ assert(tmp, "https://local host/gogits/src/commit/190d9492934af498c3f669d6a2431dc5459e5b20", TestRepoURL)
})
}
func TestRender_IssueIndexPatternRef(t *testing.T) {
- setting.AppURL = TestAppURL
+ defer test.MockVariableValue(&setting.AppURL, TestAppURL)()
test := func(input, expected string) {
var buf strings.Builder
@@ -428,7 +439,7 @@ func TestRender_IssueIndexPatternRef(t *testing.T) {
}
func TestRender_FullIssueURLs(t *testing.T) {
- setting.AppURL = TestAppURL
+ defer test.MockVariableValue(&setting.AppURL, TestAppURL)()
test := func(input, expected string) {
var result strings.Builder
@@ -475,6 +486,9 @@ func TestRegExp_hashCurrentPattern(t *testing.T) {
":abcd3ef",
".abcd3ef",
" (abcd3ef). ",
+ "abcd3ef...",
+ "...abcd3ef",
+ "(!...abcd3ef",
}
falseTestCases := []string{
"test",
@@ -484,6 +498,7 @@ func TestRegExp_hashCurrentPattern(t *testing.T) {
"abcdefghijklmnopqrstuvwxyzabcdefghijklmO",
"commit/abcdefd",
"abcd3ef...defabcd",
+ "f..defabcd",
}
for _, testCase := range trueTestCases {
@@ -600,3 +615,30 @@ func TestRegExp_shortLinkPattern(t *testing.T) {
assert.False(t, shortLinkPattern.MatchString(testCase))
}
}
+
+func TestRender_escapeInlineCodeBlocks(t *testing.T) {
+ test := func(input, expected string) {
+ result := escapeInlineCodeBlocks(input)
+ assert.Equal(t, expected, result)
+ }
+ test("`65f1bf27bc...
65f1bf27bc...
codeberg.org/`+markup.TestOrgRepo+`@eeb243c339
localhost:3000/sub1/sub2/gogits/gogs@!1 (commit `+sha[0:10]+`)
`+markup.TestOrgRepo+`@!1 (commit `+sha[0:10]+`)
localhost:3000/sub1/sub2/gogits/gogs@!1 (commit `+sha[0:10]+`)
forgejo/forgejo@!7979 (commit 4d968c08e0)
codeberg.org/forgejo/forgejo@!7979 (commit 4d968c08e0)
783b039...da951ce", res.String())
+ assert.Equal(t, "domain/org/repo@783b039...da951ce", res.String())
}
func TestRender_FilePreview(t *testing.T) {
@@ -740,7 +754,7 @@ func TestRender_FilePreview(t *testing.T) {
defer test.MockVariableValue(&setting.Langs, []string{"en-US"})()
translation.InitLocales(t.Context())
- setting.AppURL = markup.TestAppURL
+ defer test.MockVariableValue(&setting.AppURL, markup.TestAppURL)()
markup.Init(&markup.ProcessorHelper{
GetRepoFileBlob: func(ctx context.Context, ownerName, repoName, commitSha, filePath string, language *string) (*git.Blob, error) {
gitRepo, err := git.OpenRepository(git.DefaultContext, "./tests/repo/repo1_filepreview")
@@ -795,7 +809,7 @@ func TestRender_FilePreview(t *testing.T) {
``+
`C`+"\n"+`C`+"\n"+`C`+"\n"+`C`+"\n"+`gogits/gogs@190d949293/path/to/file.go (L2-L3)
localhost:3000/sub/gogits/gogs@190d949293/path/to/file.go (L2-L3)
C`+"\n"+`C`+"\n"+`first without sub 190d949293/path/to/file.go (L2-L3) second
first without sub localhost:3000/gogits/gogs@190d949293/path/to/file.go (L2-L3) second
C`+"\n"+`C`+"\n"+`C`+"\n"+`C`+"\n"+`C`+"\n"+`C`+"\n"+`C`+"\n"+`C`+"\n"+`C`+"\n"+`C`+"\n"+`C`+"\n"+`C`+"\n"+`B`+"\n"+`B`+"\n"+`B`+"\n"+`B`+"\n"+`B`+"\n"+`B`+"\n"+`C`+"\n"+`C`+"\n"+`Wiki! Enjoy :)
@@ -292,7 +319,7 @@ func TestTotal_RenderWiki(t *testing.T) { answers := testAnswers(util.URLJoin(FullURL, "wiki"), util.URLJoin(FullURL, "wiki", "raw")) - for i := 0; i < len(sameCases); i++ { + for i := range sameCases { line, err := markdown.RenderString(&markup.RenderContext{ Ctx: git.DefaultContext, Links: markup.Links{ @@ -336,7 +363,7 @@ func TestTotal_RenderString(t *testing.T) { answers := testAnswers(util.URLJoin(FullURL, "src", "master"), util.URLJoin(FullURL, "media", "master")) - for i := 0; i < len(sameCases); i++ { + for i := range sameCases { line, err := markdown.RenderString(&markup.RenderContext{ Ctx: git.DefaultContext, Links: markup.Links{ @@ -1461,3 +1488,61 @@ func TestCallout(t *testing.T) {Bad stuff is brewing here
`) } + +func TestCodeblockLanguageTransformation(t *testing.T) { + test := func(input, expected string) { + buffer, err := markdown.RenderString(&markup.RenderContext{Ctx: git.DefaultContext}, input) + require.NoError(t, err) + assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer))) + } + + // No transformation + test( + "```rust\n"+ + "fn main() {}\n"+ + "```", + `fn main() {}
+`)
+
+ // Comma stripped
+ test(
+ "```rust,ignore\n"+
+ "fn main() {}\n"+
+ "```",
+ `fn main() {}
+`)
+
+ // Pandoc stripping
+ // https://pandoc.org/MANUAL.html#extension-fenced_code_attributes
+ test(
+ "```haskell {.numberLines}\n"+
+ "qsort [] = []\n"+
+ "qsort (x:xs) = qsort (filter (< x) xs) ++ [x] ++\n"+
+ " qsort (filter (>= x) xs)\n"+
+ "```",
+ `qsort [] = []
+qsort (x:xs) = qsort (filter (< x) xs) ++ [x] ++
+ qsort (filter (>= x) xs)
+`)
+
+ // Pandoc language extracting
+ // https://pandoc.org/MANUAL.html#extension-fenced_code_attributes
+ test(
+ "``` { #mycode .numberLines .haskell startFrom=\"100\" } \n"+
+ "qsort [] = []\n"+
+ "qsort (x:xs) = qsort (filter (< x) xs) ++ [x] ++\n"+
+ " qsort (filter (>= x) xs)\n"+
+ "```",
+ `qsort [] = []
+qsort (x:xs) = qsort (filter (< x) xs) ++ [x] ++
+ qsort (filter (>= x) xs)
+`)
+
+ // No language identifier
+ test(
+ "```\n"+
+ "fn main() {}\n"+
+ "```",
+ `fn main() {}
+`)
+}
diff --git a/modules/markup/markdown/math/block_renderer.go b/modules/markup/markdown/math/block_renderer.go
index 84817ef1e4..d27318c623 100644
--- a/modules/markup/markdown/math/block_renderer.go
+++ b/modules/markup/markdown/math/block_renderer.go
@@ -24,7 +24,7 @@ func (r *BlockRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
func (r *BlockRenderer) writeLines(w util.BufWriter, source []byte, n gast.Node) {
l := n.Lines().Len()
- for i := 0; i < l; i++ {
+ for i := range l {
line := n.Lines().At(i)
_, _ = w.Write(util.EscapeHTML(line.Value(source)))
}
diff --git a/modules/markup/markdown/meta_test.go b/modules/markup/markdown/meta_test.go
index aaf116ff20..9345dd528a 100644
--- a/modules/markup/markdown/meta_test.go
+++ b/modules/markup/markdown/meta_test.go
@@ -63,7 +63,7 @@ func TestExtractMetadata(t *testing.T) {
func TestExtractMetadataBytes(t *testing.T) {
t.Run("ValidFrontAndBody", func(t *testing.T) {
var meta IssueTemplate
- body, err := ExtractMetadataBytes([]byte(fmt.Sprintf("%s\n%s\n%s\n%s", sepTest, frontTest, sepTest, bodyTest)), &meta)
+ body, err := ExtractMetadataBytes(fmt.Appendf(nil, "%s\n%s\n%s\n%s", sepTest, frontTest, sepTest, bodyTest), &meta)
require.NoError(t, err)
assert.Equal(t, bodyTest, string(body))
assert.Equal(t, metaTest, meta)
@@ -72,19 +72,19 @@ func TestExtractMetadataBytes(t *testing.T) {
t.Run("NoFirstSeparator", func(t *testing.T) {
var meta IssueTemplate
- _, err := ExtractMetadataBytes([]byte(fmt.Sprintf("%s\n%s\n%s", frontTest, sepTest, bodyTest)), &meta)
+ _, err := ExtractMetadataBytes(fmt.Appendf(nil, "%s\n%s\n%s", frontTest, sepTest, bodyTest), &meta)
require.Error(t, err)
})
t.Run("NoLastSeparator", func(t *testing.T) {
var meta IssueTemplate
- _, err := ExtractMetadataBytes([]byte(fmt.Sprintf("%s\n%s\n%s", sepTest, frontTest, bodyTest)), &meta)
+ _, err := ExtractMetadataBytes(fmt.Appendf(nil, "%s\n%s\n%s", sepTest, frontTest, bodyTest), &meta)
require.Error(t, err)
})
t.Run("NoBody", func(t *testing.T) {
var meta IssueTemplate
- body, err := ExtractMetadataBytes([]byte(fmt.Sprintf("%s\n%s\n%s", sepTest, frontTest, sepTest)), &meta)
+ body, err := ExtractMetadataBytes(fmt.Appendf(nil, "%s\n%s\n%s", sepTest, frontTest, sepTest), &meta)
require.NoError(t, err)
assert.Empty(t, string(body))
assert.Equal(t, metaTest, meta)
diff --git a/modules/markup/markdown/toc.go b/modules/markup/markdown/toc.go
index dbfab3e9dc..53add219f5 100644
--- a/modules/markup/markdown/toc.go
+++ b/modules/markup/markdown/toc.go
@@ -44,7 +44,7 @@ func createTOCNode(toc []markup.Header, lang string, detailsAttrs map[string]str
}
li := ast.NewListItem(currentLevel * 2)
a := ast.NewLink()
- a.Destination = []byte(fmt.Sprintf("#%s", url.QueryEscape(header.ID)))
+ a.Destination = fmt.Appendf(nil, "#%s", url.QueryEscape(header.ID))
a.AppendChild(a, ast.NewString([]byte(header.Text)))
li.AppendChild(li, a)
ul.AppendChild(ul, li)
diff --git a/modules/markup/markdown/transform_codeblock_lang.go b/modules/markup/markdown/transform_codeblock_lang.go
new file mode 100644
index 0000000000..f730265b15
--- /dev/null
+++ b/modules/markup/markdown/transform_codeblock_lang.go
@@ -0,0 +1,57 @@
+// Copyright 2026 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markdown
+
+import (
+ "bytes"
+
+ "github.com/alecthomas/chroma/v2/lexers"
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/text"
+)
+
+func (g *ASTTransformer) transformCodeblockLanguage(v *ast.FencedCodeBlock, reader text.Reader) {
+ if v.Info == nil {
+ return
+ }
+ src := reader.Source()
+ info := v.Info.Segment.Value(src)
+
+ // Parse Pandoc style attributes
+ // https://pandoc.org/MANUAL.html#extension-fenced_code_attributes
+ //
+ // For example,
+ // ```{.haskell .numberLines}
+ // ...
+ // ```
+ // Should have a language of "haskell", not "{.haskell .numberLines}"
+ if trimmed := bytes.TrimSpace(info); bytes.HasPrefix(trimmed, []byte{'{'}) && bytes.HasSuffix(trimmed, []byte{'}'}) {
+ attributes := trimmed[1 : len(trimmed)-1]
+ for attribute := range bytes.SplitSeq(attributes, []byte{' '}) {
+ if class, found := bytes.CutPrefix(attribute, []byte{'.'}); found {
+ if lexer := lexers.Get(string(class)); lexer != nil {
+ lang := class
+ langInx := bytes.Index(info, lang)
+ start := v.Info.Segment.Start + langInx
+ end := start + len(lang)
+ v.Info = ast.NewTextSegment(text.NewSegment(start, end))
+ return
+ }
+ }
+ }
+ return
+ }
+
+ // Strip language after commas
+ //
+ // For example,
+ // ```rust,ignore
+ // ...
+ // ```
+ // Should have a language of "rust", not "rust,ignore"
+ if i := bytes.IndexByte(info, ','); i != -1 {
+ start := v.Info.Segment.Start
+ v.Info = ast.NewTextSegment(text.NewSegment(start, start+i))
+ }
+}
diff --git a/modules/markup/markdown/transform_heading.go b/modules/markup/markdown/transform_heading.go
index eedaf58556..16779d5099 100644
--- a/modules/markup/markdown/transform_heading.go
+++ b/modules/markup/markdown/transform_heading.go
@@ -17,7 +17,7 @@ import (
func (g *ASTTransformer) transformHeading(_ *markup.RenderContext, v *ast.Heading, reader text.Reader, tocList *[]markup.Header) {
for _, attr := range v.Attributes() {
if _, ok := attr.Value.([]byte); !ok {
- v.SetAttribute(attr.Name, []byte(fmt.Sprintf("%v", attr.Value)))
+ v.SetAttribute(attr.Name, fmt.Appendf(nil, "%v", attr.Value))
}
}
txt := mdutil.Text(v, reader.Source())
diff --git a/modules/markup/markdown/transform_html.go b/modules/markup/markdown/transform_html.go
new file mode 100644
index 0000000000..9bebb45554
--- /dev/null
+++ b/modules/markup/markdown/transform_html.go
@@ -0,0 +1,28 @@
+// Copyright 2025 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package markdown
+
+import (
+ "strings"
+
+ "forgejo.org/modules/markup"
+
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/text"
+)
+
+func (g *ASTTransformer) addTypeToButton(v *ast.RawHTML, segment string) {
+ segment = strings.TrimPrefix(segment, "