adrian-altner.de/src/i18n/ui.ts
Adrian Altner 5bb63bacf5
All checks were successful
Deploy / deploy (push) Successful in 49s
Initial commit: Astro 6 static blog site
- German (default) and English i18n support
- Categories and tags
- Blog posts with hero images
- Dark/light theme switcher
- View Transitions removed to fix reload ghost images
- Webmentions integration
- RSS feeds per locale

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-04-22 10:55:29 +02:00

134 lines
4.4 KiB
TypeScript

import { DEFAULT_LOCALE, type Locale, LOCALES } from '~/consts';
export const ui = {
de: {
'nav.home': 'Start',
'nav.about': 'Über mich',
'nav.categories': 'Kategorien',
'nav.tags': 'Schlagwörter',
'post.lastUpdated': 'Zuletzt aktualisiert am',
'post.category': 'Kategorie',
'post.tags': 'Schlagwörter',
'post.translationAvailable': 'Dieser Beitrag ist auch auf Englisch verfügbar:',
'post.translationLink': 'Englische Version lesen',
'categories.title': 'Kategorien',
'categories.description': 'Alle Kategorien im Überblick.',
'category.postsIn': 'Beiträge in',
'category.noPosts': 'Noch keine Beiträge in dieser Kategorie.',
'tags.title': 'Schlagwörter',
'tags.description': 'Alle Schlagwörter im Überblick.',
'tag.postsTagged': 'Beiträge mit',
'tag.noPosts': 'Noch keine Beiträge mit diesem Stichwort.',
'footer.contact': 'Kontakt',
'footer.imprint': 'Impressum',
'footer.privacy': 'Datenschutz',
'webmentions.title': 'Reaktionen',
'webmentions.like': 'Like',
'webmentions.likes': 'Likes',
'webmentions.repost': 'Repost',
'webmentions.reposts': 'Reposts',
'webmentions.replies': 'Antworten',
'webmentions.mentions': 'Erwähnungen',
'lang.de': 'Deutsch',
'lang.en': 'English',
},
en: {
'nav.home': 'Home',
'nav.about': 'About',
'nav.categories': 'Categories',
'nav.tags': 'Tags',
'post.lastUpdated': 'Last updated on',
'post.category': 'Category',
'post.tags': 'Tags',
'post.translationAvailable': 'This post is also available in German:',
'post.translationLink': 'Read the German version',
'categories.title': 'Categories',
'categories.description': 'All categories at a glance.',
'category.postsIn': 'Posts in',
'category.noPosts': 'No posts in this category yet.',
'tags.title': 'Tags',
'tags.description': 'All tags at a glance.',
'tag.postsTagged': 'Posts tagged',
'tag.noPosts': 'No posts with this tag yet.',
'footer.contact': 'Contact',
'footer.imprint': 'Imprint',
'footer.privacy': 'Privacy',
'webmentions.title': 'Reactions',
'webmentions.like': 'Like',
'webmentions.likes': 'Likes',
'webmentions.repost': 'Repost',
'webmentions.reposts': 'Reposts',
'webmentions.replies': 'Replies',
'webmentions.mentions': 'Mentions',
'lang.de': 'Deutsch',
'lang.en': 'English',
},
} as const satisfies Record<Locale, Record<string, string>>;
export type UIKey = keyof (typeof ui)['de'];
export function t(locale: Locale, key: UIKey): string {
return ui[locale][key];
}
export function isLocale(value: string | undefined): value is Locale {
return !!value && (LOCALES as readonly string[]).includes(value);
}
export function getLocaleFromUrl(url: URL): Locale {
const seg = url.pathname.split('/').filter(Boolean)[0];
return isLocale(seg) ? seg : DEFAULT_LOCALE;
}
/**
* Build a URL for a route within a given locale. `path` is the route without
* any language prefix, e.g. "/" or "/about".
*/
export function localizePath(path: string, locale: Locale): string {
const normalized = path.startsWith('/') ? path : `/${path}`;
if (locale === DEFAULT_LOCALE) return normalized;
if (normalized === '/') return `/${locale}/`;
return `/${locale}${normalized}`;
}
/**
* Segments whose URL slug differs per locale. The first segment of any
* non-prefixed pathname is translated through this map when switching.
*/
const LOCALIZED_SEGMENTS: Record<Locale, Record<string, string>> = {
de: {
category: 'kategorie',
categories: 'kategorien',
about: 'ueber-mich',
tag: 'schlagwort',
tags: 'schlagwoerter',
contact: 'kontakt',
imprint: 'impressum',
'privacy-policy': 'datenschutz',
},
en: {
kategorie: 'category',
kategorien: 'categories',
'ueber-mich': 'about',
schlagwort: 'tag',
schlagwoerter: 'tags',
kontakt: 'contact',
impressum: 'imprint',
datenschutz: 'privacy-policy',
},
};
/**
* Swap the locale of the current pathname, preserving the rest of the route
* and translating known per-locale URL segments (e.g. `kategorie` ↔ `category`).
*/
export function switchLocalePath(pathname: string, target: Locale): string {
const parts = pathname.split('/').filter(Boolean);
if (parts.length > 0 && isLocale(parts[0])) parts.shift();
if (parts.length > 0) {
const translated = LOCALIZED_SEGMENTS[target][parts[0]];
if (translated) parts[0] = translated;
}
const rest = parts.length ? `/${parts.join('/')}` : '/';
return localizePath(rest === '/' ? '/' : rest, target);
}