diff --git a/lib/hexo/post.js b/lib/hexo/post.js index 3a0075ef3d..210622e9f1 100644 --- a/lib/hexo/post.js +++ b/lib/hexo/post.js @@ -4,92 +4,56 @@ const assert = require('assert'); const moment = require('moment'); const Promise = require('bluebird'); const { join, extname } = require('path'); -const chalk = require('chalk'); -const yaml = require('js-yaml'); +const { magenta } = require('chalk'); +const { load } = require('js-yaml'); const { slugize, escapeRegExp } = require('hexo-util'); -const fs = require('hexo-fs'); +const { copyDir, exists, listDir, mkdirs, readFile, rmdir, unlink, writeFile } = require('hexo-fs'); const yfm = require('hexo-front-matter'); const preservedKeys = ['title', 'slug', 'path', 'layout', 'date', 'content']; -function PostRenderCache() { - this.cache = []; -} - const _escapeContent = (cache, str) => { const placeholder = '\uFFFC'; return ``; }; -PostRenderCache.prototype.escapeContent = function(str) { - const rEscapeContent = /]*)>([\s\S]*?)<\/escape>/g; - return str.replace(rEscapeContent, (_, content) => _escapeContent(this.cache, content)); -}; - -PostRenderCache.prototype.loadContent = function(str) { - const rPlaceholder = /(?:<|<)!--\uFFFC(\d+)--(?:>|>)/g; - const restored = str.replace(rPlaceholder, (_, index) => { - assert(this.cache[index]); - const value = this.cache[index]; - this.cache[index] = null; - return value; - }); - if (restored === str) return restored; - return this.loadContent(restored); // self-recursive for nexted escaping -}; - -PostRenderCache.prototype.escapeAllSwigTags = function(str) { - const rSwigVar = /\{\{[\s\S]*?\}\}/g; - const rSwigComment = /\{#[\s\S]*?#\}/g; - const rSwigBlock = /\{%[\s\S]*?%\}/g; - const rSwigFullBlock = /\{% *(.+?)(?: *| +.*)%\}[\s\S]+?\{% *end\1 *%\}/g; - - const escape = _str => _escapeContent(this.cache, _str); - return str.replace(rSwigFullBlock, escape) - .replace(rSwigBlock, escape) - .replace(rSwigComment, '') - .replace(rSwigVar, escape); -}; - -function Post(context) { - this.context = context; -} - -Post.prototype.create = function(data, replace, callback) { - if (!callback && typeof replace === 'function') { - callback = replace; - replace = false; +class PostRenderCache { + constructor() { + this.cache = []; } - const ctx = this.context; - const { config } = ctx; - - data.slug = slugize((data.slug || data.title).toString(), {transform: config.filename_case}); - data.layout = (data.layout || config.default_layout).toLowerCase(); - data.date = data.date ? moment(data.date) : moment(); + escapeContent(str) { + const rEscapeContent = /]*)>([\s\S]*?)<\/escape>/g; + return str.replace(rEscapeContent, (_, content) => _escapeContent(this.cache, content)); + } - return Promise.all([ - // Get the post path - ctx.execFilter('new_post_path', data, { - args: [replace], - context: ctx - }), - this._renderScaffold(data) - ]).spread((path, content) => { - const result = { path, content }; + loadContent(str) { + const rPlaceholder = /(?:<|<)!--\uFFFC(\d+)--(?:>|>)/g; + const restored = str.replace(rPlaceholder, (_, index) => { + assert(this.cache[index]); + const value = this.cache[index]; + this.cache[index] = null; + return value; + }); + if (restored === str) return restored; + return this.loadContent(restored); // self-recursive for nexted escaping + } - return Promise.all([ - // Write content to file - fs.writeFile(path, content), - // Create asset folder - createAssetFolder(path, config.post_asset_folder) - ]).then(() => { - ctx.emit('new', result); - }).thenReturn(result); - }).asCallback(callback); -}; + escapeAllSwigTags(str) { + const rSwigVar = /\{\{[\s\S]*?\}\}/g; + const rSwigComment = /\{#[\s\S]*?#\}/g; + const rSwigBlock = /\{%[\s\S]*?%\}/g; + const rSwigFullBlock = /\{% *(.+?)(?: *| +.*)%\}[\s\S]+?\{% *end\1 *%\}/g; + + const escape = _str => _escapeContent(this.cache, _str); + return str.replace(rSwigFullBlock, escape) + .replace(rSwigBlock, escape) + .replace(rSwigComment, '') + .replace(rSwigVar, escape); + } +} -function prepareFrontMatter(data) { +const prepareFrontMatter = data => { for (const key of Object.keys(data)) { const item = data[key]; @@ -103,196 +67,237 @@ function prepareFrontMatter(data) { } return data; -} +}; -Post.prototype._getScaffold = function(layout) { - const ctx = this.context; - return ctx.scaffold.get(layout).then(result => { - if (result != null) return result; - return ctx.scaffold.get('normal'); - }); +const removeExtname = str => { + return str.substring(0, str.length - extname(str).length); }; -Post.prototype._renderScaffold = function(data) { - const { tag } = this.context.extend; - let yfmSplit; +const createAssetFolder = (path, assetFolder) => { + if (!assetFolder) return Promise.resolve(); - return this._getScaffold(data.layout).then(scaffold => { - const frontMatter = prepareFrontMatter({...data}); - yfmSplit = yfm.split(scaffold); + const target = removeExtname(path); - return tag.render(yfmSplit.data, frontMatter); - }).then(frontMatter => { - const { separator } = yfmSplit; - const jsonMode = separator[0] === ';'; + return exists(target).then(exist => { + if (!exist) return mkdirs(target); + }); +}; - // Parse front-matter - const obj = jsonMode ? JSON.parse(`{${frontMatter}}`) : yaml.load(frontMatter); +class Post { + constructor(context) { + this.context = context; + } - // Add data which are not in the front-matter - for (const key of Object.keys(data)) { - if (!preservedKeys.includes(key) && obj[key] == null) { - obj[key] = data[key]; - } + create(data, replace, callback) { + if (!callback && typeof replace === 'function') { + callback = replace; + replace = false; } - let content = ''; - // Prepend the separator - if (yfmSplit.prefixSeparator) content += `${separator}\n`; + const ctx = this.context; + const { config } = ctx; - content += yfm.stringify(obj, { - mode: jsonMode ? 'json' : '' - }); + data.slug = slugize((data.slug || data.title).toString(), {transform: config.filename_case}); + data.layout = (data.layout || config.default_layout).toLowerCase(); + data.date = data.date ? moment(data.date) : moment(); + + return Promise.all([ + // Get the post path + ctx.execFilter('new_post_path', data, { + args: [replace], + context: ctx + }), + this._renderScaffold(data) + ]).spread((path, content) => { + const result = { path, content }; + + return Promise.all([ + // Write content to file + writeFile(path, content), + // Create asset folder + createAssetFolder(path, config.post_asset_folder) + ]).then(() => { + ctx.emit('new', result); + }).thenReturn(result); + }).asCallback(callback); + } - // Concat content - content += yfmSplit.content; + _getScaffold(layout) { + const ctx = this.context; - if (data.content) { - content += `\n${data.content}`; - } + return ctx.scaffold.get(layout).then(result => { + if (result != null) return result; + return ctx.scaffold.get('normal'); + }); + } - return content; - }); -}; + _renderScaffold(data) { + const { tag } = this.context.extend; + let yfmSplit; -function createAssetFolder(path, assetFolder) { - if (!assetFolder) return Promise.resolve(); + return this._getScaffold(data.layout).then(scaffold => { + const frontMatter = prepareFrontMatter({...data}); + yfmSplit = yfm.split(scaffold); - const target = removeExtname(path); + return tag.render(yfmSplit.data, frontMatter); + }).then(frontMatter => { + const { separator } = yfmSplit; + const jsonMode = separator[0] === ';'; - return fs.exists(target).then(exist => { - if (!exist) return fs.mkdirs(target); - }); -} + // Parse front-matter + const obj = jsonMode ? JSON.parse(`{${frontMatter}}`) : load(frontMatter); -function removeExtname(str) { - return str.substring(0, str.length - extname(str).length); -} + // Add data which are not in the front-matter + for (const key of Object.keys(data)) { + if (!preservedKeys.includes(key) && obj[key] == null) { + obj[key] = data[key]; + } + } -Post.prototype.publish = function(data, replace, callback) { - if (!callback && typeof replace === 'function') { - callback = replace; - replace = false; - } + let content = ''; + // Prepend the separator + if (yfmSplit.prefixSeparator) content += `${separator}\n`; - if (data.layout === 'draft') data.layout = 'post'; - - const ctx = this.context; - const { config } = ctx; - const draftDir = join(ctx.source_dir, '_drafts'); - const slug = slugize(data.slug.toString(), {transform: config.filename_case}); - data.slug = slug; - const regex = new RegExp(`^${escapeRegExp(slug)}(?:[^\\/\\\\]+)`); - let src = ''; - const result = {}; - - data.layout = (data.layout || config.default_layout).toLowerCase(); - - // Find the draft - return fs.listDir(draftDir).then(list => { - return list.find(item => regex.test(item)); - }).then(item => { - if (!item) throw new Error(`Draft "${slug}" does not exist.`); - - // Read the content - src = join(draftDir, item); - return fs.readFile(src); - }).then(content => { - // Create post - Object.assign(data, yfm(content)); - data.content = data._content; - delete data._content; - - return this.create(data, replace).then(post => { - result.path = post.path; - result.content = post.content; - }); - }).then(() => // Remove the original draft file - fs.unlink(src)).then(() => { - if (!config.post_asset_folder) return; + content += yfm.stringify(obj, { + mode: jsonMode ? 'json' : '' + }); - // Copy assets - const assetSrc = removeExtname(src); - const assetDest = removeExtname(result.path); + // Concat content + content += yfmSplit.content; - return fs.exists(assetSrc).then(exist => { - if (!exist) return; + if (data.content) { + content += `\n${data.content}`; + } - return fs.copyDir(assetSrc, assetDest).then(() => fs.rmdir(assetSrc)); + return content; }); - }).thenReturn(result).asCallback(callback); -}; - -Post.prototype.render = function(source, data = {}, callback) { - const ctx = this.context; - const { config } = ctx; - const { tag } = ctx.extend; - const ext = data.engine || (source ? extname(source) : ''); - - let promise; - - if (data.content != null) { - promise = Promise.resolve(data.content); - } else if (source) { - // Read content from files - promise = fs.readFile(source); - } else { - return Promise.reject(new Error('No input file or string!')).asCallback(callback); } - const isSwig = ext === 'swig'; - - // disable Nunjucks when the renderer spcify that. - const disableNunjucks = ext && ctx.render.renderer.get(ext) && !!ctx.render.renderer.get(ext).disableNunjucks; - - const cacheObj = new PostRenderCache(); - - return promise.then(content => { - data.content = content; + publish(data, replace, callback) { + if (!callback && typeof replace === 'function') { + callback = replace; + replace = false; + } - // Run "before_post_render" filters - return ctx.execFilter('before_post_render', data, {context: ctx}); - }).then(() => { - data.content = cacheObj.escapeContent(data.content); + if (data.layout === 'draft') data.layout = 'post'; + + const ctx = this.context; + const { config } = ctx; + const draftDir = join(ctx.source_dir, '_drafts'); + const slug = slugize(data.slug.toString(), {transform: config.filename_case}); + data.slug = slug; + const regex = new RegExp(`^${escapeRegExp(slug)}(?:[^\\/\\\\]+)`); + let src = ''; + const result = {}; + + data.layout = (data.layout || config.default_layout).toLowerCase(); + + // Find the draft + return listDir(draftDir).then(list => { + return list.find(item => regex.test(item)); + }).then(item => { + if (!item) throw new Error(`Draft "${slug}" does not exist.`); + + // Read the content + src = join(draftDir, item); + return readFile(src); + }).then(content => { + // Create post + Object.assign(data, yfm(content)); + data.content = data._content; + delete data._content; + + return this.create(data, replace).then(post => { + result.path = post.path; + result.content = post.content; + }); + }).then(() => // Remove the original draft file + unlink(src)).then(() => { + if (!config.post_asset_folder) return; + + // Copy assets + const assetSrc = removeExtname(src); + const assetDest = removeExtname(result.path); + + return exists(assetSrc).then(exist => { + if (!exist) return; + + return copyDir(assetSrc, assetDest).then(() => rmdir(assetSrc)); + }); + }).thenReturn(result).asCallback(callback); + } - if (isSwig) { - // Render with Nunjucks if this is a swig file - return tag.render(data.content, data); + render(source, data = {}, callback) { + const ctx = this.context; + const { config } = ctx; + const { tag } = ctx.extend; + const ext = data.engine || (source ? extname(source) : ''); + + let promise; + + if (data.content != null) { + promise = Promise.resolve(data.content); + } else if (source) { + // Read content from files + promise = readFile(source); + } else { + return Promise.reject(new Error('No input file or string!')).asCallback(callback); } - // Escape all Swig tags - if (!disableNunjucks) { - data.content = cacheObj.escapeAllSwigTags(data.content); - } + const isSwig = ext === 'swig'; + + // disable Nunjucks when the renderer spcify that. + const disableNunjucks = ext && ctx.render.renderer.get(ext) && !!ctx.render.renderer.get(ext).disableNunjucks; - const options = data.markdown || {}; - if (!config.highlight.enable) options.highlight = null; + const cacheObj = new PostRenderCache(); - ctx.log.debug('Rendering post: %s', chalk.magenta(source)); - // Render with markdown or other renderer - return ctx.render.render({ - text: data.content, - path: source, - engine: data.engine, - toString: true, - onRenderEnd(content) { - // Replace cache data with real contents - data.content = cacheObj.loadContent(content); + return promise.then(content => { + data.content = content; - // Return content after replace the placeholders - if (disableNunjucks) return data.content; + // Run "before_post_render" filters + return ctx.execFilter('before_post_render', data, {context: ctx}); + }).then(() => { + data.content = cacheObj.escapeContent(data.content); - // Render with Nunjucks + if (isSwig) { + // Render with Nunjucks if this is a swig file return tag.render(data.content, data); } - }, options); - }).then(content => { - data.content = content; - // Run "after_post_render" filters - return ctx.execFilter('after_post_render', data, {context: ctx}); - }).asCallback(callback); -}; + // Escape all Swig tags + if (!disableNunjucks) { + data.content = cacheObj.escapeAllSwigTags(data.content); + } + + const options = data.markdown || {}; + if (!config.highlight.enable) options.highlight = null; + + ctx.log.debug('Rendering post: %s', magenta(source)); + // Render with markdown or other renderer + return ctx.render.render({ + text: data.content, + path: source, + engine: data.engine, + toString: true, + onRenderEnd(content) { + // Replace cache data with real contents + data.content = cacheObj.loadContent(content); + + // Return content after replace the placeholders + if (disableNunjucks) return data.content; + + // Render with Nunjucks + return tag.render(data.content, data); + } + }, options); + }).then(content => { + data.content = content; + + // Run "after_post_render" filters + return ctx.execFilter('after_post_render', data, {context: ctx}); + }).asCallback(callback); + } +} module.exports = Post;