rspress-plugin-api-extractor 0.10.0 → 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.
@@ -1,20 +1,12 @@
1
1
  import { PluginEvent } from "./observability/events.js";
2
- import { TwoslashManager } from "./twoslash-transformer.js";
2
+ import { emitSync, syncBuildId, syncSlowCodeBlockMs } from "./observability/sync-emitter.js";
3
3
  import { VfsRegistry } from "./vfs-registry.js";
4
4
  import { generateShikiHast } from "./markdown/shiki-utils.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
+ import { VfsRegistry } from "./vfs-registry.js";
5
6
  import { DEFAULT_SHIKI_THEMES } from "./markdown/shiki-utils.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 };
@@ -0,0 +1,30 @@
1
+ import { Context } from "effect";
2
+
3
+ //#region src/services/HighlighterService.ts
4
+ /**
5
+ * The build's single Shiki highlighter.
6
+ *
7
+ * @remarks
8
+ * A highlighter owns a WASM oniguruma instance and every loaded grammar and
9
+ * theme, and it has a `dispose()` nobody was calling: `ConfigServiceLive`
10
+ * created one per `resolve()`, so a dev-mode HMR session leaked one per
11
+ * rebuild. The test run reported it as
12
+ * `[Shiki] 10 instances have been created` — a console leak, not a failure.
13
+ *
14
+ * The fix is NOT to scope it to `resolve()`. `VfsRegistry` hands this
15
+ * highlighter to the remark plugins, which RSPress invokes during its render
16
+ * pass — after `config()` has returned and after any `resolve()`-scoped scope
17
+ * would have closed. The lifetime that matches is the `ManagedRuntime`'s:
18
+ * acquired when the layer builds, released by `effectRuntime.dispose()` in
19
+ * `afterBuild` on production builds, and deliberately kept alive in dev so HMR
20
+ * rebuilds reuse it.
21
+ *
22
+ * Getting that wrong is silent — a disposed highlighter does not throw, the
23
+ * code blocks just render as unhighlighted `<pre>`.
24
+ *
25
+ * @packageDocumentation
26
+ */
27
+ var HighlighterService = class extends Context.Service()("rspress-plugin-api-extractor/HighlighterService") {};
28
+
29
+ //#endregion
30
+ export { HighlighterService };
@@ -0,0 +1,23 @@
1
+ import { Context, Data } from "effect";
2
+
3
+ //#region src/services/OgService.ts
4
+ const OgImageErrorBase = Data.TaggedError("OgImageError");
5
+ /**
6
+ * A configured OG image that could not be resolved.
7
+ *
8
+ * @remarks
9
+ * `cause` carries the original failure (an `image-size` parse error, a
10
+ * filesystem error) rather than a stringified copy of it.
11
+ */
12
+ var OgImageError = class extends OgImageErrorBase {
13
+ get message() {
14
+ if (this.code === "invalid-url") return `Invalid Open Graph image URL in '${this.field}': ${this.value} — expected an absolute http(s) URL or a path starting with '/'`;
15
+ if (this.code === "invalid-secure-url") return `Invalid Open Graph secure URL in '${this.field}': ${this.value} — secureUrl must be an absolute https URL`;
16
+ const cause = this.cause instanceof Error ? this.cause.message : String(this.cause);
17
+ return `Could not read Open Graph image '${this.value}': ${cause}`;
18
+ }
19
+ };
20
+ var OgService = class extends Context.Service()("rspress-plugin-api-extractor/OgService") {};
21
+
22
+ //#endregion
23
+ export { OgImageError, OgService };
@@ -0,0 +1,26 @@
1
+ import { Context } from "effect";
2
+
3
+ //#region src/services/PluginConfig.ts
4
+ /**
5
+ * The decoded plugin options, as a service.
6
+ *
7
+ * @remarks
8
+ * `ConfigServiceLive` used to be a factory taking these as an argument, which
9
+ * made it a layer-returning function — the shape the house rules warn about,
10
+ * since layers memoize by reference and a second call mints a second layer.
11
+ * It was only ever called once, but "only ever called once" is a property of
12
+ * the current call sites, not of the design.
13
+ *
14
+ * A `Context.Service` rather than a `Context.Reference`, deliberately, and for
15
+ * the same reason the Shiki themes are a layer argument: a Reference carries a
16
+ * default, so a wiring mistake would silently resolve to empty options and the
17
+ * build would document nothing while reporting success. There is no sensible
18
+ * default for "which APIs is this site documenting", so forgetting to provide
19
+ * it should be a loud "service not provided", which is what this gives.
20
+ *
21
+ * @packageDocumentation
22
+ */
23
+ var PluginConfig = class extends Context.Service()("rspress-plugin-api-extractor/PluginConfig") {};
24
+
25
+ //#endregion
26
+ export { PluginConfig };
@@ -0,0 +1,7 @@
1
+ import { Context } from "effect";
2
+
3
+ //#region src/services/TwoslashEnvironments.ts
4
+ var TwoslashEnvironments = class extends Context.Service()("rspress-plugin-api-extractor/TwoslashEnvironments") {};
5
+
6
+ //#endregion
7
+ export { TwoslashEnvironments };
@@ -43,100 +43,53 @@
43
43
  * @see the `@tsdoctor/model` CrossLinker for the markdown equivalent
44
44
  * @see {@link TwoslashManager} for type-aware documentation features
45
45
  */
46
- var ShikiCrossLinker = class {
47
- /** Map of API scopes to their route maps (API item name to route) */
48
- apiItemRoutesByScope = /* @__PURE__ */ new Map();
49
- /** Map of API scopes to their kind maps (API item name to kind) */
50
- apiItemKindsByScope = /* @__PURE__ */ new Map();
51
- /** Map of API scopes to their class members maps (class name to member names) */
52
- classMembersMapByScope = /* @__PURE__ */ new Map();
53
- /**
54
- * Current API scope being processed (e.g., "claude-binary-plugin")
55
- */
56
- currentApiScope = null;
57
- /**
58
- * Creates a new ShikiCrossLinker instance. Call reinitialize() with routes, kinds,
59
- * and API scope before using the transformer.
60
- */
61
- constructor(routes, kinds, apiScope) {
62
- if (routes && kinds && apiScope) this.reinitialize(routes, kinds, apiScope);
46
+ var ShikiCrossLinker = class ShikiCrossLinker {
47
+ /** API item name to route, for THIS scope. */
48
+ apiItemRoutes;
49
+ /** API item name to kind (Class, Interface, …), for THIS scope. */
50
+ apiItemKinds;
51
+ /** Parent name to its member names, longest first, for THIS scope. */
52
+ classMembersMap;
53
+ /** The scope this linker links for. Read-only after construction. */
54
+ apiScope;
55
+ constructor(apiScope, apiItemRoutes, apiItemKinds, classMembersMap) {
56
+ this.apiScope = apiScope;
57
+ this.apiItemRoutes = apiItemRoutes;
58
+ this.apiItemKinds = apiItemKinds;
59
+ this.classMembersMap = classMembersMap;
63
60
  }
64
61
  /**
65
- * Initialize or reinitialize the cross-link maps with new data for a specific API scope.
66
- * This allows the same transformer instance to be used across multiple API packages,
67
- * with each API's routes stored separately and scoped to prevent cross-API linking.
62
+ * One linker for one API's routes.
63
+ *
64
+ * @remarks
65
+ * Mirrors `@tsdoctor/model`'s `CrossLinker.fromRoutes`, deliberately: the
66
+ * two halves of cross-linking — prose and code blocks — are built the same
67
+ * way and both are immutable per build.
68
68
  *
69
- * @param routes - Map of API item names to their documentation routes
70
- * @param kinds - Map of API item names to their kinds (Class, Interface, etc.)
71
- * @param apiScope - The API scope identifier (e.g., "claude-binary-plugin", "rslib-builder")
69
+ * This replaces a single long-lived instance created at plugin-factory time,
70
+ * threaded through `ConfigServiceLive`'s constructor and the build context,
71
+ * and mutated per API by `reinitialize()`. Scope isolation used to be a
72
+ * property of internal `…ByScope` maps plus a mutable `currentApiScope` that
73
+ * any caller could reassign between a lookup and a render; it is now a
74
+ * property of the instance, which cannot be pointed at another package's
75
+ * routes at all.
72
76
  */
73
- reinitialize(routes, kinds, apiScope) {
74
- this.apiItemRoutesByScope.set(apiScope, new Map(routes));
75
- this.apiItemKindsByScope.set(apiScope, new Map(kinds));
77
+ static fromRoutes(routes, kinds, apiScope) {
76
78
  const classMembersMap = /* @__PURE__ */ new Map();
77
- for (const [name] of routes.entries()) if (name.includes(".")) {
79
+ for (const [name] of routes.entries()) {
78
80
  const dotIndex = name.indexOf(".");
81
+ if (dotIndex === -1) continue;
79
82
  const className = name.substring(0, dotIndex);
80
83
  const memberName = name.substring(dotIndex + 1);
81
- if (!classMembersMap.has(className)) classMembersMap.set(className, []);
82
84
  const members = classMembersMap.get(className);
83
- if (members && !members.includes(memberName)) members.push(memberName);
85
+ if (members === void 0) classMembersMap.set(className, [memberName]);
86
+ else if (!members.includes(memberName)) members.push(memberName);
84
87
  }
85
88
  for (const members of classMembersMap.values()) members.sort((a, b) => b.length - a.length);
86
- this.classMembersMapByScope.set(apiScope, classMembersMap);
87
- this.currentApiScope = apiScope;
88
- }
89
- /**
90
- * Set the current API scope for cross-linking.
91
- * This should be called before rendering each file to ensure links are scoped correctly.
92
- *
93
- * @param apiScope - The API scope identifier (e.g., "claude-binary-plugin")
94
- */
95
- setApiScope(apiScope) {
96
- this.currentApiScope = apiScope;
97
- }
98
- /**
99
- * Get the routes map for the current API scope
100
- */
101
- getRoutesForCurrentScope() {
102
- if (!this.currentApiScope) return /* @__PURE__ */ new Map();
103
- return this.apiItemRoutesByScope.get(this.currentApiScope) || /* @__PURE__ */ new Map();
104
- }
105
- /**
106
- * Get the kinds map for the current API scope
107
- */
108
- getKindsForCurrentScope() {
109
- if (!this.currentApiScope) return /* @__PURE__ */ new Map();
110
- return this.apiItemKindsByScope.get(this.currentApiScope) || /* @__PURE__ */ new Map();
111
- }
112
- /**
113
- * Get the class members map for the current API scope
114
- */
115
- getClassMembersForCurrentScope() {
116
- if (!this.currentApiScope) return /* @__PURE__ */ new Map();
117
- return this.classMembersMapByScope.get(this.currentApiScope) || /* @__PURE__ */ new Map();
118
- }
119
- /**
120
- * Get the routes map for a specific API scope
121
- */
122
- getRoutesForScope(scope) {
123
- if (!scope) return /* @__PURE__ */ new Map();
124
- return this.apiItemRoutesByScope.get(scope) || /* @__PURE__ */ new Map();
125
- }
126
- /**
127
- * Get the kinds map for a specific API scope
128
- */
129
- getKindsForScope(scope) {
130
- if (!scope) return /* @__PURE__ */ new Map();
131
- return this.apiItemKindsByScope.get(scope) || /* @__PURE__ */ new Map();
132
- }
133
- /**
134
- * Get the class members map for a specific API scope
135
- */
136
- getClassMembersForScope(scope) {
137
- if (!scope) return /* @__PURE__ */ new Map();
138
- return this.classMembersMapByScope.get(scope) || /* @__PURE__ */ new Map();
89
+ return new ShikiCrossLinker(apiScope, new Map(routes), new Map(kinds), classMembersMap);
139
90
  }
91
+ /** A linker that links nothing — for a scope with no documented routes. */
92
+ static empty = new ShikiCrossLinker("", /* @__PURE__ */ new Map(), /* @__PURE__ */ new Map(), /* @__PURE__ */ new Map());
140
93
  /**
141
94
  * Transform a finalized HAST tree to add cross-links to type references.
142
95
  *
@@ -145,162 +98,28 @@ var ShikiCrossLinker = class {
145
98
  * positions before we add anchor links.
146
99
  *
147
100
  * @param hast - The finalized HAST root node from Shiki
148
- * @param apiScope - Optional API scope to use for lookups. If not provided, uses currentApiScope.
149
101
  * @returns The transformed HAST with cross-links added
150
102
  *
103
+ * @remarks
104
+ * There is no scope parameter. A linker IS a scope — the caller picks the
105
+ * right instance (from the `VfsRegistry` entry for the page's scope) rather
106
+ * than picking the right argument. The parameter it replaces was optional
107
+ * and fell back to a mutable `currentApiScope`, so omitting it linked
108
+ * against whichever package happened to render last.
109
+ *
151
110
  * @example
152
111
  * ```ts
153
112
  * const hast = await highlighter.codeToHast(code, { transformers: [twoslashTransformer] });
154
- * const linkedHast = crossLinker.transformHast(hast, "my-api");
113
+ * const linkedHast = crossLinker.transformHast(hast);
155
114
  * ```
156
115
  */
157
- transformHast(hast, apiScope) {
158
- const effectiveScope = apiScope ?? this.currentApiScope;
159
- return this.transformRootWithScope(hast, effectiveScope);
116
+ transformHast(hast) {
117
+ return this.transformRoot(hast);
160
118
  }
161
- /**
162
- * Transform the root node of the syntax tree with explicit scope
163
- */
164
- transformRootWithScope(node, scope) {
165
- const apiItemRoutes = this.getRoutesForScope(scope);
166
- const apiItemKinds = this.getKindsForScope(scope);
167
- const classMembersMap = this.getClassMembersForScope(scope);
168
- const scopeStack = [];
169
- const preElement = node.children.find((child) => child.type === "element" && child.tagName === "pre");
170
- if (preElement?.type !== "element") return node;
171
- const codeElement = preElement.children.find((child) => child.type === "element" && child.tagName === "code");
172
- if (codeElement?.type !== "element") return node;
173
- for (const lineElement of codeElement.children) {
174
- if (lineElement.type !== "element" || lineElement.tagName !== "span") continue;
175
- const getText = (node) => {
176
- if (node.type === "text") return node.value;
177
- if (node.type === "element") return node.children.map(getText).join("");
178
- return "";
179
- };
180
- const lineText = lineElement.children.map(getText).join("");
181
- const currentScope = scopeStack.length > 0 ? scopeStack[scopeStack.length - 1] : null;
182
- if (currentScope) {
183
- const members = classMembersMap.get(currentScope);
184
- if (members) for (const spanElement of lineElement.children) {
185
- if (spanElement.type !== "element" || spanElement.tagName !== "span") continue;
186
- if (spanElement.children?.length !== 1) continue;
187
- const textNode = spanElement.children[0];
188
- if (textNode.type !== "text") continue;
189
- const rawContent = textNode.value;
190
- const content = rawContent.trim();
191
- if (!content) continue;
192
- if (members.includes(content)) {
193
- const fullMemberName = `${currentScope}.${content}`;
194
- const memberRoute = apiItemRoutes.get(fullMemberName);
195
- if (memberRoute) {
196
- const memberKind = apiItemKinds.get(fullMemberName);
197
- const memberSemanticClass = memberKind ? this.getSemanticClass(memberKind) : null;
198
- const leadingSpace = rawContent.match(/^\s*/)?.[0] || "";
199
- const trailingSpace = rawContent.match(/\s*$/)?.[0] || "";
200
- const classNames = ["api-type-link"];
201
- if (memberSemanticClass) classNames.push(memberSemanticClass);
202
- const newChildren = [];
203
- if (leadingSpace) newChildren.push({
204
- type: "text",
205
- value: leadingSpace
206
- });
207
- newChildren.push({
208
- type: "element",
209
- tagName: "a",
210
- properties: {
211
- href: memberRoute,
212
- class: classNames.join(" ")
213
- },
214
- children: [{
215
- type: "text",
216
- value: content
217
- }]
218
- });
219
- if (trailingSpace) newChildren.push({
220
- type: "text",
221
- value: trailingSpace
222
- });
223
- spanElement.children = newChildren;
224
- spanElement.properties = {
225
- ...spanElement.properties,
226
- "data-api-processed": "true"
227
- };
228
- }
229
- }
230
- }
231
- }
232
- const classMatch = lineText.match(/(?:class|interface|namespace)\s+(\w+)\s*(?:<[^>]*>)?\s*(?:extends|implements)?[^{]*\{/);
233
- if (classMatch) {
234
- if ((lineText.match(/\{/g) || []).length > (lineText.match(/\}/g) || []).length) scopeStack.push(classMatch[1]);
235
- }
236
- const openBraces = (lineText.match(/\{/g) || []).length;
237
- const excessCloses = (lineText.match(/\}/g) || []).length - openBraces;
238
- for (let i = 0; i < excessCloses && scopeStack.length > 0; i++) scopeStack.pop();
239
- }
240
- const findTwoslashSpans = (element) => {
241
- const results = [];
242
- if (element.properties?.class && String(element.properties.class).includes("twoslash-hover")) results.push(element);
243
- if (element.children) {
244
- for (const child of element.children) if (child.type === "element") results.push(...findTwoslashSpans(child));
245
- }
246
- return results;
247
- };
248
- const twoslashSpans = findTwoslashSpans(codeElement);
249
- for (const twoslashSpan of twoslashSpans) {
250
- if (twoslashSpan.properties?.["data-api-processed"] === "true") continue;
251
- const methodInfo = this.extractMethodInfoFromTwoslashTooltip(twoslashSpan);
252
- if (!methodInfo) continue;
253
- const { className, methodName } = methodInfo;
254
- const fullMemberName = `${className}.${methodName}`;
255
- const memberRoute = apiItemRoutes.get(fullMemberName);
256
- if (!memberRoute) continue;
257
- const memberKind = apiItemKinds.get(fullMemberName);
258
- const memberSemanticClass = memberKind ? this.getSemanticClass(memberKind) : null;
259
- const memberClassNames = ["api-type-link"];
260
- if (memberSemanticClass) memberClassNames.push(memberSemanticClass);
261
- const textContent = this.extractTextFromTwoslash(twoslashSpan);
262
- if (!textContent) continue;
263
- this.wrapTwoslashTextInAnchor(twoslashSpan, textContent.trim(), memberRoute, memberClassNames);
264
- twoslashSpan.properties = {
265
- ...twoslashSpan.properties,
266
- "data-api-processed": "true"
267
- };
268
- }
269
- const typeNames = Array.from(apiItemRoutes.keys()).filter((name) => !name.includes(".")).sort((a, b) => b.length - a.length);
270
- if (typeNames.length > 0) {
271
- for (const twoslashSpan of twoslashSpans) {
272
- if (twoslashSpan.properties?.["data-api-processed"] === "true") continue;
273
- const text = this.extractTextFromTwoslash(twoslashSpan);
274
- if (!text) continue;
275
- const content = text.trim();
276
- const route = apiItemRoutes.get(content);
277
- if (!route) continue;
278
- const kind = apiItemKinds.get(content);
279
- const semanticClass = kind ? this.getSemanticClass(kind) : null;
280
- const classNames = ["api-type-link"];
281
- if (semanticClass) classNames.push(semanticClass);
282
- this.wrapTwoslashTextInAnchor(twoslashSpan, content, route, classNames);
283
- twoslashSpan.properties = {
284
- ...twoslashSpan.properties,
285
- "data-api-processed": "true"
286
- };
287
- }
288
- const escapedNames = typeNames.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
289
- const typePattern = new RegExp(`\\b(${escapedNames.join("|")})\\b`, "g");
290
- for (const lineElement of codeElement.children) {
291
- if (lineElement.type !== "element" || lineElement.tagName !== "span") continue;
292
- this.linkTypeReferencesInLine(lineElement, typePattern, apiItemRoutes, apiItemKinds);
293
- }
294
- }
295
- return node;
296
- }
297
- /**
298
- * Transform the root node of the syntax tree
299
- */
300
119
  transformRoot(node) {
301
- const apiItemRoutes = this.getRoutesForCurrentScope();
302
- const apiItemKinds = this.getKindsForCurrentScope();
303
- const classMembersMap = this.getClassMembersForCurrentScope();
120
+ const apiItemRoutes = this.apiItemRoutes;
121
+ const apiItemKinds = this.apiItemKinds;
122
+ const classMembersMap = this.classMembersMap;
304
123
  const scopeStack = [];
305
124
  const preElement = node.children.find((child) => child.type === "element" && child.tagName === "pre");
306
125
  if (preElement?.type !== "element") return node;
@@ -434,9 +253,9 @@ var ShikiCrossLinker = class {
434
253
  * Transform a line element
435
254
  */
436
255
  transformLine(node) {
437
- const apiItemRoutes = this.getRoutesForCurrentScope();
438
- const apiItemKinds = this.getKindsForCurrentScope();
439
- const classMembersMap = this.getClassMembersForCurrentScope();
256
+ const apiItemRoutes = this.apiItemRoutes;
257
+ const apiItemKinds = this.apiItemKinds;
258
+ const classMembersMap = this.classMembersMap;
440
259
  if (!node.children) return;
441
260
  for (let i = 0; i < node.children.length; i++) {
442
261
  const child = node.children[i];
@@ -521,8 +340,8 @@ var ShikiCrossLinker = class {
521
340
  * Transform a span element
522
341
  */
523
342
  transformSpan(node, _line, _col) {
524
- const apiItemRoutes = this.getRoutesForCurrentScope();
525
- const apiItemKinds = this.getKindsForCurrentScope();
343
+ const apiItemRoutes = this.apiItemRoutes;
344
+ const apiItemKinds = this.apiItemKinds;
526
345
  if (node.properties?.["data-api-processed"] === "true") return;
527
346
  const firstChild = node.children?.[0];
528
347
  if (firstChild && firstChild.type === "element" && firstChild.tagName === "a") return;
@@ -0,0 +1,48 @@
1
+ //#region src/twoslash-access.ts
2
+ /** The uninstalled state: no environments, so nothing to hand out. */
3
+ const NOT_INSTALLED = {
4
+ transformerFor: () => null,
5
+ setCurrentFile: () => {}
6
+ };
7
+ let current = NOT_INSTALLED;
8
+ /**
9
+ * Bind the render pass to this build's environments.
10
+ *
11
+ * @remarks
12
+ * Called from `plugin.ts`'s Effect program, beside the other seam wiring, and
13
+ * NOT from `ConfigServiceLive` — config resolution should compute a value, not
14
+ * also mutate module state on the side.
15
+ */
16
+ function installTwoslashAccess(environments) {
17
+ current = environments;
18
+ }
19
+ /**
20
+ * Reset to the uninstalled state.
21
+ *
22
+ * @remarks
23
+ * Called at the start of every build, next to `VfsRegistry.clear()`, for the
24
+ * same reason that call exists: a dev HMR session reuses the process, so a
25
+ * holder from the previous build would otherwise outlive it and hand the
26
+ * render pass transformers built against declarations that have since changed.
27
+ */
28
+ function clearTwoslashAccess() {
29
+ current = NOT_INSTALLED;
30
+ }
31
+ /**
32
+ * The transformer for a scope, for the remark plugins.
33
+ *
34
+ * @returns `null` when nothing is installed — an inert build, which registers
35
+ * no environments at all. A `with-api` fence still renders; it just renders
36
+ * without type information, which is the honest answer when the build
37
+ * documents no API.
38
+ */
39
+ function twoslashTransformerFor(apiScope) {
40
+ return current.transformerFor(apiScope);
41
+ }
42
+ /** Attribute subsequent Twoslash diagnostics to a source file. */
43
+ function setTwoslashFile(path) {
44
+ current.setCurrentFile(path);
45
+ }
46
+
47
+ //#endregion
48
+ export { clearTwoslashAccess, installTwoslashAccess, setTwoslashFile, twoslashTransformerFor };