Files
GiteaMailTemplates/tools/preview/engine.go
T
KenanZhu c93f2bf8b2
Release / Validate Templates (push) Successful in 2m55s
Release / Package & Release (push) Skipped
Release / Remind to Update Release Documentation (push) Skipped
chore: simplify workflows and unify tool logging and mail presentation
2026-10-10 14:54:40 +08:00

294 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package preview
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"gitea-mail-templates/tools/logging"
"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) {
logging.Info("Preview", "%s: %d themes × %d mail types × %d languages", 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 {
logging.Warn("Upstream", "%s", strings.TrimPrefix(warning, "[UPSTREAM-WARN] "))
}
}
if keys := result.Fallback[lang]; len(keys) > 0 {
logging.Warn("Locale", "%s -> en-US: %s", 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)
}
}
}
}
}