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.
@@ -1,17 +1,22 @@
1
1
  import { BuildMetrics } from "../../layers/build-metrics.js";
2
- import { Effect, Metric } from "effect";
2
+ import { Metric } from "effect";
3
3
 
4
4
  //#region src/observability/sinks/metrics-sink.ts
5
5
  /**
6
6
  * Event-driven metrics sink.
7
7
  *
8
8
  * Translates each `PluginEvent` to the corresponding `BuildMetrics` counter or
9
- * histogram update via `Effect.runSync`. The fan-out from `EventBus.emit` is
9
+ * histogram update against the build's metric registry. The fan-out from `EventBus.emit` is
10
10
  * synchronous, so by the time the emitting fiber resumes the metrics are already
11
11
  * recorded — counts are exact when `logBuildSummary` reads them in `afterBuild`.
12
12
  *
13
13
  * Unmapped event tags hit the `default` branch and are silently ignored.
14
14
  *
15
+ * Events whose breakdown is worth querying are recorded twice — undimensioned
16
+ * for the build-wide totals, and tagged for `metric-report.ts`. The attributes
17
+ * are deliberately bounded (scope, status, phase, TS code); anything unbounded,
18
+ * such as a file path, belongs in a sample-shaped sink instead.
19
+ *
15
20
  * Intentionally NOT derived here: `externalPackagesTotal` and `apiVersionsLoaded`
16
21
  * remain inline increments in `ConfigServiceLive`. `externalPackagesTotal` counts
17
22
  * CONFIGURED packages via `incrementBy(length)`; the only candidate event,
@@ -19,46 +24,84 @@ import { Effect, Metric } from "effect";
19
24
  * a `loaded` (SUCCEEDED) count — different semantics, so deriving it here would
20
25
  * change what the metric means. `apiVersionsLoaded` has no corresponding event.
21
26
  */
22
- function makeMetricsSink() {
27
+ function makeMetricsSink(context) {
28
+ const update = (metric, input) => metric.updateUnsafe(input, context);
29
+ /** Record `metric` twice: undimensioned, and tagged for breakdown queries. */
30
+ const both = (metric, input, attributes) => {
31
+ update(metric, input);
32
+ update(Metric.withAttributes(metric, attributes), input);
33
+ };
34
+ const scopeOf = (event) => event.ctx.apiScope ?? "(unscoped)";
23
35
  return {
24
36
  minLevel: "trace",
25
37
  handle(event) {
26
38
  switch (event._tag) {
27
39
  case "FileDecision":
28
- Effect.runSync(Metric.update(BuildMetrics.filesTotal, 1));
29
- if (event.status === "new") Effect.runSync(Metric.update(BuildMetrics.filesNew, 1));
30
- else if (event.status === "modified") Effect.runSync(Metric.update(BuildMetrics.filesModified, 1));
31
- else Effect.runSync(Metric.update(BuildMetrics.filesUnchanged, 1));
40
+ both(BuildMetrics.filesTotal, 1, {
41
+ scope: scopeOf(event),
42
+ status: event.status
43
+ });
44
+ if (event.status === "new") update(BuildMetrics.filesNew, 1);
45
+ else if (event.status === "modified") update(BuildMetrics.filesModified, 1);
46
+ else update(BuildMetrics.filesUnchanged, 1);
32
47
  break;
33
48
  case "PageGenerated":
34
- Effect.runSync(Metric.update(BuildMetrics.pagesGenerated, 1));
49
+ update(BuildMetrics.pagesGenerated, 1);
35
50
  break;
36
51
  case "ApiDocsCompleted":
37
- Effect.runSync(Metric.update(BuildMetrics.apisCompleted, 1));
52
+ update(BuildMetrics.apisCompleted, 1);
38
53
  break;
39
54
  case "TwoslashDiagnostic":
40
- Effect.runSync(Metric.update(BuildMetrics.twoslashDiagnostics, 1));
41
- Effect.runSync(Metric.update(BuildMetrics.twoslashErrors, 1));
55
+ both(BuildMetrics.twoslashDiagnostics, 1, {
56
+ code: `TS${event.code}`,
57
+ scope: scopeOf(event)
58
+ });
59
+ update(BuildMetrics.twoslashErrors, 1);
42
60
  break;
43
61
  case "PrettierError":
44
- Effect.runSync(Metric.update(BuildMetrics.prettierErrors, 1));
62
+ both(BuildMetrics.prettierErrors, 1, { scope: scopeOf(event) });
63
+ break;
64
+ case "ShikiError":
65
+ both(BuildMetrics.shikiErrors, 1, { scope: scopeOf(event) });
45
66
  break;
46
- case "CodeBlockProcessed":
47
- Effect.runSync(Metric.update(BuildMetrics.codeblockTotal, 1));
48
- Effect.runSync(Metric.update(BuildMetrics.codeblockDuration, event.totalMs));
49
- if (event.shikiMs > 0) Effect.runSync(Metric.update(BuildMetrics.codeblockShikiDuration, event.shikiMs));
50
- if (event.slow) Effect.runSync(Metric.update(BuildMetrics.codeblockSlow, 1));
67
+ case "CodeBlockProcessed": {
68
+ const attrs = {
69
+ scope: event.ctx.apiScope ?? "(unscoped)",
70
+ component: event.component,
71
+ twoslash: String(event.twoslash)
72
+ };
73
+ const tagged = (metric) => Metric.withAttributes(metric, attrs);
74
+ update(BuildMetrics.codeblockTotal, 1);
75
+ update(tagged(BuildMetrics.codeblockTotal), 1);
76
+ update(BuildMetrics.codeblockDuration, event.totalMs);
77
+ update(BuildMetrics.codeblockTimeMs, event.totalMs);
78
+ update(tagged(BuildMetrics.codeblockTimeMs), event.totalMs);
79
+ update(BuildMetrics.codeblockTwoslashMs, event.twoslashMs);
80
+ update(tagged(BuildMetrics.codeblockTwoslashMs), event.twoslashMs);
81
+ update(BuildMetrics.codeblockShikiMs, event.shikiMs);
82
+ update(tagged(BuildMetrics.codeblockShikiMs), event.shikiMs);
83
+ if (event.twoslash) {
84
+ update(BuildMetrics.codeblockTwoslashTotal, 1);
85
+ update(tagged(BuildMetrics.codeblockTwoslashTotal), 1);
86
+ }
87
+ if (event.shikiMs > 0) update(BuildMetrics.codeblockShikiDuration, event.shikiMs);
88
+ if (event.slow) {
89
+ update(BuildMetrics.codeblockSlow, 1);
90
+ update(tagged(BuildMetrics.codeblockSlow), 1);
91
+ }
51
92
  break;
93
+ }
52
94
  case "VfsGenerated":
53
- Effect.runSync(Metric.update(BuildMetrics.vfsFiles, 1));
95
+ update(BuildMetrics.vfsFiles, 1);
54
96
  break;
55
97
  case "ImportsPrepended":
56
- Effect.runSync(Metric.update(BuildMetrics.importsPrepended, 1));
98
+ update(BuildMetrics.importsPrepended, 1);
57
99
  break;
58
100
  case "PhaseCompleted":
59
- Effect.runSync(Metric.update(BuildMetrics.phaseDuration, event.durationMs));
101
+ update(BuildMetrics.phaseDuration, event.durationMs);
102
+ both(BuildMetrics.phaseTimeMs, event.durationMs, { phase: event.phase });
60
103
  break;
61
- case "DefaultApplied": Effect.runSync(Metric.update(BuildMetrics.configDefaultsApplied, 1));
104
+ case "DefaultApplied": update(BuildMetrics.configDefaultsApplied, 1);
62
105
  }
63
106
  }
64
107
  };
@@ -0,0 +1,86 @@
1
+ import path from "node:path";
2
+ import { Effect, FileSystem } from "effect";
3
+
4
+ //#region src/observability/sinks/render-sink.ts
5
+ const UNSCOPED = "(unscoped)";
6
+ /** How many of the slowest blocks the artifact retains. */
7
+ const SLOWEST_LIMIT = 25;
8
+ function makeRenderSink() {
9
+ let firstAt = 0;
10
+ let lastAt = 0;
11
+ const byFile = /* @__PURE__ */ new Map();
12
+ const slowest = [];
13
+ return {
14
+ minLevel: "trace",
15
+ handle(event) {
16
+ if (event._tag !== "CodeBlockProcessed") return;
17
+ const now = performance.now();
18
+ if (firstAt === 0) firstAt = now;
19
+ lastAt = now;
20
+ const sample = {
21
+ apiScope: event.ctx.apiScope ?? UNSCOPED,
22
+ file: event.ctx.file ?? "unknown",
23
+ component: event.component,
24
+ totalMs: event.totalMs,
25
+ twoslashMs: event.twoslashMs,
26
+ shikiMs: event.shikiMs
27
+ };
28
+ const current = byFile.get(sample.file) ?? {
29
+ blocks: 0,
30
+ twoslashBlocks: 0,
31
+ totalMs: 0,
32
+ twoslashMs: 0
33
+ };
34
+ byFile.set(sample.file, {
35
+ blocks: current.blocks + 1,
36
+ twoslashBlocks: current.twoslashBlocks + (event.twoslash ? 1 : 0),
37
+ totalMs: current.totalMs + sample.totalMs,
38
+ twoslashMs: current.twoslashMs + sample.twoslashMs
39
+ });
40
+ if (slowest.length < SLOWEST_LIMIT || sample.totalMs > (slowest.at(-1)?.totalMs ?? 0)) {
41
+ const at = slowest.findIndex((s) => s.totalMs < sample.totalMs);
42
+ slowest.splice(at === -1 ? slowest.length : at, 0, sample);
43
+ if (slowest.length > SLOWEST_LIMIT) slowest.length = SLOWEST_LIMIT;
44
+ }
45
+ },
46
+ snapshot() {
47
+ return {
48
+ wallMs: firstAt === 0 ? 0 : lastAt - firstAt,
49
+ byFile: Object.fromEntries(byFile),
50
+ slowest: [...slowest]
51
+ };
52
+ }
53
+ };
54
+ }
55
+ /**
56
+ * Write the render-phase attribution artifact to
57
+ * `<cwd>/.api-docs/build/render-phase.json`, combining the metric-derived
58
+ * rollups with the sample-shaped per-file and slowest-block data.
59
+ *
60
+ * Writes nothing when no code block was processed, so a build that never
61
+ * reached the render phase does not leave an empty artifact behind.
62
+ */
63
+ function writeRenderPhaseJson(report, samples, opts) {
64
+ return Effect.gen(function* () {
65
+ if (report.overall.blocks === 0) return;
66
+ const fs = yield* FileSystem.FileSystem;
67
+ const dir = path.join(opts.cwd, ".api-docs", "build");
68
+ yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.ignore);
69
+ const doc = {
70
+ generatedAt: opts.generatedAt,
71
+ package: opts.packageName,
72
+ target: "prod",
73
+ overall: report.overall,
74
+ wallMs: samples.wallMs,
75
+ byScope: report.byScope,
76
+ byComponent: report.byComponent,
77
+ series: report.series,
78
+ byFile: samples.byFile,
79
+ slowest: samples.slowest
80
+ };
81
+ yield* fs.writeFileString(path.join(dir, "render-phase.json"), `${JSON.stringify(doc, null, 2)}\n`).pipe(Effect.ignore);
82
+ });
83
+ }
84
+
85
+ //#endregion
86
+ export { makeRenderSink, writeRenderPhaseJson };
@@ -7,30 +7,23 @@ function openTracePath(p) {
7
7
  fs.writeFileSync(p, "");
8
8
  }
9
9
  /**
10
- * Create a JSONL trace sink.
10
+ * Create a JSONL trace sink, opening the file eagerly at construction.
11
11
  *
12
- * When `initialPath` is provided the file is opened eagerly at construction
13
- * (existing behaviour for an explicit `trace: "/some/path"` config).
14
- * When omitted the sink starts in deferred mode — events are silently dropped
15
- * until `setPath` is called. This lets `plugin.ts` create the sink before
16
- * the RSPress `outDir` is known and then bind the real path in the `config()`
17
- * hook once `_config.outDir` is available.
12
+ * The path is always known up front: `resolveObservability` derives it from
13
+ * `cwd`, which (unlike the RSPress `outDir`) is available at plugin-factory
14
+ * time. The sink previously supported a deferred mode — construct with no path,
15
+ * bind one later via `setPath` for the era when the path depended on
16
+ * `outDir`; nothing has called it since, so it is gone.
18
17
  */
19
- function makeTraceSink(initialPath) {
20
- let currentPath = initialPath ?? null;
21
- if (currentPath) openTracePath(currentPath);
18
+ function makeTraceSink(tracePath) {
19
+ openTracePath(tracePath);
22
20
  return {
23
21
  minLevel: "trace",
24
22
  capturesPayload: true,
25
23
  handle: (event) => {
26
- if (!currentPath) return;
27
- fs.appendFileSync(currentPath, `${JSON.stringify(event)}\n`);
24
+ fs.appendFileSync(tracePath, `${JSON.stringify(event)}\n`);
28
25
  },
29
- flush: () => {},
30
- setPath: (p) => {
31
- currentPath = p;
32
- openTracePath(p);
33
- }
26
+ flush: () => {}
34
27
  };
35
28
  }
36
29
 
@@ -54,4 +54,4 @@ function withPhase(phase, ctx, effect, thresholds) {
54
54
  }
55
55
 
56
56
  //#endregion
57
- export { PHASE_THRESHOLD_KEY, withPhase };
57
+ export { withPhase };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rspress-plugin-api-extractor",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "private": false,
5
5
  "description": "RSPress plugin for generating API documentation from TypeScript API Extractor models",
6
6
  "keywords": [
@@ -42,7 +42,7 @@
42
42
  "@effected/glob": "^0.4.0",
43
43
  "@effected/jsonc": "^0.8.0",
44
44
  "@effected/markdown": "^0.7.0",
45
- "@effected/npm": "^0.12.0",
45
+ "@effected/npm": "^0.12.1",
46
46
  "@effected/package-json": "^0.12.0",
47
47
  "@effected/semver": "^0.5.0",
48
48
  "@effected/store": "^0.5.0",
@@ -54,7 +54,7 @@
54
54
  "@shikijs/twoslash": "^4.4.3",
55
55
  "@tsdoctor/bundle": "0.2.0",
56
56
  "@tsdoctor/model": "0.2.2",
57
- "@tsdoctor/registry": "0.2.0",
57
+ "@tsdoctor/registry": "0.2.1",
58
58
  "@tsdoctor/snapshot": "0.1.1",
59
59
  "@typescript/vfs": "^1.6.4",
60
60
  "clsx": "^2.1.1",
package/plugin.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { PluginEvent } from "./observability/events.js";
2
2
  import { emit, makeRuntimeEmitter } from "./observability/EventBus.js";
3
+ import { codeBlockReport } from "./observability/metric-report.js";
3
4
  import { runHeartbeat } from "./observability/heartbeat.js";
4
5
  import { writeIssuesJson } from "./observability/sinks/issues-sink.js";
6
+ import { writeRenderPhaseJson } from "./observability/sinks/render-sink.js";
5
7
  import { buildEventBus, logBuildSummary, makeSummaryLoggerLayer } from "./layers/ObservabilityLive.js";
6
8
  import { setPrettierEventEmitter } from "./prettier-formatter.js";
7
9
  import { setOgResolverEventEmitter } from "./og-resolver.js";
@@ -17,8 +19,10 @@ import { resolveObservability } from "./schemas/observability.js";
17
19
  import { PluginOptions } from "./schemas/config.js";
18
20
  import "./schemas/index.js";
19
21
  import { ConfigService } from "./services/ConfigService.js";
22
+ import { TwoslashCacheService } from "./services/TwoslashCacheService.js";
20
23
  import { ConfigServiceLive } from "./layers/ConfigServiceLive.js";
21
24
  import { PathDerivationServiceLive } from "./layers/PathDerivationServiceLive.js";
25
+ import { TwoslashCacheServiceLive } from "./layers/TwoslashCacheServiceLive.js";
22
26
  import { TypeRegistryServiceLive } from "./layers/TypeRegistryServiceLive.js";
23
27
  import { remarkApiCodeblocks, setRemarkApiCodeblocksEventEmitter } from "./remark-api-codeblocks.js";
24
28
  import { remarkWithApi, setRemarkWithApiEventEmitter } from "./remark-with-api.js";
@@ -83,10 +87,10 @@ function ApiExtractorPluginImpl(rawOptions) {
83
87
  cwd: process.cwd(),
84
88
  buildId
85
89
  });
86
- const { layer: eventBusLayer, trace: traceSink, issues: issuesSink } = buildEventBus(obs);
90
+ const { layer: eventBusLayer, trace: traceSink, issues: issuesSink, render: renderSink, metrics: metricStore } = buildEventBus(obs);
87
91
  const dbPath = path.resolve(process.cwd(), ".api-docs", "snapshot", "api-docs.db");
88
92
  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
89
- const BaseLayer = Layer.mergeAll(PathDerivationServiceLive, eventBusLayer, TypeRegistryServiceLive, NodeFileSystem.layer, SnapshotServiceLive(dbPath), makeSummaryLoggerLayer(obs.logLevel));
93
+ const BaseLayer = Layer.mergeAll(PathDerivationServiceLive, eventBusLayer, metricStore.layer, TypeRegistryServiceLive, NodeFileSystem.layer, SnapshotServiceLive(dbPath), TwoslashCacheServiceLive, makeSummaryLoggerLayer(obs.logLevel));
90
94
  const EffectAppLayer = Layer.provideMerge(ConfigServiceLive(options, shikiCrossLinker, buildId, obs.thresholds), BaseLayer);
91
95
  const effectRuntime = ManagedRuntime.make(EffectAppLayer);
92
96
  const emitSync = makeRuntimeEmitter(effectRuntime);
@@ -95,8 +99,13 @@ function ApiExtractorPluginImpl(rawOptions) {
95
99
  setPrettierEventEmitter(emitSync, buildId);
96
100
  setOgResolverEventEmitter(emitSync, buildId);
97
101
  setRemarkWithApiEventEmitter(emitSync, buildId, obs.thresholds.slowCodeBlock);
98
- setRemarkApiCodeblocksEventEmitter(emitSync, buildId);
102
+ setRemarkApiCodeblocksEventEmitter(emitSync, buildId, obs.thresholds.slowCodeBlock);
99
103
  setBuildStagesEventEmitter(emitSync, buildId);
104
+ /**
105
+ * The build's Twoslash result cache, captured in `config()` and persisted in
106
+ * `afterBuild` — the render phase that populates it runs in between.
107
+ */
108
+ let twoslashCacheHandle = null;
100
109
  const fileContextMap = /* @__PURE__ */ new Map();
101
110
  let docsRoot;
102
111
  let isFirstBuild = true;
@@ -110,13 +119,37 @@ function ApiExtractorPluginImpl(rawOptions) {
110
119
  async beforeBuild(_config, _isProd) {},
111
120
  async afterBuild(_config, isProd) {
112
121
  if (isFirstBuild && !isInert) {
113
- await effectRuntime.runPromise(logBuildSummary(obs.thresholds.slowCodeBlock));
122
+ const renderSamples = renderSink.snapshot();
123
+ const report = await effectRuntime.runPromise(codeBlockReport);
124
+ if (twoslashCacheHandle) {
125
+ const { cache, envHash } = twoslashCacheHandle;
126
+ const stats = cache.stats();
127
+ await effectRuntime.runPromise(Effect.gen(function* () {
128
+ if (stats.dirty) yield* (yield* TwoslashCacheService).save(envHash, cache.entries());
129
+ yield* emit(PluginEvent.TwoslashCacheSaved({
130
+ ctx: { buildId },
131
+ level: "info",
132
+ envHash,
133
+ hits: stats.hits,
134
+ misses: stats.misses,
135
+ entries: stats.entries,
136
+ persisted: stats.dirty
137
+ }));
138
+ }));
139
+ }
140
+ await effectRuntime.runPromise(logBuildSummary(obs.thresholds.slowCodeBlock, report));
114
141
  if (isProd) await effectRuntime.runPromise(Effect.gen(function* () {
115
142
  const packageName = yield* readSitePackageName;
143
+ const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
116
144
  yield* writeIssuesJson(issuesSink.snapshot(), {
117
145
  cwd: process.cwd(),
118
146
  packageName,
119
- generatedAt: (/* @__PURE__ */ new Date()).toISOString()
147
+ generatedAt
148
+ });
149
+ yield* writeRenderPhaseJson(report, renderSamples, {
150
+ cwd: process.cwd(),
151
+ packageName,
152
+ generatedAt
120
153
  });
121
154
  }));
122
155
  if (rspressLlmsEnabled && resolvedLlmsPlugin.enabled) {
@@ -205,6 +238,10 @@ function ApiExtractorPluginImpl(rawOptions) {
205
238
  buildId
206
239
  }));
207
240
  const buildContext = yield* (yield* ConfigService).resolve(rspressConfigSubset);
241
+ twoslashCacheHandle = {
242
+ cache: buildContext.twoslashCache,
243
+ envHash: buildContext.twoslashEnvHash
244
+ };
208
245
  buildResults.length = 0;
209
246
  yield* Ref.set(phaseRef, "generate");
210
247
  yield* Effect.forEach(buildContext.apiConfigs, (apiConfig) => generateApiDocs({
@@ -271,7 +308,7 @@ function ApiExtractorPluginImpl(rawOptions) {
271
308
  const remarkTheme = normalizeThemeConfig(options.api?.theme ?? options.apis?.[0]?.theme);
272
309
  updatedConfig.markdown.remarkPlugins.push([remarkWithApi, {
273
310
  shikiCrossLinker,
274
- getTransformer: () => TwoslashManager.getInstance().getTransformer(),
311
+ getTransformer: (apiScope) => TwoslashManager.getInstance().getTransformer(apiScope),
275
312
  theme: remarkTheme
276
313
  }]);
277
314
  updatedConfig.markdown.remarkPlugins.push([remarkApiCodeblocks]);
@@ -2,15 +2,18 @@ import { PluginEvent } from "./observability/events.js";
2
2
  import { TwoslashManager } from "./twoslash-transformer.js";
3
3
  import { VfsRegistry } from "./vfs-registry.js";
4
4
  import { generateShikiHast } from "./markdown/shiki-utils.js";
5
+ import { createTwoslashTimingWrapper } from "./twoslash-timing-wrapper.js";
5
6
  import { visit } from "unist-util-visit";
6
7
 
7
8
  //#region src/remark-api-codeblocks.ts
8
9
  /** Module-level emitter injected by plugin.ts at startup. */
9
10
  let emitEvent = () => {};
10
11
  let currentBuildId = "";
11
- function setRemarkApiCodeblocksEventEmitter(fn, buildId = "") {
12
+ let currentSlowCodeBlockMs = Number.POSITIVE_INFINITY;
13
+ function setRemarkApiCodeblocksEventEmitter(fn, buildId = "", slowCodeBlockMs = Number.POSITIVE_INFINITY) {
12
14
  emitEvent = fn;
13
15
  currentBuildId = buildId;
16
+ currentSlowCodeBlockMs = slowCodeBlockMs;
14
17
  }
15
18
  /**
16
19
  * Create an MDX JSX attribute value expression with proper estree AST.
@@ -123,14 +126,37 @@ const remarkApiCodeblocks = () => {
123
126
  return;
124
127
  }
125
128
  const transformers = [];
126
- if (node.name === "ApiExample" && vfsConfig.twoslashTransformer) transformers.push(vfsConfig.twoslashTransformer);
129
+ let twoslashMs = 0;
130
+ if (node.name === "ApiExample" && vfsConfig.twoslashTransformer) transformers.push(createTwoslashTimingWrapper(vfsConfig.twoslashTransformer, (duration) => {
131
+ twoslashMs += duration;
132
+ }));
127
133
  else if (node.name === "ApiMember" && vfsConfig.hideCutTransformer) transformers.push(vfsConfig.hideCutTransformer);
128
134
  else if (node.name === "ApiSignature") {
129
135
  if (vfsConfig.hideCutLinesTransformer) transformers.push(vfsConfig.hideCutLinesTransformer);
130
136
  }
131
137
  const isExample = node.name === "ApiExample" && !!vfsConfig.twoslashTransformer;
132
- let hast = await generateShikiHast(source, vfsConfig.highlighter, transformers.length > 0 ? transformers : void 0, isExample, vfsConfig.theme);
138
+ const shikiStart = performance.now();
139
+ const hastPromise = generateShikiHast(source, vfsConfig.highlighter, transformers.length > 0 ? transformers : void 0, isExample, vfsConfig.theme);
140
+ const renderMs = performance.now() - shikiStart;
141
+ let hast = await hastPromise;
142
+ const postStart = performance.now();
133
143
  if (hast && vfsConfig.crossLinker) hast = vfsConfig.crossLinker.transformHast(hast, apiScopeValue);
144
+ const totalBlockTime = renderMs + (performance.now() - postStart);
145
+ emitEvent(PluginEvent.CodeBlockProcessed({
146
+ ctx: {
147
+ buildId: currentBuildId,
148
+ apiScope: apiScopeValue,
149
+ file: currentFilePath
150
+ },
151
+ lang: "typescript",
152
+ component: node.name,
153
+ twoslash: isExample,
154
+ twoslashMs,
155
+ shikiMs: Math.max(0, renderMs - twoslashMs),
156
+ totalMs: totalBlockTime,
157
+ slow: totalBlockTime > currentSlowCodeBlockMs,
158
+ level: "debug"
159
+ }));
134
160
  const hastBase64 = hast ? Buffer.from(JSON.stringify(hast), "utf-8").toString("base64") : "";
135
161
  node.attributes.push({
136
162
  type: "mdxJsxAttribute",
@@ -3,6 +3,7 @@ import { formatCode } from "./prettier-formatter.js";
3
3
  import { stripTwoslashDirectives } from "./markdown/helpers.js";
4
4
  import { TwoslashManager } from "./twoslash-transformer.js";
5
5
  import { DEFAULT_SHIKI_THEMES } from "./markdown/shiki-utils.js";
6
+ import { createTwoslashTimingWrapper } from "./twoslash-timing-wrapper.js";
6
7
  import { codeToHast, hastToHtml } from "shiki";
7
8
  import { visit } from "unist-util-visit";
8
9
 
@@ -78,9 +79,13 @@ const remarkWithApi = (options) => {
78
79
  const promise = (async () => {
79
80
  const blockStart = performance.now();
80
81
  const rawCode = node.value;
82
+ const apiScope = currentFilePath ? inferApiScope(currentFilePath) : void 0;
81
83
  const code = (await formatCode(rawCode, lang)).code;
82
- const twoslashTransformer = getTransformer();
83
- const transformers = twoslashTransformer ? [twoslashTransformer] : [];
84
+ let twoslashMs = 0;
85
+ const twoslashTransformer = getTransformer(apiScope);
86
+ const transformers = twoslashTransformer ? [createTwoslashTimingWrapper(twoslashTransformer, (duration) => {
87
+ twoslashMs += duration;
88
+ })] : [];
84
89
  const shikiStart = performance.now();
85
90
  let hast = await codeToHast(code, {
86
91
  lang,
@@ -93,19 +98,21 @@ const remarkWithApi = (options) => {
93
98
  cssVariablePrefix: "--api-shiki-",
94
99
  transformers
95
100
  });
96
- const apiScope = currentFilePath ? inferApiScope(currentFilePath) : void 0;
101
+ const renderMs = performance.now() - shikiStart;
97
102
  if (apiScope) hast = shikiCrossLinker.transformHast(hast, apiScope);
98
- const shikiTime = performance.now() - shikiStart;
99
103
  const totalBlockTime = performance.now() - blockStart;
100
104
  const isSlow = totalBlockTime > currentSlowCodeBlockMs;
101
105
  emitEvent(PluginEvent.CodeBlockProcessed({
102
106
  ctx: {
103
107
  buildId: currentBuildId,
108
+ ...apiScope != null ? { apiScope } : {},
104
109
  ...currentFilePath != null ? { file: currentFilePath } : {}
105
110
  },
106
111
  lang,
107
- shikiMs: shikiTime,
108
- twoslashMs: totalBlockTime - shikiTime,
112
+ component: "with-api",
113
+ twoslash: twoslashTransformer != null,
114
+ twoslashMs,
115
+ shikiMs: Math.max(0, renderMs - twoslashMs),
109
116
  totalMs: totalBlockTime,
110
117
  slow: isSlow,
111
118
  level: "debug"
package/schemas/config.js CHANGED
@@ -273,15 +273,19 @@ const MultiApiConfig = Schema.Struct({
273
273
  * Path to a `tsconfig.json` for Twoslash.
274
274
  *
275
275
  * @remarks
276
- * Twoslash runs against a single shared TypeScript environment for the
277
- * whole build, so per-API tsconfigs are not honored in multi-API mode:
278
- * the first API that provides one wins and the rest are ignored (a
279
- * `ConfigCascadeWarning` is emitted when they differ). Ensure the
280
- * configured tsconfigs are equivalent, or set the intended one on the
281
- * first API only.
276
+ * This API's code blocks are type-checked under this config. APIs that
277
+ * declare the same config share one TypeScript environment; the file set
278
+ * is shared across all documented APIs either way, so a type owned by
279
+ * another documented package still resolves.
282
280
  */
283
281
  tsconfig: Schema.optional(ModelInput),
284
- /** TypeScript compiler options for Twoslash. First API wins, as with `tsconfig`. */
282
+ /**
283
+ * TypeScript compiler options for Twoslash, applying to this API only.
284
+ *
285
+ * @remarks
286
+ * Merged on top of the defaults and of this API's `tsconfig`, so declaring
287
+ * a single option overrides just that one.
288
+ */
285
289
  compilerOptions: Schema.optional(Schema.Unknown)
286
290
  });
287
291
  /**
@@ -0,0 +1,15 @@
1
+ import { Context } from "effect";
2
+
3
+ //#region src/services/TwoslashCacheService.ts
4
+ /**
5
+ * Persistence for the Twoslash result cache.
6
+ *
7
+ * Split from the synchronous cache object (`twoslash-cache.ts`) because
8
+ * `TwoslashTypesCache.read`/`write` are called from inside Shiki's `preprocess`
9
+ * hook and cannot await: the service loads once before the render phase and
10
+ * saves once after it, while every lookup in between is a synchronous map hit.
11
+ */
12
+ var TwoslashCacheService = class extends Context.Service()("rspress-plugin-api-extractor/TwoslashCacheService") {};
13
+
14
+ //#endregion
15
+ export { TwoslashCacheService };
@@ -35,13 +35,9 @@
35
35
  * const crossLinker = new ShikiCrossLinker();
36
36
  * crossLinker.reinitialize(routes, kinds, "my-api");
37
37
  * crossLinker.setApiScope("my-api");
38
- * const transformer = crossLinker.createTransformer();
39
38
  *
40
- * // Use with Shiki
41
- * const html = await codeToHtml(code, {
42
- * lang: "typescript",
43
- * transformers: [transformer]
44
- * });
39
+ * // Cross-link the finalized HAST, after Shiki and Twoslash have run
40
+ * const linked = crossLinker.transformHast(hast, "my-api");
45
41
  * ```
46
42
  *
47
43
  * @see the `@tsdoctor/model` CrossLinker for the markdown equivalent
@@ -142,22 +138,6 @@ var ShikiCrossLinker = class {
142
138
  return this.classMembersMapByScope.get(scope) || /* @__PURE__ */ new Map();
143
139
  }
144
140
  /**
145
- * Create a Shiki transformer that adds cross-links to type references in code blocks.
146
- *
147
- * **DEPRECATED:** This method now returns a no-op transformer. Cross-linking has been
148
- * moved to post-processing via {@link transformHast} to avoid interfering with Twoslash
149
- * popup positioning. The Twoslash transformer calculates popup positions based on the
150
- * original span structure, and modifying spans during the Shiki pipeline caused popups
151
- * to appear offset from their intended positions.
152
- *
153
- * @param _apiScope - Unused, kept for API compatibility
154
- * @returns A no-op Shiki transformer
155
- * @deprecated Use {@link transformHast} after Shiki processing completes instead
156
- */
157
- createTransformer(_apiScope) {
158
- return { name: "api-docs-cross-linker" };
159
- }
160
- /**
161
141
  * Transform a finalized HAST tree to add cross-links to type references.
162
142
  *
163
143
  * This method should be called AFTER Shiki (including Twoslash) has fully processed