feat: i18n layer — locales, slug-map contract, typed dictionaries

- locales.ts: locale codes, html lang, hreflang, OG locale
- slugMap.ts: EN/NO route table + section anchors, twin-path resolver (the tested contract)
- typed EN/NO dictionaries implementing one interface (missing key = compile error)
- useDict accessor + interpolate helper; no runtime i18n library

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-04 00:54:02 +02:00
parent 9a75f73550
commit 63db8bf254
6 changed files with 454 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
import type { Dictionary } from './dictionary';
export const en: Dictionary = {
nav: {
home: 'Home',
projects: 'Projects',
experience: 'Experience',
about: 'About',
contact: 'Contact',
},
lang: {
switchTo: 'Switch to Norwegian',
en: 'EN',
no: 'NO',
},
theme: {
toLight: 'Switch to light mode',
toDark: 'Switch to dark mode',
},
cv: {
download: 'Download CV',
english: 'English CV',
norsk: 'Norwegian CV',
fileMeta: 'pdf · {size} kB',
},
project: {
readCaseStudy: 'Read case study',
explore: 'Explore',
all: 'All projects',
caseStudyEyebrow: 'Case study · personal product',
capabilityEyebrow: 'Capability · infrastructure',
onThisPage: 'On this page',
repository: 'Repository',
liveDemo: 'Live demo',
prev: 'Previous',
next: 'Next',
status: {
active: 'Active',
'in-development': 'In development',
archived: 'Archived',
},
tldr: { what: 'What', why: 'Why', stack: 'Stack', role: 'Role' },
},
form: {
name: 'Name',
email: 'E-mail',
message: 'Message',
send: 'Send message',
sending: 'Sending',
successTitle: 'Message sent',
successBody: 'Thanks — I usually reply within a day.',
errorTitle: 'Something went wrong',
errorBody: 'The message could not be sent. Please e-mail me directly instead.',
required: 'This field is required',
invalidEmail: 'Please enter a valid e-mail address',
orEmail: 'Or reach me directly',
copy: 'Copy',
copied: 'Copied',
},
// The first-visit hint appears only on EN pages, inviting a Norwegian-preferring
// visitor to switch — so its text is written in Norwegian (ROUTING_SPEC §3).
hint: {
text: 'Denne siden finnes på norsk',
action: 'Bytt til norsk',
dismiss: 'Lukk',
},
notFound: {
code: 'route not found',
title: 'No such page',
body: 'The page you were looking for does not exist or has moved.',
home: 'Go to homepage',
projects: 'View projects',
},
a11y: {
skipToContent: 'Skip to content',
primaryNav: 'Primary',
openMenu: 'Open menu',
closeMenu: 'Close menu',
breadcrumb: 'Breadcrumb',
backToTop: 'Back to top',
},
footer: {
tagline: 'Systems developer based in Tønsberg, Norway.',
colophon: 'How this site works',
availability: 'Open to remote, hybrid or on-site roles.',
copyright: '© {year} Connor Babbington',
},
};
+98
View File
@@ -0,0 +1,98 @@
import type { Dictionary } from './dictionary';
/*
Norwegian (Bokmål) UI strings. Written natively, not translated — shorter and
plainer than the English where the language allows (CONTENT_STRATEGY §5).
Glossary (fixed term pairs — I18N_SPEC §6.3):
Systems Developer → Systemutvikler · case study → prosjektgjennomgang
projects → prosjekter · experience → erfaring
skills → kompetanse · work permit → oppholdstillatelse
self-hosted → egendriftet · Product names / tech names → untranslated
*/
export const no: Dictionary = {
nav: {
home: 'Hjem',
projects: 'Prosjekter',
experience: 'Erfaring',
about: 'Om meg',
contact: 'Kontakt',
},
lang: {
switchTo: 'Switch to English',
en: 'EN',
no: 'NO',
},
theme: {
toLight: 'Bytt til lyst modus',
toDark: 'Bytt til mørkt modus',
},
cv: {
download: 'Last ned CV',
english: 'CV på engelsk',
norsk: 'CV på norsk',
fileMeta: 'pdf · {size} kB',
},
project: {
readCaseStudy: 'Les gjennomgang',
explore: 'Utforsk',
all: 'Alle prosjekter',
caseStudyEyebrow: 'Prosjektgjennomgang · eget produkt',
capabilityEyebrow: 'Kompetanse · infrastruktur',
onThisPage: 'På denne siden',
repository: 'Kildekode',
liveDemo: 'Live demo',
prev: 'Forrige',
next: 'Neste',
status: {
active: 'Aktiv',
'in-development': 'Under utvikling',
archived: 'Arkivert',
},
tldr: { what: 'Hva', why: 'Hvorfor', stack: 'Stack', role: 'Rolle' },
},
form: {
name: 'Navn',
email: 'E-post',
message: 'Melding',
send: 'Send melding',
sending: 'Sender',
successTitle: 'Meldingen er sendt',
successBody: 'Takk — jeg svarer vanligvis innen en dag.',
errorTitle: 'Noe gikk galt',
errorBody: 'Meldingen kunne ikke sendes. Send meg gjerne en e-post direkte.',
required: 'Dette feltet er påkrevd',
invalidEmail: 'Skriv inn en gyldig e-postadresse',
orEmail: 'Eller kontakt meg direkte',
copy: 'Kopier',
copied: 'Kopiert',
},
// Never shown on NO pages; mirror kept for interface completeness.
hint: {
text: 'This page is available in English',
action: 'Switch to English',
dismiss: 'Close',
},
notFound: {
code: 'fant ikke siden',
title: 'Siden finnes ikke',
body: 'Siden du lette etter finnes ikke eller har blitt flyttet.',
home: 'Gå til forsiden',
projects: 'Se prosjekter',
},
a11y: {
skipToContent: 'Hopp til innhold',
primaryNav: 'Hovedmeny',
openMenu: 'Åpne meny',
closeMenu: 'Lukk meny',
breadcrumb: 'Brødsmulesti',
backToTop: 'Til toppen',
},
footer: {
tagline: 'Systemutvikler bosatt i Tønsberg.',
colophon: 'Slik er siden bygget',
availability: 'Åpen for remote, hybrid eller stedbaserte roller.',
copyright: '© {year} Connor Babbington',
},
};
+93
View File
@@ -0,0 +1,93 @@
/*
UI-string dictionary shape (I18N_SPEC §2). Both locale files implement this
interface, so a missing key is a compile error — never a runtime fallback.
Keys are semantic, never English-sentence keys.
*/
export interface Dictionary {
nav: {
home: string;
projects: string;
experience: string;
about: string;
contact: string;
};
lang: {
/** aria-label for the switch group, phrased in the target language. */
switchTo: string;
en: string;
no: string;
};
theme: {
toLight: string;
toDark: string;
};
cv: {
download: string;
english: string;
norsk: string;
/** e.g. "pdf · {size} kB" — {size} interpolated. */
fileMeta: string;
};
project: {
readCaseStudy: string;
explore: string;
all: string;
caseStudyEyebrow: string;
capabilityEyebrow: string;
onThisPage: string;
repository: string;
liveDemo: string;
prev: string;
next: string;
status: {
active: string;
'in-development': string;
archived: string;
};
tldr: { what: string; why: string; stack: string; role: string };
};
form: {
name: string;
email: string;
message: string;
send: string;
sending: string;
successTitle: string;
successBody: string;
errorTitle: string;
errorBody: string;
required: string;
invalidEmail: string;
orEmail: string;
copy: string;
copied: string;
};
hint: {
text: string;
action: string;
dismiss: string;
};
notFound: {
code: string;
title: string;
body: string;
home: string;
projects: string;
};
a11y: {
skipToContent: string;
primaryNav: string;
openMenu: string;
closeMenu: string;
breadcrumb: string;
backToTop: string;
};
footer: {
tagline: string;
colophon: string;
availability: string;
/** {year} interpolated. */
copyright: string;
};
}
+38
View File
@@ -0,0 +1,38 @@
/* Locale definitions. Single source for locale codes, html lang, and labels. */
export const LOCALES = ['en', 'no'] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = 'en';
/** `<html lang>` value per locale (I18N_SPEC §5). */
export const HTML_LANG: Record<Locale, string> = {
en: 'en-GB',
no: 'nb-NO',
};
/** hreflang code per locale (used in alternates + sitemap). */
export const HREFLANG: Record<Locale, string> = {
en: 'en',
no: 'nb',
};
/** OpenGraph locale (SEO_SPEC §4). */
export const OG_LOCALE: Record<Locale, string> = {
en: 'en_GB',
no: 'nb_NO',
};
/** Human label for the switch control, written in the *target* language. */
export const LOCALE_LABEL: Record<Locale, string> = {
en: 'EN',
no: 'NO',
};
export function isLocale(value: string): value is Locale {
return (LOCALES as readonly string[]).includes(value);
}
export function otherLocale(locale: Locale): Locale {
return locale === 'en' ? 'no' : 'en';
}
+108
View File
@@ -0,0 +1,108 @@
/*
The EN↔NO route contract (ROUTING_SPEC §1, ARCHITECTURE A5).
Single source of truth consumed by: the language switch, hreflang generation,
the sitemap, nav links, and breadcrumbs. CI asserts its bijection (see tests).
*/
import { LOCALES, DEFAULT_LOCALE, type Locale } from './locales';
export const PAGE_IDS = [
'home',
'projects',
'jobtrack',
'inboxintel',
'homelab',
'experience',
'about',
'contact',
'cv',
'colophon',
] as const;
export type PageId = (typeof PAGE_IDS)[number];
/** Canonical, trailing-slashed paths per page per locale. */
export const ROUTES: Record<PageId, Record<Locale, string>> = {
home: { en: '/', no: '/no/' },
projects: { en: '/projects/', no: '/no/prosjekter/' },
jobtrack: { en: '/projects/jobtrack/', no: '/no/prosjekter/jobtrack/' },
inboxintel: { en: '/projects/inboxintel/', no: '/no/prosjekter/inboxintel/' },
homelab: { en: '/projects/homelab/', no: '/no/prosjekter/hjemmelab/' },
experience: { en: '/experience/', no: '/no/erfaring/' },
about: { en: '/about/', no: '/no/om-meg/' },
contact: { en: '/contact/', no: '/no/kontakt/' },
cv: { en: '/cv/', no: '/no/cv/' },
colophon: { en: '/colophon/', no: '/no/kolofon/' },
};
/** Which case-study/capability pages appear as project cards, in order. */
export const PROJECT_PAGE_IDS = ['jobtrack', 'inboxintel', 'homelab'] as const;
export type ProjectPageId = (typeof PROJECT_PAGE_IDS)[number];
/**
* Homepage section anchors (ROUTING_SPEC §5). `id` is the canonical key used by
* scroll-spy; the localised fragment is what appears in the URL and nav link.
*/
export const HOME_SECTIONS = [
{ id: 'skills', en: 'skills', no: 'kompetanse' },
{ id: 'projects', en: 'projects', no: 'prosjekter' },
{ id: 'experience', en: 'experience', no: 'erfaring' },
{ id: 'about', en: 'about', no: 'om-meg' },
{ id: 'contact', en: 'contact', no: 'kontakt' },
] as const;
export function homeAnchor(sectionId: (typeof HOME_SECTIONS)[number]['id'], locale: Locale): string {
const s = HOME_SECTIONS.find((x) => x.id === sectionId);
return s ? s[locale] : sectionId;
}
/** Absolute-within-site path for a page in a locale. */
export function pathFor(pageId: PageId, locale: Locale): string {
return ROUTES[pageId][locale];
}
/** Normalise a path to the canonical trailing-slash form for comparison. */
function normalise(path: string): string {
const clean = path.split('?')[0]!.split('#')[0]!;
if (clean === '') return '/';
return clean.endsWith('/') ? clean : `${clean}/`;
}
/** Resolve the pageId that a URL path belongs to (or undefined). */
export function pageIdFromPath(path: string): PageId | undefined {
const target = normalise(path);
for (const id of PAGE_IDS) {
for (const locale of LOCALES) {
if (ROUTES[id][locale] === target) return id;
}
}
return undefined;
}
/** Infer the locale of a URL path from its prefix. */
export function localeFromPath(path: string): Locale {
const clean = normalise(path);
return clean === '/no/' || clean.startsWith('/no/') ? 'no' : DEFAULT_LOCALE;
}
/**
* Given the current path, return the equivalent page's path in `target` locale.
* This is the language-switch mapping (ROUTING_SPEC §3, J5). Falls back to the
* target-locale home if the current path is unmapped (e.g. 404).
*/
export function twinPath(currentPath: string, target: Locale): string {
const id = pageIdFromPath(currentPath);
if (!id) return ROUTES.home[target];
return ROUTES[id][target];
}
/** Every (pageId, locale, path) triple — used by the sitemap builder. */
export function allRoutes(): Array<{ pageId: PageId; locale: Locale; path: string }> {
const out: Array<{ pageId: PageId; locale: Locale; path: string }> = [];
for (const pageId of PAGE_IDS) {
for (const locale of LOCALES) {
out.push({ pageId, locale, path: ROUTES[pageId][locale] });
}
}
return out;
}
+29
View File
@@ -0,0 +1,29 @@
/*
Dictionary accessor. Components read the fully-typed dictionary object directly
(`d.nav.projects`), which keeps compile-time key safety (I18N_SPEC §2). Runtime
string paths are deliberately avoided — they'd lose that guarantee.
*/
import type { Dictionary } from './dictionary';
import { en } from './dictionary.en';
import { no } from './dictionary.no';
import type { Locale } from './locales';
const DICTS: Record<Locale, Dictionary> = { en, no };
/** Typed dictionary for a locale. */
export function useDict(locale: Locale): Dictionary {
return DICTS[locale];
}
/** Replace {token} placeholders. The only interpolation mechanism (no plural engine). */
export function interpolate(
template: string,
params: Record<string, string | number> = {},
): string {
return template.replace(/\{(\w+)\}/g, (match, key: string) =>
key in params ? String(params[key]) : match,
);
}
export type { Dictionary };