diff --git a/client/js/libs/handlebars/ircmessageparser/anyIntersection.js b/client/js/libs/handlebars/ircmessageparser/anyIntersection.js
new file mode 100644
index 00000000..a77e031d
--- /dev/null
+++ b/client/js/libs/handlebars/ircmessageparser/anyIntersection.js
@@ -0,0 +1,12 @@
+"use strict";
+
+// Return true if any section of "a" or "b" parts (defined by their start/end
+// markers) intersect each other, false otherwise.
+function anyIntersection(a, b) {
+ return a.start <= b.start && b.start < a.end ||
+ a.start < b.end && b.end <= a.end ||
+ b.start <= a.start && a.start < b.end ||
+ b.start < a.end && a.end <= b.end;
+}
+
+module.exports = anyIntersection;
diff --git a/client/js/libs/handlebars/ircmessageparser/fill.js b/client/js/libs/handlebars/ircmessageparser/fill.js
new file mode 100644
index 00000000..7d90a96c
--- /dev/null
+++ b/client/js/libs/handlebars/ircmessageparser/fill.js
@@ -0,0 +1,34 @@
+"use strict";
+
+// Create plain text entries corresponding to areas of the text that match no
+// existing entries. Returns an empty array if all parts of the text have been
+// parsed into recognizable entries already.
+function fill(existingEntries, text) {
+ let position = 0;
+
+ // Fill inner parts of the text. For example, if text is `foobarbaz` and both
+ // `foo` and `baz` have matched into an entry, this will return a dummy entry
+ // corresponding to `bar`.
+ const result = existingEntries.reduce((acc, textSegment) => {
+ if (textSegment.start > position) {
+ acc.push({
+ start: position,
+ end: textSegment.start
+ });
+ }
+ position = textSegment.end;
+ return acc;
+ }, []);
+
+ // Complete the unmatched end of the text with a dummy entry
+ if (position < text.length) {
+ result.push({
+ start: position,
+ end: text.length
+ });
+ }
+
+ return result;
+}
+
+module.exports = fill;
diff --git a/client/js/libs/handlebars/ircmessageparser/findChannels.js b/client/js/libs/handlebars/ircmessageparser/findChannels.js
new file mode 100644
index 00000000..6edd5dad
--- /dev/null
+++ b/client/js/libs/handlebars/ircmessageparser/findChannels.js
@@ -0,0 +1,43 @@
+"use strict";
+
+// Escapes the RegExp special characters "^", "$", "", ".", "*", "+", "?", "(",
+// ")", "[", "]", "{", "}", and "|" in string.
+// See https://lodash.com/docs/#escapeRegExp
+const escapeRegExp = require("lodash/escapeRegExp");
+
+// Given an array of channel prefixes (such as "#" and "&") and an array of user
+// modes (such as "@" and "+"), this function extracts channels and nicks from a
+// text.
+// It returns an array of objects for each channel found with their start index,
+// end index and channel name.
+function findChannels(text, channelPrefixes, userModes) {
+ // `userModePattern` is necessary to ignore user modes in /whois responses.
+ // For example, a voiced user in #thelounge will have a /whois response of:
+ // > foo is on the following channels: +#thelounge
+ // We need to explicitly ignore user modes to parse such channels correctly.
+ const userModePattern = userModes.map(escapeRegExp).join("");
+ const channelPrefixPattern = channelPrefixes.map(escapeRegExp).join("");
+ const channelPattern = `(?:^|\\s)[${userModePattern}]*([${channelPrefixPattern}][^ \u0007]+)`;
+ const channelRegExp = new RegExp(channelPattern, "g");
+
+ const result = [];
+ let match;
+
+ do {
+ // With global ("g") regexes, calling `exec` multiple times will find
+ // successive matches in the same string.
+ match = channelRegExp.exec(text);
+
+ if (match) {
+ result.push({
+ start: match.index + match[0].length - match[1].length,
+ end: match.index + match[0].length,
+ channel: match[1]
+ });
+ }
+ } while (match);
+
+ return result;
+}
+
+module.exports = findChannels;
diff --git a/client/js/libs/handlebars/ircmessageparser/findLinks.js b/client/js/libs/handlebars/ircmessageparser/findLinks.js
new file mode 100644
index 00000000..1bd989b2
--- /dev/null
+++ b/client/js/libs/handlebars/ircmessageparser/findLinks.js
@@ -0,0 +1,56 @@
+"use strict";
+
+const URI = require("urijs");
+
+// Known schemes to detect in a text. If a text contains `foo...bar://foo.com`,
+// the parsed scheme should be `foo...bar` but if it contains
+// `foo...http://foo.com`, we assume the scheme to extract will be `http`.
+const commonSchemes = [
+ "http", "https",
+ "ftp", "sftp",
+ "smb", "file",
+ "irc", "ircs",
+ "svn", "git",
+ "steam", "mumble", "ts3server",
+ "svn+ssh", "ssh",
+];
+
+function findLinks(text) {
+ let result = [];
+
+ // URI.withinString() identifies URIs within text, e.g. to translate them to
+ // -Tags.
+ // See https://medialize.github.io/URI.js/docs.html#static-withinString
+ // In our case, we store each URI encountered in a result array.
+ URI.withinString(text, function(url, start, end) {
+ // Extract the scheme of the URL detected, if there is one
+ const parsedScheme = URI(url).scheme().toLowerCase();
+
+ // Check if the scheme of the detected URL matches a common one above.
+ // In a URL like `foo..http://example.com`, the scheme would be `foo..http`,
+ // so we need to clean up the end of the scheme and filter out the rest.
+ const matchedScheme = commonSchemes.find(scheme => parsedScheme.endsWith(scheme));
+
+ // A known scheme was found, extract the unknown part from the URL
+ if (matchedScheme) {
+ const prefix = parsedScheme.length - matchedScheme.length;
+ start += prefix;
+ url = url.slice(prefix);
+ }
+
+ // The URL matched but does not start with a scheme (`www.foo.com`), add it
+ if (!parsedScheme.length) {
+ url = "http://" + url;
+ }
+
+ result.push({
+ start: start,
+ end: end,
+ link: url
+ });
+ });
+
+ return result;
+}
+
+module.exports = findLinks;
diff --git a/client/js/libs/handlebars/ircmessageparser/merge.js b/client/js/libs/handlebars/ircmessageparser/merge.js
new file mode 100644
index 00000000..893997cc
--- /dev/null
+++ b/client/js/libs/handlebars/ircmessageparser/merge.js
@@ -0,0 +1,60 @@
+"use strict";
+
+const anyIntersection = require("./anyIntersection");
+const fill = require("./fill");
+
+let Object_assign = Object.assign;
+
+if (typeof Object_assign !== "function") {
+ Object_assign = function(target) {
+ Array.prototype.slice.call(arguments, 1).forEach(function(obj) {
+ Object.keys(obj).forEach(function(key) {
+ target[key] = obj[key];
+ });
+ });
+ return target;
+ };
+}
+
+// Merge text part information within a styling fragment
+function assign(textPart, fragment) {
+ const fragStart = fragment.start;
+ const start = Math.max(fragment.start, textPart.start);
+ const end = Math.min(fragment.end, textPart.end);
+
+ return Object_assign({}, fragment, {
+ start: start,
+ end: end,
+ text: fragment.text.slice(start - fragStart, end - fragStart)
+ });
+}
+
+// Merge the style fragments withing the text parts, taking into account
+// boundaries and text sections that have not matched to links or channels.
+// For example, given a string "foobar" where "foo" and "bar" have been
+// identified as parts (channels, links, etc.) and "fo", "ob" and "ar" have 3
+// different styles, the first resulting part will contain fragments "fo" and
+// "o", and the second resulting part will contain "b" and "ar". "o" and "b"
+// fragments will contain duplicate styling attributes.
+function merge(textParts, styleFragments) {
+ // Re-build the overall text (without control codes) from the style fragments
+ const cleanText = styleFragments.reduce((acc, frag) => acc + frag.text, "");
+
+ // Every section of the original text that has not been captured in a "part"
+ // is filled with "text" parts, dummy objects with start/end but no extra
+ // metadata.
+ const allParts = textParts
+ .concat(fill(textParts, cleanText))
+ .sort((a, b) => a.start - b.start);
+
+ // Distribute the style fragments within the text parts
+ return allParts.map(textPart => {
+ textPart.fragments = styleFragments
+ .filter(fragment => anyIntersection(textPart, fragment))
+ .map(fragment => assign(textPart, fragment));
+
+ return textPart;
+ });
+}
+
+module.exports = merge;
diff --git a/client/js/libs/handlebars/ircmessageparser/parseStyle.js b/client/js/libs/handlebars/ircmessageparser/parseStyle.js
new file mode 100644
index 00000000..d23d5bd6
--- /dev/null
+++ b/client/js/libs/handlebars/ircmessageparser/parseStyle.js
@@ -0,0 +1,161 @@
+"use strict";
+
+// Styling control codes
+const BOLD = "\x02";
+const COLOR = "\x03";
+const RESET = "\x0f";
+const REVERSE = "\x16";
+const ITALIC = "\x1d";
+const UNDERLINE = "\x1f";
+
+// Color code matcher, with format `XX,YY` where both `XX` and `YY` are
+// integers, `XX` is the text color and `YY` is an optional background color.
+const colorRx = /^(\d{1,2})(?:,(\d{1,2}))?/;
+
+// Represents all other control codes that to be ignored/filtered from the text
+const controlCodesRx = /[\u0000-\u001F]/g;
+
+// Converts a given text into an array of objects, each of them representing a
+// similarly styled section of the text. Each object carries the `text`, style
+// information (`bold`, `textColor`, `bgcolor`, `reverse`, `italic`,
+// `underline`), and `start`/`end` cursors.
+function parseStyle(text) {
+ const result = [];
+ let start = 0;
+ let position = 0;
+
+ // At any given time, these carry style information since last time a styling
+ // control code was met.
+ let colorCodes, bold, textColor, bgColor, reverse, italic, underline;
+
+ const resetStyle = () => {
+ bold = false;
+ textColor = undefined;
+ bgColor = undefined;
+ reverse = false;
+ italic = false;
+ underline = false;
+ };
+ resetStyle();
+
+ // When called, this "closes" the current fragment by adding an entry to the
+ // `result` array using the styling information set last time a control code
+ // was met.
+ const emitFragment = () => {
+ // Uses the text fragment starting from the last control code position up to
+ // the current position
+ const textPart = text.slice(start, position);
+
+ // Filters out all non-style related control codes present in this text
+ const processedText = textPart.replace(controlCodesRx, "");
+
+ if (processedText.length) {
+ // Current fragment starts where the previous one ends, or at 0 if none
+ const fragmentStart = result.length ? result[result.length - 1].end : 0;
+
+ result.push({
+ bold,
+ textColor,
+ bgColor,
+ reverse,
+ italic,
+ underline,
+ text: processedText,
+ start: fragmentStart,
+ end: fragmentStart + processedText.length
+ });
+ }
+
+ // Now that a fragment has been "closed", the next one will start after that
+ start = position + 1;
+ };
+
+ // This loop goes through each character of the given text one by one by
+ // bumping the `position` cursor. Every time a new special "styling" character
+ // is met, an object gets created (with `emitFragment()`)information on text
+ // encountered since the previous styling character.
+ while (position < text.length) {
+ switch (text[position]) {
+
+ case RESET:
+ emitFragment();
+ resetStyle();
+ break;
+
+ // Meeting a BOLD character means that the ongoing text is either going to
+ // be in bold or that the previous one was in bold and the following one
+ // must be reset.
+ // This same behavior applies to COLOR, REVERSE, ITALIC, and UNDERLINE.
+ case BOLD:
+ emitFragment();
+ bold = !bold;
+ break;
+
+ case COLOR:
+ emitFragment();
+
+ // Go one step further to find the corresponding color
+ colorCodes = text.slice(position + 1).match(colorRx);
+
+ if (colorCodes) {
+ textColor = Number(colorCodes[1]);
+ if (colorCodes[2]) {
+ bgColor = Number(colorCodes[2]);
+ }
+ // Color code length is > 1, so bump the current position cursor by as
+ // much (and reset the start cursor for the current text block as well)
+ position += colorCodes[0].length;
+ start = position + 1;
+ } else {
+ // If no color codes were found, toggles back to no colors (like BOLD).
+ textColor = undefined;
+ bgColor = undefined;
+ }
+ break;
+
+ case REVERSE:
+ emitFragment();
+ reverse = !reverse;
+ break;
+
+ case ITALIC:
+ emitFragment();
+ italic = !italic;
+ break;
+
+ case UNDERLINE:
+ emitFragment();
+ underline = !underline;
+ break;
+ }
+
+ // Evaluate the next character at the next iteration
+ position += 1;
+ }
+
+ // The entire text has been parsed, so we finalize the current text fragment.
+ emitFragment();
+
+ return result;
+}
+
+const properties = ["bold", "textColor", "bgColor", "italic", "underline", "reverse"];
+
+function prepare(text) {
+ return parseStyle(text)
+ // This optimizes fragments by combining them together when all their values
+ // for the properties defined above are equal.
+ .reduce((prev, curr) => {
+ if (prev.length) {
+ const lastEntry = prev[prev.length - 1];
+ if (properties.every(key => curr[key] === lastEntry[key])) {
+ lastEntry.text += curr.text;
+ lastEntry.end += curr.text.length;
+ return prev;
+ }
+ }
+ return prev.concat([curr]);
+ }, []);
+}
+
+module.exports = prepare;
diff --git a/client/js/libs/handlebars/parse.js b/client/js/libs/handlebars/parse.js
index 45d5c8d2..915a432c 100644
--- a/client/js/libs/handlebars/parse.js
+++ b/client/js/libs/handlebars/parse.js
@@ -1,126 +1,71 @@
"use strict";
const Handlebars = require("handlebars/runtime");
-const URI = require("urijs");
+const parseStyle = require("./ircmessageparser/parseStyle");
+const findChannels = require("./ircmessageparser/findChannels");
+const findLinks = require("./ircmessageparser/findLinks");
+const merge = require("./ircmessageparser/merge");
-module.exports = function(text) {
- text = Handlebars.Utils.escapeExpression(text);
- text = colors(text);
- text = channels(text);
- text = uri(text);
- return text;
+// Create an HTML `span` with styling information for a given fragment
+function createFragment(fragment) {
+ let classes = [];
+ if (fragment.bold) {
+ classes.push("irc-bold");
+ }
+ if (fragment.textColor !== undefined) {
+ classes.push("irc-fg" + fragment.textColor);
+ }
+ if (fragment.bgColor !== undefined) {
+ classes.push("irc-bg" + fragment.bgColor);
+ }
+ if (fragment.italic) {
+ classes.push("irc-italic");
+ }
+ if (fragment.underline) {
+ classes.push("irc-underline");
+ }
+ const escapedText = Handlebars.Utils.escapeExpression(fragment.text);
+ if (classes.length) {
+ return `${escapedText}`;
+ }
+ return escapedText;
+}
+
+// Transform an IRC message potentially filled with styling control codes, URLs
+// and channels into a string of HTML elements to display on the client.
+module.exports = function parse(text) {
+ // Extract the styling information and get the plain text version from it
+ const styleFragments = parseStyle(text);
+ const cleanText = styleFragments.map(fragment => fragment.text).join("");
+
+ // On the plain text, find channels and URLs, returned as "parts". Parts are
+ // arrays of objects containing start and end markers, as well as metadata
+ // depending on what was found (channel or link).
+ const channelPrefixes = ["#", "&"]; // TODO Channel prefixes should be RPL_ISUPPORT.CHANTYPES
+ const userModes = ["!", "@", "%", "+"]; // TODO User modes should be RPL_ISUPPORT.PREFIX
+ const channelParts = findChannels(cleanText, channelPrefixes, userModes);
+ const linkParts = findLinks(cleanText);
+
+ // Sort all parts identified based on their position in the original text
+ const parts = channelParts
+ .concat(linkParts)
+ .sort((a, b) => a.start - b.start);
+
+ // Merge the styling information with the channels / URLs / text objects and
+ // generate HTML strings with the resulting fragments
+ return merge(parts, styleFragments).map(textPart => {
+ // Create HTML strings with styling information
+ const fragments = textPart.fragments.map(createFragment).join("");
+
+ // Wrap these potentially styled fragments with links and channel buttons
+ if (textPart.link) {
+ const escapedLink = Handlebars.Utils.escapeExpression(textPart.link);
+ return `${fragments}`;
+ } else if (textPart.channel) {
+ const escapedChannel = Handlebars.Utils.escapeExpression(textPart.channel);
+ return `${fragments}`;
+ }
+
+ return fragments;
+ }).join("");
};
-
-function uri(text) {
- return URI.withinString(text, function(url) {
- if (url.indexOf("javascript:") === 0) {
- return url;
- }
- var split = url.split("<");
- url = "" + split[0] + "";
- if (split.length > 1) {
- url += "<" + split.slice(1).join("<");
- }
- return url;
- });
-}
-
-/**
- * Channels names are strings of length up to fifty (50) characters.
- * The only restriction on a channel name is that it SHALL NOT contain
- * any spaces (' '), a control G (^G or ASCII 7), a comma (',').
- * Channel prefix '&' is handled as '&' because this parser is executed
- * after entities in the message have been escaped. This prevents a couple of bugs.
- */
-function channels(text) {
- return text.replace(
- /(^|\s|\x07|,)((?:#|&)[^\x07\s,]{1,49})/g,
- '$1$2'
- );
-}
-
-/**
- * MIRC compliant colour and style parser
- * Unfortuanately this is a non trivial operation
- * See this branch for source and tests
- * https://github.com/megawac/irc-style-parser/tree/shout
- */
-var styleCheck_Re = /[\x00-\x1F]/,
- back_re = /^([0-9]{1,2})(,([0-9]{1,2}))?/,
- colourKey = "\x03",
- // breaks all open styles ^O (\x0F)
- styleBreak = "\x0F";
-
-function styleTemplate(settings) {
- return "" + settings.text + "";
-}
-
-var styles = [
- ["normal", "\x00", ""], ["underline", "\x1F"],
- ["bold", "\x02"], ["italic", "\x1D"]
-].map(function(style) {
- var escaped = encodeURI(style[1]).replace("%", "\\x");
- return {
- name: style[0],
- style: style[2] ? style[2] : "irc-" + style[0],
- key: style[1],
- keyregex: new RegExp(escaped + "(.*?)(" + escaped + "|$)")
- };
-});
-
-function colors(line) {
- // http://www.mirc.com/colors.html
- // http://www.aviran.org/stripremove-irc-client-control-characters/
- // https://github.com/perl6/mu/blob/master/examples/rules/Grammar-IRC.pm
- // regexs are cruel to parse this thing
-
- // already done?
- if (!styleCheck_Re.test(line)) {
- return line;
- }
-
- // split up by the irc style break character ^O
- if (line.indexOf(styleBreak) >= 0) {
- return line.split(styleBreak).map(colors).join("");
- }
-
- var result = line;
- var parseArr = result.split(colourKey);
- var text, match, colour, background = "";
- for (var i = 0; i < parseArr.length; i++) {
- text = parseArr[i];
- match = text.match(back_re);
- if (!match) {
- // ^C (no colour) ending. Escape current colour and carry on
- background = "";
- continue;
- }
- colour = "irc-fg" + +match[1];
- // set the background colour
- if (match[3]) {
- background = " irc-bg" + +match[3];
- }
- // update the parsed text result
- result = result.replace(colourKey + text, styleTemplate({
- style: colour + background,
- text: text.slice(match[0].length)
- }));
- }
-
- // Matching styles (italics/bold/underline)
- // if only colours were this easy...
- styles.forEach(function(style) {
- if (result.indexOf(style.key) < 0) {
- return;
- }
-
- result = result.replace(style.keyregex, function(matchedTrash, matchedText) {
- return styleTemplate({
- style: style.style,
- text: matchedText
- });
- });
- });
-
- return result;
-}
diff --git a/test/client/js/libs/handlebars/ircmessageparser/anyIntersection.js b/test/client/js/libs/handlebars/ircmessageparser/anyIntersection.js
new file mode 100644
index 00000000..b80a44ed
--- /dev/null
+++ b/test/client/js/libs/handlebars/ircmessageparser/anyIntersection.js
@@ -0,0 +1,30 @@
+"use strict";
+
+const expect = require("chai").expect;
+const anyIntersection = require("../../../../../../client/js/libs/handlebars/ircmessageparser/anyIntersection");
+
+describe("anyIntersection", () => {
+ it("should not intersect on edges", () => {
+ const a = {start: 1, end: 2};
+ const b = {start: 2, end: 3};
+
+ expect(anyIntersection(a, b)).to.equal(false);
+ expect(anyIntersection(b, a)).to.equal(false);
+ });
+
+ it("should intersect on overlapping", () => {
+ const a = {start: 0, end: 3};
+ const b = {start: 1, end: 2};
+
+ expect(anyIntersection(a, b)).to.equal(true);
+ expect(anyIntersection(b, a)).to.equal(true);
+ });
+
+ it("should not intersect", () => {
+ const a = {start: 0, end: 1};
+ const b = {start: 2, end: 3};
+
+ expect(anyIntersection(a, b)).to.equal(false);
+ expect(anyIntersection(b, a)).to.equal(false);
+ });
+});
diff --git a/test/client/js/libs/handlebars/ircmessageparser/fill.js b/test/client/js/libs/handlebars/ircmessageparser/fill.js
new file mode 100644
index 00000000..8723ad52
--- /dev/null
+++ b/test/client/js/libs/handlebars/ircmessageparser/fill.js
@@ -0,0 +1,50 @@
+"use strict";
+
+const expect = require("chai").expect;
+const fill = require("../../../../../../client/js/libs/handlebars/ircmessageparser/fill");
+
+describe("fill", () => {
+ const text = "01234567890123456789";
+
+ it("should return an entry for the unmatched end of string", () => {
+ const existingEntries = [
+ {start: 0, end: 10},
+ {start: 5, end: 15},
+ ];
+
+ const expected = [
+ {start: 15, end: 20},
+ ];
+
+ const actual = fill(existingEntries, text);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should return an entry per unmatched areas of the text", () => {
+ const existingEntries = [
+ {start: 0, end: 5},
+ {start: 10, end: 15},
+ ];
+
+ const expected = [
+ {start: 5, end: 10},
+ {start: 15, end: 20},
+ ];
+
+ const actual = fill(existingEntries, text);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should not return anything when entries match all text", () => {
+ const existingEntries = [
+ {start: 0, end: 10},
+ {start: 10, end: 20},
+ ];
+
+ const actual = fill(existingEntries, text);
+
+ expect(actual).to.be.empty;
+ });
+});
diff --git a/test/client/js/libs/handlebars/ircmessageparser/findChannels.js b/test/client/js/libs/handlebars/ircmessageparser/findChannels.js
new file mode 100644
index 00000000..4c676e57
--- /dev/null
+++ b/test/client/js/libs/handlebars/ircmessageparser/findChannels.js
@@ -0,0 +1,123 @@
+"use strict";
+
+const expect = require("chai").expect;
+const findChannels = require("../../../../../../client/js/libs/handlebars/ircmessageparser/findChannels");
+
+describe("findChannels", () => {
+ it("should find single letter channel", () => {
+ const input = "#a";
+ const expected = [{
+ channel: "#a",
+ start: 0,
+ end: 2
+ }];
+
+ const actual = findChannels(input, ["#"], ["@", "+"]);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should find utf8 channels", () => {
+ const input = "#äöü";
+ const expected = [{
+ channel: "#äöü",
+ start: 0,
+ end: 4
+ }];
+
+ const actual = findChannels(input, ["#"], ["@", "+"]);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should find inline channel", () => {
+ const input = "inline #channel text";
+ const expected = [{
+ channel: "#channel",
+ start: 7,
+ end: 15
+ }];
+
+ const actual = findChannels(input, ["#"], ["@", "+"]);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should stop at \\0x07", () => {
+ const input = "#chan\x07nel";
+ const expected = [{
+ channel: "#chan",
+ start: 0,
+ end: 5
+ }];
+
+ const actual = findChannels(input, ["#"], ["@", "+"]);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should allow classics pranks", () => {
+ const input = "#1,000";
+ const expected = [{
+ channel: "#1,000",
+ start: 0,
+ end: 6
+ }];
+
+ const actual = findChannels(input, ["#"], ["@", "+"]);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should work with whois reponses", () => {
+ const input = "@#a";
+ const expected = [{
+ channel: "#a",
+ start: 1,
+ end: 3
+ }];
+
+ const actual = findChannels(input, ["#"], ["@", "+"]);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should work with IRCv3.1 multi-prefix", () => {
+ const input = "!@%+#a";
+ const expected = [{
+ channel: "#a",
+ start: 4,
+ end: 6
+ }];
+
+ const actual = findChannels(input, ["#"], ["!", "@", "%", "+"]);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should work with custom channelPrefixes", () => {
+ const input = "@a";
+ const expected = [{
+ channel: "@a",
+ start: 0,
+ end: 2
+ }];
+
+ const actual = findChannels(input, ["@"], ["#", "+"]);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should handle multiple channelPrefix correctly", () => {
+ const input = "##test";
+ const expected = [{
+ channel: "##test",
+ start: 0,
+ end: 6
+ }];
+
+ const actual = findChannels(input, ["#"], ["@", "+"]);
+
+ expect(actual).to.deep.equal(expected);
+ });
+});
diff --git a/test/client/js/libs/handlebars/ircmessageparser/findLinks.js b/test/client/js/libs/handlebars/ircmessageparser/findLinks.js
new file mode 100644
index 00000000..f3f228f2
--- /dev/null
+++ b/test/client/js/libs/handlebars/ircmessageparser/findLinks.js
@@ -0,0 +1,106 @@
+"use strict";
+
+const expect = require("chai").expect;
+const findLinks = require("../../../../../../client/js/libs/handlebars/ircmessageparser/findLinks");
+
+describe("findLinks", () => {
+ it("should find url", () => {
+ const input = "irc://freenode.net/thelounge";
+ const expected = [{
+ start: 0,
+ end: 28,
+ link: "irc://freenode.net/thelounge",
+ }];
+
+ const actual = findLinks(input);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should find urls with www", () => {
+ const input = "www.nooooooooooooooo.com";
+ const expected = [{
+ start: 0,
+ end: 24,
+ link: "http://www.nooooooooooooooo.com"
+ }];
+
+ const actual = findLinks(input);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should find urls in strings", () => {
+ const input = "look at https://thelounge.github.io/ for more information";
+ const expected = [{
+ link: "https://thelounge.github.io/",
+ start: 8,
+ end: 36
+ }];
+
+ const actual = findLinks(input);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should find urls in strings starting with www", () => {
+ const input = "use www.duckduckgo.com for privacy reasons";
+ const expected = [{
+ link: "http://www.duckduckgo.com",
+ start: 4,
+ end: 22
+ }];
+
+ const actual = findLinks(input);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should find urls with odd surroundings", () => {
+ const input = "";
+ const expected = [{
+ link: "https://theos.kyriasis.com/~kyrias/stats/archlinux.html",
+ start: 1,
+ end: 56
+ }];
+
+ const actual = findLinks(input);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should find urls with starting with www. and odd surroundings", () => {
+ const input = ".:www.github.com:.";
+ const expected = [{
+ link: "http://www.github.com",
+ start: 2,
+ end: 16
+ }];
+
+ const actual = findLinks(input);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should not find urls", () => {
+ const input = "text www. text";
+ const expected = [];
+
+ const actual = findLinks(input);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should handle multiple www. correctly", () => {
+ const input = "www.www.test.com";
+ const expected = [{
+ link: "http://www.www.test.com",
+ start: 0,
+ end: 16
+ }];
+
+ const actual = findLinks(input);
+
+ expect(actual).to.deep.equal(expected);
+ });
+});
diff --git a/test/client/js/libs/handlebars/ircmessageparser/merge.js b/test/client/js/libs/handlebars/ircmessageparser/merge.js
new file mode 100644
index 00000000..d55ac1a2
--- /dev/null
+++ b/test/client/js/libs/handlebars/ircmessageparser/merge.js
@@ -0,0 +1,63 @@
+"use strict";
+
+const expect = require("chai").expect;
+const merge = require("../../../../../../client/js/libs/handlebars/ircmessageparser/merge");
+
+describe("merge", () => {
+ it("should split style information", () => {
+ const textParts = [{
+ start: 0,
+ end: 10,
+ flag1: true
+ }, {
+ start: 10,
+ end: 20,
+ flag2: true
+ }];
+ const styleFragments = [{
+ start: 0,
+ end: 5,
+ text: "01234"
+ }, {
+ start: 5,
+ end: 15,
+ text: "5678901234"
+ }, {
+ start: 15,
+ end: 20,
+ text: "56789"
+ }];
+
+ const expected = [{
+ start: 0,
+ end: 10,
+ flag1: true,
+ fragments: [{
+ start: 0,
+ end: 5,
+ text: "01234"
+ }, {
+ start: 5,
+ end: 10,
+ text: "56789"
+ }]
+ }, {
+ start: 10,
+ end: 20,
+ flag2: true,
+ fragments: [{
+ start: 10,
+ end: 15,
+ text: "01234"
+ }, {
+ start: 15,
+ end: 20,
+ text: "56789"
+ }]
+ }];
+
+ const actual = merge(textParts, styleFragments);
+
+ expect(actual).to.deep.equal(expected);
+ });
+});
diff --git a/test/client/js/libs/handlebars/ircmessageparser/parseStyle.js b/test/client/js/libs/handlebars/ircmessageparser/parseStyle.js
new file mode 100644
index 00000000..6af289c4
--- /dev/null
+++ b/test/client/js/libs/handlebars/ircmessageparser/parseStyle.js
@@ -0,0 +1,274 @@
+"use strict";
+
+const expect = require("chai").expect;
+const parseStyle = require("../../../../../../client/js/libs/handlebars/ircmessageparser/parseStyle");
+
+describe("parseStyle", () => {
+ it("should skip control codes", () => {
+ const input = "text\x01with\x04control\x05codes";
+ const expected = [{
+ bold: false,
+ textColor: undefined,
+ bgColor: undefined,
+ reverse: false,
+ italic: false,
+ underline: false,
+ text: "textwithcontrolcodes",
+
+ start: 0,
+ end: 20
+ }];
+
+ const actual = parseStyle(input);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should parse bold", () => {
+ const input = "\x02bold";
+ const expected = [{
+ bold: true,
+ textColor: undefined,
+ bgColor: undefined,
+ reverse: false,
+ italic: false,
+ underline: false,
+ text: "bold",
+
+ start: 0,
+ end: 4
+ }];
+
+ const actual = parseStyle(input);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should parse textColor", () => {
+ const input = "\x038yellowText";
+ const expected = [{
+ bold: false,
+ textColor: 8,
+ bgColor: undefined,
+ reverse: false,
+ italic: false,
+ underline: false,
+ text: "yellowText",
+
+ start: 0,
+ end: 10
+ }];
+
+ const actual = parseStyle(input);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should parse textColor and background", () => {
+ const input = "\x034,8yellowBG redText";
+ const expected = [{
+ textColor: 4,
+ bgColor: 8,
+ bold: false,
+ reverse: false,
+ italic: false,
+ underline: false,
+ text: "yellowBG redText",
+
+ start: 0,
+ end: 16
+ }];
+
+ const actual = parseStyle(input);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should parse italic", () => {
+ const input = "\x1ditalic";
+ const expected = [{
+ bold: false,
+ textColor: undefined,
+ bgColor: undefined,
+ reverse: false,
+ italic: true,
+ underline: false,
+ text: "italic",
+
+ start: 0,
+ end: 6
+ }];
+
+ const actual = parseStyle(input);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should carry state corretly forward", () => {
+ const input = "\x02bold\x038yellow\x02nonBold\x03default";
+ const expected = [{
+ bold: true,
+ textColor: undefined,
+ bgColor: undefined,
+ reverse: false,
+ italic: false,
+ underline: false,
+ text: "bold",
+
+ start: 0,
+ end: 4
+ }, {
+ bold: true,
+ textColor: 8,
+ bgColor: undefined,
+ reverse: false,
+ italic: false,
+ underline: false,
+ text: "yellow",
+
+ start: 4,
+ end: 10
+ }, {
+ bold: false,
+ textColor: 8,
+ bgColor: undefined,
+ reverse: false,
+ italic: false,
+ underline: false,
+ text: "nonBold",
+
+ start: 10,
+ end: 17
+ }, {
+ bold: false,
+ textColor: undefined,
+ bgColor: undefined,
+ reverse: false,
+ italic: false,
+ underline: false,
+ text: "default",
+
+ start: 17,
+ end: 24
+ }];
+
+ const actual = parseStyle(input);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should toggle bold correctly", () => {
+ const input = "\x02bold\x02 \x02bold\x02";
+ const expected = [{
+ bold: true,
+ textColor: undefined,
+ bgColor: undefined,
+ reverse: false,
+ italic: false,
+ underline: false,
+ text: "bold",
+
+ start: 0,
+ end: 4
+ }, {
+ bold: false,
+ textColor: undefined,
+ bgColor: undefined,
+ reverse: false,
+ italic: false,
+ underline: false,
+ text: " ",
+
+ start: 4,
+ end: 5
+ }, {
+ bold: true,
+ textColor: undefined,
+ bgColor: undefined,
+ reverse: false,
+ italic: false,
+ underline: false,
+ text: "bold",
+
+ start: 5,
+ end: 9
+ }];
+
+ const actual = parseStyle(input);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should reset all styles", () => {
+ const input = "\x02\x034\x16\x1d\x1ffull\x0fnone";
+ const expected = [{
+ bold: true,
+ textColor: 4,
+ bgColor: undefined,
+ reverse: true,
+ italic: true,
+ underline: true,
+ text: "full",
+
+ start: 0,
+ end: 4
+ }, {
+ bold: false,
+ textColor: undefined,
+ bgColor: undefined,
+ reverse: false,
+ italic: false,
+ underline: false,
+ text: "none",
+
+ start: 4,
+ end: 8
+ }];
+
+ const actual = parseStyle(input);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should not emit empty fragments", () => {
+ const input = "\x031\x031,2\x031\x031,2\x031\x031,2\x03a";
+ const expected = [{
+ bold: false,
+ textColor: undefined,
+ bgColor: undefined,
+ reverse: false,
+ italic: false,
+ underline: false,
+ text: "a",
+
+ start: 0,
+ end: 1
+ }];
+
+ const actual = parseStyle(input);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should optimize fragments", () => {
+ const rawString = "oh hi test text";
+ const colorCode = "\x0312";
+ const input = colorCode + rawString.split("").join(colorCode);
+ const expected = [{
+ bold: false,
+ textColor: 12,
+ bgColor: undefined,
+ reverse: false,
+ italic: false,
+ underline: false,
+ text: rawString,
+
+ start: 0,
+ end: rawString.length
+ }];
+
+ const actual = parseStyle(input);
+
+ expect(actual).to.deep.equal(expected);
+ });
+});
diff --git a/test/client/js/libs/handlebars/parse.js b/test/client/js/libs/handlebars/parse.js
new file mode 100644
index 00000000..d3737e98
--- /dev/null
+++ b/test/client/js/libs/handlebars/parse.js
@@ -0,0 +1,336 @@
+"use strict";
+
+const expect = require("chai").expect;
+const parse = require("../../../../../client/js/libs/handlebars/parse");
+
+describe("parse Handlebars helper", () => {
+ it("should not introduce xss", () => {
+ const testCases = [{
+ input: "
",
+ expected: "<img onerror='location.href="//youtube.com"'>"
+ }, {
+ input: "#&\">bug",
+ expected: "#&">bug"
+ }];
+
+ const actual = testCases.map(testCase => parse(testCase.input));
+ const expected = testCases.map(testCase => testCase.expected);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should skip control codes", () => {
+ const testCases = [{
+ input: "text\x01with\x04control\x05codes",
+ expected: "textwithcontrolcodes"
+ }];
+
+ const actual = testCases.map(testCase => parse(testCase.input));
+ const expected = testCases.map(testCase => testCase.expected);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should find urls", () => {
+ const testCases = [{
+ input: "irc://freenode.net/thelounge",
+ expected:
+ "" +
+ "irc://freenode.net/thelounge" +
+ ""
+ }, {
+ input: "www.nooooooooooooooo.com",
+ expected:
+ "" +
+ "www.nooooooooooooooo.com" +
+ ""
+ }, {
+ input: "look at https://thelounge.github.io/ for more information",
+ expected:
+ "look at " +
+ "" +
+ "https://thelounge.github.io/" +
+ "" +
+ " for more information",
+ }, {
+ input: "use www.duckduckgo.com for privacy reasons",
+ expected:
+ "use " +
+ "" +
+ "www.duckduckgo.com" +
+ "" +
+ " for privacy reasons"
+ }, {
+ input: "svn+ssh://example.org",
+ expected:
+ "" +
+ "svn+ssh://example.org" +
+ ""
+ }];
+
+ const actual = testCases.map(testCase => parse(testCase.input));
+ const expected = testCases.map(testCase => testCase.expected);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("url with a dot parsed correctly", () => {
+ const input =
+ "bonuspunkt: your URL parser misparses this URL: https://msdn.microsoft.com/en-us/library/windows/desktop/ms644989(v=vs.85).aspx";
+ const correctResult =
+ "bonuspunkt: your URL parser misparses this URL: " +
+ "" +
+ "https://msdn.microsoft.com/en-us/library/windows/desktop/ms644989(v=vs.85).aspx" +
+ "";
+
+ const actual = parse(input);
+
+ expect(actual).to.deep.equal(correctResult);
+ });
+
+ it("should balance brackets", () => {
+ const testCases = [{
+ input: "",
+ expected:
+ "<" +
+ "" +
+ "https://theos.kyriasis.com/~kyrias/stats/archlinux.html" +
+ "" +
+ ">"
+ }, {
+ input: "abc (www.example.com)",
+ expected:
+ "abc (" +
+ "" +
+ "www.example.com" +
+ "" +
+ ")"
+ }, {
+ input: "http://example.com/Test_(Page)",
+ expected:
+ "" +
+ "http://example.com/Test_(Page)" +
+ ""
+ }, {
+ input: "www.example.com/Test_(Page)",
+ expected:
+ "" +
+ "www.example.com/Test_(Page)" +
+ ""
+ }];
+
+ const actual = testCases.map(testCase => parse(testCase.input));
+ const expected = testCases.map(testCase => testCase.expected);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should not find urls", () => {
+ const testCases = [{
+ input: "text www. text",
+ expected: "text www. text"
+ }, {
+ input: "http://.",
+ expected: "http://."
+ }];
+
+ const actual = testCases.map(testCase => parse(testCase.input));
+ const expected = testCases.map(testCase => testCase.expected);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should find channels", () => {
+ const testCases = [{
+ input: "#a",
+ expected:
+ "" +
+ "#a" +
+ ""
+ }, {
+ input: "#test",
+ expected:
+ "" +
+ "#test" +
+ ""
+ }, {
+ input: "#äöü",
+ expected:
+ "" +
+ "#äöü" +
+ ""
+ }, {
+ input: "inline #channel text",
+ expected:
+ "inline " +
+ "" +
+ "#channel" +
+ "" +
+ " text"
+ }, {
+ input: "#1,000",
+ expected:
+ "" +
+ "#1,000" +
+ ""
+ }, {
+ input: "@#a",
+ expected:
+ "@" +
+ "" +
+ "#a" +
+ ""
+ }];
+
+ const actual = testCases.map(testCase => parse(testCase.input));
+ const expected = testCases.map(testCase => testCase.expected);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should not find channels", () => {
+ const testCases = [{
+ input: "hi#test",
+ expected: "hi#test"
+ }, {
+ input: "#",
+ expected: "#"
+ }];
+
+ const actual = testCases.map(testCase => parse(testCase.input));
+ const expected = testCases.map(testCase => testCase.expected);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should style like mirc", () => {
+ const testCases = [{
+ input: "\x02bold",
+ expected: "bold"
+ }, {
+ input: "\x038yellowText",
+ expected: "yellowText"
+ }, {
+ input: "\x030,0white,white",
+ expected: "white,white"
+ }, {
+ input: "\x034,8yellowBGredText",
+ expected: "yellowBGredText"
+ }, {
+ input: "\x1ditalic",
+ expected: "italic"
+ }, {
+ input: "\x1funderline",
+ expected: "underline"
+ }, {
+ input: "\x02bold\x038yellow\x02nonBold\x03default",
+ expected:
+ "bold" +
+ "yellow" +
+ "nonBold" +
+ "default"
+ }, {
+ input: "\x02bold\x02 \x02bold\x02",
+ expected:
+ "bold" +
+ " " +
+ "bold"
+ }];
+
+ const actual = testCases.map(testCase => parse(testCase.input));
+ const expected = testCases.map(testCase => testCase.expected);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should go bonkers like mirc", () => {
+ const testCases = [{
+ input: "\x02irc\x0f://\x1dfreenode.net\x0f/\x034,8thelounge",
+ expected:
+ "" +
+ "irc" +
+ "://" +
+ "freenode.net" +
+ "/" +
+ "thelounge" +
+ ""
+ }, {
+ input: "\x02#\x038,9thelounge",
+ expected:
+ "" +
+ "#" +
+ "thelounge" +
+ ""
+ }];
+
+ const actual = testCases.map(testCase => parse(testCase.input));
+ const expected = testCases.map(testCase => testCase.expected);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should optimize generated html", () => {
+ const testCases = [{
+ input: "test \x0312#\x0312\x0312\"te\x0312st\x0312\x0312\x0312\x0312\x0312\x0312\x0312\x0312\x0312\x0312\x0312a",
+ expected:
+ "test " +
+ "" +
+ "#"testa" +
+ ""
+ }];
+
+ const actual = testCases.map(testCase => parse(testCase.input));
+ const expected = testCases.map(testCase => testCase.expected);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should trim commom protocols", () => {
+ const testCases = [{
+ input: "like..http://example.com",
+ expected:
+ "like.." +
+ "" +
+ "http://example.com" +
+ ""
+ }, {
+ input: "like..HTTP://example.com",
+ expected:
+ "like.." +
+ "" +
+ "HTTP://example.com" +
+ ""
+ }];
+
+ const actual = testCases.map(testCase => parse(testCase.input));
+ const expected = testCases.map(testCase => testCase.expected);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should not find channel in fragment", () => {
+ const testCases = [{
+ input: "http://example.com/#hash",
+ expected:
+ "" +
+ "" +
+ "http://example.com/#hash" +
+ ""
+ }];
+
+ const actual = testCases.map(testCase => parse(testCase.input));
+ const expected = testCases.map(testCase => testCase.expected);
+
+ expect(actual).to.deep.equal(expected);
+ });
+
+ it("should not overlap parts", () => {
+ const input = "Url: http://example.com/path Channel: ##channel";
+ const actual = parse(input);
+
+ expect(actual).to.equal(
+ "Url: http://example.com/path " +
+ "Channel: ##channel"
+ );
+ });
+});