1 Internationalization
Ella Mizuki edited this page 2026-07-03 04:33:23 +00:00

Internationalization

beta-website uses a code-first, TypeScript-native i18n system — no JSON files, no external translation services at runtime. All UI strings live in typed dictionary objects per locale.

Overview

Aspect Implementation
Strategy Build-time dictionary lookup
Source of Truth src/i18n/locales/{locale}.ts (6 files)
Type Safety Dictionary type in src/i18n/types.ts
Runtime Cost Zero — resolved at build/SSG time
Locale Detection URL prefix (/pt-br/, /ja/, etc.)
Default Locale en (no prefix: /blog/)
Fallback EN content shown with isFallback: true flag

Current Locale Support

Code Language Native Name Status Direction
en English English ✅ Canonical LTR
pt-br Portuguese (Brazil) Português ✅ Complete LTR
ja Japanese 日本語 ✅ Complete LTR
zh-cn Chinese (Simplified) 中文 ✅ Complete LTR
de German Deutsch ✅ Complete LTR
fr French Français ✅ Complete LTR

Dictionary Structure

File: src/i18n/types.ts — single source of truth for all UI strings.

export type Dictionary = {
  meta: { siteDescription: string };
  navigation: Record<Section, string>;  // blog, snippets, projects, about, contact
  common: {
    skipToContent: string;
    language: string;
    openLanguageMenu: string;
    latest: string;
    featured: string;
    viewAll: string;
    readMore: string;
    email: string;
    github: string;
    rss: string;
    website: string;
    externalLink: string;
    published: string;
    updated: string;
    availableInEnglish: string;
    backToBlog: string;
    backToSnippets: string;
    backToProjects: string;
    tableOfContents: string;
    onThisNote: string;
    copyCode: string;
    copied: string;
    footerNote: string;
    minuteRead: (minutes: number) => string;  // function for pluralization
  };
  projectStatuses: Record<ProjectStatus, string>;
  home: {
    eyebrow: string;
    title: string;
    titleHighlight: string;
    description: string;
    statement: string;
    statementHighlight: string;
    statementDetail: string;
    exploreTitle: string;
    exploreDescription: string;
  };
  pages: {
    blog: BlogPageDictionary;
    snippets: SnippetsPageDictionary;
    projects: ProjectsPageDictionary;
    about: AboutPageDictionary;
    contact: ContactPageDictionary;
  };
};

Key Design Decisions

  1. Functions for pluralization — minuteRead(minutes) handles locale-specific rules
  2. Nested page dictionaries — co-locates strings with their page
  3. No flat keys — t('common.copyCode') not needed; direct object access
  4. Discriminated unions — Section, ProjectStatus ensure exhaustiveness

Routing & URL Structure

File: src/lib/routes.ts

Page EN (default) Other Locales
Home / /pt-br/, /ja/, /zh-cn/, /de/, /fr/
Blog index /blog/ /pt-br/blog/, /ja/blog/, ...
Blog post /blog/my-post/ /pt-br/blog/my-post/, ...
Snippets /snippets/ /pt-br/snippets/, ...
Projects /projects/ /pt-br/projects/, ...
About /about/ /pt-br/about/, ...
Contact /contact/ /pt-br/contact/, ...

Astro i18n Config

File: astro.config.ts

i18n: {
  locales: ["en", "pt-br", "ja", "zh-cn", "de", "fr"],
  defaultLocale: "en",
  routing: {
    prefixDefaultLocale: false,  // EN has no prefix
  },
}

Content Fallback Logic

File: src/lib/content.ts — getLocalizedListing()

  1. Group entries by slug (from slug/locale ID format)
  2. For each slug: prefer locale entry, fallback to en
  3. Return { entry, isFallback, entryLocale, slug }[] sorted by date desc
  4. Drafts hidden in production (import.meta.env.PROD)

Behavior

Scenario Result
Locale file exists Shows localized content, isFallback: false
Locale missing, EN exists Shows EN content, isFallback: true
Neither exists Entry excluded from listing

UI Indicators

  • LanguageSwitcher — unavailable locales shown disabled (aria-disabled)
  • Content pages — availableInEnglish string shown when isFallback
  • hreflang — only generated for existing locales (via sitemap config)

Translation Workflow

1. UI Strings (Dictionary)

Location: src/i18n/locales/{locale}.ts

Process:

  1. Edit src/i18n/locales/en.ts (canonical)
  2. Copy changes to all 5 locale files
  3. Translate each string
  4. Run pnpm build — TypeScript verifies completeness

2. Content (MDX)

Location: src/content/{collection}/{slug}/{locale}.mdx

Process:

  1. Create entry folder: src/content/blog/my-post/
  2. Create en.mdx with full frontmatter
  3. Create locale variants: pt-br.mdx, ja.mdx, etc.
  4. Translate frontmatter + body
  5. Set canonicalSourceLocale: en on translations

Automated Translation Adapter

pnpm content:translate reports missing translations when no adapter is configured. To generate files, configure an external local command:

CONTENT_TRANSLATION_COMMAND=/path/to/translator \
CONTENT_TRANSLATION_ARGS='["--format","mdx"]' \
pnpm content:translate

The command receives one JSON object on stdin:

{
  "collection": "blog",
  "slug": "example",
  "sourceLocale": "en",
  "targetLocale": "ja",
  "sourcePath": "/absolute/path/en.mdx",
  "sourceContent": "---\n..."
}

It must write a complete MDX document to stdout. Existing translations are never overwritten.

Adding a New Locale

  1. Add to locales array in src/i18n/config.ts
  2. Add localeMetadata entry
  3. Create src/i18n/locales/{locale}.ts satisfying Dictionary
  4. Add to dictionaries in src/i18n/index.ts
  5. Update astro.config.ts i18n config
  6. Update sitemap i18n config
  7. Run pnpm content:new:post / pnpm content:new:snippet to scaffold content
  8. Run pnpm build — TypeScript catches missing keys