From f1586d240ee0ac39492a4078d5689dc100bd9224 Mon Sep 17 00:00:00 2001 From: Fam Zheng Date: Mon, 21 Sep 2026 12:03:38 +0100 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E5=BB=BA=E4=B8=AA=E4=BA=BA=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E5=85=A5=E5=8F=A3=E4=B8=8E=E9=9D=99=E6=80=81=E5=8D=9A?= =?UTF-8?q?=E5=AE=A2=EF=BC=8C=E4=BF=9D=E7=95=99=E6=97=A7=E6=96=87=E7=AB=A0?= =?UTF-8?q?=E5=B9=B6=E6=94=AF=E6=8C=81=E6=9C=AC=E5=9C=B0=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/publish.yml | 36 +---- .gitignore | 7 + .gitmodules | 3 - AGENTS.md | 9 ++ Makefile | 61 +++----- README.md | 74 +++------- apps.json | 25 ++++ assets/favicon.svg | 1 + assets/site.css | 6 + assets/site.js | 30 ++++ build.py | 134 ++++++++++++++++++ deploy.py | 52 +++++++ hugo.toml | 45 ------ .../_markup/render-codeblock-mermaid.html | 12 -- requirements.txt | 2 + tests/test_site.py | 49 +++++++ themes/PaperMod | 1 - 17 files changed, 356 insertions(+), 191 deletions(-) create mode 100644 .gitignore delete mode 100644 .gitmodules create mode 100644 AGENTS.md create mode 100644 apps.json create mode 100644 assets/favicon.svg create mode 100644 assets/site.css create mode 100644 assets/site.js create mode 100644 build.py create mode 100644 deploy.py delete mode 100644 hugo.toml delete mode 100644 layouts/_default/_markup/render-codeblock-mermaid.html create mode 100644 requirements.txt create mode 100644 tests/test_site.py delete mode 160000 themes/PaperMod diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml index ffcac16..33ab2aa 100644 --- a/.gitea/workflows/publish.yml +++ b/.gitea/workflows/publish.yml @@ -1,37 +1,13 @@ -# Published to fam/blog:.gitea/workflows/publish.yml by app/hugo/install. -# Runs on the gnoc-host act_runner (host mode — job executes directly in -# the gnoc user's shell), so we can rsync into /var/gnoc/hugo-public where -# the gnoc-hugo nginx pod reads via hostPath. - -name: publish - +name: verify on: push: branches: [main] - + pull_request: jobs: - build-and-publish: + verify: runs-on: self-hosted steps: - uses: actions/checkout@v4 - with: - submodules: recursive - fetch-depth: 1 - - - name: Install Hugo - run: | - set -euo pipefail - HUGO_VERSION=0.147.6 - TAR="hugo_extended_0.147.6_linux-amd64.tar.gz" - curl -fsSL -o "$RUNNER_TEMP/hugo.tar.gz" \ - "https://github.com/gohugoio/hugo/releases/download/v0.147.6/${TAR}" - tar -xzf "$RUNNER_TEMP/hugo.tar.gz" -C "$RUNNER_TEMP" hugo - echo "$RUNNER_TEMP" >> "$GITHUB_PATH" - - - name: Build - run: hugo --minify --gc - - - name: Publish - run: | - set -euo pipefail - rsync -az --delete public/ /var/gnoc/hugo-public/ + - name: Verify custom static site + run: make test +# Production: make deploy from the local development machine. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1cb3ad7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv/ +public/ +__pycache__/ +.env* +!*.example +test-results/ +themes/ diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 89af1b0..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "themes/PaperMod"] - path = themes/PaperMod - url = https://github.com/adityatelange/hugo-PaperMod.git diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f3bdb45 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +# Working agreements + +- Develop locally under ~/projects/fzc-src/blog; fzc is a deployment host. +- All routine commands belong in Makefile. Run make test before deployment. +- Own static HTML/CSS/JS; do not restore Hugo/PaperMod or add a heavy framework. +- Preserve published Markdown and permanent URLs. Compare live repository history before trusting copied working trees. +- Application links/descriptions belong in apps.json. Never publish secrets, private track links or access tokens. +- Keep Ingress/TLS and unrelated workloads untouched. Deploy only public/; preserve previous releases for rollback. +- Never infer an infrastructure/Gitea upgrade from a request to update source repositories. diff --git a/Makefile b/Makefile index 0471104..c326ccd 100644 --- a/Makefile +++ b/Makefile @@ -1,47 +1,20 @@ SHELL := /bin/bash -POSTS := content/posts - .DEFAULT_GOAL := help - -.PHONY: help new serve build publish clean - +PYTHON := .venv/bin/python +.PHONY: help install build test serve deploy help: - @echo 'targets:' - @echo ' make new SLUG= TITLE="<标题>" [SUMMARY="<摘要>"] # 新建文章' - @echo ' make serve # hugo server -D (需装 hugo)' - @echo ' make build # hugo build 到 public/' - @echo ' make publish MSG="post: " # commit + push, 触发 CI' - @echo ' make clean # rm -rf public/' - -new: - @test -n "$(SLUG)" || { echo 'SLUG required'; exit 1; } - @test -n "$(TITLE)" || { echo 'TITLE required'; exit 1; } - @test ! -e $(POSTS)/$(SLUG).md || { echo "$(POSTS)/$(SLUG).md already exists"; exit 1; } - @DATE="$$(date '+%Y-%m-%dT%H:%M:%S%:z')"; \ - { \ - echo '---'; \ - echo 'title: "$(TITLE)"'; \ - echo "date: $$DATE"; \ - echo 'draft: false'; \ - if [ -n "$(SUMMARY)" ]; then echo 'summary: "$(SUMMARY)"'; fi; \ - echo '---'; \ - echo ''; \ - echo ''; \ - } > $(POSTS)/$(SLUG).md - @echo "created $(POSTS)/$(SLUG).md" - -serve: - hugo server -D - -build: - hugo --minify --gc - -publish: - @test -n "$(MSG)" || { echo 'MSG required (e.g. MSG="post: 你好世界")'; exit 1; } - git add content/ - @git diff --cached --quiet && { echo 'nothing to commit'; exit 1; } || true - git commit -m "$(MSG)" - git push - -clean: - rm -rf public/ + @echo 'make install | build | test | serve | deploy' + @echo '文章: content/posts/*.md;应用: apps.json;样式: assets/site.css' +install: .venv/.installed +.venv/.installed: requirements.txt + uv venv --allow-existing .venv + uv pip install --python $(PYTHON) -r requirements.txt + touch $@ +build: install + $(PYTHON) build.py +test: install + $(PYTHON) -m unittest discover -s tests -v +serve: build + $(PYTHON) -m http.server 4176 --bind 127.0.0.1 --directory public +deploy: test + $(PYTHON) deploy.py diff --git a/README.md b/README.md index 77b1e67..366331b 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,29 @@ -# blog +# famzheng.me -Fam 的博客源码 (Hugo + PaperMod 主题),线上 https://famzheng.me/。 +自己维护的应用入口与博客,纯静态 HTML/CSS/JS。不再依赖 Hugo、PaperMod 或运行时后端。 -## 写一篇文章 +## 开发与发布 -```bash -make new SLUG=hello-world TITLE='你好,世界' SUMMARY='第一篇,随便聊聊' -$EDITOR content/posts/hello-world.md # 写正文 (markdown) -make publish MSG='post: hello world' # commit + push,触发 CI -``` +源码:~/projects/fzc-src/blog(本机);生产:ssh fzc。服务器只接收构建产物。 -约 1 分钟后文章在 `https://famzheng.me/posts//` 上线。 -CI 状态: +- make install:安装固定版本构建依赖。 +- make test:文章完整性、站内链接、RSS、应用清单测试。 +- make serve:http://127.0.0.1:4176 本地预览。 +- make deploy:本机构建测试,rsync 产物,切换指定站点的挂载目录,公网验收。 -## 目录 +应用清单集中在 apps.json:名称、简介、URL、分类、图标、访问提示。不要放密钥、分享令牌或管理初始化链接。 -``` -content/posts/ # 文章 markdown,文件名 = URL slug -hugo.toml # 站点配置 (baseURL, 主题选项, social 链接) -layouts/ # 主题 override (尽量别动) -static/ # favicon 等公开资源 -themes/PaperMod/ # 主题 (git submodule, 尽量别动) -.gitea/workflows/publish.yml # CI: hugo build -> rsync 到 /var/gnoc/hugo-public/ -``` +文章在 content/posts/.md,保留 YAML frontmatter(title/date/summary/draft/tags)。文件名就是永久 URL。原有 11 篇文章全文、/posts/、/about/、标签、归档、分页旧地址、RSS 和 /demos/ 均保留。 -## Frontmatter +assets/ 是独立设计;首页无外部字体、分析脚本或 JS 框架。过滤是渐进增强,无 JS 仍可浏览全部入口。只有含 Mermaid 的文章按需加载固定版本渲染器,失败保留文本。 -每篇文章顶部 YAML: +## 部署边界与回滚 -```yaml ---- -title: "标题" -date: 2026-04-30T10:00:00+01:00 -draft: false -summary: "一句话摘要 (可选,影响列表页和 og:description)" ---- -``` +沿用 gnoc-hugo/hugo 的 nginx、Service、Ingress 与 TLS;名称是历史兼容,不再运行 Hugo。 +每次产物上传到 /var/gnoc/site-releases/-,只更新该 Deployment 的 site hostPath;失败恢复原目录。 +原 /var/gnoc/hugo-public 和每版发布目录保留,可回滚 Deployment 或改回发布时显示的旧路径。 +Gitea、其他子域名、数据库及私人应用数据不受影响。CI 仅校验,不再在服务器重建站点。 -`make new` 会填好这四个字段,你只需要写正文。 +## 文风 -## 文风 (供 AI 参考) - -- 第一人称、口语化,不要 AI 流水账 -- 标题简洁,正文别堆 emoji -- 长度 500-2000 字偏多,宁短勿长 -- 写之前先扫一眼 `content/posts/*.md` 已发文章,对齐 Fam 的语气 - -## 部署机制 - -``` -git push origin main - → Gitea 触发 .gitea/workflows/publish.yml - → host shell act_runner (gnoc 用户, 在 famzheng.com 节点) - → 装 hugo 0.147.6 extended → hugo --minify --gc → rsync public/ /var/gnoc/hugo-public/ - → k8s gnoc-hugo/hugo (nginx) hostPath 直出 - → ingress famzheng.me/ 命中 -``` - -## 本地预览 (可选) - -需要 hugo 0.147.6 extended: - -```bash -make serve # http://localhost:1313, 热重载, draft 也能看 -make build # 一次性 build 到 public/, sanity check -``` +第一人称、自然简洁。原文不为改版而改写;不要替作者捏造经历、观点或项目能力。 diff --git a/apps.json b/apps.json new file mode 100644 index 0000000..1e0df34 --- /dev/null +++ b/apps.json @@ -0,0 +1,25 @@ +[ + {"name":"Music Lab","subtitle":"随身的音乐练习室","description":"找熟悉的旋律,收藏曲谱,用节拍器与鼓机陪你练习。","url":"https://music.famzheng.me/","category":"音乐","icon":"music","featured":true}, + {"name":"Hedgehog","subtitle":"一桌人,一起玩","description":"从扫码入座开始,让小骰子成为聚会里的伙伴。","url":"https://hh.famzheng.me/","category":"互动","icon":"dice","featured":true,"access":"内测"}, + {"name":"在场","subtitle":"把冷场交给骰子","description":"划一下换个性格,拍一拍聊两句。一个有声音的互动实验。","url":"https://dice.famzheng.me/","category":"互动","icon":"spark","featured":true,"access":"内测"}, + {"name":"Read","description":"把文档带进浏览器,留一段专心阅读的时间。","url":"https://read.famzheng.me/","category":"工具","icon":"book"}, + {"name":"Track","description":"每日轨迹与日记,把走过的路留作自己的记录。","url":"https://track.famzheng.me/","category":"生活","icon":"route","access":"私人"}, + {"name":"Notes","description":"随手记下念头,整理值得留下的片段。","url":"https://notes.famzheng.me/","category":"工具","icon":"note"}, + {"name":"Write","description":"一个浏览器里的写作空间。","url":"https://write.famzheng.me/","category":"工具","icon":"note"}, + {"name":"Karaoke","description":"打开歌,给日常加一点伴奏。","url":"https://karaoke.famzheng.me/","category":"音乐","icon":"music"}, + {"name":"Riff","description":"围绕音乐的小站,继续探索旋律与节奏。","url":"https://riff.famzheng.me/","category":"音乐","icon":"music"}, + {"name":"Partiverse","description":"多人小游戏的实验场。","url":"https://party.famzheng.me/","category":"互动","icon":"dice"}, + {"name":"Werewolf","description":"狼人杀,让推理与演技在桌边碰面。","url":"https://werewolf.famzheng.me/","category":"互动","icon":"spark"}, + {"name":"Cube","description":"浏览器里的立方体实验。","url":"https://cube.famzheng.me/","category":"互动","icon":"cube"}, + {"name":"WebGL","description":"光、空间和图形,直接在浏览器里玩。","url":"https://webgl.famzheng.me/","category":"实验","icon":"cube"}, + {"name":"Code Space","description":"换个视角,走进一座由代码组成的三维空间。","url":"/demos/code-space-browser.html","category":"实验","icon":"cube"}, + {"name":"SimpleASM","description":"一个探索汇编与底层计算的小工具。","url":"https://asm.famzheng.me/","category":"实验","icon":"code"}, + {"name":"Articulate","description":"表达与语言练习的小实验。","url":"https://articulate.famzheng.me/","category":"工具","icon":"note"}, + {"name":"Video to Slides","description":"从视频里整理幻灯片。","url":"https://video2slides.famzheng.me/","category":"工具","icon":"book"}, + {"name":"EuDict","description":"查词与语言学习的小入口。","url":"https://eudict.famzheng.me/","category":"工具","icon":"book"}, + {"name":"记账","description":"记录日常收支,整理生活账本。","url":"https://jizhang.famzheng.me/","category":"生活","icon":"note","access":"私人"}, + {"name":"Oil Calculator","description":"一个用来计算和比较用油的小工具。","url":"https://oil.euphon.cloud/","category":"工具","icon":"spark"}, + {"name":"Moochi","description":"一个持续探索中的互动项目。","url":"https://moochi.famzheng.me/","category":"实验","icon":"spark"}, + {"name":"Mochi","description":"AI 与开发工具的实验空间。","url":"https://mochi.famzheng.me/","category":"实验","icon":"code","access":"内测"}, + {"name":"Gitea","description":"代码仓库、版本记录与协作。","url":"https://git.euphon.cloud/","category":"工具","icon":"code","access":"需登录"} +] diff --git a/assets/favicon.svg b/assets/favicon.svg new file mode 100644 index 0000000..e52073f --- /dev/null +++ b/assets/favicon.svg @@ -0,0 +1 @@ +f. diff --git a/assets/site.css b/assets/site.css new file mode 100644 index 0000000..25ff3c6 --- /dev/null +++ b/assets/site.css @@ -0,0 +1,6 @@ +@charset "UTF-8"; +:root{--paper:#f5f3ed;--ink:#28342f;--muted:#69716c;--line:#dedfd6;--accent:#385642;--serif:Georgia,"Noto Serif CJK SC","Songti SC",serif;scroll-behavior:smooth} +*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;-webkit-font-smoothing:antialiased}a{color:inherit;text-decoration:none}button,input{font:inherit}button,a,input{-webkit-tap-highlight-color:transparent}a:focus-visible,button:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:5px}[hidden]{display:none!important}.shell{max-width:1180px;margin:auto;padding:0 48px}.site-header{display:flex;align-items:center;justify-content:space-between;height:104px;border-bottom:1px solid var(--line)}.brand{display:flex;align-items:center;gap:14px;font-size:16px;letter-spacing:-.4px;font-weight:600}.brand-mark{display:grid;place-items:center;width:38px;height:38px;background:var(--accent);color:var(--paper);border-radius:12px;font:italic 32px var(--serif);padding-bottom:4px}.site-header nav{display:flex;gap:34px;font-size:13px;color:var(--muted)}nav a[aria-current]{color:var(--ink)}nav a[aria-current]::after{content:"";display:block;width:4px;height:4px;border-radius:50%;background:var(--accent);margin:5px auto -9px}.hero{display:grid;grid-template-columns:1.4fr 1fr;align-items:center;min-height:465px;padding:62px 0 66px}.eyebrow{font:10px/1.4 ui-monospace,monospace;letter-spacing:2.5px;color:var(--muted);margin:0 0 24px}.hero h1{font:500 clamp(32px,3.3vw,44px)/1.5 var(--serif);letter-spacing:-1.2px;margin:0 0 22px}.period{color:#ab7052}.intro{color:var(--muted);font-size:14px;line-height:1.95;margin-bottom:26px}.text-link{font-size:12px;display:inline-flex;gap:24px;border-bottom:1px solid #a6b2a3;padding-bottom:6px}.sculpture{position:relative;width:320px;height:280px;justify-self:center}.orbit{position:absolute;inset:26px 0;border:1px solid #d9dcca;border-radius:50%;transform:rotate(-25deg);background:radial-gradient(ellipse,rgba(218,224,204,.4),transparent 65%)}.tile{position:absolute;display:grid;place-items:center;width:133px;height:146px;border-radius:28px;box-shadow:8px 21px 30px #384e3219,inset 0 2px 2px #fff9;transform:rotate(-13deg);background:linear-gradient(135deg,#e4ead9,#cbd6be);color:#5c704a;top:45px;left:30px}.tile svg{width:63px;height:63px;stroke-width:1.15}.tile-front{left:152px;top:95px;transform:rotate(13deg);background:linear-gradient(135deg,#f0d7be,#e1b99b);color:#886346;box-shadow:7px 20px 30px #6e4d271b,inset 0 2px 2px #fff9}.orbit-dot{position:absolute;width:11px;height:11px;right:22px;top:46px;background:#b0876b;border-radius:50%;box-shadow:0 0 0 7px #b0876b0d}.tiny-caption{position:absolute;bottom:-5px;left:90px;font:8px ui-monospace,monospace;letter-spacing:2.6px;color:#879080}.section-heading{display:flex;align-items:baseline;justify-content:space-between;gap:20px;margin-bottom:22px}.section-heading h2{font:500 22px var(--serif);margin:0;letter-spacing:1px}.section-heading span,.section-heading>a{font-size:11px;color:var(--muted)}sup{font:10px ui-monospace,monospace;color:#8a9287;vertical-align:top;margin-left:6px}.featured-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}.app-card{display:flex;flex-direction:column;min-width:0;border:1px solid var(--line);border-radius:18px;padding:23px;background:#faf9f580;transition:transform .2s,background .2s,box-shadow .2s}.app-card:hover{transform:translateY(-4px);background:#fffdf8;box-shadow:0 12px 28px #35402e0a}.card-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:23px}.app-icon{width:36px;height:36px;display:grid;place-items:center;background:#e4e8db;border-radius:11px;color:#51654b}.app-icon svg{width:22px;height:22px}.tone-1 .app-icon{background:#eee2d3;color:#8a6952}.tone-2 .app-icon{background:#e4e4ed;color:#777187}.card-arrow{color:#8f968a;font-size:16px;transition:transform .2s}.app-card:hover .card-arrow{transform:translate(2px,-2px)}.card-heading{display:flex;align-items:center;gap:9px}.app-card h3{font-size:16px;font-weight:600;letter-spacing:-.2px;margin:0}.badge{font-size:9px;border:1px solid #8d97844a;padding:0 5px;border-radius:4px;color:var(--muted);white-space:nowrap}.app-card p{font-size:12px;color:var(--muted);line-height:1.8;margin:10px 0 17px}.app-card .subtitle{font-size:14px;color:var(--ink);margin:9px 0 0}.category{font:10px/1.6 sans-serif;color:#899081;margin-top:auto}.featured{padding:27px;background:#e8ecde99;border-color:#dce2d1}.featured.tone-1{background:#eee5d799;border-color:#e5dacb}.featured.tone-2{background:#e6e5ed80;border-color:#dfdee6}.featured .app-icon{background:#f9faf3aa;width:45px;height:45px}.featured .app-icon svg{width:28px;height:28px}.featured .category{display:none}section#apps{margin-top:72px;scroll-margin-top:30px}.app-tools{display:flex;align-items:center;justify-content:space-between;gap:20px;margin:24px 0}.filters{display:flex;gap:4px;flex-wrap:wrap}.filters button{border:0;background:transparent;cursor:pointer;color:var(--muted);font-size:12px;border-radius:99px;padding:7px 14px;transition:background .18s}.filters button[aria-pressed=true]{background:var(--ink);color:var(--paper)}.search input{width:184px;background:transparent;border:0;border-bottom:1px solid var(--line);padding:8px 2px;font-size:12px;color:var(--ink)}.app-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.app-grid .app-card{min-height:208px}.collection-note{font-size:11px;color:var(--muted);margin:22px 0 0}.writing{margin:72px 0 80px}.post-link{display:grid;grid-template-columns:95px 1fr 16px;gap:20px;border-top:1px solid var(--line);padding:23px 0}.post-link time{font:11px/1.9 ui-monospace,monospace;color:var(--muted);padding-top:2px}.post-link h3{font-size:15px;font-weight:500;margin:0 0 7px}.post-link p{color:var(--muted);font-size:12px;margin:0;line-height:1.85}.post-link>span{color:#929c8d}.post-link:hover h3{color:#738556}footer{display:flex;gap:22px;align-items:center;border-top:1px solid var(--line);padding:28px 0 34px;font-size:10px;color:var(--muted)}footer div{display:flex;gap:17px}.footer-note{margin-left:auto}.page-heading{padding:75px 0 42px}.page-heading h1{font:500 clamp(30px,4vw,45px)/1.5 var(--serif);margin:0 0 20px}.page-heading>p:not(.eyebrow){color:var(--muted)}.post-list{margin-bottom:32px}.article{max-width:740px;margin:55px auto 80px}.back-link{font-size:12px;color:var(--muted)}.article header{margin:45px 0}.article time{font:11px ui-monospace,monospace;color:var(--muted)}.article h1{font:500 clamp(27px,3vw,37px)/1.6 var(--serif);letter-spacing:-.5px}.article-summary{color:var(--muted);font-size:14px;border-left:2px solid #c4cdb8;padding-left:20px;line-height:1.9}.prose{font-size:15px;line-height:2;overflow-wrap:anywhere}.prose h2,.prose h3{font-weight:550;margin:2em 0 .6em;line-height:1.7}.prose h2{font-size:22px}.prose h3{font-size:18px}.prose p{margin:1.2em 0}.prose a{color:var(--accent);text-decoration:underline;text-underline-offset:4px}.prose code{font-size:13px;background:#e8e9df;padding:2px 5px;border-radius:4px}.prose pre{overflow:auto;padding:22px;background:#e9ebe3;border-radius:12px;font-size:12px;line-height:1.7}.prose pre code{padding:0;background:none}.prose table{display:block;overflow:auto;border-collapse:collapse;max-width:100%;font-size:13px}.prose th,.prose td{padding:10px 14px;border:1px solid var(--line)}.prose img,.prose svg{max-width:100%;height:auto}.prose blockquote{border-left:2px solid #b6c0ac;margin:20px 0;padding:0 20px;color:var(--muted)}.next-post{margin-top:60px;font-size:12px;color:var(--muted)}.next-post .post-list{margin-top:15px}.about{max-width:680px;margin-bottom:100px}.tag-links{display:flex;gap:24px;flex-wrap:wrap;margin-bottom:80px}.sr-only,.skip:not(:focus){position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}.skip:focus{position:absolute;background:var(--paper);padding:15px;z-index:10} +@media(max-width:900px){.shell{padding:0 30px}.app-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.sculpture{transform:scale(.8);transform-origin:center right;width:270px}.hero h1{font-size:34px}.hero{grid-template-columns:1.4fr 1fr}.featured{padding:22px}.footer-note{display:none}} +@media(max-width:640px){.shell{padding:0 22px}.site-header{height:80px}.brand{font-size:14px;gap:10px}.brand-mark{width:32px;height:32px;font-size:27px}.site-header nav{gap:20px;font-size:12px}.hero{display:block;position:relative;min-height:0;padding:52px 0 46px}.hero h1{font-size:32px;letter-spacing:-.9px;position:relative;z-index:1}.eyebrow{font-size:9px;margin-bottom:20px}.hero .intro{font-size:13px;max-width:270px}.sculpture{display:none}.section-heading h2{font-size:20px}.section-heading span{font-size:10px}.featured-grid{grid-template-columns:1fr;gap:10px}.featured{position:relative;padding:23px 23px 23px 85px;min-height:138px}.featured .card-top{position:absolute;top:25px;left:23px}.featured .card-arrow{display:none}.featured .app-icon{width:43px;height:43px}.featured .subtitle{font-size:12px;margin-top:3px}.featured p{margin:8px 0 0;font-size:12px}.app-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.app-grid .app-card{padding:18px;min-height:216px}.app-card h3{font-size:14px}.card-heading{flex-wrap:wrap;gap:5px}.app-card p{font-size:11px}.card-top{margin-bottom:20px}section#apps,.writing{margin-top:48px}.app-tools{align-items:stretch;flex-direction:column;gap:14px}.filters{gap:0;flex-wrap:nowrap;justify-content:space-between}.filters button{font-size:11px;padding:7px 10px}.search input{width:100%}.post-link{grid-template-columns:1fr 16px;gap:9px;padding:20px 0}.post-link time{grid-column:1/-1;font-size:10px}.post-link h3{font-size:14px}.post-link p{font-size:11px}.article{margin-top:30px}.article header{margin:32px 0}.prose{font-size:14px}.prose pre{padding:16px;font-size:11px}.page-heading{padding-top:48px}footer{justify-content:space-between;font-size:9px;gap:15px}footer div{gap:13px}.writing{margin-bottom:50px}} +@media(prefers-reduced-motion:reduce){:root{scroll-behavior:auto}*,*::before,*::after{transition:none!important;animation:none!important}} diff --git a/assets/site.js b/assets/site.js new file mode 100644 index 0000000..ed47fe0 --- /dev/null +++ b/assets/site.js @@ -0,0 +1,30 @@ +const tools = document.querySelector('.app-tools'); +if (tools) { + tools.hidden = false; + let category = 'all'; + const input = tools.querySelector('input'); + const cards = [...document.querySelectorAll('#app-grid .app-card')]; + const filter = () => { + const q = input.value.trim().toLocaleLowerCase(); + let count = 0; + for (const card of cards) { + const visible = (category === 'all' || card.dataset.category === category) && card.dataset.search.toLocaleLowerCase().includes(q); + card.hidden = !visible; + if (visible) count++; + } + document.querySelector('#empty').hidden = count !== 0; + }; + tools.querySelectorAll('[data-filter]').forEach(button => button.addEventListener('click', () => { + category = button.dataset.filter; + tools.querySelectorAll('[data-filter]').forEach(b => b.setAttribute('aria-pressed', String(b === button))); + filter(); + })); + input.addEventListener('input', filter); +} +// Only articles containing diagrams load the pinned renderer. Text remains readable without it. +if (document.querySelector('.mermaid')) { + import('https://cdn.jsdelivr.net/npm/mermaid@10.9.3/dist/mermaid.esm.min.mjs').then(({default: mermaid}) => { + mermaid.initialize({startOnLoad:false, securityLevel:'strict', theme:'neutral', fontFamily:'system-ui'}); + return mermaid.run({querySelector:'.mermaid'}); + }).catch(() => {}); +} diff --git a/build.py b/build.py new file mode 100644 index 0000000..a3bd92f --- /dev/null +++ b/build.py @@ -0,0 +1,134 @@ +"""Small static publisher. Content and application registry are the source of truth.""" +from pathlib import Path +from datetime import datetime +from html import escape +from urllib.parse import quote, urlsplit, unquote +import json +import re +import shutil +import hashlib +import xml.etree.ElementTree as ET +import mistune +import yaml + +ROOT = Path(__file__).resolve().parent +OUT = ROOT / 'public' +BASE = 'https://famzheng.me' +DESCRIPTION = '软件工程师。系统、AI,造点有意思的东西。' +ICONS = { + 'music': '', + 'dice': '', + 'spark': '', + 'book': '', + 'route': '', + 'note': '', + 'cube': '', + 'code': '', +} + +def e(value): + return escape(str(value), quote=True) + +def icon(name): + return f'' + +class Renderer(mistune.HTMLRenderer): + def block_code(self, code, info=None): + if (info or '').strip() == 'mermaid': + return f'
{e(code)}
\n' + return super().block_code(code, info) + +markdown = mistune.create_markdown(renderer=Renderer(escape=False), plugins=['table', 'strikethrough', 'url']) + +def load_post(path): + match = re.match(r'^---\s*\n(.*?)\n---\s*\n(.*)', path.read_text(), re.S) + if not match: + raise ValueError(f'Missing frontmatter: {path}') + meta = yaml.safe_load(match[1]) + meta.update(slug=path.stem, body=markdown(match[2]), raw=match[2]) + meta['url'] = '/about/' if path.stem == 'about' else f'/posts/{path.stem}/' + meta['date'] = str(meta.get('date', ''))[:10] + return meta + +def layout(title, content, path='/', description=DESCRIPTION): + active = 'apps' if path == '/' else 'posts' if path.startswith('/posts') else 'about' + css_hash = hashlib.sha256((ROOT/'assets/site.css').read_bytes()).hexdigest()[:12] + return f''' +{e(title)} · Fam Zheng + + + +
+
{content}
''' + +def write(path, value): + dest = OUT/path + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(value) + +def post_list(posts): + return '
' + ''.join(f'

{e(p["title"])}

{e(p.get("summary", ""))}

' for p in posts) + '
' + +def app_card(app, index, featured=False): + badge = f'{e(app["access"])}' if app.get('access') else '' + return f''' +
{icon(app['icon'])}

{e(app['name'])}

{badge}
{f'

{e(app["subtitle"])}

' if featured else ''}

{e(app['description'])}

{e(app['category'])}
''' + +def build(): + # The only removable directory is this generated, repository-local output. + if OUT.is_symlink(): + raise ValueError('public must not be a symlink') + if OUT.exists(): shutil.rmtree(OUT) + OUT.mkdir() + shutil.copytree(ROOT/'assets', OUT/'assets') + shutil.copytree(ROOT/'static', OUT, dirs_exist_ok=True) + apps = json.loads((ROOT/'apps.json').read_text()) + assert len({a['url'] for a in apps}) == len(apps), 'Duplicate application URL' + for app in apps: + assert app['url'].startswith('https://') or app['url'].startswith('/'), 'Unsafe URL' + assert not urlsplit(app['url']).fragment, 'Never publish access tokens in links' + posts = sorted((load_post(p) for p in (ROOT/'content/posts').glob('*.md')), key=lambda p:p['date'], reverse=True) + posts = [p for p in posts if not p.get('draft')] + featured = ''.join(app_card(a, i, True) for i,a in enumerate(apps) if a.get('featured')) + all_apps = ''.join(app_card(a, i) for i,a in enumerate(apps)) + categories = ''.join(f'' for c in dict.fromkeys(a['category'] for a in apps)) + home = f'''

FAM'S LITTLE CORNER

把想法,
做成可以玩的东西。

我是 Fam,软件工程师。
折腾系统、工具和 AI,也给日常生活造些小乐趣。

逛逛我的应用 ↓
+
小工具,也是小乐趣
+

应用抽屉 {len(apps):02}

用得到,就打开
{all_apps}

部分应用仍在实验中,私人应用和内测入口需要相应的访问权限。

+

边做边想

全部文章 ↗
{post_list(posts[:4])}
''' + write('index.html', layout('应用与随笔', home)) + write('posts/index.html', layout('文章', '

NOTES FROM THE WORKBENCH

边做边想

系统、工具、AI,以及造东西时留下的思考。

'+post_list(posts), '/posts/')) + for i,p in enumerate(posts): + related = posts[i+1:i+2] or posts[:1] + article = f'
← 所有文章

{e(p["title"])}

{e(p.get("summary", ""))}

{p["body"]}
' + write(f'posts/{p["slug"]}/index.html', layout(p['title'], article, p['url'], p.get('summary', DESCRIPTION))) + about = load_post(ROOT/'content/about.md') + write('about/index.html', layout('关于', f'

HELLO, I’M FAM

你好,我是 Fam。

{about["body"]}

这里收着一些自己用的小应用和实验,也记录我在造什么、在想什么。想法是我的,大部分文章由 AI 代笔整理。

', '/about/')) + # Preserve old Hugo collection URLs as real static pages. + tags = sorted({tag for p in posts for tag in p.get('tags', [])}) + for tag in tags: + slug = tag.lower().replace(' ', '-') + path = f'/tags/{quote(slug)}/' + write(f'tags/{slug}/index.html', layout(tag, f'

{e(tag)}

'+post_list([p for p in posts if tag in p.get('tags', [])]), path)) + write('tags/index.html', layout('标签', '

文章标签

', '/tags/')) + write('categories/index.html', layout('分类', '

所有文章

'+post_list(posts), '/categories/')) + for path in ['page/2/index.html', 'posts/page/2/index.html', 'archives/index.html']: + write(path, layout('文章归档', '

文章归档

'+post_list(posts), '/posts/')) + write('404.html', layout('页面不在这里', '

404

走远了一点。

这个地址暂时没有内容。回到首页 →

', '/404.html')) + rss = ET.Element('rss', version='2.0'); channel = ET.SubElement(rss, 'channel') + for k,v in [('title','Fam Zheng'),('link',BASE),('description',DESCRIPTION)]: ET.SubElement(channel,k).text=v + for p in posts: + item = ET.SubElement(channel,'item') + for k,v in [('title',p['title']),('link',BASE+p['url']),('guid',BASE+p['url']),('description',p.get('summary',''))]: ET.SubElement(item,k).text=v + write('index.xml', ET.tostring(rss,encoding='unicode')) + write('posts/index.xml', ET.tostring(rss,encoding='unicode')) + site = ET.Element('urlset', xmlns='http://www.sitemaps.org/schemas/sitemap/0.9') + for path in ['/', '/posts/', '/about/']+[p['url'] for p in posts]: ET.SubElement(ET.SubElement(site,'url'),'loc').text=BASE+path + write('sitemap.xml', ET.tostring(site,encoding='unicode')) + write('robots.txt', f'User-agent: *\nAllow: /\nSitemap: {BASE}/sitemap.xml\n') + write('index.json', json.dumps([{'title':p['title'],'permalink':BASE+p['url'],'summary':p.get('summary',''),'content':p['raw']} for p in posts],ensure_ascii=False)) + write('release.json', json.dumps({'site':'famzheng.me','posts':len(posts),'apps':len(apps),'content_sha256':hashlib.sha256(''.join(p['raw'] for p in posts).encode()).hexdigest()})) + print(f'Built {len(posts)} posts and {len(apps)} applications → {OUT}') + +if __name__ == '__main__': build() diff --git a/deploy.py b/deploy.py new file mode 100644 index 0000000..a3ff4b9 --- /dev/null +++ b/deploy.py @@ -0,0 +1,52 @@ +"""Ship locally built static output, preserving TLS, ingress and other services.""" +from pathlib import Path +import datetime +import json +import os +import subprocess +import urllib.request +import time +import shlex + +ROOT = Path(__file__).resolve().parent +HOST = os.environ.get('DEPLOY_HOST', 'fzc') +def run(*args, capture=False): + return subprocess.run(args, check=True, text=True, capture_output=capture).stdout +def ssh(*args, capture=False): + return run('ssh', HOST, shlex.join(args), capture=capture) +def kube(*args, capture=False): + return ssh('kubectl', '-n', 'gnoc-hugo', *args, capture=capture) + +def main(): + release = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%dT%H%M%SZ') + '-' + run('git','rev-parse','--short','HEAD',capture=True).strip() + target = '/var/gnoc/site-releases/' + release + deployment = json.loads(kube('get','deployment','hugo','-o','json',capture=True)) + volumes = deployment['spec']['template']['spec']['volumes'] + current = next(v for v in volumes if v['name']=='site') + previous = current['hostPath']['path'] + assert previous == '/var/gnoc/hugo-public' or previous.startswith('/var/gnoc/site-releases/'), 'Unexpected production mount' + ssh('mkdir','-p',target) + run('rsync','-az','--chmod=D755,F644',str(ROOT/'public')+'/',f'{HOST}:{target}/') + def point(path): + patch={'spec':{'template':{'spec':{'volumes':[{'name':'site','hostPath':{'path':path,'type':'Directory'}}]}}}} + kube('patch','deployment','hugo','--type=strategic','-p',json.dumps(patch)) + kube('rollout','status','deployment/hugo','--timeout=120s') + try: + point(target) + for _ in range(10): + try: + with urllib.request.urlopen('https://famzheng.me/release.json',timeout=15) as response: + actual=json.load(response) + expected=json.loads((ROOT/'public/release.json').read_text()) + if actual == expected: break + except Exception: pass + time.sleep(2) + else: raise RuntimeError('Public release smoke test failed') + except Exception: + print('Restoring previous site:',previous) + point(previous) + raise + print('Published https://famzheng.me/ →', target) + print('Previous release retained:', previous) + +if __name__ == '__main__': main() diff --git a/hugo.toml b/hugo.toml deleted file mode 100644 index d8054d2..0000000 --- a/hugo.toml +++ /dev/null @@ -1,45 +0,0 @@ -baseURL = "https://famzheng.me/" -languageCode = "zh" -title = "Fam Zheng" -theme = "PaperMod" - -[pagination] - pagerSize = 10 - -[params] - env = "production" - description = "软件工程师。系统、AI,造点有意思的东西。" - author = "Fam Zheng" - showReadingTime = true - showShareButtons = false - showBreadCrumbs = false - showPostNavLinks = true - showCodeCopyButtons = true - defaultTheme = "auto" - - [params.homeInfoParams] - Title = "嗨" - Content = "我是 Fam,软件工程师。在系统基础设施和 AI 的交叉地带折腾。这里记录我在造什么、在想什么。想法是我的,大部分内容由 AI 代笔整理。" - - [[params.socialIcons]] - name = "github" - url = "https://github.com/famz" - - [[params.socialIcons]] - name = "gitea" - url = "https://git.famzheng.com" - -[menu] - [[menu.main]] - identifier = "posts" - name = "文章" - url = "/posts/" - weight = 1 - [[menu.main]] - identifier = "about" - name = "关于" - url = "/about/" - weight = 2 - -[outputs] - home = ["HTML", "RSS", "JSON"] diff --git a/layouts/_default/_markup/render-codeblock-mermaid.html b/layouts/_default/_markup/render-codeblock-mermaid.html deleted file mode 100644 index c96fea5..0000000 --- a/layouts/_default/_markup/render-codeblock-mermaid.html +++ /dev/null @@ -1,12 +0,0 @@ -{{- $page := .Page -}} -
-{{- .Inner | safeHTML -}}
-
-{{- if not ($page.Store.Get "mermaidScriptAdded") -}} - -{{- $page.Store.Set "mermaidScriptAdded" true -}} -{{- end -}} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..237e187 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +mistune==3.1.3 +PyYAML==6.0.3 diff --git a/tests/test_site.py b/tests/test_site.py new file mode 100644 index 0000000..cde4bf5 --- /dev/null +++ b/tests/test_site.py @@ -0,0 +1,49 @@ +import unittest +from html.parser import HTMLParser +from pathlib import Path +from urllib.parse import urlsplit, unquote +import sys +import json +import xml.etree.ElementTree as ET +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import build + +class Links(HTMLParser): + def __init__(self): + super().__init__(); self.urls = []; self.h1 = 0 + def handle_starttag(self, tag, attrs): + if tag == 'h1': self.h1 += 1 + for k,v in attrs: + if k in ('href','src') and v.startswith('/'): self.urls.append(v) + +class SiteTest(unittest.TestCase): + @classmethod + def setUpClass(cls): build.build() + def test_all_posts_preserved(self): + for path in (build.ROOT/'content/posts').glob('*.md'): + p = build.load_post(path) + rendered = (build.OUT/f'posts/{p["slug"]}/index.html').read_text() + self.assertIn(build.e(p['title']), rendered) + self.assertIn(p['body'], rendered) + def test_internal_links_and_headings(self): + for path in build.OUT.rglob('*.html'): + if '/demos/' in str(path): continue + parser = Links(); parser.feed(path.read_text()) + self.assertEqual(parser.h1, 1, str(path)) + for url in parser.urls: + target = build.OUT / unquote(urlsplit(url).path).lstrip('/') + if target.is_dir(): target /= 'index.html' + self.assertTrue(target.is_file(), f'{path}: {url}') + def test_no_old_theme(self): + self.assertNotIn('PaperMod', (build.OUT/'index.html').read_text()) + self.assertNotIn('Hugo', (build.OUT/'index.html').read_text()) + def test_feeds(self): + self.assertEqual(len(ET.parse(build.OUT/'index.xml').findall('./channel/item')), 11) + self.assertEqual(len(ET.parse(build.OUT/'sitemap.xml').getroot()), 14) + def test_app_registry(self): + apps = json.loads((build.ROOT/'apps.json').read_text()) + self.assertEqual(len(apps), 23) + self.assertTrue(all(not urlsplit(a['url']).query and not urlsplit(a['url']).fragment for a in apps)) + self.assertTrue(all(a['name'] and a['description'] and a['icon'] in build.ICONS for a in apps)) + +if __name__ == '__main__': unittest.main() diff --git a/themes/PaperMod b/themes/PaperMod deleted file mode 160000 index f207ce6..0000000 --- a/themes/PaperMod +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f207ce6d58899e1498af1c569d46ed7ee56d6966