diff --git a/README.md b/README.md index c9f6a22..589f03a 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,6 @@ A type-safe HTML template rendering engine for Go. -## Table of Contents -... - -A type-safe HTML template rendering engine for Go. - - ## Table of Contents - [Problem Statement](#problem-statement) @@ -24,6 +18,7 @@ A type-safe HTML template rendering engine for Go. - [Usage Examples](#usage-examples) - [Template Generation](#template-generation) - [Configuration](#configuration) +- [Development Requirements](#development-requirements) - [Contributing](#contributing) - [License](#license) @@ -61,7 +56,7 @@ type HomeData struct { reg, _ := templator.NewRegistry[HomeData](fs) // Get type-safe handler and execute template -home, _ := reg.GetHome() +home, _ := reg.Get("home") home.Execute(ctx, w, HomeData{ Title: "Welcome", Content: "Hello", @@ -84,7 +79,13 @@ home.Execute(ctx, w, struct{ ## Installation ```bash -go install github.com/alesr/templator +go get github.com/alesr/templator@latest +``` + +Install the code generator binary (optional): + +```bash +go install github.com/alesr/templator/cmd/generate@latest ``` ## Quick Start @@ -117,7 +118,7 @@ func main() { } // Get type-safe handler for home template - home, err := reg.GetHome() + home, err := reg.Get("home") if err != nil { log.Fatal(err) } @@ -154,8 +155,8 @@ homeReg := templator.NewRegistry[HomeData](fs) aboutReg := templator.NewRegistry[AboutData](fs) // Get handlers -home, _ := homeReg.GetHome() -about, _ := aboutReg.GetAbout() +home, _ := homeReg.Get("home") +about, _ := aboutReg.Get("about") // Type safety enforced at compile time home.Execute(ctx, w, HomeData{...}) // ✅ Compiles @@ -246,29 +247,42 @@ This validation happens when loading the template, not during execution, helping ## Template Generation -Templates are automatically discovered and type-safe methods are generated: +Generate accessors in your package: + +```bash +go run github.com/alesr/templator/cmd/generate \ + -package myapp \ + -templates ./templates \ + -out ./templator_accessors_gen.go +``` + +Then use the generated wrapper: + +```go +reg, _ := templator.NewRegistry[HomeData](fs) +tpl := NewTemplateAccessors(reg) +home, _ := tpl.GetHome() +``` + +Template names are mapped to wrapper methods using title-cased path components: ```zsh templates/ -├── home.html -> reg.GetHome() -├── about.html -> reg.GetAbout() +├── home.html -> tpl.GetHome() +├── about.html -> tpl.GetAbout() └── components/ - └── header.html -> reg.GetComponentsHeader() - -# Generate methods -go generate ./... + └── header.html -> tpl.GetComponentsHeader() ``` -The generation process creates a `templator_methods.go` file containing type-safe method handlers for each template. For example: +This creates a generated file (for example `templator_accessors_gen.go`) with wrapper methods such as: ```go -// Code generated by go generate; DO NOT EDIT. -func (r *Registry[T]) GetHome() (*Handler[T], error) { - return r.Get("home") +func (r *TemplateAccessors[T]) GetHome() (*templator.Handler[T], error) { + return r.registry.Get("home") } -func (r *Registry[T]) GetAbout() (*Handler[T], error) { - return r.Get("about") +func (r *TemplateAccessors[T]) GetAbout() (*templator.Handler[T], error) { + return r.registry.Get("about") } ``` @@ -286,9 +300,9 @@ reg, err := templator.NewRegistry[HomeData]( ) ``` -### Development Requirements +## Development Requirements -- Go 1.21 or higher +- Go 1.24 or higher ## License diff --git a/cmd/generate/generate_methods.go b/cmd/generate/generate_methods.go index 2b2341c..648aec8 100644 --- a/cmd/generate/generate_methods.go +++ b/cmd/generate/generate_methods.go @@ -1,10 +1,5 @@ -// Package main provides a code generator for creating type-safe template handler methods. -// It scans a directory for HTML templates and generates corresponding Go methods that -// can be used to retrieve template handlers from a Registry. -// -// The generator creates methods following this pattern for each template: -// - Template path: "path/to/template.html" -// - Generated method: GetPathToTemplate() +// Package main provides a code generator for creating consumer-side template accessors. +// It scans a directory for HTML templates and generates a wrapper with typed helper methods. // // Usage: // @@ -13,13 +8,18 @@ // Flags: // // -templates string -// Directory containing the template files (default "templates") +// Directory containing template files (default "templates") // -out string -// Output file for generated methods (default "./templator_methods.go") +// Output file for generated code (default "./templator_accessors_gen.go") +// -package string +// Package name for generated code (default "main") +// -templator-import string +// Import path for templator (default "github.com/alesr/templator") package main import ( "bytes" + "errors" "flag" "fmt" "go/format" @@ -33,37 +33,68 @@ import ( "golang.org/x/text/language" ) -const methodsTemplate = `{{define "header"}}// Code generated by go generate; DO NOT EDIT. -package templator +const methodsTemplate = `{{ define "header" }}// Code generated by go generate; DO NOT EDIT. +package {{.PackageName}} + +import "{{ .TemplatorImport }}" + +// TemplateAccessors provides typed helper accessors over templator Registry. +type TemplateAccessors[T any] struct { + registry *templator.Registry[T] +} + +// NewTemplateAccessors creates a TemplateAccessors wrapper for a templator Registry. +func NewTemplateAccessors[T any](registry *templator.Registry[T]) *TemplateAccessors[T] { + return &TemplateAccessors[T]{registry: registry} +} -{{end}} +{{ end }} -{{define "method"}} -// {{.MethodName}} returns a handler for the {{.TemplateName}} template. -func (r *Registry[T]) {{.MethodName}}() (*Handler[T], error) { - return r.Get("{{.TemplateName}}") +{{ define "method" }} +// {{ .MethodName }} returns a handler for the {{ .TemplateName }} template. +func (r *TemplateAccessors[T]) {{.MethodName}}() (*templator.Handler[T], error) { + return r.registry.Get("{{ .TemplateName }}") } -{{end}}` +{{ end }}` type ( TemplateData struct { - DataType string MethodName string TemplateName string } + headerData struct { + PackageName string + TemplatorImport string + } config struct { - templateDir string - outputFile string + templateDir string + outputFile string + packageName string + templatorImport string } ) func main() { - cfg := parseFlags() - tmpl := loadTemplateGenerator() - generateMethods(cfg, tmpl) + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } } -func parseFlags() config { +func run() error { + cfg, err := parseFlags() + if err != nil { + return err + } + + tmpl, err := loadTemplateGenerator() + if err != nil { + return err + } + return generateMethods(cfg, tmpl) +} + +func parseFlags() (config, error) { flagSet := flag.NewFlagSet("generate", flag.ExitOnError) templateDir := flagSet.String( "templates", @@ -72,68 +103,111 @@ func parseFlags() config { ) outputFile := flagSet.String( "out", - "./templator_methods.go", - "output file for generated methods", + "./templator_accessors_gen.go", + "output file for generated accessors", ) + packageName := flagSet.String( + "package", + "main", + "package name for generated code", + ) + templatorImport := flagSet.String( + "templator-import", + "github.com/alesr/templator", + "templator import path", + ) + flagSet.Parse(os.Args[1:]) - return config{*templateDir, *outputFile} + + cfg := config{ + templateDir: *templateDir, + outputFile: *outputFile, + packageName: *packageName, + templatorImport: *templatorImport, + } + + if err := cfg.validate(); err != nil { + return config{}, err + } + return cfg, nil +} + +func (c config) validate() error { + if c.packageName == "" { + return errors.New("requires non-empty -package") + } + if c.templatorImport == "" { + return errors.New("requires non-empty -templator-import") + } + return nil } -func loadTemplateGenerator() *template.Template { +func loadTemplateGenerator() (*template.Template, error) { tmpl, err := template.New("generator").Parse(methodsTemplate) if err != nil { - panic(fmt.Errorf("failed to parse code generator template: %w", err)) + return nil, fmt.Errorf("could not parse code generator template: %w", err) } - return tmpl + return tmpl, nil } -func generateMethods(cfg config, tmpl *template.Template) { +func generateMethods(cfg config, tmpl *template.Template) error { var buf bytes.Buffer - if err := writeHeader(&buf, tmpl); err != nil { - panic(fmt.Errorf("failed to write header: %w", err)) + if err := writeHeader(&buf, cfg, tmpl); err != nil { + return fmt.Errorf("could not write header: %w", err) } - if err := processTemplates(cfg.templateDir, &buf, tmpl); err != nil { - panic(fmt.Errorf("failed to process templates: %w", err)) + + if err := processTemplates(cfg, &buf, tmpl); err != nil { + return fmt.Errorf("could not process templates: %w", err) } + if err := writeOutput(cfg.outputFile, &buf); err != nil { - panic(fmt.Errorf("failed to write output: %w", err)) + return fmt.Errorf("could not write output: %w", err) } fmt.Println("Methods generated and formatted successfully.") + + return nil } -func writeHeader(buf *bytes.Buffer, tmpl *template.Template) error { - return tmpl.ExecuteTemplate(buf, "header", nil) +func writeHeader(buf *bytes.Buffer, cfg config, tmpl *template.Template) error { + return tmpl.ExecuteTemplate(buf, "header", headerData{ + PackageName: cfg.packageName, + TemplatorImport: cfg.templatorImport, + }) } -func processTemplates(templateDir string, buf *bytes.Buffer, tmpl *template.Template) error { - caser := cases.Title(language.English) - return filepath.Walk(templateDir, func(path string, info os.FileInfo, err error) error { +func processTemplates(cfg config, buf *bytes.Buffer, tmpl *template.Template) error { + return filepath.Walk(cfg.templateDir, func(path string, info os.FileInfo, err error) error { if err != nil { - return fmt.Errorf("failed to walk directory: %w", err) + return fmt.Errorf("could not walk directory: %w", err) } + if info.IsDir() { return nil } + if filepath.Ext(path) != string(templator.ExtensionHTML) { return nil } - return generateTemplateMethod(path, templateDir, buf, tmpl, caser) + return generateTemplateMethod(path, cfg, buf, tmpl, cases.Title(language.English)) }) } -func generateTemplateMethod(path, templateDir string, buf *bytes.Buffer, tmpl *template.Template, caser cases.Caser) error { - relPath, err := filepath.Rel(templateDir, path) +func generateTemplateMethod(path string, cfg config, buf *bytes.Buffer, tmpl *template.Template, caser cases.Caser) error { + relPath, err := filepath.Rel(cfg.templateDir, path) if err != nil { - return fmt.Errorf("failed to get relative path: %w", err) + return fmt.Errorf("could not get relative path: %w", err) } + data, err := buildTemplateData(relPath, caser) if err != nil { - return fmt.Errorf("failed to build template data: %w", err) + return fmt.Errorf("could not build template data: %w", err) } + if err := tmpl.ExecuteTemplate(buf, "method", data); err != nil { - return fmt.Errorf("failed to execute template: %w", err) + return fmt.Errorf("could not execute template: %w", err) } buf.WriteString("\n") + return nil } @@ -143,6 +217,7 @@ func buildTemplateData(relPath string, caser cases.Caser) (TemplateData, error) for i, part := range parts { parts[i] = caser.String(part) } + return TemplateData{ MethodName: "Get" + strings.Join(parts, ""), TemplateName: filepath.ToSlash(basePath), @@ -152,7 +227,7 @@ func buildTemplateData(relPath string, caser cases.Caser) (TemplateData, error) func writeOutput(outputFile string, buf *bytes.Buffer) error { formattedSource, err := format.Source(buf.Bytes()) if err != nil { - return fmt.Errorf("failed to format generated code: %w", err) + return fmt.Errorf("could not format generated code: %w", err) } - return os.WriteFile(outputFile, formattedSource, 0644) + return os.WriteFile(outputFile, formattedSource, 0o644) } diff --git a/cmd/generate/generate_methods_test.go b/cmd/generate/generate_methods_test.go index c3a7eb4..fea144f 100644 --- a/cmd/generate/generate_methods_test.go +++ b/cmd/generate/generate_methods_test.go @@ -16,7 +16,6 @@ import ( var osArgsMu sync.Mutex func TestParseFlags(t *testing.T) { - // Do not run this test in parallel since it modifies global state osArgsMu.Lock() oldArgs := os.Args osArgsMu.Unlock() @@ -32,18 +31,34 @@ func TestParseFlags(t *testing.T) { args []string wantTemplates string wantOutput string + wantPackage string + wantErr string }{ { name: "default values", args: []string{"cmd"}, wantTemplates: "templates", - wantOutput: "./templator_methods.go", + wantOutput: "./templator_accessors_gen.go", + wantPackage: "main", }, { name: "custom values", args: []string{"cmd", "-templates", "custom/templates", "-out", "custom_output.go"}, wantTemplates: "custom/templates", wantOutput: "custom_output.go", + wantPackage: "main", + }, + { + name: "custom package", + args: []string{"cmd", "-package", "myapp"}, + wantTemplates: "templates", + wantOutput: "./templator_accessors_gen.go", + wantPackage: "myapp", + }, + { + name: "rejects empty package", + args: []string{"cmd", "-package", ""}, + wantErr: "requires non-empty -package", }, } @@ -53,63 +68,62 @@ func TestParseFlags(t *testing.T) { os.Args = tt.args osArgsMu.Unlock() - cfg := parseFlags() + cfg, err := parseFlags() + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + + require.NoError(t, err) assert.Equal(t, tt.wantTemplates, cfg.templateDir) assert.Equal(t, tt.wantOutput, cfg.outputFile) + assert.Equal(t, tt.wantPackage, cfg.packageName) }) } } func TestLoadTemplateGenerator(t *testing.T) { - tmpl := loadTemplateGenerator() + tmpl, err := loadTemplateGenerator() + require.NoError(t, err) require.NotNil(t, tmpl) var buf strings.Builder - err := tmpl.ExecuteTemplate(&buf, "header", nil) + err = tmpl.ExecuteTemplate(&buf, "header", headerData{PackageName: "templator"}) require.NoError(t, err) assert.Contains(t, buf.String(), "package templator") } func TestGenerateMethods(t *testing.T) { - tempDir, err := os.MkdirTemp("", "templator_test_*") - require.NoError(t, err) - defer os.RemoveAll(tempDir) - - templates := map[string]string{ - "index.html": "", - "users/profile.html": "", - } - - for path, content := range templates { - fullPath := filepath.Join(tempDir, path) - err := os.MkdirAll(filepath.Dir(fullPath), 0755) - require.NoError(t, err) + tempDir := t.TempDir() - err = os.WriteFile(fullPath, []byte(content), 0644) - require.NoError(t, err) - } + writeTemplateFixture(t, tempDir, "index.html") + writeTemplateFixture(t, tempDir, "users/profile.html") outputFile := filepath.Join(tempDir, "output.go") cfg := config{ - templateDir: tempDir, - outputFile: outputFile, + templateDir: tempDir, + outputFile: outputFile, + packageName: "myapp", + templatorImport: "github.com/alesr/templator", } - tmpl := loadTemplateGenerator() - generateMethods(cfg, tmpl) + tmpl, err := loadTemplateGenerator() + require.NoError(t, err) + + err = generateMethods(cfg, tmpl) + require.NoError(t, err) content, err := os.ReadFile(outputFile) require.NoError(t, err) generatedCode := string(content) - expectedMethods := []string{ - "GetIndex", - "GetUsersProfile", - } - - for _, method := range expectedMethods { - assert.Contains(t, generatedCode, method) - } + assert.Contains(t, generatedCode, "package myapp") + assert.Contains(t, generatedCode, "import \"github.com/alesr/templator\"") + assert.Contains(t, generatedCode, "type TemplateAccessors[T any] struct") + assert.Contains(t, generatedCode, "func NewTemplateAccessors[T any](registry *templator.Registry[T]) *TemplateAccessors[T]") + assert.Contains(t, generatedCode, "func (r *TemplateAccessors[T]) GetIndex() (*templator.Handler[T], error)") + assert.Contains(t, generatedCode, "func (r *TemplateAccessors[T]) GetUsersProfile() (*templator.Handler[T], error)") } func TestBuildTemplateData(t *testing.T) { @@ -142,3 +156,14 @@ func TestBuildTemplateData(t *testing.T) { }) } } + +func writeTemplateFixture(t *testing.T, rootDir, relativePath string) { + t.Helper() + + fullPath := filepath.Join(rootDir, relativePath) + err := os.MkdirAll(filepath.Dir(fullPath), 0o755) + require.NoError(t, err) + + err = os.WriteFile(fullPath, []byte(""), 0o644) + require.NoError(t, err) +}