Skip to content

Commit c4cb18b

Browse files
ap0niaantfu
andauthored
feat(twoslash): support @includes (#737)
Co-authored-by: Anthony Fu <[email protected]>
1 parent d5cf4a6 commit c4cb18b

File tree

6 files changed

+253
-1
lines changed

6 files changed

+253
-1
lines changed

packages/twoslash/src/core.ts

+13-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { Element, ElementContent, Text } from 'hast'
99
import { splitTokens } from '@shikijs/core'
1010
import type { TransformerTwoslashOptions, TwoslashRenderer, TwoslashShikiFunction, TwoslashShikiReturn } from './types'
1111
import { ShikiTwoslashError } from './error'
12+
import { TwoslashIncludesManager, parseIncludeMeta } from './includes'
1213

1314
export * from './types'
1415
export * from './renderer-rich'
@@ -39,6 +40,7 @@ export function createTransformerFactory(
3940
explicitTrigger = false,
4041
renderer = defaultRenderer,
4142
throws = true,
43+
includesMap = new Map(),
4244
} = options
4345

4446
const onTwoslashError = options.onTwoslashError || (
@@ -66,6 +68,9 @@ export function createTransformerFactory(
6668
const map = new WeakMap<ShikiTransformerContextMeta, TwoslashShikiReturn>()
6769

6870
const filter = options.filter || ((lang, _, options) => langs.includes(lang) && (!explicitTrigger || trigger.test(options.meta?.__raw || '')))
71+
72+
const includes = new TwoslashIncludesManager(includesMap)
73+
6974
return {
7075
preprocess(code) {
7176
let lang = this.options.lang
@@ -74,7 +79,14 @@ export function createTransformerFactory(
7479

7580
if (filter(lang, code, this.options)) {
7681
try {
77-
const twoslash = (twoslasher as TwoslashShikiFunction)(code, lang, twoslashOptions)
82+
const include = parseIncludeMeta(this.options.meta?.__raw)
83+
84+
if (include)
85+
includes.add(include, code)
86+
87+
const codeWithIncludes = includes.applyInclude(code)
88+
89+
const twoslash = (twoslasher as TwoslashShikiFunction)(codeWithIncludes, lang, twoslashOptions)
7890
map.set(this.meta, twoslash)
7991
this.meta.twoslash = twoslash
8092
this.options.lang = twoslash.meta?.extension || lang

packages/twoslash/src/includes.ts

+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
export class TwoslashIncludesManager {
2+
constructor(
3+
public map: Map<string, string> = new Map(),
4+
) {}
5+
6+
add(name: string, code: string) {
7+
const lines: string[] = []
8+
9+
code.split('\n').forEach((l, _i) => {
10+
const trimmed = l.trim()
11+
12+
if (trimmed.startsWith('// - ')) {
13+
const key = trimmed.split('// - ')[1].split(' ')[0]
14+
this.map.set(`${name}-${key}`, lines.join('\n'))
15+
}
16+
else {
17+
lines.push(l)
18+
}
19+
})
20+
this.map.set(name, lines.join('\n'))
21+
}
22+
23+
applyInclude(code: string) {
24+
const reMarker = /\/\/ @include: (.*)$/gm
25+
26+
// Basically run a regex over the code replacing any // @include: thing with
27+
// 'thing' from the map
28+
29+
// const toReplace: [index:number, length: number, str: string][] = []
30+
const toReplace: [number, number, string][] = []
31+
32+
for (const match of code.matchAll(reMarker)) {
33+
const key = match[1]
34+
const replaceWith = this.map.get(key)
35+
36+
if (!replaceWith) {
37+
const msg = `Could not find an include with the key: '${key}'.\nThere is: ${Array.from(this.map.keys())}.`
38+
throw new Error(msg)
39+
}
40+
else {
41+
toReplace.push([match.index, match[0].length, replaceWith])
42+
}
43+
}
44+
45+
let newCode = code.toString()
46+
// Go backwards through the found changes so that we can retain index position
47+
toReplace
48+
.reverse()
49+
.forEach((r) => {
50+
newCode = newCode.slice(0, r[0]) + r[2] + newCode.slice(r[0] + r[1])
51+
})
52+
return newCode
53+
}
54+
}
55+
56+
/**
57+
* An "include [name]" segment in a raw meta string is a sequence of words,
58+
* possibly connected by dashes, following "include " and ending at a word boundary.
59+
*/
60+
const INCLUDE_META_REGEX = /include\s+([\w-]+)\b.*/
61+
62+
/**
63+
* Given a raw meta string for code block like 'twoslash include main-hello-world meta=miscellaneous',
64+
* capture the name of the reusable code block as "main-hello-world", and ignore anything
65+
* before and after this segment.
66+
*/
67+
export function parseIncludeMeta(meta?: string): string | null {
68+
if (!meta)
69+
return null
70+
71+
const match = meta.match(INCLUDE_META_REGEX)
72+
return match?.[1] ?? null
73+
}

packages/twoslash/src/types.ts

+5
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ export interface TransformerTwoslashOptions {
4848
* Custom renderers to decide how each info should be rendered
4949
*/
5050
renderer?: TwoslashRenderer
51+
/**
52+
* A map to store code for `@include` directive
53+
* Provide your own instance if you want to clear the map between each transformation
54+
*/
55+
includesMap?: Map<string, string>
5156
/**
5257
* Strictly throw when there is an error
5358
* @default true
+117
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { expect, it } from 'vitest'
2+
import { codeToHtml } from 'shiki'
3+
import { TwoslashIncludesManager } from '../src/includes'
4+
import { rendererRich, transformerTwoslash } from '../src'
5+
6+
const styleTag = `
7+
<link rel="stylesheet" href="../../../style-rich.css" />
8+
<style>
9+
.dark .shiki,
10+
.dark .shiki span {
11+
color: var(--shiki-dark, inherit);
12+
background-color: var(--shiki-dark-bg, inherit);
13+
--twoslash-popup-bg: var(--shiki-dark-bg, inherit);
14+
}
15+
16+
html:not(.dark) .shiki,
17+
html:not(.dark) .shiki span {
18+
color: var(--shiki-light, inherit);
19+
background-color: var(--shiki-light-bg, inherit);
20+
--twoslash-popup-bg: var(--shiki-light-bg, inherit);
21+
}
22+
</style>
23+
`
24+
25+
const multiExample = `
26+
const a = 1
27+
// - 1
28+
const b = 2
29+
// - 2
30+
const c = 3
31+
`
32+
33+
it('creates a set of examples', () => {
34+
const manager = new TwoslashIncludesManager()
35+
manager.add('main', multiExample)
36+
expect(manager.map.size === 3)
37+
38+
expect(manager.map.get('main')).toContain('const c')
39+
expect(manager.map.get('main-1')).toContain('const a = 1')
40+
expect(manager.map.get('main-2')).toContain('const b = 2')
41+
})
42+
43+
it('replaces the code', () => {
44+
const manager = new TwoslashIncludesManager()
45+
manager.add('main', multiExample)
46+
expect(manager.map.size === 3)
47+
48+
const sample = `// @include: main`
49+
const replaced = manager.applyInclude(sample)
50+
expect(replaced).toMatchInlineSnapshot(`
51+
"
52+
const a = 1
53+
const b = 2
54+
const c = 3
55+
"
56+
`)
57+
})
58+
59+
it('throws an error if key not found', () => {
60+
const manager = new TwoslashIncludesManager()
61+
62+
const sample = `// @include: main`
63+
expect(() => manager.applyInclude(sample)).toThrow()
64+
})
65+
66+
it('replaces @include directives with previously transformed code blocks', async () => {
67+
const main = `
68+
export const hello = { str: "world" };
69+
`.trim()
70+
71+
/**
72+
* The @noErrors directive allows the code above the ^| to be invalid,
73+
* i.e. so it can demonstrate what a partial autocomplete looks like.
74+
*/
75+
const code = `
76+
// @include: main
77+
// @noErrors
78+
79+
hello.
80+
// ^|
81+
`.trim()
82+
83+
/**
84+
* Replacing @include directives only renders nicely rendererRich?
85+
*/
86+
const transformer = transformerTwoslash({
87+
renderer: rendererRich(),
88+
})
89+
90+
const htmlMain = await codeToHtml(main, {
91+
lang: 'ts',
92+
themes: {
93+
dark: 'vitesse-dark',
94+
light: 'vitesse-light',
95+
},
96+
defaultColor: false,
97+
transformers: [transformer],
98+
meta: {
99+
__raw: 'include main',
100+
},
101+
})
102+
103+
expect(styleTag + htmlMain).toMatchFileSnapshot('./out/includes/main.html')
104+
105+
const html = await codeToHtml(code, {
106+
lang: 'ts',
107+
themes: {
108+
dark: 'vitesse-dark',
109+
light: 'vitesse-light',
110+
},
111+
transformers: [transformer],
112+
})
113+
114+
expect(styleTag + html).toMatchFileSnapshot(
115+
'./out/includes/replaced_directives.html',
116+
)
117+
})

packages/twoslash/test/out/includes/main.html

+20
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/twoslash/test/out/includes/replaced_directives.html

+25
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)