diff --git a/client/js/libs/handlebars/ircmessageparser/anyIntersection.js b/client/js/libs/handlebars/ircmessageparser/anyIntersection.js
index 4fd0d239..a77e031d 100644
--- a/client/js/libs/handlebars/ircmessageparser/anyIntersection.js
+++ b/client/js/libs/handlebars/ircmessageparser/anyIntersection.js
@@ -1,5 +1,7 @@
"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 ||
diff --git a/client/js/libs/handlebars/ircmessageparser/fill.js b/client/js/libs/handlebars/ircmessageparser/fill.js
index 2cc9f705..7d90a96c 100644
--- a/client/js/libs/handlebars/ircmessageparser/fill.js
+++ b/client/js/libs/handlebars/ircmessageparser/fill.js
@@ -1,21 +1,26 @@
"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;
- const result = [];
-
- for (let i = 0; i < existingEntries.length; i++) {
- const textSegment = existingEntries[i];
+ // 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) {
- result.push({
+ 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,
diff --git a/client/js/libs/handlebars/ircmessageparser/findChannels.js b/client/js/libs/handlebars/ircmessageparser/findChannels.js
index b613415c..6edd5dad 100644
--- a/client/js/libs/handlebars/ircmessageparser/findChannels.js
+++ b/client/js/libs/handlebars/ircmessageparser/findChannels.js
@@ -1,20 +1,31 @@
"use strict";
+// Escapes the RegExp special characters "^", "$", "", ".", "*", "+", "?", "(",
+// ")", "[", "]", "{", "}", and "|" in string.
+// See https://lodash.com/docs/#escapeRegExp
const escapeRegExp = require("lodash/escapeRegExp");
-// NOTE: channel prefixes should be RPL_ISUPPORT.CHANTYPES
-// NOTE: userModes should be RPL_ISUPPORT.PREFIX
+// 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 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) {
diff --git a/client/js/libs/handlebars/ircmessageparser/findLinks.js b/client/js/libs/handlebars/ircmessageparser/findLinks.js
index 031afa17..9596a5a0 100644
--- a/client/js/libs/handlebars/ircmessageparser/findLinks.js
+++ b/client/js/libs/handlebars/ircmessageparser/findLinks.js
@@ -2,6 +2,9 @@
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",
@@ -16,6 +19,10 @@ function findLinks(text) {
let result = [];
let lastPosition = 0;
+ // 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) {
// v-- fix: url was modified and does not match input string -> cant be mapped
if (text.indexOf(url, lastPosition) < 0) {
@@ -23,19 +30,22 @@ function findLinks(text) {
}
// ^-- /fix: url was modified and does not match input string -> cant be mapped
- // v-- fix: use prefered scheme
- const parsed = URI(url);
- const parsedScheme = parsed.scheme().toLowerCase();
+ // 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);
}
- // ^-- /fix: use prefered scheme
- // URL matched, but does not start with a protocol, add it
+ // The URL matched but does not start with a scheme (`www.foo.com`), add it
if (!parsedScheme.length) {
url = "http://" + url;
}
diff --git a/client/js/libs/handlebars/ircmessageparser/merge.js b/client/js/libs/handlebars/ircmessageparser/merge.js
index 3da520e8..893997cc 100644
--- a/client/js/libs/handlebars/ircmessageparser/merge.js
+++ b/client/js/libs/handlebars/ircmessageparser/merge.js
@@ -16,6 +16,7 @@ if (typeof Object_assign !== "function") {
};
}
+// Merge text part information within a styling fragment
function assign(textPart, fragment) {
const fragStart = fragment.start;
const start = Math.max(fragment.start, textPart.start);
@@ -28,13 +29,25 @@ function assign(textPart, fragment) {
});
}
+// 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) {
- const cleanText = styleFragments.map(fragment => fragment.text).join("");
+ // 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))
diff --git a/client/js/libs/handlebars/ircmessageparser/parseStyle.js b/client/js/libs/handlebars/ircmessageparser/parseStyle.js
index 54e1c191..d23d5bd6 100644
--- a/client/js/libs/handlebars/ircmessageparser/parseStyle.js
+++ b/client/js/libs/handlebars/ircmessageparser/parseStyle.js
@@ -1,5 +1,6 @@
"use strict";
+// Styling control codes
const BOLD = "\x02";
const COLOR = "\x03";
const RESET = "\x0f";
@@ -7,14 +8,24 @@ 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 = () => {
@@ -27,27 +38,42 @@ function parseStyle(text) {
};
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);
- start = position + 1;
+ // Filters out all non-style related control codes present in this text
const processedText = textPart.replace(controlCodesRx, "");
- if (!processedText.length) {
- return;
+ 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
+ });
}
- result.push({
- bold,
- textColor,
- bgColor,
- reverse,
- italic,
- underline,
- text: processedText
- });
+ // 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]) {
@@ -56,6 +82,10 @@ function parseStyle(text) {
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;
@@ -64,20 +94,23 @@ function parseStyle(text) {
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]);
- bgColor = Number(colorCodes[2]);
- if (Number.isNaN(bgColor)) {
- bgColor = undefined;
+ 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;
}
- start = position + 1;
break;
case REVERSE:
@@ -95,9 +128,12 @@ function parseStyle(text) {
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;
@@ -107,25 +143,19 @@ const properties = ["bold", "textColor", "bgColor", "italic", "underline", "reve
function prepare(text) {
return parseStyle(text)
- .filter(fragment => fragment.text.length)
- .reduce((prev, curr, i) => {
- if (i === 0) {
- return prev.concat([curr]);
+ // 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;
+ }
}
-
- const lastEntry = prev[prev.length - 1];
- if (properties.some(key => curr[key] !== lastEntry[key])) {
- return prev.concat([curr]);
- }
-
- lastEntry.text += curr.text;
- return prev;
- }, [])
- .map((fragment, i, array) => {
- fragment.start = i === 0 ? 0 : array[i - 1].end;
- fragment.end = fragment.start + fragment.text.length;
- return fragment;
- });
+ return prev.concat([curr]);
+ }, []);
}
module.exports = prepare;
diff --git a/client/js/libs/handlebars/parse.js b/client/js/libs/handlebars/parse.js
index 7e9aebb8..915a432c 100644
--- a/client/js/libs/handlebars/parse.js
+++ b/client/js/libs/handlebars/parse.js
@@ -6,6 +6,7 @@ const findChannels = require("./ircmessageparser/findChannels");
const findLinks = require("./ircmessageparser/findLinks");
const merge = require("./ircmessageparser/merge");
+// Create an HTML `span` with styling information for a given fragment
function createFragment(fragment) {
let classes = [];
if (fragment.bold) {
@@ -30,23 +31,33 @@ function createFragment(fragment) {
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("");
- const channelPrefixes = ["#", "&"]; // RPL_ISUPPORT.CHANTYPES
- const userModes = ["!", "@", "%", "+"]; // RPL_ISUPPORT.PREFIX
+ // 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}`;
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
index 93c119ee..4c676e57 100644
--- a/test/client/js/libs/handlebars/ircmessageparser/findChannels.js
+++ b/test/client/js/libs/handlebars/ircmessageparser/findChannels.js
@@ -1,7 +1,7 @@
"use strict";
const expect = require("chai").expect;
-const analyseText = require("../../../../../../client/js/libs/handlebars/ircmessageparser/findChannels");
+const findChannels = require("../../../../../../client/js/libs/handlebars/ircmessageparser/findChannels");
describe("findChannels", () => {
it("should find single letter channel", () => {
@@ -12,7 +12,7 @@ describe("findChannels", () => {
end: 2
}];
- const actual = analyseText(input, ["#"], ["@", "+"]);
+ const actual = findChannels(input, ["#"], ["@", "+"]);
expect(actual).to.deep.equal(expected);
});
@@ -25,7 +25,7 @@ describe("findChannels", () => {
end: 4
}];
- const actual = analyseText(input, ["#"], ["@", "+"]);
+ const actual = findChannels(input, ["#"], ["@", "+"]);
expect(actual).to.deep.equal(expected);
});
@@ -38,7 +38,7 @@ describe("findChannels", () => {
end: 15
}];
- const actual = analyseText(input, ["#"], ["@", "+"]);
+ const actual = findChannels(input, ["#"], ["@", "+"]);
expect(actual).to.deep.equal(expected);
});
@@ -51,7 +51,7 @@ describe("findChannels", () => {
end: 5
}];
- const actual = analyseText(input, ["#"], ["@", "+"]);
+ const actual = findChannels(input, ["#"], ["@", "+"]);
expect(actual).to.deep.equal(expected);
});
@@ -64,7 +64,7 @@ describe("findChannels", () => {
end: 6
}];
- const actual = analyseText(input, ["#"], ["@", "+"]);
+ const actual = findChannels(input, ["#"], ["@", "+"]);
expect(actual).to.deep.equal(expected);
});
@@ -77,7 +77,7 @@ describe("findChannels", () => {
end: 3
}];
- const actual = analyseText(input, ["#"], ["@", "+"]);
+ const actual = findChannels(input, ["#"], ["@", "+"]);
expect(actual).to.deep.equal(expected);
});
@@ -90,7 +90,7 @@ describe("findChannels", () => {
end: 6
}];
- const actual = analyseText(input, ["#"], ["!", "@", "%", "+"]);
+ const actual = findChannels(input, ["#"], ["!", "@", "%", "+"]);
expect(actual).to.deep.equal(expected);
});
@@ -103,7 +103,7 @@ describe("findChannels", () => {
end: 2
}];
- const actual = analyseText(input, ["@"], ["#", "+"]);
+ const actual = findChannels(input, ["@"], ["#", "+"]);
expect(actual).to.deep.equal(expected);
});
@@ -116,7 +116,7 @@ describe("findChannels", () => {
end: 6
}];
- const actual = analyseText(input, ["#"], ["@", "+"]);
+ const actual = findChannels(input, ["#"], ["@", "+"]);
expect(actual).to.deep.equal(expected);
});