diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz
index 2624b15..0d32934 100644
Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ
diff --git a/examples/app-router/examples/highlight-example.mdx b/examples/app-router/examples/highlight-example.mdx
index 64bd2dc..a86398e 100644
--- a/examples/app-router/examples/highlight-example.mdx
+++ b/examples/app-router/examples/highlight-example.mdx
@@ -141,3 +141,33 @@ console.log('Hi! Shiki + Twoslash on CDN :)');
const count = ref(0);
// ^?
```
+
+## Link support
+
+```js Link Testing icon="js" lines mint-twoslash
+import { useEffect, useState } from 'react';
+
+// @link Component
+export function Component() {
+ // ^?
+ return
{count}
;
+}
+
+// @link OtherFunction: #hola-there
+export function OtherFunction() {
+ // ^?
+ return {count}
;
+}
+
+// @link ExternalLink: https://google.com
+export function ExternalLink() {
+ // ^?
+ const str =
+ "Don't worry, only hover targets with ExternalLink will be affected, not random strings";
+ return {count}
;
+}
+```
+
+### Component
+
+Hello world from the `Component` section
diff --git a/examples/pages-router/examples/highlight-example.mdx b/examples/pages-router/examples/highlight-example.mdx
index 64bd2dc..a86398e 100644
--- a/examples/pages-router/examples/highlight-example.mdx
+++ b/examples/pages-router/examples/highlight-example.mdx
@@ -141,3 +141,33 @@ console.log('Hi! Shiki + Twoslash on CDN :)');
const count = ref(0);
// ^?
```
+
+## Link support
+
+```js Link Testing icon="js" lines mint-twoslash
+import { useEffect, useState } from 'react';
+
+// @link Component
+export function Component() {
+ // ^?
+ return {count}
;
+}
+
+// @link OtherFunction: #hola-there
+export function OtherFunction() {
+ // ^?
+ return {count}
;
+}
+
+// @link ExternalLink: https://google.com
+export function ExternalLink() {
+ // ^?
+ const str =
+ "Don't worry, only hover targets with ExternalLink will be affected, not random strings";
+ return {count}
;
+}
+```
+
+### Component
+
+Hello world from the `Component` section
diff --git a/packages/mdx/package.json b/packages/mdx/package.json
index e3e1ae7..c6dd4ad 100644
--- a/packages/mdx/package.json
+++ b/packages/mdx/package.json
@@ -1,6 +1,6 @@
{
"name": "@mintlify/mdx",
- "version": "2.0.6",
+ "version": "2.0.7",
"description": "Markdown parser from Mintlify",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
diff --git a/packages/mdx/src/plugins/rehype/rehypeSyntaxHighlighting.ts b/packages/mdx/src/plugins/rehype/rehypeSyntaxHighlighting.ts
index 226c127..d803001 100644
--- a/packages/mdx/src/plugins/rehype/rehypeSyntaxHighlighting.ts
+++ b/packages/mdx/src/plugins/rehype/rehypeSyntaxHighlighting.ts
@@ -1,15 +1,8 @@
-import {
- createTransformerFactory,
- rendererRich,
- transformerTwoslash,
- type TransformerTwoslashOptions,
-} from '@shikijs/twoslash';
+import { transformerTwoslash } from '@shikijs/twoslash';
import type { Element, Root } from 'hast';
import { toString } from 'hast-util-to-string';
import type { MdxJsxFlowElementHast, MdxJsxTextElementHast } from 'mdast-util-mdx-jsx';
import { createHighlighter, type Highlighter } from 'shiki';
-import { createTwoslashFromCDN } from 'twoslash-cdn';
-import ts from 'typescript';
import type { Plugin } from 'unified';
import { visit } from 'unist-util-visit';
@@ -27,34 +20,19 @@ import {
DEFAULT_LANGS,
SHIKI_TRANSFORMERS,
} from './shiki-constants.js';
+import {
+ cdnTransformerTwoslash,
+ cdnTwoslash,
+ getTwoslashOptions,
+ parseLineComment,
+} from './twoslash/config.js';
import { getLanguage } from './utils.js';
-const twoslashCompilerOptions = {
- target: ts.ScriptTarget.ESNext,
- lib: ['ESNext', 'DOM', 'esnext', 'dom', 'es2020'],
-};
-
-const twoslashOptions: TransformerTwoslashOptions = {
- onTwoslashError(err, code, lang) {
- console.error(JSON.stringify({ err, code, lang }));
- },
- onShikiError(err, code, lang) {
- console.error(JSON.stringify({ err, code, lang }));
- },
- renderer: rendererRich(),
- langs: ['ts', 'typescript', 'js', 'javascript', 'tsx', 'jsx'],
- explicitTrigger: /mint-twoslash/,
- twoslashOptions: { compilerOptions: twoslashCompilerOptions },
-};
-
-const cdnTwoslash = createTwoslashFromCDN({ compilerOptions: twoslashCompilerOptions });
-
-const cdnTransformerTwoslash = createTransformerFactory(cdnTwoslash.runSync);
-
export type RehypeSyntaxHighlightingOptions = {
theme?: ShikiTheme;
themes?: Record<'light' | 'dark', ShikiTheme>;
codeStyling?: 'dark' | 'system';
+ linkMap?: Map;
};
let highlighterPromise: Promise | null = null;
@@ -73,7 +51,8 @@ export const rehypeSyntaxHighlighting: Plugin<[RehypeSyntaxHighlightingOptions?]
options = {}
) => {
return async (tree) => {
- const asyncNodesToProcess: Promise[] = [];
+ const nodesToProcess: Promise[] = [];
+
const themesToLoad: ShikiTheme[] = [];
if (options.themes) {
themesToLoad.push(options.themes.dark);
@@ -120,32 +99,35 @@ export const rehypeSyntaxHighlighting: Plugin<[RehypeSyntaxHighlightingOptions?]
getLanguage(child, DEFAULT_LANG_ALIASES) ??
DEFAULT_LANG;
- asyncNodesToProcess.push(
+ nodesToProcess.push(
(async () => {
await cdnTwoslash.prepareTypes(toString(node));
- if (!DEFAULT_LANGS.includes(lang)) {
- await highlighter.loadLanguage(lang);
- traverseNode(node, index, parent, highlighter, lang, options);
- } else {
- traverseNode(node, index, parent, highlighter, lang, options);
- }
+ if (!DEFAULT_LANGS.includes(lang)) await highlighter.loadLanguage(lang);
+ traverseNode({ node, index, parent, highlighter, lang, options });
})()
);
});
- await Promise.all(asyncNodesToProcess);
+ await Promise.all(nodesToProcess);
};
};
-const traverseNode = (
- node: Element,
- index: number,
- parent: Element | Root | MdxJsxTextElementHast | MdxJsxFlowElementHast,
- highlighter: Highlighter,
- lang: ShikiLang,
- options: RehypeSyntaxHighlightingOptions
-) => {
+function traverseNode({
+ node,
+ index,
+ parent,
+ highlighter,
+ lang,
+ options,
+}: {
+ node: Element;
+ index: number;
+ parent: Element | Root | MdxJsxTextElementHast | MdxJsxFlowElementHast;
+ highlighter: Highlighter;
+ lang: ShikiLang;
+ options: RehypeSyntaxHighlightingOptions;
+}) {
try {
- const code = toString(node);
+ let code = toString(node);
const meta = node.data?.meta?.split(' ') ?? [];
const twoslashIndex = meta.findIndex((str) => str.toLowerCase() === 'mint-twoslash');
@@ -156,6 +138,20 @@ const traverseNode = (
node.data.meta = meta.join(' ').trim() || undefined;
}
+ const linkMap = options.linkMap ?? new Map();
+ const splitCode = code.split('\n');
+ for (const [i, line] of splitCode.entries()) {
+ const parsedLineComment = parseLineComment(line);
+ if (!parsedLineComment) continue;
+ const { word, href } = parsedLineComment;
+ linkMap.set(word, href);
+ splitCode.splice(i, 1);
+ }
+
+ code = splitCode.join('\n');
+
+ const twoslashOptions = getTwoslashOptions({ linkMap });
+
const hast = highlighter.codeToHast(code, {
lang: lang ?? DEFAULT_LANG,
meta: shouldUseTwoslash ? { __raw: 'mint-twoslash' } : undefined,
@@ -195,6 +191,6 @@ const traverseNode = (
}
throw err;
}
-};
+}
export { UNIQUE_LANGS, DEFAULT_LANG_ALIASES, SHIKI_THEMES, ShikiLang, ShikiTheme };
diff --git a/packages/mdx/src/plugins/rehype/twoslash/config.ts b/packages/mdx/src/plugins/rehype/twoslash/config.ts
new file mode 100644
index 0000000..f24ac44
--- /dev/null
+++ b/packages/mdx/src/plugins/rehype/twoslash/config.ts
@@ -0,0 +1,110 @@
+import {
+ createTransformerFactory,
+ rendererRich,
+ type TransformerTwoslashOptions,
+} from '@shikijs/twoslash';
+import type { ElementContent } from 'hast';
+import type { ShikiTransformer } from 'shiki/types';
+import { createTwoslashFromCDN, type TwoslashCdnReturn } from 'twoslash-cdn';
+import ts from 'typescript';
+
+type TransformerFactory = (options?: TransformerTwoslashOptions) => ShikiTransformer;
+
+const twoslashCompilerOptions: ts.CompilerOptions = {
+ target: ts.ScriptTarget.ESNext,
+ lib: ['ESNext', 'DOM', 'esnext', 'dom', 'es2020'],
+};
+
+export const cdnTwoslash: TwoslashCdnReturn = createTwoslashFromCDN({
+ compilerOptions: twoslashCompilerOptions,
+});
+export const cdnTransformerTwoslash: TransformerFactory = createTransformerFactory(
+ cdnTwoslash.runSync
+);
+
+function onTwoslashError(err: unknown, code: string, lang: string) {
+ console.error(JSON.stringify({ err, code, lang }));
+}
+
+function onShikiError(err: unknown, code: string, lang: string) {
+ console.error(JSON.stringify({ err, code, lang }));
+}
+
+export function getTwoslashOptions(
+ { linkMap }: { linkMap: Map } = { linkMap: new Map() }
+): TransformerTwoslashOptions {
+ return {
+ onTwoslashError,
+ onShikiError,
+ renderer: rendererRich({
+ hast: {
+ hoverToken: {
+ children(input) {
+ for (const rootElement of input) {
+ if (!('children' in rootElement)) continue;
+ for (const [i, element] of rootElement.children.entries()) {
+ if (element.type !== 'text') continue;
+ const href = linkMap.get(element.value);
+ if (!href) continue;
+ const newElement: ElementContent = {
+ type: 'element',
+ tagName: 'a',
+ properties: {
+ href,
+ ...(checkIsExternalLink(href) && {
+ target: '_blank',
+ rel: 'noopener noreferrer',
+ }),
+ },
+ children: [{ type: 'text', value: element.value }],
+ };
+ input.splice(i, 1, newElement);
+ }
+ }
+ return input;
+ },
+ },
+ },
+ }),
+ langs: ['ts', 'typescript', 'js', 'javascript', 'tsx', 'jsx'],
+ explicitTrigger: /mint-twoslash/,
+ twoslashOptions: {
+ compilerOptions: twoslashCompilerOptions,
+ },
+ };
+}
+
+export function parseLineComment(line: string): { word: string; href: string } | undefined {
+ line = line.trim();
+ if (!line.startsWith('//') || (!line.includes('@link ') && !line.includes('@link:'))) return;
+
+ line = line.replace('@link:', '@link ');
+ const parts = line.split('@link ')[1];
+ if (!parts) return;
+
+ const words = parts.split(' ').filter(Boolean);
+ if (words.length === 1 && words[0]) {
+ let word = words[0];
+ if (word.endsWith(':')) word = word.slice(0, -1);
+ const lowercaseWord = word.toLowerCase();
+ const href = word.startsWith('#') ? lowercaseWord : `#${encodeURIComponent(lowercaseWord)}`;
+ return { word, href };
+ } else if (words.length === 2 && words[0] && words[1]) {
+ let word = words[0];
+ if (word.endsWith(':')) word = word.slice(0, -1);
+ const href = words[1];
+ if (!href.startsWith('#') && !href.startsWith('https://')) return;
+ return { word, href };
+ }
+
+ return;
+}
+
+type Url = `https://${string}`;
+function checkIsExternalLink(href: string | undefined): href is Url {
+ let isExternalLink = false;
+ try {
+ if (href && URL.canParse(href)) isExternalLink = true;
+ } catch {}
+ return isExternalLink;
+}