This commit is contained in:
@@ -1,49 +0,0 @@
|
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package asciicast
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
)
|
||||
|
||||
func init() {
|
||||
markup.RegisterRenderer(Renderer{})
|
||||
}
|
||||
|
||||
// Renderer implements markup.Renderer for asciicast files.
|
||||
// See https://github.com/asciinema/asciinema/blob/develop/doc/asciicast-v2.md
|
||||
type Renderer struct{}
|
||||
|
||||
func (Renderer) Name() string {
|
||||
return "asciicast"
|
||||
}
|
||||
|
||||
func (Renderer) FileNamePatterns() []string {
|
||||
return []string{"*.cast"}
|
||||
}
|
||||
|
||||
const (
|
||||
playerClassName = "asciinema-player-container"
|
||||
playerSrcAttr = "data-asciinema-player-src"
|
||||
)
|
||||
|
||||
func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
|
||||
return []setting.MarkupSanitizerRule{{Element: "div", AllowAttr: playerSrcAttr}}
|
||||
}
|
||||
|
||||
func (Renderer) Render(ctx *markup.RenderContext, _ io.Reader, output io.Writer) error {
|
||||
rawURL := fmt.Sprintf("%s/%s/%s/raw/%s/%s",
|
||||
setting.AppSubURL,
|
||||
url.PathEscape(ctx.RenderOptions.Metas["user"]),
|
||||
url.PathEscape(ctx.RenderOptions.Metas["repo"]),
|
||||
ctx.RenderOptions.Metas["RefTypeNameSubURL"],
|
||||
url.PathEscape(ctx.RenderOptions.RelativePath),
|
||||
)
|
||||
return ctx.RenderInternal.FormatWithSafeAttrs(output, `<div class="%s" %s="%s"></div>`, playerClassName, playerSrcAttr, rawURL)
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
// CamoEncode encodes a lnk to fit with the go-camo and camo proxy links. The purposes of camo-proxy are:
|
||||
|
||||
@@ -6,7 +6,7 @@ package markup
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"io"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/typesniffer"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/typesniffer"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
trend "github.com/buildkite/terminal-to-html/v3"
|
||||
)
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/typesniffer"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/typesniffer"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -9,11 +9,11 @@ import (
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
"code.gitea.io/gitea/modules/csv"
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/translation"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/modules/csv"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/translation"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"gitea.dev/modules/markup"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
+41
-25
@@ -5,16 +5,16 @@ package external
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/process"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/process"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/kballard/go-shellquote"
|
||||
)
|
||||
@@ -48,6 +48,11 @@ func RegisterRenderers() {
|
||||
},
|
||||
})
|
||||
|
||||
markup.RegisterRenderer(&frontendRenderer{
|
||||
name: "asciicast",
|
||||
patterns: []string{"*.cast"},
|
||||
})
|
||||
|
||||
for _, renderer := range setting.ExternalMarkupRenderers {
|
||||
markup.RegisterRenderer(&Renderer{renderer})
|
||||
}
|
||||
@@ -86,52 +91,63 @@ func (p *Renderer) GetExternalRendererOptions() (ret markup.ExternalRendererOpti
|
||||
return ret
|
||||
}
|
||||
|
||||
func envMark(envName string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "%" + envName + "%"
|
||||
func (p *Renderer) prepareExternalCommand(vars map[string]string) (string, []string, error) {
|
||||
fields, err := shellquote.Split(strings.TrimSpace(p.Command))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return "$" + envName
|
||||
if len(fields) == 0 {
|
||||
return "", nil, errors.New("no command")
|
||||
}
|
||||
var replacements []string
|
||||
for k, v := range vars {
|
||||
replacements = append(replacements, "$"+k, v)
|
||||
replacements = append(replacements, "%"+k+"%", v) // for legacy Windows-style support
|
||||
}
|
||||
r := strings.NewReplacer(replacements...)
|
||||
for i := range fields {
|
||||
fields[i] = r.Replace(fields[i])
|
||||
}
|
||||
return fields[0], fields[1:], nil
|
||||
}
|
||||
|
||||
// Render renders the data of the document to HTML via the external tool.
|
||||
func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
|
||||
baseLinkSrc := ctx.RenderHelper.ResolveLink("", markup.LinkTypeDefault)
|
||||
baseLinkRaw := ctx.RenderHelper.ResolveLink("", markup.LinkTypeRaw)
|
||||
command := strings.NewReplacer(
|
||||
envMark("GITEA_PREFIX_SRC"), baseLinkSrc,
|
||||
envMark("GITEA_PREFIX_RAW"), baseLinkRaw,
|
||||
).Replace(p.Command)
|
||||
commands, err := shellquote.Split(command)
|
||||
if err != nil || len(commands) == 0 {
|
||||
return fmt.Errorf("%s invalid command %q: %w", p.Name(), p.Command, err)
|
||||
cmdVars := map[string]string{
|
||||
"GITEA_PREFIX_SRC": baseLinkSrc,
|
||||
"GITEA_PREFIX_RAW": baseLinkRaw,
|
||||
}
|
||||
cmdProg, cmdArgs, err := p.prepareExternalCommand(cmdVars)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid external render (%s) command %q: %w", p.Name(), p.Command, err)
|
||||
}
|
||||
args := commands[1:]
|
||||
|
||||
if p.IsInputFile {
|
||||
// write to temp file
|
||||
f, cleanup, err := setting.AppDataTempDir("git-repo-content").CreateTempFileRandom("gitea_input")
|
||||
tmpFile, cleanup, err := setting.AppDataTempDir("git-repo-content").CreateTempFileRandom("gitea_input")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s create temp file when rendering %s failed: %w", p.Name(), p.Command, err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
_, err = io.Copy(f, input)
|
||||
_, err = io.Copy(tmpFile, input)
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
_ = tmpFile.Close()
|
||||
return fmt.Errorf("%s write data to temp file when rendering %s failed: %w", p.Name(), p.Command, err)
|
||||
}
|
||||
|
||||
err = f.Close()
|
||||
err = tmpFile.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s close temp file when rendering %s failed: %w", p.Name(), p.Command, err)
|
||||
}
|
||||
args = append(args, f.Name())
|
||||
cmdArgs = append(cmdArgs, tmpFile.Name())
|
||||
}
|
||||
|
||||
processCtx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("Render [%s] for %s", commands[0], baseLinkSrc))
|
||||
processCtx, _, finished := process.GetManager().AddContext(ctx, fmt.Sprintf("Render [%s] for %s", cmdProg, baseLinkSrc))
|
||||
defer finished()
|
||||
|
||||
cmd := exec.CommandContext(processCtx, commands[0], args...)
|
||||
cmd := exec.CommandContext(processCtx, cmdProg, cmdArgs...)
|
||||
cmd.Env = append(
|
||||
os.Environ(),
|
||||
"GITEA_PREFIX_SRC="+baseLinkSrc,
|
||||
@@ -146,7 +162,7 @@ func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.
|
||||
process.SetSysProcAttribute(cmd)
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("%s render run command %s %v failed: %w\nStderr: %s", p.Name(), commands[0], args, err, stderr.String())
|
||||
return fmt.Errorf("%s render run command %s %v failed: %w\nStderr: %s", p.Name(), cmdProg, shellquote.Join(cmdArgs...), err, stderr.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package external
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPrepareExternalCommand(t *testing.T) {
|
||||
r := &Renderer{MarkupRenderer: &setting.MarkupRenderer{Command: ""}}
|
||||
_, _, err := r.prepareExternalCommand(map[string]string{"KEY": "val"})
|
||||
assert.ErrorContains(t, err, "no command")
|
||||
|
||||
r = &Renderer{MarkupRenderer: &setting.MarkupRenderer{Command: `"/foo bar/bin" --opt $KEY "$KEY" %KEY% other`}}
|
||||
prog, args, err := r.prepareExternalCommand(map[string]string{"KEY": `a"b`})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "/foo bar/bin", prog)
|
||||
assert.Equal(t, []string{"--opt", `a"b`, `a"b`, `a"b`, "other"}, args)
|
||||
}
|
||||
+10
-17
@@ -5,14 +5,15 @@ package external
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.gitea.io/gitea/modules/htmlutil"
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/public"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/public"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
type frontendRenderer struct {
|
||||
@@ -20,19 +21,12 @@ type frontendRenderer struct {
|
||||
patterns []string
|
||||
}
|
||||
|
||||
var (
|
||||
_ markup.PostProcessRenderer = (*frontendRenderer)(nil)
|
||||
_ markup.ExternalRenderer = (*frontendRenderer)(nil)
|
||||
)
|
||||
var _ markup.ExternalRenderer = (*frontendRenderer)(nil)
|
||||
|
||||
func (p *frontendRenderer) Name() string {
|
||||
return p.name
|
||||
}
|
||||
|
||||
func (p *frontendRenderer) NeedPostProcess() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *frontendRenderer) FileNamePatterns() []string {
|
||||
// TODO: the file extensions are ambiguous, even if the file name matches, it doesn't mean that the file is a 3D model
|
||||
// There are some approaches to make it more accurate, but they are all complicated:
|
||||
@@ -54,14 +48,13 @@ func (p *frontendRenderer) SanitizerRules() []setting.MarkupSanitizerRule {
|
||||
func (p *frontendRenderer) GetExternalRendererOptions() (ret markup.ExternalRendererOptions) {
|
||||
ret.SanitizerDisabled = true
|
||||
ret.DisplayInIframe = true
|
||||
ret.ContentSandbox = "allow-scripts allow-forms allow-modals allow-popups allow-downloads"
|
||||
ret.ContentSandbox = setting.MarkupRenderDefaultSandbox
|
||||
return ret
|
||||
}
|
||||
|
||||
func (p *frontendRenderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
|
||||
if ctx.RenderOptions.StandalonePageOptions == nil {
|
||||
opts := p.GetExternalRendererOptions()
|
||||
return markup.RenderIFrame(ctx, &opts, output)
|
||||
return errors.New("should only be rendered in standalone page")
|
||||
}
|
||||
|
||||
content, err := util.ReadWithLimit(input, int(setting.UI.MaxDisplayFileSize))
|
||||
@@ -90,6 +83,6 @@ func (p *frontendRenderer) Render(ctx *markup.RenderContext, input io.Reader, ou
|
||||
</html>`,
|
||||
p.name, ctx.RenderOptions.RelativePath,
|
||||
contentEncoding, contentString,
|
||||
public.AssetURI("js/external-render-frontend.js"))
|
||||
public.AssetURI("web_src/js/external-render-frontend.ts"))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,15 +6,18 @@ package markup
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"code.gitea.io/gitea/modules/htmlutil"
|
||||
"code.gitea.io/gitea/modules/markup/common"
|
||||
"code.gitea.io/gitea/modules/translation"
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup/common"
|
||||
"gitea.dev/modules/translation"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html/atom"
|
||||
@@ -149,9 +152,8 @@ func PostProcessDefault(ctx *RenderContext, input io.Reader, output io.Writer) e
|
||||
return postProcess(ctx, procs, input, output)
|
||||
}
|
||||
|
||||
// PostProcessCommitMessage will use the same logic as PostProcess, but will disable
|
||||
// the shortLinkProcessor.
|
||||
func PostProcessCommitMessage(ctx *RenderContext, content string) (string, error) {
|
||||
// PostProcessCommitMessage will use the same logic as PostProcess, but will disable the shortLinkProcessor.
|
||||
func PostProcessCommitMessage(ctx *RenderContext, content template.HTML) template.HTML {
|
||||
procs := []processor{
|
||||
fullIssuePatternProcessor,
|
||||
comparePatternProcessor,
|
||||
@@ -165,7 +167,7 @@ func PostProcessCommitMessage(ctx *RenderContext, content string) (string, error
|
||||
emojiProcessor,
|
||||
emojiShortCodeProcessor,
|
||||
}
|
||||
return postProcessString(ctx, procs, content)
|
||||
return postProcessHTML(ctx, procs, content)
|
||||
}
|
||||
|
||||
var emojiProcessors = []processor{
|
||||
@@ -173,16 +175,25 @@ var emojiProcessors = []processor{
|
||||
emojiProcessor,
|
||||
}
|
||||
|
||||
// isBareURLSubject reports whether the (HTML-escaped) commit subject content
|
||||
// is entirely a single URL, ignoring leading/trailing whitespace.
|
||||
func isBareURLSubject(content string) bool {
|
||||
s := strings.TrimSpace(html.UnescapeString(content))
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
m := common.GlobalVars().LinkRegex.FindStringIndex(s)
|
||||
return m != nil && m[0] == 0 && m[1] == len(s)
|
||||
}
|
||||
|
||||
// PostProcessCommitMessageSubject will use the same logic as PostProcess and
|
||||
// PostProcessCommitMessage, but will disable the shortLinkProcessor and
|
||||
// emailAddressProcessor, will add a defaultLinkProcessor if defaultLink is set,
|
||||
// which changes every text node into a link to the passed default link.
|
||||
func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content string) (string, error) {
|
||||
// emailAddressProcessor, and wraps the whole subject in defaultLink.
|
||||
func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink string, content template.HTML) template.HTML {
|
||||
procs := []processor{
|
||||
fullIssuePatternProcessor,
|
||||
comparePatternProcessor,
|
||||
fullHashPatternProcessor,
|
||||
linkProcessor,
|
||||
mentionProcessor,
|
||||
issueIndexPatternProcessor,
|
||||
commitCrossReferencePatternProcessor,
|
||||
@@ -190,32 +201,42 @@ func PostProcessCommitMessageSubject(ctx *RenderContext, defaultLink, content st
|
||||
emojiShortCodeProcessor,
|
||||
emojiProcessor,
|
||||
}
|
||||
// When the whole subject is a bare URL, linkProcessor would turn it into
|
||||
// a competing anchor and hijack the surrounding defaultLink wrapper, leaving
|
||||
// the subject visually unclickable. Match GitHub: render such subjects as
|
||||
// plain text inside defaultLink. Partial URLs inside larger text still become
|
||||
// their own links (nested anchors aren't legal HTML, so the outer defaultLink
|
||||
// naturally breaks on that span, same as on GitHub).
|
||||
if !isBareURLSubject(string(content)) {
|
||||
procs = append(procs, linkProcessor)
|
||||
}
|
||||
procs = append(procs, func(ctx *RenderContext, node *html.Node) {
|
||||
ch := &html.Node{Parent: node, Type: html.TextNode, Data: node.Data}
|
||||
node.Type = html.ElementNode
|
||||
node.Data = "a"
|
||||
node.DataAtom = atom.A
|
||||
node.Attr = []html.Attribute{{Key: "href", Val: defaultLink}, {Key: "class", Val: "muted"}}
|
||||
node.Attr = []html.Attribute{{Key: "href", Val: defaultLink}, {Key: "class", Val: "muted title-full-link"}}
|
||||
node.FirstChild, node.LastChild = ch, ch
|
||||
})
|
||||
return postProcessString(ctx, procs, content)
|
||||
rendered := postProcessHTML(ctx, procs, content)
|
||||
return htmlutil.HTMLFormat(`<span class="title-full-link-hover">%s</span>`, rendered)
|
||||
}
|
||||
|
||||
// PostProcessIssueTitle to process title on individual issue/pull page
|
||||
func PostProcessIssueTitle(ctx *RenderContext, title string) (string, error) {
|
||||
return postProcessString(ctx, []processor{
|
||||
func PostProcessIssueTitle(ctx *RenderContext, titleHTML template.HTML) template.HTML {
|
||||
return postProcessHTML(ctx, []processor{
|
||||
issueIndexPatternProcessor,
|
||||
commitCrossReferencePatternProcessor,
|
||||
hashCurrentPatternProcessor,
|
||||
emojiShortCodeProcessor,
|
||||
emojiProcessor,
|
||||
}, title)
|
||||
}, titleHTML)
|
||||
}
|
||||
|
||||
// PostProcessDescriptionHTML will use similar logic as PostProcess, but will
|
||||
// use a single special linkProcessor.
|
||||
func PostProcessDescriptionHTML(ctx *RenderContext, content string) (string, error) {
|
||||
return postProcessString(ctx, []processor{
|
||||
func PostProcessDescriptionHTML(ctx *RenderContext, content template.HTML) template.HTML {
|
||||
return postProcessHTML(ctx, []processor{
|
||||
descriptionLinkProcessor,
|
||||
emojiShortCodeProcessor,
|
||||
emojiProcessor,
|
||||
@@ -223,17 +244,18 @@ func PostProcessDescriptionHTML(ctx *RenderContext, content string) (string, err
|
||||
}
|
||||
|
||||
// PostProcessEmoji for when we want to just process emoji and shortcodes
|
||||
// in various places it isn't already run through the normal markdown processor
|
||||
func PostProcessEmoji(ctx *RenderContext, content string) (string, error) {
|
||||
return postProcessString(ctx, emojiProcessors, content)
|
||||
// in various places it isn't already run through the normal Markdown processor
|
||||
func PostProcessEmoji(ctx *RenderContext, content template.HTML) template.HTML {
|
||||
return postProcessHTML(ctx, emojiProcessors, content)
|
||||
}
|
||||
|
||||
func postProcessString(ctx *RenderContext, procs []processor, content string) (string, error) {
|
||||
func postProcessHTML(ctx *RenderContext, procs []processor, content template.HTML) template.HTML {
|
||||
var buf strings.Builder
|
||||
if err := postProcess(ctx, procs, strings.NewReader(content), &buf); err != nil {
|
||||
return "", err
|
||||
if err := postProcess(ctx, procs, strings.NewReader(string(content)), &buf); err != nil {
|
||||
log.Warn("postProcessHTML err: %v, input: %s", err, util.TruncateRunes(string(content), 200))
|
||||
return content
|
||||
}
|
||||
return buf.String(), nil
|
||||
return template.HTML(buf.String())
|
||||
}
|
||||
|
||||
func RenderTocHeadingItems(ctx *RenderContext, nodeDetailsAttrs map[string]string, out io.Writer) {
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/httplib"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/markup/markdown"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -22,7 +22,7 @@ func TestRenderCodePreview(t *testing.T) {
|
||||
},
|
||||
})
|
||||
test := func(input, expected string) {
|
||||
buffer, err := markup.RenderString(markup.NewTestRenderContext().WithMarkupType(markdown.MarkupName), input)
|
||||
buffer, err := testRenderString(markup.NewTestRenderContext().WithMarkupType(markdown.MarkupName), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/base"
|
||||
"code.gitea.io/gitea/modules/httplib"
|
||||
"code.gitea.io/gitea/modules/references"
|
||||
"gitea.dev/modules/base"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/references"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html/atom"
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"code.gitea.io/gitea/modules/emoji"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"gitea.dev/modules/emoji"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html/atom"
|
||||
@@ -69,6 +69,11 @@ func emojiShortCodeProcessor(ctx *RenderContext, node *html.Node) {
|
||||
m[1] += start
|
||||
start = m[1]
|
||||
|
||||
// don't render a shortcode whose ":" directly follows a backtick (an unclosed code span)
|
||||
if m[0] > 0 && node.Data[m[0]-1] == '`' {
|
||||
continue
|
||||
}
|
||||
|
||||
alias := node.Data[m[0]:m[1]]
|
||||
|
||||
var nextChar byte
|
||||
|
||||
@@ -5,13 +5,14 @@ package markup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
testModule "code.gitea.io/gitea/modules/test"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/modules/setting"
|
||||
testModule "gitea.dev/modules/test"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -260,9 +261,8 @@ func TestRender_PostProcessIssueTitle(t *testing.T) {
|
||||
"repo": "someRepo",
|
||||
"style": IssueNameStyleNumeric,
|
||||
}
|
||||
actual, err := PostProcessIssueTitle(NewTestRenderContext(metas), "#1")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "#1", actual)
|
||||
actual := PostProcessIssueTitle(NewTestRenderContext(metas), "#1")
|
||||
assert.Equal(t, template.HTML("#1"), actual)
|
||||
}
|
||||
|
||||
func testRenderIssueIndexPattern(t *testing.T, input, expected string, ctx *RenderContext) {
|
||||
|
||||
@@ -8,13 +8,13 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/httplib"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/references"
|
||||
"code.gitea.io/gitea/modules/regexplru"
|
||||
"code.gitea.io/gitea/modules/templates/vars"
|
||||
"code.gitea.io/gitea/modules/translation"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/references"
|
||||
"gitea.dev/modules/regexplru"
|
||||
"gitea.dev/modules/templates/vars"
|
||||
"gitea.dev/modules/translation"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html/atom"
|
||||
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/htmlutil"
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/markup/markdown"
|
||||
testModule "code.gitea.io/gitea/modules/test"
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
testModule "gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/markup/common"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/modules/markup/common"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
"golang.org/x/net/html/atom"
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/references"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/modules/references"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ package markup
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/markup/common"
|
||||
"gitea.dev/modules/markup/common"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
@@ -8,12 +8,12 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/emoji"
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/markup/markdown"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
testModule "code.gitea.io/gitea/modules/test"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/modules/emoji"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
"gitea.dev/modules/setting"
|
||||
testModule "gitea.dev/modules/test"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -24,10 +24,16 @@ var (
|
||||
localMetas = map[string]string{"user": testRepoOwnerName, "repo": testRepoName}
|
||||
)
|
||||
|
||||
func testRenderString(ctx *markup.RenderContext, content string) (string, error) {
|
||||
var buf strings.Builder
|
||||
err := markup.Render(ctx, strings.NewReader(content), &buf)
|
||||
return buf.String(), err
|
||||
}
|
||||
|
||||
func TestRender_Commits(t *testing.T) {
|
||||
test := func(input, expected string) {
|
||||
rctx := markup.NewTestRenderContext(markup.TestAppURL, localMetas).WithRelativePath("a.md")
|
||||
buffer, err := markup.RenderString(rctx, input)
|
||||
buffer, err := testRenderString(rctx, input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
@@ -75,7 +81,7 @@ func TestRender_CrossReferences(t *testing.T) {
|
||||
defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||
test := func(input, expected string) {
|
||||
rctx := markup.NewTestRenderContext(markup.TestAppURL, localMetas).WithRelativePath("a.md")
|
||||
buffer, err := markup.RenderString(rctx, input)
|
||||
buffer, err := testRenderString(rctx, input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
@@ -119,7 +125,7 @@ func TestRender_links(t *testing.T) {
|
||||
setting.AppURL = markup.TestAppURL
|
||||
defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||
test := func(input, expected string) {
|
||||
buffer, err := markup.RenderString(markup.NewTestRenderContext().WithRelativePath("a.md"), input)
|
||||
buffer, err := testRenderString(markup.NewTestRenderContext().WithRelativePath("a.md"), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
@@ -234,7 +240,7 @@ func TestRender_email(t *testing.T) {
|
||||
setting.AppURL = markup.TestAppURL
|
||||
defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||
test := func(input, expected string) {
|
||||
res, err := markup.RenderString(markup.NewTestRenderContext().WithRelativePath("a.md"), input)
|
||||
res, err := testRenderString(markup.NewTestRenderContext().WithRelativePath("a.md"), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(res), "input: %s", input)
|
||||
}
|
||||
@@ -317,11 +323,11 @@ func TestRender_email(t *testing.T) {
|
||||
|
||||
func TestRender_emoji(t *testing.T) {
|
||||
setting.AppURL = markup.TestAppURL
|
||||
setting.StaticURLPrefix = markup.TestAppURL
|
||||
setting.StaticURLPrefix = strings.TrimSuffix(markup.TestAppURL, "/")
|
||||
|
||||
test := func(input, expected string) {
|
||||
expected = strings.ReplaceAll(expected, "&", "&")
|
||||
buffer, err := markup.RenderString(markup.NewTestRenderContext().WithRelativePath("a.md"), input)
|
||||
buffer, err := testRenderString(markup.NewTestRenderContext().WithRelativePath("a.md"), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
@@ -371,6 +377,9 @@ func TestRender_emoji(t *testing.T) {
|
||||
test(":100:200", `<p>:100:200</p>`)
|
||||
test("std::thread::something", `<p>std::thread::something</p>`)
|
||||
test(":not exist:", `<p>:not exist:</p>`)
|
||||
test("foo `:smile:", "<p>foo `:smile:</p>")
|
||||
test("foo `:smile:`", `<p>foo <code>:smile:</code></p>`)
|
||||
test("foo ` :smile:", "<p>foo ` <span class=\"emoji\" aria-label=\"grinning face with smiling eyes\">😄</span></p>")
|
||||
}
|
||||
|
||||
func TestRender_ShortLinks(t *testing.T) {
|
||||
@@ -500,7 +509,7 @@ func Test_ParseClusterFuzz(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPostProcess(t *testing.T) {
|
||||
setting.StaticURLPrefix = markup.TestAppURL // can't run standalone
|
||||
setting.StaticURLPrefix = strings.TrimSuffix(markup.TestAppURL, "/") // can't run standalone
|
||||
defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||
|
||||
test := func(input, expected string) {
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/markup/markdown"
|
||||
"code.gitea.io/gitea/modules/test"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"code.gitea.io/gitea/modules/htmlutil"
|
||||
"gitea.dev/modules/htmlutil"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
@@ -77,6 +77,7 @@ func (r *RenderInternal) ProtectSafeAttrs(content template.HTML) template.HTML {
|
||||
}
|
||||
|
||||
func (r *RenderInternal) FormatWithSafeAttrs(w io.Writer, fmt template.HTML, a ...any) error {
|
||||
_, err := w.Write([]byte(r.ProtectSafeAttrs(htmlutil.HTMLFormat(fmt, a...))))
|
||||
htmlStr := r.ProtectSafeAttrs(htmlutil.HTMLFormat(fmt, a...))
|
||||
_, err := io.WriteString(w, string(htmlStr))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"metadata": {},
|
||||
"nbformat": 4,
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"source": ["print('very-looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong')"],
|
||||
"outputs": [
|
||||
{
|
||||
"output_type": "execute_result",
|
||||
"text": ["very-looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong ...\n"]
|
||||
},
|
||||
{
|
||||
"output_type": "stream",
|
||||
"name": "stdout",
|
||||
"text": ["stdout 1 ...\n", "stdout 2 ...\n"]
|
||||
},
|
||||
{
|
||||
"output_type": "stream",
|
||||
"name": "stderr",
|
||||
"text": ["stderr ...\n"]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text/plain": ["data text 1\n", "data text 2\n"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text/plain": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"image/svg+xml": ["<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"2000\" height=\"20\"><rect width=\"2000\" height=\"20\" x=\"0\" y=\"0\" rx=\"5\" ry=\"5\" fill=\"red\"/></svg>"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text/html": "<a href='/'>HTML Link</a>"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text/latex": "$$a=1$$"
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text/plain": "plain text"
|
||||
}
|
||||
},
|
||||
{
|
||||
"output_type": "error",
|
||||
"ename": "Error Name",
|
||||
"traceback": ["stacktrace 1", "stacktrace 2"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "unknown-cell"
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"source": [
|
||||
"# h1\n", "## h2\n", "### h3\n", "\n", "paragraph 1\n", "\n",
|
||||
"very-looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong\n",
|
||||
"- list item 1\n", "- list item 2\n", "\n", "```python\n", "print('code block')\n", "```\n",
|
||||
"<table><tr><th>th1</th><th>th2</th></tr><tr><td>td1</td><td>td2</td></tr></table>\n"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package jupyter
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.dev/modules/highlight"
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/json"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
markup.RegisterRenderer(renderer{})
|
||||
}
|
||||
|
||||
// Renderer implements markup.Renderer for Jupyter notebooks
|
||||
type renderer struct{}
|
||||
|
||||
var (
|
||||
_ markup.Renderer = (*renderer)(nil)
|
||||
_ markup.ExternalRenderer = (*renderer)(nil) // FIXME: this is not an external render, need to refactor the framework in the future
|
||||
)
|
||||
|
||||
type mimeHandler struct {
|
||||
Mime string
|
||||
Fn func(w htmlutil.HTMLWriter, data string) error
|
||||
}
|
||||
|
||||
func renderCellCodeOutputTextPlain(w htmlutil.HTMLWriter, text string) error {
|
||||
w.WriteFormat(`<div class="cell-output-text"><pre>%s</pre></div>`, text)
|
||||
return w.Err()
|
||||
}
|
||||
|
||||
func renderCellCodeOutputUnsupported(w htmlutil.HTMLWriter, message string) error {
|
||||
w.WriteFormat(`<div class="cell-output-unsupported">%s</div>`, message)
|
||||
return w.Err()
|
||||
}
|
||||
|
||||
var dataMimeHandlers = sync.OnceValue(func() []mimeHandler {
|
||||
renderImage := func(w htmlutil.HTMLWriter, subtype, payload string) error {
|
||||
w.WriteFormat(`<div class="cell-output-image"><img src="data:image/%s;base64,%s"></div>`, subtype, payload)
|
||||
return w.Err()
|
||||
}
|
||||
renderUnsupportedOutput := func(message string) func(htmlutil.HTMLWriter, string) error {
|
||||
return func(w htmlutil.HTMLWriter, _ string) error {
|
||||
return renderCellCodeOutputUnsupported(w, message)
|
||||
}
|
||||
}
|
||||
return []mimeHandler{
|
||||
// Images (PNG, JPEG, SVG)
|
||||
{"image/png", func(w htmlutil.HTMLWriter, d string) error {
|
||||
return renderImage(w, "png", d)
|
||||
}},
|
||||
{"image/jpeg", func(w htmlutil.HTMLWriter, d string) error {
|
||||
return renderImage(w, "jpeg", d)
|
||||
}},
|
||||
{"image/svg+xml", func(w htmlutil.HTMLWriter, d string) error {
|
||||
return renderImage(w, "svg+xml", base64.StdEncoding.EncodeToString(util.UnsafeStringToBytes(d)))
|
||||
}},
|
||||
|
||||
// Rich & Math Layouts
|
||||
{"text/html", func(w htmlutil.HTMLWriter, d string) error {
|
||||
// To future developers: don't allow custom CSS classes or attributes,
|
||||
// because ".link-action" or "data-fetch-xxx" can send POST requests and lead to XSS.
|
||||
// If you'd really like to support more, do remember to correctly sanitize the values.
|
||||
w.WriteFormat(`<div class="cell-output-html">%s</div>`, markup.Sanitize(d))
|
||||
return w.Err()
|
||||
}},
|
||||
{"text/latex", func(w htmlutil.HTMLWriter, d string) error {
|
||||
w.WriteFormat(`<div class="cell-output-latex"><pre><code class="language-math display">%s</code></pre></div>`, trimMathDelimiters(d))
|
||||
return w.Err()
|
||||
}},
|
||||
{"text/plain", renderCellCodeOutputTextPlain},
|
||||
|
||||
// Security Placeholders
|
||||
{"application/javascript", renderUnsupportedOutput("[JavaScript output - execution disabled for security]")},
|
||||
{"application/vnd.plotly.v1+json", renderUnsupportedOutput("[Plotly output - interactive plots not supported]")},
|
||||
{"application/vnd.jupyter.widget-view+json", renderUnsupportedOutput("[Jupyter widget - interactive widgets not supported]")},
|
||||
}
|
||||
})
|
||||
|
||||
func (renderer) Name() string {
|
||||
return "jupyter-render"
|
||||
}
|
||||
|
||||
func (renderer) GetExternalRendererOptions() markup.ExternalRendererOptions {
|
||||
return markup.ExternalRendererOptions{
|
||||
// HINT: no need to let markup render sanitize the output because there are many special CSS class names, inline attributes.
|
||||
// This render must guarantee that the output is safe and no XSS
|
||||
SanitizerDisabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (renderer) FileNamePatterns() []string {
|
||||
return []string{"*.ipynb"}
|
||||
}
|
||||
|
||||
func (renderer) SanitizerRules() []setting.MarkupSanitizerRule {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Notebook structures
|
||||
type Notebook struct {
|
||||
Cells []Cell `json:"cells"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
Nbformat int `json:"nbformat"`
|
||||
}
|
||||
|
||||
type Cell struct {
|
||||
CellType string `json:"cell_type"`
|
||||
Source any `json:"source"` // string or []string
|
||||
Outputs []Output `json:"outputs,omitempty"`
|
||||
ExecutionCount any `json:"execution_count,omitempty"` // int or null
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type Output struct {
|
||||
OutputType string `json:"output_type"`
|
||||
Data map[string]any `json:"data,omitempty"`
|
||||
Text any `json:"text,omitempty"` // string or []string
|
||||
Name string `json:"name,omitempty"`
|
||||
Traceback any `json:"traceback,omitempty"` // []string
|
||||
Ename string `json:"ename,omitempty"`
|
||||
Evalue string `json:"evalue,omitempty"`
|
||||
}
|
||||
|
||||
// Render renders Jupyter notebook to HTML
|
||||
func (renderer) Render(ctx *markup.RenderContext, input io.Reader, outputWriter io.Writer) error {
|
||||
htmlWriter := htmlutil.NewHTMLWriter(outputWriter)
|
||||
// the size is (should be) checked and/or limited by the caller to avoid OOM
|
||||
var notebook Notebook
|
||||
if err := json.NewDecoder(input).Decode(¬ebook); err != nil {
|
||||
htmlWriter.WriteFormat(`<div class="ui error message">Failed to parse notebook JSON: %v</div>`, err)
|
||||
return htmlWriter.Err()
|
||||
}
|
||||
|
||||
// Check nbformat version
|
||||
if notebook.Nbformat < 4 {
|
||||
msg := htmlutil.HTMLFormat("This notebook uses an older format (nbformat %d). Only nbformat 4+ is supported for rendering. Please upgrade the notebook in Jupyter or view the raw JSON.", notebook.Nbformat)
|
||||
htmlWriter.WriteFormat(`<div class="file-not-rendered-prompt">%s</div>`, msg)
|
||||
return htmlWriter.Err()
|
||||
}
|
||||
|
||||
// Detect language
|
||||
language := "python" // default
|
||||
if metadata, ok := notebook.Metadata["language_info"].(map[string]any); ok {
|
||||
if name, ok := metadata["name"].(string); ok {
|
||||
language = name
|
||||
}
|
||||
} else if kernelSpec, ok := notebook.Metadata["kernelspec"].(map[string]any); ok {
|
||||
if lang, ok := kernelSpec["language"].(string); ok {
|
||||
language = lang
|
||||
}
|
||||
}
|
||||
|
||||
// Start rendering
|
||||
htmlWriter.WriteHTML(`<div class="jupyter-notebook">`)
|
||||
|
||||
// limiting the cell rendering to 100 cells
|
||||
cells := notebook.Cells
|
||||
truncated := false
|
||||
const maxRenderedCells = 100
|
||||
|
||||
if len(cells) > maxRenderedCells {
|
||||
cells = cells[:maxRenderedCells] // Slice down to exactly 100 elements instantly at the pointer layer
|
||||
truncated = true
|
||||
}
|
||||
|
||||
for _, cell := range cells {
|
||||
if err := renderCell(ctx, htmlWriter, cell, language); err != nil {
|
||||
log.Warn("Failed to render cell: %v", err) // TODO: RENDER-LOG-HANDLING: see other comments
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if truncated {
|
||||
renderCellPrompt(htmlWriter, "Warning:", "Output truncated. This notebook contains too many cells to display efficiently.")
|
||||
}
|
||||
|
||||
htmlWriter.WriteHTML(`</div>`)
|
||||
return htmlWriter.Err()
|
||||
}
|
||||
|
||||
func renderCellCode(output htmlutil.HTMLWriter, cell Cell, language string) error {
|
||||
source := joinSource(cell.Source)
|
||||
var executionCount *int64
|
||||
if cell.ExecutionCount != nil {
|
||||
if count, err := util.ToInt64(cell.ExecutionCount); err == nil {
|
||||
executionCount = &count
|
||||
}
|
||||
}
|
||||
|
||||
output.WriteHTML(`<div class="cell-line">`)
|
||||
{
|
||||
if executionCount != nil {
|
||||
output.WriteFormat(`<div class="cell-left cell-prompt">In [%d]:</div>`, *executionCount)
|
||||
} else {
|
||||
output.WriteHTML(`<div class="cell-left cell-prompt">In [ ]:</div>`)
|
||||
}
|
||||
|
||||
// Highlight code
|
||||
lexer := highlight.DetectChromaLexerByFileName("", language)
|
||||
output.WriteFormat(`<div class="cell-right cell-input"><pre><code class="chroma language-%s">`, strings.ToLower(lexer.Config().Name))
|
||||
output.WriteHTML(highlight.RenderCodeByLexer(lexer, source))
|
||||
output.WriteHTML("</code></pre></div>")
|
||||
}
|
||||
output.WriteHTML(`</div>`)
|
||||
|
||||
// Render outputs
|
||||
if len(cell.Outputs) > 0 {
|
||||
hasExecutionResult := false
|
||||
for _, out := range cell.Outputs {
|
||||
if out.OutputType == "execute_result" {
|
||||
hasExecutionResult = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
output.WriteHTML(`<div class="cell-line">`)
|
||||
{
|
||||
if hasExecutionResult && executionCount != nil {
|
||||
output.WriteFormat(`<div class="cell-left cell-prompt">Out [%d]:</div>`, *executionCount)
|
||||
} else {
|
||||
output.WriteHTML(`<div class="cell-left cell-prompt"></div>`)
|
||||
}
|
||||
|
||||
output.WriteHTML(`<div class="cell-right cell-output">`)
|
||||
for _, out := range cell.Outputs {
|
||||
renderCellCodeOutput(output, out)
|
||||
}
|
||||
output.WriteHTML(`</div>`)
|
||||
}
|
||||
output.WriteHTML(`</div>`)
|
||||
}
|
||||
|
||||
return output.Err()
|
||||
}
|
||||
|
||||
func renderCellPrompt(output htmlutil.HTMLWriter, left, right template.HTML) {
|
||||
output.WriteFormat(`
|
||||
<div class="notebook-cell">
|
||||
<div class="cell-line">
|
||||
<div class="cell-left cell-prompt">%s</div>
|
||||
<div class="cell-right cell-prompt">%s</div>
|
||||
</div>
|
||||
</div>`, left, right)
|
||||
}
|
||||
|
||||
func renderCell(ctx *markup.RenderContext, output htmlutil.HTMLWriter, cell Cell, language string) error {
|
||||
switch cell.CellType {
|
||||
case "markdown":
|
||||
output.WriteHTML(`
|
||||
<div class="notebook-cell cell-type-markdown">
|
||||
<div class="cell-line">
|
||||
<div class="cell-left cell-prompt"></div>
|
||||
<div class="cell-right">`)
|
||||
if err := renderCellMarkdown(ctx, output, joinSource(cell.Source)); err != nil {
|
||||
return err
|
||||
}
|
||||
output.WriteHTML(`
|
||||
</div>
|
||||
</div>
|
||||
</div>`)
|
||||
case "code":
|
||||
output.WriteHTML(`<div class="notebook-cell cell-type-code">`)
|
||||
if err := renderCellCode(output, cell, language); err != nil {
|
||||
return err
|
||||
}
|
||||
output.WriteHTML(`</div>`)
|
||||
default:
|
||||
renderCellPrompt(output, "Cell:", htmlutil.HTMLFormat("[Cell type %s - unsupported, skipped]", cell.CellType))
|
||||
}
|
||||
return output.Err()
|
||||
}
|
||||
|
||||
func renderCellMarkdown(rctx *markup.RenderContext, output htmlutil.HTMLWriter, source string) error {
|
||||
markdownCtx := markup.NewRenderContext(rctx)
|
||||
// make sure the markdown render use the same options and helper to generate correct contents (e.g.: links)
|
||||
markdownCtx.RenderOptions = rctx.RenderOptions
|
||||
markdownCtx.RenderHelper = rctx.RenderHelper
|
||||
output.WriteHTML(`<div class="embedded-markdown">`)
|
||||
if err := markdown.Render(markdownCtx, strings.NewReader(source), output.OriginWriter()); err != nil {
|
||||
return err
|
||||
}
|
||||
output.WriteHTML(`</div>`)
|
||||
return output.Err()
|
||||
}
|
||||
|
||||
func renderCellCodeOutput(output htmlutil.HTMLWriter, out Output) {
|
||||
if out.Data != nil {
|
||||
// Iterate through our priority list to find the best matching MIME handler available
|
||||
for _, h := range dataMimeHandlers() {
|
||||
if rawPayload, exists := out.Data[h.Mime]; exists {
|
||||
var stringPayload string
|
||||
|
||||
// Flatten the polymorphic JSON input (string or []any) into a single clean string
|
||||
switch v := rawPayload.(type) {
|
||||
case string:
|
||||
stringPayload = v
|
||||
case []any:
|
||||
stringPayload = joinSource(v)
|
||||
default:
|
||||
_ = renderCellCodeOutputUnsupported(output, fmt.Sprintf("[Data output - unsupported data type %T for mime type %s]", rawPayload, h.Mime))
|
||||
continue
|
||||
}
|
||||
|
||||
if err := h.Fn(output, stringPayload); err != nil {
|
||||
// TODO: RENDER-LOG-HANDLING: outputting render's error to sever's log is not a proper approach
|
||||
// The errors can be:
|
||||
// * unsupported element (cell, data, etc): it should render the message on the UI to tell users that the content is not supported, or ignore them if they are ignore-able
|
||||
// * logic error: it should report to server logs
|
||||
// * network error: io.Writer tries to write to the HTTP connection, so the error can also be a network error, such error should be ignored
|
||||
log.Error("Jupyter rendering engine failed for MIME type %s: %v", h.Mime, err)
|
||||
}
|
||||
|
||||
// Return immediately after rendering the top matching priority format
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stream output
|
||||
if out.OutputType == "stream" && out.Text != nil {
|
||||
streamName := util.Iif(out.Name == "stderr", "stderr", "stdout")
|
||||
output.WriteFormat(`<pre class="cell-output-stream stream-%s">%s</pre>`, streamName, joinSource(out.Text))
|
||||
return
|
||||
}
|
||||
|
||||
// Error output
|
||||
if out.OutputType == "error" {
|
||||
traceback := ""
|
||||
if tb, ok := out.Traceback.([]any); ok {
|
||||
lines := make([]string, len(tb))
|
||||
for i, line := range tb {
|
||||
lines[i] = fmt.Sprint(line)
|
||||
}
|
||||
traceback = strings.Join(lines, "\n")
|
||||
}
|
||||
if traceback == "" && out.Ename != "" {
|
||||
traceback = fmt.Sprintf("%s: %s", out.Ename, out.Evalue)
|
||||
}
|
||||
output.WriteFormat(`<pre class="cell-output-error">%s</pre>`, traceback)
|
||||
return
|
||||
}
|
||||
|
||||
// Generic text output
|
||||
if out.Text != nil {
|
||||
_ = renderCellCodeOutputTextPlain(output, joinSource(out.Text))
|
||||
}
|
||||
}
|
||||
|
||||
func joinSource(source any) string {
|
||||
switch v := source.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return v
|
||||
case []any:
|
||||
// the "source slice item" has EOL ("\n"), so just join them together
|
||||
parts := make([]string, len(v))
|
||||
for i, part := range v {
|
||||
parts[i] = fmt.Sprint(part)
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
||||
// trimMathDelimiters strips a single pair of surrounding math delimiters ("$$...$$" or "$...$"),
|
||||
// so the inner expression is handled by the math post-processor. Unlike strings.Trim, it does not
|
||||
// eat unrelated "$" characters elsewhere in multi-expression content.
|
||||
func trimMathDelimiters(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if t, ok := strings.CutPrefix(s, "$$"); ok {
|
||||
return strings.TrimSuffix(t, "$$")
|
||||
}
|
||||
if t, ok := strings.CutPrefix(s, "$"); ok {
|
||||
return strings.TrimSuffix(t, "$")
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package jupyter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRender(t *testing.T) {
|
||||
r := renderer{}
|
||||
|
||||
t.Run("Basic notebook", func(t *testing.T) {
|
||||
input := `{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"source": ["print('hello')"],
|
||||
"outputs": [
|
||||
{
|
||||
"output_type": "stream",
|
||||
"name": "stdout",
|
||||
"text": ["hello\n"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {},
|
||||
"nbformat": 4
|
||||
}`
|
||||
|
||||
var output strings.Builder
|
||||
ctx := &markup.RenderContext{}
|
||||
err := r.Render(ctx, strings.NewReader(input), &output)
|
||||
|
||||
assert.NoError(t, err)
|
||||
result := output.String()
|
||||
assert.Contains(t, result, `<div class="jupyter-notebook">`)
|
||||
assert.Contains(t, result, `<div class="notebook-cell cell-type-code">`)
|
||||
assert.Contains(t, result, `In [1]:`)
|
||||
assert.Contains(t, result, `print`)
|
||||
assert.Contains(t, result, `hello`)
|
||||
assert.Contains(t, result, `stream-stdout`)
|
||||
})
|
||||
|
||||
t.Run("Markdown cell with XSS Protection", func(t *testing.T) {
|
||||
input := `{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"source": [
|
||||
"# Title\n",
|
||||
"Some text\n",
|
||||
"[click me](javascript:alert(1))\n",
|
||||
"<script>alert('dangerous')</script>"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {},
|
||||
"nbformat": 4
|
||||
}`
|
||||
|
||||
var output strings.Builder
|
||||
ctx := markup.NewRenderContext(t.Context())
|
||||
err := r.Render(ctx, strings.NewReader(input), &output)
|
||||
|
||||
assert.NoError(t, err)
|
||||
result := output.String()
|
||||
|
||||
// Assert normal markup still renders correctly
|
||||
assert.Contains(t, result, `<div class="notebook-cell cell-type-markdown">`)
|
||||
assert.Contains(t, result, `Title`)
|
||||
assert.Contains(t, result, `Some text`)
|
||||
assert.Contains(t, result, `click me`)
|
||||
|
||||
// CRITICAL SECURITY ASSERTIONS: Ensure XSS vectors are completely stripped
|
||||
assert.NotContains(t, result, `javascript:alert`)
|
||||
assert.NotContains(t, result, `<script>`)
|
||||
})
|
||||
|
||||
t.Run("Cell limit truncation guardrail", func(t *testing.T) {
|
||||
// Generate an oversized notebook containing 105 cells dynamically
|
||||
var cellBlocks []string
|
||||
for range 105 {
|
||||
cellBlocks = append(cellBlocks, `{"cell_type": "markdown", "source": ["cell text"]}`)
|
||||
}
|
||||
input := fmt.Sprintf(`{"cells": [%s], "metadata": {}, "nbformat": 4}`, strings.Join(cellBlocks, ","))
|
||||
|
||||
var output strings.Builder
|
||||
ctx := markup.NewRenderContext(t.Context())
|
||||
err := r.Render(ctx, strings.NewReader(input), &output)
|
||||
|
||||
assert.NoError(t, err)
|
||||
result := output.String()
|
||||
|
||||
// Verify it halts rendering gracefully and shows the truncation warning
|
||||
assert.Contains(t, result, "Output truncated.")
|
||||
assert.Contains(t, result, "This notebook contains too many cells to display efficiently.")
|
||||
|
||||
// Count occurrences of the rendered cells to ensure it sliced down to exactly 100 elements
|
||||
assert.Equal(t, 100, strings.Count(result, `class="notebook-cell cell-type-markdown"`))
|
||||
})
|
||||
|
||||
t.Run("Image output", func(t *testing.T) {
|
||||
input := `{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"source": ["import matplotlib.pyplot as plt"],
|
||||
"outputs": [
|
||||
{
|
||||
"output_type": "display_data",
|
||||
"data": {
|
||||
"image/png": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {},
|
||||
"nbformat": 4
|
||||
}`
|
||||
|
||||
var output strings.Builder
|
||||
ctx := markup.NewRenderContext(t.Context())
|
||||
err := r.Render(ctx, strings.NewReader(input), &output)
|
||||
|
||||
assert.NoError(t, err)
|
||||
result := output.String()
|
||||
assert.Contains(t, result, `<img src="data:image/png;base64,`)
|
||||
assert.Contains(t, result, `iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==`)
|
||||
})
|
||||
|
||||
t.Run("HTML output with style tag", func(t *testing.T) {
|
||||
input := `{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"source": ["import pandas as pd"],
|
||||
"outputs": [
|
||||
{
|
||||
"output_type": "execute_result",
|
||||
"data": {
|
||||
"text/html": ["<style scoped>.dataframe tbody tr th { vertical-align: top; }</style><table class=\"dataframe\"><tr><td>1</td></tr></table>"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {},
|
||||
"nbformat": 4
|
||||
}`
|
||||
|
||||
var output strings.Builder
|
||||
ctx := markup.NewRenderContext(t.Context())
|
||||
err := r.Render(ctx, strings.NewReader(input), &output)
|
||||
|
||||
assert.NoError(t, err)
|
||||
result := output.String()
|
||||
assert.NotContains(t, result, `<style scoped>`)
|
||||
assert.Contains(t, result, `<table><tr><td>1</td></tr></table>`)
|
||||
assert.Contains(t, result, `<td>1</td>`)
|
||||
})
|
||||
|
||||
t.Run("Error output", func(t *testing.T) {
|
||||
input := `{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"source": ["raise ValueError('test error')"],
|
||||
"outputs": [
|
||||
{
|
||||
"output_type": "error",
|
||||
"ename": "ValueError",
|
||||
"evalue": "test error",
|
||||
"traceback": ["ValueError: test error"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {},
|
||||
"nbformat": 4
|
||||
}`
|
||||
|
||||
var output strings.Builder
|
||||
ctx := markup.NewRenderContext(t.Context())
|
||||
err := r.Render(ctx, strings.NewReader(input), &output)
|
||||
|
||||
assert.NoError(t, err)
|
||||
result := output.String()
|
||||
assert.Contains(t, result, `ValueError: test error`)
|
||||
assert.Contains(t, result, `cell-output-error`)
|
||||
})
|
||||
|
||||
t.Run("Old nbformat version", func(t *testing.T) {
|
||||
input := `{
|
||||
"cells": [],
|
||||
"metadata": {},
|
||||
"nbformat": 3
|
||||
}`
|
||||
|
||||
var output strings.Builder
|
||||
ctx := markup.NewRenderContext(t.Context())
|
||||
err := r.Render(ctx, strings.NewReader(input), &output)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Regexp(t, `<div class="file-not-rendered-prompt">This notebook uses an older format.*</div>`, output.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestJoinSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input any
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "String input",
|
||||
input: "hello world",
|
||||
expected: "hello world",
|
||||
},
|
||||
{
|
||||
name: "Array input",
|
||||
input: []any{"line1\n", "line2\n", "line3"},
|
||||
expected: "line1\nline2\nline3",
|
||||
},
|
||||
{
|
||||
name: "Empty array",
|
||||
input: []any{},
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Single element array",
|
||||
input: []any{"single"},
|
||||
expected: "single",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := joinSource(tt.input)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationAndSanitization(t *testing.T) {
|
||||
// A mock malicious Jupyter notebook containing an XSS injection attempt
|
||||
// inside a text/html output cell (e.g., pretending to be a poisoned Pandas DataFrame).
|
||||
maliciousNotebook := `{
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 2,
|
||||
"metadata": {"language_info":{"name":"any lang"}},
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"metadata": {},
|
||||
"source": ["a=1"],
|
||||
"outputs": [
|
||||
{
|
||||
"output_type": "execute_result",
|
||||
"execution_count": 1,
|
||||
"data": {
|
||||
"text/html": [
|
||||
"<div><script>foo</script><table class=other><tr><td>[[name=no-post-process|link=/link]]</td></tr></table></div>"
|
||||
]
|
||||
},
|
||||
"metadata": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
var output strings.Builder
|
||||
ctx := markup.NewRenderContext(t.Context())
|
||||
ctx.RenderOptions.MarkupType = "jupyter-render"
|
||||
err := markup.Render(ctx, strings.NewReader(maliciousNotebook), &output)
|
||||
assert.NoError(t, err)
|
||||
const expected = `
|
||||
<div class="jupyter-notebook">
|
||||
<div class="notebook-cell cell-type-code">
|
||||
<div class="cell-line">
|
||||
<div class="cell-left cell-prompt">In [1]:</div>
|
||||
<div class="cell-right cell-input">
|
||||
<pre><code class="chroma language-fallback">
|
||||
a=1
|
||||
</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cell-line">
|
||||
<div class="cell-left cell-prompt">Out [1]:</div>
|
||||
<div class="cell-right cell-output">
|
||||
<div class="cell-output-html">
|
||||
<div><table><tr><td>[[name=no-post-process|link=/link]]</td></tr></table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
assert.Equal(t, test.NormalizeHTMLSpaces(expected), test.NormalizeHTMLSpaces(output.String()))
|
||||
}
|
||||
@@ -7,14 +7,13 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
setting.IsInTesting = true
|
||||
markup.RenderBehaviorForTesting.DisableAdditionalAttributes = true
|
||||
setting.Markdown.FileNamePatterns = []string{"*.md"}
|
||||
markup.RefreshFileNamePatterns()
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
@@ -95,13 +95,13 @@ type Icon struct {
|
||||
// ColorPreview is an inline for a color preview
|
||||
type ColorPreview struct {
|
||||
ast.BaseInline
|
||||
Color []byte
|
||||
Color string
|
||||
}
|
||||
|
||||
// Dump implements Node.Dump.
|
||||
func (n *ColorPreview) Dump(source []byte, level int) {
|
||||
m := map[string]string{}
|
||||
m["Color"] = string(n.Color)
|
||||
m["Color"] = n.Color
|
||||
ast.DumpHelper(n, source, level, m, nil)
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ func (n *ColorPreview) Kind() ast.NodeKind {
|
||||
}
|
||||
|
||||
// NewColorPreview returns a new Span node.
|
||||
func NewColorPreview(color []byte) *ColorPreview {
|
||||
func NewColorPreview(color string) *ColorPreview {
|
||||
return &ColorPreview{
|
||||
BaseInline: ast.BaseInline{},
|
||||
Color: color,
|
||||
@@ -170,3 +170,14 @@ func (n *RawHTML) Kind() ast.NodeKind {
|
||||
func NewRawHTML(rawHTML template.HTML) *RawHTML {
|
||||
return &RawHTML{rawHTML: rawHTML}
|
||||
}
|
||||
|
||||
func childSingleText(node ast.Node, source []byte) (string, bool) {
|
||||
if node.FirstChild() == nil || node.FirstChild() != node.LastChild() {
|
||||
return "", false
|
||||
}
|
||||
c, ok := node.FirstChild().(*ast.Text)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return string(c.Segment.Value(source)), true
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@ package markdown
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/htmlutil"
|
||||
"code.gitea.io/gitea/modules/svg"
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/svg"
|
||||
|
||||
"github.com/yuin/goldmark/ast"
|
||||
east "github.com/yuin/goldmark/extension/ast"
|
||||
"gopkg.in/yaml.v3"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func nodeToTable(meta *yaml.Node) ast.Node {
|
||||
|
||||
@@ -6,9 +6,9 @@ package markdown
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/markup/internal"
|
||||
"gitea.dev/modules/container"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/internal"
|
||||
|
||||
"github.com/yuin/goldmark/ast"
|
||||
east "github.com/yuin/goldmark/extension/ast"
|
||||
@@ -70,6 +70,8 @@ func (g *ASTTransformer) Transform(node *ast.Document, reader text.Reader, pc pa
|
||||
}
|
||||
case *ast.CodeSpan:
|
||||
g.transformCodeSpan(ctx, v, reader)
|
||||
case *ast.FencedCodeBlock:
|
||||
g.transformFencedCodeblock(v, reader)
|
||||
case *ast.Blockquote:
|
||||
return g.transformBlockquote(v, reader)
|
||||
}
|
||||
@@ -117,12 +119,33 @@ func (r *HTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
reg.Register(KindDetails, r.renderDetails)
|
||||
reg.Register(KindSummary, r.renderSummary)
|
||||
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
|
||||
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
|
||||
reg.Register(KindAttention, r.renderAttention)
|
||||
reg.Register(KindTaskCheckBoxListItem, r.renderTaskCheckBoxListItem)
|
||||
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 {
|
||||
opening := r.renderInternal.ProtectSafeAttrs(`<div class="code-block-container code-overflow-scroll"><pre class="code-block"><code>`)
|
||||
if _, err := w.WriteString(string(opening)); 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))
|
||||
}
|
||||
} else {
|
||||
if _, err := w.WriteString("</code></pre></div>"); err != nil {
|
||||
return ast.WalkStop, err
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *HTMLRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*ast.Document)
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
|
||||
@@ -11,12 +11,13 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/markup/common"
|
||||
"code.gitea.io/gitea/modules/markup/markdown/math"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
giteautil "code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/common"
|
||||
"gitea.dev/modules/markup/markdown/math"
|
||||
"gitea.dev/modules/setting"
|
||||
giteautil "gitea.dev/modules/util"
|
||||
|
||||
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
|
||||
"github.com/yuin/goldmark"
|
||||
@@ -64,21 +65,17 @@ func newParserContext(ctx *markup.RenderContext) parser.Context {
|
||||
return pc
|
||||
}
|
||||
|
||||
type GlodmarkRender struct {
|
||||
type GoldmarkRender struct {
|
||||
ctx *markup.RenderContext
|
||||
|
||||
goldmarkMarkdown goldmark.Markdown
|
||||
}
|
||||
|
||||
func (r *GlodmarkRender) Convert(source []byte, writer io.Writer, opts ...parser.ParseOption) error {
|
||||
func (r *GoldmarkRender) Convert(source []byte, writer io.Writer, opts ...parser.ParseOption) error {
|
||||
return r.goldmarkMarkdown.Convert(source, writer, opts...)
|
||||
}
|
||||
|
||||
func (r *GlodmarkRender) Renderer() renderer.Renderer {
|
||||
return r.goldmarkMarkdown.Renderer()
|
||||
}
|
||||
|
||||
func (r *GlodmarkRender) highlightingRenderer(w util.BufWriter, c highlighting.CodeBlockContext, entering bool) {
|
||||
func (r *GoldmarkRender) highlightingRenderer(w util.BufWriter, c highlighting.CodeBlockContext, entering bool) {
|
||||
if entering {
|
||||
languageBytes, _ := c.Language()
|
||||
languageStr := giteautil.IfZero(string(languageBytes), "text")
|
||||
@@ -139,10 +136,10 @@ func goldmarkDefaultParser() parser.Parser {
|
||||
}
|
||||
|
||||
// SpecializedMarkdown sets up the Gitea specific markdown extensions
|
||||
func SpecializedMarkdown(ctx *markup.RenderContext) *GlodmarkRender {
|
||||
func SpecializedMarkdown(ctx *markup.RenderContext) *GoldmarkRender {
|
||||
// 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 := &GlodmarkRender{ctx: ctx}
|
||||
r := &GoldmarkRender{ctx: ctx}
|
||||
r.goldmarkMarkdown = goldmark.New(
|
||||
goldmark.WithParser(goldmarkDefaultParser()),
|
||||
goldmark.WithExtensions(
|
||||
@@ -272,7 +269,7 @@ func RenderString(ctx *markup.RenderContext, content string) (template.HTML, err
|
||||
if err := Render(ctx, strings.NewReader(content), &buf); err != nil {
|
||||
log.Warn("Unable to RenderString: %v, content: %s", err, giteautil.TruncateRunes(content, 200))
|
||||
err = nil
|
||||
return template.HTML(template.HTMLEscapeString(content)), err
|
||||
return htmlutil.EscapeString(content), err
|
||||
}
|
||||
return template.HTML(buf.String()), nil
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/markup/markdown"
|
||||
"code.gitea.io/gitea/modules/svg"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
"gitea.dev/modules/svg"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.org/x/text/cases"
|
||||
|
||||
@@ -6,8 +6,8 @@ package markdown_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/markup/markdown"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
)
|
||||
|
||||
func BenchmarkSpecializedMarkdown(b *testing.B) {
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/test"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -9,11 +9,11 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/markup/markdown"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/test"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/markdown"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -600,3 +600,24 @@ func TestMarkdownUlDir(t *testing.T) {
|
||||
</ul>
|
||||
`, string(result))
|
||||
}
|
||||
|
||||
func TestMarkdownCodeBlock(t *testing.T) {
|
||||
testRender := func(input, expected string) {
|
||||
buffer, err := markdown.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(string(buffer)))
|
||||
}
|
||||
const nl = "\n"
|
||||
const prefix = `<div class="code-block-container code-overflow-scroll"><pre class="code-block">`
|
||||
const suffix = `</pre></div>`
|
||||
|
||||
testRender("```\ncode\n```", prefix+`<code class="chroma language-text display">code`+nl+`</code>`+suffix)
|
||||
|
||||
const jsCommon = prefix + `<code class="chroma language-js display"><span class="nx">code</span>` + nl + `</code>` + 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)
|
||||
testRender(" code\n", prefix+`<code>code`+nl+`</code>`+suffix)
|
||||
testRender(" <script>alert(1)</script>\n", prefix+`<code><script>alert(1)</script>`+nl+`</code>`+suffix)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ package math
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
giteaUtil "code.gitea.io/gitea/modules/util"
|
||||
giteaUtil "gitea.dev/modules/util"
|
||||
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
|
||||
@@ -6,8 +6,8 @@ package math
|
||||
import (
|
||||
"html/template"
|
||||
|
||||
"code.gitea.io/gitea/modules/markup/internal"
|
||||
giteaUtil "code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/modules/markup/internal"
|
||||
giteaUtil "gitea.dev/modules/util"
|
||||
|
||||
gast "github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
|
||||
@@ -6,7 +6,7 @@ package math
|
||||
import (
|
||||
"bytes"
|
||||
|
||||
"code.gitea.io/gitea/modules/markup/internal"
|
||||
"gitea.dev/modules/markup/internal"
|
||||
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
package math
|
||||
|
||||
import (
|
||||
"code.gitea.io/gitea/modules/markup/internal"
|
||||
giteaUtil "code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/modules/markup/internal"
|
||||
giteaUtil "gitea.dev/modules/util"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func isYAMLSeparator(line []byte) bool {
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"gitea.dev/modules/markup"
|
||||
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"gopkg.in/yaml.v3"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// RenderConfig represents rendering configuration for this file
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func TestRenderConfig_UnmarshalYAML(t *testing.T) {
|
||||
|
||||
@@ -6,7 +6,7 @@ package markdown
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/svg"
|
||||
"gitea.dev/modules/svg"
|
||||
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/text"
|
||||
@@ -46,7 +46,10 @@ func (g *ASTTransformer) extractBlockquoteAttentionEmphasis(firstParagraph ast.N
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
val1 := string(node1.Text(reader.Source())) //nolint:staticcheck // Text is deprecated
|
||||
val1, ok := childSingleText(node1, reader.Source())
|
||||
if !ok {
|
||||
return "", nil
|
||||
}
|
||||
attentionType := strings.ToLower(val1)
|
||||
if g.attentionTypes.Contains(attentionType) {
|
||||
return attentionType, []ast.Node{node1}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"bytes"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"gitea.dev/modules/markup"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday/css"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
@@ -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>`, string(v.Color))
|
||||
_ = r.renderInternal.FormatWithSafeAttrs(w, `<span class="color-preview" style="background-color: %s"></span>`, v.Color)
|
||||
}
|
||||
}
|
||||
return ast.WalkSkipChildren, nil
|
||||
@@ -68,8 +68,11 @@ func cssColorHandler(value string) bool {
|
||||
}
|
||||
|
||||
func (g *ASTTransformer) transformCodeSpan(_ *markup.RenderContext, v *ast.CodeSpan, reader text.Reader) {
|
||||
colorContent := v.Text(reader.Source()) //nolint:staticcheck // Text is deprecated
|
||||
if cssColorHandler(string(colorContent)) {
|
||||
colorContent, ok := childSingleText(v, reader.Source())
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if cssColorHandler(colorContent) {
|
||||
v.AppendChild(v, NewColorPreview(colorContent))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ package markdown
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"gitea.dev/modules/markup"
|
||||
|
||||
"github.com/yuin/goldmark/ast"
|
||||
east "github.com/yuin/goldmark/extension/ast"
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/markup/common"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup/common"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
|
||||
@@ -9,11 +9,11 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/highlight"
|
||||
"code.gitea.io/gitea/modules/htmlutil"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"gitea.dev/modules/highlight"
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/log"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/alecthomas/chroma/v2"
|
||||
"github.com/niklasfasching/go-org/org"
|
||||
@@ -70,7 +70,15 @@ func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error
|
||||
w := &orgWriter{rctx: ctx, HTMLWriter: htmlWriter}
|
||||
htmlWriter.ExtendingWriter = w
|
||||
|
||||
res, err := org.New().Silent().Parse(input, "").Write(w)
|
||||
cfg := org.New()
|
||||
cfg.ReadFile = func(path string) ([]byte, error) {
|
||||
// actually the orgmode render doesn't support rendering the content from the content again,
|
||||
// so just leave the plain text to end users
|
||||
content := fmt.Sprintf("#+INCLUDE: [[%s]]", path)
|
||||
return []byte(content), nil
|
||||
}
|
||||
doc := cfg.Silent().Parse(input, "")
|
||||
res, err := doc.Write(w)
|
||||
if err != nil {
|
||||
return fmt.Errorf("orgmode.Render failed: %w", err)
|
||||
}
|
||||
@@ -106,31 +114,27 @@ func (r *orgWriter) resolveLink(link string) string {
|
||||
// WriteRegularLink renders images, links or videos
|
||||
func (r *orgWriter) WriteRegularLink(l org.RegularLink) {
|
||||
link := r.resolveLink(l.URL)
|
||||
|
||||
printHTML := func(html template.HTML, a ...any) {
|
||||
_, _ = fmt.Fprint(r, htmlutil.HTMLFormat(html, a...))
|
||||
}
|
||||
// Inspired by https://github.com/niklasfasching/go-org/blob/6eb20dbda93cb88c3503f7508dc78cbbc639378f/org/html_writer.go#L406-L427
|
||||
switch l.Kind() {
|
||||
case "image":
|
||||
if l.Description == nil {
|
||||
printHTML(`<img src="%s" alt="%s">`, link, link)
|
||||
_, _ = htmlutil.HTMLPrintf(r, `<img src="%s" alt="%s">`, link, link)
|
||||
} else {
|
||||
imageSrc := r.resolveLink(org.String(l.Description...))
|
||||
printHTML(`<a href="%s"><img src="%s" alt="%s"></a>`, link, imageSrc, imageSrc)
|
||||
_, _ = htmlutil.HTMLPrintf(r, `<a href="%s"><img src="%s" alt="%s"></a>`, link, imageSrc, imageSrc)
|
||||
}
|
||||
case "video":
|
||||
if l.Description == nil {
|
||||
printHTML(`<video src="%s">%s</video>`, link, link)
|
||||
_, _ = htmlutil.HTMLPrintf(r, `<video src="%s">%s</video>`, link, link)
|
||||
} else {
|
||||
videoSrc := r.resolveLink(org.String(l.Description...))
|
||||
printHTML(`<a href="%s"><video src="%s">%s</video></a>`, link, videoSrc, videoSrc)
|
||||
_, _ = htmlutil.HTMLPrintf(r, `<a href="%s"><video src="%s">%s</video></a>`, link, videoSrc, videoSrc)
|
||||
}
|
||||
default:
|
||||
var description any = link
|
||||
if l.Description != nil {
|
||||
description = template.HTML(r.WriteNodesAsString(l.Description...)) // orgmode HTMLWriter outputs HTML content
|
||||
}
|
||||
printHTML(`<a href="%s">%s</a>`, link, description)
|
||||
_, _ = htmlutil.HTMLPrintf(r, `<a href="%s">%s</a>`, link, description)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/markup"
|
||||
"code.gitea.io/gitea/modules/markup/orgmode"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"gitea.dev/modules/markup"
|
||||
"gitea.dev/modules/markup/orgmode"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -21,86 +21,74 @@ func TestMain(m *testing.M) {
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestRender_StandardLinks(t *testing.T) {
|
||||
test := func(input, expected string) {
|
||||
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
func testRender(t *testing.T, input, expected string) {
|
||||
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
test("[[https://google.com/]]",
|
||||
func TestRender_StandardLinks(t *testing.T) {
|
||||
testRender(t, "[[https://google.com/]]",
|
||||
`<p><a href="https://google.com/">https://google.com/</a></p>`)
|
||||
test("[[ImageLink.svg][The Image Desc]]",
|
||||
testRender(t, "[[ImageLink.svg][The Image Desc]]",
|
||||
`<p><a href="ImageLink.svg">The Image Desc</a></p>`)
|
||||
}
|
||||
|
||||
func TestRender_InternalLinks(t *testing.T) {
|
||||
test := func(input, expected string) {
|
||||
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
test("[[file:test.org][Test]]",
|
||||
testRender(t, "[[file:test.org][Test]]",
|
||||
`<p><a href="test.org">Test</a></p>`)
|
||||
test("[[./test.org][Test]]",
|
||||
testRender(t, "[[./test.org][Test]]",
|
||||
`<p><a href="./test.org">Test</a></p>`)
|
||||
test("[[test.org][Test]]",
|
||||
testRender(t, "[[test.org][Test]]",
|
||||
`<p><a href="test.org">Test</a></p>`)
|
||||
test("[[path/to/test.org][Test]]",
|
||||
testRender(t, "[[path/to/test.org][Test]]",
|
||||
`<p><a href="path/to/test.org">Test</a></p>`)
|
||||
}
|
||||
|
||||
func TestRender_Media(t *testing.T) {
|
||||
test := func(input, expected string) {
|
||||
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
test("[[file:../../.images/src/02/train.jpg]]",
|
||||
testRender(t, "[[file:../../.images/src/02/train.jpg]]",
|
||||
`<p><img src="../../.images/src/02/train.jpg" alt="../../.images/src/02/train.jpg"></p>`)
|
||||
test("[[file:train.jpg]]",
|
||||
testRender(t, "[[file:train.jpg]]",
|
||||
`<p><img src="train.jpg" alt="train.jpg"></p>`)
|
||||
|
||||
// With description.
|
||||
test("[[https://example.com][https://example.com/example.svg]]",
|
||||
testRender(t, "[[https://example.com][https://example.com/example.svg]]",
|
||||
`<p><a href="https://example.com"><img src="https://example.com/example.svg" alt="https://example.com/example.svg"></a></p>`)
|
||||
test("[[https://example.com][pre https://example.com/example.svg post]]",
|
||||
testRender(t, "[[https://example.com][pre https://example.com/example.svg post]]",
|
||||
`<p><a href="https://example.com">pre <img src="https://example.com/example.svg" alt="https://example.com/example.svg"> post</a></p>`)
|
||||
test("[[https://example.com][https://example.com/example.mp4]]",
|
||||
testRender(t, "[[https://example.com][https://example.com/example.mp4]]",
|
||||
`<p><a href="https://example.com"><video src="https://example.com/example.mp4">https://example.com/example.mp4</video></a></p>`)
|
||||
test("[[https://example.com][pre https://example.com/example.mp4 post]]",
|
||||
testRender(t, "[[https://example.com][pre https://example.com/example.mp4 post]]",
|
||||
`<p><a href="https://example.com">pre <video src="https://example.com/example.mp4">https://example.com/example.mp4</video> post</a></p>`)
|
||||
|
||||
// Without description.
|
||||
test("[[https://example.com/example.svg]]",
|
||||
testRender(t, "[[https://example.com/example.svg]]",
|
||||
`<p><img src="https://example.com/example.svg" alt="https://example.com/example.svg"></p>`)
|
||||
test("[[https://example.com/example.mp4]]",
|
||||
testRender(t, "[[https://example.com/example.mp4]]",
|
||||
`<p><video src="https://example.com/example.mp4">https://example.com/example.mp4</video></p>`)
|
||||
|
||||
// test [[LINK][DESCRIPTION]] syntax with "file:" prefix
|
||||
test(`[[https://example.com/][file:https://example.com/foo%20bar.svg]]`,
|
||||
testRender(t, `[[https://example.com/][file:https://example.com/foo%20bar.svg]]`,
|
||||
`<p><a href="https://example.com/"><img src="https://example.com/foo%20bar.svg" alt="https://example.com/foo%20bar.svg"></a></p>`)
|
||||
test(`[[file:https://example.com/foo%20bar.svg][Goto Image]]`,
|
||||
testRender(t, `[[file:https://example.com/foo%20bar.svg][Goto Image]]`,
|
||||
`<p><a href="https://example.com/foo%20bar.svg">Goto Image</a></p>`)
|
||||
test(`[[file:https://example.com/link][https://example.com/image.jpg]]`,
|
||||
testRender(t, `[[file:https://example.com/link][https://example.com/image.jpg]]`,
|
||||
`<p><a href="https://example.com/link"><img src="https://example.com/image.jpg" alt="https://example.com/image.jpg"></a></p>`)
|
||||
test(`[[file:https://example.com/link][file:https://example.com/image.jpg]]`,
|
||||
testRender(t, `[[file:https://example.com/link][file:https://example.com/image.jpg]]`,
|
||||
`<p><a href="https://example.com/link"><img src="https://example.com/image.jpg" alt="https://example.com/image.jpg"></a></p>`)
|
||||
}
|
||||
|
||||
func TestRender_Source(t *testing.T) {
|
||||
test := func(input, expected string) {
|
||||
buffer, err := orgmode.RenderString(markup.NewTestRenderContext(), input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer))
|
||||
}
|
||||
|
||||
test(`#+begin_src c
|
||||
testRender(t, `#+begin_src c
|
||||
int a;
|
||||
#+end_src
|
||||
`, `<div class="src src-c">
|
||||
<pre><code class="chroma language-c"><span class="kt">int</span> <span class="n">a</span><span class="p">;</span></code></pre>
|
||||
</div>`)
|
||||
}
|
||||
|
||||
func TestRender_IncludeLink(t *testing.T) {
|
||||
testRender(t, `#+INCLUDE: "./other.org" src text`, `<div class="src src-text">
|
||||
<pre><code class="chroma language-plaintext">#+INCLUDE: [[other.org]]</code></pre>
|
||||
</div>`)
|
||||
}
|
||||
|
||||
@@ -15,12 +15,12 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitea.io/gitea/modules/htmlutil"
|
||||
"code.gitea.io/gitea/modules/markup/internal"
|
||||
"code.gitea.io/gitea/modules/public"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/typesniffer"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
"gitea.dev/modules/htmlutil"
|
||||
"gitea.dev/modules/markup/internal"
|
||||
"gitea.dev/modules/public"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/typesniffer"
|
||||
"gitea.dev/modules/util"
|
||||
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
@@ -198,15 +198,6 @@ func Render(rctx *RenderContext, origInput io.Reader, output io.Writer) error {
|
||||
return RenderWithRenderer(rctx, renderer, input, output)
|
||||
}
|
||||
|
||||
// RenderString renders Markup string to HTML with all specific handling stuff and return string
|
||||
func RenderString(ctx *RenderContext, content string) (string, error) {
|
||||
var buf strings.Builder
|
||||
if err := Render(ctx, strings.NewReader(content), &buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
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"]
|
||||
@@ -220,11 +211,11 @@ func RenderIFrame(ctx *RenderContext, opts *ExternalRendererOptions, output io.W
|
||||
ctx.RenderOptions.Metas["RefTypeNameSubURL"],
|
||||
util.PathEscapeSegments(ctx.RenderOptions.RelativePath),
|
||||
)
|
||||
var extraAttrs template.HTML
|
||||
if opts.ContentSandbox != "" {
|
||||
extraAttrs = htmlutil.HTMLFormat(` sandbox="%s"`, opts.ContentSandbox)
|
||||
}
|
||||
_, err := htmlutil.HTMLPrintf(output, `<iframe data-src="%s" data-global-init="initExternalRenderIframe" class="external-render-iframe"%s></iframe>`, src, extraAttrs)
|
||||
|
||||
// The render response should always have correct "sandbox" limits (no same-origin),
|
||||
// otherwise the "render link" direct access can still cause XSS without iframe.
|
||||
// So here we do not need to set sandbox attribute on the iframe.
|
||||
_, err := htmlutil.HTMLPrintf(output, `<iframe data-src="%s" data-global-init="initExternalRenderIframe" class="external-render-iframe"></iframe>`, src)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -252,7 +243,7 @@ func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader,
|
||||
return RenderIFrame(ctx, &extOpts, output)
|
||||
}
|
||||
// else: this is a standalone page, fallthrough to the real rendering, and add extra JS/CSS
|
||||
extraScriptSrc := public.AssetURI("js/external-render-helper.js")
|
||||
extraScriptSrc := public.AssetURI("web_src/js/external-render-helper.ts")
|
||||
extraLinkHref := ctx.RenderOptions.StandalonePageOptions.CurrentWebTheme.PublicAssetURI()
|
||||
// "<script>" must go before "<link>", to make Golang's http.DetectContentType() can still recognize the content as "text/html"
|
||||
// DO NOT use "type=module", the script must run as early as possible, to set up the environment in the iframe
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"context"
|
||||
"html/template"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/httplib"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"gitea.dev/modules/httplib"
|
||||
"gitea.dev/modules/setting"
|
||||
)
|
||||
|
||||
// resolveLinkRelative tries to resolve the link relative to the "{base}/{cur}", and returns the final link.
|
||||
|
||||
@@ -6,7 +6,7 @@ package markup
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -22,10 +22,7 @@ func TestRenderIFrame(t *testing.T) {
|
||||
WithRelativePath("tree-path").
|
||||
WithMetas(map[string]string{"user": "test-owner", "repo": "test-repo", "RefTypeNameSubURL": "src/branch/master"})
|
||||
|
||||
// the value is read from config RENDER_CONTENT_SANDBOX, empty means "disabled"
|
||||
ret := render(ctx, ExternalRendererOptions{ContentSandbox: ""})
|
||||
// iframe doesn't need sandbox, the sandbox is set in render's response header
|
||||
ret := render(ctx, ExternalRendererOptions{ContentSandbox: "any"})
|
||||
assert.Equal(t, `<iframe data-src="/test-owner/test-repo/render/src/branch/master/tree-path" data-global-init="initExternalRenderIframe" class="external-render-iframe"></iframe>`, ret)
|
||||
|
||||
ret = render(ctx, ExternalRendererOptions{ContentSandbox: "allow"})
|
||||
assert.Equal(t, `<iframe data-src="/test-owner/test-repo/render/src/branch/master/tree-path" data-global-init="initExternalRenderIframe" class="external-render-iframe" sandbox="allow"></iframe>`, ret)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/typesniffer"
|
||||
"gitea.dev/modules/setting"
|
||||
"gitea.dev/modules/typesniffer"
|
||||
)
|
||||
|
||||
// Renderer defines an interface for rendering markup file to HTML
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"gitea.dev/modules/setting"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
)
|
||||
@@ -63,6 +63,38 @@ func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy {
|
||||
|
||||
policy.AllowAttrs("loading").OnElements("img")
|
||||
|
||||
// MathML Core (https://www.w3.org/TR/mathml-core/)
|
||||
mathMLElements := []string{
|
||||
"math",
|
||||
// token elements
|
||||
"mi", "mn", "mo", "mtext", "mspace", "ms",
|
||||
// layout elements
|
||||
"mrow", "mfrac", "msqrt", "mroot", "mstyle", "merror", "mpadded", "mphantom",
|
||||
// scripting elements
|
||||
"msub", "msup", "msubsup", "munder", "mover", "munderover", "mmultiscripts", "mprescripts", "none",
|
||||
// tabular elements
|
||||
"mtable", "mtr", "mtd",
|
||||
// semantic annotations
|
||||
"semantics", "annotation", "annotation-xml",
|
||||
}
|
||||
policy.AllowAttrs("display", "alttext").OnElements("math")
|
||||
policy.AllowAttrs(
|
||||
// global presentation attributes
|
||||
"dir", "displaystyle", "mathbackground", "mathcolor", "mathsize", "mathvariant", "scriptlevel",
|
||||
// operator attributes
|
||||
"accent", "accentunder", "fence", "form", "largeop", "lspace", "maxsize", "minsize", "movablelimits", "rspace", "separator", "stretchy", "symmetric",
|
||||
// space and padding attributes
|
||||
"depth", "height", "voffset", "width",
|
||||
// fraction attribute
|
||||
"linethickness",
|
||||
// table attributes
|
||||
"columnalign", "columnlines", "columnspacing", "frame", "framespacing", "rowalign", "rowlines", "rowspacing",
|
||||
// cell attributes
|
||||
"columnspan",
|
||||
// annotation attribute
|
||||
"encoding",
|
||||
).OnElements(mathMLElements...)
|
||||
|
||||
// Allow generally safe attributes (reference: https://github.com/jch/html-pipeline)
|
||||
generalSafeAttrs := []string{
|
||||
"abbr", "accept", "accept-charset",
|
||||
|
||||
@@ -61,6 +61,9 @@ func TestSanitizer(t *testing.T) {
|
||||
// picture
|
||||
`<picture><source media="a"><source media="b"><img alt="c" src="d"></picture>`, `<picture><source media="a"><source media="b"><img alt="c" src="d"></picture>`,
|
||||
|
||||
// MathML
|
||||
`<math display="display" class="foo"><mi mathcolor="c" class="bar"></mi></math>`, `<math display="display"><mi mathcolor="c"></mi></math>`,
|
||||
|
||||
// Disallow dangerous url schemes
|
||||
`<a href="javascript:alert('xss')">bad</a>`, `bad`,
|
||||
`<a href="vbscript:no">bad</a>`, `bad`,
|
||||
@@ -72,6 +75,6 @@ func TestSanitizer(t *testing.T) {
|
||||
}
|
||||
|
||||
for i := 0; i < len(testCases); i += 2 {
|
||||
assert.Equal(t, testCases[i+1], string(Sanitize(testCases[i])))
|
||||
assert.Equal(t, testCases[i+1], string(Sanitize(testCases[i])), "input: %s", testCases[i])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user