UNPKG

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
// 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;