Skip to content

Commit 6ca98aa

Browse files
fuma-namaantfu
andauthored
feat(rehype): support inline codes (#751)
Co-authored-by: Anthony Fu <[email protected]>
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

Lines changed: 36 additions & 0 deletions
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

Lines changed: 118 additions & 133 deletions
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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 42 additions & 0 deletions
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+
}

0 commit comments

Comments
 (0)