Implement Phase 1 core translation engine (95 tests passing)
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.
This commit is contained in:
261
README.md
261
README.md
@@ -1,3 +1,260 @@
|
||||
# summercms-phrasebook
|
||||
# summer-phrasebook
|
||||
|
||||
## SummerCMS i18n support
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user