Cho-Ching's Blog

[Promise][Bluebird] 做一個簡單的靜態Blog網站產生器

利用 Promise方式做一個簡單的靜態Blog產生器。

不需要資料庫, 只需要撰寫markdown文章, 就轉換成靜態網頁。不需要額外租用網頁空間, 利用Github Pages 開開心心發佈我們的部落格。

想法與架構

功能很簡單: 我寫了一堆markdown的部落格文章, 執行一個產生器程式, 就幫我產生一個條列所有文章的首頁(index page), 上面可以聯結到每個從markdown轉成html的文章頁面。

檔案結構如下:

.
├── build
├── templates
│   ├── xxx.jade
│   ├── ...
├── posts
│   ├── xxx.md
│   ├── ...
├── gen.js
└── ...

其中:

gen.js會先蒐集posts資料夾有哪些markdown檔案, 然後解析markdown檔名, 讀取每個檔案並套用樣板轉換成HTML網頁, 最後將所有檔案資訊, 整理製作成index.html。

產生的結果都放在build資料夾內, 我們就可以把整包拿去發佈了:

.
├── ...
├── index.html
└── posts
    ├── 2015-05-12-hello_world.html
    ├── 2015-05-13-install_and_setting_golang_on_ubuntu_and_vim.html
    ├── 2015-05-14-flexbox.html
    ├── 2015-05-15-express_middleware_1.html
    ├── ...

Markdown文章格式

為了方便(簡單), 撰寫的markdown文章檔名必須要像以下格式:

YYYY-MM-DD-post_article_url.md

檔名開頭是YYYY-MM-DD日期開頭, 再來接個-字號, 最後是整個文章的名字, 可以取名像是post_article_url, 但是不能取名用-字號連起來的名字, 例如post-article-url這樣就不行。 XD

每個文章開頭一定是大寫標題1:

# 你好,我是文章標題

....

gen.js會取每個markdown文章的第1行, 條列在首頁的文章列表上。

用到的函式庫

把會用到的函式庫安裝一下:

$ npm i --save bluebird marked jade lodash highlight.js

其中:

取得目錄

Promise概念和Bluebird使用, 可以參考我寫的[Promise]使用Bluebird

開始寫gen.js嚕!

var _ = require('lodash');
var fs = require('fs');
var Bluebird = require('bluebird');
Bluebird.promisifyAll(fs);

var conf = {
  name: 'My Blog',
  desc: '程式筆記本',
  articleSource: './posts',
  buildto: './build'
};

function reverseDirList(list){
  return _(list).reverse().value();
}   


//=== My Flow ======================
fs.readdirAsync(conf.articleSource)
  .then(reverseDirList);

這裡我利用fs.readdirAsync(conf.articleSource)建立了一個新的promise陣列,

再來利用reverseDirList函式將我們取得的目錄陣列做反轉, 讓最新的文章排在陣列第一個, 之後首頁列表顯示的時候才會由最新的文章依序往下排。

解析markdown檔案資訊

取得了文章檔案列表, 再來我們要把一些用到的資訊整理抽離出來:

function parseInfo(fileName){
  var titleArr= fileName.split('.')[0].split('-');
  var postDate = titleArr[0] +'-'+ titleArr[1] +'-'+ titleArr[2];
  return {
    fileName: fileName,
    headTitle: conf.name + ' - ' + titleArr[3],
    postDate: postDate,
    inPath: conf.articleSource + fileName,
    outPath: conf.buildto + 'posts/'+ postDate + '-' + titleArr[3] + '.html',
    content: fs.readFileAsync(conf.articleSource + fileName, 'utf8')
  };
}

parseInfo函式利用每個檔案名稱, 傳回了要顯示在每個文章title標籤的的headTitle, 發表文章的postDate日期, 來源路徑inPath, 目的地路徑outPath,還有讀出來的檔案內容content

最後利用bluebird的map method, 讓陣列裡的每個item都執行parseInfo後傳回新的promise陣列。

fs.readdirAsync(conf.articleSource)
  .then(reverseDirList)
  .map(parseInfo);

文章要套用的樣板

樣板這裡使用jade, 如果不習慣jade寫法的人,可以用自己喜歡的, 或是有現成HTML的板型, 可以利用html-to-jade轉成jade。

基本樣版Html.jade,主要就是共通引用的css,js寫在這, 以及< Body >的結構:

doctype html
html(lang='zh')
  head
    block head
      meta(charset='utf-8')
      meta(http-equiv='X-UA-Compatible', content='IE=edge,chrome=1')
      meta(name='viewport', content='width=device-width')
      link(rel='stylesheet', href=source+'css/github.css')
      link(rel='stylesheet', href=source+'css/main.css')
      title= title
  body
    .Wrapper
      .Header
        h1: a(href='/') My Blog
      block main
      .Footer
        include ./Footer.jade

注意這裡聯結css的地方, 必須要使用傳入的變數source, 例如:

link(rel='stylesheet', href=source+'css/github.css')

主要是因為現在是我所有文章都放在build/posts資料夾下, 而css我是放在build/css下, 所以source在引用是文章的時候值為../, 在index.html的值為./。(應該還有更好寫法)

Post.jade:

extends ./Html.jade
block main
  .Main!= content
  h3: a(href='/') << 回到文章列表

Post.jade傳入的content, 就是我們由markdown內容轉換過來的HTML。

Markdown --> HTML --> save

繼續我們的流程。

依照陣列裡的每個項目,我們將content由markdown轉成html, 套用我們寫好的jade樣版, 再一起轉成html後寫到我們的目的地(build/posts)資料夾去。

首先, 引用了jade和marked函式庫, 並在轉換markdown的時候設定使用highlight.js做語法高亮:

var jade = require('jade');
var marked = require('marked'); 

marked.setOptions({
  highlight: function (code) {
    return require('highlight.js').highlightAuto(code).value;
  }
});

再來,在原來的流程中,加入.each(markdownToHtml)

markdownToHtml是自定義的function, 使用bluebird的each 對前promise陣列中所存的每個item, 執行轉換markdown存到html檔案的動作:

fs.readdirAsync(conf.articleSource)
  .then(reverseDirList)
  .map(parseInfo)
  .each(markdownToHtml);

markdownToHtml就像是延伸的promise流程, 先將markdown內容轉換成html, 將這html字串套用jade樣板, 最後寫入到目標路徑去, 完成後顯示訊息。使用done method的差異在於, 任何未處理的rejection都會在這裡被拋出然後統一處理:

function markdownToHtml(md){
  var postFn = jade.compileFile('./templates/Post.jade', {pretty:false,debug:true});
  return md.content
    .then(marked)
    .then(function(data){
      return postFn({
        source: '../', 
        title: md.headTitle, 
        content: data
      });  
    })
    .then(fs.writeFileAsync.bind(fs, md.outPath))
    .done(function(){
      console.log('[done] ' + md.fileName  + ' --> ' + md.outPath );
    });
}

首頁要套用的樣板

好了, 我們已經順利的將所有文章都轉好了, 現在要來處理產生index.html的內容。

首先先把首頁需要的樣板撰寫一下, templates/Index.jade:

extends ./Html.jade
block main
  .Main
    ul.Posts
      each list in lists
        li: a(href=list.link) #{list.title} <b>(#{list.date})</b>

這裡我們列出文章的標題與撰寫的時間, 並提供聯結到對應的文章去。

製作文章列表

流程新增一個項目.map(getPostList):

fs.readdirAsync(conf.articleSource)
  .then(reverseDirList)
  .map(parseInfo)
  .each(markdownToHtml)
  .map(getPostList);

getPostList把我們原來的promise item, 改成要產生文章列表所需要的"材料", link URL聯結, title聯結標題, date文章撰寫日期:

function getPostList(md){
  return md.content
    .then(function(data){
      return {
        link: '/posts/' + md.fileName.split('.')[0] + '.html',
        title: data.split('\n')[0], //像是:  # 標題  
        date: md.postDate
      };
    });
}

產生index.html

要做的工作很單純, 把我們整理好的文章lists傳到Index.jade組裝成我們最後的HTML, 寫入檔案:

function genIndex(lists){
  var indexFn = jade.compileFile('./templates/Index.jade', {pretty:false,debug:true});
  return indexFn({
    source: './', 
    title: conf.name,
    lists: lists
  }); 
}

fs.readdirAsync(conf.articleSource)
  .then(reverseDirList)
  .map(parseInfo)
  .each.markdownToHtml)
  .map(getPostList)
  .then.genIndex)
  .then(fs.writeFileAsync.bind(fs, conf.buildto + 'index.html'))
  .done(function(){
    console.log('[done] index.html created.');
  });

Express Watch server

平常開發的時候, 還要把markdown檔案編成html檔案後, 再啟動http server看修改內容實在很麻煩, 這時候有個監看的web server幫忙就好多了, 這裡我利用expressjs, nodemon, npm script很簡單的方式實現。

安裝一下必要套件:

$ npm i --save express nodemon

Express.js是最普及的輕量化nodejs網頁框架, nodemon幫忙監控任何nodejs app檔案的變化, 一有變化就重新啟動server。

package.json加上script自動化一些動作:

{
  ...
  "scripts": {
    "dev": "nodemon server.js",
    "build": "sass ./contents/scss/main.scss:build/css/main.css & node gen",
    "scss": "sass --watch ./contents/scss/main.scss:build/css/main.css"
  },
  ...
}

執行 npm run dev 就執行express watch server, 只要新增/修改了markdown文章, server就重新啟動, 那重新realod瀏覽器就可以看到更新後的結果。

文章都寫好了, 確定要發佈就執行 npm run build, 就會直接呼叫gen.js和sass轉換css, 轉換好的build資料夾就可以整包拿去發佈。

開發的時候若要改動外觀css, 那麼除了執行npm run build以外, 再執行npm run scss就會啟動監看sass檔案, 一有改動就會更新css。

server.js就是我們的監看程式, 基本上, 就是利用我們寫的promise產生流程, 所以首先我們把gen.js的function全部抽出獨立成一個util.js, 設定的部份抽出成conf.js, 這樣gen.jsserver.js都可以共用這些函式。

server.js 基本內容就是, 引用函式庫, 使用jade樣板, 啟動, 然後沒相關的route全部導到錯誤處理這樣:


var express = require('express');
var conf = require('./conf');
var utils = require('./utils');
var marked = require('marked'); 
var fs = require('fs');
var Bluebird = require('bluebird');
Bluebird.promisifyAll(fs);

var app = express();

app.listen(3000, function(){
  console.log('server listening on port 3000');
});

app.set('views', './templates');
app.set('view engine', 'jade');
app.use('/css', express.static(__dirname + '/build/css'));

//Blog Route

//Err handling
app.use(function(req, res, next){
  var err = new Error('Not found');
  err.status = 404;
  next(err);
});

app.use(function(err, req, res, next){
  if(err.status === 404) {
    res.status(404).send('Not Found');
  }
  if(err.status === 500) {
    console.log(err.stack);
    res.status(500).send('Something broke!');
  }
});

再來加入要處理的routes: 要處理的route只有兩種, 一個就是index page, 另外就是每篇文章, 處理index page的route像這樣:

//Blog Route
app.get('/', function(req, res){
  fs.readdirAsync(conf.articleSource)
    .then(utils.reverseDirList)
    .map(utils.parseInfo)
    .map(utils.getPostList)
    .then(utils.genIndex)
    .then(function(indexPage){
      res.send(indexPage);
    });
});

完全就把gen.js promise流程拿來用就對了! 這裡的流程和gen.js相比, 只是拿掉了.each(utils.markdownToHtml)每個markdown轉成html的部份, 以及最後我們沒有把index page寫入檔案, 而是把整個index page 直接傳回顯使給使用者(res.send(indexPage))。

顯示每篇文章的部份, 則是我們上述所寫到的markdownToHtml 函式的內容, 我們解析URL, 讀取解析出來對應的markdown檔案, 轉換html, 最後直接render解析jade樣板後, 傳送結果給使用者:

//Blog Route
app.get('/', function(req, res){
  //...
}

app.get('/posts/:post', function(req, res){
  var html = req.params.post;
  var titleArr = html.split('.')[0].split('-');
  var headTitle = conf.name + ' - ' + titleArr[3];
  var inPath = conf.articleSource + html.split('.')[0] + '.md';

  fs.readFileAsync(inPath, 'utf8')
    .then(marked)
    .then(function(content){
      res.render('Post',{
        source: '../', 
        title: headTitle,  
        content: content
      });
    });
});

大功告成

終於完成 OH YA! 是不是比想像中簡單呢。利用promise, 讓整個非同步流程看起來十分清爽, 整理除錯上效率就提高不少, 雖然我覺得, promise的風格, 的確需要時間熟悉再適應。

這個網站就是利用這樣的方式完成的, 以上的程式碼我放在這裡

<< 回到文章列表