Skip to content

Commit ddd1831

Browse files
refactor(language-select): adopt shared i18n route utilities and JSON-encoded targets
Replace the inline localizedPathname function in StarlightLanguageSelect with buildDocsRoutePath and stripDocsLocalePrefix from the shared i18n library. Encode the select option value as a JSON object containing both the target path and locale code so the client-side handler can persist the locale without re-parsing the URL. Update the persistence test suite to use the new encoded value format and add coverage for Japanese locale persistence. Co-Authored-By: Hagicode <noreply@hagicode.com> Signed-off-by: newbe36524 <newbe36524@qq.com>
1 parent 3151622 commit ddd1831

3 files changed

Lines changed: 48 additions & 77 deletions

File tree

src/components/StarlightLanguageSelect.astro

Lines changed: 27 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,14 @@
11
---
2-
import context from 'virtual:starlight/project-context';
32
import config from 'virtual:starlight/user-config';
43
import Select from '@astrojs/starlight/components/Select.astro';
54
import { DOCS_LOCALE_SELECTOR_OPTIONS } from '../i18n/generated/docs-locale-resources.mjs';
5+
import { buildDocsRoutePath, stripDocsLocalePrefix, type DocsLocale } from '@/lib/i18n';
66
7-
function stripTrailingSlash(path: string): string {
8-
if (path === '/') {
9-
return '';
10-
}
11-
12-
return path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path;
13-
}
14-
15-
function localizedPathname(locale: string | undefined): string {
16-
const url = new URL(Astro.url);
17-
18-
if (!config.locales) {
19-
return url.pathname;
20-
}
21-
22-
const nextLocale = locale === 'root' ? '' : locale;
23-
const base = stripTrailingSlash(import.meta.env.BASE_URL);
24-
const hasBase = url.pathname.startsWith(base);
25-
26-
if (hasBase) {
27-
url.pathname = url.pathname.replace(base, '');
28-
}
29-
30-
const [_leadingSlash, baseSegment] = url.pathname.split('/');
31-
const htmlExt = '.html';
32-
const isRootHtml = baseSegment?.endsWith(htmlExt);
33-
const baseSlug = isRootHtml ? baseSegment?.slice(0, -1 * htmlExt.length) : baseSegment;
34-
35-
if (baseSlug && baseSlug in config.locales) {
36-
if (nextLocale) {
37-
url.pathname = url.pathname.replace(baseSlug, nextLocale);
38-
} else if (isRootHtml) {
39-
url.pathname = '/index.html';
40-
} else {
41-
url.pathname = url.pathname.replace('/' + baseSlug, '');
42-
}
43-
} else if (nextLocale) {
44-
if (baseSegment === 'index.html') {
45-
url.pathname = '/' + nextLocale + '.html';
46-
} else {
47-
url.pathname = '/' + nextLocale + url.pathname;
48-
}
49-
}
50-
51-
if (hasBase) {
52-
url.pathname = base + url.pathname;
53-
}
54-
55-
if (context.trailingSlash === 'never') {
56-
url.pathname = stripTrailingSlash(url.pathname);
57-
}
58-
59-
return url.pathname;
7+
function buildLanguageSelectValue(locale: DocsLocale): string {
8+
return JSON.stringify({
9+
path: buildDocsRoutePath(locale, stripDocsLocalePrefix(Astro.url.pathname)),
10+
locale,
11+
});
6012
}
6113
6214
const localeOptions = DOCS_LOCALE_SELECTOR_OPTIONS.filter(({ code }) => {
@@ -75,7 +27,7 @@ const localeOptions = DOCS_LOCALE_SELECTOR_OPTIONS.filter(({ code }) => {
7527
icon="translate"
7628
label={Astro.locals.t('languageSelect.accessibleLabel')}
7729
options={localeOptions.map((locale) => ({
78-
value: localizedPathname(locale.code),
30+
value: buildLanguageSelectValue(locale.code),
7931
selected: locale.code === Astro.locals.starlightRoute.locale,
8032
label: locale.label,
8133
}))}
@@ -88,23 +40,21 @@ const localeOptions = DOCS_LOCALE_SELECTOR_OPTIONS.filter(({ code }) => {
8840
<script>
8941
const DOCS_LANGUAGE_STORAGE_KEY = 'starlight-route';
9042

91-
function isEnglishDocsPath(pathname) {
92-
return pathname === '/en' || pathname === '/en/' || pathname.startsWith('/en/');
93-
}
43+
function parseSelectedLocaleTarget(value) {
44+
try {
45+
const parsed = JSON.parse(value);
46+
if (!parsed || typeof parsed !== 'object') {
47+
return null;
48+
}
9449

95-
function detectDocsLocaleFromPath(pathname) {
96-
const segments = pathname.split('/').filter(Boolean);
97-
const firstSegment = segments[0];
98-
if (firstSegment === 'en') {
99-
return 'en';
100-
}
50+
if (typeof parsed.path !== 'string' || typeof parsed.locale !== 'string') {
51+
return null;
52+
}
10153

102-
const blogRouteLocales = ['zh-Hant', 'ja-JP', 'ko-KR', 'de-DE', 'fr-FR', 'es-ES', 'pt-BR', 'ru-RU'];
103-
if (blogRouteLocales.includes(firstSegment)) {
104-
return firstSegment;
54+
return parsed;
55+
} catch (_error) {
56+
return null;
10557
}
106-
107-
return isEnglishDocsPath(pathname) ? 'en' : 'root';
10858
}
10959

11060
function serializeStoredDocsLocale(storageValue, locale) {
@@ -122,9 +72,7 @@ const localeOptions = DOCS_LOCALE_SELECTOR_OPTIONS.filter(({ code }) => {
12272
return JSON.stringify(routeObj);
12373
}
12474

125-
function persistSelectedDocsLocale(targetPath) {
126-
const locale = detectDocsLocaleFromPath(targetPath);
127-
75+
function persistSelectedDocsLocale(locale) {
12876
try {
12977
const previousValue = localStorage.getItem(DOCS_LANGUAGE_STORAGE_KEY);
13078
localStorage.setItem(
@@ -143,8 +91,13 @@ const localeOptions = DOCS_LOCALE_SELECTOR_OPTIONS.filter(({ code }) => {
14391
if (select) {
14492
select.addEventListener('change', (e) => {
14593
if (e.currentTarget instanceof HTMLSelectElement) {
146-
persistSelectedDocsLocale(e.currentTarget.value);
147-
window.location.pathname = e.currentTarget.value;
94+
const target = parseSelectedLocaleTarget(e.currentTarget.value);
95+
if (!target) {
96+
return;
97+
}
98+
99+
persistSelectedDocsLocale(target.locale);
100+
window.location.pathname = target.path;
148101
}
149102
});
150103
window.addEventListener('pageshow', (event) => {

src/lib/i18n.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ describe('docs locale helpers', () => {
9292
['root', '/en/install/', '/install/'],
9393
['root', '/install/', '/install/'],
9494
['root', '/en/install', '/install'],
95+
['ja-JP', '/en/product-overview/', '/ja-JP/product-overview/'],
9596
['ja-JP', '/blog/example/', '/ja-JP/blog/example/'],
9697
['fr-FR', '/en/blog/example/', '/fr-FR/blog/example/'],
9798
] as const)('builds %s route for %s', (locale, originalPath, expected) => {

tests/language-select-persistence.test.mjs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
77
import { DOCS_LOCALE_SELECTOR_OPTIONS } from '../src/i18n/generated/docs-locale-resources.mjs';
88

99
const testDir = path.dirname(fileURLToPath(import.meta.url));
10+
const encodeSelectionValue = (path, locale) => JSON.stringify({ path, locale });
1011

1112
async function readLanguageSelectScript() {
1213
const componentPath = path.join(testDir, '..', 'src', 'components', 'StarlightLanguageSelect.astro');
@@ -39,7 +40,7 @@ function evaluateLanguageSelectScript(scriptContent, options = {}) {
3940

4041
const select = Object.setPrototypeOf(
4142
{
42-
value: options.selectedValue ?? '/en/product-overview/',
43+
value: options.selectedValue ?? encodeSelectionValue('/en/product-overview/', 'en'),
4344
selectedIndex,
4445
addEventListener(type, listener) {
4546
listeners.set(type, listener);
@@ -115,7 +116,7 @@ function evaluateLanguageSelectScript(scriptContent, options = {}) {
115116
test('language selector persists English locale before navigating', async () => {
116117
const scriptContent = await readLanguageSelectScript();
117118
const result = evaluateLanguageSelectScript(scriptContent, {
118-
selectedValue: '/en/product-overview/',
119+
selectedValue: encodeSelectionValue('/en/product-overview/', 'en'),
119120
storedRouteValue: JSON.stringify({ path: '/product-overview/', lang: 'root' }),
120121
});
121122

@@ -131,6 +132,8 @@ test('language selector renders the full generated locale set in the header', as
131132
const source = await readFile(componentPath, 'utf8');
132133

133134
assert.match(source, /DOCS_LOCALE_SELECTOR_OPTIONS/);
135+
assert.match(source, /buildDocsRoutePath/);
136+
assert.match(source, /stripDocsLocalePrefix/);
134137
assert.match(source, /label: locale\.label/);
135138
assert.match(source, /return true;/);
136139
assert.deepEqual(
@@ -153,10 +156,24 @@ test('language selector renders the full generated locale set in the header', as
153156
test('language selector navigates even when localStorage fails', async () => {
154157
const scriptContent = await readLanguageSelectScript();
155158
const result = evaluateLanguageSelectScript(scriptContent, {
156-
selectedValue: '/product-overview/',
159+
selectedValue: encodeSelectionValue('/product-overview/', 'root'),
157160
storageThrows: true,
158161
});
159162

160163
assert.equal(result.pathname, '/product-overview/');
161164
assert.equal(result.storedRouteValue, undefined);
162165
});
166+
167+
test('language selector persists Japanese locale from the encoded route target', async () => {
168+
const scriptContent = await readLanguageSelectScript();
169+
const result = evaluateLanguageSelectScript(scriptContent, {
170+
selectedValue: encodeSelectionValue('/ja-JP/product-overview/', 'ja-JP'),
171+
storedRouteValue: JSON.stringify({ path: '/en/product-overview/', lang: 'en' }),
172+
});
173+
174+
assert.equal(result.pathname, '/ja-JP/product-overview/');
175+
assert.deepEqual(JSON.parse(result.storedRouteValue), {
176+
path: '/en/product-overview/',
177+
lang: 'ja-JP',
178+
});
179+
});

0 commit comments

Comments
 (0)