소스 검색

Started work on, import, sisyphus plugin, and fastclick

Markus Ochel 13 년 전
부모
커밋
685afcc69f

+ 51 - 1
admin/controllers/essays.coffee

@@ -3,6 +3,10 @@ $           = Spine.$
 templates   = require('duality/templates')
 utils       = require('lib/utils')
 
+# For importing HTML from old sites
+require('lib/reMarked')
+require('lib/jquery-xdomainajax')
+
 MultiSelectUI = require('controllers/ui/multi-select')
 FileUploadUI  = require('controllers/ui/file-upload')
 
@@ -25,6 +29,7 @@ class EssayForm extends Spine.Controller
     'select[name=sponsor_id]': 'formSponsorId'
     'input[name=title]':       'formTitle'
     'input[name=published]':   'formPublished'
+    'textarea[name=body]':     'formBody'
     '.collections-list':       'collectionsList'
     '.upload-ui':              'fileUploadContainer'
     '.save-button':            'saveButton'
@@ -37,6 +42,7 @@ class EssayForm extends Spine.Controller
     'click .delete-button':     'destroy'
     'change select[name=site]': 'siteChange'
     'blur input[name=slug]':    'updateSlug'
+    'click .import-button':     'import'
 
   constructor: ->
     super
@@ -72,7 +78,7 @@ class EssayForm extends Spine.Controller
       @formPublished.prop('checked', @item.published)
     else
       @formSite.val(@stack.stack.filterBox.siteId)
-      @formPublished.prop('checked', true)
+      # @formPublished.prop('checked', true)
     @siteChange()
 
     # Files upload area
@@ -82,6 +88,18 @@ class EssayForm extends Spine.Controller
       attachments: @item._attachments
     @fileUploadContainer.html @fileUploadUI.el
 
+    # Use Sisyphus to auto save forms to LocalStorage
+    @form.sisyphus
+      customKeyPrefix: ''
+      timeout: 0
+      autoRelease: true
+      name: if @editing then @item.id else 'new-essay'
+      onSave: -> console.log "Saved to local storage"
+      onBeforeRestore: -> alert "About to restore from local storage"
+      onRestore: -> alert "Restored from local storage"
+      onRelease: -> console.log "Local storage was released"
+      excludeFields: []
+
   siteChange: ->
     $siteSelected = @formSite.parents('.field').find('.site-selected')
     site = Site.exists(@formSite.val())
@@ -113,6 +131,38 @@ class EssayForm extends Spine.Controller
     unless slug.val()
       slug.val utils.cleanSlug(@formTitle.val())
 
+  import: (e) =>
+    # For importing old HTML to Markdown directly from old location
+    e.preventDefault()
+    url = $.trim @formBody.val()
+    if url
+      $.ajax
+        type: 'GET'
+        url: url
+        success: (res) =>
+          $content = $(res.responseText).find('.entry')
+          @log res
+          @log $content
+          if $content
+            $content.find('.addthis_toolbox, .author-bio').remove()
+            options =
+                link_list:  false    # render links as references, create link list as appendix
+                h1_setext:  true     # underline h1 headers
+                h2_setext:  true     # underline h2 headers
+                h_atx_suf:  true     # header suffixes (###)
+                gfm_code:   false    # render code blocks as via ``` delims
+                li_bullet:  "*"      # list item bullet style
+                hr_char:    "-"      # hr style
+                indnt_str:  "    "   # indentation string
+                bold_char:  "*"      # char used for strong
+                emph_char:  "_"      # char used for em
+                gfm_tbls:   false    # markdown-extra tables
+                tbl_edges:  false    # show side edges on tables
+                hash_lnks:  false    # anchors w/hash hrefs as links
+            reMarker = new reMarked(options)
+            markdown = reMarker.render($content.html())
+            @formBody.val(markdown)
+
   save: (e) ->
     e.preventDefault()
     if @editing

+ 11 - 1
admin/controllers/index.coffee

@@ -1,6 +1,8 @@
 Spine       = require('spine/core')
 require('spine/route')
 require('spine/manager')
+# require('lib/fastclick')
+require('lib/sisyphus')
 
 templates   = require('duality/templates')
 session     = require('session')
@@ -20,7 +22,7 @@ Sponsor     = require('models/sponsor')
 class App extends Spine.Controller
   
   constructor: ->
-    super
+    super    
     @checkSession()
 
   checkSession: ->
@@ -56,6 +58,14 @@ class App extends Spine.Controller
 
     Spine.Route.setup()
 
+    @doOtherStuff()
+
+  doOtherStuff: ->
+    # Use the fastclick module for touch devices.
+    # Add a class of `needsclick` of the original click
+    # is needed.
+    # new FastClick(document.body)
+
 
 module.exports = App
     

+ 357 - 0
admin/lib/fastclick.js

@@ -0,0 +1,357 @@
+/**
+ * @preserve FastClick: polyfill to remove click delays on browsers with touch UIs.
+ *
+ * @copyright The Financial Times Limited [All Rights Reserved]
+ * @license MIT License (see LICENCE.txt)
+ * @codingstandard ftlabs-jslint
+ */
+
+/*jslint browser:true*/
+/*global Node, define*/
+
+(function() {
+	'use strict';
+
+	var
+
+
+		/**
+		 * Android requires an exception for labels.
+		 *
+		 * @type boolean
+		 */
+		android = navigator.userAgent.indexOf('Android') > 0,
+
+
+		/**
+		 * Earlier versions of Chrome for Android don't report themselves as "Chrome" but "CrMo" - check for both.
+		 *
+		 * @type boolean
+		 */
+		chromeAndroid = android && (/Chrome|CrMo/).test(navigator.userAgent),
+
+
+		/**
+		 * Playbook requires a greater scroll boundary.
+		 *
+		 * @type number
+		 */
+		scrollBoundary = (android || (navigator.userAgent.indexOf('PlayBook') === -1)) ? 5 : 20;
+
+
+	/**
+	 * Determine whether a given element requires a native click.
+	 *
+	 * @param {Element} target DOM element
+	 * @returns {boolean} Returns true if the element needs a native click
+	 */
+	function needsClick(target) {
+		switch (target.nodeName.toLowerCase()) {
+			case 'label':
+			case 'video':
+				return true;
+			default:
+				return (/\bneedsclick\b/).test(target.className);
+		}
+	}
+
+	/**
+	 * Determine whether a given element requires a call to focus to simulate click into element.
+	 *
+	 * @param  {Element} target target DOM element.
+	 * @return {boolean}  Returns true if the element requires a call to focus to simulate native click.
+	 */
+	function needsFocus(target) {
+		switch(target.nodeName.toLowerCase()) {
+			case 'textarea':
+			case 'select':
+				return true;
+			case 'input':
+				switch (target.type) {
+					case 'button':
+					case 'checkbox':
+					case 'file':
+					case 'image':
+					case 'radio':
+					case 'submit':
+						return false;
+					default:
+						return true;
+				}
+				break;
+			default:
+				return (/\bneedsfocus\b/).test(target.className);
+		}
+	}
+
+
+	/**
+	 * Retrieve an element based on coordinates within the window.
+	 *
+	 * @param {number} x
+	 * @param {number} y
+	 * @return {Element}
+	 */
+	function eleAtWindowPosition(x, y) {
+
+		// On Chrome for Android, amend coordinates by the device pixel ratio.
+		if (chromeAndroid && window.devicePixelRatio) {
+			x *= window.devicePixelRatio;
+			y *= window.devicePixelRatio;
+		}
+
+		return document.elementFromPoint(x, y);
+	}
+
+
+	/**
+	 * Instantiate fast-clicking listeners on the specificed layer.
+	 *
+	 * @constructor
+	 * @param {Element} layer The layer to listen on
+	 */
+	function FastClick(layer) {
+		var
+
+
+			/**
+			 * @type Function
+			 */
+			oldOnClick,
+
+
+			/**
+			 * The position and page scroll amount when click had started to be tracked.
+			 *
+			 * @type Object
+			 */
+			clickStart = { x: 0, y: 0,  scrollX: 0, scrollY: 0 },
+
+
+			/**
+			 * Whether a click is currently being tracked.
+			 *
+			 * @type boolean
+			 */
+			trackingClick = false,
+
+
+			/**
+			 * Maximum distance (37 pixels) to the power of two.
+			 *
+			 * @type number
+			 */
+			bound = Math.pow(37, 2),
+
+
+			/**
+			 * On touch start, record the position and scroll offset.
+			 *
+			 * @param {Event} event
+			 * @returns {boolean}
+			 */
+			onTouchStart = function(event) {
+				trackingClick = true;
+
+				clickStart.x = event.targetTouches[0].pageX;
+				clickStart.y = event.targetTouches[0].pageY;
+				if (clickStart.x === event.targetTouches[0].clientX) {
+					clickStart.x += window.pageXOffset;
+				}
+				if (clickStart.y === event.targetTouches[0].clientY) {
+					clickStart.y += window.pageYOffset;
+				}
+				clickStart.scrollX = window.pageXOffset;
+				clickStart.scrollY = window.pageYOffset;
+
+				return true;
+			},
+
+
+			/**
+			 * Update the last position.
+			 *
+			 * @param {Event} event
+			 * @returns {boolean}
+			 */
+			onTouchMove = function(event) {
+				if (!trackingClick) {
+					return true;
+				}
+
+				// Detect whether a click has left the bounds of would be defined as a click, defined as a circle of radius sqrt(bound) around the start point.
+				if ((Math.pow(event.targetTouches[0].pageX - clickStart.x, 2) + Math.pow(event.targetTouches[0].pageY - clickStart.y, 2)) > bound) {
+					trackingClick = false;
+				}
+
+				// If the touch has moved, cancel the click tracking
+				if (Math.abs(window.pageXOffset - clickStart.scrollX) > scrollBoundary || Math.abs(window.pageYOffset - clickStart.scrollY) > scrollBoundary) {
+					trackingClick = false;
+				}
+
+				return true;
+			},
+
+
+			/**
+			 * On touch end, determine whether to send a click event at once.
+			 *
+			 * @param {Event} event
+			 * @returns {boolean}
+			 */
+			onTouchEnd = function(event) {
+				var targetElement, forElement, targetCoordinates, clickEvent;
+
+				if (!trackingClick) {
+					return true;
+				}
+
+				trackingClick = false;
+
+				// Set up the coordinates to match
+				targetCoordinates = {
+					x: clickStart.x - clickStart.scrollX,
+					y: clickStart.y	- clickStart.scrollY
+				};
+
+				// Derive the element to click as a result of the touch.
+				targetElement = eleAtWindowPosition(targetCoordinates.x, targetCoordinates.y);
+
+				// If we're not clicking anything exit early
+				if (!targetElement) {
+					return false;
+				}
+
+				// If the targetted node is a text node, target the parent instead
+				if (targetElement.nodeType === Node.TEXT_NODE) {
+					targetElement = targetElement.parentElement;
+				}
+
+				if (targetElement.nodeName.toLowerCase() === 'label' && targetElement.htmlFor) {
+					forElement = document.getElementById(targetElement.htmlFor);
+					if (forElement) {
+						targetElement.focus();
+						if (android) {
+							return false;
+						}
+
+						targetElement = forElement;
+					}
+				} else if (needsFocus(targetElement)) {
+					targetElement.focus();
+					return false;
+				}
+
+				// Prevent the actual click from going though - unless the target node is marked as requiring
+				// real clicks or if it is in the whitelist in which case only non-programmatic clicks are permitted
+				// to open the options list and so the original event is required.
+				if (needsClick(targetElement)) {
+					return false;
+				}
+
+				// Synthesise a click event, with an extra attribute so it can be tracked
+				clickEvent = document.createEvent('MouseEvents');
+				clickEvent.initMouseEvent('click', true, true, window, 1, 0, 0, targetCoordinates.x, targetCoordinates.y, false, false, false, false, 0, null);
+				clickEvent.forwardedTouchEvent = true;
+				targetElement.dispatchEvent(clickEvent);
+
+				event.preventDefault();
+				
+				return false;
+			},
+
+
+			/**
+			 * On touch cancel, stop tracking the click.
+			 */
+			onTouchCancel = function() {
+				trackingClick = false;
+			},
+
+
+			/**
+			 * On actual clicks, determine whether this is a touch-generated click, a click action occurring
+			 * naturally after a delay after a touch (which needs to be cancelled to avoid duplication), or
+			 * an actual click which should be permitted.
+			 *
+			 * @param {Event} event
+			 * @returns {boolean}
+			 */
+			onClick = function(event) {
+				var targetElement;
+
+				if (event.forwardedTouchEvent) {
+					return true;
+				}
+
+				// Programmatically generated events targeting a specific element should be permitted
+				if (!event.cancelable) {
+					return true;
+				}
+
+				targetElement = eleAtWindowPosition(clickStart.x - clickStart.scrollX, clickStart.y - clickStart.scrollY);
+
+				// Derive and check the target element to see whether the click needs to be permitted;
+				// unless explicitly enabled, prevent non-touch click events from triggering actions,
+				// to prevent ghost/doubleclicks.
+				if (!targetElement || !needsClick(targetElement)) {
+
+					// Prevent any user-added listeners declared on FastClick element from being fired.
+					if (event.stopImmediatePropagation) {
+						event.stopImmediatePropagation();
+					}
+
+					// Cancel the event
+					event.stopPropagation();
+					event.preventDefault();
+
+					return false;
+				}
+
+				// If clicks are permitted, return true for the action to go through.
+				return true;
+			};
+
+		if (!layer || !layer.nodeType) {
+			throw new TypeError('Layer must be a document node');
+		}
+
+		// Devices that don't support touch don't need FastClick
+		if (typeof window.ontouchstart === 'undefined') {
+			return;
+		}
+
+		// Set up event handlers as required
+		layer.addEventListener('click', onClick, true);
+		layer.addEventListener('touchstart', onTouchStart, true);
+		layer.addEventListener('touchmove', onTouchMove, true);
+		layer.addEventListener('touchend', onTouchEnd, true);
+		layer.addEventListener('touchcancel', onTouchCancel, true);
+
+		// If a handler is already declared in the element's onclick attribute, it will be fired before
+		// FastClick's onClick handler. Fix this by pulling out the user-defined handler function and
+		// adding it as listener.
+		if (typeof layer.onclick === 'function') {
+
+			// Android browser on at least 3.2 requires a new reference to the function in layer.onclick
+			// - the old one won't work if passed to addEventListener directly.
+			oldOnClick = layer.onclick;
+			layer.addEventListener('click', function(event) {
+				oldOnClick(event);
+			}, false);
+			layer.onclick = null;
+		}
+	}
+
+	if (typeof define === 'function' && define.amd) {
+
+		// AMD. Register as an anonymous module.
+		define(function() {
+			return FastClick;
+		});
+	} else {
+
+		// Browser global
+		window.FastClick = FastClick;
+	}
+}());

+ 75 - 0
admin/lib/jquery-xdomainajax.js

@@ -0,0 +1,75 @@
+/**
+ * jQuery.ajax mid - CROSS DOMAIN AJAX 
+ * ---
+ * @author James Padolsey (http://james.padolsey.com)
+ * @version 0.11
+ * @updated 12-JAN-10
+ * ---
+ * Note: Read the README!
+ * ---
+ * @info http://james.padolsey.com/javascript/cross-domain-requests-with-jquery/
+ */
+
+jQuery.ajax = (function(_ajax){
+    
+    var protocol = location.protocol,
+        hostname = location.hostname,
+        exRegex = RegExp(protocol + '//' + hostname),
+        YQL = 'http' + (/^https/.test(protocol)?'s':'') + '://query.yahooapis.com/v1/public/yql?callback=?',
+        query = 'select * from html where url="{URL}" and xpath="*"';
+    
+    function isExternal(url) {
+        return !exRegex.test(url) && /:\/\//.test(url);
+    }
+    
+    return function(o) {
+        
+        var url = o.url;
+        
+        if ( /get/i.test(o.type) && !/json/i.test(o.dataType) && isExternal(url) ) {
+            
+            // Manipulate options so that JSONP-x request is made to YQL
+            
+            o.url = YQL;
+            o.dataType = 'json';
+            
+            o.data = {
+                q: query.replace(
+                    '{URL}',
+                    url + (o.data ?
+                        (/\?/.test(url) ? '&' : '?') + jQuery.param(o.data)
+                    : '')
+                ),
+                format: 'xml'
+            };
+            
+            // Since it's a JSONP request
+            // complete === success
+            if (!o.success && o.complete) {
+                o.success = o.complete;
+                delete o.complete;
+            }
+            
+            o.success = (function(_success){
+                return function(data) {
+                    
+                    if (_success) {
+                        // Fake XHR callback.
+                        _success.call(this, {
+                            responseText: (data.results[0] || '')
+                                // YQL screws with <script>s
+                                // Get rid of them
+                                .replace(/<script[^>]+?\/>|<script(.|\s)*?\/script>/gi, '')
+                        }, 'success');
+                    }
+                    
+                };
+            })(o.success);
+            
+        }
+        
+        return _ajax.apply(this, arguments);
+        
+    };
+    
+})(jQuery.ajax);

+ 556 - 0
admin/lib/reMarked.js

@@ -0,0 +1,556 @@
+/**
+* Copyright (c) 2012, Leon Sorokin
+* All rights reserved. (MIT Licensed)
+*
+* reMarked.js - DOM > markdown
+*/
+
+reMarked = function(opts) {
+
+	var links = [];
+	var cfg = {
+		link_list:	false,			// render links as references, create link list as appendix
+	//  link_near:					// cite links immediately after blocks
+		h1_setext:	true,			// underline h1 headers
+		h2_setext:	true,			// underline h2 headers
+		h_atx_suf:	false,			// header suffix (###)
+	//	h_compact:	true,			// compact headers (except h1)
+		gfm_code:	false,			// render code blocks as via ``` delims
+		li_bullet:	"*-+"[0],		// list item bullet style
+	//	list_indnt:					// indent top-level lists
+		hr_char:	"-_*"[0],		// hr style
+		indnt_str:	["    ","\t","  "][0],	// indentation string
+		bold_char:	"*_"[0],		// char used for strong
+		emph_char:	"*_"[1],		// char used for em
+		gfm_tbls:	true,			// markdown-extra tables
+		tbl_edges:	false,			// show side edges on tables
+		hash_lnks:	false,			// anchors w/hash hrefs as links
+	};
+
+	extend(cfg, opts);
+
+	function extend(a, b) {
+		if (!b) return a;
+		for (var i in a) {
+			if (typeof b[i] !== "undefined")
+				a[i] = b[i];
+		}
+	}
+
+	function rep(str, num) {
+		var s = "";
+		while (num-- > 0)
+			s += str;
+		return s;
+	}
+
+	function trim12(str) {
+		var	str = str.replace(/^\s\s*/, ''),
+			ws = /\s/,
+			i = str.length;
+		while (ws.test(str.charAt(--i)));
+		return str.slice(0, i + 1);
+	}
+
+	function lpad(targ, padStr, len) {
+		return rep(padStr, len - targ.length) + targ;
+	}
+
+	function rpad(targ, padStr, len) {
+		return targ + rep(padStr, len - targ.length);
+	}
+
+	function otag(tag) {
+		if (!tag) return "";
+		return "<" + tag + ">";
+	}
+
+	function ctag(tag) {
+		if (!tag) return "";
+		return "</" + tag + ">";
+	}
+
+	function pfxLines(txt, pfx)	{
+		return txt.replace(/^/gm, pfx);
+	}
+
+	function nodeName(e) {
+		return (e.nodeName == "#text" ? "txt" : e.nodeName).toLowerCase();
+	}
+
+	function wrap(str, opts) {
+		var pre, suf;
+
+		if (opts instanceof Array) {
+			pre = opts[0];
+			suf = opts[1];
+		}
+		else
+			pre = suf = opts;
+
+		pre = pre instanceof Function ? pre.call(this, str) : pre;
+		suf = suf instanceof Function ? suf.call(this, str) : suf;
+
+		return pre + str + suf;
+	}
+
+	this.render = function(ctr) {
+		if (typeof ctr == "string") {
+			var htmlstr = ctr;
+			ctr = document.createElement("div");
+			ctr.innerHTML = htmlstr;
+		}
+		var s = new lib.tag(ctr, null, 0);
+		var re = s.rend().replace(/^[\t ]+\n/gm, "\n");
+		if (cfg.link_list) {
+			// hack
+			re += "\n\n";
+			var maxlen = 0;
+			// get longest link href with title, TODO: use getAttribute?
+			for (var y in links) {
+				if (!links[y].e.title) continue;
+				var len = links[y].e.href.length;
+				if (len && len > maxlen)
+					maxlen = len;
+			}
+
+			for (var k in links) {
+				var title = links[k].e.title ? rep(" ", (maxlen + 2) - links[k].e.href.length) + '"' + links[k].e.title + '"' : "";
+				re += "  [" + (+k+1) + "]: " + links[k].e.href + title + "\n";
+			}
+		}
+
+		return re.replace(/^[\t ]+\n/gm, "\n");
+	};
+
+	var lib = {};
+
+	lib.tag = klass({
+		wrap: "",
+		lnPfx: "",		// only block
+		lnInd: 0,		// only block
+		init: function(e, p, i)
+		{
+			this.e = e;
+			this.p = p;
+			this.i = i;
+			this.c = [];
+			this.tag = nodeName(e);
+
+			this.initK();
+		},
+
+		initK: function()
+		{
+			var i;
+			if (this.e.hasChildNodes()) {
+				// inline elems allowing adjacent whitespace text nodes to be rendered
+				var inlRe = /^(?:a|strong|code|em|sub|sup|del|i|u|b|big|center)$/, n, name;
+				for (i in this.e.childNodes) {
+					if (!/\d+/.test(i)) continue;
+
+					n = this.e.childNodes[i];
+					name = nodeName(n);
+
+					// ignored tags
+					if (/style|script|canvas|video|audio/.test(name))
+						continue;
+
+					// empty whitespace handling
+					if (name == "txt" && /^\s+$/.test(n.textContent)) {
+						// ignore if first or last child (trim)
+						if (i == 0 || i == this.e.childNodes.length - 1 || !this.p)
+							continue;
+
+						// only ouput when has an adjacent inline elem
+						var prev = this.e.childNodes[i-1],
+							next = this.e.childNodes[i+1];
+						if (prev && !nodeName(prev).match(inlRe) || next && !nodeName(next).match(inlRe))
+							continue;
+					}
+					if (!lib[name])
+						name = "tag";
+
+					var node = new lib[name](n, this, this.c.length);
+
+					if (node instanceof lib.a && n.href || node instanceof lib.img) {
+						node.lnkid = links.length;
+						links.push(node);
+					}
+
+					this.c.push(node);
+				}
+			}
+		},
+
+		rend: function()
+		{
+			return this.rendK().replace(/\n{3,}/gm, "\n\n");		// can screw up pre and code :(
+		},
+
+		rendK: function()
+		{
+			var n, buf = "";
+			for (var i in this.c) {
+				n = this.c[i];
+				buf += (n.bef || "") + n.rend() + (n.aft || "");
+			}
+			return buf.replace(/^\n+|\n+$/, "");
+		}
+	});
+
+	lib.blk = lib.tag.extend({
+		wrap: ["\n\n", ""],
+		wrapK: null,
+		tagr: false,
+		lnInd: null,
+		init: function(e, p ,i) {
+			this.supr(e,p,i);
+
+			// kids indented
+			if (this.lnInd === null) {
+				if (this.p && this.tagr && this.c[0] instanceof lib.blk)
+					this.lnInd = 4;
+				else
+					this.lnInd = 0;
+			}
+
+			// kids wrapped?
+			if (this.wrapK === null) {
+				if (this.tagr && this.c[0] instanceof lib.blk)
+					this.wrapK = "\n";
+				else
+					this.wrapK = "";
+			}
+		},
+
+		rend: function()
+		{
+			return wrap.call(this, (this.tagr ? otag(this.tag) : "") + wrap.call(this, pfxLines(pfxLines(this.rendK(), this.lnPfx), rep(" ", this.lnInd)), this.wrapK) + (this.tagr ? ctag(this.tag) : ""), this.wrap);
+		},
+
+		rendK: function()
+		{
+			var kids = this.supr();
+			// remove min uniform leading spaces from block children. marked.js's list outdent algo sometimes leaves these
+			if (this.p instanceof lib.li) {
+				var repl = null, spcs = kids.match(/^[\t ]+/gm);
+				if (!spcs) return kids;
+				for (var i in spcs) {
+					if (repl === null || spcs[i][0].length < repl.length)
+						repl = spcs[i][0];
+				}
+				return kids.replace(new RegExp("^" + repl), "");
+			}
+			return kids;
+		}
+	});
+
+	lib.tblk = lib.blk.extend({tagr: true});
+
+	lib.cblk = lib.blk.extend({wrap: ["\n", ""]});
+		lib.ctblk = lib.cblk.extend({tagr: true});
+
+	lib.inl = lib.tag.extend({
+		rend: function()
+		{
+			return wrap.call(this, this.rendK(), this.wrap);
+		}
+	});
+
+		lib.tinl = lib.inl.extend({
+			tagr: true,
+			rend: function()
+			{
+				return otag(this.tag) + wrap.call(this, this.rendK(), this.wrap) + ctag(this.tag);
+			}
+		});
+
+		lib.p = lib.blk.extend({
+			rendK: function() {
+				return this.supr().replace(/^\s+/gm, "");
+			}
+		});
+
+		lib.div = lib.p.extend();
+
+		lib.span = lib.inl.extend();
+
+		lib.list = lib.blk.extend({
+			expn: false,
+			wrap: [function(){return this.p instanceof lib.li ? "\n" : "\n\n";}, ""]
+		});
+
+		lib.ul = lib.list.extend({});
+
+		lib.ol = lib.list.extend({});
+
+		lib.li = lib.cblk.extend({
+			wrap: ["\n", function(kids) {
+				return this.p.expn || kids.match(/\n{2}/gm) ? "\n" : "";			// || this.kids.match(\n)
+			}],
+			wrapK: [function() {
+				return this.p.tag == "ul" ? cfg.li_bullet + " " : (this.i + 1) + ".  ";
+			}, ""],
+			rendK: function() {
+				return this.supr().replace(/\n([^\n])/gm, "\n" + cfg.indnt_str + "$1");
+			}
+		});
+
+		lib.hr = lib.blk.extend({
+			wrap: ["\n\n", rep(cfg.hr_char, 3)]
+		});
+
+		lib.h = lib.blk.extend({});
+
+		lib.h_setext = lib.h.extend({});
+
+			cfg.h1_setext && (lib.h1 = lib.h_setext.extend({
+				wrapK: ["", function(kids) {
+					return "\n" + rep("=", kids.length);
+				}]
+			}));
+
+			cfg.h2_setext && (lib.h2 = lib.h_setext.extend({
+				wrapK: ["", function(kids) {
+					return "\n" + rep("-", kids.length);
+				}]
+			}));
+
+		lib.h_atx = lib.h.extend({
+			wrapK: [
+				function(kids) {
+					return rep("#", this.tag[1]) + " ";
+				},
+				function(kids) {
+					return cfg.h_atx_suf ? " " + rep("#", this.tag[1]) : "";
+				}
+			]
+		});
+			!cfg.h1_setext && (lib.h1 = lib.h_atx.extend({}));
+
+			!cfg.h2_setext && (lib.h2 = lib.h_atx.extend({}));
+
+			lib.h3 = lib.h_atx.extend({});
+
+			lib.h4 = lib.h_atx.extend({});
+
+			lib.h5 = lib.h_atx.extend({});
+
+			lib.h6 = lib.h_atx.extend({});
+
+		lib.a = lib.inl.extend({
+			lnkid: null,
+			rend: function() {
+				var kids = this.rendK(),
+					href = this.e.getAttribute("href"),
+					title = this.e.title ? ' "' + this.e.title + '"' : "";
+
+				if (!href || href == kids || href[0] == "#" && !cfg.hash_lnks)
+					return kids;
+
+				if (cfg.link_list)
+					return "[" + kids + "] [" + (this.lnkid + 1) + "]";
+
+				return "[" + kids + "](" + href + title + ")";
+			}
+		});
+
+		// almost identical to links, maybe merge
+		lib.img = lib.inl.extend({
+			lnkid: null,
+			rend: function() {
+				var kids = this.e.alt,
+					src = this.e.getAttribute("src");
+
+				if (cfg.link_list)
+					return "[" + kids + "] [" + (this.lnkid + 1) + "]";
+
+				var title = this.e.title ? ' "'+ this.e.title + '"' : "";
+
+				return "![" + kids + "](" + src + title + ")";
+			}
+		});
+
+
+		lib.em = lib.inl.extend({wrap: cfg.emph_char});
+
+			lib.i = lib.em.extend();
+
+		lib.del = lib.tinl.extend();
+
+		lib.br = lib.inl.extend({
+			wrap: ["", function() {
+				// br in headers output as html
+				return this.p instanceof lib.h ? "<br>" : "  \n";
+			}]
+		});
+
+		lib.strong = lib.inl.extend({wrap: rep(cfg.bold_char, 2)});
+
+			lib.b = lib.strong.extend();
+
+		lib.dl = lib.tblk.extend({lnInd: 2});
+
+		lib.dt = lib.ctblk.extend();
+
+		lib.dd = lib.ctblk.extend();
+
+		lib.sub = lib.tinl.extend();
+
+		lib.sup = lib.tinl.extend();
+
+		lib.blockquote = lib.blk.extend({
+			lnPfx: "> ",
+			rend: function() {
+				return this.supr().replace(/>[ \t]$/gm, ">");
+			}
+		});
+
+		// can render with or without tags
+		lib.pre = lib.blk.extend({
+			tagr: true,
+			wrapK: "\n",
+			lnInd: 0
+		});
+
+		// can morph into inline based on context
+		lib.code = lib.blk.extend({
+			tagr: false,
+			wrap: "",
+			wrapK: function(kids) {
+				return kids.indexOf("`") !== -1 ? "``" : "`";	// esc double backticks
+			},
+			lnInd: 0,
+			init: function(e, p, i) {
+				this.supr(e, p, i);
+
+				if (this.p instanceof lib.pre) {
+					this.p.tagr = false;
+
+					if (cfg.gfm_code) {
+						var cls = this.e.getAttribute("class");
+						this.wrapK = ["```" + (cls ? " " + cls : "") + "\n", "\n```"];
+					}
+					else {
+						this.wrapK = "";
+						this.p.lnInd = 4;
+					}
+				}
+			}
+		});
+
+		lib.table = cfg.gfm_tbls ? lib.blk.extend({
+			cols: [],
+			init: function(e, p, i) {
+				this.supr(e, p, i);
+				this.cols = [];
+			},
+			rend: function() {
+				// run prep on all cells to get max col widths
+				for (var tsec in this.c)
+					for (var row in this.c[tsec].c)
+						for (var cell in this.c[tsec].c[row].c)
+							this.c[tsec].c[row].c[cell].prep();
+
+				return this.supr();
+			}
+		}) : lib.tblk.extend();
+
+		lib.thead = cfg.gfm_tbls ? lib.cblk.extend({
+			wrap: ["\n", function(kids) {
+				var buf = "";
+				for (var i in this.p.cols) {
+					var col = this.p.cols[i],
+						al = col.a[0] == "c" ? ":" : " ",
+						ar = col.a[0] == "r" || col.a[0] == "c" ? ":" : " ";
+
+					buf += (i == 0 && cfg.tbl_edges ? "|" : "") + al + rep("-", col.w) + ar + (i < this.p.cols.length-1 || cfg.tbl_edges ? "|" : "");
+				}
+				return "\n" + trim12(buf);
+			}]
+		}) : lib.ctblk.extend();
+
+		lib.tbody = cfg.gfm_tbls ? lib.cblk.extend() : lib.ctblk.extend();
+
+		lib.tfoot = cfg.gfm_tbls ? lib.cblk.extend() : lib.ctblk.extend();
+
+		lib.tr = cfg.gfm_tbls ? lib.cblk.extend({
+			wrapK: [cfg.tbl_edges ? "| " : "", cfg.tbl_edges ? " |" : ""],
+		}) : lib.ctblk.extend();
+
+		lib.th = cfg.gfm_tbls ? lib.inl.extend({
+			guts: null,
+			// TODO: DRY?
+			wrap: [function() {
+				var col = this.p.p.p.cols[this.i],
+					spc = this.i == 0 ? "" : " ",
+					pad, fill = col.w - this.guts.length;
+
+				switch (col.a[0]) {
+					case "r": pad = rep(" ", fill); break;
+					case "c": pad = rep(" ", Math.floor(fill/2)); break;
+					default:  pad = "";
+				}
+
+				return spc + pad;
+			}, function() {
+				var col = this.p.p.p.cols[this.i],
+					edg = this.i == this.p.c.length - 1 ? "" : " |",
+					pad, fill = col.w - this.guts.length;
+
+				switch (col.a[0]) {
+					case "r": pad = ""; break;
+					case "c": pad = rep(" ", Math.ceil(fill/2)); break;
+					default:  pad = rep(" ", fill);
+				}
+
+				return pad + edg;
+			}],
+			prep: function() {
+				this.guts = this.rendK();					// pre-render
+				this.rendK = function() {return this.guts};
+
+				var cols = this.p.p.p.cols;
+				if (!cols[this.i])
+					cols[this.i] = {w: null, a: ""};		// width and alignment
+				var col = cols[this.i];
+				col.w = Math.max(col.w || 0, this.guts.length);
+				if (this.e.align)
+					col.a = this.e.align;
+			},
+		}) : lib.ctblk.extend();
+
+			lib.td = lib.th.extend();
+
+		lib.txt = lib.inl.extend({
+			initK: function()
+			{
+				this.c = this.e.textContent.split(/^/gm);
+			},
+			rendK: function()
+			{
+				var kids = this.c.join("").replace(/\r/gm, "");
+
+				// this is strange, cause inside of code, inline should not be processed, but is?
+				if (!(this.p instanceof lib.code || this.p instanceof lib.pre)) {
+					kids = kids
+					.replace(/^\s*#/gm,"\\#")
+					.replace(/\*/gm,"\\*");
+				}
+
+				if (this.i == 0)
+					kids = kids.replace(/^\n+/, "");
+				if (this.i == this.p.c.length - 1)
+					kids = kids.replace(/\n+$/, "");
+				return kids;
+			}
+		});
+};
+
+/*!
+  * klass: a classical JS OOP façade
+  * https://github.com/ded/klass
+  * License MIT (c) Dustin Diaz & Jacob Thornton 2012
+  */
+!function(a,b){typeof define=="function"?define(b):typeof module!="undefined"?module.exports=b():this[a]=b()}("klass",function(){function f(a){return j.call(g(a)?a:function(){},a,1)}function g(a){return typeof a===c}function h(a,b,c){return function(){var d=this.supr;this.supr=c[e][a];var f=b.apply(this,arguments);return this.supr=d,f}}function i(a,b,c){for(var f in b)b.hasOwnProperty(f)&&(a[f]=g(b[f])&&g(c[e][f])&&d.test(b[f])?h(f,b[f],c):b[f])}function j(a,b){function c(){}function l(){this.init?this.init.apply(this,arguments):(b||h&&d.apply(this,arguments),j.apply(this,arguments))}c[e]=this[e];var d=this,f=new c,h=g(a),j=h?a:this,k=h?{}:a;return l.methods=function(a){return i(f,a,d),l[e]=f,this},l.methods.call(l,k).prototype.constructor=l,l.extend=arguments.callee,l[e].implement=l.statics=function(a,b){return a=typeof a=="string"?function(){var c={};return c[a]=b,c}():a,i(this,a,d),this},l}var a=this,b=a.klass,c="function",d=/xyz/.test(function(){xyz})?/\bsupr\b/:/.*/,e="prototype";return f.noConflict=function(){return a.klass=b,this},a.klass=f,f});

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
admin/lib/sisyphus.js


+ 3 - 1
admin/static/css/common.styl

@@ -146,7 +146,9 @@ button, .button
       background: lighten($faintGrey, 50%)
 
   &.small
-    padding: 0 5px
+    padding: 0px 10px
+    font-size: 1em
+    line-height: 1.5em
 
 ::-webkit-input-placeholder
   color: #e3e3e3

+ 13 - 0
admin/static/css/theme.styl

@@ -324,6 +324,19 @@ span.label
           float: left
           margin: 4px 10px 4px 0
 
+        textarea
+
+          &.fullscreen
+            position: fixed
+            top: 0
+            bottom: 0
+            left: 0
+            width: 50%
+            padding: 5px
+            background: white
+            border-left: 20px solid $primaryColor
+            z-index: 10
+
         .field-left
           float: left
           width: 48%

+ 1 - 0
admin/templates/essay-form.html

@@ -44,6 +44,7 @@
     <div class="field">
       <label>Body MD/HTML</label>
       <textarea name="body" placeholder="Write your essay content as Markdown/HTML">{{body}}</textarea>
+      <button class="import-button small">Import</button>
     </div>
     <div class="field">
       <div class="field-left">

+ 6 - 1
site/lib/app.coffee

@@ -1,10 +1,15 @@
 # App's main client script
-
 $ = require('jquery')
+require('lib/fastclick')
 
 exports.initialize = (config) ->
   touch = Modernizr.touch
 
+  # Use the fastclick module for touch devices.
+  # Add a class of `needsclick` of the original click
+  # is needed.
+  new FastClick(document.body)
+
   $mainNav = $('.main-nav')
   $mainNavIcon = $mainNav.find('> .icon')
   $mainNavList = $mainNav.find('> ul')

+ 357 - 0
site/lib/fastclick.js

@@ -0,0 +1,357 @@
+/**
+ * @preserve FastClick: polyfill to remove click delays on browsers with touch UIs.
+ *
+ * @copyright The Financial Times Limited [All Rights Reserved]
+ * @license MIT License (see LICENCE.txt)
+ * @codingstandard ftlabs-jslint
+ */
+
+/*jslint browser:true*/
+/*global Node, define*/
+
+(function() {
+	'use strict';
+
+	var
+
+
+		/**
+		 * Android requires an exception for labels.
+		 *
+		 * @type boolean
+		 */
+		android = navigator.userAgent.indexOf('Android') > 0,
+
+
+		/**
+		 * Earlier versions of Chrome for Android don't report themselves as "Chrome" but "CrMo" - check for both.
+		 *
+		 * @type boolean
+		 */
+		chromeAndroid = android && (/Chrome|CrMo/).test(navigator.userAgent),
+
+
+		/**
+		 * Playbook requires a greater scroll boundary.
+		 *
+		 * @type number
+		 */
+		scrollBoundary = (android || (navigator.userAgent.indexOf('PlayBook') === -1)) ? 5 : 20;
+
+
+	/**
+	 * Determine whether a given element requires a native click.
+	 *
+	 * @param {Element} target DOM element
+	 * @returns {boolean} Returns true if the element needs a native click
+	 */
+	function needsClick(target) {
+		switch (target.nodeName.toLowerCase()) {
+			case 'label':
+			case 'video':
+				return true;
+			default:
+				return (/\bneedsclick\b/).test(target.className);
+		}
+	}
+
+	/**
+	 * Determine whether a given element requires a call to focus to simulate click into element.
+	 *
+	 * @param  {Element} target target DOM element.
+	 * @return {boolean}  Returns true if the element requires a call to focus to simulate native click.
+	 */
+	function needsFocus(target) {
+		switch(target.nodeName.toLowerCase()) {
+			case 'textarea':
+			case 'select':
+				return true;
+			case 'input':
+				switch (target.type) {
+					case 'button':
+					case 'checkbox':
+					case 'file':
+					case 'image':
+					case 'radio':
+					case 'submit':
+						return false;
+					default:
+						return true;
+				}
+				break;
+			default:
+				return (/\bneedsfocus\b/).test(target.className);
+		}
+	}
+
+
+	/**
+	 * Retrieve an element based on coordinates within the window.
+	 *
+	 * @param {number} x
+	 * @param {number} y
+	 * @return {Element}
+	 */
+	function eleAtWindowPosition(x, y) {
+
+		// On Chrome for Android, amend coordinates by the device pixel ratio.
+		if (chromeAndroid && window.devicePixelRatio) {
+			x *= window.devicePixelRatio;
+			y *= window.devicePixelRatio;
+		}
+
+		return document.elementFromPoint(x, y);
+	}
+
+
+	/**
+	 * Instantiate fast-clicking listeners on the specificed layer.
+	 *
+	 * @constructor
+	 * @param {Element} layer The layer to listen on
+	 */
+	function FastClick(layer) {
+		var
+
+
+			/**
+			 * @type Function
+			 */
+			oldOnClick,
+
+
+			/**
+			 * The position and page scroll amount when click had started to be tracked.
+			 *
+			 * @type Object
+			 */
+			clickStart = { x: 0, y: 0,  scrollX: 0, scrollY: 0 },
+
+
+			/**
+			 * Whether a click is currently being tracked.
+			 *
+			 * @type boolean
+			 */
+			trackingClick = false,
+
+
+			/**
+			 * Maximum distance (37 pixels) to the power of two.
+			 *
+			 * @type number
+			 */
+			bound = Math.pow(37, 2),
+
+
+			/**
+			 * On touch start, record the position and scroll offset.
+			 *
+			 * @param {Event} event
+			 * @returns {boolean}
+			 */
+			onTouchStart = function(event) {
+				trackingClick = true;
+
+				clickStart.x = event.targetTouches[0].pageX;
+				clickStart.y = event.targetTouches[0].pageY;
+				if (clickStart.x === event.targetTouches[0].clientX) {
+					clickStart.x += window.pageXOffset;
+				}
+				if (clickStart.y === event.targetTouches[0].clientY) {
+					clickStart.y += window.pageYOffset;
+				}
+				clickStart.scrollX = window.pageXOffset;
+				clickStart.scrollY = window.pageYOffset;
+
+				return true;
+			},
+
+
+			/**
+			 * Update the last position.
+			 *
+			 * @param {Event} event
+			 * @returns {boolean}
+			 */
+			onTouchMove = function(event) {
+				if (!trackingClick) {
+					return true;
+				}
+
+				// Detect whether a click has left the bounds of would be defined as a click, defined as a circle of radius sqrt(bound) around the start point.
+				if ((Math.pow(event.targetTouches[0].pageX - clickStart.x, 2) + Math.pow(event.targetTouches[0].pageY - clickStart.y, 2)) > bound) {
+					trackingClick = false;
+				}
+
+				// If the touch has moved, cancel the click tracking
+				if (Math.abs(window.pageXOffset - clickStart.scrollX) > scrollBoundary || Math.abs(window.pageYOffset - clickStart.scrollY) > scrollBoundary) {
+					trackingClick = false;
+				}
+
+				return true;
+			},
+
+
+			/**
+			 * On touch end, determine whether to send a click event at once.
+			 *
+			 * @param {Event} event
+			 * @returns {boolean}
+			 */
+			onTouchEnd = function(event) {
+				var targetElement, forElement, targetCoordinates, clickEvent;
+
+				if (!trackingClick) {
+					return true;
+				}
+
+				trackingClick = false;
+
+				// Set up the coordinates to match
+				targetCoordinates = {
+					x: clickStart.x - clickStart.scrollX,
+					y: clickStart.y	- clickStart.scrollY
+				};
+
+				// Derive the element to click as a result of the touch.
+				targetElement = eleAtWindowPosition(targetCoordinates.x, targetCoordinates.y);
+
+				// If we're not clicking anything exit early
+				if (!targetElement) {
+					return false;
+				}
+
+				// If the targetted node is a text node, target the parent instead
+				if (targetElement.nodeType === Node.TEXT_NODE) {
+					targetElement = targetElement.parentElement;
+				}
+
+				if (targetElement.nodeName.toLowerCase() === 'label' && targetElement.htmlFor) {
+					forElement = document.getElementById(targetElement.htmlFor);
+					if (forElement) {
+						targetElement.focus();
+						if (android) {
+							return false;
+						}
+
+						targetElement = forElement;
+					}
+				} else if (needsFocus(targetElement)) {
+					targetElement.focus();
+					return false;
+				}
+
+				// Prevent the actual click from going though - unless the target node is marked as requiring
+				// real clicks or if it is in the whitelist in which case only non-programmatic clicks are permitted
+				// to open the options list and so the original event is required.
+				if (needsClick(targetElement)) {
+					return false;
+				}
+
+				// Synthesise a click event, with an extra attribute so it can be tracked
+				clickEvent = document.createEvent('MouseEvents');
+				clickEvent.initMouseEvent('click', true, true, window, 1, 0, 0, targetCoordinates.x, targetCoordinates.y, false, false, false, false, 0, null);
+				clickEvent.forwardedTouchEvent = true;
+				targetElement.dispatchEvent(clickEvent);
+
+				event.preventDefault();
+				
+				return false;
+			},
+
+
+			/**
+			 * On touch cancel, stop tracking the click.
+			 */
+			onTouchCancel = function() {
+				trackingClick = false;
+			},
+
+
+			/**
+			 * On actual clicks, determine whether this is a touch-generated click, a click action occurring
+			 * naturally after a delay after a touch (which needs to be cancelled to avoid duplication), or
+			 * an actual click which should be permitted.
+			 *
+			 * @param {Event} event
+			 * @returns {boolean}
+			 */
+			onClick = function(event) {
+				var targetElement;
+
+				if (event.forwardedTouchEvent) {
+					return true;
+				}
+
+				// Programmatically generated events targeting a specific element should be permitted
+				if (!event.cancelable) {
+					return true;
+				}
+
+				targetElement = eleAtWindowPosition(clickStart.x - clickStart.scrollX, clickStart.y - clickStart.scrollY);
+
+				// Derive and check the target element to see whether the click needs to be permitted;
+				// unless explicitly enabled, prevent non-touch click events from triggering actions,
+				// to prevent ghost/doubleclicks.
+				if (!targetElement || !needsClick(targetElement)) {
+
+					// Prevent any user-added listeners declared on FastClick element from being fired.
+					if (event.stopImmediatePropagation) {
+						event.stopImmediatePropagation();
+					}
+
+					// Cancel the event
+					event.stopPropagation();
+					event.preventDefault();
+
+					return false;
+				}
+
+				// If clicks are permitted, return true for the action to go through.
+				return true;
+			};
+
+		if (!layer || !layer.nodeType) {
+			throw new TypeError('Layer must be a document node');
+		}
+
+		// Devices that don't support touch don't need FastClick
+		if (typeof window.ontouchstart === 'undefined') {
+			return;
+		}
+
+		// Set up event handlers as required
+		layer.addEventListener('click', onClick, true);
+		layer.addEventListener('touchstart', onTouchStart, true);
+		layer.addEventListener('touchmove', onTouchMove, true);
+		layer.addEventListener('touchend', onTouchEnd, true);
+		layer.addEventListener('touchcancel', onTouchCancel, true);
+
+		// If a handler is already declared in the element's onclick attribute, it will be fired before
+		// FastClick's onClick handler. Fix this by pulling out the user-defined handler function and
+		// adding it as listener.
+		if (typeof layer.onclick === 'function') {
+
+			// Android browser on at least 3.2 requires a new reference to the function in layer.onclick
+			// - the old one won't work if passed to addEventListener directly.
+			oldOnClick = layer.onclick;
+			layer.addEventListener('click', function(event) {
+				oldOnClick(event);
+			}, false);
+			layer.onclick = null;
+		}
+	}
+
+	if (typeof define === 'function' && define.amd) {
+
+		// AMD. Register as an anonymous module.
+		define(function() {
+			return FastClick;
+		});
+	} else {
+
+		// Browser global
+		window.FastClick = FastClick;
+	}
+}());

+ 22 - 12
site/server/lists.coffee

@@ -11,7 +11,7 @@ exports.home = (head, req) ->
   return if req.client and req.initial_hit
   start code: 200, headers: {'Content-Type': 'text/html'}
 
-  converter = new Showdown.converter()
+  md = new Showdown.converter()
   collections = []
   blocks = {}
   site = null
@@ -24,7 +24,7 @@ exports.home = (head, req) ->
   
   collections = _.map collections, (doc) ->
     if doc.intro?
-      doc.intro_html = converter.makeHtml(
+      doc.intro_html = md.makeHtml(
         doc.intro.replace(/\{\{?baseURL\}?\}/g, dutils.getBaseURL(req))
       )
     doc.updated_at_html = utils.prettyDate(doc.updated_at)
@@ -46,9 +46,10 @@ exports.collection = (head, req) ->
   return if req.client and req.initial_hit
   start code: 200, headers: {'Content-Type': 'text/html'}
 
-  converter = new Showdown.converter()
+  md = new Showdown.converter()
   essays = []
   collection = null
+  blocks = {}
   sponsor = null
   site = null
 
@@ -56,15 +57,20 @@ exports.collection = (head, req) ->
     doc = row.doc
     essays.push(doc) if doc.type is 'essay'
     collection ?= doc if doc.type is 'collection'
+    blocks[doc.code] = doc if doc.type is 'block'
     sponsor ?= doc if doc.type is 'sponsor'
     site ?= doc if doc.type is 'site'
 
   if collection
+    if collection.intro?
+      collection.intro_html = md.makeHtml(
+        collection.intro.replace(/\{\{?baseURL\}?\}/g, dutils.getBaseURL(req))
+      )
     collection.fresh = utils.isItFresh(collection.updated_at)
 
   essays = _.map essays, (doc) ->
     if doc.intro?
-      doc.intro_html = converter.makeHtml(
+      doc.intro_html = md.makeHtml(
         doc.intro.replace(/\{\{?baseURL\}?\}/g, dutils.getBaseURL(req))
       )
     doc.published_at_html = utils.prettyDate(doc.published_at)
@@ -93,6 +99,7 @@ exports.collection = (head, req) ->
         collection: collection
         essays: essays
         sponsor: sponsor
+        blocks: blocks
         nav: 'collection'
     }
   else
@@ -108,7 +115,7 @@ exports.essays = (head, req) ->
   return if req.client and req.initial_hit
   start code: 200, headers: {'Content-Type': 'text/html'}
 
-  converter = new Showdown.converter()
+  md = new Showdown.converter()
   essays = []
   site = null
 
@@ -124,7 +131,7 @@ exports.essays = (head, req) ->
 
   essays = _.map essays, (doc) ->
     if doc.intro?
-      doc.intro_html = converter.makeHtml(
+      doc.intro_html = md.makeHtml(
         doc.intro.replace(/\{\{?baseURL\}?\}/g, dutils.getBaseURL(req))
       )
     doc.published_at_html = utils.prettyDate(doc.published_at)
@@ -150,11 +157,12 @@ exports.essay = (head, req) ->
   return if req.client and req.initial_hit
   start code: 200, headers: {'Content-Type': 'text/html'}
 
-  converter = new Showdown.converter()
+  md = new Showdown.converter()
   essay = null
   collections = []
   author = null
   sponsor = null
+  blocks = {}
   site = null
 
   while row = getRow()
@@ -163,13 +171,14 @@ exports.essay = (head, req) ->
     collections.push(doc) if doc.type is 'collection'
     sponsor ?= doc if doc.type is 'sponsor'
     author ?= doc if doc.type is 'author'
+    blocks[doc.code] = doc if doc.type is 'block'
     site ?= doc if doc.type is 'site'
 
   transformEssay = (doc) ->
-    doc.intro_html = converter.makeHtml(
+    doc.intro_html = md.makeHtml(
       doc.intro.replace(/\{\{?baseURL\}?\}/g, dutils.getBaseURL(req))
     )
-    doc.body_html = converter.makeHtml(
+    doc.body_html = md.makeHtml(
       doc.body.replace(/\{\{?baseURL\}?\}/g, dutils.getBaseURL(req))
     )
     doc.published_at_html = utils.prettyDate(doc.published_at)
@@ -181,7 +190,7 @@ exports.essay = (head, req) ->
 
   collections = _.map collections, (doc) ->
     if doc.intro?
-      doc.intro_html = converter.makeHtml(
+      doc.intro_html = md.makeHtml(
         doc.intro.replace(/\{\{?baseURL\}?\}/g, dutils.getBaseURL(req))
       )
     doc.updated_at_html = utils.prettyDate(doc.updated_at)
@@ -211,6 +220,7 @@ exports.essay = (head, req) ->
         collections: collections
         author: author
         sponsor: sponsor
+        blocks: blocks
         nav: 'essay'
     }
   else
@@ -225,11 +235,11 @@ exports.essay = (head, req) ->
 exports.rssfeed = function (head, req) {
     start({code: 200, headers: {'Content-Type': 'application/rss+xml'}});
 
-    var converter = new Showdown.converter();
+    var md = new Showdown.converter();
 
     var rows = getRows(function (row) {
         var doc = row.doc;
-        doc.markdown_html = converter.makeHtml(
+        doc.markdown_html = md.makeHtml(
             doc.markdown.replace(/\{\{?baseURL\}?\}/g, dutils.getBaseURL(req))
         );
         doc.guid = 'http://caolanmcmahon.com' + (

+ 8 - 0
site/server/views.coffee

@@ -24,6 +24,10 @@ exports.essays_by_collection =
       emit [doc.site, doc.slug, {}], null
       # Also add the collection's sponsor doc
       emit [doc.site, doc.slug, {}], { _id: doc.sponsor_id } if doc.sponsor_id
+      # Also add the collection's associated blocks
+      if doc.blocks
+        for block_id, i in doc.blocks
+          emit [doc.site, doc.slug, {}], { _id: block_id }
       # Also add the site doc
       emit [doc.site, doc.slug, {}], { _id: doc.site }
 
@@ -54,5 +58,9 @@ exports.essays_by_slug =
       emit [doc.site, doc.slug, {}], { _id: doc.author_id } if doc.author_id
       # Also add the essay's sponsor doc
       emit [doc.site, doc.slug, {}], { _id: doc.sponsor_id } if doc.sponsor_id
+      # Also add the essay's associated blocks
+      if doc.blocks
+        for block_id, i in doc.blocks
+          emit [doc.site, doc.slug, {}], { _id: block_id }
       # Also add the site doc
       emit [doc.site, doc.slug, {}], { _id: doc.site }

+ 4 - 0
site/static/css/theme.styl

@@ -196,6 +196,10 @@ article
       color: $textDarkColor
       font-weight: $normalFont
 
+    small
+      font-size: 0.5em
+      margin-left: 0.5em
+
   > .photo
     position: relative
     left: -40px

+ 3 - 1
site/templates/collection.html

@@ -1,7 +1,9 @@
 {{#if collection.css}}<style>{{{collection.css}}}</style>{{/if}}
 
 <article class="collection">
-  <h2 class="title"><a href="{{baseURL}}/collection/{{collection.slug}}">{{collection.name}}</a></h2>
+  <h2 class="title">
+    <a href="{{baseURL}}/collection/{{collection.slug}}">{{collection.name}}</a>&nbsp;<small>Collection</small>
+  </h2>
 
   {{#if collection.photo}}
   <section class="photo">

+ 2 - 0
site/templates/essay.html

@@ -62,6 +62,7 @@
     {{/each}}
   </section>
 
+  {{#if author}}
   <section class="author">
     {{#if author.photo}}
     <div class="photo"><img src="{{author.photo}}" alt="{{author.name}}"></div>
@@ -76,6 +77,7 @@
       </ul>
     </div>
   </section>
+  {{/if}}
 </article>
 
 <nav class="toc-nav">

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.