Files
GiteaMailTemplates/tools/builder/build.go
T
KenanZhu fec3ace600
Release / Validate Templates (push) Canceled after 0s
Release / Package & Release (push) Canceled after 0s
Release / Update Latest Release Documentation (push) Canceled after 0s
chore: migrate mail themes to shared framework and locked upstream inputs
2026-10-09 18:34:36 +08:00

358 lines
9.9 KiB
Go

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 "<!-- THEME:" + name + " -->" + content + "<!-- /THEME:" + name + " -->"
}
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<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<style>\n"+t.CSS+"\n</style>\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)<!-- THEME:(HEAD|OPEN|FOOTER|CLOSE) -->.*?<!-- /THEME:(HEAD|OPEN|FOOTER|CLOSE) -->`)
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("<!-- THEME:")) || bytes.Contains(result, []byte("<!-- /THEME:")) {
return nil, fmt.Errorf("unpaired injection marker")
}
return result, nil
}
// EffectiveTemplates overlays the minimal install package on stock Gitea,
// matching the custom-before-builtin lookup used by Gitea's layered asset FS.
func EffectiveTemplates(s *upstream.Snapshot, p *Package) map[string][]byte {
result := map[string][]byte{}
for n, b := range s.Templates {
result[n] = b
}
if p != nil {
for n, b := range p.Files {
result[n] = b
}
}
return result
}
// Write owns only generated files under build/themes. Updating files in place
// avoids directory-rename races with Windows antivirus and the dev watcher.
func Write(output string, s *upstream.Snapshot, packages map[string]*Package) error {
abs, err := filepath.Abs(output)
if err != nil {
return err
}
if filepath.Base(abs) != "themes" || filepath.Base(filepath.Dir(abs)) != "build" {
return fmt.Errorf("generated output must be build/themes")
}
if err := os.MkdirAll(abs, 0755); err != nil {
return err
}
var names []string
for n := range packages {
names = append(names, n)
}
sort.Strings(names)
for _, name := range names {
if !ValidName(name) {
return fmt.Errorf("unsafe theme name")
}
dest := filepath.Join(abs, name)
if info, err := os.Lstat(dest); err == nil && info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("generated theme cannot be a symlink")
}
if _, err := os.Stat(dest); err == nil {
if _, err := os.Stat(filepath.Join(dest, "build.json")); err != nil {
return fmt.Errorf("refusing to replace non-generated directory %s", dest)
}
}
p := packages[name]
if err := os.MkdirAll(dest, 0755); err != nil {
return err
}
err := filepath.WalkDir(dest, func(filename string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.Type()&os.ModeSymlink != 0 {
return fmt.Errorf("generated symlink %s", filename)
}
if !d.IsDir() && strings.HasSuffix(filename, ".tmpl") {
rel, _ := filepath.Rel(dest, filename)
rel = filepath.ToSlash(rel)
if !strings.HasPrefix(rel, "mail/") || !upstream.SafePath(rel) {
return fmt.Errorf("unexpected generated file %s", rel)
}
if _, ok := p.Files[rel]; !ok {
return os.Remove(filename)
}
}
return nil
})
if err != nil {
return err
}
hashes := map[string]string{}
for filename, b := range p.Files {
if !upstream.SafePath(filename) {
return fmt.Errorf("unsafe generated path")
}
target := filepath.Join(dest, filepath.FromSlash(filename))
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return err
}
if err := os.WriteFile(target, b, 0644); err != nil {
return err
}
hashes[filename] = upstream.Digest(b)
}
meta, _ := json.MarshalIndent(map[string]any{"theme": name, "mode": p.Theme.Mode, "gitea_tag": s.Lock.Tag, "gitea_commit": s.Lock.Commit, "files": hashes, "sources": p.Theme.Sources}, "", " ")
if err := os.WriteFile(filepath.Join(dest, "build.json"), append(meta, '\n'), 0644); err != nil {
return err
}
}
return nil
}
func Filter(names []string) (map[string]bool, error) {
if len(names) == 0 {
return nil, fmt.Errorf("specify themes or 'all'")
}
if len(names) == 1 && names[0] == "all" {
return nil, nil
}
f := map[string]bool{}
for _, n := range names {
if !ValidName(n) {
return nil, fmt.Errorf("invalid theme %q", n)
}
f[n] = true
}
return f, nil
}
func TemplateName(p string) string { return strings.TrimSuffix(p, ".tmpl") }