UNPKG

shelving

Version:

Toolkit for using data in JavaScript.

195 lines (171 loc) 7.61 kB
import type { ImmutableArray } from "../../util/array.js"; import { type DictionaryItem, getDictionaryItems, type ImmutableDictionary } from "../../util/dictionary.js"; import type { AnyCaller } from "../../util/function.js"; import { type PossibleLink, requireLink } from "../../util/link.js"; import type { Nullish } from "../../util/null.js"; import { type ImmutableURI, type PossibleURI, type PossibleURIParams, withURIParams } from "../../util/uri.js"; import { type ImmutableURL, type PossibleURL, requireURL } from "../../util/url.js"; /** Set of named meta `<meta />` tags in `{ name: content }` format. */ export type MetaTags = ImmutableDictionary<string | boolean | null | undefined>; /** Set of named meta `<link />` tags in `{ rel: href }` format. */ export type MetaLinks = ImmutableDictionary<ImmutableURI>; /** Set of named meta `<link />` tags in `{ rel: href }` format. */ export type PossibleMetaLinks = ImmutableDictionary<Nullish<PossibleLink>>; /** Set of linked assets in `(href)[]` format. */ export type MetaAssets = ImmutableArray<ImmutableURI>; /** Set of linked assets in `(href)[]` format. */ export type PossibleMetaAssets = ImmutableArray<Nullish<PossibleLink>>; /** Type for a meta `Content-Security-Policy` tag in `{ resource: string[] }` format. */ export type MetaCSP = { readonly [resource: string]: string[] }; /** Combined meta data for a website page. */ export interface Meta { /** Base URL for the app (used to resolve `url` and set as `<base>` tag in `<Head>`). */ readonly root?: ImmutableURL | undefined; /** URL of the current page (used to update history API and as the initial URL for routing). */ readonly url?: ImmutableURL | undefined; /** Title of the entire application. */ readonly app?: string | undefined; /** Title of the current page (set as `<title>` in `<Head>` */ readonly title?: string | undefined; /** Description of the current page. */ readonly description?: string | undefined; readonly image?: string | undefined; /** Language code (used for `lang` tag in HTML). */ readonly language?: string | undefined; // Meta. readonly csp?: MetaCSP | undefined; readonly tags?: MetaTags | undefined; // Links and assets. readonly links?: MetaLinks | undefined; readonly modules?: MetaAssets | undefined; readonly scripts?: MetaAssets | undefined; readonly stylesheets?: MetaAssets | undefined; } /** Input metadata that can be parsed and converted to proper metadata. */ export interface PossibleMeta extends Omit<Meta, "root" | "url" | "links" | "scripts" | "modules" | "stylesheets"> { /** Base URL for the app — accepts a string or `URL`, resolved with `requireURL()`. */ readonly root?: PossibleURL | undefined; /** * New URL for the page. * - Resolved using `requireURL()` if set relative to `root` */ readonly url?: PossibleURI | undefined; /** * Set the params in the URL (not merged with existing params). * - Added to `url` after it is resolved. * - Baseically */ readonly params?: PossibleURIParams | undefined; // Possible links and assets. readonly links?: PossibleMetaLinks | undefined; readonly modules?: PossibleMetaAssets | undefined; readonly scripts?: PossibleMetaAssets | undefined; readonly stylesheets?: PossibleMetaAssets | undefined; } /** Turn a deconstructed CSP into a string. */ export function joinMetaCSP(csp: Nullish<MetaCSP>): string | undefined { if (typeof csp === "string") return csp; if (csp !== null && csp !== undefined) return Object.entries(csp).map(_mapCSP).join("; "); } const _mapCSP = ([key, content]: [string, string[]]) => `${key} ${content.join(" ")}`; /** Merge two page or site titles together, e.g. `Manchester Runners` + `Messages` becomes `Messages - Manchester Runners` */ export function joinTitles(...titles: (string | undefined)[]): string { return titles.filter(Boolean).join(" - "); } /** * Merge two `MetaData` objects. * - `title` is merged. * - `URL` is resolved to an absolute URL, e.g. `./d/e/f` + `/a/b/c` becomes `https://d.com/a/b/c/d/e/f` * - `stylesheets` and `links` hrefs newly set in `meta2` are absolutified against the merged `url`/`base`, so they stay correct no matter where they are later rendered. */ export function mergeMeta(meta1: Meta, meta2: PossibleMeta, caller: AnyCaller = mergeMeta): Meta { const title = joinTitles(meta2.title, meta1.title); const root = mergeMetaURL(undefined, meta1.root, meta2.root, undefined, caller); const url = mergeMetaURL(root, meta1.url, meta2.url, meta2.params, caller); return { ...meta1, ...meta2, root, url, title, tags: mergeMetaTags(meta1.tags, meta2.tags), links: mergeMetaLinks(meta1.links, meta2.links, url, root, caller), modules: mergeMetaAssets(meta1.modules, meta2.modules, url, root, caller), scripts: mergeMetaAssets(meta1.scripts, meta2.scripts, url, root, caller), stylesheets: mergeMetaAssets(meta1.stylesheets, meta2.stylesheets, url, root, caller), }; } /** * Create a fully-formed `Meta` from a `PossibleMeta`. * - Like `mergeMeta()` but with no previous `Meta` to merge into — initialises meta from scratch. */ export function createMeta(meta: PossibleMeta, caller: AnyCaller = createMeta): Meta { return mergeMeta({}, meta, caller); } /** * Merge two metadata URLs. * - New URL is resolved relative to: current URL, new base URL, current base URL */ export function mergeMetaURL( base: ImmutableURL | undefined, current: ImmutableURL | undefined, next: PossibleURL | undefined, params: PossibleURIParams | undefined, caller: AnyCaller = mergeMetaURL, ): ImmutableURL | undefined { const url = next ? requireURL(next, base, caller) : current; return url && params ? withURIParams(url, params, caller) : url; } /** * Merge two metadata tags. * - New assets are resolved relative to current URL (relative paths) and root URL (absolute paths). */ export function mergeMetaTags(current: MetaTags | undefined, next: MetaTags | undefined): MetaTags | undefined { return current && next ? { ...current, ...next } : current || next; } /** * Merge two metadata link lists. * - New assets are resolved relative to current URL (relative paths) and root URL (absolute paths). */ export function mergeMetaLinks( current: MetaLinks | undefined, next: PossibleMetaLinks | undefined, url: ImmutableURL | undefined, root: ImmutableURL | undefined, caller: AnyCaller = mergeMetaLinks, ): MetaLinks | undefined { return next ? { ...current, ..._yieldMetaLinkEntries(next, url, root, caller) } : current; } function* _yieldMetaLinkEntries( links: PossibleMetaLinks, url: ImmutableURL | undefined, root: ImmutableURL | undefined, caller: AnyCaller, ): Iterable<DictionaryItem<ImmutableURI>> { for (const [k, link] of getDictionaryItems(links)) if (link) yield [k, requireLink(link, url, root, caller)]; } /** * Merge two metadata asset lists. * - New assets are resolved relative to current URL (relative paths) and root URL (absolute paths). */ export function mergeMetaAssets( current: MetaAssets | undefined, next: PossibleMetaAssets | undefined, url: ImmutableURL | undefined, root: ImmutableURL | undefined, caller: AnyCaller = mergeMetaAssets, ): MetaAssets | undefined { if (next) { const mapped = _yieldMetaAssets(next, url, root, caller); return current ? [...current, ...mapped] : Array.from(mapped); } return current; } function* _yieldMetaAssets( assets: PossibleMetaAssets, url: ImmutableURL | undefined, root: ImmutableURL | undefined, caller: AnyCaller, ): Iterable<ImmutableURI> { for (const asset of assets) if (asset) yield requireLink(asset, url, root, caller); }