From 7f86e71687c3610c78ff136004dc5182cf1e168d Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Tue, 5 Nov 2024 00:07:03 +0800 Subject: [PATCH] feat(transformers): add Style to Class transformer --- docs/packages/transformers.md | 81 ++++++++++++ packages/transformers/src/index.ts | 1 + .../src/transformers/style-to-class.ts | 123 ++++++++++++++++++ .../transformers/test/style-to-class.test.ts | 50 +++++++ 4 files changed, 255 insertions(+) create mode 100644 packages/transformers/src/transformers/style-to-class.ts create mode 100644 packages/transformers/test/style-to-class.test.ts diff --git a/docs/packages/transformers.md b/docs/packages/transformers.md index e13db3fab..f1106e7b6 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 1d305eaa9..a7f5291a7 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 000000000..771e5297a --- /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 000000000..9db4d329e --- /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}"`) +})