From 63db8bf254bdab5a9826cef869f56ba93c12bcc9 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 4 Jul 2026 00:54:02 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20i18n=20layer=20=E2=80=94=20locales,=20s?= =?UTF-8?q?lug-map=20contract,=20typed=20dictionaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- site/src/i18n/dictionary.en.ts | 88 +++++++++++++++++++++++++++ site/src/i18n/dictionary.no.ts | 98 ++++++++++++++++++++++++++++++ site/src/i18n/dictionary.ts | 93 ++++++++++++++++++++++++++++ site/src/i18n/locales.ts | 38 ++++++++++++ site/src/i18n/slugMap.ts | 108 +++++++++++++++++++++++++++++++++ site/src/i18n/t.ts | 29 +++++++++ 6 files changed, 454 insertions(+) create mode 100644 site/src/i18n/dictionary.en.ts create mode 100644 site/src/i18n/dictionary.no.ts create mode 100644 site/src/i18n/dictionary.ts create mode 100644 site/src/i18n/locales.ts create mode 100644 site/src/i18n/slugMap.ts create mode 100644 site/src/i18n/t.ts diff --git a/site/src/i18n/dictionary.en.ts b/site/src/i18n/dictionary.en.ts new file mode 100644 index 0000000..e8ccf19 --- /dev/null +++ b/site/src/i18n/dictionary.en.ts @@ -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', + }, +}; diff --git a/site/src/i18n/dictionary.no.ts b/site/src/i18n/dictionary.no.ts new file mode 100644 index 0000000..ff90313 --- /dev/null +++ b/site/src/i18n/dictionary.no.ts @@ -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', + }, +}; diff --git a/site/src/i18n/dictionary.ts b/site/src/i18n/dictionary.ts new file mode 100644 index 0000000..b4bb487 --- /dev/null +++ b/site/src/i18n/dictionary.ts @@ -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; + }; +} diff --git a/site/src/i18n/locales.ts b/site/src/i18n/locales.ts new file mode 100644 index 0000000..acc8b10 --- /dev/null +++ b/site/src/i18n/locales.ts @@ -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'; + +/** `` value per locale (I18N_SPEC §5). */ +export const HTML_LANG: Record = { + en: 'en-GB', + no: 'nb-NO', +}; + +/** hreflang code per locale (used in alternates + sitemap). */ +export const HREFLANG: Record = { + en: 'en', + no: 'nb', +}; + +/** OpenGraph locale (SEO_SPEC §4). */ +export const OG_LOCALE: Record = { + en: 'en_GB', + no: 'nb_NO', +}; + +/** Human label for the switch control, written in the *target* language. */ +export const LOCALE_LABEL: Record = { + 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'; +} diff --git a/site/src/i18n/slugMap.ts b/site/src/i18n/slugMap.ts new file mode 100644 index 0000000..49fd48e --- /dev/null +++ b/site/src/i18n/slugMap.ts @@ -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> = { + 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; +} diff --git a/site/src/i18n/t.ts b/site/src/i18n/t.ts new file mode 100644 index 0000000..2c0b5c4 --- /dev/null +++ b/site/src/i18n/t.ts @@ -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 = { 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 { + return template.replace(/\{(\w+)\}/g, (match, key: string) => + key in params ? String(params[key]) : match, + ); +} + +export type { Dictionary };