博客 / 詳情

返回

搭建博客太簡單,這次我們來做一個博客生成工具

文章較長,耐心讀下來我想你肯定會有所收穫 : )

作為一個技術人員,見到別人那光鮮亮麗的個人博客,心裏總免不了想搭建自己博客的衝動。當然,搭建博客的方式有好多種,但是大體上分這兩種:

  1. 服務端數據庫

    例如:你可以用 WordPress 搭建自己的博客,你可以利用 PHP 和 MySQL 數據庫在服務器上架設屬於自己的網站。

  2. 純靜態頁面

    市面上有挺多的免費 靜態文件HTML)託管機構,當然其中最簡單,最方便的可能就是 Github Pages 了。純靜態文件構建的網站有很多的優點,比如靜態網頁的訪問速度比較快、容易被搜索引擎檢索等。

當然,僅僅用作博客的話,純靜態頁面足夠使用了。評論系統的話可以用第三方的插件,比如 Disqus。

Github Pages

Github Pages 是Github提供的一個靜態文件託管系統,配合Github倉庫,使用起來特別方便。如果你不會使用的話,請看這裏。

而且,Github Pages 集成了 Jekyll,可以自動幫你把 markdown 語法編譯成漂亮的 html 頁面。

市面上有很多的博客生成工具,可以跟 Github pages 很好的結合,像是 Hexo。其實本質上很簡單,Hexo就是幫你把 markdown 編譯成了 html,
並且幫你生成了完善的目錄和路由。

手把手教你寫一個博客生成工具出來

通過一篇文章很難把整個工具描述的一清二楚,所以先放源代碼在這裏。源代碼

通過我們寫的工具可以作出的博客效果是這樣的:http://isweety.me/

我們得知了博客生成的本質,那麼動手做出一個博客生成工具也就沒有那麼大的難度了。我們先來梳理一下博客生成工具需要有哪些最基本的功能:

  1. markdown 編譯成 html

    我們寫博客,如果自己去寫html的話,那怕會被累死。。 Markdown 語法幫我們解決了這個問題,如果你對markdown不瞭解的話,可以看這裏。

  2. 生成目錄結構

我們想一下,確實,一個博客的話最基本的就兩個部分:目錄和博客內容。我們模仿Hexo的命令,設計如下:

我們把工具命名為 Bloger
bloger init blog # 初始化一個名為blog的博客項目

bloger new hello-world # 創建一篇名為 hello-word 的博客

bloger build # 編譯博客網站

bloger dev # 監聽 markdown 文件,實時編譯博客網站

bloger serve # 本地起服務

按照以上的設計,我們開始寫工具:

一、目錄設計

我們需要為我們 生成的博客項目 設計一個合理的文件目錄。如下:

blog
  ├── my.json (網站的基本配置)
  ├── index.html (首頁)
  ├── node_modules
  ├── package.json
  ├── _posts (博客 markdown 源文件)
  │   └── 2018
  │       ├── test.md
  │       └── hello-world.md
  ├── blog (_posts 中 markdown 生成的 html 文件)
  │   └── 2018
  │       ├── test
  │       │   └──index.html (這樣設計的話,我們就可以通過訪問 https://xxx.com/blog/2018/test/ 來訪問這篇博客了)
  │       └── hello-world
  │           └──index.html
  └── static (博客 markdown 源文件)
      ├── css (網站的css存放的文件)
      ├── iconfonts (網站的 iconfonts 存放的文件夾)
      ├── images (網站的圖片存放的文件夾)
      └── less (存放於這兒的 less 文件,會在 dev 的時候被編譯到 css 文件夾中,生成同名的 css 文件)

下面是我們寫的工具的源碼結構:

bloger
  ├── bin
  │   └── cli.js
  ├── lib
  │   ├── less (博客的樣式文件)
  │   ├── pages (博客的ejs模版)
  │   ├── tasks (編譯網站的腳本)
  │   └── gulpfile.js
  └── tpl (生成的博客模版,結構見上方)

二、markdown編譯成html

markdown編譯成html,有許多成熟的庫,這裏我們選用 mdpack。這個項目其實是在marked上的一層封裝。
mdpack 支持模版定製,支持多markdown拼接。

三、文章信息配置

一篇文章有很多的信息需要我們配置,比如 標題標籤發佈日期 等等,HexoJekyll 通常有一個規範是這樣的,在markdown文件的頂部放置文章的配置,
front-matter 格式如下:

 ---
 title: Hello world
 date: 2018-09-10
 tag: JavaScript,NodeJs
 info: 這篇文章簡單介紹了寫一個博客生成工具.
 ---

我們需要寫個腳本將這些信息提取,並且轉換成一個json對象,比如上邊的信息,我們要轉換成這樣:

{
  "title": "Hello world",
  "date": "2018-09-10",
  "tag": "JavaScript,NodeJs",
  "info": "這篇文章簡單介紹了寫一個博客生成工具."
}

腳本如下:

// task/metadata.js
const frontMatter = require('@egoist/front-matter'); // 截取頭部front-matter信息
const fs = require('fs');
const path = require('path');
const root = process.cwd();

const metadata = {
  post: []
};

// 把提取出來的front-matter字符串解析,生成對象
function getMetadata(content) {
  const head = frontMatter(content).head.split('\n');
  const ret = {};
  head.forEach((h) => {
    const [key, value] = h.split(': ');
    ret[key.trim()] = value.trim();
  });

  if (!ret.type) {
    ret.type = '原創';
  }

  return ret;
}

try {
  // 便利 _posts 文件夾,將所有的markdown內容的front-matter轉換成對象,存放到metadata數組中
  // 將生成的metadata信息寫入一個文件中,我們命名為postMap.json,保存到所生成項目的根目錄,以備使用
  fs.readdirSync(path.resolve(root, '_posts'))
  .filter(m => fs.statSync(path.resolve(root, '_posts', m)).isDirectory())
  .forEach((year) => {
    fs.readdirSync(path.resolve(root, '_posts', year))
      .forEach((post) => {
        const content = fs.readFileSync(path.resolve(root, '_posts', year, post), 'utf8');
        metadata.post.push({
          year,
          filename: post.split('.md')[0],
          metadata: getMetadata(content)
        });
      });
  });

  fs.writeFileSync(path.resolve(root, 'postMap.json'), JSON.stringify(metadata), 'utf8');
} catch (err) {}

module.exports = metadata;

四、博客目錄生成

通過讀取postMap.json中的metadata信息,我們可以構建一個博客目錄出來。代碼如下:

const fs = require('fs-extra');
const path = require('path');
const ejs = require('ejs');
// 首頁的ejs模版
const homeTpl = fs.readFileSync(path.resolve(__dirname, '../pages/home.ejs'), 'utf8');
const root = process.cwd();

function buildHomeHtml() {
  const metadata = require('./metadata');
  // 博客網站的基本配置
  const myInfo = require(path.resolve(root, 'my.json'));
  const htmlMenu = require('./menu')(); // 菜單生成,這裏不講

  // 講postMap.json中的metadata遍歷,然後生成一個blogList數組
  const blogList = metadata.post.map((postInfo) => {
    const data = postInfo.metadata;

    return {
      title: data.title,
      date: data.date,
      url: `/blog/${postInfo.year}/${postInfo.filename}`,
      intro: data.intro,
      tags: data.tag.split(','),
      author: data.author,
      type: data.type,
      top: data.top === 'true' ? true : false
    };
  });

  // 默認按發佈時間排序
  blogList.sort((a, b) => new Date(a.date) - new Date(b.date));

  // 置頂
  blogList.sort((a, b) => !a.top);

  // ejs替換
  fs.outputFile(
    path.resolve(root, 'index.html'),
    ejs.render(homeTpl, {
      name: myInfo.name,
      intro: myInfo.intro,
      homepage: myInfo.homepage,
      links: myInfo.links,
      blogList,
      htmlMenu
    }),
    (err) => {
      console.log('\nUpadate home html success!\n');
    }
  );
}

module.exports = buildHomeHtml;

五、集成gulp

在編譯博客的過程中,一些操作利用 gulp 會簡單快捷許多。比如 編譯less打包iconfonts監聽文件改動 等。
但是gulp是一個命令行工具,我們怎麼樣能把gulp繼承到我們的工具中呢?方法很簡單,如下:

const gulp = require('gulp');
require('./gulpfile.js');

// 啓動gulpfile中的build任務
if(gulp.tasks.build) {
  gulp.start('build');
}

通過以上的方法,我們可以在我們的cli工具中集成 gulp,那麼好多問題就變得特別簡單,貼上完整的 gulpfile:

const fs = require('fs');
const path = require('path');
const url = require('url');
const del = require('del');
const gulp = require('gulp');
const log = require('fancy-log');
const less = require('gulp-less');
const minifyCSS = require('gulp-csso');
const autoprefixer = require('gulp-autoprefixer');
const plumber = require('gulp-plumber');
const iconfont = require('gulp-iconfont');
const iconfontCss = require('gulp-iconfont-css');
const mdpack = require('mdpack');
const buildHome = require('./tasks/home');
const root = process.cwd();

// 編譯博客文章頁面
function build() {
  const metadata = require(path.resolve(root, 'postMap.json'));
  const myInfo = require(path.resolve(root, 'my.json'));
  const htmlMenu = require('./tasks/menu')(); // 跳過
  // 刪除博客文件夾
  del.sync(path.resolve(root, 'blog'));
  
  // 遍歷_posts文件夾,編譯所有的markdown文件
  // 生成的格式為 blog/${year}/${filename}/index.html
  fs.readdirSync(path.resolve(root, '_posts'))
  .filter(m => fs.statSync(path.resolve(root, '_posts', m)).isDirectory())
  .forEach((year) => {
    fs.readdirSync(path.resolve(root, '_posts', year))
      .forEach((post) => {
        const filename = post.split('.md')[0];
        const _meta = metadata.post.find(_m => _m.filename === filename).metadata;
        const currentUrl = url.resolve(myInfo.homepage, `blog/${year}/${filename}`);
        const mdConfig = {
          entry: path.resolve(root, '_posts', year, post),
          output: {
            path: path.resolve(root, 'blog', year, filename),
            name: 'index'
          },
          format: ['html'],
          plugins: [
            // 去除markdown文件頭部的front-matter
            new mdpack.plugins.mdpackPluginRemoveHead()
          ],
          template: path.join(__dirname, 'pages/blog.ejs'),
          resources: {
            markdownCss: '/static/css/markdown.css',
            highlightCss: '/static/css/highlight.css',
            title: _meta.title,
            author: _meta.author,
            type: _meta.type,
            intro: _meta.intro,
            tag: _meta.tag,
            keywords: _meta.keywords,
            homepage: myInfo.homepage,
            name: myInfo.name,
            disqusUrl: myInfo.disqus ? myInfo.disqus.src : false,
            currentUrl,
            htmlMenu
          }
        };
        mdpack(mdConfig);
      });
  });
}

// 編譯css
gulp.task('css', () => {
  log('Compile less.');
  // 我們編譯當前項目下的 lib/less/*.less 和 生成的博客項目下的 static/less/**/*.less
  return gulp.src([path.resolve(__dirname, 'less/*.less'), path.resolve(root, 'static/less/**/*.less')])
    .pipe(plumber())
    .pipe(less({
      paths: [root]
    }))
    // css壓縮
    .pipe(minifyCSS())
    // 自動加前綴
    .pipe(autoprefixer({
      browsers: ['last 2 versions'],
      cascade: false
    }))
    // 將編譯生成的css放入生成的博客項目下的 static/css 文件夾中
    .pipe(gulp.dest(path.resolve(root, 'static/css')));
});

// 監聽css文件的改動,編譯css
gulp.task('cssDev', () => {
  log('Starting watch less files...');
  return gulp.watch([path.resolve(__dirname, 'less/**/*.less'), path.resolve(root, 'static/less/**/*.less')], ['css']);
});

// 監聽markdown文件的改動,編譯首頁和博客文章頁
gulp.task('mdDev', () => {
  log('Starting watch markdown files...');
  return gulp.watch(path.resolve(root, '_posts/**/*.md'), ['home', 'blog']);
});

// 編譯首頁
gulp.task('home', buildHome);

// build博客
gulp.task('blog', build);

gulp.task('default', ['build']);

// 監聽模式
gulp.task('dev', ['cssDev', 'mdDev']);

// 執行build的時候會編譯css,編譯首頁,編譯文章頁
gulp.task('build', ['css', 'home', 'blog']);

// 生成iconfonts
gulp.task('fonts', () => {
  console.log('Task: [Generate icon fonts and stylesheets and preview html]');
  return gulp.src([path.resolve(root, 'static/iconfonts/svgs/**/*.svg')])
    .pipe(iconfontCss({
      fontName: 'icons',
      path: 'css',
      targetPath: 'icons.css',
      cacheBuster: Math.random()
    }))
    .pipe(iconfont({
      fontName: 'icons',
      prependUnicode: true,
      fontHeight: 1000,
      normalize: true
    }))
    .pipe(gulp.dest(path.resolve(root, 'static/iconfonts/icons')));
});

六、cli文件

我們已經把gulpfile寫完了,下面就要寫我們的命令行工具了,並且集成gulp。代碼如下:

// cli.js
#!/usr/bin/env node

const gulp = require('gulp');
const program = require('commander'); // 命令行參數解析
const fs = require('fs-extra');
const path = require('path');
const spawn = require('cross-spawn');
const chalk = require('chalk');
const dateTime = require('date-time');
require('../lib/gulpfile');
const { version } = require('../package.json');
const root = process.cwd();

// 判斷是否是所生成博客項目的根目錄(因為我們必須進入到所生成的博客項目中,才可以執行我們的build和dev等命令)
const isRoot = fs.existsSync(path.resolve(root, '_posts'));
// 如果不是根目錄的話,輸出的內容
const notRootError = chalk.red('\nError: You should in the root path of blog project!\n');

// 參數解析,正如我們上面所設計的命令用法,我們實現了以下幾個命令
// bloger init [blogName]
// bloger new [blog]
// bloger build
// bloger dev
// bloger iconfonts
program
  .version(version)
  .option('init [blogName]', 'init blog project')
  .option('new [blog]', 'Create a new blog')
  .option('build', 'Build blog')
  .option('dev', 'Writing blog, watch mode.')
  .option('iconfonts', 'Generate iconfonts.')
  .parse(process.argv);

// 如果使用 bloger init 命令的話,執行以下操作
if (program.init) {
  const projectName = typeof program.init === 'string' ? program.init : 'blog';
  const tplPath = path.resolve(__dirname, '../tpl');
  const projectPath = path.resolve(root, projectName);
  // 將我們的項目模版複製到當前目錄下
  fs.copy(tplPath, projectPath)
    .then((err) => {
      if (err) throw err;
      console.log('\nInit project success!');
      console.log('\nInstall npm packages...\n');
      fs.ensureDirSync(projectPath); // 確保存在項目目錄
      process.chdir(projectPath); // 進入到我們生成的博客項目,然後執行 npm install 操作
      const commond = 'npm';
      const args = [
        'install'
      ];
      
      // npm install
      spawn(commond, args, { stdio: 'inherit' }).on('close', code => {
        if (code !== 0) {
          process.exit(1);
        }
        // npm install 之後執行 npm run build,構建博客項目
        spawn('npm', ['run', 'build'], { stdio: 'inherit' }).on('close', code => {
          if (code !== 0) {
            process.exit(1);
          }
          // 構建成功之後輸出成功信息
          console.log(chalk.cyan('\nProject created!\n'));
          console.log(`${chalk.cyan('You can')} ${chalk.grey(`cd ${projectName} && npm start`)} ${chalk.cyan('to serve blog website.')}\n`);
        });
      });
    });
}

// bloger build 執行的操作
if (program.build && gulp.tasks.build) {
  if (isRoot) {
    gulp.start('build');
  } else {
    console.log(notRootError);
  }
}

// bloger dev執行的操作
if (program.dev && gulp.tasks.dev) {
  if (isRoot) {
    gulp.start('dev');
  } else {
    console.log(notRootError);
  }
}

// bloger new 執行的操作
if (program.new && typeof program.new === 'string') {
  if (isRoot) {
    const postRoot = path.resolve(root, '_posts');
    const date = new Date();
    const thisYear = date.getFullYear().toString();
    // 在_posts文件夾中生成一個markdown文件,內容是下邊的字符串模版
    const template = `---\ntitle: ${program.new}\ndate: ${dateTime()}\nauthor: 作者\ntag: 標籤\nintro: 簡短的介紹這篇文章.\ntype: 原創\n---\n\nBlog Content`;
    fs.ensureDirSync(path.resolve(postRoot, thisYear));
    const allList = fs.readdirSync(path.resolve(postRoot, thisYear)).map(name => name.split('.md')[0]);
    // name exist
    if (~allList.indexOf(program.new)) {
      console.log(chalk.red(`\nFile ${program.new}.md already exist!\n`));
      process.exit(2);
    }
    fs.outputFile(path.resolve(postRoot, thisYear, `${program.new}.md`), template, 'utf8', (err) => {
      if (err) throw err;
      console.log(chalk.green(`\nCreate new blog ${chalk.cyan(`${program.new}.md`)} done!\n`));
    });
  } else {
    console.log(notRootError);
  }
}

// bloger iconfonts執行的操作
if (program.iconfonts && gulp.tasks.fonts) {
  if (isRoot) {
    gulp.start('fonts');
  } else {
    console.log(notRootError);
  }
}
完整的項目源代碼:https://github.com/PengJiyuan...

相關閲讀:手把手教你寫一個命令行工具

本章完

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.