Skip to content

Commit 4b5fb9e

Browse files
Support links with custom '// @link' syntax
1 parent c8045a8 commit 4b5fb9e

File tree

4 files changed

+133
-32
lines changed

4 files changed

+133
-32
lines changed

.yarn/install-state.gz

-1 Bytes
Binary file not shown.

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: 22 additions & 31 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,30 +20,14 @@ 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>;
@@ -145,7 +122,7 @@ const traverseNode = (
145122
options: RehypeSyntaxHighlightingOptions
146123
) => {
147124
try {
148-
const code = toString(node);
125+
let code = toString(node);
149126

150127
const meta = node.data?.meta?.split(' ') ?? [];
151128
const twoslashIndex = meta.findIndex((str) => str.toLowerCase() === 'mint-twoslash');
@@ -156,6 +133,20 @@ const traverseNode = (
156133
node.data.meta = meta.join(' ').trim() || undefined;
157134
}
158135

136+
const linkMap: Map<string, string> = new Map();
137+
const splitCode = code.split('\n');
138+
for (const [i, line] of splitCode.entries()) {
139+
const parsedLineComment = parseLineComment(line);
140+
if (!parsedLineComment) continue;
141+
const { word, href } = parsedLineComment;
142+
linkMap.set(word, href);
143+
splitCode.splice(i, 1);
144+
}
145+
146+
code = splitCode.join('\n');
147+
148+
const twoslashOptions = getTwoslashOptions({ linkMap });
149+
159150
const hast = highlighter.codeToHast(code, {
160151
lang: lang ?? DEFAULT_LANG,
161152
meta: shouldUseTwoslash ? { __raw: 'mint-twoslash' } : undefined,
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)