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.
@@ -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 };
@@ -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 };