diff --git a/test_urlize.js b/test_urlize.js index 004db4d..cd87720 100644 --- a/test_urlize.js +++ b/test_urlize.js @@ -209,6 +209,7 @@ describe('Basic functionality', function () { describe('convert_arguments', function () { it('single argument', function () { deepEqual(urlize.test.convert_arguments(['foo']), { + attrs: undefined, autoescape: undefined, nofollow: undefined, target: undefined, @@ -364,3 +365,22 @@ describe('Trimming', function () { 'http://www.example.com/'); }); }); + +describe('Additional HTML Attributes', function () { + it('add single additional attributes', function () { + equal(urlize('http://www.example.com/', {attrs: {position: 'left'}}), + 'http://www.example.com/'); + }); + it('add several additional attributes', function () { + equal(urlize('http://www.example.com/', {attrs: {position: 'left', 'open-in-app': true}}), + 'http://www.example.com/'); + }); + it('add additional attributes with illegal values', function () { + equal(urlize('http://www.example.com/', {attrs: {position: 'left', 'open-in-app': '"/true'}}), + 'http://www.example.com/'); + }); + it('add additional attributes with illegal keys', function () { + equal(urlize('http://www.example.com/', {attrs: {position: 'left', '"open,-in-app': '"/true'}}), + 'http://www.example.com/'); + }); +}); diff --git a/urlize.js b/urlize.js index 24ba320..a7ad5fe 100644 --- a/urlize.js +++ b/urlize.js @@ -1,4 +1,4 @@ -(function (root, factory) { +(function(root, factory) { if (typeof define === 'function' && define.amd) { // AMD. Register as an anonymous module. define('urlize', [], factory); @@ -9,7 +9,7 @@ // Browser globals (root is window) root.urlize = factory(root.b); } -}(this, function () { +}(this, function() { // From http://blog.stevenlevithan.com/archives/cross-browser-split // modified to not add itself to String.prototype. @@ -47,13 +47,13 @@ var split; // Avoid running twice; that would break the `nativeSplit` reference - split = split || function (undef) { + split = split || function(undef) { var nativeSplit = String.prototype.split, compliantExecNpcg = /()??/.exec("")[1] === undef, // NPCG: nonparticipating capturing group self; - self = function (str, separator, limit) { + self = function(str, separator, limit) { // If `separator` is not a regex, use `nativeSplit` if (Object.prototype.toString.call(separator) !== "[object RegExp]") { return nativeSplit.call(str, separator, limit); @@ -92,7 +92,7 @@ // Fix browsers whose `exec` methods don't consistently return `undefined` for // nonparticipating capturing groups if (!compliantExecNpcg && match.length > 1) { - match[0].replace(separator2, function () { + match[0].replace(separator2, function() { for (var i = 1; i < arguments.length - 2; i++) { if (arguments[i] === undef) { match[i] = undef; @@ -125,7 +125,36 @@ return self; }(); + + RegExp.escape = function(text) { + return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); + } + String.prototype.mapReplace = function (replacements) { + var regex = []; + + for (var prop in replacements) { + regex.push(RegExp.escape(prop)); + } + + regex = new RegExp( regex.join('|'), "g" ); + + return this.replace(regex, function(match){ + return replacements[match]; + }); + } + + String.prototype.escapeHtml = function() { + return this.mapReplace({ + '<': '<', + '>': '>', + ' ': ' ', + '"': '"', + "'": '‘', + '/': '/', + '=': '=' + }); + }; function startswith(string, prefix) { return string.substr(0, prefix.length) == prefix; @@ -172,9 +201,18 @@ var trailing_punctuation_django = ['.', ',', ':', ';']; var trailing_punctuation_improved = ['.', ',', ':', ';', '.)']; - var wrapping_punctuation_django = [['(', ')'], ['<', '>'], ['<', '>']]; - var wrapping_punctuation_improved = [['(', ')'], ['<', '>'], ['<', '>'], - ['“', '”'], ['‘', '’']]; + var wrapping_punctuation_django = [ + ['(', ')'], + ['<', '>'], + ['<', '>'] + ]; + var wrapping_punctuation_improved = [ + ['(', ')'], + ['<', '>'], + ['<', '>'], + ['“', '”'], + ['‘', '’'] + ]; var word_split_re_django = /(\s+)/; var word_split_re_improved = /([\s<>"]+)/; var simple_url_re = /^https?:\/\/\w/i; @@ -205,14 +243,15 @@ function convert_arguments(args) { var options; - if (args.length == 2 && typeof (args[1]) == 'object') { + if (args.length == 2 && typeof(args[1]) == 'object') { options = args[1]; } else { options = { nofollow: args[1], autoescape: args[2], trim_url_limit: args[3], - target: args[4] + target: args[4], + attrs: args[5] }; } if (!('django_compatible' in options)) options.django_compatible = true; @@ -235,10 +274,10 @@ var word_split_re = options.django_compatible ? word_split_re_django : word_split_re_improved; var trailing_punctuation = options.django_compatible ? trailing_punctuation_django : trailing_punctuation_improved; var wrapping_punctuation = options.django_compatible ? wrapping_punctuation_django : wrapping_punctuation_improved; - var simple_url_2_re = new RegExp('^www\\.|^(?!http)\\w[^@]+\\.(' + - (options.top_level_domains || django_top_level_domains).join('|') + - ')$', - "i"); + var simple_url_2_re = new RegExp('^www\\.|^(?!http)\\w[^@]+\\.(' + + (options.top_level_domains || django_top_level_domains).join('|') + + ')$', + "i"); var words = split(text, word_split_re); for (var i = 0; i < words.length; i++) { var word = words[i]; @@ -274,6 +313,12 @@ var nofollow_attr = options.nofollow ? ' rel="nofollow"' : ''; var target_attr = options.target ? ' target="' + options.target + '"' : ''; + var other_attr = ''; + + for (attr in options.attrs) { + other_attr += options.attrs[attr] ? ' ' + attr.replace(/[^a-zA-Z0-9_.-]/gi, '') + '="' + String(options.attrs[attr]).escapeHtml() + '"' : ''; + } + if (middle.match(simple_url_re)) url = smart_urlquote(middle); else if (middle.match(simple_url_2_re)) url = smart_urlquote('http://' + middle); else if (middle.indexOf(':') == -1 && middle.match(simple_email_re)) { @@ -292,7 +337,7 @@ url = urlescape(url); trimmed = htmlescape(trimmed, options); } - middle = '' + trimmed + ''; + middle = '' + trimmed + ''; words[i] = lead + middle + trail; } else { if (safe_input) { @@ -316,3 +361,4 @@ return urlize; })); +