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 `![logo](logo.png)` 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>
This commit is contained in:
Thomas Hallock
2026-05-24 04:13:49 -05:00
committed by GitHub
parent 748d4a8040
commit 6f4027a6be
12 changed files with 99 additions and 24 deletions

View File

@@ -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

View File

@@ -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) {

View File

@@ -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 `<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"))

View File

@@ -194,6 +194,38 @@ space</p>
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![logo](logo.png)", mockRepo, "pkg-subdir")
expected := `<div class="markup markdown"><p><a href="/user13/repo11/src/branch/main/pkg-subdir/docs/getting-started.md" rel="nofollow">docs</a>
<a href="/user13/repo11/src/branch/main/pkg-subdir/logo.png" target="_blank" rel="nofollow noopener"><img src="/user13/repo11/media/branch/main/pkg-subdir/logo.png" alt="logo"/></a></p>
</div>`
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 := `<div class="markup markdown"><p><a href="/user13/repo11/src/branch/main/docs/getting-started.md" rel="nofollow">docs</a></p>
</div>`
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 := `<div class="markup markdown"><p><a href="/docs/getting-started.md" rel="nofollow">docs</a></p>
</div>`
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"}

View File

@@ -27,7 +27,7 @@ git-fetch-with-cli = true</code></pre></div>
{{if or .PackageDescriptor.Metadata.Description .PackageDescriptor.Metadata.Readme}}
<h4 class="ui top attached header">{{ctx.Locale.Tr "packages.about"}}</h4>
{{if .PackageDescriptor.Metadata.Description}}<div class="ui attached segment">{{.PackageDescriptor.Metadata.Description}}</div>{{end}}
{{if .PackageDescriptor.Metadata.Readme}}<div class="ui attached segment">{{ctx.RenderUtils.MarkdownToHtml .PackageDescriptor.Metadata.Readme}}</div>{{end}}
{{if .PackageDescriptor.Metadata.Readme}}<div class="ui attached segment">{{ctx.RenderUtils.RenderPackageMarkdown .PackageDescriptor.Metadata.Readme .PackageDescriptor.Repository}}</div>{{end}}
{{end}}
{{if .PackageDescriptor.Metadata.Dependencies}}

View File

@@ -20,7 +20,7 @@
<h4 class="ui top attached header">{{ctx.Locale.Tr "packages.about"}}</h4>
<div class="ui attached segment">
{{if .PackageDescriptor.Metadata.Description}}<p>{{.PackageDescriptor.Metadata.Description}}</p>{{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}}
</div>
{{end}}

View File

@@ -25,7 +25,7 @@
{{if or .PackageDescriptor.Metadata.Description .PackageDescriptor.Metadata.Comments}}
<h4 class="ui top attached header">{{ctx.Locale.Tr "packages.about"}}</h4>
{{if .PackageDescriptor.Metadata.Description}}<div class="ui attached segment">{{.PackageDescriptor.Metadata.Description}}</div>{{end}}
{{if .PackageDescriptor.Metadata.Readme}}<div class="ui attached segment markup markdown">{{ctx.RenderUtils.MarkdownToHtml .PackageDescriptor.Metadata.Readme}}</div>{{end}}
{{if .PackageDescriptor.Metadata.Readme}}<div class="ui attached segment">{{ctx.RenderUtils.RenderPackageMarkdown .PackageDescriptor.Metadata.Readme .PackageDescriptor.Repository}}</div>{{end}}
{{if .PackageDescriptor.Metadata.Comments}}<div class="ui attached segment">{{StringUtils.Join .PackageDescriptor.Metadata.Comments " "}}</div>{{end}}
{{end}}

View File

@@ -22,15 +22,8 @@
{{if or .PackageDescriptor.Metadata.Description .PackageDescriptor.Metadata.Readme}}
<h4 class="ui top attached header">{{ctx.Locale.Tr "packages.about"}}</h4>
<div class="ui attached segment">
{{if .PackageDescriptor.Metadata.Readme}}
<div class="markup markdown">
{{ctx.RenderUtils.MarkdownToHtml .PackageDescriptor.Metadata.Readme}}
</div>
{{else if .PackageDescriptor.Metadata.Description}}
{{.PackageDescriptor.Metadata.Description}}
{{end}}
</div>
{{if .PackageDescriptor.Metadata.Description}}<div class="ui attached segment">{{.PackageDescriptor.Metadata.Description}}</div>{{end}}
{{if .PackageDescriptor.Metadata.Readme}}<div class="ui attached segment">{{ctx.RenderUtils.RenderPackageMarkdown .PackageDescriptor.Metadata.Readme .PackageDescriptor.Repository .PackageDescriptor.Metadata.Repository.Directory}}</div>{{end}}
{{end}}
{{if or .PackageDescriptor.Metadata.Dependencies .PackageDescriptor.Metadata.DevelopmentDependencies .PackageDescriptor.Metadata.PeerDependencies .PackageDescriptor.Metadata.OptionalDependencies}}

View File

@@ -18,9 +18,9 @@
{{if or .PackageDescriptor.Metadata.Description .PackageDescriptor.Metadata.ReleaseNotes .PackageDescriptor.Metadata.Readme}}
<h4 class="ui top attached header">{{ctx.Locale.Tr "packages.about"}}</h4>
{{if .PackageDescriptor.Metadata.Description}}<div class="ui attached segment">{{ctx.RenderUtils.MarkdownToHtml .PackageDescriptor.Metadata.Description}}</div>{{end}}
{{if .PackageDescriptor.Metadata.Readme}}<div class="ui attached segment markup markdown">{{ctx.RenderUtils.MarkdownToHtml .PackageDescriptor.Metadata.Readme}}</div>{{end}}
{{if .PackageDescriptor.Metadata.ReleaseNotes}}<div class="ui attached segment">{{ctx.RenderUtils.MarkdownToHtml .PackageDescriptor.Metadata.ReleaseNotes}}</div>{{end}}
{{if .PackageDescriptor.Metadata.Description}}<div class="ui attached segment">{{ctx.RenderUtils.RenderPackageMarkdown .PackageDescriptor.Metadata.Description .PackageDescriptor.Repository}}</div>{{end}}
{{if .PackageDescriptor.Metadata.Readme}}<div class="ui attached segment">{{ctx.RenderUtils.RenderPackageMarkdown .PackageDescriptor.Metadata.Readme .PackageDescriptor.Repository}}</div>{{end}}
{{if .PackageDescriptor.Metadata.ReleaseNotes}}<div class="ui attached segment">{{ctx.RenderUtils.RenderPackageMarkdown .PackageDescriptor.Metadata.ReleaseNotes .PackageDescriptor.Repository}}</div>{{end}}
{{end}}
{{if .PackageDescriptor.Metadata.Dependencies}}

View File

@@ -14,6 +14,6 @@
{{if or .PackageDescriptor.Metadata.Description .PackageDescriptor.Metadata.Readme}}
<h4 class="ui top attached header">{{ctx.Locale.Tr "packages.about"}}</h4>
{{if .PackageDescriptor.Metadata.Description}}<div class="ui attached segment">{{.PackageDescriptor.Metadata.Description}}</div>{{end}}
{{if .PackageDescriptor.Metadata.Readme}}<div class="ui attached segment">{{ctx.RenderUtils.MarkdownToHtml .PackageDescriptor.Metadata.Readme}}</div>{{end}}
{{if .PackageDescriptor.Metadata.Readme}}<div class="ui attached segment">{{ctx.RenderUtils.RenderPackageMarkdown .PackageDescriptor.Metadata.Readme .PackageDescriptor.Repository}}</div>{{end}}
{{end}}
{{end}}

View File

@@ -16,9 +16,9 @@
<div class="ui attached segment">
<p>{{if .PackageDescriptor.Metadata.Summary}}{{.PackageDescriptor.Metadata.Summary}}{{end}}</p>
{{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}}
</div>
{{end}}

View File

@@ -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![logo](logo.png)",
"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, `<p dir="auto"><a href="/user2/repo1/src/branch/master/package-subdir/docs/usage.md" rel="nofollow">docs</a>
<a href="/user2/repo1/src/branch/master/package-subdir/logo.png" rel="nofollow noopener" target="_blank"><img src="/user2/repo1/media/branch/master/package-subdir/logo.png" alt="logo" loading="lazy"/></a></p>
`, rendered)
})
t.Run("Delete", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()