taler-merchant-demos

Python-based Frontends for the Demonstration Web site
Log | Files | Refs | README | LICENSE

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 }