diff --git a/.changeset/caching-docs.md b/.changeset/caching-docs.md
new file mode 100644
index 00000000..d4b44d47
--- /dev/null
+++ b/.changeset/caching-docs.md
@@ -0,0 +1,5 @@
+---
+"expressive-code-twoslash": patch
+---
+
+feat: add opt-in build-time cache for Twoslash results
diff --git a/docs/astro.config.mts b/docs/astro.config.mts
index 95594111..aa965224 100644
--- a/docs/astro.config.mts
+++ b/docs/astro.config.mts
@@ -199,6 +199,14 @@ export default defineConfig({
label: "External Types",
link: "usage/external-types",
},
+ {
+ label: "Build-Time Cache",
+ link: "usage/build-time-cache",
+ badge: {
+ text: "v0.6.2",
+ variant: "success",
+ },
+ },
{
label: "Show Emitted Files",
link: "usage/show-emitted-files",
diff --git a/docs/src/content/docs/getting-started/installation.mdx b/docs/src/content/docs/getting-started/installation.mdx
index 8390109e..ca18546b 100644
--- a/docs/src/content/docs/getting-started/installation.mdx
+++ b/docs/src/content/docs/getting-started/installation.mdx
@@ -19,6 +19,7 @@ import { PackageManagers } from 'starlight-package-managers'
| [Code Cutting](/usage/code-cutting) | ✅ |
| [Callouts](/usage/banners/callouts) | ✅ |
| [TS Compiler Overrides](/usage/ts-compiler-flags) | ✅ |
+| [Build-Time Cache](/usage/build-time-cache) | ✅ |
| [Show Emitted Files](/usage/show-emitted-files) | ✅ |
### Installation
@@ -164,6 +165,7 @@ ecTwoSlash({
includeJsDoc: true,
// @annotate: allowNonStandardJsDocTags is required for like this to work
allowNonStandardJsDocTags: true,
+ cache: false,
twoslashOptions: {
compilerOptions: {
moduleResolution: 100 satisfies ts.ModuleResolutionKind.Bundler,
@@ -188,4 +190,4 @@ Another option is to set the `NODE_OPTIONS` environment variable to increase the
```bash
NODE_OPTIONS="--max-old-space-size=8192" astro build
-```
\ No newline at end of file
+```
diff --git a/docs/src/content/docs/reference/configuration.mdx b/docs/src/content/docs/reference/configuration.mdx
index bfd76305..13a6607e 100644
--- a/docs/src/content/docs/reference/configuration.mdx
+++ b/docs/src/content/docs/reference/configuration.mdx
@@ -211,6 +211,63 @@ export default defineEcConfig({
});
```
+### cache
+
+
+- Type: `boolean | TwoslashCacheOptions`
+- Default: `false`
+
+
+The `cache` option enables the opt-in persistent disk cache for Twoslash JSON results. Set it to `true` to use the default cache directory, or pass an object to customize where entries are stored, how verbose logging should be, and how cache invalidation should work.
+
+#### Example
+
+```ts twoslash title="ec.config.mjs" {7-12}
+import { defineEcConfig } from 'astro-expressive-code';
+import ecTwoSlash from "expressive-code-twoslash";
+
+export default defineEcConfig({
+ plugins: [
+ ecTwoSlash({
+ cache: {
+ dir: ".cache/twoslash",
+ fingerprint: "docs-v2",
+ logLevel: "summary",
+ },
+ }),
+ ],
+});
+```
+
+#### TwoslashCacheOptions
+
+##### dir
+
+
+- Type: `string`
+- Default: `.cache/expressive-code-twoslash`
+
+
+Directory used to persist cached Twoslash JSON artifacts. Relative paths resolve from the plugin `cwd`.
+
+##### fingerprint
+
+
+- Type: `string`
+- Default: `undefined`
+
+
+Optional caller-controlled invalidation string. Change it whenever you want to force a new cache namespace without moving directories.
+
+##### logLevel
+
+
+- Type: `"off" | "summary" | "debug"`
+- Default: `"summary"`
+
+
+Controls cache logging verbosity. Use `off` to stay silent, `summary` for one end-of-build summary, or `debug` to also log cache read and write errors as they happen.
+
### twoslashOptions
@@ -302,4 +359,4 @@ type CreateTwoslashESLintOptions = {
*/
mergeMessages?: boolean;
}
-```
\ No newline at end of file
+```
diff --git a/docs/src/content/docs/usage/build-time-cache.mdx b/docs/src/content/docs/usage/build-time-cache.mdx
new file mode 100644
index 00000000..d19a432f
--- /dev/null
+++ b/docs/src/content/docs/usage/build-time-cache.mdx
@@ -0,0 +1,100 @@
+---
+title: Build-Time Cache
+description: Speed up repeated Twoslash builds with the opt-in disk cache.
+sidebar:
+ order: 7
+---
+
+import { Aside, TabItem, Tabs } from '@astrojs/starlight/components';
+
+EC-Twoslash can persist Twoslash JSON output to disk and reuse it on later builds. The cache is disabled by default and only turns on when you set the `cache` option.
+
+## Enable the cache
+
+
+
+
+```ts twoslash title="ec.config.mjs" {7-12}
+import { defineEcConfig } from 'astro-expressive-code';
+import ecTwoSlash from 'expressive-code-twoslash';
+
+export default defineEcConfig({
+ plugins: [
+ ecTwoSlash({
+ cache: true,
+ }),
+ ],
+});
+```
+
+
+
+
+```ts twoslash title="ec.config.mjs" {7-12}
+import { defineEcConfig } from '@astrojs/starlight/expressive-code';
+import ecTwoSlash from 'expressive-code-twoslash';
+
+export default defineEcConfig({
+ plugins: [
+ ecTwoSlash({
+ cache: true,
+ }),
+ ],
+});
+```
+
+
+
+
+With `cache: true`, EC-Twoslash writes entries to `.cache/expressive-code-twoslash` relative to the plugin `cwd`.
+
+## Customize cache behavior
+
+```ts twoslash title="ec.config.mjs" {7-14}
+import { defineEcConfig } from 'astro-expressive-code';
+import ecTwoSlash from 'expressive-code-twoslash';
+
+export default defineEcConfig({
+ plugins: [
+ ecTwoSlash({
+ cache: {
+ dir: '.cache/twoslash',
+ fingerprint: 'docs-v2',
+ logLevel: 'summary',
+ },
+ }),
+ ],
+});
+```
+
+- `dir`: cache directory, resolved from the plugin `cwd`
+- `fingerprint`: optional invalidation string for forcing a fresh cache namespace
+- `logLevel`: `off`, `summary`, or `debug`
+
+## Cache keys
+
+Each cache key includes the rendered snippet input plus the relevant execution context. That means cache entries are automatically invalidated when the code sample or Twoslash environment changes.
+
+The key currently includes:
+
+- snippet contents
+- effective Twoslash create and execute options
+- plugin context passed into the render
+- cache fingerprint
+- `expressive-code-twoslash` version
+- `@ec-ts/twoslash` version
+- `typescript` version
+
+
+
+## Logging
+
+- `off`: no cache logging
+- `summary`: prints one end-of-build summary with hits, misses, writes, and errors
+- `debug`: prints the summary plus per-entry read and write errors
+
+## When to use it
+
+The cache helps most when your docs render the same Twoslash blocks across repeated local builds or CI runs. For one-off builds, leaving it disabled keeps behavior simple.
diff --git a/packages/expressive-code-twoslash/README.md b/packages/expressive-code-twoslash/README.md
index db3e60c9..03204e45 100644
--- a/packages/expressive-code-twoslash/README.md
+++ b/packages/expressive-code-twoslash/README.md
@@ -11,29 +11,49 @@ A Expressive Code plugin that adds Twoslash support to your Expressive Code Type
[Read the full documentation →](https://twoslash.studiocms.dev)
+## Build-Time Cache
+
+Disk caching is opt-in:
+
+```ts
+ecTwoSlash({
+ cache: {
+ dir: ".cache/twoslash",
+ logLevel: "summary",
+ },
+});
+```
+
+- `dir`: cache directory, resolved from the plugin `cwd`
+- `fingerprint`: optional caller-controlled invalidation string
+- `logLevel`: `off`, `summary`, or `debug`
+
+The cache key includes the snippet contents, effective Twoslash options, cache fingerprint, plugin version, `@ec-ts/twoslash` version, and TypeScript version.
+
## Currently Supported Languages
-| Language | Identifier |
-| -------- | ---------- |
-| TypeScript | `ts` |
-| React TSX | `tsx` |
-| Vue | `vue` |
+| Language | Identifier |
+| ---------- | ---------- |
+| TypeScript | `ts` |
+| React TSX | `tsx` |
+| Vue | `vue` |
## Currently Supported Features
-| Feature | Supported Status |
-|-----------------------------------------------------------|------------------|
-| [JSDocs and Type Hover boxes](https://twoslash.studiocms.dev/getting-started/basic) | ✅ |
-| [Error Handling/Messages](https://twoslash.studiocms.dev/usage/banners/errors) | ✅ |
-| [Type Extraction](https://twoslash.studiocms.dev/usage/queries/extractions) | ✅ |
-| [Code Completions](https://twoslash.studiocms.dev/usage/queries/completions) | ✅ |
-| [Code Highlighting](https://twoslash.studiocms.dev/usage/queries/highlights) | ✅ |
-| [Code Cutting](https://twoslash.studiocms.dev/usage/code-cutting) | ✅ |
-| [Callouts](https://twoslash.studiocms.dev/usage/banners/callouts) | ✅ |
-| [TS Compiler Overrides](https://twoslash.studiocms.dev/usage/ts-compiler-flags) | ✅ |
-| [Show Emitted Files](https://twoslash.studiocms.dev/usage/show-emitted-files) | ✅ |
+| Feature | Supported Status |
+| ----------------------------------------------------------------------------------- | ---------------- |
+| [JSDocs and Type Hover boxes](https://twoslash.studiocms.dev/getting-started/basic) | ✅ |
+| [Error Handling/Messages](https://twoslash.studiocms.dev/usage/banners/errors) | ✅ |
+| [Type Extraction](https://twoslash.studiocms.dev/usage/queries/extractions) | ✅ |
+| [Code Completions](https://twoslash.studiocms.dev/usage/queries/completions) | ✅ |
+| [Code Highlighting](https://twoslash.studiocms.dev/usage/queries/highlights) | ✅ |
+| [Code Cutting](https://twoslash.studiocms.dev/usage/code-cutting) | ✅ |
+| [Callouts](https://twoslash.studiocms.dev/usage/banners/callouts) | ✅ |
+| [TS Compiler Overrides](https://twoslash.studiocms.dev/usage/ts-compiler-flags) | ✅ |
+| [Show Emitted Files](https://twoslash.studiocms.dev/usage/show-emitted-files) | ✅ |
### TODO
+
- [ ] Make Annotations accessible
- [ ] Use EC's Markdown processing system once released. (Requires support from EC (Planned))
@@ -45,4 +65,4 @@ A Expressive Code plugin that adds Twoslash support to your Expressive Code Type
- [GitHub: @Hippotastic](https://github.com/hippotastic) for providing/maintaining Expressive Code as well as being a huge help during the development of this plugin!
- [EffectTS Website](https://effect.website/docs) for showing the EC Community that Twoslash CAN be used with Expressive-Code. While they did have the first working version, this re-sparked interest in me building out a fully featured EC-Twoslash plugin for the community.
-- [`shiki-twoslash`](https://github.com/shikijs/twoslash/tree/main/packages/shiki-twoslash) for being the example of how to implement the elements within codeblocks as well as providing the basic layouts of the elements.
\ No newline at end of file
+- [`shiki-twoslash`](https://github.com/shikijs/twoslash/tree/main/packages/shiki-twoslash) for being the example of how to implement the elements within codeblocks as well as providing the basic layouts of the elements.
diff --git a/packages/expressive-code-twoslash/src/cache/index.ts b/packages/expressive-code-twoslash/src/cache/index.ts
new file mode 100644
index 00000000..5249faf6
--- /dev/null
+++ b/packages/expressive-code-twoslash/src/cache/index.ts
@@ -0,0 +1,125 @@
+import { createRequire } from "node:module";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import ts from "typescript";
+import { createTwoslashCacheKey } from "./key.ts";
+import { createTwoslashCacheStats } from "./stats.ts";
+import { readCacheEntry, writeCacheEntry } from "./store.ts";
+import type {
+ ResolvedTwoslashCacheOptions,
+ TwoslashCacheEnvironment,
+ TwoslashCacheKeyInput,
+ TwoslashCacheOptions,
+} from "./types.ts";
+
+function findNearestPackageJson(startPath: string) {
+ let currentPath = startPath;
+
+ for (;;) {
+ const packageJsonPath = path.join(currentPath, "package.json");
+ try {
+ const require = createRequire(import.meta.url);
+ const packageJson = require(packageJsonPath);
+ if (
+ packageJson &&
+ typeof packageJson === "object" &&
+ "version" in packageJson &&
+ typeof packageJson.version === "string"
+ ) {
+ return packageJson.version;
+ }
+ return undefined;
+ } catch {
+ const nextPath = path.dirname(currentPath);
+ if (nextPath === currentPath) {
+ return undefined;
+ }
+ currentPath = nextPath;
+ }
+ }
+}
+
+function resolvePackageVersion(specifier: string) {
+ const require = createRequire(import.meta.url);
+
+ try {
+ const entryPath = require.resolve(specifier);
+ return findNearestPackageJson(path.dirname(entryPath));
+ } catch {
+ return undefined;
+ }
+}
+
+export function getTwoslashCacheEnvironment(): TwoslashCacheEnvironment {
+ return {
+ expressiveCodeTwoslashVersion: findNearestPackageJson(
+ path.dirname(fileURLToPath(import.meta.url)),
+ ),
+ twoslashVersion: resolvePackageVersion("@ec-ts/twoslash"),
+ typescriptVersion: ts.version,
+ };
+}
+
+export function resolveTwoslashCacheOptions(
+ cwd: string,
+ cache: boolean | TwoslashCacheOptions | undefined,
+) {
+ if (!cache) {
+ return undefined;
+ }
+
+ const options = cache === true ? {} : cache;
+
+ const resolved: ResolvedTwoslashCacheOptions = {
+ dir: path.resolve(cwd, options.dir ?? ".cache/expressive-code-twoslash"),
+ fingerprint: options.fingerprint,
+ logLevel: options.logLevel ?? "summary",
+ };
+
+ return resolved;
+}
+
+export function createTwoslashCache(
+ options: ResolvedTwoslashCacheOptions | undefined,
+ environment: TwoslashCacheEnvironment,
+) {
+ if (!options) {
+ return undefined;
+ }
+
+ const stats = createTwoslashCacheStats(options);
+ stats.registerSummary();
+
+ return {
+ async getOrCompute(input: TwoslashCacheKeyInput, compute: () => Promise) {
+ const key = createTwoslashCacheKey(environment, {
+ ...input,
+ fingerprint: input.fingerprint ?? options.fingerprint,
+ });
+
+ const cached = await readCacheEntry(options.dir, key);
+
+ if (cached.status === "hit") {
+ stats.recordHit(key);
+ return cached.value;
+ }
+
+ if (cached.status === "error") {
+ stats.recordReadError(key, cached.error);
+ }
+
+ stats.recordMiss(key);
+
+ const result = await compute();
+
+ try {
+ await writeCacheEntry(options.dir, key, result);
+ stats.recordWrite(key);
+ } catch (error) {
+ stats.recordWriteError(key, error);
+ }
+
+ return result;
+ },
+ };
+}
diff --git a/packages/expressive-code-twoslash/src/cache/key.ts b/packages/expressive-code-twoslash/src/cache/key.ts
new file mode 100644
index 00000000..e1314c6d
--- /dev/null
+++ b/packages/expressive-code-twoslash/src/cache/key.ts
@@ -0,0 +1,75 @@
+import { createHash } from "node:crypto";
+import type { TwoslashCacheEnvironment, TwoslashCacheKeyInput } from "./types.ts";
+
+type JsonPrimitive = boolean | null | number | string;
+type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
+
+const cacheSchemaVersion = 1;
+
+function normalizeJson(value: unknown): JsonValue {
+ if (
+ value === null ||
+ typeof value === "boolean" ||
+ typeof value === "number" ||
+ typeof value === "string"
+ ) {
+ return value;
+ }
+
+ if (typeof value === "bigint") {
+ return value.toString();
+ }
+
+ if (value === undefined || typeof value === "function" || typeof value === "symbol") {
+ return null;
+ }
+
+ if (Array.isArray(value)) {
+ return value.map(normalizeJson);
+ }
+
+ if (value instanceof Map) {
+ return [...value.entries()]
+ .map(([key, entryValue]) => ({
+ key: String(key),
+ value: normalizeJson(entryValue),
+ }))
+ .sort((left, right) => left.key.localeCompare(right.key));
+ }
+
+ if (value instanceof Set) {
+ return [...value.values()].map(normalizeJson);
+ }
+
+ if (value instanceof Date) {
+ return value.toISOString();
+ }
+
+ if (typeof value === "object") {
+ return Object.entries(value)
+ .sort(([left], [right]) => left.localeCompare(right))
+ .reduce>((record, [key, entryValue]) => {
+ record[key] = normalizeJson(entryValue);
+ return record;
+ }, {});
+ }
+
+ return String(value);
+}
+
+export function createTwoslashCacheKey(
+ environment: TwoslashCacheEnvironment,
+ input: TwoslashCacheKeyInput,
+) {
+ return createHash("sha256")
+ .update(
+ JSON.stringify(
+ normalizeJson({
+ cacheSchemaVersion,
+ environment,
+ input,
+ }),
+ ),
+ )
+ .digest("hex");
+}
diff --git a/packages/expressive-code-twoslash/src/cache/stats.ts b/packages/expressive-code-twoslash/src/cache/stats.ts
new file mode 100644
index 00000000..57c70309
--- /dev/null
+++ b/packages/expressive-code-twoslash/src/cache/stats.ts
@@ -0,0 +1,77 @@
+import type { ResolvedTwoslashCacheOptions, TwoslashCacheStats } from "./types.ts";
+
+function createInitialStats(): TwoslashCacheStats {
+ return {
+ hits: 0,
+ misses: 0,
+ writes: 0,
+ readErrors: 0,
+ writeErrors: 0,
+ };
+}
+
+export function createTwoslashCacheStats(options: ResolvedTwoslashCacheOptions) {
+ const stats = createInitialStats();
+ let didRegisterSummary = false;
+
+ function debug(message: string) {
+ if (options.logLevel === "debug") {
+ console.info(`[twoslash-cache] ${message}`);
+ }
+ }
+
+ function registerSummary() {
+ if (didRegisterSummary || options.logLevel === "off") {
+ return;
+ }
+
+ didRegisterSummary = true;
+
+ process.once("exit", () => {
+ const total = stats.hits + stats.misses;
+ if (total === 0 || options.logLevel === "off") {
+ return;
+ }
+
+ const hitRate = ((stats.hits / total) * 100).toFixed(1);
+ console.info(
+ `[twoslash-cache] hits=${stats.hits} misses=${stats.misses} writes=${stats.writes} readErrors=${stats.readErrors} writeErrors=${stats.writeErrors} hitRate=${hitRate}% dir=${options.dir}`,
+ );
+ });
+ }
+
+ function recordHit(key: string) {
+ stats.hits += 1;
+ debug(`hit key=${key}`);
+ }
+
+ function recordMiss(key: string) {
+ stats.misses += 1;
+ debug(`miss key=${key}`);
+ }
+
+ function recordWrite(key: string) {
+ stats.writes += 1;
+ debug(`write key=${key}`);
+ }
+
+ function recordReadError(key: string, error: unknown) {
+ stats.readErrors += 1;
+ debug(`read-error key=${key} error=${String(error)}`);
+ }
+
+ function recordWriteError(key: string, error: unknown) {
+ stats.writeErrors += 1;
+ debug(`write-error key=${key} error=${String(error)}`);
+ }
+
+ return {
+ stats,
+ registerSummary,
+ recordHit,
+ recordMiss,
+ recordWrite,
+ recordReadError,
+ recordWriteError,
+ };
+}
diff --git a/packages/expressive-code-twoslash/src/cache/store.ts b/packages/expressive-code-twoslash/src/cache/store.ts
new file mode 100644
index 00000000..7c07e61a
--- /dev/null
+++ b/packages/expressive-code-twoslash/src/cache/store.ts
@@ -0,0 +1,52 @@
+import { randomUUID } from "node:crypto";
+import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
+import path from "node:path";
+
+export type CacheReadResult =
+ | { status: "hit"; value: T }
+ | { status: "miss" }
+ | { status: "error"; error: unknown };
+
+function getCacheFilePath(dir: string, key: string) {
+ return path.join(dir, key.slice(0, 2), `${key}.json`);
+}
+
+export async function readCacheEntry(dir: string, key: string) {
+ const filePath = getCacheFilePath(dir, key);
+
+ try {
+ const serialized = await readFile(filePath, "utf8");
+ return {
+ status: "hit",
+ value: JSON.parse(serialized),
+ } satisfies CacheReadResult;
+ } catch (error) {
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
+ return {
+ status: "miss",
+ } satisfies CacheReadResult;
+ }
+
+ return {
+ status: "error",
+ error,
+ } satisfies CacheReadResult;
+ }
+}
+
+export async function writeCacheEntry(dir: string, key: string, value: unknown) {
+ const filePath = getCacheFilePath(dir, key);
+ const cacheDir = path.dirname(filePath);
+ const tempFilePath = path.join(cacheDir, `${key}.${randomUUID()}.tmp`);
+
+ await mkdir(cacheDir, { recursive: true });
+ await writeFile(tempFilePath, JSON.stringify(value));
+
+ try {
+ await rename(tempFilePath, filePath);
+ } catch (error) {
+ await rm(tempFilePath, { force: true });
+ throw error;
+ }
+ return filePath;
+}
diff --git a/packages/expressive-code-twoslash/src/cache/types.ts b/packages/expressive-code-twoslash/src/cache/types.ts
new file mode 100644
index 00000000..3da4ed1a
--- /dev/null
+++ b/packages/expressive-code-twoslash/src/cache/types.ts
@@ -0,0 +1,53 @@
+export type TwoslashCacheLogLevel = "off" | "summary" | "debug";
+
+export interface TwoslashCacheOptions {
+ /**
+ * Directory used to persist cached Twoslash JSON artifacts.
+ *
+ * Relative paths resolve from the plugin `cwd`.
+ *
+ * @default ".cache/expressive-code-twoslash"
+ */
+ dir?: string;
+
+ /**
+ * Additional caller-controlled fingerprint to invalidate all cache entries.
+ */
+ fingerprint?: string;
+
+ /**
+ * Cache logging verbosity.
+ *
+ * @default "summary"
+ */
+ logLevel?: TwoslashCacheLogLevel;
+}
+
+export interface ResolvedTwoslashCacheOptions {
+ dir: string;
+ fingerprint?: string;
+ logLevel: TwoslashCacheLogLevel;
+}
+
+export interface TwoslashCacheEnvironment {
+ expressiveCodeTwoslashVersion?: string;
+ twoslashVersion?: string;
+ typescriptVersion: string;
+}
+
+export interface TwoslashCacheKeyInput {
+ code: string;
+ extension: string;
+ createOptions?: unknown;
+ executeOptions?: unknown;
+ pluginContext?: unknown;
+ fingerprint?: string;
+}
+
+export interface TwoslashCacheStats {
+ hits: number;
+ misses: number;
+ writes: number;
+ readErrors: number;
+ writeErrors: number;
+}
diff --git a/packages/expressive-code-twoslash/src/index.ts b/packages/expressive-code-twoslash/src/index.ts
index 4fab3912..c0fc469f 100644
--- a/packages/expressive-code-twoslash/src/index.ts
+++ b/packages/expressive-code-twoslash/src/index.ts
@@ -20,6 +20,11 @@ import {
TwoslashHoverAnnotation,
TwoslashStaticAnnotation,
} from "./annotations/index.ts";
+import {
+ createTwoslashCache,
+ getTwoslashCacheEnvironment,
+ resolveTwoslashCacheOptions,
+} from "./cache/index.ts";
import { instanceConfigsDefaults, twoslashEslintDefaults } from "./consts.ts";
import {
checkForCustomTagsAndMerge,
@@ -40,9 +45,13 @@ import floatingUiCore from "./module-code/floating-ui-core.min.ts";
import floatingUiDom from "./module-code/floating-ui-dom.min.ts";
import hoverDocsManager from "./module-code/popup.min.ts";
import { getTwoSlashBaseStyles, twoSlashStyleSettings } from "./styles.ts";
-import type { PluginTwoslashOptions, TwoSlashStyleSettings } from "./types.ts";
+import type {
+ PluginTwoslashOptions,
+ TwoSlashStyleSettings,
+ TwoslashCacheOptions,
+} from "./types.ts";
-export type { PluginTwoslashOptions, TwoSlashStyleSettings };
+export type { PluginTwoslashOptions, TwoSlashStyleSettings, TwoslashCacheOptions };
declare module "@expressive-code/core" {
export interface StyleSettings {
@@ -105,8 +114,17 @@ export default function ecTwoSlash(options: PluginTwoslashOptions = {}): Express
...twoslashEslintDefaults,
...options.twoslashEslintOptions,
},
+ cache,
} = options;
+ const resolvedTsConfigPath = resolveTsconfigPath(cwd, tsConfigPath);
+ const { source: tsConfigSource, options: baseCompilerOptions } =
+ parseSnippetTsconfig(resolvedTsConfigPath);
+ const twoslashCache = createTwoslashCache(
+ resolveTwoslashCacheOptions(cwd, cache),
+ getTwoslashCacheEnvironment(),
+ );
+
// Get the Twoslash transformation function based on the provided instance configurations and options
const shouldTransform = getTwoslasher(
instanceConfigs,
@@ -119,12 +137,6 @@ export default function ecTwoSlash(options: PluginTwoslashOptions = {}): Express
);
if (!tsLibDirectory) {
- // Get the TSConfig path for getting the default library files for Twoslash
- const _TsConfigPath = resolveTsconfigPath(cwd, tsConfigPath);
-
- // Get the default compiler options from the parsed TSConfig, which includes the default library files for Twoslash
- const { options: baseCompilerOptions } = parseSnippetTsconfig(_TsConfigPath);
-
// Get the directory of the default library files for Twoslash, which is needed for proper module resolution in Twoslash
tsLibDirectory = path.dirname(ts.getDefaultLibFilePath(baseCompilerOptions));
}
@@ -166,15 +178,37 @@ export default function ecTwoSlash(options: PluginTwoslashOptions = {}): Express
const extension =
trigger === "eslint" ? `index.${codeBlock.language}` : codeBlock.language;
- // Twoslash the code block
- const twoslash = twoslasher(codeWithIncludes, extension, {
+ const twoslashExecutionOptions = {
tsLibDirectory,
...twoslashOptions,
compilerOptions: {
...defaultCompilerOptions,
...(twoslashOptions?.compilerOptions ?? {}),
},
- });
+ };
+
+ // Twoslash the code block
+ const twoslash = twoslashCache
+ ? await twoslashCache.getOrCompute(
+ {
+ code: codeWithIncludes,
+ createOptions: {
+ trigger,
+ instanceConfigs,
+ twoslashOptions,
+ twoslashVueOptions,
+ twoslashEslintOptions,
+ },
+ extension,
+ executeOptions: twoslashExecutionOptions,
+ pluginContext: {
+ resolvedTsConfigPath,
+ tsConfigSource,
+ },
+ },
+ async () => twoslasher(codeWithIncludes, extension, twoslashExecutionOptions),
+ )
+ : twoslasher(codeWithIncludes, extension, twoslashExecutionOptions);
// Update EC code block with the twoslash information this is important to ensure that if the end user
// is using the @showEmit functionality, the emitted code is properly displayed in the code block.
diff --git a/packages/expressive-code-twoslash/src/types.ts b/packages/expressive-code-twoslash/src/types.ts
index 572e5272..67fbeb01 100644
--- a/packages/expressive-code-twoslash/src/types.ts
+++ b/packages/expressive-code-twoslash/src/types.ts
@@ -2,9 +2,12 @@ import type { TwoslashOptions } from "@ec-ts/twoslash";
import type { CreateTwoslashVueOptions } from "@ec-ts/twoslash-vue";
import type { Element } from "@expressive-code/core/hast";
import type { CreateTwoslashESLintOptions } from "twoslash-eslint";
+import type { TwoslashCacheOptions } from "./cache/types.ts";
import type { completionIcons } from "./icons/completionIcons.ts";
import type { customTagsIcons } from "./icons/customTagsIcons.ts";
+export type { TwoslashCacheOptions };
+
/**
* Type representing the options for creating a Twoslash instance with Vue support, excluding the standard Twoslash options.
* This type is derived by omitting the keys of `TwoslashOptions` from `CreateTwoslashVueOptions`.
@@ -131,6 +134,13 @@ export interface PluginTwoslashOptions {
* @remarks This is only used if the `eslint` language is included in the `languages` option and will be ignored otherwise.
*/
readonly twoslashEslintOptions?: CreateTwoslashESLintOptions;
+
+ /**
+ * Enables a persistent disk cache for Twoslash JSON results.
+ *
+ * Disabled by default.
+ */
+ readonly cache?: boolean | TwoslashCacheOptions;
}
/**
diff --git a/packages/expressive-code-twoslash/test/cache.test.ts b/packages/expressive-code-twoslash/test/cache.test.ts
new file mode 100644
index 00000000..5067fe52
--- /dev/null
+++ b/packages/expressive-code-twoslash/test/cache.test.ts
@@ -0,0 +1,148 @@
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createTwoslashCache, resolveTwoslashCacheOptions } from "../src/cache/index.ts";
+import { createTwoslashCacheKey } from "../src/cache/key.ts";
+import { readCacheEntry, writeCacheEntry } from "../src/cache/store.ts";
+
+const tempDirs: string[] = [];
+
+async function createTempDir() {
+ const dir = await mkdtemp(path.join(os.tmpdir(), "ec-twoslash-cache-"));
+ tempDirs.push(dir);
+ return dir;
+}
+
+afterEach(async () => {
+ await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true })));
+ vi.restoreAllMocks();
+});
+
+describe("twoslash cache", () => {
+ it("creates stable keys for equivalent inputs", () => {
+ const environment = {
+ expressiveCodeTwoslashVersion: "0.6.1",
+ twoslashVersion: "1.0.0",
+ typescriptVersion: "5.9.3",
+ };
+
+ const left = createTwoslashCacheKey(environment, {
+ code: "const value = 1",
+ extension: "ts",
+ executeOptions: {
+ compilerOptions: {
+ strict: true,
+ lib: ["lib.es2022.d.ts"],
+ },
+ },
+ pluginContext: {
+ tsConfigSource: JSON.stringify({ compilerOptions: { strict: true } }),
+ },
+ });
+
+ const right = createTwoslashCacheKey(environment, {
+ code: "const value = 1",
+ extension: "ts",
+ executeOptions: {
+ compilerOptions: {
+ lib: ["lib.es2022.d.ts"],
+ strict: true,
+ },
+ },
+ pluginContext: {
+ tsConfigSource: JSON.stringify({ compilerOptions: { strict: true } }),
+ },
+ });
+
+ expect(left).toBe(right);
+ });
+
+ it("invalidates when the fingerprint changes", () => {
+ const environment = {
+ expressiveCodeTwoslashVersion: "0.6.1",
+ twoslashVersion: "1.0.0",
+ typescriptVersion: "5.9.3",
+ };
+
+ const left = createTwoslashCacheKey(environment, {
+ code: "const value = 1",
+ extension: "ts",
+ fingerprint: "alpha",
+ });
+
+ const right = createTwoslashCacheKey(environment, {
+ code: "const value = 1",
+ extension: "ts",
+ fingerprint: "beta",
+ });
+
+ expect(left).not.toBe(right);
+ });
+
+ it("roundtrips cached json entries", async () => {
+ const dir = await createTempDir();
+
+ await writeCacheEntry(dir, "abcdef", { code: "const value = 1" });
+
+ const result = await readCacheEntry<{ code: string }>(dir, "abcdef");
+ expect(result).toEqual({
+ status: "hit",
+ value: { code: "const value = 1" },
+ });
+ });
+
+ it("treats corrupt entries as cache errors", async () => {
+ const dir = await createTempDir();
+ const filePath = path.join(dir, "ab", "abcdef.json");
+
+ await mkdir(path.dirname(filePath), { recursive: true });
+ await writeFile(filePath, "{bad json", { encoding: "utf8" });
+
+ const result = await readCacheEntry(dir, "abcdef");
+ expect(result.status).toBe("error");
+ });
+
+ it("hits the cache on a warm run", async () => {
+ const cwd = await createTempDir();
+ const cache = createTwoslashCache(
+ resolveTwoslashCacheOptions(cwd, {
+ dir: ".cache/twoslash",
+ logLevel: "off",
+ }),
+ {
+ expressiveCodeTwoslashVersion: "0.6.1",
+ twoslashVersion: "1.0.0",
+ typescriptVersion: "5.9.3",
+ },
+ );
+
+ if (!cache) {
+ throw new Error("Cache should be enabled");
+ }
+
+ const compute = vi.fn(async () => ({ code: "const greeting = 'hi'" }));
+
+ const cold = await cache.getOrCompute(
+ {
+ code: "const greeting = 'hi'",
+ extension: "ts",
+ executeOptions: { compilerOptions: { strict: true } },
+ },
+ compute,
+ );
+
+ const warm = await cache.getOrCompute(
+ {
+ code: "const greeting = 'hi'",
+ extension: "ts",
+ executeOptions: { compilerOptions: { strict: true } },
+ },
+ compute,
+ );
+
+ expect(cold).toEqual({ code: "const greeting = 'hi'" });
+ expect(warm).toEqual(cold);
+ expect(compute).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/expressive-code-twoslash/vitest.config.ts b/packages/expressive-code-twoslash/vitest.config.ts
new file mode 100644
index 00000000..b9849d3f
--- /dev/null
+++ b/packages/expressive-code-twoslash/vitest.config.ts
@@ -0,0 +1,12 @@
+import { defineProject, mergeConfig } from "vitest/config";
+import { configShared } from "../../vitest.shared.ts";
+
+export default mergeConfig(
+ configShared,
+ defineProject({
+ test: {
+ name: "expressive-code-twoslash",
+ include: ["**/*.test.ts"],
+ },
+ }),
+);
diff --git a/vitest.config.ts b/vitest.config.ts
index 34c2672d..a113510f 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -29,7 +29,7 @@ import { defineConfig } from "vitest/config";
*/
const projectsWithTests: { scope?: string; names: string[] }[] = [
{
- names: ["css-js-gen"],
+ names: ["expressive-code-twoslash", "css-js-gen"],
},
{
scope: "ec-ts",