@truenas/ui-components 0.8.9 → 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truenas/ui-components",
3
- "version": "0.8.9",
3
+ "version": "0.8.10",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org",
6
6
  "access": "public"
@@ -17,7 +17,12 @@
17
17
  *
18
18
  * Configuration File:
19
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
+ * 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.
21
26
  *
22
27
  * export default {
23
28
  * srcDirs: ['./src/lib', './src/app'],
@@ -81,6 +86,15 @@ Configuration File:
81
86
 
82
87
  npx truenas-icons generate --config truenas-icons.config.cjs
83
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
+
84
98
  Examples:
85
99
  # Generate with defaults
86
100
  npx truenas-icons generate
@@ -31,13 +31,29 @@ import { resolveConfig } from '../sprite-config-interface';
31
31
  const repoRoot = path.resolve(__dirname, '..', '..', '..', '..', '..');
32
32
  const tsxCli = path.join(repoRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs');
33
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
+
34
43
  interface CaseDefinition {
35
44
  /** What the consumer's own `package.json` says — `type` is what decides the config's kind. */
36
45
  packageJson: Record<string, unknown>;
46
+ /** Relative to the project root, so a `/` in it puts the config in a subdirectory. */
37
47
  configFile: string;
38
48
  contents: string;
39
49
  /** Set on the case that is about the file not being there. Defaults to writing it. */
40
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>;
41
57
  }
42
58
 
43
59
  interface CaseResult {
@@ -184,6 +200,112 @@ const CASES = {
184
200
  contents: IMPORT_META_CONFIG,
185
201
  },
186
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
+
187
309
  // The CommonJS half of that pair: an extension that rules ESM out, so the
188
310
  // CommonJS loader is the one that reads it.
189
311
  'commonjs-config-named-cts': {
@@ -313,8 +435,19 @@ beforeAll(() => {
313
435
  JSON.stringify({ name, version: '1.0.0', ...definition.packageJson })
314
436
  );
315
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
+
316
446
  if (definition.write !== false) {
317
- fs.writeFileSync(path.join(projectRoot, definition.configFile), definition.contents);
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);
318
451
  }
319
452
  }
320
453
 
@@ -495,16 +628,19 @@ describe('an ESM config', () => {
495
628
  * no warning. So the assertion is on the value, and the count rules out it having
496
629
  * been reached by falling back after a failure.
497
630
  *
498
- * Three projects, because what decides the kind differs in each and a rule can be
631
+ * Four projects, because what decides the kind differs in each and a rule can be
499
632
  * right about one and wrong about the others: `"type": "module"` says so; a
500
633
  * 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.
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.
503
638
  */
504
639
  it.each([
505
640
  [`${IMPORT_META}-in-an-esm-project`],
506
641
  [`${IMPORT_META}-in-a-plain-project`],
507
642
  [`${IMPORT_META}-named-mts`],
643
+ [`${IMPORT_META}-named-ts-in-an-esm-project`],
508
644
  ])('loads %s with import.meta actually populated', (name) => {
509
645
  const { config, warnings, evaluations } = resultFor(name as keyof typeof CASES);
510
646
 
@@ -515,6 +651,162 @@ describe('an ESM config', () => {
515
651
  });
516
652
  });
517
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
+
518
810
  describe('a JSON config', () => {
519
811
  it('loads when --config names one', () => {
520
812
  const { config, warnings } = resultFor('json-config');
@@ -34,14 +34,225 @@ function reasonFor(error: unknown): string {
34
34
  * extension already names is the one that should answer, not because an outcome
35
35
  * depends on it — do not read the measured asymmetry below as applying to it.
36
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.
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.
42
45
  */
43
46
  const NEVER_ESM = ['.cjs', '.cts', '.json'];
44
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
+
45
256
  /**
46
257
  * Loads a consumer's icon configuration file, which may be ESM or CommonJS.
47
258
  *
@@ -92,6 +303,10 @@ export async function loadConfig(
92
303
  return {};
93
304
  }
94
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
+
95
310
  const loaders = [
96
311
  { label: 'import()', load: async () => await import(pathToFileURL(configPath).href) },
97
312
  { label: 'require()', load: async () => createRequire(configPath)(configPath) },