'use strict'; const { access, cp, mkdir, readdir, readFile, writeFile } = require('fs/promises'); const { exec } = require('child_process'); const { basename, resolve, join } = require('path'); const ParseGemini = require('gemini-to-html/parse'); const RenderGemini = require('gemini-to-html/render'); const { debuglog, promisify } = require('util'); const { ensureDirectoryExists, rmIfExists } = require('./utils'); const { kFileNotFoundError } = require('./constants'); // Generators for the Blog const StaticGenerator = require('./generators/static'); const HTMLGenerator = require('./generators/html'); const RSSGenerator = require('./generators/rss'); const TXTGenerator = require('./generators/txt'); // Archiving Methods const GemlogArchiver = require('./archivers/gemlog'); // Remote Handler const Remote = require('./remote'); const internals = { // Promisified functions exec: promisify(exec), debuglog: debuglog('blog'), // constants kGeminiRe: /\.gmi$/i, kMetadataFilename: 'metadata.json', // Strings strings: { geminiNotFound: 'Gemini 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 file to slot 0, and * generates files. * * @function add * @memberof Blog * @param {string} postLocation the path to the blog post file * @return {Promise} empty promise, returns no value * @instance */ async add(postLocation) { await ensureDirectoryExists(this.postsDirectory); try { await this.syncDown(); } catch {}; await this._shift(); const firstDirectory = join(this.postsDirectory, '0'); await rmIfExists(firstDirectory); await ensureDirectoryExists(firstDirectory); await this._update(postLocation); } /** * Update slot 0 with the passed gmi file, and generates files. * * @function update * @memberof Blog * @param {string} postLocation the path to the blog post file * @return {Promise} empty promise, returns no value * @instance */ async update(postLocation) { try { await this.syncDown(); } catch {}; const metadata = await this._update(postLocation); } /** * Publishes the files to a static host. * * @function publish * @memberof Blog * @return {Promise} empty promise, returns no value * @instance */ async publish(host) { internals.debuglog(`Publishing to ${host}`); try { await internals.exec('which aws'); } catch (err) { console.error('Please install and configure AWS CLI to publish.'); } try { internals.debuglog(`Copying ephemeral blog from ${this.staticDirectory}`); await internals.exec(`rsync -r ${this.staticDirectory}/ ${host}`); } catch (err) { console.error('Failed to publish'); console.error(err.stderr); } internals.debuglog('Finished publishing'); } /** * Publishes the archive to a host using rsync. Currently assumes * gemlog archive. * * @function publishArchive * @memberof Blog * @return {Promise} empty promise, returns no value * @instance */ async publishArchive(host) { internals.debuglog(`Publishing archive to ${host}`); try { await internals.exec('which rsync'); } catch (err) { console.error('Please install rsync to publish the archive.'); } try { const gemlogPath = resolve(join(__dirname, '../', '.gemlog')); internals.debuglog(`Copying archive from ${gemlogPath}`); await internals.exec(`rsync -r ${gemlogPath}/ ${host}`); } catch (err) { console.error('Failed to publish archive'); console.error(err.stderr); } internals.debuglog('Finished publishing'); } /** * Adds a remote * * @function addRemote * @memberof Blog * @return {Promise} empty promise, returns no value * @instance */ async addRemote(remote) { await Remote.add(this.remoteConfig, remote) } /** * Removes a remote * * @function removeRemote * @memberof Blog * @return {Promise} empty promise, returns no value * @instance */ async removeRemote() { await Remote.remove(this.remoteConfig) } /** * Pulls the posts and archive from the remote * * @function syncDown * @memberof Blog * @return {Promise} empty promise, returns no value * @instance */ async syncDown() { internals.debuglog('Pulling remote state'); await ensureDirectoryExists(this.postsDirectory); await Remote.syncDown(this.remoteConfig, this.blogDirectory) internals.debuglog('Pulled remote state'); } /** * Pushes the posts and archive to the remote * * @function syncUp * @memberof Blog * @return {Promise} empty promise, returns no value * @instance */ async syncUp() { internals.debuglog('Pushing remote state'); await ensureDirectoryExists(this.postsDirectory); await Remote.syncUp(this.remoteConfig, this.blogDirectory) internals.debuglog('Pushed remote state'); } // Adds the passed path to slot 0, and generates files. async _update(postLocation) { const metadata = await this._getMetadata(); await ensureDirectoryExists(this.postsDirectory); await this._copyPost(postLocation); await this._writeMetadata(metadata); await this._archive(postLocation); await this.generate(); try { await this.syncUp(); } catch {}; } // Parses Gemini for each page, copies assets and generates index. async generate() { internals.debuglog('Generating output'); const posts = await this._readPosts(); await StaticGenerator(this.postsDirectory, this.staticDirectory, posts); await HTMLGenerator(this.templatesDirectory, this.staticDirectory, posts); await RSSGenerator(this.templatesDirectory, this.staticDirectory, posts); await TXTGenerator(this.templatesDirectory, this.staticDirectory, posts); await GemlogArchiver(this.archiveDirectory); } // Reads the posts into an array async _readPosts() { internals.debuglog('Reading posts'); const posts = []; for (let i = 0; i < this.maxPosts; ++i) { try { posts.push(await this._readPost(i)); } catch (error) { if (error.code === kFileNotFoundError) { internals.debuglog(`Skipping ${i}`); continue; } throw error; } } return posts; } // Reads an individual post async _readPost(index=0) { const postSourcePath = join(this.postsDirectory, `${index}`); internals.debuglog(`Reading ${postSourcePath}`); await access(postSourcePath); const metadata = await this._getMetadata(index); const postContentPath = await this._findBlogContent(postSourcePath); internals.debuglog(`Reading ${postContentPath}`); const postContent = await readFile(postContentPath, { encoding: 'utf8' }); internals.debuglog('Parsing Gemini'); return { ...metadata, location: postSourcePath, index, html: RenderGemini(ParseGemini(postContent)), raw: postContent }; } // Shift the posts, delete any remainder. async _shift() { for (let i = this.maxPosts - 1; i >= 1; --i) { const targetPath = join(this.postsDirectory, `${i}`); const sourcePath = join(this.postsDirectory, `${i - 1}`); try { internals.debuglog(`Archiving ${targetPath}`); await rmIfExists(targetPath); await access(sourcePath); // check the source path internals.debuglog(`Shifting blog post ${sourcePath} to ${targetPath}`); await cp(sourcePath, targetPath, { recursive: true }); } catch (error) { if (error.code === kFileNotFoundError) { internals.debuglog(`Skipping ${sourcePath}: Does not exist.`); continue; } throw error; } } } // Moves older posts to the archive async _archive() { internals.debuglog('Archiving post'); const post = await this._readPost(0); await ensureDirectoryExists(this.archiveDirectory); const targetPath = join(this.archiveDirectory, post.id); internals.debuglog(`Removing ${targetPath}`); await rmIfExists(targetPath); internals.debuglog(`Adding ${post.location} to ${targetPath}`); await ensureDirectoryExists(targetPath); await cp(post.location, targetPath, { recursive: true }); internals.debuglog(`Added ${post.location} to ${targetPath}`); } // 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 file to the latest slot. async _copyPost(postLocation) { internals.debuglog(`Copying ${postLocation}`); const targetPath = join(this.postsDirectory, '0'); const postName = basename(postLocation); const targetPost = join(targetPath, postName); await rmIfExists(targetPath); await ensureDirectoryExists(targetPath); await cp(postLocation, targetPost, { recursive: true }); internals.debuglog(`Added ${postLocation} to ${targetPath}`); } // Looks for a `.gmi` file in the blog directory, and returns the path async _findBlogContent(directory) { const entries = await readdir(directory); const geminiEntries = entries .filter((entry) => internals.kGeminiRe.test(entry)) .map((entry) => join(directory, entry)); if (geminiEntries.length > 0) { internals.debuglog(`Found gemini file: ${geminiEntries[0]}`); return geminiEntries[0]; } throw new Error(internals.strings.geminiNotFound); } };