main.go (6985B)
1 // Command i18n extracts translatable messages from the Go web application and 2 // its templates into a gettext POT file. 3 package main 4 5 import ( 6 "bufio" 7 "flag" 8 "fmt" 9 "go/ast" 10 "go/parser" 11 "go/token" 12 "io/fs" 13 "os" 14 "path/filepath" 15 "regexp" 16 "sort" 17 "strconv" 18 "strings" 19 ) 20 21 type message struct { 22 id string 23 references map[string]bool 24 comments map[string]bool 25 } 26 27 var ( 28 root = flag.String("root", ".", "repository root") 29 output = flag.String("output", "internal/web/assets/translations/messages.pot", "output POT file") 30 templateCallRE = regexp.MustCompile(`\{\{\s*(?:tr|trHTML|trf|trfHTML)\b`) 31 templateRE = regexp.MustCompile(`\{\{\s*(?:tr|trHTML|trf|trfHTML)\s+\$?\.Lang\s+("(?:\\.|[^"\\])*")`) 32 templateTranslatorCommentRE = regexp.MustCompile(`(?s)\{\{/\*\s*Translators:\s*(.*?)\s*\*/\}\}`) 33 braceRE = regexp.MustCompile(`\{(?:[A-Za-z_][A-Za-z0-9_]*)?\}`) 34 ) 35 36 func main() { 37 flag.Parse() 38 messages := make(map[string]*message) 39 if err := extractGo(messages); err != nil { 40 fatal(err) 41 } 42 if err := extractTemplates(messages); err != nil { 43 fatal(err) 44 } 45 if err := writePOT(messages); err != nil { 46 fatal(err) 47 } 48 } 49 50 func extractGo(messages map[string]*message) error { 51 fset := token.NewFileSet() 52 directory := filepath.Join(*root, "internal", "web") 53 entries, err := os.ReadDir(directory) 54 if err != nil { 55 return err 56 } 57 messageArgument := map[string]int{ 58 "makePage": 2, "renderError": 4, "renderErrorf": 4, 59 "translate": 1, "translatedHTML": 1, "translatedHTMLf": 1, 60 } 61 for _, entry := range entries { 62 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") { 63 continue 64 } 65 filename := filepath.Join(directory, entry.Name()) 66 file, err := parser.ParseFile(fset, filename, nil, parser.ParseComments) 67 if err != nil { 68 return err 69 } 70 translatorComments := make(map[int][]string) 71 for _, group := range file.Comments { 72 comment := strings.TrimSpace(group.Text()) 73 if !strings.HasPrefix(comment, "Translators:") { 74 continue 75 } 76 comment = normalizeComment(strings.TrimPrefix(comment, "Translators:")) 77 if comment != "" { 78 line := fset.Position(group.End()).Line 79 translatorComments[line] = append(translatorComments[line], comment) 80 } 81 } 82 ast.Inspect(file, func(node ast.Node) bool { 83 call, ok := node.(*ast.CallExpr) 84 if !ok { 85 return true 86 } 87 selector, ok := call.Fun.(*ast.SelectorExpr) 88 if !ok { 89 return true 90 } 91 argument, ok := messageArgument[selector.Sel.Name] 92 if !ok || argument >= len(call.Args) { 93 return true 94 } 95 literal, ok := call.Args[argument].(*ast.BasicLit) 96 if !ok || literal.Kind != token.STRING { 97 return true 98 } 99 id, err := strconv.Unquote(literal.Value) 100 if err == nil { 101 position := fset.Position(literal.Pos()) 102 callLine := fset.Position(call.Pos()).Line 103 add(messages, id, reference(filename, position.Line), translatorComments[callLine-1]...) 104 } 105 return true 106 }) 107 } 108 return nil 109 } 110 111 func extractTemplates(messages map[string]*message) error { 112 directory := filepath.Join(*root, "internal", "web", "templates") 113 return filepath.WalkDir(directory, func(filename string, entry fs.DirEntry, err error) error { 114 if err != nil { 115 return err 116 } 117 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".gohtml") { 118 return nil 119 } 120 contents, err := os.ReadFile(filename) 121 if err != nil { 122 return err 123 } 124 matches := templateRE.FindAllSubmatchIndex(contents, -1) 125 if len(matches) != len(templateCallRE.FindAllIndex(contents, -1)) { 126 return fmt.Errorf("%s: translation helper without a literal message", filename) 127 } 128 comments := templateTranslatorCommentRE.FindAllSubmatchIndex(contents, -1) 129 commentIndex := 0 130 previousMatchEnd := 0 131 for _, match := range matches { 132 var translatorComments []string 133 for commentIndex < len(comments) && comments[commentIndex][0] < match[0] { 134 comment := comments[commentIndex] 135 if comment[0] >= previousMatchEnd { 136 text := normalizeComment(string(contents[comment[2]:comment[3]])) 137 if text != "" { 138 translatorComments = append(translatorComments, text) 139 } 140 } 141 commentIndex++ 142 } 143 id, err := strconv.Unquote(string(contents[match[2]:match[3]])) 144 if err != nil { 145 return fmt.Errorf("%s: invalid quoted message: %w", filename, err) 146 } 147 line := 1 + strings.Count(string(contents[:match[2]]), "\n") 148 add(messages, id, reference(filename, line), translatorComments...) 149 previousMatchEnd = match[1] 150 } 151 return nil 152 }) 153 } 154 155 func add(messages map[string]*message, id, ref string, comments ...string) { 156 if id == "" { 157 return 158 } 159 entry := messages[id] 160 if entry == nil { 161 entry = &message{id: id, references: make(map[string]bool), comments: make(map[string]bool)} 162 messages[id] = entry 163 } 164 entry.references[ref] = true 165 for _, comment := range comments { 166 entry.comments[comment] = true 167 } 168 } 169 170 func normalizeComment(comment string) string { 171 return strings.Join(strings.Fields(comment), " ") 172 } 173 174 func reference(filename string, line int) string { 175 relative, err := filepath.Rel(*root, filename) 176 if err != nil { 177 relative = filename 178 } 179 return filepath.ToSlash(relative) + ":" + strconv.Itoa(line) 180 } 181 182 func writePOT(messages map[string]*message) error { 183 file, err := os.Create(*output) 184 if err != nil { 185 return err 186 } 187 writer := bufio.NewWriter(file) 188 fmt.Fprintln(writer, "# Messages for GNU Taler merchant demos.") 189 fmt.Fprintln(writer, "# This file is distributed under the same license as taler-merchant-demos.") 190 fmt.Fprintln(writer, "msgid \"\"") 191 fmt.Fprintln(writer, "msgstr \"\"") 192 fmt.Fprintln(writer, `"Project-Id-Version: taler-merchant-demos\n"`) 193 fmt.Fprintln(writer, `"Report-Msgid-Bugs-To: taler@gnu.org\n"`) 194 fmt.Fprintln(writer, `"MIME-Version: 1.0\n"`) 195 fmt.Fprintln(writer, `"Content-Type: text/plain; charset=UTF-8\n"`) 196 fmt.Fprintln(writer, `"Content-Transfer-Encoding: 8bit\n"`) 197 198 ids := make([]string, 0, len(messages)) 199 for id := range messages { 200 ids = append(ids, id) 201 } 202 sort.Strings(ids) 203 for _, id := range ids { 204 entry := messages[id] 205 comments := make([]string, 0, len(entry.comments)) 206 for comment := range entry.comments { 207 comments = append(comments, comment) 208 } 209 sort.Strings(comments) 210 references := make([]string, 0, len(entry.references)) 211 for ref := range entry.references { 212 references = append(references, ref) 213 } 214 sort.Strings(references) 215 fmt.Fprintln(writer) 216 for _, comment := range comments { 217 fmt.Fprintln(writer, "#. "+comment) 218 } 219 fmt.Fprintln(writer, "#: "+strings.Join(references, " ")) 220 if braceRE.MatchString(id) { 221 fmt.Fprintln(writer, "#, python-brace-format") 222 } 223 fmt.Fprintln(writer, "msgid "+strconv.Quote(id)) 224 fmt.Fprintln(writer, "msgstr \"\"") 225 } 226 if err := writer.Flush(); err != nil { 227 _ = file.Close() 228 return err 229 } 230 return file.Close() 231 } 232 233 func fatal(err error) { 234 fmt.Fprintln(os.Stderr, "i18n:", err) 235 os.Exit(1) 236 }