test
`, }, @@ -443,8 +446,8 @@ anything --- test `, - `anything - + `test
`, }, @@ -471,14 +474,26 @@ foo: barfoo
`, }, } - for _, test := range testcases { - res, err := markdown.RenderString(markup.NewTestRenderContext(), test.input) - assert.NoError(t, err, "Unexpected error in testcase: %q", test.name) - assert.Equal(t, test.expected, string(res), "Unexpected result in testcase %q", test.name) + for _, tt := range testcases { + t.Run(tt.name, func(t *testing.T) { + res, err := markdown.RenderString(markup.NewTestRenderContext(), tt.input) + assert.NoError(t, err, "Unexpected error in testcase: %q", tt.name) + assert.Equal(t, tt.expected, string(res), "Unexpected result in testcase %q", tt.name) + }) } } @@ -568,3 +583,39 @@ func TestMarkdownLink(t *testing.T) { assert.Equal(t, `https://example.com/__init__.py
`, string(result)) } + +func TestMarkdownUlDir(t *testing.T) { + defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, false)() + result, err := markdown.RenderString(markup.NewTestRenderContext(), ` +* a + * b +`) + assert.NoError(t, err) + assert.Equal(t, `` + const suffix = `
code`+nl+``+suffix)
+
+ const jsCommon = prefix + `code` + nl + `` + suffix
+ testRender("```js\ncode\n```", jsCommon)
+ testRender("```js:app.ts\ncode\n```", jsCommon)
+ testRender("```js,ignore\ncode\n```", jsCommon)
+ testRender("```js ignore\ncode\n```", jsCommon)
+}
diff --git a/modules/markup/markdown/meta.go b/modules/markup/markdown/meta.go
index e76b253ecd..6ddd892110 100644
--- a/modules/markup/markdown/meta.go
+++ b/modules/markup/markdown/meta.go
@@ -60,8 +60,8 @@ func ExtractMetadata(contents string, out any) (string, error) {
return string(body), err
}
-// ExtractMetadata consumes a markdown file, parses YAML frontmatter,
-// and returns the frontmatter metadata separated from the markdown content
+// ExtractMetadataBytes consumes a Markdown content, parses YAML frontmatter,
+// and returns the frontmatter metadata separated from the Markdown content
func ExtractMetadataBytes(contents []byte, out any) ([]byte, error) {
var front, body []byte
diff --git a/modules/markup/markdown/transform_codeblock.go b/modules/markup/markdown/transform_codeblock.go
new file mode 100644
index 0000000000..de9264c4c4
--- /dev/null
+++ b/modules/markup/markdown/transform_codeblock.go
@@ -0,0 +1,32 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package markdown
+
+import (
+ "github.com/yuin/goldmark/ast"
+ "github.com/yuin/goldmark/text"
+)
+
+func (g *ASTTransformer) transformFencedCodeblock(v *ast.FencedCodeBlock, reader text.Reader) {
+ // * Some engines support a meta syntax for appending the filename after the language, separated by a colon
+ // * https://www.glukhov.org/documentation-tools/markdown/markdown-codeblocks/
+ // * Some engines support additional "options" after the language, separated by a space or comma: ```rust,ignore```
+ // * https://docs.readme.com/rdmd/docs/code-blocks
+ // * https://next-book.vercel.app/reference/fencedcode
+ if v.Info == nil {
+ return
+ }
+ info := v.Info.Segment.Value(reader.Source())
+ newEnd := -1
+ for i, b := range info {
+ if b == ' ' || b == ',' || b == ':' {
+ newEnd = i
+ break
+ }
+ }
+ if newEnd != -1 {
+ start := v.Info.Segment.Start
+ v.Info = ast.NewTextSegment(text.NewSegment(start, start+newEnd))
+ }
+}
diff --git a/modules/markup/markdown/transform_list.go b/modules/markup/markdown/transform_list.go
index c89ad2f2cf..6cafa8ff78 100644
--- a/modules/markup/markdown/transform_list.go
+++ b/modules/markup/markdown/transform_list.go
@@ -81,5 +81,16 @@ func (g *ASTTransformer) transformList(_ *markup.RenderContext, v *ast.List, rc
v.AppendChild(v, newChild)
}
}
- g.applyElementDir(v)
+
+ nestedList := false
+ for p := v.Parent(); p != nil; p = p.Parent() {
+ if _, ok := p.(*ast.List); ok {
+ nestedList = true
+ break
+ }
+ }
+ if !nestedList {
+ // "dir=auto" should be only added to top-level "ul". https://github.com/go-gitea/gitea/issues/35058
+ g.applyElementDir(v)
+ }
}
diff --git a/modules/markup/render.go b/modules/markup/render.go
index c0d44c72fc..6e8838d49f 100644
--- a/modules/markup/render.go
+++ b/modules/markup/render.go
@@ -6,6 +6,7 @@ package markup
import (
"bytes"
"context"
+ "errors"
"fmt"
"html/template"
"io"
@@ -38,6 +39,15 @@ var RenderBehaviorForTesting struct {
DisableAdditionalAttributes bool
}
+type WebThemeInterface interface {
+ PublicAssetURI() string
+}
+
+type StandalonePageOptions struct {
+ CurrentWebTheme WebThemeInterface
+ RenderQueryString string
+}
+
type RenderOptions struct {
UseAbsoluteLink bool
@@ -55,7 +65,7 @@ type RenderOptions struct {
Metas map[string]string
// used by external render. the router "/org/repo/render/..." will output the rendered content in a standalone page
- InStandalonePage bool
+ StandalonePageOptions *StandalonePageOptions
// EnableHeadingIDGeneration controls whether to auto-generate IDs for HTML headings without id attribute.
// This should be enabled for repository files and wiki pages, but disabled for comments to avoid duplicate IDs.
@@ -127,8 +137,8 @@ func (ctx *RenderContext) WithMetas(metas map[string]string) *RenderContext {
return ctx
}
-func (ctx *RenderContext) WithInStandalonePage(v bool) *RenderContext {
- ctx.RenderOptions.InStandalonePage = v
+func (ctx *RenderContext) WithStandalonePage(opts StandalonePageOptions) *RenderContext {
+ ctx.RenderOptions.StandalonePageOptions = &opts
return ctx
}
@@ -197,20 +207,24 @@ func RenderString(ctx *RenderContext, content string) (string, error) {
return buf.String(), nil
}
-func renderIFrame(ctx *RenderContext, sandbox string, output io.Writer) error {
+func RenderIFrame(ctx *RenderContext, opts *ExternalRendererOptions, output io.Writer) error {
+ ownerName, repoName := ctx.RenderOptions.Metas["user"], ctx.RenderOptions.Metas["repo"]
+ refSubURL := ctx.RenderOptions.Metas["RefTypeNameSubURL"]
+ if ownerName == "" || repoName == "" || refSubURL == "" {
+ setting.PanicInDevOrTesting("RenderIFrame requires user, repo and RefTypeNameSubURL metas")
+ return errors.New("RenderIFrame requires user, repo and RefTypeNameSubURL metas")
+ }
src := fmt.Sprintf("%s/%s/%s/render/%s/%s", setting.AppSubURL,
- url.PathEscape(ctx.RenderOptions.Metas["user"]),
- url.PathEscape(ctx.RenderOptions.Metas["repo"]),
- util.PathEscapeSegments(ctx.RenderOptions.Metas["RefTypeNameSubURL"]),
+ url.PathEscape(ownerName),
+ url.PathEscape(repoName),
+ ctx.RenderOptions.Metas["RefTypeNameSubURL"],
util.PathEscapeSegments(ctx.RenderOptions.RelativePath),
)
-
- var sandboxAttrValue template.HTML
- if sandbox != "" {
- sandboxAttrValue = htmlutil.HTMLFormat(`sandbox="%s"`, sandbox)
+ var extraAttrs template.HTML
+ if opts.ContentSandbox != "" {
+ extraAttrs = htmlutil.HTMLFormat(` sandbox="%s"`, opts.ContentSandbox)
}
- iframe := htmlutil.HTMLFormat(``, src, sandboxAttrValue)
- _, err := io.WriteString(output, string(iframe))
+ _, err := htmlutil.HTMLPrintf(output, ``, src, extraAttrs)
return err
}
@@ -222,7 +236,7 @@ func pipes() (io.ReadCloser, io.WriteCloser, func()) {
}
}
-func getExternalRendererOptions(renderer Renderer) (ret ExternalRendererOptions, _ bool) {
+func GetExternalRendererOptions(renderer Renderer) (ret ExternalRendererOptions, _ bool) {
if externalRender, ok := renderer.(ExternalRenderer); ok {
return externalRender.GetExternalRendererOptions(), true
}
@@ -231,17 +245,23 @@ func getExternalRendererOptions(renderer Renderer) (ret ExternalRendererOptions,
func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error {
var extraHeadHTML template.HTML
- if extOpts, ok := getExternalRendererOptions(renderer); ok && extOpts.DisplayInIframe {
- if !ctx.RenderOptions.InStandalonePage {
+ if extOpts, ok := GetExternalRendererOptions(renderer); ok && extOpts.DisplayInIframe {
+ if ctx.RenderOptions.StandalonePageOptions == nil {
// for an external "DisplayInIFrame" render, it could only output its content in a standalone page
// otherwise, a