From 2b7aeb6a1d6742fd3936fe8f1065a35e83f09950 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 14 Aug 2026 10:45:43 +0100 Subject: [PATCH 1/6] Added formatHtml --- README.md | 33 +++++ src/TextFormatter.js | 315 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 348 insertions(+) diff --git a/README.md b/README.md index 3745f3d..fe01333 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,39 @@ let ansiFormat = TextFormatter.deformat(TextFormatter.formatAnsi(formatted)); console.log(deformatted); ``` +`TextFormatter.formatHtml()` + +This is a port of the original [Maniaplanet style parser](https://github.com/maniaplanet/maniaplanet-style-js-parser) written in pure JS. *Copyright © 2012 - 2026 Nadeo & Baptiste Lafontaine* + +```js +let formatted = "$l[https://kc.jfreu.com]$F00T$D01M$C13U$A14.$815K$727r$528a$329z$23By$03CC$03Co$04Bl$059o$068r$077s$085$l $094v$0A30$0B1.$0C01"; +let htmlFormat = TextFormatter.formatHtml(formatted); +document.querySelector("#result").innerHTML = htmlFormat; +``` + +You can also pass some options in it: + +- `disableLinks`: will disable links to external sites (false by default) +- `lightBackground`: adapt color to be visible on light backgrounds (false by default) +- `linkTarget`: add a `target` attribute into links + +```js +let formatted = "$l[https://kc.jfreu.com]$F00T$D01M$C13U$A14.$815K$727r$528a$329z$23By$03CC$03Co$04Bl$059o$068r$077s$085$l $094v$0A30$0B1.$0C01"; +let htmlFormat = TextFormatter.formatHtml(formatted, { + disableLinks: true, + lightBackground: true +}); +document.querySelector("#result").innerHTML = htmlFormat; +``` + +```js +let formatted = "$l[https://greep.fr]Open this link to a new tab$l with the target attribute"; +let htmlFormat = TextFormatter.formatHtml(formatted, { + linkTarget: "_blank" +}); +document.querySelector("#result").innerHTML = htmlFormat; +``` + ## Time formatting `Time.time` - Total milliseconds as integer value. diff --git a/src/TextFormatter.js b/src/TextFormatter.js index 6c1ff88..7eaaf5e 100644 --- a/src/TextFormatter.js +++ b/src/TextFormatter.js @@ -71,6 +71,321 @@ class TextFormatter { } return input; } + + /** + * Colored Style bit flag + * @type {number} + * @private + */ + static #StyleColored = 0x1000; + + /** + * Italic Style bit flag + * @type {number} + * @private + */ + static #StyleItalic = 0x2000; + + /** + * Bold Style bit flag + * @type {number} + * @private + */ + static #StyleBold = 0x4000; + + /** + * Shadowed Style bit flag + * @type {number} + * @private + */ + static #StyleShadowed = 0x8000; + + /** + * Wide Style bit flag + * @type {number} + * @private + */ + static #StyleWide = 0x20000; + + /** + * Narrow Style bit flag + * @type {number} + * @private + */ + static #StyleNarrow = 0x40000; + + /** + * Format the text into HTML format + * @param {string} input + * @param {{disableLinks?: boolean, lightBackground?: boolean, linkTarget?: string}} [options] + * @returns {string} + */ + static formatHtml(input, options = {}) { + let isCode = false, + isQuickLink = false, + isPrettyLink = false, + color = null; + + let style = 0, + tokens = [], + styleStack = [], + nextToken = { style: 0, text: "" }, + nextLinkToken = null, + linkLevel = 0; + + const endLink = () => { + if (nextToken.text !== "") { + tokens.push(nextToken); + nextToken = { style, text: "" }; + tokens.push({ type: "linkEnd" }); + } else if (tokens[tokens.length - 1] === nextLinkToken) { + tokens.pop(); + } else { + tokens.push({ type: "linkEnd" }); + } + + nextLinkToken = null; + isQuickLink = false; + isPrettyLink = false; + }; + + const endText = (force = false) => { + if (force || style !== nextToken.style) { + if (nextToken.text !== "") { + tokens.push(nextToken); + nextToken = { style, text: "" }; + } else { + nextToken.style = style; + } + } + }; + + for (const c of input) { + if (isCode) { + const tok = c.toLowerCase(); + switch (tok) { + case "i": + style ^= this.#StyleItalic; + break; + case "o": + style ^= this.#StyleBold; + break; + case "s": + style ^= this.#StyleShadowed; + break; + case "w": + style |= this.#StyleWide; + style &= ~this.#StyleNarrow; + break; + case "n": + style |= this.#StyleNarrow; + style &= ~this.#StyleWide; + break; + case "l": + case "h": + case "p": + if (nextLinkToken) { + endLink(); + } else { + endText(true); + nextLinkToken = { type: "link", manialink: tok === "h", link: "", target: options.linkTarget }; + if (!options.disableLinks) { + tokens.push(nextLinkToken); + } + isQuickLink = true; + isPrettyLink = true; + linkLevel = styleStack.length; + } + break; + case "z": + style = styleStack.length === 0 ? 0 : styleStack[styleStack.length - 1]; + if (nextLinkToken) { + endLink(); + } + break; + case "m": + style &= ~(this.#StyleNarrow | this.#StyleWide); + break; + case "g": + style &= styleStack.length === 0 ? ~0x1fff : (styleStack[styleStack.length - 1] | ~0x1fff); + break; + case "<": + styleStack.push(style); + break; + case ">": + if (styleStack.length !== 0) { + style = styleStack.pop(); + if (nextLinkToken && linkLevel > styleStack.length) { + endLink(); + } + } + break; + case "$": + nextToken.text += "$"; + break; + default: + if (/[a-f0-9]/i.test(c)) { + color = c; + } + } + endText(); + isCode = false; + } else if (c === "$") { + isCode = true; + if (isQuickLink && isPrettyLink) { + isPrettyLink = false; + } + } else if (color) { + let endColor = false, + addChar = false; + + if (/[a-f0-9]/i.test(c)) { + color += c.replace(/[^a-f0-9]/gi, "0"); + endColor = color.length === 3; + } else { + color += "0".repeat(3 - color.length); + endColor = true; + addChar = true; + } + + if (endColor) { + if (options.lightBackground) { + color = this.#invertLight(color); + } + style &= ~0xfff; + style |= this.#StyleColored | (parseInt(color, 16) & 0xfff); + endText(); + color = null; + + if (addChar) { + nextToken.text += c; + } + } + } else if (isQuickLink && isPrettyLink) { + if (c === "[") { + isQuickLink = false; + } else { + isPrettyLink = false; + nextToken.text += c; + nextLinkToken.link += c; + } + } else if (isPrettyLink) { + if (c === "]") { + isPrettyLink = false; + } else { + nextLinkToken.link += c; + } + } else { + nextToken.text += c; + if (isQuickLink) { + nextLinkToken.link += c; + } + } + } + + if (nextToken.text !== "") { + tokens.push(nextToken); + } + + if (nextLinkToken && !options.disableLinks) { + tokens.push({ type: "linkEnd" }); + } + + return tokens.map((token) => this.#tokenToHtml(token)).join(""); + } + + /** + * Convert a token to HTML + * @param {{type?: string, style?: number, text?: string, manialink?: boolean, link?: string}} token + * @returns {string} + * @private + */ + static #tokenToHtml(token) { + if (token.type === "link") { + return this.#linkToHtml(token); + } + if (token.type === "linkEnd") { + return ""; + } + const styleStack = []; + if (token.style) { + if (token.style & this.#StyleColored) { + let color = this.#colorRgb12to24(token.style & 0xfff).toString(16); + if (color.length === 1) { + color = "00000" + color; + } else if (color.length === 2) { + color = "0000" + color; + } else if (color.length === 4) { + color = "00" + color; + } + styleStack.push(`color: #${color};`); + } + if (token.style & this.#StyleItalic) { + styleStack.push("font-style:italic;"); + } + if (token.style & this.#StyleBold) { + styleStack.push("font-weight:bold;"); + } + if (token.style & this.#StyleShadowed) { + styleStack.push("text-shadow:1px 1px 1px rgba(0, 0, 0, 0.5);"); + } + if (token.style & this.#StyleWide) { + styleStack.push("letter-spacing:.1em;font-size:105%;"); + } else if (token.style & this.#StyleNarrow) { + styleStack.push("letter-spacing:-.1em;font-size:95%;"); + } + return `${token.text}`; + } + return token.text; + } + + /** + * Convert a link token to HTML + * @param {{manialink: boolean, link: string, target?: string}} token + * @returns {string} + * @private + */ + static #linkToHtml(token) { + if (token.manialink && !/^maniaplanet:/i.test(token.link)) { + token.link = "maniaplanet://#manialink=" + token.link; + } + if (!token.manialink && !/^https?:/i.test(token.link)) { + token.link = "http://" + token.link; + } + return ``; + } + + /** + * Convert a 12 bit color to a 24 bit color + * @param {number} color + * @returns {number} + * @private + */ + static #colorRgb12to24(color) { + return (color & 0xf00) * 0x1100 + (color & 0xf0) * 0x110 + (color & 0xf) * 0x11; + } + + /** + * Invert a light color + * @param {string} hexColor + * @returns {string} + * @private + */ + static #invertLight(hexColor) { + const r = parseInt(hexColor[0], 16) * 17, + g = parseInt(hexColor[1], 16) * 17, + b = parseInt(hexColor[2], 16) * 17, + grey = (r + g + b) / 3; + + if (grey > 160) { + const upper = 255 + 74; + const nr = Math.min(15, Math.floor((upper - r) / 17)), + ng = Math.min(15, Math.floor((upper - g) / 17)), + nb = Math.min(15, Math.floor((upper - b) / 17)); + return nr.toString(16) + ng.toString(16) + nb.toString(16); + } + return hexColor; + } } module.exports = TextFormatter; \ No newline at end of file From fb737fbc8a2f37c1404d6671df8e823570e8af9b Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 14 Aug 2026 10:46:02 +0100 Subject: [PATCH 2/6] Added tests to formatHtml --- test/TextFormat.test.js | 46 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/test/TextFormat.test.js b/test/TextFormat.test.js index 8d20374..2b64f40 100644 --- a/test/TextFormat.test.js +++ b/test/TextFormat.test.js @@ -20,6 +20,52 @@ describe("Text Formatting", function(){ ); }); + it("should format text to HTML", function(){ + assert.equal("foo", tmessentials.TextFormatter.formatHtml("foo")); + assert.equal('tag', tmessentials.TextFormatter.formatHtml("$otag")); + assert.equal("hi there", tmessentials.TextFormatter.formatHtml("$uhi there")); + assert.equal('Red', tmessentials.TextFormatter.formatHtml("$f00Red")); + assert.equal('Red', tmessentials.TextFormatter.formatHtml("$fRed")); + }); + + it("should format links to HTML", function(){ + assert.equal( + 'trackmania.com', + tmessentials.TextFormatter.formatHtml("$l[http://maniaplanet.com]trackmania.com$l") + ); + assert.equal( + 'http://maniaplanet.com', + tmessentials.TextFormatter.formatHtml("$lhttp://maniaplanet.com$l") + ); + assert.equal( + 'http://maniaplanet.com', + tmessentials.TextFormatter.formatHtml("$lhttp://maniaplanet.com") + ); + assert.equal("", tmessentials.TextFormatter.formatHtml("$l[www.clan-nuitblanche.org]$fff$l")); + assert.equal( + 'maniaplanet', + tmessentials.TextFormatter.formatHtml("$l[maniaplanet.com]maniaplanet$l") + ); + assert.equal( + 'maniaplanet', + tmessentials.TextFormatter.formatHtml("$l[https://maniaplanet.com]maniaplanet$l") + ); + assert.equal( + 'maniaplanet', + tmessentials.TextFormatter.formatHtml("$l[maniaplanet.com]maniaplanet$l", { linkTarget: "_blank" }) + ); + assert.equal( + 'ManiaFlash', + tmessentials.TextFormatter.formatHtml("$h[maniaflash]ManiaFlash$h") + ); + }); + + it("should format text to HTML with options", function(){ + assert.equal("maniaplanet.com", tmessentials.TextFormatter.formatHtml("$lmaniaplanet.com", { disableLinks: true })); + assert.equal("Maniaplanet", tmessentials.TextFormatter.formatHtml("$l[maniaplanet.com]Maniaplanet", { disableLinks: true })); + assert.equal('Text', tmessentials.TextFormatter.formatHtml("$fffText", { lightBackground: true })); + }); + it("should format text with ANSI escape codes + deformat", function(){ assert.equal( "\u001b[38;2;255;255;0mHello \u001b[38;2;0;255;255mWorld\u001b[38;2;255;255;255m! \u001b[38;2;0;0;0mI'm \u001b[38;2;255;255;255ma \u001b[38;2;0;0;255mBlue \u001b[38;2;255;0;0mRed \u001b[38;2;0;255;0mGreen \u001b[39m\u001b[22mText \u001b[38;2;255;255;0mWide\u001b[39m\u001b[22m", From a364e9a2fdd761eeb8b7f0d0f0a6e9a9c4a3d2d0 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 14 Aug 2026 10:46:37 +0100 Subject: [PATCH 3/6] Added Baptiste Lafontaine into original contributors --- package.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/package.json b/package.json index ef9c581..c3d533e 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,10 @@ "name": "ThaumicTom", "url": "https://thaumictom.de", "email": "tom@thaumictom.de" + }, + { + "name": "Baptiste Lafontaine", + "url": "https://magnetik.org" } ], "license": "MIT", From 26da2e763288eb60585dff6c420513b42da41bf8 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 14 Aug 2026 10:48:42 +0100 Subject: [PATCH 4/6] Added magnetik email --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index c3d533e..f1584be 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ }, { "name": "Baptiste Lafontaine", - "url": "https://magnetik.org" + "url": "https://magnetik.org", + "email": "baptiste@nadeo.com" } ], "license": "MIT", From f65ff7cdb619f29862b8284272f43e128089ae6f Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 14 Aug 2026 13:08:41 +0100 Subject: [PATCH 5/6] Added credits for the original parser --- src/TextFormatter.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/TextFormatter.js b/src/TextFormatter.js index cd8a549..c31e5f4 100644 --- a/src/TextFormatter.js +++ b/src/TextFormatter.js @@ -114,6 +114,7 @@ class TextFormatter { */ static #StyleNarrow = 0x40000; + // Credits to @magnetik for the original HTML parser /** * Format the text into HTML format * @param {string} input From c8affe2b7a5f4cd5db1c970719f0b59694a3f860 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 14 Aug 2026 13:10:26 +0100 Subject: [PATCH 6/6] Removed duplicate #invertLight method --- src/TextFormatter.js | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/src/TextFormatter.js b/src/TextFormatter.js index d83cda5..a0e9824 100644 --- a/src/TextFormatter.js +++ b/src/TextFormatter.js @@ -134,28 +134,6 @@ class TextFormatter { return { output, bold, italic, uppercase, colored, link }; } - /** - * Darken a light color for a light background - * @param {string} hexColor - * @returns {string} - * @private - */ - static #invertLight(hexColor) { - const r = parseInt(hexColor[0], 16) * 17, - g = parseInt(hexColor[1], 16) * 17, - b = parseInt(hexColor[2], 16) * 17, - grey = (r + g + b) / 3; - - if (grey > 160) { - const upper = 255 + 74; - const nr = Math.min(15, Math.floor((upper - r) / 17)), - ng = Math.min(15, Math.floor((upper - g) / 17)), - nb = Math.min(15, Math.floor((upper - b) / 17)); - return nr.toString(16) + ng.toString(16) + nb.toString(16); - } - return hexColor; - } - /** * Colored Style bit flag * @type {number} @@ -451,7 +429,7 @@ class TextFormatter { } /** - * Invert a light color + * Darken a light color for a light background * @param {string} hexColor * @returns {string} * @private