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.
package/BuildEnv.js ADDED
@@ -0,0 +1,58 @@
1
+ import { Context } from "effect";
2
+
3
+ //#region src/BuildEnv.ts
4
+ /**
5
+ * Per-build configuration, as `Context.Reference` values rather than threaded
6
+ * parameters.
7
+ *
8
+ * @remarks
9
+ * Each of these used to travel by hand. `thresholds` made a four-hop journey —
10
+ * `plugin.ts` → `ConfigServiceLive`'s fourth constructor argument →
11
+ * a `ResolvedBuildContext` field → destructured in `build-program.ts` → every
12
+ * `withPhase` call — while `obs.thresholds` already held the same value one
13
+ * scope away. `buildId` was worse: 24 event emit sites wrote
14
+ * `ctx: { buildId: "" }` because the value was not reachable from where they
15
+ * stood, and in `TypeRegistryServiceLive` it genuinely was not — that layer is
16
+ * module-level and has no build to name. A Reference is the fix precisely
17
+ * because it reaches module-level code that no parameter can.
18
+ *
19
+ * A `Context.Reference` carries a default, so nothing has to provide these for
20
+ * a program to run. That is convenient and it is also the hazard: a wiring
21
+ * mistake does not fail, it silently substitutes the default. The tests for
22
+ * this module provide NON-default values and assert the provided value is the
23
+ * one observed, which is the only way to tell the two apart.
24
+ *
25
+ * @packageDocumentation
26
+ */
27
+ /**
28
+ * Identifier correlating every event emitted by one build.
29
+ *
30
+ * @remarks
31
+ * The empty-string default is deliberate: an event emitted outside a build —
32
+ * a test, a stray sync callback — is better tagged empty than crashing. Every
33
+ * production path provides a real id.
34
+ */
35
+ const BuildId = Context.Reference("rspress-plugin-api-extractor/BuildId", { defaultValue: () => "" });
36
+ /** Duration thresholds above which an operation is reported as slow. */
37
+ const Thresholds = Context.Reference("rspress-plugin-api-extractor/Thresholds", { defaultValue: () => ({
38
+ slowCodeBlock: 100,
39
+ slowPageGeneration: 500,
40
+ slowApiLoad: 1e3,
41
+ slowFileOperation: 50,
42
+ slowHttpRequest: 2e3,
43
+ slowDbOperation: 100
44
+ }) });
45
+ /**
46
+ * How many pages the build pipeline generates concurrently.
47
+ *
48
+ * @remarks
49
+ * Defaults to the CPU count, which is what `ConfigServiceLive` computed
50
+ * inline. Kept a Reference rather than a constant so a consumer with a
51
+ * constrained CI runner can lower it without a code change.
52
+ */
53
+ const PageConcurrency = Context.Reference("rspress-plugin-api-extractor/PageConcurrency", { defaultValue: () => 1 });
54
+ /** Whether Twoslash diagnostics inside `@example` blocks are suppressed. */
55
+ const SuppressExampleErrors = Context.Reference("rspress-plugin-api-extractor/SuppressExampleErrors", { defaultValue: () => true });
56
+
57
+ //#endregion
58
+ export { BuildId, PageConcurrency, SuppressExampleErrors, Thresholds };
package/build-program.js CHANGED
@@ -1,8 +1,13 @@
1
+ import { BuildId, PageConcurrency, SuppressExampleErrors } from "./BuildEnv.js";
1
2
  import { setProseLinker } from "./markdown/prose-linker.js";
2
3
  import "./markdown/index.js";
3
4
  import { buildPipelineForApi, cleanupAndCommit, prepareWorkItems, writeMetadata } from "./build-stages.js";
5
+ import { HideCutLinesTransformer, MemberFormatTransformer } from "./hide-cut-transformer.js";
4
6
  import { withPhase } from "./observability/spans.js";
5
- import { TwoslashManager } from "./twoslash-transformer.js";
7
+ import { HighlighterService } from "./services/HighlighterService.js";
8
+ import { TwoslashEnvironments } from "./services/TwoslashEnvironments.js";
9
+ import { ShikiCrossLinker } from "./shiki-transformer.js";
10
+ import { addTypeRoutes } from "./twoslash-transformer.js";
6
11
  import { VfsRegistry } from "./vfs-registry.js";
7
12
  import path from "node:path";
8
13
  import { SnapshotService } from "@tsdoctor/snapshot";
@@ -22,17 +27,17 @@ import { Effect, FileSystem } from "effect";
22
27
  * Returns build result metadata including CrossLinkData for cross-link merging
23
28
  * and generated file paths for LLMs post-processing.
24
29
  */
25
- function generateApiDocs(apiConfig, buildContext, fileContextMap) {
30
+ function generateApiDocs(apiConfig, fileContextMap) {
26
31
  return Effect.gen(function* () {
27
32
  const fileSystem = yield* FileSystem.FileSystem;
33
+ const { highlighter } = yield* HighlighterService;
34
+ const environments = yield* TwoslashEnvironments;
35
+ const buildId = yield* BuildId;
36
+ const pageConcurrency = yield* PageConcurrency;
37
+ const suppressExampleErrors = yield* SuppressExampleErrors;
28
38
  const snapshotSvc = yield* SnapshotService;
29
39
  const { apiPackage, packageName, apiName, outputDir, baseRoute, categories, source, packageJson, llmsPlugin, siteUrl, ogImage } = apiConfig;
30
- const suppressExampleErrors = apiConfig.suppressExampleErrors ?? true;
31
- const { shikiCrossLinker, highlighter, hideCutTransformer, hideCutLinesTransformer, twoslashTransformer, ogResolver, pageConcurrency, thresholds, buildId } = buildContext;
32
- const phaseCtx = {
33
- buildId,
34
- packageName
35
- };
40
+ const phaseCtx = { packageName };
36
41
  const resolvedOutputDir = path.resolve(process.cwd(), outputDir);
37
42
  const buildTime = (/* @__PURE__ */ new Date()).toISOString();
38
43
  const allSnapshots = yield* snapshotSvc.getAllForDirectory(resolvedOutputDir).pipe(Effect.orDie);
@@ -43,29 +48,27 @@ function generateApiDocs(apiConfig, buildContext, fileContextMap) {
43
48
  categories,
44
49
  baseRoute,
45
50
  packageName
46
- })), thresholds);
51
+ })));
47
52
  setProseLinker(crossLinkData.routes);
48
53
  const apiScope = baseRoute.replace(/^\//, "").split("/")[0] || packageName;
49
- shikiCrossLinker.reinitialize(crossLinkData.routes, crossLinkData.kinds, apiScope);
50
- TwoslashManager.addTypeRoutes(crossLinkData.routes);
51
- if (highlighter) {
52
- const vfsConfig = {
53
- vfs: /* @__PURE__ */ new Map(),
54
- highlighter,
55
- crossLinker: shikiCrossLinker,
56
- packageName,
57
- apiScope
58
- };
59
- const scopeTransformer = TwoslashManager.getInstance().getTransformer(apiScope) ?? twoslashTransformer;
60
- if (scopeTransformer != null) vfsConfig.twoslashTransformer = scopeTransformer;
61
- if (hideCutTransformer != null) vfsConfig.hideCutTransformer = hideCutTransformer;
62
- if (hideCutLinesTransformer != null) vfsConfig.hideCutLinesTransformer = hideCutLinesTransformer;
63
- if (apiConfig.theme != null) vfsConfig.theme = apiConfig.theme;
64
- VfsRegistry.register(apiScope, vfsConfig);
65
- }
54
+ const shikiCrossLinker = ShikiCrossLinker.fromRoutes(crossLinkData.routes, crossLinkData.kinds, apiScope);
55
+ addTypeRoutes(crossLinkData.routes);
56
+ const vfsConfig = {
57
+ highlighter,
58
+ crossLinker: shikiCrossLinker,
59
+ packageName,
60
+ apiScope
61
+ };
62
+ const scopeTransformer = environments.transformerFor(apiScope);
63
+ if (scopeTransformer != null) vfsConfig.twoslashTransformer = scopeTransformer;
64
+ vfsConfig.hideCutTransformer = MemberFormatTransformer;
65
+ vfsConfig.hideCutLinesTransformer = HideCutLinesTransformer;
66
+ if (apiConfig.theme != null) vfsConfig.theme = apiConfig.theme;
67
+ VfsRegistry.register(apiScope, vfsConfig);
66
68
  yield* Effect.logDebug(`Generating ${workItems.length} pages across ${Object.keys(categories).length} categories in parallel`);
67
69
  const fileResults = yield* withPhase("generate", phaseCtx, buildPipelineForApi({
68
70
  buildId,
71
+ pageConcurrency,
69
72
  workItems,
70
73
  baseRoute,
71
74
  packageName,
@@ -74,14 +77,13 @@ function generateApiDocs(apiConfig, buildContext, fileContextMap) {
74
77
  ...source != null ? { source } : {},
75
78
  buildTime,
76
79
  resolvedOutputDir,
77
- pageConcurrency,
78
80
  existingSnapshots,
79
81
  ...suppressExampleErrors != null ? { suppressExampleErrors } : {},
80
82
  ...llmsPlugin != null ? { llmsPlugin } : {},
81
- ...ogResolver !== void 0 ? { ogResolver } : {},
83
+ ...apiConfig.docsRoot != null ? { docsRoot: apiConfig.docsRoot } : {},
82
84
  ...siteUrl != null ? { siteUrl } : {},
83
85
  ...ogImage != null ? { ogImage } : {}
84
- }), thresholds);
86
+ }));
85
87
  const changedCount = fileResults.filter((r) => r.status !== "unchanged").length;
86
88
  yield* Effect.logDebug(`Generated ${changedCount} pages`);
87
89
  const generatedFiles = /* @__PURE__ */ new Set();
@@ -103,13 +105,13 @@ function generateApiDocs(apiConfig, buildContext, fileContextMap) {
103
105
  packageName,
104
106
  ...apiName != null ? { apiName } : {},
105
107
  generatedFiles
106
- }), thresholds);
108
+ }));
107
109
  yield* withPhase("cleanup", phaseCtx, cleanupAndCommit({
108
110
  buildId,
109
111
  fileResults,
110
112
  resolvedOutputDir,
111
113
  generatedFiles
112
- }), thresholds);
114
+ }));
113
115
  yield* Effect.logDebug(`Generated ${changedCount} API documentation files for ${packageName}`);
114
116
  return {
115
117
  crossLinkData,
package/build-stages.js CHANGED
@@ -3,6 +3,7 @@ import { PluginEvent } from "./observability/events.js";
3
3
  import { emit } from "./observability/EventBus.js";
4
4
  import { BuildMetrics } from "./layers/build-metrics.js";
5
5
  import "./layers/ObservabilityLive.js";
6
+ import { emitSync, syncBuildId } from "./observability/sync-emitter.js";
6
7
  import { generateFrontmatter } from "./markdown/helpers.js";
7
8
  import { ClassPageGenerator } from "./markdown/page-generators/class-page.js";
8
9
  import { EnumPageGenerator } from "./markdown/page-generators/enum-page.js";
@@ -13,10 +14,11 @@ import { NamespacePageGenerator } from "./markdown/page-generators/namespace-pag
13
14
  import { TypeAliasPageGenerator } from "./markdown/page-generators/type-alias-page.js";
14
15
  import { VariablePageGenerator } from "./markdown/page-generators/variable-page.js";
15
16
  import "./markdown/index.js";
16
- import { OpenGraphResolver } from "./og-resolver.js";
17
+ import { createPageMetadata } from "./og-resolver.js";
18
+ import { OgService } from "./services/OgService.js";
17
19
  import path from "node:path";
18
20
  import { SnapshotService, hashContent, hashFrontmatter } from "@tsdoctor/snapshot";
19
- import { Effect, FileSystem, Metric, Stream } from "effect";
21
+ import { Effect, FileSystem, Metric, Option, Stream } from "effect";
20
22
  import { ApiItemKind } from "@microsoft/api-extractor-model";
21
23
  import { ApiItems, EntryPoints, Routes, SyntheticBases } from "@tsdoctor/model";
22
24
 
@@ -41,24 +43,6 @@ function crossLinkKindPriority(kind) {
41
43
  return CROSS_LINK_KIND_PRIORITY[kind] ?? 100;
42
44
  }
43
45
  /**
44
- * Module-level emitter seam. `prepareWorkItems` runs synchronously outside any
45
- * Effect fiber, so a route collision cannot `yield* emit(...)` — it mirrors the
46
- * sync-island pattern used by `twoslash-transformer.ts` (`setEventEmitter`) and
47
- * `loader.ts` (`setLoaderEventEmitter`). Default is a no-op; wired in plugin.ts
48
- * via `setBuildStagesEventEmitter(emitSync, buildId)` right after the runtime
49
- * emitter is created.
50
- */
51
- let emitEvent = () => {};
52
- let currentBuildId = "";
53
- /**
54
- * Inject the runtime-bound emitter into the build-stages module.
55
- * Call this right after `makeRuntimeEmitter` in plugin.ts.
56
- */
57
- function setBuildStagesEventEmitter(fn, buildId = "") {
58
- emitEvent = fn;
59
- currentBuildId = buildId;
60
- }
61
- /**
62
46
  * Prepare the flat list of WorkItems to process and the cross-link data maps.
63
47
  *
64
48
  * This function:
@@ -82,8 +66,8 @@ function prepareWorkItems(input) {
82
66
  resolvedLookup.set(key, resolved);
83
67
  }
84
68
  const { items, uncategorized } = ApiItems.categorize(docItems, categories);
85
- for (const skipped of uncategorized) emitEvent(PluginEvent.ItemSkipped({
86
- ctx: { buildId: currentBuildId },
69
+ for (const skipped of uncategorized) emitSync(PluginEvent.ItemSkipped({
70
+ ctx: { buildId: syncBuildId() },
87
71
  item: skipped.displayName,
88
72
  kind: String(skipped.kind),
89
73
  reason: "uncategorized",
@@ -115,9 +99,9 @@ function prepareWorkItems(input) {
115
99
  const collisions = Routes.detectCollisions(candidates);
116
100
  if (collisions.length > 0) {
117
101
  try {
118
- for (const collision of collisions) emitEvent(PluginEvent.RouteCollisionDetected({
102
+ for (const collision of collisions) emitSync(PluginEvent.RouteCollisionDetected({
119
103
  ctx: {
120
- buildId: currentBuildId,
104
+ buildId: syncBuildId(),
121
105
  route: collision.route
122
106
  },
123
107
  level: "error",
@@ -145,13 +129,14 @@ function prepareWorkItems(input) {
145
129
  }
146
130
  if (item.kind === "Class" || item.kind === "Interface") {
147
131
  const itemWithMembers = item;
148
- for (const member of itemWithMembers.members) {
149
- const memberName = member.displayName;
150
- const memberId = Routes.sanitizeId(memberName);
151
- const fullMemberName = `${item.displayName}.${memberName}`;
152
- const memberRoute = `${itemRoute}#${memberId}`;
153
- routes.set(fullMemberName, memberRoute);
154
- kinds.set(fullMemberName, member.kind);
132
+ const anchors = ApiItems.memberAnchors(itemWithMembers);
133
+ const byCanonicalRef = new Map(itemWithMembers.members.map((member) => [member.canonicalReference?.toString() ?? member.displayName, member]));
134
+ for (const [routeKey, memberId] of ApiItems.memberRouteKeys(itemWithMembers)) {
135
+ const member = byCanonicalRef.get(memberId);
136
+ if (!member) continue;
137
+ const anchor = anchors.get(memberId) ?? Routes.memberAnchor(member.displayName);
138
+ routes.set(routeKey, `${itemRoute}#${anchor}`);
139
+ kinds.set(routeKey, member.kind);
155
140
  }
156
141
  }
157
142
  }
@@ -190,12 +175,14 @@ function prepareWorkItems(input) {
190
175
  const lookupKey = `${item.displayName}::${item.kind}`;
191
176
  const resolved = resolvedLookup.get(lookupKey);
192
177
  const syntheticBase = syntheticBases.baseByOwner.get(item);
178
+ const memberAnchors = item.kind === "Class" || item.kind === "Interface" ? ApiItems.memberAnchors(item) : void 0;
193
179
  workItems.push({
194
180
  item,
195
181
  categoryKey,
196
182
  categoryConfig,
197
183
  ...resolved?.availableFrom != null ? { availableFrom: resolved.availableFrom } : {},
198
- ...syntheticBase != null ? { syntheticBase } : {}
184
+ ...syntheticBase != null ? { syntheticBase } : {},
185
+ ...memberAnchors != null ? { memberAnchors } : {}
199
186
  });
200
187
  }
201
188
  }
@@ -240,7 +227,7 @@ function generateSinglePage(workItem, ctx) {
240
227
  switch (item.kind) {
241
228
  case ApiItemKind.Class: {
242
229
  const generator = new ClassPageGenerator();
243
- page = yield* Effect.promise(() => generator.generate(item, baseRoute, packageName, categoryConfig.singularName, apiScope, apiName, source, suppressExampleErrors, llmsPlugin, workItem.availableFrom, workItem.syntheticBase));
230
+ page = yield* Effect.promise(() => generator.generate(item, baseRoute, packageName, categoryConfig.singularName, apiScope, apiName, source, suppressExampleErrors, llmsPlugin, workItem.availableFrom, workItem.syntheticBase, workItem.memberAnchors));
244
231
  page = {
245
232
  routePath: page.routePath.replace("/class/", `/${categoryConfig.folderName}/`),
246
233
  content: page.content
@@ -402,7 +389,7 @@ function generateSinglePage(workItem, ctx) {
402
389
  function writeSingleFile(result, ctx) {
403
390
  return Effect.gen(function* () {
404
391
  const fileSystem = yield* FileSystem.FileSystem;
405
- const { buildId, resolvedOutputDir, buildTime, ogResolver, siteUrl, ogImage, packageName, apiName } = ctx;
392
+ const { buildId, resolvedOutputDir, buildTime, siteUrl, docsRoot, ogImage, packageName, apiName } = ctx;
406
393
  const { workItem, bodyContent, frontmatter, contentHash, frontmatterHash, publishedTime, modifiedTime, isUnchanged, routePath, relativePathWithExt } = result;
407
394
  const { item, categoryKey, categoryConfig, namespaceMember } = workItem;
408
395
  const absolutePath = path.join(resolvedOutputDir, relativePathWithExt);
@@ -440,8 +427,29 @@ function writeSingleFile(result, ctx) {
440
427
  };
441
428
  }
442
429
  let finalContent = stringifyFrontmatter(bodyContent, frontmatter);
443
- if (ogResolver && siteUrl && packageName) {
444
- const ogImageMetadata = yield* Effect.promise(() => ogResolver.resolve(ogImage, packageName, apiName));
430
+ if (siteUrl && packageName) {
431
+ const ogSvc = yield* OgService;
432
+ const ogImageResult = yield* Effect.result(ogSvc.resolveImage({
433
+ config: ogImage,
434
+ siteUrl,
435
+ docsRoot,
436
+ packageName,
437
+ ...apiName != null ? { apiName } : {}
438
+ }));
439
+ if (ogImageResult._tag === "Failure") {
440
+ const failure = ogImageResult.failure;
441
+ yield* emit(PluginEvent.ConfigValidationWarning({
442
+ ctx: {
443
+ buildId,
444
+ packageName
445
+ },
446
+ field: failure.field,
447
+ value: failure.value,
448
+ reason: failure.message,
449
+ level: "warn"
450
+ }));
451
+ }
452
+ const ogImageMetadata = ogImageResult._tag === "Success" && Option.isSome(ogImageResult.success) ? ogImageResult.success.value : void 0;
445
453
  const ogMetadataOptions = {
446
454
  siteUrl,
447
455
  pageRoute: routePath,
@@ -452,7 +460,7 @@ function writeSingleFile(result, ctx) {
452
460
  packageName
453
461
  };
454
462
  if (ogImageMetadata) ogMetadataOptions.ogImage = ogImageMetadata;
455
- const ogMetadata = OpenGraphResolver.createPageMetadata(ogMetadataOptions);
463
+ const ogMetadata = createPageMetadata(ogMetadataOptions);
456
464
  finalContent = generateFrontmatter(item.displayName, frontmatter.description, categoryConfig.singularName, apiName, ogMetadata) + bodyContent;
457
465
  }
458
466
  const fileExisted = yield* fileSystem.exists(absolutePath).pipe(Effect.orElseSucceed(() => false));
@@ -793,7 +801,7 @@ function buildPipelineForApi(input) {
793
801
  buildId: input.buildId,
794
802
  resolvedOutputDir: input.resolvedOutputDir,
795
803
  buildTime: input.buildTime,
796
- ...input.ogResolver !== void 0 ? { ogResolver: input.ogResolver } : {},
804
+ ...input.docsRoot !== void 0 ? { docsRoot: input.docsRoot } : {},
797
805
  ...input.siteUrl != null ? { siteUrl: input.siteUrl } : {},
798
806
  ...input.ogImage != null ? { ogImage: input.ogImage } : {},
799
807
  ...input.packageName != null ? { packageName: input.packageName } : {},
@@ -803,4 +811,4 @@ function buildPipelineForApi(input) {
803
811
  }
804
812
 
805
813
  //#endregion
806
- export { buildPipelineForApi, cleanupAndCommit, crossLinkKindPriority, generateSinglePage, prepareWorkItems, setBuildStagesEventEmitter, writeMetadata, writeSingleFile };
814
+ export { buildPipelineForApi, cleanupAndCommit, crossLinkKindPriority, generateSinglePage, prepareWorkItems, writeMetadata, writeSingleFile };
package/errors.js CHANGED
@@ -8,7 +8,6 @@ var ConfigValidationError = class extends ConfigValidationErrorBase {
8
8
  }
9
9
  };
10
10
  const ApiModelLoadErrorBase = Data.TaggedError("ApiModelLoadError");
11
- const PathDerivationErrorBase = Data.TaggedError("PathDerivationError");
12
11
  const TypeRegistryErrorBase = Data.TaggedError("TypeRegistryError");
13
12
  var TypeRegistryError = class extends TypeRegistryErrorBase {
14
13
  get message() {