rspress-plugin-api-extractor 0.11.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 (46) hide show
  1. package/BuildEnv.js +3 -3
  2. package/build-program.js +2 -3
  3. package/build-stages.js +3 -5
  4. package/config-helpers.js +7 -7
  5. package/errors.js +1 -5
  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/{ConfigServiceLive.js → config-resolution.js} +76 -175
  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 +1 -1
  15. package/markdown/shiki-utils.js +1 -1
  16. package/model-loader.js +3 -3
  17. package/observability/EventBus.js +2 -2
  18. package/observability/heartbeat.js +1 -1
  19. package/observability/sinks/metrics-sink.js +1 -1
  20. package/observability/sinks/trace-sink.js +4 -4
  21. package/og-resolver.js +32 -1
  22. package/package.json +2 -2
  23. package/plugin.js +30 -33
  24. package/prettier-formatter.js +1 -1
  25. package/remark-api-codeblocks.js +1 -1
  26. package/remark-with-api.js +1 -1
  27. package/schemas/config.js +0 -2
  28. package/services/ConfigService.js +37 -2
  29. package/services/HighlighterService.js +48 -3
  30. package/services/OgService.js +169 -2
  31. package/services/PluginConfig.js +1 -1
  32. package/services/TwoslashCacheService.js +128 -2
  33. package/services/TwoslashEnvironments.js +30 -2
  34. package/services/TypeRegistryService.js +178 -2
  35. package/shiki-transformer.js +1 -1
  36. package/sync-node-fs.js +6 -6
  37. package/tsconfig-parser.js +77 -95
  38. package/twoslash-access.js +1 -1
  39. package/twoslash-transformer.js +1 -1
  40. package/layers/HighlighterServiceLive.js +0 -52
  41. package/layers/OgServiceLive.js +0 -134
  42. package/layers/TwoslashCacheServiceLive.js +0 -108
  43. package/layers/TwoslashEnvironmentsLive.js +0 -33
  44. package/layers/TypeRegistryServiceLive.js +0 -162
  45. package/markdown/index.js +0 -11
  46. package/schemas/index.js +0 -6
@@ -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/og-resolver.js CHANGED
@@ -30,6 +30,37 @@ function resolveOgUrl(siteUrl, url) {
30
30
  if (url.startsWith("http://") || url.startsWith("https://")) return url;
31
31
  if (url.startsWith("/")) return `${siteUrl}${url}`;
32
32
  }
33
+ /**
34
+ * Derive the site URL prefix from RSPress's own config.
35
+ *
36
+ * @remarks
37
+ * Replaces the plugin's former `siteUrl` option. RSPress already knows where a
38
+ * site is deployed — {@link https://rspress.rs/api/config/config-basic#siteorigin | `siteOrigin`}
39
+ * plus `base` — so asking for it a second time invited the two to disagree, and
40
+ * a plugin-level answer that contradicted the site's own would silently emit
41
+ * canonical and `og:url` tags pointing at a host the site is not served from.
42
+ *
43
+ * RSPress concatenates as `siteOrigin + base + routePath`, and **this follows
44
+ * its documented fallback exactly**: with no `siteOrigin`, RSPress uses
45
+ * `base + routePath`. So an unset origin yields a ROOT-RELATIVE prefix rather
46
+ * than nothing.
47
+ *
48
+ * That fallback is what makes the tags inspectable in `rspress dev`, where the
49
+ * site is served from `localhost` and no configured origin could be correct
50
+ * anyway. A root-relative `/images/og.png` resolves against the page's own
51
+ * origin in the browser; it is a *relative* path (`images/og.png`, no leading
52
+ * slash) that has no base to resolve against, and this never emits one.
53
+ *
54
+ * @returns The prefix to put in front of a route that already begins with `/`.
55
+ * `""` when the site declares neither `siteOrigin` nor a non-root `base`, which
56
+ * leaves every URL root-relative. Never has a trailing slash, since every
57
+ * caller appends a route starting with `/`.
58
+ */
59
+ function deriveSiteUrl(siteOrigin, base) {
60
+ const origin = (siteOrigin ?? "").trim().replace(/\/+$/, "");
61
+ const path = (base ?? "/").trim();
62
+ return `${origin}${path === "" || path === "/" ? "" : `/${path.replace(/^\/+/, "").replace(/\/+$/, "")}`}`;
63
+ }
33
64
  /** Descriptive alt text for a package's (or one API's) OG image. */
34
65
  function ogAltText(packageName, apiName) {
35
66
  return apiName ? `${apiName} - ${packageName} API Documentation` : `${packageName} API Documentation`;
@@ -61,4 +92,4 @@ function createPageMetadata(options) {
61
92
  }
62
93
 
63
94
  //#endregion
64
- export { createPageMetadata, imageMimeType, ogAltText, resolveOgUrl };
95
+ export { createPageMetadata, deriveSiteUrl, imageMimeType, ogAltText, resolveOgUrl };
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.12.0",
4
4
  "private": false,
5
5
  "description": "RSPress plugin for generating API documentation from TypeScript API Extractor models",
6
6
  "keywords": [
@@ -55,7 +55,7 @@
55
55
  "@tsdoctor/bundle": "0.2.0",
56
56
  "@tsdoctor/model": "0.3.0",
57
57
  "@tsdoctor/registry": "0.2.1",
58
- "@tsdoctor/snapshot": "0.1.1",
58
+ "@tsdoctor/snapshot": "0.2.0",
59
59
  "@typescript/vfs": "^1.6.4",
60
60
  "clsx": "^2.1.1",
61
61
  "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
@@ -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,6 +1,25 @@
1
- import { Context, Data } from "effect";
1
+ import { PluginEvent } from "../observability/events.js";
2
+ import { emit } from "../observability/EventBus.js";
3
+ import { imageMimeType, ogAltText, resolveOgUrl } from "../og-resolver.js";
4
+ import { Context, Data, Effect, FileSystem, Layer, Option, Path } from "effect";
5
+ import { imageSize } from "image-size";
2
6
 
3
7
  //#region src/services/OgService.ts
8
+ /**
9
+ * Resolving an API's configured Open Graph image into page metadata.
10
+ *
11
+ * @remarks
12
+ * Replaces the `OpenGraphResolver` class, which did synchronous `node:fs` from
13
+ * inside `Effect.promise`, carried its own sync-island event emitter, and
14
+ * returned `undefined` for all three of its failure modes — indistinguishable
15
+ * from "no image was configured".
16
+ *
17
+ * This is also where phase 4's SEO work lands, which is why the contract is
18
+ * wider than today's single caller needs: it names its failures instead of
19
+ * erasing them.
20
+ *
21
+ * @packageDocumentation
22
+ */
4
23
  const OgImageErrorBase = Data.TaggedError("OgImageError");
5
24
  /**
6
25
  * A configured OG image that could not be resolved.
@@ -17,7 +36,155 @@ var OgImageError = class extends OgImageErrorBase {
17
36
  return `Could not read Open Graph image '${this.value}': ${cause}`;
18
37
  }
19
38
  };
20
- var OgService = class extends Context.Service()("rspress-plugin-api-extractor/OgService") {};
39
+ var OgService = class OgService extends Context.Service()("rspress-plugin-api-extractor/OgService") {
40
+ /**
41
+ * Resolve OG images through the core `FileSystem`, with one read per file per
42
+ * build.
43
+ *
44
+ * @remarks
45
+ * The `node:fs` `existsSync` + `imageSizeFromFile` pair this replaces ran once
46
+ * per PAGE, so a 400-page API re-read the same image 400 times. The memo below
47
+ * keys on the absolute path and removes that entirely.
48
+ *
49
+ * The memo is per build, not persisted. A cross-build cache in the shared XDG
50
+ * store was considered and deliberately deferred: it would need mtime/size
51
+ * invalidation to stay sound, and a stale image dimension is a silent wrong
52
+ * answer. There is nothing expensive enough here to justify that yet — when
53
+ * phase 4 starts GENERATING images, which are expensive and content-addressed,
54
+ * the XDG cache is the right home for them.
55
+ *
56
+ * `imageSize` over the read bytes replaces `imageSizeFromFile`, which took a
57
+ * path and therefore required real `node:fs`. Same parser, same output.
58
+ *
59
+ * `Effect.suspend(() => make())` rather than a bare `make`: a static
60
+ * initializer runs while the module body is still evaluating, so naming a
61
+ * `const` declared further down throws at import time with a clean typecheck.
62
+ */
63
+ static layer = Layer.effect(this, Effect.suspend(() => make()));
64
+ /**
65
+ * An in-memory double whose unstubbed member dies naming itself.
66
+ *
67
+ * @remarks
68
+ * **There is deliberately no default `resolveImage`.** A default returning
69
+ * `Option.none` would be indistinguishable from "this API declares no
70
+ * image", which is precisely the ambiguity {@link OgImageError} exists to
71
+ * remove — and a test asserting that a page rendered without an `og:image`
72
+ * would then pass whether or not the service was ever consulted.
73
+ *
74
+ * Where the wiring is what matters, prefer the real layer over a platform
75
+ * filesystem instead of this double; see `__test__/utils/layers.ts`.
76
+ */
77
+ static makeTest = (overrides = {}) => ({ resolveImage: overrides.resolveImage ?? (() => unstubbed("resolveImage")) });
78
+ /** {@link OgService.makeTest} behind a `Layer`. */
79
+ static layerTest = (overrides = {}) => Layer.succeed(OgService, OgService.makeTest(overrides));
80
+ };
81
+ const unstubbed = (member) => {
82
+ throw new Error(`OgService.makeTest: ${member}() was called but not stubbed — pass an override.`);
83
+ };
84
+ const make = () => Effect.gen(function* () {
85
+ const fileSystem = yield* FileSystem.FileSystem;
86
+ const path = yield* Path.Path;
87
+ /** Absolute path → facts, or `null` for "looked, could not use it". */
88
+ const factsByPath = /* @__PURE__ */ new Map();
89
+ /** Locate a root-relative image under the docs `public/` directory. */
90
+ const findLocalImage = (imagePath, docsRoot) => {
91
+ if (docsRoot == null || !imagePath.startsWith("/")) return Effect.succeed(Option.none());
92
+ const candidate = path.join(docsRoot, "public", imagePath);
93
+ return fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false), Effect.map((found) => found ? Option.some(candidate) : Option.none()));
94
+ };
95
+ /**
96
+ * Read dimensions and MIME type. A file that cannot be parsed warns and
97
+ * yields nothing — the page still gets its `og:image`, just without
98
+ * dimensions, which is what the class this replaced did.
99
+ */
100
+ const readImageFacts = (filePath) => Effect.gen(function* () {
101
+ const memoed = factsByPath.get(filePath);
102
+ if (memoed !== void 0) return memoed;
103
+ const result = yield* Effect.result(fileSystem.readFile(filePath).pipe(Effect.flatMap((bytes) => Effect.try(() => imageSize(bytes)))));
104
+ if (result._tag === "Failure") {
105
+ const error = new OgImageError({
106
+ code: "unreadable-image",
107
+ field: "ogImage",
108
+ value: filePath,
109
+ cause: result.failure
110
+ });
111
+ yield* emit(PluginEvent.ConfigValidationWarning({
112
+ ctx: {},
113
+ field: "ogImage",
114
+ value: filePath,
115
+ reason: error.message,
116
+ level: "warn"
117
+ }));
118
+ factsByPath.set(filePath, null);
119
+ return null;
120
+ }
121
+ const size = result.success;
122
+ const mimeType = imageMimeType(size.type);
123
+ const facts = {
124
+ ...size.width != null ? { width: size.width } : {},
125
+ ...size.height != null ? { height: size.height } : {},
126
+ ...mimeType != null ? { type: mimeType } : {}
127
+ };
128
+ factsByPath.set(filePath, facts);
129
+ return facts;
130
+ });
131
+ const resolveFromString = (imageUrl, request) => Effect.gen(function* () {
132
+ const resolvedUrl = resolveOgUrl(request.siteUrl, imageUrl);
133
+ if (resolvedUrl == null) return yield* Effect.fail(new OgImageError({
134
+ code: "invalid-url",
135
+ field: "ogImage",
136
+ value: imageUrl
137
+ }));
138
+ const localPath = yield* findLocalImage(imageUrl, request.docsRoot);
139
+ const facts = Option.isSome(localPath) ? yield* readImageFacts(localPath.value) : null;
140
+ return Option.some({
141
+ url: resolvedUrl,
142
+ type: facts?.type,
143
+ width: facts?.width,
144
+ height: facts?.height,
145
+ alt: ogAltText(request.packageName, request.apiName)
146
+ });
147
+ });
148
+ const resolveFromMetadata = (metadata, request) => Effect.gen(function* () {
149
+ const { url, secureUrl, type, width, height, alt } = metadata;
150
+ const resolvedUrl = resolveOgUrl(request.siteUrl, url);
151
+ if (resolvedUrl == null) return yield* Effect.fail(new OgImageError({
152
+ code: "invalid-url",
153
+ field: "ogImage.url",
154
+ value: url
155
+ }));
156
+ let resolvedSecureUrl;
157
+ if (secureUrl != null) {
158
+ if (secureUrl.startsWith("https://")) resolvedSecureUrl = secureUrl;
159
+ else {
160
+ const error = new OgImageError({
161
+ code: "invalid-secure-url",
162
+ field: "ogImage.secureUrl",
163
+ value: secureUrl
164
+ });
165
+ yield* emit(PluginEvent.ConfigValidationWarning({
166
+ ctx: {},
167
+ field: "ogImage.secureUrl",
168
+ value: secureUrl,
169
+ reason: error.message,
170
+ level: "warn"
171
+ }));
172
+ }
173
+ }
174
+ return Option.some({
175
+ url: resolvedUrl,
176
+ secureUrl: resolvedSecureUrl,
177
+ type,
178
+ width,
179
+ height,
180
+ alt: alt ?? ogAltText(request.packageName, request.apiName)
181
+ });
182
+ });
183
+ return { resolveImage: (request) => {
184
+ if (request.config == null) return Effect.succeed(Option.none());
185
+ return typeof request.config === "object" ? resolveFromMetadata(request.config, request) : resolveFromString(request.config, request);
186
+ } };
187
+ });
21
188
 
22
189
  //#endregion
23
190
  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