rspress-plugin-api-extractor 0.11.0 → 0.13.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 (49) hide show
  1. package/BuildEnv.js +3 -3
  2. package/README.md +1 -0
  3. package/build-program.js +14 -5
  4. package/build-stages.js +88 -49
  5. package/config-helpers.js +7 -7
  6. package/errors.js +1 -5
  7. package/index.d.ts +1 -86
  8. package/layers/AppLayer.js +67 -0
  9. package/layers/api-results.js +83 -0
  10. package/layers/build-metrics.js +1 -1
  11. package/layers/{ConfigServiceLive.js → config-resolution.js} +115 -175
  12. package/layers/external-types.js +74 -0
  13. package/layers/{ObservabilityLive.js → observability.js} +3 -3
  14. package/layers/type-environment.js +109 -0
  15. package/layers/xdg.js +1 -1
  16. package/markdown/helpers.js +8 -33
  17. package/markdown/shiki-utils.js +1 -1
  18. package/model-loader.js +3 -3
  19. package/observability/EventBus.js +2 -2
  20. package/observability/heartbeat.js +1 -1
  21. package/observability/sinks/metrics-sink.js +1 -1
  22. package/observability/sinks/trace-sink.js +4 -4
  23. package/package.json +5 -4
  24. package/plugin.js +30 -33
  25. package/prettier-formatter.js +1 -1
  26. package/remark-api-codeblocks.js +1 -1
  27. package/remark-with-api.js +1 -1
  28. package/schemas/config.js +1 -3
  29. package/services/ConfigService.js +37 -2
  30. package/services/HighlighterService.js +48 -3
  31. package/services/OgService.js +154 -2
  32. package/services/PluginConfig.js +1 -1
  33. package/services/TwoslashCacheService.js +128 -2
  34. package/services/TwoslashEnvironments.js +30 -2
  35. package/services/TypeRegistryService.js +178 -2
  36. package/shiki-transformer.js +1 -1
  37. package/sync-node-fs.js +6 -6
  38. package/tsconfig-parser.js +77 -95
  39. package/twoslash-access.js +1 -1
  40. package/twoslash-transformer.js +1 -1
  41. package/layers/HighlighterServiceLive.js +0 -52
  42. package/layers/OgServiceLive.js +0 -134
  43. package/layers/TwoslashCacheServiceLive.js +0 -108
  44. package/layers/TwoslashEnvironmentsLive.js +0 -33
  45. package/layers/TypeRegistryServiceLive.js +0 -162
  46. package/markdown/index.js +0 -11
  47. package/og-resolver.js +0 -64
  48. package/schemas/index.js +0 -6
  49. package/schemas/opengraph.js +0 -56
@@ -5,15 +5,6 @@ import { TypeReferenceExtractor } from "../type-reference-extractor.js";
5
5
 
6
6
  //#region src/markdown/helpers.ts
7
7
  /**
8
- * Helper utilities for generating markdown API documentation.
9
- *
10
- * This module provides shared utility functions used by the page generators
11
- * for common tasks like preparing Twoslash examples, generating frontmatter,
12
- * escaping special characters, and sanitizing IDs.
13
- *
14
- * @packageDocumentation
15
- */
16
- /**
17
8
  * Generate an "Available from" line for items exported from multiple entry points.
18
9
  * Returns empty string if only one entry point or none provided.
19
10
  */
@@ -118,7 +109,7 @@ function buildPageTitle(entityName, singularName, apiName) {
118
109
  * @param description - Page description for SEO
119
110
  * @param singularName - The category singular name (e.g., "Class")
120
111
  * @param apiName - Optional API/package display name
121
- * @param ogMetadata - Optional Open Graph metadata for social sharing
112
+ * @param tags - Optional neutral head tags to render into the `head` array
122
113
  * @returns YAML frontmatter string
123
114
  *
124
115
  * @example
@@ -136,30 +127,14 @@ function buildPageTitle(entityName, singularName, apiName) {
136
127
  * // ---
137
128
  * ```
138
129
  */
139
- function generateFrontmatter(entityName, description, singularName, apiName, ogMetadata) {
130
+ function generateFrontmatter(entityName, description, singularName, apiName, tags) {
140
131
  const title = buildPageTitle(entityName, singularName, apiName);
141
- const meta = (property, content) => ["meta", {
142
- property,
143
- content: cleanYamlValue(content)
144
- }];
145
- const headEntries = [];
146
- if (ogMetadata) {
147
- headEntries.push(meta("og:url", `${ogMetadata.siteUrl}${ogMetadata.pageRoute}`));
148
- headEntries.push(meta("og:type", ogMetadata.ogType));
149
- headEntries.push(meta("og:description", ogMetadata.description));
150
- if (ogMetadata.ogImage) {
151
- headEntries.push(meta("og:image", ogMetadata.ogImage.url));
152
- if (ogMetadata.ogImage.secureUrl) headEntries.push(meta("og:image:secure_url", ogMetadata.ogImage.secureUrl));
153
- if (ogMetadata.ogImage.type) headEntries.push(meta("og:image:type", ogMetadata.ogImage.type));
154
- if (ogMetadata.ogImage.width) headEntries.push(meta("og:image:width", String(ogMetadata.ogImage.width)));
155
- if (ogMetadata.ogImage.height) headEntries.push(meta("og:image:height", String(ogMetadata.ogImage.height)));
156
- if (ogMetadata.ogImage.alt) headEntries.push(meta("og:image:alt", ogMetadata.ogImage.alt));
157
- }
158
- headEntries.push(meta("article:published_time", ogMetadata.publishedTime));
159
- headEntries.push(meta("article:modified_time", ogMetadata.modifiedTime));
160
- headEntries.push(meta("article:section", ogMetadata.section));
161
- for (const tag of ogMetadata.tags) headEntries.push(meta("article:tag", tag));
162
- }
132
+ const headEntries = (tags ?? []).map((tag) => {
133
+ const attrs = {};
134
+ for (const [key, value] of Object.entries(tag.attrs)) attrs[key] = cleanYamlValue(value);
135
+ if (tag.body != null) attrs.children = cleanYamlValue(tag.body);
136
+ return [tag.tag, attrs];
137
+ });
163
138
  const data = {
164
139
  title: cleanYamlValue(title),
165
140
  description: cleanYamlValue(description)
@@ -60,7 +60,7 @@ async function generateShikiHast(code, highlighter, transformers, enableTwoslash
60
60
  * Accepts a single theme name applied to both modes, an explicit pair, or a
61
61
  * raw theme object. Lives beside {@link DEFAULT_SHIKI_THEMES} because it falls
62
62
  * back to it; it was previously duplicated byte-for-byte in `plugin.ts` and
63
- * `ConfigServiceLive.ts`.
63
+ * `ConfigService.layer.ts`.
64
64
  */
65
65
  function normalizeThemeConfig(theme) {
66
66
  if (!theme) return { ...DEFAULT_SHIKI_THEMES };
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) {
@@ -23,9 +23,9 @@ function makeEventBusLayer(sinks) {
23
23
  * @remarks
24
24
  * Fills `ctx.buildId` from the {@link BuildId} Reference when the caller left
25
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
26
+ * `ctx: { buildId: "" }` — 22 in `ConfigService.layer`, where the real value sat
27
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
28
+ * `TypeRegistryService.layer`, where the layer is module-level and there is no
29
29
  * build to name. The second group is why a Reference is the fix and a
30
30
  * find-and-replace is not: a Reference reaches code that no parameter can.
31
31
  *
@@ -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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rspress-plugin-api-extractor",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "private": false,
5
5
  "description": "RSPress plugin for generating API documentation from TypeScript API Extractor models",
6
6
  "keywords": [
@@ -43,7 +43,7 @@
43
43
  "@effected/jsonc": "^0.8.0",
44
44
  "@effected/markdown": "^0.7.0",
45
45
  "@effected/npm": "^0.12.1",
46
- "@effected/package-json": "^0.12.0",
46
+ "@effected/package-json": "^0.13.0",
47
47
  "@effected/semver": "^0.5.0",
48
48
  "@effected/store": "^0.5.0",
49
49
  "@effected/tsconfig-json": "^0.6.1",
@@ -53,9 +53,10 @@
53
53
  "@microsoft/api-extractor-model": "^7.33.11",
54
54
  "@shikijs/twoslash": "^4.4.3",
55
55
  "@tsdoctor/bundle": "0.2.0",
56
- "@tsdoctor/model": "0.3.0",
56
+ "@tsdoctor/model": "0.4.0",
57
57
  "@tsdoctor/registry": "0.2.1",
58
- "@tsdoctor/snapshot": "0.1.1",
58
+ "@tsdoctor/seo": "0.1.0",
59
+ "@tsdoctor/snapshot": "0.2.1",
59
60
  "@typescript/vfs": "^1.6.4",
60
61
  "clsx": "^2.1.1",
61
62
  "effect": "4.0.0-rc.109",
package/plugin.js CHANGED
@@ -1,44 +1,33 @@
1
- import { BuildId, PageConcurrency, SuppressExampleErrors, Thresholds } from "./BuildEnv.js";
2
1
  import { PluginEvent } from "./observability/events.js";
3
2
  import { emit } from "./observability/EventBus.js";
4
- import { codeBlockReport } from "./observability/metric-report.js";
5
- import { runHeartbeat } from "./observability/heartbeat.js";
6
- import { writeIssuesJson } from "./observability/sinks/issues-sink.js";
7
- import { writeRenderPhaseJson } from "./observability/sinks/render-sink.js";
8
- import { buildEventBus, logBuildSummary, makeSummaryLoggerLayer } from "./layers/ObservabilityLive.js";
9
3
  import { emitSync, installSyncEmitter } from "./observability/sync-emitter.js";
10
- import { TwoslashEnvironments } from "./services/TwoslashEnvironments.js";
4
+ import { normalizeThemeConfig } from "./markdown/shiki-utils.js";
11
5
  import { clearTypeRoutes } from "./twoslash-transformer.js";
6
+ import { TwoslashEnvironments } from "./services/TwoslashEnvironments.js";
12
7
  import { VfsRegistry } from "./vfs-registry.js";
13
8
  import { generateApiDocs } from "./build-program.js";
14
9
  import { deriveOutputPaths, normalizeBaseRoute, unscopedName } from "./path-derivation.js";
15
10
  import { fromDir, fromParentDir } from "./config-helpers.js";
16
11
  import { classifyApiConfig, mergeLlmsPluginConfig } from "./config-utils.js";
17
- import { collectShikiThemes, normalizeThemeConfig } from "./markdown/shiki-utils.js";
18
12
  import { resolveObservability } from "./schemas/observability.js";
19
13
  import { PluginOptions } from "./schemas/config.js";
20
- import "./schemas/index.js";
21
- import { ConfigService } from "./services/ConfigService.js";
22
- import { PluginConfig } from "./services/PluginConfig.js";
23
14
  import { TwoslashCacheService } from "./services/TwoslashCacheService.js";
24
- import { ConfigServiceLive } from "./layers/ConfigServiceLive.js";
25
- import { HighlighterServiceLive } from "./layers/HighlighterServiceLive.js";
26
- import { OgServiceLive } from "./layers/OgServiceLive.js";
27
- import { PlatformLive } from "./layers/xdg.js";
28
- import { TwoslashCacheServiceLive } from "./layers/TwoslashCacheServiceLive.js";
29
- import { TwoslashEnvironmentsLive } from "./layers/TwoslashEnvironmentsLive.js";
30
- import { TypeRegistryServiceLive } from "./layers/TypeRegistryServiceLive.js";
15
+ import { ConfigService } from "./services/ConfigService.js";
16
+ import { codeBlockReport } from "./observability/metric-report.js";
17
+ import { runHeartbeat } from "./observability/heartbeat.js";
18
+ import { writeIssuesJson } from "./observability/sinks/issues-sink.js";
19
+ import { writeRenderPhaseJson } from "./observability/sinks/render-sink.js";
20
+ import { buildEventBus, logBuildSummary } from "./layers/observability.js";
21
+ import { makeAppLayers } from "./layers/AppLayer.js";
31
22
  import { clearTwoslashAccess, installTwoslashAccess, twoslashTransformerFor } from "./twoslash-access.js";
32
23
  import { remarkApiCodeblocks } from "./remark-api-codeblocks.js";
33
24
  import { remarkWithApi } from "./remark-with-api.js";
34
25
  import { createRequire } from "node:module";
35
- import fs from "node:fs";
26
+ import fsSync from "node:fs";
36
27
  import os from "node:os";
37
28
  import path from "node:path";
38
29
  import { fileURLToPath } from "node:url";
39
- import { NodeFileSystem } from "@effect/platform-node";
40
- import { SnapshotServiceLive } from "@tsdoctor/snapshot";
41
- import { Effect, FileSystem, Layer, ManagedRuntime, Option, Ref, Schema } from "effect";
30
+ import { Effect, FileSystem, ManagedRuntime, Option, Ref, Schema } from "effect";
42
31
 
43
32
  //#region src/plugin.ts
44
33
  /* v8 ignore start -- RSPress plugin adapter, requires RSPress runtime */
@@ -75,14 +64,18 @@ function ApiExtractorPluginImpl(rawOptions) {
75
64
  });
76
65
  const { layer: eventBusLayer, trace: traceSink, issues: issuesSink, render: renderSink, metrics: metricStore } = buildEventBus(obs);
77
66
  const dbPath = path.resolve(process.cwd(), ".api-docs", "snapshot", "api-docs.db");
78
- fs.mkdirSync(path.dirname(dbPath), { recursive: true });
79
- const BuildEnvLayer = Layer.mergeAll(Layer.succeed(BuildId, buildId), Layer.succeed(Thresholds, obs.thresholds), Layer.succeed(PageConcurrency, os.cpus().length), Layer.succeed(SuppressExampleErrors, options.errors?.example !== "show"));
80
- const PluginConfigLive = Layer.succeed(PluginConfig, options);
81
- const HighlighterLive = HighlighterServiceLive(collectShikiThemes(options.api ? [options.api] : options.apis ?? []));
82
- const BaseLayer = Layer.mergeAll(eventBusLayer, PluginConfigLive, HighlighterLive, TwoslashEnvironmentsLive, Layer.provide(OgServiceLive, PlatformLive), BuildEnvLayer, metricStore.layer, TypeRegistryServiceLive, NodeFileSystem.layer, SnapshotServiceLive(dbPath), TwoslashCacheServiceLive, makeSummaryLoggerLayer(obs.logLevel));
83
- const EffectAppLayer = Layer.provideMerge(ConfigServiceLive, BaseLayer);
84
- const effectRuntime = ManagedRuntime.make(EffectAppLayer);
85
- const emitterRuntime = ManagedRuntime.make(Layer.mergeAll(eventBusLayer, metricStore.layer, makeSummaryLoggerLayer(obs.logLevel), BuildEnvLayer));
67
+ fsSync.mkdirSync(path.dirname(dbPath), { recursive: true });
68
+ const appLayers = makeAppLayers({
69
+ options,
70
+ obs,
71
+ buildId,
72
+ dbPath,
73
+ pageConcurrency: os.cpus().length,
74
+ eventBus: eventBusLayer,
75
+ metrics: metricStore
76
+ });
77
+ const effectRuntime = ManagedRuntime.make(appLayers.app);
78
+ const emitterRuntime = ManagedRuntime.make(appLayers.emitter);
86
79
  installSyncEmitter(emitterRuntime);
87
80
  const fileContextMap = /* @__PURE__ */ new Map();
88
81
  let docsRoot;
@@ -153,6 +146,8 @@ function ApiExtractorPluginImpl(rawOptions) {
153
146
  const rspressLocales = _config.locales?.map((l) => l.lang) ?? [];
154
147
  const rspressLang = _config.lang;
155
148
  const rspressMultiVersion = _config.multiVersion;
149
+ const rspressSiteOrigin = _config.siteOrigin;
150
+ const rspressBase = _config.base;
156
151
  rspressLlmsEnabled = Boolean(_config.llms);
157
152
  rspressOutDir = _config.outDir ?? "dist";
158
153
  if (options.api) {
@@ -169,7 +164,7 @@ function ApiExtractorPluginImpl(rawOptions) {
169
164
  versions,
170
165
  defaultVersion: rspressMultiVersion?.default
171
166
  });
172
- for (const dp of derivedPaths) fs.mkdirSync(dp.outputDir, { recursive: true });
167
+ for (const dp of derivedPaths) fsSync.mkdirSync(dp.outputDir, { recursive: true });
173
168
  } else if (options.apis) for (const api of options.apis) {
174
169
  const baseRoute = normalizeBaseRoute(api.baseRoute ?? `/${unscopedName(api.packageName)}`);
175
170
  const derivedPaths = deriveOutputPaths({
@@ -182,7 +177,7 @@ function ApiExtractorPluginImpl(rawOptions) {
182
177
  versions: [],
183
178
  defaultVersion: void 0
184
179
  });
185
- for (const dp of derivedPaths) fs.mkdirSync(dp.outputDir, { recursive: true });
180
+ for (const dp of derivedPaths) fsSync.mkdirSync(dp.outputDir, { recursive: true });
186
181
  }
187
182
  VfsRegistry.clear();
188
183
  clearTwoslashAccess();
@@ -200,7 +195,9 @@ function ApiExtractorPluginImpl(rawOptions) {
200
195
  ...rspressMultiVersion != null ? { multiVersion: rspressMultiVersion } : {},
201
196
  ...rspressLocales.length > 0 ? { locales: rspressLocales.map((lang) => ({ lang })) } : {},
202
197
  ...rspressLang != null ? { lang: rspressLang } : {},
203
- ...docsRoot != null ? { root: docsRoot } : {}
198
+ ...docsRoot != null ? { root: docsRoot } : {},
199
+ ...rspressSiteOrigin != null ? { siteOrigin: rspressSiteOrigin } : {},
200
+ ...rspressBase != null ? { base: rspressBase } : {}
204
201
  };
205
202
  await effectRuntime.runPromise(Effect.gen(function* () {
206
203
  const apiCount = options.api ? 1 : options.apis?.length ?? 0;
@@ -1,5 +1,5 @@
1
- import { PluginEvent } from "./observability/events.js";
2
1
  import { addLogicalBlankLines } from "./code-post-processor.js";
2
+ import { PluginEvent } from "./observability/events.js";
3
3
  import { emitSync, syncBuildId } from "./observability/sync-emitter.js";
4
4
  import { format } from "prettier";
5
5
 
@@ -1,7 +1,7 @@
1
1
  import { PluginEvent } from "./observability/events.js";
2
2
  import { emitSync, syncBuildId, syncSlowCodeBlockMs } from "./observability/sync-emitter.js";
3
- import { VfsRegistry } from "./vfs-registry.js";
4
3
  import { generateShikiHast } from "./markdown/shiki-utils.js";
4
+ import { VfsRegistry } from "./vfs-registry.js";
5
5
  import { setTwoslashFile } from "./twoslash-access.js";
6
6
  import { createTwoslashTimingWrapper } from "./twoslash-timing-wrapper.js";
7
7
  import { visit } from "unist-util-visit";
@@ -2,8 +2,8 @@ import { PluginEvent } from "./observability/events.js";
2
2
  import { emitSync, syncBuildId, syncSlowCodeBlockMs } from "./observability/sync-emitter.js";
3
3
  import { formatCode } from "./prettier-formatter.js";
4
4
  import { stripTwoslashDirectives } from "./markdown/helpers.js";
5
- import { VfsRegistry } from "./vfs-registry.js";
6
5
  import { DEFAULT_SHIKI_THEMES } from "./markdown/shiki-utils.js";
6
+ import { VfsRegistry } from "./vfs-registry.js";
7
7
  import { setTwoslashFile } from "./twoslash-access.js";
8
8
  import { createTwoslashTimingWrapper } from "./twoslash-timing-wrapper.js";
9
9
  import { codeToHast, hastToHtml } from "shiki";
package/schemas/config.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { PerformanceConfig } from "./performance.js";
2
2
  import { ObservabilityConfig } from "./observability.js";
3
- import { OpenGraphImageConfig } from "./opengraph.js";
4
3
  import { Effect, Schema } from "effect";
4
+ import { OpenGraphImageConfig } from "@tsdoctor/seo";
5
5
  import { ApiItemKind } from "@microsoft/api-extractor-model";
6
6
 
7
7
  //#region src/schemas/config.ts
@@ -304,8 +304,6 @@ const PluginOptions = Schema.Struct({
304
304
  api: Schema.optional(Schema.NullOr(SingleApiConfig)),
305
305
  /** Multi-API portal configuration (mutually exclusive with `api`). `null` or `[]` disables generation. */
306
306
  apis: Schema.optional(Schema.NullOr(Schema.mutable(Schema.Array(MultiApiConfig)))),
307
- /** Canonical site URL used for Open Graph absolute URLs. */
308
- siteUrl: Schema.optional(Schema.String),
309
307
  /** Global Open Graph image configuration (overridden per-API). */
310
308
  ogImage: Schema.optional(OpenGraphImageConfig),
311
309
  /** Override the default category definitions for all APIs. */
@@ -1,7 +1,42 @@
1
- import { Context } from "effect";
1
+ import { makeConfigService } from "../layers/config-resolution.js";
2
+ import { Context, Effect, Layer } from "effect";
2
3
 
3
4
  //#region src/services/ConfigService.ts
4
- var ConfigService = class extends Context.Service()("rspress-plugin-api-extractor/ConfigService") {};
5
+ var ConfigService = class ConfigService extends Context.Service()("rspress-plugin-api-extractor/ConfigService") {
6
+ /**
7
+ * Config resolution over the plugin options and the RSPress config.
8
+ *
9
+ * @remarks
10
+ * A plain `const`, not a factory. It used to take the plugin options as an
11
+ * argument, which made it a layer-RETURNING function: layers memoize by
12
+ * reference, so a second call would build a second `ConfigService` with its
13
+ * own captured `TypeRegistry`. The options come from {@link PluginConfig}
14
+ * now, so there is nothing to pass and "call it twice" is a type error
15
+ * rather than a test case.
16
+ *
17
+ * `Effect.suspend` because {@link makeConfigService} is imported from a
18
+ * module this one is also imported BY: a static initializer runs while the
19
+ * module body is still evaluating, so reading the binding eagerly can throw
20
+ * at import time with a completely clean typecheck.
21
+ */
22
+ static layer = Layer.effect(this, Effect.suspend(() => makeConfigService));
23
+ /**
24
+ * An in-memory double whose unstubbed member dies naming itself.
25
+ *
26
+ * @remarks
27
+ * **No default `resolve`.** Returning an empty array by default would be a
28
+ * silent "this site documents nothing" — the exact state an inert plugin
29
+ * produces — so a test that forgot to stub it would assert against a build
30
+ * that generated no pages and pass. Stub it explicitly, or provide
31
+ * {@link ConfigService.layer} over real inputs.
32
+ */
33
+ static makeTest = (overrides = {}) => ({ resolve: overrides.resolve ?? (() => unstubbed("resolve")) });
34
+ /** {@link ConfigService.makeTest} behind a `Layer`. */
35
+ static layerTest = (overrides = {}) => Layer.succeed(ConfigService, ConfigService.makeTest(overrides));
36
+ };
37
+ const unstubbed = (member) => {
38
+ throw new Error(`ConfigService.makeTest: ${member}() was called but not stubbed — pass an override.`);
39
+ };
5
40
 
6
41
  //#endregion
7
42
  export { ConfigService };
@@ -1,4 +1,8 @@
1
- import { Context } from "effect";
1
+ import { PluginEvent } from "../observability/events.js";
2
+ import { emit } from "../observability/EventBus.js";
3
+ import { SHIKI_LANGS } from "../markdown/shiki-utils.js";
4
+ import { Context, Effect, Layer } from "effect";
5
+ import { createHighlighter } from "shiki";
2
6
 
3
7
  //#region src/services/HighlighterService.ts
4
8
  /**
@@ -6,7 +10,7 @@ import { Context } from "effect";
6
10
  *
7
11
  * @remarks
8
12
  * A highlighter owns a WASM oniguruma instance and every loaded grammar and
9
- * theme, and it has a `dispose()` nobody was calling: `ConfigServiceLive`
13
+ * theme, and it has a `dispose()` nobody was calling: `ConfigService.layer`
10
14
  * created one per `resolve()`, so a dev-mode HMR session leaked one per
11
15
  * rebuild. The test run reported it as
12
16
  * `[Shiki] 10 instances have been created` — a console leak, not a failure.
@@ -24,7 +28,48 @@ import { Context } from "effect";
24
28
  *
25
29
  * @packageDocumentation
26
30
  */
27
- var HighlighterService = class extends Context.Service()("rspress-plugin-api-extractor/HighlighterService") {};
31
+ var HighlighterService = class extends Context.Service()("rspress-plugin-api-extractor/HighlighterService") {
32
+ /**
33
+ * Acquire the build's highlighter, and release it when the runtime is
34
+ * disposed.
35
+ *
36
+ * @remarks
37
+ * `Layer.effect` over `Effect.acquireRelease` is the v4 scoped-constructor
38
+ * idiom (`Layer.scoped` is gone; `Layer.effect` strips `Scope` from `R`).
39
+ * Because the layer sits in the `ManagedRuntime`'s stack, the highlighter is
40
+ * created on the runtime's first use and `dispose()`d by
41
+ * `effectRuntime.dispose()` — which `plugin.ts` calls on production builds
42
+ * only, so a dev HMR session keeps one highlighter across rebuilds instead of
43
+ * leaking one per rebuild.
44
+ *
45
+ * **Bind the result to a `const`.** This is a layer FACTORY: each call mints a
46
+ * fresh layer reference, and layers memoize by reference, so calling it twice
47
+ * in one graph acquires two highlighters — the exact leak this layer exists to
48
+ * fix.
49
+ *
50
+ * The themes are passed in rather than read from a resolved build context
51
+ * because the layer builds before `ConfigService.resolve()` runs. Passing them
52
+ * as an argument rather than through a `Context.Reference` is deliberate: a
53
+ * Reference carries a default, so forgetting to wire it would silently load
54
+ * only the default themes and render every custom-themed block wrong. A
55
+ * missing argument is a type error.
56
+ */
57
+ static layer = (themes) => Layer.effect(this, make(themes));
58
+ };
59
+ const make = (themes) => Effect.gen(function* () {
60
+ const startedMs = performance.now();
61
+ const highlighter = yield* Effect.acquireRelease(Effect.promise(() => createHighlighter({
62
+ themes: [...themes],
63
+ langs: [...SHIKI_LANGS]
64
+ })), (instance) => Effect.sync(() => instance.dispose()));
65
+ yield* emit(PluginEvent.PhaseCompleted({
66
+ ctx: {},
67
+ level: "debug",
68
+ phase: "shikiInit",
69
+ durationMs: Math.round(performance.now() - startedMs)
70
+ }));
71
+ return { highlighter };
72
+ });
28
73
 
29
74
  //#endregion
30
75
  export { HighlighterService };
@@ -1,4 +1,8 @@
1
- import { Context, Data } from "effect";
1
+ import { PluginEvent } from "../observability/events.js";
2
+ import { emit } from "../observability/EventBus.js";
3
+ import { Context, Data, Effect, FileSystem, Layer, Option, Path } from "effect";
4
+ import { imageMimeType, ogAltText, resolveUrl } from "@tsdoctor/seo";
5
+ import { imageSize } from "image-size";
2
6
 
3
7
  //#region src/services/OgService.ts
4
8
  const OgImageErrorBase = Data.TaggedError("OgImageError");
@@ -17,7 +21,155 @@ var OgImageError = class extends OgImageErrorBase {
17
21
  return `Could not read Open Graph image '${this.value}': ${cause}`;
18
22
  }
19
23
  };
20
- var OgService = class extends Context.Service()("rspress-plugin-api-extractor/OgService") {};
24
+ var OgService = class OgService extends Context.Service()("rspress-plugin-api-extractor/OgService") {
25
+ /**
26
+ * Resolve OG images through the core `FileSystem`, with one read per file per
27
+ * build.
28
+ *
29
+ * @remarks
30
+ * The `node:fs` `existsSync` + `imageSizeFromFile` pair this replaces ran once
31
+ * per PAGE, so a 400-page API re-read the same image 400 times. The memo below
32
+ * keys on the absolute path and removes that entirely.
33
+ *
34
+ * The memo is per build, not persisted. A cross-build cache in the shared XDG
35
+ * store was considered and deliberately deferred: it would need mtime/size
36
+ * invalidation to stay sound, and a stale image dimension is a silent wrong
37
+ * answer. There is nothing expensive enough here to justify that yet — when
38
+ * phase 4 starts GENERATING images, which are expensive and content-addressed,
39
+ * the XDG cache is the right home for them.
40
+ *
41
+ * `imageSize` over the read bytes replaces `imageSizeFromFile`, which took a
42
+ * path and therefore required real `node:fs`. Same parser, same output.
43
+ *
44
+ * `Effect.suspend(() => make())` rather than a bare `make`: a static
45
+ * initializer runs while the module body is still evaluating, so naming a
46
+ * `const` declared further down throws at import time with a clean typecheck.
47
+ */
48
+ static layer = Layer.effect(this, Effect.suspend(() => make()));
49
+ /**
50
+ * An in-memory double whose unstubbed member dies naming itself.
51
+ *
52
+ * @remarks
53
+ * **There is deliberately no default `resolveImage`.** A default returning
54
+ * `Option.none` would be indistinguishable from "this API declares no
55
+ * image", which is precisely the ambiguity {@link OgImageError} exists to
56
+ * remove — and a test asserting that a page rendered without an `og:image`
57
+ * would then pass whether or not the service was ever consulted.
58
+ *
59
+ * Where the wiring is what matters, prefer the real layer over a platform
60
+ * filesystem instead of this double; see `__test__/utils/layers.ts`.
61
+ */
62
+ static makeTest = (overrides = {}) => ({ resolveImage: overrides.resolveImage ?? (() => unstubbed("resolveImage")) });
63
+ /** {@link OgService.makeTest} behind a `Layer`. */
64
+ static layerTest = (overrides = {}) => Layer.succeed(OgService, OgService.makeTest(overrides));
65
+ };
66
+ const unstubbed = (member) => {
67
+ throw new Error(`OgService.makeTest: ${member}() was called but not stubbed — pass an override.`);
68
+ };
69
+ const make = () => Effect.gen(function* () {
70
+ const fileSystem = yield* FileSystem.FileSystem;
71
+ const path = yield* Path.Path;
72
+ /** Absolute path → facts, or `null` for "looked, could not use it". */
73
+ const factsByPath = /* @__PURE__ */ new Map();
74
+ /** Locate a root-relative image under the docs `public/` directory. */
75
+ const findLocalImage = (imagePath, docsRoot) => {
76
+ if (docsRoot == null || !imagePath.startsWith("/")) return Effect.succeed(Option.none());
77
+ const candidate = path.join(docsRoot, "public", imagePath);
78
+ return fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false), Effect.map((found) => found ? Option.some(candidate) : Option.none()));
79
+ };
80
+ /**
81
+ * Read dimensions and MIME type. A file that cannot be parsed warns and
82
+ * yields nothing — the page still gets its `og:image`, just without
83
+ * dimensions, which is what the class this replaced did.
84
+ */
85
+ const readImageFacts = (filePath) => Effect.gen(function* () {
86
+ const memoed = factsByPath.get(filePath);
87
+ if (memoed !== void 0) return memoed;
88
+ const result = yield* Effect.result(fileSystem.readFile(filePath).pipe(Effect.flatMap((bytes) => Effect.try(() => imageSize(bytes)))));
89
+ if (result._tag === "Failure") {
90
+ const error = new OgImageError({
91
+ code: "unreadable-image",
92
+ field: "ogImage",
93
+ value: filePath,
94
+ cause: result.failure
95
+ });
96
+ yield* emit(PluginEvent.ConfigValidationWarning({
97
+ ctx: {},
98
+ field: "ogImage",
99
+ value: filePath,
100
+ reason: error.message,
101
+ level: "warn"
102
+ }));
103
+ factsByPath.set(filePath, null);
104
+ return null;
105
+ }
106
+ const size = result.success;
107
+ const mimeType = imageMimeType(size.type);
108
+ const facts = {
109
+ ...size.width != null ? { width: size.width } : {},
110
+ ...size.height != null ? { height: size.height } : {},
111
+ ...mimeType != null ? { type: mimeType } : {}
112
+ };
113
+ factsByPath.set(filePath, facts);
114
+ return facts;
115
+ });
116
+ const resolveFromString = (imageUrl, request) => Effect.gen(function* () {
117
+ const resolvedUrl = resolveUrl(request.siteUrl, imageUrl);
118
+ if (resolvedUrl == null) return yield* Effect.fail(new OgImageError({
119
+ code: "invalid-url",
120
+ field: "ogImage",
121
+ value: imageUrl
122
+ }));
123
+ const localPath = yield* findLocalImage(imageUrl, request.docsRoot);
124
+ const facts = Option.isSome(localPath) ? yield* readImageFacts(localPath.value) : null;
125
+ return Option.some({
126
+ url: resolvedUrl,
127
+ type: facts?.type,
128
+ width: facts?.width,
129
+ height: facts?.height,
130
+ alt: ogAltText(request.packageName, request.apiName)
131
+ });
132
+ });
133
+ const resolveFromMetadata = (metadata, request) => Effect.gen(function* () {
134
+ const { url, secureUrl, type, width, height, alt } = metadata;
135
+ const resolvedUrl = resolveUrl(request.siteUrl, url);
136
+ if (resolvedUrl == null) return yield* Effect.fail(new OgImageError({
137
+ code: "invalid-url",
138
+ field: "ogImage.url",
139
+ value: url
140
+ }));
141
+ let resolvedSecureUrl;
142
+ if (secureUrl != null) {
143
+ if (secureUrl.startsWith("https://")) resolvedSecureUrl = secureUrl;
144
+ else {
145
+ const error = new OgImageError({
146
+ code: "invalid-secure-url",
147
+ field: "ogImage.secureUrl",
148
+ value: secureUrl
149
+ });
150
+ yield* emit(PluginEvent.ConfigValidationWarning({
151
+ ctx: {},
152
+ field: "ogImage.secureUrl",
153
+ value: secureUrl,
154
+ reason: error.message,
155
+ level: "warn"
156
+ }));
157
+ }
158
+ }
159
+ return Option.some({
160
+ url: resolvedUrl,
161
+ secureUrl: resolvedSecureUrl,
162
+ type,
163
+ width,
164
+ height,
165
+ alt: alt ?? ogAltText(request.packageName, request.apiName)
166
+ });
167
+ });
168
+ return { resolveImage: (request) => {
169
+ if (request.config == null) return Effect.succeed(Option.none());
170
+ return typeof request.config === "object" ? resolveFromMetadata(request.config, request) : resolveFromString(request.config, request);
171
+ } };
172
+ });
21
173
 
22
174
  //#endregion
23
175
  export { OgImageError, OgService };
@@ -5,7 +5,7 @@ import { Context } from "effect";
5
5
  * The decoded plugin options, as a service.
6
6
  *
7
7
  * @remarks
8
- * `ConfigServiceLive` used to be a factory taking these as an argument, which
8
+ * `ConfigService.layer` used to be a factory taking these as an argument, which
9
9
  * made it a layer-returning function — the shape the house rules warn about,
10
10
  * since layers memoize by reference and a second call mints a second layer.
11
11
  * It was only ever called once, but "only ever called once" is a property of