From 67ca55294b96bf7e8228e0c1e1f5184bc15f050c Mon Sep 17 00:00:00 2001 From: Maxwell Brown Date: Mon, 30 Mar 2026 14:25:53 -0400 Subject: [PATCH] feat: add opt-in twoslash cache --- .changeset/caching-docs.md | 5 + docs/astro.config.mts | 8 + .../docs/getting-started/installation.mdx | 4 +- .../content/docs/reference/configuration.mdx | 59 ++++++- .../content/docs/usage/build-time-cache.mdx | 100 ++++++++++++ packages/expressive-code-twoslash/README.md | 54 +++++-- .../src/cache/index.ts | 125 +++++++++++++++ .../expressive-code-twoslash/src/cache/key.ts | 75 +++++++++ .../src/cache/stats.ts | 77 +++++++++ .../src/cache/store.ts | 52 ++++++ .../src/cache/types.ts | 53 +++++++ .../expressive-code-twoslash/src/index.ts | 56 +++++-- .../expressive-code-twoslash/src/types.ts | 10 ++ .../test/cache.test.ts | 148 ++++++++++++++++++ .../expressive-code-twoslash/vitest.config.ts | 12 ++ vitest.config.ts | 2 +- 16 files changed, 809 insertions(+), 31 deletions(-) create mode 100644 .changeset/caching-docs.md create mode 100644 docs/src/content/docs/usage/build-time-cache.mdx create mode 100644 packages/expressive-code-twoslash/src/cache/index.ts create mode 100644 packages/expressive-code-twoslash/src/cache/key.ts create mode 100644 packages/expressive-code-twoslash/src/cache/stats.ts create mode 100644 packages/expressive-code-twoslash/src/cache/store.ts create mode 100644 packages/expressive-code-twoslash/src/cache/types.ts create mode 100644 packages/expressive-code-twoslash/test/cache.test.ts create mode 100644 packages/expressive-code-twoslash/vitest.config.ts 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",