Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 40 additions & 2 deletions apps/site/mdx/plugins.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
Comment thread
dario-piotrowicz marked this conversation as resolved.
});
}

// 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.
Expand All @@ -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,
});

/**
Expand Down
3 changes: 2 additions & 1 deletion apps/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
73 changes: 73 additions & 0 deletions apps/site/scripts/twoslash-fsmap/generate.mjs
Original file line number Diff line number Diff line change
@@ -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<string, string>} 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<string, string>} 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;
}
15 changes: 15 additions & 0 deletions apps/site/scripts/twoslash-fsmap/index.mjs
Original file line number Diff line number Diff line change
@@ -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
);
Comment thread
dario-piotrowicz marked this conversation as resolved.

mkdirSync(new URL('.', outputPath), { recursive: true });
writeFileSync(outputPath, JSON.stringify(fsMap), 'utf8');
8 changes: 6 additions & 2 deletions apps/site/turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
95 changes: 60 additions & 35 deletions packages/rehype-shiki/src/transformers/twoslash/index.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { transformerTwoslash } from '@shikijs/twoslash';
import {
createTransformerFactory,
rendererRich,
transformerTwoslash,
} from '@shikijs/twoslash';

const compose = ({ token, cursor, popup }) => [
{
Expand All @@ -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 });
Comment thread
dario-piotrowicz marked this conversation as resolved.
}

return transformerTwoslash({ ...transformerOptions, ...options });
};
Comment thread
dario-piotrowicz marked this conversation as resolved.
Loading