diff --git a/bin/build-pages.d.mts b/bin/build-pages.d.mts index afaec7b4..f03ee374 100644 --- a/bin/build-pages.d.mts +++ b/bin/build-pages.d.mts @@ -10,7 +10,10 @@ export interface GeneratedPage { */ kind: 'home' | 'landing' | 'doc'; } -export const LOCALES: Record; +export const LOCALES: Record< + string, + { prefix: string; lang: string; label: string; home: string; dir: string; og: string } +>; export const LANDING_SLUGS: string[]; export const PAGES: Array<{ slug: string; diff --git a/bin/build-pages.mjs b/bin/build-pages.mjs index 5b29acca..fe1d2981 100644 --- a/bin/build-pages.mjs +++ b/bin/build-pages.mjs @@ -34,13 +34,15 @@ const REPO = 'https://github.com/ranuts/document'; /** Locales the shell knows about. `prefix` is the URL directory; '' = root. */ export const LOCALES = { - en: { prefix: '', lang: 'en', label: 'EN', home: '/', dir: 'ltr' }, - 'zh-CN': { prefix: '/zh-CN', lang: 'zh-CN', label: '中文', home: '/zh-CN/', dir: 'ltr' }, - ja: { prefix: '/ja', lang: 'ja', label: '日本語', home: '/ja/', dir: 'ltr' }, - de: { prefix: '/de', lang: 'de', label: 'Deutsch', home: '/de/', dir: 'ltr' }, - es: { prefix: '/es', lang: 'es', label: 'Español', home: '/es/', dir: 'ltr' }, - ko: { prefix: '/ko', lang: 'ko', label: '한국어', home: '/ko/', dir: 'ltr' }, - pt: { prefix: '/pt', lang: 'pt', label: 'Português', home: '/pt/', dir: 'ltr' }, + en: { prefix: '', lang: 'en', label: 'EN', home: '/', dir: 'ltr', og: 'en_US' }, + 'zh-CN': { prefix: '/zh-CN', lang: 'zh-CN', label: '中文', home: '/zh-CN/', dir: 'ltr', og: 'zh_CN' }, + ja: { prefix: '/ja', lang: 'ja', label: '日本語', home: '/ja/', dir: 'ltr', og: 'ja_JP' }, + de: { prefix: '/de', lang: 'de', label: 'Deutsch', home: '/de/', dir: 'ltr', og: 'de_DE' }, + es: { prefix: '/es', lang: 'es', label: 'Español', home: '/es/', dir: 'ltr', og: 'es_ES' }, + ko: { prefix: '/ko', lang: 'ko', label: '한국어', home: '/ko/', dir: 'ltr', og: 'ko_KR' }, + // pt_BR, not pt_PT: the pages, the shell strings and the vendor locale the + // editor loads (pt.json) are all Brazilian Portuguese. + pt: { prefix: '/pt', lang: 'pt', label: 'Português', home: '/pt/', dir: 'ltr', og: 'pt_BR' }, }; const DEFAULT_LOCALE = 'en'; @@ -614,6 +616,10 @@ function renderHome({ locale, data, locales }) { const alternates = locales .map((l) => ` `) .join('\n'); + const ogAlternates = locales + .filter((l) => l !== locale) + .map((l) => ` `) + .join('\n'); const langOptions = locales .map((l) => ` ${LOCALES[l].label}`) .join('\n'); @@ -751,6 +757,8 @@ ${alternates} + +${ogAlternates} @@ -959,6 +967,7 @@ function renderPage({ page, locale, meta, body, headings, faq, steps, source }) applicationCategory: 'BusinessApplication', operatingSystem: 'Any (web browser)', isAccessibleForFree: true, + inLanguage: L.lang, description: meta.appDescription || description, offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }, } @@ -1006,10 +1015,17 @@ function renderPage({ page, locale, meta, body, headings, faq, steps, source }) crumbs.push({ '@type': 'ListItem', position: crumbs.length + 1, name: meta.breadcrumb || title, item: url }); graph.push({ '@type': 'BreadcrumbList', itemListElement: crumbs }); - const alternates = Object.keys(LOCALES) - .filter((l) => page.sources[l]) + const translations = Object.keys(LOCALES).filter((l) => page.sources[l]); + const alternates = translations .map((l) => ` `) .join('\n'); + // Open Graph carries the same set as hreflang, in its own spelling: the + // language of this page, then the others it exists in. Without it a share of + // /ja/ or /pt/ is treated as English by every consumer that reads OG. + const ogAlternates = translations + .filter((l) => l !== locale) + .map((l) => ` `) + .join('\n'); const langOptions = Object.keys(LOCALES) .filter((l) => page.sources[l]) .map( @@ -1095,6 +1111,8 @@ ${alternates} + +${ogAlternates} diff --git a/docs/explorations/2026-08-23-seven-languages-audit.md b/docs/explorations/2026-08-23-seven-languages-audit.md index 73c20b77..0e78cbbe 100644 --- a/docs/explorations/2026-08-23-seven-languages-audit.md +++ b/docs/explorations/2026-08-23-seven-languages-audit.md @@ -50,6 +50,19 @@ landing.css。白色主题下看不出来,暗色下就是页面四周一圈底 它遍历 `public/` 下所有 HTML,七种语言进来之后从 97 KB 涨到 680 KB——同样的事实说七遍。 对读它的模型只有害处。改成只收英文页面,开头说明镜像在哪;`/llms.txt` 仍然逐语言列出。 +## 8. Open Graph 不知道站点有七种语言(已修) + +`hreflang` 是完整的七语言互指,但 Open Graph 那边**一个字都没说**:既没有 `og:locale` +也没有 `og:locale:alternate`。后果是任何读 OG 的消费者(Facebook、LinkedIn、Slack、 +各类抓取器)把 `/ja/` 和 `/pt/` 的分享都当成英文页。补上之后每页声明自己的 +`og:locale`,并列出其余六种的 `og:locale:alternate`。 + +映射写在 `LOCALES` 表里,因为 OG 要的是 `language_TERRITORY` 而不是 BCP47: +`en_US` / `zh_CN` / `ja_JP` / `de_DE` / `es_ES` / `ko_KR` / **`pt_BR`**。最后一个再次 +确认了第 6 条的取舍——页面、shell 词条、vendor 包(`pt.json`)、OG locale 四处都是巴西葡语。 + +顺带补上落地页 `WebApplication` 节点缺的 `inLanguage`(首页和文档页早就有)。 + ## 复查过但没有问题的 - **SEO 覆盖**:7 种语言 × 21 页齐全;hreflang 是完整的七语言互指 + x-default; @@ -60,6 +73,16 @@ landing.css。白色主题下看不出来,暗色下就是页面四周一圈底 - **德语长词**:`Installieren und im Flugzeug nutzen`(35 字符)在 pillars 列里正常换行, 没有溢出。 - **`/embed-demo`**:仍然是英文,这是 CLAUDE.md 的规矩(共用页面用英文),不是遗漏。 +- **站内死链**:158 个页面、5043 条内链、165 个不同目标,逐个请求,零 4xx。 +- **基础可达性**:五类页面上没有缺 alt 的图、没有无名链接/按钮、标题层级不跳级、 + 每页恰好一个 h1、`` 都在。格式索引的行、右栏链接、语言选择器都能 Tab 到。 +- **首屏性能**:落地页 10 个请求 / 392 KB,FCP 44 ms。首页看起来 9 MB 是 + `landing-prefetch.js` 在预热三个编辑器引擎(既有行为,有 E2E 钉着),发生在 + load 之后;线上走 br 压缩(`sdk-all.js` 13.7 MB → 3.0 MB)。 +- **缓存头**:`sdkjs/` 与 `web-apps/` 故意不上 `immutable`(里面有我们的补丁和 + iframe HTML),4 小时 + `must-revalidate`。这次补齐的 locale JSON 也在这棵树里, + 但 SW 的 runtime cache 按 `VENDOR_VERSION` 命名,vendor 一变就是新 cache, + 拿不到旧副本。 ## 还没做的 diff --git a/index.html b/index.html index 11327aaf..5df1fa9b 100644 --- a/index.html +++ b/index.html @@ -57,6 +57,13 @@ + + + + + + + diff --git a/test/unit/landing-pages.test.ts b/test/unit/landing-pages.test.ts index e687f89e..98dec821 100644 --- a/test/unit/landing-pages.test.ts +++ b/test/unit/landing-pages.test.ts @@ -150,6 +150,22 @@ describe('landing pages', () => { } }); + /** + * Open Graph has its own spelling of the same set hreflang carries. A + * share of /ja/ or /pt/ without og:locale is read as English by every + * consumer that looks at OG -- which is most of them. + */ + it('declares its Open Graph locale, and the ones it is also published in', () => { + const og = (l: string) => LOCALES[l].og; + expect(attr(html, /property="og:locale" content="([^"]+)"/), `${route} og:locale`).toBe(og(locale)); + + const alternates = [...html.matchAll(/property="og:locale:alternate" content="([^"]+)"/g)].map((m) => m[1]); + const expected = Object.keys(LOCALES) + .filter((l) => l !== locale && routes.has(routeIn(l, enRoute))) + .map(og); + expect(alternates.sort(), `${route} og:locale:alternate`).toEqual(expected.sort()); + }); + it('offers every translation it has in the language switch', () => { for (const other of Object.keys(LOCALES)) { const target = routeIn(other, enRoute);