From a6ef1c211ea504ce6c98bd8e73a6e4a8e0df67ee Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Mon, 20 Nov 2023 15:38:42 -0600 Subject: [PATCH 01/14] feat(i18n): refined locales --- packages/astro/src/@types/astro.ts | 6 ++-- packages/astro/src/core/app/types.ts | 2 +- packages/astro/src/core/config/schema.ts | 19 +++++++++-- packages/astro/src/i18n/index.ts | 42 +++++++++++++++++++++--- 4 files changed, 59 insertions(+), 10 deletions(-) diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 2f2e9f75a1a5..75dfca7889c4 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1473,7 +1473,7 @@ export interface AstroUserConfig { * @docs * @kind h4 * @name experimental.i18n.locales - * @type {string[]} + * @type {(string | { path: string; code: string[] })[]} * @version 3.5.0 * @description * @@ -1481,7 +1481,7 @@ export interface AstroUserConfig { * * No particular language format or syntax is enforced, but your folder structure must match exactly the locales in the list. */ - locales: string[]; + locales: (string | { path: string; code: string[] })[]; /** * @docs @@ -2044,6 +2044,8 @@ export interface AstroInternationalizationFeature { detectBrowserLanguage?: SupportsKind; } +export type Locales = (string | { codes: string[]; path: string })[]; + export interface AstroAdapter { name: string; serverEntrypoint?: string; diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index 9f9d80f44511..5b2712020527 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -56,7 +56,7 @@ export type SSRManifest = { export type SSRManifestI18n = { fallback?: Record; routingStrategy?: 'prefix-always' | 'prefix-other-locales'; - locales: string[]; + locales: (string | { code: string[]; path: string })[]; defaultLocale: string; }; diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index eaa11786a5c9..8b8d10c64113 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -344,7 +344,15 @@ export const AstroConfigSchema = z.object({ z .object({ defaultLocale: z.string(), - locales: z.string().array(), + locales: z.array( + z.union([ + z.string(), + z.object({ + path: z.string(), + codes: z.string().array(), + }), + ]) + ), fallback: z.record(z.string(), z.string()).optional(), // TODO: properly add default when the feature goes of experimental routingStrategy: z @@ -355,7 +363,14 @@ export const AstroConfigSchema = z.object({ .optional() .superRefine((i18n, ctx) => { if (i18n) { - const { defaultLocale, locales, fallback } = i18n; + const { defaultLocale, locales: _locales, fallback } = i18n; + const locales = _locales.map((locale) => { + if (typeof locale === 'string') { + return locale; + } else { + return locale.path; + } + }); if (!locales.includes(defaultLocale)) { ctx.addIssue({ code: z.ZodIssueCode.custom, diff --git a/packages/astro/src/i18n/index.ts b/packages/astro/src/i18n/index.ts index 937daf279a67..72f83fc84005 100644 --- a/packages/astro/src/i18n/index.ts +++ b/packages/astro/src/i18n/index.ts @@ -1,5 +1,5 @@ import { appendForwardSlash, joinPaths } from '@astrojs/internal-helpers/path'; -import type { AstroConfig } from '../@types/astro.js'; +import type { AstroConfig, Locales } from '../@types/astro.js'; import { shouldAppendForwardSlash } from '../core/build/util.js'; import { MissingLocale } from '../core/errors/errors-data.js'; import { AstroError } from '../core/errors/index.js'; @@ -7,7 +7,7 @@ import { AstroError } from '../core/errors/index.js'; type GetLocaleRelativeUrl = GetLocaleOptions & { locale: string; base: string; - locales: string[]; + locales: Locales; trailingSlash: AstroConfig['trailingSlash']; format: AstroConfig['build']['format']; routingStrategy?: 'prefix-always' | 'prefix-other-locales'; @@ -39,7 +39,7 @@ type GetLocaleAbsoluteUrl = GetLocaleRelativeUrl & { export function getLocaleRelativeUrl({ locale, base, - locales, + locales: _locales, trailingSlash, format, path, @@ -48,6 +48,7 @@ export function getLocaleRelativeUrl({ routingStrategy = 'prefix-other-locales', defaultLocale, }: GetLocaleRelativeUrl) { + const locales = toPathLocales(_locales); if (!locales.includes(locale)) { throw new AstroError({ ...MissingLocale, @@ -84,7 +85,7 @@ export function getLocaleAbsoluteUrl({ site, ...rest }: GetLocaleAbsoluteUrl) { type GetLocalesBaseUrl = GetLocaleOptions & { base: string; - locales: string[]; + locales: Locales; trailingSlash: AstroConfig['trailingSlash']; format: AstroConfig['build']['format']; routingStrategy?: 'prefix-always' | 'prefix-other-locales'; @@ -93,7 +94,7 @@ type GetLocalesBaseUrl = GetLocaleOptions & { export function getLocaleRelativeUrlList({ base, - locales, + locales: _locales, trailingSlash, format, path, @@ -102,6 +103,7 @@ export function getLocaleRelativeUrlList({ routingStrategy = 'prefix-other-locales', defaultLocale, }: GetLocalesBaseUrl) { + const locales = toPathLocales(_locales); return locales.map((locale) => { const pathsToJoin = [base, prependWith]; const normalizedLocale = normalizeLocale ? normalizeTheLocale(locale) : locale; @@ -140,3 +142,33 @@ export function getLocaleAbsoluteUrlList({ site, ...rest }: GetLocaleAbsoluteUrl export function normalizeTheLocale(locale: string): string { return locale.replaceAll('_', '-').toLowerCase(); } + +/** + * Returns an array of only locales, by picking the `path` + */ +export function toPathLocales(locales: Locales): string[] { + return locales.map((locale) => { + if (typeof locale === 'string') { + return locale; + } else { + return locale.path; + } + }); +} + +/** + * Returns an array of only locales, by picking the `code` + */ +export function toCodeLocales(locales: Locales): string[] { + const codes: string[] = []; + for (const locale of locales) { + if (typeof locale === 'string') { + codes.push(locale); + } else { + for (const code of locale.codes) { + codes.push(code); + } + } + } + return codes; +} From bc435d54c4ad41226d690391fc641934760d5f38 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Mon, 27 Nov 2023 10:00:51 -0500 Subject: [PATCH 02/14] feat: support granular locales inside the virtual module --- packages/astro/src/@types/astro.ts | 4 +- packages/astro/src/core/app/types.ts | 3 +- packages/astro/src/core/config/schema.ts | 2 +- packages/astro/src/core/errors/errors-data.ts | 6 +- packages/astro/src/i18n/index.ts | 48 +++++-- packages/astro/src/i18n/middleware.ts | 48 +++++-- .../src/pages/spanish/start.astro | 12 ++ .../fixtures/i18n-routing/astro.config.mjs | 8 +- .../src/pages/virtual-module.astro | 2 + packages/astro/test/i18n-routing.test.js | 1 + .../test/units/config/config-validate.test.js | 48 +++++++ .../astro/test/units/i18n/astro_i18n.test.js | 124 ++++++++++++++++-- 12 files changed, 267 insertions(+), 39 deletions(-) create mode 100644 packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/spanish/start.astro diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 75dfca7889c4..8ea2957291fb 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1473,7 +1473,7 @@ export interface AstroUserConfig { * @docs * @kind h4 * @name experimental.i18n.locales - * @type {(string | { path: string; code: string[] })[]} + * @type {(string | { path: string; codes: string[] })[]} * @version 3.5.0 * @description * @@ -1481,7 +1481,7 @@ export interface AstroUserConfig { * * No particular language format or syntax is enforced, but your folder structure must match exactly the locales in the list. */ - locales: (string | { path: string; code: string[] })[]; + locales: (string | { path: string; codes: string[] })[]; /** * @docs diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index 5b2712020527..58c4d8fd6519 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -1,4 +1,5 @@ import type { + Locales, RouteData, SerializedRouteData, SSRComponentMetadata, @@ -56,7 +57,7 @@ export type SSRManifest = { export type SSRManifestI18n = { fallback?: Record; routingStrategy?: 'prefix-always' | 'prefix-other-locales'; - locales: (string | { code: string[]; path: string })[]; + locales: Locales; defaultLocale: string; }; diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 8b8d10c64113..a5b3fa4c4e5b 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -349,7 +349,7 @@ export const AstroConfigSchema = z.object({ z.string(), z.object({ path: z.string(), - codes: z.string().array(), + codes: z.string().array().nonempty(), }), ]) ), diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index ec84888d4cc9..fd4e8d72dd66 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -1275,10 +1275,8 @@ export const UnsupportedConfigTransformError = { export const MissingLocale = { name: 'MissingLocaleError', title: 'The provided locale does not exist.', - message: (locale: string, locales: string[]) => { - return `The locale \`${locale}\` does not exist in the configured locales. Available locales: ${locales.join( - ', ' - )}.`; + message: (locale: string) => { + return `The locale/path \`${locale}\` does not exist in the configured \`i18n.locales\`.`; }, } satisfies ErrorData; diff --git a/packages/astro/src/i18n/index.ts b/packages/astro/src/i18n/index.ts index 72f83fc84005..8a7c99e79ad4 100644 --- a/packages/astro/src/i18n/index.ts +++ b/packages/astro/src/i18n/index.ts @@ -48,15 +48,15 @@ export function getLocaleRelativeUrl({ routingStrategy = 'prefix-other-locales', defaultLocale, }: GetLocaleRelativeUrl) { - const locales = toPathLocales(_locales); - if (!locales.includes(locale)) { + const codeToUse = peekCodePathToUse(_locales, locale); + if (!codeToUse) { throw new AstroError({ ...MissingLocale, - message: MissingLocale.message(locale, locales), + message: MissingLocale.message(locale), }); } const pathsToJoin = [base, prependWith]; - const normalizedLocale = normalizeLocale ? normalizeTheLocale(locale) : locale; + const normalizedLocale = normalizeLocale ? normalizeTheLocale(codeToUse) : codeToUse; if (routingStrategy === 'prefix-always') { pathsToJoin.push(normalizedLocale); } else if (locale !== defaultLocale) { @@ -103,7 +103,7 @@ export function getLocaleRelativeUrlList({ routingStrategy = 'prefix-other-locales', defaultLocale, }: GetLocalesBaseUrl) { - const locales = toPathLocales(_locales); + const locales = peekPaths(_locales); return locales.map((locale) => { const pathsToJoin = [base, prependWith]; const normalizedLocale = normalizeLocale ? normalizeTheLocale(locale) : locale; @@ -147,13 +147,15 @@ export function normalizeTheLocale(locale: string): string { * Returns an array of only locales, by picking the `path` */ export function toPathLocales(locales: Locales): string[] { - return locales.map((locale) => { + const codes: string[] = []; + for (const locale of locales) { if (typeof locale === 'string') { - return locale; + codes.push(locale); } else { - return locale.path; + codes.push(...locale.codes); } - }); + } + return codes; } /** @@ -172,3 +174,31 @@ export function toCodeLocales(locales: Locales): string[] { } return codes; } + +function peekPaths(locales: Locales): string[] { + return locales.map((loopLocale) => { + if (typeof loopLocale === 'string') { + return loopLocale; + } else { + return loopLocale.path; + } + }); +} + +function peekCodePathToUse(locales: Locales, locale: string): undefined | string { + for (const loopLocale of locales) { + if (typeof loopLocale === 'string') { + if (loopLocale === locale) { + return loopLocale; + } + } else { + for (const code of loopLocale.codes) { + if (code === locale) { + return loopLocale.path; + } + } + } + } + + return undefined; +} diff --git a/packages/astro/src/i18n/middleware.ts b/packages/astro/src/i18n/middleware.ts index 854a39b77cda..f2edfe35f3cb 100644 --- a/packages/astro/src/i18n/middleware.ts +++ b/packages/astro/src/i18n/middleware.ts @@ -1,18 +1,33 @@ import { appendForwardSlash, joinPaths } from '@astrojs/internal-helpers/path'; -import type { MiddlewareEndpointHandler, RouteData, SSRManifest } from '../@types/astro.js'; +import type { + Locales, + MiddlewareEndpointHandler, + RouteData, + SSRManifest, +} from '../@types/astro.js'; import type { PipelineHookFunction } from '../core/pipeline.js'; +import { normalizeTheLocale } from './index.js'; const routeDataSymbol = Symbol.for('astro.routeData'); -// Checks if the pathname doesn't have any locale, exception for the defaultLocale, which is ignored on purpose -function checkIsLocaleFree(pathname: string, locales: string[]): boolean { - for (const locale of locales) { - if (pathname.includes(`/${locale}`)) { - return false; +// Checks if the pathname doesn't have any locale, exception for the defaultLocale, which is ignored on purpose. +function pathnameHasLocale(pathname: string, locales: Locales): boolean { + const segments = pathname.split('/'); + for (const segment of segments) { + for (const locale of locales) { + if (typeof locale === 'string') { + if (normalizeTheLocale(segment) === normalizeTheLocale(locale)) { + return true; + } + } else { + if (segment === locale.path) { + return true; + } + } } } - return true; + return false; } export function createI18nMiddleware( @@ -45,9 +60,7 @@ export function createI18nMiddleware( const response = await next(); if (response instanceof Response) { - const separators = url.pathname.split('/'); const pathnameContainsDefaultLocale = url.pathname.includes(`/${defaultLocale}`); - const isLocaleFree = checkIsLocaleFree(url.pathname, i18n.locales); if (i18n.routingStrategy === 'prefix-other-locales' && pathnameContainsDefaultLocale) { const newLocation = url.pathname.replace(`/${defaultLocale}`, ''); response.headers.set('Location', newLocation); @@ -65,7 +78,7 @@ export function createI18nMiddleware( } // Astro can't know where the default locale is supposed to be, so it returns a 404 with no content. - else if (isLocaleFree) { + else if (!pathnameHasLocale(url.pathname, i18n.locales)) { return new Response(null, { status: 404, headers: response.headers, @@ -75,7 +88,20 @@ export function createI18nMiddleware( if (response.status >= 300 && fallback) { const fallbackKeys = i18n.fallback ? Object.keys(i18n.fallback) : []; - const urlLocale = separators.find((s) => locales.includes(s)); + // we split the URL using the `/`, and then check in the returned array we have the locale + const segments = url.pathname.split('/'); + const urlLocale = segments.find((segment) => { + for (const locale of locales) { + if (typeof locale === 'string') { + if (locale === segment) { + return true; + } + } else if (locale.path === segment) { + return true; + } + } + return false; + }); if (urlLocale && fallbackKeys.includes(urlLocale)) { const fallbackLocale = fallback[urlLocale]; diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/spanish/start.astro b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/spanish/start.astro new file mode 100644 index 000000000000..d67e9de3f085 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/spanish/start.astro @@ -0,0 +1,12 @@ +--- +const currentLocale = Astro.currentLocale; +--- + + + Astro + + +Espanol +Current Locale: {currentLocale ? currentLocale : "none"} + + diff --git a/packages/astro/test/fixtures/i18n-routing/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing/astro.config.mjs index 209ad40fd057..a3ee1e9c605f 100644 --- a/packages/astro/test/fixtures/i18n-routing/astro.config.mjs +++ b/packages/astro/test/fixtures/i18n-routing/astro.config.mjs @@ -5,7 +5,13 @@ export default defineConfig({ i18n: { defaultLocale: 'en', locales: [ - 'en', 'pt', 'it' + 'en', + 'pt', + 'it', + { + path: "spanish", + codes: ["es", "es-SP"] + } ] } }, diff --git a/packages/astro/test/fixtures/i18n-routing/src/pages/virtual-module.astro b/packages/astro/test/fixtures/i18n-routing/src/pages/virtual-module.astro index e6fa2ac2ffbf..1f2b46f2f408 100644 --- a/packages/astro/test/fixtures/i18n-routing/src/pages/virtual-module.astro +++ b/packages/astro/test/fixtures/i18n-routing/src/pages/virtual-module.astro @@ -2,6 +2,7 @@ import { getRelativeLocaleUrl } from "astro:i18n"; let about = getRelativeLocaleUrl("pt", "about"); +let spanish = getRelativeLocaleUrl("es", "about"); --- @@ -13,5 +14,6 @@ let about = getRelativeLocaleUrl("pt", "about"); Virtual module doesn't break About: {about} + About spanish: {spanish} diff --git a/packages/astro/test/i18n-routing.test.js b/packages/astro/test/i18n-routing.test.js index 52ebeda23ae1..b7ec328851f9 100644 --- a/packages/astro/test/i18n-routing.test.js +++ b/packages/astro/test/i18n-routing.test.js @@ -26,6 +26,7 @@ describe('astro:i18n virtual module', () => { const text = await response.text(); expect(text).includes("Virtual module doesn't break"); expect(text).includes('About: /pt/about'); + expect(text).includes('About spanish: /spanish/about'); }); }); describe('[DEV] i18n routing', () => { diff --git a/packages/astro/test/units/config/config-validate.test.js b/packages/astro/test/units/config/config-validate.test.js index 93dd0e28dd77..abe40c6d15fd 100644 --- a/packages/astro/test/units/config/config-validate.test.js +++ b/packages/astro/test/units/config/config-validate.test.js @@ -97,6 +97,54 @@ describe('Config Validation', () => { ); }); + it('errors if the default locale is not in path', async () => { + const configError = await validateConfig( + { + experimental: { + i18n: { + defaultLocale: 'uk', + locales: [ + 'es', + { + path: 'something', + codes: [], + }, + ], + }, + }, + }, + process.cwd() + ).catch((err) => err); + expect(configError instanceof z.ZodError).to.equal(true); + expect(configError.errors[0].message).to.equal( + 'The default locale `uk` is not present in the `i18n.locales` array.' + ); + }); + + it('errors if the default locale is not in path', async () => { + const configError = await validateConfig( + { + experimental: { + i18n: { + defaultLocale: 'uk', + locales: [ + 'es', + { + path: 'something', + codes: ['en'], + }, + ], + }, + }, + }, + process.cwd() + ).catch((err) => err); + expect(configError instanceof z.ZodError).to.equal(true); + expect(configError.errors[0].message).to.equal( + 'The default locale `uk` is not present in the `i18n.locales` array.' + ); + }); + it('errors if a fallback value does not exist', async () => { const configError = await validateConfig( { diff --git a/packages/astro/test/units/i18n/astro_i18n.test.js b/packages/astro/test/units/i18n/astro_i18n.test.js index 63e2df83326d..40f6153764b6 100644 --- a/packages/astro/test/units/i18n/astro_i18n.test.js +++ b/packages/astro/test/units/i18n/astro_i18n.test.js @@ -18,7 +18,15 @@ describe('getLocaleRelativeUrl', () => { experimental: { i18n: { defaultLocale: 'en', - locales: ['en', 'en_US', 'es'], + locales: [ + 'en', + 'en_US', + 'es', + { + path: 'italiano', + codes: ['it', 'it-VA'], + }, + ], }, }, }; @@ -82,6 +90,16 @@ describe('getLocaleRelativeUrl', () => { format: 'file', }) ).to.throw; + + expect( + getLocaleRelativeUrl({ + locale: 'it-VA', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'file', + }) + ).to.eq('/blog/italiano/'); }); it('should correctly return the URL without base', () => { @@ -127,7 +145,14 @@ describe('getLocaleRelativeUrl', () => { experimental: { i18n: { defaultLocale: 'en', - locales: ['en', 'es'], + locales: [ + 'en', + 'es', + { + path: 'italiano', + codes: ['it', 'it-VA'], + }, + ], }, }, }; @@ -151,6 +176,16 @@ describe('getLocaleRelativeUrl', () => { }) ).to.eq('/blog/es/'); + expect( + getLocaleRelativeUrl({ + locale: 'it-VA', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'file', + }) + ).to.eq('/blog/italiano/'); + expect( getLocaleRelativeUrl({ locale: 'en', @@ -328,7 +363,15 @@ describe('getLocaleRelativeUrlList', () => { experimental: { i18n: { defaultLocale: 'en', - locales: ['en', 'en_US', 'es'], + locales: [ + 'en', + 'en_US', + 'es', + { + path: 'italiano', + codes: ['it', 'it-VA'], + }, + ], }, }, }; @@ -341,7 +384,7 @@ describe('getLocaleRelativeUrlList', () => { trailingSlash: 'never', format: 'directory', }) - ).to.have.members(['/blog', '/blog/en_US', '/blog/es']); + ).to.have.members(['/blog', '/blog/en_US', '/blog/es', '/blog/italiano']); }); it('should retrieve the correct list of base URL with locales [format: directory, trailingSlash: always]', () => { @@ -353,7 +396,15 @@ describe('getLocaleRelativeUrlList', () => { experimental: { i18n: { defaultLocale: 'en', - locales: ['en', 'en_US', 'es'], + locales: [ + 'en', + 'en_US', + 'es', + { + path: 'italiano', + codes: ['it', 'it-VA'], + }, + ], }, }, }; @@ -366,7 +417,7 @@ describe('getLocaleRelativeUrlList', () => { trailingSlash: 'always', format: 'directory', }) - ).to.have.members(['/blog/', '/blog/en_US/', '/blog/es/']); + ).to.have.members(['/blog/', '/blog/en_US/', '/blog/es/', '/blog/italiano/']); }); it('should retrieve the correct list of base URL with locales [format: file, trailingSlash: always]', () => { @@ -507,7 +558,15 @@ describe('getLocaleAbsoluteUrl', () => { experimental: { i18n: { defaultLocale: 'en', - locales: ['en', 'en_US', 'es'], + locales: [ + 'en', + 'en_US', + 'es', + { + path: 'italiano', + codes: ['it', 'it-VA'], + }, + ], }, }, }; @@ -577,6 +636,16 @@ describe('getLocaleAbsoluteUrl', () => { site: 'https://example.com', }) ).to.throw; + expect( + getLocaleAbsoluteUrl({ + locale: 'it-VA', + base: '/blog/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'file', + site: 'https://example.com', + }) + ).to.eq('https://example.com/blog/italiano/'); }); it('should correctly return the URL without base', () => { @@ -588,7 +657,14 @@ describe('getLocaleAbsoluteUrl', () => { experimental: { i18n: { defaultLocale: 'en', - locales: ['en', 'es'], + locales: [ + 'en', + 'es', + { + path: 'italiano', + codes: ['it', 'it-VA'], + }, + ], }, }, }; @@ -613,6 +689,16 @@ describe('getLocaleAbsoluteUrl', () => { site: 'https://example.com', }) ).to.eq('https://example.com/es/'); + expect( + getLocaleAbsoluteUrl({ + locale: 'it-VT', + base: '/', + ...config.experimental.i18n, + trailingSlash: 'always', + format: 'directory', + site: 'https://example.com', + }) + ).to.eq('https://example.com/italiano/'); }); it('should correctly handle the trailing slash', () => { @@ -837,7 +923,15 @@ describe('getLocaleAbsoluteUrlList', () => { experimental: { i18n: { defaultLocale: 'en', - locales: ['en', 'en_US', 'es'], + locales: [ + 'en', + 'en_US', + 'es', + { + path: 'italiano', + codes: ['it', 'it-VA'], + }, + ], }, }, }; @@ -855,6 +949,7 @@ describe('getLocaleAbsoluteUrlList', () => { 'https://example.com/blog', 'https://example.com/blog/en_US', 'https://example.com/blog/es', + 'https://example.com/blog/italiano', ]); }); @@ -897,7 +992,15 @@ describe('getLocaleAbsoluteUrlList', () => { experimental: { i18n: { defaultLocale: 'en', - locales: ['en', 'en_US', 'es'], + locales: [ + 'en', + 'en_US', + 'es', + { + path: 'italiano', + codes: ['it', 'it-VA'], + }, + ], }, }, }; @@ -915,6 +1018,7 @@ describe('getLocaleAbsoluteUrlList', () => { 'https://example.com/blog/', 'https://example.com/blog/en_US/', 'https://example.com/blog/es/', + 'https://example.com/blog/italiano/', ]); }); From 3605237d16a7d040ee279ac81ab791d472715cd0 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Mon, 27 Nov 2023 13:38:13 -0500 Subject: [PATCH 03/14] feat: expose new function to retrieve the path by locale --- packages/astro/client.d.ts | 63 +++++++++++++++++++ packages/astro/src/core/render/context.ts | 46 +++++++++----- packages/astro/src/i18n/index.ts | 63 ++++++++++++++----- packages/astro/src/i18n/middleware.ts | 14 ++--- packages/astro/src/i18n/vite-plugin-i18n.ts | 6 +- .../src/pages/virtual-module.astro | 7 ++- packages/astro/test/i18n-routing.test.js | 2 + 7 files changed, 159 insertions(+), 42 deletions(-) diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts index dfcffbee378c..e4ecc296bc01 100644 --- a/packages/astro/client.d.ts +++ b/packages/astro/client.d.ts @@ -226,6 +226,69 @@ declare module 'astro:i18n' { * Works like `getAbsoluteLocaleUrl` but it emits the absolute URLs for ALL locales: */ export const getAbsoluteLocaleUrlList: (path?: string, options?: GetLocaleOptions) => string[]; + + /** + * A function that return the `path` associated to a locale (defined as code). It's particularly useful in case you decide + * to use locales that are broken down in paths and codes. + * + * @param {string} code The code of the locale + * @returns {string} The path associated to the locale + * + * ## Example + * + * ```js + * // astro.config.mjs + * + * export default defineConfig({ + * i18n: { + * locales: [ + * { codes: ["it", "it-VT"], path: "italiano" }, + * "es" + * ] + * } + * }) + * ``` + * + * ```js + * import { getPathByLocale } from "astro:i18n"; + * getPathByLocale("it"); // returns "italiano" + * getPathByLocale("it-VT"); // returns "italiano" + * getPathByLocale("es"); // returns "es" + * ``` + */ + export const getPathByLocale: (code: string) => string; + + /** + * A function that returns the preferred locale given a certain path. This is particularly useful if you configure a locale using + * `path` and `codes`. When you define multiple `code`, this function will return the first code of the array. + * + * Astro will treat the first code as the one that the user prefers. + * + * @param {string} path The path that maps to a locale + * @returns {string} The path associated to the locale + * + * ## Example + * + * ```js + * // astro.config.mjs + * + * export default defineConfig({ + * i18n: { + * locales: [ + * { codes: ["it-VT", "it"], path: "italiano" }, + * "es" + * ] + * } + * }) + * ``` + * + * ```js + * import { getLocaleByPath } from "astro:i18n"; + * getLocaleByPath("italiano"); // returns "it-VT" because that's the first code configured + * getLocaleByPath("es"); // returns "es" + * ``` + */ + export const getLocaleByPath: (path: string) => string; } declare module 'astro:middleware' { diff --git a/packages/astro/src/core/render/context.ts b/packages/astro/src/core/render/context.ts index 0f0bf39b0465..800449abb9a9 100644 --- a/packages/astro/src/core/render/context.ts +++ b/packages/astro/src/core/render/context.ts @@ -1,12 +1,13 @@ import type { ComponentInstance, + Locales, Params, Props, RouteData, SSRElement, SSRResult, } from '../../@types/astro.js'; -import { normalizeTheLocale } from '../../i18n/index.js'; +import { normalizeTheLocale, toCodes } from '../../i18n/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; import type { Environment } from './environment.js'; import { getParamsAndProps } from './params-and-props.js'; @@ -143,8 +144,8 @@ export function parseLocale(header: string): BrowserLocale[] { return result; } -function sortAndFilterLocales(browserLocaleList: BrowserLocale[], locales: string[]) { - const normalizedLocales = locales.map(normalizeTheLocale); +function sortAndFilterLocales(browserLocaleList: BrowserLocale[], locales: Locales) { + const normalizedLocales = toCodes(locales).map(normalizeTheLocale); return browserLocaleList .filter((browserLocale) => { if (browserLocale.locale !== '*') { @@ -170,18 +171,26 @@ function sortAndFilterLocales(browserLocaleList: BrowserLocale[], locales: strin * If multiple locales are present in the header, they are sorted by their quality value and the highest is selected as current locale. * */ -export function computePreferredLocale(request: Request, locales: string[]): string | undefined { +export function computePreferredLocale(request: Request, locales: Locales): string | undefined { const acceptHeader = request.headers.get('Accept-Language'); let result: string | undefined = undefined; if (acceptHeader) { const browserLocaleList = sortAndFilterLocales(parseLocale(acceptHeader), locales); const firstResult = browserLocaleList.at(0); - if (firstResult) { - if (firstResult.locale !== '*') { - result = locales.find( - (locale) => normalizeTheLocale(locale) === normalizeTheLocale(firstResult.locale) - ); + if (firstResult && firstResult.locale !== '*') { + for (const currentLocale of locales) { + if (typeof currentLocale === 'string') { + if (normalizeTheLocale(currentLocale) === normalizeTheLocale(firstResult.locale)) { + result = currentLocale; + } + } else { + for (const currentCode of currentLocale.codes) { + if (normalizeTheLocale(currentCode) === normalizeTheLocale(firstResult.locale)) { + result = currentCode; + } + } + } } } } @@ -189,7 +198,7 @@ export function computePreferredLocale(request: Request, locales: string[]): str return result; } -export function computePreferredLocaleList(request: Request, locales: string[]) { +export function computePreferredLocaleList(request: Request, locales: Locales) { const acceptHeader = request.headers.get('Accept-Language'); let result: string[] = []; if (acceptHeader) { @@ -200,11 +209,18 @@ export function computePreferredLocaleList(request: Request, locales: string[]) return locales; } else if (browserLocaleList.length > 0) { for (const browserLocale of browserLocaleList) { - const found = locales.find( - (l) => normalizeTheLocale(l) === normalizeTheLocale(browserLocale.locale) - ); - if (found) { - result.push(found); + for (const loopLocale of locales) { + if (typeof loopLocale === 'string') { + if (normalizeTheLocale(loopLocale) === normalizeTheLocale(browserLocale.locale)) { + result.push(loopLocale); + } + } else { + for (const code of loopLocale.codes) { + if (code === browserLocale.locale) { + result.push(loopLocale.path); + } + } + } } } } diff --git a/packages/astro/src/i18n/index.ts b/packages/astro/src/i18n/index.ts index 8a7c99e79ad4..1370087bcc6f 100644 --- a/packages/astro/src/i18n/index.ts +++ b/packages/astro/src/i18n/index.ts @@ -103,7 +103,7 @@ export function getLocaleRelativeUrlList({ routingStrategy = 'prefix-other-locales', defaultLocale, }: GetLocalesBaseUrl) { - const locales = peekPaths(_locales); + const locales = toPaths(_locales); return locales.map((locale) => { const pathsToJoin = [base, prependWith]; const normalizedLocale = normalizeLocale ? normalizeTheLocale(locale) : locale; @@ -134,34 +134,59 @@ export function getLocaleAbsoluteUrlList({ site, ...rest }: GetLocaleAbsoluteUrl } /** - * - * Given a locale, this function: - * - replaces the `_` with a `-`; - * - transforms all letters to be lower case; + * Given a locale (code), it returns its corresponding path + * @param locale + * @param locales */ -export function normalizeTheLocale(locale: string): string { - return locale.replaceAll('_', '-').toLowerCase(); +export function getPathByLocale(locale: string, locales: Locales) { + for (const loopLocale of locales) { + if (typeof loopLocale === 'string') { + if (loopLocale === locale) { + return loopLocale; + } + } else { + for (const code of loopLocale.codes) { + if (code === locale) { + return loopLocale.path; + } + } + } + } } /** - * Returns an array of only locales, by picking the `path` + * An utility function that retrieves the preferred locale that correspond to a path. + * + * @param locale + * @param locales */ -export function toPathLocales(locales: Locales): string[] { - const codes: string[] = []; +export function getLocaleByPath(path: string, locales: Locales): string | undefined { for (const locale of locales) { - if (typeof locale === 'string') { - codes.push(locale); - } else { - codes.push(...locale.codes); + if (typeof locale !== 'string') { + // the first code is the one that user usually wants + const code = locale.codes.at(0); + return code; } + 1; } - return codes; + return undefined; +} + +/** + * + * Given a locale, this function: + * - replaces the `_` with a `-`; + * - transforms all letters to be lower case; + */ +export function normalizeTheLocale(locale: string): string { + return locale.replaceAll('_', '-').toLowerCase(); } /** * Returns an array of only locales, by picking the `code` + * @param locales */ -export function toCodeLocales(locales: Locales): string[] { +export function toCodes(locales: Locales): string[] { const codes: string[] = []; for (const locale of locales) { if (typeof locale === 'string') { @@ -175,7 +200,11 @@ export function toCodeLocales(locales: Locales): string[] { return codes; } -function peekPaths(locales: Locales): string[] { +/** + * It returns the array of paths + * @param locales + */ +export function toPaths(locales: Locales): string[] { return locales.map((loopLocale) => { if (typeof loopLocale === 'string') { return loopLocale; diff --git a/packages/astro/src/i18n/middleware.ts b/packages/astro/src/i18n/middleware.ts index f2edfe35f3cb..83ac7c34acec 100644 --- a/packages/astro/src/i18n/middleware.ts +++ b/packages/astro/src/i18n/middleware.ts @@ -6,7 +6,7 @@ import type { SSRManifest, } from '../@types/astro.js'; import type { PipelineHookFunction } from '../core/pipeline.js'; -import { normalizeTheLocale } from './index.js'; +import { getPathByLocale, normalizeTheLocale } from './index.js'; const routeDataSymbol = Symbol.for('astro.routeData'); @@ -19,10 +19,8 @@ function pathnameHasLocale(pathname: string, locales: Locales): boolean { if (normalizeTheLocale(segment) === normalizeTheLocale(locale)) { return true; } - } else { - if (segment === locale.path) { - return true; - } + } else if (segment === locale.path) { + return true; } } } @@ -105,13 +103,15 @@ export function createI18nMiddleware( if (urlLocale && fallbackKeys.includes(urlLocale)) { const fallbackLocale = fallback[urlLocale]; + // the user might have configured the locale using the granular locales, so we want to retrieve its corresponding path instead + const pathFallbackLocale = getPathByLocale(fallbackLocale, locales); let newPathname: string; // If a locale falls back to the default locale, we want to **remove** the locale because // the default locale doesn't have a prefix - if (fallbackLocale === defaultLocale) { + if (pathFallbackLocale === defaultLocale) { newPathname = url.pathname.replace(`/${urlLocale}`, ``); } else { - newPathname = url.pathname.replace(`/${urlLocale}`, `/${fallbackLocale}`); + newPathname = url.pathname.replace(`/${urlLocale}`, `/${pathFallbackLocale}`); } return context.redirect(newPathname); diff --git a/packages/astro/src/i18n/vite-plugin-i18n.ts b/packages/astro/src/i18n/vite-plugin-i18n.ts index 4aa6ee42e4f5..21eddad5c902 100644 --- a/packages/astro/src/i18n/vite-plugin-i18n.ts +++ b/packages/astro/src/i18n/vite-plugin-i18n.ts @@ -27,7 +27,8 @@ export default function astroInternationalization({ getLocaleRelativeUrlList as _getLocaleRelativeUrlList, getLocaleAbsoluteUrl as _getLocaleAbsoluteUrl, getLocaleAbsoluteUrlList as _getLocaleAbsoluteUrlList, - + getPathByLocale as _getPathByLocale, + getLocaleByPath as _getLocaleByPath, } from "astro/i18n"; const base = ${JSON.stringify(settings.config.base)}; @@ -59,6 +60,9 @@ export default function astroInternationalization({ export const getRelativeLocaleUrlList = (path = "", opts) => _getLocaleRelativeUrlList({ base, path, trailingSlash, format, ...i18n, ...opts }); export const getAbsoluteLocaleUrlList = (path = "", opts) => _getLocaleAbsoluteUrlList({ base, path, trailingSlash, format, site, ...i18n, ...opts }); + + export const getPathByLocale = (locale) => _getPathByLocale(locale, i18n.locales); + export const getLocaleByPath = (locale) => _getLocaleByPath(locale, i18n.locales); `; } }, diff --git a/packages/astro/test/fixtures/i18n-routing/src/pages/virtual-module.astro b/packages/astro/test/fixtures/i18n-routing/src/pages/virtual-module.astro index 1f2b46f2f408..ca33030dbed0 100644 --- a/packages/astro/test/fixtures/i18n-routing/src/pages/virtual-module.astro +++ b/packages/astro/test/fixtures/i18n-routing/src/pages/virtual-module.astro @@ -1,9 +1,10 @@ --- -import { getRelativeLocaleUrl } from "astro:i18n"; +import { getRelativeLocaleUrl, getPathByLocale, getLocaleByPath } from "astro:i18n"; let about = getRelativeLocaleUrl("pt", "about"); let spanish = getRelativeLocaleUrl("es", "about"); - +let spainPath = getPathByLocale("es-SP"); +let localeByPath = getLocaleByPath("spanish"); --- @@ -15,5 +16,7 @@ let spanish = getRelativeLocaleUrl("es", "about"); About: {about} About spanish: {spanish} + Spain path: {spainPath} + Preferred path: {localeByPath} diff --git a/packages/astro/test/i18n-routing.test.js b/packages/astro/test/i18n-routing.test.js index b7ec328851f9..7d02df0b9932 100644 --- a/packages/astro/test/i18n-routing.test.js +++ b/packages/astro/test/i18n-routing.test.js @@ -27,6 +27,8 @@ describe('astro:i18n virtual module', () => { expect(text).includes("Virtual module doesn't break"); expect(text).includes('About: /pt/about'); expect(text).includes('About spanish: /spanish/about'); + expect(text).includes('Spain path: spanish'); + expect(text).includes('Preferred path: es'); }); }); describe('[DEV] i18n routing', () => { From 378b8399a87ccafce4324827d8cd66f685f51a51 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Mon, 27 Nov 2023 16:19:32 -0500 Subject: [PATCH 04/14] chore: update fallback logic --- .../astro/src/core/routing/manifest/create.ts | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/manifest/create.ts index 44482fdcbbc9..edf649e8fc36 100644 --- a/packages/astro/src/core/routing/manifest/create.ts +++ b/packages/astro/src/core/routing/manifest/create.ts @@ -17,6 +17,7 @@ import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from '../../constants.js'; import { removeLeadingForwardSlash, slash } from '../../path.js'; import { resolvePages } from '../../util.js'; import { getRouteGenerator } from './generator.js'; +import { getPathByLocale } from '../../../i18n/index.js'; const require = createRequire(import.meta.url); interface Item { @@ -502,7 +503,21 @@ export function createRouteManifest( // First loop // We loop over the locales minus the default locale and add only the routes that contain `/`. - for (const locale of i18n.locales.filter((loc) => loc !== i18n.defaultLocale)) { + const filteredLocales = i18n.locales + .filter((loc) => { + if (typeof loc === 'string') { + return loc !== i18n.defaultLocale; + } else { + return loc.path !== i18n.defaultLocale; + } + }) + .map((locale) => { + if (typeof locale === 'string') { + return locale; + } + return locale.path; + }); + for (const locale of filteredLocales) { for (const route of setRoutes) { if (!route.route.includes(`/${locale}`)) { continue; @@ -569,8 +584,12 @@ export function createRouteManifest( let fallback = Object.entries(i18n.fallback); if (fallback.length > 0) { - for (const [fallbackFromLocale, fallbackToLocale] of fallback) { + for (const [_fallbackFromLocale, fallbackToLocale] of fallback) { let fallbackToRoutes; + const fallbackFromLocale = getPathByLocale(_fallbackFromLocale, i18n.locales); + if (!fallbackFromLocale) { + continue; + } if (fallbackToLocale === i18n.defaultLocale) { fallbackToRoutes = routesByLocale.get(i18n.defaultLocale); } else { From bc5a0b5964b3497b02aada76d5a29789bf469694 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Tue, 28 Nov 2023 11:31:27 -0500 Subject: [PATCH 05/14] chore: fix other broken cases inside source code --- packages/astro/src/core/endpoint/index.ts | 3 ++- packages/astro/src/core/render/context.ts | 25 +++++++++++++++++------ packages/astro/src/core/render/result.ts | 3 ++- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/astro/src/core/endpoint/index.ts b/packages/astro/src/core/endpoint/index.ts index 80af2358d13b..43d87a7c798a 100644 --- a/packages/astro/src/core/endpoint/index.ts +++ b/packages/astro/src/core/endpoint/index.ts @@ -3,6 +3,7 @@ import type { APIContext, EndpointHandler, EndpointOutput, + Locales, MiddlewareEndpointHandler, MiddlewareHandler, Params, @@ -30,7 +31,7 @@ type CreateAPIContext = { site?: string; props: Record; adapterName?: string; - locales: string[] | undefined; + locales: Locales | undefined; routingStrategy: 'prefix-always' | 'prefix-other-locales' | undefined; defaultLocale: string | undefined; }; diff --git a/packages/astro/src/core/render/context.ts b/packages/astro/src/core/render/context.ts index 800449abb9a9..cb90d46a60ed 100644 --- a/packages/astro/src/core/render/context.ts +++ b/packages/astro/src/core/render/context.ts @@ -29,7 +29,7 @@ export interface RenderContext { params: Params; props: Props; locals?: object; - locales: string[] | undefined; + locales: Locales | undefined; defaultLocale: string | undefined; routingStrategy: 'prefix-always' | 'prefix-other-locales' | undefined; } @@ -198,7 +198,7 @@ export function computePreferredLocale(request: Request, locales: Locales): stri return result; } -export function computePreferredLocaleList(request: Request, locales: Locales) { +export function computePreferredLocaleList(request: Request, locales: Locales): string[] { const acceptHeader = request.headers.get('Accept-Language'); let result: string[] = []; if (acceptHeader) { @@ -206,7 +206,14 @@ export function computePreferredLocaleList(request: Request, locales: Locales) { // SAFETY: bang operator is safe because checked by the previous condition if (browserLocaleList.length === 1 && browserLocaleList.at(0)!.locale === '*') { - return locales; + return locales.map((locale) => { + if (typeof locale === 'string') { + return locale; + } else { + // SAFETY: codes is never empty + return locale.codes.at(0)!; + } + }); } else if (browserLocaleList.length > 0) { for (const browserLocale of browserLocaleList) { for (const loopLocale of locales) { @@ -231,15 +238,21 @@ export function computePreferredLocaleList(request: Request, locales: Locales) { export function computeCurrentLocale( request: Request, - locales: string[], + locales: Locales, routingStrategy: 'prefix-always' | 'prefix-other-locales' | undefined, defaultLocale: string | undefined ): undefined | string { const requestUrl = new URL(request.url); for (const segment of requestUrl.pathname.split('/')) { for (const locale of locales) { - if (normalizeTheLocale(locale) === normalizeTheLocale(segment)) { - return locale; + if (typeof locale === 'string') { + if (normalizeTheLocale(locale) === normalizeTheLocale(segment)) { + return locale; + } + } else { + if (locale.path === segment) { + return locale.codes.at(0); + } } } } diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts index e9c8302a1ed7..0c65b0c8da3d 100644 --- a/packages/astro/src/core/render/result.ts +++ b/packages/astro/src/core/render/result.ts @@ -1,6 +1,7 @@ import type { AstroGlobal, AstroGlobalPartial, + Locales, Params, SSRElement, SSRLoadedRenderer, @@ -50,7 +51,7 @@ export interface CreateResultArgs { status: number; locals: App.Locals; cookies?: AstroCookies; - locales: string[] | undefined; + locales: Locales | undefined; defaultLocale: string | undefined; routingStrategy: 'prefix-always' | 'prefix-other-locales' | undefined; } From 098a67657230397c6361e7516df34826e93aa9c8 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Tue, 28 Nov 2023 15:47:58 -0500 Subject: [PATCH 06/14] chore: add new test cases --- packages/astro/src/core/render/context.ts | 2 +- .../astro/src/core/routing/manifest/create.ts | 13 +- .../src/vite-plugin-astro-server/route.ts | 17 +- .../i18n-routing-base/astro.config.mjs | 5 +- .../src/pages/spanish/blog/[id].astro | 18 ++ .../src/pages/spanish/start.astro | 12 + .../astro.config.mjs | 5 +- .../src/pages/spanish/blog/[id].astro | 18 ++ .../src/pages/spanish/blog/[id].astro | 18 ++ .../src/pages/spanish/start.astro | 12 + packages/astro/test/i18n-routing.test.js | 205 +++++++++++++++++- .../test/units/config/config-validate.test.js | 8 +- .../astro/test/units/i18n/astro_i18n.test.js | 2 +- 13 files changed, 316 insertions(+), 19 deletions(-) create mode 100644 packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/blog/[id].astro create mode 100644 packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/start.astro create mode 100644 packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/spanish/blog/[id].astro create mode 100644 packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/spanish/blog/[id].astro create mode 100644 packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/spanish/start.astro diff --git a/packages/astro/src/core/render/context.ts b/packages/astro/src/core/render/context.ts index cb90d46a60ed..93bd9c42c9db 100644 --- a/packages/astro/src/core/render/context.ts +++ b/packages/astro/src/core/render/context.ts @@ -187,7 +187,7 @@ export function computePreferredLocale(request: Request, locales: Locales): stri } else { for (const currentCode of currentLocale.codes) { if (normalizeTheLocale(currentCode) === normalizeTheLocale(firstResult.locale)) { - result = currentCode; + result = currentLocale.path; } } } diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/manifest/create.ts index edf649e8fc36..3d6cd25ee032 100644 --- a/packages/astro/src/core/routing/manifest/create.ts +++ b/packages/astro/src/core/routing/manifest/create.ts @@ -535,6 +535,9 @@ export function createRouteManifest( // we loop over the remaining routes and add them to the default locale for (const route of setRoutes) { + if (route.type !== 'page') { + continue; + } const currentRoutes = routesByLocale.get(i18n.defaultLocale); if (currentRoutes) { currentRoutes.push(route); @@ -584,12 +587,12 @@ export function createRouteManifest( let fallback = Object.entries(i18n.fallback); if (fallback.length > 0) { - for (const [_fallbackFromLocale, fallbackToLocale] of fallback) { + for (const [fallbackFromLocale, fallbackToLocale] of fallback) { let fallbackToRoutes; - const fallbackFromLocale = getPathByLocale(_fallbackFromLocale, i18n.locales); - if (!fallbackFromLocale) { - continue; - } + // const fallbackFromLocale = getPathByLocale(_fallbackFromLocale, i18n.locales); + // if (!fallbackFromLocale) { + // continue; + // } if (fallbackToLocale === i18n.defaultLocale) { fallbackToRoutes = routesByLocale.get(i18n.defaultLocale); } else { diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts index e7f8fd1e4c47..a49509366a0a 100644 --- a/packages/astro/src/vite-plugin-astro-server/route.ts +++ b/packages/astro/src/vite-plugin-astro-server/route.ts @@ -31,6 +31,7 @@ import { preload } from './index.js'; import { getComponentMetadata } from './metadata.js'; import { handle404Response, writeSSRResult, writeWebResponse } from './response.js'; import { getScriptsForURL } from './scripts.js'; +import { normalizeTheLocale } from '../i18n/index.js'; const clientLocalsSymbol = Symbol.for('astro.locals'); @@ -185,7 +186,21 @@ export async function handleRoute({ .split('/') .filter(Boolean) .some((segment) => { - return locales.includes(segment); + let found = false; + for (const locale of locales) { + if (typeof locale === 'string') { + if (normalizeTheLocale(locale) === normalizeTheLocale(segment)) { + found = true; + break; + } + } else { + if (locale.path === segment) { + found = true; + break; + } + } + } + return found; }); // Even when we have `config.base`, the pathname is still `/` because it gets stripped before if (!pathNameHasLocale && pathname !== '/') { diff --git a/packages/astro/test/fixtures/i18n-routing-base/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing-base/astro.config.mjs index d20245efb010..5a67108d5045 100644 --- a/packages/astro/test/fixtures/i18n-routing-base/astro.config.mjs +++ b/packages/astro/test/fixtures/i18n-routing-base/astro.config.mjs @@ -6,7 +6,10 @@ export default defineConfig({ i18n: { defaultLocale: 'en', locales: [ - 'en', 'pt', 'it' + 'en', 'pt', 'it', { + path: "spanish", + codes: ["es", "es-ar"] + } ], routingStrategy: "prefix-always" } diff --git a/packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/blog/[id].astro new file mode 100644 index 000000000000..f560f94f5ade --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/blog/[id].astro @@ -0,0 +1,18 @@ +--- +export function getStaticPaths() { + return [ + {params: {id: '1'}, props: { content: "Lo siento" }}, + {params: {id: '2'}, props: { content: "Eat Something" }}, + {params: {id: '3'}, props: { content: "How are you?" }}, + ]; +} +const { content } = Astro.props; +--- + + + Astro + + +{content} + + diff --git a/packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/start.astro b/packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/start.astro new file mode 100644 index 000000000000..d67e9de3f085 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-base/src/pages/spanish/start.astro @@ -0,0 +1,12 @@ +--- +const currentLocale = Astro.currentLocale; +--- + + + Astro + + +Espanol +Current Locale: {currentLocale ? currentLocale : "none"} + + diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-always/astro.config.mjs b/packages/astro/test/fixtures/i18n-routing-prefix-always/astro.config.mjs index f2aac5899cbb..6eaed3e38ddd 100644 --- a/packages/astro/test/fixtures/i18n-routing-prefix-always/astro.config.mjs +++ b/packages/astro/test/fixtures/i18n-routing-prefix-always/astro.config.mjs @@ -5,7 +5,10 @@ export default defineConfig({ i18n: { defaultLocale: 'en', locales: [ - 'en', 'pt', 'it' + 'en', 'pt', 'it', { + path: "spanish", + codes: ["es", "es-ar"] + } ], routingStrategy: "prefix-always" } diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/spanish/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/spanish/blog/[id].astro new file mode 100644 index 000000000000..f560f94f5ade --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/spanish/blog/[id].astro @@ -0,0 +1,18 @@ +--- +export function getStaticPaths() { + return [ + {params: {id: '1'}, props: { content: "Lo siento" }}, + {params: {id: '2'}, props: { content: "Eat Something" }}, + {params: {id: '3'}, props: { content: "How are you?" }}, + ]; +} +const { content } = Astro.props; +--- + + + Astro + + +{content} + + diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/spanish/blog/[id].astro b/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/spanish/blog/[id].astro new file mode 100644 index 000000000000..f560f94f5ade --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/spanish/blog/[id].astro @@ -0,0 +1,18 @@ +--- +export function getStaticPaths() { + return [ + {params: {id: '1'}, props: { content: "Lo siento" }}, + {params: {id: '2'}, props: { content: "Eat Something" }}, + {params: {id: '3'}, props: { content: "How are you?" }}, + ]; +} +const { content } = Astro.props; +--- + + + Astro + + +{content} + + diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/spanish/start.astro b/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/spanish/start.astro new file mode 100644 index 000000000000..d67e9de3f085 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing-prefix-other-locales/src/pages/spanish/start.astro @@ -0,0 +1,12 @@ +--- +const currentLocale = Astro.currentLocale; +--- + + + Astro + + +Espanol +Current Locale: {currentLocale ? currentLocale : "none"} + + diff --git a/packages/astro/test/i18n-routing.test.js b/packages/astro/test/i18n-routing.test.js index 7d02df0b9932..ce83fa913dbd 100644 --- a/packages/astro/test/i18n-routing.test.js +++ b/packages/astro/test/i18n-routing.test.js @@ -69,6 +69,16 @@ describe('[DEV] i18n routing', () => { expect(await response2.text()).includes('Hola mundo'); }); + it('should render localised page correctly when using path+codes', async () => { + const response = await fixture.fetch('/spanish/start'); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Espanol'); + + const response2 = await fixture.fetch('/spanish/blog/1'); + expect(response2.status).to.equal(200); + expect(await response2.text()).includes('Lo siento'); + }); + it("should NOT render the default locale if there isn't a fallback and the route is missing", async () => { const response = await fixture.fetch('/it/start'); expect(response.status).to.equal(404); @@ -117,6 +127,16 @@ describe('[DEV] i18n routing', () => { expect(await response2.text()).includes('Hola mundo'); }); + it('should render localised page correctly when using path+codes', async () => { + const response = await fixture.fetch('/new-site/spanish/start'); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Espanol'); + + const response2 = await fixture.fetch('/new-site/spanish/blog/1'); + expect(response2.status).to.equal(200); + expect(await response2.text()).includes('Lo siento'); + }); + it("should NOT render the default locale if there isn't a fallback and the route is missing", async () => { const response = await fixture.fetch('/new-site/it/start'); expect(response.status).to.equal(404); @@ -140,9 +160,18 @@ describe('[DEV] i18n routing', () => { experimental: { i18n: { defaultLocale: 'en', - locales: ['en', 'pt', 'it'], + locales: [ + 'en', + 'pt', + 'it', + { + path: 'spanish', + codes: ['es', 'es-AR'], + }, + ], fallback: { it: 'en', + spanish: 'en', }, }, }, @@ -182,6 +211,16 @@ describe('[DEV] i18n routing', () => { expect(await response2.text()).includes('Hola mundo'); }); + it('should render localised page correctly when using path+codes', async () => { + const response = await fixture.fetch('/new-site/spanish/start'); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Espanol'); + + const response2 = await fixture.fetch('/new-site/spanish/blog/1'); + expect(response2.status).to.equal(200); + expect(await response2.text()).includes('Lo siento'); + }); + it('should redirect to the english locale, which is the first fallback', async () => { const response = await fixture.fetch('/new-site/it/start'); expect(response.status).to.equal(200); @@ -247,6 +286,16 @@ describe('[DEV] i18n routing', () => { expect(await response2.text()).includes('Hola mundo'); }); + it('should render localised page correctly when using path+codes', async () => { + const response = await fixture.fetch('/new-site/spanish/start'); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Espanol'); + + const response2 = await fixture.fetch('/new-site/spanish/blog/1'); + expect(response2.status).to.equal(200); + expect(await response2.text()).includes('Lo siento'); + }); + it('should not redirect to the english locale', async () => { const response = await fixture.fetch('/new-site/it/start'); expect(response.status).to.equal(404); @@ -290,9 +339,18 @@ describe('[DEV] i18n routing', () => { experimental: { i18n: { defaultLocale: 'en', - locales: ['en', 'pt', 'it'], + locales: [ + 'en', + 'pt', + 'it', + { + path: 'spanish', + codes: ['es', 'es-AR'], + }, + ], fallback: { it: 'en', + spanish: 'en', }, routingStrategy: 'prefix-other-locales', }, @@ -325,6 +383,16 @@ describe('[DEV] i18n routing', () => { expect(await response2.text()).includes('Hola mundo'); }); + it('should render localised page correctly when using path+codes', async () => { + const response = await fixture.fetch('/new-site/spanish/start'); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Start'); + + const response2 = await fixture.fetch('/new-site/spanish/blog/1'); + expect(response2.status).to.equal(200); + expect(await response2.text()).includes('Hello world'); + }); + it('should redirect to the english locale, which is the first fallback', async () => { const response = await fixture.fetch('/new-site/it/start'); expect(response.status).to.equal(200); @@ -369,6 +437,16 @@ describe('[SSG] i18n routing', () => { expect($('body').text()).includes('Hola mundo'); }); + it('should render localised page correctly when it has codes+path', async () => { + let html = await fixture.readFile('/spanish/start/index.html'); + let $ = cheerio.load(html); + expect($('body').text()).includes('Espanol'); + + html = await fixture.readFile('/spanish/blog/1/index.html'); + $ = cheerio.load(html); + expect($('body').text()).includes('Lo siento'); + }); + it("should NOT render the default locale if there isn't a fallback and the route is missing", async () => { try { await fixture.readFile('/it/start/index.html'); @@ -423,6 +501,16 @@ describe('[SSG] i18n routing', () => { expect($('body').text()).includes('Hola mundo'); }); + it('should render localised page correctly when it has codes+path', async () => { + let html = await fixture.readFile('/spanish/start/index.html'); + let $ = cheerio.load(html); + expect($('body').text()).includes('Espanol'); + + html = await fixture.readFile('/spanish/blog/1/index.html'); + $ = cheerio.load(html); + expect($('body').text()).includes('Lo siento'); + }); + it("should NOT render the default locale if there isn't a fallback and the route is missing", async () => { try { await fixture.readFile('/it/start/index.html'); @@ -488,6 +576,16 @@ describe('[SSG] i18n routing', () => { expect($('body').text()).includes('Hola mundo'); }); + it('should render localised page correctly when it has codes+path', async () => { + let html = await fixture.readFile('/spanish/start/index.html'); + let $ = cheerio.load(html); + expect($('body').text()).includes('Espanol'); + + html = await fixture.readFile('/spanish/blog/1/index.html'); + $ = cheerio.load(html); + expect($('body').text()).includes('Lo siento'); + }); + it("should NOT render the default locale if there isn't a fallback and the route is missing", async () => { try { await fixture.readFile('/it/start/index.html'); @@ -548,6 +646,16 @@ describe('[SSG] i18n routing', () => { expect($('body').text()).includes('Hola mundo'); }); + it('should render localised page correctly when it has codes+path', async () => { + let html = await fixture.readFile('/spanish/start/index.html'); + let $ = cheerio.load(html); + expect($('body').text()).includes('Espanol'); + + html = await fixture.readFile('/spanish/blog/1/index.html'); + $ = cheerio.load(html); + expect($('body').text()).includes('Lo siento'); + }); + it("should NOT render the default locale if there isn't a fallback and the route is missing", async () => { try { await fixture.readFile('/it/start/index.html'); @@ -596,9 +704,18 @@ describe('[SSG] i18n routing', () => { experimental: { i18n: { defaultLocale: 'en', - locales: ['en', 'pt', 'it'], + locales: [ + 'en', + 'pt', + 'it', + { + path: 'spanish', + codes: ['es', 'es-AR'], + }, + ], fallback: { it: 'en', + spanish: 'en', }, }, }, @@ -626,6 +743,13 @@ describe('[SSG] i18n routing', () => { expect($('body').text()).includes('Hola mundo'); }); + it('should redirect to the english locale correctly when it has codes+path', async () => { + let html = await fixture.readFile('/spanish/start/index.html'); + let $ = cheerio.load(html); + expect(html).to.include('http-equiv="refresh'); + expect(html).to.include('url=/new-site/start'); + }); + it('should redirect to the english locale, which is the first fallback', async () => { const html = await fixture.readFile('/it/start/index.html'); expect(html).to.include('http-equiv="refresh'); @@ -746,6 +870,13 @@ describe('[SSR] i18n routing', () => { expect(await response.text()).includes('Oi essa e start'); }); + it('should render localised page correctly when locale has codes+path', async () => { + let request = new Request('http://example.com/spanish/start'); + let response = await app.render(request); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Espanol'); + }); + it("should NOT render the default locale if there isn't a fallback and the route is missing", async () => { let request = new Request('http://example.com/it/start'); let response = await app.render(request); @@ -834,6 +965,13 @@ describe('[SSR] i18n routing', () => { expect(await response.text()).includes('Oi essa e start'); }); + it('should render localised page correctly when locale has codes+path', async () => { + let request = new Request('http://example.com/new-site/spanish/start'); + let response = await app.render(request); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Espanol'); + }); + it("should NOT render the default locale if there isn't a fallback and the route is missing", async () => { let request = new Request('http://example.com/new-site/it/start'); let response = await app.render(request); @@ -882,6 +1020,13 @@ describe('[SSR] i18n routing', () => { expect(await response.text()).includes('Oi essa e start'); }); + it('should render localised page correctly when locale has codes+path', async () => { + let request = new Request('http://example.com/spanish/start'); + let response = await app.render(request); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Espanol'); + }); + it("should NOT render the default locale if there isn't a fallback and the route is missing", async () => { let request = new Request('http://example.com/it/start'); let response = await app.render(request); @@ -927,9 +1072,18 @@ describe('[SSR] i18n routing', () => { experimental: { i18n: { defaultLocale: 'en', - locales: ['en', 'pt', 'it'], + locales: [ + 'en', + 'pt', + 'it', + { + codes: ['es', 'es-AR'], + path: 'spanish', + }, + ], fallback: { it: 'en', + spanish: 'en', }, }, }, @@ -959,6 +1113,13 @@ describe('[SSR] i18n routing', () => { expect(response.headers.get('location')).to.equal('/new-site/start'); }); + it('should redirect to the english locale when locale has codes+path', async () => { + let request = new Request('http://example.com/new-site/spanish/start'); + let response = await app.render(request); + expect(response.status).to.equal(302); + expect(response.headers.get('location')).to.equal('/new-site/start'); + }); + it("should render a 404 because the route `fr` isn't included in the list of locales of the configuration", async () => { let request = new Request('http://example.com/new-site/fr/start'); let response = await app.render(request); @@ -1058,6 +1219,42 @@ describe('[SSR] i18n routing', () => { expect(text).includes('Locale list: pt_BR, en_AU'); }); }); + + describe('in case the configured locales are granular', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-routing/', + output: 'server', + adapter: testAdapter(), + experimental: { + i18n: { + defaultLocale: 'en', + locales: [ + { + path: 'english', + codes: ['en', 'en-AU', 'pt-BR', 'es-US'], + }, + ], + }, + }, + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + }); + + it('they should be still considered when parsing the Accept-Language header', async () => { + let request = new Request('http://example.com/preferred-locale', { + headers: { + 'Accept-Language': 'en-AU;q=0.1,pt-BR;q=0.9', + }, + }); + let response = await app.render(request); + const text = await response.text(); + expect(response.status).to.equal(200); + expect(text).includes('Locale: english'); + expect(text).includes('Locale list: english'); + }); + }); }); describe('current locale', () => { diff --git a/packages/astro/test/units/config/config-validate.test.js b/packages/astro/test/units/config/config-validate.test.js index abe40c6d15fd..f759be9b92b5 100644 --- a/packages/astro/test/units/config/config-validate.test.js +++ b/packages/astro/test/units/config/config-validate.test.js @@ -97,7 +97,7 @@ describe('Config Validation', () => { ); }); - it('errors if the default locale is not in path', async () => { + it('errors if codes are empty', async () => { const configError = await validateConfig( { experimental: { @@ -116,9 +116,7 @@ describe('Config Validation', () => { process.cwd() ).catch((err) => err); expect(configError instanceof z.ZodError).to.equal(true); - expect(configError.errors[0].message).to.equal( - 'The default locale `uk` is not present in the `i18n.locales` array.' - ); + expect(configError.errors[0].message).to.equal('Array must contain at least 1 element(s)'); }); it('errors if the default locale is not in path', async () => { @@ -131,7 +129,7 @@ describe('Config Validation', () => { 'es', { path: 'something', - codes: ['en'], + codes: ['en-UK'], }, ], }, diff --git a/packages/astro/test/units/i18n/astro_i18n.test.js b/packages/astro/test/units/i18n/astro_i18n.test.js index 40f6153764b6..126883d54d58 100644 --- a/packages/astro/test/units/i18n/astro_i18n.test.js +++ b/packages/astro/test/units/i18n/astro_i18n.test.js @@ -691,7 +691,7 @@ describe('getLocaleAbsoluteUrl', () => { ).to.eq('https://example.com/es/'); expect( getLocaleAbsoluteUrl({ - locale: 'it-VT', + locale: 'it-VA', base: '/', ...config.experimental.i18n, trailingSlash: 'always', From ca1a87e0b34508df30f8b343b5221ab5ee979421 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Tue, 28 Nov 2023 15:59:30 -0500 Subject: [PATCH 07/14] maybe fix the type for codegen --- packages/astro/src/@types/astro.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 8ea2957291fb..d0bb66fa1c0e 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1473,7 +1473,7 @@ export interface AstroUserConfig { * @docs * @kind h4 * @name experimental.i18n.locales - * @type {(string | { path: string; codes: string[] })[]} + * @type {Locales} * @version 3.5.0 * @description * From fdeef290e6712e46838d28ac44ab1f726f3bf903 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Tue, 28 Nov 2023 16:32:36 -0500 Subject: [PATCH 08/14] changelog --- .changeset/fluffy-dolls-sleep.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .changeset/fluffy-dolls-sleep.md diff --git a/.changeset/fluffy-dolls-sleep.md b/.changeset/fluffy-dolls-sleep.md new file mode 100644 index 000000000000..2cf69d30a071 --- /dev/null +++ b/.changeset/fluffy-dolls-sleep.md @@ -0,0 +1,24 @@ +--- +'astro': minor +--- + +Add a new way to configure the `i18n.locales` array. + +Developers can now assign a custom path that can span multiple language codes: + +```js +// astro.config.mjs +export default defineConfig({ + experimental: { + i18n: { + defaultLocale: "english", + locales: [ + { path: "ensligh", codes: ["en", "en-US"]} + ], + routingStrategy: "prefix-always" + } + } +}) +``` + +With this setting, the URL of the default locale will be start with `/english`. When computing `Astro.preferredLocale`, Astro will use the `codes`. From df66b06cfbf2ffbc15f304c6527341549f8b75f1 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Wed, 29 Nov 2023 09:12:32 -0500 Subject: [PATCH 09/14] Apply suggestions from code review Co-authored-by: Happydev <81974850+MoustaphaDev@users.noreply.github.com> --- .changeset/fluffy-dolls-sleep.md | 4 ++-- packages/astro/src/@types/astro.ts | 2 +- packages/astro/src/i18n/middleware.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/fluffy-dolls-sleep.md b/.changeset/fluffy-dolls-sleep.md index 2cf69d30a071..65c1ee8d2c0b 100644 --- a/.changeset/fluffy-dolls-sleep.md +++ b/.changeset/fluffy-dolls-sleep.md @@ -2,7 +2,7 @@ 'astro': minor --- -Add a new way to configure the `i18n.locales` array. +Adds a new way to configure the `i18n.locales` array. Developers can now assign a custom path that can span multiple language codes: @@ -13,7 +13,7 @@ export default defineConfig({ i18n: { defaultLocale: "english", locales: [ - { path: "ensligh", codes: ["en", "en-US"]} + { path: "english", codes: ["en", "en-US"]} ], routingStrategy: "prefix-always" } diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index d0bb66fa1c0e..3615d01b66b2 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1481,7 +1481,7 @@ export interface AstroUserConfig { * * No particular language format or syntax is enforced, but your folder structure must match exactly the locales in the list. */ - locales: (string | { path: string; codes: string[] })[]; + locales: Locales; /** * @docs diff --git a/packages/astro/src/i18n/middleware.ts b/packages/astro/src/i18n/middleware.ts index 83ac7c34acec..a69488a656e1 100644 --- a/packages/astro/src/i18n/middleware.ts +++ b/packages/astro/src/i18n/middleware.ts @@ -10,7 +10,7 @@ import { getPathByLocale, normalizeTheLocale } from './index.js'; const routeDataSymbol = Symbol.for('astro.routeData'); -// Checks if the pathname doesn't have any locale, exception for the defaultLocale, which is ignored on purpose. +// Checks if the pathname has any locale, exception for the defaultLocale, which is ignored on purpose. function pathnameHasLocale(pathname: string, locales: Locales): boolean { const segments = pathname.split('/'); for (const segment of segments) { From ec971bd8f108aa0da13155b00237162e3bdeb581 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Wed, 29 Nov 2023 09:12:41 -0500 Subject: [PATCH 10/14] chore: apply suggestions --- .../astro/src/core/routing/manifest/create.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/manifest/create.ts index 3d6cd25ee032..a6c18bfcbaa9 100644 --- a/packages/astro/src/core/routing/manifest/create.ts +++ b/packages/astro/src/core/routing/manifest/create.ts @@ -499,7 +499,11 @@ export function createRouteManifest( const routesByLocale = new Map(); // This type is here only as a helper. We copy the routes and make them unique, so we don't "process" the same route twice. // The assumption is that a route in the file system belongs to only one locale. - const setRoutes = new Set(routes); + const setRoutes = new Set( + routes.filter((route) => { + return route.type === 'page'; + }) + ); // First loop // We loop over the locales minus the default locale and add only the routes that contain `/`. @@ -507,9 +511,8 @@ export function createRouteManifest( .filter((loc) => { if (typeof loc === 'string') { return loc !== i18n.defaultLocale; - } else { - return loc.path !== i18n.defaultLocale; } + return loc.path !== i18n.defaultLocale; }) .map((locale) => { if (typeof locale === 'string') { @@ -535,9 +538,6 @@ export function createRouteManifest( // we loop over the remaining routes and add them to the default locale for (const route of setRoutes) { - if (route.type !== 'page') { - continue; - } const currentRoutes = routesByLocale.get(i18n.defaultLocale); if (currentRoutes) { currentRoutes.push(route); @@ -589,10 +589,6 @@ export function createRouteManifest( if (fallback.length > 0) { for (const [fallbackFromLocale, fallbackToLocale] of fallback) { let fallbackToRoutes; - // const fallbackFromLocale = getPathByLocale(_fallbackFromLocale, i18n.locales); - // if (!fallbackFromLocale) { - // continue; - // } if (fallbackToLocale === i18n.defaultLocale) { fallbackToRoutes = routesByLocale.get(i18n.defaultLocale); } else { From d5c27e1940eb49574f9c329b2b02a595e895dac9 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Wed, 29 Nov 2023 10:41:03 -0500 Subject: [PATCH 11/14] Apply suggestions from code review Co-authored-by: Sarah Rainsberger --- .changeset/fluffy-dolls-sleep.md | 8 +++++--- packages/astro/src/@types/astro.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.changeset/fluffy-dolls-sleep.md b/.changeset/fluffy-dolls-sleep.md index 65c1ee8d2c0b..02b698f1e492 100644 --- a/.changeset/fluffy-dolls-sleep.md +++ b/.changeset/fluffy-dolls-sleep.md @@ -4,7 +4,7 @@ Adds a new way to configure the `i18n.locales` array. -Developers can now assign a custom path that can span multiple language codes: +Developers can now assign a custom URL path prefix that can span multiple language codes: ```js // astro.config.mjs @@ -13,7 +13,9 @@ export default defineConfig({ i18n: { defaultLocale: "english", locales: [ - { path: "english", codes: ["en", "en-US"]} + "de", + { path: "english", codes: ["en", "en-US"]}, + "fr", ], routingStrategy: "prefix-always" } @@ -21,4 +23,4 @@ export default defineConfig({ }) ``` -With this setting, the URL of the default locale will be start with `/english`. When computing `Astro.preferredLocale`, Astro will use the `codes`. +With the above configuration, the URL prefix of the default locale will be `/english/`. When computing `Astro.preferredLocale`, Astro will use the `codes`. diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 3615d01b66b2..fa4b00d53250 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1477,7 +1477,7 @@ export interface AstroUserConfig { * @version 3.5.0 * @description * - * A list of all locales supported by the website (e.g. `['en', 'es', 'pt-br']`). This list should also include the `defaultLocale`. This is a required field. + * A list of all locales supported by the website, include the `defaultLocale`. This is a required field. * * No particular language format or syntax is enforced, but your folder structure must match exactly the locales in the list. */ From fd554e5d571a7d97bd1925e9d956e3074587a043 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Thu, 30 Nov 2023 09:39:29 -0500 Subject: [PATCH 12/14] Update packages/astro/src/@types/astro.ts Co-authored-by: Sarah Rainsberger --- packages/astro/src/@types/astro.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index fa4b00d53250..77bb7cf550b1 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1479,7 +1479,9 @@ export interface AstroUserConfig { * * A list of all locales supported by the website, include the `defaultLocale`. This is a required field. * - * No particular language format or syntax is enforced, but your folder structure must match exactly the locales in the list. + * Languages can be listed either as individual codes (e.g. `['en', 'es', 'pt-br']`) or mapped to a shared `path` of codes (e.g. `{ path: "english", codes: ["en", "en-US"]}`). These codes will be used to determine the URL structure of your deployed site. + * + * No particular language code format or syntax is enforced, but your project folders containing your content files must match exactly the `locales` items in the list. In the case of multiple `codes` pointing to a custom URL path prefix, store your content files in a folder with the same name as the `path` configured. */ locales: Locales; From 6d8e854ccda3159f42864db2b33e4c37b7f0846c Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Thu, 30 Nov 2023 09:39:40 -0500 Subject: [PATCH 13/14] Update packages/astro/src/@types/astro.ts Co-authored-by: Sarah Rainsberger --- packages/astro/src/@types/astro.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 77bb7cf550b1..3e52c30e5f5e 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1477,7 +1477,7 @@ export interface AstroUserConfig { * @version 3.5.0 * @description * - * A list of all locales supported by the website, include the `defaultLocale`. This is a required field. + * A list of all locales supported by the website, including the `defaultLocale`. This is a required field. * * Languages can be listed either as individual codes (e.g. `['en', 'es', 'pt-br']`) or mapped to a shared `path` of codes (e.g. `{ path: "english", codes: ["en", "en-US"]}`). These codes will be used to determine the URL structure of your deployed site. * From 89239a35e92ab290fc83f680b75694df73f36ec1 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Thu, 30 Nov 2023 15:29:50 -0500 Subject: [PATCH 14/14] fix: merge --- packages/astro/src/core/endpoint/index.ts | 4 ---- packages/astro/src/i18n/middleware.ts | 7 +------ 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/astro/src/core/endpoint/index.ts b/packages/astro/src/core/endpoint/index.ts index d5341ec7eb4c..c04c9b2b5021 100644 --- a/packages/astro/src/core/endpoint/index.ts +++ b/packages/astro/src/core/endpoint/index.ts @@ -1,11 +1,7 @@ -import type { APIContext, EndpointHandler, MiddlewareHandler, Params } from '../../@types/astro.js'; -import mime from 'mime'; import type { APIContext, EndpointHandler, - EndpointOutput, Locales, - MiddlewareEndpointHandler, MiddlewareHandler, Params, } from '../../@types/astro.js'; diff --git a/packages/astro/src/i18n/middleware.ts b/packages/astro/src/i18n/middleware.ts index ee6284e9acc6..12732d880eb0 100644 --- a/packages/astro/src/i18n/middleware.ts +++ b/packages/astro/src/i18n/middleware.ts @@ -1,10 +1,5 @@ import { appendForwardSlash, joinPaths } from '@astrojs/internal-helpers/path'; -import type { - Locales, - MiddlewareEndpointHandler, - RouteData, - SSRManifest, -} from '../@types/astro.js'; +import type { Locales, MiddlewareHandler, RouteData, SSRManifest } from '../@types/astro.js'; import type { PipelineHookFunction } from '../core/pipeline.js'; import { getPathByLocale, normalizeTheLocale } from './index.js';