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:
1 parent
28b1b88ace
commit
d5cbf3220a
3 files changed
+173
-1
No files matched your search
@@ -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);
|
||||
}),
|
||||
],
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user