140 lines
12 KiB
Python
140 lines
12 KiB
Python
"""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()
|