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; +}