1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5package i18n
6
7import (
8 "embed"
9 "errors"
10 "fmt"
11 "io/fs"
12 "strings"
13
14 "github.com/BurntSushi/toml"
15)
16
17const (
18 // DefaultLanguage is the fallback locale when no other language is available.
19 DefaultLanguage = "en"
20)
21
22var (
23 //go:embed locales/*.toml
24 localeFiles embed.FS
25
26 languageAliases = map[string]string{
27 "en": "en",
28 "eng": "en",
29 "tok": "tok",
30 "tp": "tok",
31 }
32)
33
34// Localizer provides translated strings with an English fallback.
35type Localizer struct {
36 language string
37 entries map[string]string
38 fallback map[string]string
39}
40
41// Language reports the canonical language code for the localizer.
42func (l *Localizer) Language() string {
43 if l == nil || l.language == "" {
44 return DefaultLanguage
45 }
46 return l.language
47}
48
49// T returns the translated string for key, formatting with args when provided.
50// When the key does not exist in the active catalog, the English fallback is
51// used. Missing keys return the key itself as a last resort.
52func (l *Localizer) T(key string, args ...any) string {
53 if key == "" {
54 return ""
55 }
56
57 template := ""
58 if l != nil {
59 if value, ok := l.entries[key]; ok && value != "" {
60 template = value
61 } else if value, ok := l.fallback[key]; ok && value != "" {
62 template = value
63 }
64 }
65 if template == "" {
66 template = key
67 }
68 if len(args) == 0 {
69 return template
70 }
71 return fmt.Sprintf(template, args...)
72}
73
74// Load constructs a Localizer for language, falling back to English when the
75// requested catalog is unavailable.
76func Load(language string) (*Localizer, error) {
77 lang := canonicalLanguage(language)
78
79 fallback, err := loadCatalog(DefaultLanguage)
80 if err != nil {
81 return nil, fmt.Errorf("i18n: load fallback %q: %w", DefaultLanguage, err)
82 }
83
84 var entries map[string]string
85 if lang == DefaultLanguage {
86 entries = cloneCatalog(fallback)
87 } else {
88 entries, err = loadCatalog(lang)
89 if err != nil {
90 if !errors.Is(err, fs.ErrNotExist) {
91 return nil, fmt.Errorf("i18n: load %q: %w", lang, err)
92 }
93 entries = map[string]string{}
94 lang = DefaultLanguage
95 }
96 }
97
98 return &Localizer{
99 language: lang,
100 entries: entries,
101 fallback: fallback,
102 }, nil
103}
104
105func canonicalLanguage(input string) string {
106 code := strings.TrimSpace(strings.ToLower(input))
107 if code == "" {
108 return DefaultLanguage
109 }
110 if canonical, ok := languageAliases[code]; ok {
111 return canonical
112 }
113 return code
114}
115
116func loadCatalog(language string) (map[string]string, error) {
117 filename := fmt.Sprintf("locales/%s.toml", language)
118 data, err := localeFiles.ReadFile(filename)
119 if err != nil {
120 return nil, err
121 }
122 return parseCatalog(data)
123}
124
125func parseCatalog(data []byte) (map[string]string, error) {
126 var raw map[string]any
127 if err := toml.Unmarshal(data, &raw); err != nil {
128 return nil, fmt.Errorf("parse toml: %w", err)
129 }
130 return flattenToml(raw, ""), nil
131}
132
133// flattenToml recursively flattens nested TOML structures into dot-notation keys.
134func flattenToml(data map[string]any, prefix string) map[string]string {
135 result := make(map[string]string)
136 for key, value := range data {
137 fullKey := key
138 if prefix != "" {
139 fullKey = prefix + "." + key
140 }
141
142 switch v := value.(type) {
143 case string:
144 result[fullKey] = v
145 case map[string]any:
146 nested := flattenToml(v, fullKey)
147 for k, val := range nested {
148 result[k] = val
149 }
150 default:
151 // Convert other types to strings
152 result[fullKey] = fmt.Sprint(v)
153 }
154 }
155 return result
156}
157
158func cloneCatalog(src map[string]string) map[string]string {
159 clone := make(map[string]string, len(src))
160 for k, v := range src {
161 clone[k] = v
162 }
163 return clone
164}