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
30 changes: 30 additions & 0 deletions apps/site/mdx/create-vfs-twoslasher.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
'use strict';

/**
* 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`.
*/
export async function createVfsTwoslasher() {
const { createTwoslasher } = await import('twoslash/core');
const ts = (await import('typescript')).default;
const fsMapJson = (
await import('../generated/twoslash-fsmap.json', { with: { type: 'json' } })
).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'],
},
});
}
12 changes: 10 additions & 2 deletions apps/site/mdx/plugins.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import rehypeSlug from 'rehype-slug';
import remarkGfm from 'remark-gfm';
import readingTime from 'remark-reading-time';

import { createVfsTwoslasher } from './create-vfs-twoslasher.mjs';
import remarkTableTitles from '../util/table';

// TODO(@avivkeller): When available, use `OPEN_NEXT_CLOUDFLARE` environment
Expand All @@ -25,8 +26,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
4 changes: 3 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 Expand Up @@ -47,6 +48,7 @@
"@tailwindcss/postcss": "~4.3.0",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@typescript/vfs": "^1.6.4",
"@vcarl/remark-headings": "~0.1.0",
"@vercel/analytics": "~2.0.1",
"@vercel/otel": "~2.1.2",
Expand Down
66 changes: 66 additions & 0 deletions apps/site/scripts/twoslash-fsmap/generate.mjs
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we really need any of this? I'm fairly sue twoslash supports using vfs out of the box? Since they have this CDN thing https://twoslash.netlify.app/packages/cdn (for example)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mh... that is interesting, however I am a bit worried on moving this to the runtime since it would add network calls for fetching from the CDN increasing cold starts 😕

Are we sure that we'd be happy with such tradeoff?

Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
'use strict';

import { readdirSync, readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { join, resolve } from 'node:path';

import { createDefaultMapFromNodeModules } from '@typescript/vfs';
import ts from 'typescript';

const require = createRequire(import.meta.url);

/**
* Recursively collects all `.d.ts` files from a directory into the fsMap.
*
* @param {Map<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.set(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 {Map<string, string>} A map of virtual paths to file contents
*/
export default function generateTwoslashFsMap() {
// 1. Collect TypeScript lib .d.ts files using @typescript/vfs
// This returns a Map keyed as "/lib.es5.d.ts", "/lib.dom.d.ts", etc.
const fsMap = createDefaultMapFromNodeModules({}, ts);

// 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(Object.fromEntries(fsMap)), 'utf8');
8 changes: 6 additions & 2 deletions apps/site/turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,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 @@ -137,8 +137,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.
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading