commenting system is almost ready for primetime

* switched to CORS from JSONP
* improved style
* separated almost all JavaScript from the HTML
* minify & combine JavaScript using closure & cat
* fleshed out Makefile
This commit is contained in:
Sami Samhuri 2010-12-18 02:24:01 -08:00
parent ab85efcf6b
commit 94bf683fb1
22 changed files with 2216 additions and 357 deletions

1
.gitignore vendored
View file

@ -1,4 +1,5 @@
*.tmproj
.DS_Store
_blog
assets/*.min.js
discussd/discuss.dirty

View file

@ -1,2 +1,45 @@
build:
JAVASCRIPTS=assets/blog.js assets/gitter.js assets/jquery-serializeObject.js assets/proj.js \
assets/request.js assets/showdown.js assets/storage-polyfill.js assets/store.js \
assets/strftime.js assets/tmpl.js
MIN_JAVASCRIPTS=assets/blog.min.js assets/gitter.min.js assets/jquery-serializeObject.min.js assets/proj.min.js \
assets/request.min.js assets/showdown.min.js assets/storage-polyfill.min.js assets/store.min.js \
assets/strftime.min.js assets/tmpl.min.js
POSTS=$(shell echo _blog/*.html)
all: proj blog combine
proj: projects.json templates/proj/index.html templates/proj/proj/index.html
./build.js
blog: _blog/posts.json templates/blog/index.html templates/blog/post.html $(POSTS)
@echo
./blog.rb _blog blog
minify: $(JAVASCRIPTS)
@echo
./minify.sh
combine: minify $(MIN_JAVASCRIPTS)
@echo
./combine.sh
publish_blog: blog combine
publish blog
publish_proj: proj combine
publish proj
publish: publish_blog publish_proj index.html
publish index.html
publish assets
publish blog
publish proj
clean:
rm -rf proj/*
rm -rf blog/*
rm assets/*.min.js
.PHONY: blog

View file

@ -48,9 +48,9 @@ a.img { border: none }
}
#index { display: none
; width: 60%
; width: 80%
; min-width: 200px
; max-width: 600px
; max-width: 800px
; border: solid 1px #999
; -moz-border-radius: 10px
; -webkit-border-radius: 10px
@ -68,22 +68,22 @@ a.img { border: none }
.date { float: right }
#posts { border-left: solid 0.2em #ddd
#posts { border-left: solid 0.15em #999
; width: 80%
; min-width: 400px
; max-width: 800px
; margin: 4em auto
; margin: 4em auto 2em
; padding: 0 3em
; font-size: 1.2em
}
article { color: #222
; padding-bottom: 5em
; padding-bottom: 1em
; line-height: 1.2em
}
article:last-child { padding-bottom: 0
; margin-bottom: 2em
; margin-bottom: 1em
}
article h1 { text-align: left
@ -116,7 +116,9 @@ article h3 { font-size: 1.6em
; font-weight: normal
}
time { color: #444 }
time { color: #444
; font-size: 1.2em
}
.gist { font-size: 0.8em }
@ -124,21 +126,93 @@ time { color: #444 }
; min-width: 400px
; max-width: 800px
; margin: 1em auto
; padding: 0 3em
; padding: 0 3em 1em
; font-size: 1.2em
}
#prev { float: left }
#next { float: right }
#comments { display: none }
.sep { text-align: center
; font-size: 2em
; color: #666
}
/* show discussion */
#sd-container { margin: 3em 0 }
input[type=submit],
#sd { border: solid 1px #999
; border-right-color: #333
; border-bottom-color: #333
; padding: 0.4em 1em
; color: #444
; background-color: #ececec
; -moz-border-radius: 5px
; -webkit-border-radius: 5px
; border-radius: 5px
; text-decoration: none
; margin: 0 2px 2px 0
}
input[type=submit]:active,
#sd:active { margin: 2px 0 0 2px
; color: #000
; background-color: #ffc
}
#comment-stuff { display: none
; color: #efefef
; margin: 0
; padding: 2em 0
}
#comments { width: 70%
; max-width: 400px
; margin: 0 auto
}
.comment { color: #555
; border-top: solid 2px #ccc
; padding-bottom: 2em
; margin-bottom: 2em
}
.comment big { font-size: 2em
; font-family: Georgia, serif
}
#comment-form { width: 400px
; margin: 2em auto 0
}
input[type=text],
textarea { font-size: 1.4em
; color: #333
; width: 100%
; padding: 0.2em
; border: solid 1px #999
; -moz-border-radius: 5px
; -webkit-border-radius: 5px
; border-radius: 5px
; font-family: verdana, sans-serif
}
input:focus[type=text],
textarea:focus { border: solid 1px #333 }
textarea { height: 100px }
input[type=submit] { font-size: 1.1em
; cursor: pointer
}
footer { text-align: center
; font-size: 1.2em
; margin: 0
; padding: 1em
; background-color: #a6bcdf
; border-top: solid 1px #666
; padding: 1em 0
; background-color: #cdf
; border-top: solid 1px #bbb
; clear: both
}

78
assets/blog.js Normal file
View file

@ -0,0 +1,78 @@
;(function() {
var server = 'http://bohodev.net:8000/'
, getCommentsURL = function(post) { return server + 'comments/' + post }
, postCommentURL = function() { return server + 'comment' }
, countCommentsURL = function(post) { return server + 'count/' + post }
function getComments() {
console.log('*** getComments()')
SJS.request({uri: getCommentsURL(SJS.filename)}, function(err, request, body) {
if (err) {
$('#comments').text('derp')
return
}
var data
, comments
, h = ''
try {
data = JSON.parse(body)
} catch (e) {
console && console.log && console.log('not json -> ' + body)
}
comments = data.comments
if (comments.length) {
h = data.comments.map(function(c) {
return tmpl('comment_tmpl', c)
}).join('')
}
$('#comments').html(h)
})
}
jQuery(function($) {
$('#need-js').remove()
SJS.request({uri: countCommentsURL(SJS.filename)}, function(err, request, body) {
if (err) return
var data
, n
try {
data = JSON.parse(body)
} catch (e) {
console && console.log && console.log('not json -> ' + body)
}
n = data.count
$('#sd').text(n > 0 ? 'show the discussion (' + n + ')' : 'start the discussion')
})
$('#sd').click(function() {
$('#sd-container').remove()
$('#comment-stuff').slideDown(1.5, function() { this.scrollIntoView(true) })
getComments()
return false
})
var showdown = new Showdown.converter()
$('#comment-form').submit(function() {
var comment = $(this).serializeObject()
var options = { method: 'POST'
, uri: postCommentURL()
, body: JSON.stringify(comment)
}
SJS.request(options, function(err, request, body) {
if (err) {
console.dir(err)
alert('derp')
return
}
// FIXME check for error, how do we get the returned status code?
comment.timestamp = +new Date()
comment.html = showdown.makeHtml(comment.body)
$('#comments').append(tmpl('comment_tmpl', comment))
})
return false
})
})
}());

View file

@ -23,7 +23,7 @@
while (
div.innerHTML = '<!--[if gt IE ' + (++v) + ']><i></i><![endif]-->',
all[0]
);
){/* braces here to satifsy closure-compiler */};
return v > 4 ? v : undef
}())
@ -594,7 +594,7 @@
, url = options.uri + '?callback=GITR.' + jsonpCallbackName
GITR[jsonpCallbackName] = function(obj) {
cb(null, null, obj)
setTimeout(function() { delete GITR[jsonpCallbackName] }, 0)
delete GITR[jsonpCallbackName]
}
load(url)
}

31
assets/jquery-serializeObject.js vendored Normal file
View file

@ -0,0 +1,31 @@
/*!
* jQuery serializeObject - v0.2 - 1/20/2010
* http://benalman.com/projects/jquery-misc-plugins/
*
* Copyright (c) 2010 "Cowboy" Ben Alman
* Dual licensed under the MIT and GPL licenses.
* http://benalman.com/about/license/
*/
// Whereas .serializeArray() serializes a form into an array, .serializeObject()
// serializes a form into an (arguably more useful) object.
;(function($,undefined){
'$:nomunge'; // Used by YUI compressor.
$.fn.serializeObject = function(){
var obj = {};
$.each( this.serializeArray(), function(i,o){
var n = o.name,
v = o.value;
obj[n] = obj[n] === undefined ? v
: $.isArray( obj[n] ) ? obj[n].concat( v )
: [ obj[n], v ];
});
return obj;
};
})(jQuery);

View file

@ -1,54 +1,31 @@
(function() {
;(function() {
if (typeof SJS === 'undefined') SJS = {}
var load, _jsonpCounter = 1
SJS.request = function(options, cb) { // jsonp request, quacks like mikeal's request module
var jsonpCallbackName = '_jsonpCallback' + _jsonpCounter++
, url = options.uri + '?callback=SJS.' + jsonpCallbackName
SJS[jsonpCallbackName] = function(obj) {
cb(null, obj)
setTimeout(function() { delete SJS[jsonpCallbackName] }, 0)
}
load(url)
}
// bootstrap loader from LABjs
load = function(url) {
var oDOC = document
, handler
, head = oDOC.head || oDOC.getElementsByTagName("head")
// cors xhr request, quacks like mikeal's request module
SJS.request = function(options, cb) {
var url = options.uri
, method = options.method || 'GET'
, headers = options.headers || {}
, body = typeof options.body === 'undefined' ? null : String(options.body)
, xhr = new XMLHttpRequest()
// loading code borrowed directly from LABjs itself
// (now removes script elem when done and nullifies its reference --sjs)
setTimeout(function () {
if ("item" in head) { // check if ref is still a live node list
if (!head[0]) { // append_to node not yet ready
setTimeout(arguments.callee, 25)
// withCredentials => cors
if ('withCredentials' in xhr) {
xhr.open(method, url, true)
} else if (typeof XDomainRequest === 'functon') {
xhr = new XDomainRequest()
xhr.open(method, url)
} else {
cb(new Error('cross domain requests not supported'))
return
}
head = head[0]; // reassign from live node list ref to pure node ref -- avoids nasty IE bug where changes to DOM invalidate live node lists
for (var k in headers) if (headers.hasOwnProperty(k)) {
xhr.setRequestHeader(k, headers[k])
}
var scriptElem = oDOC.createElement("script"),
scriptdone = false
scriptElem.onload = scriptElem.onreadystatechange = function () {
if ((scriptElem.readyState && scriptElem.readyState !== "complete" && scriptElem.readyState !== "loaded") || scriptdone) {
return false
xhr.onload = function() {
var response = xhr.responseText
cb(null, xhr, response)
}
scriptElem.onload = scriptElem.onreadystatechange = null
scriptElem.parentNode.removeChild(scriptElem)
scriptElem = null
scriptdone = true
};
scriptElem.src = url
head.insertBefore(scriptElem, head.firstChild)
}, 0)
// required: shim for FF <= 3.5 not having document.readyState
if (oDOC.readyState == null && oDOC.addEventListener) {
oDOC.readyState = "loading"
oDOC.addEventListener("DOMContentLoaded", function handler() {
oDOC.removeEventListener("DOMContentLoaded", handler, false)
oDOC.readyState = "complete"
}, false)
xhr.send(body)
}
}
}())
}());

1296
assets/showdown.js Normal file

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

83
assets/strftime.js Normal file
View file

@ -0,0 +1,83 @@
/// strftime
/// http://github.com/samsonjs/strftime
/// @_sjs
///
/// Copyright 2010 Sami Samhuri <sami.samhuri@gmail.com>
/// MIT License
var strftime = (function() {
var Weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday',
'Friday', 'Saturday'];
var WeekdaysShort = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
var Months = ['January', 'February', 'March', 'April', 'May', 'June', 'July',
'August', 'September', 'October', 'November', 'December'];
var MonthsShort = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug',
'Sep', 'Oct', 'Nov', 'Dec'];
function pad(n, padding) {
padding = padding || '0';
return n < 10 ? (padding + n) : n;
}
function hours12(d) {
var hour = d.getHours();
if (hour == 0) hour = 12;
else if (hour > 12) hour -= 12;
return hour;
}
// Most of the specifiers supported by C's strftime
function strftime(fmt, d) {
d || (d = new Date());
return fmt.replace(/%(.)/g, function(_, c) {
switch (c) {
case 'A': return Weekdays[d.getDay()];
case 'a': return WeekdaysShort[d.getDay()];
case 'B': return Months[d.getMonth()];
case 'b': // fall through
case 'h': return MonthsShort[d.getMonth()];
case 'D': return strftime('%m/%d/%y', d);
case 'd': return pad(d.getDate());
case 'e': return d.getDate();
case 'F': return strftime('%Y-%m-%d', d);
case 'H': return pad(d.getHours());
case 'I': return pad(hours12(d));
case 'k': return pad(d.getHours(), ' ');
case 'l': return pad(hours12(d), ' ');
case 'M': return pad(d.getMinutes());
case 'm': return pad(d.getMonth() + 1);
case 'n': return '\n';
case 'p': return d.getHours() < 12 ? 'AM' : 'PM';
case 'R': return strftime('%H:%M', d);
case 'r': return strftime('%I:%M:%S %p', d);
case 'S': return pad(d.getSeconds());
case 's': return d.getTime();
case 'T': return strftime('%H:%M:%S', d);
case 't': return '\t';
case 'u':
var day = d.getDay();
return day == 0 ? 7 : day; // 1 - 7, Monday is first day of the week
case 'v': return strftime('%e-%b-%Y', d);
case 'w': return d.getDay(); // 0 - 6, Sunday is first day of the week
case 'Y': return d.getFullYear();
case 'y':
var year = d.getYear();
return year < 100 ? year : year - 100;
case 'Z':
var tz = d.toString().match(/\((\w+)\)/);
return tz && tz[1] || '';
case 'z':
var off = d.getTimezoneOffset();
return (off < 0 ? '-' : '+') + pad(off / 60) + pad(off % 60);
default: return c;
}
});
}
return strftime;
}());
if (typeof exports !== 'undefined') exports.strftime = strftime;
else (function(global) { global.strftime = strftime }(this));

35
assets/tmpl.js Normal file
View file

@ -0,0 +1,35 @@
// Simple JavaScript Templating
// John Resig - http://ejohn.org/ - MIT Licensed
;(function(){
var cache = {};
this.tmpl = function tmpl(str, data){
// Figure out if we're getting a template, or if we need to
// load the template - and be sure to cache the result.
var fn = !/\W/.test(str) ?
cache[str] = cache[str] ||
tmpl(document.getElementById(str).innerHTML) :
// Generate a reusable function that will serve as a template
// generator (and which will be cached).
new Function("obj",
"var p=[],print=function(){p.push.apply(p,arguments);};" +
// Introduce the data as local variables using with(){}
"with(obj){p.push('" +
// Convert the template into pure JavaScript
str
.replace(/[\r\t\n]/g, " ")
.split("<%").join("\t")
.replace(/((^|%>)[^\t]*)'/g, "$1\r")
.replace(/\t=(.*?)%>/g, "',$1,'")
.split("\t").join("');")
.split("%>").join("p.push('")
.split("\r").join("\\'")
+ "');}return p.join('');");
// Provide some basic currying to the user
return data ? fn( data ) : fn;
};
})();

View file

@ -46,6 +46,7 @@ posts.each_with_index do |post, i|
:post => post,
:previous => i < posts.length - 1 && posts[i + 1],
:next => i > 0 && posts[i - 1],
:filename => post[:filename],
:comments => post[:comments]
})
end
@ -54,7 +55,9 @@ end
index_template = File.read(File.join('templates', 'blog', 'index.html'))
index_html = Mustache.render(index_template, { :posts => posts,
:post => posts.first,
:previous => posts[1]
:previous => posts[1],
:filename => posts.first[:filename],
:comments => posts.first[:comments]
})
# write landing page

View file

@ -7,36 +7,20 @@
var _gaq = _gaq || [];
_gaq.push( ['_setAccount', 'UA-214054-5']
, ['_trackPageview']
);
)
(function() {
var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
})();
if (document.addEventListener) {
document.addEventListener('DOMContentLoaded', ready, false)
} else if (window.attachEvent) {
window.attachEvent('onload', ready)
}
function ready() {
function html(id, h) {
document.getElementById(id).innerHTML = h
}
var discussion = document.getElementById('discussion')
, discussionToggle = document.getElementById('discussion-toggle')
, hidden = true
discussionToggle.onclick = function() {
hidden = !hidden
discussion.style.display = hidden ? 'none' : 'block'
discussionToggle.innerHTML = hidden ? '&darr; show discussion &darr;' : '&uarr; hide discussion &uarr;'
SJS.request({uri: 'http://bohodev.net:8000/comments/'}, function(err, body) {
html('comments', body)
})
}
}
;(function() {
var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true
ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js'
var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s)
})()
</script>
<script src=http://ajax.googleapis.com/ajax/libs/jquery/1.4.4/jquery.min.js></script>
<script>
window.SJS = window.SJS || {}
SJS.filename = "37signals-chalk-dissected.html"
</script>
<script src=../assets/blog-all.min.js></script>
<header>
<h1><a href=index.html>sjs' blog</a></h1>
</header>
@ -292,25 +276,39 @@ addLineNumbersToAllGists();
</article>
</div>
<p class=sep>&#x0f04;</p>
<div id=around>
<a id=prev href=a-preview-of-mach-o-file-generation.html>&larr; A preview of Mach-O file generation</a>
<br style=clear:both>
</div>
<div class=center><a id=discussion-toggle href=#>&darr; show discussion &darr;</a></div>
<div id=discussion>
<div id=comment-form>
<form method=post action=http://bohodev.net:8000/comment>
<input name=from type=hidden value=37signals-chalk-dissected.html>
<p>Name: <input name=name size=30></p>
<p>URL: <input name=url size=30></p>
<p>Thoughts: <textarea name=body cols=40 rows=5></textarea></p>
<p><input type=submit value=Add to discussion></p>
</form>
</div>
<p id=need-js align=center><strong>(discussion requires JavaScript)</strong></p>
<div class=center id=sd-container><a id=sd href=#comment-stuff>show the discussion</a></div>
<div id=comment-stuff>
<div id=comments>
<img id=discussion-spinner src=../assets/spinner.gif>
</div>
<form id=comment-form>
<input name=post type=hidden value=37signals-chalk-dissected.html>
<p><input name=name type=text placeholder=name></p>
<p><input name=email type=text placeholder=email></p>
<p><input name=url type=text placeholder=url></p>
<p><textarea name=body placeholder=thoughts></textarea></p>
<p align=center><input type=submit value='so there'></p>
</form>
</div>
<script type="text/html" id="comment_tmpl">
<div class=comment>
<p>
<% if (url) { %>
<a href="<%= url %>"><%= name %></a>
<% } else { %>
<%= name %>
<% } %>
@ <%= strftime('%F %r', new Date(timestamp)) %>
</p>
<blockquote><%= html %></blockquote>
</div>
</script>
<footer>
<a href=https://twitter.com/_sjs>@_sjs</a>
</footer>

View file

@ -7,36 +7,20 @@
var _gaq = _gaq || [];
_gaq.push( ['_setAccount', 'UA-214054-5']
, ['_trackPageview']
);
)
(function() {
var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
})();
if (document.addEventListener) {
document.addEventListener('DOMContentLoaded', ready, false)
} else if (window.attachEvent) {
window.attachEvent('onload', ready)
}
function ready() {
function html(id, h) {
document.getElementById(id).innerHTML = h
}
var discussion = document.getElementById('discussion')
, discussionToggle = document.getElementById('discussion-toggle')
, hidden = true
discussionToggle.onclick = function() {
hidden = !hidden
discussion.style.display = hidden ? 'none' : 'block'
discussionToggle.innerHTML = hidden ? '&darr; show discussion &darr;' : '&uarr; hide discussion &uarr;'
SJS.request({uri: 'http://bohodev.net:8000/comments/'}, function(err, body) {
html('comments', body)
})
}
}
;(function() {
var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true
ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js'
var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s)
})()
</script>
<script src=http://ajax.googleapis.com/ajax/libs/jquery/1.4.4/jquery.min.js></script>
<script>
window.SJS = window.SJS || {}
SJS.filename = "a-preview-of-mach-o-file-generation.html"
</script>
<script src=../assets/blog-all.min.js></script>
<header>
<h1><a href=index.html>sjs' blog</a></h1>
</header>
@ -86,26 +70,40 @@ straightforward, an example is in asm/binary.rb, in the #output method.</p>
</article>
</div>
<p class=sep>&#x0f04;</p>
<div id=around>
<a id=prev href=basics-of-the-mach-o-file-format.html>&larr; Basics of the Mach-O file format</a>
<a id=next href=37signals-chalk-dissected.html>37signals' Chalk Dissected &rarr;</a>
<br style=clear:both>
</div>
<div class=center><a id=discussion-toggle href=#>&darr; show discussion &darr;</a></div>
<div id=discussion>
<div id=comment-form>
<form method=post action=http://bohodev.net:8000/comment>
<input name=from type=hidden value=a-preview-of-mach-o-file-generation.html>
<p>Name: <input name=name size=30></p>
<p>URL: <input name=url size=30></p>
<p>Thoughts: <textarea name=body cols=40 rows=5></textarea></p>
<p><input type=submit value=Add to discussion></p>
</form>
</div>
<p id=need-js align=center><strong>(discussion requires JavaScript)</strong></p>
<div class=center id=sd-container><a id=sd href=#comment-stuff>show the discussion</a></div>
<div id=comment-stuff>
<div id=comments>
<img id=discussion-spinner src=../assets/spinner.gif>
</div>
<form id=comment-form>
<input name=post type=hidden value=a-preview-of-mach-o-file-generation.html>
<p><input name=name type=text placeholder=name></p>
<p><input name=email type=text placeholder=email></p>
<p><input name=url type=text placeholder=url></p>
<p><textarea name=body placeholder=thoughts></textarea></p>
<p align=center><input type=submit value='so there'></p>
</form>
</div>
<script type="text/html" id="comment_tmpl">
<div class=comment>
<p>
<% if (url) { %>
<a href="<%= url %>"><%= name %></a>
<% } else { %>
<%= name %>
<% } %>
@ <%= strftime('%F %r', new Date(timestamp)) %>
</p>
<blockquote><%= html %></blockquote>
</div>
</script>
<footer>
<a href=https://twitter.com/_sjs>@_sjs</a>
</footer>

View file

@ -7,36 +7,20 @@
var _gaq = _gaq || [];
_gaq.push( ['_setAccount', 'UA-214054-5']
, ['_trackPageview']
);
)
(function() {
var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
})();
if (document.addEventListener) {
document.addEventListener('DOMContentLoaded', ready, false)
} else if (window.attachEvent) {
window.attachEvent('onload', ready)
}
function ready() {
function html(id, h) {
document.getElementById(id).innerHTML = h
}
var discussion = document.getElementById('discussion')
, discussionToggle = document.getElementById('discussion-toggle')
, hidden = true
discussionToggle.onclick = function() {
hidden = !hidden
discussion.style.display = hidden ? 'none' : 'block'
discussionToggle.innerHTML = hidden ? '&darr; show discussion &darr;' : '&uarr; hide discussion &uarr;'
SJS.request({uri: 'http://bohodev.net:8000/comments/'}, function(err, body) {
html('comments', body)
})
}
}
;(function() {
var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true
ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js'
var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s)
})()
</script>
<script src=http://ajax.googleapis.com/ajax/libs/jquery/1.4.4/jquery.min.js></script>
<script>
window.SJS = window.SJS || {}
SJS.filename = "basics-of-the-mach-o-file-format.html"
</script>
<script src=../assets/blog-all.min.js></script>
<header>
<h1><a href=index.html>sjs' blog</a></h1>
</header>
@ -309,26 +293,40 @@ would almost have a useful Mach object file.)</i></p>
</article>
</div>
<p class=sep>&#x0f04;</p>
<div id=around>
<a id=prev href=working-with-c-style-structs-in-ruby.html>&larr; Working with C-style structs in Ruby</a>
<a id=next href=a-preview-of-mach-o-file-generation.html>A preview of Mach-O file generation &rarr;</a>
<br style=clear:both>
</div>
<div class=center><a id=discussion-toggle href=#>&darr; show discussion &darr;</a></div>
<div id=discussion>
<div id=comment-form>
<form method=post action=http://bohodev.net:8000/comment>
<input name=from type=hidden value=basics-of-the-mach-o-file-format.html>
<p>Name: <input name=name size=30></p>
<p>URL: <input name=url size=30></p>
<p>Thoughts: <textarea name=body cols=40 rows=5></textarea></p>
<p><input type=submit value=Add to discussion></p>
</form>
</div>
<p id=need-js align=center><strong>(discussion requires JavaScript)</strong></p>
<div class=center id=sd-container><a id=sd href=#comment-stuff>show the discussion</a></div>
<div id=comment-stuff>
<div id=comments>
<img id=discussion-spinner src=../assets/spinner.gif>
</div>
<form id=comment-form>
<input name=post type=hidden value=basics-of-the-mach-o-file-format.html>
<p><input name=name type=text placeholder=name></p>
<p><input name=email type=text placeholder=email></p>
<p><input name=url type=text placeholder=url></p>
<p><textarea name=body placeholder=thoughts></textarea></p>
<p align=center><input type=submit value='so there'></p>
</form>
</div>
<script type="text/html" id="comment_tmpl">
<div class=comment>
<p>
<% if (url) { %>
<a href="<%= url %>"><%= name %></a>
<% } else { %>
<%= name %>
<% } %>
@ <%= strftime('%F %r', new Date(timestamp)) %>
</p>
<blockquote><%= html %></blockquote>
</div>
</script>
<footer>
<a href=https://twitter.com/_sjs>@_sjs</a>
</footer>

View file

@ -3,34 +3,34 @@
<meta name=viewport content=width=device-width>
<title>blog :: samhuri.net</title>
<link rel=stylesheet href=../assets/blog.css>
<script src=http://ajax.googleapis.com/ajax/libs/jquery/1.4.4/jquery.min.js></script>
<script>
var _gaq = _gaq || [];
_gaq.push( ['_setAccount', 'UA-214054-5']
, ['_trackPageview']
);
(function() {
;(function() {
var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
})();
if (document.addEventListener) {
document.addEventListener('DOMContentLoaded', ready, false)
} else if (window.attachEvent) {
window.attachEvent('onload', ready)
}
function ready() {
var index = document.getElementById('index')
, indexToggle = document.getElementById('index-toggle')
, hidden = true
indexToggle.onclick = function() {
jQuery(function($) {
var hidden = true
, index = $('#index')
$('#index-toggle').click(function() {
index.toggle()
hidden = !hidden
index.style.display = hidden ? 'none' : 'block'
indexToggle.innerHTML = hidden ? '&darr; show posts &darr;' : '&uarr; hide posts &uarr;'
}
}
$(this).html(hidden ? '&darr; show posts &darr;' : '&uarr; hide posts &uarr;')
})
})
</script>
<script>
window.SJS = window.SJS || {}
SJS.filename = "37signals-chalk-dissected.html"
</script>
<script src=../assets/blog-all.min.js></script>
<header>
<h1><a href=index.html>sjs' blog</a></h1>
</header>
@ -294,11 +294,40 @@ addLineNumbersToAllGists();
</article>
</div>
</div>
<p class=sep>&#x0f04;</p>
<div id=around>
<a id=prev href=a-preview-of-mach-o-file-generation.html>&larr; A preview of Mach-O file generation</a>
<br style=clear:both>
</div>
<p id=need-js align=center><strong>(discussion requires JavaScript)</strong></p>
<div class=center id=sd-container><a id=sd href=#comment-stuff>show the discussion</a></div>
<div id=comment-stuff>
<div id=comments>
<img id=discussion-spinner src=../assets/spinner.gif>
</div>
<form id=comment-form>
<input name=post type=hidden value=37signals-chalk-dissected.html>
<p><input name=name type=text placeholder=name></p>
<p><input name=email type=text placeholder=email></p>
<p><input name=url type=text placeholder=url></p>
<p><textarea name=body placeholder=thoughts></textarea></p>
<p align=center><input type=submit value='so there'></p>
</form>
</div>
<script type="text/html" id="comment_tmpl">
<div class=comment>
<p>
<% if (url) { %>
<a href="<%= url %>"><%= name %></a>
<% } else { %>
<%= name %>
<% } %>
@ <%= strftime('%F %r', new Date(timestamp)) %>
</p>
<blockquote><%= html %></blockquote>
</div>
</script>
<footer>
<a href=https://twitter.com/_sjs>@_sjs</a>
</footer>

View file

@ -7,36 +7,20 @@
var _gaq = _gaq || [];
_gaq.push( ['_setAccount', 'UA-214054-5']
, ['_trackPageview']
);
)
(function() {
var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
})();
if (document.addEventListener) {
document.addEventListener('DOMContentLoaded', ready, false)
} else if (window.attachEvent) {
window.attachEvent('onload', ready)
}
function ready() {
function html(id, h) {
document.getElementById(id).innerHTML = h
}
var discussion = document.getElementById('discussion')
, discussionToggle = document.getElementById('discussion-toggle')
, hidden = true
discussionToggle.onclick = function() {
hidden = !hidden
discussion.style.display = hidden ? 'none' : 'block'
discussionToggle.innerHTML = hidden ? '&darr; show discussion &darr;' : '&uarr; hide discussion &uarr;'
SJS.request({uri: 'http://bohodev.net:8000/comments/'}, function(err, body) {
html('comments', body)
})
}
}
;(function() {
var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true
ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js'
var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s)
})()
</script>
<script src=http://ajax.googleapis.com/ajax/libs/jquery/1.4.4/jquery.min.js></script>
<script>
window.SJS = window.SJS || {}
SJS.filename = "working-with-c-style-structs-in-ruby.html"
</script>
<script src=../assets/blog-all.min.js></script>
<header>
<h1><a href=index.html>sjs' blog</a></h1>
</header>
@ -158,25 +142,39 @@ of the Mach-O file format</a></i><p>
</article>
</div>
<p class=sep>&#x0f04;</p>
<div id=around>
<a id=next href=basics-of-the-mach-o-file-format.html>Basics of the Mach-O file format &rarr;</a>
<br style=clear:both>
</div>
<div class=center><a id=discussion-toggle href=#>&darr; show discussion &darr;</a></div>
<div id=discussion>
<div id=comment-form>
<form method=post action=http://bohodev.net:8000/comment>
<input name=from type=hidden value=working-with-c-style-structs-in-ruby.html>
<p>Name: <input name=name size=30></p>
<p>URL: <input name=url size=30></p>
<p>Thoughts: <textarea name=body cols=40 rows=5></textarea></p>
<p><input type=submit value=Add to discussion></p>
</form>
</div>
<p id=need-js align=center><strong>(discussion requires JavaScript)</strong></p>
<div class=center id=sd-container><a id=sd href=#comment-stuff>show the discussion</a></div>
<div id=comment-stuff>
<div id=comments>
<img id=discussion-spinner src=../assets/spinner.gif>
</div>
<form id=comment-form>
<input name=post type=hidden value=working-with-c-style-structs-in-ruby.html>
<p><input name=name type=text placeholder=name></p>
<p><input name=email type=text placeholder=email></p>
<p><input name=url type=text placeholder=url></p>
<p><textarea name=body placeholder=thoughts></textarea></p>
<p align=center><input type=submit value='so there'></p>
</form>
</div>
<script type="text/html" id="comment_tmpl">
<div class=comment>
<p>
<% if (url) { %>
<a href="<%= url %>"><%= name %></a>
<% } else { %>
<%= name %>
<% } %>
@ <%= strftime('%F %r', new Date(timestamp)) %>
</p>
<blockquote><%= html %></blockquote>
</div>
</script>
<footer>
<a href=https://twitter.com/_sjs>@_sjs</a>
</footer>

7
combine.sh Executable file
View file

@ -0,0 +1,7 @@
#!/usr/bin/env zsh
echo "request,showdown,strftime,tmpl,jquery-serializeObject,blog -> assets/blog-all.min.js"
cat assets/{request,showdown,strftime,tmpl,jquery-serializeObject,blog}.min.js >|assets/blog-all.min.js
echo "gitter.storage-polyfill,store,proj -> assets/proj-all.min.js"
cat assets/{gitter,storage-polyfill,store,proj}.min.js >|assets/proj-all.min.js

View file

@ -2,10 +2,14 @@
var fs = require('fs')
, http = require('http')
, path = require('path')
, parseURL = require('url').parse
, keys = require('keys')
, markdown = require('markdown')
, strftime = require('strftime').strftime
, DefaultOptions = { host: 'localhost'
, port: 2020
, postsFile: 'posts.json'
, postsFile: path.join(__dirname, 'posts.json')
}
function main() {
@ -19,12 +23,15 @@ function main() {
readJSON(options.postsFile, function(err, posts) {
if (err) {
console.error('failed to parse posts file, is it valid JSON?')
console.dir(e)
console.dir(err)
process.exit(1)
}
if (context.posts === null) {
var n = posts.published.length
, t = strftime('%Y-%m-%d %I:%M:%S %p')
console.log('(' + t + ') ' + 'loaded discussions for ' + n + ' posts...')
}
context.posts = posts.published
var n = context.posts.length
console.log((context.posts === null ? '' : 're') + 'loaded ' + n + ' posts...')
if (typeof cb == 'function') cb()
})
}
@ -56,63 +63,232 @@ function readJSON(f, cb) {
})
}
function requestHandler(context) {
function addComment(data) {
if (missingParams(data) || context.posts.indexOf(data.post) === -1) {
console.log('missing params or invalid post title in ' + JSON.stringify(data, null, 2))
return false
// returns a request handler that returns a string
function createTextHandler(options) {
if (typeof options === 'string') {
options = { body: options }
} else {
options = options || {}
}
var comments = context.db.get(data.post) || []
comments.push({ name: data.name
, email: data.email
, body: data.body
var body = options.body || ''
, code = options.cody || 200
, type = options.type || 'text/plain'
, n = body.length
return function(req, res) {
var headers = res.headers || {}
headers['content-type'] = type
headers['content-length'] = n
// console.log('code: ', code)
// console.log('headers: ', JSON.stringify(headers, null, 2))
// console.log('body: ', body)
res.writeHead(code, headers)
res.end(body)
}
}
// Cross-Origin Resource Sharing
var createCorsHandler = (function() {
var AllowedOrigins = [ 'http://samhuri.net'
, 'http://localhost:8888'
]
return function(handler) {
handler = handler || createTextHandler('ok')
return function(req, res) {
var origin = req.headers.origin
console.log('origin: ', origin)
console.log('index: ', AllowedOrigins.indexOf(origin))
if (AllowedOrigins.indexOf(origin) !== -1) {
res.headers = { 'Access-Control-Allow-Origin': origin
, 'Access-Control-Request-Method': 'POST, GET'
, 'Access-Control-Allow-Headers': 'content-type'
}
handler(req, res)
} else {
BadRequest(req, res)
}
}
}
}())
var DefaultHandler = createTextHandler({ code: 404, body: 'not found' })
, BadRequest = createTextHandler({ code: 400, body: 'bad request' })
, ServerError = createTextHandler({ code: 500, body: 'server error' })
, _routes = {}
function route(method, pattern, handler) {
if (typeof pattern === 'function' && !handler) {
handler = pattern
pattern = ''
}
if (!pattern || typeof pattern.exec !== 'function') {
pattern = new RegExp('^/' + pattern)
}
var route = { pattern: pattern, handler: handler }
console.log('routing ' + method, pattern)
if (!(method in _routes)) _routes[method] = []
_routes[method].push(route)
}
function resolve(method, path) {
var rs = _routes[method]
, i = rs.length
, m
, r
while (i--) {
r = rs[i]
m = r.pattern.exec ? r.pattern.exec(path) : path.match(r.pattern)
if (m) return r.handler
}
console.warn('*** using default handler, this is probably not what you want')
return DefaultHandler
}
function get(pattern, handler) {
route('GET', pattern, handler)
}
function post(pattern, handler) {
route('POST', pattern, handler)
}
function options(pattern, handler) {
route('OPTIONS', pattern, handler)
}
function handleRequest(req, res) {
var handler = resolve(req.method, req.url)
try {
handler(req, res)
} catch (e) {
console.error('!!! error handling ' + req.method, req.url)
console.dir(e)
}
}
function commentServer(context) {
function addComment(post, name, email, url, body) {
var comments = context.db.get(post) || []
comments.push({ name: name
, email: email
, url: url
, body: body
, timestamp: Date.now()
})
context.db.set(data.post, comments)
console.log('[' + new Date() + '] add comment ' + JSON.stringify(data, null, 2))
return true
context.db.set(post, comments)
console.log('[' + new Date() + '] comment on ' + post)
console.log('name:', name)
console.log('email:', email)
console.log('url:', url)
console.log('body:', body)
}
return function(req, res) {
var body = ''
, m
if (req.method === 'POST' && req.url.match(/^\/comment\/?$/)) {
req.on('data', function(chunk) { body += chunk })
req.on('end', function() {
var data
try {
data = JSON.parse(body)
} catch (x) {
badRequest(res)
return
}
if (!addComment(data)) {
badRequest(res)
return
}
res.writeHead(204)
res.end()
// TODO mail watchers about the comment
})
} else if (req.method === 'GET' && (m = req.url.match(/^\/comments\/(.*)$/))) {
var post = m[1]
function getComments(req, res) {
var post = parseURL(req.url).pathname.replace(/^\/comments\//, '')
, comments
, s
if (context.posts.indexOf(post) === -1) {
badRequest(res)
console.warn('post not found: ' + post)
BadRequest(req, res)
return
}
comments = context.db.get(post) || []
s = JSON.stringify({comments: comments})
res.writeHead(200, { 'content-type': 'appliaction/json'
, 'content-length': s.length
})
res.end(s)
} else {
console.log('unhandled request')
console.dir(req)
badRequest(res)
res.respond({comments: comments.map(function(c) {
delete c.email
c.html = markdown.parse(c.body)
// FIXME discount has a race condition, sometimes gives a string
// with trailing garbage.
while (c.html.charAt(c.html.length - 1) !== '>') {
console.log("!!! removing trailing garbage from discount's html")
c.html = c.html.slice(0, c.html.length - 1)
}
return c
})})
}
function postComment(req, res) {
var body = ''
req.on('data', function(chunk) { body += chunk })
req.on('end', function() {
var data, post, name, email, url
try {
data = JSON.parse(body)
} catch (e) {
console.log('not json -> ' + body)
BadRequest(req, res)
return
}
post = (data.post || '').trim()
name = (data.name || 'anonymous').trim()
email = (data.email || '').trim()
url = (data.url || '').trim()
if (!url.match(/^https?:\/\//)) url = 'http://' + url
body = data.body || ''
if (!post || !body || context.posts.indexOf(post) === -1) {
console.warn('mising post, body, or post not found: ' + post)
console.warn('body: ', body)
BadRequest(req, res)
return
}
addComment(post, name, email, url, body)
res.respond()
// TODO mail watchers about the comment
})
}
function countComments(req, res) {
var post = parseURL(req.url).pathname.replace(/^\/count\//, '')
, comments
if (context.posts.indexOf(post) === -1) {
console.warn('post not found: ' + post)
BadRequest(req, res)
return
}
comments = context.db.get(post) || []
res.respond({count: comments.length})
}
return { get: getComments
, count: countComments
, post: postComment
}
}
function requestHandler(context) {
var comments = commentServer(context)
get(/comments\//, createCorsHandler(comments.get))
get(/count\//, createCorsHandler(comments.count))
post(/comment\/?/, createCorsHandler(comments.post))
options(createCorsHandler())
return function(req, res) {
console.log(req.method + ' ' + req.url)
res.respond = function(obj) {
var s = ''
var headers = res.headers || {}
if (obj) {
try {
s = JSON.stringify(obj)
} catch (e) {
ServerError(req, res)
return
}
headers['content-type'] = 'application/json'
}
headers['content-length'] = s.length
/*
console.log('code: ', s ? 200 : 204)
process.stdout.write('headers: ')
console.dir(headers)
console.log('body: ', s)
*/
res.writeHead(s ? 200 : 204, headers)
res.end(s)
}
handleRequest(req, res)
}
}
@ -151,14 +327,6 @@ function parseArgs(defaults) {
return options
}
function badRequest(res) {
var s = 'bad request'
res.writeHead(400, { 'content-type': 'text/plain'
, 'content-length': s.length
})
res.end(s)
}
var missingParams = (function() {
var requiredParams = 'name email body'.split(' ')
return function(d) {

11
minify.sh Executable file
View file

@ -0,0 +1,11 @@
#!/usr/bin/env zsh
setopt extendedglob
for js (assets/*.js~*.min.js) {
target=${js%.js}.min.js
if [ ! -f $target ] || [ $js -nt $target ]; then
echo "$js -> $target"
closure-compiler < $js >| $target
fi
}

View file

@ -3,34 +3,36 @@
<meta name=viewport content=width=device-width>
<title>blog :: samhuri.net</title>
<link rel=stylesheet href=../assets/blog.css>
<script src=http://ajax.googleapis.com/ajax/libs/jquery/1.4.4/jquery.min.js></script>
<script>
var _gaq = _gaq || [];
_gaq.push( ['_setAccount', 'UA-214054-5']
, ['_trackPageview']
);
(function() {
;(function() {
var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
})();
if (document.addEventListener) {
document.addEventListener('DOMContentLoaded', ready, false)
} else if (window.attachEvent) {
window.attachEvent('onload', ready)
}
function ready() {
var index = document.getElementById('index')
, indexToggle = document.getElementById('index-toggle')
, hidden = true
indexToggle.onclick = function() {
jQuery(function($) {
var hidden = true
, index = $('#index')
$('#index-toggle').click(function() {
index.toggle()
hidden = !hidden
index.style.display = hidden ? 'none' : 'block'
indexToggle.innerHTML = hidden ? '&darr; show posts &darr;' : '&uarr; hide posts &uarr;'
}
}
$(this).html(hidden ? '&darr; show posts &darr;' : '&uarr; hide posts &uarr;')
})
})
</script>
{{#comments}}
<script>
window.SJS = window.SJS || {}
SJS.filename = "{{filename}}"
</script>
<script src=../assets/blog-all.min.js></script>
{{/comments}}
<header>
<h1><a href=index.html>sjs' blog</a></h1>
</header>
@ -45,16 +47,17 @@ function ready() {
</nav>
<div id=posts>
{{! TODO extract a post partial used here and in post.html }}
{{#post}}
<article>
{{#post}}
<header>
<h1><a href={{filename}}>{{title}}</a></h1>
<time>{{date}}</time>
</header>
{{{body}}}
</article>
{{/post}}
</article>
</div>
<p class=sep>&#x0f04;</p>
<div id=around>
{{#previous}}
<a id=prev href={{filename}}>&larr; {{title}}</a>
@ -64,6 +67,36 @@ function ready() {
{{/next}}
<br style=clear:both>
</div>
{{#comments}}
<p id=need-js align=center><strong>(discussion requires JavaScript)</strong></p>
<div class=center id=sd-container><a id=sd href=#comment-stuff>show the discussion</a></div>
<div id=comment-stuff>
<div id=comments>
<img id=discussion-spinner src=../assets/spinner.gif>
</div>
<form id=comment-form>
<input name=post type=hidden value={{filename}}>
<p><input name=name type=text placeholder=name></p>
<p><input name=email type=text placeholder=email></p>
<p><input name=url type=text placeholder=url></p>
<p><textarea name=body placeholder=thoughts></textarea></p>
<p align=center><input type=submit value='so there'></p>
</form>
</div>
<script type="text/html" id="comment_tmpl">
<div class=comment>
<p>
<% if (url) { %>
<a href="<%= url %>"><%= name %></a>
<% } else { %>
<%= name %>
<% } %>
@ <%= strftime('%F %r', new Date(timestamp)) %>
</p>
<blockquote><%= html %></blockquote>
</div>
</script>
{{/comments}}
<footer>
<a href=https://twitter.com/_sjs>@_sjs</a>
</footer>

View file

@ -7,38 +7,22 @@
var _gaq = _gaq || [];
_gaq.push( ['_setAccount', 'UA-214054-5']
, ['_trackPageview']
);
)
(function() {
var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true;
ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js';
var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s);
})();
{{#comments}}
if (document.addEventListener) {
document.addEventListener('DOMContentLoaded', ready, false)
} else if (window.attachEvent) {
window.attachEvent('onload', ready)
}
function ready() {
function html(id, h) {
document.getElementById(id).innerHTML = h
}
var discussion = document.getElementById('discussion')
, discussionToggle = document.getElementById('discussion-toggle')
, hidden = true
discussionToggle.onclick = function() {
hidden = !hidden
discussion.style.display = hidden ? 'none' : 'block'
discussionToggle.innerHTML = hidden ? '&darr; show discussion &darr;' : '&uarr; hide discussion &uarr;'
SJS.request({uri: 'http://bohodev.net:8000/comments/{{filename}}'}, function(err, body) {
html('comments', body)
})
}
}
{{/comments}}
;(function() {
var ga = document.createElement('script'); ga.type = 'text/javascript'; ga.async = true
ga.src = ('https:' == document.location.protocol ? 'https://ssl' : 'http://www') + '.google-analytics.com/ga.js'
var s = document.getElementsByTagName('script')[0]; s.parentNode.insertBefore(ga, s)
})()
</script>
{{#comments}}
<script src=http://ajax.googleapis.com/ajax/libs/jquery/1.4.4/jquery.min.js></script>
<script>
window.SJS = window.SJS || {}
SJS.filename = "{{filename}}"
</script>
<script src=../assets/blog-all.min.js></script>
{{/comments}}
<header>
<h1><a href=index.html>sjs' blog</a></h1>
</header>
@ -55,6 +39,7 @@ function ready() {
{{/post}}
</article>
</div>
<p class=sep>&#x0f04;</p>
<div id=around>
{{#previous}}
<a id=prev href={{filename}}>&larr; {{title}}</a>
@ -64,22 +49,35 @@ function ready() {
{{/next}}
<br style=clear:both>
</div>
<div class=center><a id=discussion-toggle href=#>&darr; show discussion &darr;</a></div>
{{#comments}}
<div id=discussion>
<div id=comment-form>
<form method=post action=http://bohodev.net:8000/comment>
<input name=from type=hidden value={{#post}}{{filename}}{{/post}}>
<p>Name: <input name=name size=30></p>
<p>URL: <input name=url size=30></p>
<p>Thoughts: <textarea name=body cols=40 rows=5></textarea></p>
<p><input type=submit value=Add to discussion></p>
</form>
</div>
<p id=need-js align=center><strong>(discussion requires JavaScript)</strong></p>
<div class=center id=sd-container><a id=sd href=#comment-stuff>show the discussion</a></div>
<div id=comment-stuff>
<div id=comments>
<img id=discussion-spinner src=../assets/spinner.gif>
</div>
<form id=comment-form>
<input name=post type=hidden value={{filename}}>
<p><input name=name type=text placeholder=name></p>
<p><input name=email type=text placeholder=email></p>
<p><input name=url type=text placeholder=url></p>
<p><textarea name=body placeholder=thoughts></textarea></p>
<p align=center><input type=submit value='so there'></p>
</form>
</div>
<script type="text/html" id="comment_tmpl">
<div class=comment>
<p>
<% if (url) { %>
<a href="<%= url %>"><%= name %></a>
<% } else { %>
<%= name %>
<% } %>
@ <%= strftime('%F %r', new Date(timestamp)) %>
</p>
<blockquote><%= html %></blockquote>
</div>
</script>
{{/comments}}
<footer>
<a href=https://twitter.com/_sjs>@_sjs</a>