"""生成软件包的网站。
用法:
python website/build_site.py # 输出到 dist/site
python website/build_site.py -o build/site # 指定输出目录
python website/build_site.py --source PyPI/src/guan
网站的内容有四个来源,都不是手工维护的:
* 包源码(静态解析,见 extract.py)—— API 参考
* 仓库根目录的 README.md(渲染,见 md_render.py)—— 关于、用法、引用
* 仓库根目录的 INSTALLATION.md(渲染,见 md_render.py)—— 安装
* 仓库根目录的 tutorials/(读取,见 tutorials.py)—— 教程与示例
因此不会与实际发布的包脱节。
构建过程不假设任何固定的包名、目录名或函数名:
* 包目录自动发现(PyPI/src、src 等常见布局都能找到)
* 页面与导航按实际有内容与否动态生成
* 首页示例代码从源码里真实的函数挑出来,而不是写死
站点有中英文两套界面(见 i18n.py):中文在根目录,英文在 en/ 子目录。
只有网站框架(导航、按钮、标题这些)会翻译,文件内容一律保持原样。
"""
from __future__ import annotations
import argparse
import html
import json
import re
import shutil
import sys
import urllib.parse
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
# 允许 `python -m website.build_site` 与 `python website/build_site.py` 两种调用方式
sys.path.insert(0, str(Path(__file__).resolve().parent))
import extract # noqa: E402
import i18n # noqa: E402
import md_render # noqa: E402
import tutorials # noqa: E402
ROOT = Path(__file__).resolve().parent.parent
PYPI_DIR = ROOT / "PyPI"
TEMPLATE_DIR = Path(__file__).resolve().parent / "templates"
STATIC_DIR = Path(__file__).resolve().parent / "static"
# 源码可能放在这些地方,按顺序找
SOURCE_CANDIDATES = (
PYPI_DIR / "src",
ROOT / "src",
ROOT,
)
# 教程目录(示例脚本)可能放在这些地方,按顺序找
TUTORIALS_CANDIDATES = (
ROOT / "tutorials",
PYPI_DIR / "tutorials",
)
def e(text: object) -> str:
"""HTML 转义。"""
return html.escape(str(text), quote=True)
def display_name(package: dict) -> str:
"""站点上展示的名字(品牌名)。
PyPI 上的包名与导入名必须是小写的(pip install guan、import guan),
但网站标题、页头品牌这类地方用大写更醒目——所以展示名与包名分开。
展示名仍由包名推导,不写死具体的名字。
"""
name = package.get("display_name") or package["name"]
if name.islower() and name.isalnum():
return name.upper()
return name
def load_template(name: str) -> str:
return (TEMPLATE_DIR / name).read_text(encoding="utf-8")
def render(template: str, **context: object) -> str:
"""极简模板替换:{{ key }} 形式。
只扫描模板本身一次,替换进去的内容不会被二次替换——
否则源码或 README 里出现 {{version}} 之类会被误改。
"""
return re.sub(
r"\{\{\s*(\w+)\s*\}\}",
lambda match: str(context.get(match.group(1), match.group(0))),
template,
)
# --------------------------------------------------------------------------
# 页面定义
# --------------------------------------------------------------------------
@dataclass
class PageSpec:
"""一个页面的定义。
页面是否出现在导航里,由 available 决定——这样源码里没有函数时,
「API 参考」页会自动消失,而不是留下一个空页面。
标题与导航文字都用 i18n.py 里的文案键,两种语言各写一份;
title_fn 用于标题里要带包名这类动态内容的情况。
"""
key: str
filename: str
label_key: str
title_key: str
builder: Callable[[dict], str]
description_fn: Callable[[dict], str]
title_fn: Callable[[dict], str] | None = None
available: Callable[[dict], bool] | None = None
def page_title(self, data: dict) -> str:
if self.title_fn is not None:
return self.title_fn(data)
return data["t"](self.title_key)
def description(self, data: dict) -> str:
return self.description_fn(data)
def _has_functions(data: dict) -> bool:
"""包里有没有可展示的函数(类的方法也算)。"""
if data["stats"]["functions"]:
return True
return any(module["classes"] for module in data["modules"])
def _has_installation(data: dict) -> bool:
"""有没有安装说明文档可渲染。"""
return Path(data["meta"]["installation_file"]).exists()
def _has_tutorials(data: dict) -> bool:
"""tutorials/ 目录里有没有可展示的示例脚本。"""
tutorials_data = data.get("tutorials") or {}
return bool(tutorials_data.get("files"))
PAGE_SPECS: list[PageSpec] = [
PageSpec(
key="index",
filename="index.html",
label_key="nav_home",
title_key="page_home",
title_fn=lambda data: (
f"{display_name(data['package'])} — "
f"{data['package']['description'] or data['package'].get('summary') or ''}"
).strip(" —"),
builder=lambda data: build_home(data),
description_fn=lambda data: data["t"](
"page_home", name=display_name(data["package"])
),
),
PageSpec(
key="installation",
filename="installation.html",
label_key="nav_installation",
title_key="page_installation",
builder=lambda data: build_installation(data),
description_fn=lambda data: data["t"](
"desc_installation", name=display_name(data["package"])
),
available=_has_installation,
),
PageSpec(
key="api",
filename="api.html",
label_key="nav_api",
title_key="page_api",
builder=lambda data: build_api(data),
description_fn=lambda data: data["t"](
"desc_api", name=display_name(data["package"])
),
available=_has_functions,
),
PageSpec(
key="tutorials",
filename="tutorials.html",
label_key="nav_tutorials",
title_key="page_tutorials",
builder=lambda data: build_tutorials(data),
description_fn=lambda data: data["t"](
"desc_tutorials", name=display_name(data["package"])
),
available=_has_tutorials,
),
PageSpec(
key="about",
filename="about.html",
label_key="nav_about",
title_key="page_about",
builder=lambda data: build_readme(data),
description_fn=lambda data: data["t"](
"desc_about", name=display_name(data["package"])
),
),
]
def active_pages(data: dict) -> list[PageSpec]:
"""返回实际要生成的页面列表。"""
pages = []
for spec in PAGE_SPECS:
if spec.available is not None and not spec.available(data):
continue
pages.append(spec)
return pages
def lang_url(lang: str, filename: str, from_lang: str) -> str:
"""另一种语言里同一个页面的相对路径(相对于当前页面)。
中文页面在根目录,英文页面在 en/ 子目录,所以两个方向要分别处理。
"""
target_dir = i18n.LANG_DIRS[lang]
current_dir = i18n.LANG_DIRS[from_lang]
if target_dir == current_dir:
return filename
if not target_dir:
return "../" + filename
if not current_dir:
return target_dir + "/" + filename
return "../" + target_dir + "/" + filename
def lang_dir(output: Path, lang: str) -> Path:
"""某种语言的页面所在目录。"""
directory = i18n.LANG_DIRS[lang]
return output / directory if directory else output
def absolute_url(base: str, path: str) -> str:
"""把站点内的相对路径拼成绝对地址。
base 是站点根地址(如 https://py.guanjihuan.com),path 是页面里的相对路径
(如 "api.html" 或 "../api.html")。搜索引擎要求 hreflang 与 canonical
用绝对地址,所以这里把 ".." 之类的片段解析掉再拼。
不知道根地址时原样返回,页面至少还能用相对地址互相跳转。
"""
if not base:
return path
parts: list[str] = []
for segment in path.split("/"):
if segment in ("", "."):
continue
if segment == "..":
if parts:
parts.pop()
continue
parts.append(segment)
return base + "/" + "/".join(parts)
def site_base_url(package: dict, override: str | None = None) -> str:
"""站点根地址。
优先用命令行给的 --base-url;没给就从包元数据里的主页地址推。
末尾的斜杠去掉,免得拼出 "https://x.com//api.html" 这种双斜杠。
"""
candidate = override or package.get("url") or ""
return candidate.strip().rstrip("/")
def _lang_context(data: dict, lang: str, pages: list[PageSpec]) -> dict:
"""按语言准备一份数据:文案翻译器,加上该语言的导航标签。"""
t = i18n.translator(lang)
meta = dict(data["meta"])
meta["page_links"] = [
{"key": spec.key, "filename": spec.filename, "label": t(spec.label_key)}
for spec in pages
if spec.key != "index"
]
return {**data, "meta": meta, "t": t, "lang": lang}
def nav_html(pages: list[PageSpec], active_key: str, t) -> str:
"""生成导航条(顶栏与页脚共用)。"""
items = []
for spec in pages:
class_attr = "is-active" if spec.key == active_key else ""
items.append(
f'{e(t(spec.label_key))} '
)
return "\n ".join(items)
def page(
template: str,
*,
title: str,
description: str,
content: str,
info: dict,
stats: dict,
pages: list[PageSpec],
active: str,
lang: str,
filename: str,
site_url: str = "",
extra_js: str = "",
) -> str:
"""套上 base.html 外壳。"""
package = info
t = i18n.translator(lang)
other = next(item for item in i18n.LANGS if item != lang)
return render(
template,
# 标题与描述会进到
和 meta 里,必须转义
# 首页标题已经以展示名开头,后面不再重复追加「· 展示名」
title=e(
title
if title.startswith(display_name(package))
else f"{title} · {display_name(package)}"
),
description=e(description),
content=content,
version=package["version"],
package_name=display_name(package),
import_name=package.get("import_name") or package["name"],
nav_items=nav_html(pages, active, t),
foot_nav_items=nav_html(pages, active, t),
module_count=stats["modules"],
function_count=stats["functions"],
class_count=stats["classes"],
line_count=stats["lines"],
lang=i18n.HTML_LANG[lang],
lang_switch=lang_url(other, filename, lang),
lang_switch_label=i18n.LANG_NAMES_FULL[other],
lang_switch_title=t("switch_language"),
# 无障碍名称里必须包含按钮上看得见的文字,否则读屏软件会读出
# 一个跟界面上的标签对不上的名字
lang_switch_aria=t("switch_to_lang", lang=i18n.LANG_NAMES_FULL[other]),
lang_switch_hreflang=i18n.HTML_LANG[other],
# hreflang 与 canonical 用绝对地址——搜索引擎只认绝对的
alt_zh=absolute_url(site_url, lang_url("zh", filename, lang)),
alt_en=absolute_url(site_url, lang_url("en", filename, lang)),
# x-default 指向默认语言(中文)的那一份
alt_default=absolute_url(site_url, lang_url(i18n.DEFAULT_LANG, filename, lang)),
canonical=absolute_url(site_url, lang_url(lang, filename, lang)),
skip_link=t("skip"),
theme_label=t("theme_toggle"),
menu_label=t("menu_open"),
to_top_label=t("to_top"),
foot_meta=t(
"foot_stats",
version=package["version"],
modules=stats["modules"],
functions=stats["functions"],
lines=stats["lines"],
),
site_i18n=json.dumps(t.js_all(), ensure_ascii=False),
extra_js=extra_js,
)
# --------------------------------------------------------------------------
# 首页
# --------------------------------------------------------------------------
def page_filename(data: dict, key: str) -> str:
"""取某个页面的文件名;页面不存在时返回空串。"""
for link in data["meta"].get("page_links", []):
if link.get("key") == key:
return link.get("filename", "")
return ""
def build_home(data: dict) -> str:
package = data["package"]
stats = data["stats"]
modules = data["modules"]
import_name = package.get("import_name") or package["name"]
t = data["t"]
api_page = page_filename(data, "api")
cards = []
for module in modules:
count = e(t("functions_count", count=len(module["functions"])))
# 没有 API 页时(包里还没有函数),卡片不做成链接
if api_page:
cards.append(
f"""
{e(module['name'])}
{count}
"""
)
else:
cards.append(
f"""
{e(module['name'])}
{count}
"""
)
# 首页按钮跟着实际生成的页面走,避免链到不存在的页面
buttons = []
for index, link in enumerate(data["meta"].get("page_links", [])):
style = "btn" if index == 0 else "btn btn-ghost"
buttons.append(f'{e(link["label"])} ')
hero_links = "\n ".join(buttons)
return f"""
{e(t("hero_eyebrow"))}
{e(display_name(package))}
{e(package['description'] or package.get('summary') or '')}
pip install --upgrade {e(package['name'])}
{e(t("copy"))}
{e(t("hero_version"))} v{e(package['version'])}
· {e(t("hero_python", version=package['requires_python'] or '3.6+'))}
{hero_links}
{stats['modules']} {e(t("stat_modules"))}
{stats['functions']} {e(t("stat_functions"))}
{stats['lines']:,} {e(t("stat_lines"))}
{e(t("section_package"))}
{e(t("field_display_name"))} {e(display_name(package))}
{e(t("field_pypi_name"))} {e(package['name'])}
{e(t("field_import_name"))} {e(import_name)}
{e(t("field_version"))} {e(package['version'])}
{e(t("field_python"))} {e(package['requires_python'] or '>=3.6')}
{e(t("field_homepage"))} {e(package.get('url') or '—')}
"""
# --------------------------------------------------------------------------
# API 参考
# --------------------------------------------------------------------------
def _function_block(
function: dict,
module_name: str,
home_of: dict[str, str],
import_name: str,
t,
anchor: str | None = None,
) -> str:
params = function["params"]
param_rows = []
for param in params:
default = param["default"]
default_html = (
f'{e(default)}'
if default
else f'{e(t("param_required"))} '
)
annotation = (
f'{e(param["annotation"])} '
if param["annotation"]
else ""
)
param_rows.append(
f'{e(param["name"])}{annotation} {default_html} '
)
params_html = (
f"""
{e(t("param_name"))} {e(t("param_type"))} {e(t("param_default"))}
{chr(10).join(param_rows)}
"""
if param_rows
else ""
)
badges = []
if function.get("deprecated"):
badges.append(f'{e(t("badge_deprecated"))} ')
if not function.get("exported", True):
badges.append(f'{e(t("badge_not_exported"))} ')
if function.get("is_async"):
badges.append('async ')
if function.get("decorators"):
badges.append('@' + e(function["decorators"][0]) + " ")
badges_html = " ".join(badges)
# 源码里的分节注释(形如 "# --- 某某某 ---")作为小标签显示
section_html = (
f'{e(function["section"])}
'
if function.get("section")
else ""
)
calls_html = ""
if function.get("calls"):
links = []
for call in function["calls"]:
target = home_of.get(call)
if target:
links.append(
f'{e(import_name)}.{e(call)} '
)
else:
links.append(f"{e(import_name)}.{e(call)}")
calls_html = f'{e(t("calls"))}{", ".join(links)}
'
doc_html = _docstring_html(function)
anchor_id = anchor or f"{module_name}.{function['name']}"
owner = function.get("owner") or ""
shown_name = f"{owner}.{function['name']}" if owner else function["name"]
return f"""
{section_html}
{e(shown_name)}{badges_html}
{e(function['signature'])}
{f'{e(function["description"])}
' if function["description"] else ""}
{doc_html}
{params_html}
{calls_html}
{e(t("view_source"))}
{e(function['source'])}
"""
def _docstring_html(function: dict) -> str:
"""docstring 除了首段(已作为简短说明)之外的其余段落。"""
docstring = (function.get("docstring") or "").strip()
if not docstring:
return ""
paragraphs = [p.strip() for p in docstring.split("\n\n") if p.strip()]
rest = paragraphs[1:]
if not rest:
return ""
body = "".join(f"{e(p)}
" for p in rest)
return f'{body}
'
def build_api(data: dict) -> str:
package = data["package"]
modules = data["modules"]
import_name = package.get("import_name") or package["name"]
t = data["t"]
# 函数名 → 该函数在页面上的锚点,用于生成交叉参考链接
# 类的锚点是「模块.类」,类的方法是「模块.类.方法」,
# 与模块级函数的「模块.函数」不一样
home_of: dict[str, str] = {}
for module in modules:
for function in module["functions"]:
home_of.setdefault(
function["name"], f"{module['name']}.{function['name']}"
)
for klass in module["classes"]:
home_of.setdefault(
klass["name"], f"{module['name']}.{klass['name']}"
)
for method in klass["methods"]:
home_of.setdefault(
method["name"], f"{module['name']}.{klass['name']}.{method['name']}"
)
nav_items = []
body = []
for module in modules:
nav_items.append(
f'{e(module["name"])} '
f'{len(module["functions"])} '
)
functions = "\n".join(
_function_block(function, module["name"], home_of, import_name, t)
for function in module["functions"]
)
classes_html = ""
if module["classes"]:
class_blocks = []
for klass in module["classes"]:
methods = "\n".join(
_function_block(
method,
module["name"],
home_of,
import_name,
t,
anchor=f"{module['name']}.{klass['name']}.{method['name']}",
)
for method in klass["methods"]
)
bases = (
f'{e(t("base_classes"))}{", ".join(f"{e(b)}" for b in klass["bases"])}
'
if klass.get("bases")
else ""
)
class_blocks.append(
f"""
class {e(klass['name'])}
{f'
{e(klass["description"])}
' if klass["description"] else ""}
{bases}
{methods}
"""
)
classes_html = (
f'{e(t("api_classes_title"))} \n'
+ "\n".join(class_blocks)
)
error_html = (
f'{e(t("parse_error"))}:{e(module["error"])}
'
if module.get("error")
else ""
)
# 只有真的有函数时才写「函数」这个分组标题,
# 否则像包根目录那种空模块会留下一对空标题和空网格
functions_html = (
f'{e(t("api_functions_title"))} \n'
f' \n{functions}\n
'
if module["functions"]
else ""
)
body.append(
f"""
{e(module['name'])}
{e(t("module_meta", lines=module['line_count'], count=len(module['functions'])))}
{error_html}
{classes_html}
{functions_html}
"""
)
return f"""
"""
# --------------------------------------------------------------------------
# 关于页
# --------------------------------------------------------------------------
def _markdown_doc_page(
data: dict,
*,
title: str,
lead: str,
source_path: str,
file_key: str,
show_toc: bool = True,
) -> str:
"""把一份 Markdown 文档渲染成页面内容。"""
t = data["t"]
doc_path = Path(data["meta"][file_key])
if not doc_path.exists():
return (
f''
f'{e(t("file_not_found", path=source_path))}
'
)
try:
markdown = doc_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
try:
markdown = doc_path.read_text(encoding="gbk")
except (OSError, UnicodeDecodeError):
markdown = ""
rendered, toc = md_render.render_document(markdown)
toc_html = ""
if toc and show_toc:
items = "\n".join(
f''
f'{e(entry["text"])} '
for entry in toc
)
toc_html = f"""
"""
lead_html = f' {lead}
\n' if lead else ""
layout_class = "doc-layout" if toc_html else "doc-layout doc-layout-flat"
return f"""
"""
def build_readme(data: dict) -> str:
"""把 README.md 渲染成页面内容。"""
return _markdown_doc_page(
data,
title=data["t"]("page_about"),
lead="",
source_path=data["meta"]["readme_path"],
file_key="readme_file",
)
def build_installation(data: dict) -> str:
"""把 INSTALLATION.md 渲染成「安装」页。"""
return _markdown_doc_page(
data,
title=data["t"]("page_installation"),
lead="",
source_path=data["meta"]["installation_path"],
file_key="installation_file",
show_toc=False,
)
# --------------------------------------------------------------------------
# 教程页
# --------------------------------------------------------------------------
def _tutorial_card(item: dict, t) -> str:
"""一个示例脚本的卡片:标题、说明、源码。"""
description = item.get("description") or ""
description_html = (
f'{md_render.render_inline(description)}
'
if description
else ""
)
source_link = (
f' · {e(t("view_original"))} '
if item.get("source")
else ""
)
return f"""
{e(item['title'])}
{e(t("lines", count=item.get('lines', 0)))}
{description_html}
{e(t("view_source"))}
{e(item['code'])}
{e(t("download_script"))} {source_link}
"""
def build_tutorials(data: dict) -> str:
"""把 tutorials/ 目录里的示例脚本渲染成「教程」页。"""
tutorials_data = data.get("tutorials") or {}
groups = tutorials_data.get("groups", [])
t = data["t"]
nav_items = []
body = []
for group in groups:
# 根目录下的脚本归为「开始使用」这一组,分组名按语言给
title = group["title"]
subtitle_text = group.get("subtitle") or ""
if not group.get("key"):
title = t("tut_root_title")
subtitle_text = t("tut_root_subtitle")
nav_items.append(
f''
f'{e(title)} '
f'{len(group["files"])} '
)
cards = "\n".join(_tutorial_card(item, t) for item in group["files"])
subtitle = (
f'{e(subtitle_text)}
'
if subtitle_text
else ""
)
body.append(
f"""
{e(title)}
{subtitle}
{cards}
"""
)
return f"""
"""
# --------------------------------------------------------------------------
# 构建
# --------------------------------------------------------------------------
def _relative(path: Path) -> str:
"""尽量用相对仓库根目录的路径展示。"""
try:
return path.resolve().relative_to(ROOT).as_posix()
except ValueError:
return path.resolve().as_posix()
def _metadata_dir(source_dir: Path) -> Path:
"""在源码目录附近找放 pyproject.toml / setup.cfg 的目录。"""
candidates = [
source_dir,
source_dir.parent,
source_dir.parent.parent,
PYPI_DIR,
ROOT,
]
for candidate in candidates:
if (candidate / "pyproject.toml").exists() or (candidate / "setup.cfg").exists():
return candidate
return source_dir
def _readme_file() -> Path:
"""找 README.md(先仓库根目录,再包目录)。"""
for candidate in (ROOT / "README.md", PYPI_DIR / "README.md"):
if candidate.exists():
return candidate
return ROOT / "README.md"
def _installation_file() -> Path:
"""找 INSTALLATION.md(先仓库根目录,再包目录)。"""
for candidate in (ROOT / "INSTALLATION.md", PYPI_DIR / "INSTALLATION.md"):
if candidate.exists():
return candidate
return ROOT / "INSTALLATION.md"
def copy_local_assets(
output: Path, base_dirs: list[Path]
) -> tuple[list[str], list[str]]:
"""把页面里引用的本地文件(图片、文档等)复制到输出目录。
README 里可能写 `` 或 `[说明](docs/notes.md)`,
这些文件不在网站目录里,需要按相对路径复制过去,否则图片会裂开、
链接会断掉。相对路径依次按 base_dirs 里的目录去找。
站点有多个语言目录,每套页面里的引用都是相对于自己所在目录的,
所以每个语言目录都要备一份这些文件。
返回 (已复制, 找不到的图片)。
"""
copied: list[str] = []
missing: list[str] = []
for page_file in sorted(output.rglob("*.html")):
page_dir = page_file.parent
text = page_file.read_text(encoding="utf-8")
for kind, pattern in (("src", r'src="([^"]+)"'), ("href", r'href="([^"]+)"')):
for raw in re.findall(pattern, text):
src = html.unescape(raw).split("#")[0].split("?")[0]
if not src or re.match(
r"^(?:https?:|data:|//|mailto:|javascript:)", src
):
continue
if src.startswith("/"):
continue # 站点根路径,交给服务器处理
if (page_dir / src).exists():
continue # 已经是网站自带的资源(如 static/style.css)
source = _find_local_file(src, base_dirs)
if source is None:
if kind == "src":
missing.append(src)
continue
target = (page_dir / src).resolve()
# 不允许复制到输出目录之外
try:
target.relative_to(output.resolve())
except ValueError:
continue
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
copied.append(src)
return copied, missing
def _find_local_file(name: str, base_dirs: list[Path]) -> Path | None:
"""在若干个基准目录里找本地文件。"""
for base in base_dirs:
candidate = (Path(base) / name).resolve()
if candidate.is_file():
return candidate
return None
def clean_output(output: Path) -> None:
"""清空输出目录,只留下本次要生成的东西。
先删文件、再按层级从深到浅删目录:某个文件被占用时,
不影响其余文件的重建;实在删不掉的目录最后再整体试一次。
"""
if not output.exists():
return
paths = sorted(output.rglob("*"), key=lambda p: len(p.parts), reverse=True)
for path in paths:
try:
if path.is_symlink() or path.is_file():
path.unlink()
elif path.is_dir():
path.rmdir()
except OSError:
pass
for path in paths:
if path.is_dir():
try:
shutil.rmtree(path)
except OSError:
pass
def verify_output(output: Path) -> list[str]:
"""检查生成的页面:内部链接指向的文件与锚点都必须存在。
源码一旦大改,很容易出现「函数改名后旧锚点失效」这类问题,
构建时顺手查一遍,可以第一时间发现。
链接都是相对于页面自己所在目录的,所以每个语言目录都要单独查。
"""
problems: list[str] = []
pages = sorted(output.rglob("*.html"))
anchors: dict[str, set[str]] = {}
for page_file in pages:
text = page_file.read_text(encoding="utf-8")
key = page_file.relative_to(output).as_posix()
anchors[key] = set(re.findall(r'id="([^"]+)"', text))
for page_file in pages:
text = page_file.read_text(encoding="utf-8")
key = page_file.relative_to(output).as_posix()
for href in re.findall(r'href="([^"]+)"', text):
href = html.unescape(href)
if href.startswith(("http://", "https://", "mailto:", "data:", "javascript:")):
continue
if href.startswith("#"):
anchor = urllib.parse.unquote(href[1:])
if anchor and anchor not in anchors[key]:
problems.append(f"{key}:锚点 {href} 不存在")
continue
target, _, fragment = href.partition("#")
target = urllib.parse.unquote(target)
if not target:
continue
target_path = (page_file.parent / target).resolve()
if not target_path.exists():
problems.append(f"{key}:链接 {href} 指向的文件不存在")
continue
if fragment and target.endswith(".html"):
try:
target_key = target_path.relative_to(output.resolve()).as_posix()
except ValueError:
continue
if urllib.parse.unquote(fragment) not in anchors.get(target_key, set()):
problems.append(f"{key}:链接 {href} 的锚点不存在")
# 页面里引用的本地文件(图片等)也要在
for src in re.findall(r'src="([^"]+)"', text):
src = html.unescape(src).split("#")[0].split("?")[0]
if not src or src.startswith(("http:", "https:", "data:", "//", "/")):
continue
if not (page_file.parent / urllib.parse.unquote(src)).exists():
problems.append(f"{key}:引用的文件 {src} 不存在")
return problems
def generate(
source_dir: Path | None = None,
output: Path = ROOT / "dist" / "site",
readme_path: Path | None = None,
installation_path: Path | None = None,
tutorials_dir: Path | None = None,
base_url: str | None = None,
quiet: bool = False,
) -> dict:
"""生成网站,返回提取到的数据字典。
source_dir 包源码目录;为 None 时自动发现
output 网站输出目录
readme_path README.md 路径,用于生成「关于」页面
installation_path INSTALLATION.md 路径,用于生成「安装」页面
tutorials_dir 示例脚本目录;为 None 时自动发现,没有就不生成「教程」页
base_url 站点根地址;为 None 时从包元数据里的主页地址推
"""
if source_dir is None:
source_dir = extract.discover_package_dir(list(SOURCE_CANDIDATES))
if source_dir is None:
raise SystemExit(
"找不到包源码目录。请用 --source 指定,例如 --source PyPI/src/<包名>"
)
source_dir = Path(source_dir).resolve()
if not source_dir.is_dir():
raise SystemExit(f"包源码目录不存在或不是目录:{source_dir}")
output = Path(output).resolve()
clean_output(output)
output.mkdir(parents=True, exist_ok=True)
# 界面文案必须两种语言都写全,缺了就直接报错,
# 免得构建出「一半中文一半英文」的页面
incomplete = i18n.missing_keys()
if incomplete:
raise SystemExit(
"界面文案不完整:\n " + "\n ".join(incomplete)
)
# 解析、导出关系、交叉参考分三步做,每一步只用做一次
modules = extract.parse_modules(source_dir)
metadata_dir = _metadata_dir(source_dir)
info = extract.parse_package_info(
metadata_dir / "pyproject.toml",
metadata_dir / "setup.cfg",
source_dir / "__init__.py",
package_dir=source_dir,
import_name=source_dir.name,
)
exports, _ = extract.compute_exports(source_dir, modules)
extract.apply_exports(modules, exports)
extract.resolve_calls(modules, source_dir)
data = extract.to_dict(modules, info, exports)
readme = Path(readme_path) if readme_path else _readme_file()
installation = Path(installation_path) if installation_path else _installation_file()
data["meta"] = {
"source_path": _relative(source_dir),
"readme_path": _relative(readme),
"readme_file": str(readme),
"installation_path": _relative(installation),
"installation_file": str(installation),
"metadata_dir": _relative(metadata_dir),
}
# 教程:tutorials/ 目录里的示例脚本,没有这个目录就不生成「教程」页
if tutorials_dir is None:
tutorials_dir = next(
(path for path in TUTORIALS_CANDIDATES if path.is_dir()), None
)
if tutorials_dir is not None:
tutorials_dir = Path(tutorials_dir).resolve()
data["tutorials"] = tutorials.collect(tutorials_dir)
data["meta"]["tutorials_path"] = _relative(tutorials_dir)
# 站点根地址:hreflang、canonical、sitemap 都要用绝对地址
site_url = site_base_url(data["package"], base_url)
data["meta"]["site_url"] = site_url
stats = data["stats"]
shell = load_template("base.html")
pages = active_pages(data)
# 每种语言生成一套页面:中文在根目录,英文在 en/ 子目录。
# 页面里的链接都相对于自己所在目录,所以每个目录都是自洽的。
for lang in i18n.LANGS:
context = _lang_context(data, lang, pages)
directory = lang_dir(output, lang)
directory.mkdir(parents=True, exist_ok=True)
for spec in pages:
content = spec.builder(context)
html_text = page(
shell,
title=spec.page_title(context),
description=spec.description(context),
content=content,
info=data["package"],
stats=stats,
pages=pages,
active=spec.key,
lang=lang,
filename=spec.filename,
site_url=site_url,
)
(directory / spec.filename).write_text(html_text, encoding="utf-8")
# 机器可读的完整 API 数据
# 对外发布的产物里只放相对路径,不泄露本机的绝对路径
api_data = dict(data)
api_data["meta"] = {
**data["meta"],
"readme_file": data["meta"]["readme_path"],
"installation_file": data["meta"]["installation_path"],
}
if "tutorials" in api_data:
# 源码正文太大,索引里只留标题、说明这些摘要信息
api_data["tutorials"] = tutorials.without_code(api_data["tutorials"])
(output / "api.json").write_text(
json.dumps(api_data, ensure_ascii=False, indent=2), encoding="utf-8"
)
# 静态资源:每个语言目录里都放一份
for lang in i18n.LANGS:
static_output = lang_dir(output, lang) / "static"
static_output.mkdir(parents=True, exist_ok=True)
for asset in STATIC_DIR.glob("*"):
if asset.is_file():
shutil.copy2(asset, static_output / asset.name)
# README / INSTALLATION / 教程里引用的本地图片、文档与脚本
base_dirs = [readme.parent, installation.parent, ROOT]
if tutorials_dir is not None:
base_dirs.append(Path(tutorials_dir).parent)
copied, missing = copy_local_assets(output, base_dirs)
errors = [m for m in modules if m.error]
problems = verify_output(output)
if not quiet:
print(f"网站已生成:{output}")
print(
f" {stats['modules']} 个模块 · {stats['functions']} 个函数 "
f"· {stats['classes']} 个类 · {stats['lines']} 行源码"
)
print(
" 语言:"
+ " · ".join(
f"{i18n.LANG_NAMES[lang]}({lang_dir(output, lang).relative_to(output) or '.'})"
for lang in i18n.LANGS
)
)
print(f" 页面:{', '.join(spec.filename for spec in pages)}")
if data.get("tutorials"):
groups = data["tutorials"]["groups"]
names = "、".join(f"{group['title']}({len(group['files'])})" for group in groups)
print(f" 教程:{data['tutorials']['count']} 个示例脚本 — {names}")
for module in errors:
print(f" ! {module.path}:{module.error}")
for name in missing:
print(f" ! 找不到图片资源:{name}")
for problem in problems:
print(f" ! {problem}")
if problems:
print(f" (共 {len(problems)} 处链接问题)")
if copied:
print(f" 已复制 {len(copied)} 个本地资源:{', '.join(sorted(set(copied)))}")
# 供调用方判断构建是否干净
data["problems"] = problems
return data
def main() -> None:
parser = argparse.ArgumentParser(description="生成软件包的网站")
parser.add_argument(
"-o",
"--output",
default=str(ROOT / "dist" / "site"),
help="网站输出目录(默认 dist/site)",
)
parser.add_argument(
"--source",
default=None,
help="包源码目录(默认自动发现,例如 PyPI/src/<包名>)",
)
parser.add_argument(
"--readme",
default=None,
help="README.md 路径(默认仓库根目录的 README.md)",
)
parser.add_argument(
"--installation",
default=None,
help="INSTALLATION.md 路径(默认仓库根目录的 INSTALLATION.md)",
)
parser.add_argument(
"--tutorials",
default=None,
help="示例脚本目录(默认仓库根目录的 tutorials/,没有则不生成教程页)",
)
parser.add_argument(
"-q",
"--quiet",
action="store_true",
help="只生成网站,不打印检查结果",
)
parser.add_argument(
"--strict",
action="store_true",
help="发现断链或解析错误时以非零状态退出(用于 CI)",
)
args = parser.parse_args()
data = generate(
source_dir=Path(args.source) if args.source else None,
output=Path(args.output),
readme_path=Path(args.readme) if args.readme else None,
installation_path=Path(args.installation) if args.installation else None,
tutorials_dir=Path(args.tutorials) if args.tutorials else None,
quiet=args.quiet,
)
if args.strict:
problems = list(data.get("problems") or [])
problems += [
f"源码解析失败:{module.path}"
for module in data.get("modules", [])
if module.get("error")
]
if problems:
print(f"严格模式下发现 {len(problems)} 个问题,构建失败。")
raise SystemExit(1)
if __name__ == "__main__":
main()