github带火了类似jekyll的静态博客生成器,可以将markdown文件转换为html文件,然后发布到网站上,对喜欢markdown简洁的人来说,简直太棒了,尤其现在大量的网站可以免费托管这种小型的博客,当然,我也是因github pages的服务而接触到它,懒人必备。
但用久了jekyll就觉得不爽,毕竟我对ruby不太熟悉,有时候想定制一些功能稍显不太方便,既然这样,那就自己写一个吧。先思考下静态博客生成器的核心功能是啥?首先是将markdown文件转换为html文件,然后生成首页,最后是生成文章页,这三个功能是最基本的,当然还有一些其他功能,比如watch功能,当markdown文件发生变化时,自动重新生成html文件,这样就不用每次都手动去执行生成器了。markdown转html就不在这写了,毕竟也是一个解释器,有一定代码量,这里就直接使用第三方的markdown组件了,要真想写,其实也可以使用字符串替代来将就一下,但这里先不考虑这个。
生成首页和文章页我们这里只有三个关键的header、实际内容、footer三类模板,这三个模板是固定的,只是内容不同,所以我们可以将这三个模板放在一个目录下,然后在生成器中读取这三个模板,将标签内容替换掉,最后生成html文件。关于标签部分,我定义了几个$$post$$、$$content$$、$$nav$$、$$header$$和$$footer$$,分别代表文章列表、文章内容、导航、页头和页脚,这样我们就可以在模板中使用这些标签,然后在生成器中将这些标签替换为实际内容。要更强大的标签,则需要定制一门模板语言。
$$post$$可以通过扫描content目录来获取,$$content$$的内容需要经过markdown解释器转换,$$nav$$是自定义的,$$header$$特指templates/header.html文件,$$footer$$则指templates/footer.html。
至于watch功能,单独启动一个进程定时扫描特定目录,发现目录文件更新后,就重新生成html文件,这里使用了swoole的Process类,当然也可以使用inotify来监听文件变化,这里就不展开了。
项目目录结构如下,content/目录用于放置markdown文章;public/目录的文件由生成器生成;templates/目录是模板目录,像页头、页脚、首页、文章页等模板文件都放在这里;composer.json是PHP的版本管理工具,为了简化代码,使用了第三方markdown组件,毕竟本文的重点是静态页面生成,而不是markdown渲染;generator.php是生成器的主要代码。
content/
    2025-02-12-测试.md
public/
templates/
    footer.html
    header.html
    index.html  
    post.html
composer.json
generator.php
文章页 content/2025-02-12-测试.md
这就是一个常规的markdown文件,内容如下。
### PHP
`PHP`是世界上最好的语言,以下是一个简单的`PHP`代码示例,想了解更多请访问[官网](https://www.php.net)。
```php
<?php
echo "Hello world\n";
```
页面尾模板 templates/footer.html
<div style="color: gray; position: fixed; bottom: 10px; left: 50%; transform: translateX(-50%);">copyright xxx</div>
页面头模板 templates/header.html
<div style="text-align: center; margin-bottom: 20px;">$$nav$$</div>
首页模板 templates/index.html
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>静态博客生成器</title>
</head>
<body>
$$header$$
$$post$$
$$footer$$
</body>
</html>
文章页模板 templates/post.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>静态博客生成器</title>
</head>
<body>
$$header$$
$$content$$
$$footer$$
</body>
</html>
composer.json
只需要以下markdown解释器。
{
    "require": {
        "league/commonmark": "^2.6"
    }
}
generator.php
<?php
require 'vendor/autoload.php';
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Exception\CommonMarkException;
use Swoole\Http\Request;
use Swoole\Http\Response;
use Swoole\Http\Server;
use Swoole\Process;
/**
 * 打印函数
 * @param mixed $msg
 * @return void
 */
function println(mixed $msg): void
{
    switch (gettype($msg)) {
        case 'string':
        case 'integer':
        case 'double':
            echo $msg, PHP_EOL;
            break;
        default:
            var_dump($msg);
    }
}
/**
 * 获取当前时间
 * @return string
 */ 
function now(): string
{
    return date('Y-m-d H:i:s');
}
/**
 * 静态站点生成器
 */
class StaticSiteGenerator
{
    private CommonMarkConverter $converter;
    private string $contentDir;
    private string $publicDir;
    private string $templateDir;
    private string $htmlExtension = '.html';
    private string $markdownExtension = '.md';
    private string $indexTemplate;
    private string $headerTemplate = 'header.html';
    private string $footerTemplate = 'footer.html';
    private string $postTemplate = 'post.html';
    private string $postTag = '$$post$$';
    private string $contentTag = '$$content$$';
    private string $navTag = '$$nav$$';
    private string $headerTag = '$$header$$';
    private string $footerTag = '$$footer$$';
    private array $nav = ['首页' => '/', '关于' => '/'];
    private string $linkFormatter = '<a href="%s">%s</a>';
    public function __construct(string $contentDir, string $publicDir, string $templateDir, string $indexTemplate)
    {
        $this->contentDir = $contentDir;
        $this->publicDir = $publicDir;
        $this->templateDir = $templateDir;
        $this->indexTemplate = $indexTemplate;
        $this->converter = new CommonMarkConverter();
    }
    /**
     * markdown 转 html 以及合并页面
     * @throws CommonMarkException
     */
    public function generate(): void
    {
        $files = glob($this->contentDir . "/*$this->markdownExtension");
        // 链接
        $posts = [];
        // 生成文章页
        foreach ($files as $file) {
            $this->generateHtml($file);
            $generatedName = basename($file, $this->markdownExtension) . $this->htmlExtension;
            $posts[] = sprintf($this->linkFormatter, $generatedName, $generatedName);
        }
        $indexContent = file_get_contents($this->templateDir . '/' . $this->indexTemplate);
        $indexContent = str_replace($this->postTag, implode('<br>', $posts), $indexContent);
        $indexContent = $this->composeHtml($indexContent);
        file_put_contents($this->publicDir . '/' . $this->indexTemplate, $indexContent);
    }
    /**
     * 合成完整的页面
     */
    private function composeHtml(string $htmlContent): string
    {
        $headerContent = file_get_contents($this->templateDir . '/' . $this->headerTemplate);
        $nav = '';
        foreach ($this->nav as $name => $href) {
            $nav .= sprintf($this->linkFormatter, $href, $name) . '    ';
        }
        $headerContent = str_replace($this->navTag, $nav, $headerContent);
        $footer = file_get_contents($this->templateDir . '/' . $this->footerTemplate);
        $htmlContent = str_replace($this->headerTag, $headerContent, $htmlContent);
        return str_replace($this->footerTag, $footer, $htmlContent);
    }
    /**
     * 生成文章页 
     * @throws CommonMarkException
     * @throws Exception
     */
    private function generateHtml($markdownFile): void
    {
        $content = file_get_contents($markdownFile);
        $htmlContent = $this->converter->convert($content);
        $template = file_get_contents($this->templateDir . '/' . $this->postTemplate);
        $htmlContent = str_replace($this->contentTag, $htmlContent, $template);
        $htmlContent = $this->composeHtml($htmlContent);
        $basename = basename($markdownFile, $this->markdownExtension);
        $outputFile = $this->publicDir . '/' . $basename . $this->htmlExtension;
        file_put_contents($outputFile, $htmlContent);
        println("Generating $outputFile..." . now());
    }
    /**
     * 文件监控变化重新生成页面
     */
    public function watch(): void
    {
        $process = new Process(function (Process $worker) {
            $lastMtime = 0;
            while (true) {
                $files = glob($this->contentDir . "/*$this->markdownExtension", GLOB_BRACE);
                $files = array_merge($files, glob($this->templateDir . "/*$this->htmlExtension", GLOB_BRACE));
                $maxMtime = 0;
                foreach ($files as $file) {
                    clearstatcache(true, $file);
                    $mtime = filemtime($file);
                    if ($mtime > $maxMtime) {
                        $maxMtime = $mtime;
                    }
                }
                if ($maxMtime > $lastMtime) {
                    $lastMtime = $maxMtime;
                    $this->generate();
                }
                sleep(2);
            }
        });
        $process->start();
    }
}
try {
    $contentDir = __DIR__ . '/content';
    $publicDir = __DIR__ . '/public';
    $templateDir = __DIR__ . '/templates';
    $indexTemplate = 'index.html';
    $host = '0.0.0.0';
    $port = 10004;
    $generator = new StaticSiteGenerator($contentDir, $publicDir, $templateDir, $indexTemplate);
    $generator->watch();
    // 静态页面访问服务
    $http = new Server($host, $port);
    $http->on('request', function (Request $request, Response $response) use ($publicDir, $indexTemplate) {
        $path = urldecode($request->server['request_uri']);
        $path = $path === '/' ? '/' . $indexTemplate : $path;
        $file = $publicDir . $path;
        println("Requesting $file..." . now());
        if (file_exists($file)) {
            $response->header('Content-Type', 'text/html');
            $response->sendfile($file);
        } else {
            $response->setStatusCode(404);
            $response->end('404 Not Found');
        }
    });
    println("Static site generator server running at http://$host:$port");
    $http->start();
} catch (Throwable $e) {
    println($e->getMessage());
}
运行效果图

首页效果图

文章效果图
