From 6f4027a6be28c876c0abaf37cc939658645b78a3 Mon Sep 17 00:00:00 2001
From: Thomas Hallock
Date: Sun, 24 May 2026 04:13:49 -0500
Subject: [PATCH] fix(packages): render markdown links relative to linked repo
(#37676)
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
Signed-off-by: silverwind
Co-authored-by: silverwind
Co-authored-by: Claude (Opus 4.7)
Co-authored-by: wxiaoguang
---
modules/packages/npm/creator.go | 7 +++--
modules/packages/npm/creator_test.go | 6 ++--
modules/templates/util_render.go | 20 ++++++++++++++
modules/templates/util_render_test.go | 32 ++++++++++++++++++++++
templates/package/content/cargo.tmpl | 2 +-
templates/package/content/chef.tmpl | 2 +-
templates/package/content/composer.tmpl | 2 +-
templates/package/content/npm.tmpl | 11 ++------
templates/package/content/nuget.tmpl | 6 ++--
templates/package/content/pub.tmpl | 2 +-
templates/package/content/pypi.tmpl | 4 +--
tests/integration/api_packages_npm_test.go | 29 +++++++++++++++++++-
12 files changed, 99 insertions(+), 24 deletions(-)
diff --git a/modules/packages/npm/creator.go b/modules/packages/npm/creator.go
index cc7695726bb..c49e1267a7a 100644
--- a/modules/packages/npm/creator.go
+++ b/modules/packages/npm/creator.go
@@ -181,10 +181,11 @@ func (u *User) UnmarshalJSON(data []byte) error {
return nil
}
-// Repository https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md#version
+// Repository https://docs.npmjs.com/cli/v11/configuring-npm/package-json#repository
type Repository struct {
- Type string `json:"type"`
- URL string `json:"url"`
+ Type string `json:"type"`
+ URL string `json:"url"`
+ Directory string `json:"directory,omitempty"`
}
// PackageAttachment https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md#package
diff --git a/modules/packages/npm/creator_test.go b/modules/packages/npm/creator_test.go
index 40c50de91f9..7474d6c9f4b 100644
--- a/modules/packages/npm/creator_test.go
+++ b/modules/packages/npm/creator_test.go
@@ -28,8 +28,9 @@ func TestParsePackage(t *testing.T) {
data := "H4sIAAAAAAAA/ytITM5OTE/VL4DQelnF+XkMVAYGBgZmJiYK2MRBwNDcSIHB2NTMwNDQzMwAqA7IMDUxA9LUdgg2UFpcklgEdAql5kD8ogCnhwio5lJQUMpLzE1VslJQcihOzi9I1S9JLS7RhSYIJR2QgrLUouLM/DyQGkM9Az1D3YIiqExKanFyUWZBCVQ2BKhVwQVJDKwosbQkI78IJO/tZ+LsbRykxFXLNdA+HwWjYBSMgpENACgAbtAACAAA"
integrity := "sha512-yA4FJsVhetynGfOC1jFf79BuS+jrHbm0fhh+aHzCQkOaOBXKf9oBnC4a6DnLLnEsHQDRLYd00cwj8sCXpC+wIg=="
repository := Repository{
- Type: "gitea",
- URL: "http://localhost:3000/gitea/test.git",
+ Type: "gitea",
+ URL: "http://localhost:3000/gitea/test.git",
+ Directory: "packages/test-package",
}
t.Run("InvalidUpload", func(t *testing.T) {
@@ -298,6 +299,7 @@ func TestParsePackage(t *testing.T) {
assert.Equal(t, "1.2.0", p.Metadata.Dependencies["package"])
assert.Equal(t, repository.Type, p.Metadata.Repository.Type)
assert.Equal(t, repository.URL, p.Metadata.Repository.URL)
+ assert.Equal(t, repository.Directory, p.Metadata.Repository.Directory)
})
t.Run("ValidLicenseMap", func(t *testing.T) {
diff --git a/modules/templates/util_render.go b/modules/templates/util_render.go
index 9d05bb2a0b6..b78196bd5c9 100644
--- a/modules/templates/util_render.go
+++ b/modules/templates/util_render.go
@@ -18,6 +18,7 @@ import (
"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"
@@ -223,6 +224,25 @@ func (ut *RenderUtils) MarkdownToHtml(input string) template.HTML { //nolint:rev
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 `` + ut.MarkdownToHtml(input) + `
`
+ }
+ 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 `` + output + `
`
+}
+
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"))
diff --git a/modules/templates/util_render_test.go b/modules/templates/util_render_test.go
index de7768e91c3..61dcb4937f1 100644
--- a/modules/templates/util_render_test.go
+++ b/modules/templates/util_render_test.go
@@ -194,6 +194,38 @@ space
assert.Equal(t, expected, string(newTestRenderUtils(t).MarkdownToHtml(testInput())))
}
+func TestRenderPackageMarkdown(t *testing.T) {
+ defer test.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
+ mockRepo := &repo.Repository{
+ ID: 1, OwnerName: "user13", Name: "repo11", DefaultBranch: "main",
+ Owner: &user_model.User{ID: 13, Name: "user13"},
+ Units: []*repo.RepoUnit{},
+ }
+ ut := newTestRenderUtils(t)
+
+ t.Run("LinkedRepoWithDirectory", func(t *testing.T) {
+ rendered := ut.RenderPackageMarkdown("[docs](docs/getting-started.md)\n", mockRepo, "pkg-subdir")
+ expected := ``
+ assert.Equal(t, expected, strings.TrimSpace(string(rendered)))
+ })
+
+ t.Run("LinkedRepoWithEmptyDirectory", func(t *testing.T) {
+ rendered := ut.RenderPackageMarkdown("[docs](docs/getting-started.md)", mockRepo, "")
+ expected := ``
+ assert.Equal(t, expected, strings.TrimSpace(string(rendered)))
+ })
+
+ t.Run("UnlinkedRepo", func(t *testing.T) {
+ rendered := ut.RenderPackageMarkdown("[docs](docs/getting-started.md)", nil, "pkg-subdir")
+ expected := ``
+ assert.Equal(t, expected, strings.TrimSpace(string(rendered)))
+ })
+}
+
func TestRenderLabels(t *testing.T) {
ut := newTestRenderUtils(t)
label := &issues.Label{ID: 123, Name: "label-name", Color: "label-color"}
diff --git a/templates/package/content/cargo.tmpl b/templates/package/content/cargo.tmpl
index ebb23e86597..5c04ab8aae9 100644
--- a/templates/package/content/cargo.tmpl
+++ b/templates/package/content/cargo.tmpl
@@ -27,7 +27,7 @@ git-fetch-with-cli = true
{{if or .PackageDescriptor.Metadata.Description .PackageDescriptor.Metadata.Readme}}
{{if .PackageDescriptor.Metadata.Description}}{{.PackageDescriptor.Metadata.Description}}
{{end}}
- {{if .PackageDescriptor.Metadata.Readme}}{{ctx.RenderUtils.MarkdownToHtml .PackageDescriptor.Metadata.Readme}}
{{end}}
+ {{if .PackageDescriptor.Metadata.Readme}}{{ctx.RenderUtils.RenderPackageMarkdown .PackageDescriptor.Metadata.Readme .PackageDescriptor.Repository}}
{{end}}
{{end}}
{{if .PackageDescriptor.Metadata.Dependencies}}
diff --git a/templates/package/content/chef.tmpl b/templates/package/content/chef.tmpl
index d1c17d36a49..0912aff792c 100644
--- a/templates/package/content/chef.tmpl
+++ b/templates/package/content/chef.tmpl
@@ -20,7 +20,7 @@
{{if .PackageDescriptor.Metadata.Description}}
{{.PackageDescriptor.Metadata.Description}}
{{end}}
- {{if .PackageDescriptor.Metadata.LongDescription}}{{ctx.RenderUtils.MarkdownToHtml .PackageDescriptor.Metadata.LongDescription}}{{end}}
+ {{if .PackageDescriptor.Metadata.LongDescription}}{{ctx.RenderUtils.RenderPackageMarkdown .PackageDescriptor.Metadata.LongDescription .PackageDescriptor.Repository}}{{end}}
{{end}}
diff --git a/templates/package/content/composer.tmpl b/templates/package/content/composer.tmpl
index 2e8cfb77ebc..5bbc19e9f25 100644
--- a/templates/package/content/composer.tmpl
+++ b/templates/package/content/composer.tmpl
@@ -25,7 +25,7 @@
{{if or .PackageDescriptor.Metadata.Description .PackageDescriptor.Metadata.Comments}}
{{if .PackageDescriptor.Metadata.Description}}{{.PackageDescriptor.Metadata.Description}}
{{end}}
- {{if .PackageDescriptor.Metadata.Readme}}{{ctx.RenderUtils.MarkdownToHtml .PackageDescriptor.Metadata.Readme}}
{{end}}
+ {{if .PackageDescriptor.Metadata.Readme}}{{ctx.RenderUtils.RenderPackageMarkdown .PackageDescriptor.Metadata.Readme .PackageDescriptor.Repository}}
{{end}}
{{if .PackageDescriptor.Metadata.Comments}}{{StringUtils.Join .PackageDescriptor.Metadata.Comments " "}}
{{end}}
{{end}}
diff --git a/templates/package/content/npm.tmpl b/templates/package/content/npm.tmpl
index 28bcf45ee87..89f4c940081 100644
--- a/templates/package/content/npm.tmpl
+++ b/templates/package/content/npm.tmpl
@@ -22,15 +22,8 @@
{{if or .PackageDescriptor.Metadata.Description .PackageDescriptor.Metadata.Readme}}
-
- {{if .PackageDescriptor.Metadata.Readme}}
-
- {{ctx.RenderUtils.MarkdownToHtml .PackageDescriptor.Metadata.Readme}}
-
- {{else if .PackageDescriptor.Metadata.Description}}
- {{.PackageDescriptor.Metadata.Description}}
- {{end}}
-
+ {{if .PackageDescriptor.Metadata.Description}}{{.PackageDescriptor.Metadata.Description}}
{{end}}
+ {{if .PackageDescriptor.Metadata.Readme}}{{ctx.RenderUtils.RenderPackageMarkdown .PackageDescriptor.Metadata.Readme .PackageDescriptor.Repository .PackageDescriptor.Metadata.Repository.Directory}}
{{end}}
{{end}}
{{if or .PackageDescriptor.Metadata.Dependencies .PackageDescriptor.Metadata.DevelopmentDependencies .PackageDescriptor.Metadata.PeerDependencies .PackageDescriptor.Metadata.OptionalDependencies}}
diff --git a/templates/package/content/nuget.tmpl b/templates/package/content/nuget.tmpl
index 7f874044cc1..fc9a715a28e 100644
--- a/templates/package/content/nuget.tmpl
+++ b/templates/package/content/nuget.tmpl
@@ -18,9 +18,9 @@
{{if or .PackageDescriptor.Metadata.Description .PackageDescriptor.Metadata.ReleaseNotes .PackageDescriptor.Metadata.Readme}}
- {{if .PackageDescriptor.Metadata.Description}}{{ctx.RenderUtils.MarkdownToHtml .PackageDescriptor.Metadata.Description}}
{{end}}
- {{if .PackageDescriptor.Metadata.Readme}}{{ctx.RenderUtils.MarkdownToHtml .PackageDescriptor.Metadata.Readme}}
{{end}}
- {{if .PackageDescriptor.Metadata.ReleaseNotes}}{{ctx.RenderUtils.MarkdownToHtml .PackageDescriptor.Metadata.ReleaseNotes}}
{{end}}
+ {{if .PackageDescriptor.Metadata.Description}}{{ctx.RenderUtils.RenderPackageMarkdown .PackageDescriptor.Metadata.Description .PackageDescriptor.Repository}}
{{end}}
+ {{if .PackageDescriptor.Metadata.Readme}}{{ctx.RenderUtils.RenderPackageMarkdown .PackageDescriptor.Metadata.Readme .PackageDescriptor.Repository}}
{{end}}
+ {{if .PackageDescriptor.Metadata.ReleaseNotes}}{{ctx.RenderUtils.RenderPackageMarkdown .PackageDescriptor.Metadata.ReleaseNotes .PackageDescriptor.Repository}}
{{end}}
{{end}}
{{if .PackageDescriptor.Metadata.Dependencies}}
diff --git a/templates/package/content/pub.tmpl b/templates/package/content/pub.tmpl
index 9eefcf71625..5dafa2db47e 100644
--- a/templates/package/content/pub.tmpl
+++ b/templates/package/content/pub.tmpl
@@ -14,6 +14,6 @@
{{if or .PackageDescriptor.Metadata.Description .PackageDescriptor.Metadata.Readme}}
{{if .PackageDescriptor.Metadata.Description}}{{.PackageDescriptor.Metadata.Description}}
{{end}}
- {{if .PackageDescriptor.Metadata.Readme}}{{ctx.RenderUtils.MarkdownToHtml .PackageDescriptor.Metadata.Readme}}
{{end}}
+ {{if .PackageDescriptor.Metadata.Readme}}{{ctx.RenderUtils.RenderPackageMarkdown .PackageDescriptor.Metadata.Readme .PackageDescriptor.Repository}}
{{end}}
{{end}}
{{end}}
diff --git a/templates/package/content/pypi.tmpl b/templates/package/content/pypi.tmpl
index 4afdc1b72a7..c570e60e6bf 100644
--- a/templates/package/content/pypi.tmpl
+++ b/templates/package/content/pypi.tmpl
@@ -16,9 +16,9 @@
{{if .PackageDescriptor.Metadata.Summary}}{{.PackageDescriptor.Metadata.Summary}}{{end}}
{{if .PackageDescriptor.Metadata.LongDescription}}
- {{ctx.RenderUtils.MarkdownToHtml .PackageDescriptor.Metadata.LongDescription}}
+ {{ctx.RenderUtils.RenderPackageMarkdown .PackageDescriptor.Metadata.LongDescription .PackageDescriptor.Repository}}
{{else if .PackageDescriptor.Metadata.Description}}
- {{ctx.RenderUtils.MarkdownToHtml .PackageDescriptor.Metadata.Description}}
+ {{ctx.RenderUtils.RenderPackageMarkdown .PackageDescriptor.Metadata.Description .PackageDescriptor.Repository}}
{{end}}
{{end}}
diff --git a/tests/integration/api_packages_npm_test.go b/tests/integration/api_packages_npm_test.go
index 92f9b61081b..8fb892cc866 100644
--- a/tests/integration/api_packages_npm_test.go
+++ b/tests/integration/api_packages_npm_test.go
@@ -13,6 +13,7 @@ import (
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/packages"
+ repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/packages/npm"
@@ -20,6 +21,7 @@ import (
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestPackageNpm(t *testing.T) {
@@ -39,6 +41,7 @@ func TestPackageNpm(t *testing.T) {
packageBinPath := "./cli.sh"
repoType := "gitea"
repoURL := "http://localhost:3000/gitea/test.git"
+ repoDirectory := "package-subdir"
data := "H4sIAAAAAAAA/ytITM5OTE/VL4DQelnF+XkMVAYGBgZmJiYK2MRBwNDcSIHB2NTMwNDQzMwAqA7IMDUxA9LUdgg2UFpcklgEdAql5kD8ogCnhwio5lJQUMpLzE1VslJQcihOzi9I1S9JLS7RhSYIJR2QgrLUouLM/DyQGkM9Az1D3YIiqExKanFyUWZBCVQ2BKhVwQVJDKwosbQkI78IJO/tZ+LsbRykxFXLNdA+HwWjYBSMgpENACgAbtAACAAA"
@@ -67,8 +70,10 @@ func TestPackageNpm(t *testing.T) {
},
"repository": {
"type": "` + repoType + `",
- "url": "` + repoURL + `"
+ "url": "` + repoURL + `",
+ "directory": "` + repoDirectory + `"
},
+ "readme": "[docs](docs/usage.md)\n",
"peerDependencies": {
"tea": "2.x",
"soy-milk": "1.2"
@@ -282,6 +287,28 @@ func TestPackageNpm(t *testing.T) {
}
})
+ t.Run("WebViewReadmeRepoLinks", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ pvs, err := packages.GetVersionsByPackageType(t.Context(), user.ID, packages.TypeNpm)
+ assert.NoError(t, err)
+ require.Len(t, pvs, 1)
+
+ // link the package to a repository so README relative links resolve against
+ // repository files instead of the site root
+ repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
+ assert.NoError(t, packages.SetRepositoryLink(t.Context(), pvs[0].PackageID, repo.ID))
+
+ req := NewRequest(t, "GET", fmt.Sprintf("/%s/-/packages/npm/%s/%s", user.Name, url.PathEscape(packageName), packageVersion)).
+ AddBasicAuth(user.Name)
+ resp := MakeRequest(t, req, http.StatusOK)
+ doc := NewHTMLParser(t, resp.Body)
+ rendered, _ := doc.Find(".markup.markdown").Html()
+ assert.Equal(t, `docs
+
+`, rendered)
+ })
+
t.Run("Delete", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()