Skip to content

Commit 3fbd016

Browse files
committed
feat(twoslash): support custom renderer
1 parent fc857f0 commit 3fbd016

16 files changed

+536
-50
lines changed

packages/shikiji-twoslash/README.md

+32
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,38 @@ const html = await codeToHtml(code, {
3131

3232
Same as `shiki-twoslash`, the output is unstyled. You need to add some extra CSS to make them look good.
3333

34+
## Renderers
35+
36+
Thanks to the flexibility of [`hast`](https://github.com/syntax-tree/hast), this transformer allows customizing how each piece of information is rendered in the output HTML with ASTs.
37+
38+
We provide two renderers built-in, while you can also create your own:
39+
40+
### `rendererClassic`
41+
42+
[Source code](./src/renderer-classic.ts)
43+
44+
This is the default renderer that aligns with the output of [`shiki-twoslash`](https://shikijs.github.io/twoslash/).
45+
46+
You might need to reference `shiki-twoslash`'s CSS to make them look good. [Here](./style-shiki-twoslash.css) we also copied the CSS from `shiki-twoslash` but it might need some cleanup.
47+
48+
### `rendererRich`
49+
50+
[Source code](./src/renderer-rich.ts)
51+
52+
This renderer provides a more explicit class name that is always prefixed with `twoslash-` for better scoping. In addition, it runs syntax highlighting on the hover information as well.
53+
54+
```ts
55+
import { rendererRich, transformerTwoSlash } from 'shikiji-twoslash'
56+
57+
transformerTwoSlash({
58+
renderer: rendererRich // <--
59+
})
60+
```
61+
62+
Here is an example with the [`style-rich.css`](./style-rich.css):
63+
64+
![](https://github.com/antfu/shikiji/assets/11247099/41f75799-e652-4331-ab75-39dbe8772c81)
65+
3466
## Integrations
3567

3668
### VitePress

packages/shikiji-twoslash/package.json

+3
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
"types": "./dist/index.d.mts",
2323
"default": "./dist/index.mjs"
2424
},
25+
"./style-rich.css": "./style-rich.css",
26+
"./style-shiki-twoslash.css": "./style-shiki-twoslash.css",
2527
"./*": "./dist/*"
2628
},
2729
"main": "./dist/index.mjs",
@@ -36,6 +38,7 @@
3638
}
3739
},
3840
"files": [
41+
"*.css",
3942
"dist"
4043
],
4144
"scripts": {

packages/shikiji-twoslash/src/index.ts

+41-34
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { TransformerTwoSlashOptions } from './types'
77

88
export * from './types'
99
export * from './renderer-classic'
10+
export * from './renderer-rich'
1011

1112
export function transformerTwoSlash(options: TransformerTwoSlashOptions = {}): ShikijiTransformer {
1213
const {
@@ -20,6 +21,7 @@ export function transformerTwoSlash(options: TransformerTwoSlashOptions = {}): S
2021
yml: 'yaml',
2122
},
2223
renderer = rendererClassic,
24+
throws = true,
2325
} = options
2426
const filter = options.filter || (lang => langs.includes(lang))
2527
return {
@@ -52,72 +54,77 @@ export function transformerTwoSlash(options: TransformerTwoSlashOptions = {}): S
5254
else {
5355
const lineEl = this.lines[line]
5456
index = codeEl.children.indexOf(lineEl)
55-
if (index === -1)
56-
return false
57+
if (index === -1) {
58+
if (throws)
59+
throw new Error(`[shikiji-twoslash] Cannot find line ${line} in code element`)
60+
return
61+
}
5762
}
5863

5964
// If there is a newline after this line, remove it because we have the error element take place.
6065
const nodeAfter = codeEl.children[index + 1]
6166
if (nodeAfter && nodeAfter.type === 'text' && nodeAfter.value === '\n')
6267
codeEl.children.splice(index + 1, 1)
6368
codeEl.children.splice(index + 1, 0, ...nodes)
64-
return true
6569
}
6670

67-
for (const info of twoslash.staticQuickInfos) {
68-
const token = locateTextToken(this, info.line, info.character)
69-
if (!token || token.type !== 'text')
70-
continue
71+
const locateTextToken = (
72+
line: number,
73+
character: number,
74+
) => {
75+
const lineEl = this.lines[line]
76+
if (!lineEl) {
77+
if (throws)
78+
throw new Error(`[shikiji-twoslash] Cannot find line ${line} in code element`)
79+
}
80+
const textNodes = lineEl.children.flatMap(i => i.type === 'element' ? i.children || [] : []) as (Text | Element)[]
81+
let index = 0
82+
for (const token of textNodes) {
83+
if ('value' in token && typeof token.value === 'string')
84+
index += token.value.length
7185

72-
const clone = { ...token }
73-
Object.assign(token, renderer.nodeStaticInfo(info, clone))
86+
if (index > character)
87+
return token
88+
}
89+
if (throws)
90+
throw new Error(`[shikiji-twoslash] Cannot find token at L${line}:${character}`)
7491
}
7592

7693
for (const error of twoslash.errors) {
7794
if (error.line == null || error.character == null)
7895
return
79-
const token = locateTextToken(this, error.line, error.character)
96+
const token = locateTextToken(error.line, error.character)
8097
if (!token)
8198
continue
8299

83100
const clone = { ...token }
84-
Object.assign(token, renderer.nodeError(error, clone))
101+
Object.assign(token, renderer.nodeError.call(this, error, clone))
85102

86-
insertAfterLine(error.line, renderer.lineError(error))
103+
insertAfterLine(error.line, renderer.lineError.call(this, error))
104+
}
105+
106+
for (const info of twoslash.staticQuickInfos) {
107+
const token = locateTextToken(info.line, info.character)
108+
if (!token || token.type !== 'text')
109+
continue
110+
111+
const clone = { ...token }
112+
Object.assign(token, renderer.nodeStaticInfo.call(this, info, clone))
87113
}
88114

89115
for (const query of twoslash.queries) {
90116
insertAfterLine(
91117
query.line,
92118
query.kind === 'completions'
93-
? renderer.lineCompletions(query)
119+
? renderer.lineCompletions.call(this, query)
94120
: query.kind === 'query'
95-
? renderer.lineQuery(query, locateTextToken(this, query.line, query.offset))
121+
? renderer.lineQuery.call(this, query, locateTextToken(query.line - 1, query.offset))
96122
: [],
97123
)
98124
}
99125

100126
for (const tag of twoslash.tags)
101-
insertAfterLine(tag.line, renderer.lineCustomTag(tag))
127+
insertAfterLine(tag.line, renderer.lineCustomTag.call(this, tag))
102128
},
103129
}
104130
}
105-
106-
function locateTextToken(
107-
context: ShikijiTransformerContext,
108-
line: number,
109-
character: number,
110-
) {
111-
const lineEl = context.lines[line]
112-
if (!lineEl)
113-
return
114-
const textNodes = lineEl.children.flatMap(i => i.type === 'element' ? i.children || [] : []) as (Text | Element)[]
115-
let index = 0
116-
for (const token of textNodes) {
117-
if ('value' in token && typeof token.value === 'string')
118-
index += token.value.length
119-
120-
if (index > character)
121-
return token
122-
}
123-
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import type { Element, ElementContent } from 'hast'
2+
import type { TwoSlashRenderers } from './types'
3+
4+
/**
5+
* An alternative renderer that providers better prefixed class names,
6+
* with syntax highlight for the info text.
7+
*/
8+
export const rendererRich: TwoSlashRenderers = {
9+
nodeStaticInfo(info, node) {
10+
let themed: ElementContent
11+
12+
try {
13+
themed = (this.codeToHast(info.text, {
14+
...this.options,
15+
transformers: [],
16+
transforms: undefined,
17+
}).children[0] as Element).children[0]
18+
}
19+
catch (e) {
20+
themed = {
21+
type: 'text',
22+
value: info.text,
23+
}
24+
}
25+
26+
return {
27+
type: 'element',
28+
tagName: 'span',
29+
properties: {
30+
class: 'twoslash-hover',
31+
},
32+
children: [
33+
node,
34+
{
35+
type: 'element',
36+
tagName: 'span',
37+
properties: {
38+
class: 'twoslash-hover-info',
39+
},
40+
children: [
41+
themed,
42+
],
43+
},
44+
],
45+
}
46+
},
47+
48+
nodeError(_, node) {
49+
return {
50+
type: 'element',
51+
tagName: 'span',
52+
properties: {
53+
class: 'twoslash-error',
54+
},
55+
children: [node],
56+
}
57+
},
58+
59+
lineError(error) {
60+
return [
61+
{
62+
type: 'element',
63+
tagName: 'div',
64+
properties: {
65+
class: 'twoslash-meta-line twoslash-error-line',
66+
},
67+
children: [
68+
{
69+
type: 'text',
70+
value: error.renderedMessage,
71+
},
72+
],
73+
},
74+
]
75+
},
76+
77+
lineCompletions(query) {
78+
return [
79+
{
80+
type: 'element',
81+
tagName: 'div',
82+
properties: { class: 'twoslash-meta-line' },
83+
children: [
84+
{ type: 'text', value: ' '.repeat(query.offset) },
85+
{
86+
type: 'element',
87+
tagName: 'span',
88+
properties: { class: 'twoslash-completions' },
89+
children: [{
90+
type: 'element',
91+
tagName: 'ul',
92+
properties: { },
93+
children: query.completions!
94+
.filter(i => i.name.startsWith(query.completionsPrefix || '____'))
95+
.map(i => ({
96+
type: 'element',
97+
tagName: 'li',
98+
properties: {
99+
class: i.kindModifiers?.split(',').includes('deprecated')
100+
? 'deprecated'
101+
: undefined,
102+
},
103+
children: [{
104+
type: 'element',
105+
tagName: 'span',
106+
properties: {},
107+
children: [
108+
{
109+
type: 'element',
110+
tagName: 'span',
111+
properties: { class: 'twoslash-completions-matched' },
112+
children: [
113+
{
114+
type: 'text',
115+
value: query.completionsPrefix || '',
116+
},
117+
],
118+
},
119+
{
120+
type: 'text',
121+
value: i.name.slice(query.completionsPrefix?.length || 0),
122+
},
123+
],
124+
}],
125+
})),
126+
}],
127+
},
128+
],
129+
},
130+
]
131+
},
132+
133+
lineQuery(query, targetNode) {
134+
const targetText = targetNode?.type === 'text' ? targetNode.value : ''
135+
const offset = Math.max(0, (query.offset || 0) - Math.round(targetText.length / 2) - 1)
136+
137+
let themed: ElementContent
138+
139+
try {
140+
themed = (this.codeToHast(query.text || '', {
141+
...this.options,
142+
transformers: [],
143+
transforms: undefined,
144+
}).children[0] as Element).children[0]
145+
}
146+
catch (e) {
147+
themed = {
148+
type: 'text',
149+
value: query.text || '',
150+
}
151+
}
152+
153+
return [
154+
{
155+
type: 'element',
156+
tagName: 'div',
157+
properties: { class: 'twoslash-meta-line' },
158+
children: [
159+
{ type: 'text', value: ' '.repeat(offset) },
160+
{
161+
type: 'element',
162+
tagName: 'span',
163+
properties: { class: 'twoslash-popover' },
164+
children: [
165+
{
166+
type: 'element',
167+
tagName: 'div',
168+
properties: { class: 'twoslash-popover-arrow' },
169+
children: [],
170+
},
171+
themed,
172+
],
173+
},
174+
],
175+
},
176+
]
177+
},
178+
179+
lineCustomTag(tag) {
180+
return [
181+
{
182+
type: 'element',
183+
tagName: 'div',
184+
properties: { class: `twoslash-meta-line twoslash-tag-${tag.name}` },
185+
children: [
186+
{
187+
type: 'text',
188+
value: tag.annotation || '',
189+
},
190+
],
191+
},
192+
]
193+
},
194+
}

0 commit comments

Comments
 (0)