前言

Node.js 除了可以编写服务器端程序, 也可以用来编写一些命令行工具, 本文会如何使用 commander 模块来编写一个命令行工具, 并以一个静态博客系统构建工具作为实例。具体内容包含以下几部分:

  • commander 模块
  • markdown-it 模块
  • 将 Markdown 转换成 HTML
  • 实时监控文件变化
  • 给 Markdown 内容套用模板
  • 实时预览
  • 生成整站静态页面

所使用到的第三方模块:

命令格式

在编写命令行工具时,我们首先要定义命令的使用方法,比如:

$ XBlog new # 表示创建一个空的博客;
$ XBlog build # 表示生成整站静态HTML页面等。

常见的命令格式

一条命令一般包含以下几部分:

command [options] [arguments]

- command # 命令名称,比如node。
- options # 单词或单字,比如 --help或-h。
- arguments # 参数,有时选项也带参数,比如:xss。

在查看命令帮助时,会出现 "[]" "<>" "|" 等符号:

- [] # 表示是可选的。
- <> # 表示可变选项,一般是多选一,而且必须要选其一。
- x|y|z # 多选一,如果加上"[]",则可不选。
- -abc # 多选,如果加上"[]", 则可不选。

比如,NPM 命令的使用方法描述如下:

$ npm 

$ npm install
$ npm list

以上是大多数命令行工具遵循的语法格式。

定义静态博客命令格式

我们要实现的命令行生成工具包含以下功能:

  • 创建一个空的博客;
  • 文章使用 Markdown 格式编写;
  • 本地实时预览;
  • 生成整站静态 HTML;

根据描述,我们先来定义这个命令行工具的使用方法:

XBlog create [dir] # 创建一个空的博客,dir为博客所在目录(可选,默认为当前目录)

XBlog preview [dir] # 实时预览,dir为博客所在目录(可选,默认为当前目录)

XBlog build [dir] [--output target] # 生成整站静态HTML, dir为博客所在目录(可选,默认为当前目录),target为生成的静态HTML存放目录

编写命令行工具

在 Node 中,可以通过 process.argv 变量来取得启动时的参数,它是一个数组。比如:

$ node test.js build xxx
# process.argv 的值为:
['node', 'test.js', 'build', 'xxx']

第一个为命令名,第二个为程序文件名,从第三个参数起则是启动 Node 程序所传的参数,每个参数用空格隔开。由于这些参数都是字符串,为了支持更灵活的参数组合方法,需要编写一个专门的程序先来解析这些参数字符串,而 commander 模块已经为我们做好了。

首先新建空的项目文件夹,然后npm init -y来初始化。

$ mkdir XBlog && cd XBlog
$ npm init -y

# 安装commander模块
$ npm i commander -D

然后新建文件 bin/XBlog 并编辑

#!/usr/bin/env node

var program = require('commander');

// 命令版本号
program.version('0.0.1');

// help命令
program
  .command('help')
  .description('显示使用帮助')
  .action(function() {
    program.outputHelp();
  });

// create命令
program
  .command('create [dir]')
  .description('创建一个空的博客')
  .action(function(dir) {
    console.log('create %s', dir);
  });

// preview命令
program
  .command('preview [dir]')
  .description('实时预览')
  .action(function(dir) {
    console.log('preview %s', dir);
  });

// build命令
program
  .command('build [dir]')
  .description('生成整站静态HTML')
  .option('-o, --output <dir>', '生成的静态HTML存放目录')
  .action(function(dir, options) {
    console.log('create %s, output %s', dir, options.output);
  });

// 开始解析命令
program.parse(process.argv);
  • command('help') 表示当前是什么命令;
  • description('显示使用帮助') 当前命令的简单描述,在查看命令帮助时会显示出来;
  • action(callback) 为解析到当前命令时执行的回调函数。
  • option('-o, --output <dir>', '生成的静态HTML存放目录') 表示在执行 build 命令时,还可以附加一些可选项,比如 -o <dir>用来指定生成的文件输出到哪里。

第一行 #!/usr/bin/env node 指定当前文件使用哪个解释器执行。在 Linux Shell 环境下,文件具有执行权限时,可以直接通过 ./xxx 来执行(一般要执行 Node 时是执行命令 node xxx.js), 如不指定解释器,则默认使用 bash 执行。

现在编辑文件package.json, 增加 bin 属性:

{
  "name": "XBlog",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "liusixin",
  "license": "MIT",
  "bin": {
    "XBlog": "./bin/XBlog"
  },
  "devDependencies": {
    "commander": "^2.10.0"
  }
}

bin 属性用来指定当前模块需要链接的命令,在这里我们指定了 XBlog 命令是执行文件 ./bin/XBlog

为了让这个设置生效,还需要执行以下命令来进行链接:

$ sudo npm link

执行成功后,会显示

现在我们试下执行: XBlog help

说明我们这个命令行工具的基本框架已经完成了;

实时预览

虽然实现了基本框架,但在功能上还是空白的。一个静态博客工具包含以下这些功能模块:

  • 渲染文章内容页面和文章列表页面;
  • 修改模板实时预览;
  • 创建基本的博客模板;

我们先从实现 preview 命令入手。

启动 Web 服务器

为了使代码结构更加清晰,首先我们将 bin/XBlog 文件中 preview 命令的回调函数改为require('../lib/cmd_preview');

// preview命令
program
  .command('preview [dir]')
  .description('实时预览')
  .action(require('../lib/cmd_preview'));

新建文件 lib/cmd_preview.js

var express = require('express');
var serveStatic = require('serve-static');
var path = require('path');

module.exports = function(dir) {
  dir = dir || '.'; // 指定当前博客项目所在的目录,如果没有指定则默认为当前目录

  // 初始化express
  var app = express();
  var router = express.Router();
  app.use('/assets', serveStatic(path.resolve(dir, 'assets'))); // 静态资源文件
  app.use(router);

  // 渲染文章
  router.get('/posts/*', function(req, res, next) {
    // 文章内容页面
    res.end(req.params[0]);
  });

  // 渲染列表
  router.get('/', function(req, res, next) {
    // 文章列表页面
    res.end('文章列表');
  });

  app.listen(3000);
};

使用 express 来启动一个 Web 服务器,主要处理以下三部分内容:

  • "/assets" 开头的 URL 为博客中用到的静态资源文件, 对应的是博客根目录下的 assets 目录;
  • "/posts" 开头的 URL 为文章内容页面,比如访问的 URL 是 /posts/2017-12/hello-world.html, 对应的是源文件_posts/2017-12/helloworld.md;
  • "/" 为文章列表页面。

由于使用到了 expressserve-static 两个模块,所以先安装它们:

$ npm i express serve-static -S

然后启动

$ XBlog preview

然后 浏览器输入 http://localhost:3000 查看效果

渲染文章页面

文章内容使用 Markdown 语法来编写,我们可以使用markdown-it模块来解析并将其转为相应的 HTML。模板引擎我们使用 swig。

文章源文件存储在_post 目录下,比如文件_posts/2017-12/hello-world.md 对应的 URL 是 /posts/2017-12/hello-world.html

文件lib/cmd_preview.js:

var express = require('express');
var serveStatic = require('serve-static');
var path = require('path');
var fs = require('fs');
var md = require('markdown-it')({
  html: true,
  langPrefix: 'code-'
});

module.exports = function(dir) {
  dir = dir || '.'; // 指定当前博客项目所在的目录,如果没有指定则默认为当前目录

  // 初始化express
  var app = express();
  var router = express.Router();
  app.use('/assets', serveStatic(path.resolve(dir, 'assets'))); // 静态资源文件
  app.use(router);

  // 渲染文章
  router.get('/posts/*', function(req, res, next) {
    // 文章内容页面
    var name = stripExtname(req.params[0]);
    var file = path.resolve(dir, '_posts', name + '.md');
    fs.readFile(file, function(err, content) {
      if (err) return next(err);
      var html = markdownToHTML(content.toString());
      res.end(html);
    });
  });

  // 渲染列表
  router.get('/', function(req, res, next) {
    // 文章列表页面
    res.end('文章列表');
  });

  app.listen(3000);
};

// 去掉文件名中的扩展名
function stripExtname(name) {
  var i = 0 - path.extname(name).length;
  if (i === 0) i = name.length;
  return name.slice(0, i);
}

// 将Markdown转换为HTML
function markdownToHTML(content) {
  return md.render(content || '');
}

我们随便写篇文章,保存到 example/posts/2017-12/hello-world.md

# hello world

安装所需模块 markdown-it 并启动程序(注意 preview 命令后面有指定博客项目所在的目录,为example)

$ npm i markdown-it -S
$ XBlog preview example

然后在浏览器中打开 http://127.0.0.1:3000/posts/2017-12/hello-world.html

文章元数据

一篇文章除内容外,一般还会带上一些元数据,比如文章标题、发表时间、标签等:

---
title: hello-world
date: 2017-12-01
---

文件顶部在 “—” 之间的部分是文章的元数据

修改文件lib/cmd_preview.js, 增加函数解析文章元数据:

// 解析文章内容
function parseSourceContent(data) {
  var split = '---\n';
  var i = data.indexOf(split);
  var info = {};
  if (i !== -1) {
    var j = data.indexOf(split, i + split.length);
    if (j !== -1) {
      var str = data.slice(i + split.length, j).trim();
      data = data.slice(j + split.length);
      str.split('\n').forEach(function(line) {
        var i = line.indexOf(':');
        if (i !== -1) {
          var key = line.slice(0, i).trim();
          var value = line.slice(i + 1).trim();
          info[key] = value;
        }
      });
    }
  }
  info.source = data;
  return info;
}

再修改渲染文章路由处理部分

fs.readFile(file, function(err, content) {
  if (err) return next(err);
  // var html = markdownToHTML(content.toString());
  var post = parseSourceContent(content.toString());
  console.log(post);
  var html = markdownToHTML(post.source);
  res.end(html);
});

重新启动程序并刷新页面,可看到文章的标题不见了,而在控制台中可看到这样的输出:

其中 title 和 date 为我们在文章源文件中设置的元数据,而 source 则为文章的内容。有了这些元数据,我们接下来就可以做更多的事情了。

增加模板

上面的示例中,没有好看样式,显得略丑, 接下来我们给它接上一个模板。

修改文件lib/cmd_preview.js:

var swig = require('swig');
swig.setDefaults({
  cache: false
})
...
// 渲染模板
function renderFile(file, data) {
  return swig.render(fs.readFileSync(file).toString(), {
    filename: file,
    autoescape: false,
    locals: data
  })
}

为了编程方便,此处使用 fs.readFileSync() 这个方法来读取文件内容,在读取过程中它会造成进程阻塞,但在本例中不会造成影响。

修改原来渲染文章内容的部分:

fs.readFile(file, function(err, content) {
  if (err) return next(err);
  var post = parseSourceContent(content.toString());
  post.content = markdownToHTML(post.source);
  post.layout = post.layout || 'post';
  var html = renderFile(path.resolve(dir, '_layout', post.layout + '.html'), {
    post: post
  });
  res.end(html);
});

在渲染模板时,传递post变量进去,在模板中可以通过post.content来取得文章的内容,通过post.xxx来取得文章的元数据XXX。可以通过元数据layout来指定要渲染的模块,默认为post, 模板文件存储在 _layout 目录下。

新建模板文件,保存到example/_layout/post.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>{{config.title|escape}}</title>
  <link rel="stylesheet" href="/assets/style.css">
</head>
<body>
  <h1>{{post.title|escape}}</h1>
  <p>日期:{{post.date|escape}}</p>
  <hr>
  <div class="post-content">{{post.content}}</div>
</body>
</html>

引用了一个 CSS 文件,example/assets/style.css:

html {
  background-color: #eee;
}

body {
  width: 800px;
  min-height: 500px;
  margin: 0 auto;
  padding: 40px;
  line-height: 1.6;
  background-color: #fff;
  font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
  font-weight: 300;
}

ul {
  margin: 0;
  padding: 0;
  list-style: none;
}

li i {
  display: block;
  margin-top: 20px;
  border-bottom: 1px solid #888;
}

pre {
  background-color: #f5f5f5;
  margin: 0;
  padding: 8px 12px;
  font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
  font-size: 14px;
  font-weight: 300;
  color: #000;
}

.post-date {
  display: inline-block;
  padding: 0px 16px;
  background-color: #888;
  color: #fff;
}

.post-title {
  display: inline-block;
  margin-left: 6px;
  text-decoration: none;
  font-weight: bold;
}

.post-title:hover {
  color: #f00;
}

安装 swig 模块并启动程序:

$ npm i swig -S
$ XBlog preview example

可以看到美观多了。接下来我们试试换个模板,给文章添加元数据:

layout: post2

新建模板文件example/_layout/post2.html:

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>{{config.title|escape}}</title>
</head>
<body>
  <h1>{{post.title|escape}}</h1>
  <p>日期:{{post.date|escape}}</p>
  <hr>
  <div class="post-content">{{post.content}}</div>
</body>
</html>

区别是 post2 里没有引 css 文件。我们再看下效果:

渲染文章列表

要渲染文章列表,则首先要遍历所有文章,并且按照发表时间来排序,然后将其标题渲染出来。每篇文章均存储在 _posts 目录下,为了方便管理,我们采用的格式是 发表年月/文件名.md,可以借助rd模块来遍历整个 _posts 目录下的 .md 文件。

修改文件lib/cmd_preview.js:

var rd = require('rd');
...
// 渲染列表
router.get('/', function(req, res, next) { // 文章列表页面
  var list = [];
  var sourceDir = path.resolve(dir, '_posts');
  rd.eachFileFilterSync(sourceDir, /\.md$/, function(f, s) {
    var source = fs.readFileSync(f).toString();
    var post = parseSourceContent(source);
    post.timestamp = new Date(post.date);
    post.url = '/posts/' + stripExtname(f.slice(sourceDir.length + 1)) + '.html';
    list.push(post);
  })

  list.sort(function(a, b) {
    return b.timestamp - a.timestamp;
  })

  var html = renderFile(path.resolve(dir, '_layout', 'index.html'), {
    posts: list
  })
  res.end('html');
})
  • rd.eachFileFilterSync(dir, pattern, callback)遍历所有文件,dir为遍历目录,pattern为过滤规则正则,此例子中我们只读取 md 后缀的文件,callback 为回调函数,每读取到一个文件就会执行一次,它的第一个参数为这个文件的完整路径;
  • 在得到文章列表之后,我们还需要对文章按发表时间降序排序,首先我们先通过 post.timestamp = new Date(post.date); 得到文章发表时间的时间戳,然后通过数组的 sort()来进行排序;
  • post.url 为文章的链接,主要为了在渲染文章列表时,点击跳转详细内容页面,通过 '/posts/' + stripExtname(f.slice(sourceDir.length + 1)) + '.html' 来取得。其原理为先取得文章源文件在 _posts 目录下的相对路径,然后将其后缀名.md改为.html
    可。

新建文章列表的模板文件为_layout/index.html,

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>{{config.title|escape}}</title>
  <link rel="stylesheet" href="/assets/style.css">
</head>
<body>
  <h1>{{post.title|escape}}</h1>
  <ul>
    {% for post in posts %}
    <li>
      <span class="post-date">{{post.date|escape}}</span>
      <a class="post-title" href="{{post.url|escape}}">
        {{post.title|escape}}
      </a>
    </li>
    {% endfor %}
  </ul>
</body>
</html>

我们再随便添加几篇文章,并安装 rd 模块,然后启动程序

$ npm i rd -S
$ XBlog preview example

然后在浏览器中打开 http://localhost:3000

这样,我们的静态博客实时预览就完成了。

生成静态博客

生成静态博客内容时渲染文章的程序与实时预览时基本一样,区别是这一步不是等待用户访问时再渲染文章,而是直接遍历所有文章并直接渲染,然后把渲染后的页面直接保存为文件,因此我们可以先把这些公共的程序提取出来。

首先将渲染文章的内容部分和渲染文章的列表部分的程序提取处理, 分别命名为renderPost()renderIndex(), 新建文件lib/utils.js:

var swig = require('swig');
var rd = require('rd');
var path = require('path');
var fs = require('fs');
var md = require('markdown-it')({
  html: true,
  langPrefix: 'code-'
});

swig.setDefaults({
  cache: false
});

// 去掉文件名中的扩展名
function stripExtname(name) {
  var i = 0 - path.extname(name).length;
  if (i === 0) i = name.length;
  return name.slice(0, i);
}

// 将Markdown转换为HTML
function markdownToHTML(content) {
  return md.render(content || '');
}

// 解析文章内容
function parseSourceContent(data) {
  var split = '---\n';
  var i = data.indexOf(split);
  var info = {};
  if (i !== -1) {
    var j = data.indexOf(split, i + split.length);
    if (j !== -1) {
      var str = data.slice(i + split.length, j).trim();
      data = data.slice(j + split.length);
      str.split('\n').forEach(function(line) {
        var i = line.indexOf(':');
        if (i !== -1) {
          var key = line.slice(0, i).trim();
          var value = line.slice(i + 1).trim();
          info[key] = value;
        }
      });
    }
  }
  info.source = data;
  return info;
}

// 渲染模板
function renderFile(file, data) {
  return swig.render(fs.readFileSync(file).toString(), {
    filename: file,
    autoescape: false,
    locals: data
  });
}

// 遍历所有文章
function eachSourceFile(sourceDir, callback) {
  rd.eachFileFilterSync(sourceDir, /\.md$/, callback);
}

// 渲染文章
function renderPost(dir, file) {
  var content = fs.readFileSync(file).toString();
  var post = parseSourceContent(content.toString());
  post.content = markdownToHTML(post.source);
  post.layout = post.layout || 'post';
  var html = renderFile(path.resolve(dir, '_layout', post.layout + '.html'), {
    post: post
  });
  return html;
}

// 渲染文章列表
function renderIndex(dir) {
  var list = [];
  var sourceDir = path.resolve(dir, '_posts');
  eachSourceFile(sourceDir, function(f, s) {
    var source = fs.readFileSync(f).toString();
    var post = parseSourceContent(source);
    post.timestamp = new Date(post.date);
    post.url =
      '/posts/' + stripExtname(f.slice(sourceDir.length + 1)) + '.html';
    list.push(post);
  });

  list.sort(function(a, b) {
    return b.timestamp - a.timestamp;
  });

  var html = renderFile(path.resolve(dir, '_layout', 'index.html'), {
    posts: list
  });

  return html;
}

exports.renderPost = renderPost;
exports.renderIndex = renderIndex;
exports.stripExtname = stripExtname;
exports.eachSourceFile = eachSourceFile;

优化lib/cmd_preview.js:

var express = require('express');
var serveStatic = require('serve-static');
var path = require('path');
var utils = require('./utils');

module.exports = function(dir) {
  dir = dir || '.'; // 指定当前博客项目所在的目录,如果没有指定则默认为当前目录

  // 初始化express
  var app = express();
  var router = express.Router();
  app.use('/assets', serveStatic(path.resolve(dir, 'assets'))); // 静态资源文件
  app.use(router);

  // 渲染文章
  router.get('/posts/*', function(req, res, next) {
    // 文章内容页面
    var name = utils.stripExtname(req.params[0]);
    var file = path.resolve(dir, '_posts', name + '.md');
    var html = utils.renderPost(dir, file);
    res.end(html);
  });

  // 渲染列表
  router.get('/', function(req, res, next) {
    // 文章列表页面
    var html = utils.renderIndex(dir);
    res.end(html);
  });

  app.listen(3000, function() {
    console.log(`Blog is running`);
  });
};

重新启动预览程序,可以看到程序正常运行

接下来实现 build 命令

修改文件bin/XBlog:

// build命令
program
  .command('build [dir]')
  .description('生成整站静态HTML')
  .option('-o, --output <dir>', '生成的静态HTML存放目录')
  .action(require('../lib/cmd_build'));

新建文件lib/cmd_build.js

var path = require('path');
var utils = require('./utils');
var fs = require('fs-extra');

module.exports = function(dir, options) {
  dir = dir || '.';
  var outputDir = path.resolve(options.output || dir);

  // 写入文件
  function outputFile(file, content) {
    console.log('生成页面:%s', file.slice(outputDir.length + 1));
    fs.outputFileSync(file, content);
  }

  // 生成文章内容页面
  var sourceDir = path.resolve(dir, '_posts');
  utils.eachSourceFile(sourceDir, function(f, s) {
    var html = utils.renderPost(dir, f);
    var relativeFile =
      utils.stripExtname(f.slice(sourceDir.length + 1)) + '.html';
    var file = path.resolve(outputDir, 'posts', relativeFile);
    outputFile(file, html);
  });

  // 生成首页
  var htmlIndex = utils.renderIndex(dir);
  var fileIndex = path.resolve(outputDir, 'index.html');
  outputFile(fileIndex, htmlIndex);
};
  • build 命令允许通过 --output <dir> 选项来指定文件的输出路径,如果没有指定则默认输出到当前博客项目所在的目录, 通过 path.resolve(options.output || dir)来取得,并保存到变量outputDir中;
  • 保存文件时使用fs-extra模块的 fs.outputFileSync() 函数来做,其好处是可以不用管目录是否存在,如果目录不存在,则模块会自动帮我们创建。

安装fs-extra模块并执行 build 命令:

$ npm i fs-extra -S
$ XBlog build example

同时可以看到生成目录

如果想要输出到别的目录,可以指定 --output,比如:

$ XBlog build example --output output

配置文件

有时需要在渲染页面时用到一些公共的数据,在启动实时预览程序时希望可以自已指定要监听的端口,这时我们可以通过博客目录下的config.json来指定这些数据。

修改文件lib/utils.js

// 读取配置文件
function loadConfig(dir) {
  var content = fs.readFileSync(path.resolve(dir, 'config.json')).toString();
  var data = JSON.parse(content);
  return data;
}

exports.loadConfig = loadConfig;

新建文件example/config.json:

{
  "port": 3100
}

修改文件lib/cmd_preview.js,顶部增加代码

var open = require('open');
...
var config = utils.loadConfig(dir);
var port = config.port || 3000;
var url = 'http://127.0.0.1:' + port;
app.listen(port, function(){
  console.log(`Blog is running`);
})
open(url);
  • 通过配置文件 port 属性指定监听端口,如果没有则默认3000;
  • open 模块用于调用系统浏览器打开指定网址,在preview命令执行后,将自动在浏览器中打开博客首页。

安装open模块并执行preview命令:

S npm i open -S
S XBlog preview example

执行命令后,就可以看到浏览器会自动打开http://127.0.0.1:3100

接下来我们实现在模板中也可以通过 config 变量来访问到配置数据。

修改文件lib/utils.js, 将renderPost()renderIndex()修改:

// 渲染文章
function renderPost(dir, file) {
  var content = fs.readFileSync(file).toString();
  var post = parseSourceContent(content.toString());
  post.content = markdownToHTML(post.source);
  post.layout = post.layout || 'post';

  var config = loadConfig(dir);
  var layout = path.resolve(dir, '_layout', post.layout + '.html');
  var html = renderFile(layout, {
    config: config,
    post: post
  });
  return html;
}

// 渲染文章列表
function renderIndex(dir) {
  var list = [];
  var sourceDir = path.resolve(dir, '_posts');
  eachSourceFile(sourceDir, function(f, s) {
    var source = fs.readFileSync(f).toString();
    var post = parseSourceContent(source);
    post.timestamp = new Date(post.date);
    post.url =
      '/posts/' + stripExtname(f.slice(sourceDir.length + 1)) + '.html';
    list.push(post);
  });

  list.sort(function(a, b) {
    return b.timestamp - a.timestamp;
  });

  var config = loadConfig(dir);
  var layout = path.resolve(dir, '_layout', 'index.html');
  var html = renderFile(layout, {
    config: config,
    posts: list
  });

  return html;
}

在渲染时通过loadConfig()来读取配置信息,并作为 config 变量传递给模板。

创建空白博客模板

在创建一个新的博客项目时,为了方便使用,我们希望这个工具能自动创建一些必需的文件,比如页面模板和默认配置文件,这样就可以马上编写文章了。

把上面创建的一些文件保存在 tpl 目录下:

  • example/_layout/index.html复制到tpl/_layout/index.html;
  • example/_layout/post.html复制到tpl/_layout/post.html;
  • example/assets/style.css复制到tpl/assets/style.css;
  • example/config.json复制到tpl/config.json

一个空的博客项目包含以下目录:

  • _layout目录,存放模板文件;
  • _posts目录,存放文章内容源文件;
  • posts目录,存放生成的博客页面;
  • assets目录,存放博客页面中引用到的静态资源。

我们还可以在新博客自动创建一篇 hello, world 文章。

修改文件bin/XBlog, 将 create 命令部分改为:

// create命令
program
  .command('create [dir]')
  .description('创建一个空的博客')
  .action(require('../lib/cmd_create'));

新建文件lib/cmd_create.js

var path = require('path');
var utils = require('./utils');
var fs = require('fs-extra');
var moment = require('moment');

module.exports = function(dir) {
  dir = dir || '.';

  // 创建基本目录
  fs.mkdirsSync(path.resolve(dir, '_layout'));
  fs.mkdirsSync(path.resolve(dir, '_posts'));
  fs.mkdirsSync(path.resolve(dir, 'assets'));
  fs.mkdirsSync(path.resolve(dir, 'posts'));

  // 复制模板文件
  var tplDir = path.resolve(__dirname, '../tpl');
  fs.copySync(tplDir, dir);

  // 创建第一篇文章
  newPost(dir, 'hello, world', '这是我的第一篇文章');
  console.log('OK');
};

// 创建一篇文章
function newPost(dir, title, content) {
  var data = [
    '---',
    'title: ' + title,
    'date: ' + moment().format('YYYY-MM-DD'),
    '---',
    '',
    content
  ].join('\n');
  var name = moment().format('YYYY-MM') + '/hello-world.md';
  var file = path.resolve(dir, '_posts', name);
  fs.outputFileSync(file, data);
}
  • 使用fs-extra模块提供的mkdirsSync()来创建目录, 可以不用管其父目录是否存在,模块会自动帮我们创建;
  • 使用fs-extra模块提供的copySync()来复制一个目录;
  • 使用moment模块来生成日期字符串。

安装 moment 模块,创建一个空的项目并预览:

$ npm i moment -S
$ XBlog create new_blog
$ XBlog preview new_blog

至此,静态博客的基本功能已经完成了。

第三方服务

评论组件

由于这个博客是静态页面,所以无法在服务器端处理用户对文章内容的评论。现在网上已有很多第三方的评论组件,我们可以在模板中简单地填写一些代码即可使用到这些服务。以下是一些常用的第三方评论组件。

我们在模板文件_layout/post.html末尾添加以下多说评论组件的代码:

<!-- 多说评论框start -->
<div class="ds-thread" data-thread-key="" data-title="{{post.title|escape}}" data-url=""></div>
<!-- 多说评论框end -->
<!-- 多说公共JS代码start (一个网页只需插入一次) -->
<script type="text/javascript">
  var duoshuoQuery = {
    short_name: "node-in-action-blog"
  };
  (function() {
      var ds = document.createElement('script');
      ds.type = 'text/javascript';
      ds.async = true;
      ds.src = (document.location.protocol == 'https:' ? 'https:' : 'http:') + '//static.duoshuo.com/embed.js';
      ds.charset = 'UTF-8';
      (document.getElementsByTagName('head')[O] || document.getElementsByTagName('body')[O]).appendChild(ds);
      })()
</script>
<!-- 多说公共JS代码end -->

分享组件

可以使用国内的 “加网” 提供的分享组件,详细介绍见http://www.jiathis.com/

我们在模板文件_layout/post.html末尾添加以下 JiaThis 分享组件:

<!-- JiaThis Button BEGIN -->
<div class="jiathis—style_32x32">
  <a class="jiathis_button_qzone"></a>
  <a class="jiathis button tsina"></a>
  <a class="jiathis_button_tqq"></a>
  <a class="jiathis button weixin"></a>
  <a class="jiathis button renren"></a>
  <a href="http://www.jiathis.com/share" class="jiathis jiathis_txt jtico jtico_jiathis " target="_blank"></a>
  <a class="jiathis_counter_style"></a>
</div>
<script type="text/javascript" src="http://v3.jiathis.com/code/jia.js" charset="utf-8"></script>
<!-- JiaThis Button END -->