]> git.r.bdr.sh - rbdr/blog/blame - lib/blog.js
Add rudimentary sync support
[rbdr/blog] / lib / blog.js
CommitLineData
cf630290
BB
1'use strict';
2
c5cbbd38 3const { access, cp, mkdir, readdir, readFile, rm, writeFile } = require('fs/promises');
fac54389 4const { exec } = require('child_process');
24de2f06 5const { basename, resolve, join } = require('path');
fac54389
RBR
6const ParseGemini = require('gemini-to-html/parse');
7const RenderGemini = require('gemini-to-html/render');
d92ac8cc 8const { debuglog, promisify } = require('util');
cf630290 9
fac54389
RBR
10// Generators for the Blog
11
67fdfa7c
BB
12const StaticGenerator = require('./generators/static');
13const HTMLGenerator = require('./generators/html');
14const RSSGenerator = require('./generators/rss');
5f31ea34 15const TXTGenerator = require('./generators/txt');
67fdfa7c 16
fac54389
RBR
17// Archiving Methods
18
19const GemlogArchiver = require('./archivers/gemlog');
20
c5cbbd38
RBR
21// Remote Handler
22
23const Remote = require('./remote');
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
cf630290 34 kFileNotFoundError: 'ENOENT',
fac54389 35 kGeminiRe: /\.gmi$/i,
6f72ad0f 36 kMetadataFilename: 'metadata.json',
cf630290
BB
37
38 // Strings
39
40 strings: {
fac54389 41 geminiNotFound: 'Gemini file was not found in blog directory. Please update.'
cf630290
BB
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
67fdfa7c 50 * @param {Blog.tConfiguration} config the initialization options to
cf630290
BB
51 * extend the instance
52 */
53module.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
6f72ad0f 73 await this._ensurePostsDirectoryExists();
c5cbbd38
RBR
74 try {
75 await this.syncDown();
76 }
77 catch {};
cf630290 78 await this._shift();
24de2f06 79 await this._ensurePostsDirectoryExists(join(this.postsDirectory, '0'));
cf630290
BB
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
c5cbbd38
RBR
95 try {
96 await this.syncDown();
97 }
98 catch {};
6f72ad0f
BB
99 const metadata = await this._getMetadata();
100 await this._ensurePostsDirectoryExists();
cf630290 101 await this._copyPost(postLocation);
6f72ad0f
BB
102 await this._writeMetadata(metadata);
103
fac54389
RBR
104 await this._archive(postLocation);
105
e54f8139 106 await this.generate();
c5cbbd38
RBR
107 try {
108 await this.syncUp();
109 }
110 catch {};
cf630290
BB
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 */
fac54389
RBR
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 }
cf630290 139
fac54389 140 internals.debuglog('Finished publishing');
cf630290
BB
141 }
142
65d379f5
RBR
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
c5cbbd38
RBR
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
fac54389 224 // Parses Gemini for each page, copies assets and generates index.
cf630290 225
e54f8139 226 async generate() {
cf630290 227
67fdfa7c
BB
228 internals.debuglog('Generating output');
229
fac54389 230 const posts = await this._readPosts();
67fdfa7c
BB
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);
5f31ea34 235 await TXTGenerator(this.templatesDirectory, this.staticDirectory, posts);
fac54389
RBR
236
237 await GemlogArchiver(this.archiveDirectory);
67fdfa7c
BB
238 }
239
240 // Reads the posts into an array
cf630290 241
fac54389 242 async _readPosts() {
67fdfa7c
BB
243
244 internals.debuglog('Reading posts');
245 const posts = [];
cf630290
BB
246
247 for (let i = 0; i < this.maxPosts; ++i) {
67fdfa7c 248 try {
fac54389 249 posts.push(await this._readPost(i));
eccb3cc4
BB
250 }
251 catch (error) {
252 if (error.code === internals.kFileNotFoundError) {
253 internals.debuglog(`Skipping ${i}`);
254 continue;
255 }
256
257 throw error;
258 }
cf630290
BB
259 }
260
67fdfa7c 261 return posts;
cf630290
BB
262 }
263
fac54389
RBR
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
cf630290
BB
289 // Shift the posts, delete any remainder.
290
291 async _shift() {
292
cf630290 293
24de2f06 294 for (let i = this.maxPosts - 1; i >= 1; --i) {
d92ac8cc
BB
295 const targetPath = join(this.postsDirectory, `${i}`);
296 const sourcePath = join(this.postsDirectory, `${i - 1}`);
cf630290
BB
297
298 try {
fac54389 299 internals.debuglog(`Archiving ${targetPath}`);
24de2f06 300 await rm(targetPath, { recursive: true, force: true });
6f72ad0f
BB
301 await access(sourcePath); // check the source path
302
cf630290 303 internals.debuglog(`Shifting blog post ${sourcePath} to ${targetPath}`);
c5cbbd38 304 await cp(sourcePath, targetPath, { recursive: true });
cf630290
BB
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
fac54389
RBR
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
c5cbbd38
RBR
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}`);
fac54389
RBR
332 }
333
6f72ad0f
BB
334 // Attempts to read existing metadata. Otherwise generates new set.
335
67fdfa7c 336 async _getMetadata(index = 0) {
6f72ad0f 337
67fdfa7c 338 const metadataTarget = join(this.postsDirectory, String(index), internals.kMetadataFilename);
6f72ad0f
BB
339
340 try {
341 internals.debuglog(`Looking for metadata at ${metadataTarget}`);
67fdfa7c 342 return JSON.parse(await readFile(metadataTarget, { encoding: 'utf8' }));
6f72ad0f
BB
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
67fdfa7c 352 return metadata;
6f72ad0f
BB
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}`);
67fdfa7c 363 await writeFile(metadataTarget, JSON.stringify(metadata, null, 2));
6f72ad0f
BB
364 }
365
cf630290
BB
366 // Copies a post directory to the latest slot.
367
368 async _copyPost(postLocation) {
369
d92ac8cc 370 const targetPath = join(this.postsDirectory, '0');
24de2f06
RBR
371 const postName = basename(postLocation);
372 const targetPost = join(targetPath, postName);
cf630290
BB
373
374 internals.debuglog(`Removing ${targetPath}`);
c5cbbd38
RBR
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}`);
cf630290
BB
380 }
381
fac54389
RBR
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) {
24de2f06 392 internals.debuglog(`Creating ${directory}`);
c5cbbd38 393 await mkdir(directory, { recursive: true });
fac54389
RBR
394 return;
395 }
396
397 throw error;
398 }
399 }
400
401 // Ensures posts directory exists
cf630290
BB
402
403 async _ensurePostsDirectoryExists() {
404
fac54389 405 return this._ensureDirectoryExists(this.postsDirectory);
cf630290
BB
406 }
407
fac54389 408 // Looks for a `.gmi` file in the blog directory, and returns the path
cf630290
BB
409
410 async _findBlogContent(directory) {
411
d92ac8cc 412 const entries = await readdir(directory);
cf630290 413
fac54389
RBR
414 const geminiEntries = entries
415 .filter((entry) => internals.kGeminiRe.test(entry))
d92ac8cc 416 .map((entry) => join(directory, entry));
cf630290 417
fac54389
RBR
418 if (geminiEntries.length > 0) {
419 internals.debuglog(`Found gemini file: ${geminiEntries[0]}`);
420 return geminiEntries[0];
cf630290
BB
421 }
422
fac54389 423 throw new Error(internals.strings.geminiNotFound);
cf630290
BB
424 }
425};