Compare commits
6
Commits
76562f77e3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1cd15b2cf | ||
|
|
12dac31355 | ||
|
|
41114eb0ad | ||
|
|
acfa02f96d | ||
|
|
a0fefc5041 | ||
|
|
f1586d240e |
@@ -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/
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
.venv/
|
||||||
|
public/
|
||||||
|
__pycache__/
|
||||||
|
.env*
|
||||||
|
!*.example
|
||||||
|
test-results/
|
||||||
|
themes/
|
||||||
|
node_modules/
|
||||||
|
playwright-report/
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
[submodule "themes/PaperMod"]
|
|
||||||
path = themes/PaperMod
|
|
||||||
url = https://github.com/adityatelange/hugo-PaperMod.git
|
|
||||||
@@ -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.
|
||||||
@@ -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/
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -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":"需登录"}
|
||||||
|
]
|
||||||
@@ -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 |
File diff suppressed because one or more lines are too long
@@ -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(() => {});
|
||||||
|
}
|
||||||
@@ -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
@@ -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/)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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 -}}
|
|
||||||
Generated
+71
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"private":true,"type":"module","scripts":{"test:browser":"playwright test"},"devDependencies":{"@playwright/test":"1.61.1"}}
|
||||||
@@ -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}}}]});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
mistune==3.1.3
|
||||||
|
PyYAML==6.0.3
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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
Reference in New Issue
Block a user