Better API for adding on to our Dialect

This commit is contained in:
Robin Ward
2013-08-27 12:52:00 -04:00
parent 92d7953dd0
commit 8f94760cd4
7 changed files with 265 additions and 242 deletions

View File

@ -1,45 +1,19 @@
/** /**
This addition handles auto linking of text. When included, it will parse out links and create This addition handles auto linking of text. When included, it will parse out links and create
a hrefs for them. 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, emitter: function(matches) {
MD = event.MD; var url = matches[2],
displayUrl = url;
/** if (url.match(/^www/)) { url = "http://" + url; }
Parses out links from HTML. return ['a', {href: url}, displayUrl];
}
};
@method autoLink Discourse.Dialect.inlineRegexp(_.merge({start: 'http'}, urlReplacerArgs));
@param {String} text the text match Discourse.Dialect.inlineRegexp(_.merge({start: 'www'}, urlReplacerArgs));
@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]];
}
};
});

View File

@ -1,76 +1,112 @@
/** /**
Regsiter all functionality for supporting BBCode in Discourse. Create a simple BBCode tag handler
@event register @method replaceBBCode
@namespace Discourse.Dialect @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) { Discourse.Dialect.on("register", function(event) {
var dialect = event.dialect, var dialect = event.dialect,
MD = event.MD; 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 Support BBCode [code] blocks

View File

@ -1,43 +1,25 @@
/** /**
Markdown.js doesn't seem to do bold and italics at the same time if you surround code with markdown-js doesn't ensure that em/strong codes are present on word boundaries.
three asterisks. This adds that support. So we create our own handlers here.
@event register
@namespace Discourse.Dialect
**/ **/
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');

View File

@ -43,51 +43,67 @@
**/ **/
var parser = window.BetterMarkdown, var parser = window.BetterMarkdown,
MD = parser.Markdown, MD = parser.Markdown,
// Our dialect
dialect = MD.dialects.Discourse = MD.subclassDialect( MD.dialects.Gruber ), dialect = MD.dialects.Discourse = MD.subclassDialect( MD.dialects.Gruber ),
initialized = false;
initialized = false, /**
Initialize our dialects for processing.
/** @method initializeDialects
Initialize our dialects for processing. **/
function initializeDialects() {
Discourse.Dialect.trigger('register', {dialect: dialect, MD: MD});
MD.buildBlockOrder(dialect.block);
MD.buildInlinePatterns(dialect.inline);
initialized = true;
}
@method initializeDialects /**
**/ Parse a JSON ML tree, using registered handlers to adjust it if necessary.
initializeDialects = function() {
Discourse.Dialect.trigger('register', {dialect: dialect, MD: MD});
MD.buildBlockOrder(dialect.block);
MD.buildInlinePatterns(dialect.inline);
initialized = true;
},
/** @method parseTree
Parse a JSON ML tree, using registered handlers to adjust it if necessary. @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 path = path || [];
@param {Array} tree the JsonML tree to parse insideCounts = insideCounts || {};
@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 || []; path.push(tree);
insideCounts = insideCounts || {}; 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) { Returns true if there's an invalid word boundary for a match.
var tagName = n[0];
insideCounts[tagName] = (insideCounts[tagName] || 0) + 1; @method invalidBoundary
parseTree(n, path, insideCounts); @param {Object} args our arguments, including whether we care about boundaries
insideCounts[tagName] = insideCounts[tagName] - 1; @param {Array} prev the previous content, if exists
}); @returns {Boolean} whether there is an invalid word boundary
path.pop(); **/
} function invalidBoundary(args, prev) {
return tree;
}; 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. An object used for rendering our dialects.
@ -110,7 +126,51 @@ Discourse.Dialect = {
dialect.options = opts; dialect.options = opts;
var tree = parser.toHTMLTree(text, 'Discourse'); var tree = parser.toHTMLTree(text, 'Discourse');
return parser.renderJsonML(parseTree(tree)); 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); RSVP.EventTarget.mixin(Discourse.Dialect);

View File

@ -2,47 +2,20 @@
Supports Discourse's custom @mention syntax for calling out a user in a post. 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 It will add a special class to them, and create a link if the user is found in a
local map. 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, emitter: function(matches) {
MD = event.MD; var username = matches[1],
mentionLookup = this.dialect.options.mentionLookup || Discourse.Mention.lookupCache;
/** if (mentionLookup(username.substr(1))) {
Parses out @username mentions. return ['a', {'class': 'mention', href: Discourse.getURL("/users/") + username.substr(1).toLowerCase()}, username];
} else {
@method parseMentions return ['span', {'class': 'mention'}, username];
@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; }
} }
}
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]];
}
}
};
});

View File

@ -10,33 +10,28 @@ Discourse.Dialect.on("parseNode", function(event) {
insideCounts = event.insideCounts, insideCounts = event.insideCounts,
linebreaks = opts.traditional_markdown_linebreaks || Discourse.SiteSettings.traditional_markdown_linebreaks; linebreaks = opts.traditional_markdown_linebreaks || Discourse.SiteSettings.traditional_markdown_linebreaks;
if (!linebreaks) { if (linebreaks || (insideCounts.pre > 0) || (node.length < 1)) { return; }
// We don't add line breaks inside a pre
if (insideCounts.pre > 0) { return; }
if (node.length > 1) { for (var j=1; j<node.length; j++) {
for (var j=1; j<node.length; j++) { var textContent = node[j];
var textContent = node[j];
if (typeof textContent === "string") { if (typeof textContent === "string") {
if (textContent === "\n") {
if (textContent === "\n") { node[j] = ['br'];
node[j] = ['br']; } else {
} else { var split = textContent.split(/\n+/);
var split = textContent.split(/\n+/); if (split.length) {
if (split.length) { var spliceInstructions = [j, 1];
var spliceInstructions = [j, 1]; for (var i=0; i<split.length; i++) {
for (var i=0; i<split.length; i++) { if (split[i].length > 0) {
if (split[i].length > 0) { spliceInstructions.push(split[i]);
spliceInstructions.push(split[i]); if (i !== split.length-1) { spliceInstructions.push(['br']); }
if (i !== split.length-1) { spliceInstructions.push(['br']); }
}
}
node.splice.apply(node, spliceInstructions);
} }
} }
node.splice.apply(node, spliceInstructions);
} }
} }
} }
} }
});
});

View File

@ -17,6 +17,9 @@ test('basic bbcode', function() {
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");
format("[b]evil [i]trout[/i][/b]",
"<span class=\"bbcode-b\">evil <span class=\"bbcode-i\">trout</span></span>",
"allows embedding of tags");
}); });
test('lists', function() { test('lists', function() {
@ -28,7 +31,7 @@ test('color', function() {
format("[color=#00f]blue[/color]", "<span style=\"color: #00f\">blue</span>", "supports [color=] with a short hex value"); format("[color=#00f]blue[/color]", "<span style=\"color: #00f\">blue</span>", "supports [color=] with a short hex value");
format("[color=#ffff00]yellow[/color]", "<span style=\"color: #ffff00\">yellow</span>", "supports [color=] with a long hex value"); format("[color=#ffff00]yellow[/color]", "<span style=\"color: #ffff00\">yellow</span>", "supports [color=] with a long hex value");
format("[color=red]red[/color]", "<span style=\"color: red\">red</span>", "supports [color=] with an html color"); format("[color=red]red[/color]", "<span style=\"color: red\">red</span>", "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]", "<span>noop</span>", "it performs a noop on invalid input");
}); });
test('tags with arguments', function() { test('tags with arguments', function() {