Skip to content

Instantly share code, notes, and snippets.

@staylor
Created April 16, 2012 18:06
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save staylor/2400406 to your computer and use it in GitHub Desktop.
Save staylor/2400406 to your computer and use it in GitHub Desktop.
The old one
/*
Please do not edit this file directly (unless you intend to fork).
It's been open-sourced here:
http://github.com/dgouldin/django-hashsignal
Requires
* jQuery hashchange event - v1.2 - 2/11/2010
* http://benalman.com/projects/jquery-hashchange-plugin/
*/
/*globals ActiveXObject, _gaq, window, jQuery */
(function (window, $, undefined) {
"use strict";
var ALWAYS_RELOAD = '__all__',
HASH_REPLACEMENT = ':',
CONTAINERPATH = '/listen/',
previousLocation = null,
upcomingLocation = null,
previousSubhash = null,
transitions = {},
document = window.document,
location = window.location,
history = window.history,
insertId = 0,
activeOpts,
defaultOpts,
liveFormsSel,
referrer,
methods;
function isCached(url) {
var cache = -1 !== url.indexOf('.css') ||
-1 !== url.indexOf('.js') ||
-1 !== url.indexOf('/browse/') ||
-1 !== url.indexOf('/charts/') ||
-1 !== url.indexOf('/music-news/') ||
-1 !== url.indexOf('/book-news/') ||
-1 !== url.indexOf('/music-genres/') ||
-1 !== url.indexOf('/book-genres/') ||
-1 !== url.indexOf('/artist/') ||
-1 !== url.indexOf('/search/') ||
-1 !== url.indexOf('/radio/') ||
(url.indexOf('/album/') === -1 && url.indexOf('/book/') === -1 && false !== pathOf(url));
return cache;
}
// url helpers
function isCrossDomain(url) {
// taken straight from jQuery:
// https://github.com/jquery/jquery/blob/master/src/ajax.js
var ajaxLocation,
ajaxLocParts,
parts,
rurl = /^([\w\+\.\-]+:)(?:\/\/([^\/?#:]*)(?::(\d+))?)?/;
try {
ajaxLocation = window.location.href;
} catch (e) {
ajaxLocation = window.document.createElement("a");
ajaxLocation.href = "";
ajaxLocation = ajaxLocation.href;
}
// Segment location into parts
ajaxLocParts = rurl.exec(ajaxLocation.toLowerCase()) || [];
parts = rurl.exec(url.toLowerCase());
return !!(parts &&
(parts[1] !== ajaxLocParts[1] || parts[2] !== ajaxLocParts[2] ||
(parts[3] || (parts[1] === "http:" ? 80 : 443)) !==
(ajaxLocParts[3] || (ajaxLocParts[1] === "http:" ? 80 : 443))));
}
function hrefToHash(href) {
var parts = href.split("#"),
subhash = parts[1] || "";
return parts[0] + HASH_REPLACEMENT + encodeURIComponent(subhash);
}
function hashToHref(hash) {
var subhashIndex,
page,
subhash;
hash = (hash.charAt(0) === "#" ? hash.substr(1) : hash);
subhashIndex = hash.lastIndexOf(HASH_REPLACEMENT);
if (subhashIndex === -1) {
return hash;
} else {
page = hash.substr(0, subhashIndex);
subhash = decodeURIComponent(hash.substr(subhashIndex + 1));
return page + (subhash ? "#" + subhash : "");
}
}
function urlPrefix() {
return location.protocol + "//" + location.host;
}
function pathOf(absolute) {
var domain = urlPrefix() + "/";
if (0 !== absolute.indexOf(domain)) { //off-site, or different protocol.
return false;
}
if (-1 !== absolute.indexOf(CONTAINERPATH + '#/')) { //handle links that have container path in the url, not for real hash
if (/:$/.test(absolute)) {
absolute = absolute.slice(0, -1);
return absolute.slice(domain.length + CONTAINERPATH.length);
} else {
return absolute.slice(domain.length + CONTAINERPATH.length);
}
} else {
return "/" + absolute.slice(domain.length);
}
}
function resolve(url) {
return $('<a href="' + url + '"></a>').get(0).href;
}
function log() {
var args;
if (!(activeOpts && activeOpts.debug)) {
return;
}
args = [new Date(), "hashsignal"].concat(Array.prototype.slice.apply(arguments));
if (window.console) {
window.console.log(args);
} else {
window.alert(args.join(" "));
}
}
defaultOpts = {
excludeSelector: '.no-ajax',
beforeUpdate: function () { log('beforeUpdate'); },
afterUpdate: function () { log('afterUpdate'); },
errorUpdate: function () { log('errorUpdate'); },
onDocumentWrite: function (msg) {
$('.footer-wrapper').writeCapture().append(msg);
if (window.console) {
window.console.log("jQuery.hashsignal received document.write: " + msg);
}
},
debug: false,
disabled: false,
resolverId: "hashsignal-abs",
inlineStylesheets: false,
replaceBlocksOnError: false
};
function blockAction(actionName, blockName) {
/* DRYs up _unloadBlock and _loadBlock below */
var transition = transitions[blockName], name;
if (!transition) {
return;
}
for (name in transition) {
if (transition.hasOwnProperty(name)) {
transition[name][actionName]();
/* Clean up old transitions which are no longer needed. */
if (actionName === 'unload' && !transition[name].o.runOnce && blockName !== ALWAYS_RELOAD) {
delete transition[name];
}
}
}
}
function getOldBlocks(doc) {
var blockRe = /^ (end)?block ([^ ]*) ([0-9a-f]{32} )?$/,
blocks = {};
function walker(root, handle) {
var i, c;
handle(root);
for (i = 0, c = root.childNodes.length; i < c; i += 1) {
walker(root.childNodes[i], handle);
}
}
function blockWalker(root, handle) { //handle(name, isStart, node)
walker(root, function (node) {
var match;
if (node.nodeType === 8) { // comment node
match = blockRe.exec(node.nodeValue);
if (match) {
handle(match[2], match[3], !match[1], node);
}
}
});
}
doc = doc || document;
blockWalker(doc, function (name, signature, isStart, node) {
if (blocks[name] === undefined) {
blocks[name] = {
nodes: [null, null],
signature: signature
};
}
blocks[name].nodes[isStart ? 0 : 1] = node;
});
return blocks;
}
function getNewBlocks(html, callback) {
var blocker = /<!-- (end)?block ([^ ]*) ([0123456789abcdef]{32} )?-->/gi,
stylesheet = /<link.+?(rel=["']?stylesheet["'\s])?.*?href=["']?(.+?)["'\s].*?(rel=["']?stylesheet["'\s])?.*?>/gi,
starts = [], //stack of {name:a, signature:x, start:y};
closing,
blocks = {}, //name: {signature:x, html:z}
stylesheetPromises = [];
function last() {
return starts[starts.length - 1];
}
html.replace(blocker, function (matched, ending, blockName, signatureMaybe, offset, fullString) {
if (ending && starts.length === 0) {
log("Unexpected block nesting on match: " + matched);
}
if (!ending && !signatureMaybe) {
log('WARNING: block found without signature', blockName);
}
if (ending) {
closing = last();
blockName = closing.name;
starts.length = starts.length - 1;
blocks[blockName] = {
html: fullString.slice(closing.start, offset),
signature: closing.signature
};
if (activeOpts.inlineStylesheets) {
// begin async loading stylesheets for inline replacement
blocks[blockName].html.replace(stylesheet, function (sMatched, preRel, href, postRel, offset, sFullString) {
if (!isCrossDomain(href) && ((preRel && (preRel.toLowerCase().indexOf('stylesheet') !== -1)) || (postRel && (postRel.toLowerCase().indexOf('stylesheet') !== -1)))) {
stylesheetPromises.push($.ajax({
cache: isCached(href),
dataType: "text",
url: href
}).done(function (css) {
blocks[blockName].html = blocks[blockName].html.replace(sMatched, '<style type="text/css">' + css + '</style>');
}));
}
});
}
} else {
starts.push({
name: blockName,
start: offset + matched.length,
signature: signatureMaybe
});
}
});
if (0 !== starts.length) {
log("Unclosed block: " + last().name);
}
if (stylesheetPromises.length > 0) {
$.when.apply($, stylesheetPromises).then(function () {
callback(blocks);
}, function () {
callback(blocks);
});
} else {
callback(blocks);
}
}
function replaceBlocks(html, forceReload, callback) {
log('replaceBlocks');
var oldBlocks = getOldBlocks();
callback = callback || $.noop;
function siblingsBetween(start, end) {
var siblings = [],
current = start;
while (current && (current !== end)) {
if (current !== start) {
siblings.push(current);
}
current = current.nextSibling;
}
return siblings;
}
function getBodyAttrs(body) {
var bodyAttrs = {},
// these are attrs which may be reported as present
// but cannot be modified, so we must skip.
blacklist = 'contentEditable';
$.each(body.attributes, function (i, attr) {
// WARNING: attributes behavior is not very cross-browser friendly.
// see: http://www.quirksmode.org/dom/w3c_core.html#attributes
if (!(!!attr && attr.name) || blacklist.indexOf(attr.name) !== -1) {
return;
}
if (attr.value) {
bodyAttrs[attr.name] = attr.value;
}
});
return bodyAttrs;
}
getNewBlocks(html, function (newBlocks) {
//update title
var titleRe = /<title>(.*)<\/title>/,
titleMatch = titleRe.exec(html),
oldBody = $('body'),
bodyRe = /<body([^>]*)>/,
bodyMatch = bodyRe.exec(html),
oldBodyAttrs,
newBodyAttrs,
fakeDoc,
namespaceURI,
fakeHtml,
newBody,
newValue,
blockName,
oldBlock,
newBlock;
if (titleMatch) {
document.title = titleMatch[1];
}
// replace old body attributes with new ones
if (bodyMatch) {
if (window.ActiveXObject) {
// For IE
// NOTE: requires valid xml body attributes!
try {
fakeDoc = new ActiveXObject("Microsoft.XMLDOM");
fakeDoc.async = false;
fakeDoc.loadXML('<body ' + bodyMatch[1] + '></body>');
newBody = fakeDoc.documentElement;
} catch (e) {}
} else if (window.document.implementation && window.document.implementation.createDocument) {
namespaceURI = window.document.namespaceURI || 'http://www.w3.org/1999/xhtml';
fakeDoc = window.document.implementation.createDocument(namespaceURI, 'html', null);
fakeHtml = fakeDoc.documentElement;
fakeHtml.innerHTML = '<body ' + bodyMatch[1] + '></body>';
newBody = $('body', fakeHtml).get(0);
}
if (newBody) {
oldBodyAttrs = getBodyAttrs(oldBody.get(0));
newBodyAttrs = getBodyAttrs(newBody);
$.each(oldBodyAttrs, function (key, oldValue) {
newValue = newBodyAttrs[key];
if (newValue) {
if (newValue !== oldValue) {
oldBody.attr(key, newValue);
}
delete newBodyAttrs[key];
} else {
if (oldBody.attr(key)) {
oldBody.removeAttr(key);
}
}
});
oldBody.attr(newBodyAttrs);
}
}
methods._unloadBlock(ALWAYS_RELOAD);
for (blockName in newBlocks) {
if (newBlocks.hasOwnProperty(blockName)) {
if (oldBlocks.hasOwnProperty(blockName)) { // if (blockName in oldBlocks
oldBlock = oldBlocks[blockName];
newBlock = newBlocks[blockName];
if (oldBlock.signature && ((newBlock.signature && (oldBlock.signature === newBlock.signature)) && !forceReload)) {
log('Not replacing block, signatures match.', blockName, oldBlock.signature);
// The block is the same, no need to swap out the content.
continue;
}
methods._unloadBlock(blockName);
$(siblingsBetween(oldBlock.nodes[0], oldBlock.nodes[1])).remove();
log('Replacing block', blockName, newBlock.html);
// methods._loadBlock must be called from inside newBlock.html so that mutations block as
//would normally happen with inline scripts.
$(oldBlock.nodes[0]).after(
newBlock.html +
'<script type="text/javascript">' +
'jQuery.hashsignal._loadBlock("' +
blockName.replace('"', '\\"') + '");' +
'</scr' + 'ipt>'
);
insertId += 1;
// update block signature
$(oldBlock.nodes[0]).replaceWith("<!-- block " + blockName + " " + (newBlock.signature || "") + "-->");
} else {
log('WARNING: unmatched block', blockName);
}
}
}
methods._loadBlock(ALWAYS_RELOAD);
callback();
});
}
function updatePage(opts) {
var o,
callbacks,
urlParts,
expectedLocation,
subhash;
opts.url = opts.url + (opts.url.indexOf('?') < 0 ? '?' : '&') + 'hashsignal=true';
o = $.extend({
url: (previousLocation || '') + '#' + (previousSubhash || ''),
type: 'GET',
data: '',
cache: false,
forceReload: false
}, opts);
callbacks = $.extend({
beforeUpdate: function () {},
afterUpdate: function () {},
errorUpdate: function () {}
}, activeOpts);
urlParts = o.url.split("#");
expectedLocation = urlParts[0] || previousLocation;
subhash = urlParts[1] || '';
if (expectedLocation === previousLocation && (subhash !== previousSubhash)) {
$(window).trigger('hashsignal.hashchange', [subhash]);
previousSubhash = subhash;
return;
}
if (!o.forceReload && (expectedLocation === previousLocation && ((o.type.toLowerCase() === 'get') && !o.data))) {
return;
}
//deal with multiple pending requests by always having the
// last-requested win, rather than last-responded.
upcomingLocation = expectedLocation;
function makeSuccessor(expectedLocation) {
return function (data, status, xhr) {
var jsonData;
if (expectedLocation !== upcomingLocation) {
log("Success for ", expectedLocation, " fired but last-requested was ", upcomingLocation, " - aborting");
return;
}
try {
jsonData = $.parseJSON(data);
} catch (ex) {}
// If response body contains a redirect location, perform the redirect.
// This is an xhr-compatible proxy for 301/302 responses.
if (jsonData && jsonData.redirectLocation) {
log('redirecting page', jsonData.redirectLocation);
previousLocation = expectedLocation;
previousSubhash = subhash;
location.replace('#' + hrefToHash(jsonData.redirectLocation));
return;
}
//setBase(urlPrefix() + expectedLocation);
replaceBlocks(data, o.forceReload, function () {
if (subhash) {
$(window).trigger('hashsignal.hashchange', [subhash]);
}
previousLocation = expectedLocation;
previousSubhash = subhash;
callbacks.afterUpdate();
});
};
}
callbacks.beforeUpdate();
$.ajax({
dataType: "text",
data: o.data,
cache: isCached(expectedLocation),
error: function (xhr, status, error) {
log('updatePage error ' + status + " " + error);
callbacks.errorUpdate(xhr, status, error, previousLocation);
if (activeOpts.replaceBlocksOnError) {
makeSuccessor(expectedLocation)(xhr.responseText, status, xhr);
} else {
history.back();
}
},
success: makeSuccessor(expectedLocation),
beforeSend: function (xhr) {
xhr.setRequestHeader('X-Hashsignal', 'Hashsignal'); //Used to tell server to send Ajax-friendly redirects.
if (previousLocation) {
xhr.setRequestHeader('X-Hashsignal-Referer', resolve(previousLocation));
}
},
type: o.type,
url: expectedLocation
});
}
function Transition(opts) {
var script,
that;
this.hasRun = false;
this.o = $.extend({
load: function () {},
unload: function () {},
runOnce: false
}, opts);
this.events = [];
this.delegates = [];
this.timeouts = [];
this.intervals = [];
this.scripts = {};
// shims
this.bind = function (obj, eventType, eventData, handler) {
this.events.push([obj, eventType, handler]);
return $(obj).bind(eventType, eventData, handler);
};
this.delegate = function (obj, selector, eventType, eventData, handler) {
this.delegates.push([obj, selector, eventType, handler]);
return $(obj).delegate(selector, eventType, eventData, handler);
};
this.setTimeout = function (callback, timeout) {
this.timeouts.push(window.setTimeout(callback, timeout));
};
this.setInterval = function (callback, timeout) {
this.intervals.push(window.setInterval(callback, timeout));
};
this.clearTimeout = window.clearTimeout;
this.clearInterval = window.clearInterval;
this.addScript = function (src, loadOnce) {
var parts;
loadOnce = loadOnce === undefined ? true : loadOnce;
if (!(loadOnce && this.scripts[src])) {
script = document.createElement('script');
script.type = 'text/javascript';
if (src.indexOf('?')) {
parts = src.split('?');
src = parts[0];
}
script.src = src;
script = $(script);
that = this;
script.load(function () {
that.scripts[src] = true;
$(this).unbind('load');
});
$('body').append(script);
}
};
this.load = function () {
if (!(this.hasRun && this.runOnce)) {
this.o.load(this);
}
this.hasRun = true;
};
this.unload = function () {
var i, e;
if (!this.runOnce) {
for (i = 0; i < this.events.length; i += 1) {
e = this.events[i];
$(e[0]).unbind(e[1], e[2]);
}
for (i = 0; i < this.delegates.length; i += 1) {
e = this.delegates[i];
$(e[0]).undelegate(e[1], e[2], e[3]);
}
for (i = 0; i < this.timeouts.length; i += 1) {
window.clearTimeout(this.timeouts[i]);
}
for (i = 0; i < this.intervals.length; i += 1) {
window.clearInterval(this.intervals[i]);
}
this.o.unload(this);
}
};
}
function Location(url) {
var parts,
that,
partFunc,
k;
// parseUri 1.2.2
// (c) Steven Levithan <stevenlevithan.com>
// MIT License
function parseUri(str) {
var o = parseUri.options,
m = o.parser[o.strictMode ? "strict" : "loose"].exec(str),
uri = {},
i = 14;
while (i--) { uri[o.key[i]] = m[i] || ""; }
uri[o.q.name] = {};
uri[o.key[12]].replace(o.q.parser, function ($0, $1, $2) {
if ($1) {uri[o.q.name][$1] = $2;}
});
return uri;
}
parseUri.options = {
strictMode: false,
key: ["source", "protocol", "authority", "userInfo", "user", "password", "host", "port", "relative", "path", "directory", "file", "query", "anchor"],
q: {
name: "queryKey",
parser: /(?:^|&)([^&=]*)=?([^&]*)/g
},
parser: {
strict: /^(?:([^:\/?#]+):)?(?:\/\/((?:(([^:@]*)(?::([^:@]*))?)?@)?([^:\/?#]*)(?::(\d*))?))?((((?:[^?#\/]*\/)*)([^?#]*))(?:\?([^#]*))?(?:#(.*))?)/,
loose: /^(?:(?![^:@]+:[^:@\/]*@)([^:\/?#.]+):)?(?:\/\/)?((?:(([^:@]*)(?::([^:@]*))?)?@)?([^:\/?#]*)(?::(\d*))?)(((\/(?:[^?#](?![^?#\/]*\.[^?#\/.]+(?:[?#]|$)))*\/?)?([^?#\/]*))(?:\?([^#]*))?(?:#(.*))?)/
}
};
// end parseUri
parts = {
port: '', // 80
protocol: '', // http:
hostname: '', // www.google.com
pathname: '', // /search
search: '' // ?q=devmo
};
that = this;
partFunc = function (k) {
return function (value) {
if (value === undefined) {
return parts[k];
} else {
parts[k] = value;
}
};
};
for (k in parts) {
if (parts.hasOwnProperty(k)) {
that[k] = partFunc(k);
}
}
parts.hash = '';
this.hash = function (value) { // #test
if (value === undefined) {
return parts.hash;
} else {
if (value.length === 0) {
parts.hash = '';
} else {
parts.hash = value[0] === '#' ? value : '#' + value;
}
return parts.hash;
}
};
this.href = function (value) {
var obj; // http://www.google.com:80/search?q=devmo#test
if (value === undefined) {
return this.protocol() + '//' + this.host() + this.pathname() + this.search() + this.hash();
} else {
obj = parseUri(value);
parts = {
port: obj.port,
protocol: obj.protocol + ':',
hostname: obj.host,
pathname: obj.path,
search: obj.query ? "?" + obj.query : "",
hash: obj.anchor ? "#" + obj.anchor : ""
};
return this.href();
}
};
this.host = function (value) {
var obj; // www.google.com:80
if (value === undefined) {
return this.hostname() + (this.port() === '' ? '' : ':' + this.port());
} else {
obj = parseUri(value + this.pathname() + this.search() + this.hash());
parts.port = obj.port;
parts.hostname = obj.host;
return this.host();
}
};
this.relativeHref = function () {
return this.pathname() + this.search() + this.hash();
};
this.href(url); // hook it up!
}
methods = {
init: function (explicitOpts) {
activeOpts = $.extend(defaultOpts, explicitOpts);
if (activeOpts.disabled) {
// shortcut event binding
return this;
}
document.write = activeOpts.onDocumentWrite;
$(window).bind('hashchange', function (e) {
log('hashchange', e);
updatePage({
url: hashToHref(location.hash),
type: 'GET'
});
});
if (location.hash && location.hash !== '#') {
updatePage({
url: hashToHref(location.hash),
type: 'GET'
});
}
$('a:not(' + activeOpts.excludeSelector + ')').live('click', function () {
var href = resolve(this.getAttribute('href') || "."),
hash;
if (isCrossDomain(href)) { //off-site links act normally.
return true;
}
hash = hrefToHash(pathOf(href));
if (hash === "/:" && ((window.location.hash === '#/' || window.location.hash === '#/:') && (!$.cookie('EMUSIC_REMEMBER_ME_COOKIE')))) { //we are at the home page and we might need to reload it to see a pitch page because we have no cookie, so force a reload here
window.location.href = "/";
}
location.hash = hash;
return false;
});
liveFormsSel = 'form:not(' + activeOpts.excludeSelector + ')';
$(liveFormsSel).live('submit', function (event) {
var href = resolve($(this).attr('action') || "."),
path = pathOf(href),
type,
data,
submitter;
if (isCrossDomain(href) || path === false) { //off-site forms act normally.
return true;
}
if ($(this).has("input[type='file']").length) {
// we can't serialize files, so we have to do it the old-fashioned way
$(this).attr('action', path);
return true;
}
type = $(this).attr('method');
data = $(this).serialize();
if (!data) { //form might have no data
return false;
}
submitter = this.submitter;
if (submitter) {
data += (data.length === 0 ? "" : "&") + (encodeURIComponent($(submitter).attr("name")) + "=" + encodeURIComponent($(submitter).attr("value")) + '');
}
if (type && type.toLowerCase() === 'get') {
//fix up the querystring.
path = path.substring(0, path.indexOf('?')) || path;
path += '?' + data;
location.hash = hrefToHash(path);
} else {
// TODO: how does a post affect the hash fragment?
activeOpts.beforeUpdate();
updatePage({
url: path,
type: type,
data: data
});
}
return false;
});
//make sure the submitting button is included in the form data.
$(liveFormsSel + " input[type=submit], " + liveFormsSel + " button[type=submit]").live('click', function (event) {
var form = $(this).closest("form").get(0);
if (form) {
form.submitter = this;
}
return true;
});
return this;
},
isEnabled: function () {
return activeOpts && activeOpts.hashsignalEnabled;
},
hashchange: function (callback) { // callback = function(e, hash) { ... }
$(window).bind('hashsignal.hashchange', callback);
return this;
},
location: (function (properties) {
var that = {};
$(properties).each(function (i, property) {
that[property] = function (value) {
var href = resolve(hashToHref(location.hash)),
l = new Location(href);
if (!l) {
log("Could not parse current location! " + href);
}
if (value === undefined) {
return l[property]();
} else {
l[property](value);
location.hash = hrefToHash(l.relativeHref());
}
};
});
that.assign = that.href; // alias to fully support window.location parity
that.reload = function () {
updatePage({
forceReload: true
});
};
that.replace = function (url) {
var l = new Location(url);
location.replace('#' + hrefToHash(l.relativeHref()));
};
return that;
}(['hash', 'href', 'pathname', 'search'])),
registerTransition: function (name, blockNames, opts) {
log('hashsignal.registerTransition', name, blockNames);
var transition = new Transition(opts),
i,
blockName;
if (!!opts.alwaysReload) {
blockNames = [ALWAYS_RELOAD];
}
for (i = 0; i < blockNames.length; i += 1) {
blockName = blockNames[i];
if (transitions[blockName] === undefined) {
transitions[blockName] = {};
}
if (transitions[blockName][name] === undefined) {
transitions[blockName][name] = transition;
}
}
return this;
},
_unloadBlock: function (blockName) {
log('hashsignal.unloadBlock', blockName);
blockAction('unload', blockName);
},
_loadBlock: function (blockName) {
log('hashsignal.loadBlock', blockName);
blockAction('load', blockName);
}
};
$.hashsignal = methods;
}(window, jQuery));
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment