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.
Files changed (52) hide show
  1. package/BuildEnv.js +58 -0
  2. package/README.md +2 -1
  3. package/build-program.js +33 -30
  4. package/build-stages.js +47 -39
  5. package/errors.js +0 -1
  6. package/index.d.ts +22 -14
  7. package/layers/ConfigServiceLive.js +349 -400
  8. package/layers/HighlighterServiceLive.js +52 -0
  9. package/layers/ObservabilityLive.js +26 -7
  10. package/layers/OgServiceLive.js +134 -0
  11. package/layers/TwoslashCacheServiceLive.js +108 -0
  12. package/layers/TwoslashEnvironmentsLive.js +33 -0
  13. package/layers/TypeRegistryServiceLive.js +54 -47
  14. package/layers/build-metrics.js +32 -5
  15. package/layers/xdg.js +44 -0
  16. package/markdown/helpers.js +9 -55
  17. package/markdown/page-generators/class-page.js +8 -31
  18. package/markdown/page-generators/index-pages.js +6 -8
  19. package/markdown/page-generators/interface-page.js +7 -7
  20. package/markdown/shiki-utils.js +65 -10
  21. package/observability/EventBus.js +29 -9
  22. package/observability/heartbeat.js +1 -1
  23. package/observability/metric-report.js +124 -0
  24. package/observability/sinks/console-sink.js +6 -0
  25. package/observability/sinks/metrics-sink.js +64 -21
  26. package/observability/sinks/render-sink.js +86 -0
  27. package/observability/sinks/trace-sink.js +10 -17
  28. package/observability/spans.js +4 -2
  29. package/observability/sync-emitter.js +78 -0
  30. package/og-resolver.js +46 -287
  31. package/package.json +4 -5
  32. package/path-derivation.js +19 -1
  33. package/plugin.js +64 -52
  34. package/prettier-formatter.js +4 -10
  35. package/remark-api-codeblocks.js +33 -15
  36. package/remark-with-api.js +24 -27
  37. package/schemas/config.js +11 -7
  38. package/services/HighlighterService.js +30 -0
  39. package/services/OgService.js +23 -0
  40. package/services/PluginConfig.js +26 -0
  41. package/services/TwoslashCacheService.js +15 -0
  42. package/services/TwoslashEnvironments.js +7 -0
  43. package/shiki-transformer.js +55 -256
  44. package/twoslash-access.js +48 -0
  45. package/twoslash-cache.js +174 -0
  46. package/twoslash-patterns.js +1 -1
  47. package/twoslash-timing-wrapper.js +23 -0
  48. package/twoslash-transformer.js +153 -89
  49. package/vfs-registry.js +1 -31
  50. package/layers/PathDerivationServiceLive.js +0 -16
  51. package/runtime/components/MarkdownText/index.js +0 -34
  52. package/services/PathDerivationService.js +0 -7
@@ -1,17 +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";
6
+ import { createTwoslashTimingWrapper } from "./twoslash-timing-wrapper.js";
5
7
  import { visit } from "unist-util-visit";
6
8
 
7
9
  //#region src/remark-api-codeblocks.ts
8
- /** Module-level emitter injected by plugin.ts at startup. */
9
- let emitEvent = () => {};
10
- let currentBuildId = "";
11
- function setRemarkApiCodeblocksEventEmitter(fn, buildId = "") {
12
- emitEvent = fn;
13
- currentBuildId = buildId;
14
- }
15
10
  /**
16
11
  * Create an MDX JSX attribute value expression with proper estree AST.
17
12
  * This ensures the value is properly serialized as a JavaScript string literal.
@@ -91,7 +86,7 @@ const remarkApiCodeblocks = () => {
91
86
  const promises = [];
92
87
  const isSsgMd = import.meta.env?.SSG_MD || process.env.RSBUILD_ENVIRONMENT === "node_md" || process.env.BUILD_TARGET === "node_md";
93
88
  const currentFilePath = file.path || "unknown";
94
- if (file.path) TwoslashManager.getInstance().setCurrentFile(file.path);
89
+ if (file.path) setTwoslashFile(file.path);
95
90
  const jsxComponentNames = /* @__PURE__ */ new Set([
96
91
  "ApiSignature",
97
92
  "ApiMember",
@@ -109,9 +104,9 @@ const remarkApiCodeblocks = () => {
109
104
  }
110
105
  const vfsConfig = VfsRegistry.get(apiScopeValue);
111
106
  if (!vfsConfig) {
112
- emitEvent(PluginEvent.ConfigCascadeWarning({
107
+ emitSync(PluginEvent.ConfigCascadeWarning({
113
108
  ctx: {
114
- buildId: currentBuildId,
109
+ buildId: syncBuildId(),
115
110
  file: currentFilePath
116
111
  },
117
112
  field: "vfs",
@@ -123,14 +118,37 @@ const remarkApiCodeblocks = () => {
123
118
  return;
124
119
  }
125
120
  const transformers = [];
126
- if (node.name === "ApiExample" && vfsConfig.twoslashTransformer) transformers.push(vfsConfig.twoslashTransformer);
121
+ let twoslashMs = 0;
122
+ if (node.name === "ApiExample" && vfsConfig.twoslashTransformer) transformers.push(createTwoslashTimingWrapper(vfsConfig.twoslashTransformer, (duration) => {
123
+ twoslashMs += duration;
124
+ }));
127
125
  else if (node.name === "ApiMember" && vfsConfig.hideCutTransformer) transformers.push(vfsConfig.hideCutTransformer);
128
126
  else if (node.name === "ApiSignature") {
129
127
  if (vfsConfig.hideCutLinesTransformer) transformers.push(vfsConfig.hideCutLinesTransformer);
130
128
  }
131
129
  const isExample = node.name === "ApiExample" && !!vfsConfig.twoslashTransformer;
132
- let hast = await generateShikiHast(source, vfsConfig.highlighter, transformers.length > 0 ? transformers : void 0, isExample, vfsConfig.theme);
133
- if (hast && vfsConfig.crossLinker) hast = vfsConfig.crossLinker.transformHast(hast, apiScopeValue);
130
+ const shikiStart = performance.now();
131
+ const hastPromise = generateShikiHast(source, vfsConfig.highlighter, transformers.length > 0 ? transformers : void 0, isExample, vfsConfig.theme);
132
+ const renderMs = performance.now() - shikiStart;
133
+ let hast = await hastPromise;
134
+ const postStart = performance.now();
135
+ if (hast && vfsConfig.crossLinker) hast = vfsConfig.crossLinker.transformHast(hast);
136
+ const totalBlockTime = renderMs + (performance.now() - postStart);
137
+ emitSync(PluginEvent.CodeBlockProcessed({
138
+ ctx: {
139
+ buildId: syncBuildId(),
140
+ apiScope: apiScopeValue,
141
+ file: currentFilePath
142
+ },
143
+ lang: "typescript",
144
+ component: node.name,
145
+ twoslash: isExample,
146
+ twoslashMs,
147
+ shikiMs: Math.max(0, renderMs - twoslashMs),
148
+ totalMs: totalBlockTime,
149
+ slow: totalBlockTime > syncSlowCodeBlockMs(),
150
+ level: "debug"
151
+ }));
134
152
  const hastBase64 = hast ? Buffer.from(JSON.stringify(hast), "utf-8").toString("base64") : "";
135
153
  node.attributes.push({
136
154
  type: "mdxJsxAttribute",
@@ -146,4 +164,4 @@ const remarkApiCodeblocks = () => {
146
164
  };
147
165
 
148
166
  //#endregion
149
- export { remarkApiCodeblocks, setRemarkApiCodeblocksEventEmitter };
167
+ export { remarkApiCodeblocks };
@@ -1,21 +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";
8
+ import { createTwoslashTimingWrapper } from "./twoslash-timing-wrapper.js";
6
9
  import { codeToHast, hastToHtml } from "shiki";
7
10
  import { visit } from "unist-util-visit";
8
11
 
9
12
  //#region src/remark-with-api.ts
10
- /** Module-level emitter injected by plugin.ts at startup. */
11
- let emitEvent = () => {};
12
- let currentBuildId = "";
13
- let currentSlowCodeBlockMs = 500;
14
- function setRemarkWithApiEventEmitter(fn, buildId = "", slowCodeBlockMs = 500) {
15
- emitEvent = fn;
16
- currentBuildId = buildId;
17
- currentSlowCodeBlockMs = slowCodeBlockMs;
18
- }
19
13
  /**
20
14
  * Supported languages for with-api code blocks
21
15
  * Based on GitHub Linguist standard aliases:
@@ -58,18 +52,14 @@ function inferApiScope(filePath) {
58
52
  * 5. Renders to ApiExample component with pre-rendered Shiki HAST
59
53
  */
60
54
  const remarkWithApi = (options) => {
61
- const { shikiCrossLinker, getTransformer, theme } = options;
55
+ const { getTransformer, theme } = options;
62
56
  const resolvedTheme = theme ?? DEFAULT_SHIKI_THEMES;
63
57
  return async function remarkTransformer(tree, file) {
64
58
  const promises = [];
65
59
  let needsApiExampleImport = false;
66
60
  const isSsgMd = import.meta.env?.SSG_MD || process.env.RSBUILD_ENVIRONMENT === "node_md" || process.env.BUILD_TARGET === "node_md";
67
61
  const currentFilePath = file.path;
68
- if (currentFilePath) {
69
- const apiScope = inferApiScope(currentFilePath);
70
- if (apiScope) shikiCrossLinker.setApiScope(apiScope);
71
- TwoslashManager.getInstance().setCurrentFile(currentFilePath);
72
- }
62
+ if (currentFilePath) setTwoslashFile(currentFilePath);
73
63
  visit(tree, "code", (node, index, parent) => {
74
64
  const hasWithApi = node.meta?.includes("with-api");
75
65
  const lang = node.lang || "typescript";
@@ -78,9 +68,13 @@ const remarkWithApi = (options) => {
78
68
  const promise = (async () => {
79
69
  const blockStart = performance.now();
80
70
  const rawCode = node.value;
71
+ const apiScope = currentFilePath ? inferApiScope(currentFilePath) : void 0;
81
72
  const code = (await formatCode(rawCode, lang)).code;
82
- const twoslashTransformer = getTransformer();
83
- const transformers = twoslashTransformer ? [twoslashTransformer] : [];
73
+ let twoslashMs = 0;
74
+ const twoslashTransformer = getTransformer(apiScope);
75
+ const transformers = twoslashTransformer ? [createTwoslashTimingWrapper(twoslashTransformer, (duration) => {
76
+ twoslashMs += duration;
77
+ })] : [];
84
78
  const shikiStart = performance.now();
85
79
  let hast = await codeToHast(code, {
86
80
  lang,
@@ -93,19 +87,22 @@ const remarkWithApi = (options) => {
93
87
  cssVariablePrefix: "--api-shiki-",
94
88
  transformers
95
89
  });
96
- const apiScope = currentFilePath ? inferApiScope(currentFilePath) : void 0;
97
- if (apiScope) hast = shikiCrossLinker.transformHast(hast, apiScope);
98
- const shikiTime = performance.now() - shikiStart;
90
+ const renderMs = performance.now() - shikiStart;
91
+ const scopeLinker = apiScope ? VfsRegistry.get(apiScope)?.crossLinker : void 0;
92
+ if (scopeLinker) hast = scopeLinker.transformHast(hast);
99
93
  const totalBlockTime = performance.now() - blockStart;
100
- const isSlow = totalBlockTime > currentSlowCodeBlockMs;
101
- emitEvent(PluginEvent.CodeBlockProcessed({
94
+ const isSlow = totalBlockTime > syncSlowCodeBlockMs();
95
+ emitSync(PluginEvent.CodeBlockProcessed({
102
96
  ctx: {
103
- buildId: currentBuildId,
97
+ buildId: syncBuildId(),
98
+ ...apiScope != null ? { apiScope } : {},
104
99
  ...currentFilePath != null ? { file: currentFilePath } : {}
105
100
  },
106
101
  lang,
107
- shikiMs: shikiTime,
108
- twoslashMs: totalBlockTime - shikiTime,
102
+ component: "with-api",
103
+ twoslash: twoslashTransformer != null,
104
+ twoslashMs,
105
+ shikiMs: Math.max(0, renderMs - twoslashMs),
109
106
  totalMs: totalBlockTime,
110
107
  slow: isSlow,
111
108
  level: "debug"
@@ -184,4 +181,4 @@ const remarkWithApi = (options) => {
184
181
  };
185
182
 
186
183
  //#endregion
187
- export { remarkWithApi, setRemarkWithApiEventEmitter };
184
+ export { remarkWithApi };
package/schemas/config.js CHANGED
@@ -273,15 +273,19 @@ const MultiApiConfig = Schema.Struct({
273
273
  * Path to a `tsconfig.json` for Twoslash.
274
274
  *
275
275
  * @remarks
276
- * Twoslash runs against a single shared TypeScript environment for the
277
- * whole build, so per-API tsconfigs are not honored in multi-API mode:
278
- * the first API that provides one wins and the rest are ignored (a
279
- * `ConfigCascadeWarning` is emitted when they differ). Ensure the
280
- * configured tsconfigs are equivalent, or set the intended one on the
281
- * first API only.
276
+ * This API's code blocks are type-checked under this config. APIs that
277
+ * declare the same config share one TypeScript environment; the file set
278
+ * is shared across all documented APIs either way, so a type owned by
279
+ * another documented package still resolves.
282
280
  */
283
281
  tsconfig: Schema.optional(ModelInput),
284
- /** TypeScript compiler options for Twoslash. First API wins, as with `tsconfig`. */
282
+ /**
283
+ * TypeScript compiler options for Twoslash, applying to this API only.
284
+ *
285
+ * @remarks
286
+ * Merged on top of the defaults and of this API's `tsconfig`, so declaring
287
+ * a single option overrides just that one.
288
+ */
285
289
  compilerOptions: Schema.optional(Schema.Unknown)
286
290
  });
287
291
  /**
@@ -0,0 +1,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,15 @@
1
+ import { Context } from "effect";
2
+
3
+ //#region src/services/TwoslashCacheService.ts
4
+ /**
5
+ * Persistence for the Twoslash result cache.
6
+ *
7
+ * Split from the synchronous cache object (`twoslash-cache.ts`) because
8
+ * `TwoslashTypesCache.read`/`write` are called from inside Shiki's `preprocess`
9
+ * hook and cannot await: the service loads once before the render phase and
10
+ * saves once after it, while every lookup in between is a synchronous map hit.
11
+ */
12
+ var TwoslashCacheService = class extends Context.Service()("rspress-plugin-api-extractor/TwoslashCacheService") {};
13
+
14
+ //#endregion
15
+ export { TwoslashCacheService };
@@ -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 };