diff options
Diffstat (limited to 'lib/models')
-rw-r--r-- | lib/models/__tests__/config.js | 63 | ||||
-rw-r--r-- | lib/models/__tests__/glossary.js | 42 | ||||
-rw-r--r-- | lib/models/__tests__/glossaryEntry.js | 17 | ||||
-rw-r--r-- | lib/models/__tests__/plugin.js | 29 | ||||
-rw-r--r-- | lib/models/__tests__/summary.js | 81 | ||||
-rw-r--r-- | lib/models/__tests__/templateBlock.js | 106 | ||||
-rw-r--r-- | lib/models/__tests__/templateEngine.js | 51 | ||||
-rw-r--r-- | lib/models/book.js | 258 | ||||
-rw-r--r-- | lib/models/config.js | 106 | ||||
-rw-r--r-- | lib/models/file.js | 89 | ||||
-rw-r--r-- | lib/models/fs.js | 274 | ||||
-rw-r--r-- | lib/models/glossary.js | 109 | ||||
-rw-r--r-- | lib/models/glossaryEntry.js | 43 | ||||
-rw-r--r-- | lib/models/language.js | 21 | ||||
-rw-r--r-- | lib/models/languages.js | 71 | ||||
-rw-r--r-- | lib/models/output.js | 93 | ||||
-rw-r--r-- | lib/models/page.js | 55 | ||||
-rw-r--r-- | lib/models/plugin.js | 152 | ||||
-rw-r--r-- | lib/models/readme.js | 40 | ||||
-rw-r--r-- | lib/models/summary.js | 190 | ||||
-rw-r--r-- | lib/models/summaryArticle.js | 150 | ||||
-rw-r--r-- | lib/models/summaryPart.js | 48 | ||||
-rw-r--r-- | lib/models/templateBlock.js | 310 | ||||
-rw-r--r-- | lib/models/templateEngine.js | 139 |
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; |