diff --git a/packages/astro/src/core/build/buildPipeline.ts b/packages/astro/src/core/build/buildPipeline.ts index d38361c36c62..e81d28efe8c0 100644 --- a/packages/astro/src/core/build/buildPipeline.ts +++ b/packages/astro/src/core/build/buildPipeline.ts @@ -11,6 +11,8 @@ import { ASTRO_PAGE_RESOLVED_MODULE_ID } from './plugins/plugin-pages.js'; import { RESOLVED_SPLIT_MODULE_ID } from './plugins/plugin-ssr.js'; import { ASTRO_PAGE_EXTENSION_POST_PATTERN } from './plugins/util.js'; import type { PageBuildData, StaticBuildOptions } from './types.js'; +import { routeIsFallback, routeIsRedirect } from '../redirects/helpers.js'; +import { i18nHasFallback } from './util.js'; /** * This pipeline is responsible to gather the files emitted by the SSR build and generate the pages by executing these files. @@ -154,11 +156,17 @@ export class BuildPipeline extends Pipeline { pages.set(pageData, filePath); } } - for (const [path, pageData] of this.#internals.pagesByComponent.entries()) { - if (pageData.route.type === 'redirect') { - pages.set(pageData, path); + + for (const [path, pageDataList] of this.#internals.pagesByComponents.entries()) { + for (const pageData of pageDataList) { + if (routeIsRedirect(pageData.route)) { + pages.set(pageData, path); + } else if (routeIsFallback(pageData.route) && i18nHasFallback(this.getConfig())) { + pages.set(pageData, path); + } } } + return pages; } diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index ed6893add1e7..69480a60e4a4 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -32,7 +32,11 @@ import { runHookBuildGenerated } from '../../integrations/index.js'; import { getOutputDirectory, isServerLikeOutput } from '../../prerender/utils.js'; import { PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; -import { RedirectSinglePageBuiltModule, getRedirectLocationOrThrow } from '../redirects/index.js'; +import { + RedirectSinglePageBuiltModule, + getRedirectLocationOrThrow, + routeIsRedirect, +} from '../redirects/index.js'; import { createRenderContext } from '../render/index.js'; import { callGetStaticPaths } from '../render/route-cache.js'; import { @@ -58,6 +62,9 @@ import type { StylesheetAsset, } from './types.js'; import { getTimeStat, shouldAppendForwardSlash } from './util.js'; +import { createI18nMiddleware } from '../../i18n/middleware.js'; +import { sequence } from '../middleware/index.js'; +import { routeIsFallback } from '../redirects/helpers.js'; function createEntryURL(filePath: string, outFolder: URL) { return new URL('./' + filePath + `?time=${Date.now()}`, outFolder); @@ -83,6 +90,26 @@ async function getEntryForRedirectRoute( return RedirectSinglePageBuiltModule; } +async function getEntryForFallbackRoute( + route: RouteData, + internals: BuildInternals, + outFolder: URL +): Promise { + if (route.type !== 'fallback') { + throw new Error(`Expected a redirect route.`); + } + if (route.redirectRoute) { + const filePath = getEntryFilePathFromComponentPath(internals, route.redirectRoute.component); + if (filePath) { + const url = createEntryURL(filePath, outFolder); + const ssrEntryPage: SinglePageBuiltModule = await import(url.toString()); + return ssrEntryPage; + } + } + + return RedirectSinglePageBuiltModule; +} + function shouldSkipDraft(pageModule: ComponentInstance, settings: AstroSettings): boolean { return ( // Drafts are disabled @@ -176,16 +203,15 @@ export async function generatePages(opts: StaticBuildOptions, internals: BuildIn await generatePage(pageData, ssrEntry, builtPaths, pipeline); } } - if (pageData.route.type === 'redirect') { - const entry = await getEntryForRedirectRoute(pageData.route, internals, outFolder); - await generatePage(pageData, entry, builtPaths, pipeline); - } } } else { for (const [pageData, filePath] of pagesToGenerate) { - if (pageData.route.type === 'redirect') { + if (routeIsRedirect(pageData.route)) { const entry = await getEntryForRedirectRoute(pageData.route, internals, outFolder); await generatePage(pageData, entry, builtPaths, pipeline); + } else if (routeIsFallback(pageData.route)) { + const entry = await getEntryForFallbackRoute(pageData.route, internals, outFolder); + await generatePage(pageData, entry, builtPaths, pipeline); } else { const ssrEntryURLPage = createEntryURL(filePath, outFolder); const entry: SinglePageBuiltModule = await import(ssrEntryURLPage.toString()); @@ -257,6 +283,7 @@ async function generatePage( ) { let timeStart = performance.now(); const logger = pipeline.getLogger(); + const config = pipeline.getConfig(); const pageInfo = getPageDataByComponent(pipeline.getInternals(), pageData.route.component); // may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc. @@ -269,7 +296,16 @@ async function generatePage( const pageModulePromise = ssrEntry.page; const onRequest = ssrEntry.onRequest; - if (onRequest) { + const i18nMiddleware = createI18nMiddleware(config, logger); + if (config.experimental.i18n && i18nMiddleware) { + if (onRequest) { + pipeline.setMiddlewareFunction( + sequence(i18nMiddleware, onRequest as MiddlewareEndpointHandler) + ); + } else { + pipeline.setMiddlewareFunction(i18nMiddleware); + } + } else if (onRequest) { pipeline.setMiddlewareFunction(onRequest as MiddlewareEndpointHandler); } if (!pageModulePromise) { @@ -297,7 +333,9 @@ async function generatePage( }; const icon = - pageData.route.type === 'page' || pageData.route.type === 'redirect' + pageData.route.type === 'page' || + pageData.route.type === 'redirect' || + pageData.route.type === 'fallback' ? green('▶') : magenta('λ'); if (isRelativePath(pageData.route.component)) { diff --git a/packages/astro/src/core/build/index.ts b/packages/astro/src/core/build/index.ts index 797a29a2589e..a6bcf8b17990 100644 --- a/packages/astro/src/core/build/index.ts +++ b/packages/astro/src/core/build/index.ts @@ -198,7 +198,9 @@ class AstroBuilder { await runHookBuildDone({ config: this.settings.config, pages: pageNames, - routes: Object.values(allPages).map((pd) => pd.route), + routes: Object.values(allPages) + .flat() + .map((pageData) => pageData.route), logging: this.logger, }); diff --git a/packages/astro/src/core/build/internal.ts b/packages/astro/src/core/build/internal.ts index 55cff37f3b61..df3a7e6832f1 100644 --- a/packages/astro/src/core/build/internal.ts +++ b/packages/astro/src/core/build/internal.ts @@ -9,7 +9,8 @@ import { } from './plugins/plugin-pages.js'; import { RESOLVED_SPLIT_MODULE_ID } from './plugins/plugin-ssr.js'; import { ASTRO_PAGE_EXTENSION_POST_PATTERN } from './plugins/util.js'; -import type { PageBuildData, StylesheetAsset, ViteID } from './types.js'; +import type { AllPagesData, PageBuildData, StylesheetAsset, ViteID } from './types.js'; +import { routeIsFallback } from '../redirects/helpers.js'; export interface BuildInternals { /** @@ -37,9 +38,16 @@ export interface BuildInternals { /** * A map for page-specific information. + * // TODO: Remove in Astro 4.0 + * @deprecated */ pagesByComponent: Map; + /** + * TODO: Use this in Astro 4.0 + */ + pagesByComponents: Map; + /** * A map for page-specific output. */ @@ -112,6 +120,7 @@ export function createBuildInternals(): BuildInternals { entrySpecifierToBundleMap: new Map(), pageToBundleMap: new Map(), pagesByComponent: new Map(), + pagesByComponents: new Map(), pageOptionsByPage: new Map(), pagesByViteID: new Map(), pagesByClientOnly: new Map(), @@ -134,7 +143,16 @@ export function trackPageData( componentURL: URL ): void { pageData.moduleSpecifier = componentModuleId; - internals.pagesByComponent.set(component, pageData); + if (!routeIsFallback(pageData.route)) { + internals.pagesByComponent.set(component, pageData); + } + const list = internals.pagesByComponents.get(component); + if (list) { + list.push(pageData); + internals.pagesByComponents.set(component, list); + } else { + internals.pagesByComponents.set(component, [pageData]); + } internals.pagesByViteID.set(viteID(componentURL), pageData); } @@ -230,6 +248,14 @@ export function* eachPageData(internals: BuildInternals) { yield* internals.pagesByComponent.values(); } +export function* eachPageFromAllPages(allPages: AllPagesData): Generator<[string, PageBuildData]> { + for (const [path, list] of Object.entries(allPages)) { + for (const pageData of list) { + yield [path, pageData]; + } + } +} + export function* eachPageDataFromEntryPoint( internals: BuildInternals ): Generator<[PageBuildData, string]> { diff --git a/packages/astro/src/core/build/page-data.ts b/packages/astro/src/core/build/page-data.ts index da002a05185a..7292cb4e8f94 100644 --- a/packages/astro/src/core/build/page-data.ts +++ b/packages/astro/src/core/build/page-data.ts @@ -47,15 +47,29 @@ export async function collectPagesData( clearInterval(routeCollectionLogTimeout); }, 10000); builtPaths.add(route.pathname); - allPages[route.component] = { - component: route.component, - route, - moduleSpecifier: '', - styles: [], - propagatedStyles: new Map(), - propagatedScripts: new Map(), - hoistedScript: undefined, - }; + if (allPages[route.component]) { + allPages[route.component].push({ + component: route.component, + route, + moduleSpecifier: '', + styles: [], + propagatedStyles: new Map(), + propagatedScripts: new Map(), + hoistedScript: undefined, + }); + } else { + allPages[route.component] = [ + { + component: route.component, + route, + moduleSpecifier: '', + styles: [], + propagatedStyles: new Map(), + propagatedScripts: new Map(), + hoistedScript: undefined, + }, + ]; + } clearInterval(routeCollectionLogTimeout); if (settings.config.output === 'static') { @@ -70,18 +84,31 @@ export async function collectPagesData( continue; } // dynamic route: - allPages[route.component] = { - component: route.component, - route, - moduleSpecifier: '', - styles: [], - propagatedStyles: new Map(), - propagatedScripts: new Map(), - hoistedScript: undefined, - }; + if (allPages[route.component]) { + allPages[route.component].push({ + component: route.component, + route, + moduleSpecifier: '', + styles: [], + propagatedStyles: new Map(), + propagatedScripts: new Map(), + hoistedScript: undefined, + }); + } else { + allPages[route.component] = [ + { + component: route.component, + route, + moduleSpecifier: '', + styles: [], + propagatedStyles: new Map(), + propagatedScripts: new Map(), + hoistedScript: undefined, + }, + ]; + } } clearInterval(dataCollectionLogTimeout); - return { assets, allPages }; } diff --git a/packages/astro/src/core/build/plugins/plugin-pages.ts b/packages/astro/src/core/build/plugins/plugin-pages.ts index 00401285f1d8..20c877a54ac1 100644 --- a/packages/astro/src/core/build/plugins/plugin-pages.ts +++ b/packages/astro/src/core/build/plugins/plugin-pages.ts @@ -3,7 +3,7 @@ import type { Plugin as VitePlugin } from 'vite'; import type { AstroSettings } from '../../../@types/astro.js'; import { routeIsRedirect } from '../../redirects/index.js'; import { addRollupInput } from '../add-rollup-input.js'; -import { type BuildInternals } from '../internal.js'; +import { type BuildInternals, eachPageFromAllPages } from '../internal.js'; import type { AstroBuildPlugin } from '../plugin.js'; import type { StaticBuildOptions } from '../types.js'; import { MIDDLEWARE_MODULE_ID } from './plugin-middleware.js'; @@ -42,7 +42,7 @@ function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): V if (opts.settings.config.output === 'static') { const inputs = new Set(); - for (const [path, pageData] of Object.entries(opts.allPages)) { + for (const [path, pageData] of eachPageFromAllPages(opts.allPages)) { if (routeIsRedirect(pageData.route)) { continue; } diff --git a/packages/astro/src/core/build/plugins/plugin-ssr.ts b/packages/astro/src/core/build/plugins/plugin-ssr.ts index 1887351b17b7..7d1a1dbd351e 100644 --- a/packages/astro/src/core/build/plugins/plugin-ssr.ts +++ b/packages/astro/src/core/build/plugins/plugin-ssr.ts @@ -13,6 +13,7 @@ import { SSR_MANIFEST_VIRTUAL_MODULE_ID } from './plugin-manifest.js'; import { ASTRO_PAGE_MODULE_ID } from './plugin-pages.js'; import { RENDERERS_MODULE_ID } from './plugin-renderers.js'; import { getPathFromVirtualModulePageName, getVirtualModulePageNameFromPath } from './util.js'; +import { eachPageFromAllPages } from '../internal.js'; export const SSR_VIRTUAL_MODULE_ID = '@astrojs-ssr-virtual-entry'; export const RESOLVED_SSR_VIRTUAL_MODULE_ID = '\0' + SSR_VIRTUAL_MODULE_ID; @@ -42,7 +43,7 @@ function vitePluginSSR( let i = 0; const pageMap: string[] = []; - for (const [path, pageData] of Object.entries(allPages)) { + for (const [path, pageData] of eachPageFromAllPages(allPages)) { if (routeIsRedirect(pageData.route)) { continue; } @@ -148,7 +149,7 @@ function vitePluginSSRSplit( if (options.settings.config.build.split || functionPerRouteEnabled) { const inputs = new Set(); - for (const [path, pageData] of Object.entries(options.allPages)) { + for (const [path, pageData] of eachPageFromAllPages(options.allPages)) { if (routeIsRedirect(pageData.route)) { continue; } @@ -294,7 +295,7 @@ function storeEntryPoint( fileName: string ) { const componentPath = getPathFromVirtualModulePageName(RESOLVED_SPLIT_MODULE_ID, moduleKey); - for (const [page, pageData] of Object.entries(options.allPages)) { + for (const [page, pageData] of eachPageFromAllPages(options.allPages)) { if (componentPath == page) { const publicPath = fileURLToPath(options.settings.config.build.server); internals.entryPoints.set(pageData.route, pathToFileURL(join(publicPath, fileName))); diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index 016e245410f0..619ebe3b4330 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -30,7 +30,7 @@ import { ASTRO_PAGE_RESOLVED_MODULE_ID } from './plugins/plugin-pages.js'; import { RESOLVED_RENDERERS_MODULE_ID } from './plugins/plugin-renderers.js'; import { RESOLVED_SPLIT_MODULE_ID, RESOLVED_SSR_VIRTUAL_MODULE_ID } from './plugins/plugin-ssr.js'; import { ASTRO_PAGE_EXTENSION_POST_PATTERN } from './plugins/util.js'; -import type { PageBuildData, StaticBuildOptions } from './types.js'; +import type { StaticBuildOptions } from './types.js'; import { getTimeStat } from './util.js'; export async function viteBuild(opts: StaticBuildOptions) { @@ -46,23 +46,20 @@ export async function viteBuild(opts: StaticBuildOptions) { // The pages to be built for rendering purposes. const pageInput = new Set(); - // A map of each page .astro file, to the PageBuildData which contains information - // about that page, such as its paths. - const facadeIdToPageDataMap = new Map(); - // Build internals needed by the CSS plugin const internals = createBuildInternals(); - for (const [component, pageData] of Object.entries(allPages)) { - const astroModuleURL = new URL('./' + component, settings.config.root); - const astroModuleId = prependForwardSlash(component); + for (const [component, pageDataList] of Object.entries(allPages)) { + for (const pageData of pageDataList) { + const astroModuleURL = new URL('./' + component, settings.config.root); + const astroModuleId = prependForwardSlash(component); - // Track the page data in internals - trackPageData(internals, component, pageData, astroModuleId, astroModuleURL); + // Track the page data in internals + trackPageData(internals, component, pageData, astroModuleId, astroModuleURL); - if (!routeIsRedirect(pageData.route)) { - pageInput.add(astroModuleId); - facadeIdToPageDataMap.set(fileURLToPath(astroModuleURL), pageData); + if (!routeIsRedirect(pageData.route)) { + pageInput.add(astroModuleId); + } } } @@ -148,7 +145,10 @@ async function ssrBuild( const { allPages, settings, viteConfig } = opts; const ssr = isServerLikeOutput(settings.config); const out = getOutputDirectory(settings.config); - const routes = Object.values(allPages).map((pd) => pd.route); + const routes = Object.values(allPages) + .flat() + .map((pageData) => pageData.route); + const { lastVitePlugins, vitePlugins } = container.runBeforeHook('ssr', input); const viteBuildConfig: vite.InlineConfig = { diff --git a/packages/astro/src/core/build/types.ts b/packages/astro/src/core/build/types.ts index a51fc8d000e5..00d6ce0461bf 100644 --- a/packages/astro/src/core/build/types.ts +++ b/packages/astro/src/core/build/types.ts @@ -30,7 +30,7 @@ export interface PageBuildData { hoistedScript: { type: 'inline' | 'external'; value: string } | undefined; styles: Array<{ depth: number; order: number; sheet: StylesheetAsset }>; } -export type AllPagesData = Record; +export type AllPagesData = Record; /** Options for the static build */ export interface StaticBuildOptions { diff --git a/packages/astro/src/core/build/util.ts b/packages/astro/src/core/build/util.ts index 2449caef703d..7202a1a6b849 100644 --- a/packages/astro/src/core/build/util.ts +++ b/packages/astro/src/core/build/util.ts @@ -27,3 +27,15 @@ export function shouldAppendForwardSlash( } } } + +export function i18nHasFallback(config: AstroConfig): boolean { + if (config.experimental.i18n) { + // we have some fallback and the control is not none + return ( + Object.keys(config.experimental.i18n.fallback).length > 0 && + config.experimental.i18n.fallbackControl !== 'none' + ); + } + + return false; +} diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/manifest/create.ts index 8fd3a8d8215a..73d20da0445c 100644 --- a/packages/astro/src/core/routing/manifest/create.ts +++ b/packages/astro/src/core/routing/manifest/create.ts @@ -60,11 +60,9 @@ function getParts(part: string, file: string) { return result; } -function getPattern( - segments: RoutePart[][], - base: string, - addTrailingSlash: AstroConfig['trailingSlash'] -) { +function getPattern(segments: RoutePart[][], config: AstroConfig) { + const base = config.base; + const addTrailingSlash = config.trailingSlash; const pathname = segments .map((segment) => { if (segment.length === 1 && segment[0].spread) { @@ -327,7 +325,7 @@ export function createRouteManifest( components.push(item.file); const component = item.file; const trailingSlash = item.isPage ? settings.config.trailingSlash : 'never'; - const pattern = getPattern(segments, settings.config.base, trailingSlash); + const pattern = getPattern(segments, settings.config); const generate = getRouteGenerator(segments, trailingSlash); const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic) ? `/${segments.map((segment) => segment[0].content).join('/')}` @@ -388,7 +386,7 @@ export function createRouteManifest( const isPage = type === 'page'; const trailingSlash = isPage ? config.trailingSlash : 'never'; - const pattern = getPattern(segments, settings.config.base, trailingSlash); + const pattern = getPattern(segments, settings.config); const generate = getRouteGenerator(segments, trailingSlash); const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic) ? `/${segments.map((segment) => segment[0].content).join('/')}` @@ -435,7 +433,7 @@ export function createRouteManifest( return getParts(s, from); }); - const pattern = getPattern(segments, settings.config.base, trailingSlash); + const pattern = getPattern(segments, settings.config); const generate = getRouteGenerator(segments, trailingSlash); const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic) ? `/${segments.map((segment) => segment[0].content).join('/')}` @@ -487,6 +485,54 @@ export function createRouteManifest( routes.push(routeData); }); + if (settings.config.experimental.i18n) { + let fallback = Object.entries(settings.config.experimental.i18n.fallback); + if (fallback.length > 0) { + for (const [fallbackLocale, fallbackLocaleList] of fallback) { + for (const fallbackLocaleEntry of fallbackLocaleList) { + const fallbackToRoutes = routes.filter((r) => + r.component.includes(`/${fallbackLocaleEntry}`) + ); + const fallbackFromRoutes = routes.filter((r) => + r.component.includes(`/${fallbackLocale}`) + ); + + for (const fallbackToRoute of fallbackToRoutes) { + const hasRoute = fallbackFromRoutes.some((r) => + r.component.replace(`/${fallbackLocaleEntry}`, `/${fallbackLocale}`) + ); + + if (!hasRoute) { + const pathname = fallbackToRoute.pathname?.replace( + `/${fallbackLocaleEntry}`, + `/${fallbackLocale}` + ); + const route = fallbackToRoute.route?.replace( + `/${fallbackLocaleEntry}`, + `/${fallbackLocale}` + ); + + const segments = removeLeadingForwardSlash(route) + .split(path.posix.sep) + .filter(Boolean) + .map((s: string) => { + validateSegment(s); + return getParts(s, route); + }); + routes.push({ + ...fallbackToRoute, + pathname, + route, + pattern: getPattern(segments, config), + type: 'fallback', + }); + } + } + } + } + } + } + return { routes, }; diff --git a/packages/astro/test/dev-routing.test.js b/packages/astro/test/dev-routing.test.js index 8095b2999150..ff5f3a75db35 100644 --- a/packages/astro/test/dev-routing.test.js +++ b/packages/astro/test/dev-routing.test.js @@ -339,159 +339,4 @@ describe('Development Routing', () => { expect(await response.text()).includes('html: 1'); }); }); - - describe('i18n routing', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').DevServer} */ - let devServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/i18n-routing/', - }); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('should render the en locale', async () => { - const response = await fixture.fetch('/en/start'); - expect(response.status).to.equal(200); - expect(await response.text()).includes('Hello'); - - const response2 = await fixture.fetch('/en/blog/1'); - expect(response2.status).to.equal(200); - expect(await response2.text()).includes('Hello world'); - }); - - it('should render localised page correctly', async () => { - const response = await fixture.fetch('/pt/start'); - expect(response.status).to.equal(200); - expect(await response.text()).includes('Hola'); - - const response2 = await fixture.fetch('/pt/blog/1'); - expect(response2.status).to.equal(200); - expect(await response2.text()).includes('Hola mundo'); - }); - - 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); - }); - - it("should render a 404 because the route `fr` isn't included in the list of locales of the configuration", async () => { - const response = await fixture.fetch('/fr/start'); - expect(response.status).to.equal(404); - }); - }); - - describe('i18n routing, with base', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').DevServer} */ - let devServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/i18n-routing-base/', - }); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('should render the en locale', async () => { - const response = await fixture.fetch('/new-site/en/start'); - expect(response.status).to.equal(200); - expect(await response.text()).includes('Hello'); - - const response2 = await fixture.fetch('/new-site/en/blog/1'); - expect(response2.status).to.equal(200); - expect(await response2.text()).includes('Hello world'); - }); - - it('should render localised page correctly', async () => { - const response = await fixture.fetch('/new-site/pt/start'); - expect(response.status).to.equal(200); - expect(await response.text()).includes('Hola'); - - const response2 = await fixture.fetch('/new-site/pt/blog/1'); - expect(response2.status).to.equal(200); - expect(await response2.text()).includes('Hola mundo'); - }); - - 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); - }); - - it("should render a 404 because the route `fr` isn't included in the list of locales of the configuration", async () => { - const response = await fixture.fetch('/new-site/fr/start'); - expect(response.status).to.equal(404); - }); - }); - - describe('i18n routing with fallback [redirect]', () => { - /** @type {import('./test-utils').Fixture} */ - let fixture; - /** @type {import('./test-utils').DevServer} */ - let devServer; - - before(async () => { - fixture = await loadFixture({ - root: './fixtures/i18n-routing-fallback/', - experimental: { - i18n: { - defaultLocale: 'en', - locales: ['en', 'pt', 'it'], - fallback: { - it: ['en'], - }, - fallbackControl: 'redirect', - }, - }, - }); - devServer = await fixture.startDevServer(); - }); - - after(async () => { - await devServer.stop(); - }); - - it('should render the en locale', async () => { - const response = await fixture.fetch('/new-site/en/start'); - expect(response.status).to.equal(200); - expect(await response.text()).includes('Hello'); - - const response2 = await fixture.fetch('/new-site/en/blog/1'); - expect(response2.status).to.equal(200); - expect(await response2.text()).includes('Hello world'); - }); - - it('should render localised page correctly', async () => { - const response = await fixture.fetch('/new-site/pt/start'); - expect(response.status).to.equal(200); - expect(await response.text()).includes('Hola'); - - const response2 = await fixture.fetch('/new-site/pt/blog/1'); - expect(response2.status).to.equal(200); - expect(await response2.text()).includes('Hola mundo'); - }); - - it('should render the english locale, which is the first fallback', async () => { - const response = await fixture.fetch('/new-site/it/start'); - expect(response.status).to.equal(200); - expect(await response.text()).includes('Hello'); - }); - - it("should render a 404 because the route `fr` isn't included in the list of locales of the configuration", async () => { - const response = await fixture.fetch('/new-site/fr/start'); - expect(response.status).to.equal(404); - }); - }); }); diff --git a/packages/astro/test/i18-routing.test.js b/packages/astro/test/i18-routing.test.js new file mode 100644 index 000000000000..01c101365037 --- /dev/null +++ b/packages/astro/test/i18-routing.test.js @@ -0,0 +1,330 @@ +import { loadFixture } from './test-utils.js'; +import { expect } from 'chai'; +import * as cheerio from 'cheerio'; + +describe('[DEV] i18n routing', () => { + describe('i18n routing', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + /** @type {import('./test-utils').DevServer} */ + let devServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-routing/', + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('should render the en locale', async () => { + const response = await fixture.fetch('/en/start'); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Hello'); + + const response2 = await fixture.fetch('/en/blog/1'); + expect(response2.status).to.equal(200); + expect(await response2.text()).includes('Hello world'); + }); + + it('should render localised page correctly', async () => { + const response = await fixture.fetch('/pt/start'); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Hola'); + + const response2 = await fixture.fetch('/pt/blog/1'); + expect(response2.status).to.equal(200); + expect(await response2.text()).includes('Hola mundo'); + }); + + 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); + }); + + it("should render a 404 because the route `fr` isn't included in the list of locales of the configuration", async () => { + const response = await fixture.fetch('/fr/start'); + expect(response.status).to.equal(404); + }); + }); + + describe('i18n routing, with base', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + /** @type {import('./test-utils').DevServer} */ + let devServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-routing-base/', + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('should render the en locale', async () => { + const response = await fixture.fetch('/new-site/en/start'); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Hello'); + + const response2 = await fixture.fetch('/new-site/en/blog/1'); + expect(response2.status).to.equal(200); + expect(await response2.text()).includes('Hello world'); + }); + + it('should render localised page correctly', async () => { + const response = await fixture.fetch('/new-site/pt/start'); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Hola'); + + const response2 = await fixture.fetch('/new-site/pt/blog/1'); + expect(response2.status).to.equal(200); + expect(await response2.text()).includes('Hola mundo'); + }); + + 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); + }); + + it("should render a 404 because the route `fr` isn't included in the list of locales of the configuration", async () => { + const response = await fixture.fetch('/new-site/fr/start'); + expect(response.status).to.equal(404); + }); + }); + + describe('i18n routing with fallback [redirect]', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + /** @type {import('./test-utils').DevServer} */ + let devServer; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-routing-fallback/', + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'pt', 'it'], + fallback: { + it: ['en'], + }, + fallbackControl: 'redirect', + }, + }, + }); + devServer = await fixture.startDevServer(); + }); + + after(async () => { + await devServer.stop(); + }); + + it('should render the en locale', async () => { + const response = await fixture.fetch('/new-site/en/start'); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Hello'); + + const response2 = await fixture.fetch('/new-site/en/blog/1'); + expect(response2.status).to.equal(200); + expect(await response2.text()).includes('Hello world'); + }); + + it('should render localised page correctly', async () => { + const response = await fixture.fetch('/new-site/pt/start'); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Hola'); + + const response2 = await fixture.fetch('/new-site/pt/blog/1'); + expect(response2.status).to.equal(200); + expect(await response2.text()).includes('Hola mundo'); + }); + + it('should render the english locale, which is the first fallback', async () => { + const response = await fixture.fetch('/new-site/it/start'); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Hello'); + }); + + it("should render a 404 because the route `fr` isn't included in the list of locales of the configuration", async () => { + const response = await fixture.fetch('/new-site/fr/start'); + expect(response.status).to.equal(404); + }); + }); +}); + +describe('[SSG] i18n routing', () => { + describe('i18n routing', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-routing/', + }); + await fixture.build(); + }); + + it('should render the en locale', async () => { + let html = await fixture.readFile('/en/start/index.html'); + let $ = cheerio.load(html); + expect($('body').text()).includes('Hello'); + + html = await fixture.readFile('/en/blog/1/index.html'); + $ = cheerio.load(html); + expect($('body').text()).includes('Hello world'); + }); + + it('should render localised page correctly', async () => { + let html = await fixture.readFile('/pt/start/index.html'); + let $ = cheerio.load(html); + expect($('body').text()).includes('Hola'); + + html = await fixture.readFile('/pt/blog/1/index.html'); + $ = cheerio.load(html); + expect($('body').text()).includes('Hola mundo'); + }); + + 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'); + // failed + return false; + } catch { + // success + return true; + } + }); + + it("should render a 404 because the route `fr` isn't included in the list of locales of the configuration", async () => { + try { + await fixture.readFile('/fr/start/index.html'); + // failed + return false; + } catch { + // success + return true; + } + }); + }); + + describe('i18n routing, with base', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-routing-base/', + }); + await fixture.build(); + }); + + it('should render the en locale', async () => { + let html = await fixture.readFile('/en/start/index.html'); + let $ = cheerio.load(html); + expect($('body').text()).includes('Hello'); + + html = await fixture.readFile('/en/blog/1/index.html'); + $ = cheerio.load(html); + expect($('body').text()).includes('Hello world'); + }); + + it('should render localised page correctly', async () => { + let html = await fixture.readFile('/pt/start/index.html'); + let $ = cheerio.load(html); + expect($('body').text()).includes('Hola'); + + html = await fixture.readFile('/pt/blog/1/index.html'); + $ = cheerio.load(html); + expect($('body').text()).includes('Hola mundo'); + }); + + 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'); + // failed + return false; + } catch { + // success + return true; + } + }); + + it("should render a 404 because the route `fr` isn't included in the list of locales of the configuration", async () => { + try { + await fixture.readFile('/fr/start/index.html'); + // failed + return false; + } catch { + // success + return true; + } + }); + }); + + describe('i18n routing with fallback [redirect]', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-routing-fallback/', + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'pt', 'it'], + fallback: { + it: ['en'], + }, + fallbackControl: 'redirect', + }, + }, + }); + await fixture.build(); + }); + + it('should render the en locale', async () => { + let html = await fixture.readFile('/en/start/index.html'); + let $ = cheerio.load(html); + expect($('body').text()).includes('Hello'); + + html = await fixture.readFile('/en/blog/1/index.html'); + $ = cheerio.load(html); + expect($('body').text()).includes('Hello world'); + }); + + it('should render localised page correctly', async () => { + let html = await fixture.readFile('/pt/start/index.html'); + let $ = cheerio.load(html); + expect($('body').text()).includes('Hola'); + + html = await fixture.readFile('/pt/blog/1/index.html'); + $ = cheerio.load(html); + expect($('body').text()).includes('Hola mundo'); + }); + + it('should render 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'); + console.log(html); + expect(html).to.include('url=/new-site/en/start'); + }); + + it("should render a 404 because the route `fr` isn't included in the list of locales of the configuration", async () => { + try { + await fixture.readFile('/fr/start/index.html'); + // failed + return false; + } catch { + // success + return true; + } + }); + }); +});