/** * writeCapture.js v1.0.5 * * @author noah <noah.sloan@gmail.com> * */ (function($,global) { var doc = global.document; function doEvil(code) { var div = doc.createElement('div'); doc.body.insertBefore(div,null); $.replaceWith(div,'<script type="text/javascript">'+code+'</script>'); } // ensure we have our support functions $ = $ || (function(jQuery) { /** * @name writeCaptureSupport * * The support functions writeCapture needs. */ return { /** * Takes an options parameter that must support the following: * { * url: url, * type: 'GET', // all requests are GET * dataType: "script", // it this is set to script, script tag injection is expected, otherwise, treat as plain text * async: true/false, // local scripts are loaded synchronously by default * success: callback(text,status), // must not pass a truthy 3rd parameter * error: callback(xhr,status,error) // must pass truthy 3rd parameter to indicate error * } */ ajax: jQuery.ajax, /** * @param {String Element} selector an Element or selector * @return {Element} the first element matching selector */ $: function(s) { return jQuery(s)[0]; }, /** * @param {String jQuery Element} selector the element to replace. * writeCapture only needs the first matched element to be replaced. * @param {String} content the content to replace * the matched element with. script tags must be evaluated/loaded * and executed if present. */ replaceWith: function(selector,content) { // jQuery 1.4? has a bug in replaceWith so we can't use it directly var el = jQuery(selector)[0]; var next = el.nextSibling, parent = el.parentNode; jQuery(el).remove(); if ( next ) { jQuery(next).before( content ); } else { jQuery(parent).append( content ); } }, onLoad: function(fn) { jQuery(fn); }, copyAttrs: function(src,dest) { var el = jQuery(dest), attrs = src.attributes; for (var i = 0, len = attrs.length; i < len; i++) { if(attrs[i] && attrs[i].value) { try { el.attr(attrs[i].name,attrs[i].value); } catch(e) { } } } } }; })(global.jQuery); $.copyAttrs = $.copyAttrs || function() {}; $.onLoad = $.onLoad || function() { throw "error: autoAsync cannot be used without jQuery " + "or defining writeCaptureSupport.onLoad"; }; // utilities function each(array,fn) { for(var i =0, len = array.length; i < len; i++) { if( fn(array[i]) === false) return; } } function isFunction(o) { return Object.prototype.toString.call(o) === "[object Function]"; } function isString(o) { return Object.prototype.toString.call(o) === "[object String]"; } function slice(array,start,end) { return Array.prototype.slice.call(array,start || 0,end || array && array.length); } function any(array,fn) { var result = false; each(array,check); function check(it) { return !(result = fn(it)); } return result; } function SubQ(parent) { this._queue = []; this._children = []; this._parent = parent; if(parent) parent._addChild(this); } SubQ.prototype = { _addChild: function(q) { this._children.push(q); }, push: function (task) { this._queue.push(task); this._bubble('_doRun'); }, pause: function() { this._bubble('_doPause'); }, resume: function() { this._bubble('_doResume'); }, _bubble: function(name) { var root = this; while(!root[name]) { root = root._parent; } return root[name](); }, _next: function() { if(any(this._children,runNext)) return true; function runNext(c) { return c._next(); } var task = this._queue.shift(); if(task) { task(); } return !!task; } }; /** * Provides a task queue for ensuring that scripts are run in order. * * The only public methods are push, pause and resume. */ function Q(parent) { if(parent) { return new SubQ(parent); } SubQ.call(this); this.paused = 0; } Q.prototype = (function() { function f() {} f.prototype = SubQ.prototype; return new f(); })(); Q.prototype._doRun = function() { if(!this.running) { this.running = true; try { // just in case there is a bug, always resume // if paused is less than 1 while(this.paused < 1 && this._next()){} } finally { this.running = false; } } }; Q.prototype._doPause= function() { this.paused++; }; Q.prototype._doResume = function() { this.paused--; this._doRun(); }; // TODO unit tests... function MockDocument() { } MockDocument.prototype = { _html: '', open: function( ) { this._opened = true; if(this._delegate) { this._delegate.open(); } }, write: function(s) { if(this._closed) return; this._written = true; if(this._delegate) { this._delegate.write(s); } else { this._html += s; } }, writeln: function(s) { this.write(s + '\n'); }, close: function( ) { this._closed = true; if(this._delegate) { this._delegate.close(); } }, copyTo: function(d) { this._delegate = d; d.foobar = true; if(this._opened) { d.open(); } if(this._written) { d.write(this._html); } if(this._closed) { d.close(); } } }; // test for IE 6/7 issue (issue 6) that prevents us from using call var canCall = (function() { var f = { f: doc.getElementById }; try { f.f.call(doc,'abc'); return true; } catch(e) { return false; } })(); function unProxy(elements) { each(elements,function(it) { var real = doc.getElementById(it.id); if(!real) { logError('<proxyGetElementById - finish>', 'no element in writen markup with id ' + it.id); return; } each(it.el.childNodes,function(it) { real.appendChild(it); }); if(real.contentWindow) { // TODO why is the setTimeout necessary? global.setTimeout(function() { it.el.contentWindow.document. copyTo(real.contentWindow.document); },1); } $.copyAttrs(it.el,real); }); } function getOption(name,options) { if(options && options[name] === false) { return false; } return options && options[name] || self[name]; } function capture(context,options) { var tempEls = [], proxy = getOption('proxyGetElementById',options), forceLast = getOption('forceLastScriptTag',options), writeOnGet = getOption('writeOnGetElementById',options), immediate = getOption('immediateWrites', options), state = { write: doc.write, writeln: doc.writeln, finish: function() {}, out: '' }; context.state = state; doc.write = immediate ? immediateWrite : replacementWrite; doc.writeln = immediate ? immediateWriteln : replacementWriteln; if(proxy || writeOnGet) { state.getEl = doc.getElementById; doc.getElementById = getEl; if(writeOnGet) { findEl = writeThenGet; } else { findEl = makeTemp; state.finish = function() { unProxy(tempEls); }; } } if(forceLast) { state.getByTag = doc.getElementsByTagName; doc.getElementsByTagName = function(name) { var result = slice(canCall ? state.getByTag.call(doc,name) : state.getByTag(name)); if(name === 'script') { result.push( $.$(context.target) ); } return result; }; var f = state.finish; state.finish = function() { f(); doc.getElementsByTagName = state.getByTag; }; } function replacementWrite(s) { state.out += s; } function replacementWriteln(s) { state.out += s + '\n'; } function immediateWrite(s) { var target = $.$(context.target); var div = doc.createElement('div'); target.parentNode.insertBefore(div,target); $.replaceWith(div,sanitize(s)); } function immediateWriteln(s) { var target = $.$(context.target); var div = doc.createElement('div'); target.parentNode.insertBefore(div,target); $.replaceWith(div,sanitize(s) + '\n'); } function makeTemp(id) { var t = doc.createElement('div'); tempEls.push({id:id,el:t}); // mock contentWindow in case it's supposed to be an iframe t.contentWindow = { document: new MockDocument() }; return t; } function writeThenGet(id) { var target = $.$(context.target); var div = doc.createElement('div'); target.parentNode.insertBefore(div,target); $.replaceWith(div,state.out); state.out = ''; return canCall ? state.getEl.call(doc,id) : state.getEl(id); } function getEl(id) { var result = canCall ? state.getEl.call(doc,id) : state.getEl(id); return result || findEl(id); } return state; } function uncapture(state) { doc.write = state.write; doc.writeln = state.writeln; if(state.getEl) { doc.getElementById = state.getEl; } return state.out; } function clean(code) { // IE will execute inline scripts with <!-- (uncommented) on the first // line, but will not eval() them happily return code && code.replace(/^\s*<!(\[CDATA\[|--)/,'').replace(/(\]\]|--)>\s*$/,''); } function ignore() {} function doLog(code,error) { console.error("Error",error,"executing code:",code); } var logError = isFunction(global.console && console.error) ? doLog : ignore; function captureWrite(code,context,options) { var state = capture(context,options); try { doEvil(clean(code)); } catch(e) { logError(code,e); } finally { uncapture(state); } return state; } // copied from jQuery function isXDomain(src) { var parts = /^(\w+:)?\/\/([^\/?#]+)/.exec(src); return parts && ( parts[1] && parts[1] != location.protocol || parts[2] != location.host ); } function attrPattern(name) { return new RegExp('\\b'+name+'[\\s\\r\\n]*=[\\s\\r\\n]*(?:(["\'])([\\s\\S]*?)\\1|([^\\s>]+))','i'); } function matchAttr(name) { var regex = attrPattern(name); return function(tag) { var match = regex.exec(tag) || []; return match[2] || match[3]; }; } var SCRIPT_TAGS = /(<script[^>]*>)([\s\S]*?)<\/script>/ig, SCRIPT_2 = /<script[^>]*\/>/ig, SRC_REGEX = attrPattern('src'), SRC_ATTR = matchAttr('src'), TYPE_ATTR = matchAttr('type'), LANG_ATTR = matchAttr('language'), GLOBAL = "__document_write_ajax_callbacks__", DIV_PREFIX = "__document_write_ajax_div-", TEMPLATE = "window['"+GLOBAL+"']['%d']();", callbacks = global[GLOBAL] = {}, TEMPLATE_TAG = '<script type="text/javascript">' + TEMPLATE + '</script>', global_id = 0; function nextId() { return (++global_id).toString(); } function normalizeOptions(options,callback) { var done; if(isFunction(options)) { done = options; options = null; } options = options || {}; done = done || options && options.done; options.done = callback ? function() { callback(done); } : done; return options; } // The global Q synchronizes all sanitize operations. // The only time this synchronization is really necessary is when two or // more consecutive sanitize operations make async requests. e.g., // sanitize call A requests foo, then sanitize B is called and bar is // requested. document.write was replaced by B, so if A returns first, the // content will be captured by B, then when B returns, document.write will // be the original document.write, probably messing up the page. At the // very least, A will get nothing and B will get the wrong content. var GLOBAL_Q = new Q(); var debug = []; var logDebug = window._debugWriteCapture ? function() {} : function (type,src,data) { debug.push({type:type,src:src,data:data}); }; var logString = window._debugWriteCapture ? function() {} : function () { debug.push(arguments); }; function newCallback(fn) { var id = nextId(); callbacks[id] = function() { fn(); delete callbacks[id]; }; return id; } function newCallbackTag(fn) { return TEMPLATE_TAG.replace(/%d/,newCallback(fn)); } /** * Sanitize the given HTML so that the scripts will execute with a modified * document.write that will capture the output and append it in the * appropriate location. * * @param {String} html * @param {Object Function} [options] * @param {Function} [options.done] Called when all the scripts in the * sanitized HTML have run. * @param {boolean} [options.asyncAll] If true, scripts loaded from the * same domain will be loaded asynchronously. This can improve UI * responsiveness, but will delay completion of the scripts and may * cause problems with some scripts, so it defaults to false. */ function sanitize(html,options,parentQ,parentContext) { // each HTML fragment has it's own queue var queue = parentQ && new Q(parentQ) || GLOBAL_Q; options = normalizeOptions(options); var done = getOption('done',options); var doneHtml = ''; var fixUrls = getOption('fixUrls',options); if(!isFunction(fixUrls)) { fixUrls = function(src) { return src; }; } // if a done callback is passed, append a script to call it if(isFunction(done)) { // no need to proxy the call to done, so we can append this to the // filtered HTML doneHtml = newCallbackTag(function() { queue.push(done); }); } // for each tag, generate a function to load and eval the code and queue // themselves return html.replace(SCRIPT_TAGS,proxyTag).replace(SCRIPT_2,proxyBodyless) + doneHtml; function proxyBodyless(tag) { // hack in a bodyless tag... return proxyTag(tag,tag.substring(0,tag.length-2)+'>',''); } function proxyTag(element,openTag,code) { var src = SRC_ATTR(openTag), type = TYPE_ATTR(openTag) || '', lang = LANG_ATTR(openTag) || '', isJs = (!type && !lang) || // no type or lang assumes JS type.toLowerCase().indexOf('javascript') !== -1 || lang.toLowerCase().indexOf('javascript') !== -1; logDebug('replace',src,element); if(!isJs) { return element; } var id = newCallback(queueScript), divId = DIV_PREFIX + id, run, context = { target: '#' + divId, parent: parentContext }; function queueScript() { queue.push(run); } if(src) { // fix for the inline script that writes a script tag with encoded // ampersands hack (more comon than you'd think) src = fixUrls(src); openTag = openTag.replace(SRC_REGEX,''); if(isXDomain(src)) { // will load async via script tag injection (eval()'d on // it's own) run = loadXDomain; } else { // can be loaded then eval()d if(getOption('asyncAll',options)) { run = loadAsync(); } else { run = loadSync; } } } else { // just eval code and be done run = runInline; } function runInline() { captureHtml(code); } function loadSync() { $.ajax({ url: src, type: 'GET', dataType: 'text', async: false, success: function(html) { captureHtml(html); } }); } function logAjaxError(xhr,status,error) { logError("<XHR for "+src+">",error); queue.resume(); } function setupResume() { return newCallbackTag(function() { queue.resume(); }); } function loadAsync() { var ready, scriptText; function captureAndResume(script,status) { if(!ready) { // loaded before queue run, cache text scriptText = script; return; } try { captureHtml(script, setupResume()); } catch(e) { logError(script,e); } } // start loading the text $.ajax({ url: src, type: 'GET', dataType: 'text', async: true, success: captureAndResume, error: logAjaxError }); return function() { ready = true; if(scriptText) { // already loaded, so don't pause the queue and don't resume! captureHtml(scriptText); } else { queue.pause(); } }; } function loadXDomain(cb) { var state = capture(context,options); queue.pause(); // pause the queue while the script loads logDebug('pause',src); doXDomainLoad(context.target,src,captureAndResume); function captureAndResume(xhr,st,error) { logDebug('out', src, state.out); html(uncapture(state), newCallbackTag(state.finish) + setupResume()); logDebug('resume',src); } } function captureHtml(script, cb) { var state = captureWrite(script,context,options); cb = newCallbackTag(state.finish) + (cb || ''); html(state.out,cb); } function safeOpts(options) { var copy = {}; for(var i in options) { if(options.hasOwnProperty(i)) { copy[i] = options[i]; } } delete copy.done; return copy; } function html(markup,cb) { $.replaceWith(context.target,sanitize(markup,safeOpts(options),queue,context) + (cb || '')); } return '<div style="display: none" id="'+divId+'"></div>' + openTag + TEMPLATE.replace(/%d/,id) + '</script>'; } } function doXDomainLoad(target,url,success) { // TODO what about scripts that fail to load? bad url, etc.? var script = document.createElement("script"); script.src = url; target = $.$(target); var done = false, parent = target.parentNode; // Attach handlers for all browsers script.onload = script.onreadystatechange = function(){ if ( !done && (!this.readyState || this.readyState == "loaded" || this.readyState == "complete") ) { done = true; success(); // Handle memory leak in IE script.onload = script.onreadystatechange = null; parent.removeChild( script ); } }; parent.insertBefore(script,target); } /** * Sanitizes all the given fragments and calls action with the HTML. * The next fragment is not started until the previous fragment * has executed completely. * * @param {Array} fragments array of objects like this: * { * html: '<p>My html with a <script...', * action: function(safeHtml,frag) { doSomethingToInject(safeHtml); }, * options: {} // optional, see #sanitize * } * Where frag is the object. * * @param {Function} [done] Optional. Called when all fragments are done. */ function sanitizeSerial(fragments,done) { // create a queue for these fragments and make it the parent of each // sanitize call var queue = GLOBAL_Q; each(fragments, function (f) { queue.push(run); function run() { f.action(sanitize(f.html,f.options,queue),f); } }); if(done) { queue.push(done); } } function findLastChild(el) { var n = el; while(n && n.nodeType === 1) { el = n; n = n.lastChild; // last child may not be an element while(n && n.nodeType !== 1) { n = n.previousSibling; } } return el; } /** * Experimental - automatically captures document.write calls and * defers them untill after page load. * @param {Function} [done] optional callback for when all the * captured content has been loaded. */ function autoCapture(done) { var write = doc.write, writeln = doc.writeln, currentScript, autoQ = []; doc.writeln = function(s) { doc.write(s+'\n'); }; var state; doc.write = function(s) { var scriptEl = findLastChild(doc.body); if(scriptEl !== currentScript) { currentScript = scriptEl; autoQ.push(state = { el: scriptEl, out: [] }); } state.out.push(s); }; $.onLoad(function() { // for each script, append a div immediately after it, // then replace the div with the sanitized output var el, div, out, safe, doneFn; done = normalizeOptions(done); doneFn = done.done; done.done = function() { doc.write = write; doc.writeln = writeln; if(doneFn) doneFn(); }; for(var i = 0, len = autoQ.length; i < len; i++ ) { el = autoQ[i].el; div = doc.createElement('div'); el.parentNode.insertBefore( div, el.nextSibling ); out = autoQ[i].out.join(''); // only the last snippet gets passed the callback safe = len - i === 1 ? sanitize(out,done) : sanitize(out); $.replaceWith(div,safe); } }); } function extsrc(cb) { var scripts = document.getElementsByTagName('script'), s,o, html, q, ext, async, doneCount = 0, done = cb ? newCallbackTag(function() { if(++doneCount >= exts.length) { cb(); } }) : '', exts = []; for(var i = 0, len = scripts.length; i < len; i++) { s = scripts[i]; ext = s.getAttribute('extsrc'); async = s.getAttribute('asyncsrc'); if(ext || async) { exts.push({ext:ext,async:async,s:s}); } } for(i = 0, len = exts.length; i < len; i++) { o = exts[i]; if(o.ext) { html = '<script type="text/javascript" src="'+o.ext+'"> </script>'; $.replaceWith(o.s,sanitize(html) + done); } else if(o.async) { html = '<script type="text/javascript" src="'+o.async+'"> </script>'; $.replaceWith(o.s,sanitize(html,{asyncAll:true}, new Q()) + done); } } } var name = 'writeCapture'; var self = global[name] = { _original: global[name], support: $, /** */ fixUrls: function(src) { return src.replace(/&/g,'&'); }, noConflict: function() { global[name] = this._original; return this; }, debug: debug, /** * Enables a fun little hack that replaces document.getElementById and * creates temporary elements for the calling code to use. */ proxyGetElementById: false, // this is only for testing, please don't use these _forTest: { Q: Q, GLOBAL_Q: GLOBAL_Q, $: $, matchAttr: matchAttr, slice: slice, capture: capture, uncapture: uncapture, captureWrite: captureWrite }, replaceWith: function(selector,content,options) { $.replaceWith(selector,sanitize(content,options)); }, html: function(selector,content,options) { var el = $.$(selector); el.innerHTML ='<span/>'; $.replaceWith(el.firstChild,sanitize(content,options)); }, load: function(selector,url,options) { $.ajax({ url: url, dataType: 'text', type: "GET", success: function(content) { self.html(selector,content,options); } }); }, extsrc: extsrc, autoAsync: autoCapture, sanitize: sanitize, sanitizeSerial: sanitizeSerial }; })(this.writeCaptureSupport,this);