rspress-plugin-api-extractor 0.10.0 → 0.12.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 (54) hide show
  1. package/BuildEnv.js +58 -0
  2. package/build-program.js +34 -33
  3. package/build-stages.js +48 -42
  4. package/config-helpers.js +7 -7
  5. package/errors.js +1 -6
  6. package/index.d.ts +84 -86
  7. package/layers/AppLayer.js +67 -0
  8. package/layers/api-results.js +83 -0
  9. package/layers/build-metrics.js +1 -1
  10. package/layers/config-resolution.js +407 -0
  11. package/layers/external-types.js +74 -0
  12. package/layers/{ObservabilityLive.js → observability.js} +3 -3
  13. package/layers/type-environment.js +109 -0
  14. package/layers/xdg.js +44 -0
  15. package/markdown/helpers.js +9 -55
  16. package/markdown/page-generators/class-page.js +8 -31
  17. package/markdown/page-generators/index-pages.js +6 -8
  18. package/markdown/page-generators/interface-page.js +7 -7
  19. package/markdown/shiki-utils.js +65 -10
  20. package/model-loader.js +3 -3
  21. package/observability/EventBus.js +29 -7
  22. package/observability/heartbeat.js +1 -1
  23. package/observability/sinks/metrics-sink.js +1 -1
  24. package/observability/sinks/trace-sink.js +4 -4
  25. package/observability/spans.js +3 -1
  26. package/observability/sync-emitter.js +78 -0
  27. package/og-resolver.js +74 -284
  28. package/package.json +3 -4
  29. package/path-derivation.js +19 -1
  30. package/plugin.js +63 -91
  31. package/prettier-formatter.js +5 -11
  32. package/remark-api-codeblocks.js +11 -19
  33. package/remark-with-api.js +11 -21
  34. package/schemas/config.js +0 -2
  35. package/services/ConfigService.js +37 -2
  36. package/services/HighlighterService.js +75 -0
  37. package/services/OgService.js +190 -0
  38. package/services/PluginConfig.js +26 -0
  39. package/services/TwoslashCacheService.js +128 -2
  40. package/services/TwoslashEnvironments.js +35 -0
  41. package/services/TypeRegistryService.js +178 -2
  42. package/shiki-transformer.js +53 -234
  43. package/sync-node-fs.js +6 -6
  44. package/tsconfig-parser.js +77 -95
  45. package/twoslash-access.js +48 -0
  46. package/twoslash-transformer.js +106 -83
  47. package/vfs-registry.js +1 -31
  48. package/layers/ConfigServiceLive.js +0 -600
  49. package/layers/PathDerivationServiceLive.js +0 -16
  50. package/layers/TwoslashCacheServiceLive.js +0 -53
  51. package/layers/TypeRegistryServiceLive.js +0 -155
  52. package/markdown/index.js +0 -11
  53. package/schemas/index.js +0 -6
  54. package/services/PathDerivationService.js +0 -7
@@ -0,0 +1,190 @@
1
+ import { PluginEvent } from "../observability/events.js";
2
+ import { emit } from "../observability/EventBus.js";
3
+ import { imageMimeType, ogAltText, resolveOgUrl } from "../og-resolver.js";
4
+ import { Context, Data, Effect, FileSystem, Layer, Option, Path } from "effect";
5
+ import { imageSize } from "image-size";
6
+
7
+ //#region src/services/OgService.ts
8
+ /**
9
+ * Resolving an API's configured Open Graph image into page metadata.
10
+ *
11
+ * @remarks
12
+ * Replaces the `OpenGraphResolver` class, which did synchronous `node:fs` from
13
+ * inside `Effect.promise`, carried its own sync-island event emitter, and
14
+ * returned `undefined` for all three of its failure modes — indistinguishable
15
+ * from "no image was configured".
16
+ *
17
+ * This is also where phase 4's SEO work lands, which is why the contract is
18
+ * wider than today's single caller needs: it names its failures instead of
19
+ * erasing them.
20
+ *
21
+ * @packageDocumentation
22
+ */
23
+ const OgImageErrorBase = Data.TaggedError("OgImageError");
24
+ /**
25
+ * A configured OG image that could not be resolved.
26
+ *
27
+ * @remarks
28
+ * `cause` carries the original failure (an `image-size` parse error, a
29
+ * filesystem error) rather than a stringified copy of it.
30
+ */
31
+ var OgImageError = class extends OgImageErrorBase {
32
+ get message() {
33
+ if (this.code === "invalid-url") return `Invalid Open Graph image URL in '${this.field}': ${this.value} — expected an absolute http(s) URL or a path starting with '/'`;
34
+ if (this.code === "invalid-secure-url") return `Invalid Open Graph secure URL in '${this.field}': ${this.value} — secureUrl must be an absolute https URL`;
35
+ const cause = this.cause instanceof Error ? this.cause.message : String(this.cause);
36
+ return `Could not read Open Graph image '${this.value}': ${cause}`;
37
+ }
38
+ };
39
+ var OgService = class OgService extends Context.Service()("rspress-plugin-api-extractor/OgService") {
40
+ /**
41
+ * Resolve OG images through the core `FileSystem`, with one read per file per
42
+ * build.
43
+ *
44
+ * @remarks
45
+ * The `node:fs` `existsSync` + `imageSizeFromFile` pair this replaces ran once
46
+ * per PAGE, so a 400-page API re-read the same image 400 times. The memo below
47
+ * keys on the absolute path and removes that entirely.
48
+ *
49
+ * The memo is per build, not persisted. A cross-build cache in the shared XDG
50
+ * store was considered and deliberately deferred: it would need mtime/size
51
+ * invalidation to stay sound, and a stale image dimension is a silent wrong
52
+ * answer. There is nothing expensive enough here to justify that yet — when
53
+ * phase 4 starts GENERATING images, which are expensive and content-addressed,
54
+ * the XDG cache is the right home for them.
55
+ *
56
+ * `imageSize` over the read bytes replaces `imageSizeFromFile`, which took a
57
+ * path and therefore required real `node:fs`. Same parser, same output.
58
+ *
59
+ * `Effect.suspend(() => make())` rather than a bare `make`: a static
60
+ * initializer runs while the module body is still evaluating, so naming a
61
+ * `const` declared further down throws at import time with a clean typecheck.
62
+ */
63
+ static layer = Layer.effect(this, Effect.suspend(() => make()));
64
+ /**
65
+ * An in-memory double whose unstubbed member dies naming itself.
66
+ *
67
+ * @remarks
68
+ * **There is deliberately no default `resolveImage`.** A default returning
69
+ * `Option.none` would be indistinguishable from "this API declares no
70
+ * image", which is precisely the ambiguity {@link OgImageError} exists to
71
+ * remove — and a test asserting that a page rendered without an `og:image`
72
+ * would then pass whether or not the service was ever consulted.
73
+ *
74
+ * Where the wiring is what matters, prefer the real layer over a platform
75
+ * filesystem instead of this double; see `__test__/utils/layers.ts`.
76
+ */
77
+ static makeTest = (overrides = {}) => ({ resolveImage: overrides.resolveImage ?? (() => unstubbed("resolveImage")) });
78
+ /** {@link OgService.makeTest} behind a `Layer`. */
79
+ static layerTest = (overrides = {}) => Layer.succeed(OgService, OgService.makeTest(overrides));
80
+ };
81
+ const unstubbed = (member) => {
82
+ throw new Error(`OgService.makeTest: ${member}() was called but not stubbed — pass an override.`);
83
+ };
84
+ const make = () => Effect.gen(function* () {
85
+ const fileSystem = yield* FileSystem.FileSystem;
86
+ const path = yield* Path.Path;
87
+ /** Absolute path → facts, or `null` for "looked, could not use it". */
88
+ const factsByPath = /* @__PURE__ */ new Map();
89
+ /** Locate a root-relative image under the docs `public/` directory. */
90
+ const findLocalImage = (imagePath, docsRoot) => {
91
+ if (docsRoot == null || !imagePath.startsWith("/")) return Effect.succeed(Option.none());
92
+ const candidate = path.join(docsRoot, "public", imagePath);
93
+ return fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false), Effect.map((found) => found ? Option.some(candidate) : Option.none()));
94
+ };
95
+ /**
96
+ * Read dimensions and MIME type. A file that cannot be parsed warns and
97
+ * yields nothing — the page still gets its `og:image`, just without
98
+ * dimensions, which is what the class this replaced did.
99
+ */
100
+ const readImageFacts = (filePath) => Effect.gen(function* () {
101
+ const memoed = factsByPath.get(filePath);
102
+ if (memoed !== void 0) return memoed;
103
+ const result = yield* Effect.result(fileSystem.readFile(filePath).pipe(Effect.flatMap((bytes) => Effect.try(() => imageSize(bytes)))));
104
+ if (result._tag === "Failure") {
105
+ const error = new OgImageError({
106
+ code: "unreadable-image",
107
+ field: "ogImage",
108
+ value: filePath,
109
+ cause: result.failure
110
+ });
111
+ yield* emit(PluginEvent.ConfigValidationWarning({
112
+ ctx: {},
113
+ field: "ogImage",
114
+ value: filePath,
115
+ reason: error.message,
116
+ level: "warn"
117
+ }));
118
+ factsByPath.set(filePath, null);
119
+ return null;
120
+ }
121
+ const size = result.success;
122
+ const mimeType = imageMimeType(size.type);
123
+ const facts = {
124
+ ...size.width != null ? { width: size.width } : {},
125
+ ...size.height != null ? { height: size.height } : {},
126
+ ...mimeType != null ? { type: mimeType } : {}
127
+ };
128
+ factsByPath.set(filePath, facts);
129
+ return facts;
130
+ });
131
+ const resolveFromString = (imageUrl, request) => Effect.gen(function* () {
132
+ const resolvedUrl = resolveOgUrl(request.siteUrl, imageUrl);
133
+ if (resolvedUrl == null) return yield* Effect.fail(new OgImageError({
134
+ code: "invalid-url",
135
+ field: "ogImage",
136
+ value: imageUrl
137
+ }));
138
+ const localPath = yield* findLocalImage(imageUrl, request.docsRoot);
139
+ const facts = Option.isSome(localPath) ? yield* readImageFacts(localPath.value) : null;
140
+ return Option.some({
141
+ url: resolvedUrl,
142
+ type: facts?.type,
143
+ width: facts?.width,
144
+ height: facts?.height,
145
+ alt: ogAltText(request.packageName, request.apiName)
146
+ });
147
+ });
148
+ const resolveFromMetadata = (metadata, request) => Effect.gen(function* () {
149
+ const { url, secureUrl, type, width, height, alt } = metadata;
150
+ const resolvedUrl = resolveOgUrl(request.siteUrl, url);
151
+ if (resolvedUrl == null) return yield* Effect.fail(new OgImageError({
152
+ code: "invalid-url",
153
+ field: "ogImage.url",
154
+ value: url
155
+ }));
156
+ let resolvedSecureUrl;
157
+ if (secureUrl != null) {
158
+ if (secureUrl.startsWith("https://")) resolvedSecureUrl = secureUrl;
159
+ else {
160
+ const error = new OgImageError({
161
+ code: "invalid-secure-url",
162
+ field: "ogImage.secureUrl",
163
+ value: secureUrl
164
+ });
165
+ yield* emit(PluginEvent.ConfigValidationWarning({
166
+ ctx: {},
167
+ field: "ogImage.secureUrl",
168
+ value: secureUrl,
169
+ reason: error.message,
170
+ level: "warn"
171
+ }));
172
+ }
173
+ }
174
+ return Option.some({
175
+ url: resolvedUrl,
176
+ secureUrl: resolvedSecureUrl,
177
+ type,
178
+ width,
179
+ height,
180
+ alt: alt ?? ogAltText(request.packageName, request.apiName)
181
+ });
182
+ });
183
+ return { resolveImage: (request) => {
184
+ if (request.config == null) return Effect.succeed(Option.none());
185
+ return typeof request.config === "object" ? resolveFromMetadata(request.config, request) : resolveFromString(request.config, request);
186
+ } };
187
+ });
188
+
189
+ //#endregion
190
+ export { OgImageError, OgService };
@@ -0,0 +1,26 @@
1
+ import { Context } from "effect";
2
+
3
+ //#region src/services/PluginConfig.ts
4
+ /**
5
+ * The decoded plugin options, as a service.
6
+ *
7
+ * @remarks
8
+ * `ConfigService.layer` used to be a factory taking these as an argument, which
9
+ * made it a layer-returning function — the shape the house rules warn about,
10
+ * since layers memoize by reference and a second call mints a second layer.
11
+ * It was only ever called once, but "only ever called once" is a property of
12
+ * the current call sites, not of the design.
13
+ *
14
+ * A `Context.Service` rather than a `Context.Reference`, deliberately, and for
15
+ * the same reason the Shiki themes are a layer argument: a Reference carries a
16
+ * default, so a wiring mistake would silently resolve to empty options and the
17
+ * build would document nothing while reporting success. There is no sensible
18
+ * default for "which APIs is this site documenting", so forgetting to provide
19
+ * it should be a loud "service not provided", which is what this gives.
20
+ *
21
+ * @packageDocumentation
22
+ */
23
+ var PluginConfig = class extends Context.Service()("rspress-plugin-api-extractor/PluginConfig") {};
24
+
25
+ //#endregion
26
+ export { PluginConfig };
@@ -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 };
@@ -0,0 +1,35 @@
1
+ import { TwoslashEnvironmentRegistry } from "../twoslash-transformer.js";
2
+ import { Context, Layer } from "effect";
3
+
4
+ //#region src/services/TwoslashEnvironments.ts
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
+ };
33
+
34
+ //#endregion
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 };