package preview import ( "context" "encoding/base64" "encoding/json" "fmt" "html/template" "os" "path/filepath" "regexp" "sort" "strings" texttmpl "text/template" "gitea-mail-templates/tools/builder" "gitea-mail-templates/tools/config" "gitea-mail-templates/tools/upstream" ) type PreviewResult struct { 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 } 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 } for id := range cfg.Templates { if !found[id] { return fmt.Errorf("stale fixture %s is absent from upstream snapshot", id) } } return nil } // 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) } 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, ", ")) } } } func markSafeHTML(data map[string]any) { for k, v := range data { switch v := v.(type) { case string: if k == "RenderedNote" || k == "Body" || k == "RenderedContent" { data[k] = template.HTML(v) } case map[string]any: markSafeHTML(v) case []any: for _, item := range v { if m, ok := item.(map[string]any); ok { markSafeHTML(m) } } } } }