-
-
Notifications
You must be signed in to change notification settings - Fork 265
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(router): add ability to provide meta tags
- Loading branch information
1 parent
b7858f4
commit 5add5d8
Showing
18 changed files
with
560 additions
and
50 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
export interface PostAttributes { | ||
title: string; | ||
slug: string; | ||
description: string; | ||
coverImage: string; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
import { ActivatedRouteSnapshot, ResolveFn } from '@angular/router'; | ||
import { MetaTag } from '@analogjs/router'; | ||
import { injectContentFiles } from '@analogjs/content'; | ||
import { PostAttributes } from './models'; | ||
|
||
// temporary | ||
function injectActivePostAttributes( | ||
route: ActivatedRouteSnapshot | ||
): PostAttributes { | ||
return injectContentFiles<PostAttributes>().find( | ||
(contentFile) => | ||
contentFile.filename === `/src/content/${route.params['slug']}.md` | ||
)!.attributes; | ||
} | ||
|
||
export const postTitleResolver: ResolveFn<string> = (route) => | ||
injectActivePostAttributes(route).title; | ||
|
||
export const postMetaResolver: ResolveFn<MetaTag[]> = (route) => { | ||
const postAttributes = injectActivePostAttributes(route); | ||
|
||
return [ | ||
{ | ||
name: 'description', | ||
content: postAttributes.description, | ||
}, | ||
{ | ||
name: 'author', | ||
content: 'Analog Team', | ||
}, | ||
{ | ||
property: 'og:title', | ||
content: postAttributes.title, | ||
}, | ||
{ | ||
property: 'og:description', | ||
content: postAttributes.description, | ||
}, | ||
{ | ||
property: 'og:image', | ||
content: postAttributes.coverImage, | ||
}, | ||
]; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,19 +1,25 @@ | ||
import { injectContent, MarkdownComponent } from '@analogjs/content'; | ||
import { AsyncPipe, NgIf } from '@angular/common'; | ||
import { Component } from '@angular/core'; | ||
import { BlogAttributes } from '../../../lib/blog-attributes'; | ||
import { RouteMeta } from '@analogjs/router'; | ||
import { PostAttributes } from '../../blog/models'; | ||
import { postMetaResolver, postTitleResolver } from '../../blog/resolvers'; | ||
|
||
export const routeMeta: RouteMeta = { | ||
title: postTitleResolver, | ||
meta: postMetaResolver, | ||
}; | ||
|
||
@Component({ | ||
selector: 'blog-post', | ||
standalone: true, | ||
imports: [MarkdownComponent, AsyncPipe, NgIf], | ||
template: ` | ||
<ng-container *ngIf="contentFile$ | async as cf"> | ||
<h1>{{ cf.attributes.title }}</h1> | ||
<analog-markdown [content]="cf.content"></analog-markdown> | ||
<ng-container *ngIf="post$ | async as post"> | ||
<h1>{{ post.attributes.title }}</h1> | ||
<analog-markdown [content]="post.content"></analog-markdown> | ||
</ng-container> | ||
`, | ||
}) | ||
export default class BlogPostComponent { | ||
public contentFile$ = injectContent<BlogAttributes>(); | ||
readonly post$ = injectContent<PostAttributes>(); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,8 @@ | ||
--- | ||
title: My First Post | ||
slug: 2022-12-27-my-first-post | ||
description: My First Post Description | ||
coverImage: https://images.unsplash.com/photo-1493612276216-ee3925520721?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=464&q=80 | ||
--- | ||
|
||
Hello |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,227 @@ | ||
import { Component, ENVIRONMENT_INITIALIZER } from '@angular/core'; | ||
import { Route, Router, RouterOutlet } from '@angular/router'; | ||
import { fakeAsync, TestBed, tick } from '@angular/core/testing'; | ||
import { RouterTestingModule } from '@angular/router/testing'; | ||
import { map, timer } from 'rxjs'; | ||
import { | ||
MetaTag, | ||
ROUTE_META_TAGS_KEY, | ||
setMetaTagsOnRouteChange, | ||
} from './meta-tags'; | ||
import { DOCUMENT } from '@angular/common'; | ||
import { expect } from 'vitest'; | ||
|
||
describe('setMetaTagsOnRouteChange', () => { | ||
function setup() { | ||
@Component({ | ||
standalone: true, | ||
imports: [RouterOutlet], | ||
template: '<router-outlet></router-outlet>', | ||
}) | ||
class TestComponent {} | ||
|
||
const parentMetaTagValues = { | ||
charset: 'utf-8', | ||
httpEquivRefresh: '1000', | ||
description: 'Parent Description', | ||
keywords: 'Analog, Angular', | ||
ogTitle: 'Parent Og:Title', | ||
ogImage: 'https://example.com', | ||
}; | ||
|
||
const childMetaTagValues = { | ||
charset: 'ascii', | ||
httpEquivRefresh: '3000', | ||
httpEquivContentSec: "default-src 'self'", | ||
description: 'Child Description', | ||
author: 'Analog Team', | ||
ogTitle: 'Child Og:Title', | ||
ogDescription: 'Child Og:Description', | ||
}; | ||
|
||
const routes: Route[] = [ | ||
{ | ||
path: '', | ||
component: TestComponent, | ||
data: { | ||
[ROUTE_META_TAGS_KEY]: [ | ||
{ charset: parentMetaTagValues.charset }, | ||
{ | ||
httpEquiv: 'refresh', | ||
content: parentMetaTagValues.httpEquivRefresh, | ||
}, | ||
{ name: 'description', content: parentMetaTagValues.description }, | ||
{ name: 'keywords', content: parentMetaTagValues.keywords }, | ||
{ property: 'og:title', content: parentMetaTagValues.ogTitle }, | ||
{ property: 'og:image', content: parentMetaTagValues.ogImage }, | ||
] as MetaTag[], | ||
}, | ||
children: [ | ||
{ | ||
path: 'child', | ||
component: TestComponent, | ||
resolve: { | ||
[ROUTE_META_TAGS_KEY]: () => | ||
timer(1000).pipe( | ||
map( | ||
() => | ||
[ | ||
{ charset: childMetaTagValues.charset }, | ||
{ | ||
httpEquiv: 'refresh', | ||
content: childMetaTagValues.httpEquivRefresh, | ||
}, | ||
{ | ||
httpEquiv: 'content-security-policy', | ||
content: childMetaTagValues.httpEquivContentSec, | ||
}, | ||
{ | ||
name: 'description', | ||
content: childMetaTagValues.description, | ||
}, | ||
{ name: 'author', content: childMetaTagValues.author }, | ||
{ | ||
property: 'og:title', | ||
content: childMetaTagValues.ogTitle, | ||
}, | ||
{ | ||
property: 'og:description', | ||
content: childMetaTagValues.ogDescription, | ||
}, | ||
] as MetaTag[] | ||
) | ||
), | ||
}, | ||
}, | ||
], | ||
}, | ||
]; | ||
|
||
TestBed.configureTestingModule({ | ||
imports: [RouterTestingModule.withRoutes(routes)], | ||
providers: [ | ||
{ | ||
provide: ENVIRONMENT_INITIALIZER, | ||
multi: true, | ||
useValue: () => setMetaTagsOnRouteChange(), | ||
}, | ||
], | ||
}); | ||
|
||
const router = TestBed.inject(Router); | ||
const document = TestBed.inject(DOCUMENT); | ||
|
||
const getMetaElements = () => ({ | ||
charset: document.querySelector('meta[charset]') as HTMLMetaElement, | ||
httpEquivRefresh: document.querySelector( | ||
'meta[http-equiv="refresh"]' | ||
) as HTMLMetaElement, | ||
httpEquivContentSec: document.querySelector( | ||
'meta[http-equiv="content-security-policy"]' | ||
) as HTMLMetaElement, | ||
description: document.querySelector( | ||
'meta[name="description"]' | ||
) as HTMLMetaElement, | ||
keywords: document.querySelector( | ||
'meta[name="keywords"]' | ||
) as HTMLMetaElement, | ||
author: document.querySelector('meta[name="author"]') as HTMLMetaElement, | ||
ogTitle: document.querySelector( | ||
'meta[property="og:title"]' | ||
) as HTMLMetaElement, | ||
ogDescription: document.querySelector( | ||
'meta[property="og:description"]' | ||
) as HTMLMetaElement, | ||
ogImage: document.querySelector( | ||
'meta[property="og:image"]' | ||
) as HTMLMetaElement, | ||
}); | ||
|
||
return { router, getMetaElements, parentMetaTagValues, childMetaTagValues }; | ||
} | ||
|
||
it('adds meta tags on initial navigation', fakeAsync(() => { | ||
const { router, getMetaElements, parentMetaTagValues } = setup(); | ||
|
||
router.navigateByUrl('/'); | ||
tick(); | ||
|
||
const metaElements = getMetaElements(); | ||
expect(metaElements.charset.getAttribute('charset')).toBe( | ||
parentMetaTagValues.charset | ||
); | ||
expect(metaElements.httpEquivRefresh.content).toBe( | ||
parentMetaTagValues.httpEquivRefresh | ||
); | ||
expect(metaElements.description.content).toBe( | ||
parentMetaTagValues.description | ||
); | ||
expect(metaElements.keywords.content).toBe(parentMetaTagValues.keywords); | ||
expect(metaElements.ogTitle.content).toBe(parentMetaTagValues.ogTitle); | ||
expect(metaElements.ogImage.content).toBe(parentMetaTagValues.ogImage); | ||
})); | ||
|
||
it('merges parent and child meta tags on child route navigation', fakeAsync(() => { | ||
const { router, getMetaElements, parentMetaTagValues, childMetaTagValues } = | ||
setup(); | ||
|
||
router.navigateByUrl('/child'); | ||
// child meta tags are resolved after 1s | ||
tick(1000); | ||
|
||
const metaElements = getMetaElements(); | ||
expect(metaElements.charset.getAttribute('charset')).toBe( | ||
childMetaTagValues.charset | ||
); | ||
expect(metaElements.httpEquivRefresh.content).toBe( | ||
childMetaTagValues.httpEquivRefresh | ||
); | ||
expect(metaElements.httpEquivContentSec.content).toBe( | ||
childMetaTagValues.httpEquivContentSec | ||
); | ||
expect(metaElements.description.content).toBe( | ||
childMetaTagValues.description | ||
); | ||
expect(metaElements.author.content).toBe(childMetaTagValues.author); | ||
expect(metaElements.ogTitle.content).toBe(childMetaTagValues.ogTitle); | ||
expect(metaElements.ogDescription.content).toBe( | ||
childMetaTagValues.ogDescription | ||
); | ||
// meta tags inherited from parent route | ||
expect(metaElements.keywords.content).toBe(parentMetaTagValues.keywords); | ||
expect(metaElements.ogImage.content).toBe(parentMetaTagValues.ogImage); | ||
})); | ||
|
||
it('lefts over meta tags from the previous route that are not changed', fakeAsync(() => { | ||
const { router, getMetaElements, parentMetaTagValues, childMetaTagValues } = | ||
setup(); | ||
|
||
router.navigateByUrl('/child'); | ||
tick(1000); | ||
|
||
router.navigateByUrl('/'); | ||
tick(); | ||
|
||
const metaElements = getMetaElements(); | ||
expect(metaElements.charset.getAttribute('charset')).toBe( | ||
parentMetaTagValues.charset | ||
); | ||
expect(metaElements.description.content).toBe( | ||
parentMetaTagValues.description | ||
); | ||
expect(metaElements.keywords.content).toBe(parentMetaTagValues.keywords); | ||
expect(metaElements.httpEquivRefresh.content).toBe( | ||
parentMetaTagValues.httpEquivRefresh | ||
); | ||
expect(metaElements.ogTitle.content).toBe(parentMetaTagValues.ogTitle); | ||
expect(metaElements.ogImage.content).toBe(parentMetaTagValues.ogImage); | ||
// meta tags that are not changed | ||
expect(metaElements.httpEquivContentSec.content).toBe( | ||
childMetaTagValues.httpEquivContentSec | ||
); | ||
expect(metaElements.author.content).toBe(childMetaTagValues.author); | ||
expect(metaElements.ogDescription.content).toBe( | ||
childMetaTagValues.ogDescription | ||
); | ||
})); | ||
}); |
Oops, something went wrong.