@truenas/ui-components 0.8.8 → 0.8.10
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/package.json
CHANGED
|
@@ -16,7 +16,13 @@
|
|
|
16
16
|
* --help Show help
|
|
17
17
|
*
|
|
18
18
|
* Configuration File:
|
|
19
|
-
* Create truenas-icons.config.js in your project root
|
|
19
|
+
* Create truenas-icons.config.js in your project root, as ESM or as CommonJS —
|
|
20
|
+
* either is read. A .cjs, .mjs, .cts, .mts or .json file is read too when named
|
|
21
|
+
* with --config. For TypeScript prefer .mts or .cts: a bare .ts takes its module
|
|
22
|
+
* kind from its package scope, so a scope declaring no "type" at all has it read
|
|
23
|
+
* as CommonJS, leaving import.meta.dirname undefined — which is warned about
|
|
24
|
+
* rather than loaded quietly. See `lib/load-config.ts` for which shapes are read
|
|
25
|
+
* and how.
|
|
20
26
|
*
|
|
21
27
|
* export default {
|
|
22
28
|
* srcDirs: ['./src/lib', './src/app'],
|
|
@@ -26,10 +32,9 @@
|
|
|
26
32
|
*/
|
|
27
33
|
|
|
28
34
|
import { generateSprite } from './generate-sprite.js';
|
|
35
|
+
import { loadConfig } from './lib/load-config.js';
|
|
29
36
|
import { validateIcons, printValidationReport } from './lib/validate-icons.js';
|
|
30
37
|
import { resolveConfig } from './sprite-config-interface.js';
|
|
31
|
-
import fs from 'fs';
|
|
32
|
-
import path from 'path';
|
|
33
38
|
|
|
34
39
|
const HELP_TEXT = `
|
|
35
40
|
truenas-icons - Icon sprite generation for TrueNAS UI components
|
|
@@ -63,7 +68,8 @@ Options:
|
|
|
63
68
|
--help Show this help message
|
|
64
69
|
|
|
65
70
|
Configuration File:
|
|
66
|
-
Create truenas-icons.config.js in your project root
|
|
71
|
+
Create truenas-icons.config.js in your project root. Either module form is
|
|
72
|
+
read, so write it the way the rest of your project's .js files are written.
|
|
67
73
|
|
|
68
74
|
export default {
|
|
69
75
|
srcDirs: ['./src/lib', './src/app'],
|
|
@@ -71,6 +77,24 @@ Configuration File:
|
|
|
71
77
|
customIconsDir: './custom-icons'
|
|
72
78
|
};
|
|
73
79
|
|
|
80
|
+
module.exports = {
|
|
81
|
+
srcDirs: ['./src/lib', './src/app']
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
Only truenas-icons.config.js is looked for by default. A .cjs, .mjs or .json
|
|
85
|
+
config is read correctly but is not discovered, so name it with --config:
|
|
86
|
+
|
|
87
|
+
npx truenas-icons generate --config truenas-icons.config.cjs
|
|
88
|
+
|
|
89
|
+
For a TypeScript config, name it .mts if it is ESM or .cts if it is CommonJS.
|
|
90
|
+
Both are decided by their extension. A bare .ts takes its module kind from its
|
|
91
|
+
package scope instead — the nearest package.json above it, stopping at any
|
|
92
|
+
node_modules directory — and if that scope declares no "type" at all the file
|
|
93
|
+
is read as CommonJS whatever it contains, leaving import.meta.dirname
|
|
94
|
+
undefined. That case is warned about rather than loaded quietly.
|
|
95
|
+
|
|
96
|
+
npx truenas-icons generate --config truenas-icons.config.mts
|
|
97
|
+
|
|
74
98
|
Examples:
|
|
75
99
|
# Generate with defaults
|
|
76
100
|
npx truenas-icons generate
|
|
@@ -123,29 +147,6 @@ function parseArgs() {
|
|
|
123
147
|
return parsed;
|
|
124
148
|
}
|
|
125
149
|
|
|
126
|
-
async function loadConfig(configFile: string) {
|
|
127
|
-
const configPath = path.resolve(process.cwd(), configFile);
|
|
128
|
-
|
|
129
|
-
if (!fs.existsSync(configPath)) {
|
|
130
|
-
return {};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
try {
|
|
134
|
-
// Try to load as ES module
|
|
135
|
-
const config = await import(configPath);
|
|
136
|
-
return config.default || config;
|
|
137
|
-
} catch (error) {
|
|
138
|
-
// Fallback to CommonJS
|
|
139
|
-
try {
|
|
140
|
-
const config = require(configPath);
|
|
141
|
-
return config.default || config;
|
|
142
|
-
} catch (err) {
|
|
143
|
-
console.warn(`Warning: Could not load config file: ${configPath}`);
|
|
144
|
-
return {};
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
150
|
async function main() {
|
|
150
151
|
const args = parseArgs();
|
|
151
152
|
|
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { resolveConfig } from '../sprite-config-interface';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Why this spec runs a real process instead of calling `loadConfig` directly.
|
|
9
|
+
*
|
|
10
|
+
* What the loader does is decided entirely by the module system reading it, and
|
|
11
|
+
* neither of the two things that decide that is true inside Jest:
|
|
12
|
+
*
|
|
13
|
+
* - **The scope it runs in.** The published package carries `"type": "module"`,
|
|
14
|
+
* written by ng-packagr rather than by this repo (#362), so tsx reads the
|
|
15
|
+
* shipped CLI as ESM — where `require` is not defined, which is the whole
|
|
16
|
+
* defect. `projects/truenas-ui/package.json` has no `type`, so the same file in
|
|
17
|
+
* the source tree is CommonJS. The published shape has to be built to be seen.
|
|
18
|
+
* - **The loader doing the reading.** Jest's CommonJS runtime rewrites `import()`
|
|
19
|
+
* into its own `require`, which ignores a package's `type` and wraps every file
|
|
20
|
+
* as CommonJS. Called in-process, this loader "successfully" reads a
|
|
21
|
+
* `module.exports` config out of a `"type": "module"` project — something node
|
|
22
|
+
* cannot do and tsx only manages through its own transform. A test asserting
|
|
23
|
+
* that would be asserting Jest's module system.
|
|
24
|
+
*
|
|
25
|
+
* So the setup below reproduces the publish step for the files under test: copy
|
|
26
|
+
* them, byte for byte, into a package that declares `"type": "module"`, then run
|
|
27
|
+
* them under tsx the way `cli.cjs` runs the real CLI. One process handles every
|
|
28
|
+
* case and writes a result table, because a spawn per case is the slow way to
|
|
29
|
+
* learn the same thing.
|
|
30
|
+
*/
|
|
31
|
+
const repoRoot = path.resolve(__dirname, '..', '..', '..', '..', '..');
|
|
32
|
+
const tsxCli = path.join(repoRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Whether this node strips TypeScript itself, which is what decides a `.ts` file's
|
|
36
|
+
* kind when its package scope declares an explicit `"type": "commonjs"`. Absent
|
|
37
|
+
* before 22.10, `false` when `--no-experimental-strip-types` turns it off, a string
|
|
38
|
+
* when on; widened because `@types/node` only grew the field in 22.x. The spawn
|
|
39
|
+
* below uses `process.execPath`, so this is the child's behaviour too.
|
|
40
|
+
*/
|
|
41
|
+
const nodeStripsTypeScript = Boolean((process.features as { typescript?: unknown }).typescript);
|
|
42
|
+
|
|
43
|
+
interface CaseDefinition {
|
|
44
|
+
/** What the consumer's own `package.json` says — `type` is what decides the config's kind. */
|
|
45
|
+
packageJson: Record<string, unknown>;
|
|
46
|
+
/** Relative to the project root, so a `/` in it puts the config in a subdirectory. */
|
|
47
|
+
configFile: string;
|
|
48
|
+
contents: string;
|
|
49
|
+
/** Set on the case that is about the file not being there. Defaults to writing it. */
|
|
50
|
+
write?: false;
|
|
51
|
+
/**
|
|
52
|
+
* Extra files, by path relative to the project root. Only the walk cases need
|
|
53
|
+
* this: where a config's *package scope* is depends on what sits between it and
|
|
54
|
+
* the project root, which a single `packageJson` cannot express.
|
|
55
|
+
*/
|
|
56
|
+
extraFiles?: Record<string, string>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface CaseResult {
|
|
60
|
+
config: Record<string, unknown>;
|
|
61
|
+
warnings: string[];
|
|
62
|
+
/** How many times the config file's own body ran. See `EVALUATION_COUNTER`. */
|
|
63
|
+
evaluations: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Prepended to a config fixture to count its own evaluations. Counts into
|
|
68
|
+
* `globalThis` because both module scopes have it: anything reached through
|
|
69
|
+
* `require` cannot run in the ESM scope that half these cases are about, and a
|
|
70
|
+
* counter that cannot run is a counter that reads zero and proves nothing.
|
|
71
|
+
*/
|
|
72
|
+
const EVALUATION_COUNTER = 'globalThis.__timesEvaluated = (globalThis.__timesEvaluated || 0) + 1;';
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Shared by the `import.meta` cases below, which differ only in where they sit and
|
|
76
|
+
* what they are called. It reports its own directory's name, which is the case's
|
|
77
|
+
* own key — asserted through `IMPORT_META` rather than written out, so renaming a
|
|
78
|
+
* case cannot turn into a value mismatch that explains nothing.
|
|
79
|
+
*/
|
|
80
|
+
const IMPORT_META = 'esm-config-using-import-meta';
|
|
81
|
+
|
|
82
|
+
const IMPORT_META_CONFIG = [
|
|
83
|
+
EVALUATION_COUNTER,
|
|
84
|
+
"import path from 'path';",
|
|
85
|
+
'',
|
|
86
|
+
'export default { srcDirs: [path.basename(String(import.meta.dirname))] };',
|
|
87
|
+
].join('\n');
|
|
88
|
+
|
|
89
|
+
// `satisfies` rather than an annotation, so `keyof typeof CASES` stays the union of
|
|
90
|
+
// the names below. Annotated as `Record<string, CaseDefinition>` it widens to
|
|
91
|
+
// `string`, and `resultFor`'s parameter type stops checking anything.
|
|
92
|
+
const CASES = {
|
|
93
|
+
// The shape in the report: no `type` field, so a plain `.js` config is
|
|
94
|
+
// CommonJS. The config uses `require` itself, not just `module.exports`.
|
|
95
|
+
'commonjs-config-in-a-plain-project': {
|
|
96
|
+
packageJson: {},
|
|
97
|
+
configFile: 'truenas-icons.config.js',
|
|
98
|
+
contents: [
|
|
99
|
+
"const path = require('path');",
|
|
100
|
+
'',
|
|
101
|
+
'module.exports = {',
|
|
102
|
+
" srcDirs: [path.join('.', 'src', 'app')],",
|
|
103
|
+
" outputDir: './public/icons',",
|
|
104
|
+
'};',
|
|
105
|
+
].join('\n'),
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
// The one the old loader actually dropped: CommonJS syntax in a `.js` file that
|
|
109
|
+
// its own package declares to be ESM. `import()` cannot read it, so everything
|
|
110
|
+
// depended on a fallback that could not run. It counts its own evaluations
|
|
111
|
+
// because it is also the shape that gets run twice if the loaders are tried the
|
|
112
|
+
// other way round.
|
|
113
|
+
'commonjs-config-in-an-esm-project': {
|
|
114
|
+
packageJson: { type: 'module' },
|
|
115
|
+
configFile: 'truenas-icons.config.js',
|
|
116
|
+
contents: [
|
|
117
|
+
EVALUATION_COUNTER,
|
|
118
|
+
"module.exports = { srcDirs: ['./src/mixed'], outputDir: './dist/mixed' };",
|
|
119
|
+
].join('\n'),
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
'commonjs-config-named-cjs': {
|
|
123
|
+
packageJson: { type: 'module' },
|
|
124
|
+
configFile: 'truenas-icons.config.cjs',
|
|
125
|
+
contents: "module.exports = { srcDirs: ['./src/cjs'], customIconsDir: './brand' };",
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
// `module.exports` replaced wholesale is the common form; assigning onto
|
|
129
|
+
// `exports` is the other one, and it arrives as a different shape.
|
|
130
|
+
'commonjs-config-assigning-onto-exports': {
|
|
131
|
+
packageJson: {},
|
|
132
|
+
configFile: 'truenas-icons.config.js',
|
|
133
|
+
contents: ["exports.srcDirs = ['./src/named'];", "exports.outputDir = './dist/named';"].join(
|
|
134
|
+
'\n'
|
|
135
|
+
),
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
'esm-config': {
|
|
139
|
+
packageJson: { type: 'module' },
|
|
140
|
+
configFile: 'truenas-icons.config.js',
|
|
141
|
+
contents: "export default { srcDirs: ['./src/esm'], spriteUrlPath: 'assets/tn-icons' };",
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
'esm-config-named-mjs': {
|
|
145
|
+
packageJson: {},
|
|
146
|
+
configFile: 'truenas-icons.config.mjs',
|
|
147
|
+
contents: "export default { srcDirs: ['./src/mjs'] };",
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
// No default export, so the module namespace is the configuration.
|
|
151
|
+
'esm-config-without-a-default-export': {
|
|
152
|
+
packageJson: { type: 'module' },
|
|
153
|
+
configFile: 'truenas-icons.config.js',
|
|
154
|
+
contents: "export const srcDirs = ['./src/named-esm'];",
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
// Top-level await is a shape `require` cannot take at all, so it pins that the
|
|
158
|
+
// ESM loader is the one reading an ESM config rather than a fallback reached
|
|
159
|
+
// after a failure.
|
|
160
|
+
'esm-config-using-top-level-await': {
|
|
161
|
+
packageJson: { type: 'module' },
|
|
162
|
+
configFile: 'truenas-icons.config.js',
|
|
163
|
+
contents: [
|
|
164
|
+
EVALUATION_COUNTER,
|
|
165
|
+
"const srcDirs = await Promise.resolve(['./src/awaited']);",
|
|
166
|
+
'export default { srcDirs };',
|
|
167
|
+
].join('\n'),
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* `import.meta.dirname` is what makes the loader choice matter beyond counting
|
|
172
|
+
* evaluations. tsx's CommonJS transform shims `import.meta.url` and leaves
|
|
173
|
+
* `dirname` **undefined**, so a config read by the wrong loader does not fail — it
|
|
174
|
+
* loads with a wrong value in it. Reporting its own directory name is what turns
|
|
175
|
+
* that from invisible into an assertion.
|
|
176
|
+
*/
|
|
177
|
+
[`${IMPORT_META}-in-an-esm-project`]: {
|
|
178
|
+
packageJson: { type: 'module' },
|
|
179
|
+
configFile: 'truenas-icons.config.js',
|
|
180
|
+
contents: IMPORT_META_CONFIG,
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The same file in a project that declares no type at all — which is not the
|
|
185
|
+
* CommonJS case it looks like. Node has read a typeless `.js` as CommonJS *and
|
|
186
|
+
* reparsed it as ESM on failure* since 22.7, so this is an ESM config however
|
|
187
|
+
* little its package says so, and anything that treats "no type" as CommonJS
|
|
188
|
+
* hands it an undefined `import.meta.dirname`.
|
|
189
|
+
*/
|
|
190
|
+
[`${IMPORT_META}-in-a-plain-project`]: {
|
|
191
|
+
packageJson: {},
|
|
192
|
+
configFile: 'truenas-icons.config.js',
|
|
193
|
+
contents: IMPORT_META_CONFIG,
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
/** `.mts` is ESM by extension, the way `.cts` below is CommonJS by extension. */
|
|
197
|
+
[`${IMPORT_META}-named-mts`]: {
|
|
198
|
+
packageJson: {},
|
|
199
|
+
configFile: 'truenas-icons.config.mts',
|
|
200
|
+
contents: IMPORT_META_CONFIG,
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* #369, the shape nothing can load correctly: a bare `.ts` whose project
|
|
205
|
+
* declares no type. Unlike the typeless `.js` above, tsx reads this one as
|
|
206
|
+
* CommonJS whatever its syntax and never reparses it as ESM, so
|
|
207
|
+
* `import.meta.dirname` is `undefined` and the value that reaches the sprite is
|
|
208
|
+
* wrong. The extension is what has to change, so what is asserted is that the
|
|
209
|
+
* loader says so instead of loading it quietly.
|
|
210
|
+
*/
|
|
211
|
+
[`${IMPORT_META}-named-ts-in-a-plain-project`]: {
|
|
212
|
+
packageJson: {},
|
|
213
|
+
configFile: 'truenas-icons.config.ts',
|
|
214
|
+
contents: IMPORT_META_CONFIG,
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The same file where its own project declares ESM, which is the half of `.ts`
|
|
219
|
+
* that works — and so the half a warning must stay out of.
|
|
220
|
+
*/
|
|
221
|
+
[`${IMPORT_META}-named-ts-in-an-esm-project`]: {
|
|
222
|
+
packageJson: { type: 'module' },
|
|
223
|
+
configFile: 'truenas-icons.config.ts',
|
|
224
|
+
contents: IMPORT_META_CONFIG,
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* A `.ts` config that really is CommonJS, in a project declaring no type: read
|
|
229
|
+
* exactly right, and warned about regardless. Here so the false positive the
|
|
230
|
+
* warning accepts is on the record as a decision rather than found as a surprise.
|
|
231
|
+
*/
|
|
232
|
+
'commonjs-config-named-ts': {
|
|
233
|
+
packageJson: {},
|
|
234
|
+
configFile: 'truenas-icons.config.ts',
|
|
235
|
+
contents: "module.exports = { srcDirs: ['./src/ts'] };",
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Where the walk stops, half one: the closest manifest decides even when it
|
|
240
|
+
* declares nothing. The project root says `"type": "module"` and the config's own
|
|
241
|
+
* directory has a manifest that does not, so tsx reads the config as CommonJS —
|
|
242
|
+
* an implementation that skipped the typeless manifest to find a typed one would
|
|
243
|
+
* answer `"module"` and say nothing.
|
|
244
|
+
*/
|
|
245
|
+
'ts-config-under-a-typeless-nested-manifest': {
|
|
246
|
+
packageJson: { type: 'module' },
|
|
247
|
+
configFile: 'tools/truenas-icons.config.ts',
|
|
248
|
+
contents: IMPORT_META_CONFIG,
|
|
249
|
+
extraFiles: { 'tools/package.json': '{ "name": "nested-tools", "version": "1.0.0" }' },
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Where the walk stops, half two: a `node_modules` directory ends it. There is no
|
|
254
|
+
* manifest inside it, so a walk that merely looks for the nearest one keeps going
|
|
255
|
+
* and finds the project root's `"type": "module"` — which is not what tsx does,
|
|
256
|
+
* and the config loads as CommonJS with nothing said.
|
|
257
|
+
*/
|
|
258
|
+
'ts-config-inside-node-modules': {
|
|
259
|
+
packageJson: { type: 'module' },
|
|
260
|
+
configFile: 'node_modules/truenas-icons.config.ts',
|
|
261
|
+
contents: IMPORT_META_CONFIG,
|
|
262
|
+
},
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The rest of the family in `.ts`'s position. tsx recognises
|
|
266
|
+
* `/\.([cm]?ts|[tj]sx)($|\?)/` and settles only `.mts` and `.cts` by extension,
|
|
267
|
+
* so `.tsx` and `.jsx` take their kind from package scope exactly as `.ts` does
|
|
268
|
+
* — and were silently mis-read while the check was a literal `'.ts'`. `--config`
|
|
269
|
+
* takes any path, so these turn up whether or not anyone recommends them.
|
|
270
|
+
*/
|
|
271
|
+
'tsx-config-in-a-plain-project': {
|
|
272
|
+
packageJson: {},
|
|
273
|
+
configFile: 'truenas-icons.config.tsx',
|
|
274
|
+
contents: IMPORT_META_CONFIG,
|
|
275
|
+
},
|
|
276
|
+
|
|
277
|
+
'jsx-config-in-a-plain-project': {
|
|
278
|
+
packageJson: {},
|
|
279
|
+
configFile: 'truenas-icons.config.jsx',
|
|
280
|
+
contents: IMPORT_META_CONFIG,
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* **The asymmetry between `.ts` and the other two, which one predicate gets
|
|
285
|
+
* wrong.** Node recognises `.ts` natively and supplies a format whenever the
|
|
286
|
+
* scope declares a `type` at all, so tsx's format override never runs and an
|
|
287
|
+
* explicit `"type": "commonjs"` still has the file read as ESM — correctly, with
|
|
288
|
+
* `import.meta.dirname` populated. A check for "scope is not module" warns here
|
|
289
|
+
* and every sentence it prints is false of this file.
|
|
290
|
+
*/
|
|
291
|
+
[`${IMPORT_META}-named-ts-under-an-explicit-commonjs-type`]: {
|
|
292
|
+
packageJson: { type: 'commonjs' },
|
|
293
|
+
configFile: 'truenas-icons.config.ts',
|
|
294
|
+
contents: IMPORT_META_CONFIG,
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The same scope with the extension node does *not* recognise, where the answer
|
|
299
|
+
* is the opposite: `getPackageType` decides, `"type": "commonjs"` is taken
|
|
300
|
+
* literally, and the file really is transformed. So this one must warn while the
|
|
301
|
+
* case above must not.
|
|
302
|
+
*/
|
|
303
|
+
'tsx-config-under-an-explicit-commonjs-type': {
|
|
304
|
+
packageJson: { type: 'commonjs' },
|
|
305
|
+
configFile: 'truenas-icons.config.tsx',
|
|
306
|
+
contents: IMPORT_META_CONFIG,
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
// The CommonJS half of that pair: an extension that rules ESM out, so the
|
|
310
|
+
// CommonJS loader is the one that reads it.
|
|
311
|
+
'commonjs-config-named-cts': {
|
|
312
|
+
packageJson: { type: 'module' },
|
|
313
|
+
configFile: 'truenas-icons.config.cts',
|
|
314
|
+
contents: "module.exports = { srcDirs: ['./src/cts'] };",
|
|
315
|
+
},
|
|
316
|
+
|
|
317
|
+
// `--config` takes any path, so a JSON config is a shape that turns up. Its
|
|
318
|
+
// extension settles its kind, so `require` reads it first and node's ESM rule
|
|
319
|
+
// about an import attribute never comes up.
|
|
320
|
+
'json-config': {
|
|
321
|
+
packageJson: {},
|
|
322
|
+
configFile: 'icons.config.json',
|
|
323
|
+
contents: JSON.stringify({ srcDirs: ['./src/json'], outputDir: './dist/json' }),
|
|
324
|
+
},
|
|
325
|
+
|
|
326
|
+
'malformed-config': {
|
|
327
|
+
packageJson: {},
|
|
328
|
+
configFile: 'truenas-icons.config.js',
|
|
329
|
+
contents: "module.exports = { srcDirs: ['./unclosed",
|
|
330
|
+
},
|
|
331
|
+
|
|
332
|
+
'absent-config': {
|
|
333
|
+
packageJson: {},
|
|
334
|
+
configFile: 'truenas-icons.config.js',
|
|
335
|
+
contents: '',
|
|
336
|
+
write: false,
|
|
337
|
+
},
|
|
338
|
+
} satisfies Record<string, CaseDefinition>;
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Runs inside the spawned process. Written out as a `.mjs` so it is ESM whatever
|
|
342
|
+
* directory it lands in, and it reports through a file rather than stdout so that
|
|
343
|
+
* nothing tsx happens to print can be mistaken for a result.
|
|
344
|
+
*/
|
|
345
|
+
const DRIVER = `
|
|
346
|
+
import fs from 'fs';
|
|
347
|
+
import path from 'path';
|
|
348
|
+
import { pathToFileURL } from 'url';
|
|
349
|
+
|
|
350
|
+
const root = process.argv[2];
|
|
351
|
+
const cases = JSON.parse(fs.readFileSync(path.join(root, 'cases.json'), 'utf8'));
|
|
352
|
+
const published = path.join(root, 'published', 'lib');
|
|
353
|
+
const loaderPath = path.join(published, 'load-config.ts');
|
|
354
|
+
const { loadConfig } = await import(pathToFileURL(loaderPath).href);
|
|
355
|
+
|
|
356
|
+
// Whether the loader is in ESM scope at all -- read from a file beside it, so the
|
|
357
|
+
// answer is the one its own directory and manifest produce.
|
|
358
|
+
const { requireInScope } = await import(
|
|
359
|
+
pathToFileURL(path.join(published, 'module-scope.ts')).href
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
const realWarn = console.warn;
|
|
363
|
+
const results = {};
|
|
364
|
+
|
|
365
|
+
for (const [name, configFile] of cases) {
|
|
366
|
+
const warnings = [];
|
|
367
|
+
console.warn = (...args) => warnings.push(args.map(String).join(' '));
|
|
368
|
+
globalThis.__timesEvaluated = 0;
|
|
369
|
+
|
|
370
|
+
let config = null;
|
|
371
|
+
let threw = null;
|
|
372
|
+
try {
|
|
373
|
+
config = await loadConfig(configFile, path.join(root, 'projects', name));
|
|
374
|
+
} catch (error) {
|
|
375
|
+
threw = String(error && error.stack ? error.stack : error);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
console.warn = realWarn;
|
|
379
|
+
// A namespace object does not survive JSON.stringify as a plain object, so
|
|
380
|
+
// spread it: the caller only ever reads configuration keys off it.
|
|
381
|
+
results[name] = {
|
|
382
|
+
config: config ? { ...config } : config,
|
|
383
|
+
warnings,
|
|
384
|
+
threw,
|
|
385
|
+
evaluations: globalThis.__timesEvaluated,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
fs.writeFileSync(
|
|
390
|
+
path.join(root, 'results.json'),
|
|
391
|
+
JSON.stringify({ requireInScope, cases: results })
|
|
392
|
+
);
|
|
393
|
+
`;
|
|
394
|
+
|
|
395
|
+
let workspace = '';
|
|
396
|
+
let requireInScope: string;
|
|
397
|
+
let results: Record<string, CaseResult & { threw: string | null }>;
|
|
398
|
+
|
|
399
|
+
beforeAll(() => {
|
|
400
|
+
workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'truenas-icons-load-config-'));
|
|
401
|
+
|
|
402
|
+
// The publish step, for the files under test: into a package whose manifest says
|
|
403
|
+
// `"type": "module"`, in the same relative layout the published one has. The
|
|
404
|
+
// interface is copied because the loader imports it by a relative path — only as
|
|
405
|
+
// a type today, which the transform erases, but a value import later should not
|
|
406
|
+
// turn into a puzzling resolution failure here.
|
|
407
|
+
const published = path.join(workspace, 'published');
|
|
408
|
+
fs.mkdirSync(path.join(published, 'lib'), { recursive: true });
|
|
409
|
+
fs.writeFileSync(
|
|
410
|
+
path.join(published, 'package.json'),
|
|
411
|
+
JSON.stringify({ name: 'published-like', version: '0.0.0', type: 'module' })
|
|
412
|
+
);
|
|
413
|
+
fs.copyFileSync(
|
|
414
|
+
path.join(__dirname, '..', 'sprite-config-interface.ts'),
|
|
415
|
+
path.join(published, 'sprite-config-interface.ts')
|
|
416
|
+
);
|
|
417
|
+
fs.copyFileSync(path.join(__dirname, 'load-config.ts'), path.join(published, 'lib', 'load-config.ts'));
|
|
418
|
+
|
|
419
|
+
// Beside the loader, sharing its directory and so its module kind: what this
|
|
420
|
+
// reports is what the loader itself gets.
|
|
421
|
+
fs.writeFileSync(
|
|
422
|
+
path.join(published, 'lib', 'module-scope.ts'),
|
|
423
|
+
'export const requireInScope = typeof require;\n'
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
// One throwaway consumer project per case: the manifest always, and the config
|
|
427
|
+
// file unless the case is the one about it being missing — which the definition
|
|
428
|
+
// says, rather than a name compared here, so that renaming a case cannot quietly
|
|
429
|
+
// turn "absent" into "present and empty". Both load as `{}`.
|
|
430
|
+
for (const [name, definition] of Object.entries(CASES as Record<string, CaseDefinition>)) {
|
|
431
|
+
const projectRoot = path.join(workspace, 'projects', name);
|
|
432
|
+
fs.mkdirSync(projectRoot, { recursive: true });
|
|
433
|
+
fs.writeFileSync(
|
|
434
|
+
path.join(projectRoot, 'package.json'),
|
|
435
|
+
JSON.stringify({ name, version: '1.0.0', ...definition.packageJson })
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
// Before the config, since a case may place one inside a directory that an
|
|
439
|
+
// extra file is what creates.
|
|
440
|
+
for (const [relativePath, contents] of Object.entries(definition.extraFiles ?? {})) {
|
|
441
|
+
const target = path.join(projectRoot, relativePath);
|
|
442
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
443
|
+
fs.writeFileSync(target, contents);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (definition.write !== false) {
|
|
447
|
+
const configTarget = path.join(projectRoot, definition.configFile);
|
|
448
|
+
// `configFile` may name a subdirectory — that is what the walk cases vary.
|
|
449
|
+
fs.mkdirSync(path.dirname(configTarget), { recursive: true });
|
|
450
|
+
fs.writeFileSync(configTarget, definition.contents);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
fs.writeFileSync(
|
|
455
|
+
path.join(workspace, 'cases.json'),
|
|
456
|
+
JSON.stringify(Object.entries(CASES).map(([name, { configFile }]) => [name, configFile]))
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
const driverPath = path.join(workspace, 'driver.mjs');
|
|
460
|
+
fs.writeFileSync(driverPath, DRIVER);
|
|
461
|
+
|
|
462
|
+
const run = spawnSync(process.execPath, [tsxCli, driverPath, workspace], {
|
|
463
|
+
cwd: repoRoot,
|
|
464
|
+
encoding: 'utf8',
|
|
465
|
+
// Jest's own timeout cannot interrupt a synchronous spawn, so a wedged tsx
|
|
466
|
+
// would hold the worker open rather than failing. This is the one that fires.
|
|
467
|
+
timeout: 120_000,
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
const resultsPath = path.join(workspace, 'results.json');
|
|
471
|
+
|
|
472
|
+
// Without this the suite's failure for a driver that never ran would be a
|
|
473
|
+
// confusing one about a missing file, with tsx's own diagnosis thrown away.
|
|
474
|
+
if (!fs.existsSync(resultsPath)) {
|
|
475
|
+
throw new Error(
|
|
476
|
+
// `run.error` and not only the exit status: a spawn killed by the timeout
|
|
477
|
+
// above reports a null status and says why in nothing else.
|
|
478
|
+
`the config loader driver produced no results (exit ${String(run.status)}, ` +
|
|
479
|
+
`error ${String(run.error)})\nstdout: ${run.stdout}\nstderr: ${run.stderr}`
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
({ requireInScope, cases: results } = JSON.parse(fs.readFileSync(resultsPath, 'utf8')));
|
|
484
|
+
}, 180_000);
|
|
485
|
+
|
|
486
|
+
afterAll(() => {
|
|
487
|
+
// Guarded because `afterAll` runs even when `beforeAll` threw, and removing a
|
|
488
|
+
// path that was never made would replace the real failure with this one.
|
|
489
|
+
if (workspace !== '') {
|
|
490
|
+
fs.rmSync(workspace, { recursive: true, force: true });
|
|
491
|
+
}
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
function resultFor(name: keyof typeof CASES): CaseResult {
|
|
495
|
+
const result = results[name];
|
|
496
|
+
|
|
497
|
+
expect(result.threw).toBeNull();
|
|
498
|
+
|
|
499
|
+
return result;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
describe('the setup itself', () => {
|
|
503
|
+
/** The ways this file could pass while having tested nothing. */
|
|
504
|
+
it('runs the loader through tsx, the way the shipped CLI is run', () => {
|
|
505
|
+
expect(fs.existsSync(tsxCli)).toBe(true);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* The premise every case below rests on. `require` being absent is what the old
|
|
510
|
+
* fallback died of, so in CommonJS scope the loader this file is checking and the
|
|
511
|
+
* one it replaced behave the same and nothing here discriminates them. If this
|
|
512
|
+
* goes green while reporting `function`, the suite is measuring the wrong thing.
|
|
513
|
+
*/
|
|
514
|
+
it('evaluates the loader in ESM scope, where require does not exist', () => {
|
|
515
|
+
expect(requireInScope).toBe('undefined');
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('produced a result for every case', () => {
|
|
519
|
+
expect(Object.keys(results).sort()).toEqual(Object.keys(CASES).sort());
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
describe('a CommonJS config', () => {
|
|
524
|
+
it('loads from a plain .js file in a project that does not declare a type', () => {
|
|
525
|
+
const { config, warnings } = resultFor('commonjs-config-in-a-plain-project');
|
|
526
|
+
|
|
527
|
+
// `require` inside the config has to work too, not only the `module.exports`
|
|
528
|
+
// around it.
|
|
529
|
+
expect(config.srcDirs).toEqual([path.join('.', 'src', 'app')]);
|
|
530
|
+
expect(config.outputDir).toBe('./public/icons');
|
|
531
|
+
expect(warnings).toEqual([]);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* The regression this file exists for. `import()` cannot read `module.exports`
|
|
536
|
+
* out of a file its own package declares to be ESM, so the old code fell
|
|
537
|
+
* through to a bare `require` — which is not defined in that scope, measured as
|
|
538
|
+
* `typeof require === 'undefined'` under tsx — and the consumer's whole
|
|
539
|
+
* configuration was replaced by `{}` behind a one-line warning.
|
|
540
|
+
*/
|
|
541
|
+
it('loads from a .js file even in a project that declares "type": "module"', () => {
|
|
542
|
+
const { config, warnings } = resultFor('commonjs-config-in-an-esm-project');
|
|
543
|
+
|
|
544
|
+
expect(config.srcDirs).toEqual(['./src/mixed']);
|
|
545
|
+
expect(config.outputDir).toBe('./dist/mixed');
|
|
546
|
+
expect(warnings).toEqual([]);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* **The one shape that is read twice, asserted so that changing it is a
|
|
551
|
+
* decision.** Its package says ESM, so the ESM loader goes first and gets as far
|
|
552
|
+
* as `module.exports` before failing — at runtime, with the statements above that
|
|
553
|
+
* line already run — and `require` then runs the whole file again. Nothing can
|
|
554
|
+
* tell which kind this file is without running it, and running it twice beats
|
|
555
|
+
* dropping it, which is what used to happen.
|
|
556
|
+
*
|
|
557
|
+
* Every other shape in this matrix that loads is read exactly once; see the
|
|
558
|
+
* `import.meta` cases, which is where getting the loader wrong stops being a
|
|
559
|
+
* doubled side effect and becomes a wrong value.
|
|
560
|
+
*/
|
|
561
|
+
it('is the one shape that loads and is still read twice', () => {
|
|
562
|
+
expect(resultFor('commonjs-config-in-an-esm-project').evaluations).toBe(2);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('loads from a .cjs file in a project that declares "type": "module"', () => {
|
|
566
|
+
const { config, warnings } = resultFor('commonjs-config-named-cjs');
|
|
567
|
+
|
|
568
|
+
expect(config.srcDirs).toEqual(['./src/cjs']);
|
|
569
|
+
expect(config.customIconsDir).toBe('./brand');
|
|
570
|
+
expect(warnings).toEqual([]);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
// `.cts` rules ESM out the same way `.cjs` does, and is the pair to the `.mts`
|
|
574
|
+
// case below: both are decided by their extension and neither consults a manifest.
|
|
575
|
+
it('loads from a .cts file in a project that declares "type": "module"', () => {
|
|
576
|
+
const { config, warnings } = resultFor('commonjs-config-named-cts');
|
|
577
|
+
|
|
578
|
+
expect(config.srcDirs).toEqual(['./src/cts']);
|
|
579
|
+
expect(warnings).toEqual([]);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('loads one that assigns onto exports rather than replacing module.exports', () => {
|
|
583
|
+
const { config } = resultFor('commonjs-config-assigning-onto-exports');
|
|
584
|
+
|
|
585
|
+
expect(config.srcDirs).toEqual(['./src/named']);
|
|
586
|
+
expect(config.outputDir).toBe('./dist/named');
|
|
587
|
+
});
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
describe('an ESM config', () => {
|
|
591
|
+
it('still loads from a .js file in a project that declares "type": "module"', () => {
|
|
592
|
+
const { config, warnings } = resultFor('esm-config');
|
|
593
|
+
|
|
594
|
+
expect(config.srcDirs).toEqual(['./src/esm']);
|
|
595
|
+
expect(config.spriteUrlPath).toBe('assets/tn-icons');
|
|
596
|
+
expect(warnings).toEqual([]);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it('still loads from an .mjs file in a project that does not declare a type', () => {
|
|
600
|
+
const { config } = resultFor('esm-config-named-mjs');
|
|
601
|
+
|
|
602
|
+
expect(config.srcDirs).toEqual(['./src/mjs']);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it('still reads named exports when there is no default export', () => {
|
|
606
|
+
const { config } = resultFor('esm-config-without-a-default-export');
|
|
607
|
+
|
|
608
|
+
expect(config.srcDirs).toEqual(['./src/named-esm']);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* `require` cannot load a module that awaits at its top level, under tsx or under
|
|
613
|
+
* node, so one evaluation here says the ESM loader was tried first rather than
|
|
614
|
+
* reached after a failure.
|
|
615
|
+
*/
|
|
616
|
+
it('loads one that awaits at the top level, which only import() can take', () => {
|
|
617
|
+
const { config, warnings, evaluations } = resultFor('esm-config-using-top-level-await');
|
|
618
|
+
|
|
619
|
+
expect(config.srcDirs).toEqual(['./src/awaited']);
|
|
620
|
+
expect(warnings).toEqual([]);
|
|
621
|
+
expect(evaluations).toBe(1);
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Where reading an ESM config with the CommonJS loader is not a doubled side
|
|
626
|
+
* effect but a wrong answer: under tsx's transform `import.meta.dirname` is
|
|
627
|
+
* `undefined`, and a config deriving a path from it loads with nonsense in it and
|
|
628
|
+
* no warning. So the assertion is on the value, and the count rules out it having
|
|
629
|
+
* been reached by falling back after a failure.
|
|
630
|
+
*
|
|
631
|
+
* Four projects, because what decides the kind differs in each and a rule can be
|
|
632
|
+
* right about one and wrong about the others: `"type": "module"` says so; a
|
|
633
|
+
* typeless project says nothing and is still ESM, since node reads a typeless
|
|
634
|
+
* `.js` as CommonJS and reparses it as ESM when that fails; `.mts` says so in its
|
|
635
|
+
* own extension; and a `.ts` under `"type": "module"` is ESM by its manifest,
|
|
636
|
+
* which is the one `.ts` shape that works — the typeless one cannot, and has its
|
|
637
|
+
* own describe below.
|
|
638
|
+
*/
|
|
639
|
+
it.each([
|
|
640
|
+
[`${IMPORT_META}-in-an-esm-project`],
|
|
641
|
+
[`${IMPORT_META}-in-a-plain-project`],
|
|
642
|
+
[`${IMPORT_META}-named-mts`],
|
|
643
|
+
[`${IMPORT_META}-named-ts-in-an-esm-project`],
|
|
644
|
+
])('loads %s with import.meta actually populated', (name) => {
|
|
645
|
+
const { config, warnings, evaluations } = resultFor(name as keyof typeof CASES);
|
|
646
|
+
|
|
647
|
+
// The config reports its own directory's name, which is the case's own key.
|
|
648
|
+
expect(config.srcDirs).toEqual([name]);
|
|
649
|
+
expect(warnings).toEqual([]);
|
|
650
|
+
expect(evaluations).toBe(1);
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* #369. A `.ts`, `.tsx` or `.jsx` config takes its module kind from its package
|
|
656
|
+
* scope rather than from its own extension, and a scope that does not make it ESM
|
|
657
|
+
* has tsx read it as CommonJS with no reparse. The value cannot be fixed from here
|
|
658
|
+
* — nothing makes tsx reparse the file — so the criterion is that the loader
|
|
659
|
+
* reports the condition rather than loading quietly, and stays quiet when there is
|
|
660
|
+
* nothing to report. Both halves are below, and the last case states them as one
|
|
661
|
+
* invariant.
|
|
662
|
+
*/
|
|
663
|
+
describe('what decides a TypeScript config module kind', () => {
|
|
664
|
+
it('warns and names .mts rather than loading a wrong import.meta silently', () => {
|
|
665
|
+
const { config, warnings } = resultFor(`${IMPORT_META}-named-ts-in-a-plain-project`);
|
|
666
|
+
|
|
667
|
+
// Still the wrong value. What changed is that it is announced: this is the
|
|
668
|
+
// measurement in the report, now with something said about it.
|
|
669
|
+
expect(config.srcDirs).toEqual(['undefined']);
|
|
670
|
+
|
|
671
|
+
const said = warnings.join('\n');
|
|
672
|
+
expect(said).toContain('truenas-icons.config.ts');
|
|
673
|
+
expect(said).toContain('will be read as CommonJS');
|
|
674
|
+
expect(said).toContain('import.meta.dirname');
|
|
675
|
+
expect(said).toContain('.mts');
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it('says nothing when the project declares "type": "module"', () => {
|
|
679
|
+
expect(resultFor(`${IMPORT_META}-named-ts-in-an-esm-project`).warnings).toEqual([]);
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* The accepted false positive, asserted so that narrowing it later is a decision
|
|
684
|
+
* made against a failing test rather than a quiet change. Which kind a `.ts` file
|
|
685
|
+
* means to be is not knowable without running it, so a correct CommonJS config
|
|
686
|
+
* gets the warning too — and still loads.
|
|
687
|
+
*/
|
|
688
|
+
it('still loads a .ts config that really is CommonJS, warning about it anyway', () => {
|
|
689
|
+
const { config, warnings } = resultFor('commonjs-config-named-ts');
|
|
690
|
+
|
|
691
|
+
expect(config.srcDirs).toEqual(['./src/ts']);
|
|
692
|
+
expect(warnings.join('\n')).toContain('will be read as CommonJS');
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
// The pair that needs no manifest to be right, and so draws no warning. `.cts`
|
|
696
|
+
// and `.mts` are covered for loading above; this is about staying quiet.
|
|
697
|
+
it.each([[`${IMPORT_META}-named-mts`], ['commonjs-config-named-cts']])(
|
|
698
|
+
'leaves %s alone, since its extension settles its kind',
|
|
699
|
+
(name) => {
|
|
700
|
+
expect(resultFor(name as keyof typeof CASES).warnings).toEqual([]);
|
|
701
|
+
}
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* **Where the walk stops, which is the half that cannot be checked by varying the
|
|
706
|
+
* project root's own manifest.** Both cases sit under a project declaring
|
|
707
|
+
* `"type": "module"` and are still read as CommonJS, so each one fails if the
|
|
708
|
+
* scope is computed by looking for the nearest manifest that *has* a `type`, or
|
|
709
|
+
* by walking past `node_modules`. Read off tsx's own `findPackageJson`; the
|
|
710
|
+
* asserted `['undefined']` is that resolver's answer, measured here rather than
|
|
711
|
+
* predicted.
|
|
712
|
+
*/
|
|
713
|
+
it.each([
|
|
714
|
+
['ts-config-under-a-typeless-nested-manifest'],
|
|
715
|
+
['ts-config-inside-node-modules'],
|
|
716
|
+
] as const)('warns for %s, whose scope is not the project root', (name) => {
|
|
717
|
+
const { config, warnings } = resultFor(name);
|
|
718
|
+
|
|
719
|
+
expect(config.srcDirs).toEqual(['undefined']);
|
|
720
|
+
expect(warnings.join('\n')).toContain('will be read as CommonJS');
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* **The other two extensions in `.ts`'s position**, which a literal `'.ts'` check
|
|
725
|
+
* missed: tsx settles only `.mts` and `.cts` by extension, so everything else it
|
|
726
|
+
* recognises is decided by package scope. Each asserts the mis-read value as well
|
|
727
|
+
* as the warning, so the case cannot go green against a file that loaded as ESM.
|
|
728
|
+
*/
|
|
729
|
+
it.each([['tsx-config-in-a-plain-project'], ['jsx-config-in-a-plain-project']] as const)(
|
|
730
|
+
'warns for %s, which package scope decides the same way',
|
|
731
|
+
(name) => {
|
|
732
|
+
const { config, warnings } = resultFor(name);
|
|
733
|
+
|
|
734
|
+
expect(config.srcDirs).toEqual(['undefined']);
|
|
735
|
+
expect(warnings.join('\n')).toContain('will be read as CommonJS');
|
|
736
|
+
}
|
|
737
|
+
);
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* **What an explicit `"type": "commonjs"` does, which is not the same for `.ts`
|
|
741
|
+
* as for the other two.** Node recognises `.ts` natively and supplies a format
|
|
742
|
+
* for any declared `type`, so tsx never transforms it and the config is read as
|
|
743
|
+
* ESM; node does not recognise `.tsx`, so there `getPackageType` takes
|
|
744
|
+
* `commonjs` literally and the transform runs. A single "scope is not module"
|
|
745
|
+
* predicate passes the second of these and fails the first — which is why both
|
|
746
|
+
* are here, asserting opposite outcomes against one shared manifest.
|
|
747
|
+
*/
|
|
748
|
+
/**
|
|
749
|
+
* **This one is decided by the node running the suite, so it asks that node.**
|
|
750
|
+
* `.ts` escapes an explicit `"type": "commonjs"` only where node strips
|
|
751
|
+
* TypeScript itself; with stripping off — every node before 22.18, and the
|
|
752
|
+
* `--no-experimental-strip-types` flag — it is transformed like `.tsx`. Jest
|
|
753
|
+
* spawns tsx with `process.execPath`, so the flag read here is the one the child
|
|
754
|
+
* ran under. Asserting either outcome unconditionally would be asserting CI's
|
|
755
|
+
* node rather than the loader.
|
|
756
|
+
*/
|
|
757
|
+
it('matches the node it runs on for a .ts config under "type": "commonjs"', () => {
|
|
758
|
+
const name = `${IMPORT_META}-named-ts-under-an-explicit-commonjs-type`;
|
|
759
|
+
const { config, warnings } = resultFor(name);
|
|
760
|
+
|
|
761
|
+
if (nodeStripsTypeScript) {
|
|
762
|
+
// Populated, so the config reports its own directory rather than 'undefined'.
|
|
763
|
+
expect(config.srcDirs).toEqual([name]);
|
|
764
|
+
expect(warnings).toEqual([]);
|
|
765
|
+
} else {
|
|
766
|
+
expect(config.srcDirs).toEqual(['undefined']);
|
|
767
|
+
expect(warnings.join('\n')).toContain('will be read as CommonJS');
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it('warns for a .tsx config under the same "type": "commonjs"', () => {
|
|
772
|
+
const { config, warnings } = resultFor('tsx-config-under-an-explicit-commonjs-type');
|
|
773
|
+
|
|
774
|
+
expect(config.srcDirs).toEqual(['undefined']);
|
|
775
|
+
expect(warnings.join('\n')).toContain('will be read as CommonJS');
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* **The invariant the whole warning exists to hold, stated once over every case
|
|
780
|
+
* whose config reports its own `import.meta`: it is said exactly when the value
|
|
781
|
+
* is wrong.** Both directions can fail — a silent mis-read, which is #369, and a
|
|
782
|
+
* warning on a config that loaded correctly, which is noise that teaches a
|
|
783
|
+
* consumer to ignore it.
|
|
784
|
+
*
|
|
785
|
+
* Worth having alongside the cases above because it needs no table: it holds on
|
|
786
|
+
* any node and any tsx, and it is what a new case is checked against for free.
|
|
787
|
+
* Every defect three review rounds found here — a walk that stopped in the wrong
|
|
788
|
+
* place, an extension missed, a predicate that was right for one extension and
|
|
789
|
+
* wrong for another — breaks it.
|
|
790
|
+
*
|
|
791
|
+
* The set is derived from the shared config rather than listed, so a case added
|
|
792
|
+
* with `contents: IMPORT_META_CONFIG` joins it without anyone remembering to.
|
|
793
|
+
*/
|
|
794
|
+
it.each(
|
|
795
|
+
Object.entries(CASES)
|
|
796
|
+
.filter(([, definition]) => definition.contents === IMPORT_META_CONFIG)
|
|
797
|
+
.map(([name]) => [name])
|
|
798
|
+
)('says something for %s exactly when import.meta was not populated', (name) => {
|
|
799
|
+
const { config, warnings } = resultFor(name as keyof typeof CASES);
|
|
800
|
+
|
|
801
|
+
// The config reports `path.basename(String(import.meta.dirname))`, so the
|
|
802
|
+
// literal string 'undefined' is what a mis-read looks like from out here.
|
|
803
|
+
const misread = (config.srcDirs as string[])[0] === 'undefined';
|
|
804
|
+
const warned = warnings.join('\n').includes('will be read as CommonJS');
|
|
805
|
+
|
|
806
|
+
expect(warned).toBe(misread);
|
|
807
|
+
});
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
describe('a JSON config', () => {
|
|
811
|
+
it('loads when --config names one', () => {
|
|
812
|
+
const { config, warnings } = resultFor('json-config');
|
|
813
|
+
|
|
814
|
+
expect(config.srcDirs).toEqual(['./src/json']);
|
|
815
|
+
expect(config.outputDir).toBe('./dist/json');
|
|
816
|
+
expect(warnings).toEqual([]);
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
describe('a config that cannot be loaded', () => {
|
|
821
|
+
it('falls back to {} silently when the file is absent', () => {
|
|
822
|
+
const { config, warnings } = resultFor('absent-config');
|
|
823
|
+
|
|
824
|
+
// Most consumers pass flags and write no config at all, so this stays quiet
|
|
825
|
+
// rather than warning about a file nobody asked for.
|
|
826
|
+
expect(config).toEqual({});
|
|
827
|
+
expect(warnings).toEqual([]);
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it('falls back to {} and says what each loader objected to', () => {
|
|
831
|
+
const { config, warnings } = resultFor('malformed-config');
|
|
832
|
+
|
|
833
|
+
expect(config).toEqual({});
|
|
834
|
+
expect(warnings[0]).toContain('Could not load config file');
|
|
835
|
+
|
|
836
|
+
// The old message named no cause at all: the only error it ever had in hand
|
|
837
|
+
// was its own `require is not defined`, and it did not print even that.
|
|
838
|
+
const reasons = warnings.slice(1).join('\n');
|
|
839
|
+
expect(reasons).toContain('import():');
|
|
840
|
+
expect(reasons).toContain('require():');
|
|
841
|
+
expect(reasons).not.toContain('require is not defined');
|
|
842
|
+
|
|
843
|
+
// What makes the reason useful rather than merely present: it points at the
|
|
844
|
+
// file that would not parse. Asserted as the filename rather than as the
|
|
845
|
+
// transform's own wording, which belongs to whatever tsx ships.
|
|
846
|
+
expect(reasons).toContain(CASES['malformed-config'].configFile);
|
|
847
|
+
});
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* The criterion is that a CommonJS config's values reach the generated sprite,
|
|
852
|
+
* not just that they parse. `generateSprite` reads every path it scans and writes
|
|
853
|
+
* out of `resolveConfig`'s result, so composing the two covers the gap where a
|
|
854
|
+
* value could load and still never arrive.
|
|
855
|
+
*/
|
|
856
|
+
describe('what reaches the sprite', () => {
|
|
857
|
+
it('carries a CommonJS config through to the resolved sprite configuration', () => {
|
|
858
|
+
const { config } = resultFor('commonjs-config-in-an-esm-project');
|
|
859
|
+
const projectRoot = '/consumer';
|
|
860
|
+
|
|
861
|
+
expect(resolveConfig({ ...config, projectRoot })).toEqual({
|
|
862
|
+
projectRoot,
|
|
863
|
+
srcDirs: ['./src/mixed'],
|
|
864
|
+
outputDir: './dist/mixed',
|
|
865
|
+
spriteUrlPath: 'dist/mixed',
|
|
866
|
+
customIconsDir: null,
|
|
867
|
+
});
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it('leaves the defaults in place when there was no config to load', () => {
|
|
871
|
+
const { config } = resultFor('absent-config');
|
|
872
|
+
const resolved = resolveConfig({ ...config, projectRoot: '/consumer' });
|
|
873
|
+
|
|
874
|
+
expect(resolved.srcDirs).toEqual(['./src/lib', './src/app']);
|
|
875
|
+
expect(resolved.customIconsDir).toBeNull();
|
|
876
|
+
});
|
|
877
|
+
});
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { createRequire } from 'module';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { pathToFileURL } from 'url';
|
|
5
|
+
import type { SpriteGeneratorConfig } from '../sprite-config-interface';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What an unknown thrown value says for itself, without assuming it is an `Error`,
|
|
9
|
+
* indented to read as one entry under the warning above it.
|
|
10
|
+
*
|
|
11
|
+
* The whole message, not its first line: which line carries the cause depends on
|
|
12
|
+
* who threw. Node leads with it (`Cannot find module ...`), while the transform
|
|
13
|
+
* tsx runs leads with `Transform failed with 1 error:` and puts the file, the
|
|
14
|
+
* line and the syntax error underneath — so truncating loses exactly the detail
|
|
15
|
+
* this is here to report.
|
|
16
|
+
*/
|
|
17
|
+
function reasonFor(error: unknown): string {
|
|
18
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
19
|
+
|
|
20
|
+
return message.trim().split('\n').join('\n ');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Extensions that rule an ES module out whatever any manifest says, so the CommonJS
|
|
25
|
+
* loader can be tried first for them without guessing. Everything else — `.mjs`,
|
|
26
|
+
* `.mts`, a `.js` whose kind depends on its package, an extension nobody
|
|
27
|
+
* anticipated — goes to `import()` first, which is the half of the ordering that
|
|
28
|
+
* carries the weight; see `loadConfig`.
|
|
29
|
+
*
|
|
30
|
+
* **This half spares a futile first attempt rather than changing an outcome.**
|
|
31
|
+
* Measured: `import()` reads a `.cjs` or `.cts` correctly anyway, and fails on JSON
|
|
32
|
+
* before executing anything, so putting those three second costs nothing but a
|
|
33
|
+
* caught error. It is here because a loader that is asked for the kind the
|
|
34
|
+
* extension already names is the one that should answer, not because an outcome
|
|
35
|
+
* depends on it — do not read the measured asymmetry below as applying to it.
|
|
36
|
+
*
|
|
37
|
+
* `.ts` is deliberately not here and is not safe either: tsx reads one whose scope
|
|
38
|
+
* is not ESM as CommonJS whatever its syntax, with no ESM reparse, so `--config`
|
|
39
|
+
* pointing at it gets an undefined `import.meta.dirname`. Neither order changes
|
|
40
|
+
* that, which is why no ordering entry fixes it —
|
|
41
|
+
* `warnAboutTypeScriptConfigReadAsCommonJS` reports it instead, for `.ts` and for
|
|
42
|
+
* the two other extensions in the same position, and the reasoning for reporting
|
|
43
|
+
* rather than refusing lives there — see `willBeReadAsCommonJS` for the set and for
|
|
44
|
+
* what decides each member of it.
|
|
45
|
+
*/
|
|
46
|
+
const NEVER_ESM = ['.cjs', '.cts', '.json'];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether tsx will read `configPath` as CommonJS — meaning its `import.meta`
|
|
50
|
+
* accessors come back undefined — for an extension whose kind is not settled by
|
|
51
|
+
* the extension itself.
|
|
52
|
+
*
|
|
53
|
+
* **The three extensions do not answer to the same condition, and one of them
|
|
54
|
+
* depends on the node running the CLI.** Measured under tsx 4.19.4 on node 26.7.0
|
|
55
|
+
* with native TypeScript stripping both on and off — the latter via
|
|
56
|
+
* `--no-experimental-strip-types`, which is also how every node before 22.18
|
|
57
|
+
* behaves — over scopes declaring nothing, `commonjs`, `module`, and an invalid
|
|
58
|
+
* string:
|
|
59
|
+
*
|
|
60
|
+
* | extension | typeless | `commonjs` | `module` | invalid string |
|
|
61
|
+
* |---|---|---|---|---|
|
|
62
|
+
* | `.ts`, node strips TS | **CommonJS** | ESM | ESM | ESM |
|
|
63
|
+
* | `.ts`, node does not | **CommonJS** | **CommonJS** | ESM | ESM |
|
|
64
|
+
* | `.tsx`, `.jsx` | **CommonJS** | **CommonJS** | ESM | ESM |
|
|
65
|
+
* | `.mts`, `.cts` | ESM | ESM | ESM | ESM |
|
|
66
|
+
*
|
|
67
|
+
* `.ts` is the only row with two states, and `process.features.typescript` tells
|
|
68
|
+
* them apart. Where node recognises `.ts` natively it supplies a format for any
|
|
69
|
+
* declared `type`, so tsx's own `!resolved.format &&
|
|
70
|
+
* (resolved.format = getFormatFromFileUrl(url))` never runs and its `load` hook
|
|
71
|
+
* returns `{ format: 'module' }` for a TS extension it did not transform. Where it
|
|
72
|
+
* does not, `.ts` reaches tsx's `getPackageType` exactly as `.tsx` does and a
|
|
73
|
+
* literal `"type": "commonjs"` transforms. Node never recognises `.tsx` or `.jsx`,
|
|
74
|
+
* so those have one row.
|
|
75
|
+
*
|
|
76
|
+
* **The second row is a real environment, and CI never exercises it.** The
|
|
77
|
+
* published manifest declares no `engines`, `cli.cjs` runs `tsx` on whatever node
|
|
78
|
+
* the consumer has, and tsx supports node 18 — while CI pins 24.15.0, where native
|
|
79
|
+
* stripping is on. So this is detected rather than assumed: reading the row off the
|
|
80
|
+
* repo's own development range would be correct about CI and wrong about the
|
|
81
|
+
* consumer the bin actually runs for.
|
|
82
|
+
*
|
|
83
|
+
* `.mts` and `.cts` are absent because `getFormatFromExtension` answers for them
|
|
84
|
+
* outright, in every scope and on both node behaviours. The three covered here are
|
|
85
|
+
* tsx's pattern `/\.([cm]?ts|[tj]sx)($|\?)/` minus those two — tsx's own internal
|
|
86
|
+
* `['.ts', '.tsx', '.jsx']`.
|
|
87
|
+
*
|
|
88
|
+
* An invalid `type` loads as ESM only when it is a *string*. A non-string
|
|
89
|
+
* `"type": 123` throws out of the loader instead, and `packageScopeType` reports it
|
|
90
|
+
* as `undefined`, so a warning is printed just before the real failure is reported.
|
|
91
|
+
* Not worth a branch, and not an ESM load either.
|
|
92
|
+
*
|
|
93
|
+
* **Case-sensitive, because tsx's pattern is.** A `--config` naming `.TS` draws no
|
|
94
|
+
* warning and is right not to, though not by the route it looks like: tsx does not
|
|
95
|
+
* recognise the extension, `import()` fails on it as unknown, and `loadConfig`'s
|
|
96
|
+
* `createRequire` fallback is what loads it — with `import.meta.dirname` populated
|
|
97
|
+
* and `typeof require` still `undefined` inside it, so node's own `require()` of an
|
|
98
|
+
* ES module is doing the work rather than any CommonJS transform. The value that
|
|
99
|
+
* reaches the sprite is correct, on a node new enough for that (22.12); before it
|
|
100
|
+
* both loaders fail and the config falls back to `{}` with both reasons reported.
|
|
101
|
+
*/
|
|
102
|
+
function willBeReadAsCommonJS(configPath: string): boolean {
|
|
103
|
+
const extension = path.extname(configPath);
|
|
104
|
+
|
|
105
|
+
if (extension !== '.ts' && extension !== '.tsx' && extension !== '.jsx') {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const scope = packageScopeType(configPath);
|
|
110
|
+
|
|
111
|
+
// A scope declaring nothing is read as CommonJS for all three, on either node.
|
|
112
|
+
if (scope === undefined) {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Read off `process.features` rather than a version comparison, and widened
|
|
117
|
+
// because `@types/node` only grew the field in 22.x: it is absent before 22.10,
|
|
118
|
+
// `false` when the flag turns stripping off, and a string when it is on.
|
|
119
|
+
const nodeStripsTypeScript = Boolean((process.features as { typescript?: unknown }).typescript);
|
|
120
|
+
|
|
121
|
+
// Past that, only `.ts` on a node that strips TypeScript escapes an explicit
|
|
122
|
+
// `"type": "commonjs"`, because that is what supplies a format and keeps tsx's
|
|
123
|
+
// own override from running. See the table above.
|
|
124
|
+
return scope === 'commonjs' && !(extension === '.ts' && nodeStripsTypeScript);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The `type` declared by the `package.json` that decides `configPath`'s module
|
|
129
|
+
* kind — its *package scope* — or `undefined` when nothing in that scope declares
|
|
130
|
+
* one.
|
|
131
|
+
*
|
|
132
|
+
* **Two things decide where the walk stops, and both have to be here**, because a
|
|
133
|
+
* walk that answers `"module"` where tsx computes `"commonjs"` suppresses the
|
|
134
|
+
* warning in exactly the silent case this file exists to end:
|
|
135
|
+
*
|
|
136
|
+
* - **The closest manifest decides**, whether or not it carries the field. A
|
|
137
|
+
* manifest further up gets no say, so skipping a typeless one to find a typed
|
|
138
|
+
* one answers a different question than the loader is going to.
|
|
139
|
+
* - **The walk stops at a `node_modules` directory**, and does not look past it.
|
|
140
|
+
* So a config under `node_modules/` is CommonJS however the project above it is
|
|
141
|
+
* declared.
|
|
142
|
+
*
|
|
143
|
+
* Both read off tsx's own resolver, not inferred from node's documentation:
|
|
144
|
+
* `findPackageJson` in `tsx/dist/esm/index.mjs` is
|
|
145
|
+
* `for (; !url.pathname.endsWith('/node_modules/package.json');)` returning the
|
|
146
|
+
* first manifest that parses, and `getPackageType` is that `?.type ?? 'commonjs'`.
|
|
147
|
+
* Node's two loaders agree — `readPackageScope` returns false on `node_modules`,
|
|
148
|
+
* and `getPackageScopeConfig` breaks on `node_modules/package.json`.
|
|
149
|
+
*
|
|
150
|
+
* The path is resolved through `fs.realpathSync` first, because node resolves a
|
|
151
|
+
* symlinked module to its real path before any loader hook sees it: a config
|
|
152
|
+
* symlinked into a project takes its scope from where the file really lives, not
|
|
153
|
+
* from where it is linked.
|
|
154
|
+
*
|
|
155
|
+
* Unreadable or malformed reads as `undefined` rather than throwing. tsx itself
|
|
156
|
+
* throws on a nearest manifest it cannot parse, so that config was not going to
|
|
157
|
+
* load either way — this only decides which message the consumer gets, and the
|
|
158
|
+
* warning is the less confusing of the two.
|
|
159
|
+
*/
|
|
160
|
+
function packageScopeType(configPath: string): string | undefined {
|
|
161
|
+
let directory: string;
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
directory = path.dirname(fs.realpathSync(configPath));
|
|
165
|
+
} catch {
|
|
166
|
+
directory = path.dirname(configPath);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (;;) {
|
|
170
|
+
// Checked before the manifest is read, which is where tsx checks it: the
|
|
171
|
+
// boundary is the candidate path, so a `node_modules` directory's own
|
|
172
|
+
// `package.json` is not consulted either.
|
|
173
|
+
if (path.basename(directory) === 'node_modules') {
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const manifest = path.join(directory, 'package.json');
|
|
178
|
+
|
|
179
|
+
if (fs.existsSync(manifest)) {
|
|
180
|
+
try {
|
|
181
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(manifest, 'utf8'));
|
|
182
|
+
const declared = (parsed as { type?: unknown }).type;
|
|
183
|
+
|
|
184
|
+
return typeof declared === 'string' ? declared : undefined;
|
|
185
|
+
} catch {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const parent = path.dirname(directory);
|
|
191
|
+
|
|
192
|
+
if (parent === directory) {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
directory = parent;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Reports a `--config` naming a file tsx is about to read as CommonJS, which
|
|
202
|
+
* `willBeReadAsCommonJS` decides. Such a config loads, says nothing, and gets
|
|
203
|
+
* `undefined` for `import.meta.dirname` and `import.meta.filename` — so the sprite
|
|
204
|
+
* is generated from a path the consumer never wrote and no output mentions it.
|
|
205
|
+
* That is #369.
|
|
206
|
+
*
|
|
207
|
+
* **It warns rather than refusing, because the mis-read file and a perfectly
|
|
208
|
+
* correct one are the same file to everything that can be inspected.** A `.ts`
|
|
209
|
+
* config written as CommonJS is read exactly right, and refusing every `.ts`
|
|
210
|
+
* would break it; which kind a file *means* to be is not knowable without running
|
|
211
|
+
* it, which is the same reason `loadConfig` does not pick a loader by kind. So the
|
|
212
|
+
* accepted cost is a warning on a config that did not need one, and the message
|
|
213
|
+
* names the condition it fired on so that reads as what it is. The other way round
|
|
214
|
+
* is the silent wrong value this exists to end.
|
|
215
|
+
*
|
|
216
|
+
* **It covers the extension and not what the file imports.** A `.mts` config —
|
|
217
|
+
* correctly silent — importing a `.ts` helper in a typeless scope gets that helper
|
|
218
|
+
* read as CommonJS, with `import.meta.dirname` undefined inside it and nothing
|
|
219
|
+
* said. Naming the config's own extension cannot reach that; only the loader
|
|
220
|
+
* reading each file could, and `--config` is the only path this one is given.
|
|
221
|
+
*
|
|
222
|
+
* **Reading `type` here is not the rule the docblocks above rule out.** That one
|
|
223
|
+
* guesses which kind an unknowable `.js` file *is*, in order to choose a loader,
|
|
224
|
+
* and is wrong because node itself reparses. This computes what tsx *will do* with
|
|
225
|
+
* the file, which for these extensions is settled rather than guessed — there is no
|
|
226
|
+
* reparse to upset the answer, and the inputs that decide it are enumerated in
|
|
227
|
+
* `willBeReadAsCommonJS`, measured rather than reasoned from. **No loader choice
|
|
228
|
+
* turns on it** — both still run, in the same order, and the file loads either way.
|
|
229
|
+
*/
|
|
230
|
+
function warnAboutTypeScriptConfigReadAsCommonJS(configPath: string): void {
|
|
231
|
+
if (!willBeReadAsCommonJS(configPath)) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// "does not make it ESM" rather than naming a manifest condition. The condition
|
|
236
|
+
// differs by extension and there may be no manifest at all, so any sentence
|
|
237
|
+
// specific enough to name one is false for some input that reaches here — which
|
|
238
|
+
// is what the first version of this message got wrong. The scope is described
|
|
239
|
+
// below, the fixes are listed, and neither claim depends on which case fired.
|
|
240
|
+
console.warn(
|
|
241
|
+
`Warning: ${configPath} will be read as CommonJS, because its package scope ` +
|
|
242
|
+
'does not make it ESM.'
|
|
243
|
+
);
|
|
244
|
+
console.warn(' (That scope is the nearest package.json above the file, and stops at a');
|
|
245
|
+
console.warn(' node_modules directory — nothing past one is consulted.)');
|
|
246
|
+
console.warn(' tsx applies its CommonJS transform with no ESM reparse, which leaves');
|
|
247
|
+
console.warn(' import.meta.dirname and import.meta.filename undefined — so an ESM config');
|
|
248
|
+
console.warn(' reading either one loads with no error and a wrong value in it.');
|
|
249
|
+
// Both extension pairs, because this also fires for a `.jsx` config, whose
|
|
250
|
+
// equivalents are `.mjs` and `.cjs` rather than the TypeScript pair.
|
|
251
|
+
console.warn(' Name it .mts or .mjs to be read as ESM, or .cts or .cjs if it really is');
|
|
252
|
+
console.warn(' CommonJS — none of those depends on a manifest — or declare');
|
|
253
|
+
console.warn(' "type": "module" in the package.json above it.');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Loads a consumer's icon configuration file, which may be ESM or CommonJS.
|
|
258
|
+
*
|
|
259
|
+
* Two loaders: `import()`, which reads ESM and the CommonJS that node's ESM loader
|
|
260
|
+
* can take, and `createRequire()`, for the shapes it refuses outright — JSON among
|
|
261
|
+
* them, and `--config` accepts any path. The `file:` URL on the import is not
|
|
262
|
+
* decoration: a bare absolute path is not a valid ESM specifier on Windows.
|
|
263
|
+
* Whichever runs first, the other is tried if it fails.
|
|
264
|
+
*
|
|
265
|
+
* **`import()` goes first unless the extension rules ESM out, and that asymmetry
|
|
266
|
+
* is the whole of the ordering rule.** A loader that fails does so at *runtime*,
|
|
267
|
+
* with every statement above the failing line already run, so retrying runs the
|
|
268
|
+
* whole file again — a config that appends to a log at its top level does it twice.
|
|
269
|
+
* That is the cost of guessing `import()` wrong. Guessing `require()` wrong costs
|
|
270
|
+
* something worse, and silent: **tsx's CommonJS transform shims `import.meta.url`
|
|
271
|
+
* and leaves `import.meta.dirname` undefined**, so an ESM config read that way
|
|
272
|
+
* loads with no error and a wrong value in it. Measured, counting evaluations
|
|
273
|
+
* rather than successes, over eleven config shapes under tsx.
|
|
274
|
+
*
|
|
275
|
+
* So the question is never "which kind is this file", which cannot be answered
|
|
276
|
+
* without running it — node itself does not answer it statically, and since 22.7
|
|
277
|
+
* reads a typeless `.js` as CommonJS *and reparses it as ESM* when that fails. It
|
|
278
|
+
* is "can this extension possibly be ESM", which `NEVER_ESM` answers outright.
|
|
279
|
+
*
|
|
280
|
+
* **One shape that loads is still read twice: a config whose syntax contradicts
|
|
281
|
+
* what its own package declares** — `module.exports` in a `.js` file under
|
|
282
|
+
* `"type": "module"`, which is the shape #365 is about. `import()` gets as far as
|
|
283
|
+
* that line before failing, and loading it twice beats the old behaviour of
|
|
284
|
+
* dropping it. A file that fails for its own reasons, a syntax error or a `throw`,
|
|
285
|
+
* is also run twice and reports both objections; it was not going to load either
|
|
286
|
+
* way.
|
|
287
|
+
*
|
|
288
|
+
* What this replaced was a bare `require(configPath)` fallback that could never
|
|
289
|
+
* run. The published package carries `"type": "module"` — written by ng-packagr,
|
|
290
|
+
* not by this repo (#362) — so tsx reads the CLI as ESM, where `require` is not
|
|
291
|
+
* defined. That `ReferenceError` was caught by the same `catch` as a genuine
|
|
292
|
+
* failure, which then reported `Could not load config file` and returned `{}`: the
|
|
293
|
+
* consumer lost their whole configuration, and the message named neither the real
|
|
294
|
+
* reason nor the fact that the second loader had not run at all.
|
|
295
|
+
*/
|
|
296
|
+
export async function loadConfig(
|
|
297
|
+
configFile: string,
|
|
298
|
+
cwd: string = process.cwd()
|
|
299
|
+
): Promise<SpriteGeneratorConfig> {
|
|
300
|
+
const configPath = path.resolve(cwd, configFile);
|
|
301
|
+
|
|
302
|
+
if (!fs.existsSync(configPath)) {
|
|
303
|
+
return {};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Before the loaders, so it is said even for a file that then fails to load for
|
|
307
|
+
// its own reasons — the extension is the consumer's problem either way.
|
|
308
|
+
warnAboutTypeScriptConfigReadAsCommonJS(configPath);
|
|
309
|
+
|
|
310
|
+
const loaders = [
|
|
311
|
+
{ label: 'import()', load: async () => await import(pathToFileURL(configPath).href) },
|
|
312
|
+
{ label: 'require()', load: async () => createRequire(configPath)(configPath) },
|
|
313
|
+
];
|
|
314
|
+
|
|
315
|
+
if (NEVER_ESM.includes(path.extname(configPath))) {
|
|
316
|
+
loaders.reverse();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const failures: string[] = [];
|
|
320
|
+
|
|
321
|
+
for (const { label, load } of loaders) {
|
|
322
|
+
try {
|
|
323
|
+
const loaded = await load();
|
|
324
|
+
|
|
325
|
+
return (loaded.default ?? loaded) as SpriteGeneratorConfig;
|
|
326
|
+
} catch (error) {
|
|
327
|
+
failures.push(`${label}: ${reasonFor(error)}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Both loaders ran and both rejected the file, so report what each one said.
|
|
332
|
+
// "Could not load config file" on its own is what hid #365 for a release: it
|
|
333
|
+
// reads as a problem with the file even when the loader was what failed.
|
|
334
|
+
console.warn(`Warning: Could not load config file: ${configPath}`);
|
|
335
|
+
for (const failure of failures) {
|
|
336
|
+
console.warn(` ${failure}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {};
|
|
340
|
+
}
|