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:
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user