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

140 lines
5.6 KiB
Go

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
}