Skip to content

Commit

Permalink
feat(transformers): add Style to Class transformer (#826)
Browse files Browse the repository at this point in the history
  • Loading branch information
antfu authored Nov 14, 2024
1 parent 5d08484 commit 48d6c57
Show file tree
Hide file tree
Showing 4 changed files with 255 additions and 0 deletions.
81 changes: 81 additions & 0 deletions docs/packages/transformers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
```
1 change: 1 addition & 0 deletions packages/transformers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
123 changes: 123 additions & 0 deletions packages/transformers/src/transformers/style-to-class.ts
Original file line number Diff line number Diff line change
@@ -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)
}
50 changes: 50 additions & 0 deletions packages/transformers/test/style-to-class.test.ts
Original file line number Diff line number Diff line change
@@ -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}"`)
})

0 comments on commit 48d6c57

Please sign in to comment.