我们经常使用各种 Node Cli 工具(也就是Node命令行),例如 Hexo,vite/vue press,asar,等等等等。今天我们研究一下,cli 是怎么写出来的呢?

Node Cli,简单来说,就是用终端命令,调用 JS,产生相应的行为/输出。所以肯定还是用Node写的。

这方面我们来看看 Microsoft 的 asar 工具,他的命令行是怎么写的。这个是它有关于 cli 的源码,我已经加上了注释:

// 这一行应该是mac/linux环境自动加上的
#!/usr/bin/env node

// 导入package.json
var packageJSON = require('../package.json')
// 这行不是很懂,貌似不大重要
var splitVersion = function (version) { return version.split('.').map(function (part) { return Number(part) }) }
// 看变量名应该是node版本
var requiredNodeVersion = splitVersion(packageJSON.engines.node.slice(2))
// 这个貌似也是 node 版本
var actualNodeVersion = splitVersion(process.versions.node)
// 这应该是判断两个node版本的语句
if (actualNodeVersion[0] < requiredNodeVersion[0] || (actualNodeVersion[0] === requiredNodeVersion[0] && actualNodeVersion[1] < requiredNodeVersion[1])) {
  console.error('CANNOT RUN WITH NODE ' + process.versions.node)
  console.error('asar requires Node ' + packageJSON.engines.node + '.')
  process.exit(1)
}

// Not consts so that this file can load in Node < 4.0
// 上面这段注释,是自带的,大概翻译为无法在版本小于4.0的node中使用那个文件,下面这两句是导入其他模块
var asar = require('../lib/asar')
// 这个commander貌似在下面都有出现,应该是cli的核心模块,语法貌似基于promise
var program = require('commander')

// 这应该是获取package中的asar版本
program.version('v' + packageJSON.version)
  .description('Manipulate asar archive files')

// 这个根据 asar的cli输出,是规定一个命令
program.command('pack <dir> <output>')
// alias应该是简写
  .alias('p')
// 命令介绍
  .description('create asar archive')
// 其它一些选项
  .option('--ordering <file path>', 'path to a text file for ordering contents')
  .option('--unpack <expression>', 'do not pack files matching glob <expression>')
  .option('--unpack-dir <expression>', 'do not pack dirs matching glob <expression> or starting with literal <expression>')
  .option('--exclude-hidden', 'exclude hidden files')
// 应该是对输入行为的反馈,function里的参数是cli输入的参数
  .action(function (dir, output, options) {
    // 下面这些应该是asar的行为,跟cli没有太大关系
    options = {
      unpack: options.unpack,
      unpackDir: options.unpackDir,
      ordering: options.ordering,
      version: options.sv,
      arch: options.sa,
      builddir: options.sb,
      dot: !options.excludeHidden
    }
    asar.createPackageWithOptions(dir, output, options, function (error) {
      if (error) {
        console.error(error.stack)
        process.exit(1)
      }
    })
  })

// 再次规定一个命令
program.command('list <archive>')
// 大概是简写
  .alias('l')
// 下面以前说过的就不说了
  .description('list files of asar archive')
  .option('-i, --is-pack', 'each file in the asar is pack or unpack')
  .action(function (archive, options) {
    options = {
      isPack: options.isPack
    }
    var files = asar.listPackage(archive, options)
    for (var i in files) {
      console.log(files[i])
    }
  })

program.command('extract-file <archive> <filename>')
  .alias('ef')
  .description('extract one file from archive')
  .action(function (archive, filename) {
    require('fs').writeFileSync(require('path').basename(filename),
      asar.extractFile(archive, filename))
  })

program.command('extract <archive> <dest>')
  .alias('e')
  .description('extract archive')
  .action(function (archive, dest) {
    asar.extractAll(archive, dest)
  })

// 如果用户输入的不是上面规定的任何一个,就运行下面这个action。
program.command('*')
// cmd参数应该是返回的用户输入
  .action(function (cmd) {
    console.log('asar: \'%s\' is not an asar command. See \'asar --help\'.', cmd)
  })

// 这个我就不知道啥意思了,不过删掉他cli就无法运行
program.parse(process.argv)

// 不带参数默认为help
if (program.args.length === 0) {
  program.help()
}

大家有了注释,但凡有点node/js功底,应该都能看个大差不差。

根据上面这个代码块,我们可以推测出来,这个commander应该是整个cli的核心。

当我们改掉名字、模块之后,我们发现它还是提示asar,所以我们知道了cli的名称来源于其引用commander的js文件名。还有就是,cli要想工作,就要有一个package.json。例如这个:

{
 "name": "yue-css",
 "version": "1.0.0",
 "description": "generate yue.css html.",
    // main不一定要指向cli的js,不过建议指向cli
 "main": "yue-css.js",
 "scripts": {
  "test": "echo \"Error: no test specified\" && exit 1",
        // 这句没啥大必要
  "g": "node script.js"
 },
 "author": "Adkinsm",
 "license": "MIT",
 "dependencies": {
  "commander": "5.0.0",
  "markdown-it": "^12.0.6"
 }
}

有了上面这个基础我们可以写出别的cli,例如这个yue.css的html生成器:

// yue-css.js,cli主文件
// 并不是非得var,const也可以的
const cli = require("commander");
// 引用主模块,注意花括号为部分引用
const { yue_css } = require("./script.js");
const packageJSON = require("./package.json");

// 尖括号里的是参数名,到时候如果用户漏输入每个参数,commander会提示参数名,所以通常用一些具有语义化的参数名
cli
 .command("generate <MarkdownFilePath> <MarkdownFileName> <OutMarkdownFileName>")
// 整个cli只有这个命令和version
 .alias("g")
 .description("build Markdown to yue.css Html.")
 .action(function (filepath,filename,outfile) {
    // 三个参数,分别对应script中的三个参数,即markdown文件目录,markdown文件名,编译后的html文件名,html会输出到markdown的目录
    // CommonJS 模块
  yue_css(filepath,filename,outfile);
 });

cli
 .version("v" + packageJSON.version)
 .description("build Markdown to yue.css Html.");

cli.parse(process.argv);

if (cli.args.length === 0) {
 cli.help();
}

由于引用了别的模块(而且是自己写的,注意./),所以我们需要进行函数导出:

// script.js,功能主文件,指定cli不同参数的行为
// 注意exports,这是node的commonjs使用的模块导出语句
// 声明导出很像声明变量
exports.yue_css = function (path,file,outfile) {
    // 三个参数,分别对应cli中的三个参数,即markdown文件目录,markdown文件名,编译后的html文件名,html会输出到markdown的目录
    // 导入node自带的fs文件模块
 let fs = require("fs");

    // 涉及Markdown,这里采用比较规范严谨的markdown-it
    // 注意这个连续声明使用逗号而非分号
 let MarkdownIt = require("markdown-it"),
  md = new MarkdownIt();//这里才是分号,标志声明结束
    
    // 采用同步读取Api,将文件内容保存为变量
 let mdData = fs.readFileSync(path + file, "utf-8");

    // 虽然用了try/catch,不过貌似无法捕获异常,可能是因为我没有抛出err
 try {
        // 这些console其实完全是为了装13~~(当然也有让用户知道程序运行成功了以及让给我知道bug出在哪一步的功能)~~
  console.log("Compiling File...");

        // markdown-it的render函数,将编译后的html存在变量中
  let html_marked = md.render(mdData);
  
        //这是规定html的内容,把html后的markdown和yue.css的内容直接加进html里。
  let data = `<style type="text/css">body{margin: 0;padding: 0.4em 1em 6em;background: #f9f9f7;}.yue {max-width: 650px;margin: 0 auto;}.yue{font:400 18px/1.62 -apple-system,BlinkMacSystemFont,"Segoe UI","Droid Sans","Helvetica Neue","PingFang SC","Hiragino Sans GB","Droid Sans Fallback","Microsoft YaHei",sans-serif;color:#444443}.yue ::-moz-selection{background-color:rgba(0,0,0,.2)}.yue ::selection{background-color:rgba(0,0,0,.2)}.yue h1,.yue h2,.yue h3,.yue h4,.yue h5,.yue h6{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Droid Sans","Helvetica Neue","PingFang SC","Hiragino Sans GB","Droid Sans Fallback","Microsoft YaHei",sans-serif;color:#222223}.yue h1{font-size:1.8em;margin:.67em 0}.yue>h1{margin-top:0;font-size:2em}.yue h2{font-size:1.5em;margin:.83em 0}.yue h3{font-size:1.17em;margin:1em 0}.yue h4,.yue h5,.yue h6{font-size:1em;margin:1.6em 0 1em 0}.yue h6{font-weight:500}.yue p{margin-top:0;margin-bottom:1.24em}.yue a{color:#111;word-wrap:break-word;-webkit-text-decoration-color:rgba(0,0,0,.4);text-decoration-color:rgba(0,0,0,.4)}.yue a:hover{color:#555;-webkit-text-decoration-color:rgba(0,0,0,.6);text-decoration-color:rgba(0,0,0,.6)}.yue h1 a,.yue h2 a,.yue h3 a{text-decoration:none}.yue b,.yue strong{font-weight:700;color:#222223}.yue em,.yue i{font-style:italic;color:#222223}.yue img{max-width:100%;height:auto;margin:.2em 0}.yue a img{border:none}.yue figure{position:relative;clear:both;outline:0;margin:10px 0 30px;padding:0;min-height:100px}.yue figure img{display:block;max-width:100%;margin:auto auto 4px;box-sizing:border-box}.yue figure figcaption{position:relative;width:100%;text-align:center;left:0;margin-top:10px;font-weight:400;font-size:14px;color:#666665}.yue figure figcaption a{text-decoration:none;color:#666665}.yue hr{display:block;width:14%;margin:40px auto 34px;border:0 none;border-top:3px solid #dededc}.yue blockquote{margin:0 0 1.64em 0;border-left:3px solid #dadada;padding-left:12px;color:#666664}.yue blockquote a{color:#666664}.yue ol,.yue ul{margin:0 0 24px 6px;padding-left:16px}.yue ul{list-style-type:square}.yue ol{list-style-type:decimal}.yue li{margin-bottom:.2em}.yue li ol,.yue li ul{margin-top:0;margin-bottom:0;margin-left:14px}.yue li ul{list-style-type:disc}.yue li ul ul{list-style-type:circle}.yue li p{margin:.4em 0 .6em}.yue .unstyled{list-style-type:none;margin:0;padding:0}.yue code,.yue tt{color:grey;font-size:.96em;background-color:#f9f9f7;padding:1px 2px;border:1px solid #eee;border-radius:3px;font-family:Menlo,Monaco,Consolas,"Courier New",monospace;word-wrap:break-word}.yue pre{margin:1.64em 0;padding:7px;border:none;border-left:3px solid #dadada;padding-left:10px;overflow:auto;line-height:1.5;font-size:.96em;font-family:Menlo,Monaco,Consolas,"Courier New",monospace;color:#4c4c4c;background-color:#f9f9f7}.yue pre code,.yue pre tt{color:#4c4c4c;border:none;background:0 0;padding:0}.yue table{width:100%;max-width:100%;border-collapse:collapse;border-spacing:0;margin-bottom:1.5em;font-size:.96em;box-sizing:border-box}.yue td,.yue th{text-align:left;padding:4px 8px 4px 10px;border:1px solid #dadada}.yue td{vertical-align:top}.yue tr:nth-child(even){background-color:#efefee}.yue iframe{display:block;max-width:100%;margin-bottom:30px}.yue figure iframe{margin:auto}.yue table pre{margin:0;padding:0;border:none;background:0 0}@media (min-width:1100px){.yue blockquote{margin-left:-24px;padding-left:20px;border-width:4px}.yue blockquote blockquote{margin-left:0}}</style><link rel="stylesheet" href="./yue.css"><body><div class="yue">${html_marked}</div></body>`;

  console.log("Done.");

  console.log("Writing File...");
        
        // 同步写出文件api

  fs.writeFileSync(path + "/" + outfile, data, "utf-8");
        
        // 提示用户文件存在了哪里

  console.log("Writed File At " + path + outfile);

  console.log("Done.");
 } catch (e) {
        // catch没啥用
  console.log("[INFO] 出现 Error :");
  console.log("[ERROR] " + e);
  return;
 }
};

如想体验实际效果,运行:

npm i yue-gen -g

使用方法:

如果你有一个Markdown文件,它的路径是 “D:\yue\readme.md”,你想把它编译成HTML,运行这个命令:

yue-css g D:\yue\ readme.md index.html

然后,yue.css 会将readme.md中的内容转译为HTML,再写入index.html。这个index.html会创建在 D:\yue\。

在转译的同时,yue.css中的样式文件会一并转移到index.html中的一个style标签,所以你只需安心写Markdown,关于HTML的一切交给yue-css。