@evolu/nodejs 3.0.1 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/TestBundle.js +5 -4
- package/dist/src/TestJSDoc.d.ts +83 -0
- package/dist/src/TestJSDoc.d.ts.map +1 -0
- package/dist/src/TestJSDoc.js +452 -0
- package/dist/src/Worker.d.ts.map +1 -1
- package/dist/src/Worker.js +2 -0
- package/dist/src/local-first/Relay.d.ts.map +1 -1
- package/dist/src/local-first/Relay.js +2 -2
- package/package.json +37 -6
- package/src/TestBundle.ts +5 -4
- package/src/TestJSDoc.ts +820 -0
- package/src/Worker.ts +2 -0
- package/src/local-first/Relay.ts +9 -7
package/src/TestJSDoc.ts
ADDED
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities for testing TypeScript examples embedded in JSDoc and Markdown.
|
|
3
|
+
*
|
|
4
|
+
* These utilities use the dedicated `@evolu/nodejs/TestJSDoc` entry point so
|
|
5
|
+
* normal `@evolu/nodejs` imports do not evaluate TypeScript and JSDoc tooling.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { mapArray, type ReadonlyRecord } from "@evolu/common";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import {
|
|
14
|
+
globSync,
|
|
15
|
+
mkdirSync,
|
|
16
|
+
mkdtempSync,
|
|
17
|
+
readFileSync,
|
|
18
|
+
realpathSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
} from "node:fs";
|
|
22
|
+
import { findPackageJSON } from "node:module";
|
|
23
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
24
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
25
|
+
import { stripVTControlCharacters } from "node:util";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Options for linting, compiling, and executing TypeScript documentation
|
|
29
|
+
* examples.
|
|
30
|
+
*/
|
|
31
|
+
export interface TestJSDocExamplesOptions {
|
|
32
|
+
/** Source files or glob patterns resolved from `cwd`. */
|
|
33
|
+
readonly include: string | ReadonlyArray<string>;
|
|
34
|
+
/** Package imports redirected to absolute TypeScript entry paths. */
|
|
35
|
+
readonly aliases?: ReadonlyRecord<string, string>;
|
|
36
|
+
/** Directory used for globbing and package resolution. */
|
|
37
|
+
readonly cwd?: string;
|
|
38
|
+
/** TypeScript compiler package. Defaults to `"typescript"`. */
|
|
39
|
+
readonly typescriptPackage?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface JSDocExample {
|
|
43
|
+
readonly filePath: string;
|
|
44
|
+
readonly line: number;
|
|
45
|
+
readonly source: string;
|
|
46
|
+
readonly sourceLine: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface GeneratedJSDocExample extends JSDocExample {
|
|
50
|
+
readonly generatedPath: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface JSDocExampleRunnerResult {
|
|
54
|
+
readonly failures: ReadonlyArray<{
|
|
55
|
+
readonly index: number;
|
|
56
|
+
readonly message: string;
|
|
57
|
+
}>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface PackageWithBinary {
|
|
61
|
+
readonly bin?: string | Readonly<Record<string, string>>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface OxlintOutput {
|
|
65
|
+
readonly diagnostics: ReadonlyArray<{
|
|
66
|
+
readonly code?: string;
|
|
67
|
+
readonly filename: string;
|
|
68
|
+
readonly help?: string;
|
|
69
|
+
readonly labels: ReadonlyArray<{
|
|
70
|
+
readonly span: {
|
|
71
|
+
readonly line: number;
|
|
72
|
+
};
|
|
73
|
+
}>;
|
|
74
|
+
readonly message: string;
|
|
75
|
+
}>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const jsdocPattern = /\/\*\*[\s\S]*?\*\//gu;
|
|
79
|
+
const fencePattern = /(?:```|~~~)([^\n]*)\n([\s\S]*?)(?:(```|~~~)|$)/gu;
|
|
80
|
+
const markdownFilePattern = /\.mdx?$/iu;
|
|
81
|
+
const packageNamePattern = /^(?:@[a-z\d][a-z\d._~-]*\/)?[a-z\d][a-z\d._~-]*$/iu;
|
|
82
|
+
const packageSubpathSegmentPattern = /^[a-z\d][a-z\d._~-]*$/iu;
|
|
83
|
+
const polyfillsPath = fileURLToPath(
|
|
84
|
+
import.meta.resolve("@evolu/common/polyfills"),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Lints, compiles, and executes every TypeScript example in the included JSDoc
|
|
89
|
+
* comments and Markdown files.
|
|
90
|
+
*
|
|
91
|
+
* Examples are compiled together as isolated TypeScript modules. Examples
|
|
92
|
+
* without compilation errors are then imported in source order by one Node.js
|
|
93
|
+
* process. Lint, compilation, and execution failures are reported together.
|
|
94
|
+
* Examples are linted with `@evolu/oxlint-config`. Evolu's required polyfills
|
|
95
|
+
* are installed before each example runs.
|
|
96
|
+
*
|
|
97
|
+
* Install `@evolu/oxlint-config`, `@evolu/typescript-config`, `oxlint`,
|
|
98
|
+
* `oxlint-tsgolint`, and TypeScript as development dependencies in the project
|
|
99
|
+
* that calls this helper.
|
|
100
|
+
*
|
|
101
|
+
* Write every TypeScript fence as a standalone, deterministic example and
|
|
102
|
+
* explicitly import its dependencies and assertions. Use `assertType` to prove
|
|
103
|
+
* static contracts, `assertEqual` for Data comparisons, `assert` with a
|
|
104
|
+
* descriptive message for invariants and narrowing, and `assertOk` or
|
|
105
|
+
* `assertErr` for Results. Prefix intentionally unused declarations with `_`;
|
|
106
|
+
* an underscore-prefixed declaration must remain unused. Package aliases are
|
|
107
|
+
* useful for examples documenting an entry point that is not exported yet.
|
|
108
|
+
* Package subpaths can be aliased independently. Each alias target must be an
|
|
109
|
+
* absolute TypeScript module path exposing the named exports used by the
|
|
110
|
+
* example.
|
|
111
|
+
*
|
|
112
|
+
* ### Example
|
|
113
|
+
*
|
|
114
|
+
* ```ts
|
|
115
|
+
* import { testJSDocExamples } from "@evolu/nodejs/TestJSDoc";
|
|
116
|
+
* import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
117
|
+
* import { tmpdir } from "node:os";
|
|
118
|
+
* import { join } from "node:path";
|
|
119
|
+
*
|
|
120
|
+
* const directory = await mkdtemp(join(tmpdir(), "evolu-jsdoc-example-"));
|
|
121
|
+
* try {
|
|
122
|
+
* const sourcePath = join(directory, "Example.ts");
|
|
123
|
+
* await writeFile(
|
|
124
|
+
* sourcePath,
|
|
125
|
+
* [
|
|
126
|
+
* "/**",
|
|
127
|
+
* " * ``" + "`ts",
|
|
128
|
+
* ' * import { assertEqual } from "@evolu/common";',
|
|
129
|
+
* " *",
|
|
130
|
+
* " * assertEqual(1 + 1, 2);",
|
|
131
|
+
* " * ``" + "`",
|
|
132
|
+
* " *" + "/",
|
|
133
|
+
* "export {};",
|
|
134
|
+
* ].join("\n"),
|
|
135
|
+
* );
|
|
136
|
+
* await testJSDocExamples({
|
|
137
|
+
* cwd: process.cwd(),
|
|
138
|
+
* include: sourcePath,
|
|
139
|
+
* });
|
|
140
|
+
* } finally {
|
|
141
|
+
* await rm(directory, { force: true, recursive: true });
|
|
142
|
+
* }
|
|
143
|
+
* ```
|
|
144
|
+
*/
|
|
145
|
+
export const testJSDocExamples = async ({
|
|
146
|
+
include,
|
|
147
|
+
aliases = {},
|
|
148
|
+
cwd = process.cwd(),
|
|
149
|
+
typescriptPackage = "typescript",
|
|
150
|
+
}: TestJSDocExamplesOptions): Promise<void> => {
|
|
151
|
+
const workingDirectory = realpathSync(resolve(cwd));
|
|
152
|
+
const patterns = typeof include === "string" ? [include] : include;
|
|
153
|
+
assert(
|
|
154
|
+
patterns.length > 0,
|
|
155
|
+
"Documentation example tests require included files.",
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const filePaths = Array.from(
|
|
159
|
+
new Set(
|
|
160
|
+
globSync(patterns, { cwd: workingDirectory }).map((filePath) =>
|
|
161
|
+
resolve(workingDirectory, filePath),
|
|
162
|
+
),
|
|
163
|
+
),
|
|
164
|
+
).toSorted();
|
|
165
|
+
assert(
|
|
166
|
+
filePaths.length > 0,
|
|
167
|
+
"No files matched the documentation example patterns.",
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const examples = filePaths.flatMap((filePath) =>
|
|
171
|
+
extractDocumentationExamples(readFileSync(filePath, "utf8"), filePath),
|
|
172
|
+
);
|
|
173
|
+
assert(
|
|
174
|
+
examples.length > 0,
|
|
175
|
+
"No TypeScript documentation examples were found.",
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const compilerPath = resolveTypeScriptCompiler(
|
|
179
|
+
workingDirectory,
|
|
180
|
+
typescriptPackage,
|
|
181
|
+
);
|
|
182
|
+
const typescriptConfigPath = resolve(
|
|
183
|
+
dirname(resolvePackageJSON(workingDirectory, "@evolu/typescript-config")),
|
|
184
|
+
"base.json",
|
|
185
|
+
);
|
|
186
|
+
const temporaryRoot = join(workingDirectory, "tmp");
|
|
187
|
+
mkdirSync(temporaryRoot, { recursive: true });
|
|
188
|
+
|
|
189
|
+
const temporaryDirectory = mkdtempSync(join(temporaryRoot, "evolu-jsdoc-"));
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
writeFileSync(
|
|
193
|
+
join(temporaryDirectory, "package.json"),
|
|
194
|
+
JSON.stringify({ type: "module" }),
|
|
195
|
+
);
|
|
196
|
+
createPackageAliases(temporaryDirectory, aliases);
|
|
197
|
+
|
|
198
|
+
const generatedExamples = examples.map((example, index) => {
|
|
199
|
+
const generatedPath = join(temporaryDirectory, `example-${index}.ts`);
|
|
200
|
+
writeFileSync(
|
|
201
|
+
generatedPath,
|
|
202
|
+
transformJSDocExample(example, generatedPath),
|
|
203
|
+
);
|
|
204
|
+
return { ...example, generatedPath } satisfies GeneratedJSDocExample;
|
|
205
|
+
});
|
|
206
|
+
const compilerConfigPath = join(temporaryDirectory, "tsconfig.json");
|
|
207
|
+
writeFileSync(
|
|
208
|
+
compilerConfigPath,
|
|
209
|
+
JSON.stringify({
|
|
210
|
+
extends: typescriptConfigPath,
|
|
211
|
+
compilerOptions: {
|
|
212
|
+
allowImportingTsExtensions: true,
|
|
213
|
+
composite: false,
|
|
214
|
+
declaration: false,
|
|
215
|
+
declarationMap: false,
|
|
216
|
+
incremental: false,
|
|
217
|
+
lib: ["dom", "esnext"],
|
|
218
|
+
module: "NodeNext",
|
|
219
|
+
noEmit: true,
|
|
220
|
+
noUnusedLocals: false,
|
|
221
|
+
noUnusedParameters: false,
|
|
222
|
+
target: "es2022",
|
|
223
|
+
types: ["node"],
|
|
224
|
+
},
|
|
225
|
+
files: generatedExamples.map(({ generatedPath }) => generatedPath),
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
let lintError: Error | undefined;
|
|
230
|
+
try {
|
|
231
|
+
await lintJSDocExamples(
|
|
232
|
+
generatedExamples,
|
|
233
|
+
compilerConfigPath,
|
|
234
|
+
workingDirectory,
|
|
235
|
+
);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
assert(error instanceof Error, "Expected Oxlint to fail with an Error.");
|
|
238
|
+
lintError = error;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let compilationError: Error | undefined;
|
|
242
|
+
try {
|
|
243
|
+
await runProcess(
|
|
244
|
+
process.execPath,
|
|
245
|
+
[compilerPath, "--project", compilerConfigPath],
|
|
246
|
+
workingDirectory,
|
|
247
|
+
"Documentation example TypeScript compilation",
|
|
248
|
+
{
|
|
249
|
+
details: generatedExamplesToDetails(
|
|
250
|
+
generatedExamples,
|
|
251
|
+
temporaryDirectory,
|
|
252
|
+
workingDirectory,
|
|
253
|
+
),
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
assert(
|
|
258
|
+
error instanceof Error,
|
|
259
|
+
"Expected TypeScript compilation to fail with an Error.",
|
|
260
|
+
);
|
|
261
|
+
compilationError = error;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const runnableExamples =
|
|
265
|
+
compilationError === undefined
|
|
266
|
+
? generatedExamples
|
|
267
|
+
: getExamplesWithoutCompilationErrors(
|
|
268
|
+
generatedExamples,
|
|
269
|
+
compilationError,
|
|
270
|
+
workingDirectory,
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
let executionError: Error | undefined;
|
|
274
|
+
try {
|
|
275
|
+
await runJSDocExamples(
|
|
276
|
+
runnableExamples,
|
|
277
|
+
temporaryDirectory,
|
|
278
|
+
workingDirectory,
|
|
279
|
+
);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
assert(
|
|
282
|
+
error instanceof Error,
|
|
283
|
+
"Expected example execution to fail with an Error.",
|
|
284
|
+
);
|
|
285
|
+
executionError = error;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const failures: Array<Error> = [];
|
|
289
|
+
if (lintError !== undefined) failures.push(lintError);
|
|
290
|
+
if (compilationError !== undefined) failures.push(compilationError);
|
|
291
|
+
if (executionError !== undefined) failures.push(executionError);
|
|
292
|
+
|
|
293
|
+
const firstFailure = failures[0];
|
|
294
|
+
if (failures.length === 1 && firstFailure !== undefined) {
|
|
295
|
+
throw firstFailure;
|
|
296
|
+
}
|
|
297
|
+
if (failures.length > 1) {
|
|
298
|
+
throw new AggregateError(
|
|
299
|
+
failures.flatMap((failure) =>
|
|
300
|
+
failure instanceof AggregateError
|
|
301
|
+
? (failure.errors as ReadonlyArray<unknown>)
|
|
302
|
+
: [failure],
|
|
303
|
+
),
|
|
304
|
+
mapArray(failures, ({ message }) => message).join("\n"),
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
} finally {
|
|
308
|
+
rmSync(temporaryDirectory, { force: true, recursive: true });
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const lintJSDocExamples = async (
|
|
313
|
+
examples: ReadonlyArray<GeneratedJSDocExample>,
|
|
314
|
+
compilerConfigPath: string,
|
|
315
|
+
workingDirectory: string,
|
|
316
|
+
): Promise<void> => {
|
|
317
|
+
const oxlintPath = resolvePackageBinary(workingDirectory, "oxlint", "oxlint");
|
|
318
|
+
const tsgolintPath = resolvePackageBinary(
|
|
319
|
+
workingDirectory,
|
|
320
|
+
"oxlint-tsgolint",
|
|
321
|
+
"tsgolint",
|
|
322
|
+
);
|
|
323
|
+
const sharedOxlintConfigPath = resolve(
|
|
324
|
+
dirname(resolvePackageJSON(workingDirectory, "@evolu/oxlint-config")),
|
|
325
|
+
"config.jsonc",
|
|
326
|
+
);
|
|
327
|
+
const oxlintConfigPath = join(dirname(compilerConfigPath), "oxlint.json");
|
|
328
|
+
writeFileSync(
|
|
329
|
+
oxlintConfigPath,
|
|
330
|
+
JSON.stringify({
|
|
331
|
+
extends: [sharedOxlintConfigPath],
|
|
332
|
+
overrides: [
|
|
333
|
+
{
|
|
334
|
+
files: ["**/*.{ts,tsx,mts}"],
|
|
335
|
+
rules: {
|
|
336
|
+
"eslint/no-unused-vars": [
|
|
337
|
+
"error",
|
|
338
|
+
{
|
|
339
|
+
args: "all",
|
|
340
|
+
argsIgnorePattern: "^_",
|
|
341
|
+
caughtErrors: "all",
|
|
342
|
+
caughtErrorsIgnorePattern: "^_",
|
|
343
|
+
destructuredArrayIgnorePattern: "^_",
|
|
344
|
+
reportUsedIgnorePattern: true,
|
|
345
|
+
varsIgnorePattern: "^_",
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
],
|
|
351
|
+
}),
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
await runProcess(
|
|
355
|
+
process.execPath,
|
|
356
|
+
[
|
|
357
|
+
oxlintPath,
|
|
358
|
+
"--config",
|
|
359
|
+
oxlintConfigPath,
|
|
360
|
+
"--deny-warnings",
|
|
361
|
+
"--format",
|
|
362
|
+
"json",
|
|
363
|
+
"--tsconfig",
|
|
364
|
+
compilerConfigPath,
|
|
365
|
+
"--report-unused-disable-directives-severity=error",
|
|
366
|
+
...mapArray(examples, ({ generatedPath }) => generatedPath),
|
|
367
|
+
],
|
|
368
|
+
workingDirectory,
|
|
369
|
+
"Documentation example Oxlint",
|
|
370
|
+
{
|
|
371
|
+
environment: { OXLINT_TSGOLINT_PATH: tsgolintPath },
|
|
372
|
+
exitErrorFromOutput: (stdout, stderr) =>
|
|
373
|
+
oxlintOutputToError(stdout, stderr, examples, workingDirectory),
|
|
374
|
+
},
|
|
375
|
+
);
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const oxlintOutputToError = (
|
|
379
|
+
stdout: string,
|
|
380
|
+
stderr: string,
|
|
381
|
+
examples: ReadonlyArray<GeneratedJSDocExample>,
|
|
382
|
+
workingDirectory: string,
|
|
383
|
+
): Error => {
|
|
384
|
+
const { diagnostics } = JSON.parse(stdout) as OxlintOutput;
|
|
385
|
+
if (diagnostics.length === 0) {
|
|
386
|
+
return new Error(
|
|
387
|
+
["Documentation example Oxlint failed.", stdout, stderr]
|
|
388
|
+
.filter(Boolean)
|
|
389
|
+
.join("\n"),
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const examplesByGeneratedPath = new Map(
|
|
394
|
+
mapArray(examples, (example) => [example.generatedPath, example] as const),
|
|
395
|
+
);
|
|
396
|
+
const errors = diagnostics
|
|
397
|
+
.map((diagnostic) => {
|
|
398
|
+
const example = examplesByGeneratedPath.get(
|
|
399
|
+
resolve(workingDirectory, diagnostic.filename),
|
|
400
|
+
);
|
|
401
|
+
assert(
|
|
402
|
+
example !== undefined,
|
|
403
|
+
`Oxlint reported an unknown generated example: ${diagnostic.filename}.`,
|
|
404
|
+
);
|
|
405
|
+
const label = diagnostic.labels[0];
|
|
406
|
+
assert(
|
|
407
|
+
label !== undefined,
|
|
408
|
+
`Oxlint did not report a source location for ${diagnostic.filename}.`,
|
|
409
|
+
);
|
|
410
|
+
const line = example.sourceLine + label.span.line - 5;
|
|
411
|
+
const filePath = relative(workingDirectory, example.filePath);
|
|
412
|
+
const code = diagnostic.code ?? "";
|
|
413
|
+
return {
|
|
414
|
+
code,
|
|
415
|
+
filePath,
|
|
416
|
+
line,
|
|
417
|
+
message: `${filePath}:${line}: ${code === "" ? "" : `${code}: `}${diagnostic.message}${diagnostic.help === undefined ? "" : ` ${diagnostic.help}`}`,
|
|
418
|
+
};
|
|
419
|
+
})
|
|
420
|
+
.toSorted(
|
|
421
|
+
(first, second) =>
|
|
422
|
+
first.filePath.localeCompare(second.filePath) ||
|
|
423
|
+
first.line - second.line ||
|
|
424
|
+
first.code.localeCompare(second.code),
|
|
425
|
+
)
|
|
426
|
+
.map(({ message }) => new Error(message));
|
|
427
|
+
|
|
428
|
+
return new AggregateError(
|
|
429
|
+
errors,
|
|
430
|
+
[
|
|
431
|
+
"Documentation example Oxlint failed.",
|
|
432
|
+
...mapArray(errors, ({ message }) => `- ${message}`),
|
|
433
|
+
].join("\n"),
|
|
434
|
+
);
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const generatedExamplesToDetails = (
|
|
438
|
+
examples: ReadonlyArray<GeneratedJSDocExample>,
|
|
439
|
+
temporaryDirectory: string,
|
|
440
|
+
workingDirectory: string,
|
|
441
|
+
): string =>
|
|
442
|
+
[
|
|
443
|
+
"Generated example sources:",
|
|
444
|
+
...mapArray(
|
|
445
|
+
examples,
|
|
446
|
+
({ filePath, generatedPath, line }) =>
|
|
447
|
+
`- ${relative(temporaryDirectory, generatedPath)}: ${relative(workingDirectory, filePath)}:${line}`,
|
|
448
|
+
),
|
|
449
|
+
].join("\n");
|
|
450
|
+
|
|
451
|
+
const getExamplesWithoutCompilationErrors = (
|
|
452
|
+
examples: ReadonlyArray<GeneratedJSDocExample>,
|
|
453
|
+
compilationError: Error,
|
|
454
|
+
workingDirectory: string,
|
|
455
|
+
): ReadonlyArray<GeneratedJSDocExample> => {
|
|
456
|
+
const compilationErrorMessage = stripVTControlCharacters(
|
|
457
|
+
compilationError.message,
|
|
458
|
+
);
|
|
459
|
+
const examplesWithErrors = examples.filter(({ generatedPath }) =>
|
|
460
|
+
[generatedPath, relative(workingDirectory, generatedPath)].some((path) =>
|
|
461
|
+
[`${path}(`, `${path}:`].some((segment) =>
|
|
462
|
+
compilationErrorMessage.includes(segment),
|
|
463
|
+
),
|
|
464
|
+
),
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
if (examplesWithErrors.length === 0) return [];
|
|
468
|
+
|
|
469
|
+
return examples.filter((example) => !examplesWithErrors.includes(example));
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const extractDocumentationExamples = (
|
|
473
|
+
source: string,
|
|
474
|
+
filePath: string,
|
|
475
|
+
): ReadonlyArray<JSDocExample> => {
|
|
476
|
+
if (markdownFilePattern.test(filePath)) {
|
|
477
|
+
return extractFencedExamples(source, source, filePath, 0, false);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const examples: Array<JSDocExample> = [];
|
|
481
|
+
for (const jsdoc of source.matchAll(jsdocPattern)) {
|
|
482
|
+
examples.push(
|
|
483
|
+
...extractFencedExamples(source, jsdoc[0], filePath, jsdoc.index, true),
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return examples;
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
const extractFencedExamples = (
|
|
491
|
+
source: string,
|
|
492
|
+
fencedSource: string,
|
|
493
|
+
filePath: string,
|
|
494
|
+
offset: number,
|
|
495
|
+
stripJSDocPrefixes: boolean,
|
|
496
|
+
): ReadonlyArray<JSDocExample> => {
|
|
497
|
+
const examples: Array<JSDocExample> = [];
|
|
498
|
+
|
|
499
|
+
for (const fence of fencedSource.matchAll(fencePattern)) {
|
|
500
|
+
const metadata = fence[1].trim().toLowerCase().split(/\s+/u);
|
|
501
|
+
if (!["ts", "typescript"].includes(metadata[0])) {
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
const line = getLineNumber(source, offset + fence.index);
|
|
505
|
+
const closingFence = fence[3] as string | undefined;
|
|
506
|
+
assert(
|
|
507
|
+
closingFence !== undefined,
|
|
508
|
+
`${filePath}:${line} has an unclosed TypeScript example fence.`,
|
|
509
|
+
);
|
|
510
|
+
const untrimmedExampleSource = stripJSDocPrefixes
|
|
511
|
+
? fence[2].replaceAll(/^[ \t]*\* ?/gmu, "")
|
|
512
|
+
: fence[2];
|
|
513
|
+
const exampleSource = untrimmedExampleSource.trim();
|
|
514
|
+
assert(
|
|
515
|
+
exampleSource.length > 0,
|
|
516
|
+
`${filePath}:${line} has an empty TypeScript example.`,
|
|
517
|
+
);
|
|
518
|
+
const sourceLine =
|
|
519
|
+
line +
|
|
520
|
+
getLineNumber(
|
|
521
|
+
untrimmedExampleSource,
|
|
522
|
+
untrimmedExampleSource.indexOf(exampleSource),
|
|
523
|
+
);
|
|
524
|
+
examples.push({ filePath, line, source: exampleSource, sourceLine });
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return examples;
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
const getLineNumber = (source: string, offset: number): number => {
|
|
531
|
+
let line = 1;
|
|
532
|
+
for (let index = 0; index < offset; index++) {
|
|
533
|
+
if (source.charCodeAt(index) === 10) line++;
|
|
534
|
+
}
|
|
535
|
+
return line;
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const transformJSDocExample = (
|
|
539
|
+
example: JSDocExample,
|
|
540
|
+
generatedPath: string,
|
|
541
|
+
): string =>
|
|
542
|
+
[
|
|
543
|
+
`import { installPolyfills } from ${JSON.stringify(pathToImportSpecifier(generatedPath, polyfillsPath))};`,
|
|
544
|
+
"installPolyfills();",
|
|
545
|
+
example.source,
|
|
546
|
+
].join("\n\n");
|
|
547
|
+
|
|
548
|
+
const resolveTypeScriptCompiler = (
|
|
549
|
+
workingDirectory: string,
|
|
550
|
+
typescriptPackage: string,
|
|
551
|
+
): string => resolvePackageBinary(workingDirectory, typescriptPackage, "tsc");
|
|
552
|
+
|
|
553
|
+
const resolvePackageBinary = (
|
|
554
|
+
workingDirectory: string,
|
|
555
|
+
packageName: string,
|
|
556
|
+
binaryName: string,
|
|
557
|
+
): string => {
|
|
558
|
+
const packagePath = resolvePackageJSON(workingDirectory, packageName);
|
|
559
|
+
const packageJson = JSON.parse(
|
|
560
|
+
readFileSync(packagePath, "utf8"),
|
|
561
|
+
) as PackageWithBinary;
|
|
562
|
+
const namedBinaries =
|
|
563
|
+
typeof packageJson.bin === "object" ? Object.values(packageJson.bin) : [];
|
|
564
|
+
const binary =
|
|
565
|
+
typeof packageJson.bin === "string"
|
|
566
|
+
? packageJson.bin
|
|
567
|
+
: (packageJson.bin?.[binaryName] ??
|
|
568
|
+
(namedBinaries.length === 1 ? namedBinaries[0] : undefined));
|
|
569
|
+
assert(
|
|
570
|
+
binary !== undefined,
|
|
571
|
+
`${packageName} does not expose a ${binaryName} executable.`,
|
|
572
|
+
);
|
|
573
|
+
return resolve(dirname(packagePath), binary);
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
const resolvePackageJSON = (
|
|
577
|
+
workingDirectory: string,
|
|
578
|
+
packageName: string,
|
|
579
|
+
): string => {
|
|
580
|
+
const packagePath = findPackageJSON(
|
|
581
|
+
packageName,
|
|
582
|
+
pathToFileURL(join(workingDirectory, "package.json")),
|
|
583
|
+
);
|
|
584
|
+
assert(
|
|
585
|
+
packagePath !== undefined,
|
|
586
|
+
`Cannot resolve ${packageName} from ${workingDirectory}.`,
|
|
587
|
+
);
|
|
588
|
+
return packagePath;
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
const runJSDocExamples = async (
|
|
592
|
+
examples: ReadonlyArray<GeneratedJSDocExample>,
|
|
593
|
+
temporaryDirectory: string,
|
|
594
|
+
workingDirectory: string,
|
|
595
|
+
): Promise<void> => {
|
|
596
|
+
if (examples.length === 0) return;
|
|
597
|
+
|
|
598
|
+
const runnerPath = join(temporaryDirectory, "run-examples.mjs");
|
|
599
|
+
const resultPath = join(temporaryDirectory, "run-examples-result.json");
|
|
600
|
+
writeFileSync(
|
|
601
|
+
runnerPath,
|
|
602
|
+
[
|
|
603
|
+
'import { writeFileSync } from "node:fs";',
|
|
604
|
+
"",
|
|
605
|
+
`const examples = ${JSON.stringify(
|
|
606
|
+
mapArray(
|
|
607
|
+
examples,
|
|
608
|
+
({ generatedPath }) => pathToFileURL(generatedPath).href,
|
|
609
|
+
),
|
|
610
|
+
)};`,
|
|
611
|
+
"const failures = [];",
|
|
612
|
+
"for (const [index, example] of examples.entries()) {",
|
|
613
|
+
" try {",
|
|
614
|
+
" await import(example);",
|
|
615
|
+
" } catch (error) {",
|
|
616
|
+
" failures.push({",
|
|
617
|
+
" index,",
|
|
618
|
+
" message: error instanceof Error ? error.message : String(error),",
|
|
619
|
+
" });",
|
|
620
|
+
" }",
|
|
621
|
+
"}",
|
|
622
|
+
`writeFileSync(${JSON.stringify(resultPath)}, JSON.stringify({ failures }));`,
|
|
623
|
+
"",
|
|
624
|
+
].join("\n"),
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
await runProcess(
|
|
628
|
+
process.execPath,
|
|
629
|
+
[runnerPath],
|
|
630
|
+
workingDirectory,
|
|
631
|
+
"Node.js execution",
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
const { failures } = JSON.parse(
|
|
635
|
+
readFileSync(resultPath, "utf8"),
|
|
636
|
+
) as JSDocExampleRunnerResult;
|
|
637
|
+
if (failures.length === 0) return;
|
|
638
|
+
|
|
639
|
+
const errors = mapArray(failures, ({ index, message }) => {
|
|
640
|
+
const example = examples[index];
|
|
641
|
+
assert(
|
|
642
|
+
example !== undefined,
|
|
643
|
+
`Expected generated example at index ${index}.`,
|
|
644
|
+
);
|
|
645
|
+
return new Error(
|
|
646
|
+
`${relative(workingDirectory, example.filePath)}:${example.line}: ${message}`,
|
|
647
|
+
);
|
|
648
|
+
});
|
|
649
|
+
throw new AggregateError(
|
|
650
|
+
errors,
|
|
651
|
+
[
|
|
652
|
+
"JSDoc example execution failed.",
|
|
653
|
+
...mapArray(errors, (error) => `- ${error.message}`),
|
|
654
|
+
].join("\n"),
|
|
655
|
+
);
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
const createPackageAliases = (
|
|
659
|
+
temporaryDirectory: string,
|
|
660
|
+
aliases: ReadonlyRecord<string, string>,
|
|
661
|
+
): void => {
|
|
662
|
+
const aliasesByPackageName = new Map<
|
|
663
|
+
string,
|
|
664
|
+
Array<{
|
|
665
|
+
readonly subpath: string;
|
|
666
|
+
readonly targetPath: string;
|
|
667
|
+
}>
|
|
668
|
+
>();
|
|
669
|
+
|
|
670
|
+
for (const [name, target] of Object.entries(aliases)) {
|
|
671
|
+
assert(isAbsolute(target), `Package alias ${name} must be absolute.`);
|
|
672
|
+
const segments = name.split("/");
|
|
673
|
+
const packageSegmentCount = name.startsWith("@") ? 2 : 1;
|
|
674
|
+
const packageName = segments.slice(0, packageSegmentCount).join("/");
|
|
675
|
+
const subpathSegments = segments.slice(packageSegmentCount);
|
|
676
|
+
assert(
|
|
677
|
+
packageNamePattern.test(packageName) &&
|
|
678
|
+
subpathSegments.every((segment) =>
|
|
679
|
+
packageSubpathSegmentPattern.test(segment),
|
|
680
|
+
),
|
|
681
|
+
`Invalid package alias: ${name}.`,
|
|
682
|
+
);
|
|
683
|
+
const subpath = subpathSegments.join("/");
|
|
684
|
+
const packageAliases = aliasesByPackageName.get(packageName) ?? [];
|
|
685
|
+
packageAliases.push({ subpath, targetPath: realpathSync(target) });
|
|
686
|
+
aliasesByPackageName.set(packageName, packageAliases);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
for (const [packageName, packageAliases] of aliasesByPackageName) {
|
|
690
|
+
const packageSegments = packageName.split("/");
|
|
691
|
+
const packageDirectory = join(
|
|
692
|
+
temporaryDirectory,
|
|
693
|
+
"node_modules",
|
|
694
|
+
...packageSegments,
|
|
695
|
+
);
|
|
696
|
+
mkdirSync(packageDirectory, { recursive: true });
|
|
697
|
+
const exportsBySubpath: Record<
|
|
698
|
+
string,
|
|
699
|
+
{ readonly types: string; readonly default: string }
|
|
700
|
+
> = {};
|
|
701
|
+
|
|
702
|
+
for (const { subpath, targetPath } of packageAliases) {
|
|
703
|
+
const entryPathWithoutExtension = subpath || "index";
|
|
704
|
+
const exportName = subpath ? `./${subpath}` : ".";
|
|
705
|
+
exportsBySubpath[exportName] = {
|
|
706
|
+
types: `./${entryPathWithoutExtension}.ts`,
|
|
707
|
+
default: `./${entryPathWithoutExtension}.js`,
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
for (const extension of ["ts", "js"]) {
|
|
711
|
+
const entryPath = join(
|
|
712
|
+
packageDirectory,
|
|
713
|
+
`${entryPathWithoutExtension}.${extension}`,
|
|
714
|
+
);
|
|
715
|
+
mkdirSync(dirname(entryPath), { recursive: true });
|
|
716
|
+
writeFileSync(
|
|
717
|
+
entryPath,
|
|
718
|
+
`export * from ${JSON.stringify(pathToImportSpecifier(entryPath, targetPath))};\n`,
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
writeFileSync(
|
|
724
|
+
join(packageDirectory, "package.json"),
|
|
725
|
+
JSON.stringify({
|
|
726
|
+
name: packageName,
|
|
727
|
+
type: "module",
|
|
728
|
+
exports: exportsBySubpath,
|
|
729
|
+
}),
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
const pathToImportSpecifier = (from: string, to: string): string => {
|
|
735
|
+
const path = relative(dirname(from), to).split(sep).join("/");
|
|
736
|
+
return `./${path}`;
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
const runProcess = (
|
|
740
|
+
command: string,
|
|
741
|
+
args: ReadonlyArray<string>,
|
|
742
|
+
cwd: string,
|
|
743
|
+
operation: string,
|
|
744
|
+
{
|
|
745
|
+
details,
|
|
746
|
+
environment,
|
|
747
|
+
exitErrorFromOutput,
|
|
748
|
+
}: {
|
|
749
|
+
readonly details?: string;
|
|
750
|
+
readonly environment?: ReadonlyRecord<string, string>;
|
|
751
|
+
readonly exitErrorFromOutput?: (stdout: string, stderr: string) => Error;
|
|
752
|
+
} = {},
|
|
753
|
+
): Promise<void> =>
|
|
754
|
+
new Promise((resolve, reject) => {
|
|
755
|
+
const child = spawn(command, args, {
|
|
756
|
+
cwd,
|
|
757
|
+
env:
|
|
758
|
+
environment === undefined
|
|
759
|
+
? process.env
|
|
760
|
+
: { ...process.env, ...environment },
|
|
761
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
762
|
+
});
|
|
763
|
+
const stdout: Array<Buffer> = [];
|
|
764
|
+
const stderr: Array<Buffer> = [];
|
|
765
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
766
|
+
stdout.push(chunk);
|
|
767
|
+
});
|
|
768
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
769
|
+
stderr.push(chunk);
|
|
770
|
+
});
|
|
771
|
+
child.once("error", (error) => {
|
|
772
|
+
reject(
|
|
773
|
+
new Error(
|
|
774
|
+
[`${operation} failed.`, details].filter(Boolean).join("\n"),
|
|
775
|
+
{
|
|
776
|
+
cause: error,
|
|
777
|
+
},
|
|
778
|
+
),
|
|
779
|
+
);
|
|
780
|
+
});
|
|
781
|
+
child.once("close", (code, signal) => {
|
|
782
|
+
if (code === 0) {
|
|
783
|
+
resolve();
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
const stdoutText = Buffer.concat(stdout).toString("utf8");
|
|
787
|
+
const stderrText = Buffer.concat(stderr).toString("utf8");
|
|
788
|
+
if (exitErrorFromOutput !== undefined) {
|
|
789
|
+
try {
|
|
790
|
+
reject(exitErrorFromOutput(stdoutText, stderrText));
|
|
791
|
+
} catch (error) {
|
|
792
|
+
reject(
|
|
793
|
+
new Error(
|
|
794
|
+
[
|
|
795
|
+
`${operation} failed while reading its output.`,
|
|
796
|
+
stdoutText,
|
|
797
|
+
stderrText,
|
|
798
|
+
]
|
|
799
|
+
.filter(Boolean)
|
|
800
|
+
.join("\n"),
|
|
801
|
+
{ cause: error },
|
|
802
|
+
),
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
reject(
|
|
808
|
+
new Error(
|
|
809
|
+
[
|
|
810
|
+
`${operation} failed${signal === null ? ` with exit code ${String(code)}` : ` from signal ${signal}`}.`,
|
|
811
|
+
details,
|
|
812
|
+
stdoutText,
|
|
813
|
+
stderrText,
|
|
814
|
+
]
|
|
815
|
+
.filter(Boolean)
|
|
816
|
+
.join("\n"),
|
|
817
|
+
),
|
|
818
|
+
);
|
|
819
|
+
});
|
|
820
|
+
});
|