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("