# summer-phrasebook Internationalization module for [SummerCMS](https://git.golem15.com/golem15/summercms). Provides translation loading, CLDR-aware pluralization, and parameter interpolation — the Scala 3 equivalent of Laravel's `Illuminate\Translation` + WinterCMS `Storm\Translation`. ## Quick start ```scala import summer.phrasebook.* import java.nio.file.Path val translator = PhrasebookTranslator( initialLocale = LocaleTag("en"), initialFallback = Some(LocaleTag("en")), ) // Register a plugin's translation directory translator.addNamespace("golem15.blog", Path.of("plugins/golem15/blog/lang")) // Simple lookup translator.get("golem15.blog::lang.labels.pluginName") // => "Blog" // With parameter replacement translator.get("golem15.blog::lang.welcome", Map("name" -> "Alice")) // => "Welcome, Alice!" // Pluralization (auto-selects correct CLDR form) translator.choice("golem15.blog::lang.posts_count", 5) // => "5 posts" // Switch locale translator.setLocale(LocaleTag("pl")) translator.choice("golem15.blog::lang.posts_count", 5) // => "5 wpisów" ``` ## Translation file format Translation files use [HOCON](https://github.com/lightbend/config/blob/main/HOCON.md) (`.conf`), organized per-plugin, per-locale, per-group: ``` plugins/golem15/blog/lang/ en/ lang.conf validation.conf pl/ lang.conf ``` Example `lang/en/lang.conf`: ```hocon labels { pluginName = "Blog" posts = "Posts" } post { create_title = "Create Post" edit_title = "Edit Post" } # Pluralization: pipe-separated forms (English: singular|plural) posts_count = ":count post|:count posts" # Explicit range syntax items_range = "{0} No items|{1} One item|[2,*] Many items" # Parameter interpolation welcome = "Welcome, :name!" ``` Nested HOCON keys are flattened to dot-separated paths: `labels.pluginName`, `post.create_title`, etc. ## Key format Keys follow the WinterCMS/Laravel convention: ``` namespace::group.item ``` | Part | Description | Example | |------|-------------|---------| | `namespace` | Plugin identifier (optional) | `golem15.blog` | | `group` | Filename without `.conf` | `lang` | | `item` | Dot-path into the flattened HOCON | `labels.pluginName` | Examples: ``` golem15.blog::lang.labels.pluginName -> plugins/golem15/blog/lang/{locale}/lang.conf -> labels.pluginName golem15.blog::validation.required -> plugins/golem15/blog/lang/{locale}/validation.conf -> required system::validation.required -> modules/system/lang/{locale}/validation.conf -> required ``` Keys without a namespace that start with `validation.` auto-resolve to `system::validation.*` for WinterCMS compatibility. ## Parameter interpolation Use `:name` placeholders with automatic case variants: | Placeholder | Replacement | Result for `"alice"` | |-------------|-------------|---------------------| | `:name` | as-is | `alice` | | `:Name` | ucfirst | `Alice` | | `:NAME` | uppercase | `ALICE` | ```scala translator.get("golem15.blog::lang.greeting", Map("name" -> "alice", "count" -> "3")) // Given: "Hello :Name, you have :count new messages" // => "Hello Alice, you have 3 new messages" ``` ## Pluralization Two mechanisms, evaluated in order: ### 1. Explicit conditions Exact values with `{N}` and ranges with `[from,to]` (`*` = unbounded): ```hocon items = "{0} No items|{1} One item|[2,5] A few items|[6,*] Many items" ``` ### 2. CLDR plural rules When no explicit condition matches, the engine selects a form by CLDR index based on the locale. Forms are pipe-separated, ordered by CLDR category: | Locale group | Forms | Categories | |-------------|-------|------------| | Chinese, Japanese, Korean, Turkish, ... | 1 | other | | English, German, Spanish, Italian, ... | 2 | one \| other | | French, Hindi, ... | 2 | one (0 and 1) \| other | | Polish, Russian, Ukrainian, Czech, ... | 3 | one \| few \| many | | Slovenian, Maltese, Welsh | 4 | varies | | Arabic | 6 | zero \| one \| two \| few \| many \| other | Covers 40+ locale groups. Ported from Laravel's `MessageSelector` (Zend Framework, BSD license). ```hocon # English: singular|plural posts_count = ":count post|:count posts" # Polish: one|few|many posts_count = ":count wpis|:count wpisy|:count wpisów" # Arabic: zero|one|two|few|many|other posts_count = "لا مقالات|مقالة واحدة|مقالتان|:count مقالات|:count مقالة|:count مقالة" ``` ```scala translator.choice("golem15.blog::lang.posts_count", 1, locale = Some(LocaleTag("pl"))) // => "1 wpis" translator.choice("golem15.blog::lang.posts_count", 22, locale = Some(LocaleTag("pl"))) // => "22 wpisy" ``` ## Locale fallback When a key is not found in the requested locale, the engine walks a fallback chain: ``` requested (de-at) -> parent (de) -> configured fallback (en) -> core locale (en) ``` First match wins. If no translation is found anywhere, the raw key string is returned. ## Namespace aliases Shorthand aliases for long plugin namespaces: ```scala translator.addAlias("blog", "golem15.blog") translator.get("blog::lang.labels.pluginName") // => "Blog" ``` ## Runtime overrides Override any translation at runtime (locale-specific): ```scala translator.set("golem15.blog::lang.labels.pluginName", "My Custom Blog") translator.get("golem15.blog::lang.labels.pluginName") // => "My Custom Blog" ``` ## API reference ```scala trait Translator: def get(key: String, replace: Map[String, String] = Map.empty, locale: Option[LocaleTag] = None): String def choice(key: String, number: Int, replace: Map[String, String] = Map.empty, locale: Option[LocaleTag] = None): String def has(key: String, locale: Option[LocaleTag] = None): Boolean def allForLocale(locale: LocaleTag, namespace: Option[String] = None): Map[String, String] def setLocale(locale: LocaleTag): Unit def getLocale: LocaleTag def getFallback: Option[LocaleTag] def setFallback(locale: LocaleTag): Unit def addNamespace(namespace: String, path: java.nio.file.Path): Unit def addAlias(alias: String, namespace: String): Unit def set(key: String, value: String, locale: Option[LocaleTag] = None): Unit def clearCache(): Unit ``` ## Module structure ``` src/main/scala/summer/phrasebook/ LocaleTag.scala Opaque type for locale identifiers TranslationKey.scala ParsedKey case class (namespace/group/item) KeyParser.scala namespace::group.item parser with cache PluralRules.scala CLDR plural index rules (40+ locales) Interpolator.scala :name / :Name / :NAME replacement HoconLoader.scala HOCON -> flat Map[String, String] FallbackChain.scala Locale fallback chain builder Translator.scala Public API trait PhrasebookTranslator.scala Full implementation ``` ## Dependencies | Dependency | Version | Purpose | |-----------|---------|---------| | `com.typesafe:config` | 1.4.3 | HOCON parsing | | `org.scalameta:munit` | 1.0.3 | Testing (test scope only) | No effect systems. No Cats, no ZIO. Pure direct-style Scala 3 on JDK 21. ## Building ```bash sbt compile # compile sbt test # run all 95 tests ``` Requires JDK 21+ and sbt 1.10+. ## Roadmap - **Phase 2** — Integration: Pebble template extension (`|_` filter, `trans()`/`transChoice()` functions), Tapir endpoint for JSON translation export to Vue admin SPA - **Phase 3** — Tooling: `summer phrasebook:scan` (extract translatable strings), `summer phrasebook:export/import` (flat files for translators), dev-mode hot-reload - **Phase 4** — Database-backed translation overrides, compile-time key validation macros ## License Part of SummerCMS. Plural rules derived from the Zend Framework (BSD license) via Laravel.