diff --git a/client/js/libs/handlebars/ircmessageparser/anyIntersection.js b/client/js/libs/handlebars/ircmessageparser/anyIntersection.js
new file mode 100644
index 00000000..4fd0d239
--- /dev/null
+++ b/client/js/libs/handlebars/ircmessageparser/anyIntersection.js
@@ -0,0 +1,10 @@
+"use strict";
+
+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..2cc9f705
--- /dev/null
+++ b/client/js/libs/handlebars/ircmessageparser/fill.js
@@ -0,0 +1,29 @@
+"use strict";
+
+function fill(existingEntries, text) {
+ let position = 0;
+ const result = [];
+
+ for (let i = 0; i < existingEntries.length; i++) {
+ const textSegment = existingEntries[i];
+
+ if (textSegment.start > position) {
+ result.push({
+ start: position,
+ end: textSegment.start
+ });
+ }
+ position = textSegment.end;
+ }
+
+ 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..b613415c
--- /dev/null
+++ b/client/js/libs/handlebars/ircmessageparser/findChannels.js
@@ -0,0 +1,32 @@
+"use strict";
+
+const escapeRegExp = require("lodash/escapeRegExp");
+
+// NOTE: channel prefixes should be RPL_ISUPPORT.CHANTYPES
+// NOTE: userModes should be RPL_ISUPPORT.PREFIX
+function findChannels(text, channelPrefixes, userModes) {
+ 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 {
+ 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/merge.js b/client/js/libs/handlebars/ircmessageparser/merge.js
new file mode 100644
index 00000000..3da520e8
--- /dev/null
+++ b/client/js/libs/handlebars/ircmessageparser/merge.js
@@ -0,0 +1,47 @@
+"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;
+ };
+}
+
+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)
+ });
+}
+
+function merge(textParts, styleFragments) {
+ const cleanText = styleFragments.map(fragment => fragment.text).join("");
+
+ const allParts = textParts
+ .concat(fill(textParts, cleanText))
+ .sort((a, b) => a.start - b.start);
+
+ 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..54e1c191
--- /dev/null
+++ b/client/js/libs/handlebars/ircmessageparser/parseStyle.js
@@ -0,0 +1,131 @@
+"use strict";
+
+const BOLD = "\x02";
+const COLOR = "\x03";
+const RESET = "\x0f";
+const REVERSE = "\x16";
+const ITALIC = "\x1d";
+const UNDERLINE = "\x1f";
+
+const colorRx = /^(\d{1,2})(?:,(\d{1,2}))?/;
+const controlCodesRx = /[\u0000-\u001F]/g;
+
+function parseStyle(text) {
+ const result = [];
+ let start = 0;
+ let position = 0;
+
+ let colorCodes, bold, textColor, bgColor, reverse, italic, underline;
+
+ const resetStyle = () => {
+ bold = false;
+ textColor = undefined;
+ bgColor = undefined;
+ reverse = false;
+ italic = false;
+ underline = false;
+ };
+ resetStyle();
+
+ const emitFragment = () => {
+ const textPart = text.slice(start, position);
+ start = position + 1;
+
+ const processedText = textPart.replace(controlCodesRx, "");
+
+ if (!processedText.length) {
+ return;
+ }
+
+ result.push({
+ bold,
+ textColor,
+ bgColor,
+ reverse,
+ italic,
+ underline,
+ text: processedText
+ });
+ };
+
+ while (position < text.length) {
+ switch (text[position]) {
+
+ case RESET:
+ emitFragment();
+ resetStyle();
+ break;
+
+ case BOLD:
+ emitFragment();
+ bold = !bold;
+ break;
+
+ case COLOR:
+ emitFragment();
+
+ colorCodes = text.slice(position + 1).match(colorRx);
+
+ if (colorCodes) {
+ textColor = Number(colorCodes[1]);
+ bgColor = Number(colorCodes[2]);
+ if (Number.isNaN(bgColor)) {
+ bgColor = undefined;
+ }
+ position += colorCodes[0].length;
+ } else {
+ textColor = undefined;
+ bgColor = undefined;
+ }
+ start = position + 1;
+ break;
+
+ case REVERSE:
+ emitFragment();
+ reverse = !reverse;
+ break;
+
+ case ITALIC:
+ emitFragment();
+ italic = !italic;
+ break;
+
+ case UNDERLINE:
+ emitFragment();
+ underline = !underline;
+ break;
+ }
+ position += 1;
+ }
+
+ emitFragment();
+
+ return result;
+}
+
+const properties = ["bold", "textColor", "bgColor", "italic", "underline", "reverse"];
+
+function prepare(text) {
+ return parseStyle(text)
+ .filter(fragment => fragment.text.length)
+ .reduce((prev, curr, i) => {
+ if (i === 0) {
+ return prev.concat([curr]);
+ }
+
+ 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;
+ });
+}
+
+module.exports = prepare;
diff --git a/client/js/libs/handlebars/parse.js b/client/js/libs/handlebars/parse.js
index 45d5c8d2..8c6ae432 100644
--- a/client/js/libs/handlebars/parse.js
+++ b/client/js/libs/handlebars/parse.js
@@ -2,125 +2,113 @@
const Handlebars = require("handlebars/runtime");
const URI = require("urijs");
+const parseStyle = require("./ircmessageparser/parseStyle");
+const findChannels = require("./ircmessageparser/findChannels");
+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;
-};
+const commonSchemes = [
+ "http", "https",
+ "ftp", "sftp",
+ "smb", "file",
+ "irc", "ircs",
+ "svn", "git",
+ "steam", "mumble", "ts3server",
+ "svn+ssh", "ssh",
+];
-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;
- });
-}
+function findLinks(text) {
+ let result = [];
+ let lastPosition = 0;
-/**
- * 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) {
+ 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) {
return;
}
+ // ^-- /fix: url was modified and does not match input string -> cant be mapped
- result = result.replace(style.keyregex, function(matchedTrash, matchedText) {
- return styleTemplate({
- style: style.style,
- text: matchedText
- });
+ // v-- fix: use prefered scheme
+ const parsed = URI(url);
+ const parsedScheme = parsed.scheme().toLowerCase();
+ const matchedScheme = commonSchemes.find(scheme => parsedScheme.endsWith(scheme));
+
+ 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
+ if (!parsedScheme.length) {
+ url = "http://" + url;
+ }
+
+ result.push({
+ start: start,
+ end: end,
+ link: url
});
});
return result;
}
+
+function createFragment(fragment) {
+ let className = "";
+ if (fragment.bold) {
+ className += " irc-bold";
+ }
+ if (fragment.textColor !== undefined) {
+ className += " irc-fg" + fragment.textColor;
+ }
+ if (fragment.bgColor !== undefined) {
+ className += " irc-bg" + fragment.bgColor;
+ }
+ if (fragment.italic) {
+ className += " irc-italic";
+ }
+ if (fragment.underline) {
+ className += " irc-underline";
+ }
+ const escapedText = Handlebars.Utils.escapeExpression(fragment.text);
+ if (className) {
+ return "" + escapedText + "";
+ }
+ return escapedText;
+}
+
+module.exports = function parse(text) {
+ const styleFragments = parseStyle(text);
+ const cleanText = styleFragments.map(fragment => fragment.text).join("");
+
+ const channelPrefixes = ["#", "&"]; // RPL_ISUPPORT.CHANTYPES
+ const userModes = ["!", "@", "%", "+"]; // RPL_ISUPPORT.PREFIX
+ const channelParts = findChannels(cleanText, channelPrefixes, userModes);
+
+ const linkParts = findLinks(cleanText);
+
+ const parts = channelParts
+ .concat(linkParts)
+ .sort((a, b) => a.start - b.start);
+
+ return merge(parts, styleFragments).map(textPart => {
+ const fragments = textPart.fragments.map(createFragment).join("");
+
+ 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("");
+};