Back to notes

One bilingual source for 26 case studies

The 26 case studies on this portfolio lived in two hand-mirrored files: projects.ts (3,358 lines, PT) and projects.en.ts (3,348 lines, EN). Every edit had to be made twice, and nothing guaranteed the two copies stayed structurally identical. This note documents unifying them into a single bilingual source — and how to prove the migration lost nothing.

The diagnosis

Before touching anything, a script compared both trees structurally. It confirmed the risk: 1,616 leaves differed between locales (translated copy), 485 were identical (slugs, URLs, stack) — and there was one real drift: a project had 14 SEO keywords in PT and only 12 in EN. Exactly the kind of silent divergence manual duplication produces.

The unified format

The merge produced a single source where every divergent leaf becomes a { pt, en } pair and every identical value is stored once:

tagline: {
  pt: "BI operacional para SLA, backlog e incidentes.",
  en: "Operational BI for SLA, backlog, and incidents.",
},
stack: ["Power BI", "SQL", "DAX"],

A localizeProject function projects the tree down to the requested locale, and the two legacy files became 12-line facades — consumers (sitemap, project page) did not change a single line.

Proving nothing was lost

Hand-reviewing a content migration by eye does not work. Verification had three layers:

  1. In the generator: localize(merged, "pt") and localize(merged, "en") were deep-equal compared against both original arrays, order included.
  2. Independently: the legacy files were extracted from git HEAD and compared via JSON.stringify against the new facades' output — identical byte for byte, key order included.
  3. Permanently: invariant tests now lock the format — unique slugs per locale, count parity, no empty bilingual leaves, and cross-locale slug resolution.

The typing detail that almost slipped through

The Bilingual<Project> type had to be distributive: optional fields (string | undefined) and discriminated unions (kind: "video" | "embed") broke the naive mapped type. The rule that worked: only plain string may become a { pt, en } pair; literal unions stay intact so TypeScript can keep discriminating the variants.

Structural drift between locales is now impossible by construction — the translations live side by side, and the compiler validates the whole file.