]> git.r.bdr.sh - rbdr/blog/blob - lib/blog.js
Assorted fixes
[rbdr/blog] / lib / blog.js
1 'use strict';
2
3 const { access, mkdir, readdir, readFile, rm, writeFile } = require('fs/promises');
4 const { exec } = require('child_process');
5 const { ncp } = require('ncp');
6 const { basename, resolve, join } = require('path');
7 const ParseGemini = require('gemini-to-html/parse');
8 const RenderGemini = require('gemini-to-html/render');
9 const { debuglog, promisify } = require('util');
10
11 // Generators for the Blog
12
13 const StaticGenerator = require('./generators/static');
14 const HTMLGenerator = require('./generators/html');
15 const RSSGenerator = require('./generators/rss');
16 const TXTGenerator = require('./generators/txt');
17
18 // Archiving Methods
19
20 const GemlogArchiver = require('./archivers/gemlog');
21
22 const internals = {
23
24 // Promisified functions
25 exec: promisify(exec),
26 ncp: promisify(ncp),
27
28 debuglog: debuglog('blog'),
29
30 // constants
31
32 kFileNotFoundError: 'ENOENT',
33 kGeminiRe: /\.gmi$/i,
34 kMetadataFilename: 'metadata.json',
35
36 // Strings
37
38 strings: {
39 geminiNotFound: 'Gemini file was not found in blog directory. Please update.'
40 }
41 };
42
43 /**
44 * The Blog class is the blog generator, it's in charge of adding and
45 * updating posts, and handling the publishing.
46 *
47 * @class Blog
48 * @param {Blog.tConfiguration} config the initialization options to
49 * extend the instance
50 */
51 module.exports = class Blog {
52
53 constructor(config) {
54
55 Object.assign(this, config);
56 }
57
58 /**
59 * Shifts the blog posts, adds the passed path to slot 0, and
60 * generates files.
61 *
62 * @function add
63 * @memberof Blog
64 * @param {string} postLocation the path to the directory containing
65 * the post structure
66 * @return {Promise<undefined>} empty promise, returns no value
67 * @instance
68 */
69 async add(postLocation) {
70
71 await this._ensurePostsDirectoryExists();
72 await this._shift();
73 await this._ensurePostsDirectoryExists(join(this.postsDirectory, '0'));
74 await this.update(postLocation);
75 }
76
77 /**
78 * Adds the passed path to slot 0, and generates files.
79 *
80 * @function update
81 * @memberof Blog
82 * @param {string} postLocation the path to the directory containing
83 * the post structure
84 * @return {Promise<undefined>} empty promise, returns no value
85 * @instance
86 */
87 async update(postLocation) {
88
89 const metadata = await this._getMetadata();
90 await this._ensurePostsDirectoryExists();
91 await this._copyPost(postLocation);
92 await this._writeMetadata(metadata);
93
94 await this._archive(postLocation);
95
96 await this.generate();
97 }
98
99 /**
100 * Publishes the files to a static host.
101 *
102 * @function publish
103 * @memberof Blog
104 * @return {Promise<undefined>} empty promise, returns no value
105 * @instance
106 */
107 async publish(bucket) {
108
109 internals.debuglog(`Publishing to ${bucket}`);
110 try {
111 await internals.exec('which aws');
112 }
113 catch (err) {
114 console.error('Please install and configure AWS CLI to publish.');
115 }
116
117 try {
118 await internals.exec(`aws s3 sync --acl public-read --delete ${this.staticDirectory} s3://${bucket}`);
119 await internals.exec(`aws s3 cp --content-type 'text/plain; charset=utf-8 ' --acl public-read ${this.staticDirectory}/index.txt s3://${bucket}`);
120 }
121 catch (err) {
122 console.error('Failed to publish');
123 console.error(err.stderr);
124 }
125
126 internals.debuglog('Finished publishing');
127 }
128
129 /**
130 * Publishes the archive to a host using rsync. Currently assumes
131 * gemlog archive.
132 *
133 * @function publishArchive
134 * @memberof Blog
135 * @return {Promise<undefined>} empty promise, returns no value
136 * @instance
137 */
138 async publishArchive(host) {
139
140 internals.debuglog(`Publishing archive to ${host}`);
141 try {
142 await internals.exec('which rsync');
143 }
144 catch (err) {
145 console.error('Please install rsync to publish the archive.');
146 }
147
148 try {
149 const gemlogPath = resolve(join(__dirname, '../', '.gemlog'));
150 internals.debuglog(`Reading archive from ${gemlogPath}`);
151 await internals.exec(`rsync -r ${gemlogPath}/ ${host}`);
152 }
153 catch (err) {
154 console.error('Failed to publish archive');
155 console.error(err.stderr);
156 }
157
158 internals.debuglog('Finished publishing');
159 }
160
161 // Parses Gemini for each page, copies assets and generates index.
162
163 async generate() {
164
165 internals.debuglog('Generating output');
166
167 const posts = await this._readPosts();
168
169 await StaticGenerator(this.postsDirectory, this.staticDirectory, posts);
170 await HTMLGenerator(this.templatesDirectory, this.staticDirectory, posts);
171 await RSSGenerator(this.templatesDirectory, this.staticDirectory, posts);
172 await TXTGenerator(this.templatesDirectory, this.staticDirectory, posts);
173
174 await GemlogArchiver(this.archiveDirectory);
175 }
176
177 // Reads the posts into an array
178
179 async _readPosts() {
180
181 internals.debuglog('Reading posts');
182 const posts = [];
183
184 for (let i = 0; i < this.maxPosts; ++i) {
185 try {
186 posts.push(await this._readPost(i));
187 }
188 catch (error) {
189 if (error.code === internals.kFileNotFoundError) {
190 internals.debuglog(`Skipping ${i}`);
191 continue;
192 }
193
194 throw error;
195 }
196 }
197
198 return posts;
199 }
200
201 // Reads an individual post
202
203 async _readPost(index=0) {
204 const postSourcePath = join(this.postsDirectory, `${index}`);
205
206 internals.debuglog(`Reading ${postSourcePath}`);
207
208 await access(postSourcePath);
209
210 const metadata = await this._getMetadata(index);
211
212 const postContentPath = await this._findBlogContent(postSourcePath);
213 internals.debuglog(`Reading ${postContentPath}`);
214 const postContent = await readFile(postContentPath, { encoding: 'utf8' });
215
216 internals.debuglog('Parsing Gemini');
217 return {
218 ...metadata,
219 location: postSourcePath,
220 index,
221 html: RenderGemini(ParseGemini(postContent)),
222 raw: postContent
223 };
224 }
225
226 // Shift the posts, delete any remainder.
227
228 async _shift() {
229
230
231 for (let i = this.maxPosts - 1; i >= 1; --i) {
232 const targetPath = join(this.postsDirectory, `${i}`);
233 const sourcePath = join(this.postsDirectory, `${i - 1}`);
234
235 try {
236 internals.debuglog(`Archiving ${targetPath}`);
237 await rm(targetPath, { recursive: true, force: true });
238 await access(sourcePath); // check the source path
239
240 internals.debuglog(`Shifting blog post ${sourcePath} to ${targetPath}`);
241 await internals.ncp(sourcePath, targetPath);
242 }
243 catch (error) {
244 if (error.code === internals.kFileNotFoundError) {
245 internals.debuglog(`Skipping ${sourcePath}: Does not exist.`);
246 continue;
247 }
248
249 throw error;
250 }
251 }
252 }
253
254 // Moves older posts to the archive
255
256 async _archive() {
257 internals.debuglog('Archiving post');
258 const post = await this._readPost(0);
259 await this._ensureDirectoryExists(this.archiveDirectory);
260
261 const targetPath = join(this.archiveDirectory, post.id);
262
263 try {
264 internals.debuglog(`Removing ${targetPath}`);
265 await rm(targetPath, { recursive: true });
266 }
267 finally {
268 internals.debuglog(`Adding ${post.location} to ${targetPath}`);
269 await this._ensureDirectoryExists(targetPath);
270 await internals.ncp(post.location, targetPath);
271 internals.debuglog(`Added ${post.location} to ${targetPath}`);
272 }
273 }
274
275 // Attempts to read existing metadata. Otherwise generates new set.
276
277 async _getMetadata(index = 0) {
278
279 const metadataTarget = join(this.postsDirectory, String(index), internals.kMetadataFilename);
280
281 try {
282 internals.debuglog(`Looking for metadata at ${metadataTarget}`);
283 return JSON.parse(await readFile(metadataTarget, { encoding: 'utf8' }));
284 }
285 catch (e) {
286 internals.debuglog(`Metadata not found or unreadable. Generating new set.`);
287 const createdOn = Date.now();
288 const metadata = {
289 id: String(createdOn),
290 createdOn
291 };
292
293 return metadata;
294 }
295 }
296
297 // Writes metadata. Assumes post 0 since it only gets written
298 // on create
299
300 async _writeMetadata(metadata) {
301
302 const metadataTarget = join(this.postsDirectory, '0', internals.kMetadataFilename);
303 internals.debuglog(`Writing ${metadataTarget}`);
304 await writeFile(metadataTarget, JSON.stringify(metadata, null, 2));
305 }
306
307 // Copies a post directory to the latest slot.
308
309 async _copyPost(postLocation) {
310
311 const targetPath = join(this.postsDirectory, '0');
312 const postName = basename(postLocation);
313 const targetPost = join(targetPath, postName);
314
315 internals.debuglog(`Removing ${targetPath}`);
316 try {
317 await rm(targetPath, { recursive: true });
318 }
319 finally {
320 await this._ensureDirectoryExists(targetPath);
321 internals.debuglog(`Adding ${postLocation} to ${targetPost}`);
322 await internals.ncp(postLocation, targetPost);
323 }
324 }
325
326 // Ensures a directory exists.
327
328 async _ensureDirectoryExists(directory) {
329
330 internals.debuglog(`Checking if ${directory} exists.`);
331 try {
332 await access(directory);
333 }
334 catch (error) {
335 if (error.code === internals.kFileNotFoundError) {
336 internals.debuglog(`Creating ${directory}`);
337 await mkdir(directory);
338 return;
339 }
340
341 throw error;
342 }
343 }
344
345 // Ensures posts directory exists
346
347 async _ensurePostsDirectoryExists() {
348
349 return this._ensureDirectoryExists(this.postsDirectory);
350 }
351
352 // Looks for a `.gmi` file in the blog directory, and returns the path
353
354 async _findBlogContent(directory) {
355
356 const entries = await readdir(directory);
357
358 const geminiEntries = entries
359 .filter((entry) => internals.kGeminiRe.test(entry))
360 .map((entry) => join(directory, entry));
361
362 if (geminiEntries.length > 0) {
363 internals.debuglog(`Found gemini file: ${geminiEntries[0]}`);
364 return geminiEntries[0];
365 }
366
367 throw new Error(internals.strings.geminiNotFound);
368 }
369 };