AT Mot — a bilingual (EN/FR) daily word game native to the AT Protocol.
0

Configure Feed

Select the types of activity you want to include in your feed.

Switch puzzle URL when toggling language on a puzzle page

The language toggle did nothing on /p/<lang>/<n> pages: the router
re-derives the language from the URL path on every render, so flipping
ctx.lang alone left the page on its original language while only the
chrome strings changed.

setLang now routes to the equivalent puzzle in the new language via a
pure pathForLangSwitch() helper, keeping the URL and ctx.lang in sync.

+77 -1
+14
src/config.ts
··· 103 103 return { lang, puzzleNumber }; 104 104 } 105 105 106 + /** 107 + * Where the language toggle should take you. Puzzle pages encode the language 108 + * in the URL (`/p/<lang>/<n>`), so the router re-derives the language from the 109 + * path on every render — switching `ctx.lang` alone leaves a puzzle page on its 110 + * original language. Returns the equivalent puzzle path in `lang`, or null when 111 + * the current path is language-independent (or already in `lang`) and a plain 112 + * re-render suffices. 113 + */ 114 + export function pathForLangSwitch(currentPath: string, lang: Lang): string | null { 115 + const puzzle = parsePuzzleTarget(currentPath); 116 + if (puzzle && puzzle.lang !== lang) return `/p/${lang}/${puzzle.puzzleNumber}`; 117 + return null; 118 + } 119 + 106 120 /** Deterministic rkey for a result record: `<lang>-<puzzleNumber>` (documented in the lexicon). */ 107 121 export function resultRkey(lang: Lang, puzzleNumber: number): string { 108 122 return `${lang}-${puzzleNumber}`;
+8 -1
src/main.ts
··· 1 1 import './ui/theme.css'; 2 - import { isLang, type Lang, parsePuzzleTarget } from './config.js'; 2 + import { isLang, type Lang, parsePuzzleTarget, pathForLangSwitch } from './config.js'; 3 3 import { 4 4 isOAuthCallback, 5 5 completeCallback, ··· 37 37 ctx.lang = lang; 38 38 localStorage.setItem(LANG_KEY, lang); 39 39 document.documentElement.lang = lang; 40 + // On a puzzle page the language lives in the URL, so switch the path too — 41 + // otherwise render() re-derives the old language and nothing visibly changes. 42 + const dest = pathForLangSwitch(location.pathname, lang); 43 + if (dest) { 44 + ctx.navigate(dest); 45 + return; 46 + } 40 47 render(); 41 48 }, 42 49
+17
tests/config.test.ts
··· 2 2 import { 3 3 puzzleTarget, 4 4 parsePuzzleTarget, 5 + pathForLangSwitch, 5 6 resultRkey, 6 7 daysSinceEpoch, 7 8 puzzleNumberFor, ··· 32 33 expect(parsePuzzleTarget('https://atmot.herve.bzh/p/FR/412')).toBeNull(); 33 34 expect(parsePuzzleTarget('https://atmot.herve.bzh/p/de/1')).toBeNull(); 34 35 expect(parsePuzzleTarget('https://atmot.herve.bzh/p/fr/0')).toBeNull(); 36 + }); 37 + }); 38 + 39 + describe('pathForLangSwitch — keep puzzle pages in sync with the lang toggle', () => { 40 + it('redirects a puzzle page to the same puzzle in the new language', () => { 41 + expect(pathForLangSwitch('/p/fr/1', 'en')).toBe('/p/en/1'); 42 + expect(pathForLangSwitch('/p/en/412', 'fr')).toBe('/p/fr/412'); 43 + }); 44 + 45 + it('returns null when the puzzle is already in the target language', () => { 46 + expect(pathForLangSwitch('/p/fr/1', 'fr')).toBeNull(); 47 + }); 48 + 49 + it('returns null on language-independent paths (home, about)', () => { 50 + expect(pathForLangSwitch('/', 'fr')).toBeNull(); 51 + expect(pathForLangSwitch('/about', 'en')).toBeNull(); 35 52 }); 36 53 }); 37 54
+38
tests/header.test.ts
··· 1 + // @vitest-environment happy-dom 2 + import { describe, expect, it, vi } from 'vitest'; 3 + import { renderHeader } from '../src/ui/header.js'; 4 + import { pathForLangSwitch } from '../src/config.js'; 5 + import type { Ctx } from '../src/ui/context.js'; 6 + 7 + function makeCtx(over: Partial<Ctx> = {}): Ctx { 8 + return { 9 + lang: 'fr', 10 + session: null, 11 + handle: null, 12 + pendingRecord: null, 13 + setLang() {}, 14 + navigate() {}, 15 + rerender() {}, 16 + async signInWith() {}, 17 + async signOut() {}, 18 + ...over, 19 + }; 20 + } 21 + 22 + describe('renderHeader language switch', () => { 23 + it('the EN button asks to switch to English', () => { 24 + const setLang = vi.fn(); 25 + const header = renderHeader(makeCtx({ setLang })); 26 + const en = [...header.querySelectorAll('button')].find((b) => b.textContent === 'EN'); 27 + expect(en).toBeDefined(); 28 + en!.click(); 29 + expect(setLang).toHaveBeenCalledWith('en'); 30 + }); 31 + 32 + // Regression: on a French puzzle page, switching to EN must route to the 33 + // English puzzle. Previously setLang only flipped ctx.lang and re-rendered, 34 + // so the router re-derived `fr` from the unchanged path and nothing changed. 35 + it('switching language on a puzzle page routes to the same puzzle in the new language', () => { 36 + expect(pathForLangSwitch('/p/fr/1', 'en')).toBe('/p/en/1'); 37 + }); 38 + });