integration: add route metadata, JSON-LD and harden the public shell

Ship prerendered SEO, crawl files and palette/map a11y so every locale route is indexable, honest and keyboard-usable without inventing claims.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-25 18:28:22 +02:00
parent 2ccac6b28a
commit 46c86447d1
48 changed files with 1597 additions and 207 deletions

View File

@@ -0,0 +1,24 @@
export type CommandId =
| 'help'
| 'projects'
| 'servicesAi'
| 'cv'
| 'contact'
| 'brew'
| 'ignite'
| 'rev'
| 'clear'
| 'close';
export const COMMAND_IDS: readonly CommandId[] = [
'help',
'projects',
'servicesAi',
'cv',
'contact',
'brew',
'ignite',
'rev',
'clear',
'close',
];

View File

@@ -111,6 +111,10 @@ export interface HomePageCopy extends PageCopy {
readonly metrics: readonly MetricCopy[];
readonly audiences: readonly AudienceEntryCopy[];
readonly featuredCaseIds: readonly CaseStudyId[];
readonly systemsMap: {
readonly heading: string;
readonly intro: string;
};
}
export type ContactFieldId =
@@ -180,6 +184,12 @@ export interface ProjectsPageCopy extends PageCopy {
export type ServicePageId =
'servicesSoftware' | 'servicesHardwareNetwork' | 'servicesClusters' | 'servicesAi';
export interface SiteSeoCopy {
readonly jobTitle: string;
readonly professionalServiceName: string;
readonly professionalServiceDescription: string;
}
export interface SiteContent {
readonly pages: Record<RouteId, PageCopy>;
readonly home: HomePageCopy;
@@ -189,4 +199,5 @@ export interface SiteContent {
readonly projects: ProjectsPageCopy;
readonly stack: StackPageCopy;
readonly legal: Record<'imprint' | 'privacy', LegalPageCopy>;
readonly seo: SiteSeoCopy;
}

View File

@@ -75,6 +75,10 @@ export const HOME_DE: HomePageCopy = {
},
],
featuredCaseIds: ['innofocus', 'roesterei', 'myspa', 'hdi'],
systemsMap: {
heading: 'Wie die Schichten zusammenhängen',
intro: 'Hardware, Cluster, Software und KI-Anbindung — und wo die öffentlichen Fälle ansetzen.',
},
sections: [
{
id: 'profile',

View File

@@ -34,6 +34,12 @@ export const SITE_CONTENT_DE: SiteContent = {
imprint: IMPRINT_DE,
privacy: PRIVACY_DE,
},
seo: {
jobTitle: 'Fullstack- und DevOps-Ingenieur',
professionalServiceName: 'Software- und DevOps-Leistungen',
professionalServiceDescription:
'Vier Leistungsbereiche für Produktsoftware, Infrastruktur, Cluster und KI-Anbindung — vom ersten Workshop bis zum Betrieb, mit direktem Kundenkontakt.',
},
pages: {
home: HOME_DE,
services: SERVICES_OVERVIEW_DE,

View File

@@ -75,6 +75,10 @@ export const HOME_EN: HomePageCopy = {
},
],
featuredCaseIds: ['innofocus', 'roesterei', 'myspa', 'hdi'],
systemsMap: {
heading: 'How the layers connect',
intro: 'Hardware, clusters, software and AI integration — and where the public cases attach.',
},
sections: [
{
id: 'profile',

View File

@@ -34,6 +34,12 @@ export const SITE_CONTENT_EN: SiteContent = {
imprint: IMPRINT_EN,
privacy: PRIVACY_EN,
},
seo: {
jobTitle: 'Fullstack and DevOps engineer',
professionalServiceName: 'Software and DevOps services',
professionalServiceDescription:
'Four service areas covering product software, infrastructure, clusters and AI integration — from the first workshop through to operations, with direct customer contact.',
},
pages: {
home: HOME_EN,
services: SERVICES_OVERVIEW_EN,

View File

@@ -1,5 +1,7 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { COMMAND_IDS } from '../commands/command-ids';
import { APP_LOCALES } from '../i18n/locale';
import { COMMAND_IDS } from '../../shared/command-palette/commands';
import { SIGNATURE_COPY } from './signature-copy';
function leafPaths(value: unknown, prefix = ''): string[] {
@@ -46,6 +48,14 @@ describe('SIGNATURE_COPY', () => {
}
});
it('does not import from src/app/shared', () => {
const source = readFileSync(
join(process.cwd(), 'src/app/core/content/signature-copy.ts'),
'utf8',
);
expect(source).not.toMatch(/from ['"][^'"]*\/shared\//);
});
it('describes every CommandId in both locales', () => {
for (const locale of APP_LOCALES) {
const descriptions = SIGNATURE_COPY[locale].palette.commandDescriptions;

View File

@@ -1,10 +1,11 @@
import { type CommandId } from '../commands/command-ids';
import { type AppLocale } from '../i18n/locale';
import { type CommandId } from '../../shared/command-palette/commands';
export interface SignatureCopy {
readonly palette: {
readonly triggerLabel: string;
readonly shortcutHint: string;
readonly shortcutHintApple: string;
readonly dialogTitle: string;
readonly dialogDescription: string;
readonly inputLabel: string;
@@ -47,6 +48,7 @@ export const SIGNATURE_COPY: Record<AppLocale, SignatureCopy> = {
palette: {
triggerLabel: 'Befehle öffnen',
shortcutHint: 'Strg+K',
shortcutHintApple: '⌘K',
dialogTitle: 'Befehle',
dialogDescription:
'Zur Navigation oder zu einer kurzen Rückmeldung. Es wird kein Code ausgeführt.',
@@ -100,6 +102,7 @@ export const SIGNATURE_COPY: Record<AppLocale, SignatureCopy> = {
palette: {
triggerLabel: 'Open commands',
shortcutHint: 'Ctrl+K',
shortcutHintApple: '⌘K',
dialogTitle: 'Commands',
dialogDescription: 'Navigate or get a short acknowledgement. No code is executed.',
inputLabel: 'Command',

View File

@@ -1,12 +1,14 @@
export const SITE_CONFIG: {
readonly personName: string;
readonly contactEmail: string;
readonly siteOrigin: string;
readonly cvAssetPath: string;
readonly cvDownloadFileName: string;
readonly calendarUrl: string | null;
} = {
personName: 'Antonio Ledebuhr',
contactEmail: 'info@antoniolede.de',
siteOrigin: 'https://antoniolede.de',
cvAssetPath: '/cv/CV.pdf',
cvDownloadFileName: 'Antonio-Ledebuhr-CV.pdf',
calendarUrl: null,

View File

@@ -10,7 +10,7 @@ export interface NavItem {
export const PRIMARY_NAV: readonly NavItem[] = [
{
routeId: 'home',
label: { de: 'Start', en: 'Home' },
label: { de: 'Startseite', en: 'Home' },
},
{
routeId: 'services',

View File

@@ -1,6 +1,7 @@
import { PLATFORM_ID } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
isApplePlatform,
isBrowserPlatform,
prefersCoarsePointer,
prefersReducedMotion,
@@ -18,6 +19,7 @@ describe('browser platform helpers', () => {
expect(prefersReducedMotion()).toBe(false);
expect(prefersCoarsePointer()).toBe(false);
expect(viewportMatches('(min-width: 40rem)')).toBe(false);
expect(isApplePlatform()).toBe(false);
});
});
});

View File

@@ -17,6 +17,22 @@ export function viewportMatches(query: string): boolean {
return mediaQueryMatches(query);
}
/**
* Keyboard-labelling exception: there is no CSS media query for the Command key.
* Used only to swap the palette shortcut hint after hydration.
*/
export function isApplePlatform(): boolean {
if (!isBrowserPlatform()) {
return false;
}
const navigatorWithHints = window.navigator as Navigator & {
readonly userAgentData?: { readonly platform?: string };
};
const platform = navigatorWithHints.userAgentData?.platform ?? navigatorWithHints.platform ?? '';
return /mac|iphone|ipad|ipod/i.test(platform);
}
function mediaQueryMatches(query: string): boolean {
if (!isBrowserPlatform() || typeof window.matchMedia !== 'function') {
return false;

View File

@@ -0,0 +1,169 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { APP_LOCALES } from '../i18n/locale';
import { SITE_CONFIG } from '../content/site-config';
import { CASE_STUDY_IDS } from '../content/content.contracts';
import { prerenderablePaths, routePath } from '../routing/route-paths';
import { absoluteUrl } from './route-metadata';
const PUBLIC = join(process.cwd(), 'public');
const EXCLUDED = /HUP|BitWiz|Cybertrading/;
function readPublic(name: 'robots.txt' | 'sitemap.xml' | 'llms.txt'): string {
return readFileSync(join(PUBLIC, name), 'utf8');
}
function expectedCanonicals(): readonly string[] {
return prerenderablePaths().map((path) => absoluteUrl(path));
}
function sitemapLocs(xml: string): string[] {
return [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) => match[1]);
}
function sitemapAlternates(xml: string): Array<{ loc: string; hreflang: string; href: string }> {
const blocks = xml.split(/<url>/).slice(1);
const rows: Array<{ loc: string; hreflang: string; href: string }> = [];
for (const block of blocks) {
const loc = block.match(/<loc>([^<]+)<\/loc>/)?.[1];
if (!loc) {
throw new Error(`sitemap.xml has a <url> block without <loc>: ${block.slice(0, 120)}`);
}
const links = [
...block.matchAll(
/<xhtml:link[^>]*rel="alternate"[^>]*hreflang="([^"]+)"[^>]*href="([^"]+)"/g,
),
];
if (links.length === 0) {
throw new Error(`sitemap.xml is missing xhtml alternates for ${loc}`);
}
for (const link of links) {
rows.push({ loc, hreflang: link[1], href: link[2] });
}
}
return rows;
}
function siteOriginsIn(text: string): string[] {
return [...text.matchAll(/(?:href|loc|Sitemap:\s*)["']?(https?:\/\/[^/\s"'<>]+)/g)].map(
(match) => match[1],
);
}
describe('crawl assets', () => {
it('lists every prerenderable path once and no URL outside that set', () => {
const xml = readPublic('sitemap.xml');
const expected = [...expectedCanonicals()].sort();
const actual = [...new Set(sitemapLocs(xml))].sort();
expect(
actual,
'sitemap.xml locs must match SITE_CONFIG.siteOrigin + prerenderablePaths()',
).toEqual(expected);
expect(sitemapLocs(xml)).toHaveLength(expected.length);
});
it('keeps reciprocal hreflang alternates for de-DE, en and x-default', () => {
const xml = readPublic('sitemap.xml');
const required = ['de-DE', 'en', 'x-default'] as const;
for (const loc of sitemapLocs(xml)) {
const links = sitemapAlternates(xml).filter((row) => row.loc === loc);
const langs = links.map((row) => row.hreflang).sort();
expect(langs, `${loc} must have de-DE, en and x-default`).toEqual([...required].sort());
const byLang = Object.fromEntries(links.map((row) => [row.hreflang, row.href]));
expect(byLang['x-default'], `${loc} x-default must be the German URL`).toBe(byLang['de-DE']);
}
for (const locale of APP_LOCALES) {
for (const path of prerenderablePaths().filter((entry) =>
locale === 'en'
? entry === '/en' || entry.startsWith('/en/')
: !(entry === '/en' || entry.startsWith('/en/')),
)) {
const loc = absoluteUrl(path);
const links = sitemapAlternates(xml).filter((row) => row.loc === loc);
const germanHref = links.find((row) => row.hreflang === 'de-DE')?.href;
const englishHref = links.find((row) => row.hreflang === 'en')?.href;
expect(germanHref, `${loc} is missing a de-DE alternate`).toBeTruthy();
expect(englishHref, `${loc} is missing an en alternate`).toBeTruthy();
const germanLinks = sitemapAlternates(xml).filter((row) => row.loc === germanHref);
const englishLinks = sitemapAlternates(xml).filter((row) => row.loc === englishHref);
expect(
germanLinks.find((row) => row.hreflang === 'en')?.href,
`${germanHref} must point back to ${englishHref}`,
).toBe(englishHref);
expect(
englishLinks.find((row) => row.hreflang === 'de-DE')?.href,
`${englishHref} must point back to ${germanHref}`,
).toBe(germanHref);
}
}
});
it('lists the required canonical URLs in both locales in llms.txt', () => {
const text = readPublic('llms.txt');
const required = [
absoluteUrl(routePath('home', 'de')),
absoluteUrl(routePath('home', 'en')),
absoluteUrl(routePath('about', 'de')),
absoluteUrl(routePath('about', 'en')),
absoluteUrl(routePath('services', 'de')),
absoluteUrl(routePath('services', 'en')),
absoluteUrl(routePath('servicesSoftware', 'de')),
absoluteUrl(routePath('servicesSoftware', 'en')),
absoluteUrl(routePath('servicesHardwareNetwork', 'de')),
absoluteUrl(routePath('servicesHardwareNetwork', 'en')),
absoluteUrl(routePath('servicesClusters', 'de')),
absoluteUrl(routePath('servicesClusters', 'en')),
absoluteUrl(routePath('servicesAi', 'de')),
absoluteUrl(routePath('servicesAi', 'en')),
absoluteUrl(routePath('projects', 'de')),
absoluteUrl(routePath('projects', 'en')),
...CASE_STUDY_IDS.flatMap((id) => [
`${absoluteUrl(routePath('projects', 'de'))}#${id}`,
`${absoluteUrl(routePath('projects', 'en'))}#${id}`,
]),
absoluteUrl(routePath('contact', 'de')),
absoluteUrl(routePath('contact', 'en')),
absoluteUrl(routePath('stack', 'de')),
absoluteUrl(routePath('stack', 'en')),
absoluteUrl(routePath('imprint', 'de')),
absoluteUrl(routePath('imprint', 'en')),
absoluteUrl(routePath('privacy', 'de')),
absoluteUrl(routePath('privacy', 'en')),
];
for (const url of required) {
expect(text.includes(url), `llms.txt is missing ${url}`).toBe(true);
}
});
it('points robots.txt at the sitemap on the canonical origin', () => {
const robots = readPublic('robots.txt');
expect(robots, 'robots.txt must reference the sitemap at SITE_CONFIG.siteOrigin').toContain(
`Sitemap: ${SITE_CONFIG.siteOrigin}/sitemap.xml`,
);
expect(robots).toMatch(/User-agent:\s*\*/);
expect(robots).not.toMatch(/Disallow:\s+\S+/);
});
it('uses only SITE_CONFIG.siteOrigin and never names excluded stations', () => {
for (const name of ['robots.txt', 'sitemap.xml', 'llms.txt'] as const) {
const text = readPublic(name);
const origins = siteOriginsIn(text);
const unexpected = origins.filter((origin) => origin !== SITE_CONFIG.siteOrigin);
expect(unexpected, `${name} contains an origin other than ${SITE_CONFIG.siteOrigin}`).toEqual(
[],
);
expect(EXCLUDED.test(text), `${name} contains an excluded station name`).toBe(false);
}
});
});

View File

@@ -0,0 +1,69 @@
import { SITE_CONTENT_DATA } from '../content/site-content';
import { SITE_CONFIG } from '../content/site-config';
import { APP_LOCALES } from '../i18n/locale';
import { ROUTE_IDS } from '../routing/route-ids';
import { routePath } from '../routing/route-paths';
import { buildRouteMetadata, OG_LOCALE } from './route-metadata';
describe('buildRouteMetadata', () => {
it('derives title, description, canonical, alternates, Open Graph and Twitter for every route', () => {
for (const locale of APP_LOCALES) {
for (const routeId of ROUTE_IDS) {
const page = SITE_CONTENT_DATA[locale].pages[routeId];
const metadata = buildRouteMetadata(routeId, locale, SITE_CONTENT_DATA);
const other = locale === 'de' ? 'en' : 'de';
expect(metadata.title, `${locale}.${routeId} title`).toBe(page.title);
expect(metadata.description, `${locale}.${routeId} description`).toBe(page.description);
expect(metadata.openGraph.type).toBe('website');
expect(metadata.openGraph.title).toBe(page.title);
expect(metadata.openGraph.description).toBe(page.description);
expect(metadata.openGraph.siteName).toBe(SITE_CONFIG.personName);
expect(metadata.openGraph.locale).toBe(OG_LOCALE[locale]);
expect(metadata.openGraph.localeAlternate).toBe(OG_LOCALE[other]);
expect(metadata.twitter.card).toBe('summary');
expect(metadata.twitter.title).toBe(page.title);
expect(metadata.twitter.description).toBe(page.description);
if (routeId === 'notFound') {
expect(metadata.canonical, `${locale}.notFound must have no canonical`).toBeNull();
expect(metadata.alternates, `${locale}.notFound must have no alternates`).toEqual([]);
expect(metadata.openGraph.url).toBeNull();
expect(metadata.robots).toBe('noindex, follow');
continue;
}
const expectedCanonical = `${SITE_CONFIG.siteOrigin}${routePath(routeId, locale)}`;
expect(metadata.canonical, `${locale}.${routeId} canonical`).toBe(expectedCanonical);
expect(metadata.openGraph.url).toBe(expectedCanonical);
expect(metadata.robots).toBe('index, follow');
const byLang = Object.fromEntries(
metadata.alternates.map((entry) => [entry.hreflang, entry.href]),
);
expect(Object.keys(byLang).sort()).toEqual(['de-DE', 'en', 'x-default']);
expect(byLang['de-DE']).toBe(`${SITE_CONFIG.siteOrigin}${routePath(routeId, 'de')}`);
expect(byLang['en']).toBe(`${SITE_CONFIG.siteOrigin}${routePath(routeId, 'en')}`);
expect(byLang['x-default']).toBe(byLang['de-DE']);
}
}
});
it('keeps German and English alternates reciprocal for every public route', () => {
for (const routeId of ROUTE_IDS.filter((id) => id !== 'notFound')) {
const german = buildRouteMetadata(routeId, 'de', SITE_CONTENT_DATA);
const english = buildRouteMetadata(routeId, 'en', SITE_CONTENT_DATA);
const germanHref = german.alternates.find((entry) => entry.hreflang === 'de-DE')?.href;
const englishHref = german.alternates.find((entry) => entry.hreflang === 'en')?.href;
expect(english.alternates.find((entry) => entry.hreflang === 'de-DE')?.href).toBe(germanHref);
expect(english.alternates.find((entry) => entry.hreflang === 'en')?.href).toBe(englishHref);
expect(german.alternates.find((entry) => entry.hreflang === 'x-default')?.href).toBe(
germanHref,
);
expect(english.alternates.find((entry) => entry.hreflang === 'x-default')?.href).toBe(
germanHref,
);
}
});
});

View File

@@ -0,0 +1,61 @@
import { type SiteContent } from '../content/content.contracts';
import { SITE_CONFIG } from '../content/site-config';
import { type AppLocale } from '../i18n/locale';
import { otherLocale } from '../i18n/locale';
import { type RouteId } from '../routing/route-ids';
import { routePath } from '../routing/route-paths';
import { type RouteMetadata } from './seo.contracts';
export const OG_LOCALE: Record<AppLocale, string> = {
de: 'de_DE',
en: 'en_US',
};
export function absoluteUrl(path: string): string {
return `${SITE_CONFIG.siteOrigin}${path === '/' ? '/' : path}`;
}
export function canonicalUrl(routeId: RouteId, locale: AppLocale): string {
return absoluteUrl(routePath(routeId, locale));
}
export function buildRouteMetadata(
routeId: RouteId,
locale: AppLocale,
content: Record<AppLocale, SiteContent>,
): RouteMetadata {
const page = content[locale].pages[routeId];
const title = page.title;
const description = page.description;
const isNotFound = routeId === 'notFound';
const canonical = isNotFound ? null : canonicalUrl(routeId, locale);
const alternateLocale = otherLocale(locale);
return {
title,
description,
canonical,
alternates: isNotFound
? []
: [
{ hreflang: 'de-DE', href: canonicalUrl(routeId, 'de') },
{ hreflang: 'en', href: canonicalUrl(routeId, 'en') },
{ hreflang: 'x-default', href: canonicalUrl(routeId, 'de') },
],
openGraph: {
type: 'website',
title,
description,
url: canonical,
siteName: SITE_CONFIG.personName,
locale: OG_LOCALE[locale],
localeAlternate: OG_LOCALE[alternateLocale],
},
twitter: {
card: 'summary',
title,
description,
},
robots: isNotFound ? 'noindex, follow' : 'index, follow',
};
}

View File

@@ -0,0 +1,30 @@
import { inject, Injectable } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { RouterStateSnapshot, TitleStrategy } from '@angular/router';
import { SITE_CONTENT } from '../content/content.token';
import { isAppRouteData } from '../routing/app-route-data';
import { buildRouteMetadata } from './route-metadata';
import { SeoService } from './seo.service';
@Injectable()
export class SeoTitleStrategy extends TitleStrategy {
private readonly title = inject(Title);
private readonly seo = inject(SeoService);
private readonly content = inject(SITE_CONTENT);
override updateTitle(snapshot: RouterStateSnapshot): void {
let current = snapshot.root;
while (current.firstChild) {
current = current.firstChild;
}
if (!isAppRouteData(current.data)) {
return;
}
const metadata = buildRouteMetadata(current.data.routeId, current.data.locale, this.content);
this.title.setTitle(metadata.title);
this.seo.apply(current.data.routeId, current.data.locale);
}
}

View File

@@ -0,0 +1,30 @@
export interface RouteAlternate {
readonly hreflang: string;
readonly href: string;
}
export interface OpenGraphMetadata {
readonly type: 'website';
readonly title: string;
readonly description: string;
readonly url: string | null;
readonly siteName: string;
readonly locale: string;
readonly localeAlternate: string;
}
export interface TwitterMetadata {
readonly card: 'summary';
readonly title: string;
readonly description: string;
}
export interface RouteMetadata {
readonly title: string;
readonly description: string;
readonly canonical: string | null;
readonly alternates: readonly RouteAlternate[];
readonly openGraph: OpenGraphMetadata;
readonly twitter: TwitterMetadata;
readonly robots: string;
}

View File

@@ -0,0 +1,61 @@
import { TestBed } from '@angular/core/testing';
import { Title } from '@angular/platform-browser';
import { SITE_CONTENT } from '../content/content.token';
import { SITE_CONTENT_DATA } from '../content/site-content';
import { SITE_CONFIG } from '../content/site-config';
import { routePath } from '../routing/route-paths';
import { SeoService } from './seo.service';
describe('SeoService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [SeoService, { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
});
});
afterEach(() => {
document.head.querySelectorAll('[data-seo]').forEach((node) => node.remove());
document.head
.querySelectorAll(
'meta[name="description"], meta[name="robots"], meta[name^="twitter:"], meta[property^="og:"]',
)
.forEach((node) => node.remove());
});
it('applies two routes in sequence without duplicating owned head elements', () => {
const seo = TestBed.inject(SeoService);
const title = TestBed.inject(Title);
seo.apply('home', 'de');
seo.apply('projects', 'en');
expect(title.getTitle()).toBe(SITE_CONTENT_DATA.en.pages.projects.title);
expect(document.querySelectorAll('link[rel="canonical"]')).toHaveLength(1);
expect(document.querySelectorAll('link[rel="alternate"][hreflang]')).toHaveLength(3);
expect(document.querySelectorAll('meta[name="description"]')).toHaveLength(1);
expect(document.querySelectorAll('meta[name="robots"]')).toHaveLength(1);
expect(document.querySelectorAll('script[type="application/ld+json"]')).toHaveLength(1);
const canonical = document.querySelector('link[rel="canonical"]')?.getAttribute('href');
expect(canonical).toBe(`${SITE_CONFIG.siteOrigin}${routePath('projects', 'en')}`);
expect(document.querySelector('meta[name="description"]')?.getAttribute('content')).toBe(
SITE_CONTENT_DATA.en.pages.projects.description,
);
expect(document.querySelector('meta[name="robots"]')?.getAttribute('content')).toBe(
'index, follow',
);
});
it('drops canonical and alternate links for notFound and sets noindex', () => {
const seo = TestBed.inject(SeoService);
seo.apply('home', 'de');
seo.apply('notFound', 'de');
expect(document.querySelectorAll('link[rel="canonical"]')).toHaveLength(0);
expect(document.querySelectorAll('link[rel="alternate"][hreflang]')).toHaveLength(0);
expect(document.querySelector('meta[name="robots"]')?.getAttribute('content')).toBe(
'noindex, follow',
);
expect(document.querySelectorAll('script[type="application/ld+json"]')).toHaveLength(1);
});
});

View File

@@ -0,0 +1,116 @@
import { DOCUMENT } from '@angular/common';
import { inject, Injectable } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';
import { SITE_CONTENT } from '../content/content.token';
import { type AppLocale } from '../i18n/locale';
import { type RouteId } from '../routing/route-ids';
import { buildRouteMetadata } from './route-metadata';
import { type RouteMetadata } from './seo.contracts';
import { buildJsonLdGraph, serializeJsonLd } from './structured-data';
const SEO_OWNED = 'data-seo';
@Injectable({ providedIn: 'root' })
export class SeoService {
private readonly document = inject(DOCUMENT);
private readonly title = inject(Title);
private readonly meta = inject(Meta);
private readonly content = inject(SITE_CONTENT);
apply(routeId: RouteId, locale: AppLocale): void {
const metadata = buildRouteMetadata(routeId, locale, this.content);
this.title.setTitle(metadata.title);
this.updateMeta(metadata);
this.replaceOwnedElements(routeId, locale, metadata);
}
private updateMeta(metadata: RouteMetadata): void {
this.meta.updateTag(
{ name: 'description', content: metadata.description },
'name="description"',
);
this.meta.updateTag({ name: 'robots', content: metadata.robots }, 'name="robots"');
this.meta.updateTag(
{ property: 'og:type', content: metadata.openGraph.type },
'property="og:type"',
);
this.meta.updateTag(
{ property: 'og:title', content: metadata.openGraph.title },
'property="og:title"',
);
this.meta.updateTag(
{ property: 'og:description', content: metadata.openGraph.description },
'property="og:description"',
);
this.meta.updateTag(
{ property: 'og:site_name', content: metadata.openGraph.siteName },
'property="og:site_name"',
);
this.meta.updateTag(
{ property: 'og:locale', content: metadata.openGraph.locale },
'property="og:locale"',
);
this.meta.updateTag(
{ property: 'og:locale:alternate', content: metadata.openGraph.localeAlternate },
'property="og:locale:alternate"',
);
this.meta.updateTag(
{ name: 'twitter:card', content: metadata.twitter.card },
'name="twitter:card"',
);
this.meta.updateTag(
{ name: 'twitter:title', content: metadata.twitter.title },
'name="twitter:title"',
);
this.meta.updateTag(
{ name: 'twitter:description', content: metadata.twitter.description },
'name="twitter:description"',
);
if (metadata.openGraph.url) {
this.meta.updateTag(
{ property: 'og:url', content: metadata.openGraph.url },
'property="og:url"',
);
} else {
this.meta.removeTag('property="og:url"');
}
}
private replaceOwnedElements(routeId: RouteId, locale: AppLocale, metadata: RouteMetadata): void {
const head = this.document.head;
for (const owned of Array.from(head.querySelectorAll(`[${SEO_OWNED}]`))) {
owned.remove();
}
if (metadata.canonical) {
this.appendLink(head, { rel: 'canonical', href: metadata.canonical });
}
for (const alternate of metadata.alternates) {
this.appendLink(head, {
rel: 'alternate',
hreflang: alternate.hreflang,
href: alternate.href,
});
}
const script = this.document.createElement('script');
script.type = 'application/ld+json';
script.setAttribute(SEO_OWNED, '');
script.textContent = serializeJsonLd(buildJsonLdGraph(routeId, locale, this.content));
head.appendChild(script);
}
private appendLink(head: HTMLHeadElement, attributes: Record<string, string>): void {
const link = this.document.createElement('link');
link.setAttribute(SEO_OWNED, '');
for (const [name, value] of Object.entries(attributes)) {
link.setAttribute(name, value);
}
head.appendChild(link);
}
}

View File

@@ -0,0 +1,111 @@
import { SITE_CONTENT_DATA } from '../content/site-content';
import { APP_LOCALES } from '../i18n/locale';
import { ROUTE_IDS, type RouteId } from '../routing/route-ids';
import { buildJsonLdGraph, serializeJsonLd } from './structured-data';
const FORBIDDEN_FIELDS = [
'aggregateRating',
'review',
'offers',
'price',
'priceRange',
'worksFor',
'employee',
'sponsor',
'address',
'geo',
'openingHours',
'telephone',
'foundingDate',
'numberOfEmployees',
] as const;
const SERVICE_ROUTES: readonly RouteId[] = [
'services',
'servicesSoftware',
'servicesHardwareNetwork',
'servicesClusters',
'servicesAi',
];
function collectKeys(value: unknown, keys = new Set<string>()): Set<string> {
if (Array.isArray(value)) {
value.forEach((entry) => collectKeys(entry, keys));
return keys;
}
if (value && typeof value === 'object') {
for (const [key, child] of Object.entries(value)) {
keys.add(key);
collectKeys(child, keys);
}
}
return keys;
}
function graphTypes(routeId: RouteId, locale: (typeof APP_LOCALES)[number]): string[] {
const graph = buildJsonLdGraph(routeId, locale, SITE_CONTENT_DATA);
return graph['@graph'].map((node) => {
if (node && typeof node === 'object' && !Array.isArray(node) && '@type' in node) {
return String(node['@type']);
}
return '';
});
}
describe('JSON-LD builders', () => {
it('emits a schema.org @graph with the expected types per route', () => {
for (const locale of APP_LOCALES) {
const home = buildJsonLdGraph('home', locale, SITE_CONTENT_DATA);
expect(home['@context']).toBe('https://schema.org');
expect(graphTypes('home', locale)).toEqual(['Person', 'ProfessionalService']);
for (const routeId of SERVICE_ROUTES) {
expect(graphTypes(routeId, locale), `${locale}.${routeId}`).toEqual(['Service']);
}
expect(graphTypes('projects', locale)).toEqual([
'CreativeWork',
'CreativeWork',
'CreativeWork',
'CreativeWork',
]);
for (const routeId of ROUTE_IDS.filter(
(id) => id !== 'home' && id !== 'projects' && !SERVICE_ROUTES.includes(id),
)) {
expect(graphTypes(routeId, locale), `${locale}.${routeId}`).toEqual(['WebPage']);
}
}
});
it('escapes <, > and & inside the serialized payload', () => {
const serialized = serializeJsonLd({
name: 'A <script>alert(1)</script> & more',
});
expect(serialized).toContain('\\u003c');
expect(serialized).toContain('\\u003e');
expect(serialized).toContain('\\u0026');
expect(serialized).not.toContain('<');
expect(serialized).not.toContain('>');
expect(serialized).not.toContain('&');
});
it('never invents forbidden fields or excluded station names', () => {
for (const locale of APP_LOCALES) {
for (const routeId of ROUTE_IDS) {
const graph = buildJsonLdGraph(routeId, locale, SITE_CONTENT_DATA);
const keys = collectKeys(graph);
for (const field of FORBIDDEN_FIELDS) {
expect(keys.has(field), `${locale}.${routeId} contains ${field}`).toBe(false);
}
const serialized = serializeJsonLd(graph);
expect(serialized).not.toMatch(/HUP|BitWiz|Cybertrading/);
}
}
});
});

View File

@@ -0,0 +1,149 @@
import { CASE_STUDY_IDS, type SiteContent } from '../content/content.contracts';
import { SITE_CONFIG } from '../content/site-config';
import { LOCALE_HTML_LANG, type AppLocale } from '../i18n/locale';
import { type RouteId } from '../routing/route-ids';
import { canonicalUrl } from './route-metadata';
const SCHEMA_CONTEXT = 'https://schema.org';
const SERVICE_ROUTE_IDS: readonly RouteId[] = [
'services',
'servicesSoftware',
'servicesHardwareNetwork',
'servicesClusters',
'servicesAi',
];
export type JsonLdValue =
| string
| number
| boolean
| null
| readonly JsonLdValue[]
| { readonly [key: string]: JsonLdValue };
export interface JsonLdGraph {
readonly '@context': typeof SCHEMA_CONTEXT;
readonly '@graph': readonly JsonLdValue[];
}
function personNode(locale: AppLocale, content: Record<AppLocale, SiteContent>): JsonLdValue {
return {
'@type': 'Person',
name: SITE_CONFIG.personName,
jobTitle: content[locale].seo.jobTitle,
email: SITE_CONFIG.contactEmail,
url: canonicalUrl('home', locale),
description: content[locale].pages.home.description,
};
}
function professionalServiceNode(
locale: AppLocale,
content: Record<AppLocale, SiteContent>,
): JsonLdValue {
const seo = content[locale].seo;
return {
'@type': 'ProfessionalService',
name: seo.professionalServiceName,
description: seo.professionalServiceDescription,
url: canonicalUrl('home', locale),
provider: {
'@type': 'Person',
name: SITE_CONFIG.personName,
},
};
}
function serviceNode(
routeId: RouteId,
locale: AppLocale,
content: Record<AppLocale, SiteContent>,
): JsonLdValue {
const page = content[locale].pages[routeId];
return {
'@type': 'Service',
name: page.hero.headline,
description: page.description,
url: canonicalUrl(routeId, locale),
provider: {
'@type': 'Person',
name: SITE_CONFIG.personName,
},
};
}
function projectWorks(
locale: AppLocale,
content: Record<AppLocale, SiteContent>,
): readonly JsonLdValue[] {
const projectsUrl = canonicalUrl('projects', locale);
return CASE_STUDY_IDS.map((caseId) => {
const study = content[locale].cases[caseId];
return {
'@type': 'CreativeWork',
name: study.headline,
description: study.summary,
url: `${projectsUrl}#${caseId}`,
};
});
}
function webPageNode(
routeId: RouteId,
locale: AppLocale,
content: Record<AppLocale, SiteContent>,
): JsonLdValue {
const page = content[locale].pages[routeId];
const node: { [key: string]: JsonLdValue } = {
'@type': 'WebPage',
name: page.title,
description: page.description,
inLanguage: LOCALE_HTML_LANG[locale],
isPartOf: {
'@type': 'WebSite',
name: SITE_CONFIG.personName,
url: SITE_CONFIG.siteOrigin + '/',
},
};
if (routeId !== 'notFound') {
node['url'] = canonicalUrl(routeId, locale);
}
return node;
}
export function buildJsonLdGraph(
routeId: RouteId,
locale: AppLocale,
content: Record<AppLocale, SiteContent>,
): JsonLdGraph {
let graph: readonly JsonLdValue[];
if (routeId === 'home') {
graph = [personNode(locale, content), professionalServiceNode(locale, content)];
} else if (SERVICE_ROUTE_IDS.includes(routeId)) {
graph = [serviceNode(routeId, locale, content)];
} else if (routeId === 'projects') {
graph = projectWorks(locale, content);
} else {
graph = [webPageNode(routeId, locale, content)];
}
return {
'@context': SCHEMA_CONTEXT,
'@graph': graph,
};
}
export function serializeJsonLd(value: unknown): string {
return JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
}