shelving
Version:
Toolkit for using data in JavaScript.
196 lines (195 loc) • 9.34 kB
JavaScript
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);
}