This commit is contained in:
2026-11-02 19:42:26 +08:00
parent 7d5c445c72
commit b6edf6366f
4 changed files with 147 additions and 26 deletions

View File

@@ -60,6 +60,9 @@ TUTORIALS_CANDIDATES = (
PYPI_DIR / "tutorials",
)
# 首页文件名。首页在地址栏里显示成目录(/ 与 /en/),不带 index.html
HOME_FILENAME = "index.html"
def e(text: object) -> str:
"""HTML 转义。"""
@@ -223,9 +226,19 @@ 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:
@@ -241,26 +254,21 @@ def lang_dir(output: Path, lang: str) -> Path:
return output / directory if directory else output
def absolute_url(base: str, path: str) -> str:
"""把站点内的相对路径拼成绝对地址。
def absolute_page_url(base: str, lang: str, filename: str) -> str:
"""页面的绝对地址(canonical 与 hreflang 用)。
base 是站点根地址(如 https://py.guanjihuan.com),path 是页面里的相对路径
(如 "api.html" 或 "../api.html")。搜索引擎要求 hreflang 与 canonical
用绝对地址,所以这里把 ".." 之类的片段解析掉再拼。
不知道根地址时原样返回,页面至少还能用相对地址互相跳转。
首页用目录地址表示:/ 与 /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 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)
return filename
return base + "/" + "/".join(part for part in (directory, filename) if part)
def site_base_url(package: dict, override: str | None = None) -> str:
@@ -287,16 +295,29 @@ def _lang_context(data: dict, lang: str, pages: list[PageSpec]) -> dict:
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'<a href="{spec.filename}" class="{class_attr}">{e(t(spec.label_key))}</a>'
f'<a href="{href}" class="{class_attr}">{e(t(spec.label_key))}</a>'
)
return "\n ".join(items)
def home_href() -> str:
"""首页链接(相对于当前页面所在的目录)。
页面都在自己语言的目录里(中文在根目录,英文在 en/),
所以 "./" 正好指向该语言的首页:/ 与 /en/。
"""
return "./"
def page(
template: str,
*,
@@ -345,11 +366,12 @@ def page(
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)),
alt_zh=absolute_page_url(site_url, "zh", filename),
alt_en=absolute_page_url(site_url, "en", filename),
# 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)),
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"),
@@ -962,6 +984,62 @@ def _find_local_file(name: str, base_dirs: list[Path]) -> Path | None:
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>
<meta http-equiv="refresh" content="0; url={target}">
<link rel="canonical" href="{canonical}">
<script>location.replace({target_js});</script>
</head>
<body>
<p>{title}:<a href="{target}">{target}</a></p>
</body>
</html>
"""
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:
"""清空输出目录,只留下本次要生成的东西。
@@ -1175,6 +1253,9 @@ def generate(
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:
@@ -1205,6 +1286,8 @@ def generate(
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