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" + ); + }); +});