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