@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
375 lines (333 loc) • 9.27 kB
JavaScript
/**
* 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");
}
validateString(text);
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("");
}