Slidev {monaco-write} 可写 Monaco 编辑器:幻灯片内编辑代码并保存回文件的完整链路
本篇讲解 Slidev 的「可写 Monaco 编辑器」(monaco-write)功能:通过在代码导入语法后追加 {monaco-write} 指令,将幻灯片中的 Monaco 编辑器与磁盘上的真实文件绑定,直接在演示中修改代码并通过快捷键把变更写回文件。读完本文,你将掌握该功能的完整用法、适用限制,以及从 Markdown 解析、白名单注册到 WebSocket 落盘的保存回写机制。
功能定位:只读代码块升级为「文件级编辑器」
普通代码块和 {monaco} 代码编辑器都只操作内存中的文本,演示结束后内容即丢失。{monaco-write} 解决了「边讲边改、改完即存」的场景:它复用 Slidev 的 Import Code Snippets 语法把文件内容载入编辑器,并额外把该文件注册为「可写目标」,使 Monaco Editor 从展示工具变成真正的文件编辑器。
根据该功能文档的 frontmatter,此特性自 v0.49.5 起提供,标签为 codeblock 与 editor(见 docs/features/monaco-write.md)。
基本用法
在导入语法的元信息中加入 {monaco-write}:
<<< ./some-file.ts {monaco-write}
此时幻灯片会渲染出一个 Monaco 编辑器,其初始内容来自 ./some-file.ts 的真实文件内容。在编辑器中修改代码后,按 Ctrl+S(macOS 为 Cmd+S) 即可将编辑器当前内容写回该文件。
由于导入语法本身支持的能力,以下用法同样有效(详见 docs/features/import-snippet.md):
<<< @/snippets/snippet.ts {monaco-write} # 使用 @ 指向包根目录
<<< @/src/app.ts ts {monaco-write} # 显式指定语言标识
<<< @/src/app.ts {monaco-write}{lines:true} # 叠加行号等其它代码块特性
其中 @ 对应项目的根目录。官方文档建议把片段文件放在 @/snippets 下,以便与 Monaco 编辑器配合使用;也支持相对路径导入。
警告(原文档强调):修改会直接保存回文件本身,因此使用
monaco-write之前务必备份文件。
编辑器端的保存动作
保存行为由内置 Monaco 组件注册的一个编辑器 Action 实现。在 Monaco.vue 中:
editableEditor.addAction({
id: 'slidev-save',
label: 'Save',
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS],
run: () => {
if (!isWritable.value || !import.meta.hot?.send) {
console.warn('[Slidev] this monaco editor is not writable, save action is ignored.')
return
}
import.meta.hot.send('slidev:monaco-write', {
file: props.writable!,
content: editableEditor.getValue(),
})
},
})
三个关键事实可以从这段源码确认:
- 保存走的是 Vite HMR WebSocket:
import.meta.hot.send('slidev:monaco-write', ...)依赖 Vite 开发服务器的 HMR 通道,消息体是{ file, content }。 isWritable有三重约束:在 Monaco.vue 中const isWritable = computed(() => props.writable && !props.readonly && __DEV__)。即只有「带writable路径属性 + 非只读 + 开发环境(__DEV__)」同时满足时,Ctrl+S 才生效;生产构建或非serve场景下,按保存键只会输出一条控制台警告。writable属性就是目标文件路径:该属性由服务端解析阶段注入(见下一节),编辑器本身并不知道文件在哪,只知道往谁身上写。
服务端解析:白名单注册与 Token 生成
{monaco-write} 的处理发生在 Markdown → Vue 的解析阶段。snippet.ts 中的 markdown-it 插件拦截 <<< 导入语法后:
if (meta.includes('{monaco-write}')) {
monacoWriterWhitelist.add(filepath)
lang = lang.trim()
meta = meta.replace('{monaco-write}', '').trim() || '{}'
const safeFilepath = JSON.stringify(filepath).slice(1, -1)
const encoded = lz.compressToBase64(content)
const token = state.push('html_block', '', 0)
token.content = `<Monaco writable="${safeFilepath}" code-lz="${encoded}" lang="${lang}" v-bind="${meta}" />\n`
}
这段逻辑说明了完整的「装配」过程:
- 文件路径被登记进白名单
monacoWriterWhitelist(一个模块级Set<string>),这是后续服务端授权写盘的前提; - 文件内容用 lz-string 压缩为 Base64(
code-lz),避免特殊字符破坏 HTML 属性,也减小模板体积; - 路径经过
JSON.stringify(...).slice(1, -1)转义(safeFilepath),防止引号等字符破坏属性语法; {monaco-write}从 meta 中剥离,剩余 meta(如{lines:true})原样透传给 Monaco 组件;- 最终输出的是
<Monaco writable="..." code-lz="..." lang="..." v-bind="..." />组件调用——writable属性正是编辑器端判断「能否保存」的依据。
落盘链路:Vite 插件 + 双重校验
真正执行写文件的是 monacoWrite.ts 中定义的 Vite 插件 slidev:monaco-write(在 vite/index.ts 注册):
export function createMonacoWriterPlugin({ userRoot }: ResolvedSlidevOptions): Plugin {
return {
name: 'slidev:monaco-write',
apply: 'serve',
configureServer(server) {
server.ws.on('connection', (socket) => {
socket.on('message', async (data) => {
// ...
if (json.type === 'custom' && json.event === 'slidev:monaco-write') {
const { file, content } = json.data
if (!monacoWriterWhitelist.has(file)) {
console.error(`[Slidev] Unauthorized file write: ${file}`)
return
}
const filepath = path.resolve(userRoot, file)
const rel = path.relative(userRoot, filepath)
if (rel.startsWith('..') || path.isAbsolute(rel)) {
console.error(`[slidev] Refusing monaco write outside project root: ${file}`)
return
}
console.log('[Slidev] Writing file:', filepath)
await fs.writeFile(filepath, content, 'utf-8')
}
})
})
},
}
}
从源码结构可以确认以下安全与边界设计:
- 仅限开发模式:
apply: 'serve'表明插件只在slidev dev启动的 Vite 开发服务器中生效,slidev build产物不具备写回能力——与编辑器端__DEV__的约束相互印证。 - 白名单校验:消息中的
file必须精确命中解析阶段登记的monacoWriterWhitelist,否则直接拒绝并打印Unauthorized file write。客户端伪造一个任意文件路径是无效的,只有幻灯片里实际用{monaco-write}导入过的文件才能被写。 - 路径穿越防护:即使路径在(理论上)通过了白名单,
path.resolve(userRoot, file)后若相对结果以..开头或是绝对路径,同样被拒绝,保证写入永远落在项目根目录之内。 - 落盘是全文覆盖:
fs.writeFile(filepath, content, 'utf-8')直接用编辑器当前全文替换文件内容,没有 diff 或合并逻辑。这也解释了为何原文档反复强调先备份。
使用限制与注意事项
结合文档说明与源码事实,使用 monaco-write 前需要明确的前提与限制:
- 只适用于
slidev dev(serve 场景):写入插件apply: 'serve',编辑器端又要求__DEV__与 HMR 通道,因此slidev build、导出 PDF 或部署后的静态页面中,编辑器会退化为只读展示。 - 写回是整文件覆盖:
fs.writeFile直接以编辑器内容替换磁盘文件,无版本管理、无撤销。原文档明确建议「使用此功能前务必备份文件」。 - 与 region 导入组合要格外小心:
<<< file.ts#region {monaco-write}只会把 region 内的代码载入编辑器,但保存时写入的是整份文件内容。从源码链路(内容解析见 snippet.ts 的 region 截取逻辑,写盘见 monacoWrite.ts)可以推断,这样组合会导致 region 之外的原有代码被覆盖,不建议使用。 - 编辑器可叠加普通 Monaco 配置:
{monaco-write}之外的 meta 原样透传,例如 Monaco Editor 文档 中的{height:'auto'}、{lines:true}等均可继续生效;编辑器选项层面的定制可参考 配置 Monaco。 - 只读模式下保存被忽略:若代码块带有
readonly标记,isWritable为 false,Ctrl+S 会打印this monaco editor is not writable, save action is ignored.警告而不做任何事。
保存动作的完整链路总结
把前后端两侧串起来,一次 Ctrl+S 保存的完整调用链为:
- 解析阶段:
<<< ./some-file.ts {monaco-write}被 markdown-it 插件识别,./some-file.ts登记进monacoWriterWhitelist,文件内容经 lz-string 压缩后生成<Monaco writable="..." code-lz="...">组件调用(snippet.ts); - 编辑阶段:
Monaco.vue懒加载 monaco,用codeLz解压初始化 model,并注册slidev-save快捷键动作(Monaco.vue); - 发送阶段:Ctrl+S 触发
import.meta.hot.send('slidev:monaco-write', { file, content }),经 Vite HMR WebSocket 到达开发服务器; - 校验落盘:
slidev:monaco-write插件校验事件类型custom、白名单命中、路径未越出项目根目录后,fs.writeFile覆盖写回文件(monacoWrite.ts)。
这条「白名单 + 路径校验 + serve-only」的设计,在让幻灯片具备真实代码编辑能力的同时,把写盘权限严格限定在幻灯片作者显式声明的文件集合内。对于「live coding」式的技术演示,这正是既安全又实用的实现方式。
相关功能
- Import Code Snippets:
<<<文件导入语法,是monaco-write的语法基础; - Monaco Editor:
{monaco}/{monaco-diff}编辑器及其高度、行号等选项; - Configure Monaco:通过项目配置定制 Monaco 编辑器行为。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00