Replace Markdown parser.

This commit is contained in:
Robin Ward 2013-08-08 18:14:12 -04:00
parent 0d2b4aeb61
commit 7f69a58439
24 changed files with 2204 additions and 3140 deletions

View File

@ -1,297 +0,0 @@
/*global HANDLEBARS_TEMPLATES:true md5:true*/
/**
Support for BBCode rendering
@class BBCode
@namespace Discourse
@module Discourse
**/
Discourse.BBCode = {
QUOTE_REGEXP: /\[quote=([^\]]*)\]((?:[\s\S](?!\[quote=[^\]]*\]))*?)\[\/quote\]/im,
IMG_REGEXP: /\[img\]([\s\S]*?)\[\/img\]/i,
URL_REGEXP: /\[url\]([\s\S]*?)\[\/url\]/i,
URL_WITH_TITLE_REGEXP: /\[url=(.+?)\]([\s\S]*?)\[\/url\]/i,
// Define our replacers
replacers: {
base: {
withoutArgs: {
"ol": function(_, content) { return "<ol>" + content + "</ol>"; },
"li": function(_, content) { return "<li>" + content + "</li>"; },
"ul": function(_, content) { return "<ul>" + content + "</ul>"; },
"code": function(_, content) { return "<pre>" + content + "</pre>"; },
"url": function(_, url) { return "<a href=\"" + url + "\">" + url + "</a>"; },
"email": function(_, address) { return "<a href=\"mailto:" + address + "\">" + address + "</a>"; },
"img": function(_, src) { return "<img src=\"" + src + "\">"; }
},
withArgs: {
"url": function(_, href, title) { return "<a href=\"" + href + "\">" + title + "</a>"; },
"email": function(_, address, title) { return "<a href=\"mailto:" + address + "\">" + title + "</a>"; },
"color": function(_, color, content) {
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(color)) {
return content;
}
return "<span style=\"color: " + color + "\">" + content + "</span>";
}
}
},
// For HTML emails
email: {
withoutArgs: {
"b": function(_, content) { return "<b>" + content + "</b>"; },
"i": function(_, content) { return "<i>" + content + "</i>"; },
"u": function(_, content) { return "<u>" + content + "</u>"; },
"s": function(_, content) { return "<s>" + content + "</s>"; },
"spoiler": function(_, content) { return "<span style='background-color: #000'>" + content + "</span>"; }
},
withArgs: {
"size": function(_, size, content) { return "<span style=\"font-size: " + size + "px\">" + content + "</span>"; }
}
},
// For sane environments that support CSS
"default": {
withoutArgs: {
"b": function(_, content) { return "<span class='bbcode-b'>" + content + "</span>"; },
"i": function(_, content) { return "<span class='bbcode-i'>" + content + "</span>"; },
"u": function(_, content) { return "<span class='bbcode-u'>" + content + "</span>"; },
"s": function(_, content) { return "<span class='bbcode-s'>" + content + "</span>"; },
"spoiler": function(_, content) { return "<span class=\"spoiler\">" + content + "</span>";
}
},
withArgs: {
"size": function(_, size, content) { return "<span class=\"bbcode-size-" + size + "\">" + content + "</span>"; }
}
}
},
/**
Apply a particular set of replacers
@method apply
@param {String} text The text we want to format
@param {String} environment The environment in which this
**/
apply: function(text, environment) {
var replacer = Discourse.BBCode.parsedReplacers()[environment];
// apply all available replacers
replacer.forEach(function(r) {
text = text.replace(r.regexp, r.fn);
});
return text;
},
/**
Lazy parse replacers
@property parsedReplacers
**/
parsedReplacers: function() {
if (this.parsed) return this.parsed;
var result = {};
_.each(Discourse.BBCode.replacers, function(rules, name) {
var parsed = result[name] = [];
_.each(_.extend(Discourse.BBCode.replacers.base.withoutArgs, rules.withoutArgs), function(val, tag) {
parsed.push({ regexp: new RegExp("\\[" + tag + "\\]([\\s\\S]*?)\\[\\/" + tag + "\\]", "igm"), fn: val });
});
_.each(_.extend(Discourse.BBCode.replacers.base.withArgs, rules.withArgs), function(val, tag) {
parsed.push({ regexp: new RegExp("\\[" + tag + "=?(.+?)\\]([\\s\\S]*?)\\[\\/" + tag + "\\]", "igm"), fn: val });
});
});
this.parsed = result;
return this.parsed;
},
/**
Build the BBCode quote around the selected text
@method buildQuoteBBCode
@param {Discourse.Post} post The post we are quoting
@param {String} contents The text selected
**/
buildQuoteBBCode: function(post, contents) {
var contents_hashed, result, sansQuotes, stripped, stripped_hashed, tmp;
if (!contents) contents = "";
sansQuotes = contents.replace(this.QUOTE_REGEXP, '').trim();
if (sansQuotes.length === 0) return "";
result = "[quote=\"" + (post.get('username')) + ", post:" + (post.get('post_number')) + ", topic:" + (post.get('topic_id'));
/* Strip the HTML from cooked */
tmp = document.createElement('div');
tmp.innerHTML = post.get('cooked');
stripped = tmp.textContent || tmp.innerText;
/*
Let's remove any non alphanumeric characters as a kind of hash. Yes it's
not accurate but it should work almost every time we need it to. It would be unlikely
that the user would quote another post that matches in exactly this way.
*/
stripped_hashed = stripped.replace(/[^a-zA-Z0-9]/g, '');
contents_hashed = contents.replace(/[^a-zA-Z0-9]/g, '');
/* If the quote is the full message, attribute it as such */
if (stripped_hashed === contents_hashed) result += ", full:true";
result += "\"]\n" + sansQuotes + "\n[/quote]\n\n";
return result;
},
/**
We want to remove urls in BBCode tags from a string before applying markdown
to prevent them from being modified by markdown.
This will return an object that contains:
- a new version of the text with the urls replaced with unique ids
- a `template()` function for reapplying them later.
@method extractUrls
@param {String} text The text inside which we want to replace urls
@returns {Object} object containing the new string and template function
**/
extractUrls: function(text) {
var result = { text: "" + text, replacements: [] };
var replacements = [];
var matches, key;
_.each([Discourse.BBCode.IMG_REGEXP, Discourse.BBCode.URL_REGEXP, Discourse.BBCode.URL_WITH_TITLE_REGEXP], function(r) {
while (matches = r.exec(result.text)) {
key = md5(matches[0]);
replacements.push({ key: key, value: matches[0] });
result.text = result.text.replace(matches[0], key);
}
});
result.template = function(input) {
_.each(replacements, function(r) {
input = input.replace(r.key, r.value);
});
return input;
};
return (result);
},
/**
We want to remove quotes from a string before applying markdown to avoid
weird stuff with newlines and such. This will return an object that
contains a new version of the text with the quotes replaced with
unique ids and `template()` function for reapplying them later.
@method extractQuotes
@param {String} text The text inside which we want to replace quotes
@returns {Object} object containing the new string and template function
**/
extractQuotes: function(text) {
var result = { text: "" + text, replacements: [] };
var replacements = [];
var matches, key;
while (matches = Discourse.BBCode.QUOTE_REGEXP.exec(result.text)) {
key = md5(matches[0]);
replacements.push({
key: key,
value: matches[0],
content: matches[2].trim()
});
result.text = result.text.replace(matches[0], key + "\n");
}
result.template = function(input) {
_.each(replacements,function(r) {
var val = r.value.trim();
val = val.replace(r.content, r.content.replace(/\n/g, '<br>'));
input = input.replace(r.key, val);
});
return input;
};
return (result);
},
/**
Replace quotes with appropriate markup
@method formatQuote
@param {String} text The text inside which we want to replace quotes
@param {Object} opts Rendering options
**/
formatQuote: function(text, opts) {
var args, matches, params, paramsSplit, paramsString, templateName, username;
var splitter = function(p,i) {
if (i > 0) {
var assignment = p.split(':');
if (assignment[0] && assignment[1]) {
return params.push({
key: assignment[0],
value: assignment[1].trim()
});
}
}
};
while (matches = this.QUOTE_REGEXP.exec(text)) {
paramsString = matches[1].replace(/\"/g, '');
paramsSplit = paramsString.split(/\, */);
params = [];
_.each(paramsSplit, splitter);
username = paramsSplit[0];
// remove leading <br>s
var content = matches[2].trim();
var avatarImg;
if (opts.lookupAvatarByPostNumber) {
// client-side, we can retrieve the avatar from the post
var postNumber = parseInt(_.find(params, { 'key' : 'post' }).value, 10);
avatarImg = opts.lookupAvatarByPostNumber(postNumber);
} else if (opts.lookupAvatar) {
// server-side, we need to lookup the avatar from the username
avatarImg = opts.lookupAvatar(username);
}
// Arguments for formatting
args = {
username: I18n.t('user.said', {username: username}),
params: params,
quote: content,
avatarImg: avatarImg
};
// Name of the template
templateName = 'quote';
if (opts && opts.environment) templateName = "quote_" + opts.environment;
// Apply the template
text = text.replace(matches[0], "</p>" + HANDLEBARS_TEMPLATES[templateName](args) + "<p>");
}
return text;
},
/**
Format a text string using BBCode
@method format
@param {String} text The text we want to format
@param {Object} opts Rendering options
**/
format: function(text, opts) {
var environment = opts && opts.environment ? opts.environment : 'default';
// Apply replacers for basic tags
text = Discourse.BBCode.apply(text, environment);
// Format
text = Discourse.BBCode.formatQuote(text, opts);
return text;
}
};

View File

@ -1,4 +1,4 @@
/*global Markdown:true */ /*global Markdown:true BetterMarkdown:true */
/** /**
Contains methods to help us with markdown formatting. Contains methods to help us with markdown formatting.
@ -94,116 +94,30 @@ Discourse.Markdown = {
markdownConverter: function(opts) { markdownConverter: function(opts) {
if (!opts) opts = {}; if (!opts) opts = {};
var converter = new Markdown.Converter(); return {
var mentionLookup = opts.mentionLookup || Discourse.Mention.lookupCache; makeHtml: function(text) {
var quoteTemplate = null, urlsTemplate = null; // Linebreaks
var linebreaks = opts.traditional_markdown_linebreaks || Discourse.SiteSettings.traditional_markdown_linebreaks;
// Before cooking callbacks if (!linebreaks) {
converter.hooks.chain("preConversion", function(text) { text = text.replace(/(^[\w<][^\n]*\n+)/gim, function(t) {
// If a user puts text right up against a quote, make sure the spacing is equivalnt to a new line if (t.match(/\n{2}/gim)) return t;
return text.replace(/\[\/quote\]/, "[/quote]\n"); return t.replace("\n", " \n");
}); });
converter.hooks.chain("preConversion", function(text) {
Discourse.Markdown.textResult = null;
Discourse.Markdown.trigger('beforeCook', { detail: text, opts: opts });
return Discourse.Markdown.textResult || text;
});
// Extract quotes so their contents are not passed through markdown.
converter.hooks.chain("preConversion", function(text) {
var extracted = Discourse.BBCode.extractQuotes(text);
quoteTemplate = extracted.template;
return extracted.text;
});
// Extract urls in BBCode tags so they are not passed through markdown.
converter.hooks.chain("preConversion", function(text) {
var extracted = Discourse.BBCode.extractUrls(text);
urlsTemplate = extracted.template;
return extracted.text;
});
// Support autolinking of www.something.com
converter.hooks.chain("preConversion", function(text) {
return text.replace(/(^|[\s\n])(www\.[a-z\.\-\_\(\)\/\?\=\%0-9]+)/gim, function(full, _, rest) {
return " <a href=\"http://" + rest + "\">" + rest + "</a>";
});
});
// newline prediction in trivial cases
var linebreaks = opts.traditional_markdown_linebreaks || Discourse.SiteSettings.traditional_markdown_linebreaks;
if (!linebreaks) {
converter.hooks.chain("preConversion", function(text) {
return text.replace(/(^[\w<][^\n]*\n+)/gim, function(t) {
if (t.match(/\n{2}/gim)) return t;
return t.replace("\n", " \n");
});
});
}
// github style fenced code
converter.hooks.chain("preConversion", function(text) {
return text.replace(/^`{3}(?:(.*$)\n)?([\s\S]*?)^`{3}/gm, function(wholeMatch, m1, m2) {
var escaped = Handlebars.Utils.escapeExpression(m2);
return "<pre><code class='" + (m1 || 'lang-auto') + "'>" + escaped + "</code></pre>";
});
});
converter.hooks.chain("postConversion", function(text) {
if (!text) return "";
// don't do @username mentions inside <pre> or <code> blocks
text = text.replace(/<(pre|code)>([\s\S](?!<(pre|code)>))*?@([\s\S](?!<(pre|code)>))*?<\/(pre|code)>/gi, function(m) {
return m.replace(/@/g, '&#64;');
});
// add @username mentions, if valid; must be bounded on left and right by non-word characters
text = text.replace(/(\W)(@[A-Za-z0-9][A-Za-z0-9_]{2,14})(?=\W)/g, function(x, pre, name) {
if (mentionLookup(name.substr(1))) {
return pre + "<a href='" + Discourse.getURL("/users/") + (name.substr(1).toLowerCase()) + "' class='mention'>" + name + "</a>";
} else {
return pre + "<span class='mention'>" + name + "</span>";
} }
});
// a primitive attempt at oneboxing, this regex gives me much eye sores text = Discourse.Dialect.cook(text, opts);
text = text.replace(/(<li>)?((<p>|<br>)[\s\n\r]*)(<a href=["]([^"]+)[^>]*)>([^<]+<\/a>[\s\n\r]*(?=<\/p>|<br>))/gi, function() {
// We don't onebox items in a list
if (arguments[1]) return arguments[0];
var url = arguments[5];
var onebox;
if (Discourse && Discourse.Onebox) { if (!text) return "";
onebox = Discourse.Onebox.lookupCache(url);
if (opts.sanitize) {
if (!window.sanitizeHtml) return "";
text = window.sanitizeHtml(text);
} }
if (onebox && onebox.trim().length > 0) {
return arguments[2] + onebox;
} else {
return arguments[2] + arguments[4] + " class=\"onebox\" target=\"_blank\">" + arguments[6];
}
});
return(text); return text;
}); }
};
converter.hooks.chain("postConversion", function(text) {
// reapply quotes
if (quoteTemplate) { text = quoteTemplate(text); }
// reapply urls
if (urlsTemplate) { text = urlsTemplate(text); }
// format with BBCode
return Discourse.BBCode.format(text, opts);
});
if (opts.sanitize) {
converter.hooks.chain("postConversion", function(text) {
if (!window.sanitizeHtml) return "";
return window.sanitizeHtml(text);
});
}
return converter;
} }
}; };

View File

@ -0,0 +1,48 @@
/**
Build the BBCode for a Quote
@class BBCode
@namespace Discourse
@module Discourse
**/
Discourse.Quote = {
REGEXP: /\[quote=([^\]]*)\]((?:[\s\S](?!\[quote=[^\]]*\]))*?)\[\/quote\]/im,
/**
Build the BBCode quote around the selected text
@method buildQuote
@param {Discourse.Post} post The post we are quoting
@param {String} contents The text selected
**/
build: function(post, contents) {
var contents_hashed, result, sansQuotes, stripped, stripped_hashed, tmp;
if (!contents) contents = "";
sansQuotes = contents.replace(this.REGEXP, '').trim();
if (sansQuotes.length === 0) return "";
result = "[quote=\"" + post.get('username') + ", post:" + post.get('post_number') + ", topic:" + post.get('topic_id');
/* Strip the HTML from cooked */
tmp = document.createElement('div');
tmp.innerHTML = post.get('cooked');
stripped = tmp.textContent || tmp.innerText;
/*
Let's remove any non alphanumeric characters as a kind of hash. Yes it's
not accurate but it should work almost every time we need it to. It would be unlikely
that the user would quote another post that matches in exactly this way.
*/
stripped_hashed = stripped.replace(/[^a-zA-Z0-9]/g, '');
contents_hashed = contents.replace(/[^a-zA-Z0-9]/g, '');
/* If the quote is the full message, attribute it as such */
if (stripped_hashed === contents_hashed) result += ", full:true";
result += "\"]\n" + sansQuotes + "\n[/quote]\n\n";
return result;
}
};

View File

@ -117,7 +117,7 @@ Discourse.QuoteButtonController = Discourse.Controller.extend({
} }
var buffer = this.get('buffer'); var buffer = this.get('buffer');
var quotedText = Discourse.BBCode.buildQuoteBBCode(post, buffer); var quotedText = Discourse.Quote.build(post, buffer);
if (composerController.get('content.replyDirty')) { if (composerController.get('content.replyDirty')) {
composerController.appendText(quotedText); composerController.appendText(quotedText);
} else { } else {

View File

@ -323,7 +323,7 @@ Discourse.TopicController = Discourse.ObjectController.extend(Discourse.Selected
replyToPost: function(post) { replyToPost: function(post) {
var composerController = this.get('controllers.composer'); var composerController = this.get('controllers.composer');
var quoteController = this.get('controllers.quoteButton'); var quoteController = this.get('controllers.quoteButton');
var quotedText = Discourse.BBCode.buildQuoteBBCode(quoteController.get('post'), quoteController.get('buffer')); var quotedText = Discourse.Quote.build(quoteController.get('post'), quoteController.get('buffer'));
var topic = post ? post.get('topic') : this.get('model'); var topic = post ? post.get('topic') : this.get('model');

View File

@ -0,0 +1,63 @@
/**
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 dialect = event.dialect,
MD = event.MD;
/**
Parses out links from HTML.
@method autoLink
@param {Markdown.Block} block the block to examine
@param {Array} next the next blocks in the sequence
@return {Array} the JsonML containing the markup or undefined if nothing changed.
@namespace Discourse.Dialect
**/
dialect.block['autolink'] = function autoLink(block, next) {
var pattern = /(^|\s)((?:https?:(?:\/{1,3}|[a-z0-9%])|www\d{0,3}[.])(?:[^\s()<>]+|\([^\s()<>]+\))+(?:\([^\s()<>]+\)|[^`!()\[\]{};:'".,<>?«»“”‘’\s]))/gm,
result,
remaining = block,
m;
var pushIt = function(p) { result.push(p) };
while (m = pattern.exec(remaining)) {
result = result || ['p'];
var url = m[2],
urlIndex = remaining.indexOf(url),
before = remaining.slice(0, urlIndex);
if (before.match(/\[\d+\]/)) { return; }
pattern.lastIndex = 0;
remaining = remaining.slice(urlIndex + url.length);
if (before) {
this.processInline(before).forEach(pushIt);
}
var displayUrl = url;
if (url.match(/^www/)) { url = "http://" + url; }
result.push(['a', {href: url}, displayUrl]);
if (remaining && remaining.match(/\n/)) {
next.unshift(MD.mk_block(remaining));
}
}
if (result) {
if (remaining.length) {
this.processInline(remaining).forEach(pushIt);
}
return [result];
}
};
});

View File

@ -0,0 +1,171 @@
/**
Regsiter all functionality for supporting BBCode in Discourse.
@event register
@namespace Discourse.Dialect
**/
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
@method bbcodeCode
@param {Markdown.Block} block the block to examine
@param {Array} next the next blocks in the sequence
@return {Array} the JsonML containing the markup or undefined if nothing changed.
@namespace Discourse.Dialect
**/
dialect.inline["[code]"] = function bbcodeCode(text, orig_match) {
var bbcodePattern = new RegExp("\\[code\\]([\\s\\S]*?)\\[\\/code\\]", "igm"),
m = bbcodePattern.exec(text);
if (m) {
var contents = m[1].trim().split("\n");
var html = ['pre', "\n"];
contents.forEach(function (n) {
html.push(n.trim());
html.push(["br"]);
html.push("\n");
});
return [m[0].length, html];
}
};
/**
Support BBCode [quote] blocks
@method bbcodeQuote
@param {Markdown.Block} block the block to examine
@param {Array} next the next blocks in the sequence
@return {Array} the JsonML containing the markup or undefined if nothing changed.
@namespace Discourse.Dialect
**/
dialect.inline["[quote="] = function bbcodeQuote(text, orig_match) {
var bbcodePattern = new RegExp("\\[quote=?([^\\[\\]]+)?\\]([\\s\\S]*?)\\[\\/quote\\]", "igm"),
m = bbcodePattern.exec(text);
if (!m) { return; }
var paramsString = m[1].replace(/\"/g, ''),
params = {'class': 'quote'},
paramsSplit = paramsString.split(/\, */),
username = paramsSplit[0],
opts = dialect.options;
paramsSplit.forEach(function(p,i) {
if (i > 0) {
var assignment = p.split(':');
if (assignment[0] && assignment[1]) {
params['data-' + assignment[0]] = assignment[1].trim();
}
}
});
var avatarImg;
if (opts.lookupAvatarByPostNumber) {
// client-side, we can retrieve the avatar from the post
var postNumber = parseInt(params['data-post'], 10);
avatarImg = opts.lookupAvatarByPostNumber(postNumber);
} else if (opts.lookupAvatar) {
// server-side, we need to lookup the avatar from the username
avatarImg = opts.lookupAvatar(username);
}
var quote = ['aside', params,
['div', {'class': 'title'},
['div', {'class': 'quote-controls'}],
avatarImg ? avatarImg + "\n" : "",
I18n.t('user.said',{username: username})
],
['blockquote'].concat(this.processInline(m[2]))
];
return [m[0].length, quote];
};
});
Discourse.Dialect.on("parseNode", function(event) {
var node = event.node,
path = event.path;
// Make sure any quotes are followed by a <br>. The formatting looks weird otherwise.
if (node[0] === 'aside' && node[1] && node[1]['class'] === 'quote') {
var parent = path[path.length - 1],
location = parent.indexOf(node)+1,
trailing = parent.slice(location);
if (trailing.length) {
parent.splice(location, 0, ['br']);
}
}
});

View File

@ -0,0 +1,110 @@
/**
Discourse uses the Markdown.js as its main parser. `Discourse.Dialect` is the framework
for extending it with additional formatting.
To extend the dialect, you can register a handler, and you will receive an `event` object
with a handle to the markdown `Dialect` from Markdown.js that we are defining. Here's
a sample dialect that replaces all occurances of "evil trout" with a link that says
"EVIL TROUT IS AWESOME":
```javascript
Discourse.Dialect.on("register", function(event) {
var dialect = event.dialect;
// To see how this works, review one of our samples or the Markdown.js code:
dialect.inline["evil trout"] = function(text) {
return ["evil trout".length, ['a', {href: "http://eviltrout.com"}, "EVIL TROUT IS AWESOME"] ];
};
});
```
You can also manipulate the JsonML tree that is produced by the parser before it converted to HTML.
This is useful if the markup you want needs a certain structure of HTML elements. Rather than
writing regular expressions to match HTML, consider parsing the tree instead! We use this for
making sure a onebox is on one line, as an example.
This example changes the content of any `<code>` tags.
The `event.path` attribute contains the current path to the node.
```javascript
Discourse.Dialect.on("parseNode", function(event) {
var node = event.node,
path = event.path;
if (node[0] === 'code') {
node[node.length-1] = "EVIL TROUT HACKED YOUR CODE";
}
});
```
**/
var parser = window.BetterMarkdown,
MD = parser.Markdown,
// Our dialect
dialect = MD.dialects.Discourse = MD.subclassDialect( MD.dialects.Gruber ),
initialized = false,
/**
Initialize our dialects for processing.
@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.
@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.
@returns {Array} the parsed tree
**/
parseTree = function parseTree(tree, path) {
if (tree instanceof Array) {
Discourse.Dialect.trigger('parseNode', {node: tree, path: path});
path = path || [];
path.push(tree);
tree.slice(1).forEach(function (n) {
parseTree(n, path);
});
path.pop();
}
return tree;
};
/**
An object used for rendering our dialects.
@class Dialect
@namespace Discourse
@module Discourse
**/
Discourse.Dialect = {
/**
Cook text using the dialects.
@method cook
@param {String} text the raw text to cook
@returns {String} the cooked text
**/
cook: function(text, opts) {
if (!initialized) { initializeDialects(); }
dialect.options = opts;
return parser.renderJsonML(parseTree(parser.toHTMLTree(text, 'Discourse')));
}
};
RSVP.EventTarget.mixin(Discourse.Dialect);

View File

@ -0,0 +1,87 @@
/**
Support for github style code blocks, here you begin with three backticks and supply a language,
The language is made into a class on the resulting `<code>` element.
@event register
@namespace Discourse.Dialect
**/
Discourse.Dialect.on("register", function(event) {
var dialect = event.dialect,
MD = event.MD;
/**
Support for github style code blocks
@method githubCode
@param {Markdown.Block} block the block to examine
@param {Array} next the next blocks in the sequence
@return {Array} the JsonML containing the markup or undefined if nothing changed.
@namespace Discourse.Dialect
**/
dialect.block['github_code'] = function githubCode(block, next) {
var m = /^`{3}([^\n]+)?\n?([\s\S]*)?/gm.exec(block);
if (m) {
var startPos = block.indexOf(m[0]),
leading,
codeContents = [],
result = [],
lineNumber = block.lineNumber;
if (startPos > 0) {
leading = block.slice(0, startPos);
lineNumber += (leading.split("\n").length - 1);
var para = ['p'];
this.processInline(leading).forEach(function (l) {
para.push(l);
});
result.push(para);
}
if (m[2]) { next.unshift(MD.mk_block(m[2], null, lineNumber + 1)); }
while (next.length > 0) {
var b = next.shift(),
n = b.match(/([^`]*)```([^`]*)/m),
diff = ((typeof b.lineNumber === "undefined") ? lineNumber : b.lineNumber) - lineNumber;
lineNumber = b.lineNumber;
for (var i=1; i<diff; i++) {
codeContents.push("");
}
if (n) {
if (n[2]) {
next.unshift(MD.mk_block(n[2]));
}
codeContents.push(n[1].trim());
break;
} else {
codeContents.push(b);
}
}
result.push(['p', ['pre', ['code', {'class': m[1] || 'lang-auto'}, codeContents.join("\n") ]]]);
return result;
}
};
});
/**
Ensure that content in a code block is fully escaped. This way it's not white listed
and we can use HTML and Javascript examples.
@event parseNode
@namespace Discourse.Dialect
**/
Discourse.Dialect.on("parseNode", function(event) {
var node = event.node,
path = event.path;
if (node[0] === 'code') {
node[node.length-1] = Handlebars.Utils.escapeExpression(node[node.length-1]);
}
});

View File

@ -0,0 +1,68 @@
/**
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) {
var dialect = event.dialect,
MD = event.MD;
/**
Support for github style code blocks
@method mentionSupport
@param {Markdown.Block} block the block to examine
@param {Array} next the next blocks in the sequence
@return {Array} the JsonML containing the markup or undefined if nothing changed.
@namespace Discourse.Dialect
**/
dialect.block['mentions'] = function mentionSupport(block, next) {
var pattern = /(\W|^)(@[A-Za-z0-9][A-Za-z0-9_]{2,14})(?=(\W|$))/gm,
result,
remaining = block,
m,
mentionLookup = dialect.options.mentionLookup || Discourse.Mention.lookupCache;
if (block.match(/^ {3}/)) { return; }
var pushIt = function(p) { result.push(p) };
while (m = pattern.exec(remaining)) {
result = result || ['p'];
var username = m[2],
usernameIndex = remaining.indexOf(username),
before = remaining.slice(0, usernameIndex);
pattern.lastIndex = 0;
remaining = remaining.slice(usernameIndex + username.length);
if (before) {
this.processInline(before).forEach(pushIt);
}
if (mentionLookup(username.substr(1))) {
result.push(['a', {'class': 'mention', href: Discourse.getURL("/users/") + username.substr(1).toLowerCase()}, username]);
} else {
result.push(['span', {'class': 'mention'}, username]);
}
if (remaining && remaining.match(/\n/)) {
next.unshift(MD.mk_block(remaining));
}
}
if (result) {
if (remaining.length) {
this.processInline(remaining).forEach(pushIt);
}
return [result];
}
};
});

View File

@ -0,0 +1,70 @@
/**
Given a node in the document and its parent, determine whether it is on its
own line or not.
@method isOnOneLine
@namespace Discourse.Dialect
**/
var isOnOneLine = function(link, parent) {
if (!parent) { return false; }
var siblings = parent.slice(1);
if ((!siblings) || (siblings.length < 1)) { return false; }
var idx = siblings.indexOf(link);
if (idx === -1) { return false; }
if (idx > 0) {
var prev = siblings[idx-1];
if (prev[0] !== 'br') { return false; }
}
if (idx < siblings.length) {
var next = siblings[idx+1];
if (next && (!((next[0] === 'br') || (typeof next === 'string' && next.trim() === "")))) { return false; }
}
return true;
};
/**
We only onebox stuff that is on its own line. This navigates the JsonML tree and
correctly inserts the oneboxes.
@event parseNode
@namespace Discourse.Dialect
**/
Discourse.Dialect.on("parseNode", function(event) {
var node = event.node,
path = event.path;
// We only care about links
if (node[0] !== 'a') { return; }
var parent = path[path.length - 1];
// We don't onebox bbcode
if (node[1]['data-bbcode']) {
delete node[1]['data-bbcode'];
return;
}
// Don't onebox links within a list
for (var i=0; i<path.length; i++) {
if (path[i][0] === 'li') { return; }
}
if (isOnOneLine(node, parent)) {
node[1]['class'] = 'onebox';
node[1].target = '_blank';
if (Discourse && Discourse.Onebox) {
var contents = Discourse.Onebox.lookupCache(node[1].href);
if (contents) {
node[0] = 'raw';
node[1] = contents;
node.length = 2;
}
}
}
});

View File

@ -221,7 +221,7 @@ Discourse.Composer = Discourse.Model.extend({
**/ **/
replyLength: function() { replyLength: function() {
var reply = this.get('reply') || ""; var reply = this.get('reply') || "";
while (Discourse.BBCode.QUOTE_REGEXP.test(reply)) { reply = reply.replace(Discourse.BBCode.QUOTE_REGEXP, ""); } while (Discourse.Quote.REGEXP.test(reply)) { reply = reply.replace(Discourse.Quote.REGEXP, ""); }
return reply.replace(/\s+/img, " ").trim().length; return reply.replace(/\s+/img, " ").trim().length;
}.property('reply'), }.property('reply'),
@ -279,7 +279,7 @@ Discourse.Composer = Discourse.Model.extend({
this.set('loading', true); this.set('loading', true);
var composer = this; var composer = this;
return Discourse.Post.load(postId).then(function(post) { return Discourse.Post.load(postId).then(function(post) {
composer.appendText(Discourse.BBCode.buildQuoteBBCode(post, post.get('raw'))); composer.appendText(Discourse.Quote.build(post, post.get('raw')));
composer.set('loading', false); composer.set('loading', false);
}); });
} }

View File

@ -394,7 +394,7 @@ Discourse.Post.reopenClass({
loadQuote: function(postId) { loadQuote: function(postId) {
return Discourse.ajax("/posts/" + postId + ".json").then(function(result) { return Discourse.ajax("/posts/" + postId + ".json").then(function(result) {
var post = Discourse.Post.create(result); var post = Discourse.Post.create(result);
return Discourse.BBCode.buildQuoteBBCode(post, post.get('raw')); return Discourse.Quote.build(post, post.get('raw'));
}); });
}, },

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -28,6 +28,8 @@
//= require ./discourse/routes/discourse_route //= require ./discourse/routes/discourse_route
//= require ./discourse/routes/discourse_restricted_user_route //= require ./discourse/routes/discourse_restricted_user_route
//= require ./discourse/dialects/dialect
//= require_tree ./discourse/dialects
//= require_tree ./discourse/controllers //= require_tree ./discourse/controllers
//= require_tree ./discourse/components //= require_tree ./discourse/components
//= require_tree ./discourse/models //= require_tree ./discourse/models

View File

@ -88,6 +88,7 @@ class PostAnalyzer
# Returns an array of all links in a post excluding mentions # Returns an array of all links in a post excluding mentions
def raw_links def raw_links
return [] unless @raw.present? return [] unless @raw.present?
return @raw_links if @raw_links.present? return @raw_links if @raw_links.present?

View File

@ -96,7 +96,6 @@ module PrettyText
"app/assets/javascripts/external/md5.js", "app/assets/javascripts/external/md5.js",
"app/assets/javascripts/external/lodash.js", "app/assets/javascripts/external/lodash.js",
"app/assets/javascripts/external/Markdown.Converter.js", "app/assets/javascripts/external/Markdown.Converter.js",
"app/assets/javascripts/external/twitter-text-1.5.0.js",
"lib/headless-ember.js", "lib/headless-ember.js",
"app/assets/javascripts/external/rsvp.js", "app/assets/javascripts/external/rsvp.js",
Rails.configuration.ember.handlebars_location) Rails.configuration.ember.handlebars_location)
@ -106,10 +105,17 @@ module PrettyText
ctx.eval("var I18n = {}; I18n.t = function(a,b){ return helpers.t(a,b); }"); ctx.eval("var I18n = {}; I18n.t = function(a,b){ return helpers.t(a,b); }");
ctx_load(ctx, ctx_load(ctx,
"app/assets/javascripts/discourse/components/bbcode.js", "app/assets/javascripts/external/markdown.js",
"app/assets/javascripts/discourse/dialects/dialect.js",
"app/assets/javascripts/discourse/components/utilities.js", "app/assets/javascripts/discourse/components/utilities.js",
"app/assets/javascripts/discourse/components/markdown.js") "app/assets/javascripts/discourse/components/markdown.js")
Dir["#{Rails.root}/app/assets/javascripts/discourse/dialects/**.js"].each do |dialect|
unless dialect =~ /\/dialect\.js$/
ctx.load(dialect)
end
end
# Load server side javascripts # Load server side javascripts
if DiscoursePluginRegistry.server_side_javascripts.present? if DiscoursePluginRegistry.server_side_javascripts.present?
DiscoursePluginRegistry.server_side_javascripts.each do |ssjs| DiscoursePluginRegistry.server_side_javascripts.each do |ssjs|

View File

@ -4,15 +4,6 @@ require 'pretty_text'
describe PrettyText do describe PrettyText do
describe "Cooking" do describe "Cooking" do
it "should support github style code blocks" do
PrettyText.cook("```
test
```").should match_html "<pre><code class=\"lang-auto\">test \n</code></pre>"
end
it "should support quoting [] " do
PrettyText.cook("[quote=\"EvilTrout, post:123, topic:456, full:true\"][sam][/quote]").should =~ /\[sam\]/
end
describe "with avatar" do describe "with avatar" do
@ -23,15 +14,15 @@ test
end end
it "produces a quote even with new lines in it" do it "produces a quote even with new lines in it" do
PrettyText.cook("[quote=\"EvilTrout, post:123, topic:456, full:true\"]ddd\n[/quote]").should match_html "<p></p><aside class=\"quote\" data-post=\"123\" data-topic=\"456\" data-full=\"true\"><div class=\"title\">\n <div class=\"quote-controls\"></div>\n <img width=\"20\" height=\"20\" src=\"http://test.localhost/uploads/default/avatars/42d/57c/46ce7ee487/40.png\" class=\"avatar\">\n EvilTrout said:\n </div>\n <blockquote>ddd</blockquote>\n</aside><p></p>" PrettyText.cook("[quote=\"EvilTrout, post:123, topic:456, full:true\"]ddd\n[/quote]").should match_html "<p><aside class=\"quote\" data-post=\"123\" data-topic=\"456\" data-full=\"true\"><div class=\"title\">\n<div class=\"quote-controls\"></div>\n<img width=\"20\" height=\"20\" src=\"http://test.localhost/uploads/default/avatars/42d/57c/46ce7ee487/40.png\" class=\"avatar\">\nEvilTrout said:</div>\n<blockquote>ddd\n</blockquote></aside></p>"
end end
it "should produce a quote" do it "should produce a quote" do
PrettyText.cook("[quote=\"EvilTrout, post:123, topic:456, full:true\"]ddd[/quote]").should match_html "<p></p><aside class=\"quote\" data-post=\"123\" data-topic=\"456\" data-full=\"true\"><div class=\"title\">\n <div class=\"quote-controls\"></div>\n <img width=\"20\" height=\"20\" src=\"http://test.localhost/uploads/default/avatars/42d/57c/46ce7ee487/40.png\" class=\"avatar\">\n EvilTrout said:\n </div>\n <blockquote>ddd</blockquote>\n</aside><p></p>" PrettyText.cook("[quote=\"EvilTrout, post:123, topic:456, full:true\"]ddd[/quote]").should match_html "<p><aside class=\"quote\" data-post=\"123\" data-topic=\"456\" data-full=\"true\"><div class=\"title\">\n<div class=\"quote-controls\"></div>\n<img width=\"20\" height=\"20\" src=\"http://test.localhost/uploads/default/avatars/42d/57c/46ce7ee487/40.png\" class=\"avatar\">\nEvilTrout said:</div>\n<blockquote>ddd</blockquote></aside></p>"
end end
it "trims spaces on quote params" do it "trims spaces on quote params" do
PrettyText.cook("[quote=\"EvilTrout, post:555, topic: 666\"]ddd[/quote]").should match_html "<p></p><aside class=\"quote\" data-post=\"555\" data-topic=\"666\"><div class=\"title\">\n <div class=\"quote-controls\"></div>\n <img width=\"20\" height=\"20\" src=\"http://test.localhost/uploads/default/avatars/42d/57c/46ce7ee487/40.png\" class=\"avatar\">\n EvilTrout said:\n </div>\n <blockquote>ddd</blockquote>\n</aside><p></p>" PrettyText.cook("[quote=\"EvilTrout, post:555, topic: 666\"]ddd[/quote]").should match_html "<p><aside class=\"quote\" data-post=\"555\" data-topic=\"666\"><div class=\"title\">\n<div class=\"quote-controls\"></div>\n<img width=\"20\" height=\"20\" src=\"http://test.localhost/uploads/default/avatars/42d/57c/46ce7ee487/40.png\" class=\"avatar\">\nEvilTrout said:</div>\n<blockquote>ddd</blockquote></aside></p>"
end end
end end
@ -40,36 +31,10 @@ test
PrettyText.cook('@hello @hello @hello').should match_html "<p><span class=\"mention\">@hello</span> <span class=\"mention\">@hello</span> <span class=\"mention\">@hello</span></p>" PrettyText.cook('@hello @hello @hello').should match_html "<p><span class=\"mention\">@hello</span> <span class=\"mention\">@hello</span> <span class=\"mention\">@hello</span></p>"
end end
it "should not do weird @ mention stuff inside a pre block" do
PrettyText.cook("```
a @test
```").should match_html "<pre><code class=\"lang-auto\">a @test \n</code></pre>"
end
it "should sanitize the html" do it "should sanitize the html" do
PrettyText.cook("<script>alert(42)</script>").should match_html "alert(42)" PrettyText.cook("<script>alert(42)</script>").should match_html "alert(42)"
end end
it "should escape html within the code block" do
PrettyText.cook("```text
<header>hello</header>
```").should match_html "<pre><code class=\"text\">&lt;header&gt;hello&lt;/header&gt; \n</code></pre>"
end
it "should support language choices" do
PrettyText.cook("```ruby
test
```").should match_html "<pre><code class=\"ruby\">test \n</code></pre>"
end
it 'should decorate @mentions' do
PrettyText.cook("Hello @eviltrout").should match_html "<p>Hello <span class=\"mention\">@eviltrout</span></p>"
end
it 'should allow for @mentions to have punctuation' do it 'should allow for @mentions to have punctuation' do
PrettyText.cook("hello @bob's @bob,@bob; @bob\"").should PrettyText.cook("hello @bob's @bob,@bob; @bob\"").should
match_html "<p>hello <span class=\"mention\">@bob</span>'s <span class=\"mention\">@bob</span>,<span class=\"mention\">@bob</span>; <span class=\"mention\">@bob</span>\"</p>" match_html "<p>hello <span class=\"mention\">@bob</span>'s <span class=\"mention\">@bob</span>,<span class=\"mention\">@bob</span>; <span class=\"mention\">@bob</span>\"</p>"
@ -78,11 +43,6 @@ test
it 'should add spoiler tags' do it 'should add spoiler tags' do
PrettyText.cook("[spoiler]hello[/spoiler]").should match_html "<p><span class=\"spoiler\">hello</span></p>" PrettyText.cook("[spoiler]hello[/spoiler]").should match_html "<p><span class=\"spoiler\">hello</span></p>"
end end
it "should only detect ``` at the begining of lines" do
PrettyText.cook(" ```\n hello\n ```")
.should match_html "<pre><code>```\nhello\n```\n</code></pre>"
end
end end
describe "rel nofollow" do describe "rel nofollow" do

View File

@ -57,6 +57,7 @@ describe TopicLink do
@topic.posts.create(user: @user, raw: 'initial post') @topic.posts.create(user: @user, raw: 'initial post')
@post = @topic.posts.create(user: @user, raw: "Link to another topic:\n\n#{@url}\n\n") @post = @topic.posts.create(user: @user, raw: "Link to another topic:\n\n#{@url}\n\n")
@post.reload @post.reload
TopicLink.extract_from(@post) TopicLink.extract_from(@post)
@link = @topic.topic_links.first @link = @topic.topic_links.first

View File

@ -7,13 +7,14 @@ var format = function(input, expected, text) {
}; };
test('basic bbcode', function() { test('basic bbcode', function() {
format("[b]strong[/b]", "<span class='bbcode-b'>strong</span>", "bolds text"); format("[b]strong[/b]", "<span class=\"bbcode-b\">strong</span>", "bolds text");
format("[i]emphasis[/i]", "<span class='bbcode-i'>emphasis</span>", "italics text"); format("[i]emphasis[/i]", "<span class=\"bbcode-i\">emphasis</span>", "italics text");
format("[u]underlined[/u]", "<span class='bbcode-u'>underlined</span>", "underlines text"); format("[u]underlined[/u]", "<span class=\"bbcode-u\">underlined</span>", "underlines text");
format("[s]strikethrough[/s]", "<span class='bbcode-s'>strikethrough</span>", "strikes-through text"); format("[s]strikethrough[/s]", "<span class=\"bbcode-s\">strikethrough</span>", "strikes-through text");
format("[code]\nx++\n[/code]", "<pre>\nx++ <br>\n</pre>", "makes code into pre"); format("[code]\nx++\n[/code]", "<pre>\nx++<br/>\n</pre>", "makes code into pre");
format("[code]\nx++\ny++\nz++\n[/code]", "<pre>\nx++<br/>\ny++<br/>\nz++<br/>\n</pre>", "makes code into pre");
format("[spoiler]it's a sled[/spoiler]", "<span class=\"spoiler\">it's a sled</span>", "supports spoiler tags"); format("[spoiler]it's a sled[/spoiler]", "<span class=\"spoiler\">it's a sled</span>", "supports spoiler tags");
format("[img]http://eviltrout.com/eviltrout.png[/img]", "<img src=\"http://eviltrout.com/eviltrout.png\">", "links images"); format("[img]http://eviltrout.com/eviltrout.png[/img]", "<img src=\"http://eviltrout.com/eviltrout.png\"/>", "links images");
format("[url]http://bettercallsaul.com[/url]", "<a href=\"http://bettercallsaul.com\">http://bettercallsaul.com</a>", "supports [url] without a title"); format("[url]http://bettercallsaul.com[/url]", "<a href=\"http://bettercallsaul.com\">http://bettercallsaul.com</a>", "supports [url] without a title");
format("[email]eviltrout@mailinator.com[/email]", "<a href=\"mailto:eviltrout@mailinator.com\">eviltrout@mailinator.com</a>", "supports [email] without a title"); format("[email]eviltrout@mailinator.com[/email]", "<a href=\"mailto:eviltrout@mailinator.com\">eviltrout@mailinator.com</a>", "supports [email] without a title");
}); });
@ -31,11 +32,11 @@ test('color', function() {
}); });
test('tags with arguments', function() { test('tags with arguments', function() {
format("[size=35]BIG[/size]", "<span class=\"bbcode-size-35\">BIG</span>", "supports [size=]"); format("[size=35]BIG [b]whoop[/b][/size]", "<span class=\"bbcode-size-35\">BIG <span class=\"bbcode-b\">whoop</span></span>", "supports [size=]");
format("[url=http://bettercallsaul.com]better call![/url]", "<a href=\"http://bettercallsaul.com\">better call!</a>", "supports [url] with a title"); format("[url=http://bettercallsaul.com]better call![/url]", "<a href=\"http://bettercallsaul.com\">better call!</a>", "supports [url] with a title");
format("[email=eviltrout@mailinator.com]evil trout[/email]", "<a href=\"mailto:eviltrout@mailinator.com\">evil trout</a>", "supports [email] with a title"); format("[email=eviltrout@mailinator.com]evil trout[/email]", "<a href=\"mailto:eviltrout@mailinator.com\">evil trout</a>", "supports [email] with a title");
format("[u][i]abc[/i][/u]", "<span class='bbcode-u'><span class='bbcode-i'>abc</span></span>", "can nest tags"); format("[u][i]abc[/i][/u]", "<span class=\"bbcode-u\"><span class=\"bbcode-i\">abc</span></span>", "can nest tags");
format("[b]first[/b] [b]second[/b]", "<span class='bbcode-b'>first</span> <span class='bbcode-b'>second</span>", "can bold two things on the same line"); format("[b]first[/b] [b]second[/b]", "<span class=\"bbcode-b\">first</span> <span class=\"bbcode-b\">second</span>", "can bold two things on the same line");
}); });
@ -49,7 +50,7 @@ test("quotes", function() {
}); });
var formatQuote = function(val, expected, text) { var formatQuote = function(val, expected, text) {
equal(Discourse.BBCode.buildQuoteBBCode(post, val), expected, text); equal(Discourse.Quote.build(post, val), expected, text);
}; };
formatQuote(undefined, "", "empty string for undefined content"); formatQuote(undefined, "", "empty string for undefined content");
@ -58,6 +59,7 @@ test("quotes", function() {
formatQuote("lorem", "[quote=\"eviltrout, post:1, topic:2\"]\nlorem\n[/quote]\n\n", "correctly formats quotes"); formatQuote("lorem", "[quote=\"eviltrout, post:1, topic:2\"]\nlorem\n[/quote]\n\n", "correctly formats quotes");
formatQuote(" lorem \t ", formatQuote(" lorem \t ",
"[quote=\"eviltrout, post:1, topic:2\"]\nlorem\n[/quote]\n\n", "[quote=\"eviltrout, post:1, topic:2\"]\nlorem\n[/quote]\n\n",
"trims white spaces before & after the quoted contents"); "trims white spaces before & after the quoted contents");
@ -74,34 +76,27 @@ test("quotes", function() {
test("quote formatting", function() { test("quote formatting", function() {
// TODO: This HTML matching is quite ugly. format("[quote=\"EvilTrout, post:123, topic:456, full:true\"][sam][/quote]",
"<aside class=\"quote\" data-post=\"123\" data-topic=\"456\" data-full=\"true\"><div class=\"title\">" +
"<div class=\"quote-controls\"></div>EvilTrout said:</div><blockquote>[sam]</blockquote></aside>",
"it allows quotes with [] inside");
format("[quote=\"eviltrout, post:1, topic:1\"]abc[/quote]", format("[quote=\"eviltrout, post:1, topic:1\"]abc[/quote]",
"</p><aside class='quote' data-post=\"1\" data-topic=\"1\" >\n <div class='title'>\n " + "<aside class=\"quote\" data-post=\"1\" data-topic=\"1\"><div class=\"title\"><div class=\"quote-controls\"></div>eviltrout said:" +
"<div class='quote-controls'></div>\n \n eviltrout said:\n </div>\n <blockquote>abc</blockquote>\n</aside>\n<p>", "</div><blockquote>abc</blockquote></aside>",
"renders quotes properly"); "renders quotes properly");
format("[quote=\"eviltrout, post:1, topic:1\"]abc[quote=\"eviltrout, post:2, topic:2\"]nested[/quote][/quote]", format("[quote=\"eviltrout, post:1, topic:1\"]abc[/quote]\nhello",
"</p><aside class='quote' data-post=\"1\" data-topic=\"1\" >\n <div class='title'>\n <div class='quote-controls'></div>" + "<aside class=\"quote\" data-post=\"1\" data-topic=\"1\"><div class=\"title\"><div class=\"quote-controls\"></div>eviltrout said:" +
"\n \n eviltrout said:\n </div>\n <blockquote>abc1fe072ca2fadbb4f3dfca9ee8bedef19</blockquote>\n</aside>\n<p> ", "</div><blockquote>abc</blockquote></aside><br/>\nhello",
"can nest quotes"); "handles new lines properly");
format("before[quote=\"eviltrout, post:1, topic:1\"]first[/quote]middle[quote=\"eviltrout, post:2, topic:2\"]second[/quote]after", format("before[quote=\"eviltrout, post:1, topic:1\"]first[/quote]middle[quote=\"eviltrout, post:2, topic:2\"]second[/quote]after",
"before</p><aside class='quote' data-post=\"1\" data-topic=\"1\" >\n <div class='title'>\n <div class='quote-controls'>" + "before<aside class=\"quote\" data-post=\"1\" data-topic=\"1\"><div class=\"title\"><div class=\"quote-controls\"></div>eviltrout said:</div><blockquote>" +
"</div>\n \n eviltrout said:\n </div>\n <blockquote>first</blockquote>\n</aside>\n<p></p>\n\n<p>middle</p><aside class='quote'" + "first</blockquote></aside><br/>middle<aside class=\"quote\" data-post=\"2\" data-topic=\"2\"><div class=\"title\"><div class=\"quote-controls\"></div>" +
" data-post=\"2\" data-topic=\"2\" >\n <div class='title'>\n <div class='quote-controls'></div>\n \n eviltrout said:\n " + "eviltrout said:</div><blockquote>second</blockquote></aside><br/>after",
"</div>\n <blockquote>second</blockquote>\n</aside>\n<p> <br>\nafter",
"can handle more than one quote"); "can handle more than one quote");
}); });
test("extract quotes", function() {
var q = "[quote=\"eviltrout, post:1, topic:2\"]hello[/quote]";
var result = Discourse.BBCode.extractQuotes(q + " world");
equal(result.text, md5(q) + "\n world");
present(result.template);
});

View File

@ -7,7 +7,8 @@ module("Discourse.Markdown", {
}); });
var cooked = function(input, expected, text) { var cooked = function(input, expected, text) {
equal(Discourse.Markdown.cook(input, {mentionLookup: false }), expected, text); var result = Discourse.Markdown.cook(input, {mentionLookup: false, sanitize: true});
equal(result, expected, text);
}; };
var cookedOptions = function(input, opts, expected, text) { var cookedOptions = function(input, opts, expected, text) {
@ -21,7 +22,7 @@ test("basic cooking", function() {
test("Line Breaks", function() { test("Line Breaks", function() {
var input = "1\n2\n3"; var input = "1\n2\n3";
cooked(input, "<p>1 <br>\n2 <br>\n3</p>", "automatically handles trivial newlines"); cooked(input, "<p>1<br>2<br>3</p>", "automatically handles trivial newlines");
var traditionalOutput = "<p>1\n2\n3</p>"; var traditionalOutput = "<p>1\n2\n3</p>";
@ -36,13 +37,18 @@ test("Line Breaks", function() {
}); });
test("Links", function() { test("Links", function() {
cooked("Youtube: http://www.youtube.com/watch?v=1MrpeBRkM5A", cooked("Youtube: http://www.youtube.com/watch?v=1MrpeBRkM5A",
'<p>Youtube: <a href="http://www.youtube.com/watch?v=1MrpeBRkM5A">http://www.youtube.com/watch?v=1MrpeBRkM5A</a></p>', '<p>Youtube: <a href="http://www.youtube.com/watch?v=1MrpeBRkM5A">http://www.youtube.com/watch?v=1MrpeBRkM5A</a></p>',
"allows links to contain query params"); "allows links to contain query params");
cooked("Derpy: http://derp.com?__test=1", cooked("Derpy: http://derp.com?__test=1",
'<p>Derpy: <a href="http://derp.com?%5F%5Ftest=1">http://derp.com?__test=1</a></p>', '<p>Derpy: <a href="http://derp.com?__test=1">http://derp.com?__test=1</a></p>',
"escapes double underscores in URLs"); "works with double underscores in urls");
cooked("Derpy: http://derp.com?_test_=1",
'<p>Derpy: <a href="http://derp.com?_test_=1">http://derp.com?_test_=1</a></p>',
"works with underscores in urls");
cooked("Atwood: www.codinghorror.com", cooked("Atwood: www.codinghorror.com",
'<p>Atwood: <a href="http://www.codinghorror.com">www.codinghorror.com</a></p>', '<p>Atwood: <a href="http://www.codinghorror.com">www.codinghorror.com</a></p>',
@ -63,34 +69,48 @@ test("Links", function() {
cooked("Batman: http://en.wikipedia.org/wiki/The_Dark_Knight_(film)", cooked("Batman: http://en.wikipedia.org/wiki/The_Dark_Knight_(film)",
'<p>Batman: <a href="http://en.wikipedia.org/wiki/The_Dark_Knight_(film)">http://en.wikipedia.org/wiki/The_Dark_Knight_(film)</a></p>', '<p>Batman: <a href="http://en.wikipedia.org/wiki/The_Dark_Knight_(film)">http://en.wikipedia.org/wiki/The_Dark_Knight_(film)</a></p>',
"autolinks a URL with parentheses (like Wikipedia)"); "autolinks a URL with parentheses (like Wikipedia)");
cooked("Here's a tweet:\nhttps://twitter.com/evil_trout/status/345954894420787200",
"<p>Here's a tweet:<br><a href=\"https://twitter.com/evil_trout/status/345954894420787200\" class=\"onebox\">https://twitter.com/evil_trout/status/345954894420787200</a></p>",
"It doesn't strip the new line.");
cooked("[3]: http://eviltrout.com", "", "It doesn't autolink markdown link references");
cooked("http://discourse.org and http://discourse.org/another_url and http://www.imdb.com/name/nm2225369",
"<p><a href=\"http://discourse.org\">http://discourse.org</a> and " +
"<a href=\"http://discourse.org/another_url\">http://discourse.org/another_url</a> and " +
"<a href=\"http://www.imdb.com/name/nm2225369\">http://www.imdb.com/name/nm2225369</a></p>",
'allows multiple links on one line');
}); });
test("Quotes", function() { test("Quotes", function() {
cookedOptions("1[quote=\"bob, post:1\"]my quote[/quote]2", cookedOptions("1[quote=\"bob, post:1\"]my quote[/quote]2",
{ topicId: 2, lookupAvatar: function(name) { return "" + name; } }, { topicId: 2, lookupAvatar: function(name) { return "" + name; } },
"<p>1</p><aside class='quote' data-post=\"1\" >\n <div class='title'>\n <div class='quote-controls'></div>\n" + "<p>1<aside class=\"quote\" data-post=\"1\"><div class=\"title\"><div class=\"quote-controls\"></div>bob\n" +
" bob\n bob said:\n </div>\n <blockquote>my quote</blockquote>\n</aside>\n<p></p>\n\n<p>2</p>", "bob said:</div><blockquote>my quote</blockquote></aside><br/>2</p>",
"handles quotes properly"); "handles quotes properly");
cookedOptions("1[quote=\"bob, post:1\"]my quote[/quote]2", cookedOptions("1[quote=\"bob, post:1\"]my quote[/quote]2",
{ topicId: 2, lookupAvatar: function(name) { } }, { topicId: 2, lookupAvatar: function(name) { } },
"<p>1</p><aside class='quote' data-post=\"1\" >\n <div class='title'>\n <div class='quote-controls'></div>\n" + "<p>1<aside class=\"quote\" data-post=\"1\"><div class=\"title\"><div class=\"quote-controls\"></div>bob said:</div><blockquote>my quote</blockquote></aside><br/>2</p>",
" \n bob said:\n </div>\n <blockquote>my quote</blockquote>\n</aside>\n<p></p>\n\n<p>2</p>",
"includes no avatar if none is found"); "includes no avatar if none is found");
}); });
test("Mentions", function() { test("Mentions", function() {
cookedOptions("Hello @sam", { mentionLookup: (function() { return true; }) }, cookedOptions("Hello @sam", { mentionLookup: (function() { return true; }) },
"<p>Hello <a href='/users/sam' class='mention'>@sam</a></p>", "<p>Hello <a class=\"mention\" href=\"/users/sam\">@sam</a></p>",
"translates mentions to links"); "translates mentions to links");
cooked("Hello @EvilTrout", "<p>Hello <span class='mention'>@EvilTrout</span></p>", "adds a mention class"); cooked("Hello @EvilTrout", "<p>Hello <span class=\"mention\">@EvilTrout</span></p>", "adds a mention class");
cooked("robin@email.host", "<p>robin@email.host</p>", "won't add mention class to an email address"); cooked("robin@email.host", "<p>robin@email.host</p>", "won't add mention class to an email address");
cooked("hanzo55@yahoo.com", "<p>hanzo55@yahoo.com</p>", "won't be affected by email addresses that have a number before the @ symbol"); cooked("hanzo55@yahoo.com", "<p>hanzo55@yahoo.com</p>", "won't be affected by email addresses that have a number before the @ symbol");
cooked("@EvilTrout yo", "<p><span class='mention'>@EvilTrout</span> yo</p>", "doesn't do @username mentions inside <pre> or <code> blocks"); cooked("@EvilTrout yo", "<p><span class=\"mention\">@EvilTrout</span> yo</p>", "it handles mentions at the beginning of a string");
cooked("yo\n@EvilTrout", "<p>yo<br><span class=\"mention\">@EvilTrout</span></p>", "it handles mentions at the beginning of a new line");
cooked("`evil` @EvilTrout `trout`", cooked("`evil` @EvilTrout `trout`",
"<p><code>evil</code> <span class='mention'>@EvilTrout</span> <code>trout</code></p>", "<p><code>evil</code> <span class=\"mention\">@EvilTrout</span> <code>trout</code></p>",
"deals correctly with multiple <code> blocks"); "deals correctly with multiple <code> blocks");
cooked("```\na @test\n```", "<p><pre><code class=\"lang-auto\">a @test</code></pre></p>", "should not do mentions within a code block.");
}); });
@ -101,22 +121,59 @@ test("Oneboxing", function() {
}; };
ok(!matches("- http://www.textfiles.com/bbs/MINDVOX/FORUMS/ethics\n\n- http://drupal.org", /onebox/), ok(!matches("- http://www.textfiles.com/bbs/MINDVOX/FORUMS/ethics\n\n- http://drupal.org", /onebox/),
"doesn't onebox a link within a list"); "doesn't onebox a link within a list");
ok(matches("http://test.com", /onebox/), "adds a onebox class to a link on its own line"); ok(matches("http://test.com", /onebox/), "adds a onebox class to a link on its own line");
ok(matches("http://test.com\nhttp://test2.com", /onebox[\s\S]+onebox/m), "supports multiple links"); ok(matches("http://test.com\nhttp://test2.com", /onebox[\s\S]+onebox/m), "supports multiple links");
ok(!matches("http://test.com bob", /onebox/), "doesn't onebox links that have trailing text"); ok(!matches("http://test.com bob", /onebox/), "doesn't onebox links that have trailing text");
cooked("http://en.wikipedia.org/wiki/Homicide:_Life_on_the_Street", cooked("http://en.wikipedia.org/wiki/Homicide:_Life_on_the_Street",
"<p><a href=\"http://en.wikipedia.org/wiki/Homicide:_Life_on_the_Street\" class=\"onebox\" target=\"_blank\"" + "<p><a href=\"http://en.wikipedia.org/wiki/Homicide:_Life_on_the_Street\" class=\"onebox\"" +
">http://en.wikipedia.org/wiki/Homicide:_Life_on_the_Street</a></p>", ">http://en.wikipedia.org/wiki/Homicide:_Life_on_the_Street</a></p>",
"works with links that have underscores in them"); "works with links that have underscores in them");
}); });
test("Code Blocks", function() {
cooked("```\ntest\n```",
"<p><pre><code class=\"lang-auto\">test</code></pre></p>",
"it supports basic code blocks");
cooked("```json\n{hello: 'world'}\n```\ntrailing",
"<p><pre><code class=\"json\">{hello: &#x27;world&#x27;}</code></pre></p>\n\n<p>\ntrailing</p>",
"It does not truncate text after a code block.");
cooked("```json\nline 1\n\nline 2\n\n\nline3\n```",
"<p><pre><code class=\"json\">line 1\n\nline 2\n\n\nline3</code></pre></p>",
"it maintains new lines inside a code block.");
cooked("hello\nworld\n```json\nline 1\n\nline 2\n\n\nline3\n```",
"<p>hello<br>world<br></p>\n\n<p><pre><code class=\"json\">line 1\n\nline 2\n\n\nline3</code></pre></p>",
"it maintains new lines inside a code block with leading content.");
cooked("```text\n<header>hello</header>\n```",
"<p><pre><code class=\"text\">&lt;header&gt;hello&lt;/header&gt;</code></pre></p>",
"it escapes code in the code block");
cooked("```ruby\n# cool\n```",
"<p><pre><code class=\"ruby\"># cool</code></pre></p>",
"it supports changing the language");
cooked(" ```\n hello\n ```",
"<pre><code>&#x60;&#x60;&#x60;\nhello\n&#x60;&#x60;&#x60;</code></pre>",
"only detect ``` at the begining of lines");
});
test("SanitizeHTML", function() { test("SanitizeHTML", function() {
equal(sanitizeHtml("<div><script>alert('hi');</script></div>"), "<div></div>"); equal(sanitizeHtml("<div><script>alert('hi');</script></div>"), "<div></div>");
equal(sanitizeHtml("<div><p class=\"funky\" wrong='1'>hello</p></div>"), "<div><p class=\"funky\">hello</p></div>"); equal(sanitizeHtml("<div><p class=\"funky\" wrong='1'>hello</p></div>"), "<div><p class=\"funky\">hello</p></div>");
cooked("hello<script>alert(42)</script>", "<p>hello</p>", "it sanitizes while cooking");
cooked("<a href='http://disneyland.disney.go.com/'>disney</a> <a href='http://reddit.com'>reddit</a>",
"<p><a href=\"http://disneyland.disney.go.com/\">disney</a> <a href=\"http://reddit.com\">reddit</a></p>",
"we can embed proper links");
}); });

File diff suppressed because one or more lines are too long