summaryrefslogtreecommitdiffstats
path: root/lib/models
diff options
context:
space:
mode:
Diffstat (limited to 'lib/models')
-rw-r--r--lib/models/__tests__/config.js63
-rw-r--r--lib/models/__tests__/glossary.js42
-rw-r--r--lib/models/__tests__/glossaryEntry.js17
-rw-r--r--lib/models/__tests__/plugin.js29
-rw-r--r--lib/models/__tests__/summary.js81
-rw-r--r--lib/models/__tests__/templateBlock.js106
-rw-r--r--lib/models/__tests__/templateEngine.js51
-rw-r--r--lib/models/book.js258
-rw-r--r--lib/models/config.js106
-rw-r--r--lib/models/file.js89
-rw-r--r--lib/models/fs.js274
-rw-r--r--lib/models/glossary.js109
-rw-r--r--lib/models/glossaryEntry.js43
-rw-r--r--lib/models/language.js21
-rw-r--r--lib/models/languages.js71
-rw-r--r--lib/models/output.js93
-rw-r--r--lib/models/page.js55
-rw-r--r--lib/models/plugin.js152
-rw-r--r--lib/models/readme.js40
-rw-r--r--lib/models/summary.js190
-rw-r--r--lib/models/summaryArticle.js150
-rw-r--r--lib/models/summaryPart.js48
-rw-r--r--lib/models/templateBlock.js310
-rw-r--r--lib/models/templateEngine.js139
24 files changed, 2537 insertions, 0 deletions
diff --git a/lib/models/__tests__/config.js b/lib/models/__tests__/config.js
new file mode 100644
index 0000000..8445cef
--- /dev/null
+++ b/lib/models/__tests__/config.js
@@ -0,0 +1,63 @@
+jest.autoMockOff();
+
+var Immutable = require('immutable');
+
+describe('Config', function() {
+ var Config = require('../config');
+
+ var config = Config.createWithValues({
+ hello: {
+ world: 1,
+ test: 'Hello',
+ isFalse: false
+ }
+ });
+
+ describe('getValue', function() {
+ it('must return value as immutable', function() {
+ var value = config.getValue('hello');
+ expect(Immutable.Map.isMap(value)).toBeTruthy();
+ });
+
+ it('must return deep value', function() {
+ var value = config.getValue('hello.world');
+ expect(value).toBe(1);
+ });
+
+ it('must return default value if non existant', function() {
+ var value = config.getValue('hello.nonExistant', 'defaultValue');
+ expect(value).toBe('defaultValue');
+ });
+
+ it('must not return default value for falsy values', function() {
+ var value = config.getValue('hello.isFalse', 'defaultValue');
+ expect(value).toBe(false);
+ });
+ });
+
+ describe('setValue', function() {
+ it('must set value as immutable', function() {
+ var testConfig = config.setValue('hello', {
+ 'cool': 1
+ });
+ var value = testConfig.getValue('hello');
+
+ expect(Immutable.Map.isMap(value)).toBeTruthy();
+ expect(value.size).toBe(1);
+ expect(value.has('cool')).toBeTruthy();
+ });
+
+ it('must set deep value', function() {
+ var testConfig = config.setValue('hello.world', 2);
+ var hello = testConfig.getValue('hello');
+ var world = testConfig.getValue('hello.world');
+
+ expect(Immutable.Map.isMap(hello)).toBeTruthy();
+ expect(hello.size).toBe(3);
+
+ expect(world).toBe(2);
+ });
+ });
+});
+
+
diff --git a/lib/models/__tests__/glossary.js b/lib/models/__tests__/glossary.js
new file mode 100644
index 0000000..2ce224c
--- /dev/null
+++ b/lib/models/__tests__/glossary.js
@@ -0,0 +1,42 @@
+jest.autoMockOff();
+
+describe('Glossary', function() {
+ var File = require('../file');
+ var Glossary = require('../glossary');
+ var GlossaryEntry = require('../glossaryEntry');
+
+ var glossary = Glossary.createFromEntries(File(), [
+ {
+ name: 'Hello World',
+ description: 'Awesome!'
+ },
+ {
+ name: 'JavaScript',
+ description: 'This is a cool language'
+ }
+ ]);
+
+ describe('createFromEntries', function() {
+ it('must add all entries', function() {
+ var entries = glossary.getEntries();
+ expect(entries.size).toBe(2);
+ });
+
+ it('must add entries as GlossaryEntries', function() {
+ var entries = glossary.getEntries();
+ var entry = entries.get('hello-world');
+ expect(entry instanceof GlossaryEntry).toBeTruthy();
+ });
+ });
+
+ describe('toText', function() {
+ pit('return as markdown', function() {
+ return glossary.toText('.md')
+ .then(function(text) {
+ expect(text).toContain('# Glossary');
+ });
+ });
+ });
+});
+
+
diff --git a/lib/models/__tests__/glossaryEntry.js b/lib/models/__tests__/glossaryEntry.js
new file mode 100644
index 0000000..9eabc68
--- /dev/null
+++ b/lib/models/__tests__/glossaryEntry.js
@@ -0,0 +1,17 @@
+jest.autoMockOff();
+
+describe('GlossaryEntry', function() {
+ var GlossaryEntry = require('../glossaryEntry');
+
+ describe('getID', function() {
+ it('must return a normalized ID', function() {
+ var entry = new GlossaryEntry({
+ name: 'Hello World'
+ });
+
+ expect(entry.getID()).toBe('hello-world');
+ });
+ });
+});
+
+
diff --git a/lib/models/__tests__/plugin.js b/lib/models/__tests__/plugin.js
new file mode 100644
index 0000000..81d9d51
--- /dev/null
+++ b/lib/models/__tests__/plugin.js
@@ -0,0 +1,29 @@
+jest.autoMockOff();
+
+describe('Plugin', function() {
+ var Plugin = require('../plugin');
+
+ describe('createFromString', function() {
+ it('must parse name', function() {
+ var plugin = Plugin.createFromString('hello');
+ expect(plugin.getName()).toBe('hello');
+ expect(plugin.getVersion()).toBe('*');
+ });
+
+ it('must parse version', function() {
+ var plugin = Plugin.createFromString('hello@1.0.0');
+ expect(plugin.getName()).toBe('hello');
+ expect(plugin.getVersion()).toBe('1.0.0');
+ });
+ });
+
+ describe('isLoaded', function() {
+ it('must return false for empty plugin', function() {
+ var plugin = Plugin.createFromString('hello');
+ expect(plugin.isLoaded()).toBe(false);
+ });
+
+ });
+});
+
+
diff --git a/lib/models/__tests__/summary.js b/lib/models/__tests__/summary.js
new file mode 100644
index 0000000..ad040cf
--- /dev/null
+++ b/lib/models/__tests__/summary.js
@@ -0,0 +1,81 @@
+
+describe('Summary', function() {
+ var File = require('../file');
+ var Summary = require('../summary');
+
+ var summary = Summary.createFromParts(File(), [
+ {
+ articles: [
+ {
+ title: 'My First Article',
+ path: 'README.md'
+ },
+ {
+ title: 'My Second Article',
+ path: 'article.md'
+ }
+ ]
+ },
+ {
+ title: 'Test'
+ }
+ ]);
+
+ describe('createFromEntries', function() {
+ it('must add all parts', function() {
+ var parts = summary.getParts();
+ expect(parts.size).toBe(2);
+ });
+ });
+
+ describe('getByLevel', function() {
+ it('can return a Part', function() {
+ var part = summary.getByLevel('1');
+
+ expect(part).toBeDefined();
+ expect(part.getArticles().size).toBe(2);
+ });
+
+ it('can return a Part (2)', function() {
+ var part = summary.getByLevel('2');
+
+ expect(part).toBeDefined();
+ expect(part.getTitle()).toBe('Test');
+ expect(part.getArticles().size).toBe(0);
+ });
+
+ it('can return an Article', function() {
+ var article = summary.getByLevel('1.1');
+
+ expect(article).toBeDefined();
+ expect(article.getTitle()).toBe('My First Article');
+ });
+ });
+
+ describe('getByPath', function() {
+ it('return correct article', function() {
+ var article = summary.getByPath('README.md');
+
+ expect(article).toBeDefined();
+ expect(article.getTitle()).toBe('My First Article');
+ });
+
+ it('return correct article', function() {
+ var article = summary.getByPath('article.md');
+
+ expect(article).toBeDefined();
+ expect(article.getTitle()).toBe('My Second Article');
+ });
+ });
+
+ describe('toText', function() {
+ pit('return as markdown', function() {
+ return summary.toText('.md')
+ .then(function(text) {
+ expect(text).toContain('# Summary');
+ });
+ });
+ });
+});
+
+
diff --git a/lib/models/__tests__/templateBlock.js b/lib/models/__tests__/templateBlock.js
new file mode 100644
index 0000000..44d53de
--- /dev/null
+++ b/lib/models/__tests__/templateBlock.js
@@ -0,0 +1,106 @@
+var nunjucks = require('nunjucks');
+var Immutable = require('immutable');
+var Promise = require('../../utils/promise');
+
+describe('TemplateBlock', function() {
+ var TemplateBlock = require('../templateBlock');
+
+ describe('create', function() {
+ pit('must initialize a simple TemplateBlock from a function', function() {
+ var templateBlock = TemplateBlock.create('sayhello', function(block) {
+ return '<p>Hello, World!</p>';
+ });
+
+ // Check basic templateBlock properties
+ expect(templateBlock.getName()).toBe('sayhello');
+ expect(templateBlock.getPost()).toBeNull();
+ expect(templateBlock.getParse()).toBeTruthy();
+ expect(templateBlock.getEndTag()).toBe('endsayhello');
+ expect(templateBlock.getBlocks().size).toBe(0);
+ expect(templateBlock.getShortcuts().size).toBe(0);
+ expect(templateBlock.getExtensionName()).toBe('BlocksayhelloExtension');
+
+ // Check result of applying block
+ return Promise()
+ .then(function() {
+ return templateBlock.applyBlock();
+ })
+ .then(function(result) {
+ expect(result.name).toBe('sayhello');
+ expect(result.body).toBe('<p>Hello, World!</p>');
+ });
+ });
+ });
+
+ describe('toNunjucksExt()', function() {
+ pit('must create a valid nunjucks extension', function() {
+ var templateBlock = TemplateBlock.create('sayhello', function(block) {
+ return '<p>Hello, World!</p>';
+ });
+
+ // Create a fresh Nunjucks environment
+ var env = new nunjucks.Environment(null, { autoescape: false });
+
+ // Add template block to environement
+ var Ext = templateBlock.toNunjucksExt();
+ env.addExtension(templateBlock.getExtensionName(), new Ext());
+
+ // Render a template using the block
+ var src = '{% sayhello %}{% endsayhello %}';
+ return Promise.nfcall(env.renderString.bind(env), src)
+ .then(function(res) {
+ expect(res).toBe('<p>Hello, World!</p>');
+ });
+ });
+
+ pit('must apply block arguments correctly', function() {
+ var templateBlock = TemplateBlock.create('sayhello', function(block) {
+ return '<'+block.kwargs.tag+'>Hello, '+block.kwargs.name+'!</'+block.kwargs.tag+'>';
+ });
+
+ // Create a fresh Nunjucks environment
+ var env = new nunjucks.Environment(null, { autoescape: false });
+
+ // Add template block to environement
+ var Ext = templateBlock.toNunjucksExt();
+ env.addExtension(templateBlock.getExtensionName(), new Ext());
+
+ // Render a template using the block
+ var src = '{% sayhello name="Samy", tag="p" %}{% endsayhello %}';
+ return Promise.nfcall(env.renderString.bind(env), src)
+ .then(function(res) {
+ expect(res).toBe('<p>Hello, Samy!</p>');
+ });
+ });
+
+ pit('must handle nested blocks', function() {
+ var templateBlock = new TemplateBlock({
+ name: 'yoda',
+ blocks: Immutable.List(['start', 'end']),
+ process: function(block) {
+ var nested = {};
+
+ block.blocks.forEach(function(blk) {
+ nested[blk.name] = blk.body.trim();
+ });
+
+ return '<p class="yoda">'+nested.end+' '+nested.start+'</p>';
+ }
+ });
+
+ // Create a fresh Nunjucks environment
+ var env = new nunjucks.Environment(null, { autoescape: false });
+
+ // Add template block to environement
+ var Ext = templateBlock.toNunjucksExt();
+ env.addExtension(templateBlock.getExtensionName(), new Ext());
+
+ // Render a template using the block
+ var src = '{% yoda %}{% start %}this sentence should be{% end %}inverted{% endyoda %}';
+ return Promise.nfcall(env.renderString.bind(env), src)
+ .then(function(res) {
+ expect(res).toBe('<p class="yoda">inverted this sentence should be</p>');
+ });
+ });
+ });
+}); \ No newline at end of file
diff --git a/lib/models/__tests__/templateEngine.js b/lib/models/__tests__/templateEngine.js
new file mode 100644
index 0000000..6f18b18
--- /dev/null
+++ b/lib/models/__tests__/templateEngine.js
@@ -0,0 +1,51 @@
+
+describe('TemplateBlock', function() {
+ var TemplateEngine = require('../templateEngine');
+
+ describe('create', function() {
+ it('must initialize with a list of filters', function() {
+ var engine = TemplateEngine.create({
+ filters: {
+ hello: function(name) {
+ return 'Hello ' + name + '!';
+ }
+ }
+ });
+ var env = engine.toNunjucks();
+ var res = env.renderString('{{ "Luke"|hello }}');
+
+ expect(res).toBe('Hello Luke!');
+ });
+
+ it('must initialize with a list of globals', function() {
+ var engine = TemplateEngine.create({
+ globals: {
+ hello: function(name) {
+ return 'Hello ' + name + '!';
+ }
+ }
+ });
+ var env = engine.toNunjucks();
+ var res = env.renderString('{{ hello("Luke") }}');
+
+ expect(res).toBe('Hello Luke!');
+ });
+
+ it('must pass context to filters and blocks', function() {
+ var engine = TemplateEngine.create({
+ filters: {
+ hello: function(name) {
+ return 'Hello ' + name + ' ' + this.lastName + '!';
+ }
+ },
+ context: {
+ lastName: 'Skywalker'
+ }
+ });
+ var env = engine.toNunjucks();
+ var res = env.renderString('{{ "Luke"|hello }}');
+
+ expect(res).toBe('Hello Luke Skywalker!');
+ });
+ });
+}); \ No newline at end of file
diff --git a/lib/models/book.js b/lib/models/book.js
new file mode 100644
index 0000000..f960df1
--- /dev/null
+++ b/lib/models/book.js
@@ -0,0 +1,258 @@
+var path = require('path');
+var Immutable = require('immutable');
+var Ignore = require('ignore');
+
+var Logger = require('../utils/logger');
+
+var FS = require('./fs');
+var Config = require('./config');
+var Readme = require('./readme');
+var Summary = require('./summary');
+var Glossary = require('./glossary');
+var Languages = require('./languages');
+
+
+var Book = Immutable.Record({
+ // Logger for outptu message
+ logger: Logger(),
+
+ // Filesystem binded to the book scope to read files/directories
+ fs: FS(),
+
+ // Ignore files parser
+ ignore: Ignore(),
+
+ // Structure files
+ config: Config(),
+ readme: Readme(),
+ summary: Summary(),
+ glossary: Glossary(),
+ languages: Languages(),
+
+ // ID of the language for language books
+ language: String(),
+
+ // List of children, if multilingual (String -> Book)
+ books: Immutable.OrderedMap()
+});
+
+Book.prototype.getLogger = function() {
+ return this.get('logger');
+};
+
+Book.prototype.getFS = function() {
+ return this.get('fs');
+};
+
+Book.prototype.getIgnore = function() {
+ return this.get('ignore');
+};
+
+Book.prototype.getConfig = function() {
+ return this.get('config');
+};
+
+Book.prototype.getReadme = function() {
+ return this.get('readme');
+};
+
+Book.prototype.getSummary = function() {
+ return this.get('summary');
+};
+
+Book.prototype.getGlossary = function() {
+ return this.get('glossary');
+};
+
+Book.prototype.getLanguages = function() {
+ return this.get('languages');
+};
+
+Book.prototype.getBooks = function() {
+ return this.get('books');
+};
+
+Book.prototype.getLanguage = function() {
+ return this.get('language');
+};
+
+/**
+ Return FS instance to access the content
+
+ @return {FS}
+*/
+Book.prototype.getContentFS = function() {
+ var fs = this.getFS();
+ var config = this.getConfig();
+ var rootFolder = config.getValue('root');
+
+ if (rootFolder) {
+ return FS.reduceScope(fs, rootFolder);
+ }
+
+ return fs;
+};
+
+/**
+ Return root of the book
+
+ @return {String}
+*/
+Book.prototype.getRoot = function() {
+ var fs = this.getFS();
+ return fs.getRoot();
+};
+
+/**
+ Return root for content of the book
+
+ @return {String}
+*/
+Book.prototype.getContentRoot = function() {
+ var fs = this.getContentFS();
+ return fs.getRoot();
+};
+
+/**
+ Check if a file is ignore (should not being parsed, etc)
+
+ @param {String} ref
+ @return {Page|undefined}
+*/
+Book.prototype.isFileIgnored = function(filename) {
+ var ignore = this.getIgnore();
+ var language = this.getLanguage();
+
+ // Ignore is always relative to the root of the main book
+ if (language) {
+ filename = path.join(language, filename);
+ }
+
+
+ return ignore.filter([filename]).length == 0;
+};
+
+/**
+ Check if a content file is ignore (should not being parsed, etc)
+
+ @param {String} ref
+ @return {Page|undefined}
+*/
+Book.prototype.isContentFileIgnored = function(filename) {
+ var config = this.getConfig();
+ var rootFolder = config.getValue('root');
+
+ if (rootFolder) {
+ filename = path.join(rootFolder, filename);
+ }
+
+ return this.isFileIgnored(filename);
+};
+
+/**
+ Return a page from a book by its path
+
+ @param {String} ref
+ @return {Page|undefined}
+*/
+Book.prototype.getPage = function(ref) {
+ return this.getPages().get(ref);
+};
+
+/**
+ Is this book the parent of language's books
+
+ @return {Boolean}
+*/
+Book.prototype.isMultilingual = function() {
+ return (this.getLanguages().getCount() > 0);
+};
+
+/**
+ Return true if book is associated to a language
+
+ @return {Boolean}
+*/
+Book.prototype.isLanguageBook = function() {
+ return Boolean(this.getLanguage());
+};
+
+/**
+ Add a new language book
+
+ @param {String} language
+ @param {Book} book
+ @return {Book}
+*/
+Book.prototype.addLanguageBook = function(language, book) {
+ var books = this.getBooks();
+ books = books.set(language, book);
+
+ return this.set('books', books);
+};
+
+/**
+ Set the summary for this book
+
+ @param {Summary}
+ @return {Book}
+*/
+Book.prototype.setSummary = function(summary) {
+ return this.set('summary', summary);
+};
+
+/**
+ Set the readme for this book
+
+ @param {Readme}
+ @return {Book}
+*/
+Book.prototype.setReadme = function(readme) {
+ return this.set('readme', readme);
+};
+
+/**
+ Change log level
+
+ @param {String} level
+ @return {Book}
+*/
+Book.prototype.setLogLevel = function(level) {
+ this.getLogger().setLevel(level);
+ return this;
+};
+
+/**
+ Create a book using a filesystem
+
+ @param {FS} fs
+ @return {Book}
+*/
+Book.createForFS = function createForFS(fs) {
+ return new Book({
+ fs: fs
+ });
+};
+
+/**
+ Create a language book from a parent
+
+ @param {Book} parent
+ @param {String} language
+ @return {Book}
+*/
+Book.createFromParent = function createFromParent(parent, language) {
+ var ignore = parent.getIgnore();
+
+ return new Book({
+ // Inherits config. logegr and list of ignored files
+ logger: parent.getLogger(),
+ config: parent.getConfig(),
+ ignore: Ignore().add(ignore),
+
+ language: language,
+ fs: FS.reduceScope(parent.getContentFS(), language)
+ });
+};
+
+module.exports = Book;
diff --git a/lib/models/config.js b/lib/models/config.js
new file mode 100644
index 0000000..6ee03e4
--- /dev/null
+++ b/lib/models/config.js
@@ -0,0 +1,106 @@
+var is = require('is');
+var Immutable = require('immutable');
+
+var File = require('./file');
+var configDefault = require('../constants/configDefault');
+
+var Config = Immutable.Record({
+ file: File(),
+ values: configDefault
+}, 'Config');
+
+Config.prototype.getFile = function() {
+ return this.get('file');
+};
+
+Config.prototype.getValues = function() {
+ return this.get('values');
+};
+
+/**
+ Return a configuration value by its key path
+
+ @param {String} key
+ @return {Mixed}
+*/
+Config.prototype.getValue = function(keyPath, def) {
+ var values = this.getValues();
+ keyPath = Config.keyToKeyPath(keyPath);
+
+ if (!values.hasIn(keyPath)) {
+ return Immutable.fromJS(def);
+ }
+
+ return values.getIn(keyPath);
+};
+
+/**
+ Update a configuration value
+
+ @param {String} key
+ @param {Mixed} value
+ @return {Mixed}
+*/
+Config.prototype.setValue = function(keyPath, value) {
+ keyPath = Config.keyToKeyPath(keyPath);
+
+ value = Immutable.fromJS(value);
+
+ var values = this.getValues();
+ values = values.setIn(keyPath, value);
+
+ return this.set('values', values);
+};
+
+/**
+ Create a new config for a file
+
+ @param {File} file
+ @param {Object} values
+ @returns {Config}
+*/
+Config.create = function(file, values) {
+ return new Config({
+ file: file,
+ values: Immutable.fromJS(values)
+ });
+};
+
+/**
+ Create a new config
+
+ @param {Object} values
+ @returns {Config}
+*/
+Config.createWithValues = function(values) {
+ return new Config({
+ values: Immutable.fromJS(values)
+ });
+};
+
+/**
+ Update values for an existing configuration
+
+ @param {Config} config
+ @param {Object} values
+ @returns {Config}
+*/
+Config.updateValues = function(config, values) {
+ values = Immutable.fromJS(values);
+
+ return config.set('values', values);
+};
+
+
+/**
+ Convert a keyPath to an array of keys
+
+ @param {String|Array}
+ @return {Array}
+*/
+Config.keyToKeyPath = function(keyPath) {
+ if (is.string(keyPath)) keyPath = keyPath.split('.');
+ return keyPath;
+};
+
+module.exports = Config;
diff --git a/lib/models/file.js b/lib/models/file.js
new file mode 100644
index 0000000..d1726a7
--- /dev/null
+++ b/lib/models/file.js
@@ -0,0 +1,89 @@
+var path = require('path');
+var Immutable = require('immutable');
+
+var parsers = require('../parsers');
+
+var File = Immutable.Record({
+ // Path of the file, relative to the FS
+ path: String(),
+
+ // Time when file data last modified
+ mtime: Date()
+});
+
+File.prototype.getPath = function() {
+ return this.get('path');
+};
+
+File.prototype.getMTime = function() {
+ return this.get('mtime');
+};
+
+/**
+ Does the file exists / is set
+
+ @return {Boolean}
+*/
+File.prototype.exists = function() {
+ return Boolean(this.getPath());
+};
+
+/**
+ Return type of file ('markdown' or 'asciidoc')
+
+ @return {String}
+*/
+File.prototype.getType = function() {
+ var parser = this.getParser();
+ if (parser) {
+ return parser.name;
+ } else {
+ return undefined;
+ }
+};
+
+/**
+ Return extension of this file (lowercased)
+
+ @return {String}
+*/
+File.prototype.getExtension = function() {
+ return path.extname(this.getPath()).toLowerCase();
+};
+
+/**
+ Return parser for this file
+
+ @return {Parser}
+*/
+File.prototype.getParser = function() {
+ return parsers.getByExt(this.getExtension());
+};
+
+/**
+ Create a file from stats informations
+
+ @param {String} filepath
+ @param {Object|fs.Stats} stat
+ @return {File}
+*/
+File.createFromStat = function createFromStat(filepath, stat) {
+ return new File({
+ path: filepath,
+ mtime: stat.mtime
+ });
+};
+
+/**
+ Create a file with only a path
+
+ @param {String} filepath
+ @return {File}
+*/
+File.createWithFilepath = function createWithFilepath(filepath) {
+ return new File({
+ path: filepath
+ });
+};
+
+module.exports = File;
diff --git a/lib/models/fs.js b/lib/models/fs.js
new file mode 100644
index 0000000..ab65dd5
--- /dev/null
+++ b/lib/models/fs.js
@@ -0,0 +1,274 @@
+var path = require('path');
+var Immutable = require('immutable');
+
+var File = require('./file');
+var Promise = require('../utils/promise');
+var error = require('../utils/error');
+var PathUtil = require('../utils/path');
+
+var FS = Immutable.Record({
+ root: String(),
+
+ fsExists: Function(),
+ fsReadFile: Function(),
+ fsStatFile: Function(),
+ fsReadDir: Function(),
+ fsLoadObject: null
+});
+
+/**
+ Return path to the root
+
+ @return {String}
+*/
+FS.prototype.getRoot = function() {
+ return this.get('root');
+};
+
+/**
+ Verify that a file is in the fs scope
+
+ @param {String} filename
+ @return {Boolean}
+*/
+FS.prototype.isInScope = function(filename) {
+ var rootPath = this.getRoot();
+ filename = path.join(rootPath, filename);
+
+ return PathUtil.isInRoot(rootPath, filename);
+};
+
+/**
+ Resolve a file in this FS
+
+ @param {String}
+ @return {String}
+*/
+FS.prototype.resolve = function() {
+ var rootPath = this.getRoot();
+ var args = Array.prototype.slice.call(arguments);
+ var filename = path.join.apply(path, [rootPath].concat(args));
+ filename = path.normalize(filename);
+
+ if (!this.isInScope(filename)) {
+ throw error.FileOutOfScopeError({
+ filename: filename,
+ root: this.root
+ });
+ }
+
+ return filename;
+};
+
+/**
+ Check if a file exists, run a Promise(true) if that's the case, Promise(false) otherwise
+
+ @param {String} filename
+ @return {Promise<Boolean>}
+*/
+FS.prototype.exists = function(filename) {
+ var that = this;
+
+ return Promise()
+ .then(function() {
+ filename = that.resolve(filename);
+ var exists = that.get('fsExists');
+
+ return exists(filename);
+ });
+};
+
+/**
+ Read a file and returns a promise with the content as a buffer
+
+ @param {String} filename
+ @return {Promise<Buffer>}
+*/
+FS.prototype.read = function(filename) {
+ var that = this;
+
+ return Promise()
+ .then(function() {
+ filename = that.resolve(filename);
+ var read = that.get('fsReadFile');
+
+ return read(filename);
+ });
+};
+
+/**
+ Read a file as a string (utf-8)
+
+ @param {String} filename
+ @return {Promise<String>}
+*/
+FS.prototype.readAsString = function(filename, encoding) {
+ encoding = encoding || 'utf8';
+
+ return this.read(filename)
+ .then(function(buf) {
+ return buf.toString(encoding);
+ });
+};
+
+/**
+ Read stat infos about a file
+
+ @param {String} filename
+ @return {Promise<File>}
+*/
+FS.prototype.statFile = function(filename) {
+ var that = this;
+
+ return Promise()
+ .then(function() {
+ var filepath = that.resolve(filename);
+ var stat = that.get('fsStatFile');
+
+ return stat(filepath);
+ })
+ .then(function(stat) {
+ return File.createFromStat(filename, stat);
+ });
+};
+
+/**
+ List files/directories in a directory.
+ Directories ends with '/'
+
+ @param {String} dirname
+ @return {Promise<List<String>>}
+*/
+FS.prototype.readDir = function(dirname) {
+ var that = this;
+
+ return Promise()
+ .then(function() {
+ var dirpath = that.resolve(dirname);
+ var readDir = that.get('fsReadDir');
+
+ return readDir(dirpath);
+ })
+ .then(function(files) {
+ return Immutable.List(files);
+ });
+};
+
+/**
+ List only files in a diretcory
+ Directories ends with '/'
+
+ @param {String} dirname
+ @return {Promise<List<String>>}
+*/
+FS.prototype.listFiles = function(dirname) {
+ return this.readDir(dirname)
+ .then(function(files) {
+ return files.filterNot(pathIsFolder);
+ });
+};
+
+/**
+ List all files in a directory
+
+ @param {String} dirname
+ @return {Promise<List<String>>}
+*/
+FS.prototype.listAllFiles = function(folder) {
+ var that = this;
+ folder = folder || '.';
+
+ return this.readDir(folder)
+ .then(function(files) {
+ return Promise.reduce(files, function(out, file) {
+ var isDirectory = pathIsFolder(file);
+
+ if (!isDirectory) {
+ return out.push(path.join(folder, file));
+ }
+
+ return that.listAllFiles(path.join(folder, file))
+ .then(function(inner) {
+ return out.concat(inner);
+ });
+ }, Immutable.List());
+ });
+};
+
+/**
+ Find a file in a folder (case incensitive)
+ Return the found filename
+
+ @param {String} dirname
+ @param {String} filename
+ @return {Promise<String>}
+*/
+FS.prototype.findFile = function(dirname, filename) {
+ return this.listFiles(dirname)
+ .then(function(files) {
+ return files.find(function(file) {
+ return (file.toLowerCase() == filename.toLowerCase());
+ });
+ });
+};
+
+/**
+ Load a JSON file
+ By default, fs only supports JSON
+
+ @param {String} filename
+ @return {Promise<Object>}
+*/
+FS.prototype.loadAsObject = function(filename) {
+ var that = this;
+ var fsLoadObject = this.get('fsLoadObject');
+
+ return this.exists(filename)
+ .then(function(exists) {
+ if (!exists) {
+ var err = new Error('Module doesn\'t exist');
+ err.code = 'MODULE_NOT_FOUND';
+
+ throw err;
+ }
+
+ if (fsLoadObject) {
+ return fsLoadObject(that.resolve(filename));
+ } else {
+ return that.readAsString(filename)
+ .then(function(str) {
+ return JSON.parse(str);
+ });
+ }
+ });
+};
+
+/**
+ Create a FS instance
+
+ @param {Object} def
+ @return {FS}
+*/
+FS.create = function create(def) {
+ return new FS(def);
+};
+
+/**
+ Create a new FS instance with a reduced scope
+
+ @param {FS} fs
+ @param {String} scope
+ @return {FS}
+*/
+FS.reduceScope = function reduceScope(fs, scope) {
+ return fs.set('root', path.join(fs.getRoot(), scope));
+};
+
+
+// .readdir return files/folder as a list of string, folder ending with '/'
+function pathIsFolder(filename) {
+ var lastChar = filename[filename.length - 1];
+ return lastChar == '/' || lastChar == '\\';
+}
+
+module.exports = FS; \ No newline at end of file
diff --git a/lib/models/glossary.js b/lib/models/glossary.js
new file mode 100644
index 0000000..bb4407d
--- /dev/null
+++ b/lib/models/glossary.js
@@ -0,0 +1,109 @@
+var Immutable = require('immutable');
+
+var error = require('../utils/error');
+var File = require('./file');
+var GlossaryEntry = require('./glossaryEntry');
+var parsers = require('../parsers');
+
+var Glossary = Immutable.Record({
+ file: File(),
+ entries: Immutable.OrderedMap()
+});
+
+Glossary.prototype.getFile = function() {
+ return this.get('file');
+};
+
+Glossary.prototype.getEntries = function() {
+ return this.get('entries');
+};
+
+/**
+ Return an entry by its name
+
+ @param {String} name
+ @return {GlossaryEntry}
+*/
+Glossary.prototype.getEntry = function(name) {
+ var entries = this.getEntries();
+ var id = GlossaryEntry.nameToID(name);
+
+ return entries.get(id);
+};
+
+/**
+ Render glossary as text
+
+ @return {Promise<String>}
+*/
+Glossary.prototype.toText = function(parser) {
+ var file = this.getFile();
+ var entries = this.getEntries();
+
+ parser = parser? parsers.getByExt(parser) : file.getParser();
+
+ if (!parser) {
+ throw error.FileNotParsableError({
+ filename: file.getPath()
+ });
+ }
+
+ return parser.glossary.toText(entries.toJS());
+};
+
+
+/**
+ Add/Replace an entry to a glossary
+
+ @param {Glossary} glossary
+ @param {GlossaryEntry} entry
+ @return {Glossary}
+*/
+Glossary.addEntry = function addEntry(glossary, entry) {
+ var id = entry.getID();
+ var entries = glossary.getEntries();
+
+ entries = entries.set(id, entry);
+ return glossary.set('entries', entries);
+};
+
+/**
+ Add/Replace an entry to a glossary by name/description
+
+ @param {Glossary} glossary
+ @param {GlossaryEntry} entry
+ @return {Glossary}
+*/
+Glossary.addEntryByName = function addEntryByName(glossary, name, description) {
+ var entry = new GlossaryEntry({
+ name: name,
+ description: description
+ });
+
+ return Glossary.addEntry(glossary, entry);
+};
+
+/**
+ Create a glossary from a list of entries
+
+ @param {String} filename
+ @param {Array|List} entries
+ @return {Glossary}
+*/
+Glossary.createFromEntries = function createFromEntries(file, entries) {
+ entries = entries.map(function(entry) {
+ if (!(entry instanceof GlossaryEntry)) {
+ entry = new GlossaryEntry(entry);
+ }
+
+ return [entry.getID(), entry];
+ });
+
+ return new Glossary({
+ file: file,
+ entries: Immutable.OrderedMap(entries)
+ });
+};
+
+
+module.exports = Glossary;
diff --git a/lib/models/glossaryEntry.js b/lib/models/glossaryEntry.js
new file mode 100644
index 0000000..10791db
--- /dev/null
+++ b/lib/models/glossaryEntry.js
@@ -0,0 +1,43 @@
+var Immutable = require('immutable');
+var slug = require('github-slugid');
+
+/*
+ A definition represents an entry in the glossary
+*/
+
+var GlossaryEntry = Immutable.Record({
+ name: String(),
+ description: String()
+});
+
+GlossaryEntry.prototype.getName = function() {
+ return this.get('name');
+};
+
+GlossaryEntry.prototype.getDescription = function() {
+ return this.get('description');
+};
+
+
+/**
+ Get identifier for this entry
+
+ @retrun {Boolean}
+*/
+GlossaryEntry.prototype.getID = function() {
+ return GlossaryEntry.nameToID(this.getName());
+};
+
+
+/**
+ Normalize a glossary entry name into a unique id
+
+ @param {String}
+ @return {String}
+*/
+GlossaryEntry.nameToID = function nameToID(name) {
+ return slug(name);
+};
+
+
+module.exports = GlossaryEntry;
diff --git a/lib/models/language.js b/lib/models/language.js
new file mode 100644
index 0000000..dcefbf6
--- /dev/null
+++ b/lib/models/language.js
@@ -0,0 +1,21 @@
+var path = require('path');
+var Immutable = require('immutable');
+
+var Language = Immutable.Record({
+ title: String(),
+ path: String()
+});
+
+Language.prototype.getTitle = function() {
+ return this.get('title');
+};
+
+Language.prototype.getPath = function() {
+ return this.get('path');
+};
+
+Language.prototype.getID = function() {
+ return path.basename(this.getPath());
+};
+
+module.exports = Language;
diff --git a/lib/models/languages.js b/lib/models/languages.js
new file mode 100644
index 0000000..1e58d88
--- /dev/null
+++ b/lib/models/languages.js
@@ -0,0 +1,71 @@
+var Immutable = require('immutable');
+
+var File = require('./file');
+var Language = require('./language');
+
+var Languages = Immutable.Record({
+ file: File(),
+ list: Immutable.OrderedMap()
+});
+
+Languages.prototype.getFile = function() {
+ return this.get('file');
+};
+
+Languages.prototype.getList = function() {
+ return this.get('list');
+};
+
+/**
+ Get default languages
+
+ @return {Language}
+*/
+Languages.prototype.getDefaultLanguage = function() {
+ return this.getList().first();
+};
+
+/**
+ Get a language by its ID
+
+ @param {String} lang
+ @return {Language}
+*/
+Languages.prototype.getLanguage = function(lang) {
+ return this.getList().get(lang);
+};
+
+/**
+ Return count of langs
+
+ @return {Number}
+*/
+Languages.prototype.getCount = function() {
+ return this.getList().size;
+};
+
+/**
+ Create a languages list from a JS object
+
+ @param {File}
+ @param {Array}
+ @return {Language}
+*/
+Languages.createFromList = function(file, langs) {
+ var list = Immutable.OrderedMap();
+
+ langs.forEach(function(lang) {
+ lang = Language({
+ title: lang.title,
+ path: lang.path
+ });
+ list = list.set(lang.getID(), lang);
+ });
+
+ return Languages({
+ file: file,
+ list: list
+ });
+};
+
+module.exports = Languages;
diff --git a/lib/models/output.js b/lib/models/output.js
new file mode 100644
index 0000000..43e36f8
--- /dev/null
+++ b/lib/models/output.js
@@ -0,0 +1,93 @@
+var Immutable = require('immutable');
+
+var Book = require('./book');
+
+var Output = Immutable.Record({
+ book: Book(),
+
+ // Name of the generator being used
+ generator: String(),
+
+ // Map of plugins to use (String -> Plugin)
+ plugins: Immutable.OrderedMap(),
+
+ // Map pages to generation (String -> Page)
+ pages: Immutable.OrderedMap(),
+
+ // List assets (String)
+ assets: Immutable.List(),
+
+ // Option for the generation
+ options: Immutable.Map(),
+
+ // Internal state for the generation
+ state: Immutable.Map()
+});
+
+Output.prototype.getBook = function() {
+ return this.get('book');
+};
+
+Output.prototype.getGenerator = function() {
+ return this.get('generator');
+};
+
+Output.prototype.getPlugins = function() {
+ return this.get('plugins');
+};
+
+Output.prototype.getPages = function() {
+ return this.get('pages');
+};
+
+Output.prototype.getOptions = function() {
+ return this.get('options');
+};
+
+Output.prototype.getAssets = function() {
+ return this.get('assets');
+};
+
+Output.prototype.getState = function() {
+ return this.get('state');
+};
+
+/**
+ Get root folder for output
+
+ @return {String}
+*/
+Output.prototype.getRoot = function() {
+ return this.getOptions().get('root');
+};
+
+/**
+ Update state of output
+
+ @param {Map} newState
+ @return {Output}
+*/
+Output.prototype.setState = function(newState) {
+ return this.set('state', newState);
+};
+
+/**
+ Update options
+
+ @param {Map} newOptions
+ @return {Output}
+*/
+Output.prototype.setOptions = function(newOptions) {
+ return this.set('options', newOptions);
+};
+
+/**
+ Return logegr for this output (same as book)
+
+ @return {Logger}
+*/
+Output.prototype.getLogger = function() {
+ return this.getBook().getLogger();
+};
+
+module.exports = Output;
diff --git a/lib/models/page.js b/lib/models/page.js
new file mode 100644
index 0000000..ffb9601
--- /dev/null
+++ b/lib/models/page.js
@@ -0,0 +1,55 @@
+var Immutable = require('immutable');
+
+var File = require('./file');
+
+var Page = Immutable.Record({
+ file: File(),
+
+ // Attributes extracted from the YAML header
+ attributes: Immutable.Map(),
+
+ // Content of the page
+ content: String(),
+
+ // Direction of the text
+ dir: String('ltr')
+});
+
+Page.prototype.getFile = function() {
+ return this.get('file');
+};
+
+Page.prototype.getAttributes = function() {
+ return this.get('attributes');
+};
+
+Page.prototype.getContent = function() {
+ return this.get('content');
+};
+
+Page.prototype.getDir = function() {
+ return this.get('dir');
+};
+
+/**
+ Return path of the page
+
+ @return {String}
+*/
+Page.prototype.getPath = function() {
+ return this.getFile().getPath();
+};
+
+/**
+ Create a page for a file
+
+ @param {File} file
+ @return {Page}
+*/
+Page.createForFile = function(file) {
+ return new Page({
+ file: file
+ });
+};
+
+module.exports = Page;
diff --git a/lib/models/plugin.js b/lib/models/plugin.js
new file mode 100644
index 0000000..dd7bc90
--- /dev/null
+++ b/lib/models/plugin.js
@@ -0,0 +1,152 @@
+var Immutable = require('immutable');
+
+var TemplateBlock = require('./templateBlock');
+var PREFIX = require('../constants/pluginPrefix');
+var DEFAULT_VERSION = '*';
+
+var Plugin = Immutable.Record({
+ name: String(),
+
+ // Requirement version (ex: ">1.0.0")
+ version: String(DEFAULT_VERSION),
+
+ // Path to load this plugin
+ path: String(),
+
+ // Depth of this plugin in the dependency tree
+ depth: Number(0),
+
+ // Content of the "package.json"
+ package: Immutable.Map(),
+
+ // Content of the package itself
+ content: Immutable.Map()
+}, 'Plugin');
+
+Plugin.prototype.getName = function() {
+ return this.get('name');
+};
+
+Plugin.prototype.getPath = function() {
+ return this.get('path');
+};
+
+Plugin.prototype.getVersion = function() {
+ return this.get('version');
+};
+
+Plugin.prototype.getPackage = function() {
+ return this.get('package');
+};
+
+Plugin.prototype.getContent = function() {
+ return this.get('content');
+};
+
+Plugin.prototype.getDepth = function() {
+ return this.get('depth');
+};
+
+/**
+ Return the ID on NPM for this plugin
+
+ @return {String}
+*/
+Plugin.prototype.getNpmID = function() {
+ return Plugin.nameToNpmID(this.getName());
+};
+
+/**
+ Check if a plugin is loaded
+
+ @return {Boolean}
+*/
+Plugin.prototype.isLoaded = function() {
+ return Boolean(this.getPackage().size > 0);
+};
+
+/**
+ Return map of hooks
+ @return {Map<String:Function>}
+*/
+Plugin.prototype.getHooks = function() {
+ return this.getContent().get('hooks') || Immutable.Map();
+};
+
+/**
+ Return infos about resources for a specific type
+
+ @param {String} type
+ @return {Map<String:Mixed>}
+*/
+Plugin.prototype.getResources = function(type) {
+ if (type != 'website' && type != 'ebook') {
+ throw new Error('Invalid assets type ' + type);
+ }
+
+ var content = this.getContent();
+ return (content.get(type)
+ || (type == 'website'? content.get('book') : null)
+ || Immutable.Map());
+};
+
+/**
+ Return map of filters
+ @return {Map<String:Function>}
+*/
+Plugin.prototype.getFilters = function() {
+ return this.getContent().get('filters');
+};
+
+/**
+ Return map of blocks
+ @return {Map<String:TemplateBlock>}
+*/
+Plugin.prototype.getBlocks = function() {
+ var blocks = this.getContent().get('blocks');
+ blocks = blocks || Immutable.Map();
+
+ return blocks
+ .map(function(block, blockName) {
+ return TemplateBlock.create(blockName, block);
+ });
+};
+
+/**
+ Return a specific hook
+
+ @param {String} name
+ @return {Function|undefined}
+*/
+Plugin.prototype.getHook = function(name) {
+ return this.getHooks().get(name);
+};
+
+/**
+ Create a plugin from a string
+
+ @param {String}
+ @return {Plugin}
+*/
+Plugin.createFromString = function(s) {
+ var parts = s.split('@');
+ var name = parts[0];
+ var version = parts.slice(1).join('@');
+
+ return new Plugin({
+ name: name,
+ version: version || DEFAULT_VERSION
+ });
+};
+
+/**
+ Return NPM id for a plugin name
+
+ @param {String}
+ @return {String}
+*/
+Plugin.nameToNpmID = function(s) {
+ return PREFIX + s;
+};
+
+module.exports = Plugin;
diff --git a/lib/models/readme.js b/lib/models/readme.js
new file mode 100644
index 0000000..c655c82
--- /dev/null
+++ b/lib/models/readme.js
@@ -0,0 +1,40 @@
+var Immutable = require('immutable');
+
+var File = require('./file');
+
+var Readme = Immutable.Record({
+ file: File(),
+ title: String(),
+ description: String()
+});
+
+Readme.prototype.getFile = function() {
+ return this.get('file');
+};
+
+Readme.prototype.getTitle = function() {
+ return this.get('title');
+};
+
+Readme.prototype.getDescription = function() {
+ return this.get('description');
+};
+
+/**
+ Create a new readme
+
+ @param {File} file
+ @param {Object} def
+ @return {Readme}
+*/
+Readme.create = function(file, def) {
+ def = def || {};
+
+ return new Readme({
+ file: file,
+ title: def.title || '',
+ description: def.description || ''
+ });
+};
+
+module.exports = Readme;
diff --git a/lib/models/summary.js b/lib/models/summary.js
new file mode 100644
index 0000000..5314bb0
--- /dev/null
+++ b/lib/models/summary.js
@@ -0,0 +1,190 @@
+var is = require('is');
+var Immutable = require('immutable');
+
+var error = require('../utils/error');
+var LocationUtils = require('../utils/location');
+var File = require('./file');
+var SummaryPart = require('./summaryPart');
+var SummaryArticle = require('./summaryArticle');
+var parsers = require('../parsers');
+
+var Summary = Immutable.Record({
+ file: File(),
+ parts: Immutable.List()
+}, 'Summary');
+
+Summary.prototype.getFile = function() {
+ return this.get('file');
+};
+
+Summary.prototype.getParts = function() {
+ return this.get('parts');
+};
+
+/**
+ Return a part by its index
+
+ @param {Number}
+ @return {Part}
+*/
+Summary.prototype.getPart = function(i) {
+ var parts = this.getParts();
+ return parts.get(i);
+};
+
+/**
+ Return an article using an iterator to find it.
+ if "partIter" is set, it can also return a Part.
+
+ @param {Function} iter
+ @param {Function} partIter
+ @return {Article|Part}
+*/
+Summary.prototype.getArticle = function(iter, partIter) {
+ var parts = this.getParts();
+
+ return parts.reduce(function(result, part) {
+ if (result) return result;
+
+ if (partIter && partIter(part)) return part;
+ return SummaryArticle.findArticle(part, iter);
+ }, null);
+};
+
+
+/**
+ Return a part/article by its level
+
+ @param {String} level
+ @return {Article}
+*/
+Summary.prototype.getByLevel = function(level) {
+ function iterByLevel(article) {
+ return (article.getLevel() === level);
+ }
+
+ return this.getArticle(iterByLevel, iterByLevel);
+};
+
+/**
+ Return an article by its path
+
+ @param {String} filePath
+ @return {Article}
+*/
+Summary.prototype.getByPath = function(filePath) {
+ return this.getArticle(function(article) {
+ return (LocationUtils.areIdenticalPaths(article.getPath(), filePath));
+ });
+};
+
+/**
+ Return the first article
+
+ @return {Article}
+*/
+Summary.prototype.getFirstArticle = function() {
+ return this.getArticle(function(article) {
+ return true;
+ });
+};
+
+/**
+ Return next article of an article
+
+ @param {Article} current
+ @return {Article}
+*/
+Summary.prototype.getNextArticle = function(current) {
+ var level = is.string(current)? current : current.getLevel();
+ var wasPrev = false;
+
+ return this.getArticle(function(article) {
+ if (wasPrev) return true;
+
+ wasPrev = article.getLevel() == level;
+ return false;
+ });
+};
+
+/**
+ Return previous article of an article
+
+ @param {Article} current
+ @return {Article}
+*/
+Summary.prototype.getPrevArticle = function(current) {
+ var level = is.string(current)? current : current.getLevel();
+ var prev = undefined;
+
+ this.getArticle(function(article) {
+ if (article.getLevel() == level) {
+ return true;
+ }
+
+ prev = article;
+ return false;
+ });
+
+ return prev;
+};
+
+/**
+ Render summary as text
+
+ @return {Promise<String>}
+*/
+Summary.prototype.toText = function(parser) {
+ var file = this.getFile();
+ var parts = this.getParts();
+
+ parser = parser? parsers.getByExt(parser) : file.getParser();
+
+ if (!parser) {
+ throw error.FileNotParsableError({
+ filename: file.getPath()
+ });
+ }
+
+ return parser.summary.toText({
+ parts: parts.toJS()
+ });
+};
+
+/**
+ Return all articles as a list
+
+ @return {List<Article>}
+*/
+Summary.prototype.getArticlesAsList = function() {
+ var accu = [];
+
+ this.getArticle(function(article) {
+ accu.push(article);
+ });
+
+ return Immutable.List(accu);
+};
+
+/**
+ Create a new summary for a list of parts
+
+ @param {Lust|Array} parts
+ @return {Summary}
+*/
+Summary.createFromParts = function createFromParts(file, parts) {
+ parts = parts.map(function(part, i) {
+ if (part instanceof SummaryPart) {
+ return part;
+ }
+
+ return SummaryPart.create(part, i + 1);
+ });
+
+ return new Summary({
+ file: file,
+ parts: new Immutable.List(parts)
+ });
+};
+
+module.exports = Summary;
diff --git a/lib/models/summaryArticle.js b/lib/models/summaryArticle.js
new file mode 100644
index 0000000..da82790
--- /dev/null
+++ b/lib/models/summaryArticle.js
@@ -0,0 +1,150 @@
+var Immutable = require('immutable');
+
+var location = require('../utils/location');
+
+/*
+ An article represents an entry in the Summary / table of Contents
+*/
+
+var SummaryArticle = Immutable.Record({
+ level: String(),
+ title: String(),
+ ref: String(),
+ articles: Immutable.List()
+}, 'SummaryArticle');
+
+SummaryArticle.prototype.getLevel = function() {
+ return this.get('level');
+};
+
+SummaryArticle.prototype.getTitle = function() {
+ return this.get('title');
+};
+
+SummaryArticle.prototype.getRef = function() {
+ return this.get('ref');
+};
+
+SummaryArticle.prototype.getArticles = function() {
+ return this.get('articles');
+};
+
+/**
+ Return how deep the article is
+
+ @return {Number}
+*/
+SummaryArticle.prototype.getDepth = function() {
+ return this.getLevel().split('.').length;
+};
+
+/**
+ Get path (without anchor) to the pointing file
+
+ @return {String}
+*/
+SummaryArticle.prototype.getPath = function() {
+ if (this.isExternal()) {
+ return undefined;
+ }
+
+ var ref = this.getRef();
+ if (!ref) {
+ return undefined;
+ }
+
+
+ var parts = ref.split('#');
+
+ var pathname = (parts.length > 1? parts.slice(0, -1).join('#') : ref);
+
+ // Normalize path to remove ('./', etc)
+ return location.normalize(pathname);
+};
+
+/**
+ Return url if article is external
+
+ @return {String}
+*/
+SummaryArticle.prototype.getUrl = function() {
+ return this.isExternal()? this.getRef() : undefined;
+};
+
+/**
+ Get anchor for this article (or undefined)
+
+ @return {String}
+*/
+SummaryArticle.prototype.getAnchor = function() {
+ var ref = this.getRef();
+ var parts = ref.split('#');
+
+ var anchor = (parts.length > 1? '#' + parts[parts.length - 1] : undefined);
+ return anchor;
+};
+
+/**
+ Is article pointing to a page of an absolute url
+
+ @return {Boolean}
+*/
+SummaryArticle.prototype.isPage = function() {
+ return !this.isExternal() && this.getRef();
+};
+
+/**
+ Is article pointing to aan absolute url
+
+ @return {Boolean}
+*/
+SummaryArticle.prototype.isExternal = function() {
+ return location.isExternal(this.getRef());
+};
+
+/**
+ Create a SummaryArticle
+
+ @param {Object} def
+ @return {SummaryArticle}
+*/
+SummaryArticle.create = function(def, level) {
+ var articles = (def.articles || []).map(function(article, i) {
+ if (article instanceof SummaryArticle) {
+ return article;
+ }
+ return SummaryArticle.create(article, [level, i + 1].join('.'));
+ });
+
+ return new SummaryArticle({
+ level: level,
+ title: def.title,
+ ref: def.ref || def.path || '',
+ articles: Immutable.List(articles)
+ });
+};
+
+
+/**
+ Find an article from a base one
+
+ @param {Article|Part} base
+ @param {Function(article)} iter
+ @return {Article}
+*/
+SummaryArticle.findArticle = function(base, iter) {
+ var articles = base.getArticles();
+
+ return articles.reduce(function(result, article) {
+ if (result) return result;
+
+ if (iter(article)) {
+ return article;
+ }
+
+ return SummaryArticle.findArticle(article, iter);
+ }, null);
+};
+
+
+module.exports = SummaryArticle;
diff --git a/lib/models/summaryPart.js b/lib/models/summaryPart.js
new file mode 100644
index 0000000..f7a82ce
--- /dev/null
+++ b/lib/models/summaryPart.js
@@ -0,0 +1,48 @@
+var Immutable = require('immutable');
+
+var SummaryArticle = require('./summaryArticle');
+
+/*
+ A part represents a section in the Summary / table of Contents
+*/
+
+var SummaryPart = Immutable.Record({
+ level: String(),
+ title: String(),
+ articles: Immutable.List()
+});
+
+SummaryPart.prototype.getLevel = function() {
+ return this.get('level');
+};
+
+SummaryPart.prototype.getTitle = function() {
+ return this.get('title');
+};
+
+SummaryPart.prototype.getArticles = function() {
+ return this.get('articles');
+};
+
+/**
+ Create a SummaryPart
+
+ @param {Object} def
+ @return {SummaryPart}
+*/
+SummaryPart.create = function(def, level) {
+ var articles = (def.articles || []).map(function(article, i) {
+ if (article instanceof SummaryArticle) {
+ return article;
+ }
+ return SummaryArticle.create(article, [level, i + 1].join('.'));
+ });
+
+ return new SummaryPart({
+ level: String(level),
+ title: def.title,
+ articles: Immutable.List(articles)
+ });
+};
+
+module.exports = SummaryPart;
diff --git a/lib/models/templateBlock.js b/lib/models/templateBlock.js
new file mode 100644
index 0000000..4e47da7
--- /dev/null
+++ b/lib/models/templateBlock.js
@@ -0,0 +1,310 @@
+var is = require('is');
+var extend = require('extend');
+var Immutable = require('immutable');
+
+var Promise = require('../utils/promise');
+var genKey = require('../utils/genKey');
+
+var NODE_ENDARGS = '%%endargs%%';
+
+var blockBodies = {};
+
+var TemplateBlock = Immutable.Record({
+ // Name of block, also the start tag
+ name: String(),
+
+ // End tag, default to "end<name>"
+ end: String(),
+
+ // Function to process the block content
+ process: Function(),
+
+ // List of String, for inner block tags
+ blocks: Immutable.List(),
+
+ // List of shortcuts to replace with this block
+ shortcuts: Immutable.List(),
+
+ // Function to execute in post processing
+ post: null,
+
+ parse: true
+}, 'TemplateBlock');
+
+TemplateBlock.prototype.getName = function() {
+ return this.get('name');
+};
+
+TemplateBlock.prototype.getPost = function() {
+ return this.get('post');
+};
+
+TemplateBlock.prototype.getParse = function() {
+ return this.get('parse');
+};
+
+TemplateBlock.prototype.getEndTag = function() {
+ return this.get('end') || ('end' + this.getName());
+};
+
+TemplateBlock.prototype.getProcess = function() {
+ return this.get('process');
+};
+
+TemplateBlock.prototype.getBlocks = function() {
+ return this.get('blocks');
+};
+
+TemplateBlock.prototype.getShortcuts = function() {
+ return this.get('shortcuts');
+};
+
+/**
+ Return name for the nunjucks extension
+
+ @return {String}
+*/
+TemplateBlock.prototype.getExtensionName = function() {
+ return 'Block' + this.getName() + 'Extension';
+};
+
+/**
+ Return a nunjucks extension to represents this block
+
+ @return {Nunjucks.Extension}
+*/
+TemplateBlock.prototype.toNunjucksExt = function(mainContext) {
+ var that = this;
+ var name = this.getName();
+ var endTag = this.getEndTag();
+ var blocks = this.getBlocks().toJS();
+
+ function Ext() {
+ this.tags = [name];
+
+ this.parse = function(parser, nodes) {
+ var lastBlockName = null;
+ var lastBlockArgs = null;
+ var allBlocks = blocks.concat([endTag]);
+
+ // Parse first block
+ var tok = parser.nextToken();
+ lastBlockArgs = parser.parseSignature(null, true);
+ parser.advanceAfterBlockEnd(tok.value);
+
+ var args = new nodes.NodeList();
+ var bodies = [];
+ var blockNamesNode = new nodes.Array(tok.lineno, tok.colno);
+ var blockArgCounts = new nodes.Array(tok.lineno, tok.colno);
+
+ // Parse while we found "end<block>"
+ do {
+ // Read body
+ var currentBody = parser.parseUntilBlocks.apply(parser, allBlocks);
+
+ // Handle body with previous block name and args
+ blockNamesNode.addChild(new nodes.Literal(args.lineno, args.colno, lastBlockName));
+ blockArgCounts.addChild(new nodes.Literal(args.lineno, args.colno, lastBlockArgs.children.length));
+ bodies.push(currentBody);
+
+ // Append arguments of this block as arguments of the run function
+ lastBlockArgs.children.forEach(function(child) {
+ args.addChild(child);
+ });
+
+ // Read new block
+ lastBlockName = parser.nextToken().value;
+
+ // Parse signature and move to the end of the block
+ if (lastBlockName != endTag) {
+ lastBlockArgs = parser.parseSignature(null, true);
+ }
+
+ parser.advanceAfterBlockEnd(lastBlockName);
+ } while (lastBlockName != endTag);
+
+ args.addChild(blockNamesNode);
+ args.addChild(blockArgCounts);
+ args.addChild(new nodes.Literal(args.lineno, args.colno, NODE_ENDARGS));
+
+ return new nodes.CallExtensionAsync(this, 'run', args, bodies);
+ };
+
+ this.run = function(context) {
+ var fnArgs = Array.prototype.slice.call(arguments, 1);
+
+ var args;
+ var blocks = [];
+ var bodies = [];
+ var blockNames;
+ var blockArgCounts;
+ var callback;
+
+ // Extract callback
+ callback = fnArgs.pop();
+
+ // Detect end of arguments
+ var endArgIndex = fnArgs.indexOf(NODE_ENDARGS);
+
+ // Extract arguments and bodies
+ args = fnArgs.slice(0, endArgIndex);
+ bodies = fnArgs.slice(endArgIndex + 1);
+
+ // Extract block counts
+ blockArgCounts = args.pop();
+ blockNames = args.pop();
+
+ // Recreate list of blocks
+ blockNames.forEach(function(name, i) {
+ var countArgs = blockArgCounts[i];
+ var blockBody = bodies.shift();
+
+ var blockArgs = countArgs > 0? args.slice(0, countArgs) : [];
+ args = args.slice(countArgs);
+ var blockKwargs = extractKwargs(blockArgs);
+
+ blocks.push({
+ name: name,
+ body: blockBody(),
+ args: blockArgs,
+ kwargs: blockKwargs
+ });
+ });
+
+ var mainBlock = blocks.shift();
+ mainBlock.blocks = blocks;
+
+ Promise()
+ .then(function() {
+ var ctx = extend({
+ ctx: context
+ }, mainContext || {});
+
+ return that.applyBlock(mainBlock, ctx);
+ })
+ .then(function(result) {
+ return that.blockResultToHtml(result);
+ })
+ .nodeify(callback);
+ };
+ };
+
+ return Ext;
+};
+
+/**
+ Apply a block to a content
+ @param {Object} inner
+ @param {Object} context
+ @return {Promise<String>|String}
+*/
+TemplateBlock.prototype.applyBlock = function(inner, context) {
+ var processFn = this.getProcess();
+
+ inner = inner || {};
+ inner.args = inner.args || [];
+ inner.kwargs = inner.kwargs || {};
+ inner.blocks = inner.blocks || [];
+
+ var r = processFn.call(context, inner);
+
+ if (Promise.isPromiseAlike(r)) {
+ return r.then(this.handleBlockResult);
+ } else {
+ return this.handleBlockResult(r);
+ }
+};
+
+/**
+ Handle result from a block process function
+
+ @param {Object} result
+ @return {Object}
+*/
+TemplateBlock.prototype.handleBlockResult = function(result) {
+ if (is.string(result)) {
+ result = { body: result };
+ }
+ result.name = this.getName();
+
+ return result;
+};
+
+/**
+ Convert a block result to HTML
+
+ @param {Object} result
+ @return {String}
+*/
+TemplateBlock.prototype.blockResultToHtml = function(result) {
+ var parse = this.getParse();
+ var indexedKey;
+ var toIndex = (!parse) || (this.getPost() !== undefined);
+
+ if (toIndex) {
+ indexedKey = TemplateBlock.indexBlockResult(result);
+ }
+
+ // Parsable block, just return it
+ if (parse) {
+ return result.body;
+ }
+
+ // Return it as a position marker
+ return '{{-%' + indexedKey + '%-}}';
+
+};
+
+/**
+ Index a block result, and return the indexed key
+
+ @param {Object} blk
+ @return {String}
+*/
+TemplateBlock.indexBlockResult = function(blk) {
+ var key = genKey();
+ blockBodies[key] = blk;
+
+ return key;
+};
+
+/**
+ Get a block results indexed for a specific key
+
+ @param {String} key
+ @return {Object|undefined}
+*/
+TemplateBlock.getBlockResultByKey = function(key) {
+ return blockBodies[key];
+};
+
+/**
+ Create a template block from a function or an object
+
+ @param {String} blockName
+ @param {Object} block
+ @return {TemplateBlock}
+*/
+TemplateBlock.create = function(blockName, block) {
+ if (is.fn(block)) {
+ block = new Immutable.Map({
+ process: block
+ });
+ }
+
+ block = block.set('name', blockName);
+ return new TemplateBlock(block);
+};
+
+/**
+ Extract kwargs from an arguments array
+
+ @param {Array} args
+ @return {Object}
+*/
+function extractKwargs(args) {
+ var last = args[args.length - 1];
+ return (is.object(last) && last.__keywords)? args.pop() : {};
+}
+
+module.exports = TemplateBlock;
diff --git a/lib/models/templateEngine.js b/lib/models/templateEngine.js
new file mode 100644
index 0000000..243bfc6
--- /dev/null
+++ b/lib/models/templateEngine.js
@@ -0,0 +1,139 @@
+var nunjucks = require('nunjucks');
+var Immutable = require('immutable');
+
+var TemplateEngine = Immutable.Record({
+ // Map of {TemplateBlock}
+ blocks: Immutable.Map(),
+
+ // Map of Extension
+ extensions: Immutable.Map(),
+
+ // Map of filters: {String} name -> {Function} fn
+ filters: Immutable.Map(),
+
+ // Map of globals: {String} name -> {Mixed}
+ globals: Immutable.Map(),
+
+ // Context for filters / blocks
+ context: Object(),
+
+ // Nunjucks loader
+ loader: nunjucks.FileSystemLoader('views')
+}, 'TemplateEngine');
+
+TemplateEngine.prototype.getBlocks = function() {
+ return this.get('blocks');
+};
+
+TemplateEngine.prototype.getGlobals = function() {
+ return this.get('globals');
+};
+
+TemplateEngine.prototype.getFilters = function() {
+ return this.get('filters');
+};
+
+TemplateEngine.prototype.getShortcuts = function() {
+ return this.get('shortcuts');
+};
+
+TemplateEngine.prototype.getLoader = function() {
+ return this.get('loader');
+};
+
+TemplateEngine.prototype.getContext = function() {
+ return this.get('context');
+};
+
+TemplateEngine.prototype.getExtensions = function() {
+ return this.get('extensions');
+};
+
+/**
+ Return a block by its name (or undefined)
+
+ @param {String} name
+ @return {TemplateBlock}
+*/
+TemplateEngine.prototype.getBlock = function(name) {
+ var blocks = this.getBlocks();
+ return blocks.find(function(block) {
+ return block.getName() === name;
+ });
+};
+
+/**
+ Return a nunjucks environment from this configuration
+
+ @return {Nunjucks.Environment}
+*/
+TemplateEngine.prototype.toNunjucks = function() {
+ var loader = this.getLoader();
+ var blocks = this.getBlocks();
+ var filters = this.getFilters();
+ var globals = this.getGlobals();
+ var extensions = this.getExtensions();
+ var context = this.getContext();
+
+ var env = new nunjucks.Environment(
+ loader,
+ {
+ // Escaping is done after by the asciidoc/markdown parser
+ autoescape: false,
+
+ // Syntax
+ tags: {
+ blockStart: '{%',
+ blockEnd: '%}',
+ variableStart: '{{',
+ variableEnd: '}}',
+ commentStart: '{###',
+ commentEnd: '###}'
+ }
+ }
+ );
+
+ // Add filters
+ filters.forEach(function(filterFn, filterName) {
+ env.addFilter(filterName, filterFn.bind(context));
+ });
+
+ // Add blocks
+ blocks.forEach(function(block) {
+ var extName = block.getExtensionName();
+ var Ext = block.toNunjucksExt(context);
+
+ env.addExtension(extName, new Ext());
+ });
+
+ // Add globals
+ globals.forEach(function(globalValue, globalName) {
+ env.addGlobal(globalName, globalValue);
+ });
+
+ // Add other extensions
+ extensions.forEach(function(ext, extName) {
+ env.addExtension(extName, ext);
+ });
+
+ return env;
+};
+
+/**
+ Create a template engine
+
+ @param {Object} def
+ @return {TemplateEngine}
+*/
+TemplateEngine.create = function(def) {
+ return new TemplateEngine({
+ blocks: Immutable.List(def.blocks || []),
+ extensions: Immutable.Map(def.extensions || {}),
+ filters: Immutable.Map(def.filters || {}),
+ globals: Immutable.Map(def.globals || {}),
+ context: def.context,
+ loader: def.loader
+ });
+};
+
+module.exports = TemplateEngine;