foundation: project guide, tooling, design tokens, bilingual shell and routing contracts
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
50
src/app/core/content/content.contracts.ts
Normal file
50
src/app/core/content/content.contracts.ts
Normal 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;
|
||||
}
|
||||
15
src/app/core/content/content.service.ts
Normal file
15
src/app/core/content/content.service.ts
Normal 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]);
|
||||
}
|
||||
}
|
||||
5
src/app/core/content/content.token.ts
Normal file
5
src/app/core/content/content.token.ts
Normal 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');
|
||||
58
src/app/core/content/placeholder-content.ts
Normal file
58
src/app/core/content/placeholder-content.ts
Normal 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'),
|
||||
};
|
||||
49
src/app/core/content/shell-copy.ts
Normal file
49
src/app/core/content/shell-copy.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
7
src/app/core/content/site-config.ts
Normal file
7
src/app/core/content/site-config.ts
Normal 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;
|
||||
11
src/app/core/i18n/locale.resolver.ts
Normal file
11
src/app/core/i18n/locale.resolver.ts
Normal 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;
|
||||
};
|
||||
25
src/app/core/i18n/locale.service.ts
Normal file
25
src/app/core/i18n/locale.service.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
44
src/app/core/i18n/locale.spec.ts
Normal file
44
src/app/core/i18n/locale.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
18
src/app/core/i18n/locale.ts
Normal file
18
src/app/core/i18n/locale.ts
Normal 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';
|
||||
}
|
||||
43
src/app/core/navigation/navigation.service.spec.ts
Normal file
43
src/app/core/navigation/navigation.service.spec.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
77
src/app/core/navigation/navigation.service.ts
Normal file
77
src/app/core/navigation/navigation.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
68
src/app/core/navigation/navigation.ts
Normal file
68
src/app/core/navigation/navigation.ts
Normal 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' },
|
||||
},
|
||||
];
|
||||
23
src/app/core/platform/browser.spec.ts
Normal file
23
src/app/core/platform/browser.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
26
src/app/core/platform/browser.ts
Normal file
26
src/app/core/platform/browser.ts
Normal 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;
|
||||
}
|
||||
16
src/app/core/routing/app-route-data.ts
Normal file
16
src/app/core/routing/app-route-data.ts
Normal 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']);
|
||||
}
|
||||
30
src/app/core/routing/route-ids.ts
Normal file
30
src/app/core/routing/route-ids.ts
Normal 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',
|
||||
];
|
||||
46
src/app/core/routing/route-paths.spec.ts
Normal file
46
src/app/core/routing/route-paths.spec.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
83
src/app/core/routing/route-paths.ts
Normal file
83
src/app/core/routing/route-paths.ts
Normal 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)));
|
||||
}
|
||||
Reference in New Issue
Block a user