feat: multi-language with FuncMap + lng/ files

This commit is contained in:
Claus Lohmar 2026-07-10 08:15:14 +01:00
parent 45f2509fe4
commit bde90a8186
2 changed files with 73 additions and 59 deletions

View file

@ -185,6 +185,11 @@ else
echo "[4/5] Swapping binary..."
cp "$BINARY_NAME" "$TARGET_DIR/$BINARY_NAME"
# Copy language files
if [ -d "$SCRIPT_DIR/lng" ]; then
cp -r "$SCRIPT_DIR/lng" "$TARGET_DIR/"
fi
# Ensure AUTHELIA_SECRET is in .env
AUTHELIA_SECRET=$(grep -oP 'session_secret: \K.*' "$TARGET_DIR/config/authelia/configuration.yml" 2>/dev/null || echo "")
if [ -n "$AUTHELIA_SECRET" ]; then

127
main.go
View file

@ -190,13 +190,12 @@ func healthHandler(w http.ResponseWriter, r *http.Request) {
}
func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc {
tmpl := template.Must(template.New("launcher").Parse(launcherHTML))
return func(w http.ResponseWriter, r *http.Request) {
user := r.Header.Get("Remote-User")
groupsHeader := r.Header.Get("Remote-Groups")
userGroups := strings.Split(groupsHeader, ",")
settings, _ := loadSettings(os.Getenv("CONFIG_DIR"))
lang := getUserLang(r)
var allowedApps []AppEntry
for _, app := range apps {
@ -213,6 +212,7 @@ func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc {
Apps []AppEntry
CompanyLogo string
Subtitle string
Lang string
}{
AppName: cfg.App.Name,
Description: cfg.App.Description,
@ -221,9 +221,10 @@ func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc {
Apps: allowedApps,
CompanyLogo: settings.Company.Logo,
Subtitle: settings.Company.Subtitle,
Lang: lang,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl.Execute(w, data)
launcherTmpl.Execute(w, data)
}
}
@ -280,12 +281,6 @@ func adminHandler(w http.ResponseWriter, r *http.Request) {
return
}
funcMap := template.FuncMap{
"t": func(key string) string {
return T(settings.Company.Language, key)
},
}
tmpl := template.Must(template.New("admin").Funcs(funcMap).Parse(adminHTML))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
autheliaUp := false
@ -294,11 +289,20 @@ func adminHandler(w http.ResponseWriter, r *http.Request) {
resp.Body.Close()
}
tmpl.Execute(w, map[string]interface{}{
userLang := os.Getenv("LANG")
if userLang == "" {
userLang = "en"
}
if settings != nil && settings.Company.Language != "" {
userLang = settings.Company.Language
}
adminTmpl.Execute(w, map[string]interface{}{
"Settings": settings,
"Domain": domain,
"AdminEmail": adminEmail,
"AutheliaUp": autheliaUp,
"Lang": userLang,
})
case http.MethodPost:
@ -465,59 +469,53 @@ func publicSettingsHandler(w http.ResponseWriter, r *http.Request) {
// --- Translation system ---
type Translations map[string]string
var translations = make(map[string]map[string]string)
var launcherTmpl *template.Template
var adminTmpl *template.Template
var translationCache map[string]Translations
func loadTranslations(lang string) Translations {
if translationCache != nil {
if cached, ok := translationCache[lang]; ok {
return cached
}
}
basePath := filepath.Join("/opt/nextworkspace", "lng", lang)
result := Translations{}
func loadTranslations(lngDir string) {
langs := []string{"en", "de"}
files := []string{"launcher.yaml", "admin.yaml"}
for _, f := range files {
data, err := os.ReadFile(filepath.Join(basePath, f))
if err != nil {
continue
for _, lang := range langs {
if translations[lang] == nil {
translations[lang] = make(map[string]string)
}
var m map[string]string
yaml.Unmarshal(data, &m)
for k, v := range m {
result[k] = v
for _, file := range files {
path := filepath.Join(lngDir, lang, file)
data, err := os.ReadFile(path)
if err != nil {
continue
}
var keys map[string]string
if err := yaml.Unmarshal(data, &keys); err != nil {
continue
}
for k, v := range keys {
translations[lang][k] = v
}
}
}
if translationCache == nil {
translationCache = make(map[string]Translations)
}
translationCache[lang] = result
return result
}
func T(lang, key string, args ...string) string {
t := loadTranslations(lang)
text, ok := t[key]
if !ok {
en := loadTranslations("en")
text = en[key]
if text == "" {
return key
}
func t(lang, key string, args ...string) string {
text := translations[lang][key]
if text == "" {
text = translations["en"][key]
}
if text == "" {
return key
}
for i := 0; i < len(args); i += 2 {
if i+1 < len(args) {
text = strings.ReplaceAll(text, "{"+args[i]+"}", args[i+1])
}
text = strings.ReplaceAll(text, "{"+args[i]+"}", args[i+1])
}
return text
}
func getUserLang(r *http.Request) string {
headerLang := r.Header.Get("Remote-Lang")
if headerLang == "de" || headerLang == "en" {
return headerLang
lang := r.Header.Get("Accept-Language")
if strings.HasPrefix(lang, "de") {
return "de"
}
settings, _ := loadSettings(os.Getenv("CONFIG_DIR"))
if settings != nil && settings.Company.Language == "de" {
@ -620,9 +618,9 @@ const launcherHTML = `<!DOCTYPE html>
{{if .Subtitle}}<p>{{.Subtitle}}</p>{{end}}
</header>
<div class="user-banner">
<span>Welcome, {{.User}}</span>
<a href="/settings">Settings</a>
<a href="https://auth.nextwks.eu/logout">Logout</a>
<span>{{t .Lang "welcome" "user" .User}}</span>
<a href="/settings">{{t .Lang "settings"}}</a>
<a href="https://auth.nextwks.eu/logout">{{t .Lang "logout"}}</a>
</div>
<div class="grid">
{{range .Apps}}
@ -897,15 +895,15 @@ const adminHTML = `<!DOCTYPE html>
<div class="layout">
<nav class="sidebar">
<div class="section-label">Core Settings</div>
<a href="#" class="active" data-section="global" onclick="return switchSection('global')"><span class="icon">&#9881;</span> Global</a>
<a href="#" data-section="access" onclick="return switchSection('access')"><span class="icon">&#128101;</span> Access</a>
<a href="#" data-section="security" onclick="return switchSection('security')"><span class="icon">&#128274;</span> Security</a>
<a href="#" data-section="domain" onclick="return switchSection('domain')"><span class="icon">&#127760;</span> Domain</a>
<a href="#" class="active" data-section="global" onclick="return switchSection('global')"><span class="icon">&#9881;</span> {{t .Lang "nav_global"}}</a>
<a href="#" data-section="access" onclick="return switchSection('access')"><span class="icon">&#128101;</span> {{t .Lang "nav_access"}}</a>
<a href="#" data-section="security" onclick="return switchSection('security')"><span class="icon">&#128274;</span> {{t .Lang "nav_security"}}</a>
<a href="#" data-section="domain" onclick="return switchSection('domain')"><span class="icon">&#127760;</span> {{t .Lang "nav_domain"}}</a>
<div class="section-label">App Settings</div>
<a href="#" class="disabled"><span class="icon">&#128230;</span> Mail</a>
<a href="#" class="disabled"><span class="icon">&#128196;</span> Docs</a>
<a href="#" class="disabled"><span class="icon">&#128197;</span> Calendar</a>
<a href="#" class="disabled"><span class="icon">&#128230;</span> {{t .Lang "nav_mail"}}</a>
<a href="#" class="disabled"><span class="icon">&#128196;</span> {{t .Lang "nav_docs"}}</a>
<a href="#" class="disabled"><span class="icon">&#128197;</span> {{t .Lang "nav_calendar"}}</a>
<div class="spacer"></div>
<div class="footer">NextWorkspace v0.1.0.0011</div>
@ -1128,6 +1126,17 @@ func main() {
companyName = settings.Company.Name
}
// Load translations from lng/ directory
lngDir := filepath.Join(configDir, "../../lng")
loadTranslations(lngDir)
// Create template FuncMap for translations
funcMap := template.FuncMap{"t": t}
// Parse templates with FuncMap
launcherTmpl = template.Must(template.New("launcher").Funcs(funcMap).Parse(launcherHTML))
adminTmpl = template.Must(template.New("admin").Funcs(funcMap).Parse(adminHTML))
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
mux := http.NewServeMux()