rspress-plugin-api-extractor 0.10.0 → 0.12.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 (54) hide show
  1. package/BuildEnv.js +58 -0
  2. package/build-program.js +34 -33
  3. package/build-stages.js +48 -42
  4. package/config-helpers.js +7 -7
  5. package/errors.js +1 -6
  6. package/index.d.ts +84 -86
  7. package/layers/AppLayer.js +67 -0
  8. package/layers/api-results.js +83 -0
  9. package/layers/build-metrics.js +1 -1
  10. package/layers/config-resolution.js +407 -0
  11. package/layers/external-types.js +74 -0
  12. package/layers/{ObservabilityLive.js → observability.js} +3 -3
  13. package/layers/type-environment.js +109 -0
  14. package/layers/xdg.js +44 -0
  15. package/markdown/helpers.js +9 -55
  16. package/markdown/page-generators/class-page.js +8 -31
  17. package/markdown/page-generators/index-pages.js +6 -8
  18. package/markdown/page-generators/interface-page.js +7 -7
  19. package/markdown/shiki-utils.js +65 -10
  20. package/model-loader.js +3 -3
  21. package/observability/EventBus.js +29 -7
  22. package/observability/heartbeat.js +1 -1
  23. package/observability/sinks/metrics-sink.js +1 -1
  24. package/observability/sinks/trace-sink.js +4 -4
  25. package/observability/spans.js +3 -1
  26. package/observability/sync-emitter.js +78 -0
  27. package/og-resolver.js +74 -284
  28. package/package.json +3 -4
  29. package/path-derivation.js +19 -1
  30. package/plugin.js +63 -91
  31. package/prettier-formatter.js +5 -11
  32. package/remark-api-codeblocks.js +11 -19
  33. package/remark-with-api.js +11 -21
  34. package/schemas/config.js +0 -2
  35. package/services/ConfigService.js +37 -2
  36. package/services/HighlighterService.js +75 -0
  37. package/services/OgService.js +190 -0
  38. package/services/PluginConfig.js +26 -0
  39. package/services/TwoslashCacheService.js +128 -2
  40. package/services/TwoslashEnvironments.js +35 -0
  41. package/services/TypeRegistryService.js +178 -2
  42. package/shiki-transformer.js +53 -234
  43. package/sync-node-fs.js +6 -6
  44. package/tsconfig-parser.js +77 -95
  45. package/twoslash-access.js +48 -0
  46. package/twoslash-transformer.js +106 -83
  47. package/vfs-registry.js +1 -31
  48. package/layers/ConfigServiceLive.js +0 -600
  49. package/layers/PathDerivationServiceLive.js +0 -16
  50. package/layers/TwoslashCacheServiceLive.js +0 -53
  51. package/layers/TypeRegistryServiceLive.js +0 -155
  52. package/markdown/index.js +0 -11
  53. package/schemas/index.js +0 -6
  54. package/services/PathDerivationService.js +0 -7
@@ -0,0 +1,109 @@
1
+ import { PluginEvent } from "../observability/events.js";
2
+ import { emit } from "../observability/EventBus.js";
3
+ import { resolveTypeScriptConfig } from "../typescript-config.js";
4
+ import { TwoslashEnvironments } from "../services/TwoslashEnvironments.js";
5
+ import { ConfigValidationError } from "../errors.js";
6
+ import { twoslashEnvHash } from "../twoslash-cache.js";
7
+ import { TwoslashCacheService } from "../services/TwoslashCacheService.js";
8
+ import { Effect } from "effect";
9
+ import ts from "typescript";
10
+
11
+ //#region src/layers/type-environment.ts
12
+ /**
13
+ * Building the build's Twoslash type-checking environments.
14
+ *
15
+ * @remarks
16
+ * The last phase of config resolution, and the only one that has to run after
17
+ * every API has contributed to the VFS: the result cache is keyed on the type
18
+ * environment, so it can only be opened once the VFS is final, and no code
19
+ * block may be rendered before it is.
20
+ *
21
+ * @packageDocumentation
22
+ */
23
+ /**
24
+ * Resolve a TypeScript configuration, failing typed on a malformed one.
25
+ *
26
+ * @remarks
27
+ * `resolveTypeScriptConfig` throws a `TsConfigParseError` for a missing file, a
28
+ * syntax error or a semantically invalid config. Both call sites used to run it
29
+ * inside `Effect.promise`, so all three became untyped defects: the build died
30
+ * with an unhandled rejection naming a file the user could fix, and wrote no
31
+ * `issues.json` entry for it.
32
+ *
33
+ * A malformed tsconfig is a user misconfiguration, so it is fatal and TYPED —
34
+ * not degraded. Falling back to default compiler options would type-check every
35
+ * example against a configuration the user did not ask for and silently render
36
+ * wrong hovers, which is the failure shape this subsystem suffers from most
37
+ * (see the `lib`-spelling defect in `type-loading-vfs.md`).
38
+ */
39
+ const resolveTsConfigTyped = (projectRoot, config) => Effect.tryPromise({
40
+ try: () => resolveTypeScriptConfig(projectRoot, config),
41
+ catch: (cause) => new ConfigValidationError({
42
+ field: "tsconfig",
43
+ reason: cause instanceof Error ? cause.message : String(cause),
44
+ cause
45
+ })
46
+ });
47
+ /**
48
+ * Open the Twoslash result cache and register one environment per distinct
49
+ * compiler configuration.
50
+ *
51
+ * @remarks
52
+ * **The build-wide options are registered FIRST, deliberately.** An unknown
53
+ * scope falls back to the first environment registered, and a `with-api` fence
54
+ * can appear on a page outside any documented package's route — checking it
55
+ * under the build-wide configuration beats not checking it at all.
56
+ *
57
+ * Resolution is memoised twice over. This function memoises by RAW config, so
58
+ * N APIs sharing a tsconfig read it from disk once; `registerEnvironment` then
59
+ * dedupes by a fingerprint of the ENCODED options, so APIs that spell the same
60
+ * configuration differently still share one TypeScript environment. The second
61
+ * of those is load-bearing: when the two fingerprints drifted apart once, every
62
+ * scope lookup missed, per-scope type-checking silently degraded to build-wide,
63
+ * and a 994-test suite stayed green through it.
64
+ */
65
+ const registerTypeEnvironments = (input) => Effect.gen(function* () {
66
+ const twoslashEnv = twoslashEnvHash(input.combinedVfs, `typescript@${ts.version}`);
67
+ const twoslashCache = yield* (yield* TwoslashCacheService).open(twoslashEnv);
68
+ yield* emit(PluginEvent.TwoslashCacheLoaded({
69
+ ctx: {},
70
+ level: "debug",
71
+ envHash: twoslashEnv,
72
+ entries: twoslashCache.entries().size
73
+ }));
74
+ const twoslashStartMs = performance.now();
75
+ const environments = yield* TwoslashEnvironments;
76
+ environments.registerEnvironment({
77
+ vfs: input.combinedVfs,
78
+ compilerOptions: input.resolvedCompilerOptions,
79
+ typesCache: twoslashCache
80
+ });
81
+ const resolvedByRawConfig = /* @__PURE__ */ new Map();
82
+ for (const [apiScope, rawConfig] of input.scopeTsConfigs) {
83
+ if (rawConfig === void 0) {
84
+ environments.registerScope(apiScope, input.resolvedCompilerOptions);
85
+ continue;
86
+ }
87
+ const rawKey = JSON.stringify([String(rawConfig.tsconfig ?? ""), rawConfig.compilerOptions ?? null]);
88
+ let scopeOptions = resolvedByRawConfig.get(rawKey);
89
+ if (scopeOptions === void 0) {
90
+ scopeOptions = yield* resolveTsConfigTyped(input.projectRoot, rawConfig);
91
+ resolvedByRawConfig.set(rawKey, scopeOptions);
92
+ }
93
+ environments.registerEnvironment({
94
+ vfs: input.combinedVfs,
95
+ compilerOptions: scopeOptions,
96
+ typesCache: twoslashCache
97
+ });
98
+ environments.registerScope(apiScope, scopeOptions);
99
+ }
100
+ yield* emit(PluginEvent.TwoslashInitialized({
101
+ ctx: {},
102
+ level: "debug",
103
+ durationMs: Math.round(performance.now() - twoslashStartMs),
104
+ vfsFileCount: input.combinedVfs.size
105
+ }));
106
+ });
107
+
108
+ //#endregion
109
+ export { registerTypeEnvironments, resolveTsConfigTyped };
package/layers/xdg.js ADDED
@@ -0,0 +1,44 @@
1
+ import { Layer, Path } from "effect";
2
+ import { NodeFileSystem } from "@effect/platform-node";
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, 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
+ * `ConfigService.layer.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 };
package/model-loader.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { isLoadedModel, isVersionConfig } from "./config-utils.js";
2
- import fs from "node:fs";
2
+ import fsSync from "node:fs";
3
3
  import path from "node:path";
4
4
  import { Effect } from "effect";
5
5
  import { Model } from "@tsdoctor/model";
@@ -10,8 +10,8 @@ import { Model } from "@tsdoctor/model";
10
10
  */
11
11
  async function loadPackageJsonFromPath(pkgPath) {
12
12
  const resolvedPath = path.resolve(pkgPath.toString());
13
- if (!fs.existsSync(resolvedPath)) throw new Error(`Package.json file not found: ${resolvedPath}`);
14
- const content = fs.readFileSync(resolvedPath, "utf-8");
13
+ if (!fsSync.existsSync(resolvedPath)) throw new Error(`Package.json file not found: ${resolvedPath}`);
14
+ const content = fsSync.readFileSync(resolvedPath, "utf-8");
15
15
  try {
16
16
  return JSON.parse(content);
17
17
  } catch (error) {
@@ -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,9 +17,34 @@ function makeShape(sinks) {
16
17
  function makeEventBusLayer(sinks) {
17
18
  return Layer.succeed(EventBus, makeShape(sinks));
18
19
  }
19
- /** 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 `ConfigService.layer`, where the real value sat
27
+ * three scopes up and was simply not reached, and every site in
28
+ * `TypeRegistryService.layer`, 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
+ */
20
35
  function emit(event) {
21
- 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
+ });
22
48
  }
23
49
  /**
24
50
  * Returns true when a bus is in context and has at least one sink admitted at
@@ -27,10 +53,6 @@ function emit(event) {
27
53
  function wantsLevel(level) {
28
54
  return Effect.serviceOption(EventBus).pipe(Effect.flatMap((maybe) => Option.isSome(maybe) ? maybe.value.wantsLevel(level) : Effect.succeed(false)));
29
55
  }
30
- /** Bind a runtime so non-Effect (sync island) callbacks can emit. */
31
- function makeRuntimeEmitter(runtime) {
32
- return (event) => runtime.runSync(emit(event));
33
- }
34
56
 
35
57
  //#endregion
36
- export { EventBus, emit, makeEventBusLayer, makeRuntimeEmitter, wantsLevel };
58
+ export { EventBus, emit, makeEventBusLayer, wantsLevel };
@@ -1,6 +1,6 @@
1
+ import { BuildMetrics } from "../layers/build-metrics.js";
1
2
  import { PluginEvent } from "./events.js";
2
3
  import { emit } from "./EventBus.js";
3
- import { BuildMetrics } from "../layers/build-metrics.js";
4
4
  import { Duration, Effect, Metric, Ref } from "effect";
5
5
 
6
6
  //#region src/observability/heartbeat.ts
@@ -18,7 +18,7 @@ import { Metric } from "effect";
18
18
  * such as a file path, belongs in a sample-shaped sink instead.
19
19
  *
20
20
  * Intentionally NOT derived here: `externalPackagesTotal` and `apiVersionsLoaded`
21
- * remain inline increments in `ConfigServiceLive`. `externalPackagesTotal` counts
21
+ * remain inline increments in `ConfigService.layer`. `externalPackagesTotal` counts
22
22
  * CONFIGURED packages via `incrementBy(length)`; the only candidate event,
23
23
  * `TypeRegistryEvent{BatchComplete}`, carries an unstructured `detail` string and
24
24
  * a `loaded` (SUCCEEDED) count — different semantics, so deriving it here would
@@ -1,10 +1,10 @@
1
- import fs from "node:fs";
1
+ import fsSync from "node:fs";
2
2
  import path from "node:path";
3
3
 
4
4
  //#region src/observability/sinks/trace-sink.ts
5
5
  function openTracePath(p) {
6
- fs.mkdirSync(path.dirname(p), { recursive: true });
7
- fs.writeFileSync(p, "");
6
+ fsSync.mkdirSync(path.dirname(p), { recursive: true });
7
+ fsSync.writeFileSync(p, "");
8
8
  }
9
9
  /**
10
10
  * Create a JSONL trace sink, opening the file eagerly at construction.
@@ -21,7 +21,7 @@ function makeTraceSink(tracePath) {
21
21
  minLevel: "trace",
22
22
  capturesPayload: true,
23
23
  handle: (event) => {
24
- fs.appendFileSync(tracePath, `${JSON.stringify(event)}\n`);
24
+ fsSync.appendFileSync(tracePath, `${JSON.stringify(event)}\n`);
25
25
  },
26
26
  flush: () => {}
27
27
  };
@@ -1,3 +1,4 @@
1
+ import { Thresholds } from "../BuildEnv.js";
1
2
  import { PluginEvent } from "./events.js";
2
3
  import { emit } from "./EventBus.js";
3
4
  import { Effect } from "effect";
@@ -24,8 +25,9 @@ const PHASE_THRESHOLD_KEY = {
24
25
  * R type is unchanged: emit() is serviceOption-based (R = never), so adding
25
26
  * the span/events adds zero requirements to the caller.
26
27
  */
27
- function withPhase(phase, ctx, effect, thresholds) {
28
+ function withPhase(phase, ctx, effect) {
28
29
  return Effect.gen(function* () {
30
+ const thresholds = yield* Thresholds;
29
31
  yield* emit(PluginEvent.PhaseStarted({
30
32
  ctx,
31
33
  level: "debug",