From 841a898b5cbb82930d1a1e2d4fe6a7feddf2621b Mon Sep 17 00:00:00 2001 From: Dario Piotrowicz Date: Wed, 22 Apr 2026 22:35:16 +0100 Subject: [PATCH] feat: enable Twoslash on Cloudflare --- .gitignore | 3 + apps/site/mdx/plugins.mjs | 42 +++++++- apps/site/package.json | 3 +- apps/site/scripts/twoslash-fsmap/generate.mjs | 73 ++++++++++++++ apps/site/scripts/twoslash-fsmap/index.mjs | 15 +++ apps/site/turbo.json | 8 +- .../src/transformers/twoslash/index.mjs | 95 ++++++++++++------- 7 files changed, 199 insertions(+), 40 deletions(-) create mode 100644 apps/site/scripts/twoslash-fsmap/generate.mjs create mode 100644 apps/site/scripts/twoslash-fsmap/index.mjs diff --git a/.gitignore b/.gitignore index f461599c85893..d5f3106348e6b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ apps/site/build apps/site/public/blog-data.json apps/site/next-env.d.ts +# Generated Build Artifacts +apps/site/generated + # Test Runner junit.xml lcov.info diff --git a/apps/site/mdx/plugins.mjs b/apps/site/mdx/plugins.mjs index 02166643aaf26..578e198292113 100644 --- a/apps/site/mdx/plugins.mjs +++ b/apps/site/mdx/plugins.mjs @@ -15,6 +15,37 @@ import remarkTableTitles from '../util/table'; // Reference: https://github.com/nodejs/nodejs.org/pull/7896#issuecomment-3009480615 const OPEN_NEXT_CLOUDFLARE = 'Cloudflare' in global; +/** + * Creates a Twoslash instance backed by a virtual filesystem for environments + * without real filesystem access (e.g. Cloudflare Workers). + * + * Uses a pre-built JSON map of TypeScript lib declarations and @types/node + * generated at build time by `scripts/twoslash-fsmap/index.mjs`. + */ +async function createVfsTwoslasher() { + const [{ createTwoslasher }, ts, fsMapJson] = await Promise.all([ + import('twoslash/core'), + import('typescript').then(m => m.default), + import('../generated/twoslash-fsmap.json', { with: { type: 'json' } }).then( + m => m.default + ), + ]); + + const fsMap = new Map(Object.entries(fsMapJson)); + + return createTwoslasher({ + fsMap, + tsModule: ts, + vfsRoot: '/', + compilerOptions: { + moduleResolution: ts.ModuleResolutionKind.Bundler, + // Explicitly include @types/node so that the VFS resolves Node.js + // globals and `node:*` module imports from the bundled declarations. + types: ['node'], + }, + }); +} + // Shiki is created out here to avoid an async rehype plugin const singletonShiki = await rehypeShikiji({ // We use the faster WASM engine on the server instead of the web-optimized version. @@ -25,8 +56,15 @@ const singletonShiki = await rehypeShikiji({ // for security reasons. wasm: !OPEN_NEXT_CLOUDFLARE, - // TODO(@avivkeller): Find a way to enable Twoslash w/ a VFS on Cloudflare - twoslash: !OPEN_NEXT_CLOUDFLARE, + twoslash: true, + + // On Cloudflare Workers, the default filesystem-backed Twoslash cannot work + // because there is no real filesystem. Instead, we provide a custom twoslasher + // backed by an in-memory VFS pre-populated at build time with TypeScript + // lib declarations and @types/node. + twoslashOptions: OPEN_NEXT_CLOUDFLARE + ? { twoslasher: await createVfsTwoslasher() } + : undefined, }); /** diff --git a/apps/site/package.json b/apps/site/package.json index 9158a7d4620f0..d6b49a2abaafc 100644 --- a/apps/site/package.json +++ b/apps/site/package.json @@ -2,10 +2,11 @@ "name": "@node-core/website", "type": "module", "scripts": { - "prebuild": "node --run build:blog-data", + "prebuild": "node --run build:blog-data && node --run build:twoslash-fsmap", "build": "cross-env NODE_NO_WARNINGS=1 next build", "build:blog-data": "cross-env NODE_NO_WARNINGS=1 node ./scripts/blog-data/index.mjs", "build:blog-data:watch": "node --watch --watch-path=pages/en/blog ./scripts/blog-data/index.mjs", + "build:twoslash-fsmap": "node ./scripts/twoslash-fsmap/index.mjs", "cloudflare:build:worker": "OPEN_NEXT_CLOUDFLARE=true opennextjs-cloudflare build", "cloudflare:deploy": "opennextjs-cloudflare deploy", "cloudflare:preview": "wrangler dev", diff --git a/apps/site/scripts/twoslash-fsmap/generate.mjs b/apps/site/scripts/twoslash-fsmap/generate.mjs new file mode 100644 index 0000000000000..797f64860c5e3 --- /dev/null +++ b/apps/site/scripts/twoslash-fsmap/generate.mjs @@ -0,0 +1,73 @@ +'use strict'; + +import { readdirSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join, resolve } from 'node:path'; + +const require = createRequire(import.meta.url); + +/** + * Recursively collects all `.d.ts` files from a directory into the fsMap. + * + * @param {Record} fsMap The map to populate + * @param {string} dir The directory to walk + * @param {string} virtualPrefix The virtual path prefix (e.g., "/node_modules/@types/node") + * @param {string} baseDir The base directory for computing relative paths + */ +function collectDtsFiles(fsMap, dir, virtualPrefix, baseDir) { + const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => + a.name.localeCompare(b.name) + ); + + for (const entry of entries) { + const fullPath = join(dir, entry.name); + + if (entry.isDirectory()) { + collectDtsFiles(fsMap, fullPath, virtualPrefix, baseDir); + } else if (entry.isFile() && /\.d\.([^.]+\.)?[cm]?ts$/i.test(entry.name)) { + const relativePath = fullPath.slice(baseDir.length).replace(/\\/g, '/'); + const virtualPath = `${virtualPrefix}${relativePath}`; + + fsMap[virtualPath] = readFileSync(fullPath, 'utf8'); + } + } +} + +/** + * Generates a virtual filesystem map containing all TypeScript library + * declaration files and `@types/node` declarations needed for Twoslash + * to run without real filesystem access (e.g., on Cloudflare Workers). + * + * @returns {Record} A map of virtual paths to file contents + */ +export default function generateTwoslashFsMap() { + const fsMap = {}; + + // 1. Collect TypeScript lib .d.ts files + // These are keyed as "/lib.es5.d.ts", "/lib.dom.d.ts", etc. + // (matching the convention used by @typescript/vfs) + const tsLibDir = dirname(require.resolve('typescript/lib/lib.d.ts')); + const tsLibFiles = readdirSync(tsLibDir) + .filter(f => f.startsWith('lib.') && /\.d\.([^.]+\.)?[cm]?ts$/i.test(f)) + .sort(); + + for (const file of tsLibFiles) { + fsMap[`/${file}`] = readFileSync(join(tsLibDir, file), 'utf8'); + } + + // 2. Collect @types/node .d.ts files + // These are keyed as "/node_modules/@types/node/index.d.ts", etc. + const typesNodeDir = resolve( + require.resolve('@types/node/package.json'), + '..' + ); + + collectDtsFiles( + fsMap, + typesNodeDir, + '/node_modules/@types/node', + typesNodeDir + ); + + return fsMap; +} diff --git a/apps/site/scripts/twoslash-fsmap/index.mjs b/apps/site/scripts/twoslash-fsmap/index.mjs new file mode 100644 index 0000000000000..1509a09f4ccb7 --- /dev/null +++ b/apps/site/scripts/twoslash-fsmap/index.mjs @@ -0,0 +1,15 @@ +'use strict'; + +import { mkdirSync, writeFileSync } from 'node:fs'; + +import generateTwoslashFsMap from './generate.mjs'; + +const fsMap = generateTwoslashFsMap(); + +const outputPath = new URL( + '../../generated/twoslash-fsmap.json', + import.meta.url +); + +mkdirSync(new URL('.', outputPath), { recursive: true }); +writeFileSync(outputPath, JSON.stringify(fsMap), 'utf8'); diff --git a/apps/site/turbo.json b/apps/site/turbo.json index e08e9169e7a11..cb6e8838f13b5 100644 --- a/apps/site/turbo.json +++ b/apps/site/turbo.json @@ -24,7 +24,7 @@ ] }, "build": { - "dependsOn": ["build:blog-data", "^build"], + "dependsOn": ["build:blog-data", "build:twoslash-fsmap", "^build"], "inputs": [ "{app,components,hooks,i18n,layouts,middlewares,pages,providers,types,util}/**/*.{ts,tsx}", "{app,components,layouts,pages,styles}/**/*.css", @@ -145,8 +145,12 @@ "ENABLE_EXPERIMENTAL_COREPACK" ] }, + "build:twoslash-fsmap": { + "inputs": ["scripts/twoslash-fsmap/**", "../../pnpm-lock.yaml"], + "outputs": ["generated/twoslash-fsmap.json"] + }, "cloudflare:build:worker": { - "dependsOn": ["build:blog-data"], + "dependsOn": ["build:blog-data", "build:twoslash-fsmap"], "inputs": [ "{app,components,hooks,i18n,layouts,middlewares,pages,providers,types,util}/**/*.{ts,tsx}", "{app,components,layouts,pages,styles}/**/*.css", diff --git a/packages/rehype-shiki/src/transformers/twoslash/index.mjs b/packages/rehype-shiki/src/transformers/twoslash/index.mjs index 2a6f347a04d0e..0d3be5b76a1c1 100644 --- a/packages/rehype-shiki/src/transformers/twoslash/index.mjs +++ b/packages/rehype-shiki/src/transformers/twoslash/index.mjs @@ -1,4 +1,8 @@ -import { transformerTwoslash } from '@shikijs/twoslash'; +import { + createTransformerFactory, + rendererRich, + transformerTwoslash, +} from '@shikijs/twoslash'; const compose = ({ token, cursor, popup }) => [ { @@ -10,39 +14,60 @@ const compose = ({ token, cursor, popup }) => [ popup, ]; -export const twoslash = (options = {}) => - transformerTwoslash({ - langs: ['ts', 'js', 'cjs', 'mjs'], - rendererRich: { - jsdoc: false, - hast: { - hoverToken: { tagName: 'MDXTooltip' }, - hoverPopup: { tagName: 'MDXTooltipContent' }, - hoverCompose: compose, - - queryToken: { tagName: 'MDXTooltip' }, - queryPopup: { tagName: 'MDXTooltipContent' }, - queryCompose: compose, - - errorToken: { tagName: 'MDXTooltip' }, - errorPopup: { tagName: 'MDXTooltipContent' }, - errorCompose: compose, - - completionToken: { - tagName: 'MDXTooltip', - properties: { - open: true, - }, - }, - completionPopup: { - tagName: 'MDXTooltipContent', - properties: { - align: 'start', - }, - }, - completionCompose: compose, +const rendererOptions = { + jsdoc: false, + hast: { + hoverToken: { tagName: 'MDXTooltip' }, + hoverPopup: { tagName: 'MDXTooltipContent' }, + hoverCompose: compose, + + queryToken: { tagName: 'MDXTooltip' }, + queryPopup: { tagName: 'MDXTooltipContent' }, + queryCompose: compose, + + errorToken: { tagName: 'MDXTooltip' }, + errorPopup: { tagName: 'MDXTooltipContent' }, + errorCompose: compose, + + completionToken: { + tagName: 'MDXTooltip', + properties: { + open: true, + }, + }, + completionPopup: { + tagName: 'MDXTooltipContent', + properties: { + align: 'start', }, }, - throws: false, - ...options, - }); + completionCompose: compose, + }, +}; + +const transformerOptions = { + langs: ['ts', 'js', 'cjs', 'mjs'], + rendererRich: rendererOptions, + throws: false, +}; + +/** + * Creates the Twoslash Shiki transformer. + * + * When `options.twoslasher` is provided, uses `createTransformerFactory` + * directly to avoid importing the default Node.js-dependent twoslasher from + * `twoslash`. This is needed for environments like Cloudflare Workers where + * the filesystem-backed default twoslasher cannot be used. + * + * @param {import('@shikijs/twoslash').TransformerTwoslashIndexOptions} [options] + */ +export const twoslash = (options = {}) => { + if (options.twoslasher) { + return createTransformerFactory( + options.twoslasher, + rendererRich(rendererOptions) + )({ ...transformerOptions, ...options }); + } + + return transformerTwoslash({ ...transformerOptions, ...options }); +};