Custom i18n module with HOCON translation files, CLDR-aware pluralization for 40+ locales, namespace::group.item key resolution, :name/:Name/:NAME parameter interpolation, and locale fallback chains. No effect system deps.
7.6 KiB
summer-phrasebook
Internationalization module for SummerCMS. Provides translation loading, CLDR-aware pluralization, and parameter interpolation — the Scala 3 equivalent of Laravel's Illuminate\Translation + WinterCMS Storm\Translation.
Quick start
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 (.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:
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 |
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):
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).
# 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 مقالة"
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:
translator.addAlias("blog", "golem15.blog")
translator.get("blog::lang.labels.pluginName")
// => "Blog"
Runtime overrides
Override any translation at runtime (locale-specific):
translator.set("golem15.blog::lang.labels.pluginName", "My Custom Blog")
translator.get("golem15.blog::lang.labels.pluginName")
// => "My Custom Blog"
API reference
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
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.