Moved Markdown out of Discourse.Utilities -> Discourse.Markdown

This commit is contained in:
Robin Ward 2013-03-05 14:33:27 -05:00
parent 1416bc7475
commit cf09e200a5
9 changed files with 330 additions and 255 deletions

View file

@ -0,0 +1,188 @@
/*global sanitizeHtml:true Markdown:true */
/**
Contains methods to help us with markdown formatting.
@class Markdown
@namespace Discourse
@module Discourse
**/
Discourse.Markdown = {
/**
Convert a raw string to a cooked markdown string.
@method cook
@param {String} raw the raw string we want to apply markdown to
@param {Object} opts the options for the rendering
**/
cook: function(raw, opts) {
if (!opts) opts = {};
// Make sure we've got a string
if (!raw) return "";
if (raw.length === 0) return "";
this.converter = this.markdownConverter(opts);
return this.converter.makeHtml(raw);
},
/**
Creates a new markdown editor
@method createNewMarkdownEditor
@param {Markdown.Converter} markdownConverter the converter object
@param {String} idPostfix
@param {Object} options the options for the markdown editor
**/
createNewMarkdownEditor: function(markdownConverter, idPostfix, options) {
options = options || {};
options.strings = {
bold: I18n.t("js.composer.bold_title") + " <strong> Ctrl+B",
boldexample: I18n.t("js.composer.bold_text"),
italic: I18n.t("js.composer.italic_title") + " <em> Ctrl+I",
italicexample: I18n.t("js.composer.italic_text"),
link: I18n.t("js.composer.link_title") + " <a> Ctrl+L",
linkdescription: "enter link description here",
linkdialog: "<p><b>" + I18n.t("js.composer.link_dialog_title") + "</b></p><p>http://example.com/ \"" +
I18n.t("js.composer.link_optional_text") + "\"</p>",
quote: I18n.t("js.composer.quote_title") + " <blockquote> Ctrl+Q",
quoteexample: I18n.t("js.composer.quote_text"),
code: I18n.t("js.composer.code_title") + " <pre><code> Ctrl+K",
codeexample: I18n.t("js.composer.code_text"),
image: I18n.t("js.composer.image_title") + " <img> Ctrl+G",
imagedescription: I18n.t("js.composer.image_description"),
imagedialog: "<p><b>" + I18n.t("js.composer.image_dialog_title") + "</b></p><p>http://example.com/images/diagram.jpg \"" +
I18n.t("js.composer.image_optional_text") + "\"<br><br>" + I18n.t("js.composer.image_hosting_hint") + "</p>",
olist: I18n.t("js.composer.olist_title") + " <ol> Ctrl+O",
ulist: I18n.t("js.composer.ulist_title") + " <ul> Ctrl+U",
litem: I18n.t("js.compser.list_item"),
heading: I18n.t("js.composer.heading_title") + " <h1>/<h2> Ctrl+H",
headingexample: I18n.t("js.composer.heading_text"),
hr: I18n.t("js.composer_hr_title") + " <hr> Ctrl+R",
undo: I18n.t("js.composer.undo_title") + " - Ctrl+Z",
redo: I18n.t("js.composer.redo_title") + " - Ctrl+Y",
redomac: I18n.t("js.composer.redo_title") + " - Ctrl+Shift+Z",
help: I18n.t("js.composer.help")
};
return new Markdown.Editor(markdownConverter, idPostfix, options);
},
/**
Creates a Markdown.Converter that we we can use for formatting
@method markdownConverter
@param {Object} opts the converting options
**/
markdownConverter: function(opts) {
var converter, mentionLookup,
_this = this;
converter = new Markdown.Converter();
if (opts) {
mentionLookup = opts.mentionLookup;
}
mentionLookup = mentionLookup || Discourse.Mention.lookupCache;
// Before cooking callbacks
converter.hooks.chain("preConversion", function(text) {
_this.trigger('beforeCook', {
detail: text,
opts: opts
});
return _this.textResult || 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
if (!Discourse.SiteSettings.traditional_markdown_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;
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 to mention voodoo in pres
text = text.replace(/<pre>([\s\S]*@[\s\S]*)<\/pre>/gi, function(wholeMatch, inner) {
return "<pre>" + (inner.replace(/@/g, '&#64;')) + "</pre>";
});
// Add @mentions of names
text = text.replace(/([\s\t>,:'|";\]])(@[A-Za-z0-9_-|\.]*[A-Za-z0-9_-|]+)(?=[\s\t<\!:|;',"\?\.])/g, function(x, pre, name) {
if (mentionLookup(name.substr(1))) {
return "" + pre + "<a href='/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 = 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
var onebox, url;
if (arguments[1]) {
return arguments[0];
}
url = arguments[5];
if (Discourse && Discourse.Onebox) {
onebox = Discourse.Onebox.lookupCache(url);
}
if (onebox && !onebox.isBlank()) {
return arguments[2] + onebox;
} else {
return arguments[2] + arguments[4] + " class=\"onebox\" target=\"_blank\">" + arguments[6];
}
});
return(text);
});
converter.hooks.chain("postConversion", function(text) {
return Discourse.BBCode.format(text, opts);
});
if (opts.sanitize) {
converter.hooks.chain("postConversion", function(text) {
if (!window.sanitizeHtml) {
return "";
}
return sanitizeHtml(text);
});
}
return converter;
}
};
RSVP.EventTarget.mixin(Discourse.Markdown);

View file

@ -1,5 +1,3 @@
/*global sanitizeHtml:true Markdown:true */
/**
General utility functions
@ -148,119 +146,6 @@ Discourse.Utilities = {
range.moveStart('character', pos);
return range.select();
}
},
markdownConverter: function(opts) {
var converter, mentionLookup,
_this = this;
converter = new Markdown.Converter();
if (opts) {
mentionLookup = opts.mentionLookup;
}
mentionLookup = mentionLookup || Discourse.Mention.lookupCache;
// Before cooking callbacks
converter.hooks.chain("preConversion", function(text) {
_this.trigger('beforeCook', {
detail: text,
opts: opts
});
return _this.textResult || 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
if (!Discourse.SiteSettings.traditional_markdown_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;
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 to mention voodoo in pres
text = text.replace(/<pre>([\s\S]*@[\s\S]*)<\/pre>/gi, function(wholeMatch, inner) {
return "<pre>" + (inner.replace(/@/g, '&#64;')) + "</pre>";
});
// Add @mentions of names
text = text.replace(/([\s\t>,:'|";\]])(@[A-Za-z0-9_-|\.]*[A-Za-z0-9_-|]+)(?=[\s\t<\!:|;',"\?\.])/g, function(x, pre, name) {
if (mentionLookup(name.substr(1))) {
return "" + pre + "<a href='/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 = 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
var onebox, url;
if (arguments[1]) {
return arguments[0];
}
url = arguments[5];
if (Discourse && Discourse.Onebox) {
onebox = Discourse.Onebox.lookupCache(url);
}
if (onebox && !onebox.isBlank()) {
return arguments[2] + onebox;
} else {
return arguments[2] + arguments[4] + " class=\"onebox\" target=\"_blank\">" + arguments[6];
}
});
return(text);
});
converter.hooks.chain("postConversion", function(text) {
return Discourse.BBCode.format(text, opts);
});
if (opts.sanitize) {
converter.hooks.chain("postConversion", function(text) {
if (!window.sanitizeHtml) {
return "";
}
return sanitizeHtml(text);
});
}
return converter;
},
// Takes raw input and cooks it to display nicely (mostly markdown)
cook: function(raw, opts) {
if (!opts) opts = {};
// Make sure we've got a string
if (!raw) return "";
if (raw.length === 0) return "";
this.converter = this.markdownConverter(opts);
return this.converter.makeHtml(raw);
}
};
RSVP.EventTarget.mixin(Discourse.Utilities);
};

View file

@ -251,7 +251,7 @@ Discourse.ComposerView = Discourse.View.extend({
});
topic = this.get('topic');
this.editor = editor = new Markdown.Editor(Discourse.Utilities.markdownConverter({
this.editor = editor = Discourse.Markdown.createNewMarkdownEditor(Discourse.Markdown.markdownConverter({
lookupAvatar: function(username) {
return Discourse.Utilities.avatarImg({
username: username,

View file

@ -34,7 +34,7 @@ Discourse.PagedownEditor = Ember.ContainerView.extend({
var $wmdInput;
$wmdInput = $('#wmd-input');
$wmdInput.data('init', true);
this.editor = new Markdown.Editor(Discourse.Utilities.markdownConverter({
this.editor = Discourse.Markdown.createNewMarkdownEditor(Discourse.Markdown.markdownConverter({
sanitize: true
}));
return this.editor.run();

View file

@ -1,18 +1,14 @@
window.PagedownCustom = {
insertButtons: [
{
id: 'wmd-quote-post',
description: 'Quote Post',
execute: function() {
/* AWFUL but I can't figure out how to call a controller method from outside
*/
/*global Markdown:true*/
/* my app?
*/
return Discourse.__container__.lookup('controller:composer').importQuote();
}
window.PagedownCustom = {
insertButtons: [
{
id: 'wmd-quote-post',
description: I18n.t("js.composer.quote_title"),
execute: function() {
// AWFUL but I can't figure out how to call a controller method from outside our app
return Discourse.__container__.lookup('controller:composer').importQuote();
}
]
};
}
]
};

View file

@ -92,6 +92,7 @@ module PrettyText
@ctx.load(app_root + "app/assets/javascripts/discourse/components/bbcode.js")
@ctx.load(app_root + "app/assets/javascripts/discourse/components/utilities.js")
@ctx.load(app_root + "app/assets/javascripts/discourse/components/markdown.js")
# Load server side javascripts
if DiscoursePluginRegistry.server_side_javascripts.present?
@ -123,7 +124,7 @@ module PrettyText
v8['raw'] = text
v8.eval('opts["mentionLookup"] = function(u){return helpers.is_username_valid(u);}')
v8.eval('opts["lookupAvatar"] = function(p){return Discourse.Utilities.avatarImg({username: p, size: "tiny", avatarTemplate: helpers.avatar_template(p)});}')
baked = v8.eval('Discourse.Utilities.markdownConverter(opts).makeHtml(raw)')
baked = v8.eval('Discourse.Markdown.markdownConverter(opts).makeHtml(raw)')
end
# we need some minimal server side stuff, apply CDN and TODO filter disallowed markup

View file

@ -0,0 +1,124 @@
/*global waitsFor:true expect:true describe:true beforeEach:true it:true */
describe("Discourse.Markdown", function() {
describe("Cooking", function() {
var cook = function(contents, opts) {
opts = opts || {};
opts.mentionLookup = opts.mentionLookup || false;
return Discourse.Markdown.cook(contents, opts);
};
it("surrounds text with paragraphs", function() {
expect(cook("hello")).toBe("<p>hello</p>");
});
it("automatically handles trivial newlines", function() {
expect(cook("1\n2\n3")).toBe("<p>1 <br>\n2 <br>\n3</p>");
});
it("handles quotes properly", function() {
var cooked = cook("1[quote=\"bob, post:1\"]my quote[/quote]2", {
topicId: 2,
lookupAvatar: function(name) { return "" + name; }
});
expect(cooked).toBe("<p>1</p><aside class='quote' data-post=\"1\" >\n <div class='title'>\n <div class='quote-controls'></div>\n" +
" bob\n bob\n said:\n </div>\n <blockquote>my quote</blockquote>\n</aside>\n<p>2</p>");
});
it("includes no avatar if none is found", function() {
var cooked = cook("1[quote=\"bob, post:1\"]my quote[/quote]2", {
topicId: 2,
lookupAvatar: function(name) { return null; }
});
expect(cooked).toBe("<p>1</p><aside class='quote' data-post=\"1\" >\n <div class='title'>\n <div class='quote-controls'></div>\n" +
" \n bob\n said:\n </div>\n <blockquote>my quote</blockquote>\n</aside>\n<p>2</p>");
});
describe("Links", function() {
it("allows links to contain query params", function() {
expect(cook("Youtube: http://www.youtube.com/watch?v=1MrpeBRkM5A")).
toBe('<p>Youtube: <a href="http://www.youtube.com/watch?v=1MrpeBRkM5A">http://www.youtube.com/watch?v=1MrpeBRkM5A</a></p>');
});
it("escapes double underscores in URLs", function() {
expect(cook("Derpy: http://derp.com?__test=1")).toBe('<p>Derpy: <a href="http://derp.com?%5F%5Ftest=1">http://derp.com?__test=1</a></p>');
});
it("autolinks something that begins with www", function() {
expect(cook("Atwood: www.codinghorror.com")).toBe('<p>Atwood: <a href="http://www.codinghorror.com">www.codinghorror.com</a></p>');
});
it("autolinks a URL with http://www", function() {
expect(cook("Atwood: http://www.codinghorror.com")).toBe('<p>Atwood: <a href="http://www.codinghorror.com">http://www.codinghorror.com</a></p>');
});
it("autolinks a URL", function() {
expect(cook("EvilTrout: http://eviltrout.com")).toBe('<p>EvilTrout: <a href="http://eviltrout.com">http://eviltrout.com</a></p>');
});
it("supports markdown style links", function() {
expect(cook("here is [an example](http://twitter.com)")).toBe('<p>here is <a href="http://twitter.com">an example</a></p>');
});
it("autolinks a URL with parentheses (like Wikipedia)", function() {
expect(cook("Batman: http://en.wikipedia.org/wiki/The_Dark_Knight_(film)")).
toBe('<p>Batman: <a href="http://en.wikipedia.org/wiki/The_Dark_Knight_(film)">http://en.wikipedia.org/wiki/The_Dark_Knight_(film)</a></p>');
});
});
describe("Mentioning", function() {
it("translates mentions to links", function() {
expect(cook("Hello @sam", { mentionLookup: (function() { return true; }) })).toBe("<p>Hello <a href='/users/sam' class='mention'>@sam</a></p>");
});
it("adds a mention class", function() {
expect(cook("Hello @EvilTrout")).toBe("<p>Hello <span class='mention'>@EvilTrout</span></p>");
});
it("won't add mention class to an email address", function() {
expect(cook("robin@email.host")).toBe("<p>robin@email.host</p>");
});
it("won't be affected by email addresses that have a number before the @ symbol", function() {
expect(cook("hanzo55@yahoo.com")).toBe("<p>hanzo55@yahoo.com</p>");
});
it("supports a @mention at the beginning of a post", function() {
expect(cook("@EvilTrout yo")).toBe("<p><span class='mention'>@EvilTrout</span> yo</p>");
});
});
describe("Oneboxing", function() {
it("doesn't onebox a link within a list", function() {
expect(cook("- http://www.textfiles.com/bbs/MINDVOX/FORUMS/ethics\n\n- http://drupal.org")).not.toMatch(/onebox/);
});
it("adds a onebox class to a link on its own line", function() {
expect(cook("http://test.com")).toMatch(/onebox/);
});
it("supports multiple links", function() {
expect(cook("http://test.com\nhttp://test2.com")).toMatch(/onebox[\s\S]+onebox/m);
});
it("doesn't onebox links that have trailing text", function() {
expect(cook("http://test.com bob")).not.toMatch(/onebox/);
});
it("works with links that have underscores in them", function() {
expect(cook("http://en.wikipedia.org/wiki/Homicide:_Life_on_the_Street")).
toBe("<p><a href=\"http://en.wikipedia.org/wiki/Homicide:_Life_on_the_Street\" class=\"onebox\" target=\"_blank\">http://en.wikipedia.org/wiki/Homicide:_Life_on_the_Street</a></p>");
});
});
});
});

View file

@ -18,125 +18,6 @@ describe("Discourse.Utilities", function() {
});
describe("Cooking", function() {
var cook = function(contents, opts) {
opts = opts || {};
opts.mentionLookup = opts.mentionLookup || false;
return Discourse.Utilities.cook(contents, opts);
};
it("surrounds text with paragraphs", function() {
expect(cook("hello")).toBe("<p>hello</p>");
});
it("automatically handles trivial newlines", function() {
expect(cook("1\n2\n3")).toBe("<p>1 <br>\n2 <br>\n3</p>");
});
it("handles quotes properly", function() {
var cooked = cook("1[quote=\"bob, post:1\"]my quote[/quote]2", {
topicId: 2,
lookupAvatar: function(name) { return "" + name; }
});
expect(cooked).toBe("<p>1</p><aside class='quote' data-post=\"1\" >\n <div class='title'>\n <div class='quote-controls'></div>\n" +
" bob\n bob\n said:\n </div>\n <blockquote>my quote</blockquote>\n</aside>\n<p>2</p>");
});
it("includes no avatar if none is found", function() {
var cooked = cook("1[quote=\"bob, post:1\"]my quote[/quote]2", {
topicId: 2,
lookupAvatar: function(name) { return null; }
});
expect(cooked).toBe("<p>1</p><aside class='quote' data-post=\"1\" >\n <div class='title'>\n <div class='quote-controls'></div>\n" +
" \n bob\n said:\n </div>\n <blockquote>my quote</blockquote>\n</aside>\n<p>2</p>");
});
describe("Links", function() {
it("allows links to contain query params", function() {
expect(cook("Youtube: http://www.youtube.com/watch?v=1MrpeBRkM5A")).
toBe('<p>Youtube: <a href="http://www.youtube.com/watch?v=1MrpeBRkM5A">http://www.youtube.com/watch?v=1MrpeBRkM5A</a></p>');
});
it("escapes double underscores in URLs", function() {
expect(cook("Derpy: http://derp.com?__test=1")).toBe('<p>Derpy: <a href="http://derp.com?%5F%5Ftest=1">http://derp.com?__test=1</a></p>');
});
it("autolinks something that begins with www", function() {
expect(cook("Atwood: www.codinghorror.com")).toBe('<p>Atwood: <a href="http://www.codinghorror.com">www.codinghorror.com</a></p>');
});
it("autolinks a URL with http://www", function() {
expect(cook("Atwood: http://www.codinghorror.com")).toBe('<p>Atwood: <a href="http://www.codinghorror.com">http://www.codinghorror.com</a></p>');
});
it("autolinks a URL", function() {
expect(cook("EvilTrout: http://eviltrout.com")).toBe('<p>EvilTrout: <a href="http://eviltrout.com">http://eviltrout.com</a></p>');
});
it("supports markdown style links", function() {
expect(cook("here is [an example](http://twitter.com)")).toBe('<p>here is <a href="http://twitter.com">an example</a></p>');
});
it("autolinks a URL with parentheses (like Wikipedia)", function() {
expect(cook("Batman: http://en.wikipedia.org/wiki/The_Dark_Knight_(film)")).
toBe('<p>Batman: <a href="http://en.wikipedia.org/wiki/The_Dark_Knight_(film)">http://en.wikipedia.org/wiki/The_Dark_Knight_(film)</a></p>');
});
});
describe("Mentioning", function() {
it("translates mentions to links", function() {
expect(cook("Hello @sam", { mentionLookup: (function() { return true; }) })).toBe("<p>Hello <a href='/users/sam' class='mention'>@sam</a></p>");
});
it("adds a mention class", function() {
expect(cook("Hello @EvilTrout")).toBe("<p>Hello <span class='mention'>@EvilTrout</span></p>");
});
it("won't add mention class to an email address", function() {
expect(cook("robin@email.host")).toBe("<p>robin@email.host</p>");
});
it("won't be affected by email addresses that have a number before the @ symbol", function() {
expect(cook("hanzo55@yahoo.com")).toBe("<p>hanzo55@yahoo.com</p>");
});
it("supports a @mention at the beginning of a post", function() {
expect(cook("@EvilTrout yo")).toBe("<p><span class='mention'>@EvilTrout</span> yo</p>");
});
});
describe("Oneboxing", function() {
it("doesn't onebox a link within a list", function() {
expect(cook("- http://www.textfiles.com/bbs/MINDVOX/FORUMS/ethics\n\n- http://drupal.org")).not.toMatch(/onebox/);
});
it("adds a onebox class to a link on its own line", function() {
expect(cook("http://test.com")).toMatch(/onebox/);
});
it("supports multiple links", function() {
expect(cook("http://test.com\nhttp://test2.com")).toMatch(/onebox[\s\S]+onebox/m);
});
it("doesn't onebox links that have trailing text", function() {
expect(cook("http://test.com bob")).not.toMatch(/onebox/);
});
it("works with links that have underscores in them", function() {
expect(cook("http://en.wikipedia.org/wiki/Homicide:_Life_on_the_Street")).
toBe("<p><a href=\"http://en.wikipedia.org/wiki/Homicide:_Life_on_the_Street\" class=\"onebox\" target=\"_blank\">http://en.wikipedia.org/wiki/Homicide:_Life_on_the_Street</a></p>");
});
});
});
describe("emailValid", function() {
it("allows upper case in first part of emails", function() {

File diff suppressed because one or more lines are too long