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 ``. 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 +

+  console
+  .
+  log
+  (
+  '
+  hello
+  '
+  )
+
+``` + +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/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> + 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>() + + function stringifyStyle(style: Record): string { + return Object.entries(style) + .map(([key, value]) => `${key}:${value}`) + .join(';') + } + + function registerStyle(style: Record | 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/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(/ + + const + a + = + Math + . + random + () + > + 0.5 + ? + 1 + : + \` + foo + \`" + `) + + 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}"`) +})