@truenas/ui-components 0.8.8 → 0.8.9

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truenas/ui-components",
3
- "version": "0.8.8",
3
+ "version": "0.8.9",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org",
6
6
  "access": "public"
@@ -16,7 +16,8 @@
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. See `lib/load-config.ts` for which shapes are read and how.
20
21
  *
21
22
  * export default {
22
23
  * srcDirs: ['./src/lib', './src/app'],
@@ -26,10 +27,9 @@
26
27
  */
27
28
 
28
29
  import { generateSprite } from './generate-sprite.js';
30
+ import { loadConfig } from './lib/load-config.js';
29
31
  import { validateIcons, printValidationReport } from './lib/validate-icons.js';
30
32
  import { resolveConfig } from './sprite-config-interface.js';
31
- import fs from 'fs';
32
- import path from 'path';
33
33
 
34
34
  const HELP_TEXT = `
35
35
  truenas-icons - Icon sprite generation for TrueNAS UI components
@@ -63,7 +63,8 @@ Options:
63
63
  --help Show this help message
64
64
 
65
65
  Configuration File:
66
- Create truenas-icons.config.js in your project root:
66
+ Create truenas-icons.config.js in your project root. Either module form is
67
+ read, so write it the way the rest of your project's .js files are written.
67
68
 
68
69
  export default {
69
70
  srcDirs: ['./src/lib', './src/app'],
@@ -71,6 +72,15 @@ Configuration File:
71
72
  customIconsDir: './custom-icons'
72
73
  };
73
74
 
75
+ module.exports = {
76
+ srcDirs: ['./src/lib', './src/app']
77
+ };
78
+
79
+ Only truenas-icons.config.js is looked for by default. A .cjs, .mjs or .json
80
+ config is read correctly but is not discovered, so name it with --config:
81
+
82
+ npx truenas-icons generate --config truenas-icons.config.cjs
83
+
74
84
  Examples:
75
85
  # Generate with defaults
76
86
  npx truenas-icons generate
@@ -123,29 +133,6 @@ function parseArgs() {
123
133
  return parsed;
124
134
  }
125
135
 
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
136
  async function main() {
150
137
  const args = parseArgs();
151
138
 
@@ -0,0 +1,585 @@
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
+ interface CaseDefinition {
35
+ /** What the consumer's own `package.json` says — `type` is what decides the config's kind. */
36
+ packageJson: Record<string, unknown>;
37
+ configFile: string;
38
+ contents: string;
39
+ /** Set on the case that is about the file not being there. Defaults to writing it. */
40
+ write?: false;
41
+ }
42
+
43
+ interface CaseResult {
44
+ config: Record<string, unknown>;
45
+ warnings: string[];
46
+ /** How many times the config file's own body ran. See `EVALUATION_COUNTER`. */
47
+ evaluations: number;
48
+ }
49
+
50
+ /**
51
+ * Prepended to a config fixture to count its own evaluations. Counts into
52
+ * `globalThis` because both module scopes have it: anything reached through
53
+ * `require` cannot run in the ESM scope that half these cases are about, and a
54
+ * counter that cannot run is a counter that reads zero and proves nothing.
55
+ */
56
+ const EVALUATION_COUNTER = 'globalThis.__timesEvaluated = (globalThis.__timesEvaluated || 0) + 1;';
57
+
58
+ /**
59
+ * Shared by the `import.meta` cases below, which differ only in where they sit and
60
+ * what they are called. It reports its own directory's name, which is the case's
61
+ * own key — asserted through `IMPORT_META` rather than written out, so renaming a
62
+ * case cannot turn into a value mismatch that explains nothing.
63
+ */
64
+ const IMPORT_META = 'esm-config-using-import-meta';
65
+
66
+ const IMPORT_META_CONFIG = [
67
+ EVALUATION_COUNTER,
68
+ "import path from 'path';",
69
+ '',
70
+ 'export default { srcDirs: [path.basename(String(import.meta.dirname))] };',
71
+ ].join('\n');
72
+
73
+ // `satisfies` rather than an annotation, so `keyof typeof CASES` stays the union of
74
+ // the names below. Annotated as `Record<string, CaseDefinition>` it widens to
75
+ // `string`, and `resultFor`'s parameter type stops checking anything.
76
+ const CASES = {
77
+ // The shape in the report: no `type` field, so a plain `.js` config is
78
+ // CommonJS. The config uses `require` itself, not just `module.exports`.
79
+ 'commonjs-config-in-a-plain-project': {
80
+ packageJson: {},
81
+ configFile: 'truenas-icons.config.js',
82
+ contents: [
83
+ "const path = require('path');",
84
+ '',
85
+ 'module.exports = {',
86
+ " srcDirs: [path.join('.', 'src', 'app')],",
87
+ " outputDir: './public/icons',",
88
+ '};',
89
+ ].join('\n'),
90
+ },
91
+
92
+ // The one the old loader actually dropped: CommonJS syntax in a `.js` file that
93
+ // its own package declares to be ESM. `import()` cannot read it, so everything
94
+ // depended on a fallback that could not run. It counts its own evaluations
95
+ // because it is also the shape that gets run twice if the loaders are tried the
96
+ // other way round.
97
+ 'commonjs-config-in-an-esm-project': {
98
+ packageJson: { type: 'module' },
99
+ configFile: 'truenas-icons.config.js',
100
+ contents: [
101
+ EVALUATION_COUNTER,
102
+ "module.exports = { srcDirs: ['./src/mixed'], outputDir: './dist/mixed' };",
103
+ ].join('\n'),
104
+ },
105
+
106
+ 'commonjs-config-named-cjs': {
107
+ packageJson: { type: 'module' },
108
+ configFile: 'truenas-icons.config.cjs',
109
+ contents: "module.exports = { srcDirs: ['./src/cjs'], customIconsDir: './brand' };",
110
+ },
111
+
112
+ // `module.exports` replaced wholesale is the common form; assigning onto
113
+ // `exports` is the other one, and it arrives as a different shape.
114
+ 'commonjs-config-assigning-onto-exports': {
115
+ packageJson: {},
116
+ configFile: 'truenas-icons.config.js',
117
+ contents: ["exports.srcDirs = ['./src/named'];", "exports.outputDir = './dist/named';"].join(
118
+ '\n'
119
+ ),
120
+ },
121
+
122
+ 'esm-config': {
123
+ packageJson: { type: 'module' },
124
+ configFile: 'truenas-icons.config.js',
125
+ contents: "export default { srcDirs: ['./src/esm'], spriteUrlPath: 'assets/tn-icons' };",
126
+ },
127
+
128
+ 'esm-config-named-mjs': {
129
+ packageJson: {},
130
+ configFile: 'truenas-icons.config.mjs',
131
+ contents: "export default { srcDirs: ['./src/mjs'] };",
132
+ },
133
+
134
+ // No default export, so the module namespace is the configuration.
135
+ 'esm-config-without-a-default-export': {
136
+ packageJson: { type: 'module' },
137
+ configFile: 'truenas-icons.config.js',
138
+ contents: "export const srcDirs = ['./src/named-esm'];",
139
+ },
140
+
141
+ // Top-level await is a shape `require` cannot take at all, so it pins that the
142
+ // ESM loader is the one reading an ESM config rather than a fallback reached
143
+ // after a failure.
144
+ 'esm-config-using-top-level-await': {
145
+ packageJson: { type: 'module' },
146
+ configFile: 'truenas-icons.config.js',
147
+ contents: [
148
+ EVALUATION_COUNTER,
149
+ "const srcDirs = await Promise.resolve(['./src/awaited']);",
150
+ 'export default { srcDirs };',
151
+ ].join('\n'),
152
+ },
153
+
154
+ /**
155
+ * `import.meta.dirname` is what makes the loader choice matter beyond counting
156
+ * evaluations. tsx's CommonJS transform shims `import.meta.url` and leaves
157
+ * `dirname` **undefined**, so a config read by the wrong loader does not fail — it
158
+ * loads with a wrong value in it. Reporting its own directory name is what turns
159
+ * that from invisible into an assertion.
160
+ */
161
+ [`${IMPORT_META}-in-an-esm-project`]: {
162
+ packageJson: { type: 'module' },
163
+ configFile: 'truenas-icons.config.js',
164
+ contents: IMPORT_META_CONFIG,
165
+ },
166
+
167
+ /**
168
+ * The same file in a project that declares no type at all — which is not the
169
+ * CommonJS case it looks like. Node has read a typeless `.js` as CommonJS *and
170
+ * reparsed it as ESM on failure* since 22.7, so this is an ESM config however
171
+ * little its package says so, and anything that treats "no type" as CommonJS
172
+ * hands it an undefined `import.meta.dirname`.
173
+ */
174
+ [`${IMPORT_META}-in-a-plain-project`]: {
175
+ packageJson: {},
176
+ configFile: 'truenas-icons.config.js',
177
+ contents: IMPORT_META_CONFIG,
178
+ },
179
+
180
+ /** `.mts` is ESM by extension, the way `.cts` below is CommonJS by extension. */
181
+ [`${IMPORT_META}-named-mts`]: {
182
+ packageJson: {},
183
+ configFile: 'truenas-icons.config.mts',
184
+ contents: IMPORT_META_CONFIG,
185
+ },
186
+
187
+ // The CommonJS half of that pair: an extension that rules ESM out, so the
188
+ // CommonJS loader is the one that reads it.
189
+ 'commonjs-config-named-cts': {
190
+ packageJson: { type: 'module' },
191
+ configFile: 'truenas-icons.config.cts',
192
+ contents: "module.exports = { srcDirs: ['./src/cts'] };",
193
+ },
194
+
195
+ // `--config` takes any path, so a JSON config is a shape that turns up. Its
196
+ // extension settles its kind, so `require` reads it first and node's ESM rule
197
+ // about an import attribute never comes up.
198
+ 'json-config': {
199
+ packageJson: {},
200
+ configFile: 'icons.config.json',
201
+ contents: JSON.stringify({ srcDirs: ['./src/json'], outputDir: './dist/json' }),
202
+ },
203
+
204
+ 'malformed-config': {
205
+ packageJson: {},
206
+ configFile: 'truenas-icons.config.js',
207
+ contents: "module.exports = { srcDirs: ['./unclosed",
208
+ },
209
+
210
+ 'absent-config': {
211
+ packageJson: {},
212
+ configFile: 'truenas-icons.config.js',
213
+ contents: '',
214
+ write: false,
215
+ },
216
+ } satisfies Record<string, CaseDefinition>;
217
+
218
+ /**
219
+ * Runs inside the spawned process. Written out as a `.mjs` so it is ESM whatever
220
+ * directory it lands in, and it reports through a file rather than stdout so that
221
+ * nothing tsx happens to print can be mistaken for a result.
222
+ */
223
+ const DRIVER = `
224
+ import fs from 'fs';
225
+ import path from 'path';
226
+ import { pathToFileURL } from 'url';
227
+
228
+ const root = process.argv[2];
229
+ const cases = JSON.parse(fs.readFileSync(path.join(root, 'cases.json'), 'utf8'));
230
+ const published = path.join(root, 'published', 'lib');
231
+ const loaderPath = path.join(published, 'load-config.ts');
232
+ const { loadConfig } = await import(pathToFileURL(loaderPath).href);
233
+
234
+ // Whether the loader is in ESM scope at all -- read from a file beside it, so the
235
+ // answer is the one its own directory and manifest produce.
236
+ const { requireInScope } = await import(
237
+ pathToFileURL(path.join(published, 'module-scope.ts')).href
238
+ );
239
+
240
+ const realWarn = console.warn;
241
+ const results = {};
242
+
243
+ for (const [name, configFile] of cases) {
244
+ const warnings = [];
245
+ console.warn = (...args) => warnings.push(args.map(String).join(' '));
246
+ globalThis.__timesEvaluated = 0;
247
+
248
+ let config = null;
249
+ let threw = null;
250
+ try {
251
+ config = await loadConfig(configFile, path.join(root, 'projects', name));
252
+ } catch (error) {
253
+ threw = String(error && error.stack ? error.stack : error);
254
+ }
255
+
256
+ console.warn = realWarn;
257
+ // A namespace object does not survive JSON.stringify as a plain object, so
258
+ // spread it: the caller only ever reads configuration keys off it.
259
+ results[name] = {
260
+ config: config ? { ...config } : config,
261
+ warnings,
262
+ threw,
263
+ evaluations: globalThis.__timesEvaluated,
264
+ };
265
+ }
266
+
267
+ fs.writeFileSync(
268
+ path.join(root, 'results.json'),
269
+ JSON.stringify({ requireInScope, cases: results })
270
+ );
271
+ `;
272
+
273
+ let workspace = '';
274
+ let requireInScope: string;
275
+ let results: Record<string, CaseResult & { threw: string | null }>;
276
+
277
+ beforeAll(() => {
278
+ workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'truenas-icons-load-config-'));
279
+
280
+ // The publish step, for the files under test: into a package whose manifest says
281
+ // `"type": "module"`, in the same relative layout the published one has. The
282
+ // interface is copied because the loader imports it by a relative path — only as
283
+ // a type today, which the transform erases, but a value import later should not
284
+ // turn into a puzzling resolution failure here.
285
+ const published = path.join(workspace, 'published');
286
+ fs.mkdirSync(path.join(published, 'lib'), { recursive: true });
287
+ fs.writeFileSync(
288
+ path.join(published, 'package.json'),
289
+ JSON.stringify({ name: 'published-like', version: '0.0.0', type: 'module' })
290
+ );
291
+ fs.copyFileSync(
292
+ path.join(__dirname, '..', 'sprite-config-interface.ts'),
293
+ path.join(published, 'sprite-config-interface.ts')
294
+ );
295
+ fs.copyFileSync(path.join(__dirname, 'load-config.ts'), path.join(published, 'lib', 'load-config.ts'));
296
+
297
+ // Beside the loader, sharing its directory and so its module kind: what this
298
+ // reports is what the loader itself gets.
299
+ fs.writeFileSync(
300
+ path.join(published, 'lib', 'module-scope.ts'),
301
+ 'export const requireInScope = typeof require;\n'
302
+ );
303
+
304
+ // One throwaway consumer project per case: the manifest always, and the config
305
+ // file unless the case is the one about it being missing — which the definition
306
+ // says, rather than a name compared here, so that renaming a case cannot quietly
307
+ // turn "absent" into "present and empty". Both load as `{}`.
308
+ for (const [name, definition] of Object.entries(CASES as Record<string, CaseDefinition>)) {
309
+ const projectRoot = path.join(workspace, 'projects', name);
310
+ fs.mkdirSync(projectRoot, { recursive: true });
311
+ fs.writeFileSync(
312
+ path.join(projectRoot, 'package.json'),
313
+ JSON.stringify({ name, version: '1.0.0', ...definition.packageJson })
314
+ );
315
+
316
+ if (definition.write !== false) {
317
+ fs.writeFileSync(path.join(projectRoot, definition.configFile), definition.contents);
318
+ }
319
+ }
320
+
321
+ fs.writeFileSync(
322
+ path.join(workspace, 'cases.json'),
323
+ JSON.stringify(Object.entries(CASES).map(([name, { configFile }]) => [name, configFile]))
324
+ );
325
+
326
+ const driverPath = path.join(workspace, 'driver.mjs');
327
+ fs.writeFileSync(driverPath, DRIVER);
328
+
329
+ const run = spawnSync(process.execPath, [tsxCli, driverPath, workspace], {
330
+ cwd: repoRoot,
331
+ encoding: 'utf8',
332
+ // Jest's own timeout cannot interrupt a synchronous spawn, so a wedged tsx
333
+ // would hold the worker open rather than failing. This is the one that fires.
334
+ timeout: 120_000,
335
+ });
336
+
337
+ const resultsPath = path.join(workspace, 'results.json');
338
+
339
+ // Without this the suite's failure for a driver that never ran would be a
340
+ // confusing one about a missing file, with tsx's own diagnosis thrown away.
341
+ if (!fs.existsSync(resultsPath)) {
342
+ throw new Error(
343
+ // `run.error` and not only the exit status: a spawn killed by the timeout
344
+ // above reports a null status and says why in nothing else.
345
+ `the config loader driver produced no results (exit ${String(run.status)}, ` +
346
+ `error ${String(run.error)})\nstdout: ${run.stdout}\nstderr: ${run.stderr}`
347
+ );
348
+ }
349
+
350
+ ({ requireInScope, cases: results } = JSON.parse(fs.readFileSync(resultsPath, 'utf8')));
351
+ }, 180_000);
352
+
353
+ afterAll(() => {
354
+ // Guarded because `afterAll` runs even when `beforeAll` threw, and removing a
355
+ // path that was never made would replace the real failure with this one.
356
+ if (workspace !== '') {
357
+ fs.rmSync(workspace, { recursive: true, force: true });
358
+ }
359
+ });
360
+
361
+ function resultFor(name: keyof typeof CASES): CaseResult {
362
+ const result = results[name];
363
+
364
+ expect(result.threw).toBeNull();
365
+
366
+ return result;
367
+ }
368
+
369
+ describe('the setup itself', () => {
370
+ /** The ways this file could pass while having tested nothing. */
371
+ it('runs the loader through tsx, the way the shipped CLI is run', () => {
372
+ expect(fs.existsSync(tsxCli)).toBe(true);
373
+ });
374
+
375
+ /**
376
+ * The premise every case below rests on. `require` being absent is what the old
377
+ * fallback died of, so in CommonJS scope the loader this file is checking and the
378
+ * one it replaced behave the same and nothing here discriminates them. If this
379
+ * goes green while reporting `function`, the suite is measuring the wrong thing.
380
+ */
381
+ it('evaluates the loader in ESM scope, where require does not exist', () => {
382
+ expect(requireInScope).toBe('undefined');
383
+ });
384
+
385
+ it('produced a result for every case', () => {
386
+ expect(Object.keys(results).sort()).toEqual(Object.keys(CASES).sort());
387
+ });
388
+ });
389
+
390
+ describe('a CommonJS config', () => {
391
+ it('loads from a plain .js file in a project that does not declare a type', () => {
392
+ const { config, warnings } = resultFor('commonjs-config-in-a-plain-project');
393
+
394
+ // `require` inside the config has to work too, not only the `module.exports`
395
+ // around it.
396
+ expect(config.srcDirs).toEqual([path.join('.', 'src', 'app')]);
397
+ expect(config.outputDir).toBe('./public/icons');
398
+ expect(warnings).toEqual([]);
399
+ });
400
+
401
+ /**
402
+ * The regression this file exists for. `import()` cannot read `module.exports`
403
+ * out of a file its own package declares to be ESM, so the old code fell
404
+ * through to a bare `require` — which is not defined in that scope, measured as
405
+ * `typeof require === 'undefined'` under tsx — and the consumer's whole
406
+ * configuration was replaced by `{}` behind a one-line warning.
407
+ */
408
+ it('loads from a .js file even in a project that declares "type": "module"', () => {
409
+ const { config, warnings } = resultFor('commonjs-config-in-an-esm-project');
410
+
411
+ expect(config.srcDirs).toEqual(['./src/mixed']);
412
+ expect(config.outputDir).toBe('./dist/mixed');
413
+ expect(warnings).toEqual([]);
414
+ });
415
+
416
+ /**
417
+ * **The one shape that is read twice, asserted so that changing it is a
418
+ * decision.** Its package says ESM, so the ESM loader goes first and gets as far
419
+ * as `module.exports` before failing — at runtime, with the statements above that
420
+ * line already run — and `require` then runs the whole file again. Nothing can
421
+ * tell which kind this file is without running it, and running it twice beats
422
+ * dropping it, which is what used to happen.
423
+ *
424
+ * Every other shape in this matrix that loads is read exactly once; see the
425
+ * `import.meta` cases, which is where getting the loader wrong stops being a
426
+ * doubled side effect and becomes a wrong value.
427
+ */
428
+ it('is the one shape that loads and is still read twice', () => {
429
+ expect(resultFor('commonjs-config-in-an-esm-project').evaluations).toBe(2);
430
+ });
431
+
432
+ it('loads from a .cjs file in a project that declares "type": "module"', () => {
433
+ const { config, warnings } = resultFor('commonjs-config-named-cjs');
434
+
435
+ expect(config.srcDirs).toEqual(['./src/cjs']);
436
+ expect(config.customIconsDir).toBe('./brand');
437
+ expect(warnings).toEqual([]);
438
+ });
439
+
440
+ // `.cts` rules ESM out the same way `.cjs` does, and is the pair to the `.mts`
441
+ // case below: both are decided by their extension and neither consults a manifest.
442
+ it('loads from a .cts file in a project that declares "type": "module"', () => {
443
+ const { config, warnings } = resultFor('commonjs-config-named-cts');
444
+
445
+ expect(config.srcDirs).toEqual(['./src/cts']);
446
+ expect(warnings).toEqual([]);
447
+ });
448
+
449
+ it('loads one that assigns onto exports rather than replacing module.exports', () => {
450
+ const { config } = resultFor('commonjs-config-assigning-onto-exports');
451
+
452
+ expect(config.srcDirs).toEqual(['./src/named']);
453
+ expect(config.outputDir).toBe('./dist/named');
454
+ });
455
+ });
456
+
457
+ describe('an ESM config', () => {
458
+ it('still loads from a .js file in a project that declares "type": "module"', () => {
459
+ const { config, warnings } = resultFor('esm-config');
460
+
461
+ expect(config.srcDirs).toEqual(['./src/esm']);
462
+ expect(config.spriteUrlPath).toBe('assets/tn-icons');
463
+ expect(warnings).toEqual([]);
464
+ });
465
+
466
+ it('still loads from an .mjs file in a project that does not declare a type', () => {
467
+ const { config } = resultFor('esm-config-named-mjs');
468
+
469
+ expect(config.srcDirs).toEqual(['./src/mjs']);
470
+ });
471
+
472
+ it('still reads named exports when there is no default export', () => {
473
+ const { config } = resultFor('esm-config-without-a-default-export');
474
+
475
+ expect(config.srcDirs).toEqual(['./src/named-esm']);
476
+ });
477
+
478
+ /**
479
+ * `require` cannot load a module that awaits at its top level, under tsx or under
480
+ * node, so one evaluation here says the ESM loader was tried first rather than
481
+ * reached after a failure.
482
+ */
483
+ it('loads one that awaits at the top level, which only import() can take', () => {
484
+ const { config, warnings, evaluations } = resultFor('esm-config-using-top-level-await');
485
+
486
+ expect(config.srcDirs).toEqual(['./src/awaited']);
487
+ expect(warnings).toEqual([]);
488
+ expect(evaluations).toBe(1);
489
+ });
490
+
491
+ /**
492
+ * Where reading an ESM config with the CommonJS loader is not a doubled side
493
+ * effect but a wrong answer: under tsx's transform `import.meta.dirname` is
494
+ * `undefined`, and a config deriving a path from it loads with nonsense in it and
495
+ * no warning. So the assertion is on the value, and the count rules out it having
496
+ * been reached by falling back after a failure.
497
+ *
498
+ * Three projects, because what decides the kind differs in each and a rule can be
499
+ * right about one and wrong about the others: `"type": "module"` says so; a
500
+ * typeless project says nothing and is still ESM, since node reads a typeless
501
+ * `.js` as CommonJS and reparses it as ESM when that fails; and `.mts` says so in
502
+ * its own extension.
503
+ */
504
+ it.each([
505
+ [`${IMPORT_META}-in-an-esm-project`],
506
+ [`${IMPORT_META}-in-a-plain-project`],
507
+ [`${IMPORT_META}-named-mts`],
508
+ ])('loads %s with import.meta actually populated', (name) => {
509
+ const { config, warnings, evaluations } = resultFor(name as keyof typeof CASES);
510
+
511
+ // The config reports its own directory's name, which is the case's own key.
512
+ expect(config.srcDirs).toEqual([name]);
513
+ expect(warnings).toEqual([]);
514
+ expect(evaluations).toBe(1);
515
+ });
516
+ });
517
+
518
+ describe('a JSON config', () => {
519
+ it('loads when --config names one', () => {
520
+ const { config, warnings } = resultFor('json-config');
521
+
522
+ expect(config.srcDirs).toEqual(['./src/json']);
523
+ expect(config.outputDir).toBe('./dist/json');
524
+ expect(warnings).toEqual([]);
525
+ });
526
+ });
527
+
528
+ describe('a config that cannot be loaded', () => {
529
+ it('falls back to {} silently when the file is absent', () => {
530
+ const { config, warnings } = resultFor('absent-config');
531
+
532
+ // Most consumers pass flags and write no config at all, so this stays quiet
533
+ // rather than warning about a file nobody asked for.
534
+ expect(config).toEqual({});
535
+ expect(warnings).toEqual([]);
536
+ });
537
+
538
+ it('falls back to {} and says what each loader objected to', () => {
539
+ const { config, warnings } = resultFor('malformed-config');
540
+
541
+ expect(config).toEqual({});
542
+ expect(warnings[0]).toContain('Could not load config file');
543
+
544
+ // The old message named no cause at all: the only error it ever had in hand
545
+ // was its own `require is not defined`, and it did not print even that.
546
+ const reasons = warnings.slice(1).join('\n');
547
+ expect(reasons).toContain('import():');
548
+ expect(reasons).toContain('require():');
549
+ expect(reasons).not.toContain('require is not defined');
550
+
551
+ // What makes the reason useful rather than merely present: it points at the
552
+ // file that would not parse. Asserted as the filename rather than as the
553
+ // transform's own wording, which belongs to whatever tsx ships.
554
+ expect(reasons).toContain(CASES['malformed-config'].configFile);
555
+ });
556
+ });
557
+
558
+ /**
559
+ * The criterion is that a CommonJS config's values reach the generated sprite,
560
+ * not just that they parse. `generateSprite` reads every path it scans and writes
561
+ * out of `resolveConfig`'s result, so composing the two covers the gap where a
562
+ * value could load and still never arrive.
563
+ */
564
+ describe('what reaches the sprite', () => {
565
+ it('carries a CommonJS config through to the resolved sprite configuration', () => {
566
+ const { config } = resultFor('commonjs-config-in-an-esm-project');
567
+ const projectRoot = '/consumer';
568
+
569
+ expect(resolveConfig({ ...config, projectRoot })).toEqual({
570
+ projectRoot,
571
+ srcDirs: ['./src/mixed'],
572
+ outputDir: './dist/mixed',
573
+ spriteUrlPath: 'dist/mixed',
574
+ customIconsDir: null,
575
+ });
576
+ });
577
+
578
+ it('leaves the defaults in place when there was no config to load', () => {
579
+ const { config } = resultFor('absent-config');
580
+ const resolved = resolveConfig({ ...config, projectRoot: '/consumer' });
581
+
582
+ expect(resolved.srcDirs).toEqual(['./src/lib', './src/app']);
583
+ expect(resolved.customIconsDir).toBeNull();
584
+ });
585
+ });
@@ -0,0 +1,125 @@
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 a typeless `.ts`
38
+ * as CommonJS whatever its syntax, with no ESM reparse, so `--config` pointing at
39
+ * one gets an undefined `import.meta.dirname` and no warning. Neither order changes
40
+ * that, it predates this function, and a `.ts` config is not a documented shape —
41
+ * so it is recorded here rather than worked around.
42
+ */
43
+ const NEVER_ESM = ['.cjs', '.cts', '.json'];
44
+
45
+ /**
46
+ * Loads a consumer's icon configuration file, which may be ESM or CommonJS.
47
+ *
48
+ * Two loaders: `import()`, which reads ESM and the CommonJS that node's ESM loader
49
+ * can take, and `createRequire()`, for the shapes it refuses outright — JSON among
50
+ * them, and `--config` accepts any path. The `file:` URL on the import is not
51
+ * decoration: a bare absolute path is not a valid ESM specifier on Windows.
52
+ * Whichever runs first, the other is tried if it fails.
53
+ *
54
+ * **`import()` goes first unless the extension rules ESM out, and that asymmetry
55
+ * is the whole of the ordering rule.** A loader that fails does so at *runtime*,
56
+ * with every statement above the failing line already run, so retrying runs the
57
+ * whole file again — a config that appends to a log at its top level does it twice.
58
+ * That is the cost of guessing `import()` wrong. Guessing `require()` wrong costs
59
+ * something worse, and silent: **tsx's CommonJS transform shims `import.meta.url`
60
+ * and leaves `import.meta.dirname` undefined**, so an ESM config read that way
61
+ * loads with no error and a wrong value in it. Measured, counting evaluations
62
+ * rather than successes, over eleven config shapes under tsx.
63
+ *
64
+ * So the question is never "which kind is this file", which cannot be answered
65
+ * without running it — node itself does not answer it statically, and since 22.7
66
+ * reads a typeless `.js` as CommonJS *and reparses it as ESM* when that fails. It
67
+ * is "can this extension possibly be ESM", which `NEVER_ESM` answers outright.
68
+ *
69
+ * **One shape that loads is still read twice: a config whose syntax contradicts
70
+ * what its own package declares** — `module.exports` in a `.js` file under
71
+ * `"type": "module"`, which is the shape #365 is about. `import()` gets as far as
72
+ * that line before failing, and loading it twice beats the old behaviour of
73
+ * dropping it. A file that fails for its own reasons, a syntax error or a `throw`,
74
+ * is also run twice and reports both objections; it was not going to load either
75
+ * way.
76
+ *
77
+ * What this replaced was a bare `require(configPath)` fallback that could never
78
+ * run. The published package carries `"type": "module"` — written by ng-packagr,
79
+ * not by this repo (#362) — so tsx reads the CLI as ESM, where `require` is not
80
+ * defined. That `ReferenceError` was caught by the same `catch` as a genuine
81
+ * failure, which then reported `Could not load config file` and returned `{}`: the
82
+ * consumer lost their whole configuration, and the message named neither the real
83
+ * reason nor the fact that the second loader had not run at all.
84
+ */
85
+ export async function loadConfig(
86
+ configFile: string,
87
+ cwd: string = process.cwd()
88
+ ): Promise<SpriteGeneratorConfig> {
89
+ const configPath = path.resolve(cwd, configFile);
90
+
91
+ if (!fs.existsSync(configPath)) {
92
+ return {};
93
+ }
94
+
95
+ const loaders = [
96
+ { label: 'import()', load: async () => await import(pathToFileURL(configPath).href) },
97
+ { label: 'require()', load: async () => createRequire(configPath)(configPath) },
98
+ ];
99
+
100
+ if (NEVER_ESM.includes(path.extname(configPath))) {
101
+ loaders.reverse();
102
+ }
103
+
104
+ const failures: string[] = [];
105
+
106
+ for (const { label, load } of loaders) {
107
+ try {
108
+ const loaded = await load();
109
+
110
+ return (loaded.default ?? loaded) as SpriteGeneratorConfig;
111
+ } catch (error) {
112
+ failures.push(`${label}: ${reasonFor(error)}`);
113
+ }
114
+ }
115
+
116
+ // Both loaders ran and both rejected the file, so report what each one said.
117
+ // "Could not load config file" on its own is what hid #365 for a release: it
118
+ // reads as a problem with the file even when the loader was what failed.
119
+ console.warn(`Warning: Could not load config file: ${configPath}`);
120
+ for (const failure of failures) {
121
+ console.warn(` ${failure}`);
122
+ }
123
+
124
+ return {};
125
+ }