chore: migrate mail themes to shared framework and locked upstream inputs
Release / Validate Templates (push) Canceled after 0s
Release / Package & Release (push) Canceled after 0s
Release / Update Latest Release Documentation (push) Canceled after 0s

This commit is contained in:
KenanZhu committed 2026-10-09 18:34:36 +08:00
1 parent 5c0589f6f6
commit fec3ace600
225 files changed
+4718 -15807

No files matched your search

+249 -270
View File
@@ -1,313 +1,292 @@
package preview
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"html/template"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
texttmpl "text/template"
"gitea-mail-templates/tools/builder"
"gitea-mail-templates/tools/config"
"gitea-mail-templates/tools/upstream"
)
// 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:"-"`
Rendered map[string]map[string]map[string]string
Registry map[string]map[string]string
Params map[string]map[string]string
Languages []string
Themes []string
Upstream string
Commit string
Logo []byte
Fallback map[string][]string
Warnings map[string]map[string][]string
}
// 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)
func DiscoverThemes(dir string) ([]string, error) { return builder.Discover(dir) }
// AlignConfig discovers all entrypoints from the snapshot. Metadata is a fixture
// catalog, not a second list of templates. Uncovered new mail types fail closed.
func AlignConfig(s *upstream.Snapshot, cfg *config.TemplatesConfig) error {
found := map[string]bool{}
for _, p := range s.Entrypoints() {
id := upstream.TemplateID(p)
if found[id] {
return fmt.Errorf("duplicate entrypoint ID %s", id)
}
found[id] = true
fixture, ok := cfg.Templates[id]
if !ok {
return fmt.Errorf("new upstream mail type %s needs a preview fixture", p)
}
fixture.Path = strings.Split(p, "/")
cfg.Templates[id] = fixture
}
var names []string
for _, e := range entries {
if e.IsDir() {
names = append(names, e.Name())
for id := range cfg.Templates {
if !found[id] {
return fmt.Errorf("stale fixture %s is absent from upstream snapshot", id)
}
}
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 {
fmt.Fprintf(os.Stderr, "\033[31m[E]\033[0m [Preview] Error discovering themes: %v\n", 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 {
fmt.Println("\033[33m[W]\033[0m [Preview] 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{}
// Convert HTML-safe fields from string to template.HTML so Go's
// html/template renders them raw instead of escaping. Matches Gitea's
// behavior where RenderedNote / Body / RenderedContent are template.HTML.
markSafeHTML(data)
// 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("\033[32m[I]\033[0m [Preview] Found %d styles in '%s':\n",
len(result.Summaries), filepath.Base(absDir))
for _, s := range result.Summaries {
if s.MissingCount == 0 && s.ErrorCount == 0 {
fmt.Printf("\033[32m[I]\033[0m [Preview] %-16s all required templates OK (total %d .tmpl, %s, %v)\n",
s.Name, s.TotalFiles,
formatSize(s.RenderedSize),
s.RenderDuration.Round(time.Millisecond))
} else {
fmt.Printf("\033[33m[W]\033[0m [Preview] %-16s only %d required templates rendered:\n",
s.Name, s.RenderedCount)
for _, tplID := range s.Missing {
if t, ok := cfg.Templates[tplID]; ok {
fmt.Printf("\033[31m[E]\033[0m [Preview] |-> %s not found, expected at 'themes/%s/%s'\n",
filepath.Base(t.PathStr()), s.Name, t.PathStr())
} else {
fmt.Printf("\033[31m[E]\033[0m [Preview] |-> %s not found\n", tplID)
// RenderAll builds installable overrides and renders every official language.
func RenderAll(themesDir string, cfg *config.TemplatesConfig, filter map[string]bool) (*PreviewResult, error) {
root := filepath.Dir(themesDir)
s, err := upstream.Prepare(context.Background(), root)
if err != nil {
return nil, err
}
if err := AlignConfig(s, cfg); err != nil {
return nil, err
}
packages, err := builder.BuildAll(themesDir, s, filter)
if err != nil {
return nil, err
}
if err := builder.Write(filepath.Join(root, "build", "themes"), s, packages); err != nil {
return nil, err
}
// Include framework-added labels/controls, not only stock mail keys.
reference := *s
reference.Templates = map[string][]byte{}
for name, b := range s.Templates {
reference.Templates[name] = b
}
for _, p := range packages {
for name, b := range p.Files {
reference.Templates[name] = b
}
}
missing, err := upstream.MissingKeys(&reference)
if err != nil {
return nil, err
}
result := &PreviewResult{Rendered: map[string]map[string]map[string]string{}, Registry: config.Registry(cfg), Params: map[string]map[string]string{}, Languages: s.Languages(), Upstream: s.Lock.Tag, Fallback: missing, Warnings: map[string]map[string][]string{}}
for id, t := range cfg.Templates {
result.Params[id] = config.FlattenParams(t.Params)
}
for name := range packages {
result.Themes = append(result.Themes, name)
}
sort.Strings(result.Themes)
result.Commit = s.Lock.Commit
result.Logo = s.Files["public/assets/img/favicon.png"]
for _, lang := range result.Languages {
result.Rendered[lang] = map[string]map[string]string{}
result.Warnings[lang] = map[string][]string{}
for _, name := range result.Themes {
templates := builder.EffectiveTemplates(s, packages[name])
result.Rendered[lang][name] = map[string]string{}
for id, fixture := range cfg.Templates {
mail, err := RenderMail(s, templates, strings.Join(fixture.Path, "/"), fixture.Params, lang)
if err != nil {
return nil, fmt.Errorf("%s/%s/%s: %w", lang, name, id, err)
}
}
for _, tplID := range s.Errors {
if rr, ok := s.Results[tplID]; ok && rr.Error != "" {
fmt.Printf("\033[31m[E]\033[0m [Preview] |-> %s render error: %s\n", tplID, rr.Error)
} else {
fmt.Printf("\033[31m[E]\033[0m [Preview] |-> %s render error\n", tplID)
result.Rendered[lang][name][id] = mail.HTML
if len(mail.Warnings) > 0 {
result.Warnings[lang][id] = mail.Warnings
}
}
}
}
return result, nil
}
type Mail struct {
Subject string
HTML string
Warnings []string
}
var subjectSplit = regexp.MustCompile(`(?m)^-{3,}\s*$`)
// RenderMail parses subjects with text/template, bodies with html/template, and
// registers shared partials under Gitea's mail/... names.
func RenderMail(s *upstream.Snapshot, files map[string][]byte, entry string, params map[string]any, lang string) (Mail, error) {
var result Mail
catalog, ok := s.Locales[lang]
if !ok {
return result, fmt.Errorf("unknown preview language %s", lang)
}
data := CloneParams(params)
var problems []error
data["locale"] = Locale{Lang: lang, Catalog: catalog, English: s.Locales["en-US"], Problems: &problems, Warnings: &result.Warnings}
markSafeHTML(data)
root := template.New("").Funcs(TemplateFuncs).Option("missingkey=error")
var entrySubject string
names := make([]string, 0, len(files))
for p := range files {
names = append(names, p)
}
sort.Strings(names)
for _, p := range names {
source := string(files[p])
subject, body := splitMail(source)
if p == entry {
entrySubject = subject
}
if _, err := root.New(builder.TemplateName(p)).Parse(body); err != nil {
return result, fmt.Errorf("%s: %w", p, err)
}
}
if entrySubject != "" {
// Gitea's subject environment is intentionally smaller than the body map.
sub, err := texttmpl.New("subject").Funcs(texttmpl.FuncMap{"AppName": TemplateFuncs["AppName"], "AppDomain": TemplateFuncs["AppDomain"], "dict": Dict}).Option("missingkey=error").Parse(entrySubject)
if err != nil {
return result, err
}
var buf strings.Builder
if err := sub.Execute(&buf, data); err != nil {
return result, err
}
result.Subject = strings.Join(strings.Fields(buf.String()), " ")
runes := []rune(result.Subject)
if len(runes) > 256 {
result.Subject = string(runes[:256])
}
}
if result.Subject == "" {
if subject, ok := data["FallbackSubject"].(string); ok {
result.Subject = subject
} else if subject, ok := data["Subject"].(string); ok {
result.Subject = subject
}
}
data["Subject"] = result.Subject
var buf strings.Builder
if err := root.ExecuteTemplate(&buf, builder.TemplateName(entry), data); err != nil {
return result, err
}
if len(problems) > 0 {
return result, problems[0]
}
result.HTML = buf.String()
return result, nil
}
func splitMail(source string) (string, string) {
loc := subjectSplit.FindStringIndex(source)
if loc == nil {
return "", source
}
return source[:loc[0]], source[loc[1]:]
}
// CloneParams prevents safe-HTML conversions or branch fixtures from mutating
// another language's input data.
func CloneParams(params map[string]any) map[string]any {
result := map[string]any{}
for k, v := range params {
switch v := v.(type) {
case map[string]any:
result[k] = CloneParams(v)
case []any:
values := make([]any, len(v))
for i, item := range v {
if m, ok := item.(map[string]any); ok {
values[i] = CloneParams(m)
} else {
values[i] = item
}
}
result[k] = values
default:
result[k] = v
}
}
return result
}
// The small manifest is always loaded first; each catalog's rendered data is a
// classic script so static preview also works without fetch on file://.
func WriteRenderedJS(result *PreviewResult, output string) error {
dir := filepath.Dir(output)
dataDir := filepath.Join(dir, "rendered")
if err := os.MkdirAll(dataDir, 0755); err != nil {
return err
}
manifest := map[string]any{"schema": 1, "upstream": result.Upstream, "languages": result.Languages, "themes": result.Themes, "registry": result.Registry, "params": result.Params, "fallback": result.Fallback, "warnings": result.Warnings}
manifest["logo"] = "data:image/png;base64," + base64.StdEncoding.EncodeToString(result.Logo)
manifest["upstream_commit"] = result.Commit
data, err := json.Marshal(manifest)
if err != nil {
return err
}
// Payloads are written before the manifest, so a failed render never publishes
// a manifest that advertises missing files.
for _, lang := range result.Languages {
payload, err := json.Marshal(result.Rendered[lang])
if err != nil {
return err
}
key, _ := json.Marshal(lang)
script := "window.__MAIL_PREVIEW_DATA__ = window.__MAIL_PREVIEW_DATA__ || {};\nwindow.__MAIL_PREVIEW_DATA__[" + string(key) + "] = " + string(payload) + ";\n"
if err := os.WriteFile(filepath.Join(dataDir, lang+".js"), []byte(script), 0644); err != nil {
return err
}
}
script := "// Generated by tools; do not edit.\nwindow.__MAIL_PREVIEW__ = " + string(data) + ";\n"
return os.WriteFile(output, []byte(script), 0644)
}
func PrintDetailedSummary(result *PreviewResult, _ string, _ *config.TemplatesConfig) {
fmt.Printf("[PASS] %s: %d themes × %d mail types × %d languages\n", result.Upstream, len(result.Themes), len(result.Registry), len(result.Languages))
for _, lang := range result.Languages {
for _, warnings := range result.Warnings[lang] {
for _, warning := range warnings {
fmt.Println(warning)
}
}
if keys := result.Fallback[lang]; len(keys) > 0 {
fmt.Printf("[FALLBACK] %s -> en-US: %s\n", lang, strings.Join(keys, ", "))
}
}
}
// markSafeHTML recursively converts known HTML-containing string fields to
// template.HTML so Go's html/template renders them raw instead of escaping.
// These fields mirror Gitea's data model where rendered markdown is typed as
// template.HTML: Release.RenderedNote, Body (comment content), and
// ReviewComments[].RenderedContent.
func markSafeHTML(data map[string]any) {
for k, v := range data {
switch val := v.(type) {
switch v := v.(type) {
case string:
if k == "RenderedNote" || k == "Body" || k == "RenderedContent" {
data[k] = template.HTML(val)
data[k] = template.HTML(v)
}
case map[string]any:
markSafeHTML(val)
markSafeHTML(v)
case []any:
for i, item := range val {
for _, item := range v {
if m, ok := item.(map[string]any); ok {
markSafeHTML(m)
val[i] = m
}
}
}
}
}
func formatSize(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%dB", bytes)
}
return fmt.Sprintf("%dKiB", bytes/unit)
}
+4 -11
View File
@@ -9,9 +9,11 @@ import (
// TemplateFuncs maps Gitea's built-in template functions available in mail templates.
var TemplateFuncs = template.FuncMap{
"dict": Dict,
"AppUrl": func() string { return "https://gitea.com/" },
"AppName": func() string { return "Gitea" },
"DotEscape": func(s string) template.HTML { return template.HTML(template.HTMLEscapeString(s)) },
"AppDomain": func() string { return "gitea.com" },
"DotEscape": func(s string) string { return strings.ReplaceAll(s, ".", "\u200d.\u200d") },
"QueryEscape": url.QueryEscape,
"ShortSha": ShortSha,
"PathEscapeSegments": PathEscapeSegments,
@@ -69,16 +71,7 @@ func FormatByteSize(n int64) string {
// 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)
return template.HTML(fmt.Sprintf(fmtStr, escapedArgs(args)...))
}
// Dict builds a map from alternating key/value pairs (used by Gitea's Dict function).
+98 -112
View File
@@ -2,129 +2,115 @@ package preview
import (
"fmt"
"gitea-mail-templates/tools/upstream"
"html/template"
"reflect"
"strings"
)
// Locale provides English fallback translations, matching Gitea's locale.Tr interface.
type Locale struct{}
// Locale is a lightweight adapter for the translation implementation pinned in
// upstream/modules/translation. Catalogs are always official snapshot data.
type Locale struct {
Lang string
Catalog map[string]string
English map[string]string
Problems *[]error
Warnings *[]string
}
func (l Locale) Language() string { return l.Lang }
func (l Locale) HasKey(key string) bool { _, ok := l.Catalog[key]; return ok }
func (l Locale) problem(err error) {
if l.Problems != nil {
*l.Problems = append(*l.Problems, err)
}
}
func (l Locale) TrString(key string, args ...any) string {
format := l.Catalog[key]
if format == "" {
format = l.English[key]
}
if format == "" {
l.problem(fmt.Errorf("missing translation key %s", key))
format = template.HTMLEscapeString(key)
}
if len(args) == 0 {
return format
}
values := make([]any, 0, len(args))
for _, a := range args {
v := reflect.ValueOf(a)
if v.IsValid() && v.Kind() == reflect.Slice {
if len(args) != 1 {
l.problem(fmt.Errorf("translation %s has uncertain slice arguments", key))
break
}
for i := 0; i < v.Len(); i++ {
values = append(values, v.Index(i).Interface())
}
} else {
values = append(values, a)
}
}
msg := fmt.Sprintf(format, values...)
// Upstream logs formatting failures; a fixture preview must surface them.
if strings.Contains(msg, "%!") && upstream.ReviewedFormattingIssue(l.Lang, key, format) {
if l.Warnings != nil {
*l.Warnings = append(*l.Warnings, "[UPSTREAM-WARN] "+l.Lang+" "+key+": official placeholder %[1]s is malformed; output matches Gitea")
}
} else if strings.Contains(msg, "%!") {
l.problem(fmt.Errorf("translation %s formatting failed: %s", key, msg))
}
return msg
}
func escapedArgs(args []any) []any {
out := append([]any(nil), args...)
for i, a := range out {
switch a := a.(type) {
case nil, bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, template.HTML:
case string:
out[i] = template.HTMLEscapeString(a)
case fmt.Stringer:
out[i] = template.HTMLEscapeString(a.String())
default:
out[i] = template.HTMLEscapeString(fmt.Sprint(a))
}
}
return out
}
// Tr translates a key and substitutes %s / %d / %[N]s / %[N]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 i, arg := range args {
var val string
if h, ok := arg.(template.HTML); ok {
val = string(h) // already safe HTML
} else {
val = template.HTMLEscapeString(fmt.Sprint(arg))
}
// Handle positional format specifiers: %[1]s, %[2]d, etc.
pos := fmt.Sprintf("%%[%d]", i+1)
s = strings.Replace(s, pos+"s", val, 1)
s = strings.Replace(s, pos+"d", val, 1)
}
// Fallback for non-positional %s / %d (sequential replacement)
for _, arg := range args {
var val string
if h, ok := arg.(template.HTML); ok {
val = string(h)
} else {
val = template.HTMLEscapeString(fmt.Sprint(arg))
}
if !strings.Contains(s, "%s") && !strings.Contains(s, "%d") {
break
}
s = strings.Replace(s, "%s", val, 1)
s = strings.Replace(s, "%d", val, 1)
}
return template.HTML(s)
return template.HTML(l.TrString(key, escapedArgs(args)...))
}
// 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) {
func (l Locale) TrN(count any, one, many string, args ...any) template.HTML {
var n int64
switch count := count.(type) {
case int:
n = v
n = int64(count)
case int16:
n = int64(count)
case int32:
n = int64(count)
case int64:
n = int(v)
case float64:
n = int(v)
n = count
default:
n = 1
return l.Tr(many, args...)
}
key := plural
if n == 1 {
key = singular
singular := n == 1
switch l.Lang {
case "zh-CN", "zh-HK", "zh-TW":
singular = true
case "lv-LV", "ru-RU":
singular = n%10 == 1 && n%100 != 11
case "fr-FR":
singular = n > -2 && n < 2
}
return l.Tr(key, args...)
}
// LocaleEN holds all English translation strings used by Gitea mail templates.
var LocaleEN = map[string]string{
"mail.hi_user_x": "Hi <b>%s</b>,",
"mail.activate_account": "Please activate your account",
"mail.activate_account.title": "%s, please activate your account",
"mail.activate_account.text_1": "Hi <b>%[1]s</b>, thanks for registering at %[2]s!",
"mail.activate_account.text_2": "Please click the following link to activate your account within <b>%s</b>:",
"mail.activate_email": "Verify your email address",
"mail.activate_email.title": "%s, please verify your email address",
"mail.activate_email.text": "Please click the following link to verify your email address within <b>%s</b>:",
"mail.register_success": "Registration successful",
"mail.register_notify": "Welcome to %s",
"mail.register_notify.title": "%[1]s, welcome to %[2]s",
"mail.register_notify.text_1": "This is your registration confirmation email for %s!",
"mail.register_notify.text_2": "You can now log in via username: %s.",
"mail.register_notify.text_3": "If this account has been created for you, please <a href=\"%s\">set your password</a> first.",
"mail.reset_password": "Recover your account",
"mail.reset_password.title": "%s, you have requested to recover your account",
"mail.reset_password.text": "Please click the following link to recover your account within <b>%s</b>:",
"mail.team_invite.subject": "%[1]s has invited you to join the %[2]s organization",
"mail.team_invite.text_1": "%[1]s has invited you to join team %[2]s in organization %[3]s.",
"mail.team_invite.text_2": "Please click the following link to join the team:",
"mail.team_invite.text_3": "Note: This invitation was intended for %[1]s. If you were not expecting this invitation, you can ignore this email.",
"mail.repo.collaborator.added.subject": "%s added you to %s",
"mail.repo.collaborator.added.text": "You have been added as a collaborator of repository:",
"mail.repo.transfer.subject_to": "%s would like to transfer \"%s\" to %s",
"mail.repo.transfer.subject_to_you": "%s would like to transfer \"%s\" to you",
"mail.repo.transfer.to_you": "you",
"mail.repo.transfer.body": "To accept or reject it, visit %s or just ignore it.",
"mail.release.new.subject": "%s in %s released",
"mail.release.new.text": "<b>@%[1]s</b> released %[2]s in %[3]s",
"mail.release.title": "Title: %s",
"mail.release.note": "Note:",
"mail.release.downloads": "Downloads:",
"mail.release.download.zip": "Source Code (ZIP)",
"mail.release.download.targz": "Source Code (TAR.GZ)",
"mail.repo.actions.run.failed": "Run failed",
"mail.repo.actions.run.succeeded": "Run succeeded",
"mail.repo.actions.run.cancelled": "Run cancelled",
"mail.repo.actions.jobs.all_succeeded": "All jobs have succeeded",
"mail.repo.actions.jobs.all_failed": "All jobs have failed",
"mail.repo.actions.jobs.some_not_successful": "Some jobs were not successful",
"mail.repo.actions.jobs.all_cancelled": "All jobs have been cancelled",
"mail.issue_assigned.pull": "@%[1]s assigned you to pull request %[2]s in repository %[3]s.",
"mail.issue_assigned.issue": "@%[1]s assigned you to issue %[2]s in repository %[3]s.",
"mail.issue.x_mentioned_you": "<b>@%s</b> mentioned you:",
"mail.issue.action.close": "<b>@%[1]s</b> closed #%[2]d.",
"mail.issue.action.reopen": "<b>@%[1]s</b> reopened #%[2]d.",
"mail.issue.action.merge": "<b>@%[1]s</b> merged #%[2]d into %[3]s.",
"mail.issue.action.approve": "<b>@%[1]s</b> approved this pull request.",
"mail.issue.action.reject": "<b>@%[1]s</b> requested changes on this pull request.",
"mail.issue.action.review": "<b>@%[1]s</b> commented on this pull request.",
"mail.issue.action.review_dismissed": "<b>@%[1]s</b> dismissed last review from %[2]s for this pull request.",
"mail.issue.action.ready_for_review": "<b>@%[1]s</b> marked this pull request ready for review.",
"mail.issue.action.new": "<b>@%[1]s</b> created #%[2]d.",
"mail.issue.action.force_push": "<b>%[1]s</b> force-pushed the <b>%[2]s</b> from %[3]s to %[4]s.",
"mail.issue.action.push_1": "<b>@%[1]s</b> pushed %[3]d commit to %[2]s",
"mail.issue.action.push_n": "<b>@%[1]s</b> pushed %[3]d commits to %[2]s",
"mail.issue.in_tree_path": "In %s:",
"mail.view_it_on": "View it on %s",
"mail.reply": "or reply to this email directly",
"mail.link_not_working_do_paste": "Not working? Try copying and pasting it to your browser.",
if singular {
return l.Tr(one, args...)
}
return l.Tr(many, args...)
}
+317
View File
@@ -0,0 +1,317 @@
package preview
import (
"bytes"
"fmt"
"html/template"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"gitea-mail-templates/tools/builder"
"gitea-mail-templates/tools/config"
"gitea-mail-templates/tools/upstream"
"golang.org/x/net/html"
)
type testCommitID string
func (id testCommitID) String() string { return string(id) }
type scenario struct {
id, name string
params map[string]any
}
func branchFixtures(cfg *config.TemplatesConfig) []scenario {
var result []scenario
for id, f := range cfg.Templates {
result = append(result, scenario{id, "default", f.Params})
}
add := func(id, name string, edit func(map[string]any)) {
data := CloneParams(cfg.Templates[id].Params)
edit(data)
result = append(result, scenario{id, name, data})
}
for _, action := range []string{"new", "close", "reopen", "merge", "approve", "reject", "review", "review_dismissed", "ready_for_review"} {
add("default", action, func(d map[string]any) {
d["ActionName"] = action
d["Body"] = ""
d["IsMention"] = true
d["CanReply"] = true
})
}
for _, n := range []int{0, 1, 2} {
add("default", fmt.Sprintf("push-%d", n), func(d map[string]any) {
d["ActionName"] = "push"
d["Body"] = ""
c := d["Comment"].(map[string]any)
commits := make([]any, n)
for i := range commits {
commits[i] = map[string]any{"UserCommit": map[string]any{"GitCommit": map[string]any{"ID": testCommitID("0123456789abcdef0123456789abcdef01234567"), "MessageTitle": "Commit <escaped>"}}}
}
c["Commits"] = commits
})
}
add("default", "force-push", func(d map[string]any) {
d["ActionName"] = "push"
d["Body"] = ""
c := d["Comment"].(map[string]any)
c["IsForcePush"] = true
c["OldCommit"] = "0123456789abcdef"
c["NewCommit"] = "fedcba9876543210"
})
add("default", "review-comments", func(d map[string]any) {
d["ReviewComments"] = []any{map[string]any{"TreePath": "src/example.go", "Patch": "<unsafe>& patch", "RenderedContent": "<p>Safe <strong>review</strong></p>"}}
})
add("assigned", "pull-reply", func(d map[string]any) { d["IsPull"] = true; d["CanReply"] = true })
add("release", "no-downloads-or-note", func(d map[string]any) {
d["DisableDownloadSourceArchives"] = true
r := d["Release"].(map[string]any)
r["Attachments"] = []any{}
r["RenderedNote"] = ""
})
add("workflow_run", "no-jobs", func(d map[string]any) { d["Jobs"] = []any{} })
add("workflow_run", "failure-jobs", func(d map[string]any) {
for _, job := range d["Jobs"].([]any) {
j := job.(map[string]any)
j["StatusClass"] = "status-failure"
j["StatusIconCID"] = "failure"
j["StatusIconAlt"] = "Failure"
}
})
add("activate", "escaped-name", func(d map[string]any) { d["DisplayName"] = "test.example <User> & \"Admin\""; d["Code"] = "a&b= c" })
return result
}
type semantics struct {
Text string
Links, Images []string
}
func mailSemantics(source string, hints ...string) (semantics, error) {
root, err := html.Parse(strings.NewReader(source))
if err != nil {
return semantics{}, err
}
var result semantics
var text []string
var visible func(*html.Node) string
visible = func(n *html.Node) string {
if n.Type == html.TextNode {
return n.Data
}
if n.Type == html.ElementNode && (n.Data == "head" || n.Data == "script" || n.Data == "style") {
return ""
}
var parts []string
for c := n.FirstChild; c != nil; c = c.NextSibling {
parts = append(parts, visible(c))
}
return strings.Join(parts, " ")
}
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "head" {
return
}
if n.Type == html.ElementNode {
for _, a := range n.Attr {
if a.Key == "class" && strings.Contains(" "+a.Val+" ", " framework-extra ") {
return
}
}
if n.Data == "p" {
for _, hint := range hints {
if strings.Join(strings.Fields(visible(n)), " ") == strings.Join(strings.Fields(hint), " ") {
return
}
}
}
}
if n.Type == html.TextNode {
text = append(text, n.Data)
}
if n.Type == html.ElementNode {
if n.Data == "a" {
for _, a := range n.Attr {
if a.Key == "href" {
result.Links = append(result.Links, a.Val+" | "+strings.Join(strings.Fields(visible(n)), " "))
}
}
}
if n.Data == "img" {
var attrs []string
for _, a := range n.Attr {
if a.Key == "src" || a.Key == "alt" {
attrs = append(attrs, a.Key+"="+a.Val)
}
}
result.Images = append(result.Images, strings.Join(attrs, " | "))
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(root)
result.Text = strings.Join(strings.Fields(strings.Join(text, " ")), " ")
return result, nil
}
// Run every discovered language and theme against stock Gitea for both normal
// fixtures and branches that historically broke custom mail overrides.
func TestAllThemeContentParity(t *testing.T) {
s, err := upstream.Load(filepath.Join("..", "..", "build", "upstream"))
if err != nil {
t.Fatal(err)
}
cfg, err := config.Load(filepath.Join("..", "data", "templates_config.json"))
if err != nil {
t.Fatal(err)
}
if err := AlignConfig(s, cfg); err != nil {
t.Fatal(err)
}
packages, err := builder.BuildAll(filepath.Join("..", "..", "themes"), s, nil)
if err != nil {
t.Fatal(err)
}
// Shared mode has the same parity guarantee although migrated themes are framed.
shared, err := builder.Build(s, &builder.Theme{Name: "test", Mode: "shared", CSS: "a { color:#2563eb; }"})
if err != nil {
t.Fatal(err)
}
packages["shared-test"] = shared
for _, lang := range s.Languages() {
t.Run(lang, func(t *testing.T) {
for _, fixture := range branchFixtures(cfg) {
entry := strings.Join(cfg.Templates[fixture.id].Path, "/")
stock, err := RenderMail(s, s.Templates, entry, fixture.params, lang)
if err != nil {
t.Fatalf("stock %s/%s: %v", fixture.id, fixture.name, err)
}
hint := Locale{Lang: lang, Catalog: s.Locales[lang], English: s.Locales["en-US"]}.TrString("mail.link_not_working_do_paste")
want, err := mailSemantics(stock.HTML, hint)
if err != nil {
t.Fatal(err)
}
for theme, p := range packages {
mail, err := RenderMail(s, builder.EffectiveTemplates(s, p), entry, fixture.params, lang)
if err != nil {
t.Fatalf("%s/%s/%s: %v", theme, fixture.id, fixture.name, err)
}
got, err := mailSemantics(mail.HTML, hint)
if err != nil {
t.Fatal(err)
}
if stock.Subject != mail.Subject || !reflect.DeepEqual(want, got) {
t.Fatalf("content changed for %s/%s/%s", theme, fixture.id, fixture.name)
}
if fixture.id == "release" && fixture.name == "default" && (!strings.Contains(mail.HTML, "1.5 KiB") || !strings.Contains(mail.HTML, "release-notes.txt")) {
t.Fatal("release attachment regression")
}
if fixture.name == "push-1" && !strings.Contains(mail.HTML, "/commit/0123456789abcdef0123456789abcdef01234567") {
t.Fatal("push link regression")
}
}
}
})
}
}
func TestLocaleFormatting(t *testing.T) {
var problems []error
base := map[string]string{"repeat": "%[1]s %[1]s (%[2]d)", "mixed": "%[2]s %s %%", "html": "<b>%s</b> %s", "one": "one", "many": "many", "fallback": "English", "broken": "%d"}
l := Locale{Lang: "en-US", Catalog: base, English: base, Problems: &problems}
if got := string(l.Tr("repeat", "<x>", 7)); got != "&lt;x&gt; &lt;x&gt; (7)" {
t.Fatal(got)
}
if got := l.TrString("mixed", "ignored", "second", "third"); got != "second third %" {
t.Fatal(got)
}
if got := string(l.Tr("html", "<unsafe>", template.HTML("<i>safe</i>"))); got != "<b>&lt;unsafe&gt;</b> <i>safe</i>" {
t.Fatal(got)
}
if got := l.TrString("repeat", []any{"slice", int64(2)}); got != "slice slice (2)" {
t.Fatal(got)
}
for _, tc := range []struct {
lang string
n any
want string
}{{"en-US", 0, "many"}, {"en-US", 1, "one"}, {"en-US", 2, "many"}, {"zh-CN", 2, "one"}, {"fr-FR", 0, "one"}, {"ru-RU", 21, "one"}, {"ru-RU", 11, "many"}, {"en-US", float64(1), "many"}} {
l.Lang = tc.lang
if got := string(l.TrN(tc.n, "one", "many")); got != tc.want {
t.Fatalf("%v: %s", tc, got)
}
}
l.Catalog = map[string]string{}
if got := l.TrString("fallback"); got != "English" {
t.Fatal(got)
}
if len(problems) != 0 {
t.Fatal(problems)
}
l.Tr("missing")
l.Tr("broken", "text")
if len(problems) != 2 {
t.Fatal("missing/formatting errors were not surfaced")
}
if got := string(HTMLFormat("<a href='%[1]s'>%[2]s</a>", "x?a=1&b=2", template.HTML("<b>safe</b>"))); got != "<a href='x?a=1&amp;b=2'><b>safe</b></a>" {
t.Fatal(got)
}
}
func TestSubjectSplitAndMissingFixture(t *testing.T) {
s, err := upstream.Load(filepath.Join("..", "..", "build", "upstream"))
if err != nil {
t.Fatal(err)
}
files := builder.EffectiveTemplates(s, nil)
files["mail/test.tmpl"] = []byte(" {{.Subject}}\n------------\n<body>{{.Subject}}</body>")
mail, err := RenderMail(s, files, "mail/test.tmpl", map[string]any{"Subject": " Hello world "}, "en-US")
if err != nil {
t.Fatal(err)
}
if mail.Subject != "Hello world" || strings.TrimSpace(mail.HTML) != "<body>Hello world</body>" {
t.Fatal(mail)
}
cfg, err := config.Load(filepath.Join("..", "data", "templates_config.json"))
if err != nil {
t.Fatal(err)
}
delete(cfg.Templates, "release")
if AlignConfig(s, cfg) == nil {
t.Fatal("missing fixture accepted")
}
if _, err := RenderMail(s, s.Templates, "mail/repo/release.tmpl", map[string]any{}, "en-US"); err == nil {
t.Fatal("missing context accepted")
}
}
func TestPreviewBundles(t *testing.T) {
result := &PreviewResult{Rendered: map[string]map[string]map[string]string{"en-US": {"test": {"activate": "<body>hello</body>"}}, "zh-CN": {"test": {"activate": "<body>你好</body>"}}}, Languages: []string{"en-US", "zh-CN"}, Themes: []string{"test"}, Registry: map[string]map[string]string{}, Upstream: "v28.0.0"}
output := filepath.Join(t.TempDir(), "rendered.js")
if err := WriteRenderedJS(result, output); err != nil {
t.Fatal(err)
}
manifest, err := os.ReadFile(output)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(manifest, []byte("<body>")) {
t.Fatal("manifest embeds mail payload")
}
for _, lang := range result.Languages {
data, err := os.ReadFile(filepath.Join(filepath.Dir(output), "rendered", lang+".js"))
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(data, []byte("window.__MAIL_PREVIEW_DATA__")) {
t.Fatal("not a file:// script bundle")
}
}
}
-67
View File
@@ -1,67 +0,0 @@
package preview
import (
"html/template"
"os"
"path/filepath"
"strings"
"testing"
"gitea-mail-templates/tools/config"
)
type testCommitID string
func (id testCommitID) String() string { return string(id) }
// Exercise the push-only branch with Gitea's nested commit data shape.
// The normal preview fixture has no commits, so it cannot catch stale .ID paths.
func TestPushCommitTemplates(t *testing.T) {
cfg, err := config.Load(filepath.Join("..", "data", "templates_config.json"))
if err != nil {
t.Fatal(err)
}
const sha = "0123456789abcdef0123456789abcdef01234567"
data := cfg.Templates["default"].Params
data["ActionName"] = "push"
data["locale"] = Locale{}
comment := data["Comment"].(map[string]any)
comment["Commits"] = []any{map[string]any{
"UserCommit": map[string]any{
"GitCommit": map[string]any{
"ID": testCommitID(sha),
"MessageTitle": "Test push commit",
},
},
}}
markSafeHTML(data)
themesDir := filepath.Join("..", "..", "themes")
themes, err := os.ReadDir(themesDir)
if err != nil {
t.Fatal(err)
}
for _, theme := range themes {
if !theme.IsDir() {
continue
}
t.Run(theme.Name(), func(t *testing.T) {
path := filepath.Join(themesDir, theme.Name(), "mail", "repo", "issue", "default.tmpl")
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
tmpl, err := template.New("push").Funcs(TemplateFuncs).Parse(string(content))
if err != nil {
t.Fatal(err)
}
var html strings.Builder
if err := tmpl.Execute(&html, data); err != nil {
t.Fatal(err)
}
if !strings.Contains(html.String(), "/commit/"+sha) || !strings.Contains(html.String(), sha[:10]) {
t.Fatal("push commit link or abbreviated hash missing from rendered email")
}
})
}
}
-53
View File
@@ -1,53 +0,0 @@
package preview
import (
"html/template"
"os"
"path/filepath"
"strings"
"testing"
"gitea-mail-templates/tools/config"
)
// Gitea 28 removes FileSize, so render an attachment in every release theme.
func TestReleaseAttachmentsGitea28(t *testing.T) {
if _, ok := TemplateFuncs["FileSize"]; ok {
t.Fatal("FileSize is not available in Gitea 28 mail templates")
}
cfg, err := config.Load(filepath.Join("..", "data", "templates_config.json"))
if err != nil {
t.Fatal(err)
}
data := cfg.Templates["release"].Params
data["locale"] = Locale{}
markSafeHTML(data)
themesDir := filepath.Join("..", "..", "themes")
themes, err := os.ReadDir(themesDir)
if err != nil {
t.Fatal(err)
}
for _, theme := range themes {
if !theme.IsDir() {
continue
}
t.Run(theme.Name(), func(t *testing.T) {
content, err := os.ReadFile(filepath.Join(themesDir, theme.Name(), "mail", "repo", "release.tmpl"))
if err != nil {
t.Fatal(err)
}
tmpl, err := template.New("release").Funcs(TemplateFuncs).Parse(string(content))
if err != nil {
t.Fatal(err)
}
var rendered strings.Builder
if err := tmpl.Execute(&rendered, data); err != nil {
t.Fatal(err)
}
if !strings.Contains(rendered.String(), "release-notes.txt") || !strings.Contains(rendered.String(), "1.5 KiB") {
t.Fatal("release attachment or formatted size missing from email")
}
})
}
}
+39 -12
View File
@@ -22,7 +22,7 @@ type DevServerConfig struct {
}
// DevServer is a live-reload dev server with SSE-based browser notifications.
// It serves preview/ as static files, watches themes/ for .tmpl changes,
// It serves preview/ as static files, watches theme resources and snapshots,
// re-renders templates in-process, and pushes reload events to connected browsers.
type DevServer struct {
cfg DevServerConfig
@@ -46,7 +46,7 @@ func NewDevServer(cfg DevServerConfig) *DevServer {
func (s *DevServer) Start() error {
// Build initial rendered.js
if err := s.rebuildAll(); err != nil {
fmt.Fprintf(os.Stderr, "\033[33m[W]\033[0m [Builder] Initial render warning: %v\n", err)
return fmt.Errorf("initial preview build: %w", err)
}
// Start file watcher in background
@@ -61,11 +61,11 @@ func (s *DevServer) Start() error {
previewFS := http.FileServer(http.Dir(s.cfg.PreviewDir))
mux.Handle("/", previewFS)
addr := fmt.Sprintf(":%d", s.cfg.Port)
addr := fmt.Sprintf("127.0.0.1:%d", s.cfg.Port)
fmt.Printf("\033[32m[I]\033[0m [Server] Gitea Mail Templates — Dev Server\n")
fmt.Printf("\033[32m[I]\033[0m [Server] http://localhost%s\n", addr)
fmt.Printf("\033[32m[I]\033[0m [Watcher] Watching themes/ for changes\n")
fmt.Printf("\033[32m[I]\033[0m [Server] SSE endpoint ws://localhost%s/events\n\n", addr)
fmt.Printf("\033[32m[I]\033[0m [Server] http://%s\n", addr)
fmt.Printf("\033[32m[I]\033[0m [Watcher] Watching theme CSS, framework, lock/cache and fixtures\n")
fmt.Printf("\033[32m[I]\033[0m [Server] SSE endpoint http://%s/events\n\n", addr)
return http.ListenAndServe(addr, mux)
}
@@ -131,19 +131,42 @@ func (s *DevServer) broadcast(typ string, payload map[string]interface{}) {
// --- File watcher (polling) ---
// scanMods scans themes/ for .tmpl files and returns a map of path → mod time.
// scanMods records theme resources, snapshot files and fixture modification times.
func (s *DevServer) scanMods() (map[string]time.Time, error) {
mods := make(map[string]time.Time)
err := filepath.Walk(s.cfg.ThemesDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // skip unreadable
}
if !info.IsDir() && strings.HasSuffix(info.Name(), ".tmpl") {
if !info.IsDir() && (strings.HasSuffix(info.Name(), ".json") || strings.HasSuffix(info.Name(), ".css") || strings.HasSuffix(info.Name(), ".html")) {
mods[path] = info.ModTime()
}
return nil
})
return mods, err
if err != nil {
return mods, err
}
for _, dir := range []string{filepath.Join(filepath.Dir(s.cfg.ThemesDir), "framework"), filepath.Join(filepath.Dir(s.cfg.ThemesDir), "build", "upstream"), filepath.Join(filepath.Dir(s.cfg.ThemesDir), "tools", "data")} {
err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
mods[path] = info.ModTime()
}
return nil
})
if err != nil {
return mods, err
}
}
lock := filepath.Join(filepath.Dir(s.cfg.ThemesDir), "gitea.lock.json")
if info, err := os.Stat(lock); err == nil {
mods[lock] = info.ModTime()
} else {
return mods, err
}
return mods, nil
}
// watchThemes polls the themes directory every 500ms and triggers rebuild on changes.
@@ -213,9 +236,13 @@ func (s *DevServer) rebuildAll() error {
start := time.Now()
fmt.Printf("\033[32m[I]\033[0m [Builder] Rebuilding all themes...\n")
result := RenderAll(s.cfg.ThemesDir, s.cfg.Config, nil)
if result == nil {
err := fmt.Errorf("no results produced")
cfg, err := config.Load(filepath.Join(filepath.Dir(s.cfg.ThemesDir), "tools", "data", "templates_config.json"))
if err != nil {
s.broadcast("error", map[string]interface{}{"message": err.Error()})
return err
}
result, err := RenderAll(s.cfg.ThemesDir, cfg, nil)
if err != nil {
s.broadcast("error", map[string]interface{}{"message": err.Error()})
return err
}