From 8150d32b862d37650faeb500cdf3557729fdd1ec Mon Sep 17 00:00:00 2001 From: danielgrippi <danielgrippi@gmail.com> Date: Sun, 15 Jan 2012 18:28:58 -0800 Subject: [PATCH] using pageDown Markdown library, fixing autolinking. created app.helpers.textFormatter, which takes care of text formatting; functions can be called individually throughout the app --- config/assets.yml | 5 +- public/javascripts/app/app.js | 1 + .../javascripts/app/helpers/text_formatter.js | 38 + public/javascripts/app/views/content_view.js | 47 +- .../vendor/markdown/Markdown.Converter.js | 1332 +++++++++++++++++ .../vendor/markdown/Markdown.Sanitizer.js | 108 ++ .../app/helpers/text_formatter_spec.js | 109 ++ spec/javascripts/app/views/post_view_spec.js | 126 -- spec/javascripts/support/jasmine.yml | 3 +- 9 files changed, 1593 insertions(+), 176 deletions(-) create mode 100644 public/javascripts/app/helpers/text_formatter.js create mode 100644 public/javascripts/vendor/markdown/Markdown.Converter.js create mode 100644 public/javascripts/vendor/markdown/Markdown.Sanitizer.js create mode 100644 spec/javascripts/app/helpers/text_formatter_spec.js diff --git a/config/assets.yml b/config/assets.yml index 41dcbcf6c4..7cfd5a3e92 100644 --- a/config/assets.yml +++ b/config/assets.yml @@ -9,10 +9,9 @@ javascripts: main: - public/javascripts/vendor/underscore.js - public/javascripts/vendor/backbone.js - - - public/javascripts/vendor/markdown.js - + - public/javascripts/vendor/markdown/* - public/javascripts/app/app.js + - public/javascripts/app/helpers/* - public/javascripts/app/router.js - public/javascripts/app/views.js - public/javascripts/app/models/post.js diff --git a/public/javascripts/app/app.js b/public/javascripts/app/app.js index 2609be1c92..4779a36636 100644 --- a/public/javascripts/app/app.js +++ b/public/javascripts/app/app.js @@ -1,6 +1,7 @@ var app = { collections: {}, models: {}, + helpers: {}, views: {}, user: function(user) { diff --git a/public/javascripts/app/helpers/text_formatter.js b/public/javascripts/app/helpers/text_formatter.js new file mode 100644 index 0000000000..9fe59937d4 --- /dev/null +++ b/public/javascripts/app/helpers/text_formatter.js @@ -0,0 +1,38 @@ +(function(){ + var textFormatter = function textFormatter(model) { + var text = model.get("text"); + var mentions = model.get("mentioned_people"); + + return textFormatter.mentionify( + textFormatter.hashtagify( + textFormatter.markdownify(text) + ), mentions + ) + }; + + textFormatter.markdownify = function markdownify(text){ + var converter = Markdown.getSanitizingConverter(); + return converter.makeHtml(text) + }; + + textFormatter.hashtagify = function hashtagify(text){ + var utf8WordCharcters =/(\s|^|>)#([\u0080-\uFFFF|\w|-]+|<3)/g + return text.replace(utf8WordCharcters, function(hashtag, preceeder, tagText) { + return preceeder + "<a href='/tags/" + tagText + "' class='tag'>#" + tagText + "</a>" + }) + }; + + textFormatter.mentionify = function mentionify(text, mentions) { + var mentionRegex = /@\{([^;]+); ([^\}]+)\}/g + return text.replace(mentionRegex, function(mentionText, fullName, diasporaId) { + var personId = _.find(mentions, function(person){ + return person.diaspora_id == diasporaId + }).id + + return "<a href='/people/" + personId + "' class='mention'>" + fullName + "</a>" + }) + } + + app.helpers.textFormatter = textFormatter; +})(); + diff --git a/public/javascripts/app/views/content_view.js b/public/javascripts/app/views/content_view.js index 1d321d5907..461f7f6f85 100644 --- a/public/javascripts/app/views/content_view.js +++ b/public/javascripts/app/views/content_view.js @@ -2,54 +2,10 @@ app.views.Content = app.views.StreamObject.extend({ presenter : function(){ var model = this.model return _.extend(this.defaultPresenter(), { - text : metafyText(model.get("text")), + text : app.helpers.textFormatter(model), o_embed_html : embedHTML(model) }) - function metafyText(text) { - //we want it to return at least a <p> from markdown - text = text || "" - return urlify( - mentionify( - hashtagify( - markdownify(text) - ) - ) - ) - } - - function markdownify(text){ - //markdown returns falsy when it performs no substitutions, apparently... - return markdown.toHTML(text) || text - } - - function hashtagify(text){ - var utf8WordCharcters =/(\s|^|>)#([\u0080-\uFFFF|\w|-]+|<3)/g - return text.replace(utf8WordCharcters, function(hashtag, preceeder, tagText) { - return preceeder + "<a href='/tags/" + tagText + "' class='tag'>#" + tagText + "</a>" - }) - } - - function mentionify(text) { - var mentionRegex = /@\{([^;]+); ([^\}]+)\}/g - return text.replace(mentionRegex, function(mentionText, fullName, diasporaId) { - var personId = _.find(model.get("mentioned_people"), function(person){ - return person.diaspora_id == diasporaId - }).id - - return "<a href='/people/" + personId + "' class='mention'>" + fullName + "</a>" - }) - } - - function urlify(text) { - var urlRegex = /(=\s?'|=\s?")?[-a-zA-Z0-9@:%_\+.~#?&//=]{2,256}\.[a-z]{2,4}\b(\/[-a-zA-Z0-9@:%_\+.~#?(#!)&//=;]*)?/gi - return text.replace(urlRegex, function(url, preceeder, bang) { - if(preceeder) return url - var protocol = (url.search(/:\/\//) == -1 ? "http://" : "") - return "<a href='" + protocol + url + "' target=_blank>" + url + "</a>" - }) - } - function embedHTML(model){ if(!model.get("o_embed_cache")) { return ""; } return model.get("o_embed_cache").data.html @@ -57,7 +13,6 @@ app.views.Content = app.views.StreamObject.extend({ } }) - app.views.StatusMessage = app.views.Content.extend({ template_name : "#status-message-template" }); diff --git a/public/javascripts/vendor/markdown/Markdown.Converter.js b/public/javascripts/vendor/markdown/Markdown.Converter.js new file mode 100644 index 0000000000..34a624b3a5 --- /dev/null +++ b/public/javascripts/vendor/markdown/Markdown.Converter.js @@ -0,0 +1,1332 @@ +var Markdown; + +if (typeof exports === "object" && typeof require === "function") // we're in a CommonJS (e.g. Node.js) module + Markdown = exports; +else + Markdown = {}; + +// The following text is included for historical reasons, but should +// be taken with a pinch of salt; it's not all true anymore. + +// +// Wherever possible, Showdown is a straight, line-by-line port +// of the Perl version of Markdown. +// +// This is not a normal parser design; it's basically just a +// series of string substitutions. It's hard to read and +// maintain this way, but keeping Showdown close to the original +// design makes it easier to port new features. +// +// More importantly, Showdown behaves like markdown.pl in most +// edge cases. So web applications can do client-side preview +// in Javascript, and then build identical HTML on the server. +// +// This port needs the new RegExp functionality of ECMA 262, +// 3rd Edition (i.e. Javascript 1.5). Most modern web browsers +// should do fine. Even with the new regular expression features, +// We do a lot of work to emulate Perl's regex functionality. +// The tricky changes in this file mostly have the "attacklab:" +// label. Major or self-explanatory changes don't. +// +// Smart diff tools like Araxis Merge will be able to match up +// this file with markdown.pl in a useful way. A little tweaking +// helps: in a copy of markdown.pl, replace "#" with "//" and +// replace "$text" with "text". Be sure to ignore whitespace +// and line endings. +// + + +// +// Usage: +// +// var text = "Markdown *rocks*."; +// +// var converter = new Markdown.Converter(); +// var html = converter.makeHtml(text); +// +// alert(html); +// +// Note: move the sample code to the bottom of this +// file before uncommenting it. +// + +(function () { + + function identity(x) { return x; } + function returnFalse(x) { return false; } + + function HookCollection() { } + + HookCollection.prototype = { + + chain: function (hookname, func) { + var original = this[hookname]; + if (!original) + throw new Error("unknown hook " + hookname); + + if (original === identity) + this[hookname] = func; + else + this[hookname] = function (x) { return func(original(x)); } + }, + set: function (hookname, func) { + if (!this[hookname]) + throw new Error("unknown hook " + hookname); + this[hookname] = func; + }, + addNoop: function (hookname) { + this[hookname] = identity; + }, + addFalse: function (hookname) { + this[hookname] = returnFalse; + } + }; + + Markdown.HookCollection = HookCollection; + + // g_urls and g_titles allow arbitrary user-entered strings as keys. This + // caused an exception (and hence stopped the rendering) when the user entered + // e.g. [push] or [__proto__]. Adding a prefix to the actual key prevents this + // (since no builtin property starts with "s_"). See + // http://meta.stackoverflow.com/questions/64655/strange-wmd-bug + // (granted, switching from Array() to Object() alone would have left only __proto__ + // to be a problem) + function SaveHash() { } + SaveHash.prototype = { + set: function (key, value) { + this["s_" + key] = value; + }, + get: function (key) { + return this["s_" + key]; + } + }; + + Markdown.Converter = function () { + var pluginHooks = this.hooks = new HookCollection(); + pluginHooks.addNoop("plainLinkText"); // given a URL that was encountered by itself (without markup), should return the link text that's to be given to this link + pluginHooks.addNoop("preConversion"); // called with the orignal text as given to makeHtml. The result of this plugin hook is the actual markdown source that will be cooked + pluginHooks.addNoop("postConversion"); // called with the final cooked HTML code. The result of this plugin hook is the actual output of makeHtml + + // + // Private state of the converter instance: + // + + // Global hashes, used by various utility routines + var g_urls; + var g_titles; + var g_html_blocks; + + // Used to track when we're inside an ordered or unordered list + // (see _ProcessListItems() for details): + var g_list_level; + + this.makeHtml = function (text) { + + // + // Main function. The order in which other subs are called here is + // essential. Link and image substitutions need to happen before + // _EscapeSpecialCharsWithinTagAttributes(), so that any *'s or _'s in the <a> + // and <img> tags get encoded. + // + + // This will only happen if makeHtml on the same converter instance is called from a plugin hook. + // Don't do that. + if (g_urls) + throw new Error("Recursive call to converter.makeHtml"); + + // Create the private state objects. + g_urls = new SaveHash(); + g_titles = new SaveHash(); + g_html_blocks = []; + g_list_level = 0; + + text = pluginHooks.preConversion(text); + + // attacklab: Replace ~ with ~T + // This lets us use tilde as an escape char to avoid md5 hashes + // The choice of character is arbitray; anything that isn't + // magic in Markdown will work. + text = text.replace(/~/g, "~T"); + + // attacklab: Replace $ with ~D + // RegExp interprets $ as a special character + // when it's in a replacement string + text = text.replace(/\$/g, "~D"); + + // Standardize line endings + text = text.replace(/\r\n/g, "\n"); // DOS to Unix + text = text.replace(/\r/g, "\n"); // Mac to Unix + + // Make sure text begins and ends with a couple of newlines: + text = "\n\n" + text + "\n\n"; + + // Convert all tabs to spaces. + text = _Detab(text); + + // Strip any lines consisting only of spaces and tabs. + // This makes subsequent regexen easier to write, because we can + // match consecutive blank lines with /\n+/ instead of something + // contorted like /[ \t]*\n+/ . + text = text.replace(/^[ \t]+$/mg, ""); + + // Turn block-level HTML blocks into hash entries + text = _HashHTMLBlocks(text); + + // Strip link definitions, store in hashes. + text = _StripLinkDefinitions(text); + + text = _RunBlockGamut(text); + + text = _UnescapeSpecialChars(text); + + // attacklab: Restore dollar signs + text = text.replace(/~D/g, "$$"); + + // attacklab: Restore tildes + text = text.replace(/~T/g, "~"); + + text = pluginHooks.postConversion(text); + + g_html_blocks = g_titles = g_urls = null; + + return text; + }; + + function _StripLinkDefinitions(text) { + // + // Strips link definitions from text, stores the URLs and titles in + // hash references. + // + + // Link defs are in the form: ^[id]: url "optional title" + + /* + text = text.replace(/ + ^[ ]{0,3}\[(.+)\]: // id = $1 attacklab: g_tab_width - 1 + [ \t]* + \n? // maybe *one* newline + [ \t]* + <?(\S+?)>? // url = $2 + (?=\s|$) // lookahead for whitespace instead of the lookbehind removed below + [ \t]* + \n? // maybe one newline + [ \t]* + ( // (potential) title = $3 + (\n*) // any lines skipped = $4 attacklab: lookbehind removed + [ \t]+ + ["(] + (.+?) // title = $5 + [")] + [ \t]* + )? // title is optional + (?:\n+|$) + /gm, function(){...}); + */ + + text = text.replace(/^[ ]{0,3}\[(.+)\]:[ \t]*\n?[ \t]*<?(\S+?)>?(?=\s|$)[ \t]*\n?[ \t]*((\n*)["(](.+?)[")][ \t]*)?(?:\n+)/gm, + function (wholeMatch, m1, m2, m3, m4, m5) { + m1 = m1.toLowerCase(); + g_urls.set(m1, _EncodeAmpsAndAngles(m2)); // Link IDs are case-insensitive + if (m4) { + // Oops, found blank lines, so it's not a title. + // Put back the parenthetical statement we stole. + return m3; + } else if (m5) { + g_titles.set(m1, m5.replace(/"/g, """)); + } + + // Completely remove the definition from the text + return ""; + } + ); + + return text; + } + + function _HashHTMLBlocks(text) { + + // Hashify HTML blocks: + // We only want to do this for block-level HTML tags, such as headers, + // lists, and tables. That's because we still want to wrap <p>s around + // "paragraphs" that are wrapped in non-block-level tags, such as anchors, + // phrase emphasis, and spans. The list of tags we're looking for is + // hard-coded: + var block_tags_a = "p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math|ins|del" + var block_tags_b = "p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math" + + // First, look for nested blocks, e.g.: + // <div> + // <div> + // tags for inner block must be indented. + // </div> + // </div> + // + // The outermost tags must start at the left margin for this to match, and + // the inner nested divs must be indented. + // We need to do this before the next, more liberal match, because the next + // match will start at the first `<div>` and stop at the first `</div>`. + + // attacklab: This regex can be expensive when it fails. + + /* + text = text.replace(/ + ( // save in $1 + ^ // start of line (with /m) + <($block_tags_a) // start tag = $2 + \b // word break + // attacklab: hack around khtml/pcre bug... + [^\r]*?\n // any number of lines, minimally matching + </\2> // the matching end tag + [ \t]* // trailing spaces/tabs + (?=\n+) // followed by a newline + ) // attacklab: there are sentinel newlines at end of document + /gm,function(){...}}; + */ + text = text.replace(/^(<(p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math|ins|del)\b[^\r]*?\n<\/\2>[ \t]*(?=\n+))/gm, hashElement); + + // + // Now match more liberally, simply from `\n<tag>` to `</tag>\n` + // + + /* + text = text.replace(/ + ( // save in $1 + ^ // start of line (with /m) + <($block_tags_b) // start tag = $2 + \b // word break + // attacklab: hack around khtml/pcre bug... + [^\r]*? // any number of lines, minimally matching + .*</\2> // the matching end tag + [ \t]* // trailing spaces/tabs + (?=\n+) // followed by a newline + ) // attacklab: there are sentinel newlines at end of document + /gm,function(){...}}; + */ + text = text.replace(/^(<(p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math)\b[^\r]*?.*<\/\2>[ \t]*(?=\n+)\n)/gm, hashElement); + + // Special case just for <hr />. It was easier to make a special case than + // to make the other regex more complicated. + + /* + text = text.replace(/ + \n // Starting after a blank line + [ ]{0,3} + ( // save in $1 + (<(hr) // start tag = $2 + \b // word break + ([^<>])*? + \/?>) // the matching end tag + [ \t]* + (?=\n{2,}) // followed by a blank line + ) + /g,hashElement); + */ + text = text.replace(/\n[ ]{0,3}((<(hr)\b([^<>])*?\/?>)[ \t]*(?=\n{2,}))/g, hashElement); + + // Special case for standalone HTML comments: + + /* + text = text.replace(/ + \n\n // Starting after a blank line + [ ]{0,3} // attacklab: g_tab_width - 1 + ( // save in $1 + <! + (--(?:|(?:[^>-]|-[^>])(?:[^-]|-[^-])*)--) // see http://www.w3.org/TR/html-markup/syntax.html#comments and http://meta.stackoverflow.com/q/95256 + > + [ \t]* + (?=\n{2,}) // followed by a blank line + ) + /g,hashElement); + */ + text = text.replace(/\n\n[ ]{0,3}(<!(--(?:|(?:[^>-]|-[^>])(?:[^-]|-[^-])*)--)>[ \t]*(?=\n{2,}))/g, hashElement); + + // PHP and ASP-style processor instructions (<?...?> and <%...%>) + + /* + text = text.replace(/ + (?: + \n\n // Starting after a blank line + ) + ( // save in $1 + [ ]{0,3} // attacklab: g_tab_width - 1 + (?: + <([?%]) // $2 + [^\r]*? + \2> + ) + [ \t]* + (?=\n{2,}) // followed by a blank line + ) + /g,hashElement); + */ + text = text.replace(/(?:\n\n)([ ]{0,3}(?:<([?%])[^\r]*?\2>)[ \t]*(?=\n{2,}))/g, hashElement); + + return text; + } + + function hashElement(wholeMatch, m1) { + var blockText = m1; + + // Undo double lines + blockText = blockText.replace(/^\n+/, ""); + + // strip trailing blank lines + blockText = blockText.replace(/\n+$/g, ""); + + // Replace the element text with a marker ("~KxK" where x is its key) + blockText = "\n\n~K" + (g_html_blocks.push(blockText) - 1) + "K\n\n"; + + return blockText; + } + + function _RunBlockGamut(text, doNotUnhash) { + // + // These are all the transformations that form block-level + // tags like paragraphs, headers, and list items. + // + text = _DoHeaders(text); + + // Do Horizontal Rules: + var replacement = "<hr />\n"; + text = text.replace(/^[ ]{0,2}([ ]?\*[ ]?){3,}[ \t]*$/gm, replacement); + text = text.replace(/^[ ]{0,2}([ ]?-[ ]?){3,}[ \t]*$/gm, replacement); + text = text.replace(/^[ ]{0,2}([ ]?_[ ]?){3,}[ \t]*$/gm, replacement); + + text = _DoLists(text); + text = _DoCodeBlocks(text); + text = _DoBlockQuotes(text); + + // We already ran _HashHTMLBlocks() before, in Markdown(), but that + // was to escape raw HTML in the original Markdown source. This time, + // we're escaping the markup we've just created, so that we don't wrap + // <p> tags around block-level tags. + text = _HashHTMLBlocks(text); + text = _FormParagraphs(text, doNotUnhash); + + return text; + } + + function _RunSpanGamut(text) { + // + // These are all the transformations that occur *within* block-level + // tags like paragraphs, headers, and list items. + // + + text = _DoCodeSpans(text); + text = _EscapeSpecialCharsWithinTagAttributes(text); + text = _EncodeBackslashEscapes(text); + + // Process anchor and image tags. Images must come first, + // because ![foo][f] looks like an anchor. + text = _DoImages(text); + text = _DoAnchors(text); + + // Make links out of things like `<http://example.com/>` + // Must come after _DoAnchors(), because you can use < and > + // delimiters in inline links like [this](<url>). + text = _DoAutoLinks(text); + + text = text.replace(/~P/g, "://"); // put in place to prevent autolinking; reset now + + text = _EncodeAmpsAndAngles(text); + text = _DoItalicsAndBold(text); + + // Do hard breaks: + text = text.replace(/ +\n/g, " <br>\n"); + + return text; + } + + function _EscapeSpecialCharsWithinTagAttributes(text) { + // + // Within tags -- meaning between < and > -- encode [\ ` * _] so they + // don't conflict with their use in Markdown for code, italics and strong. + // + + // Build a regex to find HTML tags and comments. See Friedl's + // "Mastering Regular Expressions", 2nd Ed., pp. 200-201. + + // SE: changed the comment part of the regex + + var regex = /(<[a-z\/!$]("[^"]*"|'[^']*'|[^'">])*>|<!(--(?:|(?:[^>-]|-[^>])(?:[^-]|-[^-])*)--)>)/gi; + + text = text.replace(regex, function (wholeMatch) { + var tag = wholeMatch.replace(/(.)<\/?code>(?=.)/g, "$1`"); + tag = escapeCharacters(tag, wholeMatch.charAt(1) == "!" ? "\\`*_/" : "\\`*_"); // also escape slashes in comments to prevent autolinking there -- http://meta.stackoverflow.com/questions/95987 + return tag; + }); + + return text; + } + + function _DoAnchors(text) { + // + // Turn Markdown link shortcuts into XHTML <a> tags. + // + // + // First, handle reference-style links: [link text] [id] + // + + /* + text = text.replace(/ + ( // wrap whole match in $1 + \[ + ( + (?: + \[[^\]]*\] // allow brackets nested one level + | + [^\[] // or anything else + )* + ) + \] + + [ ]? // one optional space + (?:\n[ ]*)? // one optional newline followed by spaces + + \[ + (.*?) // id = $3 + \] + ) + ()()()() // pad remaining backreferences + /g, writeAnchorTag); + */ + text = text.replace(/(\[((?:\[[^\]]*\]|[^\[\]])*)\][ ]?(?:\n[ ]*)?\[(.*?)\])()()()()/g, writeAnchorTag); + + // + // Next, inline-style links: [link text](url "optional title") + // + + /* + text = text.replace(/ + ( // wrap whole match in $1 + \[ + ( + (?: + \[[^\]]*\] // allow brackets nested one level + | + [^\[\]] // or anything else + )* + ) + \] + \( // literal paren + [ \t]* + () // no id, so leave $3 empty + <?( // href = $4 + (?: + \([^)]*\) // allow one level of (correctly nested) parens (think MSDN) + | + [^()] + )*? + )>? + [ \t]* + ( // $5 + (['"]) // quote char = $6 + (.*?) // Title = $7 + \6 // matching quote + [ \t]* // ignore any spaces/tabs between closing quote and ) + )? // title is optional + \) + ) + /g, writeAnchorTag); + */ + + text = text.replace(/(\[((?:\[[^\]]*\]|[^\[\]])*)\]\([ \t]*()<?((?:\([^)]*\)|[^()])*?)>?[ \t]*((['"])(.*?)\6[ \t]*)?\))/g, writeAnchorTag); + + // + // Last, handle reference-style shortcuts: [link text] + // These must come last in case you've also got [link test][1] + // or [link test](/foo) + // + + /* + text = text.replace(/ + ( // wrap whole match in $1 + \[ + ([^\[\]]+) // link text = $2; can't contain '[' or ']' + \] + ) + ()()()()() // pad rest of backreferences + /g, writeAnchorTag); + */ + text = text.replace(/(\[([^\[\]]+)\])()()()()()/g, writeAnchorTag); + + return text; + } + + function writeAnchorTag(wholeMatch, m1, m2, m3, m4, m5, m6, m7) { + if (m7 == undefined) m7 = ""; + var whole_match = m1; + var link_text = m2.replace(/:\/\//g, "~P"); // to prevent auto-linking withing the link. will be converted back after the auto-linker runs + var link_id = m3.toLowerCase(); + var url = m4; + var title = m7; + + if (url == "") { + if (link_id == "") { + // lower-case and turn embedded newlines into spaces + link_id = link_text.toLowerCase().replace(/ ?\n/g, " "); + } + url = "#" + link_id; + + if (g_urls.get(link_id) != undefined) { + url = g_urls.get(link_id); + if (g_titles.get(link_id) != undefined) { + title = g_titles.get(link_id); + } + } + else { + if (whole_match.search(/\(\s*\)$/m) > -1) { + // Special case for explicit empty url + url = ""; + } else { + return whole_match; + } + } + } + url = encodeProblemUrlChars(url); + url = escapeCharacters(url, "*_"); + var result = "<a href=\"" + url + "\""; + + if (title != "") { + title = attributeEncode(title); + title = escapeCharacters(title, "*_"); + result += " title=\"" + title + "\""; + } + + result += ">" + link_text + "</a>"; + + return result; + } + + function _DoImages(text) { + // + // Turn Markdown image shortcuts into <img> tags. + // + + // + // First, handle reference-style labeled images: ![alt text][id] + // + + /* + text = text.replace(/ + ( // wrap whole match in $1 + !\[ + (.*?) // alt text = $2 + \] + + [ ]? // one optional space + (?:\n[ ]*)? // one optional newline followed by spaces + + \[ + (.*?) // id = $3 + \] + ) + ()()()() // pad rest of backreferences + /g, writeImageTag); + */ + text = text.replace(/(!\[(.*?)\][ ]?(?:\n[ ]*)?\[(.*?)\])()()()()/g, writeImageTag); + + // + // Next, handle inline images:  + // Don't forget: encode * and _ + + /* + text = text.replace(/ + ( // wrap whole match in $1 + !\[ + (.*?) // alt text = $2 + \] + \s? // One optional whitespace character + \( // literal paren + [ \t]* + () // no id, so leave $3 empty + <?(\S+?)>? // src url = $4 + [ \t]* + ( // $5 + (['"]) // quote char = $6 + (.*?) // title = $7 + \6 // matching quote + [ \t]* + )? // title is optional + \) + ) + /g, writeImageTag); + */ + text = text.replace(/(!\[(.*?)\]\s?\([ \t]*()<?(\S+?)>?[ \t]*((['"])(.*?)\6[ \t]*)?\))/g, writeImageTag); + + return text; + } + + function attributeEncode(text) { + // unconditionally replace angle brackets here -- what ends up in an attribute (e.g. alt or title) + // never makes sense to have verbatim HTML in it (and the sanitizer would totally break it) + return text.replace(/>/g, ">").replace(/</g, "<").replace(/"/g, """); + } + + function writeImageTag(wholeMatch, m1, m2, m3, m4, m5, m6, m7) { + var whole_match = m1; + var alt_text = m2; + var link_id = m3.toLowerCase(); + var url = m4; + var title = m7; + + if (!title) title = ""; + + if (url == "") { + if (link_id == "") { + // lower-case and turn embedded newlines into spaces + link_id = alt_text.toLowerCase().replace(/ ?\n/g, " "); + } + url = "#" + link_id; + + if (g_urls.get(link_id) != undefined) { + url = g_urls.get(link_id); + if (g_titles.get(link_id) != undefined) { + title = g_titles.get(link_id); + } + } + else { + return whole_match; + } + } + + alt_text = escapeCharacters(attributeEncode(alt_text), "*_[]()"); + url = escapeCharacters(url, "*_"); + var result = "<img src=\"" + url + "\" alt=\"" + alt_text + "\""; + + // attacklab: Markdown.pl adds empty title attributes to images. + // Replicate this bug. + + //if (title != "") { + title = attributeEncode(title); + title = escapeCharacters(title, "*_"); + result += " title=\"" + title + "\""; + //} + + result += " />"; + + return result; + } + + function _DoHeaders(text) { + + // Setext-style headers: + // Header 1 + // ======== + // + // Header 2 + // -------- + // + text = text.replace(/^(.+)[ \t]*\n=+[ \t]*\n+/gm, + function (wholeMatch, m1) { return "<h1>" + _RunSpanGamut(m1) + "</h1>\n\n"; } + ); + + text = text.replace(/^(.+)[ \t]*\n-+[ \t]*\n+/gm, + function (matchFound, m1) { return "<h2>" + _RunSpanGamut(m1) + "</h2>\n\n"; } + ); + + // atx-style headers: + // # Header 1 + // ## Header 2 + // ## Header 2 with closing hashes ## + // ... + // ###### Header 6 + // + + /* + text = text.replace(/ + ^(\#{1,6}) // $1 = string of #'s + [ \t]* + (.+?) // $2 = Header text + [ \t]* + \#* // optional closing #'s (not counted) + \n+ + /gm, function() {...}); + */ + + text = text.replace(/^(\#{1,6})[ \t]+(.+?)[ \t]*\#*\n+/gm, + function (wholeMatch, m1, m2) { + var h_level = m1.length; + return "<h" + h_level + ">" + _RunSpanGamut(m2) + "</h" + h_level + ">\n\n"; + } + ); + + return text; + } + + function _DoLists(text) { + // + // Form HTML ordered (numbered) and unordered (bulleted) lists. + // + + // attacklab: add sentinel to hack around khtml/safari bug: + // http://bugs.webkit.org/show_bug.cgi?id=11231 + text += "~0"; + + // Re-usable pattern to match any entirel ul or ol list: + + /* + var whole_list = / + ( // $1 = whole list + ( // $2 + [ ]{0,3} // attacklab: g_tab_width - 1 + ([*+-]|\d+[.]) // $3 = first list item marker + [ \t]+ + ) + [^\r]+? + ( // $4 + ~0 // sentinel for workaround; should be $ + | + \n{2,} + (?=\S) + (?! // Negative lookahead for another list item marker + [ \t]* + (?:[*+-]|\d+[.])[ \t]+ + ) + ) + ) + /g + */ + var whole_list = /^(([ ]{0,3}([*+-]|\d+[.])[ \t]+)[^\r]+?(~0|\n{2,}(?=\S)(?![ \t]*(?:[*+-]|\d+[.])[ \t]+)))/gm; + + if (g_list_level) { + text = text.replace(whole_list, function (wholeMatch, m1, m2) { + var list = m1; + var list_type = (m2.search(/[*+-]/g) > -1) ? "ul" : "ol"; + + var result = _ProcessListItems(list, list_type); + + // Trim any trailing whitespace, to put the closing `</$list_type>` + // up on the preceding line, to get it past the current stupid + // HTML block parser. This is a hack to work around the terrible + // hack that is the HTML block parser. + result = result.replace(/\s+$/, ""); + result = "<" + list_type + ">" + result + "</" + list_type + ">\n"; + return result; + }); + } else { + whole_list = /(\n\n|^\n?)(([ ]{0,3}([*+-]|\d+[.])[ \t]+)[^\r]+?(~0|\n{2,}(?=\S)(?![ \t]*(?:[*+-]|\d+[.])[ \t]+)))/g; + text = text.replace(whole_list, function (wholeMatch, m1, m2, m3) { + var runup = m1; + var list = m2; + + var list_type = (m3.search(/[*+-]/g) > -1) ? "ul" : "ol"; + var result = _ProcessListItems(list, list_type); + result = runup + "<" + list_type + ">\n" + result + "</" + list_type + ">\n"; + return result; + }); + } + + // attacklab: strip sentinel + text = text.replace(/~0/, ""); + + return text; + } + + var _listItemMarkers = { ol: "\\d+[.]", ul: "[*+-]" }; + + function _ProcessListItems(list_str, list_type) { + // + // Process the contents of a single ordered or unordered list, splitting it + // into individual list items. + // + // list_type is either "ul" or "ol". + + // The $g_list_level global keeps track of when we're inside a list. + // Each time we enter a list, we increment it; when we leave a list, + // we decrement. If it's zero, we're not in a list anymore. + // + // We do this because when we're not inside a list, we want to treat + // something like this: + // + // I recommend upgrading to version + // 8. Oops, now this line is treated + // as a sub-list. + // + // As a single paragraph, despite the fact that the second line starts + // with a digit-period-space sequence. + // + // Whereas when we're inside a list (or sub-list), that line will be + // treated as the start of a sub-list. What a kludge, huh? This is + // an aspect of Markdown's syntax that's hard to parse perfectly + // without resorting to mind-reading. Perhaps the solution is to + // change the syntax rules such that sub-lists must start with a + // starting cardinal number; e.g. "1." or "a.". + + g_list_level++; + + // trim trailing blank lines: + list_str = list_str.replace(/\n{2,}$/, "\n"); + + // attacklab: add sentinel to emulate \z + list_str += "~0"; + + // In the original attacklab showdown, list_type was not given to this function, and anything + // that matched /[*+-]|\d+[.]/ would just create the next <li>, causing this mismatch: + // + // Markdown rendered by WMD rendered by MarkdownSharp + // ------------------------------------------------------------------ + // 1. first 1. first 1. first + // 2. second 2. second 2. second + // - third 3. third * third + // + // We changed this to behave identical to MarkdownSharp. This is the constructed RegEx, + // with {MARKER} being one of \d+[.] or [*+-], depending on list_type: + + /* + list_str = list_str.replace(/ + (^[ \t]*) // leading whitespace = $1 + ({MARKER}) [ \t]+ // list marker = $2 + ([^\r]+? // list item text = $3 + (\n+) + ) + (?= + (~0 | \2 ({MARKER}) [ \t]+) + ) + /gm, function(){...}); + */ + + var marker = _listItemMarkers[list_type]; + var re = new RegExp("(^[ \\t]*)(" + marker + ")[ \\t]+([^\\r]+?(\\n+))(?=(~0|\\1(" + marker + ")[ \\t]+))", "gm"); + var last_item_had_a_double_newline = false; + list_str = list_str.replace(re, + function (wholeMatch, m1, m2, m3) { + var item = m3; + var leading_space = m1; + var ends_with_double_newline = /\n\n$/.test(item); + var contains_double_newline = ends_with_double_newline || item.search(/\n{2,}/) > -1; + + if (contains_double_newline || last_item_had_a_double_newline) { + item = _RunBlockGamut(_Outdent(item), /* doNotUnhash = */true); + } + else { + // Recursion for sub-lists: + item = _DoLists(_Outdent(item)); + item = item.replace(/\n$/, ""); // chomp(item) + item = _RunSpanGamut(item); + } + last_item_had_a_double_newline = ends_with_double_newline; + return "<li>" + item + "</li>\n"; + } + ); + + // attacklab: strip sentinel + list_str = list_str.replace(/~0/g, ""); + + g_list_level--; + return list_str; + } + + function _DoCodeBlocks(text) { + // + // Process Markdown `<pre><code>` blocks. + // + + /* + text = text.replace(/ + (?:\n\n|^) + ( // $1 = the code block -- one or more lines, starting with a space/tab + (?: + (?:[ ]{4}|\t) // Lines must start with a tab or a tab-width of spaces - attacklab: g_tab_width + .*\n+ + )+ + ) + (\n*[ ]{0,3}[^ \t\n]|(?=~0)) // attacklab: g_tab_width + /g ,function(){...}); + */ + + // attacklab: sentinel workarounds for lack of \A and \Z, safari\khtml bug + text += "~0"; + + text = text.replace(/(?:\n\n|^)((?:(?:[ ]{4}|\t).*\n+)+)(\n*[ ]{0,3}[^ \t\n]|(?=~0))/g, + function (wholeMatch, m1, m2) { + var codeblock = m1; + var nextChar = m2; + + codeblock = _EncodeCode(_Outdent(codeblock)); + codeblock = _Detab(codeblock); + codeblock = codeblock.replace(/^\n+/g, ""); // trim leading newlines + codeblock = codeblock.replace(/\n+$/g, ""); // trim trailing whitespace + + codeblock = "<pre><code>" + codeblock + "\n</code></pre>"; + + return "\n\n" + codeblock + "\n\n" + nextChar; + } + ); + + // attacklab: strip sentinel + text = text.replace(/~0/, ""); + + return text; + } + + function hashBlock(text) { + text = text.replace(/(^\n+|\n+$)/g, ""); + return "\n\n~K" + (g_html_blocks.push(text) - 1) + "K\n\n"; + } + + function _DoCodeSpans(text) { + // + // * Backtick quotes are used for <code></code> spans. + // + // * You can use multiple backticks as the delimiters if you want to + // include literal backticks in the code span. So, this input: + // + // Just type ``foo `bar` baz`` at the prompt. + // + // Will translate to: + // + // <p>Just type <code>foo `bar` baz</code> at the prompt.</p> + // + // There's no arbitrary limit to the number of backticks you + // can use as delimters. If you need three consecutive backticks + // in your code, use four for delimiters, etc. + // + // * You can use spaces to get literal backticks at the edges: + // + // ... type `` `bar` `` ... + // + // Turns to: + // + // ... type <code>`bar`</code> ... + // + + /* + text = text.replace(/ + (^|[^\\]) // Character before opening ` can't be a backslash + (`+) // $2 = Opening run of ` + ( // $3 = The code block + [^\r]*? + [^`] // attacklab: work around lack of lookbehind + ) + \2 // Matching closer + (?!`) + /gm, function(){...}); + */ + + text = text.replace(/(^|[^\\])(`+)([^\r]*?[^`])\2(?!`)/gm, + function (wholeMatch, m1, m2, m3, m4) { + var c = m3; + c = c.replace(/^([ \t]*)/g, ""); // leading whitespace + c = c.replace(/[ \t]*$/g, ""); // trailing whitespace + c = _EncodeCode(c); + c = c.replace(/:\/\//g, "~P"); // to prevent auto-linking. Not necessary in code *blocks*, but in code spans. Will be converted back after the auto-linker runs. + return m1 + "<code>" + c + "</code>"; + } + ); + + return text; + } + + function _EncodeCode(text) { + // + // Encode/escape certain characters inside Markdown code runs. + // The point is that in code, these characters are literals, + // and lose their special Markdown meanings. + // + // Encode all ampersands; HTML entities are not + // entities within a Markdown code span. + text = text.replace(/&/g, "&"); + + // Do the angle bracket song and dance: + text = text.replace(/</g, "<"); + text = text.replace(/>/g, ">"); + + // Now, escape characters that are magic in Markdown: + text = escapeCharacters(text, "\*_{}[]\\", false); + + // jj the line above breaks this: + //--- + + //* Item + + // 1. Subitem + + // special char: * + //--- + + return text; + } + + function _DoItalicsAndBold(text) { + + // <strong> must go first: + text = text.replace(/([\W_]|^)(\*\*|__)(?=\S)([^\r]*?\S[\*_]*)\2([\W_]|$)/g, + "$1<strong>$3</strong>$4"); + + text = text.replace(/([\W_]|^)(\*|_)(?=\S)([^\r\*_]*?\S)\2([\W_]|$)/g, + "$1<em>$3</em>$4"); + + return text; + } + + function _DoBlockQuotes(text) { + + /* + text = text.replace(/ + ( // Wrap whole match in $1 + ( + ^[ \t]*>[ \t]? // '>' at the start of a line + .+\n // rest of the first line + (.+\n)* // subsequent consecutive lines + \n* // blanks + )+ + ) + /gm, function(){...}); + */ + + text = text.replace(/((^[ \t]*>[ \t]?.+\n(.+\n)*\n*)+)/gm, + function (wholeMatch, m1) { + var bq = m1; + + // attacklab: hack around Konqueror 3.5.4 bug: + // "----------bug".replace(/^-/g,"") == "bug" + + bq = bq.replace(/^[ \t]*>[ \t]?/gm, "~0"); // trim one level of quoting + + // attacklab: clean up hack + bq = bq.replace(/~0/g, ""); + + bq = bq.replace(/^[ \t]+$/gm, ""); // trim whitespace-only lines + bq = _RunBlockGamut(bq); // recurse + + bq = bq.replace(/(^|\n)/g, "$1 "); + // These leading spaces screw with <pre> content, so we need to fix that: + bq = bq.replace( + /(\s*<pre>[^\r]+?<\/pre>)/gm, + function (wholeMatch, m1) { + var pre = m1; + // attacklab: hack around Konqueror 3.5.4 bug: + pre = pre.replace(/^ /mg, "~0"); + pre = pre.replace(/~0/g, ""); + return pre; + }); + + return hashBlock("<blockquote>\n" + bq + "\n</blockquote>"); + } + ); + return text; + } + + function _FormParagraphs(text, doNotUnhash) { + // + // Params: + // $text - string to process with html <p> tags + // + + // Strip leading and trailing lines: + text = text.replace(/^\n+/g, ""); + text = text.replace(/\n+$/g, ""); + + var grafs = text.split(/\n{2,}/g); + var grafsOut = []; + + var markerRe = /~K(\d+)K/; + + // + // Wrap <p> tags. + // + var end = grafs.length; + for (var i = 0; i < end; i++) { + var str = grafs[i]; + + // if this is an HTML marker, copy it + if (markerRe.test(str)) { + grafsOut.push(str); + } + else if (/\S/.test(str)) { + str = _RunSpanGamut(str); + str = str.replace(/^([ \t]*)/g, "<p>"); + str += "</p>" + grafsOut.push(str); + } + + } + // + // Unhashify HTML blocks + // + if (!doNotUnhash) { + end = grafsOut.length; + for (var i = 0; i < end; i++) { + var foundAny = true; + while (foundAny) { // we may need several runs, since the data may be nested + foundAny = false; + grafsOut[i] = grafsOut[i].replace(/~K(\d+)K/g, function (wholeMatch, id) { + foundAny = true; + return g_html_blocks[id]; + }); + } + } + } + return grafsOut.join("\n\n"); + } + + function _EncodeAmpsAndAngles(text) { + // Smart processing for ampersands and angle brackets that need to be encoded. + + // Ampersand-encoding based entirely on Nat Irons's Amputator MT plugin: + // http://bumppo.net/projects/amputator/ + text = text.replace(/&(?!#?[xX]?(?:[0-9a-fA-F]+|\w+);)/g, "&"); + + // Encode naked <'s + text = text.replace(/<(?![a-z\/?\$!])/gi, "<"); + + return text; + } + + function _EncodeBackslashEscapes(text) { + // + // Parameter: String. + // Returns: The string, with after processing the following backslash + // escape sequences. + // + + // attacklab: The polite way to do this is with the new + // escapeCharacters() function: + // + // text = escapeCharacters(text,"\\",true); + // text = escapeCharacters(text,"`*_{}[]()>#+-.!",true); + // + // ...but we're sidestepping its use of the (slow) RegExp constructor + // as an optimization for Firefox. This function gets called a LOT. + + text = text.replace(/\\(\\)/g, escapeCharacters_callback); + text = text.replace(/\\([`*_{}\[\]()>#+-.!])/g, escapeCharacters_callback); + return text; + } + + function _DoAutoLinks(text) { + + // note that at this point, all other URL in the text are already hyperlinked as <a href=""></a> + // *except* for the <http://www.foo.com> case + + // automatically add < and > around unadorned raw hyperlinks + // must be preceded by space/BOF and followed by non-word/EOF character + text = text.replace(/(^|\s)(https?|ftp)(:\/\/[-A-Z0-9+&@#\/%?=~_|\[\]\(\)!:,\.;]*[-A-Z0-9+&@#\/%=~_|\[\]])($|\W)/gi, "$1<$2$3>$4"); + + // autolink anything like <http://example.com> + + var replacer = function (wholematch, m1) { return "<a href=\"" + m1 + "\">" + pluginHooks.plainLinkText(m1) + "</a>"; } + text = text.replace(/<((https?|ftp):[^'">\s]+)>/gi, replacer); + + // Email addresses: <address@domain.foo> + /* + text = text.replace(/ + < + (?:mailto:)? + ( + [-.\w]+ + \@ + [-a-z0-9]+(\.[-a-z0-9]+)*\.[a-z]+ + ) + > + /gi, _DoAutoLinks_callback()); + */ + + /* disabling email autolinking, since we don't do that on the server, either + text = text.replace(/<(?:mailto:)?([-.\w]+\@[-a-z0-9]+(\.[-a-z0-9]+)*\.[a-z]+)>/gi, + function(wholeMatch,m1) { + return _EncodeEmailAddress( _UnescapeSpecialChars(m1) ); + } + ); + */ + return text; + } + + function _UnescapeSpecialChars(text) { + // + // Swap back in all the special characters we've hidden. + // + text = text.replace(/~E(\d+)E/g, + function (wholeMatch, m1) { + var charCodeToReplace = parseInt(m1); + return String.fromCharCode(charCodeToReplace); + } + ); + return text; + } + + function _Outdent(text) { + // + // Remove one level of line-leading tabs or spaces + // + + // attacklab: hack around Konqueror 3.5.4 bug: + // "----------bug".replace(/^-/g,"") == "bug" + + text = text.replace(/^(\t|[ ]{1,4})/gm, "~0"); // attacklab: g_tab_width + + // attacklab: clean up hack + text = text.replace(/~0/g, "") + + return text; + } + + function _Detab(text) { + if (!/\t/.test(text)) + return text; + + var spaces = [" ", " ", " ", " "], + skew = 0, + v; + + return text.replace(/[\n\t]/g, function (match, offset) { + if (match === "\n") { + skew = offset + 1; + return match; + } + v = (offset - skew) % 4; + skew = offset + 1; + return spaces[v]; + }); + } + + // + // attacklab: Utility functions + // + + var _problemUrlChars = /(?:["'*()[\]:]|~D)/g; + + // hex-encodes some unusual "problem" chars in URLs to avoid URL detection problems + function encodeProblemUrlChars(url) { + if (!url) + return ""; + + var len = url.length; + + return url.replace(_problemUrlChars, function (match, offset) { + if (match == "~D") // escape for dollar + return "%24"; + if (match == ":") { + if (offset == len - 1 || /[0-9\/]/.test(url.charAt(offset + 1))) + return ":" + } + return "%" + match.charCodeAt(0).toString(16); + }); + } + + + function escapeCharacters(text, charsToEscape, afterBackslash) { + // First we have to escape the escape characters so that + // we can build a character class out of them + var regexString = "([" + charsToEscape.replace(/([\[\]\\])/g, "\\$1") + "])"; + + if (afterBackslash) { + regexString = "\\\\" + regexString; + } + + var regex = new RegExp(regexString, "g"); + text = text.replace(regex, escapeCharacters_callback); + + return text; + } + + + function escapeCharacters_callback(wholeMatch, m1) { + var charCodeToEscape = m1.charCodeAt(0); + return "~E" + charCodeToEscape + "E"; + } + + }; // end of the Markdown.Converter constructor + +})(); diff --git a/public/javascripts/vendor/markdown/Markdown.Sanitizer.js b/public/javascripts/vendor/markdown/Markdown.Sanitizer.js new file mode 100644 index 0000000000..cc5826fa8f --- /dev/null +++ b/public/javascripts/vendor/markdown/Markdown.Sanitizer.js @@ -0,0 +1,108 @@ +(function () { + var output, Converter; + if (typeof exports === "object" && typeof require === "function") { // we're in a CommonJS (e.g. Node.js) module + output = exports; + Converter = require("./Markdown.Converter").Converter; + } else { + output = window.Markdown; + Converter = output.Converter; + } + + output.getSanitizingConverter = function () { + var converter = new Converter(); + converter.hooks.chain("postConversion", sanitizeHtml); + converter.hooks.chain("postConversion", balanceTags); + return converter; + } + + function sanitizeHtml(html) { + return html.replace(/<[^>]*>?/gi, sanitizeTag); + } + + // (tags that can be opened/closed) | (tags that stand alone) + var basic_tag_whitelist = /^(<\/?(b|blockquote|code|del|dd|dl|dt|em|h1|h2|h3|i|kbd|li|ol|p|pre|s|sup|sub|strong|strike|ul)>|<(br|hr)\s?\/?>)$/i; + // <a href="url..." optional title>|</a> + var a_white = /^(<a\shref="((https?|ftp):\/\/|\/)[-A-Za-z0-9+&@#\/%?=~_|!:,.;\(\)]+"(\stitle="[^"<>]+")?\s?>|<\/a>)$/i; + + // <img src="url..." optional width optional height optional alt optional title + var img_white = /^(<img\ssrc="(https?:\/\/|\/)[-A-Za-z0-9+&@#\/%?=~_|!:,.;\(\)]+"(\swidth="\d{1,3}")?(\sheight="\d{1,3}")?(\salt="[^"<>]*")?(\stitle="[^"<>]*")?\s?\/?>)$/i; + + function sanitizeTag(tag) { + if (tag.match(basic_tag_whitelist) || tag.match(a_white) || tag.match(img_white)) + return tag; + else + return ""; + } + + /// <summary> + /// attempt to balance HTML tags in the html string + /// by removing any unmatched opening or closing tags + /// IMPORTANT: we *assume* HTML has *already* been + /// sanitized and is safe/sane before balancing! + /// + /// adapted from CODESNIPPET: A8591DBA-D1D3-11DE-947C-BA5556D89593 + /// </summary> + function balanceTags(html) { + + if (html == "") + return ""; + + var re = /<\/?\w+[^>]*(\s|$|>)/g; + // convert everything to lower case; this makes + // our case insensitive comparisons easier + var tags = html.toLowerCase().match(re); + + // no HTML tags present? nothing to do; exit now + var tagcount = (tags || []).length; + if (tagcount == 0) + return html; + + var tagname, tag; + var ignoredtags = "<p><img><br><li><hr>"; + var match; + var tagpaired = []; + var tagremove = []; + var needsRemoval = false; + + // loop through matched tags in forward order + for (var ctag = 0; ctag < tagcount; ctag++) { + tagname = tags[ctag].replace(/<\/?(\w+).*/, "$1"); + // skip any already paired tags + // and skip tags in our ignore list; assume they're self-closed + if (tagpaired[ctag] || ignoredtags.search("<" + tagname + ">") > -1) + continue; + + tag = tags[ctag]; + match = -1; + + if (!/^<\//.test(tag)) { + // this is an opening tag + // search forwards (next tags), look for closing tags + for (var ntag = ctag + 1; ntag < tagcount; ntag++) { + if (!tagpaired[ntag] && tags[ntag] == "</" + tagname + ">") { + match = ntag; + break; + } + } + } + + if (match == -1) + needsRemoval = tagremove[ctag] = true; // mark for removal + else + tagpaired[match] = true; // mark paired + } + + if (!needsRemoval) + return html; + + // delete all orphaned tags from the string + + var ctag = 0; + html = html.replace(re, function (match) { + var res = tagremove[ctag] ? "" : match; + ctag++; + return res; + }); + return html; + } +})(); diff --git a/spec/javascripts/app/helpers/text_formatter_spec.js b/spec/javascripts/app/helpers/text_formatter_spec.js new file mode 100644 index 0000000000..14323996ad --- /dev/null +++ b/spec/javascripts/app/helpers/text_formatter_spec.js @@ -0,0 +1,109 @@ +describe("app.helpers.textFormatter", function(){ + + beforeEach(function(){ + this.statusMessage = factory.post(); + this.formatter = app.helpers.textFormatter; + }) + + describe("main", function(){ + it("calls mentionify, hashtagify, and markdownify", function(){ + spyOn(app.helpers.textFormatter, "mentionify") + spyOn(app.helpers.textFormatter, "hashtagify") + spyOn(app.helpers.textFormatter, "markdownify") + + app.helpers.textFormatter(this.statusMessage) + expect(app.helpers.textFormatter.mentionify).toHaveBeenCalled() + expect(app.helpers.textFormatter.hashtagify).toHaveBeenCalled() + expect(app.helpers.textFormatter.markdownify).toHaveBeenCalled() + }) + + // A couple of complex (intergration) test cases here would be rad. + }) + + describe(".markdownify", function(){ + // NOTE: for some strange reason, links separated by just a whitespace character + // will not be autolinked; thus we join our URLS here with (" and "). + // This test will fail if our join is just (" ") -- an edge case that should be addressed. + + it("autolinks", function(){ + var links = ["http://google.com", + "https://joindiaspora.com", + "http://www.yahooligans.com", + "http://obama.com", + "http://japan.co.jp"] + + // The join that would make this particular test fail: + // + // var formattedText = this.formatter.markdownify(links.join(" ")) + + var formattedText = this.formatter.markdownify(links.join(" and ")) + var wrapper = $("<div>").html(formattedText); + + _.each(links, function(link) { + expect(wrapper.find("a[href='" + link + "']").text()).toContain(link) + }) + }) + }) + + describe(".hashtagify", function(){ + context("changes hashtags to links", function(){ + it("creates links to hashtags", function(){ + var formattedText = this.formatter.hashtagify("I love #parties and #rockstars and #unicorns") + var wrapper = $("<div>").html(formattedText); + + _.each(["parties", "rockstars", "unicorns"], function(tagName){ + expect(wrapper.find("a[href='/tags/" + tagName + "']").text()).toContain(tagName) + }) + }) + + it("requires hashtags to be preceeded with a space", function(){ + var formattedText = this.formatter.hashtagify("I love the#parties") + expect(formattedText).not.toContain('/tags/parties') + }) + + // NOTE THIS DIVERGES FROM GRUBER'S ORIGINAL DIALECT OF MARKDOWN. + // We had to edit Markdown.Converter.js line 747 + // + // text = text.replace(/^(\#{1,6})[ \t]+(.+?)[ \t]*\#*\n+/gm, + // [ \t]* changed to [ \t]+ + // + it("doesn't create a header tag if the first word is a hashtag", function(){ + var formattedText = this.formatter.hashtagify("#parties, I love") + var wrapper = $("<div>").html(formattedText); + + expect(wrapper.find("h1").length).toBe(0) + expect(wrapper.find("a[href='/tags/parties']").text()).toContain("#parties") + }) + }) + }) + + describe(".mentionify", function(){ + context("changes mention markup to links", function(){ + beforeEach(function(){ + this.alice = factory.author({ + name : "Alice Smith", + diaspora_id : "alice@example.com", + id : "555" + }) + + this.bob = factory.author({ + name : "Bob Grimm", + diaspora_id : "bob@example.com", + id : "666" + }) + + this.statusMessage.set({text: "hey there @{Alice Smith; alice@example.com} and @{Bob Grimm; bob@example.com}"}) + this.statusMessage.set({mentioned_people : [this.alice, this.bob]}) + }) + + it("matches mentions", function(){ + var formattedText = this.formatter.mentionify(this.statusMessage.get("text"), this.statusMessage.get("mentioned_people")) + var wrapper = $("<div>").html(formattedText); + + _.each([this.alice, this.bob], function(person) { + expect(wrapper.find("a[href='/people/" + person.id + "']").text()).toContain(person.name) + }) + }) + }) + }) +}) diff --git a/spec/javascripts/app/views/post_view_spec.js b/spec/javascripts/app/views/post_view_spec.js index 6dd2678dc2..d775005b48 100644 --- a/spec/javascripts/app/views/post_view_spec.js +++ b/spec/javascripts/app/views/post_view_spec.js @@ -40,132 +40,6 @@ describe("app.views.Post", function(){ expect(view.$(".post_initial_info").html()).not.toContain("0 Reshares") }) - it("should markdownify the post's text", function(){ - this.statusMessage.set({text: "I have three Belly Buttons"}) - spyOn(window.markdown, "toHTML") - new app.views.Post({model : this.statusMessage}).render(); - expect(window.markdown.toHTML).toHaveBeenCalledWith("I have three Belly Buttons") - }) - - context("changes hashtags to links", function(){ - it("links to a hashtag to the tag page", function(){ - this.statusMessage.set({text: "I love #parties"}) - var view = new app.views.Post({model : this.statusMessage}).render(); - expect(view.$("a:contains('#parties')").attr('href')).toBe('/tags/parties') - }) - - it("changes all hashtags", function(){ - this.statusMessage.set({text: "I love #parties and #rockstars and #unicorns"}) - var view = new app.views.Post({model : this.statusMessage}).render(); - expect(view.$("a.tag").length).toBe(3) - expect(view.$("a:contains('#parties')")).toExist(); - expect(view.$("a:contains('#rockstars')")).toExist(); - expect(view.$("a:contains('#unicorns')")).toExist(); - }) - - it("requires hashtags to be preceeded with a space", function(){ - this.statusMessage.set({text: "I love the#parties"}) - var view = new app.views.Post({model : this.statusMessage}).render(); - expect(view.$(".tag").length).toBe(0) - }) - - // NOTE THIS DIVERGES FROM GRUBER'S ORIGINAL DIALECT OF MARKDOWN. - // We had to edit markdown.js line 291 - good people would have made a new dialect. - // - // original : var m = block.match( /^(#{1,6})\s*(.*?)\s*#*\s*(?:\n|$)/ ); - // \s* changed to \s+ - // - it("doesn't create a header tag if the first word is a hashtag", function(){ - this.statusMessage.set({text: "#parties, I love"}) - var view = new app.views.Post({model : this.statusMessage}).render(); - expect(view.$("h1:contains(parties)")).not.toExist(); - expect(view.$("a:contains('#parties')")).toExist(); - }) - - it("works on reshares", function(){ - this.statusMessage.set({text: "I love #parties"}) - var reshare = new app.models.Reshare(factory.post({ - text : this.statusMessage.get("text"), - root : this.statusMessage - })) - - var view = new app.views.Post({model : reshare}).render(); - expect(view.$("a:contains('#parties')").attr('href')).toBe('/tags/parties') - }) - }) - - context("changes mention markup to links", function(){ - beforeEach(function(){ - this.alice = factory.author({ - name : "Alice Smith", - diaspora_id : "alice@example.com", - id : "555" - }) - - this.bob = factory.author({ - name : "Bob Grimm", - diaspora_id : "bob@example.com", - id : "666" - }) - - this.statusMessage.set({mentioned_people : [this.alice, this.bob]}) - this.statusMessage.set({text: "hey there @{Alice Smith; alice@example.com} and @{Bob Grimm; bob@example.com}"}) - }) - - it("links to the mentioned person's page", function(){ - var view = new app.views.Post({model : this.statusMessage}).render(); - expect(view.$("a:contains('Alice Smith')").attr('href')).toBe('/people/555') - }) - - it("matches all mentions", function(){ - var view = new app.views.Post({model : this.statusMessage}).render(); - expect(view.$("a.mention").length).toBe(2) - }) - - it("works on reshares", function(){ - var reshare = new app.models.Reshare(factory.post({ - text : this.statusMessage.get("text"), - mentioned_people : this.statusMessage.get("mentioned_people"), - root : this.statusMessage - })) - - var view = new app.views.Post({model : reshare}).render(); - expect(view.$("a.mention").length).toBe(2) - }) - }) - - context("generates urls from plaintext", function(){ - it("works", function(){ - links = ["http://google.com", - "https://joindiaspora.com", - "http://www.yahooligans.com", - "http://obama.com", - "http://japan.co.jp"] - - this.statusMessage.set({text : links.join(" ")}) - var view = new app.views.Post({model : this.statusMessage}).render(); - - _.each(links, function(link) { - expect(view.$("a[href='" + link + "']").text()).toContain(link) - }) - }) - - it("works with urls that use #! syntax (i'm looking at you, twitter)')", function(){ - link = "http://twitter.com/#!/hashbangs?gross=true" - this.statusMessage.set({text : link}) - var view = new app.views.Post({model : this.statusMessage}).render(); - - expect(view.$("a[href='" + link + "']").text()).toContain(link) - }) - - it("doesn't create link tags for links that are already in <a/> or <img/> tags", function(){ - link = "http://google.com" - - this.statusMessage.set({text : ""}) - var view = new app.views.Content({model : this.statusMessage}) - expect(view.presenter().text).toNotContain('</a>') - }) - }) context("embed_html", function(){ it("provides oembed html from the model response", function(){ diff --git a/spec/javascripts/support/jasmine.yml b/spec/javascripts/support/jasmine.yml index 1b8eb2ce23..baaa9dc160 100644 --- a/spec/javascripts/support/jasmine.yml +++ b/spec/javascripts/support/jasmine.yml @@ -22,7 +22,7 @@ src_files: - public/javascripts/vendor/jquery.charcount.js - public/javascripts/vendor/timeago.js - public/javascripts/vendor/facebox.js - - public/javascripts/vendor/markdown.js + - public/javascripts/vendor/markdown/* - public/javascripts/jquery.infieldlabel-custom.js - public/javascripts/vendor/underscore.js - public/javascripts/vendor/backbone.js @@ -36,6 +36,7 @@ src_files: - public/javascripts/widgets/* - public/javascripts/app/app.js + - public/javascripts/app/helpers/* - public/javascripts/app/router.js - public/javascripts/app/views.js - public/javascripts/app/models/post.js -- GitLab