rspress-plugin-api-extractor 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/BuildEnv.js +58 -0
  2. package/build-program.js +34 -33
  3. package/build-stages.js +48 -42
  4. package/config-helpers.js +7 -7
  5. package/errors.js +1 -6
  6. package/index.d.ts +84 -86
  7. package/layers/AppLayer.js +67 -0
  8. package/layers/api-results.js +83 -0
  9. package/layers/build-metrics.js +1 -1
  10. package/layers/config-resolution.js +407 -0
  11. package/layers/external-types.js +74 -0
  12. package/layers/{ObservabilityLive.js → observability.js} +3 -3
  13. package/layers/type-environment.js +109 -0
  14. package/layers/xdg.js +44 -0
  15. package/markdown/helpers.js +9 -55
  16. package/markdown/page-generators/class-page.js +8 -31
  17. package/markdown/page-generators/index-pages.js +6 -8
  18. package/markdown/page-generators/interface-page.js +7 -7
  19. package/markdown/shiki-utils.js +65 -10
  20. package/model-loader.js +3 -3
  21. package/observability/EventBus.js +29 -7
  22. package/observability/heartbeat.js +1 -1
  23. package/observability/sinks/metrics-sink.js +1 -1
  24. package/observability/sinks/trace-sink.js +4 -4
  25. package/observability/spans.js +3 -1
  26. package/observability/sync-emitter.js +78 -0
  27. package/og-resolver.js +74 -284
  28. package/package.json +3 -4
  29. package/path-derivation.js +19 -1
  30. package/plugin.js +63 -91
  31. package/prettier-formatter.js +5 -11
  32. package/remark-api-codeblocks.js +11 -19
  33. package/remark-with-api.js +11 -21
  34. package/schemas/config.js +0 -2
  35. package/services/ConfigService.js +37 -2
  36. package/services/HighlighterService.js +75 -0
  37. package/services/OgService.js +190 -0
  38. package/services/PluginConfig.js +26 -0
  39. package/services/TwoslashCacheService.js +128 -2
  40. package/services/TwoslashEnvironments.js +35 -0
  41. package/services/TypeRegistryService.js +178 -2
  42. package/shiki-transformer.js +53 -234
  43. package/sync-node-fs.js +6 -6
  44. package/tsconfig-parser.js +77 -95
  45. package/twoslash-access.js +48 -0
  46. package/twoslash-transformer.js +106 -83
  47. package/vfs-registry.js +1 -31
  48. package/layers/ConfigServiceLive.js +0 -600
  49. package/layers/PathDerivationServiceLive.js +0 -16
  50. package/layers/TwoslashCacheServiceLive.js +0 -53
  51. package/layers/TypeRegistryServiceLive.js +0 -155
  52. package/markdown/index.js +0 -11
  53. package/schemas/index.js +0 -6
  54. package/services/PathDerivationService.js +0 -7
package/plugin.js CHANGED
@@ -1,39 +1,33 @@
1
1
  import { PluginEvent } from "./observability/events.js";
2
- import { emit, makeRuntimeEmitter } from "./observability/EventBus.js";
3
- import { codeBlockReport } from "./observability/metric-report.js";
4
- import { runHeartbeat } from "./observability/heartbeat.js";
5
- import { writeIssuesJson } from "./observability/sinks/issues-sink.js";
6
- import { writeRenderPhaseJson } from "./observability/sinks/render-sink.js";
7
- import { buildEventBus, logBuildSummary, makeSummaryLoggerLayer } from "./layers/ObservabilityLive.js";
8
- import { setPrettierEventEmitter } from "./prettier-formatter.js";
9
- import { setOgResolverEventEmitter } from "./og-resolver.js";
10
- import { setBuildStagesEventEmitter } from "./build-stages.js";
11
- import { TwoslashManager, setEventEmitter } from "./twoslash-transformer.js";
2
+ import { emit } from "./observability/EventBus.js";
3
+ import { emitSync, installSyncEmitter } from "./observability/sync-emitter.js";
4
+ import { normalizeThemeConfig } from "./markdown/shiki-utils.js";
5
+ import { clearTypeRoutes } from "./twoslash-transformer.js";
6
+ import { TwoslashEnvironments } from "./services/TwoslashEnvironments.js";
12
7
  import { VfsRegistry } from "./vfs-registry.js";
13
8
  import { generateApiDocs } from "./build-program.js";
14
9
  import { deriveOutputPaths, normalizeBaseRoute, unscopedName } from "./path-derivation.js";
15
10
  import { fromDir, fromParentDir } from "./config-helpers.js";
16
11
  import { classifyApiConfig, mergeLlmsPluginConfig } from "./config-utils.js";
17
- import { DEFAULT_SHIKI_THEMES, setShikiUtilsEventEmitter } from "./markdown/shiki-utils.js";
18
12
  import { resolveObservability } from "./schemas/observability.js";
19
13
  import { PluginOptions } from "./schemas/config.js";
20
- import "./schemas/index.js";
21
- import { ConfigService } from "./services/ConfigService.js";
22
14
  import { TwoslashCacheService } from "./services/TwoslashCacheService.js";
23
- import { ConfigServiceLive } from "./layers/ConfigServiceLive.js";
24
- import { PathDerivationServiceLive } from "./layers/PathDerivationServiceLive.js";
25
- import { TwoslashCacheServiceLive } from "./layers/TwoslashCacheServiceLive.js";
26
- import { TypeRegistryServiceLive } from "./layers/TypeRegistryServiceLive.js";
27
- import { remarkApiCodeblocks, setRemarkApiCodeblocksEventEmitter } from "./remark-api-codeblocks.js";
28
- import { remarkWithApi, setRemarkWithApiEventEmitter } from "./remark-with-api.js";
29
- import { ShikiCrossLinker } from "./shiki-transformer.js";
15
+ import { ConfigService } from "./services/ConfigService.js";
16
+ import { codeBlockReport } from "./observability/metric-report.js";
17
+ import { runHeartbeat } from "./observability/heartbeat.js";
18
+ import { writeIssuesJson } from "./observability/sinks/issues-sink.js";
19
+ import { writeRenderPhaseJson } from "./observability/sinks/render-sink.js";
20
+ import { buildEventBus, logBuildSummary } from "./layers/observability.js";
21
+ import { makeAppLayers } from "./layers/AppLayer.js";
22
+ import { clearTwoslashAccess, installTwoslashAccess, twoslashTransformerFor } from "./twoslash-access.js";
23
+ import { remarkApiCodeblocks } from "./remark-api-codeblocks.js";
24
+ import { remarkWithApi } from "./remark-with-api.js";
30
25
  import { createRequire } from "node:module";
31
- import fs from "node:fs";
26
+ import fsSync from "node:fs";
27
+ import os from "node:os";
32
28
  import path from "node:path";
33
29
  import { fileURLToPath } from "node:url";
34
- import { NodeFileSystem } from "@effect/platform-node";
35
- import { SnapshotServiceLive } from "@tsdoctor/snapshot";
36
- import { Effect, FileSystem, Layer, ManagedRuntime, Ref, Schema } from "effect";
30
+ import { Effect, FileSystem, ManagedRuntime, Option, Ref, Schema } from "effect";
37
31
 
38
32
  //#region src/plugin.ts
39
33
  /* v8 ignore start -- RSPress plugin adapter, requires RSPress runtime */
@@ -53,30 +47,11 @@ const readSitePackageName = Effect.gen(function* () {
53
47
  return "unknown";
54
48
  });
55
49
  /**
56
- * Normalize theme configuration from user input to a consistent format.
57
- */
58
- function normalizeThemeConfig(theme) {
59
- if (!theme) return { ...DEFAULT_SHIKI_THEMES };
60
- if (typeof theme === "string") return {
61
- light: theme,
62
- dark: theme
63
- };
64
- if ("light" in theme && "dark" in theme && typeof theme.light === "string" && typeof theme.dark === "string") return {
65
- light: theme.light,
66
- dark: theme.dark
67
- };
68
- return {
69
- light: theme,
70
- dark: theme
71
- };
72
- }
73
- /**
74
50
  * RSPress plugin for generating API documentation from API Extractor model files
75
51
  */
76
52
  function ApiExtractorPluginImpl(rawOptions) {
77
53
  const options = Schema.decodeUnknownSync(PluginOptions)(rawOptions);
78
54
  const isInert = classifyApiConfig(options) === "disabled";
79
- const shikiCrossLinker = new ShikiCrossLinker();
80
55
  const envLogLevel = process.env.LOG_LEVEL?.toLowerCase();
81
56
  const buildId = `${process.pid}-${performance.now().toString(36)}`;
82
57
  const { resolved: obs, deprecations } = resolveObservability({
@@ -89,23 +64,19 @@ function ApiExtractorPluginImpl(rawOptions) {
89
64
  });
90
65
  const { layer: eventBusLayer, trace: traceSink, issues: issuesSink, render: renderSink, metrics: metricStore } = buildEventBus(obs);
91
66
  const dbPath = path.resolve(process.cwd(), ".api-docs", "snapshot", "api-docs.db");
92
- fs.mkdirSync(path.dirname(dbPath), { recursive: true });
93
- const BaseLayer = Layer.mergeAll(PathDerivationServiceLive, eventBusLayer, metricStore.layer, TypeRegistryServiceLive, NodeFileSystem.layer, SnapshotServiceLive(dbPath), TwoslashCacheServiceLive, makeSummaryLoggerLayer(obs.logLevel));
94
- const EffectAppLayer = Layer.provideMerge(ConfigServiceLive(options, shikiCrossLinker, buildId, obs.thresholds), BaseLayer);
95
- const effectRuntime = ManagedRuntime.make(EffectAppLayer);
96
- const emitSync = makeRuntimeEmitter(effectRuntime);
97
- setEventEmitter(emitSync, buildId);
98
- setShikiUtilsEventEmitter(emitSync, buildId);
99
- setPrettierEventEmitter(emitSync, buildId);
100
- setOgResolverEventEmitter(emitSync, buildId);
101
- setRemarkWithApiEventEmitter(emitSync, buildId, obs.thresholds.slowCodeBlock);
102
- setRemarkApiCodeblocksEventEmitter(emitSync, buildId, obs.thresholds.slowCodeBlock);
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;
67
+ fsSync.mkdirSync(path.dirname(dbPath), { recursive: true });
68
+ const appLayers = makeAppLayers({
69
+ options,
70
+ obs,
71
+ buildId,
72
+ dbPath,
73
+ pageConcurrency: os.cpus().length,
74
+ eventBus: eventBusLayer,
75
+ metrics: metricStore
76
+ });
77
+ const effectRuntime = ManagedRuntime.make(appLayers.app);
78
+ const emitterRuntime = ManagedRuntime.make(appLayers.emitter);
79
+ installSyncEmitter(emitterRuntime);
109
80
  const fileContextMap = /* @__PURE__ */ new Map();
110
81
  let docsRoot;
111
82
  let isFirstBuild = true;
@@ -121,22 +92,20 @@ function ApiExtractorPluginImpl(rawOptions) {
121
92
  if (isFirstBuild && !isInert) {
122
93
  const renderSamples = renderSink.snapshot();
123
94
  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
- }));
95
+ await effectRuntime.runPromise(Effect.gen(function* () {
96
+ const saved = yield* (yield* TwoslashCacheService).persist();
97
+ if (Option.isNone(saved)) return;
98
+ const stats = saved.value;
99
+ yield* emit(PluginEvent.TwoslashCacheSaved({
100
+ ctx: { buildId },
101
+ level: "info",
102
+ envHash: stats.envHash,
103
+ hits: stats.hits,
104
+ misses: stats.misses,
105
+ entries: stats.entries,
106
+ persisted: stats.dirty
138
107
  }));
139
- }
108
+ }));
140
109
  await effectRuntime.runPromise(logBuildSummary(obs.thresholds.slowCodeBlock, report));
141
110
  if (isProd) await effectRuntime.runPromise(Effect.gen(function* () {
142
111
  const packageName = yield* readSitePackageName;
@@ -165,7 +134,10 @@ function ApiExtractorPluginImpl(rawOptions) {
165
134
  isFirstBuild = false;
166
135
  }
167
136
  if (traceSink) traceSink.flush();
168
- if (isProd) await effectRuntime.dispose();
137
+ if (isProd) {
138
+ await effectRuntime.dispose();
139
+ await emitterRuntime.dispose();
140
+ }
169
141
  },
170
142
  async config(_config, _utils, isProd) {
171
143
  const buildStartTime = performance.now();
@@ -174,6 +146,8 @@ function ApiExtractorPluginImpl(rawOptions) {
174
146
  const rspressLocales = _config.locales?.map((l) => l.lang) ?? [];
175
147
  const rspressLang = _config.lang;
176
148
  const rspressMultiVersion = _config.multiVersion;
149
+ const rspressSiteOrigin = _config.siteOrigin;
150
+ const rspressBase = _config.base;
177
151
  rspressLlmsEnabled = Boolean(_config.llms);
178
152
  rspressOutDir = _config.outDir ?? "dist";
179
153
  if (options.api) {
@@ -190,7 +164,7 @@ function ApiExtractorPluginImpl(rawOptions) {
190
164
  versions,
191
165
  defaultVersion: rspressMultiVersion?.default
192
166
  });
193
- for (const dp of derivedPaths) fs.mkdirSync(dp.outputDir, { recursive: true });
167
+ for (const dp of derivedPaths) fsSync.mkdirSync(dp.outputDir, { recursive: true });
194
168
  } else if (options.apis) for (const api of options.apis) {
195
169
  const baseRoute = normalizeBaseRoute(api.baseRoute ?? `/${unscopedName(api.packageName)}`);
196
170
  const derivedPaths = deriveOutputPaths({
@@ -203,9 +177,11 @@ function ApiExtractorPluginImpl(rawOptions) {
203
177
  versions: [],
204
178
  defaultVersion: void 0
205
179
  });
206
- for (const dp of derivedPaths) fs.mkdirSync(dp.outputDir, { recursive: true });
180
+ for (const dp of derivedPaths) fsSync.mkdirSync(dp.outputDir, { recursive: true });
207
181
  }
208
182
  VfsRegistry.clear();
183
+ clearTwoslashAccess();
184
+ clearTypeRoutes();
209
185
  fileContextMap.clear();
210
186
  issuesSink.reset();
211
187
  for (const dep of deprecations) emitSync(PluginEvent.DeprecatedConfigUsed({
@@ -219,7 +195,9 @@ function ApiExtractorPluginImpl(rawOptions) {
219
195
  ...rspressMultiVersion != null ? { multiVersion: rspressMultiVersion } : {},
220
196
  ...rspressLocales.length > 0 ? { locales: rspressLocales.map((lang) => ({ lang })) } : {},
221
197
  ...rspressLang != null ? { lang: rspressLang } : {},
222
- ...docsRoot != null ? { root: docsRoot } : {}
198
+ ...docsRoot != null ? { root: docsRoot } : {},
199
+ ...rspressSiteOrigin != null ? { siteOrigin: rspressSiteOrigin } : {},
200
+ ...rspressBase != null ? { base: rspressBase } : {}
223
201
  };
224
202
  await effectRuntime.runPromise(Effect.gen(function* () {
225
203
  const apiCount = options.api ? 1 : options.apis?.length ?? 0;
@@ -237,17 +215,11 @@ function ApiExtractorPluginImpl(rawOptions) {
237
215
  apisTotal: apiCount,
238
216
  buildId
239
217
  }));
240
- const buildContext = yield* (yield* ConfigService).resolve(rspressConfigSubset);
241
- twoslashCacheHandle = {
242
- cache: buildContext.twoslashCache,
243
- envHash: buildContext.twoslashEnvHash
244
- };
218
+ installTwoslashAccess(yield* TwoslashEnvironments);
219
+ const apiConfigs = yield* (yield* ConfigService).resolve(rspressConfigSubset);
245
220
  buildResults.length = 0;
246
221
  yield* Ref.set(phaseRef, "generate");
247
- yield* Effect.forEach(buildContext.apiConfigs, (apiConfig) => generateApiDocs({
248
- ...apiConfig,
249
- suppressExampleErrors: buildContext.suppressExampleErrors
250
- }, buildContext, fileContextMap).pipe(Effect.tap((result) => {
222
+ yield* Effect.forEach(apiConfigs, (apiConfig) => generateApiDocs(apiConfig, fileContextMap).pipe(Effect.tap((result) => {
251
223
  buildResults.push(result);
252
224
  return emit(PluginEvent.ApiDocsCompleted({
253
225
  ctx: { buildId },
@@ -305,10 +277,10 @@ function ApiExtractorPluginImpl(rawOptions) {
305
277
  if (!existingInclude.includes("rspress-plugin-api-extractor/runtime")) updatedConfig.builderConfig.source.include = [...existingInclude, "rspress-plugin-api-extractor/runtime"];
306
278
  if (!updatedConfig.markdown) updatedConfig.markdown = {};
307
279
  if (!updatedConfig.markdown.remarkPlugins) updatedConfig.markdown.remarkPlugins = [];
308
- const remarkTheme = normalizeThemeConfig(options.api?.theme ?? options.apis?.[0]?.theme);
280
+ const firstApiTheme = options.api?.theme ?? options.apis?.[0]?.theme;
281
+ const remarkTheme = normalizeThemeConfig(firstApiTheme);
309
282
  updatedConfig.markdown.remarkPlugins.push([remarkWithApi, {
310
- shikiCrossLinker,
311
- getTransformer: (apiScope) => TwoslashManager.getInstance().getTransformer(apiScope),
283
+ getTransformer: (apiScope) => twoslashTransformerFor(apiScope),
312
284
  theme: remarkTheme
313
285
  }]);
314
286
  updatedConfig.markdown.remarkPlugins.push([remarkApiCodeblocks]);
@@ -1,16 +1,10 @@
1
- import { PluginEvent } from "./observability/events.js";
2
1
  import { addLogicalBlankLines } from "./code-post-processor.js";
2
+ import { PluginEvent } from "./observability/events.js";
3
+ import { emitSync, syncBuildId } from "./observability/sync-emitter.js";
3
4
  import { format } from "prettier";
4
5
 
5
6
  //#region src/prettier-formatter.ts
6
7
  /* v8 ignore start -- Prettier integration wrapper, tested via page generator integration tests */
7
- /** Module-level emitter injected by plugin.ts at startup. */
8
- let emitEvent = () => {};
9
- let currentBuildId = "";
10
- function setPrettierEventEmitter(fn, buildId = "") {
11
- emitEvent = fn;
12
- currentBuildId = buildId;
13
- }
14
8
  /**
15
9
  * Map code fence languages to Prettier parsers
16
10
  */
@@ -65,8 +59,8 @@ async function formatCode(code, language) {
65
59
  } catch (error) {
66
60
  const formatTime = performance.now() - start;
67
61
  const errorMsg = error instanceof Error ? error.message : String(error);
68
- emitEvent(PluginEvent.PrettierError({
69
- ctx: { buildId: currentBuildId },
62
+ emitSync(PluginEvent.PrettierError({
63
+ ctx: { buildId: syncBuildId() },
70
64
  file: "unknown",
71
65
  reason: errorMsg,
72
66
  level: "warn"
@@ -81,4 +75,4 @@ async function formatCode(code, language) {
81
75
  }
82
76
 
83
77
  //#endregion
84
- export { formatCode, setPrettierEventEmitter };
78
+ export { formatCode };
@@ -1,20 +1,12 @@
1
1
  import { PluginEvent } from "./observability/events.js";
2
- import { TwoslashManager } from "./twoslash-transformer.js";
3
- import { VfsRegistry } from "./vfs-registry.js";
2
+ import { emitSync, syncBuildId, syncSlowCodeBlockMs } from "./observability/sync-emitter.js";
4
3
  import { generateShikiHast } from "./markdown/shiki-utils.js";
4
+ import { VfsRegistry } from "./vfs-registry.js";
5
+ import { setTwoslashFile } from "./twoslash-access.js";
5
6
  import { createTwoslashTimingWrapper } from "./twoslash-timing-wrapper.js";
6
7
  import { visit } from "unist-util-visit";
7
8
 
8
9
  //#region src/remark-api-codeblocks.ts
9
- /** Module-level emitter injected by plugin.ts at startup. */
10
- let emitEvent = () => {};
11
- let currentBuildId = "";
12
- let currentSlowCodeBlockMs = Number.POSITIVE_INFINITY;
13
- function setRemarkApiCodeblocksEventEmitter(fn, buildId = "", slowCodeBlockMs = Number.POSITIVE_INFINITY) {
14
- emitEvent = fn;
15
- currentBuildId = buildId;
16
- currentSlowCodeBlockMs = slowCodeBlockMs;
17
- }
18
10
  /**
19
11
  * Create an MDX JSX attribute value expression with proper estree AST.
20
12
  * This ensures the value is properly serialized as a JavaScript string literal.
@@ -94,7 +86,7 @@ const remarkApiCodeblocks = () => {
94
86
  const promises = [];
95
87
  const isSsgMd = import.meta.env?.SSG_MD || process.env.RSBUILD_ENVIRONMENT === "node_md" || process.env.BUILD_TARGET === "node_md";
96
88
  const currentFilePath = file.path || "unknown";
97
- if (file.path) TwoslashManager.getInstance().setCurrentFile(file.path);
89
+ if (file.path) setTwoslashFile(file.path);
98
90
  const jsxComponentNames = /* @__PURE__ */ new Set([
99
91
  "ApiSignature",
100
92
  "ApiMember",
@@ -112,9 +104,9 @@ const remarkApiCodeblocks = () => {
112
104
  }
113
105
  const vfsConfig = VfsRegistry.get(apiScopeValue);
114
106
  if (!vfsConfig) {
115
- emitEvent(PluginEvent.ConfigCascadeWarning({
107
+ emitSync(PluginEvent.ConfigCascadeWarning({
116
108
  ctx: {
117
- buildId: currentBuildId,
109
+ buildId: syncBuildId(),
118
110
  file: currentFilePath
119
111
  },
120
112
  field: "vfs",
@@ -140,11 +132,11 @@ const remarkApiCodeblocks = () => {
140
132
  const renderMs = performance.now() - shikiStart;
141
133
  let hast = await hastPromise;
142
134
  const postStart = performance.now();
143
- if (hast && vfsConfig.crossLinker) hast = vfsConfig.crossLinker.transformHast(hast, apiScopeValue);
135
+ if (hast && vfsConfig.crossLinker) hast = vfsConfig.crossLinker.transformHast(hast);
144
136
  const totalBlockTime = renderMs + (performance.now() - postStart);
145
- emitEvent(PluginEvent.CodeBlockProcessed({
137
+ emitSync(PluginEvent.CodeBlockProcessed({
146
138
  ctx: {
147
- buildId: currentBuildId,
139
+ buildId: syncBuildId(),
148
140
  apiScope: apiScopeValue,
149
141
  file: currentFilePath
150
142
  },
@@ -154,7 +146,7 @@ const remarkApiCodeblocks = () => {
154
146
  twoslashMs,
155
147
  shikiMs: Math.max(0, renderMs - twoslashMs),
156
148
  totalMs: totalBlockTime,
157
- slow: totalBlockTime > currentSlowCodeBlockMs,
149
+ slow: totalBlockTime > syncSlowCodeBlockMs(),
158
150
  level: "debug"
159
151
  }));
160
152
  const hastBase64 = hast ? Buffer.from(JSON.stringify(hast), "utf-8").toString("base64") : "";
@@ -172,4 +164,4 @@ const remarkApiCodeblocks = () => {
172
164
  };
173
165
 
174
166
  //#endregion
175
- export { remarkApiCodeblocks, setRemarkApiCodeblocksEventEmitter };
167
+ export { remarkApiCodeblocks };
@@ -1,22 +1,15 @@
1
1
  import { PluginEvent } from "./observability/events.js";
2
+ import { emitSync, syncBuildId, syncSlowCodeBlockMs } from "./observability/sync-emitter.js";
2
3
  import { formatCode } from "./prettier-formatter.js";
3
4
  import { stripTwoslashDirectives } from "./markdown/helpers.js";
4
- import { TwoslashManager } from "./twoslash-transformer.js";
5
5
  import { DEFAULT_SHIKI_THEMES } from "./markdown/shiki-utils.js";
6
+ import { VfsRegistry } from "./vfs-registry.js";
7
+ import { setTwoslashFile } from "./twoslash-access.js";
6
8
  import { createTwoslashTimingWrapper } from "./twoslash-timing-wrapper.js";
7
9
  import { codeToHast, hastToHtml } from "shiki";
8
10
  import { visit } from "unist-util-visit";
9
11
 
10
12
  //#region src/remark-with-api.ts
11
- /** Module-level emitter injected by plugin.ts at startup. */
12
- let emitEvent = () => {};
13
- let currentBuildId = "";
14
- let currentSlowCodeBlockMs = 500;
15
- function setRemarkWithApiEventEmitter(fn, buildId = "", slowCodeBlockMs = 500) {
16
- emitEvent = fn;
17
- currentBuildId = buildId;
18
- currentSlowCodeBlockMs = slowCodeBlockMs;
19
- }
20
13
  /**
21
14
  * Supported languages for with-api code blocks
22
15
  * Based on GitHub Linguist standard aliases:
@@ -59,18 +52,14 @@ function inferApiScope(filePath) {
59
52
  * 5. Renders to ApiExample component with pre-rendered Shiki HAST
60
53
  */
61
54
  const remarkWithApi = (options) => {
62
- const { shikiCrossLinker, getTransformer, theme } = options;
55
+ const { getTransformer, theme } = options;
63
56
  const resolvedTheme = theme ?? DEFAULT_SHIKI_THEMES;
64
57
  return async function remarkTransformer(tree, file) {
65
58
  const promises = [];
66
59
  let needsApiExampleImport = false;
67
60
  const isSsgMd = import.meta.env?.SSG_MD || process.env.RSBUILD_ENVIRONMENT === "node_md" || process.env.BUILD_TARGET === "node_md";
68
61
  const currentFilePath = file.path;
69
- if (currentFilePath) {
70
- const apiScope = inferApiScope(currentFilePath);
71
- if (apiScope) shikiCrossLinker.setApiScope(apiScope);
72
- TwoslashManager.getInstance().setCurrentFile(currentFilePath);
73
- }
62
+ if (currentFilePath) setTwoslashFile(currentFilePath);
74
63
  visit(tree, "code", (node, index, parent) => {
75
64
  const hasWithApi = node.meta?.includes("with-api");
76
65
  const lang = node.lang || "typescript";
@@ -99,12 +88,13 @@ const remarkWithApi = (options) => {
99
88
  transformers
100
89
  });
101
90
  const renderMs = performance.now() - shikiStart;
102
- if (apiScope) hast = shikiCrossLinker.transformHast(hast, apiScope);
91
+ const scopeLinker = apiScope ? VfsRegistry.get(apiScope)?.crossLinker : void 0;
92
+ if (scopeLinker) hast = scopeLinker.transformHast(hast);
103
93
  const totalBlockTime = performance.now() - blockStart;
104
- const isSlow = totalBlockTime > currentSlowCodeBlockMs;
105
- emitEvent(PluginEvent.CodeBlockProcessed({
94
+ const isSlow = totalBlockTime > syncSlowCodeBlockMs();
95
+ emitSync(PluginEvent.CodeBlockProcessed({
106
96
  ctx: {
107
- buildId: currentBuildId,
97
+ buildId: syncBuildId(),
108
98
  ...apiScope != null ? { apiScope } : {},
109
99
  ...currentFilePath != null ? { file: currentFilePath } : {}
110
100
  },
@@ -191,4 +181,4 @@ const remarkWithApi = (options) => {
191
181
  };
192
182
 
193
183
  //#endregion
194
- export { remarkWithApi, setRemarkWithApiEventEmitter };
184
+ export { remarkWithApi };
package/schemas/config.js CHANGED
@@ -304,8 +304,6 @@ const PluginOptions = Schema.Struct({
304
304
  api: Schema.optional(Schema.NullOr(SingleApiConfig)),
305
305
  /** Multi-API portal configuration (mutually exclusive with `api`). `null` or `[]` disables generation. */
306
306
  apis: Schema.optional(Schema.NullOr(Schema.mutable(Schema.Array(MultiApiConfig)))),
307
- /** Canonical site URL used for Open Graph absolute URLs. */
308
- siteUrl: Schema.optional(Schema.String),
309
307
  /** Global Open Graph image configuration (overridden per-API). */
310
308
  ogImage: Schema.optional(OpenGraphImageConfig),
311
309
  /** Override the default category definitions for all APIs. */
@@ -1,7 +1,42 @@
1
- import { Context } from "effect";
1
+ import { makeConfigService } from "../layers/config-resolution.js";
2
+ import { Context, Effect, Layer } from "effect";
2
3
 
3
4
  //#region src/services/ConfigService.ts
4
- var ConfigService = class extends Context.Service()("rspress-plugin-api-extractor/ConfigService") {};
5
+ var ConfigService = class ConfigService extends Context.Service()("rspress-plugin-api-extractor/ConfigService") {
6
+ /**
7
+ * Config resolution over the plugin options and the RSPress config.
8
+ *
9
+ * @remarks
10
+ * A plain `const`, not a factory. It used to take the plugin options as an
11
+ * argument, which made it a layer-RETURNING function: layers memoize by
12
+ * reference, so a second call would build a second `ConfigService` with its
13
+ * own captured `TypeRegistry`. The options come from {@link PluginConfig}
14
+ * now, so there is nothing to pass and "call it twice" is a type error
15
+ * rather than a test case.
16
+ *
17
+ * `Effect.suspend` because {@link makeConfigService} is imported from a
18
+ * module this one is also imported BY: a static initializer runs while the
19
+ * module body is still evaluating, so reading the binding eagerly can throw
20
+ * at import time with a completely clean typecheck.
21
+ */
22
+ static layer = Layer.effect(this, Effect.suspend(() => makeConfigService));
23
+ /**
24
+ * An in-memory double whose unstubbed member dies naming itself.
25
+ *
26
+ * @remarks
27
+ * **No default `resolve`.** Returning an empty array by default would be a
28
+ * silent "this site documents nothing" — the exact state an inert plugin
29
+ * produces — so a test that forgot to stub it would assert against a build
30
+ * that generated no pages and pass. Stub it explicitly, or provide
31
+ * {@link ConfigService.layer} over real inputs.
32
+ */
33
+ static makeTest = (overrides = {}) => ({ resolve: overrides.resolve ?? (() => unstubbed("resolve")) });
34
+ /** {@link ConfigService.makeTest} behind a `Layer`. */
35
+ static layerTest = (overrides = {}) => Layer.succeed(ConfigService, ConfigService.makeTest(overrides));
36
+ };
37
+ const unstubbed = (member) => {
38
+ throw new Error(`ConfigService.makeTest: ${member}() was called but not stubbed — pass an override.`);
39
+ };
5
40
 
6
41
  //#endregion
7
42
  export { ConfigService };
@@ -0,0 +1,75 @@
1
+ import { PluginEvent } from "../observability/events.js";
2
+ import { emit } from "../observability/EventBus.js";
3
+ import { SHIKI_LANGS } from "../markdown/shiki-utils.js";
4
+ import { Context, Effect, Layer } from "effect";
5
+ import { createHighlighter } from "shiki";
6
+
7
+ //#region src/services/HighlighterService.ts
8
+ /**
9
+ * The build's single Shiki highlighter.
10
+ *
11
+ * @remarks
12
+ * A highlighter owns a WASM oniguruma instance and every loaded grammar and
13
+ * theme, and it has a `dispose()` nobody was calling: `ConfigService.layer`
14
+ * created one per `resolve()`, so a dev-mode HMR session leaked one per
15
+ * rebuild. The test run reported it as
16
+ * `[Shiki] 10 instances have been created` — a console leak, not a failure.
17
+ *
18
+ * The fix is NOT to scope it to `resolve()`. `VfsRegistry` hands this
19
+ * highlighter to the remark plugins, which RSPress invokes during its render
20
+ * pass — after `config()` has returned and after any `resolve()`-scoped scope
21
+ * would have closed. The lifetime that matches is the `ManagedRuntime`'s:
22
+ * acquired when the layer builds, released by `effectRuntime.dispose()` in
23
+ * `afterBuild` on production builds, and deliberately kept alive in dev so HMR
24
+ * rebuilds reuse it.
25
+ *
26
+ * Getting that wrong is silent — a disposed highlighter does not throw, the
27
+ * code blocks just render as unhighlighted `<pre>`.
28
+ *
29
+ * @packageDocumentation
30
+ */
31
+ var HighlighterService = class extends Context.Service()("rspress-plugin-api-extractor/HighlighterService") {
32
+ /**
33
+ * Acquire the build's highlighter, and release it when the runtime is
34
+ * disposed.
35
+ *
36
+ * @remarks
37
+ * `Layer.effect` over `Effect.acquireRelease` is the v4 scoped-constructor
38
+ * idiom (`Layer.scoped` is gone; `Layer.effect` strips `Scope` from `R`).
39
+ * Because the layer sits in the `ManagedRuntime`'s stack, the highlighter is
40
+ * created on the runtime's first use and `dispose()`d by
41
+ * `effectRuntime.dispose()` — which `plugin.ts` calls on production builds
42
+ * only, so a dev HMR session keeps one highlighter across rebuilds instead of
43
+ * leaking one per rebuild.
44
+ *
45
+ * **Bind the result to a `const`.** This is a layer FACTORY: each call mints a
46
+ * fresh layer reference, and layers memoize by reference, so calling it twice
47
+ * in one graph acquires two highlighters — the exact leak this layer exists to
48
+ * fix.
49
+ *
50
+ * The themes are passed in rather than read from a resolved build context
51
+ * because the layer builds before `ConfigService.resolve()` runs. Passing them
52
+ * as an argument rather than through a `Context.Reference` is deliberate: a
53
+ * Reference carries a default, so forgetting to wire it would silently load
54
+ * only the default themes and render every custom-themed block wrong. A
55
+ * missing argument is a type error.
56
+ */
57
+ static layer = (themes) => Layer.effect(this, make(themes));
58
+ };
59
+ const make = (themes) => Effect.gen(function* () {
60
+ const startedMs = performance.now();
61
+ const highlighter = yield* Effect.acquireRelease(Effect.promise(() => createHighlighter({
62
+ themes: [...themes],
63
+ langs: [...SHIKI_LANGS]
64
+ })), (instance) => Effect.sync(() => instance.dispose()));
65
+ yield* emit(PluginEvent.PhaseCompleted({
66
+ ctx: {},
67
+ level: "debug",
68
+ phase: "shikiInit",
69
+ durationMs: Math.round(performance.now() - startedMs)
70
+ }));
71
+ return { highlighter };
72
+ });
73
+
74
+ //#endregion
75
+ export { HighlighterService };