├── .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; } --------------------------------------------------------------------------------