Skip to content

Commit fe87384

Browse files
committed
feat: twoslash support
1 parent 90ccfd1 commit fe87384

File tree

9 files changed

+454
-18
lines changed

9 files changed

+454
-18
lines changed

npm-shrinkwrap.json

Lines changed: 283 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
"@orama/orama": "^3.1.16",
4848
"@orama/react-components": "^0.8.1",
4949
"@rollup/plugin-virtual": "^3.0.2",
50+
"@shikijs/twoslash": "^3.14.0",
5051
"acorn": "^8.15.0",
5152
"commander": "^14.0.2",
5253
"dedent": "^1.7.0",
@@ -58,6 +59,7 @@
5859
"globals": "^16.4.0",
5960
"hast-util-to-string": "^3.0.1",
6061
"hastscript": "^9.0.1",
62+
"idb-keyval": "^6.2.2",
6163
"lightningcss": "^1.30.1",
6264
"mdast-util-slice-markdown": "^2.0.1",
6365
"preact": "^10.27.2",
@@ -74,13 +76,15 @@
7476
"rolldown": "^1.0.0-beta.40",
7577
"semver": "^7.7.2",
7678
"shiki": "^3.15.0",
79+
"twoslash-cdn": "^0.3.4",
7780
"unified": "^11.0.5",
7881
"unist-builder": "^4.0.0",
7982
"unist-util-find-after": "^5.0.0",
8083
"unist-util-position": "^5.0.0",
8184
"unist-util-remove": "^4.0.0",
8285
"unist-util-select": "^5.1.0",
8386
"unist-util-visit": "^5.0.0",
87+
"unstorage": "^1.17.2",
8488
"vfile": "^6.0.3",
8589
"yaml": "^2.8.1"
8690
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/* global document */
2+
3+
import createHighlighter from '@node-core/rehype-shiki';
4+
import { createTransformerFactory, rendererRich } from '@shikijs/twoslash';
5+
import shikiNordTheme from 'shiki/themes/nord.mjs';
6+
import { createTwoslashFromCDN } from 'twoslash-cdn';
7+
import { createStorage } from 'unstorage';
8+
import indexedDbDriver from 'unstorage/drivers/indexedb';
9+
10+
// An example using unstorage with IndexedDB to cache the virtual file system
11+
const storage = createStorage({
12+
driver: indexedDbDriver({ base: 'twoslash-cdn' }),
13+
});
14+
15+
const twoslash = createTwoslashFromCDN({
16+
storage,
17+
compilerOptions: {
18+
lib: ['dom', 'dom.iterable', 'esnext'],
19+
module: 'nodenext',
20+
types: ['node'],
21+
},
22+
});
23+
24+
const transformerTwoslash = createTransformerFactory(twoslash.runSync)({
25+
renderer: rendererRich({ jsdoc: true }),
26+
langs: ['ts', 'js', 'cjs', 'mjs'],
27+
throws: false,
28+
});
29+
30+
const highlighterPromise = createHighlighter({
31+
wasm: false,
32+
});
33+
34+
/**
35+
* Extracts the raw code content from a <pre><code> element
36+
* @param {HTMLPreElement} preElement - The pre element
37+
* @returns {string} The raw code content
38+
*/
39+
function extractRawCode(preElement) {
40+
const codeElement = preElement.querySelector('code');
41+
if (!codeElement) {
42+
return '';
43+
}
44+
45+
return codeElement.textContent || '';
46+
}
47+
48+
/**
49+
* Process a single code block with Twoslash
50+
* @param {HTMLPreElement} preElement - The pre element
51+
* @returns {Promise<void>}
52+
*/
53+
async function processTwoslashBlock(preElement) {
54+
try {
55+
const rawCode = extractRawCode(preElement);
56+
57+
if (!rawCode) {
58+
return;
59+
}
60+
61+
const highlighter = await highlighterPromise;
62+
63+
await twoslash.prepareTypes(rawCode);
64+
65+
const html = highlighter.shiki.codeToHtml(rawCode, {
66+
lang: 'mjs',
67+
theme: {
68+
// We are updating this color because the background color and comment text color
69+
// in the Codebox component do not comply with accessibility standards.
70+
// See: https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html
71+
colorReplacements: { '#616e88': '#707e99' },
72+
...shikiNordTheme,
73+
},
74+
transformers: [transformerTwoslash],
75+
});
76+
77+
const temp = document.createElement('div');
78+
79+
temp.innerHTML = html;
80+
81+
const newPre = temp.querySelector('pre');
82+
83+
if (newPre) {
84+
newPre.className = `${preElement.className} twoslash`;
85+
newPre.style = '';
86+
87+
preElement.parentNode?.replaceChild(newPre, preElement);
88+
}
89+
} catch (error) {
90+
console.error('Error processing Twoslash block:', error);
91+
}
92+
}
93+
94+
/**
95+
* Initialize Twoslash processing on page load
96+
*/
97+
async function initTwoslash() {
98+
const codeBlocks = document.querySelectorAll('pre');
99+
100+
const twoslashBlocks = Array.from(codeBlocks).filter(preElement =>
101+
preElement.querySelector('code')
102+
);
103+
104+
await Promise.all(twoslashBlocks.map(block => processTwoslashBlock(block)));
105+
}
106+
107+
initTwoslash();
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { readFile, writeFile } from 'node:fs/promises';
2+
import { join } from 'node:path';
3+
4+
import bundleCode from '../web/utils/bundle.mjs';
5+
6+
/**
7+
* Bundles the Twoslash client-side script and writes it as an external file
8+
*
9+
* @type {GeneratorMetadata<never, void>}
10+
*/
11+
export default {
12+
name: 'web-twoslash',
13+
version: '1.0.0',
14+
description: 'Generates client-side Twoslash script for web bundle',
15+
dependsOn: null,
16+
17+
/**
18+
* Generates the Twoslash client script with content hash
19+
*
20+
* @param {never} _ - No input needed
21+
* @param {Partial<GeneratorOptions>} options
22+
* @returns {Promise<void>}
23+
*/
24+
async generate(_, { output }) {
25+
// Read the client code from file
26+
const twoslashClientCode = await readFile(
27+
new URL('client.mjs', import.meta.url),
28+
'utf-8'
29+
);
30+
31+
// Bundle the code
32+
const { js } = await bundleCode(twoslashClientCode, {
33+
server: false,
34+
});
35+
36+
// Write the files if output directory is specified
37+
if (output) {
38+
await writeFile(join(output, 'twoslash.js'), js, 'utf-8');
39+
}
40+
},
41+
};

src/generators/web/index.mjs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { readFile, writeFile } from 'node:fs/promises';
22
import { createRequire } from 'node:module';
33
import { join } from 'node:path';
44

5+
import webTwoslashGenerator from '../web-twoslash/index.mjs';
56
import createASTBuilder from './utils/generate.mjs';
67
import { processJSXEntry } from './utils/processing.mjs';
78

@@ -26,21 +27,19 @@ export default {
2627
* @param {Partial<GeneratorOptions>} options
2728
*/
2829
async generate(entries, { output, version }) {
29-
// Load the HTML template.
30+
// Load the HTML template
3031
const template = await readFile(
3132
new URL('template.html', import.meta.url),
3233
'utf-8'
3334
);
3435

35-
// These builders are responsible for converting the JSX AST into executable
36-
// JavaScript code for both server-side rendering and client-side hydration.
3736
const astBuilders = createASTBuilder();
38-
39-
// This is necessary for the `executeServerCode` function to resolve modules
40-
// within the dynamically executed server-side code.
4137
const requireFn = createRequire(import.meta.url);
4238

39+
await webTwoslashGenerator.generate(null, { output });
40+
4341
const results = [];
42+
4443
let mainCss = '';
4544

4645
for (const entry of entries) {
@@ -49,11 +48,12 @@ export default {
4948
template,
5049
astBuilders,
5150
requireFn,
52-
version
51+
{ version }
5352
);
53+
5454
results.push({ html, css });
5555

56-
// Capture the main CSS bundle from the first processed entry.
56+
// Capture the main CSS bundle from the first processed entry
5757
if (!mainCss && css) {
5858
mainCss = css;
5959
}
@@ -64,9 +64,9 @@ export default {
6464
}
6565
}
6666

67+
// Write CSS file
6768
if (output && mainCss) {
68-
const filePath = join(output, 'styles.css');
69-
await writeFile(filePath, mainCss, 'utf-8');
69+
await writeFile(join(output, 'styles.css'), mainCss, 'utf-8');
7070
}
7171

7272
return results;

src/generators/web/template.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,6 @@
2020
<body>
2121
<div id="root">{{dehydrated}}</div>
2222
<script>{{clientBundleJs}}</script>
23+
<script type="module" src="twoslash.js?hash={{cacheHash}}"></script>
2324
</body>
2425
</html>

src/generators/web/ui/components/CodeBox.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export default ({ className, ...props }) => {
4545
onCopy={onCopy}
4646
language={getLanguageDisplayName(language)}
4747
{...props}
48+
className={className}
4849
buttonText="Copy to clipboard"
4950
/>
5051
);

src/generators/web/ui/index.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
@import '@node-core/ui-components/styles/index.css';
2+
@import '@shikijs/twoslash/style-rich.css';
23

34
/* Fonts */
45
:root {

src/generators/web/utils/processing.mjs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { randomUUID } from 'node:crypto';
2+
13
import HTMLMinifier from '@minify-html/node';
24
import { toJs, jsx } from 'estree-util-to-js';
35

@@ -36,7 +38,8 @@ export async function executeServerCode(serverCode, requireFn) {
3638
* @param {import('../jsx-ast/utils/buildContent.mjs').JSXContent} entry - The JSX AST entry to process.
3739
* @param {string} template - The HTML template string that serves as the base for the output page.
3840
* @param {ReturnType<import('./generate.mjs')>} astBuilders - The AST generators
39-
* @param {version} version - The version to generator the documentation for
41+
* @param {Object} options - Processing options
42+
* @param {string} options.version - The version to generate the documentation for
4043
* @param {ReturnType<import('node:module').createRequire>} requireFn - A Node.js `require` function.
4144
*/
4245
export async function processJSXEntry(
@@ -68,7 +71,8 @@ export async function processJSXEntry(
6871
const renderedHtml = template
6972
.replace('{{title}}', title)
7073
.replace('{{dehydrated}}', dehydrated ?? '')
71-
.replace('{{clientBundleJs}}', () => clientBundle.js);
74+
.replace('{{clientBundleJs}}', () => clientBundle.js)
75+
.replace('{{cacheHash}}', randomUUID());
7276

7377
// The input to `minify` must be a Buffer.
7478
const finalHTMLBuffer = HTMLMinifier.minify(Buffer.from(renderedHtml), {});

0 commit comments

Comments
 (0)