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,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
|
||||
}
|
||||
Reference in new issue
Block a user