Skip to content

Commit 48d6c57

Browse files
authored
feat(transformers): add Style to Class transformer (#826)
1 parent 5d08484 commit 48d6c57

File tree

4 files changed

+255
-0
lines changed

4 files changed

+255
-0
lines changed

docs/packages/transformers.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,3 +348,84 @@ Remove line breaks between `<span class="line">`. Useful when you set `display:
348348

349349
Transform `// [\!code ...]` to `// [!code ...]`.
350350
Avoid rendering the escaped notation syntax as it is.
351+
352+
---
353+
354+
### `transformerStyleToClass`
355+
356+
Convert Shiki's inline styles to unique classes.
357+
358+
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.
359+
360+
For example:
361+
362+
```ts
363+
import { transformerStyleToClass } from '@shikijs/transformers'
364+
import { codeToHtml } from 'shiki'
365+
366+
const toClass = transformerStyleToClass({ // [!code highlight:3]
367+
classPrefix: '__shiki_',
368+
})
369+
370+
const code = `console.log('hello')`
371+
const html = await codeToHtml(code, {
372+
lang: 'ts',
373+
themes: {
374+
dark: 'vitesse-dark',
375+
light: 'vitesse-light',
376+
},
377+
defaultColor: false,
378+
transformers: [toClass], // [!code highlight]
379+
})
380+
381+
// The transformer instance exposes some methods to get the CSS
382+
const css = toClass.getCSS() // [!code highlight]
383+
384+
// use `html` and `css` in your app
385+
```
386+
387+
HTML output:
388+
389+
```html
390+
<pre class="shiki shiki-themes vitesse-dark vitesse-light __shiki_9knfln" tabindex="0"><code><span class="line">
391+
<span class="__shiki_14cn0u">console</span>
392+
<span class="__shiki_ps5uht">.</span>
393+
<span class="__shiki_1zrdwt">log</span>
394+
<span class="__shiki_ps5uht">(</span>
395+
<span class="__shiki_236mh3">'</span>
396+
<span class="__shiki_1g4r39">hello</span>
397+
<span class="__shiki_236mh3">'</span>
398+
<span class="__shiki_ps5uht">)</span>
399+
</span></code></pre>
400+
```
401+
402+
CSS output:
403+
404+
```css
405+
.__shiki_14cn0u {
406+
--shiki-dark: #bd976a;
407+
--shiki-light: #b07d48;
408+
}
409+
.__shiki_ps5uht {
410+
--shiki-dark: #666666;
411+
--shiki-light: #999999;
412+
}
413+
.__shiki_1zrdwt {
414+
--shiki-dark: #80a665;
415+
--shiki-light: #59873a;
416+
}
417+
.__shiki_236mh3 {
418+
--shiki-dark: #c98a7d77;
419+
--shiki-light: #b5695977;
420+
}
421+
.__shiki_1g4r39 {
422+
--shiki-dark: #c98a7d;
423+
--shiki-light: #b56959;
424+
}
425+
.__shiki_9knfln {
426+
--shiki-dark: #dbd7caee;
427+
--shiki-light: #393a34;
428+
--shiki-dark-bg: #121212;
429+
--shiki-light-bg: #ffffff;
430+
}
431+
```

packages/transformers/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ export * from './transformers/notation-highlight-word'
99
export * from './transformers/remove-line-breaks'
1010
export * from './transformers/remove-notation-escape'
1111
export * from './transformers/render-whitespace'
12+
export * from './transformers/style-to-class'
1213
export * from './utils'
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import type { ShikiTransformer } from 'shiki'
2+
3+
export interface TransformerStyleToClassOptions {
4+
/**
5+
* Prefix for class names.
6+
* @default '__shiki_'
7+
*/
8+
classPrefix?: string
9+
/**
10+
* Suffix for class names.
11+
* @default ''
12+
*/
13+
classSuffix?: string
14+
/**
15+
* Callback to replace class names.
16+
* @default (className) => className
17+
*/
18+
classReplacer?: (className: string) => string
19+
}
20+
21+
export interface ShikiTransformerStyleToClass extends ShikiTransformer {
22+
getClassRegistry: () => Map<string, Record<string, string> | string>
23+
getCSS: () => string
24+
clearRegistry: () => void
25+
}
26+
27+
/**
28+
* Remove line breaks between lines.
29+
* Useful when you override `display: block` to `.line` in CSS.
30+
*/
31+
export function transformerStyleToClass(options: TransformerStyleToClassOptions = {}): ShikiTransformerStyleToClass {
32+
const {
33+
classPrefix = '__shiki_',
34+
classSuffix = '',
35+
classReplacer = (className: string) => className,
36+
} = options
37+
38+
const classToStyle = new Map<string, Record<string, string> | string>()
39+
40+
function stringifyStyle(style: Record<string, string>): string {
41+
return Object.entries(style)
42+
.map(([key, value]) => `${key}:${value}`)
43+
.join(';')
44+
}
45+
46+
function registerStyle(style: Record<string, string> | string): string {
47+
const str = typeof style === 'string'
48+
? style
49+
: stringifyStyle(style)
50+
let className = classPrefix + cyrb53(str) + classSuffix
51+
className = classReplacer(className)
52+
if (!classToStyle.has(className)) {
53+
classToStyle.set(
54+
className,
55+
typeof style === 'string'
56+
? style
57+
: { ...style },
58+
)
59+
}
60+
return className
61+
}
62+
63+
return {
64+
name: '@shikijs/transformers:style-to-class',
65+
pre(t) {
66+
if (!t.properties.style)
67+
return
68+
const className = registerStyle(t.properties.style as string)
69+
delete t.properties.style
70+
this.addClassToHast(t, className)
71+
},
72+
tokens(lines) {
73+
for (const line of lines) {
74+
for (const token of line) {
75+
if (!token.htmlStyle)
76+
continue
77+
78+
const className = registerStyle(token.htmlStyle)
79+
token.htmlStyle = {}
80+
token.htmlAttrs ||= {}
81+
if (!token.htmlAttrs.class)
82+
token.htmlAttrs.class = className
83+
else
84+
token.htmlAttrs.class += ` ${className}`
85+
}
86+
}
87+
},
88+
getClassRegistry() {
89+
return classToStyle
90+
},
91+
getCSS() {
92+
let css = ''
93+
for (const [className, style] of classToStyle.entries()) {
94+
css += `.${className}{${typeof style === 'string' ? style : stringifyStyle(style)}}`
95+
}
96+
return css
97+
},
98+
clearRegistry() {
99+
classToStyle.clear()
100+
},
101+
}
102+
}
103+
104+
/**
105+
* A simple hash function.
106+
*
107+
* @see https://stackoverflow.com/a/52171480
108+
*/
109+
function cyrb53(str: string, seed = 0): string {
110+
let h1 = 0xDEADBEEF ^ seed
111+
let h2 = 0x41C6CE57 ^ seed
112+
for (let i = 0, ch; i < str.length; i++) {
113+
ch = str.charCodeAt(i)
114+
h1 = Math.imul(h1 ^ ch, 2654435761)
115+
h2 = Math.imul(h2 ^ ch, 1597334677)
116+
}
117+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507)
118+
h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909)
119+
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507)
120+
h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909)
121+
122+
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36).slice(0, 6)
123+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { createHighlighter } from 'shiki'
2+
import { expect, it } from 'vitest'
3+
import { transformerStyleToClass } from '../src/transformers/style-to-class'
4+
5+
it('transformerStyleToClass', async () => {
6+
const shiki = await createHighlighter({
7+
themes: ['vitesse-dark', 'vitesse-light', 'nord'],
8+
langs: ['typescript'],
9+
})
10+
11+
const transformer = transformerStyleToClass()
12+
13+
const code = `
14+
const a = Math.random() > 0.5 ? 1 : \`foo\`
15+
`.trim()
16+
17+
const result = shiki.codeToHtml(code, {
18+
lang: 'typescript',
19+
themes: {
20+
dark: 'vitesse-dark',
21+
light: 'vitesse-light',
22+
nord: 'nord',
23+
},
24+
defaultColor: false,
25+
transformers: [transformer],
26+
})
27+
28+
expect(result.replace(/<span/g, '\n<span'))
29+
.toMatchInlineSnapshot(`
30+
"<pre class="shiki shiki-themes vitesse-dark vitesse-light nord __shiki_uywmyh" tabindex="0"><code>
31+
<span class="line">
32+
<span class="__shiki_223nhr">const</span>
33+
<span class="__shiki_u5wfov"> a</span>
34+
<span class="__shiki_26darv"> =</span>
35+
<span class="__shiki_u5wfov"> Math</span>
36+
<span class="__shiki_17lqoe">.</span>
37+
<span class="__shiki_6u0ar0">random</span>
38+
<span class="__shiki_k92bfk">()</span>
39+
<span class="__shiki_26darv"> ></span>
40+
<span class="__shiki_1328cg"> 0.5</span>
41+
<span class="__shiki_223nhr"> ?</span>
42+
<span class="__shiki_1328cg"> 1</span>
43+
<span class="__shiki_223nhr"> :</span>
44+
<span class="__shiki_ga6n9x"> \`</span>
45+
<span class="__shiki_23isjw">foo</span>
46+
<span class="__shiki_ga6n9x">\`</span></span></code></pre>"
47+
`)
48+
49+
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}"`)
50+
})

0 commit comments

Comments
 (0)