Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions packages/twoslash/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { createTransformerFactory, rendererRich } from './core'

export * from './core'

export interface TransformerTwoslashIndexOptions extends TransformerTwoslashOptions, Pick<CreateTwoslashOptions, 'cache'> {
export interface TransformerTwoslashIndexOptions extends TransformerTwoslashOptions, Pick<CreateTwoslashOptions, 'cache' | 'tsModule'> {
/**
* Options for the default rich renderer.
*
Expand All @@ -20,13 +20,25 @@ export interface TransformerTwoslashIndexOptions extends TransformerTwoslashOpti
* Factory function to create a Shiki transformer for twoslash integrations.
*/
export function transformerTwoslash(options: TransformerTwoslashIndexOptions = {}): ShikiTransformer {
const twoslashOptions: CreateTwoslashOptions = {
cache: options.cache,
compilerOptions: {
moduleResolution: 100 satisfies ModuleResolutionKind.Bundler,
},
}

// tsModule is a create-time option that must reach createTwoslasher directly.
// It can be set at the top level or inside twoslashOptions; top level takes precedence.
const tsModule = options.tsModule || options.twoslashOptions?.tsModule

// Only include when defined, passing `undefined` explicitly overrides the default
// TypeScript module that twoslash sets internally, causing an immediate crash.
if (tsModule) {
twoslashOptions.tsModule = tsModule
}

return createTransformerFactory(
createTwoslasher({
cache: options?.cache,
compilerOptions: {
moduleResolution: 100 satisfies ModuleResolutionKind.Bundler,
},
}),
createTwoslasher(twoslashOptions),
rendererRich(options.rendererRich),
)(options)
}
68 changes: 68 additions & 0 deletions packages/twoslash/test/ts-module.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { codeToHtml } from 'shiki'
import ts from 'typescript'
import { describe, expect, it, vi } from 'vitest'
import { transformerTwoslash } from '../src'

// Wraps `ts` with a spy on `createLanguageService` so we can assert it was called
// by the virtual TypeScript environment that twoslash creates internally.
function spiedTsModule() {
const createLanguageService = vi.fn((...args: Parameters<typeof ts.createLanguageService>) =>
ts.createLanguageService(...args),
)
const tsModule = new Proxy(ts, {
get(target, prop, receiver) {
if (prop === 'createLanguageService')
return createLanguageService
return Reflect.get(target, prop, receiver)
},
}) as typeof ts
return { tsModule, createLanguageService }
}

describe('transformerTwoslash: tsModule forwarding', () => {
const sample = 'const a: number = 1'

it('uses tsModule from top-level options', async () => {
const { tsModule, createLanguageService } = spiedTsModule()

await codeToHtml(sample, {
lang: 'ts',
theme: 'vitesse-dark',
transformers: [transformerTwoslash({ cache: false, tsModule })],
})

expect(createLanguageService).toHaveBeenCalled()
})

it('uses tsModule from twoslashOptions as fallback', async () => {
const { tsModule, createLanguageService } = spiedTsModule()

await codeToHtml(sample, {
lang: 'ts',
theme: 'vitesse-dark',
transformers: [transformerTwoslash({ cache: false, twoslashOptions: { tsModule } })],
})

expect(createLanguageService).toHaveBeenCalled()
})

it('top-level tsModule takes precedence over twoslashOptions.tsModule', async () => {
const topLevel = spiedTsModule()
const nested = spiedTsModule()

await codeToHtml(sample, {
lang: 'ts',
theme: 'vitesse-dark',
transformers: [
transformerTwoslash({
cache: false,
tsModule: topLevel.tsModule,
twoslashOptions: { tsModule: nested.tsModule },
}),
],
})

expect(topLevel.createLanguageService).toHaveBeenCalled()
expect(nested.createLanguageService).not.toHaveBeenCalled()
})
})
Loading