动手写个简易的静态博客生成器

2025/02/12 PHP

github带火了类似jekyll的静态博客生成器,可以将markdown文件转换为html文件,然后发布到网站上,对喜欢markdown简洁的人来说,简直太棒了,尤其现在大量的网站可以免费托管这种小型的博客,当然,我也是因github pages的服务而接触到它,懒人必备。

但用久了jekyll就觉得不爽,毕竟我对ruby不太熟悉,有时候想定制一些功能稍显不太方便,既然这样,那就自己写一个吧。先思考下静态博客生成器的核心功能是啥?首先是将markdown文件转换为html文件,然后生成首页,最后是生成文章页,这三个功能是最基本的,当然还有一些其他功能,比如watch功能,当markdown文件发生变化时,自动重新生成html文件,这样就不用每次都手动去执行生成器了。markdownhtml就不在这写了,毕竟也是一个解释器,有一定代码量,这里就直接使用第三方的markdown组件了,要真想写,其实也可以使用字符串替代来将就一下,但这里先不考虑这个。

生成首页和文章页我们这里只有三个关键的header、实际内容、footer三类模板,这三个模板是固定的,只是内容不同,所以我们可以将这三个模板放在一个目录下,然后在生成器中读取这三个模板,将标签内容替换掉,最后生成html文件。关于标签部分,我定义了几个$$post$$$$content$$$$nav$$$$header$$$$footer$$,分别代表文章列表、文章内容、导航、页头和页脚,这样我们就可以在模板中使用这些标签,然后在生成器中将这些标签替换为实际内容。要更强大的标签,则需要定制一门模板语言。

$$post$$可以通过扫描content目录来获取,$$content$$的内容需要经过markdown解释器转换,$$nav$$是自定义的,$$header$$特指templates/header.html文件,$$footer$$则指templates/footer.html

至于watch功能,单独启动一个进程定时扫描特定目录,发现目录文件更新后,就重新生成html文件,这里使用了swooleProcess类,当然也可以使用inotify来监听文件变化,这里就不展开了。

项目目录结构如下,content/目录用于放置markdown文章;public/目录的文件由生成器生成;templates/目录是模板目录,像页头、页脚、首页、文章页等模板文件都放在这里;composer.jsonPHP的版本管理工具,为了简化代码,使用了第三方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) . '&nbsp;&nbsp;&nbsp;&nbsp;';
        }
        $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());
}

运行效果图

run

首页效果图

homepage

文章效果图

detail

Search

    Table of Contents