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

@@ -101,6 +101,10 @@ for page in HTML_PAGES:
rel = page.relative_to(SITE).as_posix() rel = page.relative_to(SITE).as_posix()
text = page.read_text(encoding="utf-8") text = page.read_text(encoding="utf-8")
# 重定向页(不带 .html 的入口)只有一个跳转,不需要完整的页面结构
if 'http-equiv="refresh"' in text:
continue
# doctype # doctype
if not text.lstrip().lower().startswith("<!doctype html"): if not text.lstrip().lower().startswith("<!doctype html"):
report("结构", rel, "缺少 <!DOCTYPE html>") report("结构", rel, "缺少 <!DOCTYPE html>")

View File

@@ -60,6 +60,9 @@ TUTORIALS_CANDIDATES = (
PYPI_DIR / "tutorials", PYPI_DIR / "tutorials",
) )
# 首页文件名。首页在地址栏里显示成目录(/ 与 /en/),不带 index.html
HOME_FILENAME = "index.html"
def e(text: object) -> str: def e(text: object) -> str:
"""HTML 转义。""" """HTML 转义。"""
@@ -223,9 +226,19 @@ def lang_url(lang: str, filename: str, from_lang: str) -> str:
"""另一种语言里同一个页面的相对路径(相对于当前页面)。 """另一种语言里同一个页面的相对路径(相对于当前页面)。
中文页面在根目录,英文页面在 en/ 子目录,所以两个方向要分别处理。 中文页面在根目录,英文页面在 en/ 子目录,所以两个方向要分别处理。
首页用目录地址表示(/ 与 /en/),不带 index.html。
""" """
target_dir = i18n.LANG_DIRS[lang] target_dir = i18n.LANG_DIRS[lang]
current_dir = i18n.LANG_DIRS[from_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: if target_dir == current_dir:
return filename return filename
if not target_dir: if not target_dir:
@@ -241,26 +254,21 @@ def lang_dir(output: Path, lang: str) -> Path:
return output / directory if directory else output 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 是页面里的相对路径 首页用目录地址表示:/ 与 /en/,不带 index.html;
(如 "api.html" 或 "../api.html")。搜索引擎要求 hreflang 与 canonical 其余页面是 /api.html 这种带后缀的地址。
用绝对地址,所以这里把 ".." 之类的片段解析掉再拼。 不知道站点根地址时退回到相对地址,页面至少还能互相跳转。
不知道根地址时原样返回,页面至少还能用相对地址互相跳转。
""" """
directory = i18n.LANG_DIRS[lang]
if filename == HOME_FILENAME:
if not base: if not base:
return path return "./"
parts: list[str] = [] return base + "/" + (directory + "/" if directory else "")
for segment in path.split("/"): if not base:
if segment in ("", "."): return filename
continue return base + "/" + "/".join(part for part in (directory, filename) if part)
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: 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: def nav_html(pages: list[PageSpec], active_key: str, t) -> str:
"""生成导航条(顶栏与页脚共用)。""" """生成导航条(顶栏与页脚共用)。
首页指向当前目录(./),也就是 / 与 /en/,不带 index.html。
"""
items = [] items = []
for spec in pages: for spec in pages:
class_attr = "is-active" if spec.key == active_key else "" class_attr = "is-active" if spec.key == active_key else ""
href = home_href() if spec.key == "index" else spec.filename
items.append( 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) return "\n ".join(items)
def home_href() -> str:
"""首页链接(相对于当前页面所在的目录)。
页面都在自己语言的目录里(中文在根目录,英文在 en/),
所以 "./" 正好指向该语言的首页:/ 与 /en/。
"""
return "./"
def page( def page(
template: str, template: str,
*, *,
@@ -345,11 +366,12 @@ def page(
lang_switch_aria=t("switch_to_lang", lang=i18n.LANG_NAMES_FULL[other]), lang_switch_aria=t("switch_to_lang", lang=i18n.LANG_NAMES_FULL[other]),
lang_switch_hreflang=i18n.HTML_LANG[other], lang_switch_hreflang=i18n.HTML_LANG[other],
# hreflang 与 canonical 用绝对地址——搜索引擎只认绝对的 # hreflang 与 canonical 用绝对地址——搜索引擎只认绝对的
alt_zh=absolute_url(site_url, lang_url("zh", filename, lang)), alt_zh=absolute_page_url(site_url, "zh", filename),
alt_en=absolute_url(site_url, lang_url("en", filename, lang)), alt_en=absolute_page_url(site_url, "en", filename),
# x-default 指向默认语言(中文)的那一份 # x-default 指向默认语言(中文)的那一份
alt_default=absolute_url(site_url, lang_url(i18n.DEFAULT_LANG, filename, lang)), alt_default=absolute_page_url(site_url, i18n.DEFAULT_LANG, filename),
canonical=absolute_url(site_url, lang_url(lang, filename, lang)), canonical=absolute_page_url(site_url, lang, filename),
home_href=home_href(),
skip_link=t("skip"), skip_link=t("skip"),
theme_label=t("theme_toggle"), theme_label=t("theme_toggle"),
menu_label=t("menu_open"), menu_label=t("menu_open"),
@@ -962,6 +984,62 @@ def _find_local_file(name: str, base_dirs: list[Path]) -> Path | None:
return 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: def clean_output(output: Path) -> None:
"""清空输出目录,只留下本次要生成的东西。 """清空输出目录,只留下本次要生成的东西。
@@ -1175,6 +1253,9 @@ def generate(
base_dirs.append(Path(tutorials_dir).parent) base_dirs.append(Path(tutorials_dir).parent)
copied, missing = copy_local_assets(output, base_dirs) 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] errors = [m for m in modules if m.error]
problems = verify_output(output) problems = verify_output(output)
if not quiet: if not quiet:
@@ -1205,6 +1286,8 @@ def generate(
print(f" (共 {len(problems)} 处链接问题)") print(f" (共 {len(problems)} 处链接问题)")
if copied: if copied:
print(f" 已复制 {len(copied)} 个本地资源:{', '.join(sorted(set(copied)))}") print(f" 已复制 {len(copied)} 个本地资源:{', '.join(sorted(set(copied)))}")
if stubs:
print(f" 已生成 {len(stubs)} 个不带 .html 的入口(跳转到对应的 .html 页面)")
# 供调用方判断构建是否干净 # 供调用方判断构建是否干净
data["problems"] = problems data["problems"] = problems

View File

@@ -658,13 +658,36 @@ def check_i18n(data: dict, output: Path) -> None:
check(">Modules<" in en_index, "英文页面有对应的英文标题") check(">Modules<" in en_index, "英文页面有对应的英文标题")
print("\n语言切换") print("\n语言切换")
check('href="en/index.html"' in zh_index, "中文页面链到英文页面") check('href="en/"' in zh_index, "中文页面链到英文页面")
check('href="../index.html"' in en_index, "英文页面链回中文页面") check('href="../"' in en_index, "英文页面链回中文页面")
zh_api = (output / "api.html").read_text(encoding="utf-8") zh_api = (output / "api.html").read_text(encoding="utf-8")
en_api = (output / "en" / "api.html").read_text(encoding="utf-8") en_api = (output / "en" / "api.html").read_text(encoding="utf-8")
check('href="en/api.html"' in zh_api, "API 页也能切到另一种语言") check('href="en/api.html"' in zh_api, "API 页也能切到另一种语言")
check('href="../api.html"' in en_api, "英文 API 页能切回中文") check('href="../api.html"' in en_api, "英文 API 页能切回中文")
print("\n地址里不带 index.html")
zh_canonical = re.search(r'canonical" href="([^"]*)"', zh_index)
en_canonical = re.search(r'canonical" href="([^"]*)"', en_index)
check(
bool(zh_canonical) and not zh_canonical.group(1).endswith("index.html"),
"中文首页的规范地址不带 index.html",
)
check(
bool(en_canonical) and not en_canonical.group(1).endswith("index.html"),
"英文首页的规范地址不带 index.html",
)
check('href="./" class="is-active"' in zh_index, "首页链接指向目录")
stub = output / "about" / "index.html"
check(stub.exists(), "生成了不带 .html 的入口(/about)")
if stub.exists():
stub_text = stub.read_text(encoding="utf-8")
check("../about.html" in stub_text, "入口跳到对应的 .html 页面")
stub_canonical = re.search(r'canonical" href="([^"]*)"', stub_text)
check(
bool(stub_canonical) and stub_canonical.group(1).endswith("about.html"),
"入口的规范地址指向 .html 页面",
)
print("\n文件内容保持原样") print("\n文件内容保持原样")
# README、源码里的说明不翻译:两种语言的页面里都应当原样出现 # README、源码里的说明不翻译:两种语言的页面里都应当原样出现
zh_about = (output / "about.html").read_text(encoding="utf-8") zh_about = (output / "about.html").read_text(encoding="utf-8")

View File

@@ -22,12 +22,23 @@
else document.documentElement.setAttribute("data-theme", "dark"); else document.documentElement.setAttribute("data-theme", "dark");
} catch (e) {} } catch (e) {}
</script> </script>
<script>
// 首页统一用不带 index.html 的地址(/ 与 /en/)。
// 服务器配了重定向规则时用不上这里,只是没配服务器时的兜底。
(function () {
var suffix = "/index.html";
var path = location.pathname;
if (path.slice(-suffix.length) === suffix) {
location.replace(path.slice(0, path.length - "index.html".length));
}
})();
</script>
</head> </head>
<body> <body>
<a class="skip" href="#main">{{skip_link}}</a> <a class="skip" href="#main">{{skip_link}}</a>
<header class="site-head"> <header class="site-head">
<div class="wrap head-inner"> <div class="wrap head-inner">
<a class="brand" href="index.html"> <a class="brand" href="{{home_href}}">
<img class="brand-mark" src="static/icon.jpg" alt="" width="28" height="28"> <img class="brand-mark" src="static/icon.jpg" alt="" width="28" height="28">
<span class="brand-text">{{package_name}}</span> <span class="brand-text">{{package_name}}</span>
<span class="brand-version">v{{version}}</span> <span class="brand-version">v{{version}}</span>