1 import { access
, cp
, readdir
, readFile
, writeFile
} from 'fs/promises';
2 import { exec
} from 'child_process';
3 import { basename
, resolve
, 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();
77 const firstDirectory
= join(this.postsDirectory
, '0');
78 await
rmIfExists(firstDirectory
);
79 await
ensureDirectoryExists(firstDirectory
);
80 await
this._update(postLocation
);
84 * Update slot 0 with the passed gmi file, and generates files.
88 * @param {string} postLocation the path to the blog post file
89 * @return {Promise<undefined>} empty promise, returns no value
92 async
update(postLocation
) {
95 await
this.syncDown();
98 const metadata
= await
this._update(postLocation
);
102 * Publishes the files to a static host.
106 * @return {Promise<undefined>} empty promise, returns no value
109 async
publish(host
) {
111 internals
.debuglog(`Publishing to ${host}`);
113 await internals
.exec('which rsync');
116 console
.error('Please install and configure rsync to publish.');
120 internals
.debuglog(`Copying ephemeral blog from ${this.blogOutputDirectory}`);
121 await internals
.exec(`rsync -r ${this.blogOutputDirectory}/ ${host}`);
124 console
.error('Failed to publish');
125 console
.error(err
.stderr
);
128 internals
.debuglog('Finished publishing');
132 * Publishes the archive to a host using rsync. Currently assumes
135 * @function publishArchive
137 * @return {Promise<undefined>} empty promise, returns no value
140 async
publishArchive(host
) {
142 internals
.debuglog(`Publishing archive to ${host}`);
144 await internals
.exec('which rsync');
147 console
.error('Please install rsync to publish the archive.');
151 internals
.debuglog(`Copying archive from ${this.archiveOutputDirectory}`);
152 await internals
.exec(`rsync -r ${this.archiveOutputDirectory}/ ${host}`);
155 console
.error('Failed to publish archive');
156 console
.error(err
.stderr
);
159 internals
.debuglog('Finished publishing');
165 * @function addRemote
167 * @return {Promise<undefined>} empty promise, returns no value
170 async
addRemote(remote
) {
171 await
ensureDirectoryExists(this.configDirectory
);
172 await Remote
.add(this.remoteConfig
, remote
)
178 * @function removeRemote
180 * @return {Promise<undefined>} empty promise, returns no value
183 async
removeRemote() {
184 await Remote
.remove(this.remoteConfig
)
189 * Pulls the posts and archive from the remote
193 * @return {Promise<undefined>} empty promise, returns no value
197 internals
.debuglog('Pulling remote state');
198 await
ensureDirectoryExists(this.dataDirectory
);
199 await Remote
.syncDown(this.remoteConfig
, this.dataDirectory
)
200 internals
.debuglog('Pulled remote state');
204 * Pushes the posts and archive to the remote
208 * @return {Promise<undefined>} empty promise, returns no value
212 internals
.debuglog('Pushing remote state');
213 await
ensureDirectoryExists(this.dataDirectory
);
214 await Remote
.syncUp(this.remoteConfig
, this.dataDirectory
)
215 internals
.debuglog('Pushed remote state');
218 // Adds the passed path to slot 0, and generates files.
220 async
_update(postLocation
) {
222 const metadata
= await
this._getMetadata();
223 await
ensureDirectoryExists(this.postsDirectory
);
224 await
this._copyPost(postLocation
);
225 await
this._writeMetadata(metadata
);
227 await
this._archive(postLocation
);
229 await
this.generate();
237 // Parses Gemini for each page, copies assets and generates index.
241 internals
.debuglog('Generating output');
243 const posts
= await
this._readPosts();
245 // Start from a clean slate.
246 await
rmIfExists(this.blogOutputDirectory
);
247 await
ensureDirectoryExists(this.blogOutputDirectory
);
249 // Run each generator
250 await
StaticGenerator(this.staticDirectory
, this.blogOutputDirectory
, posts
);
251 await
HTMLGenerator(await
this._templateDirectoryFor('index.html'), this.blogOutputDirectory
, posts
);
252 await
RSSGenerator(await
this._templateDirectoryFor('feed.xml'), this.blogOutputDirectory
, posts
);
253 await
TXTGenerator(await
this._templateDirectoryFor('index.txt'), this.blogOutputDirectory
, posts
);
255 // Start from a clean slate.
256 await
rmIfExists(this.archiveOutputDirectory
);
257 await
ensureDirectoryExists(this.archiveOutputDirectory
);
258 await
ensureDirectoryExists(this.archiveDirectory
);
261 await
GemlogArchiver(await
this._templateDirectoryFor('index.gmi'), this.archiveDirectory
, this.archiveOutputDirectory
);
262 // TODO: GopherArchiver
265 // Reads the posts into an array
269 internals
.debuglog('Reading posts');
272 for (let i
= 0; i
< this.maxPosts
; ++i
) {
274 posts
.push(await
this._readPost(i
));
277 if (error
.code
=== kFileNotFoundError
) {
278 internals
.debuglog(`Skipping ${i}`);
289 // Reads an individual post
291 async
_readPost(index
=0) {
292 const postSourcePath
= join(this.postsDirectory
, `${index}`);
294 internals
.debuglog(`Reading ${postSourcePath}`);
296 await
access(postSourcePath
);
298 const metadata
= await
this._getMetadata(index
);
300 const postContentPath
= await
this._findBlogContent(postSourcePath
);
301 internals
.debuglog(`Reading ${postContentPath}`);
302 const postContent
= await
readFile(postContentPath
, { encoding: 'utf8' });
304 internals
.debuglog('Parsing Gemini');
307 location: postSourcePath
,
309 html: RenderGemini(ParseGemini(postContent
)),
314 // Shift the posts, delete any remainder.
319 for (let i
= this.maxPosts
- 1; i
>= 1; --i
) {
320 const targetPath
= join(this.postsDirectory
, `${i}`);
321 const sourcePath
= join(this.postsDirectory
, `${i - 1}`);
324 internals
.debuglog(`Archiving ${targetPath}`);
325 await
rmIfExists(targetPath
);
326 await
access(sourcePath
); // check the source path
328 internals
.debuglog(`Shifting blog post ${sourcePath} to ${targetPath}`);
329 await
cp(sourcePath
, targetPath
, { recursive: true });
332 if (error
.code
=== kFileNotFoundError
) {
333 internals
.debuglog(`Skipping ${sourcePath}: Does not exist.`);
342 // Moves older posts to the archive
345 internals
.debuglog('Archiving post');
346 const post
= await
this._readPost(0);
347 await
ensureDirectoryExists(this.archiveDirectory
);
349 const targetPath
= join(this.archiveDirectory
, post
.id
);
351 internals
.debuglog(`Removing ${targetPath}`);
352 await
rmIfExists(targetPath
);
353 internals
.debuglog(`Adding ${post.location} to ${targetPath}`);
354 await
ensureDirectoryExists(targetPath
);
355 await
cp(post
.location
, targetPath
, { recursive: true });
356 internals
.debuglog(`Added ${post.location} to ${targetPath}`);
359 // Attempts to read existing metadata. Otherwise generates new set.
361 async
_getMetadata(index
= 0) {
363 const metadataTarget
= join(this.postsDirectory
, String(index
), internals
.kMetadataFilename
);
366 internals
.debuglog(`Looking for metadata at ${metadataTarget}`);
367 return JSON
.parse(await
readFile(metadataTarget
, { encoding: 'utf8' }));
370 internals
.debuglog(`Metadata not found or unreadable. Generating new set.`);
371 const createdOn
= Date
.now();
373 id: String(createdOn
),
381 // Writes metadata. Assumes post 0 since it only gets written
384 async
_writeMetadata(metadata
) {
386 const metadataTarget
= join(this.postsDirectory
, '0', internals
.kMetadataFilename
);
387 internals
.debuglog(`Writing ${metadataTarget}`);
388 await
writeFile(metadataTarget
, JSON
.stringify(metadata
, null, 2));
391 // Copies a post file to the latest slot.
393 async
_copyPost(postLocation
) {
395 internals
.debuglog(`Copying ${postLocation}`);
396 const targetPath
= join(this.postsDirectory
, '0');
397 const postName
= basename(postLocation
);
398 const targetPost
= join(targetPath
, postName
);
400 await
rmIfExists(targetPath
);
401 await
ensureDirectoryExists(targetPath
);
402 await
cp(postLocation
, targetPost
, { recursive: true });
403 internals
.debuglog(`Added ${postLocation} to ${targetPath}`);
406 // Looks for a `.gmi` file in the blog directory, and returns the path
408 async
_findBlogContent(directory
) {
410 const entries
= await
readdir(directory
);
412 const geminiEntries
= entries
413 .filter((entry
) => internals
.kGeminiRe
.test(entry
))
414 .map((entry
) => join(directory
, entry
));
416 if (geminiEntries
.length
> 0) {
417 internals
.debuglog(`Found gemini file: ${geminiEntries[0]}`);
418 return geminiEntries
[0];
421 throw new Error(internals
.strings
.geminiNotFound
);
424 // Gets the template directory for a given template.
425 async
_templateDirectoryFor(template
) {
427 await
access(join(this.templatesDirectory
, template
));
428 return this.templatesDirectory
;
431 if (error
.code
=== kFileNotFoundError
) {
432 internals
.debuglog(`No custom template for ${template}`);
433 return this.defaultTemplatesDirectory
;