rspress-plugin-api-extractor 0.9.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BuildEnv.js +58 -0
- package/README.md +2 -1
- package/build-program.js +33 -30
- package/build-stages.js +47 -39
- package/errors.js +0 -1
- package/index.d.ts +22 -14
- package/layers/ConfigServiceLive.js +349 -400
- package/layers/HighlighterServiceLive.js +52 -0
- package/layers/ObservabilityLive.js +26 -7
- package/layers/OgServiceLive.js +134 -0
- package/layers/TwoslashCacheServiceLive.js +108 -0
- package/layers/TwoslashEnvironmentsLive.js +33 -0
- package/layers/TypeRegistryServiceLive.js +54 -47
- package/layers/build-metrics.js +32 -5
- package/layers/xdg.js +44 -0
- package/markdown/helpers.js +9 -55
- package/markdown/page-generators/class-page.js +8 -31
- package/markdown/page-generators/index-pages.js +6 -8
- package/markdown/page-generators/interface-page.js +7 -7
- package/markdown/shiki-utils.js +65 -10
- package/observability/EventBus.js +29 -9
- package/observability/heartbeat.js +1 -1
- package/observability/metric-report.js +124 -0
- package/observability/sinks/console-sink.js +6 -0
- package/observability/sinks/metrics-sink.js +64 -21
- package/observability/sinks/render-sink.js +86 -0
- package/observability/sinks/trace-sink.js +10 -17
- package/observability/spans.js +4 -2
- package/observability/sync-emitter.js +78 -0
- package/og-resolver.js +46 -287
- package/package.json +4 -5
- package/path-derivation.js +19 -1
- package/plugin.js +64 -52
- package/prettier-formatter.js +4 -10
- package/remark-api-codeblocks.js +33 -15
- package/remark-with-api.js +24 -27
- package/schemas/config.js +11 -7
- package/services/HighlighterService.js +30 -0
- package/services/OgService.js +23 -0
- package/services/PluginConfig.js +26 -0
- package/services/TwoslashCacheService.js +15 -0
- package/services/TwoslashEnvironments.js +7 -0
- package/shiki-transformer.js +55 -256
- package/twoslash-access.js +48 -0
- package/twoslash-cache.js +174 -0
- package/twoslash-patterns.js +1 -1
- package/twoslash-timing-wrapper.js +23 -0
- package/twoslash-transformer.js +153 -89
- package/vfs-registry.js +1 -31
- package/layers/PathDerivationServiceLive.js +0 -16
- package/runtime/components/MarkdownText/index.js +0 -34
- package/services/PathDerivationService.js +0 -7
package/shiki-transformer.js
CHANGED
|
@@ -35,128 +35,61 @@
|
|
|
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
|
-
* //
|
|
41
|
-
* const
|
|
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
|
|
48
44
|
* @see {@link TwoslashManager} for type-aware documentation features
|
|
49
45
|
*/
|
|
50
|
-
var ShikiCrossLinker = class {
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
*/
|
|
65
|
-
constructor(routes, kinds, apiScope) {
|
|
66
|
-
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;
|
|
67
60
|
}
|
|
68
61
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
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.
|
|
72
68
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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.
|
|
76
76
|
*/
|
|
77
|
-
|
|
78
|
-
this.apiItemRoutesByScope.set(apiScope, new Map(routes));
|
|
79
|
-
this.apiItemKindsByScope.set(apiScope, new Map(kinds));
|
|
77
|
+
static fromRoutes(routes, kinds, apiScope) {
|
|
80
78
|
const classMembersMap = /* @__PURE__ */ new Map();
|
|
81
|
-
for (const [name] of routes.entries())
|
|
79
|
+
for (const [name] of routes.entries()) {
|
|
82
80
|
const dotIndex = name.indexOf(".");
|
|
81
|
+
if (dotIndex === -1) continue;
|
|
83
82
|
const className = name.substring(0, dotIndex);
|
|
84
83
|
const memberName = name.substring(dotIndex + 1);
|
|
85
|
-
if (!classMembersMap.has(className)) classMembersMap.set(className, []);
|
|
86
84
|
const members = classMembersMap.get(className);
|
|
87
|
-
if (members
|
|
85
|
+
if (members === void 0) classMembersMap.set(className, [memberName]);
|
|
86
|
+
else if (!members.includes(memberName)) members.push(memberName);
|
|
88
87
|
}
|
|
89
88
|
for (const members of classMembersMap.values()) members.sort((a, b) => b.length - a.length);
|
|
90
|
-
|
|
91
|
-
this.currentApiScope = apiScope;
|
|
92
|
-
}
|
|
93
|
-
/**
|
|
94
|
-
* Set the current API scope for cross-linking.
|
|
95
|
-
* This should be called before rendering each file to ensure links are scoped correctly.
|
|
96
|
-
*
|
|
97
|
-
* @param apiScope - The API scope identifier (e.g., "claude-binary-plugin")
|
|
98
|
-
*/
|
|
99
|
-
setApiScope(apiScope) {
|
|
100
|
-
this.currentApiScope = apiScope;
|
|
101
|
-
}
|
|
102
|
-
/**
|
|
103
|
-
* Get the routes map for the current API scope
|
|
104
|
-
*/
|
|
105
|
-
getRoutesForCurrentScope() {
|
|
106
|
-
if (!this.currentApiScope) return /* @__PURE__ */ new Map();
|
|
107
|
-
return this.apiItemRoutesByScope.get(this.currentApiScope) || /* @__PURE__ */ new Map();
|
|
108
|
-
}
|
|
109
|
-
/**
|
|
110
|
-
* Get the kinds map for the current API scope
|
|
111
|
-
*/
|
|
112
|
-
getKindsForCurrentScope() {
|
|
113
|
-
if (!this.currentApiScope) return /* @__PURE__ */ new Map();
|
|
114
|
-
return this.apiItemKindsByScope.get(this.currentApiScope) || /* @__PURE__ */ new Map();
|
|
115
|
-
}
|
|
116
|
-
/**
|
|
117
|
-
* Get the class members map for the current API scope
|
|
118
|
-
*/
|
|
119
|
-
getClassMembersForCurrentScope() {
|
|
120
|
-
if (!this.currentApiScope) return /* @__PURE__ */ new Map();
|
|
121
|
-
return this.classMembersMapByScope.get(this.currentApiScope) || /* @__PURE__ */ new Map();
|
|
122
|
-
}
|
|
123
|
-
/**
|
|
124
|
-
* Get the routes map for a specific API scope
|
|
125
|
-
*/
|
|
126
|
-
getRoutesForScope(scope) {
|
|
127
|
-
if (!scope) return /* @__PURE__ */ new Map();
|
|
128
|
-
return this.apiItemRoutesByScope.get(scope) || /* @__PURE__ */ new Map();
|
|
129
|
-
}
|
|
130
|
-
/**
|
|
131
|
-
* Get the kinds map for a specific API scope
|
|
132
|
-
*/
|
|
133
|
-
getKindsForScope(scope) {
|
|
134
|
-
if (!scope) return /* @__PURE__ */ new Map();
|
|
135
|
-
return this.apiItemKindsByScope.get(scope) || /* @__PURE__ */ new Map();
|
|
136
|
-
}
|
|
137
|
-
/**
|
|
138
|
-
* Get the class members map for a specific API scope
|
|
139
|
-
*/
|
|
140
|
-
getClassMembersForScope(scope) {
|
|
141
|
-
if (!scope) return /* @__PURE__ */ new Map();
|
|
142
|
-
return this.classMembersMapByScope.get(scope) || /* @__PURE__ */ new Map();
|
|
143
|
-
}
|
|
144
|
-
/**
|
|
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" };
|
|
89
|
+
return new ShikiCrossLinker(apiScope, new Map(routes), new Map(kinds), classMembersMap);
|
|
159
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());
|
|
160
93
|
/**
|
|
161
94
|
* Transform a finalized HAST tree to add cross-links to type references.
|
|
162
95
|
*
|
|
@@ -165,162 +98,28 @@ var ShikiCrossLinker = class {
|
|
|
165
98
|
* positions before we add anchor links.
|
|
166
99
|
*
|
|
167
100
|
* @param hast - The finalized HAST root node from Shiki
|
|
168
|
-
* @param apiScope - Optional API scope to use for lookups. If not provided, uses currentApiScope.
|
|
169
101
|
* @returns The transformed HAST with cross-links added
|
|
170
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
|
+
*
|
|
171
110
|
* @example
|
|
172
111
|
* ```ts
|
|
173
112
|
* const hast = await highlighter.codeToHast(code, { transformers: [twoslashTransformer] });
|
|
174
|
-
* const linkedHast = crossLinker.transformHast(hast
|
|
113
|
+
* const linkedHast = crossLinker.transformHast(hast);
|
|
175
114
|
* ```
|
|
176
115
|
*/
|
|
177
|
-
transformHast(hast
|
|
178
|
-
|
|
179
|
-
return this.transformRootWithScope(hast, effectiveScope);
|
|
180
|
-
}
|
|
181
|
-
/**
|
|
182
|
-
* Transform the root node of the syntax tree with explicit scope
|
|
183
|
-
*/
|
|
184
|
-
transformRootWithScope(node, scope) {
|
|
185
|
-
const apiItemRoutes = this.getRoutesForScope(scope);
|
|
186
|
-
const apiItemKinds = this.getKindsForScope(scope);
|
|
187
|
-
const classMembersMap = this.getClassMembersForScope(scope);
|
|
188
|
-
const scopeStack = [];
|
|
189
|
-
const preElement = node.children.find((child) => child.type === "element" && child.tagName === "pre");
|
|
190
|
-
if (preElement?.type !== "element") return node;
|
|
191
|
-
const codeElement = preElement.children.find((child) => child.type === "element" && child.tagName === "code");
|
|
192
|
-
if (codeElement?.type !== "element") return node;
|
|
193
|
-
for (const lineElement of codeElement.children) {
|
|
194
|
-
if (lineElement.type !== "element" || lineElement.tagName !== "span") continue;
|
|
195
|
-
const getText = (node) => {
|
|
196
|
-
if (node.type === "text") return node.value;
|
|
197
|
-
if (node.type === "element") return node.children.map(getText).join("");
|
|
198
|
-
return "";
|
|
199
|
-
};
|
|
200
|
-
const lineText = lineElement.children.map(getText).join("");
|
|
201
|
-
const currentScope = scopeStack.length > 0 ? scopeStack[scopeStack.length - 1] : null;
|
|
202
|
-
if (currentScope) {
|
|
203
|
-
const members = classMembersMap.get(currentScope);
|
|
204
|
-
if (members) for (const spanElement of lineElement.children) {
|
|
205
|
-
if (spanElement.type !== "element" || spanElement.tagName !== "span") continue;
|
|
206
|
-
if (spanElement.children?.length !== 1) continue;
|
|
207
|
-
const textNode = spanElement.children[0];
|
|
208
|
-
if (textNode.type !== "text") continue;
|
|
209
|
-
const rawContent = textNode.value;
|
|
210
|
-
const content = rawContent.trim();
|
|
211
|
-
if (!content) continue;
|
|
212
|
-
if (members.includes(content)) {
|
|
213
|
-
const fullMemberName = `${currentScope}.${content}`;
|
|
214
|
-
const memberRoute = apiItemRoutes.get(fullMemberName);
|
|
215
|
-
if (memberRoute) {
|
|
216
|
-
const memberKind = apiItemKinds.get(fullMemberName);
|
|
217
|
-
const memberSemanticClass = memberKind ? this.getSemanticClass(memberKind) : null;
|
|
218
|
-
const leadingSpace = rawContent.match(/^\s*/)?.[0] || "";
|
|
219
|
-
const trailingSpace = rawContent.match(/\s*$/)?.[0] || "";
|
|
220
|
-
const classNames = ["api-type-link"];
|
|
221
|
-
if (memberSemanticClass) classNames.push(memberSemanticClass);
|
|
222
|
-
const newChildren = [];
|
|
223
|
-
if (leadingSpace) newChildren.push({
|
|
224
|
-
type: "text",
|
|
225
|
-
value: leadingSpace
|
|
226
|
-
});
|
|
227
|
-
newChildren.push({
|
|
228
|
-
type: "element",
|
|
229
|
-
tagName: "a",
|
|
230
|
-
properties: {
|
|
231
|
-
href: memberRoute,
|
|
232
|
-
class: classNames.join(" ")
|
|
233
|
-
},
|
|
234
|
-
children: [{
|
|
235
|
-
type: "text",
|
|
236
|
-
value: content
|
|
237
|
-
}]
|
|
238
|
-
});
|
|
239
|
-
if (trailingSpace) newChildren.push({
|
|
240
|
-
type: "text",
|
|
241
|
-
value: trailingSpace
|
|
242
|
-
});
|
|
243
|
-
spanElement.children = newChildren;
|
|
244
|
-
spanElement.properties = {
|
|
245
|
-
...spanElement.properties,
|
|
246
|
-
"data-api-processed": "true"
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
const classMatch = lineText.match(/(?:class|interface|namespace)\s+(\w+)\s*(?:<[^>]*>)?\s*(?:extends|implements)?[^{]*\{/);
|
|
253
|
-
if (classMatch) {
|
|
254
|
-
if ((lineText.match(/\{/g) || []).length > (lineText.match(/\}/g) || []).length) scopeStack.push(classMatch[1]);
|
|
255
|
-
}
|
|
256
|
-
const openBraces = (lineText.match(/\{/g) || []).length;
|
|
257
|
-
const excessCloses = (lineText.match(/\}/g) || []).length - openBraces;
|
|
258
|
-
for (let i = 0; i < excessCloses && scopeStack.length > 0; i++) scopeStack.pop();
|
|
259
|
-
}
|
|
260
|
-
const findTwoslashSpans = (element) => {
|
|
261
|
-
const results = [];
|
|
262
|
-
if (element.properties?.class && String(element.properties.class).includes("twoslash-hover")) results.push(element);
|
|
263
|
-
if (element.children) {
|
|
264
|
-
for (const child of element.children) if (child.type === "element") results.push(...findTwoslashSpans(child));
|
|
265
|
-
}
|
|
266
|
-
return results;
|
|
267
|
-
};
|
|
268
|
-
const twoslashSpans = findTwoslashSpans(codeElement);
|
|
269
|
-
for (const twoslashSpan of twoslashSpans) {
|
|
270
|
-
if (twoslashSpan.properties?.["data-api-processed"] === "true") continue;
|
|
271
|
-
const methodInfo = this.extractMethodInfoFromTwoslashTooltip(twoslashSpan);
|
|
272
|
-
if (!methodInfo) continue;
|
|
273
|
-
const { className, methodName } = methodInfo;
|
|
274
|
-
const fullMemberName = `${className}.${methodName}`;
|
|
275
|
-
const memberRoute = apiItemRoutes.get(fullMemberName);
|
|
276
|
-
if (!memberRoute) continue;
|
|
277
|
-
const memberKind = apiItemKinds.get(fullMemberName);
|
|
278
|
-
const memberSemanticClass = memberKind ? this.getSemanticClass(memberKind) : null;
|
|
279
|
-
const memberClassNames = ["api-type-link"];
|
|
280
|
-
if (memberSemanticClass) memberClassNames.push(memberSemanticClass);
|
|
281
|
-
const textContent = this.extractTextFromTwoslash(twoslashSpan);
|
|
282
|
-
if (!textContent) continue;
|
|
283
|
-
this.wrapTwoslashTextInAnchor(twoslashSpan, textContent.trim(), memberRoute, memberClassNames);
|
|
284
|
-
twoslashSpan.properties = {
|
|
285
|
-
...twoslashSpan.properties,
|
|
286
|
-
"data-api-processed": "true"
|
|
287
|
-
};
|
|
288
|
-
}
|
|
289
|
-
const typeNames = Array.from(apiItemRoutes.keys()).filter((name) => !name.includes(".")).sort((a, b) => b.length - a.length);
|
|
290
|
-
if (typeNames.length > 0) {
|
|
291
|
-
for (const twoslashSpan of twoslashSpans) {
|
|
292
|
-
if (twoslashSpan.properties?.["data-api-processed"] === "true") continue;
|
|
293
|
-
const text = this.extractTextFromTwoslash(twoslashSpan);
|
|
294
|
-
if (!text) continue;
|
|
295
|
-
const content = text.trim();
|
|
296
|
-
const route = apiItemRoutes.get(content);
|
|
297
|
-
if (!route) continue;
|
|
298
|
-
const kind = apiItemKinds.get(content);
|
|
299
|
-
const semanticClass = kind ? this.getSemanticClass(kind) : null;
|
|
300
|
-
const classNames = ["api-type-link"];
|
|
301
|
-
if (semanticClass) classNames.push(semanticClass);
|
|
302
|
-
this.wrapTwoslashTextInAnchor(twoslashSpan, content, route, classNames);
|
|
303
|
-
twoslashSpan.properties = {
|
|
304
|
-
...twoslashSpan.properties,
|
|
305
|
-
"data-api-processed": "true"
|
|
306
|
-
};
|
|
307
|
-
}
|
|
308
|
-
const escapedNames = typeNames.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
309
|
-
const typePattern = new RegExp(`\\b(${escapedNames.join("|")})\\b`, "g");
|
|
310
|
-
for (const lineElement of codeElement.children) {
|
|
311
|
-
if (lineElement.type !== "element" || lineElement.tagName !== "span") continue;
|
|
312
|
-
this.linkTypeReferencesInLine(lineElement, typePattern, apiItemRoutes, apiItemKinds);
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
return node;
|
|
116
|
+
transformHast(hast) {
|
|
117
|
+
return this.transformRoot(hast);
|
|
316
118
|
}
|
|
317
|
-
/**
|
|
318
|
-
* Transform the root node of the syntax tree
|
|
319
|
-
*/
|
|
320
119
|
transformRoot(node) {
|
|
321
|
-
const apiItemRoutes = this.
|
|
322
|
-
const apiItemKinds = this.
|
|
323
|
-
const classMembersMap = this.
|
|
120
|
+
const apiItemRoutes = this.apiItemRoutes;
|
|
121
|
+
const apiItemKinds = this.apiItemKinds;
|
|
122
|
+
const classMembersMap = this.classMembersMap;
|
|
324
123
|
const scopeStack = [];
|
|
325
124
|
const preElement = node.children.find((child) => child.type === "element" && child.tagName === "pre");
|
|
326
125
|
if (preElement?.type !== "element") return node;
|
|
@@ -454,9 +253,9 @@ var ShikiCrossLinker = class {
|
|
|
454
253
|
* Transform a line element
|
|
455
254
|
*/
|
|
456
255
|
transformLine(node) {
|
|
457
|
-
const apiItemRoutes = this.
|
|
458
|
-
const apiItemKinds = this.
|
|
459
|
-
const classMembersMap = this.
|
|
256
|
+
const apiItemRoutes = this.apiItemRoutes;
|
|
257
|
+
const apiItemKinds = this.apiItemKinds;
|
|
258
|
+
const classMembersMap = this.classMembersMap;
|
|
460
259
|
if (!node.children) return;
|
|
461
260
|
for (let i = 0; i < node.children.length; i++) {
|
|
462
261
|
const child = node.children[i];
|
|
@@ -541,8 +340,8 @@ var ShikiCrossLinker = class {
|
|
|
541
340
|
* Transform a span element
|
|
542
341
|
*/
|
|
543
342
|
transformSpan(node, _line, _col) {
|
|
544
|
-
const apiItemRoutes = this.
|
|
545
|
-
const apiItemKinds = this.
|
|
343
|
+
const apiItemRoutes = this.apiItemRoutes;
|
|
344
|
+
const apiItemKinds = this.apiItemKinds;
|
|
546
345
|
if (node.properties?.["data-api-processed"] === "true") return;
|
|
547
346
|
const firstChild = node.children?.[0];
|
|
548
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 };
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { gunzipSync, gzipSync } from "node:zlib";
|
|
3
|
+
|
|
4
|
+
//#region src/twoslash-cache.ts
|
|
5
|
+
/**
|
|
6
|
+
* Persisted Twoslash result cache.
|
|
7
|
+
*
|
|
8
|
+
* Type-checking code blocks is by far the dominant cost of the render phase —
|
|
9
|
+
* measured at ~97% of it, concentrated in the minority of blocks that carry an
|
|
10
|
+
* `@example` (see `render-phase-instrumentation.md`). `@shikijs/twoslash`
|
|
11
|
+
* exposes a first-class `typesCache` seam for exactly this, so the work here is
|
|
12
|
+
* a keying scheme and a store rather than a new interception point.
|
|
13
|
+
*
|
|
14
|
+
* ## Soundness
|
|
15
|
+
*
|
|
16
|
+
* A Twoslash result depends on the code, the compiler options, the declarations
|
|
17
|
+
* it is checked against, and the compiler doing the checking. The keys cover
|
|
18
|
+
* all four: the per-entry key carries the code, its language and the compiler
|
|
19
|
+
* options; {@link twoslashEnvHash} carries the declarations and the TypeScript
|
|
20
|
+
* version.
|
|
21
|
+
*
|
|
22
|
+
* The TypeScript version is load-bearing and easy to overlook — `lib.d.ts`
|
|
23
|
+
* ships with the compiler and inference changes between releases, so an upgrade
|
|
24
|
+
* against unchanged declarations yields different hovers. Omitting it would let
|
|
25
|
+
* a warm cache serve results from the previous compiler and stay wrong until
|
|
26
|
+
* the API's own declarations happened to change.
|
|
27
|
+
*
|
|
28
|
+
* NOT covered, and covered instead by {@link TWOSLASH_CACHE_FORMAT}: the
|
|
29
|
+
* `@shikijs/twoslash` / `twoslash` renderer version, which determines the shape
|
|
30
|
+
* of the stored `nodes`. Bump the format constant when upgrading those, since
|
|
31
|
+
* nothing derives it automatically.
|
|
32
|
+
*
|
|
33
|
+
* ## Invalidation granularity
|
|
34
|
+
*
|
|
35
|
+
* The consequence of that soundness is coarse invalidation: any VFS change
|
|
36
|
+
* discards the whole generation, because a declaration change anywhere can
|
|
37
|
+
* legitimately change any block's inferred types. So this cache makes repeat
|
|
38
|
+
* builds over an UNCHANGED API nearly free — CI re-runs, prose-only edits,
|
|
39
|
+
* theme and config changes, rebuilding a site without touching the library —
|
|
40
|
+
* and does nothing for the build right after an API item changes.
|
|
41
|
+
*
|
|
42
|
+
* Sharpening that would need per-scope type environments, so one package's
|
|
43
|
+
* change stops invalidating every other package's blocks. That is fix (b) in
|
|
44
|
+
* `render-phase-instrumentation.md`, tracked as a correctness fix; it would
|
|
45
|
+
* make this cache substantially more effective on a multi-API site as a side
|
|
46
|
+
* effect.
|
|
47
|
+
*
|
|
48
|
+
* ## Synchronous by necessity
|
|
49
|
+
*
|
|
50
|
+
* `TwoslashTypesCache.read`/`write` are synchronous — they are called from
|
|
51
|
+
* inside Shiki's `preprocess` hook. Persistence is therefore load-once at
|
|
52
|
+
* startup and save-once at the end, against an in-memory map; there is no
|
|
53
|
+
* per-entry I/O. See `TwoslashCacheService`.
|
|
54
|
+
*/
|
|
55
|
+
/**
|
|
56
|
+
* Bumped when the stored shape changes, so an older blob is treated as absent
|
|
57
|
+
* rather than deserialized into the wrong shape.
|
|
58
|
+
*
|
|
59
|
+
* Also the manual lever for renderer changes: bump this when upgrading
|
|
60
|
+
* `@shikijs/twoslash` or `twoslash`, whose versions determine the shape of the
|
|
61
|
+
* stored `nodes` and are not derived into any key.
|
|
62
|
+
*/
|
|
63
|
+
const TWOSLASH_CACHE_FORMAT = 1;
|
|
64
|
+
function sha256(input) {
|
|
65
|
+
return createHash("sha256").update(input).digest("hex");
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Fingerprint the type environment a generation is checked against.
|
|
69
|
+
*
|
|
70
|
+
* Covers the declarations (`vfs`) and the compiler that interprets them
|
|
71
|
+
* (`toolchain`). The VFS is hashed over sorted `path\0content` pairs so the
|
|
72
|
+
* digest is stable against map iteration order.
|
|
73
|
+
*
|
|
74
|
+
* `toolchain` must carry the TypeScript version. The declarations alone do not
|
|
75
|
+
* determine the answer: `lib.d.ts` ships with the compiler and inference
|
|
76
|
+
* behaviour changes between releases, so upgrading TypeScript against unchanged
|
|
77
|
+
* declarations produces different hovers. Without the version in the key the
|
|
78
|
+
* warm cache would serve results computed by the previous compiler, and stay
|
|
79
|
+
* wrong until the API's own declarations happened to change.
|
|
80
|
+
*
|
|
81
|
+
* Compiler OPTIONS are deliberately not folded in here — they belong on the
|
|
82
|
+
* per-entry key, so one generation can hold results from the several
|
|
83
|
+
* configurations a multi-API site may declare.
|
|
84
|
+
*/
|
|
85
|
+
function twoslashEnvHash(vfs, toolchain) {
|
|
86
|
+
const hash = createHash("sha256");
|
|
87
|
+
hash.update(`format:${1}\0toolchain:${toolchain}\0`);
|
|
88
|
+
for (const key of [...vfs.keys()].sort()) hash.update(`${key}\0${vfs.get(key) ?? ""}\0`);
|
|
89
|
+
return hash.digest("hex");
|
|
90
|
+
}
|
|
91
|
+
/** JSON with object keys sorted, so equivalent options hash identically. */
|
|
92
|
+
function stableStringify(value) {
|
|
93
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
94
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
95
|
+
return `{${Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Per-entry key: the code, its language, and the compiler configuration it is
|
|
99
|
+
* checked under.
|
|
100
|
+
*
|
|
101
|
+
* The configuration matters because two APIs on one site may be documented
|
|
102
|
+
* under different `tsconfig`s — the same source checked under different options
|
|
103
|
+
* can produce different types, so it must not share a cache entry.
|
|
104
|
+
*/
|
|
105
|
+
function twoslashEntryKey(code, lang, compilerOptions) {
|
|
106
|
+
return sha256(`${lang ?? "ts"}\0${stableStringify(compilerOptions ?? {})}\0${code}`);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The cache key a whole generation is stored under. One blob per environment,
|
|
110
|
+
* so a changed environment reads as a miss rather than serving stale results.
|
|
111
|
+
*/
|
|
112
|
+
function twoslashBlobKey(envHash) {
|
|
113
|
+
return `twoslash/v${1}/${envHash}`;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Build a synchronous Twoslash cache over an in-memory map, optionally seeded
|
|
117
|
+
* with entries loaded from a previous build.
|
|
118
|
+
*/
|
|
119
|
+
function makeTwoslashCache(initial) {
|
|
120
|
+
const map = new Map(initial);
|
|
121
|
+
let hits = 0;
|
|
122
|
+
let misses = 0;
|
|
123
|
+
let dirty = false;
|
|
124
|
+
return {
|
|
125
|
+
read: (code, lang, options) => {
|
|
126
|
+
const found = map.get(twoslashEntryKey(code, lang, options?.compilerOptions));
|
|
127
|
+
if (found === void 0) {
|
|
128
|
+
misses += 1;
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
hits += 1;
|
|
132
|
+
return found;
|
|
133
|
+
},
|
|
134
|
+
write: (code, data, lang, options) => {
|
|
135
|
+
const value = {
|
|
136
|
+
nodes: data.nodes,
|
|
137
|
+
code: data.code,
|
|
138
|
+
...data.meta?.extension != null ? { meta: { extension: data.meta.extension } } : {}
|
|
139
|
+
};
|
|
140
|
+
map.set(twoslashEntryKey(code, lang, options?.compilerOptions), value);
|
|
141
|
+
dirty = true;
|
|
142
|
+
},
|
|
143
|
+
stats: () => ({
|
|
144
|
+
hits,
|
|
145
|
+
misses,
|
|
146
|
+
entries: map.size,
|
|
147
|
+
dirty
|
|
148
|
+
}),
|
|
149
|
+
entries: () => map
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** Serialize a generation for storage. Gzipped JSON — hover text compresses well. */
|
|
153
|
+
function encodeTwoslashCache(entries) {
|
|
154
|
+
return gzipSync(Buffer.from(JSON.stringify(Object.fromEntries(entries)), "utf-8"));
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Deserialize a stored generation.
|
|
158
|
+
*
|
|
159
|
+
* Returns an empty map for anything unreadable — a truncated blob, a format
|
|
160
|
+
* change, a corrupted file. A cache that cannot be read is a cache miss, never
|
|
161
|
+
* a build failure.
|
|
162
|
+
*/
|
|
163
|
+
function decodeTwoslashCache(blob) {
|
|
164
|
+
try {
|
|
165
|
+
const parsed = JSON.parse(gunzipSync(blob).toString("utf-8"));
|
|
166
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return /* @__PURE__ */ new Map();
|
|
167
|
+
return new Map(Object.entries(parsed));
|
|
168
|
+
} catch {
|
|
169
|
+
return /* @__PURE__ */ new Map();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
//#endregion
|
|
174
|
+
export { decodeTwoslashCache, encodeTwoslashCache, makeTwoslashCache, twoslashBlobKey, twoslashEntryKey, twoslashEnvHash };
|
package/twoslash-patterns.js
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region src/twoslash-timing-wrapper.ts
|
|
2
|
+
/**
|
|
3
|
+
* Wraps a Twoslash transformer to measure execution time
|
|
4
|
+
*/
|
|
5
|
+
function createTwoslashTimingWrapper(twoslashTransformer, onTiming) {
|
|
6
|
+
const wrapper = {
|
|
7
|
+
...twoslashTransformer,
|
|
8
|
+
name: `${twoslashTransformer.name}-timing-wrapper`
|
|
9
|
+
};
|
|
10
|
+
if (twoslashTransformer.preprocess) {
|
|
11
|
+
const originalPreprocess = twoslashTransformer.preprocess;
|
|
12
|
+
wrapper.preprocess = function(code, options) {
|
|
13
|
+
const start = performance.now();
|
|
14
|
+
const result = originalPreprocess.call(this, code, options);
|
|
15
|
+
onTiming(performance.now() - start);
|
|
16
|
+
return result ?? void 0;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
return wrapper;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
//#endregion
|
|
23
|
+
export { createTwoslashTimingWrapper };
|