从“手写 Markdown 工具”这个念头到真正跑通一条完整链路我折腾了不少时间。最初的需求很简单团队知识库里的 Markdown 文档要导出一份可以分发给外部的 HTML但里面嵌了好几个本地视频和一堆高清截图直接转换出来的 HTML 又大又卡。后来我干脆用 Node.js 的一票核心模块——path、OS、process、child_process、FS、crypto、zlib——搭了一套能扫描目录、解析文档、调用 ffmpeg 处理媒体、最终生成静态 HTML 的小工具。整个过程走下来最大的感受是原生模块足够搞定 80% 的场景配合 ffmpeg 这类外部命令能做的事情远超预期。这篇内容适合已经写过一点 Node.js、但想把文件处理、子进程调用和构建流程吃透的开发者。我会把整体设计、核心模块拆解、Markdown 转 HTML 的完整实现、环境配置和常见问题全部过一遍保证你能照着复现也能理解每一步为什么这么写。1. 整体设计与核心思路1.1 为什么用 Node.js 原生模块搭这套工具链看到标题里那一串模块名很多人第一反应是“有必要这么复杂吗直接装个 webpack 或者 vite 不就行了”但现实场景往往没到需要上重型构建工具的地步。我这次要处理的是一批固定目录下的 Markdown 文档要求输出到另一个目录供内网访问同时要把里面的视频转成 H.264 MP4、图片压缩成 WebP再生成 gzip 压缩包方便传输。用 Node.js 原生模块加一个 Markdown 渲染器就能闭环不需要引入几十个依赖。原生模块的好处是稳定、可控、跨平台。FS 负责文件和目录操作path 规范化路径process 读命令行参数OS 拿系统 CPU 核数child_process 调用 ffmpegcrypto 生成内容哈希用于增量构建zlib 压缩输出。这些模块都是 Node.js 自带不用锁版本也不会有依赖冲突。配合一个宽松的解析流程整个工具的核心代码不到 300 行后续给非技术同事用也不需要他们装额外环境。1.2 工具链组成解析、资源处理、构建输出把这套工具的流水线拆开大概是这样的输入阶段用 FS 递归扫描源目录过滤出.md、.markdown文件以及文档里引用的图片、视频文件。解析阶段读取 Markdown 文本交给 markdown-it 渲染成 HTML同时解析出里面的本地媒体路径。资源处理阶段对图片执行压缩/格式转换对视频调用 ffmpeg 转码/裁剪封面处理结果输出到构建目录。输出阶段把 HTML 写入目标目录替换媒体路径用 zlib 生成.gz版本顺便记录一份内容哈希映射表。这个设计最大的优势是把“解析”和“资源处理”解耦。解析只关心文本结构资源处理只关心文件二进制两边通过路径和元数据对接。比如 Markdown 里写渲染器会把demo.png作为一个相对路径暴露出来资源处理模块再去决定是压缩成.webp还是原样复制。这样即使某天你换了 Markdown 渲染器或者改成用 Pandoc 转换资源处理部分完全不用动。2. 核心模块逐个拆解2.1 FS 与 Path文件读写、目录遍历和跨平台路径在 Node.js 里FS 和 path 基本是黄金搭档。FS 提供readFile、writeFile、mkdir、readdir、stat这些底层能力path 则负责处理 Windows 的反斜杠和 Linux/macOS 的正斜杠差异避免你手写字符串拼接。我经常遇到新手直接写dir / fileName在 Windows 上跑没问题但一到 Linux 上部署就可能因为分隔符不统一导致路径找不到。更稳妥的写法是path.join(dir, fileName)。反过来如果你想从一个文件反推相对路径用path.relative(from, to)它能自动处理.和..。下面是一个递归扫描目录的示例我更喜欢用readdir加withFileTypes: true这样不用额外调用stat去判断是不是目录const fs require(fs); const path require(path); function walkDir(dir, fileList []) { const entries fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath path.join(dir, entry.name); if (entry.isDirectory()) { walkDir(fullPath, fileList); } else if (entry.isFile()) { fileList.push(fullPath); } } return fileList; }这里用了同步 API因为 CLI 工具在启动阶段需要先拿到文件清单同步写起来更直观。如果文档量大你可以在后续改成fs.promises.readdir配合Promise.all并行扫描。另一个容易踩坑的是 Windows 路径的 drive letter。比如C:\docs\a.md在path.relative之后可能会得到..\docs\a.md但放到 HTML 的src属性里需要用正斜杠/。这时可以做一个toWebPath转换function toWebPath(p) { return p.split(path.sep).join(/); }否则生成的 HTML 在 Windows 上本地打开没问题部署到 Linux 服务器上图片全都裂了。这个坑我至少踩过两次简历上甚至值得单独写一条。2.2 Process 与 OS命令行参数、环境变量和系统资源感知process是 Node.js 的全局对象不需要 require。在 CLI 工具里最常用的三个东西是process.argv、process.env、process.exitCode。process.argv的前两个固定是 node 路径和脚本路径真正的参数从下标 2 开始。简单场景可以直接用process.argv.slice(2)但参数如果多建议手动解析成键值对。我不会一上来就推荐commander或者yargs因为小工具里自己解析更干净。来看一个轻量解析方案function parseArgs(argv) { const args {}; for (let i 0; i argv.length; i) { if (argv[i].startsWith(--)) { const key argv[i].slice(2); const next argv[i 1]; if (next !next.startsWith(--)) { args[key] next; i; } else { args[key] true; } } } return args; }OS模块用来获取系统资源。最实用的场景是根据 CPU 核数决定并行调用几个 ffmpeg 进程。os.cpus().length返回逻辑核数我一般取Math.max(1, cpus - 1)留一个核给主线程避免资源耗尽导致系统卡顿。os.platform()也可以用来写平台相关的逻辑比如 Windows 上 ffmpeg 可执行文件要加.exe后缀macOS/Linux 则不需要。用os.tmpdir()可以生成临时目录转码中间文件放到系统临时目录里比放在项目目录下更干净也不会污染 git 提交记录。2.3 Child_process调用 ffmpeg 的正确姿势child_process是链接 Node.js 和外部命令的桥梁。调用 ffmpeg 有两种常见方式execFile和spawn。很多人习惯用exec因为它可以带上 shell 语法但exec把整个命令字符串交给 shell 执行如果文件名里包含空格或特殊字符很容易出问题甚至会有命令注入风险。我更推荐execFile。它把可执行文件路径和参数数组分开Node.js 会直接创建子进程不经过 shell 解析既安全又稳定。下面是一个调用 ffmpeg 转码视频的例子const { execFile } require(child_process); function convertVideo(input, output) { return new Promise((resolve, reject) { const args [ -i, input, -c:v, libx264, -preset, fast, -crf, 23, -c:a, aac, -b:a, 128k, -movflags, faststart, -y, output, ]; execFile(ffmpeg, args, { timeout: 60000 }, (error, stdout, stderr) { if (error) { reject(error); return; } resolve(output); }); }); }这里重点解释几个参数-movflags faststart是让 MP4 的元数据放到文件头部浏览器才能边下载边播放。-crf 23是 H.264 编码的质量因子数值越小画质越高文件越大。23 是通用平衡点对大多数视频足够。-preset fast是编码速度和压缩率的折中如果你有充足时间可以改成slow文件体积会进一步下降。你有没有想过stdout和stderr里到底有什么ffmpeg 默认把进度信息写到 stderr如果调用失败错误原因也在 stderr 里。所以诊断问题时要看error对象里stderr字段而不是stdout。我在调试时就会把 stderr 打出来能快速定位是不是编码器不支持、输入路径不存在、或者输出目录没权限。2.4 Crypto 与 Zlib内容哈希、增量缓存和压缩输出crypto最常用的功能不是加解密而是计算哈希。在构建工具里我用哈希来判断文件内容有没有变化从而实现增量构建只有 Markdown 内容或媒体文件变了才重新转换和转码否则直接复用上次的输出。来看一个计算文件哈希的函数const crypto require(crypto); const fs require(fs); function fileHash(filePath) { const content fs.readFileSync(filePath); return crypto.createHash(sha256).update(content).digest(hex).slice(0, 12); }有人会问为什么用 SHA-256 而不是 MD5虽然 MD5 更快但碰撞概率更高而且很多安全扫描工具会对 MD5 报 warning。构建工具里的哈希只是为了判断内容变化不涉及安全认证所以用截断到 12 位的 SHA-256 完全够用。缓存文件命名成index-3f7a2b1c4d2e.html当源文件内容变化时哈希值变化旧文件自然不会被引用。zlib模块用来生成 gzip 文件。静态服务器一般会自动开启 gzip但如果你的目标环境没有开启手动生成一份.gz文件也能达到一样的效果。Node.js 的zlib.gzipSync用起来很简单const zlib require(zlib); function writeGzip(filePath, content) { const gzip zlib.gzipSync(Buffer.from(content), { level: 9 }); fs.writeFileSync(filePath .gz, gzip); }level: 9表示压缩率最高但耗时也最长。HTML 文本一般几十 KB耗时几乎可以忽略所以可以放心用最高压缩率。如果是大文件建议改成level: 6平衡速度。3. Markdown 转 HTML 的完整实现3.1 渲染器选型为什么我选 markdown-it而不自己手写虽然标题里没有提到任何 Markdown 库但真正做转换时我不会劝你手写一个 Markdown 解析器。Markdown 规范里的嵌套列表、代码块、表格、引用块看似简单实际上有一堆边界情况。手写解析器可能支持 90% 的语法但剩下的 10% 会在真实文档里以诡异的方式出现。我用的是markdown-it它成熟、插件丰富、解析速度快而且支持开箱即用的 HTML 标签。安装命令npm install markdown-it一个最基础的渲染循环const MarkdownIt require(markdown-it); const md new MarkdownIt({ html: true, linkify: true, typographer: true, }); function renderMarkdown(mdPath) { const src fs.readFileSync(mdPath, utf8); return md.render(src); }html: true允许 Markdown 里直接包含原生 HTML像嵌入 iframe 或者视频标签。linkify: true自动把裸链接变成可点击链接。typographer会把直引号转换成弯引号但这个功能对中文内容有时候会误伤代码块我一般保守一点设置为false。3.2 解析媒体引用从 Markdown 文本里提取本地资源Markdown 渲染成 HTML 之后图片和视频的路径仍然保留在src属性里。要做资源处理第一步是从渲染后的 HTML 里提取路径。可以用正则表达式粗暴匹配但更可靠的是用markdown-it的插件机制在 token 解析阶段就把图片地址收集起来。下面是用markdown-it插件收集图片路径的写法const md new MarkdownIt(); function collectAssets(mdSource) { const assets []; const plugin (md) { const defaultImageRule md.renderer.rules.image || ((tokens, idx, options, env, self) self.renderToken(tokens, idx, options)); md.renderer.rules.image (tokens, idx, options, env, self) { const src tokens[idx].attrGet(src); if (src !/^(https?:)?\/\//.test(src)) { assets.push(src); } return defaultImageRule(tokens, idx, options, env, self); }; }; md.use(plugin); md.render(mdSource); return assets; }这里过滤掉了http://、https://和//开头的绝对链接。剩下的本地相对路径交给后续资源处理模块。视频路径通常出现在原生 HTML 的videosource src...里一样可以用 token 规则收集或者直接用正则提取source[^]src([^])。具体选哪个取决于你 Markdown 的书写习惯。3.3 集成 ffmpeg 处理图片和视频压缩、转码、提取封面资源处理是整个工具里最“重”的部分。ffmpeg 不只是视频转码工具它也能处理图片。比如把 PNG/JPG 统一压缩成 WebP并对图片尺寸做限制ffmpeg -i input.png -vf scalemin(1200,iw):-2 -quality 80 output.webp这条命令的关键是scale滤镜。min(1200,iw)意思是如果原图宽度小于 1200 就保持原宽大于 1200 就缩到 1200。-2表示高度按比例自动计算并且强制为偶数因为某些编码格式不支持奇数高度。-quality 80是 WebP 的压缩质量80 是个肉眼几乎感知不到损失、体积又明显缩小的档位。视频转码我在前面已经写过基础命令。这里补一个提取视频封面的用法ffmpeg -i demo.mp4 -ss 00:00:03 -vframes 1 -vf scale1280:-2 cover.jpg-ss 00:00:03表示定位到 3 秒处-vframes 1表示只输出一帧。注意-ss放在-i后面是“精确解码到 3 秒”速度慢但更准放在-i前面是“快速定位”速度极快但成功输出关键帧的位置可能不是精确 3 秒。对于提取封面这种场景我建议把-ss放前面速度快很多封面差个零点几秒没人看得出来。如果你要在 Node.js 里动态拼接这些参数记住一个原则所有来自文件系统的路径必须作为独立参数传入不要拼进命令行字符串。比如execFile(ffmpeg, [-i, inputPath, -vf, scale${width}:-2, -quality, 80, outputPath]);inputPath和outputPath作为数组元素传给 execFile即使在空格和中文路径下也安全。3.4 构建 HTML 模板和目录结构回到 Markdown 转 HTML 本身。Markdown 渲染出来的只是文章正文还需要包一层完整页面包括head、CSS、目录导航。我习惯准备一个简单的模板字符串function buildHtml(title, contentHtml, metadata) { return !DOCTYPE html html langzh-CN head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1 title${title}/title link relstylesheet hrefassets/style.css /head body main classcontent h1${title}/h1 ${contentHtml} /main /body /html; }模板里的assets/style.css指向构建目录里的样式文件。这里的路径要特别小心如果 HTML 输出在dist/post/a.html而样式在dist/assets/style.css那么hrefassets/style.css就不对了应该用../assets/style.css。我发现最不容易出错的方案是先确定输出 HTML 的相对目录再用path.relative计算资源路径const rel path.relative(path.dirname(htmlOutput), assetFile); const webPath toWebPath(rel);这样不管是单层目录还是嵌套多层目录资源路径永远不会断。目录结构我建议长这样docs/ ├── markdown/ │ ├── 001-intro.md │ └── images/ │ ├── demo.png │ └── video.mp4 └── dist/ ├── 001-intro.html └── assets/ ├── demo.webp ├── video-processed.mp4 └── style.css输出目录里的assets集中存放所有经过处理的媒体文件和样式HTML 文件通过相对路径引用整包拷到任何服务器上都能独立运行。4. 实操过程从零跑通这个工具4.1 环境准备Node.js 安装与 ffmpeg 配置先说 Node.js。去官网下载 LTS 版本Windows 下直接安装.msi安装时注意勾选“Add to PATH”。macOS 用户可以用 Homebrew 装brew install node。Linux 用包管理器Ubuntu 上sudo apt install nodejs npm不过我更推荐用nvm装方便切换版本。安装完验证node -v npm -v如果提示node 不是内部或外部命令十有八九是 PATH 没配好。Windows 下打开“编辑系统环境变量”在Path里加一行 Node.js 的安装目录比如C:\Program Files\nodejs\。改完后一定要重新打开终端否则环境变量不生效。ffmpeg 的安装稍微隐蔽一点。Windows 用户大多数人下载的是 “Windows builds” 版本解压后其实是一个文件夹里面有个bin/ffmpeg.exe。你需要做的不是把文件夹整个放进去而是把bin目录加到 PATH。比如解压到D:\ffmpeg那就把D:\ffmpeg\bin加进系统变量。否则在终端里运行ffmpeg -version就会看到经典报错ffmpeg 不是内部或外部命令。还有一个高频问题Windows 的 PowerShell 下执行npm或node脚本时报错npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本这不是 Node 环境有问题而是 PowerShell 的脚本执行策略默认是Restricted。解决方法有两个在管理员 PowerShell 里执行Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser允许本机脚本运行。不用 PowerShell改用cmd或 Windows Terminal 里的 Command Prompt 跑 npm 命令。个人建议方式一顺手还能让你后面写 npm 测试脚本时不再遇到莫名的执行策略问题。4.2 初始化项目和目录环境准备好后初始化一个 Node 项目mkdir md2html-tool cd md2html-tool npm init -y npm install markdown-it然后建一个build.js入口文件。我习惯把所有模块按功能拆成单独的.js文件比如scanner.js、renderer.js、media.js、cache.js但下面演示为了阅读方便我先合在一起讲。创建源文档目录和输出目录mkdir docs mkdir docs/markdown mkdir dist在docs/markdown/test.md写一个简单测试文档# 测试文档 你好这是一个 **Markdown 转换测试**。  video controls source src./images/sample.mp4 typevideo/mp4 /video放一张图片和一个视频进去。如果手头没有视频可以先随便录一段十来秒的屏幕录制或者用 ffmpeg 生成测试视频ffmpeg -f lavfi -i testsrcduration5:size640x480:rate30 test.mp4这条命令会生成一个 5 秒的彩色测试视频用来验证转码流程足够了。4.3 编写构建脚本从读取到输出下面是一个能跑的build.js核心逻辑包含扫描、渲染、媒体处理和写入。我会拆成几个小块讲解。第一段扫描所有 Markdown 文件并建立输出映射const fs require(fs); const path require(path); const MarkdownIt require(markdown-it); const { execFileSync } require(child_process); const crypto require(crypto); const zlib require(zlib); const SOURCE_DIR path.join(__dirname, docs/markdown); const DIST_DIR path.join(__dirname, dist); const CACHE_FILE path.join(__dirname, cache.json); const md new MarkdownIt({ html: true, linkify: true }); if (!fs.existsSync(DIST_DIR)) { fs.mkdirSync(DIST_DIR, { recursive: true }); } const mdFiles []; walkDir(SOURCE_DIR, mdFiles).forEach((file) { if (/\.(md|markdown)$/i.test(file)) { processMarkdownFile(file); } });这里的walkDir在前面已经写过直接复用。processMarkdownFile是整个流程的核心function processMarkdownFile(mdFilePath) { const src fs.readFileSync(mdFilePath, utf8); const contentHtml md.render(src); const title path.basename(mdFilePath, path.extname(mdFilePath)); const htmlOutput path.join(DIST_DIR, title .html); const html buildHtml(title, contentHtml); fs.writeFileSync(htmlOutput, html); writeGzip(htmlOutput, html); console.log([build] ${path.relative(__dirname, htmlOutput)}); }这么说起来很简单但还没处理媒体。补上进阶版本遍历contentHtml里的图片和视频路径先按原路径转成绝对路径复制或转码到dist/assets下再替换 HTML 里的src属性。由于用正则替换会显得比较乱我先给每个媒体资源生成新的文件名。命名的规则是原文件名 内容哈希前缀。这样同一个文件重复转换时可以直接复用输出不需要重新跑 ffmpeg。function ensureMedia(srcPath, destDir) { const hash crypto.createHash(sha1).update(fs.readFileSync(srcPath)).digest(hex).slice(0, 8); const ext path.extname(srcPath).toLowerCase(); const destName ${path.basename(srcPath, ext)}-${hash}${ext}; const destPath path.join(destDir, destName); if (!fs.existsSync(destPath)) { processMedia(srcPath, destPath); } return destPath; }ext这里我要强调一下如果原图是.png你希望转成.webp那ext不应该取原始扩展名而是要按目标格式拼。后面我会单独说怎么处理格式转换。4.4 验证运行结果运行node build.js正常的话终端会打印构建文件列表dist目录下出现测试 HTML、压缩后的图片、转码后的 MP4还有每个 HTML 的.gz版本。再用浏览器直接打开 HTML能看到图片正常显示、视频能播放说明路径替换成功。这个阶段最容易出问题的点是路径替换。如果你打开 HTML 后图片裂了优先按F12打开开发者工具看图片请求的 URL 是不是 404然后对照dist目录里的实际文件名。如果文件名一模一样那就是当前位置相对于图片的路径算错了。建议在ensureMedia里返回一个relative路径而不是绝对路径这样后续模板替换更直观。5. 常见问题与排查技巧实录5.1 PATH 配置类问题下面这几个问题基本是环境变量引起的我把高频情况列成表格报错信息原因解决方式node 不是内部或外部命令Node 安装目录未加入 PATH把 Node 安装目录加到系统 PATH重新开终端ffmpeg 不是内部或外部命令ffmpeg 的 bin 目录未加入 PATH将 ffmpeg 解压目录下的bin路径加入 PATHffmpeg: command not foundLinux/macOS 下未安装 ffmpegbrew install ffmpeg或apt install ffmpegnpm 无法加载文件 ... npm.ps1PowerShell 执行策略限制Set-ExecutionPolicy RemoteSigned -Scope CurrentUser改完 PATH 以后务必重启终端因为环境变量读取是在终端启动时完成的。旧的终端窗口不会感知新的 PATH。如果你改完没有任何反应还报同样错误先敲echo $env:PathPowerShell或echo %Path%CMD确认一下有没有生效。5.2 Windows 下 npm 脚本执行策略问题除了 npm.ps1 的报错还有一个比较隐蔽的问题很多教程让你在 package.json 里写build: node build.js然后npm run build如果之前装了 Yarn 或者 nvm可能会因为 PATH 里的 Node 路径顺序不对导致npm调用了错误版本的 node。这个问题的排查方法是先跑where node和where npm看它们指向哪里。如果npm指向一个旧的 npm 脚本而node是新的那就要手动调整 PATH 顺序把C:\Program Files\nodejs\排在前面。5.3 文件路径过长 warningGit 在 Windows 上经常报warning: path too long其实 Git 默认限制了路径长度Windows 本身也有限制。解决办法是以管理员身份打开 Git Bash运行git config --system core.longpaths true如果你遇到的不是 Git而是 Node.js 读写文件时报ENAMETOOLONG那通常是因为输出目录嵌套太深或者文件名太长。建议在ensureMedia里缩短哈希长度或直接扁平化输出目录不要保留多层嵌套。HTML 的内链用相对路径就能有效避免把整个绝对路径暴露给浏览器。5.4 child_process 调用 ffmpeg 失败排查调用 ffmpeg 失败时Node.js 抛出的错误对象里往往只有一段提示不够明确。我的排查套路是先把execFile的参数数组原样打印出来然后手动把它们拼成一行命令在终端里跑一遍。如果终端一跑就成功说明问题出在 Node.js 的调用方式上如果终端也失败那就是 ffmpeg 命令或者输入文件本身有问题。典型错误包括No such file or directory输入路径不对多半是相对路径计算错。Unknown encoder libx264你的 ffmpeg 编译版本没带 H.264 编码器。这是 Windows 某个精简版 ffmpeg 的老问题换成官网的 full build 或者从 gyan.dev 下载版本能解决。Permission denied输出目录没有写权限检查dist目录属性。Invalid data found when processing input文件本身不是完整的视频可能是录制过程中中断导致。5.5 增量缓存和哈希冲突问题哈希缓存也不是万无一失。如果你在ensureMedia里用了sha1并截断到 8 位理论上碰撞概率很低但磁盘文件一旦被截断名覆盖哈希对应关系就丢了。我一般会在首次生成时把哈希写到cache.json下次先读缓存如果缓存里已经存在相同哈希的文件就直接引用不再读取原文件。这样可以省一次fs.readFileSync的开销在文件数量大时优势明显。但也有一种情况会造成缓存失效你把原文件重命名了但内容没变。这时哈希变了旧文件会被重新转码一次。这是增量构建的正常行为不用太担心。真正需要留意的是如果你把图片格式从 PNG 转成 WebP缓存 key 应该包含目标格式否则同一个源文件第二次转成 JPEG 时会错误复用 WebP 的输出。最简单的做法是 key 用源哈希 目标扩展名拼接。const cacheKey ${hash}.${targetExt};5.6 调试 ffmpeg 进度和性能的小技巧在转码长视频时execFile回调里拿不到逐行进度因为 ffmpeg 的进度是写到 stderr 的而且带有\r回车符。如果你用的是spawn可以监听 stderr 数据写一个简单的进度条。但这个属于锦上添花我实际使用中更关注的是不要在主进程里同步调用 ffmpeg否则视频转码 10 分钟你的工具就干等 10 分钟体验极差。改成spawn或者用execFile的异步回调用Promise.all并行处理多个视频。另一个性能优化是控制并行度。我有一次在一台 4 核机器上同时跑 6 个 ffmpeg 转码任务结果不仅每个任务慢了一圈还导致系统整体卡顿。后来统一用os.cpus().length - 1作为并发数用简单的计数器把任务分配到固定并行度效果立马改善。这个细节在输出日志中变化很直观也值得记下来。写在最后的一点个人体会把 Node.js 的 path、FS、child_process 这些模块串起来做工具比较考验你对“进程边界”和“路径语义”的理解。Markdown 转 HTML 只是入口真正的复杂度全在资源处理和构建缓存上。踩过几次格式转换、路径拼接和 PATH 配置的坑之后我养成了一个习惯每写一个调用外部命令的函数先把参数打印出来人工验一遍再丢给 execFile。这是目前对我帮助最大的调试手段。如果你也想自己搭一套类似的工具建议从一个小目录开始不要一开始就想着处理上百篇文章。先把一个文件、一张图、一个视频完整跑通再把循环和缓存加上去。工具类项目的复杂度往往是循环带来的单个文件的正确性是地基。等这套东西稳定了再考虑往里面加 TOC 生成、样式主题、语法高亮甚至接一个静态站点生成器都有很自然的扩展路径。
