diff --git a/app/assets/javascripts/discourse/dialects/autolink_dialect.js b/app/assets/javascripts/discourse/dialects/autolink_dialect.js index 4846abd6815..cd00e3036ed 100644 --- a/app/assets/javascripts/discourse/dialects/autolink_dialect.js +++ b/app/assets/javascripts/discourse/dialects/autolink_dialect.js @@ -1,45 +1,19 @@ /** This addition handles auto linking of text. When included, it will parse out links and create a hrefs for them. - - @event register - @namespace Discourse.Dialect **/ -Discourse.Dialect.on("register", function(event) { +var urlReplacerArgs = { + matcher: /(^|\s)((?:https?:(?:\/{1,3}|[a-z0-9%])|www\d{0,3}[.])(?:[^\s()<>]+|\([^\s()<>]+\))+(?:\([^\s()<>]+\)|[^`!()\[\]{};:'".,<>?«»“”‘’\s]))/gm, + spaceBoundary: true, - var dialect = event.dialect, - MD = event.MD; + emitter: function(matches) { + var url = matches[2], + displayUrl = url; - /** - Parses out links from HTML. + if (url.match(/^www/)) { url = "http://" + url; } + return ['a', {href: url}, displayUrl]; + } +}; - @method autoLink - @param {String} text the text match - @param {Array} match the match found - @param {Array} prev the previous jsonML - @return {Array} an array containing how many chars we've replaced and the jsonML content for it. - @namespace Discourse.Dialect - **/ - dialect.inline['http'] = dialect.inline['www'] = function autoLink(text, match, prev) { - - // We only care about links on boundaries - if (prev && (prev.length > 0)) { - var last = prev[prev.length - 1]; - if (typeof last === "string" && (!last.match(/\s$/))) { return; } - } - - var pattern = /(^|\s)((?:https?:(?:\/{1,3}|[a-z0-9%])|www\d{0,3}[.])(?:[^\s()<>]+|\([^\s()<>]+\))+(?:\([^\s()<>]+\)|[^`!()\[\]{};:'".,<>?«»“”‘’\s]))/gm, - m = pattern.exec(text); - - if (m) { - var url = m[2], - displayUrl = m[2]; - - if (url.match(/^www/)) { url = "http://" + url; } - return [m[0].length, ['a', {href: url}, displayUrl]]; - } - - }; - - -}); \ No newline at end of file +Discourse.Dialect.inlineRegexp(_.merge({start: 'http'}, urlReplacerArgs)); +Discourse.Dialect.inlineRegexp(_.merge({start: 'www'}, urlReplacerArgs)); diff --git a/app/assets/javascripts/discourse/dialects/bbcode_dialect.js b/app/assets/javascripts/discourse/dialects/bbcode_dialect.js index 8f2c9fb3071..7295d8de5d6 100644 --- a/app/assets/javascripts/discourse/dialects/bbcode_dialect.js +++ b/app/assets/javascripts/discourse/dialects/bbcode_dialect.js @@ -1,76 +1,112 @@ /** - Regsiter all functionality for supporting BBCode in Discourse. + Create a simple BBCode tag handler - @event register - @namespace Discourse.Dialect + @method replaceBBCode + @param {tag} tag the tag we want to match + @param {function} emitter the function that creates JsonML for the tag **/ +function replaceBBCode(tag, emitter) { + Discourse.Dialect.inlineReplace({ + start: "[" + tag + "]", + stop: "[/" + tag + "]", + emitter: emitter + }); +} + +/** + Creates a BBCode handler that accepts parameters. Passes them to the emitter. + + @method replaceBBCodeParamsRaw + @param {tag} tag the tag we want to match + @param {function} emitter the function that creates JsonML for the tag +**/ +function replaceBBCodeParamsRaw(tag, emitter) { + Discourse.Dialect.inlineReplace({ + start: "[" + tag + "=", + stop: "[/" + tag + "]", + rawContents: true, + emitter: function(contents) { + var regexp = /^([^\]]+)\](.*)$/, + m = regexp.exec(contents); + + if (m) { return emitter.call(this, m[1], m[2]); } + } + }); +} + +/** + Creates a BBCode handler that accepts parameters. Passes them to the emitter. + Processes the inside recursively so it can be nested. + + @method replaceBBCodeParams + @param {tag} tag the tag we want to match + @param {function} emitter the function that creates JsonML for the tag +**/ +function replaceBBCodeParams(tag, emitter) { + replaceBBCodeParamsRaw(tag, function (param, contents) { + return emitter(param, this.processInline(contents)); + }); +} + +replaceBBCode('b', function(contents) { return ['span', {'class': 'bbcode-b'}].concat(contents); }); +replaceBBCode('i', function(contents) { return ['span', {'class': 'bbcode-i'}].concat(contents); }); +replaceBBCode('u', function(contents) { return ['span', {'class': 'bbcode-u'}].concat(contents); }); +replaceBBCode('s', function(contents) { return ['span', {'class': 'bbcode-s'}].concat(contents); }); + +replaceBBCode('ul', function(contents) { return ['ul'].concat(contents); }); +replaceBBCode('ol', function(contents) { return ['ol'].concat(contents); }); +replaceBBCode('li', function(contents) { return ['li'].concat(contents); }); + +replaceBBCode('spoiler', function(contents) { return ['span', {'class': 'spoiler'}].concat(contents); }); + +Discourse.Dialect.inlineReplace({ + start: '[img]', + stop: '[/img]', + rawContents: true, + emitter: function(contents) { return ['img', {href: contents}]; } +}); + +Discourse.Dialect.inlineReplace({ + start: '[email]', + stop: '[/email]', + rawContents: true, + emitter: function(contents) { return ['a', {href: "mailto:" + contents, 'data-bbcode': true}, contents]; } +}); + +Discourse.Dialect.inlineReplace({ + start: '[url]', + stop: '[/url]', + rawContents: true, + emitter: function(contents) { return ['a', {href: contents, 'data-bbcode': true}, contents]; } +}); + + +replaceBBCodeParamsRaw("url", function(param, contents) { + return ['a', {href: param, 'data-bbcode': true}, contents]; +}); + +replaceBBCodeParamsRaw("email", function(param, contents) { + return ['a', {href: "mailto:" + param, 'data-bbcode': true}, contents]; +}); + +replaceBBCodeParams("size", function(param, contents) { + return ['span', {'class': "bbcode-size-" + param}].concat(contents); +}); + +replaceBBCodeParams("color", function(param, contents) { + // Only allow valid HTML colors. + if (/^(\#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?)|(aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|purple|red|silver|teal|white|yellow)$/.test(param)) { + return ['span', {style: "color: " + param}].concat(contents); + } else { + return ['span'].concat(contents); + } +}); + Discourse.Dialect.on("register", function(event) { var dialect = event.dialect, MD = event.MD; - var createBBCode = function(tag, builder, hasArgs) { - return function(text, orig_match) { - var bbcodePattern = new RegExp("\\[" + tag + "=?([^\\[\\]]+)?\\]([\\s\\S]*?)\\[\\/" + tag + "\\]", "igm"); - var m = bbcodePattern.exec(text); - if (m && m[0]) { - return [m[0].length, builder(m, this)]; - } - }; - }; - - var bbcodes = {'b': ['span', {'class': 'bbcode-b'}], - 'i': ['span', {'class': 'bbcode-i'}], - 'u': ['span', {'class': 'bbcode-u'}], - 's': ['span', {'class': 'bbcode-s'}], - 'spoiler': ['span', {'class': 'spoiler'}], - 'li': ['li'], - 'ul': ['ul'], - 'ol': ['ol']}; - - Object.keys(bbcodes).forEach(function(tag) { - var element = bbcodes[tag]; - dialect.inline["[" + tag + "]"] = createBBCode(tag, function(m, self) { - return element.concat(self.processInline(m[2])); - }); - }); - - dialect.inline["[img]"] = createBBCode('img', function(m) { - return ['img', {href: m[2]}]; - }); - - dialect.inline["[email]"] = createBBCode('email', function(m) { - return ['a', {href: "mailto:" + m[2], 'data-bbcode': true}, m[2]]; - }); - - dialect.inline["[url]"] = createBBCode('url', function(m) { - return ['a', {href: m[2], 'data-bbcode': true}, m[2]]; - }); - - dialect.inline["[url="] = createBBCode('url', function(m, self) { - return ['a', {href: m[1], 'data-bbcode': true}].concat(self.processInline(m[2])); - }); - - dialect.inline["[email="] = createBBCode('email', function(m, self) { - return ['a', {href: "mailto:" + m[1], 'data-bbcode': true}].concat(self.processInline(m[2])); - }); - - dialect.inline["[size="] = createBBCode('size', function(m, self) { - return ['span', {'class': "bbcode-size-" + m[1]}].concat(self.processInline(m[2])); - }); - - dialect.inline["[color="] = function(text, orig_match) { - var bbcodePattern = new RegExp("\\[color=?([^\\[\\]]+)?\\]([\\s\\S]*?)\\[\\/color\\]", "igm"), - m = bbcodePattern.exec(text); - - if (m && m[0]) { - if (!/^(\#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?)|(aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|purple|red|silver|teal|white|yellow)$/.test(m[1])) { - return [m[0].length].concat(this.processInline(m[2])); - } - return [m[0].length, ['span', {style: "color: " + m[1]}].concat(this.processInline(m[2]))]; - } - }; - /** Support BBCode [code] blocks diff --git a/app/assets/javascripts/discourse/dialects/bold_italics_dialect.js b/app/assets/javascripts/discourse/dialects/bold_italics_dialect.js index b4fadffb778..890b380d803 100644 --- a/app/assets/javascripts/discourse/dialects/bold_italics_dialect.js +++ b/app/assets/javascripts/discourse/dialects/bold_italics_dialect.js @@ -1,43 +1,25 @@ /** - Markdown.js doesn't seem to do bold and italics at the same time if you surround code with - three asterisks. This adds that support. - - @event register - @namespace Discourse.Dialect + markdown-js doesn't ensure that em/strong codes are present on word boundaries. + So we create our own handlers here. **/ -Discourse.Dialect.on("register", function(event) { - - var dialect = event.dialect, - MD = event.MD; - - - var inlineBuilder = function(symbol, tag, surround) { - return function(text, match, prev) { - if (prev && (prev.length > 0)) { - var last = prev[prev.length - 1]; - if (typeof last === "string" && (!last.match(/\W$/))) { return; } - } - - var regExp = new RegExp("^\\" + symbol + "([^\\" + symbol + "]+)" + "\\" + symbol, "igm"), - m = regExp.exec(text); - - if (m) { - - var contents = [tag].concat(this.processInline(m[1])); - if (surround) { - contents = [surround, contents]; - } - - return [m[0].length, contents]; - } - }; - }; - - dialect.inline['***'] = inlineBuilder('**', 'em', 'strong'); - dialect.inline['**'] = inlineBuilder('**', 'strong'); - dialect.inline['*'] = inlineBuilder('*', 'em'); - dialect.inline['_'] = inlineBuilder('_', 'em'); - - +// Support for simultaneous bold and italics +Discourse.Dialect.inlineReplace({ + between: '***', + wordBoundary: true, + emitter: function(contents) { return ['strong', ['em'].concat(contents)]; } }); + +// Builds a common markdown replacer +var replaceMarkdown = function(match, tag) { + Discourse.Dialect.inlineReplace({ + between: match, + wordBoundary: true, + emitter: function(contents) { return [tag].concat(contents) } + }); +}; + +replaceMarkdown('**', 'strong'); +replaceMarkdown('*', 'em'); +replaceMarkdown('_', 'em'); + diff --git a/app/assets/javascripts/discourse/dialects/dialect.js b/app/assets/javascripts/discourse/dialects/dialect.js index 6d2d50e535c..d5befd2315e 100644 --- a/app/assets/javascripts/discourse/dialects/dialect.js +++ b/app/assets/javascripts/discourse/dialects/dialect.js @@ -43,51 +43,67 @@ **/ var parser = window.BetterMarkdown, MD = parser.Markdown, - - // Our dialect dialect = MD.dialects.Discourse = MD.subclassDialect( MD.dialects.Gruber ), + initialized = false; - initialized = false, +/** + Initialize our dialects for processing. - /** - Initialize our dialects for processing. + @method initializeDialects +**/ +function initializeDialects() { + Discourse.Dialect.trigger('register', {dialect: dialect, MD: MD}); + MD.buildBlockOrder(dialect.block); + MD.buildInlinePatterns(dialect.inline); + initialized = true; +} - @method initializeDialects - **/ - initializeDialects = function() { - Discourse.Dialect.trigger('register', {dialect: dialect, MD: MD}); - MD.buildBlockOrder(dialect.block); - MD.buildInlinePatterns(dialect.inline); - initialized = true; - }, +/** + Parse a JSON ML tree, using registered handlers to adjust it if necessary. - /** - Parse a JSON ML tree, using registered handlers to adjust it if necessary. + @method parseTree + @param {Array} tree the JsonML tree to parse + @param {Array} path the path of ancestors to the current node in the tree. Can be used for matching. + @param {Object} insideCounts counts what tags we're inside + @returns {Array} the parsed tree +**/ +function parseTree(tree, path, insideCounts) { + if (tree instanceof Array) { + Discourse.Dialect.trigger('parseNode', {node: tree, path: path, dialect: dialect, insideCounts: insideCounts || {}}); - @method parseTree - @param {Array} tree the JsonML tree to parse - @param {Array} path the path of ancestors to the current node in the tree. Can be used for matching. - @param {Object} insideCounts counts what tags we're inside - @returns {Array} the parsed tree - **/ - parseTree = function parseTree(tree, path, insideCounts) { - if (tree instanceof Array) { - Discourse.Dialect.trigger('parseNode', {node: tree, path: path, dialect: dialect, insideCounts: insideCounts || {}}); + path = path || []; + insideCounts = insideCounts || {}; - path = path || []; - insideCounts = insideCounts || {}; + path.push(tree); + tree.slice(1).forEach(function (n) { + var tagName = n[0]; + insideCounts[tagName] = (insideCounts[tagName] || 0) + 1; + parseTree(n, path, insideCounts); + insideCounts[tagName] = insideCounts[tagName] - 1; + }); + path.pop(); + } + return tree; +} - path.push(tree); - tree.slice(1).forEach(function (n) { - var tagName = n[0]; - insideCounts[tagName] = (insideCounts[tagName] || 0) + 1; - parseTree(n, path, insideCounts); - insideCounts[tagName] = insideCounts[tagName] - 1; - }); - path.pop(); - } - return tree; - }; +/** + Returns true if there's an invalid word boundary for a match. + + @method invalidBoundary + @param {Object} args our arguments, including whether we care about boundaries + @param {Array} prev the previous content, if exists + @returns {Boolean} whether there is an invalid word boundary +**/ +function invalidBoundary(args, prev) { + + if (!args.wordBoundary && !args.spaceBoundary) { return; } + + var last = prev[prev.length - 1]; + if (typeof last !== "string") { return; } + + if (args.wordBoundary && (!last.match(/\W$/))) { return true; } + if (args.spaceBoundary && (!last.match(/\s$/))) { return true; } +} /** An object used for rendering our dialects. @@ -110,7 +126,51 @@ Discourse.Dialect = { dialect.options = opts; var tree = parser.toHTMLTree(text, 'Discourse'); return parser.renderJsonML(parseTree(tree)); + }, + + inlineRegexp: function(args) { + dialect.inline[args.start] = function(text, match, prev) { + if (invalidBoundary(args, prev)) { return; } + + args.matcher.lastIndex = 0; + var m = args.matcher.exec(text); + if (m) { + var result = args.emitter.call(this, m); + if (result) { + return [m[0].length, result]; + } + } + }; + }, + + inlineReplace: function(args) { + var start = args.start || args.between, + stop = args.stop || args.between, + startLength = start.length; + + dialect.inline[start] = function(text, match, prev) { + if (invalidBoundary(args, prev)) { return; } + + var endPos = text.indexOf(stop, startLength); + if (endPos === -1) { return; } + + var between = text.slice(startLength, endPos); + + // If rawcontents is set, don't process inline + if (!args.rawContents) { + between = this.processInline(between); + } + + var contents = args.emitter.call(this, between); + if (contents) { + return [endPos + startLength + 1, contents]; + } + }; + } + }; RSVP.EventTarget.mixin(Discourse.Dialect); + + diff --git a/app/assets/javascripts/discourse/dialects/mention_dialect.js b/app/assets/javascripts/discourse/dialects/mention_dialect.js index 914e52dd7a0..66dc10fb5a5 100644 --- a/app/assets/javascripts/discourse/dialects/mention_dialect.js +++ b/app/assets/javascripts/discourse/dialects/mention_dialect.js @@ -2,47 +2,20 @@ Supports Discourse's custom @mention syntax for calling out a user in a post. It will add a special class to them, and create a link if the user is found in a local map. - - @event register - @namespace Discourse.Dialect **/ -Discourse.Dialect.on("register", function(event) { +Discourse.Dialect.inlineRegexp({ + start: '@', + matcher: /^(@[A-Za-z0-9][A-Za-z0-9_]{2,14})/m, + wordBoundary: true, - var dialect = event.dialect, - MD = event.MD; + emitter: function(matches) { + var username = matches[1], + mentionLookup = this.dialect.options.mentionLookup || Discourse.Mention.lookupCache; - /** - Parses out @username mentions. - - @method parseMentions - @param {String} text the text match - @param {Array} match the match found - @param {Array} prev the previous jsonML - @return {Array} an array containing how many chars we've replaced and the jsonML content for it. - @namespace Discourse.Dialect - **/ - dialect.inline['@'] = function parseMentions(text, match, prev) { - - // We only care about mentions on word boundaries - if (prev && (prev.length > 0)) { - var last = prev[prev.length - 1]; - if (typeof last === "string" && (!last.match(/\W$/))) { return; } + if (mentionLookup(username.substr(1))) { + return ['a', {'class': 'mention', href: Discourse.getURL("/users/") + username.substr(1).toLowerCase()}, username]; + } else { + return ['span', {'class': 'mention'}, username]; } - - var pattern = /^(@[A-Za-z0-9][A-Za-z0-9_]{2,14})(?=(\W|$))/m, - m = pattern.exec(text); - - if (m) { - var username = m[1], - mentionLookup = dialect.options.mentionLookup || Discourse.Mention.lookupCache; - - if (mentionLookup(username.substr(1))) { - return [username.length, ['a', {'class': 'mention', href: Discourse.getURL("/users/") + username.substr(1).toLowerCase()}, username]]; - } else { - return [username.length, ['span', {'class': 'mention'}, username]]; - } - } - - }; - -}); + } +}); \ No newline at end of file diff --git a/app/assets/javascripts/discourse/dialects/newline_dialect.js b/app/assets/javascripts/discourse/dialects/newline_dialect.js index 5859a3ed5df..3653bdcdb3e 100644 --- a/app/assets/javascripts/discourse/dialects/newline_dialect.js +++ b/app/assets/javascripts/discourse/dialects/newline_dialect.js @@ -10,33 +10,28 @@ Discourse.Dialect.on("parseNode", function(event) { insideCounts = event.insideCounts, linebreaks = opts.traditional_markdown_linebreaks || Discourse.SiteSettings.traditional_markdown_linebreaks; - if (!linebreaks) { - // We don't add line breaks inside a pre - if (insideCounts.pre > 0) { return; } + if (linebreaks || (insideCounts.pre > 0) || (node.length < 1)) { return; } - if (node.length > 1) { - for (var j=1; j 0) { - spliceInstructions.push(split[i]); - if (i !== split.length-1) { spliceInstructions.push(['br']); } - } - } - node.splice.apply(node, spliceInstructions); + if (typeof textContent === "string") { + if (textContent === "\n") { + node[j] = ['br']; + } else { + var split = textContent.split(/\n+/); + if (split.length) { + var spliceInstructions = [j, 1]; + for (var i=0; i 0) { + spliceInstructions.push(split[i]); + if (i !== split.length-1) { spliceInstructions.push(['br']); } } } + node.splice.apply(node, spliceInstructions); } } } } -}); \ No newline at end of file + +}); diff --git a/test/javascripts/components/bbcode_test.js b/test/javascripts/components/bbcode_test.js index 08a93df0246..71416e267d1 100644 --- a/test/javascripts/components/bbcode_test.js +++ b/test/javascripts/components/bbcode_test.js @@ -17,6 +17,9 @@ test('basic bbcode', function() { format("[img]http://eviltrout.com/eviltrout.png[/img]", "", "links images"); format("[url]http://bettercallsaul.com[/url]", "http://bettercallsaul.com", "supports [url] without a title"); format("[email]eviltrout@mailinator.com[/email]", "eviltrout@mailinator.com", "supports [email] without a title"); + format("[b]evil [i]trout[/i][/b]", + "evil trout", + "allows embedding of tags"); }); test('lists', function() { @@ -28,7 +31,7 @@ test('color', function() { format("[color=#00f]blue[/color]", "blue", "supports [color=] with a short hex value"); format("[color=#ffff00]yellow[/color]", "yellow", "supports [color=] with a long hex value"); format("[color=red]red[/color]", "red", "supports [color=] with an html color"); - format("[color=javascript:alert('wat')]noop[/color]", "noop", "it performs a noop on invalid input"); + format("[color=javascript:alert('wat')]noop[/color]", "noop", "it performs a noop on invalid input"); }); test('tags with arguments', function() {