Skip to content

Commit 751f987

Browse files
authored
feat: add codeToHtmlDualThemes (#5)
1 parent 5d8cf5d commit 751f987

11 files changed

+311
-11
lines changed

README.md

+83
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ An ESM-focused rewrite of [shiki](https://github.com/shikijs/shiki), a beautiful
1010
- Portable. Does not rely on Node.js APIs or the filesystem, works in any modern JavaScript runtime.
1111
- Drop CJS and IIFE build, focus on ESM (or you can use bundlers).
1212
- Bundles languages/themes composedly.
13+
- Light/Dark dual themes support.
1314
- Zero-dependencies.
1415
- Simplified APIs.
1516
- Please don't hate me Pine 😜 ([What's Next?](#whats-next))
@@ -52,6 +53,12 @@ const code1 = await codeToHtml('const a = 1', { lang: 'javascript', theme: 'nord
5253
const code2 = await codeToHtml('<div class="foo">bar</div>', { lang: 'html', theme: 'min-dark' })
5354
```
5455

56+
Currently supports:
57+
58+
- `codeToThemedTokens`
59+
- `codeToHtml`
60+
- `codeToHtmlDualThemes`
61+
5562
Internally they maintains a singleton highlighter instance and load the theme/language on demand. Different from `shiki.codeToHtml`, the `codeToHtml` shorthand function returns a Promise and `lang` and `theme` options are required.
5663

5764
### Fine-grained Bundle
@@ -156,6 +163,82 @@ export default {
156163
}
157164
```
158165

166+
## Additional Features
167+
168+
### Light/Dark Dual Themes
169+
170+
`shikiji` added an experimental light/dark dual themes support. Different from [markdown-it-shiki](https://github.com/antfu/markdown-it-shiki#dark-mode)'s approach which renders the code twice, `shikiji`'s dual themes approach uses CSS variables to store the colors on each token. It's more performant with a smaller bundle size.
171+
172+
Use `codeToHtmlDualThemes` to render the code with dual themes:
173+
174+
```js
175+
import { getHighlighter } from 'shikiji'
176+
177+
const shiki = await getHighlighter({
178+
themes: ['nord', 'min-light'],
179+
langs: ['javascript'],
180+
})
181+
182+
const code = shiki.codeToHtmlDualThemes('console.log("hello")', {
183+
lang: 'javascript',
184+
theme: {
185+
light: 'min-light',
186+
dark: 'nord',
187+
}
188+
})
189+
```
190+
191+
The following HTML will be generated:
192+
193+
```html
194+
<pre
195+
class="shiki shiki-dual-themes min-light--nord"
196+
style="background-color: #ffffff;--shiki-dark-bg:#2e3440ff;color: #ffffff;--shiki-dark-bg:#2e3440ff"
197+
tabindex="0"
198+
>
199+
<code>
200+
<span class="line">
201+
<span style="color:#1976D2;--shiki-dark:#D8DEE9">console</span>
202+
<span style="color:#6F42C1;--shiki-dark:#ECEFF4">.</span>
203+
<span style="color:#6F42C1;--shiki-dark:#88C0D0">log</span>
204+
<span style="color:#24292EFF;--shiki-dark:#D8DEE9FF">(</span>
205+
<span style="color:#22863A;--shiki-dark:#ECEFF4">&quot;</span>
206+
<span style="color:#22863A;--shiki-dark:#A3BE8C">hello</span>
207+
<span style="color:#22863A;--shiki-dark:#ECEFF4">&quot;</span>
208+
<span style="color:#24292EFF;--shiki-dark:#D8DEE9FF">)</span>
209+
</span>
210+
</code>
211+
</pre>
212+
```
213+
214+
To make it reactive to your site's theme, you need to add a short CSS snippet:
215+
216+
###### Query-based Dark Mode
217+
218+
```css
219+
@media (prefers-color-scheme: dark) {
220+
.shiki {
221+
background-color: var(--shiki-dark-bg) !important;
222+
color: var(--shiki-dark) !important;
223+
}
224+
.shiki span {
225+
color: var(--shiki-dark) !important;
226+
}
227+
}
228+
```
229+
230+
###### Class-based Dark Mode
231+
232+
```css
233+
html.dark .shiki {
234+
background-color: var(--shiki-dark-bg) !important;
235+
color: var(--shiki-dark) !important;
236+
}
237+
html.dark .shiki span {
238+
color: var(--shiki-dark) !important;
239+
}
240+
```
241+
159242
## Bundle Size
160243

161244
You can inspect the bundle size in detail on [pkg-size.dev/shikiji](https://pkg-size.dev/shikiji).

src/bundled/singleton.ts

+13-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { BuiltinLanguages, BuiltinThemes, CodeToHtmlOptions, CodeToThemedTokensOptions, PlainTextLanguage, RequireKeys } from '../types'
1+
import type { BuiltinLanguages, BuiltinThemes, CodeToHtmlDualThemesOptions, CodeToHtmlOptions, CodeToThemedTokensOptions, PlainTextLanguage, RequireKeys } from '../types'
22
import { getHighlighter } from './highlighter'
33
import type { Highlighter } from './highlighter'
44

@@ -43,3 +43,15 @@ export async function codeToThemedTokens(code: string, options: RequireKeys<Code
4343
const shiki = await getShikiWithThemeLang(options)
4444
return shiki.codeToThemedTokens(code, options)
4545
}
46+
47+
/**
48+
* Shorthand for `codeToHtmlDualThemes` with auto-loaded theme and language.
49+
* A singleton highlighter it maintained internally.
50+
*
51+
* Differences from `shiki.codeToHtmlDualThemes()`, this function is async.
52+
*/
53+
export async function codeToHtmlDualThemes(code: string, options: RequireKeys<CodeToHtmlDualThemesOptions<BuiltinLanguages, BuiltinThemes>, 'theme' | 'lang'>) {
54+
const shiki = await getShikiWithThemeLang({ lang: options.lang, theme: options.theme.light })
55+
await shiki.loadTheme(options.theme.dark)
56+
return shiki.codeToHtmlDualThemes(code, options)
57+
}

src/core/index.ts

+34-3
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import type { CodeToHtmlOptions, CodeToThemedTokensOptions, LanguageInput, MaybeGetter, ThemeInput } from '../types'
1+
import type { CodeToHtmlDualThemesOptions, CodeToHtmlOptions, CodeToThemedTokensOptions, LanguageInput, MaybeGetter, ThemeInput, ThemedToken } from '../types'
22
import type { OnigurumaLoadOptions } from '../oniguruma'
33
import { createOnigScanner, createOnigString, loadWasm } from '../oniguruma'
44
import { Registry } from './registry'
55
import { Resolver } from './resolver'
66
import { tokenizeWithTheme } from './themedTokenizer'
7-
import { renderToHtml } from './renderer'
7+
import { renderToHtml } from './renderer-html'
88
import { isPlaintext } from './utils'
9+
import { renderToHtmlDualThemes } from './renderer-html-dual-themes'
910

1011
export interface HighlighterCoreOptions {
1112
themes: ThemeInput[]
@@ -56,7 +57,7 @@ export async function getHighlighterCore(options: HighlighterCoreOptions) {
5657
function codeToThemedTokens(
5758
code: string,
5859
options: CodeToThemedTokensOptions = {},
59-
) {
60+
): ThemedToken[][] {
6061
const {
6162
lang = 'text',
6263
theme = defaultTheme,
@@ -107,6 +108,35 @@ export async function getHighlighterCore(options: HighlighterCoreOptions) {
107108
})
108109
}
109110

111+
function codeToHtmlDualThemes(code: string, options: CodeToHtmlDualThemesOptions): string {
112+
const {
113+
defaultColor = 'light',
114+
cssVariableName = '--shiki-dark',
115+
} = options
116+
117+
const tokens1 = codeToThemedTokens(code, {
118+
...options,
119+
theme: defaultColor === 'light' ? options.theme.light : options.theme.dark,
120+
includeExplanation: false,
121+
})
122+
123+
const tokens2 = codeToThemedTokens(code, {
124+
...options,
125+
theme: defaultColor === 'light' ? options.theme.dark : options.theme.light,
126+
includeExplanation: false,
127+
})
128+
129+
const { _theme: _theme1 } = getTheme(defaultColor === 'light' ? options.theme.light : options.theme.dark)
130+
const { _theme: _theme2 } = getTheme(defaultColor === 'light' ? options.theme.dark : options.theme.light)
131+
132+
return renderToHtmlDualThemes(tokens1, tokens2, cssVariableName, {
133+
fg: `${_theme1.fg};${cssVariableName}:${_theme2.fg}`,
134+
bg: `${_theme1.bg};${cssVariableName}-bg:${_theme2.bg}`,
135+
lineOptions: options?.lineOptions,
136+
themeName: `shiki-dual-themes ${_theme1.name}--${_theme2.name}`,
137+
})
138+
}
139+
110140
async function loadLanguage(...langs: LanguageInput[]) {
111141
await _registry.loadLanguages(await resolveLangs(langs))
112142
}
@@ -122,6 +152,7 @@ export async function getHighlighterCore(options: HighlighterCoreOptions) {
122152
return {
123153
codeToThemedTokens,
124154
codeToHtml,
155+
codeToHtmlDualThemes,
125156
loadLanguage,
126157
loadTheme,
127158
getLoadedThemes: () => _registry.getLoadedThemes(),

src/core/renderer-html-dual-themes.ts

+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import type { HtmlRendererOptions, ThemedToken } from '../types'
2+
import { renderToHtml } from './renderer-html'
3+
4+
export function _syncTwoThemedTokens(tokens1: ThemedToken[][], tokens2: ThemedToken[][]) {
5+
const out1: ThemedToken[][] = []
6+
const out2: ThemedToken[][] = []
7+
8+
for (let i = 0; i < tokens1.length; i++) {
9+
const line1 = tokens1[i]
10+
const line2 = tokens2[i]
11+
12+
const line1out: ThemedToken[] = []
13+
const line2out: ThemedToken[] = []
14+
out1.push(line1out)
15+
out2.push(line2out)
16+
17+
let i1 = 0
18+
let i2 = 0
19+
20+
let token1 = line1[i1]
21+
let token2 = line2[i2]
22+
23+
while (token1 && token2) {
24+
if (token1.content.length > token2.content.length) {
25+
line1out.push(
26+
{
27+
...token1,
28+
content: token1.content.slice(0, token2.content.length),
29+
},
30+
)
31+
token1 = {
32+
...token1,
33+
content: token1.content.slice(token2.content.length),
34+
}
35+
line2out.push(token2)
36+
i2 += 1
37+
token2 = line2[i2]
38+
}
39+
else if (token1.content.length < token2.content.length) {
40+
line2out.push(
41+
{
42+
...token2,
43+
content: token2.content.slice(0, token1.content.length),
44+
},
45+
)
46+
token2 = {
47+
...token2,
48+
content: token2.content.slice(token1.content.length),
49+
}
50+
line1out.push(token1)
51+
i1 += 1
52+
token1 = line1[i1]
53+
}
54+
else {
55+
line1out.push(token1)
56+
line2out.push(token2)
57+
i1 += 1
58+
i2 += 1
59+
token1 = line1[i1]
60+
token2 = line2[i2]
61+
}
62+
}
63+
}
64+
65+
return [out1, out2]
66+
}
67+
68+
export function renderToHtmlDualThemes(
69+
tokens1: ThemedToken[][],
70+
tokens2: ThemedToken[][],
71+
cssName = '--shiki-dark',
72+
options: HtmlRendererOptions = {},
73+
) {
74+
const [synced1, synced2] = _syncTwoThemedTokens(tokens1, tokens2)
75+
76+
const merged: ThemedToken[][] = []
77+
for (let i = 0; i < synced1.length; i++) {
78+
const line1 = synced1[i]
79+
const line2 = synced2[i]
80+
const lineout: any[] = []
81+
merged.push(lineout)
82+
for (let j = 0; j < line1.length; j++) {
83+
const token1 = line1[j]
84+
const token2 = line2[j]
85+
lineout.push({
86+
...token1,
87+
color: `${token1.color || 'inherit'};${cssName}: ${token2.color || 'inherit'}`,
88+
})
89+
}
90+
}
91+
92+
return renderToHtml(merged, options)
93+
}

src/core/renderer.ts src/core/renderer-html.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const defaultElements: ElementsOptions = {
1818

1919
export function renderToHtml(lines: ThemedToken[][], options: HtmlRendererOptions = {}) {
2020
const bg = options.bg || '#fff'
21+
const fg = options.bg || '#000'
2122
const optionsByLineNumber = groupBy(options.lineOptions ?? [], option => option.line)
2223
const userElements = options.elements || {}
2324

@@ -38,7 +39,7 @@ export function renderToHtml(lines: ThemedToken[][], options: HtmlRendererOption
3839

3940
return h(
4041
'pre',
41-
{ className: `shiki ${options.themeName || ''}`, style: `background-color: ${bg}` },
42+
{ className: `shiki ${options.themeName || ''}`, style: `background-color: ${bg}; color: ${fg}` },
4243
[
4344
options.langId ? `<div class="language-id">${options.langId}</div>` : '',
4445
h(

src/types.ts

+17
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,23 @@ export interface CodeToHtmlOptions<Languages = string, Themes = string> {
4747
lineOptions?: LineOption[]
4848
}
4949

50+
export interface CodeToHtmlDualThemesOptions<Languages = string, Themes = string> {
51+
lang?: Languages | PlainTextLanguage
52+
theme: {
53+
light: Themes
54+
dark: Themes
55+
}
56+
/**
57+
* @default 'light'
58+
*/
59+
defaultColor?: 'light' | 'dark'
60+
/**
61+
* @default '--shiki-dark'
62+
*/
63+
cssVariableName?: string
64+
lineOptions?: LineOption[]
65+
}
66+
5067
export interface CodeToThemedTokensOptions<Languages = string, Themes = string> {
5168
lang?: Languages | PlainTextLanguage
5269
theme?: Themes

test/core.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ describe('should', () => {
1818
})
1919

2020
expect(shiki.codeToHtml('console.log("Hi")', { lang: 'javascript' }))
21-
.toMatchInlineSnapshot('"<pre class=\\"shiki nord\\" style=\\"background-color: #2e3440ff\\" tabindex=\\"0\\"><code><span class=\\"line\\"><span style=\\"color: #D8DEE9\\">console</span><span style=\\"color: #ECEFF4\\">.</span><span style=\\"color: #88C0D0\\">log</span><span style=\\"color: #D8DEE9FF\\">(</span><span style=\\"color: #ECEFF4\\">&quot;</span><span style=\\"color: #A3BE8C\\">Hi</span><span style=\\"color: #ECEFF4\\">&quot;</span><span style=\\"color: #D8DEE9FF\\">)</span></span></code></pre>"')
21+
.toMatchInlineSnapshot('"<pre class=\\"shiki nord\\" style=\\"background-color: #2e3440ff; color: #2e3440ff\\" tabindex=\\"0\\"><code><span class=\\"line\\"><span style=\\"color: #D8DEE9\\">console</span><span style=\\"color: #ECEFF4\\">.</span><span style=\\"color: #88C0D0\\">log</span><span style=\\"color: #D8DEE9FF\\">(</span><span style=\\"color: #ECEFF4\\">&quot;</span><span style=\\"color: #A3BE8C\\">Hi</span><span style=\\"color: #ECEFF4\\">&quot;</span><span style=\\"color: #D8DEE9FF\\">)</span></span></code></pre>"')
2222
})
2323

2424
it('dynamic load theme and lang', async () => {
@@ -55,7 +55,7 @@ describe('should', () => {
5555
`)
5656

5757
expect(shiki.codeToHtml('print 1', { lang: 'python', theme: 'vitesse-light' }))
58-
.toMatchInlineSnapshot('"<pre class=\\"shiki vitesse-light\\" style=\\"background-color: #ffffff\\" tabindex=\\"0\\"><code><span class=\\"line\\"><span style=\\"color: #998418\\">print</span><span style=\\"color: #393A34\\"> </span><span style=\\"color: #2F798A\\">1</span></span></code></pre>"')
58+
.toMatchInlineSnapshot('"<pre class=\\"shiki vitesse-light\\" style=\\"background-color: #ffffff; color: #ffffff\\" tabindex=\\"0\\"><code><span class=\\"line\\"><span style=\\"color: #998418\\">print</span><span style=\\"color: #393A34\\"> </span><span style=\\"color: #2F798A\\">1</span></span></code></pre>"')
5959
})
6060

6161
it('requires nested lang', async () => {

test/dual-themes.test.ts

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, it } from 'vitest'
2+
import type { ThemedToken } from '../src'
3+
import { codeToHtmlDualThemes, codeToThemedTokens } from '../src'
4+
import { _syncTwoThemedTokens } from '../src/core/renderer-html-dual-themes'
5+
6+
describe('should', () => {
7+
it('syncTwoThemedTokens', async () => {
8+
function stringifyTokens(tokens: ThemedToken[][]) {
9+
return tokens.map(line => line.map(token => token.content).join('_')).join('\n')
10+
}
11+
12+
const lines1 = await codeToThemedTokens('console.log("hello")', { lang: 'js', theme: 'vitesse-dark', includeExplanation: true })
13+
const lines2 = await codeToThemedTokens('console.log("hello")', { lang: 'js', theme: 'min-light', includeExplanation: true })
14+
15+
expect(stringifyTokens(lines1))
16+
.toMatchInlineSnapshot('"console_._log_(_\\"_hello_\\"_)"')
17+
expect(stringifyTokens(lines2))
18+
.toMatchInlineSnapshot('"console_.log_(_\\"hello\\"_)"')
19+
20+
const [out1, out2] = _syncTwoThemedTokens(lines1, lines2)
21+
22+
expect(stringifyTokens(out1))
23+
.toBe(stringifyTokens(out2))
24+
})
25+
26+
it('codeToHtmlDualThemes', async () => {
27+
const code = await codeToHtmlDualThemes('console.log("hello")', {
28+
lang: 'js',
29+
theme: {
30+
dark: 'nord',
31+
light: 'min-light',
32+
},
33+
})
34+
35+
const snippet = `
36+
<style>
37+
.dark .shiki {
38+
background-color: var(--shiki-dark-bg) !important;
39+
color: var(--shiki-dark) !important;
40+
}
41+
.dark .shiki span {
42+
color: var(--shiki-dark) !important;
43+
}
44+
</style>
45+
<button onclick="document.body.classList.toggle('dark')">Toggle theme</button>
46+
`
47+
48+
expect(snippet + code)
49+
.toMatchFileSnapshot('./out/dual-themes.html')
50+
})
51+
})

0 commit comments

Comments
 (0)