464 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			464 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| '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;
 | |
|     }
 | |
| };
 |