server.js

/**
 * @author Nguyen Ly <lyphtec@gmail.com>
 * @copyright Nguyen Ly 2014-2024
 * @license MIT License
 *
 * @fileOverview Main object exported by module. Can be used to instantiate a new {@link MarkdownServer} instance or invoked as a [middleware]{@link module:markdown-serve.middleware} function in Express.
 * @module markdown-serve
 * @requires path
 * @requires lodash
 * @requires mkdirp
 * @requires fs
 * @requires markdown-serve/parser
 * @requires markdown-serve/resolver
 * @exports markdown-serve
 */

var path = require('path'),
    resolver = require('./resolver'),
    parser = require('./parser'),
    _ = require('lodash'),
    mkdirp = require('mkdirp'),
    fs = require('fs'),
    logger = null;

exports = module.exports = {
    /**
    * {@link MarkdownServer}
    * @this {MarkdownServer}
    * */
    MarkdownServer: MarkdownServer,

    /**
     * preParse function option used for customizing the view model object that is passed to the view
     * @typedef {function(MarkdownFile):Object} module:markdown-serve~preParseFn
     * @alias preParseFn
     * @param {MarkdownFile} markdownFile Resolved {@link MarkdownFile} instance from `req.path` passed to middleware
     * @returns {Object} Object literal to pass as view model to view
     * @example
     * // preParse is specified as a function
     * app.use(mds.middleware({
     *    rootDirectory: path.resolve(__dirname, 'content'),
     *    view: 'markdown',
     *    preParse: function(markdownFile) {
     *        return { title: markdownFile.meta.title, content: markdownFile.parseContent(), created: moment(markdownFile.created).format('L') };
     *    }
     * }));
     *
     * // views/markdown.hbs
     * <div class="container">
     *    <h1>{{title}}</h1>
     *
     *    {{{content}}}
     *
     *    <footer>
     *        <hr />
     *        <strong>Created:</strong> {{created}}
     *    </footer>
     * </div>
     */

    /**
     * Customized middleware handler function option. Do not specify `options.view` if using this feature.
     * @typedef {function(MarkdownFile, req, res, next)} module:markdown-serve~handlerFn
     * @alias handlerFn
     * @param {MarkdownFile} markdownFile Resolved {@link MarkdownFile} instance from `req.path` passed to middleware
     * @param {Object} req Express Request object
     * @param {Object} res Express Response object
     * @param {function} next Express next() function
     * @example
     * // custom handler
     * app.use(mds.middleware({
     *    rootDirectory: path.resolve(__dirname, 'content'),
     *    handler: function(markdownFile, req, res, next) {
     *        if (req.method !== 'GET') next();
     *
     *        // limit access based on draft variable in front-matter
     *        if (markdownFile.meta.draft && !req.isAuthenticated && !req.user.isAdmin) {
     *            next();
     *            return;   // need return here
     *        }
     *
     *        res.render('markdown', { title: markdownFile.meta.title, content: markdownFile.parseContent() });
     *    }
     * }));
     */

    /**
     * Simple Express middleware that hosts a {@link MarkdownServer}
     * @function
     * @param {Object} options Middleware options
     * @param {string} options.rootDirectory Full path to root physical directory containing Markdown files to serve
     * @param {Object=} options.markedOptions Global marked module options for Markdown processing
     * @param {resolverOptions=} options.resolverOptions [Options]{@link MarkdownServer~resolverOptions} to override default document name and Markdown file extension.
     * If not specified, will default to `index` and `md` resppectively.
     * @param {string=} options.view Name of view to use for rendering content. If this property & `handler` are not specified, a JSON respresentation of
     * {@link MarkdownFile} will be returned by the middleware.
     * @param {(boolean|preParseFn)=} options.preParse  Only applies when `view` is specified. If set to true (not truthy), will
     * make the parsed HTML content available as `markdownFile.parsedContent` on the view model object passed to the view. This is to
     * support some view engines like `hbs` that do not support calling methods directly on the view model.
     *
     * If specified as a [preParseFn]{@link module:markdown-serve~preParseFn} function, will return the function result as the view model object to the view.
     * @param {handlerFn=} options.handler [handlerFn]{@link module:markdown-serve~handlerFn}. Provides full customization of middleware response. Make sure `view` is not
     * set when using this feature as that takes precedence.
     * @param {Object=} options.logger Logger object - should respond to log function, optional replacement for console object
     * @example
     * // basic usage
     * // app.js
     * var express = require('express'),
     *     path = require('path'),
     *     mds = require('markdown-serve');
     *
     * var app = express();
     *
     * app.set('view', path.join(__dirname, 'views'));
     * app.set('view engine', 'pug');
     *
     * app.use(mds.middleware({
     *    rootDirectory: path.resolve(__dirname, 'content'),
     *    view: 'markdown'    // will use views/markdown.pug file for rendering out HTML content
     * }));
     *
     * // views/markdown.pug
     * extend layout
     *
     * block content
     *    header
     *      h1= markdownFile.meta.title
     *
     *    .content
     *      != markdownFile.parseContent()
     *
     *    footer
     *      hr
     *      | <strong>Created:</strong> #{markdownFile.created} <br/>
     *
     * @example
     * // preParse set to true when using hbs view engine
     * // as calling markdownFile.parseContent() is not supported, the HTML content is pre-parsed and available as markdownFile.parsedContent
     * app.set('view engine', 'hbs');
     *
     * app.use(mds.middleware({
     *    rootDirectory: path.resolve(__dirname, 'content'),
     *    view: 'markdown',
     *    preParse: true
     * }));
     *
     * // views/markdown.hbs
     * <div class="container">
     *    <h1>{{markdownFile.meta.title}}</h1>
     *
     *    {{{markdownFile.parsedContent}}}
     *
     *    <footer>
     *        <hr />
     *        <strong>Created:</strong> {{markdownFile.created}}
     *    </footer>
     * </div>
     */
    middleware: function(options) {
        if (!options)
            throw new Error('"options" argument is required');

        if (!options.rootDirectory)
            throw new Error('"rootDirectory" value is required');

        var server = new MarkdownServer(options.rootDirectory);

        if (options.markedOptions)
            server.markedOptions = options.markedOptions;

        if (options.resolverOptions)
            server.resolverOptions = options.resolverOptions;

        if (options.logger)
          logger = options.logger;

        return function(req, res, next) {
            if (req.method !== 'GET' && !options.handler) return next();

            server.get(req.path, function(err, result) {
                if (err) {
                    if (logger) {
                        logger.log(err);
                    } else {
                      console.log(err);
                    }
                    next();
                    return;   // need return here because next call (above) is inside a callback
                }

                // remove _file property as this is potentially a security risk
                delete result._file;

                if (options.view) {
                    if (options.preParse && _.isBoolean(options.preParse) && options.preParse === true) {
                        result.parsedContent = result.parseContent();
                        res.render(options.view, { markdownFile: result });
                        return;
                    }

                    if (options.preParse && _.isFunction(options.preParse)) {
                        var vm = options.preParse(result);
                        res.render(options.view, vm);
                        return;
                    }

                    res.render(options.view, { markdownFile: result });
                    return;
                }

                if (options.handler && _.isFunction(options.handler)) {
                    return options.handler(result, req, res, next);
                }

                // default fallback is to send a JSON response of the MarkdownFile object
                result.parsedContent = result.parseContent();
                res.send(result);
            });
        };
    }
};

/**
 * Options to pass to the resolver indicating default document name and Markdown file extension
 * @typedef {Object} MarkdownServer~resolverOptions
 * @alias resolverOptions
 * @property {string=} [defaultPagename=index] Name of default document
 * @property {string=} [fileExtension=md] File extension of Markdown files
 * @property {boolean=} [useExtensionInUrl=false] If true, the file extension will not be removed from the url when resolving it
 */

/**
 * Create an instance of a Markdown files server. Can be instantiated from the module, and is mainly used in custom middleware scenarios.
 * @class
 * @alias MarkdownServer
 * @param {string} rootDirectory Full path to root directory containing Markdown files to serve
 * @property {string} rootDirectory Gets or sets the full path to root directory containing Markdown files to server. Set to same as rootDirectory parameter when class is instantiated.
 * @property {?Object} markedOptions Gets or sets optional global [marked]{@link https://github.com/chjj/marked} module options used for Markdown processing
 * @property {?resolverOptions} resolverOptions [resolverOptions]{@link MarkdownServer~resolverOptions}. Gets or sets optional global options used by the resolver to configure default page name and file extension of Markdown files.
 *
 * @example
 * var path = require('path'),
 *     mds = require('markdown-serve');
 *
 * // Instantiate a new instance of MarkdownServer
 * var server = new mds.MarkdownServer( path.resolve(__dirname, 'content') );
 */
function MarkdownServer(rootDirectory) {
    if (!rootDirectory || !fs.existsSync(rootDirectory))
        throw new Error('"rootDirectory" not specified or is invalid');

    // clean up path
    this.rootDirectory = path.resolve(rootDirectory);

    this.markedOptions = null;
    this.resolverOptions = {
        defaultPageName: 'index',   // name of default document in each sub folder
        fileExtension: 'md',
        useExtensionInUrl: false
    };
}

/**
 * [MarkdownServer.get()]{@link MarkdownServer#get} callback
 * @callback MarkdownServer~getCallback
 * @alias getCallback
 * @param {?Object} err Errors if any
 * @param {MarkdownFile} result
 */

/**
 * Resolves & returns {@link MarkdownFile} for specified URI path.
 * @function
 * @param {string} uriPath Path (relative to root) with leading / (slash) to Markdown file we want to obtain eg. "/subfolder/file". Note: Do not include ".md" extension.
 * @param {getCallback} callback [getCallback]{@link MarkdownServer~getCallback}
 * @returns {MarkdownFile}
 *
 * @example
 * var server = new mds.MarkdownServer( path.resolve(__dirname, 'content') );
 *
 * server.get('/intro', function(err, result) {
 *    if (err) return err;
 *
 *    // result == MarkdownFile instance
 *    console.log(result.parseContent());   // the parsed HTML result
 * });
 */
MarkdownServer.prototype.get = function(uriPath, callback) {
    var self = this;
    var file = resolver(uriPath, self.rootDirectory, self.resolverOptions);

    if (!file) {
        return callback(new Error('No file found matching path: ' + uriPath));
    }

    return parser.parse(file, self.markedOptions, callback);
};

/**
 * [MarkdownServer.save()]{@link MarkdownServer#save} callback
 * @callback MarkdownServer~saveCallback
 * @alias saveCallback
 * @param {?Object} err Errors if any, otherwise null
 * @param {MarkdownFile} result Saved file returned as a {@link MarkdownFile} object
 */

/**
 * Saves MarkdownFile for given uriPath
 * @function
 * @param {string} uriPath Path (relative to root) with leading / (slash) to Markdown file we want to save eg. "/subfolder/file". Will overwrite existing file or create new file if it doesn't exist. Note: Do not include ".md" extension.
 * @param {string} rawContent Markdown text content to save
 * @param {?Object=} meta Optional Javascript object to serialize as YAML front-matter and saved in file header
 * @param {saveCallback} callback [saveCallback]{@link MarkdownServer~saveCallback}
 * @returns {MarkdownFile}
 *
 * @example
 * var mdContent = '# Heading\n\n' +
 *                 'Bullets:\n\n' +
 *                 '- one\n' +
 *                 '- two\n' +
 *                 '- three\nn';
 *
 * // will create any subfolders in hierarchy if it doesn't exist
 * server.save('/subfolder/new', mdContent, { title: 'New file', draft: true }, function(err, result) {
 *    if (err) return err;
 *
 *    // result == MarkdownFile instance
 *    console.log(result.parseContent());
 * });
 */
MarkdownServer.prototype.save = function(uriPath, rawContent, meta, callback) {
    var self = this;

    // make "meta" param optional
    var hasMeta = true;
    if (arguments.length === 3 && _.isFunction(arguments[2])) {
        callback = arguments[2];
        hasMeta = false;
    }

    if (!rawContent)
        return callback(new Error('rawContent is required'));

    if (!_.isString(rawContent))
        return callback(new Error('rawContent must be a string'));


    var file = resolver(uriPath, self.rootDirectory, self.resolverOptions);

    if (!file) {
        var ext = (self.resolverOptions && self.resolverOptions.fileExtension) ? self.resolverOptions.fileExtension : '.md';
        if (ext.indexOf('.') !== 0)
            ext = '.' + ext;

        var defPageName = (self.resolverOptions && self.resolverOptions.defaultPageName) ? self.resolverOptions.defaultPageName : 'index';

        // append default page name to trailing slashes
        if (uriPath.match(/\/$/))
            uriPath += defPageName;

        file = path.join(self.rootDirectory, uriPath + ext);
    }

    var dir = path.dirname(file);
    if (!fs.existsSync(dir))
        mkdirp.sync(dir);

    var mdFile = new parser.MarkdownFile(file);

    if (hasMeta && meta)
        mdFile.meta = meta;

    mdFile.rawContent = rawContent;

    mdFile.saveChanges(function(err, success) {
        if (err) return callback(err);

        return self.get(uriPath, callback);
    });
};