From cbaf97b867989e9d03dd277555f73ecca69318a8 Mon Sep 17 00:00:00 2001 From: Marten Lienen Date: Mon, 11 May 2026 20:21:29 +0200 Subject: [PATCH] feat: render org-mode file links with line numbers (#12496) This change renders file links in org-mode like `./module.el::20` as a link to the 20th line, for example. It also strips off other search types that are not currently supported in forgejo like regex search to avoid generating invalid URLs. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12496 Reviewed-by: Gusted --- modules/markup/orgmode/orgmode.go | 11 ++++++++ modules/markup/orgmode/orgmode_test.go | 37 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/modules/markup/orgmode/orgmode.go b/modules/markup/orgmode/orgmode.go index b9d7b21db0..dd92ca90d0 100644 --- a/modules/markup/orgmode/orgmode.go +++ b/modules/markup/orgmode/orgmode.go @@ -7,6 +7,7 @@ import ( "fmt" "html" "io" + "strconv" "strings" "forgejo.org/modules/highlight" @@ -159,6 +160,16 @@ func (r *Writer) resolveLink(node org.Node) string { switch l.Kind() { case "image", "video": base = r.Ctx.Links.ResolveMediaLink(r.Ctx.IsWiki) + case "regular": + // Convert line search syntax to line links + target, search, found := strings.Cut(link, "::") + if found { + if _, err := strconv.Atoi(search); err == nil { + link = target + "#L" + search + } else { + link = target + } + } } link = util.URLJoin(base, link) diff --git a/modules/markup/orgmode/orgmode_test.go b/modules/markup/orgmode/orgmode_test.go index 723d78e745..2cd4eada22 100644 --- a/modules/markup/orgmode/orgmode_test.go +++ b/modules/markup/orgmode/orgmode_test.go @@ -89,6 +89,43 @@ func TestRender_BaseLinks(t *testing.T) { `

./src/

`) } +func TestRender_SearchSuffix(t *testing.T) { + setting.AppURL = AppURL + setting.AppSubURL = AppSubURL + + test := func(input, expected string) { + buffer, err := RenderString(&markup.RenderContext{ + Ctx: git.DefaultContext, + Links: markup.Links{ + Base: setting.AppSubURL, + BranchPath: "branch/main", + }, + }, input) + require.NoError(t, err) + assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(buffer)) + } + + // `::N` line search becomes an `#L` anchor. + test("[[./file.el::35][line 35]]", + `

line 35

`) + test("[[file:./file.el::35][line 35]]", + `

line 35

`) + + // Other search types are ignored. + test("[[./file.org::*Heading][heading]]", + `

heading

`) + test("[[./file.org::#custom-id][heading]]", + `

heading

`) + test("[[./file.el::/regex/][regex]]", + `

regex

`) + test("[[file:./file.el::][no search]]", + `

no search

`) + + // Absolute URLs that happen to contain `::` are unchanged. + test("[[https://example.com/foo::35][ext]]", + `

ext

`) +} + func TestRender_Media(t *testing.T) { setting.AppURL = AppURL setting.AppSubURL = AppSubURL