├── .gitignore ├── app ├── css │ ├── main.css │ └── style.css ├── templates │ ├── github │ │ ├── WatchEvent-template.handlebars │ │ ├── actor.handlebars │ │ ├── DeleteEvent-template.handlebars │ │ ├── ForkEvent-template.handlebars │ │ ├── CommitCommentEvent-template.handlebars │ │ ├── DownloadEvent-template.handlebars │ │ ├── MemberEvent-template.handlebars │ │ ├── IssuesEvent-template.handlebars │ │ ├── PushEvent-template.handlebars │ │ ├── PullRequestEvent-template.handlebars │ │ ├── githubEvent.handlebars │ │ ├── CreateEvent-template.handlebars │ │ ├── PullRequestReviewCommentEvent-template.handlebars │ │ ├── GollumEvent-template.handlebars │ │ └── IssueCommentEvent-template.handlebars │ ├── tweet.handlebars │ ├── tweets.handlebars │ ├── github.handlebars │ ├── reddits.handlebars │ ├── questions.handlebars │ ├── reddit.handlebars │ └── question.handlebars ├── static │ └── img │ │ ├── fork_me_ribbon.png │ │ ├── glyphicons-halflings.png │ │ └── glyphicons-halflings-white.png ├── lib │ ├── core.js │ ├── ext.js │ ├── datasource.js │ ├── handlebars-helpers.js │ ├── controllers.js │ ├── main.js │ └── views.js ├── tests │ ├── core_test.js │ ├── datasource_test.js │ ├── controllers_test.js │ ├── views_test.js │ └── handlebars-helpers_test.js ├── plugins │ └── loader.js └── vendor │ ├── moment.js │ └── twitter-text.js ├── Guardfile ├── Gemfile ├── config.ru ├── LICENSE ├── tests ├── index.html └── qunit │ ├── run-qunit.js │ └── qunit.css ├── Rakefile ├── README.md ├── Gemfile.lock ├── index.html ├── Assetfile └── test_sources ├── stackoverflow.json ├── twitter.json └── reddit.json /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | assets 3 | tmp -------------------------------------------------------------------------------- /app/css/main.css: -------------------------------------------------------------------------------- 1 | a { 2 | cursor: pointer; 3 | } 4 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard :rake, :task => :test do 2 | watch(%r{^app/.+\.js$}) 3 | end 4 | -------------------------------------------------------------------------------- /app/templates/github/WatchEvent-template.handlebars: -------------------------------------------------------------------------------- 1 | {{#view ActorView}} {{event.payload.action}} watching{{/view}} -------------------------------------------------------------------------------- /app/templates/github/actor.handlebars: -------------------------------------------------------------------------------- 1 | {{actor.login}} {{yield}} -------------------------------------------------------------------------------- /app/static/img/fork_me_ribbon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pangratz/ember.js-dashboard/dev/app/static/img/fork_me_ribbon.png -------------------------------------------------------------------------------- /app/templates/tweet.handlebars: -------------------------------------------------------------------------------- 1 | {{event.from_user}} {{parseTweet event.text}} -------------------------------------------------------------------------------- /app/static/img/glyphicons-halflings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pangratz/ember.js-dashboard/dev/app/static/img/glyphicons-halflings.png -------------------------------------------------------------------------------- /app/templates/github/DeleteEvent-template.handlebars: -------------------------------------------------------------------------------- 1 | {{#view ActorView}} 2 | deleted {{event.payload.ref_type}} {{event.payload.ref}} 3 | {{/view}} -------------------------------------------------------------------------------- /app/static/img/glyphicons-halflings-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pangratz/ember.js-dashboard/dev/app/static/img/glyphicons-halflings-white.png -------------------------------------------------------------------------------- /app/templates/github/ForkEvent-template.handlebars: -------------------------------------------------------------------------------- 1 | {{#view ActorView}} 2 | forked 3 | repository 4 | {{/view}} -------------------------------------------------------------------------------- /app/templates/github/CommitCommentEvent-template.handlebars: -------------------------------------------------------------------------------- 1 | {{#view ActorView actorBinding="event.actor"}} 2 | commented on commit 3 | {{/view}} -------------------------------------------------------------------------------- /app/templates/github/DownloadEvent-template.handlebars: -------------------------------------------------------------------------------- 1 | {{#view ActorView}} 2 | created download {{event.payload.download.name}} 3 | {{/view}} -------------------------------------------------------------------------------- /app/templates/github/MemberEvent-template.handlebars: -------------------------------------------------------------------------------- 1 | {{#view ActorView}} 2 | added {{event.payload.member.login}} as a collaborator 3 | {{/view}} -------------------------------------------------------------------------------- /app/templates/github/IssuesEvent-template.handlebars: -------------------------------------------------------------------------------- 1 | {{#view ActorView}} 2 | {{event.payload.action}} issue 3 | #{{event.payload.issue.number}} 4 | {{/view}} -------------------------------------------------------------------------------- /app/templates/github/PushEvent-template.handlebars: -------------------------------------------------------------------------------- 1 | {{#view ActorView}} 2 | pushed to 3 | {{event.payload.ref}} 4 | {{/view}} -------------------------------------------------------------------------------- /app/templates/tweets.handlebars: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/templates/github.handlebars: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/templates/reddits.handlebars: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/templates/github/PullRequestEvent-template.handlebars: -------------------------------------------------------------------------------- 1 | {{#view ActorView}} 2 | {{event.payload.action}} Pull Request 3 | #{{event.payload.number}} 4 | {{/view}} -------------------------------------------------------------------------------- /app/templates/github/githubEvent.handlebars: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | {{view DetailView eventBinding="event" }} 6 | 7 |
-------------------------------------------------------------------------------- /app/lib/core.js: -------------------------------------------------------------------------------- 1 | require('jquery'); 2 | require('ember'); 3 | require('twitter-text'); 4 | require('moment'); 5 | 6 | require('dashboard/ext'); 7 | require('dashboard/handlebars-helpers'); 8 | 9 | Dashboard = D = Ember.Application.create(); -------------------------------------------------------------------------------- /app/templates/questions.handlebars: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/templates/github/CreateEvent-template.handlebars: -------------------------------------------------------------------------------- 1 | {{#view ActorView}} created {{event.payload.ref_type}}{{/view}} 2 |
3 | {{#if event.payload.ref}} 4 | {{event.payload.ref}} 5 | {{/if}} 6 |
-------------------------------------------------------------------------------- /app/templates/github/PullRequestReviewCommentEvent-template.handlebars: -------------------------------------------------------------------------------- 1 | {{#view ActorView}} 2 | reviewed on commit 3 | 4 | {{trim event.payload.comment.commit_id length=7}} 5 | 6 | {{/view}} 7 |
8 | {{event.payload.comment.body}} 9 |
-------------------------------------------------------------------------------- /app/templates/github/GollumEvent-template.handlebars: -------------------------------------------------------------------------------- 1 | {{#view ActorView}} 2 | modified the wiki 3 | {{/view}} 4 |
5 | {{#each event.payload.pages }} 6 | {{echo "action"}} {{page_name}} 7 | {{/each}} 8 |
9 | -------------------------------------------------------------------------------- /app/templates/github/IssueCommentEvent-template.handlebars: -------------------------------------------------------------------------------- 1 | {{#view ActorView}} 2 | commented on issue 3 | 4 | #{{event.payload.issue.number}} 5 | 6 | {{/view}} 7 |
8 | {{event.payload.issue.title}} 9 |
-------------------------------------------------------------------------------- /app/templates/reddit.handlebars: -------------------------------------------------------------------------------- 1 | {{#with event}} 2 | {{ups}} - 3 | {{author}} 4 | {{title}} - 5 | 6 | {{num_comments}} 7 | 8 | {{/with}} -------------------------------------------------------------------------------- /app/tests/core_test.js: -------------------------------------------------------------------------------- 1 | require('dashboard/core'); 2 | 3 | module("core"); 4 | 5 | test("Dashboard namespace is available", 6 | function() { 7 | ok(Dashboard, "namespace is available "); 8 | }); 9 | 10 | test("Dashboard namespace has a shortcut D", 11 | function() { 12 | ok(D, "namespace is available"); 13 | equal(Dashboard, D, "Dashboard equals D"); 14 | }); -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source :rubygems 2 | 3 | gem 'colored' 4 | 5 | gem 'guard' 6 | gem 'guard-rake' 7 | 8 | gem 'rack' 9 | gem 'rack-rewrite' 10 | # gem 'rack-streaming-proxy' 11 | 12 | gem 'sass' 13 | gem 'compass' 14 | 15 | gem 'uglifier' 16 | gem 'yui-compressor' 17 | 18 | gem 'rake-pipeline', :git => 'https://github.com/livingsocial/rake-pipeline.git' 19 | gem 'rake-pipeline-web-filters', :git => 'https://github.com/wycats/rake-pipeline-web-filters.git' 20 | -------------------------------------------------------------------------------- /app/tests/datasource_test.js: -------------------------------------------------------------------------------- 1 | require('dashboard/datasource'); 2 | 3 | module("Dashboard.DataSource"); 4 | 5 | test("it exists", 6 | function() { 7 | ok(Dashboard.DataSource, "datasource exists"); 8 | ok(Ember.Object.detect(Dashboard.DataSource), "it is a subclass of Ember.Object"); 9 | }); 10 | 11 | test("has a method for getting latest Tweets", 12 | function() { 13 | ok(Dashboard.DataSource.create().getLatestTweets, "method exists"); 14 | }); -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require 'rake-pipeline' 2 | require 'rake-pipeline/middleware' 3 | use Rake::Pipeline::Middleware, 'Assetfile' 4 | 5 | # require 'rack/streaming_proxy' 6 | # use Rack::StreamingProxy do |request| 7 | # if request.path.start_with?('/proxy') 8 | # "http://127.0.0.1:8080#{request.path}" 9 | # end 10 | # end 11 | 12 | require 'rack-rewrite' 13 | use Rack::Rewrite do 14 | rewrite %r{^(.*)\/$}, '$1/index.html' 15 | end 16 | 17 | run Rack::Directory.new('.') 18 | -------------------------------------------------------------------------------- /app/templates/question.handlebars: -------------------------------------------------------------------------------- 1 | {{#with event}} 2 |
3 | {{score}} / 4 | {{answer_count}} - 5 | {{{title}}} 6 | {{#if accepted_answer_id}} 7 | 8 | {{/if}} 9 |
10 |
11 | {{#each tags}} 12 | {{this}} 13 | {{/each}} 14 |
15 | {{/with}} -------------------------------------------------------------------------------- /app/lib/ext.js: -------------------------------------------------------------------------------- 1 | var get = Ember.get, fmt = Ember.String.fmt; 2 | 3 | Ember.View.reopen({ 4 | templateForName: function(name, type) { 5 | if (!name) { 6 | return; 7 | } 8 | 9 | var templates = get(this, 'templates'), 10 | template = get(templates, name); 11 | 12 | if (!template) { 13 | try { 14 | template = require(name); 15 | } catch (error) { throw error; } 16 | 17 | if (!template) { 18 | throw new Ember.Error(fmt('%@ - Unable to find %@ "%@".', [this, type, name])); 19 | } 20 | } 21 | 22 | return template; 23 | } 24 | }); 25 | -------------------------------------------------------------------------------- /app/tests/controllers_test.js: -------------------------------------------------------------------------------- 1 | require('dashboard/controllers'); 2 | 3 | module("Dashboard.GitHubController"); 4 | 5 | test("it exists", 6 | function() { 7 | ok(Dashboard.GitHubController, "controller exists"); 8 | }); 9 | 10 | module("Dashboard.TwitterController"); 11 | 12 | test("it exists", 13 | function() { 14 | ok(Dashboard.TwitterController, "controller exists"); 15 | }); 16 | 17 | module("Dashboard.StackOverflowController"); 18 | 19 | test("it exists", 20 | function() { 21 | ok(Dashboard.StackOverflowController, "controller exists"); 22 | }); 23 | 24 | module("Dashboard.RedditController"); 25 | 26 | test("it exists", 27 | function() { 28 | ok(Dashboard.RedditController, "controller exists"); 29 | }); 30 | -------------------------------------------------------------------------------- /app/css/style.css: -------------------------------------------------------------------------------- 1 | div.container-fluid > h1 { 2 | color: #CD4F1A; 3 | text-transform: uppercase; 4 | text-shadow: 2px 2px 0px #FFF, 3px 3px 0px #CD4F1A; 5 | } 6 | 7 | li { 8 | background-color: #F7F7F9; 9 | -webkit-border-radius: 5px; 10 | -moz-border-radius: 5px; 11 | border-radius: 5px; 12 | margin-bottom: 1em; 13 | padding: 0.5em; 14 | border: 1px solid #E1E1E8; 15 | } 16 | 17 | .profilePic { 18 | float: left; 19 | margin: 3px; 20 | margin-right: 10px; 21 | } 22 | 23 | .profilePic > img { 24 | -webkit-border-radius: 3px; 25 | -moz-border-radius: 3px; 26 | border-radius: 3px; 27 | } 28 | 29 | .info { 30 | float: none; 31 | } 32 | 33 | div.additional-info { 34 | margin-left: 5px; 35 | margin-top: 5px; 36 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Clemens Müller and contributors 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /app/lib/datasource.js: -------------------------------------------------------------------------------- 1 | require('dashboard/core'); 2 | 3 | Dashboard.DataSource = Ember.Object.extend({ 4 | getLatestTweets: function(callback) { 5 | Ember.$.getJSON('http://search.twitter.com/search.json?callback=?&q=ember.js%20OR%20emberjs%20OR%20ember-data%20OR%20emberjs', callback); 6 | }, 7 | 8 | getLatestStackOverflowQuestions: function(callback) { 9 | Ember.$.getJSON('https://api.stackexchange.com/2.0/search?pagesize=20&order=desc&sort=activity&tagged=ember.js&site=stackoverflow&callback=?', callback); 10 | }, 11 | 12 | getLatestRedditEntries: function(callback) { 13 | Ember.$.getJSON('http://www.reddit.com/r/emberjs/new.json?sort=new&jsonp=?', callback); 14 | }, 15 | 16 | getLatestGitHubEvents: function(callback) { 17 | Ember.$.getJSON('https://api.github.com/repos/emberjs/ember.js/events?page=1&per_page=100&callback=?', callback); 18 | } 19 | }); -------------------------------------------------------------------------------- /tests/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Dashboard Test Suite 6 | 7 | 8 | 9 | 10 |
11 |
12 | 13 | 14 | 15 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | APPNAME = 'ember-skeleton' 2 | 3 | require 'colored' 4 | require 'rake-pipeline' 5 | 6 | desc "Build #{APPNAME}" 7 | task :build do 8 | Rake::Pipeline::Project.new('Assetfile').invoke 9 | end 10 | 11 | desc "Deploy #{APPNAME} to gh-pages branch" 12 | task :deploy do 13 | `rm -rf build` 14 | `mkdir build` 15 | origin = `git config remote.origin.url`.chomp 16 | ENV["RAKEP_MODE"] = "production" 17 | Rake::Task["build"].invoke 18 | Dir.chdir "build" do 19 | `git init` 20 | `git remote add origin #{origin}` 21 | `git checkout -b gh-pages` 22 | `cp ../index.html .` 23 | `cp -r ../assets .` 24 | `rm assets/app-tests.js` 25 | `git add .` 26 | `git commit -m 'Site updated at #{Time.now.utc}'` 27 | `git push -f origin gh-pages` 28 | end 29 | `rm -rf build` 30 | end 31 | 32 | desc "Run tests with PhantomJS" 33 | task :test => :build do 34 | unless system("which phantomjs > /dev/null 2>&1") 35 | abort "PhantomJS is not installed. Download from http://phantomjs.org/" 36 | end 37 | 38 | cmd = "phantomjs tests/qunit/run-qunit.js \"file://#{File.dirname(__FILE__)}/tests/index.html\"" 39 | 40 | # Run the tests 41 | puts "Running #{APPNAME} tests" 42 | success = system(cmd) 43 | 44 | if success 45 | puts "Tests Passed".green 46 | else 47 | puts "Tests Failed".red 48 | exit(1) 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /app/tests/views_test.js: -------------------------------------------------------------------------------- 1 | require('dashboard/views'); 2 | 3 | module("Dashboard.ActorView"); 4 | 5 | test("it exists", 6 | function() { 7 | ok(Dashboard.ActorView, "view exists"); 8 | ok(Ember.View.detect(Dashboard.ActorView), "it is a subclass of Ember.View"); 9 | }); 10 | 11 | test("has a property href which returns link to GitHub profile", 12 | function() { 13 | var view = Dashboard.ActorView.create({ 14 | actor: Ember.Object.create({ 15 | login: 'buster' 16 | }) 17 | }); 18 | 19 | Ember.run(function() { 20 | view.appendTo('#qunit-fixture'); 21 | }); 22 | 23 | equal(Ember.$('#qunit-fixture').find('a[href="https://github.com/buster"]').length, 1, "rendered view has a link to GitHub repo"); 24 | }); 25 | 26 | module("Dashboard.EventView"); 27 | 28 | test("it exists", 29 | function() { 30 | ok(Dashboard.EventView, "view exists"); 31 | ok(Ember.View.detect(Dashboard.EventView), "it is a sublcass of Ember.View"); 32 | }); 33 | 34 | test("it returns a templateName based on the event.type property", 35 | function() { 36 | var view = Dashboard.EventView.create({ 37 | event: Ember.Object.create({ 38 | type: 'CommitCommentEvent' 39 | }) 40 | }); 41 | 42 | Ember.run(function() { 43 | view.appendTo('#qunit-fixture'); 44 | }); 45 | 46 | equal(view.get('templateName'), 'dashboard/~templates/github/CommitCommentEvent-template', "returns template for given event type"); 47 | }); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ember.js Dashboard 2 | 3 | This is the repo for [Ember.js Dashboard](http://pangratz.github.com/ember.js-dashboard), which shows the activity of the community unified at one glance. It aggregates the latest stuff from [Twitter](http://twitter.com/#!/search/realtime/emberjs), events in the ember.js [GitHub](https://github.com/emberjs/ember.js) repository, latest questions on [StackOverflow](http://stackoverflow.com/questions/tagged/emberjs) and stuff from [reddit/emberjs](http://www.reddit.com/r/emberjs/). 4 | 5 | I wrote this dashboard because I constantly hit those 4 pages in intervals of 1 minute, just to see if there's something new. With this dashboard I can check out all this 4 times more in the minute :pensive: 6 | 7 | ## Hack 8 | 9 | The project is organized with the layout from the excellent [interline/ember-skeleton](https://github.com/interline/ember-skeleton). 10 | 11 | To hack around clone this very repo and do a `bundle install` and afterwards a simple `bundle exec rackup` to start your local server. Then go to `http://localhost:9292`, hack around and hit refresh to see the changes. 12 | 13 | Pull Requests are **very** welcome! Questions? Find me on Twitter @pangratz. 14 | 15 | ## Deploy your own dashboard 16 | 17 | There is a `deploy` task in the `Rakefile`. Simply execute `bundle exec rake deploy` and the dashboard will be pushed to the `gh-pages` branch in your `ember.js-dashboard` repository, which is afterwards available via `http://YOURSELF.github.com/ember.js-dashboard` ... 18 | 19 | 20 | ---- 21 | 22 | Holy Moly. -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GIT 2 | remote: https://github.com/livingsocial/rake-pipeline.git 3 | revision: b70ca6cad7655e58d13031f3e24df7dfc74f9030 4 | specs: 5 | rake-pipeline (0.6.0) 6 | rake (~> 0.9.0) 7 | thor 8 | 9 | GIT 10 | remote: https://github.com/wycats/rake-pipeline-web-filters.git 11 | revision: ba0b8a00356b4c854930a8e849b5629d51ffd70f 12 | specs: 13 | rake-pipeline-web-filters (0.6.0) 14 | rack 15 | rake-pipeline (~> 0.6) 16 | 17 | GEM 18 | remote: http://rubygems.org/ 19 | specs: 20 | POpen4 (0.1.4) 21 | Platform (>= 0.4.0) 22 | open4 23 | Platform (0.4.0) 24 | chunky_png (1.2.5) 25 | colored (1.2) 26 | compass (0.12.1) 27 | chunky_png (~> 1.2) 28 | fssm (>= 0.2.7) 29 | sass (~> 3.1) 30 | execjs (1.3.0) 31 | multi_json (~> 1.0) 32 | ffi (1.0.11) 33 | fssm (0.2.8.1) 34 | guard (1.0.1) 35 | ffi (>= 0.5.0) 36 | thor (~> 0.14.6) 37 | guard-rake (0.0.5) 38 | guard 39 | rake 40 | multi_json (1.2.0) 41 | open4 (1.3.0) 42 | rack (1.4.1) 43 | rack-rewrite (1.2.1) 44 | rake (0.9.2.2) 45 | sass (3.1.15) 46 | thor (0.14.6) 47 | uglifier (1.2.4) 48 | execjs (>= 0.3.0) 49 | multi_json (>= 1.0.2) 50 | yui-compressor (0.9.6) 51 | POpen4 (>= 0.1.4) 52 | 53 | PLATFORMS 54 | ruby 55 | 56 | DEPENDENCIES 57 | colored 58 | compass 59 | guard 60 | guard-rake 61 | rack 62 | rack-rewrite 63 | rake-pipeline! 64 | rake-pipeline-web-filters! 65 | sass 66 | uglifier 67 | yui-compressor 68 | -------------------------------------------------------------------------------- /app/lib/handlebars-helpers.js: -------------------------------------------------------------------------------- 1 | Ember.Handlebars.registerHelper('echo', 2 | function(propertyName, options) { 3 | return Ember.getPath(options.contexts[0], propertyName); 4 | }); 5 | 6 | Ember.Handlebars.registerHelper('parseTweet', 7 | function(propertyPath, options) { 8 | var tweet; 9 | if (!options) { 10 | options = propertyPath; 11 | tweet = options.contexts[0].text; 12 | } else { 13 | tweet = Ember.getPath(options.contexts[0], propertyPath); 14 | } 15 | var parsed = twttr.txt.autoLink(tweet); 16 | return new Handlebars.SafeString(parsed); 17 | }); 18 | 19 | Ember.Handlebars.registerHelper('ago', 20 | function(propertyName, options) { 21 | var timestamp = Ember.getPath(options.contexts[0], propertyName); 22 | if (options.hash.isSeconds) { 23 | // the given property represents seconds since UNIX epoch, so we multiply 24 | // by 1000 to get the date in milliseconds since UNIX epoch 25 | timestamp *= 1000; 26 | } 27 | return moment(new Date(timestamp)).fromNow(); 28 | }); 29 | 30 | Ember.Handlebars.registerHelper('event', 31 | function(path, options) { 32 | var eventType = Ember.getPath(options.contexts[0], 'TYPE'); 33 | var viewClass = 'Dashboard.%@View'.fmt(eventType); 34 | return Ember.Handlebars.ViewHelper.helper(this, viewClass, options); 35 | }); 36 | 37 | Ember.Handlebars.registerHelper('trim', 38 | function(path, options) { 39 | var length = options.hash.length; 40 | var string = Ember.getPath(options.contexts[0], path); 41 | if (length) { 42 | string = string.substring(0, length); 43 | } 44 | return new Handlebars.SafeString(string); 45 | }); -------------------------------------------------------------------------------- /app/lib/controllers.js: -------------------------------------------------------------------------------- 1 | require('dashboard/core'); 2 | 3 | Dashboard.RedditController = Ember.ArrayProxy.extend({ 4 | content: [], 5 | loadLatestEntries: function() { 6 | var that = this; 7 | var ds = this.get('dataSource'); 8 | ds.getLatestRedditEntries(function(response) { 9 | that.pushObjects(response.data.children.getEach('data')); 10 | that.setEach('TYPE', 'RedditEvent'); 11 | }); 12 | } 13 | }); 14 | 15 | Dashboard.GitHubController = Ember.ArrayProxy.extend({ 16 | content: [], 17 | loadLatestEvents: function() { 18 | var that = this; 19 | var ds = this.get('dataSource'); 20 | ds.getLatestGitHubEvents(function(response) { 21 | that.pushObjects(response.data); 22 | that.setEach('TYPE', 'GitHubEvent'); 23 | }); 24 | } 25 | }); 26 | 27 | Dashboard.StackOverflowController = Ember.ArrayProxy.extend({ 28 | content: [], 29 | loadLatestQuestions: function() { 30 | var that = this; 31 | var ds = this.get('dataSource'); 32 | ds.getLatestStackOverflowQuestions(function(response) { 33 | that.pushObjects(response.items); 34 | that.setEach('TYPE', 'StackOverflowEvent'); 35 | }); 36 | } 37 | }); 38 | 39 | Dashboard.TwitterController = Ember.ArrayProxy.extend({ 40 | content: [], 41 | loadLatestTweets: function() { 42 | var that = this; 43 | var ds = this.get('dataSource'); 44 | ds.getLatestTweets(function(response) { 45 | that.pushObjects(response.results); 46 | that.setEach('TYPE', 'TwitterEvent'); 47 | }); 48 | } 49 | }); 50 | -------------------------------------------------------------------------------- /app/plugins/loader.js: -------------------------------------------------------------------------------- 1 | (function(window) { 2 | function requireWrapper(self) { 3 | var require = function() { 4 | return self.require.apply(self, arguments); 5 | }; 6 | require.exists = function() { 7 | return self.exists.apply(self, arguments); 8 | }; 9 | return require; 10 | } 11 | 12 | var Context = function() { 13 | return this; 14 | }; 15 | 16 | var Loader = function() { 17 | this.modules = {}; 18 | this.loaded = {}; 19 | this.exports = {}; 20 | return this; 21 | }; 22 | 23 | Loader.prototype.require = function(name) { 24 | if (!this.loaded[name]) { 25 | var module = this.modules[name]; 26 | if (module) { 27 | var require = requireWrapper(this); 28 | try { 29 | this.exports[name] = module.call(new Context(), require); 30 | return this.exports[name]; 31 | } finally { 32 | this.loaded[name] = true; 33 | } 34 | } else { 35 | throw "The module '" + name + "' has not been registered"; 36 | } 37 | } 38 | return this.exports[name]; 39 | }; 40 | 41 | Loader.prototype.register = function(name, module) { 42 | if (this.exists(name)) { 43 | throw "The module '"+ "' has already been registered"; 44 | } 45 | this.modules[name] = module; 46 | return true; 47 | }; 48 | 49 | Loader.prototype.unregister = function(name) { 50 | var loaded = !!this.loaded[name]; 51 | if (loaded) { 52 | delete this.exports[name]; 53 | delete this.modules[name]; 54 | delete this.loaded[name]; 55 | } 56 | return loaded; 57 | }; 58 | 59 | Loader.prototype.exists = function(name) { 60 | return name in this.modules; 61 | }; 62 | 63 | window.loader = new Loader(); 64 | })(this); 65 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Ember.js Dashboard 5 | 6 | 19 | 20 | 21 | 22 | Fork me on GitHub 23 | 24 |
25 |

Ember.js Dashboard

26 |
27 |
28 |

Twitter

29 |
loading latest Tweets ...
30 |
31 |
32 |

GitHub

33 |
loading latest events on GitHub
34 |
35 |
36 |

StackOverflow

37 |
loading latest questions on StackOverflow ...
38 |
39 |
40 |

r/emberjs

41 |
loading latest reddit entries ...
42 |
43 |
44 |
45 | 46 | 47 | 50 | 51 | -------------------------------------------------------------------------------- /tests/qunit/run-qunit.js: -------------------------------------------------------------------------------- 1 | // PhantomJS QUnit Test Runner 2 | 3 | var args = phantom.args; 4 | if (args.length < 1 || args.length > 2) { 5 | console.log("Usage: " + phantom.scriptName + " "); 6 | phantom.exit(1); 7 | } 8 | 9 | var page = require('webpage').create(); 10 | 11 | var depRe = /^DEPRECATION:/; 12 | page.onConsoleMessage = function(msg) { 13 | if (!depRe.test(msg)) console.log(msg); 14 | }; 15 | 16 | page.open(args[0], function(status) { 17 | if (status !== 'success') { 18 | console.error("Unable to access network"); 19 | phantom.exit(1); 20 | } else { 21 | page.evaluate(addLogging); 22 | 23 | var timeout = parseInt(args[1] || 30000, 10); 24 | var start = Date.now(); 25 | var interval = setInterval(function() { 26 | if (Date.now() > start + timeout) { 27 | console.error("Tests timed out"); 28 | phantom.exit(1); 29 | } else { 30 | var qunitDone = page.evaluate(function() { 31 | return window.qunitDone; 32 | }); 33 | 34 | if (qunitDone) { 35 | clearInterval(interval); 36 | if (qunitDone.failed > 0) { 37 | phantom.exit(1); 38 | } else { 39 | phantom.exit(); 40 | } 41 | } 42 | } 43 | }, 500); 44 | } 45 | }); 46 | 47 | function addLogging() { 48 | var testErrors = []; 49 | var assertionErrors = []; 50 | 51 | QUnit.moduleDone(function(context) { 52 | if (context.failed) { 53 | var msg = "Module Failed: " + context.name + "\n" + testErrors.join("\n"); 54 | console.error(msg); 55 | testErrors = []; 56 | } 57 | }); 58 | 59 | QUnit.testDone(function(context) { 60 | if (context.failed) { 61 | var msg = " Test Failed: " + context.name + assertionErrors.join(" "); 62 | testErrors.push(msg); 63 | assertionErrors = []; 64 | } 65 | }); 66 | 67 | QUnit.log(function(context) { 68 | if (context.result) return; 69 | 70 | var msg = "\n Assertion Failed:"; 71 | if (context.message) { 72 | msg += " " + context.message; 73 | } 74 | 75 | if (context.expected) { 76 | msg += "\n Expected: " + context.expected + ", Actual: " + context.actual; 77 | } 78 | 79 | assertionErrors.push(msg); 80 | }); 81 | 82 | QUnit.done(function(context) { 83 | var stats = [ 84 | "Time: " + context.runtime + "ms", 85 | "Total: " + context.total, 86 | "Passed: " + context.passed, 87 | "Failed: " + context.failed 88 | ]; 89 | console.log(stats.join(", ")); 90 | window.qunitDone = context; 91 | }); 92 | } 93 | -------------------------------------------------------------------------------- /app/lib/main.js: -------------------------------------------------------------------------------- 1 | require('dashboard/core'); 2 | require('dashboard/controllers'); 3 | require('dashboard/views'); 4 | require('dashboard/datasource'); 5 | 6 | // this is for testing purpose: if you add ?fixtures=true to your local url, 7 | // the pre-fetched sources are used and so you don't waste API calls during development 8 | if (window.location.search.indexOf('fixtures') !== -1) { 9 | Dashboard.dataSource = Dashboard.DataSource.create({ 10 | getLatestTweets: function(callback) { 11 | this.getJSON('twitter.json', callback); 12 | }, 13 | getLatestGitHubEvents: function(callback) { 14 | this.getJSON('github.json', callback); 15 | }, 16 | getLatestStackOverflowQuestions: function(callback) { 17 | this.getJSON('stackoverflow.json', callback); 18 | }, 19 | getLatestRedditEntries: function(callback) { 20 | this.getJSON('reddit.json', callback); 21 | }, 22 | getJSON: function(json, callback) { 23 | Ember.$.getJSON('/test_sources/' + json, callback); 24 | } 25 | }); 26 | } else { 27 | Dashboard.dataSource = Dashboard.DataSource.create(); 28 | } 29 | 30 | // create da controllers 31 | Dashboard.redditController = Dashboard.RedditController.create({ 32 | dataSourceBinding: 'Dashboard.dataSource' 33 | }); 34 | Dashboard.githubEventsController = Dashboard.GitHubController.create({ 35 | dataSourceBinding: 'Dashboard.dataSource' 36 | }); 37 | Dashboard.questionsController = Dashboard.StackOverflowController.create({ 38 | dataSourceBinding: 'Dashboard.dataSource' 39 | }); 40 | Dashboard.tweetsController = Dashboard.TwitterController.create({ 41 | dataSourceBinding: 'Dashboard.dataSource' 42 | }); 43 | 44 | Ember.run.sync(); 45 | 46 | // fetch initial data 47 | Dashboard.githubEventsController.loadLatestEvents(); 48 | Dashboard.tweetsController.loadLatestTweets(); 49 | Dashboard.questionsController.loadLatestQuestions(); 50 | Dashboard.redditController.loadLatestEntries(); 51 | 52 | // create da views 53 | Ember.View.create({ 54 | tweetsBinding: 'Dashboard.tweetsController', 55 | templateName: 'dashboard/~templates/tweets' 56 | }).replaceIn('.tweets'); 57 | 58 | Ember.View.create({ 59 | questionsBinding: 'Dashboard.questionsController', 60 | templateName: 'dashboard/~templates/questions' 61 | }).replaceIn('.stackoverflow'); 62 | 63 | Ember.View.create({ 64 | eventsBinding: 'Dashboard.githubEventsController', 65 | templateName: 'dashboard/~templates/github' 66 | }).replaceIn('.github'); 67 | 68 | Ember.View.create({ 69 | entriesBinding: 'Dashboard.redditController', 70 | templateName: 'dashboard/~templates/reddits' 71 | }).replaceIn('.reddit'); 72 | -------------------------------------------------------------------------------- /app/lib/views.js: -------------------------------------------------------------------------------- 1 | require('dashboard/core'); 2 | 3 | Dashboard.EventView = Ember.View.extend({ 4 | layout: Ember.Handlebars.compile('{{timeAgoString}}
  • {{yield}}
  • '), 5 | defaultTemplate: Ember.Handlebars.compile('{{event.TYPE}}'), 6 | classNameBindings: 'event.TYPE'.w(), 7 | timestampProperty: 'event.created_at', 8 | 9 | timeAgo: function() { 10 | timestamp = Ember.getPath(this, this.get('timestampProperty')); 11 | if (this.get('isSeconds')) { 12 | // the given property represents seconds since UNIX epoch, so we multiply 13 | // by 1000 to get the date in milliseconds since UNIX epoch 14 | timestamp *= 1000; 15 | } 16 | return new Date(timestamp); 17 | }.property('timestampProperty', 'isSeconds').cacheable(), 18 | 19 | timeAgoString: function() { 20 | var timeAgo = this.get('timeAgo'); 21 | return moment(timeAgo).fromNow(); 22 | }.property('timeAgo') 23 | }); 24 | 25 | Dashboard.StackOverflowEventView = Dashboard.EventView.extend({ 26 | timestampProperty: 'event.last_activity_date', 27 | isSeconds: true, 28 | templateName: 'dashboard/~templates/question' 29 | }); 30 | 31 | Dashboard.RedditEventView = Dashboard.EventView.extend({ 32 | timestampProperty: 'event.created_utc', 33 | isSeconds: true, 34 | templateName: 'dashboard/~templates/reddit' 35 | }); 36 | 37 | Dashboard.TwitterEventView = Dashboard.EventView.extend({ 38 | templateName: 'dashboard/~templates/tweet', 39 | tweetUrl: function() { 40 | var user = Ember.getPath(this, 'event.from_user'); 41 | var id = Ember.getPath(this, 'event.id_str'); 42 | return 'http://twitter.com/#!/%@/status/%@'.fmt(user, id); 43 | }.property() 44 | }); 45 | 46 | Dashboard.GitHubEventView = Dashboard.EventView.extend({ 47 | templateName: 'dashboard/~templates/github/githubEvent', 48 | 49 | avatarUrl: function() { 50 | var gravatarId = Ember.getPath(this, 'event.actor.gravatar_id'); 51 | return 'http://www.gravatar.com/avatar/%@'.fmt(gravatarId); 52 | }.property('event.actor.gravatar_id'), 53 | 54 | DetailView: Ember.View.extend({ 55 | classNames: 'info'.w(), 56 | templateName: function() { 57 | var type = Ember.getPath(this, 'event.type'); 58 | return 'dashboard/~templates/github/%@-template'.fmt(type); 59 | }.property('event.type').cacheable(), 60 | 61 | ActorView: Ember.View.extend({ 62 | layoutName: 'dashboard/~templates/github/actor', 63 | defaultTemplate: Ember.Handlebars.compile(''), 64 | tagName: '', 65 | 66 | actorBinding: 'event.actor', 67 | eventBinding: 'parentView.event', 68 | 69 | href: function() { 70 | var login = Ember.getPath(this, 'actor.login'); 71 | return 'https://github.com/%@'.fmt(login); 72 | }.property('actor.login') 73 | }) 74 | }) 75 | 76 | 77 | }); -------------------------------------------------------------------------------- /Assetfile: -------------------------------------------------------------------------------- 1 | APPNAME = 'dashboard' 2 | 3 | require 'json' 4 | require 'rake-pipeline-web-filters' 5 | 6 | WebFilters = Rake::Pipeline::Web::Filters 7 | 8 | class LoaderFilter < WebFilters::MinispadeFilter 9 | def generate_output(inputs, output) 10 | inputs.each do |input| 11 | code = input.read 12 | module_id = @module_id_generator.call(input) 13 | contents = "function(require) {\n#{code}\n}" 14 | ret = "\nloader.register('#{module_id}', #{contents});\n" 15 | output.write ret 16 | end 17 | end 18 | end 19 | 20 | class EmberAssertFilter < Filter 21 | def generate_output(inputs, output) 22 | inputs.each do |input| 23 | result = input.read 24 | result.gsub!(/ember_assert\((.*)\);/, '') 25 | output.write(result) 26 | end 27 | end 28 | end 29 | 30 | class HandlebarsFilter < Filter 31 | def generate_output(inputs, output) 32 | inputs.each do |input| 33 | code = input.read.to_json 34 | name = File.basename(input.path, '.handlebars') 35 | output.write "\nreturn Ember.Handlebars.compile(#{code});\n" 36 | end 37 | end 38 | end 39 | 40 | output 'assets' 41 | 42 | input 'app' do 43 | match 'lib/**/*.js' do 44 | filter LoaderFilter, 45 | :module_id_generator => proc { |input| 46 | input.path.sub(/^lib\//, "#{APPNAME}/").sub(/\.js$/, '') 47 | } 48 | 49 | if ENV['RAKEP_MODE'] == 'production' 50 | filter EmberAssertFilter 51 | uglify {|input| input} 52 | end 53 | concat 'app.js' 54 | end 55 | 56 | match 'vendor/**/*.js' do 57 | filter LoaderFilter, 58 | :module_id_generator => proc { |input| 59 | input.path.sub(/^vendor\//, '').sub(/\.js$/, '') 60 | } 61 | 62 | if ENV['RAKEP_MODE'] == 'production' 63 | filter EmberAssertFilter 64 | uglify {|input| input} 65 | end 66 | concat %w[ 67 | vendor/jquery.js 68 | vendor/ember.js 69 | vendor/moment.js 70 | vendor/twitter-text.js 71 | ], 'app.js' 72 | end 73 | 74 | match 'modules/**/*.js' do 75 | if ENV['RAKEP_MODE'] == 'production' 76 | filter EmberAssertFilter 77 | uglify {|input| input} 78 | end 79 | concat 'app.js' 80 | end 81 | 82 | match 'plugins/**/*.js' do 83 | if ENV['RAKEP_MODE'] == 'production' 84 | uglify {|input| input} 85 | end 86 | concat do |input| 87 | input.sub(/plugins\//, '') 88 | end 89 | end 90 | 91 | match 'templates/**/*.handlebars' do 92 | filter HandlebarsFilter 93 | filter LoaderFilter, 94 | :module_id_generator => proc { |input| 95 | input.path.sub(/^templates\//, "#{APPNAME}/~templates/").sub(/\.handlebars$/, '') 96 | } 97 | if ENV['RAKEP_MODE'] == 'production' 98 | uglify {|input| input} 99 | end 100 | concat 'app.js' 101 | end 102 | 103 | match 'tests/**/*.js' do 104 | filter LoaderFilter, 105 | :module_id_generator => proc { |input| 106 | input.path.sub(/^lib\//, "#{APPNAME}/").sub(/\.js$/, '') 107 | } 108 | concat 'app-tests.js' 109 | end 110 | 111 | match 'css/**/*.css' do 112 | if ENV['RAKEP_MODE'] == 'production' 113 | yui_css 114 | end 115 | concat ['bootstrap.css', 'main.css'], 'app.css' 116 | end 117 | 118 | match 'css/**/*.scss' do 119 | sass 120 | if ENV['RAKEP_MODE'] == 'production' 121 | yui_css 122 | end 123 | concat 'app.css' 124 | end 125 | 126 | match "static/**/*" do 127 | concat do |input| 128 | input.sub(/static\//, '') 129 | end 130 | end 131 | end 132 | 133 | # vim: filetype=ruby 134 | -------------------------------------------------------------------------------- /tests/qunit/qunit.css: -------------------------------------------------------------------------------- 1 | /** 2 | * QUnit v1.4.0 - A JavaScript Unit Testing Framework 3 | * 4 | * http://docs.jquery.com/QUnit 5 | * 6 | * Copyright (c) 2012 John Resig, Jörn Zaefferer 7 | * Dual licensed under the MIT (MIT-LICENSE.txt) 8 | * or GPL (GPL-LICENSE.txt) licenses. 9 | */ 10 | 11 | /** Font Family and Sizes */ 12 | 13 | #qunit-tests, #qunit-header, #qunit-banner, #qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult { 14 | font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial, sans-serif; 15 | } 16 | 17 | #qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult, #qunit-tests li { font-size: small; } 18 | #qunit-tests { font-size: smaller; } 19 | 20 | 21 | /** Resets */ 22 | 23 | #qunit-tests, #qunit-tests ol, #qunit-header, #qunit-banner, #qunit-userAgent, #qunit-testresult { 24 | margin: 0; 25 | padding: 0; 26 | } 27 | 28 | 29 | /** Header */ 30 | 31 | #qunit-header { 32 | padding: 0.5em 0 0.5em 1em; 33 | 34 | color: #8699a4; 35 | background-color: #0d3349; 36 | 37 | font-size: 1.5em; 38 | line-height: 1em; 39 | font-weight: normal; 40 | 41 | border-radius: 15px 15px 0 0; 42 | -moz-border-radius: 15px 15px 0 0; 43 | -webkit-border-top-right-radius: 15px; 44 | -webkit-border-top-left-radius: 15px; 45 | } 46 | 47 | #qunit-header a { 48 | text-decoration: none; 49 | color: #c2ccd1; 50 | } 51 | 52 | #qunit-header a:hover, 53 | #qunit-header a:focus { 54 | color: #fff; 55 | } 56 | 57 | #qunit-header label { 58 | display: inline-block; 59 | } 60 | 61 | #qunit-banner { 62 | height: 5px; 63 | } 64 | 65 | #qunit-testrunner-toolbar { 66 | padding: 0.5em 0 0.5em 2em; 67 | color: #5E740B; 68 | background-color: #eee; 69 | } 70 | 71 | #qunit-userAgent { 72 | padding: 0.5em 0 0.5em 2.5em; 73 | background-color: #2b81af; 74 | color: #fff; 75 | text-shadow: rgba(0, 0, 0, 0.5) 2px 2px 1px; 76 | } 77 | 78 | 79 | /** Tests: Pass/Fail */ 80 | 81 | #qunit-tests { 82 | list-style-position: inside; 83 | } 84 | 85 | #qunit-tests li { 86 | padding: 0.4em 0.5em 0.4em 2.5em; 87 | border-bottom: 1px solid #fff; 88 | list-style-position: inside; 89 | } 90 | 91 | #qunit-tests.hidepass li.pass, #qunit-tests.hidepass li.running { 92 | display: none; 93 | } 94 | 95 | #qunit-tests li strong { 96 | cursor: pointer; 97 | } 98 | 99 | #qunit-tests li a { 100 | padding: 0.5em; 101 | color: #c2ccd1; 102 | text-decoration: none; 103 | } 104 | #qunit-tests li a:hover, 105 | #qunit-tests li a:focus { 106 | color: #000; 107 | } 108 | 109 | #qunit-tests ol { 110 | margin-top: 0.5em; 111 | padding: 0.5em; 112 | 113 | background-color: #fff; 114 | 115 | border-radius: 15px; 116 | -moz-border-radius: 15px; 117 | -webkit-border-radius: 15px; 118 | 119 | box-shadow: inset 0px 2px 13px #999; 120 | -moz-box-shadow: inset 0px 2px 13px #999; 121 | -webkit-box-shadow: inset 0px 2px 13px #999; 122 | } 123 | 124 | #qunit-tests table { 125 | border-collapse: collapse; 126 | margin-top: .2em; 127 | } 128 | 129 | #qunit-tests th { 130 | text-align: right; 131 | vertical-align: top; 132 | padding: 0 .5em 0 0; 133 | } 134 | 135 | #qunit-tests td { 136 | vertical-align: top; 137 | } 138 | 139 | #qunit-tests pre { 140 | margin: 0; 141 | white-space: pre-wrap; 142 | word-wrap: break-word; 143 | } 144 | 145 | #qunit-tests del { 146 | background-color: #e0f2be; 147 | color: #374e0c; 148 | text-decoration: none; 149 | } 150 | 151 | #qunit-tests ins { 152 | background-color: #ffcaca; 153 | color: #500; 154 | text-decoration: none; 155 | } 156 | 157 | /*** Test Counts */ 158 | 159 | #qunit-tests b.counts { color: black; } 160 | #qunit-tests b.passed { color: #5E740B; } 161 | #qunit-tests b.failed { color: #710909; } 162 | 163 | #qunit-tests li li { 164 | margin: 0.5em; 165 | padding: 0.4em 0.5em 0.4em 0.5em; 166 | background-color: #fff; 167 | border-bottom: none; 168 | list-style-position: inside; 169 | } 170 | 171 | /*** Passing Styles */ 172 | 173 | #qunit-tests li li.pass { 174 | color: #5E740B; 175 | background-color: #fff; 176 | border-left: 26px solid #C6E746; 177 | } 178 | 179 | #qunit-tests .pass { color: #528CE0; background-color: #D2E0E6; } 180 | #qunit-tests .pass .test-name { color: #366097; } 181 | 182 | #qunit-tests .pass .test-actual, 183 | #qunit-tests .pass .test-expected { color: #999999; } 184 | 185 | #qunit-banner.qunit-pass { background-color: #C6E746; } 186 | 187 | /*** Failing Styles */ 188 | 189 | #qunit-tests li li.fail { 190 | color: #710909; 191 | background-color: #fff; 192 | border-left: 26px solid #EE5757; 193 | white-space: pre; 194 | } 195 | 196 | #qunit-tests > li:last-child { 197 | border-radius: 0 0 15px 15px; 198 | -moz-border-radius: 0 0 15px 15px; 199 | -webkit-border-bottom-right-radius: 15px; 200 | -webkit-border-bottom-left-radius: 15px; 201 | } 202 | 203 | #qunit-tests .fail { color: #000000; background-color: #EE5757; } 204 | #qunit-tests .fail .test-name, 205 | #qunit-tests .fail .module-name { color: #000000; } 206 | 207 | #qunit-tests .fail .test-actual { color: #EE5757; } 208 | #qunit-tests .fail .test-expected { color: green; } 209 | 210 | #qunit-banner.qunit-fail { background-color: #EE5757; } 211 | 212 | 213 | /** Result */ 214 | 215 | #qunit-testresult { 216 | padding: 0.5em 0.5em 0.5em 2.5em; 217 | 218 | color: #2b81af; 219 | background-color: #D2E0E6; 220 | 221 | border-bottom: 1px solid white; 222 | } 223 | 224 | /** Fixture */ 225 | 226 | #qunit-fixture { 227 | position: absolute; 228 | top: -10000px; 229 | left: -10000px; 230 | width: 1000px; 231 | height: 1000px; 232 | } 233 | -------------------------------------------------------------------------------- /app/tests/handlebars-helpers_test.js: -------------------------------------------------------------------------------- 1 | require('dashboard/handlebars-helpers'); 2 | 3 | module("{{echo}}"); 4 | 5 | test("helper is available", 6 | function() { 7 | ok(Ember.Handlebars.helpers.echo, "echo helper is available"); 8 | }); 9 | 10 | test("returns the specified property", 11 | function() { 12 | var view = Ember.View.create({ 13 | obj: Ember.Object.create({ 14 | action: 'dingdong' 15 | }), 16 | template: Ember.Handlebars.compile('{{#with obj}}{{echo "action"}}{{/with}}') 17 | }); 18 | 19 | Ember.run(function() { 20 | view.appendTo('#qunit-fixture'); 21 | }); 22 | 23 | equal(Ember.$('#qunit-fixture').text(), "dingdong", "echo helper outputs correct value"); 24 | }); 25 | 26 | module("{{ago}}"); 27 | 28 | test("helper is available", 29 | function() { 30 | ok(Ember.Handlebars.helpers.ago, "ago helper is availbale"); 31 | }); 32 | 33 | test("ago can handle Date objects", 34 | function() { 35 | var view = Ember.View.create({ 36 | time: new Date(), 37 | template: Ember.Handlebars.compile('{{ago time}}') 38 | }); 39 | 40 | Ember.run(function() { 41 | view.appendTo('#qunit-fixture'); 42 | }); 43 | 44 | equal(Ember.$('#qunit-fixture').text(), "a few seconds ago", "ago helper outputs correct value"); 45 | }); 46 | 47 | test("ago can handle numbers", 48 | function() { 49 | var view = Ember.View.create({ 50 | time: new Date().getTime(), 51 | template: Ember.Handlebars.compile('{{ago time}}') 52 | }); 53 | 54 | Ember.run(function() { 55 | view.appendTo('#qunit-fixture'); 56 | }); 57 | 58 | equal(Ember.$('#qunit-fixture').text(), "a few seconds ago", "ago helper outputs correct value"); 59 | }); 60 | 61 | test("ago respects 'isSeconds' parameter", 62 | function() { 63 | var nowInSeconds = new Date().getTime() / 1000; 64 | var view = Ember.View.create({ 65 | time: nowInSeconds, 66 | template: Ember.Handlebars.compile('{{ago time isSeconds=true}}') 67 | }); 68 | 69 | Ember.run(function() { 70 | view.appendTo('#qunit-fixture'); 71 | }); 72 | 73 | equal(Ember.$('#qunit-fixture').text(), "a few seconds ago", "ago helper outputs correct value"); 74 | }); 75 | 76 | module("{{parseTweet}}"); 77 | 78 | test("helper is available", 79 | function() { 80 | ok(Ember.Handlebars.helpers.parseTweet, "parseTweet helper is available"); 81 | }); 82 | 83 | test("parseTweet uses context.text as tweet text", 84 | function() { 85 | var view = Ember.View.create({ 86 | tweet: Ember.Object.create({ 87 | text: "@user #tag http://google.com" 88 | }), 89 | template: Ember.Handlebars.compile('{{#with tweet}}{{parseTweet}}{{/with}}') 90 | }); 91 | 92 | Ember.run(function() { 93 | view.appendTo('#qunit-fixture'); 94 | }); 95 | 96 | equal(Ember.$('#qunit-fixture').text(), "@user #tag http://google.com", "output text is the same"); 97 | var links = Ember.$('#qunit-fixture').find('a'); 98 | equal(links.length, 3, "parsed tweet should contain 3 links"); 99 | equal(Ember.$(links[0]).text(), "user", "first link has the user as text"); 100 | equal(Ember.$(links[1]).text(), "#tag", "second link has the #tag as text"); 101 | equal(Ember.$(links[2]).text(), "http://google.com", "third link has the url as text"); 102 | }); 103 | 104 | test("if a parameter is specified, parseTweet uses this as path to the tweet", 105 | function() { 106 | var view = Ember.View.create({ 107 | tweet: "@user #tag http://google.com", 108 | template: Ember.Handlebars.compile('{{parseTweet tweet}}') 109 | }); 110 | 111 | Ember.run(function() { 112 | view.appendTo('#qunit-fixture'); 113 | }); 114 | 115 | equal(Ember.$('#qunit-fixture').text(), "@user #tag http://google.com", "output text is the same"); 116 | var links = Ember.$('#qunit-fixture').find('a'); 117 | equal(links.length, 3, "parsed tweet should contain 3 links"); 118 | equal(Ember.$(links[0]).text(), "user", "first link has the user as text"); 119 | equal(Ember.$(links[1]).text(), "#tag", "second link has the #tag as text"); 120 | equal(Ember.$(links[2]).text(), "http://google.com", "third link has the url as text"); 121 | }); 122 | 123 | module("{{parseTweet}}"); 124 | 125 | test("helper is available", 126 | function() { 127 | ok(Ember.Handlebars.helpers.parseTweet, "parseTweet helper is available"); 128 | }); 129 | 130 | test("parseTweet uses context.text as tweet text", 131 | function() { 132 | var view = Ember.View.create({ 133 | tweet: Ember.Object.create({ 134 | text: "@user #tag http://google.com" 135 | }), 136 | template: Ember.Handlebars.compile('{{#with tweet}}{{parseTweet}}{{/with}}') 137 | }); 138 | 139 | Ember.run(function() { 140 | view.appendTo('#qunit-fixture'); 141 | }); 142 | 143 | equal(Ember.$('#qunit-fixture').text(), "@user #tag http://google.com", "output text is the same"); 144 | var links = Ember.$('#qunit-fixture').find('a'); 145 | equal(links.length, 3, "parsed tweet should contain 3 links"); 146 | equal(Ember.$(links[0]).text(), "user", "first link has the user as text"); 147 | equal(Ember.$(links[1]).text(), "#tag", "second link has the #tag as text"); 148 | equal(Ember.$(links[2]).text(), "http://google.com", "third link has the url as text"); 149 | }); 150 | 151 | test("if a parameter is specified, parseTweet uses this as path to the tweet", 152 | function() { 153 | var view = Ember.View.create({ 154 | tweet: "@user #tag http://google.com", 155 | template: Ember.Handlebars.compile('{{parseTweet tweet}}') 156 | }); 157 | 158 | Ember.run(function() { 159 | view.appendTo('#qunit-fixture'); 160 | }); 161 | 162 | equal(Ember.$('#qunit-fixture').text(), "@user #tag http://google.com", "output text is the same"); 163 | var links = Ember.$('#qunit-fixture').find('a'); 164 | equal(links.length, 3, "parsed tweet should contain 3 links"); 165 | equal(Ember.$(links[0]).text(), "user", "first link has the user as text"); 166 | equal(Ember.$(links[1]).text(), "#tag", "second link has the #tag as text"); 167 | equal(Ember.$(links[2]).text(), "http://google.com", "third link has the url as text"); 168 | }); 169 | 170 | module("{{trim}}"); 171 | 172 | test("trim is available", 173 | function() { 174 | ok(Ember.Handlebars.helpers.trim, "trim helper is available"); 175 | }); 176 | 177 | test("outputs by default given string", 178 | function() { 179 | var view = Ember.View.create({ 180 | property: 'mystring', 181 | template: Ember.Handlebars.compile('{{trim property}}') 182 | }); 183 | 184 | Ember.run(function() { 185 | view.appendTo('#qunit-fixture'); 186 | }); 187 | 188 | equal(Ember.$('#qunit-fixture').text(), 'mystring', "by default doesn't trim anything"); 189 | }); 190 | 191 | test("trims string to specific length", 192 | function() { 193 | var view = Ember.View.create({ 194 | property: 'mystring', 195 | template: Ember.Handlebars.compile('{{trim property length=2}}') 196 | }); 197 | 198 | Ember.run(function() { 199 | view.appendTo('#qunit-fixture'); 200 | }); 201 | 202 | equal(Ember.$('#qunit-fixture').text(), 'my', "it trims the string to given length"); 203 | }); 204 | 205 | test("exceeding length does not blow up the handler", 206 | function() { 207 | var view = Ember.View.create({ 208 | property: 'mystring', 209 | template: Ember.Handlebars.compile('{{trim property length=100}}') 210 | }); 211 | 212 | Ember.run(function() { 213 | view.appendTo('#qunit-fixture'); 214 | }); 215 | 216 | equal(Ember.$('#qunit-fixture').text(), 'mystring', "it trims the string to given length"); 217 | }); -------------------------------------------------------------------------------- /test_sources/stackoverflow.json: -------------------------------------------------------------------------------- 1 | { 2 | "items": [{ 3 | "question_id": 10048691, 4 | "last_edit_date": 1333746525, 5 | "creation_date": 1333742816, 6 | "last_activity_date": 1333746525, 7 | "score": 0, 8 | "answer_count": 1, 9 | "title": "Headless testing Ember application with guard and jasmine", 10 | "tags": ["emberjs", "jasmine", "phantomjs"], 11 | "view_count": 9, 12 | "owner": { 13 | "user_id": 1259390, 14 | "display_name": "jrabary", 15 | "reputation": 6, 16 | "user_type": "registered", 17 | "profile_image": "http://www.gravatar.com/avatar/ab64211b24abeb1e283c0ea1326d6ad0?d=identicon&r=PG", 18 | "link": "http://stackoverflow.com/users/1259390/jrabary", 19 | "accept_rate": 0 20 | }, 21 | "link": "http://stackoverflow.com/questions/10048691/headless-testing-ember-application-with-guard-and-jasmine", 22 | "is_answered": false 23 | }, 24 | { 25 | "question_id": 10047447, 26 | "creation_date": 1333736006, 27 | "last_activity_date": 1333745211, 28 | "score": 0, 29 | "answer_count": 1, 30 | "title": "Binding to a nested model in Ember.js", 31 | "tags": ["javascript", "emberjs"], 32 | "view_count": 15, 33 | "owner": { 34 | "user_id": 1293561, 35 | "display_name": "sirvine", 36 | "reputation": 1, 37 | "user_type": "registered", 38 | "profile_image": "http://www.gravatar.com/avatar/31654782c71cfd0073e7c7394815e299?d=identicon&r=PG", 39 | "link": "http://stackoverflow.com/users/1293561/sirvine" 40 | }, 41 | "link": "http://stackoverflow.com/questions/10047447/binding-to-a-nested-model-in-ember-js", 42 | "is_answered": false 43 | }, 44 | { 45 | "question_id": 9991749, 46 | "last_edit_date": 1333526671, 47 | "creation_date": 1333450277, 48 | "last_activity_date": 1333742528, 49 | "score": 5, 50 | "answer_count": 1, 51 | "title": "Delete created uncommited record?", 52 | "tags": ["emberjs", "emberdata"], 53 | "view_count": 46, 54 | "owner": { 55 | "user_id": 219931, 56 | "display_name": "Dziamid", 57 | "reputation": 2159, 58 | "user_type": "registered", 59 | "profile_image": "http://www.gravatar.com/avatar/3430e60d8dd7c8d8d2b0c377e0c6cc57?d=identicon&r=PG", 60 | "link": "http://stackoverflow.com/users/219931/dziamid", 61 | "accept_rate": 92 62 | }, 63 | "link": "http://stackoverflow.com/questions/9991749/delete-created-uncommited-record", 64 | "is_answered": false 65 | }, 66 | { 67 | "question_id": 10046699, 68 | "creation_date": 1333731967, 69 | "last_activity_date": 1333735105, 70 | "score": 1, 71 | "answer_count": 1, 72 | "title": "Has anyone successfully integrated Ember.js - Phonegap (and jQuery Mobile)?", 73 | "tags": ["ios", "phonegap", "emberjs", "cordova"], 74 | "view_count": 18, 75 | "owner": { 76 | "user_id": 359104, 77 | "display_name": "Zack", 78 | "reputation": 597, 79 | "user_type": "registered", 80 | "profile_image": "http://www.gravatar.com/avatar/0d2643856090c41deef2b021c93764b3?d=identicon&r=PG", 81 | "link": "http://stackoverflow.com/users/359104/zack", 82 | "accept_rate": 94 83 | }, 84 | "link": "http://stackoverflow.com/questions/10046699/has-anyone-successfully-integrated-ember-js-phonegap-and-jquery-mobile", 85 | "is_answered": true 86 | }, 87 | { 88 | "question_id": 10045619, 89 | "creation_date": 1333726203, 90 | "last_activity_date": 1333727370, 91 | "score": 1, 92 | "answer_count": 1, 93 | "title": "Controller Strategy / Garbage Collection (destroy)", 94 | "tags": ["emberjs"], 95 | "view_count": 28, 96 | "owner": { 97 | "user_id": 130221, 98 | "display_name": "Nick Franceschina", 99 | "reputation": 847, 100 | "user_type": "registered", 101 | "profile_image": "http://www.gravatar.com/avatar/19f7f12138e2a60930b56253e6f54b11?d=identicon&r=PG", 102 | "link": "http://stackoverflow.com/users/130221/nick-franceschina", 103 | "accept_rate": 95 104 | }, 105 | "link": "http://stackoverflow.com/questions/10045619/controller-strategy-garbage-collection-destroy", 106 | "is_answered": true 107 | }, 108 | { 109 | "question_id": 10002737, 110 | "creation_date": 1333494362, 111 | "last_activity_date": 1333719471, 112 | "score": 0, 113 | "answer_count": 2, 114 | "title": "emberjs: when I try to use the action helper, i get - Handlebars error: Could not find property 'action' on object", 115 | "tags": ["emberjs"], 116 | "view_count": 46, 117 | "owner": { 118 | "user_id": 1311561, 119 | "display_name": "Robert Beaupre", 120 | "reputation": 1, 121 | "user_type": "registered", 122 | "profile_image": "http://www.gravatar.com/avatar/3b449142aef0ee76ee7e09396b6805dc?d=identicon&r=PG", 123 | "link": "http://stackoverflow.com/users/1311561/robert-beaupre" 124 | }, 125 | "link": "http://stackoverflow.com/questions/10002737/emberjs-when-i-try-to-use-the-action-helper-i-get-handlebars-error-could-no", 126 | "is_answered": true 127 | }, 128 | { 129 | "question_id": 9445676, 130 | "last_edit_date": 1330206430, 131 | "creation_date": 1330186510, 132 | "last_activity_date": 1333719455, 133 | "score": 1, 134 | "answer_count": 2, 135 | "accepted_answer_id": 9450608, 136 | "title": "Computed property being observed doesn't fire if changed twice in a row", 137 | "tags": ["emberjs"], 138 | "view_count": 87, 139 | "owner": { 140 | "user_id": 88170, 141 | "display_name": "thedesertfox", 142 | "reputation": 131, 143 | "user_type": "registered", 144 | "profile_image": "http://www.gravatar.com/avatar/0e0476bc1ab931a7382caae4fa7a537f?d=identicon&r=PG", 145 | "link": "http://stackoverflow.com/users/88170/thedesertfox", 146 | "accept_rate": 73 147 | }, 148 | "link": "http://stackoverflow.com/questions/9445676/computed-property-being-observed-doesnt-fire-if-changed-twice-in-a-row", 149 | "is_answered": true 150 | }, 151 | { 152 | "question_id": 10043951, 153 | "creation_date": 1333717838, 154 | "last_activity_date": 1333719181, 155 | "score": 1, 156 | "answer_count": 2, 157 | "accepted_answer_id": 10044059, 158 | "title": "Ember.js init behaviour", 159 | "tags": ["javascript", "emberjs"], 160 | "view_count": 13, 161 | "owner": { 162 | "user_id": 172439, 163 | "display_name": "spacevillain", 164 | "reputation": 412, 165 | "user_type": "registered", 166 | "profile_image": "http://www.gravatar.com/avatar/37c374a1516b645b0ee0e6363c82d887?d=identicon&r=PG", 167 | "link": "http://stackoverflow.com/users/172439/spacevillain", 168 | "accept_rate": 64 169 | }, 170 | "link": "http://stackoverflow.com/questions/10043951/ember-js-init-behaviour", 171 | "is_answered": true 172 | }, 173 | { 174 | "question_id": 10043451, 175 | "creation_date": 1333715112, 176 | "last_activity_date": 1333716683, 177 | "score": 3, 178 | "answer_count": 1, 179 | "accepted_answer_id": 10043731, 180 | "title": "Ember.ArrayProxy changes not triggering handlebars #each update", 181 | "tags": ["emberjs"], 182 | "view_count": 14, 183 | "owner": { 184 | "user_id": 993270, 185 | "display_name": "alexrothenberg", 186 | "reputation": 18, 187 | "user_type": "registered", 188 | "profile_image": "http://www.gravatar.com/avatar/f4252ea05ddfbeb7b1803e9941ea46aa?d=identicon&r=PG", 189 | "link": "http://stackoverflow.com/users/993270/alexrothenberg" 190 | }, 191 | "link": "http://stackoverflow.com/questions/10043451/ember-arrayproxy-changes-not-triggering-handlebars-each-update", 192 | "is_answered": true 193 | }, 194 | { 195 | "question_id": 10040339, 196 | "creation_date": 1333696437, 197 | "last_activity_date": 1333700173, 198 | "score": 2, 199 | "answer_count": 1, 200 | "title": "Ember Views, Handlebars and jQuery Effects", 201 | "tags": ["emberjs"], 202 | "view_count": 25, 203 | "owner": { 204 | "user_id": 996265, 205 | "display_name": "mike", 206 | "reputation": 43, 207 | "user_type": "registered", 208 | "profile_image": "http://www.gravatar.com/avatar/dbc7ce5d92647532b49bbed08a75cfca?d=identicon&r=PG", 209 | "link": "http://stackoverflow.com/users/996265/mike" 210 | }, 211 | "link": "http://stackoverflow.com/questions/10040339/ember-views-handlebars-and-jquery-effects", 212 | "is_answered": true 213 | }], 214 | "quota_remaining": 261, 215 | "quota_max": 300, 216 | "has_more": true 217 | } -------------------------------------------------------------------------------- /test_sources/twitter.json: -------------------------------------------------------------------------------- 1 | { 2 | "completed_in": 0.023, 3 | "max_id": 188372280315871234, 4 | "max_id_str": "188372280315871234", 5 | "next_page": "?page=2&max_id=188372280315871234&q=emberjs", 6 | "page": 1, 7 | "query": "emberjs", 8 | "refresh_url": "?since_id=188372280315871234&q=emberjs", 9 | "results": [{ 10 | "created_at": "Fri, 06 Apr 2012 21:07:06 +0000", 11 | "from_user": "pangratz", 12 | "from_user_id": 49576387, 13 | "from_user_id_str": "49576387", 14 | "from_user_name": "Clemens M\u00fcller", 15 | "geo": null, 16 | "id": 188372280315871234, 17 | "id_str": "188372280315871234", 18 | "iso_language_code": "in", 19 | "metadata": { 20 | "result_type": "recent" 21 | }, 22 | "profile_image_url": "http:\/\/a0.twimg.com\/profile_images\/1866862141\/IMG_3719_normal.JPG", 23 | "profile_image_url_https": "https:\/\/si0.twimg.com\/profile_images\/1866862141\/IMG_3719_normal.JPG", 24 | "source": "<a href="http:\/\/www.echofon.com\/" rel="nofollow">Echofon<\/a>", 25 | "text": "Very good explanation about the difference of \u007b\u007bbind\u007d\u007d and \u007b\u007bbindAttr\u007d\u007d by @wagenet https:\/\/t.co\/7NrNOByV #emberjs", 26 | "to_user": null, 27 | "to_user_id": null, 28 | "to_user_id_str": null, 29 | "to_user_name": null 30 | }, 31 | { 32 | "created_at": "Fri, 06 Apr 2012 19:34:41 +0000", 33 | "from_user": "som32dt", 34 | "from_user_id": 41788781, 35 | "from_user_id_str": "41788781", 36 | "from_user_name": "som32dt", 37 | "geo": null, 38 | "id": 188349023655165952, 39 | "id_str": "188349023655165952", 40 | "iso_language_code": "en", 41 | "metadata": { 42 | "result_type": "recent" 43 | }, 44 | "profile_image_url": "http:\/\/a0.twimg.com\/sticky\/default_profile_images\/default_profile_6_normal.png", 45 | "profile_image_url_https": "https:\/\/si0.twimg.com\/sticky\/default_profile_images\/default_profile_6_normal.png", 46 | "source": "<a href="http:\/\/twitter.com\/">web<\/a>", 47 | "text": "JS : Ember - A JavaScript framework for creating ambitious web applications - http:\/\/t.co\/5VmiSqPD", 48 | "to_user": null, 49 | "to_user_id": null, 50 | "to_user_id_str": null, 51 | "to_user_name": null 52 | }, 53 | { 54 | "created_at": "Fri, 06 Apr 2012 17:55:20 +0000", 55 | "from_user": "agilemeister", 56 | "from_user_id": 68325967, 57 | "from_user_id_str": "68325967", 58 | "from_user_name": "Angeline Tan", 59 | "geo": null, 60 | "id": 188324023304855552, 61 | "id_str": "188324023304855552", 62 | "iso_language_code": "en", 63 | "metadata": { 64 | "result_type": "recent" 65 | }, 66 | "profile_image_url": "http:\/\/a0.twimg.com\/profile_images\/1170469190\/twitter44_normal.jpg", 67 | "profile_image_url_https": "https:\/\/si0.twimg.com\/profile_images\/1170469190\/twitter44_normal.jpg", 68 | "source": "<a href="http:\/\/twitter.com\/">web<\/a>", 69 | "text": "RT @bmelton: @agilemeister Working on easing the learning curve for Ember. Hopefully it will help some? -- http:\/\/t.co\/BdcXr7mG", 70 | "to_user": null, 71 | "to_user_id": null, 72 | "to_user_id_str": null, 73 | "to_user_name": null, 74 | "in_reply_to_status_id": 188083765459951617, 75 | "in_reply_to_status_id_str": "188083765459951617" 76 | }, 77 | { 78 | "created_at": "Fri, 06 Apr 2012 17:53:30 +0000", 79 | "from_user": "shoanm", 80 | "from_user_id": 14292596, 81 | "from_user_id_str": "14292596", 82 | "from_user_name": "Shoan", 83 | "geo": null, 84 | "id": 188323562141122560, 85 | "id_str": "188323562141122560", 86 | "iso_language_code": "en", 87 | "metadata": { 88 | "result_type": "recent" 89 | }, 90 | "profile_image_url": "http:\/\/a0.twimg.com\/profile_images\/53089644\/tilted_crop_normal.jpg", 91 | "profile_image_url_https": "https:\/\/si0.twimg.com\/profile_images\/53089644\/tilted_crop_normal.jpg", 92 | "source": "<a href="http:\/\/www.echofon.com\/" rel="nofollow">Echofon<\/a>", 93 | "text": "RT @pangratz: An Extremely Gentle Introduction to Ember.js - Part 1 by @bmelton http:\/\/t.co\/UVKmMUNR", 94 | "to_user": null, 95 | "to_user_id": null, 96 | "to_user_id_str": null, 97 | "to_user_name": null 98 | }, 99 | { 100 | "created_at": "Fri, 06 Apr 2012 16:12:24 +0000", 101 | "from_user": "quartzmo", 102 | "from_user_id": 116046841, 103 | "from_user_id_str": "116046841", 104 | "from_user_name": "Chris Smith", 105 | "geo": null, 106 | "id": 188298118301892609, 107 | "id_str": "188298118301892609", 108 | "iso_language_code": "en", 109 | "metadata": { 110 | "result_type": "recent" 111 | }, 112 | "profile_image_url": "http:\/\/a0.twimg.com\/profile_images\/708441488\/chris_avatar_photo_facebook_normal.png", 113 | "profile_image_url_https": "https:\/\/si0.twimg.com\/profile_images\/708441488\/chris_avatar_photo_facebook_normal.png", 114 | "source": "<a href="http:\/\/itunes.apple.com\/us\/app\/twitter\/id409789998?mt=12" rel="nofollow">Twitter for Mac<\/a>", 115 | "text": "My understanding is that if we start using #emberjs and #handlebarsjs, we all magically get mustaches. Poof! Awesome.", 116 | "to_user": null, 117 | "to_user_id": null, 118 | "to_user_id_str": null, 119 | "to_user_name": null 120 | }, 121 | { 122 | "created_at": "Fri, 06 Apr 2012 14:53:57 +0000", 123 | "from_user": "pangratz", 124 | "from_user_id": 49576387, 125 | "from_user_id_str": "49576387", 126 | "from_user_name": "Clemens M\u00fcller", 127 | "geo": null, 128 | "id": 188278375767478272, 129 | "id_str": "188278375767478272", 130 | "iso_language_code": "en", 131 | "metadata": { 132 | "result_type": "recent" 133 | }, 134 | "profile_image_url": "http:\/\/a0.twimg.com\/profile_images\/1866862141\/IMG_3719_normal.JPG", 135 | "profile_image_url_https": "https:\/\/si0.twimg.com\/profile_images\/1866862141\/IMG_3719_normal.JPG", 136 | "source": "<a href="http:\/\/www.echofon.com\/" rel="nofollow">Echofon<\/a>", 137 | "text": "An Extremely Gentle Introduction to Ember.js - Part 1 by @bmelton http:\/\/t.co\/UVKmMUNR", 138 | "to_user": null, 139 | "to_user_id": null, 140 | "to_user_id_str": null, 141 | "to_user_name": null 142 | }, 143 | { 144 | "created_at": "Fri, 06 Apr 2012 14:45:36 +0000", 145 | "from_user": "bmelton", 146 | "from_user_id": 17073443, 147 | "from_user_id_str": "17073443", 148 | "from_user_name": "bmelton", 149 | "geo": null, 150 | "id": 188276276417667072, 151 | "id_str": "188276276417667072", 152 | "iso_language_code": "en", 153 | "metadata": { 154 | "result_type": "recent" 155 | }, 156 | "profile_image_url": "http:\/\/a0.twimg.com\/profile_images\/952738636\/calvin_and_hobbes_normal.jpg", 157 | "profile_image_url_https": "https:\/\/si0.twimg.com\/profile_images\/952738636\/calvin_and_hobbes_normal.jpg", 158 | "source": "<a href="http:\/\/twitter.com\/">web<\/a>", 159 | "text": "@agilemeister Working on easing the learning curve for Ember. Hopefully it will help some? -- http:\/\/t.co\/BdcXr7mG", 160 | "to_user": "agilemeister", 161 | "to_user_id": 68325967, 162 | "to_user_id_str": "68325967", 163 | "to_user_name": "Angeline Tan", 164 | "in_reply_to_status_id": 188083765459951617, 165 | "in_reply_to_status_id_str": "188083765459951617" 166 | }, 167 | { 168 | "created_at": "Fri, 06 Apr 2012 05:14:18 +0000", 169 | "from_user": "brianvoss", 170 | "from_user_id": 14836811, 171 | "from_user_id_str": "14836811", 172 | "from_user_name": "Brian Voss", 173 | "geo": null, 174 | "id": 188132500667772928, 175 | "id_str": "188132500667772928", 176 | "iso_language_code": "en", 177 | "metadata": { 178 | "result_type": "recent" 179 | }, 180 | "profile_image_url": "http:\/\/a0.twimg.com\/profile_images\/1333296999\/headshot_normal.jpg", 181 | "profile_image_url_https": "https:\/\/si0.twimg.com\/profile_images\/1333296999\/headshot_normal.jpg", 182 | "source": "<a href="http:\/\/twitter.com\/">web<\/a>", 183 | "text": "@petele Thanks again for a great session on modern web apps! Going to be playing with EmberJS and LawnChair a lot more! #GTUG", 184 | "to_user": "petele", 185 | "to_user_id": 757903, 186 | "to_user_id_str": "757903", 187 | "to_user_name": "Pete LePage" 188 | }, 189 | { 190 | "created_at": "Fri, 06 Apr 2012 03:37:41 +0000", 191 | "from_user": "BenATkin", 192 | "from_user_id": 64218381, 193 | "from_user_id_str": "64218381", 194 | "from_user_name": "benatkin.com", 195 | "geo": null, 196 | "id": 188108187650629632, 197 | "id_str": "188108187650629632", 198 | "iso_language_code": "en", 199 | "metadata": { 200 | "result_type": "recent" 201 | }, 202 | "profile_image_url": "http:\/\/a0.twimg.com\/profile_images\/1794836902\/twitter_normal.jpg", 203 | "profile_image_url_https": "https:\/\/si0.twimg.com\/profile_images\/1794836902\/twitter_normal.jpg", 204 | "source": "<a href="http:\/\/twitter.com\/">web<\/a>", 205 | "text": "skimmed this morning, didn't grok; read tonight, grokked http:\/\/t.co\/WSSXVZ6G #emberjs", 206 | "to_user": null, 207 | "to_user_id": null, 208 | "to_user_id_str": null, 209 | "to_user_name": null 210 | }, 211 | { 212 | "created_at": "Thu, 05 Apr 2012 21:55:48 +0000", 213 | "from_user": "lukemelia", 214 | "from_user_id": 6134412, 215 | "from_user_id_str": "6134412", 216 | "from_user_name": "Luke Melia", 217 | "geo": null, 218 | "id": 188022149666189313, 219 | "id_str": "188022149666189313", 220 | "iso_language_code": "en", 221 | "metadata": { 222 | "result_type": "recent" 223 | }, 224 | "profile_image_url": "http:\/\/a0.twimg.com\/profile_images\/1768827648\/image1327076921_normal.png", 225 | "profile_image_url_https": "https:\/\/si0.twimg.com\/profile_images\/1768827648\/image1327076921_normal.png", 226 | "source": "<a href="http:\/\/itunes.apple.com\/us\/app\/twitter\/id409789998?mt=12" rel="nofollow">Twitter for Mac<\/a>", 227 | "text": "The intersection of routing and statemachines is important to the next wave of JS browser apps: see @ghempton http:\/\/t.co\/MJ05YVvk #emberjs", 228 | "to_user": null, 229 | "to_user_id": null, 230 | "to_user_id_str": null, 231 | "to_user_name": null 232 | }, 233 | { 234 | "created_at": "Thu, 05 Apr 2012 20:42:06 +0000", 235 | "from_user": "climboid", 236 | "from_user_id": 214605119, 237 | "from_user_id_str": "214605119", 238 | "from_user_name": "Oscar Villarreal", 239 | "geo": null, 240 | "id": 188003604240666624, 241 | "id_str": "188003604240666624", 242 | "iso_language_code": "en", 243 | "metadata": { 244 | "result_type": "recent" 245 | }, 246 | "profile_image_url": "http:\/\/a0.twimg.com\/profile_images\/1844431824\/me_normal.jpg", 247 | "profile_image_url_https": "https:\/\/si0.twimg.com\/profile_images\/1844431824\/me_normal.jpg", 248 | "source": "<a href="http:\/\/twitter.com\/tweetbutton" rel="nofollow">Tweet Button<\/a>", 249 | "text": "events on arrayproxy emberjs http:\/\/t.co\/3XQbZQfA", 250 | "to_user": null, 251 | "to_user_id": null, 252 | "to_user_id_str": null, 253 | "to_user_name": null 254 | }, 255 | { 256 | "created_at": "Thu, 05 Apr 2012 17:24:28 +0000", 257 | "from_user": "alexrothenberg", 258 | "from_user_id": 17340043, 259 | "from_user_id_str": "17340043", 260 | "from_user_name": "alexrothenberg", 261 | "geo": null, 262 | "id": 187953868125114368, 263 | "id_str": "187953868125114368", 264 | "iso_language_code": "en", 265 | "metadata": { 266 | "result_type": "recent" 267 | }, 268 | "profile_image_url": "http:\/\/a0.twimg.com\/profile_images\/518386110\/alex_bw_normal.jpg", 269 | "profile_image_url_https": "https:\/\/si0.twimg.com\/profile_images\/518386110\/alex_bw_normal.jpg", 270 | "source": "<a href="http:\/\/itunes.apple.com\/us\/app\/twitter\/id409789998?mt=12" rel="nofollow">Twitter for Mac<\/a>", 271 | "text": "Nice article on testing #emberjs apps and the run loop \u201c@dagda1: How I Learned to Stop Worrying and Love the Runloop\" http:\/\/t.co\/PH3sEiNS\u201d", 272 | "to_user": null, 273 | "to_user_id": null, 274 | "to_user_id_str": null, 275 | "to_user_name": null 276 | }, 277 | { 278 | "created_at": "Thu, 05 Apr 2012 16:27:28 +0000", 279 | "from_user": "distracteddev", 280 | "from_user_id": 272517143, 281 | "from_user_id_str": "272517143", 282 | "from_user_name": "Zeus Lalkaka", 283 | "geo": null, 284 | "id": 187939522925969408, 285 | "id_str": "187939522925969408", 286 | "iso_language_code": "en", 287 | "metadata": { 288 | "result_type": "recent" 289 | }, 290 | "profile_image_url": "http:\/\/a0.twimg.com\/profile_images\/1681547896\/image_normal.jpg", 291 | "profile_image_url_https": "https:\/\/si0.twimg.com\/profile_images\/1681547896\/image_normal.jpg", 292 | "source": "<a href="http:\/\/www.apple.com" rel="nofollow">Safari on iOS<\/a>", 293 | "text": "RIL http:\/\/t.co\/DKL2dUtQ", 294 | "to_user": null, 295 | "to_user_id": null, 296 | "to_user_id_str": null, 297 | "to_user_name": null 298 | }, 299 | { 300 | "created_at": "Thu, 05 Apr 2012 08:27:01 +0000", 301 | "from_user": "NewsNodejs", 302 | "from_user_id": 486450041, 303 | "from_user_id_str": "486450041", 304 | "from_user_name": "nodejs-news", 305 | "geo": null, 306 | "id": 187818613749714944, 307 | "id_str": "187818613749714944", 308 | "iso_language_code": "en", 309 | "metadata": { 310 | "result_type": "recent" 311 | }, 312 | "profile_image_url": "http:\/\/a0.twimg.com\/profile_images\/1812607216\/nodejs-news_normal.png", 313 | "profile_image_url_https": "https:\/\/si0.twimg.com\/profile_images\/1812607216\/nodejs-news_normal.png", 314 | "source": "<a href="http:\/\/www.scoop.it" rel="nofollow">Scoop.it<\/a>", 315 | "text": "Ember and D3: Building responsive analytics | #javascript #3Djs #emberjs http:\/\/t.co\/4LIJRJ9U", 316 | "to_user": null, 317 | "to_user_id": null, 318 | "to_user_id_str": null, 319 | "to_user_name": null 320 | }, 321 | { 322 | "created_at": "Thu, 05 Apr 2012 07:15:07 +0000", 323 | "from_user": "mathrobin", 324 | "from_user_id": 133651103, 325 | "from_user_id_str": "133651103", 326 | "from_user_name": "mathrobin", 327 | "geo": null, 328 | "id": 187800519392239616, 329 | "id_str": "187800519392239616", 330 | "iso_language_code": "fr", 331 | "metadata": { 332 | "result_type": "recent" 333 | }, 334 | "profile_image_url": "http:\/\/a0.twimg.com\/profile_images\/1369959547\/Avatar_normal.png", 335 | "profile_image_url_https": "https:\/\/si0.twimg.com\/profile_images\/1369959547\/Avatar_normal.png", 336 | "source": "<a href="http:\/\/www.hootsuite.com" rel="nofollow">HootSuite<\/a>", 337 | "text": "Astuce #Emberjs : r\u00e9ussir vos tableaux http:\/\/t.co\/IcM530nz", 338 | "to_user": null, 339 | "to_user_id": null, 340 | "to_user_id_str": null, 341 | "to_user_name": null 342 | }], 343 | "results_per_page": 15, 344 | "since_id": 0, 345 | "since_id_str": "0" 346 | } -------------------------------------------------------------------------------- /app/vendor/moment.js: -------------------------------------------------------------------------------- 1 | // moment.js 2 | // version : 1.5.0 3 | // author : Tim Wood 4 | // license : MIT 5 | // momentjs.com 6 | 7 | (function (Date, undefined) { 8 | 9 | var moment, 10 | round = Math.round, 11 | languages = {}, 12 | hasModule = (typeof module !== 'undefined'), 13 | paramsToParse = 'months|monthsShort|monthsParse|weekdays|weekdaysShort|longDateFormat|calendar|relativeTime|ordinal|meridiem'.split('|'), 14 | i, 15 | jsonRegex = /^\/?Date\((\-?\d+)/i, 16 | charactersToReplace = /(\[[^\[]*\])|(\\)?(Mo|MM?M?M?|Do|DDDo|DD?D?D?|dddd?|do?|w[o|w]?|YYYY|YY|a|A|hh?|HH?|mm?|ss?|zz?|ZZ?|LT|LL?L?L?)/g, 17 | nonuppercaseLetters = /[^A-Z]/g, 18 | timezoneRegex = /\([A-Za-z ]+\)|:[0-9]{2} [A-Z]{3} /g, 19 | tokenCharacters = /(\\)?(MM?M?M?|dd?d?d|DD?D?D?|YYYY|YY|a|A|hh?|HH?|mm?|ss?|ZZ?|T)/g, 20 | inputCharacters = /(\\)?([0-9]+|([a-zA-Z\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+|([\+\-]\d\d:?\d\d))/gi, 21 | isoRegex = /\d{4}.\d\d.\d\d(T(\d\d(.\d\d(.\d\d)?)?)?([\+\-]\d\d:?\d\d)?)?/, 22 | isoFormat = 'YYYY-MM-DDTHH:mm:ssZ', 23 | isoTimes = [ 24 | ['HH:mm:ss', /T\d\d:\d\d:\d\d/], 25 | ['HH:mm', /T\d\d:\d\d/], 26 | ['HH', /T\d\d/] 27 | ], 28 | timezoneParseRegex = /([\+\-]|\d\d)/gi, 29 | VERSION = "1.5.0", 30 | shortcuts = 'Month|Date|Hours|Minutes|Seconds|Milliseconds'.split('|'); 31 | 32 | // Moment prototype object 33 | function Moment(date, isUTC) { 34 | this._d = date; 35 | this._isUTC = !!isUTC; 36 | } 37 | 38 | // left zero fill a number 39 | // see http://jsperf.com/left-zero-filling for performance comparison 40 | function leftZeroFill(number, targetLength) { 41 | var output = number + ''; 42 | while (output.length < targetLength) { 43 | output = '0' + output; 44 | } 45 | return output; 46 | } 47 | 48 | // helper function for _.addTime and _.subtractTime 49 | function dateAddRemove(date, _input, adding, val) { 50 | var isString = (typeof _input === 'string'), 51 | input = isString ? {} : _input, 52 | ms, d, M, currentDate; 53 | if (isString && val) { 54 | input[_input] = +val; 55 | } 56 | ms = (input.ms || input.milliseconds || 0) + 57 | (input.s || input.seconds || 0) * 1e3 + // 1000 58 | (input.m || input.minutes || 0) * 6e4 + // 1000 * 60 59 | (input.h || input.hours || 0) * 36e5; // 1000 * 60 * 60 60 | d = (input.d || input.days || 0) + 61 | (input.w || input.weeks || 0) * 7; 62 | M = (input.M || input.months || 0) + 63 | (input.y || input.years || 0) * 12; 64 | if (ms) { 65 | date.setTime(+date + ms * adding); 66 | } 67 | if (d) { 68 | date.setDate(date.getDate() + d * adding); 69 | } 70 | if (M) { 71 | currentDate = date.getDate(); 72 | date.setDate(1); 73 | date.setMonth(date.getMonth() + M * adding); 74 | date.setDate(Math.min(new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(), currentDate)); 75 | } 76 | return date; 77 | } 78 | 79 | // check if is an array 80 | function isArray(input) { 81 | return Object.prototype.toString.call(input) === '[object Array]'; 82 | } 83 | 84 | // convert an array to a date. 85 | // the array should mirror the parameters below 86 | // note: all values past the year are optional and will default to the lowest possible value. 87 | // [year, month, day , hour, minute, second, millisecond] 88 | function dateFromArray(input) { 89 | return new Date(input[0], input[1] || 0, input[2] || 1, input[3] || 0, input[4] || 0, input[5] || 0, input[6] || 0); 90 | } 91 | 92 | // format date using native date object 93 | function formatMoment(m, inputString) { 94 | var currentMonth = m.month(), 95 | currentDate = m.date(), 96 | currentYear = m.year(), 97 | currentDay = m.day(), 98 | currentHours = m.hours(), 99 | currentMinutes = m.minutes(), 100 | currentSeconds = m.seconds(), 101 | currentZone = -m.zone(), 102 | ordinal = moment.ordinal, 103 | meridiem = moment.meridiem; 104 | // check if the character is a format 105 | // return formatted string or non string. 106 | // 107 | // uses switch/case instead of an object of named functions (like http://phpjs.org/functions/date:380) 108 | // for minification and performance 109 | // see http://jsperf.com/object-of-functions-vs-switch for performance comparison 110 | function replaceFunction(input) { 111 | // create a couple variables to be used later inside one of the cases. 112 | var a, b; 113 | switch (input) { 114 | // MONTH 115 | case 'M' : 116 | return currentMonth + 1; 117 | case 'Mo' : 118 | return (currentMonth + 1) + ordinal(currentMonth + 1); 119 | case 'MM' : 120 | return leftZeroFill(currentMonth + 1, 2); 121 | case 'MMM' : 122 | return moment.monthsShort[currentMonth]; 123 | case 'MMMM' : 124 | return moment.months[currentMonth]; 125 | // DAY OF MONTH 126 | case 'D' : 127 | return currentDate; 128 | case 'Do' : 129 | return currentDate + ordinal(currentDate); 130 | case 'DD' : 131 | return leftZeroFill(currentDate, 2); 132 | // DAY OF YEAR 133 | case 'DDD' : 134 | a = new Date(currentYear, currentMonth, currentDate); 135 | b = new Date(currentYear, 0, 1); 136 | return ~~ (((a - b) / 864e5) + 1.5); 137 | case 'DDDo' : 138 | a = replaceFunction('DDD'); 139 | return a + ordinal(a); 140 | case 'DDDD' : 141 | return leftZeroFill(replaceFunction('DDD'), 3); 142 | // WEEKDAY 143 | case 'd' : 144 | return currentDay; 145 | case 'do' : 146 | return currentDay + ordinal(currentDay); 147 | case 'ddd' : 148 | return moment.weekdaysShort[currentDay]; 149 | case 'dddd' : 150 | return moment.weekdays[currentDay]; 151 | // WEEK OF YEAR 152 | case 'w' : 153 | a = new Date(currentYear, currentMonth, currentDate - currentDay + 5); 154 | b = new Date(a.getFullYear(), 0, 4); 155 | return ~~ ((a - b) / 864e5 / 7 + 1.5); 156 | case 'wo' : 157 | a = replaceFunction('w'); 158 | return a + ordinal(a); 159 | case 'ww' : 160 | return leftZeroFill(replaceFunction('w'), 2); 161 | // YEAR 162 | case 'YY' : 163 | return leftZeroFill(currentYear % 100, 2); 164 | case 'YYYY' : 165 | return currentYear; 166 | // AM / PM 167 | case 'a' : 168 | return currentHours > 11 ? meridiem.pm : meridiem.am; 169 | case 'A' : 170 | return currentHours > 11 ? meridiem.PM : meridiem.AM; 171 | // 24 HOUR 172 | case 'H' : 173 | return currentHours; 174 | case 'HH' : 175 | return leftZeroFill(currentHours, 2); 176 | // 12 HOUR 177 | case 'h' : 178 | return currentHours % 12 || 12; 179 | case 'hh' : 180 | return leftZeroFill(currentHours % 12 || 12, 2); 181 | // MINUTE 182 | case 'm' : 183 | return currentMinutes; 184 | case 'mm' : 185 | return leftZeroFill(currentMinutes, 2); 186 | // SECOND 187 | case 's' : 188 | return currentSeconds; 189 | case 'ss' : 190 | return leftZeroFill(currentSeconds, 2); 191 | // TIMEZONE 192 | case 'zz' : 193 | // depreciating 'zz' fall through to 'z' 194 | case 'z' : 195 | return (m._d.toString().match(timezoneRegex) || [''])[0].replace(nonuppercaseLetters, ''); 196 | case 'Z' : 197 | return (currentZone < 0 ? '-' : '+') + leftZeroFill(~~(Math.abs(currentZone) / 60), 2) + ':' + leftZeroFill(~~(Math.abs(currentZone) % 60), 2); 198 | case 'ZZ' : 199 | return (currentZone < 0 ? '-' : '+') + leftZeroFill(~~(10 * Math.abs(currentZone) / 6), 4); 200 | // LONG DATES 201 | case 'L' : 202 | case 'LL' : 203 | case 'LLL' : 204 | case 'LLLL' : 205 | case 'LT' : 206 | return formatMoment(m, moment.longDateFormat[input]); 207 | // DEFAULT 208 | default : 209 | return input.replace(/(^\[)|(\\)|\]$/g, ""); 210 | } 211 | } 212 | return inputString.replace(charactersToReplace, replaceFunction); 213 | } 214 | 215 | // date from string and format string 216 | function makeDateFromStringAndFormat(string, format) { 217 | var inArray = [0, 0, 1, 0, 0, 0, 0], 218 | timezoneHours = 0, 219 | timezoneMinutes = 0, 220 | isUsingUTC = false, 221 | inputParts = string.match(inputCharacters), 222 | formatParts = format.match(tokenCharacters), 223 | len = Math.min(inputParts.length, formatParts.length), 224 | i, 225 | isPm; 226 | 227 | // function to convert string input to date 228 | function addTime(format, input) { 229 | var a; 230 | switch (format) { 231 | // MONTH 232 | case 'M' : 233 | // fall through to MM 234 | case 'MM' : 235 | inArray[1] = ~~input - 1; 236 | break; 237 | case 'MMM' : 238 | // fall through to MMMM 239 | case 'MMMM' : 240 | for (a = 0; a < 12; a++) { 241 | if (moment.monthsParse[a].test(input)) { 242 | inArray[1] = a; 243 | break; 244 | } 245 | } 246 | break; 247 | // DAY OF MONTH 248 | case 'D' : 249 | // fall through to DDDD 250 | case 'DD' : 251 | // fall through to DDDD 252 | case 'DDD' : 253 | // fall through to DDDD 254 | case 'DDDD' : 255 | inArray[2] = ~~input; 256 | break; 257 | // YEAR 258 | case 'YY' : 259 | input = ~~input; 260 | inArray[0] = input + (input > 70 ? 1900 : 2000); 261 | break; 262 | case 'YYYY' : 263 | inArray[0] = ~~Math.abs(input); 264 | break; 265 | // AM / PM 266 | case 'a' : 267 | // fall through to A 268 | case 'A' : 269 | isPm = (input.toLowerCase() === 'pm'); 270 | break; 271 | // 24 HOUR 272 | case 'H' : 273 | // fall through to hh 274 | case 'HH' : 275 | // fall through to hh 276 | case 'h' : 277 | // fall through to hh 278 | case 'hh' : 279 | inArray[3] = ~~input; 280 | break; 281 | // MINUTE 282 | case 'm' : 283 | // fall through to mm 284 | case 'mm' : 285 | inArray[4] = ~~input; 286 | break; 287 | // SECOND 288 | case 's' : 289 | // fall through to ss 290 | case 'ss' : 291 | inArray[5] = ~~input; 292 | break; 293 | // TIMEZONE 294 | case 'Z' : 295 | // fall through to ZZ 296 | case 'ZZ' : 297 | isUsingUTC = true; 298 | a = (input || '').match(timezoneParseRegex); 299 | if (a && a[1]) { 300 | timezoneHours = ~~a[1]; 301 | } 302 | if (a && a[2]) { 303 | timezoneMinutes = ~~a[2]; 304 | } 305 | // reverse offsets 306 | if (a && a[0] === '+') { 307 | timezoneHours = -timezoneHours; 308 | timezoneMinutes = -timezoneMinutes; 309 | } 310 | break; 311 | } 312 | } 313 | for (i = 0; i < len; i++) { 314 | addTime(formatParts[i], inputParts[i]); 315 | } 316 | // handle am pm 317 | if (isPm && inArray[3] < 12) { 318 | inArray[3] += 12; 319 | } 320 | // if is 12 am, change hours to 0 321 | if (isPm === false && inArray[3] === 12) { 322 | inArray[3] = 0; 323 | } 324 | // handle timezone 325 | inArray[3] += timezoneHours; 326 | inArray[4] += timezoneMinutes; 327 | // return 328 | return isUsingUTC ? new Date(Date.UTC.apply({}, inArray)) : dateFromArray(inArray); 329 | } 330 | 331 | // compare two arrays, return the number of differences 332 | function compareArrays(array1, array2) { 333 | var len = Math.min(array1.length, array2.length), 334 | lengthDiff = Math.abs(array1.length - array2.length), 335 | diffs = 0, 336 | i; 337 | for (i = 0; i < len; i++) { 338 | if (~~array1[i] !== ~~array2[i]) { 339 | diffs++; 340 | } 341 | } 342 | return diffs + lengthDiff; 343 | } 344 | 345 | // date from string and array of format strings 346 | function makeDateFromStringAndArray(string, formats) { 347 | var output, 348 | inputParts = string.match(inputCharacters), 349 | scores = [], 350 | scoreToBeat = 99, 351 | i, 352 | curDate, 353 | curScore; 354 | for (i = 0; i < formats.length; i++) { 355 | curDate = makeDateFromStringAndFormat(string, formats[i]); 356 | curScore = compareArrays(inputParts, formatMoment(new Moment(curDate), formats[i]).match(inputCharacters)); 357 | if (curScore < scoreToBeat) { 358 | scoreToBeat = curScore; 359 | output = curDate; 360 | } 361 | } 362 | return output; 363 | } 364 | 365 | // date from iso format 366 | function makeDateFromString(string) { 367 | var format = 'YYYY-MM-DDT', 368 | i; 369 | if (isoRegex.exec(string)) { 370 | for (i = 0; i < 3; i++) { 371 | if (isoTimes[i][1].exec(string)) { 372 | format += isoTimes[i][0]; 373 | break; 374 | } 375 | } 376 | return makeDateFromStringAndFormat(string, format + 'Z'); 377 | } 378 | return new Date(string); 379 | } 380 | 381 | // helper function for _date.from() and _date.fromNow() 382 | function substituteTimeAgo(string, number, withoutSuffix) { 383 | var rt = moment.relativeTime[string]; 384 | return (typeof rt === 'function') ? 385 | rt(number || 1, !!withoutSuffix, string) : 386 | rt.replace(/%d/i, number || 1); 387 | } 388 | 389 | function relativeTime(milliseconds, withoutSuffix) { 390 | var seconds = round(Math.abs(milliseconds) / 1000), 391 | minutes = round(seconds / 60), 392 | hours = round(minutes / 60), 393 | days = round(hours / 24), 394 | years = round(days / 365), 395 | args = seconds < 45 && ['s', seconds] || 396 | minutes === 1 && ['m'] || 397 | minutes < 45 && ['mm', minutes] || 398 | hours === 1 && ['h'] || 399 | hours < 22 && ['hh', hours] || 400 | days === 1 && ['d'] || 401 | days <= 25 && ['dd', days] || 402 | days <= 45 && ['M'] || 403 | days < 345 && ['MM', round(days / 30)] || 404 | years === 1 && ['y'] || ['yy', years]; 405 | args[2] = withoutSuffix; 406 | return substituteTimeAgo.apply({}, args); 407 | } 408 | 409 | moment = function (input, format) { 410 | if (input === null || input === '') { 411 | return null; 412 | } 413 | var date, 414 | matched; 415 | // parse Moment object 416 | if (input && input._d instanceof Date) { 417 | date = new Date(+input._d); 418 | // parse string and format 419 | } else if (format) { 420 | if (isArray(format)) { 421 | date = makeDateFromStringAndArray(input, format); 422 | } else { 423 | date = makeDateFromStringAndFormat(input, format); 424 | } 425 | // evaluate it as a JSON-encoded date 426 | } else { 427 | matched = jsonRegex.exec(input); 428 | date = input === undefined ? new Date() : 429 | matched ? new Date(+matched[1]) : 430 | input instanceof Date ? input : 431 | isArray(input) ? dateFromArray(input) : 432 | typeof input === 'string' ? makeDateFromString(input) : 433 | new Date(input); 434 | } 435 | return new Moment(date); 436 | }; 437 | 438 | // creating with utc 439 | moment.utc = function (input, format) { 440 | if (isArray(input)) { 441 | return new Moment(new Date(Date.UTC.apply({}, input)), true); 442 | } 443 | return (format && input) ? moment(input + ' 0', format + ' Z').utc() : moment(input).utc(); 444 | }; 445 | 446 | // humanizeDuration 447 | moment.humanizeDuration = function (num, type, withSuffix) { 448 | var difference = +num, 449 | rel = moment.relativeTime, 450 | output; 451 | switch (type) { 452 | case "seconds" : 453 | difference *= 1000; // 1000 454 | break; 455 | case "minutes" : 456 | difference *= 60000; // 60 * 1000 457 | break; 458 | case "hours" : 459 | difference *= 3600000; // 60 * 60 * 1000 460 | break; 461 | case "days" : 462 | difference *= 86400000; // 24 * 60 * 60 * 1000 463 | break; 464 | case "weeks" : 465 | difference *= 604800000; // 7 * 24 * 60 * 60 * 1000 466 | break; 467 | case "months" : 468 | difference *= 2592000000; // 30 * 24 * 60 * 60 * 1000 469 | break; 470 | case "years" : 471 | difference *= 31536000000; // 365 * 24 * 60 * 60 * 1000 472 | break; 473 | default : 474 | withSuffix = !!type; 475 | break; 476 | } 477 | output = relativeTime(difference, !withSuffix); 478 | return withSuffix ? (difference <= 0 ? rel.past : rel.future).replace(/%s/i, output) : output; 479 | }; 480 | 481 | // version number 482 | moment.version = VERSION; 483 | 484 | // default format 485 | moment.defaultFormat = isoFormat; 486 | 487 | // language switching and caching 488 | moment.lang = function (key, values) { 489 | var i, 490 | param, 491 | req, 492 | parse = []; 493 | if (values) { 494 | for (i = 0; i < 12; i++) { 495 | parse[i] = new RegExp('^' + values.months[i] + '|^' + values.monthsShort[i].replace('.', ''), 'i'); 496 | } 497 | values.monthsParse = values.monthsParse || parse; 498 | languages[key] = values; 499 | } 500 | if (languages[key]) { 501 | for (i = 0; i < paramsToParse.length; i++) { 502 | param = paramsToParse[i]; 503 | moment[param] = languages[key][param] || moment[param]; 504 | } 505 | } else { 506 | if (hasModule) { 507 | req = require('./lang/' + key); 508 | moment.lang(key, req); 509 | } 510 | } 511 | }; 512 | 513 | // set default language 514 | moment.lang('en', { 515 | months : "January_February_March_April_May_June_July_August_September_October_November_December".split("_"), 516 | monthsShort : "Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec".split("_"), 517 | weekdays : "Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"), 518 | weekdaysShort : "Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_"), 519 | longDateFormat : { 520 | LT : "h:mm A", 521 | L : "MM/DD/YYYY", 522 | LL : "MMMM D YYYY", 523 | LLL : "MMMM D YYYY LT", 524 | LLLL : "dddd, MMMM D YYYY LT" 525 | }, 526 | meridiem : { 527 | AM : 'AM', 528 | am : 'am', 529 | PM : 'PM', 530 | pm : 'pm' 531 | }, 532 | calendar : { 533 | sameDay : '[Today at] LT', 534 | nextDay : '[Tomorrow at] LT', 535 | nextWeek : 'dddd [at] LT', 536 | lastDay : '[Yesterday at] LT', 537 | lastWeek : '[last] dddd [at] LT', 538 | sameElse : 'L' 539 | }, 540 | relativeTime : { 541 | future : "in %s", 542 | past : "%s ago", 543 | s : "a few seconds", 544 | m : "a minute", 545 | mm : "%d minutes", 546 | h : "an hour", 547 | hh : "%d hours", 548 | d : "a day", 549 | dd : "%d days", 550 | M : "a month", 551 | MM : "%d months", 552 | y : "a year", 553 | yy : "%d years" 554 | }, 555 | ordinal : function (number) { 556 | var b = number % 10; 557 | return (~~ (number % 100 / 10) === 1) ? 'th' : 558 | (b === 1) ? 'st' : 559 | (b === 2) ? 'nd' : 560 | (b === 3) ? 'rd' : 'th'; 561 | } 562 | }); 563 | 564 | // compare moment object 565 | moment.isMoment = function (obj) { 566 | return obj instanceof Moment; 567 | }; 568 | 569 | // shortcut for prototype 570 | moment.fn = Moment.prototype = { 571 | 572 | clone : function () { 573 | return moment(this); 574 | }, 575 | 576 | valueOf : function () { 577 | return +this._d; 578 | }, 579 | 580 | 'native' : function () { 581 | return this._d; 582 | }, 583 | 584 | toString : function () { 585 | return this._d.toString(); 586 | }, 587 | 588 | toDate : function () { 589 | return this._d; 590 | }, 591 | 592 | utc : function () { 593 | this._isUTC = true; 594 | return this; 595 | }, 596 | 597 | local : function () { 598 | this._isUTC = false; 599 | return this; 600 | }, 601 | 602 | format : function (inputString) { 603 | return formatMoment(this, inputString ? inputString : moment.defaultFormat); 604 | }, 605 | 606 | add : function (input, val) { 607 | this._d = dateAddRemove(this._d, input, 1, val); 608 | return this; 609 | }, 610 | 611 | subtract : function (input, val) { 612 | this._d = dateAddRemove(this._d, input, -1, val); 613 | return this; 614 | }, 615 | 616 | diff : function (input, val, asFloat) { 617 | var inputMoment = moment(input), 618 | zoneDiff = (this.zone() - inputMoment.zone()) * 6e4, 619 | diff = this._d - inputMoment._d - zoneDiff, 620 | year = this.year() - inputMoment.year(), 621 | month = this.month() - inputMoment.month(), 622 | date = this.date() - inputMoment.date(), 623 | output; 624 | if (val === 'months') { 625 | output = year * 12 + month + date / 30; 626 | } else if (val === 'years') { 627 | output = year + month / 12; 628 | } else { 629 | output = val === 'seconds' ? diff / 1e3 : // 1000 630 | val === 'minutes' ? diff / 6e4 : // 1000 * 60 631 | val === 'hours' ? diff / 36e5 : // 1000 * 60 * 60 632 | val === 'days' ? diff / 864e5 : // 1000 * 60 * 60 * 24 633 | val === 'weeks' ? diff / 6048e5 : // 1000 * 60 * 60 * 24 * 7 634 | diff; 635 | } 636 | return asFloat ? output : round(output); 637 | }, 638 | 639 | from : function (time, withoutSuffix) { 640 | return moment.humanizeDuration(this.diff(time), !withoutSuffix); 641 | }, 642 | 643 | fromNow : function (withoutSuffix) { 644 | return this.from(moment(), withoutSuffix); 645 | }, 646 | 647 | calendar : function () { 648 | var diff = this.diff(moment().sod(), 'days', true), 649 | calendar = moment.calendar, 650 | allElse = calendar.sameElse, 651 | format = diff < -6 ? allElse : 652 | diff < -1 ? calendar.lastWeek : 653 | diff < 0 ? calendar.lastDay : 654 | diff < 1 ? calendar.sameDay : 655 | diff < 2 ? calendar.nextDay : 656 | diff < 7 ? calendar.nextWeek : allElse; 657 | return this.format(typeof format === 'function' ? format.apply(this) : format); 658 | }, 659 | 660 | isLeapYear : function () { 661 | var year = this.year(); 662 | return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; 663 | }, 664 | 665 | isDST : function () { 666 | return (this.zone() < moment([this.year()]).zone() || 667 | this.zone() < moment([this.year(), 5]).zone()); 668 | }, 669 | 670 | day : function (input) { 671 | var day = this._d.getDay(); 672 | return input == null ? day : 673 | this.add({ d : input - day }); 674 | }, 675 | 676 | sod: function () { 677 | return this.clone() 678 | .hours(0) 679 | .minutes(0) 680 | .seconds(0) 681 | .milliseconds(0); 682 | }, 683 | 684 | eod: function () { 685 | // end of day = start of day plus 1 day, minus 1 millisecond 686 | return this.sod().add({ 687 | d : 1, 688 | ms : -1 689 | }); 690 | }, 691 | 692 | zone : function () { 693 | return this._isUTC ? 0 : this._d.getTimezoneOffset(); 694 | }, 695 | 696 | daysInMonth : function () { 697 | return this.clone().month(this.month() + 1).date(0).date(); 698 | } 699 | }; 700 | 701 | // helper for adding shortcuts 702 | function makeShortcut(name, key) { 703 | moment.fn[name] = function (input) { 704 | var utc = this._isUTC ? 'UTC' : ''; 705 | if (input != null) { 706 | this._d['set' + utc + key](input); 707 | return this; 708 | } else { 709 | return this._d['get' + utc + key](); 710 | } 711 | }; 712 | } 713 | 714 | // loop through and add shortcuts (Month, Date, Hours, Minutes, Seconds, Milliseconds) 715 | for (i = 0; i < shortcuts.length; i ++) { 716 | makeShortcut(shortcuts[i].toLowerCase(), shortcuts[i]); 717 | } 718 | 719 | // add shortcut for year (uses different syntax than the getter/setter 'year' == 'FullYear') 720 | makeShortcut('year', 'FullYear'); 721 | 722 | // CommonJS module is defined 723 | if (hasModule) { 724 | module.exports = moment; 725 | } 726 | if (typeof window !== 'undefined') { 727 | window.moment = moment; 728 | } 729 | /*global define:false */ 730 | if (typeof define === "function" && define.amd) { 731 | define("moment", [], function () { 732 | return moment; 733 | }); 734 | } 735 | })(Date); 736 | -------------------------------------------------------------------------------- /test_sources/reddit.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": "Listing", 3 | "data": { 4 | "modhash": "48wdey9m7zb6ea56337dcb4921b6b43edda6337903d1cd56b9", 5 | "children": [{ 6 | "kind": "t3", 7 | "data": { 8 | "domain": "emberist.herokuapp.com", 9 | "banned_by": null, 10 | "media_embed": {}, 11 | "subreddit": "emberjs", 12 | "selftext_html": null, 13 | "selftext": "", 14 | "likes": true, 15 | "saved": false, 16 | "id": "rwwjc", 17 | "clicked": false, 18 | "title": "Difference between {{bind}} and {{bindAttr}}", 19 | "num_comments": 0, 20 | "score": 2, 21 | "approved_by": null, 22 | "over_18": false, 23 | "hidden": false, 24 | "thumbnail": "default", 25 | "subreddit_id": "t5_2tjb2", 26 | "author_flair_css_class": null, 27 | "downs": 0, 28 | "is_self": false, 29 | "permalink": "/r/emberjs/comments/rwwjc/difference_between_bind_and_bindattr/", 30 | "name": "t3_rwwjc", 31 | "created": 1333771949.0, 32 | "url": "https://emberist.herokuapp.com/2012/04/06/bind-and-bindattr.html", 33 | "author_flair_text": null, 34 | "author": "pangratz", 35 | "created_utc": 1333746749.0, 36 | "media": null, 37 | "num_reports": null, 38 | "ups": 2 39 | } 40 | }, 41 | { 42 | "kind": "t3", 43 | "data": { 44 | "domain": "sympodial.com", 45 | "banned_by": null, 46 | "media_embed": {}, 47 | "subreddit": "emberjs", 48 | "selftext_html": null, 49 | "selftext": "", 50 | "likes": true, 51 | "saved": false, 52 | "id": "rwblv", 53 | "clicked": false, 54 | "title": "An Extremely Gentle Introduction to Ember.js - Part 1", 55 | "num_comments": 0, 56 | "score": 3, 57 | "approved_by": null, 58 | "over_18": false, 59 | "hidden": false, 60 | "thumbnail": "default", 61 | "subreddit_id": "t5_2tjb2", 62 | "author_flair_css_class": null, 63 | "downs": 0, 64 | "is_self": false, 65 | "permalink": "/r/emberjs/comments/rwblv/an_extremely_gentle_introduction_to_emberjs_part_1/", 66 | "name": "t3_rwblv", 67 | "created": 1333749267.0, 68 | "url": "http://sympodial.com/blog/extremely-gentle-introduction-emberjs-part-1/", 69 | "author_flair_text": null, 70 | "author": "pangratz", 71 | "created_utc": 1333724067.0, 72 | "media": null, 73 | "num_reports": null, 74 | "ups": 3 75 | } 76 | }, 77 | { 78 | "kind": "t3", 79 | "data": { 80 | "domain": "thesoftwaresimpleton.com", 81 | "banned_by": null, 82 | "media_embed": {}, 83 | "subreddit": "emberjs", 84 | "selftext_html": null, 85 | "selftext": "", 86 | "likes": true, 87 | "saved": false, 88 | "id": "rum9j", 89 | "clicked": false, 90 | "title": "Unit Testing Ember.js, How I Learned to Stop Worrying and Love the Runloop", 91 | "num_comments": 0, 92 | "score": 3, 93 | "approved_by": null, 94 | "over_18": false, 95 | "hidden": false, 96 | "thumbnail": "http://b.thumbs.redditmedia.com/6pb8jlvhlleWPVIm.jpg", 97 | "subreddit_id": "t5_2tjb2", 98 | "author_flair_css_class": null, 99 | "downs": 0, 100 | "is_self": false, 101 | "permalink": "/r/emberjs/comments/rum9j/unit_testing_emberjs_how_i_learned_to_stop/", 102 | "name": "t3_rum9j", 103 | "created": 1333660131.0, 104 | "url": "http://www.thesoftwaresimpleton.com/blog/2012/04/03/testing-ember-and-the-runloop/", 105 | "author_flair_text": null, 106 | "author": "pangratz", 107 | "created_utc": 1333634931.0, 108 | "media": null, 109 | "num_reports": null, 110 | "ups": 3 111 | } 112 | }, 113 | { 114 | "kind": "t3", 115 | "data": { 116 | "domain": "corner.squareup.com", 117 | "banned_by": null, 118 | "media_embed": {}, 119 | "subreddit": "emberjs", 120 | "selftext_html": null, 121 | "selftext": "", 122 | "likes": true, 123 | "saved": false, 124 | "id": "rub67", 125 | "clicked": false, 126 | "title": "Square - Ember and D3: Building responsive analytics", 127 | "num_comments": 0, 128 | "score": 3, 129 | "approved_by": null, 130 | "over_18": false, 131 | "hidden": false, 132 | "thumbnail": "http://f.thumbs.redditmedia.com/uCAKDeDKrMxupYYk.jpg", 133 | "subreddit_id": "t5_2tjb2", 134 | "author_flair_css_class": null, 135 | "downs": 0, 136 | "is_self": false, 137 | "permalink": "/r/emberjs/comments/rub67/square_ember_and_d3_building_responsive_analytics/", 138 | "name": "t3_rub67", 139 | "created": 1333633758.0, 140 | "url": "http://corner.squareup.com/2012/04/building-analytics.html", 141 | "author_flair_text": null, 142 | "author": "pangratz", 143 | "created_utc": 1333608558.0, 144 | "media": null, 145 | "num_reports": null, 146 | "ups": 3 147 | } 148 | }, 149 | { 150 | "kind": "t3", 151 | "data": { 152 | "domain": "code418.com", 153 | "banned_by": null, 154 | "media_embed": {}, 155 | "subreddit": "emberjs", 156 | "selftext_html": null, 157 | "selftext": "", 158 | "likes": true, 159 | "saved": false, 160 | "id": "rog1y", 161 | "clicked": false, 162 | "title": "Changes in Ember.js v0.9.6", 163 | "num_comments": 0, 164 | "score": 3, 165 | "approved_by": null, 166 | "over_18": false, 167 | "hidden": false, 168 | "thumbnail": "default", 169 | "subreddit_id": "t5_2tjb2", 170 | "author_flair_css_class": null, 171 | "downs": 0, 172 | "is_self": false, 173 | "permalink": "/r/emberjs/comments/rog1y/changes_in_emberjs_v096/", 174 | "name": "t3_rog1y", 175 | "created": 1333312618.0, 176 | "url": "http://code418.com/blog/2012/04/01/changes-in-emberjs-v0.9.6/", 177 | "author_flair_text": null, 178 | "author": "pangratz", 179 | "created_utc": 1333312618.0, 180 | "media": null, 181 | "num_reports": null, 182 | "ups": 3 183 | } 184 | }, 185 | { 186 | "kind": "t3", 187 | "data": { 188 | "domain": "code418.com", 189 | "banned_by": null, 190 | "media_embed": {}, 191 | "subreddit": "emberjs", 192 | "selftext_html": null, 193 | "selftext": "", 194 | "likes": true, 195 | "saved": false, 196 | "id": "rnq2a", 197 | "clicked": false, 198 | "title": "Useful Ember.Observable functions", 199 | "num_comments": 0, 200 | "score": 1, 201 | "approved_by": null, 202 | "over_18": false, 203 | "hidden": false, 204 | "thumbnail": "default", 205 | "subreddit_id": "t5_2tjb2", 206 | "author_flair_css_class": null, 207 | "downs": 0, 208 | "is_self": false, 209 | "permalink": "/r/emberjs/comments/rnq2a/useful_emberobservable_functions/", 210 | "name": "t3_rnq2a", 211 | "created": 1333271133.0, 212 | "url": "http://code418.com/blog/2012/03/31/useful-observable-functions/", 213 | "author_flair_text": null, 214 | "author": "pangratz", 215 | "created_utc": 1333271133.0, 216 | "media": null, 217 | "num_reports": null, 218 | "ups": 1 219 | } 220 | }, 221 | { 222 | "kind": "t3", 223 | "data": { 224 | "domain": "code418.com", 225 | "banned_by": null, 226 | "media_embed": {}, 227 | "subreddit": "emberjs", 228 | "selftext_html": null, 229 | "selftext": "", 230 | "likes": true, 231 | "saved": false, 232 | "id": "rg64a", 233 | "clicked": false, 234 | "title": "Advanced Ember.js Bindings", 235 | "num_comments": 0, 236 | "score": 2, 237 | "approved_by": null, 238 | "over_18": false, 239 | "hidden": false, 240 | "thumbnail": "default", 241 | "subreddit_id": "t5_2tjb2", 242 | "author_flair_css_class": null, 243 | "downs": 0, 244 | "is_self": false, 245 | "permalink": "/r/emberjs/comments/rg64a/advanced_emberjs_bindings/", 246 | "name": "t3_rg64a", 247 | "created": 1332871765.0, 248 | "url": "http://code418.com/blog/2012/03/26/advanced-emberjs-bindings/", 249 | "author_flair_text": null, 250 | "author": "pangratz", 251 | "created_utc": 1332871765.0, 252 | "media": null, 253 | "num_reports": null, 254 | "ups": 2 255 | } 256 | }, 257 | { 258 | "kind": "t3", 259 | "data": { 260 | "domain": "infoq.com", 261 | "banned_by": null, 262 | "media_embed": {}, 263 | "subreddit": "emberjs", 264 | "selftext_html": null, 265 | "selftext": "", 266 | "likes": true, 267 | "saved": false, 268 | "id": "r8e21", 269 | "clicked": false, 270 | "title": "Ember.js: Rich Web Applications Done Right", 271 | "num_comments": 0, 272 | "score": 4, 273 | "approved_by": null, 274 | "over_18": false, 275 | "hidden": false, 276 | "thumbnail": "http://f.thumbs.redditmedia.com/A9YWRpqOgrASVBiS.jpg", 277 | "subreddit_id": "t5_2tjb2", 278 | "author_flair_css_class": null, 279 | "downs": 1, 280 | "is_self": false, 281 | "permalink": "/r/emberjs/comments/r8e21/emberjs_rich_web_applications_done_right/", 282 | "name": "t3_r8e21", 283 | "created": 1332426607.0, 284 | "url": "http://www.infoq.com/articles/emberjs", 285 | "author_flair_text": null, 286 | "author": "CritterM72800", 287 | "created_utc": 1332426607.0, 288 | "media": null, 289 | "num_reports": null, 290 | "ups": 5 291 | } 292 | }, 293 | { 294 | "kind": "t3", 295 | "data": { 296 | "domain": "jsfiddle.net", 297 | "banned_by": null, 298 | "media_embed": {}, 299 | "subreddit": "emberjs", 300 | "selftext_html": null, 301 | "selftext": "", 302 | "likes": null, 303 | "saved": false, 304 | "id": "qxsfa", 305 | "clicked": false, 306 | "title": "Backbone Events vs. Ember Bindings: A Benchmark", 307 | "num_comments": 1, 308 | "score": 1, 309 | "approved_by": null, 310 | "over_18": false, 311 | "hidden": false, 312 | "thumbnail": "default", 313 | "subreddit_id": "t5_2tjb2", 314 | "author_flair_css_class": null, 315 | "downs": 0, 316 | "is_self": false, 317 | "permalink": "/r/emberjs/comments/qxsfa/backbone_events_vs_ember_bindings_a_benchmark/", 318 | "name": "t3_qxsfa", 319 | "created": 1331821180.0, 320 | "url": "http://jsfiddle.net/jashkenas/CGSd5/", 321 | "author_flair_text": null, 322 | "author": "CritterM72800", 323 | "created_utc": 1331821180.0, 324 | "media": null, 325 | "num_reports": null, 326 | "ups": 1 327 | } 328 | }, 329 | { 330 | "kind": "t3", 331 | "data": { 332 | "domain": "code418.com", 333 | "banned_by": null, 334 | "media_embed": {}, 335 | "subreddit": "emberjs", 336 | "selftext_html": null, 337 | "selftext": "", 338 | "likes": true, 339 | "saved": false, 340 | "id": "qnk1a", 341 | "clicked": false, 342 | "title": "Useful Ember.js Functions", 343 | "num_comments": 0, 344 | "score": 3, 345 | "approved_by": null, 346 | "over_18": false, 347 | "hidden": false, 348 | "thumbnail": "default", 349 | "subreddit_id": "t5_2tjb2", 350 | "author_flair_css_class": null, 351 | "downs": 0, 352 | "is_self": false, 353 | "permalink": "/r/emberjs/comments/qnk1a/useful_emberjs_functions/", 354 | "name": "t3_qnk1a", 355 | "created": 1331228343.0, 356 | "url": "http://code418.com/blog/2012/03/08/useful-emberjs-functions/", 357 | "author_flair_text": null, 358 | "author": "pangratz", 359 | "created_utc": 1331228343.0, 360 | "media": null, 361 | "num_reports": null, 362 | "ups": 3 363 | } 364 | }, 365 | { 366 | "kind": "t3", 367 | "data": { 368 | "domain": "code418.com", 369 | "banned_by": null, 370 | "media_embed": {}, 371 | "subreddit": "emberjs", 372 | "selftext_html": null, 373 | "selftext": "", 374 | "likes": true, 375 | "saved": false, 376 | "id": "qkrls", 377 | "clicked": false, 378 | "title": "IRC log viewer using Hubot, CouchDB and Ember.js", 379 | "num_comments": 0, 380 | "score": 2, 381 | "approved_by": null, 382 | "over_18": false, 383 | "hidden": false, 384 | "thumbnail": "default", 385 | "subreddit_id": "t5_2tjb2", 386 | "author_flair_css_class": null, 387 | "downs": 0, 388 | "is_self": false, 389 | "permalink": "/r/emberjs/comments/qkrls/irc_log_viewer_using_hubot_couchdb_and_emberjs/", 390 | "name": "t3_qkrls", 391 | "created": 1331072530.0, 392 | "url": "http://code418.com/blog/2012/03/04/irc-log-viewer-using-hubot-couchdb-emberjs/", 393 | "author_flair_text": null, 394 | "author": "pangratz", 395 | "created_utc": 1331072530.0, 396 | "media": null, 397 | "num_reports": null, 398 | "ups": 2 399 | } 400 | }, 401 | { 402 | "kind": "t3", 403 | "data": { 404 | "domain": "cerebris.com", 405 | "banned_by": null, 406 | "media_embed": {}, 407 | "subreddit": "emberjs", 408 | "selftext_html": null, 409 | "selftext": "", 410 | "likes": true, 411 | "saved": false, 412 | "id": "qk9m2", 413 | "clicked": false, 414 | "title": "Understanding Ember.Object", 415 | "num_comments": 0, 416 | "score": 2, 417 | "approved_by": null, 418 | "over_18": false, 419 | "hidden": false, 420 | "thumbnail": "http://c.thumbs.redditmedia.com/O1aNWXOOUWj6076D.jpg", 421 | "subreddit_id": "t5_2tjb2", 422 | "author_flair_css_class": null, 423 | "downs": 0, 424 | "is_self": false, 425 | "permalink": "/r/emberjs/comments/qk9m2/understanding_emberobject/", 426 | "name": "t3_qk9m2", 427 | "created": 1331049989.0, 428 | "url": "http://www.cerebris.com/blog/2012/03/06/understanding-ember-object/", 429 | "author_flair_text": null, 430 | "author": "CritterM72800", 431 | "created_utc": 1331049989.0, 432 | "media": null, 433 | "num_reports": null, 434 | "ups": 2 435 | } 436 | }, 437 | { 438 | "kind": "t3", 439 | "data": { 440 | "domain": "github.com", 441 | "banned_by": null, 442 | "media_embed": {}, 443 | "subreddit": "emberjs", 444 | "selftext_html": null, 445 | "selftext": "", 446 | "likes": null, 447 | "saved": false, 448 | "id": "qehgl", 449 | "clicked": false, 450 | "title": "Quick Notes is a small example to showcase some of the capabilities of sproutcore-statechart and sproutcore-routing in Ember.js", 451 | "num_comments": 0, 452 | "score": 2, 453 | "approved_by": null, 454 | "over_18": false, 455 | "hidden": false, 456 | "thumbnail": "default", 457 | "subreddit_id": "t5_2tjb2", 458 | "author_flair_css_class": null, 459 | "downs": 0, 460 | "is_self": false, 461 | "permalink": "/r/emberjs/comments/qehgl/quick_notes_is_a_small_example_to_showcase_some/", 462 | "name": "t3_qehgl", 463 | "created": 1330698993.0, 464 | "url": "https://github.com/DominikGuzei/ember-routing-statechart-example", 465 | "author_flair_text": null, 466 | "author": "CritterM72800", 467 | "created_utc": 1330698993.0, 468 | "media": null, 469 | "num_reports": null, 470 | "ups": 2 471 | } 472 | }, 473 | { 474 | "kind": "t3", 475 | "data": { 476 | "domain": "thesoftwaresimpleton.com", 477 | "banned_by": null, 478 | "media_embed": {}, 479 | "subreddit": "emberjs", 480 | "selftext_html": null, 481 | "selftext": "", 482 | "likes": null, 483 | "saved": false, 484 | "id": "qehd1", 485 | "clicked": false, 486 | "title": "Ember.js - Model, View, StateMachine?", 487 | "num_comments": 0, 488 | "score": 1, 489 | "approved_by": null, 490 | "over_18": false, 491 | "hidden": false, 492 | "thumbnail": "http://b.thumbs.redditmedia.com/6pb8jlvhlleWPVIm.jpg", 493 | "subreddit_id": "t5_2tjb2", 494 | "author_flair_css_class": null, 495 | "downs": 0, 496 | "is_self": false, 497 | "permalink": "/r/emberjs/comments/qehd1/emberjs_model_view_statemachine/", 498 | "name": "t3_qehd1", 499 | "created": 1330698835.0, 500 | "url": "http://www.thesoftwaresimpleton.com/blog/2012/02/28/statemachine/", 501 | "author_flair_text": null, 502 | "author": "CritterM72800", 503 | "created_utc": 1330698835.0, 504 | "media": null, 505 | "num_reports": null, 506 | "ups": 1 507 | } 508 | }, 509 | { 510 | "kind": "t3", 511 | "data": { 512 | "domain": "frodsan.com", 513 | "banned_by": null, 514 | "media_embed": {}, 515 | "subreddit": "emberjs", 516 | "selftext_html": null, 517 | "selftext": "", 518 | "likes": true, 519 | "saved": false, 520 | "id": "qar10", 521 | "clicked": false, 522 | "title": "Submitting your first patch to Ember.js docs", 523 | "num_comments": 0, 524 | "score": 2, 525 | "approved_by": null, 526 | "over_18": false, 527 | "hidden": false, 528 | "thumbnail": "default", 529 | "subreddit_id": "t5_2tjb2", 530 | "author_flair_css_class": null, 531 | "downs": 0, 532 | "is_self": false, 533 | "permalink": "/r/emberjs/comments/qar10/submitting_your_first_patch_to_emberjs_docs/", 534 | "name": "t3_qar10", 535 | "created": 1330485976.0, 536 | "url": "http://www.frodsan.com/2012/02/21/contribute-to-ember-docs.html", 537 | "author_flair_text": null, 538 | "author": "CritterM72800", 539 | "created_utc": 1330485976.0, 540 | "media": null, 541 | "num_reports": null, 542 | "ups": 2 543 | } 544 | }, 545 | { 546 | "kind": "t3", 547 | "data": { 548 | "domain": "github.com", 549 | "banned_by": null, 550 | "media_embed": {}, 551 | "subreddit": "emberjs", 552 | "selftext_html": null, 553 | "selftext": "", 554 | "likes": true, 555 | "saved": false, 556 | "id": "qaqw1", 557 | "clicked": false, 558 | "title": "A simple Rails app for testing CRUD with Ember Data.", 559 | "num_comments": 0, 560 | "score": 2, 561 | "approved_by": null, 562 | "over_18": false, 563 | "hidden": false, 564 | "thumbnail": "default", 565 | "subreddit_id": "t5_2tjb2", 566 | "author_flair_css_class": null, 567 | "downs": 0, 568 | "is_self": false, 569 | "permalink": "/r/emberjs/comments/qaqw1/a_simple_rails_app_for_testing_crud_with_ember/", 570 | "name": "t3_qaqw1", 571 | "created": 1330485821.0, 572 | "url": "https://github.com/dgeb/ember_data_example", 573 | "author_flair_text": null, 574 | "author": "CritterM72800", 575 | "created_utc": 1330485821.0, 576 | "media": null, 577 | "num_reports": null, 578 | "ups": 2 579 | } 580 | }, 581 | { 582 | "kind": "t3", 583 | "data": { 584 | "domain": "vimeo.com", 585 | "banned_by": null, 586 | "media_embed": { 587 | "content": "<iframe src=\"http://player.vimeo.com/video/37539737\" width=\"600\" height=\"338\" frameborder=\"0\" webkitallowfullscreen mozallowfullscreen allowfullscreen></iframe>", 588 | "width": 600, 589 | "scrolling": false, 590 | "height": 338 591 | }, 592 | "subreddit": "emberjs", 593 | "selftext_html": null, 594 | "selftext": "", 595 | "likes": true, 596 | "saved": false, 597 | "id": "qa2ij", 598 | "clicked": false, 599 | "title": "Video: Tom Dale talks about debugging Ember.js apps [58min]", 600 | "num_comments": 0, 601 | "score": 5, 602 | "approved_by": null, 603 | "over_18": false, 604 | "hidden": false, 605 | "thumbnail": "http://c.thumbs.redditmedia.com/eXhJgK50smloyI_n.jpg", 606 | "subreddit_id": "t5_2tjb2", 607 | "author_flair_css_class": null, 608 | "downs": 0, 609 | "is_self": false, 610 | "permalink": "/r/emberjs/comments/qa2ij/video_tom_dale_talks_about_debugging_emberjs_apps/", 611 | "name": "t3_qa2ij", 612 | "created": 1330457435.0, 613 | "url": "http://vimeo.com/37539737", 614 | "author_flair_text": null, 615 | "author": "CritterM72800", 616 | "created_utc": 1330457435.0, 617 | "media": { 618 | "oembed": { 619 | "provider_url": "http://vimeo.com/", 620 | "description": "Tom Dale, co-founder of Tilde Inc, talks about debugging Ember.js apps.", 621 | "title": "Ember.js Meetup (21/2/12): Tom Dale", 622 | "type": "video", 623 | "author_name": "Ember.js", 624 | "height": 338, 625 | "width": 600, 626 | "html": "<iframe src=\"http://player.vimeo.com/video/37539737\" width=\"600\" height=\"338\" frameborder=\"0\" webkitallowfullscreen mozallowfullscreen allowfullscreen></iframe>", 627 | "thumbnail_width": 960, 628 | "version": "1.0", 629 | "provider_name": "Vimeo", 630 | "thumbnail_url": "http://b.vimeocdn.com/ts/257/997/257997150_960.jpg", 631 | "thumbnail_height": 540, 632 | "author_url": "http://vimeo.com/user10613810" 633 | }, 634 | "type": "vimeo.com" 635 | }, 636 | "num_reports": null, 637 | "ups": 5 638 | } 639 | }, 640 | { 641 | "kind": "t3", 642 | "data": { 643 | "domain": "vimeo.com", 644 | "banned_by": null, 645 | "media_embed": { 646 | "content": "<iframe src=\"http://player.vimeo.com/video/36992934\" width=\"600\" height=\"338\" frameborder=\"0\" webkitallowfullscreen mozallowfullscreen allowfullscreen></iframe>", 647 | "width": 600, 648 | "scrolling": false, 649 | "height": 338 650 | }, 651 | "subreddit": "emberjs", 652 | "selftext_html": null, 653 | "selftext": "", 654 | "likes": true, 655 | "saved": false, 656 | "id": "q8ou5", 657 | "clicked": false, 658 | "title": "Ember.js Lunch Talk at Carbon Five", 659 | "num_comments": 2, 660 | "score": 3, 661 | "approved_by": null, 662 | "over_18": false, 663 | "hidden": false, 664 | "thumbnail": "http://d.thumbs.redditmedia.com/x3B60ddGw7DEjugQ.jpg", 665 | "subreddit_id": "t5_2tjb2", 666 | "author_flair_css_class": null, 667 | "downs": 1, 668 | "is_self": false, 669 | "permalink": "/r/emberjs/comments/q8ou5/emberjs_lunch_talk_at_carbon_five/", 670 | "name": "t3_q8ou5", 671 | "created": 1330379970.0, 672 | "url": "http://vimeo.com/36992934", 673 | "author_flair_text": null, 674 | "author": "billiamthesecond", 675 | "created_utc": 1330379970.0, 676 | "media": { 677 | "oembed": { 678 | "provider_url": "http://vimeo.com/", 679 | "description": "Tom Dale and Yehuda Katz discuss their new JavaScript MVC ember.js over lunch at Carbon Five. There's about 45 minutes of presentation plus 20 minutes of Q & A. Check out more tech goodness at http://blog.carbonfive.com.", 680 | "title": "Ember.js Lunch Talk at Carbon Five", 681 | "type": "video", 682 | "author_name": "Christian Nelson", 683 | "height": 338, 684 | "width": 600, 685 | "html": "<iframe src=\"http://player.vimeo.com/video/36992934\" width=\"600\" height=\"338\" frameborder=\"0\" webkitallowfullscreen mozallowfullscreen allowfullscreen></iframe>", 686 | "thumbnail_width": 640, 687 | "version": "1.0", 688 | "provider_name": "Vimeo", 689 | "thumbnail_url": "http://b.vimeocdn.com/ts/253/953/253953484_640.jpg", 690 | "thumbnail_height": 357, 691 | "author_url": "http://vimeo.com/christiannelson" 692 | }, 693 | "type": "vimeo.com" 694 | }, 695 | "num_reports": null, 696 | "ups": 4 697 | } 698 | }, 699 | { 700 | "kind": "t3", 701 | "data": { 702 | "domain": "codebrief.com", 703 | "banned_by": null, 704 | "media_embed": {}, 705 | "subreddit": "emberjs", 706 | "selftext_html": null, 707 | "selftext": "", 708 | "likes": true, 709 | "saved": false, 710 | "id": "q8ndc", 711 | "clicked": false, 712 | "title": "Anatomy of a Complex Ember.js App Part I: States and Routes", 713 | "num_comments": 0, 714 | "score": 3, 715 | "approved_by": null, 716 | "over_18": false, 717 | "hidden": false, 718 | "thumbnail": "default", 719 | "subreddit_id": "t5_2tjb2", 720 | "author_flair_css_class": null, 721 | "downs": 0, 722 | "is_self": false, 723 | "permalink": "/r/emberjs/comments/q8ndc/anatomy_of_a_complex_emberjs_app_part_i_states/", 724 | "name": "t3_q8ndc", 725 | "created": 1330378227.0, 726 | "url": "http://codebrief.com/2012/02/anatomy-of-a-complex-ember-js-app-part-i-states-and-routes/", 727 | "author_flair_text": null, 728 | "author": "CritterM72800", 729 | "created_utc": 1330378227.0, 730 | "media": null, 731 | "num_reports": null, 732 | "ups": 3 733 | } 734 | }, 735 | { 736 | "kind": "t3", 737 | "data": { 738 | "domain": "ngauthier.com", 739 | "banned_by": null, 740 | "media_embed": {}, 741 | "subreddit": "emberjs", 742 | "selftext_html": null, 743 | "selftext": "", 744 | "likes": true, 745 | "saved": false, 746 | "id": "q03d1", 747 | "clicked": false, 748 | "title": "Playing with Ember.js", 749 | "num_comments": 0, 750 | "score": 2, 751 | "approved_by": null, 752 | "over_18": false, 753 | "hidden": false, 754 | "thumbnail": "default", 755 | "subreddit_id": "t5_2tjb2", 756 | "author_flair_css_class": null, 757 | "downs": 0, 758 | "is_self": false, 759 | "permalink": "/r/emberjs/comments/q03d1/playing_with_emberjs/", 760 | "name": "t3_q03d1", 761 | "created": 1329869120.0, 762 | "url": "http://ngauthier.com/2012/02/playing-with-ember.html", 763 | "author_flair_text": null, 764 | "author": "CritterM72800", 765 | "created_utc": 1329869120.0, 766 | "media": null, 767 | "num_reports": null, 768 | "ups": 2 769 | } 770 | }], 771 | "after": null, 772 | "before": null 773 | } 774 | } -------------------------------------------------------------------------------- /app/vendor/twitter-text.js: -------------------------------------------------------------------------------- 1 | if (typeof window === "undefined" || window === null) { 2 | window = { twttr: {} }; 3 | } 4 | if (window.twttr == null) { 5 | window.twttr = {}; 6 | } 7 | if (typeof twttr === "undefined" || twttr === null) { 8 | twttr = {}; 9 | } 10 | 11 | (function() { 12 | twttr.txt = {}; 13 | twttr.txt.regexen = {}; 14 | 15 | var HTML_ENTITIES = { 16 | '&': '&', 17 | '>': '>', 18 | '<': '<', 19 | '"': '"', 20 | "'": ''' 21 | }; 22 | 23 | // HTML escaping 24 | twttr.txt.htmlEscape = function(text) { 25 | return text && text.replace(/[&"'><]/g, function(character) { 26 | return HTML_ENTITIES[character]; 27 | }); 28 | }; 29 | 30 | // Builds a RegExp 31 | function regexSupplant(regex, flags) { 32 | flags = flags || ""; 33 | if (typeof regex !== "string") { 34 | if (regex.global && flags.indexOf("g") < 0) { 35 | flags += "g"; 36 | } 37 | if (regex.ignoreCase && flags.indexOf("i") < 0) { 38 | flags += "i"; 39 | } 40 | if (regex.multiline && flags.indexOf("m") < 0) { 41 | flags += "m"; 42 | } 43 | 44 | regex = regex.source; 45 | } 46 | 47 | return new RegExp(regex.replace(/#\{(\w+)\}/g, function(match, name) { 48 | var newRegex = twttr.txt.regexen[name] || ""; 49 | if (typeof newRegex !== "string") { 50 | newRegex = newRegex.source; 51 | } 52 | return newRegex; 53 | }), flags); 54 | } 55 | 56 | // simple string interpolation 57 | function stringSupplant(str, values) { 58 | return str.replace(/#\{(\w+)\}/g, function(match, name) { 59 | return values[name] || ""; 60 | }); 61 | } 62 | 63 | function addCharsToCharClass(charClass, start, end) { 64 | var s = String.fromCharCode(start); 65 | if (end !== start) { 66 | s += "-" + String.fromCharCode(end); 67 | } 68 | charClass.push(s); 69 | return charClass; 70 | } 71 | 72 | // Space is more than %20, U+3000 for example is the full-width space used with Kanji. Provide a short-hand 73 | // to access both the list of characters and a pattern suitible for use with String#split 74 | // Taken from: ActiveSupport::Multibyte::Handlers::UTF8Handler::UNICODE_WHITESPACE 75 | var fromCode = String.fromCharCode; 76 | var UNICODE_SPACES = [ 77 | fromCode(0x0020), // White_Space # Zs SPACE 78 | fromCode(0x0085), // White_Space # Cc 79 | fromCode(0x00A0), // White_Space # Zs NO-BREAK SPACE 80 | fromCode(0x1680), // White_Space # Zs OGHAM SPACE MARK 81 | fromCode(0x180E), // White_Space # Zs MONGOLIAN VOWEL SEPARATOR 82 | fromCode(0x2028), // White_Space # Zl LINE SEPARATOR 83 | fromCode(0x2029), // White_Space # Zp PARAGRAPH SEPARATOR 84 | fromCode(0x202F), // White_Space # Zs NARROW NO-BREAK SPACE 85 | fromCode(0x205F), // White_Space # Zs MEDIUM MATHEMATICAL SPACE 86 | fromCode(0x3000) // White_Space # Zs IDEOGRAPHIC SPACE 87 | ]; 88 | addCharsToCharClass(UNICODE_SPACES, 0x009, 0x00D); // White_Space # Cc [5] .. 89 | addCharsToCharClass(UNICODE_SPACES, 0x2000, 0x200A); // White_Space # Zs [11] EN QUAD..HAIR SPACE 90 | 91 | var INVALID_CHARS = [ 92 | fromCode(0xFFFE), 93 | fromCode(0xFEFF), // BOM 94 | fromCode(0xFFFF) // Special 95 | ]; 96 | addCharsToCharClass(INVALID_CHARS, 0x202A, 0x202E); // Directional change 97 | 98 | twttr.txt.regexen.spaces_group = regexSupplant(UNICODE_SPACES.join("")); 99 | twttr.txt.regexen.spaces = regexSupplant("[" + UNICODE_SPACES.join("") + "]"); 100 | twttr.txt.regexen.invalid_chars_group = regexSupplant(INVALID_CHARS.join("")); 101 | twttr.txt.regexen.punct = /\!'#%&'\(\)*\+,\\\-\.\/:;<=>\?@\[\]\^_{|}~\$/; 102 | 103 | var nonLatinHashtagChars = []; 104 | // Cyrillic 105 | addCharsToCharClass(nonLatinHashtagChars, 0x0400, 0x04ff); // Cyrillic 106 | addCharsToCharClass(nonLatinHashtagChars, 0x0500, 0x0527); // Cyrillic Supplement 107 | addCharsToCharClass(nonLatinHashtagChars, 0x2de0, 0x2dff); // Cyrillic Extended A 108 | addCharsToCharClass(nonLatinHashtagChars, 0xa640, 0xa69f); // Cyrillic Extended B 109 | // Hebrew 110 | addCharsToCharClass(nonLatinHashtagChars, 0x0591, 0x05bf); // Hebrew 111 | addCharsToCharClass(nonLatinHashtagChars, 0x05c1, 0x05c2); 112 | addCharsToCharClass(nonLatinHashtagChars, 0x05c4, 0x05c5); 113 | addCharsToCharClass(nonLatinHashtagChars, 0x05c7, 0x05c7); 114 | addCharsToCharClass(nonLatinHashtagChars, 0x05d0, 0x05ea); 115 | addCharsToCharClass(nonLatinHashtagChars, 0x05f0, 0x05f4); 116 | addCharsToCharClass(nonLatinHashtagChars, 0xfb12, 0xfb28); // Hebrew Presentation Forms 117 | addCharsToCharClass(nonLatinHashtagChars, 0xfb2a, 0xfb36); 118 | addCharsToCharClass(nonLatinHashtagChars, 0xfb38, 0xfb3c); 119 | addCharsToCharClass(nonLatinHashtagChars, 0xfb3e, 0xfb3e); 120 | addCharsToCharClass(nonLatinHashtagChars, 0xfb40, 0xfb41); 121 | addCharsToCharClass(nonLatinHashtagChars, 0xfb43, 0xfb44); 122 | addCharsToCharClass(nonLatinHashtagChars, 0xfb46, 0xfb4f); 123 | // Arabic 124 | addCharsToCharClass(nonLatinHashtagChars, 0x0610, 0x061a); // Arabic 125 | addCharsToCharClass(nonLatinHashtagChars, 0x0620, 0x065f); 126 | addCharsToCharClass(nonLatinHashtagChars, 0x066e, 0x06d3); 127 | addCharsToCharClass(nonLatinHashtagChars, 0x06d5, 0x06dc); 128 | addCharsToCharClass(nonLatinHashtagChars, 0x06de, 0x06e8); 129 | addCharsToCharClass(nonLatinHashtagChars, 0x06ea, 0x06ef); 130 | addCharsToCharClass(nonLatinHashtagChars, 0x06fa, 0x06fc); 131 | addCharsToCharClass(nonLatinHashtagChars, 0x06ff, 0x06ff); 132 | addCharsToCharClass(nonLatinHashtagChars, 0x0750, 0x077f); // Arabic Supplement 133 | addCharsToCharClass(nonLatinHashtagChars, 0x08a0, 0x08a0); // Arabic Extended A 134 | addCharsToCharClass(nonLatinHashtagChars, 0x08a2, 0x08ac); 135 | addCharsToCharClass(nonLatinHashtagChars, 0x08e4, 0x08fe); 136 | addCharsToCharClass(nonLatinHashtagChars, 0xfb50, 0xfbb1); // Arabic Pres. Forms A 137 | addCharsToCharClass(nonLatinHashtagChars, 0xfbd3, 0xfd3d); 138 | addCharsToCharClass(nonLatinHashtagChars, 0xfd50, 0xfd8f); 139 | addCharsToCharClass(nonLatinHashtagChars, 0xfd92, 0xfdc7); 140 | addCharsToCharClass(nonLatinHashtagChars, 0xfdf0, 0xfdfb); 141 | addCharsToCharClass(nonLatinHashtagChars, 0xfe70, 0xfe74); // Arabic Pres. Forms B 142 | addCharsToCharClass(nonLatinHashtagChars, 0xfe76, 0xfefc); 143 | addCharsToCharClass(nonLatinHashtagChars, 0x200c, 0x200c); // Zero-Width Non-Joiner 144 | // Thai 145 | addCharsToCharClass(nonLatinHashtagChars, 0x0e01, 0x0e3a); 146 | addCharsToCharClass(nonLatinHashtagChars, 0x0e40, 0x0e4e); 147 | // Hangul (Korean) 148 | addCharsToCharClass(nonLatinHashtagChars, 0x1100, 0x11ff); // Hangul Jamo 149 | addCharsToCharClass(nonLatinHashtagChars, 0x3130, 0x3185); // Hangul Compatibility Jamo 150 | addCharsToCharClass(nonLatinHashtagChars, 0xA960, 0xA97F); // Hangul Jamo Extended-A 151 | addCharsToCharClass(nonLatinHashtagChars, 0xAC00, 0xD7AF); // Hangul Syllables 152 | addCharsToCharClass(nonLatinHashtagChars, 0xD7B0, 0xD7FF); // Hangul Jamo Extended-B 153 | addCharsToCharClass(nonLatinHashtagChars, 0xFFA1, 0xFFDC); // half-width Hangul 154 | // Japanese and Chinese 155 | addCharsToCharClass(nonLatinHashtagChars, 0x30A1, 0x30FA); // Katakana (full-width) 156 | addCharsToCharClass(nonLatinHashtagChars, 0x30FC, 0x30FE); // Katakana Chouon and iteration marks (full-width) 157 | addCharsToCharClass(nonLatinHashtagChars, 0xFF66, 0xFF9F); // Katakana (half-width) 158 | addCharsToCharClass(nonLatinHashtagChars, 0xFF70, 0xFF70); // Katakana Chouon (half-width) 159 | addCharsToCharClass(nonLatinHashtagChars, 0xFF10, 0xFF19); // \ 160 | addCharsToCharClass(nonLatinHashtagChars, 0xFF21, 0xFF3A); // - Latin (full-width) 161 | addCharsToCharClass(nonLatinHashtagChars, 0xFF41, 0xFF5A); // / 162 | addCharsToCharClass(nonLatinHashtagChars, 0x3041, 0x3096); // Hiragana 163 | addCharsToCharClass(nonLatinHashtagChars, 0x3099, 0x309E); // Hiragana voicing and iteration mark 164 | addCharsToCharClass(nonLatinHashtagChars, 0x3400, 0x4DBF); // Kanji (CJK Extension A) 165 | addCharsToCharClass(nonLatinHashtagChars, 0x4E00, 0x9FFF); // Kanji (Unified) 166 | // -- Disabled as it breaks the Regex. 167 | //addCharsToCharClass(nonLatinHashtagChars, 0x20000, 0x2A6DF); // Kanji (CJK Extension B) 168 | addCharsToCharClass(nonLatinHashtagChars, 0x2A700, 0x2B73F); // Kanji (CJK Extension C) 169 | addCharsToCharClass(nonLatinHashtagChars, 0x2B740, 0x2B81F); // Kanji (CJK Extension D) 170 | addCharsToCharClass(nonLatinHashtagChars, 0x2F800, 0x2FA1F); // Kanji (CJK supplement) 171 | addCharsToCharClass(nonLatinHashtagChars, 0x3003, 0x3003); // Kanji iteration mark 172 | addCharsToCharClass(nonLatinHashtagChars, 0x3005, 0x3005); // Kanji iteration mark 173 | addCharsToCharClass(nonLatinHashtagChars, 0x303B, 0x303B); // Han iteration mark 174 | 175 | twttr.txt.regexen.nonLatinHashtagChars = regexSupplant(nonLatinHashtagChars.join("")); 176 | 177 | var latinAccentChars = []; 178 | // Latin accented characters (subtracted 0xD7 from the range, it's a confusable multiplication sign. Looks like "x") 179 | addCharsToCharClass(latinAccentChars, 0x00c0, 0x00d6); 180 | addCharsToCharClass(latinAccentChars, 0x00d8, 0x00f6); 181 | addCharsToCharClass(latinAccentChars, 0x00f8, 0x00ff); 182 | // Latin Extended A and B 183 | addCharsToCharClass(latinAccentChars, 0x0100, 0x024f); 184 | // assorted IPA Extensions 185 | addCharsToCharClass(latinAccentChars, 0x0253, 0x0254); 186 | addCharsToCharClass(latinAccentChars, 0x0256, 0x0257); 187 | addCharsToCharClass(latinAccentChars, 0x0259, 0x0259); 188 | addCharsToCharClass(latinAccentChars, 0x025b, 0x025b); 189 | addCharsToCharClass(latinAccentChars, 0x0263, 0x0263); 190 | addCharsToCharClass(latinAccentChars, 0x0268, 0x0268); 191 | addCharsToCharClass(latinAccentChars, 0x026f, 0x026f); 192 | addCharsToCharClass(latinAccentChars, 0x0272, 0x0272); 193 | addCharsToCharClass(latinAccentChars, 0x0289, 0x0289); 194 | addCharsToCharClass(latinAccentChars, 0x028b, 0x028b); 195 | // Okina for Hawaiian (it *is* a letter character) 196 | addCharsToCharClass(latinAccentChars, 0x02bb, 0x02bb); 197 | // Latin Extended Additional 198 | addCharsToCharClass(latinAccentChars, 0x1e00, 0x1eff); 199 | twttr.txt.regexen.latinAccentChars = regexSupplant(latinAccentChars.join("")); 200 | 201 | // A hashtag must contain characters, numbers and underscores, but not all numbers. 202 | twttr.txt.regexen.hashSigns = /[##]/; 203 | twttr.txt.regexen.hashtagAlpha = regexSupplant(/[a-z_#{latinAccentChars}#{nonLatinHashtagChars}]/i); 204 | twttr.txt.regexen.hashtagAlphaNumeric = regexSupplant(/[a-z0-9_#{latinAccentChars}#{nonLatinHashtagChars}]/i); 205 | twttr.txt.regexen.endHashtagMatch = regexSupplant(/^(?:#{hashSigns}|:\/\/)/); 206 | twttr.txt.regexen.hashtagBoundary = regexSupplant(/(?:^|$|[^&a-z0-9_#{latinAccentChars}#{nonLatinHashtagChars}])/); 207 | twttr.txt.regexen.validHashtag = regexSupplant(/(#{hashtagBoundary})(#{hashSigns})(#{hashtagAlphaNumeric}*#{hashtagAlpha}#{hashtagAlphaNumeric}*)/gi); 208 | 209 | // Mention related regex collection 210 | twttr.txt.regexen.validMentionPrecedingChars = /(?:^|[^a-zA-Z0-9_!#$%&*@@]|RT:?)/; 211 | twttr.txt.regexen.atSigns = /[@@]/; 212 | twttr.txt.regexen.validMentionOrList = regexSupplant( 213 | '(#{validMentionPrecedingChars})' + // $1: Preceding character 214 | '(#{atSigns})' + // $2: At mark 215 | '([a-zA-Z0-9_]{1,20})' + // $3: Screen name 216 | '(\/[a-zA-Z][a-zA-Z0-9_\-]{0,24})?' // $4: List (optional) 217 | , 'g'); 218 | twttr.txt.regexen.validReply = regexSupplant(/^(?:#{spaces})*#{atSigns}([a-zA-Z0-9_]{1,20})/); 219 | twttr.txt.regexen.endMentionMatch = regexSupplant(/^(?:#{atSigns}|[#{latinAccentChars}]|:\/\/)/); 220 | 221 | // URL related regex collection 222 | twttr.txt.regexen.validUrlPrecedingChars = regexSupplant(/(?:[^A-Za-z0-9@@$###{invalid_chars_group}]|^)/); 223 | twttr.txt.regexen.invalidUrlWithoutProtocolPrecedingChars = /[-_.\/]$/; 224 | twttr.txt.regexen.invalidDomainChars = stringSupplant("#{punct}#{spaces_group}#{invalid_chars_group}", twttr.txt.regexen); 225 | twttr.txt.regexen.validDomainChars = regexSupplant(/[^#{invalidDomainChars}]/); 226 | twttr.txt.regexen.validSubdomain = regexSupplant(/(?:(?:#{validDomainChars}(?:[_-]|#{validDomainChars})*)?#{validDomainChars}\.)/); 227 | twttr.txt.regexen.validDomainName = regexSupplant(/(?:(?:#{validDomainChars}(?:-|#{validDomainChars})*)?#{validDomainChars}\.)/); 228 | twttr.txt.regexen.validGTLD = regexSupplant(/(?:(?:aero|asia|biz|cat|com|coop|edu|gov|info|int|jobs|mil|mobi|museum|name|net|org|pro|tel|travel|xxx)(?=[^0-9a-zA-Z]|$))/); 229 | twttr.txt.regexen.validCCTLD = regexSupplant(/(?:(?:ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cs|cu|cv|cx|cy|cz|dd|de|dj|dk|dm|do|dz|ec|ee|eg|eh|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|ir|is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mk|ml|mm|mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|sk|sl|sm|sn|so|sr|ss|st|su|sv|sy|sz|tc|td|tf|tg|th|tj|tk|tl|tm|tn|to|tp|tr|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|za|zm|zw)(?=[^0-9a-zA-Z]|$))/); 230 | twttr.txt.regexen.validPunycode = regexSupplant(/(?:xn--[0-9a-z]+)/); 231 | twttr.txt.regexen.validDomain = regexSupplant(/(?:#{validSubdomain}*#{validDomainName}(?:#{validGTLD}|#{validCCTLD}|#{validPunycode}))/); 232 | twttr.txt.regexen.validAsciiDomain = regexSupplant(/(?:(?:[a-z0-9#{latinAccentChars}]+)\.)+(?:#{validGTLD}|#{validCCTLD}|#{validPunycode})/gi); 233 | twttr.txt.regexen.invalidShortDomain = regexSupplant(/^#{validDomainName}#{validCCTLD}$/); 234 | 235 | twttr.txt.regexen.validPortNumber = regexSupplant(/[0-9]+/); 236 | 237 | twttr.txt.regexen.validGeneralUrlPathChars = regexSupplant(/[a-z0-9!\*';:=\+,\.\$\/%#\[\]\-_~|&#{latinAccentChars}]/i); 238 | // Allow URL paths to contain balanced parens 239 | // 1. Used in Wikipedia URLs like /Primer_(film) 240 | // 2. Used in IIS sessions like /S(dfd346)/ 241 | twttr.txt.regexen.validUrlBalancedParens = regexSupplant(/\(#{validGeneralUrlPathChars}+\)/i); 242 | // Valid end-of-path chracters (so /foo. does not gobble the period). 243 | // 1. Allow =&# for empty URL parameters and other URL-join artifacts 244 | twttr.txt.regexen.validUrlPathEndingChars = regexSupplant(/[\+\-a-z0-9=_#\/#{latinAccentChars}]|(?:#{validUrlBalancedParens})/i); 245 | // Allow @ in a url, but only in the middle. Catch things like http://example.com/@user/ 246 | twttr.txt.regexen.validUrlPath = regexSupplant('(?:' + 247 | '(?:' + 248 | '#{validGeneralUrlPathChars}*' + 249 | '(?:#{validUrlBalancedParens}#{validGeneralUrlPathChars}*)*' + 250 | '#{validUrlPathEndingChars}'+ 251 | ')|(?:@#{validGeneralUrlPathChars}+\/)'+ 252 | ')', 'i'); 253 | 254 | twttr.txt.regexen.validUrlQueryChars = /[a-z0-9!?\*'\(\);:&=\+\$\/%#\[\]\-_\.,~|]/i; 255 | twttr.txt.regexen.validUrlQueryEndingChars = /[a-z0-9_&=#\/]/i; 256 | twttr.txt.regexen.extractUrl = regexSupplant( 257 | '(' + // $1 total match 258 | '(#{validUrlPrecedingChars})' + // $2 Preceeding chracter 259 | '(' + // $3 URL 260 | '(https?:\\/\\/)?' + // $4 Protocol (optional) 261 | '(#{validDomain})' + // $5 Domain(s) 262 | '(?::(#{validPortNumber}))?' + // $6 Port number (optional) 263 | '(\\/#{validUrlPath}*)?' + // $7 URL Path 264 | '(\\?#{validUrlQueryChars}*#{validUrlQueryEndingChars})?' + // $8 Query String 265 | ')' + 266 | ')' 267 | , 'gi'); 268 | 269 | twttr.txt.regexen.validTcoUrl = /^https?:\/\/t\.co\/[a-z0-9]+/i; 270 | 271 | // These URL validation pattern strings are based on the ABNF from RFC 3986 272 | twttr.txt.regexen.validateUrlUnreserved = /[a-z0-9\-._~]/i; 273 | twttr.txt.regexen.validateUrlPctEncoded = /(?:%[0-9a-f]{2})/i; 274 | twttr.txt.regexen.validateUrlSubDelims = /[!$&'()*+,;=]/i; 275 | twttr.txt.regexen.validateUrlPchar = regexSupplant('(?:' + 276 | '#{validateUrlUnreserved}|' + 277 | '#{validateUrlPctEncoded}|' + 278 | '#{validateUrlSubDelims}|' + 279 | '[:|@]' + 280 | ')', 'i'); 281 | 282 | twttr.txt.regexen.validateUrlScheme = /(?:[a-z][a-z0-9+\-.]*)/i; 283 | twttr.txt.regexen.validateUrlUserinfo = regexSupplant('(?:' + 284 | '#{validateUrlUnreserved}|' + 285 | '#{validateUrlPctEncoded}|' + 286 | '#{validateUrlSubDelims}|' + 287 | ':' + 288 | ')*', 'i'); 289 | 290 | twttr.txt.regexen.validateUrlDecOctet = /(?:[0-9]|(?:[1-9][0-9])|(?:1[0-9]{2})|(?:2[0-4][0-9])|(?:25[0-5]))/i; 291 | twttr.txt.regexen.validateUrlIpv4 = regexSupplant(/(?:#{validateUrlDecOctet}(?:\.#{validateUrlDecOctet}){3})/i); 292 | 293 | // Punting on real IPv6 validation for now 294 | twttr.txt.regexen.validateUrlIpv6 = /(?:\[[a-f0-9:\.]+\])/i; 295 | 296 | // Also punting on IPvFuture for now 297 | twttr.txt.regexen.validateUrlIp = regexSupplant('(?:' + 298 | '#{validateUrlIpv4}|' + 299 | '#{validateUrlIpv6}' + 300 | ')', 'i'); 301 | 302 | // This is more strict than the rfc specifies 303 | twttr.txt.regexen.validateUrlSubDomainSegment = /(?:[a-z0-9](?:[a-z0-9_\-]*[a-z0-9])?)/i; 304 | twttr.txt.regexen.validateUrlDomainSegment = /(?:[a-z0-9](?:[a-z0-9\-]*[a-z0-9])?)/i; 305 | twttr.txt.regexen.validateUrlDomainTld = /(?:[a-z](?:[a-z0-9\-]*[a-z0-9])?)/i; 306 | twttr.txt.regexen.validateUrlDomain = regexSupplant(/(?:(?:#{validateUrlSubDomainSegment]}\.)*(?:#{validateUrlDomainSegment]}\.)#{validateUrlDomainTld})/i); 307 | 308 | twttr.txt.regexen.validateUrlHost = regexSupplant('(?:' + 309 | '#{validateUrlIp}|' + 310 | '#{validateUrlDomain}' + 311 | ')', 'i'); 312 | 313 | // Unencoded internationalized domains - this doesn't check for invalid UTF-8 sequences 314 | twttr.txt.regexen.validateUrlUnicodeSubDomainSegment = /(?:(?:[a-z0-9]|[^\u0000-\u007f])(?:(?:[a-z0-9_\-]|[^\u0000-\u007f])*(?:[a-z0-9]|[^\u0000-\u007f]))?)/i; 315 | twttr.txt.regexen.validateUrlUnicodeDomainSegment = /(?:(?:[a-z0-9]|[^\u0000-\u007f])(?:(?:[a-z0-9\-]|[^\u0000-\u007f])*(?:[a-z0-9]|[^\u0000-\u007f]))?)/i; 316 | twttr.txt.regexen.validateUrlUnicodeDomainTld = /(?:(?:[a-z]|[^\u0000-\u007f])(?:(?:[a-z0-9\-]|[^\u0000-\u007f])*(?:[a-z0-9]|[^\u0000-\u007f]))?)/i; 317 | twttr.txt.regexen.validateUrlUnicodeDomain = regexSupplant(/(?:(?:#{validateUrlUnicodeSubDomainSegment}\.)*(?:#{validateUrlUnicodeDomainSegment}\.)#{validateUrlUnicodeDomainTld})/i); 318 | 319 | twttr.txt.regexen.validateUrlUnicodeHost = regexSupplant('(?:' + 320 | '#{validateUrlIp}|' + 321 | '#{validateUrlUnicodeDomain}' + 322 | ')', 'i'); 323 | 324 | twttr.txt.regexen.validateUrlPort = /[0-9]{1,5}/; 325 | 326 | twttr.txt.regexen.validateUrlUnicodeAuthority = regexSupplant( 327 | '(?:(#{validateUrlUserinfo})@)?' + // $1 userinfo 328 | '(#{validateUrlUnicodeHost})' + // $2 host 329 | '(?::(#{validateUrlPort}))?' //$3 port 330 | , "i"); 331 | 332 | twttr.txt.regexen.validateUrlAuthority = regexSupplant( 333 | '(?:(#{validateUrlUserinfo})@)?' + // $1 userinfo 334 | '(#{validateUrlHost})' + // $2 host 335 | '(?::(#{validateUrlPort}))?' // $3 port 336 | , "i"); 337 | 338 | twttr.txt.regexen.validateUrlPath = regexSupplant(/(\/#{validateUrlPchar}*)*/i); 339 | twttr.txt.regexen.validateUrlQuery = regexSupplant(/(#{validateUrlPchar}|\/|\?)*/i); 340 | twttr.txt.regexen.validateUrlFragment = regexSupplant(/(#{validateUrlPchar}|\/|\?)*/i); 341 | 342 | // Modified version of RFC 3986 Appendix B 343 | twttr.txt.regexen.validateUrlUnencoded = regexSupplant( 344 | '^' + // Full URL 345 | '(?:' + 346 | '([^:/?#]+):\\/\\/' + // $1 Scheme 347 | ')?' + 348 | '([^/?#]*)' + // $2 Authority 349 | '([^?#]*)' + // $3 Path 350 | '(?:' + 351 | '\\?([^#]*)' + // $4 Query 352 | ')?' + 353 | '(?:' + 354 | '#(.*)' + // $5 Fragment 355 | ')?$' 356 | , "i"); 357 | 358 | 359 | // Default CSS class for auto-linked URLs 360 | var DEFAULT_URL_CLASS = "tweet-url"; 361 | // Default CSS class for auto-linked lists (along with the url class) 362 | var DEFAULT_LIST_CLASS = "list-slug"; 363 | // Default CSS class for auto-linked usernames (along with the url class) 364 | var DEFAULT_USERNAME_CLASS = "username"; 365 | // Default CSS class for auto-linked hashtags (along with the url class) 366 | var DEFAULT_HASHTAG_CLASS = "hashtag"; 367 | // HTML attribute for robot nofollow behavior (default) 368 | var HTML_ATTR_NO_FOLLOW = " rel=\"nofollow\""; 369 | // Options which should not be passed as HTML attributes 370 | var OPTIONS_NOT_ATTRIBUTES = {'urlClass':true, 'listClass':true, 'usernameClass':true, 'hashtagClass':true, 371 | 'usernameUrlBase':true, 'listUrlBase':true, 'hashtagUrlBase':true, 372 | 'usernameUrlBlock':true, 'listUrlBlock':true, 'hashtagUrlBlock':true, 'linkUrlBlock':true, 373 | 'usernameIncludeSymbol':true, 'suppressLists':true, 'suppressNoFollow':true, 374 | 'suppressDataScreenName':true, 'urlEntities':true, 'before':true 375 | }; 376 | var BOOLEAN_ATTRIBUTES = {'disabled':true, 'readonly':true, 'multiple':true, 'checked':true}; 377 | 378 | // Simple object cloning function for simple objects 379 | function clone(o) { 380 | var r = {}; 381 | for (var k in o) { 382 | if (o.hasOwnProperty(k)) { 383 | r[k] = o[k]; 384 | } 385 | } 386 | 387 | return r; 388 | } 389 | 390 | twttr.txt.linkToHashtag = function(entity, text, options) { 391 | var d = { 392 | hash: text.substring(entity.indices[0], entity.indices[0] + 1), 393 | preText: "", 394 | text: twttr.txt.htmlEscape(entity.hashtag), 395 | postText: "", 396 | extraHtml: options.suppressNoFollow ? "" : HTML_ATTR_NO_FOLLOW 397 | }; 398 | for (var k in options) { 399 | if (options.hasOwnProperty(k)) { 400 | d[k] = options[k]; 401 | } 402 | } 403 | 404 | return stringSupplant("#{before}#{hash}#{preText}#{text}#{postText}", d); 405 | }; 406 | 407 | twttr.txt.linkToMentionAndList = function(entity, text, options) { 408 | var at = text.substring(entity.indices[0], entity.indices[0] + 1); 409 | var d = { 410 | at: options.usernameIncludeSymbol ? "" : at, 411 | at_before_user: options.usernameIncludeSymbol ? at : "", 412 | user: twttr.txt.htmlEscape(entity.screenName), 413 | slashListname: twttr.txt.htmlEscape(entity.listSlug), 414 | extraHtml: options.suppressNoFollow ? "" : HTML_ATTR_NO_FOLLOW, 415 | preChunk: "", 416 | postChunk: "" 417 | }; 418 | for (var k in options) { 419 | if (options.hasOwnProperty(k)) { 420 | d[k] = options[k]; 421 | } 422 | } 423 | 424 | if (entity.listSlug && !options.suppressLists) { 425 | // the link is a list 426 | var list = d.chunk = stringSupplant("#{user}#{slashListname}", d); 427 | d.list = twttr.txt.htmlEscape(list.toLowerCase()); 428 | return stringSupplant("#{before}#{at}#{preChunk}#{at_before_user}#{chunk}#{postChunk}", d); 429 | } else { 430 | // this is a screen name 431 | d.chunk = d.user; 432 | d.dataScreenName = !options.suppressDataScreenName ? stringSupplant("data-screen-name=\"#{chunk}\" ", d) : ""; 433 | return stringSupplant("#{before}#{at}#{preChunk}#{at_before_user}#{chunk}#{postChunk}", d); 434 | } 435 | }; 436 | 437 | twttr.txt.linkToUrl = function(entity, text, options) { 438 | var url = entity.url; 439 | var displayUrl = url; 440 | var linkText = twttr.txt.htmlEscape(displayUrl); 441 | // If the caller passed a urlEntities object (provided by a Twitter API 442 | // response with include_entities=true), we use that to render the display_url 443 | // for each URL instead of it's underlying t.co URL. 444 | if (options.urlEntities && options.urlEntities[url] && options.urlEntities[url].display_url) { 445 | var displayUrl = options.urlEntities[url].display_url; 446 | var expandedUrl = options.urlEntities[url].expanded_url; 447 | if (!options.title) { 448 | options.title = expandedUrl; 449 | } 450 | 451 | // Goal: If a user copies and pastes a tweet containing t.co'ed link, the resulting paste 452 | // should contain the full original URL (expanded_url), not the display URL. 453 | // 454 | // Method: Whenever possible, we actually emit HTML that contains expanded_url, and use 455 | // font-size:0 to hide those parts that should not be displayed (because they are not part of display_url). 456 | // Elements with font-size:0 get copied even though they are not visible. 457 | // Note that display:none doesn't work here. Elements with display:none don't get copied. 458 | // 459 | // Additionally, we want to *display* ellipses, but we don't want them copied. To make this happen we 460 | // wrap the ellipses in a tco-ellipsis class and provide an onCopy handler that sets display:none on 461 | // everything with the tco-ellipsis class. 462 | // 463 | // Exception: pic.twitter.com images, for which expandedUrl = "https://twitter.com/#!/username/status/1234/photo/1 464 | // For those URLs, display_url is not a substring of expanded_url, so we don't do anything special to render the elided parts. 465 | // For a pic.twitter.com URL, the only elided part will be the "https://", so this is fine. 466 | 467 | var displayUrlSansEllipses = displayUrl.replace(/…/g, ""); // We have to disregard ellipses for matching 468 | // Note: we currently only support eliding parts of the URL at the beginning or the end. 469 | // Eventually we may want to elide parts of the URL in the *middle*. If so, this code will 470 | // become more complicated. We will probably want to create a regexp out of display URL, 471 | // replacing every ellipsis with a ".*". 472 | if (expandedUrl.indexOf(displayUrlSansEllipses) != -1) { 473 | var displayUrlIndex = expandedUrl.indexOf(displayUrlSansEllipses); 474 | var v = { 475 | displayUrlSansEllipses: displayUrlSansEllipses, 476 | // Portion of expandedUrl that precedes the displayUrl substring 477 | beforeDisplayUrl: expandedUrl.substr(0, displayUrlIndex), 478 | // Portion of expandedUrl that comes after displayUrl 479 | afterDisplayUrl: expandedUrl.substr(displayUrlIndex + displayUrlSansEllipses.length), 480 | precedingEllipsis: displayUrl.match(/^…/) ? "…" : "", 481 | followingEllipsis: displayUrl.match(/…$/) ? "…" : "" 482 | }; 483 | $.each(v, function(index, value) { 484 | v[index] = twttr.txt.htmlEscape(value); 485 | }); 486 | // As an example: The user tweets "hi http://longdomainname.com/foo" 487 | // This gets shortened to "hi http://t.co/xyzabc", with display_url = "…nname.com/foo" 488 | // This will get rendered as: 489 | // 490 | // … 491 | // 499 | // http://longdomai 500 | // 501 | // 502 | // nname.com/foo 503 | // 504 | // 505 | //   506 | // … 507 | // 508 | v['invisible'] = options.invisibleTagAttrs; 509 | linkText = stringSupplant("#{precedingEllipsis} #{beforeDisplayUrl}#{displayUrlSansEllipses}#{afterDisplayUrl} #{followingEllipsis}", v); 510 | } else { 511 | linkText = displayUrl; 512 | } 513 | } 514 | 515 | var d = { 516 | htmlAttrs: options.htmlAttrs, 517 | url: twttr.txt.htmlEscape(url), 518 | linkText: linkText 519 | }; 520 | 521 | return stringSupplant("#{linkText}", d); 522 | }; 523 | 524 | twttr.txt.autoLinkEntities = function(text, entities, options) { 525 | options = clone(options || {}); 526 | 527 | if (!options.suppressNoFollow) { 528 | options.rel = "nofollow"; 529 | } 530 | if (options.urlClass) { 531 | options["class"] = options.urlClass; 532 | } 533 | options.urlClass = options.urlClass || DEFAULT_URL_CLASS; 534 | options.hashtagClass = options.hashtagClass || DEFAULT_HASHTAG_CLASS; 535 | options.hashtagUrlBase = options.hashtagUrlBase || "https://twitter.com/#!/search?q=%23"; 536 | options.listClass = options.listClass || DEFAULT_LIST_CLASS; 537 | options.usernameClass = options.usernameClass || DEFAULT_USERNAME_CLASS; 538 | options.usernameUrlBase = options.usernameUrlBase || "https://twitter.com/"; 539 | options.listUrlBase = options.listUrlBase || "https://twitter.com/"; 540 | options.before = options.before || ""; 541 | options.htmlAttrs = twttr.txt.extractHtmlAttrsFromOptions(options); 542 | options.invisibleTagAttrs = options.invisibleTagAttrs || "style='position:absolute;left:-9999px;'"; 543 | 544 | // remap url entities to hash 545 | var urlEntities, i, len; 546 | if(options.urlEntities) { 547 | urlEntities = {}; 548 | for(i = 0, len = options.urlEntities.length; i < len; i++) { 549 | urlEntities[options.urlEntities[i].url] = options.urlEntities[i]; 550 | } 551 | options.urlEntities = urlEntities; 552 | } 553 | 554 | var result = ""; 555 | var beginIndex = 0; 556 | 557 | for (var i = 0; i < entities.length; i++) { 558 | var entity = entities[i]; 559 | result += text.substring(beginIndex, entity.indices[0]); 560 | 561 | if (entity.url) { 562 | result += twttr.txt.linkToUrl(entity, text, options); 563 | } else if (entity.hashtag) { 564 | result += twttr.txt.linkToHashtag(entity, text, options); 565 | } else if(entity.screenName) { 566 | result += twttr.txt.linkToMentionAndList(entity, text, options); 567 | } 568 | beginIndex = entity.indices[1]; 569 | } 570 | result += text.substring(beginIndex, text.length); 571 | return result; 572 | }; 573 | 574 | twttr.txt.extractHtmlAttrsFromOptions = function(options) { 575 | var htmlAttrs = ""; 576 | for (var k in options) { 577 | var v = options[k]; 578 | if (OPTIONS_NOT_ATTRIBUTES[k]) continue; 579 | if (BOOLEAN_ATTRIBUTES[k]) { 580 | v = v ? k : null; 581 | } 582 | if (v == null) continue; 583 | htmlAttrs += stringSupplant(" #{k}=\"#{v}\" ", {k: twttr.txt.htmlEscape(k), v: twttr.txt.htmlEscape(v.toString())}); 584 | } 585 | return htmlAttrs; 586 | }; 587 | 588 | twttr.txt.autoLink = function(text, options) { 589 | var entities = twttr.txt.extractEntitiesWithIndices(text, {extractUrlWithoutProtocol: false}); 590 | return twttr.txt.autoLinkEntities(text, entities, options); 591 | }; 592 | 593 | twttr.txt.autoLinkUsernamesOrLists = function(text, options) { 594 | var entities = twttr.txt.extractMentionsOrListsWithIndices(text); 595 | return twttr.txt.autoLinkEntities(text, entities, options); 596 | }; 597 | 598 | twttr.txt.autoLinkHashtags = function(text, options) { 599 | var entities = twttr.txt.extractHashtagsWithIndices(text); 600 | return twttr.txt.autoLinkEntities(text, entities, options); 601 | }; 602 | 603 | twttr.txt.autoLinkUrlsCustom = function(text, options) { 604 | var entities = twttr.txt.extractUrlsWithIndices(text, {extractUrlWithoutProtocol: false}); 605 | return twttr.txt.autoLinkEntities(text, entities, options); 606 | }; 607 | 608 | twttr.txt.removeOverlappingEntities = function(entities) { 609 | entities.sort(function(a,b){ return a.indices[0] - b.indices[0]; }); 610 | 611 | var prev = entities[0]; 612 | for (var i = 1; i < entities.length; i++) { 613 | if (prev.indices[1] > entities[i].indices[0]) { 614 | entities.splice(i, 1); 615 | i--; 616 | } else { 617 | prev = entities[i]; 618 | } 619 | } 620 | }; 621 | 622 | twttr.txt.extractEntitiesWithIndices = function(text, options) { 623 | var entities = twttr.txt.extractUrlsWithIndices(text, options) 624 | .concat(twttr.txt.extractMentionsOrListsWithIndices(text)) 625 | .concat(twttr.txt.extractHashtagsWithIndices(text, {checkUrlOverlap: false})); 626 | 627 | if (entities.length == 0) { 628 | return []; 629 | } 630 | 631 | twttr.txt.removeOverlappingEntities(entities); 632 | return entities; 633 | }; 634 | 635 | twttr.txt.extractMentions = function(text) { 636 | var screenNamesOnly = [], 637 | screenNamesWithIndices = twttr.txt.extractMentionsWithIndices(text); 638 | 639 | for (var i = 0; i < screenNamesWithIndices.length; i++) { 640 | var screenName = screenNamesWithIndices[i].screenName; 641 | screenNamesOnly.push(screenName); 642 | } 643 | 644 | return screenNamesOnly; 645 | }; 646 | 647 | twttr.txt.extractMentionsWithIndices = function(text) { 648 | var mentions = []; 649 | var mentionsOrLists = twttr.txt.extractMentionsOrListsWithIndices(text); 650 | 651 | for (var i = 0 ; i < mentionsOrLists.length; i++) { 652 | mentionOrList = mentionsOrLists[i]; 653 | if (mentionOrList.listSlug == '') { 654 | mentions.push({ 655 | screenName: mentionOrList.screenName, 656 | indices: mentionOrList.indices 657 | }); 658 | } 659 | } 660 | 661 | return mentions; 662 | }; 663 | 664 | /** 665 | * Extract list or user mentions. 666 | * (Presence of listSlug indicates a list) 667 | */ 668 | twttr.txt.extractMentionsOrListsWithIndices = function(text) { 669 | if (!text || !text.match(twttr.txt.regexen.atSigns)) { 670 | return []; 671 | } 672 | 673 | var possibleNames = [], 674 | position = 0; 675 | 676 | text.replace(twttr.txt.regexen.validMentionOrList, function(match, before, atSign, screenName, slashListname, offset, chunk) { 677 | var after = chunk.slice(offset + match.length); 678 | if (!after.match(twttr.txt.regexen.endMentionMatch)) { 679 | slashListname = slashListname || ''; 680 | var startPosition = text.indexOf(atSign + screenName + slashListname, position); 681 | position = startPosition + screenName.length + slashListname.length + 1; 682 | possibleNames.push({ 683 | screenName: screenName, 684 | listSlug: slashListname, 685 | indices: [startPosition, position] 686 | }); 687 | } 688 | }); 689 | 690 | return possibleNames; 691 | }; 692 | 693 | 694 | twttr.txt.extractReplies = function(text) { 695 | if (!text) { 696 | return null; 697 | } 698 | 699 | var possibleScreenName = text.match(twttr.txt.regexen.validReply); 700 | if (!possibleScreenName || 701 | RegExp.rightContext.match(twttr.txt.regexen.endMentionMatch)) { 702 | return null; 703 | } 704 | 705 | return possibleScreenName[1]; 706 | }; 707 | 708 | twttr.txt.extractUrls = function(text, options) { 709 | var urlsOnly = [], 710 | urlsWithIndices = twttr.txt.extractUrlsWithIndices(text, options); 711 | 712 | for (var i = 0; i < urlsWithIndices.length; i++) { 713 | urlsOnly.push(urlsWithIndices[i].url); 714 | } 715 | 716 | return urlsOnly; 717 | }; 718 | 719 | twttr.txt.extractUrlsWithIndices = function(text, options) { 720 | if (!options) { 721 | options = {extractUrlsWithoutProtocol: true}; 722 | } 723 | 724 | if (!text || (options.extractUrlsWithoutProtocol ? !text.match(/\./) : !text.match(/:/))) { 725 | return []; 726 | } 727 | 728 | var urls = []; 729 | 730 | while (twttr.txt.regexen.extractUrl.exec(text)) { 731 | var before = RegExp.$2, url = RegExp.$3, protocol = RegExp.$4, domain = RegExp.$5, path = RegExp.$7; 732 | var endPosition = twttr.txt.regexen.extractUrl.lastIndex, 733 | startPosition = endPosition - url.length; 734 | 735 | // if protocol is missing and domain contains non-ASCII characters, 736 | // extract ASCII-only domains. 737 | if (!protocol) { 738 | if (!options.extractUrlsWithoutProtocol 739 | || before.match(twttr.txt.regexen.invalidUrlWithoutProtocolPrecedingChars)) { 740 | continue; 741 | } 742 | var lastUrl = null, 743 | lastUrlInvalidMatch = false, 744 | asciiEndPosition = 0; 745 | domain.replace(twttr.txt.regexen.validAsciiDomain, function(asciiDomain) { 746 | var asciiStartPosition = domain.indexOf(asciiDomain, asciiEndPosition); 747 | asciiEndPosition = asciiStartPosition + asciiDomain.length; 748 | lastUrl = { 749 | url: asciiDomain, 750 | indices: [startPosition + asciiStartPosition, startPosition + asciiEndPosition] 751 | }; 752 | lastUrlInvalidMatch = asciiDomain.match(twttr.txt.regexen.invalidShortDomain); 753 | if (!lastUrlInvalidMatch) { 754 | urls.push(lastUrl); 755 | } 756 | }); 757 | 758 | // no ASCII-only domain found. Skip the entire URL. 759 | if (lastUrl == null) { 760 | continue; 761 | } 762 | 763 | // lastUrl only contains domain. Need to add path and query if they exist. 764 | if (path) { 765 | if (lastUrlInvalidMatch) { 766 | urls.push(lastUrl); 767 | } 768 | lastUrl.url = url.replace(domain, lastUrl.url); 769 | lastUrl.indices[1] = endPosition; 770 | } 771 | } else { 772 | // In the case of t.co URLs, don't allow additional path characters. 773 | if (url.match(twttr.txt.regexen.validTcoUrl)) { 774 | url = RegExp.lastMatch; 775 | endPosition = startPosition + url.length; 776 | } 777 | urls.push({ 778 | url: url, 779 | indices: [startPosition, endPosition] 780 | }); 781 | } 782 | } 783 | 784 | return urls; 785 | }; 786 | 787 | twttr.txt.extractHashtags = function(text) { 788 | var hashtagsOnly = [], 789 | hashtagsWithIndices = twttr.txt.extractHashtagsWithIndices(text); 790 | 791 | for (var i = 0; i < hashtagsWithIndices.length; i++) { 792 | hashtagsOnly.push(hashtagsWithIndices[i].hashtag); 793 | } 794 | 795 | return hashtagsOnly; 796 | }; 797 | 798 | twttr.txt.extractHashtagsWithIndices = function(text, options) { 799 | if (!options) { 800 | options = {checkUrlOverlap: true}; 801 | } 802 | 803 | if (!text || !text.match(twttr.txt.regexen.hashSigns)) { 804 | return []; 805 | } 806 | 807 | var tags = [], 808 | position = 0; 809 | 810 | text.replace(twttr.txt.regexen.validHashtag, function(match, before, hash, hashText, offset, chunk) { 811 | var after = chunk.slice(offset + match.length); 812 | if (after.match(twttr.txt.regexen.endHashtagMatch)) 813 | return; 814 | var startPosition = text.indexOf(hash + hashText, position); 815 | position = startPosition + hashText.length + 1; 816 | tags.push({ 817 | hashtag: hashText, 818 | indices: [startPosition, position] 819 | }); 820 | }); 821 | 822 | if (options.checkUrlOverlap) { 823 | // also extract URL entities 824 | var urls = twttr.txt.extractUrlsWithIndices(text); 825 | if (urls.length > 0) { 826 | var entities = tags.concat(urls); 827 | // remove overlap 828 | twttr.txt.removeOverlappingEntities(entities); 829 | // only push back hashtags 830 | tags = []; 831 | for (var i = 0; i < entities.length; i++) { 832 | if (entities[i].hashtag) { 833 | tags.push(entities[i]); 834 | } 835 | } 836 | } 837 | } 838 | 839 | return tags; 840 | }; 841 | 842 | twttr.txt.modifyIndicesFromUnicodeToUTF16 = function(text, entities) { 843 | twttr.txt.convertUnicodeIndices(text, entities, false); 844 | }; 845 | 846 | twttr.txt.modifyIndicesFromUTF16ToUnicode = function(text, entities) { 847 | twttr.txt.convertUnicodeIndices(text, entities, true); 848 | }; 849 | 850 | twttr.txt.convertUnicodeIndices = function(text, entities, indicesInUTF16) { 851 | if (entities.length == 0) { 852 | return; 853 | } 854 | 855 | var charIndex = 0; 856 | var codePointIndex = 0; 857 | 858 | // sort entities by start index 859 | entities.sort(function(a,b){ return a.indices[0] - b.indices[0]; }); 860 | var entityIndex = 0; 861 | var entity = entities[0]; 862 | 863 | while (charIndex < text.length) { 864 | if (entity.indices[0] == (indicesInUTF16 ? charIndex : codePointIndex)) { 865 | var len = entity.indices[1] - entity.indices[0]; 866 | entity.indices[0] = indicesInUTF16 ? codePointIndex : charIndex; 867 | entity.indices[1] = entity.indices[0] + len; 868 | 869 | entityIndex++; 870 | if (entityIndex == entities.length) { 871 | // no more entity 872 | break; 873 | } 874 | entity = entities[entityIndex]; 875 | } 876 | 877 | var c = text.charCodeAt(charIndex); 878 | if (0xD800 <= c && c <= 0xDBFF && charIndex < text.length - 1) { 879 | // Found high surrogate char 880 | c = text.charCodeAt(charIndex + 1); 881 | if (0xDC00 <= c && c <= 0xDFFF) { 882 | // Found surrogate pair 883 | charIndex++; 884 | } 885 | } 886 | codePointIndex++; 887 | charIndex++; 888 | } 889 | }; 890 | 891 | // this essentially does text.split(/<|>/) 892 | // except that won't work in IE, where empty strings are ommitted 893 | // so "<>".split(/<|>/) => [] in IE, but is ["", "", ""] in all others 894 | // but "<<".split("<") => ["", "", ""] 895 | twttr.txt.splitTags = function(text) { 896 | var firstSplits = text.split("<"), 897 | secondSplits, 898 | allSplits = [], 899 | split; 900 | 901 | for (var i = 0; i < firstSplits.length; i += 1) { 902 | split = firstSplits[i]; 903 | if (!split) { 904 | allSplits.push(""); 905 | } else { 906 | secondSplits = split.split(">"); 907 | for (var j = 0; j < secondSplits.length; j += 1) { 908 | allSplits.push(secondSplits[j]); 909 | } 910 | } 911 | } 912 | 913 | return allSplits; 914 | }; 915 | 916 | twttr.txt.hitHighlight = function(text, hits, options) { 917 | var defaultHighlightTag = "em"; 918 | 919 | hits = hits || []; 920 | options = options || {}; 921 | 922 | if (hits.length === 0) { 923 | return text; 924 | } 925 | 926 | var tagName = options.tag || defaultHighlightTag, 927 | tags = ["<" + tagName + ">", ""], 928 | chunks = twttr.txt.splitTags(text), 929 | i, 930 | j, 931 | result = "", 932 | chunkIndex = 0, 933 | chunk = chunks[0], 934 | prevChunksLen = 0, 935 | chunkCursor = 0, 936 | startInChunk = false, 937 | chunkChars = chunk, 938 | flatHits = [], 939 | index, 940 | hit, 941 | tag, 942 | placed, 943 | hitSpot; 944 | 945 | for (i = 0; i < hits.length; i += 1) { 946 | for (j = 0; j < hits[i].length; j += 1) { 947 | flatHits.push(hits[i][j]); 948 | } 949 | } 950 | 951 | for (index = 0; index < flatHits.length; index += 1) { 952 | hit = flatHits[index]; 953 | tag = tags[index % 2]; 954 | placed = false; 955 | 956 | while (chunk != null && hit >= prevChunksLen + chunk.length) { 957 | result += chunkChars.slice(chunkCursor); 958 | if (startInChunk && hit === prevChunksLen + chunkChars.length) { 959 | result += tag; 960 | placed = true; 961 | } 962 | 963 | if (chunks[chunkIndex + 1]) { 964 | result += "<" + chunks[chunkIndex + 1] + ">"; 965 | } 966 | 967 | prevChunksLen += chunkChars.length; 968 | chunkCursor = 0; 969 | chunkIndex += 2; 970 | chunk = chunks[chunkIndex]; 971 | chunkChars = chunk; 972 | startInChunk = false; 973 | } 974 | 975 | if (!placed && chunk != null) { 976 | hitSpot = hit - prevChunksLen; 977 | result += chunkChars.slice(chunkCursor, hitSpot) + tag; 978 | chunkCursor = hitSpot; 979 | if (index % 2 === 0) { 980 | startInChunk = true; 981 | } else { 982 | startInChunk = false; 983 | } 984 | } else if(!placed) { 985 | placed = true; 986 | result += tag; 987 | } 988 | } 989 | 990 | if (chunk != null) { 991 | if (chunkCursor < chunkChars.length) { 992 | result += chunkChars.slice(chunkCursor); 993 | } 994 | for (index = chunkIndex + 1; index < chunks.length; index += 1) { 995 | result += (index % 2 === 0 ? chunks[index] : "<" + chunks[index] + ">"); 996 | } 997 | } 998 | 999 | return result; 1000 | }; 1001 | 1002 | var MAX_LENGTH = 140; 1003 | 1004 | // Characters not allowed in Tweets 1005 | var INVALID_CHARACTERS = [ 1006 | // BOM 1007 | fromCode(0xFFFE), 1008 | fromCode(0xFEFF), 1009 | 1010 | // Special 1011 | fromCode(0xFFFF), 1012 | 1013 | // Directional Change 1014 | fromCode(0x202A), 1015 | fromCode(0x202B), 1016 | fromCode(0x202C), 1017 | fromCode(0x202D), 1018 | fromCode(0x202E) 1019 | ]; 1020 | 1021 | // Check the text for any reason that it may not be valid as a Tweet. This is meant as a pre-validation 1022 | // before posting to api.twitter.com. There are several server-side reasons for Tweets to fail but this pre-validation 1023 | // will allow quicker feedback. 1024 | // 1025 | // Returns false if this text is valid. Otherwise one of the following strings will be returned: 1026 | // 1027 | // "too_long": if the text is too long 1028 | // "empty": if the text is nil or empty 1029 | // "invalid_characters": if the text contains non-Unicode or any of the disallowed Unicode characters 1030 | twttr.txt.isInvalidTweet = function(text) { 1031 | if (!text) { 1032 | return "empty"; 1033 | } 1034 | 1035 | if (text.length > MAX_LENGTH) { 1036 | return "too_long"; 1037 | } 1038 | 1039 | for (var i = 0; i < INVALID_CHARACTERS.length; i++) { 1040 | if (text.indexOf(INVALID_CHARACTERS[i]) >= 0) { 1041 | return "invalid_characters"; 1042 | } 1043 | } 1044 | 1045 | return false; 1046 | }; 1047 | 1048 | twttr.txt.isValidTweetText = function(text) { 1049 | return !twttr.txt.isInvalidTweet(text); 1050 | }; 1051 | 1052 | twttr.txt.isValidUsername = function(username) { 1053 | if (!username) { 1054 | return false; 1055 | } 1056 | 1057 | var extracted = twttr.txt.extractMentions(username); 1058 | 1059 | // Should extract the username minus the @ sign, hence the .slice(1) 1060 | return extracted.length === 1 && extracted[0] === username.slice(1); 1061 | }; 1062 | 1063 | var VALID_LIST_RE = regexSupplant(/^#{validMentionOrList}$/); 1064 | 1065 | twttr.txt.isValidList = function(usernameList) { 1066 | var match = usernameList.match(VALID_LIST_RE); 1067 | 1068 | // Must have matched and had nothing before or after 1069 | return !!(match && match[1] == "" && match[4]); 1070 | }; 1071 | 1072 | twttr.txt.isValidHashtag = function(hashtag) { 1073 | if (!hashtag) { 1074 | return false; 1075 | } 1076 | 1077 | var extracted = twttr.txt.extractHashtags(hashtag); 1078 | 1079 | // Should extract the hashtag minus the # sign, hence the .slice(1) 1080 | return extracted.length === 1 && extracted[0] === hashtag.slice(1); 1081 | }; 1082 | 1083 | twttr.txt.isValidUrl = function(url, unicodeDomains, requireProtocol) { 1084 | if (unicodeDomains == null) { 1085 | unicodeDomains = true; 1086 | } 1087 | 1088 | if (requireProtocol == null) { 1089 | requireProtocol = true; 1090 | } 1091 | 1092 | if (!url) { 1093 | return false; 1094 | } 1095 | 1096 | var urlParts = url.match(twttr.txt.regexen.validateUrlUnencoded); 1097 | 1098 | if (!urlParts || urlParts[0] !== url) { 1099 | return false; 1100 | } 1101 | 1102 | var scheme = urlParts[1], 1103 | authority = urlParts[2], 1104 | path = urlParts[3], 1105 | query = urlParts[4], 1106 | fragment = urlParts[5]; 1107 | 1108 | if (!( 1109 | (!requireProtocol || (isValidMatch(scheme, twttr.txt.regexen.validateUrlScheme) && scheme.match(/^https?$/i))) && 1110 | isValidMatch(path, twttr.txt.regexen.validateUrlPath) && 1111 | isValidMatch(query, twttr.txt.regexen.validateUrlQuery, true) && 1112 | isValidMatch(fragment, twttr.txt.regexen.validateUrlFragment, true) 1113 | )) { 1114 | return false; 1115 | } 1116 | 1117 | return (unicodeDomains && isValidMatch(authority, twttr.txt.regexen.validateUrlUnicodeAuthority)) || 1118 | (!unicodeDomains && isValidMatch(authority, twttr.txt.regexen.validateUrlAuthority)); 1119 | }; 1120 | 1121 | function isValidMatch(string, regex, optional) { 1122 | if (!optional) { 1123 | // RegExp["$&"] is the text of the last match 1124 | // blank strings are ok, but are falsy, so we check stringiness instead of truthiness 1125 | return ((typeof string === "string") && string.match(regex) && RegExp["$&"] === string); 1126 | } 1127 | 1128 | // RegExp["$&"] is the text of the last match 1129 | return (!string || (string.match(regex) && RegExp["$&"] === string)); 1130 | } 1131 | 1132 | if (typeof module != 'undefined' && module.exports) { 1133 | module.exports = twttr.txt; 1134 | } 1135 | 1136 | }()); 1137 | --------------------------------------------------------------------------------