From d5cbf3220a2b3a798d9f0f94b89dc720eaa3a10e Mon Sep 17 00:00:00 2001 From: Julian Lechner Date: Thu, 15 Oct 2026 16:22:43 +0200 Subject: [PATCH] feat(seo): sync html lang, title, canonical, hreflang and Open Graph tags Co-Authored-By: Claude Opus 5.5 --- src/app/app.config.ts | 6 +- src/app/services/seo.service.spec.ts | 79 ++++++++++++++++++++++++ src/app/services/seo.service.ts | 89 ++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 src/app/services/seo.service.spec.ts create mode 100644 src/app/services/seo.service.ts diff --git a/src/app/app.config.ts b/src/app/app.config.ts index c019ad9..23c8935 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -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); + }), ], }; diff --git a/src/app/services/seo.service.spec.ts b/src/app/services/seo.service.spec.ts new file mode 100644 index 0000000..7c74700 --- /dev/null +++ b/src/app/services/seo.service.spec.ts @@ -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 => { + 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 => { + 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 => { + 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'); + }); + }); +}); diff --git a/src/app/services/seo.service.ts b/src/app/services/seo.service.ts new file mode 100644 index 0000000..cd947a8 --- /dev/null +++ b/src/app/services/seo.service.ts @@ -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> = { en: 'en_US', de: 'de_AT' }; +const homeTitles: readonly string[] = ['Julian Lechner', 'Full Stack Developer']; +const homeTitleSwitchMs = 3500; + +/** Keeps ``, 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>): void { + let link = this.document.head.querySelector(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); + } + } +}