/** * Copyright © schukai GmbH and all contributing authors, {{copyRightYear}}. All rights reserved. * Node module: @schukai/monster * * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3). * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html * * For those who do not wish to adhere to the AGPLv3, a commercial license is available. * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms. * For more information about purchasing a commercial license, please contact schukai GmbH. * * SPDX-License-Identifier: AGPL-3.0 */ import { internalSymbol } from "../constants.mjs"; import { extend } from "../data/extend.mjs"; import { Pipe } from "../data/pipe.mjs"; import { BaseWithOptions } from "../types/basewithoptions.mjs"; import { isObject, isString } from "../types/is.mjs"; import { validateArray, validateString } from "../types/validate.mjs"; export { Formatter }; /** * @private * @type {symbol} */ const internalObjectSymbol = Symbol("internalObject"); /** * @private * @type {symbol} */ const watchdogSymbol = Symbol("watchdog"); /** * @private * @type {symbol} */ const markerOpenIndexSymbol = Symbol("markerOpenIndex"); /** * @private * @type {symbol} */ const markerCloseIndexSymbol = Symbol("markercloseIndex"); /** * @private * @type {symbol} */ const workingDataSymbol = Symbol("workingData"); /** * Messages can be formatted with the formatter. To do this, an object with the values must be passed to the formatter. The message can then contain placeholders. * * Look at the example below. The placeholders use the logic of Pipe. * * ## Marker in marker * * Markers can be nested. Here, the inner marker is resolved first `${subkey} ↦ 1 = ${mykey2}` and then the outer marker `${mykey2}`. * * ``` * const text = '${mykey${subkey}}'; * let obj = { * mykey2: "1", * subkey: "2" * }; * * new Formatter(obj).format(text); * // ↦ 1 * ``` * * ## Callbacks * * The values in a formatter can be adjusted via the commands of the `Transformer` or the`Pipe`. * There is also the possibility to use callbacks. * * const formatter = new Formatter({x: '1'}, { * callbacks: { * quote: (value) => { * return '"' + value + '"' * } * } * }); * * formatter.format('${x | call:quote}')) * // ↦ "1" * * ## Marker with parameter * * A string can also bring its own values. These must then be separated from the key by a separator `::`. * The values themselves must be specified in key/value pairs. The key must be separated from the value by a separator `=`. * * When using a pipe, you must pay attention to the separators. * * @example * * import {Formatter} from '@schukai/monster/source/text/formatter.mjs'; * * new Formatter({ * a: { * b: { * c: "Hello" * }, * d: "world", * } * }).format("${a.b.c} ${a.d | ucfirst}!"); // with pipe * * // ↦ Hello World! * * @license AGPLv3 * @since 1.12.0 * @copyright schukai GmbH */ class Formatter extends BaseWithOptions { /** * Default values for the markers are `${` and `}` * * @param object * @param options */ constructor(object, options) { super(options); this[internalObjectSymbol] = object || {}; this[markerOpenIndexSymbol] = 0; this[markerCloseIndexSymbol] = 0; } /** * @property {object} marker * @property {array} marker.open=["${"] * @property {array} marker.close=["${"] * @property {object} parameter * @property {string} parameter.delimiter="::" * @property {string} parameter.assignment="=" * @property {object} callbacks={} */ get defaults() { return extend({}, super.defaults, { marker: { open: ["${"], close: ["}"], }, parameter: { delimiter: "::", assignment: "=", }, callbacks: {}, }); } /** * Set new Parameter Character * * Default values for the chars are `::` and `=` * * ``` * formatter.setParameterChars('#'); * formatter.setParameterChars('[',']'); * formatter.setParameterChars('i18n{','}'); * ``` * * @param {string} delimiter * @param {string} assignment * @return {Formatter} * @since 1.24.0 * @throws {TypeError} value is not a string */ setParameterChars(delimiter, assignment) { if (delimiter !== undefined) { this[internalSymbol]["parameter"]["delimiter"] = validateString(delimiter); } if (assignment !== undefined) { this[internalSymbol]["parameter"]["assignment"] = validateString(assignment); } return this; } /** * Set new Marker * * Default values for the markers are `${` and `}` * * ``` * formatter.setMarker('#'); // open and close are both # * formatter.setMarker('[',']'); * formatter.setMarker('i18n{','}'); * ``` * * @param {array|string} open * @param {array|string|undefined} close * @return {Formatter} * @since 1.12.0 * @throws {TypeError} value is not a string */ setMarker(open, close) { if (close === undefined) { close = open; } if (isString(open)) open = [open]; if (isString(close)) close = [close]; this[internalSymbol]["marker"]["open"] = validateArray(open); this[internalSymbol]["marker"]["close"] = validateArray(close); return this; } /** * * @param {string} text * @return {string} * @throws {TypeError} value is not a string * @throws {Error} too deep nesting */ format(text) { this[watchdogSymbol] = 0; this[markerOpenIndexSymbol] = 0; this[markerCloseIndexSymbol] = 0; this[workingDataSymbol] = {}; return format.call(this, text); } } /** * @private * @return {string} */ function format(text) { this[watchdogSymbol]++; if (this[watchdogSymbol] > 20) { throw new Error("too deep nesting"); } const openMarker = this[internalSymbol]["marker"]["open"]?.[this[markerOpenIndexSymbol]]; const closeMarker = this[internalSymbol]["marker"]["close"]?.[this[markerCloseIndexSymbol]]; // contains no placeholders if (text.indexOf(openMarker) === -1 || text.indexOf(closeMarker) === -1) { return text; } let result = tokenize.call( this, validateString(text), openMarker, closeMarker, ); if ( this[internalSymbol]["marker"]["open"]?.[this[markerOpenIndexSymbol] + 1] ) { this[markerOpenIndexSymbol]++; } if ( this[internalSymbol]["marker"]["close"]?.[this[markerCloseIndexSymbol] + 1] ) { this[markerCloseIndexSymbol]++; } result = format.call(this, result); return result; } /** * @private * @license AGPLv3 * @since 1.12.0 * * @param {string} text * @param {string} openMarker * @param {string} closeMarker * @return {string} */ function tokenize(text, openMarker, closeMarker) { const formatted = []; const parameterAssignment = this[internalSymbol]["parameter"]["assignment"]; const parameterDelimiter = this[internalSymbol]["parameter"]["delimiter"]; const callbacks = this[internalSymbol]["callbacks"]; while (true) { const startIndex = text.indexOf(openMarker); // no marker if (startIndex === -1) { formatted.push(text); break; } else if (startIndex > 0) { formatted.push(text.substring(0, startIndex)); text = text.substring(startIndex); } let endIndex = text.substring(openMarker.length).indexOf(closeMarker); if (endIndex !== -1) endIndex += openMarker.length; let insideStartIndex = text .substring(openMarker.length) .indexOf(openMarker); if (insideStartIndex !== -1) { insideStartIndex += openMarker.length; if (insideStartIndex < endIndex) { const result = tokenize.call( this, text.substring(insideStartIndex), openMarker, closeMarker, ); text = text.substring(0, insideStartIndex) + result; endIndex = text.substring(openMarker.length).indexOf(closeMarker); if (endIndex !== -1) endIndex += openMarker.length; } } if (endIndex === -1) { throw new Error("syntax error in formatter template"); } const key = text.substring(openMarker.length, endIndex); const parts = key.split(parameterDelimiter); const currentPipe = parts.shift(); this[workingDataSymbol] = extend( {}, this[internalObjectSymbol], this[workingDataSymbol], ); for (const kv of parts) { const [k, v] = kv.split(parameterAssignment); this[workingDataSymbol][k] = v; } const t1 = key.split("|").shift().trim(); // pipe symbol const t2 = t1.split("::").shift().trim(); // key value delimiter const t3 = t2.split(".").shift().trim(); // path delimiter const prefix = (t3 in this[workingDataSymbol]) ? "path:" : "static:"; let command = ""; if ( prefix && key.indexOf(prefix) !== 0 && key.indexOf("path:") !== 0 && key.indexOf("static:") !== 0 ) { command = prefix; } command += currentPipe; const pipe = new Pipe(command); if (isObject(callbacks)) { for (const [name, callback] of Object.entries(callbacks)) { pipe.setCallback(name, callback); } } formatted.push(validateString(pipe.run(this[workingDataSymbol]))); text = text.substring(endIndex + closeMarker.length); } return formatted.join(""); }