把源码工作区嵌进 Hexo:Sandpack、Monaco 和本地预览的取舍
这次给博客补源码工作区,起点是一个很朴素的问题:很多文章都来自真实项目复盘,读者看完原理以后,还需要拿到能直接放进项目里的代码。
以 Unicode 文本字符数那篇文章为例,正文可以讲清楚为什么 length 会误判 emoji,为什么要用 grapheme cluster,ESLint 规则又该怎么拦住用户可见文本上的原生字符串操作。但这些内容真正落地时,需要一组工具函数、一条 ESLint 规则、单测、接入示例和迁移说明。普通 Markdown 代码块只能解释关键片段,放完整工程包会把文章撑得很难读。
最后我采用的是「文章里嵌源码工作区,代码按文件分发」:可复用代码仍跟着文章放在 blog 仓库里,读者可以在正文里浏览、编辑、预览,也可以直接按 FILES.json 下载源码文件,或复制 copy/ 目录到自己的项目。
先定分发边界
单独建仓库也可以做,但第一版会把文章和代码的演进拆开。读者看文章时要跳到另一个仓库找文件,作者改文章时也要记得同步另一个仓库的 README、demo 和链接。对这种轻量复用材料来说,先让文章、代码包和嵌入工作区在同一个 Hexo 仓库里演进,维护成本更低。
所以代码包放在文章资源目录下:
source/_posts/2026-07-03 - 别再用 length 统计用户文本:一次 Unicode 字符数和 ESLint 护栏复盘.md
source/files/2026-07-03 - 别再用 length 统计用户文本:一次 Unicode 字符数和 ESLint 护栏复盘/kits/unicode-user-text-guardrail/公开访问路径仍然走已有的 /files/<url_title>/... 映射。读者看到的是稳定 URL,本地维护时看到的是日期和中文标题。kits/<kit-id>/ 下面再固定几类文件:
copy/放建议迁移到项目里的源码。examples/放接入示例、配置示例和演示输入。README.md写迁移说明和目录边界。AGENT_PROMPT.md放给 Coding Agent 的接入 prompt,帮助读者把工具包按目标项目结构迁进去。CHANGELOG.md记录文章配套代码怎么迭代。MANIFEST.json描述文章里要嵌哪些工作区。FILES.json描述公开文件索引,给读者和 Agent 一个稳定的逐文件下载入口。
这个结构的重点是让正文和代码互相指向,但不强迫读者接受一个公共库契约。文章解释为什么,源码工作区展示怎么做,copy/ 目录负责迁移。
后来又补了一层 Agent prompt。文件分发需要让代码进入目标项目,并按项目已有工具目录、ESLint 配置、测试框架和命名空间调整。把这段迁移口径写成 AGENT_PROMPT.md,读者就可以把工具包根路径或本地目录交给 Coding Agent,让它先读 README.md、MANIFEST.json 和 FILES.json,再执行接入、替换调用点和验证命令。
这类 prompt 也要写边界。比如 Unicode 工具包里会明确要求 Agent 不要批量替换所有 .length,只迁移昵称、签名、描述、搜索词这类用户可见文本;技术字符串、ID、token、path、checksum、sku、serial 仍然可以保留原生字符串操作。否则 prompt 反而会把文件分发的灵活性变成另一种自动化误伤。
三类能力拆开
最开始容易把「源码工作区」想成一个大而全的在线 IDE。真正拆需求时,它其实只有三类能力:
| 场景 | 读者要做什么 | 更合适的工具 |
|---|---|---|
| 多文件源码浏览 | 看工具函数、Lint 规则、单测和示例之间的关系 | Sandpack |
| 代码预览 | 改一段 CSS、DOM 或组件示例后立刻看效果 | Sandpack |
| Lint 规则演示 | 在编辑器里看到波浪线和 hover 提示 | Monaco |
Sandpack 已经把文件树、编辑器、预览、控制台和测试这几个部分组织好了。它适合承载「一个小项目」:读者打开后能切文件、看默认入口、跑预览,未来写 CSS demo 或组件 demo 也能继续用同一套容器。
Monaco 更适合单文件诊断。ESLint 规则文档里常见的体验是在代码块里标出哪一行会被规则命中;hover 上去能看到 message、ruleId 和类似 VSCode 的问题提示。这件事用 Monaco 的 marker 就够了,不需要启动完整 Node 环境。
后面补 Vue SFC 高亮时,没有继续用 HTML 语法兜底,而是接了 @shikijs/monaco。Monaco 负责编辑器和 marker,Shiki 负责 TextMate 语法和主题;vue、typescript、scss、html 这些语言统一由 Shiki 注册,代码块里的 <script setup lang="ts"> 和 <style lang="scss"> 才不会被当成普通 HTML 看。
对应到 MANIFEST.json,一篇文章可以同时声明 Sandpack 工作区和 Monaco Lint 示例:
// sheng-blog/source/files/.../kits/unicode-user-text-guardrail/MANIFEST.json,阅读版节选
{
"labs": {
"unicode-text-source": {
"engine": "sandpack",
"mode": "source",
// ...省略默认文件和文件列表
},
"eslint-rule-source": {
"engine": "sandpack",
"mode": "source",
// ...省略默认文件和文件列表
},
"eslint-user-text-lint": {
"engine": "monaco",
"mode": "lint",
"runner": "unicode-user-text/no-native-string-user-text-ops",
// ...省略多个平铺示例
}
}
}文章里只需要写一个 Hexo tag:
{% code_lab unicode-user-text-guardrail unicode-text-source height=640 %}这层声明只关心「我要嵌哪个 kit 的哪个 lab」。至于它最终由 Sandpack 还是 Monaco 承载,由 MANIFEST.json 决定。
先不引入更重的运行时
有两个方向一开始很有诱惑力:VSCode Workbench 和 WebContainer。
VSCode Workbench 能提供更接近桌面 IDE 的文件树、编辑器、面板和快捷键,但它的集成成本也接近在博客里维护一个小型 IDE。博客文章里的代码工作区不需要项目级搜索、插件系统、调试面板、Git 面板和完整命令系统。为了展示几组可复制文件,把 Workbench 搬进 Hexo,成本会压过收益。
WebContainer 的边界也类似。它能在浏览器里跑 Node 项目,适合需要安装依赖、启动 dev server、跑真实构建工具的 playground。但当前的 Lint 场景只是验证自定义规则能扫出问题;StyleLint Lite Playground 那类思路已经够用:把 linter 和规则打进浏览器,在前端直接给编辑器打标。后续如果某篇文章确实要演示真实 npm install、服务端渲染或完整脚手架,再单独评估 WebContainer。
这一步的判断标准比较简单:先用能覆盖当前文章和近期扩展的最小运行时。源码浏览和预览交给 Sandpack,单文件诊断交给 Monaco;等文章需要完整 Node runtime 时,再把 WebContainer 当第三种 lab engine 加进去。
Lint 示例像代码块
Lint 示例的交互后来也收窄了一次。
一开始很容易给它加下拉列表、右侧问题面板、全屏按钮和各种工具按钮。但 ESLint 规则文档里的核心体验其实更接近一段代码块:多个例子直接平铺,示例名称写进代码第一行注释,错误就在对应 token 下面出现红色波浪线。读者不需要先选一个示例,也不需要看右侧面板统计问题数。
这套实现里,Lint 区块只做三件事:
- 每个示例都是一个 Monaco editor。
- 内容变化后重新执行对应的 browser-side lint runner。
- 用
editor.setModelMarkers()把 ESLint 诊断交给 Monaco 渲染。
// sheng-blog/client/code-lab/lint-lab.ts:144-147
function runLint(state: LintState, filename: string) {
const code = state.model.getValue();
const problems = state.runner(code, filename, state.lintOptions);
state.monaco.editor.setModelMarkers(state.model, 'code-lab-eslint', toMonacoMarkers(state.monaco, problems));
}这样 hover 提示、波浪线和问题来源都走 Monaco 原生机制,文章页面不再额外渲染一个「问题列表」。多文件源码区才保留全屏按钮,因为它确实会受文章内容宽度限制;Lint 示例代码短,保持文档代码块形态更清爽。
Hexo 标签只输出容器
Hexo 侧不直接渲染复杂交互,只负责把文章里的 tag 变成一个可挂载容器,并带上 kit 路径、lab id、资源版本和初始高度。
scripts/code-lab-tag.js 会读取文章对应的 source/files/.../kits/<kit-id>/MANIFEST.json,找到目标 lab,再计算公开资源路径和版本参数:
// sheng-blog/scripts/code-lab-tag.js:155-177
const publicRoot = normalizePublicRoot(options.root || `files/${urlTitle}/kits/${kitId}`);
const blockId = `code-lab-${kitId.replace(/[^a-zA-Z0-9_-]+/g, '-')}-${labId.replace(/[^a-zA-Z0-9_-]+/g, '-')}`;
const title = options.title || lab?.title || manifest.name || kitId;
const mode = options.mode || lab?.mode || 'source';
const height = toStyleHeight(options.height || lab?.height);
const readmeUrl = `${publicRoot}README.md`;
const codeLabAssetsDir = path.join(hexo.base_dir, '.code-lab-dist');
const cssUrl = appendVersion('/js/code-lab/code-lab.css', getFileVersion(path.join(codeLabAssetsDir, 'code-lab.css')));
const scriptUrl = appendVersion('/js/code-lab/code-lab.js', getFileVersion(path.join(codeLabAssetsDir, 'code-lab.js')));后面的 return 再把这些值写进容器、CSS 和 ESM 脚本:
// sheng-blog/scripts/code-lab-tag.js:168-177
return [
`<section id="${escapeAttribute(blockId)}" class="code-lab" style="--code-lab-height: ${height}" data-code-lab data-kit-id="${escapeAttribute(kitId)}" data-lab-id="${escapeAttribute(labId)}" data-mode="${escapeAttribute(mode)}" data-root="${escapeAttribute(publicRoot)}" data-code-lab-version="${escapeAttribute(kitVersion)}" data-manifest="${escapeAttribute(manifestUrl)}" data-files-index="${escapeAttribute(filesIndexUrl)}">`,
`<div class="code-lab-loading">`,
`<strong>${escapeHtml(title)}</strong>`,
`<span>正在加载代码工作区...</span>`,
`<noscript><p><a href="${escapeAttribute(readmeUrl)}">阅读迁移说明</a></p></noscript>`,
`</div>`,
`</section>`,
`<link rel="stylesheet" href="${escapeAttribute(cssUrl)}">`,
`<script type="module" src="${escapeAttribute(scriptUrl)}"></script>`,
].join('');这里有两个细节很关键。
第一,MANIFEST.json、FILES.json、code-lab.js 和 code-lab.css 都要带内容版本参数。Code Lab 的公开 URL 很稳定,稳定 URL 对文章链接是好事,对浏览器缓存和静态站缓存却是风险;版本参数可以让读者刷新后拿到当前代码包和当前 bundle。
这里的版本只放在 query 上,不放进文件名里。OSS 上仍然覆盖 /js/code-lab/code-lab.js、/js/code-lab/code-lab.css 和 /js/code-lab/chunks/... 这些固定对象。否则每次改 Code Lab 都会新增一批 hash 文件,文章能更新,远端存储却会越堆越多。
第二,Hexo tag 不关心 Sandpack 或 Monaco 的具体实现。浏览器端脚本会根据 data-mode、data-manifest 和 data-files-index 懒加载对应模块。这样以后新增 preview、console、tests 或别的 engine,不需要改文章语法。
打包产物不要写进 source/
真正踩坑的是前端 bundle 的输出位置。
第一版把 Code Lab bundle 直接打到 source/js/code-lab/。这个路径看起来很自然:Hexo 会把 source/js/... 发布成 /js/...,文章里引用也方便。但 hexo server 运行期间,Code Lab 重新打包会生成新的 ESM chunk、worker、CSS 和字体文件,文件名里还带 hash。Hexo 一边监听 source/,一边把这些文件建模成站点资源,控制台就可能出现这类报错:
Unhandled rejection WarehouseError: ID `source/js/code-lab/chunks/sandpack-lab-4VHUSWN2.js` has been usedskip_render 解决不了这个问题。它能告诉 Hexo 不要渲染某些文件,但这些文件仍然在 source/ 下面,仍然会被 watch 和建模。对频繁变化的构建产物来说,正确边界是不要放进 source/。
现在 bundle 输出到 .code-lab-dist/:
// sheng-blog/build-scripts/build-code-lab.mts:7-12
const outputDir = path.join(repoRoot, '.code-lab-dist');
const legacySourceOutputDir = path.join(repoRoot, 'source/js/code-lab');
const browserTypeScriptShim = path.join(repoRoot, 'client/code-lab/eslint-typescript-shim.ts');
await rm(outputDir, { recursive: true, force: true });
await rm(legacySourceOutputDir, { recursive: true, force: true });构建时还有一个小细节:不能直接把 esbuild 的 chunk 命名改成 chunks/[name]。Shiki 和 Monaco 里有不少同名模块,比如 chunk、register、scss,直接去掉 hash 会让 esbuild 报输出路径冲突。所以现在先让 esbuild 生成临时 hash 文件,再根据 metafile 改成稳定文件名,并同步重写 JS / CSS 里的引用:
// sheng-blog/build-scripts/build-code-lab.mts,节选
const codeLabBuild = await build({
// ...
chunkNames: 'chunks/[name]-[hash]',
assetNames: 'assets/[name]-[hash]',
metafile: true,
splitting: true,
});
await stabilizeOutputFilenames(codeLabBuild.metafile);最后公开出来的是 chunks/lint-lab.js、chunks/sandpack-lab.js、chunks/chunk-1.js、assets/codicon.ttf 这类稳定路径。更新时覆盖同名对象,缓存刷新靠入口文件和 kit 文件上的 ?v=。
.code-lab-dist/ 不在 Hexo 的 source/ 下面,也不提交。生成阶段再由 scripts/code-lab-assets.js 把它挂回公开 /js/code-lab/ 路径:
// sheng-blog/scripts/code-lab-assets.js:57-75
hexo.extend.generator.register('code_lab_assets', function() {
const distDir = getCodeLabDistDir(this);
if (!fs.existsSync(distDir)) {
this.log.warn('Code Lab assets not found. Run `pnpm run build:code-lab` before Hexo generate/server.');
return [];
}
return collectFiles(distDir).map(filePath => {
const relativePath = toRoutePath(path.relative(distDir, filePath));
return {
path: `${ROUTE_PREFIX}${relativePath}`,
data: {
data: () => fs.createReadStream(filePath),
modified: true
}
};
});
});本地 hexo server 还额外注册了 middleware,从当前 .code-lab-dist/ 直接读取 /js/code-lab/...。这样 server 重启后,同一个公开路径会直接返回当前 dist 里的文件,不需要等 Hexo 把它当成 source/ 资源重新扫描。
这个坑留下的规则很明确:文章资源源码可以放 source/files/,前端构建产物不要放 source/。源码是内容,bundle 是生成物,两者生命周期不一样。
本地预览需要重启边界
Code Lab 还有一类问题不在构建,而在本地预览。
裸 hexo server 对普通文章正文和图片已经够用,但 tag helper、站点脚本、配置文件和 Code Lab bundle 改动,经常需要重新读取配置、重新生成 route,甚至清掉 Hexo 数据库。只靠增量 watch,页面可能继续加载旧的 code-lab.js、旧的 manifest URL 或旧 DOM;看起来像「代码没生效」,实际是预览服务还在复用旧状态。
所以 package.json 里的 server 不再直接跑 hexo server,而是包一层:
// sheng-blog/package.json,节选
{
"scripts": {
"build:code-lab": "tsx build-scripts/build-code-lab.mts",
"server": "tsx build-scripts/dev-server.mts"
}
}包装脚本的首次启动顺序是:
pnpm run build:code-labpnpm exec hexo cleanpnpm exec hexo server ...
运行期间它只监听会影响 Code Lab 的文件:
// sheng-blog/build-scripts/dev-server.mts:29-50
function shouldRestart(filePath: string) {
const relativePath = toRelativePath(filePath);
if (!relativePath || relativePath.endsWith('/.DS_Store') || relativePath === '.DS_Store') return false;
if (
relativePath === '_config.yml'
|| relativePath === '_config.fluid.yml'
|| relativePath === 'package.json'
|| relativePath === 'pnpm-lock.yaml'
|| relativePath === 'pnpm-workspace.yaml'
|| relativePath === 'build-scripts/build-code-lab.mts'
|| relativePath === 'build-scripts/dev-server.mts'
|| relativePath === 'scripts/code-lab-assets.js'
|| relativePath === 'scripts/code-lab-tag.js'
) {
return true;
}
if (relativePath.startsWith('client/code-lab/')) return true;
return relativePath.startsWith('source/files/') && relativePath.includes('/kits/');
}命中这些文件后,脚本会停掉旧 server,重新构建 Code Lab,执行 hexo clean,再启动新的 hexo server。普通文章正文和普通图片仍交给 Hexo 自己的 watch 处理;只有会影响 Code Lab 资源、标签输出或配置读取的变化,才触发整站重启。
本地预览命令保持简单:
pnpm run server -p <port>这条命令仍然把 -p <port> 透传给 Hexo。使用时要看最终 Hexo 输出的 URL,确认浏览器打开的是同一个预览服务。
这套方案的适用范围
现在的 Code Lab 更像博客里的「可复用代码展示层」,不是托管 IDE。
它适合这些内容:
- 工具函数、Lint 规则、小型构建脚本和单测。
- CSS / DOM / 组件写法的小型 live preview。
- 文章配套的 playground、迁移示例和可下载代码包。
- 需要跟文章一起迭代、并允许读者按项目调整的实践代码。
它暂时不适合这些内容:
- 需要完整 Node runtime、安装依赖和启动服务端的项目。
- 需要多人协作、保存云端编辑状态或提交代码的场景。
- 需要复刻完整 VSCode 工作台体验的长期文档站。
如果后面某篇文章真的需要完整运行时,MANIFEST.json 的 engine 可以继续扩展,比如增加 WebContainer;但默认不把重运行时作为第一解。
验证口径
这类改动不能只看 bundle 是否构建成功。真正要验证的是文章页面能否加载到当前资源,编辑器和预览是否按预期运行。
当前这套改动的本地验证口径是:
pnpm run test:kits:确认每个source/files/*/kits/*/copy/tests/下的源码包测试都能在自己的copy/根目录运行,避免文章展示正常、读者复制后才发现规则或工具函数已经坏了。pnpm run build:code-lab:确认 Sandpack、Monaco、Lint runner 和 worker 都能打包进.code-lab-dist/。pnpm exec tsc -p build-scripts/tsconfig.json:确认构建脚本和 dev server 的 TypeScript 类型没坏。pnpm exec hexo clean && pnpm exec hexo generate:确认 Hexo 可以把文章、kit 源码文件和/js/code-lab/路由完整生成。git diff --check:确认 Markdown 和脚本没有留下尾随空格等格式问题。pnpm run server -p <port>:确认首次启动会构建 Code Lab、清理 Hexo 数据库并启动预览服务。- 打开文章页后检查 Network:
code-lab.js、code-lab.css、MANIFEST.json和FILES.json都应该带版本参数;/js/code-lab/chunks/和/js/code-lab/assets/下的文件名应该是稳定命名,不再带 esbuild hash。 - 检查 Sandpack 区块:文件树、当前文件、预览区和全屏按钮符合
MANIFEST.json。 - 检查 Monaco Lint 区块:多个示例平铺显示,第一行注释作为示例名,命中 token 下方有波浪线,hover 能看到 ESLint message 和 ruleId;
.vue示例要按 Vue SFC 语言高亮,而不是退回 HTML。
pnpm run test:kits 不是形式检查。补上这一步后,Nuxt 自动导入那组规则立刻暴露出一个 ESLint 10 兼容问题:规则原来只读 context.getFilename(),在新的 RuleContext 里会直接失败。后续这类规则不要把 context.getFilename()、context.cwd、context.sourceCode 这类版本差异散在每条规则里,而是先通过一个小的 RuleContext 兼容层探测 API 形态;ESLint 8 / 9 走旧方法和新属性共存的路径,ESLint 10 走只保留新属性的路径,缺必要 SourceCode 能力时提前报出明确错误。统一测试入口把这个问题提前留在 blog 仓库里解决,而不是让读者复制代码后再遇到。
其中最容易漏的是最后两项。代码里能搜到新字符串,只能说明 bundle 里有新内容;文章页实际加载到哪一个 URL、浏览器有没有拿缓存、Hexo server 有没有复用旧 HTML,都要在页面运行态确认。
总结
这次补工具后的稳定判断是:
- 可复用代码和文章放在一起维护。读者需要的是能复制、能改、能跟文章对上的源码包。
- 文章资源源码和前端打包产物分开。
source/files/放读者要看的内容,.code-lab-dist/放生成物,再由 Hexo 脚本挂回/js/code-lab/;前端产物用稳定文件名覆盖发布,缓存刷新交给查询版本。 - Sandpack 负责多文件源码和预览,Monaco 负责单文件 Lint 诊断。不要为了统一体验,把所有场景都塞进一个重运行时。
- Lint 示例更像增强版代码块。平铺、波浪线、hover 比下拉列表和右侧问题面板更贴近规则文档。
- 本地预览也属于方案的一部分。改 tag helper、bundle、manifest 或配置时,自动构建、
hexo clean和重启 server,能少掉很多「明明改了但页面没变」的假问题。
这套机制后面应该还会继续长,但第一层边界已经清楚了:blog 仍然是文章,Code Lab 只补足文章落地所需的源码、预览和诊断能力。