Skip to content

Instantly share code, notes, and snippets.

@Mabbs
Last active September 1, 2026 04:30
Show Gist options
  • Select an option

  • Save Mabbs/3db745dc403d752a1d59d3e06938e310 to your computer and use it in GitHub Desktop.

Select an option

Save Mabbs/3db745dc403d752a1d59d3e06938e310 to your computer and use it in GitHub Desktop.
possg - 单脚本静态站点生成器 (Single-file Static Site Generator)
#!/bin/sh
#==============================================================================
# possg - 单脚本静态站点生成器 (Single-file Static Site Generator)
#
# 目标环境:POSIX Shell + POSIX awk,可在仅有 BusyBox 的最小化嵌入式系统运行。
#
# 许可:MIT
#==============================================================================
SSG_NAME="possg"
SSG_VERSION="1.0.0"
SSG_HOME="https://example.invalid/possg"
# awk 实现可通过环境变量覆盖,例如 SSG_AWK="busybox awk"
AWK=${SSG_AWK:-awk}
#------------------------------------------------------------------------------
# 一、默认配置(可被项目根目录下的 ssg.conf 覆盖)
#------------------------------------------------------------------------------
SITE_TITLE="我的小站"
SITE_DESC="用 POSIX Shell 生成的静态站点"
SITE_URL="http://localhost:8080"
SITE_AUTHOR="anonymous"
SITE_EMAIL=""
SITE_LANG="zh-CN"
POSTS_DIR="posts" # 文章 Markdown 源目录
PAGES_DIR="pages" # 独立页面 Markdown 源目录
STATIC_DIR="static" # 原样拷贝的静态资源目录
TPL_DIR="templates" # 模板目录(header.html / footer.html / style.css)
OUT_DIR="public" # 输出目录
CACHE_DIR=".ssgcache" # 增量构建缓存目录
POSTS_PER_PAGE=8 # 首页每页文章数
FEED_ITEMS=20 # feed 中包含的文章数
FEED_FULL_TEXT=1 # 1=feed 输出全文,0=只输出摘要
# 导航栏:条目之间用空格分隔,每个条目形如 "显示名|相对路径"
SITE_NAV="首页|index.html 归档|archive.html 标签|tags/index.html"
# 界面文案(i18n)
T_READ_MORE="阅读全文"
T_ARCHIVE="全部文章"
T_TAGS="全部标签"
T_TAGGED="标签"
T_NO_POST="暂时还没有文章。"
T_PREV="上一篇"
T_NEXT="下一篇"
T_NEWER="更新的文章"
T_OLDER="更早的文章"
T_POSTED="发表于"
T_BY="作者"
#------------------------------------------------------------------------------
# 二、基础工具函数
#------------------------------------------------------------------------------
info() { printf '%s\n' "$*" >&2; }
warn() { printf 'warning: %s\n' "$*" >&2; }
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
# 载入项目配置文件(简单的 key=value shell 片段)
load_config() {
if [ -f "./ssg.conf" ]; then
. "./ssg.conf"
fi
}
# 字段分隔符:US(0x1F)。用于 shell 侧 read 读取可能含空字段的记录,
# 因为 POSIX read 会把 IFS 里的空白字符(空格/TAB/换行)连续出现折叠成一个,
# 从而吞掉空字段;US 是非空白字符,不会被折叠。
US=$(printf '\037')
# 创建临时工作目录并在退出时清理
W=""
setup_workdir() {
[ -n "$W" ] && return 0
W="${TMPDIR:-/tmp}/possg.$$"
rm -rf "$W"
mkdir -p "$W" || die "无法创建临时目录 $W"
trap 'rm -rf "$W"' EXIT
trap 'rm -rf "$W"; exit 130' INT
trap 'rm -rf "$W"; exit 143' TERM
trap 'rm -rf "$W"; exit 129' HUP
emit_awk_programs
}
# HTML 文本转义(用于 shell 侧的少量字符串)
esc_html() {
printf '%s\n' "$1" | $AWK '{
gsub(/&/, "\\&amp;"); gsub(/</, "\\&lt;")
gsub(/>/, "\\&gt;"); gsub(/"/, "\\&quot;")
print
}'
}
# slug 化:小写化,非 [a-z0-9] 折叠为 "-"。
# 若结果为空(例如纯中文标题),退化为对原字符串按字节做 31 进制哈希,
# 保证 URL 稳定且只含 ASCII。od 为 POSIX 命令,BusyBox 亦提供。
slugify() {
_sl_in=$1
_sl_out=$(printf '%s\n' "$_sl_in" | $AWK '{
s = tolower($0)
gsub(/[^a-z0-9]+/, "-", s)
sub(/^-+/, "", s); sub(/-+$/, "", s)
print s
}')
if [ -z "$_sl_out" ]; then
_sl_out="h$(printf '%s' "$_sl_in" | od -An -tu1 | $AWK '
{ for (i = 1; i <= NF; i++) h = (h * 31 + $i) % 2147483647 }
END { printf "%d", h + 0 }')"
fi
printf '%s' "$_sl_out"
}
# 判断 $1 是否比 $2 新($2 不存在也视为需要更新)。
# 使用 POSIX find -newer,避免非 POSIX 的 test -nt。
is_stale() {
[ -f "$2" ] || return 0
[ -n "$(find "$1" -newer "$2" 2>/dev/null)" ]
}
#------------------------------------------------------------------------------
# 三、内嵌 awk 程序(运行时写入临时目录)
#------------------------------------------------------------------------------
emit_awk_programs() {
write_md_awk
write_meta_awk
write_tpl_awk
write_list_awk
write_archive_awk
write_tags_awk
write_feed_awk
write_nav_awk
}
#--- 3.1 Markdown -> HTML ------------------------------------------------------
write_md_awk() {
cat > "$W/md.awk" <<'__MD_AWK__'
# =============================================================================
# md.awk —— 轻量 Markdown -> HTML 转换器
#
# 解析模型(参考 d.awk):
# * 逐行喂给 filter(),由全局 Mode 决定当前处于哪种块级上下文
# (p / pre / ul / ol / blockquote / table / html)
# * Buf 累积当前块的原始内容,块结束时统一转换并输出
# * push()/pop() 维护 Mode 栈,使块可以嵌套
# * scrub() 负责行内语法(强调、代码、链接、图片、转义、实体、自动链接)
# * render_block() 保存/恢复整套解析器状态,实现引用块内的递归块级解析
#
# 两遍扫描:第一遍收集引用式链接定义(支持前向引用),第二遍真正解析。
# =============================================================================
BEGIN {
Mode = "p"; Buf = ""; Prev = ""
ListLevel = 0; StackTop = 0; Depth = 0
BQ = ""; HtmlBuf = ""; Fence = ""; PreLang = ""
TRow = 0; TRowN = 0; NoTable = 0
# 允许直接透传的行内 HTML 标签白名单(其余 < 一律转义)
InlineTags = "^/?(a|abbr|b|br|cite|code|del|em|i|img|ins|kbd|mark|q|s|samp|small|span|strong|sub|sup|time|u|var|wbr)$"
# 块级 HTML 标签白名单(整块原样透传)
BlockTags = "^/?(address|article|aside|audio|blockquote|canvas|details|div|dl|dd|dt|fieldset|figcaption|figure|footer|form|h1|h2|h3|h4|h5|h6|header|hgroup|hr|iframe|li|main|nav|noscript|ol|p|pre|script|section|style|summary|table|tbody|td|tfoot|th|thead|tr|ul|video)$"
}
{ sub(/\r$/, ""); N++; Line[N] = $0 }
END {
start = 1
# 跳过 YAML 风格 front matter
if (N >= 1 && Line[1] ~ /^---[ \t]*$/) {
for (i = 2; i <= N; i++) {
if (Line[i] ~ /^---[ \t]*$/ || Line[i] ~ /^\.\.\.[ \t]*$/) { start = i + 1; break }
}
}
# 第一遍:登记引用式链接定义 [ref]: url "title"
fence = 0
for (i = start; i <= N; i++) {
s = Line[i]
if (s ~ /^[ \t]*```/ || s ~ /^[ \t]*~~~/) { fence = 1 - fence; continue }
if (fence) continue
if (is_refdef(s)) register_ref(s)
}
# 第二遍:块级解析
out = ""
for (i = start; i <= N; i++) out = out filter(Line[i])
out = out flush()
printf "%s", out
}
# --------------------------------------------------------------------------
# 通用小工具
# --------------------------------------------------------------------------
function trim(s) { sub(/^[[:space:]]+/, "", s); sub(/[[:space:]]+$/, "", s); return s }
function esc(s) {
gsub(/&/, "\\&amp;", s); gsub(/</, "\\&lt;", s); gsub(/>/, "\\&gt;", s)
return s
}
function attresc(s) {
s = esc(s); gsub(/"/, "\\&quot;", s)
return s
}
function strip_tags(s) { gsub(/<[^>]*>/, "", s); return s }
function strip_md(s) { gsub(/[*_`~]/, "", s); return s }
# 去掉空白后判断是否由某种字符重复 n 次以上组成
function squeeze(s) { gsub(/[ \t]/, "", s); return s }
function slug_of(s) {
s = tolower(s)
gsub(/&[#a-z0-9]*;/, "", s)
gsub(/[^a-z0-9]+/, "-", s)
sub(/^-+/, "", s); sub(/-+$/, "", s)
return s
}
function push(m) { Stack[StackTop] = Mode; StackTop = StackTop + 1; Mode = m }
function pop() { StackTop = StackTop - 1; Mode = Stack[StackTop]; Buf = "" }
# --------------------------------------------------------------------------
# 引用式链接定义
# --------------------------------------------------------------------------
function is_refdef(s) {
if (s !~ /^[ \t]*\[[^]]+\][ \t]*:[ \t]*[^ \t]/) return 0
return 1
}
function register_ref(s, k, v, p, ttl, d) {
sub(/^[ \t]*\[/, "", s)
p = index(s, "]")
if (p == 0) return
k = tolower(trim(substr(s, 1, p - 1)))
v = substr(s, p + 1)
sub(/^[ \t]*:[ \t]*/, "", v)
ttl = ""
if (match(v, /[ \t]+["'(]/)) {
ttl = substr(v, RSTART)
v = substr(v, 1, RSTART - 1)
sub(/^[ \t]*["'(]/, "", ttl)
sub(/["')][ \t]*$/, "", ttl)
}
v = trim(v)
if (v ~ /^<.*>$/) v = substr(v, 2, length(v) - 2)
RefUrl[k] = v
RefTitle[k] = ttl
}
# --------------------------------------------------------------------------
# 块级状态机
# --------------------------------------------------------------------------
function flush( r) {
r = ""
while (1) {
if (Mode == "p") {
r = r flush_para()
break
} else if (Mode == "pre") {
r = r end_pre(); pop()
} else if (Mode == "ul" || Mode == "ol") {
r = r end_list(); pop()
} else if (Mode == "blockquote") {
r = r end_bq(); pop()
} else if (Mode == "html") {
r = r HtmlBuf; HtmlBuf = ""; pop()
} else if (Mode == "table") {
if (TRow == 1) { tmp = TblRows[1]; pop(); NoTable = 1; r = r filter(tmp); NoTable = 0 }
else { r = r end_table(); pop() }
} else {
break
}
}
return r
}
function flush_para( r) {
r = ""
if (trim(Buf) != "") r = "<p>" scrub(trim(Buf)) "</p>\n"
Buf = ""
return r
}
function filter(st, res, tmp, lvl, txt, ind, item, sq, isHR, isS1, isS2, isBH) {
res = ""
# ---------------- 段落 / 顶层上下文 ----------------
if (Mode == "p") {
sq = squeeze(st)
isS1 = (trim(Buf) != "" && sq ~ /^==*$/)
isS2 = (trim(Buf) != "" && sq ~ /^---*$/)
isHR = (sq ~ /^\*\*\**$/ || sq ~ /^----*$/ || sq ~ /^____*$/)
isBH = (trim(Buf) == "" && st ~ /^[ \t]*</ && is_block_html(st))
if (trim(Buf) == "" && is_refdef(st)) {
# 引用式链接定义:第一遍已登记,这里直接吃掉
res = ""
} else if (st ~ /^[ \t]*```/ || st ~ /^[ \t]*~~~/) {
# 围栏代码块开始
res = flush_para()
match(st, /^[ \t]*(```|~~~)/)
Fence = trim(substr(st, RSTART, RLENGTH))
tmp = substr(st, RSTART + RLENGTH)
gsub(/[ \t`~]/, "", tmp)
PreLang = tmp
Buf = ""
push("pre")
} else if (st ~ /^[ \t]*<!--more-->[ \t]*$/) {
# 摘要分隔标记(供首页/feed 截断)
res = flush_para() "<!--more-->\n"
} else if (trim(Buf) == "" && match(st, /^( |\t)/)) {
# 缩进代码块。先保存 RLENGTH:任何函数调用都可能覆盖 RSTART/RLENGTH。
ind = RLENGTH
res = flush_para()
Fence = ""; PreLang = ""
Buf = substr(st, ind + 1)
push("pre")
} else if (isBH) {
res = flush_para()
HtmlBuf = st "\n"
push("html")
} else if (isS1) {
res = setext(1)
} else if (isS2) {
res = setext(2)
} else if (isHR) {
res = flush_para() "<hr>\n"
} else if (st ~ /^[ \t]*#/) {
# ATX 标题
res = flush_para()
tmp = st
sub(/^[ \t]*/, "", tmp)
match(tmp, /^#+/)
lvl = RLENGTH
txt = substr(tmp, lvl + 1)
sub(/[ \t]*#*[ \t]*$/, "", txt)
res = res heading(lvl, trim(txt))
Buf = ""
} else if (st ~ /^[ \t]*>/) {
# 引用块
res = flush_para()
BQ = strip_bq(st)
push("blockquote")
} else if (match(st, /^[ \t]*([*+-]|[0-9][0-9]*[.)])[ \t]/)) {
# 列表开始
res = flush_para()
match(st, /^[ \t]*/); ind = RLENGTH
ListLevel = 1
indent[1] = ind
ItemOpen[1] = 0
Open[1] = (st ~ /^[ \t]*[*+-][ \t]/) ? "ul" : "ol"
Buf = ""
push(Open[1])
res = res filter(st)
} else if (!NoTable && trim(Buf) == "" && index(st, "|") > 0 && trim(st) != "") {
# 疑似表格:先收下首行,下一行是分隔行才认定为表格
TblRows[1] = st; TRowN = 1; TRow = 1
push("table")
} else if (st ~ /^[ \t]*$/) {
res = flush_para()
} else {
Buf = Buf st "\n"
}
Prev = st
return res
}
# ---------------- 代码块 ----------------
if (Mode == "pre") {
if (Fence != "") {
if (st ~ /^[ \t]*```[ \t]*$/ || st ~ /^[ \t]*~~~[ \t]*$/) {
res = end_pre(); pop()
} else {
Buf = Buf ((Buf != "") ? "\n" : "") st
}
} else {
if (match(st, /^( |\t)/)) {
Buf = Buf "\n" substr(st, RLENGTH + 1)
} else if (st ~ /^[ \t]*$/) {
Buf = Buf "\n"
} else {
res = end_pre(); pop()
res = res filter(st)
}
}
Prev = st
return res
}
# ---------------- 列表 ----------------
if (Mode == "ul" || Mode == "ol") {
if (st ~ /^[ \t]*$/) {
res = end_list(); pop()
} else if (match(st, /^[ \t]*([*+-]|[0-9][0-9]*[.)])[ \t]/)) {
item = substr(st, RSTART + RLENGTH)
match(st, /^[ \t]*/); ind = RLENGTH
if (ind > indent[ListLevel]) {
# 进入更深一层(嵌套列表放在当前 <li> 内部)
ListLevel = ListLevel + 1
indent[ListLevel] = ind
ItemOpen[ListLevel] = 0
Open[ListLevel] = (st ~ /^[ \t]*[*+-][ \t]/) ? "ul" : "ol"
Buf = Buf "\n<" Open[ListLevel] ">"
} else {
while (ListLevel > 1 && ind < indent[ListLevel]) {
if (ItemOpen[ListLevel]) { Buf = Buf "</li>"; ItemOpen[ListLevel] = 0 }
Buf = Buf "\n</" Open[ListLevel] ">"
ListLevel = ListLevel - 1
}
}
if (ItemOpen[ListLevel]) Buf = Buf "</li>"
Buf = Buf "\n" li_open(item)
ItemOpen[ListLevel] = 1
} else {
# 惰性延续行
Buf = Buf "\n" scrub(trim(st))
}
Prev = st
return res
}
# ---------------- 引用块 ----------------
if (Mode == "blockquote") {
if (st ~ /^[ \t]*$/) {
res = end_bq(); pop()
} else if (st ~ /^[ \t]*>/) {
BQ = BQ "\n" strip_bq(st)
} else {
BQ = BQ "\n" st
}
Prev = st
return res
}
# ---------------- 原始 HTML 块 ----------------
if (Mode == "html") {
if (st ~ /^[ \t]*$/) {
res = HtmlBuf; HtmlBuf = ""; pop()
} else {
HtmlBuf = HtmlBuf st "\n"
}
Prev = st
return res
}
# ---------------- 表格 ----------------
if (Mode == "table") {
if (TRow == 1) {
if (is_sep(st)) {
TblRows[2] = st; TRowN = 2; TRow = 2
set_aligns(st)
} else {
tmp = TblRows[1]
pop()
NoTable = 1; res = filter(tmp); NoTable = 0
res = res filter(st)
}
} else {
if (st ~ /^[ \t]*$/) {
res = end_table(); pop()
} else if (index(st, "|") > 0) {
TRowN = TRowN + 1
TblRows[TRowN] = st
} else {
res = end_table(); pop()
res = res filter(st)
}
}
Prev = st
return res
}
Prev = st
return res
}
# Setext 标题:Buf 的最后一行(Prev)作为标题文本
function setext(lvl, res, body) {
body = substr(Buf, 1, length(Buf) - length(Prev) - 1)
Buf = body
res = flush_para()
if (trim(Prev) != "") res = res heading(lvl, trim(Prev))
Buf = ""
return res
}
function heading(lvl, txt, h, id) {
if (lvl > 6) lvl = 6
if (lvl < 1) lvl = 1
h = scrub(txt)
id = slug_of(strip_tags(h))
if (id != "") {
if (id in SeenId) { SeenId[id] = SeenId[id] + 1; id = id "-" SeenId[id] }
else SeenId[id] = 1
}
if (id != "") return "<h" lvl " id=\"" id "\">" h "</h" lvl ">\n"
return "<h" lvl ">" h "</h" lvl ">\n"
}
function li_open(item, t, checked) {
# 支持任务列表 - [x] / - [ ]
if (match(item, /^\[[ xX]\][ \t]/)) {
t = substr(item, 1, RLENGTH)
checked = (index(t, "x") > 0 || index(t, "X") > 0) ? " checked" : ""
item = substr(item, RLENGTH + 1)
return "<li class=\"task\"><input type=\"checkbox\" disabled" checked "> " scrub(trim(item))
}
return "<li>" scrub(trim(item))
}
function end_list( r, tag) {
while (ListLevel > 1) {
if (ItemOpen[ListLevel]) { Buf = Buf "</li>"; ItemOpen[ListLevel] = 0 }
Buf = Buf "\n</" Open[ListLevel] ">"
ListLevel = ListLevel - 1
}
if (ItemOpen[1]) { Buf = Buf "</li>"; ItemOpen[1] = 0 }
tag = Open[1]
r = "<" tag ">" Buf "\n</" tag ">\n"
ListLevel = 0
return r
}
function end_pre( r, c) {
c = Buf
gsub(/\t/, " ", c)
sub(/\n+$/, "", c)
if (PreLang != "") r = "<pre><code class=\"language-" attresc(PreLang) "\">" esc(c) "</code></pre>\n"
else r = "<pre><code>" esc(c) "</code></pre>\n"
PreLang = ""; Fence = ""
return r
}
function strip_bq(s) { sub(/^[ \t]*>[ ]?/, "", s); return s }
function end_bq( r, body) {
body = BQ; BQ = ""
sub(/^\n+/, "", body)
r = "<blockquote>\n" render_block(body) "</blockquote>\n"
return r
}
# 表格分隔行判定:去空白后只允许 | : - 且至少含一个 -
function is_sep(s, t) {
t = squeeze(s)
if (t == "") return 0
if (t !~ /^[|:-]*$/) return 0
if (index(t, "-") == 0) return 0
return 1
}
function split_row(s, arr, t, n, i) {
t = s
sub(/^[ \t]*\|/, "", t)
sub(/\|[ \t]*$/, "", t)
n = split(t, arr, "|")
for (i = 1; i <= n; i++) arr[i] = trim(arr[i])
return n
}
function set_aligns(s, n, a, i, c) {
n = split_row(s, a)
for (i = 1; i <= n; i++) {
c = a[i]
gsub(/[ \t]/, "", c)
if (c ~ /^:.*:$/) Align[i] = " style=\"text-align:center\""
else if (c ~ /:$/) Align[i] = " style=\"text-align:right\""
else if (c ~ /^:/) Align[i] = " style=\"text-align:left\""
else Align[i] = ""
}
AlignN = n
}
function align_of(i) { return (i <= AlignN) ? Align[i] : "" }
function end_table( r, i, j, n, cells) {
r = "<table>\n<thead>\n<tr>"
n = split_row(TblRows[1], cells)
for (i = 1; i <= n; i++) r = r "<th" align_of(i) ">" scrub(cells[i]) "</th>"
r = r "</tr>\n</thead>\n"
if (TRowN >= 3) {
r = r "<tbody>\n"
for (j = 3; j <= TRowN; j++) {
r = r "<tr>"
n = split_row(TblRows[j], cells)
for (i = 1; i <= n; i++) r = r "<td" align_of(i) ">" scrub(cells[i]) "</td>"
r = r "</tr>\n"
}
r = r "</tbody>\n"
}
r = r "</table>\n"
TRow = 0; TRowN = 0
return r
}
function is_block_html(s, t, name) {
t = s
sub(/^[ \t]*/, "", t)
if (t ~ /^<!--/) return 1
if (t ~ /^<!/) return 1
if (!match(t, /^<\/?[A-Za-z][A-Za-z0-9]*/)) return 0
name = substr(t, 2, RLENGTH - 1)
return (tolower(name) ~ BlockTags)
}
# --------------------------------------------------------------------------
# 递归块级渲染:保存/恢复全部解析器状态,供引用块内部使用
# --------------------------------------------------------------------------
function render_block(text, d, i, n, arr, r) {
Depth = Depth + 1
d = Depth
S_Mode[d] = Mode; S_Buf[d] = Buf; S_Prev[d] = Prev
S_LL[d] = ListLevel; S_ST[d] = StackTop
S_Fence[d] = Fence; S_Lang[d] = PreLang
S_BQ[d] = BQ; S_Html[d] = HtmlBuf
S_TR[d] = TRow; S_TRN[d] = TRowN
for (i = 1; i <= ListLevel; i++) {
S_Open[d, i] = Open[i]; S_Ind[d, i] = indent[i]; S_IO[d, i] = ItemOpen[i]
}
for (i = 0; i < StackTop; i++) S_Stk[d, i] = Stack[i]
Mode = "p"; Buf = ""; Prev = ""; ListLevel = 0; StackTop = 0
Fence = ""; PreLang = ""; BQ = ""; HtmlBuf = ""; TRow = 0; TRowN = 0
r = ""
n = split(text, arr, "\n")
for (i = 1; i <= n; i++) r = r filter(arr[i])
r = r flush()
Mode = S_Mode[d]; Buf = S_Buf[d]; Prev = S_Prev[d]
ListLevel = S_LL[d]; StackTop = S_ST[d]
Fence = S_Fence[d]; PreLang = S_Lang[d]
BQ = S_BQ[d]; HtmlBuf = S_Html[d]
TRow = S_TR[d]; TRowN = S_TRN[d]
for (i = 1; i <= ListLevel; i++) {
Open[i] = S_Open[d, i]; indent[i] = S_Ind[d, i]; ItemOpen[i] = S_IO[d, i]
}
for (i = 0; i < StackTop; i++) Stack[i] = S_Stk[d, i]
Depth = Depth - 1
return r
}
# --------------------------------------------------------------------------
# 行内扫描 scrub()
# 左到右扫描特殊标记,逐段拼接结果,避免对已生成的 HTML 再次处理。
# --------------------------------------------------------------------------
function scrub(st, r, a, mp, ms, me, p, inner, url, res, tlen) {
# 硬换行:行尾两个及以上空格,或行尾反斜杠
gsub(/\\\n/, "<br>\n", st)
gsub(/ [ ]+\n/, "<br>\n", st)
sub(/ [ ]+$/, "", st)
r = ""
while (match(st, /\\.|`+|\*\*|__|~~|\*|_|!\[|\[|<|&|>/)) {
a = substr(st, 1, RSTART - 1)
mp = substr(st, RSTART, RLENGTH)
ms = (RSTART > 1) ? substr(st, RSTART - 1, 1) : ""
me = substr(st, RSTART + RLENGTH, 1)
r = r a
st = substr(st, RSTART + RLENGTH)
if (substr(mp, 1, 1) == "\\") {
# 反斜杠转义
r = r esc(substr(mp, 2, 1))
} else if (substr(mp, 1, 1) == "`") {
# 行内代码:以等长反引号收尾
p = index(st, mp)
if (p == 0) { r = r esc(mp); continue }
inner = substr(st, 1, p - 1)
st = substr(st, p + length(mp))
sub(/^ /, "", inner); sub(/ $/, "", inner)
r = r "<code>" esc(inner) "</code>"
} else if (mp == "**" || mp == "__") {
if (mp == "__" && ms ~ /[A-Za-z0-9]/) { r = r mp; continue }
if (me == "" || me ~ /[ \t]/) { r = r mp; continue }
p = find_close(st, mp)
if (p == 0) { r = r mp; continue }
inner = substr(st, 1, p - 1)
st = substr(st, p + 2)
r = r "<strong>" scrub(inner) "</strong>"
} else if (mp == "~~") {
p = find_close(st, mp)
if (p == 0) { r = r mp; continue }
inner = substr(st, 1, p - 1)
st = substr(st, p + 2)
r = r "<del>" scrub(inner) "</del>"
} else if (mp == "*" || mp == "_") {
if (mp == "_" && ms ~ /[A-Za-z0-9]/) { r = r mp; continue }
if (me == "" || me ~ /[ \t]/) { r = r mp; continue }
p = find_close(st, mp)
if (p == 0) { r = r mp; continue }
inner = substr(st, 1, p - 1)
st = substr(st, p + 1)
r = r "<em>" scrub(inner) "</em>"
} else if (mp == "[" || mp == "![") {
res = parse_link(st, (mp == "![") ? 1 : 0)
if (res == "") { r = r ((mp == "![") ? "!" : "") "["; }
else { r = r res; st = substr(st, LK_len + 1) }
} else if (mp == "<") {
if (match(st, /^[A-Za-z][A-Za-z0-9+.-]*:[^ \t<>]*>/)) {
url = substr(st, 1, RLENGTH - 1)
st = substr(st, RLENGTH + 1)
r = r "<a href=\"" attresc(url) "\">" esc(url) "</a>"
} else if (match(st, /^[^ \t<>@]+@[^ \t<>@]+>/)) {
url = substr(st, 1, RLENGTH - 1)
st = substr(st, RLENGTH + 1)
r = r "<a href=\"mailto:" attresc(url) "\">" esc(url) "</a>"
} else if (match(st, /^\/?[A-Za-z][A-Za-z0-9]*[^<>]*>/)) {
# 注意:inline_tag_ok() 内部会调用 match(),从而覆盖 RSTART/RLENGTH,
# 所以必须先把长度存到局部变量再做判断。
tlen = RLENGTH
if (inline_tag_ok(st)) {
r = r "<" substr(st, 1, tlen)
st = substr(st, tlen + 1)
} else {
r = r "&lt;"
}
} else {
r = r "&lt;"
}
} else if (mp == "&") {
if (match(st, /^#?[A-Za-z0-9]+;/)) {
r = r "&" substr(st, 1, RLENGTH)
st = substr(st, RLENGTH + 1)
} else {
r = r "&amp;"
}
} else if (mp == ">") {
r = r "&gt;"
}
}
return r st
}
function inline_tag_ok(s, name) {
if (!match(s, /^\/?[A-Za-z][A-Za-z0-9]*/)) return 0
name = substr(s, 1, RLENGTH)
if (tolower(name) !~ InlineTags) return 0
# 行内原始 HTML 只允许安全的白名单标签;拒绝任何事件处理器(on* =)
# 与 javascript: 协议,防止 XSS。注意 s 包含结尾的 “>”。
if (s ~ /[ \t]on[A-Za-z]+[ \t]*=/) return 0
if (tolower(s) ~ /javascript:/) return 0
return 1
}
# 查找配对的结束标记,跳过前面是空白或反斜杠的伪结束标记
function find_close(s, m, off, p, pre) {
off = 0
while (1) {
p = index(substr(s, off + 1), m)
if (p == 0) return 0
p = p + off
pre = (p > 1) ? substr(s, p - 1, 1) : ""
if (pre != " " && pre != "\t" && pre != "\\") return p
off = p + length(m) - 1
}
}
# 解析 [text](url "title") / [text][ref] / [ref] / ![alt](src)
# 成功返回 HTML 并把消耗的字符数写入全局 LK_len,失败返回 ""
function parse_link(s, isimg, depth, i, c, n, txt, rest, url, ttl, ref, q) {
depth = 1; n = length(s); i = 0
while (i < n) {
i = i + 1
c = substr(s, i, 1)
if (c == "\\") { i = i + 1; continue }
if (c == "[") depth = depth + 1
else if (c == "]") { depth = depth - 1; if (depth == 0) break }
}
if (depth != 0) return ""
txt = substr(s, 1, i - 1)
rest = substr(s, i + 1)
ttl = ""
if (substr(rest, 1, 1) == "(") {
q = index(rest, ")")
if (q == 0) return ""
url = substr(rest, 2, q - 2)
LK_len = i + q
if (match(url, /[ \t]+["'(]/)) {
ttl = substr(url, RSTART)
url = substr(url, 1, RSTART - 1)
sub(/^[ \t]*["'(]/, "", ttl)
sub(/["')][ \t]*$/, "", ttl)
}
url = trim(url)
if (url ~ /^<.*>$/) url = substr(url, 2, length(url) - 2)
} else if (substr(rest, 1, 1) == "[") {
q = index(rest, "]")
if (q == 0) return ""
ref = tolower(trim(substr(rest, 2, q - 2)))
if (ref == "") ref = tolower(trim(txt))
LK_len = i + q
if (!(ref in RefUrl)) return ""
url = RefUrl[ref]; ttl = RefTitle[ref]
} else {
ref = tolower(trim(txt))
if (!(ref in RefUrl)) return ""
LK_len = i
url = RefUrl[ref]; ttl = RefTitle[ref]
}
if (isimg) {
return "<img src=\"" attresc(url) "\" alt=\"" attresc(strip_md(txt)) "\"" \
((ttl != "") ? " title=\"" attresc(ttl) "\"" : "") ">"
}
return "<a href=\"" attresc(url) "\"" \
((ttl != "") ? " title=\"" attresc(ttl) "\"" : "") ">" scrub(txt) "</a>"
}
__MD_AWK__
}
#--- 3.2 front matter 解析 ------------------------------------------------------
write_meta_awk() {
cat > "$W/meta.awk" <<'__META_AWK__'
# =============================================================================
# meta.awk —— 解析文章 front matter,输出可被 shell eval 的变量赋值
#
# 支持两种写法:
# (a) YAML 风格(推荐) (b) bashblog 风格(兼容)
# --- 文章标题
# title: 标题 Tags: a, b
# date: 2026-08-08 10:30 正文...
# tags: a, b
# ---
#
# 日期相关格式全部在 awk 内部计算(Sakamoto 算法求星期),
# 不依赖 GNU date -d / BusyBox date -D 等非 POSIX 扩展。
# =============================================================================
BEGIN {
infm = 0; fmdone = 0; gottitle = 0; bodylines = 0
split("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec", MN, " ")
split("Sun Mon Tue Wed Thu Fri Sat", DN, " ")
}
{ sub(/\r$/, "") }
NR == 1 && $0 ~ /^---[ \t]*$/ { infm = 1; next }
infm && ($0 ~ /^---[ \t]*$/ || $0 ~ /^\.\.\.[ \t]*$/) { infm = 0; fmdone = 1; next }
infm {
p = index($0, ":")
if (p > 0) {
k = substr($0, 1, p - 1)
v = substr($0, p + 1)
sub(/^[ \t]+/, "", k); sub(/[ \t]+$/, "", k)
sub(/^[ \t]+/, "", v); sub(/[ \t]+$/, "", v)
# 去掉可能的引号
if (v ~ /^".*"$/ || v ~ /^'.*'$/) v = substr(v, 2, length(v) - 2)
FM[tolower(k)] = v
}
next
}
{
# front matter 之后的正文(也用于 bashblog 风格回退)
if (!fmdone && !gottitle && $0 !~ /^[ \t]*$/) {
t = $0
sub(/^[ \t]*#+[ \t]*/, "", t)
sub(/[ \t]*#*[ \t]*$/, "", t)
FB_TITLE = t
gottitle = 1
next
}
if (!fmdone && $0 ~ /^[Tt]ags:/) {
FB_TAGS = substr($0, index($0, ":") + 1)
next
}
if (bodylines < 40 && $0 !~ /^[ \t]*$/) {
BODY[++bodylines] = $0
}
}
END {
title = ("title" in FM) ? FM["title"] : FB_TITLE
if (title == "") title = base_name(fname)
tags = ("tags" in FM) ? FM["tags"] : FB_TAGS
if ("categories" in FM && tags == "") tags = FM["categories"]
author = ("author" in FM) ? FM["author"] : defauthor
slug = ("slug" in FM) ? FM["slug"] : ""
draft = "0"
if ("draft" in FM && FM["draft"] ~ /^([Tt]rue|1|[Yy]es)$/) draft = "1"
if ("publish" in FM && FM["publish"] ~ /^([Ff]alse|0|[Nn]o)$/) draft = "1"
date = ("date" in FM) ? FM["date"] : ""
if (date == "") date = date_from_name(fname)
if (date == "") date = today
parse_date(date)
desc = ("description" in FM) ? FM["description"] : ""
if (desc == "") desc = auto_desc()
emit("M_TITLE", clean(title))
emit("M_SLUG", clean(slug))
emit("M_AUTHOR", clean(author))
emit("M_TAGS", clean(norm_tags(tags)))
emit("M_DRAFT", draft)
emit("M_SORT", D_y D_mo D_d D_h D_mi D_s)
emit("M_ISO", D_y "-" D_mo "-" D_d "T" D_h ":" D_mi ":" D_s "Z")
emit("M_DISP", D_y "-" D_mo "-" D_d)
emit("M_RFC", rfc822())
emit("M_YEAR", D_y)
emit("M_DESC", clean(desc))
}
# ---- 输出为 shell 单引号安全字符串 ----
function emit(k, v) {
gsub(/'/, "'\\''", v)
printf "%s='%s'\n", k, v
}
# 清理:制表符转空格(TSV 分隔符安全),压缩空白
function clean(s) {
gsub(/\t/, " ", s)
gsub(/\n/, " ", s)
sub(/^[ \t]+/, "", s); sub(/[ \t]+$/, "", s)
return s
}
function base_name(p, n, a) {
n = split(p, a, "/")
p = a[n]
sub(/\.[Mm][Dd]$/, "", p)
sub(/\.markdown$/, "", p)
sub(/^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-/, "", p)
return p
}
function date_from_name(p, n, a, f) {
n = split(p, a, "/")
f = a[n]
if (match(f, /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/)) return substr(f, RSTART, RLENGTH)
return ""
}
# 标签规范化:逗号分隔、去空白、去空项
function norm_tags(s, n, a, i, r, t) {
# 把各类分隔符统一成半角逗号。注意:busybox/mawk 等字节级 awk 里,
# 字符类 [;,、] 会把全角标点“、”(E3 80 81) /“,”(EF BC 8C) 的“组成字节”
# 误当成集合元素,从而把恰好含这些字节的汉字(如“通”含 0x80、“兼”含 0xBC)
# 错误切开。因此这里用字面量逐个替换多字节标点,避免字节级误匹配。
gsub(/;/, ",", s)
gsub(/,/, ",", s)
gsub(/、/, ",", s)
n = split(s, a, ",")
r = ""
for (i = 1; i <= n; i++) {
t = a[i]
sub(/^[ \t]+/, "", t); sub(/[ \t]+$/, "", t)
if (t == "") continue
r = (r == "") ? t : r "," t
}
return r
}
# 摘要:取正文前若干行,剥掉 markdown 标记
# 注意:POSIX awk 的 gsub() 替换串不支持 \1 反向引用(那是 GNU gensub 的特性),
# 因此这里用 match()+substr() 手工提取链接文字。
# 注意:不做 substr 字节截断,避免把 UTF-8 多字节字符从中间切断。
function auto_desc( i, s, t) {
s = ""
for (i = 1; i <= bodylines && length(s) < 160; i++) {
t = BODY[i]
if (t ~ /^[ \t]*```/ || t ~ /^[ \t]*~~~/) continue
if (t ~ /^[ \t]*</) continue
if (t ~ /^[ \t]*[|>#=-]/) continue
t = strip_links(t)
gsub(/[*_`~]/, "", t)
sub(/^[ \t]+/, "", t); sub(/[ \t]+$/, "", t)
if (t == "") continue
s = (s == "") ? t : s " " t
}
return s
}
# 把 [文字](链接) 还原成"文字",![alt](图) 直接丢弃
function strip_links(t, r, m, p, q, txt) {
r = ""
while (match(t, /!?\[[^]]*\]\([^)]*\)/)) {
r = r substr(t, 1, RSTART - 1)
m = substr(t, RSTART, RLENGTH)
t = substr(t, RSTART + RLENGTH)
if (substr(m, 1, 1) == "!") continue
p = index(m, "["); q = index(m, "]")
txt = substr(m, p + 1, q - p - 1)
r = r txt
}
return r t
}
# 解析日期字符串到 D_* 全局变量
function parse_date(s, rest, r2) {
D_y = "1970"; D_mo = "01"; D_d = "01"; D_h = "00"; D_mi = "00"; D_s = "00"
if (match(s, /[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]/)) {
D_y = substr(s, RSTART, 4)
D_mo = substr(s, RSTART + 5, 2)
D_d = substr(s, RSTART + 8, 2)
rest = substr(s, RSTART + 10)
if (match(rest, /[0-9][0-9]:[0-9][0-9]/)) {
D_h = substr(rest, RSTART, 2)
D_mi = substr(rest, RSTART + 3, 2)
r2 = substr(rest, RSTART + 5)
if (match(r2, /^:[0-9][0-9]/)) D_s = substr(r2, 2, 2)
}
}
}
# Sakamoto 算法计算星期(0=周日)
function dow(y, m, d, a, n) {
n = split("0 3 2 5 0 3 5 1 4 6 2 4", a, " ")
y = y + 0; m = m + 0; d = d + 0
if (m < 3) y = y - 1
return (y + int(y / 4) - int(y / 100) + int(y / 400) + a[m] + d) % 7
}
function rfc822( w) {
w = dow(D_y, D_mo, D_d)
return DN[w + 1] ", " D_d " " MN[D_mo + 0] " " D_y " " D_h ":" D_mi ":" D_s " +0000"
}
__META_AWK__
}
#--- 3.3 模板占位符替换 ---------------------------------------------------------
write_tpl_awk() {
cat > "$W/tpl.awk" <<'__TPL_AWK__'
# tpl.awk —— 把模板中的 {{KEY}} 替换成变量文件里的值。
# 变量文件每行 KEY=VALUE;未定义的键原样保留,避免误吞正文内容。
BEGIN {
while ((getline l < vf) > 0) {
p = index(l, "=")
if (p > 0) V[substr(l, 1, p - 1)] = substr(l, p + 1)
}
close(vf)
}
{ print substitute($0) }
function substitute(s, r, p, q, k) {
r = ""
while ((p = index(s, "{{")) > 0) {
r = r substr(s, 1, p - 1)
s = substr(s, p + 2)
q = index(s, "}}")
if (q == 0) { r = r "{{"; break }
k = substr(s, 1, q - 1)
if (k in V) { r = r V[k]; s = substr(s, q + 2) }
else { r = r "{{" k "}}"; s = substr(s, q + 2) }
}
return r s
}
__TPL_AWK__
}
#--- 3.4 首页列表(含分页) -----------------------------------------------------
write_list_awk() {
cat > "$W/list.awk" <<'__LIST_AWK__'
# list.awk —— 读取排序后的文章索引,生成分页首页主体,输出总页数到 stdout
# 索引字段:1 sort 2 slug 3 iso 4 disp 5 rfc 6 title 7 tags 8 author 9 desc
BEGIN { FS = "\t"; n = 0 }
{ n++; L[n] = $0 }
END {
if (per + 0 <= 0) per = 8
if (n == 0) {
f = outdir "/idx-1.html"
printf "%s", "<p class=\"empty\">" empty_text "</p>\n" > f
close(f)
print 1
exit
}
pages = int((n + per - 1) / per)
for (p = 1; p <= pages; p++) {
f = outdir "/idx-" p ".html"
printf "%s", "" > f
print "<div class=\"post-list\">" > f
lo = (p - 1) * per + 1
hi = p * per
if (hi > n) hi = n
for (i = lo; i <= hi; i++) {
split(L[i], F, "\t")
print "<article class=\"post-item\">" > f
print " <h2 class=\"post-item-title\"><a href=\"posts/" F[2] ".html\">" esc(F[6]) "</a></h2>" > f
print " <p class=\"post-meta\"><time datetime=\"" F[3] "\">" F[4] "</time>" taghtml(F[7]) "</p>" > f
print " <div class=\"post-excerpt\">" > f
ex = exdir "/" F[2] ".html"
while ((getline line < ex) > 0) print line > f
close(ex)
print " </div>" > f
print " <p class=\"read-more\"><a href=\"posts/" F[2] ".html\">" more_text " &rarr;</a></p>" > f
print "</article>" > f
}
print "</div>" > f
if (pages > 1) {
print "<nav class=\"pager\">" > f
if (p > 1) print " <a class=\"pager-prev\" href=\"" pagename(p - 1) "\">&larr; " newer_text "</a>" > f
print " <span class=\"pager-info\">" p " / " pages "</span>" > f
if (p < pages) print " <a class=\"pager-next\" href=\"" pagename(p + 1) "\">" older_text " &rarr;</a>" > f
print "</nav>" > f
}
close(f)
}
print pages
}
function pagename(i) { return (i <= 1) ? "index.html" : "page-" i ".html" }
function esc(s) {
gsub(/&/, "\\&amp;", s); gsub(/</, "\\&lt;", s); gsub(/>/, "\\&gt;", s)
return s
}
# tags 字段格式:slug1|name1,slug2|name2
function taghtml(t, nn, a, i, kv, r) {
if (t == "") return ""
nn = split(t, a, ",")
r = " <span class=\"post-tags\">"
for (i = 1; i <= nn; i++) {
split(a[i], kv, "|")
r = r "<a class=\"tag\" href=\"tags/" kv[1] ".html\">" esc(kv[2]) "</a>"
}
return r "</span>"
}
__LIST_AWK__
}
#--- 3.5 归档页 ----------------------------------------------------------------
write_archive_awk() {
cat > "$W/archive.awk" <<'__ARCH_AWK__'
# archive.awk —— 按年份分组生成归档页主体
BEGIN { FS = "\t"; cury = "" }
{
y = substr($1, 1, 4)
if (y != cury) {
if (cury != "") print "</ul>"
print "<h2 class=\"year\" id=\"y" y "\">" y "</h2>"
print "<ul class=\"archive-list\">"
cury = y
}
printf "%s", "<li><time datetime=\"" $3 "\">" $4 "</time> "
printf "%s", "<a href=\"posts/" $2 ".html\">" esc($6) "</a>"
print "</li>"
}
END {
if (cury != "") print "</ul>"
else print "<p class=\"empty\">" empty_text "</p>"
}
function esc(s) {
gsub(/&/, "\\&amp;", s); gsub(/</, "\\&lt;", s); gsub(/>/, "\\&gt;", s)
return s
}
__ARCH_AWK__
}
#--- 3.6 标签页 ----------------------------------------------------------------
write_tags_awk() {
cat > "$W/tags.awk" <<'__TAGS_AWK__'
# tags.awk —— 读取排序后的标签索引,为每个标签写出主体文件,并输出
# "tagslug<TAB>tagname<TAB>count" 供 shell 逐个套模板
# 输入字段:1 tagslug 2 tagname 3 sort 4 postslug 5 title 6 disp 7 iso
BEGIN { FS = "\t"; cur = ""; nt = 0 }
{
if ($1 != cur) {
if (cur != "") closetag()
cur = $1; curname = $2; cnt = 0
f = outdir "/tag-" cur ".html"
printf "%s", "" > f
print "<ul class=\"archive-list\">" > f
}
cnt++
printf "%s", "<li><time datetime=\"" $7 "\">" $6 "</time> " > f
printf "%s", "<a href=\"../posts/" $4 ".html\">" esc($5) "</a>" > f
print "</li>" > f
}
END {
if (cur != "") closetag()
# 标签总览页
g = outdir "/tagindex.html"
printf "%s", "" > g
if (nt == 0) {
print "<p class=\"empty\">" empty_text "</p>" > g
} else {
print "<ul class=\"tag-cloud\">" > g
for (i = 1; i <= nt; i++) {
print "<li><a class=\"tag\" href=\"" TS[i] ".html\">" esc(TN[i]) "</a> <span class=\"count\">" TC[i] "</span></li>" > g
}
print "</ul>" > g
}
close(g)
}
function closetag( us) {
print "</ul>" > f
close(f)
nt++
TS[nt] = cur; TN[nt] = curname; TC[nt] = cnt
us = sprintf("%c", 31)
print cur us curname us cnt
}
function esc(s) {
gsub(/&/, "\\&amp;", s); gsub(/</, "\\&lt;", s); gsub(/>/, "\\&gt;", s)
return s
}
__TAGS_AWK__
}
#--- 3.7 RSS / Atom ------------------------------------------------------------
write_feed_awk() {
cat > "$W/feed.awk" <<'__FEED_AWK__'
# feed.awk —— 生成 RSS 2.0 或 Atom 1.0
# -v kind=rss|atom -v bodydir= -v exdir= -v items= -v full=
BEGIN {
FS = "\t"
if (kind == "atom") {
print "<?xml version=\"1.0\" encoding=\"utf-8\"?>"
print "<feed xmlns=\"http://www.w3.org/2005/Atom\" xml:lang=\"" x(lang) "\">"
print " <title>" x(title) "</title>"
print " <subtitle>" x(desc) "</subtitle>"
print " <link rel=\"alternate\" type=\"text/html\" href=\"" x(url) "/\"/>"
print " <link rel=\"self\" type=\"application/atom+xml\" href=\"" x(url) "/atom.xml\"/>"
print " <id>" x(url) "/</id>"
print " <updated>" x(now_iso) "</updated>"
print " <generator>" x(gen) "</generator>"
if (author != "") print " <author><name>" x(author) "</name></author>"
} else {
print "<?xml version=\"1.0\" encoding=\"utf-8\"?>"
print "<rss version=\"2.0\" xmlns:atom=\"http://www.w3.org/2005/Atom\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\">"
print "<channel>"
print " <title>" x(title) "</title>"
print " <link>" x(url) "/index.html</link>"
print " <description>" x(desc) "</description>"
print " <language>" x(lang) "</language>"
print " <generator>" x(gen) "</generator>"
print " <lastBuildDate>" x(now_rfc) "</lastBuildDate>"
print " <atom:link href=\"" x(url) "/feed.xml\" rel=\"self\" type=\"application/rss+xml\"/>"
}
}
{
n++
if (n > items + 0) next
purl = url "/posts/" $2 ".html"
src = (full + 0 == 1) ? (bodydir "/" $2 ".html") : (exdir "/" $2 ".html")
body = ""
while ((getline line < src) > 0) body = body line "\n"
close(src)
gsub(/\]\]>/, "]]&gt;", body)
if (kind == "atom") {
print " <entry>"
print " <title>" x($6) "</title>"
print " <link rel=\"alternate\" type=\"text/html\" href=\"" x(purl) "\"/>"
print " <id>" x(purl) "</id>"
print " <published>" x($3) "</published>"
print " <updated>" x($3) "</updated>"
if ($8 != "") print " <author><name>" x($8) "</name></author>"
tagout("atom", $7)
print " <summary>" x($9) "</summary>"
print " <content type=\"html\"><![CDATA[" body "]]></content>"
print " </entry>"
} else {
print " <item>"
print " <title>" x($6) "</title>"
print " <link>" x(purl) "</link>"
print " <guid isPermaLink=\"true\">" x(purl) "</guid>"
print " <pubDate>" x($5) "</pubDate>"
if ($8 != "") print " <dc:creator>" x($8) "</dc:creator>"
tagout("rss", $7)
print " <description><![CDATA[" body "]]></description>"
print " </item>"
}
}
END {
if (kind == "atom") print "</feed>"
else { print "</channel>"; print "</rss>" }
}
function tagout(k, t, nn, a, i, kv) {
if (t == "") return
nn = split(t, a, ",")
for (i = 1; i <= nn; i++) {
split(a[i], kv, "|")
if (k == "atom") print " <category term=\"" x(kv[2]) "\"/>"
else print " <category>" x(kv[2]) "</category>"
}
}
function x(s) {
gsub(/&/, "\\&amp;", s); gsub(/</, "\\&lt;", s); gsub(/>/, "\\&gt;", s)
gsub(/"/, "\\&quot;", s)
return s
}
__FEED_AWK__
}
#--- 3.8 上一篇 / 下一篇 + 预渲染字段 -------------------------------------------
write_nav_awk() {
cat > "$W/nav.awk" <<'__NAV_AWK__'
# nav.awk —— 在排序索引上补充相邻文章信息与预渲染片段
# 输出字段:原 1..9 + 10 prev_slug 11 prev_title 12 next_slug 13 next_title
# + 14 tag_html(供文章页, root="..") + 15 title_escaped
#
# 输出用 US(0x1F) 而不是 TAB 作分隔符:POSIX shell 的 read 会把 IFS 中的空白类
# 字符(含 TAB)连续出现视为单个分隔符,导致空字段被吞掉、后续字段整体错位。
# US 不是空白字符,read 会严格保留空字段。
BEGIN { FS = "\t"; OFS = sprintf("%c", 31) }
{ n++; L[n] = $0; split($0, F, "\t"); S[n] = F[2]; T[n] = F[6]; G[n] = F[7] }
END {
for (i = 1; i <= n; i++) {
ps = (i > 1) ? S[i - 1] : ""
pt = (i > 1) ? T[i - 1] : ""
ns = (i < n) ? S[i + 1] : ""
nt = (i < n) ? T[i + 1] : ""
rec = L[i]
gsub(/\t/, OFS, rec) # 原始 9 个字段也改用 US 分隔
print rec, ps, pt, ns, nt, taghtml(G[i]), esc(T[i])
}
}
function esc(s) {
gsub(/&/, "\\&amp;", s); gsub(/</, "\\&lt;", s); gsub(/>/, "\\&gt;", s)
return s
}
function taghtml(t, nn, a, i, kv, r) {
if (t == "") return ""
nn = split(t, a, ",")
r = "<span class=\"post-tags\">"
for (i = 1; i <= nn; i++) {
split(a[i], kv, "|")
r = r "<a class=\"tag\" href=\"../tags/" kv[1] ".html\">" esc(kv[2]) "</a>"
}
return r "</span>"
}
__NAV_AWK__
}
#------------------------------------------------------------------------------
# 四、默认模板与样式
#------------------------------------------------------------------------------
default_header() {
cat <<'__HDR__'
<!DOCTYPE html>
<html lang="{{LANG}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{PAGE_TITLE}}</title>
<meta name="description" content="{{PAGE_DESC}}">
<meta name="generator" content="{{GENERATOR}}">
<link rel="canonical" href="{{CANONICAL}}">
<link rel="stylesheet" href="{{ROOT}}/style.css">
<link rel="alternate" type="application/rss+xml" title="{{SITE_TITLE}} RSS" href="{{ROOT}}/feed.xml">
<link rel="alternate" type="application/atom+xml" title="{{SITE_TITLE}} Atom" href="{{ROOT}}/atom.xml">
</head>
<body>
<a class="skip" href="#main">跳到正文</a>
<header class="site-header">
<div class="wrap">
<a class="site-title" href="{{ROOT}}/index.html">{{SITE_TITLE}}</a>
<p class="site-desc">{{SITE_DESC}}</p>
<nav class="site-nav">{{NAV}}</nav>
</div>
</header>
<main id="main" class="wrap">
__HDR__
}
default_footer() {
cat <<'__FTR__'
</main>
<footer class="site-footer">
<div class="wrap">
<p>&copy; {{YEAR}} {{SITE_AUTHOR}} &middot; 由 <a href="{{SSG_HOME}}">{{GENERATOR}}</a> 生成</p>
<p class="feeds"><a href="{{ROOT}}/feed.xml">RSS</a> &middot; <a href="{{ROOT}}/atom.xml">Atom</a></p>
</div>
</footer>
</body>
</html>
__FTR__
}
default_css() {
cat <<'__CSS__'
/* possg 默认样式:无依赖、响应式、支持深色模式 */
:root {
--bg: #ffffff; --fg: #24292f; --muted: #57606a; --line: #d8dee4;
--link: #0969da; --accent: #0969da; --code-bg: #f6f8fa; --card: #ffffff;
--max: 46rem;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d1117; --fg: #c9d1d9; --muted: #8b949e; --line: #30363d;
--link: #58a6ff; --accent: #58a6ff; --code-bg: #161b22; --card: #0d1117;
}
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0; background: var(--bg); color: var(--fg);
font: 16px/1.75 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
}
.wrap { max-width: var(--max); margin: 0 auto; padding: 0 1.2rem; }
a { color: var(--link); text-decoration: none; }
a:hover { text-decoration: underline; }
.skip { position: absolute; left: -9999px; }
.skip:focus { left: 1rem; top: 1rem; background: var(--card); padding: .5rem; }
.site-header { border-bottom: 1px solid var(--line); padding: 2rem 0 1.2rem; margin-bottom: 2rem; }
.site-title { font-size: 1.6rem; font-weight: 700; color: var(--fg); }
.site-desc { margin: .3rem 0 .8rem; color: var(--muted); font-size: .92rem; }
.site-nav a { margin-right: 1rem; font-size: .95rem; }
h1, h2, h3, h4, h5, h6 { line-height: 1.3; margin: 2rem 0 .8rem; }
h1 { font-size: 1.9rem; } h2 { font-size: 1.45rem; } h3 { font-size: 1.2rem; }
p, ul, ol, blockquote, table, pre { margin: 0 0 1rem; }
.post-item { padding-bottom: 1.6rem; margin-bottom: 1.6rem; border-bottom: 1px solid var(--line); }
.post-item:last-child { border-bottom: 0; }
.post-item-title { margin: 0 0 .3rem; font-size: 1.3rem; }
.post-meta { color: var(--muted); font-size: .86rem; margin: 0 0 .7rem; }
.post-tags { margin-left: .5rem; }
.tag {
display: inline-block; padding: .05rem .5rem; margin-right: .3rem;
border: 1px solid var(--line); border-radius: 999px; font-size: .78rem;
}
.read-more { margin: .6rem 0 0; font-size: .9rem; }
.post-title { margin-top: 0; }
.post-content img { max-width: 100%; height: auto; }
blockquote {
border-left: .25rem solid var(--line); padding: .1rem 1rem;
color: var(--muted); margin-left: 0;
}
code {
background: var(--code-bg); padding: .15em .35em; border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .88em;
}
pre {
background: var(--code-bg); padding: 1rem; overflow-x: auto;
border-radius: 6px; border: 1px solid var(--line);
}
pre code { background: none; padding: 0; font-size: .85rem; }
table { border-collapse: collapse; width: 100%; font-size: .93rem; display: block; overflow-x: auto; }
th, td { border: 1px solid var(--line); padding: .45rem .7rem; }
th { background: var(--code-bg); }
hr { border: 0; border-top: 1px solid var(--line); margin: 2rem 0; }
li.task { list-style: none; margin-left: -1.2rem; }
.archive-list { list-style: none; padding: 0; }
.archive-list li { padding: .25rem 0; }
.archive-list time { color: var(--muted); font-variant-numeric: tabular-nums; margin-right: .6rem; font-size: .88rem; }
.tag-cloud { list-style: none; padding: 0; display: flex; flex-wrap: wrap; gap: .5rem; }
.tag-cloud li { display: flex; align-items: center; gap: .25rem; }
.tag-cloud .count { color: var(--muted); font-size: .8rem; }
.year { border-bottom: 1px solid var(--line); padding-bottom: .3rem; }
.pager { display: flex; justify-content: space-between; align-items: center; margin: 2rem 0; font-size: .92rem; }
.pager-info { color: var(--muted); }
.post-nav { display: flex; justify-content: space-between; gap: 1rem; margin-top: 2.5rem;
padding-top: 1rem; border-top: 1px solid var(--line); font-size: .92rem; }
.empty { color: var(--muted); }
.site-footer { border-top: 1px solid var(--line); margin-top: 3rem; padding: 1.5rem 0 2.5rem;
color: var(--muted); font-size: .85rem; }
.site-footer p { margin: .2rem 0; }
__CSS__
}
#------------------------------------------------------------------------------
# 五、页面渲染
#------------------------------------------------------------------------------
# 生成导航栏 HTML。$1 = 相对根路径
build_nav() {
_nv_root=$1
_nv_out=""
for _nv_item in $SITE_NAV; do
_nv_name=${_nv_item%%|*}
_nv_href=${_nv_item#*|}
[ "$_nv_name" = "$_nv_item" ] && _nv_href="$_nv_item"
_nv_out="$_nv_out<a href=\"$_nv_root/$_nv_href\">$(esc_html "$_nv_name")</a>"
done
printf '%s' "$_nv_out"
}
# 写出模板变量文件
# $1=页面标题(已转义) $2=root $3=描述(已转义) $4=canonical
make_vars() {
{
printf 'LANG=%s\n' "$SITE_LANG"
printf 'PAGE_TITLE=%s\n' "$1"
printf 'ROOT=%s\n' "$2"
printf 'PAGE_DESC=%s\n' "$3"
printf 'CANONICAL=%s\n' "$4"
printf 'SITE_TITLE=%s\n' "$SITE_TITLE_E"
printf 'SITE_DESC=%s\n' "$SITE_DESC_E"
printf 'SITE_AUTHOR=%s\n' "$SITE_AUTHOR_E"
printf 'SITE_URL=%s\n' "$SITE_URL"
printf 'GENERATOR=%s\n' "$SSG_NAME $SSG_VERSION"
printf 'SSG_HOME=%s\n' "$SSG_HOME"
printf 'YEAR=%s\n' "$BUILD_YEAR"
printf 'BUILD_TIME=%s\n' "$BUILD_ISO"
printf 'NAV=%s\n' "$(build_nav "$2")"
} > "$W/vars"
}
# 组装完整页面:$1=正文文件 $2=输出文件 $3=标题 $4=root $5=描述 $6=canonical
render_page() {
make_vars "$3" "$4" "$5" "$6"
{
$AWK -f "$W/tpl.awk" -v vf="$W/vars" "$W/header.html"
cat "$1"
$AWK -f "$W/tpl.awk" -v vf="$W/vars" "$W/footer.html"
} > "$2"
}
# 准备模板:优先使用项目 templates/ 下的文件,否则落地内置默认模板
prepare_templates() {
if [ -f "$TPL_DIR/header.html" ]; then cp "$TPL_DIR/header.html" "$W/header.html"
else default_header > "$W/header.html"; fi
if [ -f "$TPL_DIR/footer.html" ]; then cp "$TPL_DIR/footer.html" "$W/footer.html"
else default_footer > "$W/footer.html"; fi
if [ -f "$TPL_DIR/style.css" ]; then cp "$TPL_DIR/style.css" "$OUT_DIR/style.css"
else default_css > "$OUT_DIR/style.css"; fi
}
#------------------------------------------------------------------------------
# 六、子命令
#------------------------------------------------------------------------------
cmd_init() {
_tgt=${1:-.}
mkdir -p "$_tgt" || die "无法创建目录 $_tgt"
cd "$_tgt" || die "无法进入目录 $_tgt"
mkdir -p "$POSTS_DIR" "$PAGES_DIR" "$STATIC_DIR" "$TPL_DIR"
if [ ! -f ssg.conf ]; then
cat > ssg.conf <<'__CONF__'
# possg 站点配置(POSIX sh 片段,由 ssg.sh 直接 source)
SITE_TITLE="我的小站"
SITE_DESC="用 POSIX Shell 生成的静态站点"
SITE_URL="http://localhost:8080"
SITE_AUTHOR="anonymous"
SITE_EMAIL=""
SITE_LANG="zh-CN"
POSTS_DIR="posts"
PAGES_DIR="pages"
STATIC_DIR="static"
TPL_DIR="templates"
OUT_DIR="public"
CACHE_DIR=".ssgcache"
POSTS_PER_PAGE=8
FEED_ITEMS=20
FEED_FULL_TEXT=1
# 导航条目格式:"显示名|相对路径",条目之间用空格分隔
SITE_NAV="首页|index.html 归档|archive.html 标签|tags/index.html"
__CONF__
info "已创建 ssg.conf"
fi
if [ ! -f "$POSTS_DIR/2026-01-01-hello-possg.md" ]; then
cat > "$POSTS_DIR/2026-01-01-hello-possg.md" <<'__SAMPLE__'
---
title: 你好,possg
date: 2026-01-01 09:00
tags: 示例, shell, awk
author: anonymous
---
这是一篇示例文章,用来演示 **possg** 支持的 Markdown 语法。
<!--more-->
## 标题与段落
行内语法:*斜体*、**粗体**、~~删除线~~、`行内代码`、[链接](https://example.com)。
## 列表
- 无序项一
- 无序项二
- 嵌套项
- [x] 任务已完成
- [ ] 任务未完成
1. 有序项一
2. 有序项二
## 代码块
```sh
printf '%s\n' "hello, possg"
```
## 引用
> 引用块里也可以有 **强调** 和列表:
>
> - 项目 A
> - 项目 B
## 表格
| 语法 | 说明 |
|:-----|-----:|
| `#` | 标题 |
| `-` | 列表 |
__SAMPLE__
info "已创建示例文章"
fi
if [ ! -f "$PAGES_DIR/about.md" ]; then
cat > "$PAGES_DIR/about.md" <<'__ABOUT__'
---
title: 关于
---
# 关于本站
本站由 possg 生成 —— 一个只用 POSIX Shell 与 POSIX awk 写成的静态站点生成器。
__ABOUT__
info "已创建示例页面"
fi
info "站点骨架已就绪:$(pwd)"
info "下一步:$0 build"
}
cmd_new() {
[ $# -gt 0 ] || die "用法: $0 new <标题> [标签1,标签2]"
_nt=$1
_ntags=${2:-}
mkdir -p "$POSTS_DIR"
_nd=$(date '+%Y-%m-%d')
_ntime=$(date '+%Y-%m-%d %H:%M')
_nslug=$(slugify "$_nt")
_nf="$POSTS_DIR/$_nd-$_nslug.md"
_i=2
while [ -e "$_nf" ]; do
_nf="$POSTS_DIR/$_nd-$_nslug-$_i.md"
_i=$((_i + 1))
done
{
printf '%s\n' "---"
printf 'title: %s\n' "$_nt"
printf 'date: %s\n' "$_ntime"
printf 'tags: %s\n' "$_ntags"
printf 'author: %s\n' "$SITE_AUTHOR"
printf 'draft: %s\n' "false"
printf '%s\n' "---"
printf '\n'
printf '%s\n' "在这里开始写正文。"
printf '\n'
printf '%s\n' "<!--more-->"
printf '\n'
printf '%s\n' "摘要分隔符以下的内容不会出现在首页列表中。"
} > "$_nf"
info "已创建:$_nf"
printf '%s\n' "$_nf"
if [ -n "${EDITOR:-}" ] && [ -t 0 ]; then "$EDITOR" "$_nf"; fi
}
cmd_clean() {
_all=${1:-}
if [ -d "$OUT_DIR" ]; then
rm -rf "$OUT_DIR"
info "已删除输出目录 $OUT_DIR"
fi
if [ "$_all" = "--all" ] || [ "$_all" = "-a" ]; then
if [ -d "$CACHE_DIR" ]; then
rm -rf "$CACHE_DIR"
info "已删除缓存目录 $CACHE_DIR"
fi
fi
}
cmd_rebuild() {
cmd_clean --all
cmd_build "$@"
}
# 处理单篇 Markdown:解析元数据 -> 转换正文 -> 写索引行
# $1 = 源文件
process_post() {
_f=$1
_key=$(printf '%s' "$_f" | tr '/ ' '__')
M_TITLE=""; M_SLUG=""; M_AUTHOR=""; M_TAGS=""; M_DRAFT="0"
M_SORT=""; M_ISO=""; M_DISP=""; M_RFC=""; M_YEAR=""; M_DESC=""
_metacache="$CACHE_DIR/$_key.meta"
if is_stale "$_f" "$_metacache"; then
$AWK -f "$W/meta.awk" -v fname="$_f" -v today="$TODAY" \
-v defauthor="$SITE_AUTHOR" "$_f" > "$_metacache" || die "解析失败: $_f"
fi
. "$_metacache"
if [ "$M_DRAFT" = "1" ]; then
info " 跳过草稿 $_f"
return 0
fi
# 计算最终 slug(front matter 优先,其次文件名)
if [ -n "$M_SLUG" ]; then _slug=$(slugify "$M_SLUG")
else _slug=$(slugify "$(basename_noext "$_f")"); fi
while grep -q "^$_slug\$" "$W/slugs" 2>/dev/null; do
_slug="$_slug-x"
done
printf '%s\n' "$_slug" >> "$W/slugs"
# Markdown -> HTML(带缓存)
_bodycache="$CACHE_DIR/$_key.body"
if is_stale "$_f" "$_bodycache"; then
$AWK -f "$W/md.awk" "$_f" > "$_bodycache" || die "转换失败: $_f"
fi
# 去掉摘要标记后作为正文;标记之前的部分作为摘要
$AWK '!/^<!--more-->[ \t]*$/ { print }' "$_bodycache" > "$W/body/$_slug.html"
$AWK '/^<!--more-->[ \t]*$/ { exit } { print }' "$_bodycache" > "$W/excerpt/$_slug.html"
# 标签:构造 "slug|name,slug|name" 并写入标签索引
_taglist=""
if [ -n "$M_TAGS" ]; then
_oldifs=$IFS
IFS=','
for _t in $M_TAGS; do
IFS=$_oldifs
[ -n "$_t" ] || continue
_tslug=$(slugify "$_t")
_taglist="${_taglist:+$_taglist,}$_tslug|$_t"
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$_tslug" "$_t" "$M_SORT" "$_slug" "$M_TITLE" "$M_DISP" "$M_ISO" >> "$W/tags.tsv"
IFS=','
done
IFS=$_oldifs
fi
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$M_SORT" "$_slug" "$M_ISO" "$M_DISP" "$M_RFC" \
"$M_TITLE" "$_taglist" "$M_AUTHOR" "$M_DESC" >> "$W/index.tsv"
}
basename_noext() {
_bn=${1##*/}
_bn=${_bn%.md}
_bn=${_bn%.markdown}
# 去掉 YYYY-MM-DD- 前缀
printf '%s' "$_bn" | sed 's/^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]-//'
}
cmd_build() {
setup_workdir
[ -d "$POSTS_DIR" ] || die "找不到文章目录 '$POSTS_DIR',先运行: $0 init"
BUILD_ISO=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
BUILD_YEAR=$(date '+%Y')
TODAY=$(date '+%Y-%m-%d %H:%M:%S')
BUILD_RFC=$(printf '%s\n' "$BUILD_ISO" | $AWK '
BEGIN { split("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec", MN, " ")
split("Sun Mon Tue Wed Thu Fri Sat", DN, " ") }
{
y = substr($0,1,4); mo = substr($0,6,2); d = substr($0,9,2)
h = substr($0,12,2); mi = substr($0,15,2); s = substr($0,18,2)
yy = y + 0; mm = mo + 0; dd = d + 0
n = split("0 3 2 5 0 3 5 1 4 6 2 4", a, " ")
if (mm < 3) yy = yy - 1
w = (yy + int(yy/4) - int(yy/100) + int(yy/400) + a[mm] + dd) % 7
printf "%s, %s %s %s %s:%s:%s +0000", DN[w+1], d, MN[mm], y, h, mi, s
}')
SITE_TITLE_E=$(esc_html "$SITE_TITLE")
SITE_DESC_E=$(esc_html "$SITE_DESC")
SITE_AUTHOR_E=$(esc_html "$SITE_AUTHOR")
_url=${SITE_URL%/}
mkdir -p "$CACHE_DIR" "$OUT_DIR" "$OUT_DIR/posts" "$OUT_DIR/tags"
mkdir -p "$W/body" "$W/excerpt" "$W/parts"
: > "$W/index.tsv"
: > "$W/tags.tsv"
: > "$W/slugs"
prepare_templates
# 静态资源原样拷贝
if [ -d "$STATIC_DIR" ]; then
for _s in "$STATIC_DIR"/* "$STATIC_DIR"/.[!.]*; do
[ -e "$_s" ] || continue
cp -r "$_s" "$OUT_DIR/" 2>/dev/null || true
done
fi
info "==> 解析文章"
_n=0
for _f in "$POSTS_DIR"/*.md "$POSTS_DIR"/*.markdown; do
[ -f "$_f" ] || continue
process_post "$_f"
_n=$((_n + 1))
done
info " 共 $_n 篇源文件"
# 按时间倒序
LC_ALL=C sort -r "$W/index.tsv" > "$W/index.sorted"
$AWK -f "$W/nav.awk" "$W/index.sorted" > "$W/index.nav"
info "==> 生成文章页"
_pc=0
while IFS="$US" read -r f1 f2 f3 f4 f5 f6 f7 f8 f9 f10 f11 f12 f13 f14 f15; do
[ -n "$f2" ] || continue
_out="$W/parts/post.html"
{
printf '%s\n' '<article class="post">'
printf '%s\n' '<header class="post-header">'
printf '<h1 class="post-title">%s</h1>\n' "$f15"
printf '<p class="post-meta">%s <time datetime="%s">%s</time>' "$T_POSTED" "$f3" "$f4"
[ -n "$f8" ] && printf ' &middot; %s %s' "$T_BY" "$(esc_html "$f8")"
[ -n "$f14" ] && printf ' &middot; %s' "$f14"
printf '</p>\n'
printf '%s\n' '</header>'
printf '%s\n' '<div class="post-content">'
cat "$W/body/$f2.html"
printf '%s\n' '</div>'
printf '%s\n' '<nav class="post-nav">'
if [ -n "$f10" ]; then
printf '<a class="nav-prev" href="%s.html">&larr; %s</a>\n' "$f10" "$(esc_html "$f11")"
else
printf '%s\n' '<span></span>'
fi
if [ -n "$f12" ]; then
printf '<a class="nav-next" href="%s.html">%s &rarr;</a>\n' "$f12" "$(esc_html "$f13")"
else
printf '%s\n' '<span></span>'
fi
printf '%s\n' '</nav>'
printf '%s\n' '</article>'
} > "$_out"
render_page "$_out" "$OUT_DIR/posts/$f2.html" \
"$f15 &middot; $SITE_TITLE_E" ".." "$(esc_html "$f9")" "$_url/posts/$f2.html"
_pc=$((_pc + 1))
done < "$W/index.nav"
info " 生成 $_pc 篇文章页"
info "==> 生成首页"
_pages=$($AWK -f "$W/list.awk" \
-v outdir="$W/parts" -v exdir="$W/excerpt" -v per="$POSTS_PER_PAGE" \
-v more_text="$T_READ_MORE" -v newer_text="$T_NEWER" \
-v older_text="$T_OLDER" -v empty_text="$T_NO_POST" "$W/index.sorted")
_i=1
while [ "$_i" -le "$_pages" ]; do
if [ "$_i" -eq 1 ]; then
_pf="$OUT_DIR/index.html"; _pt="$SITE_TITLE_E"; _pu="$_url/index.html"
else
_pf="$OUT_DIR/page-$_i.html"; _pt="$SITE_TITLE_E (第 $_i 页)"; _pu="$_url/page-$_i.html"
fi
render_page "$W/parts/idx-$_i.html" "$_pf" "$_pt" "." "$SITE_DESC_E" "$_pu"
_i=$((_i + 1))
done
info " 首页共 $_pages 页"
info "==> 生成归档页"
$AWK -f "$W/archive.awk" -v empty_text="$T_NO_POST" "$W/index.sorted" > "$W/parts/archive.html"
{
printf '<h1>%s</h1>\n' "$T_ARCHIVE"
cat "$W/parts/archive.html"
} > "$W/parts/archive-full.html"
render_page "$W/parts/archive-full.html" "$OUT_DIR/archive.html" \
"$T_ARCHIVE &middot; $SITE_TITLE_E" "." "$SITE_DESC_E" "$_url/archive.html"
info "==> 生成标签页"
LC_ALL=C sort -t ' ' -k1,1 -k3,3r "$W/tags.tsv" > "$W/tags.sorted"
$AWK -f "$W/tags.awk" -v outdir="$W/parts" -v empty_text="$T_NO_POST" \
"$W/tags.sorted" > "$W/taglist"
_tc=0
while IFS="$US" read -r tslug tname tcount; do
[ -n "$tslug" ] || continue
_te=$(esc_html "$tname")
{
printf '<h1>%s: %s</h1>\n' "$T_TAGGED" "$_te"
printf '<p class="post-meta">%s 篇文章</p>\n' "$tcount"
cat "$W/parts/tag-$tslug.html"
} > "$W/parts/tagpage.html"
render_page "$W/parts/tagpage.html" "$OUT_DIR/tags/$tslug.html" \
"$T_TAGGED: $_te &middot; $SITE_TITLE_E" ".." "$_te" "$_url/tags/$tslug.html"
_tc=$((_tc + 1))
done < "$W/taglist"
{
printf '<h1>%s</h1>\n' "$T_TAGS"
cat "$W/parts/tagindex.html"
} > "$W/parts/tagindex-full.html"
render_page "$W/parts/tagindex-full.html" "$OUT_DIR/tags/index.html" \
"$T_TAGS &middot; $SITE_TITLE_E" ".." "$SITE_DESC_E" "$_url/tags/index.html"
info " 生成 $_tc 个标签页"
info "==> 生成独立页面"
_gc=0
if [ -d "$PAGES_DIR" ]; then
for _f in "$PAGES_DIR"/*.md "$PAGES_DIR"/*.markdown; do
[ -f "$_f" ] || continue
M_TITLE=""; M_SLUG=""; M_AUTHOR=""; M_TAGS=""; M_DRAFT="0"
M_SORT=""; M_ISO=""; M_DISP=""; M_RFC=""; M_YEAR=""; M_DESC=""
eval "$($AWK -f "$W/meta.awk" -v fname="$_f" -v today="$TODAY" \
-v defauthor="$SITE_AUTHOR" "$_f")"
[ "$M_DRAFT" = "1" ] && continue
if [ -n "$M_SLUG" ]; then _pslug=$(slugify "$M_SLUG")
else _pslug=$(slugify "$(basename_noext "$_f")"); fi
$AWK -f "$W/md.awk" "$_f" > "$W/parts/page-body.html"
{
printf '%s\n' '<article class="page">'
printf '%s\n' '<div class="post-content">'
cat "$W/parts/page-body.html"
printf '%s\n' '</div>'
printf '%s\n' '</article>'
} > "$W/parts/page-full.html"
render_page "$W/parts/page-full.html" "$OUT_DIR/$_pslug.html" \
"$(esc_html "$M_TITLE") &middot; $SITE_TITLE_E" "." \
"$(esc_html "$M_DESC")" "$_url/$_pslug.html"
_gc=$((_gc + 1))
done
fi
info " 生成 $_gc 个独立页面"
info "==> 生成 feed"
$AWK -f "$W/feed.awk" -v kind=rss \
-v title="$SITE_TITLE" -v desc="$SITE_DESC" -v url="$_url" \
-v lang="$SITE_LANG" -v author="$SITE_AUTHOR" \
-v gen="$SSG_NAME $SSG_VERSION" -v now_iso="$BUILD_ISO" -v now_rfc="$BUILD_RFC" \
-v bodydir="$W/body" -v exdir="$W/excerpt" \
-v items="$FEED_ITEMS" -v full="$FEED_FULL_TEXT" \
"$W/index.sorted" > "$OUT_DIR/feed.xml"
$AWK -f "$W/feed.awk" -v kind=atom \
-v title="$SITE_TITLE" -v desc="$SITE_DESC" -v url="$_url" \
-v lang="$SITE_LANG" -v author="$SITE_AUTHOR" \
-v gen="$SSG_NAME $SSG_VERSION" -v now_iso="$BUILD_ISO" -v now_rfc="$BUILD_RFC" \
-v bodydir="$W/body" -v exdir="$W/excerpt" \
-v items="$FEED_ITEMS" -v full="$FEED_FULL_TEXT" \
"$W/index.sorted" > "$OUT_DIR/atom.xml"
# sitemap.txt:简单的 URL 清单
{
printf '%s/index.html\n' "$_url"
printf '%s/archive.html\n' "$_url"
printf '%s/tags/index.html\n' "$_url"
$AWK -F'\t' -v u="$_url" '{ print u "/posts/" $2 ".html" }' "$W/index.sorted"
} > "$OUT_DIR/sitemap.txt"
info "==> 完成,输出目录: $OUT_DIR"
}
cmd_list() {
setup_workdir
[ -d "$POSTS_DIR" ] || die "找不到文章目录 '$POSTS_DIR'"
TODAY=$(date '+%Y-%m-%d %H:%M:%S')
for _f in "$POSTS_DIR"/*.md "$POSTS_DIR"/*.markdown; do
[ -f "$_f" ] || continue
M_TITLE=""; M_SLUG=""; M_AUTHOR=""; M_TAGS=""; M_DRAFT="0"
M_SORT=""; M_ISO=""; M_DISP=""; M_RFC=""; M_YEAR=""; M_DESC=""
eval "$($AWK -f "$W/meta.awk" -v fname="$_f" -v today="$TODAY" \
-v defauthor="$SITE_AUTHOR" "$_f")"
_flag=" "
[ "$M_DRAFT" = "1" ] && _flag="D"
printf '%s %s %-40s %s\n' "$_flag" "$M_DISP" "$M_TITLE" "$M_TAGS"
done
}
# 仅做 Markdown -> HTML 片段转换,便于调试与单元测试
cmd_md() {
setup_workdir
if [ $# -eq 0 ] || [ "$1" = "-" ]; then
cat > "$W/stdin.md"
$AWK -f "$W/md.awk" "$W/stdin.md"
else
[ -f "$1" ] || die "找不到文件: $1"
$AWK -f "$W/md.awk" "$1"
fi
}
# 打印 front matter 解析结果(shell 变量赋值形式),便于调试与单元测试
cmd_meta() {
setup_workdir
[ $# -gt 0 ] || die "用法: $0 meta <文件>"
[ -f "$1" ] || die "找不到文件: $1"
$AWK -f "$W/meta.awk" -v fname="$1" -v today="$(date '+%Y-%m-%d %H:%M:%S')" \
-v defauthor="$SITE_AUTHOR" "$1"
}
cmd_serve() {
_port=${1:-8080}
[ -d "$OUT_DIR" ] || die "输出目录不存在,先运行: $0 build"
if command -v busybox >/dev/null 2>&1; then
info "http://localhost:$_port (Ctrl-C 退出)"
busybox httpd -f -p "$_port" -h "$OUT_DIR"
elif command -v httpd >/dev/null 2>&1; then
info "http://localhost:$_port (Ctrl-C 退出)"
httpd -f -p "$_port" -h "$OUT_DIR"
else
die "未找到 busybox httpd,可用任意静态服务器托管 $OUT_DIR"
fi
}
cmd_version() { printf '%s %s\n' "$SSG_NAME" "$SSG_VERSION"; }
usage() {
cat <<__USAGE__
$SSG_NAME $SSG_VERSION —— 单脚本静态站点生成器 (POSIX sh + POSIX awk)
用法: $0 <子命令> [参数]
子命令:
init [目录] 在指定目录创建站点骨架(默认当前目录)
new <标题> [标签] 新建一篇带 front matter 的文章,标签用逗号分隔
build 增量构建站点(复用 $CACHE_DIR 缓存)
rebuild 清空输出与缓存后全量重建
clean [--all] 删除输出目录;--all 同时删除缓存目录
list 列出所有文章(D 表示草稿)
md [文件] 仅把 Markdown 转成 HTML 片段输出到 stdout(省略文件读 stdin)
meta <文件> 打印 front matter 解析结果(调试用)
serve [端口] 用 busybox httpd 本地预览(默认 8080)
version 显示版本
help 显示本帮助
目录约定:
ssg.conf 站点配置
$POSTS_DIR/ 文章 Markdown(建议命名 YYYY-MM-DD-slug.md)
$PAGES_DIR/ 独立页面 Markdown(输出到站点根)
$STATIC_DIR/ 静态资源,原样拷贝到输出目录
$TPL_DIR/ header.html / footer.html / style.css(可选覆盖)
$OUT_DIR/ 输出目录
$CACHE_DIR/ 增量构建缓存
环境变量:
SSG_AWK 指定 awk 实现,例如 SSG_AWK="busybox awk"
EDITOR new 之后自动打开编辑器
TMPDIR 临时目录位置
__USAGE__
}
#------------------------------------------------------------------------------
# 七、入口
#------------------------------------------------------------------------------
main() {
load_config
[ $# -gt 0 ] || { usage; exit 1; }
_cmd=$1
shift
case $_cmd in
init) cmd_init "$@" ;;
new|post) cmd_new "$@" ;;
build) cmd_build "$@" ;;
rebuild) cmd_rebuild "$@" ;;
clean) cmd_clean "$@" ;;
list|ls) cmd_list "$@" ;;
md) cmd_md "$@" ;;
meta) cmd_meta "$@" ;;
serve) cmd_serve "$@" ;;
version|-v|--version) cmd_version ;;
help|-h|--help) usage ;;
*) printf 'error: 未知子命令 "%s"\n\n' "$_cmd" >&2; usage; exit 1 ;;
esac
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment