← Back to team overview

yellow team mailing list archive

[Merge] lp:~gary/juju-gui/grabbag into lp:juju-gui

 

Gary Poster has proposed merging lp:~gary/juju-gui/grabbag into lp:juju-gui.

Requested reviews:
  Juju GUI Hackers (juju-gui)
Related bugs:
  Bug #1092199 in juju-gui: "Makefile build target is misnamed"
  https://bugs.launchpad.net/juju-gui/+bug/1092199

For more details, see:
https://code.launchpad.net/~gary/juju-gui/grabbag/+merge/141022

Various small cleanups

This branch has a little for everyone.  I did it during spare moments today to see if I could address various issues I saw and had heard about.  I think that landing this branch would be fine, but I also would be happy enough if people wanted to extract bits from it on a piecemeal basis instead.  I'm sorry that everything is mixed together, but they are small changes.  I can extract, or ask other people to, if necessary.

I'm going to fully explain the changes.  This will help me remember.  It will also probably be painful to read.  Sorry.

For Nicola, and for those with a masochistic enjoyment of Makefile churn, I addressed bug 1092199: "Makefile build target is misnamed."  See the bug for rationale and for the plan, which I followed except that I used "build-shared" instead of "build-common" as the new name for the former "build" directory.  This "build-shared" choice, like a couple of other choices in this branch, was fairly arbitrary.  I'd rather not change it, but that's because of time and inertia.  If I'd reread the bug before starting I probably would have used build-common.  Fixes for this bug account for the vast majority of the Makefile changes.  The two exceptions are the change to the JSFILES variable definition, and the removal of the skin asset Make dependencies for build-prod and build-devel.  I'll explain those in a moment.  This bug also accounts for many of the changes in bin/merge-files, and various other places where you see s/build/build-shared/.

Thanks to the diagnosis and discussion from Nicola, Francesco and Kapil, I addressed one of the remaining parts of bug 1083920, "Charm should serve the GUI assets over HTTPS".  Francesco identified two insecure sources we were having trouble with when we served over HTTPS.  One insecure connection was the charm store.  Kapil is working on getting a cert for the charm store, so we can simply use HTTPS for our charm store URLs.  The other insecure connection is that we were still relying on a number of files from the Yahoo CDN.  In prod, we relied on the HTTP-only CDN for three Gallery files, and in debug (and devel) we relied on the CDN for many files in addition.  I fixed the way we serve our files in devel, debug and prod, so that all YUI files are served locally.  The main step for this was to get the gallery files stored locally.  I could have done this many ways.  One choice could have been to use the npm yui-gallery package, but it is quite old so I rejected it.  I also could have gotten all the gallery files from git as part of the build process.  That would have been fine, as would have other approaches, but I went for an explicit and simple approach: I downloaded the files we needed into app/assets/javascripts and told the debug modules file where to find them and told the minifier to include them.  This also required some tweaks to the Makefile's JSFILES calculation, to exclude those files.

With help from Benji, I updated the reviewer documentation and the release documentation to reflect the current state of things.  Hopefully those changes are self-explanatory, and if they are not, we should probably fix them so they are!

I removed some unnecessarily duplicated and ignored topology "requires" in the modules-debug.js file, as requested by a comment at the top of that file and as blessed by Ben.

More valuably, I seem to have fixed the errors we saw in ``make test-prod`` in Ben's recently landed branch.  I believe it was because of the fake console missing a "debug" call.  In any case, I was having trouble with prod, and now it is working for me, and the prod tests seem fine too.

I tweaked Benji's Makefile change that had switched back from linking to copying YUI files into the build directories.  The old symlinks had put the skin file assets in the wrong place.  When Benji fixed the assets, he didn't notice that the Makefile said that it depended on the wrong location of those files.  This meant that every time you ran make, the Makefile would rerun the linking code, in the vain attempt to create those incorrectly-placed files.  Rather than include all skin asset locations, I simply removed those two dependencies ("night" and "sam").  Now the Makefile is as quiet as desired on repeated runs of "make," "make prod," "make debug," and "make devel."

OK, that's it.  As I said, landing this seems OK, but feel free to steal from it instead.  Please poke around both at the Makefile and the app to make sure nothing seems to have regressed.  Thank you!

https://codereview.appspot.com/7005044/

-- 
https://code.launchpad.net/~gary/juju-gui/grabbag/+merge/141022
Your team Juju GUI Hackers is requested to review the proposed merge of lp:~gary/juju-gui/grabbag into lp:juju-gui.
=== modified file '.bzrignore'
--- .bzrignore	2012-12-07 13:59:27 +0000
+++ .bzrignore	2012-12-21 02:37:21 +0000
@@ -13,7 +13,7 @@
 Session.vim
 virtualenv
 yuidoc
-build
+build-shared
 upload_release.py
 releases
 build-prod

=== modified file 'Makefile'
--- Makefile	2012-12-19 16:08:09 +0000
+++ Makefile	2012-12-21 02:37:21 +0000
@@ -27,8 +27,9 @@
 	\) -print \
 	| sort | sed -e 's/^\.\///' \
 	| grep -Ev -e '^manifest\.json$$' \
-		-e '^app/assets/javascripts/d3.v2.*.js$$' \
-		-e '^app/assets/javascripts/reconnecting-websocket.js$$' \
+		-e '^app/assets/javascripts/d3\.v2(\.min)?\.js$$' \
+		-e '^app/assets/javascripts/reconnecting-websocket\.js$$' \
+		-e '^app/assets/javascripts/gallery-.*\.js$$' \
 		-e '^server.js$$')
 THIRD_PARTY_JS=app/assets/javascripts/reconnecting-websocket.js
 NODE_TARGETS=node_modules/chai node_modules/cryptojs node_modules/d3 \
@@ -108,15 +109,15 @@
 TEMPLATE_TARGETS=$(shell find app/templates -type f ! -name '.*' ! -name '*.swp' ! -name '*~' ! -name '\#*' -print)
 
 SPRITE_SOURCE_FILES=$(shell find app/assets/images -type f ! -name '.*' ! -name '*.swp' ! -name '*~' ! -name '\#*' -print)
-SPRITE_GENERATED_FILES=build/juju-ui/assets/sprite.css \
-	build/juju-ui/assets/sprite.png
-BUILD_FILES=build/juju-ui/assets/app.js \
-	build/juju-ui/assets/all-yui.js \
-	build/juju-ui/assets/combined-css/all-static.css
+SPRITE_GENERATED_FILES=build-shared/juju-ui/assets/sprite.css \
+	build-shared/juju-ui/assets/sprite.png
+BUILD_FILES=build-shared/juju-ui/assets/app.js \
+	build-shared/juju-ui/assets/all-yui.js \
+	build-shared/juju-ui/assets/combined-css/all-static.css
 JAVASCRIPT_LIBRARIES=app/assets/javascripts/d3.v2.js \
 	app/assets/javascripts/d3.v2.min.js app/assets/javascripts/yui
 DATE=$(shell date -u)
-APPCACHE=build/juju-ui/assets/manifest.appcache
+APPCACHE=build-shared/juju-ui/assets/manifest.appcache
 
 # Some environments, notably sudo, do not populate the default PWD environment
 # variable, which is used to set $(PWD).  Worse, in some situations, such as
@@ -132,7 +133,7 @@
 
 help:
 	@echo "Main targets:"
-	@echo "[no target]: build the debug and production environments"
+	@echo "[no target] or build: build the debug and production environments"
 	@echo "devel: run the development environment (dynamic templates/CSS)"
 	@echo "debug: run the debugging environment (static templates/CSS)"
 	@echo "prod: run the production environment (aggregated, compressed files)"
@@ -147,8 +148,8 @@
 	@echo "help: this description"
 	@echo "Other, less common targets are available, see Makefile."
 
-build/juju-ui/templates.js: $(TEMPLATE_TARGETS) bin/generateTemplates
-	mkdir -p build/juju-ui/assets
+build-shared/juju-ui/templates.js: $(TEMPLATE_TARGETS) bin/generateTemplates
+	mkdir -p build-shared/juju-ui/assets
 	bin/generateTemplates
 
 yuidoc/index.html: node_modules/yuidocjs $(JSFILES)
@@ -233,10 +234,10 @@
 
 spritegen: $(SPRITE_GENERATED_FILES)
 
-$(BUILD_FILES): $(JSFILES) $(THIRD_PARTY_JS) build/juju-ui/templates.js \
+$(BUILD_FILES): $(JSFILES) $(THIRD_PARTY_JS) build-shared/juju-ui/templates.js \
 		bin/merge-files lib/merge-files.js | $(JAVASCRIPT_LIBRARIES)
 	rm -f $(BUILD_FILES)
-	mkdir -p build/juju-ui/assets/combined-css/
+	mkdir -p build-shared/juju-ui/assets/combined-css/
 	bin/merge-files
 
 build-files: $(BUILD_FILES)
@@ -254,8 +255,7 @@
 	build-$(1)/juju-ui/assets/sprite.css \
 	build-$(1)/juju-ui/assets/sprite.png \
 	build-$(1)/juju-ui/assets/combined-css/rail-x.png \
-	build-$(1)/juju-ui/assets/skins/night/ \
-	build-$(1)/juju-ui/assets/skins/sam/ build-$(1)/juju-ui/assets/all-yui.js
+	build-$(1)/juju-ui/assets/all-yui.js
 
 LINK_DEBUG_FILES=$(call shared-link-files-list,debug) \
 	build-debug/juju-ui/app.js build-debug/juju-ui/models \
@@ -274,15 +274,15 @@
 	ln -sf "$(PWD)/app/modules-$(1).js" build-$(1)/juju-ui/assets/modules.js
 	ln -sf "$(PWD)/app/assets/images" build-$(1)/juju-ui/assets/
 	ln -sf "$(PWD)/app/assets/svgs" build-$(1)/juju-ui/assets/
-	ln -sf "$(PWD)/build/juju-ui/version.js" build-$(1)/juju-ui/
-	ln -sf "$(PWD)/build/juju-ui/assets/app.js" build-$(1)/juju-ui/assets/
-	ln -sf "$(PWD)/build/juju-ui/assets/manifest.appcache" \
+	ln -sf "$(PWD)/build-shared/juju-ui/version.js" build-$(1)/juju-ui/
+	ln -sf "$(PWD)/build-shared/juju-ui/assets/app.js" build-$(1)/juju-ui/assets/
+	ln -sf "$(PWD)/build-shared/juju-ui/assets/manifest.appcache" \
 		build-$(1)/juju-ui/assets/
-	ln -sf "$(PWD)/build/juju-ui/assets/combined-css/all-static.css" \
+	ln -sf "$(PWD)/build-shared/juju-ui/assets/combined-css/all-static.css" \
 		build-$(1)/juju-ui/assets/combined-css/
-	ln -sf "$(PWD)/build/juju-ui/assets/juju-gui.css" build-$(1)/juju-ui/assets/
-	ln -sf "$(PWD)/build/juju-ui/assets/sprite.css" build-$(1)/juju-ui/assets/
-	ln -sf "$(PWD)/build/juju-ui/assets/sprite.png" build-$(1)/juju-ui/assets/
+	ln -sf "$(PWD)/build-shared/juju-ui/assets/juju-gui.css" build-$(1)/juju-ui/assets/
+	ln -sf "$(PWD)/build-shared/juju-ui/assets/sprite.css" build-$(1)/juju-ui/assets/
+	ln -sf "$(PWD)/build-shared/juju-ui/assets/sprite.png" build-$(1)/juju-ui/assets/
 	ln -sf "$(PWD)/node_modules/yui/assets/skins/sam/rail-x.png" \
 		build-$(1)/juju-ui/assets/combined-css/rail-x.png
 	ln -sf "$(PWD)/node_modules/yui/event-simulate/event-simulate.js" \
@@ -311,11 +311,11 @@
 	ln -sf "$(PWD)/app/assets/javascripts/yui/yui/yui-debug.js" \
 		build-debug/juju-ui/assets/all-yui.js
 	ln -sf "$(PWD)/app/assets/javascripts" build-debug/juju-ui/assets/
-	ln -sf "$(PWD)/build/juju-ui/templates.js" build-debug/juju-ui/
+	ln -sf "$(PWD)/build-shared/juju-ui/templates.js" build-debug/juju-ui/
 
 $(LINK_PROD_FILES):
 	$(call link-files,prod)
-	ln -sf "$(PWD)/build/juju-ui/assets/all-yui.js" build-prod/juju-ui/assets/
+	ln -sf "$(PWD)/build-shared/juju-ui/assets/all-yui.js" build-prod/juju-ui/assets/
 
 prep: beautify lint
 
@@ -355,7 +355,7 @@
 	cd build-prod && python -m SimpleHTTPServer 8888
 
 clean:
-	rm -rf build build-debug build-prod
+	rm -rf build-shared build-debug build-prod
 	find app/assets/javascripts/ -type l | xargs rm -rf
 
 clean-deps:
@@ -367,17 +367,20 @@
 
 clean-all: clean clean-deps clean-docs
 
-build: build-prod build-debug
-
-build-devel: $(APPCACHE) $(NODE_TARGETS) spritegen \
-	  $(BUILD_FILES) build/juju-ui/version.js
-
-build-debug: build-devel | $(LINK_DEBUG_FILES)
-
-build-prod: build-devel | $(LINK_PROD_FILES)
+build: build-prod build-debug build-devel
+
+build-shared: $(APPCACHE) $(NODE_TARGETS) spritegen \
+	  $(BUILD_FILES) build-shared/juju-ui/version.js
+
+# build-devel is phony. build-shared, build-debug, and build-common are real.
+build-devel: build-shared
+
+build-debug: build-shared | $(LINK_DEBUG_FILES)
+
+build-prod: build-shared | $(LINK_PROD_FILES)
 
 $(APPCACHE): manifest.appcache.in
-	mkdir -p build/juju-ui/assets
+	mkdir -p build-shared/juju-ui/assets
 	cp manifest.appcache.in $(APPCACHE)
 	sed -re 's/^\# TIMESTAMP .+$$/\# TIMESTAMP $(DATE)/' -i $(APPCACHE)
 
@@ -386,10 +389,10 @@
 # one by connecting it to our pertinent versioned files.  The appcache target
 # creates the third, and directories are a bit tricky with Makefiles so we are
 # OK with that.
-build/juju-ui/version.js: $(APPCACHE) CHANGES.yaml $(JSFILES) $(TEMPLATE_TARGETS) \
+build-shared/juju-ui/version.js: $(APPCACHE) CHANGES.yaml $(JSFILES) $(TEMPLATE_TARGETS) \
 		$(SPRITE_SOURCE_FILES)
 	echo "var jujuGuiVersionInfo=['$(RELEASE_VERSION)', '$(BZR_REVNO)'];" \
-	    > build/juju-ui/version.js
+	    > build-shared/juju-ui/version.js
 
 upload_release.py:
 	bzr cat lp:launchpadlib/contrib/upload_release_tarball.py \
@@ -452,8 +455,8 @@
 appcache-force: appcache-touch $(APPCACHE)
 
 # targets are alphabetically sorted, they like it that way :-)
-.PHONY: appcache-force appcache-touch beautify build \
-	build-debug build-files build-prod clean clean clean-all \
+.PHONY: appcache-force appcache-touch beautify \
+	build-files build-devel clean clean-all \
 	clean-deps clean-docs debug devel docs dist gjslint help \
 	jshint lint prep prod server spritegen test test-debug test-prod \
 	undocumented yuidoc yuidoc-lint

=== added file 'app/assets/javascripts/gallery-ellipsis-debug.js'
--- app/assets/javascripts/gallery-ellipsis-debug.js	1970-01-01 00:00:00 +0000
+++ app/assets/javascripts/gallery-ellipsis-debug.js	2012-12-21 02:37:21 +0000
@@ -0,0 +1,257 @@
+YUI.add('gallery-ellipsis', function(Y) {
+
+/**
+* Ellipsis plugin (YUI) - For when text is too l ...
+*
+* @fileOverview  A slightly smarter way of truncating text
+* @author        Dan Beam <dan@xxxxxxxxxxx>
+* @param         {object} conf - configuration objects to override the defaults
+* @return        {Node} the Node passed to the method
+*
+* Copyright (c) 2010 Dan Beam
+* Licensed under the MIT License: http://www.opensource.org/licenses/mit-license.php
+*
+* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+* THE SOFTWARE.
+*/
+
+    var // the allowable difference when comparing floating point numbers
+        fp_epsilon  = 0.01,
+
+        // floating point comparison
+        fp_equals   = function (a, b) { return Math.abs(a - b) <= fp_epsilon; },
+        fp_greater  = function (a, b) { return a - b >= fp_epsilon; },
+        fp_lesser   = function (a, b) { return a - b <= fp_epsilon; },
+
+        // do a quick feature test to see if native text-overflow: ellipsis is supported
+        nativeRule  = false,
+
+        // remember some native styles if we have support
+        nativeStyles = {
+            'white-space'   : 'nowrap',
+            'overflow'      : 'hidden'
+        },
+
+        // determine whether we want to use currentStyle instead of some buggy .getComputedStyle() results
+        currentStyle = false;
+
+    // add this on all Y.Node instances (but only if imported
+    Y.DOM.ellipsis = function (node, conf) {
+
+        // homogenize conf to object
+        conf = conf || {};
+
+        // augment our conf object with some default settings
+        Y.mix(conf, {
+            // end marker
+            'ellipsis' : '\u2026',
+            
+            // for stuff we *really* don't want to wrap, increase this number just in case
+            'fudge'    : 3,
+
+            // target number of lines to wrap
+            'lines'    : 1,
+
+            // whether or not to remember the original text to able to de-truncate
+            'remember' : true,
+
+            // should we use native browser support when it exists? (on by default)
+            'native'   : true
+        });
+
+        // console.log(conf);
+        // console.log(Y.one(node).getComputedStyle('lineHeight'));
+        // console.log(Y.one(node).getComputedStyle('fontSize'));
+
+            // the element we're trying to truncate
+        var yEl           = Y.one(node),
+
+            // the name of the field we use to store using .setData()
+            dataAttrName  = 'ellipsis-original-text',
+
+            // original text
+            originalText  = conf.remember && yEl.getData(dataAttrName) || yEl.get('text'),
+            
+            // keep the current length of the text so far
+            currentLength = originalText.length,
+            
+            // the number of characters to increment or decrement the text by
+            charIncrement = currentLength,
+      
+            // copy the element so we can string length invisibly
+            clone         = Y.one(document.createElement(yEl.get('nodeName'))),
+
+            // some current values used to cache .getComputedStyle() accesses and compare to our goals
+            lineHeight, targetHeight, currentHeight, lastKnownGood;
+
+
+        // @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
+        // @ NOTE: I'm intentionally ignoring padding as .getComputedStyle('height') @
+        // @ NOTE: and .getComputedStyle('width') both ignore this as well.          @
+        // @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
+
+        // copy styles to clone object
+        clone.setStyles({
+            'overflow'      : 'hidden',   // only at first
+            'position'      : 'absolute',
+            'visibility'    : 'hidden',
+            'display'       : 'block',
+            'bottom'        : '-10px',
+            'left'          : '-10px',
+            'width'         : currentStyle ? node.offsetWidth           : yEl.getComputedStyle('width'),
+            'fontSize'      : currentStyle ? node.currentStyle.fontSize : yEl.getComputedStyle('fontSize'), /* weird IE7 + reset bug */
+            'fontFamily'    : yEl.getComputedStyle('fontFamily'),
+            'fontWeight'    : yEl.getComputedStyle('fontWeight'),
+            'letterSpacing' : yEl.getComputedStyle('letterSpacing'),
+            'lineHeight'    : yEl.getComputedStyle('lineHeight')
+        });
+
+        // insert some text to get the line-height (because .getComputedStyle('lineHeight') can be "normal" sometimes!)
+        clone.set('text', 'some sample text');
+
+        // unfortunately, we must insert into the DOM, :(
+        Y.one('body').append(clone);
+
+        // get the height of the node with only 1 character of text (should be 1 line)
+        lineHeight    = parseFloat(clone.getComputedStyle('height'));
+
+        // if we have the native support for text-overflow and we only want 1 line with the same style ellipsis
+        if (Y.DOM.ellipsis.nativeSupport && conf['native'] && 1 == conf.lines && '\u2026' === conf.ellipsis) {
+            // console.log('using native!');
+            // apply the styles
+            yEl.setStyles(nativeStyles);
+            // this is needed to trigger the overflow in some browser (*cough* Opera *cough*)
+            yEl.setStyle('height', lineHeight + 'px');
+            // exit early and clean-up
+            clone.remove();
+            return;
+        }
+
+        // set overflow back to visible
+        clone.setStyle('overflow', 'visible');
+
+        // compute how high the node should be if it's the right number of lines
+        targetHeight  = conf.lines * lineHeight;
+
+        // insert the original text, in case we've already truncated
+        clone.set('text', originalText);
+
+        // ok, now that we have a node in the DOM with the right text, measure it's height
+        currentHeight = parseFloat(clone.getComputedStyle('height'));
+
+        // console.log('lineHeight', lineHeight);
+        // console.log('currentHeight', currentHeight);
+        // console.log('targetHeight', targetHeight);
+        // console.log('originalText.length', originalText.length);
+        // console.log('yEl.get(\'text\').length', yEl.get('text').length);
+
+        // quick sanity check
+        if (fp_lesser(currentHeight, targetHeight) && originalText.length === yEl.get('text').length) {
+            // console.log('truncation not necessary!');
+            clone.remove();
+            return;
+        }
+
+        // now, let's start looping through and slicing the text as necessary
+        for (; charIncrement >= 1; ) {
+
+            // increment decays by half every time 
+            charIncrement = Math.floor(charIncrement / 2);
+            
+            // if the height is too big, remove some chars, else add some
+            currentLength += fp_greater(currentHeight, targetHeight) ? -charIncrement : +charIncrement;
+            
+            // try text at current length
+            clone.set('text', originalText.slice(0, currentLength - conf.ellipsis.length) + conf.ellipsis);
+            
+            // compute the current height
+            currentHeight = parseFloat(clone.getComputedStyle('height'));
+
+            // we only want to store values that aren't too big
+            if (fp_equals(currentHeight, targetHeight) || fp_lesser(currentHeight, targetHeight)) {
+                lastKnownGood = currentLength;
+            }
+
+            // console.log('currentLength', currentLength);
+            // console.log('currentHeight', currentHeight);
+            // console.log('targetHeight' , targetHeight );
+            // console.log('charIncrement', charIncrement);
+            // console.log('lastKnownGood', lastKnownGood);
+
+        }
+
+        // remove from DOM
+        clone.remove();
+        
+        // set the original text if we want to ever want to expand past the current truncation
+        if (conf.remember && !yEl.getData(dataAttrName)) {
+            yEl.setData(dataAttrName, originalText);
+        }
+
+        // console.log('originalText.length', originalText.length);
+        // console.log('clone.get(\'text\').length', clone.get('text').length);
+        // console.log('conf.ellipsis.length', conf.ellipsis.length);
+
+        // if the text matches
+        if (originalText.length === (clone.get('text').length - conf.ellipsis.length)) {
+            // this means we *de-truncated* and can fit fully in the new space
+            // console.log('de-truncated!');
+            yEl.set('text', originalText);
+        }
+        // this should never happen, but it doesn't hurt to check
+        else if ('undefined' !== typeof lastKnownGood) {
+            // do this thing, already!
+            yEl.set('text', originalText.slice(0, lastKnownGood - conf.ellipsis.length - conf.fudge) + conf.ellipsis);
+        }
+
+        // return myself for chainability
+        return yEl;
+
+    };
+
+    Y.Node.importMethod(Y.DOM, 'ellipsis');
+    Y.NodeList.importMethod(Y.Node.prototype, 'ellipsis');
+
+    // must wait to append hidden node
+    Y.on('domready', function () {
+
+        // create a hidden node and try to style it
+        var cloned,
+            hidden  = Y.Node.create('<div style="visibility:hidden;position:absolute;white-space:nowrap;overflow:hidden;"></div>'),
+            rules   = ['textOverflow', 'OTextOverflow'];
+
+        // pseudo feature detection to detect browsers with currentStyle but without a more standards-ish implementation (currently IE6-8)
+        currentStyle = !!(document.body.currentStyle && (window.CSSCurrentStyleDeclaration || !window.CSSStyleDeclaration));
+
+        Y.each(rules, function (rule) {
+            hidden.setStyle(rule, 'ellipsis');
+        });
+
+        // add to DOM
+        Y.one('body').appendChild(hidden);
+
+        // deep clone the node (include attributes)
+        cloned = hidden.cloneNode(true);
+
+        Y.some(rules, function (rule) {
+            if ('ellipsis' === cloned.getStyle(rule)) {
+                nativeRule = rule;
+                nativeStyles[nativeRule] = 'ellipsis';
+                Y.DOM.ellipsis.nativeSupport = true;
+                return true;
+            }
+        });
+
+        // clean-up
+        hidden.remove();
+        hidden = cloned = null;
+
+    });
+
+
+}, 'gallery-2011.04.13-22-38' ,{requires:['base','node']});

=== added file 'app/assets/javascripts/gallery-ellipsis.js'
--- app/assets/javascripts/gallery-ellipsis.js	1970-01-01 00:00:00 +0000
+++ app/assets/javascripts/gallery-ellipsis.js	2012-12-21 02:37:21 +0000
@@ -0,0 +1,257 @@
+YUI.add('gallery-ellipsis', function(Y) {
+
+/**
+* Ellipsis plugin (YUI) - For when text is too l ...
+*
+* @fileOverview  A slightly smarter way of truncating text
+* @author        Dan Beam <dan@xxxxxxxxxxx>
+* @param         {object} conf - configuration objects to override the defaults
+* @return        {Node} the Node passed to the method
+*
+* Copyright (c) 2010 Dan Beam
+* Licensed under the MIT License: http://www.opensource.org/licenses/mit-license.php
+*
+* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+* THE SOFTWARE.
+*/
+
+    var // the allowable difference when comparing floating point numbers
+        fp_epsilon  = 0.01,
+
+        // floating point comparison
+        fp_equals   = function (a, b) { return Math.abs(a - b) <= fp_epsilon; },
+        fp_greater  = function (a, b) { return a - b >= fp_epsilon; },
+        fp_lesser   = function (a, b) { return a - b <= fp_epsilon; },
+
+        // do a quick feature test to see if native text-overflow: ellipsis is supported
+        nativeRule  = false,
+
+        // remember some native styles if we have support
+        nativeStyles = {
+            'white-space'   : 'nowrap',
+            'overflow'      : 'hidden'
+        },
+
+        // determine whether we want to use currentStyle instead of some buggy .getComputedStyle() results
+        currentStyle = false;
+
+    // add this on all Y.Node instances (but only if imported
+    Y.DOM.ellipsis = function (node, conf) {
+
+        // homogenize conf to object
+        conf = conf || {};
+
+        // augment our conf object with some default settings
+        Y.mix(conf, {
+            // end marker
+            'ellipsis' : '\u2026',
+            
+            // for stuff we *really* don't want to wrap, increase this number just in case
+            'fudge'    : 3,
+
+            // target number of lines to wrap
+            'lines'    : 1,
+
+            // whether or not to remember the original text to able to de-truncate
+            'remember' : true,
+
+            // should we use native browser support when it exists? (on by default)
+            'native'   : true
+        });
+
+        // console.log(conf);
+        // console.log(Y.one(node).getComputedStyle('lineHeight'));
+        // console.log(Y.one(node).getComputedStyle('fontSize'));
+
+            // the element we're trying to truncate
+        var yEl           = Y.one(node),
+
+            // the name of the field we use to store using .setData()
+            dataAttrName  = 'ellipsis-original-text',
+
+            // original text
+            originalText  = conf.remember && yEl.getData(dataAttrName) || yEl.get('text'),
+            
+            // keep the current length of the text so far
+            currentLength = originalText.length,
+            
+            // the number of characters to increment or decrement the text by
+            charIncrement = currentLength,
+      
+            // copy the element so we can string length invisibly
+            clone         = Y.one(document.createElement(yEl.get('nodeName'))),
+
+            // some current values used to cache .getComputedStyle() accesses and compare to our goals
+            lineHeight, targetHeight, currentHeight, lastKnownGood;
+
+
+        // @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
+        // @ NOTE: I'm intentionally ignoring padding as .getComputedStyle('height') @
+        // @ NOTE: and .getComputedStyle('width') both ignore this as well.          @
+        // @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
+
+        // copy styles to clone object
+        clone.setStyles({
+            'overflow'      : 'hidden',   // only at first
+            'position'      : 'absolute',
+            'visibility'    : 'hidden',
+            'display'       : 'block',
+            'bottom'        : '-10px',
+            'left'          : '-10px',
+            'width'         : currentStyle ? node.offsetWidth           : yEl.getComputedStyle('width'),
+            'fontSize'      : currentStyle ? node.currentStyle.fontSize : yEl.getComputedStyle('fontSize'), /* weird IE7 + reset bug */
+            'fontFamily'    : yEl.getComputedStyle('fontFamily'),
+            'fontWeight'    : yEl.getComputedStyle('fontWeight'),
+            'letterSpacing' : yEl.getComputedStyle('letterSpacing'),
+            'lineHeight'    : yEl.getComputedStyle('lineHeight')
+        });
+
+        // insert some text to get the line-height (because .getComputedStyle('lineHeight') can be "normal" sometimes!)
+        clone.set('text', 'some sample text');
+
+        // unfortunately, we must insert into the DOM, :(
+        Y.one('body').append(clone);
+
+        // get the height of the node with only 1 character of text (should be 1 line)
+        lineHeight    = parseFloat(clone.getComputedStyle('height'));
+
+        // if we have the native support for text-overflow and we only want 1 line with the same style ellipsis
+        if (Y.DOM.ellipsis.nativeSupport && conf['native'] && 1 == conf.lines && '\u2026' === conf.ellipsis) {
+            // console.log('using native!');
+            // apply the styles
+            yEl.setStyles(nativeStyles);
+            // this is needed to trigger the overflow in some browser (*cough* Opera *cough*)
+            yEl.setStyle('height', lineHeight + 'px');
+            // exit early and clean-up
+            clone.remove();
+            return;
+        }
+
+        // set overflow back to visible
+        clone.setStyle('overflow', 'visible');
+
+        // compute how high the node should be if it's the right number of lines
+        targetHeight  = conf.lines * lineHeight;
+
+        // insert the original text, in case we've already truncated
+        clone.set('text', originalText);
+
+        // ok, now that we have a node in the DOM with the right text, measure it's height
+        currentHeight = parseFloat(clone.getComputedStyle('height'));
+
+        // console.log('lineHeight', lineHeight);
+        // console.log('currentHeight', currentHeight);
+        // console.log('targetHeight', targetHeight);
+        // console.log('originalText.length', originalText.length);
+        // console.log('yEl.get(\'text\').length', yEl.get('text').length);
+
+        // quick sanity check
+        if (fp_lesser(currentHeight, targetHeight) && originalText.length === yEl.get('text').length) {
+            // console.log('truncation not necessary!');
+            clone.remove();
+            return;
+        }
+
+        // now, let's start looping through and slicing the text as necessary
+        for (; charIncrement >= 1; ) {
+
+            // increment decays by half every time 
+            charIncrement = Math.floor(charIncrement / 2);
+            
+            // if the height is too big, remove some chars, else add some
+            currentLength += fp_greater(currentHeight, targetHeight) ? -charIncrement : +charIncrement;
+            
+            // try text at current length
+            clone.set('text', originalText.slice(0, currentLength - conf.ellipsis.length) + conf.ellipsis);
+            
+            // compute the current height
+            currentHeight = parseFloat(clone.getComputedStyle('height'));
+
+            // we only want to store values that aren't too big
+            if (fp_equals(currentHeight, targetHeight) || fp_lesser(currentHeight, targetHeight)) {
+                lastKnownGood = currentLength;
+            }
+
+            // console.log('currentLength', currentLength);
+            // console.log('currentHeight', currentHeight);
+            // console.log('targetHeight' , targetHeight );
+            // console.log('charIncrement', charIncrement);
+            // console.log('lastKnownGood', lastKnownGood);
+
+        }
+
+        // remove from DOM
+        clone.remove();
+        
+        // set the original text if we want to ever want to expand past the current truncation
+        if (conf.remember && !yEl.getData(dataAttrName)) {
+            yEl.setData(dataAttrName, originalText);
+        }
+
+        // console.log('originalText.length', originalText.length);
+        // console.log('clone.get(\'text\').length', clone.get('text').length);
+        // console.log('conf.ellipsis.length', conf.ellipsis.length);
+
+        // if the text matches
+        if (originalText.length === (clone.get('text').length - conf.ellipsis.length)) {
+            // this means we *de-truncated* and can fit fully in the new space
+            // console.log('de-truncated!');
+            yEl.set('text', originalText);
+        }
+        // this should never happen, but it doesn't hurt to check
+        else if ('undefined' !== typeof lastKnownGood) {
+            // do this thing, already!
+            yEl.set('text', originalText.slice(0, lastKnownGood - conf.ellipsis.length - conf.fudge) + conf.ellipsis);
+        }
+
+        // return myself for chainability
+        return yEl;
+
+    };
+
+    Y.Node.importMethod(Y.DOM, 'ellipsis');
+    Y.NodeList.importMethod(Y.Node.prototype, 'ellipsis');
+
+    // must wait to append hidden node
+    Y.on('domready', function () {
+
+        // create a hidden node and try to style it
+        var cloned,
+            hidden  = Y.Node.create('<div style="visibility:hidden;position:absolute;white-space:nowrap;overflow:hidden;"></div>'),
+            rules   = ['textOverflow', 'OTextOverflow'];
+
+        // pseudo feature detection to detect browsers with currentStyle but without a more standards-ish implementation (currently IE6-8)
+        currentStyle = !!(document.body.currentStyle && (window.CSSCurrentStyleDeclaration || !window.CSSStyleDeclaration));
+
+        Y.each(rules, function (rule) {
+            hidden.setStyle(rule, 'ellipsis');
+        });
+
+        // add to DOM
+        Y.one('body').appendChild(hidden);
+
+        // deep clone the node (include attributes)
+        cloned = hidden.cloneNode(true);
+
+        Y.some(rules, function (rule) {
+            if ('ellipsis' === cloned.getStyle(rule)) {
+                nativeRule = rule;
+                nativeStyles[nativeRule] = 'ellipsis';
+                Y.DOM.ellipsis.nativeSupport = true;
+                return true;
+            }
+        });
+
+        // clean-up
+        hidden.remove();
+        hidden = cloned = null;
+
+    });
+
+
+}, 'gallery-2011.04.13-22-38' ,{requires:['base','node']});

=== added file 'app/assets/javascripts/gallery-markdown-debug.js'
--- app/assets/javascripts/gallery-markdown-debug.js	1970-01-01 00:00:00 +0000
+++ app/assets/javascripts/gallery-markdown-debug.js	2012-12-21 02:37:21 +0000
@@ -0,0 +1,1643 @@
+YUI.add('gallery-markdown', function(Y) {
+
+// Released under MIT license
+// Copyright (c) 2009-2010 Dominic Baggott
+// Copyright (c) 2009-2010 Ash Berlin
+// Copyright (c) 2011 Christoph Dorn <christoph@xxxxxxxxxxxxxxxxx> (http://www.christophdorn.com)
+
+/**
+A wrapper around the fantastic Markdown library at https://github.com/evilstreak/markdown-js
+
+@module gallery-markdown
+
+**/
+
+/**
+A YUI wrapper around the fantastic Markdown library at https://github.com/evilstreak/markdown-js.
+This supports several dialects and works very well. I (jshirley) am not the author, merely the one maintaining the YUI wrapper.
+
+Below is the original documentation:
+
+Markdown processing in Javascript done right. We have very particular views
+on what constitutes 'right' which include:
+
+ - produces well-formed HTML (this means that em and strong nesting is
+   important)
+
+ - has an intermediate representation to allow processing of parsed data (We
+   in fact have two, both as [JsonML]: a markdown tree and an HTML tree).
+
+ - is easily extensible to add new dialects without having to rewrite the
+   entire parsing mechanics
+
+ - has a good test suite
+
+This implementation fulfills all of these (except that the test suite could
+do with expanding to automatically run all the fixtures from other Markdown
+implementations.)
+
+##### Intermediate Representation
+
+*TODO* Talk about this :) Its JsonML, but document the node names we use.
+
+[JsonML]: http://jsonml.org/ "JSON Markup Language"
+
+**/
+
+(function( expose ) {
+
+var Markdown = expose.Markdown = function Markdown(dialect) {
+  switch (typeof dialect) {
+    case "undefined":
+      this.dialect = Markdown.dialects.Gruber;
+      break;
+    case "object":
+      this.dialect = dialect;
+      break;
+    default:
+      if (dialect in Markdown.dialects) {
+        this.dialect = Markdown.dialects[dialect];
+      }
+      else {
+        throw new Error("Unknown Markdown dialect '" + String(dialect) + "'");
+      }
+      break;
+  }
+  this.em_state = [];
+  this.strong_state = [];
+  this.debug_indent = "";
+};
+
+/**
+Parse `markdown` and return a markdown document as a Markdown.JsonML tree.
+
+@method parse
+@param markdown {String} The Markdown string to parse
+@param dialect {String} The Markdown dialect to use, defaults to gruber
+@static
+
+**/
+expose.parse = function( source, dialect ) {
+  // dialect will default if undefined
+  var md = new Markdown( dialect );
+  return md.toTree( source );
+};
+
+/**
+Take markdown (either as a string or as a JsonML tree) and run it through
+[[toHTMLTree]] then turn it into a well-formated HTML fragment.
+
+@method toHTML
+@static
+@param source {String} markdown string to parse
+@param dialect {String} dialect to use
+
+**/
+expose.toHTML = function toHTML( source , dialect , options ) {
+  var input = expose.toHTMLTree( source , dialect , options );
+
+  return expose.renderJsonML( input );
+};
+
+/**
+Turn markdown into HTML, represented as a JsonML tree. If a string is given
+to this function, it is first parsed into a markdown tree by calling
+[[parse]].
+
+@method toHTMLTree
+@static
+@param markdown {String | Object } markdown string to parse or already parsed tree
+@param dialect {String} the dialect to use, defaults to gruber
+**/
+expose.toHTMLTree = function toHTMLTree( input, dialect , options ) {
+  // convert string input to an MD tree
+  if ( typeof input ==="string" ) input = this.parse( input, dialect );
+
+  // Now convert the MD tree to an HTML tree
+
+  // remove references from the tree
+  var attrs = extract_attr( input ),
+      refs = {};
+
+  if ( attrs && attrs.references ) {
+    refs = attrs.references;
+  }
+
+  var html = convert_tree_to_html( input, refs , options );
+  merge_text_nodes( html );
+  return html;
+};
+
+// For Spidermonkey based engines
+function mk_block_toSource() {
+  return "Markdown.mk_block( " +
+          uneval(this.toString()) +
+          ", " +
+          uneval(this.trailing) +
+          ", " +
+          uneval(this.lineNumber) +
+          " )";
+}
+
+// node
+function mk_block_inspect() {
+  var util = require('util');
+  return "Markdown.mk_block( " +
+          util.inspect(this.toString()) +
+          ", " +
+          util.inspect(this.trailing) +
+          ", " +
+          util.inspect(this.lineNumber) +
+          " )";
+
+}
+
+var mk_block = Markdown.mk_block = function(block, trail, line) {
+  // Be helpful for default case in tests.
+  if ( arguments.length == 1 ) trail = "\n\n";
+
+  var s = new String(block);
+  s.trailing = trail;
+  // To make it clear its not just a string
+  s.inspect = mk_block_inspect;
+  s.toSource = mk_block_toSource;
+
+  if (line != undefined)
+    s.lineNumber = line;
+
+  return s;
+};
+
+function count_lines( str ) {
+  var n = 0, i = -1;
+  while ( ( i = str.indexOf('\n', i+1) ) !== -1) n++;
+  return n;
+}
+
+// Internal - split source into rough blocks
+Markdown.prototype.split_blocks = function splitBlocks( input, startLine ) {
+  // [\s\S] matches _anything_ (newline or space)
+  var re = /([\s\S]+?)($|\n(?:\s*\n|$)+)/g,
+      blocks = [],
+      m;
+
+  var line_no = 1;
+
+  if ( ( m = /^(\s*\n)/.exec(input) ) != null ) {
+    // skip (but count) leading blank lines
+    line_no += count_lines( m[0] );
+    re.lastIndex = m[0].length;
+  }
+
+  while ( ( m = re.exec(input) ) !== null ) {
+    blocks.push( mk_block( m[1], m[2], line_no ) );
+    line_no += count_lines( m[0] );
+  }
+
+  return blocks;
+};
+
+/**
+Process `block` and return an array of JsonML nodes representing `block`.
+
+It does this by asking each block level function in the dialect to process
+the block until one can. Succesful handling is indicated by returning an
+array (with zero or more JsonML nodes), failure by a false value.
+
+Blocks handlers are responsible for calling [[Markdown#processInline]]
+themselves as appropriate.
+
+If the blocks were split incorrectly or adjacent blocks need collapsing you
+can adjust `next` in place using shift/splice etc.
+
+If any of this default behaviour is not right for the dialect, you can
+define a `__call__` method on the dialect that will get invoked to handle
+the block processing.
+
+@method processBlock
+@protected
+@static
+@param block {String} the block to process
+@param next {Array} following blocks
+**/
+Markdown.prototype.processBlock = function processBlock( block, next ) {
+  var cbs = this.dialect.block,
+      ord = cbs.__order__;
+
+  if ( "__call__" in cbs ) {
+    return cbs.__call__.call(this, block, next);
+  }
+
+  for ( var i = 0; i < ord.length; i++ ) {
+    //D:this.debug( "Testing", ord[i] );
+    var res = cbs[ ord[i] ].call( this, block, next );
+    if ( res ) {
+      //D:this.debug("  matched");
+      if ( !isArray(res) || ( res.length > 0 && !( isArray(res[0]) ) ) )
+        this.debug(ord[i], "didn't return a proper array");
+      //D:this.debug( "" );
+      return res;
+    }
+  }
+
+  // Uhoh! no match! Should we throw an error?
+  return [];
+};
+
+Markdown.prototype.processInline = function processInline( block ) {
+  return this.dialect.inline.__call__.call( this, String( block ) );
+};
+
+/**
+Parse `source` into a JsonML tree representing the markdown document.
+
+@method toTree
+@protected
+@static
+@param source {String} markdown source to parse.
+@param custom_root {Object} A previous tree to append to
+**/
+// custom_tree means set this.tree to `custom_tree` and restore old value on return
+Markdown.prototype.toTree = function toTree( source, custom_root ) {
+  var blocks = source instanceof Array ? source : this.split_blocks( source );
+
+  // Make tree a member variable so its easier to mess with in extensions
+  var old_tree = this.tree;
+  try {
+    this.tree = custom_root || this.tree || [ "markdown" ];
+
+    blocks:
+    while ( blocks.length ) {
+      var b = this.processBlock( blocks.shift(), blocks );
+
+      // Reference blocks and the like won't return any content
+      if ( !b.length ) continue blocks;
+
+      this.tree.push.apply( this.tree, b );
+    }
+    return this.tree;
+  }
+  finally {
+    if ( custom_root ) {
+      this.tree = old_tree;
+    }
+  }
+};
+
+// Noop by default
+Markdown.prototype.debug = function () {
+  var args = Array.prototype.slice.call( arguments);
+  args.unshift(this.debug_indent);
+  if (typeof print !== "undefined")
+      print.apply( print, args );
+  if (typeof console !== "undefined" && typeof console.log !== "undefined")
+      console.log.apply( null, args );
+}
+
+Markdown.prototype.loop_re_over_block = function( re, block, cb ) {
+  // Dont use /g regexps with this
+  var m,
+      b = block.valueOf();
+
+  while ( b.length && (m = re.exec(b) ) != null) {
+    b = b.substr( m[0].length );
+    cb.call(this, m);
+  }
+  return b;
+};
+
+/**
+ * Markdown.dialects
+ *
+ * Namespace of built-in dialects.
+ **/
+Markdown.dialects = {};
+
+/**
+ * Markdown.dialects.Gruber
+ *
+ * The default dialect that follows the rules set out by John Gruber's
+ * markdown.pl as closely as possible. Well actually we follow the behaviour of
+ * that script which in some places is not exactly what the syntax web page
+ * says.
+ **/
+Markdown.dialects.Gruber = {
+  block: {
+    atxHeader: function atxHeader( block, next ) {
+      var m = block.match( /^(#{1,6})\s*(.*?)\s*#*\s*(?:\n|$)/ );
+
+      if ( !m ) return undefined;
+
+      var header = [ "header", { level: m[ 1 ].length } ];
+      Array.prototype.push.apply(header, this.processInline(m[ 2 ]));
+
+      if ( m[0].length < block.length )
+        next.unshift( mk_block( block.substr( m[0].length ), block.trailing, block.lineNumber + 2 ) );
+
+      return [ header ];
+    },
+
+    setextHeader: function setextHeader( block, next ) {
+      var m = block.match( /^(.*)\n([-=])\2\2+(?:\n|$)/ );
+
+      if ( !m ) return undefined;
+
+      var level = ( m[ 2 ] === "=" ) ? 1 : 2;
+      var header = [ "header", { level : level }, m[ 1 ] ];
+
+      if ( m[0].length < block.length )
+        next.unshift( mk_block( block.substr( m[0].length ), block.trailing, block.lineNumber + 2 ) );
+
+      return [ header ];
+    },
+
+    code: function code( block, next ) {
+      // |    Foo
+      // |bar
+      // should be a code block followed by a paragraph. Fun
+      //
+      // There might also be adjacent code block to merge.
+
+      var ret = [],
+          re = /^(?: {0,3}\t| {4})(.*)\n?/,
+          lines;
+
+      // 4 spaces + content
+      if ( !block.match( re ) ) return undefined;
+
+      block_search:
+      do {
+        // Now pull out the rest of the lines
+        var b = this.loop_re_over_block(
+                  re, block.valueOf(), function( m ) { ret.push( m[1] ); } );
+
+        if (b.length) {
+          // Case alluded to in first comment. push it back on as a new block
+          next.unshift( mk_block(b, block.trailing) );
+          break block_search;
+        }
+        else if (next.length) {
+          // Check the next block - it might be code too
+          if ( !next[0].match( re ) ) break block_search;
+
+          // Pull how how many blanks lines follow - minus two to account for .join
+          ret.push ( block.trailing.replace(/[^\n]/g, '').substring(2) );
+
+          block = next.shift();
+        }
+        else {
+          break block_search;
+        }
+      } while (true);
+
+      return [ [ "code_block", ret.join("\n") ] ];
+    },
+
+    horizRule: function horizRule( block, next ) {
+      // this needs to find any hr in the block to handle abutting blocks
+      var m = block.match( /^(?:([\s\S]*?)\n)?[ \t]*([-_*])(?:[ \t]*\2){2,}[ \t]*(?:\n([\s\S]*))?$/ );
+
+      if ( !m ) {
+        return undefined;
+      }
+
+      var jsonml = [ [ "hr" ] ];
+
+      // if there's a leading abutting block, process it
+      if ( m[ 1 ] ) {
+        jsonml.unshift.apply( jsonml, this.processBlock( m[ 1 ], [] ) );
+      }
+
+      // if there's a trailing abutting block, stick it into next
+      if ( m[ 3 ] ) {
+        next.unshift( mk_block( m[ 3 ] ) );
+      }
+
+      return jsonml;
+    },
+
+    // There are two types of lists. Tight and loose. Tight lists have no whitespace
+    // between the items (and result in text just in the <li>) and loose lists,
+    // which have an empty line between list items, resulting in (one or more)
+    // paragraphs inside the <li>.
+    //
+    // There are all sorts weird edge cases about the original markdown.pl's
+    // handling of lists:
+    //
+    // * Nested lists are supposed to be indented by four chars per level. But
+    //   if they aren't, you can get a nested list by indenting by less than
+    //   four so long as the indent doesn't match an indent of an existing list
+    //   item in the 'nest stack'.
+    //
+    // * The type of the list (bullet or number) is controlled just by the
+    //    first item at the indent. Subsequent changes are ignored unless they
+    //    are for nested lists
+    //
+    lists: (function( ) {
+      // Use a closure to hide a few variables.
+      var any_list = "[*+-]|\\d+\\.",
+          bullet_list = /[*+-]/,
+          number_list = /\d+\./,
+          // Capture leading indent as it matters for determining nested lists.
+          is_list_re = new RegExp( "^( {0,3})(" + any_list + ")[ \t]+" ),
+          indent_re = "(?: {0,3}\\t| {4})";
+
+      // TODO: Cache this regexp for certain depths.
+      // Create a regexp suitable for matching an li for a given stack depth
+      function regex_for_depth( depth ) {
+
+        return new RegExp(
+          // m[1] = indent, m[2] = list_type
+          "(?:^(" + indent_re + "{0," + depth + "} {0,3})(" + any_list + ")\\s+)|" +
+          // m[3] = cont
+          "(^" + indent_re + "{0," + (depth-1) + "}[ ]{0,4})"
+        );
+      }
+      function expand_tab( input ) {
+        return input.replace( / {0,3}\t/g, "    " );
+      }
+
+      // Add inline content `inline` to `li`. inline comes from processInline
+      // so is an array of content
+      function add(li, loose, inline, nl) {
+        if (loose) {
+          li.push( [ "para" ].concat(inline) );
+          return;
+        }
+        // Hmmm, should this be any block level element or just paras?
+        var add_to = li[li.length -1] instanceof Array && li[li.length - 1][0] == "para"
+                   ? li[li.length -1]
+                   : li;
+
+        // If there is already some content in this list, add the new line in
+        if (nl && li.length > 1) inline.unshift(nl);
+
+        for (var i=0; i < inline.length; i++) {
+          var what = inline[i],
+              is_str = typeof what == "string";
+          if (is_str && add_to.length > 1 && typeof add_to[add_to.length-1] == "string" ) {
+            add_to[ add_to.length-1 ] += what;
+          }
+          else {
+            add_to.push( what );
+          }
+        }
+      }
+
+      // contained means have an indent greater than the current one. On
+      // *every* line in the block
+      function get_contained_blocks( depth, blocks ) {
+
+        var re = new RegExp( "^(" + indent_re + "{" + depth + "}.*?\\n?)*$" ),
+            replace = new RegExp("^" + indent_re + "{" + depth + "}", "gm"),
+            ret = [];
+
+        while ( blocks.length > 0 ) {
+          if ( re.exec( blocks[0] ) ) {
+            var b = blocks.shift(),
+                // Now remove that indent
+                x = b.replace( replace, "");
+
+            ret.push( mk_block( x, b.trailing, b.lineNumber ) );
+          }
+          break;
+        }
+        return ret;
+      }
+
+      // passed to stack.forEach to turn list items up the stack into paras
+      function paragraphify(s, i, stack) {
+        var list = s.list;
+        var last_li = list[list.length-1];
+
+        if (last_li[1] instanceof Array && last_li[1][0] == "para") {
+          return;
+        }
+        if (i+1 == stack.length) {
+          // Last stack frame
+          // Keep the same array, but replace the contents
+          last_li.push( ["para"].concat( last_li.splice(1) ) );
+        }
+        else {
+          var sublist = last_li.pop();
+          last_li.push( ["para"].concat( last_li.splice(1) ), sublist );
+        }
+      }
+
+      // The matcher function
+      return function( block, next ) {
+        var m = block.match( is_list_re );
+        if ( !m ) return undefined;
+
+        function make_list( m ) {
+          var list = bullet_list.exec( m[2] )
+                   ? ["bulletlist"]
+                   : ["numberlist"];
+
+          stack.push( { list: list, indent: m[1] } );
+          return list;
+        }
+
+
+        var stack = [], // Stack of lists for nesting.
+            list = make_list( m ),
+            last_li,
+            loose = false,
+            ret = [ stack[0].list ],
+            i;
+
+        // Loop to search over block looking for inner block elements and loose lists
+        loose_search:
+        while( true ) {
+          // Split into lines preserving new lines at end of line
+          var lines = block.split( /(?=\n)/ );
+
+          // We have to grab all lines for a li and call processInline on them
+          // once as there are some inline things that can span lines.
+          var li_accumulate = "";
+
+          // Loop over the lines in this block looking for tight lists.
+          tight_search:
+          for (var line_no=0; line_no < lines.length; line_no++) {
+            var nl = "",
+                l = lines[line_no].replace(/^\n/, function(n) { nl = n; return ""; });
+
+            // TODO: really should cache this
+            var line_re = regex_for_depth( stack.length );
+
+            m = l.match( line_re );
+            //print( "line:", uneval(l), "\nline match:", uneval(m) );
+
+            // We have a list item
+            if ( m[1] !== undefined ) {
+              // Process the previous list item, if any
+              if ( li_accumulate.length ) {
+                add( last_li, loose, this.processInline( li_accumulate ), nl );
+                // Loose mode will have been dealt with. Reset it
+                loose = false;
+                li_accumulate = "";
+              }
+
+              m[1] = expand_tab( m[1] );
+              var wanted_depth = Math.floor(m[1].length/4)+1;
+              //print( "want:", wanted_depth, "stack:", stack.length);
+              if ( wanted_depth > stack.length ) {
+                // Deep enough for a nested list outright
+                //print ( "new nested list" );
+                list = make_list( m );
+                last_li.push( list );
+                last_li = list[1] = [ "listitem" ];
+              }
+              else {
+                // We aren't deep enough to be strictly a new level. This is
+                // where Md.pl goes nuts. If the indent matches a level in the
+                // stack, put it there, else put it one deeper then the
+                // wanted_depth deserves.
+                var found = false;
+                for (i = 0; i < stack.length; i++) {
+                  if ( stack[ i ].indent != m[1] ) continue;
+                  list = stack[ i ].list;
+                  stack.splice( i+1 );
+                  found = true;
+                  break;
+                }
+
+                if (!found) {
+                  //print("not found. l:", uneval(l));
+                  wanted_depth++;
+                  if (wanted_depth <= stack.length) {
+                    stack.splice(wanted_depth);
+                    //print("Desired depth now", wanted_depth, "stack:", stack.length);
+                    list = stack[wanted_depth-1].list;
+                    //print("list:", uneval(list) );
+                  }
+                  else {
+                    //print ("made new stack for messy indent");
+                    list = make_list(m);
+                    last_li.push(list);
+                  }
+                }
+
+                //print( uneval(list), "last", list === stack[stack.length-1].list );
+                last_li = [ "listitem" ];
+                list.push(last_li);
+              } // end depth of shenegains
+              nl = "";
+            }
+
+            // Add content
+            if (l.length > m[0].length) {
+              li_accumulate += nl + l.substr( m[0].length );
+            }
+          } // tight_search
+
+          if ( li_accumulate.length ) {
+            add( last_li, loose, this.processInline( li_accumulate ), nl );
+            // Loose mode will have been dealt with. Reset it
+            loose = false;
+            li_accumulate = "";
+          }
+
+          // Look at the next block - we might have a loose list. Or an extra
+          // paragraph for the current li
+          var contained = get_contained_blocks( stack.length, next );
+
+          // Deal with code blocks or properly nested lists
+          if (contained.length > 0) {
+            // Make sure all listitems up the stack are paragraphs
+            forEach( stack, paragraphify, this);
+
+            last_li.push.apply( last_li, this.toTree( contained, [] ) );
+          }
+
+          var next_block = next[0] && next[0].valueOf() || "";
+
+          if ( next_block.match(is_list_re) || next_block.match( /^ / ) ) {
+            block = next.shift();
+
+            // Check for an HR following a list: features/lists/hr_abutting
+            var hr = this.dialect.block.horizRule( block, next );
+
+            if (hr) {
+              ret.push.apply(ret, hr);
+              break;
+            }
+
+            // Make sure all listitems up the stack are paragraphs
+            forEach( stack, paragraphify, this);
+
+            loose = true;
+            continue loose_search;
+          }
+          break;
+        } // loose_search
+
+        return ret;
+      };
+    })(),
+
+    blockquote: function blockquote( block, next ) {
+      if ( !block.match( /^>/m ) )
+        return undefined;
+
+      var jsonml = [];
+
+      // separate out the leading abutting block, if any
+      if ( block[ 0 ] != ">" ) {
+        var lines = block.split( /\n/ ),
+            prev = [];
+
+        // keep shifting lines until you find a crotchet
+        while ( lines.length && lines[ 0 ][ 0 ] != ">" ) {
+            prev.push( lines.shift() );
+        }
+
+        // reassemble!
+        block = lines.join( "\n" );
+        jsonml.push.apply( jsonml, this.processBlock( prev.join( "\n" ), [] ) );
+      }
+
+      // if the next block is also a blockquote merge it in
+      while ( next.length && next[ 0 ][ 0 ] == ">" ) {
+        var b = next.shift();
+        block = new String(block + block.trailing + b);
+        block.trailing = b.trailing;
+      }
+
+      // Strip off the leading "> " and re-process as a block.
+      var input = block.replace( /^> ?/gm, '' ),
+          old_tree = this.tree;
+      jsonml.push( this.toTree( input, [ "blockquote" ] ) );
+
+      return jsonml;
+    },
+
+    referenceDefn: function referenceDefn( block, next) {
+      var re = /^\s*\[(.*?)\]:\s*(\S+)(?:\s+(?:(['"])(.*?)\3|\((.*?)\)))?\n?/;
+      // interesting matches are [ , ref_id, url, , title, title ]
+
+      if ( !block.match(re) )
+        return undefined;
+
+      // make an attribute node if it doesn't exist
+      if ( !extract_attr( this.tree ) ) {
+        this.tree.splice( 1, 0, {} );
+      }
+
+      var attrs = extract_attr( this.tree );
+
+      // make a references hash if it doesn't exist
+      if ( attrs.references === undefined ) {
+        attrs.references = {};
+      }
+
+      var b = this.loop_re_over_block(re, block, function( m ) {
+
+        if ( m[2] && m[2][0] == '<' && m[2][m[2].length-1] == '>' )
+          m[2] = m[2].substring( 1, m[2].length - 1 );
+
+        var ref = attrs.references[ m[1].toLowerCase() ] = {
+          href: m[2]
+        };
+
+        if (m[4] !== undefined)
+          ref.title = m[4];
+        else if (m[5] !== undefined)
+          ref.title = m[5];
+
+      } );
+
+      if (b.length)
+        next.unshift( mk_block( b, block.trailing ) );
+
+      return [];
+    },
+
+    para: function para( block, next ) {
+      // everything's a para!
+      return [ ["para"].concat( this.processInline( block ) ) ];
+    }
+  }
+};
+
+Markdown.dialects.Gruber.inline = {
+
+    __oneElement__: function oneElement( text, patterns_or_re, previous_nodes ) {
+      var m,
+          res,
+          lastIndex = 0;
+
+      patterns_or_re = patterns_or_re || this.dialect.inline.__patterns__;
+      var re = new RegExp( "([\\s\\S]*?)(" + (patterns_or_re.source || patterns_or_re) + ")" );
+
+      m = re.exec( text );
+      if (!m) {
+        // Just boring text
+        return [ text.length, text ];
+      }
+      else if ( m[1] ) {
+        // Some un-interesting text matched. Return that first
+        return [ m[1].length, m[1] ];
+      }
+
+      var res;
+      if ( m[2] in this.dialect.inline ) {
+        res = this.dialect.inline[ m[2] ].call(
+                  this,
+                  text.substr( m.index ), m, previous_nodes || [] );
+      }
+      // Default for now to make dev easier. just slurp special and output it.
+      res = res || [ m[2].length, m[2] ];
+      return res;
+    },
+
+    __call__: function inline( text, patterns ) {
+
+      var out = [],
+          res;
+
+      function add(x) {
+        //D:self.debug("  adding output", uneval(x));
+        if (typeof x == "string" && typeof out[out.length-1] == "string")
+          out[ out.length-1 ] += x;
+        else
+          out.push(x);
+      }
+
+      while ( text.length > 0 ) {
+        res = this.dialect.inline.__oneElement__.call(this, text, patterns, out );
+        text = text.substr( res.shift() );
+        forEach(res, add )
+      }
+
+      return out;
+    },
+
+    // These characters are intersting elsewhere, so have rules for them so that
+    // chunks of plain text blocks don't include them
+    "]": function () {},
+    "}": function () {},
+
+    "\\": function escaped( text ) {
+      // [ length of input processed, node/children to add... ]
+      // Only esacape: \ ` * _ { } [ ] ( ) # * + - . !
+      if ( text.match( /^\\[\\`\*_{}\[\]()#\+.!\-]/ ) )
+        return [ 2, text[1] ];
+      else
+        // Not an esacpe
+        return [ 1, "\\" ];
+    },
+
+    "![": function image( text ) {
+
+      // Unlike images, alt text is plain text only. no other elements are
+      // allowed in there
+
+      // ![Alt text](/path/to/img.jpg "Optional title")
+      //      1          2            3       4         <--- captures
+      var m = text.match( /^!\[(.*?)\][ \t]*\([ \t]*(\S*)(?:[ \t]+(["'])(.*?)\3)?[ \t]*\)/ );
+
+      if ( m ) {
+        if ( m[2] && m[2][0] == '<' && m[2][m[2].length-1] == '>' )
+          m[2] = m[2].substring( 1, m[2].length - 1 );
+
+        m[2] = this.dialect.inline.__call__.call( this, m[2], /\\/ )[0];
+
+        var attrs = { alt: m[1], href: m[2] || "" };
+        if ( m[4] !== undefined)
+          attrs.title = m[4];
+
+        return [ m[0].length, [ "img", attrs ] ];
+      }
+
+      // ![Alt text][id]
+      m = text.match( /^!\[(.*?)\][ \t]*\[(.*?)\]/ );
+
+      if ( m ) {
+        // We can't check if the reference is known here as it likely wont be
+        // found till after. Check it in md tree->hmtl tree conversion
+        return [ m[0].length, [ "img_ref", { alt: m[1], ref: m[2].toLowerCase(), original: m[0] } ] ];
+      }
+
+      // Just consume the '!['
+      return [ 2, "![" ];
+    },
+
+    "[": function link( text ) {
+
+      var orig = String(text);
+      // Inline content is possible inside `link text`
+      var res = Markdown.DialectHelpers.inline_until_char.call( this, text.substr(1), ']' );
+
+      // No closing ']' found. Just consume the [
+      if ( !res ) return [ 1, '[' ];
+
+      var consumed = 1 + res[ 0 ],
+          children = res[ 1 ],
+          link,
+          attrs;
+
+      // At this point the first [...] has been parsed. See what follows to find
+      // out which kind of link we are (reference or direct url)
+      text = text.substr( consumed );
+
+      // [link text](/path/to/img.jpg "Optional title")
+      //                 1            2       3         <--- captures
+      // This will capture up to the last paren in the block. We then pull
+      // back based on if there a matching ones in the url
+      //    ([here](/url/(test))
+      // The parens have to be balanced
+      var m = text.match( /^\s*\([ \t]*(\S+)(?:[ \t]+(["'])(.*?)\2)?[ \t]*\)/ );
+      if ( m ) {
+        var url = m[1];
+        consumed += m[0].length;
+
+        if ( url && url[0] == '<' && url[url.length-1] == '>' )
+          url = url.substring( 1, url.length - 1 );
+
+        // If there is a title we don't have to worry about parens in the url
+        if ( !m[3] ) {
+          var open_parens = 1; // One open that isn't in the capture
+          for (var len = 0; len < url.length; len++) {
+            switch ( url[len] ) {
+            case '(':
+              open_parens++;
+              break;
+            case ')':
+              if ( --open_parens == 0) {
+                consumed -= url.length - len;
+                url = url.substring(0, len);
+              }
+              break;
+            }
+          }
+        }
+
+        // Process escapes only
+        url = this.dialect.inline.__call__.call( this, url, /\\/ )[0];
+
+        attrs = { href: url || "" };
+        if ( m[3] !== undefined)
+          attrs.title = m[3];
+
+        link = [ "link", attrs ].concat( children );
+        return [ consumed, link ];
+      }
+
+      // [Alt text][id]
+      // [Alt text] [id]
+      m = text.match( /^\s*\[(.*?)\]/ );
+
+      if ( m ) {
+
+        consumed += m[ 0 ].length;
+
+        // [links][] uses links as its reference
+        attrs = { ref: ( m[ 1 ] || String(children) ).toLowerCase(),  original: orig.substr( 0, consumed ) };
+
+        link = [ "link_ref", attrs ].concat( children );
+
+        // We can't check if the reference is known here as it likely wont be
+        // found till after. Check it in md tree->hmtl tree conversion.
+        // Store the original so that conversion can revert if the ref isn't found.
+        return [ consumed, link ];
+      }
+
+      // [id]
+      // Only if id is plain (no formatting.)
+      if ( children.length == 1 && typeof children[0] == "string" ) {
+
+        attrs = { ref: children[0].toLowerCase(),  original: orig.substr( 0, consumed ) };
+        link = [ "link_ref", attrs, children[0] ];
+        return [ consumed, link ];
+      }
+
+      // Just consume the '['
+      return [ 1, "[" ];
+    },
+
+
+    "<": function autoLink( text ) {
+      var m;
+
+      if ( ( m = text.match( /^<(?:((https?|ftp|mailto):[^>]+)|(.*?@.*?\.[a-zA-Z]+))>/ ) ) != null ) {
+        if ( m[3] ) {
+          return [ m[0].length, [ "link", { href: "mailto:"; + m[3] }, m[3] ] ];
+
+        }
+        else if ( m[2] == "mailto" ) {
+          return [ m[0].length, [ "link", { href: m[1] }, m[1].substr("mailto:".length ) ] ];
+        }
+        else
+          return [ m[0].length, [ "link", { href: m[1] }, m[1] ] ];
+      }
+
+      return [ 1, "<" ];
+    },
+
+    "`": function inlineCode( text ) {
+      // Inline code block. as many backticks as you like to start it
+      // Always skip over the opening ticks.
+      var m = text.match( /(`+)(([\s\S]*?)\1)/ );
+
+      if ( m && m[2] )
+        return [ m[1].length + m[2].length, [ "inlinecode", m[3] ] ];
+      else {
+        // TODO: No matching end code found - warn!
+        return [ 1, "`" ];
+      }
+    },
+
+    "  \n": function lineBreak( text ) {
+      return [ 3, [ "linebreak" ] ];
+    }
+
+};
+
+// Meta Helper/generator method for em and strong handling
+function strong_em( tag, md ) {
+
+  var state_slot = tag + "_state",
+      other_slot = tag == "strong" ? "em_state" : "strong_state";
+
+  function CloseTag(len) {
+    this.len_after = len;
+    this.name = "close_" + md;
+  }
+
+  return function ( text, orig_match ) {
+
+    if (this[state_slot][0] == md) {
+      // Most recent em is of this type
+      //D:this.debug("closing", md);
+      this[state_slot].shift();
+
+      // "Consume" everything to go back to the recrusion in the else-block below
+      return[ text.length, new CloseTag(text.length-md.length) ];
+    }
+    else {
+      // Store a clone of the em/strong states
+      var other = this[other_slot].slice(),
+          state = this[state_slot].slice();
+
+      this[state_slot].unshift(md);
+
+      //D:this.debug_indent += "  ";
+
+      // Recurse
+      var res = this.processInline( text.substr( md.length ) );
+      //D:this.debug_indent = this.debug_indent.substr(2);
+
+      var last = res[res.length - 1];
+
+      //D:this.debug("processInline from", tag + ": ", uneval( res ) );
+
+      var check = this[state_slot].shift();
+      if (last instanceof CloseTag) {
+        res.pop();
+        // We matched! Huzzah.
+        var consumed = text.length - last.len_after;
+        return [ consumed, [ tag ].concat(res) ];
+      }
+      else {
+        // Restore the state of the other kind. We might have mistakenly closed it.
+        this[other_slot] = other;
+        this[state_slot] = state;
+
+        // We can't reuse the processed result as it could have wrong parsing contexts in it.
+        return [ md.length, md ];
+      }
+    }
+  }; // End returned function
+}
+
+Markdown.dialects.Gruber.inline["**"] = strong_em("strong", "**");
+Markdown.dialects.Gruber.inline["__"] = strong_em("strong", "__");
+Markdown.dialects.Gruber.inline["*"]  = strong_em("em", "*");
+Markdown.dialects.Gruber.inline["_"]  = strong_em("em", "_");
+
+
+// Build default order from insertion order.
+Markdown.buildBlockOrder = function(d) {
+  var ord = [];
+  for ( var i in d ) {
+    if ( i == "__order__" || i == "__call__" ) continue;
+    ord.push( i );
+  }
+  d.__order__ = ord;
+};
+
+// Build patterns for inline matcher
+Markdown.buildInlinePatterns = function(d) {
+  var patterns = [];
+
+  for ( var i in d ) {
+    // __foo__ is reserved and not a pattern
+    if ( i.match( /^__.*__$/) ) continue;
+    var l = i.replace( /([\\.*+?|()\[\]{}])/g, "\\$1" )
+             .replace( /\n/, "\\n" );
+    patterns.push( i.length == 1 ? l : "(?:" + l + ")" );
+  }
+
+  patterns = patterns.join("|");
+  d.__patterns__ = patterns;
+  //print("patterns:", uneval( patterns ) );
+
+  var fn = d.__call__;
+  d.__call__ = function(text, pattern) {
+    if (pattern != undefined) {
+      return fn.call(this, text, pattern);
+    }
+    else
+    {
+      return fn.call(this, text, patterns);
+    }
+  };
+};
+
+Markdown.DialectHelpers = {};
+Markdown.DialectHelpers.inline_until_char = function( text, want ) {
+  var consumed = 0,
+      nodes = [];
+
+  while ( true ) {
+    if ( text[ consumed ] == want ) {
+      // Found the character we were looking for
+      consumed++;
+      return [ consumed, nodes ];
+    }
+
+    if ( consumed >= text.length ) {
+      // No closing char found. Abort.
+      return null;
+    }
+
+    res = this.dialect.inline.__oneElement__.call(this, text.substr( consumed ) );
+    consumed += res[ 0 ];
+    // Add any returned nodes.
+    nodes.push.apply( nodes, res.slice( 1 ) );
+  }
+}
+
+// Helper function to make sub-classing a dialect easier
+Markdown.subclassDialect = function( d ) {
+  function Block() {}
+  Block.prototype = d.block;
+  function Inline() {}
+  Inline.prototype = d.inline;
+
+  return { block: new Block(), inline: new Inline() };
+};
+
+Markdown.buildBlockOrder ( Markdown.dialects.Gruber.block );
+Markdown.buildInlinePatterns( Markdown.dialects.Gruber.inline );
+
+Markdown.dialects.Maruku = Markdown.subclassDialect( Markdown.dialects.Gruber );
+
+Markdown.dialects.Maruku.processMetaHash = function processMetaHash( meta_string ) {
+  var meta = split_meta_hash( meta_string ),
+      attr = {};
+
+  for ( var i = 0; i < meta.length; ++i ) {
+    // id: #foo
+    if ( /^#/.test( meta[ i ] ) ) {
+      attr.id = meta[ i ].substring( 1 );
+    }
+    // class: .foo
+    else if ( /^\./.test( meta[ i ] ) ) {
+      // if class already exists, append the new one
+      if ( attr['class'] ) {
+        attr['class'] = attr['class'] + meta[ i ].replace( /./, " " );
+      }
+      else {
+        attr['class'] = meta[ i ].substring( 1 );
+      }
+    }
+    // attribute: foo=bar
+    else if ( /\=/.test( meta[ i ] ) ) {
+      var s = meta[ i ].split( /\=/ );
+      attr[ s[ 0 ] ] = s[ 1 ];
+    }
+  }
+
+  return attr;
+}
+
+function split_meta_hash( meta_string ) {
+  var meta = meta_string.split( "" ),
+      parts = [ "" ],
+      in_quotes = false;
+
+  while ( meta.length ) {
+    var letter = meta.shift();
+    switch ( letter ) {
+      case " " :
+        // if we're in a quoted section, keep it
+        if ( in_quotes ) {
+          parts[ parts.length - 1 ] += letter;
+        }
+        // otherwise make a new part
+        else {
+          parts.push( "" );
+        }
+        break;
+      case "'" :
+      case '"' :
+        // reverse the quotes and move straight on
+        in_quotes = !in_quotes;
+        break;
+      case "\\" :
+        // shift off the next letter to be used straight away.
+        // it was escaped so we'll keep it whatever it is
+        letter = meta.shift();
+      default :
+        parts[ parts.length - 1 ] += letter;
+        break;
+    }
+  }
+
+  return parts;
+}
+
+Markdown.dialects.Maruku.block.document_meta = function document_meta( block, next ) {
+  // we're only interested in the first block
+  if ( block.lineNumber > 1 ) return undefined;
+
+  // document_meta blocks consist of one or more lines of `Key: Value\n`
+  if ( ! block.match( /^(?:\w+:.*\n)*\w+:.*$/ ) ) return undefined;
+
+  // make an attribute node if it doesn't exist
+  if ( !extract_attr( this.tree ) ) {
+    this.tree.splice( 1, 0, {} );
+  }
+
+  var pairs = block.split( /\n/ );
+  for ( p in pairs ) {
+    var m = pairs[ p ].match( /(\w+):\s*(.*)$/ ),
+        key = m[ 1 ].toLowerCase(),
+        value = m[ 2 ];
+
+    this.tree[ 1 ][ key ] = value;
+  }
+
+  // document_meta produces no content!
+  return [];
+};
+
+Markdown.dialects.Maruku.block.block_meta = function block_meta( block, next ) {
+  // check if the last line of the block is an meta hash
+  var m = block.match( /(^|\n) {0,3}\{:\s*((?:\\\}|[^\}])*)\s*\}$/ );
+  if ( !m ) return undefined;
+
+  // process the meta hash
+  var attr = this.dialect.processMetaHash( m[ 2 ] );
+
+  var hash;
+
+  // if we matched ^ then we need to apply meta to the previous block
+  if ( m[ 1 ] === "" ) {
+    var node = this.tree[ this.tree.length - 1 ];
+    hash = extract_attr( node );
+
+    // if the node is a string (rather than JsonML), bail
+    if ( typeof node === "string" ) return undefined;
+
+    // create the attribute hash if it doesn't exist
+    if ( !hash ) {
+      hash = {};
+      node.splice( 1, 0, hash );
+    }
+
+    // add the attributes in
+    for ( a in attr ) {
+      hash[ a ] = attr[ a ];
+    }
+
+    // return nothing so the meta hash is removed
+    return [];
+  }
+
+  // pull the meta hash off the block and process what's left
+  var b = block.replace( /\n.*$/, "" ),
+      result = this.processBlock( b, [] );
+
+  // get or make the attributes hash
+  hash = extract_attr( result[ 0 ] );
+  if ( !hash ) {
+    hash = {};
+    result[ 0 ].splice( 1, 0, hash );
+  }
+
+  // attach the attributes to the block
+  for ( a in attr ) {
+    hash[ a ] = attr[ a ];
+  }
+
+  return result;
+};
+
+Markdown.dialects.Maruku.block.definition_list = function definition_list( block, next ) {
+  // one or more terms followed by one or more definitions, in a single block
+  var tight = /^((?:[^\s:].*\n)+):\s+([\s\S]+)$/,
+      list = [ "dl" ],
+      i;
+
+  // see if we're dealing with a tight or loose block
+  if ( ( m = block.match( tight ) ) ) {
+    // pull subsequent tight DL blocks out of `next`
+    var blocks = [ block ];
+    while ( next.length && tight.exec( next[ 0 ] ) ) {
+      blocks.push( next.shift() );
+    }
+
+    for ( var b = 0; b < blocks.length; ++b ) {
+      var m = blocks[ b ].match( tight ),
+          terms = m[ 1 ].replace( /\n$/, "" ).split( /\n/ ),
+          defns = m[ 2 ].split( /\n:\s+/ );
+
+      // print( uneval( m ) );
+
+      for ( i = 0; i < terms.length; ++i ) {
+        list.push( [ "dt", terms[ i ] ] );
+      }
+
+      for ( i = 0; i < defns.length; ++i ) {
+        // run inline processing over the definition
+        list.push( [ "dd" ].concat( this.processInline( defns[ i ].replace( /(\n)\s+/, "$1" ) ) ) );
+      }
+    }
+  }
+  else {
+    return undefined;
+  }
+
+  return [ list ];
+};
+
+Markdown.dialects.Maruku.inline[ "{:" ] = function inline_meta( text, matches, out ) {
+  if ( !out.length ) {
+    return [ 2, "{:" ];
+  }
+
+  // get the preceeding element
+  var before = out[ out.length - 1 ];
+
+  if ( typeof before === "string" ) {
+    return [ 2, "{:" ];
+  }
+
+  // match a meta hash
+  var m = text.match( /^\{:\s*((?:\\\}|[^\}])*)\s*\}/ );
+
+  // no match, false alarm
+  if ( !m ) {
+    return [ 2, "{:" ];
+  }
+
+  // attach the attributes to the preceeding element
+  var meta = this.dialect.processMetaHash( m[ 1 ] ),
+      attr = extract_attr( before );
+
+  if ( !attr ) {
+    attr = {};
+    before.splice( 1, 0, attr );
+  }
+
+  for ( var k in meta ) {
+    attr[ k ] = meta[ k ];
+  }
+
+  // cut out the string and replace it with nothing
+  return [ m[ 0 ].length, "" ];
+};
+
+Markdown.buildBlockOrder ( Markdown.dialects.Maruku.block );
+Markdown.buildInlinePatterns( Markdown.dialects.Maruku.inline );
+
+var isArray = Array.isArray || function(obj) {
+  return Object.prototype.toString.call(obj) == '[object Array]';
+};
+
+var forEach;
+// Don't mess with Array.prototype. Its not friendly
+if ( Array.prototype.forEach ) {
+  forEach = function( arr, cb, thisp ) {
+    return arr.forEach( cb, thisp );
+  };
+}
+else {
+  forEach = function(arr, cb, thisp) {
+    for (var i = 0; i < arr.length; i++) {
+      cb.call(thisp || arr, arr[i], i, arr);
+    }
+  }
+}
+
+function extract_attr( jsonml ) {
+  return isArray(jsonml)
+      && jsonml.length > 1
+      && typeof jsonml[ 1 ] === "object"
+      && !( isArray(jsonml[ 1 ]) )
+      ? jsonml[ 1 ]
+      : undefined;
+}
+
+
+
+/**
+ *  renderJsonML( jsonml[, options] ) -> String
+ *  - jsonml (Array): JsonML array to render to XML
+ *  - options (Object): options
+ *
+ *  Converts the given JsonML into well-formed XML.
+ *
+ *  The options currently understood are:
+ *
+ *  - root (Boolean): wether or not the root node should be included in the
+ *    output, or just its children. The default `false` is to not include the
+ *    root itself.
+ */
+expose.renderJsonML = function( jsonml, options ) {
+  options = options || {};
+  // include the root element in the rendered output?
+  options.root = options.root || false;
+
+  var content = [];
+
+  if ( options.root ) {
+    content.push( render_tree( jsonml ) );
+  }
+  else {
+    jsonml.shift(); // get rid of the tag
+    if ( jsonml.length && typeof jsonml[ 0 ] === "object" && !( jsonml[ 0 ] instanceof Array ) ) {
+      jsonml.shift(); // get rid of the attributes
+    }
+
+    while ( jsonml.length ) {
+      content.push( render_tree( jsonml.shift() ) );
+    }
+  }
+
+  return content.join( "\n\n" );
+};
+
+function escapeHTML( text ) {
+  return text.replace( /&/g, "&amp;" )
+             .replace( /</g, "&lt;" )
+             .replace( />/g, "&gt;" )
+             .replace( /"/g, "&quot;" )
+             .replace( /'/g, "&#39;" );
+}
+
+function render_tree( jsonml ) {
+  // basic case
+  if ( typeof jsonml === "string" ) {
+    return escapeHTML( jsonml );
+  }
+
+  var tag = jsonml.shift(),
+      attributes = {},
+      content = [];
+
+  if ( jsonml.length && typeof jsonml[ 0 ] === "object" && !( jsonml[ 0 ] instanceof Array ) ) {
+    attributes = jsonml.shift();
+  }
+
+  while ( jsonml.length ) {
+    content.push( arguments.callee( jsonml.shift() ) );
+  }
+
+  var tag_attrs = "";
+  for ( var a in attributes ) {
+    tag_attrs += " " + a + '="' + escapeHTML( attributes[ a ] ) + '"';
+  }
+
+  // be careful about adding whitespace here for inline elements
+  if ( tag == "img" || tag == "br" || tag == "hr" ) {
+    return "<"+ tag + tag_attrs + "/>";
+  }
+  else {
+    return "<"+ tag + tag_attrs + ">" + content.join( "" ) + "</" + tag + ">";
+  }
+}
+
+function convert_tree_to_html( tree, references, options ) {
+  var i;
+  options = options || {};
+
+  // shallow clone
+  var jsonml = tree.slice( 0 );
+
+  if (typeof options.preprocessTreeNode === "function") {
+      jsonml = options.preprocessTreeNode(jsonml, references);
+  }
+
+  // Clone attributes if they exist
+  var attrs = extract_attr( jsonml );
+  if ( attrs ) {
+    jsonml[ 1 ] = {};
+    for ( i in attrs ) {
+      jsonml[ 1 ][ i ] = attrs[ i ];
+    }
+    attrs = jsonml[ 1 ];
+  }
+
+  // basic case
+  if ( typeof jsonml === "string" ) {
+    return jsonml;
+  }
+
+  // convert this node
+  switch ( jsonml[ 0 ] ) {
+    case "header":
+      jsonml[ 0 ] = "h" + jsonml[ 1 ].level;
+      delete jsonml[ 1 ].level;
+      break;
+    case "bulletlist":
+      jsonml[ 0 ] = "ul";
+      break;
+    case "numberlist":
+      jsonml[ 0 ] = "ol";
+      break;
+    case "listitem":
+      jsonml[ 0 ] = "li";
+      break;
+    case "para":
+      jsonml[ 0 ] = "p";
+      break;
+    case "markdown":
+      jsonml[ 0 ] = "html";
+      if ( attrs ) delete attrs.references;
+      break;
+    case "code_block":
+      jsonml[ 0 ] = "pre";
+      i = attrs ? 2 : 1;
+      var code = [ "code" ];
+      code.push.apply( code, jsonml.splice( i ) );
+      jsonml[ i ] = code;
+      break;
+    case "inlinecode":
+      jsonml[ 0 ] = "code";
+      break;
+    case "img":
+      jsonml[ 1 ].src = jsonml[ 1 ].href;
+      delete jsonml[ 1 ].href;
+      break;
+    case "linebreak":
+      jsonml[ 0 ] = "br";
+    break;
+    case "link":
+      jsonml[ 0 ] = "a";
+      break;
+    case "link_ref":
+      jsonml[ 0 ] = "a";
+
+      // grab this ref and clean up the attribute node
+      var ref = references[ attrs.ref ];
+
+      // if the reference exists, make the link
+      if ( ref ) {
+        delete attrs.ref;
+
+        // add in the href and title, if present
+        attrs.href = ref.href;
+        if ( ref.title ) {
+          attrs.title = ref.title;
+        }
+
+        // get rid of the unneeded original text
+        delete attrs.original;
+      }
+      // the reference doesn't exist, so revert to plain text
+      else {
+        return attrs.original;
+      }
+      break;
+    case "img_ref":
+      jsonml[ 0 ] = "img";
+
+      // grab this ref and clean up the attribute node
+      var ref = references[ attrs.ref ];
+
+      // if the reference exists, make the link
+      if ( ref ) {
+        delete attrs.ref;
+
+        // add in the href and title, if present
+        attrs.src = ref.href;
+        if ( ref.title ) {
+          attrs.title = ref.title;
+        }
+
+        // get rid of the unneeded original text
+        delete attrs.original;
+      }
+      // the reference doesn't exist, so revert to plain text
+      else {
+        return attrs.original;
+      }
+      break;
+  }
+
+  // convert all the children
+  i = 1;
+
+  // deal with the attribute node, if it exists
+  if ( attrs ) {
+    // if there are keys, skip over it
+    for ( var key in jsonml[ 1 ] ) {
+      i = 2;
+    }
+    // if there aren't, remove it
+    if ( i === 1 ) {
+      jsonml.splice( i, 1 );
+    }
+  }
+
+  for ( ; i < jsonml.length; ++i ) {
+    jsonml[ i ] = arguments.callee( jsonml[ i ], references, options );
+  }
+
+  return jsonml;
+}
+
+
+// merges adjacent text nodes into a single node
+function merge_text_nodes( jsonml ) {
+  // skip the tag name and attribute hash
+  var i = extract_attr( jsonml ) ? 2 : 1;
+
+  while ( i < jsonml.length ) {
+    // if it's a string check the next item too
+    if ( typeof jsonml[ i ] === "string" ) {
+      if ( i + 1 < jsonml.length && typeof jsonml[ i + 1 ] === "string" ) {
+        // merge the second string into the first and remove it
+        jsonml[ i ] += jsonml.splice( i + 1, 1 )[ 0 ];
+      }
+      else {
+        ++i;
+      }
+    }
+    // if it's not a string recurse
+    else {
+      arguments.callee( jsonml[ i ] );
+      ++i;
+    }
+  }
+}
+
+} )( (function() {
+  if ( typeof Y !== "undefined" ) {
+    return Y.namespace('Markdown');
+  }
+  else if ( typeof exports === "undefined" ) {
+    window.markdown = {};
+    return window.markdown;
+  }
+  else {
+    return exports;
+  }
+} )() );
+
+
+}, 'gallery-2012.07.18-13-22' );

=== added file 'app/assets/javascripts/gallery-markdown.js'
--- app/assets/javascripts/gallery-markdown.js	1970-01-01 00:00:00 +0000
+++ app/assets/javascripts/gallery-markdown.js	2012-12-21 02:37:21 +0000
@@ -0,0 +1,1643 @@
+YUI.add('gallery-markdown', function(Y) {
+
+// Released under MIT license
+// Copyright (c) 2009-2010 Dominic Baggott
+// Copyright (c) 2009-2010 Ash Berlin
+// Copyright (c) 2011 Christoph Dorn <christoph@xxxxxxxxxxxxxxxxx> (http://www.christophdorn.com)
+
+/**
+A wrapper around the fantastic Markdown library at https://github.com/evilstreak/markdown-js
+
+@module gallery-markdown
+
+**/
+
+/**
+A YUI wrapper around the fantastic Markdown library at https://github.com/evilstreak/markdown-js.
+This supports several dialects and works very well. I (jshirley) am not the author, merely the one maintaining the YUI wrapper.
+
+Below is the original documentation:
+
+Markdown processing in Javascript done right. We have very particular views
+on what constitutes 'right' which include:
+
+ - produces well-formed HTML (this means that em and strong nesting is
+   important)
+
+ - has an intermediate representation to allow processing of parsed data (We
+   in fact have two, both as [JsonML]: a markdown tree and an HTML tree).
+
+ - is easily extensible to add new dialects without having to rewrite the
+   entire parsing mechanics
+
+ - has a good test suite
+
+This implementation fulfills all of these (except that the test suite could
+do with expanding to automatically run all the fixtures from other Markdown
+implementations.)
+
+##### Intermediate Representation
+
+*TODO* Talk about this :) Its JsonML, but document the node names we use.
+
+[JsonML]: http://jsonml.org/ "JSON Markup Language"
+
+**/
+
+(function( expose ) {
+
+var Markdown = expose.Markdown = function Markdown(dialect) {
+  switch (typeof dialect) {
+    case "undefined":
+      this.dialect = Markdown.dialects.Gruber;
+      break;
+    case "object":
+      this.dialect = dialect;
+      break;
+    default:
+      if (dialect in Markdown.dialects) {
+        this.dialect = Markdown.dialects[dialect];
+      }
+      else {
+        throw new Error("Unknown Markdown dialect '" + String(dialect) + "'");
+      }
+      break;
+  }
+  this.em_state = [];
+  this.strong_state = [];
+  this.debug_indent = "";
+};
+
+/**
+Parse `markdown` and return a markdown document as a Markdown.JsonML tree.
+
+@method parse
+@param markdown {String} The Markdown string to parse
+@param dialect {String} The Markdown dialect to use, defaults to gruber
+@static
+
+**/
+expose.parse = function( source, dialect ) {
+  // dialect will default if undefined
+  var md = new Markdown( dialect );
+  return md.toTree( source );
+};
+
+/**
+Take markdown (either as a string or as a JsonML tree) and run it through
+[[toHTMLTree]] then turn it into a well-formated HTML fragment.
+
+@method toHTML
+@static
+@param source {String} markdown string to parse
+@param dialect {String} dialect to use
+
+**/
+expose.toHTML = function toHTML( source , dialect , options ) {
+  var input = expose.toHTMLTree( source , dialect , options );
+
+  return expose.renderJsonML( input );
+};
+
+/**
+Turn markdown into HTML, represented as a JsonML tree. If a string is given
+to this function, it is first parsed into a markdown tree by calling
+[[parse]].
+
+@method toHTMLTree
+@static
+@param markdown {String | Object } markdown string to parse or already parsed tree
+@param dialect {String} the dialect to use, defaults to gruber
+**/
+expose.toHTMLTree = function toHTMLTree( input, dialect , options ) {
+  // convert string input to an MD tree
+  if ( typeof input ==="string" ) input = this.parse( input, dialect );
+
+  // Now convert the MD tree to an HTML tree
+
+  // remove references from the tree
+  var attrs = extract_attr( input ),
+      refs = {};
+
+  if ( attrs && attrs.references ) {
+    refs = attrs.references;
+  }
+
+  var html = convert_tree_to_html( input, refs , options );
+  merge_text_nodes( html );
+  return html;
+};
+
+// For Spidermonkey based engines
+function mk_block_toSource() {
+  return "Markdown.mk_block( " +
+          uneval(this.toString()) +
+          ", " +
+          uneval(this.trailing) +
+          ", " +
+          uneval(this.lineNumber) +
+          " )";
+}
+
+// node
+function mk_block_inspect() {
+  var util = require('util');
+  return "Markdown.mk_block( " +
+          util.inspect(this.toString()) +
+          ", " +
+          util.inspect(this.trailing) +
+          ", " +
+          util.inspect(this.lineNumber) +
+          " )";
+
+}
+
+var mk_block = Markdown.mk_block = function(block, trail, line) {
+  // Be helpful for default case in tests.
+  if ( arguments.length == 1 ) trail = "\n\n";
+
+  var s = new String(block);
+  s.trailing = trail;
+  // To make it clear its not just a string
+  s.inspect = mk_block_inspect;
+  s.toSource = mk_block_toSource;
+
+  if (line != undefined)
+    s.lineNumber = line;
+
+  return s;
+};
+
+function count_lines( str ) {
+  var n = 0, i = -1;
+  while ( ( i = str.indexOf('\n', i+1) ) !== -1) n++;
+  return n;
+}
+
+// Internal - split source into rough blocks
+Markdown.prototype.split_blocks = function splitBlocks( input, startLine ) {
+  // [\s\S] matches _anything_ (newline or space)
+  var re = /([\s\S]+?)($|\n(?:\s*\n|$)+)/g,
+      blocks = [],
+      m;
+
+  var line_no = 1;
+
+  if ( ( m = /^(\s*\n)/.exec(input) ) != null ) {
+    // skip (but count) leading blank lines
+    line_no += count_lines( m[0] );
+    re.lastIndex = m[0].length;
+  }
+
+  while ( ( m = re.exec(input) ) !== null ) {
+    blocks.push( mk_block( m[1], m[2], line_no ) );
+    line_no += count_lines( m[0] );
+  }
+
+  return blocks;
+};
+
+/**
+Process `block` and return an array of JsonML nodes representing `block`.
+
+It does this by asking each block level function in the dialect to process
+the block until one can. Succesful handling is indicated by returning an
+array (with zero or more JsonML nodes), failure by a false value.
+
+Blocks handlers are responsible for calling [[Markdown#processInline]]
+themselves as appropriate.
+
+If the blocks were split incorrectly or adjacent blocks need collapsing you
+can adjust `next` in place using shift/splice etc.
+
+If any of this default behaviour is not right for the dialect, you can
+define a `__call__` method on the dialect that will get invoked to handle
+the block processing.
+
+@method processBlock
+@protected
+@static
+@param block {String} the block to process
+@param next {Array} following blocks
+**/
+Markdown.prototype.processBlock = function processBlock( block, next ) {
+  var cbs = this.dialect.block,
+      ord = cbs.__order__;
+
+  if ( "__call__" in cbs ) {
+    return cbs.__call__.call(this, block, next);
+  }
+
+  for ( var i = 0; i < ord.length; i++ ) {
+    //D:this.debug( "Testing", ord[i] );
+    var res = cbs[ ord[i] ].call( this, block, next );
+    if ( res ) {
+      //D:this.debug("  matched");
+      if ( !isArray(res) || ( res.length > 0 && !( isArray(res[0]) ) ) )
+        this.debug(ord[i], "didn't return a proper array");
+      //D:this.debug( "" );
+      return res;
+    }
+  }
+
+  // Uhoh! no match! Should we throw an error?
+  return [];
+};
+
+Markdown.prototype.processInline = function processInline( block ) {
+  return this.dialect.inline.__call__.call( this, String( block ) );
+};
+
+/**
+Parse `source` into a JsonML tree representing the markdown document.
+
+@method toTree
+@protected
+@static
+@param source {String} markdown source to parse.
+@param custom_root {Object} A previous tree to append to
+**/
+// custom_tree means set this.tree to `custom_tree` and restore old value on return
+Markdown.prototype.toTree = function toTree( source, custom_root ) {
+  var blocks = source instanceof Array ? source : this.split_blocks( source );
+
+  // Make tree a member variable so its easier to mess with in extensions
+  var old_tree = this.tree;
+  try {
+    this.tree = custom_root || this.tree || [ "markdown" ];
+
+    blocks:
+    while ( blocks.length ) {
+      var b = this.processBlock( blocks.shift(), blocks );
+
+      // Reference blocks and the like won't return any content
+      if ( !b.length ) continue blocks;
+
+      this.tree.push.apply( this.tree, b );
+    }
+    return this.tree;
+  }
+  finally {
+    if ( custom_root ) {
+      this.tree = old_tree;
+    }
+  }
+};
+
+// Noop by default
+Markdown.prototype.debug = function () {
+  var args = Array.prototype.slice.call( arguments);
+  args.unshift(this.debug_indent);
+  if (typeof print !== "undefined")
+      print.apply( print, args );
+  if (typeof console !== "undefined" && typeof console.log !== "undefined")
+      console.log.apply( null, args );
+}
+
+Markdown.prototype.loop_re_over_block = function( re, block, cb ) {
+  // Dont use /g regexps with this
+  var m,
+      b = block.valueOf();
+
+  while ( b.length && (m = re.exec(b) ) != null) {
+    b = b.substr( m[0].length );
+    cb.call(this, m);
+  }
+  return b;
+};
+
+/**
+ * Markdown.dialects
+ *
+ * Namespace of built-in dialects.
+ **/
+Markdown.dialects = {};
+
+/**
+ * Markdown.dialects.Gruber
+ *
+ * The default dialect that follows the rules set out by John Gruber's
+ * markdown.pl as closely as possible. Well actually we follow the behaviour of
+ * that script which in some places is not exactly what the syntax web page
+ * says.
+ **/
+Markdown.dialects.Gruber = {
+  block: {
+    atxHeader: function atxHeader( block, next ) {
+      var m = block.match( /^(#{1,6})\s*(.*?)\s*#*\s*(?:\n|$)/ );
+
+      if ( !m ) return undefined;
+
+      var header = [ "header", { level: m[ 1 ].length } ];
+      Array.prototype.push.apply(header, this.processInline(m[ 2 ]));
+
+      if ( m[0].length < block.length )
+        next.unshift( mk_block( block.substr( m[0].length ), block.trailing, block.lineNumber + 2 ) );
+
+      return [ header ];
+    },
+
+    setextHeader: function setextHeader( block, next ) {
+      var m = block.match( /^(.*)\n([-=])\2\2+(?:\n|$)/ );
+
+      if ( !m ) return undefined;
+
+      var level = ( m[ 2 ] === "=" ) ? 1 : 2;
+      var header = [ "header", { level : level }, m[ 1 ] ];
+
+      if ( m[0].length < block.length )
+        next.unshift( mk_block( block.substr( m[0].length ), block.trailing, block.lineNumber + 2 ) );
+
+      return [ header ];
+    },
+
+    code: function code( block, next ) {
+      // |    Foo
+      // |bar
+      // should be a code block followed by a paragraph. Fun
+      //
+      // There might also be adjacent code block to merge.
+
+      var ret = [],
+          re = /^(?: {0,3}\t| {4})(.*)\n?/,
+          lines;
+
+      // 4 spaces + content
+      if ( !block.match( re ) ) return undefined;
+
+      block_search:
+      do {
+        // Now pull out the rest of the lines
+        var b = this.loop_re_over_block(
+                  re, block.valueOf(), function( m ) { ret.push( m[1] ); } );
+
+        if (b.length) {
+          // Case alluded to in first comment. push it back on as a new block
+          next.unshift( mk_block(b, block.trailing) );
+          break block_search;
+        }
+        else if (next.length) {
+          // Check the next block - it might be code too
+          if ( !next[0].match( re ) ) break block_search;
+
+          // Pull how how many blanks lines follow - minus two to account for .join
+          ret.push ( block.trailing.replace(/[^\n]/g, '').substring(2) );
+
+          block = next.shift();
+        }
+        else {
+          break block_search;
+        }
+      } while (true);
+
+      return [ [ "code_block", ret.join("\n") ] ];
+    },
+
+    horizRule: function horizRule( block, next ) {
+      // this needs to find any hr in the block to handle abutting blocks
+      var m = block.match( /^(?:([\s\S]*?)\n)?[ \t]*([-_*])(?:[ \t]*\2){2,}[ \t]*(?:\n([\s\S]*))?$/ );
+
+      if ( !m ) {
+        return undefined;
+      }
+
+      var jsonml = [ [ "hr" ] ];
+
+      // if there's a leading abutting block, process it
+      if ( m[ 1 ] ) {
+        jsonml.unshift.apply( jsonml, this.processBlock( m[ 1 ], [] ) );
+      }
+
+      // if there's a trailing abutting block, stick it into next
+      if ( m[ 3 ] ) {
+        next.unshift( mk_block( m[ 3 ] ) );
+      }
+
+      return jsonml;
+    },
+
+    // There are two types of lists. Tight and loose. Tight lists have no whitespace
+    // between the items (and result in text just in the <li>) and loose lists,
+    // which have an empty line between list items, resulting in (one or more)
+    // paragraphs inside the <li>.
+    //
+    // There are all sorts weird edge cases about the original markdown.pl's
+    // handling of lists:
+    //
+    // * Nested lists are supposed to be indented by four chars per level. But
+    //   if they aren't, you can get a nested list by indenting by less than
+    //   four so long as the indent doesn't match an indent of an existing list
+    //   item in the 'nest stack'.
+    //
+    // * The type of the list (bullet or number) is controlled just by the
+    //    first item at the indent. Subsequent changes are ignored unless they
+    //    are for nested lists
+    //
+    lists: (function( ) {
+      // Use a closure to hide a few variables.
+      var any_list = "[*+-]|\\d+\\.",
+          bullet_list = /[*+-]/,
+          number_list = /\d+\./,
+          // Capture leading indent as it matters for determining nested lists.
+          is_list_re = new RegExp( "^( {0,3})(" + any_list + ")[ \t]+" ),
+          indent_re = "(?: {0,3}\\t| {4})";
+
+      // TODO: Cache this regexp for certain depths.
+      // Create a regexp suitable for matching an li for a given stack depth
+      function regex_for_depth( depth ) {
+
+        return new RegExp(
+          // m[1] = indent, m[2] = list_type
+          "(?:^(" + indent_re + "{0," + depth + "} {0,3})(" + any_list + ")\\s+)|" +
+          // m[3] = cont
+          "(^" + indent_re + "{0," + (depth-1) + "}[ ]{0,4})"
+        );
+      }
+      function expand_tab( input ) {
+        return input.replace( / {0,3}\t/g, "    " );
+      }
+
+      // Add inline content `inline` to `li`. inline comes from processInline
+      // so is an array of content
+      function add(li, loose, inline, nl) {
+        if (loose) {
+          li.push( [ "para" ].concat(inline) );
+          return;
+        }
+        // Hmmm, should this be any block level element or just paras?
+        var add_to = li[li.length -1] instanceof Array && li[li.length - 1][0] == "para"
+                   ? li[li.length -1]
+                   : li;
+
+        // If there is already some content in this list, add the new line in
+        if (nl && li.length > 1) inline.unshift(nl);
+
+        for (var i=0; i < inline.length; i++) {
+          var what = inline[i],
+              is_str = typeof what == "string";
+          if (is_str && add_to.length > 1 && typeof add_to[add_to.length-1] == "string" ) {
+            add_to[ add_to.length-1 ] += what;
+          }
+          else {
+            add_to.push( what );
+          }
+        }
+      }
+
+      // contained means have an indent greater than the current one. On
+      // *every* line in the block
+      function get_contained_blocks( depth, blocks ) {
+
+        var re = new RegExp( "^(" + indent_re + "{" + depth + "}.*?\\n?)*$" ),
+            replace = new RegExp("^" + indent_re + "{" + depth + "}", "gm"),
+            ret = [];
+
+        while ( blocks.length > 0 ) {
+          if ( re.exec( blocks[0] ) ) {
+            var b = blocks.shift(),
+                // Now remove that indent
+                x = b.replace( replace, "");
+
+            ret.push( mk_block( x, b.trailing, b.lineNumber ) );
+          }
+          break;
+        }
+        return ret;
+      }
+
+      // passed to stack.forEach to turn list items up the stack into paras
+      function paragraphify(s, i, stack) {
+        var list = s.list;
+        var last_li = list[list.length-1];
+
+        if (last_li[1] instanceof Array && last_li[1][0] == "para") {
+          return;
+        }
+        if (i+1 == stack.length) {
+          // Last stack frame
+          // Keep the same array, but replace the contents
+          last_li.push( ["para"].concat( last_li.splice(1) ) );
+        }
+        else {
+          var sublist = last_li.pop();
+          last_li.push( ["para"].concat( last_li.splice(1) ), sublist );
+        }
+      }
+
+      // The matcher function
+      return function( block, next ) {
+        var m = block.match( is_list_re );
+        if ( !m ) return undefined;
+
+        function make_list( m ) {
+          var list = bullet_list.exec( m[2] )
+                   ? ["bulletlist"]
+                   : ["numberlist"];
+
+          stack.push( { list: list, indent: m[1] } );
+          return list;
+        }
+
+
+        var stack = [], // Stack of lists for nesting.
+            list = make_list( m ),
+            last_li,
+            loose = false,
+            ret = [ stack[0].list ],
+            i;
+
+        // Loop to search over block looking for inner block elements and loose lists
+        loose_search:
+        while( true ) {
+          // Split into lines preserving new lines at end of line
+          var lines = block.split( /(?=\n)/ );
+
+          // We have to grab all lines for a li and call processInline on them
+          // once as there are some inline things that can span lines.
+          var li_accumulate = "";
+
+          // Loop over the lines in this block looking for tight lists.
+          tight_search:
+          for (var line_no=0; line_no < lines.length; line_no++) {
+            var nl = "",
+                l = lines[line_no].replace(/^\n/, function(n) { nl = n; return ""; });
+
+            // TODO: really should cache this
+            var line_re = regex_for_depth( stack.length );
+
+            m = l.match( line_re );
+            //print( "line:", uneval(l), "\nline match:", uneval(m) );
+
+            // We have a list item
+            if ( m[1] !== undefined ) {
+              // Process the previous list item, if any
+              if ( li_accumulate.length ) {
+                add( last_li, loose, this.processInline( li_accumulate ), nl );
+                // Loose mode will have been dealt with. Reset it
+                loose = false;
+                li_accumulate = "";
+              }
+
+              m[1] = expand_tab( m[1] );
+              var wanted_depth = Math.floor(m[1].length/4)+1;
+              //print( "want:", wanted_depth, "stack:", stack.length);
+              if ( wanted_depth > stack.length ) {
+                // Deep enough for a nested list outright
+                //print ( "new nested list" );
+                list = make_list( m );
+                last_li.push( list );
+                last_li = list[1] = [ "listitem" ];
+              }
+              else {
+                // We aren't deep enough to be strictly a new level. This is
+                // where Md.pl goes nuts. If the indent matches a level in the
+                // stack, put it there, else put it one deeper then the
+                // wanted_depth deserves.
+                var found = false;
+                for (i = 0; i < stack.length; i++) {
+                  if ( stack[ i ].indent != m[1] ) continue;
+                  list = stack[ i ].list;
+                  stack.splice( i+1 );
+                  found = true;
+                  break;
+                }
+
+                if (!found) {
+                  //print("not found. l:", uneval(l));
+                  wanted_depth++;
+                  if (wanted_depth <= stack.length) {
+                    stack.splice(wanted_depth);
+                    //print("Desired depth now", wanted_depth, "stack:", stack.length);
+                    list = stack[wanted_depth-1].list;
+                    //print("list:", uneval(list) );
+                  }
+                  else {
+                    //print ("made new stack for messy indent");
+                    list = make_list(m);
+                    last_li.push(list);
+                  }
+                }
+
+                //print( uneval(list), "last", list === stack[stack.length-1].list );
+                last_li = [ "listitem" ];
+                list.push(last_li);
+              } // end depth of shenegains
+              nl = "";
+            }
+
+            // Add content
+            if (l.length > m[0].length) {
+              li_accumulate += nl + l.substr( m[0].length );
+            }
+          } // tight_search
+
+          if ( li_accumulate.length ) {
+            add( last_li, loose, this.processInline( li_accumulate ), nl );
+            // Loose mode will have been dealt with. Reset it
+            loose = false;
+            li_accumulate = "";
+          }
+
+          // Look at the next block - we might have a loose list. Or an extra
+          // paragraph for the current li
+          var contained = get_contained_blocks( stack.length, next );
+
+          // Deal with code blocks or properly nested lists
+          if (contained.length > 0) {
+            // Make sure all listitems up the stack are paragraphs
+            forEach( stack, paragraphify, this);
+
+            last_li.push.apply( last_li, this.toTree( contained, [] ) );
+          }
+
+          var next_block = next[0] && next[0].valueOf() || "";
+
+          if ( next_block.match(is_list_re) || next_block.match( /^ / ) ) {
+            block = next.shift();
+
+            // Check for an HR following a list: features/lists/hr_abutting
+            var hr = this.dialect.block.horizRule( block, next );
+
+            if (hr) {
+              ret.push.apply(ret, hr);
+              break;
+            }
+
+            // Make sure all listitems up the stack are paragraphs
+            forEach( stack, paragraphify, this);
+
+            loose = true;
+            continue loose_search;
+          }
+          break;
+        } // loose_search
+
+        return ret;
+      };
+    })(),
+
+    blockquote: function blockquote( block, next ) {
+      if ( !block.match( /^>/m ) )
+        return undefined;
+
+      var jsonml = [];
+
+      // separate out the leading abutting block, if any
+      if ( block[ 0 ] != ">" ) {
+        var lines = block.split( /\n/ ),
+            prev = [];
+
+        // keep shifting lines until you find a crotchet
+        while ( lines.length && lines[ 0 ][ 0 ] != ">" ) {
+            prev.push( lines.shift() );
+        }
+
+        // reassemble!
+        block = lines.join( "\n" );
+        jsonml.push.apply( jsonml, this.processBlock( prev.join( "\n" ), [] ) );
+      }
+
+      // if the next block is also a blockquote merge it in
+      while ( next.length && next[ 0 ][ 0 ] == ">" ) {
+        var b = next.shift();
+        block = new String(block + block.trailing + b);
+        block.trailing = b.trailing;
+      }
+
+      // Strip off the leading "> " and re-process as a block.
+      var input = block.replace( /^> ?/gm, '' ),
+          old_tree = this.tree;
+      jsonml.push( this.toTree( input, [ "blockquote" ] ) );
+
+      return jsonml;
+    },
+
+    referenceDefn: function referenceDefn( block, next) {
+      var re = /^\s*\[(.*?)\]:\s*(\S+)(?:\s+(?:(['"])(.*?)\3|\((.*?)\)))?\n?/;
+      // interesting matches are [ , ref_id, url, , title, title ]
+
+      if ( !block.match(re) )
+        return undefined;
+
+      // make an attribute node if it doesn't exist
+      if ( !extract_attr( this.tree ) ) {
+        this.tree.splice( 1, 0, {} );
+      }
+
+      var attrs = extract_attr( this.tree );
+
+      // make a references hash if it doesn't exist
+      if ( attrs.references === undefined ) {
+        attrs.references = {};
+      }
+
+      var b = this.loop_re_over_block(re, block, function( m ) {
+
+        if ( m[2] && m[2][0] == '<' && m[2][m[2].length-1] == '>' )
+          m[2] = m[2].substring( 1, m[2].length - 1 );
+
+        var ref = attrs.references[ m[1].toLowerCase() ] = {
+          href: m[2]
+        };
+
+        if (m[4] !== undefined)
+          ref.title = m[4];
+        else if (m[5] !== undefined)
+          ref.title = m[5];
+
+      } );
+
+      if (b.length)
+        next.unshift( mk_block( b, block.trailing ) );
+
+      return [];
+    },
+
+    para: function para( block, next ) {
+      // everything's a para!
+      return [ ["para"].concat( this.processInline( block ) ) ];
+    }
+  }
+};
+
+Markdown.dialects.Gruber.inline = {
+
+    __oneElement__: function oneElement( text, patterns_or_re, previous_nodes ) {
+      var m,
+          res,
+          lastIndex = 0;
+
+      patterns_or_re = patterns_or_re || this.dialect.inline.__patterns__;
+      var re = new RegExp( "([\\s\\S]*?)(" + (patterns_or_re.source || patterns_or_re) + ")" );
+
+      m = re.exec( text );
+      if (!m) {
+        // Just boring text
+        return [ text.length, text ];
+      }
+      else if ( m[1] ) {
+        // Some un-interesting text matched. Return that first
+        return [ m[1].length, m[1] ];
+      }
+
+      var res;
+      if ( m[2] in this.dialect.inline ) {
+        res = this.dialect.inline[ m[2] ].call(
+                  this,
+                  text.substr( m.index ), m, previous_nodes || [] );
+      }
+      // Default for now to make dev easier. just slurp special and output it.
+      res = res || [ m[2].length, m[2] ];
+      return res;
+    },
+
+    __call__: function inline( text, patterns ) {
+
+      var out = [],
+          res;
+
+      function add(x) {
+        //D:self.debug("  adding output", uneval(x));
+        if (typeof x == "string" && typeof out[out.length-1] == "string")
+          out[ out.length-1 ] += x;
+        else
+          out.push(x);
+      }
+
+      while ( text.length > 0 ) {
+        res = this.dialect.inline.__oneElement__.call(this, text, patterns, out );
+        text = text.substr( res.shift() );
+        forEach(res, add )
+      }
+
+      return out;
+    },
+
+    // These characters are intersting elsewhere, so have rules for them so that
+    // chunks of plain text blocks don't include them
+    "]": function () {},
+    "}": function () {},
+
+    "\\": function escaped( text ) {
+      // [ length of input processed, node/children to add... ]
+      // Only esacape: \ ` * _ { } [ ] ( ) # * + - . !
+      if ( text.match( /^\\[\\`\*_{}\[\]()#\+.!\-]/ ) )
+        return [ 2, text[1] ];
+      else
+        // Not an esacpe
+        return [ 1, "\\" ];
+    },
+
+    "![": function image( text ) {
+
+      // Unlike images, alt text is plain text only. no other elements are
+      // allowed in there
+
+      // ![Alt text](/path/to/img.jpg "Optional title")
+      //      1          2            3       4         <--- captures
+      var m = text.match( /^!\[(.*?)\][ \t]*\([ \t]*(\S*)(?:[ \t]+(["'])(.*?)\3)?[ \t]*\)/ );
+
+      if ( m ) {
+        if ( m[2] && m[2][0] == '<' && m[2][m[2].length-1] == '>' )
+          m[2] = m[2].substring( 1, m[2].length - 1 );
+
+        m[2] = this.dialect.inline.__call__.call( this, m[2], /\\/ )[0];
+
+        var attrs = { alt: m[1], href: m[2] || "" };
+        if ( m[4] !== undefined)
+          attrs.title = m[4];
+
+        return [ m[0].length, [ "img", attrs ] ];
+      }
+
+      // ![Alt text][id]
+      m = text.match( /^!\[(.*?)\][ \t]*\[(.*?)\]/ );
+
+      if ( m ) {
+        // We can't check if the reference is known here as it likely wont be
+        // found till after. Check it in md tree->hmtl tree conversion
+        return [ m[0].length, [ "img_ref", { alt: m[1], ref: m[2].toLowerCase(), original: m[0] } ] ];
+      }
+
+      // Just consume the '!['
+      return [ 2, "![" ];
+    },
+
+    "[": function link( text ) {
+
+      var orig = String(text);
+      // Inline content is possible inside `link text`
+      var res = Markdown.DialectHelpers.inline_until_char.call( this, text.substr(1), ']' );
+
+      // No closing ']' found. Just consume the [
+      if ( !res ) return [ 1, '[' ];
+
+      var consumed = 1 + res[ 0 ],
+          children = res[ 1 ],
+          link,
+          attrs;
+
+      // At this point the first [...] has been parsed. See what follows to find
+      // out which kind of link we are (reference or direct url)
+      text = text.substr( consumed );
+
+      // [link text](/path/to/img.jpg "Optional title")
+      //                 1            2       3         <--- captures
+      // This will capture up to the last paren in the block. We then pull
+      // back based on if there a matching ones in the url
+      //    ([here](/url/(test))
+      // The parens have to be balanced
+      var m = text.match( /^\s*\([ \t]*(\S+)(?:[ \t]+(["'])(.*?)\2)?[ \t]*\)/ );
+      if ( m ) {
+        var url = m[1];
+        consumed += m[0].length;
+
+        if ( url && url[0] == '<' && url[url.length-1] == '>' )
+          url = url.substring( 1, url.length - 1 );
+
+        // If there is a title we don't have to worry about parens in the url
+        if ( !m[3] ) {
+          var open_parens = 1; // One open that isn't in the capture
+          for (var len = 0; len < url.length; len++) {
+            switch ( url[len] ) {
+            case '(':
+              open_parens++;
+              break;
+            case ')':
+              if ( --open_parens == 0) {
+                consumed -= url.length - len;
+                url = url.substring(0, len);
+              }
+              break;
+            }
+          }
+        }
+
+        // Process escapes only
+        url = this.dialect.inline.__call__.call( this, url, /\\/ )[0];
+
+        attrs = { href: url || "" };
+        if ( m[3] !== undefined)
+          attrs.title = m[3];
+
+        link = [ "link", attrs ].concat( children );
+        return [ consumed, link ];
+      }
+
+      // [Alt text][id]
+      // [Alt text] [id]
+      m = text.match( /^\s*\[(.*?)\]/ );
+
+      if ( m ) {
+
+        consumed += m[ 0 ].length;
+
+        // [links][] uses links as its reference
+        attrs = { ref: ( m[ 1 ] || String(children) ).toLowerCase(),  original: orig.substr( 0, consumed ) };
+
+        link = [ "link_ref", attrs ].concat( children );
+
+        // We can't check if the reference is known here as it likely wont be
+        // found till after. Check it in md tree->hmtl tree conversion.
+        // Store the original so that conversion can revert if the ref isn't found.
+        return [ consumed, link ];
+      }
+
+      // [id]
+      // Only if id is plain (no formatting.)
+      if ( children.length == 1 && typeof children[0] == "string" ) {
+
+        attrs = { ref: children[0].toLowerCase(),  original: orig.substr( 0, consumed ) };
+        link = [ "link_ref", attrs, children[0] ];
+        return [ consumed, link ];
+      }
+
+      // Just consume the '['
+      return [ 1, "[" ];
+    },
+
+
+    "<": function autoLink( text ) {
+      var m;
+
+      if ( ( m = text.match( /^<(?:((https?|ftp|mailto):[^>]+)|(.*?@.*?\.[a-zA-Z]+))>/ ) ) != null ) {
+        if ( m[3] ) {
+          return [ m[0].length, [ "link", { href: "mailto:"; + m[3] }, m[3] ] ];
+
+        }
+        else if ( m[2] == "mailto" ) {
+          return [ m[0].length, [ "link", { href: m[1] }, m[1].substr("mailto:".length ) ] ];
+        }
+        else
+          return [ m[0].length, [ "link", { href: m[1] }, m[1] ] ];
+      }
+
+      return [ 1, "<" ];
+    },
+
+    "`": function inlineCode( text ) {
+      // Inline code block. as many backticks as you like to start it
+      // Always skip over the opening ticks.
+      var m = text.match( /(`+)(([\s\S]*?)\1)/ );
+
+      if ( m && m[2] )
+        return [ m[1].length + m[2].length, [ "inlinecode", m[3] ] ];
+      else {
+        // TODO: No matching end code found - warn!
+        return [ 1, "`" ];
+      }
+    },
+
+    "  \n": function lineBreak( text ) {
+      return [ 3, [ "linebreak" ] ];
+    }
+
+};
+
+// Meta Helper/generator method for em and strong handling
+function strong_em( tag, md ) {
+
+  var state_slot = tag + "_state",
+      other_slot = tag == "strong" ? "em_state" : "strong_state";
+
+  function CloseTag(len) {
+    this.len_after = len;
+    this.name = "close_" + md;
+  }
+
+  return function ( text, orig_match ) {
+
+    if (this[state_slot][0] == md) {
+      // Most recent em is of this type
+      //D:this.debug("closing", md);
+      this[state_slot].shift();
+
+      // "Consume" everything to go back to the recrusion in the else-block below
+      return[ text.length, new CloseTag(text.length-md.length) ];
+    }
+    else {
+      // Store a clone of the em/strong states
+      var other = this[other_slot].slice(),
+          state = this[state_slot].slice();
+
+      this[state_slot].unshift(md);
+
+      //D:this.debug_indent += "  ";
+
+      // Recurse
+      var res = this.processInline( text.substr( md.length ) );
+      //D:this.debug_indent = this.debug_indent.substr(2);
+
+      var last = res[res.length - 1];
+
+      //D:this.debug("processInline from", tag + ": ", uneval( res ) );
+
+      var check = this[state_slot].shift();
+      if (last instanceof CloseTag) {
+        res.pop();
+        // We matched! Huzzah.
+        var consumed = text.length - last.len_after;
+        return [ consumed, [ tag ].concat(res) ];
+      }
+      else {
+        // Restore the state of the other kind. We might have mistakenly closed it.
+        this[other_slot] = other;
+        this[state_slot] = state;
+
+        // We can't reuse the processed result as it could have wrong parsing contexts in it.
+        return [ md.length, md ];
+      }
+    }
+  }; // End returned function
+}
+
+Markdown.dialects.Gruber.inline["**"] = strong_em("strong", "**");
+Markdown.dialects.Gruber.inline["__"] = strong_em("strong", "__");
+Markdown.dialects.Gruber.inline["*"]  = strong_em("em", "*");
+Markdown.dialects.Gruber.inline["_"]  = strong_em("em", "_");
+
+
+// Build default order from insertion order.
+Markdown.buildBlockOrder = function(d) {
+  var ord = [];
+  for ( var i in d ) {
+    if ( i == "__order__" || i == "__call__" ) continue;
+    ord.push( i );
+  }
+  d.__order__ = ord;
+};
+
+// Build patterns for inline matcher
+Markdown.buildInlinePatterns = function(d) {
+  var patterns = [];
+
+  for ( var i in d ) {
+    // __foo__ is reserved and not a pattern
+    if ( i.match( /^__.*__$/) ) continue;
+    var l = i.replace( /([\\.*+?|()\[\]{}])/g, "\\$1" )
+             .replace( /\n/, "\\n" );
+    patterns.push( i.length == 1 ? l : "(?:" + l + ")" );
+  }
+
+  patterns = patterns.join("|");
+  d.__patterns__ = patterns;
+  //print("patterns:", uneval( patterns ) );
+
+  var fn = d.__call__;
+  d.__call__ = function(text, pattern) {
+    if (pattern != undefined) {
+      return fn.call(this, text, pattern);
+    }
+    else
+    {
+      return fn.call(this, text, patterns);
+    }
+  };
+};
+
+Markdown.DialectHelpers = {};
+Markdown.DialectHelpers.inline_until_char = function( text, want ) {
+  var consumed = 0,
+      nodes = [];
+
+  while ( true ) {
+    if ( text[ consumed ] == want ) {
+      // Found the character we were looking for
+      consumed++;
+      return [ consumed, nodes ];
+    }
+
+    if ( consumed >= text.length ) {
+      // No closing char found. Abort.
+      return null;
+    }
+
+    res = this.dialect.inline.__oneElement__.call(this, text.substr( consumed ) );
+    consumed += res[ 0 ];
+    // Add any returned nodes.
+    nodes.push.apply( nodes, res.slice( 1 ) );
+  }
+}
+
+// Helper function to make sub-classing a dialect easier
+Markdown.subclassDialect = function( d ) {
+  function Block() {}
+  Block.prototype = d.block;
+  function Inline() {}
+  Inline.prototype = d.inline;
+
+  return { block: new Block(), inline: new Inline() };
+};
+
+Markdown.buildBlockOrder ( Markdown.dialects.Gruber.block );
+Markdown.buildInlinePatterns( Markdown.dialects.Gruber.inline );
+
+Markdown.dialects.Maruku = Markdown.subclassDialect( Markdown.dialects.Gruber );
+
+Markdown.dialects.Maruku.processMetaHash = function processMetaHash( meta_string ) {
+  var meta = split_meta_hash( meta_string ),
+      attr = {};
+
+  for ( var i = 0; i < meta.length; ++i ) {
+    // id: #foo
+    if ( /^#/.test( meta[ i ] ) ) {
+      attr.id = meta[ i ].substring( 1 );
+    }
+    // class: .foo
+    else if ( /^\./.test( meta[ i ] ) ) {
+      // if class already exists, append the new one
+      if ( attr['class'] ) {
+        attr['class'] = attr['class'] + meta[ i ].replace( /./, " " );
+      }
+      else {
+        attr['class'] = meta[ i ].substring( 1 );
+      }
+    }
+    // attribute: foo=bar
+    else if ( /\=/.test( meta[ i ] ) ) {
+      var s = meta[ i ].split( /\=/ );
+      attr[ s[ 0 ] ] = s[ 1 ];
+    }
+  }
+
+  return attr;
+}
+
+function split_meta_hash( meta_string ) {
+  var meta = meta_string.split( "" ),
+      parts = [ "" ],
+      in_quotes = false;
+
+  while ( meta.length ) {
+    var letter = meta.shift();
+    switch ( letter ) {
+      case " " :
+        // if we're in a quoted section, keep it
+        if ( in_quotes ) {
+          parts[ parts.length - 1 ] += letter;
+        }
+        // otherwise make a new part
+        else {
+          parts.push( "" );
+        }
+        break;
+      case "'" :
+      case '"' :
+        // reverse the quotes and move straight on
+        in_quotes = !in_quotes;
+        break;
+      case "\\" :
+        // shift off the next letter to be used straight away.
+        // it was escaped so we'll keep it whatever it is
+        letter = meta.shift();
+      default :
+        parts[ parts.length - 1 ] += letter;
+        break;
+    }
+  }
+
+  return parts;
+}
+
+Markdown.dialects.Maruku.block.document_meta = function document_meta( block, next ) {
+  // we're only interested in the first block
+  if ( block.lineNumber > 1 ) return undefined;
+
+  // document_meta blocks consist of one or more lines of `Key: Value\n`
+  if ( ! block.match( /^(?:\w+:.*\n)*\w+:.*$/ ) ) return undefined;
+
+  // make an attribute node if it doesn't exist
+  if ( !extract_attr( this.tree ) ) {
+    this.tree.splice( 1, 0, {} );
+  }
+
+  var pairs = block.split( /\n/ );
+  for ( p in pairs ) {
+    var m = pairs[ p ].match( /(\w+):\s*(.*)$/ ),
+        key = m[ 1 ].toLowerCase(),
+        value = m[ 2 ];
+
+    this.tree[ 1 ][ key ] = value;
+  }
+
+  // document_meta produces no content!
+  return [];
+};
+
+Markdown.dialects.Maruku.block.block_meta = function block_meta( block, next ) {
+  // check if the last line of the block is an meta hash
+  var m = block.match( /(^|\n) {0,3}\{:\s*((?:\\\}|[^\}])*)\s*\}$/ );
+  if ( !m ) return undefined;
+
+  // process the meta hash
+  var attr = this.dialect.processMetaHash( m[ 2 ] );
+
+  var hash;
+
+  // if we matched ^ then we need to apply meta to the previous block
+  if ( m[ 1 ] === "" ) {
+    var node = this.tree[ this.tree.length - 1 ];
+    hash = extract_attr( node );
+
+    // if the node is a string (rather than JsonML), bail
+    if ( typeof node === "string" ) return undefined;
+
+    // create the attribute hash if it doesn't exist
+    if ( !hash ) {
+      hash = {};
+      node.splice( 1, 0, hash );
+    }
+
+    // add the attributes in
+    for ( a in attr ) {
+      hash[ a ] = attr[ a ];
+    }
+
+    // return nothing so the meta hash is removed
+    return [];
+  }
+
+  // pull the meta hash off the block and process what's left
+  var b = block.replace( /\n.*$/, "" ),
+      result = this.processBlock( b, [] );
+
+  // get or make the attributes hash
+  hash = extract_attr( result[ 0 ] );
+  if ( !hash ) {
+    hash = {};
+    result[ 0 ].splice( 1, 0, hash );
+  }
+
+  // attach the attributes to the block
+  for ( a in attr ) {
+    hash[ a ] = attr[ a ];
+  }
+
+  return result;
+};
+
+Markdown.dialects.Maruku.block.definition_list = function definition_list( block, next ) {
+  // one or more terms followed by one or more definitions, in a single block
+  var tight = /^((?:[^\s:].*\n)+):\s+([\s\S]+)$/,
+      list = [ "dl" ],
+      i;
+
+  // see if we're dealing with a tight or loose block
+  if ( ( m = block.match( tight ) ) ) {
+    // pull subsequent tight DL blocks out of `next`
+    var blocks = [ block ];
+    while ( next.length && tight.exec( next[ 0 ] ) ) {
+      blocks.push( next.shift() );
+    }
+
+    for ( var b = 0; b < blocks.length; ++b ) {
+      var m = blocks[ b ].match( tight ),
+          terms = m[ 1 ].replace( /\n$/, "" ).split( /\n/ ),
+          defns = m[ 2 ].split( /\n:\s+/ );
+
+      // print( uneval( m ) );
+
+      for ( i = 0; i < terms.length; ++i ) {
+        list.push( [ "dt", terms[ i ] ] );
+      }
+
+      for ( i = 0; i < defns.length; ++i ) {
+        // run inline processing over the definition
+        list.push( [ "dd" ].concat( this.processInline( defns[ i ].replace( /(\n)\s+/, "$1" ) ) ) );
+      }
+    }
+  }
+  else {
+    return undefined;
+  }
+
+  return [ list ];
+};
+
+Markdown.dialects.Maruku.inline[ "{:" ] = function inline_meta( text, matches, out ) {
+  if ( !out.length ) {
+    return [ 2, "{:" ];
+  }
+
+  // get the preceeding element
+  var before = out[ out.length - 1 ];
+
+  if ( typeof before === "string" ) {
+    return [ 2, "{:" ];
+  }
+
+  // match a meta hash
+  var m = text.match( /^\{:\s*((?:\\\}|[^\}])*)\s*\}/ );
+
+  // no match, false alarm
+  if ( !m ) {
+    return [ 2, "{:" ];
+  }
+
+  // attach the attributes to the preceeding element
+  var meta = this.dialect.processMetaHash( m[ 1 ] ),
+      attr = extract_attr( before );
+
+  if ( !attr ) {
+    attr = {};
+    before.splice( 1, 0, attr );
+  }
+
+  for ( var k in meta ) {
+    attr[ k ] = meta[ k ];
+  }
+
+  // cut out the string and replace it with nothing
+  return [ m[ 0 ].length, "" ];
+};
+
+Markdown.buildBlockOrder ( Markdown.dialects.Maruku.block );
+Markdown.buildInlinePatterns( Markdown.dialects.Maruku.inline );
+
+var isArray = Array.isArray || function(obj) {
+  return Object.prototype.toString.call(obj) == '[object Array]';
+};
+
+var forEach;
+// Don't mess with Array.prototype. Its not friendly
+if ( Array.prototype.forEach ) {
+  forEach = function( arr, cb, thisp ) {
+    return arr.forEach( cb, thisp );
+  };
+}
+else {
+  forEach = function(arr, cb, thisp) {
+    for (var i = 0; i < arr.length; i++) {
+      cb.call(thisp || arr, arr[i], i, arr);
+    }
+  }
+}
+
+function extract_attr( jsonml ) {
+  return isArray(jsonml)
+      && jsonml.length > 1
+      && typeof jsonml[ 1 ] === "object"
+      && !( isArray(jsonml[ 1 ]) )
+      ? jsonml[ 1 ]
+      : undefined;
+}
+
+
+
+/**
+ *  renderJsonML( jsonml[, options] ) -> String
+ *  - jsonml (Array): JsonML array to render to XML
+ *  - options (Object): options
+ *
+ *  Converts the given JsonML into well-formed XML.
+ *
+ *  The options currently understood are:
+ *
+ *  - root (Boolean): wether or not the root node should be included in the
+ *    output, or just its children. The default `false` is to not include the
+ *    root itself.
+ */
+expose.renderJsonML = function( jsonml, options ) {
+  options = options || {};
+  // include the root element in the rendered output?
+  options.root = options.root || false;
+
+  var content = [];
+
+  if ( options.root ) {
+    content.push( render_tree( jsonml ) );
+  }
+  else {
+    jsonml.shift(); // get rid of the tag
+    if ( jsonml.length && typeof jsonml[ 0 ] === "object" && !( jsonml[ 0 ] instanceof Array ) ) {
+      jsonml.shift(); // get rid of the attributes
+    }
+
+    while ( jsonml.length ) {
+      content.push( render_tree( jsonml.shift() ) );
+    }
+  }
+
+  return content.join( "\n\n" );
+};
+
+function escapeHTML( text ) {
+  return text.replace( /&/g, "&amp;" )
+             .replace( /</g, "&lt;" )
+             .replace( />/g, "&gt;" )
+             .replace( /"/g, "&quot;" )
+             .replace( /'/g, "&#39;" );
+}
+
+function render_tree( jsonml ) {
+  // basic case
+  if ( typeof jsonml === "string" ) {
+    return escapeHTML( jsonml );
+  }
+
+  var tag = jsonml.shift(),
+      attributes = {},
+      content = [];
+
+  if ( jsonml.length && typeof jsonml[ 0 ] === "object" && !( jsonml[ 0 ] instanceof Array ) ) {
+    attributes = jsonml.shift();
+  }
+
+  while ( jsonml.length ) {
+    content.push( arguments.callee( jsonml.shift() ) );
+  }
+
+  var tag_attrs = "";
+  for ( var a in attributes ) {
+    tag_attrs += " " + a + '="' + escapeHTML( attributes[ a ] ) + '"';
+  }
+
+  // be careful about adding whitespace here for inline elements
+  if ( tag == "img" || tag == "br" || tag == "hr" ) {
+    return "<"+ tag + tag_attrs + "/>";
+  }
+  else {
+    return "<"+ tag + tag_attrs + ">" + content.join( "" ) + "</" + tag + ">";
+  }
+}
+
+function convert_tree_to_html( tree, references, options ) {
+  var i;
+  options = options || {};
+
+  // shallow clone
+  var jsonml = tree.slice( 0 );
+
+  if (typeof options.preprocessTreeNode === "function") {
+      jsonml = options.preprocessTreeNode(jsonml, references);
+  }
+
+  // Clone attributes if they exist
+  var attrs = extract_attr( jsonml );
+  if ( attrs ) {
+    jsonml[ 1 ] = {};
+    for ( i in attrs ) {
+      jsonml[ 1 ][ i ] = attrs[ i ];
+    }
+    attrs = jsonml[ 1 ];
+  }
+
+  // basic case
+  if ( typeof jsonml === "string" ) {
+    return jsonml;
+  }
+
+  // convert this node
+  switch ( jsonml[ 0 ] ) {
+    case "header":
+      jsonml[ 0 ] = "h" + jsonml[ 1 ].level;
+      delete jsonml[ 1 ].level;
+      break;
+    case "bulletlist":
+      jsonml[ 0 ] = "ul";
+      break;
+    case "numberlist":
+      jsonml[ 0 ] = "ol";
+      break;
+    case "listitem":
+      jsonml[ 0 ] = "li";
+      break;
+    case "para":
+      jsonml[ 0 ] = "p";
+      break;
+    case "markdown":
+      jsonml[ 0 ] = "html";
+      if ( attrs ) delete attrs.references;
+      break;
+    case "code_block":
+      jsonml[ 0 ] = "pre";
+      i = attrs ? 2 : 1;
+      var code = [ "code" ];
+      code.push.apply( code, jsonml.splice( i ) );
+      jsonml[ i ] = code;
+      break;
+    case "inlinecode":
+      jsonml[ 0 ] = "code";
+      break;
+    case "img":
+      jsonml[ 1 ].src = jsonml[ 1 ].href;
+      delete jsonml[ 1 ].href;
+      break;
+    case "linebreak":
+      jsonml[ 0 ] = "br";
+    break;
+    case "link":
+      jsonml[ 0 ] = "a";
+      break;
+    case "link_ref":
+      jsonml[ 0 ] = "a";
+
+      // grab this ref and clean up the attribute node
+      var ref = references[ attrs.ref ];
+
+      // if the reference exists, make the link
+      if ( ref ) {
+        delete attrs.ref;
+
+        // add in the href and title, if present
+        attrs.href = ref.href;
+        if ( ref.title ) {
+          attrs.title = ref.title;
+        }
+
+        // get rid of the unneeded original text
+        delete attrs.original;
+      }
+      // the reference doesn't exist, so revert to plain text
+      else {
+        return attrs.original;
+      }
+      break;
+    case "img_ref":
+      jsonml[ 0 ] = "img";
+
+      // grab this ref and clean up the attribute node
+      var ref = references[ attrs.ref ];
+
+      // if the reference exists, make the link
+      if ( ref ) {
+        delete attrs.ref;
+
+        // add in the href and title, if present
+        attrs.src = ref.href;
+        if ( ref.title ) {
+          attrs.title = ref.title;
+        }
+
+        // get rid of the unneeded original text
+        delete attrs.original;
+      }
+      // the reference doesn't exist, so revert to plain text
+      else {
+        return attrs.original;
+      }
+      break;
+  }
+
+  // convert all the children
+  i = 1;
+
+  // deal with the attribute node, if it exists
+  if ( attrs ) {
+    // if there are keys, skip over it
+    for ( var key in jsonml[ 1 ] ) {
+      i = 2;
+    }
+    // if there aren't, remove it
+    if ( i === 1 ) {
+      jsonml.splice( i, 1 );
+    }
+  }
+
+  for ( ; i < jsonml.length; ++i ) {
+    jsonml[ i ] = arguments.callee( jsonml[ i ], references, options );
+  }
+
+  return jsonml;
+}
+
+
+// merges adjacent text nodes into a single node
+function merge_text_nodes( jsonml ) {
+  // skip the tag name and attribute hash
+  var i = extract_attr( jsonml ) ? 2 : 1;
+
+  while ( i < jsonml.length ) {
+    // if it's a string check the next item too
+    if ( typeof jsonml[ i ] === "string" ) {
+      if ( i + 1 < jsonml.length && typeof jsonml[ i + 1 ] === "string" ) {
+        // merge the second string into the first and remove it
+        jsonml[ i ] += jsonml.splice( i + 1, 1 )[ 0 ];
+      }
+      else {
+        ++i;
+      }
+    }
+    // if it's not a string recurse
+    else {
+      arguments.callee( jsonml[ i ] );
+      ++i;
+    }
+  }
+}
+
+} )( (function() {
+  if ( typeof Y !== "undefined" ) {
+    return Y.namespace('Markdown');
+  }
+  else if ( typeof exports === "undefined" ) {
+    window.markdown = {};
+    return window.markdown;
+  }
+  else {
+    return exports;
+  }
+} )() );
+
+
+}, 'gallery-2012.07.18-13-22' );

=== added file 'app/assets/javascripts/gallery-timer-debug.js'
--- app/assets/javascripts/gallery-timer-debug.js	1970-01-01 00:00:00 +0000
+++ app/assets/javascripts/gallery-timer-debug.js	2012-12-21 02:37:21 +0000
@@ -0,0 +1,559 @@
+YUI.add('gallery-timer', function(Y) {
+
+/**
+* Losely modeled after AS3's Timer class. Provides a simple interface start,
+*   pause, resume, and stop a defined timer set with a custom callback method.
+* @module timer
+* @author Anthony Pipkin
+* @version 1.2.0
+*/
+/**
+* Losely modeled after AS3's Timer class. Provides a simple interface start,
+*   pause, resume, and stop a defined timer set with a custom callback method.
+* @class Y.Timer
+* @extends Y.Base
+*/
+
+// Local constants
+var STATUS_RUNNING = 'running',
+    STATUS_PAUSED  = 'paused',
+    STATUS_STOPPED = 'stopped',
+
+    EVENT_START  = 'start',
+    EVENT_STOP   = 'stop',
+    EVENT_PAUSE  = 'pause',
+    EVENT_RESUME = 'resume',
+    EVENT_TIMER  = 'timer';
+
+
+Y.Timer = Y.Base.create('timer', Y.Base, [] , {
+
+    /**
+    * @event start
+    * @description The timer has started
+    * @param {Event.Facade} event An Event Facade object
+    * @type {Event.Custom}
+    */
+
+    /**
+    * @event stop
+    * @description The timer has stopped
+    * @param {Event.Facade} event An Event Facade object
+    * @type {Event.Custom}
+    */
+
+    /**
+    * @event pause
+    * @description The timer has paused
+    * @param {Event.Facade} event An Event Facade object
+    * @type {Event.Custom}
+    */
+
+    /**
+    * @event resume
+    * @description The timer has resumed
+    * @param {Event.Facade} event An Event Facade object
+    * @type {Event.Custom}
+    */
+
+    /**
+    * Fires at every interval of Y.Timer
+    * @event timer
+    * @description The timer has reached a reached zero
+    * @param {Event.Facade} event An Event Facade object
+    * @type {Event.Custom}
+    */
+
+    //////   P U B L I C   //////
+
+    /**
+    * Initializer lifecycle implementation for the Timer class.
+    * Publishes events and subscribes
+    * to update after the status is changed.
+    *
+    * @method initializer
+    * @protected
+    * @param config {Object} Configuration object literal for
+    *     the Timer
+    * @since 1.0.0
+    */
+    initializer : function(config){
+        this.after('statusChange',this._afterStatusChange,this);
+        this.publish(EVENT_START ,  { defaultFn : this._defStartFn });
+        this.publish(EVENT_STOP ,   { defaultFn : this._defStopFn });
+        this.publish(EVENT_PAUSE ,  { defaultFn : this._defPauseFn });
+        this.publish(EVENT_RESUME , { defaultFn : this._defResumeFn });
+    },
+
+    /**
+    * Interface method to start the Timer. Fires timer:start
+    *
+    * @method start
+    * @public
+    * @since 1.0.0
+    */
+    start : function() {
+        Y.log('Timer::start','info');
+        if(this.get('status') !== STATUS_RUNNING) {
+            this.fire(EVENT_START);
+        }
+
+        return this;
+    },
+
+    /**
+    * Interface method to stop the Timer. Fires timer:stop
+    *
+    * @method stop
+    * @public
+    * @since 1.0.0
+    */
+    stop : function() {
+        Y.log('Timer::stop','info');
+        if(this.get('status') === STATUS_RUNNING) {
+            this.fire(EVENT_STOP);
+        }
+
+        return this;
+    },
+
+    /**
+    * Interface method to pause the Timer. Fires timer:pause
+    *
+    * @method pause
+    * @public
+    * @since 1.0.0
+    */
+    pause : function() {
+        Y.log('Timer::pause','info');
+        if(this.get('status') === STATUS_RUNNING) {
+            this.fire(EVENT_PAUSE);
+        }
+
+        return this;
+    },
+
+    /**
+    * Interface method to resume the Timer. Fires timer:resume
+    *
+    * @method resume
+    * @public
+    * @since 1.0.0
+    */
+    resume : function() {
+        Y.log('Timer::resume','info');
+        if(this.get('status') === STATUS_PAUSED) {
+            this.fire(EVENT_RESUME);
+        }
+
+        return this;
+    },
+
+
+    //////   P R O T E C T E D   //////
+
+    /**
+    * Internal timer
+    * 
+    * @property {Y.later} _timerObj
+    * @protected
+    * @since 1.0.0
+    */
+    _timerObj : null,
+
+    /**
+    * Resume length
+    *
+    * @property _remainingLength
+    * @protected
+    * @since 1.2.0
+    */
+    _remainingLength: null,
+
+    /**
+    * Checks to see if a new Timer is to be created. If so, calls
+    * _timer() after a the schedule number of milliseconds. Sets
+    * Timer pointer to the new Timer id. Sets start to the current
+    * timestamp.
+    *
+    * @method _makeTimer
+    * @protected
+    * @since 1.0.0
+    */
+    _makeTimer : function() {
+        Y.log('Timer::_makeTimer','info');
+        var timerObj = this._timerObj,
+        repeat = this.get('repeatCount');
+
+        if (timerObj) {
+            timerObj.cancel();
+            timerObj = null;
+            this._timerObj = null;
+        }
+
+        if(repeat === 0 || repeat > this.get('step')) {
+            timerObj = Y.later(this._remainingLength, this, this._timer);
+        }
+
+        this._timerObj = timerObj;
+        this.set('timer', timerObj);
+        this.set('start', (new Date()).getTime());
+        this.set('stop', this.get('start'));
+    },
+
+    /**
+    * Resets the Timer.
+    *
+    * @method _destroyTimer
+    * @protected
+    * @since 1.0.0
+    */
+    _destroyTimer : function() {
+        Y.log('Timer::_destroyTimer','info');
+        var timerObj = this._timerObj;
+
+        if (timerObj) {
+            timerObj.cancel();
+            timerObj = null;
+            this._timerObj = null;
+        }
+
+        this.set('timer', null);
+        this.set('stop', (new Date()).getTime());
+        this.set('step', 0);
+
+        this._remainingLength = this._remainingLength - (this.get('stop') - this.get('start'));
+
+    },
+
+    /**
+    * Increments the step and either stops or starts a new Timer
+    * interval. Fires the timer callback method.
+    *
+    * @method _timer
+    * @protected
+    * @since 1.0.0
+    */
+    _timer : function() {
+        Y.log('Timer::_timer','info');
+        this.fire(EVENT_TIMER);
+
+        var step = this.get('step'),
+        repeat = this.get('repeatCount');
+
+        this.set('step', ++step);
+
+        if(repeat > 0 && repeat <= step) { // repeat at 0 is infinite loop
+            this._remainingLength = 0;
+            this.stop();
+        }else{
+            this._remainingLength = this.get('length');
+            this._makeTimer();
+        }
+
+        this._executeCallback();
+    },
+
+    /**
+    * Internal status change event callback. Allows status changes
+    * to fire start(), pause(), resume(), and stop() automatically.
+    *
+    * @method _statusChanged
+    * @protcted
+    * @since 1.0.0
+    */
+    _afterStatusChange : function(e){
+        Y.log('Timer::_afterStatusChange','info');
+        switch(e.newVal) {
+            case STATUS_RUNNING:
+                this._makeTimer();
+                break;
+            case STATUS_STOPPED: // overflow intentional
+            case STATUS_PAUSED:
+                this._destroyTimer();
+                break;
+        }
+    },
+
+    /**
+    * Default function for start event.
+    *
+    * @method _defStartFn
+    * @protected
+    * @since 1.0.0
+    */
+    _defStartFn : function(e) {
+        Y.log('Timer::_defStartFn','info');
+        var delay = this.get('startDelay');
+
+        this._remainingLength = this.get('length');
+
+        if(delay > 0) {
+            Y.later(delay, this, function(){
+                this.set('status', STATUS_RUNNING);
+            });
+        }else{
+            this.set('status', STATUS_RUNNING);
+        }
+    },
+
+    /**
+    * Default function for stop event.
+    *
+    * @method _defStopFn
+    * @protected
+    * @since 1.0.0
+    */
+    _defStopFn : function(e) {
+        Y.log('Timer::_defStopFn','info');
+
+        this._remainingLength = 0;
+        this.set('status', STATUS_STOPPED);
+    },
+
+    /**
+    * Default function for pause event.
+    *
+    * @method _defPauseFn
+    * @protected
+    * @since 1.0.0
+    */
+    _defPauseFn : function(e) {
+        Y.log('Timer::_defPauseFn','info');
+        this.set('status', STATUS_PAUSED);
+    },
+
+    /**
+    * Default function for resume event. Starts timer with
+    * remaining time left after Timer was paused.
+    *
+    * @method _defResumeFn
+    * @protected
+    * @since 1.0.0
+    */
+    _defResumeFn : function(e) {
+        Y.log('Timer::_defResumeFn','info');
+        this.set('status',STATUS_RUNNING);
+    },
+
+    /**
+    * Abstracted the repeatCount validator into the prototype to
+    * encourage class extension.
+    *
+    * @method _repeatCountValidator
+    * @protected
+    * @since 1.1.0
+    */
+    _repeatCountValidator : function(val) {
+        Y.log('Timer::_repeatCountValidator','info');
+        return (this.get('status') === STATUS_STOPPED);
+    },
+
+    /**
+    * Used to fire the internal callback
+    *
+    * @method _executeCallback
+    * @protected
+    * @since 1.1.0
+    */
+    _executeCallback : function() {
+        Y.log('Timer::_executeCallback','info');
+        var callback = this.get('callback');
+        if (Y.Lang.isFunction(callback)) {
+            (this.get('callback'))();
+        }
+    },
+
+    /**
+    * Returns the time from `now` if the timer is running and returns remaining
+    *   time from `stop` if the timer has stopped.
+    * @method _remainingGetter
+    * @protected
+    * @since 1.2.0
+    */
+    _remainingGetter: function(){
+        Y.log('Timer::_remainingGetter', 'info');
+        var status = this.get('status'),
+            length = this._remainingLength,
+            maxTime = (new Date()).getTime();
+
+        if (status === STATUS_STOPPED) {
+            return 0;
+        } else if (status === STATUS_PAUSED) {
+            return length;
+        } else {
+            return length - ( maxTime - this.get('start') );
+        }
+    }
+
+},{
+    /**
+    * Static property used to define the default attribute
+    * configuration for the Timer.
+    *
+    * @property ATTRS
+    * @type Object
+    * @static
+    */
+    ATTRS : {
+
+        /**
+        * @description The callback method that fires when the
+        * timer interval is reached.
+        *
+        * @attribute callback
+        * @type function
+        * @since 1.0.0
+        */
+        callback : {
+            value : null,
+            validator : Y.Lang.isFunction
+        },
+
+        /**
+        * Time in milliseconds between intervals
+        *
+        * @attribute length
+        * @type Number
+        * @since 1.0.0
+        */
+        length : {
+            value : 3000,
+            setter : function(val) {
+                return parseInt(val,10);
+            }
+        },
+
+        /**
+        * Get remaining milliseconds
+        *
+        * @attribute remaining
+        * @type Number
+        * @since 1.2.0
+        */
+        remaining: {
+            readonly: true,
+            getter: '_remainingGetter'
+        },
+
+        /**
+        * Number of times the Timer should fire before it stops
+        *  - 1.1.0 - added lazyAdd false to prevent starting from
+        *            overriding the validator
+        * @attribute repeatCount
+        * @type Number
+        * @since 1.1.0
+        */
+        repeatCount : {
+            validator : 'repeatCountValidator',
+            setter : function(val) {
+                return parseInt(val,10);
+            },
+            value : 0,
+            lazyAdd : false
+        },
+
+        /**
+        * Timestamp Timer was started
+        *
+        * @attribute start
+        * @type Boolean
+        * @since 1.0.0
+        */
+        start : {
+            readonly : true
+        },
+
+        /**
+        * Time in ms to wait until starting after start() has been called
+        * @attribute startDelay
+        * @type Number
+        * @since 1.1.0
+        */
+        startDelay : {
+            value : 0
+        },
+
+        /**
+        * Timer status
+        *  - 1.1.0 - Changed from state to status. state was left
+        *            from legacy code
+        * @attribute status
+        * @default STATUS_STOPPED
+        * @type String
+        * @since 1.1.0
+        */
+        status : {
+            value : STATUS_STOPPED,
+            readonly : true
+        },
+
+        /**
+        * Number of times the Timer has looped
+        *
+        * @attribute step
+        * @type Boolean
+        * @since 1.0.0
+        */
+        step : { // number of intervals passed
+            value : 0,
+            readonly : true
+        },
+
+        /**
+        * Timestamp Timer was stoped or paused
+        *
+        * @attribute stop
+        * @type Boolean
+        * @since 1.0.0
+        */
+        stop : {
+            readonly : true
+        },
+
+        /**
+        * Timer id to used during stop()
+        *
+        * @attribute timer
+        * @type Number
+        * @since 1.0.0
+        */
+        timer : {
+            readonly : true
+        }
+    },
+
+    /**
+    * Static property provides public access to registered timer
+    * status strings
+    *
+    * @property Timer.STATUS
+    * @type Object
+    * @static
+    */
+    STATUS : {
+        RUNNING : STATUS_RUNNING,
+        PAUSED  : STATUS_PAUSED,
+        STOPPED : STATUS_STOPPED
+    },
+
+    /**
+    * Static property provides public access to registered timer
+    * event strings
+    *
+    * @property Timer.EVENTS
+    * @type Object
+    * @static
+    */
+    EVENTS : {
+        START  : EVENT_START,
+        STOP   : EVENT_STOP,
+        PAUSE  : EVENT_PAUSE,
+        RESUME : EVENT_RESUME,
+        TIMER  : EVENT_TIMER
+    }
+});
+
+
+
+}, 'gallery-2012.07.25-21-36' ,{requires:['base-build','event-custom']});

=== added file 'app/assets/javascripts/gallery-timer.js'
--- app/assets/javascripts/gallery-timer.js	1970-01-01 00:00:00 +0000
+++ app/assets/javascripts/gallery-timer.js	2012-12-21 02:37:21 +0000
@@ -0,0 +1,544 @@
+YUI.add('gallery-timer', function(Y) {
+
+/**
+* Losely modeled after AS3's Timer class. Provides a simple interface start,
+*   pause, resume, and stop a defined timer set with a custom callback method.
+* @module timer
+* @author Anthony Pipkin
+* @version 1.2.0
+*/
+/**
+* Losely modeled after AS3's Timer class. Provides a simple interface start,
+*   pause, resume, and stop a defined timer set with a custom callback method.
+* @class Y.Timer
+* @extends Y.Base
+*/
+
+// Local constants
+var STATUS_RUNNING = 'running',
+    STATUS_PAUSED  = 'paused',
+    STATUS_STOPPED = 'stopped',
+
+    EVENT_START  = 'start',
+    EVENT_STOP   = 'stop',
+    EVENT_PAUSE  = 'pause',
+    EVENT_RESUME = 'resume',
+    EVENT_TIMER  = 'timer';
+
+
+Y.Timer = Y.Base.create('timer', Y.Base, [] , {
+
+    /**
+    * @event start
+    * @description The timer has started
+    * @param {Event.Facade} event An Event Facade object
+    * @type {Event.Custom}
+    */
+
+    /**
+    * @event stop
+    * @description The timer has stopped
+    * @param {Event.Facade} event An Event Facade object
+    * @type {Event.Custom}
+    */
+
+    /**
+    * @event pause
+    * @description The timer has paused
+    * @param {Event.Facade} event An Event Facade object
+    * @type {Event.Custom}
+    */
+
+    /**
+    * @event resume
+    * @description The timer has resumed
+    * @param {Event.Facade} event An Event Facade object
+    * @type {Event.Custom}
+    */
+
+    /**
+    * Fires at every interval of Y.Timer
+    * @event timer
+    * @description The timer has reached a reached zero
+    * @param {Event.Facade} event An Event Facade object
+    * @type {Event.Custom}
+    */
+
+    //////   P U B L I C   //////
+
+    /**
+    * Initializer lifecycle implementation for the Timer class.
+    * Publishes events and subscribes
+    * to update after the status is changed.
+    *
+    * @method initializer
+    * @protected
+    * @param config {Object} Configuration object literal for
+    *     the Timer
+    * @since 1.0.0
+    */
+    initializer : function(config){
+        this.after('statusChange',this._afterStatusChange,this);
+        this.publish(EVENT_START ,  { defaultFn : this._defStartFn });
+        this.publish(EVENT_STOP ,   { defaultFn : this._defStopFn });
+        this.publish(EVENT_PAUSE ,  { defaultFn : this._defPauseFn });
+        this.publish(EVENT_RESUME , { defaultFn : this._defResumeFn });
+    },
+
+    /**
+    * Interface method to start the Timer. Fires timer:start
+    *
+    * @method start
+    * @public
+    * @since 1.0.0
+    */
+    start : function() {
+        if(this.get('status') !== STATUS_RUNNING) {
+            this.fire(EVENT_START);
+        }
+
+        return this;
+    },
+
+    /**
+    * Interface method to stop the Timer. Fires timer:stop
+    *
+    * @method stop
+    * @public
+    * @since 1.0.0
+    */
+    stop : function() {
+        if(this.get('status') === STATUS_RUNNING) {
+            this.fire(EVENT_STOP);
+        }
+
+        return this;
+    },
+
+    /**
+    * Interface method to pause the Timer. Fires timer:pause
+    *
+    * @method pause
+    * @public
+    * @since 1.0.0
+    */
+    pause : function() {
+        if(this.get('status') === STATUS_RUNNING) {
+            this.fire(EVENT_PAUSE);
+        }
+
+        return this;
+    },
+
+    /**
+    * Interface method to resume the Timer. Fires timer:resume
+    *
+    * @method resume
+    * @public
+    * @since 1.0.0
+    */
+    resume : function() {
+        if(this.get('status') === STATUS_PAUSED) {
+            this.fire(EVENT_RESUME);
+        }
+
+        return this;
+    },
+
+
+    //////   P R O T E C T E D   //////
+
+    /**
+    * Internal timer
+    * 
+    * @property {Y.later} _timerObj
+    * @protected
+    * @since 1.0.0
+    */
+    _timerObj : null,
+
+    /**
+    * Resume length
+    *
+    * @property _remainingLength
+    * @protected
+    * @since 1.2.0
+    */
+    _remainingLength: null,
+
+    /**
+    * Checks to see if a new Timer is to be created. If so, calls
+    * _timer() after a the schedule number of milliseconds. Sets
+    * Timer pointer to the new Timer id. Sets start to the current
+    * timestamp.
+    *
+    * @method _makeTimer
+    * @protected
+    * @since 1.0.0
+    */
+    _makeTimer : function() {
+        var timerObj = this._timerObj,
+        repeat = this.get('repeatCount');
+
+        if (timerObj) {
+            timerObj.cancel();
+            timerObj = null;
+            this._timerObj = null;
+        }
+
+        if(repeat === 0 || repeat > this.get('step')) {
+            timerObj = Y.later(this._remainingLength, this, this._timer);
+        }
+
+        this._timerObj = timerObj;
+        this.set('timer', timerObj);
+        this.set('start', (new Date()).getTime());
+        this.set('stop', this.get('start'));
+    },
+
+    /**
+    * Resets the Timer.
+    *
+    * @method _destroyTimer
+    * @protected
+    * @since 1.0.0
+    */
+    _destroyTimer : function() {
+        var timerObj = this._timerObj;
+
+        if (timerObj) {
+            timerObj.cancel();
+            timerObj = null;
+            this._timerObj = null;
+        }
+
+        this.set('timer', null);
+        this.set('stop', (new Date()).getTime());
+        this.set('step', 0);
+
+        this._remainingLength = this._remainingLength - (this.get('stop') - this.get('start'));
+
+    },
+
+    /**
+    * Increments the step and either stops or starts a new Timer
+    * interval. Fires the timer callback method.
+    *
+    * @method _timer
+    * @protected
+    * @since 1.0.0
+    */
+    _timer : function() {
+        this.fire(EVENT_TIMER);
+
+        var step = this.get('step'),
+        repeat = this.get('repeatCount');
+
+        this.set('step', ++step);
+
+        if(repeat > 0 && repeat <= step) { // repeat at 0 is infinite loop
+            this._remainingLength = 0;
+            this.stop();
+        }else{
+            this._remainingLength = this.get('length');
+            this._makeTimer();
+        }
+
+        this._executeCallback();
+    },
+
+    /**
+    * Internal status change event callback. Allows status changes
+    * to fire start(), pause(), resume(), and stop() automatically.
+    *
+    * @method _statusChanged
+    * @protcted
+    * @since 1.0.0
+    */
+    _afterStatusChange : function(e){
+        switch(e.newVal) {
+            case STATUS_RUNNING:
+                this._makeTimer();
+                break;
+            case STATUS_STOPPED: // overflow intentional
+            case STATUS_PAUSED:
+                this._destroyTimer();
+                break;
+        }
+    },
+
+    /**
+    * Default function for start event.
+    *
+    * @method _defStartFn
+    * @protected
+    * @since 1.0.0
+    */
+    _defStartFn : function(e) {
+        var delay = this.get('startDelay');
+
+        this._remainingLength = this.get('length');
+
+        if(delay > 0) {
+            Y.later(delay, this, function(){
+                this.set('status', STATUS_RUNNING);
+            });
+        }else{
+            this.set('status', STATUS_RUNNING);
+        }
+    },
+
+    /**
+    * Default function for stop event.
+    *
+    * @method _defStopFn
+    * @protected
+    * @since 1.0.0
+    */
+    _defStopFn : function(e) {
+
+        this._remainingLength = 0;
+        this.set('status', STATUS_STOPPED);
+    },
+
+    /**
+    * Default function for pause event.
+    *
+    * @method _defPauseFn
+    * @protected
+    * @since 1.0.0
+    */
+    _defPauseFn : function(e) {
+        this.set('status', STATUS_PAUSED);
+    },
+
+    /**
+    * Default function for resume event. Starts timer with
+    * remaining time left after Timer was paused.
+    *
+    * @method _defResumeFn
+    * @protected
+    * @since 1.0.0
+    */
+    _defResumeFn : function(e) {
+        this.set('status',STATUS_RUNNING);
+    },
+
+    /**
+    * Abstracted the repeatCount validator into the prototype to
+    * encourage class extension.
+    *
+    * @method _repeatCountValidator
+    * @protected
+    * @since 1.1.0
+    */
+    _repeatCountValidator : function(val) {
+        return (this.get('status') === STATUS_STOPPED);
+    },
+
+    /**
+    * Used to fire the internal callback
+    *
+    * @method _executeCallback
+    * @protected
+    * @since 1.1.0
+    */
+    _executeCallback : function() {
+        var callback = this.get('callback');
+        if (Y.Lang.isFunction(callback)) {
+            (this.get('callback'))();
+        }
+    },
+
+    /**
+    * Returns the time from `now` if the timer is running and returns remaining
+    *   time from `stop` if the timer has stopped.
+    * @method _remainingGetter
+    * @protected
+    * @since 1.2.0
+    */
+    _remainingGetter: function(){
+        var status = this.get('status'),
+            length = this._remainingLength,
+            maxTime = (new Date()).getTime();
+
+        if (status === STATUS_STOPPED) {
+            return 0;
+        } else if (status === STATUS_PAUSED) {
+            return length;
+        } else {
+            return length - ( maxTime - this.get('start') );
+        }
+    }
+
+},{
+    /**
+    * Static property used to define the default attribute
+    * configuration for the Timer.
+    *
+    * @property ATTRS
+    * @type Object
+    * @static
+    */
+    ATTRS : {
+
+        /**
+        * @description The callback method that fires when the
+        * timer interval is reached.
+        *
+        * @attribute callback
+        * @type function
+        * @since 1.0.0
+        */
+        callback : {
+            value : null,
+            validator : Y.Lang.isFunction
+        },
+
+        /**
+        * Time in milliseconds between intervals
+        *
+        * @attribute length
+        * @type Number
+        * @since 1.0.0
+        */
+        length : {
+            value : 3000,
+            setter : function(val) {
+                return parseInt(val,10);
+            }
+        },
+
+        /**
+        * Get remaining milliseconds
+        *
+        * @attribute remaining
+        * @type Number
+        * @since 1.2.0
+        */
+        remaining: {
+            readonly: true,
+            getter: '_remainingGetter'
+        },
+
+        /**
+        * Number of times the Timer should fire before it stops
+        *  - 1.1.0 - added lazyAdd false to prevent starting from
+        *            overriding the validator
+        * @attribute repeatCount
+        * @type Number
+        * @since 1.1.0
+        */
+        repeatCount : {
+            validator : 'repeatCountValidator',
+            setter : function(val) {
+                return parseInt(val,10);
+            },
+            value : 0,
+            lazyAdd : false
+        },
+
+        /**
+        * Timestamp Timer was started
+        *
+        * @attribute start
+        * @type Boolean
+        * @since 1.0.0
+        */
+        start : {
+            readonly : true
+        },
+
+        /**
+        * Time in ms to wait until starting after start() has been called
+        * @attribute startDelay
+        * @type Number
+        * @since 1.1.0
+        */
+        startDelay : {
+            value : 0
+        },
+
+        /**
+        * Timer status
+        *  - 1.1.0 - Changed from state to status. state was left
+        *            from legacy code
+        * @attribute status
+        * @default STATUS_STOPPED
+        * @type String
+        * @since 1.1.0
+        */
+        status : {
+            value : STATUS_STOPPED,
+            readonly : true
+        },
+
+        /**
+        * Number of times the Timer has looped
+        *
+        * @attribute step
+        * @type Boolean
+        * @since 1.0.0
+        */
+        step : { // number of intervals passed
+            value : 0,
+            readonly : true
+        },
+
+        /**
+        * Timestamp Timer was stoped or paused
+        *
+        * @attribute stop
+        * @type Boolean
+        * @since 1.0.0
+        */
+        stop : {
+            readonly : true
+        },
+
+        /**
+        * Timer id to used during stop()
+        *
+        * @attribute timer
+        * @type Number
+        * @since 1.0.0
+        */
+        timer : {
+            readonly : true
+        }
+    },
+
+    /**
+    * Static property provides public access to registered timer
+    * status strings
+    *
+    * @property Timer.STATUS
+    * @type Object
+    * @static
+    */
+    STATUS : {
+        RUNNING : STATUS_RUNNING,
+        PAUSED  : STATUS_PAUSED,
+        STOPPED : STATUS_STOPPED
+    },
+
+    /**
+    * Static property provides public access to registered timer
+    * event strings
+    *
+    * @property Timer.EVENTS
+    * @type Object
+    * @static
+    */
+    EVENTS : {
+        START  : EVENT_START,
+        STOP   : EVENT_STOP,
+        PAUSE  : EVENT_PAUSE,
+        RESUME : EVENT_RESUME,
+        TIMER  : EVENT_TIMER
+    }
+});
+
+
+
+}, 'gallery-2012.07.25-21-36' ,{requires:['base-build','event-custom']});

=== modified file 'app/modules-debug.js'
--- app/modules-debug.js	2012-12-06 17:46:42 +0000
+++ app/modules-debug.js	2012-12-21 02:37:21 +0000
@@ -12,11 +12,24 @@
   filter: 'debug',
   // Set "true" for verbose logging of YUI
   debug: false,
-
+  base: '/juju-ui/assets/javascripts/yui/',
   // Use Rollups
   combine: false,
 
   groups: {
+    gallery: {
+      modules: {
+        'gallery-ellipsis': {
+          fullpath: 'juju-ui/assets/javascripts/gallery-ellipsis-debug.js'
+        },
+        'gallery-markdown': {
+          fullpath: 'juju-ui/assets/javascripts/gallery-markdown-debug.js'
+        },
+        'gallery-timer': {
+          fullpath: 'juju-ui/assets/javascripts/gallery-timer-debug.js'
+        }
+      }
+    },
     d3: {
       modules: {
         'd3': {
@@ -65,14 +78,7 @@
         },
 
         'juju-topology': {
-          fullpath: '/juju-ui/views/topology/topology.js',
-          require: [
-            'juju-topology-mega',
-            'juju-topology-service',
-            'juju-topology-relation',
-            'juju-topology-panzoom',
-            'juju-topology-viewport'
-          ]
+          fullpath: '/juju-ui/views/topology/topology.js'
         },
         'juju-view-utils': {
           fullpath: '/juju-ui/views/utils.js'

=== modified file 'app/views/utils.js'
--- app/views/utils.js	2012-12-06 17:46:42 +0000
+++ app/views/utils.js	2012-12-21 02:37:21 +0000
@@ -117,7 +117,8 @@
           groupCollapsed: noop,
           time: noop,
           timeEnd: noop,
-          log: noop
+          log: noop,
+          debug: noop
         };
 
     if (winConsole === undefined) {

=== modified file 'bin/merge-files'
--- bin/merge-files	2012-12-17 15:15:14 +0000
+++ bin/merge-files	2012-12-21 02:37:21 +0000
@@ -47,11 +47,11 @@
      syspath.join(process.cwd(), 'app/config-debug.js')]);
 
   // templates.js is a generated file. It is not part of the app directory.
-  paths.push(syspath.join(process.cwd(), 'build/juju-ui/templates.js'));
+  paths.push(syspath.join(process.cwd(), 'build-shared/juju-ui/templates.js'));
   // XXX Why do we have to do this?  (bug 1090563).
   paths.push(syspath.join(process.cwd(), 'app/models/charm.js'));
 
-  merge.combineJs(paths, 'build/juju-ui/assets/app.js');
+  merge.combineJs(paths, 'build-shared/juju-ui/assets/app.js');
 
   // Get the paths to the YUI modules that we use.
   var reqs = merge.loadRequires(paths);
@@ -63,6 +63,7 @@
   reqs.push('app-transitions-native');
   reqs.push('gallery-markdown');
   reqs.push('gallery-ellipsis');
+  reqs.push('gallery-timer');
   reqs.push('loader-base');
 
   // Get all of the YUI files and their dependencies
@@ -73,13 +74,16 @@
     'app/assets/javascripts/d3.v2.min.js',
     'app/assets/javascripts/d3-components.js',
     'app/assets/javascripts/reconnecting-websocket.js',
-    'app/assets/javascripts/svg-layouts.js']);
+    'app/assets/javascripts/svg-layouts.js',
+    'app/assets/javascripts/gallery-ellipsis.js',
+    'app/assets/javascripts/gallery-markdown.js',
+    'app/assets/javascripts/gallery-timer.js']);
 
-  merge.combineJs(filesToLoad.js, 'build/juju-ui/assets/all-yui.js');
+  merge.combineJs(filesToLoad.js, 'build-shared/juju-ui/assets/all-yui.js');
 
   var cssFiles = filesToLoad.css;
   cssFiles.push('app/assets/stylesheets/bootstrap-2.0.4.css');
   cssFiles.push('app/assets/stylesheets/bootstrap-responsive-2.0.4.css');
   merge.combineCSS(cssFiles,
-    'build/juju-ui/assets/combined-css/all-static.css');
+    'build-shared/juju-ui/assets/combined-css/all-static.css');
 });

=== modified file 'docs/process.rst'
--- docs/process.rst	2012-12-13 17:36:37 +0000
+++ docs/process.rst	2012-12-21 02:37:21 +0000
@@ -63,12 +63,20 @@
 Checklist for Reviewing
 =======================
 
-- Run ``make test`` and confirm that tests pass.
+Your goal is to help the coder land their code, so that we incrementally
+improve the user experience and the codebase quality without user-facing
+regressions.  Occasionally we might even have to consciously take a step
+backwards in order to step forwards, `as Kent Beck explains
+<http://goo.gl/DBDtJ>`_.
+
+- Run ``make test-prod`` and ``make test-debug`` and confirm that tests pass.
 - Run ``python improv.py -f sample.json`` in the rapi-rollup juju branch, and
   run ``make server`` with the juju-ui branch.
 
   * Don't forget to clear the browser cache: index.html may be sticking around
     because of the cache.manifest.
+  * Verify that the browser reports no 404s and no Javascript errors in the
+    console.
   * QA the changes if possible, exploring different use cases (and edge cases).
   * Spend between 60 and 120 seconds exploring the entire app.  Do different
     things every time.  Try to break the app, generally.
@@ -77,6 +85,7 @@
 - Review the diff, including notes from the above as appropriate.
 
   * Make sure that new code has tests.
+  * Make sure user-facing changes are described in CHANGES.yaml.
   * Make sure you can understand the new code.  Ask for clarification if
     necessary.
   * Consider the advice in this preferred `Software Architecture Cheat Sheet
@@ -90,28 +99,41 @@
 - In your summary message, thank the coder.
 - In your summary message, if you ask for changes, make it clear whether you
   want to re-review after the changes, or if you automatically approve if the
-  changes are made.
+  changes are made.  We've agreed to use these arbitrary code phrases, for
+  clarity: "Land as is," "Land with changes," and "Request review".
 
 Checklist for Making a Stable Release
 =====================================
 
-- Get a checkout of the trunk:: ``bzr co lp:juju-gui``.
-- If you are using a pre-existing checkout, make sure it is up-to-date:: ``bzr
-  up``.
-- Verify that the top-most version in CHANGES.yaml specifies the
-  expected version string.  It should be bigger than the most recent
-  version found on https://launchpad.net/juju-gui/stable .
-- Run the tests and verify they pass: ``make test``.
+- Get a clean branch of the trunk:: ``bzr branch lp:juju-gui``.
+- If you are using a pre-existing branch, make sure it is up-to-date:: ``bzr
+  pull``.
+- Verify that the top-most version in CHANGES.yaml specifies the expected
+  version string.  It should be bigger than the most recent version found on
+  https://launchpad.net/juju-gui/stable .  If the most recent version string
+  is "unreleased," do the following.
+
+  * Decide what the next version number should be (see http://semver.org/) and
+    change "unreleased" to that value.
+  * Commit to the branch with this checkin message: ``bzr commit -m 'Set
+    version for release.'``
+  * Push the branch directly to the parent (``bzr push`` should work).
+
+- Run the tests and verify they pass: ``make test-prod`` and then
+  ``make test-debug``.
 - Create the tarball: ``FINAL=1 make distfile``.  The process will end by
   reporting the name of the tarball it made.
 - In an empty temporary directory somewhere else on your system, expand the
   tarball: ``tar xvzf PATH_TO_TARBALL``
 - In that directory, start a server: ``python -m SimpleHTTPServer 8888``
-- In Chrome and Firefox, QA the application.  XXX EXPLICIT QA STEPS GO HERE!
+- In Chrome and Firefox, QA the application.  At the very least, load the app,
+  open the charm panel, go to an inner page, and make sure there are no 404s
+  or Javascript errors in the console.  We want a real QA script for the
+  future.
 - For now, we will assume you would like to verify the release on the
   Launchpad staging server.  As we become more confident with this process,
   this step may become unnecessary.  In the checkout, run ``FINAL=1 make
-  release``.  This will step you through signing the tarball, connecting
+  dist``.  This will step you through signing the tarball, connecting
   to Launchpad, and uploading the release.
 
   * Note that you may need to ask the webops to turn off the two-factor
@@ -148,24 +170,24 @@
 Checklist for Making a Developer Release
 ========================================
 
-- Get a checkout of the trunk:: ``bzr co lp:juju-gui``.
-- If you are using a pre-existing checkout, make sure it is up-to-date::
-  ``bzr up``.
-- Verify that the top-most version in CHANGES.yaml specifies the expected
-  version string.  Run ``bzr revno``.  These two values, combined, should be
-  bigger than the most recent version found on
-  https://launchpad.net/juju-gui/trunk .  To be clear, the version should be
-  the same or greater as the most recent developer release, and the revno
-  should be greater.
+- Get a clean branch of the trunk:: ``bzr branch lp:juju-gui``.
+- If you are using a pre-existing branch, make sure it is up-to-date::
+  ``bzr pull``.
+- Verify that the top-most version in CHANGES.yaml is "unreleased."
+- Run ``bzr revno``.  The revno should be bigger than the most recent release found on
+  `Launchpad <https://launchpad.net/juju-gui/trunk>`_.
 - Run the tests and verify they pass: ``make test``.
 - Create the tarball: ``make distfile``.  It will end by reporting the name of
   the tarball it made.
 - In an empty temporary directory somewhere else on your system, expand the
-  tarball: ``tar xvzf PATH_TO_TARBALL``
+  tarball: ``tar xvzf PATH_TO_TARBALL``.
 - Looking at juju-ui/version.js should show you a version string that combines
   the value in the checkout's CHANGES.yaml with the checkout's revno.
 - In that directory, start a server: ``python -m SimpleHTTPServer 8888``
-- In Chrome and Firefox, QA the application.  XXX EXPLICIT QA STEPS GO HERE!
+- In Chrome and Firefox, QA the application.  At the very least, load the app,
+  open the charm panel, go to an inner page, and make sure there are no 404s
+  or Javascript errors in the console.  We want a real QA script for the
+  future.
 - For now, we will assume you would like to verify the release on the
   Launchpad staging server.  As we become more confident with this process,
   this step may become unnecessary.  In the checkout, run ``make dist``.

=== modified file 'grunt.js'
--- grunt.js	2012-12-05 19:52:20 +0000
+++ grunt.js	2012-12-21 02:37:21 +0000
@@ -15,7 +15,7 @@
 
         },
         files: {
-          'build/juju-ui/assets': 'app/assets/images/*'
+          'build-shared/juju-ui/assets': 'app/assets/images/*'
         }
       }
     }

=== modified file 'lib/server.js'
--- lib/server.js	2012-12-13 02:43:15 +0000
+++ lib/server.js	2012-12-21 02:37:21 +0000
@@ -56,17 +56,17 @@
   } else if ('config.js' === fileName) {
     res.sendfile('app/config-debug.js');
   } else {
-    res.sendfile('build/juju-ui/assets/' + fileName);
+    res.sendfile('build-shared/juju-ui/assets/' + fileName);
   }
 });
 
 server.get('/juju-ui/:file', function(req, res) {
   var fileName = req.params.file;
-  res.sendfile('build/juju-ui/' + fileName);
+  res.sendfile('build-shared/juju-ui/' + fileName);
 });
 
 server.get('/juju-ui/assets/combined-css/:file', function(req, res) {
-  res.sendfile('build/juju-ui/assets/combined-css/' + req.params.file);
+  res.sendfile('build-shared/juju-ui/assets/combined-css/' + req.params.file);
 });
 
 server.get('/favicon.ico', function(req, res) {

=== modified file 'lib/templates.js'
--- lib/templates.js	2012-12-05 19:52:20 +0000
+++ lib/templates.js	2012-12-21 02:37:21 +0000
@@ -141,7 +141,7 @@
 
 var templateSpecs = {
   templates: {
-    output: __dirname + '/../build/juju-ui/templates.js',
+    output: __dirname + '/../build-shared/juju-ui/templates.js',
     callback: function(strategy, name) {
       cache = {};
       getPrecompiled();
@@ -157,7 +157,7 @@
   },
 
   stylesheet: {
-    output: __dirname + '/../build/juju-ui/assets/juju-gui.css',
+    output: __dirname + '/../build-shared/juju-ui/assets/juju-gui.css',
     callback: function(strategy, name) {
       var parser = new less.Parser({
         paths: [config.server.view_dir],


Follow ups