diff --git a/README.md b/README.md
index 05341c7..2754132 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,39 @@ let ansiFormat = 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/package.json b/package.json
index ef9c581..f1584be 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,11 @@
"name": "ThaumicTom",
"url": "https://thaumictom.de",
"email": "tom@thaumictom.de"
+ },
+ {
+ "name": "Baptiste Lafontaine",
+ "url": "https://magnetik.org",
+ "email": "baptiste@nadeo.com"
}
],
"license": "MIT",
diff --git a/src/TextFormatter.js b/src/TextFormatter.js
index b9f86a4..a0e9824 100644
--- a/src/TextFormatter.js
+++ b/src/TextFormatter.js
@@ -134,6 +134,300 @@ class TextFormatter {
return { output, bold, italic, uppercase, colored, link };
}
+ /**
+ * 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;
+
+ // Credits to @magnetik for the original HTML parser
+ /**
+ * 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;
+ }
+
/**
* Darken a light color for a light background
* @param {string} hexColor
diff --git a/test/TextFormat.test.js b/test/TextFormat.test.js
index f604947..f9152e5 100644
--- a/test/TextFormat.test.js
+++ b/test/TextFormat.test.js
@@ -139,4 +139,50 @@ 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 }));
+ });
});
\ No newline at end of file