-
Notifications
You must be signed in to change notification settings - Fork 18
/
file.js
358 lines (305 loc) · 9.55 KB
/
file.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
import path from 'path';
import Promise from 'bluebird';
import fs from 'fs-extra';
import _ from 'lodash';
import createChecksum from './checksum';
import log from './log';
import Url from './url';
import Parse from './parse/index';
import cache from './cache';
import filter from './filter/index';
import Config from './config/index'; // eslint-disable-line no-unused-vars
import Renderer from './renderer/index'; // eslint-disable-line no-unused-vars
export default class File {
constructor(filePath = '', { config, renderer } = {}) {
/**
* Unique ID for this file. Right now an alias for the file's path.
* @type {string}
*/
this.id = filePath;
/**
* Absolute path to file location.
* @type {string}
*/
this.path = filePath;
/**
* Absolute destination path of where file should be written.
* @type {string} destination Absolute path to file.
*/
this.destination = '';
/**
* Frontmatter for this file. Can be undefined if a file has no frontmatter.
* @type {object}
*/
this.frontmatter = Object.create(null);
/**
* Template accessible data.
* @type {Object.<string, Object>}
*/
this.data = Object.create(null);
/**
* Should we skip processing this file, ignoring templates and markdown
* conversion. This is generally only true for images and similar files.
* @type {boolean}
*/
this.skipProcessing = false;
/**
* An asset processor that will handle rendering this file.
* @type {function?}
*/
this.assetProcessor = null;
/**
* If this File is filtered out of rendering. Filter settings are defined
* in the {@link Config.ConfigFilename} file.
* @type {boolean}
*/
this.filtered = false;
/**
* @type {Config}
* @private
*/
this._config = config;
/**
* @type {Renderer}
* @private
*/
this._renderer = renderer;
}
/**
* Update's File's data from the file system.
*/
async update() {
// Check if a file has frontmatter.
const hasFrontmatter = await Parse.fileHasFrontmatter(this.path);
// If File doesn't have frontmatter then return early.
if (!hasFrontmatter) {
const assetConfig = this._config
.get('assets')
.find(({ test }) => test(this.path));
if (assetConfig) {
this.assetProcessor = assetConfig.use;
}
this.skipProcessing = true;
this._calculateDestination();
return;
}
/**
* Raw contents of file, directly from file system.
* @type {string} One long string.
*/
const rawContent = await Promise.fromCallback(cb =>
fs.readFile(this.path, 'utf8', cb)
);
/**
* Checksum hash of rawContent, for use in seeing if file is different.
* @example:
* '50de70409f11f87b430f248daaa94d67'
* @type {string}
*/
this.checksum = createChecksum(rawContent);
// Try to parse File's frontmatter.
let parsedContent;
try {
parsedContent = Parse.fromFrontMatter(rawContent);
} catch (e) {
// Couldn't parse File's frontmatter.
}
// Ensure we have an object to dereference.
if (!_.isObject(parsedContent)) {
parsedContent = {};
}
const {
data: frontmatter,
// The file's text content.
content,
} = parsedContent;
// Create new data object.
this.data = Object.create(null);
this.frontmatter = frontmatter;
this.defaults = this._gatherDefaults();
// Merge in new data that's accessible from template.
_.merge(this.data, this.defaults, this.frontmatter, {
// The content of the Page.
content,
});
this.filtered = filter.isFileFiltered(
this._config.get('file.filters'),
this
);
try {
this._calculateDestination();
} catch (e) {
throw new Error(
'Unable to calculate destination for file at ' +
`${this.path}. Message: ${e.message}`
);
}
}
/**
* Gather default values that should be applied to this file.
* @return {Object} Default values applied to this file.
*/
_gatherDefaults() {
// Defaults are sorted from least to most specific, so we iterate over them
// in the reverse order to allow most specific first chance to apply their
// values.
return _.reduceRight(
this._config.get('file.defaults'),
(acc, defaultObj) => {
const { scope, values } = defaultObj;
// If default path property is defined does it exist within this file's
// path.
const pathMatches =
scope.path != null ? this.path.includes(scope.path) : true;
// If metadata is set the does it match the file's metadata.
const metadataMatches = _.isObject(scope.metadata)
? _.isMatch(this.frontmatter, scope.metadata)
: true;
// If we have a match then apply the values.
if (pathMatches && metadataMatches) {
return _.defaults(acc, values);
}
return acc;
},
{}
);
}
/**
* Calculate both relative and absolute destination path for where to write
* the file.
* @private
*/
_calculateDestination() {
let destinationUrl;
const hasMarkdownExtension = Url.pathHasMarkdownExtension(
this.path,
this._config.get('markdown.extensions')
);
/**
* If the file itself wants to customize what its URL is then it will use
* the `config.file.urlKey` value of the File's frontmatter as the basis
* for which the URL of this file should be.
* So if you have a File with a frontmatter that has `url: /pandas/` then
* the File's URL will be `/pandas/`.
* @type {string?} url Relative path to file.
*/
const url = this.frontmatter[this._config.get('file.urlKey')];
if (url) {
// If the individual File defined its own unique URL that gets first
// dibs at setting the official URL for this file.
destinationUrl = url;
} else if (this.data.permalink) {
// If the file has no URL but has a permalink set on it then use it to
// find the URL of the File.
destinationUrl = Url.interpolatePermalink(this.data.permalink, this.data);
} else {
// Path to file relative to root of project.
destinationUrl = this.path.replace(this._config.get('path.source'), '');
if (hasMarkdownExtension) {
// If the file has no URL set and no permalink then use its relative
// file path as its url.
destinationUrl = Url.replaceMarkdownExtension(
destinationUrl,
this._config.get('markdown.extensions')
);
}
}
if (this.assetProcessor) {
destinationUrl = this.assetProcessor.calculateDestination(destinationUrl);
}
if (hasMarkdownExtension) {
this.destination = Url.makeUrlFileSystemSafe(destinationUrl);
this.data.url = Url.makePretty(this.destination);
} else {
this.destination = destinationUrl;
this.data.url = destinationUrl;
}
}
/**
* Render the markdown into HTML.
* If there is an assetProcessor then we delegate render responsibility to
* that assetProcessor.
* @param {Object} globalData Global site metadata.
* @return {string} Rendered content.
*/
render(globalData) {
if (this.assetProcessor) {
return this.assetProcessor.render(this);
}
const template = this.data.template;
let result = this.data.content;
const templateData = {
...globalData,
file: this.data,
};
try {
// Set result of content to result content.
result = this._renderer.renderTemplateString(
this.data.content,
templateData
);
// Set result to file's contents.
this.data.content = result;
} catch (e) {
log.error(e.message);
throw new Error(
"File: Could not render file's contents.\n" +
`File: ${JSON.stringify(this)}`
);
}
// Convert to HTML.
// However if the File's frontmatter sets markdown value to false then
// skip the markdown conversion.
if (this.data.markdown !== false) {
result = this._renderer.renderMarkdown(this.data.content);
this.data.content = result;
}
if (
!_.isNil(template) &&
!(_.isString(template) && template.length === 0)
) {
result = this._renderer.renderTemplate(template, templateData);
}
return result;
}
/**
* Writes a given File object to the file system.
* @param {Object} globalData Site wide data.
*/
async write(globalData) {
const destinationPath = path.join(
this._config.get('path.destination'),
this.destination
);
if (this.assetProcessor) {
const content = await this.render(this);
await Promise.fromCallback(cb => {
fs.outputFile(destinationPath, content, 'utf8', cb);
});
return;
}
// If this File is a static asset then we don't process it at all, and just
// copy it to its destination path.
// This typically applies to images and other similar files.
if (this.skipProcessing) {
await Promise.fromCallback(cb => fs.copy(this.path, destinationPath, cb));
return;
}
// Don't write File if it is filtered.
if (this.filtered) {
return;
}
const content = await this.render(globalData);
if (
this._config.get('incremental') &&
cache.get(this.path) === this.checksum
) {
return;
}
await Promise.fromCallback(cb => {
fs.outputFile(destinationPath, content, 'utf8', cb);
});
// Save checksum to cache for incremental builds.
cache.put(this.path, this.checksum);
}
}