Skip to content

Commit bdee76b

Browse files
Merge pull request #29 from mintlify/ricardo/eng-4258-add-link-support-to-twoslash-code-snippets
Support links with custom '// @link' syntax
2 parents c8045a8 + 7e5b177 commit bdee76b

File tree

6 files changed

+216
-50
lines changed

6 files changed

+216
-50
lines changed

.yarn/install-state.gz

-1 Bytes
Binary file not shown.

examples/app-router/examples/highlight-example.mdx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,33 @@ console.log('Hi! Shiki + Twoslash on CDN :)');
141141
const count = ref(0);
142142
// ^?
143143
```
144+
145+
## Link support
146+
147+
```js Link Testing icon="js" lines mint-twoslash
148+
import { useEffect, useState } from 'react';
149+
150+
// @link Component
151+
export function Component() {
152+
// ^?
153+
return <div>{count}</div>;
154+
}
155+
156+
// @link OtherFunction: #hola-there
157+
export function OtherFunction() {
158+
// ^?
159+
return <div>{count}</div>;
160+
}
161+
162+
// @link ExternalLink: https://google.com
163+
export function ExternalLink() {
164+
// ^?
165+
const str =
166+
"Don't worry, only hover targets with ExternalLink will be affected, not random strings";
167+
return <div>{count}</div>;
168+
}
169+
```
170+
171+
### Component
172+
173+
Hello world from the `Component` section

examples/pages-router/examples/highlight-example.mdx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,33 @@ console.log('Hi! Shiki + Twoslash on CDN :)');
141141
const count = ref(0);
142142
// ^?
143143
```
144+
145+
## Link support
146+
147+
```js Link Testing icon="js" lines mint-twoslash
148+
import { useEffect, useState } from 'react';
149+
150+
// @link Component
151+
export function Component() {
152+
// ^?
153+
return <div>{count}</div>;
154+
}
155+
156+
// @link OtherFunction: #hola-there
157+
export function OtherFunction() {
158+
// ^?
159+
return <div>{count}</div>;
160+
}
161+
162+
// @link ExternalLink: https://google.com
163+
export function ExternalLink() {
164+
// ^?
165+
const str =
166+
"Don't worry, only hover targets with ExternalLink will be affected, not random strings";
167+
return <div>{count}</div>;
168+
}
169+
```
170+
171+
### Component
172+
173+
Hello world from the `Component` section

packages/mdx/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@mintlify/mdx",
3-
"version": "2.0.6",
3+
"version": "2.0.7",
44
"description": "Markdown parser from Mintlify",
55
"main": "./dist/index.js",
66
"types": "./dist/index.d.ts",

packages/mdx/src/plugins/rehype/rehypeSyntaxHighlighting.ts

Lines changed: 45 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,8 @@
1-
import {
2-
createTransformerFactory,
3-
rendererRich,
4-
transformerTwoslash,
5-
type TransformerTwoslashOptions,
6-
} from '@shikijs/twoslash';
1+
import { transformerTwoslash } from '@shikijs/twoslash';
72
import type { Element, Root } from 'hast';
83
import { toString } from 'hast-util-to-string';
94
import type { MdxJsxFlowElementHast, MdxJsxTextElementHast } from 'mdast-util-mdx-jsx';
105
import { createHighlighter, type Highlighter } from 'shiki';
11-
import { createTwoslashFromCDN } from 'twoslash-cdn';
12-
import ts from 'typescript';
136
import type { Plugin } from 'unified';
147
import { visit } from 'unist-util-visit';
158

@@ -27,34 +20,19 @@ import {
2720
DEFAULT_LANGS,
2821
SHIKI_TRANSFORMERS,
2922
} from './shiki-constants.js';
23+
import {
24+
cdnTransformerTwoslash,
25+
cdnTwoslash,
26+
getTwoslashOptions,
27+
parseLineComment,
28+
} from './twoslash/config.js';
3029
import { getLanguage } from './utils.js';
3130

32-
const twoslashCompilerOptions = {
33-
target: ts.ScriptTarget.ESNext,
34-
lib: ['ESNext', 'DOM', 'esnext', 'dom', 'es2020'],
35-
};
36-
37-
const twoslashOptions: TransformerTwoslashOptions = {
38-
onTwoslashError(err, code, lang) {
39-
console.error(JSON.stringify({ err, code, lang }));
40-
},
41-
onShikiError(err, code, lang) {
42-
console.error(JSON.stringify({ err, code, lang }));
43-
},
44-
renderer: rendererRich(),
45-
langs: ['ts', 'typescript', 'js', 'javascript', 'tsx', 'jsx'],
46-
explicitTrigger: /mint-twoslash/,
47-
twoslashOptions: { compilerOptions: twoslashCompilerOptions },
48-
};
49-
50-
const cdnTwoslash = createTwoslashFromCDN({ compilerOptions: twoslashCompilerOptions });
51-
52-
const cdnTransformerTwoslash = createTransformerFactory(cdnTwoslash.runSync);
53-
5431
export type RehypeSyntaxHighlightingOptions = {
5532
theme?: ShikiTheme;
5633
themes?: Record<'light' | 'dark', ShikiTheme>;
5734
codeStyling?: 'dark' | 'system';
35+
linkMap?: Map<string, string>;
5836
};
5937

6038
let highlighterPromise: Promise<Highlighter> | null = null;
@@ -73,7 +51,8 @@ export const rehypeSyntaxHighlighting: Plugin<[RehypeSyntaxHighlightingOptions?]
7351
options = {}
7452
) => {
7553
return async (tree) => {
76-
const asyncNodesToProcess: Promise<void>[] = [];
54+
const nodesToProcess: Promise<void>[] = [];
55+
7756
const themesToLoad: ShikiTheme[] = [];
7857
if (options.themes) {
7958
themesToLoad.push(options.themes.dark);
@@ -120,32 +99,35 @@ export const rehypeSyntaxHighlighting: Plugin<[RehypeSyntaxHighlightingOptions?]
12099
getLanguage(child, DEFAULT_LANG_ALIASES) ??
121100
DEFAULT_LANG;
122101

123-
asyncNodesToProcess.push(
102+
nodesToProcess.push(
124103
(async () => {
125104
await cdnTwoslash.prepareTypes(toString(node));
126-
if (!DEFAULT_LANGS.includes(lang)) {
127-
await highlighter.loadLanguage(lang);
128-
traverseNode(node, index, parent, highlighter, lang, options);
129-
} else {
130-
traverseNode(node, index, parent, highlighter, lang, options);
131-
}
105+
if (!DEFAULT_LANGS.includes(lang)) await highlighter.loadLanguage(lang);
106+
traverseNode({ node, index, parent, highlighter, lang, options });
132107
})()
133108
);
134109
});
135-
await Promise.all(asyncNodesToProcess);
110+
await Promise.all(nodesToProcess);
136111
};
137112
};
138113

139-
const traverseNode = (
140-
node: Element,
141-
index: number,
142-
parent: Element | Root | MdxJsxTextElementHast | MdxJsxFlowElementHast,
143-
highlighter: Highlighter,
144-
lang: ShikiLang,
145-
options: RehypeSyntaxHighlightingOptions
146-
) => {
114+
function traverseNode({
115+
node,
116+
index,
117+
parent,
118+
highlighter,
119+
lang,
120+
options,
121+
}: {
122+
node: Element;
123+
index: number;
124+
parent: Element | Root | MdxJsxTextElementHast | MdxJsxFlowElementHast;
125+
highlighter: Highlighter;
126+
lang: ShikiLang;
127+
options: RehypeSyntaxHighlightingOptions;
128+
}) {
147129
try {
148-
const code = toString(node);
130+
let code = toString(node);
149131

150132
const meta = node.data?.meta?.split(' ') ?? [];
151133
const twoslashIndex = meta.findIndex((str) => str.toLowerCase() === 'mint-twoslash');
@@ -156,6 +138,20 @@ const traverseNode = (
156138
node.data.meta = meta.join(' ').trim() || undefined;
157139
}
158140

141+
const linkMap = options.linkMap ?? new Map();
142+
const splitCode = code.split('\n');
143+
for (const [i, line] of splitCode.entries()) {
144+
const parsedLineComment = parseLineComment(line);
145+
if (!parsedLineComment) continue;
146+
const { word, href } = parsedLineComment;
147+
linkMap.set(word, href);
148+
splitCode.splice(i, 1);
149+
}
150+
151+
code = splitCode.join('\n');
152+
153+
const twoslashOptions = getTwoslashOptions({ linkMap });
154+
159155
const hast = highlighter.codeToHast(code, {
160156
lang: lang ?? DEFAULT_LANG,
161157
meta: shouldUseTwoslash ? { __raw: 'mint-twoslash' } : undefined,
@@ -195,6 +191,6 @@ const traverseNode = (
195191
}
196192
throw err;
197193
}
198-
};
194+
}
199195

200196
export { UNIQUE_LANGS, DEFAULT_LANG_ALIASES, SHIKI_THEMES, ShikiLang, ShikiTheme };
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import {
2+
createTransformerFactory,
3+
rendererRich,
4+
type TransformerTwoslashOptions,
5+
} from '@shikijs/twoslash';
6+
import type { ElementContent } from 'hast';
7+
import type { ShikiTransformer } from 'shiki/types';
8+
import { createTwoslashFromCDN, type TwoslashCdnReturn } from 'twoslash-cdn';
9+
import ts from 'typescript';
10+
11+
type TransformerFactory = (options?: TransformerTwoslashOptions) => ShikiTransformer;
12+
13+
const twoslashCompilerOptions: ts.CompilerOptions = {
14+
target: ts.ScriptTarget.ESNext,
15+
lib: ['ESNext', 'DOM', 'esnext', 'dom', 'es2020'],
16+
};
17+
18+
export const cdnTwoslash: TwoslashCdnReturn = createTwoslashFromCDN({
19+
compilerOptions: twoslashCompilerOptions,
20+
});
21+
export const cdnTransformerTwoslash: TransformerFactory = createTransformerFactory(
22+
cdnTwoslash.runSync
23+
);
24+
25+
function onTwoslashError(err: unknown, code: string, lang: string) {
26+
console.error(JSON.stringify({ err, code, lang }));
27+
}
28+
29+
function onShikiError(err: unknown, code: string, lang: string) {
30+
console.error(JSON.stringify({ err, code, lang }));
31+
}
32+
33+
export function getTwoslashOptions(
34+
{ linkMap }: { linkMap: Map<string, string> } = { linkMap: new Map() }
35+
): TransformerTwoslashOptions {
36+
return {
37+
onTwoslashError,
38+
onShikiError,
39+
renderer: rendererRich({
40+
hast: {
41+
hoverToken: {
42+
children(input) {
43+
for (const rootElement of input) {
44+
if (!('children' in rootElement)) continue;
45+
for (const [i, element] of rootElement.children.entries()) {
46+
if (element.type !== 'text') continue;
47+
const href = linkMap.get(element.value);
48+
if (!href) continue;
49+
const newElement: ElementContent = {
50+
type: 'element',
51+
tagName: 'a',
52+
properties: {
53+
href,
54+
...(checkIsExternalLink(href) && {
55+
target: '_blank',
56+
rel: 'noopener noreferrer',
57+
}),
58+
},
59+
children: [{ type: 'text', value: element.value }],
60+
};
61+
input.splice(i, 1, newElement);
62+
}
63+
}
64+
return input;
65+
},
66+
},
67+
},
68+
}),
69+
langs: ['ts', 'typescript', 'js', 'javascript', 'tsx', 'jsx'],
70+
explicitTrigger: /mint-twoslash/,
71+
twoslashOptions: {
72+
compilerOptions: twoslashCompilerOptions,
73+
},
74+
};
75+
}
76+
77+
export function parseLineComment(line: string): { word: string; href: string } | undefined {
78+
line = line.trim();
79+
if (!line.startsWith('//') || (!line.includes('@link ') && !line.includes('@link:'))) return;
80+
81+
line = line.replace('@link:', '@link ');
82+
const parts = line.split('@link ')[1];
83+
if (!parts) return;
84+
85+
const words = parts.split(' ').filter(Boolean);
86+
if (words.length === 1 && words[0]) {
87+
let word = words[0];
88+
if (word.endsWith(':')) word = word.slice(0, -1);
89+
const lowercaseWord = word.toLowerCase();
90+
const href = word.startsWith('#') ? lowercaseWord : `#${encodeURIComponent(lowercaseWord)}`;
91+
return { word, href };
92+
} else if (words.length === 2 && words[0] && words[1]) {
93+
let word = words[0];
94+
if (word.endsWith(':')) word = word.slice(0, -1);
95+
const href = words[1];
96+
if (!href.startsWith('#') && !href.startsWith('https://')) return;
97+
return { word, href };
98+
}
99+
100+
return;
101+
}
102+
103+
type Url = `https://${string}`;
104+
function checkIsExternalLink(href: string | undefined): href is Url {
105+
let isExternalLink = false;
106+
try {
107+
if (href && URL.canParse(href)) isExternalLink = true;
108+
} catch {}
109+
return isExternalLink;
110+
}

0 commit comments

Comments
 (0)