Slidev {monaco-write} 可写 Monaco 编辑器:幻灯片内编辑代码并保存回文件的完整链路
【免费下载链接】slidevPresentation Slides for Developers项目地址: https://gitcode.com/GitHub_Trending/sl/slidev
本篇讲解 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 编辑器行为。
【免费下载链接】slidevPresentation Slides for Developers项目地址: https://gitcode.com/GitHub_Trending/sl/slidev
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考