]> git.r.bdr.sh - rbdr/blog/blame - lib/blog.js
Allow adding / updating
[rbdr/blog] / lib / blog.js
CommitLineData
6cd62e79
RBR
1import { access, cp, readdir, readFile, writeFile } from 'fs/promises';
2import { exec } from 'child_process';
10a76a5b 3import { basename, join } from 'path';
6cd62e79
RBR
4import ParseGemini from 'gemini-to-html/parse.js';
5import RenderGemini from 'gemini-to-html/render.js';
6import { debuglog, promisify } from 'util';
7import { ensureDirectoryExists, rmIfExists } from './utils.js';
8import { kFileNotFoundError } from './constants.js';
cf630290 9
fac54389
RBR
10// Generators for the Blog
11
6cd62e79
RBR
12import StaticGenerator from './generators/static.js';
13import HTMLGenerator from './generators/html.js';
14import RSSGenerator from './generators/rss.js';
15import TXTGenerator from './generators/txt.js';
67fdfa7c 16
fac54389
RBR
17// Archiving Methods
18
6cd62e79 19import GemlogArchiver from './archivers/gemlog.js';
fac54389 20
c5cbbd38
RBR
21// Remote Handler
22
6cd62e79 23import Remote from './remote.js';
c5cbbd38 24
cf630290
BB
25const internals = {
26
27 // Promisified functions
fac54389 28 exec: promisify(exec),
cf630290 29
d92ac8cc 30 debuglog: debuglog('blog'),
cf630290
BB
31
32 // constants
33
fac54389 34 kGeminiRe: /\.gmi$/i,
6f72ad0f 35 kMetadataFilename: 'metadata.json',
cf630290
BB
36
37 // Strings
38
39 strings: {
fac54389 40 geminiNotFound: 'Gemini file was not found in blog directory. Please update.'
cf630290
BB
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
67fdfa7c 49 * @param {Blog.tConfiguration} config the initialization options to
cf630290
BB
50 * extend the instance
51 */
6cd62e79 52export default class Blog {
cf630290
BB
53
54 constructor(config) {
55
56 Object.assign(this, config);
57 }
58
59 /**
2acbdb03 60 * Shifts the blog posts, adds the passed file to slot 0, and
cf630290
BB
61 * generates files.
62 *
63 * @function add
64 * @memberof Blog
2acbdb03 65 * @param {string} postLocation the path to the blog post file
cf630290
BB
66 * @return {Promise<undefined>} empty promise, returns no value
67 * @instance
68 */
69 async add(postLocation) {
70
d3f282a1 71 await ensureDirectoryExists(this.postsDirectory);
c5cbbd38
RBR
72 try {
73 await this.syncDown();
74 }
10a76a5b
RBR
75 catch {}
76
cf630290 77 await this._shift();
10a76a5b 78 const firstDirectory = join(this.postsDirectory, '0');
d3f282a1
RBR
79 await rmIfExists(firstDirectory);
80 await ensureDirectoryExists(firstDirectory);
bd8c1fa2 81 await this._update(postLocation);
cf630290
BB
82 }
83
84 /**
2acbdb03 85 * Update slot 0 with the passed gmi file, and generates files.
cf630290
BB
86 *
87 * @function update
88 * @memberof Blog
2acbdb03 89 * @param {string} postLocation the path to the blog post file
cf630290
BB
90 * @return {Promise<undefined>} empty promise, returns no value
91 * @instance
92 */
93 async update(postLocation) {
94
c5cbbd38
RBR
95 try {
96 await this.syncDown();
97 }
10a76a5b
RBR
98 catch {}
99
100 await this._update(postLocation);
cf630290
BB
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 */
d50db372 111 async publish(host) {
fac54389 112
d50db372 113 internals.debuglog(`Publishing to ${host}`);
fac54389 114 try {
6cd62e79 115 await internals.exec('which rsync');
fac54389
RBR
116 }
117 catch (err) {
6cd62e79 118 console.error('Please install and configure rsync to publish.');
fac54389
RBR
119 }
120
121 try {
6cd62e79
RBR
122 internals.debuglog(`Copying ephemeral blog from ${this.blogOutputDirectory}`);
123 await internals.exec(`rsync -r ${this.blogOutputDirectory}/ ${host}`);
fac54389
RBR
124 }
125 catch (err) {
126 console.error('Failed to publish');
127 console.error(err.stderr);
128 }
cf630290 129
fac54389 130 internals.debuglog('Finished publishing');
cf630290
BB
131 }
132
65d379f5
RBR
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 {
6cd62e79
RBR
153 internals.debuglog(`Copying archive from ${this.archiveOutputDirectory}`);
154 await internals.exec(`rsync -r ${this.archiveOutputDirectory}/ ${host}`);
65d379f5
RBR
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
c5cbbd38
RBR
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) {
10a76a5b 173
6cd62e79 174 await ensureDirectoryExists(this.configDirectory);
10a76a5b 175 await Remote.add(this.remoteConfig, remote);
c5cbbd38
RBR
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() {
10a76a5b
RBR
187
188 await Remote.remove(this.remoteConfig);
c5cbbd38
RBR
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() {
10a76a5b 201
bd8c1fa2 202 internals.debuglog('Pulling remote state');
6cd62e79 203 await ensureDirectoryExists(this.dataDirectory);
10a76a5b 204 await Remote.syncDown(this.remoteConfig, this.dataDirectory);
bd8c1fa2 205 internals.debuglog('Pulled remote state');
c5cbbd38
RBR
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() {
10a76a5b 217
bd8c1fa2 218 internals.debuglog('Pushing remote state');
6cd62e79 219 await ensureDirectoryExists(this.dataDirectory);
10a76a5b 220 await Remote.syncUp(this.remoteConfig, this.dataDirectory);
bd8c1fa2 221 internals.debuglog('Pushed remote state');
c5cbbd38
RBR
222 }
223
bd8c1fa2
RBR
224 // Adds the passed path to slot 0, and generates files.
225
226 async _update(postLocation) {
227
228 const metadata = await this._getMetadata();
d3f282a1 229 await ensureDirectoryExists(this.postsDirectory);
bd8c1fa2
RBR
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 }
10a76a5b 239 catch {}
bd8c1fa2
RBR
240 }
241
242
fac54389 243 // Parses Gemini for each page, copies assets and generates index.
cf630290 244
e54f8139 245 async generate() {
cf630290 246
67fdfa7c
BB
247 internals.debuglog('Generating output');
248
fac54389 249 const posts = await this._readPosts();
67fdfa7c 250
6cd62e79
RBR
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);
fac54389 265
6cd62e79
RBR
266 // Run each archiver
267 await GemlogArchiver(await this._templateDirectoryFor('index.gmi'), this.archiveDirectory, this.archiveOutputDirectory);
268 // TODO: GopherArchiver
67fdfa7c
BB
269 }
270
271 // Reads the posts into an array
cf630290 272
fac54389 273 async _readPosts() {
67fdfa7c
BB
274
275 internals.debuglog('Reading posts');
276 const posts = [];
cf630290
BB
277
278 for (let i = 0; i < this.maxPosts; ++i) {
67fdfa7c 279 try {
fac54389 280 posts.push(await this._readPost(i));
eccb3cc4
BB
281 }
282 catch (error) {
d3f282a1 283 if (error.code === kFileNotFoundError) {
eccb3cc4
BB
284 internals.debuglog(`Skipping ${i}`);
285 continue;
286 }
287
288 throw error;
289 }
cf630290
BB
290 }
291
67fdfa7c 292 return posts;
cf630290
BB
293 }
294
fac54389
RBR
295 // Reads an individual post
296
10a76a5b 297 async _readPost(index = 0) {
fac54389 298
10a76a5b 299 const postSourcePath = join(this.postsDirectory, `${index}`);
fac54389 300
10a76a5b 301 internals.debuglog(`Reading ${postSourcePath}`);
fac54389 302
10a76a5b 303 await access(postSourcePath);
fac54389 304
10a76a5b 305 const metadata = await this._getMetadata(index);
fac54389 306
10a76a5b
RBR
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 };
fac54389
RBR
319 }
320
cf630290
BB
321 // Shift the posts, delete any remainder.
322
323 async _shift() {
324
cf630290 325
24de2f06 326 for (let i = this.maxPosts - 1; i >= 1; --i) {
d92ac8cc
BB
327 const targetPath = join(this.postsDirectory, `${i}`);
328 const sourcePath = join(this.postsDirectory, `${i - 1}`);
cf630290
BB
329
330 try {
fac54389 331 internals.debuglog(`Archiving ${targetPath}`);
d3f282a1 332 await rmIfExists(targetPath);
6f72ad0f
BB
333 await access(sourcePath); // check the source path
334
cf630290 335 internals.debuglog(`Shifting blog post ${sourcePath} to ${targetPath}`);
c5cbbd38 336 await cp(sourcePath, targetPath, { recursive: true });
cf630290
BB
337 }
338 catch (error) {
d3f282a1 339 if (error.code === kFileNotFoundError) {
cf630290
BB
340 internals.debuglog(`Skipping ${sourcePath}: Does not exist.`);
341 continue;
342 }
343
344 throw error;
345 }
346 }
347 }
348
fac54389
RBR
349 // Moves older posts to the archive
350
351 async _archive() {
10a76a5b 352
fac54389
RBR
353 internals.debuglog('Archiving post');
354 const post = await this._readPost(0);
d3f282a1 355 await ensureDirectoryExists(this.archiveDirectory);
fac54389
RBR
356
357 const targetPath = join(this.archiveDirectory, post.id);
358
c5cbbd38 359 internals.debuglog(`Removing ${targetPath}`);
d3f282a1 360 await rmIfExists(targetPath);
c5cbbd38 361 internals.debuglog(`Adding ${post.location} to ${targetPath}`);
d3f282a1 362 await ensureDirectoryExists(targetPath);
c5cbbd38
RBR
363 await cp(post.location, targetPath, { recursive: true });
364 internals.debuglog(`Added ${post.location} to ${targetPath}`);
fac54389
RBR
365 }
366
6f72ad0f
BB
367 // Attempts to read existing metadata. Otherwise generates new set.
368
67fdfa7c 369 async _getMetadata(index = 0) {
6f72ad0f 370
67fdfa7c 371 const metadataTarget = join(this.postsDirectory, String(index), internals.kMetadataFilename);
6f72ad0f
BB
372
373 try {
374 internals.debuglog(`Looking for metadata at ${metadataTarget}`);
67fdfa7c 375 return JSON.parse(await readFile(metadataTarget, { encoding: 'utf8' }));
6f72ad0f
BB
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
67fdfa7c 385 return metadata;
6f72ad0f
BB
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}`);
67fdfa7c 396 await writeFile(metadataTarget, JSON.stringify(metadata, null, 2));
6f72ad0f
BB
397 }
398
2acbdb03 399 // Copies a post file to the latest slot.
cf630290
BB
400
401 async _copyPost(postLocation) {
402
2acbdb03 403 internals.debuglog(`Copying ${postLocation}`);
d92ac8cc 404 const targetPath = join(this.postsDirectory, '0');
24de2f06
RBR
405 const postName = basename(postLocation);
406 const targetPost = join(targetPath, postName);
cf630290 407
d3f282a1
RBR
408 await rmIfExists(targetPath);
409 await ensureDirectoryExists(targetPath);
c5cbbd38
RBR
410 await cp(postLocation, targetPost, { recursive: true });
411 internals.debuglog(`Added ${postLocation} to ${targetPath}`);
cf630290
BB
412 }
413
fac54389 414 // Looks for a `.gmi` file in the blog directory, and returns the path
cf630290
BB
415
416 async _findBlogContent(directory) {
417
d92ac8cc 418 const entries = await readdir(directory);
cf630290 419
fac54389
RBR
420 const geminiEntries = entries
421 .filter((entry) => internals.kGeminiRe.test(entry))
d92ac8cc 422 .map((entry) => join(directory, entry));
cf630290 423
fac54389
RBR
424 if (geminiEntries.length > 0) {
425 internals.debuglog(`Found gemini file: ${geminiEntries[0]}`);
426 return geminiEntries[0];
cf630290
BB
427 }
428
fac54389 429 throw new Error(internals.strings.geminiNotFound);
cf630290 430 }
6cd62e79
RBR
431
432 // Gets the template directory for a given template.
433 async _templateDirectoryFor(template) {
10a76a5b 434
6cd62e79
RBR
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 }
10a76a5b 448}