diff --git a/.gitignore b/.gitignore
index 6f9fe6b..44aff8c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
node_modules
.DS_Store
.nyc_output
+.vscode
\ No newline at end of file
diff --git a/README.md b/README.md
index b1e1c94..f5007ee 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,9 @@
> a JavaScript static site generator with support for WordPress
## Usage
+
### CLI
+
```shell
$ tobiko
$ tobiko -h
@@ -20,6 +22,7 @@ Options:
```
### API
+
```js
const tobiko = require('tobiko');
const conf = require('./tobiko.js');
@@ -27,39 +30,44 @@ tobiko(conf);
```
### Config file
+
By default, the CLI `tobiko` will look for `tobiko.json`. If dynamic options are needed, they can be declared in a JavaScript file:
```js
// tobiko.js
module.exports = {
- contentsDir: 'contents',
- outDir: 'dist',
- handlebars: {
- templatesDir: 'templates',
- partialsDir: 'templates/partials',
- helpersDir: 'templates/helpers'
- },
- plugins: {
- wordpress: {
- apiRoot: 'https://mywordpress.com/wp-json/wp/v2',
- contents: [{
- postType: 'posts',
- folder: 'articles',
- template: 'article.hbs'
- }]
- },
- archive: {
- articles: {
- postsPerPage: 4,
- title: 'Articles',
- template: 'articles.hbs'
- }
- },
- transform: function (contentTree) {
- // do something with the content object
- return Promise.resolve(contentTree)
- }
- }
+ outDir: 'dist',
+ handlebars: {
+ templatesDir: 'templates',
+ partialsDir: 'templates/partials',
+ helpersDir: 'templates/helpers'
+ },
+ plugins: {
+ files: {
+ contentsDir: 'contents'
+ },
+ wordpress: {
+ apiRoot: 'https://mywordpress.com/wp-json/wp/v2',
+ contents: [
+ {
+ postType: 'posts',
+ folder: 'articles',
+ template: 'article.hbs'
+ }
+ ]
+ },
+ archive: {
+ articles: {
+ postsPerPage: 4,
+ title: 'Articles',
+ template: 'articles.hbs'
+ }
+ },
+ transform: function(contentTree) {
+ // do something with the content object
+ return Promise.resolve(contentTree);
+ }
+ }
};
```
@@ -68,24 +76,24 @@ $ tobiko -f tobiko.js
```
## What's supported
-- Content: JSON / Markdown (optionally with YAML frontmatter) / WordPress (through WP REST API)
-- Template: Handlebars
-- Styles: SCSS
-- JavaScript: browserify (but it can really be anything)
+
+* Content: JSON / Markdown (optionally with YAML frontmatter) / WordPress (through WP REST API)
+* Template: Handlebars
+* Styles: SCSS
+* JavaScript: browserify (but it can really be anything)
## Documentation
### Options
-- `contentsDir`: where the site's contents are located. Defaults to `contents`.
-- `outDir`: where to generate the static files to. Defaults to `dist`.
-- `handlebars`: Handlebars configuration options.
- - `templatesDir`: location of the templates directory. Defaults to `templates`.
- - `partialsDir`: location of template partials. Defaults to `templates/partials`.
- - `helpersDir`: location of template helpers. Defaults to `templates/helpers`.
-- `plugins`: configuration for plugins. See [plugins](#plugins) for more info.
+* `contentsDir`: where the site's contents are located. Defaults to `contents`.
+* `outDir`: where to generate the static files to. Defaults to `dist`.
+* `handlebars`: Handlebars configuration options.
+ * `templatesDir`: location of the templates directory. Defaults to `templates`. - `partialsDir`: location of template partials. Defaults to `templates/partials`. - `helpersDir`: location of template helpers. Defaults to `templates/helpers`.
+* `plugins`: configuration for plugins. See [plugins](#plugins) for more info.
### Contents
+
By default, the site content will be in the `contents` folder. This option could be changed in `tobiko.json`, under `contentDir` property.
Content can be written in `json` and `markdown` with `yaml` [frontmatter](https://github.com/mojombo/jekyll/wiki/YAML-Front-Matter).
@@ -93,32 +101,37 @@ Content can be written in `json` and `markdown` with `yaml` [frontmatter](https:
The structure of the `contents` directory will be reflected in the final static HTML output.
#### config.json
+
High level, site-wide configurations can be specified in `config.json` in the root folder. Environment-specific configurations are also supported.
For example:
`config.json`
+
```json
{
- "site-name": "Tobiko Example",
- "site-url": "http://tobiko.io",
- "author": "Sushi Connoisseur"
+ "site-name": "Tobiko Example",
+ "site-url": "http://tobiko.io",
+ "author": "Sushi Connoisseur"
}
```
`config.dev.json`
+
```json
{
- "site-url": "http://localhost:4000",
+ "site-url": "http://localhost:4000"
}
```
Environment-specific settings cascade over the original config. This allows you to declare only the different parameters.
#### Nesting
+
In any directory, a file's sibling files and directories are available for the template to access. This is a convenient and structural way to store and organize data, instead of dumping everything into a single JSON file.
For example, for this file structure
+
```
contents
├── index.json
@@ -133,10 +146,13 @@ contents
```
If you're writing the template for `index.json`, its own content is available through the `content` variable.
+
```html
{{content.title}}
```
+
And `cars` are also available as
+
```html
{{#each cars}}
@@ -152,11 +168,13 @@ And `cars` are also available as
The numbered files are used to organize the order of the children.
#### template property
+
Each page specifies a template that it uses, either as a JSON property or YAML frontmmatter. If a file doesn't specify a template, its data is available to be used in the ContentTree but will not be rendered.
Example:
`index.json`
+
```js
{
template: "index.hbs",
@@ -165,28 +183,34 @@ Example:
```
`index.md`
+
```md
---
template: index.hbs
---
+
Hello World
```
#### filepath
+
By default, the path of the page is its directory structure.
For example, the page `contents/articles/06/a-new-day.json` will have the URL `http://your-website.com/articles/06/a-new-day.html`.
However, each page's path can be overwritten by a `filepath` property.
Example, the file above can have the following property,
+
```js
{
- filepath: "articles/a-new-day.json"
+ filepath: 'articles/a-new-day.json';
}
```
+
which will give it a URL `http://your-website.com/articles/a-new-day.html`.
This could be useful as a way to order files in a directory structure.
In the cars example above:
+
```
contents
├── index.json
@@ -203,6 +227,7 @@ contents
In order to avoid the number 1, 2, 3 etc. appear in these cars' URLs, they could have a custom `filepath` property, such as `cars/tesla.json`.
#### date
+
Post or page date is supported by declaring property `date` in JSON or YAML. Any [ISO-8601 string formats](http://momentjs.com/docs/#/parsing/string/) for date is supported.
By default, a file without a `date` specified will have the `date` value of when the file was created. (To be more exact, it will have the [`ctime`][1] value when `grunt` is first run).
@@ -212,33 +237,40 @@ By default, a file without a `date` specified will have the `date` value of when
See [momentjs](http://momentjs.com) for more information about the date format.
### Templates
+
By default tobiko uses [Handlebars](http://handlebarsjs.com) as its templating engine.
Helpers and Partials are supported. They can be stored under `helpers` and `partials` directories under `templates`. These directory names of course can be changed in [`options`](#options) object.
Each page needs to specify its own template. This can be done with a JSON property
+
```js
{
- template: index.hbs
+ template: index.hbs;
}
```
+
or in the YAML frontmatter. A file with no `template` property will **not** be rendered.
#### Context
+
Each template will be passed in a context object generated from the content file with the following properties:
-- `content`: the content file
-- `content.main`: the parsed HTML if the content file is a markdown file
-- `content.filename`: name of the content file
-- `content.fileext`: extension type of the content file
-- `content.url`: url of the page
-- `config`: see [config](#config.json)
-- `global`: all data in the `contents` directory
-- Other sub-directories included in the same directory is accessible in the template with [nesting](#nesting).
+
+* `content`: the content file
+* `content.main`: the parsed HTML if the content file is a markdown file
+* `content.filename`: name of the content file
+* `content.fileext`: extension type of the content file
+* `content.url`: url of the page
+* `config`: see [config](#config.json)
+* `global`: all data in the `contents` directory
+* Other sub-directories included in the same directory is accessible in the template with [nesting](#nesting).
### Plugins
+
Tobiko can be extended with plugins. By default, it comes with 3 plugins:
#### WordPress
+
While static site can be a great way to publish content, managing them using the file system can feel clunky at times. It is not too friendly for non-developers. As such, tobiko allows you to pull in content from WordPress, one of the most popular content management systems. With [WP REST API](http://v2.wp-api.org/), content from WordPress can be exported to a system like tobiko.
After installing the WP API plugin, you can start using it in tobiko by configuring it in [`options`](#options). For example:
@@ -257,6 +289,7 @@ After installing the WP API plugin, you can start using it in tobiko by configur
The `folder` key defines where the WordPress content is put on the content tree.
#### Archives and Pagination
+
A directory with a big number of posts could be configured to paginate. The paginated pages are called archives.
The option for enabling archives can be added to `options`. For example:
@@ -272,6 +305,7 @@ The option for enabling archives can be added to `options`. For example:
Each key in the `archives` object represents the name of the directory to be paginated.
Each value can have the following options:
+
* `orderby`: (string) how to order the posts in the archives. Default to ['date'](#date)
* `postsPerPage`: (number) number of posts to be displayed per archive page
* `template`: (string) the template used to display these archive pages
@@ -279,22 +313,26 @@ Each value can have the following options:
The paginated content in each archive page is accessible in the template file under `content.posts`.
-*The `archives` plugin can be used in combination with the `wordpress` plugin to paginate WordPress content.*
+_The `archives` plugin can be used in combination with the `wordpress` plugin to paginate WordPress content._
#### Transform
+
The `transform` plugin allows you to perform any type of modification/ transformation of the content tree.
In order to do so, instead of passing in a JS object like the other plugins for options, `transform` takes a function that accepts the `contentTree` object as an argument, and returns a promise that will resolve with a value that is the new `contentTree`.
### Deployment
+
The site can be deployed to [Github Pages](http://pages.github.com) or any static site hosting solutions.
In order to deploy to Github Pages, you can use [`gh-pages`](https://www.npmjs.com/package/gh-pages).
## Examples
+
Some examples of how tobiko is used
-- [tnguyen14/tridnguyen.com](https://github.com/tnguyen14/tridnguyen.com)
+* [tnguyen14/tridnguyen.com](https://github.com/tnguyen14/tridnguyen.com)
## Issues/ Requests
+
Any issues, questions or feature requests could be created under [Github Issues](https://github.com/tnguyen14/tobiko/issues).
diff --git a/index.js b/index.js
index f1b8348..2204eb3 100644
--- a/index.js
+++ b/index.js
@@ -2,22 +2,29 @@ const generateHtml = require('./lib/generateHtml');
const importContents = require('./lib/importContents');
const copyImages = require('./lib/copyImages');
-module.exports = function (options) {
- let opts = Object.assign({}, {
- contentsDir: 'contents',
- outDir: 'dist',
- markdown: {
- breaks: true,
- smartLists: true,
- smartypants: true
- }
- }, options);
+module.exports = function(options) {
+ let opts = Object.assign(
+ {},
+ {
+ outDir: 'dist',
+ markdown: {
+ breaks: true,
+ smartLists: true,
+ smartypants: true
+ },
+ plugins: {
+ files: {
+ contentsDir: 'contents'
+ }
+ }
+ },
+ options
+ );
- return Promise.all([
- importContents(opts)
- .then((contentTree) => {
- return generateHtml(opts, contentTree);
- }),
- copyImages(opts)
- ]);
+ return Promise.all([
+ importContents(opts).then(contentTree => {
+ return generateHtml(opts, contentTree);
+ }),
+ copyImages(opts)
+ ]);
};
diff --git a/lib/content/plugins.js b/lib/content/plugins.js
index 90ef03b..dc2e8e1 100644
--- a/lib/content/plugins.js
+++ b/lib/content/plugins.js
@@ -2,6 +2,7 @@
const plugins = {
wordpress: require('../../plugins/wordpress'),
archive: require('../../plugins/archive'),
+ files: require('../../plugins/files'),
transform: function (contentTree, fn) {
if (typeof fn === 'function') {
return fn(contentTree);
@@ -12,13 +13,18 @@ const plugins = {
};
function processPlugins (options, contentTree) {
- let opts = Object.assign({}, {
- archive: {},
- wordpress: {}
- }, options);
+ let opts = Object.assign(
+ {},
+ {
+ files: {},
+ archive: {},
+ wordpress: {}
+ },
+ options
+ );
// pass through plugins
return Object.keys(plugins).reduce((prevPlugin, pluginName) => {
- return prevPlugin.then((contents) => {
+ return prevPlugin.then(contents => {
// NOTE: should plugin be skipped if no option is provided for plugin?
return plugins[pluginName](contents, opts[pluginName]);
});
diff --git a/lib/copyImages.js b/lib/copyImages.js
index 64d65b6..d2bbd14 100644
--- a/lib/copyImages.js
+++ b/lib/copyImages.js
@@ -5,14 +5,22 @@ const cpFile = require('cp-file');
function copyImages (opts) {
return new Promise((resolve, reject) => {
// copy images
- glob(opts.contentsDir + '/**/*.{jpg,png,svg}', (err, files) => {
+ glob(opts.files.contentsDir + '/**/*.{jpg,png,svg}', (err, files) => {
if (err) {
return reject(err);
}
- return Promise.all(files.map((filepath) => {
- let pathWithoutContentsDir = path.relative(opts.contentsDir, filepath);
- return cpFile(filepath, path.resolve(opts.outDir, pathWithoutContentsDir));
- })).then(() => {
+ return Promise.all(
+ files.map(filepath => {
+ let pathWithoutContentsDir = path.relative(
+ opts.files.contentsDir,
+ filepath
+ );
+ return cpFile(
+ filepath,
+ path.resolve(opts.outDir, pathWithoutContentsDir)
+ );
+ })
+ ).then(() => {
resolve();
});
});
diff --git a/lib/importContents.js b/lib/importContents.js
index e667dc1..42f84a6 100644
--- a/lib/importContents.js
+++ b/lib/importContents.js
@@ -1,59 +1,7 @@
-const fs = require('fs');
-const path = require('path');
-const glob = require('glob');
-const decorate = require('./content/decorate');
-const parse = require('./content/parse');
const plugins = require('./content/plugins');
function importContents (opts) {
- var contentTree = {};
- return new Promise((resolve, reject) => {
- glob(opts.contentsDir + '/**/*.{md,json}', (err, files) => {
- if (err) {
- return reject(err);
- }
- files.forEach((filepath) => {
- processFile(filepath, opts, contentTree);
- });
-
- resolve(contentTree);
- });
- }).then((contentTree) => {
- return plugins(opts.plugins, contentTree);
- });
-}
-
-function processFile (filepath, opts, contentTree) {
- let pathWithoutContentsDir = path.relative(opts.contentsDir, filepath);
- let directories = path.dirname(pathWithoutContentsDir).split(path.sep);
- let file = parse(filepath, opts.markdown);
- if (!file) {
- return;
- }
-
- if (!file.date) {
- file.date = fs.statSync(filepath).ctime;
- }
- file = decorate(file, pathWithoutContentsDir);
-
- // Put the file content on the content tree
-
- // Start at the top of the content tree, traverse the directory path
- let currentDir = contentTree;
- directories.forEach((d) => {
- // skip the first level
- if (d === '.') {
- return;
- }
- // if the dir doesn't exist yet, create an empty object on the content tree
- if (!currentDir[d]) {
- currentDir = currentDir[d] = {};
- // if the directory already there, go into the next dir level
- } else {
- currentDir = currentDir[d];
- }
- });
- currentDir[file.filename] = file;
+ return Promise.all(plugins(opts.plugins, {}));
}
module.exports = importContents;
diff --git a/package-lock.json b/package-lock.json
index c02405b..771d2fc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4229,14 +4229,6 @@
"integrity": "sha1-1PM6tU6OOHeLDKXP07OvsS22hiA=",
"dev": true
},
- "string_decoder": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz",
- "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==",
- "requires": {
- "safe-buffer": "5.1.1"
- }
- },
"string-width": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz",
@@ -4261,6 +4253,14 @@
}
}
},
+ "string_decoder": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz",
+ "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==",
+ "requires": {
+ "safe-buffer": "5.1.1"
+ }
+ },
"stringstream": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz",
diff --git a/plugins/archive.js b/plugins/archive.js
index ed70aff..5ac562f 100644
--- a/plugins/archive.js
+++ b/plugins/archive.js
@@ -72,7 +72,7 @@ function paginate (dir, dirName, options) {
// set up each archive page
for (var pageNum = 1; pageNum <= numPages; pageNum++) {
archive[pageNum] = {};
- var archivePage = archive[pageNum].index = {};
+ var archivePage = (archive[pageNum].index = {});
// add template so it gets rendered
archivePage.template = options.template;
// a title as well
diff --git a/plugins/files.js b/plugins/files.js
new file mode 100644
index 0000000..c55b4ac
--- /dev/null
+++ b/plugins/files.js
@@ -0,0 +1,55 @@
+const fs = require('fs');
+const path = require('path');
+const glob = require('glob');
+const decorate = require('../lib/content/decorate');
+const parse = require('../lib/content/parse');
+
+function importFiles (contentTree, opts) {
+ return new Promise((resolve, reject) => {
+ glob(opts.files.contentsDir + '/**/*.{md,json}', (err, files) => {
+ if (err) {
+ return reject(err);
+ }
+ files.forEach(filepath => {
+ processFile(filepath, opts, contentTree);
+ });
+
+ resolve(contentTree);
+ });
+ });
+}
+
+function processFile (filepath, opts, contentTree) {
+ let pathWithoutContentsDir = path.relative(opts.files.contentsDir, filepath);
+ let directories = path.dirname(pathWithoutContentsDir).split(path.sep);
+ let file = parse(filepath, opts.markdown);
+ if (!file) {
+ return;
+ }
+
+ if (!file.date) {
+ file.date = fs.statSync(filepath).ctime;
+ }
+ file = decorate(file, pathWithoutContentsDir);
+
+ // Put the file content on the content tree
+
+ // Start at the top of the content tree, traverse the directory path
+ let currentDir = contentTree;
+ directories.forEach(d => {
+ // skip the first level
+ if (d === '.') {
+ return;
+ }
+ // if the dir doesn't exist yet, create an empty object on the content tree
+ if (!currentDir[d]) {
+ currentDir = currentDir[d] = {};
+ // if the directory already there, go into the next dir level
+ } else {
+ currentDir = currentDir[d];
+ }
+ });
+ currentDir[file.filename] = file;
+}
+
+module.exports = importFiles;
diff --git a/test/lib.js b/test/lib.js
index 3cbe9ae..e393081 100644
--- a/test/lib.js
+++ b/test/lib.js
@@ -21,7 +21,11 @@ tap.test('should parse JSON file', function (t) {
tap.test('should parse markdown file', function (t) {
var baz = parse(fixtures.md);
t.equal(baz.title, 'Baz', 'File content');
- t.equal(baz.main, 'This is an example paragraph.
\n', 'File content markdown');
+ t.equal(
+ baz.main,
+ 'This is an example paragraph.
\n',
+ 'File content markdown'
+ );
t.end();
});
@@ -45,39 +49,57 @@ tap.test('should decorate file with properties', function (t) {
tap.test('should import contents for test fixtures', function (t) {
importContents({
- contentsDir: fixtures._
- }).then(function (contentTree) {
- t.deepEqual(Object.keys(contentTree.nested.ordered),
- ['1.chikorita', '2.totodile', '3.cyndaquil']);
- t.deepEqual(Object.keys(contentTree.nested.unordered),
- ['chikorita', 'cyndaquil', 'totodile']
- );
- t.end();
- }, function (err) {
- console.error(err);
- t.notOk(err, 'no error when importing contents');
- t.end();
- });
+ plugins: {
+ files: {
+ contentsDir: fixtures._
+ }
+ }
+ }).then(
+ function (contentTree) {
+ t.deepEqual(Object.keys(contentTree.nested.ordered), [
+ '1.chikorita',
+ '2.totodile',
+ '3.cyndaquil'
+ ]);
+ t.deepEqual(Object.keys(contentTree.nested.unordered), [
+ 'chikorita',
+ 'cyndaquil',
+ 'totodile'
+ ]);
+ t.end();
+ },
+ function (err) {
+ console.error(err);
+ t.notOk(err, 'no error when importing contents');
+ t.end();
+ }
+ );
});
tap.test('should import contents and reverse order', function (t) {
importContents({
- contentsDir: fixtures._,
plugins: {
+ files: {
+ contentsDir: fixtures._
+ },
transform: function (contentTree) {
// reverse the order of the contents of a folder
let reversedOrdered = {};
- Object.keys(contentTree.nested.ordered).reverse().forEach(function (key) {
- reversedOrdered[key] = contentTree.nested.ordered[key];
- });
+ Object.keys(contentTree.nested.ordered)
+ .reverse()
+ .forEach(function (key) {
+ reversedOrdered[key] = contentTree.nested.ordered[key];
+ });
contentTree.nested.ordered = reversedOrdered;
return Promise.resolve(contentTree);
}
}
}).then(function (contentTree) {
- t.deepEqual(Object.keys(contentTree.nested.ordered),
- ['3.cyndaquil', '2.totodile', '1.chikorita']
- );
+ t.deepEqual(Object.keys(contentTree.nested.ordered), [
+ '3.cyndaquil',
+ '2.totodile',
+ '1.chikorita'
+ ]);
t.end();
});
});