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:
51
public/llms.txt
Normal file
51
public/llms.txt
Normal file
@@ -0,0 +1,51 @@
|
||||
# Antonio Ledebuhr
|
||||
|
||||
Fullstack and DevOps engineer in Tangermünde. The public site documents a curated set of stations, four service areas, and a local mailto briefing. German is the default language; English is the full second version.
|
||||
|
||||
## Profile
|
||||
|
||||
- https://antoniolede.de/
|
||||
- https://antoniolede.de/en
|
||||
- https://antoniolede.de/ueber-mich
|
||||
- https://antoniolede.de/en/about
|
||||
|
||||
## Services
|
||||
|
||||
- https://antoniolede.de/leistungen
|
||||
- https://antoniolede.de/en/services
|
||||
- https://antoniolede.de/leistungen/software
|
||||
- https://antoniolede.de/en/services/software
|
||||
- https://antoniolede.de/leistungen/hardware-netzwerk
|
||||
- https://antoniolede.de/en/services/hardware-network
|
||||
- https://antoniolede.de/leistungen/cluster
|
||||
- https://antoniolede.de/en/services/clusters
|
||||
- https://antoniolede.de/leistungen/ai-integration
|
||||
- https://antoniolede.de/en/services/ai-integration
|
||||
|
||||
## Projects
|
||||
|
||||
- https://antoniolede.de/projekte
|
||||
- https://antoniolede.de/en/projects
|
||||
- https://antoniolede.de/projekte#innofocus
|
||||
- https://antoniolede.de/en/projects#innofocus
|
||||
- https://antoniolede.de/projekte#roesterei
|
||||
- https://antoniolede.de/en/projects#roesterei
|
||||
- https://antoniolede.de/projekte#myspa
|
||||
- https://antoniolede.de/en/projects#myspa
|
||||
- https://antoniolede.de/projekte#hdi
|
||||
- https://antoniolede.de/en/projects#hdi
|
||||
|
||||
## Contact, stack and legal
|
||||
|
||||
- https://antoniolede.de/kontakt
|
||||
- https://antoniolede.de/en/contact
|
||||
- https://antoniolede.de/stack
|
||||
- https://antoniolede.de/en/stack
|
||||
- https://antoniolede.de/impressum
|
||||
- https://antoniolede.de/en/legal-notice
|
||||
- https://antoniolede.de/datenschutz
|
||||
- https://antoniolede.de/en/privacy
|
||||
|
||||
## Languages
|
||||
|
||||
German URLs are unprefixed. English URLs live under `/en`. Each public page exists in both locales.
|
||||
4
public/robots.txt
Normal file
4
public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://antoniolede.de/sitemap.xml
|
||||
147
public/sitemap.xml
Normal file
147
public/sitemap.xml
Normal file
@@ -0,0 +1,147 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||
<url>
|
||||
<loc>https://antoniolede.de/</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/leistungen</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/leistungen/software</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/software" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/software" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/software" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/leistungen/hardware-netzwerk</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/hardware-netzwerk" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/hardware-network" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/hardware-netzwerk" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/leistungen/cluster</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/cluster" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/clusters" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/cluster" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/leistungen/ai-integration</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/ai-integration" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/ai-integration" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/ai-integration" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/projekte</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/projekte" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/projects" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/projekte" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/stack</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/stack" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/stack" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/stack" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/ueber-mich</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/ueber-mich" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/about" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/ueber-mich" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/kontakt</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/kontakt" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/contact" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/kontakt" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/impressum</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/impressum" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/legal-notice" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/impressum" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/datenschutz</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/datenschutz" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/privacy" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/datenschutz" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/en</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/en/services</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/en/services/software</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/software" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/software" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/software" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/en/services/hardware-network</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/hardware-netzwerk" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/hardware-network" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/hardware-netzwerk" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/en/services/clusters</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/cluster" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/clusters" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/cluster" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/en/services/ai-integration</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/leistungen/ai-integration" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/services/ai-integration" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/leistungen/ai-integration" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/en/projects</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/projekte" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/projects" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/projekte" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/en/stack</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/stack" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/stack" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/stack" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/en/about</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/ueber-mich" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/about" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/ueber-mich" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/en/contact</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/kontakt" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/contact" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/kontakt" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/en/legal-notice</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/impressum" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/legal-notice" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/impressum" />
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://antoniolede.de/en/privacy</loc>
|
||||
<xhtml:link rel="alternate" hreflang="de-DE" href="https://antoniolede.de/datenschutz" />
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://antoniolede.de/en/privacy" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://antoniolede.de/datenschutz" />
|
||||
</url>
|
||||
</urlset>
|
||||
@@ -1,9 +1,15 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter, withComponentInputBinding, withInMemoryScrolling } from '@angular/router';
|
||||
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
|
||||
import {
|
||||
provideRouter,
|
||||
TitleStrategy,
|
||||
withComponentInputBinding,
|
||||
withInMemoryScrolling,
|
||||
} from '@angular/router';
|
||||
import { routes } from './app.routes';
|
||||
import { SITE_CONTENT } from './core/content/content.token';
|
||||
import { SITE_CONTENT_DATA } from './core/content/site-content';
|
||||
import { routes } from './app.routes';
|
||||
import { SeoTitleStrategy } from './core/seo/seo-title.strategy';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
@@ -18,5 +24,6 @@ export const appConfig: ApplicationConfig = {
|
||||
),
|
||||
provideClientHydration(withEventReplay()),
|
||||
{ provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA },
|
||||
{ provide: TitleStrategy, useClass: SeoTitleStrategy },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<a class="skip-link" href="#main-content">{{ shell().skipLink }}</a>
|
||||
<app-dot-background aria-hidden="true"></app-dot-background>
|
||||
<div class="site" [class.nav-collapsed]="!navOpen()">
|
||||
<div class="site" [class.nav-collapsed]="!navOpen()" [attr.inert]="palette.open() ? '' : null">
|
||||
<header class="site-header">
|
||||
<div class="content-container site-header-inner glass-surface">
|
||||
<a class="site-identity" [routerLink]="navigation.link('home')">
|
||||
{{ siteConfig.personName }}
|
||||
</a>
|
||||
<div class="site-toolbar cluster">
|
||||
<app-command-palette-trigger></app-command-palette-trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="nav-toggle"
|
||||
@@ -16,6 +18,7 @@
|
||||
>
|
||||
<span aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="site-nav" [attr.aria-label]="shell().primaryNavLabel">
|
||||
<ul class="primary-nav" id="primary-nav">
|
||||
@for (item of navigation.primaryNav(); track item.routeId) {
|
||||
@@ -47,7 +50,6 @@
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="site-actions cluster">
|
||||
<app-command-palette></app-command-palette>
|
||||
<a
|
||||
class="language-switch"
|
||||
[routerLink]="navigation.alternateLocaleLink()"
|
||||
@@ -92,3 +94,4 @@
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<app-command-palette></app-command-palette>
|
||||
|
||||
@@ -29,10 +29,6 @@
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.site-actions app-command-palette {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.site-identity,
|
||||
.site-nav a,
|
||||
.site-actions a,
|
||||
@@ -41,14 +37,34 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.site-identity,
|
||||
.site-nav a,
|
||||
.site-actions a,
|
||||
.site-footer a,
|
||||
.nav-toggle {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
.site-identity,
|
||||
.site-nav a,
|
||||
.site-actions a,
|
||||
.site-footer a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.site-identity {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-toggle {
|
||||
.site-toolbar {
|
||||
justify-self: end;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-toggle {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
@@ -120,7 +136,11 @@
|
||||
|
||||
.site-main {
|
||||
flex: 1;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.site-main:focus-visible {
|
||||
outline: var(--focus-ring-width) solid var(--focus-ring-color);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
@@ -142,7 +162,7 @@
|
||||
}
|
||||
|
||||
.site-header-inner {
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-columns: auto 1fr auto auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -154,6 +174,14 @@
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.site-toolbar {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.site-actions {
|
||||
order: 4;
|
||||
}
|
||||
|
||||
.primary-nav {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { ApplicationRef } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideRouter, TitleStrategy } from '@angular/router';
|
||||
import { App } from './app';
|
||||
import { routes } from './app.routes';
|
||||
import { SITE_CONTENT } from './core/content/content.token';
|
||||
import { SITE_CONTENT_DATA } from './core/content/site-content';
|
||||
import { SeoTitleStrategy } from './core/seo/seo-title.strategy';
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(async () => {
|
||||
@@ -11,7 +13,11 @@ describe('App', () => {
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
providers: [
|
||||
provideRouter(routes),
|
||||
{ provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA },
|
||||
{ provide: TitleStrategy, useClass: SeoTitleStrategy },
|
||||
],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
@@ -43,4 +49,27 @@ describe('App', () => {
|
||||
expect(compiled.querySelector('a.language-switch[hreflang]')).toBeTruthy();
|
||||
expect(compiled.querySelector('footer')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps the palette trigger in the header and the dialog outside .site', async () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
await fixture.whenStable();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const site = compiled.querySelector('.site');
|
||||
const trigger = compiled.querySelector('header .command-palette-trigger');
|
||||
|
||||
expect(site).toBeTruthy();
|
||||
expect(trigger).toBeTruthy();
|
||||
expect(site?.contains(trigger)).toBe(true);
|
||||
|
||||
(trigger as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
TestBed.inject(ApplicationRef).tick();
|
||||
fixture.detectChanges();
|
||||
|
||||
const dialog = compiled.querySelector('[role="dialog"]');
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(site?.contains(dialog)).toBe(false);
|
||||
expect(site?.hasAttribute('inert')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,17 +7,27 @@ import { LOCALE_HTML_LANG, otherLocale } from './core/i18n/locale';
|
||||
import { LocaleService } from './core/i18n/locale.service';
|
||||
import { NavigationService } from './core/navigation/navigation.service';
|
||||
import { CommandPalette } from './shared/command-palette/command-palette';
|
||||
import { CommandPaletteTrigger } from './shared/command-palette/command-palette-trigger/command-palette-trigger';
|
||||
import { CommandPaletteService } from './shared/command-palette/command-palette.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterOutlet, RouterLink, RouterLinkActive, DotBackground, CommandPalette],
|
||||
imports: [
|
||||
RouterOutlet,
|
||||
RouterLink,
|
||||
RouterLinkActive,
|
||||
DotBackground,
|
||||
CommandPalette,
|
||||
CommandPaletteTrigger,
|
||||
],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss',
|
||||
})
|
||||
export class App {
|
||||
protected readonly navigation = inject(NavigationService);
|
||||
protected readonly localeService = inject(LocaleService);
|
||||
protected readonly palette = inject(CommandPaletteService);
|
||||
protected readonly siteConfig = SITE_CONFIG;
|
||||
protected readonly navOpen = signal(true);
|
||||
|
||||
|
||||
24
src/app/core/commands/command-ids.ts
Normal file
24
src/app/core/commands/command-ids.ts
Normal 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',
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
169
src/app/core/seo/crawl-assets.spec.ts
Normal file
169
src/app/core/seo/crawl-assets.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
69
src/app/core/seo/route-metadata.spec.ts
Normal file
69
src/app/core/seo/route-metadata.spec.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
61
src/app/core/seo/route-metadata.ts
Normal file
61
src/app/core/seo/route-metadata.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
30
src/app/core/seo/seo-title.strategy.ts
Normal file
30
src/app/core/seo/seo-title.strategy.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
30
src/app/core/seo/seo.contracts.ts
Normal file
30
src/app/core/seo/seo.contracts.ts
Normal 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;
|
||||
}
|
||||
61
src/app/core/seo/seo.service.spec.ts
Normal file
61
src/app/core/seo/seo.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
116
src/app/core/seo/seo.service.ts
Normal file
116
src/app/core/seo/seo.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
111
src/app/core/seo/structured-data.spec.ts
Normal file
111
src/app/core/seo/structured-data.spec.ts
Normal 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/);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
149
src/app/core/seo/structured-data.ts
Normal file
149
src/app/core/seo/structured-data.ts
Normal 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');
|
||||
}
|
||||
@@ -10,9 +10,16 @@
|
||||
}
|
||||
</ul>
|
||||
<app-metric-list [metrics]="copy.metrics" [labelledBy]="'section-profile'" />
|
||||
<div appReveal class="home-reveal">
|
||||
<app-systems-map
|
||||
[heading]="copy.systemsMap.heading"
|
||||
[intro]="copy.systemsMap.intro"
|
||||
[headingLevel]="2"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
@if (section.id === 'featured-cases') {
|
||||
<div class="stack">
|
||||
<div appReveal class="home-reveal stack">
|
||||
@for (caseStudy of featuredCases(); track caseStudy.id) {
|
||||
<app-case-card [caseStudy]="caseStudy" />
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
@use '../../shared/motion/reveal';
|
||||
@use '../../shared/page-shell';
|
||||
|
||||
.home-reveal {
|
||||
@include reveal.reveal-target;
|
||||
}
|
||||
|
||||
.page h2 {
|
||||
margin: 0;
|
||||
font-size: var(--text-xl);
|
||||
|
||||
@@ -4,12 +4,14 @@ import { CaseCard } from '../../shared/case-card/case-card';
|
||||
import { ContentSection } from '../../shared/content-section/content-section';
|
||||
import { CtaRow } from '../../shared/cta-row/cta-row';
|
||||
import { MetricList } from '../../shared/metric-list/metric-list';
|
||||
import { RevealDirective } from '../../shared/motion/reveal.directive';
|
||||
import { PageHero } from '../../shared/page-hero/page-hero';
|
||||
import { SystemsMap } from '../../shared/systems-map/systems-map';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [PageHero, ContentSection, MetricList, CaseCard, CtaRow],
|
||||
imports: [PageHero, ContentSection, MetricList, CaseCard, CtaRow, SystemsMap, RevealDirective],
|
||||
templateUrl: './home.html',
|
||||
styleUrl: './home.scss',
|
||||
})
|
||||
|
||||
@@ -98,6 +98,33 @@ describe('page rendering, semantics and accessibility', () => {
|
||||
expect(SITE_CONTENT_DATA.de.pages.notFound.hero.headline).toBeTruthy();
|
||||
});
|
||||
|
||||
it('places the Systems Map between the profile block and the featured cases', async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
});
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
|
||||
for (const path of ['/', '/en']) {
|
||||
await harness.navigateByUrl(path);
|
||||
const root = harness.routeNativeElement as HTMLElement;
|
||||
const metrics = root.querySelector('app-metric-list');
|
||||
const map = root.querySelector('app-systems-map');
|
||||
const firstCase = root.querySelector('app-case-card');
|
||||
|
||||
expect(metrics).toBeTruthy();
|
||||
expect(map).toBeTruthy();
|
||||
expect(firstCase).toBeTruthy();
|
||||
|
||||
const position = metrics!.compareDocumentPosition(map!);
|
||||
expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
const afterMap = map!.compareDocumentPosition(firstCase!);
|
||||
expect(afterMap & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
|
||||
assertPageSemantics(root);
|
||||
}
|
||||
});
|
||||
|
||||
it('sends the software service case CTA to the innofocus project anchor', async () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter(routes), { provide: SITE_CONTENT, useValue: SITE_CONTENT_DATA }],
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<button
|
||||
#trigger
|
||||
type="button"
|
||||
class="command-palette-trigger"
|
||||
aria-haspopup="dialog"
|
||||
[attr.aria-expanded]="open()"
|
||||
[attr.aria-controls]="open() ? dialogId : null"
|
||||
[attr.aria-label]="copy().triggerLabel"
|
||||
(click)="onTriggerClick()"
|
||||
>
|
||||
<span>{{ copy().triggerLabel }}</span>
|
||||
<kbd>{{ shortcutHint() }}</kbd>
|
||||
</button>
|
||||
@@ -0,0 +1,32 @@
|
||||
:host {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.command-palette-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 2.75rem;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.command-palette-trigger kbd {
|
||||
padding: 0.1rem var(--space-2);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.command-palette-trigger:hover {
|
||||
border-color: var(--surface-glass-border-strong);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
afterNextRender,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
inject,
|
||||
Injector,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { SIGNATURE_COPY } from '../../../core/content/signature-copy';
|
||||
import { LocaleService } from '../../../core/i18n/locale.service';
|
||||
import { isApplePlatform, isBrowserPlatform } from '../../../core/platform/browser';
|
||||
import { CommandPaletteService } from '../command-palette.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-command-palette-trigger',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './command-palette-trigger.html',
|
||||
styleUrl: './command-palette-trigger.scss',
|
||||
})
|
||||
export class CommandPaletteTrigger {
|
||||
private readonly palette = inject(CommandPaletteService);
|
||||
private readonly localeService = inject(LocaleService);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly isBrowser = isBrowserPlatform();
|
||||
private readonly applePlatform = isApplePlatform();
|
||||
|
||||
protected readonly triggerRef = viewChild<ElementRef<HTMLButtonElement>>('trigger');
|
||||
protected readonly useAppleHint = signal(false);
|
||||
protected readonly copy = computed(() => SIGNATURE_COPY[this.localeService.locale()].palette);
|
||||
protected readonly shortcutHint = computed(() =>
|
||||
this.useAppleHint() ? this.copy().shortcutHintApple : this.copy().shortcutHint,
|
||||
);
|
||||
protected readonly open = this.palette.open;
|
||||
protected readonly dialogId = this.palette.dialogId;
|
||||
|
||||
constructor() {
|
||||
afterNextRender(
|
||||
() => {
|
||||
if (this.isBrowser) {
|
||||
this.useAppleHint.set(this.applePlatform);
|
||||
}
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
}
|
||||
|
||||
protected onTriggerClick(): void {
|
||||
const trigger = this.triggerRef()?.nativeElement;
|
||||
trigger?.focus();
|
||||
this.palette.openPalette(trigger ?? null);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,3 @@
|
||||
<button
|
||||
#trigger
|
||||
type="button"
|
||||
class="command-palette-trigger"
|
||||
aria-haspopup="dialog"
|
||||
[attr.aria-expanded]="open()"
|
||||
[attr.aria-controls]="dialogId"
|
||||
[attr.aria-label]="copy().triggerLabel"
|
||||
(click)="onTriggerClick()"
|
||||
>
|
||||
<span>{{ copy().triggerLabel }}</span>
|
||||
<kbd>{{ copy().shortcutHint }}</kbd>
|
||||
</button>
|
||||
|
||||
@if (open()) {
|
||||
<div class="command-palette-overlay">
|
||||
<div class="command-palette-scrim" aria-hidden="true"></div>
|
||||
|
||||
@@ -1,28 +1,5 @@
|
||||
:host {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.command-palette-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 2.75rem;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.command-palette-trigger kbd {
|
||||
padding: 0.1rem var(--space-2);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.command-palette-overlay {
|
||||
@@ -153,7 +130,6 @@
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.command-palette-trigger:hover,
|
||||
.command-palette-close:hover,
|
||||
.command-palette-suggestions button:hover {
|
||||
border-color: var(--surface-glass-border-strong);
|
||||
|
||||
115
src/app/shared/command-palette/command-palette.service.ts
Normal file
115
src/app/shared/command-palette/command-palette.service.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
|
||||
import {
|
||||
afterNextRender,
|
||||
DestroyRef,
|
||||
inject,
|
||||
Injectable,
|
||||
Injector,
|
||||
PLATFORM_ID,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CommandPaletteService {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||
|
||||
readonly open = signal(false);
|
||||
readonly dialogId = 'command-palette-dialog';
|
||||
readonly titleId = 'command-palette-title';
|
||||
readonly descriptionId = 'command-palette-description';
|
||||
readonly inputId = 'command-palette-input';
|
||||
readonly suggestionsId = 'command-palette-suggestions';
|
||||
|
||||
private opener: HTMLElement | null = null;
|
||||
private previousOverflow = '';
|
||||
private scrollLocked = false;
|
||||
|
||||
constructor() {
|
||||
afterNextRender(
|
||||
() => {
|
||||
if (!this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.document.addEventListener('keydown', this.onDocumentKeydown);
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
|
||||
this.destroyRef.onDestroy(() => this.teardown());
|
||||
}
|
||||
|
||||
openPalette(opener?: HTMLElement | null): void {
|
||||
if (this.open()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const active = opener ?? this.document.activeElement;
|
||||
this.opener = active instanceof HTMLElement ? active : null;
|
||||
this.lockScroll();
|
||||
this.open.set(true);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (!this.open()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.open.set(false);
|
||||
this.unlockScroll();
|
||||
this.restoreFocus();
|
||||
}
|
||||
|
||||
private restoreFocus(): void {
|
||||
const opener = this.opener;
|
||||
this.opener = null;
|
||||
|
||||
afterNextRender(
|
||||
() => {
|
||||
if (opener instanceof HTMLElement && opener.isConnected) {
|
||||
opener.focus();
|
||||
}
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
}
|
||||
|
||||
private lockScroll(): void {
|
||||
if (!this.isBrowser || this.scrollLocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.previousOverflow = this.document.body.style.overflow;
|
||||
this.document.body.style.overflow = 'hidden';
|
||||
this.scrollLocked = true;
|
||||
}
|
||||
|
||||
private unlockScroll(): void {
|
||||
if (!this.isBrowser || !this.scrollLocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.document.body.style.overflow = this.previousOverflow;
|
||||
this.previousOverflow = '';
|
||||
this.scrollLocked = false;
|
||||
}
|
||||
|
||||
private teardown(): void {
|
||||
this.document.removeEventListener('keydown', this.onDocumentKeydown);
|
||||
this.unlockScroll();
|
||||
this.open.set(false);
|
||||
this.opener = null;
|
||||
}
|
||||
|
||||
private readonly onDocumentKeydown = (event: KeyboardEvent): void => {
|
||||
if (event.key.toLowerCase() !== 'k' || !(event.ctrlKey || event.metaKey) || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.openPalette();
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { ApplicationRef, PLATFORM_ID } from '@angular/core';
|
||||
import { ApplicationRef, Component, inject, PLATFORM_ID } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { SIGNATURE_COPY } from '../../core/content/signature-copy';
|
||||
@@ -8,31 +8,48 @@ import { APP_LOCALES, type AppLocale } from '../../core/i18n/locale';
|
||||
import { LocaleService } from '../../core/i18n/locale.service';
|
||||
import { NavigationService } from '../../core/navigation/navigation.service';
|
||||
import { CommandPalette } from './command-palette';
|
||||
import { CommandPaletteTrigger } from './command-palette-trigger/command-palette-trigger';
|
||||
import { CommandPaletteService } from './command-palette.service';
|
||||
import { COMMAND_IDS, COMMANDS } from './commands';
|
||||
|
||||
@Component({
|
||||
selector: 'app-palette-host',
|
||||
imports: [CommandPaletteTrigger, CommandPalette],
|
||||
template: `
|
||||
<div class="site" [attr.inert]="palette.open() ? '' : null">
|
||||
<app-command-palette-trigger></app-command-palette-trigger>
|
||||
</div>
|
||||
<app-command-palette></app-command-palette>
|
||||
`,
|
||||
})
|
||||
class PaletteHost {
|
||||
readonly palette = inject(CommandPaletteService);
|
||||
}
|
||||
|
||||
describe('CommandPalette', () => {
|
||||
afterEach(() => {
|
||||
document.body.style.overflow = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function createFixture(
|
||||
locale: AppLocale = 'de',
|
||||
extraProviders: { provide: unknown; useValue: unknown }[] = [],
|
||||
): Promise<ComponentFixture<CommandPalette>> {
|
||||
): Promise<ComponentFixture<PaletteHost>> {
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CommandPalette],
|
||||
imports: [PaletteHost],
|
||||
providers: [provideRouter([]), ...extraProviders],
|
||||
}).compileComponents();
|
||||
|
||||
TestBed.inject(LocaleService).setLocale(locale);
|
||||
const fixture = TestBed.createComponent(CommandPalette);
|
||||
const fixture = TestBed.createComponent(PaletteHost);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
async function flush(fixture: ComponentFixture<CommandPalette>): Promise<void> {
|
||||
async function flush(fixture: ComponentFixture<PaletteHost>): Promise<void> {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
TestBed.inject(ApplicationRef).tick();
|
||||
@@ -40,23 +57,24 @@ describe('CommandPalette', () => {
|
||||
await fixture.whenStable();
|
||||
}
|
||||
|
||||
function trigger(fixture: ComponentFixture<CommandPalette>): HTMLButtonElement {
|
||||
function trigger(fixture: ComponentFixture<PaletteHost>): HTMLButtonElement {
|
||||
return fixture.nativeElement.querySelector('.command-palette-trigger');
|
||||
}
|
||||
|
||||
function dialog(fixture: ComponentFixture<CommandPalette>): HTMLElement | null {
|
||||
function dialog(fixture: ComponentFixture<PaletteHost>): HTMLElement | null {
|
||||
return fixture.nativeElement.querySelector('[role="dialog"]');
|
||||
}
|
||||
|
||||
async function openViaTrigger(fixture: ComponentFixture<CommandPalette>): Promise<void> {
|
||||
function site(fixture: ComponentFixture<PaletteHost>): HTMLElement {
|
||||
return fixture.nativeElement.querySelector('.site');
|
||||
}
|
||||
|
||||
async function openViaTrigger(fixture: ComponentFixture<PaletteHost>): Promise<void> {
|
||||
trigger(fixture).click();
|
||||
await flush(fixture);
|
||||
}
|
||||
|
||||
async function submitQuery(
|
||||
fixture: ComponentFixture<CommandPalette>,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
async function submitQuery(fixture: ComponentFixture<PaletteHost>, value: string): Promise<void> {
|
||||
const input = fixture.nativeElement.querySelector('input');
|
||||
expect(input).toBeTruthy();
|
||||
input.value = value;
|
||||
@@ -67,7 +85,7 @@ describe('CommandPalette', () => {
|
||||
await flush(fixture);
|
||||
}
|
||||
|
||||
function outputText(fixture: ComponentFixture<CommandPalette>): string {
|
||||
function outputText(fixture: ComponentFixture<PaletteHost>): string {
|
||||
return fixture.nativeElement.querySelector('.command-palette-output')?.textContent ?? '';
|
||||
}
|
||||
|
||||
@@ -172,6 +190,36 @@ describe('CommandPalette', () => {
|
||||
expect(document.activeElement).toBe(last);
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)(
|
||||
'marks .site inert while open and removes it on close (%s)',
|
||||
async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
expect(site(fixture).hasAttribute('inert')).toBe(false);
|
||||
|
||||
await openViaTrigger(fixture);
|
||||
expect(site(fixture).hasAttribute('inert')).toBe(true);
|
||||
|
||||
fixture.nativeElement.querySelector('.command-palette-close').click();
|
||||
await flush(fixture);
|
||||
expect(site(fixture).hasAttribute('inert')).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(APP_LOCALES)(
|
||||
'locks body overflow while open and restores it on close (%s)',
|
||||
async (locale) => {
|
||||
document.body.style.overflow = 'auto';
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(fixture);
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
|
||||
fixture.nativeElement.querySelector('.command-palette-close').click();
|
||||
await flush(fixture);
|
||||
expect(document.body.style.overflow).toBe('auto');
|
||||
document.body.style.overflow = '';
|
||||
},
|
||||
);
|
||||
|
||||
it.each(APP_LOCALES)('writes localized help, playful replies and clear (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(fixture);
|
||||
@@ -208,6 +256,7 @@ describe('CommandPalette', () => {
|
||||
await openViaTrigger(fixture);
|
||||
await submitQuery(fixture, 'projects');
|
||||
expect(navigate).toHaveBeenCalledWith(navigation.link('projects'));
|
||||
expect(dialog(fixture)).toBeNull();
|
||||
|
||||
await openViaTrigger(fixture);
|
||||
await submitQuery(fixture, 'services ai');
|
||||
@@ -265,6 +314,7 @@ describe('CommandPalette', () => {
|
||||
|
||||
const remove = vi.spyOn(document, 'removeEventListener');
|
||||
fixture.destroy();
|
||||
TestBed.resetTestingModule();
|
||||
|
||||
expect(remove).toHaveBeenCalledWith('keydown', added?.[1]);
|
||||
});
|
||||
@@ -274,4 +324,11 @@ describe('CommandPalette', () => {
|
||||
await createFixture('de', [{ provide: PLATFORM_ID, useValue: 'server' }]);
|
||||
expect(add.mock.calls.some((call) => call[0] === 'keydown')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the trigger aria-label stable and shows the Ctrl hint before hydration', async () => {
|
||||
const fixture = await createFixture('de', [{ provide: PLATFORM_ID, useValue: 'server' }]);
|
||||
const button = trigger(fixture);
|
||||
expect(button.getAttribute('aria-label')).toBe(SIGNATURE_COPY.de.palette.triggerLabel);
|
||||
expect(button.querySelector('kbd')?.textContent).toBe(SIGNATURE_COPY.de.palette.shortcutHint);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
effect,
|
||||
ElementRef,
|
||||
inject,
|
||||
Injector,
|
||||
@@ -17,6 +17,7 @@ import { SITE_CONFIG } from '../../core/content/site-config';
|
||||
import { LocaleService } from '../../core/i18n/locale.service';
|
||||
import { NavigationService } from '../../core/navigation/navigation.service';
|
||||
import { isBrowserPlatform } from '../../core/platform/browser';
|
||||
import { CommandPaletteService } from './command-palette.service';
|
||||
import {
|
||||
COMMAND_IDS,
|
||||
COMMANDS,
|
||||
@@ -25,7 +26,6 @@ import {
|
||||
type CommandDefinition,
|
||||
} from './commands';
|
||||
|
||||
let commandPaletteInstanceId = 0;
|
||||
let commandPaletteOutputId = 0;
|
||||
|
||||
@Component({
|
||||
@@ -37,23 +37,21 @@ let commandPaletteOutputId = 0;
|
||||
export class CommandPalette {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly router = inject(Router);
|
||||
private readonly navigation = inject(NavigationService);
|
||||
private readonly localeService = inject(LocaleService);
|
||||
private readonly palette = inject(CommandPaletteService);
|
||||
private readonly isBrowser = isBrowserPlatform();
|
||||
private readonly instanceId = commandPaletteInstanceId++;
|
||||
|
||||
protected readonly triggerRef = viewChild<ElementRef<HTMLButtonElement>>('trigger');
|
||||
private readonly inputRef = viewChild<ElementRef<HTMLInputElement>>('commandInput');
|
||||
|
||||
protected readonly dialogId = `command-palette-dialog-${this.instanceId}`;
|
||||
protected readonly titleId = `command-palette-title-${this.instanceId}`;
|
||||
protected readonly descriptionId = `command-palette-description-${this.instanceId}`;
|
||||
protected readonly inputId = `command-palette-input-${this.instanceId}`;
|
||||
protected readonly suggestionsId = `command-palette-suggestions-${this.instanceId}`;
|
||||
protected readonly dialogId = this.palette.dialogId;
|
||||
protected readonly titleId = this.palette.titleId;
|
||||
protected readonly descriptionId = this.palette.descriptionId;
|
||||
protected readonly inputId = this.palette.inputId;
|
||||
protected readonly suggestionsId = this.palette.suggestionsId;
|
||||
protected readonly open = this.palette.open;
|
||||
|
||||
protected readonly open = signal(false);
|
||||
protected readonly query = signal('');
|
||||
protected readonly output = signal<readonly { id: number; text: string }[]>([]);
|
||||
|
||||
@@ -62,30 +60,21 @@ export class CommandPalette {
|
||||
suggestCommands(this.query(), this.localeService.locale()),
|
||||
);
|
||||
|
||||
private opener: HTMLElement | null = null;
|
||||
|
||||
constructor() {
|
||||
afterNextRender(
|
||||
() => {
|
||||
if (!this.isBrowser) {
|
||||
effect(() => {
|
||||
if (!this.open()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.document.addEventListener('keydown', this.onDocumentKeydown);
|
||||
afterNextRender(
|
||||
() => {
|
||||
this.inputRef()?.nativeElement.focus();
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.document.removeEventListener('keydown', this.onDocumentKeydown);
|
||||
});
|
||||
}
|
||||
|
||||
protected onTriggerClick(): void {
|
||||
this.triggerRef()?.nativeElement.focus();
|
||||
this.openPalette();
|
||||
}
|
||||
|
||||
protected onQueryInput(event: Event): void {
|
||||
const target = event.target;
|
||||
|
||||
@@ -168,6 +157,10 @@ export class CommandPalette {
|
||||
}
|
||||
}
|
||||
|
||||
protected closePalette(): void {
|
||||
this.palette.close();
|
||||
}
|
||||
|
||||
private runRaw(raw: string): void {
|
||||
const match = parseCommand(raw);
|
||||
|
||||
@@ -206,53 +199,6 @@ export class CommandPalette {
|
||||
this.output.update((lines) => [...lines, { id: commandPaletteOutputId++, text }]);
|
||||
}
|
||||
|
||||
private openPalette(): void {
|
||||
if (this.open()) {
|
||||
this.focusInput();
|
||||
return;
|
||||
}
|
||||
|
||||
const active = this.document.activeElement;
|
||||
this.opener = active instanceof HTMLElement ? active : null;
|
||||
this.open.set(true);
|
||||
this.focusInput();
|
||||
}
|
||||
|
||||
protected closePalette(): void {
|
||||
if (!this.open()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.open.set(false);
|
||||
this.restoreFocus();
|
||||
}
|
||||
|
||||
private focusInput(): void {
|
||||
afterNextRender(
|
||||
() => {
|
||||
this.inputRef()?.nativeElement.focus();
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
}
|
||||
|
||||
private restoreFocus(): void {
|
||||
const opener = this.opener;
|
||||
this.opener = null;
|
||||
|
||||
afterNextRender(
|
||||
() => {
|
||||
if (opener instanceof HTMLElement && opener.isConnected) {
|
||||
opener.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
this.triggerRef()?.nativeElement.focus();
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
}
|
||||
|
||||
private focusableElements(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(
|
||||
container.querySelectorAll<HTMLElement>(
|
||||
@@ -260,13 +206,4 @@ export class CommandPalette {
|
||||
),
|
||||
).filter((element) => !element.hasAttribute('disabled') && element.tabIndex >= 0);
|
||||
}
|
||||
|
||||
private readonly onDocumentKeydown = (event: KeyboardEvent): void => {
|
||||
if (event.key.toLowerCase() !== 'k' || !(event.ctrlKey || event.metaKey) || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.openPalette();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,30 +1,8 @@
|
||||
import { type CommandId } from '../../core/commands/command-ids';
|
||||
import { APP_LOCALES, type AppLocale } from '../../core/i18n/locale';
|
||||
import { type RouteId } from '../../core/routing/route-ids';
|
||||
|
||||
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',
|
||||
];
|
||||
export { COMMAND_IDS, type CommandId } from '../../core/commands/command-ids';
|
||||
|
||||
export type CommandAction =
|
||||
| { readonly kind: 'navigate'; readonly routeId: RouteId }
|
||||
|
||||
@@ -47,15 +47,21 @@
|
||||
<circle [attr.cx]="node.x" [attr.cy]="node.y" r="28" aria-hidden="true" />
|
||||
} @else {
|
||||
<rect
|
||||
[attr.x]="node.x - 70"
|
||||
[attr.y]="node.y - 20"
|
||||
width="140"
|
||||
height="40"
|
||||
[attr.x]="node.x - node.shapeWidth / 2"
|
||||
[attr.y]="node.y - node.shapeHeight / 2"
|
||||
[attr.width]="node.shapeWidth"
|
||||
[attr.height]="node.shapeHeight"
|
||||
rx="8"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
}
|
||||
<text [attr.x]="node.x" [attr.y]="node.y + 4" aria-hidden="true">{{ node.label }}</text>
|
||||
<text [attr.x]="node.x" [attr.y]="node.y" aria-hidden="true">
|
||||
@for (line of node.labelLines; track $index) {
|
||||
<tspan [attr.x]="node.x" [attr.dy]="$index === 0 ? node.textStartDy : 14">
|
||||
{{ line }}
|
||||
</tspan>
|
||||
}
|
||||
</text>
|
||||
</a>
|
||||
}
|
||||
</svg>
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface SystemsMapNode {
|
||||
readonly kind: SystemsMapNodeKind;
|
||||
readonly routeId: RouteId;
|
||||
readonly label: Record<AppLocale, string>;
|
||||
readonly labelLines: Record<AppLocale, readonly string[]>;
|
||||
readonly summary: Record<AppLocale, string>;
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
@@ -42,6 +43,7 @@ export const SYSTEMS_MAP_NODES: readonly SystemsMapNode[] = [
|
||||
kind: 'hardware',
|
||||
routeId: 'servicesHardwareNetwork',
|
||||
label: { de: 'Hardware', en: 'Hardware' },
|
||||
labelLines: { de: ['Hardware'], en: ['Hardware'] },
|
||||
summary: {
|
||||
de: 'Geräte, Netz und physische Schicht',
|
||||
en: 'Devices, network and the physical layer',
|
||||
@@ -54,6 +56,7 @@ export const SYSTEMS_MAP_NODES: readonly SystemsMapNode[] = [
|
||||
kind: 'cluster',
|
||||
routeId: 'servicesClusters',
|
||||
label: { de: 'Cluster', en: 'Clusters' },
|
||||
labelLines: { de: ['Cluster'], en: ['Clusters'] },
|
||||
summary: {
|
||||
de: 'Orchestrierung und Betrieb von Cluster-Umgebungen',
|
||||
en: 'Orchestration and cluster operations',
|
||||
@@ -66,6 +69,7 @@ export const SYSTEMS_MAP_NODES: readonly SystemsMapNode[] = [
|
||||
kind: 'software',
|
||||
routeId: 'servicesSoftware',
|
||||
label: { de: 'Software', en: 'Software' },
|
||||
labelLines: { de: ['Software'], en: ['Software'] },
|
||||
summary: {
|
||||
de: 'Anwendungen und Schnittstellen',
|
||||
en: 'Applications and interfaces',
|
||||
@@ -78,9 +82,10 @@ export const SYSTEMS_MAP_NODES: readonly SystemsMapNode[] = [
|
||||
kind: 'ai',
|
||||
routeId: 'servicesAi',
|
||||
label: { de: 'KI', en: 'AI' },
|
||||
labelLines: { de: ['KI'], en: ['AI'] },
|
||||
summary: {
|
||||
de: 'Lokale Modelle und Integrationsarbeit',
|
||||
en: 'Local models and integration work',
|
||||
de: 'Angebot: lokale Modelle und Integrationsarbeit',
|
||||
en: 'Offered as local models and integration work',
|
||||
},
|
||||
x: 720,
|
||||
y: 140,
|
||||
@@ -90,6 +95,7 @@ export const SYSTEMS_MAP_NODES: readonly SystemsMapNode[] = [
|
||||
kind: 'software',
|
||||
routeId: 'stack',
|
||||
label: { de: 'Stack', en: 'Stack' },
|
||||
labelLines: { de: ['Stack'], en: ['Stack'] },
|
||||
summary: {
|
||||
de: 'Werkzeuge und Laufzeitumgebung',
|
||||
en: 'Tools and runtime environment',
|
||||
@@ -102,6 +108,7 @@ export const SYSTEMS_MAP_NODES: readonly SystemsMapNode[] = [
|
||||
kind: 'project',
|
||||
routeId: 'projects',
|
||||
label: { de: 'Datenmigration', en: 'Data migration' },
|
||||
labelLines: { de: ['Datenmigration'], en: ['Data migration'] },
|
||||
summary: {
|
||||
de: 'Datenbestände strukturiert überführen',
|
||||
en: 'Moving data stores in a structured way',
|
||||
@@ -114,6 +121,7 @@ export const SYSTEMS_MAP_NODES: readonly SystemsMapNode[] = [
|
||||
kind: 'project',
|
||||
routeId: 'projects',
|
||||
label: { de: 'Plattform und Betrieb', en: 'Platform and operations' },
|
||||
labelLines: { de: ['Plattform und', 'Betrieb'], en: ['Platform and', 'operations'] },
|
||||
summary: {
|
||||
de: 'Plattformen betreiben und weiterentwickeln',
|
||||
en: 'Operating and evolving platforms',
|
||||
@@ -126,9 +134,10 @@ export const SYSTEMS_MAP_NODES: readonly SystemsMapNode[] = [
|
||||
kind: 'project',
|
||||
routeId: 'projects',
|
||||
label: { de: 'Automatisierung und AI', en: 'Automation and AI' },
|
||||
labelLines: { de: ['Automatisierung', 'und AI'], en: ['Automation', 'and AI'] },
|
||||
summary: {
|
||||
de: 'Abläufe automatisieren und Modelle anbinden',
|
||||
en: 'Automating workflows and connecting models',
|
||||
de: 'Angebot: Abläufe automatisieren und Modelle anbinden',
|
||||
en: 'Offered as workflow automation and model connections',
|
||||
},
|
||||
x: 900,
|
||||
y: 140,
|
||||
|
||||
@@ -181,8 +181,4 @@
|
||||
.systems-map-figure {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.systems-map-list {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,21 @@ describe('SystemsMap', () => {
|
||||
expect(compiled.querySelector('.systems-map-intro')?.textContent?.trim()).toBe(defaults.intro);
|
||||
});
|
||||
|
||||
it('renders every node in the card list with label, summary and relationship', async () => {
|
||||
const fixture = await createFixture();
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
const cards = compiled.querySelectorAll('.systems-map-cards li');
|
||||
const nodes = expectedNodes(TestBed.inject(LocaleService).locale());
|
||||
|
||||
expect(cards.length).toBe(nodes.length);
|
||||
nodes.forEach((node, index) => {
|
||||
const text = cards.item(index).textContent ?? '';
|
||||
expect(text).toContain(node.label);
|
||||
expect(text).toContain(node.summary);
|
||||
expect(text).toContain(node.relationship);
|
||||
});
|
||||
});
|
||||
|
||||
it('still renders the SVG, card list, links and summaries on the server', async () => {
|
||||
const fixture = await createFixture([{ provide: PLATFORM_ID, useValue: 'server' }]);
|
||||
const compiled = fixture.nativeElement as HTMLElement;
|
||||
|
||||
@@ -23,11 +23,15 @@ export interface SystemsMapNodeView {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly label: string;
|
||||
readonly labelLines: readonly string[];
|
||||
readonly summary: string;
|
||||
readonly href: string;
|
||||
readonly link: unknown[];
|
||||
readonly relationship: string;
|
||||
readonly accessibleName: string;
|
||||
readonly shapeWidth: number;
|
||||
readonly shapeHeight: number;
|
||||
readonly textStartDy: number;
|
||||
}
|
||||
|
||||
export interface SystemsMapEdgeView {
|
||||
@@ -191,7 +195,10 @@ export class SystemsMap {
|
||||
);
|
||||
const relationship = relationshipLabel.replace('{targets}', connectedLabels.join(', '));
|
||||
const label = node.label[locale];
|
||||
const labelLines = node.labelLines[locale];
|
||||
const summary = node.summary[locale];
|
||||
const multiline = labelLines.length > 1;
|
||||
const longest = labelLines.reduce((max, line) => Math.max(max, line.length), 0);
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
@@ -200,7 +207,11 @@ export class SystemsMap {
|
||||
x: node.x,
|
||||
y: node.y,
|
||||
label,
|
||||
labelLines,
|
||||
summary,
|
||||
shapeWidth: Math.max(140, longest * 8 + 24),
|
||||
shapeHeight: multiline ? 52 : 40,
|
||||
textStartDy: multiline ? -6 : 4,
|
||||
href: routePath(node.routeId, locale),
|
||||
link: this.navigation.link(node.routeId),
|
||||
relationship,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/spec",
|
||||
"types": ["vitest/globals"]
|
||||
"types": ["vitest/globals", "node"]
|
||||
},
|
||||
"include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user