shikiji-dom-loader:基于 shiki(ji) 的 DOM 代码块自动高亮工具
写这个东西的原因是,之前用的 PrismJS 的代码高亮没有办法满足我的需求,魔改了一版也是一样,然后想起来 Vitepress 使用的代码高亮工具十分的有意思,于是就想着能不能给这玩意写一个在 Web 加载的工具。主要手里面的你看到的博客还是 WordPress,要是 SSG 的话还是会好很多,就不用写这玩意了。
首先就是要解决一个问题,就是,我发现用 ESBuild 把 shikiji 的所有文件打包在一起的大小有近 12MB。加载一个 12MB 的脚本显然是不现实的,而且里面有 80% 的语言都用不到;想着分模块加载似乎又不是很好做……
直到我换成 rollup 对还没写完的 main.js
进行了打包。rollup 告诉我,这玩意必须分块分包啥的,打包不了 iife/umd。这不正好!印象中 SystemJS 似乎是有一个全局对象,所以就让 rollup 打包成了 SystemJS 格式的一个个的 modules。正合我意。于是便有了下面的想法:
Dynamic Script Tag -> Mount on global object -> export / import / execute
整个机制是借鉴 Prism 的 autoloader。最后写出来也是和这个 autoloader 比较像的(
显然,如果直接使用 System 作为这个插件在全局作用域还是太草率了,毕竟是 SystemJS 自己的名称。所以这里使用了 @rollup/plugin-replace
插件替换掉 System
这一名称。
接下来就是人工实现一版 SystemJS 的过程;且称之为 preloader。
观察 SystemJS 打包出来的东西:
ShikijiDOMLoader.register(['./javascript.js', './typescript.js', './coffee.js', './stylus.js', './sass.js', './css.js', './scss.js', './less.js', './postcss.js', './pug.js', './markdown.js', './html.js'], (function (exports) {
'use strict';
var javascript, typescript, coffee, stylus, sass, css, scss, less, postcss, pug, markdown;
return {
setters: [function (module) {
javascript = module.default;
}, function (module) {
typescript = module.default;
}, function (module) {
coffee = module.default;
}, function (module) {
stylus = module.default;
}, function (module) {
sass = module.default;
}, function (module) {
css = module.default;
}, function (module) {
scss = module.default;
}, function (module) {
less = module.default;
}, function (module) {
postcss = module.default;
}, function (module) {
pug = module.default;
}, function (module) {
markdown = module.default;
}, null],
execute: (function () {
const lang = {}; // 已经省略
var svelte = exports("default", [
...javascript,
...typescript,
...coffee,
...stylus,
...sass,
...css,
...scss,
...less,
...postcss,
...pug,
...markdown,
lang
]);
})
};
}));
main.js 还有动态导入:
ShikijiDOMLoader.register([], (function (exports, module) {
'use strict';
return {
execute: (async function () {
const bundledLanguagesInfo = [
{
"id": "abap",
"name": "ABAP",
"import": () => module.import('./abap.js')
}
]
})
}
}));
大概不难猜到是下面的流程:
type Module = { import: (url: string) => Promise<Record<string, unknown>>; };
type Exporter = (name: string, data: unknown) => void;
type Loader = () => {
execute: () => Promise<unknown>;
setters: ((module: unknown) => void | null)[];
}
register(deps: string[], loader: (exports: Exporter, module: Module) => Loader) => void
每一个 deps[i]
对应一个 setter[i]
,deps[i]
的导入结果传给 setter[i]
;很明显了吧。
然后通过事件给动态标签的脚本加载(串行)做了一个 Promise
的封装:
(不串行加载会出现一个问题:在 register
的时候不能判断 moduleUrl
,因此只能串行了,对网络条件不好的用户来说体验会差一点)
window.ShikijiDOMLoader = new (class {
loadingScript = "";
/** @type {Record<string, Record<string, unknown>>} */
modulesData = {};
/** @param {string} moduleUrl */
import = (moduleUrl) => {
const self = this;
return new Promise((resolve, reject) => {
const moduleKey = self.getModuleUrl(moduleUrl);
this.loadingScript = moduleKey;
const el = document.createElement("script");
el.src = moduleKey;
function resolver() {
document.body.removeChild(el);
resolve(self.modulesData[moduleKey]);
removeEventListener(
`_ShikijiDOMLoader__register__module__{moduleKey}`,
resolver
);
}
addEventListener(
`_ShikijiDOMLoader__register__module__{moduleKey}`,
resolver
);
el.onerror = (e) => {
document.body.removeChild(el);
reject(e);
};
document.body.appendChild(el);
});
};
/**
* @param {string[]} dependencies
* @param {import("../preloader").ModuleLoaderFn} loader
*/
register = async (dependencies, loader) => {
const registerModuleKey = this.loadingScript;
// ...
dispatchEvent(
new CustomEvent(
`_ShikijiDOMLoader__register__module__${registerModuleKey}`
)
);
};
})();
稍微模拟下,整个 preloader
就写好了。现在的问题是,preloader
似乎没法和 main.js
合在一起通过多入口构建。还是希望整个项目是一个单文件的;于是这里采用了一个笨办法:
(构建脚本)
const f = fs.openSync("temp/main.js", "w");
fs.writeSync(
f,
fs.readFileSync("src/preloader.js").toString() +
"\n\n" +
fs.readFileSync("dist/main.js").toString()
);
然后对 temp/main.js
跑 rollup
。这样就有了!
另外是记一下 JSDoc + TS 的一些 trick:
Window
可以这样写:
// preloader.d.ts
export declare global {
interface Window {
ShikijiDOMLoader: any;
}
}
// preloader.js
/// <reference path="./preloader.d.ts" />
window.ShikijiDOMLoader = ...
源代码放在 https://github.com/immccn123/shikiji-dom-loader 了。还是很有意思的。
不过现在看起来这个代码高亮还是有点 CSS 要修复,不过问题不大啦(有点溢出)。周末再修(
Imken
2024年1月7日 @ 17:12
发现这个 bug 多多的 Markdown Parser 把一些不应该解析成数学公式的代码块解析了。主要位于 `` 之间的 JavaScript 字符串。