i18n.go (4308B)
1 package web 2 3 import ( 4 "bufio" 5 "fmt" 6 "io/fs" 7 "regexp" 8 "strconv" 9 "strings" 10 ) 11 12 type catalogs map[string]map[string]string 13 14 var ( 15 placeholderPattern = regexp.MustCompile(`\{(?:[A-Za-z_][A-Za-z0-9_]*)?\}`) 16 translationTagPattern = regexp.MustCompile(`</?[A-Za-z][A-Za-z0-9]*\b`) 17 ) 18 19 func loadCatalogs(supported map[string]bool) (catalogs, error) { 20 result := make(catalogs) 21 entries, err := fs.ReadDir(Assets, "assets/translations") 22 if err != nil { 23 return nil, fmt.Errorf("read translations: %w", err) 24 } 25 for _, entry := range entries { 26 if !entry.IsDir() { 27 continue 28 } 29 lang := entry.Name() 30 if !supported[lang] { 31 continue 32 } 33 data, err := Assets.ReadFile("assets/translations/" + lang + "/LC_MESSAGES/messages.po") 34 if err != nil { 35 continue 36 } 37 catalog := parsePO(string(data)) 38 for message, translation := range catalog { 39 if !samePlaceholders(message, translation) { 40 return nil, fmt.Errorf("translation %s has different placeholders for %q", lang, message) 41 } 42 if !sameHTMLTags(message, translation) { 43 return nil, fmt.Errorf("translation %s has different HTML tags for %q", lang, message) 44 } 45 } 46 result[lang] = catalog 47 } 48 for lang := range supported { 49 if result[lang] == nil { 50 return nil, fmt.Errorf("translation catalog %s is missing", lang) 51 } 52 } 53 return result, nil 54 } 55 56 func parsePO(input string) map[string]string { 57 translations := make(map[string]string) 58 var id, value strings.Builder 59 field := "" 60 fuzzy := false 61 flush := func() { 62 if !fuzzy && id.Len() != 0 && value.Len() != 0 { 63 translations[id.String()] = value.String() 64 } 65 id.Reset() 66 value.Reset() 67 field = "" 68 fuzzy = false 69 } 70 scanner := bufio.NewScanner(strings.NewReader(input)) 71 for scanner.Scan() { 72 line := strings.TrimSpace(scanner.Text()) 73 switch { 74 case line == "": 75 flush() 76 case strings.HasPrefix(line, "#,") && strings.Contains(line, "fuzzy"): 77 fuzzy = true 78 case strings.HasPrefix(line, "msgid "): 79 if id.Len() != 0 || value.Len() != 0 { 80 flush() 81 } 82 field = "id" 83 id.WriteString(poString(strings.TrimSpace(strings.TrimPrefix(line, "msgid")))) 84 case strings.HasPrefix(line, "msgstr "): 85 field = "value" 86 value.WriteString(poString(strings.TrimSpace(strings.TrimPrefix(line, "msgstr")))) 87 case strings.HasPrefix(line, `"`): 88 if field == "id" { 89 id.WriteString(poString(line)) 90 } else if field == "value" { 91 value.WriteString(poString(line)) 92 } 93 } 94 } 95 flush() 96 return translations 97 } 98 99 func poString(value string) string { 100 decoded, err := strconv.Unquote(value) 101 if err != nil { 102 return "" 103 } 104 return decoded 105 } 106 107 func (c catalogs) translate(lang, message string) string { 108 if translated := c[lang][message]; translated != "" { 109 return translated 110 } 111 if base, _, ok := strings.Cut(lang, "_"); ok { 112 if translated := c[base][message]; translated != "" { 113 return translated 114 } 115 } 116 return message 117 } 118 119 func formatNamed(message string, values ...any) string { 120 replacements := make(map[string]string) 121 for i := 0; i+1 < len(values); i += 2 { 122 replacements["{"+fmt.Sprint(values[i])+"}"] = fmt.Sprint(values[i+1]) 123 } 124 return placeholderPattern.ReplaceAllStringFunc(message, func(placeholder string) string { 125 if replacement, ok := replacements[placeholder]; ok { 126 return replacement 127 } 128 return placeholder 129 }) 130 } 131 132 func samePlaceholders(message, translation string) bool { 133 count := func(input string) map[string]int { 134 result := make(map[string]int) 135 for _, placeholder := range placeholderPattern.FindAllString(input, -1) { 136 result[placeholder]++ 137 } 138 return result 139 } 140 original := count(message) 141 translated := count(translation) 142 if len(original) != len(translated) { 143 return false 144 } 145 for placeholder, occurrences := range original { 146 if translated[placeholder] != occurrences { 147 return false 148 } 149 } 150 return true 151 } 152 153 func sameHTMLTags(message, translation string) bool { 154 count := func(input string) map[string]int { 155 result := make(map[string]int) 156 for _, tag := range translationTagPattern.FindAllString(input, -1) { 157 result[strings.ToLower(tag)]++ 158 } 159 return result 160 } 161 original := count(message) 162 translated := count(translation) 163 if len(original) != len(translated) { 164 return false 165 } 166 for tag, occurrences := range original { 167 if translated[tag] != occurrences { 168 return false 169 } 170 } 171 return true 172 }