10 KiB
Template Patterns for Go CLI Tools
This guide covers patterns for implementing customizable output templates in Go CLI tools, based on successful patterns from linkding-to-markdown and mastodon-to-markdown.
Overview
CLI tools that generate formatted output (Markdown, HTML, XML, etc.) benefit from:
- Embedded default templates - Work out of the box, single binary
- User customization - Users can modify templates for their needs
- Init command - Easy way to get started with configuration and templates
Architecture
Directory Structure
my-cli-tool/
├── cmd/
│ ├── init.go # Init command to bootstrap config/templates
│ └── fetch.go # Command that uses templates
├── internal/
│ ├── templates/
│ │ ├── templates.go # Template loader with embedded defaults
│ │ └── default.md # Default template (embedded via //go:embed)
│ └── generator/ # Or markdown/, formatter/, etc.
│ └── generator.go # Template renderer and data structures
Implementation Steps
1. Create Template Package
File: internal/templates/templates.go
package templates
import (
_ "embed"
)
//go:embed default.md
var defaultTemplate string
// GetDefaultTemplate returns the embedded default template content
func GetDefaultTemplate() (string, error) {
return defaultTemplate, nil
}
File: internal/templates/default.md
Create your default template using Go's text/template syntax:
# {{ .Title }}
_Generated: {{ .Generated }}_
---
{{ range .Items -}}
## {{ .Name }}
{{ .Description }}
{{ if .Tags -}}
Tags: {{ join .Tags ", " }}
{{ end -}}
---
{{ end -}}
2. Create Generator/Renderer
File: internal/generator/generator.go
package generator
import (
"fmt"
"io"
"os"
"strings"
"text/template"
"time"
"yourproject/internal/templates"
)
type Generator struct {
template *template.Template
}
// NewGenerator creates a generator with the default embedded template
func NewGenerator() (*Generator, error) {
defaultTmpl, err := templates.GetDefaultTemplate()
if err != nil {
return nil, fmt.Errorf("failed to get default template: %w", err)
}
return NewGeneratorWithTemplate(defaultTmpl)
}
// NewGeneratorFromFile creates a generator from a template file
func NewGeneratorFromFile(templatePath string) (*Generator, error) {
content, err := os.ReadFile(templatePath)
if err != nil {
return nil, fmt.Errorf("failed to read template file: %w", err)
}
return NewGeneratorWithTemplate(string(content))
}
// NewGeneratorWithTemplate creates a generator with a custom template string
func NewGeneratorWithTemplate(tmplStr string) (*Generator, error) {
// Define template functions
funcMap := template.FuncMap{
"formatDate": func(t time.Time, format string) string {
return t.Format(format)
},
"join": strings.Join,
"hasContent": func(s string) bool {
return strings.TrimSpace(s) != ""
},
}
tmpl, err := template.New("output").Funcs(funcMap).Parse(tmplStr)
if err != nil {
return nil, fmt.Errorf("failed to parse template: %w", err)
}
return &Generator{template: tmpl}, nil
}
// TemplateData holds data passed to templates
type TemplateData struct {
Title string
Generated string
Items []Item
// Add your domain-specific fields here
}
// Generate executes the template with data and writes to writer
func (g *Generator) Generate(w io.Writer, data TemplateData) error {
if err := g.template.Execute(w, data); err != nil {
return fmt.Errorf("failed to execute template: %w", err)
}
return nil
}
3. Create Init Command
Use the init.go.template from the skill, customizing the defaultConfigContent for your project's needs.
Key features:
- Creates config file with documented options
- Creates customizable template file from embedded default
- Supports
--forceto overwrite - Supports
--template-fileto specify custom filename - Provides helpful next steps
4. Integrate with Commands
In your command that generates output:
func runFetch(cmd *cobra.Command, args []string) error {
logger := GetLogger()
// ... fetch your data ...
// Create generator with custom template or default
templatePath := viper.GetString("fetch.template")
var generator *generator.Generator
var err error
if templatePath != "" {
logger.Infof("Using custom template: %s", templatePath)
generator, err = generator.NewGeneratorFromFile(templatePath)
if err != nil {
return fmt.Errorf("failed to load custom template: %w", err)
}
} else {
generator, err = generator.NewGenerator()
if err != nil {
return fmt.Errorf("failed to create generator: %w", err)
}
}
// Prepare template data
data := generator.TemplateData{
Title: viper.GetString("fetch.title"),
Generated: time.Now().Format(time.RFC3339),
Items: fetchedItems,
}
// Determine output destination
outputPath := viper.GetString("fetch.output")
var output *os.File
if outputPath != "" {
output, err = os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer output.Close()
logger.Infof("Writing output to %s", outputPath)
} else {
output = os.Stdout
}
// Generate output
if err := generator.Generate(output, data); err != nil {
return fmt.Errorf("failed to generate output: %w", err)
}
return nil
}
5. Add Configuration Support
In internal/config/config.go:
type Config struct {
// ... other config ...
Fetch struct {
Output string
Title string
Template string // Path to custom template file
}
}
In your command's flags:
fetchCmd.Flags().String("template", "", "Custom template file (default: built-in template)")
_ = viper.BindPFlag("fetch.template", fetchCmd.Flags().Lookup("template"))
In config YAML:
fetch:
output: "output.md"
title: "My Output"
template: "my-custom-template.md" # Optional
Template Functions
Provide helpful template functions for common operations:
funcMap := template.FuncMap{
// Date formatting
"formatDate": func(t time.Time, format string) string {
return t.Format(format)
},
// String operations
"join": strings.Join,
"hasContent": func(s string) bool {
return strings.TrimSpace(s) != ""
},
"truncate": func(s string, length int) string {
if len(s) <= length {
return s
}
return s[:length] + "..."
},
// Conditional helpers
"default": func(defaultVal, val interface{}) interface{} {
if val == nil || val == "" {
return defaultVal
}
return val
},
}
User Workflow
First-Time Setup
# User initializes config and template
$ my-tool init
✅ Initialization complete!
Next steps:
1. Edit my-tool.yaml and add your configuration
2. (Optional) Customize my-tool.md for your preferred output format
3. Run: my-tool fetch --help for usage information
Using Default Template
# Just works with embedded default
$ my-tool fetch --output result.md
Using Custom Template
# After editing my-tool.md
$ my-tool fetch --template my-tool.md --output result.md
# Or via config file
$ cat my-tool.yaml
fetch:
template: "my-tool.md"
$ my-tool fetch --output result.md
Best Practices
- Always provide a sensible default template - Tool should work without customization
- Document template variables - In README and/or generated template comments
- Validate templates early - Parse template when creating generator, not during execution
- Provide helpful error messages - Template parse errors should show line numbers
- Include examples - Show template snippets in documentation
- Support both stdout and file output - Enables piping and integration
- Make template optional - Config file should work without template field set
Template Documentation
In your README, document:
Available Variables
### Template Variables
- `.Title` - Document title (string)
- `.Generated` - Generation timestamp (string)
- `.Items` - Array of items to include
### Item Fields
Each item has:
- `.Name` - Item name (string)
- `.Description` - Item description (string)
- `.Tags` - Array of tags ([]string)
Available Functions
### Template Functions
- `formatDate <time> <format>` - Format time.Time with Go time format
- `join <slice> <separator>` - Join string slice
- `hasContent <string>` - Check if string is non-empty
Example Template
Include a complete working example users can copy/paste.
Testing Templates
func TestTemplateExecution(t *testing.T) {
tmpl := `{{ .Title }}
{{ range .Items }}{{ .Name }}{{ end }}`
gen, err := NewGeneratorWithTemplate(tmpl)
if err != nil {
t.Fatalf("failed to create generator: %v", err)
}
data := TemplateData{
Title: "Test",
Items: []Item{{Name: "Item1"}, {Name: "Item2"}},
}
var buf bytes.Buffer
if err := gen.Generate(&buf, data); err != nil {
t.Fatalf("failed to generate: %v", err)
}
expected := "Test\nItem1Item2"
if buf.String() != expected {
t.Errorf("expected %q, got %q", expected, buf.String())
}
}
Common Pitfalls
- Not using
//go:embed- Requires users to distribute template files separately - No template validation - Errors appear late during execution
- Poor error messages - Template errors can be cryptic, add context
- Forgetting
defer file.Close()- When writing to files - Not supporting stdout - Reduces composability with other tools
- Hardcoded paths - Use relative paths or make configurable
Examples in the Wild
- linkding-to-markdown - Bookmarks to Markdown with grouping options
- mastodon-to-markdown - Posts to Markdown with media handling
- feedspool-go - RSS/Atom feed processing with custom templates