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 type { ApplicationConfig } from '@angular/core';
|
||||||
import { provideZonelessChangeDetection } from '@angular/core';
|
import { inject, provideAppInitializer, provideZonelessChangeDetection } from '@angular/core';
|
||||||
import { provideRouter, withInMemoryScrolling } from '@angular/router';
|
import { provideRouter, withInMemoryScrolling } from '@angular/router';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
|
import { SeoService } from './services/seo.service';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
@@ -14,5 +15,8 @@ export const appConfig: ApplicationConfig = {
|
|||||||
scrollPositionRestoration: 'top',
|
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