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.
- package/BuildEnv.js +3 -3
- package/README.md +1 -0
- package/build-program.js +14 -5
- package/build-stages.js +88 -49
- package/config-helpers.js +7 -7
- package/errors.js +1 -5
- package/index.d.ts +1 -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} +115 -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/helpers.js +8 -33
- 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/package.json +5 -4
- 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 +1 -3
- package/services/ConfigService.js +37 -2
- package/services/HighlighterService.js +48 -3
- package/services/OgService.js +154 -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/og-resolver.js +0 -64
- package/schemas/index.js +0 -6
- package/schemas/opengraph.js +0 -56
package/markdown/helpers.js
CHANGED
|
@@ -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
|
|
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,
|
|
130
|
+
function generateFrontmatter(entityName, description, singularName, apiName, tags) {
|
|
140
131
|
const title = buildPageTitle(entityName, singularName, apiName);
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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)
|
package/markdown/shiki-utils.js
CHANGED
|
@@ -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
|
-
* `
|
|
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
|
|
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 (!
|
|
14
|
-
const content =
|
|
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 `
|
|
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
|
-
* `
|
|
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 `
|
|
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
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rspress-plugin-api-extractor",
|
|
3
|
-
"version": "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.
|
|
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.
|
|
56
|
+
"@tsdoctor/model": "0.4.0",
|
|
57
57
|
"@tsdoctor/registry": "0.2.1",
|
|
58
|
-
"@tsdoctor/
|
|
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 {
|
|
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
|
@@ -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 {
|
|
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,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
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 };
|
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
|