docpad
Version:
DocPad is a dynamic static site generator. Write your content as files, or import your content from other external sources. Render the content with plugins. And deploy your static or dynamic website to your favourite hosting provider.
886 lines (846 loc) • 29.9 kB
JavaScript
// Generated by CoffeeScript 2.5.1
// =====================================
// Requires
// Standard
var CSON, DocumentModel, Errlop, FileModel, YAML, docmatter, docpadUtil, eachr, extendr, extractOptsAndCallback, pathUtil, util,
hasProp = {}.hasOwnProperty,
indexOf = [].indexOf;
util = require('util');
pathUtil = require('path');
docpadUtil = require('../util');
// External
Errlop = require('errlop').default;
CSON = require('cson');
extendr = require('extendr');
eachr = require('eachr');
extractOptsAndCallback = require('extract-opts');
docmatter = require('docmatter');
// Local
FileModel = require('./file');
// Optional
YAML = null;
DocumentModel = (function() {
// =====================================
// Classes
/**
* The DocumentModel class is DocPad's representation
* of a website or project's content files. This can be
* individual web pages or blog posts etc. Generally, this
* is not other website files such as css files, images, or scripts -
* unless there is a requirement to have DocPad do transformation on
* these files.
* Extends the DocPad FileModel class
* DocumentModel primarily handles the rendering and parsing of document files.
* This includes merging the document with layouts and managing the rendering
* from one file extension to another. The class inherits many of the file
* specific operations and DocPad specific attributes from the FileModel class.
* However, it also overrides some parsing and file output operations contained
* in the FileModel class.
*
* Typically we do not need to create DocumentModels ourselves as DocPad handles
* all of that. Most of the time when we encounter DocumentModels is when
* querying DocPad's document collections either in the docpad.coffee file or
* from within a template.
*
* indexDoc = @getCollection('documents').findOne({relativeOutPath: 'index.html'})
*
* A plugin, however, may need to create a DocumentModel depending on its requirements.
* In such a case it is wise to use the built in DocPad methods to do so, in particular
* docpad.createModel
*
* #check to see if the document alread exists ie its an update
* docModel = @docpad.getCollection('posts').findOne({slug: 'some-slug'})
*
* #if so, load the existing document ready for regeneration
* if docModel
* docModel.load()
* else
* #if document doesn't already exist, create it and add to database
* docModel = @docpad.createModel({fullPath:'file/path/to/somewhere'})
* docModel.load()
* @docpad.getDatabase().add(docModel)
*
* @class DocumentModel
* @constructor
* @extends FileModel
*/
class DocumentModel extends FileModel {
// ---------------------------------
// Helpers
/**
* Get the file content for output. This
* will be the text content AFTER it has
* been through the rendering process. If
* this has been called before the rendering
* process, then the raw text content will be returned,
* or, if early enough in the process, the file buffer object.
* @method getOutContent
* @return {String or Object}
*/
getOutContent() {
var content;
content = this.get('contentRendered') || this.getContent();
return content;
}
/**
* Set flag to indicate if the document
* contains references to other documents.
* Used in the rendering process to decide
* on whether to render this document when
* another document is updated.
* @method referencesOthers
* @param {Boolean} [flag=true]
*/
referencesOthers(flag) {
if (flag == null) {
flag = true;
}
this.set({
referencesOthers: flag
});
return this;
}
// ---------------------------------
// Actions
/**
* Parse our buffer and extract meaningful data from it.
* next(err).
* @method parse
* @param {Object} [opts]
* @param {Object} next callback
*/
parse(opts, next) {
var buffer, filePath, locale;
// Prepare
[opts, next] = extractOptsAndCallback(opts, next);
buffer = this.getBuffer();
locale = this.getLocale();
filePath = this.getFilePath();
// Reparse the data and extract the content
// With the content, fetch the new meta data, header, and body
super.parse(opts, () => {
var body, content, csonOptions, err, header, i, j, key, len, len1, meta, metaDataChanges, metaParseResult, parser, ref, ref1, ref2, ref3;
// Prepare
meta = this.getMeta();
metaDataChanges = {};
parser = header = body = content = null;
// Parse
({header, body, content, parser} = docmatter(this.get('content')));
body = body != null ? body.trim() : void 0;
content = content.trim();
// Parse
if (body) {
parser || (parser = 'yaml');
switch (parser) {
case 'cson':
case 'json':
case 'coffee':
case 'coffeescript':
case 'coffee-script':
case 'js':
case 'javascript':
switch (parser) {
case 'coffee':
case 'coffeescript':
case 'coffee-script':
parser = 'coffeescript';
break;
case 'js':
case 'javascript':
parser = 'javascript';
}
csonOptions = {
format: parser,
json: true,
cson: true,
coffeescript: true,
javascript: true
};
try {
metaParseResult = CSON.parseString(header, csonOptions);
if (!(metaParseResult instanceof Error)) {
extendr.extend(metaDataChanges, metaParseResult);
}
} catch (error) {
err = error;
metaParseResult = err; // wrapped later
}
break;
case 'yaml':
if (!YAML) {
YAML = require('yamljs');
}
try {
metaParseResult = YAML.parse(header.replace(/\t/g, ' ')); // YAML doesn't support tabs that well
extendr.extend(metaDataChanges, metaParseResult);
} catch (error) {
err = error;
metaParseResult = err; // wrapped later
}
break;
default:
err = new Errlop(util.format(locale.documentMissingParserError, parser, filePath));
return next(err);
}
} else {
body = content;
}
// Check for error
if (metaParseResult instanceof Error) {
err = new Errlop(util.format(locale.documentParserError, parser, filePath), metaParseResult);
return next(err);
}
// Incorrect encoding detection?
// If so, re-parse with the correct encoding conversion
if (metaDataChanges.encoding && metaDataChanges.encoding !== this.get('encoding')) {
this.set({
encoding: metaDataChanges.encoding
});
opts.reencode = true;
return this.parse(opts, next);
}
// Update meta data
body = body.replace(/^\n+/, '');
this.set({
source: content,
content: body,
header: header,
body: body,
parser: parser,
name: this.get('name') || this.get('title') || this.get('basename')
});
if (metaDataChanges.date) {
// Correct data format
metaDataChanges.date = new Date(metaDataChanges.date);
}
ref = ['ignore', 'skip', 'draft'];
// Correct ignore
for (i = 0, len = ref.length; i < len; i++) {
key = ref[i];
if (metaDataChanges[key] != null) {
metaDataChanges.ignored = (ref1 = metaDataChanges[key]) != null ? ref1 : false;
delete metaDataChanges[key];
}
}
ref2 = ['published'];
for (j = 0, len1 = ref2.length; j < len1; j++) {
key = ref2[j];
if (metaDataChanges[key] != null) {
metaDataChanges.ignored = !((ref3 = metaDataChanges[key]) != null ? ref3 : false);
delete metaDataChanges[key];
}
}
if (metaDataChanges.urls) {
// Handle urls
this.addUrl(metaDataChanges.urls);
}
if (metaDataChanges.url) {
this.setUrl(metaDataChanges.url);
}
// Check if the id was being over-written
if (metaDataChanges.id != null) {
this.log('warn', util.format(locale.documentIdChangeError, filePath));
delete metaDataChanges.id;
}
// Apply meta data
this.setMeta(metaDataChanges);
// Next
return next();
});
return this;
}
/**
* Normalize any parsing we have done, because if a value has
* updates it may have consequences on another value.
* This will ensure everything is okay.
* next(err)
* @method normalize
* @param {Object} [opts]
* @param {Object} next callback
*/
normalize(opts, next) {
var changes, extensions, filename, meta, outExtension;
// Prepare
[opts, next] = extractOptsAndCallback(opts, next);
changes = {};
meta = this.getMeta();
// Extract
outExtension = opts.outExtension || meta.get('outExtension') || null;
filename = opts.filename || this.get('filename') || null;
extensions = this.getExtensions({filename}) || null;
if (!outExtension) {
changes.outExtension = outExtension = extensions[0] || null;
}
// Forward
super.normalize(extendr.extend(opts, changes), next);
return this;
}
/**
* Contextualize the data. In other words,
* put our data into the perspective of the bigger picture of the data.
* For instance, generate the url for it's rendered equivalant.
* next(err)
* @method contextualize
* @param {Object} [opts]
* @param {Object} next callback
*/
contextualize(opts, next) {
// Prepare
[opts, next] = extractOptsAndCallback(opts, next);
// Get our highest ancestor
this.getEve((err, eve) => {
var changes, extensions, meta, outExtension, outFilename, outPath;
if (err) {
// Prepare
return next(err);
}
changes = {};
meta = this.getMeta();
// User specified
outFilename = opts.outFilename || meta.get('outFilename') || null;
outPath = opts.outPath || meta.get('outPath') || null;
outExtension = opts.outExtension || meta.get('outExtension') || null;
extensions = this.getExtensions({
filename: outFilename
}) || null;
if (!outExtension) {
if (!outFilename && !outPath) {
if (eve != null) {
changes.outExtension = outExtension = eve.get('outExtension') || extensions[0] || null;
} else {
changes.outExtension = extensions[0] || null;
}
}
}
// Forward onto normalize to adjust for the outExtension change
return this.normalize(extendr.extend(opts, changes), next);
});
return this;
}
// ---------------------------------
// Layouts
/**
* Checks if the file has a layout.
* @method hasLayout
* @return {Boolean}
*/
hasLayout() {
return this.get('layout') != null;
}
// Get Layout
/**
* Get the layout object that this file references (if any).
* We update the layoutRelativePath as it is
* used for finding what documents are used by a
* layout for when a layout changes.
* next(err, layout)
* @method getLayout
* @param {Function} next callback
*/
getLayout(next) {
var file, layoutSelector;
// Prepare
file = this;
layoutSelector = this.get('layout');
if (!layoutSelector) {
// Check
return next(null, null);
}
// Find parent
this.emit('getLayout', {
selector: layoutSelector
}, function(err, opts) {
var layout;
// Prepare
({layout} = opts);
// Error
if (err) {
file.set({
'layoutRelativePath': null
});
return next(err);
// Not Found
} else if (!layout) {
file.set({
'layoutRelativePath': null
});
return next();
} else {
// Found
file.set({
'layoutRelativePath': layout.get('relativePath')
});
return next(null, layout);
}
});
return this;
}
/**
* Get the most ancestoral (root) layout we
* have - ie, the very top one. Often this
* will be the base or default layout for
* a project. The layout where the head and other
* html on all pages is defined. In some projects,
* however, there may be more than one root layout
* so we can't assume there will always only be one.
* This is used by the contextualize method to determine
* the output extension of the document. In other words
* the document's final output extension is determined by
* the root layout.
* next(err,layout)
* @method getEve
* @param {Function} next
*/
getEve(next) {
if (this.hasLayout()) {
this.getLayout(function(err, layout) {
if (err) {
return next(err, null);
} else if (layout) {
return layout.getEve(next);
} else {
return next(null, null);
}
});
} else {
next(null, this);
}
return this;
}
// ---------------------------------
// Rendering
/**
* Renders one extension to another depending
* on the document model's extensions property.
* Triggers the render event for each extension conversion.
* This is the point where the various templating systems listen
* for their extension and perform their conversions.
* Common extension conversion is from md to html.
* So the document source file maybe index.md.html.
* This will be a markdown file to be converted to HTML.
* However, documents can be rendered through more than
* one conversion. Index.html.md.eco will be rendered from
* eco to md and then from md to html. Two conversions.
* next(err,result)
* @private
* @method renderExtensions
* @param {Object} opts
* @param {Function} next callback
*/
renderExtensions(opts, next) {
var content, extension, extensions, extensionsReversed, file, filePath, filename, i, len, locale, renderSingleExtensions, result, tasks, templateData;
// Prepare
file = this;
locale = this.getLocale();
[opts, next] = extractOptsAndCallback(opts, next);
({content, templateData, renderSingleExtensions} = opts);
extensions = this.get('extensions');
filename = this.get('filename');
filePath = this.getFilePath();
if (content == null) {
content = this.get('body');
}
if (templateData == null) {
templateData = {};
}
if (renderSingleExtensions == null) {
renderSingleExtensions = this.get('renderSingleExtensions');
}
// Prepare result
result = content;
// Prepare extensions
extensionsReversed = [];
if (extensions.length === 0 && filename) {
extensionsReversed.push(filename);
}
for (i = 0, len = extensions.length; i < len; i++) {
extension = extensions[i];
extensionsReversed.unshift(extension);
}
// If we want to allow rendering of single extensions, then add null to the extension list
if (renderSingleExtensions && extensionsReversed.length === 1) {
if (renderSingleExtensions !== 'auto' || filename.replace(/^\./, '') === extensionsReversed[0]) {
extensionsReversed.push(null);
}
}
if (extensionsReversed.length <= 1) {
// If we only have one extension, then skip ahead to rendering layouts
return next(null, result);
}
// Prepare the tasks
tasks = this.createTaskGroup(`renderExtensions: ${filePath}`, {
next: function(err) {
// Forward with result
return next(err, result);
}
});
// Cycle through all the extension groups and render them
eachr(extensionsReversed.slice(1), function(extension, index) {
// Task
return tasks.addTask(`renderExtension: ${filePath} [${extensionsReversed[index]} => ${extension}]`, function(complete) {
var eventData;
// Prepare
// eventData must be defined in the task
// definining it in the above loop will cause eventData to persist between the tasks... very strange, but it happens
// will cause the jade tests to fail
eventData = {
inExtension: extensionsReversed[index],
outExtension: extension,
templateData: templateData,
file: file,
content: result
};
// Prepare result for the later check
result = eventData.content;
// Render
return file.trigger('render', eventData, function(err) {
if (err) {
// Check
return complete(err);
}
// Check if the render did anything
// and only check if we actually have content to render!
// if this check fails, error with a suggestion
if (result === eventData.content) {
file.log('warn', util.format(locale.documentRenderExtensionNoChange, eventData.inExtension, eventData.outExtension, filePath));
return complete();
}
// The render did something, so apply and continue
result = eventData.content;
return complete();
});
});
});
// Run tasks synchronously
tasks.run();
return this;
}
/**
* Triggers the renderDocument event after
* all extensions have been rendered. Listeners
* can use this event to perform transformations
* on the already rendered content.
* @private
* @method renderDocument
* @param {Object} opts
* @param {Function} next callback
*/
renderDocument(opts, next) {
var content, eventData, extension, file, templateData;
// Prepare
file = this;
[opts, next] = extractOptsAndCallback(opts, next);
({content, templateData} = opts);
extension = this.get('extensions')[0];
if (content == null) {
content = this.get('body');
}
if (templateData == null) {
templateData = {};
}
// Prepare event data
eventData = {extension, templateData, file, content};
// Render via plugins
file.trigger('renderDocument', eventData, function(err) {
// Forward
return next(err, eventData.content);
});
return this;
}
/**
* Render and merge layout content. Merge
* layout metadata with document metadata.
* Return the resulting merged content to
* the callback result parameter.
* next(err,result)
* @private
* @method renderLayouts
* @param {Object} opts
* @param {Function} next callback
*/
renderLayouts(opts, next) {
var content, file, filePath, locale, templateData;
// Prepare
file = this;
locale = this.getLocale();
filePath = this.getFilePath();
[opts, next] = extractOptsAndCallback(opts, next);
({content, templateData} = opts);
if (content == null) {
content = this.get('body');
}
if (templateData == null) {
templateData = {};
}
// Grab the layout
return file.getLayout(function(err, layout) {
var layoutSelector;
if (err) {
// Check
return next(err, content);
}
// We have a layout to render
if (layout) {
// Assign the current rendering to the templateData.content
templateData.content = content;
// Merge in the layout meta data into the document JSON
// and make the result available via documentMerged
// templateData.document.metaMerged = extendr.extend({}, layout.getMeta().toJSON(), file.getMeta().toJSON())
// Render the layout with the templateData
return layout.clone().action('render', {templateData}, function(err, result) {
return next(err, result);
});
// We had a layout, but it is missing
} else if (file.hasLayout()) {
layoutSelector = file.get('layout');
err = new Errlop(util.format(locale.documentMissingLayoutError, layoutSelector, filePath));
return next(err, content);
} else {
// We never had a layout
return next(null, content);
}
});
}
/**
* Triggers the render process for this document.
* Calls the renderExtensions, renderDocument and
* renderLayouts methods in sequence. This is the
* method you want to call if you want to trigger
* the rendering of a document manually.
*
* The rendered content is returned as the result
* parameter to the passed callback and the DocumentModel
* instance is returned in the document parameter.
* next(err,result,document)
* @method render
* @param {Object} [opts]
* @param {Function} next callback
*/
render(opts, next) {
var base, base1, contentRenderedWithoutLayouts, err, file, filePath, key, locale, ref, relativePath, tasks, value;
// Prepare
[opts, next] = extractOptsAndCallback(opts, next);
file = this;
locale = this.getLocale();
// Prepare variables
contentRenderedWithoutLayouts = null;
filePath = this.getFilePath();
relativePath = file.get('relativePath');
// Options
opts = extendr.clone(opts || {});
if (opts.actions == null) {
opts.actions = ['renderExtensions', 'renderDocument', 'renderLayouts'];
}
if (opts.apply != null) {
err = new Errlop(locale.documentApplyError);
return next(err);
}
// Prepare content
if (opts.content == null) {
opts.content = file.get('body');
}
// Prepare templateData
opts.templateData = extendr.clone(opts.templateData || {});
if ((base = opts.templateData).document == null) {
base.document = file.toJSON();
}
if ((base1 = opts.templateData).documentModel == null) {
base1.documentModel = file;
}
ref = opts.templateData;
for (key in ref) {
if (!hasProp.call(ref, key)) continue;
value = ref[key];
if ((value != null ? value.bind : void 0) === Function.prototype.bind) { // we do this style of check, as underscore is a function that has it's own bind
opts.templateData[key] = value.bind(opts.templateData);
}
}
// Prepare result
// file.set({contentRendered:null, contentRenderedWithoutLayouts:null, rendered:false})
// Log
file.log('debug', util.format(locale.documentRender, filePath));
// Prepare the tasks
tasks = this.createTaskGroup(`render tasks for: ${relativePath}`, {
next: function(groupError) {
var contentRendered, rendered;
// Error?
if (groupError) {
err = new Errlop(util.format(locale.documentRenderError, filePath), groupError);
return next(err, opts.content, file);
}
// Attributes
contentRendered = opts.content;
if (contentRenderedWithoutLayouts == null) {
contentRenderedWithoutLayouts = contentRendered;
}
rendered = true;
file.set({contentRendered, contentRenderedWithoutLayouts, rendered});
// Log
file.log('debug', util.format(locale.documentRendered, filePath));
// Apply
file.attributes.rtime = new Date();
// Success
return next(null, opts.content, file);
}
});
// ^ do not use super here, even with =>
// as it causes layout rendering to fail
// the reasoning for this is that super uses the document's contentRendered
// where, with layouts, opts.apply is false
// so that isn't set
// Render Extensions Task
if (indexOf.call(opts.actions, 'renderExtensions') >= 0) {
tasks.addTask(`renderExtensions: ${relativePath}`, function(complete) {
return file.renderExtensions(opts, function(err, result) {
if (err) {
// Check
return complete(err);
}
// Apply the result
opts.content = result;
// Done
return complete();
});
});
}
// Render Document Task
if (indexOf.call(opts.actions, 'renderDocument') >= 0) {
tasks.addTask(`renderDocument: ${relativePath}`, function(complete) {
return file.renderDocument(opts, function(err, result) {
if (err) {
// Check
return complete(err);
}
// Apply the result
opts.content = result;
contentRenderedWithoutLayouts = result;
// Done
return complete();
});
});
}
// Render Layouts Task
if (indexOf.call(opts.actions, 'renderLayouts') >= 0) {
tasks.addTask(`renderLayouts: ${relativePath}`, function(complete) {
return file.renderLayouts(opts, function(err, result) {
if (err) {
// Check
return complete(err);
}
// Apply the result
opts.content = result;
// Done
return complete();
});
});
}
// Fire the tasks
tasks.run();
return this;
}
// ---------------------------------
// CRUD
/**
* Write the source file. Optionally pass
* the opts parameter to modify or set the file's
* path, content or type.
* next(err)
* @method writeSource
* @param {Object} [opts]
* @param {Object} next callback
*/
writeSource(opts, next) {
var body, content, err, file, filePath, header, metaData, parser, seperator, source;
// Prepare
[opts, next] = extractOptsAndCallback(opts, next);
file = this;
filePath = this.getFilePath();
// Fetch
if (opts.content == null) {
opts.content = (this.getContent() || '').toString('');
}
// Adjust
metaData = this.getMeta().toJSON(true);
delete metaData.writeSource;
content = body = opts.content.replace(/^\s+/, '');
header = CSON.stringify(metaData);
if (header instanceof Error) {
err = new Errlop(`Failed to write CSON meta header for the file: ${filePath}`, header);
return next(err);
}
if (!header || header === '{}') {
// No meta data
source = body;
} else {
// Has meta data
parser = 'cson';
seperator = '###';
source = `${seperator} ${parser}\n${header}\n${seperator}\n\n${body}`;
}
// Apply
// @set({parser,header,body,content,source})
// ^ commented out as we probably don't need to do this, it could be handled on the next load
opts.content = source;
// Write data
super.writeSource(opts, next);
return this;
}
};
// ---------------------------------
// Properties
/**
* The document model class.
* @private
* @property {Object} klass
*/
DocumentModel.prototype.klass = DocumentModel;
/**
* String name of the model type.
* In this case, 'document'.
* @private
* @property {String} type
*/
DocumentModel.prototype.type = 'document';
// ---------------------------------
// Attributes
/**
* The default attributes for any document model.
* @private
* @property {Object}
*/
DocumentModel.prototype.defaults = extendr.extend({}, FileModel.prototype.defaults, {
// ---------------------------------
// Special variables
// outExtension
// The final extension used for our file
// Takes into accounts layouts
// "layout.html", "post.md.eco" -> "html"
// already defined in file.coffee
// Whether or not we reference other doucments
referencesOthers: false,
// ---------------------------------
// Content variables
// The file meta data (header) in string format before it has been parsed
header: null,
// The parser to use for the file's meta data (header)
parser: null,
// The file content (body) before rendering, excludes the meta data (header)
body: null,
// Have we been rendered yet?
rendered: false,
// The rendered content (after it has been wrapped in the layouts)
contentRendered: null,
// The rendered content (before being passed through the layouts)
contentRenderedWithoutLayouts: null,
// ---------------------------------
// User set variables
// Whether or not we should render this file
render: true,
// Whether or not we want to render single extensions
renderSingleExtensions: false
});
return DocumentModel;
}).call(this);
// =====================================
// Export
module.exports = DocumentModel;