-
Notifications
You must be signed in to change notification settings - Fork 0
/
fontfacegen-webpack-plugin.js
319 lines (277 loc) · 11.8 KB
/
fontfacegen-webpack-plugin.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
/*
* I had no idea where I was getting into before writing this plugin. I just
* wanted to make webpack run fontfacegen to generate some font files for an
* experimental project. I didn't think it would be too difficult to write a
* plugin for a very popular tool. I assumed there would be plenty of
* documentation available. I end up finding out that the documentation on how
* to write plugins for webpack seems to have been made as an afterthought. My
* first lines of code were written through trial and error because it's not
* clear what you are supposed to access and modify when hooking into the
* various compiler and compilation stages. The documentation states you need to
* understand the internals of webpack and to be prepared to read the source
* code. So you just access whatever you can. That always leaves you guessing
* what the intentions of the developers were when they wrote things the way
* they are now. It is not obvious from looking at the code. You're left
* wondering whether modifying field X will break field Y, for example. You will
* only find out when things stop working, either due to an update or if you
* start adding more plugins to the mix. I searched the web for help but most of
* the tutorials on the internet only made trivial demonstrations (like hooking
* into the done event to write a couple of informative lines to the terminal),
* had code for old versions of webpack or did things that were not useful for
* what I wanted to do.
*
* There are some internal workings in webpack that caught me off guard.
*
* During the prototype phase of this plugin I had implemented an optional
* parameter that would let you specify the directory where the generated font
* files would be placed. If a relative path was passed then the directory would
* be relative to the output path. I found out that placing directories in the
* output path is not the webpack way of doing things. All files are supposed to
* be emitted to the root of the output path. If you do place directories in it,
* the files in those directories will end up being copied to the root. Having
* all files on the root can lead to name clashes. The official loaders and
* plugins solve that by allowing you to personalize the file name with hashes
* and whatnot. This plugin does not provide a solution to that problem. Yet.
*
* About emitting: that is the webpack term for creating your output files. The
* fontfacegen module creates the font files in the file system automatically.
* However, I'm not sure whether that is the right way to do things. Do I have
* to register source objects in the assets array? Do I have to use the
* inputFileSystem object to create the files? OR can I create the file as long
* as I save the path to it in the undocumented `existsAt` property of the
* source object? There is an if statement in the source code that checks for
* it. Is it meant for plugin authors? I. Don't. Know.
*
* Not even logging is straightforward. There's a logger interface but it was a
* recent addition to Webpack 4 so the plugins whose source I read aren't even
* using it. I am left guessing if it is meant for future-compatibility with
* webpack 5. I just ended up using console.log like everyone else. Had I used
* the logger interface you would need a version of webpack 4.39 or above to be
* able to use this plugin.
*
* The files generated by this plugin are meant to be referenced in the code.
* This causes webpack to track these files as dependencies which means that if
* webpack is in watch mode and a compilation occurs, webpack will dispatch
* another compilation because the generated files changed, thus entering a
* loop. I didn't want to have user code reference source files because then I
* would have to rewrite the CSS font face definitions (lets not even mention
* font face definitions in JS) to use the various formats that are generated. I
* ended up removing them from the dependencies set and manually adding the
* source files to the set. Was I meant to do that? Will it break in a future
* release? Will it break the assumptions of anothe plugins? I don't know.
*
* It just works.
*/
const fs = require('fs');
const path = require('path');
const fontfacegen = require('fontfacegen');
class CompileResultSuccess {
constructor(files) {
this.files = files;
}
}
class CompileResultCache {
constructor(files) {
this.files = files;
}
}
// Symbols for data we store in Webpack objects.
const COMPILATION_STATE = Symbol('COMPILATION_STATE');
// Symbols for private fields.
const NAME = Symbol('NAME');
const TASKS = Symbol('TASKS');
const LAST_RESULTS = Symbol('LAST_RESULTS');
const COMPILE = Symbol('COMPILE');
const REUSE = Symbol('REUSE');
const COLLECT_FONTS = Symbol('COLLECT_FONTS');
module.exports = class FontfacegenWebpackPlugin {
constructor(options = {}) {
this[NAME] = 'fontfacegenWebpackPlugin';
this[TASKS] = (options.tasks !== undefined ? options.tasks : [])
.map((task) => {
if (typeof task === 'string') {
// If task is a string then the user has only provided the source.
task = {
src: task
};
} else if (typeof task === 'object') {
// We need to make sure this is a copy.
task = Object.assign({}, task);
}
if (typeof task.src === 'string') {
// The user only provided a single source.
task.src = [task.src];
} else if (task.src instanceof Array) {
// We need to make sure this is a copy.
task.src = task.src.concat();
}
if (!(typeof task.subset === 'string' || task.subset instanceof Array)) {
if (options.subset !== undefined) {
task.subset = options.subset;
}
}
return task;
});
// Tracks the results of the last compilation.
this[LAST_RESULTS] = [];
}
apply(compiler) {
compiler.hooks.make.tapPromise(this[NAME], async (compilation) => {
let compilationTasks = await Promise.all(this[TASKS].map(async (task) => {
return {
sourceFiles: await this[COLLECT_FONTS](task),
subset: task.subset,
results: [],
};
}));
// State initialization.
compilation[COMPILATION_STATE] = compilationTasks;
// Discards previous results.
this[LAST_RESULTS].length = 0;
for (let ct of compilationTasks) {
for (let sourceFile of ct.sourceFiles) {
let friendlyName = path.basename(sourceFile);
try {
let result = await this[COMPILE](sourceFile, compilation.outputOptions.path, ct.subset);
if (result instanceof CompileResultSuccess) {
console.log(`Generated fonts for "${friendlyName}" successfully.`);
for (let file of result.files) {
this[LAST_RESULTS].push(file);
}
} else if (result instanceof CompileResultCache) {
console.log(`Fonts for "${friendlyName}" are up to date.`);
}
ct.results.push(result);
} catch (e) {
console.error(`Failed to generate "${friendlyName}" fonts`, e);
// Move on to the next file.
}
}
}
});
compiler.hooks.afterCompile.tapPromise(this[NAME], async (compilation) => {
let compilationTasks = compilation[COMPILATION_STATE];
if (compilationTasks === undefined) {
return
}
for (let ct of compilationTasks) {
for (let result of ct.results) {
try {
for (let file of result.files) {
let fullPath = path.join(compilation.outputOptions.path, file);
// Paths to the generated font files may be used in the code so we
// need to remove those from the dependencies list otherwise webpack
// watch will go in a loop when the fonts get compiled.
if (compilation.fileDependencies.has(fullPath)) {
compilation.fileDependencies.delete(fullPath);
}
}
// We need to manually register the source files as dependencies
// because they might not be referenced in the code. This is what
// makes the watch command work.
for (let sourceFile of ct.sourceFiles) {
compilation.fileDependencies.add(sourceFile);
}
} catch (e) {
console.error(e);
// Move on to the next result.
}
}
}
// State deinitialization.
delete compilation[COMPILATION_STATE];
});
}
/*
* Compiles a font into various other font formats. src is an absolute path to
* a file, dst is an absolute path to the directory where the font files will
* be created and subset is a string of characters that should be included in
* the generated fonts.
*
* It returns an object on success. It will eithe be CompileResultSuccess if
* the fonts were generated or CompileResultCache if the existing fonts were
* reused. The object provides a list of the names of the font files that were
* generated in the given directory.
*/
async [COMPILE](src, dst, subset = undefined) {
let extension = path.extname(src);
let fontname = path.basename(src, extension);
const files = [
fontname + '.eot',
fontname + '.ttf',
fontname + '.svg',
fontname + '.woff',
fontname + '.woff2',
];
if (await this[REUSE](src, dst, files)) {
return new CompileResultCache(files);
}
fontfacegen({
source: src,
dest: dst,
subset: subset,
css: '/dev/null',
});
return new CompileResultSuccess(files);
}
/**
* Determines whether the previously generated files are still valid and can
* be reused.
*/
async [REUSE](src, dst, files) {
let { mtime: sourceTimestamp } = await fs.promises.stat(src);
// The null value means that no compilation has occurred before. A Date
// object holds the time of the last compilation.
let lastCompilationTimestamp = null;
for (let file of files) {
// This is the path to a generated file.
let generatedFile = path.join(dst, file);
if (!fs.existsSync(generatedFile)) {
// A file is missing. We can't reuse existing assets.
return false;
}
let { mtime: modificationTime } = await fs.promises.stat(generatedFile);
if (lastCompilationTimestamp === null
|| lastCompilationTimestamp.getTime() > modificationTime.getTime()) {
// Oldest compilation time.
lastCompilationTimestamp = modificationTime;
}
}
// If the modification date of the source file is less than that of the
// compiled files then there is a very good chance that it was not changed
// therefore we can reuse the existing assets.
return sourceTimestamp.getTime() < lastCompilationTimestamp.getTime();
}
/*
* Generates a list of absolute file paths to the font files referenced in the
* source list of a task.
*/
async [COLLECT_FONTS](task) {
const fontFiles = [];
for (let src of task.src) {
const srcStat = await fs.promises.stat(src);
if (srcStat.isDirectory()) {
fontFiles.push.apply(fontFiles, fs.readdirSync(src)
// We only want font files.
.filter((file) => {
let extension = path.extname(file);
return extension == '.ttf' || extension == '.otf';
})
// The file names returned by readdirSync don't contain the directory
// path so we have to put it in.
.map((file) => path.resolve(src, file)));
} else {
fontFiles.push(path.resolve(src));
}
}
return fontFiles;
}
/**
* A list of files that were generated in the last compilation. Files are
* relative to the output path.
* @returns {string[]}
*/
lastResults() {
return this[LAST_RESULTS].concat();
}
};