post.js 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. 'use strict';
  2. const assert = require('assert');
  3. const moment = require('moment');
  4. const Promise = require('bluebird');
  5. const { join, extname, basename } = require('path');
  6. const { magenta } = require('chalk');
  7. const { load } = require('js-yaml');
  8. const { slugize, escapeRegExp } = require('hexo-util');
  9. const { copyDir, exists, listDir, mkdirs, readFile, rmdir, unlink, writeFile } = require('hexo-fs');
  10. const { parse: yfmParse, split: yfmSplit, stringify: yfmStringify } = require('hexo-front-matter');
  11. const preservedKeys = ['title', 'slug', 'path', 'layout', 'date', 'content'];
  12. const rHexoPostRenderEscape = /<hexoPostRenderCodeBlock>([\s\S]+?)<\/hexoPostRenderCodeBlock>/g;
  13. const rSwigVarAndComment = /{[{#][\s\S]+?[}#]}/g;
  14. const rSwigFullBlock = /{% *(\S+?)(?: *| +.+?)%}[\s\S]+?{% *end\1 *%}/g;
  15. const rSwigBlock = /{%[\s\S]+?%}/g;
  16. const rSwigPlaceHolder = /(?:<|&lt;)!--swig\uFFFC(\d+)--(?:>|&gt;)/g;
  17. const rCodeBlockPlaceHolder = /(?:<|&lt;)!--code\uFFFC(\d+)--(?:>|&gt;)/g;
  18. const _escapeContent = (cache, flag, str) => `<!--${flag}\uFFFC${cache.push(str) - 1}-->`;
  19. const _restoreContent = cache => (_, index) => {
  20. assert(cache[index]);
  21. const value = cache[index];
  22. cache[index] = null;
  23. return value;
  24. };
  25. class PostRenderCache {
  26. constructor() {
  27. this.cache = [];
  28. }
  29. restoreAllSwigTags(str) {
  30. const restored = str.replace(rSwigPlaceHolder, _restoreContent(this.cache));
  31. if (restored === str) return restored;
  32. return this.restoreAllSwigTags(restored); // self-recursive for nexted escaping
  33. }
  34. restoreCodeBlocks(str) {
  35. return str.replace(rCodeBlockPlaceHolder, _restoreContent(this.cache));
  36. }
  37. escapeCodeBlocks(str) {
  38. return str.replace(rHexoPostRenderEscape, (_, content) => _escapeContent(this.cache, 'code', content));
  39. }
  40. escapeAllSwigTags(str) {
  41. const escape = _str => _escapeContent(this.cache, 'swig', _str);
  42. return str.replace(rSwigVarAndComment, escape) // Remove swig comment first to reduce string size being matched next
  43. .replace(rSwigFullBlock, escape) // swig full block must escaped before swig block to avoid confliction
  44. .replace(rSwigBlock, escape);
  45. }
  46. }
  47. const prepareFrontMatter = (data, jsonMode) => {
  48. for (const [key, item] of Object.entries(data)) {
  49. if (moment.isMoment(item)) {
  50. data[key] = item.utc().format('YYYY-MM-DD HH:mm:ss');
  51. } else if (moment.isDate(item)) {
  52. data[key] = moment.utc(item).format('YYYY-MM-DD HH:mm:ss');
  53. } else if (typeof item === 'string') {
  54. if (jsonMode || item.includes(':') || item.startsWith('#') || item.startsWith('!!')) data[key] = `"${item}"`;
  55. }
  56. }
  57. return data;
  58. };
  59. const removeExtname = str => {
  60. return str.substring(0, str.length - extname(str).length);
  61. };
  62. const createAssetFolder = (path, assetFolder) => {
  63. if (!assetFolder) return Promise.resolve();
  64. const target = removeExtname(path);
  65. if (basename(target) === 'index') return Promise.resolve();
  66. return exists(target).then(exist => {
  67. if (!exist) return mkdirs(target);
  68. });
  69. };
  70. class Post {
  71. constructor(context) {
  72. this.context = context;
  73. }
  74. create(data, replace, callback) {
  75. if (!callback && typeof replace === 'function') {
  76. callback = replace;
  77. replace = false;
  78. }
  79. const ctx = this.context;
  80. const { config } = ctx;
  81. data.slug = slugize((data.slug || data.title).toString(), { transform: config.filename_case });
  82. data.layout = (data.layout || config.default_layout).toLowerCase();
  83. data.date = data.date ? moment(data.date) : moment();
  84. return Promise.all([
  85. // Get the post path
  86. ctx.execFilter('new_post_path', data, {
  87. args: [replace],
  88. context: ctx
  89. }),
  90. this._renderScaffold(data)
  91. ]).spread((path, content) => {
  92. const result = { path, content };
  93. return Promise.all([
  94. // Write content to file
  95. writeFile(path, content),
  96. // Create asset folder
  97. createAssetFolder(path, config.post_asset_folder)
  98. ]).then(() => {
  99. ctx.emit('new', result);
  100. }).thenReturn(result);
  101. }).asCallback(callback);
  102. }
  103. _getScaffold(layout) {
  104. const ctx = this.context;
  105. return ctx.scaffold.get(layout).then(result => {
  106. if (result != null) return result;
  107. return ctx.scaffold.get('normal');
  108. });
  109. }
  110. _renderScaffold(data) {
  111. const { tag } = this.context.extend;
  112. let splited;
  113. return this._getScaffold(data.layout).then(scaffold => {
  114. splited = yfmSplit(scaffold);
  115. const jsonMode = splited.separator.startsWith(';');
  116. const frontMatter = prepareFrontMatter({ ...data }, jsonMode);
  117. return tag.render(splited.data, frontMatter);
  118. }).then(frontMatter => {
  119. const { separator } = splited;
  120. const jsonMode = separator.startsWith(';');
  121. // Parse front-matter
  122. const obj = jsonMode ? JSON.parse(`{${frontMatter}}`) : load(frontMatter);
  123. // Add data which are not in the front-matter
  124. for (const key of Object.keys(data)) {
  125. if (!preservedKeys.includes(key) && obj[key] == null) {
  126. obj[key] = data[key];
  127. }
  128. }
  129. let content = '';
  130. // Prepend the separator
  131. if (splited.prefixSeparator) content += `${separator}\n`;
  132. content += yfmStringify(obj, {
  133. mode: jsonMode ? 'json' : ''
  134. });
  135. // Concat content
  136. content += splited.content;
  137. if (data.content) {
  138. content += `\n${data.content}`;
  139. }
  140. return content;
  141. });
  142. }
  143. publish(data, replace, callback) {
  144. if (!callback && typeof replace === 'function') {
  145. callback = replace;
  146. replace = false;
  147. }
  148. if (data.layout === 'draft') data.layout = 'post';
  149. const ctx = this.context;
  150. const { config } = ctx;
  151. const draftDir = join(ctx.source_dir, '_drafts');
  152. const slug = slugize(data.slug.toString(), { transform: config.filename_case });
  153. data.slug = slug;
  154. const regex = new RegExp(`^${escapeRegExp(slug)}(?:[^\\/\\\\]+)`);
  155. let src = '';
  156. const result = {};
  157. data.layout = (data.layout || config.default_layout).toLowerCase();
  158. // Find the draft
  159. return listDir(draftDir).then(list => {
  160. const item = list.find(item => regex.test(item));
  161. if (!item) throw new Error(`Draft "${slug}" does not exist.`);
  162. // Read the content
  163. src = join(draftDir, item);
  164. return readFile(src);
  165. }).then(content => {
  166. // Create post
  167. Object.assign(data, yfmParse(content));
  168. data.content = data._content;
  169. delete data._content;
  170. return this.create(data, replace);
  171. }).then(post => {
  172. result.path = post.path;
  173. result.content = post.content;
  174. return unlink(src);
  175. }).then(() => { // Remove the original draft file
  176. if (!config.post_asset_folder) return;
  177. // Copy assets
  178. const assetSrc = removeExtname(src);
  179. const assetDest = removeExtname(result.path);
  180. return exists(assetSrc).then(exist => {
  181. if (!exist) return;
  182. return copyDir(assetSrc, assetDest).then(() => rmdir(assetSrc));
  183. });
  184. }).thenReturn(result).asCallback(callback);
  185. }
  186. render(source, data = {}, callback) {
  187. const ctx = this.context;
  188. const { config } = ctx;
  189. const { tag } = ctx.extend;
  190. const ext = data.engine || (source ? extname(source) : '');
  191. let promise;
  192. if (data.content != null) {
  193. promise = Promise.resolve(data.content);
  194. } else if (source) {
  195. // Read content from files
  196. promise = readFile(source);
  197. } else {
  198. return Promise.reject(new Error('No input file or string!')).asCallback(callback);
  199. }
  200. // disable Nunjucks when the renderer specify that.
  201. let disableNunjucks = false;
  202. let extRenderer = ext && ctx.render.renderer.get(ext);
  203. if (extRenderer) {
  204. disableNunjucks = Boolean(extRenderer.disableNunjucks);
  205. if (!Object.prototype.hasOwnProperty.call(extRenderer, 'disableNunjucks')) {
  206. extRenderer = ctx.render.renderer.get(ext, true);
  207. if (extRenderer) {
  208. disableNunjucks = Boolean(extRenderer.disableNunjucks);
  209. }
  210. }
  211. }
  212. // front-matter overrides renderer's option
  213. if (typeof data.disableNunjucks === 'boolean') disableNunjucks = data.disableNunjucks;
  214. const cacheObj = new PostRenderCache();
  215. return promise.then(content => {
  216. data.content = content;
  217. // Run "before_post_render" filters
  218. return ctx.execFilter('before_post_render', data, { context: ctx });
  219. }).then(() => {
  220. data.content = cacheObj.escapeCodeBlocks(data.content);
  221. // Escape all Nunjucks/Swig tags
  222. if (disableNunjucks === false) {
  223. data.content = cacheObj.escapeAllSwigTags(data.content);
  224. }
  225. const options = data.markdown || {};
  226. if (!config.highlight.enable) options.highlight = null;
  227. ctx.log.debug('Rendering post: %s', magenta(source));
  228. // Render with markdown or other renderer
  229. return ctx.render.render({
  230. text: data.content,
  231. path: source,
  232. engine: data.engine,
  233. toString: true,
  234. onRenderEnd(content) {
  235. // Replace cache data with real contents
  236. data.content = cacheObj.restoreAllSwigTags(content);
  237. // Return content after replace the placeholders
  238. if (disableNunjucks) return data.content;
  239. // Render with Nunjucks
  240. return tag.render(data.content, data);
  241. }
  242. }, options);
  243. }).then(content => {
  244. data.content = cacheObj.restoreCodeBlocks(content);
  245. // Run "after_post_render" filters
  246. return ctx.execFilter('after_post_render', data, { context: ctx });
  247. }).asCallback(callback);
  248. }
  249. }
  250. module.exports = Post;