foundation: project guide, tooling, design tokens, bilingual shell and routing contracts

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-25 17:06:51 +02:00
parent 1020ac4c68
commit 42e9f01253
89 changed files with 3615 additions and 297 deletions

View File

@@ -0,0 +1,50 @@
import { type RouteId } from '../routing/route-ids';
/**
* Copy roles stay separate on purpose:
* - headline is the serious heading used for SEO and page structure
* - proof is only ever a verifiable statement
* - playfulLine is an optional wordplay hook that is never a capability claim
* - cta lives on CtaCopy so action labels stay swappable
*/
export interface CopyBlock {
readonly headline: string;
readonly proof?: string;
readonly playfulLine?: string;
readonly body?: readonly string[];
}
export interface CtaCopy {
readonly label: string;
readonly routeId?: RouteId;
readonly href?: string;
readonly external?: boolean;
}
export interface SectionCopy extends CopyBlock {
readonly id: string;
}
export interface PageCopy {
readonly routeId: RouteId;
readonly title: string;
readonly description: string;
readonly hero: CopyBlock;
readonly sections: readonly SectionCopy[];
readonly ctas: readonly CtaCopy[];
}
export interface CaseStudySummary {
readonly id: string;
readonly client: string;
readonly headline: string;
readonly proof?: string;
readonly tags: readonly string[];
readonly routeId?: RouteId;
}
export type LocalizedPages = Partial<Record<RouteId, PageCopy>>;
export interface SiteContent {
readonly pages: LocalizedPages;
}

View File

@@ -0,0 +1,15 @@
import { computed, inject, Injectable, type Signal } from '@angular/core';
import { LocaleService } from '../i18n/locale.service';
import { type RouteId } from '../routing/route-ids';
import { type PageCopy } from './content.contracts';
import { SITE_CONTENT } from './content.token';
@Injectable({ providedIn: 'root' })
export class ContentService {
private readonly localeService = inject(LocaleService);
private readonly content = inject(SITE_CONTENT);
page(routeId: RouteId): Signal<PageCopy | undefined> {
return computed(() => this.content[this.localeService.locale()].pages[routeId]);
}
}

View File

@@ -0,0 +1,5 @@
import { InjectionToken } from '@angular/core';
import { type AppLocale } from '../i18n/locale';
import { type SiteContent } from './content.contracts';
export const SITE_CONTENT = new InjectionToken<Record<AppLocale, SiteContent>>('SITE_CONTENT');

View File

@@ -0,0 +1,58 @@
import { type AppLocale } from '../i18n/locale';
import { ROUTE_IDS, type RouteId } from '../routing/route-ids';
import { type PageCopy, type SiteContent } from './content.contracts';
const PAGE_TITLES: Record<RouteId, Record<AppLocale, string>> = {
home: { de: 'Startseite', en: 'Home' },
services: { de: 'Leistungen', en: 'Services' },
servicesSoftware: { de: 'Software', en: 'Software' },
servicesHardwareNetwork: { de: 'Hardware und Netzwerk', en: 'Hardware and network' },
servicesClusters: { de: 'Cluster', en: 'Clusters' },
servicesAi: { de: 'KI-Integration', en: 'AI integration' },
projects: { de: 'Projekte', en: 'Projects' },
stack: { de: 'Stack', en: 'Stack' },
about: { de: 'Über mich', en: 'About' },
contact: { de: 'Kontakt', en: 'Contact' },
imprint: { de: 'Impressum', en: 'Legal notice' },
privacy: { de: 'Datenschutz', en: 'Privacy' },
notFound: { de: 'Seite nicht gefunden', en: 'Page not found' },
};
function scaffoldPage(routeId: RouteId, locale: AppLocale): PageCopy {
const title = PAGE_TITLES[routeId][locale];
const description =
locale === 'de' ? 'Diese Seite wird derzeit aufgebaut.' : 'This page is being built.';
return {
routeId,
title,
description,
hero: {
headline: title,
body: [description],
},
sections: [],
ctas:
routeId === 'notFound'
? [
{
label: locale === 'de' ? 'Zur Startseite' : 'Back to home',
routeId: 'home',
},
]
: [],
};
}
function pagesFor(locale: AppLocale): SiteContent {
const pages = Object.fromEntries(
ROUTE_IDS.map((routeId) => [routeId, scaffoldPage(routeId, locale)]),
) as Record<RouteId, PageCopy>;
return { pages };
}
export const PLACEHOLDER_CONTENT: Record<AppLocale, SiteContent> = {
de: pagesFor('de'),
en: pagesFor('en'),
};

View File

@@ -0,0 +1,49 @@
import { type AppLocale } from '../i18n/locale';
export interface ShellCopy {
readonly skipLink: string;
readonly primaryNavLabel: string;
readonly footerNavLabel: string;
readonly menuOpen: string;
readonly menuClose: string;
readonly languageSwitch: string;
readonly otherLocaleName: string;
readonly cvLabel: string;
readonly contactCta: string;
readonly scaffoldingNote: string;
readonly legalReviewNotice: string;
readonly notFoundHomeLabel: string;
}
export const SHELL_COPY: Record<AppLocale, ShellCopy> = {
de: {
skipLink: 'Zum Inhalt springen',
primaryNavLabel: 'Hauptnavigation',
footerNavLabel: 'Fußzeilen-Navigation',
menuOpen: 'Menü öffnen',
menuClose: 'Menü schließen',
languageSwitch: 'Zur englischen Version wechseln',
otherLocaleName: 'English',
cvLabel: 'Lebenslauf als PDF',
contactCta: 'Kontakt',
scaffoldingNote: 'Hinweis: Der endgültige Inhalt folgt. Diese Seite ist ein Gerüst.',
legalReviewNotice:
'Platzhalter: Dieser Rechtstext ist ungeprüft und muss vor der Veröffentlichung vom Seitenbetreiber geprüft werden.',
notFoundHomeLabel: 'Zur Startseite',
},
en: {
skipLink: 'Skip to content',
primaryNavLabel: 'Primary navigation',
footerNavLabel: 'Footer navigation',
menuOpen: 'Open menu',
menuClose: 'Close menu',
languageSwitch: 'Switch to the German version',
otherLocaleName: 'Deutsch',
cvLabel: 'Curriculum vitae as PDF',
contactCta: 'Contact',
scaffoldingNote: 'Note: Final content follows. This page is scaffolding.',
legalReviewNotice:
'Placeholder: This legal text is unreviewed and must be checked by the site owner before publication.',
notFoundHomeLabel: 'Back to home',
},
};

View File

@@ -0,0 +1,7 @@
export const SITE_CONFIG = {
personName: 'Antonio Ledebuhr',
contactEmail: 'info@antoniolede.de',
cvAssetPath: '/cv/CV.pdf',
cvDownloadFileName: 'Antonio-Ledebuhr-CV.pdf',
calendarUrl: null,
} as const;

View File

@@ -0,0 +1,11 @@
import { inject } from '@angular/core';
import { type ResolveFn } from '@angular/router';
import { DEFAULT_LOCALE, isAppLocale, type AppLocale } from './locale';
import { LocaleService } from './locale.service';
export const localeResolver: ResolveFn<AppLocale> = (route) => {
const localeValue: unknown = route.data['locale'];
const locale = isAppLocale(localeValue) ? localeValue : DEFAULT_LOCALE;
inject(LocaleService).setLocale(locale);
return locale;
};

View File

@@ -0,0 +1,25 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { DEFAULT_LOCALE, LOCALE_HTML_LANG, type AppLocale } from './locale';
@Injectable({ providedIn: 'root' })
export class LocaleService {
private readonly document = inject(DOCUMENT);
private readonly localeSignal = signal<AppLocale>(DEFAULT_LOCALE);
readonly locale = this.localeSignal.asReadonly();
readonly htmlLang = computed(() => LOCALE_HTML_LANG[this.localeSignal()]);
constructor() {
this.applyHtmlLang(DEFAULT_LOCALE);
}
setLocale(locale: AppLocale): void {
this.localeSignal.set(locale);
this.applyHtmlLang(locale);
}
private applyHtmlLang(locale: AppLocale): void {
this.document.documentElement.lang = LOCALE_HTML_LANG[locale];
}
}

View File

@@ -0,0 +1,44 @@
import { DOCUMENT } from '@angular/common';
import { TestBed } from '@angular/core/testing';
import { DEFAULT_LOCALE, isAppLocale, otherLocale } from './locale';
import { LocaleService } from './locale.service';
describe('locale helpers', () => {
it('defaults to German', () => {
expect(DEFAULT_LOCALE).toBe('de');
});
it('accepts only de and en', () => {
expect(isAppLocale('de')).toBe(true);
expect(isAppLocale('en')).toBe(true);
expect(isAppLocale('fr')).toBe(false);
expect(isAppLocale('')).toBe(false);
expect(isAppLocale(null)).toBe(false);
});
it('round-trips the other locale', () => {
expect(otherLocale('de')).toBe('en');
expect(otherLocale('en')).toBe('de');
expect(otherLocale(otherLocale('de'))).toBe('de');
});
});
describe('LocaleService', () => {
it('updates the locale signal and the document lang attribute', () => {
TestBed.configureTestingModule({});
const service = TestBed.inject(LocaleService);
const document = TestBed.inject(DOCUMENT);
expect(service.locale()).toBe('de');
expect(document.documentElement.lang).toBe('de-DE');
service.setLocale('en');
expect(service.locale()).toBe('en');
expect(service.htmlLang()).toBe('en');
expect(document.documentElement.lang).toBe('en');
service.setLocale('de');
expect(service.locale()).toBe('de');
expect(document.documentElement.lang).toBe('de-DE');
});
});

View File

@@ -0,0 +1,18 @@
export type AppLocale = 'de' | 'en';
export const APP_LOCALES: readonly AppLocale[] = ['de', 'en'];
export const DEFAULT_LOCALE: AppLocale = 'de';
export const LOCALE_HTML_LANG: Record<AppLocale, string> = {
de: 'de-DE',
en: 'en',
};
export function isAppLocale(value: unknown): value is AppLocale {
return value === 'de' || value === 'en';
}
export function otherLocale(locale: AppLocale): AppLocale {
return locale === 'de' ? 'en' : 'de';
}

View File

@@ -0,0 +1,43 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { routes } from '../../app.routes';
import { PLACEHOLDER_CONTENT } from '../content/placeholder-content';
import { SITE_CONTENT } from '../content/content.token';
import { LocaleService } from '../i18n/locale.service';
import { NavigationService } from './navigation.service';
describe('NavigationService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: PLACEHOLDER_CONTENT }],
});
});
it('maps a German route to its English counterpart and back', async () => {
const harness = await RouterTestingHarness.create();
const navigation = TestBed.inject(NavigationService);
await harness.navigateByUrl('/projekte');
expect(navigation.activeRouteId()).toBe('projects');
expect(navigation.alternateLocaleLink()).toEqual(['/', 'en', 'projects']);
await harness.navigateByUrl('/en/projects');
expect(navigation.activeRouteId()).toBe('projects');
expect(navigation.alternateLocaleLink()).toEqual(['/', 'projekte']);
});
it('builds links for the active locale', () => {
const navigation = TestBed.inject(NavigationService);
const locale = TestBed.inject(LocaleService);
locale.setLocale('de');
expect(navigation.link('projects')).toEqual(['/', 'projekte']);
expect(navigation.link('home')).toEqual(['/']);
locale.setLocale('en');
expect(navigation.link('projects')).toEqual(['/', 'en', 'projects']);
expect(navigation.link('home')).toEqual(['/', 'en']);
expect(navigation.link('contact', 'de')).toEqual(['/', 'kontakt']);
});
});

View File

@@ -0,0 +1,77 @@
import { computed, inject, Injectable, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs';
import { SITE_CONFIG } from '../content/site-config';
import { otherLocale, type AppLocale } from '../i18n/locale';
import { LocaleService } from '../i18n/locale.service';
import { isAppRouteData } from '../routing/app-route-data';
import { type RouteId } from '../routing/route-ids';
import { routeCommands } from '../routing/route-paths';
import { FOOTER_NAV, PRIMARY_NAV, type NavItem } from './navigation';
export interface ResolvedNavItem {
readonly routeId: RouteId;
readonly label: string;
readonly link: unknown[];
readonly children?: readonly ResolvedNavItem[];
}
@Injectable({ providedIn: 'root' })
export class NavigationService {
private readonly localeService = inject(LocaleService);
private readonly router = inject(Router);
private readonly activeRouteIdSignal = signal<RouteId>('home');
readonly activeRouteId = this.activeRouteIdSignal.asReadonly();
readonly cvHref = SITE_CONFIG.cvAssetPath;
readonly contactLink = computed(() => this.link('contact'));
readonly primaryNav = computed(() => this.resolveItems(PRIMARY_NAV, this.localeService.locale()));
readonly footerNav = computed(() => this.resolveItems(FOOTER_NAV, this.localeService.locale()));
constructor() {
this.router.events
.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
takeUntilDestroyed(),
)
.subscribe(() => this.syncFromRouter());
this.syncFromRouter();
}
link(routeId: RouteId, locale?: AppLocale): unknown[] {
return routeCommands(routeId, locale ?? this.localeService.locale());
}
alternateLocaleLink(): unknown[] {
const targetLocale = otherLocale(this.localeService.locale());
const current = this.activeRouteId();
const routeId = current === 'notFound' ? 'home' : current;
return routeCommands(routeId, targetLocale);
}
private resolveItems(items: readonly NavItem[], locale: AppLocale): ResolvedNavItem[] {
return items.map((item) => ({
routeId: item.routeId,
label: item.label[locale],
link: routeCommands(item.routeId, locale),
children: item.children ? this.resolveItems(item.children, locale) : undefined,
}));
}
private syncFromRouter(): void {
let snapshot = this.router.routerState.snapshot.root;
while (snapshot.firstChild) {
snapshot = snapshot.firstChild;
}
if (!isAppRouteData(snapshot.data)) {
return;
}
this.localeService.setLocale(snapshot.data.locale);
this.activeRouteIdSignal.set(snapshot.data.routeId);
}
}

View File

@@ -0,0 +1,68 @@
import { type AppLocale } from '../i18n/locale';
import { type RouteId } from '../routing/route-ids';
export interface NavItem {
readonly routeId: RouteId;
readonly label: Record<AppLocale, string>;
readonly children?: readonly NavItem[];
}
export const PRIMARY_NAV: readonly NavItem[] = [
{
routeId: 'home',
label: { de: 'Start', en: 'Home' },
},
{
routeId: 'services',
label: { de: 'Leistungen', en: 'Services' },
children: [
{
routeId: 'servicesSoftware',
label: { de: 'Software', en: 'Software' },
},
{
routeId: 'servicesHardwareNetwork',
label: { de: 'Hardware und Netzwerk', en: 'Hardware and network' },
},
{
routeId: 'servicesClusters',
label: { de: 'Cluster', en: 'Clusters' },
},
{
routeId: 'servicesAi',
label: { de: 'KI-Integration', en: 'AI integration' },
},
],
},
{
routeId: 'projects',
label: { de: 'Projekte', en: 'Projects' },
},
{
routeId: 'stack',
label: { de: 'Stack', en: 'Stack' },
},
{
routeId: 'about',
label: { de: 'Über mich', en: 'About' },
},
{
routeId: 'contact',
label: { de: 'Kontakt', en: 'Contact' },
},
];
export const FOOTER_NAV: readonly NavItem[] = [
{
routeId: 'imprint',
label: { de: 'Impressum', en: 'Legal notice' },
},
{
routeId: 'privacy',
label: { de: 'Datenschutz', en: 'Privacy' },
},
{
routeId: 'contact',
label: { de: 'Kontakt', en: 'Contact' },
},
];

View File

@@ -0,0 +1,23 @@
import { PLATFORM_ID } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
isBrowserPlatform,
prefersCoarsePointer,
prefersReducedMotion,
viewportMatches,
} from './browser';
describe('browser platform helpers', () => {
it('returns conservative defaults on the server without throwing', () => {
TestBed.configureTestingModule({
providers: [{ provide: PLATFORM_ID, useValue: 'server' }],
});
TestBed.runInInjectionContext(() => {
expect(isBrowserPlatform()).toBe(false);
expect(prefersReducedMotion()).toBe(false);
expect(prefersCoarsePointer()).toBe(false);
expect(viewportMatches('(min-width: 40rem)')).toBe(false);
});
});
});

View File

@@ -0,0 +1,26 @@
import { inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
export function isBrowserPlatform(): boolean {
return isPlatformBrowser(inject(PLATFORM_ID));
}
export function prefersReducedMotion(): boolean {
return mediaQueryMatches('(prefers-reduced-motion: reduce)');
}
export function prefersCoarsePointer(): boolean {
return mediaQueryMatches('(pointer: coarse)');
}
export function viewportMatches(query: string): boolean {
return mediaQueryMatches(query);
}
function mediaQueryMatches(query: string): boolean {
if (!isBrowserPlatform() || typeof window.matchMedia !== 'function') {
return false;
}
return window.matchMedia(query).matches;
}

View File

@@ -0,0 +1,16 @@
import { isAppLocale, type AppLocale } from '../i18n/locale';
import { type RouteId } from './route-ids';
export interface AppRouteData {
readonly routeId: RouteId;
readonly locale: AppLocale;
}
export function isAppRouteData(value: unknown): value is AppRouteData {
if (typeof value !== 'object' || value === null) {
return false;
}
const record = value as Record<string, unknown>;
return typeof record['routeId'] === 'string' && isAppLocale(record['locale']);
}

View File

@@ -0,0 +1,30 @@
export type RouteId =
| 'home'
| 'services'
| 'servicesSoftware'
| 'servicesHardwareNetwork'
| 'servicesClusters'
| 'servicesAi'
| 'projects'
| 'stack'
| 'about'
| 'contact'
| 'imprint'
| 'privacy'
| 'notFound';
export const ROUTE_IDS: readonly RouteId[] = [
'home',
'services',
'servicesSoftware',
'servicesHardwareNetwork',
'servicesClusters',
'servicesAi',
'projects',
'stack',
'about',
'contact',
'imprint',
'privacy',
'notFound',
];

View File

@@ -0,0 +1,46 @@
import { APP_LOCALES } from '../i18n/locale';
import { ROUTE_IDS, type RouteId } from './route-ids';
import { prerenderablePaths, routePath, ROUTE_SEGMENTS } from './route-paths';
describe('route paths', () => {
const addressableIds = ROUTE_IDS.filter((routeId) => routeId !== 'notFound');
it('defines every route id in both locales', () => {
for (const locale of APP_LOCALES) {
for (const routeId of ROUTE_IDS) {
expect(ROUTE_SEGMENTS[locale][routeId]).toBeTypeOf('string');
}
}
});
it('has no duplicate addressable paths within a locale', () => {
for (const locale of APP_LOCALES) {
const paths = addressableIds.map((routeId) => routePath(routeId, locale));
expect(new Set(paths).size).toBe(paths.length);
}
});
it('builds the expected home and projects paths', () => {
expect(routePath('home', 'de')).toBe('/');
expect(routePath('home', 'en')).toBe('/en');
expect(routePath('projects', 'de')).toBe('/projekte');
expect(routePath('projects', 'en')).toBe('/en/projects');
});
it('includes both locales in prerenderable paths and excludes the wildcard', () => {
const paths = prerenderablePaths();
expect(paths).toContain('/');
expect(paths).toContain('/en');
expect(paths).toContain('/projekte');
expect(paths).toContain('/en/projects');
expect(paths.some((path) => path.includes('**'))).toBe(false);
expect(paths).not.toContain('');
for (const locale of APP_LOCALES) {
for (const routeId of addressableIds) {
expect(paths).toContain(routePath(routeId as RouteId, locale));
}
}
});
});

View File

@@ -0,0 +1,83 @@
import { APP_LOCALES, type AppLocale } from '../i18n/locale';
import { ROUTE_IDS, type RouteId } from './route-ids';
export const LOCALE_PREFIX: Record<AppLocale, string> = {
de: '',
en: 'en',
};
export const ROUTE_SEGMENTS: Record<AppLocale, Record<RouteId, string>> = {
de: {
home: '',
services: 'leistungen',
servicesSoftware: 'leistungen/software',
servicesHardwareNetwork: 'leistungen/hardware-netzwerk',
servicesClusters: 'leistungen/cluster',
servicesAi: 'leistungen/ai-integration',
projects: 'projekte',
stack: 'stack',
about: 'ueber-mich',
contact: 'kontakt',
imprint: 'impressum',
privacy: 'datenschutz',
notFound: '**',
},
en: {
home: '',
services: 'services',
servicesSoftware: 'services/software',
servicesHardwareNetwork: 'services/hardware-network',
servicesClusters: 'services/clusters',
servicesAi: 'services/ai-integration',
projects: 'projects',
stack: 'stack',
about: 'about',
contact: 'contact',
imprint: 'legal-notice',
privacy: 'privacy',
notFound: '**',
},
};
export function routePath(routeId: RouteId, locale: AppLocale): string {
if (routeId === 'notFound') {
return '';
}
const prefix = LOCALE_PREFIX[locale];
const segment = ROUTE_SEGMENTS[locale][routeId];
const parts = [prefix, segment].filter((part) => part.length > 0);
return parts.length === 0 ? '/' : `/${parts.join('/')}`;
}
export function routeCommands(routeId: RouteId, locale: AppLocale): unknown[] {
if (routeId === 'notFound') {
return ['/'];
}
const commands: string[] = ['/'];
const prefix = LOCALE_PREFIX[locale];
const segment = ROUTE_SEGMENTS[locale][routeId];
if (prefix.length > 0) {
commands.push(prefix);
}
if (segment.length > 0) {
commands.push(...segment.split('/'));
}
return commands;
}
export function prerenderablePaths(): readonly string[] {
return APP_LOCALES.flatMap((locale) =>
ROUTE_IDS.filter((routeId) => routeId !== 'notFound').map((routeId) =>
routePath(routeId, locale),
),
);
}
export function prerenderableServerPaths(): readonly string[] {
return prerenderablePaths().map((path) => (path === '/' ? '' : path.slice(1)));
}