rspress-plugin-api-extractor 0.11.0 → 0.13.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.
Files changed (49) hide show
  1. package/BuildEnv.js +3 -3
  2. package/README.md +1 -0
  3. package/build-program.js +14 -5
  4. package/build-stages.js +88 -49
  5. package/config-helpers.js +7 -7
  6. package/errors.js +1 -5
  7. package/index.d.ts +1 -86
  8. package/layers/AppLayer.js +67 -0
  9. package/layers/api-results.js +83 -0
  10. package/layers/build-metrics.js +1 -1
  11. package/layers/{ConfigServiceLive.js → config-resolution.js} +115 -175
  12. package/layers/external-types.js +74 -0
  13. package/layers/{ObservabilityLive.js → observability.js} +3 -3
  14. package/layers/type-environment.js +109 -0
  15. package/layers/xdg.js +1 -1
  16. package/markdown/helpers.js +8 -33
  17. package/markdown/shiki-utils.js +1 -1
  18. package/model-loader.js +3 -3
  19. package/observability/EventBus.js +2 -2
  20. package/observability/heartbeat.js +1 -1
  21. package/observability/sinks/metrics-sink.js +1 -1
  22. package/observability/sinks/trace-sink.js +4 -4
  23. package/package.json +5 -4
  24. package/plugin.js +30 -33
  25. package/prettier-formatter.js +1 -1
  26. package/remark-api-codeblocks.js +1 -1
  27. package/remark-with-api.js +1 -1
  28. package/schemas/config.js +1 -3
  29. package/services/ConfigService.js +37 -2
  30. package/services/HighlighterService.js +48 -3
  31. package/services/OgService.js +154 -2
  32. package/services/PluginConfig.js +1 -1
  33. package/services/TwoslashCacheService.js +128 -2
  34. package/services/TwoslashEnvironments.js +30 -2
  35. package/services/TypeRegistryService.js +178 -2
  36. package/shiki-transformer.js +1 -1
  37. package/sync-node-fs.js +6 -6
  38. package/tsconfig-parser.js +77 -95
  39. package/twoslash-access.js +1 -1
  40. package/twoslash-transformer.js +1 -1
  41. package/layers/HighlighterServiceLive.js +0 -52
  42. package/layers/OgServiceLive.js +0 -134
  43. package/layers/TwoslashCacheServiceLive.js +0 -108
  44. package/layers/TwoslashEnvironmentsLive.js +0 -33
  45. package/layers/TypeRegistryServiceLive.js +0 -162
  46. package/markdown/index.js +0 -11
  47. package/og-resolver.js +0 -64
  48. package/schemas/index.js +0 -6
  49. package/schemas/opengraph.js +0 -56
@@ -1,4 +1,8 @@
1
- import { Context } from "effect";
1
+ import { AppDirsLive, PlatformLive } from "../layers/xdg.js";
2
+ import { decodeTwoslashCache, encodeTwoslashCache, makeTwoslashCache, twoslashBlobKey } from "../twoslash-cache.js";
3
+ import { Context, Effect, Layer, Option, Path } from "effect";
4
+ import { Cache } from "@effected/store";
5
+ import { AppDirs } from "@effected/xdg";
2
6
 
3
7
  //#region src/services/TwoslashCacheService.ts
4
8
  /**
@@ -9,7 +13,129 @@ import { Context } from "effect";
9
13
  * hook and cannot await: the service loads once before the render phase and
10
14
  * saves once after it, while every lookup in between is a synchronous map hit.
11
15
  */
12
- var TwoslashCacheService = class extends Context.Service()("rspress-plugin-api-extractor/TwoslashCacheService") {};
16
+ var TwoslashCacheService = class TwoslashCacheService extends Context.Service()("rspress-plugin-api-extractor/TwoslashCacheService") {
17
+ /**
18
+ * Live Twoslash cache persistence.
19
+ *
20
+ * @remarks
21
+ * Failure is absorbed at TWO levels, and both are load-bearing. Inside the
22
+ * service, a failed read or write degrades that one operation. Around the
23
+ * layer, a failed CONSTRUCTION — no HOME for XDG, an unwritable cache
24
+ * directory, a corrupt database — degrades to {@link DegradedLive}.
25
+ *
26
+ * The second is why `Layer.catchCause` wraps this at all. While the sqlite
27
+ * layer was provided inside each method, a construction failure surfaced as
28
+ * that method's failure and the in-method handler swallowed it. Hoisting
29
+ * acquisition to layer construction moved the failure to `ManagedRuntime`
30
+ * build time, where it would abort the entire build — breaking the contract
31
+ * this service documents, that an unreachable cache must never fail a build
32
+ * that would otherwise succeed. `catchCause` rather than a failure-only catch
33
+ * because a defect thrown by the sqlite driver must degrade too.
34
+ *
35
+ * `Layer.suspend` because the composition below is declared after this class:
36
+ * a static initializer runs while the module body is still evaluating, so
37
+ * naming those consts directly throws at import time with a clean typecheck.
38
+ */
39
+ static layer = Layer.suspend(() => CacheBackedLive.pipe(Layer.catchCause(() => DegradedLive)));
40
+ /**
41
+ * An always-cold in-memory double.
42
+ *
43
+ * @remarks
44
+ * Tests that resolve config must not touch the user's real XDG cache, and
45
+ * must not have their results depend on whether a previous run warmed it.
46
+ *
47
+ * `open` returns a REAL in-memory generation rather than a stub, and that is
48
+ * not an accident: `registerEnvironment` hands this object to the Twoslash
49
+ * transformers, so a cache that could not be read or written would change
50
+ * the render path's SHAPE rather than merely dropping its persistence.
51
+ */
52
+ static makeTest = (overrides = {}) => ({
53
+ load: overrides.load ?? (() => Effect.succeed(/* @__PURE__ */ new Map())),
54
+ save: overrides.save ?? (() => Effect.void),
55
+ open: overrides.open ?? (() => Effect.succeed(makeTwoslashCache())),
56
+ persist: overrides.persist ?? (() => Effect.succeed(Option.none()))
57
+ });
58
+ /** {@link TwoslashCacheService.makeTest} behind a `Layer`. */
59
+ static layerTest = (overrides = {}) => Layer.succeed(TwoslashCacheService, TwoslashCacheService.makeTest(overrides));
60
+ };
61
+ /**
62
+ * A sqlite-backed `@effected/store` Cache in the XDG cache dir, separate from
63
+ * the registry's `metadata.sqlite`.
64
+ *
65
+ * XDG rather than the repo: these are regenerable results derived from content
66
+ * hashes, so they belong with the user's other caches — shared across worktrees
67
+ * and checkouts of the same project, and untouched by cleaning `dist/`. Nothing
68
+ * here needs to be committed for a build to be correct.
69
+ */
70
+ const CacheLive = Layer.unwrap(Effect.gen(function* () {
71
+ const appDirs = yield* AppDirs;
72
+ const path = yield* Path.Path;
73
+ const cacheDir = yield* appDirs.ensureCache;
74
+ return Cache.layerSqlite({ filename: path.join(cacheDir, "twoslash.sqlite") });
75
+ })).pipe(Layer.provide(Layer.mergeAll(AppDirsLive, PlatformLive)));
76
+ /**
77
+ * Live Twoslash cache persistence.
78
+ *
79
+ * Both operations swallow every failure by design — see the service docs. A
80
+ * missing HOME, an unwritable cache dir or a corrupt database degrades the
81
+ * build to "type-check everything", which is exactly the behaviour before this
82
+ * cache existed.
83
+ */
84
+ /**
85
+ * Add the build-generation half of the service on top of a load/save pair.
86
+ *
87
+ * @remarks
88
+ * Shared by the real and degraded layers so the two cannot drift: a degraded
89
+ * build must still hand out a working in-memory cache, otherwise the
90
+ * transformers have nothing to read or write and the render pass changes
91
+ * shape rather than merely losing persistence.
92
+ */
93
+ function withGeneration(base) {
94
+ let open = null;
95
+ return {
96
+ ...base,
97
+ open: (envHash) => base.load(envHash).pipe(Effect.map((restored) => {
98
+ const cache = makeTwoslashCache(restored);
99
+ open = {
100
+ cache,
101
+ envHash
102
+ };
103
+ return cache;
104
+ })),
105
+ persist: () => Effect.suspend(() => {
106
+ if (open === null) return Effect.succeed(Option.none());
107
+ const { cache, envHash } = open;
108
+ const stats = cache.stats();
109
+ const report = Option.some({
110
+ ...stats,
111
+ envHash
112
+ });
113
+ return stats.dirty ? base.save(envHash, cache.entries()).pipe(Effect.as(report)) : Effect.succeed(report);
114
+ })
115
+ };
116
+ }
117
+ const CacheBackedLive = Layer.effect(TwoslashCacheService, Effect.gen(function* () {
118
+ const cache = yield* Cache;
119
+ return withGeneration({
120
+ load: (envHash) => cache.get(twoslashBlobKey(envHash)).pipe(Effect.map((entry) => Option.isSome(entry) ? decodeTwoslashCache(entry.value.value) : /* @__PURE__ */ new Map()), Effect.catch(() => Effect.succeed(/* @__PURE__ */ new Map()))),
121
+ save: (envHash, entries) => cache.set({
122
+ key: twoslashBlobKey(envHash),
123
+ value: encodeTwoslashCache(entries),
124
+ tags: ["twoslash"]
125
+ }).pipe(Effect.catch(() => Effect.void))
126
+ });
127
+ })).pipe(Layer.provide(CacheLive));
128
+ /**
129
+ * A cache that holds nothing, for when the real one cannot be opened.
130
+ *
131
+ * @remarks
132
+ * `load` returns empty and `save` discards, which is precisely the behaviour
133
+ * before this cache existed: type-check everything, persist nothing.
134
+ */
135
+ const DegradedLive = Layer.succeed(TwoslashCacheService, withGeneration({
136
+ load: () => Effect.succeed(/* @__PURE__ */ new Map()),
137
+ save: () => Effect.void
138
+ }));
13
139
 
14
140
  //#endregion
15
141
  export { TwoslashCacheService };
@@ -1,7 +1,35 @@
1
- import { Context } from "effect";
1
+ import { TwoslashEnvironmentRegistry } from "../twoslash-transformer.js";
2
+ import { Context, Layer } from "effect";
2
3
 
3
4
  //#region src/services/TwoslashEnvironments.ts
4
- var TwoslashEnvironments = class extends Context.Service()("rspress-plugin-api-extractor/TwoslashEnvironments") {};
5
+ var TwoslashEnvironments = class extends Context.Service()("rspress-plugin-api-extractor/TwoslashEnvironments") {
6
+ /**
7
+ * One environment registry per runtime.
8
+ *
9
+ * @remarks
10
+ * `Layer.sync` rather than `Layer.succeed`: the registry is mutable, and a
11
+ * `Layer.succeed` would capture a single instance shared by every layer graph
12
+ * that referenced this const — including, in a test run, every test file in
13
+ * the process. Building it when the layer builds is what makes substitution
14
+ * work: a test that wants an isolated registry provides its own layer, which
15
+ * is what the old static `TwoslashManager.reset()` was standing in for.
16
+ *
17
+ * Deliberately NOT `Layer.effect` with a finalizer. The registry holds Shiki
18
+ * transformers, which the render pass uses AFTER `config()` returns — the same
19
+ * lifetime constraint the highlighter has, and for the same reason.
20
+ */
21
+ static layer = Layer.sync(this, () => make());
22
+ };
23
+ const make = () => {
24
+ const registry = new TwoslashEnvironmentRegistry();
25
+ return {
26
+ registerEnvironment: (options) => registry.registerEnvironment(options),
27
+ registerScope: (apiScope, compilerOptions) => registry.registerScope(apiScope, compilerOptions),
28
+ transformerFor: (apiScope) => registry.transformerFor(apiScope),
29
+ setCurrentFile: (path) => registry.setCurrentFile(path),
30
+ reportErrorForTest: (error, code, file) => registry.reportErrorForTest(error, code, file)
31
+ };
32
+ };
5
33
 
6
34
  //#endregion
7
35
  export { TwoslashEnvironments };
@@ -1,7 +1,183 @@
1
- import { Context } from "effect";
1
+ import { PluginEvent } from "../observability/events.js";
2
+ import { emit } from "../observability/EventBus.js";
3
+ import { resolveExternalPackageVersions } from "../config-utils.js";
4
+ import { TypeRegistryError } from "../errors.js";
5
+ import { AppDirsLive, PlatformLive } from "../layers/xdg.js";
6
+ import { Context, Duration, Effect, Layer, Path } from "effect";
7
+ import { NodeHttpClient } from "@effect/platform-node";
8
+ import { PackageFetcher, PackageSpec, RegistryObserver, TypeCache, TypeRegistry } from "@tsdoctor/registry";
9
+ import { Cache } from "@effected/store";
10
+ import { AppDirs } from "@effected/xdg";
2
11
 
3
12
  //#region src/services/TypeRegistryService.ts
4
- var TypeRegistryService = class extends Context.Service()("rspress-plugin-api-extractor/TypeRegistryService") {};
13
+ var TypeRegistryService = class TypeRegistryService extends Context.Service()("rspress-plugin-api-extractor/TypeRegistryService") {
14
+ /**
15
+ * The `@tsdoctor/registry` stack, acquired once.
16
+ *
17
+ * @remarks
18
+ * `Layer.catchCause` keeps a broken environment — no HOME for XDG, an
19
+ * unwritable cache directory — from aborting the build at `ManagedRuntime`
20
+ * construction. External type loading is an enhancement: without it code
21
+ * blocks render without Twoslash enrichment, which is a degradation, not a
22
+ * failure. That was true while the stack was provided per method and the
23
+ * in-method handlers absorbed it; hoisting acquisition made it something the
24
+ * layer has to say for itself.
25
+ *
26
+ * `Layer.suspend` because the composition below is declared after this class: a
27
+ * static initializer runs while the module body is still evaluating, so naming
28
+ * those consts directly throws at import time with a clean typecheck.
29
+ */
30
+ static layer = Layer.suspend(() => RegistryBackedLive.pipe(Layer.catchCause(() => DegradedLive)));
31
+ /**
32
+ * An in-memory double: no network, no XDG cache, no sqlite.
33
+ *
34
+ * @remarks
35
+ * Defaults resolve every spec unchanged and load an empty VFS — the shape a
36
+ * build takes when nothing external is configured. Override a member to
37
+ * exercise a specific path; an override for one member leaves the other at
38
+ * its default rather than forcing the test to restate it, which is the whole
39
+ * difference from the hand-written `Layer.succeed` doubles this replaces.
40
+ */
41
+ static makeTest = (overrides = {}) => ({
42
+ resolveVersions: overrides.resolveVersions ?? ((packages) => Effect.succeed([...packages])),
43
+ loadPackages: overrides.loadPackages ?? (() => Effect.succeed({ vfs: /* @__PURE__ */ new Map() }))
44
+ });
45
+ /** {@link TypeRegistryService.makeTest} behind a `Layer`. */
46
+ static layerTest = (overrides = {}) => Layer.succeed(TypeRegistryService, TypeRegistryService.makeTest(overrides));
47
+ };
48
+ /**
49
+ * Forward @tsdoctor/registry's typed `RegistryEvent`s to the plugin's Effect
50
+ * logger. Since v1 the library emits no logs of its own — observers are the only
51
+ * diagnostic surface — so this restores the build output and routes it through
52
+ * the plugin's configured log level/format (a single source, no duplication).
53
+ *
54
+ * The summary (`BatchComplete`) and failures are surfaced at info/warning;
55
+ * per-package detail stays at debug so a normal build is quiet.
56
+ */
57
+ const RegistryObserverLayer = Layer.succeed(RegistryObserver, { emit: (event) => {
58
+ switch (event._tag) {
59
+ case "VersionResolved": return emit(PluginEvent.TypeRegistryEvent({
60
+ ctx: { packageName: event.package },
61
+ level: "debug",
62
+ kind: "VersionResolved",
63
+ detail: `${event.requested} -> ${event.resolved}`
64
+ }));
65
+ case "VersionResolveFailed": return emit(PluginEvent.TypeRegistryEvent({
66
+ ctx: { packageName: event.package },
67
+ level: "debug",
68
+ kind: "VersionResolveFailed",
69
+ detail: `${event.requested}: ${event.kind}`
70
+ }));
71
+ case "CacheHit":
72
+ case "CacheMiss":
73
+ case "FetchStart": return emit(PluginEvent.TypeRegistryEvent({
74
+ ctx: {
75
+ packageName: event.package,
76
+ version: event.version
77
+ },
78
+ level: "debug",
79
+ kind: event._tag,
80
+ detail: ""
81
+ }));
82
+ case "CacheStale": return emit(PluginEvent.TypeRegistryEvent({
83
+ ctx: {
84
+ packageName: event.package,
85
+ version: event.version
86
+ },
87
+ level: "debug",
88
+ kind: "CacheStale",
89
+ detail: ""
90
+ }));
91
+ case "FetchFailed": return emit(PluginEvent.TypeRegistryEvent({
92
+ ctx: {},
93
+ level: "debug",
94
+ kind: "FetchFailed",
95
+ detail: `HTTP ${event.status}: ${event.url}${event.bodySnippet ? ` — ${event.bodySnippet}` : ""}`
96
+ }));
97
+ case "PackageLoaded": return emit(PluginEvent.TypeRegistryEvent({
98
+ ctx: {
99
+ packageName: event.package,
100
+ version: event.version
101
+ },
102
+ level: "debug",
103
+ kind: "PackageLoaded",
104
+ detail: `${event.files} files, ${event.source}`
105
+ }));
106
+ case "PackageLoadFailed": return emit(PluginEvent.TypeRegistryEvent({
107
+ ctx: {
108
+ packageName: event.package,
109
+ version: event.version
110
+ },
111
+ level: "warn",
112
+ kind: "PackageLoadFailed",
113
+ detail: `[${event.kind}] ${event.error instanceof Error ? event.error.message : String(event.error)}`
114
+ }));
115
+ case "BatchStart": return emit(PluginEvent.TypeRegistryEvent({
116
+ ctx: {},
117
+ level: "debug",
118
+ kind: "BatchStart",
119
+ detail: `${event.total} package(s)`
120
+ }));
121
+ case "BatchComplete": return emit(PluginEvent.TypeRegistryEvent({
122
+ ctx: {},
123
+ level: "info",
124
+ kind: "BatchComplete",
125
+ detail: `${event.loaded}/${event.total} packages, ${event.totalFiles} files, ${Math.round(Duration.toMillis(event.duration))}ms`
126
+ }));
127
+ }
128
+ } });
129
+ /** Metadata plane: a sqlite-backed `@effected/store` Cache rooted in the XDG cache dir. */
130
+ const MetadataCacheLive = Layer.unwrap(Effect.gen(function* () {
131
+ const appDirs = yield* AppDirs;
132
+ const path = yield* Path.Path;
133
+ const cacheDir = yield* appDirs.ensureCache;
134
+ return Cache.layerSqlite({ filename: path.join(cacheDir, "metadata.sqlite") });
135
+ })).pipe(Layer.provide(Layer.mergeAll(AppDirsLive, PlatformLive)));
136
+ /**
137
+ * The full registry runtime: TypeRegistry over an XDG-rooted TypeCache and the
138
+ * jsDelivr PackageFetcher, with the observer that forwards registry events to
139
+ * the plugin's EventBus (found ambiently via serviceOption at emit time).
140
+ */
141
+ const RegistryLayer = TypeRegistry.layer.pipe(Layer.provideMerge(Layer.mergeAll(TypeCache.layerXdg(), PackageFetcher.layer)), Layer.provideMerge(RegistryObserverLayer), Layer.provide(Layer.mergeAll(MetadataCacheLive, AppDirsLive, PlatformLive, NodeHttpClient.layerUndici)));
142
+ /**
143
+ * TypeRegistryService.layer: uses @tsdoctor/registry Effect programs directly.
144
+ */
145
+ const RegistryBackedLive = Layer.effect(TypeRegistryService, Effect.gen(function* () {
146
+ const registry = yield* TypeRegistry;
147
+ return {
148
+ resolveVersions: (packages) => resolveExternalPackageVersions(packages, (pkg) => registry.resolveVersion(pkg.name, pkg.version)).pipe(Effect.catch(() => Effect.succeed([...packages]))),
149
+ loadPackages: (packages) => packages.length === 0 ? Effect.succeed({ vfs: /* @__PURE__ */ new Map() }) : Effect.gen(function* () {
150
+ const specs = packages.map((pkg) => new PackageSpec({
151
+ name: pkg.name,
152
+ version: pkg.version
153
+ }));
154
+ return { vfs: yield* registry.getVfs(specs, { autoFetch: true }) };
155
+ }).pipe(Effect.catch((error) => Effect.fail(new TypeRegistryError({
156
+ packageName: packages.map((p) => p.name).join(", "),
157
+ version: packages.map((p) => p.version).join(", "),
158
+ reason: error instanceof Error ? error.message ?? String(error) : String(error)
159
+ }))))
160
+ };
161
+ })).pipe(Layer.provide(RegistryLayer));
162
+ /**
163
+ * The service when the registry stack cannot be built at all.
164
+ *
165
+ * @remarks
166
+ * Preserves exactly the split the working service documents: `resolveVersions`
167
+ * passes its specs through unresolved rather than swallowing the problem, so
168
+ * the failure surfaces from `loadPackages` as a {@link PluginTypeRegistryError}
169
+ * with a message, which `ConfigService.layer` turns into a build-continues
170
+ * warning. Before acquisition moved to layer construction this fell out of the
171
+ * per-method handlers; it has to be stated explicitly now.
172
+ */
173
+ const DegradedLive = Layer.succeed(TypeRegistryService, {
174
+ resolveVersions: (packages) => Effect.succeed([...packages]),
175
+ loadPackages: (packages) => Effect.fail(new TypeRegistryError({
176
+ packageName: packages.map((p) => p.name).join(", "),
177
+ version: packages.map((p) => p.version).join(", "),
178
+ reason: "type registry unavailable: its cache directory or metadata database could not be opened"
179
+ }))
180
+ });
5
181
 
6
182
  //#endregion
7
183
  export { TypeRegistryService };
@@ -67,7 +67,7 @@ var ShikiCrossLinker = class ShikiCrossLinker {
67
67
  * way and both are immutable per build.
68
68
  *
69
69
  * This replaces a single long-lived instance created at plugin-factory time,
70
- * threaded through `ConfigServiceLive`'s constructor and the build context,
70
+ * threaded through `ConfigService.layer`'s constructor and the build context,
71
71
  * and mutated per API by `reinitialize()`. Scope isolation used to be a
72
72
  * property of internal `…ByScope` maps plus a mutable `currentApiScope` that
73
73
  * any caller could reassign between a lookup and a render; it is now a
package/sync-node-fs.js CHANGED
@@ -1,4 +1,4 @@
1
- import fs from "node:fs";
1
+ import fsSync from "node:fs";
2
2
  import { Effect, FileSystem, Layer, Option, Path, PlatformError } from "effect";
3
3
 
4
4
  //#region src/sync-node-fs.ts
@@ -51,21 +51,21 @@ const infoFromStats = (stats) => ({
51
51
  blocks: Option.some(stats.blocks)
52
52
  });
53
53
  const syncFileSystem = FileSystem.layerNoop({
54
- exists: (path) => Effect.sync(() => fs.existsSync(path)),
54
+ exists: (path) => Effect.sync(() => fsSync.existsSync(path)),
55
55
  stat: (path) => Effect.try({
56
- try: () => infoFromStats(fs.statSync(path)),
56
+ try: () => infoFromStats(fsSync.statSync(path)),
57
57
  catch: (cause) => fail("stat", path, cause)
58
58
  }),
59
59
  readFileString: (path) => Effect.try({
60
- try: () => fs.readFileSync(path, "utf8"),
60
+ try: () => fsSync.readFileSync(path, "utf8"),
61
61
  catch: (cause) => fail("readFileString", path, cause)
62
62
  }),
63
63
  readDirectory: (path) => Effect.try({
64
- try: () => fs.readdirSync(path),
64
+ try: () => fsSync.readdirSync(path),
65
65
  catch: (cause) => fail("readDirectory", path, cause)
66
66
  }),
67
67
  readLink: (path) => Effect.try({
68
- try: () => fs.readlinkSync(path),
68
+ try: () => fsSync.readlinkSync(path),
69
69
  catch: (cause) => fail("readLink", path, cause)
70
70
  })
71
71
  });
@@ -1,10 +1,36 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { dirname, isAbsolute, resolve } from "node:path";
3
- import ts from "typescript";
2
+ import path from "node:path";
3
+ import { TsconfigLoaderSync } from "@effected/tsconfig-json";
4
4
 
5
5
  //#region src/tsconfig-parser.ts
6
6
  /**
7
+ * Reading a `tsconfig.json` into the compiler options the plugin consumes.
8
+ *
9
+ * @remarks
10
+ * A thin adapter over `@effected/tsconfig-json`'s `TsconfigLoaderSync`, which
11
+ * owns `extends` chain resolution (including package specifiers), JSONC
12
+ * parsing and relative-path handling. This module used to hand-roll all three
13
+ * over TypeScript's `parseJsonConfigFileContent`.
14
+ *
15
+ * **The loader returns the tsconfig SPELLING, not the programmatic one.**
16
+ * `target` is `"es2025"` rather than `ts.ScriptTarget.ES2025`, and `lib` is
17
+ * `["esnext"]` rather than `["lib.esnext.d.ts"]`. That is fine, and it is why
18
+ * the normalization seam had to land first: `toProgrammaticCompilerOptions`
19
+ * (`twoslash-transformer.ts`) converts at ONE place, and
20
+ * {@link TypeResolutionCompilerOptions} accepts both spellings by design. Do
21
+ * not convert here — a second conversion site is exactly the drift that made
22
+ * three of four resolution paths load zero lib files once already.
23
+ *
24
+ * @packageDocumentation
25
+ */
26
+ /**
7
27
  * Error thrown when tsconfig.json parsing fails.
28
+ *
29
+ * @remarks
30
+ * Retained as the plugin's own type rather than surfacing the kit's
31
+ * `TsconfigParseError`/`TsconfigExtendsError` directly: `typescript-config.ts`
32
+ * branches on `instanceof TsConfigParseError` to decide whether a failure is
33
+ * already reported, and both kit errors mean the same thing to that caller.
8
34
  */
9
35
  var TsConfigParseError = class extends Error {
10
36
  configPath;
@@ -17,117 +43,73 @@ var TsConfigParseError = class extends Error {
17
43
  }
18
44
  };
19
45
  /**
20
- * Parse a tsconfig.json file and extract compiler options relevant for type resolution.
46
+ * The sync host the kit loader reads through.
21
47
  *
22
- * This function uses TypeScript's native config parsing which automatically handles:
23
- * - `extends` chains (resolves and merges all extended configs)
24
- * - Comments in JSON (JSONC support)
25
- * - Relative path resolution
26
- *
27
- * @param configPath - Path to tsconfig.json (relative or absolute)
28
- * @param projectRoot - Project root directory for resolving relative paths
29
- * @returns Parsed compiler options
30
- * @throws TsConfigParseError if the config cannot be read or parsed
31
- *
32
- * @example
33
- * ```ts
34
- * const options = parseTsConfig("tsconfig.json", "/path/to/project");
35
- * // Returns: { target: 99, module: 99, lib: ["ESNext", "DOM"], ... }
36
- * ```
48
+ * @remarks
49
+ * `node:path` satisfies `SyncPath` verbatim. The filesystem half is two
50
+ * functions, so no shim module is needed.
37
51
  */
38
- function parseTsConfig(configPath, projectRoot) {
39
- return parseTsConfigWithMetadata(configPath, projectRoot).compilerOptions;
40
- }
52
+ const syncHost = {
53
+ fileSystem: {
54
+ exists: existsSync,
55
+ readFile: (filePath) => readFileSync(filePath, "utf8")
56
+ },
57
+ path
58
+ };
41
59
  /**
42
- * Parse a tsconfig.json file and return detailed metadata including extended paths.
60
+ * Parse a `tsconfig.json` and extract the compiler options used for type
61
+ * resolution.
43
62
  *
44
63
  * @param configPath - Path to tsconfig.json (relative or absolute)
45
64
  * @param projectRoot - Project root directory for resolving relative paths
46
- * @returns Parse result with compiler options and metadata
65
+ * @returns The declared compiler options, in the tsconfig spelling
47
66
  * @throws TsConfigParseError if the config cannot be read or parsed
48
67
  *
49
68
  * @example
50
69
  * ```ts
51
- * const result = parseTsConfigWithMetadata("tsconfig.json", "/path/to/project");
52
- * console.log(result.configPath); // Absolute path to resolved config
53
- * console.log(result.extendedPaths); // ["base.json", "tsconfig.json"]
54
- * console.log(result.compilerOptions); // Merged compiler options
70
+ * const options = parseTsConfig("tsconfig.json", "/path/to/project");
71
+ * // Returns: { target: "es2025", module: "nodenext", lib: ["esnext"], ... }
55
72
  * ```
56
73
  */
57
- function parseTsConfigWithMetadata(configPath, projectRoot) {
58
- const absolutePath = isAbsolute(configPath) ? configPath : resolve(projectRoot, configPath);
74
+ function parseTsConfig(configPath, projectRoot) {
75
+ const absolutePath = path.isAbsolute(configPath) ? configPath : path.resolve(projectRoot, configPath);
59
76
  if (!existsSync(absolutePath)) throw new TsConfigParseError(absolutePath, "File not found");
60
- const configFileContent = ts.readConfigFile(absolutePath, (path) => readFileSync(path, "utf-8"));
61
- if (configFileContent.error) {
62
- const message = ts.flattenDiagnosticMessageText(configFileContent.error.messageText, "\n");
63
- throw new TsConfigParseError(absolutePath, message, configFileContent.error);
64
- }
65
- const configDir = dirname(absolutePath);
66
- const parsedConfig = ts.parseJsonConfigFileContent(configFileContent.config, ts.sys, configDir, void 0, absolutePath);
67
- const significantErrors = parsedConfig.errors.filter((error) => {
68
- const message = ts.flattenDiagnosticMessageText(error.messageText, "\n");
69
- return error.code !== 18003 && !message.includes("No inputs were found");
70
- });
71
- if (significantErrors.length > 0) {
72
- const errorMessages = significantErrors.map((error) => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("; ");
73
- throw new TsConfigParseError(absolutePath, errorMessages, significantErrors);
74
- }
75
- const extendedPaths = [absolutePath];
76
- collectExtendedPaths(configFileContent.config, configDir, extendedPaths);
77
- const tsOptions = parsedConfig.options;
78
- return {
79
- compilerOptions: extractTypeResolutionOptions(tsOptions),
80
- configPath: absolutePath,
81
- extendedPaths
82
- };
83
- }
84
- /**
85
- * Recursively collect extended config paths.
86
- * @internal
87
- */
88
- function collectExtendedPaths(config, baseDir, paths) {
89
- if (!config || typeof config !== "object") return;
90
- const extendsValue = config.extends;
91
- if (typeof extendsValue === "string") {
92
- const extendedPath = resolveExtendedPath(extendsValue, baseDir);
93
- if (extendedPath && !paths.includes(extendedPath)) paths.unshift(extendedPath);
94
- } else if (Array.isArray(extendsValue)) {
95
- for (const ext of extendsValue) if (typeof ext === "string") {
96
- const extendedPath = resolveExtendedPath(ext, baseDir);
97
- if (extendedPath && !paths.includes(extendedPath)) paths.unshift(extendedPath);
98
- }
99
- }
100
- }
101
- /**
102
- * Resolve an extended config path.
103
- * @internal
104
- */
105
- function resolveExtendedPath(extendsValue, baseDir) {
77
+ let options;
106
78
  try {
107
- if (extendsValue.startsWith(".")) return resolve(baseDir, extendsValue);
108
- return extendsValue;
109
- } catch {
110
- return null;
79
+ options = TsconfigLoaderSync.compilerOptions(absolutePath, syncHost);
80
+ } catch (error) {
81
+ throw new TsConfigParseError(absolutePath, error instanceof Error ? error.message : String(error), error);
111
82
  }
83
+ return extractTypeResolutionOptions(options);
112
84
  }
113
85
  /**
114
- * Extract TypeResolutionCompilerOptions from full TypeScript CompilerOptions.
115
- * @internal
86
+ * Narrow the full compiler options to the ones the plugin actually consumes.
87
+ *
88
+ * @remarks
89
+ * Deliberately a whitelist. Everything here reaches Twoslash's TypeScript
90
+ * environment, and passing through options the plugin does not understand
91
+ * would let a consumer's unrelated build setting change how examples
92
+ * type-check.
116
93
  */
117
- function extractTypeResolutionOptions(tsOptions) {
118
- const options = {};
119
- if (tsOptions.target !== void 0) options.target = tsOptions.target;
120
- if (tsOptions.module !== void 0) options.module = tsOptions.module;
121
- if (tsOptions.moduleResolution !== void 0) options.moduleResolution = tsOptions.moduleResolution;
122
- if (tsOptions.lib !== void 0 && tsOptions.lib.length > 0) options.lib = tsOptions.lib;
123
- if (tsOptions.strict !== void 0) options.strict = tsOptions.strict;
124
- if (tsOptions.skipLibCheck !== void 0) options.skipLibCheck = tsOptions.skipLibCheck;
125
- if (tsOptions.esModuleInterop !== void 0) options.esModuleInterop = tsOptions.esModuleInterop;
126
- if (tsOptions.allowSyntheticDefaultImports !== void 0) options.allowSyntheticDefaultImports = tsOptions.allowSyntheticDefaultImports;
127
- if (tsOptions.jsx !== void 0) options.jsx = tsOptions.jsx;
128
- if (tsOptions.types !== void 0 && tsOptions.types.length > 0) options.types = tsOptions.types;
129
- return options;
94
+ function extractTypeResolutionOptions(options) {
95
+ const result = {};
96
+ const scalar = (value) => typeof value === "string" || typeof value === "number" ? value : void 0;
97
+ const target = scalar(options.target);
98
+ if (target !== void 0) result.target = target;
99
+ const module_ = scalar(options.module);
100
+ if (module_ !== void 0) result.module = module_;
101
+ const moduleResolution = scalar(options.moduleResolution);
102
+ if (moduleResolution !== void 0) result.moduleResolution = moduleResolution;
103
+ const jsx = scalar(options.jsx);
104
+ if (jsx !== void 0) result.jsx = jsx;
105
+ if (typeof options.strict === "boolean") result.strict = options.strict;
106
+ if (typeof options.skipLibCheck === "boolean") result.skipLibCheck = options.skipLibCheck;
107
+ if (typeof options.esModuleInterop === "boolean") result.esModuleInterop = options.esModuleInterop;
108
+ if (typeof options.allowSyntheticDefaultImports === "boolean") result.allowSyntheticDefaultImports = options.allowSyntheticDefaultImports;
109
+ if (Array.isArray(options.lib) && options.lib.length > 0) result.lib = options.lib.map(String);
110
+ if (Array.isArray(options.types) && options.types.length > 0) result.types = options.types.map(String);
111
+ return result;
130
112
  }
131
113
 
132
114
  //#endregion
133
- export { TsConfigParseError, parseTsConfig, parseTsConfigWithMetadata };
115
+ export { TsConfigParseError, parseTsConfig };
@@ -10,7 +10,7 @@ let current = NOT_INSTALLED;
10
10
  *
11
11
  * @remarks
12
12
  * Called from `plugin.ts`'s Effect program, beside the other seam wiring, and
13
- * NOT from `ConfigServiceLive` — config resolution should compute a value, not
13
+ * NOT from `ConfigService.layer` — config resolution should compute a value, not
14
14
  * also mutate module state on the side.
15
15
  */
16
16
  function installTwoslashAccess(environments) {
@@ -456,7 +456,7 @@ function addTypeRoutes(routes) {
456
456
  *
457
457
  * Clears ONLY the routes. The environments are per-build too, but they are
458
458
  * owned by the layer now, so nothing here can discard the per-scope
459
- * transformers `ConfigServiceLive` just built.
459
+ * transformers `ConfigService.layer` just built.
460
460
  */
461
461
  function clearTypeRoutes() {
462
462
  typeRoutes.clear();