import type { Plugin } from 'vite'
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkFm from 'remark-frontmatter'
import rehypeStringify from 'rehype-stringify'
import { toHast } from 'mdast-util-to-hast'
import { select, selectAll } from 'unist-util-select'
import { remove } from 'unist-util-remove'
import rehypeShiki from '@shikijs/rehype'
import rehypeReact from 'rehype-react'
import { readFile } from 'node:fs/promises'
import type { Heading, Yaml } from 'mdast'
import type { Root } from 'hast'
import type { JSX } from 'react/jsx-runtime'
import * as production from 'react/jsx-runtime'function resolveId(id: string):
| {
type: 'frontmatter' | 'toc' | 'html' | 'react'
path: string
}
| undefined {
if (id.endsWith('.md?frontmatter')) {
return {
type: 'frontmatter',
path: id.slice(0, -'?frontmatter'.length),
}
} else if (id.endsWith('.md?toc')) {
return {
type: 'toc',
path: id.slice(0, -'?toc'.length),
}
} else if (id.endsWith('.md?html')) {
return {
type: 'html',
path: id.slice(0, -'?html'.length),
}
} else if (id.endsWith('.md?react')) {
return {
type: 'react',
path: id.slice(0, -'?react'.length),
}
}
}
type TransformCache = {
frontmatter: string
toc: string
html: string
react: string
}
interface TocItem {
id: string
text: string
level: number
children?: TocItem[]
}
function convertToTocItem(heading: Heading): TocItem {
const text = toString(heading.children[0])
const id = slug(text)
return {
id,
text,
level: heading.depth,
}
}
function markdownToc(md: Root): TocItem[] {
const headings = selectAll('heading', md) as Heading[]
const root: TocItem[] = []
const stack: TocItem[] = []
for (const heading of headings) {
const item = convertToTocItem(heading)
while (stack.length > 0 && stack[stack.length - 1].level >= item.level) {
stack.pop()
}
if (stack.length === 0) {
root.push(item)
} else {
const parent = stack[stack.length - 1]
if (!parent.children) {
parent.children = []
}
parent.children.push(item)
}
stack.push(item)
}
return root
}
async function transform(raw: string): Promise<TransformCache> {
const root = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkFm)
.parse(raw)
const yaml = select('yaml', root) as Yaml
const frontmatter = yaml?.data ?? {}
remove(root, 'yaml')
const toc = markdownToc(root)
const hast = toHast(root) as Root
const html = unified()
.use(rehypeShiki, {
theme: 'github-dark',
} satisfies Parameters<typeof rehypeShiki>[0])
.use(rehypeStringify)
.stringify(hast)
const file = await unified()
.use(rehypeShiki, {
theme: 'github-dark',
} satisfies Parameters<typeof rehypeShiki>[0])
.use(rehypeReact, production)
.stringify(hast)
const jsx = stringifyJsx(file)
return {
frontmatter: `export default ${JSON.stringify(frontmatter)}`,
toc: `export default ${JSON.stringify(toc)}`,
html: `export default ${JSON.stringify(html)}`,
react: `import React from "react"\nconst ReactComponent = () => ${jsx};\nexport default ReactComponent`,
}
}
export function markdown(): Plugin {
const map: Record<string, TransformCache> = {}
return {
name: 'vite-plugin-markdown',
async transform(_code, id) {
const resolved = resolveId(id)
if (!resolved) {
return
}
const { type, path } = resolved
if (map[path]) {
return map[path][type]
}
const raw = await readFile(path, 'utf-8')
const cache = await transform(raw)
map[path] = cache
return cache[type]
},
}
}