This commit is contained in:
Adrian Altner 2026-04-21 01:26:19 +02:00
commit f9ab31c247
62 changed files with 7894 additions and 0 deletions

152
src/i18n/posts.ts Normal file
View file

@ -0,0 +1,152 @@
import { type CollectionEntry, getCollection, getEntry } from 'astro:content';
import { type Locale } from '~/consts';
import { isLocale, localizePath } from '~/i18n/ui';
export function entryLocale(entry: { id: string }): Locale {
const first = entry.id.split('/')[0];
if (!isLocale(first)) {
throw new Error(`Content entry "${entry.id}" is not under a locale folder (de/ or en/).`);
}
return first;
}
export function entrySlug(entry: { id: string }): string {
return entry.id.split('/').slice(1).join('/');
}
// Back-compat aliases used across the codebase.
export const postLocale = entryLocale;
export const postSlug = entrySlug;
export async function getPostsByLocale(locale: Locale) {
const posts = await getCollection('posts', (p) => entryLocale(p) === locale);
return posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
}
export async function getCategoriesByLocale(locale: Locale) {
const categories = await getCollection('categories', (c) => entryLocale(c) === locale);
return categories.sort((a, b) => a.data.name.localeCompare(b.data.name));
}
export async function getPostsByCategory(category: CollectionEntry<'categories'>) {
const locale = entryLocale(category);
const posts = await getCollection(
'posts',
(p) => entryLocale(p) === locale && p.data.category?.id === category.id,
);
return posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
}
/** Convert a tag name into a URL-safe slug. */
export function tagSlug(tag: string): string {
return tag
.toLowerCase()
.trim()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/ß/g, 'ss')
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
/** Aggregated tag info across posts in one locale. */
export interface TagEntry {
name: string;
slug: string;
count: number;
}
export async function getTagsByLocale(locale: Locale): Promise<TagEntry[]> {
const posts = await getPostsByLocale(locale);
const byName = new Map<string, TagEntry>();
for (const post of posts) {
for (const raw of post.data.tags ?? []) {
const name = raw.trim();
if (!name) continue;
const existing = byName.get(name);
if (existing) existing.count++;
else byName.set(name, { name, slug: tagSlug(name), count: 1 });
}
}
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
}
/** Resolve a tag slug for a locale back to its canonical TagEntry. */
export async function findTagBySlug(locale: Locale, slug: string): Promise<TagEntry | undefined> {
const tags = await getTagsByLocale(locale);
return tags.find((t) => t.slug === slug);
}
export async function getPostsByTag(locale: Locale, slug: string) {
const posts = await getPostsByLocale(locale);
return posts.filter((p) => (p.data.tags ?? []).some((name) => tagSlug(name) === slug));
}
export async function resolveCategory(post: CollectionEntry<'posts'>) {
if (!post.data.category) return undefined;
return await getEntry(post.data.category);
}
/** URL segment used for category detail pages per locale. */
export function categorySegment(locale: Locale): string {
return locale === 'de' ? 'kategorie' : 'category';
}
/** URL segment used for the category listing page per locale. */
export function categoryIndexSegment(locale: Locale): string {
return locale === 'de' ? 'kategorien' : 'categories';
}
/** URL segment used for the about page per locale. */
export function aboutSegment(locale: Locale): string {
return locale === 'de' ? 'ueber-mich' : 'about';
}
/** URL segment used for tag detail pages per locale. */
export function tagSegment(locale: Locale): string {
return locale === 'de' ? 'schlagwort' : 'tag';
}
/** URL segment used for the tag listing page per locale. */
export function tagIndexSegment(locale: Locale): string {
return locale === 'de' ? 'schlagwoerter' : 'tags';
}
export function categoryHref(category: CollectionEntry<'categories'>): string {
const locale = entryLocale(category);
return localizePath(`/${categorySegment(locale)}/${entrySlug(category)}/`, locale);
}
export function postHref(post: CollectionEntry<'posts'>): string {
const locale = entryLocale(post);
return localizePath(`/${entrySlug(post)}/`, locale);
}
export function tagHref(locale: Locale, tag: string | TagEntry): string {
const slug = typeof tag === 'string' ? tagSlug(tag) : tag.slug;
return localizePath(`/${tagSegment(locale)}/${slug}/`, locale);
}
/** Canonical URL for any translatable entry. */
export function entryHref(entry: CollectionEntry<'posts' | 'categories'>): string {
return entry.collection === 'categories' ? categoryHref(entry) : postHref(entry);
}
/**
* Find the translation of an entry in the target locale, matched via the
* shared `translationKey` frontmatter field. Returns `undefined` when no
* matching translation exists.
*/
export async function findTranslation(
entry: CollectionEntry<'posts' | 'categories'>,
target: Locale,
): Promise<CollectionEntry<'posts' | 'categories'> | undefined> {
const key = entry.data.translationKey;
if (!key) return undefined;
const collection = entry.collection;
const all = await getCollection(collection, (e) => entryLocale(e) === target);
return all.find((e) => e.data.translationKey === key);
}

108
src/i18n/ui.ts Normal file
View file

@ -0,0 +1,108 @@
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.',
'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.',
'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',
},
en: {
kategorie: 'category',
kategorien: 'categories',
'ueber-mich': 'about',
schlagwort: 'tag',
schlagwoerter: 'tags',
},
};
/**
* 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);
}