rspress-plugin-api-extractor 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,7 +47,8 @@ The plugin reads your `.api.json` model and writes one MDX page per public API i
47
47
  ## Features
48
48
 
49
49
  - Generates API docs from `.api.json` models for classes, interfaces, functions, type aliases, enums, variables and namespaces.
50
- - Type-checks code examples and adds Twoslash hover tooltips that show inferred types.
50
+ - Type-checks code examples and adds Twoslash hover tooltips that show inferred types, each documented package under its own `tsconfig`.
51
+ - Caches Twoslash results between builds, so repeat builds over an unchanged API render code blocks near-instantly.
51
52
  - Cross-links type references between pages, so a type named in a signature links to its own page.
52
53
  - Inlines compiler-generated base declarations (the `Foo_base` pattern from Effect `Schema.Class`, `Data.TaggedError` and mixin factories) in a "Base Class" section on the owning class page instead of documenting them as orphan variables.
53
54
  - Drives single-package sites, multi-package portals, RSPress multiVersion and i18n from one plugin.
package/build-program.js CHANGED
@@ -56,7 +56,8 @@ function generateApiDocs(apiConfig, buildContext, fileContextMap) {
56
56
  packageName,
57
57
  apiScope
58
58
  };
59
- if (twoslashTransformer != null) vfsConfig.twoslashTransformer = twoslashTransformer;
59
+ const scopeTransformer = TwoslashManager.getInstance().getTransformer(apiScope) ?? twoslashTransformer;
60
+ if (scopeTransformer != null) vfsConfig.twoslashTransformer = scopeTransformer;
60
61
  if (hideCutTransformer != null) vfsConfig.hideCutTransformer = hideCutTransformer;
61
62
  if (hideCutLinesTransformer != null) vfsConfig.hideCutLinesTransformer = hideCutLinesTransformer;
62
63
  if (apiConfig.theme != null) vfsConfig.theme = apiConfig.theme;
package/build-stages.js CHANGED
@@ -803,4 +803,4 @@ function buildPipelineForApi(input) {
803
803
  }
804
804
 
805
805
  //#endregion
806
- export { buildPipelineForApi, cleanupAndCommit, crossLinkKindPriority, generateSinglePage, normalizeMarkdownSpacing, prepareWorkItems, setBuildStagesEventEmitter, writeMetadata, writeSingleFile };
806
+ export { buildPipelineForApi, cleanupAndCommit, crossLinkKindPriority, generateSinglePage, prepareWorkItems, setBuildStagesEventEmitter, writeMetadata, writeSingleFile };
package/index.d.ts CHANGED
@@ -390,15 +390,19 @@ declare const MultiApiConfig: Schema.Struct<{
390
390
  * Path to a `tsconfig.json` for Twoslash.
391
391
  *
392
392
  * @remarks
393
- * Twoslash runs against a single shared TypeScript environment for the
394
- * whole build, so per-API tsconfigs are not honored in multi-API mode:
395
- * the first API that provides one wins and the rest are ignored (a
396
- * `ConfigCascadeWarning` is emitted when they differ). Ensure the
397
- * configured tsconfigs are equivalent, or set the intended one on the
398
- * first API only.
393
+ * This API's code blocks are type-checked under this config. APIs that
394
+ * declare the same config share one TypeScript environment; the file set
395
+ * is shared across all documented APIs either way, so a type owned by
396
+ * another documented package still resolves.
399
397
  */
400
398
  readonly tsconfig: Schema.optional<Schema.declare<string | URL | ((...args: Array<unknown>) => unknown), string | URL | ((...args: Array<unknown>) => unknown)>>;
401
- /** TypeScript compiler options for Twoslash. First API wins, as with `tsconfig`. */
399
+ /**
400
+ * TypeScript compiler options for Twoslash, applying to this API only.
401
+ *
402
+ * @remarks
403
+ * Merged on top of the defaults and of this API's `tsconfig`, so declaring
404
+ * a single option overrides just that one.
405
+ */
402
406
  readonly compilerOptions: Schema.optional<Schema.Unknown>;
403
407
  }>;
404
408
  /** @public */
@@ -650,15 +654,19 @@ declare const PluginOptions: Schema.Struct<{
650
654
  * Path to a `tsconfig.json` for Twoslash.
651
655
  *
652
656
  * @remarks
653
- * Twoslash runs against a single shared TypeScript environment for the
654
- * whole build, so per-API tsconfigs are not honored in multi-API mode:
655
- * the first API that provides one wins and the rest are ignored (a
656
- * `ConfigCascadeWarning` is emitted when they differ). Ensure the
657
- * configured tsconfigs are equivalent, or set the intended one on the
658
- * first API only.
657
+ * This API's code blocks are type-checked under this config. APIs that
658
+ * declare the same config share one TypeScript environment; the file set
659
+ * is shared across all documented APIs either way, so a type owned by
660
+ * another documented package still resolves.
659
661
  */
660
662
  readonly tsconfig: Schema.optional<Schema.declare<string | URL | ((...args: Array<unknown>) => unknown), string | URL | ((...args: Array<unknown>) => unknown)>>;
661
- /** TypeScript compiler options for Twoslash. First API wins, as with `tsconfig`. */
663
+ /**
664
+ * TypeScript compiler options for Twoslash, applying to this API only.
665
+ *
666
+ * @remarks
667
+ * Merged on top of the defaults and of this API's `tsconfig`, so declaring
668
+ * a single option overrides just that one.
669
+ */
662
670
  readonly compilerOptions: Schema.optional<Schema.Unknown>;
663
671
  }>>>>>;
664
672
  /** Canonical site URL used for Open Graph absolute URLs. */
@@ -18,10 +18,13 @@ import { DEFAULT_CATEGORIES } from "../schemas/config.js";
18
18
  import "../schemas/index.js";
19
19
  import { ConfigService } from "../services/ConfigService.js";
20
20
  import { PathDerivationService } from "../services/PathDerivationService.js";
21
+ import { TwoslashCacheService } from "../services/TwoslashCacheService.js";
21
22
  import { TypeRegistryService } from "../services/TypeRegistryService.js";
23
+ import { makeTwoslashCache, twoslashEnvHash } from "../twoslash-cache.js";
22
24
  import path from "node:path";
23
25
  import { hashContent } from "@tsdoctor/snapshot";
24
26
  import { Effect, Layer, Metric } from "effect";
27
+ import ts from "typescript";
25
28
  import os from "node:os";
26
29
  import { createHighlighter } from "shiki";
27
30
 
@@ -172,6 +175,12 @@ function ConfigServiceLive(options, shikiCrossLinker, buildId = "", resolvedThre
172
175
  let firstApiTsconfig;
173
176
  let firstApiCompilerOptions;
174
177
  /**
178
+ * Raw TypeScript config per API scope. Each documented package is
179
+ * type-checked under its OWN configuration; the build no longer picks
180
+ * one and applies it to everything.
181
+ */
182
+ const scopeTsConfigs = /* @__PURE__ */ new Map();
183
+ /**
175
184
  * Emit a typed ModelLoadFailed event for a failed model load, then
176
185
  * convert the typed failure to a defect — a missing or unparsable
177
186
  * model remains fatal to the build, exactly as before, but the
@@ -229,6 +238,7 @@ function ConfigServiceLive(options, shikiCrossLinker, buildId = "", resolvedThre
229
238
  const baseRoute = yield* pathService.normalizeBaseRoute(api.baseRoute ?? "/");
230
239
  firstApiTsconfig = api.tsconfig;
231
240
  firstApiCompilerOptions = api.compilerOptions;
241
+ scopeTsConfigs.set(apiScopeOf(baseRoute, api.packageName), rawTsConfig(api));
232
242
  if (rspressMultiVersion && api.versions) {
233
243
  const versionResults = yield* Effect.forEach(Object.entries(api.versions), ([version, versionValue]) => Effect.gen(function* () {
234
244
  const versionDp = (yield* pathService.derivePaths({
@@ -361,23 +371,13 @@ function ConfigServiceLive(options, shikiCrossLinker, buildId = "", resolvedThre
361
371
  }
362
372
  } else if (options.apis) {
363
373
  const apisWithTsconfig = options.apis.filter((a) => a.tsconfig);
364
- if (apisWithTsconfig.length > 0) {
365
- firstApiTsconfig = apisWithTsconfig[0].tsconfig;
366
- const uniqueTsconfigs = new Set(apisWithTsconfig.map((a) => String(a.tsconfig)));
367
- if (uniqueTsconfigs.size > 1) {
368
- const chosen = String(firstApiTsconfig);
369
- const ignored = [...uniqueTsconfigs].filter((t) => t !== chosen);
370
- yield* emit(PluginEvent.ConfigCascadeWarning({
371
- ctx: { buildId: "" },
372
- level: "warn",
373
- field: "tsconfig",
374
- chosen,
375
- ignored
376
- }));
377
- }
378
- }
374
+ if (apisWithTsconfig.length > 0) firstApiTsconfig = apisWithTsconfig[0].tsconfig;
379
375
  const apisWithCompilerOptions = options.apis.filter((a) => a.compilerOptions);
380
376
  if (apisWithCompilerOptions.length > 0) firstApiCompilerOptions = apisWithCompilerOptions[0].compilerOptions;
377
+ for (const a of options.apis) {
378
+ const scopeRoute = yield* pathService.normalizeBaseRoute(a.baseRoute ?? `/${unscopedName(a.packageName)}`);
379
+ scopeTsConfigs.set(apiScopeOf(scopeRoute, a.packageName), rawTsConfig(a));
380
+ }
381
381
  const multiResults = yield* Effect.forEach(options.apis, (api) => Effect.gen(function* () {
382
382
  const apiBaseRoute = yield* pathService.normalizeBaseRoute(api.baseRoute ?? `/${unscopedName(api.packageName)}`);
383
383
  const dp = (yield* pathService.derivePaths({
@@ -477,8 +477,33 @@ function ConfigServiceLive(options, shikiCrossLinker, buildId = "", resolvedThre
477
477
  chosen: "empty VFS",
478
478
  ignored: [typeLoadResult.failure.message ?? String(typeLoadResult.failure)]
479
479
  }));
480
+ const twoslashEnv = twoslashEnvHash(combinedVfs, `typescript@${ts.version}`);
481
+ const restored = yield* (yield* TwoslashCacheService).load(twoslashEnv);
482
+ const twoslashCache = makeTwoslashCache(restored);
483
+ yield* emit(PluginEvent.TwoslashCacheLoaded({
484
+ ctx: { buildId: "" },
485
+ level: "debug",
486
+ envHash: twoslashEnv,
487
+ entries: restored.size
488
+ }));
480
489
  const twoslashStartMs = performance.now();
481
- TwoslashManager.getInstance().initialize(combinedVfs, void 0, void 0, void 0, resolvedCompilerOptions);
490
+ const manager = TwoslashManager.getInstance();
491
+ manager.initialize(combinedVfs, void 0, void 0, void 0, resolvedCompilerOptions, twoslashCache);
492
+ const resolvedByRawConfig = /* @__PURE__ */ new Map();
493
+ for (const [apiScope, rawConfig] of scopeTsConfigs) {
494
+ if (rawConfig === void 0) {
495
+ manager.registerScope(apiScope, resolvedCompilerOptions);
496
+ continue;
497
+ }
498
+ const rawKey = JSON.stringify([String(rawConfig.tsconfig ?? ""), rawConfig.compilerOptions ?? null]);
499
+ let scopeOptions = resolvedByRawConfig.get(rawKey);
500
+ if (scopeOptions === void 0) {
501
+ scopeOptions = yield* Effect.promise(() => resolveTypeScriptConfig(projectRoot, rawConfig));
502
+ resolvedByRawConfig.set(rawKey, scopeOptions);
503
+ }
504
+ manager.initialize(combinedVfs, void 0, void 0, void 0, scopeOptions, twoslashCache);
505
+ manager.registerScope(apiScope, scopeOptions);
506
+ }
482
507
  yield* emit(PluginEvent.TwoslashInitialized({
483
508
  ctx: { buildId: "" },
484
509
  level: "debug",
@@ -537,6 +562,8 @@ function ConfigServiceLive(options, shikiCrossLinker, buildId = "", resolvedThre
537
562
  hideCutTransformer,
538
563
  hideCutLinesTransformer,
539
564
  twoslashTransformer,
565
+ twoslashCache,
566
+ twoslashEnvHash: twoslashEnv,
540
567
  pageConcurrency: os.cpus().length,
541
568
  logLevel: logLevel === "none" ? "info" : logLevel,
542
569
  suppressExampleErrors,
@@ -547,6 +574,22 @@ function ConfigServiceLive(options, shikiCrossLinker, buildId = "", resolvedThre
547
574
  }));
548
575
  }
549
576
  /**
577
+ * Derive the API scope key from a base route, matching the derivation in
578
+ * `build-program.ts` so config resolution and the remark plugins agree on the
579
+ * name a code block is attributed to.
580
+ */
581
+ function apiScopeOf(baseRoute, packageName) {
582
+ return baseRoute.replace(/^\//, "").split("/")[0] || packageName;
583
+ }
584
+ /** The raw TypeScript config an API declares, or undefined when it declares none. */
585
+ function rawTsConfig(api) {
586
+ if (api.tsconfig == null && api.compilerOptions == null) return void 0;
587
+ const cfg = {};
588
+ if (api.tsconfig != null) cfg.tsconfig = api.tsconfig;
589
+ if (api.compilerOptions != null) cfg.compilerOptions = api.compilerOptions;
590
+ return cfg;
591
+ }
592
+ /**
550
593
  * Strip npm scope from a package name.
551
594
  */
552
595
  function unscopedName(packageName) {
@@ -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,53 @@
1
+ import { TwoslashCacheService } from "../services/TwoslashCacheService.js";
2
+ import { decodeTwoslashCache, encodeTwoslashCache, twoslashBlobKey } from "../twoslash-cache.js";
3
+ import { NodeFileSystem } from "@effect/platform-node";
4
+ import { Effect, Layer, Option, Path } from "effect";
5
+ import { Cache } from "@effected/store";
6
+ import { AppDirs, Xdg } from "@effected/xdg";
7
+
8
+ //#region src/layers/TwoslashCacheServiceLive.ts
9
+ const PlatformLive = Layer.mergeAll(NodeFileSystem.layer, Path.layer);
10
+ /**
11
+ * XDG app dirs under the same `tsdoctor` namespace the type registry uses, so
12
+ * every derived-artifact cache this plugin keeps lives in one place.
13
+ */
14
+ const AppDirsLive = AppDirs.layer({ namespace: "tsdoctor" }).pipe(Layer.provide(Layer.mergeAll(Xdg.layer, PlatformLive)));
15
+ /**
16
+ * A sqlite-backed `@effected/store` Cache in the XDG cache dir, separate from
17
+ * the registry's `metadata.sqlite`.
18
+ *
19
+ * XDG rather than the repo: these are regenerable results derived from content
20
+ * hashes, so they belong with the user's other caches — shared across worktrees
21
+ * and checkouts of the same project, and untouched by cleaning `dist/`. Nothing
22
+ * here needs to be committed for a build to be correct.
23
+ */
24
+ const CacheLive = Layer.unwrap(Effect.gen(function* () {
25
+ const appDirs = yield* AppDirs;
26
+ const path = yield* Path.Path;
27
+ const cacheDir = yield* appDirs.ensureCache;
28
+ return Cache.layerSqlite({ filename: path.join(cacheDir, "twoslash.sqlite") });
29
+ })).pipe(Layer.provide(Layer.mergeAll(AppDirsLive, PlatformLive)));
30
+ /**
31
+ * Live Twoslash cache persistence.
32
+ *
33
+ * Both operations swallow every failure by design — see the service docs. A
34
+ * missing HOME, an unwritable cache dir or a corrupt database degrades the
35
+ * build to "type-check everything", which is exactly the behaviour before this
36
+ * cache existed.
37
+ */
38
+ const TwoslashCacheServiceLive = Layer.succeed(TwoslashCacheService, {
39
+ load: (envHash) => Effect.gen(function* () {
40
+ const entry = yield* (yield* Cache).get(twoslashBlobKey(envHash));
41
+ return Option.isSome(entry) ? decodeTwoslashCache(entry.value.value) : /* @__PURE__ */ new Map();
42
+ }).pipe(Effect.provide(CacheLive), Effect.catchCause(() => Effect.succeed(/* @__PURE__ */ new Map()))),
43
+ save: (envHash, entries) => Effect.gen(function* () {
44
+ yield* (yield* Cache).set({
45
+ key: twoslashBlobKey(envHash),
46
+ value: encodeTwoslashCache(entries),
47
+ tags: ["twoslash"]
48
+ });
49
+ }).pipe(Effect.provide(CacheLive), Effect.catchCause(() => Effect.void))
50
+ });
51
+
52
+ //#endregion
53
+ export { TwoslashCacheServiceLive };
@@ -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 };
@@ -334,4 +334,4 @@ async function formatExampleCode(code, language, _context) {
334
334
  }
335
335
 
336
336
  //#endregion
337
- export { escapeMdxGenerics, escapeYamlString, formatExampleCode, formatImportsWithCut, generateAvailableFrom, generateFrontmatter, prepareExampleCode, prependHiddenImports, sanitizeId, stripTwoslashDirectives };
337
+ export { escapeMdxGenerics, escapeYamlString, formatExampleCode, generateAvailableFrom, generateFrontmatter, prepareExampleCode, prependHiddenImports, sanitizeId, stripTwoslashDirectives };
@@ -16,8 +16,6 @@ function makeShape(sinks) {
16
16
  function makeEventBusLayer(sinks) {
17
17
  return Layer.succeed(EventBus, makeShape(sinks));
18
18
  }
19
- /** No sinks: every emit is a no-op, wantsLevel always false. */
20
- const EventBusNoop = makeEventBusLayer([]);
21
19
  /** Emit when a bus is in context; silently no-op otherwise. */
22
20
  function emit(event) {
23
21
  return Effect.serviceOption(EventBus).pipe(Effect.flatMap((maybe) => Option.isSome(maybe) ? maybe.value.emit(event) : Effect.void));
@@ -70,4 +70,4 @@ function runHeartbeat(opts) {
70
70
  }
71
71
 
72
72
  //#endregion
73
- export { formatProgress, makeProgressEvent, readCounts, runHeartbeat };
73
+ export { formatProgress, makeProgressEvent, runHeartbeat };
@@ -0,0 +1,124 @@
1
+ import { Effect, Metric } from "effect";
2
+
3
+ //#region src/observability/metric-report.ts
4
+ /**
5
+ * Break any counter down by its attributes, largest first.
6
+ *
7
+ * This is the generic form of what the code-block report does: because Effect
8
+ * keys a registry entry by metric name plus attribute set, a breakdown is a
9
+ * filter over `Metric.snapshot`, not something a sink has to accumulate. Adding
10
+ * a dimension to an existing metric therefore costs a tag at the emit site and
11
+ * nothing here.
12
+ */
13
+ function seriesFor(snapshots, id) {
14
+ return snapshots.filter((snap) => snap.id === id && snap.attributes !== void 0).map((snap) => ({
15
+ attributes: snap.attributes ?? {},
16
+ value: counterValue(snap.state)
17
+ })).filter((series) => series.value > 0).sort((a, b) => b.value - a.value);
18
+ }
19
+ const ID_TO_FIELD = new Map(Object.entries({
20
+ blocks: "codeblock.total",
21
+ twoslashBlocks: "codeblock.twoslash.total",
22
+ slowBlocks: "codeblock.slow",
23
+ totalMs: "codeblock.time.ms",
24
+ twoslashMs: "codeblock.twoslash.ms",
25
+ shikiMs: "codeblock.shiki.ms"
26
+ }).map(([field, id]) => [id, field]));
27
+ function emptyBucket() {
28
+ return {
29
+ blocks: 0,
30
+ twoslashBlocks: 0,
31
+ slowBlocks: 0,
32
+ totalMs: 0,
33
+ twoslashMs: 0,
34
+ shikiMs: 0,
35
+ otherMs: 0
36
+ };
37
+ }
38
+ function withOther(bucket) {
39
+ return {
40
+ ...bucket,
41
+ otherMs: Math.max(0, bucket.totalMs - bucket.twoslashMs - bucket.shikiMs)
42
+ };
43
+ }
44
+ function add(a, b) {
45
+ return withOther({
46
+ blocks: a.blocks + b.blocks,
47
+ twoslashBlocks: a.twoslashBlocks + b.twoslashBlocks,
48
+ slowBlocks: a.slowBlocks + b.slowBlocks,
49
+ totalMs: a.totalMs + b.totalMs,
50
+ twoslashMs: a.twoslashMs + b.twoslashMs,
51
+ shikiMs: a.shikiMs + b.shikiMs,
52
+ otherMs: 0
53
+ });
54
+ }
55
+ /** Counter states carry `count`; anything else contributes nothing. */
56
+ function counterValue(state) {
57
+ if (typeof state !== "object" || state === null || !("count" in state)) return 0;
58
+ const count = state.count;
59
+ return typeof count === "number" ? count : Number(count);
60
+ }
61
+ /**
62
+ * Build the code-block report from an explicit set of metric snapshots.
63
+ *
64
+ * Split from {@link codeBlockReport} so it can be exercised without a live
65
+ * metric registry.
66
+ */
67
+ function codeBlockReportFrom(snapshots) {
68
+ const bySeries = /* @__PURE__ */ new Map();
69
+ for (const snap of snapshots) {
70
+ const field = ID_TO_FIELD.get(snap.id);
71
+ if (!field || !snap.attributes) continue;
72
+ const { scope, component, twoslash } = snap.attributes;
73
+ if (scope === void 0 || component === void 0 || twoslash === void 0) continue;
74
+ const key = `${scope} ${component} ${twoslash}`;
75
+ const current = bySeries.get(key) ?? {
76
+ scope,
77
+ component,
78
+ twoslash: twoslash === "true",
79
+ ...emptyBucket()
80
+ };
81
+ bySeries.set(key, {
82
+ ...current,
83
+ [field]: current[field] + counterValue(snap.state)
84
+ });
85
+ }
86
+ const series = [...bySeries.values()].map((s) => ({
87
+ ...s,
88
+ ...withOther(s)
89
+ })).sort((a, b) => b.totalMs - a.totalMs);
90
+ let overall = emptyBucket();
91
+ const byScope = /* @__PURE__ */ new Map();
92
+ const byComponent = /* @__PURE__ */ new Map();
93
+ for (const s of series) {
94
+ overall = add(overall, s);
95
+ byScope.set(s.scope, add(byScope.get(s.scope) ?? emptyBucket(), s));
96
+ byComponent.set(s.component, add(byComponent.get(s.component) ?? emptyBucket(), s));
97
+ }
98
+ return {
99
+ overall,
100
+ series,
101
+ byScope: Object.fromEntries(byScope),
102
+ byComponent: Object.fromEntries(byComponent)
103
+ };
104
+ }
105
+ /** Read the code-block report from the current context's metric registry. */
106
+ const codeBlockReport = Effect.map(Metric.snapshot, codeBlockReportFrom);
107
+ /**
108
+ * Render the report as the console summary lines logged at the end of a build.
109
+ * Returns an empty array when no code block was processed.
110
+ */
111
+ function formatCodeBlockReport(report) {
112
+ const { overall } = report;
113
+ if (overall.blocks === 0) return [];
114
+ const s = (ms) => `${(ms / 1e3).toFixed(1)}s`;
115
+ const pct = (ms) => overall.totalMs > 0 ? `${Math.round(ms / overall.totalMs * 100)}%` : "0%";
116
+ const lines = [`render phase: ${overall.blocks} code blocks in ${s(overall.totalMs)} (twoslash ${s(overall.twoslashMs)} ${pct(overall.twoslashMs)}, shiki ${s(overall.shikiMs)} ${pct(overall.shikiMs)}, other ${s(overall.otherMs)} ${pct(overall.otherMs)})`];
117
+ const scopes = Object.entries(report.byScope).sort((a, b) => b[1].totalMs - a[1].totalMs);
118
+ for (const [scope, bucket] of scopes.slice(0, 10)) lines.push(` ${scope}: ${bucket.blocks} blocks, ${s(bucket.totalMs)} (twoslash ${s(bucket.twoslashMs)}, ${bucket.twoslashBlocks} typechecked)`);
119
+ if (scopes.length > 10) lines.push(` and ${scopes.length - 10} more scopes`);
120
+ return lines;
121
+ }
122
+
123
+ //#endregion
124
+ export { codeBlockReport, codeBlockReportFrom, formatCodeBlockReport, seriesFor };
@@ -27,6 +27,12 @@ function render(event) {
27
27
  case "ConfigResolved": return `resolved ${event.baseRoute}: ${event.categoryCount} categories, ${event.externalCount} external`;
28
28
  case "TwoslashDiagnostic": return `Twoslash TS${event.code} in ${event.file}:${event.line}:${event.col}: ${event.message}`;
29
29
  case "TwoslashCheckFailed": return `Twoslash check failed (TS${event.code}) in ${event.file}; ${event.fsMapKeys.length} VFS files`;
30
+ case "TwoslashCacheLoaded": return event.entries > 0 ? `Twoslash cache: restored ${event.entries} cached result(s)` : "Twoslash cache: cold (no cached results for this type environment)";
31
+ case "TwoslashCacheSaved": {
32
+ const total = event.hits + event.misses;
33
+ const pct = total > 0 ? Math.round(event.hits / total * 100) : 0;
34
+ return `Twoslash cache: ${event.hits}/${total} hits (${pct}%), ${event.entries} entries${event.persisted ? " (saved)" : ""}`;
35
+ }
30
36
  case "PageGenerated": return `page ${event.category}/${event.item} (${event.durationMs}ms)`;
31
37
  case "FileDecision": return `${event.status}: ${event.file}`;
32
38
  case "ItemSkipped": return `skipped ${event.kind} "${event.item}": ${event.reason}`;