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:
1 parent
77854ee0ed
commit
0bf982ffb9
30 files changed
+1274
-540
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user