From ed31ee55b94931df012e1146cbde34ccd9358520 Mon Sep 17 00:00:00 2001 From: Quentin Roy Date: Sun, 19 May 2024 01:54:32 +0200 Subject: [PATCH] feat: scoped color replacements (#680) --- docs/guide/theme-colors.md | 22 +++++ packages/core/src/code-to-tokens-ansi.ts | 7 +- packages/core/src/code-to-tokens-base.ts | 7 +- packages/core/src/code-to-tokens.ts | 13 ++- packages/core/src/types/tokens.ts | 2 +- packages/core/src/utils.ts | 17 +++- packages/shiki/test/color-replacement.test.ts | 86 ++++++++++++++++++- 7 files changed, 133 insertions(+), 21 deletions(-) diff --git a/docs/guide/theme-colors.md b/docs/guide/theme-colors.md index 137553e06..ca204f76a 100644 --- a/docs/guide/theme-colors.md +++ b/docs/guide/theme-colors.md @@ -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 diff --git a/packages/core/src/code-to-tokens-ansi.ts b/packages/core/src/code-to-tokens-ansi.ts index 5acca9a3c..ea8129be5 100644 --- a/packages/core/src/code-to-tokens-ansi.ts +++ b/packages/core/src/code-to-tokens-ansi.ts @@ -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( diff --git a/packages/core/src/code-to-tokens-base.ts b/packages/core/src/code-to-tokens-base.ts index e7c3b12eb..01c1737c0 100644 --- a/packages/core/src/code-to-tokens-base.ts +++ b/packages/core/src/code-to-tokens-base.ts @@ -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' /** @@ -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, diff --git a/packages/core/src/code-to-tokens.ts b/packages/core/src/code-to-tokens.ts index e80e8de2f..43ebeb09f 100644 --- a/packages/core/src/code-to-tokens.ts +++ b/packages/core/src/code-to-tokens.ts @@ -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. @@ -24,7 +24,6 @@ export function codeToTokens( const { defaultColor = 'light', cssVariablePrefix = '--shiki-', - colorReplacements, } = options const themes = Object.entries(options.themes) @@ -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, diff --git a/packages/core/src/types/tokens.ts b/packages/core/src/types/tokens.ts index 40b225d51..95e98c786 100644 --- a/packages/core/src/types/tokens.ts +++ b/packages/core/src/types/tokens.ts @@ -157,7 +157,7 @@ export interface TokenizeWithThemeOptions { * * This will be merged with theme's `colorReplacements` if any. */ - colorReplacements?: Record + colorReplacements?: Record> /** * Lines above this length will not be tokenized for performance reasons. diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index e1dfd83ad..3decf1452 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -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(x: MaybeArray): T[] { return Array.isArray(x) ? x : [x] @@ -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 export function applyColorReplacements(color?: string | undefined, replacements?: Record): string | undefined export function applyColorReplacements(color?: string, replacements?: Record): string | undefined { diff --git a/packages/shiki/test/color-replacement.test.ts b/packages/shiki/test/color-replacement.test.ts index 90f06b3e5..9d6ebe527 100644 --- a/packages/shiki/test/color-replacement.test.ts +++ b/packages/shiki/test/color-replacement.test.ts @@ -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: { @@ -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(` + "
+      
+      
+      
+      console.log(
+      
+      "hi"
+      
+      )
+      
+      
+      
+ " + `) +})