]> git.r.bdr.sh - rbdr/blog/blob - lib/blog.js
6b3397fd7d2f008ac1de39cb4552583fd0723124
[rbdr/blog] / lib / blog.js
1 'use strict';
2
3 const { exec } = require('child_process');
4 const { access, mkdir, readdir, readFile, rmdir, writeFile } = require('fs/promises');
5 const { template } = require('dot');
6 const { ncp } = require('ncp');
7 const { join } = require('path');
8 const Marked = require('marked');
9 const { debuglog, promisify } = require('util');
10
11 const internals = {
12
13 // Promisified functions
14 ncp: promisify(ncp),
15
16 exec: promisify(exec),
17 debuglog: debuglog('blog'),
18
19 // constants
20
21 kAssetsDirectoryName: 'assets',
22 kIndexName: 'index.html',
23 kFileNotFoundError: 'ENOENT',
24 kMarkdownRe: /\.md$/i,
25 kMetadataFilename: 'metadata.json',
26
27 // Strings
28
29 strings: {
30 markdownNotFound: 'Markdown file was not found in blog directory. Please update.'
31 }
32 };
33
34 /**
35 * The Blog class is the blog generator, it's in charge of adding and
36 * updating posts, and handling the publishing.
37 *
38 * @class Blog
39 * @param {Potluck.tConfiguration} config the initialization options to
40 * extend the instance
41 */
42 module.exports = class Blog {
43
44 constructor(config) {
45
46 Object.assign(this, config);
47 }
48
49 /**
50 * Shifts the blog posts, adds the passed path to slot 0, and
51 * generates files.
52 *
53 * @function add
54 * @memberof Blog
55 * @param {string} postLocation the path to the directory containing
56 * the post structure
57 * @return {Promise<undefined>} empty promise, returns no value
58 * @instance
59 */
60 async add(postLocation) {
61
62 await this._ensurePostsDirectoryExists();
63 await this._shift();
64 await this.update(postLocation);
65 }
66
67 /**
68 * Adds the passed path to slot 0, and generates files.
69 *
70 * @function update
71 * @memberof Blog
72 * @param {string} postLocation the path to the directory containing
73 * the post structure
74 * @return {Promise<undefined>} empty promise, returns no value
75 * @instance
76 */
77 async update(postLocation) {
78
79 const metadata = await this._getMetadata();
80 await this._ensurePostsDirectoryExists();
81 await this._copyPost(postLocation);
82 await this._writeMetadata(metadata);
83
84 await this._generate();
85 }
86
87 /**
88 * Publishes the files to a static host.
89 *
90 * @function publish
91 * @memberof Blog
92 * @return {Promise<undefined>} empty promise, returns no value
93 * @instance
94 */
95 publish() {
96
97 console.error('Publishing not yet implemented');
98 return Promise.resolve();
99 }
100
101 // Parses markdown for each page, copies assets and generates index.
102
103 async _generate() {
104
105 const assetsTarget = join(this.staticDirectory, internals.kAssetsDirectoryName);
106 const indexTarget = join(this.staticDirectory, internals.kIndexName);
107 const indexLocation = join(this.templatesDirectory, internals.kIndexName);
108 const posts = [];
109
110 internals.debuglog(`Removing ${assetsTarget}`);
111 await rmdir(assetsTarget, { recursive: true });
112
113 for (let i = 0; i < this.maxPosts; ++i) {
114 const sourcePath = join(this.postsDirectory, `${i}`);
115
116 try {
117 await access(this.postsDirectory);
118
119 const assetsSource = join(sourcePath, internals.kAssetsDirectoryName);
120 const postContentPath = await this._findBlogContent(sourcePath);
121
122 internals.debuglog(`Copying ${assetsSource} to ${assetsTarget}`);
123 await internals.ncp(assetsSource, assetsTarget);
124
125 internals.debuglog(`Reading ${postContentPath}`);
126 const postContent = await readFile(postContentPath, { encoding: 'utf8' });
127
128 internals.debuglog('Parsing markdown');
129 posts.push({
130 html: Marked(postContent),
131 id: i + 1
132 });
133 }
134 catch (error) {
135 if (error.code === internals.kFileNotFoundError) {
136 internals.debuglog(`Skipping ${i}`);
137 continue;
138 }
139
140 throw error;
141 }
142 }
143
144 internals.debuglog(`Reading ${indexLocation}`);
145 const indexTemplate = await readFile(indexLocation, { encoding: 'utf8' });
146
147 internals.debuglog('Generating HTML');
148 const indexHtml = template(indexTemplate)({ posts });
149 await writeFile(indexTarget, indexHtml);
150 }
151
152 // Shift the posts, delete any remainder.
153
154 async _shift() {
155
156
157 for (let i = this.maxPosts - 1; i >= 0; --i) {
158 const targetPath = join(this.postsDirectory, `${i}`);
159 const sourcePath = join(this.postsDirectory, `${i - 1}`);
160
161 try {
162 internals.debuglog(`Removing ${targetPath}`);
163 await rmdir(targetPath, { recursive: true });
164
165 await access(sourcePath); // check the source path
166
167 internals.debuglog(`Shifting blog post ${sourcePath} to ${targetPath}`);
168 await internals.ncp(sourcePath, targetPath);
169 }
170 catch (error) {
171 if (error.code === internals.kFileNotFoundError) {
172 internals.debuglog(`Skipping ${sourcePath}: Does not exist.`);
173 continue;
174 }
175
176 throw error;
177 }
178 }
179 }
180
181 // Attempts to read existing metadata. Otherwise generates new set.
182
183 async _getMetadata() {
184
185 const metadataTarget = join(this.postsDirectory, '0', internals.kMetadataFilename);
186
187 try {
188 internals.debuglog(`Looking for metadata at ${metadataTarget}`);
189 return await readFile(metadataTarget);
190 }
191 catch (e) {
192 internals.debuglog(`Metadata not found or unreadable. Generating new set.`);
193 const createdOn = Date.now();
194 const metadata = {
195 id: String(createdOn),
196 createdOn
197 };
198
199 return JSON.stringify(metadata, null, 2);
200 }
201 }
202
203 // Writes metadata. Assumes post 0 since it only gets written
204 // on create
205
206 async _writeMetadata(metadata) {
207
208 const metadataTarget = join(this.postsDirectory, '0', internals.kMetadataFilename);
209 internals.debuglog(`Writing ${metadataTarget}`);
210 await writeFile(metadataTarget, metadata);
211 }
212
213 // Copies a post directory to the latest slot.
214
215 async _copyPost(postLocation) {
216
217 const targetPath = join(this.postsDirectory, '0');
218
219 internals.debuglog(`Removing ${targetPath}`);
220 await rmdir(targetPath, { recursive: true });
221
222 internals.debuglog(`Adding ${postLocation} to ${targetPath}`);
223 await internals.ncp(postLocation, targetPath);
224 }
225
226 // Ensures the posts directory exists.
227
228 async _ensurePostsDirectoryExists() {
229
230 internals.debuglog(`Checking if ${this.postsDirectory} exists.`);
231 try {
232 await access(this.postsDirectory);
233 }
234 catch (error) {
235 if (error.code === internals.kFileNotFoundError) {
236 internals.debuglog('Creating posts directory');
237 await mkdir(this.postsDirectory);
238 return;
239 }
240
241 throw error;
242 }
243 }
244
245 // Looks for a `.md` file in the blog directory, and returns the path
246
247 async _findBlogContent(directory) {
248
249 const entries = await readdir(directory);
250
251 const markdownEntries = entries
252 .filter((entry) => internals.kMarkdownRe.test(entry))
253 .map((entry) => join(directory, entry));
254
255 if (markdownEntries.length > 0) {
256 internals.debuglog(`Found markdown file: ${markdownEntries[0]}`);
257 return markdownEntries[0];
258 }
259
260 throw new Error(internals.strings.markdownNotFound);
261 }
262 };