Last active
September 1, 2026 04:30
-
-
Save Mabbs/3db745dc403d752a1d59d3e06938e310 to your computer and use it in GitHub Desktop.
possg - 单脚本静态站点生成器 (Single-file Static Site Generator)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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(/&/, "\\&"); gsub(/</, "\\<") | |
| gsub(/>/, "\\>"); gsub(/"/, "\\"") | |
| }' | |
| } | |
| # 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(/&/, "\\&", s); gsub(/</, "\\<", s); gsub(/>/, "\\>", s) | |
| return s | |
| } | |
| function attresc(s) { | |
| s = esc(s); gsub(/"/, "\\"", 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 "<" | |
| } | |
| } else { | |
| r = r "<" | |
| } | |
| } else if (mp == "&") { | |
| if (match(st, /^#?[A-Za-z0-9]+;/)) { | |
| r = r "&" substr(st, 1, RLENGTH) | |
| st = substr(st, RLENGTH + 1) | |
| } else { | |
| r = r "&" | |
| } | |
| } else if (mp == ">") { | |
| r = r ">" | |
| } | |
| } | |
| 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] /  | |
| # 成功返回 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 | |
| } | |
| # 把 [文字](链接) 还原成"文字", 直接丢弃 | |
| 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 " →</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) "\">← " 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 " →</a>" > f | |
| print "</nav>" > f | |
| } | |
| close(f) | |
| } | |
| print pages | |
| } | |
| function pagename(i) { return (i <= 1) ? "index.html" : "page-" i ".html" } | |
| function esc(s) { | |
| gsub(/&/, "\\&", s); gsub(/</, "\\<", s); gsub(/>/, "\\>", 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(/&/, "\\&", s); gsub(/</, "\\<", s); gsub(/>/, "\\>", 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(/&/, "\\&", s); gsub(/</, "\\<", s); gsub(/>/, "\\>", 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(/\]\]>/, "]]>", 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(/&/, "\\&", s); gsub(/</, "\\<", s); gsub(/>/, "\\>", s) | |
| gsub(/"/, "\\"", 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(/&/, "\\&", s); gsub(/</, "\\<", s); gsub(/>/, "\\>", 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>© {{YEAR}} {{SITE_AUTHOR}} · 由 <a href="{{SSG_HOME}}">{{GENERATOR}}</a> 生成</p> | |
| <p class="feeds"><a href="{{ROOT}}/feed.xml">RSS</a> · <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 ' · %s %s' "$T_BY" "$(esc_html "$f8")" | |
| [ -n "$f14" ] && printf ' · %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">← %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 →</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 · $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 · $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 · $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 · $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") · $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