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