Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(transformers): add Style to Class transformer #826

Merged
merged 1 commit into from
Nov 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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}`

Check warning on line 84 in packages/transformers/src/transformers/style-to-class.ts

View check run for this annotation

Codecov / codecov/patch

packages/transformers/src/transformers/style-to-class.ts#L84

Added line #L84 was not covered by tests
}
}
},
getClassRegistry() {
return classToStyle
},

Check warning on line 90 in packages/transformers/src/transformers/style-to-class.ts

View check run for this annotation

Codecov / codecov/patch

packages/transformers/src/transformers/style-to-class.ts#L89-L90

Added lines #L89 - L90 were not covered by tests
getCSS() {
let css = ''
for (const [className, style] of classToStyle.entries()) {
css += `.${className}{${typeof style === 'string' ? style : stringifyStyle(style)}}`
}
return css
},
clearRegistry() {
classToStyle.clear()
},

Check warning on line 100 in packages/transformers/src/transformers/style-to-class.ts

View check run for this annotation

Codecov / codecov/patch

packages/transformers/src/transformers/style-to-class.ts#L99-L100

Added lines #L99 - L100 were not covered by tests
}
}

/**
* 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}"`)
})
Loading