@tsdoctor/vfs 0.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/LICENSE +21 -0
- package/README.md +99 -0
- package/TsEnvironment.js +95 -0
- package/TsconfigParser.js +98 -0
- package/TypeResolutionOptions.js +98 -0
- package/TypeScriptConfig.js +226 -0
- package/Vfs.js +47 -0
- package/VirtualPackage.js +126 -0
- package/index.d.ts +505 -0
- package/index.js +8 -0
- package/package.json +56 -0
- package/tsdoc-metadata.json +11 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 C. Spencer Beggs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# @tsdoctor/vfs
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@tsdoctor/vfs)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
|
|
8
|
+
The virtual TypeScript project behind a documentation build: the `Vfs` currency type, declaration-backed virtual packages, `@typescript/vfs` environments over them, and the compiler-option resolution that decides how code in those environments type-checks.
|
|
9
|
+
|
|
10
|
+
## Why @tsdoctor/vfs
|
|
11
|
+
|
|
12
|
+
Tooling that type-checks code samples needs a TypeScript project that never touches disk — a map of paths to sources, a synthetic `package.json` so module resolution behaves like the real thing, and a language service built over both. Two independent concerns need exactly that substrate: fetching third-party declarations from a registry, and reconstructing declarations from an API model. This package is the substrate they share, so neither has to depend on the other.
|
|
13
|
+
|
|
14
|
+
It owns the compiler options too, and that pairing is deliberate. Options arrive in two spellings: the tsconfig one a user writes (`target: "es2022"`), and the programmatic one the compiler takes (`ts.ScriptTarget.ES2022`). A build that converts between them in more than one place will eventually disagree with itself. Here the conversion happens once, options a documentation tool cannot act on are rejected at the boundary instead of cast through it, and the environment that consumes them lives next door.
|
|
15
|
+
|
|
16
|
+
`@tsdoctor/registry` builds on it to resolve external package types; `@tsdoctor/model` builds on it to turn an API Extractor model into a virtual package.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @tsdoctor/vfs effect @effected/tsconfig-json
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pnpm add @tsdoctor/vfs effect @effected/tsconfig-json
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Requires Node.js >=24.11.0. This is an ESM-only package. `effect` and `@effected/tsconfig-json` are required peers — the compiler-option schemas are built from the kit's own field definitions, so they load with the entry point. `typescript` and `@typescript/vfs` are optional and needed only by `TsEnvironment.make`, which imports them lazily:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# for TsEnvironment.make
|
|
32
|
+
npm install typescript @typescript/vfs
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
A consumer that only builds and merges VFS maps never loads the compiler.
|
|
36
|
+
|
|
37
|
+
## What you get
|
|
38
|
+
|
|
39
|
+
### The VFS substrate
|
|
40
|
+
|
|
41
|
+
- **`Vfs`** — the currency type: a `Map<string, string>` of `node_modules/`-prefixed paths to file contents. Every loading operation produces one and every TypeScript integration consumes one.
|
|
42
|
+
- **`mergeVfs` / `prefixVfs`** — combine maps left to right (later wins on a path collision), and root one package's entries under `node_modules/<name>/`.
|
|
43
|
+
- **`isTypeDefinition`** — whether a path names a declaration file. The single spelling of that predicate.
|
|
44
|
+
- **`VirtualPackage`** — a named, versioned set of declaration entries that renders to a `Vfs` with a synthetic `package.json`. `create` for a single `index.d.ts`, `createMultiEntry` for one declaration file per entry point behind a synthetic `exports` map. Subclass-friendly, which is how an API-model-backed package is built on top of it.
|
|
45
|
+
- **`TsEnvironment.make`** — a `@typescript/vfs` `VirtualTypeScriptEnvironment` over a `Vfs` plus the TypeScript default libs. Takes options in the tsconfig spelling and converts internally, so a caller needs no compile-time dependency on `typescript`. A missing optional peer fails as a typed **`TsEnvironmentError`** rather than crashing at import.
|
|
46
|
+
|
|
47
|
+
### Compiler-option resolution
|
|
48
|
+
|
|
49
|
+
- **`TypeResolutionCompilerOptions`** — the whitelist of options allowed to influence how an example type-checks, picked from `@effected/tsconfig-json`'s own field schemas rather than restated. Passing through options the tool does not understand would let an unrelated build setting silently change a documentation build.
|
|
50
|
+
- **`decodeCompilerOptions`** — decode untrusted input in either spelling and narrow it to the whitelist, returning a `Result` that fails on a value the compiler cannot act on instead of casting it through.
|
|
51
|
+
- **`toProgrammaticCompilerOptions`** — the one conversion from the tsconfig spelling to the compiler's numeric enums. Fingerprint the encoded value, not the decoded one, or two spellings of the same configuration build two identical environments under different keys.
|
|
52
|
+
- **`parseTsConfig`** — read a `tsconfig.json` into whitelisted options, with `extends` chain resolution (package specifiers included), JSONC parsing and relative paths owned by `@effected/tsconfig-json`. Failures raise **`TsConfigParseError`** carrying the config path.
|
|
53
|
+
- **`DEFAULT_COMPILER_OPTIONS`** — the documentation defaults: ESNext target and module, bundler resolution, non-strict, `skipLibCheck`, and a `lib` covering ESNext plus DOM. Held in the tsconfig spelling, the same one users write.
|
|
54
|
+
- **`mergeCompilerOptions`** — layer one option set over another, later winning per key. Arrays (`lib`, `types`) are replaced wholesale rather than concatenated, matching TypeScript's own `extends` semantics.
|
|
55
|
+
- **`resolveTypeScriptConfig`** — the full cascade: defaults, then a global config, then a per-API one, each level loading its `tsconfig` before merging its inline `compilerOptions` on top. `resolveTypeScriptConfigSingle` resolves one level synchronously (path-based `tsconfig` only); `resolveTypeScriptConfigSingleAsync` also accepts a `tsconfig` given as a loader function.
|
|
56
|
+
- **`TypeScriptConfig` / `CompilerOptionsInput`** — how a caller points at configuration, and the deliberately loose shape their inline options arrive in before decoding. Two types rather than one, so untrusted input and decoded output cannot be confused at a call site.
|
|
57
|
+
|
|
58
|
+
## Quick start
|
|
59
|
+
|
|
60
|
+
Build a virtual package from declaration text, resolve the compiler options an example should be checked under, and type-check against both.
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import { Effect } from "effect";
|
|
64
|
+
import { TsEnvironment, VirtualPackage, mergeVfs, resolveTypeScriptConfig } from "@tsdoctor/vfs";
|
|
65
|
+
|
|
66
|
+
const vfs = mergeVfs(
|
|
67
|
+
VirtualPackage.create(
|
|
68
|
+
"my-types",
|
|
69
|
+
"1.0.0",
|
|
70
|
+
"export declare const answer: number;\nexport interface User { readonly id: string }\n",
|
|
71
|
+
).toVfs(),
|
|
72
|
+
);
|
|
73
|
+
console.log([...vfs.keys()]);
|
|
74
|
+
// ["node_modules/my-types/package.json", "node_modules/my-types/index.d.ts"]
|
|
75
|
+
|
|
76
|
+
// Defaults, then the project's tsconfig, then an inline override on top.
|
|
77
|
+
const compilerOptions = await resolveTypeScriptConfig(process.cwd(), { tsconfig: "tsconfig.json" }, {
|
|
78
|
+
compilerOptions: { strict: true },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const program = Effect.gen(function* () {
|
|
82
|
+
const environment = yield* TsEnvironment.make({ vfs, compilerOptions, projectRoot: "/docs" });
|
|
83
|
+
environment.createFile("/docs/sample.ts", 'import { answer } from "my-types";\nexport const x: number = answer;\n');
|
|
84
|
+
return environment.languageService.getSemanticDiagnostics("/docs/sample.ts");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
console.log((await Effect.runPromise(program)).length);
|
|
88
|
+
// diagnostic count — 0 when the sample type-checks against the virtual package
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`resolveTypeScriptConfig` returns the tsconfig spelling, which is what `TsEnvironment.make` takes; nothing in this example needs to import `typescript`.
|
|
92
|
+
|
|
93
|
+
## Provenance
|
|
94
|
+
|
|
95
|
+
Extracted from `@tsdoctor/registry`, which had no internal consumer for the VFS half while `@tsdoctor/model` needed it. Hosting these types in the registry would have forced a dependency in one direction or the other — the registry onto an API model it does not read, or the model onto the registry's CDN and cache stack it does not use. The registry kept its own job, fetching and caching external package types into a `Vfs`, and shed the `typescript`, `@typescript/vfs` and `@effected/tsconfig-json` peers along with the environment builder. The compiler-option resolution came from the RSPress adapter, where it sat beside the environment it configures.
|
|
96
|
+
|
|
97
|
+
## License
|
|
98
|
+
|
|
99
|
+
[MIT](LICENSE)
|
package/TsEnvironment.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { isTypeDefinition } from "./Vfs.js";
|
|
2
|
+
import { Effect, Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/TsEnvironment.ts
|
|
5
|
+
/**
|
|
6
|
+
* Raised when building a virtual TypeScript environment fails — including
|
|
7
|
+
* when the optional `typescript` / `@typescript/vfs` /
|
|
8
|
+
* `@effected/tsconfig-json` peers are not installed.
|
|
9
|
+
*
|
|
10
|
+
* @public
|
|
11
|
+
*/
|
|
12
|
+
var TsEnvironmentError = class extends Schema.TaggedError()("TsEnvironmentError", {
|
|
13
|
+
/** The underlying failure, preserved structurally. */
|
|
14
|
+
cause: Schema.Defect() }) {
|
|
15
|
+
get message() {
|
|
16
|
+
return "Failed to create the virtual TypeScript environment";
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* The `@typescript/vfs` seam: builds a `VirtualTypeScriptEnvironment` over a
|
|
21
|
+
* {@link Vfs} plus the TypeScript default lib files.
|
|
22
|
+
*
|
|
23
|
+
* @remarks
|
|
24
|
+
* The ONLY module touching the optional `typescript` / `@typescript/vfs` /
|
|
25
|
+
* `@effected/tsconfig-json` peers, and it loads all three lazily inside
|
|
26
|
+
* {@link TsEnvironment.make} — a consumer that never calls it never loads
|
|
27
|
+
* the compiler, and a missing peer fails typed as
|
|
28
|
+
* {@link TsEnvironmentError} instead of crashing at import time. Keep every
|
|
29
|
+
* one of them behind that dynamic `import()`: a static value import here is
|
|
30
|
+
* reachable from `index.ts`, so it would turn an omitted optional peer into
|
|
31
|
+
* an `ERR_MODULE_NOT_FOUND` on the entry graph for consumers who never
|
|
32
|
+
* touch this module. Only the type-only `CompilerOptions` import is safe
|
|
33
|
+
* statically, because it erases. The underlying `createDefaultMapFromNodeModules` /
|
|
34
|
+
* `createFSBackedSystem` read the real filesystem through TypeScript's own
|
|
35
|
+
* `sys`, outside the Effect `FileSystem` service — accepted and documented;
|
|
36
|
+
* this module is why the package is integrated tier on its own surface.
|
|
37
|
+
*
|
|
38
|
+
* No cache map (v3's `createTypeScriptCache` returned a one-entry `Map`
|
|
39
|
+
* keyed by `JSON.stringify(compilerOptions)`): a consumer that wants keyed
|
|
40
|
+
* reuse holds its own map.
|
|
41
|
+
*
|
|
42
|
+
* `VirtualTypeScriptEnvironment` is deliberately not re-exported — import
|
|
43
|
+
* the type from `@typescript/vfs`, which consumers of this module already
|
|
44
|
+
* declare.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```ts
|
|
48
|
+
* import { TsEnvironment } from "@tsdoctor/vfs";
|
|
49
|
+
*
|
|
50
|
+
* const environment = TsEnvironment.make({
|
|
51
|
+
* vfs,
|
|
52
|
+
* compilerOptions: { strict: true, target: "es2022" },
|
|
53
|
+
* });
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* @public
|
|
57
|
+
*/
|
|
58
|
+
var TsEnvironment = class {
|
|
59
|
+
constructor() {}
|
|
60
|
+
/** Build a `VirtualTypeScriptEnvironment` over a {@link Vfs}. */
|
|
61
|
+
static make(options) {
|
|
62
|
+
return Effect.gen(function* () {
|
|
63
|
+
const [tsModule, tsVfs, { TsEnumCodec }] = yield* Effect.tryPromise({
|
|
64
|
+
try: () => Promise.all([
|
|
65
|
+
import("typescript"),
|
|
66
|
+
import("@typescript/vfs"),
|
|
67
|
+
import("@effected/tsconfig-json")
|
|
68
|
+
]),
|
|
69
|
+
catch: (cause) => new TsEnvironmentError({ cause })
|
|
70
|
+
});
|
|
71
|
+
return yield* Effect.try({
|
|
72
|
+
try: () => {
|
|
73
|
+
const typescript = tsModule.default;
|
|
74
|
+
const projectRoot = options.projectRoot ?? process.cwd();
|
|
75
|
+
const compilerOptions = TsEnumCodec.encodeCompilerOptions(options.compilerOptions);
|
|
76
|
+
const executing = typescript.sys?.getExecutingFilePath?.();
|
|
77
|
+
const libDirectory = executing === void 0 ? void 0 : executing.slice(0, Math.max(executing.lastIndexOf("/"), executing.lastIndexOf("\\")));
|
|
78
|
+
const system = new Map(tsVfs.createDefaultMapFromNodeModules(compilerOptions, typescript, libDirectory));
|
|
79
|
+
const rootFiles = [];
|
|
80
|
+
for (const [path, content] of options.vfs) {
|
|
81
|
+
const rooted = path.startsWith("/") ? path : `${projectRoot}/${path}`;
|
|
82
|
+
system.set(rooted, content);
|
|
83
|
+
if (isTypeDefinition(rooted)) rootFiles.push(rooted);
|
|
84
|
+
}
|
|
85
|
+
const sys = tsVfs.createFSBackedSystem(system, projectRoot, typescript, libDirectory);
|
|
86
|
+
return tsVfs.createVirtualTypeScriptEnvironment(sys, rootFiles, typescript, compilerOptions);
|
|
87
|
+
},
|
|
88
|
+
catch: (cause) => new TsEnvironmentError({ cause })
|
|
89
|
+
});
|
|
90
|
+
}).pipe(Effect.withSpan("TsEnvironment.make"));
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
//#endregion
|
|
95
|
+
export { TsEnvironment, TsEnvironmentError };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { decodeCompilerOptions } from "./TypeResolutionOptions.js";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { TsconfigLoaderSync } from "@effected/tsconfig-json";
|
|
5
|
+
import { Result } from "effect";
|
|
6
|
+
|
|
7
|
+
//#region src/TsconfigParser.ts
|
|
8
|
+
/**
|
|
9
|
+
* Reading a `tsconfig.json` into the compiler options the plugin consumes.
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* A thin adapter over `@effected/tsconfig-json`'s `TsconfigLoaderSync`, which
|
|
13
|
+
* owns `extends` chain resolution (including package specifiers), JSONC
|
|
14
|
+
* parsing and relative-path handling. This module used to hand-roll all three
|
|
15
|
+
* over TypeScript's `parseJsonConfigFileContent`.
|
|
16
|
+
*
|
|
17
|
+
* **The loader returns the tsconfig SPELLING, not the programmatic one.**
|
|
18
|
+
* `target` is `"es2025"` rather than `ts.ScriptTarget.ES2025`, and `lib` is
|
|
19
|
+
* `["esnext"]` rather than `["lib.esnext.d.ts"]`. That is fine, and it is why
|
|
20
|
+
* the normalization seam had to land first: `toProgrammaticCompilerOptions`
|
|
21
|
+
* (`twoslash-transformer.ts`) converts at ONE place, and
|
|
22
|
+
* {@link TypeResolutionCompilerOptions} accepts both spellings by design. Do
|
|
23
|
+
* not convert here — a second conversion site is exactly the drift that made
|
|
24
|
+
* three of four resolution paths load zero lib files once already.
|
|
25
|
+
*
|
|
26
|
+
* @packageDocumentation
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* Error thrown when tsconfig.json parsing fails.
|
|
30
|
+
*
|
|
31
|
+
* @remarks
|
|
32
|
+
* Retained as the plugin's own type rather than surfacing the kit's
|
|
33
|
+
* `TsconfigParseError`/`TsconfigExtendsError` directly: the adapter's
|
|
34
|
+
* `typescript-config.ts` branches on `instanceof TsConfigParseError` to decide
|
|
35
|
+
* whether a failure is already reported, and both kit errors mean the same
|
|
36
|
+
* thing to that caller. It now also carries a decode failure from
|
|
37
|
+
* {@link decodeCompilerOptions}, which is the same thing again: a tsconfig
|
|
38
|
+
* this tool cannot act on.
|
|
39
|
+
*
|
|
40
|
+
* @public
|
|
41
|
+
*/
|
|
42
|
+
var TsConfigParseError = class extends Error {
|
|
43
|
+
configPath;
|
|
44
|
+
cause;
|
|
45
|
+
constructor(configPath, message, cause) {
|
|
46
|
+
super(`Failed to parse tsconfig at ${configPath}: ${message}`);
|
|
47
|
+
this.configPath = configPath;
|
|
48
|
+
this.cause = cause;
|
|
49
|
+
this.name = "TsConfigParseError";
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* The sync host the kit loader reads through.
|
|
54
|
+
*
|
|
55
|
+
* @remarks
|
|
56
|
+
* `node:path` satisfies `SyncPath` verbatim. The filesystem half is two
|
|
57
|
+
* functions, so no shim module is needed.
|
|
58
|
+
*/
|
|
59
|
+
const syncHost = {
|
|
60
|
+
fileSystem: {
|
|
61
|
+
exists: existsSync,
|
|
62
|
+
readFile: (filePath) => readFileSync(filePath, "utf8")
|
|
63
|
+
},
|
|
64
|
+
path
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Parse a `tsconfig.json` and extract the compiler options used for type
|
|
68
|
+
* resolution.
|
|
69
|
+
*
|
|
70
|
+
* @param configPath - Path to tsconfig.json (relative or absolute)
|
|
71
|
+
* @param projectRoot - Project root directory for resolving relative paths
|
|
72
|
+
* @returns The declared compiler options, in the tsconfig spelling
|
|
73
|
+
* @throws TsConfigParseError if the config cannot be read or parsed
|
|
74
|
+
*
|
|
75
|
+
* @example
|
|
76
|
+
* ```ts
|
|
77
|
+
* const options = parseTsConfig("tsconfig.json", "/path/to/project");
|
|
78
|
+
* // Returns: { target: "es2025", module: "nodenext", lib: ["esnext"], ... }
|
|
79
|
+
* ```
|
|
80
|
+
*
|
|
81
|
+
* @public
|
|
82
|
+
*/
|
|
83
|
+
function parseTsConfig(configPath, projectRoot) {
|
|
84
|
+
const absolutePath = path.isAbsolute(configPath) ? configPath : path.resolve(projectRoot, configPath);
|
|
85
|
+
if (!existsSync(absolutePath)) throw new TsConfigParseError(absolutePath, "File not found");
|
|
86
|
+
let options;
|
|
87
|
+
try {
|
|
88
|
+
options = TsconfigLoaderSync.compilerOptions(absolutePath, syncHost);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
throw new TsConfigParseError(absolutePath, error instanceof Error ? error.message : String(error), error);
|
|
91
|
+
}
|
|
92
|
+
const decoded = decodeCompilerOptions(options);
|
|
93
|
+
if (Result.isFailure(decoded)) throw new TsConfigParseError(absolutePath, decoded.failure.message, decoded.failure);
|
|
94
|
+
return decoded.success;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
//#endregion
|
|
98
|
+
export { TsConfigParseError, parseTsConfig };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { CompilerOptions, CompilerOptionsFromProgrammatic, TsEnumCodec } from "@effected/tsconfig-json";
|
|
2
|
+
import { Result, Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/TypeResolutionOptions.ts
|
|
5
|
+
const fields = CompilerOptions.schema.fields;
|
|
6
|
+
/**
|
|
7
|
+
* The compiler options that may influence how a documentation example
|
|
8
|
+
* type-checks.
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* Deliberately a **whitelist**, picked from `@effected/tsconfig-json`'s
|
|
12
|
+
* `CompilerOptions` rather than restated. Everything here reaches the
|
|
13
|
+
* TypeScript environment, and passing through options the tool does not
|
|
14
|
+
* understand would let a consumer's unrelated build setting silently change
|
|
15
|
+
* how their examples type-check.
|
|
16
|
+
*
|
|
17
|
+
* Picking from the kit's own field schemas is what keeps this a policy
|
|
18
|
+
* statement rather than a second definition of tsconfig: the accepted values,
|
|
19
|
+
* their spellings and their case-insensitivity are the kit's to own, and this
|
|
20
|
+
* module owns only the choice of WHICH options are in scope.
|
|
21
|
+
*
|
|
22
|
+
* @public
|
|
23
|
+
*/
|
|
24
|
+
const TypeResolutionCompilerOptions = Schema.Struct({
|
|
25
|
+
target: fields.target,
|
|
26
|
+
module: fields.module,
|
|
27
|
+
moduleResolution: fields.moduleResolution,
|
|
28
|
+
jsx: fields.jsx,
|
|
29
|
+
lib: fields.lib,
|
|
30
|
+
types: fields.types,
|
|
31
|
+
typeRoots: fields.typeRoots,
|
|
32
|
+
strict: fields.strict,
|
|
33
|
+
skipLibCheck: fields.skipLibCheck,
|
|
34
|
+
esModuleInterop: fields.esModuleInterop,
|
|
35
|
+
allowSyntheticDefaultImports: fields.allowSyntheticDefaultImports
|
|
36
|
+
});
|
|
37
|
+
/** The whitelisted keys, for narrowing a decoded `CompilerOptions`. */
|
|
38
|
+
const KEYS = Object.keys(TypeResolutionCompilerOptions.fields);
|
|
39
|
+
/**
|
|
40
|
+
* Narrow a decoded `CompilerOptions` to the whitelist.
|
|
41
|
+
*
|
|
42
|
+
* @remarks
|
|
43
|
+
* Empty `lib` / `types` arrays are **dropped rather than passed through**:
|
|
44
|
+
* `lib: []` REPLACES the default library set with nothing (arrays are replaced
|
|
45
|
+
* wholesale, not merged), which would type-check every example against no
|
|
46
|
+
* globals at all. An absent key inherits the default; an empty one does not.
|
|
47
|
+
*/
|
|
48
|
+
const narrow = (decoded) => {
|
|
49
|
+
const out = {};
|
|
50
|
+
for (const key of KEYS) {
|
|
51
|
+
const value = decoded[key];
|
|
52
|
+
if (value === void 0) continue;
|
|
53
|
+
if (Array.isArray(value) && value.length === 0) continue;
|
|
54
|
+
out[key] = value;
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Decode compiler options written in either spelling into the whitelist.
|
|
60
|
+
*
|
|
61
|
+
* @remarks
|
|
62
|
+
* Accepts the tsconfig spelling a user writes (`target: "es2025"`, and
|
|
63
|
+
* case-insensitively `lib: ["ESNext", "DOM"]`) and the programmatic spelling a
|
|
64
|
+
* caller holding `ts.CompilerOptions` has (`target: ts.ScriptTarget.ES2025`),
|
|
65
|
+
* because a consumer configuring a documentation build in TypeScript
|
|
66
|
+
* reasonably produces either.
|
|
67
|
+
*
|
|
68
|
+
* **Fails rather than guesses.** A value with no entry in the enum tables — a
|
|
69
|
+
* numeric target from a future TypeScript, a misspelled module kind — is
|
|
70
|
+
* rejected on the error channel instead of being passed through. Degrading to
|
|
71
|
+
* a default here would type-check every example against a configuration the
|
|
72
|
+
* user did not ask for, and produce confidently wrong output with no error:
|
|
73
|
+
* the failure mode this seam exists to prevent.
|
|
74
|
+
*
|
|
75
|
+
* @public
|
|
76
|
+
*/
|
|
77
|
+
const decodeCompilerOptions = (input) => Result.map(Schema.decodeUnknownResult(CompilerOptionsFromProgrammatic)(input), (decoded) => narrow(decoded));
|
|
78
|
+
/**
|
|
79
|
+
* Convert whitelisted options to the numeric-enum form the TypeScript compiler
|
|
80
|
+
* takes.
|
|
81
|
+
*
|
|
82
|
+
* @remarks
|
|
83
|
+
* The ONE conversion site between the tsconfig spelling and the programmatic
|
|
84
|
+
* one. Two consequences follow from it being single:
|
|
85
|
+
*
|
|
86
|
+
* - Any environment fingerprint MUST be computed on the ENCODED value.
|
|
87
|
+
* Otherwise `{lib:["ESNext"]}` and `{lib:["lib.esnext.d.ts"]}` build two
|
|
88
|
+
* identical TypeScript environments under different keys.
|
|
89
|
+
* - There is no cast here. The whitelist is a subset of the kit's own
|
|
90
|
+
* `CompilerOptions`, so it is assignable to the encoder by construction —
|
|
91
|
+
* which is precisely what a hand-rolled options type could not be.
|
|
92
|
+
*
|
|
93
|
+
* @public
|
|
94
|
+
*/
|
|
95
|
+
const toProgrammaticCompilerOptions = (options) => TsEnumCodec.encodeCompilerOptions(options);
|
|
96
|
+
|
|
97
|
+
//#endregion
|
|
98
|
+
export { TypeResolutionCompilerOptions, decodeCompilerOptions, toProgrammaticCompilerOptions };
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { decodeCompilerOptions } from "./TypeResolutionOptions.js";
|
|
2
|
+
import { TsConfigParseError, parseTsConfig } from "./TsconfigParser.js";
|
|
3
|
+
import { Result } from "effect";
|
|
4
|
+
|
|
5
|
+
//#region src/TypeScriptConfig.ts
|
|
6
|
+
/**
|
|
7
|
+
* Default TypeScript compiler options for Twoslash and type resolution.
|
|
8
|
+
*
|
|
9
|
+
* These defaults are optimized for documentation:
|
|
10
|
+
* - Modern ES targets (ESNext)
|
|
11
|
+
* - Bundler module resolution for broad compatibility
|
|
12
|
+
* - Lenient settings (non-strict) since docs often show simplified examples
|
|
13
|
+
* - Skip lib checks for faster processing
|
|
14
|
+
*
|
|
15
|
+
* @remarks
|
|
16
|
+
* Numeric values correspond to TypeScript enums:
|
|
17
|
+
* - target: 99 = ESNext
|
|
18
|
+
* - module: 99 = ESNext
|
|
19
|
+
* - moduleResolution: 100 = Bundler
|
|
20
|
+
*
|
|
21
|
+
* @public
|
|
22
|
+
*/
|
|
23
|
+
const DEFAULT_COMPILER_OPTIONS = {
|
|
24
|
+
target: "esnext",
|
|
25
|
+
module: "esnext",
|
|
26
|
+
moduleResolution: "bundler",
|
|
27
|
+
lib: ["esnext", "dom"],
|
|
28
|
+
strict: false,
|
|
29
|
+
skipLibCheck: true,
|
|
30
|
+
esModuleInterop: true,
|
|
31
|
+
allowSyntheticDefaultImports: true
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Merge two TypeResolutionCompilerOptions objects.
|
|
35
|
+
* Properties from `override` take precedence over `base`.
|
|
36
|
+
*
|
|
37
|
+
* @param base - Base compiler options
|
|
38
|
+
* @param override - Options to merge on top (takes precedence)
|
|
39
|
+
* @returns Merged options
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* const base = { target: 99, lib: ["ESNext"] };
|
|
44
|
+
* const override = { lib: ["ESNext", "DOM"], strict: true };
|
|
45
|
+
* const merged = mergeCompilerOptions(base, override);
|
|
46
|
+
* // Result: { target: 99, lib: ["ESNext", "DOM"], strict: true }
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* Decode user-supplied compiler options, failing loudly.
|
|
51
|
+
*
|
|
52
|
+
* @remarks
|
|
53
|
+
* The values reaching here are whatever a consumer wrote in their config, in
|
|
54
|
+
* either spelling. A value the enum tables cannot map is REJECTED rather than
|
|
55
|
+
* passed through: degrading to a default would type-check every example
|
|
56
|
+
* against a configuration the user did not ask for, and say nothing about it.
|
|
57
|
+
* `layers/type-environment.ts` turns this throw into a `ConfigValidationError`,
|
|
58
|
+
* which reaches the `issues.json` artifact.
|
|
59
|
+
*/
|
|
60
|
+
function decodeInput(input, source) {
|
|
61
|
+
const decoded = decodeCompilerOptions(input);
|
|
62
|
+
if (Result.isFailure(decoded)) throw new TsConfigParseError(source, decoded.failure.message, decoded.failure);
|
|
63
|
+
return decoded.success;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Merge one set of compiler options over another, later winning per key.
|
|
67
|
+
*
|
|
68
|
+
* @remarks
|
|
69
|
+
* Arrays (`lib`, `types`) are REPLACED wholesale rather than concatenated,
|
|
70
|
+
* matching TypeScript's own `extends` semantics: declaring `lib` means "these
|
|
71
|
+
* libraries", not "these as well as the defaults".
|
|
72
|
+
*
|
|
73
|
+
* @param base - the options to start from
|
|
74
|
+
* @param override - the options to layer on top, or `undefined` for a copy of `base`
|
|
75
|
+
* @returns a new object; neither argument is mutated
|
|
76
|
+
*
|
|
77
|
+
* @public
|
|
78
|
+
*/
|
|
79
|
+
function mergeCompilerOptions(base, override) {
|
|
80
|
+
if (!override) return { ...base };
|
|
81
|
+
const merged = { ...base };
|
|
82
|
+
for (const [key, value] of Object.entries(override)) if (value !== void 0) merged[key] = value;
|
|
83
|
+
return merged;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Resolve a single TypeScriptConfig to compiler options (sync version).
|
|
87
|
+
* Only handles path-based tsconfig - use resolveTypeScriptConfigSingleAsync for function-based.
|
|
88
|
+
*
|
|
89
|
+
* Follows the priority cascade:
|
|
90
|
+
* 1. Parse tsconfig.json if specified (path only, not function)
|
|
91
|
+
* 2. Merge compilerOptions on top
|
|
92
|
+
*
|
|
93
|
+
* @param config - TypeScript config with optional tsconfig path and/or compilerOptions
|
|
94
|
+
* @param projectRoot - Project root for resolving relative tsconfig paths
|
|
95
|
+
* @returns Resolved compiler options (not merged with defaults)
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* ```ts
|
|
99
|
+
* // Just tsconfig
|
|
100
|
+
* resolveTypeScriptConfigSingle({ tsconfig: "tsconfig.json" }, "/project");
|
|
101
|
+
*
|
|
102
|
+
* // Just compilerOptions
|
|
103
|
+
* resolveTypeScriptConfigSingle({ compilerOptions: { target: "esnext" } }, "/project");
|
|
104
|
+
*
|
|
105
|
+
* // Both (compilerOptions override tsconfig)
|
|
106
|
+
* resolveTypeScriptConfigSingle({
|
|
107
|
+
* tsconfig: "tsconfig.json",
|
|
108
|
+
* compilerOptions: { strict: false }
|
|
109
|
+
* }, "/project");
|
|
110
|
+
* ```
|
|
111
|
+
*
|
|
112
|
+
* @public
|
|
113
|
+
*/
|
|
114
|
+
function resolveTypeScriptConfigSingle(config, projectRoot) {
|
|
115
|
+
if (!config) return {};
|
|
116
|
+
let options = {};
|
|
117
|
+
if (config.tsconfig && typeof config.tsconfig !== "function") {
|
|
118
|
+
const tsconfigPath = String(config.tsconfig);
|
|
119
|
+
try {
|
|
120
|
+
options = parseTsConfig(tsconfigPath, projectRoot);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error instanceof TsConfigParseError) throw error;
|
|
123
|
+
throw new TsConfigParseError(tsconfigPath, error instanceof Error ? error.message : String(error), error);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (config.compilerOptions) options = mergeCompilerOptions(options, decodeInput(config.compilerOptions, "compilerOptions"));
|
|
127
|
+
return options;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Resolve a single TypeScriptConfig to compiler options (async version).
|
|
131
|
+
* Handles both path-based and function-based tsconfig.
|
|
132
|
+
*
|
|
133
|
+
* Follows the priority cascade:
|
|
134
|
+
* 1. Load tsconfig (from path or function)
|
|
135
|
+
* 2. Merge compilerOptions on top
|
|
136
|
+
*
|
|
137
|
+
* @param config - TypeScript config with optional tsconfig path/function and/or compilerOptions
|
|
138
|
+
* @param projectRoot - Project root for resolving relative tsconfig paths
|
|
139
|
+
* @returns Promise resolving to compiler options (not merged with defaults)
|
|
140
|
+
*
|
|
141
|
+
* @example
|
|
142
|
+
* ```ts
|
|
143
|
+
* // Path-based tsconfig
|
|
144
|
+
* await resolveTypeScriptConfigSingleAsync({ tsconfig: "tsconfig.json" }, "/project");
|
|
145
|
+
*
|
|
146
|
+
* // Function-based tsconfig
|
|
147
|
+
* await resolveTypeScriptConfigSingleAsync({
|
|
148
|
+
* tsconfig: async () => ({ target: 99, lib: ["ESNext"] })
|
|
149
|
+
* }, "/project");
|
|
150
|
+
*
|
|
151
|
+
* // Both (compilerOptions override tsconfig)
|
|
152
|
+
* await resolveTypeScriptConfigSingleAsync({
|
|
153
|
+
* tsconfig: async () => ({ target: 99 }),
|
|
154
|
+
* compilerOptions: { strict: false }
|
|
155
|
+
* }, "/project");
|
|
156
|
+
* ```
|
|
157
|
+
*
|
|
158
|
+
* @public
|
|
159
|
+
*/
|
|
160
|
+
async function resolveTypeScriptConfigSingleAsync(config, projectRoot) {
|
|
161
|
+
if (!config) return {};
|
|
162
|
+
let options = {};
|
|
163
|
+
if (config.tsconfig) {
|
|
164
|
+
if (typeof config.tsconfig === "function") options = decodeInput(await config.tsconfig(), "tsconfig()");
|
|
165
|
+
else {
|
|
166
|
+
const tsconfigPath = String(config.tsconfig);
|
|
167
|
+
try {
|
|
168
|
+
options = parseTsConfig(tsconfigPath, projectRoot);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (error instanceof TsConfigParseError) throw error;
|
|
171
|
+
throw new TsConfigParseError(tsconfigPath, error instanceof Error ? error.message : String(error), error);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (config.compilerOptions) options = mergeCompilerOptions(options, decodeInput(config.compilerOptions, "compilerOptions"));
|
|
176
|
+
return options;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Resolve TypeScript compiler options from a cascade of configurations (async).
|
|
180
|
+
*
|
|
181
|
+
* Resolution order (later levels override earlier):
|
|
182
|
+
* 1. DEFAULT_COMPILER_OPTIONS (sensible defaults)
|
|
183
|
+
* 2. Global config
|
|
184
|
+
* 3. API-level config
|
|
185
|
+
*
|
|
186
|
+
* At each level, if a TypeScriptConfig has both `tsconfig` and `compilerOptions`,
|
|
187
|
+
* the tsconfig is loaded first, then compilerOptions are merged on top.
|
|
188
|
+
*
|
|
189
|
+
* @param projectRoot - Project root directory for resolving relative paths
|
|
190
|
+
* @param global - Global plugin TypeScript configuration
|
|
191
|
+
* @param api - API-level TypeScript configuration
|
|
192
|
+
* @returns Promise resolving to fully resolved compiler options
|
|
193
|
+
*
|
|
194
|
+
* @example
|
|
195
|
+
* ```ts
|
|
196
|
+
* // Simple global config
|
|
197
|
+
* const options = await resolveTypeScriptConfig("/project", {
|
|
198
|
+
* tsconfig: "tsconfig.json"
|
|
199
|
+
* });
|
|
200
|
+
*
|
|
201
|
+
* // With async tsconfig loader
|
|
202
|
+
* const options = await resolveTypeScriptConfig("/project", {
|
|
203
|
+
* tsconfig: async () => ({ target: 99, lib: ["ESNext"] })
|
|
204
|
+
* });
|
|
205
|
+
*
|
|
206
|
+
* // With API override
|
|
207
|
+
* const options = await resolveTypeScriptConfig(
|
|
208
|
+
* "/project",
|
|
209
|
+
* { tsconfig: "tsconfig.json" },
|
|
210
|
+
* { compilerOptions: { strict: false } }
|
|
211
|
+
* );
|
|
212
|
+
* ```
|
|
213
|
+
*
|
|
214
|
+
* @public
|
|
215
|
+
*/
|
|
216
|
+
async function resolveTypeScriptConfig(projectRoot, global, api) {
|
|
217
|
+
let options = { ...DEFAULT_COMPILER_OPTIONS };
|
|
218
|
+
const globalOptions = await resolveTypeScriptConfigSingleAsync(global, projectRoot);
|
|
219
|
+
options = mergeCompilerOptions(options, globalOptions);
|
|
220
|
+
const apiOptions = await resolveTypeScriptConfigSingleAsync(api, projectRoot);
|
|
221
|
+
options = mergeCompilerOptions(options, apiOptions);
|
|
222
|
+
return options;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
//#endregion
|
|
226
|
+
export { DEFAULT_COMPILER_OPTIONS, mergeCompilerOptions, resolveTypeScriptConfig, resolveTypeScriptConfigSingle, resolveTypeScriptConfigSingleAsync };
|