adrian-altner.de/src/i18n/posts.ts
2026-04-21 01:26:19 +02:00

152 lines
5.2 KiB
TypeScript

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);
}