9ebc9c8868
---ci--- project: atelier phase: 6 milestone: v0.3 status: complete requirements: covered: [ATELIER-60..91] partial: [] ---/ci---
175 lines
7.8 KiB
Markdown
175 lines
7.8 KiB
Markdown
# Bad Example: i18n String Concatenation
|
||
|
||
> A checkout component that violates Atelier's i18n principles. Each
|
||
> violation is cited, then fixed.
|
||
|
||
## The Code
|
||
|
||
```typescript
|
||
// Checkout.tsx — the cardinal i18n violation
|
||
function CartSummary({ itemCount, name, total, currency, date }) {
|
||
// P3 VIOLATION: inline string concatenation. The source-language
|
||
// word order ("Welcome, {name}! You have {n} items") is baked into
|
||
// code. Every locale with different word order is broken.
|
||
const welcome = "Welcome, " + name + "!";
|
||
|
||
// P4 VIOLATION: hand-rolled plural branching. `if (n === 1)` encodes
|
||
// exactly English's one/other rule. Arabic (six categories), Russian
|
||
// (three), Polish (three) are all wrong.
|
||
const items =
|
||
itemCount === 1 ? "1 item" : itemCount + " items";
|
||
|
||
// P5 VIOLATION: hand-rolled currency + date formatting. "$" + total
|
||
// is wrong for de-DE (symbol, grouping, placement). The date
|
||
// `getMonth() + 1 + "/" + getDay()` is US-only (mm/dd/yyyy).
|
||
const price = "$" + total.toFixed(2);
|
||
const d = new Date(date);
|
||
const dateStr = (d.getMonth() + 1) + "/" + d.getDate() + "/" + d.getFullYear();
|
||
|
||
return (
|
||
<div>
|
||
<h1>{welcome}</h1>
|
||
<p>{items} · {price} · {dateStr}</p>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
```typescript
|
||
// The "resource" file — a custom CSV the team hand-rolled.
|
||
// locale,en_us
|
||
// welcome_prefix,Welcome,
|
||
// item_singular,item
|
||
// item_plural,items
|
||
//
|
||
// This is a P3 violation on its own: a custom format no standard
|
||
// tool (xgettext, i18next, FormatJS) can extract from or compose with.
|
||
```
|
||
|
||
The team runs their first i18n test against real Arabic translations —
|
||
after the string freeze, after the translator was paid. The Arabic
|
||
build renders `"Welcome, محمد!"` with the name on the wrong side of
|
||
the comma, `"1 items"` for a single item (Arabic has six plural
|
||
categories, not two), and the price as `"$1,234.56"` (Arabic-Egypt
|
||
formats as `"١٬٢٣٤٫٥٦ ج.م."`). Every screen is a rewrite, not a patch.
|
||
|
||
## What Makes It Bad
|
||
|
||
### Inline String Concatenation (i18n P3 Resources are External, Not Inline)
|
||
- `"Welcome, " + name + "!"` bakes English word order into code. In
|
||
Japanese the name comes first (`ようこそ、محمدさん!`); in Arabic the
|
||
structure differs again. The concatenation is invisible to the
|
||
extraction pipeline (`xgettext`, `i18next-parser`) — the translator
|
||
never sees it as a unit, and the string cannot be versioned or
|
||
rolled back as a whole.
|
||
- The custom `.csv` "resource" store is a second P3 violation: no
|
||
standard tool reads it, it carries no plural grammar, and it cannot
|
||
compose with the ICU formatting layer.
|
||
- **Fix:** strings live in a standard locale resource file, addressed
|
||
by key. Code calls `t("welcome", { name })`; the resource carries
|
||
the parameterized message.
|
||
|
||
```json
|
||
// en-US.json (ICU MessageFormat)
|
||
{
|
||
"checkout.welcome": "Welcome, {name}!",
|
||
"checkout.cart.summary": "{count, plural, one {# item} other {# items}} · {price} · {date}"
|
||
}
|
||
```
|
||
|
||
```json
|
||
// ar-EG.json — six plural categories per CLDR; the code is identical
|
||
{
|
||
"checkout.welcome": "أهلاً بك، {name}!",
|
||
"checkout.cart.summary": "{count, plural, zero {لا عناصر} one {عنصر واحد} two {عنصران} few {# عناصر} many {# عنصرًا} other {# عنصر}} · {price} · {date}"
|
||
}
|
||
```
|
||
- See `domains/i18n/locale-resources.md` (Resources are the Boundary)
|
||
and `domains/i18n/first-principles.md` P3.
|
||
|
||
### Hand-Rolled Plural Branching (i18n P4 Plural and Gender are Parameterized)
|
||
- `itemCount === 1 ? "1 item" : itemCount + " items"` encodes
|
||
English's one/other rule and nothing else. Arabic has six
|
||
categories (zero, one, two, few, many, other); Russian has three
|
||
(one, few, many); Polish has three with different boundaries. A
|
||
two-branch `if` is a C1 (Correctness) violation masquerading as a
|
||
shortcut — it returns a wrong answer for every non-English locale.
|
||
- **Fix:** the count goes to ICU MessageFormat; the formatter
|
||
consults `Intl.PluralRules` for the active locale; the resource
|
||
carries the variant for that category. The code passes the count,
|
||
nothing more.
|
||
|
||
```typescript
|
||
// The code passes the count; the resource + formatter pick the form.
|
||
t("checkout.cart.summary", { count: itemCount, price, date });
|
||
// Intl.PluralRules("ar-EG").select(1) === "one" -> "عنصر واحد"
|
||
// Intl.PluralRules("ar-EG").select(2) === "two" -> "عنصران"
|
||
// Intl.PluralRules("ar-EG").select(5) === "few" -> "٥ عناصر"
|
||
```
|
||
- See `domains/i18n/locale-resources.md` (Plural and Gender in
|
||
Resources) and `domains/i18n/formatting.md` (Plural Rules).
|
||
|
||
### Hand-Rolled Currency and Date Formatting (i18n P5 Formatting is Locale-Aware)
|
||
- `"$" + total.toFixed(2)` hardcodes the US dollar symbol, US
|
||
grouping (`,`), and US placement (symbol before the number). In
|
||
`de-DE` the euro formats as `"1.234,56 €"` (symbol after, dot
|
||
grouping). In `ar-EG` the pound formats as `"١٬٢٣٤٫٥٦ ج.م."`
|
||
(Arabic-Indic digits, different grouping).
|
||
- `(d.getMonth() + 1) + "/" + d.getDate() + "/" + d.getFullYear()`
|
||
produces `11/7/2024` — US `mm/dd/yyyy`. Most of the world reads
|
||
`dd/mm/yyyy`; ISO is `yyyy-mm-dd`. A hand-rolled date formatter
|
||
encodes one locale's convention and silently produces wrong output
|
||
for every other.
|
||
- **Fix:** `Intl.NumberFormat` and `Intl.DateTimeFormat` with a BCP
|
||
47 locale tag. CLDR is the source of truth; `Intl` is the runtime.
|
||
|
||
```typescript
|
||
new Intl.NumberFormat("ar-EG", { style: "currency", currency: "EGP" })
|
||
.format(1234.56); // "١٬٢٣٤٫٥٦ ج.م."
|
||
new Intl.DateTimeFormat("ar-EG", { dateStyle: "medium" })
|
||
.format(new Date(date)); // "٧ نوفمبر ٢٠٢٤"
|
||
```
|
||
- See `domains/i18n/formatting.md` (the Intl surface, dates, numbers,
|
||
currencies) and `domains/i18n/first-principles.md` P5.
|
||
|
||
### Source Language Treated as the Default (i18n P1 Source Language is a Locale)
|
||
- The component has no resource layer at all for the source locale —
|
||
English is "just the strings in the code." When the first second
|
||
locale arrives, the fix is a rewrite (extract every string,
|
||
restructure every concatenation), not a patch. The source language
|
||
is `en-US`, a locale among many — it is not `null`.
|
||
- **Fix:** extract source strings into `en-US.json` from day one,
|
||
even before a second locale exists. The resource layer is the
|
||
boundary from the first commit.
|
||
- See `domains/i18n/first-principles.md` P1 and
|
||
`domains/uiux/copywriting.md`.
|
||
|
||
## The Cascade
|
||
|
||
The violations compound. Inline concatenation makes strings invisible
|
||
to the extraction pipeline, so the translator never receives them as
|
||
units — they reconstruct them by reading the code. Hand-rolled
|
||
plurals return wrong answers for every non-English locale, so the
|
||
Arabic build ships `"1 items"` for a single item. Hand-rolled
|
||
formatting produces US-shaped output everywhere, so the price and
|
||
date are wrong for `de-DE`, `ar-EG`, `zh-Hans-CN`, and every other
|
||
locale. And because the first i18n test ran against real translations
|
||
(a P8 violation — pseudo-locales should have surfaced all of this
|
||
while the fix was still cheap), the defects are found after the
|
||
string freeze, after the translator was paid, and after the release
|
||
date was promised. The fix is now a re-translation and a re-release,
|
||
not a commit.
|
||
|
||
## Cross-Domain Links
|
||
|
||
- `domains/i18n/locale-resources.md` — the resource layer this code
|
||
lacks; the standard formats (`.po`, JSON, Fluent, ICU Resource
|
||
Bundle) it should have used.
|
||
- `domains/i18n/formatting.md` — the `Intl`/ICU/CLDR formatting this
|
||
code should call instead of hand-rolling.
|
||
- `domains/i18n/first-principles.md` — P3, P4, P5, and P8 (pseudo-
|
||
locales test early).
|
||
- `domains/uiux/copywriting.md` — copy lives in resources, not in
|
||
code.
|
||
- `domains/api/error-responses.md` — the same parameterized-message
|
||
discipline applies to localized API errors. |