rspress-plugin-api-extractor 0.6.4 → 0.7.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/build-stages.js CHANGED
@@ -16,7 +16,7 @@ import { VariablePageGenerator } from "./markdown/page-generators/variable-page.
16
16
  import "./markdown/index.js";
17
17
  import { resolveEntryPoints } from "./multi-entry-resolver.js";
18
18
  import { OpenGraphResolver } from "./og-resolver.js";
19
- import { assertNoRouteCollisions } from "./route-collisions.js";
19
+ import { detectRouteCollisions, formatRouteCollisionError } from "./route-collisions.js";
20
20
  import { SnapshotService } from "./services/SnapshotService.js";
21
21
  import { BASE_CLASS_ANCHOR, detectSyntheticBases } from "./synthetic-bases.js";
22
22
  import path from "node:path";
@@ -45,6 +45,24 @@ function crossLinkKindPriority(kind) {
45
45
  return CROSS_LINK_KIND_PRIORITY[kind] ?? 100;
46
46
  }
47
47
  /**
48
+ * Module-level emitter seam. `prepareWorkItems` runs synchronously outside any
49
+ * Effect fiber, so a route collision cannot `yield* emit(...)` — it mirrors the
50
+ * sync-island pattern used by `twoslash-transformer.ts` (`setEventEmitter`) and
51
+ * `loader.ts` (`setLoaderEventEmitter`). Default is a no-op; wired in plugin.ts
52
+ * via `setBuildStagesEventEmitter(emitSync, buildId)` right after the runtime
53
+ * emitter is created.
54
+ */
55
+ let emitEvent = () => {};
56
+ let currentBuildId = "";
57
+ /**
58
+ * Inject the runtime-bound emitter into the build-stages module.
59
+ * Call this right after `makeRuntimeEmitter` in plugin.ts.
60
+ */
61
+ function setBuildStagesEventEmitter(fn, buildId = "") {
62
+ emitEvent = fn;
63
+ currentBuildId = buildId;
64
+ }
65
+ /**
48
66
  * Sanitize a display name to create a valid HTML ID.
49
67
  * Mirrors the logic in MarkdownCrossLinker.sanitizeId().
50
68
  */
@@ -98,7 +116,20 @@ function prepareWorkItems(input) {
98
116
  canonicalRef: nsMember.item.canonicalReference?.toString() ?? nsMember.qualifiedName
99
117
  });
100
118
  }
101
- assertNoRouteCollisions(candidates, baseRoute);
119
+ const collisions = detectRouteCollisions(candidates);
120
+ if (collisions.length > 0) {
121
+ try {
122
+ for (const collision of collisions) emitEvent(PluginEvent.RouteCollisionDetected({
123
+ ctx: {
124
+ buildId: currentBuildId,
125
+ route: collision.route
126
+ },
127
+ level: "error",
128
+ items: collision.items.map((item) => `${item.displayName} (${item.kind}) [${item.canonicalRef}]`)
129
+ }));
130
+ } catch {}
131
+ throw new Error(formatRouteCollisionError(collisions, baseRoute));
132
+ }
102
133
  const routes = /* @__PURE__ */ new Map();
103
134
  const kinds = /* @__PURE__ */ new Map();
104
135
  const routeOwnerPriority = /* @__PURE__ */ new Map();
@@ -772,4 +803,4 @@ function buildPipelineForApi(input) {
772
803
  }
773
804
 
774
805
  //#endregion
775
- export { buildPipelineForApi, cleanupAndCommit, crossLinkKindPriority, generateSinglePage, normalizeMarkdownSpacing, prepareWorkItems, writeMetadata, writeSingleFile };
806
+ export { buildPipelineForApi, cleanupAndCommit, crossLinkKindPriority, generateSinglePage, normalizeMarkdownSpacing, prepareWorkItems, setBuildStagesEventEmitter, writeMetadata, writeSingleFile };
package/index.d.ts CHANGED
@@ -710,6 +710,7 @@ declare const PluginOptions: Schema.Struct<{
710
710
  readonly observability: Schema.optional<Schema.Struct<{
711
711
  readonly logLevel: Schema.optional<Schema.Literals<readonly ["none", "error", "warn", "info", "debug", "trace", "verbose"]>>;
712
712
  readonly trace: Schema.optional<Schema.Union<readonly [Schema.Boolean, Schema.String]>>;
713
+ readonly progressInterval: Schema.optional<Schema.Union<readonly [Schema.Number, Schema.Boolean]>>;
713
714
  readonly thresholds: Schema.optional<Schema.Struct<{
714
715
  readonly slowCodeBlock: Schema.withDecodingDefault<Schema.Number, never>;
715
716
  readonly slowPageGeneration: Schema.withDecodingDefault<Schema.Number, never>;
@@ -1,6 +1,7 @@
1
1
  import { makeEventBusLayer } from "../observability/EventBus.js";
2
- import { makeConsoleSink } from "../observability/sinks/console-sink.js";
3
2
  import { BuildMetrics } from "./build-metrics.js";
3
+ import { makeConsoleSink } from "../observability/sinks/console-sink.js";
4
+ import { makeIssuesSink } from "../observability/sinks/issues-sink.js";
4
5
  import { makeMetricsSink } from "../observability/sinks/metrics-sink.js";
5
6
  import { makeTraceSink } from "../observability/sinks/trace-sink.js";
6
7
  import { Effect, Layer, Logger, Metric, References } from "effect";
@@ -28,24 +29,25 @@ function makeSummaryLoggerLayer(logLevel) {
28
29
  return Layer.mergeAll(Logger.layer([pluginLogger]), Layer.succeed(References.MinimumLogLevel, effectLevel));
29
30
  }
30
31
  /**
31
- * Compose the console + metrics (+ optional trace) sinks into an EventBus layer.
32
- *
33
- * When `traceIsDefault` is true the trace path was derived from the guessed
34
- * outDir at factory time. In that case we create the sink in deferred mode
35
- * (no `initialPath`) so no stray empty file is written to the guessed path;
36
- * `plugin.ts` must call `trace.setPath(realPath)` in the `config()` hook once
37
- * the real RSPress `outDir` is known.
32
+ * Compose the console + metrics + issues (+ optional trace) sinks into an EventBus layer.
38
33
  *
39
- * When `traceIsDefault` is false the caller supplied an explicit path string,
40
- * so we open the file eagerly (existing behaviour).
34
+ * `cwd` is known at plugin-factory time (unlike the RSPress `outDir`), so the
35
+ * trace path is resolved eagerly by `resolveObservability` and the trace sink
36
+ * opens its file immediately — no deferred re-binding is needed.
41
37
  */
42
- function buildEventBus(obs, traceIsDefault = false) {
43
- const sinks = [makeConsoleSink(obs.logLevel, { json: obs.json }), makeMetricsSink()];
44
- const trace = obs.tracePath ? makeTraceSink(traceIsDefault ? void 0 : obs.tracePath) : null;
38
+ function buildEventBus(obs) {
39
+ const issues = makeIssuesSink();
40
+ const sinks = [
41
+ makeConsoleSink(obs.logLevel, { json: obs.json }),
42
+ makeMetricsSink(),
43
+ issues
44
+ ];
45
+ const trace = obs.tracePath ? makeTraceSink(obs.tracePath) : null;
45
46
  if (trace) sinks.push(trace);
46
47
  return {
47
48
  layer: makeEventBusLayer(sinks),
48
- trace
49
+ trace,
50
+ issues
49
51
  };
50
52
  }
51
53
  /**
@@ -39,6 +39,7 @@ const BuildMetrics = {
39
39
  twoslashErrors: Metric.counter("twoslash.errors"),
40
40
  prettierErrors: Metric.counter("prettier.errors"),
41
41
  pagesGenerated: Metric.counter("pages.generated"),
42
+ apisCompleted: Metric.counter("apis.completed"),
42
43
  apiVersionsLoaded: Metric.counter("api.versions.loaded"),
43
44
  externalPackagesTotal: Metric.counter("external.packages.total"),
44
45
  phaseDuration: Metric.histogram("phase.duration", { boundaries: [
package/model-loader.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { PluginEvent } from "./observability/events.js";
1
2
  import { isLoadedModel, isVersionConfig } from "./config-utils.js";
2
3
  import fs from "node:fs";
3
4
  import path from "node:path";
@@ -5,6 +6,26 @@ import { loadApiModel } from "api-extractor-llms";
5
6
 
6
7
  //#region src/model-loader.ts
7
8
  /**
9
+ * Module-level emitter seam. `loadFromPath` is called from inside an
10
+ * `Effect.promise(async () => {...})` body in `ConfigServiceLive.ts`, so a
11
+ * load failure cannot `yield* emit(...)` — it mirrors the sync-island pattern
12
+ * used by `twoslash-transformer.ts` (`setEventEmitter`) and `loader.ts`
13
+ * (`setLoaderEventEmitter`, a DIFFERENT module — the ApiParser/TSDoc statics,
14
+ * not this one). Default is a no-op; wired in plugin.ts via
15
+ * `setModelLoaderEventEmitter(emitSync, buildId)` right after the runtime
16
+ * emitter is created.
17
+ */
18
+ let emitEvent = () => {};
19
+ let currentBuildId = "";
20
+ /**
21
+ * Inject the runtime-bound emitter into the model-loader module.
22
+ * Call this right after `makeRuntimeEmitter` in plugin.ts.
23
+ */
24
+ function setModelLoaderEventEmitter(fn, buildId = "") {
25
+ emitEvent = fn;
26
+ currentBuildId = buildId;
27
+ }
28
+ /**
8
29
  * Utility class for loading API models from various sources
9
30
  */
10
31
  var ApiModelLoader = class ApiModelLoader {
@@ -17,8 +38,20 @@ var ApiModelLoader = class ApiModelLoader {
17
38
  */
18
39
  static async loadFromPath(modelPath) {
19
40
  const resolvedPath = path.resolve(modelPath.toString());
20
- if (!fs.existsSync(resolvedPath)) throw new Error(`API model file not found: ${resolvedPath}`);
21
- return loadApiModel(resolvedPath);
41
+ try {
42
+ if (!fs.existsSync(resolvedPath)) throw new Error(`API model file not found: ${resolvedPath}`);
43
+ return await loadApiModel(resolvedPath);
44
+ } catch (error) {
45
+ try {
46
+ emitEvent(PluginEvent.ModelLoadFailed({
47
+ ctx: { buildId: currentBuildId },
48
+ level: "error",
49
+ modelPath: resolvedPath,
50
+ reason: error instanceof Error ? error.message : String(error)
51
+ }));
52
+ } catch {}
53
+ throw error;
54
+ }
22
55
  }
23
56
  /**
24
57
  * Load package.json from a path (string, URL, or Buffer)
@@ -92,4 +125,4 @@ var ApiModelLoader = class ApiModelLoader {
92
125
  };
93
126
 
94
127
  //#endregion
95
- export { ApiModelLoader };
128
+ export { ApiModelLoader, setModelLoaderEventEmitter };
@@ -0,0 +1,73 @@
1
+ import { PluginEvent } from "./events.js";
2
+ import { emit } from "./EventBus.js";
3
+ import { BuildMetrics } from "../layers/build-metrics.js";
4
+ import { Duration, Effect, Metric, Ref } from "effect";
5
+
6
+ //#region src/observability/heartbeat.ts
7
+ /** Build a `BuildProgress` event from the current + previous metric snapshot. */
8
+ function makeProgressEvent(args) {
9
+ const { phase, curr, prev } = args;
10
+ const delta = phase === "resolve" ? curr.vfsFiles - prev.vfsFiles : curr.pages - prev.pages;
11
+ return PluginEvent.BuildProgress({
12
+ ctx: { buildId: args.buildId },
13
+ level: "info",
14
+ phase,
15
+ elapsedMs: args.elapsedMs,
16
+ vfsFiles: curr.vfsFiles,
17
+ externalPackages: curr.externalPackages,
18
+ apisCompleted: curr.apisCompleted,
19
+ apisTotal: args.apisTotal,
20
+ pages: curr.pages,
21
+ codeBlocks: curr.codeBlocks,
22
+ delta
23
+ });
24
+ }
25
+ /** One-line human-readable render of a `BuildProgress` event body (no timestamp prefix). */
26
+ function formatProgress(e) {
27
+ const secs = `${Math.round(e.elapsedMs / 1e3)}s`;
28
+ if (e.phase === "resolve") return `API docs · resolving types · ${e.vfsFiles} files · ${e.externalPackages} pkgs · ${secs} (+${e.delta} files)`;
29
+ return `API docs · ${e.apisCompleted}/${e.apisTotal} APIs · ${e.pages} pages · ${e.codeBlocks} blocks · ${secs} (+${e.delta} pages)`;
30
+ }
31
+ /** Read the five progress counters into a snapshot. */
32
+ const readCounts = Effect.gen(function* () {
33
+ return {
34
+ vfsFiles: (yield* Metric.value(BuildMetrics.vfsFiles)).count,
35
+ externalPackages: (yield* Metric.value(BuildMetrics.externalPackagesTotal)).count,
36
+ apisCompleted: (yield* Metric.value(BuildMetrics.apisCompleted)).count,
37
+ pages: (yield* Metric.value(BuildMetrics.pagesGenerated)).count,
38
+ codeBlocks: (yield* Metric.value(BuildMetrics.codeblockTotal)).count
39
+ };
40
+ });
41
+ /**
42
+ * Sleep-first heartbeat loop: waits `intervalMs`, then reads the metric
43
+ * snapshot and emits a `BuildProgress` event, repeating until the phase Ref
44
+ * reads `"done"` or the fiber is interrupted (scope close). Sleeping first
45
+ * means a build that finishes before the first interval emits nothing.
46
+ */
47
+ function runHeartbeat(opts) {
48
+ const loop = (prev) => Effect.gen(function* () {
49
+ yield* Effect.sleep(Duration.millis(opts.intervalMs));
50
+ const phase = yield* Ref.get(opts.phaseRef);
51
+ if (phase === "done") return;
52
+ const curr = yield* readCounts;
53
+ yield* emit(makeProgressEvent({
54
+ phase,
55
+ buildId: opts.buildId,
56
+ elapsedMs: performance.now() - opts.startTime,
57
+ apisTotal: opts.apisTotal,
58
+ curr,
59
+ prev
60
+ }));
61
+ return yield* loop(curr);
62
+ });
63
+ return loop({
64
+ vfsFiles: 0,
65
+ externalPackages: 0,
66
+ apisCompleted: 0,
67
+ pages: 0,
68
+ codeBlocks: 0
69
+ });
70
+ }
71
+
72
+ //#endregion
73
+ export { formatProgress, makeProgressEvent, readCounts, runHeartbeat };
@@ -1,4 +1,5 @@
1
1
  import { LEVEL_RANK, levelOf } from "../events.js";
2
+ import { formatProgress } from "../heartbeat.js";
2
3
 
3
4
  //#region src/observability/sinks/console-sink.ts
4
5
  function formatTime(date) {
@@ -16,6 +17,7 @@ function render(event) {
16
17
  case "PhaseStarted": return `→ ${event.phase}`;
17
18
  case "PhaseCompleted": return `✓ ${event.phase} (${event.durationMs}ms)`;
18
19
  case "BuildCompleted": return `API documentation complete (${(event.durationMs / 1e3).toFixed(2)}s)`;
20
+ case "BuildProgress": return formatProgress(event);
19
21
  case "BuildFailed": return `Error in ${event.phase}: ${event.error}`;
20
22
  case "SlowOperation": return `slow ${event.operation}: ${event.durationMs}ms (>${event.threshold}ms)`;
21
23
  case "ConfigCascadeWarning": return event.ignored.length > 2 ? `${event.field}: using '${event.chosen}', ignoring ${event.ignored.length} alternatives (first configured value wins)` : `${event.field}: using '${event.chosen}', ignoring ${event.ignored.join(", ")}`;
@@ -0,0 +1,168 @@
1
+ import path from "node:path";
2
+ import { Effect, FileSystem } from "effect";
3
+
4
+ //#region src/observability/sinks/issues-sink.ts
5
+ function withApi(base, api) {
6
+ return api ? {
7
+ ...base,
8
+ api
9
+ } : base;
10
+ }
11
+ /**
12
+ * Map an issue-relevant event to a typed `Issue` and its bucket. Returns null
13
+ * for events that are not build issues.
14
+ *
15
+ * `suppressed[]` is intentionally not produced here: no event in the current
16
+ * stream distinguishes a diagnostic silenced by `suppressExampleErrors` /
17
+ * `@noErrors` from one that surfaced, so the bucket is reserved (always emitted,
18
+ * currently empty) for schema parity with the bundler artifact.
19
+ */
20
+ function eventToIssue(event) {
21
+ switch (event._tag) {
22
+ case "TwoslashDiagnostic": return {
23
+ bucket: "warnings",
24
+ issue: withApi({
25
+ source: "twoslash",
26
+ level: "warn",
27
+ text: event.message,
28
+ code: `TS${event.code}`,
29
+ file: event.file,
30
+ line: event.line,
31
+ column: event.col
32
+ }, event.ctx.packageName)
33
+ };
34
+ case "TwoslashCheckFailed": return {
35
+ bucket: "warnings",
36
+ issue: withApi({
37
+ source: "twoslash",
38
+ level: "warn",
39
+ text: `Twoslash check failed (TS${event.code})`,
40
+ code: `TS${event.code}`,
41
+ file: event.file,
42
+ line: 0,
43
+ column: 0
44
+ }, event.ctx.packageName)
45
+ };
46
+ case "PrettierError": return {
47
+ bucket: "warnings",
48
+ issue: withApi({
49
+ source: "prettier",
50
+ level: "warn",
51
+ text: event.reason,
52
+ code: "prettier",
53
+ file: event.file,
54
+ line: 0,
55
+ column: 0
56
+ }, event.ctx.packageName)
57
+ };
58
+ case "ShikiError": return {
59
+ bucket: "warnings",
60
+ issue: withApi({
61
+ source: "shiki",
62
+ level: "warn",
63
+ text: event.reason,
64
+ code: "shiki",
65
+ file: event.file,
66
+ line: 0,
67
+ column: 0
68
+ }, event.ctx.packageName)
69
+ };
70
+ case "ConfigValidationWarning": return {
71
+ bucket: "warnings",
72
+ issue: withApi({
73
+ source: "config",
74
+ level: "warn",
75
+ text: `${event.field}: ${event.value}${event.reason ? ` — ${event.reason}` : ""}`,
76
+ code: "config-validation",
77
+ file: event.ctx.file ?? "",
78
+ line: 0,
79
+ column: 0
80
+ }, event.ctx.packageName)
81
+ };
82
+ case "RouteCollisionDetected": return {
83
+ bucket: "errors",
84
+ issue: withApi({
85
+ source: "routing",
86
+ level: "error",
87
+ text: `Route collision between: ${event.items.join(", ")}`,
88
+ code: "route-collision",
89
+ file: event.ctx.file ?? "",
90
+ line: 0,
91
+ column: 0
92
+ }, event.ctx.packageName)
93
+ };
94
+ case "ModelLoadFailed": return {
95
+ bucket: "errors",
96
+ issue: withApi({
97
+ source: "model",
98
+ level: "error",
99
+ text: event.reason,
100
+ code: "model-load-failed",
101
+ file: event.modelPath,
102
+ line: 0,
103
+ column: 0
104
+ }, event.ctx.packageName)
105
+ };
106
+ case "BuildFailed": return {
107
+ bucket: "errors",
108
+ issue: withApi({
109
+ source: "build",
110
+ level: "error",
111
+ text: event.error,
112
+ code: "build-failed",
113
+ file: "",
114
+ line: 0,
115
+ column: 0
116
+ }, event.ctx.packageName)
117
+ };
118
+ default: return null;
119
+ }
120
+ }
121
+ /**
122
+ * Collector sink: accumulates issue events into in-memory buckets. Always-on
123
+ * (collection is cheap); the write is gated by `isProd` in `afterBuild`.
124
+ */
125
+ function makeIssuesSink() {
126
+ const warnings = [];
127
+ const errors = [];
128
+ const suppressed = [];
129
+ return {
130
+ minLevel: "trace",
131
+ handle(event) {
132
+ const mapped = eventToIssue(event);
133
+ if (!mapped) return;
134
+ if (mapped.bucket === "warnings") warnings.push(mapped.issue);
135
+ else errors.push(mapped.issue);
136
+ },
137
+ snapshot: () => ({
138
+ warnings: [...warnings],
139
+ errors: [...errors],
140
+ suppressed: [...suppressed]
141
+ }),
142
+ reset: () => {
143
+ warnings.length = 0;
144
+ errors.length = 0;
145
+ suppressed.length = 0;
146
+ }
147
+ };
148
+ }
149
+ /** Serialize an issues snapshot to `<cwd>/.api-docs/build/issues.json` (bundler schema). */
150
+ function writeIssuesJson(snapshot, opts) {
151
+ return Effect.gen(function* () {
152
+ const fs = yield* FileSystem.FileSystem;
153
+ const dir = path.join(opts.cwd, ".api-docs", "build");
154
+ yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.ignore);
155
+ const doc = {
156
+ generatedAt: opts.generatedAt,
157
+ package: opts.packageName,
158
+ target: "prod",
159
+ warnings: snapshot.warnings,
160
+ errors: snapshot.errors,
161
+ suppressed: snapshot.suppressed
162
+ };
163
+ yield* fs.writeFileString(path.join(dir, "issues.json"), `${JSON.stringify(doc, null, 2)}\n`).pipe(Effect.ignore);
164
+ });
165
+ }
166
+
167
+ //#endregion
168
+ export { eventToIssue, makeIssuesSink, writeIssuesJson };
@@ -33,6 +33,9 @@ function makeMetricsSink() {
33
33
  case "PageGenerated":
34
34
  Effect.runSync(Metric.update(BuildMetrics.pagesGenerated, 1));
35
35
  break;
36
+ case "ApiDocsCompleted":
37
+ Effect.runSync(Metric.update(BuildMetrics.apisCompleted, 1));
38
+ break;
36
39
  case "TwoslashDiagnostic":
37
40
  Effect.runSync(Metric.update(BuildMetrics.twoslashDiagnostics, 1));
38
41
  Effect.runSync(Metric.update(BuildMetrics.twoslashErrors, 1));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rspress-plugin-api-extractor",
3
- "version": "0.6.4",
3
+ "version": "0.7.0",
4
4
  "private": false,
5
5
  "description": "RSPress plugin for generating API documentation from TypeScript API Extractor models",
6
6
  "keywords": [
package/plugin.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import { PluginEvent } from "./observability/events.js";
2
2
  import { emit, makeRuntimeEmitter } from "./observability/EventBus.js";
3
+ import { runHeartbeat } from "./observability/heartbeat.js";
4
+ import { writeIssuesJson } from "./observability/sinks/issues-sink.js";
3
5
  import { buildEventBus, logBuildSummary, makeSummaryLoggerLayer } from "./layers/ObservabilityLive.js";
4
6
  import { setLoaderEventEmitter } from "./loader.js";
5
7
  import { setPrettierEventEmitter } from "./prettier-formatter.js";
6
8
  import { setOgResolverEventEmitter } from "./og-resolver.js";
9
+ import { setBuildStagesEventEmitter } from "./build-stages.js";
7
10
  import { TwoslashManager, setEventEmitter } from "./twoslash-transformer.js";
8
11
  import { VfsRegistry } from "./vfs-registry.js";
9
12
  import { generateApiDocs } from "./build-program.js";
@@ -11,6 +14,7 @@ import { deriveOutputPaths, normalizeBaseRoute, unscopedName } from "./path-deri
11
14
  import { fromDir, fromParentDir } from "./config-helpers.js";
12
15
  import { mergeLlmsPluginConfig } from "./config-utils.js";
13
16
  import { DEFAULT_SHIKI_THEMES, setShikiUtilsEventEmitter } from "./markdown/shiki-utils.js";
17
+ import { setModelLoaderEventEmitter } from "./model-loader.js";
14
18
  import { resolveObservability } from "./schemas/observability.js";
15
19
  import { PluginOptions } from "./schemas/config.js";
16
20
  import "./schemas/index.js";
@@ -27,11 +31,26 @@ import fs from "node:fs";
27
31
  import path from "node:path";
28
32
  import { fileURLToPath } from "node:url";
29
33
  import { NodeFileSystem } from "@effect/platform-node";
30
- import { Effect, Layer, ManagedRuntime, Schema } from "effect";
34
+ import { Effect, FileSystem, Layer, ManagedRuntime, Ref, Schema } from "effect";
31
35
 
32
36
  //#region src/plugin.ts
33
37
  /* v8 ignore start -- RSPress plugin adapter, requires RSPress runtime */
34
38
  /**
39
+ * Best-effort read of the consuming site's `package.json` `name`, used to tag
40
+ * the `.api-docs/build/issues.json` artifact. Falls back to "unknown" when the file
41
+ * is missing or unreadable — never fails.
42
+ */
43
+ const readSitePackageName = Effect.gen(function* () {
44
+ const fileSystem = yield* FileSystem.FileSystem;
45
+ const pkgJsonPath = path.resolve(process.cwd(), "package.json");
46
+ const content = yield* fileSystem.readFileString(pkgJsonPath).pipe(Effect.orElseSucceed(() => ""));
47
+ try {
48
+ const parsed = JSON.parse(content);
49
+ if (parsed && typeof parsed === "object" && "name" in parsed && typeof parsed.name === "string") return parsed.name;
50
+ } catch {}
51
+ return "unknown";
52
+ });
53
+ /**
35
54
  * Normalize theme configuration from user input to a consistent format.
36
55
  */
37
56
  function normalizeThemeConfig(theme) {
@@ -57,18 +76,17 @@ function ApiExtractorPluginImpl(rawOptions) {
57
76
  const shikiCrossLinker = new ShikiCrossLinker();
58
77
  const envLogLevel = process.env.LOG_LEVEL?.toLowerCase();
59
78
  const buildId = `${process.pid}-${performance.now().toString(36)}`;
60
- const rspressOutDirGuess = path.resolve(process.cwd(), "dist");
61
79
  const { resolved: obs, deprecations } = resolveObservability({
62
80
  ...options.observability ? { observability: options.observability } : {},
63
81
  ...options.logLevel ? { logLevel: options.logLevel } : {},
64
82
  ...options.performance ? { performance: { ...options.performance.thresholds !== void 0 ? { thresholds: options.performance.thresholds } : {} } } : {},
65
83
  ...envLogLevel ? { envLogLevel } : {},
66
- outDir: rspressOutDirGuess,
84
+ cwd: process.cwd(),
67
85
  buildId
68
86
  });
69
- const traceIsDefault = options.observability?.trace === true;
70
- const { layer: eventBusLayer, trace: traceSink } = buildEventBus(obs, traceIsDefault);
71
- const dbPath = path.resolve(process.cwd(), "api-docs-snapshot.db");
87
+ const { layer: eventBusLayer, trace: traceSink, issues: issuesSink } = buildEventBus(obs);
88
+ const dbPath = path.resolve(process.cwd(), ".api-docs", "snapshot", "api-docs.db");
89
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true });
72
90
  const BaseLayer = Layer.mergeAll(PathDerivationServiceLive, eventBusLayer, TypeRegistryServiceLive, NodeFileSystem.layer, SnapshotServiceLive(dbPath), makeSummaryLoggerLayer(obs.logLevel));
73
91
  const EffectAppLayer = Layer.provideMerge(ConfigServiceLive(options, shikiCrossLinker, buildId, obs.thresholds), BaseLayer);
74
92
  const effectRuntime = ManagedRuntime.make(EffectAppLayer);
@@ -80,6 +98,8 @@ function ApiExtractorPluginImpl(rawOptions) {
80
98
  setOgResolverEventEmitter(emitSync, buildId);
81
99
  setRemarkWithApiEventEmitter(emitSync, buildId, obs.thresholds.slowCodeBlock);
82
100
  setRemarkApiCodeblocksEventEmitter(emitSync, buildId);
101
+ setBuildStagesEventEmitter(emitSync, buildId);
102
+ setModelLoaderEventEmitter(emitSync, buildId);
83
103
  const fileContextMap = /* @__PURE__ */ new Map();
84
104
  let docsRoot;
85
105
  let isFirstBuild = true;
@@ -94,6 +114,14 @@ function ApiExtractorPluginImpl(rawOptions) {
94
114
  async afterBuild(_config, isProd) {
95
115
  if (isFirstBuild) {
96
116
  await effectRuntime.runPromise(logBuildSummary(obs.thresholds.slowCodeBlock));
117
+ if (isProd) await effectRuntime.runPromise(Effect.gen(function* () {
118
+ const packageName = yield* readSitePackageName;
119
+ yield* writeIssuesJson(issuesSink.snapshot(), {
120
+ cwd: process.cwd(),
121
+ packageName,
122
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString()
123
+ });
124
+ }));
97
125
  if (rspressLlmsEnabled && resolvedLlmsPlugin.enabled) {
98
126
  const { processLlmsFiles } = await import("./llms-program.js");
99
127
  await effectRuntime.runPromise(processLlmsFiles({
@@ -109,7 +137,7 @@ function ApiExtractorPluginImpl(rawOptions) {
109
137
  if (traceSink) traceSink.flush();
110
138
  if (isProd) await effectRuntime.dispose();
111
139
  },
112
- async config(_config) {
140
+ async config(_config, _utils, isProd) {
113
141
  const buildStartTime = performance.now();
114
142
  if (_config.root) docsRoot = path.isAbsolute(_config.root) ? _config.root : path.resolve(process.cwd(), _config.root);
115
143
  const rspressRoot = docsRoot || process.cwd();
@@ -118,10 +146,6 @@ function ApiExtractorPluginImpl(rawOptions) {
118
146
  const rspressMultiVersion = _config.multiVersion;
119
147
  rspressLlmsEnabled = Boolean(_config.llms);
120
148
  rspressOutDir = _config.outDir ?? "dist";
121
- if (traceIsDefault && traceSink) {
122
- const realTracePath = path.resolve(process.cwd(), rspressOutDir, ".api-extractor", `trace-${buildId}.jsonl`);
123
- traceSink.setPath(realTracePath);
124
- }
125
149
  if (options.api) {
126
150
  const api = options.api;
127
151
  const baseRoute = normalizeBaseRoute(api.baseRoute ?? "/");
@@ -152,6 +176,7 @@ function ApiExtractorPluginImpl(rawOptions) {
152
176
  }
153
177
  VfsRegistry.clear();
154
178
  fileContextMap.clear();
179
+ issuesSink.reset();
155
180
  for (const dep of deprecations) emitSync(PluginEvent.DeprecatedConfigUsed({
156
181
  ctx: { buildId },
157
182
  level: "warn",
@@ -173,15 +198,29 @@ function ApiExtractorPluginImpl(rawOptions) {
173
198
  mode: "prod",
174
199
  apiCount
175
200
  }));
201
+ const phaseRef = yield* Ref.make("resolve");
202
+ if (isProd && obs.progressIntervalMs !== null) yield* Effect.forkScoped(runHeartbeat({
203
+ phaseRef,
204
+ intervalMs: obs.progressIntervalMs,
205
+ startTime: buildStartTime,
206
+ apisTotal: apiCount,
207
+ buildId
208
+ }));
176
209
  const buildContext = yield* (yield* ConfigService).resolve(rspressConfigSubset);
177
210
  buildResults.length = 0;
211
+ yield* Ref.set(phaseRef, "generate");
178
212
  yield* Effect.forEach(buildContext.apiConfigs, (apiConfig) => generateApiDocs({
179
213
  ...apiConfig,
180
214
  suppressExampleErrors: buildContext.suppressExampleErrors
181
215
  }, buildContext, fileContextMap).pipe(Effect.tap((result) => {
182
216
  buildResults.push(result);
183
- return Effect.void;
217
+ return emit(PluginEvent.ApiDocsCompleted({
218
+ ctx: { buildId },
219
+ level: "debug",
220
+ packageName: result.packageName
221
+ }));
184
222
  })), { concurrency: 2 });
223
+ yield* Ref.set(phaseRef, "done");
185
224
  const totalMs = performance.now() - buildStartTime;
186
225
  yield* emit(PluginEvent.BuildCompleted({
187
226
  ctx: { buildId },
@@ -199,6 +238,16 @@ function ApiExtractorPluginImpl(rawOptions) {
199
238
  error: message
200
239
  }));
201
240
  if (traceSink) traceSink.flush();
241
+ if (isProd) try {
242
+ await effectRuntime.runPromise(Effect.gen(function* () {
243
+ const packageName = yield* readSitePackageName;
244
+ yield* writeIssuesJson(issuesSink.snapshot(), {
245
+ cwd: process.cwd(),
246
+ packageName,
247
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString()
248
+ });
249
+ }));
250
+ } catch {}
202
251
  throw error;
203
252
  }
204
253
  const updatedConfig = { ..._config };
@@ -39,14 +39,6 @@ function formatRouteCollisionError(collisions, baseRoute) {
39
39
  lines.push("Item names must be unique per category folder. Paths are lowercased, so names differing only in case collide. Rename one of the items, or configure categories so they map to different folders.");
40
40
  return `[rspress-plugin-api-extractor] ${lines.join("\n")}`;
41
41
  }
42
- /**
43
- * Throw a descriptive error if any distinct items resolve to the same route.
44
- * Called at build time before pages are generated, so collisions fail fast.
45
- */
46
- function assertNoRouteCollisions(candidates, baseRoute) {
47
- const collisions = detectRouteCollisions(candidates);
48
- if (collisions.length > 0) throw new Error(formatRouteCollisionError(collisions, baseRoute));
49
- }
50
42
 
51
43
  //#endregion
52
- export { assertNoRouteCollisions, detectRouteCollisions, formatRouteCollisionError };
44
+ export { detectRouteCollisions, formatRouteCollisionError };
@@ -14,6 +14,7 @@ const EventLevelSchema = Schema.Literals([
14
14
  const ObservabilityConfig = Schema.Struct({
15
15
  logLevel: Schema.optional(EventLevelSchema),
16
16
  trace: Schema.optional(Schema.Union([Schema.Boolean, Schema.String])),
17
+ progressInterval: Schema.optional(Schema.Union([Schema.Number, Schema.Boolean])),
17
18
  thresholds: Schema.optional(PerformanceThresholds)
18
19
  });
19
20
  const DEFAULT_THRESHOLDS = {
@@ -41,7 +42,7 @@ function resolveObservability(input) {
41
42
  });
42
43
  const level = normalizeLevel(input.envLogLevel) ?? normalizeLevel(input.observability?.logLevel) ?? normalizeLevel(input.logLevel) ?? "info";
43
44
  const traceOpt = input.observability?.trace;
44
- const tracePath = typeof traceOpt === "string" ? traceOpt : traceOpt === true ? `${input.outDir}/.api-extractor/trace-${input.buildId}.jsonl` : null;
45
+ const tracePath = typeof traceOpt === "string" ? traceOpt : traceOpt === true ? `${input.cwd}/.api-docs/build/trace-${input.buildId}.jsonl` : null;
45
46
  const merged = {
46
47
  ...DEFAULT_THRESHOLDS,
47
48
  ...input.performance?.thresholds ?? {},
@@ -55,11 +56,15 @@ function resolveObservability(input) {
55
56
  slowHttpRequest: merged.slowHttpRequest ?? DEFAULT_THRESHOLDS.slowHttpRequest,
56
57
  slowDbOperation: merged.slowDbOperation ?? DEFAULT_THRESHOLDS.slowDbOperation
57
58
  };
59
+ const pi = input.observability?.progressInterval;
60
+ const seconds = pi === false ? null : typeof pi === "number" ? pi : 10;
61
+ const progressIntervalMs = seconds !== null && Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : null;
58
62
  return {
59
63
  resolved: {
60
64
  logLevel: level,
61
65
  json: level === "debug",
62
66
  tracePath,
67
+ progressIntervalMs,
63
68
  thresholds
64
69
  },
65
70
  deprecations