mirror of
https://github.com/go-gitea/gitea.git
synced 2026-05-25 16:08:46 +09:00
Package-page markdown (READMEs, descriptions, release notes) was rendered as a plain document, so relative links and images resolved against the site root and 404'd. This renders it in the context of the package's linked repository instead, falling back to plain rendering when the package has no linked repo. For a README link `[usage](docs/usage.md)` in a package linked to `user/repo` (default branch `main`): | | Resolved link | |---|---| | Before | `/docs/usage.md` | | After | `/user/repo/src/branch/main/docs/usage.md` | For an npm monorepo package with `repository.directory: packages/foo`, an image `` resolves to `/user/repo/src/branch/main/packages/foo/logo.png`. Applied to every package content template that renders markdown: `cargo`, `chef`, `composer`, `npm`, `nuget`, `pub`, `pypi`. Links resolve against the repository default branch (metadata records no publish commit). Only the web package detail page is affected; registry API responses are unchanged. Note: as part of restructuring `npm.tmpl`, the package description and README now render as separate sections instead of the README replacing the description, matching the existing `cargo`/`composer`/`pub` layout. Signed-off-by: wxiaoguang <wxiaoguang@gmail.com> Signed-off-by: silverwind <me@silverwind.io> Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
328 lines
13 KiB
Go
328 lines
13 KiB
Go
// Copyright 2023 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package templates
|
|
|
|
import (
|
|
"encoding/hex"
|
|
"fmt"
|
|
"html/template"
|
|
"math"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"unicode"
|
|
|
|
issues_model "code.gitea.io/gitea/models/issues"
|
|
"code.gitea.io/gitea/models/renderhelper"
|
|
"code.gitea.io/gitea/models/repo"
|
|
"code.gitea.io/gitea/modules/charset"
|
|
"code.gitea.io/gitea/modules/emoji"
|
|
"code.gitea.io/gitea/modules/git"
|
|
"code.gitea.io/gitea/modules/htmlutil"
|
|
"code.gitea.io/gitea/modules/log"
|
|
"code.gitea.io/gitea/modules/markup"
|
|
"code.gitea.io/gitea/modules/markup/markdown"
|
|
"code.gitea.io/gitea/modules/reqctx"
|
|
"code.gitea.io/gitea/modules/setting"
|
|
"code.gitea.io/gitea/modules/svg"
|
|
"code.gitea.io/gitea/modules/translation"
|
|
"code.gitea.io/gitea/modules/util"
|
|
"code.gitea.io/gitea/services/webtheme"
|
|
)
|
|
|
|
type RenderUtils struct {
|
|
ctx reqctx.RequestContext
|
|
}
|
|
|
|
func NewRenderUtils(ctx reqctx.RequestContext) *RenderUtils {
|
|
return &RenderUtils{ctx: ctx}
|
|
}
|
|
|
|
// RenderCommitMessage renders commit message with XSS-safe and special links.
|
|
func (ut *RenderUtils) RenderCommitMessage(msg string, repo *repo.Repository) template.HTML {
|
|
cleanMsg := template.HTML(template.HTMLEscapeString(msg))
|
|
// we can safely assume that it will not return any error, since there shouldn't be any special HTML.
|
|
// "repo" can be nil when rendering commit messages for deleted repositories in a user's dashboard feed.
|
|
fullMessage, err := markup.PostProcessCommitMessage(renderhelper.NewRenderContextRepoComment(ut.ctx, repo), cleanMsg)
|
|
if err != nil {
|
|
log.Error("PostProcessCommitMessage: %v", err)
|
|
return ""
|
|
}
|
|
msgLines := strings.Split(strings.TrimSpace(string(fullMessage)), "\n")
|
|
if len(msgLines) == 0 {
|
|
return ""
|
|
}
|
|
return renderCodeBlock(template.HTML(msgLines[0]))
|
|
}
|
|
|
|
// RenderCommitMessageLinkSubject renders commit message as a XSS-safe link to
|
|
// the provided default url, handling for special links without email to links.
|
|
func (ut *RenderUtils) RenderCommitMessageLinkSubject(msg, urlDefault string, repo *repo.Repository) template.HTML {
|
|
msgLine := strings.TrimLeftFunc(msg, unicode.IsSpace)
|
|
lineEnd := strings.IndexByte(msgLine, '\n')
|
|
if lineEnd > 0 {
|
|
msgLine = msgLine[:lineEnd]
|
|
}
|
|
msgLine = strings.TrimRightFunc(msgLine, unicode.IsSpace)
|
|
if len(msgLine) == 0 {
|
|
return ""
|
|
}
|
|
|
|
// we can safely assume that it will not return any error, since there shouldn't be any special HTML.
|
|
renderedMessage, err := markup.PostProcessCommitMessageSubject(renderhelper.NewRenderContextRepoComment(ut.ctx, repo), urlDefault, template.HTMLEscapeString(msgLine))
|
|
if err != nil {
|
|
log.Error("PostProcessCommitMessageSubject: %v", err)
|
|
return ""
|
|
}
|
|
return renderCodeBlock(template.HTML(renderedMessage))
|
|
}
|
|
|
|
// RenderCommitBody extracts the body of a commit message without its title.
|
|
func (ut *RenderUtils) RenderCommitBody(msg string, repo *repo.Repository) template.HTML {
|
|
_, body, _ := strings.Cut(strings.TrimSpace(msg), "\n")
|
|
body = strings.TrimFunc(body, unicode.IsSpace)
|
|
if body == "" {
|
|
return ""
|
|
}
|
|
|
|
rctx := renderhelper.NewRenderContextRepoComment(ut.ctx, repo)
|
|
htmlContent := template.HTML(template.HTMLEscapeString(body))
|
|
renderedMessage, err := markup.PostProcessCommitMessage(rctx, htmlContent)
|
|
if err != nil {
|
|
log.Error("PostProcessCommitMessage: %v", err)
|
|
return ""
|
|
}
|
|
return renderedMessage
|
|
}
|
|
|
|
// Match text that is between back ticks.
|
|
var codeMatcher = regexp.MustCompile("`([^`]+)`")
|
|
|
|
// renderCodeBlock renders "`…`" as highlighted "<code>" block, intended for issue and PR titles
|
|
func renderCodeBlock(htmlEscapedTextToRender template.HTML) template.HTML {
|
|
htmlWithCodeTags := codeMatcher.ReplaceAllString(string(htmlEscapedTextToRender), `<code class="inline-code-block">$1</code>`) // replace with HTML <code> tags
|
|
return template.HTML(htmlWithCodeTags)
|
|
}
|
|
|
|
// RenderIssueTitle renders issue/pull title with defined post processors
|
|
func (ut *RenderUtils) RenderIssueTitle(text string, repo *repo.Repository) template.HTML {
|
|
renderedText, err := markup.PostProcessIssueTitle(renderhelper.NewRenderContextRepoComment(ut.ctx, repo), template.HTMLEscapeString(text))
|
|
if err != nil {
|
|
log.Error("PostProcessIssueTitle: %v", err)
|
|
return ""
|
|
}
|
|
return renderCodeBlock(template.HTML(renderedText))
|
|
}
|
|
|
|
// RenderIssueSimpleTitle only renders with emoji and inline code block
|
|
func (ut *RenderUtils) RenderIssueSimpleTitle(text string) template.HTML {
|
|
ret := ut.RenderEmoji(text)
|
|
ret = renderCodeBlock(ret)
|
|
return ret
|
|
}
|
|
|
|
func (ut *RenderUtils) RenderLabel(label *issues_model.Label) template.HTML {
|
|
locale := ut.ctx.Value(translation.ContextKey).(translation.Locale)
|
|
var extraCSSClasses string
|
|
textColor := util.ContrastColor(label.Color)
|
|
labelScope := label.ExclusiveScope()
|
|
descriptionText := emoji.ReplaceAliases(label.Description)
|
|
|
|
if label.IsArchived() {
|
|
extraCSSClasses = "archived-label"
|
|
descriptionText = fmt.Sprintf("(%s) %s", locale.TrString("archived"), descriptionText)
|
|
}
|
|
|
|
if labelScope == "" {
|
|
// Regular label
|
|
return htmlutil.HTMLFormat(`<span class="ui label %s" style="color: %s !important; background-color: %s !important;" data-tooltip-content title="%s"><span class="gt-ellipsis">%s</span></span>`,
|
|
extraCSSClasses, textColor, label.Color, descriptionText, ut.RenderEmoji(label.Name))
|
|
}
|
|
|
|
// Scoped label
|
|
scopeHTML := ut.RenderEmoji(labelScope)
|
|
itemHTML := ut.RenderEmoji(label.Name[len(labelScope)+1:])
|
|
|
|
// Make scope and item background colors slightly darker and lighter respectively.
|
|
// More contrast needed with higher luminance, empirically tweaked.
|
|
luminance := util.GetRelativeLuminance(label.Color)
|
|
contrast := 0.01 + luminance*0.03
|
|
// Ensure we add the same amount of contrast also near 0 and 1.
|
|
darken := contrast + math.Max(luminance+contrast-1.0, 0.0)
|
|
lighten := contrast + math.Max(contrast-luminance, 0.0)
|
|
// Compute the factor to keep RGB values proportional.
|
|
darkenFactor := math.Max(luminance-darken, 0.0) / math.Max(luminance, 1.0/255.0)
|
|
lightenFactor := math.Min(luminance+lighten, 1.0) / math.Max(luminance, 1.0/255.0)
|
|
|
|
r, g, b := util.HexToRBGColor(label.Color)
|
|
scopeBytes := []byte{
|
|
uint8(math.Min(math.Round(r*darkenFactor), 255)),
|
|
uint8(math.Min(math.Round(g*darkenFactor), 255)),
|
|
uint8(math.Min(math.Round(b*darkenFactor), 255)),
|
|
}
|
|
itemBytes := []byte{
|
|
uint8(math.Min(math.Round(r*lightenFactor), 255)),
|
|
uint8(math.Min(math.Round(g*lightenFactor), 255)),
|
|
uint8(math.Min(math.Round(b*lightenFactor), 255)),
|
|
}
|
|
|
|
itemColor := "#" + hex.EncodeToString(itemBytes)
|
|
scopeColor := "#" + hex.EncodeToString(scopeBytes)
|
|
|
|
if label.ExclusiveOrder > 0 {
|
|
// <scope> | <label> | <order>
|
|
return htmlutil.HTMLFormat(`<span class="ui label %s scope-parent" data-tooltip-content title="%s">`+
|
|
`<div class="ui label scope-left" style="color: %s !important; background-color: %s !important">%s</div>`+
|
|
`<div class="ui label scope-middle" style="color: %s !important; background-color: %s !important">%s</div>`+
|
|
`<div class="ui label scope-right">%d</div>`+
|
|
`</span>`,
|
|
extraCSSClasses, descriptionText,
|
|
textColor, scopeColor, scopeHTML,
|
|
textColor, itemColor, itemHTML,
|
|
label.ExclusiveOrder)
|
|
}
|
|
|
|
// <scope> | <label>
|
|
return htmlutil.HTMLFormat(`<span class="ui label %s scope-parent" data-tooltip-content title="%s">`+
|
|
`<div class="ui label scope-left" style="color: %s !important; background-color: %s !important">%s</div>`+
|
|
`<div class="ui label scope-right" style="color: %s !important; background-color: %s !important">%s</div>`+
|
|
`</span>`,
|
|
extraCSSClasses, descriptionText,
|
|
textColor, scopeColor, scopeHTML,
|
|
textColor, itemColor, itemHTML)
|
|
}
|
|
|
|
// RenderEmoji renders html text with emoji post processors
|
|
func (ut *RenderUtils) RenderEmoji(text string) template.HTML {
|
|
renderedText, err := markup.PostProcessEmoji(markup.NewRenderContext(ut.ctx), template.HTMLEscapeString(text))
|
|
if err != nil {
|
|
log.Error("RenderEmoji: %v", err)
|
|
return ""
|
|
}
|
|
return template.HTML(renderedText)
|
|
}
|
|
|
|
// reactionToEmoji renders emoji for use in reactions
|
|
func reactionToEmoji(reaction string) template.HTML {
|
|
val := emoji.FromCode(reaction)
|
|
if val != nil {
|
|
return template.HTML(val.Emoji)
|
|
}
|
|
val = emoji.FromAlias(reaction)
|
|
if val != nil {
|
|
return template.HTML(val.Emoji)
|
|
}
|
|
return template.HTML(fmt.Sprintf(`<img alt=":%s:" src="%s/assets/img/emoji/%s.png"></img>`, reaction, setting.StaticURLPrefix, url.PathEscape(reaction)))
|
|
}
|
|
|
|
func (ut *RenderUtils) MarkdownToHtml(input string) template.HTML { //nolint:revive // variable naming triggers on Html, wants HTML
|
|
output, err := markdown.RenderString(markup.NewRenderContext(ut.ctx).WithMetas(markup.ComposeSimpleDocumentMetas()), input)
|
|
if err != nil {
|
|
log.Error("RenderString: %v", err)
|
|
}
|
|
return output
|
|
}
|
|
|
|
// RenderPackageMarkdown renders package page Markdown so relative links resolve against the
|
|
// linked repository's default branch instead of the site root, falling back to plain rendering
|
|
// when there is no linked repository. pkgTreePath optionally roots links in a subdirectory
|
|
// (e.g. npm's repository.directory for monorepo packages).
|
|
func (ut *RenderUtils) RenderPackageMarkdown(input string, linkedRepo *repo.Repository, pkgTreePath ...string) template.HTML {
|
|
if linkedRepo == nil {
|
|
return `<div class="markup markdown">` + ut.MarkdownToHtml(input) + `</div>`
|
|
}
|
|
rctx := renderhelper.NewRenderContextRepoFile(ut.ctx, linkedRepo, renderhelper.RepoFileOptions{
|
|
CurrentRefSubURL: git.RefNameFromBranch(linkedRepo.DefaultBranch).RefWebLinkPath(),
|
|
CurrentTreePath: util.OptionalArg(pkgTreePath),
|
|
})
|
|
output, err := markdown.RenderString(rctx, input)
|
|
if err != nil {
|
|
log.Error("RenderString: %v", err)
|
|
}
|
|
return `<div class="markup markdown">` + output + `</div>`
|
|
}
|
|
|
|
func (ut *RenderUtils) RenderLabels(labels []*issues_model.Label, repoLink string, issue *issues_model.Issue) template.HTML {
|
|
isPullRequest := issue != nil && issue.IsPull
|
|
baseLink := fmt.Sprintf("%s/%s", repoLink, util.Iif(isPullRequest, "pulls", "issues"))
|
|
var htmlCode htmlutil.HTMLBuilder
|
|
htmlCode.WriteHTML(`<span class="labels-list">`)
|
|
for _, label := range labels {
|
|
// Protect against nil value in labels - shouldn't happen but would cause a panic if so
|
|
if label == nil {
|
|
continue
|
|
}
|
|
htmlCode.WriteFormat(`<a class="item" href="%s?labels=%d">`, baseLink, label.ID)
|
|
htmlCode.WriteHTML(ut.RenderLabel(label))
|
|
htmlCode.WriteHTML("</a>")
|
|
}
|
|
htmlCode.WriteHTML("</span>")
|
|
return htmlCode.HTMLString()
|
|
}
|
|
|
|
func (ut *RenderUtils) RenderThemeItem(info *webtheme.ThemeMetaInfo, iconSize int) template.HTML {
|
|
svgName := "octicon-paintbrush"
|
|
switch info.ColorScheme {
|
|
case "dark":
|
|
svgName = "octicon-moon"
|
|
case "light":
|
|
svgName = "octicon-sun"
|
|
case "auto":
|
|
svgName = "gitea-eclipse"
|
|
}
|
|
icon := svg.RenderHTML(svgName, iconSize)
|
|
extraIcon := svg.RenderHTML(info.GetExtraIconName(), iconSize)
|
|
return htmlutil.HTMLFormat(`<div class="theme-menu-item" data-tooltip-content="%s">%s %s %s</div>`, info.GetDescription(), icon, info.DisplayName, extraIcon)
|
|
}
|
|
|
|
func (ut *RenderUtils) RenderFlashMessage(typ, msg string) template.HTML {
|
|
msg = strings.TrimSpace(msg)
|
|
if msg == "" {
|
|
return ""
|
|
}
|
|
|
|
cls := typ
|
|
// legacy logic: "negative" for error, "positive" for success
|
|
switch cls {
|
|
case "error":
|
|
cls = "negative"
|
|
case "success":
|
|
cls = "positive"
|
|
}
|
|
|
|
var msgContent template.HTML
|
|
if strings.Contains(msg, "</pre>") || strings.Contains(msg, "</details>") || strings.Contains(msg, "</ul>") || strings.Contains(msg, "</div>") {
|
|
// If the message contains some known "block" elements, no need to do more alignment or line-break processing, just sanitize it directly.
|
|
msgContent = sanitizeHTML(msg)
|
|
} else if !strings.Contains(msg, "\n") {
|
|
// If the message is a single line, center-align it by wrapping it
|
|
msgContent = htmlutil.HTMLFormat(`<div class="tw-text-center">%s</div>`, sanitizeHTML(msg))
|
|
} else {
|
|
// For a multi-line message, preserve line breaks, and left-align it.
|
|
msgContent = htmlutil.HTMLFormat(`%s`, sanitizeHTML(strings.ReplaceAll(msg, "\n", "<br>")))
|
|
}
|
|
return htmlutil.HTMLFormat(`<div class="ui %s message flash-message flash-%s">%s</div>`, cls, typ, msgContent)
|
|
}
|
|
|
|
func (ut *RenderUtils) RenderUnicodeEscapeToggleButton(escapeStatus *charset.EscapeStatus) template.HTML {
|
|
if escapeStatus == nil || !escapeStatus.Escaped {
|
|
return ""
|
|
}
|
|
locale := ut.ctx.Value(translation.ContextKey).(translation.Locale)
|
|
var title template.HTML
|
|
if escapeStatus.HasAmbiguous {
|
|
title += locale.Tr("repo.ambiguous_runes_line")
|
|
} else if escapeStatus.HasInvisible {
|
|
title += locale.Tr("repo.invisible_runes_line")
|
|
}
|
|
return htmlutil.HTMLFormat(`<button type="button" class="toggle-escape-button btn interact-bg" title="%s"></button>`, title)
|
|
}
|
|
|
|
func (ut *RenderUtils) RenderUnicodeEscapeToggleTd(combined, escapeStatus *charset.EscapeStatus) template.HTML {
|
|
if combined == nil || !combined.Escaped {
|
|
return ""
|
|
}
|
|
return `<td class="lines-escape">` + ut.RenderUnicodeEscapeToggleButton(escapeStatus) + `</td>`
|
|
}
|