From 31f31aee16aed143385831e53209de476aab65fe Mon Sep 17 00:00:00 2001 From: chaxus Date: Sun, 23 Aug 2026 13:10:59 +0800 Subject: [PATCH] fix(i18n): make a chosen language follow the reader off the static pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: pick a language on the homepage, go anywhere else, and it is English again. Reproduced exactly -- /ja/ is Japanese, and the moment you click "保存したドキュメント" you are on /history in English. The site is seven directories of static pages plus one app (/editor and /history) that lives under none of them. So there were two separate failures, and both had to go: 1. Links into the app did not carry the language. The generated homepage linked to a bare /history; history-recent.js built bare /editor?saved=... links; /history itself sent people back to "/" and opened documents without a locale. All of them now carry it, through two small helpers exported from the shell's i18n (localeHomePath, withLocale) so the app and the generator agree on where a language's homepage is. 2. The choice was never remembered. lang-switch.js only navigated: the language existed as the path the reader happened to be standing on and nowhere else. It now writes a `locale` cookie before navigating, which is the second entry in the app's resolution chain (?locale=, cookie, localStorage, browser). Both are needed. The parameter makes the first click right -- a fresh browser, a shared link, someone who never touched the switch. The cookie makes everything after it right, including typing /editor directly. Walked the whole path in a browser reporting en-US: English homepage -> pick 日本語 -> saved documents -> bare /editor -> bare /history -> a satellite page. Six steps, all lang="ja". Reverse-checked: remove the cookie and steps four and five fall back to English. Co-Authored-By: Claude Opus 5 (1M context) --- bin/build-pages.mjs | 4 ++- .../2026-08-23-seven-languages-audit.md | 25 ++++++++++++++++ index.html | 4 ++- lib/history-page.ts | 9 ++++-- packages/shared/src/i18n.ts | 19 ++++++++++++ public/history-recent.js | 6 +++- public/lang-switch.js | 14 ++++++++- test/e2e/language-menu.spec.ts | 30 +++++++++++++++++++ test/unit/landing-pages.test.ts | 15 ++++++++++ 9 files changed, 119 insertions(+), 7 deletions(-) diff --git a/bin/build-pages.mjs b/bin/build-pages.mjs index fe1d29810..d607b9024 100644 --- a/bin/build-pages.mjs +++ b/bin/build-pages.mjs @@ -832,7 +832,9 @@ ${langOptions}
${e(data.recent.note)} - ${e(data.recent.all)} + ${e(data.recent.all)}
${trust}
diff --git a/docs/explorations/2026-08-23-seven-languages-audit.md b/docs/explorations/2026-08-23-seven-languages-audit.md index 0e78cbbef..d3ac54427 100644 --- a/docs/explorations/2026-08-23-seven-languages-audit.md +++ b/docs/explorations/2026-08-23-seven-languages-audit.md @@ -63,6 +63,31 @@ landing.css。白色主题下看不出来,暗色下就是页面四周一圈底 顺带补上落地页 `WebApplication` 节点缺的 `inLanguage`(首页和文档页早就有)。 +## 9. 选了语言,走到 app 页面就丢(已修) + +用户报的:首页切到日语,跳去其它页面还是英文。复现路径正是这样: + + /ja/ 是日语 → 点「保存したドキュメント」→ /history → 英文 + +两个独立的原因,各修各的: + +1. **首页指向 app 的链接没带语言**。站点是七个静态目录 **加上一个 app**(`/editor` + 与 `/history`),app 不在任何语言目录下面。`/ja/` 上的 `/history` 链接因此是裸的, + app 只能按浏览器语言猜。生成器现在输出 `/history?locale=ja`; + `history-recent.js` 拼的「继续编辑」链接同样带上(它读 ``); + `/history` 自己的行链接与「返回首页」也带(`withLocale` / `localeHomePath`, + 新导出在 `packages/shared/src/i18n.ts`)。 +2. **选择从来没被记住**。`lang-switch.js` 过去只是导航——语言只存在于用户当前站着的 + 那条路径里。现在它在跳转前写 `locale` cookie(一年,`samesite=lax`)。app 的语言解析链 + 第二位就是 cookie,所以之后直接打开 `/editor` 或 `/history` 也跟随。 + +两条缺一不可:链接带参数让**第一次点击**就对(新浏览器、别人分享的链接都算), +cookie 让**之后的直接访问**也对。 + +实测整条路径(浏览器语言 en-US):落地英文首页 → 菜单选日语 → 保存的文档 → 裸 +`/editor` → 裸 `/history` → 落地页,六步全是 `lang=ja`。反向验证:去掉 cookie 那段, +E2E 的第 4/5 步立刻回落英文。 + ## 复查过但没有问题的 - **SEO 覆盖**:7 种语言 × 21 页齐全;hreflang 是完整的七语言互指 + x-default; diff --git a/index.html b/index.html index 5df1fa9b5..1b74f37ff 100644 --- a/index.html +++ b/index.html @@ -244,7 +244,9 @@

Open Word, Excel & PowerPoint files, Close the tab by accident and nothing is lost: your edits are saved into this browser as you work. Saved copies stay for 7 days and are then deleted automatically — or delete them yourself at any time. - Saved documents + Saved documents
0 bytes uploadedno accountworks offline.docx .xlsx .pptx .csv
diff --git a/lib/history-page.ts b/lib/history-page.ts index 1d4d21843..57341b94f 100644 --- a/lib/history-page.ts +++ b/lib/history-page.ts @@ -18,7 +18,7 @@ import 'ranui/message'; import { Div, View } from 'ranui/builder'; import '../styles/history.css'; import { saveFileToDisk } from 'ranuts/utils'; -import { applyDocumentLanguage, t } from '@ranuts/shared/i18n'; +import { applyDocumentLanguage, getLanguage, localeHomePath, t, withLocale } from '@ranuts/shared/i18n'; import { getDocumentMimeType } from '@ranuts/shared/document-utils'; import { formatRelativeTime } from './history/recovery'; import { clearAllHistory, deleteDoc, getLatestSnapshot, historyUsage, listDocs, pruneExpired } from './history/store'; @@ -138,7 +138,9 @@ function buildRow(doc: HistoryDoc, refresh: () => void): HTMLElement { .children( View('a') .class('history-row-title history-open') - .attr('href', `/editor?saved=${encodeURIComponent(doc.id)}`) + // Carry the language: this page knows which one it is in, and the + // editor should not have to guess it again from the browser. + .attr('href', withLocale(`/editor?saved=${encodeURIComponent(doc.id)}`, getLanguage())) .text(doc.title) .build(), ...(hasUnsavedWork(doc) @@ -325,7 +327,8 @@ async function render(): Promise { button( t('historyBack'), () => { - window.location.href = '/'; + // Back to the homepage the reader came from, not to English. + window.location.href = localeHomePath(getLanguage()); }, { type: 'text', class: 'history-back' }, ), diff --git a/packages/shared/src/i18n.ts b/packages/shared/src/i18n.ts index 46d8c7702..71dc7c385 100644 --- a/packages/shared/src/i18n.ts +++ b/packages/shared/src/i18n.ts @@ -71,6 +71,25 @@ export const RTL_LANGUAGES: readonly Language[] = []; export const isRtlLanguage = (lang: Language): boolean => RTL_LANGUAGES.includes(lang); +/** + * Where a language's homepage lives. English is the site root; every other + * language is a directory under it. Mirrors LOCALES in bin/build-pages.mjs, + * which generates those pages -- the app needs it to send a reader back to the + * homepage they came from rather than to the English one. + */ +export const localeHomePath = (lang: Language): string => (lang === LanguageCode.EN ? '/' : `/${lang}/`); + +/** + * Add `?locale=` to an app URL when the language is not the default. The app + * resolves its language from the URL first, so a link that carries it works + * even for a reader who has never chosen one explicitly (a shared link, a new + * browser) -- the cookie only covers people who used the switch. + */ +export const withLocale = (url: string, lang: Language): string => { + if (lang === LanguageCode.EN) return url; + return `${url}${url.includes('?') ? '&' : '?'}locale=${lang}`; +}; + /** * Editor (OnlyOffice) UI locales shipped by the vendored web-apps build -- * `public/web-apps/apps//main/locale/.json`. The site shell has diff --git a/public/history-recent.js b/public/history-recent.js index 6865ce978..254441f50 100644 --- a/public/history-recent.js +++ b/public/history-recent.js @@ -78,7 +78,11 @@ // not depend on a script having run. var resume = document.createElement('a'); resume.className = 'recent-resume'; - resume.href = '/editor?saved=' + encodeURIComponent(doc.id) + suffix; + // Keep the page's language on the way into the editor: this script runs + // on /ja/, /pt/ and the rest, and the editor resolves ?locale= first. + var pageLang = document.documentElement.lang || 'en'; + var localeParam = pageLang && pageLang !== 'en' ? '&locale=' + encodeURIComponent(pageLang) : ''; + resume.href = '/editor?saved=' + encodeURIComponent(doc.id) + localeParam + suffix; resume.textContent = (slot.getAttribute('data-recent-label') || 'Continue') + ' ' + doc.title; slot.appendChild(resume); diff --git a/public/lang-switch.js b/public/lang-switch.js index 2aa8df336..05d4843a2 100644 --- a/public/lang-switch.js +++ b/public/lang-switch.js @@ -16,6 +16,18 @@ document.addEventListener('DOMContentLoaded', function () { if (!value) return; var option = select.querySelector('r-option[value="' + value + '"]'); var href = option && option.getAttribute('data-href'); - if (href && href !== location.pathname) location.href = href; + if (!href || href === location.pathname) return; + // Remember the choice before navigating. The static pages carry their + // language in the URL, but /editor and /history are one app that reads + // its language from (in order) ?locale=, this cookie, localStorage and + // the browser. Without the cookie, picking 日本語 on the homepage and + // then opening the saved-documents page landed the reader back in + // English -- the choice existed only as the path they were standing on. + try { + document.cookie = 'locale=' + encodeURIComponent(value) + ';path=/;max-age=31536000;samesite=lax'; + } catch (e) { + /* cookies disabled: the URL still carries the language */ + } + location.href = href; }); }); diff --git a/test/e2e/language-menu.spec.ts b/test/e2e/language-menu.spec.ts index 7d10c42e4..312cfff60 100644 --- a/test/e2e/language-menu.spec.ts +++ b/test/e2e/language-menu.spec.ts @@ -92,6 +92,36 @@ test.describe('language menu', () => { }); }); +test.describe('a chosen language follows the reader', () => { + /** + * The site is seven directories of static pages plus one app (/editor and + * /history) that resolves its language from ?locale=, then a cookie, then + * localStorage, then the browser. Picking a language on a static page used + * to be nothing but a navigation: the choice lived in the path, and the + * moment a reader stepped off it -- "saved documents" from the Japanese + * homepage -- they were back in English. + */ + test('picking a language on a static page reaches the app pages too', async ({ page }) => { + await page.goto('/'); + // Choose 日本語 through the menu, the way a reader does. + await page.locator('r-select.lang-select').first().click(); + await page.locator('r-dropdown-item[value="ja"]').first().click(); + await page.waitForURL('**/ja/'); + expect(await page.evaluate(() => document.documentElement.lang)).toBe('ja'); + + // The link into the app carries it... + const historyHref = await page.locator('a.recent-all').getAttribute('href'); + expect(historyHref).toContain('locale=ja'); + + // ...and so does the app itself, even at a bare URL, because the choice + // was remembered rather than only navigated to. + await page.goto('/history'); + await expect.poll(() => page.evaluate(() => document.documentElement.lang)).toBe('ja'); + await page.goto('/editor?new=docx'); + await expect.poll(() => page.evaluate(() => document.documentElement.lang)).toBe('ja'); + }); +}); + test.describe('page chrome', () => { /** * The browser's default 8px body margin. landing.css always cleared it; diff --git a/test/unit/landing-pages.test.ts b/test/unit/landing-pages.test.ts index 98dec821b..0c5143023 100644 --- a/test/unit/landing-pages.test.ts +++ b/test/unit/landing-pages.test.ts @@ -166,6 +166,21 @@ describe('landing pages', () => { expect(alternates.sort(), `${route} og:locale:alternate`).toEqual(expected.sort()); }); + /** + * /editor and /history are one app serving every language, so a link from + * a translated page into them has to say which one. Picking 日本語 on the + * homepage and then opening the saved-documents page used to land the + * reader back in English: the choice existed only as the path they were + * standing on, and /history is not under it. + */ + it('carries the language on every link into the app', () => { + const appLinks = [...html.matchAll(/(?:href|data-open-local)="(\/(?:editor|history)[^"]*)"/g)].map((m) => m[1]); + for (const link of appLinks) { + const carries = link.includes(`locale=${locale}`); + expect(locale === 'en' ? !link.includes('locale=') : carries, `${route}: ${link}`).toBe(true); + } + }); + it('offers every translation it has in the language switch', () => { for (const other of Object.keys(LOCALES)) { const target = routeIn(other, enRoute);