init
This commit is contained in:
commit
f9ab31c247
62 changed files with 7894 additions and 0 deletions
152
src/i18n/posts.ts
Normal file
152
src/i18n/posts.ts
Normal 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
108
src/i18n/ui.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue