rspress-plugin-api-extractor 0.9.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/build-program.js +2 -1
- package/build-stages.js +1 -1
- package/index.d.ts +22 -14
- package/layers/ConfigServiceLive.js +59 -16
- package/layers/ObservabilityLive.js +26 -7
- package/layers/TwoslashCacheServiceLive.js +53 -0
- package/layers/build-metrics.js +32 -5
- package/markdown/helpers.js +1 -1
- package/observability/EventBus.js +0 -2
- package/observability/heartbeat.js +1 -1
- package/observability/metric-report.js +124 -0
- package/observability/sinks/console-sink.js +6 -0
- package/observability/sinks/metrics-sink.js +64 -21
- package/observability/sinks/render-sink.js +86 -0
- package/observability/sinks/trace-sink.js +10 -17
- package/observability/spans.js +1 -1
- package/package.json +3 -3
- package/plugin.js +43 -6
- package/remark-api-codeblocks.js +29 -3
- package/remark-with-api.js +13 -6
- package/schemas/config.js +11 -7
- package/services/TwoslashCacheService.js +15 -0
- package/shiki-transformer.js +2 -22
- package/twoslash-cache.js +174 -0
- package/twoslash-patterns.js +1 -1
- package/twoslash-timing-wrapper.js +23 -0
- package/twoslash-transformer.js +51 -10
- package/runtime/components/MarkdownText/index.js +0 -34
package/README.md
CHANGED
|
@@ -47,7 +47,8 @@ The plugin reads your `.api.json` model and writes one MDX page per public API i
|
|
|
47
47
|
## Features
|
|
48
48
|
|
|
49
49
|
- Generates API docs from `.api.json` models for classes, interfaces, functions, type aliases, enums, variables and namespaces.
|
|
50
|
-
- Type-checks code examples and adds Twoslash hover tooltips that show inferred types
|
|
50
|
+
- Type-checks code examples and adds Twoslash hover tooltips that show inferred types, each documented package under its own `tsconfig`.
|
|
51
|
+
- Caches Twoslash results between builds, so repeat builds over an unchanged API render code blocks near-instantly.
|
|
51
52
|
- Cross-links type references between pages, so a type named in a signature links to its own page.
|
|
52
53
|
- Inlines compiler-generated base declarations (the `Foo_base` pattern from Effect `Schema.Class`, `Data.TaggedError` and mixin factories) in a "Base Class" section on the owning class page instead of documenting them as orphan variables.
|
|
53
54
|
- Drives single-package sites, multi-package portals, RSPress multiVersion and i18n from one plugin.
|
package/build-program.js
CHANGED
|
@@ -56,7 +56,8 @@ function generateApiDocs(apiConfig, buildContext, fileContextMap) {
|
|
|
56
56
|
packageName,
|
|
57
57
|
apiScope
|
|
58
58
|
};
|
|
59
|
-
|
|
59
|
+
const scopeTransformer = TwoslashManager.getInstance().getTransformer(apiScope) ?? twoslashTransformer;
|
|
60
|
+
if (scopeTransformer != null) vfsConfig.twoslashTransformer = scopeTransformer;
|
|
60
61
|
if (hideCutTransformer != null) vfsConfig.hideCutTransformer = hideCutTransformer;
|
|
61
62
|
if (hideCutLinesTransformer != null) vfsConfig.hideCutLinesTransformer = hideCutLinesTransformer;
|
|
62
63
|
if (apiConfig.theme != null) vfsConfig.theme = apiConfig.theme;
|
package/build-stages.js
CHANGED
|
@@ -803,4 +803,4 @@ function buildPipelineForApi(input) {
|
|
|
803
803
|
}
|
|
804
804
|
|
|
805
805
|
//#endregion
|
|
806
|
-
export { buildPipelineForApi, cleanupAndCommit, crossLinkKindPriority, generateSinglePage,
|
|
806
|
+
export { buildPipelineForApi, cleanupAndCommit, crossLinkKindPriority, generateSinglePage, prepareWorkItems, setBuildStagesEventEmitter, writeMetadata, writeSingleFile };
|
package/index.d.ts
CHANGED
|
@@ -390,15 +390,19 @@ declare const MultiApiConfig: Schema.Struct<{
|
|
|
390
390
|
* Path to a `tsconfig.json` for Twoslash.
|
|
391
391
|
*
|
|
392
392
|
* @remarks
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
* configured tsconfigs are equivalent, or set the intended one on the
|
|
398
|
-
* first API only.
|
|
393
|
+
* This API's code blocks are type-checked under this config. APIs that
|
|
394
|
+
* declare the same config share one TypeScript environment; the file set
|
|
395
|
+
* is shared across all documented APIs either way, so a type owned by
|
|
396
|
+
* another documented package still resolves.
|
|
399
397
|
*/
|
|
400
398
|
readonly tsconfig: Schema.optional<Schema.declare<string | URL | ((...args: Array<unknown>) => unknown), string | URL | ((...args: Array<unknown>) => unknown)>>;
|
|
401
|
-
/**
|
|
399
|
+
/**
|
|
400
|
+
* TypeScript compiler options for Twoslash, applying to this API only.
|
|
401
|
+
*
|
|
402
|
+
* @remarks
|
|
403
|
+
* Merged on top of the defaults and of this API's `tsconfig`, so declaring
|
|
404
|
+
* a single option overrides just that one.
|
|
405
|
+
*/
|
|
402
406
|
readonly compilerOptions: Schema.optional<Schema.Unknown>;
|
|
403
407
|
}>;
|
|
404
408
|
/** @public */
|
|
@@ -650,15 +654,19 @@ declare const PluginOptions: Schema.Struct<{
|
|
|
650
654
|
* Path to a `tsconfig.json` for Twoslash.
|
|
651
655
|
*
|
|
652
656
|
* @remarks
|
|
653
|
-
*
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
* configured tsconfigs are equivalent, or set the intended one on the
|
|
658
|
-
* first API only.
|
|
657
|
+
* This API's code blocks are type-checked under this config. APIs that
|
|
658
|
+
* declare the same config share one TypeScript environment; the file set
|
|
659
|
+
* is shared across all documented APIs either way, so a type owned by
|
|
660
|
+
* another documented package still resolves.
|
|
659
661
|
*/
|
|
660
662
|
readonly tsconfig: Schema.optional<Schema.declare<string | URL | ((...args: Array<unknown>) => unknown), string | URL | ((...args: Array<unknown>) => unknown)>>;
|
|
661
|
-
/**
|
|
663
|
+
/**
|
|
664
|
+
* TypeScript compiler options for Twoslash, applying to this API only.
|
|
665
|
+
*
|
|
666
|
+
* @remarks
|
|
667
|
+
* Merged on top of the defaults and of this API's `tsconfig`, so declaring
|
|
668
|
+
* a single option overrides just that one.
|
|
669
|
+
*/
|
|
662
670
|
readonly compilerOptions: Schema.optional<Schema.Unknown>;
|
|
663
671
|
}>>>>>;
|
|
664
672
|
/** Canonical site URL used for Open Graph absolute URLs. */
|
|
@@ -18,10 +18,13 @@ import { DEFAULT_CATEGORIES } from "../schemas/config.js";
|
|
|
18
18
|
import "../schemas/index.js";
|
|
19
19
|
import { ConfigService } from "../services/ConfigService.js";
|
|
20
20
|
import { PathDerivationService } from "../services/PathDerivationService.js";
|
|
21
|
+
import { TwoslashCacheService } from "../services/TwoslashCacheService.js";
|
|
21
22
|
import { TypeRegistryService } from "../services/TypeRegistryService.js";
|
|
23
|
+
import { makeTwoslashCache, twoslashEnvHash } from "../twoslash-cache.js";
|
|
22
24
|
import path from "node:path";
|
|
23
25
|
import { hashContent } from "@tsdoctor/snapshot";
|
|
24
26
|
import { Effect, Layer, Metric } from "effect";
|
|
27
|
+
import ts from "typescript";
|
|
25
28
|
import os from "node:os";
|
|
26
29
|
import { createHighlighter } from "shiki";
|
|
27
30
|
|
|
@@ -172,6 +175,12 @@ function ConfigServiceLive(options, shikiCrossLinker, buildId = "", resolvedThre
|
|
|
172
175
|
let firstApiTsconfig;
|
|
173
176
|
let firstApiCompilerOptions;
|
|
174
177
|
/**
|
|
178
|
+
* Raw TypeScript config per API scope. Each documented package is
|
|
179
|
+
* type-checked under its OWN configuration; the build no longer picks
|
|
180
|
+
* one and applies it to everything.
|
|
181
|
+
*/
|
|
182
|
+
const scopeTsConfigs = /* @__PURE__ */ new Map();
|
|
183
|
+
/**
|
|
175
184
|
* Emit a typed ModelLoadFailed event for a failed model load, then
|
|
176
185
|
* convert the typed failure to a defect — a missing or unparsable
|
|
177
186
|
* model remains fatal to the build, exactly as before, but the
|
|
@@ -229,6 +238,7 @@ function ConfigServiceLive(options, shikiCrossLinker, buildId = "", resolvedThre
|
|
|
229
238
|
const baseRoute = yield* pathService.normalizeBaseRoute(api.baseRoute ?? "/");
|
|
230
239
|
firstApiTsconfig = api.tsconfig;
|
|
231
240
|
firstApiCompilerOptions = api.compilerOptions;
|
|
241
|
+
scopeTsConfigs.set(apiScopeOf(baseRoute, api.packageName), rawTsConfig(api));
|
|
232
242
|
if (rspressMultiVersion && api.versions) {
|
|
233
243
|
const versionResults = yield* Effect.forEach(Object.entries(api.versions), ([version, versionValue]) => Effect.gen(function* () {
|
|
234
244
|
const versionDp = (yield* pathService.derivePaths({
|
|
@@ -361,23 +371,13 @@ function ConfigServiceLive(options, shikiCrossLinker, buildId = "", resolvedThre
|
|
|
361
371
|
}
|
|
362
372
|
} else if (options.apis) {
|
|
363
373
|
const apisWithTsconfig = options.apis.filter((a) => a.tsconfig);
|
|
364
|
-
if (apisWithTsconfig.length > 0)
|
|
365
|
-
firstApiTsconfig = apisWithTsconfig[0].tsconfig;
|
|
366
|
-
const uniqueTsconfigs = new Set(apisWithTsconfig.map((a) => String(a.tsconfig)));
|
|
367
|
-
if (uniqueTsconfigs.size > 1) {
|
|
368
|
-
const chosen = String(firstApiTsconfig);
|
|
369
|
-
const ignored = [...uniqueTsconfigs].filter((t) => t !== chosen);
|
|
370
|
-
yield* emit(PluginEvent.ConfigCascadeWarning({
|
|
371
|
-
ctx: { buildId: "" },
|
|
372
|
-
level: "warn",
|
|
373
|
-
field: "tsconfig",
|
|
374
|
-
chosen,
|
|
375
|
-
ignored
|
|
376
|
-
}));
|
|
377
|
-
}
|
|
378
|
-
}
|
|
374
|
+
if (apisWithTsconfig.length > 0) firstApiTsconfig = apisWithTsconfig[0].tsconfig;
|
|
379
375
|
const apisWithCompilerOptions = options.apis.filter((a) => a.compilerOptions);
|
|
380
376
|
if (apisWithCompilerOptions.length > 0) firstApiCompilerOptions = apisWithCompilerOptions[0].compilerOptions;
|
|
377
|
+
for (const a of options.apis) {
|
|
378
|
+
const scopeRoute = yield* pathService.normalizeBaseRoute(a.baseRoute ?? `/${unscopedName(a.packageName)}`);
|
|
379
|
+
scopeTsConfigs.set(apiScopeOf(scopeRoute, a.packageName), rawTsConfig(a));
|
|
380
|
+
}
|
|
381
381
|
const multiResults = yield* Effect.forEach(options.apis, (api) => Effect.gen(function* () {
|
|
382
382
|
const apiBaseRoute = yield* pathService.normalizeBaseRoute(api.baseRoute ?? `/${unscopedName(api.packageName)}`);
|
|
383
383
|
const dp = (yield* pathService.derivePaths({
|
|
@@ -477,8 +477,33 @@ function ConfigServiceLive(options, shikiCrossLinker, buildId = "", resolvedThre
|
|
|
477
477
|
chosen: "empty VFS",
|
|
478
478
|
ignored: [typeLoadResult.failure.message ?? String(typeLoadResult.failure)]
|
|
479
479
|
}));
|
|
480
|
+
const twoslashEnv = twoslashEnvHash(combinedVfs, `typescript@${ts.version}`);
|
|
481
|
+
const restored = yield* (yield* TwoslashCacheService).load(twoslashEnv);
|
|
482
|
+
const twoslashCache = makeTwoslashCache(restored);
|
|
483
|
+
yield* emit(PluginEvent.TwoslashCacheLoaded({
|
|
484
|
+
ctx: { buildId: "" },
|
|
485
|
+
level: "debug",
|
|
486
|
+
envHash: twoslashEnv,
|
|
487
|
+
entries: restored.size
|
|
488
|
+
}));
|
|
480
489
|
const twoslashStartMs = performance.now();
|
|
481
|
-
TwoslashManager.getInstance()
|
|
490
|
+
const manager = TwoslashManager.getInstance();
|
|
491
|
+
manager.initialize(combinedVfs, void 0, void 0, void 0, resolvedCompilerOptions, twoslashCache);
|
|
492
|
+
const resolvedByRawConfig = /* @__PURE__ */ new Map();
|
|
493
|
+
for (const [apiScope, rawConfig] of scopeTsConfigs) {
|
|
494
|
+
if (rawConfig === void 0) {
|
|
495
|
+
manager.registerScope(apiScope, resolvedCompilerOptions);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
const rawKey = JSON.stringify([String(rawConfig.tsconfig ?? ""), rawConfig.compilerOptions ?? null]);
|
|
499
|
+
let scopeOptions = resolvedByRawConfig.get(rawKey);
|
|
500
|
+
if (scopeOptions === void 0) {
|
|
501
|
+
scopeOptions = yield* Effect.promise(() => resolveTypeScriptConfig(projectRoot, rawConfig));
|
|
502
|
+
resolvedByRawConfig.set(rawKey, scopeOptions);
|
|
503
|
+
}
|
|
504
|
+
manager.initialize(combinedVfs, void 0, void 0, void 0, scopeOptions, twoslashCache);
|
|
505
|
+
manager.registerScope(apiScope, scopeOptions);
|
|
506
|
+
}
|
|
482
507
|
yield* emit(PluginEvent.TwoslashInitialized({
|
|
483
508
|
ctx: { buildId: "" },
|
|
484
509
|
level: "debug",
|
|
@@ -537,6 +562,8 @@ function ConfigServiceLive(options, shikiCrossLinker, buildId = "", resolvedThre
|
|
|
537
562
|
hideCutTransformer,
|
|
538
563
|
hideCutLinesTransformer,
|
|
539
564
|
twoslashTransformer,
|
|
565
|
+
twoslashCache,
|
|
566
|
+
twoslashEnvHash: twoslashEnv,
|
|
540
567
|
pageConcurrency: os.cpus().length,
|
|
541
568
|
logLevel: logLevel === "none" ? "info" : logLevel,
|
|
542
569
|
suppressExampleErrors,
|
|
@@ -547,6 +574,22 @@ function ConfigServiceLive(options, shikiCrossLinker, buildId = "", resolvedThre
|
|
|
547
574
|
}));
|
|
548
575
|
}
|
|
549
576
|
/**
|
|
577
|
+
* Derive the API scope key from a base route, matching the derivation in
|
|
578
|
+
* `build-program.ts` so config resolution and the remark plugins agree on the
|
|
579
|
+
* name a code block is attributed to.
|
|
580
|
+
*/
|
|
581
|
+
function apiScopeOf(baseRoute, packageName) {
|
|
582
|
+
return baseRoute.replace(/^\//, "").split("/")[0] || packageName;
|
|
583
|
+
}
|
|
584
|
+
/** The raw TypeScript config an API declares, or undefined when it declares none. */
|
|
585
|
+
function rawTsConfig(api) {
|
|
586
|
+
if (api.tsconfig == null && api.compilerOptions == null) return void 0;
|
|
587
|
+
const cfg = {};
|
|
588
|
+
if (api.tsconfig != null) cfg.tsconfig = api.tsconfig;
|
|
589
|
+
if (api.compilerOptions != null) cfg.compilerOptions = api.compilerOptions;
|
|
590
|
+
return cfg;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
550
593
|
* Strip npm scope from a package name.
|
|
551
594
|
*/
|
|
552
595
|
function unscopedName(packageName) {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { makeEventBusLayer } from "../observability/EventBus.js";
|
|
2
|
-
import {
|
|
2
|
+
import { formatCodeBlockReport, seriesFor } from "../observability/metric-report.js";
|
|
3
|
+
import { BuildMetrics, makeMetricStore } from "./build-metrics.js";
|
|
3
4
|
import { makeConsoleSink } from "../observability/sinks/console-sink.js";
|
|
4
5
|
import { makeIssuesSink } from "../observability/sinks/issues-sink.js";
|
|
5
6
|
import { makeMetricsSink } from "../observability/sinks/metrics-sink.js";
|
|
7
|
+
import { makeRenderSink } from "../observability/sinks/render-sink.js";
|
|
6
8
|
import { makeTraceSink } from "../observability/sinks/trace-sink.js";
|
|
7
9
|
import { Effect, Layer, Logger, Metric, References } from "effect";
|
|
8
10
|
|
|
@@ -29,7 +31,8 @@ function makeSummaryLoggerLayer(logLevel) {
|
|
|
29
31
|
return Layer.mergeAll(Logger.layer([pluginLogger]), Layer.succeed(References.MinimumLogLevel, effectLevel));
|
|
30
32
|
}
|
|
31
33
|
/**
|
|
32
|
-
* Compose the console + metrics + issues (+ optional trace) sinks into
|
|
34
|
+
* Compose the console + metrics + issues + render (+ optional trace) sinks into
|
|
35
|
+
* an EventBus layer.
|
|
33
36
|
*
|
|
34
37
|
* `cwd` is known at plugin-factory time (unlike the RSPress `outDir`), so the
|
|
35
38
|
* trace path is resolved eagerly by `resolveObservability` and the trace sink
|
|
@@ -37,17 +40,22 @@ function makeSummaryLoggerLayer(logLevel) {
|
|
|
37
40
|
*/
|
|
38
41
|
function buildEventBus(obs) {
|
|
39
42
|
const issues = makeIssuesSink();
|
|
43
|
+
const render = makeRenderSink();
|
|
44
|
+
const metrics = makeMetricStore();
|
|
40
45
|
const sinks = [
|
|
41
46
|
makeConsoleSink(obs.logLevel, { json: obs.json }),
|
|
42
|
-
makeMetricsSink(),
|
|
43
|
-
issues
|
|
47
|
+
makeMetricsSink(metrics.context),
|
|
48
|
+
issues,
|
|
49
|
+
render
|
|
44
50
|
];
|
|
45
51
|
const trace = obs.tracePath ? makeTraceSink(obs.tracePath) : null;
|
|
46
52
|
if (trace) sinks.push(trace);
|
|
47
53
|
return {
|
|
48
54
|
layer: makeEventBusLayer(sinks),
|
|
49
55
|
trace,
|
|
50
|
-
issues
|
|
56
|
+
issues,
|
|
57
|
+
render,
|
|
58
|
+
metrics
|
|
51
59
|
};
|
|
52
60
|
}
|
|
53
61
|
/**
|
|
@@ -55,8 +63,12 @@ function buildEventBus(obs) {
|
|
|
55
63
|
* Accepts the configured slow-codeblock threshold so the warning message
|
|
56
64
|
* interpolates the actual threshold rather than a hard-coded 100ms.
|
|
57
65
|
* Replaces the 4 separate logSummary() calls in afterBuild.
|
|
66
|
+
*
|
|
67
|
+
* When a code-block report is supplied, the per-scope attribution lines are
|
|
68
|
+
* appended after the aggregate code-block line. The report is optional so
|
|
69
|
+
* existing callers and tests keep working unchanged.
|
|
58
70
|
*/
|
|
59
|
-
const logBuildSummary = (slowCodeBlockMs) => Effect.gen(function* () {
|
|
71
|
+
const logBuildSummary = (slowCodeBlockMs, renderPhase) => Effect.gen(function* () {
|
|
60
72
|
const filesTotal = yield* Metric.value(BuildMetrics.filesTotal);
|
|
61
73
|
const filesNew = yield* Metric.value(BuildMetrics.filesNew);
|
|
62
74
|
const filesModified = yield* Metric.value(BuildMetrics.filesModified);
|
|
@@ -68,6 +80,7 @@ const logBuildSummary = (slowCodeBlockMs) => Effect.gen(function* () {
|
|
|
68
80
|
const pagesGenerated = yield* Metric.value(BuildMetrics.pagesGenerated);
|
|
69
81
|
const externalPackages = yield* Metric.value(BuildMetrics.externalPackagesTotal);
|
|
70
82
|
const phaseDurationSnapshot = yield* Metric.value(BuildMetrics.phaseDuration);
|
|
83
|
+
const snapshots = yield* Metric.snapshot;
|
|
71
84
|
const total = filesTotal.count;
|
|
72
85
|
const newCount = filesNew.count;
|
|
73
86
|
const modified = filesModified.count;
|
|
@@ -86,14 +99,20 @@ const logBuildSummary = (slowCodeBlockMs) => Effect.gen(function* () {
|
|
|
86
99
|
yield* Effect.log(`📝 ${total} files (${parts.join(", ")})`);
|
|
87
100
|
}
|
|
88
101
|
if (pagesGenerated.count > 0) yield* Effect.log(`🧩 ${pagesGenerated.count} pages, ${externalPackages.count} external package(s)`);
|
|
89
|
-
if (phaseDurationSnapshot.count > 0)
|
|
102
|
+
if (phaseDurationSnapshot.count > 0) {
|
|
103
|
+
const named = seriesFor(snapshots, "phase.time.ms").map((p) => `${p.attributes.phase ?? "?"} ${(p.value / 1e3).toFixed(1)}s`).slice(0, 6).join(", ");
|
|
104
|
+
yield* Effect.log(named.length > 0 ? `⏱ ${phaseDurationSnapshot.count} phase(s): ${named}` : `⏱ ${phaseDurationSnapshot.count} phase(s) timed`);
|
|
105
|
+
}
|
|
90
106
|
if (blocks > 0 && slowBlocks > 0) yield* Effect.logWarning(`Code block performance: ${slowBlocks} of ${blocks} blocks were slow (>${slowCodeBlockMs}ms)`);
|
|
107
|
+
if (renderPhase) for (const line of formatCodeBlockReport(renderPhase)) yield* Effect.log(line);
|
|
91
108
|
const totalErrors = tsErrors + prErrors;
|
|
92
109
|
if (totalErrors > 0) {
|
|
93
110
|
const errorParts = [];
|
|
94
111
|
if (tsErrors > 0) errorParts.push(`${tsErrors} Twoslash`);
|
|
95
112
|
if (prErrors > 0) errorParts.push(`${prErrors} Prettier`);
|
|
96
113
|
yield* Effect.logWarning(`${totalErrors} error(s) in code blocks (${errorParts.join(", ")})`);
|
|
114
|
+
const byCode = seriesFor(snapshots, "twoslash.diagnostics");
|
|
115
|
+
for (const series of byCode.slice(0, 5)) yield* Effect.logWarning(` ${series.attributes.code ?? "TS?"} x${series.value} in ${series.attributes.scope ?? "(unscoped)"}`);
|
|
97
116
|
}
|
|
98
117
|
});
|
|
99
118
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { TwoslashCacheService } from "../services/TwoslashCacheService.js";
|
|
2
|
+
import { decodeTwoslashCache, encodeTwoslashCache, twoslashBlobKey } from "../twoslash-cache.js";
|
|
3
|
+
import { NodeFileSystem } from "@effect/platform-node";
|
|
4
|
+
import { Effect, Layer, Option, Path } from "effect";
|
|
5
|
+
import { Cache } from "@effected/store";
|
|
6
|
+
import { AppDirs, Xdg } from "@effected/xdg";
|
|
7
|
+
|
|
8
|
+
//#region src/layers/TwoslashCacheServiceLive.ts
|
|
9
|
+
const PlatformLive = Layer.mergeAll(NodeFileSystem.layer, Path.layer);
|
|
10
|
+
/**
|
|
11
|
+
* XDG app dirs under the same `tsdoctor` namespace the type registry uses, so
|
|
12
|
+
* every derived-artifact cache this plugin keeps lives in one place.
|
|
13
|
+
*/
|
|
14
|
+
const AppDirsLive = AppDirs.layer({ namespace: "tsdoctor" }).pipe(Layer.provide(Layer.mergeAll(Xdg.layer, PlatformLive)));
|
|
15
|
+
/**
|
|
16
|
+
* A sqlite-backed `@effected/store` Cache in the XDG cache dir, separate from
|
|
17
|
+
* the registry's `metadata.sqlite`.
|
|
18
|
+
*
|
|
19
|
+
* XDG rather than the repo: these are regenerable results derived from content
|
|
20
|
+
* hashes, so they belong with the user's other caches — shared across worktrees
|
|
21
|
+
* and checkouts of the same project, and untouched by cleaning `dist/`. Nothing
|
|
22
|
+
* here needs to be committed for a build to be correct.
|
|
23
|
+
*/
|
|
24
|
+
const CacheLive = Layer.unwrap(Effect.gen(function* () {
|
|
25
|
+
const appDirs = yield* AppDirs;
|
|
26
|
+
const path = yield* Path.Path;
|
|
27
|
+
const cacheDir = yield* appDirs.ensureCache;
|
|
28
|
+
return Cache.layerSqlite({ filename: path.join(cacheDir, "twoslash.sqlite") });
|
|
29
|
+
})).pipe(Layer.provide(Layer.mergeAll(AppDirsLive, PlatformLive)));
|
|
30
|
+
/**
|
|
31
|
+
* Live Twoslash cache persistence.
|
|
32
|
+
*
|
|
33
|
+
* Both operations swallow every failure by design — see the service docs. A
|
|
34
|
+
* missing HOME, an unwritable cache dir or a corrupt database degrades the
|
|
35
|
+
* build to "type-check everything", which is exactly the behaviour before this
|
|
36
|
+
* cache existed.
|
|
37
|
+
*/
|
|
38
|
+
const TwoslashCacheServiceLive = Layer.succeed(TwoslashCacheService, {
|
|
39
|
+
load: (envHash) => Effect.gen(function* () {
|
|
40
|
+
const entry = yield* (yield* Cache).get(twoslashBlobKey(envHash));
|
|
41
|
+
return Option.isSome(entry) ? decodeTwoslashCache(entry.value.value) : /* @__PURE__ */ new Map();
|
|
42
|
+
}).pipe(Effect.provide(CacheLive), Effect.catchCause(() => Effect.succeed(/* @__PURE__ */ new Map()))),
|
|
43
|
+
save: (envHash, entries) => Effect.gen(function* () {
|
|
44
|
+
yield* (yield* Cache).set({
|
|
45
|
+
key: twoslashBlobKey(envHash),
|
|
46
|
+
value: encodeTwoslashCache(entries),
|
|
47
|
+
tags: ["twoslash"]
|
|
48
|
+
});
|
|
49
|
+
}).pipe(Effect.provide(CacheLive), Effect.catchCause(() => Effect.void))
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
//#endregion
|
|
53
|
+
export { TwoslashCacheServiceLive };
|
package/layers/build-metrics.js
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
|
-
import { Metric } from "effect";
|
|
1
|
+
import { Context, Layer, Metric } from "effect";
|
|
2
2
|
|
|
3
3
|
//#region src/layers/build-metrics.ts
|
|
4
|
+
/** Create an isolated metric store for a single build. */
|
|
5
|
+
function makeMetricStore() {
|
|
6
|
+
const registry = /* @__PURE__ */ new Map();
|
|
7
|
+
return {
|
|
8
|
+
registry,
|
|
9
|
+
context: Context.make(Metric.MetricRegistry, registry),
|
|
10
|
+
layer: Layer.succeed(Metric.MetricRegistry, registry)
|
|
11
|
+
};
|
|
12
|
+
}
|
|
4
13
|
/**
|
|
5
14
|
* All build metrics as named counters/histograms.
|
|
6
15
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
16
|
+
* Metric state lives in the `Metric.MetricRegistry` of the surrounding context,
|
|
17
|
+
* not in these constants, so these may be shared module-level values while each
|
|
18
|
+
* build still gets its own counters — see {@link MetricRegistryLive}.
|
|
10
19
|
*
|
|
11
20
|
* Extracted into its own module so that `metrics-sink.ts` can import it
|
|
12
21
|
* without creating a circular dependency through `ObservabilityLive.ts`
|
|
@@ -36,8 +45,20 @@ const BuildMetrics = {
|
|
|
36
45
|
] }),
|
|
37
46
|
codeblockTotal: Metric.counter("codeblock.total"),
|
|
38
47
|
codeblockSlow: Metric.counter("codeblock.slow"),
|
|
48
|
+
/**
|
|
49
|
+
* Summed milliseconds per dimension. Counters rather than histograms because
|
|
50
|
+
* the question these answer is "where did the time go", which needs a total;
|
|
51
|
+
* the histograms above still carry the distribution.
|
|
52
|
+
*/
|
|
53
|
+
codeblockTimeMs: Metric.counter("codeblock.time.ms"),
|
|
54
|
+
codeblockTwoslashMs: Metric.counter("codeblock.twoslash.ms"),
|
|
55
|
+
codeblockShikiMs: Metric.counter("codeblock.shiki.ms"),
|
|
56
|
+
/** Blocks the Twoslash transformer actually ran on. */
|
|
57
|
+
codeblockTwoslashTotal: Metric.counter("codeblock.twoslash.total"),
|
|
39
58
|
twoslashErrors: Metric.counter("twoslash.errors"),
|
|
40
59
|
prettierErrors: Metric.counter("prettier.errors"),
|
|
60
|
+
/** Shiki render failures. Previously unmapped, so they never reached a metric. */
|
|
61
|
+
shikiErrors: Metric.counter("shiki.errors"),
|
|
41
62
|
pagesGenerated: Metric.counter("pages.generated"),
|
|
42
63
|
apisCompleted: Metric.counter("apis.completed"),
|
|
43
64
|
apiVersionsLoaded: Metric.counter("api.versions.loaded"),
|
|
@@ -52,6 +73,12 @@ const BuildMetrics = {
|
|
|
52
73
|
5e3,
|
|
53
74
|
1e4
|
|
54
75
|
] }),
|
|
76
|
+
/**
|
|
77
|
+
* Summed phase milliseconds. Tagged by phase name, which the histogram alone
|
|
78
|
+
* cannot express — every phase previously collapsed into one distribution, so
|
|
79
|
+
* resolve could not be told apart from generate or write.
|
|
80
|
+
*/
|
|
81
|
+
phaseTimeMs: Metric.counter("phase.time.ms"),
|
|
55
82
|
vfsFiles: Metric.counter("vfs.files"),
|
|
56
83
|
importsPrepended: Metric.counter("imports.prepended"),
|
|
57
84
|
twoslashDiagnostics: Metric.counter("twoslash.diagnostics"),
|
|
@@ -59,4 +86,4 @@ const BuildMetrics = {
|
|
|
59
86
|
};
|
|
60
87
|
|
|
61
88
|
//#endregion
|
|
62
|
-
export { BuildMetrics };
|
|
89
|
+
export { BuildMetrics, makeMetricStore };
|
package/markdown/helpers.js
CHANGED
|
@@ -334,4 +334,4 @@ async function formatExampleCode(code, language, _context) {
|
|
|
334
334
|
}
|
|
335
335
|
|
|
336
336
|
//#endregion
|
|
337
|
-
export { escapeMdxGenerics, escapeYamlString, formatExampleCode,
|
|
337
|
+
export { escapeMdxGenerics, escapeYamlString, formatExampleCode, generateAvailableFrom, generateFrontmatter, prepareExampleCode, prependHiddenImports, sanitizeId, stripTwoslashDirectives };
|
|
@@ -16,8 +16,6 @@ function makeShape(sinks) {
|
|
|
16
16
|
function makeEventBusLayer(sinks) {
|
|
17
17
|
return Layer.succeed(EventBus, makeShape(sinks));
|
|
18
18
|
}
|
|
19
|
-
/** No sinks: every emit is a no-op, wantsLevel always false. */
|
|
20
|
-
const EventBusNoop = makeEventBusLayer([]);
|
|
21
19
|
/** Emit when a bus is in context; silently no-op otherwise. */
|
|
22
20
|
function emit(event) {
|
|
23
21
|
return Effect.serviceOption(EventBus).pipe(Effect.flatMap((maybe) => Option.isSome(maybe) ? maybe.value.emit(event) : Effect.void));
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { Effect, Metric } from "effect";
|
|
2
|
+
|
|
3
|
+
//#region src/observability/metric-report.ts
|
|
4
|
+
/**
|
|
5
|
+
* Break any counter down by its attributes, largest first.
|
|
6
|
+
*
|
|
7
|
+
* This is the generic form of what the code-block report does: because Effect
|
|
8
|
+
* keys a registry entry by metric name plus attribute set, a breakdown is a
|
|
9
|
+
* filter over `Metric.snapshot`, not something a sink has to accumulate. Adding
|
|
10
|
+
* a dimension to an existing metric therefore costs a tag at the emit site and
|
|
11
|
+
* nothing here.
|
|
12
|
+
*/
|
|
13
|
+
function seriesFor(snapshots, id) {
|
|
14
|
+
return snapshots.filter((snap) => snap.id === id && snap.attributes !== void 0).map((snap) => ({
|
|
15
|
+
attributes: snap.attributes ?? {},
|
|
16
|
+
value: counterValue(snap.state)
|
|
17
|
+
})).filter((series) => series.value > 0).sort((a, b) => b.value - a.value);
|
|
18
|
+
}
|
|
19
|
+
const ID_TO_FIELD = new Map(Object.entries({
|
|
20
|
+
blocks: "codeblock.total",
|
|
21
|
+
twoslashBlocks: "codeblock.twoslash.total",
|
|
22
|
+
slowBlocks: "codeblock.slow",
|
|
23
|
+
totalMs: "codeblock.time.ms",
|
|
24
|
+
twoslashMs: "codeblock.twoslash.ms",
|
|
25
|
+
shikiMs: "codeblock.shiki.ms"
|
|
26
|
+
}).map(([field, id]) => [id, field]));
|
|
27
|
+
function emptyBucket() {
|
|
28
|
+
return {
|
|
29
|
+
blocks: 0,
|
|
30
|
+
twoslashBlocks: 0,
|
|
31
|
+
slowBlocks: 0,
|
|
32
|
+
totalMs: 0,
|
|
33
|
+
twoslashMs: 0,
|
|
34
|
+
shikiMs: 0,
|
|
35
|
+
otherMs: 0
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function withOther(bucket) {
|
|
39
|
+
return {
|
|
40
|
+
...bucket,
|
|
41
|
+
otherMs: Math.max(0, bucket.totalMs - bucket.twoslashMs - bucket.shikiMs)
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function add(a, b) {
|
|
45
|
+
return withOther({
|
|
46
|
+
blocks: a.blocks + b.blocks,
|
|
47
|
+
twoslashBlocks: a.twoslashBlocks + b.twoslashBlocks,
|
|
48
|
+
slowBlocks: a.slowBlocks + b.slowBlocks,
|
|
49
|
+
totalMs: a.totalMs + b.totalMs,
|
|
50
|
+
twoslashMs: a.twoslashMs + b.twoslashMs,
|
|
51
|
+
shikiMs: a.shikiMs + b.shikiMs,
|
|
52
|
+
otherMs: 0
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/** Counter states carry `count`; anything else contributes nothing. */
|
|
56
|
+
function counterValue(state) {
|
|
57
|
+
if (typeof state !== "object" || state === null || !("count" in state)) return 0;
|
|
58
|
+
const count = state.count;
|
|
59
|
+
return typeof count === "number" ? count : Number(count);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Build the code-block report from an explicit set of metric snapshots.
|
|
63
|
+
*
|
|
64
|
+
* Split from {@link codeBlockReport} so it can be exercised without a live
|
|
65
|
+
* metric registry.
|
|
66
|
+
*/
|
|
67
|
+
function codeBlockReportFrom(snapshots) {
|
|
68
|
+
const bySeries = /* @__PURE__ */ new Map();
|
|
69
|
+
for (const snap of snapshots) {
|
|
70
|
+
const field = ID_TO_FIELD.get(snap.id);
|
|
71
|
+
if (!field || !snap.attributes) continue;
|
|
72
|
+
const { scope, component, twoslash } = snap.attributes;
|
|
73
|
+
if (scope === void 0 || component === void 0 || twoslash === void 0) continue;
|
|
74
|
+
const key = `${scope} ${component} ${twoslash}`;
|
|
75
|
+
const current = bySeries.get(key) ?? {
|
|
76
|
+
scope,
|
|
77
|
+
component,
|
|
78
|
+
twoslash: twoslash === "true",
|
|
79
|
+
...emptyBucket()
|
|
80
|
+
};
|
|
81
|
+
bySeries.set(key, {
|
|
82
|
+
...current,
|
|
83
|
+
[field]: current[field] + counterValue(snap.state)
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const series = [...bySeries.values()].map((s) => ({
|
|
87
|
+
...s,
|
|
88
|
+
...withOther(s)
|
|
89
|
+
})).sort((a, b) => b.totalMs - a.totalMs);
|
|
90
|
+
let overall = emptyBucket();
|
|
91
|
+
const byScope = /* @__PURE__ */ new Map();
|
|
92
|
+
const byComponent = /* @__PURE__ */ new Map();
|
|
93
|
+
for (const s of series) {
|
|
94
|
+
overall = add(overall, s);
|
|
95
|
+
byScope.set(s.scope, add(byScope.get(s.scope) ?? emptyBucket(), s));
|
|
96
|
+
byComponent.set(s.component, add(byComponent.get(s.component) ?? emptyBucket(), s));
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
overall,
|
|
100
|
+
series,
|
|
101
|
+
byScope: Object.fromEntries(byScope),
|
|
102
|
+
byComponent: Object.fromEntries(byComponent)
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** Read the code-block report from the current context's metric registry. */
|
|
106
|
+
const codeBlockReport = Effect.map(Metric.snapshot, codeBlockReportFrom);
|
|
107
|
+
/**
|
|
108
|
+
* Render the report as the console summary lines logged at the end of a build.
|
|
109
|
+
* Returns an empty array when no code block was processed.
|
|
110
|
+
*/
|
|
111
|
+
function formatCodeBlockReport(report) {
|
|
112
|
+
const { overall } = report;
|
|
113
|
+
if (overall.blocks === 0) return [];
|
|
114
|
+
const s = (ms) => `${(ms / 1e3).toFixed(1)}s`;
|
|
115
|
+
const pct = (ms) => overall.totalMs > 0 ? `${Math.round(ms / overall.totalMs * 100)}%` : "0%";
|
|
116
|
+
const lines = [`render phase: ${overall.blocks} code blocks in ${s(overall.totalMs)} (twoslash ${s(overall.twoslashMs)} ${pct(overall.twoslashMs)}, shiki ${s(overall.shikiMs)} ${pct(overall.shikiMs)}, other ${s(overall.otherMs)} ${pct(overall.otherMs)})`];
|
|
117
|
+
const scopes = Object.entries(report.byScope).sort((a, b) => b[1].totalMs - a[1].totalMs);
|
|
118
|
+
for (const [scope, bucket] of scopes.slice(0, 10)) lines.push(` ${scope}: ${bucket.blocks} blocks, ${s(bucket.totalMs)} (twoslash ${s(bucket.twoslashMs)}, ${bucket.twoslashBlocks} typechecked)`);
|
|
119
|
+
if (scopes.length > 10) lines.push(` and ${scopes.length - 10} more scopes`);
|
|
120
|
+
return lines;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
//#endregion
|
|
124
|
+
export { codeBlockReport, codeBlockReportFrom, formatCodeBlockReport, seriesFor };
|
|
@@ -27,6 +27,12 @@ function render(event) {
|
|
|
27
27
|
case "ConfigResolved": return `resolved ${event.baseRoute}: ${event.categoryCount} categories, ${event.externalCount} external`;
|
|
28
28
|
case "TwoslashDiagnostic": return `Twoslash TS${event.code} in ${event.file}:${event.line}:${event.col}: ${event.message}`;
|
|
29
29
|
case "TwoslashCheckFailed": return `Twoslash check failed (TS${event.code}) in ${event.file}; ${event.fsMapKeys.length} VFS files`;
|
|
30
|
+
case "TwoslashCacheLoaded": return event.entries > 0 ? `Twoslash cache: restored ${event.entries} cached result(s)` : "Twoslash cache: cold (no cached results for this type environment)";
|
|
31
|
+
case "TwoslashCacheSaved": {
|
|
32
|
+
const total = event.hits + event.misses;
|
|
33
|
+
const pct = total > 0 ? Math.round(event.hits / total * 100) : 0;
|
|
34
|
+
return `Twoslash cache: ${event.hits}/${total} hits (${pct}%), ${event.entries} entries${event.persisted ? " (saved)" : ""}`;
|
|
35
|
+
}
|
|
30
36
|
case "PageGenerated": return `page ${event.category}/${event.item} (${event.durationMs}ms)`;
|
|
31
37
|
case "FileDecision": return `${event.status}: ${event.file}`;
|
|
32
38
|
case "ItemSkipped": return `skipped ${event.kind} "${event.item}": ${event.reason}`;
|