rspress-plugin-api-extractor 0.9.2 → 0.11.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 (52) hide show
  1. package/BuildEnv.js +58 -0
  2. package/README.md +2 -1
  3. package/build-program.js +33 -30
  4. package/build-stages.js +47 -39
  5. package/errors.js +0 -1
  6. package/index.d.ts +22 -14
  7. package/layers/ConfigServiceLive.js +349 -400
  8. package/layers/HighlighterServiceLive.js +52 -0
  9. package/layers/ObservabilityLive.js +26 -7
  10. package/layers/OgServiceLive.js +134 -0
  11. package/layers/TwoslashCacheServiceLive.js +108 -0
  12. package/layers/TwoslashEnvironmentsLive.js +33 -0
  13. package/layers/TypeRegistryServiceLive.js +54 -47
  14. package/layers/build-metrics.js +32 -5
  15. package/layers/xdg.js +44 -0
  16. package/markdown/helpers.js +9 -55
  17. package/markdown/page-generators/class-page.js +8 -31
  18. package/markdown/page-generators/index-pages.js +6 -8
  19. package/markdown/page-generators/interface-page.js +7 -7
  20. package/markdown/shiki-utils.js +65 -10
  21. package/observability/EventBus.js +29 -9
  22. package/observability/heartbeat.js +1 -1
  23. package/observability/metric-report.js +124 -0
  24. package/observability/sinks/console-sink.js +6 -0
  25. package/observability/sinks/metrics-sink.js +64 -21
  26. package/observability/sinks/render-sink.js +86 -0
  27. package/observability/sinks/trace-sink.js +10 -17
  28. package/observability/spans.js +4 -2
  29. package/observability/sync-emitter.js +78 -0
  30. package/og-resolver.js +46 -287
  31. package/package.json +4 -5
  32. package/path-derivation.js +19 -1
  33. package/plugin.js +64 -52
  34. package/prettier-formatter.js +4 -10
  35. package/remark-api-codeblocks.js +33 -15
  36. package/remark-with-api.js +24 -27
  37. package/schemas/config.js +11 -7
  38. package/services/HighlighterService.js +30 -0
  39. package/services/OgService.js +23 -0
  40. package/services/PluginConfig.js +26 -0
  41. package/services/TwoslashCacheService.js +15 -0
  42. package/services/TwoslashEnvironments.js +7 -0
  43. package/shiki-transformer.js +55 -256
  44. package/twoslash-access.js +48 -0
  45. package/twoslash-cache.js +174 -0
  46. package/twoslash-patterns.js +1 -1
  47. package/twoslash-timing-wrapper.js +23 -0
  48. package/twoslash-transformer.js +153 -89
  49. package/vfs-registry.js +1 -31
  50. package/layers/PathDerivationServiceLive.js +0 -16
  51. package/runtime/components/MarkdownText/index.js +0 -34
  52. package/services/PathDerivationService.js +0 -7
@@ -0,0 +1,52 @@
1
+ import { PluginEvent } from "../observability/events.js";
2
+ import { emit } from "../observability/EventBus.js";
3
+ import { HighlighterService } from "../services/HighlighterService.js";
4
+ import { SHIKI_LANGS } from "../markdown/shiki-utils.js";
5
+ import { Effect, Layer } from "effect";
6
+ import { createHighlighter } from "shiki";
7
+
8
+ //#region src/layers/HighlighterServiceLive.ts
9
+ /**
10
+ * Acquire the build's highlighter, and release it when the runtime is
11
+ * disposed.
12
+ *
13
+ * @remarks
14
+ * `Layer.effect` over `Effect.acquireRelease` is the v4 scoped-constructor
15
+ * idiom (`Layer.scoped` is gone; `Layer.effect` strips `Scope` from `R`).
16
+ * Because the layer sits in the `ManagedRuntime`'s stack, the highlighter is
17
+ * created on the runtime's first use and `dispose()`d by
18
+ * `effectRuntime.dispose()` — which `plugin.ts` calls on production builds
19
+ * only, so a dev HMR session keeps one highlighter across rebuilds instead of
20
+ * leaking one per rebuild.
21
+ *
22
+ * **Bind the result to a `const`.** This is a layer FACTORY: each call mints a
23
+ * fresh layer reference, and layers memoize by reference, so calling it twice
24
+ * in one graph acquires two highlighters — the exact leak this layer exists to
25
+ * fix.
26
+ *
27
+ * The themes are passed in rather than read from a resolved build context
28
+ * because the layer builds before `ConfigService.resolve()` runs. Passing them
29
+ * as an argument rather than through a `Context.Reference` is deliberate: a
30
+ * Reference carries a default, so forgetting to wire it would silently load
31
+ * only the default themes and render every custom-themed block wrong. A
32
+ * missing argument is a type error.
33
+ */
34
+ function HighlighterServiceLive(themes) {
35
+ return Layer.effect(HighlighterService, Effect.gen(function* () {
36
+ const startedMs = performance.now();
37
+ const highlighter = yield* Effect.acquireRelease(Effect.promise(() => createHighlighter({
38
+ themes: [...themes],
39
+ langs: [...SHIKI_LANGS]
40
+ })), (instance) => Effect.sync(() => instance.dispose()));
41
+ yield* emit(PluginEvent.PhaseCompleted({
42
+ ctx: {},
43
+ level: "debug",
44
+ phase: "shikiInit",
45
+ durationMs: Math.round(performance.now() - startedMs)
46
+ }));
47
+ return { highlighter };
48
+ }));
49
+ }
50
+
51
+ //#endregion
52
+ export { HighlighterServiceLive };
@@ -1,8 +1,10 @@
1
1
  import { makeEventBusLayer } from "../observability/EventBus.js";
2
- import { BuildMetrics } from "./build-metrics.js";
2
+ import { formatCodeBlockReport, seriesFor } from "../observability/metric-report.js";
3
+ import { BuildMetrics, makeMetricStore } from "./build-metrics.js";
3
4
  import { makeConsoleSink } from "../observability/sinks/console-sink.js";
4
5
  import { makeIssuesSink } from "../observability/sinks/issues-sink.js";
5
6
  import { makeMetricsSink } from "../observability/sinks/metrics-sink.js";
7
+ import { makeRenderSink } from "../observability/sinks/render-sink.js";
6
8
  import { makeTraceSink } from "../observability/sinks/trace-sink.js";
7
9
  import { Effect, Layer, Logger, Metric, References } from "effect";
8
10
 
@@ -29,7 +31,8 @@ function makeSummaryLoggerLayer(logLevel) {
29
31
  return Layer.mergeAll(Logger.layer([pluginLogger]), Layer.succeed(References.MinimumLogLevel, effectLevel));
30
32
  }
31
33
  /**
32
- * Compose the console + metrics + issues (+ optional trace) sinks into an EventBus layer.
34
+ * Compose the console + metrics + issues + render (+ optional trace) sinks into
35
+ * an EventBus layer.
33
36
  *
34
37
  * `cwd` is known at plugin-factory time (unlike the RSPress `outDir`), so the
35
38
  * trace path is resolved eagerly by `resolveObservability` and the trace sink
@@ -37,17 +40,22 @@ function makeSummaryLoggerLayer(logLevel) {
37
40
  */
38
41
  function buildEventBus(obs) {
39
42
  const issues = makeIssuesSink();
43
+ const render = makeRenderSink();
44
+ const metrics = makeMetricStore();
40
45
  const sinks = [
41
46
  makeConsoleSink(obs.logLevel, { json: obs.json }),
42
- makeMetricsSink(),
43
- issues
47
+ makeMetricsSink(metrics.context),
48
+ issues,
49
+ render
44
50
  ];
45
51
  const trace = obs.tracePath ? makeTraceSink(obs.tracePath) : null;
46
52
  if (trace) sinks.push(trace);
47
53
  return {
48
54
  layer: makeEventBusLayer(sinks),
49
55
  trace,
50
- issues
56
+ issues,
57
+ render,
58
+ metrics
51
59
  };
52
60
  }
53
61
  /**
@@ -55,8 +63,12 @@ function buildEventBus(obs) {
55
63
  * Accepts the configured slow-codeblock threshold so the warning message
56
64
  * interpolates the actual threshold rather than a hard-coded 100ms.
57
65
  * Replaces the 4 separate logSummary() calls in afterBuild.
66
+ *
67
+ * When a code-block report is supplied, the per-scope attribution lines are
68
+ * appended after the aggregate code-block line. The report is optional so
69
+ * existing callers and tests keep working unchanged.
58
70
  */
59
- const logBuildSummary = (slowCodeBlockMs) => Effect.gen(function* () {
71
+ const logBuildSummary = (slowCodeBlockMs, renderPhase) => Effect.gen(function* () {
60
72
  const filesTotal = yield* Metric.value(BuildMetrics.filesTotal);
61
73
  const filesNew = yield* Metric.value(BuildMetrics.filesNew);
62
74
  const filesModified = yield* Metric.value(BuildMetrics.filesModified);
@@ -68,6 +80,7 @@ const logBuildSummary = (slowCodeBlockMs) => Effect.gen(function* () {
68
80
  const pagesGenerated = yield* Metric.value(BuildMetrics.pagesGenerated);
69
81
  const externalPackages = yield* Metric.value(BuildMetrics.externalPackagesTotal);
70
82
  const phaseDurationSnapshot = yield* Metric.value(BuildMetrics.phaseDuration);
83
+ const snapshots = yield* Metric.snapshot;
71
84
  const total = filesTotal.count;
72
85
  const newCount = filesNew.count;
73
86
  const modified = filesModified.count;
@@ -86,14 +99,20 @@ const logBuildSummary = (slowCodeBlockMs) => Effect.gen(function* () {
86
99
  yield* Effect.log(`📝 ${total} files (${parts.join(", ")})`);
87
100
  }
88
101
  if (pagesGenerated.count > 0) yield* Effect.log(`🧩 ${pagesGenerated.count} pages, ${externalPackages.count} external package(s)`);
89
- if (phaseDurationSnapshot.count > 0) yield* Effect.log(`⏱ ${phaseDurationSnapshot.count} phase(s) timed`);
102
+ if (phaseDurationSnapshot.count > 0) {
103
+ const named = seriesFor(snapshots, "phase.time.ms").map((p) => `${p.attributes.phase ?? "?"} ${(p.value / 1e3).toFixed(1)}s`).slice(0, 6).join(", ");
104
+ yield* Effect.log(named.length > 0 ? `⏱ ${phaseDurationSnapshot.count} phase(s): ${named}` : `⏱ ${phaseDurationSnapshot.count} phase(s) timed`);
105
+ }
90
106
  if (blocks > 0 && slowBlocks > 0) yield* Effect.logWarning(`Code block performance: ${slowBlocks} of ${blocks} blocks were slow (>${slowCodeBlockMs}ms)`);
107
+ if (renderPhase) for (const line of formatCodeBlockReport(renderPhase)) yield* Effect.log(line);
91
108
  const totalErrors = tsErrors + prErrors;
92
109
  if (totalErrors > 0) {
93
110
  const errorParts = [];
94
111
  if (tsErrors > 0) errorParts.push(`${tsErrors} Twoslash`);
95
112
  if (prErrors > 0) errorParts.push(`${prErrors} Prettier`);
96
113
  yield* Effect.logWarning(`${totalErrors} error(s) in code blocks (${errorParts.join(", ")})`);
114
+ const byCode = seriesFor(snapshots, "twoslash.diagnostics");
115
+ for (const series of byCode.slice(0, 5)) yield* Effect.logWarning(` ${series.attributes.code ?? "TS?"} x${series.value} in ${series.attributes.scope ?? "(unscoped)"}`);
97
116
  }
98
117
  });
99
118
 
@@ -0,0 +1,134 @@
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 { OgImageError, OgService } from "../services/OgService.js";
5
+ import { Effect, FileSystem, Layer, Option, Path } from "effect";
6
+ import { imageSize } from "image-size";
7
+
8
+ //#region src/layers/OgServiceLive.ts
9
+ /**
10
+ * Resolve OG images through the core `FileSystem`, with one read per file per
11
+ * build.
12
+ *
13
+ * @remarks
14
+ * The `node:fs` `existsSync` + `imageSizeFromFile` pair this replaces ran once
15
+ * per PAGE, so a 400-page API re-read the same image 400 times. The memo below
16
+ * keys on the absolute path and removes that entirely.
17
+ *
18
+ * The memo is per build, not persisted. A cross-build cache in the shared XDG
19
+ * store was considered and deliberately deferred: it would need mtime/size
20
+ * invalidation to stay sound, and a stale image dimension is a silent wrong
21
+ * answer. There is nothing expensive enough here to justify that yet — when
22
+ * phase 4 starts GENERATING images, which are expensive and content-addressed,
23
+ * the XDG cache is the right home for them.
24
+ *
25
+ * `imageSize` over the read bytes replaces `imageSizeFromFile`, which took a
26
+ * path and therefore required real `node:fs`. Same parser, same output.
27
+ */
28
+ const OgServiceLive = Layer.effect(OgService, Effect.gen(function* () {
29
+ const fileSystem = yield* FileSystem.FileSystem;
30
+ const path = yield* Path.Path;
31
+ /** Absolute path → facts, or `null` for "looked, could not use it". */
32
+ const factsByPath = /* @__PURE__ */ new Map();
33
+ /** Locate a root-relative image under the docs `public/` directory. */
34
+ const findLocalImage = (imagePath, docsRoot) => {
35
+ if (docsRoot == null || !imagePath.startsWith("/")) return Effect.succeed(Option.none());
36
+ const candidate = path.join(docsRoot, "public", imagePath);
37
+ return fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false), Effect.map((found) => found ? Option.some(candidate) : Option.none()));
38
+ };
39
+ /**
40
+ * Read dimensions and MIME type. A file that cannot be parsed warns and
41
+ * yields nothing — the page still gets its `og:image`, just without
42
+ * dimensions, which is what the class this replaced did.
43
+ */
44
+ const readImageFacts = (filePath) => Effect.gen(function* () {
45
+ const memoed = factsByPath.get(filePath);
46
+ if (memoed !== void 0) return memoed;
47
+ const result = yield* Effect.result(fileSystem.readFile(filePath).pipe(Effect.flatMap((bytes) => Effect.try(() => imageSize(bytes)))));
48
+ if (result._tag === "Failure") {
49
+ const error = new OgImageError({
50
+ code: "unreadable-image",
51
+ field: "ogImage",
52
+ value: filePath,
53
+ cause: result.failure
54
+ });
55
+ yield* emit(PluginEvent.ConfigValidationWarning({
56
+ ctx: {},
57
+ field: "ogImage",
58
+ value: filePath,
59
+ reason: error.message,
60
+ level: "warn"
61
+ }));
62
+ factsByPath.set(filePath, null);
63
+ return null;
64
+ }
65
+ const size = result.success;
66
+ const mimeType = imageMimeType(size.type);
67
+ const facts = {
68
+ ...size.width != null ? { width: size.width } : {},
69
+ ...size.height != null ? { height: size.height } : {},
70
+ ...mimeType != null ? { type: mimeType } : {}
71
+ };
72
+ factsByPath.set(filePath, facts);
73
+ return facts;
74
+ });
75
+ const resolveFromString = (imageUrl, request) => Effect.gen(function* () {
76
+ const resolvedUrl = resolveOgUrl(request.siteUrl, imageUrl);
77
+ if (resolvedUrl == null) return yield* Effect.fail(new OgImageError({
78
+ code: "invalid-url",
79
+ field: "ogImage",
80
+ value: imageUrl
81
+ }));
82
+ const localPath = yield* findLocalImage(imageUrl, request.docsRoot);
83
+ const facts = Option.isSome(localPath) ? yield* readImageFacts(localPath.value) : null;
84
+ return Option.some({
85
+ url: resolvedUrl,
86
+ type: facts?.type,
87
+ width: facts?.width,
88
+ height: facts?.height,
89
+ alt: ogAltText(request.packageName, request.apiName)
90
+ });
91
+ });
92
+ const resolveFromMetadata = (metadata, request) => Effect.gen(function* () {
93
+ const { url, secureUrl, type, width, height, alt } = metadata;
94
+ const resolvedUrl = resolveOgUrl(request.siteUrl, url);
95
+ if (resolvedUrl == null) return yield* Effect.fail(new OgImageError({
96
+ code: "invalid-url",
97
+ field: "ogImage.url",
98
+ value: url
99
+ }));
100
+ let resolvedSecureUrl;
101
+ if (secureUrl != null) {
102
+ if (secureUrl.startsWith("https://")) resolvedSecureUrl = secureUrl;
103
+ else {
104
+ const error = new OgImageError({
105
+ code: "invalid-secure-url",
106
+ field: "ogImage.secureUrl",
107
+ value: secureUrl
108
+ });
109
+ yield* emit(PluginEvent.ConfigValidationWarning({
110
+ ctx: {},
111
+ field: "ogImage.secureUrl",
112
+ value: secureUrl,
113
+ reason: error.message,
114
+ level: "warn"
115
+ }));
116
+ }
117
+ }
118
+ return Option.some({
119
+ url: resolvedUrl,
120
+ secureUrl: resolvedSecureUrl,
121
+ type,
122
+ width,
123
+ height,
124
+ alt: alt ?? ogAltText(request.packageName, request.apiName)
125
+ });
126
+ });
127
+ return { resolveImage: (request) => {
128
+ if (request.config == null) return Effect.succeed(Option.none());
129
+ return typeof request.config === "object" ? resolveFromMetadata(request.config, request) : resolveFromString(request.config, request);
130
+ } };
131
+ }));
132
+
133
+ //#endregion
134
+ export { OgServiceLive };
@@ -0,0 +1,108 @@
1
+ import { TwoslashCacheService } from "../services/TwoslashCacheService.js";
2
+ import { decodeTwoslashCache, encodeTwoslashCache, makeTwoslashCache, twoslashBlobKey } from "../twoslash-cache.js";
3
+ import { AppDirsLive, PlatformLive } from "./xdg.js";
4
+ import { Effect, Layer, Option, Path } from "effect";
5
+ import { Cache } from "@effected/store";
6
+ import { AppDirs } from "@effected/xdg";
7
+
8
+ //#region src/layers/TwoslashCacheServiceLive.ts
9
+ /**
10
+ * A sqlite-backed `@effected/store` Cache in the XDG cache dir, separate from
11
+ * the registry's `metadata.sqlite`.
12
+ *
13
+ * XDG rather than the repo: these are regenerable results derived from content
14
+ * hashes, so they belong with the user's other caches — shared across worktrees
15
+ * and checkouts of the same project, and untouched by cleaning `dist/`. Nothing
16
+ * here needs to be committed for a build to be correct.
17
+ */
18
+ const CacheLive = Layer.unwrap(Effect.gen(function* () {
19
+ const appDirs = yield* AppDirs;
20
+ const path = yield* Path.Path;
21
+ const cacheDir = yield* appDirs.ensureCache;
22
+ return Cache.layerSqlite({ filename: path.join(cacheDir, "twoslash.sqlite") });
23
+ })).pipe(Layer.provide(Layer.mergeAll(AppDirsLive, PlatformLive)));
24
+ /**
25
+ * Live Twoslash cache persistence.
26
+ *
27
+ * Both operations swallow every failure by design — see the service docs. A
28
+ * missing HOME, an unwritable cache dir or a corrupt database degrades the
29
+ * build to "type-check everything", which is exactly the behaviour before this
30
+ * cache existed.
31
+ */
32
+ /**
33
+ * Add the build-generation half of the service on top of a load/save pair.
34
+ *
35
+ * @remarks
36
+ * Shared by the real and degraded layers so the two cannot drift: a degraded
37
+ * build must still hand out a working in-memory cache, otherwise the
38
+ * transformers have nothing to read or write and the render pass changes
39
+ * shape rather than merely losing persistence.
40
+ */
41
+ function withGeneration(base) {
42
+ let open = null;
43
+ return {
44
+ ...base,
45
+ open: (envHash) => base.load(envHash).pipe(Effect.map((restored) => {
46
+ const cache = makeTwoslashCache(restored);
47
+ open = {
48
+ cache,
49
+ envHash
50
+ };
51
+ return cache;
52
+ })),
53
+ persist: () => Effect.suspend(() => {
54
+ if (open === null) return Effect.succeed(Option.none());
55
+ const { cache, envHash } = open;
56
+ const stats = cache.stats();
57
+ const report = Option.some({
58
+ ...stats,
59
+ envHash
60
+ });
61
+ return stats.dirty ? base.save(envHash, cache.entries()).pipe(Effect.as(report)) : Effect.succeed(report);
62
+ })
63
+ };
64
+ }
65
+ const CacheBackedLive = Layer.effect(TwoslashCacheService, Effect.gen(function* () {
66
+ const cache = yield* Cache;
67
+ return withGeneration({
68
+ 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()))),
69
+ save: (envHash, entries) => cache.set({
70
+ key: twoslashBlobKey(envHash),
71
+ value: encodeTwoslashCache(entries),
72
+ tags: ["twoslash"]
73
+ }).pipe(Effect.catch(() => Effect.void))
74
+ });
75
+ })).pipe(Layer.provide(CacheLive));
76
+ /**
77
+ * A cache that holds nothing, for when the real one cannot be opened.
78
+ *
79
+ * @remarks
80
+ * `load` returns empty and `save` discards, which is precisely the behaviour
81
+ * before this cache existed: type-check everything, persist nothing.
82
+ */
83
+ const DegradedLive = Layer.succeed(TwoslashCacheService, withGeneration({
84
+ load: () => Effect.succeed(/* @__PURE__ */ new Map()),
85
+ save: () => Effect.void
86
+ }));
87
+ /**
88
+ * Live Twoslash cache persistence.
89
+ *
90
+ * @remarks
91
+ * Failure is absorbed at TWO levels, and both are load-bearing. Inside the
92
+ * service, a failed read or write degrades that one operation. Around the
93
+ * layer, a failed CONSTRUCTION — no HOME for XDG, an unwritable cache
94
+ * directory, a corrupt database — degrades to {@link DegradedLive}.
95
+ *
96
+ * The second is why `Layer.catchCause` wraps this at all. While the sqlite
97
+ * layer was provided inside each method, a construction failure surfaced as
98
+ * that method's failure and the in-method handler swallowed it. Hoisting
99
+ * acquisition to layer construction moved the failure to `ManagedRuntime`
100
+ * build time, where it would abort the entire build — breaking the contract
101
+ * this service documents, that an unreachable cache must never fail a build
102
+ * that would otherwise succeed. `catchCause` rather than a failure-only catch
103
+ * because a defect thrown by the sqlite driver must degrade too.
104
+ */
105
+ const TwoslashCacheServiceLive = CacheBackedLive.pipe(Layer.catchCause(() => DegradedLive));
106
+
107
+ //#endregion
108
+ export { TwoslashCacheServiceLive };
@@ -0,0 +1,33 @@
1
+ import { TwoslashEnvironments } from "../services/TwoslashEnvironments.js";
2
+ import { TwoslashEnvironmentRegistry } from "../twoslash-transformer.js";
3
+ import { Layer } from "effect";
4
+
5
+ //#region src/layers/TwoslashEnvironmentsLive.ts
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
+ const TwoslashEnvironmentsLive = Layer.sync(TwoslashEnvironments, () => {
22
+ const registry = new TwoslashEnvironmentRegistry();
23
+ return {
24
+ registerEnvironment: (options) => registry.registerEnvironment(options),
25
+ registerScope: (apiScope, compilerOptions) => registry.registerScope(apiScope, compilerOptions),
26
+ transformerFor: (apiScope) => registry.transformerFor(apiScope),
27
+ setCurrentFile: (path) => registry.setCurrentFile(path),
28
+ reportErrorForTest: (error, code, file) => registry.reportErrorForTest(error, code, file)
29
+ };
30
+ });
31
+
32
+ //#endregion
33
+ export { TwoslashEnvironmentsLive };
@@ -3,11 +3,12 @@ import { emit } from "../observability/EventBus.js";
3
3
  import { resolveExternalPackageVersions } from "../config-utils.js";
4
4
  import { TypeRegistryError } from "../errors.js";
5
5
  import { TypeRegistryService } from "../services/TypeRegistryService.js";
6
- import { NodeFileSystem, NodeHttpClient } from "@effect/platform-node";
6
+ import { AppDirsLive, PlatformLive } from "./xdg.js";
7
+ import { NodeHttpClient } from "@effect/platform-node";
7
8
  import { Duration, Effect, Layer, Path } from "effect";
8
9
  import { PackageFetcher, PackageSpec, RegistryObserver, TypeCache, TypeRegistry } from "@tsdoctor/registry";
9
10
  import { Cache } from "@effected/store";
10
- import { AppDirs, Xdg } from "@effected/xdg";
11
+ import { AppDirs } from "@effected/xdg";
11
12
 
12
13
  //#region src/layers/TypeRegistryServiceLive.ts
13
14
  /**
@@ -22,19 +23,13 @@ import { AppDirs, Xdg } from "@effected/xdg";
22
23
  const RegistryObserverLayer = Layer.succeed(RegistryObserver, { emit: (event) => {
23
24
  switch (event._tag) {
24
25
  case "VersionResolved": return emit(PluginEvent.TypeRegistryEvent({
25
- ctx: {
26
- buildId: "",
27
- packageName: event.package
28
- },
26
+ ctx: { packageName: event.package },
29
27
  level: "debug",
30
28
  kind: "VersionResolved",
31
29
  detail: `${event.requested} -> ${event.resolved}`
32
30
  }));
33
31
  case "VersionResolveFailed": return emit(PluginEvent.TypeRegistryEvent({
34
- ctx: {
35
- buildId: "",
36
- packageName: event.package
37
- },
32
+ ctx: { packageName: event.package },
38
33
  level: "debug",
39
34
  kind: "VersionResolveFailed",
40
35
  detail: `${event.requested}: ${event.kind}`
@@ -43,7 +38,6 @@ const RegistryObserverLayer = Layer.succeed(RegistryObserver, { emit: (event) =>
43
38
  case "CacheMiss":
44
39
  case "FetchStart": return emit(PluginEvent.TypeRegistryEvent({
45
40
  ctx: {
46
- buildId: "",
47
41
  packageName: event.package,
48
42
  version: event.version
49
43
  },
@@ -53,7 +47,6 @@ const RegistryObserverLayer = Layer.succeed(RegistryObserver, { emit: (event) =>
53
47
  }));
54
48
  case "CacheStale": return emit(PluginEvent.TypeRegistryEvent({
55
49
  ctx: {
56
- buildId: "",
57
50
  packageName: event.package,
58
51
  version: event.version
59
52
  },
@@ -62,14 +55,13 @@ const RegistryObserverLayer = Layer.succeed(RegistryObserver, { emit: (event) =>
62
55
  detail: ""
63
56
  }));
64
57
  case "FetchFailed": return emit(PluginEvent.TypeRegistryEvent({
65
- ctx: { buildId: "" },
58
+ ctx: {},
66
59
  level: "debug",
67
60
  kind: "FetchFailed",
68
61
  detail: `HTTP ${event.status}: ${event.url}${event.bodySnippet ? ` — ${event.bodySnippet}` : ""}`
69
62
  }));
70
63
  case "PackageLoaded": return emit(PluginEvent.TypeRegistryEvent({
71
64
  ctx: {
72
- buildId: "",
73
65
  packageName: event.package,
74
66
  version: event.version
75
67
  },
@@ -79,7 +71,6 @@ const RegistryObserverLayer = Layer.succeed(RegistryObserver, { emit: (event) =>
79
71
  }));
80
72
  case "PackageLoadFailed": return emit(PluginEvent.TypeRegistryEvent({
81
73
  ctx: {
82
- buildId: "",
83
74
  packageName: event.package,
84
75
  version: event.version
85
76
  },
@@ -88,35 +79,19 @@ const RegistryObserverLayer = Layer.succeed(RegistryObserver, { emit: (event) =>
88
79
  detail: `[${event.kind}] ${event.error instanceof Error ? event.error.message : String(event.error)}`
89
80
  }));
90
81
  case "BatchStart": return emit(PluginEvent.TypeRegistryEvent({
91
- ctx: { buildId: "" },
82
+ ctx: {},
92
83
  level: "debug",
93
84
  kind: "BatchStart",
94
85
  detail: `${event.total} package(s)`
95
86
  }));
96
87
  case "BatchComplete": return emit(PluginEvent.TypeRegistryEvent({
97
- ctx: { buildId: "" },
88
+ ctx: {},
98
89
  level: "info",
99
90
  kind: "BatchComplete",
100
91
  detail: `${event.loaded}/${event.total} packages, ${event.totalFiles} files, ${Math.round(Duration.toMillis(event.duration))}ms`
101
92
  }));
102
93
  }
103
94
  } });
104
- /**
105
- * @tsdoctor/registry composes at the edge: the library ships no platform
106
- * layer of its own, so the plugin wires FileSystem/Path, the XDG directories,
107
- * the sqlite metadata Cache and the HTTP client here.
108
- *
109
- * All layers are bound to module-level consts (never rebuilt per call) per the
110
- * v4 layer memoization discipline.
111
- */
112
- const PlatformLive = Layer.mergeAll(NodeFileSystem.layer, Path.layer);
113
- /**
114
- * XDG app directories under the tsdoctor-wide namespace. Renamed from the
115
- * legacy "type-registry-effect" namespace in phase 2 per the resolved identity
116
- * decision (see tsdoctor-package-architecture.md) — a deliberate one-time
117
- * on-disk cache invalidation: existing caches go cold and refetch.
118
- */
119
- const AppDirsLive = AppDirs.layer({ namespace: "tsdoctor" }).pipe(Layer.provide(Layer.mergeAll(Xdg.layer, PlatformLive)));
120
95
  /** Metadata plane: a sqlite-backed `@effected/store` Cache rooted in the XDG cache dir. */
121
96
  const MetadataCacheLive = Layer.unwrap(Effect.gen(function* () {
122
97
  const appDirs = yield* AppDirs;
@@ -133,23 +108,55 @@ const RegistryLayer = TypeRegistry.layer.pipe(Layer.provideMerge(Layer.mergeAll(
133
108
  /**
134
109
  * TypeRegistryServiceLive: uses @tsdoctor/registry Effect programs directly.
135
110
  */
136
- const TypeRegistryServiceLive = Layer.succeed(TypeRegistryService, {
137
- resolveVersions: (packages) => Effect.gen(function* () {
138
- const registry = yield* TypeRegistry;
139
- return yield* resolveExternalPackageVersions(packages, (pkg) => registry.resolveVersion(pkg.name, pkg.version));
140
- }).pipe(Effect.provide(RegistryLayer), Effect.catch(() => Effect.succeed([...packages]))),
141
- loadPackages: (packages) => packages.length === 0 ? Effect.succeed({ vfs: /* @__PURE__ */ new Map() }) : Effect.gen(function* () {
142
- const specs = packages.map((pkg) => new PackageSpec({
143
- name: pkg.name,
144
- version: pkg.version
145
- }));
146
- return { vfs: yield* (yield* TypeRegistry).getVfs(specs, { autoFetch: true }) };
147
- }).pipe(Effect.provide(RegistryLayer), Effect.catch((error) => Effect.fail(new TypeRegistryError({
111
+ const RegistryBackedLive = Layer.effect(TypeRegistryService, Effect.gen(function* () {
112
+ const registry = yield* TypeRegistry;
113
+ return {
114
+ resolveVersions: (packages) => resolveExternalPackageVersions(packages, (pkg) => registry.resolveVersion(pkg.name, pkg.version)).pipe(Effect.catch(() => Effect.succeed([...packages]))),
115
+ loadPackages: (packages) => packages.length === 0 ? Effect.succeed({ vfs: /* @__PURE__ */ new Map() }) : Effect.gen(function* () {
116
+ const specs = packages.map((pkg) => new PackageSpec({
117
+ name: pkg.name,
118
+ version: pkg.version
119
+ }));
120
+ return { vfs: yield* registry.getVfs(specs, { autoFetch: true }) };
121
+ }).pipe(Effect.catch((error) => Effect.fail(new TypeRegistryError({
122
+ packageName: packages.map((p) => p.name).join(", "),
123
+ version: packages.map((p) => p.version).join(", "),
124
+ reason: error instanceof Error ? error.message ?? String(error) : String(error)
125
+ }))))
126
+ };
127
+ })).pipe(Layer.provide(RegistryLayer));
128
+ /**
129
+ * The service when the registry stack cannot be built at all.
130
+ *
131
+ * @remarks
132
+ * Preserves exactly the split the working service documents: `resolveVersions`
133
+ * passes its specs through unresolved rather than swallowing the problem, so
134
+ * the failure surfaces from `loadPackages` as a {@link PluginTypeRegistryError}
135
+ * with a message, which `ConfigServiceLive` turns into a build-continues
136
+ * warning. Before acquisition moved to layer construction this fell out of the
137
+ * per-method handlers; it has to be stated explicitly now.
138
+ */
139
+ const DegradedLive = Layer.succeed(TypeRegistryService, {
140
+ resolveVersions: (packages) => Effect.succeed([...packages]),
141
+ loadPackages: (packages) => Effect.fail(new TypeRegistryError({
148
142
  packageName: packages.map((p) => p.name).join(", "),
149
143
  version: packages.map((p) => p.version).join(", "),
150
- reason: error instanceof Error ? error.message ?? String(error) : String(error)
151
- }))))
144
+ reason: "type registry unavailable: its cache directory or metadata database could not be opened"
145
+ }))
152
146
  });
147
+ /**
148
+ * TypeRegistryServiceLive: the `@tsdoctor/registry` stack, acquired once.
149
+ *
150
+ * @remarks
151
+ * `Layer.catchCause` keeps a broken environment — no HOME for XDG, an
152
+ * unwritable cache directory — from aborting the build at `ManagedRuntime`
153
+ * construction. External type loading is an enhancement: without it code
154
+ * blocks render without Twoslash enrichment, which is a degradation, not a
155
+ * failure. That was true while the stack was provided per method and the
156
+ * in-method handlers absorbed it; hoisting acquisition made it something the
157
+ * layer has to say for itself.
158
+ */
159
+ const TypeRegistryServiceLive = RegistryBackedLive.pipe(Layer.catchCause(() => DegradedLive));
153
160
 
154
161
  //#endregion
155
162
  export { TypeRegistryServiceLive };
@@ -1,12 +1,21 @@
1
- import { Metric } from "effect";
1
+ import { Context, Layer, Metric } from "effect";
2
2
 
3
3
  //#region src/layers/build-metrics.ts
4
+ /** Create an isolated metric store for a single build. */
5
+ function makeMetricStore() {
6
+ const registry = /* @__PURE__ */ new Map();
7
+ return {
8
+ registry,
9
+ context: Context.make(Metric.MetricRegistry, registry),
10
+ layer: Layer.succeed(Metric.MetricRegistry, registry)
11
+ };
12
+ }
4
13
  /**
5
14
  * All build metrics as named counters/histograms.
6
15
  *
7
- * Note: Effect Metrics use a process-wide registry. In tests, counters
8
- * accumulate across test cases within the same process. Test assertions
9
- * should use loose matching (toContain) rather than exact count checks.
16
+ * Metric state lives in the `Metric.MetricRegistry` of the surrounding context,
17
+ * not in these constants, so these may be shared module-level values while each
18
+ * build still gets its own counters see {@link MetricRegistryLive}.
10
19
  *
11
20
  * Extracted into its own module so that `metrics-sink.ts` can import it
12
21
  * without creating a circular dependency through `ObservabilityLive.ts`
@@ -36,8 +45,20 @@ const BuildMetrics = {
36
45
  ] }),
37
46
  codeblockTotal: Metric.counter("codeblock.total"),
38
47
  codeblockSlow: Metric.counter("codeblock.slow"),
48
+ /**
49
+ * Summed milliseconds per dimension. Counters rather than histograms because
50
+ * the question these answer is "where did the time go", which needs a total;
51
+ * the histograms above still carry the distribution.
52
+ */
53
+ codeblockTimeMs: Metric.counter("codeblock.time.ms"),
54
+ codeblockTwoslashMs: Metric.counter("codeblock.twoslash.ms"),
55
+ codeblockShikiMs: Metric.counter("codeblock.shiki.ms"),
56
+ /** Blocks the Twoslash transformer actually ran on. */
57
+ codeblockTwoslashTotal: Metric.counter("codeblock.twoslash.total"),
39
58
  twoslashErrors: Metric.counter("twoslash.errors"),
40
59
  prettierErrors: Metric.counter("prettier.errors"),
60
+ /** Shiki render failures. Previously unmapped, so they never reached a metric. */
61
+ shikiErrors: Metric.counter("shiki.errors"),
41
62
  pagesGenerated: Metric.counter("pages.generated"),
42
63
  apisCompleted: Metric.counter("apis.completed"),
43
64
  apiVersionsLoaded: Metric.counter("api.versions.loaded"),
@@ -52,6 +73,12 @@ const BuildMetrics = {
52
73
  5e3,
53
74
  1e4
54
75
  ] }),
76
+ /**
77
+ * Summed phase milliseconds. Tagged by phase name, which the histogram alone
78
+ * cannot express — every phase previously collapsed into one distribution, so
79
+ * resolve could not be told apart from generate or write.
80
+ */
81
+ phaseTimeMs: Metric.counter("phase.time.ms"),
55
82
  vfsFiles: Metric.counter("vfs.files"),
56
83
  importsPrepended: Metric.counter("imports.prepended"),
57
84
  twoslashDiagnostics: Metric.counter("twoslash.diagnostics"),
@@ -59,4 +86,4 @@ const BuildMetrics = {
59
86
  };
60
87
 
61
88
  //#endregion
62
- export { BuildMetrics };
89
+ export { BuildMetrics, makeMetricStore };