]> git.r.bdr.sh - rbdr/blog/blob - lib/blog.js
ff52554a07ed5faa44c817f4e12bceb35f7ee539
[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 { 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 mkdir(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 >= 0; --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 });
238
239 await access(sourcePath); // check the source path
240
241 internals.debuglog(`Shifting blog post ${sourcePath} to ${targetPath}`);
242 await internals.ncp(sourcePath, targetPath);
243 }
244 catch (error) {
245 if (error.code === internals.kFileNotFoundError) {
246 internals.debuglog(`Skipping ${sourcePath}: Does not exist.`);
247 continue;
248 }
249
250 throw error;
251 }
252 }
253 }
254
255 // Moves older posts to the archive
256
257 async _archive() {
258 internals.debuglog('Archiving post');
259 const post = await this._readPost(0);
260 await this._ensureDirectoryExists(this.archiveDirectory);
261
262 const targetPath = join(this.archiveDirectory, post.id);
263
264 try {
265 internals.debuglog(`Removing ${targetPath}`);
266 await rm(targetPath, { recursive: true });
267 }
268 finally {
269 internals.debuglog(`Adding ${post.location} to ${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
313 internals.debuglog(`Removing ${targetPath}`);
314 await rm(targetPath, { recursive: true });
315
316 internals.debuglog(`Adding ${postLocation} to ${targetPath}`);
317 await internals.ncp(postLocation, targetPath);
318 }
319
320 // Ensures a directory exists.
321
322 async _ensureDirectoryExists(directory) {
323
324 internals.debuglog(`Checking if ${directory} exists.`);
325 try {
326 await access(directory);
327 }
328 catch (error) {
329 if (error.code === internals.kFileNotFoundError) {
330 internals.debuglog('Creating posts directory');
331 await mkdir(directory);
332 return;
333 }
334
335 throw error;
336 }
337 }
338
339 // Ensures posts directory exists
340
341 async _ensurePostsDirectoryExists() {
342
343 return this._ensureDirectoryExists(this.postsDirectory);
344 internals.debuglog(`Checking if ${this.postsDirectory} exists.`);
345 try {
346 await access(this.postsDirectory);
347 }
348 catch (error) {
349 if (error.code === internals.kFileNotFoundError) {
350 internals.debuglog('Creating posts directory');
351 await mkdir(this.postsDirectory);
352 return;
353 }
354
355 throw error;
356 }
357 }
358
359 // Looks for a `.gmi` file in the blog directory, and returns the path
360
361 async _findBlogContent(directory) {
362
363 const entries = await readdir(directory);
364
365 const geminiEntries = entries
366 .filter((entry) => internals.kGeminiRe.test(entry))
367 .map((entry) => join(directory, entry));
368
369 if (geminiEntries.length > 0) {
370 internals.debuglog(`Found gemini file: ${geminiEntries[0]}`);
371 return geminiEntries[0];
372 }
373
374 throw new Error(internals.strings.geminiNotFound);
375 }
376 };