#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
U8 · 产品原型站 生成器
=====================

把 `文档/` 下的**功能原型**编译成一个纯静态站点（输出到 `site/`），
给开发和老板快速浏览「可点击演示版」与「功能 PRD」。

设计原则
--------
* **白名单收录**：站点只收录在 `MODULES` 里显式登记的功能模块。
  未登记的内容不会出现在站点里（线框图、会话存档、过程文档都不在名单里）。
  新增功能时显式加一条，避免"文档目录里有什么就上什么"。
* **零依赖**：只用 Python 标准库，不联网、不加载 CDN、不需要 Node。
* **演示页原样保留**：可点击演示（`<模块>/index.html`）按字节复制，
  只把它里面的 PRD 链接改写到站点新路径。
* **PRD 统一渲染**：Markdown 渲染成风格统一的阅读页，带目录与面包屑。

用法
----
    python3 site_build/build_site.py          # 生成 site/
    python3 site_build/verify_site.py         # 自检：编码 + 站内死链
"""

from __future__ import annotations

import argparse
import html as htmllib
import json
import os
import re
import shutil
import sys
from datetime import datetime
from pathlib import Path
from urllib.parse import quote, unquote

# --------------------------------------------------------------------------
# 站点配置
# --------------------------------------------------------------------------

SITE_BRAND = "U8"
SITE_SHORT = "产品原型站"
SITE_NAME = f"{SITE_BRAND} · {SITE_SHORT}"
SITE_DESC = "功能原型演示与产品需求文档 · 供开发与决策快速浏览"

# --------------------------------------------------------------------------
# 收录白名单
# --------------------------------------------------------------------------
# 只有登记在这里的功能模块才会进站。字段说明：
#   dir    源目录名（对应 文档/<dir>/）
#   group  分组名，用于列表分组与筛选
#   blurb  一句话说明（列表里的补充信息、也参与搜索）
#   order  组内排序（小的在前）
#   name   对外显示名（不写则自动取 PRD 标题整理后的名字）
#   section 归属页签："main"（功能演示列表，默认）或 "other"（其他资料）
#   alias  搜索别名（PRD 原标题里的关键词，便于用「换绑」「Reaction」也能搜到）
#
# 新增功能：在数组里加一条。要从站点移除：删掉对应条目。

MODULES: list[dict] = [
    # ---- 会话与消息 ----
    {"dir": "快速建群", "name": "单聊快速建群", "alias": ["发起群聊", "新建群组", "单聊"], "group": "会话与消息", "order": 10,
     "blurb": "单聊里一键把当前对话的人带进建群选人页，沿用现有建群流程。"},
    {"dir": "搜索", "name": "搜索", "alias": ["会话列表搜索", "全局分类搜索", "聊天内部搜索"], "group": "会话与消息", "order": 20,
     "blurb": "会话列表搜索、全局分类搜索、聊天内部搜索三套入口的完整规则。"},
    {"dir": "文件夹", "name": "聊天文件夹", "alias": ["会话分组", "分组"], "group": "会话与消息", "order": 30,
     "blurb": "把会话拖进文件夹做分组，含未读汇总与排序规则。"},
    {"dir": "表情回复", "name": "消息表情回复", "alias": ["Reaction", "表情", "emoji"], "group": "会话与消息", "order": 40,
     "blurb": "对单条消息做轻量情绪反馈，含合并展示与取消规则。"},
    {"dir": "消息选择", "name": "消息选择", "alias": ["多选", "批量转发", "批量删除", "收藏"], "group": "会话与消息", "order": 50,
     "blurb": "长按进入多选、批量转发 / 删除 / 收藏的交互定义。"},
    {"dir": "搜索功能", "name": "会话列表搜索", "alias": ["聊天历史", "查缺补漏"], "group": "会话与消息", "order": 60,
     "section": "other",
     "blurb": "会话列表搜索的历史查缺补漏报告与早期 PRD 版本。"},

    # ---- 资料与设置 ----
    {"dir": "用户姓名", "name": "用户原名显示", "alias": ["用户自己的原名", "备注", "用户详情"], "group": "资料与设置", "order": 10,
     "blurb": "用户详情页展示「用户自己的原名」，与好友备注区分。"},
    {"dir": "群标签", "name": "群成员标签", "alias": ["标签", "自定义名称"], "group": "资料与设置", "order": 20,
     "blurb": "群成员可自定义名称的标签体系，含权限与排序。"},
    {"dir": "密码登录", "name": "登录密码", "alias": ["密码登录", "设置密码", "忘记密码", "找回密码"], "group": "资料与设置", "order": 30,
     "blurb": "登录密码的设置、密码登录与忘记密码找回全流程。"},
    {"dir": "修改手机号", "name": "修改手机号", "alias": ["换绑", "手机号"], "group": "资料与设置", "order": 40,
     "blurb": "换绑手机号的验证链路、失败态与风控限制。"},
    {"dir": "设备管理", "name": "设备管理", "alias": ["登录设备", "在线会话", "扫码确认", "Telegram"], "group": "资料与设置", "order": 50,
     "blurb": "登录设备列表、在线会话与扫码确认授权，对齐 Telegram。"},
    {"dir": "隐私设置", "name": "隐私设置", "alias": ["最后上线时间", "在线状态", "手机号码"], "group": "资料与设置", "order": 60,
     "blurb": "最后上线时间、在线状态与手机号码的可见性控制。"},

    # ---- 资产与钱包 ----
    {"dir": "虚拟卡", "name": "虚拟卡", "alias": ["银行卡", "虚拟银行卡", "开卡", "KYC", "持卡人", "THPay", "卡充值", "卡转出", "U8Card"], "group": "资产与钱包", "order": 10,
     "blurb": "在资产模块申请虚拟卡：开卡 KYC、卡信息出示、卡充值 / 转出与交易记录全流程。"},
    {"dir": "虚拟卡后台", "name": "虚拟卡运营后台", "alias": ["银行卡后台", "开卡审核", "卡片管理", "卡片流水", "对账差异", "人工上分", "风控后台"], "group": "资产与钱包", "order": 20,
     "blurb": "运营 / 风控后台原型：开卡审核、卡片管理、卡片流水与对账差异；功能清单见「虚拟卡」PRD 第 10 章。"},

    # ---- 运营与配置 ----
    {"dir": "发红包引导改版", "group": "运营与配置", "order": 10,
     "section": "other",
     "blurb": "红包入口的引导改版演示页。"},
]

GROUP_ORDER = ["会话与消息", "资料与设置", "资产与钱包", "运营与配置"]

# 源仓库里改过名 / 拼错的文档链接 → 现有文档名
RENAMED_DOCS = {
    "02_PRD-用户原名.md": "02_PRD-用户姓名.md",
    "02_PRD-用户原名.html": "02_PRD-用户姓名.html",
}


SECTION_MAIN = "main"
SECTION_OTHER = "other"

TABS = [
    ("main", "功能演示列表", "index.html", "主要功能的可点击演示与 PRD"),
    ("other", "其他资料", "others.html", "非主要功能的演示与过程资料"),
]


def section_of(module: dict) -> str:
    return module.get("section", SECTION_MAIN)


def tabbar_html(depth: int, active: str, counts: dict[str, int] | None = None) -> str:
    """页签栏。counts 用来在标签旁显示条数，例如「功能演示列表 11」。"""
    counts = counts or {}
    items = []
    for key, label, href, hint in TABS:
        cls = ' class="on"' if key == active else ""
        n = counts.get(key)
        badge = f'<span class="tabs-n">{n}</span>' if n is not None else ""
        items.append(
            f'<a{cls} href="{rel_href(depth, href)}" title="{htmllib.escape(hint, quote=True)}">'
            f"{htmllib.escape(label)}{badge}</a>"
        )
    return '<nav class="tabs" aria-label="页面切换">' + "".join(items) + "</nav>"


def group_rank(name: str) -> int:
    return GROUP_ORDER.index(name) if name in GROUP_ORDER else len(GROUP_ORDER)


# --------------------------------------------------------------------------
# 路径与文本工具
# --------------------------------------------------------------------------


def link_seg(seg: str) -> str:
    """单个路径片段 → URL 安全形式（中文转百分号编码）。"""
    return quote(seg, safe="")


def url_join(*parts: str) -> str:
    """拼 URL 路径（逐段编码）。"""
    return "/".join(link_seg(p) for p in parts if p)


def fs_join(*parts: str) -> str:
    """拼文件系统路径（保留中文原名，便于在服务器上排查）。"""
    return "/".join(p for p in parts if p)


def rel_href(depth: int, target: str) -> str:
    """从深度为 depth 的页面指向站点根下的 target。"""
    prefix = "../" * depth
    return prefix + target if prefix else target


def human_size(n: int) -> str:
    if n < 1024:
        return f"{n} B"
    if n < 1024 * 1024:
        return f"{n / 1024:.0f} KB"
    return f"{n / 1024 / 1024:.1f} MB"


def fmt_date(ts: float) -> str:
    return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")


def slugify(text: str) -> str:
    """标题 → 锚点 id，兼容中文（保留汉字，去掉标点）。"""
    text = re.sub(r"<[^>]+>", "", text)
    text = htmllib.unescape(text).strip().lower()
    out = []
    for ch in text:
        if ch.isalnum() or ch in "-_":
            out.append(ch)
        elif ch in " \t/·、,，.。:：()（）[]【】":
            out.append("-")
    s = re.sub(r"-{2,}", "-", "".join(out)).strip("-")
    return s or "section"


def strip_tags(s: str) -> str:
    return htmllib.unescape(re.sub(r"<[^>]+>", "", s)).strip()


def clean_module_name(title: str, fallback: str) -> str:
    """把 PRD 标题收拾成列表里能用的功能名。

    例：`U8 Chat 2.0 · 登录密码（密码登录 / 设置密码 / 忘记密码）产品需求文档（PRD）`
        → `登录密码`
    括号里的副标题对外显示时去掉（列表里太长），但完整标题仍会进搜索索引。
    """
    name = re.sub(r"^U8 Chat(?: 2\.0)?\s*[·\-]\s*", "", title).strip()
    name = re.sub(r"\s*产品需求文档.*$", "", name)
    name = re.sub(r"\s*\(?PRD\)?$", "", name, flags=re.I)
    name = re.sub(r"（[^（）]*）", "", name)          # 全角括号副标题
    name = re.sub(r"\([^()]*\)", "", name)          # 半角括号副标题
    name = name.replace("「", "").replace("」", "")
    name = re.sub(r"^(?:移动端|iOS|Android)\s*", "", name)
    name = re.sub(r"\s{2,}", " ", name).strip(" ·-—")
    return name.strip() or fallback


def short_doc_name(title: str) -> str:
    """把 PRD 标题压短，用作列表里的第二个链接文案。"""
    name = re.sub(r"^U8 Chat(?: 2\.0)?\s*[·\-]\s*", "", title)
    name = re.sub(r"产品需求文档.*$", "", name).strip()
    name = name.replace("（PRD）", "").replace("(PRD)", "").strip()
    return (name[:22] + "…") if len(name) > 23 else (name or "PRD")


# --------------------------------------------------------------------------
# 活动台账：记录「哪些功能是新上传的、哪些是更新过的」
# --------------------------------------------------------------------------
# 文件系统的时间戳靠不住：仓库被拷贝/解压后，所有文件的创建时间会变得一样，
# ctime 还会被 git 操作刷新。所以这里维护一份台账，按内容指纹判断：
#   * 台账里没有的模块           → 本次「新上传」
#   * 指纹变了、文件也更新了     → 本次「有更新」
#   * 指纹没变                   → 不算新也不算更新
# 台账记的是「首次收录日期」与「最近更新日期」，构建时写回。

import hashlib

ACTIVITY_FILE = Path(__file__).resolve().parent / "activity.json"
RECENT_LIMIT = 3


def module_fingerprint(files: list[Path]) -> str:
    """按文件内容算指纹，用来判断模块内容是否变化。"""
    h = hashlib.sha256()
    for f in sorted(files):
        try:
            h.update(f.name.encode("utf-8"))
            h.update(str(f.stat().st_size).encode())
            h.update(f.read_bytes()[:200_000])
        except OSError:
            continue
    return h.hexdigest()[:16]


def load_activity() -> dict:
    if ACTIVITY_FILE.exists():
        try:
            return json.loads(ACTIVITY_FILE.read_text(encoding="utf-8"))
        except (json.JSONDecodeError, OSError):
            pass
    return {"records": {}}


def save_activity(data: dict) -> None:
    ACTIVITY_FILE.write_text(
        json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
    )
    ACTIVITY_FILE.chmod(0o644)


def track_activity(modules: list[dict], today: str) -> tuple[list[dict], list[dict]]:
    """对比台账与当前状态，写回台账，并返回（近期上传, 近期更新）。"""
    data = load_activity()
    records: dict = data.setdefault("records", {})

    for m in modules:
        key = m["dir"]
        files = list(m["prds"])
        if m["index"]:
            files.append(m["index"])
        files += [p for p in sorted(m["path"].rglob("*.md")) if p.parent != m["path"]]
        fp = module_fingerprint(files)
        latest_mtime = max((f.stat().st_mtime for f in files), default=0)
        latest_date = fmt_date(latest_mtime) if latest_mtime else today

        rec = records.get(key)
        if rec is None:
            # 首次纳入台账：以文件自身的日期作为基准，避免把历史内容误报成"刚刚上传"
            records[key] = {
                "first_seen": latest_date,
                "last_changed": latest_date,
                "fingerprint": fp,
                "seeded": True,
            }
            continue

        if rec.get("fingerprint") != fp:
            rec["fingerprint"] = fp
            rec["last_changed"] = today           # 内容真的变了
        rec.setdefault("first_seen", latest_date)
        rec.setdefault("last_changed", latest_date)

    save_activity(data)

    def entry(m: dict, rec: dict, kind: str) -> dict:
        return {
            "name": m["display"],
            "url": m["entry"],
            "date": rec.get("first_seen" if kind == "new" else "last_changed", ""),
            "group": m["group"],
            "kind": kind,
        }

    new_items, upd_items = [], []
    for m in modules:
        rec = records.get(m["dir"])
        if not rec:
            continue
        # 内容从未变过（首次收录即当前状态）就不算"更新"
        if rec.get("last_changed") != rec.get("first_seen"):
            upd_items.append(entry(m, rec, "upd"))
        new_items.append(entry(m, rec, "new"))

    new_items.sort(key=lambda x: (x["date"], x["name"]), reverse=True)
    upd_items.sort(key=lambda x: (x["date"], x["name"]), reverse=True)

    # 两个面板各自独立：新加的功能既在「近期上传」里出现，如果随后又改过，
    # 也会出现在「近期更新」里（这正是刚上传后继续调整的真实情况）。
    return new_items[:RECENT_LIMIT], upd_items[:RECENT_LIMIT]


# --------------------------------------------------------------------------
# Markdown 渲染（标准库实现）
# --------------------------------------------------------------------------


class InlineRenderer:
    """行内语法：代码 / 图片 / 链接 / 粗斜体 / 删除线。"""

    CODE = re.compile(r"`([^`]+)`")
    IMG = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)\)")
    LINK = re.compile(r"\[([^\]]+)\]\(([^)\s]+)\)")
    BOLD = re.compile(r"\*\*(?!\s)(.+?)(?<!\s)\*\*", re.S)
    ITALIC = re.compile(r"(?<!\*)\*(?!\s)([^*]+?)(?<!\s)\*(?!\*)")
    STRIKE = re.compile(r"~~(.+?)~~")

    def __init__(self, link_resolver=None):
        self.link_resolver = link_resolver or (lambda href: href)

    def render(self, text: str) -> str:
        stash: list[str] = []

        def keep(m):
            stash.append(m.group(1))
            return f"\x00{len(stash) - 1}\x00"

        text = self.CODE.sub(keep, text)
        text = htmllib.escape(text, quote=False)
        text = self.IMG.sub(
            lambda m: '<img src="%s" alt="%s" loading="lazy">'
            % (self.link_resolver(m.group(2)), htmllib.escape(m.group(1), quote=True)),
            text,
        )
        text = self.LINK.sub(
            lambda m: '<a href="%s"%s>%s</a>'
            % (
                self.link_resolver(m.group(2)),
                ' class="link-ext" target="_blank" rel="noopener"'
                if re.match(r"^https?://", m.group(2))
                else "",
                m.group(1),
            ),
            text,
        )
        text = self.BOLD.sub(r"<strong>\1</strong>", text)
        text = self.ITALIC.sub(r"<em>\1</em>", text)
        text = self.STRIKE.sub(r"<del>\1</del>", text)
        for i, code in enumerate(stash):
            text = text.replace(
                f"\x00{i}\x00", "<code>%s</code>" % htmllib.escape(code, quote=False)
            )
        return text


class MarkdownRenderer:
    """Markdown → HTML，并通过回调收集标题用于生成目录。"""

    def __init__(self, link_resolver=None, heading_hook=None):
        self.inline = InlineRenderer(link_resolver)
        self.heading_hook = heading_hook
        self.slug = self._make_slugger()

    @staticmethod
    def _make_slugger():
        seen: dict[str, int] = {}

        def slug(text: str) -> str:
            base = slugify(text)
            n = seen.get(base, 0)
            seen[base] = n + 1
            return base if n == 0 else f"{base}-{n}"

        return slug

    def render(self, md: str) -> str:
        lines = md.replace("\r\n", "\n").replace("\r", "\n").split("\n")
        out: list[str] = []
        i, n = 0, len(lines)

        while i < n:
            line = lines[i]

            if not line.strip():
                i += 1
                continue

            # 代码围栏（支持 3 个及以上反引号）
            m = re.match(r"^(\s*)(`{3,}|~{3,})\s*([^\s`]*)", line)
            if m:
                fence, lang = m.group(2), m.group(3)
                buf: list[str] = []
                i += 1
                closer = re.compile(
                    r"^\s*" + re.escape(fence[0]) + "{" + str(len(fence)) + r",}\s*$"
                )
                while i < n and not closer.match(lines[i]):
                    buf.append(lines[i])
                    i += 1
                i += 1
                cls = f' class="lang-{htmllib.escape(lang, quote=True)}"' if lang else ""
                out.append(
                    "<pre><code%s>%s</code></pre>"
                    % (cls, htmllib.escape("\n".join(buf), quote=False))
                )
                continue

            # 水平线
            if re.match(r"^\s*([-*_])(\s*\1){2,}\s*$", line):
                out.append("<hr>")
                i += 1
                continue

            # 标题
            m = re.match(r"^(#{1,6})\s+(.*?)\s*#*\s*$", line)
            if m:
                level = len(m.group(1))
                raw = m.group(2)
                text = strip_tags(self.inline.render(raw))
                sid = self.slug(text)
                if self.heading_hook and level <= 3:
                    self.heading_hook(level, text, sid)
                out.append(f'<h{level} id="{sid}">{self.inline.render(raw)}</h{level}>')
                i += 1
                continue

            # 原始 HTML 块整体透传
            if re.match(
                r"^\s*<(details|summary|div|table|figure|section|iframe|video|p|ul|ol)\b", line, re.I
            ):
                buf = [line]
                i += 1
                while i < n and lines[i].strip():
                    buf.append(lines[i])
                    i += 1
                out.append("\n".join(buf))
                continue

            # 引用
            if re.match(r"^\s*>", line):
                buf = []
                while i < n and re.match(r"^\s*>", lines[i]):
                    buf.append(re.sub(r"^\s*>\s?", "", lines[i]))
                    i += 1
                out.append("<blockquote>%s</blockquote>" % self.render("\n".join(buf)))
                continue

            # 表格
            if "|" in line and i + 1 < n and re.match(r"^\s*\|?[\s:|-]+\|[\s:|-]*$", lines[i + 1]):
                header = self._split_row(line)
                i += 2
                rows = []
                while i < n and "|" in lines[i] and lines[i].strip():
                    rows.append(self._split_row(lines[i]))
                    i += 1
                t = ['<div class="table-wrap"><table><thead><tr>']
                t += [f"<th>{self.inline.render(c)}</th>" for c in header]
                t.append("</tr></thead><tbody>")
                for r in rows:
                    r += [""] * (len(header) - len(r))
                    t.append(
                        "<tr>"
                        + "".join(f"<td>{self.inline.render(c)}</td>" for c in r[: len(header)])
                        + "</tr>"
                    )
                t.append("</tbody></table></div>")
                out.append("".join(t))
                continue

            # 列表
            if re.match(r"^\s*([-*+]|\d+[.)])\s+", line):
                block, i = self._collect_list(lines, i)
                out.append(block)
                continue

            # 段落
            buf = []
            while i < n and lines[i].strip() and not re.match(
                r"^\s*(#{1,6}\s|>|[-*+]\s|\d+[.)]\s|`{3,}|~{3,})", lines[i]
            ):
                if "|" in lines[i] and i + 1 < n and re.match(
                    r"^\s*\|?[\s:|-]+\|[\s:|-]*$", lines[i + 1]
                ):
                    break
                buf.append(lines[i].strip())
                i += 1
            if buf:
                out.append("<p>%s</p>" % self.inline.render(" ".join(buf)))
            else:
                i += 1

        return "\n".join(out)

    @staticmethod
    def _split_row(line: str) -> list[str]:
        line = line.strip()
        if line.startswith("|"):
            line = line[1:]
        if line.endswith("|"):
            line = line[:-1]
        return [c.strip() for c in line.split("|")]

    def _collect_list(self, lines: list[str], i: int):
        """收集列表，支持一层嵌套与任务清单。"""
        n = len(lines)
        ordered = bool(re.match(r"^\s*\d+[.)]\s+", lines[i]))
        items: list[tuple[str, list[str]]] = []
        base_indent = len(lines[i]) - len(lines[i].lstrip())

        while i < n:
            line = lines[i]
            if not line.strip():
                if i + 1 < n and re.match(r"^\s*([-*+]|\d+[.)])\s+", lines[i + 1]):
                    i += 1
                    continue
                break
            indent = len(line) - len(line.lstrip())
            m = re.match(r"^\s*([-*+]|\d+[.)])\s+(.*)$", line)
            if m and indent <= base_indent + 2:
                items.append((m.group(2), []))
                i += 1
            elif items and indent > base_indent:
                items[-1][1].append(line)
                i += 1
            else:
                break

        parts = ["<ol>" if ordered else "<ul>"]
        for text, sub in items:
            task = re.match(r"^\[([ xX])\]\s+(.*)$", text)
            cls = ""
            if task:
                cls = ' class="task"'
                box = "☑" if task.group(1).lower() == "x" else "☐"
                text = f"{box} {task.group(2)}"
            parts.append(f"<li{cls}>{self.inline.render(text)}")
            if sub:
                parts.append(self.render("\n".join(sub)))
            parts.append("</li>")
        parts.append("</ol>" if ordered else "</ul>")
        return "".join(parts), i


# --------------------------------------------------------------------------
# 页面骨架
# --------------------------------------------------------------------------


def head_html(title: str, depth: int) -> str:
    css = rel_href(depth, "assets/css/site.css")
    js = rel_href(depth, "assets/js/site.js")
    full = title if SITE_SHORT in title else f"{title} · {SITE_SHORT}"
    return f"""<!DOCTYPE html>
<html lang="zh-CN" data-depth="{depth}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<meta name="description" content="{htmllib.escape(SITE_DESC, quote=True)}">
<title>{htmllib.escape(full)}</title>
<link rel="stylesheet" href="{css}">
<script>window.__BASE__="{rel_href(depth, "")}";</script>
<script defer src="{js}"></script>
</head>
<body>
<a class="skip" href="#main">跳到正文</a>
"""


def topbar_html(depth: int, tabs: str = "") -> str:
    home = rel_href(depth, "index.html")
    return f"""<header class="topbar">
  <div class="topbar-in">
    <a class="brand" href="{home}">
      <span class="brand-mark">{htmllib.escape(SITE_BRAND)}</span>
      <span class="brand-text"><b>{htmllib.escape(SITE_SHORT)}</b><i>{htmllib.escape(SITE_DESC)}</i></span>
    </a>
    <div class="topbar-right">
      <button class="search-btn" type="button" data-search-open aria-label="搜索（快捷键 /）">
        <svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="8.5" cy="8.5" r="5.5"/><path d="M12.8 12.8 17 17"/></svg>
        <span>搜索原型 / PRD</span><kbd>/</kbd>
      </button>
      <button class="theme-btn" type="button" data-theme-toggle aria-label="切换深浅色">
        <svg class="ic-sun" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="4"/><path d="M10 1v2M10 17v2M1 10h2M17 10h2M3.5 3.5l1.4 1.4M15.1 15.1l1.4 1.4M16.5 3.5l-1.4 1.4M4.9 15.1l-1.4 1.4"/></svg>
        <svg class="ic-moon" viewBox="0 0 20 20" aria-hidden="true"><path d="M16 12.5A7 7 0 0 1 7.5 4a7 7 0 1 0 8.5 8.5z"/></svg>
      </button>
    </div>
  </div>
  {tabs}
</header>
"""


def footer_html(depth: int) -> str:
    home = rel_href(depth, "index.html")
    others = rel_href(depth, "others.html")
    return f"""<footer class="foot">
  <div class="foot-in">
    <div>
      <b>{htmllib.escape(SITE_NAME)}</b>
      <p>纯静态站点 · 供开发与决策快速浏览 · 构建于 {datetime.now().strftime('%Y-%m-%d')}</p>
    </div>
    <div class="foot-links">
      <a href="{home}">功能演示列表</a>
      <a href="{others}">其他资料</a>
    </div>
  </div>
</footer>
"""


def search_modal_html() -> str:
    return """<div class="modal" id="search-modal" hidden>
  <div class="modal-bg" data-search-close></div>
  <div class="modal-panel" role="dialog" aria-modal="true" aria-label="全站搜索">
    <div class="modal-input">
      <svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="8.5" cy="8.5" r="5.5"/><path d="M12.8 12.8 17 17"/></svg>
      <input type="search" id="search-input" placeholder="搜索功能、演示或 PRD…" autocomplete="off" spellcheck="false">
      <kbd>Esc</kbd>
    </div>
    <div class="modal-results" id="search-results"></div>
    <div class="modal-foot"><span id="search-meta">输入关键词开始搜索</span><span>↑↓ 选择 · Enter 打开</span></div>
  </div>
</div>
"""


def page(title: str, depth: int, body: str, tabs: str = "") -> str:
    return (
        head_html(title, depth)
        + topbar_html(depth, tabs)
        + f'<main id="main">\n{body}\n</main>\n'
        + footer_html(depth)
        + search_modal_html()
        + "</body>\n</html>\n"
    )


# --------------------------------------------------------------------------
# 生成器
# --------------------------------------------------------------------------


class SiteBuilder:
    def __init__(self, src: Path, out: Path):
        self.src = src
        self.out = out
        self.pages: list[dict] = []
        self.modules: list[dict] = []
        self.warnings: list[str] = []
        self.recent_new: list[dict] = []
        self.recent_update: list[dict] = []

    # -- 基础 IO ----------------------------------------------------------
    def emit(self, rel_path: str, content: str):
        p = self.out / rel_path
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(content, encoding="utf-8")
        p.chmod(0o644)

    def copy_file(self, src: Path, rel_path: str):
        dst = self.out / rel_path
        dst.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(src, dst)
        dst.chmod(0o755 if dst.suffix.lower() in (".sh", ".py") else 0o644)

    def add_page(self, title: str, url: str, kind: str, desc: str = "", group: str = ""):
        self.pages.append({"t": title, "u": url, "k": kind, "d": desc[:160], "g": group})

    def stage_assets(self):
        assets = Path(__file__).resolve().parent / "assets"
        for f in sorted(assets.rglob("*")):
            if f.is_file():
                rel = str(f.relative_to(assets)).replace(os.sep, "/")
                self.copy_file(f, f"assets/{rel}")

    # -- 白名单扫描 -------------------------------------------------------
    def collect_modules(self) -> list[dict]:
        """按 MODULES 白名单收集模块，未登记的目录一律不进站。"""
        found: list[dict] = []
        for entry in MODULES:
            d = self.src / entry["dir"]
            if not d.is_dir():
                self.warnings.append(f"白名单里的目录不存在，已跳过：{entry['dir']}")
                continue
            index = d / "index.html"
            prds = sorted(p for p in d.glob("*.md") if not p.name.startswith("_"))
            if not index.exists() and not prds:
                self.warnings.append(f"目录内既无演示也无 PRD，已跳过：{entry['dir']}")
                continue
            found.append(
                {**entry, "path": d, "index": index if index.exists() else None, "prds": prds}
            )
        return found

    # -- 交互演示 ---------------------------------------------------------
    def rewrite_demo_links(self, html: str, module: str, prds: list[Path]) -> str:
        """把演示页里指向同目录 PRD 的链接改写到站点新路径。

        演示页位于 /<模块>/index.html，站点根在上一层，因此前缀是 `../`。
        """
        mapping: dict[str, str] = {}
        for md in prds:
            target = f"../prd/{link_seg(module)}/{link_seg(md.stem)}.html"
            mapping[f"{md.stem}.md"] = target
            mapping[f"{md.stem}.html"] = target
        for stale, current in RENAMED_DOCS.items():
            if current in mapping:
                mapping[stale] = mapping[current]

        def sub(m):
            q = m.group(1)
            if q in mapping:
                return f'href="{mapping[q]}"'
            # 指向未收录内容的链接（例如 _archive/ 里的历史报告）：
            # 这些文件不在白名单里、不会被上传，留着就是死链，统一指回功能列表。
            if not re.match(r"^(?:[a-zA-Z][a-zA-Z0-9+.\-]*:|//|#)", q) and q.endswith(".html"):
                return 'href="../index.html"'
            return m.group(0)

        return re.sub(r'href="([^"]+\.(?:md|html))"', sub, html)

    def build_demo(self, module: dict):
        if not module["index"]:
            return
        name = module["dir"]
        html = module["index"].read_text(encoding="utf-8", errors="replace")
        html = self.rewrite_demo_links(html, name, module["prds"])
        self.emit(f"{fs_join(name)}/index.html", html)
        self.add_page(
            f"{module['display']} · 交互演示",
            f"{url_join(name)}/index.html",
            "演示",
            module.get("blurb", ""),
            module["group"],
        )

    # -- PRD --------------------------------------------------------------
    def build_prd(self, md_path: Path, module: dict) -> dict:
        url = f"prd/{url_join(module['dir'], md_path.stem)}.html"
        fs_parts = [unquote(p) for p in Path(url).with_suffix("").parts]
        depth = len(fs_parts) - 1
        raw = md_path.read_text(encoding="utf-8", errors="replace")

        title = md_path.stem
        body_md = raw
        first = re.search(r"^#\s+(.+)$", raw, re.M)
        if first and raw.index(first.group(0)) < 400:
            title = strip_tags(first.group(1))
            body_md = raw[: first.start()] + raw[first.end():]

        toc: list[dict] = []

        def hook(level, text, sid):
            if level >= 2:
                toc.append({"l": level, "t": text, "id": sid})

        prefix_root = "../" * depth
        list_href = "others.html" if section_of(module) == SECTION_OTHER else "index.html"
        list_label = "其他资料" if section_of(module) == SECTION_OTHER else "功能演示列表"

        def resolve(href: str) -> str:
            if re.match(r"^(https?:|mailto:|tel:|#|data:)", href):
                return href
            target = unquote(href.split("#")[0].split("?")[0])
            frag = ("#" + href.split("#", 1)[1]) if "#" in href else ""
            hops = href.split("#")[0].split("?")[0].count("../")
            prefix = "../" * max(depth - hops, 0)
            cleaned = re.sub(r"^(?:\.\./|\./)+", "", target)

            for probe in ((md_path.parent / target).resolve(), (self.src / cleaned).resolve()):
                try:
                    rel = probe.relative_to(self.src)
                except ValueError:
                    continue
                # rel.parent 是相对路径，必须换算成绝对路径再与模块目录比较
                probe_parent = (self.src / rel).parent
                # 同模块的交互演示页
                if rel.name == "index.html" and probe_parent == module["path"]:
                    if module["index"]:
                        return f"{prefix}{url_join(module['dir'])}/index.html{frag}"
                    return f"{prefix}prd/{url_join(module['dir'], module['prds'][0].stem)}.html{frag}"
                # 同模块的其它 PRD
                if rel.suffix.lower() == ".md" and probe_parent == module["path"]:
                    return f"{prefix}prd/{url_join(module['dir'], rel.stem)}.html{frag}"
                # 其它位置不在白名单里：保留链接但指向 docs/（站点不生成就该 404）
                return f"{prefix}docs/{url_join(*rel.with_suffix('').parts)}.html{frag}"

            if re.search(r"\.(png|jpe?g|svg|gif|webp|mp4|pdf)$", target, re.I):
                self.warnings.append(f"PRD 引用了未收录的附件：{md_path.name} → {href}")
            return href

        renderer = MarkdownRenderer(link_resolver=resolve, heading_hook=hook)
        html_body = renderer.render(body_md)
        words = len(strip_tags(html_body))

        toc_html = ""
        if len(toc) >= 3:
            items = "".join(
                f'<a class="lvl{t["l"]}" href="#{t["id"]}">{htmllib.escape(t["t"])}</a>'
                for t in toc[:80]
            )
            toc_html = (
                '<aside class="toc" aria-label="本页目录">'
                '<div class="toc-h">本页目录</div><nav>' + items + "</nav></aside>"
            )

        back_href = (
            f"{url_join(module['dir'])}/index.html"
            if module["index"]
            else f"prd/{url_join(module['dir'], module['prds'][0].stem)}.html"
        )

        body = f"""<div class="doc-shell">
  <div class="doc-head">
    <div class="crumbs"><a href="{prefix_root}{list_href}">{list_label}</a> <span>/</span> <a href="{prefix_root}{back_href}">{htmllib.escape(module['display'])}</a></div>
    <h1>{htmllib.escape(title)}</h1>
    <div class="doc-meta">
      <span class="pill">{htmllib.escape(module['group'])}</span>
      <span>{words} 字</span>
      <span>{md_path.name}</span>
      <span>更新于 {fmt_date(md_path.stat().st_mtime)}</span>
    </div>
    <a class="crumb-back" href="{prefix_root}{list_href}">← 返回{list_label}</a>
  </div>
  <div class="doc-layout{' has-toc' if toc_html else ''}">
    <article class="prose">{html_body}</article>
    {toc_html}
  </div>
</div>"""
        self.emit(f"{fs_join(*fs_parts)}.html", page(title, depth, body))
        self.add_page(title, url, "PRD", strip_tags(html_body)[:150], module["group"])
        return {"title": title, "url": url, "file": md_path.name, "size": md_path.stat().st_size}

    def build_module(self, module: dict):
        name = module["dir"]
        module["prd_pages"] = []
        module["display"] = module.get("name") or name

        for md in module["prds"]:
            st = self.build_prd(md, module)
            module["prd_pages"].append(st)
            if md.name.startswith("02_PRD") and not module.get("name"):
                module["display"] = clean_module_name(st["title"], name)

        if not module.get("name") and module["display"] == name and module["prd_pages"]:
            module["display"] = clean_module_name(module["prd_pages"][0]["title"], name)

        module["screens"] = self.count_screens(module["index"])
        self.build_demo(module)

        stamps = [p.stat().st_mtime for p in module["prds"]]
        if module["index"]:
            stamps.append(module["index"].stat().st_mtime)
        module["updated"] = max(stamps) if stamps else 0
        module["updated_ts"] = int(module["updated"])
        module["updated_str"] = fmt_date(module["updated"]) if stamps else "—"

        if module["index"]:
            module["entry"] = f"{url_join(name)}/index.html"
        elif module["prd_pages"]:
            module["entry"] = module["prd_pages"][0]["url"]
        else:
            module["entry"] = "#"

    def count_screens(self, index: Path | None) -> str:
        if not index:
            return ""
        try:
            text = index.read_text(encoding="utf-8", errors="replace")
        except OSError:
            return ""
        m = re.search(r"(\d+)\s*屏", text)
        return m.group(1) if m else ""

    # -- 首页：后台风格的功能列表 -----------------------------------------
    def module_row(self, m: dict, main_list: bool) -> str:
        """生成列表里的一行。main_list 决定「分组」列的内容与筛选依据。"""
        blob = htmllib.escape(
            " ".join(
                [m["display"], m["dir"], m["group"], m.get("blurb", "")]
                + [p["title"] for p in m["prd_pages"]]
                + list(m.get("alias", []))
            ).lower(),
            quote=True,
        )

        if m["index"]:
            cnt = f'<span class="cnt">{m["screens"]} 屏</span>' if m["screens"] else '<span class="cnt">打开</span>'
            demo = f'<a class="lnk lnk-demo" href="{m["entry"]}">可点击演示{cnt}</a>'
            has_demo = "1"
        else:
            demo = '<span class="none">未提供</span>'
            has_demo = "0"

        if m["prd_pages"]:
            if len(m["prd_pages"]) == 1:
                prd = f'<a class="lnk lnk-prd" href="{m["prd_pages"][0]["url"]}">查看 PRD</a>'
            else:
                first, rest = m["prd_pages"][0], m["prd_pages"][1:]
                titles = "&#10;".join(htmllib.escape(p["title"], quote=True) for p in rest)
                prd = (
                    '<span class="multi-docs">'
                    f'<a class="lnk lnk-prd" href="{first["url"]}">查看 PRD</a>'
                    f'<span class="more" title="{titles}">另 {len(rest)} 份</span>'
                    "</span>"
                )
            has_prd = "1"
        else:
            prd = '<span class="none">未提供</span>'
            has_prd = "0"

        # 「其他资料」页里：分组列换成来源目录，不参与分组筛选
        if main_list:
            group_cell = f'<span class="grp">{htmllib.escape(m["group"])}</span>'
            group_attr = htmllib.escape(m["group"], quote=True)
        else:
            group_cell = f'<span class="src">{htmllib.escape(m["dir"])}</span>'
            group_attr = ""

        return f"""      <tr class="row" data-blob="{blob}" data-group="{group_attr}"
          data-demo="{has_demo}" data-prd="{has_prd}" data-updated="{int(m['updated'])}"
          data-name="{htmllib.escape(m['display'], quote=True)}"
          data-order="{group_rank(m['group'])}-{m['order']:03d}">
        <td class="c-name">
          <a class="name" href="{m['entry']}">{htmllib.escape(m['display'])}</a>
          <div class="dir" title="{htmllib.escape(m.get('blurb', ''), quote=True)}">{htmllib.escape(m.get('blurb') or m['dir'])}</div>
        </td>
        <td class="c-group">{group_cell}</td>
        <td class="c-demo">{demo}</td>
        <td class="c-prd">{prd}</td>
        <td class="c-upd">{m['updated_str']}</td>
      </tr>"""

    def toolbar_html(self, mods: list[dict], main_list: bool, placeholder: str) -> str:
        groups: list[str] = []
        for m in mods:
            if m["group"] not in groups:
                groups.append(m["group"])
        options = "".join(
            f'<option value="{htmllib.escape(g, quote=True)}">{htmllib.escape(g)}</option>' for g in groups
        )
        group_field = (
            f'<label class="field"><span class="field-label">分组</span>'
            f'<select id="filter-group"><option value="">全部分组</option>{options}</select></label>'
            if main_list
            else ""
        )
        return f"""  <div class="toolbar">
    <label class="field field-search">
      <svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="8.5" cy="8.5" r="5.5"/><path d="M12.8 12.8 17 17"/></svg>
      <input type="search" id="filter-input" placeholder="{htmllib.escape(placeholder, quote=True)}" autocomplete="off">
      <button type="button" class="clear" id="filter-clear" hidden aria-label="清空搜索">×</button>
    </label>
    {group_field}
    <label class="field">
      <span class="field-label">内容</span>
      <select id="filter-kind">
        <option value="">全部</option>
        <option value="demo">有演示</option>
        <option value="prd">有 PRD</option>
        <option value="both">演示 + PRD</option>
      </select>
    </label>
    <label class="field">
      <span class="field-label">排序</span>
      <select id="sort-by">
        <option value="updated">最近更新（默认）</option>
        <option value="default">按分组</option>
        <option value="name">功能名称</option>
      </select>
    </label>
    <span class="result-count" id="result-count"></span>
  </div>"""

    def recent_activity_html(self, main_list: bool, lead: str = "") -> str:
        """首页顶部：左侧「近期上传」，右侧「近期更新」，各最多 3 条。"""

        def panel(title: str, hint: str, items: list[dict], kind: str) -> str:
            if items:
                rows = "".join(
                    f'<li><a href="{it["url"]}">'
                    f'<span class="ra-name">{htmllib.escape(it["name"])}</span>'
                    f'<span class="ra-meta">{htmllib.escape(it["group"])}</span>'
                    f'<span class="ra-date">{htmllib.escape(it["date"])}</span>'
                    "</a></li>"
                    for it in items
                )
                body = f'<ul class="ra-list">{rows}</ul>'
            else:
                body = f'<p class="ra-empty">{hint}</p>'
            return (
                f'<div class="ra-panel ra-{kind}">'
                f'<div class="ra-head"><span class="ra-dot"></span>'
                f'<h2>{htmllib.escape(title)}</h2></div>'
                f"{body}</div>"
            )

        left = panel("近期上传", "还没有新增的功能", self.recent_new, "new")
        right = panel("近期更新", "还没有更新记录", self.recent_update, "upd")
        return (
            f'<div class="recent" data-section="{"main" if main_list else "other"}"'
            f' data-lead="{htmllib.escape(strip_tags(lead), quote=True)}">'
            f"{left}{right}</div>"
        )

    def build_list_page(
        self,
        mods: list[dict],
        *,
        section: str,
        title: str,
        lead: str,
        legend: str,
        placeholder: str,
        out_file: str,
        empty_text: str,
    ) -> None:
        main_list = section == SECTION_MAIN
        n_demo = sum(1 for m in mods if m["index"])
        n_prd = sum(len(m["prd_pages"]) for m in mods)
        rows = "\n".join(self.module_row(m, main_list) for m in mods)

        body = f"""<div class="wrap">
  {self.recent_activity_html(main_list, lead)}

{self.toolbar_html(mods, main_list, placeholder)}

  <div class="table-wrap main-table">
    <table>
      <thead>
        <tr>
          <th class="c-name">{'功能模块' if main_list else '名称'}</th>
          <th class="c-group">{'分组' if main_list else '来源目录'}</th>
          <th class="c-demo">可点击演示</th>
          <th class="c-prd">产品需求文档</th>
          <th class="c-upd">更新时间</th>
        </tr>
      </thead>
      <tbody id="module-rows">
{rows}
      </tbody>
    </table>
    <div class="empty" id="empty-state" hidden>
      <b>{htmllib.escape(empty_text)}</b>
      <p>换个关键词，或把筛选条件改回「全部」。</p>
      <button type="button" class="btn" id="filter-reset">清除筛选</button>
    </div>
  </div>

  <p class="legend">{legend}</p>
</div>"""

        counts = {section: len(mods)}
        other_section = SECTION_OTHER if main_list else SECTION_MAIN
        counts[other_section] = len([m for m in self.modules if section_of(m) == other_section])
        self.emit(out_file, page(title, 0, body, tabs=tabbar_html(0, section, counts)))

    def build_home(self):
        main_mods = [m for m in self.modules if section_of(m) == SECTION_MAIN]
        other_mods = [m for m in self.modules if section_of(m) == SECTION_OTHER]
        n_other_prd = sum(len(m["prd_pages"]) for m in other_mods)

        self.build_list_page(
            main_mods,
            section=SECTION_MAIN,
            title="功能演示列表",
            lead=(
                f"共 <b>{len(main_mods)}</b> 个主要功能，"
                f"其中 <b>{sum(1 for m in main_mods if m['index'])}</b> 个提供可点击演示、"
                f"<b>{sum(len(m['prd_pages']) for m in main_mods)}</b> 份产品需求文档。"
                "演示版可直接点开操作，PRD 按需查阅。"
            ),
            legend=(
                "<span><b>可点击演示</b>：可在浏览器里直接操作的原型，用于评审交互。</span>"
                "<span><b>产品需求文档</b>：逐条对齐的规则说明，含边界与异常。</span>"
                f'<span>历史版本与过程资料见 <a href="others.html">其他资料</a>。</span>'
                "<span>按 <kbd>/</kbd> 可跨页面搜索。</span>"
            ),
            placeholder="搜索功能名称、说明或目录…",
            out_file="index.html",
            empty_text="没有匹配的功能模块",
        )
        self.add_page("功能演示列表", "index.html", "列表", "主要功能的可点击演示与 PRD", "首页")

        legend_other = (
            "<span>这里放的是<b>非主要功能</b>的演示与历史资料，避免干扰主列表。</span>"
            + (f"<span>共 {n_other_prd} 份文档。</span>" if n_other_prd else "")
            + "<span>按 <kbd>/</kbd> 可跨页面搜索。</span>"
        )
        self.emit_other_page(other_mods, legend_other)

    def emit_other_page(self, other_mods: list[dict], legend: str) -> None:
        self.build_list_page(
            other_mods,
            section=SECTION_OTHER,
            title="其他资料",
            lead=(
                f"这里收录 <b>{len(other_mods)}</b> 项非主要功能与历史资料，"
                "包括早期版本、查缺补漏报告与专题改版演示。"
            ),
            legend=legend,
            placeholder="搜索名称、说明或目录…",
            out_file="others.html",
            empty_text="没有匹配的条目",
        )
        self.add_page("其他资料", "others.html", "列表", "非主要功能的演示与过程资料", "首页")


    # -- 索引文件 ---------------------------------------------------------
    def build_index_files(self):
        index = [{"t": p["t"], "u": p["u"], "k": p["k"], "d": p["d"], "g": p["g"]} for p in self.pages]
        self.emit("assets/search-index.json", json.dumps(index, ensure_ascii=False, separators=(",", ":")))

        urls = "".join(f'  <url><loc>{htmllib.escape(p["u"])}</loc></url>\n' for p in self.pages)
        self.emit(
            "sitemap.xml",
            '<?xml version="1.0" encoding="UTF-8"?>\n'
            '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' + urls + "</urlset>\n",
        )
        self.emit("robots.txt", "User-agent: *\nAllow: /\nSitemap: sitemap.xml\n")
        self.emit(".nojekyll", "")

    # -- 部署文件 ---------------------------------------------------------
    def write_deploy_files(self):
        deps = Path(__file__).resolve().parent / "deploy"
        if deps.exists():
            for f in sorted(deps.rglob("*")):
                if f.is_file():
                    self.copy_file(f, str(f.relative_to(deps)).replace(os.sep, "/"))

    def normalize_permissions(self):
        for path in sorted(self.out.rglob("*")):
            if path.is_dir():
                path.chmod(0o755)
            else:
                path.chmod(0o755 if path.suffix.lower() in (".sh", ".py") else 0o644)
        self.out.chmod(0o755)

    # -- 主流程 -----------------------------------------------------------
    def run(self, clean: bool = True):
        if clean and self.out.exists():
            shutil.rmtree(self.out)
        self.out.mkdir(parents=True, exist_ok=True)

        print("① 静态资源")
        self.stage_assets()

        print("② 收录白名单")
        self.modules = self.collect_modules()
        print(f"   · {len(self.modules)} 个模块：" + "、".join(m["dir"] for m in self.modules))

        print("③ 演示页与 PRD")
        for m in self.modules:
            self.build_module(m)

        # 列表默认顺序：最近更新的排前面（同一天再按分组与组内次序）
        self.modules.sort(
            key=lambda m: (-m["updated_ts"], group_rank(m["group"]), m["order"], m["dir"])
        )

        print("④ 活动台账（近期上传 / 近期更新）")
        today = datetime.now().strftime("%Y-%m-%d")
        self.recent_new, self.recent_update = track_activity(self.modules, today)
        print(f"   · 近期上传 {len(self.recent_new)} 条 · 近期更新 {len(self.recent_update)} 条")

        print("⑤ 功能列表首页")
        self.build_home()

        print("⑥ 索引与站点地图")
        self.build_index_files()

        print("⑦ 部署文件")
        self.write_deploy_files()

        print("⑧ 权限整理")
        self.normalize_permissions()

        self.report()

    def report(self):
        total = sum(1 for p in self.out.rglob("*") if p.is_file())
        size = sum(p.stat().st_size for p in self.out.rglob("*") if p.is_file())
        print("\n" + "=" * 62)
        print(f"完成：{self.out}")
        print(f"  文件 {total} 个 · 体积 {human_size(size)}")
        print(f"  功能模块 {len(self.modules)} 个 · 页面 {len(self.pages)} 个")
        if self.warnings:
            print(f"\n  提示 {len(self.warnings)} 条：")
            for w in self.warnings[:10]:
                print("   · " + w)
        print("=" * 62)


def main():
    ap = argparse.ArgumentParser(description="生成 U8 产品原型站")
    here = Path(__file__).resolve().parent.parent
    ap.add_argument("--src", default=str(here / "文档"), help="源目录（默认 文档/）")
    ap.add_argument("--out", default=str(here / "site"), help="输出目录（默认 site/）")
    ap.add_argument("--no-clean", dest="clean", action="store_false", help="保留输出目录里的旧文件")
    args = ap.parse_args()

    src, out = Path(args.src).resolve(), Path(args.out).resolve()
    if not src.is_dir():
        sys.exit(f"源目录不存在：{src}")
    print(f"源目录：{src}\n输出目录：{out}\n")
    SiteBuilder(src, out).run(args.clean)


if __name__ == "__main__":
    main()
