目前有很多优秀的静态文档生成器,它们的工作原理比你想象的要简单得多。

前言

原文: Build a static site generator in 40 lines with Node.js

作者: Douglas Matoso

翻译许可:

image
img

为什么要造这个轮子

当我计划建立个人网站时,我的需求很简单,做一个只有几个页面的网站,放置一些关于自己的信息,我的技能和项目就够了。

毫无疑问,它应该是纯静态的(不需要后端服务,可托管在任何地方)。

我曾经使用过Jekyll, HugoHexo这些知名的静态文档生成器,但我认为它们有太多的功能,我不想为我的网站增加这么多的复杂性。

所以我觉得,针对我的需求,一个简单的静态文档生成器就可以满足。

嗯,手动构建一个简单的生成器,应该不会那么难。

正文

需求分析

这个生成器必须满足以下条件:

  • EJS模板生成HTML文件。

  • 具有布局文件,所有页面都应该具有相同的页眉,页脚,导航等。

  • 允许可重用布局组件。

  • 站点的大致信息封装到一个配置文件中。

  • 从 JSON 文件中读取数据。

    例如:项目列表,这样我可以轻松地迭代和构建项目页面。

为什么使用 EJS 模板?

因为 EJS 很简单,它只是嵌入在 HTML 中的 JavaScript 而已。

项目结构

1
2
3
4
5
6
7
8
public/  
src/
assets/
data/
pages/
partials/
layout.ejs
site.config.js
  • public: 生成站点的位置。
  • src: 源文件。
  • src/assets: 包含 CSS, JS, 图片 等
  • src/data: 包含 JSON 数据。
  • src/pages: 根据其中的 EJS 生成 HTML 页面的模板文件夹。
  • src/layout.ejs: 主要的原页面模板,包含特殊<%-body%>占位符,将插入具体的页面内容。
  • site.config.js: 模板中全局配置文件。

生成器

生成器代码位于scripts/build.js文件中,每次想重建站点时,执行npm run build命令即可。

实现方法是将以下脚本添加到package.jsonscripts块中:

1
"build": "node ./scripts/build"

下面是完整的生成器代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
const fse = require('fs-extra')
const path = require('path')
const { promisify } = require('util')
const ejsRenderFile = promisify(require('ejs').renderFile)
const globP = promisify(require('glob'))
const config = require('../site.config')

const srcPath = './src'
const distPath = './public'

// clear destination folder
fse.emptyDirSync(distPath)

// copy assets folder
fse.copy(`${srcPath}/assets`, `${distPath}/assets`)

// read page templates
globP('**/*.ejs', { cwd: `${srcPath}/pages` })
.then((files) => {
files.forEach((file) => {
const fileData = path.parse(file)
const destPath = path.join(distPath, fileData.dir)

// create destination directory
fse.mkdirs(destPath)
.then(() => {
// render page
return ejsRenderFile(`${srcPath}/pages/${file}`, Object.assign({}, config))
})
.then((pageContents) => {
// render layout with page contents
return ejsRenderFile(`${srcPath}/layout.ejs`, Object.assign({}, config, { body: pageContents }))
})
.then((layoutContent) => {
// save the html file
fse.writeFile(`${destPath}/${fileData.name}.html`, layoutContent)
})
.catch((err) => { console.error(err) })
})
})
.catch((err) => { console.error(err) })

接下来,我将解释代码中的具体组成部分。

依赖

我们只需要三个依赖项:

  • ejs

    把我们的模板编译成HTML

  • fs-extra

    Node 文件模块的衍生版,具有更多的功能,并增加了Promise的支持。

  • glob

    递归读取目录,返回包含与指定模式匹配的所有文件,类型是数组。

Promisify

我们使用Node提供的util.promisify将所有回调函数转换为基于Promise的函数。

它使我们的代码更短,更清晰,更易于阅读。

1
2
3
const { promisify } = require('util')  
const ejsRenderFile = promisify(require('ejs').renderFile)
const globP = promisify(require('glob'))

加载配置

在顶部,我们加载站点配置文件,以稍后将其注入模板渲染中。

1
const config = require('../site.config')

站点配置文件本身会加载其他JSON数据,例如:

1
2
3
4
5
6
7
8
9
const projects = require('./src/data/projects')

module.exports = {
site: {
title: 'NanoGen',
description: 'Micro Static Site Generator in Node.js',
projects
}
}

清空站点文件夹

我们使用fs-extra提供的emptyDirSync函数清空 生成后的站点文件夹。

1
fse.emptyDirSync(distPath)

拷贝静态资源

我们使用fs-extra提供的copy函数,该函数以递归方式复制静态资源 到站点文件夹。

1
fse.copy(`${srcPath}/assets`, `${distPath}/assets`)

编译页面模板

首先我们使用glob(已被 promisify)递归读取src/pages文件夹以查找.ejs文件。

它将返回一个匹配给定模式的所有文件数组。

1
2
globP('**/*.ejs', { cwd: `${srcPath}/pages` })  
.then((files) => {

对于找到的每个模板文件,我们使用Nodepath.parse函数来分隔文件路径的各个组成部分(例如目录,名称和扩展名)。

然后,我们在站点目录中使用fs-extra提供的mkdirs函数创建与之对应的文件夹。

1
2
3
4
5
6
files.forEach((file) => {  
const fileData = path.parse(file)
const destPath = path.join(distPath, fileData.dir)

// create destination directory
fse.mkdirs(destPath)

然后,我们使用EJS编译文件,并将配置数据作为数据参数。

由于我们使用的是已 promisify 的ejs.renderFile函数,因此我们可以返回调用结果,并在下一个promise链中处理结果。

1
2
3
4
.then(() => {  
// render page
return ejsRenderFile(`${srcPath}/pages/${file}`, Object.assign({}, config))
})

在下一个then块中,我们得到了已编译好的页面内容。

现在,我们编译布局文件,将页面内容作为body属性传递进去。

1
2
3
4
.then((pageContents) => {  
// render layout with page contents
return ejsRenderFile(`${srcPath}/layout.ejs`, Object.assign({}, config, { body: pageContents }))
})

最后,我们得到了生成好的编译结果(布局+页面内容的 HTML),然后将其保存到对应的HTML文件中。

1
2
3
4
.then((layoutContent) => {  
// save the html file
fse.writeFile(`${destPath}/${fileData.name}.html`, layoutContent)
})

调试服务器

为了使查看结果更容易,我们在package.jsonscripts中添加一个简单的静态服务器。

1
"serve": "serve ./public"

运行 npm run serve 命令,打开http://localhost:5000就看到结果了。

进一步探索

Markdown

大多数静态文档生成器都支持以Markdown格式编写内容。

并且,它们还支持以YAML格式在顶部添加一些元数据,如下所示:

1
2
3
4
---  
title: Hello World
date: 2013/7/13 20:46:25
---

只需要一些修改,我们就可以支持相同的功能了。

首先,我们必须增加两个依赖:

然后,我们将glob的匹配模式更新为包括.md文件,并保留.ejs,以支持渲染复杂页面。

如果想要部署一些纯 HTML 页面,还需包含.html

1
globP('**/*.@(md|ejs|html)', { cwd: `${srcPath}/pages` })

对于每个文件,我们都必须加载文件内容,以便可以在顶部提取到元数据。

1
2
3
4
.then(() => {  
// read page file
return fse.readFile(`${srcPath}/pages/${file}`, 'utf-8')
})

我们将加载后的内容传递给front-matter

它将返回一个对象,其中attribute属性便是提取后的元数据。

然后,我们使用此数据扩充站点配置。

1
2
3
4
.then((data) => {  
// extract front matter
const pageData = frontMatter(data)
const templateConfig = Object.assign({}, config, { page: pageData.attributes })

现在,我们根据文件扩展名将页面内容编译为 HTML。

如果是.md,则利用marked函数编译;

如果是.ejs,我们继续使用EJS编译;

如果是.html,便无需编译。

1
2
3
4
5
6
7
8
9
10
11
12
let pageContent  

switch (fileData.ext) {
case '.md':
pageContent = marked(pageData.body)
break
case '.ejs':
pageContent = ejs.render(pageData.body, templateConfig)
break
default:
pageContent = pageData.body
}

最后,我们像以前一样渲染布局。

增加元数据,最明显的一个意义是,我们可以为每个页面设置单独的标题,如下所示:

1
2
3
---  
title: Another Page
---

并让布局动态地渲染这些数据:

1
<title><%= page.title ? `${page.title} | ` : '' %><%= site.title %>title>

如此一来,每个页面将具有唯一的</code>标签。</p><h4 id="多种布局的支持"><a href="#多种布局的支持" class="headerlink" title="多种布局的支持"></a>多种布局的支持</h4><p>另一个有趣的探索是,在特定的页面中使用不同的布局。</p><p>比如专门为站点首页设置一个独一无二的布局:</p><figure class="highlight yaml"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="string">---</span> </span><br><span class="line"><span class="attr">layout:</span> <span class="string">minimal</span> </span><br><span class="line"><span class="meta">---</span></span><br></pre></td></tr></tbody></table></figure><p>我们需要有单独的布局文件,我将它们放在<code>src/layouts</code>文件夹中:</p><figure class="highlight coffeescript"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">src<span class="regexp">/layouts/</span> </span><br><span class="line"> <span class="keyword">default</span>.ejs </span><br><span class="line"> mininal.ejs</span><br></pre></td></tr></tbody></table></figure><p>如果<code>front matter</code>出现了布局属性,我们将利用<code>layouts</code>文件夹中同名模板文件进行渲染; 如果未设置,则利用默认模板渲染。</p><figure class="highlight javascript"><table><tbody><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> layout = pageData.attributes.layout || <span class="string">'default'</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">return</span> ejsRenderFile(<span class="string">`<span class="subst">${srcPath}</span>/layouts/<span class="subst">${layout}</span>.ejs`</span>, </span><br><span class="line"> <span class="built_in">Object</span>.assign({}, templateConfig, { <span class="attr">body</span>: pageContent })</span><br><span class="line">)</span><br></pre></td></tr></tbody></table></figure><p>即使添加了这些新特性,构建脚本也才只有<code>60</code>行。</p><h3 id="下一步"><a href="#下一步" class="headerlink" title="下一步"></a>下一步</h3><p>如果你想更进一步,可以添加一些不难的附加功能:</p><ul><li><p>可热重载的调试服务器</p><p>你可以使用像<a href="https://www.npmjs.com/package/live-server" target="_blank" rel="external nofollow noopener noreferrer"><strong>live-server</strong></a> (内置自动重新加载) 或 <a href="https://www.npmjs.com/package/chokidar" target="_blank" rel="external nofollow noopener noreferrer"><strong>chokidar</strong></a> (观察文件修改以自动触发构建脚本)这样的模块去完成。</p></li><li><p>自动部署</p><p>添加脚本以将站点部署到<code>GitHub Pages</code>等常见的托管服务,或仅通过<code>SSH</code>(使用<code>scp</code>或<code>rsync</code>等命令)将文件上传到你自己的服务器上。</p></li><li><p>支持 CSS/JS 预处理器</p><p>在静态文件被复制到站点文件前,增加一些预处理器(SASS 编译为 CSS,ES6 编译为 ES5 等)。</p></li><li><p>更好的日志打印</p><p>添加一些 <code>console.log</code> 日志输出 来更好地分析发生了什么。</p><p>你可以使用<code>chalk</code>包来完善这件事。</p></li></ul><p>反馈? 有什么建议吗? 请随时发表评论或与我联系!</p><hr><h2 id="结束语"><a href="#结束语" class="headerlink" title="结束语"></a>结束语</h2><p>这个文章的完整示例可以在这里找到:<a href="https://github.com/doug2k1/nanogen/tree/legacy。" target="_blank" rel="external nofollow noopener noreferrer">https://github.com/doug2k1/nanogen/tree/legacy。</a></p><p>一段时间后,我决定将项目转换为<code>CLI</code>模块,以使其更易于使用,它位于上面链接的<code>master</code>分支中。</p><p>译者:</p><p>今日本想写一篇<a href="https://github.com/panjf2000/ants/" target="_blank" rel="external nofollow noopener noreferrer">ants</a>(一个高性能的<code>goroutine</code>池)源码解析,奈何环境太吵,静不下心,遂罢。</p><p>这是一篇我前些日子无意间看到的文章,虽然是<code>17</code>年的文章,在读完之后仍对我产生了一些思考。</p><p>希望这篇文章对你有所帮助。</p><p>转载本站文章请注明作者和出处 <a href="http://tomotoes.com">一个坏掉的番茄</a>,请勿用于任何商业用途。 </p>