'use strict'; const { access, mkdir, readdir, readFile, rmdir, writeFile } = require('fs/promises'); const { ncp } = require('ncp'); const { join } = require('path'); const Marked = require('marked'); const { debuglog, promisify } = require('util'); const StaticGenerator = require('./generators/static'); const HTMLGenerator = require('./generators/html'); const RSSGenerator = require('./generators/rss'); const internals = { // Promisified functions ncp: promisify(ncp), debuglog: debuglog('blog'), // constants kFileNotFoundError: 'ENOENT', kMarkdownRe: /\.md$/i, kMetadataFilename: 'metadata.json', // Strings strings: { markdownNotFound: 'Markdown file was not found in blog directory. Please update.' } }; /** * The Blog class is the blog generator, it's in charge of adding and * updating posts, and handling the publishing. * * @class Blog * @param {Blog.tConfiguration} config the initialization options to * extend the instance */ module.exports = class Blog { constructor(config) { Object.assign(this, config); } /** * Shifts the blog posts, adds the passed path to slot 0, and * generates files. * * @function add * @memberof Blog * @param {string} postLocation the path to the directory containing * the post structure * @return {Promise} empty promise, returns no value * @instance */ async add(postLocation) { await this._ensurePostsDirectoryExists(); await this._shift(); await this.update(postLocation); } /** * Adds the passed path to slot 0, and generates files. * * @function update * @memberof Blog * @param {string} postLocation the path to the directory containing * the post structure * @return {Promise} empty promise, returns no value * @instance */ async update(postLocation) { const metadata = await this._getMetadata(); await this._ensurePostsDirectoryExists(); await this._copyPost(postLocation); await this._writeMetadata(metadata); await this._generate(); } /** * Publishes the files to a static host. * * @function publish * @memberof Blog * @return {Promise} empty promise, returns no value * @instance */ publish() { console.error('Publishing not yet implemented'); return Promise.resolve(); } // Parses markdown for each page, copies assets and generates index. async _generate() { internals.debuglog('Generating output'); const posts = await this._readPosts(this.postsDirectory); await StaticGenerator(this.postsDirectory, this.staticDirectory, posts); await HTMLGenerator(this.templatesDirectory, this.staticDirectory, posts); await RSSGenerator(this.templatesDirectory, this.staticDirectory, posts); } // Reads the posts into an array async _readPosts(source) { internals.debuglog('Reading posts'); const posts = []; for (let i = 0; i < this.maxPosts; ++i) { const postSourcePath = join(source, `${i}`); internals.debuglog(`Reading ${postSourcePath} into posts array`); try { await access(postSourcePath); const metadata = await this._getMetadata(i); const postContentPath = await this._findBlogContent(postSourcePath); internals.debuglog(`Reading ${postContentPath}`); const postContent = await readFile(postContentPath, { encoding: 'utf8' }); internals.debuglog('Parsing markdown'); posts.push({ ...metadata, html: Marked(postContent) }); } catch (error) { if (error.code === internals.kFileNotFoundError) { internals.debuglog(`Skipping ${i}`); continue; } throw error; } } return posts; } // Shift the posts, delete any remainder. async _shift() { for (let i = this.maxPosts - 1; i >= 0; --i) { const targetPath = join(this.postsDirectory, `${i}`); const sourcePath = join(this.postsDirectory, `${i - 1}`); try { internals.debuglog(`Removing ${targetPath}`); await rmdir(targetPath, { recursive: true }); await access(sourcePath); // check the source path internals.debuglog(`Shifting blog post ${sourcePath} to ${targetPath}`); await internals.ncp(sourcePath, targetPath); } catch (error) { if (error.code === internals.kFileNotFoundError) { internals.debuglog(`Skipping ${sourcePath}: Does not exist.`); continue; } throw error; } } } // Attempts to read existing metadata. Otherwise generates new set. async _getMetadata(index = 0) { const metadataTarget = join(this.postsDirectory, String(index), internals.kMetadataFilename); try { internals.debuglog(`Looking for metadata at ${metadataTarget}`); return JSON.parse(await readFile(metadataTarget, { encoding: 'utf8' })); } catch (e) { internals.debuglog(`Metadata not found or unreadable. Generating new set.`); const createdOn = Date.now(); const metadata = { id: String(createdOn), createdOn }; return metadata; } } // Writes metadata. Assumes post 0 since it only gets written // on create async _writeMetadata(metadata) { const metadataTarget = join(this.postsDirectory, '0', internals.kMetadataFilename); internals.debuglog(`Writing ${metadataTarget}`); await writeFile(metadataTarget, JSON.stringify(metadata, null, 2)); } // Copies a post directory to the latest slot. async _copyPost(postLocation) { const targetPath = join(this.postsDirectory, '0'); internals.debuglog(`Removing ${targetPath}`); await rmdir(targetPath, { recursive: true }); internals.debuglog(`Adding ${postLocation} to ${targetPath}`); await internals.ncp(postLocation, targetPath); } // Ensures the posts directory exists. async _ensurePostsDirectoryExists() { internals.debuglog(`Checking if ${this.postsDirectory} exists.`); try { await access(this.postsDirectory); } catch (error) { if (error.code === internals.kFileNotFoundError) { internals.debuglog('Creating posts directory'); await mkdir(this.postsDirectory); return; } throw error; } } // Looks for a `.md` file in the blog directory, and returns the path async _findBlogContent(directory) { const entries = await readdir(directory); const markdownEntries = entries .filter((entry) => internals.kMarkdownRe.test(entry)) .map((entry) => join(directory, entry)); if (markdownEntries.length > 0) { internals.debuglog(`Found markdown file: ${markdownEntries[0]}`); return markdownEntries[0]; } throw new Error(internals.strings.markdownNotFound); } };