rearrage_stuff
This commit is contained in:
72
utils/crackdown.js
Normal file
72
utils/crackdown.js
Normal file
@@ -0,0 +1,72 @@
|
||||
// ____ _ ____
|
||||
// / ___|_ __ __ _ ___| | _| _ \ _____ ___ __
|
||||
// | | | '__/ _` |/ __| |/ / | | |/ _ \ \ /\ / / '_ \
|
||||
// | |___| | | (_| | (__| <| |_| | (_) \ V V /| | | |
|
||||
// \____|_| \__,_|\___|_|\_\____/ \___/ \_/\_/ |_| |_|
|
||||
//
|
||||
//
|
||||
// _ __ __ _ _ __ ___ ___ _ __
|
||||
// | '_ \ / _` | '__/ __|/ _ \ '__|
|
||||
// | |_) | (_| | | \__ \ __/ |
|
||||
// | .__/ \__,_|_| |___/\___|_|
|
||||
// |_|
|
||||
|
||||
const capture = "([a-z0-9:()-](?:.*[a-zA-Z:().!-])?)";
|
||||
const skipSpace = "\\s*";
|
||||
|
||||
const htmlEscapeRegex = /[&<>"'`]/g; // used to escape html characters
|
||||
|
||||
/**
|
||||
* @type {Array.string[]}
|
||||
*
|
||||
* The order of the elements of this array matters.
|
||||
*/
|
||||
const opcodes = [
|
||||
["(^|\\n)=", "($|\\n)", "$1<h1>$2</h1>$3"],
|
||||
["(^|\\n)==", "($|\\n)", "$1<h2>$2</h2>$3"],
|
||||
["---", "---", "<span class='strike'>$1</span>"],
|
||||
["___", "___", "<span class='underline'>$1</span>"],
|
||||
["(?:[,]{3})", "(?:[,]{3})", "<span class='undercurl'>$1</span>"],
|
||||
["(?:[(]{2})", "(?:[)]{2})", "<span class='faint'>$1</span>"],
|
||||
["_", "_", "<span class='italic'>$1</span>"],
|
||||
["\\*", "\\*", "<span class='bold'>$1</span>"],
|
||||
["\\[\\[([a-zA-Z0-9_ ]+)\\[\\[", "\\]\\]", "<span class='$1'>$2</span>"],
|
||||
];
|
||||
/** @type{Array.Array.<Regexp,string>} */
|
||||
const regexes = [];
|
||||
|
||||
//
|
||||
// Pre-compile all regexes
|
||||
for (const [left, right, replacement] of opcodes) {
|
||||
regexes.push([new RegExp(left + skipSpace + capture + skipSpace + right, "gi"), replacement]);
|
||||
}
|
||||
|
||||
/** @param {string} text */
|
||||
export function crackdown(text) {
|
||||
text.replace(htmlEscapeRegex, (c) => {
|
||||
switch (c) {
|
||||
case "&":
|
||||
return "&";
|
||||
case "<":
|
||||
return "<";
|
||||
case ">":
|
||||
return ">";
|
||||
case '"':
|
||||
return """;
|
||||
case "'":
|
||||
return "'";
|
||||
case "`":
|
||||
return "`";
|
||||
default:
|
||||
return c;
|
||||
}
|
||||
});
|
||||
for (const k in regexes) {
|
||||
const [regex, replacement] = regexes[k];
|
||||
text = text.replace(regex, replacement);
|
||||
}
|
||||
|
||||
console.debug("crack output", text);
|
||||
|
||||
return text;
|
||||
}
|
||||
12
utils/dice.js
Normal file
12
utils/dice.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export function dice(sides) {
|
||||
const r = Math.random();
|
||||
return Math.floor(r * sides) + 1;
|
||||
}
|
||||
|
||||
export function d6() {
|
||||
return dice(6);
|
||||
}
|
||||
|
||||
export function d8() {
|
||||
return dice(8);
|
||||
}
|
||||
65
utils/id.js
Normal file
65
utils/id.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import * as regex from "./regex.js";
|
||||
|
||||
const MINI_UID_REGEX = regex.pretty(
|
||||
"\.uid\.", // Mini-uids always begin with ".uid."
|
||||
"[a-z0-9]{6,}$", // Terminated by 6 or more random numbers and lowercase letters.
|
||||
);
|
||||
const ID_SANITY_REGEX = regex.pretty(
|
||||
"^:", // All ids start with a colon
|
||||
"([a-z0-9]+\.)*?", // Middle -optional- part :myid.gogle.thing.thang.thong
|
||||
"[a-z0-9_]+$", // The terminating part of the id is numbers, lowercase letters, and -notably- underscores.
|
||||
);
|
||||
|
||||
/**
|
||||
* Sanity check a string to see if it is a potential id.
|
||||
*
|
||||
* @param {string} id
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isIdSane(id) {
|
||||
if (typeof id !== "string") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (id.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ID_SANITY_REGEX.test(id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string} crypto-unsafe pseudo random numbe"r.
|
||||
*
|
||||
* Generate a random number, convert it to base36, and return it as a string with 7-8 characters.
|
||||
*/
|
||||
export function miniUid() {
|
||||
// we use 12 digits, but we could go all the way to 16
|
||||
const digits = 12;
|
||||
return Number(Math.random().toFixed(digits).substring(2)).toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an id from a string
|
||||
* @param {string[]} str
|
||||
*/
|
||||
export function appendMiniUid(str) {
|
||||
return str + ".uid." + miniUid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Does a given string end with ".uid.23khtasdz", etc.
|
||||
*
|
||||
* @param {string} str
|
||||
*/
|
||||
export function endsWithMiniUid(str) {
|
||||
return MINI_UID_REGEX.test(str);
|
||||
}
|
||||
|
||||
export function appendOrReplaceMiniUid(str) {
|
||||
return appendMiniUid(str.replace(MINI_UID_REGEX, ""));
|
||||
}
|
||||
181
utils/messages.js
Executable file
181
utils/messages.js
Executable file
@@ -0,0 +1,181 @@
|
||||
import { mustBe, mustBeString, mustMatch } from "./mustbe.js";
|
||||
|
||||
export const MessageType = Object.freeze({
|
||||
/**
|
||||
* Very bad logic error. Player must quit game, refresh page, and log in again.
|
||||
*
|
||||
* Client-->Server
|
||||
* or
|
||||
* Server-->Client-->Plater
|
||||
*/
|
||||
CALAMITY: "CALAMITY",
|
||||
|
||||
/**
|
||||
* Tell recipient that an error has occurred
|
||||
*
|
||||
* Server-->Client-->Player
|
||||
*/
|
||||
ERROR: "E",
|
||||
|
||||
/**
|
||||
* Message to be displayed.
|
||||
*
|
||||
* Server-->Client-->Player
|
||||
*/
|
||||
TEXT: "T",
|
||||
|
||||
/**
|
||||
* Player has entered data, and sends it to server.
|
||||
*
|
||||
* Player-->Client-->Server
|
||||
*/
|
||||
REPLY: "R",
|
||||
|
||||
/**
|
||||
* Player wants to quit.
|
||||
*
|
||||
* Player-->Client-->Server
|
||||
*/
|
||||
QUIT: "QUIT",
|
||||
|
||||
/**
|
||||
* Player wants help
|
||||
*
|
||||
* Player-->Client-->Server
|
||||
*/
|
||||
HELP: "HELP",
|
||||
|
||||
/**
|
||||
* Server tells the client to prompt the player for some data
|
||||
*
|
||||
* Server-->Client-->Player
|
||||
*/
|
||||
PROMPT: "P",
|
||||
|
||||
/**
|
||||
* Server tells the client to prompt the player for some data
|
||||
*
|
||||
* Server-->Client-->Player
|
||||
*/
|
||||
SYSTEM: "_",
|
||||
|
||||
/**
|
||||
* Debug message, to be completely ignored in production
|
||||
*
|
||||
* Client-->Server
|
||||
* or
|
||||
* Server-->Client-->Plater
|
||||
*/
|
||||
DEBUG: "dbg",
|
||||
|
||||
/**
|
||||
* Player sent colon-prefixed, an out-of-order, command
|
||||
*
|
||||
* Player-->Client-->Server
|
||||
*/
|
||||
COLON: ":",
|
||||
});
|
||||
|
||||
/**
|
||||
* Represents a message sent to/from client
|
||||
*
|
||||
* @property {string?} command
|
||||
* @property {any[]} args
|
||||
*/
|
||||
export class WebsocketMessage {
|
||||
/** @protected @type {any[]} _arr The array that contains the message data */
|
||||
_data;
|
||||
|
||||
/** @constant @readonly @type {string} _arr The array that contains the message data */
|
||||
type;
|
||||
|
||||
/**
|
||||
* @param {string} msgData the raw text data in the websocket message.
|
||||
*/
|
||||
constructor(msgData) {
|
||||
if (typeof msgData !== "string") {
|
||||
throw new Error(
|
||||
"Could not create client message. Attempting to parse json, but data was not even a string, it was a " +
|
||||
typeof msgData,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(msgData);
|
||||
} catch (_) {
|
||||
throw new Error(
|
||||
`Could not create client message. Attempting to parse json, but data was invalid json: >>> ${msgData} <<<`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(`Could not create client message. Excpected an array, but got a ${typeof this._data}`);
|
||||
}
|
||||
|
||||
if (data.length < 1) {
|
||||
throw new Error(
|
||||
"Could not create client message. Excpected an array with at least 1 element, but got an empty one",
|
||||
);
|
||||
}
|
||||
|
||||
this.type = mustBeString(data[0]);
|
||||
|
||||
switch (this.type) {
|
||||
case MessageType.REPLY: // player ==> client ==> server
|
||||
this.text = mustBeString(data[1]);
|
||||
break;
|
||||
case MessageType.HELP: // player ==> client ==> server
|
||||
this.text = data[1] === undefined ? "" : mustBeString(data[1]).trim();
|
||||
break;
|
||||
case MessageType.COLON: // player ==> client ==> server
|
||||
this.command = mustMatch(data[1], /^[a-z0-9_]+$/);
|
||||
this.args = mustBe(data[2], "any[]");
|
||||
break;
|
||||
case MessageType.DEBUG: // server ==> client
|
||||
case MessageType.ERROR: // server ==> client ==> player
|
||||
case MessageType.QUIT: // player ==> client ==> server
|
||||
case MessageType.SYSTEM: // client <==> server
|
||||
case MessageType.PROMPT: // server ==> client ==> player
|
||||
case MessageType.TEXT: // server ==> client ==> player
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown message type: >>${typeof this.type}<<`);
|
||||
}
|
||||
}
|
||||
|
||||
isQuit() {
|
||||
return this.type === MessageType.QUIT;
|
||||
}
|
||||
|
||||
isHelp() {
|
||||
return this.type === MessageType.HELP;
|
||||
}
|
||||
|
||||
isColon() {
|
||||
return this.type === MessageType.COLON;
|
||||
}
|
||||
|
||||
isReply() {
|
||||
return this.type === MessageType.REPLY;
|
||||
}
|
||||
|
||||
isSysMessage() {
|
||||
return this.type === MessageType.SYSTEM;
|
||||
}
|
||||
|
||||
isDebug() {
|
||||
return this.type === MessageType.DEBUG;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a message type and some args, create a string that can be sent from the server to the client (or vise versa)
|
||||
*
|
||||
* @param {string} messageType
|
||||
* @param {...any} args
|
||||
*/
|
||||
export function formatMessage(messageType, ...args) {
|
||||
return JSON.stringify([messageType, ...args]);
|
||||
}
|
||||
44
utils/mustbe.js
Executable file
44
utils/mustbe.js
Executable file
@@ -0,0 +1,44 @@
|
||||
export function mustBe(value, ...types) {
|
||||
//
|
||||
// empty type enforcement.
|
||||
// Means we just want value to be define
|
||||
if (types.length === 0 && typeof value !== "undefined") {
|
||||
return value;
|
||||
}
|
||||
|
||||
//
|
||||
// value has a valid type
|
||||
if (types.includes(typeof value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const isArray = Array.isArray(value);
|
||||
|
||||
if (isArray && (types.includes("any[]") || types.includes("array"))) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// NOTE: only checks first element of array if it's a string.
|
||||
if (isArray && types.includes("strings[]") && (value.length === 0 || typeof value[0] === "string")) {
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new Error("Invalid data type. Expected >>" + types.join(" or ") + "<< but got " + typeof value);
|
||||
}
|
||||
|
||||
export function mustBeString(value) {
|
||||
return mustBe(value, "string");
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} str
|
||||
* @param {RegExp} regex
|
||||
*/
|
||||
export function mustMatch(str, regex) {
|
||||
if (!regex.test(str)) {
|
||||
throw new Error(`String did not satisfy ${regex}`);
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
82
utils/parseArgs.js
Executable file
82
utils/parseArgs.js
Executable file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Parse a command string into arguments. For use with colon-commands.
|
||||
*
|
||||
* @param {string} cmdString;
|
||||
* @returns {(string|number)[]} Command arguments
|
||||
*/
|
||||
export function parseArgs(cmdString) {
|
||||
if (typeof cmdString !== "string") {
|
||||
throw new Error("Expected string. GoT a finger in the eye instead");
|
||||
}
|
||||
const args = [];
|
||||
const quoteChars = ["'", '"', "`"];
|
||||
const backslash = "\\";
|
||||
|
||||
let currentArg = ""; // The arg we are currently constructing
|
||||
let inQuotes = false; // are we inside quotes of some kind?
|
||||
let currentQuoteChar = ""; // if were in quotes, which are they?
|
||||
|
||||
// helper function
|
||||
const pushVal = (value) => {
|
||||
const n = Number(value);
|
||||
if (Number.isSafeInteger(n)) {
|
||||
args.push(n);
|
||||
} else if (Number.isFinite(n)) {
|
||||
args.push(n);
|
||||
} else {
|
||||
args.push(value);
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < cmdString.length; i++) {
|
||||
const char = cmdString[i];
|
||||
const nextChar = cmdString[i + 1];
|
||||
|
||||
if (!inQuotes) {
|
||||
// Not in quotes - look for quote start or whitespace
|
||||
if (quoteChars.includes(char)) {
|
||||
inQuotes = true;
|
||||
currentQuoteChar = char;
|
||||
} else if (char === " " || char === "\t") {
|
||||
// Whitespace - end current arg if it exists
|
||||
if (currentArg) {
|
||||
pushVal(currentArg);
|
||||
currentArg = "";
|
||||
}
|
||||
// Skip multiple whitespace
|
||||
while (cmdString[i + 1] === " " || cmdString[i + 1] === "\t") i++;
|
||||
} else {
|
||||
currentArg += char;
|
||||
}
|
||||
} else {
|
||||
// Inside quotes
|
||||
if (char === currentQuoteChar) {
|
||||
// Found matching quote - end quoted section
|
||||
inQuotes = false;
|
||||
currentQuoteChar = "";
|
||||
} else if (char === backslash && (nextChar === currentQuoteChar || nextChar === backslash)) {
|
||||
// Escape sequence - add the escaped character
|
||||
currentArg += nextChar;
|
||||
//
|
||||
// Todo, maybe add support for \n newlines? Why would I ?
|
||||
//
|
||||
i++; // Skip next character
|
||||
} else {
|
||||
currentArg += char;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add final argument if exists
|
||||
if (currentArg) {
|
||||
pushVal(currentArg);
|
||||
}
|
||||
|
||||
if (currentQuoteChar) {
|
||||
// We allow quotes to not be terminated
|
||||
// It allows players to do stuff like `:say "wolla my lovely friend` and not have the text modified or misinterpreted in any way
|
||||
// May be good for chat where you dont want every word split into individual arguments
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
99
utils/random.js
Normal file
99
utils/random.js
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Pseudo random number generator
|
||||
* using the xorshift32 method.
|
||||
*/
|
||||
export class Xorshift32 {
|
||||
/* @type {number} */
|
||||
initialSeed;
|
||||
|
||||
/**
|
||||
* State holds a single uint32.
|
||||
* It's useful for staying within modulo 2**32.
|
||||
*
|
||||
* @type {Uint32Array}
|
||||
*/
|
||||
state;
|
||||
|
||||
/** @param {number} seed */
|
||||
constructor(seed) {
|
||||
if (seed === undefined) {
|
||||
const maxInt32 = 2 ** 32;
|
||||
seed = Math.floor(Math.random() * (maxInt32 - 1)) + 1;
|
||||
}
|
||||
seed = seed | 0;
|
||||
console.info("RNG Initial Seed %d", seed);
|
||||
this.state = Uint32Array.of(seed);
|
||||
}
|
||||
|
||||
/** @protected Shuffle the internal state. */
|
||||
shuffle() {
|
||||
console.log("RNG Shuffle: Initial State: %d", this.state);
|
||||
this.state[0] ^= this.state[0] << 13;
|
||||
this.state[0] ^= this.state[0] >>> 17;
|
||||
this.state[0] ^= this.state[0] << 5;
|
||||
|
||||
// We could also do something like this:
|
||||
// x ^= x << 13;
|
||||
// x ^= x >> 17;
|
||||
// x ^= x << 5;
|
||||
// return x;
|
||||
// But we'd have to xor the x with 2^32 after every op,
|
||||
// we get that "for free" by using the uint32array
|
||||
|
||||
console.log("RNG Shuffle: Exit State: %d", this.state);
|
||||
return this.state[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a random number and shuffle the internal state.
|
||||
* @returns {number} a pseudo-random positive integer.
|
||||
*/
|
||||
get() {
|
||||
this.shuffle();
|
||||
return this.state;
|
||||
}
|
||||
|
||||
/** @param {number} x @returns {number} a positive integer lower than x */
|
||||
lowerThan(x) {
|
||||
return this.get() % x;
|
||||
}
|
||||
|
||||
/** @param {number} x @reurns {number} a positive integer lower than or equal to x */
|
||||
lowerThanOrEqual(x) {
|
||||
return this.get() % (x + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {<T>[]} arr
|
||||
*
|
||||
* @return {<T>}
|
||||
*/
|
||||
randomElement(arr) {
|
||||
const idx = this.lowerThan(arr.length);
|
||||
|
||||
return arr[idx];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {...<T>} args
|
||||
* @returns {<T>}
|
||||
*/
|
||||
oneOf(...args) {
|
||||
const idx = this.lowerThan(args.length);
|
||||
|
||||
return args[idx];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} lowerThanOrEqual a positive integer
|
||||
* @param {number} greaterThanOrEqual a positive integer greater than lowerThanOrEqual
|
||||
* @returns {number} a pseudo-random integer
|
||||
*/
|
||||
within(greaterThanOrEqual, lowerThanOrEqual) {
|
||||
const range = lowerThanOrEqual - greaterThanOrEqual;
|
||||
|
||||
const num = this.lowerThanOrEqual(range);
|
||||
|
||||
return num + greaterThanOrEqual;
|
||||
}
|
||||
}
|
||||
10
utils/regex.js
Normal file
10
utils/regex.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Makes it easier to document regexes because you can break them up
|
||||
*
|
||||
* @param {...string} args
|
||||
* @returns {Regexp}
|
||||
*/
|
||||
export function pretty(...args) {
|
||||
const regexprStr = args.join("");
|
||||
return new RegExp(regexprStr);
|
||||
}
|
||||
68
utils/security.js
Normal file
68
utils/security.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import { randomBytes, pbkdf2Sync } from "node:crypto";
|
||||
import { Config } from "../config.js";
|
||||
|
||||
// Settings (tune as needed)
|
||||
const ITERATIONS = 1000;
|
||||
const KEYLEN = 32; // 32-bit hash
|
||||
const DIGEST = "sha256";
|
||||
const DEV = process.env.NODE_ENV === "dev";
|
||||
|
||||
/**
|
||||
* Generate a hash from a plaintext password.
|
||||
* @param {string} password
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generateHash(password) {
|
||||
const salt = randomBytes(16).toString("hex"); // 128-bit salt
|
||||
const hash = pbkdf2Sync(password, salt, ITERATIONS, KEYLEN, DIGEST).toString(
|
||||
"hex",
|
||||
);
|
||||
return `${ITERATIONS}:${salt}:${hash}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that a password is correct against a given hash.
|
||||
*
|
||||
* @param {string} password_candidate
|
||||
* @param {string} stored_password_hash
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function verifyPassword(password_candidate, stored_password_hash) {
|
||||
const [iterations, salt, hash] = stored_password_hash.split(":");
|
||||
const derived = pbkdf2Sync(
|
||||
password_candidate,
|
||||
salt,
|
||||
Number(iterations),
|
||||
KEYLEN,
|
||||
DIGEST,
|
||||
).toString("hex");
|
||||
const success = hash === derived;
|
||||
if (Config.dev || true) {
|
||||
console.debug(
|
||||
"Verifying password:\n" +
|
||||
" Input : %s (the password as it was sent to us by the client)\n" +
|
||||
" Given : %s (the input password hashed by us (not necessary for validation))\n" +
|
||||
" Stored : %s (the password hash we have on file for the player)\n" +
|
||||
" Derived : %s (the hashed version of the input password)\n" +
|
||||
" Verified : %s (was the password valid)",
|
||||
password_candidate,
|
||||
generateHash(password_candidate),
|
||||
stored_password_hash,
|
||||
derived,
|
||||
success,
|
||||
);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
/** @param {string} candidate */
|
||||
export function isUsernameSane(candidate) {
|
||||
return /^[a-zA-Z0-9_]{4,}$/.test(candidate);
|
||||
}
|
||||
|
||||
/** @param {string} candidate */
|
||||
export function isPasswordSane(candidate) {
|
||||
// We know the password must adhere to one of our client-side-hashed crypto schemes,
|
||||
// so we can be fairly strict with the allowed passwords
|
||||
return /^[a-zA-Z0-9_: -]{8,}$/.test(candidate);
|
||||
}
|
||||
337
utils/tui.js
Normal file
337
utils/tui.js
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* @readonly
|
||||
*
|
||||
* @enum {string}
|
||||
*/
|
||||
export const FrameType = {
|
||||
/**
|
||||
* ╔════════════╗
|
||||
* ║ Hello, TUI ║
|
||||
* ╚════════════╝
|
||||
*
|
||||
* @type {string} Double-lined frame
|
||||
*/
|
||||
Double: "Double",
|
||||
|
||||
/**
|
||||
* ┌────────────┐
|
||||
* │ Hello, TUI │
|
||||
* └────────────┘
|
||||
*
|
||||
* @type {string} Single-lined frame
|
||||
*/
|
||||
Single: "Single",
|
||||
|
||||
/**
|
||||
*
|
||||
* Hello, TUI
|
||||
*
|
||||
*
|
||||
* @type {string} Double-lined frame
|
||||
*/
|
||||
Invisible: "Invisible",
|
||||
|
||||
/**
|
||||
* ( )
|
||||
* ( Hello, TUI )
|
||||
* ( )
|
||||
*
|
||||
* @type {string} Double-lined frame
|
||||
*/
|
||||
Parentheses: "Parentheses",
|
||||
|
||||
/**
|
||||
* +------------+
|
||||
* | Hello, TUI |
|
||||
* +------------+
|
||||
*
|
||||
* @type {string} Double-lined frame
|
||||
*/
|
||||
Basic: "Basic",
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* Default values for the common frame types.
|
||||
*
|
||||
* [north, south, east, west, northwest, northeast, southwest, southeast]
|
||||
*/
|
||||
values: {
|
||||
Basic: "--||++++",
|
||||
Double: "══║║╔╗╚╝",
|
||||
Invisible: " ",
|
||||
Parentheses: " () ",
|
||||
Single: "──││┌┐└┘",
|
||||
},
|
||||
};
|
||||
|
||||
export class FramingOptions {
|
||||
/** @type {number=0} Vertical Padding; number of vertical whitespace (newlines) between the text and the frame. */
|
||||
vPadding = 0;
|
||||
|
||||
/** @type {number=0} Margin ; number of newlines to to insert before and after the framed text */
|
||||
vMargin = 0;
|
||||
|
||||
/** @type {number=0} Horizontal Padding; number of whitespace characters to insert between the text and the sides of the frame. */
|
||||
hPadding = 0;
|
||||
|
||||
/** @type {number=0} Margin ; number of newlines to to insert before and after the text, but inside the frame */
|
||||
hMargin = 0;
|
||||
|
||||
/** @type {FrameType=FrameType.Double} Type of frame to put around the text */
|
||||
frameType = FrameType.Double;
|
||||
|
||||
/** @type {number=0} Pad each line to become at least this long */
|
||||
minLineWidth = 0;
|
||||
|
||||
// Light block: ░ (U+2591)
|
||||
// Medium block: ▒ (U+2592)
|
||||
// Dark block: ▓ (U+2593)
|
||||
// Solid block: █ (U+2588)
|
||||
/** @type {string} Single character to use as filler inside the frame. */
|
||||
paddingChar = " "; // character used for padding inside the frame.
|
||||
|
||||
/** @type {string} Single character to use as filler outside the frame. */
|
||||
marginChar = " ";
|
||||
|
||||
/** @type {string} The 8 characters that make up the frame elements */
|
||||
frameChars = FrameType.values.Double;
|
||||
|
||||
/**
|
||||
* @param {object} o
|
||||
* @returns {FramingOptions}
|
||||
*/
|
||||
static fromObject(o) {
|
||||
const result = new FramingOptions();
|
||||
|
||||
result.vPadding = Math.max(0, Number.parseInt(o.vPadding) || 0);
|
||||
result.hPadding = Math.max(0, Number.parseInt(o.hPadding) || 0);
|
||||
result.vMargin = Math.max(0, Number.parseInt(o.vMargin) || 0);
|
||||
result.hMargin = Math.max(0, Number.parseInt(o.hMargin) || 0);
|
||||
result.minLineWidth = Math.max(0, Number.parseInt(o.hMargin) || 0);
|
||||
|
||||
result.paddingChar = String(o.paddingChar || " ")[0] || " ";
|
||||
result.marginChar = String(o.marginChar || " ")[0] || " ";
|
||||
|
||||
//
|
||||
// Do we have custom and valid frame chars?
|
||||
if (
|
||||
typeof o.frameChars === "string" &&
|
||||
o.frameChars.length === FrameType.values.Double.length
|
||||
) {
|
||||
result.frameChars = o.frameChars;
|
||||
|
||||
//
|
||||
// do we have document frame type instead ?
|
||||
} else if (o.frameType && FrameType.hasOwnProperty(o.frameType)) {
|
||||
result.frameChars = FrameType.values[o.frameType];
|
||||
|
||||
// Fall back to using "Double" frame
|
||||
} else {
|
||||
result.frameChars = FrameType.values.Double;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|string[]} text the text to be framed. If array, each element will be treated as one line, and they are joined so the whole is to be framed.
|
||||
* @param {FramingOptions} options
|
||||
*/
|
||||
export function frameText(text, options) {
|
||||
if (!options) {
|
||||
options = new FramingOptions();
|
||||
}
|
||||
|
||||
if (!(options instanceof FramingOptions)) {
|
||||
options = FramingOptions.fromObject(options);
|
||||
}
|
||||
|
||||
// There is a point to this; each element in the array may contain newlines,
|
||||
// so we have to combine everything into a long text and then split into
|
||||
// individual lines afterwards.
|
||||
if (Array.isArray(text)) {
|
||||
text = text.join("\n");
|
||||
}
|
||||
|
||||
if (typeof text !== "string") {
|
||||
console.debug(text);
|
||||
throw new Error(
|
||||
`text argument was neither an array or a string, it was a ${typeof text}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** @type {string[]} */
|
||||
const lines = text.split("\n");
|
||||
|
||||
const innerLineLength = Math.max(
|
||||
lines.reduce((accumulator, currentLine) => {
|
||||
if (currentLine.length > accumulator) {
|
||||
return currentLine.length;
|
||||
}
|
||||
return accumulator;
|
||||
}, 0),
|
||||
options.minLineWidth,
|
||||
);
|
||||
|
||||
const frameThickness = 1; // always 1 for now.
|
||||
|
||||
const outerLineLength =
|
||||
0 +
|
||||
innerLineLength +
|
||||
frameThickness * 2 +
|
||||
options.hPadding * 2 +
|
||||
options.hMargin * 2;
|
||||
|
||||
// get the frame characters from the frameType.
|
||||
let [
|
||||
fNorth, // horizontal frame top lines
|
||||
fSouth, // horizontal frame bottom lines
|
||||
fWest, // vertical frame lines on the left side
|
||||
fEast, // vertical frame lines on the right side
|
||||
fNorthWest, // upper left frame corner
|
||||
fNorthEast, // upper right frame corner
|
||||
fSouthWest, // lower left frame corner
|
||||
fSouthEast, // lower right frame corner
|
||||
] = options.frameChars.split("");
|
||||
if (fNorth === "§") {
|
||||
fNorth = "";
|
||||
}
|
||||
if (fSouth === "§") {
|
||||
fSouth = "";
|
||||
}
|
||||
if (fEast === "§") {
|
||||
fEast = "";
|
||||
}
|
||||
if (fWest === "§") {
|
||||
fWest = "";
|
||||
}
|
||||
if (fNorthEast === "§") {
|
||||
fNorthEast = "";
|
||||
}
|
||||
if (fSouthEast === "§") {
|
||||
fSouthEast = "";
|
||||
}
|
||||
if (fNorthWest === "§") {
|
||||
fNorthWest = "";
|
||||
}
|
||||
if (fSouthWest === "§") {
|
||||
fSouthWest = "";
|
||||
}
|
||||
|
||||
let output = "";
|
||||
|
||||
//
|
||||
// GENERATE THE MARGIN SPACE ABOVE THE FRAMED MsgType.TEXT
|
||||
//
|
||||
// ( we insert space characters even though )
|
||||
// ( they wouldn't normally be visible. But )
|
||||
// ( Some fonts might allow us to see blank )
|
||||
// ( space, and what if we want to nest many )
|
||||
// ( frames inside each other? )
|
||||
//
|
||||
output += (options.marginChar.repeat(outerLineLength) + "\n").repeat(
|
||||
options.vMargin,
|
||||
);
|
||||
|
||||
//
|
||||
// GENERATE THE TOP PART OF THE FRAME
|
||||
// ╔════════════╗
|
||||
//
|
||||
//
|
||||
output +=
|
||||
"" + // Make sure JS knows we're adding a string.
|
||||
options.marginChar.repeat(options.hMargin) + // the margin before the frame starts
|
||||
fNorthWest + // northwest frame corner
|
||||
fNorth.repeat(innerLineLength + options.hPadding * 2) + // the long horizontal frame top bar
|
||||
fNorthEast + // northeast frame corner
|
||||
options.marginChar.repeat(options.hMargin) + // the margin after the frame ends
|
||||
"\n";
|
||||
//
|
||||
// GENERATE UPPER PADDING
|
||||
//
|
||||
// ║ ║
|
||||
//
|
||||
// (the blank lines within the frame and above the text)
|
||||
output += (
|
||||
options.marginChar.repeat(options.hMargin) +
|
||||
fWest +
|
||||
options.paddingChar.repeat(innerLineLength + options.hPadding * 2) +
|
||||
fEast +
|
||||
options.marginChar.repeat(options.hMargin) +
|
||||
"\n"
|
||||
).repeat(options.vPadding);
|
||||
|
||||
//
|
||||
// GENERATE FRAMED MsgType.TEXT SEGMENT
|
||||
//
|
||||
// ║ My pretty ║
|
||||
// ║ text here ║
|
||||
//
|
||||
// ( this could be done with a reduce() )
|
||||
//
|
||||
for (const line of lines) {
|
||||
output +=
|
||||
"" + // Make sure JS knows we're adding a string.
|
||||
options.marginChar.repeat(options.hMargin) + // margin before frame
|
||||
fWest + // vertical frame char
|
||||
options.paddingChar.repeat(options.hPadding) + // padding before text
|
||||
line.padEnd(innerLineLength, " ") + // The actual text. Pad it with normal space character, NOT custom space.
|
||||
options.paddingChar.repeat(options.hPadding) + // padding after text
|
||||
fEast + // vertical frame bar
|
||||
options.marginChar.repeat(options.hMargin) + // margin after frame
|
||||
"\n";
|
||||
}
|
||||
|
||||
//
|
||||
// GENERATE LOWER PADDING
|
||||
//
|
||||
// ║ ║
|
||||
//
|
||||
// ( the blank lines within the )
|
||||
// ( frame and below the text )
|
||||
//
|
||||
// ( this code is a direct )
|
||||
// ( repeat of the code that )
|
||||
// ( generates top padding )
|
||||
output += (
|
||||
options.marginChar.repeat(options.hMargin) +
|
||||
fWest +
|
||||
options.paddingChar.repeat(innerLineLength + options.hPadding * 2) +
|
||||
fEast +
|
||||
options.marginChar.repeat(options.hMargin) +
|
||||
"\n"
|
||||
).repeat(options.vPadding);
|
||||
|
||||
//
|
||||
// GENERATE THE BOTTOM PART OF THE FRAME
|
||||
//
|
||||
// ╚════════════╝
|
||||
//
|
||||
output +=
|
||||
"" + // Make sure JS knows we're adding a string.
|
||||
options.marginChar.repeat(options.hMargin) + // the margin before the frame starts
|
||||
fSouthWest + // northwest frame corner
|
||||
fSouth.repeat(innerLineLength + options.hPadding * 2) + // the long horizontal frame top bar
|
||||
fSouthEast + // northeast frame corner
|
||||
options.marginChar.repeat(options.hMargin) + // the margin after the frame starts
|
||||
"\n";
|
||||
|
||||
//
|
||||
// GENERATE THE MARGIN SPACE BELOW THE FRAMED MsgType.TEXT
|
||||
//
|
||||
// ( we insert space characters even though )
|
||||
// ( they wouldn't normally be visible. But )
|
||||
// ( Some fonts might allow us to see blank )
|
||||
// ( space, and what if we want to nest many )
|
||||
// ( frames inside each other? )
|
||||
//
|
||||
output += (options.marginChar.repeat(outerLineLength) + "\n").repeat(
|
||||
options.vMargin,
|
||||
);
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
// Allow this script to be run directly from node as well as being included!
|
||||
// https://stackoverflow.com/a/66309132/5622463
|
||||
Reference in New Issue
Block a user