elem
Version:
An asset manager based on custom elements
871 lines (705 loc) • 19.7 kB
JavaScript
var elem = {};
(function() {
/**
* Root directory of all elements
*/
var root = new Dir('/');
elem.root = root;
elem.enhance = enhance;
elem.scan = scan;
elem.require = require;
elem.allTags = {};
elem.findAssociatedDir = findAssociatedDir;
/**
* Various ways of exporting
*/
// Module
if (typeof module !== 'undefined') {
module.exports = elem;
}
// Webworker
if (typeof self !== 'undefined') {
self.elem = elem;
}
// Window
if (typeof window !== 'undefined') {
window.elem = elem;
}
/**
* Environment - "production" or "development"
*/
var mode = 'development';
function basedir(filename) {
return filename.split('/').slice(0,-1).join('/') + '/'
}
function ppcss(css, filename) {
if (typeof document === 'undefined') {
return css;
}
if(mode == 'development') {
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = filename + '?' + elem.buildId;
document.head.appendChild(link);
}
else {
var dir = basedir(filename);
css = css.replace(/(@import\s*['?"?])([^\/|.*?\/\/])/g, '$1'+dir+'$2')
css = css.replace(/(url\(['?"?])([^\/|.*?\/\/|#|data:])/g, '$1'+dir+'$2')
var style = document.createElement('style');
style.innerHTML = css;
document.head.appendChild(style);
}
return css;
}
function pphtml(html) {
var dir = html.parent;
html.data = '<!-- '+html.path+' -->\n' + html.data;
if(!(dir instanceof Dir)) return html.data;
return html.data.replace(/\.\//m, dir.path)
return html.data;
}
/**
* TODO: This needs to be able to handle conflicting
* names in different directories.
*/
function findAssociatedDir(node) {
return elem.allTags[node.tagName];
}
/**
* Apply self-named resources to an element.
*
* e.g. Within directory page/, applies page.js if present
*
* If Javascript is found it is used to fill out an "exports" object on the directory.
* If HTML is found it is used as the innerHTML ONLY if there is there are no existing children.
* If CSS is found it is linked to the document head.
*
* @param {DOMElement} The target element to enhance
* @param {Dir} dir Custom element directory
* @param {Function} done Callback
*/
function enhance(elem, dir) {
if (!elem) {
return false;
}
// Always enhance once
if (elem.__elem_enhanced) {
return true;
}
dir = dir || findAssociatedDir(elem);
if (!dir) return false;
// If removed from the dom after we
// scheduled it for enhancement, cancel
if (!elem.ownerDocument.contains(elem)) {
return;
}
elem.__elem_enhanced = dir;
function rescan(node) {
// Re-scan this element against
// ancestor directories
// The impl could have introduced
// new matchable elements.
var node = elem;
while(node) {
var pdir = node.__elem_enhanced;
if(pdir) {
scan(elem, pdir);
}
node = node.parentElement;
}
// And root
scan(elem, root);
}
function render(html) {
if(html) {
if(html instanceof Element) {
elem.innerHTML = '';
elem.appendChild( html );
}
else if(typeof html === 'string') {
html = '<!-- generated by '+dir.path+' -->\n' + html;
elem.innerHTML = html;
}
else if(typeof html === 'object'
&& html.length) {
elem.innerHTML = '';
for(var i=0,l=html.length; i<l; ++i) {
elem.appendChild( html[i] );
}
}
}
rescan();
}
var html = require(dir.path,'html');
if(html) {
elem.innerHTML = html;
}
var impl = require(dir.path,'js');
if(impl) {
if(typeof impl === 'function') {
if(impl.length == 0) {
var html = impl.call(elem);
render(html);
}
else if(impl.length == 2) {
impl.call(elem, {deprecated: 'deprecated'}, render);
}
else {
impl.call(elem, render);
}
}
else {
render();
}
}
else {
render();
}
}
/**
* Searches a base element for instances of custom elements,
* loads the resources, and then calls enhance().
*
* @param {DOMElement} base The root element to search within
* @param {Dir} dir Custom element directory
*/
function scan(base, dir) {
dir = dir || root; // Default to root
var uses = dir.findAll(base);
uses = uses.sort(function(a,b) {
if( a === b) return 0;
if( !a.compareDocumentPosition) {
// support for IE8 and below
return a.sourceIndex - b.sourceIndex;
}
if( a.compareDocumentPosition(b) & 2) {
// b comes before a
return 1;
}
return -1;
});
each(uses, function(elem) {
var tagName = elem.tagName.toLowerCase();
var path = [];
var tmp = dir;
while(tmp) {
if(tmp.children[tagName]) {
path.unshift(tmp.children[tagName]);
}
tmp = tmp.parent;
}
// Must load all first an enhance in order
parallel(path, function() {
each(path, function(dir) {
enhance(elem, dir);
});
});
});
}
/**
* A simple XMLHttpRequest GET.
*
* @param {String} url URL to fetch
* @param {Function} done Callback
*/
function ajax(url,done) {
var xmlhttp;
if (elem.buildId) {
url += '?'+elem.buildId;
}
if(typeof XMLHttpRequest !== 'undefined') {
xmlhttp = new XMLHttpRequest(); // Browsers
}
else {
xmlhttp = new ActiveXObject("Microsoft.XMLHTTP"); // IE
}
xmlhttp.onreadystatechange = function() {
if(xmlhttp.readyState == 4
&& xmlhttp.status == 200) {
done(null, xmlhttp.responseText)
}
}
xmlhttp.open("GET", url, true);
xmlhttp.send();
}
/**
* Gets a file relative to the root path
* If `development`, or `production` environment
* this uses XMLHTTPRequest, but in `test` mode
* it will use native node `fs.readFileSync`.
*
* @param {String} path
* @param {Function} done function(err, data)
*/
function get(path,done) {
if (mode === 'test') {
var fs = nodeRequire('fs');
var data = ''+fs.readFileSync(root.path+path);
done(null, data);
}
else {
ajax(elem.domain+root.path+path, done);
}
}
function select(base, tags) {
var elems = [];
each(tags, function(tag) {
var all = base.getElementsByTagName(tag);
for(var i=0,l=all.length; i < l; ++i) {
elems.push(all[i]);
}
});
return elems;
}
/**
* Simple parallel processor.
*/
function parallel(objs, done) {
var count = objs.length;
if(objs.length === 0) done();
each(objs, function(obj) {
obj.load(function() {
if(--count == 0) {
done();
}
});
});
}
/**
* Dir
*
* A local mapping of a server-side directory.
*
* @param {Dir} parent Parent directory object
* @param {String} path path of the directory
*/
function Dir(path, parent) {
this.parent = parent;
this.path = path;
this.availTags = [];
this.observers = [];
this.children = {};
}
Dir.prototype.findAll = function(base) {
var tags = this.availTags;
if(this.parent) {
tags = tags.concat(this.parent.availTags);
}
return select(base, tags);
};
Dir.prototype.observe = function(done) {
this.observers.push(done);
}
Dir.prototype.complete = function() {
if(this.loaded) return;
this.loaded = true;
this.loading = false;
this.observers.forEach(function(fn) {
fn();
});
this.observers = [];
}
Dir.prototype.getChildren = function(recursive) {
var files = [];
for(var filename in this.children) {
var f = this.children[filename];
if(f instanceof File) {
files.push(this.children[filename]);
}
if(f instanceof Dir) {
if(recursive || filename == this.tagName) {
[].push.apply(files, f.getChildren(recursive));
}
}
}
return files;
}
Dir.prototype.isLoaded = function(recursive) {
if(this.loaded) {
return true;
}
if(this.loading) {
this.loaded = this.getChildren().reduce(function(a,b) {
return a && b.isLoaded()
}, true);
}
return this.loaded;
}
Dir.prototype.load = function(done, recursive) {
var self = this;
if(this.isLoaded()) {
done();
return;
}
this.observe(done);
if(this.loading) {
return;
}
this.loading = true;
var resources = [];
// FIXME
// We should not need to sort client-side
// Just do things in order of the index...
// This whole thing is a huge waste of bytes
if(this.children.window && typeof window !== 'undefined') {
[].push.apply(resources, this.children.window.getChildren(true));
var self = this;
this.children.window.load(function() {
function runAll(dir) {
var globals = Object.keys(dir.children);
globals = globals.sort(function(a,b) {
return b.length < a.length ? 1 : -1;
});
each(globals, function(name) {
if(dir.children[name] instanceof File) {
var path = dir.children[name].path;
require(path,'js');
}
});
each(globals, function(name) {
if(dir.children[name] instanceof Dir) {
runAll(dir.children[name]);
}
});
}
runAll(self.children.window);
}, true);
}
// If not in test mode, recursively load
// all children automatically.
//
// Since test mode can synchronously read
// files as they are required we can speed
// up tests by only loading them when they
// are required, later.
//
// The browser on the other hand, must have
// everything available beforehand.
if (mode !== 'test') {
[].push.apply(resources, this.getChildren(true));
}
parallel(resources, function() {
self.complete()
});
};
function jsfn(txt, isGlobal) {
var fn;
if(isGlobal) {
fn = new Function(txt);
}
else {
fn = new Function('module','exports','require', txt);
}
return fn;
}
function normalize(path) {
var result = [];
var parts;
var token;
parts = path.split('/');
for(var i=0, l=parts.length; i < l; ++i) {
token = parts[i];
if (token === '..') {
result.pop();
} else if (token && token !== '.') {
result.push(token);
}
}
return result.join('/').replace(/[\/]{2,}/g, '/');
}
function resolve(base,rel) {
var basedir = base.split('/').slice(0,-1).join('/');
var pathname = [basedir,rel].join('/');
return normalize(pathname);
}
/**
* Finds dependencies given a base just like node requires
* but with an abstract extension.
*
* Examples:
*
* require('../body','js','/elements/header/header.js')
* require('../body','html','/elements/header/header.js')
*
* @param {String} filename Module path
* @param {String} ext File extension to look for
* @param {String} basename Reference point for relative paths
*/
function require(filename, ext, basename) {
ext = ext || 'js';
basename = basename || "/";
var relpath = resolve(basename, filename);
var lastname = relpath.split('/').filter(function(n){return n;}).slice(-1)[0] || '';
var file;
filename = filename.toLowerCase();
lastname = lastname.toLowerCase();
relpath = relpath.toLowerCase();
// Make a list of possible paths
var possible = [
relpath,
relpath+'.'+ext,
relpath+'/'+lastname+'.'+ext,
relpath+'/index.'+ext,
globalModules[filename]
];
// Try all of them in order
while(possible.length) {
var attempt = File.map[possible.pop()];
if(attempt) {
file = attempt;
break;
}
}
if(!file) {
return false;
}
// For test mode, since we don't load children
// recursively, we need to do it here on require.
// And test mode loading is synchronous so this
// will complete immediately.
if (mode === 'test') {
file.load(function noop(){});
}
if(file.tagName != 'js') {
return file.data;
}
var js = file.data + '\r\n//# sourceURL=' + root.path + file.path;
var isGlobal = file.isGlobal();
var fn = jsfn(js, isGlobal);
if(isGlobal) {
fn();
return false;
}
// If we already executed return exports
if(file.module) return file.module.exports;
// Run for the first time and save exports
function localRequire(name, ext) {
ext = ext || 'js';
var dep = require(name, ext, file.path);
if(!dep) {
throw new Error("failed to require "+name+" from "+file.path);
}
return dep;
}
var module = {exports: {}};
file.module = module;
fn.call(module.exports, module, module.exports, localRequire);
return module.exports;
}
/**
* File
*
* Local tracker object for a single remote file.
*
* @param {String} path The path of the file relative to global `base`
* @param {Dir} parent Parent dir object of the file
*/
function File(path, parent) {
this.observers = [];
this.loading = false;
this.loaded = false;
this.path = path;
this.parent = parent;
File.map[path.toLowerCase()] = this;
}
File.map = {};
File.prototype = {
handle: function() {
var file = this.path;
var ext = file.split('.').slice(-1)[0];
var handlers = {
html: pphtml,
css: ppcss,
json: ppjson,
js: ppjs
};
if(handlers[ext]) {
handlers[ext](name, data, file);
}
}
, isLoaded: function() {
return this.loaded;
}
, isGlobal: function() {
var dir = this;
while(dir) {
if(dir.tagName === 'window')
return true;
dir = dir.parent;
}
return false;
}
, complete: function() {
this.loading = false;
this.loaded = true;
var ext = this.path.split('.').slice(-1)[0];
if(ext == 'js') {
this.data = '\n// ' + this.path + '\n\n' + this.data;
}
if(ext == 'json') {
this.data = JSON.parse(this.data);
}
if(ext == 'css') {
ppcss(this.data, root.path+this.path);
}
if(ext == 'html') {
this.data = pphtml(this);
}
each(this.observers, function(done) {
done();
});
this.observers = [];
}
, observe: function(done) {
this.observers.push(done);
}
, load: function(done) {
var self = this;
if(this.loaded) {
done();
return;
}
// No need to ajax load
// css since we link it
if(mode == 'development'
&& this.path.match(/\.css$/)) {
this.data = '';
self.complete();
done();
return;
}
this.observe(done);
if(this.loading) {
return;
}
this.loading = true;
var pkgpath = packages[this.path];
// css since we link it
if(pkgpath) {
var pkg = File.map[pkgpath];
pkg.load(function() {
self.data = pkg.data[self.path];
self.complete();
});
}
else {
get(self.path, function(err, data) {
self.data = data;
self.complete();
if(err) {
console.error('Problem loading ' + self.path);
return;
}
});
}
}
};
var globalModules = {};
var packages = {};
function parseIndex(json) {
var files = json.files;
var modules = json.modules;
globalModules = json.modules;
packages = json.packages;
var base = '/';
each(files, function(file) {
var nodes = file.split(/[\/|\.]/);
var numDirs = file.split('/').length+1;
var parent = root;
var parentName = null;
var dirpath = base;
each(nodes, function(node,i) {
// Space not allowed
// TODO make actual accepted symbols
if(node.match(/\s/)) return;
// Merge _ prefixed directories in
// to the parent directory
if(node && node[0] != '_') {
if(--numDirs) {
dirpath += node + '/';
}
if(i == nodes.length-1) {
var resource = new File(file, parent);
parent.children[node] = resource;
parent.children[node].tagName = node;
}
else {
var dir = parent.children[node] = parent.children[node] || new Dir(dirpath, parent);
dir.tagName = node;
elem.allTags[node.toUpperCase()] = dir;
// elem.allTags[node] = elem.allTags[node] || [];
// elem.allTags[node].push(dir);
// Old IEs needs this.
// It's a classic way of getting HTML5
// elements recognized.
// document.createElement(node);
if(parent.availTags.indexOf(node) == -1) {
parent.availTags.push(node);
}
}
parent = parent.children[node];
parentName = node;
}
});
});
}
var started = false;
elem.start = function(config) {
if(started) {
throw 'elem.start() called twice!';
}
started = true;
elem.domain = config.domain;
elem.buildId = config.buildId;
root.path = config.basePath || '/';
mode = config.mode || 'development';
parseIndex(config.index);
root.load(function() {
// No document actually required
// (we can run in webworkers)
if (typeof document !== 'undefined') {
domReady(function() {
setupDOMHooks();
scan(document, root);
});
}
});
}
// We don't support IE6 or 7. We can do a much simpler document ready check.
function domReady(callback) {
if (document.readyState !== "loading") return callback();
var addListener = document.addEventListener || document.attachEvent,
removeListener = document.removeEventListener || document.detachEvent
eventName = document.addEventListener ? "DOMContentLoaded" : "onreadystatechange"
addListener.call(document, eventName, function(){
removeListener.call(document, eventName, arguments.callee, false )
callback()
}, false )
}
function setupDOMHooks() {
var _appendChild = HTMLElement.prototype.appendChild;
HTMLElement.prototype.appendChild = function(child) {
var result = _appendChild.apply(this, arguments);
elem.enhance(child);
return result;
}
var _insertBefore = HTMLElement.prototype.insertBefore;
HTMLElement.prototype.insertBefore = function(noop, child) {
var result = _insertBefore.apply(this, arguments);
elem.enhance(child);
return result;
}
var _replaceChild = HTMLElement.prototype.replaceChild;
HTMLElement.prototype.replaceChild = function(noop, child) {
var result = _replaceChild.apply(this, arguments);
elem.enhance(child);
return result;
}
}
function each(arr,fn) {
for(var i=0,l=arr.length;i<l;++i) {
fn(arr[i],i);
}
}
})();