Skip to content

Commit

Permalink
feat: scoped color replacements (#680)
Browse files Browse the repository at this point in the history
  • Loading branch information
QuentinRoy authored May 18, 2024
1 parent c82feb5 commit ed31ee5
Show file tree
Hide file tree
Showing 7 changed files with 133 additions and 21 deletions.
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>
"
`)
})

0 comments on commit ed31ee5

Please sign in to comment.