@markuplint/file-resolver 5.3.0 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,27 @@
1
+ import fs from 'node:fs/promises';
1
2
  import path from 'node:path';
2
- import { ConfigParserError } from '@markuplint/parser-utils';
3
- import { cosmiconfig, defaultLoaders } from 'cosmiconfig';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { inspect } from 'node:util';
5
+ import { cosmiconfig, defaultLoaders, defaultLoadersSync } from 'cosmiconfig';
4
6
  import { jsonc } from 'jsonc';
5
- import { ConfigLoadError } from '@markuplint/shared';
7
+ import { ConfigLoadError, isFatalError } from '@markuplint/shared';
6
8
  import { log } from './debug.js';
7
9
  const searchLog = log.extend('search');
10
+ /**
11
+ * Fatal errors (see {@link isFatalError}) raised by markuplint's own code inside a loader. cosmiconfig
12
+ * gives every error a loader throws the path of the file, so {@link cacheConfigError} looks here to
13
+ * re-throw these as they are instead of reporting them as the configuration's fault.
14
+ */
15
+ const ownErrors = new WeakSet();
16
+ /**
17
+ * The `$import` target that {@link withImportCheck} could not read, by the error it threw.
18
+ */
19
+ const unreadableImports = new WeakMap();
20
+ /**
21
+ * The files whose change can fix a `ConfigLoadError` raised here, by the error; see
22
+ * {@link configLoadErrorFiles}.
23
+ */
24
+ const filesOfErrors = new WeakMap();
8
25
  const jsoncLoader = (path, content) => {
9
26
  try {
10
27
  return jsonc.parse(content);
@@ -13,9 +30,179 @@ const jsoncLoader = (path, content) => {
13
30
  if (error instanceof Error && error.name === 'JSONError') {
14
31
  return defaultLoaders['noExt'](path, content);
15
32
  }
33
+ if (isFatalError(error) && error instanceof Error) {
34
+ ownErrors.add(error);
35
+ }
16
36
  throw error;
17
37
  }
18
38
  };
39
+ /**
40
+ * Loads a JavaScript or TypeScript configuration file as cosmiconfig's `loadJs` does: `import()`
41
+ * of its real path, then `require()` when that fails, and the error of `import()` when `require()`
42
+ * fails only because the file is an ES module. Spelled out because `loadJs` reads `code` of what
43
+ * `require()` throws, which itself throws a `TypeError` when the configuration code throws `null`
44
+ * or `undefined`, and the value is lost.
45
+ *
46
+ * Unlike `loadJs`, what the configuration code itself threw while `import()` evaluated it is
47
+ * thrown as it is, without `require()`: see {@link isLoadingFailure}. Node.js's `require()` of
48
+ * such a module throws the same value again, but Deno's throws a new `Error` whose message is the
49
+ * stack of the original, which loses the value, its message and the location of the throw.
50
+ *
51
+ * A value the configuration code throws that is not an `Error` is thrown as one, with the value as
52
+ * its `cause`, so that it reaches {@link cacheConfigError} with the path of the file. cosmiconfig
53
+ * assigns `filepath` to what a loader throws, and that assignment itself throws a `TypeError` on a
54
+ * primitive, which loses both the value and the path.
55
+ *
56
+ * When cosmiconfig is upgraded, compare this with its `loadJs` (`dist/loaders.js`): how it
57
+ * imports the file, when it falls back to `require()` (`ERR_REQUIRE_ESM`), and whether it has
58
+ * started transpiling TypeScript itself.
59
+ *
60
+ * Neither `catch` is guarded by {@link isFatalError}: what is caught there was thrown by the
61
+ * user's code, or by the runtime loading it.
62
+ *
63
+ * @param filepath - The configuration file
64
+ * @param content - Its content, which `require()` does not use
65
+ * @returns The default export of the module, or `module.exports` of a CommonJS module
66
+ */
67
+ const jsLoader = async (filepath, content) => {
68
+ try {
69
+ try {
70
+ const { href } = pathToFileURL(await fs.realpath(filepath));
71
+ const module = await import(href);
72
+ return module && typeof module === 'object' && 'default' in module ? module.default : undefined;
73
+ }
74
+ catch (importError) {
75
+ if (!isLoadingFailure(importError)) {
76
+ throw importError;
77
+ }
78
+ try {
79
+ return defaultLoadersSync['.js'](filepath, content);
80
+ }
81
+ catch (requireError) {
82
+ throw isRequireOfEsModule(requireError) ? importError : requireError;
83
+ }
84
+ }
85
+ }
86
+ catch (error) {
87
+ if (error instanceof Error) {
88
+ throw error;
89
+ }
90
+ const message = typeof error === 'string' ? error : inspect(error, { breakLength: Infinity });
91
+ throw new Error(message, { cause: error });
92
+ }
93
+ };
94
+ /**
95
+ * Whether `import()` failed to load the module rather than the module's own code throwing: a
96
+ * `SyntaxError` (the file cannot be parsed as an ES module, but `require()` may take it as
97
+ * CommonJS, and reports the line of a syntax error that `import()` does not), or an error with a
98
+ * Node.js error code (`ERR_*`: a file extension or format that `import()` does not take, or a
99
+ * module that cannot be resolved). Anything else, an `Error` without such a code or a value that
100
+ * is not an `Error`, was thrown by the configuration code, and `require()` would only run it
101
+ * again.
102
+ *
103
+ * @param importError - What `import()` threw
104
+ * @returns `true` when `require()` is worth trying
105
+ */
106
+ function isLoadingFailure(importError) {
107
+ return (importError instanceof SyntaxError ||
108
+ (importError instanceof Error &&
109
+ 'code' in importError &&
110
+ typeof importError.code === 'string' &&
111
+ importError.code.startsWith('ERR_')));
112
+ }
113
+ /**
114
+ * Whether `require()` failed only because the file is an ES module, the same test as
115
+ * cosmiconfig's `loadJs`.
116
+ *
117
+ * @param requireError - What `require()` threw
118
+ * @returns `true` when the error of `import()` is the one to report
119
+ */
120
+ function isRequireOfEsModule(requireError) {
121
+ return ((requireError instanceof Error && 'code' in requireError && requireError.code === 'ERR_REQUIRE_ESM') ||
122
+ (requireError instanceof SyntaxError &&
123
+ requireError.toString().includes('Cannot use import statement outside a module')));
124
+ }
125
+ /**
126
+ * Wraps `loader` so that a `$import` target that cannot be read fails while the file that
127
+ * declares it is being loaded. cosmiconfig reads the targets itself after the loader has
128
+ * returned, and fails without `filepath` there; worse, its search takes a target that does not
129
+ * exist (or ENOENT's kin: EACCES, EISDIR, ENOTDIR) for a search place without a file, and passes
130
+ * the declaring file over in silence, so the target would be linted with another configuration.
131
+ * Thrown from inside the loader, the failure carries the path of the declaring file (see
132
+ * {@link cacheConfigError}).
133
+ *
134
+ * A target that is not a string, the file itself, or a cycle is left to cosmiconfig, which
135
+ * reports it with the path in its message (see {@link importErrorPath}).
136
+ *
137
+ * Each target is opened and one byte of it read, which fails as cosmiconfig's `readFile` of it
138
+ * would (a directory fails at the read, not at the open), without reading it in full twice. A
139
+ * fatal error of this check is markuplint's own, and is recorded in {@link ownErrors}.
140
+ *
141
+ * @param loader - The loader of one format
142
+ * @returns The loader, checking the `$import` of what it loaded
143
+ */
144
+ function withImportCheck(loader) {
145
+ return async (filepath, content) => {
146
+ const loaded = await loader(filepath, content);
147
+ try {
148
+ const imports = importsOf(filepath, loaded);
149
+ await Promise.all(imports
150
+ .filter(importPath => typeof importPath === 'string')
151
+ .map(importPath => probeImport(path.resolve(path.dirname(filepath), importPath), importPath)));
152
+ }
153
+ catch (error) {
154
+ if (isFatalError(error) && error instanceof Error) {
155
+ ownErrors.add(error);
156
+ }
157
+ throw error;
158
+ }
159
+ return loaded;
160
+ };
161
+ }
162
+ /**
163
+ * The `$import` entries of what a loader loaded, taken from where cosmiconfig takes them: the
164
+ * `markuplint` field of a package file (`package.json`, `package.yaml` and the like, whatever the
165
+ * extension), or the whole of any other file.
166
+ *
167
+ * @param filepath - The file the loader was given
168
+ * @param loaded - What the loader returned
169
+ * @returns The entries, of whatever type; empty when there is no `$import`
170
+ */
171
+ function importsOf(filepath, loaded) {
172
+ const own = path.basename(filepath, path.extname(filepath)) === 'package'
173
+ ? loaded && typeof loaded === 'object' && 'markuplint' in loaded
174
+ ? loaded.markuplint
175
+ : undefined
176
+ : loaded;
177
+ if (!own || typeof own !== 'object' || !('$import' in own)) {
178
+ return [];
179
+ }
180
+ return Array.isArray(own.$import) ? own.$import : [own.$import];
181
+ }
182
+ /**
183
+ * Fails when `target` cannot be read; see {@link withImportCheck}.
184
+ *
185
+ * @param target - The absolute path of a `$import` target
186
+ * @param importPath - The entry as written, for the message
187
+ */
188
+ async function probeImport(target, importPath) {
189
+ let handle;
190
+ try {
191
+ handle = await fs.open(target, 'r');
192
+ await handle.read(Buffer.alloc(1), 0, 1, 0);
193
+ }
194
+ catch (error) {
195
+ if (isFatalError(error)) {
196
+ throw error;
197
+ }
198
+ const cannotImport = new Error(`Cannot import ${importPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
199
+ unreadableImports.set(cannotImport, target);
200
+ throw cannotImport;
201
+ }
202
+ finally {
203
+ await handle?.close();
204
+ }
205
+ }
19
206
  const explorer = cosmiconfig('markuplint', {
20
207
  searchPlaces: [
21
208
  'package.json',
@@ -35,10 +222,15 @@ const explorer = cosmiconfig('markuplint', {
35
222
  'markuplint.config.jsonc',
36
223
  ],
37
224
  loaders: {
38
- noExt: jsoncLoader,
39
- '.jsonc': jsoncLoader,
225
+ noExt: withImportCheck(jsoncLoader),
226
+ '.jsonc': withImportCheck(jsoncLoader),
227
+ '.json': withImportCheck(defaultLoaders['.json']),
228
+ '.yaml': withImportCheck(defaultLoaders['.yaml']),
229
+ '.yml': withImportCheck(defaultLoaders['.yml']),
230
+ ...Object.fromEntries(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts'].map(ext => [ext, withImportCheck(jsLoader)])),
40
231
  },
41
- searchStrategy: 'project',
232
+ // Not 'project', which stops at the first `package.json`; `search()` walks the ancestors itself.
233
+ searchStrategy: 'none',
42
234
  });
43
235
  /**
44
236
  * Clears the shared `cosmiconfig` explorer's own internal search/load caches.
@@ -51,24 +243,99 @@ const explorer = cosmiconfig('markuplint', {
51
243
  export function clearExplorerCache() {
52
244
  explorer.clearCaches();
53
245
  }
246
+ /**
247
+ * Spelled out because `search()` needs the ancestors one directory at a time; see there for why
248
+ * none of cosmiconfig's own search strategies fits.
249
+ *
250
+ * @param dir - The directory to start from, which is yielded first
251
+ */
252
+ function* ancestorDirs(dir) {
253
+ let current = path.resolve(dir);
254
+ while (true) {
255
+ yield current;
256
+ const parent = path.dirname(current);
257
+ if (parent === current) {
258
+ return;
259
+ }
260
+ current = parent;
261
+ }
262
+ }
263
+ /**
264
+ * Whether `dir` does not exist: ENOENT, or ENOTDIR when one of its ancestors is a regular file
265
+ * (POSIX; Windows reports that as ENOENT). Checked before cosmiconfig is asked, as its own
266
+ * directory check takes only ENOENT for missing and throws on ENOTDIR, and as a directory it
267
+ * never sees leaves no entry in its search cache: a configuration file created later together
268
+ * with the directory is found without `clearExplorerCache()`.
269
+ *
270
+ * Any other `stat` failure (EACCES, for one) is reported like a configuration file that cannot be
271
+ * loaded, as the walk cannot tell whether that directory holds the nearest configuration.
272
+ *
273
+ * @param dir - The directory to examine
274
+ * @param filePath - The target file, named in the error report
275
+ */
276
+ async function isMissingDir(dir, filePath) {
277
+ try {
278
+ await fs.stat(dir);
279
+ return false;
280
+ }
281
+ catch (error) {
282
+ if (isFatalError(error)) {
283
+ throw error;
284
+ }
285
+ if (error instanceof Error && 'code' in error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) {
286
+ return true;
287
+ }
288
+ return cacheConfigError(dir, filePath, true)(error);
289
+ }
290
+ }
291
+ /**
292
+ * Searches for a configuration file in the directory of `filePath` and in each ancestor
293
+ * directory up to the file system root, nearest first.
294
+ *
295
+ * The walk is done here and not by cosmiconfig's `searchStrategy: 'project'`: that strategy
296
+ * stops at the first directory that has a `package.json`, which hides a configuration file at a
297
+ * monorepo root from every file inside a package, while the documented behavior is that the
298
+ * search goes upward from the target file's directory (https://markuplint.dev/docs/configuration).
299
+ * `searchStrategy: 'global'` is no substitute: it also searches the OS-level configuration
300
+ * directory, and that cannot be turned off.
301
+ *
302
+ * A directory that has a `package.json` or `package.yaml` does not end the walk: a `package.json`
303
+ * without a `markuplint` field is not a configuration file, and a `package.yaml` never is, as it
304
+ * is not among the search places. A directory that does not exist, the target's own included,
305
+ * is passed over (see {@link isMissingDir}). An empty configuration is treated like no result
306
+ * and the walk continues. A configuration file that cannot be loaded, at any level, makes the
307
+ * search throw instead of being skipped. There is no upper bound below the file system root, so a
308
+ * configuration file in a directory outside the project applies when nothing nearer exists.
309
+ *
310
+ * @template T - The type of the configuration object
311
+ * @param filePath - The path of the target file; the search starts from its directory
312
+ * @param cacheClear - Whether to clear the explorer's caches before searching
313
+ * @returns The path and content of the nearest configuration file, or `null` if none was found
314
+ */
54
315
  export async function search(filePath, cacheClear) {
55
316
  if (cacheClear) {
56
317
  explorer.clearCaches();
57
318
  }
58
- const dir = path.dirname(filePath);
59
- searchLog('Search dir: %s', dir);
60
- const result = await explorer.search(dir).catch(cacheConfigError(dir, filePath));
61
- searchLog('Search result: %O', result);
62
- if (!result || result.isEmpty) {
63
- return null;
319
+ for (const dir of ancestorDirs(path.dirname(filePath))) {
320
+ searchLog('Search dir: %s', dir);
321
+ if (await isMissingDir(dir, filePath)) {
322
+ searchLog('Missing: %s', dir);
323
+ continue;
324
+ }
325
+ const result = await explorer.search(dir).catch(cacheConfigError(dir, filePath, true));
326
+ searchLog('Search result: %O', result);
327
+ if (!result || result.isEmpty) {
328
+ continue;
329
+ }
330
+ const config = result.config;
331
+ return {
332
+ filePath: result.filepath,
333
+ config: config && typeof config === 'object' && 'default' in config && typeof config.default === 'object'
334
+ ? config.default
335
+ : config,
336
+ };
64
337
  }
65
- const config = result.config;
66
- return {
67
- filePath: result.filepath,
68
- config: config && typeof config === 'object' && 'default' in config && typeof config.default === 'object'
69
- ? config.default
70
- : config,
71
- };
338
+ return null;
72
339
  }
73
340
  export async function load(filePath, cacheClear, referrer) {
74
341
  if (cacheClear) {
@@ -86,19 +353,175 @@ export async function load(filePath, cacheClear, referrer) {
86
353
  : config,
87
354
  };
88
355
  }
89
- function cacheConfigError(fileOrDirPath, referrer) {
90
- return (reason) => {
91
- if (reason instanceof Error) {
92
- switch (reason.name) {
93
- case 'YAMLException': {
94
- throw new ConfigParserError(reason.message, {
95
- // @ts-ignore
96
- filePath: reason.filepath ?? fileOrDirPath,
97
- });
98
- }
99
- }
100
- throw new ConfigLoadError(reason.message, fileOrDirPath, referrer);
356
+ /**
357
+ * Converts a failure of cosmiconfig, or of the `stat` in {@link isMissingDir}, into the error
358
+ * markuplint reports: a `ConfigLoadError`, whatever the format of the file. A file that cannot
359
+ * be parsed is one, YAML included, as `MLEngine` fails the target for it like for any other
360
+ * configuration that cannot be loaded.
361
+ *
362
+ * cosmiconfig sets `filepath` on what a loader throws (a file that cannot be parsed or imported,
363
+ * or whose `$import` target cannot be read, see {@link withImportCheck}; a value the
364
+ * configuration code throws that is not an `Error` is one by then, see {@link jsLoader}). That
365
+ * path, not `fileOrDirPath`, is the one reported: a search walks up through the ancestors of the
366
+ * target, so the directory being searched can be far from the target, and only the path tells
367
+ * the user which configuration file to fix. The message names it as well, as `MLEngine` shows
368
+ * the message alone, unless cosmiconfig's JSON or YAML loader already began it with the path.
369
+ *
370
+ * An error without `filepath` is one cosmiconfig raises outside a loader, or one from a
371
+ * directory that cannot be examined. Of the former, an invalid `$import` (not a string, the file
372
+ * itself, a cycle) names the file that declares it in the message, and is reported with that path
373
+ * (see {@link importErrorPath}); any other is reported with `fileOrDirPath`. Either keeps its own
374
+ * message.
375
+ *
376
+ * When the configuration code throws, the path is followed by the location in that file nearest
377
+ * the throw (see {@link stackLocation}), so that the user can open it at the failing statement.
378
+ * The original error is kept as the `cause`.
379
+ *
380
+ * Only a fatal error that markuplint's own code raised inside a loader ({@link ownErrors}) is
381
+ * re-thrown as it is. Every other error a loader throws comes from the user's configuration code,
382
+ * or from parsing the user's file with `JSON.parse` or js-yaml, so a `SyntaxError` or a
383
+ * `TypeError` there is the configuration's fault, not a broken invariant of markuplint. An error
384
+ * without `filepath` is not guarded either: it comes from cosmiconfig or from the file system,
385
+ * not from markuplint, and fails the target as a configuration that cannot be loaded.
386
+ *
387
+ * What {@link configLoadErrorFiles} returns for the error is recorded here: the file reported,
388
+ * and the `$import` target that could not be read; nothing when a directory is reported.
389
+ *
390
+ * @param fileOrDirPath - The configuration file given to `load()`, or the directory being searched
391
+ * @param referrer - The file whose configuration is being loaded
392
+ * @param isDir - Whether `fileOrDirPath` is a directory
393
+ * @returns A handler for the rejection, which always throws
394
+ */
395
+ function cacheConfigError(fileOrDirPath, referrer, isDir = false) {
396
+ return async (reason) => {
397
+ if (!(reason instanceof Error) || ownErrors.has(reason)) {
398
+ throw reason;
101
399
  }
102
- throw reason;
400
+ const filepath = 'filepath' in reason && typeof reason.filepath === 'string' ? reason.filepath : null;
401
+ if (!filepath) {
402
+ const declaring = importErrorPath(reason.message);
403
+ const error = new ConfigLoadError(reason.message, declaring ?? fileOrDirPath, referrer, { cause: reason });
404
+ filesOfErrors.set(error, declaring == null && isDir ? [] : [error.filePath]);
405
+ throw error;
406
+ }
407
+ // The parser's report on the lines after the path ends with a code frame for YAML; a newline
408
+ // keeps the ` in <referrer>` that `ConfigLoadError` appends off its last line.
409
+ const message = isPrefixedByLoader(reason.message, filepath)
410
+ ? `${reason.message}\n`
411
+ : `Failed to load ${filepath}${await locate(reason, filepath)}: ${reason.message}`;
412
+ const error = new ConfigLoadError(message, filepath, referrer, { cause: reason });
413
+ const unreadable = unreadableImports.get(reason);
414
+ filesOfErrors.set(error, unreadable == null ? [filepath] : [unreadable, filepath]);
415
+ throw error;
103
416
  };
104
417
  }
418
+ /**
419
+ * The files whose change can fix `error`, for a caller to watch or to list among the files a
420
+ * configuration depends on: the file it names and, when that file's `$import` target could not
421
+ * be read, the target, which may not exist yet. Nothing when it names a directory that a search
422
+ * could not examine. For a `ConfigLoadError` raised elsewhere (a preset, a module), what it names.
423
+ *
424
+ * @param error - A `ConfigLoadError`
425
+ * @returns The absolute paths of the files, the `$import` target first; or what `error` names
426
+ */
427
+ export function configLoadErrorFiles(error) {
428
+ return filesOfErrors.get(error) ?? [error.filePath];
429
+ }
430
+ /**
431
+ * Whether `message` begins as cosmiconfig's JSON and YAML loaders begin the message of the error
432
+ * they re-throw (`JSON Error in <path>:` / `YAML Error in <path>:`). Checked as a prefix rather
433
+ * than as any occurrence of the path, which an error that the configuration code throws can
434
+ * contain too.
435
+ *
436
+ * @param message - The message of the error a loader threw
437
+ * @param filepath - The configuration file the loader was given
438
+ * @returns `true` when the message already names the file
439
+ */
440
+ function isPrefixedByLoader(message, filepath) {
441
+ return message.startsWith(`JSON Error in ${filepath}:`) || message.startsWith(`YAML Error in ${filepath}:`);
442
+ }
443
+ /**
444
+ * The file that declares an invalid `$import`, read from the message of the error that
445
+ * cosmiconfig's `validateImports` throws without `filepath`: `Self-import detected in <path>`,
446
+ * `<path>: Key $import must contain a string or a list of strings`, or `Circular import
447
+ * detected:` followed by the numbered files of the cycle, the last of which repeats an earlier
448
+ * one, so the one before the last declares the `$import` that closes it. When cosmiconfig is
449
+ * upgraded, compare these with its `ExplorerBase.validateImports`; a message that matches none
450
+ * leaves the error reported with the directory or file it was loaded for. The specs of these
451
+ * cases in `config-provider.spec.ts` run against the installed cosmiconfig, so a rewording fails
452
+ * them.
453
+ *
454
+ * @param message - The message of an error without `filepath`
455
+ * @returns The path, or `null` when the message is none of these
456
+ */
457
+ function importErrorPath(message) {
458
+ const self = /^Self-import detected in (.+)$/.exec(message);
459
+ if (self) {
460
+ return self[1];
461
+ }
462
+ const notString = /^(.+): Key \$import must contain a string or a list of strings$/.exec(message);
463
+ if (notString) {
464
+ return notString[1];
465
+ }
466
+ if (message.startsWith('Circular import detected:\n')) {
467
+ const entries = message.split('\n').slice(1);
468
+ return /^\d+\. (.+)$/.exec(entries.at(-2) ?? '')?.[1] ?? null;
469
+ }
470
+ return null;
471
+ }
472
+ /**
473
+ * The location in `filepath` at which `error` was thrown, for {@link cacheConfigError}.
474
+ *
475
+ * cosmiconfig imports the real path of a JavaScript configuration file, so its frames name the
476
+ * real path when `filepath` goes through a symbolic link; both are looked for.
477
+ *
478
+ * Reading `stack` can throw in Deno when source map resolution meets an invalid mapping (see
479
+ * `MLEngine`'s `#exec`), and `realpath` can fail if the file has gone since it was loaded. Either
480
+ * failure leaves the error named by its path alone; the error being reported is the one that
481
+ * matters, so neither is re-thrown and neither is guarded by {@link isFatalError}. Only those two
482
+ * are caught: a failure of {@link stackLocation} is markuplint's own.
483
+ *
484
+ * @param error - The error a loader threw
485
+ * @param filepath - The configuration file the loader was given
486
+ * @returns What {@link stackLocation} returns
487
+ */
488
+ async function locate(error, filepath) {
489
+ let stack;
490
+ try {
491
+ stack = error.stack ?? '';
492
+ }
493
+ catch (stackError) {
494
+ searchLog('Cannot read the stack of the error in %s: %O', filepath, stackError);
495
+ return '';
496
+ }
497
+ // The message heads the stack, and can name the file with a line of its own.
498
+ const header = `${error.name}: ${error.message}`;
499
+ const frames = stack.startsWith(header) ? stack.slice(header.length) : stack;
500
+ const realpath = await fs.realpath(filepath).catch(() => filepath);
501
+ return stackLocation(frames, [filepath, realpath]);
502
+ }
503
+ /**
504
+ * Finds the location nearest the top of `stack` that names one of `filePaths`, by its path (a
505
+ * CommonJS module) or by its `file:` URL (an ES module): a frame, `<path>:<line>:<column>`, or
506
+ * the header that Node.js puts above a `SyntaxError` in a CommonJS module, `<path>:<line>`. A
507
+ * `SyntaxError` in an ES module has neither. A Windows path is also looked for with forward
508
+ * slashes (`C:/p/…`), as a module runner such as Vitest names the module it evaluates.
509
+ *
510
+ * A path only matches as a whole, at the start of a line or after whitespace or `(`, so that
511
+ * another file whose path ends with the same characters does not.
512
+ *
513
+ * @param stack - The `stack` of an error
514
+ * @param filePaths - Absolute paths of the same file
515
+ * @returns `:<line>:<column>`, `:<line>` for a header, or an empty string when there is neither
516
+ */
517
+ export function stackLocation(stack, filePaths) {
518
+ const forms = [
519
+ ...new Set(filePaths.flatMap(filePath => [filePath, filePath.replaceAll('\\', '/'), pathToFileURL(filePath).href])),
520
+ ];
521
+ const location = new RegExp(String.raw `(?:^|[\s(])(?:${forms.map(form => RegExp.escape(form)).join('|')}):(\d+)(?::(\d+))?`, 'm');
522
+ const match = location.exec(stack);
523
+ if (!match) {
524
+ return '';
525
+ }
526
+ return match[2] == null ? `:${match[1]}` : `:${match[1]}:${match[2]}`;
527
+ }
@@ -1 +1,19 @@
1
+ /**
2
+ * Imports the module that a configuration names (a configuration to `extends`, a parser, a
3
+ * plugin, the data of `pretenders`) and returns what it exports: the default export when there is
4
+ * one, the namespace otherwise. Whatever cannot be imported, for whatever reason, yields `null`,
5
+ * as the callers report a missing module in their own terms; the reason goes to the debug log.
6
+ *
7
+ * A subpath of a package that its `exports` map does not expose is turned into the absolute path
8
+ * of the file first (see {@link resolvePackageSubpath}), as `import()` refuses it. A JSON module
9
+ * is taken by {@link importModule} through `require()`, so no `.json` file is read by hand: what
10
+ * `require()` takes as JSON, a byte order mark included, is what is accepted.
11
+ *
12
+ * The result is cached by specifier for the life of the process, `null` included; `import()`
13
+ * itself keeps a module for as long, so a fresh read is not to be had either way.
14
+ *
15
+ * @template T - What the module exports
16
+ * @param name - A bare specifier, with a subpath or not, or an absolute path
17
+ * @returns What the module exports, or `null` when it cannot be imported
18
+ */
1
19
  export declare function generalImport<T>(name: string): Promise<T | null>;
@@ -1,15 +1,33 @@
1
- import fs from 'node:fs/promises';
2
1
  import { createRequire } from 'node:module';
3
2
  import path from 'node:path';
4
3
  import { isFatalError } from '@markuplint/shared';
5
4
  import { resolve } from 'import-meta-resolve';
6
5
  import { log } from './debug.js';
7
- import { fromFileURL, toFileURL } from './path-utils.js';
6
+ import { importModule } from './import-module.js';
7
+ import { fromFileURL, isRelativePath, toFileURL } from './path-utils.js';
8
8
  const gLog = log.extend('general-import');
9
9
  const gLogSuccess = gLog.extend('success');
10
10
  const gLogError = gLog.extend('error');
11
11
  const cache = new Map();
12
12
  const require = createRequire(import.meta.url);
13
+ /**
14
+ * Imports the module that a configuration names (a configuration to `extends`, a parser, a
15
+ * plugin, the data of `pretenders`) and returns what it exports: the default export when there is
16
+ * one, the namespace otherwise. Whatever cannot be imported, for whatever reason, yields `null`,
17
+ * as the callers report a missing module in their own terms; the reason goes to the debug log.
18
+ *
19
+ * A subpath of a package that its `exports` map does not expose is turned into the absolute path
20
+ * of the file first (see {@link resolvePackageSubpath}), as `import()` refuses it. A JSON module
21
+ * is taken by {@link importModule} through `require()`, so no `.json` file is read by hand: what
22
+ * `require()` takes as JSON, a byte order mark included, is what is accepted.
23
+ *
24
+ * The result is cached by specifier for the life of the process, `null` included; `import()`
25
+ * itself keeps a module for as long, so a fresh read is not to be had either way.
26
+ *
27
+ * @template T - What the module exports
28
+ * @param name - A bare specifier, with a subpath or not, or an absolute path
29
+ * @returns What the module exports, or `null` when it cannot be imported
30
+ */
13
31
  export async function generalImport(name) {
14
32
  if (cache.has(name)) {
15
33
  return cache.get(name);
@@ -33,45 +51,21 @@ export async function generalImport(name) {
33
51
  if (importPath !== name) {
34
52
  gLog('Converted to file URL: %s', importPath);
35
53
  }
36
- const imported = await import(importPath);
37
- const mod = imported?.default ?? imported ?? null;
54
+ const imported = await importModule(importPath);
55
+ // A JSON module whose value is `null` is `null`, as a file with nothing in it; `??` would
56
+ // hand the namespace over instead.
57
+ const mod = (('default' in imported ? imported.default : imported) ?? null);
38
58
  gLogSuccess('Success by import("%s"): %O', importPath, mod);
39
59
  cache.set(name, mod);
40
60
  return mod;
41
61
  }
42
62
  catch (error) {
43
- // NOTE: `isFatalError()` is intentionally NOT applied at this catch
44
- // boundary. `await import()` / `require()` invoke third-party module
45
- // code, so any Tier-1-shaped error (TypeError / SyntaxError / etc.)
46
- // at this point may originate from inside the imported module and
47
- // not from markuplint's own code — we cannot distinguish the two.
48
- // The Tier 1 classification (see `isFatalError()` in
49
- // `@markuplint/shared`) only covers errors raised by markuplint's
50
- // own code, which excludes third-party import failures (e.g. Node 22+ removing
51
- // import assertion syntax, or bun's stricter ESM parser). Treating
52
- // every error as a recoverable null-return is the correct policy
53
- // here; callers (config / parser / plugin loaders) decide how to
54
- // surface the missing module.
55
- if (
56
- // @ts-ignore
57
- 'code' in error &&
58
- // @ts-ignore
59
- error.code === 'ERR_IMPORT_ASSERTION_TYPE_MISSING') {
60
- try {
61
- const mod = require(name) ?? null;
62
- gLogSuccess('Success by require("%s"): %O', name, mod);
63
- cache.set(name, mod);
64
- return mod;
65
- }
66
- catch (error) {
67
- if (error instanceof Error && /^parse failure/i.test(error.message)) {
68
- gLogError('Error in `createRequire(import.meta.url)()`: %O', error);
69
- cache.set(name, null);
70
- return null;
71
- }
72
- gLogError('Error in generalImport: %O', error);
73
- }
74
- }
63
+ // `isFatalError()` is intentionally NOT applied at this catch: `importModule()` runs
64
+ // `import()`, which evaluates third-party module code, so a Tier-1-shaped error here
65
+ // (TypeError / SyntaxError / etc.) may come from the imported module or from the runtime's
66
+ // loader rather than from markuplint's own code, and the two cannot be told apart. Every
67
+ // error is a recoverable `null`; the callers (config / parser / plugin loaders) decide how
68
+ // to surface the missing module.
75
69
  if (error instanceof Error) {
76
70
  const { filePath, packageName } = /Missing\s"(?<filePath>[^"]+)"\sspecifier\sin\s"(?<packageName>[^"]+)"\spackage/.exec(error.message)
77
71
  ?.groups ?? {};
@@ -86,18 +80,6 @@ export async function generalImport(name) {
86
80
  return result;
87
81
  }
88
82
  }
89
- if (path.isAbsolute(name) && name.endsWith('.json')) {
90
- try {
91
- const file = await fs.readFile(name, 'utf8');
92
- const json = JSON.parse(file);
93
- gLogSuccess('Success by readFile("%s") and JSON.parse: %O', name, json);
94
- cache.set(name, json);
95
- return json;
96
- }
97
- catch (readError) {
98
- gLogError('Error in readFile("%s"): %O', name, readError);
99
- }
100
- }
101
83
  gLogError('Error in `import()`: %O', error);
102
84
  cache.set(name, null);
103
85
  return null;
@@ -111,7 +93,7 @@ export async function generalImport(name) {
111
93
  * - The package cannot be found at all
112
94
  */
113
95
  function resolvePackageSubpath(name) {
114
- if (path.isAbsolute(name) || name.startsWith('.')) {
96
+ if (path.isAbsolute(name) || isRelativePath(name)) {
115
97
  return null;
116
98
  }
117
99
  const packageName = name.startsWith('@') ? name.split('/').slice(0, 2).join('/') : name.split('/')[0];