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

-399
View File
@@ -1,399 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"html/template"
"log"
"net/url"
"os"
"path/filepath"
"strings"
)
// TemplateFuncs maps Gitea's built-in template functions
var TemplateFuncs = template.FuncMap{
"AppUrl": func() string { return "https://about.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,
}
func ShortSha(s string) string {
if len(s) > 10 { return s[:10] }
return s
}
func PathEscapeSegments(s string) string {
parts := strings.Split(s, "/")
for i, p := range parts { parts[i] = url.PathEscape(p) }
return strings.Join(parts, "/")
}
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])
}
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)
}
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
}
// Locale provides English fallback translations
type Locale struct{}
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)
}
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...)
}
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",
}
// MockData for each template type
type TemplateDef struct {
Path string
Data map[string]any
}
var templates = map[string]TemplateDef{
"activate": {
Path: "mail/user/auth/activate.tmpl",
Data: map[string]any{
"DisplayName": "Alex Johnson",
"Code": "activation-code-abc123",
"ActiveCodeLives": "24 hours",
},
},
"activate_email": {
Path: "mail/user/auth/activate_email.tmpl",
Data: map[string]any{
"DisplayName": "Alex Johnson",
"Code": "verify-code-xyz789",
"Email": "alex@example.com",
"ActiveCodeLives": "24 hours",
},
},
"register_notify": {
Path: "mail/user/auth/register_notify.tmpl",
Data: map[string]any{
"DisplayName": "Alex Johnson",
"Username": "alexj",
},
},
"reset_passwd": {
Path: "mail/user/auth/reset_passwd.tmpl",
Data: map[string]any{
"DisplayName": "Alex Johnson",
"Code": "reset-token-abc123",
"ResetPwdCodeLives": "3 hours",
},
},
"team_invite": {
Path: "mail/org/team_invite.tmpl",
Data: map[string]any{
"Inviter": map[string]any{"DisplayName": "Sarah Chen"},
"Team": map[string]any{"Name": "Core Developers"},
"Organization": map[string]any{"DisplayName": "Acme Corp"},
"InviteURL": "https://gitea.example.com/org/team/invite?token=invite-abc",
"Invite": map[string]any{"Email": "alex@example.com"},
},
},
"collaborator": {
Path: "mail/repo/collaborator.tmpl",
Data: map[string]any{
"Subject": "Collaborator Added to repo",
"Link": "https://gitea.example.com/repo/project",
"RepoName": "acme/project",
},
},
"transfer": {
Path: "mail/repo/transfer.tmpl",
Data: map[string]any{
"Subject": "Repository Transfer",
"Link": "https://gitea.example.com/repo/transferred-project",
"Repo": "acme/transferred-project",
},
},
"release": {
Path: "mail/repo/release.tmpl",
Data: map[string]any{
"Subject": "New Release v2.0.0",
"Link": "https://gitea.example.com/repo/project/releases/tag/v2.0.0",
"Release": map[string]any{
"Publisher": map[string]any{"Name": "Sarah Chen"},
"HTMLURL": "https://gitea.example.com/repo/project/releases/tag/v2.0.0",
"TagName": "v2.0.0",
"Title": "Version 2.0 -- Major Update",
"RenderedNote": "<p>Major new features and performance improvements in this release.</p>",
"Repo": map[string]any{
"HTMLURL": "https://gitea.example.com/repo/project",
"FullName": "acme/project",
},
"Attachments": []any{},
},
"DisableDownloadSourceArchives": false,
},
},
"workflow_run": {
Path: "mail/repo/actions/workflow_run.tmpl",
Data: map[string]any{
"Subject": "Workflow Run: CI",
"RunStatusText": "All jobs passed",
"Repo": map[string]any{"FullName": "acme/project"},
"Run": map[string]any{
"WorkflowID": "ci.yml",
"HTMLURL": "https://gitea.example.com/repo/project/actions/runs/42",
},
"Jobs": []map[string]any{
{"Name": "build", "Status": "success", "Attempt": 1, "HTMLURL": "https://gitea.example.com/job/1", "Duration": "2m34s"},
{"Name": "test", "Status": "success", "Attempt": 1, "HTMLURL": "https://gitea.example.com/job/2", "Duration": "1m12s"},
{"Name": "deploy", "Status": "success", "Attempt": 2, "HTMLURL": "https://gitea.example.com/job/3", "Duration": "45s"},
},
},
},
"assigned": {
Path: "mail/repo/issue/assigned.tmpl",
Data: map[string]any{
"Subject": "Issue #42 Assigned",
"Link": "https://gitea.example.com/repo/project/issues/42",
"Doer": map[string]any{"Name": "Sarah Chen"},
"IsPull": false,
"Issue": map[string]any{
"Index": 42,
"Repo": map[string]any{
"HTMLURL": "https://gitea.example.com/repo/project",
"FullName": "acme/project",
},
},
},
},
"default": {
Path: "mail/repo/issue/default.tmpl",
Data: map[string]any{
"Subject": "Re: Issue #42 -- Bug fix",
"Link": "https://gitea.example.com/repo/project/issues/42",
"ActionName": "comment",
"Doer": map[string]any{"Name": "Sarah Chen"},
"Body": "This is a comment on the issue with details about the fix.",
"CanReply": true,
"IsMention": false,
"IsPull": false,
"Issue": map[string]any{
"Index": 42,
"PullRequest": map[string]any{
"BaseBranch": "main",
"BaseRepo": map[string]any{
"HTMLURL": "https://gitea.example.com/repo/project",
},
"HeadBranch": "feature/fix",
},
},
"Comment": map[string]any{
"Commits": []any{},
"IsForcePush": false,
"Issue": map[string]any{
"PullRequest": map[string]any{
"BaseRepo": map[string]any{
"HTMLURL": "https://gitea.example.com/repo/project",
},
"HeadBranch": "feature/fix",
},
},
},
"ReviewComments": []any{},
},
},
}
// discoverThemes scans the themes directory and returns all subdirectory names.
func discoverThemes(themesDir string) []string {
entries, err := os.ReadDir(themesDir)
if err != nil {
log.Fatalf("Cannot read themes directory %s: %v", themesDir, err)
}
var themes []string
for _, e := range entries {
if e.IsDir() {
themes = append(themes, e.Name())
}
}
if len(themes) == 0 {
log.Fatalf("No theme directories found in %s", themesDir)
}
return themes
}
func main() {
exe, _ := os.Executable()
rootDir := filepath.Dir(exe)
// When run with `go run`, use CWD's parent
if _, err := os.Stat(filepath.Join(rootDir, "themes")); os.IsNotExist(err) {
cwd, _ := os.Getwd()
// Try cwd, then cwd's parent
for _, d := range []string{cwd, filepath.Dir(cwd)} {
if _, e := os.Stat(filepath.Join(d, "themes")); e == nil { rootDir = d; break }
}
}
themesDir := filepath.Join(rootDir, "themes")
previewDir := filepath.Join(rootDir, "preview")
result := make(map[string]map[string]string)
themes := discoverThemes(themesDir)
for _, theme := range themes {
themeDir := filepath.Join(themesDir, theme)
result[theme] = make(map[string]string)
for tplID, tplDef := range templates {
tmplPath := filepath.Join(themeDir, tplDef.Path)
tmplContent, err := os.ReadFile(tmplPath)
if err != nil {
log.Printf("WARN: cannot read %s: %v", tmplPath, err)
result[theme][tplID] = fmt.Sprintf("<p style='color:red'>Failed to read template: %s</p>", err)
continue
}
// Build data context
data := make(map[string]any)
for k, v := range tplDef.Data {
data[k] = v
}
data["locale"] = Locale{}
// Parse and execute
tmpl, err := template.New(tplID).Funcs(TemplateFuncs).Parse(string(tmplContent))
if err != nil {
log.Printf("WARN: parse error %s/%s: %v", theme, tplID, err)
result[theme][tplID] = fmt.Sprintf("<p style='color:red'>Parse error: %s</p>", err)
continue
}
var buf strings.Builder
if err := tmpl.Execute(&buf, data); err != nil {
log.Printf("WARN: execute error %s/%s: %v", theme, tplID, err)
result[theme][tplID] = fmt.Sprintf("<p style='color:red'>Render error: %s</p>", err)
continue
}
// Fix favicon path for preview (official Gitea icon)
rendered := strings.Replace(buf.String(),
`src="https://about.gitea.com/assets/img/favicon.png"`,
`src="https://about.gitea.com/gitea.svg"`, -1)
result[theme][tplID] = rendered
log.Printf("OK: %s/%s (%d bytes)", theme, tplID, buf.Len())
}
}
// Write output as JS (script-loadable, avoids file:// CORS issues)
os.MkdirAll(previewDir, 0755)
outPath := filepath.Join(previewDir, "rendered.js")
outFile, err := os.Create(outPath)
if err != nil { log.Fatalf("Cannot create output: %v", err) }
defer outFile.Close()
jsonBytes, err := json.MarshalIndent(result, "", " ")
if err != nil { log.Fatalf("JSON marshal error: %v", err) }
outFile.WriteString("// Auto-generated by tools/build-preview.go — do not edit\n")
outFile.WriteString("window.__RENDERED__ = ")
outFile.Write(jsonBytes)
outFile.WriteString(";\n")
log.Printf("Wrote %d themes x %d templates to %s", len(themes), len(templates), outPath)
}
+8
View File
@@ -0,0 +1,8 @@
package cli
// Package cli defines all subcommands for the Gitea Mail Templates CLI tool.
// Command definitions use github.com/urfave/cli/v2.
//
// Shared flag names used across multiple subcommands:
// --folder : path to the themes directory
// --config : path to templates_config.json
+86
View File
@@ -0,0 +1,86 @@
package cli
import (
"fmt"
"os"
"path/filepath"
"github.com/urfave/cli/v2"
"gitea-mail-templates/tools/config"
)
// CreateCommand returns the "create" subcommand.
func CreateCommand() *cli.Command {
return &cli.Command{
Name: "create",
Usage: "Create one or more new theme style directories",
UsageText: "go run . create [--folder <themes-dir>] [--config <config-file>] <style-name>...",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "folder",
Value: "../themes",
Usage: "Path to the themes directory",
},
&cli.StringFlag{
Name: "config",
Value: "./data/templates_config.json",
Usage: "Path to templates_config.json",
},
},
Action: runCreate,
}
}
func runCreate(c *cli.Context) error {
names := c.Args().Slice()
if len(names) == 0 {
return fmt.Errorf("at least one style name is required")
}
folder := c.String("folder")
cfg, err := config.Load(c.String("config"))
if err != nil {
return err
}
for _, styleName := range names {
styleDir := filepath.Join(folder, styleName)
if info, err := os.Stat(styleDir); err == nil {
if info.IsDir() {
fmt.Printf(" [skip] '%s' already exists\n", styleName)
continue
}
return fmt.Errorf("'%s' exists but is not a directory", styleName)
}
created := 0
for _, tplCfg := range cfg.Templates {
tmplPath := filepath.Join(styleDir, tplCfg.PathStr())
dir := filepath.Dir(tmplPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("cannot create directory %s: %w", dir, err)
}
placeholder := fmt.Sprintf("<!DOCTYPE html>\n<html>\n<head>\n"+
" <meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\" />\n"+
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n"+
" <title>{{.locale.Tr \"mail.%s.title\"}}</title>\n"+
" <style type=\"text/css\">\n /* TODO: Add your custom styles here */\n"+
" </style>\n</head>\n<body style=\"margin:0;padding:0;\">\n"+
" <!-- TODO: Add your custom template design for: %s -->\n"+
"</body>\n</html>\n", tplCfg.Name, tplCfg.Desc)
if err := os.WriteFile(tmplPath, []byte(placeholder), 0644); err != nil {
return fmt.Errorf("cannot write %s: %w", tmplPath, err)
}
created++
}
fmt.Printf(" Created style '%s' with %d template files\n", styleName, created)
}
return nil
}
+51
View File
@@ -0,0 +1,51 @@
package cli
import (
"fmt"
"os"
"path/filepath"
"github.com/urfave/cli/v2"
)
// DeleteCommand returns the "delete" subcommand.
func DeleteCommand() *cli.Command {
return &cli.Command{
Name: "delete",
Usage: "Delete one or more theme style directories",
UsageText: "go run . delete [--folder <themes-dir>] <style-name>...",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "folder",
Value: "../themes",
Usage: "Path to the themes directory",
},
},
Action: runDelete,
}
}
func runDelete(c *cli.Context) error {
names := c.Args().Slice()
if len(names) == 0 {
return fmt.Errorf("at least one style name is required")
}
folder := c.String("folder")
for _, styleName := range names {
styleDir := filepath.Join(folder, styleName)
if _, err := os.Stat(styleDir); os.IsNotExist(err) {
fmt.Printf(" [skip] '%s' does not exist\n", styleName)
continue
}
if err := os.RemoveAll(styleDir); err != nil {
return fmt.Errorf("cannot delete %s: %w", styleDir, err)
}
fmt.Printf(" Deleted style '%s'\n", styleName)
}
return nil
}
+57
View File
@@ -0,0 +1,57 @@
package cli
import (
"fmt"
"os"
"path/filepath"
"sort"
"github.com/urfave/cli/v2"
"gitea-mail-templates/tools/preview"
)
// ListCommand returns the "list" subcommand.
func ListCommand() *cli.Command {
return &cli.Command{
Name: "list",
Usage: "List available theme styles in the target folder",
UsageText: "go run . list [--folder <themes-dir>]",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "folder",
Value: "../themes",
Usage: "Path to the themes directory",
},
},
Action: runList,
}
}
func runList(c *cli.Context) error {
folder := c.String("folder")
themes, err := preview.DiscoverThemes(folder)
if err != nil {
return err
}
absDir, _ := filepath.Abs(folder)
fmt.Printf("Available styles in '%s' (%d):\n", filepath.Base(absDir), len(themes))
sort.Strings(themes)
for _, t := range themes {
count := 0
filepath.Walk(filepath.Join(folder, t), func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if filepath.Ext(path) == ".tmpl" {
count++
}
return nil
})
fmt.Printf(" %-16s (%d .tmpl files)\n", t, count)
}
return nil
}
+85
View File
@@ -0,0 +1,85 @@
package cli
import (
"fmt"
"log"
"path/filepath"
"github.com/urfave/cli/v2"
"gitea-mail-templates/tools/config"
"gitea-mail-templates/tools/preview"
)
// PreviewCommand returns the "preview" subcommand.
func PreviewCommand() *cli.Command {
return &cli.Command{
Name: "preview",
Usage: "Pre-render mail templates for preview/testing",
UsageText: `go run . preview [--folder <themes-dir>] [--config <config-file>] [--output <output.js>] all
go run . preview [--folder <themes-dir>] [--config <config-file>] <style-name>...`,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "folder",
Value: "../themes",
Usage: "Path to the themes directory",
},
&cli.StringFlag{
Name: "config",
Value: "./data/templates_config.json",
Usage: "Path to templates_config.json",
},
&cli.StringFlag{
Name: "output",
Usage: "Path to output rendered.js (default: derived from --folder)",
},
},
Action: runPreview,
}
}
func runPreview(c *cli.Context) error {
folder := c.String("folder")
configPath := c.String("config")
outputPath := c.String("output")
if outputPath == "" {
outputPath = filepath.Join(filepath.Dir(folder), "preview", "rendered.js")
}
names := c.Args().Slice()
if len(names) == 0 {
return fmt.Errorf("specify style names or 'all' (e.g. go run . preview all)")
}
cfg, err := config.Load(configPath)
if err != nil {
return err
}
var themeFilter map[string]bool
if len(names) == 1 && names[0] == "all" {
themeFilter = nil
} else {
themeFilter = make(map[string]bool)
for _, name := range names {
themeFilter[name] = true
}
}
result := preview.RenderAll(folder, cfg, themeFilter)
if result == nil {
return fmt.Errorf("no results produced")
}
if err := preview.WriteRenderedJS(result, outputPath); err != nil {
return err
}
preview.PrintDetailedSummary(result, folder, cfg)
log.Printf("Wrote %d themes x %d templates to %s",
len(result.Summaries), len(cfg.Templates), outputPath)
return nil
}
+85
View File
@@ -0,0 +1,85 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// TemplateConfig holds metadata for a single mail template type.
type TemplateConfig struct {
Name string `json:"name"`
Desc string `json:"desc"`
Path []string `json:"path"`
Params map[string]any `json:"params"`
}
// TemplatesConfig is the root structure of templates_config.json.
type TemplatesConfig struct {
Templates map[string]TemplateConfig `json:"templates"`
}
// Load reads and parses a templates_config.json file.
func Load(path string) (*TemplatesConfig, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("cannot read config file %s: %w", path, err)
}
var cfg TemplatesConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("cannot parse config file %s: %w", path, err)
}
if len(cfg.Templates) == 0 {
return nil, fmt.Errorf("config file %s contains no template definitions", path)
}
return &cfg, nil
}
// PathStr returns the file path of a template definition joined with OS separator.
func (t *TemplateConfig) PathStr() string {
return filepath.Join(t.Path...)
}
// Registry builds a lookup map suitable for serializing into rendered.js
// as window.__REGISTRY__: maps template ID → {name, desc, path}.
func Registry(cfg *TemplatesConfig) map[string]map[string]string {
reg := make(map[string]map[string]string, len(cfg.Templates))
for id, t := range cfg.Templates {
reg[id] = map[string]string{
"name": t.Name,
"desc": t.Desc,
"path": strings.Join(t.Path, "/"),
}
}
return reg
}
// FlattenParams converts nested template params into a flat dot-notation map
// suitable for the preview panel display in rendered.js.
func FlattenParams(params map[string]any) map[string]string {
flat := make(map[string]string)
flatten("", params, flat)
return flat
}
func flatten(prefix string, val any, out map[string]string) {
switch v := val.(type) {
case map[string]any:
for k, inner := range v {
key := k
if prefix != "" {
key = prefix + "." + k
}
flatten(key, inner, out)
}
case []any:
out[prefix] = fmt.Sprintf("[%d items]", len(v))
default:
out[prefix] = fmt.Sprintf("%v", v)
}
}
+176
View File
@@ -0,0 +1,176 @@
{
"templates": {
"activate": {
"name": "Activate Account",
"desc": "Account activation — sent when a new user registers.",
"path": ["mail", "user", "auth", "activate.tmpl"],
"params": {
"DisplayName": "Alex Johnson",
"Code": "activation-code-abc123",
"ActiveCodeLives": "24 hours"
}
},
"activate_email": {
"name": "Activate Email",
"desc": "Email address verification — sent when adding or changing an email address.",
"path": ["mail", "user", "auth", "activate_email.tmpl"],
"params": {
"DisplayName": "Alex Johnson",
"Code": "verify-code-xyz789",
"Email": "alex@example.com",
"ActiveCodeLives": "24 hours"
}
},
"register_notify": {
"name": "Register Notify",
"desc": "Registration notification — confirms successful account registration.",
"path": ["mail", "user", "auth", "register_notify.tmpl"],
"params": {
"DisplayName": "Alex Johnson",
"Username": "alexj"
}
},
"reset_passwd": {
"name": "Reset Password",
"desc": "Password reset — sent when user requests a password reset link.",
"path": ["mail", "user", "auth", "reset_passwd.tmpl"],
"params": {
"DisplayName": "Alex Johnson",
"Code": "reset-token-abc123",
"ResetPwdCodeLives": "3 hours"
}
},
"team_invite": {
"name": "Team Invite",
"desc": "Team invitation — sent when a user is invited to join an organization team.",
"path": ["mail", "org", "team_invite.tmpl"],
"params": {
"Inviter": { "DisplayName": "Sarah Chen" },
"Team": { "Name": "Core Developers" },
"Organization": { "DisplayName": "Acme Corp" },
"InviteURL": "https://gitea.example.com/org/team/invite?token=invite-abc",
"Invite": { "Email": "alex@example.com" }
}
},
"collaborator": {
"name": "Collaborator Added",
"desc": "Collaborator added — sent when a user is added as a repository collaborator.",
"path": ["mail", "repo", "collaborator.tmpl"],
"params": {
"Subject": "Collaborator Added to repo",
"Link": "https://gitea.example.com/repo/project",
"RepoName": "acme/project"
}
},
"transfer": {
"name": "Repository Transfer",
"desc": "Repository transfer — sent when repository ownership is transferred.",
"path": ["mail", "repo", "transfer.tmpl"],
"params": {
"Subject": "Repository Transfer",
"Link": "https://gitea.example.com/repo/transferred-project",
"Repo": "acme/transferred-project"
}
},
"release": {
"name": "New Release",
"desc": "New release — sent when a new release is published.",
"path": ["mail", "repo", "release.tmpl"],
"params": {
"Subject": "New Release v2.0.0",
"Link": "https://gitea.example.com/repo/project/releases/tag/v2.0.0",
"Release": {
"Publisher": { "Name": "Sarah Chen" },
"HTMLURL": "https://gitea.example.com/repo/project/releases/tag/v2.0.0",
"TagName": "v2.0.0",
"Title": "Version 2.0 -- Major Update",
"RenderedNote": "<p>Major new features and performance improvements in this release.</p>",
"Repo": {
"HTMLURL": "https://gitea.example.com/repo/project",
"FullName": "acme/project"
},
"Attachments": []
},
"DisableDownloadSourceArchives": false
}
},
"workflow_run": {
"name": "Workflow Run",
"desc": "Workflow run — sent when a CI/CD workflow completes.",
"path": ["mail", "repo", "actions", "workflow_run.tmpl"],
"params": {
"Subject": "Workflow Run: CI",
"RunStatusText": "All jobs passed",
"Repo": { "FullName": "acme/project" },
"Run": {
"WorkflowID": "ci.yml",
"HTMLURL": "https://gitea.example.com/repo/project/actions/runs/42"
},
"Jobs": [
{ "Name": "build", "Status": "success", "Attempt": 1, "HTMLURL": "https://gitea.example.com/job/1", "Duration": "2m34s" },
{ "Name": "test", "Status": "success", "Attempt": 1, "HTMLURL": "https://gitea.example.com/job/2", "Duration": "1m12s" },
{ "Name": "deploy", "Status": "success", "Attempt": 2, "HTMLURL": "https://gitea.example.com/job/3", "Duration": "45s" }
]
}
},
"assigned": {
"name": "Issue Assigned",
"desc": "Issue/PR assigned — sent when an issue or pull request is assigned.",
"path": ["mail", "repo", "issue", "assigned.tmpl"],
"params": {
"Subject": "Issue #42 Assigned",
"Link": "https://gitea.example.com/repo/project/issues/42",
"Doer": { "Name": "Sarah Chen" },
"IsPull": false,
"Issue": {
"Index": 42,
"Repo": {
"HTMLURL": "https://gitea.example.com/repo/project",
"FullName": "acme/project"
}
}
}
},
"default": {
"name": "Issue Update",
"desc": "Issue/PR update — comments, mentions, reviews, merges, and other activity.",
"path": ["mail", "repo", "issue", "default.tmpl"],
"params": {
"Subject": "Re: Issue #42 -- Bug fix",
"Link": "https://gitea.example.com/repo/project/issues/42",
"ActionName": "comment",
"Doer": { "Name": "Sarah Chen" },
"Body": "This is a comment on the issue with details about the fix.",
"CanReply": true,
"IsMention": false,
"IsPull": false,
"Issue": {
"Index": 42,
"PullRequest": {
"BaseBranch": "main",
"BaseRepo": {
"HTMLURL": "https://gitea.example.com/repo/project"
},
"HeadBranch": "feature/fix"
}
},
"Comment": {
"Commits": [],
"IsForcePush": false,
"OldCommit": "",
"NewCommit": "",
"Review": { "Reviewer": { "Name": "Taylor Kim" } },
"Issue": {
"PullRequest": {
"BaseRepo": {
"HTMLURL": "https://gitea.example.com/repo/project"
},
"HeadBranch": "feature/fix"
}
}
},
"ReviewComments": []
}
}
}
}
+7
View File
@@ -1,3 +1,10 @@
module gitea-mail-templates/tools
go 1.21
require (
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/urfave/cli/v2 v2.27.7 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
)
+8
View File
@@ -0,0 +1,8 @@
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
+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",
}
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"fmt"
"os"
"github.com/urfave/cli/v2"
cmds "gitea-mail-templates/tools/cli"
)
func main() {
app := &cli.App{
Name: "tools",
Usage: "Gitea Mail Templates CLI — manage and preview email template themes",
UsageText: "tools <command> [args...] [--flags]",
Commands: []*cli.Command{
cmds.ListCommand(),
cmds.CreateCommand(),
cmds.DeleteCommand(),
cmds.PreviewCommand(),
},
}
if err := app.Run(os.Args); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}