feat: match on compound filename extensions (#11439)

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 <michael.hanke@gmail.com>
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11439
Reviewed-by: Ellen Εμιλία Άννα Zscheile <fogti@noreply.codeberg.org>
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Co-authored-by: Matthias Riße <matrss@0px.xyz>
Co-committed-by: Matthias Riße <matrss@0px.xyz>
This commit is contained in:
Matthias Riße 2026-03-19 01:25:51 +01:00 committed by Gusted
parent 5b47f1f002
commit 04dd8ae525
6 changed files with 150 additions and 9 deletions

View file

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

View file

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

View file

@ -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, "<div>\n\ttest external renderer\n</div>", 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))
})
}
})
}

View file

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

View file

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

View file

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