转换 Shiki 的内联样式
Shiki
是一个基于 TextMate 语法的代码语法高亮器,它与 VS Code 的语法高亮引擎 onIguruma
一致,几乎所有主流编程语言提供非常准确且快速的语法高亮,然而 Shiki
并不关注于 CSS,它的语法高亮都是通过 HTML 的 style
属性实现的,这会导致很多样式无法得到复用,产生体积更大的 HTML
体积,本文实现内联样式到类名的转换。
如果你有这方面的需求,可以使用
shiki-class-transformer
,另外shiki
也是有自己的转换器的,但是它是 js 动态产生 css 片段,并不是直接导入 css。
为什么是内联样式?
如果你曾经魔改过 VS Code 的主题的话,你应该有印象需要修改 scope
属性和 settings.foreground
等属性,我们举一个 tm-themes
仓库(shiki
中的主题基于这个) 的一个简化例子:
{
"tokenColors": [
{
"scope": ["comment"],
"settings": {
"foreground": "#a0ada0"
}
},
{
"scope": ["constant"],
"settings": {
"foreground": "#999999"
}
}
]
}
如果你好奇为什么是
scope
和settings
这种设计模式,你可以参考vscode-textmate
官方的例子,在注释代码中可以看到打印结果。
可以看到代码颜色是基于 scope
字段决定的,而配置颜色的字段在 settings.forground
中,我在看到这段配置的时候,也是在想,为什么 settings
不能直接写一个类名?
原因之一在于有时候 scope
会过长,如果我们像 prism
一样,将类名分为 keyword
、variable
等等,会导致类名比内联样式还长的情况:
{
"scope": [
"delimiter.bracket",
"delimiter",
"invalid.illegal.character-not-allowed-here.html",
"keyword.operator.rest",
"keyword.operator.spread",
"keyword.operator.type.annotation",
"keyword.operator.relational",
"keyword.operator.assignment",
"keyword.operator.type",
"meta.brace",
"meta.tag.block.any.html",
"meta.tag.inline.any.html",
"meta.tag.structure.input.void.html",
"meta.type.annotation",
"meta.embedded.block.github-actions-expression",
"storage.type.function.arrow",
"meta.objectliteral.ts",
"punctuation",
"punctuation.definition.string.begin.html.vue",
"punctuation.definition.string.end.html.vue"
],
"settings": {
"foreground": "#999999"
}
},
因此直接从 settings
中设置 style
属性是最方便的做法,但是我认为 shiki
可以做得更好,因为颜色可以一一对应一个类名,这样 shiki
就不需要内联样式了,这也是这篇文章实现的主要思路。
实现思路
之前讲过,我们可以将颜色对应为一个类名,结合 shiki
的 transfomer
即可实现外联样式:
// 导入对应的 json 文件
import vitesseDark from 'tm-themes/themes/vitesse-dark.json'
import fs from 'node:fs'
// 准备好前缀和后缀
const PREFIX = 's'
let suffix = 0
function generateMap() {
// key 为颜色,value 为类名
const map = {}
for (const token of vitesseDark.tokenColors) {
const color = token?.settings?.foreground
if (color && !map[color]) {
map[color] = PREFIX + suffix
suffix++
}
}
fs.writeFileSync('./vitesse-dark.json', JSON.stringify(map))
return map
}
generateMap()
现在我们得到了一个 map
,这个 map
会在后面的 transfomer
中使用:
{
"#a0ada0": "s0",
"#999999": "s1",
"#a65e2b": "s2",
"#59873a": "s3"
// ...
}
接下来生成对应的 css
文件
function generateCss() {
const map = generateMap()
let style = ''
for (const [color, className] of Object.entries(map)) {
style += `
.${className}: {
color: ${color};
}
`
}
fs.writeFileSync('./vitesse-dark.css')
}
generateCss()
得到的文件:
.s0 {
color: #a0ada0;
}
.s1 {
color: #999999;
}
.s2 {
color: #a65e2b;
}
.s3 {
color: #59873a;
}
/* ... */
接下来书写 transformer
:
完整代码请看
shiki-class-transformer
,这里实现的只是最基本的,很多情况没有考虑。
// map 为之前我们生成的 json 文件
function shikiClassTransformer({ map }) {
return {
tokens(tokens) {
for (const items of tokens) {
for (const token of items) {
let htmlStyle = token.htmlStyle
if (!htmlStyle || typeof htmlStyle === 'string') {
return
}
// shiki 默认情况下会使用 color 作为颜色,例如 style="color:#fff"
const className = map[htmlStyle.color]
// map 中没有对应类名,删除掉
if (!className) {
continue
}
// 保证 token.htmlAttrs 不为空
if (!token.htmlAttrs) {
token.htmlAttrs = {}
}
// 设置类名
token.htmlAttrs.class = className
// 将原有 style.color 删除
// 如果 htmlStyle 为 {} 空对象,不会产生内联样式
delete token.htmlStyle['color']
}
}
},
}
}
大功告成(你可以开发者面板看一下我博客里的类名):