diff --git a/.changeset/khaki-hairs-end.md b/.changeset/khaki-hairs-end.md new file mode 100644 index 0000000000..377b40ec38 --- /dev/null +++ b/.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. diff --git a/packages/starlight/__tests__/i18n-non-root-single-locale/config.test.ts b/packages/starlight/__tests__/i18n-non-root-single-locale/config.test.ts new file mode 100644 index 0000000000..a7b7262eb3 --- /dev/null +++ b/packages/starlight/__tests__/i18n-non-root-single-locale/config.test.ts @@ -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'); +}); diff --git a/packages/starlight/__tests__/i18n-non-root-single-locale/localizedUrl.test.ts b/packages/starlight/__tests__/i18n-non-root-single-locale/localizedUrl.test.ts new file mode 100644 index 0000000000..49ef69aa7f --- /dev/null +++ b/packages/starlight/__tests__/i18n-non-root-single-locale/localizedUrl.test.ts @@ -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); + }); +}); diff --git a/packages/starlight/__tests__/i18n-non-root-single-locale/route-data.test.ts b/packages/starlight/__tests__/i18n-non-root-single-locale/route-data.test.ts new file mode 100644 index 0000000000..dbf5d35481 --- /dev/null +++ b/packages/starlight/__tests__/i18n-non-root-single-locale/route-data.test.ts @@ -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'); +}); diff --git a/packages/starlight/__tests__/i18n-non-root-single-locale/routing.test.ts b/packages/starlight/__tests__/i18n-non-root-single-locale/routing.test.ts new file mode 100644 index 0000000000..2caef2f599 --- /dev/null +++ b/packages/starlight/__tests__/i18n-non-root-single-locale/routing.test.ts @@ -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); +}); diff --git a/packages/starlight/__tests__/i18n-non-root-single-locale/slugs.test.ts b/packages/starlight/__tests__/i18n-non-root-single-locale/slugs.test.ts new file mode 100644 index 0000000000..1904ee113b --- /dev/null +++ b/packages/starlight/__tests__/i18n-non-root-single-locale/slugs.test.ts @@ -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'); + }); +}); diff --git a/packages/starlight/__tests__/i18n-non-root-single-locale/src/content/i18n/fr.json b/packages/starlight/__tests__/i18n-non-root-single-locale/src/content/i18n/fr.json new file mode 100644 index 0000000000..2b27eb1edf --- /dev/null +++ b/packages/starlight/__tests__/i18n-non-root-single-locale/src/content/i18n/fr.json @@ -0,0 +1,3 @@ +{ + "page.editLink": "Changer cette page" +} diff --git a/packages/starlight/__tests__/i18n-non-root-single-locale/translations-fs.test.ts b/packages/starlight/__tests__/i18n-non-root-single-locale/translations-fs.test.ts new file mode 100644 index 0000000000..1248df1ebe --- /dev/null +++ b/packages/starlight/__tests__/i18n-non-root-single-locale/translations-fs.test.ts @@ -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"'); + }); +}); diff --git a/packages/starlight/__tests__/i18n-non-root-single-locale/translations-with-user-config.test.ts b/packages/starlight/__tests__/i18n-non-root-single-locale/translations-with-user-config.test.ts new file mode 100644 index 0000000000..bc76cc2860 --- /dev/null +++ b/packages/starlight/__tests__/i18n-non-root-single-locale/translations-with-user-config.test.ts @@ -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']); + }); +}); diff --git a/packages/starlight/__tests__/i18n-non-root-single-locale/translations.test.ts b/packages/starlight/__tests__/i18n-non-root-single-locale/translations.test.ts new file mode 100644 index 0000000000..3287cdf27a --- /dev/null +++ b/packages/starlight/__tests__/i18n-non-root-single-locale/translations.test.ts @@ -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']); + }); +}); diff --git a/packages/starlight/__tests__/i18n-non-root-single-locale/vitest.config.ts b/packages/starlight/__tests__/i18n-non-root-single-locale/vitest.config.ts new file mode 100644 index 0000000000..be081a7c57 --- /dev/null +++ b/packages/starlight/__tests__/i18n-non-root-single-locale/vitest.config.ts @@ -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' }, + }, +}); diff --git a/packages/starlight/utils/user-config.ts b/packages/starlight/utils/user-config.ts index f77f4e9058..8e566346b4 100644 --- a/packages/starlight/utils/user-config.ts +++ b/packages/starlight/utils/user-config.ts @@ -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: @@ -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. */