Skip to content

Commit d38eb79

Browse files
committed
more refactor
1 parent 19ea231 commit d38eb79

File tree

8 files changed

+174
-23
lines changed

8 files changed

+174
-23
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,9 @@
102102
"eslint-plugin-jsx-a11y": "^6.10.0",
103103
"eslint-plugin-react": "^7.37.5",
104104
"eslint-plugin-react-hooks": "^7.0.1",
105+
"hast-util-from-parse5": "^8.0.3",
105106
"npm-run-all": "^4.1.5",
107+
"parse5": "^8.0.0",
106108
"postcss": "^8.4.35",
107109
"prettier": "^3.7.4",
108110
"tailwindcss": "^4.1.11",

pnpm-lock.yaml

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/Markdown.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -383,8 +383,8 @@ const options: HTMLReactParserOptions = {
383383
}
384384

385385
type MarkdownProps = {
386+
htmlMarkup: string
386387
rawContent?: string
387-
htmlMarkup?: string
388388
}
389389

390390
export function Markdown({ rawContent, htmlMarkup }: MarkdownProps) {
@@ -395,11 +395,7 @@ export function Markdown({ rawContent, htmlMarkup }: MarkdownProps) {
395395
return renderMarkdown(rawContent)
396396
}
397397

398-
if (htmlMarkup) {
399-
return { markup: htmlMarkup, headings: [] }
400-
}
401-
402-
return { markup: '', headings: [] }
398+
return { markup: htmlMarkup, headings: [] }
403399
}, [rawContent, htmlMarkup])
404400

405401
React.useEffect(() => {

src/utils/markdown/plugins/collectHeadings.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ export type MarkdownHeading = {
1010
level: number
1111
}
1212

13-
export async function rehypeCollectHeadings (tree, file, headings: MarkdownHeading[]) {
14-
return (tree) => {
13+
export function rehypeCollectHeadings(headings: MarkdownHeading[]) {
14+
return function collectHeadings(tree: Root, file: any) {
1515
visit(tree, 'element', (node) => {
1616
if (!isHeading(node)) {
1717
return

src/utils/markdown/plugins/helpers.ts

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,29 @@
1-
import { unified } from 'unified'
2-
import rehypeParse from 'rehype-parse'
31
import { isElement } from 'hast-util-is-element'
42
import type { Element } from 'hast-util-is-element/lib'
3+
import type { Properties } from 'hast'
54

65
export const COMPONENT_PREFIX = '::'
76
export const START_PREFIX = '::start:'
87
export const END_PREFIX = '::end:'
98

10-
const componentParser = unified().use(rehypeParse, { fragment: true })
11-
129
export const normalizeComponentName = (name: string) => name.toLowerCase()
1310

1411
export function parseDescriptor(descriptor: string) {
15-
const tree = componentParser.parse(`<${descriptor} />`)
16-
const node = tree.children[0]
17-
if (!node || node.type !== 'element') {
12+
const match = descriptor.match(/^(?<component>[\w-]+)(?<rest>.*)$/)
13+
if (!match?.groups?.component) {
1814
return null
1915
}
2016

21-
const component = node.tagName
17+
const component = normalizeComponentName(match.groups.component)
2218
const attributes: Record<string, string> = {}
23-
const properties = node.properties ?? {}
24-
for (const [key, value] of Object.entries(properties)) {
25-
if (Array.isArray(value)) {
26-
attributes[key] = value.join(' ')
27-
} else if (value != null) {
28-
attributes[key] = String(value)
29-
}
19+
const rest = match.groups.rest ?? ''
20+
const attributePattern = /(\w[\w-]*)(?:="([^"]*)"|='([^']*)'|=([^\s]+))?/g
21+
22+
let attributeMatch: RegExpExecArray | null
23+
while ((attributeMatch = attributePattern.exec(rest))) {
24+
const [, key, doubleQuoted, singleQuoted, bare] = attributeMatch
25+
const value = doubleQuoted ?? singleQuoted ?? bare ?? ''
26+
attributes[key] = value
3027
}
3128

3229
return { component, attributes }

src/utils/markdown/plugins/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export {
55
type MarkdownHeading,
66
rehypeCollectHeadings,
77
} from './collectHeadings'
8+
export { rehypeShikiHighlight } from './syntaxHighlight'
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { transformerNotationDiff } from '@shikijs/transformers'
2+
import { unified } from 'unified'
3+
import rehypeParse from 'rehype-parse'
4+
import { visit } from 'unist-util-visit'
5+
import type { Element, Root } from 'hast'
6+
import { createHighlighter } from 'shiki'
7+
import type { HighlighterGeneric, Lang, ThemeInput } from 'shiki'
8+
9+
const DEFAULT_THEMES: ThemeInput[] = ['github-light', 'tokyo-night']
10+
const DEFAULT_LANGS: Lang[] = [
11+
'typescript',
12+
'javascript',
13+
'tsx',
14+
'jsx',
15+
'bash',
16+
'json',
17+
'html',
18+
'css',
19+
'markdown',
20+
'plaintext',
21+
]
22+
23+
const LANG_ALIASES: Record<string, Lang> = {
24+
ts: 'typescript',
25+
js: 'javascript',
26+
sh: 'bash',
27+
shell: 'bash',
28+
console: 'bash',
29+
zsh: 'bash',
30+
md: 'markdown',
31+
txt: 'plaintext',
32+
text: 'plaintext',
33+
}
34+
35+
let highlighterPromise: Promise<HighlighterGeneric<any, any>> | null = null
36+
const fragmentParser = unified().use(rehypeParse, { fragment: true })
37+
38+
async function getHighlighter() {
39+
if (!highlighterPromise) {
40+
highlighterPromise = createHighlighter({
41+
langs: DEFAULT_LANGS,
42+
themes: DEFAULT_THEMES,
43+
})
44+
}
45+
return highlighterPromise
46+
}
47+
48+
async function renderMermaid(code: string): Promise<string> {
49+
const mermaid = await import('mermaid')
50+
const { svg } = await mermaid.default.render(`mermaid-${Math.random()}`, code)
51+
return svg
52+
}
53+
54+
export const rehypeShikiHighlight = () => {
55+
return async (tree: Root) => {
56+
const highlighter = await getHighlighter()
57+
58+
const highlightTasks: Array<Promise<void>> = []
59+
60+
visit(tree, 'element', (node: Element) => {
61+
if (node.tagName !== 'pre') return
62+
const codeNode = node.children?.[0]
63+
if (!codeNode || codeNode.type !== 'element' || codeNode.tagName !== 'code') {
64+
return
65+
}
66+
67+
const classNames = codeNode.properties?.className
68+
const className = Array.isArray(classNames)
69+
? classNames.find((cls) => cls.startsWith('language-'))
70+
: typeof classNames === 'string'
71+
? classNames
72+
: undefined
73+
74+
const langRaw = className?.replace('language-', '') ?? 'plaintext'
75+
const codeText = codeNode.children?.[0]?.value
76+
if (typeof codeText !== 'string') {
77+
return
78+
}
79+
80+
highlightTasks.push(
81+
(async () => {
82+
const normalizedLang = LANG_ALIASES[langRaw] ?? (langRaw as Lang)
83+
const effectiveLang = normalizedLang === 'mermaid' ? 'plaintext' : normalizedLang
84+
85+
if (
86+
!highlighter
87+
.getLoadedLanguages()
88+
.includes(effectiveLang as unknown as Lang)
89+
) {
90+
try {
91+
await highlighter.loadLanguage(effectiveLang as unknown as Lang)
92+
} catch {
93+
// stick with plaintext fallback
94+
}
95+
}
96+
97+
const htmlFragments = await Promise.all(
98+
DEFAULT_THEMES.map((theme) =>
99+
highlighter.codeToHtml(codeText, {
100+
lang: effectiveLang,
101+
theme,
102+
transformers: [transformerNotationDiff()],
103+
}),
104+
),
105+
)
106+
107+
node.tagName = 'div'
108+
node.properties = {
109+
...node.properties,
110+
className: [
111+
...(Array.isArray(node.properties?.className)
112+
? (node.properties?.className as string[])
113+
: []),
114+
'shiki-wrapper',
115+
],
116+
'data-language': normalizedLang,
117+
}
118+
119+
if (normalizedLang === 'mermaid') {
120+
const svg = await renderMermaid(codeText)
121+
const mermaidTree = fragmentParser.parse(
122+
`<div class="mermaid-diagram py-4 bg-neutral-50 dark:bg-gray-900">${svg}</div>`,
123+
) as Root
124+
125+
node.children = mermaidTree.children
126+
return
127+
}
128+
129+
const fragmentTrees = htmlFragments.map((fragment) =>
130+
fragmentParser.parse(fragment) as Root,
131+
)
132+
133+
node.children = fragmentTrees.flatMap((fragmentTree) => fragmentTree.children)
134+
})(),
135+
)
136+
})
137+
138+
await Promise.all(highlightTasks)
139+
}
140+
}

src/utils/markdown/processor.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
rehypeParseCommentComponents,
1313
rehypeTransformCommentComponents,
1414
rehypeCollectHeadings,
15+
rehypeShikiHighlight,
1516
type MarkdownHeading,
1617
} from './plugins'
1718

@@ -62,6 +63,7 @@ export function renderMarkdown(content): MarkdownRenderResult {
6263
},
6364
})
6465
.use(rehypeSlug)
66+
.use(rehypeShikiHighlight())
6567
.use(rehypeTransformCommentComponents)
6668
.use(rehypeAutolinkHeadings, {
6769
behavior: 'wrap',

0 commit comments

Comments
 (0)