Skip to content

Commit cd80cf8

Browse files
farnabazTahul
andauthored
feat(markdown): support multiple themes for code highlighter (#1251)
Co-authored-by: Yaël Guilloux <[email protected]>
1 parent b500c86 commit cd80cf8

File tree

8 files changed

+305
-73
lines changed

8 files changed

+305
-73
lines changed

docs/content/4.api/3.configuration.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,37 @@ Nuxt Content uses [Shiki](https://github.com/shikijs/shiki) to provide syntax hi
202202

203203
| Option | Type | Description |
204204
| ----------------- | :--------: | :-------- |
205-
| `theme` | `ShikiTheme` | The [color theme](https://github.com/shikijs/shiki/blob/main/docs/themes.md) to use |
205+
| `theme` | `ShikiTheme` or `Record<string, ShikiTheme>` | The [color theme](https://github.com/shikijs/shiki/blob/main/docs/themes.md) to use. |
206206
| `preload` | `ShikiLang[]` | The [preloaded languages](https://github.com/shikijs/shiki/blob/main/docs/languages.md) available for highlighting. |
207207

208+
#### `highlight.theme`
209+
210+
Theme can be specified by a single string but also supports an object with multiple themes.
211+
212+
This option is compatible with [Color Mode module](https://color-mode.nuxtjs.org/).
213+
214+
If you are using multiple themes, it's recommended to always have a `default` theme specified.
215+
216+
```ts
217+
export default defineNuxtConfig({
218+
content: {
219+
highlight: {
220+
// Theme used in all color schemes.
221+
theme: 'github-light'
222+
// OR
223+
theme: {
224+
// Default theme (same as single string)
225+
default: 'github-light',
226+
// Theme used if `html.dark`
227+
dark: 'github-dark'
228+
// Theme used if `html.sepia`
229+
sepia: 'monokai'
230+
}
231+
}
232+
}
233+
})
234+
```
235+
208236
## `yaml`
209237

210238
- Type: `false | Object`{lang=ts}

src/module.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,10 @@ export interface ModuleOptions {
122122
/**
123123
* Default theme that will be used for highlighting code blocks.
124124
*/
125-
theme?: ShikiTheme,
125+
theme?: ShikiTheme | {
126+
default: ShikiTheme
127+
[theme: string]: ShikiTheme
128+
},
126129
/**
127130
* Preloaded languages that will be available for highlighting code blocks.
128131
*/

src/runtime/server/api/highlight.ts

Lines changed: 95 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,26 @@ const resolveLang = (lang: string): Lang | undefined =>
1515
/**
1616
* Resolve Shiki compatible theme from string.
1717
*/
18-
const resolveTheme = (theme: string): Theme | undefined =>
19-
BUNDLED_THEMES.find(t => t === theme)
18+
const resolveTheme = (theme: string | Record<string, string>): Record<string, Theme> | undefined => {
19+
if (!theme) {
20+
return
21+
}
22+
if (typeof theme === 'string') {
23+
theme = {
24+
default: theme
25+
}
26+
}
27+
28+
return Object.entries(theme).reduce((acc, [key, value]) => {
29+
acc[key] = BUNDLED_THEMES.find(t => t === value)
30+
return acc
31+
}, {})
32+
}
2033

2134
/**
2235
* Resolve Shiki highlighter compatible payload from request body.
2336
*/
24-
const resolveBody = (body: Partial<HighlightParams>): { code: string, lang?: Lang, theme?: Theme } => {
37+
const resolveBody = (body: Partial<HighlightParams>) => {
2538
// Assert body schema
2639
if (typeof body.code !== 'string') { throw createError({ statusMessage: 'Bad Request', statusCode: 400, message: 'Missing code key.' }) }
2740

@@ -40,7 +53,7 @@ export default defineLazyEventHandler(async () => {
4053

4154
// Initialize highlighter with defaults
4255
const highlighter = await getHighlighter({
43-
theme: theme || 'dark-plus',
56+
theme: theme?.default || theme || 'dark-plus',
4457
langs: [
4558
...(preload || ['json', 'js', 'ts', 'css']),
4659
'shell',
@@ -60,7 +73,7 @@ export default defineLazyEventHandler(async () => {
6073
return async (event): Promise<HighlightThemedToken[][]> => {
6174
const params = await useBody<Partial<HighlightParams>>(event)
6275

63-
const { code, lang, theme } = resolveBody(params)
76+
const { code, lang, theme = { default: highlighter.getTheme() } } = resolveBody(params)
6477

6578
// Skip highlight if lang is not supported
6679
if (!lang) {
@@ -73,21 +86,88 @@ export default defineLazyEventHandler(async () => {
7386
}
7487

7588
// Load supported theme on-demand
76-
if (theme && !highlighter.getLoadedThemes().includes(theme)) {
77-
await highlighter.loadTheme(theme)
78-
}
89+
await Promise.all(
90+
Object.values(theme).map(async (theme) => {
91+
if (!highlighter.getLoadedThemes().includes(theme)) {
92+
await highlighter.loadTheme(theme)
93+
}
94+
})
95+
)
7996

8097
// Highlight code
81-
const highlightedCode = highlighter.codeToThemedTokens(code, lang, theme)
82-
83-
// Clean up to shorten response payload
84-
for (const line of highlightedCode) {
85-
for (const token of line) {
86-
delete token.fontStyle
87-
delete token.explanation
98+
const coloredTokens = Object.entries(theme).map(([key, theme]) => {
99+
const tokens = highlighter.codeToThemedTokens(code, lang, theme)
100+
return {
101+
key,
102+
theme,
103+
tokens
88104
}
105+
})
106+
107+
const highlightedCode: HighlightThemedToken[][] = []
108+
for (const line in coloredTokens[0].tokens) {
109+
highlightedCode[line] = coloredTokens.reduce((acc, color) => {
110+
return mergeLines({
111+
key: coloredTokens[0].key,
112+
tokens: acc
113+
}, {
114+
key: color.key,
115+
tokens: color.tokens[line]
116+
})
117+
}, coloredTokens[0].tokens[line])
89118
}
90119

91120
return highlightedCode
92121
}
93122
})
123+
124+
function mergeLines (line1, line2) {
125+
const mergedTokens = []
126+
const getColors = (h, i) => typeof h.tokens[i].color === 'string' ? { [h.key]: h.tokens[i].color } : h.tokens[i].color
127+
128+
const [big, small] = line1.tokens.length > line2.tokens.length ? [line1, line2] : [line2, line1]
129+
let targetToken = 0
130+
let targetTokenCharIndex = 0
131+
big.tokens.forEach((t, i) => {
132+
if (targetTokenCharIndex === 0) {
133+
if (t.content === small.tokens[i]?.content) {
134+
mergedTokens.push({
135+
content: t.content,
136+
color: {
137+
...getColors(big, i),
138+
...getColors(small, i)
139+
}
140+
})
141+
targetToken = i + 1
142+
return
143+
}
144+
if (t.content === small.tokens[targetToken]?.content) {
145+
mergedTokens.push({
146+
content: t.content,
147+
color: {
148+
...getColors(big, i),
149+
...getColors(small, targetToken)
150+
}
151+
})
152+
targetToken += 1
153+
return
154+
}
155+
}
156+
157+
if (small.tokens[targetToken]?.content?.substring(targetTokenCharIndex, targetTokenCharIndex + t.content.length) === t.content) {
158+
targetTokenCharIndex += t.content.length
159+
mergedTokens.push({
160+
content: t.content,
161+
color: {
162+
...getColors(big, i),
163+
...getColors(small, targetToken)
164+
}
165+
})
166+
}
167+
if (small.tokens[targetToken]?.content.length <= targetTokenCharIndex) {
168+
targetToken += 1
169+
targetTokenCharIndex = 0
170+
}
171+
})
172+
return mergedTokens
173+
}

src/runtime/server/transformers/shiki.ts

Lines changed: 104 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { visit } from 'unist-util-visit'
22
import { withBase } from 'ufo'
33
import { useRuntimeConfig } from '#imports'
44

5+
const highlightConfig = useRuntimeConfig().content.highlight
6+
57
const withContentBase = (url: string) => {
68
return withBase(url, `/api/${useRuntimeConfig().public.content.base}`)
79
}
@@ -10,74 +12,122 @@ export default {
1012
name: 'markdown',
1113
extensions: ['.md'],
1214
transform: async (content) => {
13-
const codeBlocks = []
15+
const tokenColors: Record<string, {colors: any, className: string}> = {}
16+
const codeBlocks: any[] = []
17+
const inlineCodes: any = []
1418
visit(
1519
content.body,
16-
(node: any) => node.tag === 'code' && node?.props.code,
17-
(node) => { codeBlocks.push(node) }
20+
(node: any) => (node.tag === 'code' && node?.props.code) || (node.tag === 'code-inline' && (node.props?.lang || node.props?.language)),
21+
(node) => {
22+
if (node.tag === 'code') {
23+
codeBlocks.push(node)
24+
} else if (node.tag === 'code-inline') {
25+
inlineCodes.push(node)
26+
}
27+
}
1828
)
29+
1930
await Promise.all(codeBlocks.map(highlightBlock))
31+
await Promise.all(inlineCodes.map(highlightInline))
2032

21-
const inlineCodes = []
22-
visit(
23-
content.body,
24-
(node: any) => node.tag === 'code-inline' && (node.props?.lang || node.props?.language),
25-
(node) => { inlineCodes.push(node) }
26-
)
33+
// Inject token colors at the end of the document
34+
if (Object.values(tokenColors).length) {
35+
const colors: string[] = []
36+
for (const colorClass of Object.values(tokenColors)) {
37+
Object.entries(colorClass.colors).forEach(([variant, color]) => {
38+
if (variant === 'default') {
39+
colors.unshift(`.${colorClass.className}{color:${color}}`)
40+
} else {
41+
colors.push(`.${variant} .${colorClass.className}{color:${color}}`)
42+
}
43+
})
44+
}
2745

28-
await Promise.all(inlineCodes.map(highlightInline))
46+
content.body.children.push({
47+
type: 'element',
48+
tag: 'style',
49+
children: [{ type: 'text', value: colors.join('') }]
50+
})
51+
}
2952

3053
return content
31-
}
32-
}
3354

34-
const tokenSpan = ({ content, color }) => ({
35-
type: 'element',
36-
tag: 'span',
37-
props: { style: { color } },
38-
children: [{ type: 'text', value: content }]
39-
})
40-
41-
const highlightInline = async (node) => {
42-
const code = node.children[0].value
43-
44-
// Fetch highlighted tokens
45-
const lines = await $fetch(withContentBase('highlight'), {
46-
method: 'POST',
47-
body: {
48-
code,
49-
lang: node.props.lang || node.props.language
55+
/**
56+
* Highlight inline code
57+
*/
58+
async function highlightInline (node) {
59+
const code = node.children[0].value
60+
61+
// Fetch highlighted tokens
62+
const lines = await $fetch<any[]>(withContentBase('highlight'), {
63+
method: 'POST',
64+
body: {
65+
code,
66+
lang: node.props.lang || node.props.language,
67+
theme: highlightConfig.theme
68+
}
69+
})
70+
71+
// Generate highlighted children
72+
node.children = lines[0].map(tokenSpan)
73+
74+
node.props = node.props || {}
75+
node.props.class = 'colored'
76+
77+
return node
5078
}
51-
})
5279

53-
// Generate highlighted children
54-
node.children = lines[0].map(tokenSpan)
80+
/**
81+
* Highlight a code block
82+
*/
83+
async function highlightBlock (node) {
84+
const { code, language: lang, highlights = [] } = node.props
5585

56-
node.props = node.props || {}
57-
node.props.class = 'colored'
86+
// Fetch highlighted tokens
87+
const lines = await $fetch<any[]>(withContentBase('highlight'), {
88+
method: 'POST',
89+
body: {
90+
code,
91+
lang,
92+
theme: highlightConfig.theme
93+
}
94+
})
5895

59-
return node
60-
}
96+
// Generate highlighted children
97+
const innerCodeNode = node.children[0].children[0]
98+
innerCodeNode.children = lines.map((line, lineIndex) => ({
99+
type: 'element',
100+
tag: 'span',
101+
props: { class: ['line', highlights.includes(lineIndex + 1) ? 'highlight' : ''].join(' ').trim() },
102+
children: line.map(tokenSpan)
103+
}))
104+
return node
105+
}
61106

62-
const highlightBlock = async (node) => {
63-
const { code, language: lang, highlights = [] } = node.props
107+
function getColorProps (token) {
108+
if (!token.color) {
109+
return {}
110+
}
111+
if (typeof token.color === 'string') {
112+
return { style: { color: token.color } }
113+
}
114+
const key = Object.values(token.color).join('')
115+
if (!tokenColors[key]) {
116+
tokenColors[key] = {
117+
colors: token.color,
118+
className: 'ct-' + Math.random().toString(16).substring(2, 8) // hash(key)
119+
}
120+
}
121+
return { class: tokenColors[key].className }
122+
}
64123

65-
// Fetch highlighted tokens
66-
const lines = await $fetch(withContentBase('highlight'), {
67-
method: 'POST',
68-
body: {
69-
code,
70-
lang
124+
function tokenSpan (token) {
125+
return {
126+
type: 'element',
127+
tag: 'span',
128+
props: getColorProps(token),
129+
children: [{ type: 'text', value: token.content }]
130+
}
71131
}
72-
})
73-
74-
// Generate highlighted children
75-
const innerCodeNode = node.children[0].children[0]
76-
innerCodeNode.children = lines.map((line, lineIndex) => ({
77-
type: 'element',
78-
tag: 'span',
79-
props: { class: ['line', highlights.includes(lineIndex + 1) ? 'highlight' : ''].join(' ').trim() },
80-
children: line.map(tokenSpan)
81-
}))
82-
return node
132+
}
83133
}

src/runtime/types.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,10 +283,10 @@ export interface NavItem {
283283
export interface HighlightParams {
284284
code: string
285285
lang: string
286-
theme: Theme
286+
theme: Theme | Record<string, Theme>
287287
}
288288

289289
export interface HighlightThemedToken {
290290
content: string
291-
color?: string
291+
color?: string | Record<string, string>
292292
}

0 commit comments

Comments
 (0)