From 04dd8ae5258831ca4a4562497c8f4ebb7025d726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20Ri=C3=9Fe?= Date: Thu, 19 Mar 2026 01:25:51 +0100 Subject: [PATCH] feat: match on compound filename extensions (#11439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of going with a single extension, extracted by `filepath.Ext()`, all possible extensions are now generated for a given filename, by splitting the filename using a "." separator, starting with the longest candidate. Moreover, each extension candidate is matched against the actual set of known renderers (`extRenderers`), and only the longest matching extension is used. Resolves https://codeberg.org/forgejo/forgejo/issues/5190. Co-authored-by: Michael Hanke Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11439 Reviewed-by: Ellen Εμιλία Άννα Zscheile Reviewed-by: Gusted Co-authored-by: Matthias Riße Co-committed-by: Matthias Riße --- modules/markup/renderer.go | 51 ++++++++++++--- routers/common/markup.go | 2 +- tests/integration/markup_external_test.go | 76 +++++++++++++++++++++++ tests/mysql.ini.tmpl | 10 +++ tests/pgsql.ini.tmpl | 10 +++ tests/sqlite.ini.tmpl | 10 +++ 6 files changed, 150 insertions(+), 9 deletions(-) diff --git a/modules/markup/renderer.go b/modules/markup/renderer.go index 05dd512815..b1c3d35e73 100644 --- a/modules/markup/renderer.go +++ b/modules/markup/renderer.go @@ -198,10 +198,28 @@ func RegisterRenderer(renderer Renderer) { } } -// GetRendererByFileName get renderer by filename -func GetRendererByFileName(filename string) Renderer { - extension := strings.ToLower(filepath.Ext(filename)) - return extRenderers[extension] +// FullExtension returns the full extension of path, i.e. everything after and including +// the first period in the basename of path. +func FullExtension(path string) string { + _, extension, found := strings.Cut(strings.ToLower(filepath.Base(path)), ".") + if !found { + return "" + } + return "." + extension +} + +// GetRendererByExtension returns the most specific registered renderer for extension. +func GetRendererByExtension(extension string) Renderer { + _, extension, found := strings.Cut(extension, ".") + checkedExtensions := 0 + for found && checkedExtensions < 10 { + if renderer, ok := extRenderers["."+extension]; ok { + return renderer + } + checkedExtensions++ + _, extension, found = strings.Cut(extension, ".") + } + return nil } // GetRendererByType returns a renderer according type @@ -350,6 +368,20 @@ func renderByType(ctx *RenderContext, input io.Reader, output io.Writer) error { return ErrUnsupportedRenderType{ctx.Type} } +// ErrMissingExtension represents the error when a path does not have any extension. +type ErrMissingExtension struct { + Path string +} + +func IsErrMissingExtension(err error) bool { + _, ok := err.(ErrMissingExtension) + return ok +} + +func (err ErrMissingExtension) Error() string { + return fmt.Sprintf("path '%s' does not have an extension", err.Path) +} + // ErrUnsupportedRenderExtension represents the error when extension doesn't supported to render type ErrUnsupportedRenderExtension struct { Extension string @@ -365,8 +397,11 @@ func (err ErrUnsupportedRenderExtension) Error() string { } func renderFile(ctx *RenderContext, input io.Reader, output io.Writer) error { - extension := strings.ToLower(filepath.Ext(ctx.RelativePath)) - if renderer, ok := extRenderers[extension]; ok { + extension := FullExtension(ctx.RelativePath) + if extension == "" { + return ErrMissingExtension{ctx.RelativePath} + } + if renderer := GetRendererByExtension(extension); renderer != nil { if r, ok := renderer.(ExternalRenderer); ok && r.DisplayInIFrame() { if !ctx.InStandalonePage { // for an external render, it could only output its content in a standalone page @@ -381,7 +416,7 @@ func renderFile(ctx *RenderContext, input io.Reader, output io.Writer) error { // Type returns if markup format via the filename func Type(filename string) string { - if parser := GetRendererByFileName(filename); parser != nil { + if parser := GetRendererByExtension(FullExtension(filename)); parser != nil { return parser.Name() } return "" @@ -389,7 +424,7 @@ func Type(filename string) string { // IsMarkupFile reports whether file is a markup type file func IsMarkupFile(name, markup string) bool { - if parser := GetRendererByFileName(name); parser != nil { + if parser := GetRendererByExtension(FullExtension(name)); parser != nil { return parser.Name() == markup } return false diff --git a/routers/common/markup.go b/routers/common/markup.go index 715d7d883f..f581d22ff7 100644 --- a/routers/common/markup.go +++ b/routers/common/markup.go @@ -94,7 +94,7 @@ func (re *Renderer) RenderMarkup(ctx *context.Base, repo *context.Repository) { Type: markupType, RelativePath: relativePath, }, strings.NewReader(re.Text), ctx.Resp); err != nil { - if markup.IsErrUnsupportedRenderExtension(err) { + if markup.IsErrUnsupportedRenderExtension(err) || markup.IsErrMissingExtension(err) { ctx.Error(http.StatusUnprocessableEntity, err.Error()) } else { ctx.Error(http.StatusInternalServerError, err.Error()) diff --git a/tests/integration/markup_external_test.go b/tests/integration/markup_external_test.go index a7cbdf37cb..d95093a01b 100644 --- a/tests/integration/markup_external_test.go +++ b/tests/integration/markup_external_test.go @@ -7,10 +7,17 @@ import ( "bytes" "io" "net/http" + "net/url" "strings" "testing" + "time" + "forgejo.org/models/unit" + "forgejo.org/models/unittest" + user_model "forgejo.org/models/user" + "forgejo.org/modules/git" "forgejo.org/modules/setting" + files_service "forgejo.org/services/repository/files" "forgejo.org/tests" "github.com/stretchr/testify/assert" @@ -38,3 +45,72 @@ func TestExternalMarkupRenderer(t *testing.T) { require.NoError(t, err) assert.Equal(t, "
\n\ttest external renderer\n
", strings.TrimSpace(data)) } + +func TestExternalMarkupRendererWithCompoundFileExtensions(t *testing.T) { + onApplicationRun(t, func(t *testing.T, u *url.URL) { + testCases := []struct { + path string + shouldUseExternalRenderer bool + expectedRenderedContent string + }{ + {"foo.abc.def.ghi.jkl.md", true, ".def.ghi.jkl.md renderer used"}, + {"foo.def.ghi.jkl.md", true, ".def.ghi.jkl.md renderer used"}, + {"foo.ghi.jkl.md", true, ".ghi.jkl.md renderer used"}, + {"foo.jkl.md", false, "foo"}, + {"foo.md", false, "foo"}, + } + + changeRepoFiles := []*files_service.ChangeRepoFile{} + for _, testCase := range testCases { + changeRepoFiles = append(changeRepoFiles, + &files_service.ChangeRepoFile{ + Operation: "create", + TreePath: testCase.path, + ContentReader: strings.NewReader("foo"), + }, + ) + } + + user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1}) + repo, _, f := tests.CreateDeclarativeRepo(t, user, "", []unit.Type{unit.TypeCode}, nil, nil) + defer f() + + _, err := files_service.ChangeRepoFiles(git.DefaultContext, repo, user, &files_service.ChangeRepoFilesOptions{ + Files: changeRepoFiles, + Message: "add files", + Author: &files_service.IdentityOptions{ + Name: user.Name, + Email: user.Email, + }, + Committer: &files_service.IdentityOptions{ + Name: user.Name, + Email: user.Email, + }, + Dates: &files_service.CommitDateOptions{ + Author: time.Now(), + Committer: time.Now(), + }, + }) + require.NoError(t, err) + + for _, testCase := range testCases { + t.Run(testCase.path, func(t *testing.T) { + req := NewRequestf(t, "GET", "%s/src/branch/%s/%s", repo.HTMLURL(), repo.DefaultBranch, testCase.path) + resp := MakeRequest(t, req, http.StatusOK) + require.Equal(t, "text/html; charset=utf-8", resp.Header()["Content-Type"][0]) + + doc := NewHTMLParser(t, resp.Body) + var query string + if testCase.shouldUseExternalRenderer { + query = "div.file-view" + } else { + query = "div.file-view p" + } + p := doc.Find(query) + data, err := p.Html() + require.NoError(t, err) + assert.Equal(t, testCase.expectedRenderedContent, strings.TrimSpace(data)) + }) + } + }) +} diff --git a/tests/mysql.ini.tmpl b/tests/mysql.ini.tmpl index 4678cf5087..9a010daab1 100644 --- a/tests/mysql.ini.tmpl +++ b/tests/mysql.ini.tmpl @@ -140,3 +140,13 @@ NEED_POSTPROCESS = false [cache] # Disable caching so that data isn't kept in-memory between test cases ITEM_TTL = -1 + +[markup.compound_file_extension] +ENABLED = true +FILE_EXTENSIONS = .ghi.jkl.md +RENDER_COMMAND = echo .ghi.jkl.md renderer used + +[markup.compound_file_extension_2] +ENABLED = true +FILE_EXTENSIONS = .def.ghi.jkl.md +RENDER_COMMAND = echo .def.ghi.jkl.md renderer used diff --git a/tests/pgsql.ini.tmpl b/tests/pgsql.ini.tmpl index 2e5dda5c59..ad56451bc3 100644 --- a/tests/pgsql.ini.tmpl +++ b/tests/pgsql.ini.tmpl @@ -154,3 +154,13 @@ NEED_POSTPROCESS = false [cache] # Disable caching so that data isn't kept in-memory between test cases ITEM_TTL = -1 + +[markup.compound_file_extension] +ENABLED = true +FILE_EXTENSIONS = .ghi.jkl.md +RENDER_COMMAND = echo .ghi.jkl.md renderer used + +[markup.compound_file_extension_2] +ENABLED = true +FILE_EXTENSIONS = .def.ghi.jkl.md +RENDER_COMMAND = echo .def.ghi.jkl.md renderer used diff --git a/tests/sqlite.ini.tmpl b/tests/sqlite.ini.tmpl index 10280cae19..9511e6d2a5 100644 --- a/tests/sqlite.ini.tmpl +++ b/tests/sqlite.ini.tmpl @@ -141,3 +141,13 @@ NEED_POSTPROCESS = false [cache] # Disable caching so that data isn't kept in-memory between test cases ITEM_TTL = -1 + +[markup.compound_file_extension] +ENABLED = true +FILE_EXTENSIONS = .ghi.jkl.md +RENDER_COMMAND = echo .ghi.jkl.md renderer used + +[markup.compound_file_extension_2] +ENABLED = true +FILE_EXTENSIONS = .def.ghi.jkl.md +RENDER_COMMAND = echo .def.ghi.jkl.md renderer used