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.
- package/BuildEnv.js +3 -3
- package/build-program.js +2 -3
- package/build-stages.js +3 -5
- package/config-helpers.js +7 -7
- package/errors.js +1 -5
- package/index.d.ts +84 -86
- package/layers/AppLayer.js +67 -0
- package/layers/api-results.js +83 -0
- package/layers/build-metrics.js +1 -1
- package/layers/{ConfigServiceLive.js → config-resolution.js} +76 -175
- package/layers/external-types.js +74 -0
- package/layers/{ObservabilityLive.js → observability.js} +3 -3
- package/layers/type-environment.js +109 -0
- package/layers/xdg.js +1 -1
- package/markdown/shiki-utils.js +1 -1
- package/model-loader.js +3 -3
- package/observability/EventBus.js +2 -2
- package/observability/heartbeat.js +1 -1
- package/observability/sinks/metrics-sink.js +1 -1
- package/observability/sinks/trace-sink.js +4 -4
- package/og-resolver.js +32 -1
- package/package.json +2 -2
- package/plugin.js +30 -33
- package/prettier-formatter.js +1 -1
- package/remark-api-codeblocks.js +1 -1
- package/remark-with-api.js +1 -1
- package/schemas/config.js +0 -2
- package/services/ConfigService.js +37 -2
- package/services/HighlighterService.js +48 -3
- package/services/OgService.js +169 -2
- package/services/PluginConfig.js +1 -1
- package/services/TwoslashCacheService.js +128 -2
- package/services/TwoslashEnvironments.js +30 -2
- package/services/TypeRegistryService.js +178 -2
- package/shiki-transformer.js +1 -1
- package/sync-node-fs.js +6 -6
- package/tsconfig-parser.js +77 -95
- package/twoslash-access.js +1 -1
- package/twoslash-transformer.js +1 -1
- package/layers/HighlighterServiceLive.js +0 -52
- package/layers/OgServiceLive.js +0 -134
- package/layers/TwoslashCacheServiceLive.js +0 -108
- package/layers/TwoslashEnvironmentsLive.js +0 -33
- package/layers/TypeRegistryServiceLive.js +0 -162
- package/markdown/index.js +0 -11
- package/schemas/index.js +0 -6
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import
|
|
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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 {
|
|
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 {
|
|
25
|
-
import {
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
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
|
|
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 {
|
|
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
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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)
|
|
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)
|
|
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;
|
package/prettier-formatter.js
CHANGED
|
@@ -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
|
|
package/remark-api-codeblocks.js
CHANGED
|
@@ -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";
|
package/remark-with-api.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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: `
|
|
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 };
|
package/services/OgService.js
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
|
-
import {
|
|
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 };
|
package/services/PluginConfig.js
CHANGED
|
@@ -5,7 +5,7 @@ import { Context } from "effect";
|
|
|
5
5
|
* The decoded plugin options, as a service.
|
|
6
6
|
*
|
|
7
7
|
* @remarks
|
|
8
|
-
* `
|
|
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
|