Skip to content

Commit 585286c

Browse files
KaribashSoonIter
andauthored
feat: Add a twoslash plugin (#2527)
Co-authored-by: sooniter <[email protected]>
1 parent 69800e3 commit 585286c

File tree

30 files changed

+1343
-15
lines changed

30 files changed

+1343
-15
lines changed

e2e/fixtures/auto-nav-sidebar-dir-convention/index.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ test.describe('Auto nav and sidebar dir convention', async () => {
4646
waitUntil: 'networkidle',
4747
});
4848
await page.click('.rspress-scrollbar nav section div');
49+
await page.waitForURL('**/index-md-convention/**');
4950
expect(page.url()).toBe(
5051
`http://localhost:${appPort}/guide/index-md-convention/index.html`,
5152
);
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# @rspress/plugin-twoslash
2+
3+
## Extract type
4+
5+
```ts twoslash
6+
const hi = 'Hello';
7+
const msg = `${hi}, world`;
8+
// ^?
9+
```
10+
11+
## Completions
12+
13+
```ts twoslash
14+
// @noErrors
15+
console.e;
16+
// ^|
17+
```
18+
19+
## Highlighting
20+
21+
```ts twoslash
22+
function add(a: number, b: number) {
23+
// ^^^
24+
return a + b;
25+
}
26+
```
27+
28+
## Error
29+
30+
```ts twoslash
31+
// @noErrorValidation
32+
const str: string = 1;
33+
```
34+
35+
## Disable twoslash
36+
37+
```ts
38+
const hi = 'Hello';
39+
```
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { expect, test } from '@playwright/test';
2+
import {
3+
getPort,
4+
killProcess,
5+
runBuildCommand,
6+
runPreviewCommand,
7+
} from '../../utils/runCommands';
8+
9+
test.describe('plugin twoslash test', async () => {
10+
let appPort: number;
11+
let app: unknown;
12+
test.beforeAll(async () => {
13+
const appDir = __dirname;
14+
appPort = await getPort();
15+
await runBuildCommand(appDir);
16+
app = await runPreviewCommand(appDir, appPort);
17+
});
18+
19+
test.afterAll(async () => {
20+
if (app) {
21+
await killProcess(app);
22+
}
23+
});
24+
25+
test('should render twoslash popup elements', async ({ page }) => {
26+
await page.goto(`http://localhost:${appPort}`, {
27+
waitUntil: 'networkidle',
28+
});
29+
30+
const triggers = await page.$$('twoslash-popup-trigger');
31+
expect(triggers.length).toBe(10);
32+
33+
for (const trigger of triggers) {
34+
const container = await trigger.$('twoslash-popup-container');
35+
expect(container).not.toBeNull();
36+
37+
const initialized = await container?.getAttribute('data-initialized');
38+
expect(initialized).toBeNull();
39+
40+
const inner = await container.$('.twoslash-popup-inner');
41+
expect(inner).not.toBeNull();
42+
const arrow = await container.$('.twoslash-popup-arrow');
43+
expect(arrow).not.toBeNull();
44+
}
45+
});
46+
47+
test('should clone twoslash popup elements', async ({ page }) => {
48+
await page.goto(`http://localhost:${appPort}`, {
49+
waitUntil: 'networkidle',
50+
});
51+
52+
const portal = await page.$('twoslash-popup-portal');
53+
expect(portal).not.toBeNull();
54+
55+
const containers = await portal.$$('twoslash-popup-container');
56+
expect(containers.length).toBe(10);
57+
58+
const extractTypeAlways = await containers[1].getAttribute('data-always');
59+
expect(extractTypeAlways).toBe('true');
60+
61+
const completionsAlways = await containers[4].getAttribute('data-always');
62+
expect(completionsAlways).toBe('true');
63+
64+
for (const container of containers) {
65+
const initialized = await container.getAttribute('data-initialized');
66+
expect(initialized).not.toBeNull();
67+
68+
const inner = await container.$('.twoslash-popup-inner');
69+
expect(inner).not.toBeNull();
70+
const arrow = await container.$('.twoslash-popup-arrow');
71+
expect(arrow).not.toBeNull();
72+
}
73+
});
74+
75+
test('should highlight code blocks with twoslash', async ({ page }) => {
76+
await page.goto(`http://localhost:${appPort}`, {
77+
waitUntil: 'networkidle',
78+
});
79+
80+
const codeBlock = await page.$(
81+
'h2:has-text("Highlighting") + .language-ts',
82+
);
83+
expect(codeBlock).not.toBeNull();
84+
85+
const highlighted = await codeBlock?.$('.twoslash-highlighted');
86+
expect(highlighted).not.toBeNull();
87+
});
88+
89+
test('should show errors in code blocks with twoslash', async ({ page }) => {
90+
await page.goto(`http://localhost:${appPort}`, {
91+
waitUntil: 'networkidle',
92+
});
93+
94+
const codeBlock = await page.$('h2:has-text("Error") + .language-ts');
95+
expect(codeBlock).not.toBeNull();
96+
97+
const error = await codeBlock?.$('.twoslash-error');
98+
expect(error).not.toBeNull();
99+
100+
const errorLine = await codeBlock?.$('.twoslash-error-line');
101+
expect(errorLine).not.toBeNull();
102+
});
103+
104+
test('should not apply twoslash to code blocks without twoslash', async ({
105+
page,
106+
}) => {
107+
await page.goto(`http://localhost:${appPort}`, {
108+
waitUntil: 'networkidle',
109+
});
110+
111+
const codeBlock = await page.$(
112+
'h2:has-text("Disable twoslash") + .language-ts',
113+
);
114+
expect(codeBlock).not.toBeNull();
115+
116+
const triggers = await codeBlock?.$$('twoslash-popup-trigger');
117+
expect(triggers.length).toBe(0);
118+
});
119+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"name": "@rspress-fixture/plugin-twoslash",
3+
"version": "1.0.0",
4+
"private": true,
5+
"scripts": {
6+
"build": "rspress build",
7+
"dev": "rspress dev",
8+
"preview": "rspress preview"
9+
},
10+
"dependencies": {
11+
"@rspress/core": "workspace:*",
12+
"@rspress/plugin-twoslash": "workspace:*"
13+
},
14+
"devDependencies": {
15+
"@types/node": "^22.8.1"
16+
}
17+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import path from 'node:path';
2+
import { defineConfig } from '@rspress/core';
3+
import { pluginTwoslash } from '@rspress/plugin-twoslash';
4+
5+
export default defineConfig({
6+
root: path.join(__dirname, 'doc'),
7+
plugins: [pluginTwoslash()],
8+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"compilerOptions": {
3+
"esModuleInterop": true,
4+
"jsx": "react-jsx"
5+
}
6+
}

e2e/utils/search.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import assert from 'node:assert';
22
import type { Page } from '@playwright/test';
33

44
async function getSearchButton(page: Page) {
5-
const searchButton = await page.$('.rspress-nav-search-button');
5+
const searchButton = await page.$('.rp-flex > .rspress-nav-search-button');
66
return searchButton;
77
}
88

@@ -14,14 +14,17 @@ export async function searchInPage(
1414
searchText: string,
1515
reset = true,
1616
) {
17-
let searchInput = await page.$('.rspress-search-panel-input');
18-
if (!searchInput) {
17+
const searchInputLoc = page.locator('.rspress-search-panel-input');
18+
const isSearchInputVisible = await searchInputLoc.isVisible();
19+
if (!isSearchInputVisible) {
1920
const searchButton = await getSearchButton(page);
2021
assert(searchButton);
2122
await searchButton.click();
22-
searchInput = await page.$('.rspress-search-panel-input');
23+
const searchInput = await page.$('.rspress-search-panel-input');
2324
assert(searchInput);
2425
}
26+
const searchInput = await page.$('.rspress-search-panel-input');
27+
assert(searchInput);
2528
const isEditable = await searchInput.isEditable();
2629
assert(isEditable);
2730
await searchInput.focus();

packages/plugin-twoslash/LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2025-present Bytedance, Inc. and its affiliates.
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

packages/plugin-twoslash/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# @rspress/plugin-twoslash
2+
3+
> Twoslash plugin for rspress
4+
5+
[Documentation](https://rspress.rs/plugin/official-plugins/twoslash)
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
{
2+
"name": "@rspress/plugin-twoslash",
3+
"version": "2.0.0-beta.28",
4+
"description": "A plugin for rspress to applies twoslash to code blocks.",
5+
"bugs": "https://github.com/web-infra-dev/rspress/issues",
6+
"repository": {
7+
"type": "git",
8+
"url": "git+https://github.com/web-infra-dev/rspress.git",
9+
"directory": "packages/plugin-twoslash"
10+
},
11+
"license": "MIT",
12+
"type": "module",
13+
"exports": {
14+
".": {
15+
"types": "./dist/index.d.ts",
16+
"default": "./dist/index.js"
17+
}
18+
},
19+
"main": "./dist/index.js",
20+
"module": "./dist/index.js",
21+
"types": "./dist/index.d.ts",
22+
"files": [
23+
"dist",
24+
"static"
25+
],
26+
"scripts": {
27+
"build": "rslib build",
28+
"dev": "rslib build -w",
29+
"reset": "rimraf ./**/node_modules"
30+
},
31+
"dependencies": {
32+
"@floating-ui/dom": "^1.7.4",
33+
"@shikijs/twoslash": "^3.12.0",
34+
"mdast-util-from-markdown": "^2.0.2",
35+
"mdast-util-gfm": "^3.1.0",
36+
"mdast-util-to-hast": "^13.2.0"
37+
},
38+
"devDependencies": {
39+
"@microsoft/api-extractor": "^7.52.11",
40+
"@rslib/core": "0.12.4",
41+
"@rspress/config": "workspace:*",
42+
"@shikijs/types": "^3.12.0",
43+
"@types/hast": "^3.0.4",
44+
"@types/mdast": "^4.0.4",
45+
"@types/react": "^19.1.12",
46+
"react": "^19.1.1",
47+
"rsbuild-plugin-publint": "^0.3.3",
48+
"typescript": "^5.8.2"
49+
},
50+
"peerDependencies": {
51+
"@rspress/core": "workspace:^2.0.0-beta.28",
52+
"react": ">=18.0.0"
53+
},
54+
"engines": {
55+
"node": ">=18.0.0"
56+
},
57+
"publishConfig": {
58+
"access": "public",
59+
"registry": "https://registry.npmjs.org/"
60+
}
61+
}

0 commit comments

Comments
 (0)