Skip to content

Commit 68633e5

Browse files
Zih0dimaMachina
andauthoredOct 25, 2024··
fix: Improve Twoslash Popover Display (#3533)
* feat: popup component * feat: custom twoslash renderer * fix: popup style * chore: unnecessary props * apply suggestions * use one function for 2 handlers * refactor * fix console.warnings * can be exported from barrel * fix popup behind code element * Update docs/pages/docs/guide/advanced/twoslash-support.mdx * add prettier ignore * fix font * Create eight-bees-count.md --------- Co-authored-by: Dimitri POSTOLOV <dmytropostolov@gmail.com>
1 parent 1413e20 commit 68633e5

File tree

12 files changed

+262
-66
lines changed

12 files changed

+262
-66
lines changed
 

Diff for: ‎.changeset/eight-bees-count.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"nextra": patch
3+
"nextra-theme-docs": patch
4+
"nextra-theme-blog": patch
5+
---
6+
7+
fix: Improve Twoslash Popover Display

Diff for: ‎docs/pages/docs/guide/advanced/twoslash-support.mdx

+10
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Twoslash provides an inline type hove inside the code block.
66

77
You can enable twoslash to your code blocks by adding a `twoslash` metadata:
88

9+
{/* prettier-ignore */}
910
````md copy=false filename="Markdown"
1011
```ts twoslash
1112
// @errors: 2540
@@ -22,11 +23,16 @@ todo.title = 'Hello'
2223

2324
Number.parseInt('123', 10)
2425
// ^|
26+
// Just comments, so Popup will be
27+
// not behind the viewport of `<code>`
28+
// element due his `position: absolute` style
29+
//
2530
```
2631
````
2732

2833
Renders:
2934

35+
{/* prettier-ignore */}
3036
```ts twoslash
3137
// @errors: 2540
3238
interface Todo {
@@ -42,6 +48,10 @@ todo.title = 'Hello'
4248

4349
Number.parseInt('123', 10)
4450
// ^|
51+
52+
53+
54+
4555
```
4656

4757
## Custom log message

Diff for: ‎packages/nextra/package.json

+3
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,9 @@
144144
"gray-matter": "^4.0.3",
145145
"hast-util-to-estree": "^3.1.0",
146146
"katex": "^0.16.9",
147+
"mdast-util-from-markdown": "^2.0.1",
148+
"mdast-util-gfm": "^3.0.0",
149+
"mdast-util-to-hast": "^13.2.0",
147150
"negotiator": "^1.0.0",
148151
"p-limit": "^6.0.0",
149152
"rehype-katex": "^7.0.0",

Diff for: ‎packages/nextra/src/client/components/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ export { Tr } from './tr.js'
1515
export { Mermaid } from '@theguild/remark-mermaid/mermaid'
1616
export { MathJax, MathJaxContext } from 'better-react-mathjax'
1717
export { Playground } from './playground.js'
18+
export { Popup } from './popup.js'

Diff for: ‎packages/nextra/src/client/components/popup.tsx

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react'
2+
import type { PopoverPanelProps, PopoverProps } from '@headlessui/react'
3+
import cn from 'clsx'
4+
import { createContext, useCallback, useContext, useState } from 'react'
5+
import type { FC, MouseEventHandler } from 'react'
6+
7+
const PopupContext = createContext<boolean | null>(null)
8+
9+
function usePopup(): boolean {
10+
const ctx = useContext(PopupContext)
11+
if (typeof ctx !== 'boolean') {
12+
throw new Error('`usePopup` must be used within a `<Popup>` component')
13+
}
14+
return ctx
15+
}
16+
17+
const Popup_: FC<PopoverProps> = props => {
18+
const [isOpen, setIsOpen] = useState(false)
19+
20+
const handleMouse: MouseEventHandler = useCallback(event => {
21+
setIsOpen(event.type === 'mouseenter')
22+
}, [])
23+
24+
return (
25+
<PopupContext.Provider value={isOpen}>
26+
<Popover
27+
as="span"
28+
onMouseEnter={handleMouse}
29+
onMouseLeave={handleMouse}
30+
{...props}
31+
/>
32+
</PopupContext.Provider>
33+
)
34+
}
35+
36+
const PopupPanel: FC<PopoverPanelProps> = props => {
37+
const isOpen = usePopup()
38+
return (
39+
<PopoverPanel
40+
static={isOpen}
41+
anchor={{ to: 'bottom start', gap: -24 }}
42+
{...props}
43+
className={cn(
44+
'!_max-w-2xl', // override headlessui's computed max-width
45+
props.className
46+
)}
47+
/>
48+
)
49+
}
50+
51+
export const Popup = Object.assign(Popup_, {
52+
Button: PopoverButton,
53+
Panel: PopupPanel
54+
})

Diff for: ‎packages/nextra/src/server/compile.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ import {
3636
rehypeBetterReactMathjax,
3737
rehypeExtractTocContent,
3838
rehypeIcon,
39-
rehypeParseCodeMeta
39+
rehypeParseCodeMeta,
40+
rehypeTwoslashPopup
4041
} from './rehype-plugins/index.js'
4142
import {
4243
remarkCustomHeadingId,
@@ -289,6 +290,7 @@ export async function compileMdx(
289290
...rehypePrettyCodeOptions
290291
}
291292
] as any,
293+
rehypeTwoslashPopup,
292294
!isRemoteContent && rehypeIcon,
293295
rehypeAttachCodeMeta
294296
]),

Diff for: ‎packages/nextra/src/server/loader.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import path from 'node:path'
2-
import { rendererRich, transformerTwoslash } from '@shikijs/twoslash'
2+
import { transformerTwoslash } from '@shikijs/twoslash'
33
import slash from 'slash'
44
import type { LoaderContext } from 'webpack'
55
import type { LoaderOptions, PageOpts } from '../types'
@@ -11,6 +11,7 @@ import {
1111
OFFICIAL_THEMES
1212
} from './constants.js'
1313
import { PAGES_DIR } from './file-system.js'
14+
import { twoslashRenderer } from './rehype-plugins/twoslash.js'
1415
import { logger } from './utils.js'
1516

1617
const initGitRepo = (async () => {
@@ -161,7 +162,7 @@ ${themeConfigImport && '__nextra_internal__.themeConfig = __themeConfig'}`
161162
rehypePrettyCodeOptions: {
162163
transformers: [
163164
transformerTwoslash({
164-
renderer: rendererRich(),
165+
renderer: twoslashRenderer(),
165166
explicitTrigger: true
166167
})
167168
],

Diff for: ‎packages/nextra/src/server/rehype-plugins/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export {
66
export { rehypeBetterReactMathjax } from './rehype-better-react-mathjax.js'
77
export { rehypeExtractTocContent } from './rehype-extract-toc-content.js'
88
export { rehypeIcon } from './rehype-icon.js'
9+
export { rehypeTwoslashPopup } from './rehype-twoslash-popup.js'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { ImportDeclaration } from 'estree'
2+
import type { Root } from 'hast'
3+
import type { MdxjsEsm } from 'hast-util-to-estree/lib/handlers/mdxjs-esm'
4+
import type { Plugin } from 'unified'
5+
import { EXIT, visit } from 'unist-util-visit'
6+
7+
const TWOSLASH_POPUP_IMPORT_AST = {
8+
type: 'mdxjsEsm',
9+
data: {
10+
estree: {
11+
body: [
12+
{
13+
type: 'ImportDeclaration',
14+
source: { type: 'Literal', value: 'nextra/components' },
15+
specifiers: [
16+
{
17+
type: 'ImportSpecifier',
18+
imported: { type: 'Identifier', name: 'Popup' },
19+
local: { type: 'Identifier', name: 'Popup' }
20+
}
21+
]
22+
} satisfies ImportDeclaration
23+
]
24+
}
25+
}
26+
} as MdxjsEsm
27+
28+
export const rehypeTwoslashPopup: Plugin<[], Root> = () => ast => {
29+
// The tagName is being converted to lowercase when calling the shiki.codeToHtml
30+
// method inside rehypePrettyCode. Convert it back to Uppercase.
31+
visit(
32+
ast,
33+
[
34+
{ tagName: 'popup' },
35+
{ tagName: 'popupbutton' },
36+
{ tagName: 'popuppanel' }
37+
],
38+
node => {
39+
const n = node as { tagName: string }
40+
const tagName = {
41+
popup: 'Popup',
42+
popupbutton: 'Popup.Button',
43+
popuppanel: 'Popup.Panel'
44+
}[n.tagName]!
45+
n.tagName = tagName
46+
}
47+
)
48+
49+
visit(ast, { tagName: 'code' }, node => {
50+
if (node.data?.meta === 'twoslash') {
51+
ast.children.unshift(TWOSLASH_POPUP_IMPORT_AST)
52+
return EXIT
53+
}
54+
})
55+
}
+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* We explicitly didn't put this file in `rehype-twoslash-popup.ts` to avoid
3+
* having warnings which comes from `@typescript/vfs` https://github.com/shuding/nextra/pull/3349
4+
*/
5+
6+
import { rendererRich } from '@shikijs/twoslash'
7+
import type { RendererRichOptions } from '@shikijs/twoslash'
8+
import type { Element, ElementContent } from 'hast'
9+
import type { Code } from 'mdast'
10+
import { fromMarkdown } from 'mdast-util-from-markdown'
11+
import { gfmFromMarkdown } from 'mdast-util-gfm'
12+
import { defaultHandlers, toHast } from 'mdast-util-to-hast'
13+
import type { ShikiTransformerContextCommon } from 'shiki'
14+
15+
function renderMarkdown(
16+
this: ShikiTransformerContextCommon,
17+
md: string
18+
): ElementContent[] {
19+
const mdast = fromMarkdown(
20+
md.replaceAll(/{@link (?<link>[^}]*)}/g, '$1'), // replace jsdoc links
21+
{ mdastExtensions: [gfmFromMarkdown()] }
22+
)
23+
24+
return (
25+
toHast(mdast, {
26+
handlers: {
27+
code: (state, node: Code) => {
28+
if (node.lang) {
29+
return this.codeToHast(node.value, {
30+
...this.options,
31+
transformers: [],
32+
meta: {
33+
__raw: node.meta ?? undefined
34+
},
35+
lang: node.lang
36+
}).children[0] as Element
37+
}
38+
39+
return defaultHandlers.code(state, node)
40+
}
41+
}
42+
}) as Element
43+
).children
44+
}
45+
46+
function renderMarkdownInline(
47+
this: ShikiTransformerContextCommon,
48+
md: string,
49+
context?: string
50+
): ElementContent[] {
51+
const text =
52+
context === 'tag:param' ? md.replace(/^(?<link>[\w$-]+)/, '`$1` ') : md
53+
54+
const children = renderMarkdown.call(this, text)
55+
if (
56+
children.length === 1 &&
57+
children[0].type === 'element' &&
58+
children[0].tagName === 'p'
59+
)
60+
return children[0].children
61+
return children
62+
}
63+
64+
export function twoslashRenderer(options?: RendererRichOptions) {
65+
return rendererRich({
66+
...options,
67+
renderMarkdown,
68+
renderMarkdownInline,
69+
hast: {
70+
hoverToken: { tagName: 'Popup' },
71+
hoverPopup: { tagName: 'PopupPanel' },
72+
hoverCompose: ({ popup, token }) => [
73+
popup,
74+
{
75+
type: 'element',
76+
tagName: 'PopupButton',
77+
properties: {},
78+
children: [token]
79+
}
80+
],
81+
...options?.hast
82+
}
83+
})
84+
}

Diff for: ‎packages/nextra/styles/code-block.css

+24-58
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,22 @@
33
@apply dark:_bg-[--shiki-dark-bg] dark:_text-[--shiki-dark];
44
}
55

6-
code.nextra-code:not([class*='twoslash-']) {
6+
code.nextra-code {
77
box-decoration-break: slice;
88
font-feature-settings:
99
'rlig' 1,
1010
'calt' 1,
1111
'ss01' 1;
12+
@apply _text-sm;
1213

13-
:not(pre) > & {
14+
:not(pre) > &:not([class*='twoslash-']) {
1415
@apply _border-black _border-opacity-[0.04] _bg-opacity-[0.03] _bg-black _break-words _rounded-md _border _py-0.5 _px-[.25em] _text-[.9em];
1516
@apply dark:_border-white/10 dark:_bg-white/10;
1617
}
1718
}
1819

1920
pre code.nextra-code:not([class*='twoslash-']) {
20-
@apply _grid _text-sm;
21+
@apply _grid;
2122

2223
&[data-line-numbers] > span {
2324
@apply _pl-2;
@@ -64,26 +65,20 @@ pre code.nextra-code:not([class*='twoslash-']) {
6465
:root {
6566
--twoslash-border-color: #8888;
6667
--twoslash-underline-color: currentColor;
67-
--twoslash-highlighted-border: #c37d0d50;
68-
--twoslash-highlighted-bg: #c37d0d20;
68+
--twoslash-highlighted-border: 195, 125, 13;
6969
--twoslash-popup-bg: #f8f8f8;
7070
--twoslash-popup-color: inherit;
7171
--twoslash-popup-shadow: rgba(0, 0, 0.08) 0px 1px 4px;
7272
--twoslash-docs-color: #888;
7373
--twoslash-docs-font: sans-serif;
74-
--twoslash-code-font: inherit;
75-
--twoslash-code-font-size: 1em;
7674
--twoslash-matched-color: inherit;
7775
--twoslash-unmatched-color: #888;
7876
--twoslash-cursor-color: #8888;
79-
--twoslash-error-color: #d45656;
80-
--twoslash-error-bg: #d4565620;
81-
--twoslash-tag-color: #3772cf;
82-
--twoslash-tag-bg: #3772cf20;
83-
--twoslash-tag-warn-color: #c37d0d;
84-
--twoslash-tag-warn-bg: #c37d0d20;
85-
--twoslash-tag-annotate-color: #1ba673;
86-
--twoslash-tag-annotate-bg: #1ba67320;
77+
--twoslash-error-color: 212, 86, 86;
78+
--twoslash-error-bg: rgba(var(--twoslash-error-color), 0.13);
79+
--twoslash-tag-color: 55, 114, 207;
80+
--twoslash-tag-warn-color: 195, 125, 13;
81+
--twoslash-tag-annotate-color: 27, 166, 115;
8782
}
8883

8984
.dark {
@@ -111,13 +106,11 @@ pre code.nextra-code:not([class*='twoslash-']) {
111106
}
112107

113108
.twoslash-popup-container {
114-
@apply _inline-flex _flex-col _opacity-0 _absolute _transition-opacity _duration-300 _z-10 _mt-1.5 _rounded;
109+
@apply _inline-flex _flex-col _absolute _transition-opacity _duration-300 _z-10 _mt-1.5 _rounded;
115110
transform: translateY(1.1em);
116111
background: var(--twoslash-popup-bg) !important;
117112
color: var(--twoslash-popup-color);
118113
border: 1px solid var(--twoslash-border-color);
119-
pointer-events: none;
120-
user-select: none;
121114
text-align: left;
122115
/*box-shadow: var(--twoslash-popup-shadow);*/
123116
}
@@ -127,16 +120,6 @@ pre code.nextra-code:not([class*='twoslash-']) {
127120
transform: translateY(1.5em);
128121
}
129122

130-
.twoslash-hover:hover .twoslash-popup-container,
131-
.twoslash-query-presisted .twoslash-popup-container {
132-
opacity: 1;
133-
pointer-events: auto;
134-
}
135-
136-
.twoslash-popup-container:hover {
137-
user-select: auto;
138-
}
139-
140123
.twoslash-popup-arrow {
141124
@apply _absolute _-top-1 _border-t _border-r _size-1.5 _-rotate-45;
142125
@apply _border-[--twoslash-border-color] _bg-[--twoslash-popup-bg];
@@ -149,15 +132,10 @@ pre code.nextra-code:not([class*='twoslash-']) {
149132
padding: 6px 8px;
150133
}
151134

152-
.twoslash-popup-code {
153-
font-family: var(--twoslash-code-font);
154-
font-size: var(--twoslash-code-font-size);
155-
}
156-
157135
.twoslash-popup-docs {
136+
@apply _text-sm;
158137
color: var(--twoslash-docs-color);
159138
font-family: var(--twoslash-docs-font);
160-
font-size: 0.8em;
161139
border-top: 1px solid var(--twoslash-border-color);
162140
}
163141

@@ -172,16 +150,12 @@ pre code.nextra-code:not([class*='twoslash-']) {
172150
margin-right: 0.5em;
173151
}
174152

175-
.twoslash-popup-docs-tag-name {
176-
font-family: var(--twoslash-code-font);
177-
}
178-
179153
/* ===== Error Line ===== */
180154
.twoslash-error-line {
181155
position: relative;
182156
background-color: var(--twoslash-error-bg);
183-
border-left: 3px solid var(--twoslash-error-color);
184-
color: var(--twoslash-error-color);
157+
border-left: 3px solid currentColor;
158+
color: rgb(var(--twoslash-error-color));
185159
padding: 6px 12px;
186160
margin: 0.2em 0;
187161
}
@@ -206,11 +180,6 @@ pre code.nextra-code:not([class*='twoslash-']) {
206180

207181
.twoslash-completion-list {
208182
@apply _py-1 _px-2 _w-60;
209-
font-size: 0.8rem;
210-
}
211-
212-
.twoslash-completion-list:hover {
213-
user-select: auto;
214183
}
215184

216185
.twoslash-completion-list::before {
@@ -246,8 +215,8 @@ pre code.nextra-code:not([class*='twoslash-']) {
246215

247216
/* Highlights */
248217
.twoslash-highlighted {
249-
background-color: var(--twoslash-highlighted-bg);
250-
border: 1px solid var(--twoslash-highlighted-border);
218+
background-color: rgba(var(--twoslash-highlighted-border), 0.13);
219+
border: 1px solid rgba(var(--twoslash-highlighted-border), 0.31);
251220
padding: 1px 2px;
252221
margin: -1px -3px;
253222
border-radius: 4px;
@@ -263,9 +232,9 @@ pre code.nextra-code:not([class*='twoslash-']) {
263232
/* Custom Tags */
264233
.twoslash-tag-line {
265234
position: relative;
266-
background-color: var(--twoslash-tag-bg);
267-
border-left: 3px solid var(--twoslash-tag-color);
268-
color: var(--twoslash-tag-color);
235+
background-color: rgba(var(--twoslash-tag-color), 0.13);
236+
border-left: 3px solid currentColor;
237+
color: rgb(var(--twoslash-tag-color));
269238
padding: 6px 10px;
270239
margin: 0.2em 0;
271240
display: flex;
@@ -280,18 +249,15 @@ pre code.nextra-code:not([class*='twoslash-']) {
280249

281250
.twoslash-tag-line.twoslash-tag-error-line {
282251
background-color: var(--twoslash-error-bg);
283-
border-left: 3px solid var(--twoslash-error-color);
284-
color: var(--twoslash-error-color);
252+
color: rgb(var(--twoslash-error-color));
285253
}
286254

287255
.twoslash-tag-line.twoslash-tag-warn-line {
288-
background-color: var(--twoslash-tag-warn-bg);
289-
border-left: 3px solid var(--twoslash-tag-warn-color);
290-
color: var(--twoslash-tag-warn-color);
256+
background-color: rgba(var(--twoslash-tag-warn-color), 0.13);
257+
color: rgb(var(--twoslash-tag-warn-color));
291258
}
292259

293260
.twoslash-tag-line.twoslash-tag-annotate-line {
294-
background-color: var(--twoslash-tag-annotate-bg);
295-
border-left: 3px solid var(--twoslash-tag-annotate-color);
296-
color: var(--twoslash-tag-annotate-color);
261+
background-color: rgba(var(--twoslash-tag-annotate-color), 0.13);
262+
color: rgb(var(--twoslash-tag-annotate-color));
297263
}

Diff for: ‎pnpm-lock.yaml

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

0 commit comments

Comments
 (0)
Please sign in to comment.