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 `![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 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 cc7695726b..c49e1267a7 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 40c50de91f..7474d6c9f4 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 9d05bb2a0b..b78196bd5c 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 de7768e91c..61dcb4937f 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![logo](logo.png)", mockRepo, "pkg-subdir") + expected := `

docs +logo

+
` + 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 ebb23e8659..5c04ab8aae 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}}

{{ctx.Locale.Tr "packages.about"}}

{{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 d1c17d36a4..0912aff792 100644 --- a/templates/package/content/chef.tmpl +++ b/templates/package/content/chef.tmpl @@ -20,7 +20,7 @@

{{ctx.Locale.Tr "packages.about"}}

{{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 2e8cfb77eb..5bbc19e9f2 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}}

{{ctx.Locale.Tr "packages.about"}}

{{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 28bcf45ee8..89f4c94008 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}}

{{ctx.Locale.Tr "packages.about"}}

-
- {{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 7f874044cc..fc9a715a28 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}}

{{ctx.Locale.Tr "packages.about"}}

- {{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 9eefcf7162..5dafa2db47 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}}

{{ctx.Locale.Tr "packages.about"}}

{{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 4afdc1b72a..c570e60e6b 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 92f9b61081..8fb892cc86 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![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, `

docs +logo

+`, rendered) + }) + t.Run("Delete", func(t *testing.T) { defer tests.PrintCurrentTest(t)()