UNPKG

shelving

Version:

Toolkit for using data in JavaScript.

196 lines (195 loc) 9.34 kB
import { RequiredError } from "../error/RequiredError.js"; import { ValueError } from "../error/ValueError.js"; import { isArray } from "./array.js"; import { getDataProp, isData } from "./data.js"; import { EMPTY_DICTIONARY } from "./dictionary.js"; import { isFunction } from "./function.js"; import { setMapItem } from "./map.js"; import { getString } from "./string.js"; /** * RegExp to find named placeholders in several formats, each with optional catchall modifiers (`...prefix` or `*suffix` — three-or-more dots, one-or-more stars): * - Anonymous: `*` (single segment), `**` / `***` / etc. (catchall). * - Colon: `:name`, `:name*`, `:name**` (catchall). * - Dollar-brace: `${name}`, `${...name}`, `${name*}`, `${....name}`, `${name**}`. * - Double-brace: `{{name}}`, `{{...name}}`, `{{name*}}`. * - Single-brace: `{name}`, `{...name}`, `{name*}`. * - Square-bracket: `[name]`, `[...name]`, `[name*]`. */ const R_PLACEHOLDERS = /(\*+|:[a-z][a-z0-9]*\**|\$\{(?:\.{3,})?[a-z][a-z0-9]*\**\}|\{\{(?:\.{3,})?[a-z][a-z0-9]*\**\}\}|\{(?:\.{3,})?[a-z][a-z0-9]*\**\}|\[(?:\.{3,})?[a-z][a-z0-9]*\**\])/i; // Find actual name within template placeholder e.g. `${name}` → `name` const R_NAME = /[a-z0-9]+/i; /** * Split up a template into an array of separator → placeholder → separator → placeholder → separator * - Odd numbered chunks are separators. * - Even numbered chunks are placeholders. * * @param template The template including template placeholders, e.g. `:name-${country}/{city}` * @returns Array of strings alternating separator and placeholder. */ function _splitTemplate(template, caller) { const matches = template.split(R_PLACEHOLDERS); let asterisks = 0; const chunks = []; for (let i = 1; i < matches.length; i += 2) { const pre = matches[i - 1]; const placeholder = matches[i]; const post = matches[i + 1]; if (i > 1 && !pre.length) throw new ValueError("Template placeholders must be separated by at least one character", { received: template, caller }); let name; let catchall; if (placeholder[0] === "*") { // Anonymous: `*` is a single segment, `**` (or more stars) is a catchall. name = String(asterisks++); catchall = placeholder.length > 1; } else { name = R_NAME.exec(placeholder)?.[0] || ""; catchall = placeholder.includes("...") || placeholder.includes("*"); } chunks.push({ pre, placeholder, name, post, catchall }); } return chunks; } const TEMPLATE_CACHE = new Map(); function _splitTemplateCached(template, caller) { return TEMPLATE_CACHE.get(template) || setMapItem(TEMPLATE_CACHE, template, _splitTemplate(template, caller)); } /** * Get list of placeholders named in a template string. * * @param template The template including template placeholders, e.g. `:name-${country}/{city}` * @returns Array of clean string names of found placeholders, e.g. `["name", "country", "city"]` */ export function getPlaceholders(template) { return _splitTemplateCached(template, getPlaceholders).map(_getPlaceholder); } function _getPlaceholder({ name }) { return name; } /** * Match a template against a target string with no separator semantics. * - Turn `:year-:month` and `2016-06`... etc into `{ year: "2016"... }`. * - Non-catchall placeholders match non-empty values; catchall placeholders (`**`, `:name*`, `{...name}`, etc.) allow empty values. * * @param template The template string, e.g. `:name-${country}/{city}`. * @param target The string containing values, e.g. `Dave-UK/Manchester`. * * @return An object containing values (e.g. `{ name: "Dave", country: "UK", city: "Manchester" }`), or `undefined` if no match. */ export function matchTemplate(template, target, caller = matchTemplate) { return _matchTemplate(template, target, "", caller); } /** * Match a path-shaped template against a target path. * - Like `matchTemplate`, but with `/` segment semantics: non-catchall placeholders cannot span path segments; catchall placeholders can. * - A trailing catchall (e.g. `/files/{...path}`) also matches when the trailing separator is absent (e.g. `/files`), with the catchall value as `""`. */ export function matchPathTemplate(template, target, caller = matchPathTemplate) { return _matchTemplate(template, target, "/", caller); } function _matchTemplate(template, target, separator, caller) { // Get separators and placeholders from template. const chunks = _splitTemplateCached(template, caller); const firstChunk = chunks[0]; // Return early if empty. if (!firstChunk) return template === target ? EMPTY_DICTIONARY : undefined; // Special case: single trailing catchall whose `pre` ends with the separator (e.g. `/files/{...path}`) — also match the variant without the trailing separator (`/files`). if (separator && chunks.length === 1 && firstChunk.catchall && !firstChunk.post && firstChunk.pre.endsWith(separator) && target === firstChunk.pre.slice(0, -separator.length)) { return { [firstChunk.name]: "" }; } // Check first separator. if (!target.startsWith(firstChunk.pre)) return undefined; // target doesn't match template // Loop through the placeholders (placeholders are at all the even-numbered positions in `chunks`). let startIndex = firstChunk.pre.length; const values = {}; for (const { name, post, catchall } of chunks) { const stopIndex = !post ? Number.POSITIVE_INFINITY : target.indexOf(post, startIndex); if (stopIndex < 0) return undefined; // Target doesn't match template because chunk post wasn't found. const value = target.slice(startIndex, stopIndex); if (!catchall) { if (!value.length) return undefined; // Empty values only allowed for catchall placeholders. if (separator && value.includes(separator)) return undefined; // Non-catchall placeholders can't span separators (when one is configured). } values[name] = value; startIndex = stopIndex + post.length; } if (startIndex < target.length) return undefined; // Target doesn't match template because last chunk post didn't reach the end. return values; } /** Match multiple templates against a target string and return the first match (no separator semantics). */ export function matchTemplates(templates, target) { for (const template of templates) { const values = matchTemplate(template, target); if (values) return values; } } /** Match multiple path-shaped templates against a target path and return the first match. */ export function matchPathTemplates(templates, target) { for (const template of templates) { const values = matchPathTemplate(template, target); if (values) return values; } } /** * Turn ":year-:month" and `{ year: "2016"... }` etc into "2016-06..." etc. * * @param template The template including template placeholders, e.g. `:name-${country}/{city}` * @param values An object containing values, e.g. `{ name: "Dave", country: "UK", city: "Manchester" }` (functions are called, everything else converted to string), or a function or string to use for all placeholders. * @return The rendered string, e.g. `Dave-UK/Manchester` * * @throws {RequiredError} If a placeholder in the template string is not specified in values. */ export function renderTemplate(template, values, caller = renderTemplate) { const chunks = _splitTemplateCached(template, caller); if (!chunks.length) return template; let output = template; if (isFunction(values)) { for (const { name, placeholder } of chunks) output = output.replace(placeholder, values(name)); } else if (isData(values)) { for (const { name, placeholder } of chunks) { const v = getString(getDataProp(values, name)); if (v === undefined) throw new RequiredError(`Template placeholder "${name}" not found in object`, { received: values, name, caller }); output = output.replace(placeholder, v); } } else if (isArray(values)) { for (const { name, placeholder } of chunks) { const v = getString(values[Number(name)]); if (v === undefined) throw new RequiredError(`Template placeholder "${name}" not found in array`, { received: values, name, caller }); output = output.replace(placeholder, v); } } else { const v = getString(values); if (v === undefined) throw new RequiredError(`Template value must be string`, { received: values, caller }); for (const { placeholder } of chunks) output = output.replace(placeholder, v); } return output; } /** * Render a path-shaped template. Behaviourally identical to `renderTemplate` — substitution doesn't need separator awareness — but provided as a sibling to `matchPathTemplate` so callers can pair them. */ export function renderPathTemplate(template, values, caller = renderPathTemplate) { return renderTemplate(template, values, caller); }