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.

826 lines (687 loc) 22.3 kB
# ===================================== # Requires # Standard 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 # ===================================== # 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 # --------------------------------- # Properties ###* # The document model class. # @private # @property {Object} klass ### klass: DocumentModel ###* # String name of the model type. # In this case, 'document'. # @private # @property {String} type ### type: 'document' # --------------------------------- # Attributes ###* # The default attributes for any document model. # @private # @property {Object} ### defaults: extendr.extend({}, FileModel::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 }) # --------------------------------- # 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: -> content = @get('contentRendered') or @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) -> flag ?= true @set({referencesOthers:flag}) @ # --------------------------------- # Actions ###* # Parse our buffer and extract meaningful data from it. # next(err). # @method parse # @param {Object} [opts] # @param {Object} next callback ### parse: (opts,next) -> # Prepare [opts,next] = extractOptsAndCallback(opts,next) buffer = @getBuffer() locale = @getLocale() filePath = @getFilePath() # Reparse the data and extract the content # With the content, fetch the new meta data, header, and body super opts, => # Prepare meta = @getMeta() metaDataChanges = {} parser = header = body = content = null # Parse {header, body, content, parser} = docmatter(@get('content')) body = body?.trim() content = content.trim() # Parse if body parser or= 'yaml' switch parser when 'cson', 'json', 'coffee', 'coffeescript', 'coffee-script', 'js', 'javascript' switch parser when 'coffee', 'coffeescript', 'coffee-script' parser = 'coffeescript' when 'js', 'javascript' parser = 'javascript' csonOptions = format: parser json: true cson: true coffeescript: true javascript: true try metaParseResult = CSON.parseString(header, csonOptions) unless metaParseResult instanceof Error extendr.extend(metaDataChanges, metaParseResult) catch err metaParseResult = err # wrapped later when 'yaml' YAML = require('yamljs') unless YAML try metaParseResult = YAML.parse( header.replace(/\t/g,' ') # YAML doesn't support tabs that well ) extendr.extend(metaDataChanges, metaParseResult) catch err metaParseResult = err # wrapped later else 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 and metaDataChanges.encoding isnt @get('encoding') @set({ encoding: metaDataChanges.encoding }) opts.reencode = true return @parse(opts, next) # Update meta data body = body.replace(/^\n+/,'') @set( source: content content: body header: header body: body parser: parser name: @get('name') or @get('title') or @get('basename') ) # Correct data format metaDataChanges.date = new Date(metaDataChanges.date) if metaDataChanges.date # Correct ignore for key in ['ignore','skip','draft'] if metaDataChanges[key]? metaDataChanges.ignored = (metaDataChanges[key] ? false) delete metaDataChanges[key] for key in ['published'] if metaDataChanges[key]? metaDataChanges.ignored = !(metaDataChanges[key] ? false) delete metaDataChanges[key] # Handle urls @addUrl(metaDataChanges.urls) if metaDataChanges.urls @setUrl(metaDataChanges.url) if metaDataChanges.url # Check if the id was being over-written if metaDataChanges.id? @log 'warn', util.format(locale.documentIdChangeError, filePath) delete metaDataChanges.id # Apply meta data @setMeta(metaDataChanges) # Next return next() # Chain @ ###* # 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) -> # Prepare [opts,next] = extractOptsAndCallback(opts,next) changes = {} meta = @getMeta() # Extract outExtension = opts.outExtension or meta.get('outExtension') or null filename = opts.filename or @get('filename') or null extensions = @getExtensions({filename}) or null # Extension Rendered if !outExtension changes.outExtension = outExtension = extensions[0] or null # Forward super(extendr.extend(opts, changes), next) # Chain @ ###* # 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 @getEve (err,eve) => # Prepare return next(err) if err changes = {} meta = @getMeta() # User specified outFilename = opts.outFilename or meta.get('outFilename') or null outPath = opts.outPath or meta.get('outPath') or null outExtension = opts.outExtension or meta.get('outExtension') or null extensions = @getExtensions({filename:outFilename}) or null # outExtension if !outExtension if !outFilename and !outPath if eve? changes.outExtension = outExtension = eve.get('outExtension') or extensions[0] or null else changes.outExtension = extensions[0] or null # Forward onto normalize to adjust for the outExtension change return @normalize(extendr.extend(opts, changes), next) # Chain @ # --------------------------------- # Layouts ###* # Checks if the file has a layout. # @method hasLayout # @return {Boolean} ### hasLayout: -> return @get('layout')? # 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) -> # Prepare file = @ layoutSelector = @get('layout') # Check return next(null, null) unless layoutSelector # Find parent @emit 'getLayout', {selector:layoutSelector}, (err,opts) -> # Prepare {layout} = opts # Error if err file.set('layoutRelativePath': null) return next(err) # Not Found else unless layout file.set('layoutRelativePath': null) return next() # Found else file.set('layoutRelativePath': layout.get('relativePath')) return next(null, layout) # Chain @ ###* # 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 @hasLayout() @getLayout (err,layout) -> if err return next(err, null) else if layout layout.getEve(next) else next(null, null) else next(null, @) @ # --------------------------------- # 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) -> # Prepare file = @ locale = @getLocale() [opts,next] = extractOptsAndCallback(opts, next) {content,templateData,renderSingleExtensions} = opts extensions = @get('extensions') filename = @get('filename') filePath = @getFilePath() content ?= @get('body') templateData ?= {} renderSingleExtensions ?= @get('renderSingleExtensions') # Prepare result result = content # Prepare extensions extensionsReversed = [] if extensions.length is 0 and filename extensionsReversed.push(filename) for extension in extensions extensionsReversed.unshift(extension) # If we want to allow rendering of single extensions, then add null to the extension list if renderSingleExtensions and extensionsReversed.length is 1 if renderSingleExtensions isnt 'auto' or filename.replace(/^\./,'') is extensionsReversed[0] extensionsReversed.push(null) # If we only have one extension, then skip ahead to rendering layouts return next(null, result) if extensionsReversed.length <= 1 # Prepare the tasks tasks = @createTaskGroup "renderExtensions: #{filePath}", next:(err) -> # Forward with result return next(err, result) # Cycle through all the extension groups and render them eachr extensionsReversed[1..], (extension,index) -> # Task tasks.addTask "renderExtension: #{filePath} [#{extensionsReversed[index]} => #{extension}]", (complete) -> # 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 file.trigger 'render', eventData, (err) -> # Check return complete(err) if 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 is 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() # Chain @ ###* # 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) -> # Prepare file = @ [opts,next] = extractOptsAndCallback(opts, next) {content,templateData} = opts extension = @get('extensions')[0] content ?= @get('body') templateData ?= {} # Prepare event data eventData = {extension,templateData,file,content} # Render via plugins file.trigger 'renderDocument', eventData, (err) -> # Forward return next(err, eventData.content) # Chain @ ###* # 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) -> # Prepare file = @ locale = @getLocale() filePath = @getFilePath() [opts,next] = extractOptsAndCallback(opts, next) {content,templateData} = opts content ?= @get('body') templateData ?= {} # Grab the layout file.getLayout (err, layout) -> # Check return next(err, content) if err # 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 layout.clone().action 'render', {templateData}, (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) # We never had a layout else 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) -> # Prepare [opts,next] = extractOptsAndCallback(opts, next) file = @ locale = @getLocale() # Prepare variables contentRenderedWithoutLayouts = null filePath = @getFilePath() relativePath = file.get('relativePath') # Options opts = extendr.clone(opts or {}) opts.actions ?= ['renderExtensions', 'renderDocument', 'renderLayouts'] if opts.apply? err = new Errlop(locale.documentApplyError) return next(err) # Prepare content opts.content ?= file.get('body') # Prepare templateData opts.templateData = extendr.clone(opts.templateData or {}) # deepClone may be more suitable opts.templateData.document ?= file.toJSON() opts.templateData.documentModel ?= file # Ensure template helpers are bound correctly for own key, value of opts.templateData if value?.bind is Function::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 = @createTaskGroup "render tasks for: #{relativePath}", next:(groupError) -> # Error? if groupError err = new Errlop( util.format(locale.documentRenderError, filePath), groupError ) return next(err, opts.content, file) # Attributes contentRendered = opts.content 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 'renderExtensions' in opts.actions tasks.addTask "renderExtensions: #{relativePath}", (complete) -> file.renderExtensions opts, (err,result) -> # Check return complete(err) if err # Apply the result opts.content = result # Done return complete() # Render Document Task if 'renderDocument' in opts.actions tasks.addTask "renderDocument: #{relativePath}", (complete) -> file.renderDocument opts, (err,result) -> # Check return complete(err) if err # Apply the result opts.content = result contentRenderedWithoutLayouts = result # Done return complete() # Render Layouts Task if 'renderLayouts' in opts.actions tasks.addTask "renderLayouts: #{relativePath}", (complete) -> file.renderLayouts opts, (err,result) -> # Check return complete(err) if err # Apply the result opts.content = result # Done return complete() # Fire the tasks tasks.run() # Chain @ # --------------------------------- # 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) -> # Prepare [opts,next] = extractOptsAndCallback(opts, next) file = @ filePath = @getFilePath() # Fetch opts.content ?= (@getContent() or '').toString('') # Adjust metaData = @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 or header is '{}' # 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(opts, next) # Chain @ # ===================================== # Export module.exports = DocumentModel