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
a hrefs for them.
@event register
@namespace Discourse.Dialect
**/
Discourse.Dialect.on("register", function(event) {
var urlReplacerArgs = {
matcher: /(^|\s)((?:https?:(?:\/{1,3}|[a-z0-9%])|www\d{0,3}[.])(?:[^\s()<>]+|\([^\s()<>]+\))+(?:\([^\s()<>]+\)|[^`!()\[\]{};:'".,<>?«»“”‘’\s]))/gm,
spaceBoundary: true,
var dialect = event.dialect,
MD = event.MD;
emitter: function(matches) {
var url = matches[2],
displayUrl = url;
/**
Parses out links from HTML.
if (url.match(/^www/)) { url = "http://" + url; }
return ['a', {href: url}, displayUrl];
}
};
@method autoLink
@param {String} text the text match
@param {Array} match the match found
@param {Array} prev the previous jsonML
@return {Array} an array containing how many chars we've replaced and the jsonML content for it.
@namespace Discourse.Dialect
**/
dialect.inline['http'] = dialect.inline['www'] = function autoLink(text, match, prev) {
// We only care about links on boundaries
if (prev && (prev.length > 0)) {
var last = prev[prev.length - 1];
if (typeof last === "string" && (!last.match(/\s$/))) { return; }
}
var pattern = /(^|\s)((?:https?:(?:\/{1,3}|[a-z0-9%])|www\d{0,3}[.])(?:[^\s()<>]+|\([^\s()<>]+\))+(?:\([^\s()<>]+\)|[^`!()\[\]{};:'".,<>?«»“”‘’\s]))/gm,
m = pattern.exec(text);
if (m) {
var url = m[2],
displayUrl = m[2];
if (url.match(/^www/)) { url = "http://" + url; }
return [m[0].length, ['a', {href: url}, displayUrl]];
}
};
});
Discourse.Dialect.inlineRegexp(_.merge({start: 'http'}, urlReplacerArgs));
Discourse.Dialect.inlineRegexp(_.merge({start: 'www'}, urlReplacerArgs));

View File

@ -1,76 +1,112 @@
/**
Regsiter all functionality for supporting BBCode in Discourse.
Create a simple BBCode tag handler
@event register
@namespace Discourse.Dialect
@method replaceBBCode
@param {tag} tag the tag we want to match
@param {function} emitter the function that creates JsonML for the tag
**/
function replaceBBCode(tag, emitter) {
Discourse.Dialect.inlineReplace({
start: "[" + tag + "]",
stop: "[/" + tag + "]",
emitter: emitter
});
}
/**
Creates a BBCode handler that accepts parameters. Passes them to the emitter.
@method replaceBBCodeParamsRaw
@param {tag} tag the tag we want to match
@param {function} emitter the function that creates JsonML for the tag
**/
function replaceBBCodeParamsRaw(tag, emitter) {
Discourse.Dialect.inlineReplace({
start: "[" + tag + "=",
stop: "[/" + tag + "]",
rawContents: true,
emitter: function(contents) {
var regexp = /^([^\]]+)\](.*)$/,
m = regexp.exec(contents);
if (m) { return emitter.call(this, m[1], m[2]); }
}
});
}
/**
Creates a BBCode handler that accepts parameters. Passes them to the emitter.
Processes the inside recursively so it can be nested.
@method replaceBBCodeParams
@param {tag} tag the tag we want to match
@param {function} emitter the function that creates JsonML for the tag
**/
function replaceBBCodeParams(tag, emitter) {
replaceBBCodeParamsRaw(tag, function (param, contents) {
return emitter(param, this.processInline(contents));
});
}
replaceBBCode('b', function(contents) { return ['span', {'class': 'bbcode-b'}].concat(contents); });
replaceBBCode('i', function(contents) { return ['span', {'class': 'bbcode-i'}].concat(contents); });
replaceBBCode('u', function(contents) { return ['span', {'class': 'bbcode-u'}].concat(contents); });
replaceBBCode('s', function(contents) { return ['span', {'class': 'bbcode-s'}].concat(contents); });
replaceBBCode('ul', function(contents) { return ['ul'].concat(contents); });
replaceBBCode('ol', function(contents) { return ['ol'].concat(contents); });
replaceBBCode('li', function(contents) { return ['li'].concat(contents); });
replaceBBCode('spoiler', function(contents) { return ['span', {'class': 'spoiler'}].concat(contents); });
Discourse.Dialect.inlineReplace({
start: '[img]',
stop: '[/img]',
rawContents: true,
emitter: function(contents) { return ['img', {href: contents}]; }
});
Discourse.Dialect.inlineReplace({
start: '[email]',
stop: '[/email]',
rawContents: true,
emitter: function(contents) { return ['a', {href: "mailto:" + contents, 'data-bbcode': true}, contents]; }
});
Discourse.Dialect.inlineReplace({
start: '[url]',
stop: '[/url]',
rawContents: true,
emitter: function(contents) { return ['a', {href: contents, 'data-bbcode': true}, contents]; }
});
replaceBBCodeParamsRaw("url", function(param, contents) {
return ['a', {href: param, 'data-bbcode': true}, contents];
});
replaceBBCodeParamsRaw("email", function(param, contents) {
return ['a', {href: "mailto:" + param, 'data-bbcode': true}, contents];
});
replaceBBCodeParams("size", function(param, contents) {
return ['span', {'class': "bbcode-size-" + param}].concat(contents);
});
replaceBBCodeParams("color", function(param, contents) {
// Only allow valid HTML colors.
if (/^(\#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?)|(aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|purple|red|silver|teal|white|yellow)$/.test(param)) {
return ['span', {style: "color: " + param}].concat(contents);
} else {
return ['span'].concat(contents);
}
});
Discourse.Dialect.on("register", function(event) {
var dialect = event.dialect,
MD = event.MD;
var createBBCode = function(tag, builder, hasArgs) {
return function(text, orig_match) {
var bbcodePattern = new RegExp("\\[" + tag + "=?([^\\[\\]]+)?\\]([\\s\\S]*?)\\[\\/" + tag + "\\]", "igm");
var m = bbcodePattern.exec(text);
if (m && m[0]) {
return [m[0].length, builder(m, this)];
}
};
};
var bbcodes = {'b': ['span', {'class': 'bbcode-b'}],
'i': ['span', {'class': 'bbcode-i'}],
'u': ['span', {'class': 'bbcode-u'}],
's': ['span', {'class': 'bbcode-s'}],
'spoiler': ['span', {'class': 'spoiler'}],
'li': ['li'],
'ul': ['ul'],
'ol': ['ol']};
Object.keys(bbcodes).forEach(function(tag) {
var element = bbcodes[tag];
dialect.inline["[" + tag + "]"] = createBBCode(tag, function(m, self) {
return element.concat(self.processInline(m[2]));
});
});
dialect.inline["[img]"] = createBBCode('img', function(m) {
return ['img', {href: m[2]}];
});
dialect.inline["[email]"] = createBBCode('email', function(m) {
return ['a', {href: "mailto:" + m[2], 'data-bbcode': true}, m[2]];
});
dialect.inline["[url]"] = createBBCode('url', function(m) {
return ['a', {href: m[2], 'data-bbcode': true}, m[2]];
});
dialect.inline["[url="] = createBBCode('url', function(m, self) {
return ['a', {href: m[1], 'data-bbcode': true}].concat(self.processInline(m[2]));
});
dialect.inline["[email="] = createBBCode('email', function(m, self) {
return ['a', {href: "mailto:" + m[1], 'data-bbcode': true}].concat(self.processInline(m[2]));
});
dialect.inline["[size="] = createBBCode('size', function(m, self) {
return ['span', {'class': "bbcode-size-" + m[1]}].concat(self.processInline(m[2]));
});
dialect.inline["[color="] = function(text, orig_match) {
var bbcodePattern = new RegExp("\\[color=?([^\\[\\]]+)?\\]([\\s\\S]*?)\\[\\/color\\]", "igm"),
m = bbcodePattern.exec(text);
if (m && m[0]) {
if (!/^(\#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?)|(aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|purple|red|silver|teal|white|yellow)$/.test(m[1])) {
return [m[0].length].concat(this.processInline(m[2]));
}
return [m[0].length, ['span', {style: "color: " + m[1]}].concat(this.processInline(m[2]))];
}
};
/**
Support BBCode [code] blocks

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
three asterisks. This adds that support.
@event register
@namespace Discourse.Dialect
markdown-js doesn't ensure that em/strong codes are present on word boundaries.
So we create our own handlers here.
**/
Discourse.Dialect.on("register", function(event) {
var dialect = event.dialect,
MD = event.MD;
var inlineBuilder = function(symbol, tag, surround) {
return function(text, match, prev) {
if (prev && (prev.length > 0)) {
var last = prev[prev.length - 1];
if (typeof last === "string" && (!last.match(/\W$/))) { return; }
}
var regExp = new RegExp("^\\" + symbol + "([^\\" + symbol + "]+)" + "\\" + symbol, "igm"),
m = regExp.exec(text);
if (m) {
var contents = [tag].concat(this.processInline(m[1]));
if (surround) {
contents = [surround, contents];
}
return [m[0].length, contents];
}
};
};
dialect.inline['***'] = inlineBuilder('**', 'em', 'strong');
dialect.inline['**'] = inlineBuilder('**', 'strong');
dialect.inline['*'] = inlineBuilder('*', 'em');
dialect.inline['_'] = inlineBuilder('_', 'em');
// Support for simultaneous bold and italics
Discourse.Dialect.inlineReplace({
between: '***',
wordBoundary: true,
emitter: function(contents) { return ['strong', ['em'].concat(contents)]; }
});
// Builds a common markdown replacer
var replaceMarkdown = function(match, tag) {
Discourse.Dialect.inlineReplace({
between: match,
wordBoundary: true,
emitter: function(contents) { return [tag].concat(contents) }
});
};
replaceMarkdown('**', 'strong');
replaceMarkdown('*', 'em');
replaceMarkdown('_', 'em');

View File

@ -43,51 +43,67 @@
**/
var parser = window.BetterMarkdown,
MD = parser.Markdown,
// Our dialect
dialect = MD.dialects.Discourse = MD.subclassDialect( MD.dialects.Gruber ),
initialized = false;
initialized = false,
/**
Initialize our dialects for processing.
/**
Initialize our dialects for processing.
@method initializeDialects
**/
function initializeDialects() {
Discourse.Dialect.trigger('register', {dialect: dialect, MD: MD});
MD.buildBlockOrder(dialect.block);
MD.buildInlinePatterns(dialect.inline);
initialized = true;
}
@method initializeDialects
**/
initializeDialects = function() {
Discourse.Dialect.trigger('register', {dialect: dialect, MD: MD});
MD.buildBlockOrder(dialect.block);
MD.buildInlinePatterns(dialect.inline);
initialized = true;
},
/**
Parse a JSON ML tree, using registered handlers to adjust it if necessary.
/**
Parse a JSON ML tree, using registered handlers to adjust it if necessary.
@method parseTree
@param {Array} tree the JsonML tree to parse
@param {Array} path the path of ancestors to the current node in the tree. Can be used for matching.
@param {Object} insideCounts counts what tags we're inside
@returns {Array} the parsed tree
**/
function parseTree(tree, path, insideCounts) {
if (tree instanceof Array) {
Discourse.Dialect.trigger('parseNode', {node: tree, path: path, dialect: dialect, insideCounts: insideCounts || {}});
@method parseTree
@param {Array} tree the JsonML tree to parse
@param {Array} path the path of ancestors to the current node in the tree. Can be used for matching.
@param {Object} insideCounts counts what tags we're inside
@returns {Array} the parsed tree
**/
parseTree = function parseTree(tree, path, insideCounts) {
if (tree instanceof Array) {
Discourse.Dialect.trigger('parseNode', {node: tree, path: path, dialect: dialect, insideCounts: insideCounts || {}});
path = path || [];
insideCounts = insideCounts || {};
path = path || [];
insideCounts = insideCounts || {};
path.push(tree);
tree.slice(1).forEach(function (n) {
var tagName = n[0];
insideCounts[tagName] = (insideCounts[tagName] || 0) + 1;
parseTree(n, path, insideCounts);
insideCounts[tagName] = insideCounts[tagName] - 1;
});
path.pop();
}
return tree;
}
path.push(tree);
tree.slice(1).forEach(function (n) {
var tagName = n[0];
insideCounts[tagName] = (insideCounts[tagName] || 0) + 1;
parseTree(n, path, insideCounts);
insideCounts[tagName] = insideCounts[tagName] - 1;
});
path.pop();
}
return tree;
};
/**
Returns true if there's an invalid word boundary for a match.
@method invalidBoundary
@param {Object} args our arguments, including whether we care about boundaries
@param {Array} prev the previous content, if exists
@returns {Boolean} whether there is an invalid word boundary
**/
function invalidBoundary(args, prev) {
if (!args.wordBoundary && !args.spaceBoundary) { return; }
var last = prev[prev.length - 1];
if (typeof last !== "string") { return; }
if (args.wordBoundary && (!last.match(/\W$/))) { return true; }
if (args.spaceBoundary && (!last.match(/\s$/))) { return true; }
}
/**
An object used for rendering our dialects.
@ -110,7 +126,51 @@ Discourse.Dialect = {
dialect.options = opts;
var tree = parser.toHTMLTree(text, 'Discourse');
return parser.renderJsonML(parseTree(tree));
},
inlineRegexp: function(args) {
dialect.inline[args.start] = function(text, match, prev) {
if (invalidBoundary(args, prev)) { return; }
args.matcher.lastIndex = 0;
var m = args.matcher.exec(text);
if (m) {
var result = args.emitter.call(this, m);
if (result) {
return [m[0].length, result];
}
}
};
},
inlineReplace: function(args) {
var start = args.start || args.between,
stop = args.stop || args.between,
startLength = start.length;
dialect.inline[start] = function(text, match, prev) {
if (invalidBoundary(args, prev)) { return; }
var endPos = text.indexOf(stop, startLength);
if (endPos === -1) { return; }
var between = text.slice(startLength, endPos);
// If rawcontents is set, don't process inline
if (!args.rawContents) {
between = this.processInline(between);
}
var contents = args.emitter.call(this, between);
if (contents) {
return [endPos + startLength + 1, contents];
}
};
}
};
RSVP.EventTarget.mixin(Discourse.Dialect);

View File

@ -2,47 +2,20 @@
Supports Discourse's custom @mention syntax for calling out a user in a post.
It will add a special class to them, and create a link if the user is found in a
local map.
@event register
@namespace Discourse.Dialect
**/
Discourse.Dialect.on("register", function(event) {
Discourse.Dialect.inlineRegexp({
start: '@',
matcher: /^(@[A-Za-z0-9][A-Za-z0-9_]{2,14})/m,
wordBoundary: true,
var dialect = event.dialect,
MD = event.MD;
emitter: function(matches) {
var username = matches[1],
mentionLookup = this.dialect.options.mentionLookup || Discourse.Mention.lookupCache;
/**
Parses out @username mentions.
@method parseMentions
@param {String} text the text match
@param {Array} match the match found
@param {Array} prev the previous jsonML
@return {Array} an array containing how many chars we've replaced and the jsonML content for it.
@namespace Discourse.Dialect
**/
dialect.inline['@'] = function parseMentions(text, match, prev) {
// We only care about mentions on word boundaries
if (prev && (prev.length > 0)) {
var last = prev[prev.length - 1];
if (typeof last === "string" && (!last.match(/\W$/))) { return; }
if (mentionLookup(username.substr(1))) {
return ['a', {'class': 'mention', href: Discourse.getURL("/users/") + username.substr(1).toLowerCase()}, username];
} else {
return ['span', {'class': 'mention'}, username];
}
var pattern = /^(@[A-Za-z0-9][A-Za-z0-9_]{2,14})(?=(\W|$))/m,
m = pattern.exec(text);
if (m) {
var username = m[1],
mentionLookup = dialect.options.mentionLookup || Discourse.Mention.lookupCache;
if (mentionLookup(username.substr(1))) {
return [username.length, ['a', {'class': 'mention', href: Discourse.getURL("/users/") + username.substr(1).toLowerCase()}, username]];
} else {
return [username.length, ['span', {'class': 'mention'}, username]];
}
}
};
});
}
});

View File

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