如何打造一款静态开源站点搭建工具 - docsite

2018-09-10 13:51:52 +08:00
 xcold

如何打造一款静态开源站点搭建工具

本文涉及的所有代码可以在 docsite 的开源代码仓库 https://github.com/txd-team/docsite 中找到,如果对你有所帮助,欢迎 Star 关注我们。

背景

诸如 github pages 的静态托管服务的兴起,静态生成+托管对托管环境要求低、维护简单、可配合版本控制,但又灵活多变,这一系列的优点,使得静态站点生成器在近年有了极大的发展,涌现出一系列优秀的静态站点生成器。

笔者负责整个部门的开源站点搭建,要想提高开发效率,没有一个称手的工具是不行的。搭建站点的工具需要满足如下要求:

考察了一系列的开源静态站点搭建工具,总有这样或者那样的功能不满足需求,于是就着手打造一款静态站点搭建工具。因主要用于静态站点的搭建,且支持 markdown 文档,笔者为该工具起名为 docsite。

技术方案选型

docsite 工具

从整体上来说,docsite 需要能够支持站点项目的初始化、本地开发和本地构建。而对于前端同学来说,采用 NodeJS 实现一个命令行工具,不失为一个有效的方法。为此,docsite 需要对应实现至少三个命令,docsite initdocsite startdocsite build

内置模板

起初,采用的方案是 react+hashRouter 的纯 js 渲染逻辑。这种的优点在于简单,在实际项目开发中 docsite 和站点项目的交互简单。但缺点也很明显,hashRouter 是通过 hash 值来区分不同的页面的,Google 搜索引擎对于#后面的标记是会忽略的,即使采用 hashBang (#!开头的 hash 路由),Google 爬虫能够识别这种标记。比如www.example.com/ajax.html#!key=value这样的一个地址,谷歌爬虫将其识别为www.example.com/ajax.html?_escaped_fragment_=key=value。但要想爬虫收录该地址,服务端必须为后者的 URL 形式返回一份具体的内容,而对于无后端的静态站点来说,显然是不现实的。

那 browserRouter 可不可以呢? browserRouter 的 url 形式和普通的 url 形式一样,唯一需要解决的是 url 变化后刷新页面时的 404 问题。目前主流的静态托管都提供了自定义 404 页面的功能,即在访问站点的某个地址出现 404 响应码时,能够以自定义的 404 页面作为响应返回给客户端。

似乎看到了一线生机,然而,现实是残酷的。虽然利用这一机制能够实现页面刷新时的空白问题,但是 404 响应码对于搜索引擎而言并不友好,直接影响页面的收录。

那么,前端路由这条路是走不通了,只能走多页的形式。除此以外,静态站点大部分托管在 github pages 上。目前,国内访问速度还是比较慢的,纯 js 渲染的站点,需要先加载完 js 资源后,再进行页面的渲染。在加载 js 的过程中,整个页面是一片空白,影响使用体验。另外,为了让其他人更方便的寻找到你的站点,对 SEO 的支持就显得尤为重要。而国内的搜索引擎百度对 js 渲染的内容的抓取能力简直就是弱鸡。考虑到国内大多数的开发者并没法顺畅地使用 Google 搜索引擎,对于百度搜索引擎的支持就显得十分必要。

react 有一系列的优势:

但为了实现 SEO 和减少白屏时间,就这么不甘心地放弃 React 带来的这些便利性吗?

为了解决上述问题,同时还能使用 React,只好搬出最后一件利器了,ReactDOMServer.render,借用服务端渲染的概念,在生成最终的多页中插入渲染出的 html 字符串,同时保留 js 文件的引入,从而实现原有的一些交互逻辑。为实现 html 的生成,我们需要借助模板引擎,本项目中采用了 ejs。

技术实现

项目目录

确定好技术方案后,首先需要规划下站点的目录结构。采用 ES6+React 的技术方案,同时需要支持 SEO 和国际化,最终确定下来的模板目录结构如下:

.
├── .babelrc
├── .docsite
├── .eslintrc
├── .gitignore
├── README.md
├── blog
│   ├── en-us
│   └── zh-cn
├── docs
│   ├── en-us
│   └── zh-cn
├── gulpfile.js
├── img
├── package-lock.json
├── package.json
├── redirect.ejs
├── site_config
│   ├── blog.js
│   ├── community.jsx
│   ├── docs.js
│   ├── home.jsx
│   └── site.js
├── src
│   ├── components
│   ├── markdown.scss
│   ├── pages
│   │   ├── blog
│   │   ├── blogDetail
│   │   ├── community
│   │   ├── documentation
│   │   └── home
│   ├── reset.scss
│   └── variables.scss
├── template.ejs
├── utils
│   └── index.js
└── webpack.config.js

现从上至下对主要的文件、文件夹作说明。

.docsite

空文件,用作判断当前项目是否已初始化过。

template.ejs

所有生成的 html 页面的模板,修改对所有页面(除重定向页面)生效。

redirect.ejs

重定向页面模板,可在其中配置重定向逻辑。默认会根据这个模板在项目根目录下生成index.html404.html(用于某些静态托管站点的自定义 404 页面的功能)。

blog

存放博客的 markdown 文档及相关图片资源的目录,分为中、英文两个目录。

docs

存放说明文档的 markdown 文档及相关图片资源的目录,分为中、英文两个目录。

img

存放非 markdown 使用的一些站点的图片,其中 system 中存放一些业务无关的图片。

site_config

存放整个站点的中英文配置数据,其中site.js配置全局的一些数据,其余的文件用于对应pages目录下不同页面的语言包配置。

src

存放源码的位置,其中,markdown.scss为 markdown 文档的样式文件,variable.scss为一些公共 scss 变量,components为公共组件,pages为对应站点的不同页面,utils 中存放一些公共方法。

国际化

国际化分为两部分,分别为 markdown 文档的国际化和站点其余部分的国际化。

markdown 文档主要分为说明文档和博客文档,按照不同的语言版本分别放入zh-cnen-us目录。

通过在site_config目录中配置不同页面对应的语言包,根据不同的语言版本去读取不同的语言文案,从而实现国际化。

文件变更监听

webpack 对 jsx、scss 代码改动的监听占用一个进程。那么 markdown 文件和 ejs 模板的改动该如何处理呢,开启另一个独立的进程?不需要,NodeJS 可以开启子进程,在该进程中实现对 markdown 文档和模板的监听。那么文件监听如何实现呢?

其实 Node.js 标准库中提供 fs.watch 和 fs.watchFile 两个方法用于处理文件监控。但是 fs.watch 和 fs.watchFile 存在以下问题:

为此,需要一款专门用于文件监控的库来弥补这些缺点,而 chokidar 就是完成这项任务不二人选。其使用方法很简单。我们只需要监听文件的添加、修改、删除就可以了。


const watcher = chokidar.watch('file, dir, glob, or array', {
  ignored: /(^|[\/\\])\../,
  persistent: true
});

watcher
  .on('add', path => log(`File ${path} has been added`))
  .on('change', path => log(`File ${path} has been changed`))
  .on('unlink', path => log(`File ${path} has been removed`));

在文件添加、修改、删除时,执行对应的命令就可以了。

markdown 文件解析

元数据

对于 markdown 文件,除了基本的语法,我们还希望能够放置一些额外数据,用来描述 markdown 文件的内容,比如titlekeywordsdescription等,在生成 html 页面时,可以将这些数据注入其中,利于搜索引擎收录页面。为此,我们需要做些约定。

markdown 文档的顶部---(至少三个-)之间的数据会被认为是元数据,一个 key 占用一行,其基本形式如下:

---
title: demo title
keywords: keywords1,keywords2,keywords3
description: some description
---

通过简单的字符串匹配,我们就能够轻松地获取到这些元数据。

转换为 html 字符串

在获取到 markdown 的内容后,如何将 markdown 语法转换为 html 字符串呢?这下轮到markdown-it登场了。它是目前扩展性和活跃度最好的 markdown parser 了。使用方法也很简单:

const Mkit = require('markdown-it');
const hljs = require('highlight.js'); // 用于实现代码高亮 
const md = new Mkit({
  html: true,
  linkify: true,
  highlight: function (str, lang) {
    if (lang && hljs.getLanguage(lang)) {
      try {
        return hljs.highlight(lang, str).value;
      } catch(err) {
        console.log(err)
      }
    }
    return ''; // use external default escaping
  }
})
.use(plugin1)
.use(plugin2);

如果基本语法的解析不满足要求,还可以使用生态中的插件,插件名以markdown-it-开头,进一步完善markdown-it的功能。

最终,一份 markdown 文件会被解析成一个 json 文件,比如/blog/zh-cn/demo.md文档中内容如下:

---
title: demo title
keywords: keywords1,keywords2,keywords3
description: some description
---

## the title

那么经过解析后,则会在/zh-cn/blog/下生成一个demo.json文件,内容如下:

{
  "title": "demo title",
  "keywords": "keywords1,keywords2,keywords3",
  "description": "some description",
  "__html": "<h2>the title</h2>",
  "filename": "demo.md",
}

markdown 文档显示样式及代码高亮

经过 markdown 解析后的 html 字符串,默认带有一些 class。接下来就是为这些 class 指定样式了,其实这些前人早就为我们做好了。https://github.com/sindresorhus/github-markdown-css提供了 github 风格的展示效果。另外,对于代码高亮,https://highlightjs.org/static/demo/有多种丰富的配色供我们选择。

react 转换为 html

前面提到过,为使用 react,同时又要支持 SEO,需要将 react 代码转换成 html 字符串。借助于react-dom/server提供的服务端渲染功能,我们能够轻松地实现 react 到 html 的转换,但是有一些事项需要注意。

在前端代码中,我们使用了大量的 ES6/7 语法,jsx 语法,css 资源,图片资源,最终通过 webpack 配合各种 loader 打包成一个文件最后运行在浏览器环境中。但是在 nodejs 环境下,不支持 import、jsx 这种语法,并且无法识别对 css、image 资源后缀的模块引用,那么要怎么处理这些静态资源呢?我们需要借助相关的工具、插件来使得 Node.js 解析器能够加载并执行这类代码。为此,需要作如下环境配置。

  1. 首先引入 babel-polyfill 这个库来提供 regenerator 运行时和 core-js 来模拟全功能 ES6 环境。
  2. 引入 babel-register,这是一个 require 钩子,会自动对 require 命令所加载的 js 文件进行实时转码。
  3. 引入 css-modules-require-hook,同样是钩子,只针对样式文件。
  4. 引入 asset-require-hook,来识别图片资源,对小于 8K 的图片转换成 base64 字符串,大于 8k 的图片转换成路径引用。

// Provide custom regenerator runtime and core-js
require('babel-polyfill');

// Javascript required hook
require('babel-register')({
    extensions: ['.es6', '.es', '.jsx', '.js'],
    presets: ['es2015', 'react', 'stage-0'],
    plugins: ['transform-decorators-legacy'],
});

// Css required hook
require('css-modules-require-hook')({
    extensions: ['.scss', '.css'],
    preprocessCss: (data, filename) =>
        require('node-sass').renderSync({
            data,
            file: filename
        }).css,
    camelCase: true,
    generateScopedName: '[name]__[local]__[hash:base64:8]'
});

// Image required hook
require('asset-require-hook')({
    extensions: ['jpeg', 'jpg', 'png', 'gif', 'webp'],
    limit: 8000
});

模拟浏览器环境

代码中会使用一些浏览器环境下独有的对象,这样在 node 环境中,就需要模拟下浏览器中的这些对象,否则就会报错。当然jsdom就是为此而生的,其使用方法如下:

const jsdom = require('jsdom');
const { JSDOM } = jsdom;
const dom = new JSDOM('<!doctype html><html><body><head><link/><style></style><script></script></head><script></script></body></html>');
const {window} = dom;
const copyProps = (src, target) => {
    const props = Object.getOwnPropertyNames(src)
        .filter(prop => typeof target[prop] === 'undefined')
        .map(prop => Object.getOwnPropertyDescriptor(src, prop));
    Object.defineProperties(target, props);
}
global.window = window;
global.document = window.document;
global.HTMLElement=window.HTMLElement;
global.navigator = {
    userAgent: 'node.js',
};
copyProps(window, global);

将 window 下的所有对象全部复制到 node 环境下的 global 对象,从而实现在 node 环境下对浏览器环境的模拟。

其他

constructorcomponentWillMountrender等服务端渲染会调用的生命周期方法中,不要出现未定义的或者无法识别的变量和方法,包括其依赖的组件,否则会出现错误。

html 文件生成

每一个独立的页面都需要生成一份 html 文件,因此,我们需要一款模板引擎。docsite 采用了 ejs 作为模板引擎进行渲染。这个模板的内容如下所示:


<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <meta name="keywords" content="<%= keywords %>" />
    <meta name="description" content="<%= description %>" />
    <!-- 网页标签标题 -->
    <title><%= title %></title>
    <link rel="shortcut icon" href="<%= rootPath %>/img/docsite.ico"/>
    <link rel="stylesheet" href="<%= rootPath %>/build/<%= page %>.css" />
</head>
<body>
    <div id="root"><%- __html %></div>
    <script src="https://f.alicdn.com/react/15.4.1/react-with-addons.min.js"></script>
    <script src="https://f.alicdn.com/react/15.4.1/react-dom.min.js"></script>
    <script>
        window.rootPath = '<%= rootPath %>';
  </script>
    <script src="<%= rootPath %>/build/<%= page %>.js"></script>
</body>
</html>

docsite 在构建过程中,会向其中注入一些变量。其中keywordsdescriptiontitle是在 markdown 文件中定义的元数据。rootPath是站点的根路径,这个在后面会有具体描述。page就是对应不同页面的资源,其命名同pages目录下的一级文件夹的名称。__html为注入的 html 字符串,包括 react 转换而来的和 markdown 转换而来的。

__html 的注入

markdown 文件对应的 html 页面,包括页面组件的内容和 markdown 文件转换成的 html 字符串。页面组件优先获取从 props 注入的 html 字符串(由 docsite 在构建时注入,构建出具体的 html 文件)。同时,为保证不同 markdown 文件公用一个 react 页面组件,在实际的浏览器环境中,通过请求工具加载构建生成的 json 文件,从而获取到 markdown 文件对应的 html 字符串。

直接通过 ReactDOMServer.render 渲染出来,生成文件即可。

SEO 及性能

为每个页面,包括 markdown 文件均生成一份 html,不仅解决了搜索引擎收录页面的问题,而且不需要加载完 js 文件就可以展现页面,一举解决了 js 文件加载慢导致的长时间白屏问题。

路径处理

路径规则

由于整个站点支持国际化,所以对于每个可访问路径,都需要以/zh-cn/en-us开头,为此,所有可访问的页面对应的 html 文件均在这两个文件夹下。

路径前缀

当站点部署在一些静态托管站点时,其根路径并不是/。比如 github pages,其根路径一般为/repertory_name/,如果需要部署到多个平台,那么修改资源的访问地址将是个噩梦。为此,docsite 将根路径抽取出来,放置在site_config/site.js中的rootPath字段进行配置,配置规则如下:

站点内的引用地址均以/开头,在最终的处理中,和模板中全局注入的window.rootPath进行拼接,从而得到最终的访问地址。

markdown 文件内的相互引用

有时,一个 markdown 文件需要引用另一个 markdown 文件,如果让用户去指定在站点上线后的实际线上地址,显然是不现实的。可能更习惯的方式是直接按照文件间的相对目录关系进行指定。这些路径的转换不需要在 markdown 转换成 html 字符串中进行。markdown 文件路径和页面路径有如下的对应关系:

/docs/zh-cn/dir/demo.md <=> /zh-cn/docs/dir/demo.html

因此,很容易根据这一转换规则推断出 markdown 文件对应的实际访问路径。再结合rootPath,最终获取到实际的页面访问地址。

重定向

一方面,当分享给别人站点地址的时候,可能需要做一次语言版本的跳转,比如从https://txd-team.github.io/docsite-doc-v1/跳转到https://txd-team.github.io/docsite-doc-v1/zh-cn/。又或者用户访问站点的时候,访问了站点内不存在的一个页面,这时就需要一个404.html页面来进行重定向到正常的页面。

docsite 默认会在项目根目录下根据模板redirect.ejs生成index.html404.html(用于某些静态站点托管平台自定义 404 页面的功能)。redirect.ejs中配置了访问到根目录时的跳转逻辑。 如下所示:

<script>
  window.rootPath = '<%= rootPath %>';
  window.defaultLanguage = '<%= defaultLanguage %>';
  var lang = Cookies.get('docsite_language');
  if (!lang) {
    lang = '<%= defaultLanguage %>';
  }
  window.location = window.rootPath + '/' + lang + '/docs/installation.html';
</script>

自定义页面

docsite 内置模板默认包含首页、文档页、博客列表页、博客详情页、社区页,分别对应src/pages目录下的homedocumentationblogblogDetailcommunity。对于 js 和 css 资源,docsite 在构建时,会将src/pages目录下的文件夹名称作为 js 和 css 资源的名称,在build目录中生成对应的 js 和 css 文件,并通过 ejs 生成 html 页面时注入到页面中去。

结语

目前,docsite 已发布正式版本,服务了部门多个开源站点的搭建,收到了良好的反馈。欢迎有建站需求的朋友使用,说明文档详见 https://txd-team.github.io/docsite-doc-v1/

欢迎关注阿里巴巴 TXD 团队微信公众号哟,更多内容( mei zi )等你来撩~

5289 次点击
所在节点    程序员
4 条回复
ossphil
2018-09-10 15:28:59 +08:00
blogdown 通过 pandoc 对数学公式提供了完美的支持,docsite 能做到这点吗?
xcold
2018-09-10 16:00:49 +08:00
docsite 提供的渲染引擎依赖 markdown-it,可以通过扩展的方式来支持 pandoc

https://www.npmjs.com/package/markdown-it-pandoc-renderer
xcold
2018-09-10 16:23:34 +08:00
@ossphil 这周会发个版本支持下~
xcold
2018-09-13 09:59:03 +08:00
@ossphil 已支持

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/487836

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX