-
Notifications
You must be signed in to change notification settings - Fork 4
/
Copy pathbundle.go
426 lines (359 loc) · 13.1 KB
/
bundle.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
package spreak
import (
"errors"
"fmt"
"io/fs"
"golang.org/x/text/language"
"github.com/vorlif/spreak/catalog"
)
// NoDomain is the domain which is used if no default domain is stored.
const NoDomain = ""
// NoCtx is the context which is used if no context is stored.
const NoCtx = ""
// ErrorsCtx ist the context under which translations for extracted errors are searched.
// Can be changed when creating a bundle with WithErrorContext.
const ErrorsCtx = "errors"
// MissingTranslationCallback is a callback which can be stored with WithMissingTranslationCallback for a bundle.
// Called when translations, domains, or other are missing.
// The call is not goroutine safe.
type MissingTranslationCallback func(err error)
// LanguageMatcherBuilder is a builder which creates a language matcher.
// It is an abstraction of language.NewMatcher of the language package and should return the same values.
// Can be set when creating a bundle with WithLanguageMatcherBuilder for a bundle.
// The matcher is used, for example, when a new Localizer is created to determine the best matching language.
type LanguageMatcherBuilder func(t []language.Tag, options ...language.MatchOption) language.Matcher
// BundleOption is an option which can be passed when creating a bundle to customize its configuration.
type BundleOption func(opts *bundleBuilder) error
type setupAction func(options *bundleBuilder) error
type bundleBuilder struct {
*Bundle
sourceLanguage language.Tag
fallbackLanguage language.Tag
languageMatcherBuilder LanguageMatcherBuilder
domainLoaders map[string]Loader
setupActions []setupAction
}
// A Bundle is the central place to load and manage translations.
// It holds all catalogs for all domains and all languages.
// The bundle cannot be edited after creation and is goroutine safe.
// Typically, an application contains a bundle as a singleton.
// The catalog of the specified domains and languages will be loaded during the creation.
type Bundle struct {
missingCallback MissingTranslationCallback
printer Printer
defaultDomain string
errContext string
fallbackLocale *locale
sourceLocale *locale
languageMatcher language.Matcher
languages []language.Tag
locales map[language.Tag]*locale
domains map[string]bool
}
// NewBundle creates a new bundle and returns it.
// An error is returned if something fails during creation.
// This is only the case if one of the options returns an error.
// A call without options will never return an error and can thus be used for testing or as a fallback.
// The catalog of the specified domains and languages will be loaded during the creation.
func NewBundle(opts ...BundleOption) (*Bundle, error) {
builder := &bundleBuilder{
Bundle: &Bundle{
printer: NewDefaultPrinter(),
defaultDomain: NoDomain,
errContext: ErrorsCtx,
languages: make([]language.Tag, 0),
locales: make(map[language.Tag]*locale),
domains: make(map[string]bool),
},
languageMatcherBuilder: language.NewMatcher,
fallbackLanguage: language.Und,
sourceLanguage: language.Und,
domainLoaders: make(map[string]Loader),
setupActions: make([]setupAction, 0),
}
for _, opt := range opts {
if opt == nil {
return nil, errors.New("spreak.Bundle: try to create an bundle with a nil option")
}
if err := opt(builder); err != nil {
return nil, err
}
}
for domain := range builder.domainLoaders {
builder.domains[domain] = false
}
for _, action := range builder.setupActions {
if action == nil {
return nil, errors.New("spreak.Bundle: try to create an bundle with a nil action")
}
if err := action(builder); err != nil {
return nil, err
}
}
builder.languageMatcher = builder.languageMatcherBuilder(builder.languages)
builder.printer.Init(builder.languages)
if sourceLocale, hasSource := builder.locales[builder.sourceLanguage]; hasSource {
builder.Bundle.sourceLocale = sourceLocale
} else {
builder.Bundle.sourceLocale = buildSourceLocale(builder.Bundle, builder.sourceLanguage)
}
if fallbackLocale, hasFallback := builder.locales[builder.fallbackLanguage]; hasFallback {
builder.Bundle.fallbackLocale = fallbackLocale
} else {
builder.Bundle.fallbackLocale = builder.Bundle.sourceLocale
}
return builder.Bundle, nil
}
// Domains returns a list of loaded domains.
// A domain is only loaded if at least one catalog is found in one language.
func (b *Bundle) Domains() []string {
domains := make([]string, 0, len(b.domains))
for domain, loaded := range b.domains {
if loaded {
domains = append(domains, domain)
}
}
return domains
}
// CanLocalize indicates whether locales and domains have been loaded for translation.
func (b *Bundle) CanLocalize() bool {
return len(b.locales) > 0 && len(b.Domains()) > 0
}
// SupportedLanguages returns all languages for which a catalog was found for at least one domain.
func (b *Bundle) SupportedLanguages() []language.Tag {
languages := make([]language.Tag, 0, len(b.locales))
for lang := range b.locales {
languages = append(languages, lang)
}
return languages
}
// IsLanguageSupported indicates whether a language can be translated.
// The check is done by the bundle's matcher and therefore languages that are not returned by
// SupportedLanguages can be supported.
func (b *Bundle) IsLanguageSupported(lang language.Tag) bool {
_, _, confidence := b.languageMatcher.Match(lang)
return confidence > language.No
}
func (b *bundleBuilder) preloadLanguages(optional bool, languages ...interface{}) error {
for _, accept := range languages {
tag, errT := languageInterfaceToTag(accept)
if errT != nil {
return errT
}
_, err := b.createLocale(optional, tag)
if err == nil {
continue
}
if !optional {
return err
}
var missErr *ErrMissingLanguage
if errors.As(err, &missErr) {
if b.missingCallback != nil {
b.missingCallback(missErr)
}
continue
}
return err
}
return nil
}
func (b *bundleBuilder) createLocale(optional bool, lang language.Tag) (*locale, error) {
if lang == language.Und {
return nil, newMissingLanguageError(lang)
}
if lang == b.sourceLanguage {
sourceLocale := buildSourceLocale(b.Bundle, b.sourceLanguage)
b.locales[lang] = sourceLocale
b.languages = append(b.languages, lang)
return sourceLocale, nil
}
if cachedLocale, isCached := b.locales[lang]; isCached {
return cachedLocale, nil
}
catalogs := make(map[string]catalog.Catalog, len(b.domainLoaders))
for domain, domainLoader := range b.domainLoaders {
catl, errD := domainLoader.Load(lang, domain)
if errD != nil {
var notFoundErr *ErrNotFound
if errors.As(errD, ¬FoundErr) {
if b.missingCallback != nil {
b.missingCallback(notFoundErr)
}
if optional {
continue
}
}
return nil, errD
}
catalogs[domain] = catl
b.domains[domain] = true
}
if len(catalogs) == 0 {
return nil, newMissingLanguageError(lang)
}
langLocale := buildLocale(b.Bundle, lang, catalogs)
b.locales[lang] = langLocale
b.languages = append(b.languages, lang)
return langLocale, nil
}
// WithFallbackLanguage sets the fallback language to be used when creating Localizer if no suitable language is available.
// Should be used only if the fallback language is different from source language.
// Otherwise, it should not be set.
func WithFallbackLanguage(lang interface{}) BundleOption {
return func(opts *bundleBuilder) error {
tag, err := languageInterfaceToTag(lang)
if err != nil {
return err
}
opts.fallbackLanguage = tag
opts.setupActions = append(opts.setupActions, func(builder *bundleBuilder) error {
return builder.preloadLanguages(false, tag)
})
return nil
}
}
// WithSourceLanguage sets the source language used for programming.
// If it is set, it will be considered as a matching language when creating a Localizer.
// Also, it will try to use the appropriate plural form and will not trigger any missing callbacks for the language.
// It is recommended to always set the source language.
func WithSourceLanguage(tag language.Tag) BundleOption {
return func(opts *bundleBuilder) error {
opts.sourceLanguage = tag
opts.setupActions = append(opts.setupActions, func(builder *bundleBuilder) error {
return builder.preloadLanguages(false, tag)
})
return nil
}
}
// WithMissingTranslationCallback stores a MissingTranslationCallback which is called when a translation,
// domain or something else is missing.
// The call is not goroutine safe.
func WithMissingTranslationCallback(cb MissingTranslationCallback) BundleOption {
return func(opts *bundleBuilder) error {
opts.missingCallback = cb
return nil
}
}
// WithDefaultDomain sets the default domain which will be used if no domain is specified.
// By default, NoDomain (the empty string) is used.
func WithDefaultDomain(domain string) BundleOption {
return func(opts *bundleBuilder) error {
opts.defaultDomain = domain
return nil
}
}
// WithDomainLoader loads a domain via a specified loader.
func WithDomainLoader(domain string, l Loader) BundleOption {
return func(opts *bundleBuilder) error {
if _, found := opts.domainLoaders[domain]; found {
return fmt.Errorf("spreak.Bundle: loader for domain %s already set", domain)
}
if l == nil {
return errors.New("spreak.Bundle: loader of WithDomainLoader(..., loader) is nil")
}
opts.domainLoaders[domain] = l
return nil
}
}
// WithFilesystemLoader Loads a domain via a FilesystemLoader.
// The loader can be customized with options.
func WithFilesystemLoader(domain string, fsOpts ...FsOption) BundleOption {
return func(opts *bundleBuilder) error {
l, err := NewFilesystemLoader(fsOpts...)
if err != nil {
return err
}
if _, found := opts.domainLoaders[domain]; found {
return fmt.Errorf("spreak: loader for domain %s already set", domain)
}
opts.domainLoaders[domain] = l
return nil
}
}
// WithDomainPath loads a domain from a specified path.
//
// This is a shorthand for WithFilesystemLoader(domain, WithPath(path)).
func WithDomainPath(domain string, path string) BundleOption {
return WithFilesystemLoader(domain, WithPath(path))
}
// WithDomainFs loads a domain from a fs.FS.
//
// This is a shorthand for WithFilesystemLoader(domain, WithFs(fsys)).
func WithDomainFs(domain string, fsys fs.FS) BundleOption {
if fsys == nil {
return func(opts *bundleBuilder) error {
return errors.New("spreak.Bundle: fsys of WithDomainFs(..., fsys) is nil")
}
}
return WithFilesystemLoader(domain, WithFs(fsys))
}
// WithLanguage loads the catalogs of the domains for one or more languages.
// The passed languages must be of type string or language.Tag,
// all other values will abort the initialization of the bundle with an error.
// If a catalog file for a domain is not found for a language, it will be ignored.
// If a catlaog file for a domain is found but cannot be loaded, the bundle creation will fail and return errors.
//
// If you want to use a Localizer, you should pay attention to the order in which the languages are specified,
// otherwise unexpected behavior may occur.
// This is because the matching algorithm of the language.matcher can give unexpected results.
// See https://github.com/golang/go/issues/49176
func WithLanguage(languages ...interface{}) BundleOption {
loadFunc := func(builder *bundleBuilder) error {
return builder.preloadLanguages(true, languages...)
}
return func(opts *bundleBuilder) error {
opts.setupActions = append(opts.setupActions, loadFunc)
return nil
}
}
// WithRequiredLanguage works like WithLanguage except that the creation of the bundle fails
// if a catalog for a language could not be found.
func WithRequiredLanguage(languages ...interface{}) BundleOption {
loadFunc := func(builder *bundleBuilder) error {
return builder.preloadLanguages(false, languages...)
}
return func(opts *bundleBuilder) error {
opts.setupActions = append(opts.setupActions, loadFunc)
return nil
}
}
// WithPrinter sets a printer which creates a function for a language which converts a formatted string
// and variables into a string. (Like fmt.Sprintf).
func WithPrinter(p Printer) BundleOption {
return func(opts *bundleBuilder) error {
if p == nil {
return errors.New("spreak.Bundle: printer of WithPrinter(...) is nil")
}
opts.printer = p
return nil
}
}
// WithPrintFunction sets a PrintFunc which converts a formatted string and variables to a string. (Like fmt.Sprintf).
func WithPrintFunction(printFunc PrintFunc) BundleOption {
if printFunc != nil {
printer := &printFunctionWrapper{f: printFunc}
return WithPrinter(printer)
}
return func(opts *bundleBuilder) error {
return errors.New("spreak.Bundle: parameter of WithPrintFunction(...) is nil")
}
}
// WithLanguageMatcherBuilder sets a LanguageMatcherBuilder.
func WithLanguageMatcherBuilder(mc LanguageMatcherBuilder) BundleOption {
return func(opts *bundleBuilder) error {
if mc == nil {
return errors.New("spreak.Bundle: MatchCreator of WithMatchCreator(...) is nil")
}
opts.languageMatcherBuilder = mc
return nil
}
}
// WithErrorContext set a context, which is used for the translation of errors.
// If no context is set, ErrorsCtx is used.
func WithErrorContext(ctx string) BundleOption {
return func(opts *bundleBuilder) error {
opts.errContext = ctx
return nil
}
}