Pop

Pop is a static site and blog generator for Node.

cli_tools

lib/cli_tools.js

Module dependencies.

var yamlish = require('yamlish')
  , path = require('path')
  , fs = require('fs')
  , log = require(__dirname + '/log')
  , generators = require(__dirname + '/generators');

module.exports = {

Adds zero padding to single digit numbers.

  • param: Integer A number to pad

  • return: String

datePad: function(num) {
    return num.toString().length === 1 ? '0' + num : num;
  },

Makes a post file name (not URL) based on the config's permanlink format.

  • param: String Permalink format

  • param: Date Date for the file name

  • param: String The title for the post

  • param: String The file format (md or textile)

  • return: String

getPostFileName: function(pf, date, title, format) {
    title = encodeURI(title.toLowerCase().replace(/\s+/g, '-'));
    return '_posts/' + pf.replace(':year', date.getFullYear())
             .replace(':month', this.datePad(date.getMonth() + 1))
             .replace(':day', this.datePad(date.getDate()))
             .replace(/^\//, '')
             .replace(/\//g, '-')
             .replace(':title', title + '.' + format);
  },

Generates a stubbed post and writes it based on a file name.

  • param: Config A config object

  • param: String Site title

  • param: Function callback

  • return: String

makePost: function(config, title, fn) {
    // TODO: Get format and author from command-line options
    var meta = {
          layout: 'post'
        , title: title
        , author: process.env.LOGNAME || ''
        , tags: ['tag_1', 'tag_2']
        }
      , url = ''
      , format = 'md'
      , date = new Date()
      , frontMatter = ''
      , fileName = this.getPostFileName(config.permalink, date, title, format);

    frontMatter = '---\n' + yamlish.encode(meta).replace(/^\s+/mg, '') + '\n---\n';

    fs.writeFile(path.join(config.root, fileName), frontMatter, function(err) {
      if (err) {
        log.error('Error writing file:', fileName);
        throw(err);
      }
      log.info('Post created:', fileName);
      fn();
    });
  },

Default config settings, used by site generator.

  • return: String

defaultConfig: function() {
    return '' 
    + '{  "url": "http://example.com"\n'
    + ' , "title": "Example"\n'
    + ' , "permalink": "/:year/:month/:day/:title"\n'
    + ' , "perPage": 10\n'
    + ' , "exclude": ["\\\\.swp"]\n'
    + ' , "autoGenerate": [{"feed": "feed.xml", "rss": "feed.rss"}] }\n'
  },

Default index file, used by site generator.

  • return: String

defaultIndex: function() {
    return ''
    + '---\n'
    + 'layout: default\n'
    + 'title: My Site\n'
    + 'paginate: true\n'
    + '---\n\n'
    + '!{paginatedPosts()}\n'
    + '!{paginate}\n';
  },

Default layout, used by site generator.

  • return: String

defaultLayout: function() {
    return fs.readFileSync(__dirname + '/assets/default.jade');
  },

Default post layout, used by site generator.

  • return: String

defaultPostLayout: function() {
    return fs.readFileSync(__dirname + '/assets/post.jade');
  },

Default client-side JavaScript.

  • return: String

defaultClientJavaScript: function() {
    return fs.readFileSync(__dirname + '/assets/site.js');
  },

Default tags page.

  • return: String

defaultTags: function() {
    return fs.readFileSync(__dirname + '/assets/tags.jade');
  },

Sample post, to get people started.

  • return: String

samplePost: function() {
    return ''
    + '---\n'
    + 'title: Example Post About Something\n'
    + 'author: Pop\n'
    + 'layout: post\n'
    + 'tags:\n'
    + '- tag1\n'
    + '- tag2\n'
    + '---\n'
    + 'Pop is a static site generator.  It can be used to make blogs.  I hope you enjoy it!\n';
  },

Built-in stylesheet.

  • return: String

defaultStylus: function() {
    return fs.readFileSync(__dirname + '/assets/screen.styl');
  },

Site generator. Will not create a site if pathName exists.

  • param: String Site path name

  • param: Function Callback to run when finished

makeSite: function(args, fn) {
    var pathName
      , generator;

    if (args.length === 1) {
      pathName = args[0];
      generator = 'default';
    } else {
      pathName = args[1];
      generator = args[0];
    }

    if (generators.hasOwnProperty(generator)) {
      generators[generator].run(this, pathName, fn);
    } else {
      try {
        var pluginGenerator = require(generator);
      } catch (e) {
        log.error('Error: Unable to find the', generator, 'generator');
        return;
      }

      pluginGenerator.generator.run(this, pathName, fn);
    }
  },

Renders files that match pattern.

  • param: Object Site config

  • param: String File name pattern

  • param: Function Callback

renderFile: function(pop, config, pattern) {
    var fileMap = new pop.FileMap(config)
      , siteBuilder = new pop.SiteBuilder(config);
    
    fileMap.on('ready', function() {
      if (fileMap.files.length === 0) {
        fn();
      } else {
        siteBuilder.fileMap = fileMap;
        siteBuilder.build();
        siteBuilder.on('ready', function() {
          log.info('%d files rendered.', fileMap.files.length);
        });
      }
    });

    fileMap.search(pattern);
  }
};

config

lib/config.js

Config file reader.

  • param: String Config file name

  • return: Object Parsed JavaScript object

function readConfigFile(file) {
  var defaults = {
    perPage: 20
  , port: 4000
  , output: '_site/'
  };

  function applyDefaults(config) {
    for (var key in defaults) {
      config[key] = config[key] || defaults[key];
    }

    if (config.url) config.url = config.url.replace(/\/$/, '');

    return config;
  }

  try {
    var data = fs.readFileSync(file).toString();
    return applyDefaults(JSON.parse(data));
  } catch(exception) {
    if (exception.code === 'EBADF') {
      log.error('No _config.json file in this directory.  Is this a Pop site?');
      process.exit(1);
    } else {
      log.info('Error reading config:', exception.message);
      throw(exception);
    }
  }
}

Module dependencies and additional config variables.

var fs = require(__dirname + '/graceful')
  , log = require(__dirname + '/log');

module.exports = readConfigFile;

file_map

lib/file_map.js

Module dependencies.

var fs = require(__dirname + '/graceful')
  , path = require('path')
  , log = require(__dirname + '/pop').log  
  , EventEmitter = require('events').EventEmitter;

Initialize FileMap with a config object.

  • param: Object options

  • api: public

function FileMap(config) {
  this.config = config || {};
  this.config.exclude = config && config.exclude ? config.exclude : [];
  this.config.exclude.push('/_site');
  this.ignoreDotFiles = true;
  this.root = config.root;
  this.files = [];
  this.events = new EventEmitter();
  this.filesLeft = 0;
  this.dirsLeft = 1;
}

Determines file type based on file extension.

  • param: String File name

  • return: String Internal file type used by SiteBuilder

FileMap.prototype.fileType = function(fileName) {
  var extension = path.extname(fileName).substring(1);

  if (fileName.match(/\/_posts\//)) {
    return 'post ' + extension;
  } else if (fileName.match(/\/_layouts\//)) {
    return 'layout ' + extension;
  } else if (fileName.match(/\/_includes\//)) {
    return 'include ' + extension;
  } else if (['jade', 'ejs', 'styl'].indexOf(extension) !== -1) {
    return 'file ' + extension;
  } else {
    return 'file';
  }
};

Recursively iterates from an initial path.

  • param: String Start path name

FileMap.prototype.walk = function(dir) {
  if (!dir) dir = this.root;

  var self = this;

  fs.readdir(dir, function(err, files) {
    self.dirsLeft--;
    if (!files) return;
    files.forEach(function(file) {
      file = path.join(dir, file);
      self.filesLeft++;
      fs.stat(file, function(err, stats) {
        if (err) log.error('Error:', err);
        if (!stats) return;
        if (stats.isDirectory(file)) {
          self.filesLeft--;
          self.dirsLeft++;
          self.walk(file);
          self.addFile(file, 'dir');
        } else {
          self.filesLeft--;
          self.addFile(file, self.fileType(file));
          if (self.filesLeft === 0 && self.dirsLeft === 0) {
            process.nextTick(function() {
              self.events.emit('ready');
            });
          }
        }
      });
    });
  });
};

Searches for files that match pattern.

  • param: String A pattern to search for

FileMap.prototype.search = function(pattern) {
  this.searchPattern = pattern;
  this.walk();
};

Checks to see if a file name matches the excluded patterns.

  • param: String File name

  • return: Boolean Should the file be excluded?

FileMap.prototype.isExcludedFile = function(file) {
  if (this.ignoreDotFiles)
    if (file.match(/\/\./)) return true;

  return this.config.exclude.some(function(pattern) {
    return file.match(pattern);
  });
};

Determines file type based on file extension.

  • param: String File name

  • param: String File type

FileMap.prototype.addFile = function(file, type) {
  if (this.isExcludedFile(file)) return;
  if (this.searchPattern && !file.match(this.searchPattern)) return;

  this.files.push({
    name: file,
    type: type,
  });
};

Bind an event to the internal EventEmitter.

  • param: String Event name

  • param: Function Handler

FileMap.prototype.on = function(eventName, fn) {
  this.events.on(eventName, fn);
};

module.exports = FileMap;

filters

lib/filters.js

module.exports = {

*
   * Replaces liquid tag highlight directives with prettyprint HTML tags.
   *
   * @param {String} The text for a post
   * @return {String}
   

highlight: function(data) { data = data.replace(/{% highlight ([^ ]*) %}/g, '<pre class="prettyprint lang-$1">'); data = data.replace(/{% endhighlight %}/g, '</pre>'); return data; } };

generators

lib/generators.js

module.exports = { 'default': require(__dirname + '/generators/default') };

graceful

lib/graceful.js

Module dependencies.

var fs = require('fs')
  , path = require('path')
  , defaultTimeout = 0
  , timeout = defaultTimeout;

Offers functionality similar to mkdir -p, but is async.

  • param: String Path name

  • param: Number File creation mode

  • param: Function Callback

  • param: Integer Path depth counter

function mkdir_p(dir, mode, callback, position) {
  mode = mode || process.umask();
  position = position || 0;
  parts = path.normalize(dir).split('/');

  if (position &gt;= parts.length) {
    if (callback) {
      return callback();
    } else {
      return true;
    }
  }

  var directory = parts.slice(0, position + 1).join('/') || '/';
  fs.stat(directory, function(err) {    
    if (err === null) {
      mkdir_p(dir, mode, callback, position + 1);
    } else {
      fs.mkdir(directory, mode, function(err) {
        if (err &amp;&amp; err.errno != 17) {
          if (callback) {
            return callback(err);
          } else {
            throw err;
          }
        } else {
          mkdir_p(dir, mode, callback, position + 1);
        }
      });
    }
  });
}

Polymorphic approach to fs.mkdir()

  • param: String Path name

  • param: Number File creation mode

  • param: Function Callback

fs.mkdir_p = function(dir, mode, callback) {
  mkdir_p(dir, mode, callback || process.noop);
}

// Graceful patching wraps async fs methods
Object.keys(fs)
  .forEach(function(i) {
    exports[i] = (typeof fs[i] !== 'function') ? fs[i]
               : (i.match(/^[A-Z]|^create|Sync$/)) ? function() {
                   return fs[i].apply(fs, arguments);
                 }
               : graceful(fs[i]);
  });

function graceful(fn) { return function GRACEFUL() {
  var args = Array.prototype.slice.call(arguments)
    , cb_ = args.pop();
  
  args.push(cb);

  function cb(er) {
    if (er &amp;&amp; er.message.match(/^EMFILE, Too many open files/)) {
      setTimeout(function() {
        GRACEFUL.apply(fs, args)
      }, timeout++);
      return;
    }
    timeout = defaultTimeout;
    cb_.apply(null, arguments);
  }
  fn.apply(fs, args)
}};

helpers

lib/helpers.js

Module dependencies and local variables.

var jade = require('jade')
  , fs = require('fs')
  , path = require('path')
  , cache = {}
  , _date = require('underscore.date')
  , helpers;

helpers = {

Pagination links.

  • param: Object Paginator object

  • return: String

paginate: function(paginator) {
    var template = '';
    template += '.pages\n';
    template += '  - if (paginator.previousPage)\n';
    template += '    span.prev_next\n';
    template += '      - if (paginator.previousPage === 1)\n';
    template += '        span ←\n';
    template += '        a.previous(href="/") Previous\n';
    template += '      - else\n';
    template += '        span ←\n';
    template += '        a.previous(href="/page" + paginator.previousPage + "/") Previous\n';
    template += '  - if (paginator.pages > 1)\n';
    template += '    span.prev_next\n';
    template += '      - for (var i = 1; i <= paginator.pages; i++)\n';
    template += '        - if (i === paginator.page)\n';
    template += '          strong.page #{i}\n';
    template += '        - else if (i !== 1)\n';
    template += '          a.page(href="/page" + i + "/") #{i}\n';
    template += '        - else\n';
    template += '          a.page(href="/") 1\n';
    template += '      - if (paginator.nextPage <= paginator.pages)\n';
    template += '        a.next(href="/page" + paginator.nextPage + "/") Next\n';
    template += '        span →\n';
    return jade.render(template, { locals: { paginator: paginator } });
  },

Generates paginated blog posts, suitable for use on an index page.

  • return: String

paginatedPosts: function() {
    var template
      , site = this;

    template = ''
      + '- for (var i = 0; i < paginator.items.length; i++)\n'
      + '  !{hNews(paginator.items[i], true)}\n';
    return jade.render(template, { locals: site.applyHelpers({ paginator: site.paginator }) });
  },

Atom Jade template.

  • param: String Feed URL

  • param: Integer Number of paragraphs to summarise

  • return: String

atom: function(feed, summarise) {
    var template = ''
      , url = this.config.url
      , title = this.config.title
      , perPage = this.config.perPage
      , posts = this.posts.slice(-perPage).reverse()
      , site = this;

    summarise = typeof summarise === 'boolean' &amp;&amp; summarise ? 3 : summarise;
    perPage = site.posts.length &lt; perPage ? site.posts.length : perPage;

    template += '!!!xml\n';
    template += 'feed(xmlns="http://www.w3.org/2005/Atom")\n';
    template += '  title #{title}\n';
    template += '  link(href=feed, rel="self")\n';
    template += '  link(href=url)\n';

    if (posts.length &gt; 0)
      template += '  updated #{dx(posts[0].date)}\n';

    template += '  id #{url}\n';
    template += '  author\n';
    template += '    name #{title}\n';
    template += '  - for (var i = 0, post = posts[i]; i < ' + perPage + '; i++, post = posts[i])\n';
    template += '    entry\n';
    template += '      title #{post.title}\n';
    template += '      link(href=url + post.url)\n';
    template += '      updated #{dx(post.date)}\n';
    template += '      id #{url.replace(/\\/$/, "")}#{post.url}\n';

    if (summarise)
      template += '      content(type="html") !{h(truncateParagraphs(post.content, summarise, ""))}\n';
    else
      template += '      content(type="html") !{h(post.content)}\n';

    return jade.render(template, { locals: site.applyHelpers({
        paginator: site.paginator
      , posts: posts
      , title: title
      , url: url
      , feed: feed
      , summarise: summarise
    })});
  },

RSS Jade template.

  • param: String Feed URL

  • param: Integer Number of paragraphs to summarise

  • param: String Optional description

  • return: String

rss: function(feed, summarise, description) {
    var template = ''
      , url = this.config.url
      , title = this.config.title
      , perPage = this.config.perPage
      , posts = this.posts.slice(-perPage).reverse()
      , site = this;

    description = description || title;
    summarise = typeof summarise === 'boolean' &amp;&amp; summarise ? 3 : summarise;
    perPage = site.posts.length &lt; perPage ? site.posts.length : perPage;

    template += '!!!xml\n';
    template += 'rss(version="2.0")\n';
    template += '  channel\n';
    template += '    title #{title}\n';
    template += '    link #{url}\n';
    template += '    description #{description}\n';

    // TODO: Site description, language, managingEditor, webMaster

    if (posts.length &gt; 0) {
      template += '    pubDate #{d822(posts[0].date)}\n';
      template += '    lastBuildDate #{d822(posts[0].date)}\n';
    }

    template += '    generator Pop\n';
    template += '    - for (var i = 0, post = posts[i]; i < ' + perPage + '; i++, post = posts[i])\n';
    template += '      item\n';
    template += '        title #{post.title}\n';
    template += '        link #{url + post.url}\n';
    template += '        pubDate #{d822(post.date)}\n';
    template += '        guid #{url.replace(/\\/$/, "")}#{post.url}\n';

    if (summarise)
      template += '        description !{h(truncateParagraphs(post.content, summarise, ""))}\n';
    else
      template += '        description !{h(post.content)}\n';

    return jade.render(template, { locals: site.applyHelpers({
        paginator: site.paginator
      , posts: posts
      , title: title
      , url: url
      , feed: feed
      , description: description
      , summarise: summarise
    })});
  },

Returns unique sorted tags for every post.

  • return: Array

allTags: function() {
    var allTags = [];

    for (var key in this.posts) {
      if (this.posts[key].tags) {
        for (var i = 0; i &lt; this.posts[key].tags.length; i++) {
          var tag = this.posts[key].tags[i];
          if (allTags.indexOf(tag) === -1) allTags.push(tag);
        }
      }
    }

    allTags.sort(function(a, b) {
      a = a.toLowerCase();
      b = b.toLowerCase();
      if (a &lt; b) return -1;
      if (a &gt; b) return 1;
      return 0;
    });

    return allTags;
  },

Get a set of posts for a tag.

  • param: String Tag name

  • return: Array

postsForTag: function(tag) {
    var posts = [];
    for (var key in this.posts) {
      if (this.posts[key].tags &amp;&amp; this.posts[key].tags.indexOf(tag) !== -1) {
        posts.push(this.posts[key]);
      }
    }
    return posts;
  },

Display a list of tags.

TODO Link options

  • param: Array Tag names

  • return: String

tags: function(tags) {
    return tags.map(function(tag) {
      return '<a href="/tags.html#' + escape(tag) + '">' + tag + '</a>';
    }).join(', ');
  },

Renders a post using the hNews microformat, based on:

http://www.readability.com/publishers/guidelines/#view-exampleGuidelines

  • param: Object A post object

  • return: String Post in the hNews format

hNews: function(post, summary) {
    var template = '';
    template += 'article.hentry\n';
    template += '  header\n';
    template += '    h1.entry-title\n';
    template += '      a(href=post.url) !{post.title}\n';
    template += '    time.updated(datetime=dx(post.date), pubdate) #{ds(post.date)}\n';
    if (post.author)
      template += '    p.byline.author.vcard by <span class="fn">#{post.author}</span>\n';

    if (post.tags) template += '    div.tags !{tags(post.tags)}\n';

    if (summary) {
      if (post.summary) {
        template += '  !{post.summary + "<p><a class=\\"read-more\\" href=\\"' + post.url + '\\">Read More →</a></p>"}\n';
      } else {
        template += '  !{truncateParagraphs(post.content, 2, "<p><a class=\\"read-more\\" href=\\"' + post.url + '\\">Read More →</a></p>")}\n';
      }
    } else {
      template += '  !{post.content}\n';
    }
    return jade.render(template, { locals: this.applyHelpers({ post: post }) });
  },

Formats a date with date formatting rules according to underscore.date's rules.

  • param: Date Date to format

  • param: String Date format

  • return: String

df: function(date, format) {
    return _date(date).format(format);
  },

Short date (01 January 2001).

  • param: Date Date to format

  • return: String

ds: function(date) {
    return helpers.df(date, 'DD MMMM YYYY');
  },

Atom date formatting.

  • param: Date Date to format

  • return: String

dx: function(date) {
    return helpers.df(date, 'YYYY-MM-DDTHH:MM:ssZ');
  },

RFC-822 dates.

  • param: Date Date to format

  • return: String

d822: function(date) {
    // FIXME: why is _date always setting the timezone to local?
    return helpers.df(date, 'ddd, DD MMM YYYY HH:MM:ss') + ' GMT';
  },

Escapes brackets and ampersands.

  • param: String Text to escape

  • return: String

h: function(text) {
    return text &amp;&amp; text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
  },

Truncates HTML based on paragraph counts.

  • param: String Text to truncate

  • param: Integer Number of paragraphs

  • param: String Text to append when truncated

  • return: String

truncateParagraphs: function(text, length, moreText) {
    var t = text.split('</p>');
    return t.length &lt; length ? text : t.slice(0, length).join('</p>') + '</p>' + moreText;
  },

Truncates based on characters (not HTML safe, use with pre-formatted text).

  • param: String Text to truncate

  • param: Integer Length

  • param: String Text to append when truncated

  • return: String

truncate: function(text, length, moreText) {
    return text.length &gt; length ? text.slice(0, length).trim() + moreText : text;
  },

Truncates based on words (not HTML safe, use with pre-formatted text).

  • param: String Text to truncate

  • param: Integer Number of words

  • param: String Text to append when truncated

  • return: String

truncateWords: function(text, length, moreText) {
    var t = text.split(/\s/);
    return t.length &gt; length ? t.slice(0, length).join(' ') + moreText : text;
  }
};

module.exports = helpers;

log

lib/log.js

module.exports = { enabled: true,

info: function() { if (this.enabled) console.log.apply(this, arguments); },

error: function() { if (this.enabled) console.error.apply(this, arguments); } };

paginator

lib/paginator.js

Initialize Paginator with the number of items per-page and a list of items.

  • param: Integer Number of items per-page

  • param: Object A list of items (generally posts)

  • api: public

function Paginator(perPage, items) {
  this.allItems = this.sort(items);
  this.perPage = perPage;
  this.items = this.allItems.slice(0, perPage);
  this.previousPage = 0;
  this.nextPage = 2;
  this.page = 1;
  this.pages = Math.round(this.allItems.length / this.perPage) + 1;
}

Moves to the next page.

Paginator.prototype.advancePage = function() {
  this.page++;
  this.previousPage = this.page - 1;
  this.nextPage = this.page + 1;

  var start = (this.page - 1) * this.perPage
    , end = this.page * this.perPage;
  this.items = this.allItems.slice(start, end); 
};

Sort items according to date.

  • param: Array Array of items

  • return: Integer -1, 1, 0 according to the date comparison

Paginator.prototype.sort = function(items) {
  return items.sort(function(a, b) {
    a = a.date.valueOf();
    b = b.date.valueOf();
    if (a &gt; b)
      return -1;
    else if (a &lt; b)
      return 1;
    return 0;
  });
};

module.exports = Paginator;

pop

lib/pop.js

Module dependencies and local variables.

var path = require('path')
  , fs = require(__dirname + '/../lib/graceful')
  , FileMap = require(__dirname + '/file_map')
  , SiteBuilder = require(__dirname + '/site_builder')
  , cliTools = require(__dirname + '/cli_tools')
  , readConfig = require(__dirname + '/config')
  , log = require(__dirname + '/log');

Loads the config script and sets the local variable.

  • return: Object Config object

function loadConfig() {
  var root = process.cwd()
    , config = readConfig(path.join(root, '_config.json'));
  config.root = root;
  return config;
}

Loads configuration then runs generateSite.

  • params: Boolean Use a HTTP sever?

function loadConfigAndGenerateSite(useServer, port) {
  var config = loadConfig();
  if (port) config.port = port;
  generateSite(config, useServer);
}

Runs FileMap and SiteBuilder based on the config.

  • params: Object Configuration options

  • params: Boolean Use a HTTP sever?

  • return: SiteBuilder A SiteBuilder instance

function generateSite(config, useServer) {
  var fileMap = new FileMap(config)
    , siteBuilder = new SiteBuilder(config)
    , server = require(__dirname + '/server')(siteBuilder);

  fileMap.walk();
  fileMap.on('ready', function() {
    siteBuilder.fileMap = fileMap;
    siteBuilder.build();
  });

  siteBuilder.once('ready', function() {
    if (useServer) {
      server.run();
      server.watch();
    }
  });

  return siteBuilder;
}

function build() {
  var args = process.argv.slice(2)
    , usage;
  if (args.length === 0) return loadConfigAndGenerateSite();

  usage  = 'pop is a static site builder.\n\n';
  usage += 'Usage: pop [command] [options]\n';
  usage += 'new    path           Generates a new site at path/\n';
  usage += 'post   "Post Title"   Writes a new post file\n'; 
  usage += 'render pattern        Renders files that match "pattern"\n'; 
  usage += 'server [port]          Create a server on port (default: 4000) for _site/\n\n';
  usage += '-v, --version         Display version and exit\n';
  usage += '-h, --help            Shows this message\n';

  while (args.length) {
    arg = args.shift();
    switch (arg) {
      case 'server':
        loadConfigAndGenerateSite(true, args.shift());
      break;
      case 'post':
        return cliTools.makePost(loadConfig(), args.shift(), function() { process.exit(0); });
      break;
      case 'new':
        return cliTools.makeSite(args, function() { process.exit(0); });
      break;
      case 'render':
        return cliTools.renderFile(module.exports, loadConfig(), args.shift());
      break;
      case '-v':
      case '--version':
        var version = JSON.parse(fs.readFileSync(__dirname + '/../package.json')).version;
        log.info('pop version:', version);
        process.exit(0);
      break;
      case '-h':
      case '--help':
        log.info(usage);
        process.exit(1);
      default:
        loadConfigAndGenerateSite();
    }
  }
}

module.exports = {
  build: build
, SiteBuilder: SiteBuilder
, FileMap: FileMap
, generateSite: generateSite
, cliTools: cliTools
, log: log
};

server

lib/server.js

Module dependencies and local variables.

var path = require('path')
  , watch = require('nodewatch')
  , siteBuilder
  , log = require(__dirname + '/log');

Instantiates and runs the Express server.

function server() {
  // TODO: Show require express error
  var express = require('express'),
      app = express.createServer();

  app.configure(function() {
    app.use(express.static(siteBuilder.outputRoot));
    app.use(express.errorHandler({ dumpExceptions: true, showStack: true }));
  });

  // Map missing trailing slashes for posts
  app.get('*', function(req, res) {
    var postPath = siteBuilder.outputRoot + req.url + '/';
    // TODO: Security
    if (req.url.match(/[^/]$/) &amp;&amp; path.existsSync(postPath)) {
      res.redirect(req.url + '/');
    } else {
      res.send('404');
    }
  });

  app.listen(siteBuilder.config.port);
  log.info('Listening on port', siteBuilder.config.port);
}

Watches for file changes and regenerates files as required. ## TODO Work in progress

function watchChanges() {
  function buildChange(file) {
    log.info('File changed:', file);
    try {
      siteBuilder.buildChange(file);
    } catch (e) {
      log.error('Error building site:', e);
    }
  }

  // TODO: What happens when files/dirs are added?
  siteBuilder.fileMap.files.forEach(function(file) {
    if (file.type === 'dir') {
      watch.add(file.name).onChange(buildChange);
    }
  });
}

module.exports = function(s) {
  siteBuilder = s;
  return {
    run: server
  , watch: watchChanges
  };
};

site_builder

lib/site_builder.js

Module dependencies.

var textile = require('stextile')
  , fs = require('./graceful')
  , path = require('path')
  , jade = require('jade')
  , stylus = require('stylus')
  , yamlish = require('yamlish')
  , markdown = require('markdown-js')
  , Paginator = require('./paginator')
  , FileMap = require('./file_map.js').FileMap
  , EventEmitter = require('events').EventEmitter
  , filters = require('./filters')
  , helpers = require('./helpers')
  , userHelpers = {}
  , userFilters = {}
  , userPostFilters = {};

Initialize SiteBuilder with a config object and FileMap.

  • param: Object Config options

  • param: FileMap A FileMap object

  • api: public

function SiteBuilder(config, fileMap) {
  this.config = config;
  this.root = config.root;

  var helperFile = path.join(this.root, '_lib', 'helpers.js');
  if (path.existsSync(helperFile)) {
    userHelpers = require(helperFile);
  }

  var filterFile = path.join(this.root, '_lib', 'filters.js');
  if (path.existsSync(filterFile)) {
    userFilters = require(filterFile);
  }

  var postFilterFile = path.join(this.root, '_lib', 'post-filters.js');
  if (path.existsSync(postFilterFile)) {
    userPostFilters = require(postFilterFile);
  }

  this.outputRoot = config.output;
  this.fileMap = fileMap;
  this.posts = [];
  this.helpers = helpers;
  this.events = new EventEmitter();
  this.includes = {};

  this.loadPlugins();
}

Loads Pop plugins based on config.require.

SiteBuilder.prototype.loadPlugins = function() {
  if (!this.config.require) return;
  var self = this;

  this.config.require.forEach(function(name) {
    try {
      var plugin = require(name);
      self.loadPlugin(name, plugin);
    } catch (e) {
      console.error('Unable to load plugin:', name, '-', e.message);
      throw(e);
    }
  });
};

Applies helpers and "user helpers" to an object so it can be easily passed to Jade.

  • param: String The name of the plugin

  • param: Object The plugin's module. Properties loaded: helpers, filters, postFilters

SiteBuilder.prototype.loadPlugin = function(name, plugin) {
  if (!plugin) return;

  for (key in plugin.helpers) {
    if (plugin.helpers.hasOwnProperty(key))
      userHelpers[key] = this.bind(plugin.helpers[key]);
  }

  for (key in plugin.filters) {
    if (plugin.filters.hasOwnProperty(key))
      userFilters[key] = this.bind(plugin.filters[key]);
  }

  for (key in plugin.postFilters) {
    if (plugin.postFilters.hasOwnProperty(key))
      userPostFilters[key] = this.bind(plugin.postFilters[key]);
  }
};

Applies helpers and "user helpers" to an object so it can be easily passed to Jade.

  • param: Object An object to merge with

  • return: Object The mutated object

SiteBuilder.prototype.applyHelpers = function(obj) {
  var self = this
    , key;

  for (key in this.helpers) {
    obj[key] = this.bind(this.helpers[key]);
  }

  for (key in userHelpers) {
    obj[key] = this.bind(userHelpers[key]);
  }

  obj.include = function(template) {
    return self.includes[template];
  };

  // TODO: Only do this once
  if (obj.paginate &amp;&amp; obj.paginator)
    obj.paginate = obj.paginate(obj.paginator);

  obj.site = self;

  return obj;
};

Binds methods to this SiteBuilder.

  • param: Function The function to bind

  • return: Function The bound function

SiteBuilder.prototype.bind = function(fn) {
  var self = this;
  return function() {
    return fn.apply(self, arguments);
  };
};

Builds the site. This is asynchronous, so various counters and events are used to track progress.

SiteBuilder.prototype.build = function() {
  var self = this;

  function build() {
    var posts = self.findPosts()
      , otherFiles = self.otherRenderedFiles()
      , staticFiles = self.staticFiles()
      , autoGen = self.autoGenerate();

    function renderPost() {
      if (posts.length) {
        var file = posts.pop();
        self.renderPost(file, function() {
          self.events.emit('render post');
        });
      } else {
        self.events.emit('render autoGen');
      }
    }

    function renderAutoGen() {
      if (autoGen.length) {
        var file = autoGen.pop();
        self.autoGenerateFile(file, function() {
          self.events.emit('render autoGen');
        });
      } else {
        self.events.emit('check finished');
      }
    }

    function renderOtherFile() {
      if (otherFiles.length) {
        var file = otherFiles.pop();
        self.renderFile(file, function() {
          self.events.emit('render otherFile');
        });
      } else {
        self.events.emit('check finished');
      }
    }

    function renderStaticFile() {
      if (staticFiles.length) {
        var file = staticFiles.pop();
        self.copyStatic(file, function() {
          self.events.emit('render staticFile');
        });
      } else {
        self.events.emit('check finished');
      }
    }

    function checkFinished() {
      var filesLeft = posts.length + otherFiles.length + staticFiles.length + autoGen.length;
      if (filesLeft === 0) {
        self.events.emit('ready');
      }
    }

    self.events.on('render post', renderPost);
    self.events.on('render autoGen', renderAutoGen);
    self.events.on('render otherFile', renderOtherFile);
    self.events.on('render staticFile', renderStaticFile);
    self.events.on('check finished', checkFinished);

    self.events.emit('render post');
    self.events.emit('render otherFile');
    self.events.emit('render staticFile');
  }

  this.events.once('cached includes', build);
  this.cacheIncludes();
};

Returns any configured built-in pages, or an empty array.

  • return: Array

SiteBuilder.prototype.autoGenerate = function() {
  if (!this.config.autoGenerate) return [];
  return this.config.autoGenerate;
};

Generates a built-in page. Only atom feeds and RSS are currently available.

  • param: String Page/file details from the config object

  • param: Function A callback function to run on completion

SiteBuilder.prototype.autoGenerateFile = function(file, fn) {
  // TODO: Allow this to be easily extended
  var self = this;

  if (file.feed) {
    if (!this.config.url || !this.config.title) {
      console.error('Error: Built-in feed generation requires config values for: url and title.'); 
    } else {
      var layoutData = &quot;!{atom('" + this.config.url + '/' + file.feed + "')}&quot;
        , html = jade.render(layoutData, { locals: self.applyHelpers({ }) });
      this.write(this.outFileName(file.feed), html);
      fn();
    }
  }
  
  if (file.rss) {
    if (!this.config.url || !this.config.title) {
      console.error('Error: Built-in feed generation requires config values for: url and title.'); 
    } else {
      var layoutData = &quot;!{rss('" + this.config.url + '/' + file.rss + "')}&quot;
        , html = jade.render(layoutData, { locals: self.applyHelpers({ }) });
      this.write(this.outFileName(file.rss), html);
      fn();
    }
  }
};

Determines if a file needs Jade or Stylus rendering.

  • param: Object An instance that has a type property

  • return: Boolean

SiteBuilder.prototype.isRenderedFile = function(file) {
  return file.type === 'file jade' || file.type === 'file styl';
};

Builds a single file. ## TODO Work in progress.

  • param: String File name to build

SiteBuilder.prototype.buildChange = function(file) {
  if (this.fileMap.isExcludedFile(file)) return;

  file = {
    type: this.fileMap.fileType(file)
  , name: file
  };

  if (file.type.indexOf('post') !== -1) {
    this.renderPost(file);
  } else if (this.isRenderedFile(file)) {
    this.renderFile(file);
  } else if (file.type === 'file') {
    this.copyStatic(file);
  }
};

Iterates over the files in the FileMap object to find posts.

  • return: Array A list of posts

SiteBuilder.prototype.findPosts = function() {
  return this.fileMap.files.filter(function(file) {
    return file.type.indexOf('post') !== -1;
  });
};

Iterates over the files in the FileMap object to find "other" rendered files.

  • return: Array A list of files

SiteBuilder.prototype.otherRenderedFiles = function() {
  var self = this;
  return this.fileMap.files.filter(function(file) {
    return self.isRenderedFile(file);
  });
};

Iterates over the files in the FileMap object to find static files that require copying.

  • return: Array A list of files

SiteBuilder.prototype.staticFiles = function() {
  return this.fileMap.files.filter(function(file) {
    return file.type === 'file';
  });
};

Copies a static file.

  • param: String The file name

  • param: Function A callback to run on completion

SiteBuilder.prototype.copyStatic = function(file, fn) {
  var outDir = this.outFileName(path.dirname(file.name.replace(this.root, '')))
    , fileName = path.join(outDir, path.basename(file.name))
    , self = this;

  // TODO: Use a configurable ignore list
  if (path.basename(file.name).match(/^_/)) return fn.apply(self);

  fs.mkdir_p(outDir, 0777, function(err) {
    if (err) {
      console.error('Error creating directory:', outDir);
      throw(err);
    }

    fs.readFile(file.name, function(err, data) {
      if (err) {
        console.error('Error reading file:', file.name);
        throw(err);
      }

      fs.writeFile(fileName, data, function(err) {
        if (err) {
          console.error('Error writing file:', fileName);
          throw(err);
        }

        if (fn) fn.apply(self);
      });
    });
  });
};

Parse YAML meta data for both files and posts.

  • param: String File name (used by logging on errors)

  • param: String Data to parse

  • return: Array Parsed YAML

SiteBuilder.prototype.parseMeta = function(file, data) {
  function clean(yaml) {
    for (var key in yaml) {
      if (typeof yaml[key] === 'string') {
        var m = yaml[key].match(/^"([^"]*)"$/);
        if (m) yaml[key] = m[1];
      }
    }
    return yaml;
  }

  // FIXME: This shouldn't be used, my articles are badly formatted
  function fix(text) {
    if (!text) return;
    return text.split('\n').map(function(line) {
      if (line.match(/^- /))
        line = '  ' + line;
      return line;
    }).join('\n');
  }

  var dataChunks = data.split('---')
    , parsedYAML = [];

  try {
    if (dataChunks[1]) {
      // TODO: Improve YAML extraction, add JSON alternative
      parsedYAML = clean(yamlish.decode(fix(dataChunks[1] || data)));
      return [parsedYAML, ((dataChunks || []).slice(2).join('---')).trim()];
    } else {
      return ['', data];
    }
  } catch (e) {
    console.error(&quot;Couldn't parse YAML in:", file, ':', e);
  }
};

Writes a file, making directories recursively when required.

  • param: String File name to write to

  • param: String Contents of the file

SiteBuilder.prototype.write = function(fileName, content) {
  if (!content) return console.error('No content for:', fileName);

  // Apply the post-filters before writing
  content = this.applyPostFilters(content);

  fs.mkdir_p(path.dirname(fileName), 0777, function(err) {
    if (err) {
      console.error('Error creating directory:', path.dirname(fileName));
      throw(err);
    }

    fs.writeFile(fileName, content, function(err) {
      if (err) {
        console.error('Error writing file:', fileName);
        throw(err);
      }
    });
  });
};

Returns a full path name.

  • param: String Relative path name, i.e., _posts/

  • param: String File name

  • return: String

SiteBuilder.prototype.outFileName = function(subDir, name) {
  return path.join(this.outputRoot, subDir, name);
};

Caches templates inside _includes/

SiteBuilder.prototype.cacheIncludes = function() {
  var self = this;

  function done() {
    self.events.emit('cached includes');
  }

  path.exists(path.join(this.root, '_includes'), function(exists) {
    if (!exists) {
      done();
    } else {
      fs.readdir(path.join(self.root, '_includes'), function(err, files) {
        if (!files) return;
        var file;

        if (files.length === 0) done();

        for (var i = 0; i &lt; files.length; i++) {
          file = files[i];
          // TODO: Configurable templates
          if (path.extname(file) !== '.jade') {
            if (i === files.length) return self.events.emit('cached includes');
          } else {
            fs.readFile(path.join(self.root, '_includes', file), 'utf8', function(err, data) {
              // TODO: This won't cope with _include/file/file, but people will expect this
              var html = jade.render(data, { locals: self.applyHelpers({}) })
                , name = path.basename(file).replace(path.extname(file), '');
              self.includes[name] = html;

              if (i === files.length) done();
            });
          }
        }
      });
    }
  });
};

Renders a post using a template. Called by renderPost.

  • param: String Template file name

  • param: Object A post object

  • param: String The post's content

SiteBuilder.prototype.renderTemplate = function(templateFile, post, content) {
  var self = this;
  templateFile = path.join(this.root, '_layouts', templateFile + '.jade');

  fs.readFile(templateFile, 'utf8', function(err, data) {
    if (err) {
      console.error('Error in: ' + templateFile);
      console.error(err);
      console.error(err.message);
      throw(err);
    }

    try {
      var html = jade.render(data, { locals: self.applyHelpers({ post: post, content: content }) })
        , fileName = self.outFileName(post.fileName, 'index.html')
        , dirName = path.dirname(fileName);
    } catch (e) {
      console.error('Error rendering:', templateFile);
      throw(e);
    }

    path.exists(dirName, function(exists) {
      if (exists) {
        self.write(fileName, html);
      } else {
        fs.mkdir_p(dirName, 0777, function(err) {
          if (err) {
            console.error('Error making directory:', dirName);
            throw(err);
          }
          self.write(fileName, html);
        });
      }
    });
  });
};

Renders a generic Jade file and will supply pagination if required.

  • param: String File name

  • param: Function Callback to run on completion

SiteBuilder.prototype.renderFile = function(file, fn) {
  var self = this;

  if (file.type === 'file styl') {
    var outFileName = self.outFileName(
      path.dirname(file.name.replace(self.root, '')),
      path.basename(file.name).replace(path.extname(file.name), '.css')
    );

    return fs.readFile(file.name, 'utf8', function(err, fileData) {
      stylus.render(fileData, { filename: path.basename(outFileName) }, function(err, css) {
        if (err) throw err;
        self.write(outFileName, css);
        if (fn) fn.apply(self);
      });
    });
  }

  function render(fileData, meta, layoutData, dirName) {
    // Use .html for file extensions unless the file has the format file.ext.jade
    var ext = path.basename(file.name).match('\\.[^.]*\\' + path.extname(file.name) + '$') ? '' : '.html';
    dirName = dirName || '';

    var outFileName = self.outFileName(
      path.dirname(file.name.replace(self.root, '')) + dirName,
      path.basename(file.name).replace(path.extname(file.name), ext)
    );

    var fileContent = jade.render(fileData, {
      locals: self.applyHelpers({ paginator: self.paginator, page: meta }),
    });

    if (!layoutData) {
      self.write(outFileName, fileContent);
    } else {
      var html = jade.render(layoutData, { locals: self.applyHelpers({ content: fileContent }) });
      self.write(outFileName, html);
    }

    if (fn) fn.apply(self);
  }

  fs.readFile(file.name, 'utf8', function(err, fileData) {
    var meta = self.parseMeta(file.name, fileData)
      , fileContent = '';

    fileData = meta[1];
    meta = meta[0];

    if (!meta.layout) {
      self.paginator = new Paginator(self.config.perPage, self.posts);
      render(fileData, meta);
    } else {
      fs.readFile(path.join(self.root, '_layouts', meta.layout + '.jade'), function(err, layoutData) {
        if (err) {
          console.error('Unable to read layout:', meta.layout + '.jade');
          throw(err);
        }

        // TODO: Per page config
        self.paginator = new Paginator(self.config.perPage, self.posts)
        render(fileData, meta, layoutData);

        if (meta.paginate) {
          while (self.paginator.items.length) {
            self.paginator.advancePage();
            render(fileData, meta, layoutData, '/page' + self.paginator.page + '/');
          }
        }
      });
    }
  });
};

Parses a file name according to the permalink format.

  • param: String A post file name

  • return: Object An object containing the parsed file name

SiteBuilder.prototype.parseFileName = function(fileName) {
  var format = this.config.permalink
    , parts = fileName.match(/(\d+)-(\d+)-(\d+)-(.*)/)
    , year = parts[1]
    , month = parts[2]
    , day = parts[3]
    , title = parts[4].replace(/\.(textile|md)/, '');

  return { date: new Date(Date.UTC(year, month - 1, day)),
           fileName: format.replace(':year', year).
                       replace(':month', month).
                       replace(':day', day).
                       replace(':title', title) };
};

Applies internal and user-supplied content pre-filters.

  • param: String Text to transform using filters

  • return: String The transformed text

SiteBuilder.prototype.applyFilters = function(text) {
  for (var key in filters) {
    text = filters[key](text);
  }

  for (key in userFilters) {
    text = userFilters[key].apply(this, [text]);
  }

  return text;
};

Applies user-supplied content post-filters. These are run after HTML is generated.

  • param: String Text to transform using filters

  • return: String The transformed text

SiteBuilder.prototype.applyPostFilters = function(text) {
  for (key in userPostFilters) {
    text = userPostFilters[key].apply(this, [text]);
  }

  return text;
};

Renders a post.

  • param: String Post file name

  • param: Function A callback to run on completion

SiteBuilder.prototype.renderPost = function(file, fn) {
  var self = this
    , formatter;

  if (file.type.indexOf('textile') !== -1) {
    formatter = textile;
  } else if (file.type.indexOf('md') !== -1) {
    formatter = markdown.makeHtml;
  }

  fs.readFile(file.name, 'utf8', function(err, data) {
    var meta = self.parseMeta(file.name, data);
    data = meta[1];
    meta = meta[0];

    // Categories and tags are synonyms
    if (!meta.tags) meta.tags = meta.categories ? meta.categories : [];

    if (data &amp;&amp; meta) {
      var fileDetails = self.parseFileName(file.name);
      meta.fileName = fileDetails.fileName;
      meta.url = fileDetails.fileName;
      meta.date = fileDetails.date;
      meta.content = formatter(self.applyFilters(data));

      if (meta.summary)
        meta.summary = formatter(self.applyFilters(meta.summary));

      self.renderTemplate(meta.layout, meta, meta.content);
      self.posts.push(meta);
    }

    if (fn) fn.apply(self);
  });
};

Adds a listener to the internal EventEmitter object.

  • param: String The event name

  • param: Function The handler

SiteBuilder.prototype.on = function(eventName, fn) {
  this.events.on(eventName, fn);
};

Adds a listener to the internal EventEmitter object.

  • param: String The event name

  • param: Function The handler

SiteBuilder.prototype.once = function(eventName, fn) {
  this.events.once(eventName, fn);
};

module.exports = SiteBuilder;