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.
Files changed (52) hide show
  1. package/BuildEnv.js +58 -0
  2. package/README.md +2 -1
  3. package/build-program.js +33 -30
  4. package/build-stages.js +47 -39
  5. package/errors.js +0 -1
  6. package/index.d.ts +22 -14
  7. package/layers/ConfigServiceLive.js +349 -400
  8. package/layers/HighlighterServiceLive.js +52 -0
  9. package/layers/ObservabilityLive.js +26 -7
  10. package/layers/OgServiceLive.js +134 -0
  11. package/layers/TwoslashCacheServiceLive.js +108 -0
  12. package/layers/TwoslashEnvironmentsLive.js +33 -0
  13. package/layers/TypeRegistryServiceLive.js +54 -47
  14. package/layers/build-metrics.js +32 -5
  15. package/layers/xdg.js +44 -0
  16. package/markdown/helpers.js +9 -55
  17. package/markdown/page-generators/class-page.js +8 -31
  18. package/markdown/page-generators/index-pages.js +6 -8
  19. package/markdown/page-generators/interface-page.js +7 -7
  20. package/markdown/shiki-utils.js +65 -10
  21. package/observability/EventBus.js +29 -9
  22. package/observability/heartbeat.js +1 -1
  23. package/observability/metric-report.js +124 -0
  24. package/observability/sinks/console-sink.js +6 -0
  25. package/observability/sinks/metrics-sink.js +64 -21
  26. package/observability/sinks/render-sink.js +86 -0
  27. package/observability/sinks/trace-sink.js +10 -17
  28. package/observability/spans.js +4 -2
  29. package/observability/sync-emitter.js +78 -0
  30. package/og-resolver.js +46 -287
  31. package/package.json +4 -5
  32. package/path-derivation.js +19 -1
  33. package/plugin.js +64 -52
  34. package/prettier-formatter.js +4 -10
  35. package/remark-api-codeblocks.js +33 -15
  36. package/remark-with-api.js +24 -27
  37. package/schemas/config.js +11 -7
  38. package/services/HighlighterService.js +30 -0
  39. package/services/OgService.js +23 -0
  40. package/services/PluginConfig.js +26 -0
  41. package/services/TwoslashCacheService.js +15 -0
  42. package/services/TwoslashEnvironments.js +7 -0
  43. package/shiki-transformer.js +55 -256
  44. package/twoslash-access.js +48 -0
  45. package/twoslash-cache.js +174 -0
  46. package/twoslash-patterns.js +1 -1
  47. package/twoslash-timing-wrapper.js +23 -0
  48. package/twoslash-transformer.js +153 -89
  49. package/vfs-registry.js +1 -31
  50. package/layers/PathDerivationServiceLive.js +0 -16
  51. package/runtime/components/MarkdownText/index.js +0 -34
  52. 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 };
@@ -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
- * Sanitize a display name to create a URL-safe HTML ID.
54
- *
55
- * Converts a display name (e.g., method or property name) into a valid
56
- * HTML ID suitable for anchor links. Handles special characters, quotes,
57
- * and optionally adds a prefix for disambiguation.
58
- *
59
- * @param displayName - The original display name
60
- * @param prefix - Optional prefix to add (e.g., "static-property")
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, escapeYamlString, formatExampleCode, formatImportsWithCut, generateAvailableFrom, generateFrontmatter, prepareExampleCode, prependHiddenImports, sanitizeId, stripTwoslashDirectives };
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, sanitizeId, stripTwoslashDirectives } from "../helpers.js";
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 = sanitizeId("constructor");
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 prefixMap = this.detectMemberConflicts(staticProperties, grouped.staticMethods, instanceProperties, grouped.instanceMethods, grouped.getters);
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 baseName = sanitizeId(prop.displayName);
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 baseName = sanitizeId(method.displayName);
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 { escapeYamlString } from "../helpers.js";
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 ${escapeYamlString(packageName)}
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, sanitizeId, stripTwoslashDirectives } from "../helpers.js";
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 = sanitizeId("call-signature");
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 = sanitizeId("construct-signature");
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 = sanitizeId("index-signature");
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 = sanitizeId(prop.displayName);
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 = sanitizeId(method.displayName);
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();
@@ -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
- emitEvent(PluginEvent.ShikiError({
52
- ctx: { buildId: currentBuildId },
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, setShikiUtilsEventEmitter };
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
- /** No sinks: every emit is a no-op, wantsLevel always false. */
20
- const EventBusNoop = makeEventBusLayer([]);
21
- /** Emit when a bus is in context; silently no-op otherwise. */
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.serviceOption(EventBus).pipe(Effect.flatMap((maybe) => Option.isSome(maybe) ? maybe.value.emit(event) : Effect.void));
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, makeRuntimeEmitter, wantsLevel };
58
+ export { EventBus, emit, makeEventBusLayer, wantsLevel };
@@ -70,4 +70,4 @@ function runHeartbeat(opts) {
70
70
  }
71
71
 
72
72
  //#endregion
73
- export { formatProgress, makeProgressEvent, readCounts, runHeartbeat };
73
+ export { formatProgress, makeProgressEvent, runHeartbeat };
@@ -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}`;