]> git.r.bdr.sh - rbdr/blog/blob - lib/blog.js
27a440e651bbb485c4091a4e8de5ef5100e930f7
[rbdr/blog] / lib / blog.js
1 'use strict';
2
3 const { access, mkdir, readdir, readFile, rmdir, writeFile } = require('fs/promises');
4 const { ncp } = require('ncp');
5 const { join } = require('path');
6 const Marked = require('marked');
7 const { debuglog, promisify } = require('util');
8
9 const StaticGenerator = require('./generators/static');
10 const HTMLGenerator = require('./generators/html');
11 const RSSGenerator = require('./generators/rss');
12 const TXTGenerator = require('./generators/txt');
13
14 const internals = {
15
16 // Promisified functions
17 ncp: promisify(ncp),
18
19 debuglog: debuglog('blog'),
20
21 // constants
22
23 kFileNotFoundError: 'ENOENT',
24 kMarkdownRe: /\.md$/i,
25 kMetadataFilename: 'metadata.json',
26
27 // Strings
28
29 strings: {
30 markdownNotFound: 'Markdown file was not found in blog directory. Please update.'
31 }
32 };
33
34 /**
35 * The Blog class is the blog generator, it's in charge of adding and
36 * updating posts, and handling the publishing.
37 *
38 * @class Blog
39 * @param {Blog.tConfiguration} config the initialization options to
40 * extend the instance
41 */
42 module.exports = class Blog {
43
44 constructor(config) {
45
46 Object.assign(this, config);
47 }
48
49 /**
50 * Shifts the blog posts, adds the passed path to slot 0, and
51 * generates files.
52 *
53 * @function add
54 * @memberof Blog
55 * @param {string} postLocation the path to the directory containing
56 * the post structure
57 * @return {Promise<undefined>} empty promise, returns no value
58 * @instance
59 */
60 async add(postLocation) {
61
62 await this._ensurePostsDirectoryExists();
63 await this._shift();
64 await this.update(postLocation);
65 }
66
67 /**
68 * Adds the passed path to slot 0, and generates files.
69 *
70 * @function update
71 * @memberof Blog
72 * @param {string} postLocation the path to the directory containing
73 * the post structure
74 * @return {Promise<undefined>} empty promise, returns no value
75 * @instance
76 */
77 async update(postLocation) {
78
79 const metadata = await this._getMetadata();
80 await this._ensurePostsDirectoryExists();
81 await this._copyPost(postLocation);
82 await this._writeMetadata(metadata);
83
84 await this.generate();
85 }
86
87 /**
88 * Publishes the files to a static host.
89 *
90 * @function publish
91 * @memberof Blog
92 * @return {Promise<undefined>} empty promise, returns no value
93 * @instance
94 */
95 publish() {
96
97 console.error('Publishing not yet implemented');
98 return Promise.resolve();
99 }
100
101 // Parses markdown for each page, copies assets and generates index.
102
103 async generate() {
104
105 internals.debuglog('Generating output');
106
107 const posts = await this._readPosts(this.postsDirectory);
108
109 await StaticGenerator(this.postsDirectory, this.staticDirectory, posts);
110 await HTMLGenerator(this.templatesDirectory, this.staticDirectory, posts);
111 await RSSGenerator(this.templatesDirectory, this.staticDirectory, posts);
112 await TXTGenerator(this.templatesDirectory, this.staticDirectory, posts);
113 }
114
115 // Reads the posts into an array
116
117 async _readPosts(source) {
118
119 internals.debuglog('Reading posts');
120 const posts = [];
121
122 for (let i = 0; i < this.maxPosts; ++i) {
123 const postSourcePath = join(source, `${i}`);
124
125 internals.debuglog(`Reading ${postSourcePath} into posts array`);
126
127 try {
128 await access(postSourcePath);
129
130 const metadata = await this._getMetadata(i);
131
132 const postContentPath = await this._findBlogContent(postSourcePath);
133 internals.debuglog(`Reading ${postContentPath}`);
134 const postContent = await readFile(postContentPath, { encoding: 'utf8' });
135
136 internals.debuglog('Parsing markdown');
137 posts.push({
138 ...metadata,
139 index: i,
140 html: Marked(postContent),
141 raw: postContent
142 });
143 }
144 catch (error) {
145 if (error.code === internals.kFileNotFoundError) {
146 internals.debuglog(`Skipping ${i}`);
147 continue;
148 }
149
150 throw error;
151 }
152 }
153
154 return posts;
155 }
156
157 // Shift the posts, delete any remainder.
158
159 async _shift() {
160
161
162 for (let i = this.maxPosts - 1; i >= 0; --i) {
163 const targetPath = join(this.postsDirectory, `${i}`);
164 const sourcePath = join(this.postsDirectory, `${i - 1}`);
165
166 try {
167 internals.debuglog(`Removing ${targetPath}`);
168 await rmdir(targetPath, { recursive: true });
169
170 await access(sourcePath); // check the source path
171
172 internals.debuglog(`Shifting blog post ${sourcePath} to ${targetPath}`);
173 await internals.ncp(sourcePath, targetPath);
174 }
175 catch (error) {
176 if (error.code === internals.kFileNotFoundError) {
177 internals.debuglog(`Skipping ${sourcePath}: Does not exist.`);
178 continue;
179 }
180
181 throw error;
182 }
183 }
184 }
185
186 // Attempts to read existing metadata. Otherwise generates new set.
187
188 async _getMetadata(index = 0) {
189
190 const metadataTarget = join(this.postsDirectory, String(index), internals.kMetadataFilename);
191
192 try {
193 internals.debuglog(`Looking for metadata at ${metadataTarget}`);
194 return JSON.parse(await readFile(metadataTarget, { encoding: 'utf8' }));
195 }
196 catch (e) {
197 internals.debuglog(`Metadata not found or unreadable. Generating new set.`);
198 const createdOn = Date.now();
199 const metadata = {
200 id: String(createdOn),
201 createdOn
202 };
203
204 return metadata;
205 }
206 }
207
208 // Writes metadata. Assumes post 0 since it only gets written
209 // on create
210
211 async _writeMetadata(metadata) {
212
213 const metadataTarget = join(this.postsDirectory, '0', internals.kMetadataFilename);
214 internals.debuglog(`Writing ${metadataTarget}`);
215 await writeFile(metadataTarget, JSON.stringify(metadata, null, 2));
216 }
217
218 // Copies a post directory to the latest slot.
219
220 async _copyPost(postLocation) {
221
222 const targetPath = join(this.postsDirectory, '0');
223
224 internals.debuglog(`Removing ${targetPath}`);
225 await rmdir(targetPath, { recursive: true });
226
227 internals.debuglog(`Adding ${postLocation} to ${targetPath}`);
228 await internals.ncp(postLocation, targetPath);
229 }
230
231 // Ensures the posts directory exists.
232
233 async _ensurePostsDirectoryExists() {
234
235 internals.debuglog(`Checking if ${this.postsDirectory} exists.`);
236 try {
237 await access(this.postsDirectory);
238 }
239 catch (error) {
240 if (error.code === internals.kFileNotFoundError) {
241 internals.debuglog('Creating posts directory');
242 await mkdir(this.postsDirectory);
243 return;
244 }
245
246 throw error;
247 }
248 }
249
250 // Looks for a `.md` file in the blog directory, and returns the path
251
252 async _findBlogContent(directory) {
253
254 const entries = await readdir(directory);
255
256 const markdownEntries = entries
257 .filter((entry) => internals.kMarkdownRe.test(entry))
258 .map((entry) => join(directory, entry));
259
260 if (markdownEntries.length > 0) {
261 internals.debuglog(`Found markdown file: ${markdownEntries[0]}`);
262 return markdownEntries[0];
263 }
264
265 throw new Error(internals.strings.markdownNotFound);
266 }
267 };