melpa/html/js/melpa.js
2015-01-14 09:47:07 +00:00

564 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* global window */
(function(m, document, _, moment, jQuery, Cookies){
"use strict";
// TODO Disqus
// TODO Show compatible emacs versions for any package
// TODO Google Analytics http://stackoverflow.com/questions/10713708/tracking-google-analytics-page-views-with-angular-js
// TODO D3 visualisation for deps
// TODO Show recent github events on package pages where applicable
// TODO Voting / starring
//////////////////////////////////////////////////////////////////////////////
// Helpers
//////////////////////////////////////////////////////////////////////////////
function intersperse(seq, sep) {
var res = seq.slice(0,1);
for(var i=1; i < seq.length; ++i) {
res.push(sep);
res.push(seq[i]);
}
return res;
}
//////////////////////////////////////////////////////////////////////////////
// Models
//////////////////////////////////////////////////////////////////////////////
var melpa = {};
melpa.rootURL = window.location.protocol + "//" + window.location.host;
melpa.Package = function(data) {
["name", "description", "version", "dependencies", "source",
"downloads", "fetcher", "recipeURL", "packageURL", "sourceURL", "oldNames"].map(function(p) {
this[p] = data[p];
}.bind(this));
this._searchText = _([data.name, data.description, data.version])
.compact().valueOf().join(' ').toLowerCase();
this.readmeURL = "/packages/" + data.name + "-readme.txt";
this.badgeURL = "/packages/" + data.name + "-badge.svg";
this.matchesTerm = function(term) {
return this._searchText.indexOf(term) != -1;
};
};
melpa.PackageList = function(packages) {
this.packages = packages;
this.totalDownloads = m.prop(_.reduce(_.map(packages, function(p) { return p.downloads || 0; }),
function (a, b) { return b === undefined ? a : a + b; }, 0));
this.totalPackages = m.prop(packages.length);
var savedSearches = {};
function preFilteredPackages(term) {
var prefixes = _.sortBy(_.filter(_.keys(savedSearches),
function(k) { return term.indexOf(k) === 0; }),
'length').reverse();
return prefixes.length > 0 ? savedSearches[prefixes[0]] : packages;
}
this.sortedPackages = function(sortBy, sortAscending) {
var sortKey = sortBy + "-" + sortAscending;
if (this.packages.sortKey === sortKey) return this.packages;
if (this.packages.sortKey === sortBy + "-" + !sortAscending) {
this.packages = this.packages.reverse();
} else {
var sorted = _.sortBy(this.packages, function(p) { return p[sortBy]; });
this.packages = sortAscending ? sorted : sorted.reverse();
}
this.packages.sortKey = sortKey;
return this.packages;
};
this.matchingPackages = function(terms) {
var t = terms.trim().toLowerCase();
var matching = savedSearches[t];
if (!matching) {
matching = savedSearches[t] = _.filter(preFilteredPackages(t),
function(p) { return p.matchesTerm(t); });
}
var visible = {};
_.each(matching, function(p){ visible[p.name] = true; });
return visible;
};
var packagesByName = {};
_.each(packages, function(p) {
packagesByName[p.name] = p;
if(p.oldNames) {
_.each(p.oldNames, function(n) { packagesByName[n] = p; });
}
});
this.packageWithName = function(name) {
return packagesByName[name];
};
var downloadCounts = _.pluck(packages, 'downloads');
this.downloadsPercentileForPackage = function(p) {
return _.filter(downloadCounts, function(d) { return d < p.downloads; }).length * 100.0 / downloadCounts.length;
};
this.dependenciesOnPackageName = function(packageName) {
return (_.filter(packages, function(p) {
return _.findWhere(p.dependencies, {name: packageName});
}));
};
};
//////////////////////////////////////////////////////////////////////////////
// Gather remote info about packages
//////////////////////////////////////////////////////////////////////////////
melpa.packageList = m.sync([
m.request({method: 'GET', url: "/recipes.json"}),
m.request({method: 'GET', url: "/archive.json"}),
m.request({method: 'GET', url: "/download_counts.json"})
]).then(function (info) {
var recipes = info[0], archive = info[1], downloads = info[2];
var calculateSourceURL = function(name, recipe) {
if (recipe.fetcher == "github") {
if (/\//.test(recipe.repo)) {
return "https://github.com/" + recipe.repo +
(recipe.branch ? "/tree/" + recipe.branch : "");
} else {
return "https://gist.github.com/" + recipe.repo;
}
} else if (recipe.fetcher == "wiki" && !recipe.files) {
return "http://www.emacswiki.org/emacs/" + name + ".el";
} else if (recipe.url) {
var urlMatch = function(re, prefix) {
var m = recipe.url.match(re);
return m !== null ? prefix + m[0] : null;
};
return (urlMatch(/(bitbucket\.org\/[^\/]+\/[^\/\?]+)/, "https://") ||
urlMatch(/(gitorious\.org\/[^\/]+\/[^.]+)/, "https://") ||
urlMatch(/^lp:(.*)/, "https://launchpad.net/") ||
urlMatch(/^(https?:\/\/code\.google\.com\/p\/[^\/]+\/)/) ||
urlMatch(/^(https?:\/\/[^.]+\.googlecode\.com\/)/));
}
return null;
};
var listed = _.intersection(_.keys(archive), _.keys(recipes));
return new melpa.PackageList(_(listed).reduce(function(pkgs, name) {
var built = archive[name];
var recipe = recipes[name];
var version = built.ver.join(".");
var deps = _.map(built.deps || [], function (ver, name) {
return {name: name, version: ver.join('.')};
});
var oldNames = recipe['old-names'] || [];
pkgs.push(new melpa.Package({
name: name,
version: version,
dependencies: deps,
description: built.desc.replace(/\s*\[((?:source: )?\w+)\]$/, ""),
source: recipe.fetcher,
downloads: _.reduce(oldNames.concat(name), function(sum, n) { return sum + (downloads[n] || 0); }, 0),
fetcher: recipe.fetcher,
recipeURL: "https://github.com/milkypostman/melpa/blob/master/recipes/" + name,
packageURL: "packages/" + name + "-" + version + "." + (built.type == "single" ? "el" : "tar"),
sourceURL: calculateSourceURL(name, recipe),
oldNames: oldNames
}));
return pkgs;
}, []));
});
//////////////////////////////////////////////////////////////////////////////
// View helpers
//////////////////////////////////////////////////////////////////////////////
function glyphicon(name) {
return m("span.glyphicon.glyphicon-" + name);
}
function packageLink(pkg, contents) {
return m("a", {href: "/" + encodeURIComponent(pkg.name), config: m.route},
contents || pkg.name);
}
function packagePath(pkg) {
if (m.route.mode !== "hash") throw "FIXME: unsupported route mode";
return "/#/" + encodeURIComponent(pkg.name);
}
//////////////////////////////////////////////////////////////////////////////
// Pagination
//////////////////////////////////////////////////////////////////////////////
melpa.paginator = {};
melpa.paginator.controller = function(getItemList) {
this.pageLength = m.prop(50);
this.windowSize = m.prop(7);
this.pageNumber = m.prop(1);
this.items = getItemList;
this.paginatedItems = function() {
if (this.pageNumber() !== null) {
return this.items().slice(this.pageLength() * (this.pageNumber() - 1),
this.pageLength() * this.pageNumber());
} else {
return this.items();
}
};
this.maxPage = function() {
return Math.floor(this.items().length / this.pageLength());
};
this.prevPages = function() {
return _.last(_.range(1, this.pageNumber()),
Math.floor((this.windowSize() - 1) / 2));
};
this.nextPages = function() {
return _.first(_.range(this.pageNumber() + 1, this.maxPage()),
this.windowSize() - 1 - this.prevPages().length);
};
};
melpa.paginator.view = function(ctrl) {
var prevPage = _.last(ctrl.prevPages());
var nextPage = _.first(ctrl.nextPages());
var pageLinkAttrs = function(n) {
if (n)
return { onclick: function(){ ctrl.pageNumber(n); } };
};
var pageLink = function(n) {
return m("li", m("a", pageLinkAttrs(n), m("span", n)));
};
return m("nav",
m("ul.pagination", [
m("li", { class: (prevPage ? "" : "disabled") },
m("a", pageLinkAttrs(prevPage), [
m("span", {"aria-hidden": "true"}, m.trust("&laquo;")),
m("span.sr-only", "Previous")
])),
ctrl.prevPages().map(pageLink),
m("li.active", m("a", m("span", [ctrl.pageNumber(), " ", m("span.sr-only", "(current)")]))),
ctrl.nextPages().map(pageLink),
m("li", { class: (nextPage ? "" : "disabled") },
m("a", pageLinkAttrs(nextPage), [
m("span", {"aria-hidden": "true"}, m.trust("&raquo;")),
m("span.sr-only", "Next")
]))
]));
};
//////////////////////////////////////////////////////////////////////////////
// Package list
//////////////////////////////////////////////////////////////////////////////
melpa.packagelist = {};
melpa.packagelist.controller = function() {
this.filterTerms = m.prop(m.route.param('q') || '');
this.sortBy = m.prop("name");
this.sortAscending = m.prop(true);
this.packageList = melpa.packageList;
this.matchingPackages = function() {
return this.packageList().matchingPackages(this.filterTerms());
};
this.sortedPackages = function() {
var visible = this.matchingPackages();
return this.packageList()
.sortedPackages(this.sortBy(), this.sortAscending())
.filter(function(p) { return visible[p.name]; });
}.bind(this);
this.toggleSort = function(field) {
if (this.sortBy() == field) {
this.sortAscending(!this.sortAscending());
} else {
this.sortAscending(true);
this.sortBy(field);
}
};
this.wantPagination = function() {
return !Cookies.get("nopagination");
};
this.togglePagination = function() {
console.log("toggle " + this.wantPagination());
if (this.wantPagination()) {
Cookies.set("nopagination", "1");
} else {
Cookies.expire("nopagination");
}
console.log("toggled " + Cookies.get("nopagination"));
};
this.paginatorCtrl = new melpa.paginator.controller(this.sortedPackages);
};
melpa.packagelist.view = function(ctrl) {
var sortToggler = function(field) {
return function() { return ctrl.toggleSort(field); };
};
var sortIndicator = function(field) {
return glyphicon((field != ctrl.sortBy()) ? "minus" : (ctrl.sortAscending() ? "chevron-down" : "chevron-up"));
};
return m("section#packages", [
m("h2", [
"Current List of ",
ctrl.packageList().totalPackages().toLocaleString(),
" Packages ",
m("small", [
ctrl.packageList().totalDownloads().toLocaleString(),
" downloads to date"
])
]),
m("p", [
m("input.form-control[type=search]", {
placeholder: "Enter filter terms", autofocus: true,
value: ctrl.filterTerms(), onkeyup: m.withAttr("value", ctrl.filterTerms)
}),
" ",
m("span.help-block", [_.keys(ctrl.matchingPackages()).length, " matching package(s)"])
]),
m("table#package-list.table.table-bordered.table-responsive.table-hover", [
m("thead", [
m("tr", [
m("th.sortable", {onclick: sortToggler("name")}, ["Package", sortIndicator("name")]),
m("th.sortable", {onclick: sortToggler("description")}, ["Description", sortIndicator("description")]),
m("th.sortable", {onclick: sortToggler("version")}, ["Version", sortIndicator("version")]),
m("th", "Recipe"),
m("th.sortable", {onclick: sortToggler("fetcher")}, ["Source", sortIndicator("fetcher")]),
m("th.sortable", {onclick: sortToggler("downloads")}, ["DLs", sortIndicator("downloads")]),
])
]),
m("tbody",
(ctrl.wantPagination() ? ctrl.paginatorCtrl.paginatedItems() : ctrl.sortedPackages()).map(function(p) {
return m("tr", { key: p.name }, [
m("td", packageLink(p)),
m("td", packageLink(p, p.description)),
m("td.version", m("a", {href: p.packageURL}, [p.version, " ", glyphicon('download')])),
m("td.recipe", [
m("a", {href: p.recipeURL}, [
glyphicon('cutlery')
])
]),
m("td.source", [
p.sourceURL ? m("a", {href: p.sourceURL}, [p.source]) : p.source
]),
m("td", [p.downloads.toLocaleString()])
]);
}))
]),
(ctrl.wantPagination() ? melpa.paginator.view(ctrl.paginatorCtrl) : null),
m("small",
m("a", {onclick: ctrl.togglePagination.bind(ctrl)},
(ctrl.wantPagination() ? "Disable pagination (may slow down display)" : "Enable pagination")
))
]);
};
//////////////////////////////////////////////////////////////////////////////
// Package details
//////////////////////////////////////////////////////////////////////////////
melpa.packagedetails = {};
melpa.packagedetails.controller = function() {
this.packageName = m.route.param("package");
this.package = m.prop();
this.readme = m.prop('No description available.');
this.neededBy = m.prop([]);
this.downloadsPercentile = m.prop(0);
this.archivename = new melpa.archivename.controller();
melpa.packageList.then(function(packageList) {
var p = packageList.packageWithName(this.packageName);
if (!p) return;
this.package(p);
this.downloadsPercentile(packageList.downloadsPercentileForPackage(p));
this.neededBy(_.sortBy(packageList.dependenciesOnPackageName(this.packageName), 'name'));
this.packageWithName = packageList.packageWithName;
m.request({method: "GET",
url: p.readmeURL,
deserialize: function(v){return v;}
}).then(this.readme);
}.bind(this));
};
melpa.packagedetails.view = function(ctrl) {
var pkg = ctrl.package();
if (!pkg) return m("h1", ["Package not found: ", ctrl.packageName]);
this.depLink = function(dep) {
var depPkg = ctrl.packageWithName(dep.name);
var label = dep.name + " " + dep.version;
return depPkg ? packageLink(depPkg, label) : label;
};
this.reverseDepLink = function(dep) {
var depPkg = ctrl.packageWithName(dep.name);
return depPkg ? packageLink(depPkg, dep.name) : dep.name;
};
var badgeURL = melpa.rootURL + pkg.badgeURL;
var fullURL = melpa.rootURL + packagePath(pkg);
return m("section", [
m("h1", [
pkg.name,
" ",
m("small", pkg.version)
]),
m("p.lead", pkg.description),
m("p", [
m("a.btn.btn-default", {href: pkg.recipeURL}, [glyphicon('cutlery'), " Recipe"]), ' ',
m("a.btn.btn-default", {href: pkg.packageURL}, [glyphicon('download'), " Download"]), ' ',
(pkg.sourceURL ? m("a.btn.btn-default", {href: pkg.sourceURL}, [glyphicon('home'), " Homepage"]) : '')
]),
m("section", [
m(".well", [
m("dl.dl-horizontal", [
m("dt", "Downloads"),
m("dd", [
pkg.downloads.toLocaleString(),
m("span.muted", " (all versions)"),
", percentile: ",
ctrl.downloadsPercentile().toFixed(2)
]),
m("dt", "Source"),
m("dd", [
pkg.sourceURL ? m("a", {href: pkg.sourceURL}, pkg.source) : m("span", pkg.source)
]),
m("dt", "Dependencies"),
m("dd", intersperse(_.sortBy(pkg.dependencies, 'name').map(this.depLink), " / ")),
m("dt", "Needed by"),
m("dd", intersperse(ctrl.neededBy().map(this.reverseDepLink), " / ")),
pkg.oldNames.length > 0 ? [
m("dt", "Renamed from:"),
m("dd", intersperse(pkg.oldNames, ', '))
// m("dt", "Old name needed by:"),
// m("dd", "TODO")
] : []
])
])
]),
m("section", [
m("h4", "Description"),
m("pre", ctrl.readme())
]),
m("section",
m("h4", "Badge code"),
m(".well", [
packageLink(pkg, m("img", {alt: ctrl.archivename.archiveName(), src: melpa.rootURL + pkg.badgeURL})),
m("dl", [
m("dt", "HTML"),
m("dd", m("pre", '<a href="' + fullURL + '"><img alt="' + ctrl.archivename.archiveName() + '" src="' + badgeURL + '"/></a>')),
m("dt", "Markdown"),
m("dd", m("pre", "[![" + ctrl.archivename.archiveName() + "](" + badgeURL + ")](" + fullURL + ")")),
m("dt", "Org"),
m("dd", m("pre", '[[' + fullURL + '][file:' + badgeURL + ']]'))
])
]))
]);
};
//////////////////////////////////////////////////////////////////////////////
// Showing last build time
//////////////////////////////////////////////////////////////////////////////
melpa.buildstatus = {};
melpa.buildstatus.controller = function() {
this.buildCompletionTime = m.request({method: 'GET', url: "/build-status.json"})
.then(function(status){
return new Date(status.completed * 1000);
});
};
melpa.buildstatus.view = function(ctrl) {
return m(".alert.alert-success", [
m("strong", "Last build ended: "),
m("span", [moment(ctrl.buildCompletionTime()).fromNow()])
]);
};
//////////////////////////////////////////////////////////////////////////////
// Changing the appearance of the MELPA Stable page
//////////////////////////////////////////////////////////////////////////////
melpa.stable = m.prop(window.location.host === 'melpa-stable.milkbox.net' || window.location.host === 'stable.melpa.org');
melpa.archivename = {};
melpa.archivename.controller = function() {
this.archiveName = function() {
return melpa.stable() ? "MELPA Stable" : "MELPA";
};
};
melpa.archivename.view = function(ctrl) {
return m("span", ctrl.archiveName());
};
jQuery(window).load(function() {
document.title = (new melpa.archivename.controller()).archiveName();
jQuery(".archive-name").each(function(i, e) {
// jshint unused: false
m.module(e, melpa.archivename);
});
if (melpa.stable()) {
jQuery("html").addClass("stable");
}
});
//////////////////////////////////////////////////////////////////////////////
// Static pages
//////////////////////////////////////////////////////////////////////////////
melpa.staticpage = function(partialPath) {
this.controller = function() {
this.content = m.prop('');
m.request({method: "GET", url: partialPath,
deserialize: function(v){return v;}
}).then(this.content);
};
this.view = function(ctrl) {
return m("div", [m.trust(ctrl.content())]);
};
};
//////////////////////////////////////////////////////////////////////////////
// Front page
//////////////////////////////////////////////////////////////////////////////
melpa.frontpage = {};
melpa.frontpage.controller = function() {
this.packagelist = new melpa.packagelist.controller();
this.buildstatus = new melpa.buildstatus.controller();
this.archivename = new melpa.archivename.controller();
};
melpa.frontpage.view = function(ctrl) {
return m("div", [
m("section.page-header", [
m("h1", [
melpa.archivename.view(ctrl.archivename),
m("small", " (Milkypostmans Emacs Lisp Package Archive)")
])
]),
m(".row", [
m(".col-md-8", [
m("section.jumbotron", [
m("ul", [
"<strong>Up-to-date packages built on our servers from upstream source</strong>",
"<strong>Installable in any recent Emacs using 'package.el'</strong> - no need to install svn/cvs/hg/bzr/git/darcs/fossil etc.",
"<strong>Curated</strong> - no obsolete, renamed, forked or randomly hacked packages",
"<strong>Comprehensive</strong> - more packages than any other archive",
"<strong>Automatic updates</strong> - new commits result in new packages",
"<strong>Extensible</strong> - contribute recipes via github, and we'll build the packages"
].map(function(content) { return m("li", m.trust(content)); }))
])
]),
m(".col-md-4", [
melpa.buildstatus.view(ctrl.buildstatus),
m.trust('<a class="twitter-timeline" data-dnt="true" data-related="milkypostman,sanityinc" href="https://twitter.com/melpa_emacs" data-widget-id="311867756586864640">Tweets by @melpa_emacs</a>'),
m('script', {src: "http://platform.twitter.com/widgets.js", type: "text/javascript"})
])
]),
melpa.packagelist.view(ctrl.packagelist)
]);
};
//////////////////////////////////////////////////////////////////////////////
// Routing
//////////////////////////////////////////////////////////////////////////////
melpa.gettingstarted = new melpa.staticpage("/partials/getting-started.html");
m.route.mode = "hash";
m.route(document.getElementById("content"), "/", {
"/": melpa.frontpage,
"/getting-started": melpa.gettingstarted,
"/:package": melpa.packagedetails
});
})(window.m, window.document, window._, window.moment, window.jQuery, window.Cookies);