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());
}