"""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)) 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()