🧭 正切AI实验室博客(本站)

零依赖 Python 静态站生成器 + GSAP 动效,复制按钮、源码打包、Cloudflare Pages 部署配置都在里面。

PythonHTML/CSSGSAPCloudflare Pages· 9 个文件 · 795 行 · MIT 协议

零依赖的 Python 静态站生成器,输出纯 HTML/CSS/JS,部署在 Cloudflare Pages。

bash
pip install pillow          # 可选:把配图转成 WebP
python3 build.py            # 生成 dist/
python3 -m http.server -d dist 8000   # 本地预览 http://localhost:8000

目录

  • build.py:生成器(Markdown 转 HTML、代码块复制按钮、项目源码页、zip 打包、RSS、sitemap)
  • content/posts/<slug>/:每篇文章的 article.md、meta.txt、images/
  • projects/<slug>/:开源项目源码,每个文件自动生成可复制代码块
  • static/:样式、脚本、GSAP 本地备份
  • scan-secrets.sh:发布前扫描 dist 和源码包里的邮箱、IP、Token、本机路径
  • public/:原样复制到 dist 根目录(_headers、robots.txt)

部署到 Cloudflare Pages

两种方式任选:

  1. 后台直接上传:Workers 和 Pages → 创建 → Pages → 上传资产,把 dist 文件夹或 dist.zip 拖进去。
  2. 命令行:npx wrangler pages deploy dist --project-name tangentlab-blog(需要 CLOUDFLARE_API_TOKEN)。

然后在项目的「自定义域」里添加 blog.你的域名。域名在同一个 Cloudflare 账号里时,CNAME 会自动建好。

GSAP 加载顺序

npmmirror(国内快)→ jsDelivr → static/vendor/ 本站备份。三个都失败时 4 秒后直接显示内容,没有动画也能正常阅读。系统开启「减少动态效果」时不播放动画。

完整源码

build.py

375 行 · python
build.py
#!/usr/bin/env python3
"""正切AI实验室博客 · 静态站生成器(零依赖,除 Pillow 用于把配图转成 WebP)
用法:python3 build.py  → 输出 dist/,可直接上传到 Cloudflare Pages
"""
import os, re, html, json, shutil, zipfile, datetime
from pathlib import Path
try:
    from PIL import Image
except ImportError:
    Image = None

ROOT = Path(__file__).resolve().parent
DIST = ROOT / 'dist'
SITE = 'https://blog.tangentlab.kdns.fr'
BRAND = '正切AI实验室'

# ---------------------------------------------------------------- 内容配置
POSTS = [  # 新的在前;source 目录里要有 article.md 和 images/
    dict(slug='cloudflare-pages-blog', date='2026-10-20', src='content/posts/cloudflare-pages-blog',
         tags=['Cloudflare Pages', '建站'], project='tangentlab-blog'),
    dict(slug='free-domain-cloudflare', date='2026-10-20', src='content/posts/free-domain-cloudflare',
         tags=['免费域名', 'Cloudflare'], project='cf-domain-toolkit'),
    dict(slug='cloudflare-tunnel', date='2026-10-19', src='content/posts/cloudflare-tunnel',
         tags=['Cloudflare Tunnel', '内网穿透'], project='cloudflare-tunnel-homelab'),
    dict(slug='domain-onboard-cloudflare', date='2026-10-18', src='content/posts/domain-onboard-cloudflare',
         tags=['DNS', 'Cloudflare'], project='cf-domain-toolkit'),
]
PROJECTS = [
    dict(slug='tangentlab-blog', icon='🧭', name='正切AI实验室博客(本站)',
         desc='零依赖 Python 静态站生成器 + GSAP 动效,复制按钮、源码打包、Cloudflare Pages 部署配置都在里面。',
         stack=['Python', 'HTML/CSS', 'GSAP', 'Cloudflare Pages'], dir=None),
    dict(slug='cf-domain-toolkit', icon='🔎', name='Cloudflare 域名接入自检工具包',
         desc='三个 Bash 脚本:查后缀是否在 PSL、用 DoH 确认 NS 已交给 Cloudflare、检查小橙云和缓存命中。',
         stack=['Bash', 'curl', 'jq', 'DoH'], dir='projects/cf-domain-toolkit'),
    dict(slug='cloudflare-tunnel-homelab', icon='🚇', name='Cloudflare Tunnel 家庭服务器上网套件',
         desc='没有公网 IP 也能把家里的服务挂到自己的域名:安装、临时隧道、正式隧道、多服务 ingress、systemd 常驻。',
         stack=['cloudflared', 'YAML', 'systemd'], dir='projects/cloudflare-tunnel-homelab'),
    dict(slug='shortvideo-pipeline', icon='🎬', name='短视频自动生成流水线',
         desc='我们每条竖屏教程的真实生产代码:edge-tts 配音、Pillow 逐帧动画、numpy 合成原创配乐、ffmpeg 响度标准化。',
         stack=['Python', 'edge-tts', 'Pillow', 'numpy', 'ffmpeg'], dir='projects/shortvideo-pipeline'),
    dict(slug='wechat-article-kit', icon='📝', name='公众号配图与排版生成器',
         desc='代码画示意图、裁剪标注截图,把 Markdown 转成全内联样式 HTML,粘进公众号编辑器格式不丢。',
         stack=['Python', 'Pillow', 'HTML'], dir='projects/wechat-article-kit'),
]
EXT_LANG = {'.py': 'python', '.sh': 'bash', '.yml': 'yaml', '.yaml': 'yaml', '.html': 'html', '.css': 'css',
            '.js': 'javascript', '.md': 'markdown', '.txt': 'text', '.toml': 'toml', '.json': 'json', '': 'text'}
SKIP_DIRS = {'__pycache__', 'dist', '_shots', '.git', 'node_modules', 'content'}

# ---------------------------------------------------------------- 工具
def esc(s): return html.escape(s, quote=True)

def code_block(text, lang='text', fname=None, long_lines=60):
    text = text.rstrip('\n')
    n = text.count('\n') + 1
    label = esc(fname or lang)
    body = f'<pre><code class="language-{esc(lang)}">{html.escape(text)}</code></pre>'
    if n > long_lines:
        body = (f'<div class="peek">{body}</div>'
                f'<button class="expand" type="button" data-label="展开全部 {n} 行">展开全部 {n} 行</button>')
    return ('<div class="code"><div class="bar"><span class="dots"><i></i><i></i><i></i></span>'
            f'<span class="fn">{label}</span><button class="copy" type="button" aria-label="复制代码">复制</button></div>'
            f'{body}</div>')

def inline(s):
    s = html.escape(s, quote=False)
    s = re.sub(r'`([^`]+)`', lambda m: f'<code>{m.group(1)}</code>', s)
    s = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', s)
    s = re.sub(r'\[([^\]]+)\]\((https?://[^)\s]+|/[^)\s]*)\)', r'<a href="\2">\1</a>', s)
    return s

def md_to_html(md, img_map, img_prefix):
    """够用的 Markdown:标题、段落、粗体、行内代码、代码块、列表、表格、引用、链接、【图N:说明】"""
    lines = md.split('\n'); out = []; i = 0
    title = None
    while i < len(lines):
        l = lines[i]
        if l.startswith('```'):
            lang = l[3:].strip() or 'text'; i += 1; buf = []
            while i < len(lines) and not lines[i].startswith('```'): buf.append(lines[i]); i += 1
            out.append(code_block('\n'.join(buf), lang, lang, long_lines=80)); i += 1; continue
        m = re.match(r'^【图(\d+):(.+?)】\s*$', l)
        if m:
            n, cap = m.group(1), m.group(2)
            if n in img_map:
                out.append(f'<figure class="reveal"><img loading="lazy" src="{img_prefix}{img_map[n]}" alt="{esc(cap)}">'
                           + (f'<figcaption>{inline(cap)}</figcaption>' if n != '1' else '') + '</figure>')
            i += 1; continue
        if l.startswith('# '):
            title = l[2:].strip(); i += 1; continue
        if l.startswith('作者:'): i += 1; continue
        if l.startswith('## '): out.append(f'<h2>{inline(l[3:])}</h2>'); i += 1; continue
        if l.startswith('### '): out.append(f'<h3>{inline(l[4:])}</h3>'); i += 1; continue
        if l.startswith('|'):
            rows = []
            while i < len(lines) and lines[i].startswith('|'): rows.append(lines[i]); i += 1
            cells = [[c.strip() for c in r.strip().strip('|').split('|')] for r in rows]
            head = cells[0]; body = [r for r in cells[1:] if not all(re.fullmatch(r':?-{2,}:?', c) for c in r)]
            t = '<div class="tbl"><table><thead><tr>' + ''.join(f'<th>{inline(c)}</th>' for c in head) + '</tr></thead><tbody>'
            t += ''.join('<tr>' + ''.join(f'<td>{inline(c)}</td>' for c in r) + '</tr>' for r in body)
            out.append(t + '</tbody></table></div>'); continue
        if re.match(r'^(- |\d+\. )', l):
            tag = 'ol' if re.match(r'^\d+\. ', l) else 'ul'; items = []
            while i < len(lines) and re.match(r'^(- |\d+\. )', lines[i]):
                items.append(re.sub(r'^(- |\d+\. )', '', lines[i])); i += 1
            out.append(f'<{tag}>' + ''.join(f'<li>{inline(x)}</li>' for x in items) + f'</{tag}>'); continue
        if l.startswith('> '):
            buf = []
            while i < len(lines) and lines[i].startswith('>'): buf.append(lines[i][1:].strip()); i += 1
            out.append('<blockquote><p>' + inline(' '.join(buf)) + '</p></blockquote>'); continue
        if l.strip(): out.append(f'<p>{inline(l)}</p>')
        i += 1
    return title, '\n'.join(out)

def read_meta(p):
    meta = {}
    if p.exists():
        for line in p.read_text().split('\n'):
            if ':' in line:
                k, v = line.split(':', 1); meta[k.strip()] = v.strip()
    return meta

def webp(src, dst, width=1080):
    dst.parent.mkdir(parents=True, exist_ok=True)
    if Image is None:
        shutil.copy(src, dst.with_suffix(src.suffix)); return dst.with_suffix(src.suffix).name
    im = Image.open(src); im = im.convert('RGBA' if im.mode in ('RGBA', 'LA', 'P') else 'RGB')
    if im.width > width: im = im.resize((width, round(im.height * width / im.width)), Image.LANCZOS)
    im.save(dst.with_suffix('.webp'), 'WEBP', quality=86, method=6)
    return dst.with_suffix('.webp').name

# ---------------------------------------------------------------- 页面框架
LOGO = ('<svg viewBox="0 0 64 64" aria-hidden="true"><rect width="64" height="64" rx="14" fill="#0c1117"/>'
        '<path d="M8 42 C 20 42, 24 18, 36 18 S 52 34, 58 30" fill="none" stroke="#9aa4b2" stroke-width="4" stroke-linecap="round"/>'
        '<line x1="14" y1="30" x2="54" y2="10" stroke="#f38020" stroke-width="5" stroke-linecap="round"/>'
        '<circle cx="34" cy="20" r="6" fill="#f38020"/></svg>')
NAV = [('首页', ''), ('文章', 'posts/'), ('项目开源', 'projects/'), ('关于', 'about/')]

def page(path, title, body, desc='正切AI实验室(TangentLab):每期带你做出一个能用的成品。Cloudflare、自托管、AI 应用实操,所有项目源码开源。', active='', og_img=None):
    depth = path.count('/')
    root = '../' * depth
    links = ''.join(f'<a href="{root}{h}" class="{"on" if active == h else ""}">{t}</a>' for t, h in NAV)
    full_title = f'{title} · {BRAND}' if title != BRAND else f'{BRAND} · TangentLab'
    canon = SITE + '/' + path.replace('index.html', '')
    og = f'<meta property="og:image" content="{SITE}/{og_img}">' if og_img else ''
    doc = f'''<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>{esc(full_title)}</title>
<meta name="description" content="{esc(desc)}">
<link rel="canonical" href="{canon}">
<meta property="og:type" content="website"><meta property="og:title" content="{esc(full_title)}"><meta property="og:description" content="{esc(desc)}"><meta property="og:url" content="{canon}">{og}
<meta name="theme-color" content="#0c1117">
<link rel="icon" href="{root}static/favicon.svg" type="image/svg+xml">
<link rel="alternate" type="application/rss+xml" title="{BRAND}" href="{root}feed.xml">
<link rel="stylesheet" href="{root}static/style.css?v={BUILD_ID}">
<script>(function(){{var d=document.documentElement;if(window.matchMedia&&!matchMedia('(prefers-reduced-motion: reduce)').matches){{d.classList.add('anim-pending');setTimeout(function(){{d.classList.remove('anim-pending')}},4000)}}}})()</script>
</head>
<body data-root="{root}">
<header class="nav"><div class="wrap">
<a class="brand" href="{root}">{LOGO}<span><b>{BRAND}</b><small>TANGENTLAB</small></span></a>
<button class="burger" type="button" aria-label="菜单" aria-expanded="false"><span></span><span></span><span></span></button>
<nav class="links">{links}</nav>
</div></header>
<main>
{body}
</main>
<footer><div class="wrap">
<div><b style="color:#fff">{BRAND}</b> · TangentLab<br>每期带你做出一个能用的成品。站内代码以 <a href="{root}LICENSE.txt">MIT 协议</a> 开源。</div>
<div>公众号 / 抖音 / B 站搜「{BRAND}」<br><a href="{root}feed.xml">RSS 订阅</a> · <a href="{root}projects/">项目源码</a></div>
</div></footer>
<button class="totop" type="button" aria-label="回到顶部">↑</button>
<div class="toast" role="status" aria-live="polite"></div>
<script src="{root}static/main.js?v={BUILD_ID}" defer></script>
</body>
</html>'''
    out = DIST / path; out.parent.mkdir(parents=True, exist_ok=True); out.write_text(doc)

# ---------------------------------------------------------------- 项目源码
def project_files(d):
    files = []
    for p in sorted(Path(d).rglob('*')):
        if p.is_file() and not (set(p.relative_to(d).parts) & SKIP_DIRS) and p.suffix not in ('.pyc', '.zip', '.png', '.jpg', '.webp', '.mp4'):
            files.append(p)
    # README 放第一个
    files.sort(key=lambda p: (p.name != 'README.md', str(p.relative_to(d))))
    return files

def blog_self_files():
    """本站自身的源码:build.py、static、LICENSE、_headers(不含 vendor 里的 GSAP 和收集脚本)"""
    keep = [ROOT / 'README.md', ROOT / 'build.py', ROOT / 'LICENSE', ROOT / 'public' / '_headers', ROOT / 'public' / 'robots.txt',
            ROOT / 'static' / 'style.css', ROOT / 'static' / 'main.js', ROOT / 'static' / 'favicon.svg', ROOT / 'scan-secrets.sh']
    return [p for p in keep if p.exists()], ROOT

def make_zip(slug, files, base):
    zp = DIST / 'downloads' / f'{slug}.zip'; zp.parent.mkdir(parents=True, exist_ok=True)
    with zipfile.ZipFile(zp, 'w', zipfile.ZIP_DEFLATED) as z:
        for f in files: z.write(f, f'{slug}/{f.relative_to(base)}')
        if not any(f.name == 'LICENSE' and f.parent == base for f in files): z.write(ROOT / 'LICENSE', f'{slug}/LICENSE')
    return zp

# ---------------------------------------------------------------- 构建
def build():
    global BUILD_ID
    BUILD_ID = datetime.datetime.now().strftime('%Y%m%d%H%M')
    if DIST.exists(): shutil.rmtree(DIST)
    DIST.mkdir()
    shutil.copytree(ROOT / 'static', DIST / 'static')
    for f in (ROOT / 'public').iterdir(): shutil.copy(f, DIST / f.name)
    shutil.copy(ROOT / 'LICENSE', DIST / 'LICENSE.txt')

    # ---- 文章
    posts = []
    for p in POSTS:
        src = ROOT / p['src']
        if not (src / 'article.md').exists(): print('跳过(无 article.md):', p['slug']); continue
        meta = read_meta(src / 'meta.txt')
        img_map = {}
        if (src / 'images').exists():
            for f in sorted((src / 'images').iterdir()):
                m = re.match(r'^(\d+)', f.name)
                if m and f.suffix.lower() in ('.png', '.jpg', '.jpeg', '.webp'):
                    img_map[str(int(m.group(1)))] = webp(f, DIST / 'posts' / p['slug'] / 'images' / f.stem)
        title, body = md_to_html((src / 'article.md').read_text(), img_map, 'images/')
        cover = f"posts/{p['slug']}/images/{img_map['1']}" if '1' in img_map else None
        cn = len(re.findall(r'[\u4e00-\u9fff]', re.sub(r'```.*?```', '', (src / 'article.md').read_text(), flags=re.S)))
        posts.append(dict(p, title=meta.get('标题', title), summary=meta.get('摘要', ''), body=body, cover=cover,
                          mins=max(3, round(cn / 450)), h1=title))
    proj_by = {x['slug']: x for x in PROJECTS}
    for i, p in enumerate(posts):
        tags = ''.join(f'<span class="tag">{esc(t)}</span>' for t in p['tags'])
        pj = proj_by.get(p['project'])
        pj_html = (f'<div class="panel reveal" style="margin-top:34px"><b>本文用到的源码</b><p style="margin:6px 0 14px;color:var(--ink2)">{esc(pj["name"])}:{esc(pj["desc"])}</p>'
                   f'<a class="btn line" href="../../projects/{pj["slug"]}/">查看源码和下载 →</a></div>') if pj else ''
        nav2 = ''
        if i + 1 < len(posts): nav2 += f'<a class="btn line" href="../{posts[i+1]["slug"]}/">← 上一篇</a>'
        if i > 0: nav2 += f'<a class="btn line" href="../{posts[i-1]["slug"]}/" style="margin-left:auto">下一篇 →</a>'
        body = f'''<div class="wrap narrow">
<div class="phead" style="padding-bottom:18px"><div class="crumb reveal"><a href="../../">首页</a> / <a href="../">文章</a></div></div>
<article class="post reveal">
<h1>{inline(p['h1'])}</h1>
<div class="meta"><span>{p['date']}</span><span>·</span><span>约 {p['mins']} 分钟读完</span>{tags}</div>
<div class="prose">{p['body']}</div>
{pj_html}
</article>
<div class="btns" style="margin:-30px 0 70px">{nav2}</div>
</div>'''
        page(f"posts/{p['slug']}/index.html", p['title'], body, desc=p['summary'], active='posts/', og_img=p['cover'])

    def post_row(p, root):
        thumb = f'style="background-image:url({root}{p["cover"]})"' if p['cover'] else ''
        tags = ''.join(f'<span class="tag">{esc(t)}</span>' for t in p['tags'])
        return (f'<a class="prow reveal" href="{root}posts/{p["slug"]}/"><div class="thumb" {thumb}></div><div class="body">'
                f'<div class="meta"><span>{p["date"]}</span>{tags}</div><h3>{esc(p["title"])}</h3><p>{esc(p["summary"])}</p>'
                f'<span class="more" style="color:var(--acc);font-weight:700;font-size:14px">阅读全文 →</span></div></a>')
    page('posts/index.html', '文章', f'''<div class="wrap"><div class="phead"><div class="crumb reveal"><a href="../">首页</a> / 文章</div>
<h1 class="reveal">全部文章</h1><p class="reveal">每篇都是自己跑通之后写的实操记录,命令和配置可以直接复制。共 {len(posts)} 篇。</p></div>
<div class="plist">{''.join(post_row(p, '../') for p in posts)}</div></div>''', active='posts/')

    # ---- 项目
    for pj in PROJECTS:
        if pj['dir']:
            base = ROOT / pj['dir']; files = project_files(base)
        else:
            files, base = blog_self_files()
        make_zip(pj['slug'], files, base)
        pj['files'] = [(f.relative_to(base).as_posix(), f) for f in files]
        pj['nfiles'] = len(files); pj['nlines'] = sum(f.read_text(errors='ignore').count('\n') + 1 for f in files)
        zsize = (DIST / 'downloads' / f"{pj['slug']}.zip").stat().st_size
        toc = ''.join(f'<li><a href="#f{k}">{esc(n)}<span>{f.read_text(errors="ignore").count(chr(10))+1}</span></a></li>' for k, (n, f) in enumerate(pj['files']))
        blocks = []
        for k, (n, f) in enumerate(pj['files']):
            txt = f.read_text(errors='ignore')
            if n.endswith('README.md') and k == 0:
                _, rd = md_to_html(txt, {}, '')
                blocks.append(f'<div class="prose reveal" id="f{k}">{rd}</div><h2 style="margin:40px 0 0;font-size:22px">完整源码</h2>')
                continue
            lang = EXT_LANG.get(f.suffix, 'text')
            blocks.append(f'<div class="fileh" id="f{k}"><h3>{esc(n)}</h3><span>{txt.count(chr(10))+1} 行 · {lang}</span></div>' + code_block(txt, lang, n))
        stack = ''.join(f'<span class="tag t">{esc(s)}</span>' for s in pj['stack'])
        body = f'''<div class="wrap"><div class="phead"><div class="crumb reveal"><a href="../../">首页</a> / <a href="../">项目开源</a></div>
<h1 class="reveal">{pj['icon']} {esc(pj['name'])}</h1><p class="reveal">{esc(pj['desc'])}</p>
<div class="meta reveal" style="margin-top:14px">{stack}<span>· {pj['nfiles']} 个文件 · {pj['nlines']} 行 · MIT 协议</span></div></div>
<div class="pbox"><div>{''.join(blocks)}</div>
<aside class="side reveal"><a class="btn pri" href="../../downloads/{pj['slug']}.zip" download>⬇ 下载源码 zip({zsize/1024:.0f} KB)</a>
<h4>文件</h4><ul class="files">{toc}</ul></aside></div></div>'''
        page(f"projects/{pj['slug']}/index.html", pj['name'], body, desc=pj['desc'], active='projects/')

    def proj_card(pj, root):
        stack = ''.join(f'<span class="tag t">{esc(s)}</span>' for s in pj['stack'][:4])
        return (f'<a class="card reveal" href="{root}projects/{pj["slug"]}/"><div class="body"><div class="proj-ico">{pj["icon"]}</div>'
                f'<h3>{esc(pj["name"])}</h3><p>{esc(pj["desc"])}</p><div class="meta">{stack}</div>'
                f'<div class="meta">{pj["nfiles"]} 个文件 · {pj["nlines"]} 行</div><span class="more">看源码 →</span></div></a>')
    page('projects/index.html', '项目开源', f'''<div class="wrap"><div class="phead"><div class="crumb reveal"><a href="../">首页</a> / 项目开源</div>
<h1 class="reveal">项目开源</h1><p class="reveal">视频和文章里做过的项目,源码全部放在这里:每个文件都能一键复制,也能整包下载 zip。代码以 MIT 协议开源,已去掉账号、Token、邮箱、IP 等隐私信息。</p></div>
<div class="grid" style="padding-bottom:80px">{''.join(proj_card(x, '../') for x in PROJECTS)}</div></div>''', active='projects/')

    # ---- 首页
    total_lines = sum(x['nlines'] for x in PROJECTS)
    def post_card(p):
        thumb = f'<div class="thumb" style="background-image:url({p["cover"]})"></div>' if p['cover'] else ''
        return (f'<a class="card reveal" href="posts/{p["slug"]}/">{thumb}<div class="body"><div class="meta"><span>{p["date"]}</span>'
                f'<span class="tag">{esc(p["tags"][0])}</span></div><h3>{esc(p["title"])}</h3><span class="more">阅读 →</span></div></a>')
    hero = f'''<section class="hero"><div class="wrap">
<div>
<span class="eyebrow reveal"><i></i>每期一个能用的成品</span>
<h1 class="reveal">跟着做,<br>就能<span class="hl">做出来</span>。</h1>
<p class="lead reveal">{BRAND}的博客。Cloudflare、自托管、AI 应用,每篇都是自己先跑通再写的实操记录,项目源码全部开源,代码块一键复制。</p>
<div class="btns reveal"><a class="btn pri" href="projects/">看开源项目 →</a><a class="btn ghost" href="posts/">读文章</a></div>
<div class="stats reveal"><div><b>{len(posts)}</b><span>篇实操文章</span></div><div><b>{len(PROJECTS)}</b><span>个开源项目</span></div><div><b>{total_lines:,}</b><span>行可复制代码</span></div></div>
</div>
<div class="graph reveal" aria-hidden="true">
<svg id="tangent" viewBox="0 0 460 400">
<defs><linearGradient id="g1" x1="0" x2="1"><stop offset="0" stop-color="#0f9f8f"/><stop offset="1" stop-color="#9aa4b2"/></linearGradient></defs>
<g stroke="rgba(255,255,255,.07)">{''.join(f'<line x1="{x}" y1="40" x2="{x}" y2="360"/>' for x in range(40, 421, 38))}{''.join(f'<line x1="40" y1="{y}" x2="420" y2="{y}"/>' for y in range(40, 361, 40))}</g>
<line x1="40" y1="200" x2="420" y2="200" stroke="rgba(255,255,255,.25)"/>
<path class="curve" d="{' '.join(('M' if k == 0 else 'L') + f'{40 + 380*k/120:.1f},{200 - 110*__import__("math").sin(2*__import__("math").pi*k/120):.1f}' for k in range(121))}" fill="none" stroke="url(#g1)" stroke-width="3.5" stroke-linecap="round"/>
<line class="tline" x1="0" y1="0" x2="0" y2="0" stroke="#f38020" stroke-width="3" stroke-linecap="round" opacity="0"/>
<circle class="halo" r="12" fill="#f38020" opacity="0"/>
<circle class="dot" r="7" fill="#fff" stroke="#f38020" stroke-width="3" opacity="0"/>
<text x="404" y="190" fill="#9aa4b2" font-size="14" font-family="monospace">x</text>
<text x="48" y="58" fill="#9aa4b2" font-size="14" font-family="monospace">f(x) = sin x</text>
</svg>
<div class="readout">tangent · <b>f'(x) = 0.94</b></div>
</div>
</div></section>'''
    nxt = '''<div class="next reveal"><div><b>📊 股票行情看板</b><span>AkShare + ECharts,自选股实时价、K 线和到价提醒</span></div>
<div><b>🔗 Workers + D1 短链接</b><span>用自己的域名做短链,带点击统计后台</span></div>
<div><b>💬 给本站加评论区</b><span>Pages Functions + D1 + Turnstile,不靠第三方</span></div></div>'''
    home = f'''{hero}
<section class="blk"><div class="wrap">
<div class="sec-h reveal"><div><h2>最新文章</h2><p>先看结论,再一步步跟着做</p></div><a href="posts/">全部文章 →</a></div>
<div class="grid">{''.join(post_card(p) for p in posts[:3])}</div>
</div></section>
<section class="blk" style="padding-top:0"><div class="wrap">
<div class="sec-h reveal"><div><h2>开源项目</h2><p>文章和视频里的代码,整包下载就能跑</p></div><a href="projects/">全部项目 →</a></div>
<div class="grid">{''.join(proj_card(x, '') for x in PROJECTS[:3])}</div>
</div></section>
<section class="blk" style="padding-top:0"><div class="wrap">
<div class="sec-h reveal"><div><h2>接下来要做</h2><p>每期一个完整项目,做完能直接用</p></div></div>
{nxt}
</div></section>'''
    page('index.html', BRAND, home, active='')

    # ---- 关于
    about = f'''<div class="wrap"><div class="phead"><div class="crumb reveal"><a href="../">首页</a> / 关于</div>
<h1 class="reveal">关于{BRAND}</h1><p class="reveal">TangentLab:正切,就是在曲线上找到那一刻的方向。</p></div>
<div class="about-grid"><div class="panel prose reveal">
<p>你好,这里是{BRAND}的博客。公众号、抖音、B 站上发的实操教程,最后都会整理到这里:<strong>文章可以慢慢看,代码可以直接复制,项目可以整包下载</strong>。</p>
<h2>这里的规矩</h2>
<ul><li>每期带你做出一个能用的成品,不只讲一条命令。</li><li>先自己跑通再写,截图来自真实操作,并且打过码。</li><li>只用免费额度能完成的方案,需要花钱或特殊硬件的会提前说明。</li><li>不碰 VPN、破解、盗版这类内容。</li></ul>
<h2>本站是怎么搭的</h2>
<p>一个不到 400 行、没有任何依赖的 Python 脚本把 Markdown 生成静态页面,动效用 GSAP(优先从国内 npmmirror 加载,失败再换 jsDelivr 和本站备份),部署在 Cloudflare Pages,域名 <code>blog.tangentlab.kdns.fr</code>。详细过程见<a href="../posts/cloudflare-pages-blog/">这篇文章</a>,源码在<a href="../projects/tangentlab-blog/">这里</a>。</p>
<h2>开源协议</h2>
<p>站内我们写的代码以 <a href="../LICENSE.txt">MIT 协议</a> 开源,随便用,保留版权声明即可。GSAP 动画库遵循 GreenSock 的标准免费许可,不属于 MIT 部分。</p>
</div>
<div class="panel reveal"><h3 style="margin-top:0">找到我们</h3><p style="color:var(--ink2)">公众号、抖音、B 站搜索「{BRAND}」。</p>
<h3>订阅</h3><p style="color:var(--ink2)">RSS:<a href="../feed.xml" style="color:var(--acc)">feed.xml</a></p>
<h3>反馈</h3><p style="color:var(--ink2)">代码跑不通、步骤看不懂,到公众号留言就行,我会更新到文章里。</p></div></div></div>'''
    page('about/index.html', '关于', about, active='about/')
    page('404.html', '页面不存在', '<div class="wrap narrow"><div class="phead" style="padding:120px 0"><h1>404 · 这页不存在</h1><p>可能链接写错了,回 <a href="/" style="color:var(--acc)">首页</a> 看看。</p></div></div>')

    # ---- RSS / sitemap
    items = ''.join(f'<item><title>{esc(p["title"])}</title><link>{SITE}/posts/{p["slug"]}/</link><guid>{SITE}/posts/{p["slug"]}/</guid>'
                    f'<pubDate>{datetime.datetime.strptime(p["date"], "%Y-%m-%d").strftime("%a, %d %b %Y 00:00:00 +0800")}</pubDate><description>{esc(p["summary"])}</description></item>' for p in posts)
    (DIST / 'feed.xml').write_text(f'<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>{BRAND}</title><link>{SITE}/</link><description>每期一个能用的成品</description><language>zh-CN</language>{items}</channel></rss>')
    urls = [''] + ['posts/', 'projects/', 'about/'] + [f'posts/{p["slug"]}/' for p in posts] + [f'projects/{x["slug"]}/' for x in PROJECTS]
    (DIST / 'sitemap.xml').write_text('<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' + ''.join(f'<url><loc>{SITE}/{u}</loc></url>' for u in urls) + '</urlset>')
    n = sum(1 for _ in DIST.rglob('*') if _.is_file())
    print(f'构建完成:{len(posts)} 篇文章、{len(PROJECTS)} 个项目、{n} 个文件 → dist/')

if __name__ == '__main__':
    build()

LICENSE

22 行 · text
LICENSE
MIT License

Copyright (c) 2026 正切AI实验室 (TangentLab)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

public/_headers

15 行 · text
public/_headers
/*
  X-Content-Type-Options: nosniff
  X-Frame-Options: DENY
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: camera=(), microphone=(), geolocation=()

/static/*
  Cache-Control: public, max-age=86400

/posts/*/images/*
  Cache-Control: public, max-age=604800, immutable

/downloads/*
  Content-Disposition: attachment

public/robots.txt

4 行 · text
public/robots.txt
User-agent: *
Allow: /
Sitemap: https://blog.tangentlab.kdns.fr/sitemap.xml

static/style.css

190 行 · css
static/style.css
:root{
  --bg:#f7f6f2;--paper:#ffffff;--ink:#1d2127;--ink2:#4b5563;--muted:#8a919c;--line:#e7e4dc;
  --acc:#f38020;--acc2:#ff9f45;--accbg:#fff3e8;--teal:#0f9f8f;--dark:#0c1117;--dark2:#151c25;
  --code:#0f172a;--codeink:#e2e8f0;--r:14px;--sh:0 1px 2px rgba(16,24,40,.04),0 8px 24px rgba(16,24,40,.06);
  --font:-apple-system,BlinkMacSystemFont,"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans CJK SC","Noto Sans SC",sans-serif;
  --mono:"JetBrains Mono","SFMono-Regular",Menlo,Consolas,"Liberation Mono","Noto Sans Mono CJK SC",monospace;
}
*{box-sizing:border-box}
html{scroll-behavior:smooth;-webkit-text-size-adjust:100%}
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--font);font-size:16px;line-height:1.75;-webkit-font-smoothing:antialiased}
a{color:inherit;text-decoration:none}
img{max-width:100%;display:block}
.wrap{max-width:1120px;margin:0 auto;padding:0 22px}
.narrow{max-width:780px}
/* ---------- header ---------- */
.nav{position:sticky;top:0;z-index:50;background:rgba(247,246,242,.82);backdrop-filter:saturate(1.6) blur(12px);-webkit-backdrop-filter:saturate(1.6) blur(12px);border-bottom:1px solid transparent;transition:border-color .2s}
.nav.scrolled{border-bottom-color:var(--line)}
.nav .wrap{display:flex;align-items:center;height:64px;gap:18px}
.brand{display:flex;align-items:center;gap:10px;font-weight:800;letter-spacing:.3px}
.brand svg{width:30px;height:30px}
.brand small{display:block;font-weight:500;font-size:11px;color:var(--muted);letter-spacing:1.5px;line-height:1}
.brand b{display:block;font-size:17px;line-height:1.2}
.links{margin-left:auto;display:flex;gap:4px}
.links a{padding:8px 14px;border-radius:999px;color:var(--ink2);font-size:15px;transition:background .2s,color .2s}
.links a:hover{background:#efece4;color:var(--ink)}
.links a.on{background:var(--ink);color:#fff}
.burger{display:none;margin-left:auto;background:none;border:0;width:42px;height:42px;border-radius:10px;cursor:pointer}
.burger span{display:block;width:20px;height:2px;background:var(--ink);margin:5px auto;border-radius:2px;transition:transform .25s,opacity .2s}
/* ---------- hero ---------- */
.hero{position:relative;overflow:hidden;background:radial-gradient(1200px 500px at 85% -10%,rgba(243,128,32,.28),transparent 60%),radial-gradient(900px 500px at -10% 110%,rgba(15,159,143,.22),transparent 60%),var(--dark);color:#f3f4f6}
.hero .wrap{display:grid;grid-template-columns:1.1fr .9fr;gap:40px;align-items:center;padding-top:84px;padding-bottom:88px}
.eyebrow{display:inline-flex;align-items:center;gap:8px;font-size:13px;letter-spacing:2px;color:#fdba74;border:1px solid rgba(253,186,116,.35);padding:5px 12px;border-radius:999px}
.eyebrow i{width:7px;height:7px;border-radius:50%;background:#22c55e;box-shadow:0 0 0 4px rgba(34,197,94,.2)}
.hero h1{font-size:clamp(34px,5.4vw,58px);line-height:1.15;margin:20px 0 18px;letter-spacing:.5px}
.hero h1 .hl{background:linear-gradient(90deg,var(--acc),var(--acc2));-webkit-background-clip:text;background-clip:text;color:transparent}
.hero p.lead{font-size:18px;color:#c7cdd6;max-width:560px;margin:0 0 30px}
.btns{display:flex;gap:12px;flex-wrap:wrap}
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 22px;border-radius:12px;font-weight:700;font-size:15px;transition:transform .2s,box-shadow .2s,background .2s}
.btn.pri{background:var(--acc);color:#fff;box-shadow:0 8px 24px rgba(243,128,32,.35)}
.btn.pri:hover{transform:translateY(-2px);box-shadow:0 12px 30px rgba(243,128,32,.45)}
.btn.ghost{border:1px solid rgba(255,255,255,.22);color:#f3f4f6}
.btn.ghost:hover{background:rgba(255,255,255,.08)}
.btn.line{border:1px solid var(--line);background:var(--paper)}
.btn.line:hover{border-color:var(--acc);color:var(--acc)}
.graph{position:relative;aspect-ratio:1.15/1;border-radius:22px;background:linear-gradient(180deg,rgba(255,255,255,.06),rgba(255,255,255,.02));border:1px solid rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.06)}
.graph svg{position:absolute;inset:0;width:100%;height:100%}
.graph .readout{position:absolute;left:18px;bottom:14px;font-family:var(--mono);font-size:13px;color:#9aa4b2}
.graph .readout b{color:#fdba74;font-weight:600}
.stats{display:flex;gap:34px;margin-top:38px;flex-wrap:wrap}
.stats div b{display:block;font-size:28px;color:#fff;font-family:var(--mono)}
.stats div span{font-size:13px;color:#9aa4b2}
/* ---------- sections ---------- */
section.blk{padding:72px 0}
.sec-h{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:28px}
.sec-h h2{font-size:clamp(24px,3vw,32px);margin:0;line-height:1.3}
.sec-h p{margin:6px 0 0;color:var(--muted)}
.sec-h a{color:var(--acc);font-weight:700;white-space:nowrap}
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:20px}
.grid.two{grid-template-columns:repeat(2,1fr)}
.card{position:relative;display:flex;flex-direction:column;background:var(--paper);border:1px solid var(--line);border-radius:var(--r);overflow:hidden;box-shadow:var(--sh);transition:transform .25s,box-shadow .25s,border-color .25s;will-change:transform}
.card:hover{transform:translateY(-4px);box-shadow:0 2px 4px rgba(16,24,40,.05),0 18px 40px rgba(16,24,40,.10);border-color:#e0d6c8}
.card .thumb{aspect-ratio:1080/460;background:#eee center/cover no-repeat;border-bottom:1px solid var(--line)}
.card .body{padding:18px 20px 20px;display:flex;flex-direction:column;gap:8px;flex:1}
.card h3{margin:0;font-size:18px;line-height:1.45}
.card p{margin:0;color:var(--ink2);font-size:14.5px;line-height:1.7}
.meta{display:flex;gap:10px;flex-wrap:wrap;align-items:center;font-size:12.5px;color:var(--muted)}
.tag{display:inline-block;padding:2px 9px;border-radius:999px;background:var(--accbg);color:#c2410c;font-size:12px;font-weight:600}
.tag.t{background:#e6f6f4;color:#0f766e}
.card .more{margin-top:auto;padding-top:6px;color:var(--acc);font-weight:700;font-size:14px}
.proj-ico{width:46px;height:46px;border-radius:12px;display:grid;place-items:center;font-size:24px;background:var(--accbg)}
.next{background:var(--paper);border:1px dashed #e2c9ad;border-radius:var(--r);padding:26px 28px;display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
.next div b{display:block;margin-bottom:4px}
.next div span{color:var(--ink2);font-size:14px}
/* ---------- page head ---------- */
.phead{padding:64px 0 28px}
.phead h1{font-size:clamp(28px,4vw,42px);margin:0 0 10px;line-height:1.3}
.phead p{color:var(--ink2);margin:0;max-width:720px}
.crumb{font-size:13px;color:var(--muted);margin-bottom:14px}
.crumb a:hover{color:var(--acc)}
/* list */
.plist{display:flex;flex-direction:column;gap:16px;padding-bottom:80px}
.prow{display:grid;grid-template-columns:300px 1fr;gap:22px;background:var(--paper);border:1px solid var(--line);border-radius:var(--r);overflow:hidden;box-shadow:var(--sh);transition:transform .25s,box-shadow .25s}
.prow:hover{transform:translateY(-3px);box-shadow:0 18px 40px rgba(16,24,40,.10)}
.prow .thumb{background:#0c1117 center/contain no-repeat;min-height:128px;align-self:stretch}
.prow .body{padding:20px 22px 20px 0;display:flex;flex-direction:column;gap:8px}
.prow h3{margin:0;font-size:19px;line-height:1.45}
.prow p{margin:0;color:var(--ink2);font-size:14.5px}
/* ---------- article ---------- */
.post{background:var(--paper);border:1px solid var(--line);border-radius:18px;padding:44px clamp(20px,5vw,56px);box-shadow:var(--sh);margin-bottom:60px}
.post h1{font-size:clamp(26px,3.6vw,36px);line-height:1.35;margin:0 0 14px}
.post .meta{margin-bottom:26px}
.prose{font-size:17px;line-height:1.9;color:#262b33}
.prose p{margin:0 0 18px}
.prose h2{font-size:23px;margin:42px 0 16px;padding-left:12px;border-left:4px solid var(--acc);line-height:1.4}
.prose h3{font-size:19px;margin:30px 0 12px}
.prose strong{color:#b45309}
.prose a{color:var(--acc);border-bottom:1px solid rgba(243,128,32,.35)}
.prose ul,.prose ol{padding-left:24px;margin:0 0 18px}
.prose li{margin:4px 0}
.prose blockquote{margin:0 0 18px;padding:12px 18px;border-radius:10px;background:var(--accbg);color:#7c2d12}
.prose blockquote p:last-child{margin:0}
.prose figure{margin:26px 0}
.prose figure img{border-radius:12px;border:1px solid var(--line);margin:0 auto}
.prose figcaption{text-align:center;font-size:13.5px;color:var(--muted);margin-top:8px}
.prose :not(pre)>code{font-family:var(--mono);font-size:.88em;background:var(--accbg);color:#c2410c;padding:1px 6px;border-radius:5px;word-break:break-all}
.tbl{overflow-x:auto;margin:0 0 20px;border:1px solid var(--line);border-radius:12px}
.prose table{border-collapse:collapse;width:100%;font-size:14.5px;line-height:1.6}
.prose th,.prose td{padding:10px 12px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}
.prose th{background:#faf8f3;font-weight:700;white-space:nowrap}
.prose tr:last-child td{border-bottom:0}
/* ---------- code ---------- */
.code{position:relative;margin:0 0 22px;border-radius:12px;background:var(--code);overflow:hidden;box-shadow:0 6px 18px rgba(15,23,42,.18)}
.code .bar{display:flex;align-items:center;gap:10px;padding:8px 10px 8px 14px;background:#111c33;border-bottom:1px solid rgba(255,255,255,.06);font-family:var(--mono);font-size:12.5px;color:#94a3b8}
.code .bar .dots{display:flex;gap:6px}
.code .bar .dots i{width:10px;height:10px;border-radius:50%;background:#334155}
.code .bar .dots i:nth-child(1){background:#f87171}.code .bar .dots i:nth-child(2){background:#fbbf24}.code .bar .dots i:nth-child(3){background:#34d399}
.code .bar .fn{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.copy{appearance:none;border:1px solid rgba(255,255,255,.14);background:rgba(255,255,255,.06);color:#e2e8f0;font:600 12.5px var(--font);padding:5px 12px;border-radius:8px;cursor:pointer;transition:background .2s,border-color .2s,color .2s;white-space:nowrap}
.copy:hover{background:rgba(255,255,255,.14)}
.copy.ok{background:#16a34a;border-color:#16a34a;color:#fff}
.code pre{margin:0;padding:16px 18px;overflow:auto;max-height:none}
.code code{font-family:var(--mono);font-size:13.5px;line-height:1.7;color:var(--codeink);white-space:pre;tab-size:4}
details.long>summary{list-style:none;cursor:pointer;padding:10px 14px;background:#111c33;color:#fdba74;font-size:13px;border-top:1px solid rgba(255,255,255,.06)}
details.long>summary::-webkit-details-marker{display:none}
details.long[open]>summary .o{display:none}
details.long:not([open])>summary .c{display:none}
.code .peek{max-height:340px;overflow:hidden;position:relative}
.code .peek::after{content:"";position:absolute;left:0;right:0;bottom:0;height:80px;background:linear-gradient(transparent,var(--code))}
.code.open .peek{max-height:none}.code.open .peek::after{display:none}
.expand{display:block;width:100%;border:0;border-top:1px solid rgba(255,255,255,.06);background:#111c33;color:#fdba74;font:600 13px var(--font);padding:10px;cursor:pointer}
/* project page */
.pbox{display:grid;grid-template-columns:1fr 300px;gap:28px;align-items:start;padding-bottom:80px}
.side{position:sticky;top:84px;background:var(--paper);border:1px solid var(--line);border-radius:var(--r);padding:20px;box-shadow:var(--sh)}
.side h4{margin:0 0 10px;font-size:14px;color:var(--muted);letter-spacing:1px}
.side .btn{width:100%;justify-content:center;margin-bottom:14px}
.files{list-style:none;padding:0;margin:0;font-family:var(--mono);font-size:13px}
.files li a{display:flex;justify-content:space-between;gap:8px;padding:5px 8px;border-radius:7px;color:var(--ink2)}
.files li a:hover{background:#f3efe7;color:var(--ink)}
.files li a span{color:var(--muted)}
.fileh{display:flex;align-items:baseline;gap:10px;margin:34px 0 10px;scroll-margin-top:84px}
.fileh h3{margin:0;font-family:var(--mono);font-size:16px}
.fileh span{color:var(--muted);font-size:13px}
/* about */
.about-grid{display:grid;grid-template-columns:1.3fr .7fr;gap:26px;padding-bottom:80px}
.panel{background:var(--paper);border:1px solid var(--line);border-radius:var(--r);padding:26px 28px;box-shadow:var(--sh)}
/* footer */
footer{background:var(--dark);color:#9aa4b2;padding:44px 0;font-size:14px}
footer .wrap{display:flex;justify-content:space-between;gap:20px;flex-wrap:wrap}
footer a:hover{color:#fff}
.totop{position:fixed;right:18px;bottom:18px;width:44px;height:44px;border-radius:50%;border:1px solid var(--line);background:var(--paper);box-shadow:var(--sh);cursor:pointer;font-size:18px;opacity:0;pointer-events:none;transition:opacity .25s;z-index:40}
.totop.show{opacity:1;pointer-events:auto}
.toast{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);background:var(--ink);color:#fff;padding:9px 16px;border-radius:10px;font-size:14px;opacity:0;pointer-events:none;transition:opacity .2s;z-index:60}
.toast.show{opacity:1}
/* motion: only hidden while GSAP is loading; if it fails, the class is removed */
.anim-pending .reveal{opacity:0}
.anim-pending .hero .reveal{opacity:0}
@media (prefers-reduced-motion:reduce){.anim-pending .reveal{opacity:1}*{transition:none!important;scroll-behavior:auto!important}}
/* ---------- responsive ---------- */
@media (max-width:960px){
  .hero .wrap{grid-template-columns:1fr;padding-top:56px;padding-bottom:60px}
  .graph{max-width:520px;width:100%}
  .grid{grid-template-columns:repeat(2,1fr)}
  .pbox,.about-grid{grid-template-columns:1fr}
  .side{position:static;order:-1}
  .next{grid-template-columns:1fr}
}
@media (max-width:680px){
  body{font-size:15.5px}
  .wrap{padding:0 16px}
  .burger{display:block}
  .links{position:fixed;left:0;right:0;top:64px;flex-direction:column;gap:2px;padding:10px 16px 16px;background:rgba(247,246,242,.98);border-bottom:1px solid var(--line);transform:translateY(-8px);opacity:0;pointer-events:none;transition:opacity .2s,transform .2s}
  .links a{padding:12px 14px;font-size:16px}
  .nav.open .links{opacity:1;transform:none;pointer-events:auto}
  .nav.open .burger span:nth-child(1){transform:translateY(7px) rotate(45deg)}
  .nav.open .burger span:nth-child(2){opacity:0}
  .nav.open .burger span:nth-child(3){transform:translateY(-7px) rotate(-45deg)}
  .grid,.grid.two{grid-template-columns:1fr}
  .prow{grid-template-columns:1fr}
  .prow .thumb{min-height:0;aspect-ratio:1080/460}
  .prow .body{padding:0 18px 18px}
  section.blk{padding:52px 0}
  .sec-h{flex-direction:column;align-items:flex-start}
  .post{padding:28px 18px;border-radius:14px}
  .prose{font-size:16px}
  .stats{gap:22px}
  .code code{font-size:12.5px}
}
.pbox>div,.about-grid>div,.post,.prose{min-width:0}
.code code{line-height:1.6}

static/main.js

133 行 · javascript
static/main.js
/* 正切AI实验室 blog — UI + GSAP motion (progressive enhancement) */
(function () {
  var doc = document.documentElement;

  /* ---------- nav ---------- */
  var nav = document.querySelector('.nav');
  var burger = document.querySelector('.burger');
  if (burger) burger.addEventListener('click', function () {
    var open = nav.classList.toggle('open');
    burger.setAttribute('aria-expanded', open ? 'true' : 'false');
  });
  var totop = document.querySelector('.totop');
  function onScroll() {
    var y = window.scrollY || 0;
    if (nav) nav.classList.toggle('scrolled', y > 8);
    if (totop) totop.classList.toggle('show', y > 600);
  }
  window.addEventListener('scroll', onScroll, { passive: true }); onScroll();
  if (totop) totop.addEventListener('click', function () { window.scrollTo({ top: 0, behavior: 'smooth' }); });

  /* ---------- copy buttons ---------- */
  var toast = document.querySelector('.toast');
  function showToast(msg) {
    if (!toast) return; toast.textContent = msg; toast.classList.add('show');
    clearTimeout(showToast.t); showToast.t = setTimeout(function () { toast.classList.remove('show'); }, 1600);
  }
  function fallbackCopy(text) {
    var ta = document.createElement('textarea');
    ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.top = '-1000px';
    document.body.appendChild(ta); ta.select();
    var ok = false; try { ok = document.execCommand('copy'); } catch (e) {}
    document.body.removeChild(ta); return ok;
  }
  document.addEventListener('click', function (e) {
    var b = e.target.closest('.copy'); if (!b) return;
    var code = b.closest('.code').querySelector('code').innerText;
    function done(ok) {
      b.classList.toggle('ok', ok); b.textContent = ok ? '已复制 ✓' : '复制失败';
      showToast(ok ? '代码已复制到剪贴板' : '复制失败,请手动选择');
      setTimeout(function () { b.classList.remove('ok'); b.textContent = '复制'; }, 1800);
    }
    if (navigator.clipboard && window.isSecureContext) {
      navigator.clipboard.writeText(code).then(function () { done(true); }, function () { done(fallbackCopy(code)); });
    } else { done(fallbackCopy(code)); }
  });
  document.addEventListener('click', function (e) {
    var b = e.target.closest('.expand'); if (!b) return;
    var box = b.closest('.code'); var open = box.classList.toggle('open');
    b.textContent = open ? '收起' : b.getAttribute('data-label');
    if (!open) box.scrollIntoView({ block: 'nearest' });
    if (window.ScrollTrigger) window.ScrollTrigger.refresh();
  });

  /* ---------- GSAP loader: npmmirror → jsDelivr → 本站备份 ---------- */
  var V = '3.15.0';
  var SOURCES = [
    'https://registry.npmmirror.com/gsap/' + V + '/files/dist/',
    'https://cdn.jsdelivr.net/npm/gsap@' + V + '/dist/',
    (document.body.getAttribute('data-root') || '/') + 'static/vendor/'
  ];
  function loadScript(src, timeout) {
    return new Promise(function (res, rej) {
      var s = document.createElement('script'); var t = setTimeout(function () { s.onload = s.onerror = null; rej(); }, timeout);
      s.src = src; s.async = true;
      s.onload = function () { clearTimeout(t); res(); };
      s.onerror = function () { clearTimeout(t); rej(); };
      document.head.appendChild(s);
    });
  }
  function tryFrom(i) {
    if (i >= SOURCES.length) return Promise.reject();
    return loadScript(SOURCES[i] + 'gsap.min.js', 2500)
      .then(function () { return loadScript(SOURCES[i] + 'ScrollTrigger.min.js', 2500); })
      .catch(function () { return window.gsap && window.ScrollTrigger ? null : tryFrom(i + 1); });
  }
  function release() { doc.classList.remove('anim-pending'); }
  if (!doc.classList.contains('anim-pending')) return; // reduced motion: no animation at all
  tryFrom(0).then(function () {
    if (!window.gsap) return release();
    animate(window.gsap, window.ScrollTrigger);
  }, release);

  function animate(gsap, ST) {
    gsap.registerPlugin(ST);
    var late = !doc.classList.contains('anim-pending'); // 超时后才加载完:内容已显示,不再隐藏
    gsap.defaults({ ease: 'power3.out', duration: 0.8 });
    var mm = gsap.matchMedia();
    mm.add({ motion: '(prefers-reduced-motion: no-preference)', reduce: '(prefers-reduced-motion: reduce)' }, function (ctx) {
      if (ctx.conditions.reduce) { gsap.set('.reveal', { autoAlpha: 1 }); release(); return; }
      if (late) { tangent(gsap); return; }
      var heroItems = gsap.utils.toArray('.hero .reveal');
      var rest = gsap.utils.toArray('.reveal').filter(function (el) { return heroItems.indexOf(el) < 0; });
      gsap.set(rest, { autoAlpha: 0, y: 28 });
      release();
      if (heroItems.length) {
        gsap.timeline().fromTo(heroItems, { autoAlpha: 0, y: 26 }, { autoAlpha: 1, y: 0, stagger: 0.09, duration: 0.9 });
      }
      ST.batch(rest, {
        start: 'top 90%', once: true,
        onEnter: function (batch) { gsap.to(batch, { autoAlpha: 1, y: 0, stagger: 0.08, overwrite: true }); }
      });
      tangent(gsap);
    });
  }

  /* ---------- 正切:沿曲线移动的点和它的切线 ---------- */
  function tangent(gsap) {
    var svg = document.querySelector('#tangent'); if (!svg) return;
    var curve = svg.querySelector('.curve'), dot = svg.querySelector('.dot'), halo = svg.querySelector('.halo'),
        tl = svg.querySelector('.tline'), out = document.querySelector('.readout b');
    var X0 = 40, X1 = 420, YM = 200, A = 110, TAU = Math.PI * 2;
    function px(x) { return X0 + (X1 - X0) * x / TAU; }
    function py(x) { return YM - A * Math.sin(x); }
    var len = curve.getTotalLength();
    gsap.fromTo(curve, { strokeDasharray: len, strokeDashoffset: len }, { strokeDashoffset: 0, duration: 1.6, ease: 'power2.inOut', delay: 0.3 });
    var st = { x: 0.35 };
    var k = (X1 - X0) / TAU; // px per unit x
    function draw() {
      var x = st.x, cx = px(x), cy = py(x), m = Math.cos(x);
      var dx = 1, dy = -A * m / k, n = Math.sqrt(dx * dx + dy * dy), L = 95;
      dx = dx / n * L; dy = dy / n * L;
      dot.setAttribute('cx', cx); dot.setAttribute('cy', cy);
      halo.setAttribute('cx', cx); halo.setAttribute('cy', cy);
      tl.setAttribute('x1', cx - dx); tl.setAttribute('y1', cy - dy); tl.setAttribute('x2', cx + dx); tl.setAttribute('y2', cy + dy);
      if (out) out.textContent = "f'(x) = " + m.toFixed(2);
    }
    draw();
    gsap.fromTo([dot, halo, tl], { autoAlpha: 0 }, { autoAlpha: 1, duration: 0.6, delay: 1.5 });
    gsap.to(st, { x: TAU - 0.35, duration: 7, ease: 'sine.inOut', repeat: -1, yoyo: true, delay: 1.5, onUpdate: draw });
    gsap.to(halo, { attr: { r: 18 }, autoAlpha: 0.15, duration: 1.2, repeat: -1, yoyo: true, ease: 'sine.inOut' });
  }
})();

static/favicon.svg

2 行 · text
static/favicon.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#0c1117"/><path d="M8 42 C 20 42, 24 18, 36 18 S 52 34, 58 30" fill="none" stroke="#9aa4b2" stroke-width="4" stroke-linecap="round"/><line x1="14" y1="30" x2="54" y2="10" stroke="#f38020" stroke-width="5" stroke-linecap="round"/><circle cx="34" cy="20" r="6" fill="#f38020"/></svg>

scan-secrets.sh

22 行 · bash
scan-secrets.sh
#!/usr/bin/env bash
# 发布前扫描 dist/ 和所有源码 zip,确认没有邮箱、Token、账号 ID、内网/真实 IP
# 用法:./scan-secrets.sh   (需要 ripgrep:apt install ripgrep)
set -uo pipefail
cd "$(dirname "$0")"
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
for z in dist/downloads/*.zip; do unzip -q -o "$z" -d "$TMP"; done
T="dist $TMP"; X=(--glob '!**/vendor/**' --glob '!*.webp' --glob '!*.png')
bad=0
check() { # 名称 正则 允许的白名单正则
  local hits; hits=$(rg -o -i --no-filename "${X[@]}" "$2" $T | sort -u | { [ -n "${3:-}" ] && rg -v -i "$3" || cat; })
  if [ -n "$hits" ]; then echo "❌ $1:"; echo "$hits" | sed 's/^/   /'; bad=1; else echo "✅ $1:没有发现"; fi
}
check "邮箱地址"      '[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}' '@example\.com$'
check "IPv4 地址"     '\b([0-9]{1,3}\.){3}[0-9]{1,3}\b' '^(127\.0\.0\.1|1\.1\.1\.1|192\.0\.2\.[0-9]+|198\.51\.100\.[0-9]+|203\.0\.113\.[0-9]+)$'
check "Cloudflare API Token / 32 位十六进制 ID" '\b[0-9a-f]{32}\b|\b[A-Za-z0-9_-]{40}\b'
check "常见密钥字段"  '(api[_-]?key|secret[_-]?key|access[_-]?token|bearer)\s*[:=]\s*["'"'"'][^"'"'"'<]{8,}'
check "Cookie 头"     '^(set-)?cookie:\s*\S+'
check "本机绝对路径"  '/(workspace|home/box)/[^ "'"'"']*'
echo "扫描范围:dist/ 下 $(find dist -type f | wc -l) 个文件 + $(ls dist/downloads/*.zip | wc -l) 个源码包(不含第三方 GSAP 文件和图片)"
exit $bad