Skip to content

Commit b20ae35

Browse files
authored
feat(blog): handle link hreflang tags (#454)
1 parent f2c6621 commit b20ae35

File tree

6 files changed

+143
-11
lines changed

6 files changed

+143
-11
lines changed

libs/blog-contracts/articles/src/lib/articles.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,8 @@ export const articleLangToLocaleMap = {
150150
[DbLang.English]: 'en_GB',
151151
[DbLang.Polish]: 'pl_PL',
152152
} as const satisfies Record<DbLang, ArticleLocale>;
153+
154+
export const articleLocaleToLangMap = {
155+
en_GB: 'en',
156+
pl_PL: 'pl',
157+
} as const satisfies Record<ArticleLocale, Lang>;

libs/blog/articles/data-access/src/lib/state/article-details.store.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ import { rxMethod } from '@ngrx/signals/rxjs-interop';
1111
import { filter, pipe, switchMap, tap } from 'rxjs';
1212

1313
import { withLangState } from '@angular-love/blog/i18n/data-access';
14-
import { Article } from '@angular-love/contracts/articles';
15-
import { withSeo } from '@angular-love/seo';
14+
import {
15+
Article,
16+
articleLocaleToLangMap,
17+
} from '@angular-love/contracts/articles';
18+
import { HreflangEntry, withSeo } from '@angular-love/seo';
1619
import {
1720
LoadingState,
1821
withCallState,
@@ -72,6 +75,16 @@ export const ArticleDetailsStore = signalStore(
7275
next: (articleDetails) => {
7376
store.setMeta(articleDetails.seo);
7477
store.setTitle(articleDetails.seo.title);
78+
79+
const hreflangEntries =
80+
buildArticleHreflangEntries(articleDetails);
81+
82+
if (hreflangEntries) {
83+
store.setHreflang(hreflangEntries);
84+
} else {
85+
store.clearHreflang();
86+
}
87+
7588
return patchState(store, {
7689
articleDetails,
7790
slug: slug,
@@ -93,3 +106,29 @@ export const ArticleDetailsStore = signalStore(
93106
}),
94107
})),
95108
);
109+
110+
export function buildArticleHreflangEntries(
111+
article: Article,
112+
): HreflangEntry[] | null {
113+
if (!article.otherTranslations || article.otherTranslations.length < 2) {
114+
return null;
115+
}
116+
117+
return article.otherTranslations.map((translation) => {
118+
const langCode = articleLocaleToLangMap[translation.locale];
119+
const url = buildArticlePath(translation.slug, langCode);
120+
121+
return {
122+
locale: langCode,
123+
url: url,
124+
} satisfies HreflangEntry;
125+
});
126+
}
127+
128+
function buildArticlePath(slug: string, langCode: string): string {
129+
if (langCode === 'en') {
130+
return `/${slug}`;
131+
}
132+
133+
return `/${langCode}/${slug}`;
134+
}

libs/blog/articles/feature-shell/src/lib/routes.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export const articleRoutes: Routes = [
1313
(await import('@angular-love/blog/articles/feature-category'))
1414
.CategoryArticlesComponent,
1515
data: {
16-
seo: { title: 'News' },
16+
seo: { title: 'News', autoHrefLang: true },
1717
category: 'news',
1818
title: 'Angular News',
1919
id: 'angular-news',
@@ -25,7 +25,7 @@ export const articleRoutes: Routes = [
2525
(await import('@angular-love/blog/articles/feature-category'))
2626
.CategoryArticlesComponent,
2727
data: {
28-
seo: { title: 'Guides' },
28+
seo: { title: 'Guides', autoHrefLang: true },
2929
category: 'guides',
3030
title: 'Angular Guides',
3131
id: 'angular-guides-title',
@@ -37,7 +37,7 @@ export const articleRoutes: Routes = [
3737
(await import('@angular-love/blog/articles/feature-category'))
3838
.CategoryArticlesComponent,
3939
data: {
40-
seo: { title: 'Latest Articles' },
40+
seo: { title: 'Latest Articles', autoHrefLang: true },
4141
excludeCategory: 'angular-in-depth-en',
4242
title: 'Latest Articles',
4343
id: 'latest-articles',
@@ -49,7 +49,7 @@ export const articleRoutes: Routes = [
4949
(await import('@angular-love/blog/articles/feature-category'))
5050
.CategoryArticlesComponent,
5151
data: {
52-
seo: { title: 'Angular In Depth' },
52+
seo: { title: 'Angular In Depth', autoHrefLang: true },
5353
category: 'angular-in-depth',
5454
title: 'Angular In Depth',
5555
id: 'angular-in-depth-title',

libs/blog/shared/util-seo/src/lib/services/seo.service.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,21 @@ import { inject, Injectable } from '@angular/core';
33
import { Meta, MetaDefinition, Title } from '@angular/platform-browser';
44
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
55
import { TranslocoService } from '@jsverse/transloco';
6-
import { filter, map, switchMap } from 'rxjs';
6+
import { filter, map, switchMap, take } from 'rxjs';
77

8+
import { AlLocalizeService } from '@angular-love/blog/i18n/util';
89
import { SeoMetaData } from '@angular-love/contracts/articles';
910

1011
import { SEO_CONFIG } from '../tokens';
1112

1213
import { SEO_META_KEYS, SeoMetaKeys } from './seo-meta-keys';
1314
import { SEO_TITLE_KEYS, SeoTitleKeys } from './seo-title-keys';
1415

16+
export interface HreflangEntry {
17+
locale: string;
18+
url: string;
19+
}
20+
1521
@Injectable()
1622
export class SeoService {
1723
private readonly _router = inject(Router);
@@ -21,7 +27,9 @@ export class SeoService {
2127
private readonly _document = inject(DOCUMENT);
2228
private readonly _seoConfig = inject(SEO_CONFIG);
2329
private readonly _translocoService = inject(TranslocoService);
30+
private readonly _localizeService = inject(AlLocalizeService);
2431
private _url = '';
32+
private _baseUrl = '';
2533

2634
init(): void {
2735
this._router.events
@@ -41,6 +49,7 @@ export class SeoService {
4149
)
4250
.subscribe(({ routeData, seoConfig }) => {
4351
this._url = this.getUrl(seoConfig.baseUrl, this._router.url);
52+
this._baseUrl = seoConfig.baseUrl;
4453

4554
this.removeSeo();
4655

@@ -57,6 +66,10 @@ export class SeoService {
5766
}
5867

5968
this.handleCanonicalUrl(this._url);
69+
70+
if (routeData && routeData['seo'] && routeData['seo']['autoHrefLang']) {
71+
this.handleAutoHreflang(seoConfig.baseUrl, this._router.url);
72+
}
6073
});
6174
}
6275

@@ -125,6 +138,31 @@ export class SeoService {
125138
this.updateTag(title, 'name');
126139
}
127140

141+
setHreflang(hreflangEntries: HreflangEntry[]): void {
142+
this.removeHreflangTags();
143+
144+
for (const entry of hreflangEntries) {
145+
const fullUrl = entry.url.startsWith('http')
146+
? entry.url
147+
: `${this._baseUrl}${entry.url}`;
148+
this.appendHreflangLink(entry.locale, fullUrl);
149+
}
150+
151+
const defaultEntry =
152+
hreflangEntries.find((entry) => entry.locale === 'en') ||
153+
hreflangEntries[0];
154+
if (defaultEntry) {
155+
const defaultUrl = defaultEntry.url.startsWith('http')
156+
? defaultEntry.url
157+
: `${this._baseUrl}${defaultEntry.url}`;
158+
this.appendHreflangLink('x-default', defaultUrl);
159+
}
160+
}
161+
162+
clearHreflang(): void {
163+
this.removeHreflangTags();
164+
}
165+
128166
private setMetaTwitterMisc(miscData: object): void {
129167
const entries = Object.entries(miscData);
130168

@@ -204,6 +242,44 @@ export class SeoService {
204242
}
205243
},
206244
);
245+
246+
this.removeHreflangTags();
247+
}
248+
249+
private handleAutoHreflang(baseUrl: string, currentPath: string): void {
250+
const availableLanguages =
251+
this._translocoService.getAvailableLangs() as string[];
252+
const hreflangEntries: HreflangEntry[] = [];
253+
254+
for (const lang of availableLanguages) {
255+
const localizedPath = this._localizeService.localizeExplicitPath(
256+
currentPath,
257+
lang,
258+
);
259+
const fullUrl = `${baseUrl}${localizedPath}`;
260+
261+
hreflangEntries.push({
262+
locale: lang,
263+
url: fullUrl,
264+
});
265+
}
266+
267+
this.setHreflang(hreflangEntries);
268+
}
269+
270+
private appendHreflangLink(hreflang: string, href: string): void {
271+
const link = this._document.createElement('link');
272+
link.setAttribute('rel', 'alternate');
273+
link.setAttribute('hreflang', hreflang);
274+
link.setAttribute('href', href);
275+
this._document.head.appendChild(link);
276+
}
277+
278+
private removeHreflangTags(): void {
279+
const hreflangLinks = this._document.head.querySelectorAll(
280+
'link[rel="alternate"][hreflang]',
281+
);
282+
hreflangLinks.forEach((link) => link.remove());
207283
}
208284

209285
private handleCanonicalUrl(url: string): void {

libs/blog/shared/util-seo/src/lib/state/seo.store-feature.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { signalStoreFeature, withMethods } from '@ngrx/signals';
33

44
import { SeoMetaData } from '@angular-love/contracts/articles';
55

6-
import { SeoService } from '../services';
6+
import { HreflangEntry, SeoService } from '../services';
77

88
export function withSeo() {
99
return signalStoreFeature(
@@ -14,6 +14,12 @@ export function withSeo() {
1414
setTitle(title: string | undefined): void {
1515
seoService.setTitle(title);
1616
},
17+
setHreflang(hreflangEntries: HreflangEntry[]): void {
18+
seoService.setHreflang(hreflangEntries);
19+
},
20+
clearHreflang(): void {
21+
seoService.clearHreflang();
22+
},
1723
})),
1824
);
1925
}

libs/blog/shell/feature-shell-web/src/lib/blog-shell.routes.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const commonRoutes: Route[] = [
3131
(await import('@angular-love/blog/home/feature-home'))
3232
.HomePageComponent,
3333
data: {
34-
seo: { title: 'seo.home' },
34+
seo: { title: 'seo.home', autoHrefLang: true },
3535
},
3636
},
3737
{
@@ -48,22 +48,25 @@ export const commonRoutes: Route[] = [
4848
(await import('@angular-love/feature-about-us'))
4949
.FeatureAboutUsComponent,
5050
data: {
51-
seo: { title: 'seo.aboutUs' },
51+
seo: { title: 'seo.aboutUs', autoHrefLang: true },
5252
},
5353
},
5454
{
5555
path: 'author/:authorSlug',
5656
loadComponent: async () =>
5757
(await import('@angular-love/blog/authors/feature-author'))
5858
.FeatureAuthorComponent,
59+
data: {
60+
seo: { autoHrefLang: true },
61+
},
5962
},
6063
{
6164
path: 'become-author',
6265
loadComponent: async () =>
6366
(await import('@angular-love/blog/become-author-page-feature'))
6467
.BecomeAuthorPageFeatureComponent,
6568
data: {
66-
seo: { title: 'seo.becomeAuthor' },
69+
seo: { title: 'seo.becomeAuthor', autoHrefLang: true },
6770
},
6871
},
6972
{
@@ -77,6 +80,9 @@ export const commonRoutes: Route[] = [
7780
loadComponent: async () =>
7881
(await import('@angular-love/blog/feature-writing-rules'))
7982
.WritingRulesComponent,
83+
data: {
84+
seo: { autoHrefLang: true },
85+
},
8086
},
8187
{
8288
path: '404',

0 commit comments

Comments
 (0)