using pageDown Markdown library, fixing autolinking. created app.helpers.textFormatter, which takes care of text formatting; functions can be called individually throughout the app
This commit is contained in:
parent
f2cc8b4e41
commit
8150d32b86
9 changed files with 1593 additions and 176 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
var app = {
|
||||
collections: {},
|
||||
models: {},
|
||||
helpers: {},
|
||||
views: {},
|
||||
|
||||
user: function(user) {
|
||||
|
|
|
|||
38
public/javascripts/app/helpers/text_formatter.js
Normal file
38
public/javascripts/app/helpers/text_formatter.js
Normal file
|
|
@ -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;
|
||||
})();
|
||||
|
||||
|
|
@ -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"
|
||||
});
|
||||
|
|
|
|||
1332
public/javascripts/vendor/markdown/Markdown.Converter.js
vendored
Normal file
1332
public/javascripts/vendor/markdown/Markdown.Converter.js
vendored
Normal file
File diff suppressed because it is too large
Load diff
108
public/javascripts/vendor/markdown/Markdown.Sanitizer.js
vendored
Normal file
108
public/javascripts/vendor/markdown/Markdown.Sanitizer.js
vendored
Normal file
|
|
@ -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;
|
||||
}
|
||||
})();
|
||||
109
spec/javascripts/app/helpers/text_formatter_spec.js
Normal file
109
spec/javascripts/app/helpers/text_formatter_spec.js
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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(){
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue