vitepress-plugin-api-extractor 0.1.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/ApiExtractor.js +96 -0
- package/Categories.js +49 -0
- package/Generate.js +220 -0
- package/Registry.js +117 -0
- package/Twoslash.js +43 -0
- package/TwoslashCache.js +102 -0
- package/emit/frontmatter.js +58 -0
- package/emit/markdown.js +199 -0
- package/emit/sidebar.js +37 -0
- package/index.d.ts +477 -0
- package/index.js +11 -0
- package/package.json +65 -0
- package/tsdoc-metadata.json +11 -0
package/ApiExtractor.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { PlatformLive, RegistryLive } from "./Registry.js";
|
|
2
|
+
import { generate } from "./Generate.js";
|
|
3
|
+
import { environmentHash, makeTwoslashTransformer } from "./Twoslash.js";
|
|
4
|
+
import { TwoslashCacheStore } from "./TwoslashCache.js";
|
|
5
|
+
import { Effect, Layer, ManagedRuntime } from "effect";
|
|
6
|
+
|
|
7
|
+
//#region src/ApiExtractor.ts
|
|
8
|
+
/**
|
|
9
|
+
* The public helper a site's `.vitepress/config.mts` awaits at config-load
|
|
10
|
+
* time: generates the pages, opens the Twoslash result cache and returns the
|
|
11
|
+
* sidebar, the code transformer and the `buildEnd` hook that persists the
|
|
12
|
+
* cache.
|
|
13
|
+
*
|
|
14
|
+
* @remarks
|
|
15
|
+
* Generation happens HERE, at config load, because VitePress has no
|
|
16
|
+
* pre-scan hook comparable to RSPress's `config()`: its config file is ESM
|
|
17
|
+
* and may top-level await, and `buildEnd` / `postRender` run after the fact.
|
|
18
|
+
* The result is merged into `defineConfig` by the site.
|
|
19
|
+
*
|
|
20
|
+
* @packageDocumentation
|
|
21
|
+
*/
|
|
22
|
+
const AppLive = Layer.mergeAll(PlatformLive, RegistryLive, TwoslashCacheStore.layer);
|
|
23
|
+
/**
|
|
24
|
+
* Generate the API pages and return what the site's config needs.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* // .vitepress/config.mts
|
|
29
|
+
* import { defineConfig } from "vitepress";
|
|
30
|
+
* import { apiExtractor } from "vitepress-plugin-api-extractor";
|
|
31
|
+
*
|
|
32
|
+
* const api = await apiExtractor({ dir: "./lib/models/kitchensink" });
|
|
33
|
+
*
|
|
34
|
+
* export default defineConfig({
|
|
35
|
+
* themeConfig: { sidebar: api.sidebar },
|
|
36
|
+
* markdown: { codeTransformers: [...api.codeTransformers] },
|
|
37
|
+
* buildEnd: async () => { await api.hooks.buildEnd(); },
|
|
38
|
+
* });
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* @public
|
|
42
|
+
*/
|
|
43
|
+
async function apiExtractor(options) {
|
|
44
|
+
const runtime = ManagedRuntime.make(AppLive);
|
|
45
|
+
const cwd = options.cwd ?? process.cwd();
|
|
46
|
+
const log = options.log ?? true;
|
|
47
|
+
const generated = await runtime.runPromise(generate({
|
|
48
|
+
dir: options.dir,
|
|
49
|
+
cwd,
|
|
50
|
+
docsDir: options.docsDir ?? "docs",
|
|
51
|
+
baseRoute: options.baseRoute ?? "/api",
|
|
52
|
+
apiName: options.name,
|
|
53
|
+
siteOrigin: options.siteOrigin,
|
|
54
|
+
base: options.base,
|
|
55
|
+
categories: options.categories,
|
|
56
|
+
externalPackages: options.externalPackages,
|
|
57
|
+
suppressExampleErrors: options.suppressExampleErrors,
|
|
58
|
+
source: options.source
|
|
59
|
+
}));
|
|
60
|
+
const envHash = environmentHash(generated.vfs);
|
|
61
|
+
const typesCache = await runtime.runPromise(Effect.gen(function* () {
|
|
62
|
+
return yield* (yield* TwoslashCacheStore).open(envHash);
|
|
63
|
+
}));
|
|
64
|
+
const transformer = makeTwoslashTransformer({
|
|
65
|
+
vfs: generated.vfs,
|
|
66
|
+
compilerOptions: generated.compilerOptions,
|
|
67
|
+
typesCache
|
|
68
|
+
});
|
|
69
|
+
if (log) {
|
|
70
|
+
const external = generated.externalTypes.loaded.length > 0 ? `, ${generated.externalTypes.loaded.length} external package(s)` : "";
|
|
71
|
+
console.log(`[vitepress-plugin-api-extractor] ${generated.packageName}: ${generated.routes.length} pages under ${generated.baseRoute}, ${generated.vfs.size} declaration files${external}, twoslash cache ${typesCache.stats().entries} entries`);
|
|
72
|
+
if (generated.externalTypes.warning) console.warn(`[vitepress-plugin-api-extractor] external types degraded: ${generated.externalTypes.warning}`);
|
|
73
|
+
for (const name of generated.uncategorized) console.warn(`[vitepress-plugin-api-extractor] skipped ${name}: no category matched`);
|
|
74
|
+
}
|
|
75
|
+
let persisted;
|
|
76
|
+
const buildEnd = () => {
|
|
77
|
+
persisted ??= runtime.runPromise(Effect.gen(function* () {
|
|
78
|
+
const report = yield* (yield* TwoslashCacheStore).persist();
|
|
79
|
+
return report._tag === "Some" ? report.value : void 0;
|
|
80
|
+
})).then(async (report) => {
|
|
81
|
+
if (log && report) console.log(`[vitepress-plugin-api-extractor] twoslash cache: ${report.hits} hit(s), ${report.misses} miss(es), ${report.entries} entries${report.dirty ? " (saved)" : ""}${report.degraded ? " (degraded)" : ""}`);
|
|
82
|
+
await runtime.dispose();
|
|
83
|
+
return report;
|
|
84
|
+
});
|
|
85
|
+
return persisted;
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
88
|
+
sidebar: generated.sidebar,
|
|
89
|
+
codeTransformers: [transformer],
|
|
90
|
+
hooks: { buildEnd },
|
|
91
|
+
generated
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
//#endregion
|
|
96
|
+
export { apiExtractor };
|
package/Categories.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ApiItemKind } from "@microsoft/api-extractor-model";
|
|
2
|
+
|
|
3
|
+
//#region src/Categories.ts
|
|
4
|
+
/**
|
|
5
|
+
* The default category set: one group per API item kind, with the folder,
|
|
6
|
+
* singular name and sidebar presentation each carries.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* The same seven categories the RSPress plugin's `DEFAULT_CATEGORIES`
|
|
10
|
+
* declares, so both adapters generate the same routes from the same bundle.
|
|
11
|
+
* A shared home for the defaults is a Tier 2 candidate; until then keep the
|
|
12
|
+
* two in step.
|
|
13
|
+
*
|
|
14
|
+
* @packageDocumentation
|
|
15
|
+
*/
|
|
16
|
+
const category = (displayName, singularName, folderName, kind) => ({
|
|
17
|
+
displayName,
|
|
18
|
+
singularName,
|
|
19
|
+
folderName,
|
|
20
|
+
itemKinds: [kind],
|
|
21
|
+
collapsible: true,
|
|
22
|
+
collapsed: true
|
|
23
|
+
});
|
|
24
|
+
/**
|
|
25
|
+
* The default categories, in sidebar order.
|
|
26
|
+
*
|
|
27
|
+
* @public
|
|
28
|
+
*/
|
|
29
|
+
const DEFAULT_CATEGORIES = {
|
|
30
|
+
classes: category("Classes", "Class", "class", ApiItemKind.Class),
|
|
31
|
+
interfaces: category("Interfaces", "Interface", "interface", ApiItemKind.Interface),
|
|
32
|
+
functions: category("Functions", "Function", "function", ApiItemKind.Function),
|
|
33
|
+
types: category("Types", "Type", "type", ApiItemKind.TypeAlias),
|
|
34
|
+
enums: category("Enums", "Enum", "enum", ApiItemKind.Enum),
|
|
35
|
+
variables: category("Variables", "Variable", "variable", ApiItemKind.Variable),
|
|
36
|
+
namespaces: category("Namespaces", "Namespace", "namespace", ApiItemKind.Namespace)
|
|
37
|
+
};
|
|
38
|
+
/** The navigation-tree view of a category. */
|
|
39
|
+
function navCategory(config) {
|
|
40
|
+
return {
|
|
41
|
+
displayName: config.displayName,
|
|
42
|
+
folderName: config.folderName,
|
|
43
|
+
...config.collapsible !== void 0 ? { collapsible: config.collapsible } : {},
|
|
44
|
+
...config.collapsed !== void 0 ? { collapsed: config.collapsed } : {}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
//#endregion
|
|
49
|
+
export { DEFAULT_CATEGORIES, navCategory };
|
package/Generate.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { DEFAULT_CATEGORIES, navCategory } from "./Categories.js";
|
|
2
|
+
import { emitFrontmatter } from "./emit/frontmatter.js";
|
|
3
|
+
import { emitMarkdownBody } from "./emit/markdown.js";
|
|
4
|
+
import { sidebarFor } from "./emit/sidebar.js";
|
|
5
|
+
import { externalPackagesOf, loadExternalTypes } from "./Registry.js";
|
|
6
|
+
import { Effect, FileSystem, Option, Path } from "effect";
|
|
7
|
+
import { PackageManifest } from "@effected/package-json";
|
|
8
|
+
import { discoverBundle } from "@tsdoctor/bundle";
|
|
9
|
+
import { ApiExtractedPackage, CrossLinker, Model, TypeReferenceExtractor } from "@tsdoctor/model";
|
|
10
|
+
import { buildIndexPage, buildNav, buildPage, prepareWorkItems } from "@tsdoctor/pages";
|
|
11
|
+
import { attributionFacts, deriveScriptBody, deriveSiteUrl, headTags, packageContext } from "@tsdoctor/seo";
|
|
12
|
+
import { resolveTypeScriptConfig } from "@tsdoctor/vfs";
|
|
13
|
+
|
|
14
|
+
//#region src/Generate.ts
|
|
15
|
+
/**
|
|
16
|
+
* The generation program: discover the bundle, load the model, build the
|
|
17
|
+
* virtual file system and compiler options, lift every item into a
|
|
18
|
+
* `@tsdoctor/pages` `Page`, emit it as VitePress markdown and write it under
|
|
19
|
+
* the site's source directory.
|
|
20
|
+
*
|
|
21
|
+
* @remarks
|
|
22
|
+
* This is the minimal path for the alpha. The RSPress plugin runs the same
|
|
23
|
+
* sequence inside its `ConfigService` (`layers/config-resolution.ts`), which
|
|
24
|
+
* is adapter-shaped — events, metrics, the multi-API and multi-version
|
|
25
|
+
* cascades — so the neutral parts are re-spelled here rather than extracted;
|
|
26
|
+
* the duplication (model → VFS → import prepending, dependency extraction,
|
|
27
|
+
* compiler-option resolution) is the recorded Tier 2 item. Every file is
|
|
28
|
+
* written unconditionally: snapshot-tracked incremental writes are not
|
|
29
|
+
* wired for the alpha.
|
|
30
|
+
*
|
|
31
|
+
* @packageDocumentation
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* Prepend `import type` statements for external references to each entry
|
|
35
|
+
* point's declaration file, in place.
|
|
36
|
+
*/
|
|
37
|
+
function prependImportsToVfs(vfs, apiPackage, packageName) {
|
|
38
|
+
const extractor = new TypeReferenceExtractor(apiPackage, packageName);
|
|
39
|
+
for (const entryPoint of apiPackage.entryPoints) {
|
|
40
|
+
const entry = entryPoint;
|
|
41
|
+
const statements = TypeReferenceExtractor.formatImports(extractor.extractImportsForEntryPoint(entry));
|
|
42
|
+
if (statements.length === 0) continue;
|
|
43
|
+
const file = `node_modules/${packageName}/${entry.displayName ? `${entry.displayName}.d.ts` : "index.d.ts"}`;
|
|
44
|
+
const existing = vfs.get(file);
|
|
45
|
+
if (existing) vfs.set(file, `${statements.join("\n")}\n\n${existing}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function resolveCategories(overrides) {
|
|
49
|
+
const result = { ...DEFAULT_CATEGORIES };
|
|
50
|
+
for (const [key, override] of Object.entries(overrides ?? {})) {
|
|
51
|
+
const base = result[key];
|
|
52
|
+
if (base) result[key] = {
|
|
53
|
+
...base,
|
|
54
|
+
...override
|
|
55
|
+
};
|
|
56
|
+
else if (override.displayName && override.singularName && override.folderName) result[key] = override;
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
/** The file a route is written to under `docsDir`. */
|
|
61
|
+
const fileFor = (docsDir, route) => `${docsDir}${route}.md`;
|
|
62
|
+
/**
|
|
63
|
+
* Generate the API pages for one bundle.
|
|
64
|
+
*
|
|
65
|
+
* @remarks
|
|
66
|
+
* Fails typed on what a user can fix — a missing bundle folder, an
|
|
67
|
+
* unreadable model — and degrades on what is an enhancement (external types,
|
|
68
|
+
* a manifest the SEO layer cannot decode, an example Prettier rejects). The
|
|
69
|
+
* error channel is the union of `@tsdoctor/bundle`'s discovery errors,
|
|
70
|
+
* `@tsdoctor/model`'s load errors and the platform's write errors.
|
|
71
|
+
*
|
|
72
|
+
* @public
|
|
73
|
+
*/
|
|
74
|
+
const generate = Effect.fn("Generate.generate")(function* (input) {
|
|
75
|
+
const fs = yield* FileSystem.FileSystem;
|
|
76
|
+
const path = yield* Path.Path;
|
|
77
|
+
const descriptor = yield* discoverBundle(input.dir, { cwd: input.cwd });
|
|
78
|
+
const apiPackage = yield* Model.load(descriptor.modelPath);
|
|
79
|
+
const packageName = descriptor.name;
|
|
80
|
+
const packageJson = descriptor.packageJsonPath === void 0 ? void 0 : yield* fs.readFileString(descriptor.packageJsonPath).pipe(Effect.map((text) => JSON.parse(text)), Effect.orElseSucceed(() => void 0));
|
|
81
|
+
const manifest = packageJson === void 0 ? void 0 : yield* decodeManifest(packageJson);
|
|
82
|
+
const vfs = ApiExtractedPackage.fromPackage(apiPackage, packageName).toVfs();
|
|
83
|
+
prependImportsToVfs(vfs, apiPackage, packageName);
|
|
84
|
+
const externalTypes = yield* loadExternalTypes(vfs, input.externalPackages ?? externalPackagesOf(packageJson), /* @__PURE__ */ new Set([packageName]));
|
|
85
|
+
const compilerOptions = yield* Effect.promise(() => resolveTypeScriptConfig(descriptor.dir, void 0, descriptor.tsconfigPath === void 0 ? void 0 : { tsconfig: descriptor.tsconfigPath }));
|
|
86
|
+
const categories = resolveCategories(input.categories);
|
|
87
|
+
const prepared = prepareWorkItems({
|
|
88
|
+
apiPackage,
|
|
89
|
+
categories,
|
|
90
|
+
baseRoute: input.baseRoute
|
|
91
|
+
});
|
|
92
|
+
if (prepared.collisions.length > 0) return yield* Effect.die(/* @__PURE__ */ new Error(`route collisions under ${input.baseRoute}: ${prepared.collisions.map((c) => `${c.route} <- ${c.items.map((i) => i.displayName).join(", ")}`).join("; ")}`));
|
|
93
|
+
const linker = CrossLinker.fromRoutes(prepared.crossLinkData.routes);
|
|
94
|
+
const siteUrl = deriveSiteUrl(input.siteOrigin, input.base);
|
|
95
|
+
const structuredDataPkg = manifest === void 0 ? void 0 : packageContext({
|
|
96
|
+
siteUrl,
|
|
97
|
+
baseRoute: input.baseRoute,
|
|
98
|
+
packageName,
|
|
99
|
+
...manifest.version != null ? { version: manifest.version.toString() } : {},
|
|
100
|
+
...manifest.description != null ? { description: manifest.description } : {},
|
|
101
|
+
attribution: attributionFacts(manifest)
|
|
102
|
+
});
|
|
103
|
+
const buildTime = (/* @__PURE__ */ new Date()).toISOString();
|
|
104
|
+
const docsDir = path.resolve(input.cwd, input.docsDir);
|
|
105
|
+
const entries = [];
|
|
106
|
+
const routes = [];
|
|
107
|
+
const formatFailures = [];
|
|
108
|
+
for (const workItem of prepared.workItems) {
|
|
109
|
+
const written = yield* writePage(workItem, {
|
|
110
|
+
input,
|
|
111
|
+
packageName,
|
|
112
|
+
linker,
|
|
113
|
+
siteUrl,
|
|
114
|
+
structuredDataPkg,
|
|
115
|
+
buildTime,
|
|
116
|
+
docsDir,
|
|
117
|
+
onFormatFailure: (route) => formatFailures.push(route)
|
|
118
|
+
});
|
|
119
|
+
if (Option.isSome(written)) {
|
|
120
|
+
entries.push(written.value.nav);
|
|
121
|
+
routes.push(written.value.route);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const nav = buildNav({
|
|
125
|
+
baseRoute: input.baseRoute,
|
|
126
|
+
categories: Object.fromEntries(Object.entries(categories).map(([key, config]) => [key, navCategory(config)])),
|
|
127
|
+
entries
|
|
128
|
+
});
|
|
129
|
+
const index = buildIndexPage({
|
|
130
|
+
packageName,
|
|
131
|
+
baseRoute: input.baseRoute
|
|
132
|
+
});
|
|
133
|
+
const indexBody = [
|
|
134
|
+
`# ${index.title}`,
|
|
135
|
+
"",
|
|
136
|
+
index.description,
|
|
137
|
+
"",
|
|
138
|
+
...nav.groups.flatMap((group) => [
|
|
139
|
+
`## ${group.category.displayName}`,
|
|
140
|
+
"",
|
|
141
|
+
...group.pages.map((page) => `- [${page.label}](${page.route})`),
|
|
142
|
+
""
|
|
143
|
+
])
|
|
144
|
+
].join("\n");
|
|
145
|
+
yield* writeFile(fs, path, fileFor(docsDir, `${input.baseRoute}/index`), emitFrontmatter(index) + indexBody);
|
|
146
|
+
return {
|
|
147
|
+
packageName,
|
|
148
|
+
baseRoute: input.baseRoute,
|
|
149
|
+
sidebar: sidebarFor(nav),
|
|
150
|
+
vfs,
|
|
151
|
+
compilerOptions,
|
|
152
|
+
routes,
|
|
153
|
+
crossLinkData: prepared.crossLinkData,
|
|
154
|
+
externalTypes,
|
|
155
|
+
uncategorized: prepared.uncategorized.map((item) => item.displayName),
|
|
156
|
+
formatFailures
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
const writePage = Effect.fn("Generate.writePage")(function* (workItem, ctx) {
|
|
160
|
+
const fs = yield* FileSystem.FileSystem;
|
|
161
|
+
const path = yield* Path.Path;
|
|
162
|
+
const { item, categoryConfig } = workItem;
|
|
163
|
+
const built = yield* buildPage({
|
|
164
|
+
item,
|
|
165
|
+
categoryKey: workItem.categoryKey,
|
|
166
|
+
singularName: categoryConfig.singularName,
|
|
167
|
+
folderName: categoryConfig.folderName,
|
|
168
|
+
baseRoute: ctx.input.baseRoute,
|
|
169
|
+
packageName: ctx.packageName,
|
|
170
|
+
apiName: ctx.input.apiName,
|
|
171
|
+
namespaceMember: workItem.namespaceMember,
|
|
172
|
+
availableFrom: workItem.availableFrom,
|
|
173
|
+
syntheticBase: workItem.syntheticBase,
|
|
174
|
+
memberAnchors: workItem.memberAnchors,
|
|
175
|
+
source: ctx.input.source,
|
|
176
|
+
suppressExampleErrors: ctx.input.suppressExampleErrors,
|
|
177
|
+
linker: ctx.linker,
|
|
178
|
+
onExampleFormatError: () => Effect.sync(() => ctx.onFormatFailure(item.displayName))
|
|
179
|
+
});
|
|
180
|
+
if (Option.isNone(built)) return Option.none();
|
|
181
|
+
const page = built.value;
|
|
182
|
+
const structuredData = ctx.structuredDataPkg === void 0 ? void 0 : deriveScriptBody(ctx.structuredDataPkg, {
|
|
183
|
+
pageRoute: page.route,
|
|
184
|
+
symbolName: item.displayName,
|
|
185
|
+
description: page.description,
|
|
186
|
+
section: categoryConfig.displayName,
|
|
187
|
+
publishedTime: ctx.buildTime,
|
|
188
|
+
modifiedTime: ctx.buildTime
|
|
189
|
+
});
|
|
190
|
+
const tags = headTags({
|
|
191
|
+
siteUrl: ctx.siteUrl,
|
|
192
|
+
pageRoute: page.route,
|
|
193
|
+
description: page.description,
|
|
194
|
+
publishedTime: ctx.buildTime,
|
|
195
|
+
modifiedTime: ctx.buildTime,
|
|
196
|
+
section: categoryConfig.displayName,
|
|
197
|
+
packageName: ctx.packageName,
|
|
198
|
+
...structuredData !== void 0 && structuredData._tag === "Success" ? { structuredData: structuredData.success } : {}
|
|
199
|
+
});
|
|
200
|
+
const body = yield* Effect.fromResult(emitMarkdownBody(page)).pipe(Effect.orDie);
|
|
201
|
+
const content = emitFrontmatter({
|
|
202
|
+
title: page.title,
|
|
203
|
+
description: page.description,
|
|
204
|
+
headTags: tags
|
|
205
|
+
}) + body;
|
|
206
|
+
yield* writeFile(fs, path, fileFor(ctx.docsDir, page.route), content);
|
|
207
|
+
return Option.some({
|
|
208
|
+
route: page.route,
|
|
209
|
+
nav: page.nav
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
function writeFile(fs, path, file, content) {
|
|
213
|
+
return fs.makeDirectory(path.dirname(file), { recursive: true }).pipe(Effect.andThen(fs.writeFileString(file, content)));
|
|
214
|
+
}
|
|
215
|
+
function decodeManifest(packageJson) {
|
|
216
|
+
return PackageManifest.decode(packageJson).pipe(Effect.map((manifest) => manifest), Effect.orElseSucceed(() => void 0));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
//#endregion
|
|
220
|
+
export { generate };
|
package/Registry.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Effect, Layer, Path } from "effect";
|
|
2
|
+
import { NodeFileSystem, NodeHttpClient } from "@effect/platform-node";
|
|
3
|
+
import { Cache } from "@effected/store";
|
|
4
|
+
import { AppDirs, Xdg } from "@effected/xdg";
|
|
5
|
+
import { PackageFetcher, PackageSpec, RegistryObserver, TypeCache, TypeRegistry } from "@tsdoctor/registry";
|
|
6
|
+
|
|
7
|
+
//#region src/Registry.ts
|
|
8
|
+
/**
|
|
9
|
+
* External type loading: the `@tsdoctor/registry` stack over the same XDG
|
|
10
|
+
* cache the RSPress plugin uses, and the degrading merge of a documented
|
|
11
|
+
* package's dependencies into the build's virtual file system.
|
|
12
|
+
*
|
|
13
|
+
* @remarks
|
|
14
|
+
* External types are an enhancement — without them code blocks type-check
|
|
15
|
+
* without their dependencies' declarations, which is a worse page rather
|
|
16
|
+
* than a broken build — so every failure here degrades to "no external
|
|
17
|
+
* types" and is reported as a warning the caller may print.
|
|
18
|
+
*
|
|
19
|
+
* @packageDocumentation
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* The XDG namespace every tsdoctor cache lives under — shared with the
|
|
23
|
+
* RSPress plugin so both adapters read one type cache and one Twoslash
|
|
24
|
+
* result cache.
|
|
25
|
+
*
|
|
26
|
+
* @public
|
|
27
|
+
*/
|
|
28
|
+
const TSDOCTOR_NAMESPACE = "tsdoctor";
|
|
29
|
+
/** Node platform services: the filesystem and path implementations. */
|
|
30
|
+
const PlatformLive = Layer.mergeAll(NodeFileSystem.layer, Path.layer);
|
|
31
|
+
/** XDG application directories rooted at {@link TSDOCTOR_NAMESPACE}. */
|
|
32
|
+
const AppDirsLive = AppDirs.layer({ namespace: TSDOCTOR_NAMESPACE }).pipe(Layer.provide(Layer.mergeAll(Xdg.layer, PlatformLive)));
|
|
33
|
+
/** Metadata plane: a sqlite-backed `@effected/store` Cache rooted in the XDG cache dir. */
|
|
34
|
+
const MetadataCacheLive = Layer.unwrap(Effect.gen(function* () {
|
|
35
|
+
const appDirs = yield* AppDirs;
|
|
36
|
+
const path = yield* Path.Path;
|
|
37
|
+
const cacheDir = yield* appDirs.ensureCache;
|
|
38
|
+
return Cache.layerSqlite({ filename: path.join(cacheDir, "metadata.sqlite") });
|
|
39
|
+
})).pipe(Layer.provide(Layer.mergeAll(AppDirsLive, PlatformLive)), Cache.degrading);
|
|
40
|
+
/** The registry emits no logs of its own; this adapter listens to nothing yet. */
|
|
41
|
+
const RegistryObserverLive = Layer.succeed(RegistryObserver, { emit: () => Effect.void });
|
|
42
|
+
/**
|
|
43
|
+
* The full registry runtime: `TypeRegistry` over an XDG-rooted `TypeCache`
|
|
44
|
+
* and the jsDelivr `PackageFetcher`. Bound to a const so the stack builds
|
|
45
|
+
* once per runtime.
|
|
46
|
+
*
|
|
47
|
+
* @public
|
|
48
|
+
*/
|
|
49
|
+
const RegistryLive = TypeRegistry.layer.pipe(Layer.provideMerge(Layer.mergeAll(TypeCache.layerXdg(), PackageFetcher.layer)), Layer.provideMerge(RegistryObserverLive), Layer.provide(Layer.mergeAll(MetadataCacheLive, AppDirsLive, PlatformLive, NodeHttpClient.layerUndici)), Layer.orDie);
|
|
50
|
+
/**
|
|
51
|
+
* The dependency-field names `package.json` declares packages under.
|
|
52
|
+
*/
|
|
53
|
+
const DEPENDENCY_FIELDS = ["dependencies", "peerDependencies"];
|
|
54
|
+
/**
|
|
55
|
+
* The external packages a documented package's manifest declares, in the
|
|
56
|
+
* `dependencies` and `peerDependencies` fields.
|
|
57
|
+
*
|
|
58
|
+
* @public
|
|
59
|
+
*/
|
|
60
|
+
function externalPackagesOf(packageJson) {
|
|
61
|
+
if (packageJson === void 0) return [];
|
|
62
|
+
const packages = [];
|
|
63
|
+
for (const field of DEPENDENCY_FIELDS) {
|
|
64
|
+
const entries = packageJson[field];
|
|
65
|
+
if (entries === null || typeof entries !== "object") continue;
|
|
66
|
+
for (const [name, version] of Object.entries(entries)) if (typeof version === "string" && !version.startsWith("workspace:") && !version.startsWith("catalog:")) packages.push({
|
|
67
|
+
name,
|
|
68
|
+
version
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return packages;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Resolve each package to an exact published version and merge its
|
|
75
|
+
* declarations into `vfs`, in place. First-party packages (the ones being
|
|
76
|
+
* documented) are excluded: their api.json-derived declarations are
|
|
77
|
+
* authoritative and a published copy would clobber them.
|
|
78
|
+
*
|
|
79
|
+
* @remarks
|
|
80
|
+
* Degrades, never fails: an unresolvable package is skipped, and an
|
|
81
|
+
* infrastructure failure (no HOME for XDG, an unreachable CDN) leaves the
|
|
82
|
+
* VFS as it was with a `warning` in the report.
|
|
83
|
+
*
|
|
84
|
+
* @public
|
|
85
|
+
*/
|
|
86
|
+
const loadExternalTypes = Effect.fn("Registry.loadExternalTypes")(function* (vfs, packages, documented) {
|
|
87
|
+
const candidates = packages.filter((pkg) => !documented.has(pkg.name));
|
|
88
|
+
if (candidates.length === 0) return {
|
|
89
|
+
loaded: [],
|
|
90
|
+
skipped: []
|
|
91
|
+
};
|
|
92
|
+
const registry = yield* TypeRegistry;
|
|
93
|
+
const resolved = yield* Effect.forEach(candidates, (pkg) => registry.resolveVersion(pkg.name, pkg.version).pipe(Effect.map((version) => new PackageSpec({
|
|
94
|
+
name: pkg.name,
|
|
95
|
+
version
|
|
96
|
+
})), Effect.catch(() => Effect.succeed(null))), { concurrency: 5 });
|
|
97
|
+
const specs = resolved.filter((spec) => spec !== null);
|
|
98
|
+
const skipped = candidates.filter((_, index) => resolved[index] === null).map((pkg) => pkg.name);
|
|
99
|
+
if (specs.length === 0) return {
|
|
100
|
+
loaded: [],
|
|
101
|
+
skipped
|
|
102
|
+
};
|
|
103
|
+
const result = yield* Effect.result(registry.getVfs(specs, { autoFetch: true }));
|
|
104
|
+
if (result._tag === "Failure") return {
|
|
105
|
+
loaded: [],
|
|
106
|
+
skipped: [...skipped, ...specs.map((spec) => spec.name)],
|
|
107
|
+
warning: result.failure.message
|
|
108
|
+
};
|
|
109
|
+
for (const [file, content] of result.success.entries()) vfs.set(file, content);
|
|
110
|
+
return {
|
|
111
|
+
loaded: specs.map((spec) => spec.name),
|
|
112
|
+
skipped
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
//#endregion
|
|
117
|
+
export { AppDirsLive, PlatformLive, RegistryLive, TSDOCTOR_NAMESPACE, externalPackagesOf, loadExternalTypes };
|
package/Twoslash.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { DEFAULT_COMPILER_OPTIONS, toProgrammaticCompilerOptions, twoslashEnvHash } from "@tsdoctor/vfs";
|
|
2
|
+
import { transformerTwoslash } from "@shikijs/vitepress-twoslash";
|
|
3
|
+
import ts from "typescript";
|
|
4
|
+
|
|
5
|
+
//#region src/Twoslash.ts
|
|
6
|
+
/**
|
|
7
|
+
* Fingerprint the type environment: the declarations plus the compiler that
|
|
8
|
+
* interprets them, so a generation cached by one TypeScript is never served
|
|
9
|
+
* by another.
|
|
10
|
+
*
|
|
11
|
+
* @public
|
|
12
|
+
*/
|
|
13
|
+
function environmentHash(vfs) {
|
|
14
|
+
return twoslashEnvHash(vfs, `typescript@${ts.version}`);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Build the Shiki transformer for VitePress's `markdown.codeTransformers`.
|
|
18
|
+
*
|
|
19
|
+
* @remarks
|
|
20
|
+
* Errors never throw: `noErrorValidation` lets a diagnostic render as an
|
|
21
|
+
* annotation, and `throws: false` keeps `@shikijs/vitepress-twoslash` from
|
|
22
|
+
* failing the build on one (it would, by default, on CI). Examples are
|
|
23
|
+
* documentation, not a test suite.
|
|
24
|
+
*
|
|
25
|
+
* @public
|
|
26
|
+
*/
|
|
27
|
+
function makeTwoslashTransformer(options) {
|
|
28
|
+
const extraFiles = {};
|
|
29
|
+
for (const [path, content] of options.vfs.entries()) extraFiles[path] = content;
|
|
30
|
+
const compilerOptions = toProgrammaticCompilerOptions(options.compilerOptions ?? DEFAULT_COMPILER_OPTIONS);
|
|
31
|
+
return transformerTwoslash({
|
|
32
|
+
...options.typesCache != null ? { typesCache: options.typesCache } : {},
|
|
33
|
+
twoslashOptions: {
|
|
34
|
+
extraFiles,
|
|
35
|
+
compilerOptions,
|
|
36
|
+
handbookOptions: { noErrorValidation: true }
|
|
37
|
+
},
|
|
38
|
+
throws: false
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
//#endregion
|
|
43
|
+
export { environmentHash, makeTwoslashTransformer };
|
package/TwoslashCache.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { AppDirsLive, PlatformLive } from "./Registry.js";
|
|
2
|
+
import { Context, Effect, Layer, Option, Path } from "effect";
|
|
3
|
+
import { decodeTwoslashCache, encodeTwoslashCache, makeTwoslashCache, twoslashBlobKey } from "@tsdoctor/vfs";
|
|
4
|
+
import { Cache } from "@effected/store";
|
|
5
|
+
import { AppDirs } from "@effected/xdg";
|
|
6
|
+
|
|
7
|
+
//#region src/TwoslashCache.ts
|
|
8
|
+
/**
|
|
9
|
+
* Persistence for the Twoslash result cache: one gzipped generation per type
|
|
10
|
+
* environment in a sqlite-backed `@effected/store` Cache under the XDG cache
|
|
11
|
+
* dir — the same `twoslash.sqlite` and the same blob keys the RSPress plugin
|
|
12
|
+
* writes, so a site built by either adapter warms the other.
|
|
13
|
+
*
|
|
14
|
+
* @remarks
|
|
15
|
+
* `TwoslashTypesCache.read`/`write` are synchronous (Shiki calls them inside
|
|
16
|
+
* its `preprocess` hook), so persistence is load-once before generation and
|
|
17
|
+
* save-once after the site build; every lookup in between is an in-memory
|
|
18
|
+
* map hit. Both operations degrade: a cache that cannot be read is a cache
|
|
19
|
+
* miss, and one that cannot be written loses nothing but the next warm start.
|
|
20
|
+
*
|
|
21
|
+
* @packageDocumentation
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* A sqlite-backed Cache in the XDG cache dir, separate from the registry's
|
|
25
|
+
* `metadata.sqlite`. Degrading: an unusable cache directory yields a cache
|
|
26
|
+
* that always misses rather than a failed build.
|
|
27
|
+
*/
|
|
28
|
+
const CacheLive = Layer.unwrap(Effect.gen(function* () {
|
|
29
|
+
const appDirs = yield* AppDirs;
|
|
30
|
+
const path = yield* Path.Path;
|
|
31
|
+
const cacheDir = yield* appDirs.ensureCache;
|
|
32
|
+
return Cache.layerSqlite({ filename: path.join(cacheDir, "twoslash.sqlite") });
|
|
33
|
+
})).pipe(Layer.provide(Layer.mergeAll(AppDirsLive, PlatformLive)), Cache.degrading);
|
|
34
|
+
/**
|
|
35
|
+
* The Twoslash generation store.
|
|
36
|
+
*
|
|
37
|
+
* @public
|
|
38
|
+
*/
|
|
39
|
+
var TwoslashCacheStore = class extends Context.Service()("vitepress-plugin-api-extractor/TwoslashCacheStore") {
|
|
40
|
+
/**
|
|
41
|
+
* The live store over the XDG sqlite cache.
|
|
42
|
+
*
|
|
43
|
+
* @remarks
|
|
44
|
+
* `Layer.suspend` because the composition below is declared after this
|
|
45
|
+
* class: a static initializer runs while the module body is still
|
|
46
|
+
* evaluating, so naming those consts directly throws at import time with a
|
|
47
|
+
* clean typecheck.
|
|
48
|
+
*/
|
|
49
|
+
static layer = Layer.suspend(() => StoreLive);
|
|
50
|
+
/** An always-cold in-memory store, for tests. */
|
|
51
|
+
static layerTest = Layer.sync(this, () => {
|
|
52
|
+
let open = null;
|
|
53
|
+
return {
|
|
54
|
+
open: (envHash) => Effect.sync(() => {
|
|
55
|
+
const cache = makeTwoslashCache();
|
|
56
|
+
open = {
|
|
57
|
+
cache,
|
|
58
|
+
envHash
|
|
59
|
+
};
|
|
60
|
+
return cache;
|
|
61
|
+
}),
|
|
62
|
+
persist: () => Effect.sync(() => open === null ? Option.none() : Option.some({
|
|
63
|
+
...open.cache.stats(),
|
|
64
|
+
envHash: open.envHash,
|
|
65
|
+
degraded: false
|
|
66
|
+
}))
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
};
|
|
70
|
+
const StoreLive = Layer.effect(TwoslashCacheStore, Effect.gen(function* () {
|
|
71
|
+
const store = yield* Cache;
|
|
72
|
+
let open = null;
|
|
73
|
+
return {
|
|
74
|
+
open: (envHash) => store.get(twoslashBlobKey(envHash)).pipe(Effect.map((entry) => Option.isSome(entry) ? decodeTwoslashCache(entry.value.value) : /* @__PURE__ */ new Map()), Effect.catch(() => Effect.succeed(/* @__PURE__ */ new Map())), Effect.map((restored) => {
|
|
75
|
+
const cache = makeTwoslashCache(restored);
|
|
76
|
+
open = {
|
|
77
|
+
cache,
|
|
78
|
+
envHash
|
|
79
|
+
};
|
|
80
|
+
return cache;
|
|
81
|
+
})),
|
|
82
|
+
persist: () => Effect.suspend(() => {
|
|
83
|
+
if (open === null) return Effect.succeed(Option.none());
|
|
84
|
+
const { cache, envHash } = open;
|
|
85
|
+
const stats = cache.stats();
|
|
86
|
+
const report = Option.some({
|
|
87
|
+
...stats,
|
|
88
|
+
envHash,
|
|
89
|
+
degraded: store.degraded
|
|
90
|
+
});
|
|
91
|
+
if (!stats.dirty) return Effect.succeed(report);
|
|
92
|
+
return store.set({
|
|
93
|
+
key: twoslashBlobKey(envHash),
|
|
94
|
+
value: encodeTwoslashCache(cache.entries()),
|
|
95
|
+
tags: ["twoslash"]
|
|
96
|
+
}).pipe(Effect.catch(() => Effect.void), Effect.as(report));
|
|
97
|
+
})
|
|
98
|
+
};
|
|
99
|
+
})).pipe(Layer.provide(CacheLive));
|
|
100
|
+
|
|
101
|
+
//#endregion
|
|
102
|
+
export { TwoslashCacheStore };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { emitFrontmatterBlock } from "@tsdoctor/model";
|
|
2
|
+
|
|
3
|
+
//#region src/emit/frontmatter.ts
|
|
4
|
+
/**
|
|
5
|
+
* Frontmatter assembly for generated pages: the page title and description
|
|
6
|
+
* plus the neutral `@tsdoctor/seo` head tags rendered into VitePress's
|
|
7
|
+
* `HeadConfig` shape.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* Adapter-side on purpose — the IR carries facts and a `HeadTag[]`, not a
|
|
11
|
+
* block. VitePress spells a `meta`/`link` tag as a `[tag, attrs]` pair and a
|
|
12
|
+
* script body as the `[tag, attrs, innerHTML]` TRIPLE; RSPress spells the
|
|
13
|
+
* same body as a `children` attribute. That one difference is why assembly
|
|
14
|
+
* is not shared.
|
|
15
|
+
*
|
|
16
|
+
* @packageDocumentation
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Collapse newlines and runs of whitespace to single spaces, and trim.
|
|
20
|
+
*
|
|
21
|
+
* @remarks
|
|
22
|
+
* Whitespace normalization, not quoting — `@effected/yaml` owns quoting.
|
|
23
|
+
*/
|
|
24
|
+
function cleanValue(value) {
|
|
25
|
+
return value.replace(/[\r\n]+/g, " ").replace(/\s+/g, " ").trim();
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Render a neutral head tag into VitePress's `HeadConfig` entry.
|
|
29
|
+
*
|
|
30
|
+
* @public
|
|
31
|
+
*/
|
|
32
|
+
function headConfig(tag) {
|
|
33
|
+
const attrs = {};
|
|
34
|
+
for (const [key, value] of Object.entries(tag.attrs)) attrs[key] = cleanValue(value);
|
|
35
|
+
return tag.body != null ? [
|
|
36
|
+
tag.tag,
|
|
37
|
+
attrs,
|
|
38
|
+
cleanValue(tag.body)
|
|
39
|
+
] : [tag.tag, attrs];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Emit the frontmatter block for a page: `title`, `description` and, when
|
|
43
|
+
* the page carries any, `head` in VitePress's `HeadConfig[]` shape.
|
|
44
|
+
*
|
|
45
|
+
* @public
|
|
46
|
+
*/
|
|
47
|
+
function emitFrontmatter(input) {
|
|
48
|
+
const data = {
|
|
49
|
+
title: cleanValue(input.title),
|
|
50
|
+
description: cleanValue(input.description)
|
|
51
|
+
};
|
|
52
|
+
const head = (input.headTags ?? []).map(headConfig);
|
|
53
|
+
if (head.length > 0) data.head = head;
|
|
54
|
+
return emitFrontmatterBlock(data);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
//#endregion
|
|
58
|
+
export { emitFrontmatter, headConfig };
|