"""生成软件包的网站。 用法: 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", ) # 首页文件名。首页在地址栏里显示成目录(/ 与 /en/),不带 index.html HOME_FILENAME = "index.html" 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/ 子目录,所以两个方向要分别处理。 首页用目录地址表示(/ 与 /en/),不带 index.html。 """ target_dir = i18n.LANG_DIRS[lang] current_dir = i18n.LANG_DIRS[from_lang] if filename == HOME_FILENAME: # 首页指向目录本身,而不是 index.html if target_dir == current_dir: return "./" if not target_dir: return "../" if not current_dir: return target_dir + "/" return "../" + target_dir + "/" 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_page_url(base: str, lang: str, filename: str) -> str: """页面的绝对地址(canonical 与 hreflang 用)。 首页用目录地址表示:/ 与 /en/,不带 index.html; 其余页面是 /api.html 这种带后缀的地址。 不知道站点根地址时退回到相对地址,页面至少还能互相跳转。 """ directory = i18n.LANG_DIRS[lang] if filename == HOME_FILENAME: if not base: return "./" return base + "/" + (directory + "/" if directory else "") if not base: return filename return base + "/" + "/".join(part for part in (directory, filename) if part) 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: """生成导航条(顶栏与页脚共用)。 首页指向当前目录(./),也就是 / 与 /en/,不带 index.html。 """ items = [] for spec in pages: class_attr = "is-active" if spec.key == active_key else "" href = home_href() if spec.key == "index" else spec.filename items.append( f'{e(t(spec.label_key))}' ) return "\n ".join(items) def home_href() -> str: """首页链接(相对于当前页面所在的目录)。 页面都在自己语言的目录里(中文在根目录,英文在 en/), 所以 "./" 正好指向该语言的首页:/ 与 /en/。 """ return "./" 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_page_url(site_url, "zh", filename), alt_en=absolute_page_url(site_url, "en", filename), # x-default 指向默认语言(中文)的那一份 alt_default=absolute_page_url(site_url, i18n.DEFAULT_LANG, filename), canonical=absolute_page_url(site_url, lang, filename), home_href=home_href(), 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"""<a class="module-card" href="{e(api_page)}#{e(module['name'])}"> <span class="module-name">{e(module['name'])}</span> <span class="module-count">{count}</span> </a>""" ) else: cards.append( f"""<div class="module-card"> <span class="module-name">{e(module['name'])}</span> <span class="module-count">{count}</span> </div>""" ) # 首页按钮跟着实际生成的页面走,避免链到不存在的页面 buttons = [] for index, link in enumerate(data["meta"].get("page_links", [])): style = "btn" if index == 0 else "btn btn-ghost" buttons.append(f'<a class="{style}" href="{e(link["filename"])}">{e(link["label"])}</a>') hero_links = "\n ".join(buttons) return f""" <section class="hero"> <div class="hero-text"> <p class="eyebrow">{e(t("hero_eyebrow"))}</p> <h1>{e(display_name(package))}</h1> <p class="lead">{e(package['description'] or package.get('summary') or '')}</p> <div class="install"> <code>pip install --upgrade {e(package['name'])}</code> <button class="copy" type="button" data-copy="pip install --upgrade {e(package['name'])}">{e(t("copy"))}</button> </div> <p class="hero-meta">{e(t("hero_version"))} <strong>v{e(package['version'])}</strong> · {e(t("hero_python", version=package['requires_python'] or '3.6+'))}</p> <p class="hero-links"> {hero_links} </p> </div> </section> <section class="stats"> <div class="stat"><span class="stat-value">{stats['modules']}</span><span class="stat-label">{e(t("stat_modules"))}</span></div> <div class="stat"><span class="stat-value">{stats['functions']}</span><span class="stat-label">{e(t("stat_functions"))}</span></div> <div class="stat"><span class="stat-value">{stats['lines']:,}</span><span class="stat-label">{e(t("stat_lines"))}</span></div> </section> <section class="section"> <div class="section-head"> <h2>{e(t("section_modules"))}</h2> {f'<a class="more" href="{e(api_page)}">{e(t("all_api"))}</a>' if api_page else ""} </div> <div class="module-grid"> {chr(10).join(cards)} </div> </section> <section class="section"> <div class="split"> <div class="panel"> <h2>{e(t("section_package"))}</h2> <table class="params"> <tbody> <tr><td>{e(t("field_display_name"))}</td><td><code>{e(display_name(package))}</code></td></tr> <tr><td>{e(t("field_pypi_name"))}</td><td><code>{e(package['name'])}</code></td></tr> <tr><td>{e(t("field_import_name"))}</td><td><code>{e(import_name)}</code></td></tr> <tr><td>{e(t("field_version"))}</td><td><code>{e(package['version'])}</code></td></tr> <tr><td>{e(t("field_python"))}</td><td><code>{e(package['requires_python'] or '>=3.6')}</code></td></tr> <tr><td>{e(t("field_homepage"))}</td><td><a href="{e(package.get('url') or '#')}">{e(package.get('url') or '—')}</a></td></tr> </tbody> </table> </div> </div> </section> """ # -------------------------------------------------------------------------- # 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'<code class="param-default">{e(default)}</code>' if default else f'<span class="muted">{e(t("param_required"))}</span>' ) annotation = ( f'<span class="param-type">{e(param["annotation"])}</span>' if param["annotation"] else "" ) param_rows.append( f'<tr><td><code>{e(param["name"])}</code></td><td>{annotation}</td><td>{default_html}</td></tr>' ) params_html = ( f"""<table class="params"> <thead><tr><th>{e(t("param_name"))}</th><th>{e(t("param_type"))}</th><th>{e(t("param_default"))}</th></tr></thead> <tbody> {chr(10).join(param_rows)} </tbody> </table>""" if param_rows else "" ) badges = [] if function.get("deprecated"): badges.append(f'<span class="badge badge-warn">{e(t("badge_deprecated"))}</span>') if not function.get("exported", True): badges.append(f'<span class="badge badge-muted">{e(t("badge_not_exported"))}</span>') if function.get("is_async"): badges.append('<span class="badge">async</span>') if function.get("decorators"): badges.append('<span class="badge">@' + e(function["decorators"][0]) + "</span>") badges_html = " ".join(badges) # 源码里的分节注释(形如 "# --- 某某某 ---")作为小标签显示 section_html = ( f'<p class="func-section">{e(function["section"])}</p>' 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'<a href="#{e(target)}"><code>{e(import_name)}.{e(call)}</code></a>' ) else: links.append(f"<code>{e(import_name)}.{e(call)}</code>") calls_html = f'<p class="calls">{e(t("calls"))}{", ".join(links)}</p>' 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""" <article class="func" id="{e(anchor_id)}"> {section_html} <h3><code class="func-name">{e(shown_name)}</code>{badges_html}</h3> <p class="sig"><code>{e(function['signature'])}</code></p> {f'<p class="desc">{e(function["description"])}</p>' if function["description"] else ""} {doc_html} {params_html} {calls_html} <details class="source"> <summary>{e(t("view_source"))}</summary> <pre class="code"><code>{e(function['source'])}</code></pre> </details> </article> """ 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"<p>{e(p)}</p>" for p in rest) return f'<div class="docstring">{body}</div>' 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'<li><a href="#{e(module["name"])}"><span class="nav-name">{e(module["name"])}</span>' f'<span class="nav-count">{len(module["functions"])}</span></a></li>' ) 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'<p class="muted">{e(t("base_classes"))}{", ".join(f"<code>{e(b)}</code>" for b in klass["bases"])}</p>' if klass.get("bases") else "" ) class_blocks.append( f"""<div class="class-block" id="{e(module['name'])}.{e(klass['name'])}"> <h3 class="class-name">class {e(klass['name'])}</h3> {f'<p class="desc">{e(klass["description"])}</p>' if klass["description"] else ""} {bases} {methods} </div>""" ) classes_html = ( f'<h3 class="group-title">{e(t("api_classes_title"))}</h3>\n' + "\n".join(class_blocks) ) error_html = ( f'<p class="badge badge-warn">{e(t("parse_error"))}:{e(module["error"])}</p>' if module.get("error") else "" ) # 只有真的有函数时才写「函数」这个分组标题, # 否则像包根目录那种空模块会留下一对空标题和空网格 functions_html = ( f'<h3 class="group-title">{e(t("api_functions_title"))}</h3>\n' f' <div class="func-grid">\n{functions}\n </div>' if module["functions"] else "" ) body.append( f"""<section class="module" id="{e(module['name'])}"> <h2 class="module-title">{e(module['name'])}</h2> <p class="module-meta">{e(t("module_meta", lines=module['line_count'], count=len(module['functions'])))}</p> {error_html} {classes_html} {functions_html} </section>""" ) return f""" <header class="page-head"> <h1>{e(t("page_api"))}</h1> </header> <div class="api-layout"> <aside class="api-nav"> <p class="api-nav-title">{e(t("api_modules_title"))}</p> <ul> {chr(10).join(nav_items)} </ul> </aside> <div class="api-body"> {chr(10).join(body)} </div> </div> """ # -------------------------------------------------------------------------- # 关于页 # -------------------------------------------------------------------------- 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'<header class="page-head"><h1>{e(title)}</h1></header>' f'<p class="muted">{e(t("file_not_found", path=source_path))}</p>' ) 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'<li class="toc-l{entry["level"]}">' f'<a href="#{e(entry["id"])}">{e(entry["text"])}</a></li>' for entry in toc ) toc_html = f""" <aside class="toc"> <p class="toc-title">{e(t("toc_title"))}</p> <ul> {items} </ul> </aside> """ lead_html = f' <p class="lead">{lead}</p>\n' if lead else "" layout_class = "doc-layout" if toc_html else "doc-layout doc-layout-flat" return f""" <header class="page-head"> <h1>{e(title)}</h1> {lead_html}</header> <div class="{layout_class}"> {toc_html} <article class="markdown"> {rendered} </article> </div> """ 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'<p class="tut-desc">{md_render.render_inline(description)}</p>' if description else "" ) source_link = ( f' · <a href="{e(item["source"])}" rel="noopener">{e(t("view_original"))}</a>' if item.get("source") else "" ) return f""" <article class="tut-card" id="{e(item['anchor'])}"> <div class="tut-card-head"> <h3 class="tut-title">{e(item['title'])}</h3> <span class="tut-lines">{e(t("lines", count=item.get('lines', 0)))}</span> </div> {description_html} <details class="source"> <summary>{e(t("view_source"))}</summary> <pre class="code"><code>{e(item['code'])}</code></pre> </details> <p class="tut-links"> <a href="{e(item['path'])}" download="{e(Path(item['path']).name)}" data-download="{e(Path(item['path']).name)}">{e(t("download_script"))}</a>{source_link} </p> </article> """ 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'<li><a href="#{e(group["anchor"])}">' f'<span class="nav-name">{e(title)}</span>' f'<span class="nav-count">{len(group["files"])}</span></a></li>' ) cards = "\n".join(_tutorial_card(item, t) for item in group["files"]) subtitle = ( f'<p class="tut-group-sub">{e(subtitle_text)}</p>' if subtitle_text else "" ) body.append( f"""<section class="tut-group" id="{e(group['anchor'])}"> <h2 class="tut-group-title">{e(title)}</h2> {subtitle} <div class="tut-cards"> {cards} </div> </section>""" ) return f""" <header class="page-head"> <h1>{e(t("page_tutorials"))}</h1> </header> <div class="tut-layout"> <aside class="toc tut-nav"> <p class="toc-title">{e(t("tut_groups_title"))}</p> <ul> {chr(10).join(nav_items)} </ul> </aside> <div class="tut-body"> {chr(10).join(body)} </div> </div> """ # -------------------------------------------------------------------------- # 构建 # -------------------------------------------------------------------------- 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 里可能写 `![logo](docs/logo.png)` 或 `[说明](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 REDIRECT_TEMPLATE = """<!DOCTYPE html> <html lang="{lang}"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="robots" content="noindex, follow"> <title>{title}

{title}:{target}

""" def write_redirect_stubs(output: Path, pages: list[PageSpec], site_url: str) -> list[str]: """给每个页面生成一个不带 .html 的入口。 访问 /about 时,服务器会去找 /about 这个目录里的 index.html, 找到的就是这里生成的重定向页,它会跳到 /about.html。 服务器上配了重定向规则(把 /about 直接 301 到 /about.html)时走不到这一层; 这里生成的文件是纯静态部署时的兜底。 """ written: list[str] = [] for lang in i18n.LANGS: directory = lang_dir(output, lang) for spec in pages: if spec.filename == HOME_FILENAME: continue # 首页用目录地址(/ 与 /en/),不需要重定向 name = Path(spec.filename).stem stub_dir = directory / name stub_dir.mkdir(parents=True, exist_ok=True) target = "../" + spec.filename title = f"正在跳转到 {target}" # 不知道站点根地址时,规范地址用相对地址(相对于这个重定向页) canonical = ( absolute_page_url(site_url, lang, spec.filename) if site_url else target ) stub = stub_dir / "index.html" stub.write_text( REDIRECT_TEMPLATE.format( lang=i18n.HTML_LANG[lang], title=e(title), target=e(target), canonical=e(canonical), target_js=json.dumps(target), ), encoding="utf-8", ) written.append(stub.relative_to(output).as_posix()) return written 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) # 不带 .html 的入口:/about 也能访问,会跳到 /about.html stubs = write_redirect_stubs(output, pages, site_url) 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)))}") if stubs: print(f" 已生成 {len(stubs)} 个不带 .html 的入口(跳转到对应的 .html 页面)") # 供调用方判断构建是否干净 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()