diff options
| author | James Elliott <james-d-elliott@users.noreply.github.com> | 2022-09-16 11:19:16 +1000 | 
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-09-16 11:19:16 +1000 | 
| commit | 15110b732a4a24c9bd611477627bdd06291e10b2 (patch) | |
| tree | bfee8419db8945ab1e0c6099eb4dd68832c8ce50 | |
| parent | ec88b67cf1ef843d09bbaff9e2f7f7e31e40021b (diff) | |
fix(server): i18n etags missing (#3973)
This fixes missing etags from locales assets.
| -rw-r--r-- | crowdin.yml | 10 | ||||
| -rw-r--r-- | internal/middlewares/asset_override.go | 18 | ||||
| -rw-r--r-- | internal/server/asset.go | 97 | ||||
| -rw-r--r-- | internal/server/const.go | 17 | ||||
| -rw-r--r-- | internal/server/handlers.go | 18 | ||||
| -rw-r--r-- | internal/server/locales/de-DE/portal.json (renamed from internal/server/locales/de/portal.json) | 0 | ||||
| -rw-r--r-- | internal/server/locales/es-ES/portal.json (renamed from internal/server/locales/es/portal.json) | 0 | ||||
| -rw-r--r-- | internal/server/locales/fr-FR/portal.json (renamed from internal/server/locales/fr/portal.json) | 0 | ||||
| -rw-r--r-- | internal/server/locales/nl-NL/portal.json (renamed from internal/server/locales/nl/portal.json) | 0 | ||||
| -rw-r--r-- | internal/server/locales/pt-PT/portal.json (renamed from internal/server/locales/pt/portal.json) | 0 | ||||
| -rw-r--r-- | internal/server/locales/ru-RU/portal.json (renamed from internal/server/locales/ru/portal.json) | 0 | ||||
| -rw-r--r-- | internal/server/locales/sv/portal.json | 69 | ||||
| -rw-r--r-- | internal/server/locales/zh-CN/portal.json (renamed from internal/server/locales/zh/portal.json) | 0 | ||||
| -rw-r--r-- | internal/server/template.go | 17 | ||||
| -rw-r--r-- | web/src/i18n/index.ts | 4 | 
15 files changed, 115 insertions, 135 deletions
diff --git a/crowdin.yml b/crowdin.yml index 51597bf01..88613ec99 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -6,14 +6,4 @@ files:    - source: /internal/server/locales/en/*      translation: /internal/server/locales/%locale%/%original_file_name%      skip_untranslated_files: true -    languages_mapping: -      locale: -        "de-DE": de -        "en-EN": en -        "es-ES": es -        "fr-FR": fr -        "nl-NL": nl -        "pt-PT": pt -        "ru-RU": ru -        "zh-CH": zh  ... diff --git a/internal/middlewares/asset_override.go b/internal/middlewares/asset_override.go index e25444aba..c8ed111c4 100644 --- a/internal/middlewares/asset_override.go +++ b/internal/middlewares/asset_override.go @@ -9,20 +9,22 @@ import (  // AssetOverride allows overriding and serving of specific embedded assets from disk.  func AssetOverride(root string, strip int, next fasthttp.RequestHandler) fasthttp.RequestHandler { -	return func(ctx *fasthttp.RequestCtx) { -		if root == "" { -			next(ctx) +	if root == "" { +		return next +	} -			return -		} +	handler := fasthttp.FSHandler(root, strip) +	stripper := fasthttp.NewPathSlashesStripper(strip) + +	return func(ctx *fasthttp.RequestCtx) { +		asset := filepath.Join(root, string(stripper(ctx))) -		_, err := os.Stat(filepath.Join(root, string(fasthttp.NewPathSlashesStripper(strip)(ctx)))) -		if err != nil { +		if _, err := os.Stat(asset); err != nil {  			next(ctx)  			return  		} -		fasthttp.FSHandler(root, strip)(ctx) +		handler(ctx)  	}  } diff --git a/internal/server/asset.go b/internal/server/asset.go index 5c4559a9b..57905a283 100644 --- a/internal/server/asset.go +++ b/internal/server/asset.go @@ -20,19 +20,21 @@ import (  	"github.com/authelia/authelia/v4/internal/utils"  ) -//go:embed locales -var locales embed.FS +var ( +	//go:embed public_html +	assets embed.FS -//go:embed public_html -var assets embed.FS +	//go:embed locales +	locales embed.FS +)  func newPublicHTMLEmbeddedHandler() fasthttp.RequestHandler {  	etags := map[string][]byte{} -	getEmbedETags(assets, "public_html", etags) +	getEmbedETags(assets, assetsRoot, etags)  	return func(ctx *fasthttp.RequestCtx) { -		p := path.Join("public_html", string(ctx.Path())) +		p := path.Join(assetsRoot, string(ctx.Path()))  		if etag, ok := etags[p]; ok {  			ctx.Response.Header.SetBytesKV(headerETag, etag) @@ -66,8 +68,10 @@ func newPublicHTMLEmbeddedHandler() fasthttp.RequestHandler {  	}  } -func newLocalesEmbeddedHandler() (handler fasthttp.RequestHandler) { -	var languages []string +func newLocalesPathResolver() func(ctx *fasthttp.RequestCtx) (supported bool, asset string) { +	var ( +		languages, dirs []string +	)  	entries, err := locales.ReadDir("locales")  	if err == nil { @@ -84,6 +88,10 @@ func newLocalesEmbeddedHandler() (handler fasthttp.RequestHandler) {  					lng = strings.SplitN(entry.Name(), "-", 2)[0]  				} +				if !utils.IsStringInSlice(entry.Name(), dirs) { +					dirs = append(dirs, entry.Name()) +				} +  				if utils.IsStringInSlice(lng, languages) {  					continue  				} @@ -93,34 +101,79 @@ func newLocalesEmbeddedHandler() (handler fasthttp.RequestHandler) {  		}  	} -	return func(ctx *fasthttp.RequestCtx) { -		var ( -			language, variant, locale, namespace string -		) +	aliases := map[string]string{ +		"sv": "sv-SE", +		"zh": "zh-CN", +	} -		language = ctx.UserValue("language").(string) -		namespace = ctx.UserValue("namespace").(string) -		locale = language +	return func(ctx *fasthttp.RequestCtx) (supported bool, asset string) { +		var language, namespace, variant, locale string + +		language, namespace = ctx.UserValue("language").(string), ctx.UserValue("namespace").(string) + +		if !utils.IsStringInSlice(language, languages) { +			return false, "" +		}  		if v := ctx.UserValue("variant"); v != nil {  			variant = v.(string)  			locale = fmt.Sprintf("%s-%s", language, variant) +		} else { +			locale = language  		} -		var data []byte +		ll := language + "-" + strings.ToUpper(language) +		alias, ok := aliases[locale] + +		switch { +		case ok: +			return true, fmt.Sprintf("locales/%s/%s.json", alias, namespace) +		case utils.IsStringInSlice(locale, dirs): +			return true, fmt.Sprintf("locales/%s/%s.json", locale, namespace) +		case utils.IsStringInSlice(ll, dirs): +			return true, fmt.Sprintf("locales/%s-%s/%s.json", language, strings.ToUpper(language), namespace) +		default: +			return true, fmt.Sprintf("locales/%s/%s.json", locale, namespace) +		} +	} +} -		if data, err = locales.ReadFile(fmt.Sprintf("locales/%s/%s.json", locale, namespace)); err != nil { -			if utils.IsStringInSliceFold(language, languages) { -				data = []byte("{}") -			} +func newLocalesEmbeddedHandler() (handler fasthttp.RequestHandler) { +	etags := map[string][]byte{} -			if len(data) == 0 { -				hfsHandleErr(ctx, err) +	getEmbedETags(locales, "locales", etags) + +	getAssetName := newLocalesPathResolver() + +	return func(ctx *fasthttp.RequestCtx) { +		supported, asset := getAssetName(ctx) + +		if !supported { +			handlers.SetStatusCodeResponse(ctx, fasthttp.StatusNotFound) + +			return +		} + +		if etag, ok := etags[asset]; ok { +			ctx.Response.Header.SetBytesKV(headerETag, etag) +			ctx.Response.Header.SetBytesKV(headerCacheControl, headerValueCacheControlETaggedAssets) + +			if bytes.Equal(etag, ctx.Request.Header.PeekBytes(headerIfNoneMatch)) { +				ctx.SetStatusCode(fasthttp.StatusNotModified)  				return  			}  		} +		var ( +			data []byte +			err  error +		) + +		if data, err = locales.ReadFile(asset); err != nil { +			data = []byte("{}") +		} +  		middlewares.SetContentTypeApplicationJSON(ctx)  		ctx.SetBody(data) diff --git a/internal/server/const.go b/internal/server/const.go index 9c9a34098..4d7a636b2 100644 --- a/internal/server/const.go +++ b/internal/server/const.go @@ -5,16 +5,17 @@ import (  )  const ( -	embeddedAssets = "public_html/" -	swaggerAssets  = embeddedAssets + "api/" -	apiFile        = "openapi.yml" -	indexFile      = "index.html" -	logoFile       = "logo.png" +	assetsRoot    = "public_html" +	assetsSwagger = assetsRoot + "/api" + +	fileOpenAPI   = "openapi.yml" +	fileIndexHTML = "index.html" +	fileLogo      = "logo.png"  )  var ( -	rootFiles    = []string{"manifest.json", "robots.txt"} -	swaggerFiles = []string{ +	filesRoot    = []string{"manifest.json", "robots.txt"} +	filesSwagger = []string{  		"favicon-16x16.png",  		"favicon-32x32.png",  		"index.css", @@ -35,7 +36,7 @@ var (  	}  	// Directories excluded from the not found handler proceeding to the next() handler. -	httpServerDirs = []struct { +	dirsHTTPServer = []struct {  		name, prefix string  	}{  		{name: "/api", prefix: "/api/"}, diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 389743d39..9f77e0bbb 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -79,8 +79,8 @@ func handleNotFound(next fasthttp.RequestHandler) fasthttp.RequestHandler {  	return func(ctx *fasthttp.RequestCtx) {  		path := strings.ToLower(string(ctx.Path())) -		for i := 0; i < len(httpServerDirs); i++ { -			if path == httpServerDirs[i].name || strings.HasPrefix(path, httpServerDirs[i].prefix) { +		for i := 0; i < len(dirsHTTPServer); i++ { +			if path == dirsHTTPServer[i].name || strings.HasPrefix(path, dirsHTTPServer[i].prefix) {  				handlers.SetStatusCodeResponse(ctx, fasthttp.StatusNotFound)  				return @@ -104,9 +104,9 @@ func handleRouter(config schema.Configuration, providers middlewares.Providers)  	https := config.Server.TLS.Key != "" && config.Server.TLS.Certificate != "" -	serveIndexHandler := ServeTemplatedFile(embeddedAssets, indexFile, config.Server.AssetPath, duoSelfEnrollment, rememberMe, resetPassword, resetPasswordCustomURL, config.Session.Name, config.Theme, https) -	serveSwaggerHandler := ServeTemplatedFile(swaggerAssets, indexFile, config.Server.AssetPath, duoSelfEnrollment, rememberMe, resetPassword, resetPasswordCustomURL, config.Session.Name, config.Theme, https) -	serveSwaggerAPIHandler := ServeTemplatedFile(swaggerAssets, apiFile, config.Server.AssetPath, duoSelfEnrollment, rememberMe, resetPassword, resetPasswordCustomURL, config.Session.Name, config.Theme, https) +	serveIndexHandler := ServeTemplatedFile(assetsRoot, fileIndexHTML, config.Server.AssetPath, duoSelfEnrollment, rememberMe, resetPassword, resetPasswordCustomURL, config.Session.Name, config.Theme, https) +	serveSwaggerHandler := ServeTemplatedFile(assetsSwagger, fileIndexHTML, config.Server.AssetPath, duoSelfEnrollment, rememberMe, resetPassword, resetPasswordCustomURL, config.Session.Name, config.Theme, https) +	serveSwaggerAPIHandler := ServeTemplatedFile(assetsSwagger, fileOpenAPI, config.Server.AssetPath, duoSelfEnrollment, rememberMe, resetPassword, resetPasswordCustomURL, config.Session.Name, config.Theme, https)  	handlerPublicHTML := newPublicHTMLEmbeddedHandler()  	handlerLocales := newLocalesEmbeddedHandler() @@ -124,7 +124,7 @@ func handleRouter(config schema.Configuration, providers middlewares.Providers)  	// Static Assets.  	r.GET("/", middleware(serveIndexHandler)) -	for _, f := range rootFiles { +	for _, f := range filesRoot {  		r.GET("/"+f, handlerPublicHTML)  	} @@ -139,10 +139,10 @@ func handleRouter(config schema.Configuration, providers middlewares.Providers)  	// Swagger.  	r.GET("/api/", middleware(serveSwaggerHandler))  	r.OPTIONS("/api/", policyCORSPublicGET.HandleOPTIONS) -	r.GET("/api/"+apiFile, policyCORSPublicGET.Middleware(middleware(serveSwaggerAPIHandler))) -	r.OPTIONS("/api/"+apiFile, policyCORSPublicGET.HandleOPTIONS) +	r.GET("/api/"+fileOpenAPI, policyCORSPublicGET.Middleware(middleware(serveSwaggerAPIHandler))) +	r.OPTIONS("/api/"+fileOpenAPI, policyCORSPublicGET.HandleOPTIONS) -	for _, file := range swaggerFiles { +	for _, file := range filesSwagger {  		r.GET("/api/"+file, handlerPublicHTML)  	} diff --git a/internal/server/locales/de/portal.json b/internal/server/locales/de-DE/portal.json index bc7995099..bc7995099 100644 --- a/internal/server/locales/de/portal.json +++ b/internal/server/locales/de-DE/portal.json diff --git a/internal/server/locales/es/portal.json b/internal/server/locales/es-ES/portal.json index 782ba166e..782ba166e 100644 --- a/internal/server/locales/es/portal.json +++ b/internal/server/locales/es-ES/portal.json diff --git a/internal/server/locales/fr/portal.json b/internal/server/locales/fr-FR/portal.json index 8df78625f..8df78625f 100644 --- a/internal/server/locales/fr/portal.json +++ b/internal/server/locales/fr-FR/portal.json diff --git a/internal/server/locales/nl/portal.json b/internal/server/locales/nl-NL/portal.json index f31e38c7e..f31e38c7e 100644 --- a/internal/server/locales/nl/portal.json +++ b/internal/server/locales/nl-NL/portal.json diff --git a/internal/server/locales/pt/portal.json b/internal/server/locales/pt-PT/portal.json index 7cc993a44..7cc993a44 100644 --- a/internal/server/locales/pt/portal.json +++ b/internal/server/locales/pt-PT/portal.json diff --git a/internal/server/locales/ru/portal.json b/internal/server/locales/ru-RU/portal.json index e7586c4c6..e7586c4c6 100644 --- a/internal/server/locales/ru/portal.json +++ b/internal/server/locales/ru-RU/portal.json diff --git a/internal/server/locales/sv/portal.json b/internal/server/locales/sv/portal.json deleted file mode 100644 index 20f8e1948..000000000 --- a/internal/server/locales/sv/portal.json +++ /dev/null @@ -1,69 +0,0 @@ -{ -	"Accept": "Acceptera", -	"Access your email addresses": "Hantera din e-postadress", -	"Access your group membership": "Hantera dina gruppmedlemskap", -	"Access your profile information": "Hantera din profilinformation", -	"An email has been sent to your address to complete the process": "Ett mejl har skickats till din e-postadress för att slutföra processen.", -	"Authenticated": "Autentiserad", -	"Cancel": "Avbryt", -	"Client ID": "Klient-ID: {{client_id}}", -	"Consent Request": "Begäran om medgivande", -	"Contact your administrator to register a device": "Kontakta din administratör för att registrera en enhet.", -	"Could not obtain user settings": "Misslyckades med att hämta användarinställningarna.", -	"Deny": "Avböj", -	"Done": "Klar", -	"Enter new password": "Skriv ditt nya lösenord", -	"Enter one-time password": "Skriv ditt engångslösenord", -	"Failed to register device, the provided link is expired or has already been used": "Enhetsregistreringen misslyckades, den angivna länken har utgått eller redan blivit använd.", -	"Hi": "Hej", -	"Incorrect username or password": "Fel användarnamn eller lösenord", -	"Loading": "Läser in", -	"Logout": "Logga ut", -	"Lost your device?": "Har du tappat bort din enhet?", -	"Methods": "Metoder", -	"Must be at least {{len}} characters in length": "Måste vara minst {{len}} tecken långt", -	"Must have at least one UPPERCASE letter": "Måste innehålla minst en stor bokstav", -	"Must have at least one lowercase letter": "Måste innehålla minst en liten bokstav", -	"Must have at least one number": "Måste innehålla minst ett nummer", -	"Must have at least one special character": "Måste innehålla minst ett specialtecken", -	"Must not be more than {{len}} characters in length": "Får inte vara längre än {{len}} tecken", -	"Need Google Authenticator?": "Behöver du Google Authenticator?", -	"New password": "Nytt lösenord", -	"No verification token provided": "Ingen verifieringskod tillhandahålls", -	"OTP Secret copied to clipboard": "OTP koden har kopierats till urklipp", -	"OTP URL copied to clipboard": "OTP länken har kopierats till urklipp", -	"One-Time Password": "Engångslösenord", -	"Password has been reset": "Lösenordet har blivit återställt", -	"Password": "Lösenord", -	"Passwords do not match": "Lösenorden matchar inte", -	"Push Notification": "Push-avisering", -	"Register device": "Registrera enhet", -	"Register your first device by clicking on the link below": "Registrera din första enhet genom att klicka på länken nedan", -	"Remember Consent": "Kom ihåg samtycke", -	"Remember me": "Kom ihåg mig", -	"Repeat new password": "Upprepa nya lösenordet", -	"Reset password": "Återställ lösenord", -	"Reset password?": "Återställ lösenord?", -	"Reset": "Återställ", -	"Scan QR Code": "Skanna QR koden", -	"Secret": "Kod", -	"Security Key - WebAuthN": "Säkerhetsnyckel - WebAuthN", -	"Select a Device": "Välj en enhet", -	"Sign in": "Logga in", -	"Sign out": "Logga ut", -	"The above application is requesting the following permissions": "Ovanstående program begär följande behörigheter", -	"The password does not meet the password policy": "Lösenordet uppfyller inte lösenordspolicyn", -	"The resource you're attempting to access requires two-factor authentication": "Resursen du vill komma åt kräver tvåstegsverifiering", -	"There was a problem initiating the registration process": "Ett problem uppstod när registreringsprocessen skulle starta", -	"There was an issue completing the process. The verification token might have expired": "Det uppstod ett problem med att slutföra processen. Verifieringskoden kan ha gått ut", -	"There was an issue initiating the password reset process": "Ett problem uppstod när processen för lösenordsåterställning startade", -	"There was an issue resetting the password": "Ett problem uppstod med att återställa lösenordet", -	"There was an issue signing out": "Ett problem uppstod med att logga ut", -	"This saves this consent as a pre-configured consent for future use": "Spara detta samtycke som ett förkonfigurerat samtycke för framtida användning", -	"Time-based One-Time Password": "Tidsbaserat engångslösenord", -	"Use OpenID to verify your identity": "Använd OpenID till att verifiera din identitet", -	"Username": "Användarnamn", -	"You must open the link from the same device and browser that initiated the registration process": "Du måste öppna länken från samma enhet och webbläsare som startade registreringsprocessen", -	"You're being signed out and redirected": "Du blir utloggad och omdirigerad", -	"Your supplied password does not meet the password policy requirements": "Det angivna lösenordet möter inte lösenordskraven" -} diff --git a/internal/server/locales/zh/portal.json b/internal/server/locales/zh-CN/portal.json index 1f570dddf..1f570dddf 100644 --- a/internal/server/locales/zh/portal.json +++ b/internal/server/locales/zh-CN/portal.json diff --git a/internal/server/template.go b/internal/server/template.go index 9c5bb523d..fedf2df57 100644 --- a/internal/server/template.go +++ b/internal/server/template.go @@ -4,10 +4,13 @@ import (  	"fmt"  	"io"  	"os" +	"path"  	"path/filepath"  	"strings"  	"text/template" +	"github.com/valyala/fasthttp" +  	"github.com/authelia/authelia/v4/internal/logging"  	"github.com/authelia/authelia/v4/internal/middlewares"  	"github.com/authelia/authelia/v4/internal/utils" @@ -19,7 +22,7 @@ import (  func ServeTemplatedFile(publicDir, file, assetPath, duoSelfEnrollment, rememberMe, resetPassword, resetPasswordCustomURL, session, theme string, https bool) middlewares.RequestHandler {  	logger := logging.Logger() -	a, err := assets.Open(publicDir + file) +	a, err := assets.Open(path.Join(publicDir, file))  	if err != nil {  		logger.Fatalf("Unable to open %s: %s", file, err)  	} @@ -43,7 +46,7 @@ func ServeTemplatedFile(publicDir, file, assetPath, duoSelfEnrollment, rememberM  		logoOverride := f  		if assetPath != "" { -			if _, err := os.Stat(filepath.Join(assetPath, logoFile)); err == nil { +			if _, err := os.Stat(filepath.Join(assetPath, fileLogo)); err == nil {  				logoOverride = t  			}  		} @@ -71,14 +74,14 @@ func ServeTemplatedFile(publicDir, file, assetPath, duoSelfEnrollment, rememberM  		}  		switch { -		case publicDir == swaggerAssets: -			ctx.Response.Header.Add("Content-Security-Policy", fmt.Sprintf("base-uri 'self'; default-src 'self'; img-src 'self' https://validator.swagger.io data:; object-src 'none'; script-src 'self' 'unsafe-inline' 'nonce-%s'; style-src 'self' 'nonce-%s'", nonce, nonce)) +		case publicDir == assetsSwagger: +			ctx.Response.Header.Add(fasthttp.HeaderContentSecurityPolicy, fmt.Sprintf("base-uri 'self'; default-src 'self'; img-src 'self' https://validator.swagger.io data:; object-src 'none'; script-src 'self' 'unsafe-inline' 'nonce-%s'; style-src 'self' 'nonce-%s'", nonce, nonce))  		case ctx.Configuration.Server.Headers.CSPTemplate != "": -			ctx.Response.Header.Add("Content-Security-Policy", strings.ReplaceAll(ctx.Configuration.Server.Headers.CSPTemplate, cspNoncePlaceholder, nonce)) +			ctx.Response.Header.Add(fasthttp.HeaderContentSecurityPolicy, strings.ReplaceAll(ctx.Configuration.Server.Headers.CSPTemplate, cspNoncePlaceholder, nonce))  		case os.Getenv("ENVIRONMENT") == dev: -			ctx.Response.Header.Add("Content-Security-Policy", fmt.Sprintf(cspDefaultTemplate, " 'unsafe-eval'", nonce)) +			ctx.Response.Header.Add(fasthttp.HeaderContentSecurityPolicy, fmt.Sprintf(cspDefaultTemplate, " 'unsafe-eval'", nonce))  		default: -			ctx.Response.Header.Add("Content-Security-Policy", fmt.Sprintf(cspDefaultTemplate, "", nonce)) +			ctx.Response.Header.Add(fasthttp.HeaderContentSecurityPolicy, fmt.Sprintf(cspDefaultTemplate, "", nonce))  		}  		err := tmpl.Execute(ctx.Response.BodyWriter(), struct{ Base, BaseURL, CSPNonce, DuoSelfEnrollment, LogoOverride, RememberMe, ResetPassword, ResetPasswordCustomURL, Session, Theme string }{Base: base, BaseURL: baseURL, CSPNonce: nonce, DuoSelfEnrollment: duoSelfEnrollment, LogoOverride: logoOverride, RememberMe: rememberMe, ResetPassword: resetPassword, ResetPasswordCustomURL: resetPasswordCustomURL, Session: session, Theme: theme}) diff --git a/web/src/i18n/index.ts b/web/src/i18n/index.ts index 53231ccd1..6cfa4419d 100644 --- a/web/src/i18n/index.ts +++ b/web/src/i18n/index.ts @@ -18,9 +18,9 @@ i18n.use(Backend)          backend: {              loadPath: basePath + "/locales/{{lng}}/{{ns}}.json",          }, +        load: "all",          ns: ["portal"],          defaultNS: "portal", -        load: "all",          fallbackLng: {              default: ["en"],              de: ["en"], @@ -33,7 +33,7 @@ i18n.use(Backend)              "sv-SE": ["sv", "en"],              zh: ["en"],              "zh-CN": ["zh", "en"], -            "zh-TW": ["zh", "en"], +            "zh-TW": ["en"],          },          supportedLngs: ["en", "de", "es", "fr", "nl", "pt", "ru", "sv", "sv-SE", "zh", "zh-CN", "zh-TW"],          lowerCaseLng: false,  | 
