Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/guide/theme-colors.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,28 @@ const html = await codeToHtml(
)
```

In addition, `colorReplacements` may contain scoped replacements. This is useful when you provide multiple themes and want to replace the colors of a specific theme:

```js
const html = await codeToHtml(
code,
{
lang: 'js',
themes: { dark: 'min-dark', light: 'min-light' },
colorReplacements: {
'min-dark': {
'#ff79c6': '#189eff'
},
'min-light': {
'#ff79c6': '#defdef'
}
}
}
)
```

This is only allowed for the `colorReplacements` option and not for the theme object.

## CSS Variables Theme

::: warning Experimental
Expand Down
7 changes: 2 additions & 5 deletions packages/core/src/code-to-tokens-ansi.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
import { createAnsiSequenceParser, createColorPalette, namedColors } from 'ansi-sequence-parser'
import type { ThemeRegistrationResolved, ThemedToken, TokenizeWithThemeOptions } from './types'
import { FontStyle } from './types'
import { applyColorReplacements, splitLines } from './utils'
import { applyColorReplacements, resolveColorReplacements, splitLines } from './utils'

export function tokenizeAnsiWithTheme(
theme: ThemeRegistrationResolved,
fileContents: string,
options?: TokenizeWithThemeOptions,
): ThemedToken[][] {
const colorReplacements = {
...theme.colorReplacements,
...options?.colorReplacements,
}
const colorReplacements = resolveColorReplacements(theme, options)
const lines = splitLines(fileContents)

const colorPalette = createColorPalette(
Expand Down
7 changes: 2 additions & 5 deletions packages/core/src/code-to-tokens-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { IGrammar } from './textmate'
import { INITIAL } from './textmate'
import type { CodeToTokensBaseOptions, FontStyle, ShikiInternal, ThemeRegistrationResolved, ThemedToken, ThemedTokenScopeExplanation, TokenizeWithThemeOptions } from './types'
import { StackElementMetadata } from './stack-element-metadata'
import { applyColorReplacements, isNoneTheme, isPlainLang, splitLines } from './utils'
import { applyColorReplacements, isNoneTheme, isPlainLang, resolveColorReplacements, splitLines } from './utils'
import { tokenizeAnsiWithTheme } from './code-to-tokens-ansi'

/**
Expand Down Expand Up @@ -40,10 +40,7 @@ export function tokenizeWithTheme(
colorMap: string[],
options: TokenizeWithThemeOptions,
): ThemedToken[][] {
const colorReplacements = {
...theme.colorReplacements,
...options?.colorReplacements,
}
const colorReplacements = resolveColorReplacements(theme, options)

const {
tokenizeMaxLineLength = 0,
Expand Down
13 changes: 6 additions & 7 deletions packages/core/src/code-to-tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { codeToTokensBase } from './code-to-tokens-base'
import { codeToTokensWithThemes } from './code-to-tokens-themes'
import { ShikiError } from './error'
import type { CodeToTokensOptions, ShikiInternal, ThemedToken, ThemedTokenWithVariants, TokensResult } from './types'
import { applyColorReplacements, getTokenStyleObject, stringifyTokenStyle } from './utils'
import { applyColorReplacements, getTokenStyleObject, resolveColorReplacements, stringifyTokenStyle } from './utils'

/**
* High-level code-to-tokens API.
Expand All @@ -24,7 +24,6 @@ export function codeToTokens(
const {
defaultColor = 'light',
cssVariablePrefix = '--shiki-',
colorReplacements,
} = options

const themes = Object.entries(options.themes)
Expand All @@ -49,19 +48,19 @@ export function codeToTokens(
tokens = themeTokens
.map(line => line.map(token => mergeToken(token, themesOrder, cssVariablePrefix, defaultColor)))

const themeColorReplacements = themes.map(t => resolveColorReplacements(t.theme, options))

fg = themes.map((t, idx) => (idx === 0 && defaultColor
? ''
: `${cssVariablePrefix + t.color}:`) + (applyColorReplacements(themeRegs[idx].fg, colorReplacements) || 'inherit')).join(';')
: `${cssVariablePrefix + t.color}:`) + (applyColorReplacements(themeRegs[idx].fg, themeColorReplacements[idx]) || 'inherit')).join(';')
bg = themes.map((t, idx) => (idx === 0 && defaultColor
? ''
: `${cssVariablePrefix + t.color}-bg:`) + (applyColorReplacements(themeRegs[idx].bg, colorReplacements) || 'inherit')).join(';')
: `${cssVariablePrefix + t.color}-bg:`) + (applyColorReplacements(themeRegs[idx].bg, themeColorReplacements[idx]) || 'inherit')).join(';')
themeName = `shiki-themes ${themeRegs.map(t => t.name).join(' ')}`
rootStyle = defaultColor ? undefined : [fg, bg].join(';')
}
else if ('theme' in options) {
const {
colorReplacements,
} = options
const colorReplacements = resolveColorReplacements(options.theme, options.colorReplacements)

tokens = codeToTokensBase(
internal,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/types/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ export interface TokenizeWithThemeOptions {
*
* This will be merged with theme's `colorReplacements` if any.
*/
colorReplacements?: Record<string, string>
colorReplacements?: Record<string, string | Record<string, string>>

/**
* Lines above this length will not be tokenized for performance reasons.
Expand Down
17 changes: 16 additions & 1 deletion packages/core/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Element } from 'hast'
import { FontStyle } from './types'
import type { MaybeArray, PlainTextLanguage, Position, SpecialLanguage, SpecialTheme, ThemeInput, ThemedToken, TokenStyles } from './types'
import type { MaybeArray, PlainTextLanguage, Position, SpecialLanguage, SpecialTheme, ThemeInput, ThemeRegistrationAny, ThemedToken, TokenStyles, TokenizeWithThemeOptions } from './types'

export function toArray<T>(x: MaybeArray<T>): T[] {
return Array.isArray(x) ? x : [x]
Expand Down Expand Up @@ -146,6 +146,21 @@ export function splitTokens<
})
}

export function resolveColorReplacements(
theme: ThemeRegistrationAny | string,
options?: TokenizeWithThemeOptions,
) {
const replacements = typeof theme === 'string' ? {} : { ...theme.colorReplacements }
const themeName = typeof theme === 'string' ? theme : theme.name
for (const [key, value] of Object.entries(options?.colorReplacements || {})) {
if (typeof value === 'string')
replacements[key] = value
else if (key === themeName)
Object.assign(replacements, value)
}
return replacements
}

export function applyColorReplacements(color: string, replacements?: Record<string, string>): string
export function applyColorReplacements(color?: string | undefined, replacements?: Record<string, string>): string | undefined
export function applyColorReplacements(color?: string, replacements?: Record<string, string>): string | undefined {
Expand Down
86 changes: 84 additions & 2 deletions packages/shiki/test/color-replacement.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,32 @@
import { expect, it } from 'vitest'
import { codeToHtml } from '../src'
import type { ThemeRegistrationResolved } from '../src'
import { codeToHtml, resolveColorReplacements } from '../src'

it('colorReplacements', async () => {
it('resolveColorReplacements', async () => {
expect(resolveColorReplacements('nord', {
colorReplacements: {
'#000000': '#ffffff',
'nord': {
'#000000': '#222222',
'#abcabc': '#defdef',
'#ffffff': '#111111',
},
'other': {
'#000000': '#444444',
'#ffffff': '#333333',
},
'#ffffff': '#000000',
},
})).toEqual(
{
'#abcabc': '#defdef',
'#000000': '#222222',
'#ffffff': '#000000',
},
)
})

it('flat colorReplacements', async () => {
const result = await codeToHtml('console.log("hi")', {
lang: 'js',
themes: {
Expand Down Expand Up @@ -44,3 +69,60 @@ it('colorReplacements', async () => {
"
`)
})

it('scoped colorReplacements', async () => {
const customLightTheme: ThemeRegistrationResolved = {
name: 'custom-light',
type: 'light',
settings: [
{ scope: 'string', settings: { foreground: '#a3be8c' } },
],
fg: '#393a34',
bg: '#b07d48',
}
const customDarkTheme: ThemeRegistrationResolved = {
...customLightTheme,
type: 'dark',
name: 'custom-dark',
}

const result = await codeToHtml('console.log("hi")', {
lang: 'js',
themes: {
light: customLightTheme,
dark: customDarkTheme,
},
colorReplacements: {
'custom-dark': {
'#b07d48': 'var(---replaced-1)',
},
'custom-light': {
'#393a34': 'var(---replaced-2)',
'#b07d48': 'var(---replaced-3)',
},
'#393a34': 'var(---replaced-4)',
},
})

expect(result).toContain('var(---replaced-1)')
expect(result).not.toContain('var(---replaced-2)')
expect(result).toContain('var(---replaced-3)')
expect(result).toContain('var(---replaced-4)')

expect(result.replace(/>/g, '>\n'))
.toMatchInlineSnapshot(`
"<pre class="shiki shiki-themes custom-light custom-dark" style="background-color:var(---replaced-3);--shiki-dark-bg:var(---replaced-1);color:var(---replaced-4);--shiki-dark:var(---replaced-4)" tabindex="0">
<code>
<span class="line">
<span style="color:var(---replaced-4);--shiki-dark:var(---replaced-4)">
console.log(</span>
<span style="color:#A3BE8C;--shiki-dark:#A3BE8C">
"hi"</span>
<span style="color:var(---replaced-4);--shiki-dark:var(---replaced-4)">
)</span>
</span>
</code>
</pre>
"
`)
})