js-build-tools 6.5.0 → 6.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -90,6 +90,8 @@ provided are the defaults):
90
90
  "enabled": false,
91
91
  // Path to search for images
92
92
  "from": "src/img/**/*.+(png|jpg|jpeg|gif|svg)",
93
+ // The JPEG quality (0 to 100) to compress to, false keeps mozjpeg's own default (75)
94
+ "jpegQuality": false,
93
95
  // Path to output images
94
96
  "to": "browser/img"
95
97
  },
@@ -439,6 +441,9 @@ Add the following to the exports in your `build-tools.config.json`:
439
441
  "enabled": false,
440
442
  // Search pattern to find your images
441
443
  "from": "src/img/**/*.+(png|jpg|jpeg|gif|svg)",
444
+ // The JPEG quality (0 to 100) to compress to. Left false, mozjpeg uses its own default of 75, which can
445
+ // visibly band smooth gradients in photos; 85 is usually indistinguishable from the original
446
+ "jpegQuality": false,
442
447
  // Output directory for your images files
443
448
  "to": "browser/img"
444
449
  }
@@ -561,6 +566,10 @@ Add the following to the exports in your `build-tools.config.json`:
561
566
  // The directory holding the source, each folder in it becomes a module (TypeDoc) or is scanned recursively (jsdoc).
562
567
  // jsdoc also takes a list of directories and files, for example ["functions", "gulpfile.base.mjs"]
563
568
  "from": "src",
569
+ // TypeDoc only: the git revision the "Defined in" source links point to. Left false, TypeDoc uses the current
570
+ // commit, so every commit changes every page; a branch name such as "main" keeps them the same until the
571
+ // documentation itself changes
572
+ "gitRevision": false,
564
573
  // Generate the documentation as part of build (the default). Set it to false to only generate it when you run
565
574
  // the docs task yourself (see below)
566
575
  "inBuild": true,
@@ -4,7 +4,7 @@ require("core-js/modules/esnext.weak-map.delete-all.js");
4
4
  Object.defineProperty(exports, "__esModule", {
5
5
  value: true
6
6
  });
7
- exports.loadImagemin = exports.imagesFor = void 0;
7
+ exports.loadImageminPlugins = exports.loadImagemin = exports.imagesFor = exports.imageminPlugins = void 0;
8
8
  var _gulpCache = _interopRequireDefault(require("gulp-cache"));
9
9
  var _loadPeer = require("./loadPeer.js");
10
10
  var _gulp = require("gulp");
@@ -33,6 +33,36 @@ const loadImagemin = (projectPath = process.cwd()) => {
33
33
  return loaded && (loaded.__esModule || loaded.default) ? loaded.default : loaded;
34
34
  };
35
35
 
36
+ /**
37
+ * Load the plugins `gulp-imagemin` exports by name (gifsicle, mozjpeg, optipng and svgo), each a promise of a plugin
38
+ * factory, for when they need options of their own (see images.jpegQuality). Resolved like {@link loadImagemin}.
39
+ * @memberOf module:partials
40
+ * @param {string} [projectPath=process.cwd()] - The project directory to resolve `gulp-imagemin` from.
41
+ * @returns {Object<string, Promise<Function>>} The plugin factories, by name.
42
+ */
43
+ exports.loadImagemin = loadImagemin;
44
+ const loadImageminPlugins = (projectPath = process.cwd()) => (0, _loadPeer.requirePeer)('gulp-imagemin', {
45
+ task: 'images',
46
+ installHint,
47
+ esmOnlyMessage: `gulp-imagemin could not be loaded because it is ESM-only. ${installHint}`,
48
+ projectPath
49
+ });
50
+
51
+ // The plugins imagemin runs when it isn't given any, in its own order
52
+ exports.loadImageminPlugins = loadImageminPlugins;
53
+ const defaultPluginNames = ['gifsicle', 'mozjpeg', 'optipng', 'svgo'];
54
+
55
+ /**
56
+ * The plugins imagemin uses by default, with mozjpeg set to the given JPEG quality instead of its own default (75).
57
+ * @memberOf module:partials
58
+ * @param {number} jpegQuality - The mozjpeg quality, 0 to 100.
59
+ * @param {Object<string, Promise<Function>>} [plugins=loadImageminPlugins()] - The plugin factories, by name.
60
+ * @returns {Array<Promise<Function>>} The plugins, in imagemin's default order.
61
+ */
62
+ const imageminPlugins = (jpegQuality, plugins = loadImageminPlugins()) => defaultPluginNames.map(name => Promise.resolve(plugins[name]).then(makePlugin => name === 'mozjpeg' ? makePlugin({
63
+ quality: jpegQuality
64
+ }) : makePlugin()));
65
+
36
66
  /**
37
67
  * Move and optimize images into the browser directory.
38
68
  * @memberOf module:partials
@@ -40,18 +70,25 @@ const loadImagemin = (projectPath = process.cwd()) => {
40
70
  * @param {string} [imageDest=dest/image/folder]
41
71
  * @return {stream.Stream}
42
72
  */
43
- exports.loadImagemin = loadImagemin;
73
+ exports.imageminPlugins = imageminPlugins;
44
74
  const imagesFor = (imageSrc = gulpConfig.get('images.from'), imageDest = gulpConfig.get('images.to')) => {
45
75
  const imagemin = loadImagemin();
76
+ const jpegQuality = gulpConfig.get('images.jpegQuality');
77
+ // Left unset, imagemin runs its default plugins with their own defaults
78
+ const plugins = jpegQuality ? imageminPlugins(jpegQuality) : undefined;
46
79
  // Gulp 5 reads files as utf8 text unless told otherwise, which corrupts binary images (and makes gulp-cache drop them).
47
80
  return (0, _gulp.src)(imageSrc, {
48
81
  encoding: false
49
82
  }).pipe(
50
83
  // Caching images that ran through imagemin
51
- (0, _gulpCache.default)(imagemin({
84
+ (0, _gulpCache.default)(imagemin(plugins, {
52
85
  interlaced: true,
53
86
  silent: true,
54
87
  verbose: false
55
- }))).pipe((0, _gulp.dest)(imageDest));
88
+ }),
89
+ // gulp-cache keys on the file's contents alone, so keep each quality's results apart
90
+ {
91
+ name: jpegQuality ? `jpeg-quality-${jpegQuality}` : 'default'
92
+ })).pipe((0, _gulp.dest)(imageDest));
56
93
  };
57
94
  exports.imagesFor = imagesFor;
@@ -1,31 +1,31 @@
1
- "use strict";
2
-
3
- require("core-js/modules/esnext.weak-map.delete-all.js");
4
- Object.defineProperty(exports, "__esModule", {
5
- value: true
6
- });
7
- exports.writeDocEntries = exports.typeDocsFor = exports.loadTypeDoc = exports.describeFolder = exports.describeFile = exports.describeExports = void 0;
8
- require("core-js/modules/es.array.includes.js");
9
- require("core-js/modules/es.json.stringify.js");
10
- require("core-js/modules/esnext.iterator.constructor.js");
11
- require("core-js/modules/esnext.iterator.filter.js");
12
- require("core-js/modules/esnext.iterator.find.js");
13
- require("core-js/modules/esnext.iterator.flat-map.js");
14
- require("core-js/modules/esnext.iterator.for-each.js");
15
- require("core-js/modules/esnext.iterator.map.js");
16
- require("core-js/modules/esnext.iterator.some.js");
17
- var _nodeFs = _interopRequireDefault(require("node:fs"));
18
- var _nodeOs = _interopRequireDefault(require("node:os"));
19
- var _nodePath = _interopRequireDefault(require("node:path"));
20
- var gulpConfig = _interopRequireWildcard(require("../../gulp.config.js"));
21
- var _loadPeer = require("./loadPeer.js");
22
- var _tsFor = require("./tsFor.js");
23
- var _removeDirectory = require("./removeDirectory.js");
24
- var cache;
25
- function _interopRequireWildcard(e, t) { if (!t && e && e.__esModule) return e; var r, c, a = Object.defineProperty, n = { __proto__: null, default: e }; if (Object(e) !== e) return n; if (cache || "function" != typeof WeakMap || (cache = new WeakMap()), cache) { if (cache.has(e)) return cache.get(e); cache.set(e, n); } for (c in e) "default" !== c && {}.hasOwnProperty.call(e, c) && ((r = a && Object.getOwnPropertyDescriptor(e, c)) && (r.get || r.set) ? a(n, c, r) : n[c] = e[c]); return n; }
26
- function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
27
- const installHint = 'Install it in your project with: npm install --save-dev typedoc';
28
-
1
+ "use strict";
2
+
3
+ require("core-js/modules/esnext.weak-map.delete-all.js");
4
+ Object.defineProperty(exports, "__esModule", {
5
+ value: true
6
+ });
7
+ exports.writeDocEntries = exports.typeDocsFor = exports.loadTypeDoc = exports.describeFolder = exports.describeFile = exports.describeExports = void 0;
8
+ require("core-js/modules/es.array.includes.js");
9
+ require("core-js/modules/es.json.stringify.js");
10
+ require("core-js/modules/esnext.iterator.constructor.js");
11
+ require("core-js/modules/esnext.iterator.filter.js");
12
+ require("core-js/modules/esnext.iterator.find.js");
13
+ require("core-js/modules/esnext.iterator.flat-map.js");
14
+ require("core-js/modules/esnext.iterator.for-each.js");
15
+ require("core-js/modules/esnext.iterator.map.js");
16
+ require("core-js/modules/esnext.iterator.some.js");
17
+ var _nodeFs = _interopRequireDefault(require("node:fs"));
18
+ var _nodeOs = _interopRequireDefault(require("node:os"));
19
+ var _nodePath = _interopRequireDefault(require("node:path"));
20
+ var gulpConfig = _interopRequireWildcard(require("../../gulp.config.js"));
21
+ var _loadPeer = require("./loadPeer.js");
22
+ var _tsFor = require("./tsFor.js");
23
+ var _removeDirectory = require("./removeDirectory.js");
24
+ var cache;
25
+ function _interopRequireWildcard(e, t) { if (!t && e && e.__esModule) return e; var r, c, a = Object.defineProperty, n = { __proto__: null, default: e }; if (Object(e) !== e) return n; if (cache || "function" != typeof WeakMap || (cache = new WeakMap()), cache) { if (cache.has(e)) return cache.get(e); cache.set(e, n); } for (c in e) "default" !== c && {}.hasOwnProperty.call(e, c) && ((r = a && Object.getOwnPropertyDescriptor(e, c)) && (r.get || r.set) ? a(n, c, r) : n[c] = e[c]); return n; }
26
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
27
+ const installHint = 'Install it in your project with: npm install --save-dev typedoc';
28
+
29
29
  /**
30
30
  * Find the TypeDoc of the project being built (an optional peer dependency, only needed when the docs task is
31
31
  * enabled) and load it, so projects which do not document TypeScript never need it installed.
@@ -33,87 +33,87 @@ const installHint = 'Install it in your project with: npm install --save-dev typ
33
33
  * @param {string} [projectPath=process.cwd()] The folder of the project (which has the package.json) being built.
34
34
  * @returns {Promise<Object>} The TypeDoc module.
35
35
  * @throws {Error} With install instructions when typedoc is not installed.
36
- */
37
- const loadTypeDoc = (projectPath = process.cwd()) => (0, _loadPeer.importPeer)('typedoc', {
38
- task: 'docs',
39
- installHint,
40
- projectPath
41
- });
42
- exports.loadTypeDoc = loadTypeDoc;
43
- const isSource = fileName => /\.(ts|tsx|mts|cts)$/.test(fileName) && !/\.d\.[cm]?ts$/.test(fileName) && !/\.(test|spec)\./.test(fileName);
44
- const listSources = dirPath => _nodeFs.default.readdirSync(dirPath, {
45
- withFileTypes: true
46
- }).filter(entry => entry.isFile() && isSource(entry.name)).map(entry => _nodePath.default.join(dirPath, entry.name));
47
-
48
- // Every folder below dirPath (at any depth), named by its path from dirPath, with the source files directly in it
49
- const listFolders = (dirPath, prefix = '') => _nodeFs.default.readdirSync(dirPath, {
50
- withFileTypes: true
51
- }).filter(entry => entry.isDirectory() && entry.name !== 'node_modules').flatMap(entry => {
52
- const folderPath = _nodePath.default.join(dirPath, entry.name);
53
- const name = `${prefix}${entry.name}`;
54
- return [{
55
- name,
56
- path: folderPath,
57
- files: listSources(folderPath)
58
- }, ...listFolders(folderPath, `${name}/`)];
59
- });
60
- const toIdentifier = fileName => fileName.replace(/[^\w$]+(\w)?/g, (match, letter) => (letter || '').toUpperCase()).replace(/^(\d)/, '_$1');
61
- const toModulePath = filePath => filePath.replace(/\.[cm]?tsx?$/, '').split(_nodePath.default.sep).join('/');
62
-
36
+ */
37
+ const loadTypeDoc = (projectPath = process.cwd()) => (0, _loadPeer.importPeer)('typedoc', {
38
+ task: 'docs',
39
+ installHint,
40
+ projectPath
41
+ });
42
+ exports.loadTypeDoc = loadTypeDoc;
43
+ const isSource = fileName => /\.(ts|tsx|mts|cts)$/.test(fileName) && !/\.d\.[cm]?ts$/.test(fileName) && !/\.(test|spec)\./.test(fileName);
44
+ const listSources = dirPath => _nodeFs.default.readdirSync(dirPath, {
45
+ withFileTypes: true
46
+ }).filter(entry => entry.isFile() && isSource(entry.name)).map(entry => _nodePath.default.join(dirPath, entry.name));
47
+
48
+ // Every folder below dirPath (at any depth), named by its path from dirPath, with the source files directly in it
49
+ const listFolders = (dirPath, prefix = '') => _nodeFs.default.readdirSync(dirPath, {
50
+ withFileTypes: true
51
+ }).filter(entry => entry.isDirectory() && entry.name !== 'node_modules').flatMap(entry => {
52
+ const folderPath = _nodePath.default.join(dirPath, entry.name);
53
+ const name = `${prefix}${entry.name}`;
54
+ return [{
55
+ name,
56
+ path: folderPath,
57
+ files: listSources(folderPath)
58
+ }, ...listFolders(folderPath, `${name}/`)];
59
+ });
60
+ const toIdentifier = fileName => fileName.replace(/[^\w$]+(\w)?/g, (match, letter) => (letter || '').toUpperCase()).replace(/^(\d)/, '_$1');
61
+ const toModulePath = filePath => filePath.replace(/\.[cm]?tsx?$/, '').split(_nodePath.default.sep).join('/');
62
+
63
63
  /**
64
64
  * Describe what a source file exports: whether it has a default export, and whether it has any named exports.
65
65
  * @memberOf module:partials
66
66
  * @param {Object} ts - The TypeScript compiler API.
67
67
  * @param {string} filePath
68
68
  * @returns {{hasDefault: boolean, hasNamed: boolean}}
69
- */
70
- const describeExports = (ts, filePath) => {
71
- const source = ts.createSourceFile(filePath, _nodeFs.default.readFileSync(filePath, 'utf8'), ts.ScriptTarget.Latest);
72
- let hasDefault = false;
73
- let hasNamed = false;
74
- source.statements.forEach(statement => {
75
- if (ts.isExportAssignment(statement)) {
76
- hasDefault = hasDefault || !statement.isExportEquals;
77
- return;
78
- }
79
- if (ts.isExportDeclaration(statement)) {
80
- const names = statement.exportClause && ts.isNamedExports(statement.exportClause) ? statement.exportClause.elements : null;
81
- if (names && names.some(element => element.name.text === 'default')) {
82
- hasDefault = true;
83
- }
84
- if (!names || names.some(element => element.name.text !== 'default')) {
85
- hasNamed = true;
86
- }
87
- return;
88
- }
89
- const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) || [] : [];
90
- if (modifiers.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) {
91
- if (modifiers.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword)) {
92
- hasDefault = true;
93
- } else {
94
- hasNamed = true;
95
- }
96
- }
97
- });
98
- return {
99
- hasDefault,
100
- hasNamed
101
- };
102
- };
103
-
69
+ */
70
+ const describeExports = (ts, filePath) => {
71
+ const source = ts.createSourceFile(filePath, _nodeFs.default.readFileSync(filePath, 'utf8'), ts.ScriptTarget.Latest);
72
+ let hasDefault = false;
73
+ let hasNamed = false;
74
+ source.statements.forEach(statement => {
75
+ if (ts.isExportAssignment(statement)) {
76
+ hasDefault = hasDefault || !statement.isExportEquals;
77
+ return;
78
+ }
79
+ if (ts.isExportDeclaration(statement)) {
80
+ const names = statement.exportClause && ts.isNamedExports(statement.exportClause) ? statement.exportClause.elements : null;
81
+ if (names && names.some(element => element.name.text === 'default')) {
82
+ hasDefault = true;
83
+ }
84
+ if (!names || names.some(element => element.name.text !== 'default')) {
85
+ hasNamed = true;
86
+ }
87
+ return;
88
+ }
89
+ const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) || [] : [];
90
+ if (modifiers.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) {
91
+ if (modifiers.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword)) {
92
+ hasDefault = true;
93
+ } else {
94
+ hasNamed = true;
95
+ }
96
+ }
97
+ });
98
+ return {
99
+ hasDefault,
100
+ hasNamed
101
+ };
102
+ };
103
+
104
104
  /**
105
105
  * The description of a folder for its module: the first doc comment of the folder's index file, without its tags
106
106
  * (@module, @file, ...), or an empty string when there is none.
107
107
  * @memberOf module:partials
108
108
  * @param {string} folderPath
109
109
  * @returns {string}
110
- */
111
- exports.describeExports = describeExports;
112
- const describeFolder = folderPath => {
113
- const indexFile = ['index.ts', 'index.tsx', 'index.mts', 'index.cts'].map(name => _nodePath.default.join(folderPath, name)).find(file => _nodeFs.default.existsSync(file));
114
- return indexFile ? describeFile(indexFile) : '';
115
- };
116
-
110
+ */
111
+ exports.describeExports = describeExports;
112
+ const describeFolder = folderPath => {
113
+ const indexFile = ['index.ts', 'index.tsx', 'index.mts', 'index.cts'].map(name => _nodePath.default.join(folderPath, name)).find(file => _nodeFs.default.existsSync(file));
114
+ return indexFile ? describeFile(indexFile) : '';
115
+ };
116
+
117
117
  /**
118
118
  * The description of a source file for its module: its first doc comment without the tags (@module, @file, ...), when
119
119
  * that comment is a header (it is followed by an import or export, so it does not document a declaration itself), or
@@ -121,18 +121,18 @@ const describeFolder = folderPath => {
121
121
  * @memberOf module:partials
122
122
  * @param {string} filePath
123
123
  * @returns {string}
124
- */
125
- exports.describeFolder = describeFolder;
126
- const describeFile = filePath => {
127
- const comment = _nodeFs.default.readFileSync(filePath, 'utf8').match(/^\s*\/\*\*([\s\S]*?)\*\/\s*(import\b|export\s+(\*|\{[^}]*\}\s+from)|$)/);
128
- if (!comment) {
129
- return '';
130
- }
131
- const lines = comment[1].split('\n').map(line => line.replace(/^\s*\* ?/, '').trimEnd());
132
- const tagAt = lines.findIndex(line => /^@\w+/.test(line));
133
- return (tagAt === -1 ? lines : lines.slice(0, tagAt)).join('\n').trim();
134
- };
135
-
124
+ */
125
+ exports.describeFolder = describeFolder;
126
+ const describeFile = filePath => {
127
+ const comment = _nodeFs.default.readFileSync(filePath, 'utf8').match(/^\s*\/\*\*([\s\S]*?)\*\/\s*(import\b|export\s+(\*|\{[^}]*\}\s+from)|$)/);
128
+ if (!comment) {
129
+ return '';
130
+ }
131
+ const lines = comment[1].split('\n').map(line => line.replace(/^\s*\* ?/, '').trimEnd());
132
+ const tagAt = lines.findIndex(line => /^@\w+/.test(line));
133
+ return (tagAt === -1 ? lines : lines.slice(0, tagAt)).join('\n').trim();
134
+ };
135
+
136
136
  /**
137
137
  * Write one entry file per folder of the source directory into the entry directory. TypeDoc makes a module of every
138
138
  * entry file, so the modules of the documentation mirror the folders. Nested folders are modules of their own, named
@@ -147,58 +147,58 @@ const describeFile = filePath => {
147
147
  * @param {string} srcDir - The directory holding the TypeScript source.
148
148
  * @param {string} entryDir - The directory to write the entry files into.
149
149
  * @returns {Array<string>} The paths of the entry files.
150
- */
151
- exports.describeFile = describeFile;
152
- const writeDocEntries = (ts, srcDir, entryDir) => {
153
- const absoluteSrc = _nodePath.default.resolve(srcDir);
154
- _nodeFs.default.mkdirSync(entryDir, {
155
- recursive: true
156
- });
157
- const folders = listFolders(absoluteSrc).filter(folder => folder.files.some(file => !/^index\./.test(_nodePath.default.basename(file))));
158
- const rootFiles = listSources(absoluteSrc).filter(file => !/^(index|main|browser)\./.test(_nodePath.default.basename(file)));
159
- // Folders make modules, and so do the files which sit beside them (except the barrels: index, main and browser, the
160
- // browser bundle's entry). A source directory with no folders gets a single module called index which holds its files.
161
- const groups = folders.length ? [...folders, ...rootFiles.map(file => ({
162
- name: toIdentifier(_nodePath.default.basename(file).replace(/\.[cm]?tsx?$/, '')),
163
- files: [file],
164
- description: describeFile(file)
165
- })).filter(group => !folders.some(folder => folder.name === group.name))] : [{
166
- name: 'index',
167
- path: absoluteSrc,
168
- files: rootFiles
169
- }];
170
- return groups.map(group => {
171
- const lines = group.files.filter(file => !/^index\./.test(_nodePath.default.basename(file))).sort().flatMap(file => {
172
- const modulePath = toModulePath(file);
173
- const {
174
- hasDefault,
175
- hasNamed
176
- } = describeExports(ts, file);
177
- const fileName = _nodePath.default.basename(file).replace(/\.[cm]?tsx?$/, '');
178
- return [...(hasDefault ? [`export { default as ${toIdentifier(fileName)} } from '${modulePath}'`] : []), ...(hasNamed ? [`export * from '${modulePath}'`] : [])];
179
- });
180
- const description = group.description ?? (group.path ? describeFolder(group.path) : '');
181
- // TypeDoc takes the first comment of an entry file which has the @module tag as the description of the module
182
- // A nested folder's module is named by its path (utilities/parsers), so it is not mistaken for its parent's
183
- const moduleName = group.name.includes('/') ? ` ${group.name}` : '';
184
- const moduleComment = description || moduleName ? `/**\n${description ? description.split('\n').map(line => ` * ${line}`.trimEnd()).join('\n') + '\n' : ''} * @module${moduleName}\n */\n\n` : '';
185
- const entryPath = _nodePath.default.join(entryDir, `${group.name}.ts`);
186
- _nodeFs.default.mkdirSync(_nodePath.default.dirname(entryPath), {
187
- recursive: true
188
- });
189
- _nodeFs.default.writeFileSync(entryPath, moduleComment + lines.join('\n') + '\n');
190
- return entryPath;
191
- });
192
- };
193
- exports.writeDocEntries = writeDocEntries;
194
- const projectName = () => {
195
- try {
196
- return JSON.parse(_nodeFs.default.readFileSync(_nodePath.default.join(process.cwd(), 'package.json'), 'utf8')).name;
197
- } catch (error) {
198
- return _nodePath.default.basename(process.cwd());
199
- }
200
- };
201
-
150
+ */
151
+ exports.describeFile = describeFile;
152
+ const writeDocEntries = (ts, srcDir, entryDir) => {
153
+ const absoluteSrc = _nodePath.default.resolve(srcDir);
154
+ _nodeFs.default.mkdirSync(entryDir, {
155
+ recursive: true
156
+ });
157
+ const folders = listFolders(absoluteSrc).filter(folder => folder.files.some(file => !/^index\./.test(_nodePath.default.basename(file))));
158
+ const rootFiles = listSources(absoluteSrc).filter(file => !/^(index|main|browser)\./.test(_nodePath.default.basename(file)));
159
+ // Folders make modules, and so do the files which sit beside them (except the barrels: index, main and browser, the
160
+ // browser bundle's entry). A source directory with no folders gets a single module called index which holds its files.
161
+ const groups = folders.length ? [...folders, ...rootFiles.map(file => ({
162
+ name: toIdentifier(_nodePath.default.basename(file).replace(/\.[cm]?tsx?$/, '')),
163
+ files: [file],
164
+ description: describeFile(file)
165
+ })).filter(group => !folders.some(folder => folder.name === group.name))] : [{
166
+ name: 'index',
167
+ path: absoluteSrc,
168
+ files: rootFiles
169
+ }];
170
+ return groups.map(group => {
171
+ const lines = group.files.filter(file => !/^index\./.test(_nodePath.default.basename(file))).sort().flatMap(file => {
172
+ const modulePath = toModulePath(file);
173
+ const {
174
+ hasDefault,
175
+ hasNamed
176
+ } = describeExports(ts, file);
177
+ const fileName = _nodePath.default.basename(file).replace(/\.[cm]?tsx?$/, '');
178
+ return [...(hasDefault ? [`export { default as ${toIdentifier(fileName)} } from '${modulePath}'`] : []), ...(hasNamed ? [`export * from '${modulePath}'`] : [])];
179
+ });
180
+ const description = group.description ?? (group.path ? describeFolder(group.path) : '');
181
+ // TypeDoc takes the first comment of an entry file which has the @module tag as the description of the module
182
+ // A nested folder's module is named by its path (utilities/parsers), so it is not mistaken for its parent's
183
+ const moduleName = group.name.includes('/') ? ` ${group.name}` : '';
184
+ const moduleComment = description || moduleName ? `/**\n${description ? description.split('\n').map(line => ` * ${line}`.trimEnd()).join('\n') + '\n' : ''} * @module${moduleName}\n */\n\n` : '';
185
+ const entryPath = _nodePath.default.join(entryDir, `${group.name}.ts`);
186
+ _nodeFs.default.mkdirSync(_nodePath.default.dirname(entryPath), {
187
+ recursive: true
188
+ });
189
+ _nodeFs.default.writeFileSync(entryPath, moduleComment + lines.join('\n') + '\n');
190
+ return entryPath;
191
+ });
192
+ };
193
+ exports.writeDocEntries = writeDocEntries;
194
+ const projectName = () => {
195
+ try {
196
+ return JSON.parse(_nodeFs.default.readFileSync(_nodePath.default.join(process.cwd(), 'package.json'), 'utf8')).name;
197
+ } catch (error) {
198
+ return _nodePath.default.basename(process.cwd());
199
+ }
200
+ };
201
+
202
202
  /**
203
203
  * Generate the HTML documentation of a TypeScript project with TypeDoc, straight from the TypeScript source, so the
204
204
  * types (and the comments on them) are documented from where they are defined. The modules mirror the folders of the
@@ -209,51 +209,57 @@ const projectName = () => {
209
209
  * @param {string} [outPath=gulpConfig.get('docs.to')] - The directory the documentation is generated into (cleared first).
210
210
  * @param {function(string): Promise<Object>} [loadDocs] - Resolves the TypeDoc module, tests can inject a stand-in.
211
211
  * @returns {Function} A gulp task, it accepts an optional callback and returns a promise.
212
- */
213
- const typeDocsFor = (srcPath = gulpConfig.get('docs.from'), outPath = gulpConfig.get('docs.to'), loadDocs = loadTypeDoc) => {
214
- if (gulpConfig.get('docs.enabled') === false) {
215
- return () => {};
216
- }
217
- return done => {
218
- const generate = async () => {
219
- const ts = (0, _tsFor.loadTypescript)();
220
- const {
221
- Application
222
- } = await loadDocs();
223
- const workDir = _nodeFs.default.mkdtempSync(_nodePath.default.join(_nodeOs.default.tmpdir(), 'typedocs-'));
224
- try {
225
- const entryPoints = writeDocEntries(ts, srcPath, _nodePath.default.join(workDir, 'entries'));
226
- // The entry files live outside the project, so the tsconfig which is used for the documentation extends the
227
- // one of the project and adds the entries to the files it includes.
228
- const projectConfig = gulpConfig.get('docs.tsconfig') || gulpConfig.get('typescript.config');
229
- const tsconfig = _nodePath.default.join(workDir, 'tsconfig.json');
230
- _nodeFs.default.writeFileSync(tsconfig, JSON.stringify({
231
- ...(projectConfig ? {
232
- extends: _nodePath.default.resolve(projectConfig)
233
- } : {}),
234
- include: [_nodePath.default.join(workDir, 'entries', '**', '*.ts').split(_nodePath.default.sep).join('/'), _nodePath.default.resolve(srcPath).split(_nodePath.default.sep).join('/') + '/**/*.ts'],
235
- exclude: [_nodePath.default.resolve(srcPath).split(_nodePath.default.sep).join('/') + '/**/*.test.*']
236
- }));
237
- const indexFile = gulpConfig.get('docs.index');
238
- await (0, _removeDirectory.removeDirectory)(outPath);
239
- const app = await Application.bootstrap({
240
- entryPoints,
241
- tsconfig,
242
- name: gulpConfig.get('docs.title') || projectName(),
243
- readme: indexFile && _nodeFs.default.existsSync(indexFile) ? indexFile : 'none',
244
- logLevel: gulpConfig.get('docs.logLevel') || 'Warn',
245
- skipErrorChecking: true
246
- });
247
- const project = await app.convert();
248
- if (!project) {
249
- throw new Error('TypeDoc could not read the TypeScript source, see the errors above.');
250
- }
251
- await app.generateDocs(project, outPath);
252
- } finally {
253
- await (0, _removeDirectory.removeDirectory)(workDir);
254
- }
255
- };
256
- return typeof done === 'function' ? generate().then(() => done(), done) : generate();
257
- };
258
- };
212
+ */
213
+ const typeDocsFor = (srcPath = gulpConfig.get('docs.from'), outPath = gulpConfig.get('docs.to'), loadDocs = loadTypeDoc) => {
214
+ if (gulpConfig.get('docs.enabled') === false) {
215
+ return () => {};
216
+ }
217
+ return done => {
218
+ const generate = async () => {
219
+ const ts = (0, _tsFor.loadTypescript)();
220
+ const {
221
+ Application
222
+ } = await loadDocs();
223
+ const workDir = _nodeFs.default.mkdtempSync(_nodePath.default.join(_nodeOs.default.tmpdir(), 'typedocs-'));
224
+ try {
225
+ const entryPoints = writeDocEntries(ts, srcPath, _nodePath.default.join(workDir, 'entries'));
226
+ // The entry files live outside the project, so the tsconfig which is used for the documentation extends the
227
+ // one of the project and adds the entries to the files it includes.
228
+ const projectConfig = gulpConfig.get('docs.tsconfig') || gulpConfig.get('typescript.config');
229
+ const tsconfig = _nodePath.default.join(workDir, 'tsconfig.json');
230
+ _nodeFs.default.writeFileSync(tsconfig, JSON.stringify({
231
+ ...(projectConfig ? {
232
+ extends: _nodePath.default.resolve(projectConfig)
233
+ } : {}),
234
+ include: [_nodePath.default.join(workDir, 'entries', '**', '*.ts').split(_nodePath.default.sep).join('/'), _nodePath.default.resolve(srcPath).split(_nodePath.default.sep).join('/') + '/**/*.ts'],
235
+ exclude: [_nodePath.default.resolve(srcPath).split(_nodePath.default.sep).join('/') + '/**/*.test.*']
236
+ }));
237
+ const indexFile = gulpConfig.get('docs.index');
238
+ const gitRevision = gulpConfig.get('docs.gitRevision');
239
+ await (0, _removeDirectory.removeDirectory)(outPath);
240
+ const app = await Application.bootstrap({
241
+ entryPoints,
242
+ tsconfig,
243
+ name: gulpConfig.get('docs.title') || projectName(),
244
+ readme: indexFile && _nodeFs.default.existsSync(indexFile) ? indexFile : 'none',
245
+ logLevel: gulpConfig.get('docs.logLevel') || 'Warn',
246
+ skipErrorChecking: true,
247
+ // Left out, TypeDoc links each declaration to its source at the current commit, so every commit changes
248
+ // every page; a branch name keeps the pages the same until the documentation itself changes.
249
+ ...(gitRevision ? {
250
+ gitRevision
251
+ } : {})
252
+ });
253
+ const project = await app.convert();
254
+ if (!project) {
255
+ throw new Error('TypeDoc could not read the TypeScript source, see the errors above.');
256
+ }
257
+ await app.generateDocs(project, outPath);
258
+ } finally {
259
+ await (0, _removeDirectory.removeDirectory)(workDir);
260
+ }
261
+ };
262
+ return typeof done === 'function' ? generate().then(() => done(), done) : generate();
263
+ };
264
+ };
259
265
  exports.typeDocsFor = typeDocsFor;
@@ -25,6 +25,34 @@ export const loadImagemin = (projectPath = process.cwd()) => {
25
25
  return loaded && (loaded.__esModule || loaded.default) ? loaded.default : loaded
26
26
  }
27
27
 
28
+ /**
29
+ * Load the plugins `gulp-imagemin` exports by name (gifsicle, mozjpeg, optipng and svgo), each a promise of a plugin
30
+ * factory, for when they need options of their own (see images.jpegQuality). Resolved like {@link loadImagemin}.
31
+ * @memberOf module:partials
32
+ * @param {string} [projectPath=process.cwd()] - The project directory to resolve `gulp-imagemin` from.
33
+ * @returns {Object<string, Promise<Function>>} The plugin factories, by name.
34
+ */
35
+ export const loadImageminPlugins = (projectPath = process.cwd()) => requirePeer('gulp-imagemin', {
36
+ task: 'images',
37
+ installHint,
38
+ esmOnlyMessage: `gulp-imagemin could not be loaded because it is ESM-only. ${installHint}`,
39
+ projectPath
40
+ })
41
+
42
+ // The plugins imagemin runs when it isn't given any, in its own order
43
+ const defaultPluginNames = ['gifsicle', 'mozjpeg', 'optipng', 'svgo']
44
+
45
+ /**
46
+ * The plugins imagemin uses by default, with mozjpeg set to the given JPEG quality instead of its own default (75).
47
+ * @memberOf module:partials
48
+ * @param {number} jpegQuality - The mozjpeg quality, 0 to 100.
49
+ * @param {Object<string, Promise<Function>>} [plugins=loadImageminPlugins()] - The plugin factories, by name.
50
+ * @returns {Array<Promise<Function>>} The plugins, in imagemin's default order.
51
+ */
52
+ export const imageminPlugins = (jpegQuality, plugins = loadImageminPlugins()) => defaultPluginNames.map(
53
+ name => Promise.resolve(plugins[name]).then(makePlugin => name === 'mozjpeg' ? makePlugin({ quality: jpegQuality }) : makePlugin())
54
+ )
55
+
28
56
  /**
29
57
  * Move and optimize images into the browser directory.
30
58
  * @memberOf module:partials
@@ -34,16 +62,21 @@ export const loadImagemin = (projectPath = process.cwd()) => {
34
62
  */
35
63
  export const imagesFor = (imageSrc = gulpConfig.get('images.from'), imageDest = gulpConfig.get('images.to')) => {
36
64
  const imagemin = loadImagemin()
65
+ const jpegQuality = gulpConfig.get('images.jpegQuality')
66
+ // Left unset, imagemin runs its default plugins with their own defaults
67
+ const plugins = jpegQuality ? imageminPlugins(jpegQuality) : undefined
37
68
  // Gulp 5 reads files as utf8 text unless told otherwise, which corrupts binary images (and makes gulp-cache drop them).
38
69
  return src(imageSrc, { encoding: false })
39
70
  .pipe(
40
71
  // Caching images that ran through imagemin
41
72
  cache(
42
- imagemin({
73
+ imagemin(plugins, {
43
74
  interlaced: true,
44
75
  silent: true,
45
76
  verbose: false,
46
- })
77
+ }),
78
+ // gulp-cache keys on the file's contents alone, so keep each quality's results apart
79
+ { name: jpegQuality ? `jpeg-quality-${jpegQuality}` : 'default' }
47
80
  )
48
81
  )
49
82
  .pipe(dest(imageDest))
@@ -1,8 +1,10 @@
1
+ import { execFileSync } from 'child_process'
1
2
  import fs from 'fs'
2
3
  import * as setUp from '../test-helpers/setUp.mjs'
3
4
  import os from 'os'
5
+ import path from 'path'
4
6
  import zlib from 'zlib'
5
- import { imagesFor, loadImagemin } from './imagesFor.mjs'
7
+ import { imageminPlugins, imagesFor, loadImagemin } from './imagesFor.mjs'
6
8
  import { isolateGulpCache } from '../test-helpers/isolateGulpCache.mjs'
7
9
 
8
10
  setUp.setDefaults('test-images-for')
@@ -158,6 +160,25 @@ const makePng = (size = 32) => {
158
160
  ])
159
161
  }
160
162
 
163
+ // A JPEG at quality 100, encoded from a generated image by the cjpeg binary the mozjpeg dependency installs
164
+ const makeJpeg = (size = 64) => {
165
+ const pixels = Buffer.alloc(size * size * 3)
166
+ for (let i = 0; i < pixels.length; i++) {
167
+ pixels[i] = (i * 37 + Math.floor(i / (size * 3)) * 91) % 256
168
+ }
169
+ const ppm = Buffer.concat([Buffer.from(`P6\n${size} ${size}\n255\n`), pixels])
170
+ // mozjpeg's exports field hides its files, and Jest runs from the project root
171
+ const cjpeg = path.resolve('node_modules', 'mozjpeg', 'vendor', 'cjpeg')
172
+ return execFileSync(cjpeg, ['-quality', '100'], { input: ppm })
173
+ }
174
+
175
+ // Run imagesFor over one file and resolve with the written output
176
+ const optimize = (file, to) => new Promise((resolve, reject) => {
177
+ imagesFor(file, to)
178
+ .on('finish', () => resolve(fs.readFileSync(`${to}/${path.basename(file)}`)))
179
+ .on('error', reject)
180
+ })
181
+
161
182
  // The cache outlives a run, so clear it or a stale result from an older imagemin can pass
162
183
  beforeEach(() => clearImageCache().then(setUp.beforeEach))
163
184
 
@@ -210,6 +231,43 @@ describe('imagesFor', () => {
210
231
  })
211
232
  })
212
233
 
234
+ describe('images.jpegQuality', () => {
235
+ test('compresses JPEGs to the configured quality, never reusing another quality\'s cached result', async () => {
236
+ expect.assertions(4)
237
+ const srcPath = gulpConfig.get('srcPath')
238
+ const jpegFile = `${srcPath}/photo.jpg`
239
+ fs.writeFileSync(jpegFile, makeJpeg())
240
+ const browserPath = gulpConfig.get('browser.to')
241
+ try {
242
+ const atDefault = await optimize(jpegFile, `${browserPath}/default`)
243
+ gulpConfig.set('images.jpegQuality', 95)
244
+ const at95 = await optimize(jpegFile, `${browserPath}/q95`)
245
+ // Both still JPEGs (start-of-image marker)
246
+ expect(atDefault.subarray(0, 2).equals(Buffer.from([0xff, 0xd8]))).toBeTruthy()
247
+ expect(at95.subarray(0, 2).equals(Buffer.from([0xff, 0xd8]))).toBeTruthy()
248
+ // Same input, so a shared cache entry would make these identical: 95 keeps more detail than mozjpeg's 75
249
+ expect(at95.equals(atDefault)).toBe(false)
250
+ expect(at95.length).toBeGreaterThan(atDefault.length)
251
+ } finally {
252
+ gulpConfig.set('images.jpegQuality', false)
253
+ }
254
+ }, 30000)
255
+ })
256
+
257
+ describe('imageminPlugins', () => {
258
+ test('builds imagemin\'s default plugins in order, passing the quality to mozjpeg only', async () => {
259
+ expect.assertions(2)
260
+ const calls = []
261
+ const factory = name => Promise.resolve((...args) => {
262
+ calls.push([name, ...args])
263
+ return name
264
+ })
265
+ const plugins = { gifsicle: factory('gifsicle'), mozjpeg: factory('mozjpeg'), optipng: factory('optipng'), svgo: factory('svgo') }
266
+ expect(await Promise.all(imageminPlugins(85, plugins))).toEqual(['gifsicle', 'mozjpeg', 'optipng', 'svgo'])
267
+ expect(calls).toEqual([['gifsicle'], ['mozjpeg', { quality: 85 }], ['optipng'], ['svgo']])
268
+ })
269
+ })
270
+
213
271
  describe('loadImagemin', () => {
214
272
  test('loads the gulp-imagemin plugin factory from the project directory', () => {
215
273
  expect.assertions(1)
@@ -204,6 +204,7 @@ export const typeDocsFor = (
204
204
  exclude: [path.resolve(srcPath).split(path.sep).join('/') + '/**/*.test.*']
205
205
  }))
206
206
  const indexFile = gulpConfig.get('docs.index')
207
+ const gitRevision = gulpConfig.get('docs.gitRevision')
207
208
  await removeDirectory(outPath)
208
209
  const app = await Application.bootstrap({
209
210
  entryPoints,
@@ -211,7 +212,10 @@ export const typeDocsFor = (
211
212
  name: gulpConfig.get('docs.title') || projectName(),
212
213
  readme: indexFile && fs.existsSync(indexFile) ? indexFile : 'none',
213
214
  logLevel: gulpConfig.get('docs.logLevel') || 'Warn',
214
- skipErrorChecking: true
215
+ skipErrorChecking: true,
216
+ // Left out, TypeDoc links each declaration to its source at the current commit, so every commit changes
217
+ // every page; a branch name keeps the pages the same until the documentation itself changes.
218
+ ...(gitRevision ? { gitRevision } : {})
215
219
  })
216
220
  const project = await app.convert()
217
221
  if (!project) {
@@ -211,6 +211,17 @@ describe('typeDocsFor', () => {
211
211
  expect(calls.options.readme).toBe('none')
212
212
  })
213
213
 
214
+ test('passes docs.gitRevision on to TypeDoc, and leaves TypeDoc\'s own default when it is false', async () => {
215
+ gulpConfig.set('docs.enabled', true)
216
+ const calls = {}
217
+ await typeDocsFor(gulpConfig.get('srcPath'), `${gulpConfig.get('srcPath')}/../docs`, fakeTypeDoc(calls))()
218
+ expect('gitRevision' in calls.options).toBe(false)
219
+ gulpConfig.set('docs.gitRevision', 'main')
220
+ await typeDocsFor(gulpConfig.get('srcPath'), `${gulpConfig.get('srcPath')}/../docs`, fakeTypeDoc(calls))()
221
+ expect(calls.options.gitRevision).toBe('main')
222
+ gulpConfig.set('docs.gitRevision', false)
223
+ })
224
+
214
225
  test('reads the source with the tsconfig of the project, adding the entries to it', async () => {
215
226
  gulpConfig.set('docs.enabled', true)
216
227
  gulpConfig.set('typescript.config', 'tsconfig.json')
package/gulp.config.mjs CHANGED
@@ -16,6 +16,10 @@ import { fileExists } from 'test-filesystem'
16
16
  * A setting that may be true or false.
17
17
  * @typedef {boolean} module:gulpConfig~BooleanSetting
18
18
  */
19
+ /**
20
+ * A setting that may be flag 'false' or provide a number
21
+ * @typedef {false|number} module:gulpConfig~FlagNumberSetting
22
+ */
19
23
  /**
20
24
  * A setting that may be flag 'false' or provide a StringSetting
21
25
  * @typedef {false|StringSetting} module:gulpConfig~FlagStringSetting
@@ -43,7 +47,7 @@ import { fileExists } from 'test-filesystem'
43
47
  */
44
48
  /**
45
49
  * Any single configuration option is a Setting.
46
- * @typedef {ArrayableSetting|BooleanSetting|FlagsSetting|StringSetting} Setting
50
+ * @typedef {ArrayableSetting|BooleanSetting|FlagNumberSetting|FlagsSetting|StringSetting} Setting
47
51
  */
48
52
  /**
49
53
  * One copy-files entry: a glob (or array of globs) to copy, and where to copy it.
@@ -103,6 +107,9 @@ import { fileExists } from 'test-filesystem'
103
107
  * @typedef {Object<string, Setting>} module:gulpConfig~ImageConfig
104
108
  * @property {BooleanSetting} enabled - Toggle image minify and copy process.
105
109
  * @property {StringSetting} from - Path to search for images.
110
+ * @property {FlagNumberSetting} jpegQuality - The JPEG quality (0 to 100) mozjpeg compresses to. False (the
111
+ * default) keeps mozjpeg's own default, 75. Raise it (85 is usually indistinguishable from the original) when 75
112
+ * visibly bands smooth gradients.
106
113
  * @property {StringSetting} to - Path to output images.
107
114
  */
108
115
  /**
@@ -135,6 +142,10 @@ import { fileExists } from 'test-filesystem'
135
142
  * build does this instead of generating the readme from the jsdoc comments of the compiled files.
136
143
  * @property {ArrayableSetting} from - The directory holding the source, each folder in it becomes a module (TypeDoc) or
137
144
  * is scanned recursively (jsdoc). jsdoc also takes a list of directories and files.
145
+ * @property {FlagStringSetting} gitRevision - The git revision TypeDoc's "Defined in" source links point to (TypeDoc
146
+ * only). False (the default) is TypeDoc's own: the current commit, which changes every page on every commit. A
147
+ * branch name such as 'main' keeps the pages unchanged until the documentation itself changes; '{branch}' is the
148
+ * branch being built.
138
149
  * @property {BooleanSetting} inBuild - Generate the documentation as part of build (the default). Turn it off to only
139
150
  * generate it when you run the docs task yourself (gulp docs), for example when docs.to points into another
140
151
  * repository and you only want to publish the documentation of a release.
@@ -219,6 +230,7 @@ const setDefaults = {
219
230
  docs: {
220
231
  enabled: false,
221
232
  from: 'src',
233
+ gitRevision: false,
222
234
  inBuild: true,
223
235
  index: 'MAIN.md',
224
236
  logLevel: 'Warn',
@@ -236,6 +248,7 @@ const setDefaults = {
236
248
  images: {
237
249
  enabled: false,
238
250
  from: 'src/img/**/*.+(png|jpg|jpeg|gif|svg)',
251
+ jpegQuality: false,
239
252
  to: 'browser/img',
240
253
  },
241
254
  lint: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "js-build-tools",
3
- "version": "6.5.0",
3
+ "version": "6.6.0",
4
4
  "description": "Standardised build process with gulp.",
5
5
  "main": "gulpfile.base.js",
6
6
  "exports": {