shelving
Version:
Toolkit for using data in JavaScript.
126 lines (125 loc) • 5.73 kB
JavaScript
import { RequiredError } from "../error/RequiredError.js";
import { ValueError } from "../error/ValueError.js";
import { EMPTY_DICTIONARY } from "./dictionary.js";
import { setMapItem } from "./map.js";
import { isObject } from "./object.js";
// RegExp to find named variables in several formats e.g. `:a`, `${b}`, `{{c}}` or `{d}`
const R_PLACEHOLDERS = /(\*|:[a-z][a-z0-9]*|\$\{[a-z][a-z0-9]*\}|\{\{[a-z][a-z0-9]*\}\}|\{[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 });
const name = placeholder === "*" ? String(asterisks++) : R_NAME.exec(placeholder)?.[0] || "";
chunks.push({ pre, placeholder, name, post });
}
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.
* - Turn ":year-:month" and "2016-06..." etc into `{ year: "2016"... }`
*
* @param templates Either a single template string, or an iterator that returns multiple template template strings.
* - Template strings can include placeholders (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 target didn't match the template.
*/
export function matchTemplate(template, target, caller = matchTemplate) {
// 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;
// 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 } 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 (!value.length)
return undefined; // Target doesn't match template because chunk value was missing.
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.
*/
export function matchTemplates(templates, target) {
for (const template of templates) {
const values = matchTemplate(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;
for (const { name, placeholder } of chunks)
output = output.replace(placeholder, _replaceTemplateKey(name, values, caller));
return output;
}
function _replaceTemplateKey(key, values, caller) {
if (typeof values === "string")
return values;
if (typeof values === "function")
return values(key);
if (isObject(values)) {
const v = values[key];
if (typeof v === "string")
return v;
}
throw new RequiredError(`Template key "${key}" must be defined`, { received: values, key, caller });
}