Skip to content

Commit

Permalink
Fix translation issue for sites with a single non-root language diffe…
Browse files Browse the repository at this point in the history
…rent from English (#1709)

Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>
  • Loading branch information
HiDeoo and delucis committed Apr 9, 2024
1 parent ca031c0 commit c5cd181
Show file tree
Hide file tree
Showing 12 changed files with 247 additions and 7 deletions.
5 changes: 5 additions & 0 deletions .changeset/khaki-hairs-end.md
@@ -0,0 +1,5 @@
---
'@astrojs/starlight': patch
---

Fixes a UI strings translation issue for sites configured with a single non-root language different from English.
@@ -0,0 +1,17 @@
import config from 'virtual:starlight/user-config';
import { expect, test } from 'vitest';

test('test suite is using correct env', () => {
expect(config.title).toBe('i18n with a non-root single locale');
});

test('config.isMultilingual is false with a single locale', () => {
expect(config.isMultilingual).toBe(false);
expect(config.locales).keys('fr');
});

test('config.defaultLocale is populated from default locale', () => {
expect(config.defaultLocale.lang).toBe('fr');
expect(config.defaultLocale.dir).toBe('ltr');
expect(config.defaultLocale.locale).toBe('fr');
});
@@ -0,0 +1,26 @@
import { describe, expect, test } from 'vitest';
import { localizedUrl } from '../../utils/localizedUrl';

describe('with `build.output: "directory"`', () => {
test('it has no effect in a monolingual project with a non-root single locale', () => {
const url = new URL('https://example.com/fr/guide/');
expect(localizedUrl(url, 'fr').href).toBe(url.href);
});

test('has no effect on index route in a monolingual project with a non-root single locale', () => {
const url = new URL('https://example.com/fr/');
expect(localizedUrl(url, 'fr').href).toBe(url.href);
});
});

describe('with `build.output: "file"`', () => {
test('it has no effect in a monolingual project with a non-root single locale', () => {
const url = new URL('https://example.com/fr/guide.html');
expect(localizedUrl(url, 'fr').href).toBe(url.href);
});

test('has no effect on index route in a monolingual project with a non-root single locale', () => {
const url = new URL('https://example.com/fr.html');
expect(localizedUrl(url, 'fr').href).toBe(url.href);
});
});
@@ -0,0 +1,19 @@
import { expect, test, vi } from 'vitest';
import { generateRouteData } from '../../utils/route-data';
import { routes } from '../../utils/routing';

vi.mock('astro:content', async () =>
(await import('../test-utils')).mockedAstroContent({
docs: [['fr/index.mdx', { title: 'Accueil' }]],
})
);

test('includes localized labels (fr)', () => {
const route = routes[0]!;
const data = generateRouteData({
props: { ...route, headings: [{ depth: 1, slug: 'heading-1', text: 'Heading 1' }] },
url: new URL('https://example.com'),
});
expect(data.labels).toBeDefined();
expect(data.labels['skipLink.label']).toBe('Aller au contenu');
});
@@ -0,0 +1,35 @@
import { expect, test, vi } from 'vitest';
import { routes } from '../../utils/routing';

vi.mock('astro:content', async () =>
(await import('../test-utils')).mockedAstroContent({
docs: [
['fr/index.mdx', { title: 'Accueil' }],
['en/index.mdx', { title: 'Home page' }],
],
})
);

test('route slugs are normalized', () => {
const indexRoute = routes.find((route) => route.id.startsWith('fr/index.md'));
expect(indexRoute?.slug).toBe('fr');
});

test('routes for the configured locale have locale data added', () => {
for (const route of routes) {
if (route.id.startsWith('fr')) {
expect(route.lang).toBe('fr');
expect(route.dir).toBe('ltr');
expect(route.locale).toBe('fr');
} else {
expect(route.lang).toBe('fr');
expect(route.dir).toBe('ltr');
expect(route.locale).toBeUndefined();
}
}
});

test('does not mark any route as fallback routes', () => {
const fallbacks = routes.filter((route) => route.isFallback);
expect(fallbacks.length).toBe(0);
});
@@ -0,0 +1,37 @@
import { describe, expect, test } from 'vitest';
import { localeToLang, localizedId, localizedSlug, slugToLocaleData } from '../../utils/slugs';

describe('slugToLocaleData', () => {
test('returns default "fr" locale', () => {
expect(slugToLocaleData('fr/test').locale).toBe('fr');
expect(slugToLocaleData('fr/dir/test').locale).toBe('fr');
});
test('returns default locale "fr" lang', () => {
expect(slugToLocaleData('fr/test').lang).toBe('fr');
expect(slugToLocaleData('fr/dir/test').lang).toBe('fr');
});
test('returns default locale "ltr" dir', () => {
expect(slugToLocaleData('fr/test').dir).toBe('ltr');
expect(slugToLocaleData('fr/dir/test').dir).toBe('ltr');
});
});

describe('localeToLang', () => {
test('returns lang for default locale', () => {
expect(localeToLang('fr')).toBe('fr');
});
});

describe('localizedId', () => {
test('returns unchanged for default locale', () => {
expect(localizedId('fr/test.md', 'fr')).toBe('fr/test.md');
});
});

describe('localizedSlug', () => {
test('returns unchanged for default locale', () => {
expect(localizedSlug('fr', 'fr')).toBe('fr');
expect(localizedSlug('fr/test', 'fr')).toBe('fr/test');
expect(localizedSlug('fr/dir/test', 'fr')).toBe('fr/dir/test');
});
});
@@ -0,0 +1,3 @@
{
"page.editLink": "Changer cette page"
}
@@ -0,0 +1,43 @@
import { describe, expect, test } from 'vitest';
import { createTranslationSystemFromFs } from '../../utils/translations-fs';

describe('createTranslationSystemFromFs', () => {
test('creates a translation system that returns default strings', () => {
const useTranslations = createTranslationSystemFromFs(
{
locales: { fr: { label: 'Français', dir: 'ltr' } },
defaultLocale: { label: 'Français', locale: 'fr', dir: 'ltr' },
},
// Using non-existent `_src/` to ignore custom files in this test fixture.
{ srcDir: new URL('./_src/', import.meta.url) }
);
const t = useTranslations('fr');
expect(t('page.editLink')).toMatchInlineSnapshot('"Modifier cette page"');
});

test('creates a translation system that uses custom strings', () => {
const useTranslations = createTranslationSystemFromFs(
{
locales: { fr: { label: 'Français', dir: 'ltr' } },
defaultLocale: { label: 'Français', locale: 'fr', dir: 'ltr' },
},
// Using `src/` to load custom files in this test fixture.
{ srcDir: new URL('./src/', import.meta.url) }
);
const t = useTranslations('fr');
expect(t('page.editLink')).toMatchInlineSnapshot('"Changer cette page"');
});

test('returns translation for unknown language', () => {
const useTranslations = createTranslationSystemFromFs(
{
locales: { fr: { label: 'Français', dir: 'ltr', lang: 'fr' } },
defaultLocale: { label: 'Français', locale: 'fr', dir: 'ltr' },
},
// Using `src/` to load custom files in this test fixture.
{ srcDir: new URL('./src/', import.meta.url) }
);
const t = useTranslations('ar');
expect(t('page.editLink')).toMatchInlineSnapshot('"Changer cette page"');
});
});
@@ -0,0 +1,17 @@
import { describe, expect, test, vi } from 'vitest';
import translations from '../../translations';
import { useTranslations } from '../../utils/translations';

vi.mock('astro:content', async () =>
(await import('../test-utils')).mockedAstroContent({
i18n: [['fr', { 'page.editLink': 'Modifier cette doc!' }]],
})
);

describe('useTranslations()', () => {
test('uses user-defined translations', () => {
const t = useTranslations('fr');
expect(t('page.editLink')).toBe('Modifier cette doc!');
expect(t('page.editLink')).not.toBe(translations.fr?.['page.editLink']);
});
});
@@ -0,0 +1,24 @@
import { describe, expect, test } from 'vitest';
import translations from '../../translations';
import { useTranslations } from '../../utils/translations';

describe('built-in translations', () => {
test('includes French', () => {
expect(translations).toHaveProperty('fr');
});
});

describe('useTranslations()', () => {
test('works when no i18n collection is available', () => {
const t = useTranslations('fr');
expect(t).toBeTypeOf('function');
expect(t('page.editLink')).toBe(translations.fr?.['page.editLink']);
});

test('returns default locale for unknown language', () => {
const locale = 'xx';
expect(translations).not.toHaveProperty(locale);
const t = useTranslations(locale);
expect(t('page.editLink')).toBe(translations.fr?.['page.editLink']);
});
});
@@ -0,0 +1,9 @@
import { defineVitestConfig } from '../test-config';

export default defineVitestConfig({
title: 'i18n with a non-root single locale',
defaultLocale: 'fr',
locales: {
fr: { label: 'Français', lang: 'fr' },
},
});
19 changes: 12 additions & 7 deletions packages/starlight/utils/user-config.ts
Expand Up @@ -212,16 +212,20 @@ const UserConfigSchema = z.object({

export const StarlightConfigSchema = UserConfigSchema.strict().transform(
({ locales, defaultLocale, ...config }, ctx) => {
if (locales !== undefined && Object.keys(locales).length > 1) {
// This is a multilingual site (more than one locale configured).
const configuredLocales = Object.keys(locales ?? {});

// This is a multilingual site (more than one locale configured) or a monolingual site with
// only one locale configured (not a root locale).
// Monolingual sites with only one non-root locale needs their configuration to be defined in
// `config.locales` so that slugs can be correctly generated by taking into consideration the
// base path at which a language is served which is the key of the `config.locales` object.
if (locales !== undefined && configuredLocales.length >= 1) {
// Make sure we can find the default locale and if not, help the user set it.
// We treat the root locale as the default if present and no explicit default is set.
const defaultLocaleConfig = locales[defaultLocale || 'root'];

if (!defaultLocaleConfig) {
const availableLocales = Object.keys(locales)
.map((l) => `"${l}"`)
.join(', ');
const availableLocales = configuredLocales.map((l) => `"${l}"`).join(', ');
ctx.addIssue({
code: 'custom',
message:
Expand All @@ -235,14 +239,15 @@ export const StarlightConfigSchema = UserConfigSchema.strict().transform(
return {
...config,
/** Flag indicating if this site has multiple locales set up. */
isMultilingual: true,
isMultilingual: configuredLocales.length > 1,
/** Full locale object for this site’s default language. */
defaultLocale: { ...defaultLocaleConfig, locale: defaultLocale },
locales,
} as const;
}

// This is a monolingual site, so things are pretty simple.
// This is a monolingual site with no locales configured or only a root locale, so things are
// pretty simple.
return {
...config,
/** Flag indicating if this site has multiple locales set up. */
Expand Down

0 comments on commit c5cd181

Please sign in to comment.