Skip to content

Commit

Permalink
feat(router): add ability to provide meta tags using RouteMeta (analo…
Browse files Browse the repository at this point in the history
  • Loading branch information
markostanimirovic authored and Villanuevand committed Sep 12, 2023
1 parent 683d17e commit e98f87d
Show file tree
Hide file tree
Showing 18 changed files with 559 additions and 50 deletions.
6 changes: 6 additions & 0 deletions apps/blog-app/src/app/blog/models.ts
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;
}
44 changes: 44 additions & 0 deletions apps/blog-app/src/app/blog/resolvers.ts
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,
},
];
};
20 changes: 10 additions & 10 deletions apps/blog-app/src/app/routes/blog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,26 @@ import { Component } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
import { injectContentFiles } from '@analogjs/content';
import { NgFor } from '@angular/common';
import { RouteMeta } from '@analogjs/router';
import { PostAttributes } from '../blog/models';

export const routeMeta: RouteMeta = {
title: 'Analog Blog',
meta: [{ name: 'description', content: 'Analog Blog Posts' }],
};

interface MyAttributes {
title: string;
slug: string;
}
@Component({
selector: 'blog',
standalone: true,
imports: [RouterOutlet, RouterLink, NgFor],
template: `
<ng-container *ngFor="let attribute of fileAttributes">
<a [routerLink]="attribute.slug"> {{ attribute.title }}</a> |
<ng-container *ngFor="let post of posts">
<a [routerLink]="post.attributes.slug"> {{ post.attributes.title }}</a> |
</ng-container>
<a routerLink="/about">About</a>
<router-outlet></router-outlet>
`,
})
export default class BlogComponent {
public fileAttributes = injectContentFiles<MyAttributes>().map(
(file) => file.attributes
);
readonly posts = injectContentFiles<PostAttributes>();
}
18 changes: 12 additions & 6 deletions apps/blog-app/src/app/routes/blog/[slug].ts
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>();
}
2 changes: 2 additions & 0 deletions apps/blog-app/src/content/2022-12-27-my-first-post.md
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
2 changes: 2 additions & 0 deletions apps/blog-app/src/content/2022-12-31-my-second-post.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
---
title: My Second Post
slug: 2022-12-31-my-second-post
description: My Second Post Description
coverImage: https://images.unsplash.com/photo-1481627834876-b7833e8f5570?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=928&q=80
---

- [Home](/)
Expand Down
4 changes: 0 additions & 4 deletions apps/blog-app/src/lib/blog-attributes.ts

This file was deleted.

3 changes: 2 additions & 1 deletion packages/router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export {
injectRouter,
} from './lib/define-route';
export { RouteMeta } from './lib/models';
export { provideFileRouter } from './lib/provide-file-routes';
export { provideFileRouter } from './lib/provide-file-router';
export { MetaTag } from './lib/meta-tags';
226 changes: 226 additions & 0 deletions packages/router/src/lib/meta-tags.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import { Component, ENVIRONMENT_INITIALIZER } from '@angular/core';
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { DOCUMENT } from '@angular/common';
import { Route, Router, RouterOutlet } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { map, timer } from 'rxjs';
import {
MetaTag,
ROUTE_META_TAGS_KEY,
updateMetaTagsOnRouteChange,
} from './meta-tags';

describe('updateMetaTagsOnRouteChange', () => {
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: () => updateMetaTagsOnRouteChange(),
},
],
});

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
);
}));
});
Loading

0 comments on commit e98f87d

Please sign in to comment.