From ac7708d09987300f23a4a55ceb3d62149bf65a64 Mon Sep 17 00:00:00 2001 From: Timur Gilmullin Date: Wed, 28 Oct 2026 23:12:42 +0300 Subject: [PATCH 1/2] Select the preferred language on first-entry pages --- .github/workflows/language-selection.yml | 60 ++++++++ _layouts/default.html | 1 + docs/LANGUAGE_SELECTION.md | 57 ++++++++ scripts/test_locale.cjs | 154 ++++++++++++++++++++ scripts/test_site_browser.cjs | 117 ++++++++++++++++ static/locale.js | 170 +++++++++++++++++++++++ 6 files changed, 559 insertions(+) create mode 100644 .github/workflows/language-selection.yml create mode 100644 docs/LANGUAGE_SELECTION.md create mode 100644 scripts/test_locale.cjs create mode 100644 scripts/test_site_browser.cjs create mode 100644 static/locale.js diff --git a/.github/workflows/language-selection.yml b/.github/workflows/language-selection.yml new file mode 100644 index 0000000..4d82fc9 --- /dev/null +++ b/.github/workflows/language-selection.yml @@ -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 playwright@1.62.1 + "$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 diff --git a/_layouts/default.html b/_layouts/default.html index 1a258fb..e10aa3d 100644 --- a/_layouts/default.html +++ b/_layouts/default.html @@ -53,6 +53,7 @@ + {% if page.math %} diff --git a/docs/LANGUAGE_SELECTION.md b/docs/LANGUAGE_SELECTION.md new file mode 100644 index 0000000..6cd0a77 --- /dev/null +++ b/docs/LANGUAGE_SELECTION.md @@ -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/) diff --git a/scripts/test_locale.cjs b/scripts/test_locale.cjs new file mode 100644 index 0000000..4c23a51 --- /dev/null +++ b/scripts/test_locale.cjs @@ -0,0 +1,154 @@ +/* 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); +}); + +test('late geo result cannot override user input or 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); + calls.events.get('pointerdown')(); + locale.savePreference(win, 'en'); + resolve({ ok: true, text: async () => 'loc=RU\n' }); + await running; + assert.equal(calls.redirects.length, 0); +}); diff --git a/scripts/test_site_browser.cjs b/scripts/test_site_browser.cjs new file mode 100644 index 0000000..16bdc12 --- /dev/null +++ b/scripts/test_site_browser.cjs @@ -0,0 +1,117 @@ +/* Exercise published HTML and real browser navigation, using deterministic geo responses. */ +const { chromium } = require('playwright'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const origin = new URL(process.env.SITE_TEST_ORIGIN || 'http://127.0.0.1:8765').origin; +const output = process.env.SITE_BROWSER_OUTPUT || '_browser-test-results'; + +(async function () { + const browser = await chromium.launch({ headless: true }); + const results = []; + fs.mkdirSync(output, { recursive: true }); + + async function setup(options = {}) { + const context = await browser.newContext({ locale: options.locale || 'en-US', viewport: { width: options.width || 390, height: 844 } }); + const page = await context.newPage(); + const errors = []; + const geoRequests = []; + page.on('pageerror', error => errors.push(error.message)); + await context.route(/https:\/\/(?:mc\.yandex\.ru|fonts\.googleapis\.com|cdn\.jsdelivr\.net)\//, route => route.abort()); + await context.route('https://www.cloudflare.com/cdn-cgi/trace', async route => { + geoRequests.push(route.request().url()); + if (options.geo === 'error') return route.abort(); + if (options.geo === 'timeout') return; // The page's AbortController must end this request. + return route.fulfill({ status: 200, contentType: 'text/plain', headers: { 'access-control-allow-origin': '*' }, body: options.geo === 'malformed' ? 'missing country' : 'loc=' + (options.country || 'US') + '\n' }); + }); + if (options.blocked) await context.addInitScript(() => { + for (const key of ['localStorage', 'sessionStorage']) Object.defineProperty(window, key, { get() { throw new DOMException('Storage denied', 'SecurityError'); } }); + }); + if (options.saved) await context.addInitScript(value => localStorage.setItem('ft.language.v1', value), options.saved); + return { context, page, errors, geoRequests }; + } + + async function check(name, options, route, expected) { + const session = await setup(options); + try { + await session.page.goto(origin + route, { waitUntil: 'domcontentloaded' }); + await session.page.waitForURL(url => url.pathname === expected, { timeout: 10000 }); + // Allow the bounded fallback to finish before asserting an English result. + if (options.geo || (options.locale || '').startsWith('de')) await session.page.waitForTimeout(1200); + assert.equal(new URL(session.page.url()).pathname, expected, name); + assert.deepEqual(session.errors, [], name + ': unexpected browser errors'); + if (options.noGeo) assert.equal(session.geoRequests.length, 0, name + ': geo must not override browser/manual language'); + if (options.query) { + assert.equal(new URL(session.page.url()).search, '?q=a%20b'); + assert.equal(new URL(session.page.url()).hash, '#anchor'); + } + const canonical = await session.page.locator('link[rel="canonical"]').getAttribute('href'); + assert.ok(canonical.endsWith(expected), name + ': canonical must match rendered locale'); + if (options.screenshot) { + await session.page.locator('.site-header').screenshot({ path: path.join(output, name + '.png') }); + } + results.push({ name, path: expected, status: 'passed' }); + } finally { await session.context.close(); } + } + + for (const width of [1440, 390]) { + for (const [language, prefix] of [['ru-RU', '/ru'], ['zh-CN', '/zh-cn'], ['en-US', '']]) { + await check('home-' + language + '-' + width, { locale: language, width, noGeo: true, screenshot: true }, '/', prefix + '/'); + await check('catalogue-' + language + '-' + width, { locale: language, width, noGeo: true, query: true }, '/articles/?q=a%20b#anchor', prefix + '/articles/'); + } + } + for (const [country, prefix] of [['RU','/ru'], ['KZ','/ru'], ['CN','/zh-cn'], ['DE','']]) { + await check('geo-' + country, { locale: 'de-DE', country }, '/articles/', prefix + '/articles/'); + } + for (const geo of ['error', 'timeout', 'malformed']) await check('geo-' + geo, { locale: 'de-DE', geo }, '/', '/'); + await check('saved-English', { locale: 'ru-RU', saved: 'en', country: 'RU', noGeo: true }, '/', '/'); + await check('saved-Russian', { locale: 'en-US', saved: 'ru', noGeo: true }, '/articles/', '/ru/articles/'); + await check('explicit-Russian-URL', { locale: 'zh-CN', noGeo: true }, '/ru/', '/ru/'); + await check('explicit-Chinese-URL', { locale: 'ru-RU', noGeo: true }, '/zh-cn/articles/', '/zh-cn/articles/'); + await check('deep-English-article', { locale: 'ru-RU', noGeo: true }, '/articles/2025-04-10-fuzzy-scales/', '/articles/2025-04-10-fuzzy-scales/'); + await check('deep-product', { locale: 'ru-RU', noGeo: true }, '/FMA/', '/FMA/'); + + for (const blocked of [false, true]) { + const session = await setup({ locale: 'ru-RU', blocked }); + try { + await session.page.goto(origin + '/', { waitUntil: 'domcontentloaded' }); + await session.page.waitForURL(url => url.pathname === '/ru/'); + const english = session.page.locator('.language-switch a[lang="en"]'); + await english.waitFor(); + const href = await english.getAttribute('href'); + assert.equal(new URL(href, origin).searchParams.get('lang'), 'en'); + await english.click(); + await session.page.waitForURL(url => url.pathname === '/'); + await session.page.reload({ waitUntil: 'domcontentloaded' }); + assert.equal(new URL(session.page.url()).pathname, '/'); + const tab = await session.context.newPage(); + await tab.goto(href, { waitUntil: 'domcontentloaded' }); + assert.equal(new URL(tab.url()).pathname, '/'); + assert.deepEqual(session.errors, []); + results.push({ name: 'manual-English-reload-new-tab-' + blocked, status: 'passed' }); + } finally { await session.context.close(); } + } + + const session = await setup({ locale: 'ru-RU' }); + try { + await session.page.goto(origin + '/FMA/', { waitUntil: 'domcontentloaded' }); + await session.page.locator('.hero-actions a[href="/articles/"]').click(); + await session.page.waitForURL(url => url.pathname === '/articles/'); + assert.equal(new URL(session.page.url()).pathname, '/articles/'); + assert.equal(session.geoRequests.length, 0); + results.push({ name: 'internal-English-navigation', status: 'passed' }); + } finally { await session.context.close(); } + + const noScript = await browser.newContext({ javaScriptEnabled: false }); + const page = await noScript.newPage(); + await page.goto(origin + '/articles/', { waitUntil: 'domcontentloaded' }); + assert.equal(await page.locator('html').getAttribute('lang'), 'en'); + assert.ok((await page.locator('.language-switch a[lang="ru"]').getAttribute('href')).includes('/ru/articles/')); + await noScript.close(); + results.push({ name: 'no-JavaScript-navigation', status: 'passed' }); + + fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify({ origin, browser: browser.version(), tests: results }, null, 2)); + console.log('PASS: ' + results.length + ' real-browser locale scenarios at ' + origin); + await browser.close(); +})().catch(error => { console.error(error); process.exit(1); }); diff --git a/static/locale.js b/static/locale.js new file mode 100644 index 0000000..2a4ed44 --- /dev/null +++ b/static/locale.js @@ -0,0 +1,170 @@ +/* First-entry language selection; explicit content URLs remain stable. */ +(function (root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + else api.start(root); +})(typeof window === 'object' ? window : null, function () { + 'use strict'; + + const preferenceKey = 'ft.language.v1'; + const visitKey = 'ft.language.visited.v1'; + const russianCountries = new Set(['RU', 'BY', 'KZ', 'KG', 'UZ', 'TJ', 'TM', 'AM', 'AZ', 'MD']); + + function normalizeLanguage(value) { + if (typeof value !== 'string') return null; + const language = value.toLowerCase().split(/[-_]/)[0]; + return ['en', 'ru', 'zh'].includes(language) ? (language === 'zh' ? 'zh-CN' : language) : null; + } + + function browserLanguage(languages) { + for (const language of languages || []) { + const supported = normalizeLanguage(language); + if (supported) return supported; + } + return null; + } + + function countryLanguage(country) { + return russianCountries.has(country) ? 'ru' : country === 'CN' ? 'zh-CN' : 'en'; + } + + function entryPath(pathname) { + if (['/', '/index.html'].includes(pathname)) return '/'; + if (['/articles', '/articles/', '/articles/index.html'].includes(pathname)) return '/articles/'; + return null; + } + + function destination(href, language) { + const url = new URL(href); + const entry = entryPath(url.pathname); + if (!entry || language === 'en') return null; + const prefix = language === 'ru' ? '/ru' : language === 'zh-CN' ? '/zh-cn' : null; + if (!prefix) return null; + url.pathname = prefix + entry; + return url.href; + } + + function read(win, storageName, key) { + try { return win[storageName].getItem(key); } catch (_) { return null; } + } + + function write(win, storageName, key, value) { + try { + win[storageName].setItem(key, value); + return win[storageName].getItem(key) === value; + } catch (_) { return false; } + } + + function savePreference(win, language) { + const session = write(win, 'sessionStorage', preferenceKey, language); + const persistent = write(win, 'localStorage', preferenceKey, language); + return persistent || session; + } + + function savedPreference(win) { + for (const storage of ['sessionStorage', 'localStorage']) { + const value = read(win, storage, preferenceKey); + if (['en', 'ru', 'zh-CN'].includes(value)) return value; + } + return null; + } + + function bindSwitcher(win) { + win.document.querySelectorAll('.language-switch a[lang]').forEach(function (link) { + const language = normalizeLanguage(link.getAttribute('lang')); + const url = new URL(link.href, win.location.href); + if (!language || url.origin !== win.location.origin) return; + // A query marker also carries an explicit English choice into new tabs + // and survives browsers that reject both local and session storage. + url.searchParams.set('lang', language); + link.href = url.href; + }); + win.document.addEventListener('click', function (event) { + const link = event.target.closest && event.target.closest('.language-switch a[lang]'); + if (!link || new URL(link.href, win.location.href).origin !== win.location.origin) return; + const language = normalizeLanguage(link.getAttribute('lang')); + if (language) savePreference(win, language); + }); + } + + async function lookupCountry(win) { + if (!win.fetch || !win.AbortController) return null; + const controller = new win.AbortController(); + const timer = win.setTimeout(function () { controller.abort(); }, 1000); + try { + const response = await win.fetch('https://www.cloudflare.com/cdn-cgi/trace', { + credentials: 'omit', referrerPolicy: 'no-referrer', cache: 'no-store', + mode: 'cors', signal: controller.signal + }); + if (!response.ok) return null; + const match = (await response.text()).match(/^loc=([A-Z]{2})\s*$/m); + return match ? match[1] : null; + } catch (_) { return null; } + finally { win.clearTimeout(timer); } + } + + async function start(win) { + if (!win || !win.document) return; + if (win.document.readyState === 'loading') { + win.document.addEventListener('DOMContentLoaded', function () { bindSwitcher(win); }, { once: true }); + } else bindSwitcher(win); + + const url = new URL(win.location.href); + const entry = entryPath(url.pathname); + const explicit = normalizeLanguage(url.searchParams.get('lang')); + if (explicit) { + const visible = normalizeLanguage(win.document.documentElement.lang); + const chosen = entry ? explicit : visible || explicit; + const persisted = savePreference(win, chosen); + write(win, 'sessionStorage', visitKey, '1'); + if (persisted) { + url.searchParams.delete('lang'); + try { win.history.replaceState(win.history.state, '', url.href); } catch (_) { /* Keep the explicit marker if history is restricted. */ } + } + const target = destination(url.href, chosen); + if (target) win.location.replace(target); + return; + } + + const visited = read(win, 'sessionStorage', visitKey) === '1'; + write(win, 'sessionStorage', visitKey, '1'); + if (!entry) return; + if (/\b(?:Googlebot|bingbot|Baiduspider|YandexBot|DuckDuckBot|Slurp)\b/i.test(win.navigator.userAgent || '')) return; + const navigation = win.performance && win.performance.getEntriesByType('navigation')[0]; + if (navigation && navigation.type === 'back_forward') return; + try { + if (win.document.referrer && new URL(win.document.referrer).origin === url.origin) return; + } catch (_) { /* An unreadable referrer does not establish an internal visit. */ } + + const saved = savedPreference(win); + if (saved) { + const target = destination(url.href, saved); + if (target) win.location.replace(target); + return; + } + if (visited) return; + const preferred = browserLanguage(win.navigator.languages && win.navigator.languages.length ? + win.navigator.languages : [win.navigator.language]); + if (preferred) { + const target = destination(url.href, preferred); + if (target) win.location.replace(target); + return; + } + + // Geo is only a bounded fallback. Never move the page after user input + // or navigation while the optional network request is in flight. + let interacted = false; + const cancel = function () { interacted = true; }; + win.document.addEventListener('pointerdown', cancel, { once: true, capture: true }); + win.document.addEventListener('keydown', cancel, { once: true, capture: true }); + const country = await lookupCountry(win); + win.document.removeEventListener('pointerdown', cancel, true); + win.document.removeEventListener('keydown', cancel, true); + if (interacted || win.location.href !== url.href || savedPreference(win)) return; + const target = destination(url.href, countryLanguage(country)); + if (target) win.location.replace(target); + } + + return { normalizeLanguage, browserLanguage, countryLanguage, entryPath, destination, + savePreference, savedPreference, lookupCountry, start }; +}); From 2c33f0ee3d0c3c233ab180e2198931827cb703e2 Mon Sep 17 00:00:00 2001 From: Timur Gilmullin Date: Wed, 28 Oct 2026 23:14:50 +0300 Subject: [PATCH 2/2] Cancel late geo navigation on scrolling and touch interaction --- scripts/test_locale.cjs | 14 ++++++++++++-- static/locale.js | 9 +++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/scripts/test_locale.cjs b/scripts/test_locale.cjs index 4c23a51..84c7f87 100644 --- a/scripts/test_locale.cjs +++ b/scripts/test_locale.cjs @@ -141,12 +141,22 @@ test('geo request is aborted on timeout', async () => { assert.equal(calls.redirects.length, 0); }); -test('late geo result cannot override user input or a new manual choice', async () => { +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); - calls.events.get('pointerdown')(); locale.savePreference(win, 'en'); resolve({ ok: true, text: async () => 'loc=RU\n' }); await running; diff --git a/static/locale.js b/static/locale.js index 2a4ed44..6d13f05 100644 --- a/static/locale.js +++ b/static/locale.js @@ -155,11 +155,12 @@ // or navigation while the optional network request is in flight. let interacted = false; const cancel = function () { interacted = true; }; - win.document.addEventListener('pointerdown', cancel, { once: true, capture: true }); - win.document.addEventListener('keydown', cancel, { once: true, capture: true }); + const cancelEvents = ['pointerdown', 'keydown', 'wheel', 'touchstart']; + cancelEvents.forEach(function (name) { + win.document.addEventListener(name, cancel, { once: true, capture: true, passive: true }); + }); const country = await lookupCountry(win); - win.document.removeEventListener('pointerdown', cancel, true); - win.document.removeEventListener('keydown', cancel, true); + cancelEvents.forEach(function (name) { win.document.removeEventListener(name, cancel, true); }); if (interacted || win.location.href !== url.href || savedPreference(win)) return; const target = destination(url.href, countryLanguage(country)); if (target) win.location.replace(target);