diff --git a/common/paths/path.go b/common/paths/path.go index de91d6a2ff2..85387f5d2f7 100644 --- a/common/paths/path.go +++ b/common/paths/path.go @@ -428,3 +428,15 @@ func ToSlashPreserveLeading(s string) string { func IsSameFilePath(s1, s2 string) bool { return path.Clean(ToSlashTrim(s1)) == path.Clean(ToSlashTrim(s2)) } + +// ToSlashNoExtensions returns the result of removing all file extensions from +// path s, then replacing each separator character with a slash ('/') +// character. For example, "/a/b/c.d/e.f.g" becomes "/a/b/c.d/e". +func ToSlashNoExtensions(s string) string { + d, f := filepath.Split(s) + i := strings.Index(f, ".") + if i >= 0 { + f = f[:i] + } + return filepath.ToSlash(path.Join(d, f)) +} diff --git a/common/paths/path_test.go b/common/paths/path_test.go index bc27df6c6c8..305fa3da500 100644 --- a/common/paths/path_test.go +++ b/common/paths/path_test.go @@ -15,6 +15,7 @@ package paths import ( "path/filepath" + "strconv" "testing" qt "github.com/frankban/quicktest" @@ -311,3 +312,41 @@ func TestIsSameFilePath(t *testing.T) { c.Assert(IsSameFilePath(filepath.FromSlash(this.a), filepath.FromSlash(this.b)), qt.Equals, this.expected, qt.Commentf("a: %s b: %s", this.a, this.b)) } } + +func TestToSlashNoExtensions(t *testing.T) { + tests := []struct { + path string + want string + }{ + {"a", "a"}, + {"a/", "a"}, + {"/a", "/a"}, + {"/a/", "/a"}, + {"a.b", "a"}, + {"a.b/", "a.b"}, + {"/a.b", "/a"}, + {"/a.b/", "/a.b"}, + {"a/b", "a/b"}, + {"a/b/", "a/b"}, + {"a/b", "a/b"}, + {"/a/b/", "/a/b"}, + {"a/b/c.d", "a/b/c"}, + {"a/b/c.d", "a/b/c"}, + {"a/b/c.d.e", "a/b/c"}, + {"a/b/c.d/e", "a/b/c.d/e"}, + {"a/b/c.d/e.f", "a/b/c.d/e"}, + {"a/b/c.d/e.f.g", "a/b/c.d/e"}, + {"a.", "a"}, + {".a", ""}, + {"/", "/"}, + {".", ""}, + {"", ""}, + } + for k, tt := range tests { + t.Run(strconv.Itoa(k), func(t *testing.T) { + if got := ToSlashNoExtensions(tt.path); got != tt.want { + t.Errorf("ToSlashNoExtensions() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/tpl/tplimpl/template.go b/tpl/tplimpl/template.go index 0ea7117a3ac..a3794ca9952 100644 --- a/tpl/tplimpl/template.go +++ b/tpl/tplimpl/template.go @@ -23,6 +23,7 @@ import ( "path/filepath" "reflect" "regexp" + "slices" "sort" "strings" "sync" @@ -31,6 +32,7 @@ import ( "unicode/utf8" "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/paths" "github.com/gohugoio/hugo/common/types" "github.com/gohugoio/hugo/output/layouts" @@ -72,6 +74,14 @@ var embeddedTemplatesAliases = map[string][]string{ "shortcodes/twitter.html": {"shortcodes/tweet.html"}, } +// These paths are reserved for embedded templates. +// They follow the format "directory/name" relative to the layouts directory, +// omitting any file extensions. Templates with these paths are not loaded from +// user space. +var reservedTemplatePaths = []string{ + "shortcodes/comment", +} + var ( _ tpl.TemplateManager = (*templateExec)(nil) _ tpl.TemplateHandler = (*templateExec)(nil) @@ -825,6 +835,12 @@ func (t *templateHandler) loadTemplates() error { name := strings.TrimPrefix(filepath.ToSlash(path), "/") filename := filepath.Base(path) + + if t.isReservedTemplatePath(path) { + t.Log.Infof("template not loaded: the path %q is reserved for embedded templates", name) + return nil + } + outputFormats := t.Conf.GetConfigSection("outputFormats").(output.Formats) outputFormat, found := outputFormats.FromFilename(filename) @@ -849,6 +865,13 @@ func (t *templateHandler) loadTemplates() error { return nil } +// isReservedTemplatePath reports whether the template with the given name is +// reserved for embedded templates. +func (t *templateHandler) isReservedTemplatePath(path string) bool { + // shortcodesPathPrefix + return slices.Contains(reservedTemplatePaths, paths.ToSlashNoExtensions(path)) +} + func (t *templateHandler) nameIsText(name string) (string, bool) { isText := strings.HasPrefix(name, textTmplNamePrefix) if isText { diff --git a/tpl/tplimpl/tplimpl_integration_test.go b/tpl/tplimpl/tplimpl_integration_test.go index d1e214ce26f..e2795cc6f73 100644 --- a/tpl/tplimpl/tplimpl_integration_test.go +++ b/tpl/tplimpl/tplimpl_integration_test.go @@ -858,3 +858,37 @@ title: p5 } b.Assert(htmlFiles, hqt.IsAllElementsEqual) } + +func TestFoo(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['page','rss','section','sitemap','taxonomy','term'] +-- layouts/LAYOUT -- +` + + filesOriginal := files + + layouts := []string{ + "shortcodes/comment.html", + "shortcodes/comment.html.html", + "shortcodes/comment.en.html.html", + "shortcodes/comment.de.html.html", + "shortcodes/comment.json", + "shortcodes/comment.json.json", + "shortcodes/comment.en.json.json", + "shortcodes/comment.de.json.json", + } + + // want := `INFO template not loaded: the path "shortcodes/comment.html" is reserved for embedded templates` + for _, layout := range layouts { + files = strings.ReplaceAll(filesOriginal, "LAYOUT", layout) + b := hugolib.Test(t, files, hugolib.TestOptInfo()) + b.AssertLogContains("INFO template not loaded: the path", layout, "is reserved for embedded templates") + } + + files = strings.ReplaceAll(filesOriginal, "LAYOUT", "shortcodes/foo.html") + b := hugolib.Test(t, files, hugolib.TestOptInfo()) + b.AssertLogContains("! INFO template not loaded: the path", "! is reserved for embedded templates") +}