feat(seo): sync html lang, title, canonical, hreflang and Open Graph tags

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fraujulianandClaude Opus 5.5 committed 2026-10-15 16:22:43 +02:00
1 parent 28b1b88ace
commit d5cbf3220a
3 files changed
+173 -1

No files matched your search

+5 -1
View File
@@ -1,8 +1,9 @@
import type { ApplicationConfig } from '@angular/core';
import { provideZonelessChangeDetection } from '@angular/core';
import { inject, provideAppInitializer, provideZonelessChangeDetection } from '@angular/core';
import { provideRouter, withInMemoryScrolling } from '@angular/router';
import { routes } from './app.routes';
import { SeoService } from './services/seo.service';
export const appConfig: ApplicationConfig = {
providers: [
@@ -14,5 +15,8 @@ export const appConfig: ApplicationConfig = {
scrollPositionRestoration: 'top',
}),
),
provideAppInitializer((): void => {
inject(SeoService);
}),
],
};
+79
View File
@@ -0,0 +1,79 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { Component } from '@angular/core';
import { LanguagePreferenceService } from './language-preference.service';
import { SeoService } from './seo.service';
@Component({ template: '' })
class EmptyComponent {}
describe('SeoService', (): void => {
const getHref = (selector: string): string | null =>
document.head.querySelector(selector)?.getAttribute('href') ?? null;
beforeEach(async (): Promise<void> => {
localStorage.clear();
TestBed.configureTestingModule({
providers: [
provideRouter([
{ path: '', component: EmptyComponent },
{ path: 'imprint', component: EmptyComponent },
]),
],
});
TestBed.inject(SeoService);
await TestBed.inject(Router).navigateByUrl('/');
TestBed.tick();
});
it('should describe the English start page by default', (): void => {
expect(document.documentElement.lang).toBe('en');
expect(document.title).toContain('Julian Lechner');
expect(getHref('link[rel="canonical"]')).toBe('https://fraujulian.xyz/');
expect(getHref('link[hreflang="de"]')).toBe('https://fraujulian.xyz/?lang=de');
expect(getHref('link[hreflang="x-default"]')).toBe('https://fraujulian.xyz/');
});
it('should switch language, title and canonical URL together', async (): Promise<void> => {
TestBed.inject(LanguagePreferenceService).confirmLanguage('de');
await TestBed.inject(Router).navigateByUrl('/imprint');
TestBed.tick();
expect(document.documentElement.lang).toBe('de');
expect(document.title).toContain('Impressum');
expect(getHref('link[rel="canonical"]')).toBe('https://fraujulian.xyz/imprint?lang=de');
expect(document.head.querySelector('meta[property="og:locale"]')?.getAttribute('content')).toBe(
'de_AT',
);
});
describe('home title', (): void => {
beforeEach(async (): Promise<void> => {
TestBed.resetTestingModule();
vi.useFakeTimers();
TestBed.configureTestingModule({
providers: [provideRouter([{ path: '', component: EmptyComponent }])],
});
TestBed.inject(SeoService);
await TestBed.inject(Router).navigateByUrl('/');
TestBed.tick();
});
afterEach((): void => {
vi.useRealTimers();
});
it('should alternate between name and role every 3.5 seconds', (): void => {
expect(document.title).toBe('Julian Lechner');
vi.advanceTimersByTime(3500);
TestBed.tick();
expect(document.title).toBe('Full Stack Developer');
vi.advanceTimersByTime(3500);
TestBed.tick();
expect(document.title).toBe('Julian Lechner');
});
});
});
+89
View File
@@ -0,0 +1,89 @@
import { DOCUMENT } from '@angular/common';
import { Injectable, effect, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { Meta, Title } from '@angular/platform-browser';
import { NavigationEnd, Router } from '@angular/router';
import { filter, interval, map } from 'rxjs';
import { global } from '../../global';
import type { LanguageCode } from '../../languages/language.types';
import { LanguagePreferenceService } from './language-preference.service';
import { ShellLanguageService } from './shell-language.service';
const languageCodes: readonly LanguageCode[] = ['en', 'de'];
const ogLocales: Readonly<Record<LanguageCode, string>> = { en: 'en_US', de: 'de_AT' };
const homeTitles: readonly string[] = ['Julian Lechner', 'Full Stack Developer'];
const homeTitleSwitchMs = 3500;
/** Keeps `<html lang>`, title, description, canonical, hreflang and Open Graph tags in sync. */
@Injectable({ providedIn: 'root' })
export class SeoService {
private readonly document = inject(DOCUMENT);
private readonly title = inject(Title);
private readonly meta = inject(Meta);
private readonly router = inject(Router);
private readonly languageCode = inject(LanguagePreferenceService).languageCode;
private readonly content = inject(ShellLanguageService).content;
private readonly path = toSignal(
this.router.events.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
map((event: NavigationEnd): string => event.urlAfterRedirects.split(/[?#]/u)[0] ?? '/'),
),
{ initialValue: '/' },
);
private readonly titleTick = toSignal(interval(homeTitleSwitchMs), { initialValue: -1 });
private readonly syncHead = effect((): void => {
this.update(this.languageCode(), this.path());
});
/** The start page title alternates in the browser tab; other pages keep a fixed title. */
private readonly syncTitle = effect((): void => {
const homeTitle = homeTitles[(this.titleTick() + 1) % homeTitles.length] ?? global.firstname;
this.title.setTitle(this.path() === '/imprint' ? this.content().meta.imprintTitle : homeTitle);
});
private update(language: LanguageCode, path: string): void {
const { meta } = this.content();
const pageTitle = path === '/imprint' ? meta.imprintTitle : meta.homeTitle;
const pageUrl = this.getPageUrl(path, language);
this.document.documentElement.lang = language;
this.meta.updateTag({ name: 'description', content: meta.description });
this.meta.updateTag({ property: 'og:title', content: pageTitle });
this.meta.updateTag({ property: 'og:description', content: meta.description });
this.meta.updateTag({ property: 'og:url', content: pageUrl });
this.meta.updateTag({ property: 'og:locale', content: ogLocales[language] });
this.setLink('link[rel="canonical"]', { rel: 'canonical', href: pageUrl });
for (const code of languageCodes) {
this.setLink(`link[rel="alternate"][hreflang="${code}"]`, {
rel: 'alternate',
hreflang: code,
href: this.getPageUrl(path, code),
});
}
this.setLink('link[rel="alternate"][hreflang="x-default"]', {
rel: 'alternate',
hreflang: 'x-default',
href: this.getPageUrl(path, 'en'),
});
}
/** English is the default and lives on the plain URL; other languages use `?lang=`. */
private getPageUrl(path: string, language: LanguageCode): string {
const pageUrl = `${global.siteUrl}${path}`;
return language === 'en' ? pageUrl : `${pageUrl}?lang=${language}`;
}
private setLink(selector: string, attributes: Readonly<Record<string, string>>): void {
let link = this.document.head.querySelector<HTMLLinkElement>(selector);
if (link === null) {
link = this.document.createElement('link');
this.document.head.appendChild(link);
}
for (const [name, value] of Object.entries(attributes)) {
link.setAttribute(name, value);
}
}
}