diff --git a/.gitignore b/.gitignore index 02ac5bc..8c9ee8b 100644 --- a/.gitignore +++ b/.gitignore @@ -18,5 +18,5 @@ Desktop.ini *.zip *.tar.gz -# Generated preview data (run: go run ./tools/build-preview.go) +# Generated preview data (run: cd tools && go run . preview all) preview/rendered.js diff --git a/AGENTS.md b/AGENTS.md index 7a6d216..6407e67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,15 +7,24 @@ A curated collection of email template themes (10 visual styles) for self-hosted ## Repository Layout ``` -themes/ # Template themes (5 styles, 11 .tmpl each = 55 source files) - horizon/ # Enterprise / Corporate - terminal/ # Developers / Tech - ember/ # Community / Open Source +themes/ # Template themes (10 styles, 11 .tmpl each = 110 source files) + aurora/ # Ethereal / Dreamlike bloom/ # Creative / Startup (glassmorphism) + ember/ # Community / Open Source heritage/ # Education / Research -tools/ # Go build tooling - build-preview.go # Pre-renders all templates into preview/rendered.js - go.mod # Go module (stdlib only, zero dependencies) + horizon/ # Enterprise / Corporate + ink/ # Editorial / Publishing + mono/ # Minimal / Swiss design + neon/ # Cyberpunk / Gaming + terminal/ # Developers / Tech + terra/ # Nature / Sustainability +tools/ # Go CLI tooling (modular, zero dependencies) + tools.go # Main entry point + cli/ # CLI subcommands: list, create, delete, preview + config/ # Config types and templates_config.json loading + data/ # templates_config.json — single source of truth for template metadata + preview/ # Template rendering engine (funcs, locale, engine) + go.mod # Go module (stdlib only, zero dependencies) preview/ # Browser-based live preview index.html # SPA with style/template/client/viewport switching rendered.js # Pre-rendered HTML (generated, committed for clone-and-preview) @@ -32,30 +41,34 @@ docs/ # Multi-language documentation - Each style must have all 11 template types ### Adding a New Theme -1. Create `themes//` with the full `mail/` directory structure +1. Scaffold the new theme: `cd tools && go run . create ` — creates the full directory structure with placeholder `.tmpl` files for all 11 email types 2. Write all 11 `.tmpl` files with unique visual design -3. Run `go run ./tools/build-preview.go` to regenerate preview data -4. Add the theme to the `` in `preview/index.html` -6. Submit a PR with screenshots of rendered emails +1. Scaffold the new style: `cd tools && go run . create ` — this creates the directory structure with placeholder `.tmpl` files for all 11 email types +2. Edit each `.tmpl` file in `themes//` with your unique visual design +3. Regenerate the preview: `cd tools && go run . preview all` — the build script auto-discovers all theme directories under `themes/` and generates the theme selector dynamically +4. Submit a PR with screenshots of rendered emails ### Style Guidelines @@ -31,7 +29,7 @@ If a template doesn't render correctly: 1. Check that all referenced Go template variables exist — compare against the Gitea source mail templates 2. Verify translation keys match Gitea's locale files 3. Confirm `.DisplayName` isn't used in templates that lack it -4. Regenerate the preview: `go run ./tools/build-preview.go` +4. Regenerate the preview: `cd tools && go run . preview all` 5. Open an issue with: the style name, which email type, and the error or unexpected output ### Documentation Improvements @@ -53,10 +51,10 @@ No build tools or dependencies are needed — these are raw Go HTML templates. ### Regenerating Previews ```bash -go run ./tools/build-preview.go +cd tools && go run . preview all ``` -This renders all templates (themes auto-discovered from the themes/ directory) using Go's native `html/template` package and writes the output to `preview/rendered.js`. +This renders all templates (themes auto-discovered from the themes/ directory) using Go's native `html/template` package and writes the output to `preview/rendered.js`. The `--folder` and `--config` flags default to `../themes` and `./data/templates_config.json` respectively — override them only when using a custom layout. ### Integration Testing diff --git a/README.md b/README.md index 738f630..c1e0bc9 100644 --- a/README.md +++ b/README.md @@ -85,16 +85,16 @@ A live preview tool is included to browse all styles and email types without dep First, generate the preview data (requires Go): ```bash -go run ./tools/build-preview.go +cd tools && go run . preview all ``` Then open `preview/index.html` directly in a browser. No server required. -> `preview/rendered.js` is generated and git-ignored. Re-run the command after modifying templates. +> `preview/rendered.js` is generated and git-ignored. Re-run `cd tools && go run . preview all` after modifying templates. Use `cd tools && go run .` to see all commands (list, create, delete, preview). Most flags have sensible defaults — just `go run . create ` or `go run . preview all` works out of the box. ### Features -- Theme switcher — toggle between all 5 visual styles +- Theme switcher — toggle between all 10 visual styles - Template switcher — browse all 11 email types - Client simulation — Modern, Gmail (no `\n\n\n"+ + " \n"+ + "\n\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 +} diff --git a/tools/cli/delete.go b/tools/cli/delete.go new file mode 100644 index 0000000..e859e15 --- /dev/null +++ b/tools/cli/delete.go @@ -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 ] ...", + 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 +} diff --git a/tools/cli/list.go b/tools/cli/list.go new file mode 100644 index 0000000..2175d7f --- /dev/null +++ b/tools/cli/list.go @@ -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 ]", + 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 +} diff --git a/tools/cli/preview_cmd.go b/tools/cli/preview_cmd.go new file mode 100644 index 0000000..7237d96 --- /dev/null +++ b/tools/cli/preview_cmd.go @@ -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 ] [--config ] [--output ] all + go run . preview [--folder ] [--config ] ...`, + 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 +} diff --git a/tools/config/config.go b/tools/config/config.go new file mode 100644 index 0000000..88b55c4 --- /dev/null +++ b/tools/config/config.go @@ -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) + } +} diff --git a/tools/data/templates_config.json b/tools/data/templates_config.json new file mode 100644 index 0000000..2374155 --- /dev/null +++ b/tools/data/templates_config.json @@ -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": "

Major new features and performance improvements in this release.

", + "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": [] + } + } + } +} diff --git a/tools/go.mod b/tools/go.mod index a21326a..0ebb8aa 100644 --- a/tools/go.mod +++ b/tools/go.mod @@ -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 +) diff --git a/tools/go.sum b/tools/go.sum new file mode 100644 index 0000000..f749703 --- /dev/null +++ b/tools/go.sum @@ -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= diff --git a/tools/preview/engine.go b/tools/preview/engine.go new file mode 100644 index 0000000..319a1c0 --- /dev/null +++ b/tools/preview/engine.go @@ -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( + "

Render error for %s: %s

", tplID, rr.Error) + } else if rr.Skipped { + result.Rendered[themeName][tplID] = fmt.Sprintf( + "

Template not found: %s

", 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) +} diff --git a/tools/preview/funcs.go b/tools/preview/funcs.go new file mode 100644 index 0000000..ba62607 --- /dev/null +++ b/tools/preview/funcs.go @@ -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) +} diff --git a/tools/preview/locale.go b/tools/preview/locale.go new file mode 100644 index 0000000..3d98313 --- /dev/null +++ b/tools/preview/locale.go @@ -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", +} diff --git a/tools/tools.go b/tools/tools.go new file mode 100644 index 0000000..6a2bbca --- /dev/null +++ b/tools/tools.go @@ -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 [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) + } +}