/* 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("«")), 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("»")), 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", '' + ctrl.archivename.archiveName() + '')), 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", " (Milkypostman’s Emacs Lisp Package Archive)") ]) ]), m(".row", [ m(".col-md-8", [ m("section.jumbotron", [ m("ul", [ m("li", m.trust("Up-to-date packages built on our servers from upstream source")), m("li", m.trust("Installable in any recent Emacs using 'package.el' - no need to install svn/cvs/hg/bzr/git/darcs/fossil etc.")), m("li", m.trust("Curated - no obsolete, renamed, forked or randomly hacked packages")), m("li", m.trust("Comprehensive - more packages than any other archive")), m("li", m.trust("Automatic updates - new commits result in new packages")), m("li", m.trust("Extensible - contribute recipes via github, and we'll build the packages")) ]) ]) ]), m(".col-md-4", [ melpa.buildstatus.view(ctrl.buildstatus), m.trust('Tweets by @melpa_emacs'), 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);