]> git.r.bdr.sh - rbdr/blog/blame - lib/blog.js
Add metadata writing functionality
[rbdr/blog] / lib / blog.js
CommitLineData
cf630290
BB
1'use strict';
2
d92ac8cc
BB
3const { exec } = require('child_process');
4const { access, mkdir, readdir, readFile, rmdir, writeFile } = require('fs/promises');
5const { template } = require('dot');
6const { ncp } = require('ncp');
7const { join } = require('path');
8const Marked = require('marked');
9const { debuglog, promisify } = require('util');
cf630290
BB
10
11const internals = {
12
13 // Promisified functions
d92ac8cc 14 ncp: promisify(ncp),
cf630290 15
d92ac8cc
BB
16 exec: promisify(exec),
17 debuglog: debuglog('blog'),
cf630290
BB
18
19 // constants
20
21 kAssetsDirectoryName: 'assets',
22 kIndexName: 'index.html',
23 kFileNotFoundError: 'ENOENT',
24 kMarkdownRe: /\.md$/i,
6f72ad0f 25 kMetadataFilename: 'metadata.json',
cf630290
BB
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 {Potluck.tConfiguration} config the initialization options to
40 * extend the instance
41 */
42module.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
6f72ad0f 62 await this._ensurePostsDirectoryExists();
cf630290
BB
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
6f72ad0f
BB
79 const metadata = await this._getMetadata();
80 await this._ensurePostsDirectoryExists();
cf630290 81 await this._copyPost(postLocation);
6f72ad0f
BB
82 await this._writeMetadata(metadata);
83
cf630290
BB
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 */
d92ac8cc 95 publish() {
cf630290
BB
96
97 console.error('Publishing not yet implemented');
d92ac8cc 98 return Promise.resolve();
cf630290
BB
99 }
100
101 // Parses markdown for each page, copies assets and generates index.
102
103 async _generate() {
104
d92ac8cc
BB
105 const assetsTarget = join(this.staticDirectory, internals.kAssetsDirectoryName);
106 const indexTarget = join(this.staticDirectory, internals.kIndexName);
107 const indexLocation = join(this.templatesDirectory, internals.kIndexName);
cf630290
BB
108 const posts = [];
109
110 internals.debuglog(`Removing ${assetsTarget}`);
d92ac8cc 111 await rmdir(assetsTarget, { recursive: true });
cf630290
BB
112
113 for (let i = 0; i < this.maxPosts; ++i) {
d92ac8cc 114 const sourcePath = join(this.postsDirectory, `${i}`);
cf630290 115
eccb3cc4 116 try {
d92ac8cc 117 await access(this.postsDirectory);
cf630290 118
d92ac8cc 119 const assetsSource = join(sourcePath, internals.kAssetsDirectoryName);
eccb3cc4 120 const postContentPath = await this._findBlogContent(sourcePath);
cf630290 121
eccb3cc4
BB
122 internals.debuglog(`Copying ${assetsSource} to ${assetsTarget}`);
123 await internals.ncp(assetsSource, assetsTarget);
124
125 internals.debuglog(`Reading ${postContentPath}`);
d92ac8cc 126 const postContent = await readFile(postContentPath, { encoding: 'utf8' });
eccb3cc4
BB
127
128 internals.debuglog('Parsing markdown');
129 posts.push({
d92ac8cc 130 html: Marked(postContent),
eccb3cc4
BB
131 id: i + 1
132 });
133 }
134 catch (error) {
135 if (error.code === internals.kFileNotFoundError) {
136 internals.debuglog(`Skipping ${i}`);
137 continue;
138 }
139
140 throw error;
141 }
cf630290
BB
142 }
143
144 internals.debuglog(`Reading ${indexLocation}`);
d92ac8cc 145 const indexTemplate = await readFile(indexLocation, { encoding: 'utf8' });
cf630290
BB
146
147 internals.debuglog('Generating HTML');
d92ac8cc
BB
148 const indexHtml = template(indexTemplate)({ posts });
149 await writeFile(indexTarget, indexHtml);
cf630290
BB
150 }
151
152 // Shift the posts, delete any remainder.
153
154 async _shift() {
155
cf630290 156
6f72ad0f 157 for (let i = this.maxPosts - 1; i >= 0; --i) {
d92ac8cc
BB
158 const targetPath = join(this.postsDirectory, `${i}`);
159 const sourcePath = join(this.postsDirectory, `${i - 1}`);
cf630290
BB
160
161 try {
cf630290 162 internals.debuglog(`Removing ${targetPath}`);
d92ac8cc 163 await rmdir(targetPath, { recursive: true });
cf630290 164
6f72ad0f
BB
165 await access(sourcePath); // check the source path
166
cf630290
BB
167 internals.debuglog(`Shifting blog post ${sourcePath} to ${targetPath}`);
168 await internals.ncp(sourcePath, targetPath);
169 }
170 catch (error) {
171 if (error.code === internals.kFileNotFoundError) {
172 internals.debuglog(`Skipping ${sourcePath}: Does not exist.`);
173 continue;
174 }
175
176 throw error;
177 }
178 }
179 }
180
6f72ad0f
BB
181 // Attempts to read existing metadata. Otherwise generates new set.
182
183 async _getMetadata() {
184
185 const metadataTarget = join(this.postsDirectory, '0', internals.kMetadataFilename);
186
187 try {
188 internals.debuglog(`Looking for metadata at ${metadataTarget}`);
189 return await readFile(metadataTarget);
190 }
191 catch (e) {
192 internals.debuglog(`Metadata not found or unreadable. Generating new set.`);
193 const createdOn = Date.now();
194 const metadata = {
195 id: String(createdOn),
196 createdOn
197 };
198
199 return JSON.stringify(metadata, null, 2);
200 }
201 }
202
203 // Writes metadata. Assumes post 0 since it only gets written
204 // on create
205
206 async _writeMetadata(metadata) {
207
208 const metadataTarget = join(this.postsDirectory, '0', internals.kMetadataFilename);
209 internals.debuglog(`Writing ${metadataTarget}`);
210 await writeFile(metadataTarget, metadata);
211 }
212
cf630290
BB
213 // Copies a post directory to the latest slot.
214
215 async _copyPost(postLocation) {
216
d92ac8cc 217 const targetPath = join(this.postsDirectory, '0');
cf630290
BB
218
219 internals.debuglog(`Removing ${targetPath}`);
d92ac8cc 220 await rmdir(targetPath, { recursive: true });
cf630290
BB
221
222 internals.debuglog(`Adding ${postLocation} to ${targetPath}`);
223 await internals.ncp(postLocation, targetPath);
224 }
225
226 // Ensures the posts directory exists.
227
228 async _ensurePostsDirectoryExists() {
229
230 internals.debuglog(`Checking if ${this.postsDirectory} exists.`);
231 try {
d92ac8cc 232 await access(this.postsDirectory);
cf630290
BB
233 }
234 catch (error) {
235 if (error.code === internals.kFileNotFoundError) {
236 internals.debuglog('Creating posts directory');
d92ac8cc 237 await mkdir(this.postsDirectory);
cf630290
BB
238 return;
239 }
240
241 throw error;
242 }
243 }
244
245 // Looks for a `.md` file in the blog directory, and returns the path
246
247 async _findBlogContent(directory) {
248
d92ac8cc 249 const entries = await readdir(directory);
cf630290
BB
250
251 const markdownEntries = entries
252 .filter((entry) => internals.kMarkdownRe.test(entry))
d92ac8cc 253 .map((entry) => join(directory, entry));
cf630290
BB
254
255 if (markdownEntries.length > 0) {
256 internals.debuglog(`Found markdown file: ${markdownEntries[0]}`);
257 return markdownEntries[0];
258 }
259
260 throw new Error(internals.strings.markdownNotFound);
261 }
262};