refactor(tools): modularize CLI with urfave/cli/v2, externalise template metadata

- Split monolithic build-preview.go into modular packages:
  tools.go (entry), cli/ (list/create/delete/preview commands),
  config/ (JSON loading), preview/ (rendering engine, funcs, locale)
- Extract all template metadata into data/templates_config.json
  as single source of truth — no hardcoded template data in Go
- Replace hand-rolled CLI parsing with github.com/urfave/cli/v2
- Add sensible defaults for --folder (../themes) and --config
  (./data/templates_config.json) — most commands now run bare
- Add type-coercing gt/lt/ge/le template funcs for JSON float64
- Fix AppUrl to gitea.com matching official template convention
- rendered.js now exports __REGISTRY__ and __PARAMS__ alongside
  __RENDERED__ — preview/index.html reads all from single source
- Cross-validate all 11 template data contexts against official
  Gitea source (services/mailer/*.go + templates/mail/*.tmpl)
- Update all docs (README, AGENTS, CONTRIBUTING, 5 languages)
  to reflect new CLI workflow with create scaffolding command

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
KenanZhuandClaude Opus 4.8 committed 2026-06-04 12:26:44 +08:00
1 parent 77854ee0ed
commit 0bf982ffb9
30 files changed
+1274 -540

No files matched your search

+285
View File
@@ -0,0 +1,285 @@
package preview
import (
"encoding/json"
"fmt"
"html/template"
"log"
"os"
"path/filepath"
"strings"
"time"
"gitea-mail-templates/tools/config"
)
// RenderResult holds the rendered HTML for a single template in a single theme.
type RenderResult struct {
HTML string
Size int64 // bytes of the rendered HTML
Error string
Skipped bool // true when the template file was not found
}
// ThemeSummary collects statistics for one theme directory.
type ThemeSummary struct {
Name string
TotalFiles int
TotalSize int64 // total size of .tmpl source files
RenderedSize int64 // total size of rendered HTML
RenderedCount int
MissingCount int
ErrorCount int
RenderDuration time.Duration
Missing []string // template IDs that were not found
Errors []string // template IDs that had render errors
Results map[string]*RenderResult // per-template results
}
// PreviewResult is the full output from a preview run.
type PreviewResult struct {
Rendered map[string]map[string]string `json:"rendered"`
Registry map[string]map[string]string `json:"registry"`
Params map[string]map[string]string `json:"params"`
Summaries []ThemeSummary `json:"-"`
}
// DiscoverThemes scans a directory and returns all subdirectory names.
func DiscoverThemes(themesDir string) ([]string, error) {
entries, err := os.ReadDir(themesDir)
if err != nil {
return nil, fmt.Errorf("cannot read themes directory %s: %w", themesDir, err)
}
var names []string
for _, e := range entries {
if e.IsDir() {
names = append(names, e.Name())
}
}
if len(names) == 0 {
return nil, fmt.Errorf("no theme directories found in %s", themesDir)
}
return names, nil
}
// RenderAll renders all templates from the config for all discovered themes.
// If themeFilter is non-empty, only themes in the filter set are rendered.
func RenderAll(themesDir string, cfg *config.TemplatesConfig, themeFilter map[string]bool) *PreviewResult {
themes, err := DiscoverThemes(themesDir)
if err != nil {
log.Printf("ERROR: %v", err)
return nil
}
// Apply theme filter
if len(themeFilter) > 0 {
filtered := themes[:0]
for _, t := range themes {
if themeFilter[t] {
filtered = append(filtered, t)
}
}
themes = filtered
if len(themes) == 0 {
log.Printf("WARN: no themes matched the requested filter")
return nil
}
}
result := &PreviewResult{
Rendered: make(map[string]map[string]string),
Registry: config.Registry(cfg),
}
// Build flattened params for the preview panel
result.Params = make(map[string]map[string]string, len(cfg.Templates))
for id, t := range cfg.Templates {
result.Params[id] = config.FlattenParams(t.Params)
}
for _, themeName := range themes {
themeDir := filepath.Join(themesDir, themeName)
summary := renderTheme(themeDir, themeName, cfg)
result.Summaries = append(result.Summaries, summary)
if result.Rendered[themeName] == nil {
result.Rendered[themeName] = make(map[string]string)
}
for tplID, rr := range summary.Results {
if rr.Error != "" {
result.Rendered[themeName][tplID] = fmt.Sprintf(
"<p style='color:red'>Render error for %s: %s</p>", tplID, rr.Error)
} else if rr.Skipped {
result.Rendered[themeName][tplID] = fmt.Sprintf(
"<p style='color:red'>Template not found: %s</p>", tplID)
} else {
result.Rendered[themeName][tplID] = rr.HTML
}
}
}
return result
}
func renderTheme(themeDir, themeName string, cfg *config.TemplatesConfig) ThemeSummary {
summary := ThemeSummary{
Name: themeName,
Results: make(map[string]*RenderResult),
}
start := time.Now()
for tplID, tplCfg := range cfg.Templates {
tmplRelPath := tplCfg.PathStr()
tmplAbsPath := filepath.Join(themeDir, tmplRelPath)
// Check source file existence
info, err := os.Stat(tmplAbsPath)
if os.IsNotExist(err) {
summary.MissingCount++
summary.Missing = append(summary.Missing, tplID)
summary.Results[tplID] = &RenderResult{Skipped: true}
continue
}
if err != nil {
summary.ErrorCount++
summary.Errors = append(summary.Errors, tplID)
summary.Results[tplID] = &RenderResult{Error: err.Error()}
continue
}
summary.TotalFiles++
summary.TotalSize += info.Size()
// Read template content
tmplContent, err := os.ReadFile(tmplAbsPath)
if err != nil {
summary.ErrorCount++
summary.Errors = append(summary.Errors, tplID)
summary.Results[tplID] = &RenderResult{Error: fmt.Sprintf("read error: %v", err)}
continue
}
// Build data context
data := make(map[string]any)
for k, v := range tplCfg.Params {
data[k] = v
}
data["locale"] = Locale{}
// Parse and execute
tmpl, err := template.New(tplID).Funcs(TemplateFuncs).Parse(string(tmplContent))
if err != nil {
summary.ErrorCount++
summary.Errors = append(summary.Errors, tplID)
summary.Results[tplID] = &RenderResult{Error: fmt.Sprintf("parse error: %v", err)}
continue
}
var buf strings.Builder
if err := tmpl.Execute(&buf, data); err != nil {
summary.ErrorCount++
summary.Errors = append(summary.Errors, tplID)
summary.Results[tplID] = &RenderResult{Error: fmt.Sprintf("render error: %v", err)}
continue
}
rendered := buf.String()
summary.RenderedCount++
summary.RenderedSize += int64(len(rendered))
summary.Results[tplID] = &RenderResult{
HTML: rendered,
Size: int64(len(rendered)),
}
}
summary.RenderDuration = time.Since(start)
return summary
}
// WriteRenderedJS writes the preview result to a JavaScript file loaded by preview/index.html.
func WriteRenderedJS(result *PreviewResult, outputPath string) error {
dir := filepath.Dir(outputPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("cannot create output directory %s: %w", dir, err)
}
outFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("cannot create output file %s: %w", outputPath, err)
}
defer outFile.Close()
outFile.WriteString("// Auto-generated by tools/tools.go — do not edit\n")
// Write __RENDERED__
renderedJSON, err := json.MarshalIndent(result.Rendered, "", " ")
if err != nil {
return fmt.Errorf("cannot marshal rendered data: %w", err)
}
outFile.WriteString("window.__RENDERED__ = ")
outFile.Write(renderedJSON)
outFile.WriteString(";\n\n")
// Write __REGISTRY__
registryJSON, err := json.MarshalIndent(result.Registry, "", " ")
if err != nil {
return fmt.Errorf("cannot marshal registry data: %w", err)
}
outFile.WriteString("window.__REGISTRY__ = ")
outFile.Write(registryJSON)
outFile.WriteString(";\n\n")
// Write __PARAMS__
paramsJSON, err := json.MarshalIndent(result.Params, "", " ")
if err != nil {
return fmt.Errorf("cannot marshal params data: %w", err)
}
outFile.WriteString("window.__PARAMS__ = ")
outFile.Write(paramsJSON)
outFile.WriteString(";\n")
return nil
}
// PrintDetailedSummary prints a human-readable summary of the preview results.
func PrintDetailedSummary(result *PreviewResult, themesDir string, cfg *config.TemplatesConfig) {
absDir, _ := filepath.Abs(themesDir)
fmt.Printf("\nfound %d styles of possible Gitea mail template in '%s' folder:\n\n",
len(result.Summaries), filepath.Base(absDir))
for _, s := range result.Summaries {
if s.MissingCount == 0 && s.ErrorCount == 0 {
fmt.Printf(" %-16s all required mail templates are properly rendered, (total %d .tmpl files, %s after, %v)\n",
s.Name, s.TotalFiles,
formatSize(s.RenderedSize),
s.RenderDuration.Round(time.Millisecond))
} else {
fmt.Printf(" %-16s only %d required mail templates are properly rendered:\n",
s.Name, s.RenderedCount)
for _, tplID := range s.Missing {
if t, ok := cfg.Templates[tplID]; ok {
fmt.Printf(" [%s] not found, expected at 'themes/%s/%s'\n",
filepath.Base(t.PathStr()), s.Name, t.PathStr())
} else {
fmt.Printf(" [%s] not found\n", tplID)
}
}
for _, tplID := range s.Errors {
if rr, ok := s.Results[tplID]; ok && rr.Error != "" {
fmt.Printf(" [%s] render error: %s\n", tplID, rr.Error)
} else {
fmt.Printf(" [%s] render error\n", tplID)
}
}
}
}
fmt.Println()
}
func formatSize(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%dB", bytes)
}
return fmt.Sprintf("%dKiB", bytes/unit)
}
+159
View File
@@ -0,0 +1,159 @@
package preview
import (
"fmt"
"html/template"
"net/url"
"strings"
)
// TemplateFuncs maps Gitea's built-in template functions available in mail templates.
var TemplateFuncs = template.FuncMap{
"AppUrl": func() string { return "https://gitea.com/" },
"AppName": func() string { return "Gitea" },
"DotEscape": func(s string) template.HTML { return template.HTML(template.HTMLEscapeString(s)) },
"QueryEscape": url.QueryEscape,
"ShortSha": ShortSha,
"PathEscapeSegments": PathEscapeSegments,
"FileSize": FileSize,
"HTMLFormat": HTMLFormat,
"Dict": Dict,
"gt": Gt, // override built-in for JSON float64/int coercion
"lt": Lt,
"ge": Ge,
"le": Le,
}
// ShortSha truncates a commit SHA to 10 characters.
func ShortSha(s string) string {
if len(s) > 10 {
return s[:10]
}
return s
}
// PathEscapeSegments splits a path and URL-escapes each segment.
func PathEscapeSegments(s string) string {
parts := strings.Split(s, "/")
for i, p := range parts {
parts[i] = url.PathEscape(p)
}
return strings.Join(parts, "/")
}
// FileSize formats a byte count as a human-readable string (e.g. "2.5 MB").
func FileSize(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := int64(unit), 0
for nn := n / unit; nn >= unit && exp < 3; nn /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %s", float64(n)/float64(div), []string{"KB", "MB", "GB", "TB"}[exp])
}
// HTMLFormat substitutes %s/%d placeholders with HTML-escaped values.
func HTMLFormat(fmtStr string, args ...any) template.HTML {
result := fmtStr
for i, arg := range args {
escaped := template.HTMLEscapeString(fmt.Sprint(arg))
result = strings.Replace(result, fmt.Sprintf("%%[%d]s", i+1), escaped, 1)
if !strings.Contains(result, fmt.Sprintf("%%[%d]s", i+1)) {
result = strings.Replace(result, "%s", escaped, 1)
result = strings.Replace(result, "%d", fmt.Sprint(arg), 1)
}
}
return template.HTML(result)
}
// Dict builds a map from alternating key/value pairs (used by Gitea's Dict function).
func Dict(values ...any) (map[string]any, error) {
if len(values)%2 != 0 {
return nil, fmt.Errorf("odd number of Dict args")
}
dict := make(map[string]any, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, fmt.Errorf("Dict keys must be strings")
}
dict[key] = values[i+1]
}
return dict, nil
}
// toFloat64 coerces a value to float64 if numeric.
func toFloat64(v any) (float64, bool) {
switch val := v.(type) {
case float64:
return val, true
case float32:
return float64(val), true
case int:
return float64(val), true
case int64:
return float64(val), true
case int32:
return float64(val), true
case int16:
return float64(val), true
case int8:
return float64(val), true
case uint:
return float64(val), true
case uint64:
return float64(val), true
case uint32:
return float64(val), true
case uint16:
return float64(val), true
case uint8:
return float64(val), true
default:
return 0, false
}
}
// Gt is a type-coercing > comparison that handles JSON float64 vs int literals.
func Gt(a, b any) bool {
af, aok := toFloat64(a)
bf, bok := toFloat64(b)
if aok && bok {
return af > bf
}
// Fallback to string comparison
return fmt.Sprint(a) > fmt.Sprint(b)
}
// Lt is a type-coercing < comparison that handles JSON float64 vs int literals.
func Lt(a, b any) bool {
af, aok := toFloat64(a)
bf, bok := toFloat64(b)
if aok && bok {
return af < bf
}
return fmt.Sprint(a) < fmt.Sprint(b)
}
// Ge is a type-coercing >= comparison.
func Ge(a, b any) bool {
af, aok := toFloat64(a)
bf, bok := toFloat64(b)
if aok && bok {
return af >= bf
}
return fmt.Sprint(a) >= fmt.Sprint(b)
}
// Le is a type-coercing <= comparison.
func Le(a, b any) bool {
af, aok := toFloat64(a)
bf, bok := toFloat64(b)
if aok && bok {
return af <= bf
}
return fmt.Sprint(a) <= fmt.Sprint(b)
}
+96
View File
@@ -0,0 +1,96 @@
package preview
import (
"fmt"
"html/template"
"strings"
)
// Locale provides English fallback translations, matching Gitea's locale.Tr interface.
type Locale struct{}
// Tr translates a key and substitutes positional %s/%d arguments.
// HTML arguments are preserved; plain strings are HTML-escaped.
func (l Locale) Tr(key string, args ...any) template.HTML {
s := LocaleEN[key]
if s == "" {
s = key
}
for _, arg := range args {
var val string
if h, ok := arg.(template.HTML); ok {
val = string(h) // already safe HTML — don't double-escape
} else {
val = template.HTMLEscapeString(fmt.Sprint(arg))
}
s = strings.Replace(s, "%s", val, 1)
s = strings.Replace(s, "%d", fmt.Sprint(arg), 1)
}
return template.HTML(s)
}
// TrN picks singular/plural by count, then delegates to Tr.
func (l Locale) TrN(count any, singular, plural string, args ...any) template.HTML {
n := 0
switch v := count.(type) {
case int:
n = v
case int64:
n = int(v)
case float64:
n = int(v)
default:
n = 1
}
key := plural
if n == 1 {
key = singular
}
return l.Tr(key, args...)
}
// LocaleEN holds all English translation strings used by Gitea mail templates.
var LocaleEN = map[string]string{
"mail.activate_account.title": "Activate your %s account",
"mail.hi_user_x": "Hi %s,",
"mail.activate_account.text_1": "Welcome to %s! Please activate your account by clicking the button below.",
"mail.activate_account.text_2": "This activation link will expire in %s.",
"mail.activate_email.title": "Verify your email address for %s",
"mail.activate_email.text": "Please verify your email address. The link will expire in %s.",
"mail.register_notify.title": "Welcome to %s, %s",
"mail.register_notify.text_1": "Your account on %s has been created successfully.",
"mail.register_notify.text_2": "Your username is: %s",
"mail.register_notify.text_3": "If you need to set a password, visit %s.",
"mail.reset_password.title": "Reset your %s password",
"mail.reset_password.text": "Click the link below to reset your password. It will expire in %s.",
"mail.link_not_working_do_paste": "If the button doesn't work, copy and paste this link into your browser:",
"mail.team_invite.text_1": "%s has invited you to join the team %s in the organization %s.",
"mail.team_invite.text_2": "Click the button below to accept the invitation.",
"mail.team_invite.text_3": "This invitation was sent to %s.",
"mail.repo.collaborator.added.text": "You have been added as a collaborator on the repository",
"mail.view_it_on": "View it on %s",
"mail.repo.transfer.body": "The repository %s has been transferred to you.",
"mail.release.new.text": "%s published a new release %s in the repository %s.",
"mail.release.title": "Release: %s",
"mail.release.note": "Release Notes:",
"mail.release.downloads": "Downloads",
"mail.release.download.zip": "Source Code (ZIP)",
"mail.release.download.targz": "Source Code (TAR.GZ)",
"mail.issue_assigned.pull": "%s assigned pull request %s to you in the repository %s.",
"mail.issue_assigned.issue": "%s assigned issue %s to you in the repository %s.",
"mail.issue.x_mentioned_you": "%s mentioned you.",
"mail.issue.action.close": "%s closed issue #%d.",
"mail.issue.action.reopen": "%s reopened issue #%d.",
"mail.issue.action.merge": "%s merged pull request #%d into %s.",
"mail.issue.action.approve": "%s approved this change.",
"mail.issue.action.reject": "%s requested changes.",
"mail.issue.action.review": "%s reviewed this change.",
"mail.issue.action.review_dismissed": "%s dismissed the review from %s.",
"mail.issue.action.ready_for_review": "%s marked this as ready for review.",
"mail.issue.action.new": "%s created issue #%d.",
"mail.issue.action.force_push": "%s force-pushed the %s branch from %s to %s.",
"mail.issue.action.push_1": "%s pushed %d commit to %s.",
"mail.issue.action.push_n": "%s pushed %d commits to %s.",
"mail.issue.in_tree_path": "In %s:",
"mail.reply": "Reply to this email",
}