Files
atelier/examples/bad/i18n-string-concat.md
T
Jon Chery 9ebc9c8868 docs(milestone): complete v0.3 — GitOps+Operators/AI-ML/i18n/Compliance
---ci---
project: atelier
phase: 6
milestone: v0.3
status: complete
requirements:
  covered: [ATELIER-60..91]
  partial: []
---/ci---
2026-08-05 03:45:38 +00:00

7.8 KiB
Raw Blame History

Bad Example: i18n String Concatenation

A checkout component that violates Atelier's i18n principles. Each violation is cited, then fixed.

The Code

// 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>
  );
}
// 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.

    // en-US.json (ICU MessageFormat)
    {
      "checkout.welcome": "Welcome, {name}!",
      "checkout.cart.summary": "{count, plural, one {# item} other {# items}} · {price} · {date}"
    }
    
    // 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.

    // 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.

    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.

  • 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.