]> git.r.bdr.sh - rbdr/blog/blame - lib/blog.js
Add archive publishing
[rbdr/blog] / lib / blog.js
CommitLineData
cf630290
BB
1'use strict';
2
fac54389
RBR
3const { access, mkdir, readdir, readFile, rm, writeFile } = require('fs/promises');
4const { exec } = require('child_process');
d92ac8cc 5const { ncp } = require('ncp');
65d379f5 6const { resolve, join } = require('path');
fac54389
RBR
7const ParseGemini = require('gemini-to-html/parse');
8const RenderGemini = require('gemini-to-html/render');
d92ac8cc 9const { debuglog, promisify } = require('util');
cf630290 10
fac54389
RBR
11// Generators for the Blog
12
67fdfa7c
BB
13const StaticGenerator = require('./generators/static');
14const HTMLGenerator = require('./generators/html');
15const RSSGenerator = require('./generators/rss');
5f31ea34 16const TXTGenerator = require('./generators/txt');
67fdfa7c 17
fac54389
RBR
18// Archiving Methods
19
20const GemlogArchiver = require('./archivers/gemlog');
21
cf630290
BB
22const internals = {
23
24 // Promisified functions
fac54389 25 exec: promisify(exec),
d92ac8cc 26 ncp: promisify(ncp),
cf630290 27
d92ac8cc 28 debuglog: debuglog('blog'),
cf630290
BB
29
30 // constants
31
cf630290 32 kFileNotFoundError: 'ENOENT',
fac54389 33 kGeminiRe: /\.gmi$/i,
6f72ad0f 34 kMetadataFilename: 'metadata.json',
cf630290
BB
35
36 // Strings
37
38 strings: {
fac54389 39 geminiNotFound: 'Gemini file was not found in blog directory. Please update.'
cf630290
BB
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
67fdfa7c 48 * @param {Blog.tConfiguration} config the initialization options to
cf630290
BB
49 * extend the instance
50 */
51module.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
6f72ad0f 71 await this._ensurePostsDirectoryExists();
cf630290 72 await this._shift();
fac54389 73 await mkdir(join(this.postsDirectory, '0'));
cf630290
BB
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
6f72ad0f
BB
89 const metadata = await this._getMetadata();
90 await this._ensurePostsDirectoryExists();
cf630290 91 await this._copyPost(postLocation);
6f72ad0f
BB
92 await this._writeMetadata(metadata);
93
fac54389
RBR
94 await this._archive(postLocation);
95
e54f8139 96 await this.generate();
cf630290
BB
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 */
fac54389
RBR
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 }
cf630290 125
fac54389 126 internals.debuglog('Finished publishing');
cf630290
BB
127 }
128
65d379f5
RBR
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
fac54389 161 // Parses Gemini for each page, copies assets and generates index.
cf630290 162
e54f8139 163 async generate() {
cf630290 164
67fdfa7c
BB
165 internals.debuglog('Generating output');
166
fac54389 167 const posts = await this._readPosts();
67fdfa7c
BB
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);
5f31ea34 172 await TXTGenerator(this.templatesDirectory, this.staticDirectory, posts);
fac54389
RBR
173
174 await GemlogArchiver(this.archiveDirectory);
67fdfa7c
BB
175 }
176
177 // Reads the posts into an array
cf630290 178
fac54389 179 async _readPosts() {
67fdfa7c
BB
180
181 internals.debuglog('Reading posts');
182 const posts = [];
cf630290
BB
183
184 for (let i = 0; i < this.maxPosts; ++i) {
67fdfa7c 185 try {
fac54389 186 posts.push(await this._readPost(i));
eccb3cc4
BB
187 }
188 catch (error) {
189 if (error.code === internals.kFileNotFoundError) {
190 internals.debuglog(`Skipping ${i}`);
191 continue;
192 }
193
194 throw error;
195 }
cf630290
BB
196 }
197
67fdfa7c 198 return posts;
cf630290
BB
199 }
200
fac54389
RBR
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
cf630290
BB
226 // Shift the posts, delete any remainder.
227
228 async _shift() {
229
cf630290 230
6f72ad0f 231 for (let i = this.maxPosts - 1; i >= 0; --i) {
d92ac8cc
BB
232 const targetPath = join(this.postsDirectory, `${i}`);
233 const sourcePath = join(this.postsDirectory, `${i - 1}`);
cf630290
BB
234
235 try {
fac54389
RBR
236 internals.debuglog(`Archiving ${targetPath}`);
237 await rm(targetPath, { recursive: true });
cf630290 238
6f72ad0f
BB
239 await access(sourcePath); // check the source path
240
cf630290
BB
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
fac54389
RBR
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
6f72ad0f
BB
275 // Attempts to read existing metadata. Otherwise generates new set.
276
67fdfa7c 277 async _getMetadata(index = 0) {
6f72ad0f 278
67fdfa7c 279 const metadataTarget = join(this.postsDirectory, String(index), internals.kMetadataFilename);
6f72ad0f
BB
280
281 try {
282 internals.debuglog(`Looking for metadata at ${metadataTarget}`);
67fdfa7c 283 return JSON.parse(await readFile(metadataTarget, { encoding: 'utf8' }));
6f72ad0f
BB
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
67fdfa7c 293 return metadata;
6f72ad0f
BB
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}`);
67fdfa7c 304 await writeFile(metadataTarget, JSON.stringify(metadata, null, 2));
6f72ad0f
BB
305 }
306
cf630290
BB
307 // Copies a post directory to the latest slot.
308
309 async _copyPost(postLocation) {
310
d92ac8cc 311 const targetPath = join(this.postsDirectory, '0');
cf630290
BB
312
313 internals.debuglog(`Removing ${targetPath}`);
fac54389 314 await rm(targetPath, { recursive: true });
cf630290
BB
315
316 internals.debuglog(`Adding ${postLocation} to ${targetPath}`);
317 await internals.ncp(postLocation, targetPath);
318 }
319
fac54389
RBR
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
cf630290
BB
340
341 async _ensurePostsDirectoryExists() {
342
fac54389 343 return this._ensureDirectoryExists(this.postsDirectory);
cf630290
BB
344 internals.debuglog(`Checking if ${this.postsDirectory} exists.`);
345 try {
d92ac8cc 346 await access(this.postsDirectory);
cf630290
BB
347 }
348 catch (error) {
349 if (error.code === internals.kFileNotFoundError) {
350 internals.debuglog('Creating posts directory');
d92ac8cc 351 await mkdir(this.postsDirectory);
cf630290
BB
352 return;
353 }
354
355 throw error;
356 }
357 }
358
fac54389 359 // Looks for a `.gmi` file in the blog directory, and returns the path
cf630290
BB
360
361 async _findBlogContent(directory) {
362
d92ac8cc 363 const entries = await readdir(directory);
cf630290 364
fac54389
RBR
365 const geminiEntries = entries
366 .filter((entry) => internals.kGeminiRe.test(entry))
d92ac8cc 367 .map((entry) => join(directory, entry));
cf630290 368
fac54389
RBR
369 if (geminiEntries.length > 0) {
370 internals.debuglog(`Found gemini file: ${geminiEntries[0]}`);
371 return geminiEntries[0];
cf630290
BB
372 }
373
fac54389 374 throw new Error(internals.strings.geminiNotFound);
cf630290
BB
375 }
376};