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:
- In the generator:
localize(merged, "pt")andlocalize(merged, "en")were deep-equal compared against both original arrays, order included. - Independently: the legacy files were extracted from
git HEADand compared viaJSON.stringifyagainst the new facades' output — identical byte for byte, key order included. - 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.