rspress-plugin-api-extractor 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 };
@@ -84,4 +84,4 @@ function classifyCutDirective(trimmedLine) {
84
84
  }
85
85
 
86
86
  //#endregion
87
- export { RE_ANNOTATION, RE_CONFIG, RE_CUT, classifyCutDirective, isTwoslashDirective };
87
+ export { classifyCutDirective, isTwoslashDirective };
@@ -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 };
@@ -5,7 +5,6 @@ import { fromMarkdown } from "mdast-util-from-markdown";
5
5
  import { toHast } from "mdast-util-to-hast";
6
6
 
7
7
  //#region src/twoslash-transformer.ts
8
- /* v8 ignore start -- Shiki/Twoslash integration, requires full highlighter setup for testing */
9
8
  /**
10
9
  * Module-level emitter seam. Default is a no-op; wire in `setEventEmitter(emitSync)`
11
10
  * from plugin.ts right after the runtime emitter is created so that Twoslash error
@@ -228,12 +227,35 @@ function renderMarkdownInline(markdown, context) {
228
227
  *
229
228
  * @see {@link TypeRegistryService} for VFS generation
230
229
  */
230
+ /**
231
+ * Fingerprint a compiler configuration so environments can be deduped and code
232
+ * blocks routed to the right one. Keys are sorted, so two configurations that
233
+ * differ only in property order share an environment.
234
+ */
235
+ function twoslashConfigKey(options) {
236
+ const entries = Object.entries(options).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
237
+ return JSON.stringify(entries);
238
+ }
231
239
  var TwoslashManager = class TwoslashManager {
232
240
  static instance = null;
233
241
  /**
234
- * Twoslash transformer instance
242
+ * Transformers keyed by compiler-config fingerprint.
243
+ *
244
+ * One environment per DISTINCT configuration, not per API: two packages
245
+ * documented under the same compiler options share an environment, and with
246
+ * it the TypeScript language services Twoslash builds per block. A build
247
+ * where every API agrees on its config therefore costs exactly what the
248
+ * single shared environment used to.
249
+ */
250
+ environments = /* @__PURE__ */ new Map();
251
+ /** API scope to config fingerprint, for `getTransformer(scope)`. */
252
+ scopeConfigs = /* @__PURE__ */ new Map();
253
+ /**
254
+ * Fingerprint of the first environment initialized, used for code blocks
255
+ * that carry no scope — a `with-api` fence in a page outside any documented
256
+ * package's route.
235
257
  */
236
- transformer = null;
258
+ defaultConfigKey = null;
237
259
  /**
238
260
  * VFS keys snapshot captured at initialize() time.
239
261
  * Returned by vfsKeysSnapshot() for TwoslashCheckFailed events.
@@ -272,13 +294,16 @@ var TwoslashManager = class TwoslashManager {
272
294
  * @param tsEnvCache - TypeScript virtual environment cache for reusing language services
273
295
  * @param compilerOptions - TypeScript compiler options for Twoslash (defaults to DEFAULT_COMPILER_OPTIONS)
274
296
  */
275
- initialize(vfs, _reserved, _reserved2, tsEnvCache, compilerOptions) {
297
+ initialize(vfs, _reserved, _reserved2, tsEnvCache, compilerOptions, typesCache) {
276
298
  const extraFiles = {};
277
299
  for (const [path, content] of vfs.entries()) extraFiles[path] = content;
278
300
  this._vfsKeys = Array.from(vfs.keys());
279
301
  const resolvedOptions = compilerOptions ?? DEFAULT_COMPILER_OPTIONS;
280
302
  this._resolvedCompilerOptions = resolvedOptions;
281
- this.transformer = transformerTwoslash({
303
+ const configKey = twoslashConfigKey(resolvedOptions);
304
+ if (this.defaultConfigKey === null) this.defaultConfigKey = configKey;
305
+ if (this.environments.has(configKey)) return;
306
+ const transformer = transformerTwoslash({
282
307
  renderer: rendererRich({
283
308
  processHoverInfo: (info) => {
284
309
  return info.replace(/^\(([\w-]+)\)\s+/gm, "").replace(/\nimport .*$/gm, "").trim();
@@ -288,6 +313,7 @@ var TwoslashManager = class TwoslashManager {
288
313
  renderMarkdownInline
289
314
  }),
290
315
  ...tsEnvCache != null ? { cache: tsEnvCache } : {},
316
+ ...typesCache != null ? { typesCache } : {},
291
317
  twoslashOptions: {
292
318
  extraFiles,
293
319
  compilerOptions: resolvedOptions,
@@ -299,13 +325,26 @@ var TwoslashManager = class TwoslashManager {
299
325
  this.handleTwoslashError(error, code, this.currentFilePath);
300
326
  }
301
327
  });
328
+ this.environments.set(configKey, transformer);
302
329
  }
303
330
  /**
304
- * Get the initialized Twoslash transformer.
305
- * Returns null if not initialized.
331
+ * Associate an API scope with the compiler configuration it is documented
332
+ * under, so its code blocks are type-checked with that configuration.
333
+ */
334
+ registerScope(apiScope, compilerOptions) {
335
+ this.scopeConfigs.set(apiScope, twoslashConfigKey(compilerOptions));
336
+ }
337
+ /**
338
+ * Get the Twoslash transformer for an API scope.
339
+ *
340
+ * An unknown or absent scope falls back to the first environment built: a
341
+ * `with-api` fence can appear on a page outside any documented package's
342
+ * route, and type-checking it under some configuration beats not checking it.
343
+ * Returns null before any environment is initialized.
306
344
  */
307
- getTransformer() {
308
- return this.transformer;
345
+ getTransformer(apiScope) {
346
+ const key = (apiScope != null ? this.scopeConfigs.get(apiScope) : void 0) ?? this.defaultConfigKey;
347
+ return key != null ? this.environments.get(key) ?? null : null;
309
348
  }
310
349
  /**
311
350
  * Set the source file path used to attribute subsequent Twoslash error events.
@@ -320,7 +359,9 @@ var TwoslashManager = class TwoslashManager {
320
359
  * Clear the Twoslash transformer (useful for testing or reinitializing)
321
360
  */
322
361
  clear() {
323
- this.transformer = null;
362
+ this.environments.clear();
363
+ this.scopeConfigs.clear();
364
+ this.defaultConfigKey = null;
324
365
  }
325
366
  /**
326
367
  * Reset the singleton instance (useful for testing)
@@ -1,34 +0,0 @@
1
- import { Fragment, jsx } from "react/jsx-runtime";
2
-
3
- //#region src/runtime/components/MarkdownText/index.tsx
4
- /**
5
- * Renders plain text with markdown links as React elements.
6
- * Only supports basic markdown links: [text](url)
7
- */
8
- function MarkdownText({ children }) {
9
- const parts = parseMarkdownLinks(children);
10
- return /* @__PURE__ */ jsx(Fragment, { children: parts });
11
- }
12
- /**
13
- * Parse markdown links and return array of React nodes
14
- */
15
- function parseMarkdownLinks(text) {
16
- const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
17
- const parts = [];
18
- let lastIndex = 0;
19
- let key = 0;
20
- for (const match of text.matchAll(linkRegex)) {
21
- if (match.index !== void 0 && match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
22
- const [fullMatch, linkText, url] = match;
23
- parts.push(/* @__PURE__ */ jsx("a", {
24
- href: url,
25
- children: linkText
26
- }, key++));
27
- lastIndex = (match.index ?? 0) + fullMatch.length;
28
- }
29
- if (lastIndex < text.length) parts.push(text.slice(lastIndex));
30
- return parts;
31
- }
32
-
33
- //#endregion
34
- export { MarkdownText, MarkdownText as default };