Repository: gobuffalo/buffalo

Issue:
Feature: Configurable template metadata injection
[PR#2407](https://github.com/gobuffalo/buffalo/pull/2407) enable Buffalo to pass comprehensive file metadata to the render function as preparation for Plush punch hole feature integration. This foundational change expands Buffalo's render interface beyond just file contents and context to include rich metadata about template files.


**Before**: Buffalo only passed file contents and context (CTX) to the render interface. **After**: Buffalo now passes additional metadata, including file paths, extensions, modification times, and base names

## Key Benefits

1. Template engine optimization: Engines can cache AST/HTML by file properties

1.  Rich file context: Templates have access to their own metadata


Hints from issue comments:
@Mido-sys: @paganotoni, those are the only changes needed in Buffalo to get the punch hole working correctly in plush. 

Solution patch:
diff --git a/render/options.go b/render/options.go
index 1c720bc5..e84a5e80 100644
--- a/render/options.go
+++ b/render/options.go
@@ -32,4 +32,18 @@ type Options struct {
 	// DefaultContentType instructs the engine what it should fall back to if
 	// the "content-type" is unknown
 	DefaultContentType string
+
+	// TemplateMetadataKeys allows users to specify custom keys for template metadata
+	// If nil, uses default Buffalo metadata approach
+	TemplateMetadataKeys map[string]string
+
+	TemplateBaseDir string
+}
+
+// Default metadata keys
+var defaultTemplateMetadataKeys = map[string]string{
+	"template_file": "_buffalo_template_file",
+	"base_name":     "_buffalo_base_name",
+	"extension":     "_buffalo_extension",
+	"last_modified": "_buffalo_last_modification",
 }
diff --git a/render/render.go b/render/render.go
index b3e9410d..d6b3c2ea 100644
--- a/render/render.go
+++ b/render/render.go
@@ -55,6 +55,10 @@ func New(opts Options) *Engine {
 		opts.DefaultContentType = "text/html; charset=utf-8"
 	}
 
+	if opts.TemplateMetadataKeys == nil {
+		opts.TemplateMetadataKeys = defaultTemplateMetadataKeys
+	}
+
 	e := &Engine{
 		Options: opts,
 	}
diff --git a/render/template.go b/render/template.go
index f5b1681a..9f1ccabe 100644
--- a/render/template.go
+++ b/render/template.go
@@ -27,26 +27,30 @@ func (s *templateRenderer) ContentType() string {
 	return s.contentType
 }
 
-func (s *templateRenderer) resolve(name string) ([]byte, error) {
+func (s *templateRenderer) resolve(name string) ([]byte, fs.FileInfo, error) {
 	if s.TemplatesFS == nil {
-		return nil, fmt.Errorf("no templates fs defined")
+		return nil, nil, fmt.Errorf("no templates fs defined")
 	}
 
 	f, err := s.TemplatesFS.Open(name)
 	if err == nil {
-		return io.ReadAll(f)
+		contents, err := io.ReadAll(f)
+		ff, _ := f.Stat()
+		return contents, ff, err
 	}
 
 	v, ok := s.aliases.Load(name)
 	if !ok {
-		return nil, fmt.Errorf("could not find template %s", name)
+		return nil, nil, fmt.Errorf("could not find template %s", name)
 	}
 
 	f, err = s.TemplatesFS.Open(v.(string))
 	if err != nil {
-		return nil, err
+		return nil, nil, err
 	}
-	return io.ReadAll(f)
+	contents, err := io.ReadAll(f)
+	ff, _ := f.Stat()
+	return contents, ff, err
 }
 
 func (s *templateRenderer) Render(w io.Writer, data Data) error {
@@ -134,7 +138,7 @@ func (s *templateRenderer) partialFeeder(name string) (string, error) {
 	name = filepath.Join(d, "_"+f)
 	name = fixExtension(name, ct)
 
-	b, err := s.resolve(name)
+	b, _, err := s.resolve(name)
 	return string(b), err
 }
 
@@ -144,7 +148,7 @@ func (s *templateRenderer) exec(name string, data Data) (template.HTML, error) {
 
 	name = fixExtension(name, ct)
 
-	source, err := s.localizedResolve(name, data)
+	source, file, err := s.localizedResolve(name, data)
 	if err != nil {
 		return "", err
 	}
@@ -161,9 +165,10 @@ func (s *templateRenderer) exec(name string, data Data) (template.HTML, error) {
 	}
 
 	helpers = s.addAssetsHelpers(helpers)
-
+	exts, fileName := s.extsAndBase(name)
 	body := string(source)
-	for _, ext := range s.exts(name) {
+	for _, ext := range exts {
+		s.addTemplateMetadata(data, fileName, ext, file)
 		te, ok := s.TemplateEngines[ext]
 		if !ok {
 			logrus.Errorf("could not find a template engine for %s", ext)
@@ -200,7 +205,7 @@ func (s *templateRenderer) localizedName(name string, data Data) string {
 		}
 
 		candidateName = rawName + "." + strings.ToLower(l) + ext
-		if _, err := s.resolve(candidateName); err == nil {
+		if _, _, err := s.resolve(candidateName); err == nil {
 			// Replace name with the existing suffixed version
 			templateName = candidateName
 			break
@@ -210,7 +215,7 @@ func (s *templateRenderer) localizedName(name string, data Data) string {
 	return templateName
 }
 
-func (s *templateRenderer) localizedResolve(name string, data Data) ([]byte, error) {
+func (s *templateRenderer) localizedResolve(name string, data Data) ([]byte, fs.FileInfo, error) {
 	languages, ok := data["languages"].([]string)
 	if !ok || len(languages) == 0 {
 		return s.resolve(name)
@@ -229,14 +234,14 @@ func (s *templateRenderer) localizedResolve(name string, data Data) ([]byte, err
 		short := strings.Split(fullLower, "-")[0] // form of ko
 
 		fullLocale := rawName + "." + fullLower + ext
-		if source, err := s.resolve(fullLocale); err == nil {
-			return source, nil
+		if source, file, err := s.resolve(fullLocale); err == nil {
+			return source, file, nil
 		}
 
 		if fullLower != short {
 			langOnly := rawName + "." + short + ext
-			if source, err := s.resolve(langOnly); err == nil {
-				return source, nil
+			if source, file, err := s.resolve(langOnly); err == nil {
+				return source, file, nil
 			}
 		}
 	}
@@ -244,6 +249,52 @@ func (s *templateRenderer) localizedResolve(name string, data Data) ([]byte, err
 	return s.resolve(name)
 }
 
+func (s *templateRenderer) addTemplateMetadata(data Data, fileName, ext string, info fs.FileInfo) {
+	// Get metadata keys from options, fallback to defaults
+	metaKeys := s.TemplateMetadataKeys
+	if metaKeys == nil {
+		metaKeys = defaultTemplateMetadataKeys
+	}
+
+	// Map each metadata type to user-defined key
+	if templateFileKey, exists := metaKeys["template_file"]; exists && templateFileKey != "" {
+		data[templateFileKey] = filepath.Join(s.TemplateBaseDir, fileName) + "." + ext
+	}
+
+	// base_name and extension are derived from the name passed to exec
+	// last_modified is derived from the fs.FileInfo passed to exec
+	// if available
+	if baseNameKey, exists := metaKeys["base_name"]; exists && baseNameKey != "" {
+		data[baseNameKey] = fileName
+	}
+
+	if extensionKey, exists := metaKeys["extension"]; exists && extensionKey != "" {
+		data[extensionKey] = ext
+	}
+
+	if lastModKey, exists := metaKeys["last_modified"]; exists && lastModKey != "" {
+		data[lastModKey] = info.ModTime()
+	}
+}
+
+func (s *templateRenderer) extsAndBase(name string) ([]string, string) {
+	exts := []string{}
+	baseName := name
+	for {
+		ext := filepath.Ext(baseName)
+		if ext == "" {
+			break
+		}
+		baseName = strings.TrimSuffix(baseName, ext)
+		exts = append(exts, strings.ToLower(ext[1:]))
+	}
+	if len(exts) == 0 {
+		return []string{"html"}, baseName
+	}
+	sort.Sort(sort.Reverse(sort.StringSlice(exts)))
+	return exts, baseName
+}
+
 func (s *templateRenderer) exts(name string) []string {
 	exts := []string{}
 	for {



Test patch:
diff --git a/render/template_test.go b/render/template_test.go
index b1e364fd..9a5c95be 100644
--- a/render/template_test.go
+++ b/render/template_test.go
@@ -328,3 +328,110 @@ func Test_Template_resolve_LangOnly_FullFile_Mixed(t *testing.T) {
 	r.NoError(re.Render(bb, Data{"name": "Paul", "languages": []string{"ko", "en"}}))
 	r.Equal("korean Paul", strings.TrimSpace(bb.String()))
 }
+
+func Test_Template_extsAndBase(t *testing.T) {
+	r := require.New(t)
+
+	tests := []struct {
+		name         string
+		input        string
+		expectedExts []string
+		expectedBase string
+	}{
+		{
+			name:         "single extension",
+			input:        "index.html",
+			expectedExts: []string{"html"},
+			expectedBase: "index",
+		},
+		{
+			name:         "multiple extensions",
+			input:        "template.html.plush",
+			expectedExts: []string{"plush", "html"},
+			expectedBase: "template",
+		},
+		{
+			name:         "three extensions",
+			input:        "layout.md.html.plush",
+			expectedExts: []string{"plush", "md", "html"},
+			expectedBase: "layout",
+		},
+		{
+			name:         "no extension",
+			input:        "template",
+			expectedExts: []string{"html"},
+			expectedBase: "template",
+		},
+		{
+			name:         "empty string",
+			input:        "",
+			expectedExts: []string{"html"},
+			expectedBase: "",
+		},
+		{
+			name:         "extension with uppercase",
+			input:        "index.HTML",
+			expectedExts: []string{"html"},
+			expectedBase: "index",
+		},
+		{
+			name:         "mixed case extensions",
+			input:        "template.MD.HTML.PLUSH",
+			expectedExts: []string{"plush", "md", "html"},
+			expectedBase: "template",
+		},
+		{
+			name:         "path with directories",
+			input:        "layouts/application.html.plush",
+			expectedExts: []string{"plush", "html"},
+			expectedBase: "layouts/application",
+		},
+		{
+			name:         "nested path no extension",
+			input:        "views/users/index",
+			expectedExts: []string{"html"},
+			expectedBase: "views/users/index",
+		},
+		{
+			name:         "dotfile",
+			input:        ".gitignore",
+			expectedExts: []string{"gitignore"},
+			expectedBase: "",
+		},
+		{
+			name:         "dotfile with extension",
+			input:        ".env.local",
+			expectedExts: []string{"local", "env"},
+			expectedBase: "",
+		},
+		{
+			name:         "complex filename",
+			input:        "user-profile.en-US.html.plush",
+			expectedExts: []string{"plush", "html", "en-us"},
+			expectedBase: "user-profile",
+		},
+		{
+			name:         "only extension",
+			input:        ".html",
+			expectedExts: []string{"html"},
+			expectedBase: "",
+		},
+		{
+			name:         "locale and template extensions",
+			input:        "welcome.fr-FR.html.plush",
+			expectedExts: []string{"plush", "html", "fr-fr"},
+			expectedBase: "welcome",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			renderer := &templateRenderer{}
+
+			gotExts, gotBase := renderer.extsAndBase(tt.input)
+
+			r.Equal(tt.expectedExts, gotExts, "extensions should match")
+			r.Equal(tt.expectedBase, gotBase, "base name should match")
+		})
+	}
+}