├── .gitignore
├── index.js
├── example
├── templates
│ ├── default
│ │ ├── body.jade
│ │ ├── menu.jade
│ │ ├── related.jade
│ │ ├── project-list.jade
│ │ └── html.jade
│ ├── projects
│ │ └── body.jade
│ └── project
│ │ └── body.jade
├── settings.json
├── content
│ ├── 01-about-us
│ │ └── about.md
│ ├── 03-contact
│ │ └── contact.md
│ ├── 02-projects
│ │ ├── 01-a project
│ │ │ ├── trees.jpg
│ │ │ ├── trees.jpg.md
│ │ │ └── project.md
│ │ ├── projects.md
│ │ ├── invisible project
│ │ │ └── project.md
│ │ └── 02-another project
│ │ │ └── project.md
│ └── home.md
├── assets
│ └── styles
│ │ ├── styles.css
│ │ └── skeleton.css
└── parsers
│ ├── related.js
│ └── tags.js
├── lib
├── settings.json
├── util
│ ├── gm.js
│ └── fs.js
├── woods.js
├── Helpers.js
├── liveReload.js
├── Pagination.js
├── listBucket.js
├── TemplateParam.js
├── watchDirectory.js
├── Collection.js
├── Parsers.js
├── Thumbnails.js
├── File.js
├── Site.js
├── siteSync.js
└── Page.js
├── package.json
├── LICENSE.txt
├── bin
└── woods
├── README.md
└── ChangeLog
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .DS_Store
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | module.exports = require('./lib/woods');
--------------------------------------------------------------------------------
/example/templates/default/body.jade:
--------------------------------------------------------------------------------
1 | !=markdown(page.content)
--------------------------------------------------------------------------------
/example/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "contentExtension": ".md"
3 | }
4 |
--------------------------------------------------------------------------------
/lib/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "contentExtension": ".md",
3 | "naturalSort": false
4 | }
5 |
--------------------------------------------------------------------------------
/example/content/01-about-us/about.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: About
3 | ---
4 | This is the about page.
--------------------------------------------------------------------------------
/example/templates/projects/body.jade:
--------------------------------------------------------------------------------
1 | !=markdown(page.content)
2 | hr
3 | !=template('project-list')
--------------------------------------------------------------------------------
/example/content/03-contact/contact.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Contact
3 | ---
4 | Welcome to the contact page.
--------------------------------------------------------------------------------
/example/content/02-projects/01-a project/trees.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/paperjs/woods/HEAD/example/content/02-projects/01-a project/trees.jpg
--------------------------------------------------------------------------------
/example/content/02-projects/01-a project/trees.jpg.md:
--------------------------------------------------------------------------------
1 | These are **trees**. You can create image captions by placing text files named "imagename + .md" in the same folder.
2 |
--------------------------------------------------------------------------------
/example/content/02-projects/projects.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Projects
3 | ---
4 | Welcome to the projects page.
5 |
6 | Below you will find the project pages contained within this one:
--------------------------------------------------------------------------------
/example/templates/default/menu.jade:
--------------------------------------------------------------------------------
1 | ul.menu
2 | li
3 | a(href=page.url) #{page.title}
4 | for child in page.children
5 | li
6 | a(href=child.url) #{child.title}
--------------------------------------------------------------------------------
/example/content/02-projects/invisible project/project.md:
--------------------------------------------------------------------------------
1 | ---
2 | tags: tag, another tag
3 | excerpt: This is also an excerpt!
4 | date: 01/01/2012
5 | ---
6 | Welcome to the other project page.
7 |
--------------------------------------------------------------------------------
/example/templates/project/body.jade:
--------------------------------------------------------------------------------
1 | !=markdown(page.content)
2 | for image in page.images
3 | !=image.thumb({width: 320})
4 | if image.description
5 | .description
6 | !=markdown(image.description)
7 |
--------------------------------------------------------------------------------
/example/content/02-projects/02-another project/project.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Another Project
3 | tags: tag, another tag
4 | excerpt: This is also an excerpt!
5 | date: 01/01/2012
6 | ---
7 | Welcome to the other project page.
8 |
--------------------------------------------------------------------------------
/example/assets/styles/styles.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: Helvetica, Arial, sans-serif;
3 | font-size: 16px;
4 | line-height: 20px;
5 | background: #fff;
6 | margin: 0;
7 | padding:50px;
8 | }
9 |
10 | .active {
11 | font-weight: bold;
12 | }
--------------------------------------------------------------------------------
/example/templates/default/related.jade:
--------------------------------------------------------------------------------
1 | if (page.type == 'project')
2 | !=template('project-list')
3 | if (page.related)
4 | p Related pages:
5 | ul.menu
6 | for relatedPage in page.related
7 | li: a(href=relatedPage.url): !=relatedPage.title
8 |
--------------------------------------------------------------------------------
/example/templates/default/project-list.jade:
--------------------------------------------------------------------------------
1 | p: b !{page.type == 'projects' ? 'Projects:' : 'Other projects:'}
2 | ul.menu
3 | for child in page.get('/projects').children
4 | if isActive(child)
5 | li: a.active(href=child.url) #{child.title}
6 | else
7 | li: a(href=child.url) #{child.title}
--------------------------------------------------------------------------------
/example/parsers/related.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | related: function(urls, page) {
3 | urls = urls.split(/,\s*/g);
4 | page.related = [];
5 | urls.forEach(function(url) {
6 | var relatedPage = page.get(url);
7 | if (relatedPage)
8 | page.related.push(relatedPage);
9 | });
10 | }
11 | };
--------------------------------------------------------------------------------
/example/content/home.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Home
3 | ---
4 | Welcome to the example site.
5 |
6 | The content of this page can be found at /woods/example.site/content/content.md
7 |
8 | The menu above is generated from the numbered folders located in: woods/sites/example.site/content/
9 |
10 | The templates used for the pages are located in woods/templates/
--------------------------------------------------------------------------------
/lib/util/gm.js:
--------------------------------------------------------------------------------
1 | var gm;
2 | var exec = require('child_process').exec;
3 |
4 | exec('gm', function (error, stdout, stderr) {
5 | if (!stderr.length) {
6 | gm = require('gm');
7 | } else {
8 | console.error('You should install Graphics Magick for thumbnail support: http://www.graphicsmagick.org/');
9 | }
10 | });
11 |
12 | module.exports = function() {
13 | return gm.apply(null, arguments);
14 | };
15 |
--------------------------------------------------------------------------------
/example/templates/default/html.jade:
--------------------------------------------------------------------------------
1 | doctype html
2 | html(lang='en')
3 | head
4 | meta(name="description", content=page.description)
5 | link(rel="stylesheet", href="/assets/styles/skeleton.css")
6 | link(rel="stylesheet", href="/assets/styles/styles.css")
7 | title Example Website - #{page.title}
8 | body
9 | div.container
10 | div.three.columns
11 | !=template('menu', root)
12 | div.six.columns
13 | !=template('body')
14 | div.three.columns
15 | !=template('related')
16 |
--------------------------------------------------------------------------------
/lib/woods.js:
--------------------------------------------------------------------------------
1 | var EventEmitter = require('events').EventEmitter;
2 |
3 | var woods = new EventEmitter();
4 | woods.express = require('express')();
5 |
6 | woods.initialize = function (directory, port, watch, callback) {
7 | woods.export = !watch;
8 | var Site = require('./Site');
9 |
10 | port = port || 3000;
11 | woods.express.listen(port);
12 | woods.url = 'http://localhost:' + port;
13 | var site = new Site(woods, directory, watch, callback);
14 |
15 | if (woods.export) {
16 | require('./siteSync');
17 | }
18 | return woods;
19 | };
20 |
21 | module.exports = woods;
22 |
--------------------------------------------------------------------------------
/example/content/02-projects/01-a project/project.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: A Project
3 | tags: tag, another tag
4 | excerpt: This is the excerpt from the project.
5 | date: 01/01/2012
6 | related: ../another-project/, /
7 | ---
8 | Welcome to the project page. As you can see, we are using a different template here.
9 |
10 | Below there will be an image. If you are visiting the page for the first time, the image will appear broken and it will render the thumbnail in the background.
11 |
12 | If you have the livereload plugin running, it will load the thumbnail when done rendering it. Otherwise, reload the page!
--------------------------------------------------------------------------------
/example/parsers/tags.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | tags: function(string, page) {
3 | // Split on commas surrounded by 0 or more spaces:
4 | var names = string.split(/\s*,\s*/g);
5 | if (!names.length)
6 | return;
7 | var site = page.site,
8 | siteTags = site.tags,
9 | pageTags = page.tags;
10 | if (!siteTags) siteTags = site.tags = [];
11 | if (!pageTags) pageTags = page.tags = [];
12 | names.forEach(function(name) {
13 | // Note: site.tags is being treated
14 | // as an object literal and array:
15 | var tag = siteTags[name];
16 | if (!tag) {
17 | tag = siteTags[name] = [];
18 | siteTags.push(tag);
19 | }
20 | tag.push(page);
21 | pageTags.push(tag);
22 | });
23 | }
24 | };
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "woods",
3 | "version": "0.1.2",
4 | "description": "Node.js file based CMS inspired by Kirby & Stacey.",
5 | "license": "MIT",
6 | "repository": {
7 | "type": "git",
8 | "url": "https://github.com/paperjs/woods.git"
9 | },
10 | "bugs": "https://github.com/paperjs/woods/issues",
11 | "author": "Jonathan Puckey",
12 | "bin": {
13 | "woods": "bin/woods"
14 | },
15 | "main": "index.js",
16 | "dependencies": {
17 | "array": "^0.4.3",
18 | "async": "^2.3.0",
19 | "commander": "^2.9.0",
20 | "express": "^4.15.2",
21 | "gm": "^1.23.0",
22 | "jade": "^1.11.0",
23 | "knox": "^0.9.2",
24 | "marked": "^0.3.6",
25 | "mime": "^1.3.4",
26 | "mkdirp": "^0.5.1",
27 | "natural-compare-lite": "^1.4.0",
28 | "needless": "^0.1.1",
29 | "node-watch": "^0.5.2",
30 | "slugg": "^1.2.0",
31 | "superagent": "^3.5.2",
32 | "tiny-lr": "^1.0.3",
33 | "woods-parsedown": "^0.0.4"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/lib/Helpers.js:
--------------------------------------------------------------------------------
1 | var fs = require('fs'),
2 | path = require('path'),
3 | needless = require('needless');
4 |
5 | var fsUtil = require('./util/fs.js'),
6 | woods = require('./woods'),
7 | TemplateParam = require('./TemplateParam');
8 |
9 | var Helpers = function(site) {
10 | this.site = site;
11 | this.uri = site.getUri('helpers');
12 | };
13 |
14 | function loadFile(file) {
15 | // Remove any require cache first, using https://github.com/PPvG/node-needless:
16 | needless(file);
17 | var module = require(file);
18 | for (var key in module) {
19 | TemplateParam.prototype[key] = module[key];
20 | }
21 | }
22 |
23 | Helpers.prototype = {
24 | load: function(callback) {
25 | var uri = this.uri;
26 | fsUtil.listFiles(uri, function(err, files) {
27 | if (!files)
28 | return callback();
29 | files.filter(fsUtil.isVisible).forEach(function(file) {
30 | loadFile(path.join(uri, file));
31 | });
32 | if (callback)
33 | callback();
34 | });
35 | }
36 | };
37 |
38 | module.exports = Helpers;
39 |
--------------------------------------------------------------------------------
/lib/liveReload.js:
--------------------------------------------------------------------------------
1 | var woods = require('./woods'),
2 | changed = changedFile = new Function();
3 |
4 | // Only start the LR server if Node is running
5 | // in dev mode.
6 | if (woods.express.get('env') == 'development' && !woods.export) {
7 | var tinylr = require('tiny-lr'),
8 | request = require('superagent'),
9 | path = require('path');
10 |
11 | // standard LiveReload port
12 | var port = 35729, changedUrl = 'http://localhost:' + port + '/changed';
13 |
14 | var startServer = function() {
15 | tinylr().listen(port);
16 | };
17 |
18 | startServer();
19 |
20 | changed = function(url) {
21 | request.post(changedUrl)
22 | .send({ files: [ url ? url : 'index.html'] })
23 | .end(function(error, res) {
24 | if (error) {
25 | console.log(error);
26 | startServer();
27 | }
28 | });
29 | };
30 | changedFile = function(uri) {
31 | this.changed(path.relative(woods.site.directory, uri));
32 | };
33 | }
34 |
35 | // Set exports. When LR is disabled it's just empty functions.
36 | module.exports = {
37 | changed: changed,
38 | changedFile: changedFile
39 | };
40 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | Licensed under MIT license. http://opensource.org/licenses/MIT
2 |
3 | Copyright (c) 2011 - 2013, Moniker
4 | http://studiomoniker.com
5 | All rights reserved.
6 |
7 | Permission is hereby granted, free of charge, to any person obtaining a copy
8 | of this software and associated documentation files (the "Software"), to deal
9 | in the Software without restriction, including without limitation the rights
10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11 | copies of the Software, and to permit persons to whom the Software is
12 | furnished to do so, subject to the following conditions:
13 |
14 | The above copyright notice and this permission notice shall be included in
15 | all copies or substantial portions of the Software.
16 |
17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
23 | THE SOFTWARE.
24 |
--------------------------------------------------------------------------------
/lib/Pagination.js:
--------------------------------------------------------------------------------
1 | function Pagination(collection, perPage, index) {
2 | this.collection = collection;
3 | this.perPage = perPage || 10;
4 | this._index = index;
5 | }
6 |
7 | Pagination.prototype = {
8 | getIndex: function() {
9 | return Math.min(Math.max(this._index, 1), this.pageCount());
10 | },
11 |
12 | pageCount: function() {
13 | return Math.ceil(this.collection.length / this.perPage);
14 | },
15 |
16 | hasNext: function() {
17 | return this.getIndex() < this.pageCount();
18 | },
19 |
20 | next: function() {
21 | if (this.hasNext())
22 | return this.getIndex() + 1;
23 | },
24 |
25 | hasPrevious: function() {
26 | return this.getIndex() > 1;
27 | },
28 |
29 | previous: function() {
30 | if (this.hasPrevious())
31 | return this.getIndex() - 1;
32 | },
33 |
34 | atFirstPage: function() {
35 | return this.getIndex() == 1;
36 | },
37 |
38 | atLastPage: function() {
39 | return this.getIndex() == this.pageCount();
40 | },
41 |
42 | firstIndex: function() {
43 | return Math.max(0, this.perPage * (this.getIndex() - 1));
44 | },
45 |
46 | lastIndex: function() {
47 | return Math.min(
48 | Math.max(0, this.perPage * this.getIndex()),
49 | Math.max(this.collection.length, 0)
50 | );
51 | }
52 | };
53 |
54 | module.exports = Pagination;
55 |
--------------------------------------------------------------------------------
/lib/listBucket.js:
--------------------------------------------------------------------------------
1 | var async = require('async');
2 |
3 | var listBucket = function(knoxClient, marker, callback) {
4 | if ('function' == typeof marker) {
5 | callback = marker;
6 | marker = null;
7 | }
8 | var options,
9 | fullResults = [],
10 | hasMore = true;
11 | if (marker)
12 | options = { marker: marker };
13 |
14 | var addResults = function(results, done) {
15 | if (!results.Contents) {
16 | hasMore = false;
17 | return done();
18 | }
19 |
20 | fullResults.push.apply(fullResults, results.Contents);
21 | // If there are more results to be had, set the marker to
22 | // the key of the last result. The next listing will start
23 | // from there:
24 | hasMore = !!results.IsTruncated && results.Contents &&
25 | results.Contents.length == 1000;
26 | if (hasMore) {
27 | options = {
28 | marker: results.Contents[results.Contents.length - 1].Key
29 | };
30 | }
31 | done();
32 | };
33 |
34 | var hasMoreResults = function() {
35 | return hasMore;
36 | };
37 |
38 | var getList = function (done) {
39 | knoxClient.list(options, function(err, data) {
40 | if (data && data.Code)
41 | err = Error(data.Code);
42 | if (err)
43 | return done(err);
44 | return addResults(data, done);
45 | });
46 | };
47 |
48 | var gotList = function(err) {
49 | callback(err, fullResults);
50 | };
51 |
52 | async.whilst(hasMoreResults, getList, gotList);
53 | };
54 |
55 | module.exports = listBucket;
56 |
--------------------------------------------------------------------------------
/bin/woods:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | var program = require('commander'),
4 | path = require('path'),
5 | fs = require('fs'),
6 | woods = require('../index');
7 |
8 | // usage
9 |
10 | program
11 | .version(require('../package').version)
12 | .usage('[directory]')
13 | .option('-p, --port [3000]', 'The server port', parseInt)
14 | .option('-s, --sync', 'Sync site to s3')
15 | .option('-e, --export [directory]', 'Export site to directory')
16 | .parse(process.argv);
17 |
18 | if (!program.args.length)
19 | console.log('No site directory provided. Serving example site:');
20 |
21 | var uri = program.args[0] || path.resolve(__dirname, '../example');
22 | fs.exists(uri, function(exists) {
23 | if (exists) {
24 | return woods.initialize(uri, program.port, !(program.export || program.sync), function(err) {
25 | if (err) throw err;
26 | if (program.sync) {
27 | console.log('Syncing site to S3');
28 | return woods.site.syncS3(function(err) {
29 | console.log(err ? err : 'Sync complete.');
30 | process.exit();
31 | });
32 | }
33 | if (program.export) {
34 | console.log('Exporting site');
35 | return woods.site.publish(program.export === true ? null : program.export, function(err) {
36 | console.log(err ? err : 'Export complete.');
37 | process.exit();
38 | });
39 | }
40 | console.log('Serving', woods.site.directory, 'on ' + woods.url);
41 | });
42 | }
43 |
44 | console.log('Site directory not found: ', uri);
45 | process.exit();
46 | });
47 |
--------------------------------------------------------------------------------
/lib/TemplateParam.js:
--------------------------------------------------------------------------------
1 | var marked = require('marked');
2 |
3 | function TemplateParam(page, param) {
4 | param.page = page;
5 | this.page = page;
6 | this.site = page.site;
7 | this.root = page.site.root;
8 | this.param = param;
9 |
10 | /*
11 | * Jade calls the prototype functions (eg. isActive, inPath etc.)
12 | * without their context since version 0.31.0 (this == the global object).
13 | * In order to preserve the context, walk through all properties that are
14 | * functions and rebind them.
15 | */
16 | var that = this;
17 |
18 | for (var prop in this) {
19 | if (this[prop] instanceof Function) {
20 | this[prop] = this[prop].bind(that);
21 | }
22 | }
23 | }
24 |
25 | TemplateParam.prototype = {
26 | isActive: function(page) {
27 | return page == this.param._activePage;
28 | },
29 |
30 | isDescendantOfActive: function(page) {
31 | var queriedPage = this.param._activePage;
32 | while (queriedPage = queriedPage.parent) {
33 | if (page == queriedPage)
34 | return true;
35 | }
36 | return false;
37 | },
38 |
39 | inPath: function(page) {
40 | return this.isActive(page) || page.isAncestor(this.param._activePage);
41 | },
42 |
43 | markdown: function(string) {
44 | return string ? marked(string) : '';
45 | },
46 |
47 | template: function(name, page, param) {
48 | return (page || this.page).template(name, param || this.param);
49 | },
50 |
51 | paginate: function(collection, perPage) {
52 | return collection.paginate(this.param.number, perPage);
53 | }
54 | };
55 |
56 | module.exports = TemplateParam;
57 |
--------------------------------------------------------------------------------
/lib/watchDirectory.js:
--------------------------------------------------------------------------------
1 | module.exports = watchDirectory;
2 |
3 | var util = require('./util/fs.js'),
4 | watch = require('node-watch'),
5 | path = require('path'),
6 | fs = require('fs');
7 |
8 | var liveReload = require('./liveReload.js'),
9 | woods = require('./woods');
10 |
11 | function watchDirectory(site) {
12 | var options = {recursive: true, followSymLinks: true },
13 | changes = [],
14 | timeoutId;
15 |
16 | var changed = function(uri, removed) {
17 | // ignore hidden files
18 | if (!util.isVisible(path.basename(uri)))
19 | return;
20 | woods.emit('changed', site, uri, removed);
21 | site.emit('changed', uri, removed);
22 |
23 | if ((/\/(assets)\//).test(uri))
24 | return liveReload.changedFile(uri);
25 |
26 | if ((/\/(content)\/|jade$/).test(uri))
27 | site.dirty = true;
28 |
29 | if (timeoutId)
30 | clearTimeout(timeoutId);
31 |
32 | changes.push(uri);
33 |
34 | // Make the timeout longer by the amount of filesystem changes:
35 | var timeoutTime = 10 * changes.length + 30;
36 |
37 | timeoutId = setTimeout(function() {
38 | var then = new Date();
39 | // TODO: go through changes array instead and apply individual changes
40 | // instead of rebuilding the whole site?
41 | if (site.dirty) {
42 | site.build(function(err) {
43 | if (err)
44 | console.log(err);
45 | console.log('Rebuilt', site.pageCount, 'pages in', new Date() - then);
46 | liveReload.changed();
47 | site.dirty = false;
48 | });
49 | }
50 | changes.length = 0;
51 | }, timeoutTime);
52 | };
53 | watch(site.getUri(), options, function(evt, uri) {
54 | fs.exists(uri, function(exists) {
55 | changed(uri, !exists);
56 | });
57 | });
58 | }
59 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Woods
2 | =====
3 |
4 | Node.js file based CMS inspired by [Kirby](http://getkirby.com/) & [Stacey](http://www.staceyapp.com/).
5 |
6 | Used amongst others for [paperjs.org](http://paperjs.org) (Source: [github.com/paperjs/paperjs.org](https://github.com/paperjs/paperjs.org))
7 |
8 | ### Features
9 |
10 | * Tree structure with parents and children defined by files and directories in your site directory
11 | * No database
12 | * Markdown content files where any new line starting with 'propertyname:' defines a property on the page
13 | * Listens to file-system changes and rebuilds the site if needed
14 | * [Live Reload](https://chrome.google.com/webstore/detail/livereload/jnihajbhpnppcggbcgedagnkighmdlei): Automatically reloads the browser whenever you edit a content file, static asset or template
15 | * Page type specific Jade templates
16 | * Thumbnails: resizing, max width/height, cropping
17 | * Image / file captions
18 | * Pagination
19 | * Express web server for local testing or actual serving of content
20 | * Sync site to S3 bucket
21 | * Export site to directory
22 | * Basic multi-language support
23 |
24 | ### Todo
25 |
26 | * Tests
27 | * FTP syncing
28 |
29 | ### Requirements
30 |
31 | Woods requires Graphics Magick to be installed on your system: http://www.graphicsmagick.org/
32 |
33 | ### Installation
34 |
35 | npm install woods -g
36 | woods
37 |
38 | Then point your browser to:
39 | [http://localhost:3000/](http://localhost:3000/)
40 |
41 | ### Usage
42 |
43 | Usage: woods [directory]
44 |
45 | Options:
46 |
47 | -h, --help output usage information
48 | -V, --version output the version number
49 | -p, --port [3000] The server port
50 | -s, --sync Sync site to s3
51 | -e, --export [directory] Export site to directory
52 |
53 | (Don't forget to turn on your [Live Reload plugin](https://chrome.google.com/webstore/detail/livereload/jnihajbhpnppcggbcgedagnkighmdlei) while editing)
54 |
55 |
56 | ## License
57 | This project is licensed under the [MIT license](http://opensource.org/licenses/MIT).
58 |
--------------------------------------------------------------------------------
/lib/Collection.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @name Collection
3 | *
4 | * @class The Collection type is used for Page#children / Page#files etc,
5 | */
6 |
7 | var array = require('array'),
8 | util = require('util');
9 |
10 | var Pagination = require('./Pagination');
11 |
12 | /**
13 | * Returns the visible items contained within the collection
14 | * as a new Collection.
15 | *
16 | * @return {Collection}
17 | */
18 | array.prototype.visible = function() {
19 | return this.filter(function(page) {
20 | return !!page.visible;
21 | });
22 | };
23 |
24 | /**
25 | * Returns the invisible items contained within the collection
26 | * as a new Collection.
27 | *
28 | * @return {Collection}
29 | */
30 | array.prototype.invisible = function() {
31 | return this.filter(function(page) {
32 | return !page.visible;
33 | });
34 | };
35 |
36 | /**
37 | * Returns a new collection containing the items of this collection
38 | * flipped in order.
39 | *
40 | * @return {Collection}
41 | */
42 | array.prototype.flip = function() {
43 | return this.slice(0).reverse();
44 | };
45 |
46 | /**
47 | * Returns a new collection containing the items of this collection
48 | * shuffled randomly in order.
49 | *
50 | * @return {Collection}
51 | */
52 | array.prototype.shuffle = function () {
53 | var arr = this.slice(0);
54 | for (var i = arr.length - 1; i > 0; i--) {
55 | var j = Math.floor(Math.random() * (i + 1));
56 | var tmp = arr[i];
57 | arr[i] = arr[j];
58 | arr[j] = tmp;
59 | }
60 | return arr;
61 | };
62 |
63 | array.prototype.inGroupsOf = function(n){
64 | var arr = [];
65 | var group = [];
66 |
67 | for (var i = 0, len = this.length; i < len; ++i) {
68 | group.push(this[i]);
69 | if ((i + 1) % n === 0) {
70 | arr.push(group);
71 | group = [];
72 | }
73 | }
74 |
75 | if (group.length) arr.push(group);
76 |
77 | return new array(arr);
78 | };
79 |
80 | array.prototype.paginate = function(current, perPage) {
81 | var pagination = new Pagination(this, perPage, current),
82 | items = this.slice(pagination.firstIndex(), pagination.lastIndex());
83 | items.pagination = pagination;
84 | return items;
85 | };
86 |
87 | module.exports = array;
88 |
--------------------------------------------------------------------------------
/ChangeLog:
--------------------------------------------------------------------------------
1 | 2014.04.11 Version 0.0.8
2 |
3 | * Remove tiny-lr / super agent from dev dependencies again.
4 |
5 | 2014.04.11 Version 0.0.7
6 |
7 | * Implement exporting of site to directory.
8 | * Implement Page#raw, which contains the raw content of the page.
9 | * Do not override existing properties on page when parsing content file.
10 | * Use different approach to generate grayscale images.
11 | * Fix quality setting when exporting thumbnails.
12 | * Improve installation instructions and throw a better error when Graphics Magick is missing
13 | * Use slugg module instead of uslug for generating slugs.
14 | * Update jade to avoid weird filename bug, and pass along filename for nice debug messages.
15 | * Fix case insensitive naming of Parsers.js
16 | * Fix case of Thumbnails.js
17 | * Do not call watchDirectory() when exporting.
18 | * Only require watchDirectory() if we're actually watching.
19 | * Simplify fs utils by passing through callback and streamline error handling.
20 | * Add alphanumerical file sorting since on linux the file sequence is not specified.
21 | * Include more parameters in the thumbnail md5 hash.
22 | * Remove local paragraphs since it's now available as a module on NPM and projects can expose it.
23 | * Use pretty output in Jade by default.
24 | * Set proper content-type and encoding.
25 | * Also support content folders with a dot as separator.
26 | * Add optional natural sorting
27 | * Add support for image/file descriptions
28 | * Page#siblings -> Return empty collection if page has no siblings
29 | * Templates are no longer lazy loaded
30 | * Use JSON as settings file
31 | * Basic multi-language support
32 | * Only run LR server in dev environments
33 | * Only require siteSync when exporting
34 | * Don't load liveReload if only exporting
35 | * Rework TemplateParam to be compatible with latest version of Jade
36 | * Don't use a network request to render out the html, just call the template
37 |
38 | 2013.05.05 Version 0.0.6
39 |
40 | * Make woods work with one site at a time
41 | * Add 'contentExtension' setting
42 | * Modules in 'helpers' directory are added to TemplateParam for access within templates
43 | * Rename 'render' to 'template' for template rendering from within templates
44 | * Add inGroupsOf helper function to Collection
45 | * grayscale option for thumbnails
46 | * Add File#created and File#modified
47 | * Collections for Page#children, Page#files etc.
48 | * Pagination
49 | * S3 syncing
50 | * Site settings through settings.md file
51 | * Create thumbnails directory when missing
52 | * Break out content parsing into woods-parsedown module
53 |
54 | 2013.04.07 Version 0.0.5
55 |
56 | * Implemented woods binary and updated installation instructions
57 |
58 | 2013.04.07 Version 0.0.4
59 |
60 | * Created TemplateParam prototype for template access of values.
61 | * Fixed problem in Page#get where errors were being thrown if a page was not found
62 | * Removed unused removeRoute function in site
63 | * Gave templates access to request and query objects
64 | * Implemented Site#modified
65 | * Implemented File#dimensions, File#width, File#height & File#html()
66 | * Implemented exporting of thumbnails through File#exportThumbnail(param) & File#thumb(param)
67 | * Implemented cropping of thumbnails through optional param.cropWidth, param.cropHeight & param.gravity parameters.
68 |
--------------------------------------------------------------------------------
/lib/Parsers.js:
--------------------------------------------------------------------------------
1 | var fs = require('fs'),
2 | parsedown = require('woods-parsedown'),
3 | path = require('path'),
4 | needless = require('needless');
5 |
6 | var fsUtil = require('./util/fs.js'),
7 | woods = require('./woods');
8 |
9 | function Parsers(site) {
10 | this.site = site;
11 | this.uri = site.getUri('parsers');
12 | this.dictionary = {};
13 | this.keysByFile = {};
14 | // Parse date properties:
15 | this.register('date', function(string, page) {
16 | var parts = string.split('/'),
17 | date = new Date(+parts[2], +parts[0] - 1, +parts[1]);
18 | page.date = date;
19 | page.dateText = string;
20 | });
21 |
22 | var parsers = this;
23 | site.on('changed', function(uri, removed) {
24 | if ((/\/(parsers)\//).test(uri)) {
25 | site.dirty = true;
26 | if (removed) {
27 | parsers.removeByKey(uri);
28 | } else {
29 | fsUtil.isFile(uri, function(isFile) {
30 | if (isFile) {
31 | parsers.load(uri);
32 | } else {
33 | parsers.loadAll();
34 | }
35 | });
36 | }
37 | }
38 | });
39 | }
40 |
41 | Parsers.prototype = {
42 | has: function(key) {
43 | return !!this.dictionary[key];
44 | },
45 |
46 | get: function(key) {
47 | return this.dictionary[key];
48 | },
49 |
50 | removeByFile: function(file) {
51 | var keys = this.keysByFile[file];
52 | if (!keys)
53 | return;
54 | for (var i = 0; i < keys.length; i++) {
55 | delete this.dictionary[keys[i]];
56 | }
57 | delete this.keysByFile[file];
58 | },
59 |
60 | removeByKey: function(key) {
61 | delete this.dictionary[key];
62 | },
63 |
64 | loadFile: function(file) {
65 | // Remove any parsers that were previously created by this file:
66 | this.removeByFile(file);
67 | // Remove any require cache first, using https://github.com/PPvG/node-needless:
68 | needless(file);
69 | var module = require(file);
70 | var keysByFile = this.keysByFile[file] = [];
71 | for (var key in module) {
72 | keysByFile.push(key);
73 | this.register(key, module[key]);
74 | }
75 | },
76 |
77 | load: function(callback) {
78 | var parsers = this,
79 | uri = this.uri;
80 | fsUtil.listOnlyVisibleFiles(uri, function(err, files) {
81 | if (!files)
82 | return callback();
83 | files.forEach(function(file) {
84 | parsers.loadFile(path.join(uri, file));
85 | });
86 | if (callback)
87 | callback();
88 | });
89 | },
90 |
91 | parse: function(string, page) {
92 | page.raw = string;
93 | var param = parsedown(string),
94 | parsers = this;
95 | for (var key in param) {
96 | var value = param[key];
97 | if (parsers.has(key)) {
98 | parsers.get(key)(value, page);
99 | } else {
100 | // Do not override existing properties:
101 | if (!page[key])
102 | page[key] = value;
103 | }
104 | }
105 | // Fallback to page.name for page.title:
106 | page.title = page.title || page.name;
107 | },
108 |
109 | parseFile: function(file, page, callback) {
110 | var parsers = this;
111 | fs.readFile(file, 'utf8', function(err, buffer) {
112 | if (!err)
113 | parsers.parse(buffer.toString(), page);
114 | callback(err);
115 | });
116 | },
117 |
118 | register: function(name, parser) {
119 | this.dictionary[name] = parser;
120 | }
121 | };
122 |
123 | module.exports = Parsers;
124 |
--------------------------------------------------------------------------------
/lib/util/fs.js:
--------------------------------------------------------------------------------
1 | var fs = require('fs'),
2 | async = require('async'),
3 | path = require('path');
4 |
5 | // We need to sort files alphanumerically since on linux the file sequence is
6 | // not specified.
7 | function sortFiles(files) {
8 | files.sort(function(a, b) {
9 | return a < b ? -1 : 1;
10 | });
11 | return files;
12 | }
13 |
14 | function isVisible(file) {
15 | return !(/^\./).test(file);
16 | }
17 |
18 | function listFiles(dir, callback, _callback) {
19 | // When listFiles is called by one of the other listing functions, these
20 | // can bass on their callback function as _callback, for direct routing
21 | // through of errors, without having to handle these on each level.
22 | _callback = _callback || callback;
23 | fs.readdir(dir, function(err, files) {
24 | if (!files || err)
25 | return _callback(err);
26 | callback(null, sortFiles(files));
27 | });
28 | }
29 |
30 | function listFiltred(dir, directories, callback, _callback) {
31 | listFiles(dir, function(err, files) {
32 | async.filter(files, function(uri, callback) {
33 | fs.stat(path.resolve(dir, uri), function(err, stats) {
34 | var isDirectory = !!err || stats.isDirectory();
35 | callback(null, directories ? isDirectory : !isDirectory);
36 | });
37 | }, callback);
38 | }, _callback || callback);
39 | }
40 |
41 | var util = {
42 | isVisible: isVisible,
43 | listFiles: listFiles,
44 |
45 | listDirectories: function(dir, callback, _callback) {
46 | listFiltred(dir, true, callback, _callback);
47 | },
48 |
49 | listOnlyFiles: function(dir, callback, _callback) {
50 | listFiltred(dir, false, callback, _callback);
51 | },
52 |
53 | listVisibleFiles: function(dir, callback, _callback) {
54 | listFiles(dir, function(err, files) {
55 | return callback(null, files.filter(isVisible));
56 | }, _callback || callback);
57 | },
58 |
59 | listOnlyVisibleFiles: function(dir, callback, _callback) {
60 | util.listOnlyFiles(dir, function(err, files) {
61 | return callback(null, files.filter(isVisible));
62 | }, _callback || callback);
63 | },
64 |
65 | isFile: function(uri, callback) {
66 | fs.stat(uri, function(err, stats) {
67 | callback(!err && stats && stats.isFile());
68 | });
69 | },
70 |
71 | isDirectory: function(uri, callback) {
72 | fs.stat(uri, function(err, stats) {
73 | if (err) throw err;
74 | callback(!err && stats && stats.isDirectory());
75 | });
76 | },
77 |
78 | recursiveDirectoryList: function(start, callback) {
79 | // Use lstat to resolve symlink if we are passed a symlink
80 | fs.lstat(start, function(err, stat) {
81 | if(err) {
82 | return callback(err);
83 | }
84 | var found = {dirs: [], files: []},
85 | total = 0,
86 | processed = 0;
87 | function isDir(abspath) {
88 | fs.stat(abspath, function(err, stat) {
89 | if(stat.isDirectory()) {
90 | found.dirs.push(abspath);
91 | // If we found a directory, recurse!
92 | util.recursiveDirectoryList(abspath, function(err, data) {
93 | found.dirs = found.dirs.concat(data.dirs);
94 | found.files = found.files.concat(data.files);
95 | if(++processed == total) {
96 | callback(null, found);
97 | }
98 | });
99 | } else {
100 | found.files.push(abspath);
101 | if(++processed == total) {
102 | callback(null, found);
103 | }
104 | }
105 | });
106 | }
107 | // Read through all the files in this directory
108 | if(stat.isDirectory()) {
109 | fs.readdir(start, function (err, files) {
110 | files = files.filter(isVisible);
111 | total = files.length;
112 | for(var x = 0, l = files.length; x < l; x++) {
113 | isDir(path.join(start, files[x]));
114 | }
115 | if (total === 0)
116 | callback(null, found);
117 | });
118 | } else {
119 | return callback(new Error("path: " + start + " is not a directory"));
120 | }
121 | });
122 | }
123 | };
124 |
125 | module.exports = util;
126 |
--------------------------------------------------------------------------------
/lib/Thumbnails.js:
--------------------------------------------------------------------------------
1 | var gm = require('./util/gm.js'),
2 | path = require('path'),
3 | fs = require('fs'),
4 | crypto = require('crypto'),
5 | async = require('async'),
6 | mkdirp = require('mkdirp'),
7 | util = require('util'),
8 | EventEmitter = require('events').EventEmitter;
9 |
10 | var woods = require('./woods'),
11 | liveReload = require('./liveReload'),
12 | File = require('./File');
13 |
14 | var exportGM = function (param, callback) {
15 | mkdirp(path.dirname(param.dstPath), function(err) {
16 | if (err)
17 | callback(err);
18 | var resizer = gm(param.srcPath);
19 | if (param.width != this.width || param.height != this.height)
20 | resizer.resize(param.width, param.height);
21 | if (param.cropWidth || param.cropHeight) {
22 | resizer.crop(param.cropWidth, param.cropHeight).gravity(param.gravity);
23 | }
24 | if (param.grayscale)
25 | resizer.type('grayscale');
26 | resizer.quality(param.quality || 90);
27 | resizer.autoOrient()
28 | .write(param.dstPath, function(err) {
29 | callback(err);
30 | });
31 | });
32 | };
33 |
34 | var gmQueue = async.queue(exportGM, 4);
35 |
36 | function makeHash(file, param) {
37 | var parts = [
38 | file.uri,
39 | file.size,
40 | file.created,
41 | file.modified
42 | ];
43 | for (var i in param)
44 | parts.push(param[i]);
45 | return crypto.createHash('md5').update(parts.join('_')).digest('hex');
46 | }
47 |
48 | File.prototype.exportThumb = function(param, _retina, _hashName) {
49 | var settings = {};
50 | if (param) {
51 | for (var i in param)
52 | settings[i] = param[i];
53 | }
54 |
55 | var width = settings.width === undefined
56 | ? settings.height === undefined
57 | ? this.width
58 | : Math.round((height / this.height) * this.width)
59 | : settings.width;
60 | var height = settings.height === undefined
61 | ? settings.width === undefined
62 | ? this.height
63 | : Math.round((param.width / this.width) * this.height)
64 | : settings.height;
65 | if ((settings.maxWidth && width > settings.maxWidth) || (settings.maxHeight && height > settings.maxHeight)) {
66 | var factor = width / height;
67 | if (maxWidth && width > maxWidth) {
68 | width = maxWidth;
69 | height = Math.round(width / factor);
70 | }
71 | if (maxHeight && height > maxHeight) {
72 | height = maxHeight;
73 | width = Math.round(height * factor);
74 | }
75 | }
76 |
77 | if (settings.cropWidth || settings.cropHeight) {
78 | if (settings.cropWidth && !settings.cropHeight)
79 | param.cropHeight = height;
80 |
81 | if (settings.cropHeight && !settings.cropWidth)
82 | param.cropWidth = width;
83 | if (width < settings.cropWidth)
84 | settings.cropWidth = width;
85 | if (height < settings.cropHeight)
86 | settings.cropHeight = height;
87 |
88 | if (!settings.gravity)
89 | settings.gravity = 'Center';
90 | }
91 |
92 | if (_retina) {
93 | width *= 2;
94 | height *= 2;
95 | if (settings.cropWidth)
96 | settings.cropWidth *= 2;
97 | if (settings.cropHeight)
98 | settings.cropHeight *= 2;
99 | }
100 |
101 | var file = this,
102 | site = this.site,
103 | thumbnails = file.site.thumbnails,
104 | extension = path.extname(file.uri),
105 | hash = (_hashName || makeHash(file, settings)),
106 | name = hash + (_retina ? '-2x' : '') + extension,
107 | dstPath = path.join(site.getUri('assets/thumbnails'), name || ''),
108 | exists = !!thumbnails.thumbsByHash[dstPath];
109 | if (!exists) {
110 | if (param.retina) {
111 | delete param.retina;
112 | file.exportThumb(param, true, hash);
113 | }
114 |
115 | thumbnails.thumbsByHash[dstPath] = name;
116 | fs.exists(dstPath, function(exists) {
117 | if (!exists) {
118 | thumbnails.queueLength++;
119 | thumbnails.queue.push({
120 | width: width,
121 | height: height,
122 | cropWidth: settings.cropWidth,
123 | cropHeight: settings.cropHeight,
124 | gravity: settings.gravity,
125 | srcPath: file.uri,
126 | dstPath: dstPath,
127 | quality: settings.quality,
128 | grayscale: settings.grayscale
129 | }, function(err) {
130 | thumbnails.queueLength--;
131 | if (err)
132 | console.log(err);
133 | });
134 | }
135 | });
136 | }
137 | return {
138 | url: '/assets/thumbnails/' + name,
139 | width: settings.cropWidth || width,
140 | height: settings.cropHeight || height
141 | };
142 | };
143 |
144 | File.prototype.thumb = function(param) {
145 | var info = this.exportThumb(param);
146 | return '';
150 | };
151 |
152 | woods.on('changed', function(site, uri, removed) {
153 | if (removed && (/\/(thumbnails)\//).test(uri)) {
154 | delete site.thumbnails.thumbsByHash[uri];
155 | liveReload.changedFile(uri);
156 | }
157 | });
158 |
159 | function Thumbnails(site) {
160 | var thumbnails = this;
161 | this.site = site;
162 | this.queueLength = 0;
163 | this.queue = async.queue(function(task, callback) {
164 | gmQueue.push(task, callback);
165 | }, 4);
166 | this.thumbsByHash = {};
167 | this.queue.drain = function() {
168 | thumbnails.emit('done');
169 | };
170 | }
171 |
172 | util.inherits(Thumbnails, EventEmitter);
173 |
174 | module.exports = Thumbnails;
175 |
--------------------------------------------------------------------------------
/lib/File.js:
--------------------------------------------------------------------------------
1 | var path = require('path'),
2 | mime = require('mime'),
3 | fs = require('fs'),
4 | gm = require('gm'),
5 | async = require('async');
6 |
7 | function File(page, index, filename, callback) {
8 | this.index = index;
9 | this.page = page;
10 | this.site = page.site;
11 | this.filename = filename;
12 | this.uri = page.getUri(filename);
13 | this.url = page.getUrl(filename);
14 |
15 | // The file extension includes a leading .
16 | this.extension = path.extname(filename);
17 |
18 | // The name of the file without extension:
19 | this.name = path.basename(filename, this.extension);
20 | this.mime = mime.lookup(filename);
21 | this.type = getType(this.extension);
22 | var file = this;
23 | // TODO: find a way to cache these values
24 | var setFileProperties = function(callback) {
25 | fs.stat(file.uri, function(err, stats) {
26 | if (err)
27 | return callback(err);
28 | file.size = stats.size;
29 | file.humanSize = humanFileSize(stats.size);
30 | file.created = stats.ctime;
31 | file.modified = stats.mtime;
32 | return callback();
33 | });
34 | };
35 | var setDescription = function(callback) {
36 | fs.readFile(file.uri + page.site.settings.contentExtension, 'utf8', function(err, content) {
37 | if (err)
38 | file.description = null;
39 | else
40 | file.description = content;
41 | return callback();
42 | });
43 | };
44 | var setDimensions = function(callback) {
45 | if (file.type == 'image') {
46 | gm(file.uri)
47 | .size(function (err, size) {
48 | if (err)
49 | return callback(err);
50 | file.width = size.width;
51 | file.height = size.height;
52 | file.landscape = file.width > file.height;
53 | file.portrait = file.height > file.width;
54 | file.dimensions = size;
55 | callback();
56 | });
57 | } else {
58 | callback();
59 | }
60 | };
61 | async.parallel([setFileProperties, setDimensions, setDescription], callback);
62 | }
63 |
64 | /**
65 | * Get the html needed to embed this file. Currently only
66 | * images are supported. If only a width or a height is
67 | * provided, the other is calculated automatically.
68 | *
69 | * @param {Number} [width]
70 | * @param {Number} [height]
71 | * @return {String}
72 | */
73 | File.prototype.html = function(param) {
74 | if (!param)
75 | param = {};
76 | var width = param.width === undefined
77 | ? param.height === undefined
78 | ? this.width
79 | : Math.round((param.height / this.height) * this.width)
80 | : width;
81 | var height = param.height === undefined
82 | ? param.width === undefined
83 | ? this.height
84 | : Math.round((param.width / this.width) * this.height)
85 | : height;
86 | if (this.type == 'image') {
87 | return '
';
94 | } else {
95 | return '' + this.filename + '';
96 | }
97 | };
98 |
99 | /**
100 | * Get the siblings of this file (includes the file itself).
101 | *
102 | * @return {File[]}
103 | */
104 | File.prototype.getSiblings = function() {
105 | return this.page.files;
106 | };
107 |
108 | /**
109 | * Get the siblings of this file (includes the file itself).
110 | *
111 | * @return {File[]}
112 | */
113 | File.prototype.getSibling = function(index) {
114 | return this.getSiblings()[index];
115 | };
116 |
117 | /**
118 | * Checks whether there is a previous file on the same level as this one.
119 | *
120 | * @return {Boolean} {@true if the file is an ancestor of the specified
121 | * file}
122 | */
123 | File.prototype.hasPrevious = function() {
124 | return this.index > 0;
125 | };
126 |
127 | /**
128 | * Checks whether there is a next file on the same level as this one.
129 | *
130 | * @return {Boolean}
131 | */
132 | File.prototype.hasNext = function() {
133 | return this.index + 1 < this.getSiblings().length;
134 | };
135 |
136 | /**
137 | * The next file on the same level as this file.
138 | *
139 | * @return {File}
140 | */
141 | File.prototype.getNext = function(page) {
142 | return this.hasNext() ? this.getSibling(this.index + 1) : null;
143 | };
144 |
145 | /**
146 | * The previous file on the same level as this file.
147 | *
148 | * @return {File}
149 | */
150 | File.prototype.getPrevious = function() {
151 | return this.hasPrevious() ? this.getSibling(this.index - 1) : null;
152 | };
153 |
154 | // Taken from the comments of: http://programanddesign.com/js/human-readable-file-size-in-javascript/
155 | function humanFileSize(size) {
156 | var i = Math.floor( Math.log(size) / Math.log(1024) );
157 | return (size / Math.pow(1024, i)).toFixed(2) * 1 + ' '
158 | + ['B', 'KB', 'MB', 'GB', 'TB'][i];
159 | }
160 |
161 | function getType(extension) {
162 | switch (extension) {
163 | case '.jpg':
164 | case '.jpeg':
165 | case '.gif':
166 | case '.png':
167 | return 'image';
168 | case '.pdf':
169 | case '.doc':
170 | case '.txt':
171 | case '.xls':
172 | case '.ppt':
173 | case '.zip':
174 | case '.html':
175 | case '.css':
176 | return 'document';
177 | case '.mov':
178 | case '.mp4':
179 | case '.mpg':
180 | case '.swf':
181 | case '.ogg':
182 | case '.webm':
183 | case '.flv':
184 | case '.avi':
185 | return 'movie';
186 | case '.mp3':
187 | case '.wav':
188 | case '.aiff':
189 | return 'sound';
190 | default:
191 | return 'other';
192 | }
193 | }
194 |
195 | module.exports = File;
196 |
--------------------------------------------------------------------------------
/lib/Site.js:
--------------------------------------------------------------------------------
1 | module.exports = Site;
2 |
3 | var fs = require('fs'),
4 | path = require('path'),
5 | jade = require('jade'),
6 | express = require('express'),
7 | async = require('async'),
8 | marked = require('marked'),
9 | settings = require('./settings.json');
10 |
11 | var fsUtil = require('./util/fs.js'),
12 | Parsers = require('./Parsers'),
13 | Helpers = require('./Helpers'),
14 | watchDirectory = require('./watchDirectory'),
15 | Page = require('./Page'),
16 | Thumbnails = require('./Thumbnails');
17 |
18 | require('util').inherits(Site, require('events').EventEmitter);
19 |
20 | var loadSettings = function(site, callback) {
21 | fs.readFile(site.getUri('settings.json'), 'utf8', function(err, content) {
22 | var data = (err) ? {} : JSON.parse(content);
23 | for (var key in data) {
24 | settings[key] = data[key];
25 | }
26 | // Set linebreaks setting
27 | marked.setOptions({
28 | breaks: settings.trueLinebreaks || false
29 | });
30 | site.settings = settings;
31 | callback();
32 | });
33 | };
34 |
35 | function Site(woods, directory, watch, callback) {
36 | this.woods = woods;
37 | woods.site = this;
38 | this.directory = directory;
39 | this.parsers = new Parsers(this);
40 | this.helpers = new Helpers(this);
41 | this.thumbnails = new Thumbnails(this);
42 | if (watch)
43 | watchDirectory(this);
44 | installRoutes(this);
45 | var site = this;
46 | loadSettings(this, function() {
47 | // Language specific settings
48 | site.defaultLang = site.settings.defaultLanguage || 'en';
49 | site.availableLangs = site.settings.availableLanguages || [site.defaultLang];
50 |
51 | site.parsers.load(function() {
52 | site.helpers.load(function() {
53 | site.build(callback);
54 | });
55 | });
56 | });
57 | }
58 |
59 | Site.prototype.getUri = function(file) {
60 | return path.resolve(this.directory, file || '');
61 | };
62 |
63 | Site.prototype._addType = function(type, callback) {
64 | if (this._types[type])
65 | return callback();
66 |
67 | // Check for templates belonging to type and install them
68 | var typeTemplates = this._types[type] = {},
69 | templateLocations = this._templateLocations[type] = {},
70 | templatesDir = getTemplateDirectory(this, type);
71 |
72 | var compileTemplate = function(file, doneCompiling) {
73 | // Compile jade templates:
74 | if (path.extname(file) == '.jade') {
75 | var templateName = path.basename(file, '.jade');
76 | var location = templateLocations[templateName] =
77 | path.join(templatesDir, file);
78 | fs.readFile(location, 'utf8', function(err, data) {
79 | if (!err) {
80 | try {
81 | typeTemplates[templateName] = jade.compile(data, {
82 | filename: location,
83 | pretty: true
84 | });
85 | } catch (e) {
86 | typeTemplates[templateName] = function() {
87 | return '
Error compiling template: \n' + e;
88 | };
89 | }
90 | }
91 | return doneCompiling(err);
92 | });
93 | } else {
94 | return doneCompiling();
95 | }
96 | };
97 |
98 | fsUtil.listOnlyVisibleFiles(templatesDir, function(err, files) {
99 | if (err || !files)
100 | return callback(err);
101 | async.each(files, compileTemplate, callback);
102 | });
103 | };
104 |
105 | Site.prototype.build = function(callback) {
106 | var site = this;
107 | site.pageCount = 0;
108 | site._types = {};
109 | site._templateLocations = {};
110 | site._addType('default', function(err) {
111 | if (err)
112 | return callback('Error: Default template missing');
113 | registerTemplates(site, function (err) {
114 | var newRoot = new Page(site, null, '', null, function(err) {
115 | site.modified = Date.now();
116 | if (site.availableLangs.length > 1) {
117 | relinkParents(newRoot);
118 | }
119 | site.root = newRoot;
120 | callback(err);
121 | });
122 | });
123 | });
124 | };
125 |
126 |
127 | function relinkParents(page) {
128 | page.children.each(function(child, i) {
129 | // Link to correct parent
130 | for (var lang in page.translatedCopies) {
131 | if (child.hasLang(lang)) {
132 | var parent = page.getLang(lang);
133 | child.getLang(lang).parent = parent;
134 | parent.children[i] = parent.children[child.slug] = child.getLang(lang);
135 | }
136 | }
137 | // Recursive
138 | if (child.hasChildren()) {
139 | relinkParents(child);
140 | }
141 | });
142 | }
143 |
144 | function registerTemplates(site, callback) {
145 | var uri = getTemplateDirectory(site);
146 | fsUtil.listDirectories(uri, function(err, files) {
147 | if (err || !files)
148 | return callback(err);
149 | async.each(files, function (file, cb) {
150 | site._addType(file, cb);
151 | }, callback);
152 | });
153 | }
154 |
155 | function getTemplateDirectory(site, type) {
156 | return site.getUri(path.join('templates', type || ''));
157 | }
158 |
159 | function escapeRegex(str) {
160 | return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
161 | }
162 |
163 | // Check if specific language was requested,
164 | // else serve default
165 | function handleLanguage(req, res, next, site) {
166 | var page, parts = req.params[0].slice(1).split('/'),
167 | availableLangs = site.availableLangs;
168 |
169 | if (parts.length > 1 && availableLangs.indexOf(parts[0]) != -1) {
170 | var requestedLang = parts[0];
171 | parts.shift();
172 | // This is the base page
173 | page = site.root.get('/' + parts.join('/'));
174 | // If a translated version of this page exists, return it
175 | if (page.hasLang(requestedLang)) {
176 | page = page.getLang(requestedLang);
177 | }
178 | } else {
179 | page = site.root.get(req.params[0]);
180 | }
181 | handleRequest(req, res, next, page);
182 | }
183 |
184 | function handleRequest(req, res, next, page) {
185 | if (page) {
186 | res.setHeader('Content-Type', 'text/html; charset=utf-8');
187 | res.end(page.template('html', {
188 | _activePage: page,
189 | request: req,
190 | query: req.query,
191 | number: req.params[1] && +req.params[1]
192 | }));
193 | } else {
194 | next();
195 | }
196 | }
197 |
198 | function installRoutes(site) {
199 | site.woods.express.use(
200 | '/assets',
201 | express.static(site.getUri('assets'))
202 | );
203 |
204 | site.woods.express.get(/^(.*?)([0-9]*)*?$/, function(req, res, next) {
205 | if (site.availableLangs) {
206 | handleLanguage(req, res, next, site);
207 | } else {
208 | handleRequest(req, res, next, site.root.get(req.params[0]));
209 | }
210 | });
211 | }
212 |
--------------------------------------------------------------------------------
/lib/siteSync.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 | var mkdirp = require('mkdirp'),
3 | fs = require('fs'),
4 | path = require('path'),
5 | url = require('url'),
6 | async = require('async'),
7 | knox = require('knox'),
8 | crypto = require('crypto');
9 |
10 | var Page = require('./Page'),
11 | Site = require('./Site'),
12 | File = require('./File'),
13 | fsUtil = require('./util/fs.js'),
14 | listBucket = require('./listBucket');
15 |
16 |
17 | var hashFile = function (file, callback) {
18 | var hash = crypto.createHash('md5');
19 | hash.setEncoding('hex');
20 | var readStream = fs.createReadStream(file.file);
21 | readStream.on('end', function() {
22 | hash.end();
23 | callback(null, hash.read());
24 | });
25 | readStream.pipe(hash);
26 | };
27 |
28 | var hashBuffer = function(buffer) {
29 | return crypto.createHash('md5').update(buffer).digest('hex');
30 | };
31 |
32 | var exportSite = function(site, save, done) {
33 | var pageQueue = async.queue(function(page, done) {
34 | page.files.forEach(addToQueue);
35 | save(page, url.resolve(page.url, 'index.html'), done);
36 | }, 10);
37 |
38 | var queuePages = function(page) {
39 | // Filter out pages whose names consist only of dashes (e.g. used for
40 | // navigation spacers), since their URL parts would collapse and over-
41 | // ride parent pages.
42 | // TODO: Find a better fix.
43 | if (!/^-*$/.test(page.name)) {
44 | addToQueue(page);
45 | }
46 | for (var i = 0; i < page.children.length; i++)
47 | queuePages(page.children[i]);
48 | if (page.translatedCopies) {
49 | for (var lang in page.translatedCopies) {
50 | queuePages(page.translatedCopies[lang]);
51 | }
52 | }
53 | };
54 |
55 | var addToQueue = function(task) {
56 | if (task instanceof Page)
57 | return pageQueue.push(task);
58 | queue.push(task);
59 | };
60 |
61 | var queue = async.queue(function(task, done) {
62 | if (task instanceof File)
63 | return save({ file: task.uri }, path.relative(site.root.url, task.url), done);
64 |
65 | if (task.file)
66 | return save(task, path.relative(site.getUri(), task.file), done);
67 |
68 | fsUtil.recursiveDirectoryList(task.directory, function(err, found) {
69 | if (err) return done(err);
70 | found.files.forEach(function(file) {
71 | addToQueue({file: file});
72 | });
73 | done();
74 | });
75 | }, 10);
76 |
77 | var uploadAssets = function() {
78 | addToQueue({directory: site.getUri('assets')});
79 | queue.drain = done;
80 | };
81 |
82 | // When the page queue has drained, check if there are any thumbnails left
83 | // to be rendered:
84 | pageQueue.drain = function(err) {
85 | if (err) return callback(err);
86 | if (site.thumbnails.queueLength > 0) {
87 | console.log('Waiting for thumbnails to finish exporting...');
88 | return site.thumbnails.on('done', uploadAssets);
89 | }
90 | uploadAssets();
91 | };
92 |
93 | queuePages(site.root);
94 | };
95 |
96 | Site.prototype.publish = function(directory, callback) {
97 | var site = this;
98 | directory = directory ? directory : path.join(site.directory, 'out');
99 | var save = function(object, uri, callback) {
100 | var file = path.join(directory, uri),
101 | dir = path.dirname(file);
102 |
103 | mkdirp(dir, function() {
104 | if (object instanceof Page) {
105 | var html = object.getHtml();
106 | return fs.writeFile(file, html, callback);
107 | }
108 | fs.createReadStream(object.file).pipe(fs.createWriteStream(file)).on('close', callback);
109 | });
110 | };
111 | mkdirp(directory, function() {
112 | exportSite(site, save, callback);
113 | });
114 | };
115 |
116 | Site.prototype.syncS3 = function(callback) {
117 | // Try to read keys from environment if not specified in the settings
118 | this.settings.s3key = this.settings.s3key || process.env.AWS_ACCESS_KEY_ID;
119 | this.settings.s3secret = this.settings.s3secret || process.env.AWS_SECRET_ACCESS_KEY;
120 |
121 | if (!this.settings.s3key || !this.settings.s3secret || !this.settings.s3bucket)
122 | return callback(Error('S3 configuration not found. Missing one or more of s3key, s3secret and s3bucket settings in site-directory/settings.json'));
123 | var rootUrl = this.root.url,
124 | s3List = {};
125 |
126 | var client = knox.createClient({
127 | key: this.settings.s3key,
128 | secret: this.settings.s3secret,
129 | bucket: this.settings.s3bucket,
130 | region: this.settings.s3region || 'us-standard',
131 | secure: true
132 | });
133 |
134 | var listFiles = function(callback) {
135 | console.log('Getting S3 bucket list');
136 | listBucket(client, function(err, results) {
137 | if (err) return callback(err);
138 | for (let file of results) {
139 | s3List[file.Key] = {
140 | hash: file.ETag,
141 | uploaded: false
142 | }
143 | }
144 | callback();
145 | });
146 | };
147 |
148 | var put = function(object, uri, callback) {
149 | if (object instanceof Page) {
150 | return putHtml(object, callback);
151 | }
152 |
153 | var done = function() {
154 | process.nextTick(callback);
155 | };
156 |
157 | hashFile(object, function(err, hash) {
158 | if (err) return callback(err);
159 | // Skip the file if the hashes match (note that Amazon
160 | // puts quotes around their ETags, so we have to do the same)
161 | if (s3List[uri] && s3List[uri].hash == '"' + hash + '"') {
162 | s3List[uri].uploaded = true;
163 | return done();
164 | } else {
165 | client.putFile(object.file, uri, { 'x-amz-acl': 'public-read' }, done);
166 | }
167 | });
168 |
169 | };
170 |
171 | var putHtml = function(page, callback) {
172 | // FIXME: Does this work on Windows?
173 | var uri = path.join(path.relative(rootUrl, page.url), 'index.html');
174 | var html = page.getHtml();
175 |
176 | var s3Options = {
177 | 'x-amz-acl': 'public-read',
178 | 'Content-Type': 'text/html'
179 | };
180 |
181 | let buffer = new Buffer(html);
182 |
183 | if (s3List[uri] && s3List[uri].hash == '"' + hashBuffer(buffer) + '"') {
184 | s3List[uri].uploaded = true;
185 | callback();
186 | } else {
187 | client.putBuffer(buffer, uri, s3Options, callback);
188 | }
189 |
190 | };
191 |
192 | var site = this;
193 | listFiles(function(err) {
194 | if (err) return callback(err);
195 | exportSite(site, put, function(err) {
196 | if (err) return callback(err);
197 | var toDelete = [];
198 | for (let uri in s3List) {
199 | if (!s3List[uri].uploaded) {
200 | toDelete.push(uri);
201 | }
202 | }
203 | async.eachLimit(toDelete, 10, function(file, done) {
204 | client.deleteFile(file, done);
205 | }, callback);
206 | });
207 | });
208 | };
209 |
--------------------------------------------------------------------------------
/lib/Page.js:
--------------------------------------------------------------------------------
1 | var path = require('path'),
2 | url = require('url'),
3 | fs = require('fs'),
4 | express = require('express'),
5 | async = require('async'),
6 | slug = require('slugg'),
7 | sort = require('natural-compare-lite'),
8 | extend = require('util')._extend;
9 |
10 | var woods = require('./woods'),
11 | Site = require('./Site'),
12 | fsUtil = require('./util/fs.js'),
13 | TemplateParam = require('./TemplateParam'),
14 | File = require('./File'),
15 | Collection = require('./Collection');
16 |
17 | function Page(site, parent, directory, index, callback) {
18 | site.pageCount++;
19 | this.site = site;
20 | this.directory = directory;
21 | this.name = parent ? directory.replace(/^\d+[\.-]/g, '') : 'home';
22 | this.slug = parent ? slug(this.name) : '';
23 |
24 | this.files = new Collection();
25 | this.images = new Collection();
26 | this.documents = new Collection();
27 | this.movies = new Collection();
28 | this.sounds = new Collection();
29 | this.parent = parent || null;
30 | this.lang = site.defaultLang;
31 | if (parent) {
32 | parent.children[index] = this;
33 | parent.children[this.slug] = this;
34 | this.index = index;
35 | }
36 |
37 | // Only pages with a number prefixed to their
38 | // directory name are marked as being visible.
39 | this.visible = /^[0-9]/.test(this.directory);
40 | this.url = parent ? url.resolve(parent.url || '', this.slug + '/') : '/';
41 | this.uri = parent ? path.join(parent.uri || '', this.directory) : site.getUri('content');
42 | var page = this;
43 | addFiles(this, function(err) {
44 | if (err)
45 | return callback(err);
46 |
47 | addChildren(page, function(err) {
48 | // If the page has a content file, set its type based on
49 | // the basename of its filename:
50 | if (!page._contentFile || err)
51 | return callback(err);
52 |
53 | var queue = [page];
54 | page.type = path.basename(page._contentFile, site.settings.contentExtension);
55 |
56 | if (page._translatedContentFiles) {
57 | // Create translated copies of the page
58 | var translatedCopies = {};
59 | var translatedContentFiles = page._translatedContentFiles;
60 | delete page._translatedContentFiles;
61 |
62 | for (var lang in translatedContentFiles) {
63 | // Create copy of page
64 | var newPage = Object.create(Page.prototype);
65 | newPage = extend(newPage, page);
66 |
67 | // Create new children object, they can't share it
68 | newPage.children = extend(new Collection(), page.children);
69 |
70 | // Correct some properties on the new page
71 | newPage._contentFile = translatedContentFiles[lang];
72 | newPage.url = '/' + lang + newPage.url;
73 | newPage.lang = lang;
74 | translatedCopies[lang] = newPage;
75 | queue.push(newPage);
76 | }
77 | page.translatedCopies = translatedCopies;
78 | }
79 | // Parse the content file and place values in page:
80 | async.each(queue, function (page, cb) {
81 | site.parsers.parseFile(page.getUri(page._contentFile), page, cb);
82 | }, callback);
83 | });
84 | });
85 | }
86 |
87 | Page.prototype = {
88 | href: function(action) {
89 | return woods.url + url.resolve(this.url, action || '');
90 | },
91 |
92 | template: function(name, param) {
93 | var templateParam = new TemplateParam(this, param);
94 | var foundTemplate = getTemplate(this, name);
95 | return foundTemplate(templateParam);
96 | },
97 |
98 | getUri: function(file) {
99 | return path.join(this.uri, file || '');
100 | },
101 |
102 | getUrl: function(file) {
103 | return url.resolve(this.url, file || '');
104 | },
105 |
106 | /**
107 | * Get a page by a relative url.
108 | *
109 | * @return {Page}
110 | */
111 | get: function(url) {
112 | if (!url || !url.length)
113 | return this;
114 |
115 | var dirs = url.split('/'),
116 | page = url[0] == '/' ? this.site.root : this;
117 | dirs.forEach(function(dir) {
118 | if (!dir.length || !page)
119 | return;
120 | page = dir == '..' ? page && page.parent : page && page.children && page.children[dir];
121 | });
122 | return page;
123 | },
124 |
125 | /**
126 | * Renders out the page and returns the generated HTML
127 | *
128 | * @return {String} {HTML}
129 | */
130 | getHtml: function() {
131 | return this.template('html', {
132 | _activePage: this,
133 | number: null
134 | });
135 | },
136 |
137 | /**
138 | * Checks whether this page has any children.
139 | *
140 | * @return {Boolean} {@true if is has one or more children}
141 | */
142 | hasChildren: function() {
143 | return this.children.length > 0;
144 | },
145 |
146 | /**
147 | * Checks whether this page has a translation in the specified lang.
148 | *
149 | * @return {Boolean} {@true if it has the translation}
150 | */
151 | hasLang: function(lang) {
152 | return this.translatedCopies && this.translatedCopies[lang];
153 | },
154 |
155 | /**
156 | * Returns the translated copy of this page.
157 | * If none is found returns the page itself
158 | * @return {Page}
159 | */
160 | getLang: function(lang) {
161 | return this.hasLang(lang) ? this.translatedCopies[lang] : this;
162 | },
163 |
164 |
165 | /**
166 | * Returns the first child page of this page,
167 | * if any.
168 | *
169 | * @return {Page}
170 | */
171 | getFirstChild: function() {
172 | return this.children.length && this.children[0];
173 | },
174 |
175 | /**
176 | * Returns the last child page of this page.
177 | * if any.
178 | *
179 | * @return {Page}
180 | */
181 | getLastChild: function() {
182 | var length = this.children.length;
183 | return length && this.children[length - 1];
184 | },
185 |
186 | /**
187 | * Get an array of every nth child of this page.
188 | *
189 | * @return {Page[]}
190 | */
191 | nthChildren: function(n, offset) {
192 | var children = [];
193 | for (var i = offset || 0, l = this.children.length; i < l; i += n) {
194 | children.push(this.children[i]);
195 | }
196 | return new Collection(children);
197 | },
198 |
199 | /**
200 | * Get the siblings of this page (includes the page itself).
201 | *
202 | * @return {Page[]}
203 | */
204 | getSiblings: function() {
205 | return this.parent ? this.parent.children : new Collection();
206 | },
207 |
208 | /**
209 | * Checks whether the specified page is the parent of this page.
210 | *
211 | * @param {Item} page The page to check against
212 | * @return {Boolean} {@true if it is the parent of the page}
213 | */
214 | isParent: function(page) {
215 | return this.parent == page;
216 | },
217 |
218 | /**
219 | * Checks whether the specified page is a child of this page.
220 | *
221 | * @param {Item} page The page to check against
222 | * @return {Boolean} {@true it is a child of the page}
223 | */
224 | isChild: function(page) {
225 | return page && page.parent == this;
226 | },
227 |
228 | /**
229 | * Checks if this page is contained within the specified page
230 | * or one of its parents.
231 | *
232 | * @param {Item} page The page to check against
233 | * @return {Boolean} {@true if it is inside the specified page}
234 | */
235 | isDescendant: function(page) {
236 | var parent = this;
237 | while (parent = parent.parent) {
238 | if (parent == page)
239 | return true;
240 | }
241 | return false;
242 | },
243 |
244 | /**
245 | * Checks if the page is an ancestor of the specified page.
246 | *
247 | * @param {Item} page the page to check against
248 | * @return {Boolean} {@true if the page is an ancestor of the specified
249 | * page}
250 | */
251 | isAncestor: function(page) {
252 | return page ? page.isDescendant(this) : false;
253 | },
254 |
255 | /**
256 | * Checks whether there is a previous page on the same level as this one.
257 | *
258 | * @return {Boolean} {@true if the page is an ancestor of the specified
259 | * page}
260 | */
261 | hasPrevious: function() {
262 | return this.visible && this.index > 0;
263 | },
264 |
265 | /**
266 | * Checks whether there is a next page on the same level as this one.
267 | *
268 | * @return {Boolean}
269 | */
270 | hasNext: function() {
271 | return this.visible && this.index + 1 < this.parent.children.length;
272 | },
273 |
274 | /**
275 | * The previous page on the same level as this page.
276 | *
277 | * @return {Page}
278 | */
279 | getPrevious: function() {
280 | return this.hasPrevious() ? this.getSiblings()[this.index - 1] : null;
281 | },
282 |
283 | /**
284 | * The next page on the same level as this page.
285 | *
286 | * @return {Page}
287 | */
288 | getNext: function(page) {
289 | return this.hasNext() ? this.getSiblings()[this.index + 1] : null;
290 | },
291 |
292 | /**
293 | * Check whether this page has images.
294 | *
295 | * @return {Boolean}
296 | */
297 | hasImages: function(page) {
298 | return !!this.images.length;
299 | },
300 |
301 | /**
302 | * Check whether this page has files.
303 | *
304 | * @return {Boolean}
305 | */
306 | hasFiles: function(page) {
307 | return !!this.files.length;
308 | }
309 | };
310 |
311 | function getTemplate(page, name) {
312 | var type = page.type,
313 | types = page.site._types;
314 | if (type && types[type] && types[type][name]) {
315 | return types[type][name];
316 | } else {
317 | return types['default'][name] || function(param) {
318 | return 'Template not found: ' + name;
319 | };
320 | }
321 | }
322 |
323 | function addFiles(page, done) {
324 | var uri = page.getUri(),
325 | images = page.images,
326 | files = page.files,
327 | index = 0,
328 | contentExtension = page.site.settings.contentExtension;
329 |
330 | var addFile = function(filename, callback) {
331 | if (path.extname(filename) == contentExtension) {
332 | var basename = path.basename(filename, contentExtension),
333 | parts = basename.split('.');
334 |
335 |
336 | // Check if this might be a translated content file
337 | if (parts.length > 1) {
338 | var possibleLang = parts[parts.length - 1];
339 | var possibleBasename = parts.slice(0,-1).join('.');
340 |
341 | if (page.site.availableLangs.indexOf(possibleLang) != -1 && page.site._types[possibleBasename]) {
342 | //This is a translated valid page.
343 | if (!page._translatedContentFiles) page._translatedContentFiles = {};
344 | page._translatedContentFiles[possibleLang] = filename;
345 | return callback();
346 | }
347 | }
348 | // Only set _contentFile if it's empty or
349 | // if the current file has a valid template
350 | if (!page._contentFile || page.site._types[basename]) {
351 | page._contentFile = filename;
352 | }
353 | return callback();
354 | } else {
355 | var file = new File(page, index++, filename, function(err) {
356 | if (err)
357 | return callback(err);
358 | page.files.push(file);
359 | page.files[file.filename] = file;
360 | if (file.type != 'other') {
361 | var collection = page[file.type + 's'];
362 | collection.push(file);
363 | collection[filename] = file;
364 | }
365 | return callback();
366 | });
367 | }
368 | };
369 |
370 | fs.exists(uri, function(exists) {
371 | if (!exists) return;
372 | var routeUrl = page.getUrl();
373 | woods.express.use(routeUrl, express.static(uri));
374 | fsUtil.listOnlyVisibleFiles(uri, function(err, files) {
375 | if (err)
376 | return done(err);
377 | async.eachSeries(files, addFile, done);
378 | });
379 | });
380 | }
381 |
382 | function addChildren(page, callback) {
383 | // Read the directory and add child pages (if any):
384 | var index = 0;
385 | var addChild = function(name, callback) {
386 | new Page(page.site, page, name, index++, callback);
387 | };
388 | fsUtil.listDirectories(page.uri, function(err, dirs) {
389 | if (err) {
390 | return callback(err);
391 | }
392 | if (page.site.settings.naturalSort) {
393 | dirs.sort(String.naturalCompare);
394 | }
395 | page.children = new Collection(new Array(dirs.length));
396 | async.each(dirs, addChild, callback);
397 | });
398 | }
399 |
400 | module.exports = Page;
401 |
--------------------------------------------------------------------------------
/example/assets/styles/skeleton.css:
--------------------------------------------------------------------------------
1 | /*
2 | * Skeleton V1.2
3 | * Copyright 2011, Dave Gamache
4 | * www.getskeleton.com
5 | * Free to use under the MIT license.
6 | * http://www.opensource.org/licenses/mit-license.php
7 | * 6/20/2012
8 | */
9 |
10 |
11 | /* Table of Contents
12 | ==================================================
13 | #Base 960 Grid
14 | #Tablet (Portrait)
15 | #Mobile (Portrait)
16 | #Mobile (Landscape)
17 | #Clearing */
18 |
19 |
20 |
21 | /* #Base 960 Grid
22 | ================================================== */
23 |
24 | .container { position: relative; width: 960px; margin: 0 auto; padding: 0; }
25 | .container .column,
26 | .container .columns { float: left; display: inline; margin-left: 10px; margin-right: 10px; }
27 | .row { margin-bottom: 20px; }
28 |
29 | /* Nested Column Classes */
30 | .column.alpha, .columns.alpha { margin-left: 0; }
31 | .column.omega, .columns.omega { margin-right: 0; }
32 |
33 | /* Base Grid */
34 | .container .one.column,
35 | .container .one.columns { width: 40px; }
36 | .container .two.columns { width: 100px; }
37 | .container .three.columns { width: 160px; }
38 | .container .four.columns { width: 220px; }
39 | .container .five.columns { width: 280px; }
40 | .container .six.columns { width: 340px; }
41 | .container .seven.columns { width: 400px; }
42 | .container .eight.columns { width: 460px; }
43 | .container .nine.columns { width: 520px; }
44 | .container .ten.columns { width: 580px; }
45 | .container .eleven.columns { width: 640px; }
46 | .container .twelve.columns { width: 700px; }
47 | .container .thirteen.columns { width: 760px; }
48 | .container .fourteen.columns { width: 820px; }
49 | .container .fifteen.columns { width: 880px; }
50 | .container .sixteen.columns { width: 940px; }
51 |
52 | .container .one-third.column { width: 300px; }
53 | .container .two-thirds.column { width: 620px; }
54 |
55 | /* Offsets */
56 | .container .offset-by-one { padding-left: 60px; }
57 | .container .offset-by-two { padding-left: 120px; }
58 | .container .offset-by-three { padding-left: 180px; }
59 | .container .offset-by-four { padding-left: 240px; }
60 | .container .offset-by-five { padding-left: 300px; }
61 | .container .offset-by-six { padding-left: 360px; }
62 | .container .offset-by-seven { padding-left: 420px; }
63 | .container .offset-by-eight { padding-left: 480px; }
64 | .container .offset-by-nine { padding-left: 540px; }
65 | .container .offset-by-ten { padding-left: 600px; }
66 | .container .offset-by-eleven { padding-left: 660px; }
67 | .container .offset-by-twelve { padding-left: 720px; }
68 | .container .offset-by-thirteen { padding-left: 780px; }
69 | .container .offset-by-fourteen { padding-left: 840px; }
70 | .container .offset-by-fifteen { padding-left: 900px; }
71 |
72 |
73 |
74 | /* #Tablet (Portrait)
75 | ================================================== */
76 |
77 | /* Note: Design for a width of 768px */
78 |
79 | @media only screen and (min-width: 768px) and (max-width: 959px) {
80 | .container { width: 768px; }
81 | .container .column,
82 | .container .columns { margin-left: 10px; margin-right: 10px; }
83 | .column.alpha, .columns.alpha { margin-left: 0; margin-right: 10px; }
84 | .column.omega, .columns.omega { margin-right: 0; margin-left: 10px; }
85 | .alpha.omega { margin-left: 0; margin-right: 0; }
86 |
87 | .container .one.column,
88 | .container .one.columns { width: 28px; }
89 | .container .two.columns { width: 76px; }
90 | .container .three.columns { width: 124px; }
91 | .container .four.columns { width: 172px; }
92 | .container .five.columns { width: 220px; }
93 | .container .six.columns { width: 268px; }
94 | .container .seven.columns { width: 316px; }
95 | .container .eight.columns { width: 364px; }
96 | .container .nine.columns { width: 412px; }
97 | .container .ten.columns { width: 460px; }
98 | .container .eleven.columns { width: 508px; }
99 | .container .twelve.columns { width: 556px; }
100 | .container .thirteen.columns { width: 604px; }
101 | .container .fourteen.columns { width: 652px; }
102 | .container .fifteen.columns { width: 700px; }
103 | .container .sixteen.columns { width: 748px; }
104 |
105 | .container .one-third.column { width: 236px; }
106 | .container .two-thirds.column { width: 492px; }
107 |
108 | /* Offsets */
109 | .container .offset-by-one { padding-left: 48px; }
110 | .container .offset-by-two { padding-left: 96px; }
111 | .container .offset-by-three { padding-left: 144px; }
112 | .container .offset-by-four { padding-left: 192px; }
113 | .container .offset-by-five { padding-left: 240px; }
114 | .container .offset-by-six { padding-left: 288px; }
115 | .container .offset-by-seven { padding-left: 336px; }
116 | .container .offset-by-eight { padding-left: 384px; }
117 | .container .offset-by-nine { padding-left: 432px; }
118 | .container .offset-by-ten { padding-left: 480px; }
119 | .container .offset-by-eleven { padding-left: 528px; }
120 | .container .offset-by-twelve { padding-left: 576px; }
121 | .container .offset-by-thirteen { padding-left: 624px; }
122 | .container .offset-by-fourteen { padding-left: 672px; }
123 | .container .offset-by-fifteen { padding-left: 720px; }
124 | }
125 |
126 |
127 | /* #Mobile (Portrait)
128 | ================================================== */
129 |
130 | /* Note: Design for a width of 320px */
131 |
132 | @media only screen and (max-width: 767px) {
133 | .container { width: 300px; }
134 | .container .columns,
135 | .container .column { margin: 0; }
136 |
137 | .container .one.column,
138 | .container .one.columns,
139 | .container .two.columns,
140 | .container .three.columns,
141 | .container .four.columns,
142 | .container .five.columns,
143 | .container .six.columns,
144 | .container .seven.columns,
145 | .container .eight.columns,
146 | .container .nine.columns,
147 | .container .ten.columns,
148 | .container .eleven.columns,
149 | .container .twelve.columns,
150 | .container .thirteen.columns,
151 | .container .fourteen.columns,
152 | .container .fifteen.columns,
153 | .container .sixteen.columns,
154 | .container .one-third.column,
155 | .container .two-thirds.column { width: 300px; }
156 |
157 | /* Offsets */
158 | .container .offset-by-one,
159 | .container .offset-by-two,
160 | .container .offset-by-three,
161 | .container .offset-by-four,
162 | .container .offset-by-five,
163 | .container .offset-by-six,
164 | .container .offset-by-seven,
165 | .container .offset-by-eight,
166 | .container .offset-by-nine,
167 | .container .offset-by-ten,
168 | .container .offset-by-eleven,
169 | .container .offset-by-twelve,
170 | .container .offset-by-thirteen,
171 | .container .offset-by-fourteen,
172 | .container .offset-by-fifteen { padding-left: 0; }
173 |
174 | }
175 |
176 |
177 | /* #Mobile (Landscape)
178 | ================================================== */
179 |
180 | /* Note: Design for a width of 480px */
181 |
182 | @media only screen and (min-width: 480px) and (max-width: 767px) {
183 | .container { width: 420px; }
184 | .container .columns,
185 | .container .column { margin: 0; }
186 |
187 | .container .one.column,
188 | .container .one.columns,
189 | .container .two.columns,
190 | .container .three.columns,
191 | .container .four.columns,
192 | .container .five.columns,
193 | .container .six.columns,
194 | .container .seven.columns,
195 | .container .eight.columns,
196 | .container .nine.columns,
197 | .container .ten.columns,
198 | .container .eleven.columns,
199 | .container .twelve.columns,
200 | .container .thirteen.columns,
201 | .container .fourteen.columns,
202 | .container .fifteen.columns,
203 | .container .sixteen.columns,
204 | .container .one-third.column,
205 | .container .two-thirds.column { width: 420px; }
206 | }
207 |
208 |
209 | /* #Clearing
210 | ================================================== */
211 |
212 | /* Self Clearing Goodness */
213 | .container:after { content: "\0020"; display: block; height: 0; clear: both; visibility: hidden; }
214 |
215 | /* Use clearfix class on parent to clear nested columns,
216 | or wrap each row of columns in a */
217 | .clearfix:before,
218 | .clearfix:after,
219 | .row:before,
220 | .row:after {
221 | content: '\0020';
222 | display: block;
223 | overflow: hidden;
224 | visibility: hidden;
225 | width: 0;
226 | height: 0; }
227 | .row:after,
228 | .clearfix:after {
229 | clear: both; }
230 | .row,
231 | .clearfix {
232 | zoom: 1; }
233 |
234 | /* You can also use a
to clear columns */
235 | .clear {
236 | clear: both;
237 | display: block;
238 | overflow: hidden;
239 | visibility: hidden;
240 | width: 0;
241 | height: 0;
242 | }
243 |
244 | /*
245 | * Skeleton V1.2
246 | * Copyright 2011, Dave Gamache
247 | * www.getskeleton.com
248 | * Free to use under the MIT license.
249 | * http://www.opensource.org/licenses/mit-license.php
250 | * 6/20/2012
251 | */
252 |
253 | /* Table of Content
254 | ==================================================
255 | #Site Styles
256 | #Page Styles
257 | #Media Queries
258 | #Font-Face */
259 |
260 | /* #Site Styles
261 | ================================================== */
262 |
263 | /* #Page Styles
264 | ================================================== */
265 |
266 | /* #Media Queries
267 | ================================================== */
268 |
269 | /* Smaller than standard 960 (devices and browsers) */
270 | @media only screen and (max-width: 959px) {}
271 |
272 | /* Tablet Portrait size to standard 960 (devices and browsers) */
273 | @media only screen and (min-width: 768px) and (max-width: 959px) {}
274 |
275 | /* All Mobile Sizes (devices and browser) */
276 | @media only screen and (max-width: 767px) {}
277 |
278 | /* Mobile Landscape Size to Tablet Portrait (devices and browsers) */
279 | @media only screen and (min-width: 480px) and (max-width: 767px) {}
280 |
281 | /* Mobile Portrait Size to Mobile Landscape Size (devices and browsers) */
282 | @media only screen and (max-width: 479px) {}
283 |
284 |
285 | /* #Font-Face
286 | ================================================== */
287 | /* This is the proper syntax for an @font-face file
288 | Just create a "fonts" folder at the root,
289 | copy your FontName into code below and remove
290 | comment brackets */
291 |
292 | /* @font-face {
293 | font-family: 'FontName';
294 | src: url("/fonts/FontName.eot");
295 | src: url("/fonts/FontName.eot?iefix") format('eot'),
296 | url("/fonts/FontName.woff") format('woff'),
297 | url("/fonts/FontName.ttf") format('truetype'),
298 | url("/fonts/FontName.svg#webfontZam02nTh") format('svg');
299 | font-weight: normal;
300 | font-style: normal; }
301 | */
302 |
303 | /*
304 | * Skeleton V1.2
305 | * Copyright 2011, Dave Gamache
306 | * www.getskeleton.com
307 | * Free to use under the MIT license.
308 | * http://www.opensource.org/licenses/mit-license.php
309 | * 6/20/2012
310 | */
311 |
312 |
313 | /* Table of Content
314 | ==================================================
315 | #Reset & Basics
316 | #Basic Styles
317 | #Site Styles
318 | #Typography
319 | #Links
320 | #Lists
321 | #Images
322 | #Buttons
323 | #Forms
324 | #Misc */
325 |
326 |
327 | /* #Reset & Basics (Inspired by E. Meyers)
328 | ================================================== */
329 | html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article, aside, canvas, details, embed, figure, figcaption, footer, header, hgroup, menu, nav, output, ruby, section, summary, time, mark, audio, video {
330 | margin: 0;
331 | padding: 0;
332 | border: 0;
333 | font-size: 100%;
334 | font: inherit;
335 | vertical-align: baseline; }
336 | article, aside, details, figcaption, figure, footer, header, hgroup, menu, nav, section {
337 | display: block; }
338 | body {
339 | line-height: 1; }
340 | ol, ul {
341 | list-style: none; }
342 | blockquote, q {
343 | quotes: none; }
344 | blockquote:before, blockquote:after,
345 | q:before, q:after {
346 | content: '';
347 | content: none; }
348 | table {
349 | border-collapse: collapse;
350 | border-spacing: 0; }
351 |
352 |
353 | /* #Basic Styles
354 | ================================================== */
355 | body {
356 | background: #fff;
357 | font: 14px/21px "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif;
358 | color: #444;
359 | -webkit-font-smoothing: antialiased; /* Fix for webkit rendering */
360 | -webkit-text-size-adjust: 100%;
361 | }
362 |
363 |
364 | /* #Typography
365 | ================================================== */
366 | h1, h2, h3, h4, h5, h6 {
367 | color: #181818;
368 | font-family: "Georgia", "Times New Roman", serif;
369 | font-weight: normal; }
370 | h1 a, h2 a, h3 a, h4 a, h5 a, h6 a { font-weight: inherit; }
371 | h1 { font-size: 46px; line-height: 50px; margin-bottom: 14px;}
372 | h2 { font-size: 35px; line-height: 40px; margin-bottom: 10px; }
373 | h3 { font-size: 28px; line-height: 34px; margin-bottom: 8px; }
374 | h4 { font-size: 21px; line-height: 30px; margin-bottom: 4px; }
375 | h5 { font-size: 17px; line-height: 24px; }
376 | h6 { font-size: 14px; line-height: 21px; }
377 | .subheader { color: #777; }
378 |
379 | p { margin: 0 0 20px 0; }
380 | p img { margin: 0; }
381 | p.lead { font-size: 21px; line-height: 27px; color: #777; }
382 |
383 | em { font-style: italic; }
384 | strong { font-weight: bold; color: #333; }
385 | small { font-size: 80%; }
386 |
387 | /* Blockquotes */
388 | blockquote, blockquote p { font-size: 17px; line-height: 24px; color: #777; font-style: italic; }
389 | blockquote { margin: 0 0 20px; padding: 9px 20px 0 19px; border-left: 1px solid #ddd; }
390 | blockquote cite { display: block; font-size: 12px; color: #555; }
391 | blockquote cite:before { content: "\2014 \0020"; }
392 | blockquote cite a, blockquote cite a:visited, blockquote cite a:visited { color: #555; }
393 |
394 | hr { border: solid #ddd; border-width: 1px 0 0; clear: both; margin: 10px 0 30px; height: 0; }
395 |
396 |
397 | /* #Links
398 | ================================================== */
399 | a, a:visited { color: #333; text-decoration: underline; outline: 0; }
400 | a:hover, a:focus { color: #000; }
401 | p a, p a:visited { line-height: inherit; }
402 |
403 |
404 | /* #Lists
405 | ================================================== */
406 | ul, ol { margin-bottom: 20px; }
407 | ul { list-style: none outside; }
408 | ol { list-style: decimal; }
409 | ol, ul.square, ul.circle, ul.disc { margin-left: 30px; }
410 | ul.square { list-style: square outside; }
411 | ul.circle { list-style: circle outside; }
412 | ul.disc { list-style: disc outside; }
413 | ul ul, ul ol,
414 | ol ol, ol ul { margin: 4px 0 5px 30px; font-size: 90%; }
415 | ul ul li, ul ol li,
416 | ol ol li, ol ul li { margin-bottom: 6px; }
417 | li { line-height: 18px; margin-bottom: 12px; }
418 | ul.large li { line-height: 21px; }
419 | li p { line-height: 21px; }
420 |
421 | /* #Images
422 | ================================================== */
423 |
424 | img.scale-with-grid {
425 | max-width: 100%;
426 | height: auto; }
427 |
428 |
429 | /* #Buttons
430 | ================================================== */
431 |
432 | .button,
433 | button,
434 | input[type="submit"],
435 | input[type="reset"],
436 | input[type="button"] {
437 | background: #eee; /* Old browsers */
438 | background: #eee -moz-linear-gradient(top, rgba(255,255,255,.2) 0%, rgba(0,0,0,.2) 100%); /* FF3.6+ */
439 | background: #eee -webkit-gradient(linear, left top, left bottom, color-stop(0%,rgba(255,255,255,.2)), color-stop(100%,rgba(0,0,0,.2))); /* Chrome,Safari4+ */
440 | background: #eee -webkit-linear-gradient(top, rgba(255,255,255,.2) 0%,rgba(0,0,0,.2) 100%); /* Chrome10+,Safari5.1+ */
441 | background: #eee -o-linear-gradient(top, rgba(255,255,255,.2) 0%,rgba(0,0,0,.2) 100%); /* Opera11.10+ */
442 | background: #eee -ms-linear-gradient(top, rgba(255,255,255,.2) 0%,rgba(0,0,0,.2) 100%); /* IE10+ */
443 | background: #eee linear-gradient(top, rgba(255,255,255,.2) 0%,rgba(0,0,0,.2) 100%); /* W3C */
444 | border: 1px solid #aaa;
445 | border-top: 1px solid #ccc;
446 | border-left: 1px solid #ccc;
447 | -moz-border-radius: 3px;
448 | -webkit-border-radius: 3px;
449 | border-radius: 3px;
450 | color: #444;
451 | display: inline-block;
452 | font-size: 11px;
453 | font-weight: bold;
454 | text-decoration: none;
455 | text-shadow: 0 1px rgba(255, 255, 255, .75);
456 | cursor: pointer;
457 | margin-bottom: 20px;
458 | line-height: normal;
459 | padding: 8px 10px;
460 | font-family: "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif; }
461 |
462 | .button:hover,
463 | button:hover,
464 | input[type="submit"]:hover,
465 | input[type="reset"]:hover,
466 | input[type="button"]:hover {
467 | color: #222;
468 | background: #ddd; /* Old browsers */
469 | background: #ddd -moz-linear-gradient(top, rgba(255,255,255,.3) 0%, rgba(0,0,0,.3) 100%); /* FF3.6+ */
470 | background: #ddd -webkit-gradient(linear, left top, left bottom, color-stop(0%,rgba(255,255,255,.3)), color-stop(100%,rgba(0,0,0,.3))); /* Chrome,Safari4+ */
471 | background: #ddd -webkit-linear-gradient(top, rgba(255,255,255,.3) 0%,rgba(0,0,0,.3) 100%); /* Chrome10+,Safari5.1+ */
472 | background: #ddd -o-linear-gradient(top, rgba(255,255,255,.3) 0%,rgba(0,0,0,.3) 100%); /* Opera11.10+ */
473 | background: #ddd -ms-linear-gradient(top, rgba(255,255,255,.3) 0%,rgba(0,0,0,.3) 100%); /* IE10+ */
474 | background: #ddd linear-gradient(top, rgba(255,255,255,.3) 0%,rgba(0,0,0,.3) 100%); /* W3C */
475 | border: 1px solid #888;
476 | border-top: 1px solid #aaa;
477 | border-left: 1px solid #aaa; }
478 |
479 | .button:active,
480 | button:active,
481 | input[type="submit"]:active,
482 | input[type="reset"]:active,
483 | input[type="button"]:active {
484 | border: 1px solid #666;
485 | background: #ccc; /* Old browsers */
486 | background: #ccc -moz-linear-gradient(top, rgba(255,255,255,.35) 0%, rgba(10,10,10,.4) 100%); /* FF3.6+ */
487 | background: #ccc -webkit-gradient(linear, left top, left bottom, color-stop(0%,rgba(255,255,255,.35)), color-stop(100%,rgba(10,10,10,.4))); /* Chrome,Safari4+ */
488 | background: #ccc -webkit-linear-gradient(top, rgba(255,255,255,.35) 0%,rgba(10,10,10,.4) 100%); /* Chrome10+,Safari5.1+ */
489 | background: #ccc -o-linear-gradient(top, rgba(255,255,255,.35) 0%,rgba(10,10,10,.4) 100%); /* Opera11.10+ */
490 | background: #ccc -ms-linear-gradient(top, rgba(255,255,255,.35) 0%,rgba(10,10,10,.4) 100%); /* IE10+ */
491 | background: #ccc linear-gradient(top, rgba(255,255,255,.35) 0%,rgba(10,10,10,.4) 100%); /* W3C */ }
492 |
493 | .button.full-width,
494 | button.full-width,
495 | input[type="submit"].full-width,
496 | input[type="reset"].full-width,
497 | input[type="button"].full-width {
498 | width: 100%;
499 | padding-left: 0 !important;
500 | padding-right: 0 !important;
501 | text-align: center; }
502 |
503 | /* Fix for odd Mozilla border & padding issues */
504 | button::-moz-focus-inner,
505 | input::-moz-focus-inner {
506 | border: 0;
507 | padding: 0;
508 | }
509 |
510 |
511 | /* #Forms
512 | ================================================== */
513 |
514 | form {
515 | margin-bottom: 20px; }
516 | fieldset {
517 | margin-bottom: 20px; }
518 | input[type="text"],
519 | input[type="password"],
520 | input[type="email"],
521 | textarea,
522 | select {
523 | border: 1px solid #ccc;
524 | padding: 6px 4px;
525 | outline: none;
526 | -moz-border-radius: 2px;
527 | -webkit-border-radius: 2px;
528 | border-radius: 2px;
529 | font: 13px "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif;
530 | color: #777;
531 | margin: 0;
532 | width: 210px;
533 | max-width: 100%;
534 | display: block;
535 | margin-bottom: 20px;
536 | background: #fff; }
537 | select {
538 | padding: 0; }
539 | input[type="text"]:focus,
540 | input[type="password"]:focus,
541 | input[type="email"]:focus,
542 | textarea:focus {
543 | border: 1px solid #aaa;
544 | color: #444;
545 | -moz-box-shadow: 0 0 3px rgba(0,0,0,.2);
546 | -webkit-box-shadow: 0 0 3px rgba(0,0,0,.2);
547 | box-shadow: 0 0 3px rgba(0,0,0,.2); }
548 | textarea {
549 | min-height: 60px; }
550 | label,
551 | legend {
552 | display: block;
553 | font-weight: bold;
554 | font-size: 13px; }
555 | select {
556 | width: 220px; }
557 | input[type="checkbox"] {
558 | display: inline; }
559 | label span,
560 | legend span {
561 | font-weight: normal;
562 | font-size: 13px;
563 | color: #444; }
564 |
565 | /* #Misc
566 | ================================================== */
567 | .remove-bottom { margin-bottom: 0 !important; }
568 | .half-bottom { margin-bottom: 10px !important; }
569 | .add-bottom { margin-bottom: 20px !important; }
--------------------------------------------------------------------------------