Files
blog/build.py
T

140 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()