|
|
'use strict';
const Assert = require('@hapi/hoek/lib/assert'); const Clone = require('@hapi/hoek/lib/clone'); const EscapeHtml = require('@hapi/hoek/lib/escapeHtml'); const Formula = require('@sideway/formula');
const Common = require('./common'); const Errors = require('./errors'); const Ref = require('./ref');
const internals = { symbol: Symbol('template'),
opens: new Array(1000).join('\u0000'), closes: new Array(1000).join('\u0001'),
dateFormat: { date: Date.prototype.toDateString, iso: Date.prototype.toISOString, string: Date.prototype.toString, time: Date.prototype.toTimeString, utc: Date.prototype.toUTCString } };
module.exports = exports = internals.Template = class {
constructor(source, options) {
Assert(typeof source === 'string', 'Template source must be a string'); Assert(!source.includes('\u0000') && !source.includes('\u0001'), 'Template source cannot contain reserved control characters');
this.source = source; this.rendered = source;
this._template = null;
if (options) { const { functions, ...opts } = options; this._settings = Object.keys(opts).length ? Clone(opts) : undefined; this._functions = functions; if (this._functions) { Assert(Object.keys(this._functions).every((key) => typeof key === 'string'), 'Functions keys must be strings'); Assert(Object.values(this._functions).every((key) => typeof key === 'function'), 'Functions values must be functions'); } } else { this._settings = undefined; this._functions = undefined; }
this._parse(); }
_parse() {
// 'text {raw} {{ref}} \\{{ignore}} {{ignore\\}} {{ignore {{ignore}'
if (!this.source.includes('{')) { return; }
// Encode escaped \\{{{{{
const encoded = internals.encode(this.source);
// Split on first { in each set
const parts = internals.split(encoded);
// Process parts
let refs = false; const processed = []; const head = parts.shift(); if (head) { processed.push(head); }
for (const part of parts) { const raw = part[0] !== '{'; const ender = raw ? '}' : '}}'; const end = part.indexOf(ender); if (end === -1 || // Ignore non-matching closing
part[1] === '{') { // Ignore more than two {
processed.push(`{${internals.decode(part)}`); continue; }
let variable = part.slice(raw ? 0 : 1, end); const wrapped = variable[0] === ':'; if (wrapped) { variable = variable.slice(1); }
const dynamic = this._ref(internals.decode(variable), { raw, wrapped }); processed.push(dynamic); if (typeof dynamic !== 'string') { refs = true; }
const rest = part.slice(end + ender.length); if (rest) { processed.push(internals.decode(rest)); } }
if (!refs) { this.rendered = processed.join(''); return; }
this._template = processed; }
static date(date, prefs) {
return internals.dateFormat[prefs.dateFormat].call(date); }
describe(options = {}) {
if (!this._settings && options.compact) {
return this.source; }
const desc = { template: this.source }; if (this._settings) { desc.options = this._settings; }
if (this._functions) { desc.functions = this._functions; }
return desc; }
static build(desc) {
return new internals.Template(desc.template, desc.options || desc.functions ? { ...desc.options, functions: desc.functions } : undefined); }
isDynamic() {
return !!this._template; }
static isTemplate(template) {
return template ? !!template[Common.symbols.template] : false; }
refs() {
if (!this._template) { return; }
const refs = []; for (const part of this._template) { if (typeof part !== 'string') { refs.push(...part.refs); } }
return refs; }
resolve(value, state, prefs, local) {
if (this._template && this._template.length === 1) {
return this._part(this._template[0], /* context -> [*/ value, state, prefs, local, {} /*] */); }
return this.render(value, state, prefs, local); }
_part(part, ...args) {
if (part.ref) { return part.ref.resolve(...args); }
return part.formula.evaluate(args); }
render(value, state, prefs, local, options = {}) {
if (!this.isDynamic()) { return this.rendered; }
const parts = []; for (const part of this._template) { if (typeof part === 'string') { parts.push(part); } else { const rendered = this._part(part, /* context -> [*/ value, state, prefs, local, options /*] */); const string = internals.stringify(rendered, value, state, prefs, local, options); if (string !== undefined) { const result = part.raw || (options.errors && options.errors.escapeHtml) === false ? string : EscapeHtml(string); parts.push(internals.wrap(result, part.wrapped && prefs.errors.wrap.label)); } } }
return parts.join(''); }
_ref(content, { raw, wrapped }) {
const refs = []; const reference = (variable) => {
const ref = Ref.create(variable, this._settings); refs.push(ref); return (context) => {
const resolved = ref.resolve(...context); return resolved !== undefined ? resolved : null; }; };
try { const functions = this._functions ? { ...internals.functions, ...this._functions } : internals.functions; var formula = new Formula.Parser(content, { reference, functions, constants: internals.constants }); } catch (err) { err.message = `Invalid template variable "${content}" fails due to: ${err.message}`; throw err; }
if (formula.single) { if (formula.single.type === 'reference') { const ref = refs[0]; return { ref, raw, refs, wrapped: wrapped || ref.type === 'local' && ref.key === 'label' }; }
return internals.stringify(formula.single.value); }
return { formula, raw, refs }; }
toString() {
return this.source; } };
internals.Template.prototype[Common.symbols.template] = true; internals.Template.prototype.isImmutable = true; // Prevents Hoek from deep cloning schema objects
internals.encode = function (string) {
return string .replace(/\\(\{+)/g, ($0, $1) => {
return internals.opens.slice(0, $1.length); }) .replace(/\\(\}+)/g, ($0, $1) => {
return internals.closes.slice(0, $1.length); }); };
internals.decode = function (string) {
return string .replace(/\u0000/g, '{') .replace(/\u0001/g, '}'); };
internals.split = function (string) {
const parts = []; let current = '';
for (let i = 0; i < string.length; ++i) { const char = string[i];
if (char === '{') { let next = ''; while (i + 1 < string.length && string[i + 1] === '{') {
next += '{'; ++i; }
parts.push(current); current = next; } else { current += char; } }
parts.push(current); return parts; };
internals.wrap = function (value, ends) {
if (!ends) { return value; }
if (ends.length === 1) { return `${ends}${value}${ends}`; }
return `${ends[0]}${value}${ends[1]}`; };
internals.stringify = function (value, original, state, prefs, local, options = {}) {
const type = typeof value; const wrap = prefs && prefs.errors && prefs.errors.wrap || {};
let skipWrap = false; if (Ref.isRef(value) && value.render) {
skipWrap = value.in; value = value.resolve(original, state, prefs, local, { in: value.in, ...options }); }
if (value === null) { return 'null'; }
if (type === 'string') { return internals.wrap(value, options.arrayItems && wrap.string); }
if (type === 'number' || type === 'function' || type === 'symbol') {
return value.toString(); }
if (type !== 'object') { return JSON.stringify(value); }
if (value instanceof Date) { return internals.Template.date(value, prefs); }
if (value instanceof Map) { const pairs = []; for (const [key, sym] of value.entries()) { pairs.push(`${key.toString()} -> ${sym.toString()}`); }
value = pairs; }
if (!Array.isArray(value)) { return value.toString(); }
const values = []; for (const item of value) { values.push(internals.stringify(item, original, state, prefs, local, { arrayItems: true, ...options })); }
return internals.wrap(values.join(', '), !skipWrap && wrap.array); };
internals.constants = {
true: true, false: false, null: null,
second: 1000, minute: 60 * 1000, hour: 60 * 60 * 1000, day: 24 * 60 * 60 * 1000 };
internals.functions = {
if(condition, then, otherwise) {
return condition ? then : otherwise; },
length(item) {
if (typeof item === 'string') { return item.length; }
if (!item || typeof item !== 'object') { return null; }
if (Array.isArray(item)) { return item.length; }
return Object.keys(item).length; },
msg(code) {
const [value, state, prefs, local, options] = this; const messages = options.messages; if (!messages) { return ''; }
const template = Errors.template(value, messages[0], code, state, prefs) || Errors.template(value, messages[1], code, state, prefs); if (!template) { return ''; }
return template.render(value, state, prefs, local, options); },
number(value) {
if (typeof value === 'number') { return value; }
if (typeof value === 'string') { return parseFloat(value); }
if (typeof value === 'boolean') { return value ? 1 : 0; }
if (value instanceof Date) { return value.getTime(); }
return null; } };
|