Skip to content
Browse files

* First commit

  • Loading branch information...
0 parents commit e5369d9cfebdc48b18674f9d9ab6a3f78d2bf44c @joehewitt committed Jun 9, 2011
Showing with 1,182 additions and 0 deletions.
  1. +3 −0 .gitignore
  2. +13 −0 LICENSE
  3. +6 −0 Makefile
  4. +26 −0 README.md
  5. +6 −0 bin/nerve
  6. +496 −0 lib/Blog.js
  7. +85 −0 lib/DiskCache.js
  8. +72 −0 lib/FlickrEmbedder.js
  9. +25 −0 lib/ImageEmbedder.js
  10. +17 −0 lib/Post.js
  11. +320 −0 lib/Server.js
  12. +34 −0 lib/mkdir.js
  13. +6 −0 lib/nerve.js
  14. +26 −0 package.json
  15. +9 −0 test/blogs/a/articles/2011-05-02.md
  16. +6 −0 test/blogs/a/articles/2011-05-03.md
  17. +32 −0 test/nerve-test.js
3 .gitignore
@@ -0,0 +1,3 @@
+node_modules
+build*
+*.o
13 LICENSE
@@ -0,0 +1,13 @@
+Copyright 2011 Joe Hewitt
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
6 Makefile
@@ -0,0 +1,6 @@
+default: test
+
+test:
+ vows test/*-test.js
+
+.PHONY: test
26 README.md
@@ -0,0 +1,26 @@
+nerve
+========
+
+A blogging platform.
+
+Installation
+------------
+
+ $ npm install nerve
+
+License
+-------
+
+Copyright 2011 Joe Hewitt
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
6 bin/nerve
@@ -0,0 +1,6 @@
+#!/usr/bin/env node
+
+var nerve = require('../lib/nerve');
+
+var blogPaths = process.argv.slice(2);
+nerve.run(blogPaths);
496 lib/Blog.js
@@ -0,0 +1,496 @@
+
+var fs = require('fs');
+var path = require('path');
+var URL = require('url');
+var events = require('events');
+var Step = require('step');
+var async = require('async');
+var _ = require('underscore');
+var markdom = require('markdom');
+var datetime = require('datetime');
+var mkdirsSync = require('./mkdir').mkdirsSync;
+var DiskCache = require('./DiskCache').DiskCache;
+var Post = require('./Post').Post;
+var ImageEmbedder = require('./ImageEmbedder').ImageEmbedder;
+
+// *************************************************************************************************
+
+var rePostFileName = /(\d{4})-(\d{2})-(\d{2}).(md|markdown)/;
+
+// *************************************************************************************************
+
+function Blog(blogPath, settings) {
+ events.EventEmitter.call(this);
+
+ this.path = blogPath;
+ this.baseURL = settings.baseURL || '';
+ this.hostName = settings.hostName;
+ this.postsPerPage = settings.postsPerPage || 10;
+ this.transforms = [];
+
+ this.monitor(blogPath);
+
+ var cachePath = settings.cachePath || path.join(blogPath, 'cache');
+ this.diskCache = new DiskCache(cachePath);
+
+ if (settings.logsPath) {
+ var logsPath = path.dirname(settings.logsPath)
+ mkdirsSync(logsPath);
+ this.logPath = settings.logsPath;
+ } else {
+ var logsPath = path.join(blogPath, 'logs');
+ mkdirsSync(logsPath);
+ this.logPath = path.join(logsPath, 'log.txt');
+ }
+
+ if (settings.flickr) {
+ var FlickrEmbedder = require('./FlickrEmbedder').FlickrEmbedder;
+ this.addTransform(new FlickrEmbedder(settings.flickr.key, settings.flickr.secret));
+ }
+
+ this.getAllPosts(function(err, posts) {
+ if (err) {
+ console.log(err);
+ console.trace(err);
+ }
+ });
+}
+
+exports.Blog = Blog;
+
+function subclass(cls, supercls, proto) {
+ cls.super_ = supercls;
+ cls.prototype = Object.create(supercls.prototype, {
+ constructor: {value: cls, enumerable: false}
+ });
+ _.extend(cls.prototype, proto);
+}
+
+subclass(Blog, events.EventEmitter, {
+ monitor: function(articlesPath) {
+ fs.watchFile(articlesPath, {}, _.bind(function() {
+ try {
+ this.invalidate();
+ } catch (exc) {
+ console.log(exc.stack);
+ }
+ }, this));
+ },
+
+ normalizeURL: function(url) {
+ return URL.resolve(this.baseURL, url);
+ },
+
+ invalidate: function() {
+ if (!this.posts) return;
+
+ this.statPosts(_.bind(function(err, statMap) {
+ if (err) return;
+
+ var postMap = {};
+ this.posts.forEach(function(post) {
+ if (post.path in postMap) {
+ postMap[post.path].push(post);
+ } else {
+ postMap[post.path] = [post];
+ }
+ });
+
+ _.each(statMap, _.bind(function(mtime, filePath) {
+ if (!postMap[filePath]) {
+ this._parsePostsFile(path.basename(filePath), _.bind(function(err, posts) {
+ this.posts.push.apply(this.posts, posts);
+ this._assignPosts(this.posts);
+
+ posts.forEach(_.bind(function(post) {
+ this.emit('postCreated', post);
+ }, this));
+ }, this));
+ } else {
+ var previousPosts = postMap[filePath];
+ var previousPost = previousPosts[0];
+ if (mtime > previousPost.mtime) {
+ this._parsePostsFile(path.basename(filePath), _.bind( function(err, posts) {
+ if (err) return;
+
+ posts.forEach(_.bind(function(newPost, index) {
+ if (this._syncPost(newPost)) {
+ this.emit('postChanged', newPost);
+ } else {
+ this.emit('postCreated', newPost);
+ }
+ var oldPost = _.detect(previousPosts, function(post) {
+ return post.slug == newPost.slug;
+ });
+ if (oldPost) {
+ var index = previousPosts.indexOf(oldPost);
+ previousPosts.splice(index, 1);
+ }
+ }, this));
+
+ _.each(previousPosts, _.bind(function(oldPost) {
+ this._removePost(oldPost);
+ this.emit('postDeleted', oldPost);
+ }, this));
+
+ }, this));
+ }
+ delete postMap[filePath];
+ }
+ }, this));
+
+ _.each(postMap, _.bind(function(posts, path) {
+ _.each(posts, _.bind(function(post) {
+ var index = this.posts.indexOf(post);
+ this.posts.splice(index, 1);
+ this.emit('postDeleted', post);
+ }, this));
+
+ this._assignPosts(this.posts);
+ }, this));
+ }, this));
+ },
+
+ statPosts: function(cb) {
+ var blog = this;
+ var articlePaths;
+
+ Step(
+ function() {
+ fs.readdir(blog.path, this);
+ },
+ function(err, fileNames) {
+ if (err) return cb ? cb(err): 0;
+
+ articlePaths = _.map(fileNames, function(fileName) {
+ return path.join(blog.path, fileName);
+ });
+
+ var stats = this.group();
+ _.each(articlePaths, function(articlePath) {
+ fs.lstat(articlePath, stats());
+ });
+ },
+ function(err, stats) {
+ if (err) return cb ? cb(err): 0;
+
+ var statMap = {};
+ _.each(stats, function(stat, i) {
+ statMap[articlePaths[i]] = stat.mtime;
+ });
+ cb(0, statMap);
+ });
+ },
+
+ getAllPosts: function(cb) {
+ if (!this.posts) {
+ this._reload(cb);
+ } else {
+ cb(0, this.datedPosts, this.groupedPosts);
+ }
+ },
+
+ getPostsByPage: function(pageNum, pageSize, render, cb) {
+ if (typeof(render) == 'function') { cb = render; render = false; }
+
+ this.getAllPosts(_.bind(function(err, allPosts) {
+ if (err) return cb ? cb(err): 0;
+
+ var startIndex = pageNum*pageSize;
+ var posts = allPosts.slice(startIndex, startIndex+pageSize);
+
+ if (render) {
+ this.renderPosts(posts, cb);
+ } else {
+ cb(0, posts);
+ }
+ }, this));
+ },
+
+ getPostsByDay: function(year, month, day, render, cb) {
+ if (typeof(render) == 'function') { cb = render; render = false; }
+
+ this.getAllPosts(_.bind(function(err, allPosts) {
+ if (err) return cb ? cb(err) : 0;
+
+ var target = [year, month, day].join('-');
+ var posts = _.select(allPosts, function(post) {
+ return datetime.format(post.date, '%Y-%m-%d') == target;
+ });
+
+ if (render) {
+ this.renderPosts(posts, cb);
+ } else {
+ cb(0, posts);
+ }
+ }, this));
+ },
+
+ getPost: function(slug, year, month, day, render, cb) {
+ if (typeof(render) == 'function') { cb = render; render = false; }
+
+ this.getPostsByDay(year, month, day, _.bind(function(err, allPosts) {
+ if (err) return cb ? cb(err) : 0;
+
+ var posts = _.select(allPosts, function(post) {
+ return post.slug == slug;
+ });
+
+ if (render) {
+ this.renderPosts(posts, cb);
+ } else {
+ cb(0, posts);
+ }
+ }, this));
+ },
+
+ getPostsByGroup: function(groupName, render, cb) {
+ if (typeof(render) == 'function') { cb = render; render = false; }
+
+ this.getAllPosts(_.bind(function(err, allPosts, groupedPosts) {
+ if (err) return cb ? cb(err) : 0;
+
+ var posts = groupedPosts[groupName] || [];
+ if (render) {
+ this.renderPosts(posts, cb);
+ } else {
+ cb(0, posts);
+ }
+ }, this));
+ },
+
+ addTransform: function(transform) {
+ this.transforms.push(transform);
+ },
+
+ matchTransform: function(url) {
+ for (var i = 0; i < this.transforms.length; ++i) {
+ var transform = this.transforms[i];
+ var m = transform.pattern.exec(url);
+ if (m) {
+ return {groups: m.slice(1), transform: transform};
+ }
+ }
+ },
+
+ renderPost: function(post, cb) {
+ // Replaces images with embeds
+ var imageEmbedder = new ImageEmbedder(this);
+ imageEmbedder.visit(post.tree);
+
+ if (!imageEmbedder.embeds.length) {
+ post.body = markdom.toHTML(post.tree);
+ cb(0, post);
+ } else {
+ var blog = this;
+
+ // Asynchronously render each of the embeds
+ Step(
+ function() {
+ var group = this.group();
+ _.each(imageEmbedder.embeds, function(embed) {
+ blog.renderEmbed(embed, group());
+ });
+ },
+ function(err, embeds) {
+ if (err) return cb ? cb(err) : 0;
+
+ // Once embeds are rendered, generate the HTML and send it back
+ post.body = markdom.toHTML(post.tree);
+ cb(0, post);
+ });
+ }
+ },
+
+ renderPosts: function(posts, cb) {
+ async.map(posts, _.bind(this.renderPost, this), cb);
+ },
+
+ renderEmbed: function(embed, cb) {
+ var key = embed.key();
+ console.log('render embed', key);
+
+ this.diskCache.load(key, _.bind(function(err, body) {
+ // console.log('cache?', body);
+
+ if (err || !body || !body.length) {
+ embed.transform(_.bind(function(err, embed) {
+ if (err) return cb(err);
+
+ var md = embed.toMarkdown();
+ this.diskCache.store(key, md);
+ cb(0, embed);
+ }, this));
+ } else {
+ embed.content = markdom.toDOM(body);
+ cb(0, embed);
+
+ }
+ }, this));
+ },
+
+ // * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+ _reload: function(cb) {
+ var blog = this;
+ var articleFileNames, articleFiles;
+
+ fs.readdir(blog.path, function(err, fileNames) {
+ if (err) return cb ? cb(err): 0;
+
+ var filePaths = _.map(fileNames, function(fileName) {
+ return path.join(blog.path, fileName);
+ });
+ async.map(filePaths, fs.lstat, function(err, fileStats) {
+ if (err) return cb ? cb(err): 0;
+
+ fileNames = _.reject(fileNames, function(name, i) { return fileStats[i].isDirectory(); });
+ filePaths = _.reject(filePaths, function(path, i) { return fileStats[i].isDirectory(); });
+ fileStats = _.reject(fileStats, function(stat, i) { return stat.isDirectory(); });
+
+ async.map(filePaths,
+ function(filePath, cb2) {
+ fs.readFile(filePath, 'utf8', cb2);
+ },
+ function(err, fileBodies) {
+ if (err) return cb ? cb(err): 0;
+
+ var i = 0;
+ async.map(fileBodies,
+ function(fileBody, cb2) {
+ blog._parsePosts(fileBody, fileNames[i], fileStats[i], cb2);
+ ++i;
+ },
+ function(err, postFiles) {
+ if (err) return cb ? cb(err): 0;
+
+ var posts = [];
+ postFiles.forEach(function(filePosts) {
+ posts.push.apply(posts, filePosts);
+ });
+
+ blog._assignPosts(posts);
+
+ if (cb) cb(0, blog.datedPosts, blog.groupedPosts);
+ }
+ );
+ });
+ });
+ });
+ },
+
+ _parsePostsFile: function(fileName, cb) {
+ fs.readFile(path.join(this.path, fileName), 'utf8', _.bind(function(err, fileBody) {
+ this._statAndParsePosts(fileBody, fileName, cb);
+ }, this));
+ },
+
+ _statAndParsePosts: function(fileBody, fileName, cb) {
+ fs.lstat(path.join(this.path, fileName), _.bind(function(err, stat) {
+ this._parsePosts(fileBody, fileName, stat, cb);
+ }, this));
+ },
+
+ _parsePosts: function(fileBody, fileName, fileStat, cb) {
+ var tree = markdom.toDOM(fileBody);
+
+ var m = rePostFileName.exec(fileName);
+ var postSlug, postDate, postGroup;
+ if (m) {
+ postSlug = m[1] + '/' + m[2] + '/' + m[3];
+ postDate = new Date(m[2] + '/' + m[3] + '/' + m[1]);
+ } else {
+ postSlug = path.basename(fileName, '.md');
+ postGroup = postSlug;
+ }
+
+ var posts = [];
+ var currentPost;
+ for (var j = 0; j < tree.nodes.length; ++j) {
+ var node = tree.nodes[j];
+ if (node instanceof markdom.nodeTypes.Header && node.level == 1) {
+ var title = node.content.toHTML();
+ var slug = title.toLowerCase().split(/\s+/).join('-');
+ slug = slug.replace(/[^a-z0-9\-]/g, '');
+ var relativeURL = postGroup ? postSlug : postSlug + '/' + slug;
+ currentPost = new Post(this);
+ currentPost.title = title;
+ currentPost.slug = slug;
+ currentPost.mtime = fileStat.mtime;
+ currentPost.path = path.join(this.path, fileName);
+ currentPost.url = urlJoin(this.baseURL, relativeURL);
+ currentPost.date = postDate;
+ currentPost.group = postGroup;
+ currentPost.tree = new markdom.nodeTypes.NodeSet([]);
+ posts.push(currentPost);
+ } else if (currentPost) {
+ currentPost.tree.nodes.push(node);
+ }
+ }
+
+ if (cb) cb(0, posts);
+ },
+
+ _syncPost: function(newPost) {
+ var oldPost = _.detect(this.posts, function(post) {
+ return post.path == newPost.path && post.slug == newPost.slug;
+ });
+ if (oldPost) {
+ this._removePost(oldPost, newPost);
+ return true;
+ } else {
+ this.posts.push(newPost);
+ this._assignPosts(this.posts);
+ return false;
+ }
+ },
+
+ _removePost: function(oldPost, newPost) {
+ var index = this.posts.indexOf(oldPost);
+ if (newPost) {
+ this.posts.splice(index, 1, newPost);
+ } else {
+ this.posts.splice(index, 1);
+
+ }
+ this._assignPosts(this.posts);
+ },
+
+ _assignPosts: function(posts) {
+ this.posts = posts;
+
+ this.datedPosts = _.select(posts, function(post) {
+ return post.date;
+ });
+ this.datedPosts.sort(function(a, b) {
+ return a.date > b.date ? -1 : 1;
+ });
+ this.groupedPosts = groupArray(posts, function(post) {
+ return post.group;
+ });
+ },
+});
+
+function urlJoin(a, b) {
+ if (a[a.length-1] == '/' || b[0] == '/') {
+ return a + b;
+ } else {
+ return a + '/' + b;
+ }
+}
+
+function groupArray(items, cb) {
+ var groups = {};
+ for (var i = 0; i < items.length; ++i) {
+ var name = cb(items[i], i);
+ if (name) {
+ var group = groups[name];
+ if (group) {
+ group.push(items[i]);
+ } else {
+ groups[name] = [items[i]];
+ }
+ }
+ }
+ return groups;
+}
85 lib/DiskCache.js
@@ -0,0 +1,85 @@
+
+var crypto = require('crypto');
+var path = require('path');
+var fs = require('fs');
+var _ = require('underscore');
+var mkdirsSync = require('./mkdir').mkdirsSync;
+
+// *************************************************************************************************
+
+function DiskCache(cachePath, useMemCache) {
+ this.cachePath = cachePath;
+ this.useMemCache = useMemCache;
+ this.memCache = {};
+
+ mkdirsSync(cachePath);
+}
+
+DiskCache.prototype = {
+ store: function(url, body, cb) {
+ if (this.useMemCache) {
+ this.memCache[url] = body;
+ }
+
+ var filePath = this.pathForURL(url);
+ fs.open(filePath, 'w', undefined, function(err, fd) {
+ fs.write(fd, body, undefined, undefined, undefined, function(err, written, buffer) {
+ fs.closeSync(fd);
+ if (cb) {
+ cb(err);
+ }
+ });
+ });
+ },
+
+ load: function(url, cb) {
+ var filePath = this.pathForURL(url);
+ console.log('try to load', filePath, 'for', url);
+
+ fs.readFile(filePath, function(err, body) {
+ if (err) return cb ? cb(err) : 0;
+
+ if (this.useMemCache) {
+ this.memCache[url] = body;
+ }
+
+ cb(0, body);
+ });;
+ },
+
+ remove: function(url, cb) {
+ var filePath = this.pathForURL(url);
+ console.log('remove', filePath, 'for', url);
+ fs.unlink(filePath, cb);
+
+ if (this.useMemCache) {
+ delete this.memCache[url];
+ }
+ },
+
+ removeAll: function() {
+ var fileNames = fs.readdirSync(this.cachePath);
+ _.each(fileNames, _.bind(function(fileName) {
+ fs.unlink(path.join(this.cachePath, fileName));
+ }, this));
+
+ if (this.useMemCache) {
+ this.memCache = {};
+ }
+ },
+
+ keyForURL: function(url) {
+ var hash = crypto.createHash('md5');
+ hash.update(url);
+ return hash.digest('hex');
+ },
+
+ pathForURL: function(url) {
+ var key = this.keyForURL(url);
+ var fileName = key + '.txt';
+ return path.join(this.cachePath, fileName);
+ }
+
+};
+
+exports.DiskCache = DiskCache;
72 lib/FlickrEmbedder.js
@@ -0,0 +1,72 @@
+
+var markdom = require('markdom');
+var flickr = require('flickr-reflection');
+var _ = require('underscore');
+
+// ************************************************************************************************
+
+function FlickrEmbedder(key, secret) {
+ this.key = key;
+ this.secret = secret;
+}
+exports.FlickrEmbedder = FlickrEmbedder;
+
+FlickrEmbedder.prototype = {
+ pattern: /http:\/\/.*?\.flickr\.com\/photos\/(.*?)\/(.*?)\//,
+
+ authenticate: function(cb) {
+ if (this.api) {
+ cb(0, this.api);
+ } else if (this.authenticators) {
+ this.authenticators.push(cb);
+ } else {
+ this.authenticators = [cb];
+ var options = {key: this.key, secret: this.secret, apis: ['photos']};
+ console.log('Authenticating Flickr...')
+ flickr.connect(options, _.bind(function(err, api) {
+ if (err) { _.map(this.authenticators, function(cba) { cba(err) }); return; }
+
+ this.api = api;
+ _.map(this.authenticators, function(cba) { cba(0, api); });
+ this.authenticators = null;
+ }, this));
+ }
+ },
+
+ transform: function(userId, photoId, url, title, alt, cb) {
+ this.authenticate(_.bind(function(err, api) {
+ if (err) return cb ? cb(err) : 0;
+
+ api.photos.getSizes({photo_id: photoId}, _.bind(function(err, data) {
+ if (err) return cb ? cb(err) : 0;
+
+ var metadata = {};
+ for (var i = 0; i < data.sizes.size.length; ++i) {
+ var sizeInfo = data.sizes.size[i];
+ console.log(sizeInfo);
+ if (sizeInfo.label == "Thumbnail") {
+ metadata.thumb = sizeInfo.source;
+ } else if (sizeInfo.label == "Medium") {
+ metadata.small = sizeInfo.source;
+ } else if (sizeInfo.label == "Large") {
+ metadata.large = sizeInfo.source;
+ }
+ // if (sizeInfo.width > 800) {
+ // var newImage = new markdom.nodeTypes.Image(sizeInfo.source);
+ // newImage.width = sizeInfo.width;
+ // newImage.height = sizeInfo.height;
+ // // var newLink = new markdom.nodeTypes.Link(url, null, newImage);
+ // var newLink = new markdom.nodeTypes.Link(sizeInfo.source);
+ // newLink.className = 'flickrLink';
+ // cb(0, newLink);
+ // break;
+ // }
+ }
+
+ var metadataJSON = JSON.stringify(metadata);
+ var script = new markdom.nodeTypes.Script('uponahill/photo', metadataJSON);
+ cb(0, script);
+ }, this));
+ }, this));
+ }
+};
25 lib/ImageEmbedder.js
@@ -0,0 +1,25 @@
+
+var _ = require('underscore');
+var markdom = require('markdom');
+
+// *************************************************************************************************
+
+function ImageEmbedder(blog) {
+ this.blog = blog;
+ this.embeds = [];
+}
+
+ImageEmbedder.prototype = _.extend(new markdom.NodeTransformer(), {
+ image: function(node) {
+ var m = this.blog.matchTransform(node.url);
+ if (m) {
+ var embed = new markdom.nodeTypes.Embed(node.url, node.title, node.alt, m.groups, m.transform);
+ this.embeds.push(embed);
+ return embed;
+ } else {
+ return node;
+ }
+ },
+});
+
+exports.ImageEmbedder = ImageEmbedder;
17 lib/Post.js
@@ -0,0 +1,17 @@
+
+var markdom = require('markdom');
+var datetime = require('datetime');
+
+// *************************************************************************************************
+
+function Post(blog) {
+ this.blog = blog;
+}
+
+Post.prototype = {
+ get isChronological() {
+ return !!this.date;
+ },
+};
+
+exports.Post = Post;
320 lib/Server.js
@@ -0,0 +1,320 @@
+
+var express = require('express');
+var fs = require('fs');
+var path = require('path');
+var Step = require('step');
+var jade = require('jade');
+var stylus = require('stylus');
+var jsdom = require('jsdom');
+var _ = require('underscore');
+var datetime = require('datetime');
+var Blog = require('./Blog').Blog;
+
+// *************************************************************************************************
+
+var defaultPort = 8080;
+
+var rePostFileName = /(\d{4})-(\d{2})-(\d{2}).(md|markdown)/;
+
+// *************************************************************************************************
+
+function Server(port) {
+ this.port = port || defaultPort;
+ this.blogs = [];
+}
+
+Server.prototype = {
+ addBlog: function(blog) {
+ this.blogs.push(blog);
+ },
+
+ restart: function() {
+ if (this.app) {
+ this.app.stop();
+ }
+
+ var apps = [];
+ for (var i = 0; i < this.blogs.length; ++i) {
+ var blog = this.blogs[i];
+ var app = this.getServerForBlog(blog);
+ if (app) {
+ blog.app = app;
+ apps.push(app);
+
+ this.monitorBlog(blog);
+ }
+ }
+
+ if (apps.length == 1) {
+ this.mainApp = apps[0];
+ } else {
+ this.mainApp = express.createServer();
+ for (var i = 0; i < this.blogs.length; ++i) {
+ var blog = this.blogs[i];
+ if (!blog.hostName) {
+ throw new Exception("Blog requires a host name (" + blog.path + ")");
+ }
+ this.mainApp.use(express.vhost(blog.hostName, blog.app));
+ }
+ }
+
+ this.mainApp.listen(this.port);
+ console.log("Nerve server listening on port %d", this.mainApp.address().port);
+ },
+
+ renderCachedPage: function(blog, fn) {
+ return _.bind(function(req, res) {
+ try {
+ var url = blog.normalizeURL(req.url);
+ blog.diskCache.load(url, _.bind(function(err, body) {
+ if (err || !body || !body.length) {
+ fn(req, res);
+ } else {
+ sendPage(req, res, body);
+ }
+ }, this));
+ } catch (exc) {
+ console.log(exc.stack);
+ }
+ }, this);
+ },
+
+ cachePage: function(url, body, blog) {
+ blog.diskCache.store(url, body);
+ },
+
+ uncachePage: function(url, blog) {
+ blog.diskCache.remove(url);
+ },
+
+ resetPageCache: function(blog) {
+ blog.diskCache.removeAll();
+ },
+
+ postModified: function(post) {
+ console.log('MODIFIED ', post.title);
+ if (post.isChronological) {
+ // XXXjoe Need a more surgical way to delete only index pages that reference the post
+ this.resetPageCache(post.blog);
+ } else {
+ this.uncachePage(post.url, post.blog);
+ }
+ },
+
+ monitorBlog: function(blog) {
+ blog.on('postCreated', _.bind(this.postModified, this));
+ blog.on('postChanged', _.bind(this.postModified, this));
+ blog.on('postDeleted', _.bind(this.postModified, this));
+ },
+
+ getServerForBlog: function(blog) {
+ var app = express.createServer();
+ var logStream = fs.createWriteStream(blog.logPath, {flags: 'a'});
+
+ app.configure(function(){
+ app.set('views', path.join(blog.path, 'web', 'views'));
+ app.set('view engine', 'jade');
+ app.use(express.bodyParser());
+ app.use(express.methodOverride());
+ app.use(stylus.middleware({
+ src: path.join(blog.path, 'web', 'static'),
+ compile: stylusCompileMethod(path.join(blog.path, 'web', 'static', 'stylesheets'))
+ }));
+ app.use(express.logger({stream: logStream}));
+ app.use(app.router);
+ app.use(express.static(path.join(blog.path, 'web', 'static')));
+ });
+
+ app.configure('development', function() {
+ app.use(express.errorHandler({ dumpExceptions: true, showStack: true }));
+ });
+
+ app.configure('production', function() {
+ app.use(express.errorHandler());
+ });
+
+ this.getRouterForBlog(app, blog);
+
+ return app;
+ },
+
+ getRouterForBlog: function(app, blog) {
+ app.get('/', this.renderCachedPage(blog, indexPage));
+ app.get('/page/:page', this.renderCachedPage(blog, indexPage));
+ app.get('/:year/:month/:day/:id', this.renderCachedPage(blog, postPage));
+ app.get('/about', this.renderCachedPage(blog, aboutPage));
+ app.get('/drafts', this.renderCachedPage(blog, draftsPage));
+ app.get('/rss.xml', this.renderCachedPage(blog, rssPage));
+ // app.get('*', errorPage);
+
+ var server = this;
+
+ var jadeContext = {
+ formatDate: function(postDate) {
+ return postDate ? datetime.format(postDate, '%B %e%k, %Y') : '';
+ },
+
+ formatBody: function(body) {
+ var doc = jsdom.jsdom(body);
+ var scripts = doc.getElementsByTagName('script')
+ for (var i = 0; i < scripts.length; ++i) {
+ var script = scripts[i];
+ script.parentNode.removeChild(script);
+ }
+ return doc.documentElement.innerHTML;
+ },
+
+ facebookComments: function(url) {
+ return '<div id="fb-root"></div>'
+ + '<script src="http://connect.facebook.net/en_US/all.js#appId='
+ + blog.facebookAppId+'&amp;xfbml=1">'
+ + '</script><fb:comments href="'+url+'" num_posts="2" width="352"></fb:comments>';
+ },
+
+ facebookCommentCount: function(url) {
+ return '<div id="fb-root"></div>'
+ + '<script src="http://connect.facebook.net/en_US/all.js#appId='
+ + blog.facebookAppId+'&amp;xfbml=1">'
+ + '</script><fb:comments-count href="'+url+'"></fb:comments-count>';
+ }
+ };
+
+ function indexPage(req, res) {
+ var pageNum = req.params.page ? parseInt(req.params.page)-1 : 0;
+ renderMultiplePosts(req, res, 'index', pageNum, function() {
+ blog.getPostsByPage(pageNum, blog.postsPerPage, true, this);
+ });
+ }
+
+ function rssPage(req, res) {
+ renderMultiplePosts(req, res, 'rss', 0, function() {
+ blog.getPostsByPage(0, blog.postsPerPage, true, this);
+ });
+ }
+
+ function aboutPage(req, res, fileName) {
+ renderSinglePost(req, res, function() {
+ blog.getPostsByGroup('about', true, this);
+ });
+ }
+
+ function draftsPage(req, res, fileName) {
+ renderMultiplePosts(req, res, 'index', 0, function() {
+ blog.getPostsByGroup('drafts', true, this);
+ });
+ }
+
+ function postPage(req, res) {
+ renderSinglePost(req, res, function() {
+ blog.getPost(req.params.id, req.params.year, req.params.month, req.params.day, true, this);
+ });
+ }
+
+ function errorPage(req, res) {
+ renderError(req, res, 404, 'Not Found');
+ }
+
+ function renderMultiplePosts(req, res, viewName, pageNum, fn) {
+ Step(
+ fn,
+ function(err, posts) {
+ try {
+ if (err) {
+ renderError(req, res, 500, err);
+ } else if (!posts.length) {
+ renderError(req, res, 404, 'Not Found');
+ } else {
+ var isLastPage = (pageNum+1)*blog.postsPerPage < blog.datedPosts.length;
+ renderPage(req, res, viewName, {
+ context: jadeContext,
+ title: blog.title,
+ posts: posts,
+ olderLink: isLastPage ? '/page/'+(pageNum+2) : '',
+ newerLink: pageNum > 0 ? '/page/'+(pageNum) : ''
+ });
+ }
+ } catch (exc) {
+ renderError(req, res, 500, 'Bad baby');
+ }
+ });
+ }
+
+ function renderSinglePost(req, res, fn) {
+ Step(
+ fn,
+ function(err, posts) {
+ try {
+ if (err) {
+ renderError(req, res, 500, err);
+ } else if (!posts || !posts.length) {
+ renderError(req, res, 404, 'Not Found');
+ } else {
+ renderPage(req, res, 'post', {
+ context: jadeContext,
+ title: posts[0].title,
+ post: posts[0],
+ });
+ }
+ } catch (exc) {
+ renderError(req, res, 500, 'Bad baby');
+ }
+ });
+ }
+
+ function renderError(req, res, code, description) {
+ if (process.env['NODE_ENV'] == 'production') {
+ res.send('Error', {'Content-Type': 'text/html'}, code);
+ } else {
+ res.send('Error: ' + description, {'Content-Type': 'text/html'}, code);
+ //throw description;
+ }
+ }
+
+ function renderPage(req, res, name, locals) {
+ var jadePath = path.join(blog.path, 'web', 'views', name + '.jade');
+ jade.renderFile(jadePath, {locals: locals}, function(err, html) {
+ if (err) {
+ renderError(req, res, 500, err);
+ } else {
+ var url = blog.normalizeURL(req.url);
+ server.cachePage(url, html, blog);
+ sendPage(req, res, html);
+ }
+ });
+ }
+
+ }
+};
+
+// *************************************************************************************************
+
+function sendPage(req, res, html) {
+ res.send(html, {'Content-Type': 'text/html'}, 200);
+}
+
+function stylusCompileMethod(stylesheetsPath) {
+ return function(str) {
+ return stylus(str)
+ .define('url', stylus.url({paths: [stylesheetsPath]}))
+ .set('compress', true);
+ }
+};
+
+// *************************************************************************************************
+
+exports.run = function(blogPaths) {
+ var server = new Server();
+
+ _.each(blogPaths, function(blogPath) {
+ blogPath = path.resolve(blogPath);
+ if (!fs.lstatSync(blogPath)) {
+ throw new Exception("Blog path not found (" + blogPath + ")");
+ }
+
+ var settings = JSON.parse(fs.readFileSync(path.join(blogPath, 'web', 'settings.json')));
+ var blog = new Blog(blogPath, settings);
+ server.addBlog(blog);
+ })
+
+ server.restart();
+}
34 lib/mkdir.js
@@ -0,0 +1,34 @@
+// This code courtesy of Rasmus Andersson via https://gist.github.com/319051
+
+var fs = require('fs');
+var path = require('path');
+
+// mkdirsSync(path, [mode=(0777^umask)]) -> pathsCreated
+exports.mkdirsSync = function (dirname, mode) {
+ if (mode === undefined) mode = 0x1ff ^ process.umask();
+ var pathsCreated = [], pathsFound = [];
+ var fn = dirname;
+ while (true) {
+ try {
+ var stats = fs.statSync(fn);
+ if (stats.isDirectory())
+ break;
+ throw new Error('Unable to create directory at '+fn);
+ }
+ catch (e) {
+ if (e.errno === 2/*ENOENT*/) {
+ pathsFound.push(fn);
+ fn = path.dirname(fn);
+ }
+ else {
+ throw e;
+ }
+ }
+ }
+ for (var i=pathsFound.length-1; i>-1; i--) {
+ var fn = pathsFound[i];
+ fs.mkdirSync(fn, mode);
+ pathsCreated.push(fn);
+ }
+ return pathsCreated;
+};
6 lib/nerve.js
@@ -0,0 +1,6 @@
+
+var Blog = require('./Blog').Blog;
+var Server = require('./Server');
+
+exports.Blog = Blog;
+exports.run = Server.run;
26 package.json
@@ -0,0 +1,26 @@
+{
+ "name": "nerve",
+ "description": "Nerve blogging platform",
+ "url": "http://github.com/joehewitt/nerve",
+ "keywords": ["blog"],
+ "author": "Joe Hewitt <joe@joehewitt.com>",
+ "contributors": [],
+ "bin" : "./bin/nerve",
+ "dependencies": {
+ "async": "",
+ "datetime": "",
+ "express": "",
+ "flickr-reflection": "",
+ "jade": "",
+ "jsdom": "",
+ "markdom": "",
+ "step": "",
+ "stylus": "",
+ "underscore": "",
+ "vows": ">=0.5.4",
+ },
+ "version": "0.0.1",
+ "main": "./lib/nerve",
+ "directories": { "test": "./test" },
+ "engines": { "node": ">=0.4.0" }
+}
9 test/blogs/a/articles/2011-05-02.md
@@ -0,0 +1,9 @@
+Post Uno
+========
+
+abc.
+
+Post Duo
+========
+
+def.
6 test/blogs/a/articles/2011-05-03.md
@@ -0,0 +1,6 @@
+
+Title
+====================================================================================================
+
+This is an introductory blog post.
+
32 test/nerve-test.js
@@ -0,0 +1,32 @@
+var path = require('path'),
+ assert = require('assert'),
+ vows = require('vows');
+
+require.paths.unshift(path.join(__dirname, '..', 'lib'));
+
+var nerve = require('nerve');
+
+// *************************************************************************************************
+
+vows.describe('nerve basics').addBatch({
+ 'A blog': {
+ topic: new nerve.Blog(path.join(__dirname, 'blogs/a'), 'http://example.com'),
+
+ 'has posts': {
+ topic: function(blog) {
+ blog.getAllPosts(this.callback);
+ },
+
+ 'of length 3': function(posts) {
+ assert.equal(posts.length, 3);
+ },
+
+ 'with titles': function(posts) {
+ assert.equal(posts[0].title, 'Title');
+ assert.equal(posts[1].title, 'Post Duo');
+ assert.equal(posts[2].title, 'Post Uno');
+ },
+ }
+
+ },
+}).export(module);

0 comments on commit e5369d9

Please sign in to comment.
Something went wrong with that request. Please try again.