Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion bin/build-pages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -832,7 +832,9 @@ ${langOptions}
<div class="recent reveal d5">
<span data-recent-slot data-recent-label="${e(data.recent.label)}" hidden></span>
<span class="recent-note">${e(data.recent.note)}</span>
<a class="recent-all" href="/history">${e(data.recent.all)}</a>
<a class="recent-all" href="${locale === DEFAULT_LOCALE ? '/history' : `/history?locale=${locale}`}"
>${e(data.recent.all)}</a
>
</div>
<div class="trust reveal d5">${trust}</div>
</div>
Expand Down
25 changes: 25 additions & 0 deletions docs/explorations/2026-08-23-seven-languages-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 拼的「继续编辑」链接同样带上(它读 `<html lang>`);
`/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;
Expand Down
4 changes: 3 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,9 @@ <h1 class="reveal d2">Open Word, Excel &amp; PowerPoint files, <span class="acce
<div class="recent reveal d5">
<span data-recent-slot data-recent-label="Continue editing" hidden></span>
<span class="recent-note">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.</span>
<a class="recent-all" href="/history">Saved documents</a>
<a class="recent-all" href="/history"
>Saved documents</a
>
</div>
<div class="trust reveal d5"><span>0 bytes uploaded</span><span>no account</span><span>works offline</span><span>.docx .xlsx .pptx .csv</span></div>
</div>
Expand Down
9 changes: 6 additions & 3 deletions lib/history-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -325,7 +327,8 @@ async function render(): Promise<void> {
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' },
),
Expand Down
19 changes: 19 additions & 0 deletions packages/shared/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<app>/main/locale/<code>.json`. The site shell has
Expand Down
6 changes: 5 additions & 1 deletion public/history-recent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
14 changes: 13 additions & 1 deletion public/lang-switch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
});
30 changes: 30 additions & 0 deletions test/e2e/language-menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions test/unit/landing-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading