Skip to content

Commit 6ca98aa

Browse files
fuma-namaantfu
andauthoredAug 22, 2024··
feat(rehype): support inline codes (#751)
Co-authored-by: Anthony Fu <github@antfu.me>
1 parent bbf37b1 commit 6ca98aa

File tree

8 files changed

+310
-134
lines changed

8 files changed

+310
-134
lines changed
 

‎docs/packages/rehype.md

+36
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,39 @@ console.log('3') // highlighted
9797
console.log('4') // highlighted
9898
```
9999
````
100+
101+
### Inline Code
102+
103+
You can also highlight inline codes with the `inline` option.
104+
105+
| Option | Example | Description |
106+
| ----------------------- | ---------------- | ----------------------------------------------------------- |
107+
| `false` | - | Disable inline code highlighting (default) |
108+
| `'tailing-curly-colon'` | `let a = 1{:js}` | Highlight with a `{:language}` marker inside the code block |
109+
110+
Enable `inline` on the Rehype plugin:
111+
112+
```ts twoslash
113+
// @noErrors: true
114+
import { unified } from 'unified'
115+
import remarkParse from 'remark-parse'
116+
import remarkRehype from 'remark-rehype'
117+
import rehypeStringify from 'rehype-stringify'
118+
import rehypeShiki from '@shikijs/rehype'
119+
120+
const file = await unified()
121+
.use(remarkParse)
122+
.use(remarkRehype)
123+
.use(rehypeShiki, {
124+
inline: 'tailing-curly-colon', // or other options
125+
// ...
126+
})
127+
.use(rehypeStringify)
128+
.process(await fs.readFile('./input.md'))
129+
```
130+
131+
Then you can use inline code in markdown:
132+
133+
```md
134+
This code `console.log("Hello World"){:js}` will be highlighted.
135+
```

‎packages/rehype/src/core.ts

+118-133
Original file line numberDiff line numberDiff line change
@@ -1,73 +1,15 @@
1-
import type { CodeOptionsMeta, CodeOptionsThemes, CodeToHastOptions, CodeToHastOptionsCommon, HighlighterGeneric, TransformerOptions } from 'shiki/core'
1+
import type {
2+
CodeToHastOptions,
3+
HighlighterGeneric,
4+
} from 'shiki/core'
25
import type { Element, Root } from 'hast'
3-
import type { BuiltinTheme } from 'shiki'
46
import type { Transformer } from 'unified'
57
import { toString } from 'hast-util-to-string'
68
import { visit } from 'unist-util-visit'
9+
import { InlineCodeProcessors } from './inline'
10+
import type { RehypeShikiCoreOptions } from './types'
711

8-
export interface MapLike<K = any, V = any> {
9-
get: (key: K) => V | undefined
10-
set: (key: K, value: V) => this
11-
}
12-
13-
export interface RehypeShikiExtraOptions {
14-
/**
15-
* Add `language-*` class to code element
16-
*
17-
* @default false
18-
*/
19-
addLanguageClass?: boolean
20-
21-
/**
22-
* The default language to use when is not specified
23-
*/
24-
defaultLanguage?: string
25-
26-
/**
27-
* The fallback language to use when specified language is not loaded
28-
*/
29-
fallbackLanguage?: string
30-
31-
/**
32-
* `mdast-util-to-hast` adds a newline to the end of code blocks
33-
*
34-
* This option strips that newline from the code block
35-
*
36-
* @default true
37-
* @see https://github.com/syntax-tree/mdast-util-to-hast/blob/f511a93817b131fb73419bf7d24d73a5b8b0f0c2/lib/handlers/code.js#L22
38-
*/
39-
stripEndNewline?: boolean
40-
41-
/**
42-
* Custom meta string parser
43-
* Return an object to merge with `meta`
44-
*/
45-
parseMetaString?: (
46-
metaString: string,
47-
node: Element,
48-
tree: Root
49-
) => Record<string, any> | undefined | null
50-
51-
/**
52-
* Custom map to cache transformed codeToHast result
53-
*
54-
* @default undefined
55-
*/
56-
cache?: MapLike
57-
58-
/**
59-
* Chance to handle the error
60-
* If not provided, the error will be thrown
61-
*/
62-
onError?: (error: unknown) => void
63-
}
64-
65-
export type RehypeShikiCoreOptions =
66-
& CodeOptionsThemes<BuiltinTheme>
67-
& TransformerOptions
68-
& CodeOptionsMeta
69-
& RehypeShikiExtraOptions
70-
& Omit<CodeToHastOptionsCommon, 'lang'>
12+
export * from './types'
7113

7214
const languagePrefix = 'language-'
7315

@@ -84,86 +26,129 @@ function rehypeShikiFromHighlighter(
8426
fallbackLanguage,
8527
onError,
8628
stripEndNewline = true,
29+
inline = false,
8730
...rest
8831
} = options
8932

90-
return function (tree) {
91-
visit(tree, 'element', (node, index, parent) => {
92-
if (!parent || index == null || node.tagName !== 'pre')
93-
return
94-
95-
const head = node.children[0]
96-
97-
if (
98-
!head
99-
|| head.type !== 'element'
100-
|| head.tagName !== 'code'
101-
|| !head.properties
102-
) {
103-
return
104-
}
105-
106-
const classes = head.properties.className
107-
const languageClass = Array.isArray(classes)
108-
? classes.find(
109-
d => typeof d === 'string' && d.startsWith(languagePrefix),
110-
)
111-
: undefined
112-
113-
let lang = typeof languageClass === 'string' ? languageClass.slice(languagePrefix.length) : defaultLanguage
114-
115-
if (!lang)
116-
return
117-
118-
if (fallbackLanguage && !langs.includes(lang))
119-
lang = fallbackLanguage
120-
121-
let code = toString(head)
33+
/**
34+
* Get the determined language of code block (with default language & fallbacks)
35+
*/
36+
function getLanguage(lang = defaultLanguage): string | undefined {
37+
if (lang && fallbackLanguage && !langs.includes(lang))
38+
return fallbackLanguage
39+
return lang
40+
}
12241

123-
if (stripEndNewline && code.endsWith('\n'))
124-
code = code.slice(0, -1)
42+
function highlight(
43+
lang: string,
44+
code: string,
45+
metaString: string = '',
46+
meta: Record<string, unknown> = {},
47+
): Root | undefined {
48+
const cacheKey = `${lang}:${metaString}:${code}`
49+
const cachedValue = cache?.get(cacheKey)
50+
51+
if (cachedValue) {
52+
return cachedValue
53+
}
54+
55+
const codeOptions: CodeToHastOptions = {
56+
...rest,
57+
lang,
58+
meta: {
59+
...rest.meta,
60+
...meta,
61+
__raw: metaString,
62+
},
63+
}
64+
65+
if (addLanguageClass) {
66+
// always construct a new array, avoid adding the transformer repeatedly
67+
codeOptions.transformers = [
68+
...codeOptions.transformers ?? [],
69+
{
70+
name: 'rehype-shiki:code-language-class',
71+
code(node) {
72+
this.addClassToHast(node, `${languagePrefix}${lang}`)
73+
return node
74+
},
75+
},
76+
]
77+
}
78+
79+
if (stripEndNewline && code.endsWith('\n'))
80+
code = code.slice(0, -1)
81+
82+
try {
83+
const fragment = highlighter.codeToHast(code, codeOptions)
84+
cache?.set(cacheKey, fragment)
85+
return fragment
86+
}
87+
catch (error) {
88+
if (onError)
89+
onError(error)
90+
else
91+
throw error
92+
}
93+
}
12594

126-
const cachedValue = cache?.get(code)
95+
function processPre(tree: Root, node: Element): Root | undefined {
96+
const head = node.children[0]
97+
98+
if (
99+
!head
100+
|| head.type !== 'element'
101+
|| head.tagName !== 'code'
102+
|| !head.properties
103+
) {
104+
return
105+
}
106+
107+
const classes = head.properties.className
108+
const languageClass = Array.isArray(classes)
109+
? classes.find(
110+
d => typeof d === 'string' && d.startsWith(languagePrefix),
111+
)
112+
: undefined
113+
114+
const lang = getLanguage(
115+
typeof languageClass === 'string'
116+
? languageClass.slice(languagePrefix.length)
117+
: undefined,
118+
)
119+
120+
if (!lang)
121+
return
122+
123+
const code = toString(head)
124+
const metaString = head.data?.meta ?? head.properties.metastring?.toString() ?? ''
125+
const meta = parseMetaString?.(metaString, node, tree) || {}
126+
127+
return highlight(lang, code, metaString, meta)
128+
}
127129

128-
if (cachedValue) {
129-
parent.children.splice(index, 1, ...cachedValue)
130+
return function (tree) {
131+
visit(tree, 'element', (node, index, parent) => {
132+
// needed for hast node replacement
133+
if (!parent || index == null)
130134
return
131-
}
132135

133-
const metaString = head.data?.meta ?? head.properties.metastring?.toString() ?? ''
134-
const meta = parseMetaString?.(metaString, node, tree) || {}
136+
if (node.tagName === 'pre') {
137+
const result = processPre(tree, node)
135138

136-
const codeOptions: CodeToHastOptions = {
137-
...rest,
138-
lang,
139-
meta: {
140-
...rest.meta,
141-
...meta,
142-
__raw: metaString,
143-
},
144-
}
139+
if (result) {
140+
parent.children.splice(index, 1, ...result.children)
141+
}
145142

146-
if (addLanguageClass) {
147-
codeOptions.transformers ||= []
148-
codeOptions.transformers.push({
149-
name: 'rehype-shiki:code-language-class',
150-
code(node) {
151-
this.addClassToHast(node, `${languagePrefix}${lang}`)
152-
return node
153-
},
154-
})
143+
// don't look for the `code` node inside
144+
return 'skip'
155145
}
156146

157-
try {
158-
const fragment = highlighter.codeToHast(code, codeOptions)
159-
cache?.set(code, fragment.children)
160-
parent.children.splice(index, 1, ...fragment.children)
161-
}
162-
catch (error) {
163-
if (onError)
164-
onError(error)
165-
else
166-
throw error
147+
if (node.tagName === 'code' && inline) {
148+
const result = InlineCodeProcessors[inline]?.({ node, getLanguage, highlight })
149+
if (result) {
150+
parent.children.splice(index, 1, ...result.children)
151+
}
167152
}
168153
})
169154
}

‎packages/rehype/src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { bundledLanguages, getSingletonHighlighter } from 'shiki'
66
import type { Plugin } from 'unified'
77
import type { Root } from 'hast'
88
import rehypeShikiFromHighlighter from './core'
9-
import type { RehypeShikiCoreOptions } from './core'
9+
import type { RehypeShikiCoreOptions } from './types'
1010

1111
export type RehypeShikiOptions = RehypeShikiCoreOptions
1212
& {

‎packages/rehype/src/inline.ts

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { Element, Root } from 'hast'
2+
import { toString } from 'hast-util-to-string'
3+
import type { RehypeShikiCoreOptions } from './types'
4+
5+
interface InlineCodeProcessorContext {
6+
node: Element
7+
getLanguage: (lang?: string) => string | undefined
8+
highlight: (
9+
lang: string,
10+
code: string,
11+
metaString?: string,
12+
meta?: Record<string, unknown>
13+
) => Root | undefined
14+
}
15+
16+
type InlineCodeProcessor = (context: InlineCodeProcessorContext) => Root | undefined
17+
18+
type Truthy<T> = T extends false | '' | 0 | null | undefined ? never : T
19+
20+
export const InlineCodeProcessors: Record<Truthy<RehypeShikiCoreOptions['inline']>, InlineCodeProcessor> = {
21+
'tailing-curly-colon': ({ node, getLanguage, highlight }) => {
22+
const raw = toString(node)
23+
const match = raw.match(/(.+)\{:([\w-]+)\}$/)
24+
if (!match)
25+
return
26+
const lang = getLanguage(match[2])
27+
if (!lang)
28+
return
29+
30+
const code = match[1] ?? raw
31+
const fragment = highlight(lang, code)
32+
if (!fragment)
33+
return
34+
35+
const head = fragment.children[0]
36+
if (head.type === 'element' && head.tagName === 'pre') {
37+
head.tagName = 'span'
38+
}
39+
40+
return fragment
41+
},
42+
}

‎packages/rehype/src/types.ts

+83
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import type { Element, Root } from 'hast'
2+
import type {
3+
BuiltinTheme,
4+
CodeOptionsMeta,
5+
CodeOptionsThemes,
6+
CodeToHastOptionsCommon,
7+
TransformerOptions,
8+
} from 'shiki'
9+
10+
export interface MapLike<K = any, V = any> {
11+
get: (key: K) => V | undefined
12+
set: (key: K, value: V) => this
13+
}
14+
15+
export interface RehypeShikiExtraOptions {
16+
/**
17+
* Add `language-*` class to code element
18+
*
19+
* @default false
20+
*/
21+
addLanguageClass?: boolean
22+
23+
/**
24+
* The default language to use when is not specified
25+
*/
26+
defaultLanguage?: string
27+
28+
/**
29+
* The fallback language to use when specified language is not loaded
30+
*/
31+
fallbackLanguage?: string
32+
33+
/**
34+
* `mdast-util-to-hast` adds a newline to the end of code blocks
35+
*
36+
* This option strips that newline from the code block
37+
*
38+
* @default true
39+
* @see https://github.com/syntax-tree/mdast-util-to-hast/blob/f511a93817b131fb73419bf7d24d73a5b8b0f0c2/lib/handlers/code.js#L22
40+
*/
41+
stripEndNewline?: boolean
42+
43+
/**
44+
* Custom meta string parser
45+
* Return an object to merge with `meta`
46+
*/
47+
parseMetaString?: (
48+
metaString: string,
49+
node: Element,
50+
tree: Root
51+
) => Record<string, any> | undefined | null
52+
53+
/**
54+
* Highlight inline code blocks
55+
*
56+
* - `false`: disable inline code block highlighting
57+
* - `tailing-curly-colon`: highlight with `\`code{:lang}\``
58+
*
59+
* @see https://shiki.style/packages/rehype#inline-code
60+
* @default false
61+
*/
62+
inline?: false | 'tailing-curly-colon'
63+
64+
/**
65+
* Custom map to cache transformed codeToHast result
66+
*
67+
* @default undefined
68+
*/
69+
cache?: MapLike<string, Root>
70+
71+
/**
72+
* Chance to handle the error
73+
* If not provided, the error will be thrown
74+
*/
75+
onError?: (error: unknown) => void
76+
}
77+
78+
export type RehypeShikiCoreOptions =
79+
& CodeOptionsThemes<BuiltinTheme>
80+
& TransformerOptions
81+
& CodeOptionsMeta
82+
& RehypeShikiExtraOptions
83+
& Omit<CodeToHastOptionsCommon, 'lang'>
+11
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/rehype/test/index.test.ts

+14
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,20 @@ it('add-custom-cache', async () => {
6464
expect(file.toString()).toMatchFileSnapshot('./fixtures/c.out.html')
6565
})
6666

67+
it('shiki inline code', async () => {
68+
const file = await unified()
69+
.use(remarkParse)
70+
.use(remarkRehype)
71+
.use(rehypeShiki, {
72+
theme: 'vitesse-light',
73+
inline: 'tailing-curly-colon',
74+
})
75+
.use(rehypeStringify)
76+
.process(await fs.readFile(new URL('./fixtures/inline.md', import.meta.url)))
77+
78+
expect(file.toString()).toMatchFileSnapshot('./fixtures/inline.out.html')
79+
})
80+
6781
it('does not add extra trailing blank line', async () => {
6882
const file = await unified()
6983
.use(remarkParse)

0 commit comments

Comments
 (0)
Please sign in to comment.