Compare commits

..
6 Commits
Author SHA1 Message Date
fam b1cd15b2cf Add Fam account portal and describe Onion cloud projects
verify / verify (push) Canceled after 0s
2026-10-06 20:50:55 +01:00
fam 12dac31355 Add Onion image editor to application directory
verify / verify (push) Canceled after 0s
2026-10-05 22:05:22 +01:00
fam 41114eb0ad 静态站点改用就绪后切换的滚动发布,避免停机等待
verify / verify (push) Canceled after 0s
2026-09-21 12:16:31 +01:00
fam acfa02f96d 补齐浏览器回归和发布指纹,更新应用访问说明 2026-09-21 12:12:25 +01:00
fam a0fefc5041 为独立静态发布目录设置部署用户权限
verify / verify (push) Canceled after 0s
2026-09-21 12:04:17 +01:00
fam f1586d240e 重建个人应用入口与静态博客,保留旧文章并支持本地发布 2026-09-21 12:03:38 +01:00
22 changed files with 471 additions and 192 deletions
+6 -30
View File
@@ -1,37 +1,13 @@
# Published to fam/blog:.gitea/workflows/publish.yml by app/hugo/install. name: verify
# 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
on: on:
push: push:
branches: [main] branches: [main]
pull_request:
jobs: jobs:
build-and-publish: verify:
runs-on: self-hosted runs-on: self-hosted
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: - name: Verify custom static site
submodules: recursive run: make test
fetch-depth: 1 # Production: make deploy from the local development machine.
- 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/
+9
View File
@@ -0,0 +1,9 @@
.venv/
public/
__pycache__/
.env*
!*.example
test-results/
themes/
node_modules/
playwright-report/
-3
View File
@@ -1,3 +0,0 @@
[submodule "themes/PaperMod"]
path = themes/PaperMod
url = https://github.com/adityatelange/hugo-PaperMod.git
+9
View File
@@ -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.
+20 -44
View File
@@ -1,47 +1,23 @@
SHELL := /bin/bash SHELL := /bin/bash
POSTS := content/posts
.DEFAULT_GOAL := help .DEFAULT_GOAL := help
PYTHON := .venv/bin/python
.PHONY: help new serve build publish clean .PHONY: help install build test test-browser serve deploy
help: help:
@echo 'targets:' @echo 'make install | build | test | serve | deploy'
@echo ' make new SLUG=<slug> TITLE="<标题>" [SUMMARY="<摘要>"] # 新建文章' @echo '文章: content/posts/*.md;应用: apps.json;样式: assets/site.css'
@echo ' make serve # hugo server -D (需装 hugo)' install: .venv/.installed
@echo ' make build # hugo build 到 public/' .venv/.installed: requirements.txt
@echo ' make publish MSG="post: <slug>" # commit + push, 触发 CI' uv venv --allow-existing .venv
@echo ' make clean # rm -rf public/' uv pip install --python $(PYTHON) -r requirements.txt
touch $@
new: build: install
@test -n "$(SLUG)" || { echo 'SLUG required'; exit 1; } $(PYTHON) build.py
@test -n "$(TITLE)" || { echo 'TITLE required'; exit 1; } test: install
@test ! -e $(POSTS)/$(SLUG).md || { echo "$(POSTS)/$(SLUG).md already exists"; exit 1; } $(PYTHON) -m unittest discover -s tests -v
@DATE="$$(date '+%Y-%m-%dT%H:%M:%S%:z')"; \ test-browser: build
{ \ npm ci
echo '---'; \ npm run test:browser
echo 'title: "$(TITLE)"'; \ serve: build
echo "date: $$DATE"; \ $(PYTHON) -m http.server 4176 --bind 127.0.0.1 --directory public
echo 'draft: false'; \ deploy: test
if [ -n "$(SUMMARY)" ]; then echo 'summary: "$(SUMMARY)"'; fi; \ $(PYTHON) deploy.py
echo '---'; \
echo ''; \
echo '<!-- 正文写这里 (markdown) -->'; \
} > $(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/
+19 -56
View File
@@ -1,67 +1,30 @@
# blog # famzheng.me
Fam 的博客源码 (Hugo + PaperMod 主题),线上 https://famzheng.me/。 自己维护的应用入口与博客,纯静态 HTML/CSS/JS。不再依赖 Hugo、PaperMod 或运行时后端。
## 写一篇文章 ## 开发与发布
```bash 源码:~/projects/fzc-src/blog(本机);生产:ssh fzc。服务器只接收构建产物。
make new SLUG=hello-world TITLE='你好,世界' SUMMARY='第一篇,随便聊聊' 构建要求 Python 3.10+ 和 uv;浏览器回归使用 Playwright,可通过 npm ci 安装。
$EDITOR content/posts/hello-world.md # 写正文 (markdown)
make publish MSG='post: hello world' # commit + push,触发 CI
```
约 1 分钟后文章在 `https://famzheng.me/posts/<slug>/` 上线。 - make install:安装固定版本构建依赖。
CI 状态: <https://famzheng.me/gitea/fam/blog/actions> - make test:文章完整性、站内链接、RSS、应用清单测试。
- make serve:http://127.0.0.1:4176 本地预览。
- make deploy:本机构建测试,rsync 产物,切换指定站点的挂载目录,公网验收。
## 目录 应用清单集中在 apps.json:名称、简介、URL、分类、图标、访问提示。不要放密钥、分享令牌或管理初始化链接。
``` 文章在 content/posts/<slug>.md,保留 YAML frontmatter(title/date/summary/draft/tags)。文件名就是永久 URL。原有 11 篇文章全文、/posts/、/about/、标签、归档、分页旧地址、RSS 和 /demos/ 均保留。
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/
```
## Frontmatter assets/ 是独立设计;首页无外部字体、分析脚本或 JS 框架。过滤是渐进增强,无 JS 仍可浏览全部入口。只有含 Mermaid 的文章按需加载固定版本渲染器,失败保留文本。
每篇文章顶部 YAML: ## 部署边界与回滚
```yaml 沿用 gnoc-hugo/hugo 的 nginx、Service、Ingress 与 TLS;名称是历史兼容,不再运行 Hugo。
--- 每次产物上传到 /var/gnoc/site-releases/<timestamp>-<commit>,只更新该 Deployment 的 site hostPath;失败恢复原目录。
title: "标题" 原 /var/gnoc/hugo-public 和每版发布目录保留,可回滚 Deployment 或改回发布时显示的旧路径。
date: 2026-04-30T10:00:00+01:00 Gitea、其他子域名、数据库及私人应用数据不受影响。CI 仅校验,不再在服务器重建站点。
draft: false
summary: "一句话摘要 (可选,影响列表页和 og:description)"
---
```
`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
```
+27
View File
@@ -0,0 +1,27 @@
[
{"name":"Fam 账号","description":"统一登录与账号管理,连接 Onion 和私人书房。","url":"https://auth.famzheng.me/","category":"工具","icon":"cube","access":"需登录"},
{"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":"Onion","description":"中文图像编辑器,登录 Fam 后自动保存项目、跨设备继续创作。请使用桌面版 Chrome。","url":"https://onion.famzheng.me/","category":"工具","icon":"spark"},
{"name":"Read","description":"把文档带进浏览器,留一段专心阅读的时间。","url":"https://read.famzheng.me/","category":"工具","icon":"book","access":"私人"},
{"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":"需登录"}
]
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="18" fill="#314b3d"/><text x="15" y="49" font-family="Georgia,serif" font-size="50" font-style="italic" fill="#f5f3ed">f.</text></svg>

After

Width:  |  Height:  |  Size: 227 B

+6
View File
File diff suppressed because one or more lines are too long
+30
View File
@@ -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(() => {});
}
+139
View File
@@ -0,0 +1,139 @@
"""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': '<path d="M9 18V5l11-2v13M9 9l11-2"/><ellipse cx="6" cy="18" rx="3" ry="2"/><ellipse cx="17" cy="16" rx="3" ry="2"/>',
'dice': '<rect x="4" y="4" width="16" height="16" rx="5"/><path d="M8 9h.01M16 9h.01M9 14q3 4 6 0"/>',
'spark': '<path d="m12 3 2.5 6.5L21 12l-6.5 2.5L12 21l-2.5-6.5L3 12l6.5-2.5Z"/>',
'book': '<path d="M12 6v15M3 4q5-1 9 2 4-3 9-2v15q-5-1-9 2-4-3-9-2Z"/>',
'route': '<circle cx="5" cy="5" r="2"/><circle cx="19" cy="19" r="2"/><path d="M7 5h9a4 4 0 0 1 0 8H8a3 3 0 0 0 0 6h9"/>',
'note': '<path d="M17 4h3v16H4V4h3M8 3h8v4H8ZM8 12h8M8 16h5"/>',
'cube': '<path d="m12 2 9 5v10l-9 5-9-5V7Zm0 10v10M3 7l9 5 9-5M7 4l10 6"/>',
'code': '<path d="m8 6-6 6 6 6m8-12 6 6-6 6M14 3l-4 18"/>',
}
def e(value):
return escape(str(value), quote=True)
def icon(name):
return f'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">{ICONS[name]}</svg>'
class Renderer(mistune.HTMLRenderer):
def block_code(self, code, info=None):
if (info or '').strip() == 'mermaid':
return f'<pre class="mermaid">{e(code)}</pre>\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'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{e(title)} · Fam Zheng</title><meta name="description" content="{e(description)}"><meta name="theme-color" content="#f5f3ed">
<link rel="canonical" href="{BASE}{e(path)}"><link rel="alternate" type="application/rss+xml" title="Fam Zheng" href="/index.xml">
<meta property="og:title" content="{e(title)}"><meta property="og:description" content="{e(description)}"><meta property="og:type" content="{'article' if path.startswith('/posts/') and path != '/posts/' else 'website'}"><meta property="og:url" content="{BASE}{e(path)}">
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml"><link rel="stylesheet" href="/assets/site.css?v={css_hash}"><script src="/assets/site.js" defer></script>
</head><body><a class="skip" href="#main">跳到内容</a><div class="shell"><header class="site-header"><a class="brand" href="/" aria-label="Fam Zheng 首页"><span class="brand-mark">f.</span><span>Fam Zheng</span></a><nav aria-label="主导航">
<a href="/#apps" {'aria-current="page"' if active == 'apps' else ''}>应用</a><a href="/posts/" {'aria-current="page"' if active == 'posts' else ''}>文章</a><a href="/about/" {'aria-current="page"' if active == 'about' else ''}>关于</a></nav></header>
<main id="main">{content}</main><footer><span>© {datetime.now().year} Fam Zheng</span><div><a href="https://github.com/famz">GitHub ↗</a><a href="https://git.euphon.cloud/">Gitea ↗</a><a href="/index.xml">RSS ↗</a></div><span class="footer-note">一些想法,一些造出来的东西。</span></footer></div></body></html>'''
def write(path, value):
dest = OUT/path
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(value)
def post_list(posts):
return '<div class="post-list">' + ''.join(f'<a class="post-link" href="{p["url"]}"><time>{p["date"]}</time><div><h3>{e(p["title"])}</h3><p>{e(p.get("summary", ""))}</p></div><span aria-hidden="true">↗</span></a>' for p in posts) + '</div>'
def app_card(app, index, featured=False):
badge = f'<span class="badge">{e(app["access"])}</span>' if app.get('access') else ''
return f'''<a class="app-card {'featured' if featured else ''} tone-{index % 3}" data-category="{e(app['category'])}" data-search="{e(app['name']+' '+app['description']+' '+app['category'])}" href="{e(app['url'])}">
<div class="card-top"><span class="app-icon">{icon(app['icon'])}</span><span class="card-arrow" aria-hidden="true">↗</span></div><div class="card-heading"><h3>{e(app['name'])}</h3>{badge}</div>{f'<p class="subtitle">{e(app["subtitle"])}</p>' if featured else ''}<p>{e(app['description'])}</p><span class="category">{e(app['category'])}</span></a>'''
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'<button type="button" data-filter="{e(c)}" aria-pressed="false">{e(c)}</button>' for c in dict.fromkeys(a['category'] for a in apps))
home = f'''<section class="hero"><div><p class="eyebrow">FAM'S LITTLE CORNER</p><h1>把想法,<br>做成可以玩的东西<span class="period">。</span></h1><p class="intro">我是 Fam,软件工程师。<br>折腾系统、工具和 AI,也给日常生活造些小乐趣。</p><a class="text-link" href="#apps">逛逛我的应用 <span>↓</span></a></div><div class="sculpture" aria-hidden="true"><div class="orbit"></div><div class="tile tile-back">{icon('music')}</div><div class="tile tile-front">{icon('dice')}</div><span class="orbit-dot"></span><span class="tiny-caption">MAKE. PLAY. REPEAT.</span></div></section>
<section aria-labelledby="featured-title"><div class="section-heading"><h2 id="featured-title">最近在玩</h2><span>小工具,也是小乐趣</span></div><div class="featured-grid">{featured}</div></section>
<section id="apps" aria-labelledby="apps-title"><div class="section-heading"><h2 id="apps-title">应用抽屉 <sup>{len(apps):02}</sup></h2><span>用得到,就打开</span></div><div class="app-tools" hidden><div class="filters" role="group" aria-label="应用分类"><button type="button" data-filter="all" aria-pressed="true">全部</button>{categories}</div><label class="search"><span class="sr-only">搜索应用</span><input type="search" placeholder="找个小工具…" aria-label="搜索应用"></label></div><div class="app-grid" id="app-grid">{all_apps}</div><p id="empty" hidden>暂时没有找到,换个关键词试试。</p><p class="collection-note">部分应用仍在实验中,私人应用和内测入口需要相应的访问权限。</p></section>
<section class="writing" aria-labelledby="writing-title"><div class="section-heading"><h2 id="writing-title">边做边想</h2><a href="/posts/">全部文章 ↗</a></div>{post_list(posts[:4])}</section>'''
write('index.html', layout('应用与随笔', home))
write('posts/index.html', layout('文章', '<section class="page-heading"><p class="eyebrow">NOTES FROM THE WORKBENCH</p><h1>边做边想</h1><p>系统、工具、AI,以及造东西时留下的思考。</p></section>'+post_list(posts), '/posts/'))
for i,p in enumerate(posts):
related = posts[i+1:i+2] or posts[:1]
article = f'<article class="article"><a class="back-link" href="/posts/">← 所有文章</a><header><time>{p["date"]}</time><h1>{e(p["title"])}</h1><p class="article-summary">{e(p.get("summary", ""))}</p></header><div class="prose">{p["body"]}</div><aside class="next-post">继续读{post_list(related)}</aside></article>'
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'<section class="page-heading"><p class="eyebrow">HELLO, I’M FAM</p><h1>你好,我是 Fam。</h1></section><div class="prose about">{about["body"]}<p>这里收着一些自己用的小应用和实验,也记录我在造什么、在想什么。想法是我的,大部分文章由 AI 代笔整理。</p></div>', '/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'<section class="page-heading"><h1>{e(tag)}</h1></section>'+post_list([p for p in posts if tag in p.get('tags', [])]), path))
write('tags/index.html', layout('标签', '<section class="page-heading"><h1>文章标签</h1></section><div class="tag-links">'+''.join(f'<a href="/tags/{quote(t.lower().replace(" ", "-"))}/">{e(t)}</a>' for t in tags)+'</div>', '/tags/'))
write('categories/index.html', layout('分类', '<section class="page-heading"><h1>所有文章</h1></section>'+post_list(posts), '/categories/'))
for path in ['page/2/index.html', 'posts/page/2/index.html', 'archives/index.html']:
write(path, layout('文章归档', '<section class="page-heading"><h1>文章归档</h1></section>'+post_list(posts), '/posts/'))
write('404.html', layout('页面不在这里', '<section class="page-heading"><p class="eyebrow">404</p><h1>走远了一点。</h1><p>这个地址暂时没有内容。<a href="/">回到首页 →</a></p></section>', '/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))
inputs = [ROOT/'build.py', ROOT/'apps.json', ROOT/'requirements.txt'] + sorted((ROOT/'assets').rglob('*')) + sorted((ROOT/'content').rglob('*')) + sorted((ROOT/'static').rglob('*'))
digest = hashlib.sha256()
for path in inputs:
if path.is_file():
digest.update(str(path.relative_to(ROOT)).encode()); digest.update(path.read_bytes())
write('release.json', json.dumps({'site':'famzheng.me','posts':len(posts),'apps':len(apps),'build_sha256':digest.hexdigest()}))
print(f'Built {len(posts)} posts and {len(apps)} applications → {OUT}')
if __name__ == '__main__': build()
+1 -1
View File
@@ -10,4 +10,4 @@ showToc: false
**找到我:** **找到我:**
- [GitHub](https://github.com/famz) - [GitHub](https://github.com/famz)
- [Gitea](https://git.famzheng.com) - [Gitea](https://git.euphon.cloud/)
+54
View File
@@ -0,0 +1,54 @@
"""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'
owner = ssh('id','-un',capture=True).strip()
ssh('sudo','install','-d','-m','0755','-o',owner,'/var/gnoc/site-releases')
ssh('mkdir','-p',target)
run('rsync','-az','--chmod=D755,F644',str(ROOT/'public')+'/',f'{HOST}:{target}/')
def point(path):
patch={'spec':{'strategy':{'type':'RollingUpdate','rollingUpdate':{'maxUnavailable':0,'maxSurge':1}},'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()
-45
View File
@@ -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"]
@@ -1,12 +0,0 @@
{{- $page := .Page -}}
<pre class="mermaid">
{{- .Inner | safeHTML -}}
</pre>
{{- if not ($page.Store.Get "mermaidScriptAdded") -}}
<script type="module">
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
const theme = document.documentElement.dataset.theme === "dark" ? "dark" : "default";
mermaid.initialize({ startOnLoad: true, theme });
</script>
{{- $page.Store.Set "mermaidScriptAdded" true -}}
{{- end -}}
+71
View File
@@ -0,0 +1,71 @@
{
"name": "blog",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"devDependencies": {
"@playwright/test": "1.61.1"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+1
View File
@@ -0,0 +1 @@
{"private":true,"type":"module","scripts":{"test:browser":"playwright test"},"devDependencies":{"@playwright/test":"1.61.1"}}
+2
View File
@@ -0,0 +1,2 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({testDir:'tests/browser',workers:1,webServer:{command:'make serve',url:'http://127.0.0.1:4176',reuseExistingServer:true},use:{baseURL:'http://127.0.0.1:4176'},projects:[{name:'desktop',use:{viewport:{width:1440,height:1000}}},{name:'mobile',use:{viewport:{width:390,height:844}}},{name:'small',use:{viewport:{width:320,height:700}}}]});
+2
View File
@@ -0,0 +1,2 @@
mistune==3.1.3
PyYAML==6.0.3
+24
View File
@@ -0,0 +1,24 @@
import {test,expect} from '@playwright/test';
test('应用导航、搜索、旧文章与手机布局', async ({page})=>{
const errors=[]; page.on('pageerror',e=>errors.push(e.message));
await page.goto('/');
await expect(page.locator('#app-grid .app-card')).toHaveCount(23);
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true);
await page.getByRole('button',{name:'音乐',exact:true}).click();
await expect(page.locator('#app-grid .app-card:visible')).toHaveCount(3);
await page.getByRole('button',{name:'全部',exact:true}).click();
await page.getByRole('searchbox').fill('hedgehog');
await expect(page.locator('#app-grid .app-card:visible')).toHaveCount(1);
await page.getByRole('searchbox').fill('nothing-to-match');
await expect(page.locator('#empty')).toBeVisible();
await page.goto('/posts/claude-code-goal/');
await expect(page.locator('h1')).toContainText('Claude Code');
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true);
expect(errors).toEqual([]);
});
test('没有 JavaScript 仍能访问所有入口',async({browser})=>{
const context=await browser.newContext({javaScriptEnabled:false});
const page=await context.newPage();await page.goto('http://127.0.0.1:4176/');
await expect(page.locator('#app-grid .app-card:visible')).toHaveCount(23);
await context.close();
});
+50
View File
@@ -0,0 +1,50 @@
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):
count = sum(not build.load_post(p).get('draft') for p in (build.ROOT/'content/posts').glob('*.md'))
self.assertEqual(len(ET.parse(build.OUT/'index.xml').findall('./channel/item')), count)
self.assertEqual(len(ET.parse(build.OUT/'sitemap.xml').getroot()), count + 3)
def test_app_registry(self):
apps = json.loads((build.ROOT/'apps.json').read_text())
self.assertGreaterEqual(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()
Submodule themes/PaperMod deleted from f207ce6d58