Skip to content

Commit c43e915

Browse files
brunoborgesCopilot
andcommitted
i18n: partial translation files, difficultyDisplay, YAML support
- Translation files now contain only translatable fields (title, summary, explanation, oldApproach, modernApproach, whyModernWins, support.description). The generator overlays them onto the English base, preventing divergence. - Add difficultyDisplay token: CSS class stays as enum value, display text resolved from UI strings (difficulty.beginner/intermediate/advanced). - Generator loadStrings and resolveSnippet now support .json/.yaml/.yml translation files via findWithExtensions/readAuto helpers. - Update i18n spec with field translation reference table and partial-file approach. - Trim existing pt-BR translation files to translatable fields only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6ef282d commit c43e915

File tree

9 files changed

+142
-146
lines changed

9 files changed

+142
-146
lines changed

html-generators/generate.java

Lines changed: 63 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -76,20 +76,37 @@ static Map<String, String> flattenJson(JsonNode node, String prefix) {
7676
return map;
7777
}
7878

79+
/** Find a file by base path, trying .json, .yaml, .yml extensions */
80+
static Optional<Path> findWithExtensions(Path dir, String baseName) {
81+
for (var ext : List.of("json", "yaml", "yml")) {
82+
var p = dir.resolve(baseName + "." + ext);
83+
if (Files.exists(p)) return Optional.of(p);
84+
}
85+
return Optional.empty();
86+
}
87+
88+
/** Read a file using the appropriate mapper based on its extension */
89+
static JsonNode readAuto(Path path) throws IOException {
90+
var name = path.getFileName().toString();
91+
var ext = name.substring(name.lastIndexOf('.') + 1);
92+
return MAPPERS.getOrDefault(ext, JSON_MAPPER).readTree(path.toFile());
93+
}
94+
7995
/** Load UI strings for a locale, falling back to en.json for missing keys */
8096
static Map<String, String> loadStrings(String locale) throws IOException {
81-
var enPath = Path.of(TRANSLATIONS_DIR, "strings", "en.json");
82-
var enStrings = flattenJson(JSON_MAPPER.readTree(enPath.toFile()), "");
97+
var enFile = findWithExtensions(Path.of(TRANSLATIONS_DIR, "strings"), "en")
98+
.orElseThrow(() -> new IOException("No English strings file found"));
99+
var enStrings = flattenJson(readAuto(enFile), "");
83100

84101
if (locale.equals("en")) return enStrings;
85102

86-
var localePath = Path.of(TRANSLATIONS_DIR, "strings", locale + ".json");
87-
if (!Files.exists(localePath)) {
88-
IO.println("[WARN] strings/%s.json not found — using all English strings".formatted(locale));
103+
var localeFile = findWithExtensions(Path.of(TRANSLATIONS_DIR, "strings"), locale);
104+
if (localeFile.isEmpty()) {
105+
IO.println("[WARN] strings/%s.{json,yaml,yml} not found — using all English strings".formatted(locale));
89106
return enStrings;
90107
}
91108

92-
var localeStrings = flattenJson(JSON_MAPPER.readTree(localePath.toFile()), "");
109+
var localeStrings = flattenJson(readAuto(localeFile.get()), "");
93110
var merged = new LinkedHashMap<>(enStrings);
94111
for (var entry : localeStrings.entrySet()) {
95112
if (enStrings.containsKey(entry.getKey())) {
@@ -313,6 +330,10 @@ String supportBadge(String state, Map<String, String> strings) {
313330
};
314331
}
315332

333+
String difficultyDisplay(String difficulty, Map<String, String> strings) {
334+
return strings.getOrDefault("difficulty." + difficulty, difficulty);
335+
}
336+
316337
String supportBadgeClass(String state) {
317338
return switch (state) {
318339
case "preview" -> "preview";
@@ -363,6 +384,7 @@ String renderRelatedCard(String tpl, Snippet rel, String locale, Map<String, Str
363384
return replaceTokens(tpl, Map.ofEntries(
364385
Map.entry("category", rel.category()), Map.entry("slug", rel.slug()),
365386
Map.entry("catDisplay", rel.catDisplay()), Map.entry("difficulty", rel.difficulty()),
387+
Map.entry("difficultyDisplay", difficultyDisplay(rel.difficulty(), strings)),
366388
Map.entry("title", escape(rel.title())),
367389
Map.entry("oldLabel", escape(rel.oldLabel())), Map.entry("oldCode", escape(rel.oldCode())),
368390
Map.entry("modernLabel", escape(rel.modernLabel())), Map.entry("modernCode", escape(rel.modernCode())),
@@ -403,6 +425,7 @@ String generateHtml(Templates tpl, Snippet s, Map<String, Snippet> all, Map<Stri
403425
Map.entry("title", escape(s.title())), Map.entry("summary", escape(s.summary())),
404426
Map.entry("slug", s.slug()), Map.entry("category", s.category()),
405427
Map.entry("categoryDisplay", s.catDisplay()), Map.entry("difficulty", s.difficulty()),
428+
Map.entry("difficultyDisplay", difficultyDisplay(s.difficulty(), extraTokens)),
406429
Map.entry("jdkVersion", s.jdkVersion()),
407430
Map.entry("oldLabel", escape(s.oldLabel())), Map.entry("modernLabel", escape(s.modernLabel())),
408431
Map.entry("oldCode", escape(s.oldCode())), Map.entry("modernCode", escape(s.modernCode())),
@@ -423,22 +446,46 @@ String generateHtml(Templates tpl, Snippet s, Map<String, Snippet> all, Map<Stri
423446
return replaceTokens(tpl.page(), tokens);
424447
}
425448

426-
/** Load translated content or fall back to English; overwrite code fields from English */
449+
/** Translatable field names — only these are merged from translation files */
450+
static final Set<String> TRANSLATABLE_FIELDS = Set.of(
451+
"title", "summary", "explanation", "oldApproach", "modernApproach", "whyModernWins", "support"
452+
);
453+
454+
/**
455+
* Overlay translated content onto the English base.
456+
* Translation files contain only translatable fields; everything else
457+
* (id, slug, category, difficulty, code, navigation, docs, etc.)
458+
* is always taken from the English source of truth.
459+
*/
427460
Snippet resolveSnippet(Snippet englishSnippet, String locale) {
428461
if (locale.equals("en")) return englishSnippet;
429462

430-
var translatedPath = Path.of(TRANSLATIONS_DIR, "content", locale,
431-
englishSnippet.category(), englishSnippet.slug() + ".json");
432-
if (!Files.exists(translatedPath)) return englishSnippet;
463+
var translatedDir = Path.of(TRANSLATIONS_DIR, "content", locale, englishSnippet.category());
464+
var translatedFile = findWithExtensions(translatedDir, englishSnippet.slug());
465+
if (translatedFile.isEmpty()) return englishSnippet;
433466

434467
try {
435-
var translatedNode = (com.fasterxml.jackson.databind.node.ObjectNode) JSON_MAPPER.readTree(translatedPath.toFile());
436-
// Overwrite code fields with English values
437-
translatedNode.put("oldCode", englishSnippet.oldCode());
438-
translatedNode.put("modernCode", englishSnippet.modernCode());
439-
return new Snippet(translatedNode);
468+
var translatedNode = (com.fasterxml.jackson.databind.node.ObjectNode) readAuto(translatedFile.get());
469+
// Start from a copy of the English node
470+
var merged = englishSnippet.node().deepCopy();
471+
// Overlay only translatable fields from the translation file
472+
for (var field : TRANSLATABLE_FIELDS) {
473+
if (translatedNode.has(field)) {
474+
if (field.equals("support") && translatedNode.get("support").isObject()) {
475+
// For support, only merge "description" — keep "state" from English
476+
var translatedSupport = translatedNode.get("support");
477+
if (translatedSupport.has("description")) {
478+
((com.fasterxml.jackson.databind.node.ObjectNode) merged.get("support"))
479+
.put("description", translatedSupport.get("description").asText());
480+
}
481+
} else {
482+
((com.fasterxml.jackson.databind.node.ObjectNode) merged).set(field, translatedNode.get(field));
483+
}
484+
}
485+
}
486+
return new Snippet(merged);
440487
} catch (IOException e) {
441-
IO.println("[WARN] Failed to load %s — using English".formatted(translatedPath));
488+
IO.println("[WARN] Failed to load %s — using English".formatted(translatedFile.get()));
442489
return englishSnippet;
443490
}
444491
}

specs/i18n/i18n-spec.md

Lines changed: 57 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -197,25 +197,50 @@ is purely informational and does **not** abort the build.
197197

198198
## Content Translation Files
199199

200-
Translated content files are **complete** copies of the English pattern JSON
201-
with translatable fields rendered in the target language. This avoids
202-
partial-merge edge cases and makes each file self-contained.
200+
Translated content files are **partial** — they contain **only** the
201+
translatable fields. The generator overlays them onto the English base at build
202+
time. This prevents translators from diverging structural data (code,
203+
navigation, metadata) from the English source of truth.
204+
205+
### Field Translation Reference
206+
207+
Every field in a slug definition file falls into one of three categories:
208+
209+
| Category | Fields | Rule |
210+
|---|---|---|
211+
| **Translate** (include in translation file) | `title`, `summary`, `explanation`, `oldApproach`, `modernApproach`, `whyModernWins` (full array), `support.description` | These are the **only** fields present in a translation file |
212+
| **English source of truth** (never in translation file) | `id`, `slug`, `category`, `difficulty`, `jdkVersion`, `oldLabel`, `modernLabel`, `oldCode`, `modernCode`, `prev`, `next`, `related`, `docs` | Always taken from the English content file; any values in the translation file are ignored |
213+
| **Translated via UI strings** | `difficulty`, `support.state` | Enum values stay in English; display names resolved from `translations/strings/{locale}.json` at build time |
214+
215+
**Why enum fields use UI strings instead of content translation:**
216+
217+
Fields like `difficulty` (`beginner`, `intermediate`, `advanced`) and
218+
`support.state` (`available`, `preview`, `experimental`) are enum values used
219+
programmatically as CSS classes, filter keys, and data attributes. Their
220+
**display names** are resolved at build time from the UI strings layer:
221+
222+
- `difficulty``difficulty.beginner`, `difficulty.intermediate`, `difficulty.advanced`
223+
- `support.state``support.available`, `support.preview`, `support.experimental`
224+
- `category` → display name from `categories.properties`
225+
226+
This separation ensures enum values remain stable across locales while display
227+
text is centrally managed and consistently translated.
228+
229+
**Other non-translated fields:**
230+
231+
- `whyModernWins[*].icon` — emoji; typically kept as-is across locales
232+
- `docs[*].title` — kept in English since the linked documentation is English
233+
234+
### Example Translated File
235+
236+
Translation files contain **only** translatable fields — no structural data:
203237

204238
```json
205239
// translations/content/pt-BR/language/type-inference-with-var.json
206240
{
207-
"id": 1,
208-
"slug": "type-inference-with-var",
209241
"title": "Inferência de tipo com var",
210-
"category": "language",
211-
"difficulty": "beginner",
212-
"jdkVersion": "10",
213-
"oldLabel": "Java 8",
214-
"modernLabel": "Java 10+",
215242
"oldApproach": "Tipos explícitos",
216243
"modernApproach": "Palavra-chave var",
217-
"oldCode": "...",
218-
"modernCode": "...",
219244
"summary": "Use var para deixar o compilador inferir o tipo local.",
220245
"explanation": "...",
221246
"whyModernWins": [
@@ -224,31 +249,22 @@ partial-merge edge cases and makes each file self-contained.
224249
{ "icon": "🔒", "title": "Seguro", "desc": "..." }
225250
],
226251
"support": {
227-
"state": "available",
228252
"description": "Amplamente disponível desde o JDK 10 (março de 2018)"
229-
},
230-
"prev": "language/...",
231-
"next": "language/...",
232-
"related": ["..."],
233-
"docs": [{ "title": "...", "href": "..." }]
253+
}
234254
}
235255
```
236256

237-
`oldCode` and `modernCode` are **always overwritten** with the English values at
238-
build time, regardless of what appears in the translation file. Translators may
239-
leave those fields empty or copy the English values verbatim — neither causes
240-
any harm.
241-
242257
---
243258

244259
## Generator — Resolution Order
245260

246261
For each pattern and locale the generator:
247262

248-
1. Loads the English baseline from `content/<cat>/<slug>.json`.
249-
2. Checks whether `translations/content/<locale>/<cat>/<slug>.json` exists.
250-
- **Yes** → use the translated file, then overwrite `oldCode`/`modernCode`
251-
with the English values.
263+
1. Loads the English baseline from `content/<cat>/<slug>.json` (or `.yaml`/`.yml`).
264+
2. Checks whether `translations/content/<locale>/<cat>/<slug>.{json,yaml,yml}` exists.
265+
- **Yes** → deep-copies the English node, then overlays only the translatable
266+
fields (`title`, `summary`, `explanation`, `oldApproach`, `modernApproach`,
267+
`whyModernWins`, `support.description`) from the translation file.
252268
- **No** → use the English file and inject an "untranslated" banner
253269
(see next section).
254270
3. Loads `translations/strings/<locale>.json` deep-merged over `en.json`.
@@ -414,14 +430,17 @@ New English slug → AI prompt → Translated JSON file → Schema validat
414430
`content/<cat>/<slug>.json` (push event or workflow dispatch).
415431
2. **Translate** — For each supported locale, call the translation model with:
416432
```
417-
Translate the following Java pattern JSON from English to {locale}.
418-
- Keep unchanged: slug, id, category, difficulty, jdkVersion, oldLabel,
419-
modernLabel, oldCode, modernCode, docs, related, prev, next, support.state
420-
- Translate: title, summary, explanation, oldApproach, modernApproach,
421-
whyModernWins[*].title, whyModernWins[*].desc, support.description
422-
- Return valid JSON only.
433+
Translate the following Java pattern from English to {locale}.
434+
Return a JSON file containing ONLY these translated fields:
435+
- title, summary, explanation, oldApproach, modernApproach
436+
- whyModernWins (full array with icon, title, desc)
437+
- support.description (inside a "support" object)
438+
Do NOT include: slug, id, category, difficulty, jdkVersion, oldLabel,
439+
modernLabel, oldCode, modernCode, docs, related, prev, next, support.state.
440+
Return valid JSON only.
423441
```
424-
3. **Validate** — Run JSON schema validation (same rules as English content).
442+
See the **Field Translation Reference** table above for the full rationale.
443+
3. **Validate** — Verify the output contains only translatable fields.
425444
4. **Commit** — Write the output to
426445
`translations/content/{locale}/<cat>/<slug>.json` and commit.
427446
5. **Deploy** — The generator picks it up on next build; the "untranslated"
@@ -430,9 +449,10 @@ New English slug → AI prompt → Translated JSON file → Schema validat
430449
### Keeping translations in sync
431450

432451
When an English file is **modified**, the same automation regenerates the
433-
translated file or opens a PR flagging the diff for human review. A CI check
434-
can compare `id`, `slug`, and `jdkVersion` between the English and translated
435-
files to detect stale translations.
452+
translated file or opens a PR flagging the diff for human review. Since
453+
translation files only contain translatable text, structural changes to the
454+
English file (code, navigation, metadata) take effect immediately without
455+
needing to update the translation.
436456

437457
---
438458

templates/related-card.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<div class="tip-card-header">
44
<div class="tip-badges">
55
<span class="badge {{category}}">{{catDisplay}}</span>
6-
<span class="badge {{difficulty}}">{{difficulty}}</span>
6+
<span class="badge {{difficulty}}">{{difficultyDisplay}}</span>
77
</div>
88
</div>
99
<h3>{{title}}</h3>

templates/slug-template.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@
123123
<div class="tip-header">
124124
<div class="tip-meta">
125125
<span class="badge {{category}}">{{categoryDisplay}}</span>
126-
<span class="badge {{difficulty}}">{{difficulty}}</span>
126+
<span class="badge {{difficulty}}">{{difficultyDisplay}}</span>
127127
</div>
128128
<h1>{{title}}</h1>
129129
<p>{{summary}}</p>
@@ -175,7 +175,7 @@ <h1>{{title}}</h1>
175175
</div>
176176
<div class="info-card">
177177
<div class="info-label">{{sections.difficulty}}</div>
178-
<div class="info-value blue">{{difficulty}}</div>
178+
<div class="info-value blue">{{difficultyDisplay}}</div>
179179
</div>
180180
</div>
181181

Lines changed: 3 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,8 @@
11
{
2-
"id": 19,
3-
"slug": "immutable-list-creation",
4-
"title": "Criação de listas imutáveis",
5-
"category": "collections",
6-
"difficulty": "beginner",
7-
"jdkVersion": "9",
8-
"oldLabel": "Java 8",
9-
"modernLabel": "Java 9+",
10-
"oldApproach": "Encapsulamento verboso",
112
"modernApproach": "List.of()",
12-
"oldCode": "",
13-
"modernCode": "",
143
"summary": "Crie listas imutáveis em uma expressão limpa.",
4+
"title": "Criação de listas imutáveis",
5+
"oldApproach": "Encapsulamento verboso",
156
"explanation": "List.of() cria uma lista verdadeiramente imutável — sem encapsulamento, sem cópia defensiva. Rejeita elementos nulos (null-hostile) e é estruturalmente imutável. O modo antigo exigia três chamadas aninhadas.",
167
"whyModernWins": [
178
{
@@ -31,24 +22,6 @@
3122
}
3223
],
3324
"support": {
34-
"state": "available",
3525
"description": "Amplamente disponível desde o JDK 9 (setembro de 2017)"
36-
},
37-
"prev": "language/exhaustive-switch",
38-
"next": "collections/immutable-map-creation",
39-
"related": [
40-
"collections/immutable-map-creation",
41-
"collections/immutable-set-creation",
42-
"collections/sequenced-collections"
43-
],
44-
"docs": [
45-
{
46-
"title": "List.of()",
47-
"href": "https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/List.html#of()"
48-
},
49-
{
50-
"title": "Collections Factory Methods (JEP 269)",
51-
"href": "https://openjdk.org/jeps/269"
52-
}
53-
]
26+
}
5427
}
Lines changed: 3 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,8 @@
11
{
2-
"id": 5,
3-
"slug": "records-for-data-classes",
4-
"title": "Records para classes de dados",
5-
"category": "language",
6-
"difficulty": "beginner",
7-
"jdkVersion": "16",
8-
"oldLabel": "Java 8",
9-
"modernLabel": "Java 16+",
10-
"oldApproach": "POJO verboso",
112
"modernApproach": "record",
12-
"oldCode": "",
13-
"modernCode": "",
143
"summary": "Uma linha substitui mais de 30 linhas de boilerplate para portadores de dados imutáveis.",
4+
"title": "Records para classes de dados",
5+
"oldApproach": "POJO verboso",
156
"explanation": "Records geram automaticamente o construtor, acessores (x(), y()), equals(), hashCode() e toString(). São imutáveis por design e ideais para DTOs, objetos de valor e pattern matching.",
167
"whyModernWins": [
178
{
@@ -31,24 +22,6 @@
3122
}
3223
],
3324
"support": {
34-
"state": "available",
3525
"description": "Amplamente disponível desde o JDK 16 (março de 2021)"
36-
},
37-
"prev": "language/pattern-matching-instanceof",
38-
"next": "language/sealed-classes",
39-
"related": [
40-
"language/flexible-constructor-bodies",
41-
"language/private-interface-methods",
42-
"language/pattern-matching-switch"
43-
],
44-
"docs": [
45-
{
46-
"title": "Records (JEP 395)",
47-
"href": "https://openjdk.org/jeps/395"
48-
},
49-
{
50-
"title": "Record class",
51-
"href": "https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/Record.html"
52-
}
53-
]
26+
}
5427
}

0 commit comments

Comments
 (0)