Skip to content
Merged
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
Binary file modified .yarn/install-state.gz
Binary file not shown.
30 changes: 30 additions & 0 deletions examples/app-router/examples/highlight-example.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div>{count}</div>;
}

// @link OtherFunction: #hola-there
export function OtherFunction() {
// ^?
return <div>{count}</div>;
}

// @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 <div>{count}</div>;
}
```

### Component

Hello world from the `Component` section
30 changes: 30 additions & 0 deletions examples/pages-router/examples/highlight-example.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <div>{count}</div>;
}

// @link OtherFunction: #hola-there
export function OtherFunction() {
// ^?
return <div>{count}</div>;
}

// @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 <div>{count}</div>;
}
```

### Component

Hello world from the `Component` section
2 changes: 1 addition & 1 deletion packages/mdx/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
94 changes: 45 additions & 49 deletions packages/mdx/src/plugins/rehype/rehypeSyntaxHighlighting.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<string, string>;
};

let highlighterPromise: Promise<Highlighter> | null = null;
Expand All @@ -73,7 +51,8 @@ export const rehypeSyntaxHighlighting: Plugin<[RehypeSyntaxHighlightingOptions?]
options = {}
) => {
return async (tree) => {
const asyncNodesToProcess: Promise<void>[] = [];
const nodesToProcess: Promise<void>[] = [];

const themesToLoad: ShikiTheme[] = [];
if (options.themes) {
themesToLoad.push(options.themes.dark);
Expand Down Expand Up @@ -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');
Expand All @@ -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,
Expand Down Expand Up @@ -195,6 +191,6 @@ const traverseNode = (
}
throw err;
}
};
}

export { UNIQUE_LANGS, DEFAULT_LANG_ALIASES, SHIKI_THEMES, ShikiLang, ShikiTheme };
110 changes: 110 additions & 0 deletions packages/mdx/src/plugins/rehype/twoslash/config.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> } = { 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;
}