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

+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)
}