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
60 changes: 60 additions & 0 deletions .github/workflows/language-selection.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: Validate first-entry language selection

on:
pull_request:
branches: [master]
push:
branches: [master]
workflow_dispatch:
inputs:
target:
description: Browser test target
type: choice
default: preview
options: [preview, live]

permissions:
contents: read

jobs:
routing-policy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: node --test scripts/test_locale.cjs

browser:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
- uses: actions/jekyll-build-pages@v1
if: inputs.target != 'live'
with:
source: ./
destination: ./_site
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Check language selection in Chromium
env:
TARGET: ${{ inputs.target }}
run: |
if [ "$TARGET" = "live" ]; then
export SITE_TEST_ORIGIN=https://fuzzy-technologies.com
else
python3 -m http.server 8765 --bind 127.0.0.1 --directory _site > /tmp/site-browser-server.log 2>&1 &
export SITE_TEST_ORIGIN=http://127.0.0.1:8765
fi
npm install --prefix "$RUNNER_TEMP/site-browser" --no-save [email protected]
"$RUNNER_TEMP/site-browser/node_modules/.bin/playwright" install --with-deps chromium
NODE_PATH="$RUNNER_TEMP/site-browser/node_modules" node scripts/test_site_browser.cjs
- uses: actions/upload-artifact@v4
if: always()
with:
name: first-entry-language-browser-results
path: _browser-test-results
retention-days: 14
1 change: 1 addition & 0 deletions _layouts/default.html
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
<link rel="icon" type="image/png" sizes="128x128" href="/static/images/favicon-128x128.png" />
<meta name="color-scheme" content="dark" />
<link rel="stylesheet" href="/static/style.css" />
<script src="/static/locale.js"></script>
<script defer src="/static/main.js"></script>

{% if page.math %}
Expand Down
57 changes: 57 additions & 0 deletions docs/LANGUAGE_SELECTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# First-entry language selection

The homepage (`/`) and article catalogue (`/articles/`) select a preferred
locale on the first external visit in a tab session. They retain separate,
stable English, Russian and Simplified Chinese URLs. This feature does not
replace or change the public translation policy.

Selection order:

1. An explicit language-switch query marker (`lang=en`, `ru`, or `zh-CN`).
2. A saved manual language choice (session storage, then persistent storage).
3. The first supported language in the browser's ordered preferences.
4. A bounded country-by-IP fallback: RU, BY, KZ, KG, UZ, TJ, TM, AM, AZ and MD
select Russian; CN selects Simplified Chinese; other/unknown countries
select English. This is a configured language-routing list, not a statement
about political membership or users' nationality.
5. English when detection is unavailable or fails.

Direct localized URLs, individual articles, product pages, internal navigation,
history back/forward traversal and recognized search crawlers retain their
requested URL. The script never constructs an unverified article translation
URL, redirects to a translation placeholder, or sends a permanent redirect.
Queries and fragments survive automatic entry-page navigation.

The existing language switcher remembers manual choices, including English.
Its links carry an explicit query marker for new tabs and blocked-storage
browsers. The marker is removed when the preference can be stored; otherwise
it remains in the URL to make reloads stable. Automatic detection is not saved
as a permanent manual preference. With JavaScript disabled, the original page
and language-switch links remain usable.

Country lookup is an optional, fail-open request to Cloudflare's HTTPS
`https://www.cloudflare.com/cdn-cgi/trace` diagnostic endpoint. It is made only
when there is no manual choice or supported browser language. It omits
credentials and the referrer, accepts only the two-letter `loc` value, aborts
after one second, and stores neither the response nor the visitor's IP address.
Failure, blocking, malformed data and service changes leave English available.
User interaction cancels a pending automatic move. No browser GPS permission,
Cloudflare account changes, API key or additional hosted website is required.

## Validation

```sh
node --test scripts/test_locale.cjs
SITE_TEST_ORIGIN=http://127.0.0.1:8765 node scripts/test_site_browser.cjs
```

CI builds both configured origins, runs the deterministic policy tests, and
checks the production-origin build in Chromium. The browser dependency is
pinned to Playwright 1.62.1. Browser tests stub country responses and failures;
they never depend on the test runner's actual geographical location.

References:

- [HTML language preferences](https://html.spec.whatwg.org/dev/system-state.html#language-preferences)
- [Google multilingual site guidance](https://developers.google.com/search/docs/specialty/international/managing-multi-regional-sites)
- [Cloudflare diagnostic endpoints](https://developers.cloudflare.com/fundamentals/reference/cdn-cgi-endpoint/)
164 changes: 164 additions & 0 deletions scripts/test_locale.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/* Routing policy and browser/storage failure regressions, without network I/O. */
const test = require('node:test');
const assert = require('node:assert/strict');
const locale = require('../static/locale.js');

function storage(initial = {}) {
const values = new Map(Object.entries(initial));
return { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, value) };
}

function browser(options = {}) {
const url = new URL(options.path || '/', 'https://example.test');
const events = new Map();
const calls = { redirects: [], fetches: [], events };
const local = storage(options.saved ? { 'ft.language.v1': options.saved } : {});
const session = storage(options.visited ? { 'ft.language.visited.v1': '1' } : {});
const win = {
location: { href: url.href, origin: url.origin, replace: target => calls.redirects.push(target) },
history: { state: null, replaceState: (_state, _title, href) => { win.location.href = href; } },
navigator: { languages: options.languages || ['en-US'], language: options.language || 'en-US', userAgent: options.userAgent || 'Browser' },
performance: { getEntriesByType: () => [{ type: options.navigation || 'navigate' }] },
localStorage: local, sessionStorage: session,
document: {
readyState: 'complete', referrer: options.referrer || '',
documentElement: { lang: options.pageLanguage || (url.pathname.startsWith('/ru/') ? 'ru' : url.pathname.startsWith('/zh-cn/') ? 'zh-CN' : 'en') },
querySelectorAll: () => options.links || [],
addEventListener: (name, fn) => events.set(name, fn),
removeEventListener: name => events.delete(name)
},
AbortController, setTimeout, clearTimeout,
fetch: async (href, config) => {
calls.fetches.push({ href, config });
return { ok: true, text: async () => 'ip=not-recorded\nloc=' + (options.country || 'US') + '\n' };
}
};
if (options.blocked) for (const name of ['localStorage', 'sessionStorage']) Object.defineProperty(win, name, { get() { throw new Error('Storage denied'); } });
return { win, calls };
}

test('supported browser languages follow preference order', () => {
assert.equal(locale.browserLanguage(['de-DE', 'ru-RU', 'en-US']), 'ru');
assert.equal(locale.browserLanguage(['en-US', 'zh-CN']), 'en');
assert.equal(locale.browserLanguage(['zh-Hant-TW', 'en']), 'zh-CN');
assert.equal(locale.browserLanguage(['fr', 'de']), null);
});

test('only neutral entry hubs can be redirected', () => {
for (const path of ['/ru/', '/zh-cn/articles/', '/articles/example/', '/FMA/', '/TKSBrokerAPI/']) assert.equal(locale.destination('https://example.test' + path, 'ru'), null);
assert.equal(locale.destination('https://example.test/articles/?q=a%20b#item', 'ru'), 'https://example.test/ru/articles/?q=a%20b#item');
assert.equal(locale.destination('https://example.test/', 'zh-CN'), 'https://example.test/zh-cn/');
assert.equal(locale.destination('https://example.test/', '//evil.test'), null);
});

for (const [language, expected] of [['ru-RU', '/ru/articles/'], ['zh-CN', '/zh-cn/articles/'], ['en-GB', null]]) {
test('first catalogue entry uses ' + language, async () => {
const { win, calls } = browser({ path: '/articles/?q=one#section', languages: [language] });
await locale.start(win);
assert.deepEqual(calls.redirects, expected ? ['https://example.test' + expected + '?q=one#section'] : []);
assert.equal(calls.fetches.length, 0);
});
}

test('saved manual English overrides Russian browser and country', async () => {
const { win, calls } = browser({ saved: 'en', languages: ['ru'], country: 'RU' });
await locale.start(win);
assert.equal(calls.redirects.length, 0);
assert.equal(calls.fetches.length, 0);
});

test('saved manual Russian overrides English browser', async () => {
const { win, calls } = browser({ saved: 'ru', languages: ['en'], visited: true });
await locale.start(win);
assert.deepEqual(calls.redirects, ['https://example.test/ru/']);
});

for (const country of ['RU', 'BY', 'KZ', 'KG', 'UZ', 'TJ', 'TM', 'AM', 'AZ', 'MD', 'CN', 'DE', 'XX']) {
test('country fallback is bounded to configured region: ' + country, async () => {
const { win, calls } = browser({ languages: ['de-DE'], country });
await locale.start(win);
const expected = country === 'CN' ? '/zh-cn/' : ['DE', 'XX'].includes(country) ? null : '/ru/';
assert.deepEqual(calls.redirects, expected ? ['https://example.test' + expected] : []);
assert.equal(calls.fetches[0].config.credentials, 'omit');
assert.equal(calls.fetches[0].config.referrerPolicy, 'no-referrer');
assert.equal(win.localStorage.getItem('ip'), null);
});
}

for (const options of [
{ path: '/ru/' }, { path: '/zh-cn/articles/' }, { path: '/articles/example/' },
{ path: '/FMA/' }, { referrer: 'https://example.test/FMA/' },
{ navigation: 'back_forward' }, { userAgent: 'Googlebot' }, { visited: true }
]) {
test('explicit navigation remains stable: ' + JSON.stringify(options), async () => {
const { win, calls } = browser({ languages: ['ru'], ...options });
await locale.start(win);
assert.equal(calls.redirects.length, 0);
assert.equal(calls.fetches.length, 0);
});
}

test('blocked storage still honors explicit English and keeps its marker', async () => {
const { win, calls } = browser({ path: '/?lang=en&x=1#intro', languages: ['ru'], blocked: true });
await locale.start(win);
assert.equal(calls.redirects.length, 0);
assert.equal(win.location.href, 'https://example.test/?lang=en&x=1#intro');
});

test('session choice wins when persistent storage becomes read-only', () => {
const { win } = browser({ saved: 'ru' });
win.localStorage.setItem = () => { throw new Error('Quota exceeded'); };
assert.equal(locale.savePreference(win, 'en'), true);
assert.equal(locale.savedPreference(win), 'en');
});

test('explicit language is saved and control parameter removed without dropping query/hash', async () => {
const { win, calls } = browser({ path: '/articles/?lang=ru&x=1#intro' });
await locale.start(win);
assert.equal(locale.savedPreference(win), 'ru');
assert.deepEqual(calls.redirects, ['https://example.test/ru/articles/?x=1#intro']);
});

test('language-switch links carry choice into new tabs even without storage', async () => {
const link = { href: '/?q=one#intro', getAttribute: () => 'en' };
const { win } = browser({ path: '/ru/', links: [link] });
await locale.start(win);
assert.equal(link.href, 'https://example.test/?q=one&lang=en#intro');
});

test('geo error fails open without moving the page', async () => {
const { win, calls } = browser({ languages: ['de'] });
win.fetch = async () => { throw new Error('Offline'); };
await locale.start(win);
assert.equal(calls.redirects.length, 0);
});

test('geo request is aborted on timeout', async () => {
const { win, calls } = browser({ languages: ['de'] });
win.setTimeout = fn => setTimeout(fn, 5);
win.fetch = (_url, options) => new Promise((_resolve, reject) => options.signal.addEventListener('abort', () => reject(new Error('Aborted'))));
await locale.start(win);
assert.equal(calls.redirects.length, 0);
});

for (const event of ['pointerdown', 'keydown', 'wheel', 'touchstart']) test('late geo cannot interrupt ' + event, async () => {
const { win, calls } = browser({ languages: ['de'] });
let resolve;
win.fetch = () => new Promise(done => { resolve = done; });
const running = locale.start(win);
calls.events.get(event)();
resolve({ ok: true, text: async () => 'loc=RU\n' });
await running;
assert.equal(calls.redirects.length, 0);
});

test('late geo cannot override a new manual choice', async () => {
const { win, calls } = browser({ languages: ['de'] });
let resolve;
win.fetch = () => new Promise(done => { resolve = done; });
const running = locale.start(win);
locale.savePreference(win, 'en');
resolve({ ok: true, text: async () => 'loc=RU\n' });
await running;
assert.equal(calls.redirects.length, 0);
});
Loading
Loading