From a612506506e799dc1246e6d102d1940c92a4de7e Mon Sep 17 00:00:00 2001 From: hanxiankai Date: Tue, 1 Sep 2026 17:53:55 +0800 Subject: [PATCH] Declare every bilingual page relationship --- CHANGELOG.md | 1 + docs/.vitepress/config.mts | 38 ++++++++++++++++++++++++++++++++-- docs/.vitepress/navigation.mjs | 10 +++++++++ tests/site.spec.mjs | 35 ++++++++++++++++++++++++++++++- 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41f5ce0..f970792 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable project-level changes are documented here. Content pages retain thei #### Changed +- Added reciprocal `zh-CN`, `en-US`, and `x-default` language alternates to all 68 bilingual page pairs, mirrored them in the sitemap, and declared the dedicated navbar mark as the site icon. - Replaced the obsolete English-guide sharing artwork with bilingual Life Level-up Guide covers, a dedicated navbar mark, generated 1200×630 PNG social images, and Book/Chapter JSON-LD metadata. - Localised VitePress's hard-coded navigation, sidebar, pager, section-toggle, mobile-menu, and heading-permalink labels so Chinese screen readers no longer switch unexpectedly into English. - Localised the Chinese search dialog, edit and accessibility labels, appearance controls, and missing-page copy; shortened both navbar titles to the book name so the English title cannot overlap search. diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 57c5971..fdc45a2 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,7 +1,7 @@ import { existsSync, rmSync } from "node:fs"; import { resolve } from "node:path"; import { defineConfig } from "vitepress"; -import { enNavigation, toSidebar, zhNavigation } from "./navigation.mjs"; +import { bilingualRoutePairs, enNavigation, toSidebar, zhNavigation } from "./navigation.mjs"; const origin = "https://byoungd.github.io"; const base = "/up/"; @@ -12,6 +12,16 @@ const defaultDescription = const defaultDescriptionEn = "Life Level-up Guide helps ordinary people learn continuously, complete real projects, move through difficult seasons, and preserve evidence of growth in the AI era."; const editLinkPattern = "https://github.com/byoungd/up/edit/master/docs/:path"; +const bilingualRouteMap = new Map( + bilingualRoutePairs.flatMap(({ zh, en }) => [ + [zh, { zh, en }], + [en, { zh, en }], + ]), +); + +function absoluteRoute(route: string) { + return `${siteUrl}${route}${route ? "/" : ""}`; +} function routeFromRelativePath(relativePath: string) { const clean = relativePath @@ -70,7 +80,21 @@ export default defineConfig({ cleanUrls: true, lastUpdated: true, srcExclude: ["SUMMARY.md", "en/SUMMARY.md"], - sitemap: { hostname: siteUrl }, + sitemap: { + hostname: siteUrl, + transformItems(items) { + return items.map((item) => { + const links = item.links || []; + const languageOf = ({ lang, hreflang }: { lang: string; hreflang?: string }) => lang || hreflang; + const chinese = links.find((link) => languageOf(link) === "zh-CN"); + if (!chinese || links.some((link) => languageOf(link) === "x-default")) return item; + return { + ...item, + links: [...links, { lang: "x-default", hreflang: "x-default", url: chinese.url }], + }; + }); + }, + }, vite: { plugins: [privateAssetGuard()] }, rewrites: { "README.md": "index.md", @@ -79,6 +103,7 @@ export default defineConfig({ "en/threads/archive/README.md": "en/threads/archive/index.md", }, head: [ + ["link", { rel: "icon", type: "image/svg+xml", href: `${base}assets/logo.svg` }], ["meta", { name: "theme-color", content: "#1f6f5c" }], ["meta", { name: "author", content: "Han Xiankai / 韩先凯 (Li Pu / 离谱) and contributors" }], ["meta", { name: "build-revision", content: buildRevision }], @@ -209,6 +234,7 @@ export default defineConfig({ transformHead({ pageData }) { const route = routeFromRelativePath(pageData.relativePath); const canonical = `${siteUrl}${route}${route ? "/" : ""}`; + const languagePair = bilingualRouteMap.get(route); const isEnglish = route === "en" || route.startsWith("en/"); const isBookHome = route === "" || route === "en"; const isChapter = route.startsWith("threads/") || route.includes("/threads/"); @@ -257,8 +283,16 @@ export default defineConfig({ ...(dateModified ? { dateModified } : {}), }; const jsonLd = JSON.stringify(structuredData).replaceAll("<", "\\u003c"); + const alternateLinks = languagePair + ? [ + ["link", { rel: "alternate", hreflang: "zh-CN", href: absoluteRoute(languagePair.zh) }], + ["link", { rel: "alternate", hreflang: "en-US", href: absoluteRoute(languagePair.en) }], + ["link", { rel: "alternate", hreflang: "x-default", href: absoluteRoute(languagePair.zh) }], + ] + : []; return [ ["link", { rel: "canonical", href: canonical }], + ...alternateLinks, ["meta", { property: "og:type", content: isBookHome ? "book" : "article" }], ["meta", { property: "og:site_name", content: bookName }], ["meta", { property: "og:locale", content: isEnglish ? "en_US" : "zh_CN" }], diff --git a/docs/.vitepress/navigation.mjs b/docs/.vitepress/navigation.mjs index ac718f7..540b6dd 100644 --- a/docs/.vitepress/navigation.mjs +++ b/docs/.vitepress/navigation.mjs @@ -235,6 +235,16 @@ export const enNavigation = [ }, ]; +const normalizeRoute = (link) => link.replace(/^\/+|\/+$/g, ""); +const zhRoutes = zhNavigation.flatMap(({ items }) => items.map(({ link }) => normalizeRoute(link))); +const enRoutes = new Set(enNavigation.flatMap(({ items }) => items.map(({ link }) => normalizeRoute(link)))); + +export const bilingualRoutePairs = zhRoutes.map((zh) => { + const en = zh ? `en/${zh}` : "en"; + if (!enRoutes.has(en)) throw new Error(`缺少英文对应路由: /${zh}`); + return { zh, en }; +}); + const collapsedGroups = new Set(["工具箱", "旧文归档", "词表", "Toolkit", "Archive", "Word Lists"]); export function toSidebar(groups) { diff --git a/tests/site.spec.mjs b/tests/site.spec.mjs index 6bc0ac3..e1ba0db 100644 --- a/tests/site.spec.mjs +++ b/tests/site.spec.mjs @@ -1,7 +1,12 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { expect, test } from "@playwright/test"; -import { enNavigation, toSidebar, zhNavigation } from "../docs/.vitepress/navigation.mjs"; +import { + bilingualRoutePairs, + enNavigation, + toSidebar, + zhNavigation, +} from "../docs/.vitepress/navigation.mjs"; const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const headingFromSource = (source) => { @@ -73,6 +78,14 @@ test("every part opens with a bilingual introduction", () => { ]); }); +test("every public navigation route has one bilingual counterpart", () => { + const zhRoutes = zhNavigation.flatMap(({ items }) => items.map(({ link }) => link.replace(/^\/+|\/+$/g, ""))); + const enRoutes = enNavigation.flatMap(({ items }) => items.map(({ link }) => link.replace(/^\/+|\/+$/g, ""))); + expect(bilingualRoutePairs).toHaveLength(zhRoutes.length); + expect(bilingualRoutePairs.map(({ zh }) => zh)).toEqual(zhRoutes); + expect(bilingualRoutePairs.map(({ en }) => en).sort()).toEqual(enRoutes.sort()); +}); + test("reference collections follow the book and stay collapsed by default", () => { expect(zhNavigation.slice(7).map(({ text }) => text)).toEqual(["工具箱", "旧文归档", "词表"]); expect(enNavigation.slice(7).map(({ text }) => text)).toEqual(["Toolkit", "Archive", "Word Lists"]); @@ -138,6 +151,18 @@ test("page metadata follows the route", async ({ page }) => { ); await expect(page.locator('meta[property="og:image"]')).toHaveAttribute("content", /\/assets\/feature\.png$/); await expect(page.locator('meta[property="og:image:type"]')).toHaveAttribute("content", "image/png"); + await expect(page.locator('link[rel="alternate"][hreflang="zh-CN"]')).toHaveAttribute( + "href", + "https://byoungd.github.io/up/threads/part-1/2-vocabulary/", + ); + await expect(page.locator('link[rel="alternate"][hreflang="en-US"]')).toHaveAttribute( + "href", + "https://byoungd.github.io/up/en/threads/part-1/2-vocabulary/", + ); + await expect(page.locator('link[rel="alternate"][hreflang="x-default"]')).toHaveAttribute( + "href", + "https://byoungd.github.io/up/threads/part-1/2-vocabulary/", + ); const chapterData = await structuredDataFromPage(page); expect(chapterData).toMatchObject({ "@context": "https://schema.org", @@ -207,6 +232,7 @@ test("home metadata follows the lifelong-learning positioning", async ({ page }) test("brand and social assets load at their declared dimensions", async ({ page, request }) => { await page.goto("./"); + await expect(page.locator('link[rel="icon"]')).toHaveAttribute("href", "/up/assets/logo.svg"); for (const path of ["assets/feature.png", "assets/feature-en.png"]) { const response = await request.get(path); expect(response.status()).toBe(200); @@ -234,6 +260,13 @@ test("brand and social assets load at their declared dimensions", async ({ page, }), ); expect(logo).toEqual({ width: 48, height: 48 }); + + const sitemapResponse = await request.get("sitemap.xml"); + expect(sitemapResponse.status()).toBe(200); + const sitemap = await sitemapResponse.text(); + expect(sitemap).toContain('hreflang="zh-CN" href="https://byoungd.github.io/up/threads/part-1/2-vocabulary"'); + expect(sitemap).toContain('hreflang="en-US" href="https://byoungd.github.io/up/en/threads/part-1/2-vocabulary"'); + expect(sitemap).toContain('hreflang="x-default" href="https://byoungd.github.io/up/threads/part-1/2-vocabulary"'); }); test("AI resource-layer chapter has metadata and navigation", async ({ page }) => {