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(`` + regexp.QuoteMeta(spec.text) + `

`) 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 := `

{{.locale.Tr "mail.link_not_working_do_paste"}}

` 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, `
`+control+`
`+"\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)]*?)?>`).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("

")) 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 }