Skip to content

Commit

Permalink
feat(router): add ability to provide meta tags via RouteMeta
Browse files Browse the repository at this point in the history
  • Loading branch information
markostanimirovic committed Jan 23, 2023
1 parent 19beed1 commit dfed3e3
Show file tree
Hide file tree
Showing 9 changed files with 215 additions and 24 deletions.
13 changes: 13 additions & 0 deletions apps/blog-app/src/app/routes/blog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@ 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';

export const routeMeta: RouteMeta = {
title: 'Blog Title',
metaTags: [
{ charset: 'ascii' },
{ name: 'description', content: 'Blog Shell Description' },
{ property: 'og:title', content: 'Blog Og:Title' },
{ property: 'og:description', content: 'Blog Og:Description' },
{ httpEquiv: 'refresh', content: '1000' },
{ httpEquiv: 'content-security-policy', content: "default-src 'self'" },
],
};

interface MyAttributes {
title: string;
Expand Down
16 changes: 16 additions & 0 deletions apps/blog-app/src/app/routes/blog/[slug].ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@ 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 { map, timer } from 'rxjs';

export const routeMeta: RouteMeta = {
title: 'Blog Post Title',
metaTags: () =>
timer(1000).pipe(
map(() => [
{ charset: 'utf-8' },
{ name: 'description', content: 'Blog Post Description' },
{ name: 'author', content: 'AnalogJS Team' },
{ property: 'og:title', content: 'Blog Post Og:Title' },
{ httpEquiv: 'refresh', content: '3000' },
])
),
};

@Component({
selector: 'blog-post',
Expand Down
2 changes: 1 addition & 1 deletion packages/router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ 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';
99 changes: 99 additions & 0 deletions packages/router/src/lib/meta-tags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { inject } from '@angular/core';
import { Meta, MetaDefinition as NgMetaTag } from '@angular/platform-browser';
import { ActivatedRouteSnapshot, NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs/operators';

export const ROUTE_META_TAGS_KEY = Symbol(
'@analogjs/router Route Meta Tags Key'
);

const CHARSET_KEY = 'charset';
const HTTP_EQUIV_KEY = 'httpEquiv';
// httpEquiv selector key needs to be in kebab case format
const HTTP_EQUIV_SELECTOR_KEY = 'http-equiv';
const NAME_KEY = 'name';
const PROPERTY_KEY = 'property';
const CONTENT_KEY = 'content';

export type MetaTag =
| (CharsetMetaTag & ExcludeRestMetaTagKeys<typeof CHARSET_KEY>)
| (HttpEquivMetaTag & ExcludeRestMetaTagKeys<typeof HTTP_EQUIV_KEY>)
| (NameMetaTag & ExcludeRestMetaTagKeys<typeof NAME_KEY>)
| (PropertyMetaTag & ExcludeRestMetaTagKeys<typeof PROPERTY_KEY>);

type CharsetMetaTag = { [CHARSET_KEY]: string };
type HttpEquivMetaTag = { [HTTP_EQUIV_KEY]: string; [CONTENT_KEY]: string };
type NameMetaTag = { [NAME_KEY]: string; [CONTENT_KEY]: string };
type PropertyMetaTag = { [PROPERTY_KEY]: string; [CONTENT_KEY]: string };

type MetaTagKey =
| typeof CHARSET_KEY
| typeof HTTP_EQUIV_KEY
| typeof NAME_KEY
| typeof PROPERTY_KEY;
type ExcludeRestMetaTagKeys<Key extends MetaTagKey> = {
[K in Exclude<MetaTagKey, Key>]?: never;
};

type MetaTagSelector =
| typeof CHARSET_KEY
| `${
| typeof HTTP_EQUIV_SELECTOR_KEY
| typeof NAME_KEY
| typeof PROPERTY_KEY}="${string}"`;
type MetaTagMap = Record<MetaTagSelector, MetaTag>;

export function updateMetaTagsOnNavigationEnd(): void {
const router = inject(Router);
const metaService = inject(Meta);

router.events
.pipe(filter((event) => event instanceof NavigationEnd))
.subscribe(() => {
const metaTagMap = getMetaTagMap(router.routerState.snapshot.root);
setMetaTags(metaTagMap, metaService);
});
}

function setMetaTags(metaTagMap: MetaTagMap, metaService: Meta): void {
for (const metaTagSelector in metaTagMap) {
const metaTag = metaTagMap[metaTagSelector as MetaTagSelector] as NgMetaTag;

const updatedMetaTag = metaService.updateTag(metaTag, metaTagSelector);
if (!updatedMetaTag) {
metaService.addTag(metaTag);
}
}
}

function getMetaTagMap(route: ActivatedRouteSnapshot): MetaTagMap {
const metaTagMap = {} as MetaTagMap;
let currentRoute: ActivatedRouteSnapshot | null = route;

while (currentRoute) {
const metaTags: MetaTag[] = currentRoute.data[ROUTE_META_TAGS_KEY] ?? [];
for (const metaTag of metaTags) {
metaTagMap[getMetaTagSelector(metaTag)] = metaTag;
}

currentRoute = currentRoute.firstChild;
}

return metaTagMap;
}

function getMetaTagSelector(metaTag: MetaTag): MetaTagSelector {
if (metaTag.name) {
return `${NAME_KEY}="${metaTag.name}"`;
}

if (metaTag.property) {
return `${PROPERTY_KEY}="${metaTag.property}"`;
}

if (metaTag.httpEquiv) {
return `${HTTP_EQUIV_SELECTOR_KEY}="${metaTag.httpEquiv}"`;
}

return CHARSET_KEY;
}
11 changes: 7 additions & 4 deletions packages/router/src/lib/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,29 +9,32 @@ import {
} from '@angular/router';

import { defineRouteMeta } from './define-route';
import { MetaTag } from './meta-tags';

type OmittedRouteProps =
| 'path'
| 'pathMatch'
| 'matcher'
| 'redirectTo'
| 'component'
| 'loadComponent'
| 'children'
| 'loadChildren'
| 'canLoad'
| 'outlet';

interface DefaultRouteMeta extends Omit<Route, OmittedRouteProps> {
export type RouteConfig = Omit<Route, OmittedRouteProps>;

export interface DefaultRouteMeta
extends Omit<Route, OmittedRouteProps | keyof RedirectRouteMeta> {
canActivate?: CanActivateFn[];
canActivateChild?: CanActivateChildFn[];
canDeactivate?: CanDeactivateFn<unknown>[];
canMatch?: CanMatchFn[];
resolve?: { [key: string | symbol]: ResolveFn<unknown> };
title?: string | ResolveFn<string>;
metaTags?: MetaTag[] | ResolveFn<MetaTag[]>;
}

interface RedirectRouteMeta {
export interface RedirectRouteMeta {
redirectTo: string;
pathMatch?: Route['pathMatch'];
}
Expand Down
37 changes: 37 additions & 0 deletions packages/router/src/lib/provide-file-router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import {
ENVIRONMENT_INITIALIZER,
EnvironmentProviders,
makeEnvironmentProviders,
Provider,
} from '@angular/core';
import { provideRouter, RouterFeatures } from '@angular/router';

import { routes } from './routes';
import { updateMetaTagsOnNavigationEnd } from './meta-tags';

/**
* Sets up providers for the Angular router, and registers
* file-based routes. Additional features can be provided
* to further configure the behavior of the router.
*
* @param features
* @returns Providers and features to configure the router with routes
*/
export function provideFileRouter(
...features: RouterFeatures[]
): EnvironmentProviders {
return makeEnvironmentProviders([
// TODO: remove type casting after Angular >=15.1.1 upgrade
// https://github.com/angular/angular/pull/48720
(
provideRouter(routes, ...features) as unknown as {
ɵproviders: Provider[];
}
).ɵproviders,
{
provide: ENVIRONMENT_INITIALIZER,
multi: true,
useValue: () => updateMetaTagsOnNavigationEnd(),
},
]);
}
14 changes: 0 additions & 14 deletions packages/router/src/lib/provide-file-routes.ts

This file was deleted.

36 changes: 36 additions & 0 deletions packages/router/src/lib/route-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { RedirectRouteMeta, RouteConfig, RouteMeta } from './models';
import { ROUTE_META_TAGS_KEY } from './meta-tags';

export function toRouteConfig(routeMeta: RouteMeta | undefined): RouteConfig {
if (!routeMeta) {
return {};
}

if (isRedirectRouteMeta(routeMeta)) {
return routeMeta;
}

const { metaTags, ...routeConfig } = routeMeta;
if (!metaTags) {
return routeConfig;
}

return Array.isArray(metaTags)
? {
...routeConfig,
data: { ...(routeConfig.data ?? {}), [ROUTE_META_TAGS_KEY]: metaTags },
}
: {
...routeConfig,
resolve: {
...(routeConfig.resolve ?? {}),
[ROUTE_META_TAGS_KEY]: metaTags,
},
};
}

function isRedirectRouteMeta(
routeMeta: RouteMeta
): routeMeta is RedirectRouteMeta {
return !!routeMeta.redirectTo;
}
11 changes: 6 additions & 5 deletions packages/router/src/lib/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import type { Route } from '@angular/router';

import { RouteExport } from './models';
import { RouteExport, RouteMeta } from './models';
import { toRouteConfig } from './route-config';

const FILES = import.meta.glob<RouteExport>([
'/app/routes/**/*.ts',
Expand Down Expand Up @@ -79,7 +80,7 @@ export function getRoutes(files: Record<string, () => Promise<RouteExport>>) {
{
path: '',
component: m.default,
...m.routeMeta,
...toRouteConfig(m.routeMeta as RouteMeta | undefined),
},
]),
};
Expand Down Expand Up @@ -109,7 +110,7 @@ export function getRoutes(files: Record<string, () => Promise<RouteExport>>) {
{
path: '',
component: m.default,
...m.routeMeta,
...toRouteConfig(m.routeMeta as RouteMeta | undefined),
},
]),
});
Expand All @@ -131,7 +132,7 @@ export function getRoutes(files: Record<string, () => Promise<RouteExport>>) {
{
path: '',
component: m.default,
...m.routeMeta,
...toRouteConfig(m.routeMeta as RouteMeta | undefined),
},
]),
});
Expand All @@ -146,7 +147,7 @@ export function getRoutes(files: Record<string, () => Promise<RouteExport>>) {
{
path: '',
children: parent._children,
...m.routeMeta,
...toRouteConfig(m.routeMeta as RouteMeta | undefined),
},
];
});
Expand Down

0 comments on commit dfed3e3

Please sign in to comment.