chore: migrate mail themes to shared framework and locked upstream inputs
This commit is contained in:
1 parent
5c0589f6f6
commit
fec3ace600
225 files changed
+4718
-15807
No files matched your search
@@ -0,0 +1,42 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"golang.org/x/net/html"
|
||||
"io"
|
||||
)
|
||||
|
||||
func BodyAnchors(masked []byte) ([2]int, error) {
|
||||
var anchors [2]int
|
||||
opens, closes, offset := 0, 0, 0
|
||||
z := html.NewTokenizer(bytes.NewReader(masked))
|
||||
for {
|
||||
kind := z.Next()
|
||||
raw := z.Raw()
|
||||
start := offset
|
||||
offset += len(raw)
|
||||
switch kind {
|
||||
case html.ErrorToken:
|
||||
if z.Err() != io.EOF {
|
||||
return anchors, z.Err()
|
||||
}
|
||||
if opens != 1 || closes != 1 || anchors[0] >= anchors[1] {
|
||||
return anchors, fmt.Errorf("expected exactly one static body start and end")
|
||||
}
|
||||
return anchors, nil
|
||||
case html.StartTagToken:
|
||||
tag, _ := z.TagName()
|
||||
if bytes.Equal(tag, []byte("body")) {
|
||||
opens++
|
||||
anchors[0] = offset
|
||||
}
|
||||
case html.EndTagToken:
|
||||
tag, _ := z.TagName()
|
||||
if bytes.Equal(tag, []byte("body")) {
|
||||
closes++
|
||||
anchors[1] = start
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
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") }
|
||||
@@ -0,0 +1,106 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"gitea-mail-templates/tools/upstream"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerationPreservesSnapshot(t *testing.T) {
|
||||
s, err := upstream.Load(filepath.Join("..", "..", "build", "upstream"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packages, err := BuildAll(filepath.Join("..", "..", "themes"), s, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, p := range packages {
|
||||
if len(p.Files) != len(s.Templates)+3 {
|
||||
t.Fatalf("%s missing generated files", name)
|
||||
}
|
||||
for filename, generated := range p.Files {
|
||||
if _, ok := p.Theme.Controls[filename]; ok {
|
||||
continue
|
||||
}
|
||||
original, err := StripInjections(generated)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
baseline := s.Templates[filename]
|
||||
if !bytes.HasPrefix([]byte(filename), []byte("mail/base/")) {
|
||||
baseline, err = AdaptOfficial(s, filename)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !bytes.Equal(original, baseline) {
|
||||
t.Fatal("official source altered")
|
||||
}
|
||||
}
|
||||
again, err := Build(s, p.Theme)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for filename, b := range again.Files {
|
||||
if !bytes.Equal(b, p.Files[filename]) {
|
||||
t.Fatal("nondeterministic build")
|
||||
}
|
||||
}
|
||||
}
|
||||
shared, err := Build(s, &Theme{Name: "test", Mode: "shared", CSS: "a {color:#000}"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(shared.Files) != 2 {
|
||||
t.Fatal("shared theme overrides正文")
|
||||
}
|
||||
output := filepath.Join(t.TempDir(), "build", "themes")
|
||||
if err := Write(output, s, map[string]*Package{"test": packages["mono"]}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Write(output, s, map[string]*Package{"test": shared}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(output, "test", "mail", "repo", "release.tmpl")); !os.IsNotExist(err) {
|
||||
t.Fatal("stale framed override retained")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectUnsafeDecorationAndAnchors(t *testing.T) {
|
||||
for _, markup := range []string{"<div>Extra text</div>", "<a href='https://example.com'></a>", "<div onclick='bad()'></div>", "<div>{{.Subject}}</div>", "<table><tr></table>"} {
|
||||
if ValidateDecoration(markup) == nil {
|
||||
t.Fatalf("accepted %s", markup)
|
||||
}
|
||||
}
|
||||
for _, css := range []string{"a {background:url(https://example.com)}", "p {display:none}", "div:before {content:'Extra'}", "</style>"} {
|
||||
if ValidateCSS(css) == nil {
|
||||
t.Fatalf("accepted %s", css)
|
||||
}
|
||||
}
|
||||
if err := ValidateCSS("p {font-size:0.9em}"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ValidateCSS("p {font-size:0!important}") == nil {
|
||||
t.Fatal("accepted hidden text")
|
||||
}
|
||||
for _, source := range []string{"<html></html>", "<body><body>{{template \"mail/base/footer\"}}</body>", "<body></body>", "<body>{{template \"mail/base/footer\"}}</body></body>"} {
|
||||
if _, err := Frame([]byte(source), "<div>", "</div>"); err == nil {
|
||||
t.Fatalf("accepted changed anchor %s", source)
|
||||
}
|
||||
}
|
||||
good := []byte("<!-- <body> -->{{ $x := \"<body> }}\" }}<body>{{template \"mail/base/footer\"}}</body>")
|
||||
result, err := Frame(good, "<div>", "</div>")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stripped, err := StripInjections(result)
|
||||
if err != nil || !bytes.Equal(stripped, good) {
|
||||
t.Fatal("action/comment anchors altered")
|
||||
}
|
||||
if _, err := MaskActions([]byte("{{ /* unclosed")); err == nil {
|
||||
t.Fatal("accepted invalid action")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/upstream"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type actionSpec struct {
|
||||
expression, href, text, key string
|
||||
args string
|
||||
}
|
||||
|
||||
// This is the single alignment layer between reviewed official mail contexts
|
||||
// and shared controls. Themes never own URLs, translation keys or mail logic.
|
||||
var actions = map[string]actionSpec{
|
||||
"activate": {expression: "$activate_url", href: "{{$activate_url}}", text: "{{$activate_url}}", key: "mail.activate_account"},
|
||||
"activate_email": {expression: "$activate_url", href: "{{$activate_url}}", text: "{{$activate_url}}", key: "mail.activate_email"},
|
||||
"reset_passwd": {expression: "$recover_url", href: "{{$recover_url}}", text: "{{$recover_url}}", key: "mail.reset_password"},
|
||||
"register_notify": {expression: `(printf "%suser/login" AppUrl)`, href: "{{AppUrl}}user/login", text: "{{AppUrl}}user/login", key: "mail.view_it_on", args: " AppName"},
|
||||
"team_invite": {expression: ".InviteURL", href: "{{.InviteURL}}", text: "{{.InviteURL}}", key: "mail.view_it_on", args: " AppName"},
|
||||
"collaborator": {expression: ".Link", key: "mail.view_it_on", args: " AppName"},
|
||||
"transfer": {expression: ".Link", key: "mail.view_it_on", args: " AppName"},
|
||||
"release": {expression: ".Release.HTMLURL", key: "mail.view_it_on", args: " AppName"},
|
||||
"workflow_run": {expression: ".Run.HTMLURL", key: "mail.view_it_on", args: " AppName"},
|
||||
"assigned": {expression: ".Link", key: "mail.view_it_on", args: " AppName"},
|
||||
"default": {expression: ".Link", key: "mail.view_it_on", args: " AppName"},
|
||||
}
|
||||
|
||||
type sourceEdit struct{ before, after string }
|
||||
|
||||
// AdaptOfficial leaves notification branches/data accesses upstream-owned;
|
||||
// only the presentation of the primary action and fallback is reorganized.
|
||||
// An edit ledger checks action changes before static presentation annotation.
|
||||
func AdaptOfficial(s *upstream.Snapshot, name string) ([]byte, error) {
|
||||
original := string(s.Templates[name])
|
||||
source := original
|
||||
spec, ok := actions[upstream.TemplateID(name)]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("new mail type %s needs framework alignment", name)
|
||||
}
|
||||
if _, ok := s.Locales["en-US"][spec.key]; !ok {
|
||||
return nil, fmt.Errorf("missing framework translation key %s", spec.key)
|
||||
}
|
||||
control := fmt.Sprintf(`{{template "mail/base/action" (dict "URL" %s "Label" (.locale.Tr %q%s) "Hint" (.locale.Tr "mail.link_not_working_do_paste"))}}`, spec.expression, spec.key, spec.args)
|
||||
var edits []sourceEdit
|
||||
replace := func(before, after string) error {
|
||||
if strings.Count(source, before) != 1 {
|
||||
return fmt.Errorf("%s: expected one reviewed action anchor %q", name, before)
|
||||
}
|
||||
source = strings.Replace(source, before, after, 1)
|
||||
edits = append(edits, sourceEdit{before, after})
|
||||
return nil
|
||||
}
|
||||
if spec.href != "" {
|
||||
// Search only static markup/action boundaries reviewed for the primary URL.
|
||||
pattern := regexp.MustCompile(`<p(?: style="word-break: break-all;")?><a href="` + regexp.QuoteMeta(spec.href) + `">` + regexp.QuoteMeta(spec.text) + `</a></p>`)
|
||||
matches := pattern.FindAllString(source, -1)
|
||||
if len(matches) != 1 {
|
||||
return nil, fmt.Errorf("%s: primary URL anchor changed; review framework adapter", name)
|
||||
}
|
||||
if err := replace(matches[0], control); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hint := `<p>{{.locale.Tr "mail.link_not_working_do_paste"}}</p>`
|
||||
if strings.Contains(source, hint) {
|
||||
if err := replace(hint, ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// These official bodies already contain their primary link in prose.
|
||||
// The extra button/fallback does not replace notification text or links.
|
||||
footer := `{{template "mail/base/footer"}}`
|
||||
if err := replace(footer, `<div class="framework-extra">`+control+`</div>`+"\n"+footer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// Replay the recorded action edits and require an exact output match.
|
||||
replay := original
|
||||
for _, e := range edits {
|
||||
replay = strings.Replace(replay, e.before, e.after, 1)
|
||||
}
|
||||
if replay != source {
|
||||
return nil, fmt.Errorf("unrecorded framework change")
|
||||
}
|
||||
if !bytes.Equal([]byte(original), s.Templates[name]) {
|
||||
return nil, fmt.Errorf("upstream source mutated")
|
||||
}
|
||||
return paragraphRoles([]byte(source), upstream.TemplateID(name))
|
||||
}
|
||||
|
||||
// Use the established theme classes rather than duplicating their typography
|
||||
// in generic CSS. Only static paragraph tags outside Go actions are annotated.
|
||||
func paragraphRoles(data []byte, id string) ([]byte, error) {
|
||||
masked, err := MaskActions(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
anchors, err := BodyAnchors(masked)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tags := regexp.MustCompile(`(?i)<p(?:\s[^<>]*?)?>`).FindAllIndex(masked[anchors[0]:anchors[1]], -1)
|
||||
type edit struct {
|
||||
at int
|
||||
class string
|
||||
}
|
||||
var edits []edit
|
||||
for i, loc := range tags {
|
||||
start, end := anchors[0]+loc[0], anchors[0]+loc[1]
|
||||
if bytes.Contains(masked[start:end], []byte("class=")) {
|
||||
continue
|
||||
}
|
||||
role := "email-text"
|
||||
if i == 0 && id != "activate" && id != "activate_email" {
|
||||
role = "email-heading"
|
||||
}
|
||||
stop := bytes.Index(masked[end:anchors[1]], []byte("</p>"))
|
||||
if stop < 0 {
|
||||
return nil, fmt.Errorf("unclosed official paragraph")
|
||||
}
|
||||
content := string(data[end : end+stop])
|
||||
for _, key := range []string{"mail.activate_account.text_2", "mail.register_notify.text_2", "mail.register_notify.text_3", "mail.team_invite.text_3"} {
|
||||
if strings.Contains(content, `"`+key+`"`) {
|
||||
role = "email-subtext"
|
||||
}
|
||||
}
|
||||
edits = append(edits, edit{start + 2, role})
|
||||
}
|
||||
result := bytes.Clone(data)
|
||||
for i := len(edits) - 1; i >= 0; i-- {
|
||||
e := edits[i]
|
||||
result = append(append(append([]byte{}, result[:e.at]...), []byte(` class="`+e.class+`"`)...), result[e.at:]...)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"gitea-mail-templates/tools/upstream"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParagraphRolesPreserveOfficialActions(t *testing.T) {
|
||||
source := `<html><body><p>{{.locale.Tr "mail.hi_user_x" .Username}}</p><p>{{.locale.Tr "mail.register_notify.text_2"}}</p>{{printf "<p>not static</p>"}}<p class="existing">Kept</p></body></html>`
|
||||
result, err := paragraphRoles([]byte(source), "register_notify")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := strings.Replace(source, `<p>`, `<p class="email-heading">`, 1)
|
||||
want = strings.Replace(want, `<p>`, `<p class="email-subtext">`, 1)
|
||||
if string(result) != want {
|
||||
t.Fatalf("unexpected presentation annotation: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedAlignmentFailsOnDrift(t *testing.T) {
|
||||
s, err := upstream.LoadPinned(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := "mail/user/auth/activate.tmpl"
|
||||
original := bytes.Clone(s.Templates[name])
|
||||
s.Templates[name] = bytes.Replace(original, []byte(`href="{{$activate_url}}"`), []byte(`href="changed"`), 1)
|
||||
if _, err := AdaptOfficial(s, name); err == nil {
|
||||
t.Fatal("unreviewed URL anchor accepted")
|
||||
}
|
||||
s.Templates[name] = original
|
||||
delete(s.Locales["en-US"], "mail.activate_account")
|
||||
if _, err := AdaptOfficial(s, name); err == nil {
|
||||
t.Fatal("missing button key accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Package builder generates Gitea overrides from immutable upstream templates.
|
||||
package builder
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/upstream"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
type Theme struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Mode string `json:"mode"`
|
||||
Layout string `json:"layout,omitempty"`
|
||||
CSS string `json:"-"`
|
||||
Open string `json:"-"`
|
||||
Close string `json:"-"`
|
||||
Footer string `json:"-"`
|
||||
Sources map[string]string `json:"-"`
|
||||
Controls map[string][]byte `json:"-"`
|
||||
}
|
||||
|
||||
var themeName = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||||
var cssBlocks = regexp.MustCompile(`(?s)([^{}]+)\{([^{}]*)\}`)
|
||||
var hiddenContent = regexp.MustCompile(`(?:display\s*:\s*none\b|visibility\s*:\s*hidden\b|font-size\s*:\s*0(?:px)?\s*(?:!important\s*)?(?:;|$))`)
|
||||
|
||||
func ValidName(name string) bool { return themeName.MatchString(name) }
|
||||
|
||||
func Discover(dir string) ([]string, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if e.Type()&os.ModeSymlink != 0 {
|
||||
return nil, fmt.Errorf("theme symlinks are unsupported: %s", e.Name())
|
||||
}
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if !ValidName(e.Name()) {
|
||||
return nil, fmt.Errorf("invalid theme name %q", e.Name())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, e.Name(), "theme.json")); err != nil {
|
||||
return nil, fmt.Errorf("theme %s: %w", e.Name(), err)
|
||||
}
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return nil, fmt.Errorf("no theme manifests in %s", dir)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func LoadTheme(dir string) (*Theme, error) {
|
||||
b, err := os.ReadFile(filepath.Join(dir, "theme.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t := &Theme{}
|
||||
dec := json.NewDecoder(strings.NewReader(string(b)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ValidName(t.Name) || t.Name != filepath.Base(dir) || (t.Mode != "shared" && t.Mode != "framed") {
|
||||
return nil, fmt.Errorf("invalid theme name or mode in %s", dir)
|
||||
}
|
||||
b, err = os.ReadFile(filepath.Join(dir, "theme.css"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.CSS = string(b)
|
||||
if err := ValidateCSS(t.CSS); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t.Mode == "framed" {
|
||||
if t.Layout == "" {
|
||||
t.Layout = "standard"
|
||||
}
|
||||
if !ValidName(t.Layout) {
|
||||
return nil, fmt.Errorf("invalid framework layout %q", t.Layout)
|
||||
}
|
||||
root := filepath.Dir(filepath.Dir(dir))
|
||||
layoutDir := filepath.Join(root, "framework", "layouts", t.Layout)
|
||||
b, err = os.ReadFile(filepath.Join(layoutDir, "frame-open.html"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Open = string(b)
|
||||
b, err = os.ReadFile(filepath.Join(layoutDir, "frame-close.html"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Close = string(b)
|
||||
b, err = os.ReadFile(filepath.Join(layoutDir, "frame-footer.html"))
|
||||
if err == nil {
|
||||
t.Footer = string(b)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
if strings.Count(t.Open, "__HEADER__") != 1 {
|
||||
return nil, fmt.Errorf("layout must contain one header slot")
|
||||
}
|
||||
if err := ValidateDecoration(strings.ReplaceAll(t.Open, "__HEADER__", "") + strings.ReplaceAll(t.Footer, "__SIDEBAR__", "") + t.Close); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Controls = map[string][]byte{}
|
||||
for _, name := range []string{"header", "footer", "action", "sidebar"} {
|
||||
b, err := os.ReadFile(filepath.Join(root, "framework", "mail", "base", name+".tmpl"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Controls["mail/base/"+name+".tmpl"] = b
|
||||
}
|
||||
}
|
||||
t.Sources = map[string]string{}
|
||||
for _, name := range []string{"theme.json", "theme.css"} {
|
||||
filename := filepath.Join(dir, name)
|
||||
info, err := os.Lstat(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("theme resource must be a regular file: %s", filename)
|
||||
}
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Sources[name] = upstream.Digest(data)
|
||||
}
|
||||
if t.Mode == "framed" {
|
||||
root := filepath.Dir(filepath.Dir(dir))
|
||||
for name, b := range t.Controls {
|
||||
t.Sources["framework/"+name] = upstream.Digest(b)
|
||||
}
|
||||
for _, name := range []string{"frame-open.html", "frame-footer.html", "frame-close.html"} {
|
||||
path := filepath.Join("framework", "layouts", t.Layout, name)
|
||||
b, err := os.ReadFile(filepath.Join(root, path))
|
||||
if os.IsNotExist(err) && name == "frame-footer.html" {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Sources[filepath.ToSlash(path)] = upstream.Digest(b)
|
||||
}
|
||||
for _, name := range []string{"build.go", "framework.go", "theme.go", "anchors.go"} {
|
||||
path := filepath.Join("tools", "builder", name)
|
||||
b, err := os.ReadFile(filepath.Join(root, path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Sources[filepath.ToSlash(path)] = upstream.Digest(b)
|
||||
}
|
||||
}
|
||||
if err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(dir, path)
|
||||
if rel != "theme.json" && rel != "theme.css" {
|
||||
return fmt.Errorf("theme %s may contain only theme.json and theme.css, not %s", t.Name, rel)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func ValidateCSS(css string) error {
|
||||
low := strings.ToLower(css)
|
||||
for _, bad := range []string{"{{", "}}", "<", "url(", "@import", "expression(", "javascript:", "content:", "content :"} {
|
||||
if strings.Contains(low, bad) {
|
||||
return fmt.Errorf("theme CSS contains disallowed content %q", bad)
|
||||
}
|
||||
}
|
||||
for _, block := range cssBlocks.FindAllStringSubmatch(low, -1) {
|
||||
if hiddenContent.MatchString(block[2]) {
|
||||
for _, selector := range strings.Split(block[1], ",") {
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector != ".resp-hide" && selector != ".email-sidebar" && selector != ".email-ornament" {
|
||||
return fmt.Errorf("CSS may hide only decorative nodes, not %s", selector)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decoration may contain structural presentation nodes but no text, business
|
||||
// links, template actions, scripts, images, or external resources.
|
||||
func ValidateDecoration(fragment string) error {
|
||||
if strings.Contains(fragment, "{{") || strings.Contains(fragment, "}}") {
|
||||
return fmt.Errorf("decoration must not contain Go template actions")
|
||||
}
|
||||
allowed := map[string]bool{"table": true, "tbody": true, "tr": true, "td": true, "div": true, "span": true}
|
||||
attrs := map[string]bool{"class": true, "style": true, "role": true, "width": true, "height": true, "cellpadding": true, "cellspacing": true, "border": true, "align": true, "valign": true, "aria-hidden": true, "colspan": true}
|
||||
z := html.NewTokenizer(strings.NewReader(fragment))
|
||||
var stack []string
|
||||
for {
|
||||
switch z.Next() {
|
||||
case html.ErrorToken:
|
||||
if z.Err() != io.EOF {
|
||||
return z.Err()
|
||||
}
|
||||
if len(stack) != 0 {
|
||||
return fmt.Errorf("unbalanced decoration")
|
||||
}
|
||||
return nil
|
||||
case html.TextToken:
|
||||
if strings.TrimSpace(string(z.Text())) != "" {
|
||||
return fmt.Errorf("decoration must not add visible text")
|
||||
}
|
||||
case html.StartTagToken, html.SelfClosingTagToken:
|
||||
token := z.Token()
|
||||
if !allowed[token.Data] {
|
||||
return fmt.Errorf("disallowed decoration element %s", token.Data)
|
||||
}
|
||||
for _, a := range token.Attr {
|
||||
if !attrs[a.Key] {
|
||||
return fmt.Errorf("disallowed decoration attribute %s", a.Key)
|
||||
}
|
||||
if a.Key == "style" {
|
||||
if err := ValidateCSS(a.Val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if token.Type == html.StartTagToken {
|
||||
stack = append(stack, token.Data)
|
||||
}
|
||||
case html.EndTagToken:
|
||||
token := z.Token()
|
||||
if len(stack) == 0 || stack[len(stack)-1] != token.Data {
|
||||
return fmt.Errorf("unbalanced decoration end %s", token.Data)
|
||||
}
|
||||
stack = stack[:len(stack)-1]
|
||||
default:
|
||||
return fmt.Errorf("decoration accepts only static presentation markup")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/builder"
|
||||
"gitea-mail-templates/tools/upstream"
|
||||
"github.com/urfave/cli/v2"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func BuildCommand() *cli.Command {
|
||||
return &cli.Command{Name: "build", Usage: "Generate installable themes from locked official inputs and shared controls", Flags: []cli.Flag{&cli.StringFlag{Name: "folder", Value: "../themes"}}, Action: func(c *cli.Context) error {
|
||||
s, err := upstream.Prepare(c.Context, filepath.Dir(c.String("folder")))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filter, err := builder.Filter(c.Args().Slice())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
packages, err := builder.BuildAll(c.String("folder"), s, filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
output := filepath.Join(filepath.Dir(c.String("folder")), "build", "themes")
|
||||
if err := builder.Write(output, s, packages); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("[PASS] Built %d themes for %s at %s\n", len(packages), s.Lock.Tag, output)
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
+23
-67
@@ -1,86 +1,42 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/builder"
|
||||
"github.com/urfave/cli/v2"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"gitea-mail-templates/tools/config"
|
||||
)
|
||||
|
||||
// CreateCommand returns the "create" subcommand.
|
||||
func CreateCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "create",
|
||||
Usage: "Create one or more new theme style directories",
|
||||
UsageText: "go run . create [--folder <themes-dir>] [--config <config-file>] <style-name>...",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "folder",
|
||||
Value: "../themes",
|
||||
Usage: "Path to the themes directory",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "config",
|
||||
Value: "./data/templates_config.json",
|
||||
Usage: "Path to templates_config.json",
|
||||
},
|
||||
},
|
||||
Action: runCreate,
|
||||
}
|
||||
return &cli.Command{Name: "create", Usage: "Create CSS and metadata using the shared control framework", Flags: []cli.Flag{&cli.StringFlag{Name: "folder", Value: "../themes"}}, Action: runCreate}
|
||||
}
|
||||
|
||||
func runCreate(c *cli.Context) error {
|
||||
names := c.Args().Slice()
|
||||
if len(names) == 0 {
|
||||
return fmt.Errorf("at least one style name is required")
|
||||
return fmt.Errorf("at least one theme name is required")
|
||||
}
|
||||
|
||||
folder := c.String("folder")
|
||||
cfg, err := config.Load(c.String("config"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, styleName := range names {
|
||||
styleDir := filepath.Join(folder, styleName)
|
||||
|
||||
if info, err := os.Stat(styleDir); err == nil {
|
||||
if info.IsDir() {
|
||||
fmt.Printf("\033[32m[I]\033[0m [CLI] '%s' already exists, skipped\n", styleName)
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("'%s' exists but is not a directory", styleName)
|
||||
for _, name := range names {
|
||||
if !builder.ValidName(name) {
|
||||
return fmt.Errorf("invalid theme name %q", name)
|
||||
}
|
||||
|
||||
created := 0
|
||||
for _, tplCfg := range cfg.Templates {
|
||||
tmplPath := filepath.Join(styleDir, tplCfg.PathStr())
|
||||
dir := filepath.Dir(tmplPath)
|
||||
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("cannot create directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
placeholder := fmt.Sprintf("<!DOCTYPE html>\n<html>\n<head>\n"+
|
||||
" <meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\" />\n"+
|
||||
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n"+
|
||||
" <title>{{.locale.Tr \"mail.%s.title\"}}</title>\n"+
|
||||
" <style type=\"text/css\">\n /* TODO: Add your custom styles here */\n"+
|
||||
" </style>\n</head>\n<body style=\"margin:0;padding:0;\">\n"+
|
||||
" <!-- TODO: Add your custom template design for: %s -->\n"+
|
||||
"</body>\n</html>\n", tplCfg.Name, tplCfg.Desc)
|
||||
|
||||
if err := os.WriteFile(tmplPath, []byte(placeholder), 0644); err != nil {
|
||||
return fmt.Errorf("cannot write %s: %w", tmplPath, err)
|
||||
}
|
||||
created++
|
||||
dir := filepath.Join(c.String("folder"), name)
|
||||
if _, err := os.Lstat(dir); err == nil {
|
||||
return fmt.Errorf("theme %s already exists", name)
|
||||
}
|
||||
|
||||
fmt.Printf("\033[32m[I]\033[0m [CLI] Created style '%s' with %d template files\n", styleName, created)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
meta, _ := json.MarshalIndent(builder.Theme{Name: name, Description: "New theme", Mode: "framed", Layout: "standard"}, "", " ")
|
||||
if err := os.WriteFile(filepath.Join(dir, "theme.json"), append(meta, '\n'), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
css := "/* Presentation only; shared controls own mail data and translations. */\nbody { margin:0; padding:24px 16px; background:#f4f6f9; }\nbody, td { font-family:Arial, sans-serif; }\n.email-wrap { max-width:600px; margin:auto; background:#fff; }\n.email-header, .email-card, .email-footer { padding:24px; }\n.email-logo { vertical-align:middle; margin-right:8px; }\n.email-btn,.card-body .email-btn,.card-body .email-btn:visited { display:inline-block; padding:12px 24px; color:#fff!important; background:#2563eb; text-decoration:none; border-radius:6px; }\n.email-link-url { overflow-wrap:anywhere; }\na, a:visited { color:#2563eb; }\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "theme.css"), []byte(css), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("[PASS] Created framework theme %s\n", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gitea-mail-templates/tools/builder"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
@@ -34,12 +35,21 @@ func runDelete(c *cli.Context) error {
|
||||
folder := c.String("folder")
|
||||
|
||||
for _, styleName := range names {
|
||||
if !builder.ValidName(styleName) {
|
||||
return fmt.Errorf("invalid theme name %q", styleName)
|
||||
}
|
||||
styleDir := filepath.Join(folder, styleName)
|
||||
if info, err := os.Lstat(styleDir); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("refusing to delete a theme symlink")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(styleDir); os.IsNotExist(err) {
|
||||
fmt.Printf("\033[33m[W]\033[0m [CLI] '%s' does not exist, skipped\n", styleName)
|
||||
continue
|
||||
}
|
||||
if _, err := builder.LoadTheme(styleDir); err != nil {
|
||||
return fmt.Errorf("refusing to delete a non-theme directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(styleDir); err != nil {
|
||||
return fmt.Errorf("cannot delete %s: %w", styleDir, err)
|
||||
|
||||
+14
-46
@@ -2,56 +2,24 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"gitea-mail-templates/tools/builder"
|
||||
"github.com/urfave/cli/v2"
|
||||
|
||||
"gitea-mail-templates/tools/preview"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// ListCommand returns the "list" subcommand.
|
||||
func ListCommand() *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "list",
|
||||
Usage: "List available theme styles in the target folder",
|
||||
UsageText: "go run . list [--folder <themes-dir>]",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "folder",
|
||||
Value: "../themes",
|
||||
Usage: "Path to the themes directory",
|
||||
},
|
||||
},
|
||||
Action: runList,
|
||||
}
|
||||
}
|
||||
|
||||
func runList(c *cli.Context) error {
|
||||
folder := c.String("folder")
|
||||
|
||||
themes, err := preview.DiscoverThemes(folder)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
absDir, _ := filepath.Abs(folder)
|
||||
fmt.Printf("\033[32m[I]\033[0m [Preview] Available styles in '%s' (%d):\n", filepath.Base(absDir), len(themes))
|
||||
|
||||
sort.Strings(themes)
|
||||
for _, t := range themes {
|
||||
count := 0
|
||||
filepath.Walk(filepath.Join(folder, t), func(path string, info os.FileInfo, err error) error {
|
||||
return &cli.Command{Name: "list", Usage: "List available theme manifests", Flags: []cli.Flag{&cli.StringFlag{Name: "folder", Value: "../themes"}}, Action: func(c *cli.Context) error {
|
||||
names, err := builder.Discover(c.String("folder"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range names {
|
||||
t, err := builder.LoadTheme(filepath.Join(c.String("folder"), name))
|
||||
if err != nil {
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
if filepath.Ext(path) == ".tmpl" {
|
||||
count++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
fmt.Printf("\033[32m[I]\033[0m [Preview] %-16s (%d .tmpl files)\n", t, count)
|
||||
}
|
||||
return nil
|
||||
fmt.Printf("%-16s %-8s %s\n", name, t.Mode, t.Description)
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
}
|
||||
@@ -66,9 +66,9 @@ func runPreview(c *cli.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
result := preview.RenderAll(folder, cfg, themeFilter)
|
||||
if result == nil {
|
||||
return fmt.Errorf("no results produced")
|
||||
result, err := preview.RenderAll(folder, cfg, themeFilter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := preview.WriteRenderedJS(result, outputPath); err != nil {
|
||||
@@ -78,7 +78,7 @@ func runPreview(c *cli.Context) error {
|
||||
preview.PrintDetailedSummary(result, folder, cfg)
|
||||
|
||||
fmt.Printf("\033[32m[I]\033[0m [Preview] Wrote %d themes x %d templates to %s\n",
|
||||
len(result.Summaries), len(cfg.Templates), outputPath)
|
||||
len(result.Themes), len(cfg.Templates), outputPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/upstream"
|
||||
"github.com/urfave/cli/v2"
|
||||
)
|
||||
|
||||
func UpstreamCommand() *cli.Command {
|
||||
return &cli.Command{Name: "upstream", Usage: "Prepare, synchronize or verify locked official build inputs", Subcommands: []*cli.Command{
|
||||
{Name: "prepare", Usage: "Download missing immutable cache files from gitea.lock.json", Flags: []cli.Flag{&cli.StringFlag{Name: "root", Value: ".."}}, Action: func(c *cli.Context) error { _, err := upstream.Prepare(c.Context, c.String("root")); return err }},
|
||||
{Name: "sync", Usage: "Review a new Gitea tag and regenerate the lock and cache", Flags: []cli.Flag{&cli.StringFlag{Name: "tag", Required: true}, &cli.StringFlag{Name: "root", Value: ".."}}, Action: func(c *cli.Context) error { return upstream.SyncRoot(c.Context, c.String("tag"), c.String("root")) }},
|
||||
{Name: "verify", Usage: "Verify cached inputs against the repository lock offline", Flags: []cli.Flag{&cli.StringFlag{Name: "root", Value: ".."}}, Action: func(c *cli.Context) error {
|
||||
s, err := upstream.LoadPinned(c.String("root"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
missing, err := upstream.MissingKeys(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("[PASS] %s: %d mail files, %d languages\n", s.Lock.Tag, len(s.Templates), len(s.Locales))
|
||||
for _, lang := range s.Languages() {
|
||||
if keys := missing[lang]; len(keys) > 0 {
|
||||
fmt.Printf("[FALLBACK] %s -> en-US: %v\n", lang, keys)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
}}
|
||||
}
|
||||
+31
-2
@@ -12,7 +12,7 @@ import (
|
||||
type TemplateConfig struct {
|
||||
Name string `json:"name"`
|
||||
Desc string `json:"desc"`
|
||||
Path []string `json:"path"`
|
||||
Path []string `json:"-"` // Discovered from the pinned upstream snapshot.
|
||||
Params map[string]any `json:"params"`
|
||||
}
|
||||
|
||||
@@ -29,7 +29,9 @@ func Load(path string) (*TemplatesConfig, error) {
|
||||
}
|
||||
|
||||
var cfg TemplatesConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("cannot parse config file %s: %w", path, err)
|
||||
}
|
||||
|
||||
@@ -37,9 +39,36 @@ func Load(path string) (*TemplatesConfig, error) {
|
||||
return nil, fmt.Errorf("config file %s contains no template definitions", path)
|
||||
}
|
||||
|
||||
for id, t := range cfg.Templates {
|
||||
t.Params = normalizeNumbers(t.Params).(map[string]any)
|
||||
cfg.Templates[id] = t
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func normalizeNumbers(v any) any {
|
||||
switch v := v.(type) {
|
||||
case json.Number:
|
||||
if n, err := v.Int64(); err == nil {
|
||||
return n
|
||||
}
|
||||
n, _ := v.Float64()
|
||||
return n
|
||||
case map[string]any:
|
||||
for k, value := range v {
|
||||
v[k] = normalizeNumbers(value)
|
||||
}
|
||||
return v
|
||||
case []any:
|
||||
for i, value := range v {
|
||||
v[i] = normalizeNumbers(value)
|
||||
}
|
||||
return v
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// PathStr returns the file path of a template definition joined with OS separator.
|
||||
func (t *TemplateConfig) PathStr() string {
|
||||
return filepath.Join(t.Path...)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"activate": {
|
||||
"name": "Activate Account",
|
||||
"desc": "Account activation — sent when a new user registers.",
|
||||
"path": ["mail", "user", "auth", "activate.tmpl"],
|
||||
"params": {
|
||||
"DisplayName": "Alex Johnson",
|
||||
"Code": "activation-code-abc123",
|
||||
@@ -13,7 +12,6 @@
|
||||
"activate_email": {
|
||||
"name": "Activate Email",
|
||||
"desc": "Email address verification — sent when adding or changing an email address.",
|
||||
"path": ["mail", "user", "auth", "activate_email.tmpl"],
|
||||
"params": {
|
||||
"DisplayName": "Alex Johnson",
|
||||
"Code": "verify-code-xyz789",
|
||||
@@ -24,7 +22,6 @@
|
||||
"register_notify": {
|
||||
"name": "Register Notify",
|
||||
"desc": "Registration notification — confirms successful account registration.",
|
||||
"path": ["mail", "user", "auth", "register_notify.tmpl"],
|
||||
"params": {
|
||||
"DisplayName": "Alex Johnson",
|
||||
"Username": "alexj"
|
||||
@@ -33,7 +30,6 @@
|
||||
"reset_passwd": {
|
||||
"name": "Reset Password",
|
||||
"desc": "Password reset — sent when user requests a password reset link.",
|
||||
"path": ["mail", "user", "auth", "reset_passwd.tmpl"],
|
||||
"params": {
|
||||
"DisplayName": "Alex Johnson",
|
||||
"Code": "reset-token-abc123",
|
||||
@@ -43,7 +39,6 @@
|
||||
"team_invite": {
|
||||
"name": "Team Invite",
|
||||
"desc": "Team invitation — sent when a user is invited to join an organization team.",
|
||||
"path": ["mail", "org", "team_invite.tmpl"],
|
||||
"params": {
|
||||
"Inviter": { "DisplayName": "Sarah Chen" },
|
||||
"Team": { "Name": "Core Developers" },
|
||||
@@ -55,7 +50,6 @@
|
||||
"collaborator": {
|
||||
"name": "Collaborator Added",
|
||||
"desc": "Collaborator added — sent when a user is added as a repository collaborator.",
|
||||
"path": ["mail", "repo", "collaborator.tmpl"],
|
||||
"params": {
|
||||
"Subject": "Collaborator Added to repo",
|
||||
"Link": "https://gitea.example.com/repo/project",
|
||||
@@ -65,7 +59,6 @@
|
||||
"transfer": {
|
||||
"name": "Repository Transfer",
|
||||
"desc": "Repository transfer — sent when repository ownership is transferred.",
|
||||
"path": ["mail", "repo", "transfer.tmpl"],
|
||||
"params": {
|
||||
"Subject": "Repository Transfer",
|
||||
"Link": "https://gitea.example.com/repo/transferred-project",
|
||||
@@ -75,7 +68,6 @@
|
||||
"release": {
|
||||
"name": "New Release",
|
||||
"desc": "New release — sent when a new release is published.",
|
||||
"path": ["mail", "repo", "release.tmpl"],
|
||||
"params": {
|
||||
"Subject": "New Release v2.0.0",
|
||||
"Link": "https://gitea.example.com/repo/project/releases/tag/v2.0.0",
|
||||
@@ -99,7 +91,6 @@
|
||||
"workflow_run": {
|
||||
"name": "Workflow Run",
|
||||
"desc": "Workflow run — sent when a CI/CD workflow completes.",
|
||||
"path": ["mail", "repo", "actions", "workflow_run.tmpl"],
|
||||
"params": {
|
||||
"Subject": "Workflow Run: CI",
|
||||
"RunStatusText": "All jobs passed",
|
||||
@@ -109,18 +100,18 @@
|
||||
"HTMLURL": "https://gitea.example.com/repo/project/actions/runs/42"
|
||||
},
|
||||
"Jobs": [
|
||||
{ "Name": "build", "Status": "success", "Attempt": 1, "HTMLURL": "https://gitea.example.com/job/1", "Duration": "2m34s" },
|
||||
{ "Name": "test", "Status": "success", "Attempt": 1, "HTMLURL": "https://gitea.example.com/job/2", "Duration": "1m12s" },
|
||||
{ "Name": "deploy", "Status": "success", "Attempt": 2, "HTMLURL": "https://gitea.example.com/job/3", "Duration": "45s" }
|
||||
{ "Name": "build", "StatusClass": "status-success", "StatusIconCID": "success", "StatusIconAlt": "Success", "Attempt": 1, "HTMLURL": "https://gitea.example.com/job/1", "Duration": "2m34s" },
|
||||
{ "Name": "test", "StatusClass": "status-success", "StatusIconCID": "success", "StatusIconAlt": "Success", "Attempt": 1, "HTMLURL": "https://gitea.example.com/job/2", "Duration": "1m12s" },
|
||||
{ "Name": "deploy", "StatusClass": "status-success", "StatusIconCID": "success", "StatusIconAlt": "Success", "Attempt": 2, "HTMLURL": "https://gitea.example.com/job/3", "Duration": "45s" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"assigned": {
|
||||
"name": "Issue Assigned",
|
||||
"desc": "Issue/PR assigned — sent when an issue or pull request is assigned.",
|
||||
"path": ["mail", "repo", "issue", "assigned.tmpl"],
|
||||
"params": {
|
||||
"Subject": "Issue #42 Assigned",
|
||||
"CanReply": false,
|
||||
"Link": "https://gitea.example.com/repo/project/issues/42",
|
||||
"Doer": { "Name": "Sarah Chen" },
|
||||
"IsPull": false,
|
||||
@@ -136,7 +127,6 @@
|
||||
"default": {
|
||||
"name": "Issue Update",
|
||||
"desc": "Issue/PR update — comments, mentions, reviews, merges, and other activity.",
|
||||
"path": ["mail", "repo", "issue", "default.tmpl"],
|
||||
"params": {
|
||||
"Subject": "Re: Issue #42 -- Bug fix",
|
||||
"Link": "https://gitea.example.com/repo/project/issues/42",
|
||||
|
||||
+5
-2
@@ -1,8 +1,11 @@
|
||||
module gitea-mail-templates/tools
|
||||
|
||||
go 1.21
|
||||
go 1.24.0
|
||||
|
||||
require github.com/urfave/cli/v2 v2.27.7
|
||||
require (
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/net v0.46.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
|
||||
@@ -6,3 +6,5 @@ github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
||||
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
@@ -0,0 +1,315 @@
|
||||
// Package integration exercises an explicitly supplied official Gitea binary.
|
||||
// Everything runs on loopback with disposable users, SQLite and captured SMTP.
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea-mail-templates/tools/builder"
|
||||
"gitea-mail-templates/tools/upstream"
|
||||
)
|
||||
|
||||
type safeLog struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (l *safeLog) Write(p []byte) (int, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.buf.Write(p)
|
||||
}
|
||||
func (l *safeLog) String() string { l.mu.Lock(); defer l.mu.Unlock(); return l.buf.String() }
|
||||
|
||||
func smtpCapture(t *testing.T) (int, <-chan []byte) {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { listener.Close() })
|
||||
mailbox := make(chan []byte, 4)
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer conn.Close()
|
||||
conn.SetDeadline(time.Now().Add(time.Minute))
|
||||
r := textproto.NewReader(bufio.NewReader(conn))
|
||||
fmt.Fprint(conn, "220 localhost SMTP capture\r\n")
|
||||
for {
|
||||
line, err := r.ReadLine()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
command := strings.ToUpper(fields[0])
|
||||
switch command {
|
||||
case "EHLO", "HELO":
|
||||
fmt.Fprint(conn, "250 localhost\r\n")
|
||||
case "DATA":
|
||||
fmt.Fprint(conn, "354 Send mail\r\n")
|
||||
data, err := r.ReadDotBytes()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
mailbox <- data
|
||||
fmt.Fprint(conn, "250 Captured\r\n")
|
||||
case "QUIT":
|
||||
fmt.Fprint(conn, "221 Goodbye\r\n")
|
||||
return
|
||||
default:
|
||||
fmt.Fprint(conn, "250 OK\r\n")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}()
|
||||
return listener.Addr().(*net.TCPAddr).Port, mailbox
|
||||
}
|
||||
|
||||
func TestGiteaMailSmoke(t *testing.T) {
|
||||
binary := os.Getenv("GITEA_SMOKE_BINARY")
|
||||
if binary == "" {
|
||||
t.Skip("set GITEA_SMOKE_BINARY to a checksum-verified official Gitea binary")
|
||||
}
|
||||
binary, err := filepath.Abs(binary)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := upstream.Load(filepath.Join("..", "..", "build", "upstream"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
version, err := exec.Command(binary, "--version").CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fields := strings.Fields(strings.ToLower(string(version)))
|
||||
if len(fields) < 3 || fields[0] != "gitea" || fields[1] != "version" || fields[2] != strings.TrimPrefix(s.Lock.Tag, "v") {
|
||||
t.Fatalf("binary does not match snapshot: %s", version)
|
||||
}
|
||||
for _, mode := range []string{"shared", "framed"} {
|
||||
t.Run(mode, func(t *testing.T) { smokeMode(t, binary, s, mode) })
|
||||
}
|
||||
}
|
||||
|
||||
func smokeMode(t *testing.T, binary string, s *upstream.Snapshot, mode string) {
|
||||
port, mailbox := smtpCapture(t)
|
||||
probe, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
webPort := probe.Addr().(*net.TCPAddr).Port
|
||||
probe.Close()
|
||||
work := t.TempDir()
|
||||
// Preseed only Gitea's disposable Git home; never touch the user's Git config.
|
||||
gitHome := filepath.Join(work, "data", "home")
|
||||
if err := os.MkdirAll(gitHome, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gitConfig := "[user]\nname = Gitea\nemail = gitea@fake.local\n[core]\nquotepath = false\nlongpaths = true\ncommitGraph = true\n[gc]\nwriteCommitGraph = true\n[fetch]\nwriteCommitGraph = true\n[init]\ndefaultBranch = main\n[receive]\nadvertisePushOptions = true\nprocReceiveRefs = refs/for\n[uploadpack]\nallowfilter = true\nallowAnySHA1InWant = true\n[safe]\ndirectory = *\n"
|
||||
if err := os.WriteFile(filepath.Join(gitHome, ".gitconfig"), []byte(gitConfig), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
custom := filepath.Join(work, "custom")
|
||||
configFile := filepath.Join(work, "app.ini")
|
||||
rootURL := fmt.Sprintf("http://127.0.0.1:%d/", webPort)
|
||||
ini := fmt.Sprintf("APP_NAME = Smoke Gitea\nRUN_MODE = prod\n[server]\nAPP_DATA_PATH = %s/data\nHTTP_ADDR = 127.0.0.1\nHTTP_PORT = %d\nROOT_URL = %s\nDISABLE_SSH = true\n[database]\nDB_TYPE = sqlite3\nPATH = %s/gitea.db\n[repository]\nROOT = %s/repos\n[security]\nINSTALL_LOCK = true\n[service]\nDISABLE_REGISTRATION = true\nREGISTER_EMAIL_CONFIRM = false\nENABLE_NOTIFY_MAIL = true\n[mailer]\nENABLED = true\nPROTOCOL = smtp\nSMTP_ADDR = 127.0.0.1\nSMTP_PORT = %d\nFROM = Smoke Gitea <smoke@example.invalid>\n[log]\nMODE = console\nLEVEL = warn\n", filepath.ToSlash(work), webPort, rootURL, filepath.ToSlash(work), filepath.ToSlash(work), port)
|
||||
if err := os.WriteFile(configFile, []byte(ini), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Disable key-file maintenance and isolate even its unused paths from HOME.
|
||||
ini = strings.Replace(ini, "DISABLE_SSH = true", "DISABLE_SSH = true\nSSH_ROOT_PATH = "+filepath.ToSlash(filepath.Join(work, "ssh"))+"\nSSH_CREATE_AUTHORIZED_KEYS_FILE = false\nSSH_CREATE_AUTHORIZED_PRINCIPALS_FILE = false", 1)
|
||||
if err := os.WriteFile(configFile, []byte(ini), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var p *builder.Package
|
||||
if mode == "framed" {
|
||||
theme, err := builder.LoadTheme(filepath.Join("..", "..", "themes", "horizon"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err = builder.Build(s, theme)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else {
|
||||
p, err = builder.Build(s, &builder.Theme{Name: "smoke", Mode: "shared", CSS: "a { color: #123456; }"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for name, data := range p.Files {
|
||||
target := filepath.Join(custom, "templates", filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(target, data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
args := []string{"--work-path", work, "--custom-path", custom, "--config", configFile}
|
||||
run := func(more ...string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, binary, append(append([]string{}, args...), more...)...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("Gitea %v: %v\n%s", more, err, output)
|
||||
}
|
||||
}
|
||||
run("migrate")
|
||||
run("admin", "user", "create", "--username", "smoke-user", "--password", "local-smoke-password-123!", "--email", "smoke@example.invalid", "--admin", "--must-change-password=false")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
var logs safeLog
|
||||
web := exec.CommandContext(ctx, binary, append(args, "web")...)
|
||||
web.Stdout = &logs
|
||||
web.Stderr = &logs
|
||||
if err := web.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() { _ = web.Wait(); close(done) }()
|
||||
t.Cleanup(func() { cancel(); <-done })
|
||||
jar, _ := cookiejar.New(nil)
|
||||
client := &http.Client{Jar: jar, Timeout: 5 * time.Second}
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
var page []byte
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatalf("Gitea exited: %s", logs.String())
|
||||
default:
|
||||
}
|
||||
resp, err := client.Get(rootURL + "user/forgot_password")
|
||||
if err == nil {
|
||||
page, _ = io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if len(page) == 0 {
|
||||
t.Fatalf("Gitea did not start: %s", logs.String())
|
||||
}
|
||||
if !bytes.Contains(page, []byte(`name="email"`)) {
|
||||
t.Fatalf("no reset form: %s\n%s", page, logs.String())
|
||||
}
|
||||
// Gitea 28 uses origin checks rather than a hidden _csrf form field.
|
||||
req, err := http.NewRequest(http.MethodPost, rootURL+"user/forgot_password", strings.NewReader(url.Values{"email": {"smoke@example.invalid"}}.Encode()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Origin", strings.TrimSuffix(rootURL, "/"))
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("password reset HTTP %d: %s", resp.StatusCode, logs.String())
|
||||
}
|
||||
select {
|
||||
case raw := <-mailbox:
|
||||
message, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts, err := mimeBodies(message.Header.Get("Content-Type"), message.Header.Get("Content-Transfer-Encoding"), message.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := parts["text/html"]
|
||||
if !strings.Contains(body, "/user/recover_account?code=") || !strings.Contains(body, "<style>") {
|
||||
t.Fatalf("official reset body or injected head missing: %s", body)
|
||||
}
|
||||
if mode == "framed" && !strings.Contains(body, `class="email-wrap`) {
|
||||
t.Fatal("framed override was not loaded")
|
||||
}
|
||||
if mode == "framed" && (!strings.Contains(body, `class="email-btn">Recover your account</a>`) || !strings.Contains(body, `src="`+rootURL+`assets/img/favicon.png"`) || strings.Count(body, "/user/recover_account?code=") < 3) {
|
||||
t.Fatal("shared action label, fallback or instance logo missing from real mail")
|
||||
}
|
||||
if mode == "shared" && (!strings.Contains(body, "#123456") || strings.Contains(body, `class="email-wrap`)) {
|
||||
t.Fatal("shared partial was not loaded")
|
||||
}
|
||||
if !strings.Contains(parts["text/plain"], "recover_account") {
|
||||
t.Fatal("Gitea plain-text alternative missing official recovery link")
|
||||
}
|
||||
t.Logf("[PASS] %s %s: real password-reset mail captured; HTML override and plain-text link verified", s.Lock.Tag, mode)
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Fatalf("no mail captured: %s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func mimeBodies(contentType, encoding string, r io.Reader) (map[string]string, error) {
|
||||
media, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := map[string]string{}
|
||||
if strings.HasPrefix(media, "multipart/") {
|
||||
reader := multipart.NewReader(r, params["boundary"])
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bodies, err := mimeBodies(part.Header.Get("Content-Type"), part.Header.Get("Content-Transfer-Encoding"), part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range bodies {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
switch strings.ToLower(encoding) {
|
||||
case "quoted-printable":
|
||||
r = quotedprintable.NewReader(r)
|
||||
case "base64":
|
||||
r = base64.NewDecoder(base64.StdEncoding, r)
|
||||
}
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[media] = string(b)
|
||||
return result, nil
|
||||
}
|
||||
+249
-270
@@ -1,313 +1,292 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
texttmpl "text/template"
|
||||
|
||||
"gitea-mail-templates/tools/builder"
|
||||
"gitea-mail-templates/tools/config"
|
||||
"gitea-mail-templates/tools/upstream"
|
||||
)
|
||||
|
||||
// RenderResult holds the rendered HTML for a single template in a single theme.
|
||||
type RenderResult struct {
|
||||
HTML string
|
||||
Size int64 // bytes of the rendered HTML
|
||||
Error string
|
||||
Skipped bool // true when the template file was not found
|
||||
}
|
||||
|
||||
// ThemeSummary collects statistics for one theme directory.
|
||||
type ThemeSummary struct {
|
||||
Name string
|
||||
TotalFiles int
|
||||
TotalSize int64 // total size of .tmpl source files
|
||||
RenderedSize int64 // total size of rendered HTML
|
||||
RenderedCount int
|
||||
MissingCount int
|
||||
ErrorCount int
|
||||
RenderDuration time.Duration
|
||||
Missing []string // template IDs that were not found
|
||||
Errors []string // template IDs that had render errors
|
||||
Results map[string]*RenderResult // per-template results
|
||||
}
|
||||
|
||||
// PreviewResult is the full output from a preview run.
|
||||
type PreviewResult struct {
|
||||
Rendered map[string]map[string]string `json:"rendered"`
|
||||
Registry map[string]map[string]string `json:"registry"`
|
||||
Params map[string]map[string]string `json:"params"`
|
||||
Summaries []ThemeSummary `json:"-"`
|
||||
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
|
||||
}
|
||||
|
||||
// DiscoverThemes scans a directory and returns all subdirectory names.
|
||||
func DiscoverThemes(themesDir string) ([]string, error) {
|
||||
entries, err := os.ReadDir(themesDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read themes directory %s: %w", themesDir, err)
|
||||
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
|
||||
}
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
names = append(names, e.Name())
|
||||
for id := range cfg.Templates {
|
||||
if !found[id] {
|
||||
return fmt.Errorf("stale fixture %s is absent from upstream snapshot", id)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return nil, fmt.Errorf("no theme directories found in %s", themesDir)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// RenderAll renders all templates from the config for all discovered themes.
|
||||
// If themeFilter is non-empty, only themes in the filter set are rendered.
|
||||
func RenderAll(themesDir string, cfg *config.TemplatesConfig, themeFilter map[string]bool) *PreviewResult {
|
||||
themes, err := DiscoverThemes(themesDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\033[31m[E]\033[0m [Preview] Error discovering themes: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply theme filter
|
||||
if len(themeFilter) > 0 {
|
||||
filtered := themes[:0]
|
||||
for _, t := range themes {
|
||||
if themeFilter[t] {
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
themes = filtered
|
||||
if len(themes) == 0 {
|
||||
fmt.Println("\033[33m[W]\033[0m [Preview] No themes matched the requested filter")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
result := &PreviewResult{
|
||||
Rendered: make(map[string]map[string]string),
|
||||
Registry: config.Registry(cfg),
|
||||
}
|
||||
// Build flattened params for the preview panel
|
||||
result.Params = make(map[string]map[string]string, len(cfg.Templates))
|
||||
for id, t := range cfg.Templates {
|
||||
result.Params[id] = config.FlattenParams(t.Params)
|
||||
}
|
||||
|
||||
for _, themeName := range themes {
|
||||
themeDir := filepath.Join(themesDir, themeName)
|
||||
summary := renderTheme(themeDir, themeName, cfg)
|
||||
result.Summaries = append(result.Summaries, summary)
|
||||
|
||||
if result.Rendered[themeName] == nil {
|
||||
result.Rendered[themeName] = make(map[string]string)
|
||||
}
|
||||
for tplID, rr := range summary.Results {
|
||||
if rr.Error != "" {
|
||||
result.Rendered[themeName][tplID] = fmt.Sprintf(
|
||||
"<p style='color:red'>Render error for %s: %s</p>", tplID, rr.Error)
|
||||
} else if rr.Skipped {
|
||||
result.Rendered[themeName][tplID] = fmt.Sprintf(
|
||||
"<p style='color:red'>Template not found: %s</p>", tplID)
|
||||
} else {
|
||||
result.Rendered[themeName][tplID] = rr.HTML
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func renderTheme(themeDir, themeName string, cfg *config.TemplatesConfig) ThemeSummary {
|
||||
summary := ThemeSummary{
|
||||
Name: themeName,
|
||||
Results: make(map[string]*RenderResult),
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
for tplID, tplCfg := range cfg.Templates {
|
||||
tmplRelPath := tplCfg.PathStr()
|
||||
tmplAbsPath := filepath.Join(themeDir, tmplRelPath)
|
||||
|
||||
// Check source file existence
|
||||
info, err := os.Stat(tmplAbsPath)
|
||||
if os.IsNotExist(err) {
|
||||
summary.MissingCount++
|
||||
summary.Missing = append(summary.Missing, tplID)
|
||||
summary.Results[tplID] = &RenderResult{Skipped: true}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
summary.ErrorCount++
|
||||
summary.Errors = append(summary.Errors, tplID)
|
||||
summary.Results[tplID] = &RenderResult{Error: err.Error()}
|
||||
continue
|
||||
}
|
||||
summary.TotalFiles++
|
||||
summary.TotalSize += info.Size()
|
||||
|
||||
// Read template content
|
||||
tmplContent, err := os.ReadFile(tmplAbsPath)
|
||||
if err != nil {
|
||||
summary.ErrorCount++
|
||||
summary.Errors = append(summary.Errors, tplID)
|
||||
summary.Results[tplID] = &RenderResult{Error: fmt.Sprintf("read error: %v", err)}
|
||||
continue
|
||||
}
|
||||
|
||||
// Build data context
|
||||
data := make(map[string]any)
|
||||
for k, v := range tplCfg.Params {
|
||||
data[k] = v
|
||||
}
|
||||
data["locale"] = Locale{}
|
||||
|
||||
// Convert HTML-safe fields from string to template.HTML so Go's
|
||||
// html/template renders them raw instead of escaping. Matches Gitea's
|
||||
// behavior where RenderedNote / Body / RenderedContent are template.HTML.
|
||||
markSafeHTML(data)
|
||||
|
||||
// Parse and execute
|
||||
tmpl, err := template.New(tplID).Funcs(TemplateFuncs).Parse(string(tmplContent))
|
||||
if err != nil {
|
||||
summary.ErrorCount++
|
||||
summary.Errors = append(summary.Errors, tplID)
|
||||
summary.Results[tplID] = &RenderResult{Error: fmt.Sprintf("parse error: %v", err)}
|
||||
continue
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
summary.ErrorCount++
|
||||
summary.Errors = append(summary.Errors, tplID)
|
||||
summary.Results[tplID] = &RenderResult{Error: fmt.Sprintf("render error: %v", err)}
|
||||
continue
|
||||
}
|
||||
|
||||
rendered := buf.String()
|
||||
|
||||
summary.RenderedCount++
|
||||
summary.RenderedSize += int64(len(rendered))
|
||||
summary.Results[tplID] = &RenderResult{
|
||||
HTML: rendered,
|
||||
Size: int64(len(rendered)),
|
||||
}
|
||||
}
|
||||
|
||||
summary.RenderDuration = time.Since(start)
|
||||
return summary
|
||||
}
|
||||
|
||||
// WriteRenderedJS writes the preview result to a JavaScript file loaded by preview/index.html.
|
||||
func WriteRenderedJS(result *PreviewResult, outputPath string) error {
|
||||
dir := filepath.Dir(outputPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("cannot create output directory %s: %w", dir, err)
|
||||
}
|
||||
|
||||
outFile, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create output file %s: %w", outputPath, err)
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
outFile.WriteString("// Auto-generated by tools/tools.go — do not edit\n")
|
||||
|
||||
// Write __RENDERED__
|
||||
renderedJSON, err := json.MarshalIndent(result.Rendered, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot marshal rendered data: %w", err)
|
||||
}
|
||||
outFile.WriteString("window.__RENDERED__ = ")
|
||||
outFile.Write(renderedJSON)
|
||||
outFile.WriteString(";\n\n")
|
||||
|
||||
// Write __REGISTRY__
|
||||
registryJSON, err := json.MarshalIndent(result.Registry, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot marshal registry data: %w", err)
|
||||
}
|
||||
outFile.WriteString("window.__REGISTRY__ = ")
|
||||
outFile.Write(registryJSON)
|
||||
outFile.WriteString(";\n\n")
|
||||
|
||||
// Write __PARAMS__
|
||||
paramsJSON, err := json.MarshalIndent(result.Params, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot marshal params data: %w", err)
|
||||
}
|
||||
outFile.WriteString("window.__PARAMS__ = ")
|
||||
outFile.Write(paramsJSON)
|
||||
outFile.WriteString(";\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrintDetailedSummary prints a human-readable summary of the preview results.
|
||||
func PrintDetailedSummary(result *PreviewResult, themesDir string, cfg *config.TemplatesConfig) {
|
||||
absDir, _ := filepath.Abs(themesDir)
|
||||
fmt.Printf("\033[32m[I]\033[0m [Preview] Found %d styles in '%s':\n",
|
||||
len(result.Summaries), filepath.Base(absDir))
|
||||
|
||||
for _, s := range result.Summaries {
|
||||
if s.MissingCount == 0 && s.ErrorCount == 0 {
|
||||
fmt.Printf("\033[32m[I]\033[0m [Preview] %-16s all required templates OK (total %d .tmpl, %s, %v)\n",
|
||||
s.Name, s.TotalFiles,
|
||||
formatSize(s.RenderedSize),
|
||||
s.RenderDuration.Round(time.Millisecond))
|
||||
} else {
|
||||
fmt.Printf("\033[33m[W]\033[0m [Preview] %-16s only %d required templates rendered:\n",
|
||||
s.Name, s.RenderedCount)
|
||||
for _, tplID := range s.Missing {
|
||||
if t, ok := cfg.Templates[tplID]; ok {
|
||||
fmt.Printf("\033[31m[E]\033[0m [Preview] |-> %s not found, expected at 'themes/%s/%s'\n",
|
||||
filepath.Base(t.PathStr()), s.Name, t.PathStr())
|
||||
} else {
|
||||
fmt.Printf("\033[31m[E]\033[0m [Preview] |-> %s not found\n", tplID)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
for _, tplID := range s.Errors {
|
||||
if rr, ok := s.Results[tplID]; ok && rr.Error != "" {
|
||||
fmt.Printf("\033[31m[E]\033[0m [Preview] |-> %s render error: %s\n", tplID, rr.Error)
|
||||
} else {
|
||||
fmt.Printf("\033[31m[E]\033[0m [Preview] |-> %s render error\n", tplID)
|
||||
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, ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// markSafeHTML recursively converts known HTML-containing string fields to
|
||||
// template.HTML so Go's html/template renders them raw instead of escaping.
|
||||
// These fields mirror Gitea's data model where rendered markdown is typed as
|
||||
// template.HTML: Release.RenderedNote, Body (comment content), and
|
||||
// ReviewComments[].RenderedContent.
|
||||
func markSafeHTML(data map[string]any) {
|
||||
for k, v := range data {
|
||||
switch val := v.(type) {
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
if k == "RenderedNote" || k == "Body" || k == "RenderedContent" {
|
||||
data[k] = template.HTML(val)
|
||||
data[k] = template.HTML(v)
|
||||
}
|
||||
case map[string]any:
|
||||
markSafeHTML(val)
|
||||
markSafeHTML(v)
|
||||
case []any:
|
||||
for i, item := range val {
|
||||
for _, item := range v {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
markSafeHTML(m)
|
||||
val[i] = m
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formatSize(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%dB", bytes)
|
||||
}
|
||||
return fmt.Sprintf("%dKiB", bytes/unit)
|
||||
}
|
||||
+4
-11
@@ -9,9 +9,11 @@ import (
|
||||
|
||||
// TemplateFuncs maps Gitea's built-in template functions available in mail templates.
|
||||
var TemplateFuncs = template.FuncMap{
|
||||
"dict": Dict,
|
||||
"AppUrl": func() string { return "https://gitea.com/" },
|
||||
"AppName": func() string { return "Gitea" },
|
||||
"DotEscape": func(s string) template.HTML { return template.HTML(template.HTMLEscapeString(s)) },
|
||||
"AppDomain": func() string { return "gitea.com" },
|
||||
"DotEscape": func(s string) string { return strings.ReplaceAll(s, ".", "\u200d.\u200d") },
|
||||
"QueryEscape": url.QueryEscape,
|
||||
"ShortSha": ShortSha,
|
||||
"PathEscapeSegments": PathEscapeSegments,
|
||||
@@ -69,16 +71,7 @@ func FormatByteSize(n int64) string {
|
||||
|
||||
// HTMLFormat substitutes %s/%d placeholders with HTML-escaped values.
|
||||
func HTMLFormat(fmtStr string, args ...any) template.HTML {
|
||||
result := fmtStr
|
||||
for i, arg := range args {
|
||||
escaped := template.HTMLEscapeString(fmt.Sprint(arg))
|
||||
result = strings.Replace(result, fmt.Sprintf("%%[%d]s", i+1), escaped, 1)
|
||||
if !strings.Contains(result, fmt.Sprintf("%%[%d]s", i+1)) {
|
||||
result = strings.Replace(result, "%s", escaped, 1)
|
||||
result = strings.Replace(result, "%d", fmt.Sprint(arg), 1)
|
||||
}
|
||||
}
|
||||
return template.HTML(result)
|
||||
return template.HTML(fmt.Sprintf(fmtStr, escapedArgs(args)...))
|
||||
}
|
||||
|
||||
// Dict builds a map from alternating key/value pairs (used by Gitea's Dict function).
|
||||
|
||||
+98
-112
@@ -2,129 +2,115 @@ package preview
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea-mail-templates/tools/upstream"
|
||||
"html/template"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Locale provides English fallback translations, matching Gitea's locale.Tr interface.
|
||||
type Locale struct{}
|
||||
// Locale is a lightweight adapter for the translation implementation pinned in
|
||||
// upstream/modules/translation. Catalogs are always official snapshot data.
|
||||
type Locale struct {
|
||||
Lang string
|
||||
Catalog map[string]string
|
||||
English map[string]string
|
||||
Problems *[]error
|
||||
Warnings *[]string
|
||||
}
|
||||
|
||||
func (l Locale) Language() string { return l.Lang }
|
||||
func (l Locale) HasKey(key string) bool { _, ok := l.Catalog[key]; return ok }
|
||||
|
||||
func (l Locale) problem(err error) {
|
||||
if l.Problems != nil {
|
||||
*l.Problems = append(*l.Problems, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (l Locale) TrString(key string, args ...any) string {
|
||||
format := l.Catalog[key]
|
||||
if format == "" {
|
||||
format = l.English[key]
|
||||
}
|
||||
if format == "" {
|
||||
l.problem(fmt.Errorf("missing translation key %s", key))
|
||||
format = template.HTMLEscapeString(key)
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return format
|
||||
}
|
||||
values := make([]any, 0, len(args))
|
||||
for _, a := range args {
|
||||
v := reflect.ValueOf(a)
|
||||
if v.IsValid() && v.Kind() == reflect.Slice {
|
||||
if len(args) != 1 {
|
||||
l.problem(fmt.Errorf("translation %s has uncertain slice arguments", key))
|
||||
break
|
||||
}
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
values = append(values, v.Index(i).Interface())
|
||||
}
|
||||
} else {
|
||||
values = append(values, a)
|
||||
}
|
||||
}
|
||||
msg := fmt.Sprintf(format, values...)
|
||||
// Upstream logs formatting failures; a fixture preview must surface them.
|
||||
if strings.Contains(msg, "%!") && upstream.ReviewedFormattingIssue(l.Lang, key, format) {
|
||||
if l.Warnings != nil {
|
||||
*l.Warnings = append(*l.Warnings, "[UPSTREAM-WARN] "+l.Lang+" "+key+": official placeholder %[1]s is malformed; output matches Gitea")
|
||||
}
|
||||
} else if strings.Contains(msg, "%!") {
|
||||
l.problem(fmt.Errorf("translation %s formatting failed: %s", key, msg))
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func escapedArgs(args []any) []any {
|
||||
out := append([]any(nil), args...)
|
||||
for i, a := range out {
|
||||
switch a := a.(type) {
|
||||
case nil, bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, template.HTML:
|
||||
case string:
|
||||
out[i] = template.HTMLEscapeString(a)
|
||||
case fmt.Stringer:
|
||||
out[i] = template.HTMLEscapeString(a.String())
|
||||
default:
|
||||
out[i] = template.HTMLEscapeString(fmt.Sprint(a))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Tr translates a key and substitutes %s / %d / %[N]s / %[N]d arguments.
|
||||
// HTML arguments are preserved; plain strings are HTML-escaped.
|
||||
func (l Locale) Tr(key string, args ...any) template.HTML {
|
||||
s := LocaleEN[key]
|
||||
if s == "" {
|
||||
s = key
|
||||
}
|
||||
for i, arg := range args {
|
||||
var val string
|
||||
if h, ok := arg.(template.HTML); ok {
|
||||
val = string(h) // already safe HTML
|
||||
} else {
|
||||
val = template.HTMLEscapeString(fmt.Sprint(arg))
|
||||
}
|
||||
// Handle positional format specifiers: %[1]s, %[2]d, etc.
|
||||
pos := fmt.Sprintf("%%[%d]", i+1)
|
||||
s = strings.Replace(s, pos+"s", val, 1)
|
||||
s = strings.Replace(s, pos+"d", val, 1)
|
||||
}
|
||||
// Fallback for non-positional %s / %d (sequential replacement)
|
||||
for _, arg := range args {
|
||||
var val string
|
||||
if h, ok := arg.(template.HTML); ok {
|
||||
val = string(h)
|
||||
} else {
|
||||
val = template.HTMLEscapeString(fmt.Sprint(arg))
|
||||
}
|
||||
if !strings.Contains(s, "%s") && !strings.Contains(s, "%d") {
|
||||
break
|
||||
}
|
||||
s = strings.Replace(s, "%s", val, 1)
|
||||
s = strings.Replace(s, "%d", val, 1)
|
||||
}
|
||||
return template.HTML(s)
|
||||
return template.HTML(l.TrString(key, escapedArgs(args)...))
|
||||
}
|
||||
|
||||
// TrN picks singular/plural by count, then delegates to Tr.
|
||||
func (l Locale) TrN(count any, singular, plural string, args ...any) template.HTML {
|
||||
n := 0
|
||||
switch v := count.(type) {
|
||||
func (l Locale) TrN(count any, one, many string, args ...any) template.HTML {
|
||||
var n int64
|
||||
switch count := count.(type) {
|
||||
case int:
|
||||
n = v
|
||||
n = int64(count)
|
||||
case int16:
|
||||
n = int64(count)
|
||||
case int32:
|
||||
n = int64(count)
|
||||
case int64:
|
||||
n = int(v)
|
||||
case float64:
|
||||
n = int(v)
|
||||
n = count
|
||||
default:
|
||||
n = 1
|
||||
return l.Tr(many, args...)
|
||||
}
|
||||
key := plural
|
||||
if n == 1 {
|
||||
key = singular
|
||||
singular := n == 1
|
||||
switch l.Lang {
|
||||
case "zh-CN", "zh-HK", "zh-TW":
|
||||
singular = true
|
||||
case "lv-LV", "ru-RU":
|
||||
singular = n%10 == 1 && n%100 != 11
|
||||
case "fr-FR":
|
||||
singular = n > -2 && n < 2
|
||||
}
|
||||
return l.Tr(key, args...)
|
||||
}
|
||||
|
||||
// LocaleEN holds all English translation strings used by Gitea mail templates.
|
||||
var LocaleEN = map[string]string{
|
||||
"mail.hi_user_x": "Hi <b>%s</b>,",
|
||||
"mail.activate_account": "Please activate your account",
|
||||
"mail.activate_account.title": "%s, please activate your account",
|
||||
"mail.activate_account.text_1": "Hi <b>%[1]s</b>, thanks for registering at %[2]s!",
|
||||
"mail.activate_account.text_2": "Please click the following link to activate your account within <b>%s</b>:",
|
||||
"mail.activate_email": "Verify your email address",
|
||||
"mail.activate_email.title": "%s, please verify your email address",
|
||||
"mail.activate_email.text": "Please click the following link to verify your email address within <b>%s</b>:",
|
||||
"mail.register_success": "Registration successful",
|
||||
"mail.register_notify": "Welcome to %s",
|
||||
"mail.register_notify.title": "%[1]s, welcome to %[2]s",
|
||||
"mail.register_notify.text_1": "This is your registration confirmation email for %s!",
|
||||
"mail.register_notify.text_2": "You can now log in via username: %s.",
|
||||
"mail.register_notify.text_3": "If this account has been created for you, please <a href=\"%s\">set your password</a> first.",
|
||||
"mail.reset_password": "Recover your account",
|
||||
"mail.reset_password.title": "%s, you have requested to recover your account",
|
||||
"mail.reset_password.text": "Please click the following link to recover your account within <b>%s</b>:",
|
||||
"mail.team_invite.subject": "%[1]s has invited you to join the %[2]s organization",
|
||||
"mail.team_invite.text_1": "%[1]s has invited you to join team %[2]s in organization %[3]s.",
|
||||
"mail.team_invite.text_2": "Please click the following link to join the team:",
|
||||
"mail.team_invite.text_3": "Note: This invitation was intended for %[1]s. If you were not expecting this invitation, you can ignore this email.",
|
||||
"mail.repo.collaborator.added.subject": "%s added you to %s",
|
||||
"mail.repo.collaborator.added.text": "You have been added as a collaborator of repository:",
|
||||
"mail.repo.transfer.subject_to": "%s would like to transfer \"%s\" to %s",
|
||||
"mail.repo.transfer.subject_to_you": "%s would like to transfer \"%s\" to you",
|
||||
"mail.repo.transfer.to_you": "you",
|
||||
"mail.repo.transfer.body": "To accept or reject it, visit %s or just ignore it.",
|
||||
"mail.release.new.subject": "%s in %s released",
|
||||
"mail.release.new.text": "<b>@%[1]s</b> released %[2]s in %[3]s",
|
||||
"mail.release.title": "Title: %s",
|
||||
"mail.release.note": "Note:",
|
||||
"mail.release.downloads": "Downloads:",
|
||||
"mail.release.download.zip": "Source Code (ZIP)",
|
||||
"mail.release.download.targz": "Source Code (TAR.GZ)",
|
||||
"mail.repo.actions.run.failed": "Run failed",
|
||||
"mail.repo.actions.run.succeeded": "Run succeeded",
|
||||
"mail.repo.actions.run.cancelled": "Run cancelled",
|
||||
"mail.repo.actions.jobs.all_succeeded": "All jobs have succeeded",
|
||||
"mail.repo.actions.jobs.all_failed": "All jobs have failed",
|
||||
"mail.repo.actions.jobs.some_not_successful": "Some jobs were not successful",
|
||||
"mail.repo.actions.jobs.all_cancelled": "All jobs have been cancelled",
|
||||
"mail.issue_assigned.pull": "@%[1]s assigned you to pull request %[2]s in repository %[3]s.",
|
||||
"mail.issue_assigned.issue": "@%[1]s assigned you to issue %[2]s in repository %[3]s.",
|
||||
"mail.issue.x_mentioned_you": "<b>@%s</b> mentioned you:",
|
||||
"mail.issue.action.close": "<b>@%[1]s</b> closed #%[2]d.",
|
||||
"mail.issue.action.reopen": "<b>@%[1]s</b> reopened #%[2]d.",
|
||||
"mail.issue.action.merge": "<b>@%[1]s</b> merged #%[2]d into %[3]s.",
|
||||
"mail.issue.action.approve": "<b>@%[1]s</b> approved this pull request.",
|
||||
"mail.issue.action.reject": "<b>@%[1]s</b> requested changes on this pull request.",
|
||||
"mail.issue.action.review": "<b>@%[1]s</b> commented on this pull request.",
|
||||
"mail.issue.action.review_dismissed": "<b>@%[1]s</b> dismissed last review from %[2]s for this pull request.",
|
||||
"mail.issue.action.ready_for_review": "<b>@%[1]s</b> marked this pull request ready for review.",
|
||||
"mail.issue.action.new": "<b>@%[1]s</b> created #%[2]d.",
|
||||
"mail.issue.action.force_push": "<b>%[1]s</b> force-pushed the <b>%[2]s</b> from %[3]s to %[4]s.",
|
||||
"mail.issue.action.push_1": "<b>@%[1]s</b> pushed %[3]d commit to %[2]s",
|
||||
"mail.issue.action.push_n": "<b>@%[1]s</b> pushed %[3]d commits to %[2]s",
|
||||
"mail.issue.in_tree_path": "In %s:",
|
||||
"mail.view_it_on": "View it on %s",
|
||||
"mail.reply": "or reply to this email directly",
|
||||
"mail.link_not_working_do_paste": "Not working? Try copying and pasting it to your browser.",
|
||||
if singular {
|
||||
return l.Tr(one, args...)
|
||||
}
|
||||
return l.Tr(many, args...)
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea-mail-templates/tools/builder"
|
||||
"gitea-mail-templates/tools/config"
|
||||
"gitea-mail-templates/tools/upstream"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
type testCommitID string
|
||||
|
||||
func (id testCommitID) String() string { return string(id) }
|
||||
|
||||
type scenario struct {
|
||||
id, name string
|
||||
params map[string]any
|
||||
}
|
||||
|
||||
func branchFixtures(cfg *config.TemplatesConfig) []scenario {
|
||||
var result []scenario
|
||||
for id, f := range cfg.Templates {
|
||||
result = append(result, scenario{id, "default", f.Params})
|
||||
}
|
||||
add := func(id, name string, edit func(map[string]any)) {
|
||||
data := CloneParams(cfg.Templates[id].Params)
|
||||
edit(data)
|
||||
result = append(result, scenario{id, name, data})
|
||||
}
|
||||
for _, action := range []string{"new", "close", "reopen", "merge", "approve", "reject", "review", "review_dismissed", "ready_for_review"} {
|
||||
add("default", action, func(d map[string]any) {
|
||||
d["ActionName"] = action
|
||||
d["Body"] = ""
|
||||
d["IsMention"] = true
|
||||
d["CanReply"] = true
|
||||
})
|
||||
}
|
||||
for _, n := range []int{0, 1, 2} {
|
||||
add("default", fmt.Sprintf("push-%d", n), func(d map[string]any) {
|
||||
d["ActionName"] = "push"
|
||||
d["Body"] = ""
|
||||
c := d["Comment"].(map[string]any)
|
||||
commits := make([]any, n)
|
||||
for i := range commits {
|
||||
commits[i] = map[string]any{"UserCommit": map[string]any{"GitCommit": map[string]any{"ID": testCommitID("0123456789abcdef0123456789abcdef01234567"), "MessageTitle": "Commit <escaped>"}}}
|
||||
}
|
||||
c["Commits"] = commits
|
||||
})
|
||||
}
|
||||
add("default", "force-push", func(d map[string]any) {
|
||||
d["ActionName"] = "push"
|
||||
d["Body"] = ""
|
||||
c := d["Comment"].(map[string]any)
|
||||
c["IsForcePush"] = true
|
||||
c["OldCommit"] = "0123456789abcdef"
|
||||
c["NewCommit"] = "fedcba9876543210"
|
||||
})
|
||||
add("default", "review-comments", func(d map[string]any) {
|
||||
d["ReviewComments"] = []any{map[string]any{"TreePath": "src/example.go", "Patch": "<unsafe>& patch", "RenderedContent": "<p>Safe <strong>review</strong></p>"}}
|
||||
})
|
||||
add("assigned", "pull-reply", func(d map[string]any) { d["IsPull"] = true; d["CanReply"] = true })
|
||||
add("release", "no-downloads-or-note", func(d map[string]any) {
|
||||
d["DisableDownloadSourceArchives"] = true
|
||||
r := d["Release"].(map[string]any)
|
||||
r["Attachments"] = []any{}
|
||||
r["RenderedNote"] = ""
|
||||
})
|
||||
add("workflow_run", "no-jobs", func(d map[string]any) { d["Jobs"] = []any{} })
|
||||
add("workflow_run", "failure-jobs", func(d map[string]any) {
|
||||
for _, job := range d["Jobs"].([]any) {
|
||||
j := job.(map[string]any)
|
||||
j["StatusClass"] = "status-failure"
|
||||
j["StatusIconCID"] = "failure"
|
||||
j["StatusIconAlt"] = "Failure"
|
||||
}
|
||||
})
|
||||
add("activate", "escaped-name", func(d map[string]any) { d["DisplayName"] = "test.example <User> & \"Admin\""; d["Code"] = "a&b= c" })
|
||||
return result
|
||||
}
|
||||
|
||||
type semantics struct {
|
||||
Text string
|
||||
Links, Images []string
|
||||
}
|
||||
|
||||
func mailSemantics(source string, hints ...string) (semantics, error) {
|
||||
root, err := html.Parse(strings.NewReader(source))
|
||||
if err != nil {
|
||||
return semantics{}, err
|
||||
}
|
||||
var result semantics
|
||||
var text []string
|
||||
var visible func(*html.Node) string
|
||||
visible = func(n *html.Node) string {
|
||||
if n.Type == html.TextNode {
|
||||
return n.Data
|
||||
}
|
||||
if n.Type == html.ElementNode && (n.Data == "head" || n.Data == "script" || n.Data == "style") {
|
||||
return ""
|
||||
}
|
||||
var parts []string
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
parts = append(parts, visible(c))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && n.Data == "head" {
|
||||
return
|
||||
}
|
||||
if n.Type == html.ElementNode {
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == "class" && strings.Contains(" "+a.Val+" ", " framework-extra ") {
|
||||
return
|
||||
}
|
||||
}
|
||||
if n.Data == "p" {
|
||||
for _, hint := range hints {
|
||||
if strings.Join(strings.Fields(visible(n)), " ") == strings.Join(strings.Fields(hint), " ") {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if n.Type == html.TextNode {
|
||||
text = append(text, n.Data)
|
||||
}
|
||||
if n.Type == html.ElementNode {
|
||||
if n.Data == "a" {
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == "href" {
|
||||
result.Links = append(result.Links, a.Val+" | "+strings.Join(strings.Fields(visible(n)), " "))
|
||||
}
|
||||
}
|
||||
}
|
||||
if n.Data == "img" {
|
||||
var attrs []string
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == "src" || a.Key == "alt" {
|
||||
attrs = append(attrs, a.Key+"="+a.Val)
|
||||
}
|
||||
}
|
||||
result.Images = append(result.Images, strings.Join(attrs, " | "))
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
result.Text = strings.Join(strings.Fields(strings.Join(text, " ")), " ")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Run every discovered language and theme against stock Gitea for both normal
|
||||
// fixtures and branches that historically broke custom mail overrides.
|
||||
func TestAllThemeContentParity(t *testing.T) {
|
||||
s, err := upstream.Load(filepath.Join("..", "..", "build", "upstream"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.Load(filepath.Join("..", "data", "templates_config.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := AlignConfig(s, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packages, err := builder.BuildAll(filepath.Join("..", "..", "themes"), s, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Shared mode has the same parity guarantee although migrated themes are framed.
|
||||
shared, err := builder.Build(s, &builder.Theme{Name: "test", Mode: "shared", CSS: "a { color:#2563eb; }"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packages["shared-test"] = shared
|
||||
for _, lang := range s.Languages() {
|
||||
t.Run(lang, func(t *testing.T) {
|
||||
for _, fixture := range branchFixtures(cfg) {
|
||||
entry := strings.Join(cfg.Templates[fixture.id].Path, "/")
|
||||
stock, err := RenderMail(s, s.Templates, entry, fixture.params, lang)
|
||||
if err != nil {
|
||||
t.Fatalf("stock %s/%s: %v", fixture.id, fixture.name, err)
|
||||
}
|
||||
hint := Locale{Lang: lang, Catalog: s.Locales[lang], English: s.Locales["en-US"]}.TrString("mail.link_not_working_do_paste")
|
||||
want, err := mailSemantics(stock.HTML, hint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for theme, p := range packages {
|
||||
mail, err := RenderMail(s, builder.EffectiveTemplates(s, p), entry, fixture.params, lang)
|
||||
if err != nil {
|
||||
t.Fatalf("%s/%s/%s: %v", theme, fixture.id, fixture.name, err)
|
||||
}
|
||||
got, err := mailSemantics(mail.HTML, hint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stock.Subject != mail.Subject || !reflect.DeepEqual(want, got) {
|
||||
t.Fatalf("content changed for %s/%s/%s", theme, fixture.id, fixture.name)
|
||||
}
|
||||
if fixture.id == "release" && fixture.name == "default" && (!strings.Contains(mail.HTML, "1.5 KiB") || !strings.Contains(mail.HTML, "release-notes.txt")) {
|
||||
t.Fatal("release attachment regression")
|
||||
}
|
||||
if fixture.name == "push-1" && !strings.Contains(mail.HTML, "/commit/0123456789abcdef0123456789abcdef01234567") {
|
||||
t.Fatal("push link regression")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocaleFormatting(t *testing.T) {
|
||||
var problems []error
|
||||
base := map[string]string{"repeat": "%[1]s %[1]s (%[2]d)", "mixed": "%[2]s %s %%", "html": "<b>%s</b> %s", "one": "one", "many": "many", "fallback": "English", "broken": "%d"}
|
||||
l := Locale{Lang: "en-US", Catalog: base, English: base, Problems: &problems}
|
||||
if got := string(l.Tr("repeat", "<x>", 7)); got != "<x> <x> (7)" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := l.TrString("mixed", "ignored", "second", "third"); got != "second third %" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := string(l.Tr("html", "<unsafe>", template.HTML("<i>safe</i>"))); got != "<b><unsafe></b> <i>safe</i>" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := l.TrString("repeat", []any{"slice", int64(2)}); got != "slice slice (2)" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
lang string
|
||||
n any
|
||||
want string
|
||||
}{{"en-US", 0, "many"}, {"en-US", 1, "one"}, {"en-US", 2, "many"}, {"zh-CN", 2, "one"}, {"fr-FR", 0, "one"}, {"ru-RU", 21, "one"}, {"ru-RU", 11, "many"}, {"en-US", float64(1), "many"}} {
|
||||
l.Lang = tc.lang
|
||||
if got := string(l.TrN(tc.n, "one", "many")); got != tc.want {
|
||||
t.Fatalf("%v: %s", tc, got)
|
||||
}
|
||||
}
|
||||
l.Catalog = map[string]string{}
|
||||
if got := l.TrString("fallback"); got != "English" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if len(problems) != 0 {
|
||||
t.Fatal(problems)
|
||||
}
|
||||
l.Tr("missing")
|
||||
l.Tr("broken", "text")
|
||||
if len(problems) != 2 {
|
||||
t.Fatal("missing/formatting errors were not surfaced")
|
||||
}
|
||||
if got := string(HTMLFormat("<a href='%[1]s'>%[2]s</a>", "x?a=1&b=2", template.HTML("<b>safe</b>"))); got != "<a href='x?a=1&b=2'><b>safe</b></a>" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubjectSplitAndMissingFixture(t *testing.T) {
|
||||
s, err := upstream.Load(filepath.Join("..", "..", "build", "upstream"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files := builder.EffectiveTemplates(s, nil)
|
||||
files["mail/test.tmpl"] = []byte(" {{.Subject}}\n------------\n<body>{{.Subject}}</body>")
|
||||
mail, err := RenderMail(s, files, "mail/test.tmpl", map[string]any{"Subject": " Hello world "}, "en-US")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mail.Subject != "Hello world" || strings.TrimSpace(mail.HTML) != "<body>Hello world</body>" {
|
||||
t.Fatal(mail)
|
||||
}
|
||||
cfg, err := config.Load(filepath.Join("..", "data", "templates_config.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delete(cfg.Templates, "release")
|
||||
if AlignConfig(s, cfg) == nil {
|
||||
t.Fatal("missing fixture accepted")
|
||||
}
|
||||
if _, err := RenderMail(s, s.Templates, "mail/repo/release.tmpl", map[string]any{}, "en-US"); err == nil {
|
||||
t.Fatal("missing context accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewBundles(t *testing.T) {
|
||||
result := &PreviewResult{Rendered: map[string]map[string]map[string]string{"en-US": {"test": {"activate": "<body>hello</body>"}}, "zh-CN": {"test": {"activate": "<body>你好</body>"}}}, Languages: []string{"en-US", "zh-CN"}, Themes: []string{"test"}, Registry: map[string]map[string]string{}, Upstream: "v28.0.0"}
|
||||
output := filepath.Join(t.TempDir(), "rendered.js")
|
||||
if err := WriteRenderedJS(result, output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest, err := os.ReadFile(output)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(manifest, []byte("<body>")) {
|
||||
t.Fatal("manifest embeds mail payload")
|
||||
}
|
||||
for _, lang := range result.Languages {
|
||||
data, err := os.ReadFile(filepath.Join(filepath.Dir(output), "rendered", lang+".js"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(data, []byte("window.__MAIL_PREVIEW_DATA__")) {
|
||||
t.Fatal("not a file:// script bundle")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea-mail-templates/tools/config"
|
||||
)
|
||||
|
||||
type testCommitID string
|
||||
|
||||
func (id testCommitID) String() string { return string(id) }
|
||||
|
||||
// Exercise the push-only branch with Gitea's nested commit data shape.
|
||||
// The normal preview fixture has no commits, so it cannot catch stale .ID paths.
|
||||
func TestPushCommitTemplates(t *testing.T) {
|
||||
cfg, err := config.Load(filepath.Join("..", "data", "templates_config.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const sha = "0123456789abcdef0123456789abcdef01234567"
|
||||
data := cfg.Templates["default"].Params
|
||||
data["ActionName"] = "push"
|
||||
data["locale"] = Locale{}
|
||||
comment := data["Comment"].(map[string]any)
|
||||
comment["Commits"] = []any{map[string]any{
|
||||
"UserCommit": map[string]any{
|
||||
"GitCommit": map[string]any{
|
||||
"ID": testCommitID(sha),
|
||||
"MessageTitle": "Test push commit",
|
||||
},
|
||||
},
|
||||
}}
|
||||
markSafeHTML(data)
|
||||
|
||||
themesDir := filepath.Join("..", "..", "themes")
|
||||
themes, err := os.ReadDir(themesDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, theme := range themes {
|
||||
if !theme.IsDir() {
|
||||
continue
|
||||
}
|
||||
t.Run(theme.Name(), func(t *testing.T) {
|
||||
path := filepath.Join(themesDir, theme.Name(), "mail", "repo", "issue", "default.tmpl")
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpl, err := template.New("push").Funcs(TemplateFuncs).Parse(string(content))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var html strings.Builder
|
||||
if err := tmpl.Execute(&html, data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(html.String(), "/commit/"+sha) || !strings.Contains(html.String(), sha[:10]) {
|
||||
t.Fatal("push commit link or abbreviated hash missing from rendered email")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package preview
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea-mail-templates/tools/config"
|
||||
)
|
||||
|
||||
// Gitea 28 removes FileSize, so render an attachment in every release theme.
|
||||
func TestReleaseAttachmentsGitea28(t *testing.T) {
|
||||
if _, ok := TemplateFuncs["FileSize"]; ok {
|
||||
t.Fatal("FileSize is not available in Gitea 28 mail templates")
|
||||
}
|
||||
cfg, err := config.Load(filepath.Join("..", "data", "templates_config.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := cfg.Templates["release"].Params
|
||||
data["locale"] = Locale{}
|
||||
markSafeHTML(data)
|
||||
|
||||
themesDir := filepath.Join("..", "..", "themes")
|
||||
themes, err := os.ReadDir(themesDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, theme := range themes {
|
||||
if !theme.IsDir() {
|
||||
continue
|
||||
}
|
||||
t.Run(theme.Name(), func(t *testing.T) {
|
||||
content, err := os.ReadFile(filepath.Join(themesDir, theme.Name(), "mail", "repo", "release.tmpl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpl, err := template.New("release").Funcs(TemplateFuncs).Parse(string(content))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rendered strings.Builder
|
||||
if err := tmpl.Execute(&rendered, data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(rendered.String(), "release-notes.txt") || !strings.Contains(rendered.String(), "1.5 KiB") {
|
||||
t.Fatal("release attachment or formatted size missing from email")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+39
-12
@@ -22,7 +22,7 @@ type DevServerConfig struct {
|
||||
}
|
||||
|
||||
// DevServer is a live-reload dev server with SSE-based browser notifications.
|
||||
// It serves preview/ as static files, watches themes/ for .tmpl changes,
|
||||
// It serves preview/ as static files, watches theme resources and snapshots,
|
||||
// re-renders templates in-process, and pushes reload events to connected browsers.
|
||||
type DevServer struct {
|
||||
cfg DevServerConfig
|
||||
@@ -46,7 +46,7 @@ func NewDevServer(cfg DevServerConfig) *DevServer {
|
||||
func (s *DevServer) Start() error {
|
||||
// Build initial rendered.js
|
||||
if err := s.rebuildAll(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "\033[33m[W]\033[0m [Builder] Initial render warning: %v\n", err)
|
||||
return fmt.Errorf("initial preview build: %w", err)
|
||||
}
|
||||
|
||||
// Start file watcher in background
|
||||
@@ -61,11 +61,11 @@ func (s *DevServer) Start() error {
|
||||
previewFS := http.FileServer(http.Dir(s.cfg.PreviewDir))
|
||||
mux.Handle("/", previewFS)
|
||||
|
||||
addr := fmt.Sprintf(":%d", s.cfg.Port)
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", s.cfg.Port)
|
||||
fmt.Printf("\033[32m[I]\033[0m [Server] Gitea Mail Templates — Dev Server\n")
|
||||
fmt.Printf("\033[32m[I]\033[0m [Server] http://localhost%s\n", addr)
|
||||
fmt.Printf("\033[32m[I]\033[0m [Watcher] Watching themes/ for changes\n")
|
||||
fmt.Printf("\033[32m[I]\033[0m [Server] SSE endpoint ws://localhost%s/events\n\n", addr)
|
||||
fmt.Printf("\033[32m[I]\033[0m [Server] http://%s\n", addr)
|
||||
fmt.Printf("\033[32m[I]\033[0m [Watcher] Watching theme CSS, framework, lock/cache and fixtures\n")
|
||||
fmt.Printf("\033[32m[I]\033[0m [Server] SSE endpoint http://%s/events\n\n", addr)
|
||||
|
||||
return http.ListenAndServe(addr, mux)
|
||||
}
|
||||
@@ -131,19 +131,42 @@ func (s *DevServer) broadcast(typ string, payload map[string]interface{}) {
|
||||
|
||||
// --- File watcher (polling) ---
|
||||
|
||||
// scanMods scans themes/ for .tmpl files and returns a map of path → mod time.
|
||||
// scanMods records theme resources, snapshot files and fixture modification times.
|
||||
func (s *DevServer) scanMods() (map[string]time.Time, error) {
|
||||
mods := make(map[string]time.Time)
|
||||
err := filepath.Walk(s.cfg.ThemesDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // skip unreadable
|
||||
}
|
||||
if !info.IsDir() && strings.HasSuffix(info.Name(), ".tmpl") {
|
||||
if !info.IsDir() && (strings.HasSuffix(info.Name(), ".json") || strings.HasSuffix(info.Name(), ".css") || strings.HasSuffix(info.Name(), ".html")) {
|
||||
mods[path] = info.ModTime()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return mods, err
|
||||
if err != nil {
|
||||
return mods, err
|
||||
}
|
||||
for _, dir := range []string{filepath.Join(filepath.Dir(s.cfg.ThemesDir), "framework"), filepath.Join(filepath.Dir(s.cfg.ThemesDir), "build", "upstream"), filepath.Join(filepath.Dir(s.cfg.ThemesDir), "tools", "data")} {
|
||||
err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
mods[path] = info.ModTime()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return mods, err
|
||||
}
|
||||
}
|
||||
lock := filepath.Join(filepath.Dir(s.cfg.ThemesDir), "gitea.lock.json")
|
||||
if info, err := os.Stat(lock); err == nil {
|
||||
mods[lock] = info.ModTime()
|
||||
} else {
|
||||
return mods, err
|
||||
}
|
||||
return mods, nil
|
||||
}
|
||||
|
||||
// watchThemes polls the themes directory every 500ms and triggers rebuild on changes.
|
||||
@@ -213,9 +236,13 @@ func (s *DevServer) rebuildAll() error {
|
||||
start := time.Now()
|
||||
fmt.Printf("\033[32m[I]\033[0m [Builder] Rebuilding all themes...\n")
|
||||
|
||||
result := RenderAll(s.cfg.ThemesDir, s.cfg.Config, nil)
|
||||
if result == nil {
|
||||
err := fmt.Errorf("no results produced")
|
||||
cfg, err := config.Load(filepath.Join(filepath.Dir(s.cfg.ThemesDir), "tools", "data", "templates_config.json"))
|
||||
if err != nil {
|
||||
s.broadcast("error", map[string]interface{}{"message": err.Error()})
|
||||
return err
|
||||
}
|
||||
result, err := RenderAll(s.cfg.ThemesDir, cfg, nil)
|
||||
if err != nil {
|
||||
s.broadcast("error", map[string]interface{}{"message": err.Error()})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "gitea-mail-preview-qa",
|
||||
"private": true,
|
||||
"scripts": { "test": "node preview.cjs" },
|
||||
"devDependencies": { "playwright": "1.62.1", "sharp": "0.35.5" }
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Optional browser QA: npm install in this directory, then npm test.
|
||||
const { chromium } = require('playwright');
|
||||
const sharp = require('sharp');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
|
||||
async function ready(page) {
|
||||
await page.waitForFunction(() => document.getElementById('preview-frame').style.display === 'block' && document.getElementById('loading').style.display === 'none');
|
||||
const frame = page.frameLocator('#preview-frame');
|
||||
await frame.locator('body').waitFor();
|
||||
return frame;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({headless:true, executablePath:process.env.BROWSER_EXECUTABLE_PATH || undefined});
|
||||
try {
|
||||
const page = await browser.newPage({viewport:{width:1650,height:1050}});
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(pathToFileURL(path.join(root,'preview/index.html')).href);
|
||||
await page.addStyleTag({content:'*{transition:none!important;animation:none!important}'});
|
||||
await ready(page);
|
||||
// Floating inspector is preview chrome, not part of the rendered email.
|
||||
if (await page.locator('#panel-close').isVisible()) await page.click('#panel-close');
|
||||
const languages = await page.locator('#sel-locale option').evaluateAll(options => options.map(o=>o.value));
|
||||
const themes = await page.locator('#sel-theme option').evaluateAll(options => options.map(o=>o.value));
|
||||
const types = await page.locator('#sel-template option').evaluateAll(options => options.map(o=>o.value));
|
||||
const buttonColors = {aurora:'rgb(224, 245, 243)',bloom:'rgb(255, 255, 255)',ember:'rgb(255, 255, 255)',heritage:'rgb(255, 255, 255)',horizon:'rgb(255, 255, 255)',ink:'rgb(254, 253, 251)',mono:'rgb(0, 0, 0)',neon:'rgb(10, 10, 15)',terminal:'rgb(13, 17, 23)',terra:'rgb(255, 255, 255)'};
|
||||
if (!languages.includes('en-US') || !languages.includes('zh-CN')) throw new Error('official languages missing');
|
||||
for (const lang of languages) {
|
||||
await page.selectOption('#sel-locale',lang);
|
||||
const frame = await ready(page);
|
||||
if (!(await frame.locator('body').textContent()).trim()) throw new Error('empty '+lang+' preview');
|
||||
}
|
||||
await page.selectOption('#sel-locale','pl-PL');
|
||||
await page.selectOption('#sel-template','team_invite');
|
||||
await ready(page);
|
||||
await page.waitForFunction(()=>document.getElementById('locale-message')?.textContent.includes('[UPSTREAM-WARN]'));
|
||||
await page.selectOption('#sel-locale','zh-CN');
|
||||
await page.selectOption('#sel-locale','en-US');
|
||||
await ready(page);
|
||||
const screenshotDir = path.join(root,'build','screenshots');
|
||||
await fs.mkdir(screenshotDir,{recursive:true});
|
||||
for (const theme of themes) {
|
||||
await page.selectOption('#sel-theme',theme);
|
||||
await page.selectOption('#sel-template','register_notify');
|
||||
await page.click('#btn-desk');
|
||||
const desktop = await ready(page);
|
||||
const cta = desktop.locator('.card-body .email-btn').first();
|
||||
const color = await cta.evaluate(el=>getComputedStyle(el).color);
|
||||
if (buttonColors[theme] && color!==buttonColors[theme]) throw new Error('CTA styling overridden: '+theme+' '+color);
|
||||
const label = (await cta.textContent()).trim();
|
||||
if (!label || /^https?:/i.test(label)) throw new Error('button displays URL: '+theme);
|
||||
const fallback = desktop.locator('.email-link-url a').first();
|
||||
if (await cta.getAttribute('href') !== await fallback.getAttribute('href')) throw new Error('button/fallback target mismatch: '+theme);
|
||||
const logo = desktop.locator('img.email-logo');
|
||||
if (await logo.count()) {
|
||||
if (!(await logo.getAttribute('src')).startsWith('data:image/png;base64,')) throw new Error('preview logo is not generated official asset: '+theme);
|
||||
if (!(await logo.evaluate(img=>img.complete && img.naturalWidth>0))) throw new Error('logo failed to load: '+theme);
|
||||
}
|
||||
// Capture the actual 600px email, not the wide preview canvas/inspector.
|
||||
await page.waitForFunction(()=>document.getAnimations().every(a=>a.playState!=='running'));
|
||||
const box = await desktop.locator('.email-wrap').boundingBox();
|
||||
if (!box) throw new Error('email frame missing: '+theme);
|
||||
const png = await page.screenshot({clip:box,animations:'disabled'});
|
||||
const background = await desktop.locator('body').evaluate(el=>getComputedStyle(el).backgroundColor);
|
||||
const image = await sharp(png).extend({top:16,bottom:16,left:16,right:16,background}).resize({width:600}).png({compressionLevel:9}).toBuffer();
|
||||
await fs.writeFile(path.join(screenshotDir,theme+'.png'),image);
|
||||
for (const type of types) {
|
||||
await page.selectOption('#sel-template',type);
|
||||
await page.click('#btn-mob');
|
||||
const frame = await ready(page);
|
||||
const overflow = await frame.locator('html').evaluate(el=>el.scrollWidth > window.innerWidth + 1);
|
||||
if (overflow) throw new Error('mobile overflow: '+theme+'/'+type);
|
||||
}
|
||||
}
|
||||
if (errors.length) throw new Error(errors.join('\n'));
|
||||
console.log('[PASS] file:// language bundles, warning, rapid switching, '+themes.length+' theme screenshots and mobile overflow');
|
||||
// A running dev server can be included without making static QA depend on it.
|
||||
if (process.env.PREVIEW_DEV_URL) {
|
||||
await page.goto(process.env.PREVIEW_DEV_URL);
|
||||
await ready(page);
|
||||
await page.selectOption('#sel-locale','zh-CN');
|
||||
await ready(page);
|
||||
console.log('[PASS] HTTP preview and language switching');
|
||||
}
|
||||
if (process.argv.includes('--update-gallery')) {
|
||||
for (const theme of themes) {
|
||||
const image=await fs.readFile(path.join(screenshotDir,theme+'.png'));
|
||||
if (image.length>50*1024) throw new Error('gallery image exceeds 50 KiB: '+theme);
|
||||
await fs.writeFile(path.join(root,'docs','images',theme+'.png'),image);
|
||||
}
|
||||
console.log('[PASS] gallery screenshots updated');
|
||||
}
|
||||
} finally { await browser.close(); }
|
||||
})().catch(error=>{console.error(error);process.exitCode=1;});
|
||||
@@ -15,6 +15,8 @@ func main() {
|
||||
Usage: "Gitea Mail Templates CLI — manage and preview email template themes",
|
||||
UsageText: "tools <command> [args...] [--flags]",
|
||||
Commands: []*cli.Command{
|
||||
cmds.UpstreamCommand(),
|
||||
cmds.BuildCommand(),
|
||||
cmds.ListCommand(),
|
||||
cmds.CreateCommand(),
|
||||
cmds.DeleteCommand(),
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"time"
|
||||
)
|
||||
|
||||
func CacheDir(root string) string { return filepath.Join(root, "build", "upstream") }
|
||||
|
||||
func ReadLock(root string) (Lock, error) {
|
||||
var lock Lock
|
||||
b, err := os.ReadFile(filepath.Join(root, "gitea.lock.json"))
|
||||
if err != nil {
|
||||
return lock, err
|
||||
}
|
||||
if err = json.Unmarshal(b, &lock); err != nil {
|
||||
return lock, err
|
||||
}
|
||||
return lock, lock.validateIdentity()
|
||||
}
|
||||
|
||||
func LoadPinned(root string) (*Snapshot, error) {
|
||||
lock, err := ReadLock(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s, err := Load(CacheDir(root))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !reflect.DeepEqual(lock, s.Lock) {
|
||||
return nil, fmt.Errorf("upstream cache differs from gitea.lock.json; run upstream prepare after reviewing the lock")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Prepare fetches immutable commit files only if the cache is absent. A corrupt
|
||||
// cache is an error, not permission to silently replace local evidence.
|
||||
func Prepare(ctx context.Context, root string) (*Snapshot, error) {
|
||||
return prepareFrom(ctx, root, "https://raw.githubusercontent.com/go-gitea/gitea/")
|
||||
}
|
||||
|
||||
func prepareFrom(ctx context.Context, root, rawBase string) (*Snapshot, error) {
|
||||
lock, err := ReadLock(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = os.Stat(filepath.Join(CacheDir(root), "lock.json")); err == nil {
|
||||
return LoadPinned(root)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
client := &http.Client{Timeout: 45 * time.Second}
|
||||
s := &Snapshot{Lock: lock, Files: map[string][]byte{}}
|
||||
for name, hash := range lock.Files {
|
||||
if !SafePath(name) {
|
||||
return nil, fmt.Errorf("unsafe locked path %q", name)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawBase+lock.Commit+"/"+name, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "GiteaMailTemplates-locked-cache")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, readErr := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
resp.Body.Close()
|
||||
if readErr != nil {
|
||||
return nil, readErr
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s: HTTP %d", name, resp.StatusCode)
|
||||
}
|
||||
if Digest(b) != hash {
|
||||
return nil, fmt.Errorf("download checksum mismatch: %s", name)
|
||||
}
|
||||
s.Files[name] = b
|
||||
}
|
||||
if err = s.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := CacheDir(root)
|
||||
if err = os.MkdirAll(filepath.Dir(dir), 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stage, err := os.MkdirTemp(filepath.Dir(dir), ".upstream-cache-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(stage)
|
||||
for name, b := range s.Files {
|
||||
target := filepath.Join(stage, filepath.FromSlash(name))
|
||||
if err = os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = os.WriteFile(target, b, 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
b, _ := json.MarshalIndent(lock, "", " ")
|
||||
if err = os.WriteFile(filepath.Join(stage, "lock.json"), append(b, '\n'), 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = os.Stat(dir); err == nil {
|
||||
return nil, fmt.Errorf("incomplete upstream cache exists at %s; inspect it before removing it", dir)
|
||||
}
|
||||
if err = os.Rename(stage, dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Printf("[PASS] Downloaded locked Gitea %s (%s) to build cache\n", lock.Tag, lock.Commit)
|
||||
return LoadPinned(root)
|
||||
}
|
||||
|
||||
func SyncRoot(ctx context.Context, tag, root string) error {
|
||||
if err := os.MkdirAll(filepath.Join(root, "build"), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := Sync(ctx, tag, CacheDir(root)); err != nil {
|
||||
return err
|
||||
}
|
||||
s, err := Load(CacheDir(root))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.MarshalIndent(s.Lock, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(root, "gitea.lock.json"), append(b, '\n'), 0644)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPrepareCacheIsPinnedAndReusableOffline(t *testing.T) {
|
||||
s, err := LoadPinned(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := t.TempDir()
|
||||
b, _ := json.Marshal(s.Lock)
|
||||
if err = os.WriteFile(filepath.Join(root, "gitea.lock.json"), b, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
prefix := "/" + s.Lock.Commit + "/"
|
||||
if !strings.HasPrefix(r.URL.Path, prefix) {
|
||||
t.Errorf("unpinned request %s", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
data, ok := s.Files[strings.TrimPrefix(r.URL.Path, prefix)]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Write(data)
|
||||
}))
|
||||
if _, err := prepareFrom(context.Background(), root, server.URL+"/"); err != nil {
|
||||
server.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
server.Close()
|
||||
if _, err := prepareFrom(context.Background(), root, server.URL+"/"); err != nil {
|
||||
t.Fatalf("offline cache: %v", err)
|
||||
}
|
||||
path := filepath.Join(CacheDir(root), "templates", "mail", "base", "head.tmpl")
|
||||
if err = os.WriteFile(path, []byte("corrupt"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := prepareFrom(context.Background(), root, server.URL+"/"); err == nil {
|
||||
t.Fatal("corrupt cache was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package upstream
|
||||
|
||||
// ReviewedFormattingIssue is deliberately matched by language, key and exact
|
||||
// upstream text. It cannot hide new defects after a snapshot update. Preview
|
||||
// preserves Gitea's output and advertises this upstream defect to the user.
|
||||
func ReviewedFormattingIssue(lang, key, value string) bool {
|
||||
return lang == "pl-PL" && key == "mail.team_invite.text_1" && value == "%[1]zaprasza Cię do dołączenia do zespołu %[2]s w organizacji %[3]s."
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"text/template"
|
||||
"text/template/parse"
|
||||
)
|
||||
|
||||
// ReferencedKeys walks Go's template AST, including nested pipelines and both
|
||||
// TrN keys. Dynamic translation keys require an explicit adapter review.
|
||||
func ReferencedKeys(s *Snapshot) ([]string, error) {
|
||||
fns := template.FuncMap{}
|
||||
for _, name := range []string{"AppUrl", "AppName", "AppDomain", "DotEscape", "QueryEscape", "ShortSha", "HTMLFormat", "PathEscapeSegments", "FormatByteSize", "Dict", "dict", "StringUtils", "SliceUtils", "JsonUtils", "QueryBuild", "PathEscape", "SanitizeHTML"} {
|
||||
fns[name] = func(...any) string { return "" }
|
||||
}
|
||||
keys := map[string]bool{}
|
||||
var walk func(parse.Node) error
|
||||
walk = func(n parse.Node) error {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
switch n := n.(type) {
|
||||
case *parse.ListNode:
|
||||
if n != nil {
|
||||
for _, child := range n.Nodes {
|
||||
if err := walk(child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
case *parse.ActionNode:
|
||||
return walk(n.Pipe)
|
||||
case *parse.PipeNode:
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
for _, cmd := range n.Cmds {
|
||||
if err := walk(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case *parse.CommandNode:
|
||||
if len(n.Args) > 0 {
|
||||
var ident []string
|
||||
switch f := n.Args[0].(type) {
|
||||
case *parse.FieldNode:
|
||||
ident = f.Ident
|
||||
case *parse.VariableNode:
|
||||
ident = f.Ident
|
||||
}
|
||||
if len(ident) >= 2 && ident[len(ident)-2] == "locale" {
|
||||
var positions []int
|
||||
switch ident[len(ident)-1] {
|
||||
case "Tr", "TrString":
|
||||
positions = []int{1}
|
||||
case "TrN":
|
||||
positions = []int{2, 3}
|
||||
}
|
||||
for _, i := range positions {
|
||||
if i >= len(n.Args) {
|
||||
return fmt.Errorf("invalid translation call: %s", n)
|
||||
}
|
||||
key, ok := n.Args[i].(*parse.StringNode)
|
||||
if !ok {
|
||||
return fmt.Errorf("dynamic translation key needs review: %s", n)
|
||||
}
|
||||
keys[key.Text] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, arg := range n.Args {
|
||||
if err := walk(arg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case *parse.IfNode:
|
||||
for _, child := range []parse.Node{n.Pipe, n.List, n.ElseList} {
|
||||
if err := walk(child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case *parse.RangeNode:
|
||||
for _, child := range []parse.Node{n.Pipe, n.List, n.ElseList} {
|
||||
if err := walk(child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case *parse.WithNode:
|
||||
for _, child := range []parse.Node{n.Pipe, n.List, n.ElseList} {
|
||||
if err := walk(child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case *parse.TemplateNode:
|
||||
return walk(n.Pipe)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for p, data := range s.Templates {
|
||||
t, err := template.New(p).Funcs(fns).Parse(string(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", p, err)
|
||||
}
|
||||
for _, t := range t.Templates() {
|
||||
if t.Tree != nil {
|
||||
if err := walk(t.Tree.Root); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var result []string
|
||||
for k := range keys {
|
||||
result = append(result, k)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func ValidateKeys(s *Snapshot) error {
|
||||
keys, err := ReferencedKeys(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, k := range keys {
|
||||
if s.Locales["en-US"][k] == "" {
|
||||
return fmt.Errorf("official English catalog is missing referenced key %q", k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func MissingKeys(s *Snapshot) (map[string][]string, error) {
|
||||
keys, err := ReferencedKeys(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
missing := map[string][]string{}
|
||||
for lang, catalog := range s.Locales {
|
||||
for _, k := range keys {
|
||||
if catalog[k] == "" {
|
||||
missing[lang] = append(missing[lang], k)
|
||||
}
|
||||
}
|
||||
}
|
||||
return missing, nil
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// Package upstream loads and explicitly synchronizes the reviewed Gitea snapshot.
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Lock struct {
|
||||
Schema int `json:"schema"`
|
||||
Repository string `json:"repository"`
|
||||
Tag string `json:"tag"`
|
||||
Commit string `json:"commit"`
|
||||
Files map[string]string `json:"files"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Lock Lock
|
||||
Files map[string][]byte
|
||||
Templates map[string][]byte // mail/... paths, including shared partials
|
||||
Locales map[string]map[string]string
|
||||
}
|
||||
|
||||
// AdapterSources also binds the preview's formatting and rendering semantics to
|
||||
// the upstream implementation reviewed during this migration. A changed source
|
||||
// requires reviewing the adapter before accepting another snapshot.
|
||||
var AdapterSources = map[string]string{
|
||||
"modules/translation/translation.go": "4217cbd9beb79c0e1be52dd9a7d7705b9ff4becc2d9515d49236db126da0f7ee",
|
||||
"modules/translation/i18n/localestore.go": "e55a7832d01753622a7d47e904de95e5aed7ad3535c59bf6ac3fec4346e40e9d",
|
||||
"modules/translation/i18n/format.go": "f440236eaf1f73fe0a9aa36a2c036a4b34c6ab28c9e4cf971bef7fceee701cb4",
|
||||
"modules/templates/mail.go": "fb8204b79f700f88c7c3f0da16e50c3d678a2216e62e98f712805ed1d42ab3a9",
|
||||
}
|
||||
|
||||
func Digest(data []byte) string {
|
||||
h := sha256.Sum256(data)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func SafePath(p string) bool {
|
||||
return p != "." && fs.ValidPath(p) && !strings.ContainsAny(p, "\\:")
|
||||
}
|
||||
|
||||
func Load(dir string) (*Snapshot, error) {
|
||||
s, err := loadFiles(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, s.validate()
|
||||
}
|
||||
|
||||
// Ownership/checksum validation is separate so explicitly syncing after an
|
||||
// adapter review can replace an older, still intact snapshot.
|
||||
func loadFiles(dir string) (*Snapshot, error) {
|
||||
data, err := os.ReadFile(filepath.Join(dir, "lock.json"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("snapshot lock: %w (run upstream sync explicitly)", err)
|
||||
}
|
||||
s := &Snapshot{Files: map[string][]byte{}}
|
||||
if err := json.Unmarshal(data, &s.Lock); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.Lock.validateIdentity(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for p, expected := range s.Lock.Files {
|
||||
if !SafePath(p) {
|
||||
return nil, fmt.Errorf("unsafe snapshot path %q", p)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(p)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if Digest(b) != expected {
|
||||
return nil, fmt.Errorf("snapshot checksum mismatch: %s", p)
|
||||
}
|
||||
s.Files[p] = b
|
||||
}
|
||||
// Unlocked files must not silently become input to a build.
|
||||
err = filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.Type()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("snapshot symlinks are unsupported: %s", p)
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(dir, p)
|
||||
rel = filepath.ToSlash(rel)
|
||||
if rel != "lock.json" {
|
||||
if _, ok := s.Files[rel]; !ok {
|
||||
return fmt.Errorf("unlocked snapshot file: %s", rel)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (lock Lock) validateIdentity() error {
|
||||
if lock.Schema != 1 || lock.Repository != "https://github.com/go-gitea/gitea" || !validTag(lock.Tag) || len(lock.Commit) != 40 || len(lock.Files) == 0 {
|
||||
return fmt.Errorf("invalid snapshot identity or unsupported version")
|
||||
}
|
||||
if _, err := hex.DecodeString(lock.Commit); err != nil {
|
||||
return fmt.Errorf("invalid snapshot commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Snapshot) validate() error {
|
||||
if err := s.Lock.validateIdentity(); err != nil {
|
||||
return err
|
||||
}
|
||||
for p, hash := range AdapterSources {
|
||||
if Digest(s.Files[p]) != hash {
|
||||
return fmt.Errorf("upstream adapter changed: %s; review preview semantics before syncing", p)
|
||||
}
|
||||
}
|
||||
if len(s.Files["LICENSE"]) == 0 {
|
||||
return fmt.Errorf("missing upstream license")
|
||||
}
|
||||
s.Templates = map[string][]byte{}
|
||||
s.Locales = map[string]map[string]string{}
|
||||
for p, b := range s.Files {
|
||||
switch {
|
||||
case p == "public/assets/img/favicon.png":
|
||||
if len(b) == 0 {
|
||||
return fmt.Errorf("missing preview favicon")
|
||||
}
|
||||
case strings.HasPrefix(p, "templates/mail/") && strings.HasSuffix(p, ".tmpl"):
|
||||
s.Templates[strings.TrimPrefix(p, "templates/")] = b
|
||||
case strings.HasPrefix(p, "options/locale/locale_") && strings.HasSuffix(p, ".json"):
|
||||
locale := strings.TrimSuffix(strings.TrimPrefix(p, "options/locale/locale_"), ".json")
|
||||
values, err := DecodeLocale(b)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", p, err)
|
||||
}
|
||||
s.Locales[locale] = values
|
||||
default:
|
||||
if p != "LICENSE" {
|
||||
if _, ok := AdapterSources[p]; !ok {
|
||||
return fmt.Errorf("unsupported snapshot file: %s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(s.Entrypoints()) == 0 || len(s.Templates["mail/base/head.tmpl"]) == 0 || len(s.Templates["mail/base/footer.tmpl"]) == 0 || len(s.Locales["en-US"]) == 0 {
|
||||
return fmt.Errorf("snapshot is missing entrypoints, shared partials, or English translations")
|
||||
}
|
||||
return ValidateKeys(s)
|
||||
}
|
||||
|
||||
// DecodeLocale mirrors Gitea's flat or one-level nested JSON catalog layout.
|
||||
func DecodeLocale(data []byte) (map[string]string, error) {
|
||||
var raw map[string]any
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := map[string]string{}
|
||||
for k, v := range raw {
|
||||
switch v := v.(type) {
|
||||
case string:
|
||||
result[k] = v
|
||||
case map[string]any:
|
||||
for inner, value := range v {
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported translation value: %s.%s", k, inner)
|
||||
}
|
||||
result[k+"."+inner] = str
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported translation value: %s", k)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Snapshot) Entrypoints() []string {
|
||||
var result []string
|
||||
for p := range s.Templates {
|
||||
if !strings.HasPrefix(p, "mail/base/") {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Snapshot) Languages() []string {
|
||||
var langs []string
|
||||
for l := range s.Locales {
|
||||
langs = append(langs, l)
|
||||
}
|
||||
sort.Strings(langs)
|
||||
return langs
|
||||
}
|
||||
|
||||
func TemplateID(p string) string { return strings.TrimSuffix(path.Base(p), ".tmpl") }
|
||||
@@ -0,0 +1,86 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSnapshotAndKeys(t *testing.T) {
|
||||
s, err := Load(filepath.Join("..", "..", "build", "upstream"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Lock.Tag != "v28.0.0" || len(s.Templates) != 13 || len(s.Locales) != 28 {
|
||||
t.Fatalf("unexpected baseline %s", s.Lock.Tag)
|
||||
}
|
||||
keys, err := ReferencedKeys(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := map[string]bool{}
|
||||
for _, k := range keys {
|
||||
found[k] = true
|
||||
}
|
||||
for _, k := range []string{"mail.issue.action.push_1", "mail.issue.action.push_n", "actions.runs.attempt"} {
|
||||
if !found[k] {
|
||||
t.Fatalf("missing key %s", k)
|
||||
}
|
||||
}
|
||||
delete(s.Locales["en-US"], "actions.runs.attempt")
|
||||
if ValidateKeys(s) == nil {
|
||||
t.Fatal("missing English key accepted")
|
||||
}
|
||||
if !validTag("v28.0.0") || validTag("v1.27.3") || validTag("v28.1.0-rc1") {
|
||||
t.Fatal("tag policy")
|
||||
}
|
||||
for _, p := range []string{"../x", "/x", "C:/x", "mail\\x"} {
|
||||
if SafePath(p) {
|
||||
t.Fatal("unsafe path accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotRejectsChecksumAndAdapterDrift(t *testing.T) {
|
||||
s, err := Load(filepath.Join("..", "..", "build", "upstream"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
for p, b := range s.Files {
|
||||
target := filepath.Join(dir, filepath.FromSlash(p))
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(target, b, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
writeLock := func() {
|
||||
b, _ := json.Marshal(s.Lock)
|
||||
if err := os.WriteFile(filepath.Join(dir, "lock.json"), b, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
writeLock()
|
||||
p := "templates/mail/base/head.tmpl"
|
||||
if err := os.WriteFile(filepath.Join(dir, filepath.FromSlash(p)), []byte("changed"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Load(dir); err == nil {
|
||||
t.Fatal("checksum mismatch accepted")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, filepath.FromSlash(p)), s.Files[p], 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p = "modules/translation/translation.go"
|
||||
if err := os.WriteFile(filepath.Join(dir, filepath.FromSlash(p)), []byte("changed"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.Lock.Files[p] = Digest([]byte("changed"))
|
||||
writeLock()
|
||||
if _, err := Load(dir); err == nil {
|
||||
t.Fatal("changed translation adapter accepted despite updated checksum")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package upstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var tagPattern = regexp.MustCompile(`^v([0-9]+)\.[0-9]+\.[0-9]+$`)
|
||||
|
||||
func validTag(tag string) bool {
|
||||
m := tagPattern.FindStringSubmatch(tag)
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
major, err := strconv.Atoi(m[1])
|
||||
return err == nil && major >= 28
|
||||
}
|
||||
|
||||
// Sync downloads by resolved commit, validates in memory, then swaps the whole
|
||||
// snapshot. Network/adapter failures leave the existing snapshot untouched.
|
||||
func Sync(ctx context.Context, tag, dir string) error {
|
||||
return syncFrom(ctx, tag, dir, "https://api.github.com/repos/go-gitea/gitea/", "https://raw.githubusercontent.com/go-gitea/gitea/")
|
||||
}
|
||||
|
||||
func syncFrom(ctx context.Context, tag, dir, api, raw string) error {
|
||||
if !validTag(tag) {
|
||||
return fmt.Errorf("expected a stable Gitea 28+ tag, e.g. v28.0.0")
|
||||
}
|
||||
client := &http.Client{Timeout: 45 * time.Second}
|
||||
get := func(url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "GiteaMailTemplates-snapshot")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
return b, err
|
||||
}
|
||||
b, err := get(api + "commits/" + tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var commit struct {
|
||||
SHA string `json:"sha"`
|
||||
Commit struct {
|
||||
Tree struct {
|
||||
SHA string `json:"sha"`
|
||||
} `json:"tree"`
|
||||
} `json:"commit"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &commit); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err = get(api + "git/trees/" + commit.Commit.Tree.SHA + "?recursive=1")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var tree struct {
|
||||
Truncated bool `json:"truncated"`
|
||||
Tree []struct {
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
} `json:"tree"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &tree); err != nil {
|
||||
return err
|
||||
}
|
||||
if tree.Truncated {
|
||||
return fmt.Errorf("upstream tree is truncated")
|
||||
}
|
||||
s := &Snapshot{Lock: Lock{Schema: 1, Repository: "https://github.com/go-gitea/gitea", Tag: tag, Commit: commit.SHA, Files: map[string]string{}}, Files: map[string][]byte{}}
|
||||
for _, item := range tree.Tree {
|
||||
p := item.Path
|
||||
_, adapter := AdapterSources[p]
|
||||
selected := adapter || p == "LICENSE" || p == "public/assets/img/favicon.png" || (strings.HasPrefix(p, "templates/mail/") && strings.HasSuffix(p, ".tmpl")) || (strings.HasPrefix(p, "options/locale/locale_") && strings.HasSuffix(p, ".json"))
|
||||
if !selected || item.Type != "blob" {
|
||||
continue
|
||||
}
|
||||
b, err := get(raw + commit.SHA + "/" + p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.Files[p] = b
|
||||
s.Lock.Files[p] = Digest(b)
|
||||
}
|
||||
if err := s.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Never replace a checkout or arbitrary non-snapshot directory.
|
||||
if filepath.Base(abs) != "upstream" {
|
||||
return fmt.Errorf("snapshot destination must be named upstream")
|
||||
}
|
||||
if entries, err := os.ReadDir(abs); err == nil && len(entries) > 0 {
|
||||
if _, err := loadFiles(abs); err != nil {
|
||||
return fmt.Errorf("refusing to replace invalid existing snapshot: %w", err)
|
||||
}
|
||||
}
|
||||
stage, err := os.MkdirTemp(filepath.Dir(abs), ".upstream-stage-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(stage)
|
||||
for p, data := range s.Files {
|
||||
filename := filepath.Join(stage, filepath.FromSlash(p))
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filename, data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
b, err = json.MarshalIndent(s.Lock, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b = append(b, '\n')
|
||||
if err := os.WriteFile(filepath.Join(stage, "lock.json"), b, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(abs); err == nil {
|
||||
backup, err := os.MkdirTemp(filepath.Dir(abs), ".upstream-backup-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(backup); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(abs, backup); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(stage, abs); err != nil {
|
||||
_ = os.Rename(backup, abs)
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(backup); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := os.Rename(stage, abs); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("[PASS] Locked %s (%s): %d templates, %d languages\n", tag, commit.SHA, len(s.Templates), len(s.Locales))
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user