rspress-plugin-api-extractor 0.6.3 → 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 +34 -3
- package/index.d.ts +1 -0
- package/layers/ObservabilityLive.js +16 -14
- package/layers/build-metrics.js +1 -0
- package/model-loader.js +36 -3
- package/observability/heartbeat.js +73 -0
- package/observability/sinks/console-sink.js +2 -0
- package/observability/sinks/issues-sink.js +168 -0
- package/observability/sinks/metrics-sink.js +3 -0
- package/package.json +2 -2
- package/plugin.js +61 -12
- package/route-collisions.js +1 -9
- package/schemas/observability.js +6 -1
- package/tsdoc-metadata.json +1 -1
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 {
|
|
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
|
-
|
|
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
|
-
*
|
|
40
|
-
*
|
|
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
|
|
43
|
-
const
|
|
44
|
-
const
|
|
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
|
/**
|
package/layers/build-metrics.js
CHANGED
|
@@ -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
|
-
|
|
21
|
-
|
|
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.
|
|
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": [
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"mdast-util-from-markdown": "^2.0.3",
|
|
54
54
|
"mdast-util-to-hast": "^13.2.1",
|
|
55
55
|
"open": "^11.0.0",
|
|
56
|
-
"prettier": "^3.9.
|
|
56
|
+
"prettier": "^3.9.6",
|
|
57
57
|
"react-markdown": "^10.1.0",
|
|
58
58
|
"shiki": "^4.3.1",
|
|
59
59
|
"type-registry-effect": "^2.2.0",
|
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
|
-
|
|
84
|
+
cwd: process.cwd(),
|
|
67
85
|
buildId
|
|
68
86
|
});
|
|
69
|
-
const
|
|
70
|
-
const
|
|
71
|
-
|
|
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
|
|
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 };
|
package/route-collisions.js
CHANGED
|
@@ -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 {
|
|
44
|
+
export { detectRouteCollisions, formatRouteCollisionError };
|
package/schemas/observability.js
CHANGED
|
@@ -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.
|
|
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
|