Skip to content

Commit b382618

Browse files
committed
feat(shiki): add twoslash support
1 parent c10ebc1 commit b382618

File tree

11 files changed

+154
-70
lines changed

11 files changed

+154
-70
lines changed

apps/site/next.config.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ const nextConfig = {
5151
},
5252
],
5353
},
54+
serverExternalPackages: ['twoslash'],
55+
outputFileTracingIncludes: {
56+
// Twoslash needs TypeScript declarations to function, and, by default,
57+
// Next.js strips them for brevity. Therefore, they must be explicitly
58+
// incldued.
59+
'/*': [
60+
'../../node_modules/.pnpm/typescript@*/node_modules/typescript/lib/*.d.ts',
61+
],
62+
},
5463
// On static export builds we want the output directory to be "build"
5564
distDir: ENABLE_STATIC_EXPORT ? 'build' : undefined,
5665
// On static export builds we want to enable the export feature

apps/site/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
"semver": "~7.7.2",
7474
"sval": "^0.6.3",
7575
"tailwindcss": "catalog:",
76+
"twoslash": "^0.3.4",
7677
"vfile": "~6.0.3",
7778
"vfile-matter": "~5.0.1"
7879
},

apps/site/pages/en/learn/typescript/transpile.md

Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ If you have type errors in your TypeScript code, the TypeScript compiler will ca
7171
We will modify our code like this, to voluntarily introduce a type error:
7272

7373
```ts
74+
// @errors: 2322 2554
7475
type User = {
7576
name: string;
7677
age: number;
@@ -88,31 +89,4 @@ const justine: User = {
8889
const isJustineAnAdult: string = isAdult(justine, "I shouldn't be here!");
8990
```
9091

91-
And this is what TypeScript has to say about this:
92-
93-
```console
94-
example.ts:12:5 - error TS2322: Type 'string' is not assignable to type 'number'.
95-
96-
12 age: 'Secret!',
97-
~~~
98-
99-
example.ts:3:5
100-
3 age: number;
101-
~~~
102-
The expected type comes from property 'age' which is declared here on type 'User'
103-
104-
example.ts:15:7 - error TS2322: Type 'boolean' is not assignable to type 'string'.
105-
106-
15 const isJustineAnAdult: string = isAdult(justine, "I shouldn't be here!");
107-
~~~~~~~~~~~~~~~~
108-
109-
example.ts:15:51 - error TS2554: Expected 1 arguments, but got 2.
110-
111-
15 const isJustineAnAdult: string = isAdult(justine, "I shouldn't be here!");
112-
~~~~~~~~~~~~~~~~~~~~~~
113-
114-
115-
Found 3 errors in the same file, starting at: example.ts:12
116-
```
117-
11892
As you can see, TypeScript is very helpful in catching bugs before they even happen. This is one of the reasons why TypeScript is so popular among developers.

apps/site/styles/index.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@
77
*/
88

99
@import '@node-core/ui-components/styles/index.css';
10+
@import '@node-core/rehype-shiki/twoslash.css';
1011
@import './locales.css';

packages/rehype-shiki/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
{
22
"name": "@node-core/rehype-shiki",
3-
"version": "1.1.0",
3+
"version": "1.2.0",
44
"type": "module",
55
"exports": {
66
".": "./src/index.mjs",
7+
"./twoslash.css": "./src/twoslash.css",
78
"./*": "./src/*.mjs"
89
},
910
"repository": {
@@ -23,6 +24,7 @@
2324
"@shikijs/core": "^3.12.0",
2425
"@shikijs/engine-javascript": "^3.12.0",
2526
"@shikijs/engine-oniguruma": "^3.12.0",
27+
"@shikijs/twoslash": "^3.12.2",
2628
"classnames": "catalog:",
2729
"hast-util-to-string": "^3.0.1",
2830
"shiki": "~3.12.0",

packages/rehype-shiki/src/highlighter.mjs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const DEFAULT_THEME = {
1313
* Creates a syntax highlighter with utility functions
1414
* @param {import('@shikijs/core').HighlighterCoreOptions} options - Configuration options for the highlighter
1515
*/
16-
export const createHighlighter = options => {
16+
export const createHighlighter = ({ transformers, ...options }) => {
1717
const shiki = createHighlighterCoreSync({
1818
themes: [DEFAULT_THEME],
1919
...options,
@@ -37,11 +37,12 @@ export const createHighlighter = options => {
3737
*
3838
* @param {string} code - The code to highlight
3939
* @param {string} lang - The programming language to use for highlighting
40+
* @param {Record<string, any>} meta - Metadata
4041
* @returns {string} The inner HTML of the highlighted code
4142
*/
42-
const highlightToHtml = (code, lang) =>
43+
const highlightToHtml = (code, lang, meta = {}) =>
4344
shiki
44-
.codeToHtml(code, { lang, theme })
45+
.codeToHtml(code, { lang, theme, meta, transformers })
4546
// Shiki will always return the Highlighted code encapsulated in a <pre> and <code> tag
4647
// since our own CodeBox component handles the <code> tag, we just want to extract
4748
// the inner highlighted code to the CodeBox
@@ -52,9 +53,10 @@ export const createHighlighter = options => {
5253
*
5354
* @param {string} code - The code to highlight
5455
* @param {string} lang - The programming language to use for highlighting
56+
* @param {Record<string, any>} meta - Metadata
5557
*/
56-
const highlightToHast = (code, lang) =>
57-
shiki.codeToHast(code, { lang, theme });
58+
const highlightToHast = (code, lang, meta = {}) =>
59+
shiki.codeToHast(code, { lang, theme, meta, transformers });
5860

5961
return {
6062
shiki,

packages/rehype-shiki/src/index.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript';
22
import { createOnigurumaEngine } from '@shikijs/engine-oniguruma';
3+
import { transformerTwoslash } from '@shikijs/twoslash';
34
import cLanguage from 'shiki/langs/c.mjs';
45
import coffeeScriptLanguage from 'shiki/langs/coffeescript.mjs';
56
import cPlusPlusLanguage from 'shiki/langs/cpp.mjs';
@@ -19,6 +20,13 @@ import { createHighlighter } from './highlighter.mjs';
1920

2021
const { shiki, getLanguageDisplayName, highlightToHast, highlightToHtml } =
2122
createHighlighter({
23+
transformers: [
24+
transformerTwoslash({
25+
langs: ['ts', 'js', 'cjs', 'mjs'],
26+
// Don't throw on errors on untype-able code
27+
// throws: false,
28+
}),
29+
],
2230
// We use the faster WASM engine on the server instead of the web-optimized version.
2331
//
2432
// Currently we fall back to the JavaScript RegEx engine

packages/rehype-shiki/src/plugin.mjs

Lines changed: 33 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,28 @@ import { highlightToHast } from './index.mjs';
1010
// to attribute the current language of the <pre> element
1111
const languagePrefix = 'language-';
1212

13+
// The regex to match metadata
14+
const rMeta = /(\w+)(?:=(?:"([^"]+)"|(\S+)))?/g;
15+
1316
/**
14-
* Retrieve the value for the given meta key.
15-
*
16-
* @example - Returns "CommonJS"
17-
* getMetaParameter('displayName="CommonJS"', 'displayName');
18-
*
19-
* @param {any} meta - The meta parameter.
20-
* @param {string} key - The key to retrieve the value.
21-
*
22-
* @return {string | undefined} - The value related to the given key.
17+
* Parses a fenced code block metadata string into a JavaScript object.
18+
* @param {string} meta - The metadata string from a Markdown code fence.
19+
* @returns {Record<string, string|boolean>} An object representing the metadata.
2320
*/
24-
function getMetaParameter(meta, key) {
25-
if (typeof meta !== 'string') {
26-
return;
21+
function parseMeta(meta) {
22+
const obj = { __raw: meta };
23+
24+
if (!meta) {
25+
return obj;
2726
}
2827

29-
const matches = meta.match(new RegExp(`${key}="(?<parameter>[^"]*)"`));
30-
const parameter = matches?.groups.parameter;
28+
let match;
29+
30+
while ((match = rMeta.exec(meta)) !== null) {
31+
obj[match[1]] = match[2] ?? match[3] ?? true;
32+
}
3133

32-
return parameter !== undefined && parameter.length > 0
33-
? parameter
34-
: undefined;
34+
return obj;
3535
}
3636

3737
/**
@@ -65,11 +65,7 @@ export default function rehypeShikiji() {
6565

6666
while (isCodeBlock(parent?.children[currentIndex])) {
6767
const codeElement = parent?.children[currentIndex].children[0];
68-
69-
const displayName = getMetaParameter(
70-
codeElement.data?.meta,
71-
'displayName'
72-
);
68+
const meta = parseMeta(codeElement.data?.meta);
7369

7470
// We should get the language name from the class name
7571
if (codeElement.properties.className?.length) {
@@ -80,18 +76,13 @@ export default function rehypeShikiji() {
8076
}
8177

8278
// Map the display names of each variant for the CodeTab
83-
displayNames.push(displayName?.replaceAll('|', '') ?? '');
79+
displayNames.push(meta.displayName?.replaceAll('|', '') ?? '');
8480

8581
codeTabsChildren.push(parent?.children[currentIndex]);
8682

8783
// If `active="true"` is provided in a CodeBox
8884
// then the default selected entry of the CodeTabs will be the desired entry
89-
const specificActive = getMetaParameter(
90-
codeElement.data?.meta,
91-
'active'
92-
);
93-
94-
if (specificActive === 'true') {
85+
if (meta.active === 'true') {
9586
defaultTab = String(codeTabsChildren.length - 1);
9687
}
9788

@@ -162,30 +153,35 @@ export default function rehypeShikiji() {
162153
return;
163154
}
164155

156+
// Get the metadata
157+
const meta = parseMeta(preElement.data?.meta);
158+
165159
// Retrieve the whole <pre> contents as a parsed DOM string
166160
const preElementContents = toString(preElement);
167161

168162
// Grabs the relevant alias/name of the language
169163
const languageId = codeLanguage.slice(languagePrefix.length);
170164

171165
// Parses the <pre> contents and returns a HAST tree with the highlighted code
172-
const { children } = highlightToHast(preElementContents, languageId);
166+
const { children } = highlightToHast(
167+
preElementContents,
168+
languageId,
169+
meta
170+
);
173171

174172
// Adds the original language back to the <pre> element
175173
children[0].properties.class = classNames(
176174
children[0].properties.class,
177175
codeLanguage
178176
);
179177

180-
const showCopyButton = getMetaParameter(
181-
preElement.data?.meta,
182-
'showCopyButton'
183-
);
184-
185178
// Adds a Copy Button to the CodeBox if requested as an additional parameter
186179
// And avoids setting the property (overriding) if undefined or invalid value
187-
if (showCopyButton && ['true', 'false'].includes(showCopyButton)) {
188-
children[0].properties.showCopyButton = showCopyButton;
180+
if (
181+
meta.showCopyButton &&
182+
['true', 'false'].includes(meta.showCopyButton)
183+
) {
184+
children[0].properties.showCopyButton = meta.showCopyButton;
189185
}
190186

191187
// Replaces the <pre> element with the updated one
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
@import '@shikijs/twoslash/style-rich.css';
2+
3+
.twoslash-popup-container {
4+
> :not(.twoslash-popup-code) {
5+
display: none !important;
6+
}
7+
8+
.twoslash-popup-code {
9+
>span {
10+
display: inline-block !important;
11+
}
12+
13+
display: flex !important;
14+
flex-wrap: wrap !important;
15+
16+
font-size: small;
17+
}
18+
19+
position: fixed !important;
20+
}

packages/ui-components/src/Common/BaseCodeBox/index.module.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
.root {
44
@apply w-full
5+
translate-x-0
6+
translate-y-0
7+
transform
58
rounded-sm
69
border
710
border-neutral-900

0 commit comments

Comments
 (0)