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