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';
10 // Generators for the Blog
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';
19 import GemlogArchiver
from './archivers/gemlog.js';
23 import Remote
from './remote.js';
27 // Promisified functions
28 exec: promisify(exec
),
30 debuglog: debuglog('blog'),
35 kMetadataFilename: 'metadata.json',
40 geminiNotFound: 'Gemini file was not found in blog directory. Please update.'
45 * The Blog class is the blog generator, it's in charge of adding and
46 * updating posts, and handling the publishing.
49 * @param {Blog.tConfiguration} config the initialization options to
52 export default class Blog
{
56 Object
.assign(this, config
);
60 * Shifts the blog posts, adds the passed file to slot 0, and
65 * @param {string} postLocation the path to the blog post file
66 * @return {Promise<undefined>} empty promise, returns no value
69 async
add(postLocation
) {
71 await
ensureDirectoryExists(this.postsDirectory
);
73 await
this.syncDown();
78 const firstDirectory
= join(this.postsDirectory
, '0');
79 await
rmIfExists(firstDirectory
);
80 await
ensureDirectoryExists(firstDirectory
);
81 await
this._update(postLocation
);
85 * Update slot 0 with the passed gmi file, and generates files.
89 * @param {string} postLocation the path to the blog post file
90 * @return {Promise<undefined>} empty promise, returns no value
93 async
update(postLocation
) {
96 await
this.syncDown();
100 await
this._update(postLocation
);
104 * Publishes the files to a static host.
108 * @return {Promise<undefined>} empty promise, returns no value
111 async
publish(host
) {
113 internals
.debuglog(`Publishing to ${host}`);
115 await internals
.exec('which rsync');
118 console
.error('Please install and configure rsync to publish.');
122 internals
.debuglog(`Copying ephemeral blog from ${this.blogOutputDirectory}`);
123 await internals
.exec(`rsync -r ${this.blogOutputDirectory}/ ${host}`);
126 console
.error('Failed to publish');
127 console
.error(err
.stderr
);
130 internals
.debuglog('Finished publishing');
134 * Publishes the archive to a host using rsync. Currently assumes
137 * @function publishArchive
139 * @return {Promise<undefined>} empty promise, returns no value
142 async
publishArchive(host
) {
144 internals
.debuglog(`Publishing archive to ${host}`);
146 await internals
.exec('which rsync');
149 console
.error('Please install rsync to publish the archive.');
153 internals
.debuglog(`Copying archive from ${this.archiveOutputDirectory}`);
154 await internals
.exec(`rsync -r ${this.archiveOutputDirectory}/ ${host}`);
157 console
.error('Failed to publish archive');
158 console
.error(err
.stderr
);
161 internals
.debuglog('Finished publishing');
167 * @function addRemote
169 * @return {Promise<undefined>} empty promise, returns no value
172 async
addRemote(remote
) {
174 await
ensureDirectoryExists(this.configDirectory
);
175 await Remote
.add(this.remoteConfig
, remote
);
181 * @function removeRemote
183 * @return {Promise<undefined>} empty promise, returns no value
186 async
removeRemote() {
188 await Remote
.remove(this.remoteConfig
);
193 * Pulls the posts and archive from the remote
197 * @return {Promise<undefined>} empty promise, returns no value
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');
209 * Pushes the posts and archive to the remote
213 * @return {Promise<undefined>} empty promise, returns no value
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');
224 // Adds the passed path to slot 0, and generates files.
226 async
_update(postLocation
) {
228 const metadata
= await
this._getMetadata();
229 await
ensureDirectoryExists(this.postsDirectory
);
230 await
this._copyPost(postLocation
);
231 await
this._writeMetadata(metadata
);
233 await
this._archive(postLocation
);
235 await
this.generate();
243 // Parses Gemini for each page, copies assets and generates index.
247 internals
.debuglog('Generating output');
249 const posts
= await
this._readPosts();
251 // Start from a clean slate.
252 await
rmIfExists(this.blogOutputDirectory
);
253 await
ensureDirectoryExists(this.blogOutputDirectory
);
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
);
261 // Start from a clean slate.
262 await
rmIfExists(this.archiveOutputDirectory
);
263 await
ensureDirectoryExists(this.archiveOutputDirectory
);
264 await
ensureDirectoryExists(this.archiveDirectory
);
267 await
GemlogArchiver(await
this._templateDirectoryFor('index.gmi'), this.archiveDirectory
, this.archiveOutputDirectory
);
268 // TODO: GopherArchiver
271 // Reads the posts into an array
275 internals
.debuglog('Reading posts');
278 for (let i
= 0; i
< this.maxPosts
; ++i
) {
280 posts
.push(await
this._readPost(i
));
283 if (error
.code
=== kFileNotFoundError
) {
284 internals
.debuglog(`Skipping ${i}`);
295 // Reads an individual post
297 async
_readPost(index
= 0) {
299 const postSourcePath
= join(this.postsDirectory
, `${index}`);
301 internals
.debuglog(`Reading ${postSourcePath}`);
303 await
access(postSourcePath
);
305 const metadata
= await
this._getMetadata(index
);
307 const postContentPath
= await
this._findBlogContent(postSourcePath
);
308 internals
.debuglog(`Reading ${postContentPath}`);
309 const postContent
= await
readFile(postContentPath
, { encoding: 'utf8' });
311 internals
.debuglog('Parsing Gemini');
314 location: postSourcePath
,
316 html: RenderGemini(ParseGemini(postContent
)),
321 // Shift the posts, delete any remainder.
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}`);
331 internals
.debuglog(`Archiving ${targetPath}`);
332 await
rmIfExists(targetPath
);
333 await
access(sourcePath
); // check the source path
335 internals
.debuglog(`Shifting blog post ${sourcePath} to ${targetPath}`);
336 await
cp(sourcePath
, targetPath
, { recursive: true });
339 if (error
.code
=== kFileNotFoundError
) {
340 internals
.debuglog(`Skipping ${sourcePath}: Does not exist.`);
349 // Moves older posts to the archive
353 internals
.debuglog('Archiving post');
354 const post
= await
this._readPost(0);
355 await
ensureDirectoryExists(this.archiveDirectory
);
357 const targetPath
= join(this.archiveDirectory
, post
.id
);
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}`);
367 // Attempts to read existing metadata. Otherwise generates new set.
369 async
_getMetadata(index
= 0) {
371 const metadataTarget
= join(this.postsDirectory
, String(index
), internals
.kMetadataFilename
);
374 internals
.debuglog(`Looking for metadata at ${metadataTarget}`);
375 return JSON
.parse(await
readFile(metadataTarget
, { encoding: 'utf8' }));
378 internals
.debuglog(`Metadata not found or unreadable. Generating new set.`);
379 const createdOn
= Date
.now();
381 id: String(createdOn
),
389 // Writes metadata. Assumes post 0 since it only gets written
392 async
_writeMetadata(metadata
) {
394 const metadataTarget
= join(this.postsDirectory
, '0', internals
.kMetadataFilename
);
395 internals
.debuglog(`Writing ${metadataTarget}`);
396 await
writeFile(metadataTarget
, JSON
.stringify(metadata
, null, 2));
399 // Copies a post file to the latest slot.
401 async
_copyPost(postLocation
) {
403 internals
.debuglog(`Copying ${postLocation}`);
404 const targetPath
= join(this.postsDirectory
, '0');
405 const postName
= basename(postLocation
);
406 const targetPost
= join(targetPath
, postName
);
408 await
rmIfExists(targetPath
);
409 await
ensureDirectoryExists(targetPath
);
410 await
cp(postLocation
, targetPost
, { recursive: true });
411 internals
.debuglog(`Added ${postLocation} to ${targetPath}`);
414 // Looks for a `.gmi` file in the blog directory, and returns the path
416 async
_findBlogContent(directory
) {
418 const entries
= await
readdir(directory
);
420 const geminiEntries
= entries
421 .filter((entry
) => internals
.kGeminiRe
.test(entry
))
422 .map((entry
) => join(directory
, entry
));
424 if (geminiEntries
.length
> 0) {
425 internals
.debuglog(`Found gemini file: ${geminiEntries[0]}`);
426 return geminiEntries
[0];
429 throw new Error(internals
.strings
.geminiNotFound
);
432 // Gets the template directory for a given template.
433 async
_templateDirectoryFor(template
) {
436 await
access(join(this.templatesDirectory
, template
));
437 return this.templatesDirectory
;
440 if (error
.code
=== kFileNotFoundError
) {
441 internals
.debuglog(`No custom template for ${template}`);
442 return this.defaultTemplatesDirectory
;