rspress-plugin-api-extractor 0.9.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BuildEnv.js +58 -0
- package/README.md +2 -1
- package/build-program.js +33 -30
- package/build-stages.js +47 -39
- package/errors.js +0 -1
- package/index.d.ts +22 -14
- package/layers/ConfigServiceLive.js +349 -400
- package/layers/HighlighterServiceLive.js +52 -0
- package/layers/ObservabilityLive.js +26 -7
- package/layers/OgServiceLive.js +134 -0
- package/layers/TwoslashCacheServiceLive.js +108 -0
- package/layers/TwoslashEnvironmentsLive.js +33 -0
- package/layers/TypeRegistryServiceLive.js +54 -47
- package/layers/build-metrics.js +32 -5
- package/layers/xdg.js +44 -0
- package/markdown/helpers.js +9 -55
- package/markdown/page-generators/class-page.js +8 -31
- package/markdown/page-generators/index-pages.js +6 -8
- package/markdown/page-generators/interface-page.js +7 -7
- package/markdown/shiki-utils.js +65 -10
- package/observability/EventBus.js +29 -9
- 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 +4 -2
- package/observability/sync-emitter.js +78 -0
- package/og-resolver.js +46 -287
- package/package.json +4 -5
- package/path-derivation.js +19 -1
- package/plugin.js +64 -52
- package/prettier-formatter.js +4 -10
- package/remark-api-codeblocks.js +33 -15
- package/remark-with-api.js +24 -27
- package/schemas/config.js +11 -7
- package/services/HighlighterService.js +30 -0
- package/services/OgService.js +23 -0
- package/services/PluginConfig.js +26 -0
- package/services/TwoslashCacheService.js +15 -0
- package/services/TwoslashEnvironments.js +7 -0
- package/shiki-transformer.js +55 -256
- package/twoslash-access.js +48 -0
- package/twoslash-cache.js +174 -0
- package/twoslash-patterns.js +1 -1
- package/twoslash-timing-wrapper.js +23 -0
- package/twoslash-transformer.js +153 -89
- package/vfs-registry.js +1 -31
- package/layers/PathDerivationServiceLive.js +0 -16
- package/runtime/components/MarkdownText/index.js +0 -34
- package/services/PathDerivationService.js +0 -7
package/layers/xdg.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { NodeFileSystem } from "@effect/platform-node";
|
|
2
|
+
import { Layer, Path } from "effect";
|
|
3
|
+
import { AppDirs, Xdg } from "@effected/xdg";
|
|
4
|
+
|
|
5
|
+
//#region src/layers/xdg.ts
|
|
6
|
+
/**
|
|
7
|
+
* The plugin's shared platform and XDG layers.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* Both cache-backed services — the type registry and the Twoslash result cache
|
|
11
|
+
* — need a Node platform and an XDG app-directory root, and both used to
|
|
12
|
+
* declare their own. Two consequences, both fixed by having one home:
|
|
13
|
+
*
|
|
14
|
+
* 1. **Two distinct layer references build twice.** Layer memoization is by
|
|
15
|
+
* reference, so a second `Layer.mergeAll(NodeFileSystem.layer, Path.layer)`
|
|
16
|
+
* is a different layer as far as the memo map is concerned, and the XDG
|
|
17
|
+
* resolution ran once per consumer.
|
|
18
|
+
* 2. **The namespace literal was copy-pasted.** The house style bans exactly
|
|
19
|
+
* this: when two sibling layers must agree on an identity string, the
|
|
20
|
+
* agreement has to be structural rather than textual. A drift here is
|
|
21
|
+
* silent and permanent — the caches move to a different directory, every
|
|
22
|
+
* lookup misses, and a build that should hit a warm Twoslash cache goes
|
|
23
|
+
* cold forever with no error and nothing in the output to notice.
|
|
24
|
+
*
|
|
25
|
+
* @packageDocumentation
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* The XDG namespace every cache this plugin keeps lives under.
|
|
29
|
+
*
|
|
30
|
+
* @remarks
|
|
31
|
+
* One definition, deliberately. Changing it invalidates every on-disk cache —
|
|
32
|
+
* the type registry's `metadata.sqlite` and the Twoslash result cache's
|
|
33
|
+
* `twoslash.sqlite` — which is a cold refetch and a full re-type-check, not an
|
|
34
|
+
* error. That was accepted once, at the phase-2 rename from
|
|
35
|
+
* `type-registry-effect`; do not do it casually.
|
|
36
|
+
*/
|
|
37
|
+
const TSDOCTOR_NAMESPACE = "tsdoctor";
|
|
38
|
+
/** Node platform services: the filesystem and path implementations. */
|
|
39
|
+
const PlatformLive = Layer.mergeAll(NodeFileSystem.layer, Path.layer);
|
|
40
|
+
/** XDG application directories rooted at {@link TSDOCTOR_NAMESPACE}. */
|
|
41
|
+
const AppDirsLive = AppDirs.layer({ namespace: TSDOCTOR_NAMESPACE }).pipe(Layer.provide(Layer.mergeAll(Xdg.layer, PlatformLive)));
|
|
42
|
+
|
|
43
|
+
//#endregion
|
|
44
|
+
export { AppDirsLive, PlatformLive, TSDOCTOR_NAMESPACE };
|
package/markdown/helpers.js
CHANGED
|
@@ -4,7 +4,6 @@ import { formatCode } from "../prettier-formatter.js";
|
|
|
4
4
|
import { TypeReferenceExtractor } from "../type-reference-extractor.js";
|
|
5
5
|
|
|
6
6
|
//#region src/markdown/helpers.ts
|
|
7
|
-
/* v8 ignore start -- markdown generation helpers, tested via page generator integration tests */
|
|
8
7
|
/**
|
|
9
8
|
* Helper utilities for generating markdown API documentation.
|
|
10
9
|
*
|
|
@@ -50,63 +49,18 @@ function prepareExampleCode(example, apiItemName, packageName, suppressErrors =
|
|
|
50
49
|
};
|
|
51
50
|
}
|
|
52
51
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* @returns URL-safe ID string
|
|
62
|
-
*
|
|
63
|
-
* @example
|
|
64
|
-
* ```ts
|
|
65
|
-
* sanitizeId("myMethod"); // "mymethod"
|
|
66
|
-
* sanitizeId("get value"); // "get-value"
|
|
67
|
-
* sanitizeId("run", "static"); // "static-run"
|
|
68
|
-
* ```
|
|
69
|
-
*/
|
|
70
|
-
function sanitizeId(displayName, prefix = "") {
|
|
71
|
-
const baseName = displayName.replace(/["']/g, "").replace(/[^\w-]/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
|
|
72
|
-
return prefix ? `${prefix}-${baseName}` : baseName;
|
|
73
|
-
}
|
|
74
|
-
/**
|
|
75
|
-
* Escape a YAML string value by handling special characters.
|
|
76
|
-
*
|
|
77
|
-
* Normalizes whitespace and wraps strings in double quotes if they contain
|
|
78
|
-
* characters that could break YAML parsing (colons, quotes, hashes, pipes,
|
|
79
|
-
* brackets, braces, Unicode characters, etc.).
|
|
80
|
-
*
|
|
81
|
-
* @param value - The string value to escape
|
|
82
|
-
* @returns YAML-safe string
|
|
83
|
-
*
|
|
84
|
-
* @example
|
|
85
|
-
* ```ts
|
|
86
|
-
* escapeYamlString("Hello World"); // "Hello World"
|
|
87
|
-
* escapeYamlString("Type: string"); // "\"Type: string\""
|
|
88
|
-
* escapeYamlString("He said \"hello\""); // "\"He said \\\"hello\\\"\""
|
|
89
|
-
* escapeYamlString("@pkg/name。:"); // "\"@pkg/name。:\""
|
|
90
|
-
* ```
|
|
91
|
-
*/
|
|
92
|
-
/**
|
|
93
|
-
* Normalize a string for use as a YAML frontmatter value: collapse newlines
|
|
94
|
-
* and repeated whitespace to single spaces and trim.
|
|
95
|
-
*
|
|
96
|
-
* This is the cleaning half of the former hand-rolled YAML escaping. It is
|
|
97
|
-
* applied to every frontmatter value BEFORE serialization so the parsed data
|
|
98
|
-
* (and therefore the snapshot frontmatter hash — see `@tsdoctor/snapshot`)
|
|
99
|
-
* is byte-identical to what the previous emitter produced; the quoting half
|
|
100
|
-
* is now owned by the real YAML emitter in `../frontmatter.ts`.
|
|
52
|
+
* Collapse newlines and runs of whitespace to single spaces, and trim.
|
|
53
|
+
*
|
|
54
|
+
* @remarks
|
|
55
|
+
* Applied to every frontmatter scalar before emission. This is NOT quoting —
|
|
56
|
+
* `@effected/yaml` owns that — it is whitespace normalization, and it is
|
|
57
|
+
* load-bearing: the snapshot system hashes the PARSED frontmatter, so a value
|
|
58
|
+
* that folds differently between builds would churn the hash. Survives the
|
|
59
|
+
* removal of `escapeYamlString`, which was its other caller.
|
|
101
60
|
*/
|
|
102
61
|
function cleanYamlValue(value) {
|
|
103
62
|
return value.replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim();
|
|
104
63
|
}
|
|
105
|
-
function escapeYamlString(value) {
|
|
106
|
-
const cleaned = cleanYamlValue(value);
|
|
107
|
-
if (/["':#|>&*!%@`[\]{},?-]/.test(cleaned) || /[\u0080-\uFFFF]/.test(cleaned) || /^(true|false|null|~|yes|no|on|off)$/i.test(cleaned) || /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(cleaned)) return `"${cleaned.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
108
|
-
return cleaned;
|
|
109
|
-
}
|
|
110
64
|
/**
|
|
111
65
|
* Escape generic type parameters in MDX by wrapping them in backticks.
|
|
112
66
|
*
|
|
@@ -334,4 +288,4 @@ async function formatExampleCode(code, language, _context) {
|
|
|
334
288
|
}
|
|
335
289
|
|
|
336
290
|
//#endregion
|
|
337
|
-
export { escapeMdxGenerics,
|
|
291
|
+
export { escapeMdxGenerics, formatExampleCode, generateAvailableFrom, generateFrontmatter, prepareExampleCode, prependHiddenImports, stripTwoslashDirectives };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { TypeReferenceExtractor } from "../../type-reference-extractor.js";
|
|
2
|
-
import { escapeMdxGenerics, formatExampleCode, generateAvailableFrom, generateFrontmatter, prepareExampleCode, prependHiddenImports,
|
|
2
|
+
import { escapeMdxGenerics, formatExampleCode, generateAvailableFrom, generateFrontmatter, prepareExampleCode, prependHiddenImports, stripTwoslashDirectives } from "../helpers.js";
|
|
3
3
|
import { linkProse } from "../prose-linker.js";
|
|
4
|
-
import { ApiItems, Signature, Tsdoc } from "@tsdoctor/model";
|
|
4
|
+
import { ApiItems, Routes, Signature, Tsdoc } from "@tsdoctor/model";
|
|
5
5
|
|
|
6
6
|
//#region src/markdown/page-generators/class-page.ts
|
|
7
7
|
/**
|
|
@@ -60,7 +60,7 @@ var ClassPageGenerator = class {
|
|
|
60
60
|
*
|
|
61
61
|
* @param apiScope - API scope identifier for VFS lookup
|
|
62
62
|
*/
|
|
63
|
-
async generate(apiClass, baseRoute, packageName, singularName, apiScope, apiName, sourceConfig, suppressExampleErrors, llmsPlugin, availableFrom, syntheticBase) {
|
|
63
|
+
async generate(apiClass, baseRoute, packageName, singularName, apiScope, apiName, sourceConfig, suppressExampleErrors, llmsPlugin, availableFrom, syntheticBase, memberAnchors) {
|
|
64
64
|
const shouldSuppressErrors = suppressExampleErrors ?? true;
|
|
65
65
|
const name = apiClass.displayName;
|
|
66
66
|
const summary = Tsdoc.summary(apiClass) || "No description available.";
|
|
@@ -99,7 +99,7 @@ var ClassPageGenerator = class {
|
|
|
99
99
|
content += `## Constructors\n\n`;
|
|
100
100
|
for (const ctor of constructors) {
|
|
101
101
|
const ctorSummary = Tsdoc.summary(ctor);
|
|
102
|
-
const ctorId =
|
|
102
|
+
const ctorId = Routes.memberAnchor("constructor");
|
|
103
103
|
const ctorItem = ctor;
|
|
104
104
|
const params = Tsdoc.params(ctor);
|
|
105
105
|
const hasParameters = params.length > 0;
|
|
@@ -131,15 +131,14 @@ var ClassPageGenerator = class {
|
|
|
131
131
|
const isGetter = m.displayName.startsWith("get ") || m.displayName.startsWith("set ");
|
|
132
132
|
return !isStatic && !isGetter;
|
|
133
133
|
});
|
|
134
|
-
const
|
|
134
|
+
const anchors = memberAnchors ?? ApiItems.memberAnchors(apiClass);
|
|
135
|
+
const anchorFor = (member) => anchors.get(member.canonicalReference?.toString() ?? member.displayName) ?? Routes.memberAnchor(member.displayName);
|
|
135
136
|
const renderProperties = async (title, propList) => {
|
|
136
137
|
if (propList.length === 0) return;
|
|
137
138
|
content += `## ${title}\n\n`;
|
|
138
139
|
for (const prop of propList) {
|
|
139
140
|
const propSummary = Tsdoc.summary(prop);
|
|
140
|
-
const
|
|
141
|
-
const prefix = prefixMap.get(baseName) || "";
|
|
142
|
-
const propId = sanitizeId(prop.displayName, prefix);
|
|
141
|
+
const propId = anchorFor(prop);
|
|
143
142
|
const propItem = prop;
|
|
144
143
|
if (propItem.excerpt?.text) {
|
|
145
144
|
const memberSignature = Signature.format(propItem.excerpt).trim();
|
|
@@ -154,9 +153,7 @@ var ClassPageGenerator = class {
|
|
|
154
153
|
content += `## ${title}\n\n`;
|
|
155
154
|
for (const method of methodList) {
|
|
156
155
|
const methodSummary = Tsdoc.summary(method);
|
|
157
|
-
const
|
|
158
|
-
const prefix = prefixMap.get(baseName) || "";
|
|
159
|
-
const methodId = sanitizeId(method.displayName, prefix);
|
|
156
|
+
const methodId = anchorFor(method);
|
|
160
157
|
const methodItem = method;
|
|
161
158
|
const params = Tsdoc.params(method);
|
|
162
159
|
const hasParameters = params.length > 0;
|
|
@@ -258,26 +255,6 @@ var ClassPageGenerator = class {
|
|
|
258
255
|
};
|
|
259
256
|
}
|
|
260
257
|
/**
|
|
261
|
-
* Detect naming conflicts between class members and return prefixes to apply.
|
|
262
|
-
* Returns a Map of sanitized name to prefix (empty string if no conflict).
|
|
263
|
-
*/
|
|
264
|
-
detectMemberConflicts(staticProps, staticMethods, instanceProps, instanceMethods, getters) {
|
|
265
|
-
const prefixMap = /* @__PURE__ */ new Map();
|
|
266
|
-
const staticPropNames = new Set(staticProps.map((p) => sanitizeId(p.displayName)));
|
|
267
|
-
const staticMethodNames = new Set(staticMethods.map((m) => sanitizeId(m.displayName)));
|
|
268
|
-
const instancePropNames = new Set(instanceProps.map((p) => sanitizeId(p.displayName)));
|
|
269
|
-
const instanceMethodNames = new Set(instanceMethods.map((m) => sanitizeId(m.displayName)));
|
|
270
|
-
const getterNames = new Set(getters.map((g) => sanitizeId(g.displayName)));
|
|
271
|
-
for (const name of staticPropNames) if (staticMethodNames.has(name) || instancePropNames.has(name) || instanceMethodNames.has(name) || getterNames.has(name)) prefixMap.set(name, "static-property");
|
|
272
|
-
for (const name of staticMethodNames) if (instanceMethodNames.has(name) || instancePropNames.has(name) || getterNames.has(name)) prefixMap.set(name, "static");
|
|
273
|
-
else if (staticPropNames.has(name)) prefixMap.set(name, "");
|
|
274
|
-
for (const name of instancePropNames) if (instanceMethodNames.has(name) || getterNames.has(name)) prefixMap.set(name, "property");
|
|
275
|
-
else if (staticMethodNames.has(name)) prefixMap.set(name, "");
|
|
276
|
-
for (const name of instanceMethodNames) if (!prefixMap.has(name)) prefixMap.set(name, "");
|
|
277
|
-
for (const name of getterNames) if (!prefixMap.has(name)) prefixMap.set(name, "");
|
|
278
|
-
return prefixMap;
|
|
279
|
-
}
|
|
280
|
-
/**
|
|
281
258
|
* Generate a class member signature with full class context
|
|
282
259
|
* Includes hidden imports with cut directive for external type resolution
|
|
283
260
|
* Uses the simplified approach: 3 lines (class opening, member, closing)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { emitFrontmatterBlock } from "../../frontmatter.js";
|
|
2
2
|
|
|
3
3
|
//#region src/markdown/page-generators/index-pages.ts
|
|
4
4
|
/**
|
|
@@ -9,13 +9,11 @@ var MainIndexPageGenerator = class {
|
|
|
9
9
|
* Generate the main API index page
|
|
10
10
|
*/
|
|
11
11
|
generate(packageName, baseRoute, _categoryCounts) {
|
|
12
|
-
const content =
|
|
13
|
-
title: API Reference
|
|
14
|
-
description: Auto-generated API documentation for ${
|
|
15
|
-
overview: true
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
`;
|
|
12
|
+
const content = emitFrontmatterBlock({
|
|
13
|
+
title: "API Reference",
|
|
14
|
+
description: `Auto-generated API documentation for ${packageName}`,
|
|
15
|
+
overview: true
|
|
16
|
+
});
|
|
19
17
|
return {
|
|
20
18
|
routePath: `${baseRoute}/index`,
|
|
21
19
|
content
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { TypeReferenceExtractor } from "../../type-reference-extractor.js";
|
|
2
|
-
import { escapeMdxGenerics, formatExampleCode, generateAvailableFrom, generateFrontmatter, prepareExampleCode, prependHiddenImports,
|
|
2
|
+
import { escapeMdxGenerics, formatExampleCode, generateAvailableFrom, generateFrontmatter, prepareExampleCode, prependHiddenImports, stripTwoslashDirectives } from "../helpers.js";
|
|
3
3
|
import { linkProse } from "../prose-linker.js";
|
|
4
|
-
import { ApiItems, Signature, Tsdoc } from "@tsdoctor/model";
|
|
4
|
+
import { ApiItems, Routes, Signature, Tsdoc } from "@tsdoctor/model";
|
|
5
5
|
|
|
6
6
|
//#region src/markdown/page-generators/interface-page.ts
|
|
7
7
|
/**
|
|
@@ -98,7 +98,7 @@ var InterfacePageGenerator = class {
|
|
|
98
98
|
content += `## Call Signatures\n\n`;
|
|
99
99
|
for (const callSig of callSignatures) {
|
|
100
100
|
const callSigSummary = Tsdoc.summary(callSig);
|
|
101
|
-
const callSigId =
|
|
101
|
+
const callSigId = Routes.memberAnchor("call-signature");
|
|
102
102
|
const callSigItem = callSig;
|
|
103
103
|
if (callSigItem.excerpt?.text) {
|
|
104
104
|
const memberSignature = Signature.format(callSigItem.excerpt).trim();
|
|
@@ -113,7 +113,7 @@ var InterfacePageGenerator = class {
|
|
|
113
113
|
content += `## Construct Signatures\n\n`;
|
|
114
114
|
for (const constructSig of constructSignatures) {
|
|
115
115
|
const constructSigSummary = Tsdoc.summary(constructSig);
|
|
116
|
-
const constructSigId =
|
|
116
|
+
const constructSigId = Routes.memberAnchor("construct-signature");
|
|
117
117
|
const constructSigItem = constructSig;
|
|
118
118
|
if (constructSigItem.excerpt?.text) {
|
|
119
119
|
const memberSignature = Signature.format(constructSigItem.excerpt).trim();
|
|
@@ -128,7 +128,7 @@ var InterfacePageGenerator = class {
|
|
|
128
128
|
content += `## Index Signature\n\n`;
|
|
129
129
|
for (const indexSig of indexSignatures) {
|
|
130
130
|
const indexSigSummary = Tsdoc.summary(indexSig);
|
|
131
|
-
const indexSigId =
|
|
131
|
+
const indexSigId = Routes.memberAnchor("index-signature");
|
|
132
132
|
const indexSigItem = indexSig;
|
|
133
133
|
if (indexSigItem.excerpt?.text) {
|
|
134
134
|
const memberSignature = Signature.format(indexSigItem.excerpt).trim();
|
|
@@ -143,7 +143,7 @@ var InterfacePageGenerator = class {
|
|
|
143
143
|
content += `## Properties\n\n`;
|
|
144
144
|
for (const prop of properties) {
|
|
145
145
|
const propSummary = Tsdoc.summary(prop);
|
|
146
|
-
const propId =
|
|
146
|
+
const propId = Routes.memberAnchor(prop.displayName);
|
|
147
147
|
const propItem = prop;
|
|
148
148
|
if (propItem.excerpt?.text) {
|
|
149
149
|
const memberSignature = Signature.format(propItem.excerpt).trim();
|
|
@@ -158,7 +158,7 @@ var InterfacePageGenerator = class {
|
|
|
158
158
|
content += `## Methods\n\n`;
|
|
159
159
|
for (const method of methods) {
|
|
160
160
|
const methodSummary = Tsdoc.summary(method);
|
|
161
|
-
const methodId =
|
|
161
|
+
const methodId = Routes.memberAnchor(method.displayName);
|
|
162
162
|
const methodItem = method;
|
|
163
163
|
if (methodItem.excerpt?.text) {
|
|
164
164
|
const memberSignature = Signature.format(methodItem.excerpt).trim();
|
package/markdown/shiki-utils.js
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
import { PluginEvent } from "../observability/events.js";
|
|
2
|
+
import { emitSync, syncBuildId } from "../observability/sync-emitter.js";
|
|
2
3
|
|
|
3
4
|
//#region src/markdown/shiki-utils.ts
|
|
4
|
-
/** Module-level emitter injected by plugin.ts at startup. */
|
|
5
|
-
let emitEvent = () => {};
|
|
6
|
-
let currentBuildId = "";
|
|
7
|
-
function setShikiUtilsEventEmitter(fn, buildId = "") {
|
|
8
|
-
emitEvent = fn;
|
|
9
|
-
currentBuildId = buildId;
|
|
10
|
-
}
|
|
11
5
|
/**
|
|
12
6
|
* Default Shiki theme configuration
|
|
13
7
|
*/
|
|
@@ -48,8 +42,8 @@ async function generateShikiHast(code, highlighter, transformers, enableTwoslash
|
|
|
48
42
|
if (enableTwoslash) options.meta = { __raw: "twoslash" };
|
|
49
43
|
return await highlighter.codeToHast(code, options);
|
|
50
44
|
} catch (error) {
|
|
51
|
-
|
|
52
|
-
ctx: { buildId:
|
|
45
|
+
emitSync(PluginEvent.ShikiError({
|
|
46
|
+
ctx: { buildId: syncBuildId() },
|
|
53
47
|
file: "unknown",
|
|
54
48
|
reason: String(error),
|
|
55
49
|
level: "warn"
|
|
@@ -57,6 +51,67 @@ async function generateShikiHast(code, highlighter, transformers, enableTwoslash
|
|
|
57
51
|
return null;
|
|
58
52
|
}
|
|
59
53
|
}
|
|
54
|
+
/* v8 ignore stop -- everything below is pure and covered by __test__/markdown/shiki-themes.test.ts */
|
|
55
|
+
/**
|
|
56
|
+
* Normalize a theme option into the `{ light, dark }` pair the highlighter and
|
|
57
|
+
* the remark plugins both expect.
|
|
58
|
+
*
|
|
59
|
+
* @remarks
|
|
60
|
+
* Accepts a single theme name applied to both modes, an explicit pair, or a
|
|
61
|
+
* raw theme object. Lives beside {@link DEFAULT_SHIKI_THEMES} because it falls
|
|
62
|
+
* back to it; it was previously duplicated byte-for-byte in `plugin.ts` and
|
|
63
|
+
* `ConfigServiceLive.ts`.
|
|
64
|
+
*/
|
|
65
|
+
function normalizeThemeConfig(theme) {
|
|
66
|
+
if (!theme) return { ...DEFAULT_SHIKI_THEMES };
|
|
67
|
+
if (typeof theme === "string") return {
|
|
68
|
+
light: theme,
|
|
69
|
+
dark: theme
|
|
70
|
+
};
|
|
71
|
+
if ("light" in theme && "dark" in theme && typeof theme.light === "string" && typeof theme.dark === "string") return {
|
|
72
|
+
light: theme.light,
|
|
73
|
+
dark: theme.dark
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
light: theme,
|
|
77
|
+
dark: theme
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Every theme the build's single highlighter must load: one normalized pair
|
|
82
|
+
* per documented API, plus the two defaults.
|
|
83
|
+
*
|
|
84
|
+
* @remarks
|
|
85
|
+
* Takes the RAW option configs rather than resolved ones, because the
|
|
86
|
+
* highlighter is now acquired when its layer builds — before `resolve()` has
|
|
87
|
+
* run. That is sound: a resolved config's `theme` is exactly
|
|
88
|
+
* `normalizeThemeConfig(api.theme)` and no version-level override exists, so
|
|
89
|
+
* the two inputs cannot disagree.
|
|
90
|
+
*
|
|
91
|
+
* Named theme strings are deduplicated; object themes are not (they have no
|
|
92
|
+
* identity to compare), which matches the previous behaviour and is harmless —
|
|
93
|
+
* Shiki keys a loaded theme by its `name`.
|
|
94
|
+
*/
|
|
95
|
+
function collectShikiThemes(apis) {
|
|
96
|
+
const named = /* @__PURE__ */ new Set();
|
|
97
|
+
const objects = [];
|
|
98
|
+
for (const api of apis) {
|
|
99
|
+
const theme = normalizeThemeConfig(api.theme);
|
|
100
|
+
for (const input of [theme.light, theme.dark]) if (typeof input === "string") named.add(input);
|
|
101
|
+
else objects.push(input);
|
|
102
|
+
}
|
|
103
|
+
if (typeof DEFAULT_SHIKI_THEMES.light === "string") named.add(DEFAULT_SHIKI_THEMES.light);
|
|
104
|
+
if (typeof DEFAULT_SHIKI_THEMES.dark === "string") named.add(DEFAULT_SHIKI_THEMES.dark);
|
|
105
|
+
return [...named, ...objects];
|
|
106
|
+
}
|
|
107
|
+
/** Languages the highlighter loads. Every code block the plugin renders is one of these. */
|
|
108
|
+
const SHIKI_LANGS = [
|
|
109
|
+
"typescript",
|
|
110
|
+
"javascript",
|
|
111
|
+
"json",
|
|
112
|
+
"bash",
|
|
113
|
+
"sh"
|
|
114
|
+
];
|
|
60
115
|
|
|
61
116
|
//#endregion
|
|
62
|
-
export { DEFAULT_SHIKI_THEMES, generateShikiHast,
|
|
117
|
+
export { DEFAULT_SHIKI_THEMES, SHIKI_LANGS, collectShikiThemes, generateShikiHast, normalizeThemeConfig };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { BuildId } from "../BuildEnv.js";
|
|
1
2
|
import { LEVEL_RANK, levelOf } from "./events.js";
|
|
2
3
|
import { Context, Effect, Layer, Option } from "effect";
|
|
3
4
|
|
|
@@ -16,11 +17,34 @@ function makeShape(sinks) {
|
|
|
16
17
|
function makeEventBusLayer(sinks) {
|
|
17
18
|
return Layer.succeed(EventBus, makeShape(sinks));
|
|
18
19
|
}
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Emit when a bus is in context; silently no-op otherwise.
|
|
22
|
+
*
|
|
23
|
+
* @remarks
|
|
24
|
+
* Fills `ctx.buildId` from the {@link BuildId} Reference when the caller left
|
|
25
|
+
* it empty, which is why no emit site passes one. Before this, 24 sites wrote
|
|
26
|
+
* `ctx: { buildId: "" }` — 22 in `ConfigServiceLive`, where the real value sat
|
|
27
|
+
* three scopes up and was simply not reached, and every site in
|
|
28
|
+
* `TypeRegistryServiceLive`, where the layer is module-level and there is no
|
|
29
|
+
* build to name. The second group is why a Reference is the fix and a
|
|
30
|
+
* find-and-replace is not: a Reference reaches code that no parameter can.
|
|
31
|
+
*
|
|
32
|
+
* A caller that sets a non-empty `buildId` keeps it, so a test can still emit
|
|
33
|
+
* with an explicit id.
|
|
34
|
+
*/
|
|
22
35
|
function emit(event) {
|
|
23
|
-
return Effect.
|
|
36
|
+
return Effect.gen(function* () {
|
|
37
|
+
const maybe = yield* Effect.serviceOption(EventBus);
|
|
38
|
+
if (Option.isNone(maybe)) return;
|
|
39
|
+
const filled = (event.ctx.buildId ?? "") === "" ? {
|
|
40
|
+
...event,
|
|
41
|
+
ctx: {
|
|
42
|
+
...event.ctx,
|
|
43
|
+
buildId: yield* BuildId
|
|
44
|
+
}
|
|
45
|
+
} : event;
|
|
46
|
+
yield* maybe.value.emit(filled);
|
|
47
|
+
});
|
|
24
48
|
}
|
|
25
49
|
/**
|
|
26
50
|
* Returns true when a bus is in context and has at least one sink admitted at
|
|
@@ -29,10 +53,6 @@ function emit(event) {
|
|
|
29
53
|
function wantsLevel(level) {
|
|
30
54
|
return Effect.serviceOption(EventBus).pipe(Effect.flatMap((maybe) => Option.isSome(maybe) ? maybe.value.wantsLevel(level) : Effect.succeed(false)));
|
|
31
55
|
}
|
|
32
|
-
/** Bind a runtime so non-Effect (sync island) callbacks can emit. */
|
|
33
|
-
function makeRuntimeEmitter(runtime) {
|
|
34
|
-
return (event) => runtime.runSync(emit(event));
|
|
35
|
-
}
|
|
36
56
|
|
|
37
57
|
//#endregion
|
|
38
|
-
export { EventBus, emit, makeEventBusLayer,
|
|
58
|
+
export { EventBus, emit, makeEventBusLayer, wantsLevel };
|
|
@@ -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}`;
|