Repository: shikijs/shiki
Pull request #831 (2024-11-08 19:30:44)

feat: port `@michael-makes/shiki-colorized-brackets` JSR package
### Description

There is a long-standing feature request to add VSCode-style bracket pair colorization to Shiki. I created a transformer that accomplishes this, and after discussion in the linked issue, it sounds like there's interest in incorporating it as an official plugin.

### Linked Issues

fix https://github.com/shikijs/shiki/issues/303

### Additional context

This is mostly a straight port of my package, https://github.com/MichaelMakesGames/shiki-colorized-brackets. I've made some efforts to align with the styles and patterns I observed elsewhere in the Shiki repo, but let me know if there are other improvements I can make.

I'd like to call out that the way this splits tokens apart feels a little hacky, mostly just doing some basic text search. I investigated another route that used the token explanations, but that had issues highlighting mismatched brackets. If that can be worked around, that would be a more robust solution.

Question regarding the docs: currently this transformer isn't enabled in the docs. Would we want this enabled there? Perhaps only for the page for this package?

Another note: I listed myself as the author of the package. I noticed all the other packages list @antfu. I'm fine changing that.

netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy Preview for *shiki-matsu* processing.


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | a969a6e1206f9033074c24e6e4c6c51bc6c64b02 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/shiki-matsu/deploys/672e66e7edbe580008736c3c |
netlify[bot]: ### <span aria-hidden="true">👷</span> Deploy Preview for *shiki-next* processing.


|  Name | Link |
|:-:|------------------------|
|<span aria-hidden="true">🔨</span> Latest commit | a969a6e1206f9033074c24e6e4c6c51bc6c64b02 |
|<span aria-hidden="true">🔍</span> Latest deploy log | https://app.netlify.com/sites/shiki-next/deploys/672e66e71cc39a0008dd368b |
antfu: Awesome! Thanks a lot! Please give me some time to properly review.

> splits tokens apart feels a little hacky

Do you consider using https://shiki.style/guide/decorations? It should handle the complexity of token splitting, where you just need to provide the positions.

> Would we want this enabled there? Perhaps only for the page for this package?

Yeah I would vote to enable it only for the page of that package. You can refer to how we do that with theme or other transformers.

> I listed myself as the author of the package.

Surely! It totally make sense to have your name on that package!
MichaelMakesGames: > Yeah I would vote to enable it only for the page of that package

This is done in the latest commit. I added an `explicitTrigger` option, similar to twoslash, so the plugin can be enabled only for certain code blocks based on the meta string

Patch:
diff --git a/.gitignore b/.gitignore
index 451ecb15..0dde4780 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,7 @@ logs
 node_modules
 temp
 tmp
+packages/colorized-brackets/src/themes.ts
 packages/shiki/src/langs
 packages/shiki/src/themes
 packages/shiki/src/*.json
diff --git a/docs/.vitepress/components/LanguagesList.vue b/docs/.vitepress/components/LanguagesList.vue
index 62a5f3fb..7d9e6d5b 100644
--- a/docs/.vitepress/components/LanguagesList.vue
+++ b/docs/.vitepress/components/LanguagesList.vue
@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { computed, ref } from 'vue'
+import { computed, ref, watch } from 'vue'
 import { usePlayground } from '../store/playground'
 
 const play = usePlayground()
@@ -17,6 +17,14 @@ const langs = computed(() => {
     return play.bundledLangsWeb
   return play.bundledLangsFull
 })
+watch(showModel, () => {
+  if (showModel.value) {
+    (document.scrollingElement as HTMLElement).style.overflow = 'hidden'
+  }
+  else {
+    (document.scrollingElement as HTMLElement).style.overflow = 'initial'
+  }
+})
 </script>
 
 <template>
diff --git a/docs/.vitepress/components/ShikiMiniPlayground.vue b/docs/.vitepress/components/ShikiMiniPlayground.vue
index 173c3193..09108acf 100644
--- a/docs/.vitepress/components/ShikiMiniPlayground.vue
+++ b/docs/.vitepress/components/ShikiMiniPlayground.vue
@@ -27,10 +27,10 @@ function onInput() {
 
 <template>
   <div
-    class="language-ts vp-adaptive-theme mini-playground transition-none!" shadow
+    class="language-ts vp-adaptive-theme transition-none! mini-playground" shadow
     :style="[play.preStyle, { colorScheme: currentThemeType }]"
   >
-    <div absolute z-10 p2 px3 pl5 flex="~ gap-1 items-center" left-0 top-0 right-0 border="b-solid gray/5">
+    <div sticky z-12 p2 px3 pl5 flex="~ gap-1 items-center" left-0 top-0 right-0 border="b-solid gray/5" bg-inherit>
       <div i-carbon:chevron-down op50 />
       <select v-model="play.lang" font-mono :style="play.preStyle">
         <option v-for="lang in play.allLanguages" :key="lang.id" :value="lang.id">
@@ -62,7 +62,7 @@ function onInput() {
         <div i-carbon:shuffle op50 />
       </button>
     </div>
-    <div relative mt-10 min-h-100>
+    <div relative min-h-100 float-left min-w-full>
       <span ref="highlightContainerRef" v-html="play.output" />
       <textarea
         ref="textAreaRef"
@@ -80,15 +80,11 @@ function onInput() {
 </template>
 
 <style>
-.mini-playground {
-  overflow: hidden;
-}
-
 .mini-playground select {
   background: transparent;
   color: inherit;
   min-width: 8em;
-  padding: 0 !important;
+  padding: 0px !important;
   position: relative;
 }
 .mini-playground select:focus {
diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index 6cb30dca..c177b221 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -4,6 +4,7 @@ import { defineConfig } from 'vitepress'
 import { withMermaid } from 'vitepress-plugin-mermaid'
 
 import { version } from '../../package.json'
+import { transformerColorizedBrackets } from '../../packages/colorized-brackets/src'
 import { transformerMetaWordHighlight, transformerNotationWordHighlight, transformerRemoveNotationEscape } from '../../packages/transformers/src'
 import { defaultHoverInfoProcessor, transformerTwoslash } from '../../packages/vitepress-twoslash/src/index'
 import vite from './vite.config'
@@ -41,6 +42,7 @@ const INTEGRATIONS: DefaultTheme.NavItemWithLink[] = [
   { text: 'Next', link: '/packages/next' },
   { text: 'Astro', link: '/packages/astro' },
   { text: 'Common Transformers', link: '/packages/transformers' },
+  { text: 'Colorized Brackets', link: '/packages/colorized-brackets' },
   { text: 'CLI', link: '/packages/cli' },
 ]
 
@@ -125,6 +127,7 @@ export default withMermaid(defineConfig({
         },
       }),
       transformerRemoveNotationEscape(),
+      transformerColorizedBrackets({ explicitTrigger: true }),
     ],
   },
 
diff --git a/docs/guide/transformers.md b/docs/guide/transformers.md
index 8d371def..04b54778 100644
--- a/docs/guide/transformers.md
+++ b/docs/guide/transformers.md
@@ -28,7 +28,7 @@ const code = await codeToHtml('foo\bar', {
 })
 ```
 
-We also provide some common transformers for you to use, see [`@shikijs/transforms`](/packages/transformers) for more details.
+We also provide some common transformers for you to use, see [`@shikijs/transforms`](/packages/transformers) and [`@shikijs/colorized-brackets](/packages/colorized-brackets) for more details.
 
 ## Transformer Hooks
 
@@ -62,7 +62,7 @@ Transformers can also access markdown 'meta' strings in [supported integrations]
 
 ````markdown
 <!-- [!code word:meta=here] -->
-```html meta=here
+```html foo=bar baz-qux="qu ux"
 ````
 
 You can access the raw meta using:
@@ -71,5 +71,5 @@ You can access the raw meta using:
 
 ```ts
 options.meta
-// => { meta: 'here', __raw: 'meta=here' }
+// => { __raw: 'foo=bar baz-qux="qu ux"' }
 ```
diff --git a/docs/package.json b/docs/package.json
index f4dd76ac..47dc3805 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -12,6 +12,7 @@
   },
   "devDependencies": {
     "@iconify-json/svg-spinners": "catalog:",
+    "@shikijs/colorized-brackets": "workspace:*",
     "@shikijs/transformers": "workspace:*",
     "@shikijs/twoslash": "workspace:*",
     "@unocss/reset": "catalog:",
diff --git a/docs/packages/colorized-brackets.md b/docs/packages/colorized-brackets.md
new file mode 100644
index 00000000..fca353c2
--- /dev/null
+++ b/docs/packages/colorized-brackets.md
@@ -0,0 +1,107 @@
+---
+outline: deep
+---
+
+# @shikijs/colorized-brackets
+
+<Badges name="@shikijs/colorized-brackets" />
+
+VSCode-style colorized brackets transformer for Shiki.
+
+## Install
+
+```bash
+npm i -D @shikijs/colorized-brackets
+```
+
+## Usage
+
+Add to your Shiki transformers:
+
+```ts colorize-brackets
+import { transformerColorizedBrackets } from '@shikijs/colorized-brackets'
+import { codeToHtml } from 'shiki'
+
+const html = await codeToHtml('let values: number[] = [];', {
+  lang: 'ts',
+  theme: 'dark-plus',
+  transformers: [transformerColorizedBrackets()],
+})
+```
+
+### Colors
+
+Brackets are automatically colored according to your Shiki theme (or themes if using [dual themes](https://shiki.style/guide/dual-themes)), with support for all of Shiki's built-in themes. However, you can customize colors if you've added custom themes to Shiki, or if you want to override the colors of a built-in theme:
+
+```ts colorize-brackets
+const html = await codeToHtml('let values: number[] = [];', {
+  lang: 'ts',
+  theme: myCustomTheme,
+  transformers: [transformerColorizedBrackets({
+    themes: {
+      'my-custom-theme': ['goldenrod', 'blueviolet', 'dodgerblue', 'crimson'],
+    },
+  })],
+})
+```
+
+The final color is the mismatched bracket color. The other colors are for each "level" of bracket pair. Any valid CSS color can be used.
+
+If no bracket colors are found for a theme, it falls back to the default `dark-plus` theme.
+
+### Brackets
+
+You can customize the bracket pairs:
+
+```ts colorize-brackets
+const transformer = transformerColorizedBrackets({
+  bracketPairs: [{ opener: '{', closer: '}' }],
+})
+```
+
+The above would only colorize `{}` curly brackets. The default config colorizes `[]` square brackets, `{}` curly brackets, `()` parentheses, and `<>` angle brackets (only in TS type annotations).
+
+For advanced usage, you can specify which TextMate scopes a bracket pair is allowed or denied in, using `scopesAllowList` and `scopesDenyList`. For example, the default config for `<>` angle brackets is:
+
+```ts colorize-brackets
+const bracketPair = {
+  opener: '<',
+  closer: '>',
+  scopesAllowList: [
+    'punctuation.definition.typeparameters.begin.ts',
+    'punctuation.definition.typeparameters.end.ts',
+  ],
+}
+```
+
+### Language-specific Overrides
+
+All settings can be overridden for specific languages using the `langs` option:
+
+```ts colorize-brackets
+const transformer = transformerColorizedBrackets({
+  langs: { ts: myCustomTypescriptConfig },
+})
+```
+
+### Explicit Trigger
+
+If you do not want colorized brackets for all code blocks, you can enable the `explicitTrigger` option:
+
+```ts colorize-brackets
+const transformer = transformerColorizedBrackets({
+  explicitTrigger: true,
+})
+```
+
+Then, only code blocks with the `colorize-brackets` [meta string](/guide/transformers#meta) will have bracket colorizing enabled.
+
+````md
+```ts
+// no bracket colorizing
+```
+
+```ts colorize-brackets
+// brackets will be colorized
+```
+````
diff --git a/docs/packages/transformers.md b/docs/packages/transformers.md
index e13db3fa..f1106e7b 100644
--- a/docs/packages/transformers.md
+++ b/docs/packages/transformers.md
@@ -348,3 +348,84 @@ Remove line breaks between `<span class="line">`. Useful when you set `display:
 
 Transform `// [\!code ...]` to `// [!code ...]`.
 Avoid rendering the escaped notation syntax as it is.
+
+---
+
+### `transformerStyleToClass`
+
+Convert Shiki's inline styles to unique classes.
+
+Class names are generated based on the hash value of the style object with the prefix/suffix you provide. You can put this transformer in multiple highlights passes and then get the CSS at the end to reuse the exact same styles. As Shiki doesn't handle CSS, it's on your integration to decide how to extract and apply/bundle the CSS.
+
+For example:
+
+```ts
+import { transformerStyleToClass } from '@shikijs/transformers'
+import { codeToHtml } from 'shiki'
+
+const toClass = transformerStyleToClass({ // [!code highlight:3]
+  classPrefix: '__shiki_',
+})
+
+const code = `console.log('hello')`
+const html = await codeToHtml(code, {
+  lang: 'ts',
+  themes: {
+    dark: 'vitesse-dark',
+    light: 'vitesse-light',
+  },
+  defaultColor: false,
+  transformers: [toClass], // [!code highlight]
+})
+
+// The transformer instance exposes some methods to get the CSS
+const css = toClass.getCSS() // [!code highlight]
+
+// use `html` and `css` in your app
+```
+
+HTML output:
+
+```html
+<pre class="shiki shiki-themes vitesse-dark vitesse-light __shiki_9knfln" tabindex="0"><code><span class="line">
+  <span class="__shiki_14cn0u">console</span>
+  <span class="__shiki_ps5uht">.</span>
+  <span class="__shiki_1zrdwt">log</span>
+  <span class="__shiki_ps5uht">(</span>
+  <span class="__shiki_236mh3">'</span>
+  <span class="__shiki_1g4r39">hello</span>
+  <span class="__shiki_236mh3">'</span>
+  <span class="__shiki_ps5uht">)</span>
+</span></code></pre>
+```
+
+CSS output:
+
+```css
+.__shiki_14cn0u {
+  --shiki-dark: #bd976a;
+  --shiki-light: #b07d48;
+}
+.__shiki_ps5uht {
+  --shiki-dark: #666666;
+  --shiki-light: #999999;
+}
+.__shiki_1zrdwt {
+  --shiki-dark: #80a665;
+  --shiki-light: #59873a;
+}
+.__shiki_236mh3 {
+  --shiki-dark: #c98a7d77;
+  --shiki-light: #b5695977;
+}
+.__shiki_1g4r39 {
+  --shiki-dark: #c98a7d;
+  --shiki-light: #b56959;
+}
+.__shiki_9knfln {
+  --shiki-dark: #dbd7caee;
+  --shiki-light: #393a34;
+  --shiki-dark-bg: #121212;
+  --shiki-light-bg: #ffffff;
+}
+```
diff --git a/packages/colorized-brackets/README.md b/packages/colorized-brackets/README.md
new file mode 100644
index 00000000..61c2fa42
--- /dev/null
+++ b/packages/colorized-brackets/README.md
@@ -0,0 +1,9 @@
+# @shikijs/colorized-brackets
+
+VSCode-style colorized brackets transformer for [Shiki](https://github.com/shikijs/shiki).
+
+[Documentation](https://shiki.style/packages/colorized-brackets)
+
+## License
+
+MIT
diff --git a/packages/colorized-brackets/build.config.ts b/packages/colorized-brackets/build.config.ts
new file mode 100644
index 00000000..6afc6c96
--- /dev/null
+++ b/packages/colorized-brackets/build.config.ts
@@ -0,0 +1,19 @@
+import { defineBuildConfig } from 'unbuild'
+
+export default defineBuildConfig({
+  entries: [
+    'src/index.ts',
+  ],
+  declaration: true,
+  rollup: {
+    emitCJS: false,
+    dts: {
+      compilerOptions: {
+        paths: {},
+      },
+    },
+  },
+  externals: [
+    'hast',
+  ],
+})
diff --git a/packages/colorized-brackets/package.json b/packages/colorized-brackets/package.json
new file mode 100644
index 00000000..9633d775
--- /dev/null
+++ b/packages/colorized-brackets/package.json
@@ -0,0 +1,42 @@
+{
+  "name": "@shikijs/colorized-brackets",
+  "type": "module",
+  "version": "1.22.2",
+  "description": "Collective of common transformers transformers for Shiki",
+  "author": "Michael Moore <mscottmoore@pm.me>",
+  "license": "MIT",
+  "homepage": "https://github.com/shikijs/shiki#readme",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/shikijs/shiki.git",
+    "directory": "packages/colorized-brackets"
+  },
+  "bugs": "https://github.com/shikijs/shiki/issues",
+  "keywords": [
+    "shiki",
+    "@shikijs/colorized-brackets"
+  ],
+  "sideEffects": false,
+  "exports": {
+    ".": {
+      "types": "./dist/index.d.mts",
+      "default": "./dist/index.mjs"
+    }
+  },
+  "main": "./dist/index.mjs",
+  "module": "./dist/index.mjs",
+  "types": "./dist/index.d.mts",
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "unbuild",
+    "dev": "unbuild --stub",
+    "prepare": "esno scripts/prepare.ts",
+    "prepublishOnly": "nr build",
+    "test": "vitest"
+  },
+  "dependencies": {
+    "shiki": "workspace:*"
+  }
+}
diff --git a/packages/colorized-brackets/scripts/prepare.ts b/packages/colorized-brackets/scripts/prepare.ts
new file mode 100644
index 00000000..15b4caba
--- /dev/null
+++ b/packages/colorized-brackets/scripts/prepare.ts
@@ -0,0 +1,72 @@
+import fs from 'fs-extra'
+import { themes as allThemes } from 'tm-themes'
+
+async function main() {
+  // if a theme doesn't define bracket colors, it falls back to these
+  // from vscode /src/vs/editor/common/core/editorColorRegistry.ts
+  const vsCodeBaseThemes: Record<string, Record<string, string>> = {
+    light: {
+      'editorBracketHighlight.foreground1': '#0431FA',
+      'editorBracketHighlight.foreground2': '#319331',
+      'editorBracketHighlight.foreground3': '#7B3814',
+      'editorBracketHighlight.unexpectedBracket.foreground':
+        'rgba(255, 18, 18, 0.8)',
+    },
+    dark: {
+      'editorBracketHighlight.foreground1': '#FFD700',
+      'editorBracketHighlight.foreground2': '#DA70D6',
+      'editorBracketHighlight.foreground3': '#179FFF',
+      'editorBracketHighlight.unexpectedBracket.foreground':
+        'rgba(255, 18, 18, 0.8)',
+    },
+    lightHighContrast: {
+      'editorBracketHighlight.foreground1': '#0431FA',
+      'editorBracketHighlight.foreground2': '#319331',
+      'editorBracketHighlight.foreground3': '#7B3814',
+      'editorBracketHighlight.unexpectedBracket.foreground': '#B5200D',
+    },
+    darkHighContrast: {
+      'editorBracketHighlight.foreground1': '#FFD700',
+      'editorBracketHighlight.foreground2': '#DA70D6',
+      'editorBracketHighlight.foreground3': '#87CEFA',
+      'editorBracketHighlight.unexpectedBracket.foreground':
+        'rgba(255, 50, 50, 1)',
+    },
+  }
+
+  const themes: Record<string, string[]> = {}
+  for (const t of allThemes) {
+    const theme = await fs.readJSON(`./node_modules/shiki/node_modules/tm-themes/themes/${t.name}.json`)
+    const isHighContrast = t.name.includes('high-contrast')
+    const themeType = theme.type ?? 'dark'
+    const baseTheme = isHighContrast ? `${themeType}HighContrast` : themeType
+    const colors: Record<string, string> = {
+      ...vsCodeBaseThemes[baseTheme],
+      ...theme.colors,
+    }
+    const bracketTheme = [
+      colors['editorBracketHighlight.foreground1'],
+      colors['editorBracketHighlight.foreground2'],
+      colors['editorBracketHighlight.foreground3'],
+      colors['editorBracketHighlight.foreground4'],
+      colors['editorBracketHighlight.foreground5'],
+      colors['editorBracketHighlight.foreground6'],
+      colors['editorBracketHighlight.unexpectedBracket.foreground'],
+    ].filter(Boolean)
+    themes[t.name] = bracketTheme
+  }
+
+  const sorted = Object.fromEntries(
+    Object.entries(themes).sort((a, b) => a[0].localeCompare(b[0])),
+  )
+
+  await fs.writeFile(
+    './src/themes.ts',
+    `// Generated by scripts/prepare.ts
+export default ${JSON.stringify(sorted, undefined, 2)} as Record<string, string[]>
+`,
+    { encoding: 'utf-8' },
+  )
+}
+
+main()
diff --git a/packages/colorized-brackets/src/colorizeBracketTokens.ts b/packages/colorized-brackets/src/colorizeBracketTokens.ts
new file mode 100644
index 00000000..34313ede
--- /dev/null
+++ b/packages/colorized-brackets/src/colorizeBracketTokens.ts
@@ -0,0 +1,153 @@
+import type { CodeOptionsSingleTheme, CodeOptionsThemes, ThemedToken } from 'shiki'
+import type { TransformerColorizedBracketsOptions } from './types'
+import builtInThemes from './themes'
+import { getEmbeddedLang, resolveConfig, shouldIgnoreToken } from './utils'
+
+export default function colorizeBracketTokens(
+  tokens: ThemedToken[],
+  config: TransformerColorizedBracketsOptions,
+  shikiOptions: CodeOptionsThemes,
+  lang: string,
+): void {
+  const openerStack: ThemedToken[] = []
+
+  for (const token of tokens) {
+    const embeddedLang = getEmbeddedLang(token)
+    const resolvedConfig = resolveConfig(config, embeddedLang ?? lang)
+    const openers = new Set(
+      resolvedConfig.bracketPairs.map(pair => pair.opener),
+    )
+    const closers = new Set(
+      resolvedConfig.bracketPairs.map(pair => pair.closer),
+    )
+    const closerToOpener = Object.fromEntries(
+      resolvedConfig.bracketPairs.map(pair => [pair.closer, pair.opener]),
+    )
+
+    const pairDefinition = resolvedConfig.bracketPairs.find(
+      pair =>
+        pair.opener === token.content.trim()
+        || pair.closer === token.content.trim(),
+    )
+    if (
+      !pairDefinition
+      || shouldIgnoreToken(
+        token,
+        pairDefinition.scopesAllowList,
+        pairDefinition.scopesDenyList,
+      )
+    ) {
+      continue
+    }
+    if (openers.has(token.content.trim())) {
+      openerStack.push(token)
+    }
+    else if (closers.has(token.content.trim())) {
+      const opener = openerStack
+        .slice()
+        .reverse()
+        .find(t => t.content.trim() === closerToOpener[token.content.trim()])
+      if (opener) {
+        while (openerStack.at(-1) !== opener) {
+          const unexpected = openerStack.pop()
+          if (unexpected) {
+            assignColorToToken(
+              unexpected,
+              resolvedConfig.themes,
+              shikiOptions,
+              -1,
+            )
+          }
+        }
+        openerStack.pop()
+        assignColorToToken(
+          token,
+          resolvedConfig.themes,
+          shikiOptions,
+          openerStack.length,
+        )
+        assignColorToToken(
+          opener,
+          resolvedConfig.themes,
+          shikiOptions,
+          openerStack.length,
+        )
+      }
+      else {
+        assignColorToToken(token, resolvedConfig.themes, shikiOptions, -1)
+      }
+    }
+  }
+
+  for (const token of openerStack) {
+    assignColorToToken(
+      token,
+      resolveConfig(config, lang).themes,
+      shikiOptions,
+      -1,
+    )
+  }
+}
+
+function assignColorToToken(
+  token: ThemedToken,
+  themes: Record<string, string[]>,
+  shikiOptions: CodeOptionsThemes,
+  level: number,
+): void {
+  if (isSingleTheme(shikiOptions)) {
+    const themeName
+      = typeof shikiOptions.theme === 'string'
+        ? shikiOptions.theme
+        : shikiOptions.theme.name
+    token.color = getColor(themes, themeName, level)
+  }
+  else {
+    const { defaultColor = 'light', cssVariablePrefix = '--shiki-' }
+      = shikiOptions
+    const styles: string[] = []
+
+    for (const [colorName, theme] of Object.entries(shikiOptions.themes)) {
+      const themeName = typeof theme === 'string' ? theme : theme?.name
+      const cssProperty
+        = colorName === defaultColor
+          ? 'color'
+          : `${cssVariablePrefix}${colorName}`
+      styles.push(`${cssProperty}:${getColor(themes, themeName, level)}`)
+    }
+
+    token.htmlStyle = styles.join(';')
+  }
+}
+
+function isSingleTheme(
+  shikiOptions: CodeOptionsThemes,
+): shikiOptions is CodeOptionsSingleTheme {
+  return 'theme' in shikiOptions
+}
+
+const DEFAULT_BRACKETS_COLORS = [
+  '#FFD700',
+  '#DA70D6',
+  '#179FFF',
+  'rgba(255, 18, 18, 0.8)',
+]
+
+function getColor(
+  themes: Record<string, string[]>,
+  themeName: string | undefined,
+  level: number,
+): string {
+  const colors
+    = themeName == null
+      ? DEFAULT_BRACKETS_COLORS
+      : themes[themeName] ?? builtInThemes[themeName] ?? DEFAULT_BRACKETS_COLORS
+
+  const isUnexpected = level === -1
+  if (isUnexpected) {
+    return colors[colors.length - 1]
+  }
+  else {
+    return colors[level % (colors.length - 1)]
+  }
+}
diff --git a/packages/colorized-brackets/src/index.ts b/packages/colorized-brackets/src/index.ts
new file mode 100644
index 00000000..467c7300
--- /dev/null
+++ b/packages/colorized-brackets/src/index.ts
@@ -0,0 +1,98 @@
+import type {
+  CodeToTokensOptions,
+  ShikiTransformer,
+} from 'shiki'
+import type { BracketPair, TransformerColorizedBracketsOptions } from './types'
+import colorizeBracketTokens from './colorizeBracketTokens'
+import splitBracketTokens from './splitBracketTokens'
+
+const jinjaLikeBracketPairs: BracketPair[] = [
+  { opener: '[', closer: ']' },
+  { opener: '{', closer: '}' },
+  { opener: '(', closer: ')' },
+  { opener: '{{', closer: '}}' },
+  { opener: '{%', closer: '%}' },
+]
+
+/**
+ * Creates a new bracket colorizer transformer
+ *
+ * @example basic usage
+ * ```ts
+ * const html = await shiki.codeToHtml(code, {
+ *   lang: 'ts',
+ *   theme: 'dark-plus',
+ *   transformers: [transformerColorizedBrackets()],
+ * });
+ * ```
+ *
+ * @param options
+ * @param options.themes - custom themes; all Shiki built-in themes are supported without additional configuration
+ * @param options.bracketPairs - bracket definitions; be default [], {}, (), and <> (TS-only)
+ * @param options.langs - language-specific overrides for themes and bracketPairs
+ * @returns Shiki transformer
+ */
+export function transformerColorizedBrackets(
+  options: Partial<TransformerColorizedBracketsOptions> = {},
+): ShikiTransformer {
+  const config: TransformerColorizedBracketsOptions = {
+    themes: options.themes ?? {},
+    bracketPairs: options.bracketPairs ?? [
+      { opener: '[', closer: ']' },
+      { opener: '{', closer: '}' },
+      { opener: '(', closer: ')' },
+      {
+        opener: '<',
+        closer: '>',
+        scopesAllowList: [
+          'punctuation.definition.typeparameters.begin.ts',
+          'punctuation.definition.typeparameters.end.ts',
+          'entity.name.type.instance.jsdoc',
+        ],
+      },
+    ],
+    langs: {
+      html: { bracketPairs: [] },
+      jinja: { bracketPairs: jinjaLikeBracketPairs },
+      liquid: { bracketPairs: jinjaLikeBracketPairs },
+      ...options.langs,
+    },
+    explicitTrigger: options.explicitTrigger ?? false,
+  }
+
+  const transformer: ShikiTransformer = {
+    name: 'colorizedBrackets',
+    preprocess(code, options) {
+      if (!isEnabled(config, this.options.meta?.__raw)) {
+        return
+      }
+
+      // includeExplanation is a valid option for codeToTokens
+      // but is missing from the type definition here
+      (options as CodeToTokensOptions).includeExplanation ||= 'scopeName'
+    },
+    tokens: function transformTokens(tokens) {
+      if (!isEnabled(config, this.options.meta?.__raw)) {
+        return
+      }
+
+      const lang = this.options.lang
+
+      for (let lineIndex = 0; lineIndex < tokens.length; lineIndex++) {
+        const line = tokens[lineIndex]
+        const newLine = line.flatMap(token =>
+          splitBracketTokens(token, config, lang),
+        )
+        tokens[lineIndex] = newLine
+      }
+
+      colorizeBracketTokens(tokens.flat(), config, this.options, lang)
+    },
+  }
+  return transformer
+}
+
+const EXPLICIT_TRIGGER_REGEX = /(^|\s)colorize-brackets($|\s)/
+function isEnabled(config: TransformerColorizedBracketsOptions, meta: string | undefined): boolean {
+  return !config.explicitTrigger || meta?.match(EXPLICIT_TRIGGER_REGEX) != null
+}
diff --git a/packages/colorized-brackets/src/splitBracketTokens.ts b/packages/colorized-brackets/src/splitBracketTokens.ts
new file mode 100644
index 00000000..56a78930
--- /dev/null
+++ b/packages/colorized-brackets/src/splitBracketTokens.ts
@@ -0,0 +1,111 @@
+import type { ThemedToken } from 'shiki'
+import type { TransformerColorizedBracketsOptions } from './types'
+import { escapeRegExp, getEmbeddedLang, resolveConfig, shouldIgnoreToken } from './utils'
+
+export default function splitBracketTokens(
+  rawToken: ThemedToken,
+  config: TransformerColorizedBracketsOptions,
+  lang: string,
+): ThemedToken[] {
+  const embeddedLang = getEmbeddedLang(rawToken)
+  const resolvedConfig = resolveConfig(config, embeddedLang ?? lang)
+
+  if (resolvedConfig.bracketPairs.length === 0 || shouldIgnoreToken(rawToken)) {
+    return [rawToken]
+  }
+
+  const bracketsRegExp = new RegExp(
+    resolvedConfig.bracketPairs
+      .flatMap(pair => [pair.opener, pair.closer])
+      .sort((a, b) => b.length - a.length)
+      .map(escapeRegExp)
+      .join('|'),
+  )
+
+  const tokens = [rawToken]
+  while (true) {
+    const token = tokens.pop()
+    if (!token)
+      break // shouldn't be possible, but it makes TS happy
+
+    const match = token?.content.match(bracketsRegExp)
+    if (!match) {
+      tokens.push(token)
+      break
+    }
+
+    // index is always set since we're not using /g regexp flag, but typescript can't infer that
+    const matchIndex = match.index ?? 0
+
+    if (matchIndex > 0) {
+      tokens.push({
+        ...token,
+        content: token.content.substring(0, matchIndex),
+      })
+    }
+    tokens.push({
+      ...token,
+      content: match[0],
+      offset: token.offset + matchIndex,
+    })
+    if (matchIndex + match[0].length < token.content.length) {
+      tokens.push({
+        ...token,
+        content: token.content.substring(matchIndex + match[0].length),
+        offset: token.offset + matchIndex + match[0].length,
+      })
+    }
+    else {
+      break
+    }
+  }
+
+  const explanations = rawToken.explanation ?? []
+  let currentExplanationStart = 0
+  const explanationsWithStartEnd = (explanations ?? []).map(
+    (explanation, i) => {
+      const start = currentExplanationStart
+      let length = explanation.content.length
+
+      // with shiki option mergeWhitespaces (default true), the leading/trailing whitespaces of the token and explanations do not necessarily match
+      if (explanations.length === 1) {
+        length = rawToken.content.length
+      }
+      else if (i === 0) {
+        length
+          = (rawToken.content.match(/^\s*/)?.[0].length ?? 0)
+          + explanation.content.trimStart().length
+      }
+      else if (i === explanations.length - 1) {
+        length
+          = explanation.content.trimEnd().length
+          + (rawToken.content.match(/\s*$/)?.[0].length ?? 0)
+      }
+      currentExplanationStart += length
+      return {
+        ...explanation,
+        start,
+        end: start + length - 1,
+      }
+    },
+  )
+  for (const token of tokens) {
+    const tokenStart = token.offset - rawToken.offset
+    const tokenEnd = tokenStart + token.content.length - 1
+    const overlappingExplanations = explanationsWithStartEnd.filter(
+      explanation =>
+        // token start in explanation range
+        (tokenStart >= explanation.start && tokenStart <= explanation.end)
+        // token end in explanation range
+        || (tokenEnd >= explanation.start && tokenEnd <= explanation.end)
+        // explanation start in token range
+        || (explanation.start >= tokenStart && explanation.start <= tokenEnd)
+        // explanation end in token range
+        || (explanation.end >= tokenStart && explanation.end <= tokenEnd),
+    )
+    token.explanation = overlappingExplanations.map(
+      (exp, i) => explanations[i],
+    )
+  }
+  return tokens
+}
diff --git a/packages/colorized-brackets/src/types.ts b/packages/colorized-brackets/src/types.ts
new file mode 100644
index 00000000..1406459a
--- /dev/null
+++ b/packages/colorized-brackets/src/types.ts
@@ -0,0 +1,40 @@
+/**
+ * Colorized brackets plugin config
+ *
+ * @property themes - a record of theme names to bracket CSS colors; the final color is the unexpected bracket color
+ * @property bracketPairs - bracket pair definitions
+ * @property langs - language-specific configs that are merged with the base config
+ * @property explicitTrigger - if true, the transformer only runs for code blocks with the `colorize-brackets` meta string
+ */
+export interface TransformerColorizedBracketsOptions {
+  themes: Record<string, string[]>
+  bracketPairs: BracketPair[]
+  langs: Record<string, ColorizedBracketsLangConfig>
+  explicitTrigger?: boolean
+}
+
+/**
+ * Language-specific config
+ *
+ * @property themes - language-specific theme customizations; if not defined, it uses the theme customizations from the base config
+ * @property bracketPairs - language-specific bracket pairs; if not defined, it uses the bracket from the base config
+ */
+export interface ColorizedBracketsLangConfig {
+  themes?: Record<string, string[]>
+  bracketPairs?: BracketPair[]
+}
+
+/**
+ * Defines opening and closing brackets, and allowed Textmate scopes
+ *
+ * @property opener - the string that opens a bracket pair; multi-character strings are not yet supported
+ * @property closer - the string that closes a bracket pair; multi-character strings are not yet supported
+ * @property scopesAllowList - if defined, brackets will only be colored if at least 1 of their scopes matches a scope from this list
+ * @property scopesDenyList - if defined, brackets will not be colored if any of their scopes match a scope from this list
+ */
+export interface BracketPair {
+  opener: string
+  closer: string
+  scopesAllowList?: string[]
+  scopesDenyList?: string[]
+}
diff --git a/packages/colorized-brackets/src/utils.ts b/packages/colorized-brackets/src/utils.ts
new file mode 100644
index 00000000..f676003c
--- /dev/null
+++ b/packages/colorized-brackets/src/utils.ts
@@ -0,0 +1,95 @@
+import type { ThemedToken } from 'shiki'
+import type { TransformerColorizedBracketsOptions } from './types'
+
+export function getEmbeddedLang(token: ThemedToken): string | undefined {
+  return token.explanation?.[0].scopes
+    .findLast(scope => scope.scopeName.match(/^source.\w+$/))
+    ?.scopeName
+    .split('.')[1]
+}
+
+export function resolveConfig(
+  config: TransformerColorizedBracketsOptions,
+  lang: string,
+): Omit<TransformerColorizedBracketsOptions, 'langs'> {
+  return {
+    themes: config.langs[lang]?.themes ?? config.themes,
+    bracketPairs: config.langs[lang]?.bracketPairs ?? config.bracketPairs,
+  }
+}
+
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping
+export function escapeRegExp(string: string): string {
+  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string
+}
+
+export function shouldIgnoreToken(
+  token: ThemedToken,
+  scopesAllowList?: string[],
+  scopesDenyList?: string[],
+): boolean {
+  if (!token.explanation)
+    return true
+
+  const commentLastIndex
+    = token.explanation?.[0].scopes.findLastIndex(scope =>
+      scope.scopeName.startsWith('comment.'),
+    ) ?? -1
+  const stringLastIndex
+    = token.explanation?.[0].scopes.findLastIndex(scope =>
+      scope.scopeName.startsWith('string.'),
+    ) ?? -1
+  const embeddedLastIndex
+    = token.explanation?.[0].scopes.findLastIndex(
+      scope =>
+        scope.scopeName.startsWith('meta.embedded.')
+        || scope.scopeName.startsWith('scope.embedded.')
+        // jsdoc type declarations
+        || scope.scopeName === 'entity.name.type.instance.jsdoc'
+        // jsdoc default value declarations
+        || scope.scopeName === 'variable.other.jsdoc'
+        // liquid template {{ }}
+        || scope.scopeName === 'meta.object.liquid',
+    ) ?? -1
+  // skip all comments and strings (but not if a deeper scope match is meta.embedded eg template expressions)
+  if (
+    commentLastIndex > embeddedLastIndex
+    || stringLastIndex > embeddedLastIndex
+  ) {
+    return true
+  }
+
+  if (
+    scopesAllowList
+    && scopesAllowList.length
+    && !token.explanation?.some(explanation =>
+      explanation.scopes.some(scope =>
+        scopesAllowList.some(
+          allowed =>
+            scope.scopeName === allowed
+            || scope.scopeName.startsWith(`${allowed}.`),
+        ),
+      ),
+    )
+  ) {
+    return true
+  }
+
+  if (
+    scopesDenyList
+    && scopesDenyList.length
+    && token.explanation?.some(explanation =>
+      explanation.scopes.some(scope =>
+        scopesDenyList.some(
+          denied =>
+            scope.scopeName === denied
+            || scope.scopeName.startsWith(`${denied}.`),
+        ),
+      ),
+    )
+  ) {
+    return true
+  }
+
+  return false
+}
diff --git a/packages/transformers/src/index.ts b/packages/transformers/src/index.ts
index 1d305eaa..a7f5291a 100644
--- a/packages/transformers/src/index.ts
+++ b/packages/transformers/src/index.ts
@@ -9,4 +9,5 @@ export * from './transformers/notation-highlight-word'
 export * from './transformers/remove-line-breaks'
 export * from './transformers/remove-notation-escape'
 export * from './transformers/render-whitespace'
+export * from './transformers/style-to-class'
 export * from './utils'
diff --git a/packages/transformers/src/transformers/style-to-class.ts b/packages/transformers/src/transformers/style-to-class.ts
new file mode 100644
index 00000000..771e5297
--- /dev/null
+++ b/packages/transformers/src/transformers/style-to-class.ts
@@ -0,0 +1,123 @@
+import type { ShikiTransformer } from 'shiki'
+
+export interface TransformerStyleToClassOptions {
+  /**
+   * Prefix for class names.
+   * @default '__shiki_'
+   */
+  classPrefix?: string
+  /**
+   * Suffix for class names.
+   * @default ''
+   */
+  classSuffix?: string
+  /**
+   * Callback to replace class names.
+   * @default (className) => className
+   */
+  classReplacer?: (className: string) => string
+}
+
+export interface ShikiTransformerStyleToClass extends ShikiTransformer {
+  getClassRegistry: () => Map<string, Record<string, string> | string>
+  getCSS: () => string
+  clearRegistry: () => void
+}
+
+/**
+ * Remove line breaks between lines.
+ * Useful when you override `display: block` to `.line` in CSS.
+ */
+export function transformerStyleToClass(options: TransformerStyleToClassOptions = {}): ShikiTransformerStyleToClass {
+  const {
+    classPrefix = '__shiki_',
+    classSuffix = '',
+    classReplacer = (className: string) => className,
+  } = options
+
+  const classToStyle = new Map<string, Record<string, string> | string>()
+
+  function stringifyStyle(style: Record<string, string>): string {
+    return Object.entries(style)
+      .map(([key, value]) => `${key}:${value}`)
+      .join(';')
+  }
+
+  function registerStyle(style: Record<string, string> | string): string {
+    const str = typeof style === 'string'
+      ? style
+      : stringifyStyle(style)
+    let className = classPrefix + cyrb53(str) + classSuffix
+    className = classReplacer(className)
+    if (!classToStyle.has(className)) {
+      classToStyle.set(
+        className,
+        typeof style === 'string'
+          ? style
+          : { ...style },
+      )
+    }
+    return className
+  }
+
+  return {
+    name: '@shikijs/transformers:style-to-class',
+    pre(t) {
+      if (!t.properties.style)
+        return
+      const className = registerStyle(t.properties.style as string)
+      delete t.properties.style
+      this.addClassToHast(t, className)
+    },
+    tokens(lines) {
+      for (const line of lines) {
+        for (const token of line) {
+          if (!token.htmlStyle)
+            continue
+
+          const className = registerStyle(token.htmlStyle)
+          token.htmlStyle = {}
+          token.htmlAttrs ||= {}
+          if (!token.htmlAttrs.class)
+            token.htmlAttrs.class = className
+          else
+            token.htmlAttrs.class += ` ${className}`
+        }
+      }
+    },
+    getClassRegistry() {
+      return classToStyle
+    },
+    getCSS() {
+      let css = ''
+      for (const [className, style] of classToStyle.entries()) {
+        css += `.${className}{${typeof style === 'string' ? style : stringifyStyle(style)}}`
+      }
+      return css
+    },
+    clearRegistry() {
+      classToStyle.clear()
+    },
+  }
+}
+
+/**
+ * A simple hash function.
+ *
+ * @see https://stackoverflow.com/a/52171480
+ */
+function cyrb53(str: string, seed = 0): string {
+  let h1 = 0xDEADBEEF ^ seed
+  let h2 = 0x41C6CE57 ^ seed
+  for (let i = 0, ch; i < str.length; i++) {
+    ch = str.charCodeAt(i)
+    h1 = Math.imul(h1 ^ ch, 2654435761)
+    h2 = Math.imul(h2 ^ ch, 1597334677)
+  }
+  h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507)
+  h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909)
+  h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507)
+  h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909)
+
+  return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36).slice(0, 6)
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 52b1a3b0..26b91f3c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -436,6 +436,9 @@ importers:
       '@iconify-json/svg-spinners':
         specifier: 'catalog:'
         version: 1.2.1
+      '@shikijs/colorized-brackets':
+        specifier: workspace:*
+        version: link:../packages/colorized-brackets
       '@shikijs/transformers':
         specifier: workspace:*
         version: link:../packages/transformers
@@ -489,6 +492,12 @@ importers:
         specifier: 'catalog:'
         version: 1.2.5
 
+  packages/colorized-brackets:
+    dependencies:
+      shiki:
+        specifier: workspace:*
+        version: link:../shiki
+
   packages/compat:
     dependencies:
       '@shikijs/core':


Test patch:
diff --git a/packages/colorized-brackets/test/bracket-customization.test.ts b/packages/colorized-brackets/test/bracket-customization.test.ts
new file mode 100644
index 00000000..23c00b08
--- /dev/null
+++ b/packages/colorized-brackets/test/bracket-customization.test.ts
@@ -0,0 +1,51 @@
+import { createHighlighter } from 'shiki'
+import { describe, expect, it } from 'vitest'
+import { transformerColorizedBrackets } from '../src'
+
+describe('bracket customization', async () => {
+  const lang = 'ts'
+  const theme = 'dark-plus'
+  const highlighter = await createHighlighter({
+    langs: [lang],
+    themes: [theme],
+  })
+
+  it('denied scopes', () => {
+    const code = 'let values: number[] = [1, 2, 3];'
+
+    expect(
+      highlighter.codeToHtml(code, {
+        lang,
+        theme,
+        transformers: [
+          transformerColorizedBrackets({
+            themes: { 'dark-plus': ['Y', 'P', 'B', 'R'] },
+          }),
+        ],
+      }),
+    ).toContain('<span style="color:Y">[</span><span style="color:Y">]</span>')
+
+    expect(
+      highlighter.codeToHtml(code, {
+        lang,
+        theme,
+        transformers: [
+          transformerColorizedBrackets({
+            themes: { 'dark-plus': ['Y', 'P', 'B', 'R'] },
+            bracketPairs: [
+              {
+                opener: '[',
+                closer: ']',
+                scopesDenyList: ['meta.type.annotation'],
+              },
+              { opener: '{', closer: '}' },
+              { opener: '(', closer: ')' },
+            ],
+          }),
+        ],
+      }),
+    ).toContain(
+      '<span style="color:#D4D4D4">[</span><span style="color:#D4D4D4">]</span>',
+    )
+  })
+})
diff --git a/packages/colorized-brackets/test/dual-themes.test.ts b/packages/colorized-brackets/test/dual-themes.test.ts
new file mode 100644
index 00000000..4d635b5a
--- /dev/null
+++ b/packages/colorized-brackets/test/dual-themes.test.ts
@@ -0,0 +1,110 @@
+import { createHighlighter } from 'shiki'
+import { describe, expect, it } from 'vitest'
+import { transformerColorizedBrackets } from '../src'
+
+describe('dual themes', async () => {
+  const lang = 'ts'
+  const highlighter = await createHighlighter({
+    langs: [lang],
+    themes: [
+      'dark-plus',
+      'light-plus',
+      'red',
+      'vesper',
+      'material-theme-ocean',
+    ],
+  })
+
+  it('light and dark', () => {
+    const htmlStr = highlighter.codeToHtml('{}', {
+      lang,
+      themes: { light: 'light-plus', dark: 'dark-plus' },
+      transformers: [
+        transformerColorizedBrackets({
+          themes: {
+            'light-plus': ['Y', 'P', 'B', 'R'],
+            'dark-plus': ['y', 'p', 'b', 'r'],
+          },
+        }),
+      ],
+    })
+    expect(htmlStr).toContain('<span style="color:Y;--shiki-dark:y">{</span>')
+  })
+
+  it('custom prefix', () => {
+    const htmlStr = highlighter.codeToHtml('{}', {
+      lang,
+      themes: { light: 'light-plus', dark: 'dark-plus' },
+      cssVariablePrefix: '--custom-',
+      transformers: [
+        transformerColorizedBrackets({
+          themes: {
+            'light-plus': ['Y', 'P', 'B', 'R'],
+            'dark-plus': ['y', 'p', 'b', 'r'],
+          },
+        }),
+      ],
+    })
+    expect(htmlStr).toContain('<span style="color:Y;--custom-dark:y">{</span>')
+  })
+
+  it('custom default', () => {
+    const htmlStr = highlighter.codeToHtml('{}', {
+      lang,
+      themes: { dark: 'dark-plus', light: 'light-plus' },
+      defaultColor: 'dark',
+      transformers: [
+        transformerColorizedBrackets({
+          themes: {
+            'light-plus': ['Y', 'P', 'B', 'R'],
+            'dark-plus': ['y', 'p', 'b', 'r'],
+          },
+        }),
+      ],
+    })
+    expect(htmlStr).toContain('<span style="color:y;--shiki-light:Y">{</span>')
+  })
+
+  it('no default', () => {
+    const htmlStr = highlighter.codeToHtml('{}', {
+      lang,
+      themes: { light: 'light-plus', dark: 'dark-plus' },
+      defaultColor: false,
+      transformers: [
+        transformerColorizedBrackets({
+          themes: {
+            'light-plus': ['Y', 'P', 'B', 'R'],
+            'dark-plus': ['y', 'p', 'b', 'r'],
+          },
+        }),
+      ],
+    })
+    expect(htmlStr).toContain(
+      '<span style="--shiki-light:Y;--shiki-dark:y">{</span>',
+    )
+  })
+
+  it('arbitrary theme names', () => {
+    const htmlStr = highlighter.codeToHtml('{}', {
+      lang,
+      themes: {
+        cool: 'material-theme-ocean',
+        warm: 'red',
+        grayscale: 'vesper',
+      },
+      defaultColor: false,
+      transformers: [
+        transformerColorizedBrackets({
+          themes: {
+            'material-theme-ocean': ['blue', 'red'],
+            'red': ['yellow', 'red'],
+            'vesper': ['gray', 'white'],
+          },
+        }),
+      ],
+    })
+    expect(htmlStr).toContain(
+      '<span style="--shiki-cool:blue;--shiki-warm:yellow;--shiki-grayscale:gray">{</span>',
+    )
+  })
+})
diff --git a/packages/colorized-brackets/test/explicit-trigger.test.ts b/packages/colorized-brackets/test/explicit-trigger.test.ts
new file mode 100644
index 00000000..1df9f69c
--- /dev/null
+++ b/packages/colorized-brackets/test/explicit-trigger.test.ts
@@ -0,0 +1,57 @@
+import { createHighlighter } from 'shiki'
+import { describe, expect, it } from 'vitest'
+import { transformerColorizedBrackets } from '../src'
+
+describe('explicitTrigger', async () => {
+  const lang = 'ts'
+  const theme = 'dark-plus'
+  const highlighter = await createHighlighter({
+    langs: [lang],
+    themes: [theme],
+  })
+
+  const validMetaStrings = [
+    ['colorize-brackets'],
+    ['foo colorize-brackets'],
+    ['foo colorize-brackets bar'],
+    ['colorize-brackets bar'],
+  ]
+  it.each(validMetaStrings)('should colorize brackets for meta string "%s"', (meta) => {
+    const code = 'let values: number[] = [1, 2, 3];'
+    expect(
+      highlighter.codeToHtml(code, {
+        lang,
+        theme,
+        transformers: [
+          transformerColorizedBrackets({
+            themes: { 'dark-plus': ['Y', 'P', 'B', 'R'] },
+            explicitTrigger: true,
+          }),
+        ],
+        meta: { __raw: meta },
+      }),
+    ).toContain('<span style="color:Y">[</span><span style="color:Y">]</span>')
+  })
+
+  const invalidMetaStrings = [
+    [''],
+    ['colorize-brackets-no-word-break'],
+    ['no-word-break-colorize-brackets'],
+  ]
+  it.each(invalidMetaStrings)('should not colorize brackets for meta string "%s"', (meta) => {
+    const code = 'let values: number[] = [1, 2, 3];'
+    expect(
+      highlighter.codeToHtml(code, {
+        lang,
+        theme,
+        transformers: [
+          transformerColorizedBrackets({
+            themes: { 'dark-plus': ['Y', 'P', 'B', 'R'] },
+            explicitTrigger: true,
+          }),
+        ],
+        meta: { __raw: meta },
+      }),
+    ).not.toContain('<span style="color:Y">[</span><span style="color:Y">]</span>')
+  })
+})
diff --git a/packages/colorized-brackets/test/fixtures.test.ts b/packages/colorized-brackets/test/fixtures.test.ts
new file mode 100644
index 00000000..fb87abbf
--- /dev/null
+++ b/packages/colorized-brackets/test/fixtures.test.ts
@@ -0,0 +1,75 @@
+import { lstatSync, readdirSync } from 'node:fs'
+import { readFile } from 'node:fs/promises'
+import { join, sep } from 'node:path'
+import c from 'picocolors'
+import { createHighlighter } from 'shiki'
+import { describe, expect, it } from 'vitest'
+import { transformerColorizedBrackets } from '../src'
+import {
+  parseActualBrackets,
+  parseExpectedBrackets,
+  prettifyBrackets,
+} from './utils'
+
+/**
+ * `tests/samples` contains code snippets that annotate expected colors with `@colors` comments.
+ * `Y`, `P`, `B` are for the 3 levels of matched brackets (yellow, purple, blue), and `R` is for mismatched brackets (red).
+ * Values before `@colors` indicate the expected color of the bracket on the preceding line.
+ * Values after `@colors` are explicitly indexed.
+ * For example:
+ *
+ * ```ts
+ * function first<T>(array: T[]) {
+ *   //          Y YY        PPY Y @colors
+ *   return array[0];
+ *   //          P P @colors
+ * }
+ * // @colors 0=Y
+ * ```
+ *
+ * In the final line, there's not room underneath the `}` to annotate the color, so an explicit index `0=Y` is used after `@colors`.
+ */
+describe('file-driven tests', async () => {
+  const testCaseFiles: [string][] = readdirSync(join(import.meta.dirname, 'fixtures'), {
+    recursive: true,
+  })
+    .filter(
+      (fileName): fileName is string =>
+        typeof fileName === 'string'
+        && lstatSync(join(import.meta.dirname, 'fixtures', fileName)).isFile(),
+    )
+    .map<[string]>(fileName => [fileName])
+  const langs = Array.from(
+    new Set(testCaseFiles.map(fileName => fileName[0].split(sep)[0])),
+  )
+  const highlighter = await createHighlighter({
+    langs,
+    themes: ['dark-plus'],
+  })
+
+  it.each(testCaseFiles)('%s', async (fileName) => {
+    const path = join(import.meta.dirname, 'fixtures', fileName)
+    const lang = fileName.split(sep).at(0) ?? 'text'
+    const content = await readFile(path, { encoding: 'utf-8' })
+    const expectedBrackets = parseExpectedBrackets(content)
+    const html = highlighter.codeToHtml(content, {
+      lang,
+      theme: 'dark-plus',
+      transformers: [
+        transformerColorizedBrackets({
+          themes: { 'dark-plus': ['Y', 'P', 'B', 'R'] },
+        }),
+      ],
+    })
+    const actualBrackets = parseActualBrackets(html)
+    // Logging the colored brackets is much easier to read
+    /* eslint-disable no-console */
+    console.log(c.bold(fileName))
+    console.log('  Expected:', prettifyBrackets(expectedBrackets))
+    console.log('  Actual:  ', prettifyBrackets(actualBrackets))
+    /* eslint-enable no-console */
+    expect(prettifyBrackets(actualBrackets, { noAnsi: true })).toEqual(
+      prettifyBrackets(expectedBrackets, { noAnsi: true }),
+    )
+  })
+})
diff --git a/packages/colorized-brackets/test/fixtures/csharp/generic.cs b/packages/colorized-brackets/test/fixtures/csharp/generic.cs
new file mode 100644
index 00000000..c01aa37b
--- /dev/null
+++ b/packages/colorized-brackets/test/fixtures/csharp/generic.cs
@@ -0,0 +1,8 @@
+public class GenericList<T>
+// note: angle brackets not colored
+{
+  // @colors 0=Y
+  public void Add(T input) { }
+  //             P       P P P @colors
+}
+// @colors 0=Y
diff --git a/packages/colorized-brackets/test/fixtures/css/basic.css b/packages/colorized-brackets/test/fixtures/css/basic.css
new file mode 100644
index 00000000..85fb393c
--- /dev/null
+++ b/packages/colorized-brackets/test/fixtures/css/basic.css
@@ -0,0 +1,9 @@
+body[data-theme="dark"] {
+  /*Y                 Y Y @colors */
+  a {
+    /* @colors 4=P */
+    color: #AAAAFF
+  }
+  /* @colors 2=P */
+}
+/* @colors 0=Y */
\ No newline at end of file
diff --git a/packages/colorized-brackets/test/fixtures/html/basic.html b/packages/colorized-brackets/test/fixtures/html/basic.html
new file mode 100644
index 00000000..97036ad4
--- /dev/null
+++ b/packages/colorized-brackets/test/fixtures/html/basic.html
@@ -0,0 +1,4 @@
+<section>
+  <h1>Brackets in text are not colorized (like these parentheses)</h1>
+  <p [bracket] {curly}>Brackets in attributes are also not colorized.</p>
+</section>
\ No newline at end of file
diff --git a/packages/colorized-brackets/test/fixtures/html/embedded.html b/packages/colorized-brackets/test/fixtures/html/embedded.html
new file mode 100644
index 00000000..0bde5197
--- /dev/null
+++ b/packages/colorized-brackets/test/fixtures/html/embedded.html
@@ -0,0 +1,14 @@
+<style>
+  strong {
+    /*   Y @colors */
+    font-weight: 'bold'
+  }
+  /* @colors 2=Y */
+</style>
+<p>
+  Rock and stone to the bone!
+</p>
+<script>
+  const cheer = document.querySelector("p").textContent;
+  //                                  Y   Y @colors
+</script>
diff --git a/packages/colorized-brackets/test/fixtures/java/generic.java b/packages/colorized-brackets/test/fixtures/java/generic.java
new file mode 100644
index 00000000..ce72a28b
--- /dev/null
+++ b/packages/colorized-brackets/test/fixtures/java/generic.java
@@ -0,0 +1,11 @@
+public class Box<T> {
+  //                Y @colors
+  // note: the angle brackets are not highlighted
+  private T t;
+
+  public void set(T t) { this.t = t; }
+  //             P   P P             P @colors
+  public T get() { return t; }
+  //          PP P           P @colors
+}
+// @colors 0=Y
\ No newline at end of file
diff --git a/packages/colorized-brackets/test/fixtures/jinja/basic.jinja b/packages/colorized-brackets/test/fixtures/jinja/basic.jinja
new file mode 100644
index 00000000..b0b58a6f
--- /dev/null
+++ b/packages/colorized-brackets/test/fixtures/jinja/basic.jinja
@@ -0,0 +1,12 @@
+{% if test %}
+  {# @colors 0-1=Y 11-12=Y #}
+  <p>
+    {{test(foo[1])}}
+    {# @colors 4-5=Y 10=P 14=B 16=B 17=P 18-19=Y #}
+  </p>
+{% endif %}
+{# @colors 0-1=Y 9-10=Y #}
+<script>
+  let bar = [1,2,3];
+  //        Y     Y @colors
+</script>
\ No newline at end of file
diff --git a/packages/colorized-brackets/test/fixtures/liquid/basic.liquid b/packages/colorized-brackets/test/fixtures/liquid/basic.liquid
new file mode 100644
index 00000000..9ffbd55f
--- /dev/null
+++ b/packages/colorized-brackets/test/fixtures/liquid/basic.liquid
@@ -0,0 +1,16 @@
+<h3>Recommended Products</h3>
+<ul class="recommended_products">
+  {% assign recommended_products =  product.metafields.my_fields.rec_products.value %}
+  <!-- @colors 2-3=Y 84-85=Y -->
+  {% for product in recommended_products  %}
+    <!-- @colors 2-3=Y 42-43=Y -->
+    <li>
+      <a href="{{ product.url }}">
+        <!-- @colors 15-16=Y 30-31=Y -->
+        {{product.title[0]}}
+        <!-- @colors 8-9=Y 23=P 25=P 26-27=Y -->
+      </a>
+    </li>
+  {% endfor %}
+  <!-- @colors 2-3=Y 12-13=Y -->
+</ul>
\ No newline at end of file
diff --git a/packages/colorized-brackets/test/fixtures/python/basic.py b/packages/colorized-brackets/test/fixtures/python/basic.py
new file mode 100644
index 00000000..8c4d0b1a
--- /dev/null
+++ b/packages/colorized-brackets/test/fixtures/python/basic.py
@@ -0,0 +1,23 @@
+from collections.abc import Callable, Awaitable
+
+def feeder(get_next_item: Callable[[], str]) -> None:
+    #     Y                       PBB     PY @colors
+    pass
+
+def async_query(on_success: Callable[[int], None],
+                #                   PB   B      P @colors 15=Y
+                on_error: Callable[[int, Exception], None]) -> None:
+                #                 PB              B      PY @colors
+    pass
+
+async def on_update(value: str) -> None:
+    #              Y          Y @colors
+    pass
+
+callback: Callable[[str], Awaitable[None]] = on_update
+#                 YP   P           P    PY @colors
+
+l = [1, 2, 3]
+#   Y       Y @colors
+s = f"last: {l[-1]}"
+#           Y P  PY @colors
\ No newline at end of file
diff --git a/packages/colorized-brackets/test/fixtures/rust/generic.rs b/packages/colorized-brackets/test/fixtures/rust/generic.rs
new file mode 100644
index 00000000..b379a754
--- /dev/null
+++ b/packages/colorized-brackets/test/fixtures/rust/generic.rs
@@ -0,0 +1,19 @@
+fn largest<T>(list: &[T]) -> &T {
+  //         Y       P PY       Y @colors
+  // note: the angle brackets are not highlighted
+  let mut largest = &list[0];
+  //                     P P @colors
+
+  for item in list {
+    //             P @colors
+    if item > largest {
+      //              B @colors
+      largest = item;
+    }
+    // @colors 4=B
+  }
+  // @colors 2=P
+
+  largest
+}
+// @colors 0=Y
diff --git a/packages/colorized-brackets/test/fixtures/rust/turbofish.rs b/packages/colorized-brackets/test/fixtures/rust/turbofish.rs
new file mode 100644
index 00000000..5f86af02
--- /dev/null
+++ b/packages/colorized-brackets/test/fixtures/rust/turbofish.rs
@@ -0,0 +1,12 @@
+
+#![allow(unused_variables)]
+//      P                PY @colors 2=Y
+fn main() {
+  //   YY Y @colors
+  let v = Vec::<bool>::new();
+  //                      PP @colors
+  // note: angle brackets not colored
+  println!("{:?}", v);
+  //      P         P @colors
+}
+// @colors 0=Y
\ No newline at end of file
diff --git a/packages/colorized-brackets/test/fixtures/svelte/embedded.svelte b/packages/colorized-brackets/test/fixtures/svelte/embedded.svelte
new file mode 100644
index 00000000..b3d33795
--- /dev/null
+++ b/packages/colorized-brackets/test/fixtures/svelte/embedded.svelte
@@ -0,0 +1,35 @@
+<script lang="ts">
+  let rocks: Array<Array<string>> = [
+    //            Y     P      PY   Y @colors
+    ["Rock", "Stone"],
+    //              P @colors 4=P
+    ["Rock", "Roll", "Stone"],
+    //                      P @colors 4=P
+  ];
+  // @colors 2=Y
+</script>
+
+<p>
+  {#each rocks as rockWords}
+    <!--                   Y @colors 2=Y -->
+    {#each rockWords as rock, i}
+      <!--                     Y @colors 4=Y -->
+      {rock}
+      <!-- Y @colors 6=Y -->
+      {i < rocks.length - 1 ? "and" : ""}
+      <!--                              Y @colors=6=Y -->
+    {/each}
+    <!--  Y @colors 4=Y -->
+  {/each}
+  <!--  Y @colors 2=Y -->
+  !
+</p>
+
+<style>
+  p {
+    /* @colors 4=Y */
+    color: orangered;
+    font-weight: bold;
+  }
+  /* @colors 2=Y */
+</style>
diff --git a/packages/colorized-brackets/test/fixtures/ts/angle-brackets.ts b/packages/colorized-brackets/test/fixtures/ts/angle-brackets.ts
new file mode 100644
index 00000000..4ec35e36
--- /dev/null
+++ b/packages/colorized-brackets/test/fixtures/ts/angle-brackets.ts
@@ -0,0 +1,11 @@
+const objectToEntries = (obj: Record<string, string>) => {
+  //                    Y           P              PY    Y @colors
+  if (Object.keys(obj).length > 0) {
+    //           B   B           P P @colors 5=P
+    return Object.entries(obj);
+    //                   B   B @colors
+  }
+  // @colors 2=P
+  return null;
+};
+// @colors 0=Y
diff --git a/packages/colorized-brackets/test/fixtures/ts/comments.ts b/packages/colorized-brackets/test/fixtures/ts/comments.ts
new file mode 100644
index 00000000..858d644a
--- /dev/null
+++ b/packages/colorized-brackets/test/fixtures/ts/comments.ts
@@ -0,0 +1,5 @@
+// ([{}][0])
+/*
+including multiline comments
+Record<string, string[]>
+*/
diff --git a/packages/colorized-brackets/test/fixtures/ts/generic.ts b/packages/colorized-brackets/test/fixtures/ts/generic.ts
new file mode 100644
index 00000000..03ddf771
--- /dev/null
+++ b/packages/colorized-brackets/test/fixtures/ts/generic.ts
@@ -0,0 +1,9 @@
+function first<T>(array: T[]): T | undefined {
+  //          Y YY        PPY                Y @colors
+  return array[0];
+  //          P P @colors
+}
+// @colors 0=Y
+
+first<number>([1, 2, 3]);
+//   Y      YYP       PY @colors
diff --git a/packages/colorized-brackets/test/fixtures/ts/jsdoc.ts b/packages/colorized-brackets/test/fixtures/ts/jsdoc.ts
new file mode 100644
index 00000000..ed5ab2e8
--- /dev/null
+++ b/packages/colorized-brackets/test/fixtures/ts/jsdoc.ts
@@ -0,0 +1,16 @@
+/**
+ *
+ * @param {Array<string>} [strings=["()"]] - description
+ *        Y     P      PY Y        P BB PY @colors
+ * note: the colored () inside the string in the default bracket
+ * there is not enough context from the scopes to do otherwise
+ * this matches VSCode behavior
+ * @returns {string[]}
+ *          Y      PPY @colors
+ */
+function reverse(strings: string[]) {
+  //            Y               PPY Y @colors
+  return strings.reverse();
+  //                    PP @colors
+}
+// @colors 0=Y
diff --git a/packages/colorized-brackets/test/fixtures/ts/strings.ts b/packages/colorized-brackets/test/fixtures/ts/strings.ts
new file mode 100644
index 00000000..c223193e
--- /dev/null
+++ b/packages/colorized-brackets/test/fixtures/ts/strings.ts
@@ -0,0 +1,14 @@
+let foo = {
+  //      Y @colors
+  bar: ["()", "[]", "{}"],
+  //   P                P @colors
+};
+// @colors 0=Y
+`foo:
+  ${foo}
+  ${0}
+  [[]{}()]
+`;
+
+`foo.bar[0]: ${foo.bar[0]}`;
+//                    Y Y @colors
diff --git a/packages/colorized-brackets/test/fixtures/ts/template.ts b/packages/colorized-brackets/test/fixtures/ts/template.ts
new file mode 100644
index 00000000..451696f9
--- /dev/null
+++ b/packages/colorized-brackets/test/fixtures/ts/template.ts
@@ -0,0 +1,4 @@
+`(outer) ${[`(inner) ${[[]]}`]}`;
+//         Y           PBBP  Y @colors
+// note: vscode does not color the inner expression, but this plugin does
+// for now, considering this desired behavior even though it is divergent
diff --git a/packages/colorized-brackets/test/fixtures/ts/unexpected.txt b/packages/colorized-brackets/test/fixtures/ts/unexpected.txt
new file mode 100644
index 00000000..bb1b02d8
--- /dev/null
+++ b/packages/colorized-brackets/test/fixtures/ts/unexpected.txt
@@ -0,0 +1,13 @@
+// this needs to be a txt file, otherwise `jsr publish` fails while checking for slow types
+if (true) {
+// Y    Y R @colors
+  
+  let obj = {
+    //      P @colors
+    foo: 'foo'.split()(
+    //              BBR @colors
+  }];
+  // @colors 2=P 3=R
+  
+let foo = ([[[[)]]]]
+//        PRRRRPRRRR @colors
diff --git a/packages/colorized-brackets/test/fixtures/tsx/basic.tsx b/packages/colorized-brackets/test/fixtures/tsx/basic.tsx
new file mode 100644
index 00000000..602823d2
--- /dev/null
+++ b/packages/colorized-brackets/test/fixtures/tsx/basic.tsx
@@ -0,0 +1,6 @@
+function Hello({ name }: { name: string }) {
+  //          YP      P  P              PY Y @colors
+  return <h1>Hello, {name}</h1>;
+  //                P    P @colors
+}
+// @colors 0=Y
\ No newline at end of file
diff --git a/packages/colorized-brackets/test/utils.ts b/packages/colorized-brackets/test/utils.ts
new file mode 100644
index 00000000..238e7233
--- /dev/null
+++ b/packages/colorized-brackets/test/utils.ts
@@ -0,0 +1,89 @@
+import c from 'picocolors'
+
+interface ColoredBracket {
+  bracket: string
+  color: string
+}
+
+export function parseExpectedBrackets(content: string): ColoredBracket[] {
+  const brackets: ColoredBracket[] = []
+  const lines = content.split('\n')
+  const implicitIndexRegex = /[RYPB]/g
+  const explicitIndexRegex = /(\d+)(?:-(\d+))?=([RYPB])/g
+  for (let i = 0; i < lines.length; i++) {
+    const line = lines[i]
+    if (line.includes('@colors')) {
+      const prev = lines[i - 1]
+      const [implicitIndexPart, explicitIndexPart] = line.split('@colors')
+      for (const match of explicitIndexPart.matchAll(explicitIndexRegex)) {
+        const start = Number.parseInt(match[1])
+        const end = Number.parseInt(match[2] || match[1]) + 1
+        const color = match[3]
+        brackets.push({
+          bracket: prev.substring(start, end),
+          color,
+        })
+      }
+      for (const match of implicitIndexPart.matchAll(implicitIndexRegex)) {
+        const index = match.index
+        const color = match[0]
+        brackets.push({ bracket: prev[index], color })
+      }
+    }
+  }
+  return brackets
+}
+
+export function parseActualBrackets(html: string): ColoredBracket[] {
+  const spanRegex
+    // eslint-disable-next-line regexp/no-super-linear-backtracking -- this is only run on input we control, so DoS is not a concern
+    = /<span style="color:([RYPB])">\s*(&#x[0-9A-F]+;|..?)\s*<\/span>/g
+  const brackets = Array.from(html.matchAll(spanRegex)).map<ColoredBracket>(
+    (match) => {
+      const color = match[1]
+      let bracket = match[2]
+      if (bracket.startsWith('&#x')) {
+        bracket = String.fromCharCode(
+          Number.parseInt(bracket.substring(3, bracket.length - 1), 16),
+        )
+      }
+      return { color, bracket }
+    },
+  )
+  return brackets
+}
+
+export function prettifyBrackets(
+  brackets: ColoredBracket[],
+  { noAnsi = false } = {},
+): string {
+  if (!brackets.length)
+    return noAnsi ? 'none' : c.gray('none')
+  return brackets
+    .map(b => getColoredBracketTerminalOutput(b, { noAnsi }))
+    .join(' ')
+}
+
+function getColoredBracketTerminalOutput(
+  { bracket, color }: ColoredBracket,
+  { noAnsi = false } = {},
+): string {
+  const isCloser = [']', '}', ')', '>', '}}', '%}'].includes(bracket)
+  if (noAnsi)
+    return isCloser ? `${bracket}${color}` : `${color}${bracket}`
+  if (color === 'R') {
+    return c.red(bracket)
+  }
+  else if (color === 'Y') {
+    return c.yellow(bracket)
+  }
+  else if (color === 'P') {
+    return c.magenta(bracket)
+  }
+  else if (color === 'B') {
+    return c.blue(bracket)
+  }
+  else {
+    return `${color}${bracket}`
+  }
+}
diff --git a/packages/transformers/test/style-to-class.test.ts b/packages/transformers/test/style-to-class.test.ts
new file mode 100644
index 00000000..9db4d329
--- /dev/null
+++ b/packages/transformers/test/style-to-class.test.ts
@@ -0,0 +1,50 @@
+import { createHighlighter } from 'shiki'
+import { expect, it } from 'vitest'
+import { transformerStyleToClass } from '../src/transformers/style-to-class'
+
+it('transformerStyleToClass', async () => {
+  const shiki = await createHighlighter({
+    themes: ['vitesse-dark', 'vitesse-light', 'nord'],
+    langs: ['typescript'],
+  })
+
+  const transformer = transformerStyleToClass()
+
+  const code = `
+    const a = Math.random() > 0.5 ? 1 : \`foo\`
+  `.trim()
+
+  const result = shiki.codeToHtml(code, {
+    lang: 'typescript',
+    themes: {
+      dark: 'vitesse-dark',
+      light: 'vitesse-light',
+      nord: 'nord',
+    },
+    defaultColor: false,
+    transformers: [transformer],
+  })
+
+  expect(result.replace(/<span/g, '\n<span'))
+    .toMatchInlineSnapshot(`
+      "<pre class="shiki shiki-themes vitesse-dark vitesse-light nord __shiki_uywmyh" tabindex="0"><code>
+      <span class="line">
+      <span class="__shiki_223nhr">const</span>
+      <span class="__shiki_u5wfov"> a</span>
+      <span class="__shiki_26darv"> =</span>
+      <span class="__shiki_u5wfov"> Math</span>
+      <span class="__shiki_17lqoe">.</span>
+      <span class="__shiki_6u0ar0">random</span>
+      <span class="__shiki_k92bfk">()</span>
+      <span class="__shiki_26darv"> ></span>
+      <span class="__shiki_1328cg"> 0.5</span>
+      <span class="__shiki_223nhr"> ?</span>
+      <span class="__shiki_1328cg"> 1</span>
+      <span class="__shiki_223nhr"> :</span>
+      <span class="__shiki_ga6n9x"> \`</span>
+      <span class="__shiki_23isjw">foo</span>
+      <span class="__shiki_ga6n9x">\`</span></span></code></pre>"
+    `)
+
+  expect(transformer.getCSS()).toMatchInlineSnapshot(`".__shiki_223nhr{--shiki-dark:#CB7676;--shiki-light:#AB5959;--shiki-nord:#81A1C1}.__shiki_u5wfov{--shiki-dark:#BD976A;--shiki-light:#B07D48;--shiki-nord:#D8DEE9}.__shiki_26darv{--shiki-dark:#666666;--shiki-light:#999999;--shiki-nord:#81A1C1}.__shiki_17lqoe{--shiki-dark:#666666;--shiki-light:#999999;--shiki-nord:#ECEFF4}.__shiki_6u0ar0{--shiki-dark:#80A665;--shiki-light:#59873A;--shiki-nord:#88C0D0}.__shiki_k92bfk{--shiki-dark:#666666;--shiki-light:#999999;--shiki-nord:#D8DEE9FF}.__shiki_1328cg{--shiki-dark:#4C9A91;--shiki-light:#2F798A;--shiki-nord:#B48EAD}.__shiki_ga6n9x{--shiki-dark:#C98A7D77;--shiki-light:#B5695977;--shiki-nord:#ECEFF4}.__shiki_23isjw{--shiki-dark:#C98A7D;--shiki-light:#B56959;--shiki-nord:#A3BE8C}.__shiki_uywmyh{--shiki-dark:#dbd7caee;--shiki-light:#393a34;--shiki-nord:#d8dee9ff;--shiki-dark-bg:#121212;--shiki-light-bg:#ffffff;--shiki-nord-bg:#2e3440ff}"`)
+})