Commit 7b70a321 authored by Mickaël Desfrênes's avatar Mickaël Desfrênes
Browse files

add i18n + en

parent 6f1824ce
Loading
Loading
Loading
Loading
+1 −1
Original line number Diff line number Diff line
@@ -20,7 +20,7 @@ build: ## Build cross-platform binaries into dist (linux/amd64, darwin/arm64, wi

test: ## Run Go tests (including main_test.go explicitly)
	go test -mod=vendor ./...
	go test -mod=vendor main.go main_test.go
	go test -mod=vendor main.go i18n.go main_test.go

installer-macos: ## Build an unsigned macOS pkg installer (.pkg) for arm64 using pkgbuild
	mkdir -p $(DIST_DIR)
+7 −0
Original line number Diff line number Diff line
@@ -75,6 +75,13 @@ func TestCLICommandsExposeExpectedFlags(t *testing.T) {
	}
}

func TestRootExposesLangFlag(t *testing.T) {
	root := newRootCmd()
	if root.PersistentFlags().Lookup("lang") == nil {
		t.Fatalf("root command is missing expected persistent flag --lang")
	}
}

func TestCLICommandsWithRequiredArgsValidateArity(t *testing.T) {
	root := newRootCmd()
	for _, cmdName := range []string{"bundles-add", "bundles-remove", "feed"} {

go_port/i18n.go

0 → 100644
+289 −0
Original line number Diff line number Diff line
package main

import (
	"fmt"
	"os"
	"strings"
)

const (
	localeFR = "fr"
	localeEN = "en"
)

var currentLocale = localeFR

var translations = map[string]map[string]string{
	localeFR: {
		"app.short": "CLI tool for MaX",
		"help.main.long": `Utilitaire en ligne de commande pour la gestion des projets MaX.

La plupart des commandes sont à lancer dans le dossier du projet MaX, à l'exception des
commandes new, projects et cache-clear qui peuvent être lancées aussi en dehors d'un dossier MaX.`,
		"help.usage_prefix":     "Utilisation",
		"help.command_token":    "COMMANDE",
		"help.args_token":       "ARGS",
		"help.section.options":  "Options",
		"help.section.commands": "Commandes",
		"flag.help":             "Voir cette aide et quitter",
		"flag.lang":             "Langue de sortie (fr ou en)",

		"cmd.new.short":            "Création d'une nouvelle instance de MaX",
		"cmd.demo.short":           "Installe une édition de démonstration.",
		"cmd.start.short":          "Démarre l'instance de MaX.",
		"cmd.stop.short":           "Arrête l'instance de MaX",
		"cmd.info.short":           "Affiche la configuration de MaX",
		"cmd.freeze.short":         "Fait une copie HTML statique",
		"cmd.bundles_list.short":   "Liste les bundles disponibles",
		"cmd.bundles_add.short":    "Ajoute un bundle",
		"cmd.bundles_remove.short": "Supprime un bundle",
		"cmd.feed.short":           "Ajoute un fichier ou un dossier XML",
		"cmd.sync.short":           "Initialisation d'une instance existante de MaX.",
		"cmd.basex.short":          "Lance le shell de BaseX",
		"cmd.templates_list.short": "Liste les templates du bundle de vocabulaire",
		"cmd.static_list.short":    "Liste les fichiers statiques (js, css, etc) du bundle de vocabulaire",
		"cmd.projects.short":       "Liste les projets gérés par climax",
		"cmd.cache_clear.short":    "Efface le cache de climax",
		"cmd.version.short":        "Affiche la version de climax",

		"flag.common.directory":     "Dossier du projet MaX",
		"flag.new.interactive":      "Menu interactif",
		"flag.start.http_host":      "Hôte HTTP",
		"flag.start.http_port":      "Port HTTP",
		"flag.start.basex_port":     "Port BaseX",
		"flag.start.http_stop_port": "Port de stop",
		"flag.start.service":        "Démarrer MaX en tant que service",
		"flag.info.json_out":        "Sortie JSON",
		"flag.freeze.output":        "Dossier de sortie des fichiers HTML",
		"flag.freeze.debug":         "Debug",
		"flag.projects.json_out":    "Sortie JSON",

		"table.info.name":           "Nom",
		"table.info.value":          "Valeur",
		"table.info.max_version":    "Version de MaX",
		"table.info.title":          "Titre",
		"table.info.environment":    "Environnement",
		"table.info.languages":      "Langues",
		"table.info.bundles":        "Bundles installés",
		"table.info.vocabulary":     "Bundle de vocabulaire XML",
		"table.projects.project":    "Projet",
		"table.projects.path":       "Chemin",
		"table.projects.url":        "URL",
		"table.bundles.name":        "Nom",
		"table.bundles.installed":   "Installé",
		"table.bundles.description": "Description",
		"msg.bundle.local_no_desc":  "(bundle local sans description)",
		"msg.yes":                   "oui",
		"msg.no":                    "non",
		"msg.unknown_title":         "inconnu",
		"msg.untitled":              "sans titre",
		"msg.vocabulary_ignored":    "Ce bundle de vocabulaire sera ignoré: %s",
		"msg.init_new":              "Initialisation d'une nouvelle instance de MaX dans %s",
		"msg.new_ready":             "La nouvelle instance de MaX est prête dans %s",
		"msg.init_existing":         "Initialisation de l'instance de MaX existant dans %s",
		"msg.instance_ready":        "L'instance de MaX est prête dans %s",
		"msg.demo_installed":        "édition de démonstration installée",
		"msg.demo_hint":             "Vous pouvez démarrer MaX avec la commande climax start",
		"msg.starting_max":          "Démarrage de MaX sur http://%s:%d (stop port: %d)",
		"msg.site_copied":           "Site copié dans %s",
		"msg.choose_invalid":        "Choix invalide, veuillez réessayer.",
		"msg.value_not_empty":       "La valeur ne peut pas être vide.",
		"msg.directory_already_max": "Le dossier contient une instance de MaX. Utilisez la commande sync",

		"label.vocabulary_use":  "vocabulaire à utiliser",
		"label.max_version":     "Version de max",
		"label.environment":     "Environnement",
		"label.project_title":   "Titre du projet",
		"label.choice":          "Choix",
		"question.install_demo": "Voulez-vous installer une édition de démonstration ?",

		"err.no_release_available":    "aucune release disponible",
		"err.not_max_directory":       "Le dossier n'est pas une installation de MaX.",
		"err.directory_not_available": "Le dossier n'est pas vide et n'est pas un projet MaX valide, veuillez en choisir un autre.",
		"err.download_failed":         "impossible de télécharger %s",
		"err.java_required":           "Java est requis pour utiliser MaX: https://openjdk.org/install/",
		"err.bundle_install_failed":   "échec de l'installation du bundle %s: %w",
		"err.no_latest_release":       "pas de dernière release disponible",
		"err.unknown_version":         "version inconnue",
		"err.server_start_timeout":    "délai dépassé au démarrage du serveur HTTP sur %s:%d",
		"err.invalid_path":            "%s n'est pas un chemin valide",
		"err.invalid_bundle_archive":  "%s n'est pas un bundle",
		"err.bundle_not_available":    "%s n'est pas un bundle disponible",
		"err.bundle_not_active":       "%s n'est pas un bundle actif",
		"err.feed_not_exists":         "%s n'existe pas",
		"err.no_max_use_new":          "Le dossier ne contient pas une instance de MaX. Utiliser la commande new",
		"err.no_values":               "aucune valeur",
		"err.unsupported_archive":     "format d'archive non pris en charge: %s",
		"err.invalid_zip_entry":       "entrée zip invalide %s",
		"err.invalid_tar_entry":       "entrée tar invalide %s",
		"err.invalid_mirrored_path":   "chemin de copie statique invalide %s",
		"err.not_max_config":          "%s n'est pas un fichier de configuration MaX",
	},
	localeEN: {
		"app.short": "CLI tool for MaX",
		"help.main.long": `Command-line utility for managing MaX projects.

Most commands should be run in the MaX project directory, except new, projects and cache-clear,
which can also be run outside a MaX directory.`,
		"help.usage_prefix":     "Usage",
		"help.command_token":    "COMMAND",
		"help.args_token":       "ARGS",
		"help.section.options":  "Options",
		"help.section.commands": "Commands",
		"flag.help":             "Show this help and exit",
		"flag.lang":             "Output language (fr or en)",

		"cmd.new.short":            "Create a new MaX instance",
		"cmd.demo.short":           "Install a demo edition.",
		"cmd.start.short":          "Start the MaX instance.",
		"cmd.stop.short":           "Stop the MaX instance.",
		"cmd.info.short":           "Display MaX configuration",
		"cmd.freeze.short":         "Create a static HTML copy",
		"cmd.bundles_list.short":   "List available bundles",
		"cmd.bundles_add.short":    "Add a bundle",
		"cmd.bundles_remove.short": "Remove a bundle",
		"cmd.feed.short":           "Add an XML file or directory",
		"cmd.sync.short":           "Initialize an existing MaX instance.",
		"cmd.basex.short":          "Launch BaseX shell",
		"cmd.templates_list.short": "List templates from the vocabulary bundle",
		"cmd.static_list.short":    "List static files (js, css, etc.) from the vocabulary bundle",
		"cmd.projects.short":       "List projects managed by climax",
		"cmd.cache_clear.short":    "Clear climax cache",
		"cmd.version.short":        "Display climax version",

		"flag.common.directory":     "MaX project directory",
		"flag.new.interactive":      "Interactive mode",
		"flag.start.http_host":      "HTTP host",
		"flag.start.http_port":      "HTTP port",
		"flag.start.basex_port":     "BaseX port",
		"flag.start.http_stop_port": "Stop port",
		"flag.start.service":        "Start MaX as a service",
		"flag.info.json_out":        "JSON output",
		"flag.freeze.output":        "Output directory for HTML files",
		"flag.freeze.debug":         "Debug",
		"flag.projects.json_out":    "JSON output",

		"table.info.name":           "Name",
		"table.info.value":          "Value",
		"table.info.max_version":    "MaX Version",
		"table.info.title":          "Title",
		"table.info.environment":    "Environment",
		"table.info.languages":      "Languages",
		"table.info.bundles":        "Installed bundles",
		"table.info.vocabulary":     "XML vocabulary bundle",
		"table.projects.project":    "Project",
		"table.projects.path":       "Path",
		"table.projects.url":        "URL",
		"table.bundles.name":        "Name",
		"table.bundles.installed":   "Installed",
		"table.bundles.description": "Description",
		"msg.bundle.local_no_desc":  "(local bundle without description)",
		"msg.yes":                   "yes",
		"msg.no":                    "no",
		"msg.unknown_title":         "unknown",
		"msg.untitled":              "untitled",
		"msg.vocabulary_ignored":    "This vocabulary bundle will be ignored: %s",
		"msg.init_new":              "Initializing a new MaX instance in %s",
		"msg.new_ready":             "The new MaX instance is ready in %s",
		"msg.init_existing":         "Initializing existing MaX instance in %s",
		"msg.instance_ready":        "The MaX instance is ready in %s",
		"msg.demo_installed":        "demo edition installed",
		"msg.demo_hint":             "You can start MaX with the command climax start",
		"msg.starting_max":          "Starting MaX on http://%s:%d (stop port: %d)",
		"msg.site_copied":           "Site copied to %s",
		"msg.choose_invalid":        "Invalid choice, please try again.",
		"msg.value_not_empty":       "Value cannot be empty.",
		"msg.directory_already_max": "Directory already contains a MaX instance. Use the sync command.",

		"label.vocabulary_use":  "vocabulary to use",
		"label.max_version":     "MaX version",
		"label.environment":     "Environment",
		"label.project_title":   "Project title",
		"label.choice":          "Choice",
		"question.install_demo": "Do you want to install a demo edition?",

		"err.no_release_available":    "no release available",
		"err.not_max_directory":       "Directory is not a MaX installation.",
		"err.directory_not_available": "Directory is not empty and is not a valid MaX project; please choose another one.",
		"err.download_failed":         "unable to download %s",
		"err.java_required":           "Java is required to use MaX: https://openjdk.org/install/",
		"err.bundle_install_failed":   "bundle installation failed for %s: %w",
		"err.no_latest_release":       "no latest release available",
		"err.unknown_version":         "unknown version",
		"err.server_start_timeout":    "timeout while starting HTTP server on %s:%d",
		"err.invalid_path":            "%s is not a valid path",
		"err.invalid_bundle_archive":  "%s is not a bundle",
		"err.bundle_not_available":    "%s is not an available bundle",
		"err.bundle_not_active":       "%s is not an active bundle",
		"err.feed_not_exists":         "%s does not exist",
		"err.no_max_use_new":          "Directory does not contain a MaX instance. Use the new command.",
		"err.no_values":               "no value available",
		"err.unsupported_archive":     "unsupported archive format: %s",
		"err.invalid_zip_entry":       "invalid zip entry %s",
		"err.invalid_tar_entry":       "invalid tar entry %s",
		"err.invalid_mirrored_path":   "invalid mirrored path %s",
		"err.not_max_config":          "%s is not a MaX configuration file",
	},
}

func initLocale(args []string) {
	setLocale(resolveLocale(args))
}

func setLocale(lang string) {
	currentLocale = normalizeLocale(lang)
}

func resolveLocale(args []string) string {
	if fromFlag := langFromArgs(args); fromFlag != "" {
		return fromFlag
	}
	if fromEnv := strings.TrimSpace(os.Getenv("CLIMAX_LANG")); fromEnv != "" {
		return fromEnv
	}
	if fromSystem := strings.TrimSpace(os.Getenv("LANG")); fromSystem != "" {
		return fromSystem
	}
	return localeFR
}

func langFromArgs(args []string) string {
	for i := 0; i < len(args); i++ {
		arg := strings.TrimSpace(args[i])
		if strings.HasPrefix(arg, "--lang=") {
			return strings.TrimSpace(strings.TrimPrefix(arg, "--lang="))
		}
		if arg == "--lang" && i+1 < len(args) {
			return strings.TrimSpace(args[i+1])
		}
	}
	return ""
}

func normalizeLocale(lang string) string {
	normalized := strings.ToLower(strings.TrimSpace(lang))
	switch {
	case strings.HasPrefix(normalized, "fr"):
		return localeFR
	case strings.HasPrefix(normalized, "en"):
		return localeEN
	default:
		return localeFR
	}
}

func T(key string, args ...any) string {
	catalog := translations[currentLocale]
	msg, ok := catalog[key]
	if !ok {
		if fallback, exists := translations[localeFR][key]; exists {
			msg = fallback
		} else {
			msg = key
		}
	}
	if len(args) == 0 {
		return msg
	}
	return fmt.Sprintf(msg, args...)
}

go_port/i18n_test.go

0 → 100644
+59 −0
Original line number Diff line number Diff line
package main

import (
	"strings"
	"testing"
)

func TestResolveLocalePriority(t *testing.T) {
	t.Run("arg over env", func(t *testing.T) {
		t.Setenv("CLIMAX_LANG", "fr")
		t.Setenv("LANG", "fr_FR.UTF-8")
		if got := resolveLocale([]string{"--lang=en"}); got != "en" {
			t.Fatalf("expected en from --lang, got %q", got)
		}
	})

	t.Run("climax env over system", func(t *testing.T) {
		t.Setenv("CLIMAX_LANG", "en")
		t.Setenv("LANG", "fr_FR.UTF-8")
		if got := resolveLocale(nil); got != "en" {
			t.Fatalf("expected en from CLIMAX_LANG, got %q", got)
		}
	})

	t.Run("system lang fallback", func(t *testing.T) {
		t.Setenv("CLIMAX_LANG", "")
		t.Setenv("LANG", "en_US.UTF-8")
		if got := normalizeLocale(resolveLocale(nil)); got != localeEN {
			t.Fatalf("expected normalized en from LANG, got %q", got)
		}
	})

	t.Run("default french", func(t *testing.T) {
		t.Setenv("CLIMAX_LANG", "")
		t.Setenv("LANG", "")
		if got := normalizeLocale(resolveLocale(nil)); got != localeFR {
			t.Fatalf("expected default fr, got %q", got)
		}
	})
}

func TestRootLongTextFollowsCurrentLocale(t *testing.T) {
	previous := currentLocale
	t.Cleanup(func() {
		setLocale(previous)
	})

	setLocale("en")
	root := newRootCmd()
	if !strings.Contains(root.Long, "Command-line utility") {
		t.Fatalf("expected english long help, got: %s", root.Long)
	}

	setLocale("fr")
	root = newRootCmd()
	if !strings.Contains(root.Long, "Utilitaire en ligne de commande") {
		t.Fatalf("expected french long help, got: %s", root.Long)
	}
}
+144 −108

File changed.

Preview size limit exceeded, changes collapsed.