1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148
| 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] }, } }
|