package builder import ( "bytes" "encoding/json" "fmt" "os" "path/filepath" "regexp" "sort" "strings" "gitea-mail-templates/tools/upstream" ) type Package struct { Theme *Theme Files map[string][]byte // only custom overrides required by this theme } func BuildAll(dir string, s *upstream.Snapshot, filter map[string]bool) (map[string]*Package, error) { names, err := Discover(dir) if err != nil { return nil, err } known := map[string]bool{} for _, n := range names { known[n] = true } for n := range filter { if !known[n] { return nil, fmt.Errorf("unknown theme %s", n) } } result := map[string]*Package{} for _, n := range names { if len(filter) > 0 && !filter[n] { continue } t, err := LoadTheme(filepath.Join(dir, n)) if err != nil { return nil, fmt.Errorf("theme %s: %w", n, err) } p, err := Build(s, t) if err != nil { return nil, fmt.Errorf("theme %s: %w", n, err) } result[n] = p } return result, nil } func injection(name, content string) string { return "" + content + "" } func Build(s *upstream.Snapshot, t *Theme) (*Package, error) { if err := ValidateCSS(t.CSS); err != nil { return nil, err } p := &Package{Theme: t, Files: map[string][]byte{}} head := s.Templates["mail/base/head.tmpl"] p.Files["mail/base/head.tmpl"] = append(bytes.Clone(head), []byte(injection("HEAD", "\n\n\n"))...) p.Files["mail/base/footer.tmpl"] = bytes.Clone(s.Templates["mail/base/footer.tmpl"]) if t.Mode == "framed" { if err := ValidateDecoration(strings.ReplaceAll(t.Open, "__HEADER__", "") + strings.ReplaceAll(t.Footer, "__SIDEBAR__", "") + t.Close); err != nil { return nil, err } for name, b := range t.Controls { p.Files[name] = bytes.Clone(b) } for _, name := range s.Entrypoints() { open := strings.ReplaceAll(t.Open, "__MAIL_TYPE__", upstream.TemplateID(name)) open = strings.ReplaceAll(open, "__HEADER__", fmt.Sprintf(`{{template "mail/base/header" (dict "Variant" %q)}}`, t.Layout)) middle := strings.ReplaceAll(t.Footer, "__SIDEBAR__", `{{template "mail/base/sidebar" .}}`) adapted, err := AdaptOfficial(s, name) if err != nil { return nil, err } content, err := frameWithFooter(adapted, open, middle, t.Close) if err != nil { return nil, fmt.Errorf("%s: %w", name, err) } p.Files[name] = content } } else if t.Mode != "shared" { return nil, fmt.Errorf("unsupported theme mode %s", t.Mode) } for name, b := range p.Files { if _, ok := t.Controls[name]; ok && t.Mode == "framed" { continue } stripped, err := StripInjections(b) if err != nil { return nil, err } baseline := s.Templates[name] if t.Mode == "framed" && !strings.HasPrefix(name, "mail/base/") { baseline, err = AdaptOfficial(s, name) if err != nil { return nil, err } } if !bytes.Equal(stripped, baseline) { return nil, fmt.Errorf("generation changed official source: %s", name) } } checked := *s checked.Templates = EffectiveTemplates(s, p) if err := upstream.ValidateKeys(&checked); err != nil { return nil, fmt.Errorf("framework translations: %w", err) } return p, nil } // MaskActions keeps byte offsets stable and understands quotes, raw strings and // Go template comments. HTML anchors inside actions must never be transformed. func MaskActions(data []byte) ([]byte, error) { masked, _, err := maskAndActions(data) return masked, err } type actionSpan struct{ start, end int } func maskAndActions(data []byte) ([]byte, []actionSpan, error) { masked := bytes.Clone(data) var actions []actionSpan for i := 0; i < len(data)-1; i++ { if data[i] != '{' || data[i+1] != '{' { continue } start := i i += 2 var quote byte comment := false closed := false for i < len(data)-1 { if comment { if data[i] == '*' && data[i+1] == '/' { comment = false i += 2 } else { i++ } continue } if quote != 0 { if data[i] == '\\' && quote != '`' { i += 2 continue } if data[i] == quote { quote = 0 } i++ continue } if data[i] == '/' && data[i+1] == '*' { comment = true i += 2 continue } if data[i] == '"' || data[i] == '\'' || data[i] == '`' { quote = data[i] i++ continue } if data[i] == '}' && data[i+1] == '}' { i += 2 closed = true break } i++ } if !closed { return nil, nil, fmt.Errorf("unterminated Go template action") } for j := start; j < i; j++ { if masked[j] != '\n' && masked[j] != '\r' { masked[j] = ' ' } } actions = append(actions, actionSpan{start, i}) i-- } return masked, actions, nil } func Frame(data []byte, open, close string) ([]byte, error) { return frameWithFooter(data, open, "", close) } func frameWithFooter(data []byte, open, middle, close string) ([]byte, error) { masked, actions, err := maskAndActions(data) if err != nil { return nil, err } // Tokenize only static HTML so fake anchors inside comments, attributes and // style blocks do not become insertion points. anchors, err := BodyAnchors(masked) if err != nil { return nil, err } start, end := anchors[0], anchors[1] footer := []byte(`{{template "mail/base/footer"}}`) footerAt, count := -1, 0 for _, a := range actions { if bytes.Equal(data[a.start:a.end], footer) { footerAt = a.start count++ } } if count != 1 { return nil, fmt.Errorf("expected one reviewed official footer call") } if footerAt < start || footerAt > end { return nil, fmt.Errorf("official footer is outside body") } result := append([]byte{}, data[:start]...) result = append(result, []byte(injection("OPEN", open))...) result = append(result, data[start:footerAt]...) if middle != "" { result = append(result, []byte(injection("FOOTER", middle))...) } result = append(result, data[footerAt:end]...) result = append(result, []byte(injection("CLOSE", close))...) result = append(result, data[end:]...) return result, nil } var injectionPattern = regexp.MustCompile(`(?s).*?`) func StripInjections(data []byte) ([]byte, error) { for _, m := range injectionPattern.FindAllSubmatch(data, -1) { if !bytes.Equal(m[1], m[2]) { return nil, fmt.Errorf("mismatched injection markers") } } result := injectionPattern.ReplaceAll(data, nil) if bytes.Contains(result, []byte("