diff --git a/src/Router/DocumentMetaMiddleware.ts b/src/Router/DocumentMetaMiddleware.ts new file mode 100644 index 0000000..23edd7c --- /dev/null +++ b/src/Router/DocumentMetaMiddleware.ts @@ -0,0 +1,103 @@ +import { Middleware } from './Middleware'; +import type { Request } from './Request'; +import type { Meta, Response } from './Response'; + +export type TitleBuilder = (title: string | undefined, response: Response) => string; +export type MetaBuilder = (meta: Meta|undefined, response: Response) => Meta; + +/** + * Middleware to set the document title and meta tags upon navigation. + */ +export class DocumentMetaMiddleware extends Middleware { + /** + * Get the owner document. + */ + protected document?: Document; + + /** + * Title builder function. + */ + protected titleBuilder: TitleBuilder; + + /** + * Meta builder function. + */ + protected metaBuilder: MetaBuilder; + + /** + * Meta tags from last invocation. + */ + protected currentMeta: Meta = {}; + + /** + * Middleware rule constructor. + * @param document The owner document. + * @param titleBuilder The title builder function. + * @param metaBuilder The meta builder function. + */ + public constructor(document: Document | undefined = window.document, titleBuilder?: TitleBuilder, metaBuilder?: MetaBuilder) { + super({}); + + this.document = document; + this.titleBuilder = titleBuilder || ((title) => title || ''); + this.metaBuilder = metaBuilder || ((meta) => meta || {}); + } + + /** + * Set document title and meta tags. + * @inheritdoc + */ + public hookAfter(request: Readonly, response: Response): Response { + this.setTitle(this.titleBuilder(response.title, response)); + this.setMeta(this.metaBuilder(response.meta, response)); + + return response; + } + + /** + * Update title. + * @param string The title string. + */ + protected setTitle(title: string): void { + if (this.document === undefined) { + return; + } + + this.document.title = title; + } + + /** + * Update meta tags. + * @param current Metadata for current state. + * @param previous Previous metadata. + */ + protected setMeta(meta: Meta): void { + if (this.document === undefined) { + return; + } + + const head = this.document.head; + Object.entries(meta).forEach(([name, content]) => { + let meta = head.querySelector(`meta[name="${name}"]`); + if (meta !== null) { + meta.setAttribute('content', content); + + return; + } + + meta = head.ownerDocument.createElement('meta'); + meta.setAttribute('name', name); + meta.setAttribute('content', content); + head.appendChild(meta); + }); + + Object.keys(this.currentMeta) + .filter((name) => !(name in meta)) + .forEach((name) => { + const meta = head.querySelector(`meta[name="${name}"]`); + if (meta !== null) { + meta.remove(); + } + }); + } +} diff --git a/src/Router/Response.ts b/src/Router/Response.ts index 4f8b13a..fdbb733 100644 --- a/src/Router/Response.ts +++ b/src/Router/Response.ts @@ -10,6 +10,11 @@ import { Request } from './Request'; */ export type View = (request: Request, response: Response) => Template; +/** + * A set of metatags to be set on the page. + */ +export type Meta = { [key: string]: string }; + /** * A class representing the response for a new page request in the app. */ @@ -39,10 +44,39 @@ export class Response { // eslint-disable-next-line @typescript-eslint/no-explicit-any public data: any; + protected _title?: string|undefined; + /** * The title of the response. */ - public title?: string; + public get title(): string|undefined { + return this._childResponse?.title ?? this._title; + } + + /** + * Set the title of the response. + * @deprecated Use setTitle() instead. + */ + public set title(title: string|undefined) { + this.setTitle(title); + } + + protected _meta?: Meta|undefined; + + /** + * The metadata associated to the response. + */ + public get meta(): Meta|undefined { + return this._childResponse?.meta ?? this._meta; + } + + /** + * Set the metadata associated to the response. + * @deprecated Use setMeta() instead. + */ + public set meta(meta: Meta|undefined) { + this.setMeta(meta); + } /** * The view of the response. @@ -102,8 +136,22 @@ export class Response { * Set the title of the Response. * @param title The string to set. */ - setTitle(title: string) { - this.title = title; + setTitle(title: string|undefined) { + this._title = title; + if (this._childResponse) { + this._childResponse.setTitle(title); + } + } + + /** + * Set metadata to be associated to the response. + * @param meta The metadata to set. + */ + setMeta(meta: Meta|undefined) { + this._meta = meta; + if (this._childResponse) { + this._childResponse.setMeta(meta); + } } /** diff --git a/src/Router/Router.ts b/src/Router/Router.ts index c2904a4..48b2bd9 100644 --- a/src/Router/Router.ts +++ b/src/Router/Router.ts @@ -522,9 +522,6 @@ export class Router extends Factory.Emitter { this.index = state.index; this.states.splice(state.index, this.states.length, state); if (this.history) { - if (this.history === window.history) { - window.document.title = state.title; - } this.history.pushState({ id: state.id, url: state.url, @@ -551,9 +548,6 @@ export class Router extends Factory.Emitter { const previous = this.states[this.index]; this.states.splice(state.index, this.states.length, state); if (this.history) { - if (this.history === window.history) { - window.document.title = state.title; - } this.history.replaceState({ id: state.id, url: state.url, @@ -585,9 +579,6 @@ export class Router extends Factory.Emitter { state = this.states[newState.index]; this.index = newState.index; } - if (this.history === window.history) { - window.document.title = state.title; - } await this.trigger('popstate', { previous, state, diff --git a/src/index.ts b/src/index.ts index d10129e..fb39af5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,3 +6,4 @@ export { History } from './Router/History'; export { Router } from './Router/Router'; export { App } from './App'; export * from './helpers'; +export { DocumentMetaMiddleware } from './Router/DocumentMetaMiddleware';