From 81abf24e52d8dc2900995367d6984aac880ccca9 Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Tue, 17 Oct 2023 19:43:43 +0100 Subject: [PATCH] feat(i18n): fallback system in SSR (#8850) * feat(i18n): fallback system in SSG * feat(i18n): fallback system in SSR --- packages/astro/src/core/app/index.ts | 17 ++- packages/astro/src/core/app/types.ts | 13 +- packages/astro/src/core/build/generate.ts | 13 +- .../src/core/build/plugins/plugin-manifest.ts | 17 ++- .../core/build/plugins/plugin-middleware.ts | 2 +- packages/astro/src/core/build/static-build.ts | 1 - packages/astro/src/core/build/util.ts | 2 +- packages/astro/src/core/config/schema.ts | 4 +- packages/astro/src/core/create-vite.ts | 2 +- .../astro/src/core/routing/manifest/create.ts | 3 +- packages/astro/src/i18n/index.ts | 2 +- packages/astro/src/i18n/middleware.ts | 13 +- packages/astro/src/i18n/vite-plugin-i18n.ts | 3 +- .../src/vite-plugin-astro-server/plugin.ts | 11 ++ .../src/vite-plugin-astro-server/route.ts | 2 +- packages/astro/test/i18-routing.test.js | 142 +++++++++++++++++- 16 files changed, 214 insertions(+), 33 deletions(-) diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index b6bf838a9369..1099979932d2 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -26,6 +26,8 @@ import { import { matchRoute } from '../routing/match.js'; import { EndpointNotFoundError, SSRRoutePipeline } from './ssrPipeline.js'; import type { RouteInfo } from './types.js'; +import { createI18nMiddleware } from '../../i18n/middleware.js'; +import { sequence } from '../middleware/index.js'; export { deserializeManifest } from './common.js'; const clientLocalsSymbol = Symbol.for('astro.locals'); @@ -164,8 +166,19 @@ export class App { ); let response; try { - if (mod.onRequest) { - this.#pipeline.setMiddlewareFunction(mod.onRequest as MiddlewareEndpointHandler); + let i18nMiddleware = createI18nMiddleware(this.#manifest.i18n); + if (i18nMiddleware) { + if (mod.onRequest) { + this.#pipeline.setMiddlewareFunction( + sequence(i18nMiddleware, mod.onRequest as MiddlewareEndpointHandler) + ); + } else { + this.#pipeline.setMiddlewareFunction(i18nMiddleware); + } + } else { + if (mod.onRequest) { + this.#pipeline.setMiddlewareFunction(mod.onRequest as MiddlewareEndpointHandler); + } } response = await this.#pipeline.renderRoute(renderContext, pageModule); } catch (err: any) { diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index 0050b5d7a008..4c25ca97e254 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -49,6 +49,14 @@ export type SSRManifest = { componentMetadata: SSRResult['componentMetadata']; pageModule?: SinglePageBuiltModule; pageMap?: Map; + i18n: SSRManifestI18n | undefined; +}; + +export type SSRManifestI18n = { + fallback?: Record; + fallbackControl?: 'none' | 'redirect' | 'render'; + locales: string[]; + defaultLocale: string; }; export type SerializedSSRManifest = Omit< @@ -60,8 +68,3 @@ export type SerializedSSRManifest = Omit< componentMetadata: [string, SSRComponentMetadata][]; clientDirectives: [string, string][]; }; - -export type AdapterCreateExports = ( - manifest: SSRManifest, - args?: T -) => Record; diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 69480a60e4a4..39eb8a5c9a29 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -65,6 +65,7 @@ import { getTimeStat, shouldAppendForwardSlash } from './util.js'; import { createI18nMiddleware } from '../../i18n/middleware.js'; import { sequence } from '../middleware/index.js'; import { routeIsFallback } from '../redirects/helpers.js'; +import type { SSRManifestI18n } from '../app/types.js'; function createEntryURL(filePath: string, outFolder: URL) { return new URL('./' + filePath + `?time=${Date.now()}`, outFolder); @@ -296,7 +297,7 @@ async function generatePage( const pageModulePromise = ssrEntry.page; const onRequest = ssrEntry.onRequest; - const i18nMiddleware = createI18nMiddleware(config, logger); + const i18nMiddleware = createI18nMiddleware(pipeline.getManifest().i18n); if (config.experimental.i18n && i18nMiddleware) { if (onRequest) { pipeline.setMiddlewareFunction( @@ -640,6 +641,15 @@ export function createBuildManifest( internals: BuildInternals, renderers: SSRLoadedRenderer[] ): SSRManifest { + let i18nManifest: SSRManifestI18n | undefined = undefined; + if (settings.config.experimental.i18n) { + i18nManifest = { + fallback: settings.config.experimental.i18n.fallback, + fallbackControl: settings.config.experimental.i18n.fallbackControl, + defaultLocale: settings.config.experimental.i18n.defaultLocale, + locales: settings.config.experimental.i18n.locales, + }; + } return { assets: new Set(), entryModules: Object.fromEntries(internals.entrySpecifierToBundleMap.entries()), @@ -654,5 +664,6 @@ export function createBuildManifest( ? new URL(settings.config.base, settings.config.site).toString() : settings.config.site, componentMetadata: internals.componentMetadata, + i18n: i18nManifest, }; } diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index 41ceb282c9d2..d2f16d39921b 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -5,11 +5,12 @@ import { type Plugin as VitePlugin } from 'vite'; import { runHookBuildSsr } from '../../../integrations/index.js'; import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.js'; import type { SerializedRouteInfo, SerializedSSRManifest } from '../../app/types.js'; +import type { SSRManifestI18n } from '../../app/types.js'; import { joinPaths, prependForwardSlash } from '../../path.js'; import { serializeRouteData } from '../../routing/index.js'; import { addRollupInput } from '../add-rollup-input.js'; import { getOutFile, getOutFolder } from '../common.js'; -import { cssOrder, mergeInlineCss, type BuildInternals } from '../internal.js'; +import { type BuildInternals, cssOrder, mergeInlineCss } from '../internal.js'; import type { AstroBuildPlugin } from '../plugin.js'; import type { StaticBuildOptions } from '../types.js'; @@ -237,8 +238,17 @@ function buildManifest( // Set this to an empty string so that the runtime knows not to try and load this. entryModules[BEFORE_HYDRATION_SCRIPT_ID] = ''; } + let i18nManifest: SSRManifestI18n | undefined = undefined; + if (settings.config.experimental.i18n) { + i18nManifest = { + fallback: settings.config.experimental.i18n.fallback, + fallbackControl: settings.config.experimental.i18n.fallbackControl, + locales: settings.config.experimental.i18n.locales, + defaultLocale: settings.config.experimental.i18n.defaultLocale, + }; + } - const ssrManifest: SerializedSSRManifest = { + return { adapterName: opts.settings.adapter?.name ?? '', routes, site: settings.config.site, @@ -250,7 +260,6 @@ function buildManifest( clientDirectives: Array.from(settings.clientDirectives), entryModules, assets: staticFiles.map(prefixAssetPath), + i18n: i18nManifest, }; - - return ssrManifest; } diff --git a/packages/astro/src/core/build/plugins/plugin-middleware.ts b/packages/astro/src/core/build/plugins/plugin-middleware.ts index 22d3f795ba89..628a1cb70bf9 100644 --- a/packages/astro/src/core/build/plugins/plugin-middleware.ts +++ b/packages/astro/src/core/build/plugins/plugin-middleware.ts @@ -17,7 +17,7 @@ export function vitePluginMiddleware( let resolvedMiddlewareId: string; return { name: '@astro/plugin-middleware', - + enforce: 'post', options(options) { return addRollupInput(options, [MIDDLEWARE_MODULE_ID]); }, diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index 619ebe3b4330..43727b876f16 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -148,7 +148,6 @@ async function ssrBuild( 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/util.ts b/packages/astro/src/core/build/util.ts index 7202a1a6b849..f3a6f367ad24 100644 --- a/packages/astro/src/core/build/util.ts +++ b/packages/astro/src/core/build/util.ts @@ -29,7 +29,7 @@ export function shouldAppendForwardSlash( } export function i18nHasFallback(config: AstroConfig): boolean { - if (config.experimental.i18n) { + if (config.experimental.i18n && config.experimental.i18n.fallback) { // we have some fallback and the control is not none return ( Object.keys(config.experimental.i18n.fallback).length > 0 && diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 09701b06067e..36a400bb71e2 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -302,10 +302,10 @@ export const AstroConfigSchema = z.object({ .object({ defaultLocale: z.string(), locales: z.string().array(), - fallback: z.record(z.string(), z.string().array()).optional().default({}), + fallback: z.record(z.string(), z.string().array()).optional(), detectBrowserLanguage: z.boolean().optional().default(false), // TODO: properly add default when the feature goes of experimental - fallbackControl: z.enum(['none', 'redirect', 'render']).optional().default('none'), + fallbackControl: z.enum(['none', 'redirect', 'render']).optional(), }) .optional() .superRefine((i18n, ctx) => { diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index 13690a3d2b57..654d56c4a2e4 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -135,7 +135,7 @@ export async function createVite( vitePluginSSRManifest(), astroAssetsPlugin({ settings, logger, mode }), astroTransitions(), - !!settings.config.experimental.i18n && astroInternalization({ settings, logger }), + !!settings.config.experimental.i18n && astroInternalization({ settings }), ], publicDir: fileURLToPath(settings.config.publicDir), root: fileURLToPath(settings.config.root), diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/manifest/create.ts index 73d20da0445c..eb24b01ac35a 100644 --- a/packages/astro/src/core/routing/manifest/create.ts +++ b/packages/astro/src/core/routing/manifest/create.ts @@ -485,7 +485,7 @@ export function createRouteManifest( routes.push(routeData); }); - if (settings.config.experimental.i18n) { + if (settings.config.experimental.i18n && settings.config.experimental.i18n.fallback) { let fallback = Object.entries(settings.config.experimental.i18n.fallback); if (fallback.length > 0) { for (const [fallbackLocale, fallbackLocaleList] of fallback) { @@ -523,6 +523,7 @@ export function createRouteManifest( ...fallbackToRoute, pathname, route, + segments, pattern: getPattern(segments, config), type: 'fallback', }); diff --git a/packages/astro/src/i18n/index.ts b/packages/astro/src/i18n/index.ts index ff6e02ee3715..638aa085980f 100644 --- a/packages/astro/src/i18n/index.ts +++ b/packages/astro/src/i18n/index.ts @@ -1,7 +1,7 @@ import { AstroError } from '../core/errors/index.js'; import { MissingLocale } from '../core/errors/errors-data.js'; -import type { AstroConfig } from '../@types/astro.js'; import { shouldAppendForwardSlash } from '../core/build/util.js'; +import type { AstroConfig } from '../@types/astro.js'; type GetI18nBaseUrl = { locale: string; diff --git a/packages/astro/src/i18n/middleware.ts b/packages/astro/src/i18n/middleware.ts index 5496e55dcb58..e876a3c990d8 100644 --- a/packages/astro/src/i18n/middleware.ts +++ b/packages/astro/src/i18n/middleware.ts @@ -1,25 +1,22 @@ -import type { AstroConfig, MiddlewareEndpointHandler } from '../@types/astro.js'; -import type { Logger } from '../core/logger/core.js'; +import type { MiddlewareEndpointHandler } from '../@types/astro.js'; +import type { SSRManifest } from '../@types/astro.js'; export function createI18nMiddleware( - config: Readonly, - logger: Logger + i18n: SSRManifest['i18n'] ): MiddlewareEndpointHandler | undefined { - const i18n = config.experimental?.i18n; if (!i18n) { return undefined; } - const fallbackKeys = Object.keys(i18n.fallback); const locales = i18n.locales; - logger.debug('i18n', 'Successfully created middleware'); return async (context, next) => { - if (fallbackKeys.length <= 0) { + if (!i18n.fallback) { return next(); } const response = await next(); if (i18n.fallbackControl === 'redirect' && response instanceof Response) { + const fallbackKeys = i18n.fallback ? Object.keys(i18n.fallback) : []; const url = context.url; const separators = url.pathname.split('/'); diff --git a/packages/astro/src/i18n/vite-plugin-i18n.ts b/packages/astro/src/i18n/vite-plugin-i18n.ts index fa8f342b1b50..f295ef771571 100644 --- a/packages/astro/src/i18n/vite-plugin-i18n.ts +++ b/packages/astro/src/i18n/vite-plugin-i18n.ts @@ -1,18 +1,17 @@ import * as vite from 'vite'; import type { AstroSettings } from '../@types/astro.js'; -import type { Logger } from '../core/logger/core.js'; const virtualModuleId = 'astro:i18n'; const resolvedVirtualModuleId = '\0' + virtualModuleId; type AstroInternalization = { settings: AstroSettings; - logger: Logger; }; export default function astroInternalization({ settings }: AstroInternalization): vite.Plugin { return { name: 'astro:i18n', + enforce: 'pre', async resolveId(id) { if (id === virtualModuleId) { return resolvedVirtualModuleId; diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts index daa1c01e682b..cca2d525e913 100644 --- a/packages/astro/src/vite-plugin-astro-server/plugin.ts +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -9,6 +9,7 @@ import { baseMiddleware } from './base.js'; import { createController } from './controller.js'; import DevPipeline from './devPipeline.js'; import { handleRequest } from './request.js'; +import type { SSRManifestI18n } from '../core/app/types.js'; export interface AstroPluginOptions { settings: AstroSettings; @@ -85,6 +86,15 @@ export default function createVitePluginAstroServer({ * @param renderers */ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest { + let i18nManifest: SSRManifestI18n | undefined = undefined; + if (settings.config.experimental.i18n) { + i18nManifest = { + fallback: settings.config.experimental.i18n.fallback, + fallbackControl: settings.config.experimental.i18n.fallbackControl, + defaultLocale: settings.config.experimental.i18n.defaultLocale, + locales: settings.config.experimental.i18n.locales, + }; + } return { compressHTML: settings.config.compressHTML, assets: new Set(), @@ -99,5 +109,6 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest ? new URL(settings.config.base, settings.config.site).toString() : settings.config.site, componentMetadata: new Map(), + i18n: i18nManifest, }; } diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts index 21d938ec923d..a44d765f591a 100644 --- a/packages/astro/src/vite-plugin-astro-server/route.ts +++ b/packages/astro/src/vite-plugin-astro-server/route.ts @@ -273,7 +273,7 @@ export async function handleRoute({ const onRequest = middleware?.onRequest as MiddlewareEndpointHandler | undefined; if (config.experimental.i18n) { - const i18Middleware = createI18nMiddleware(config, logger); + const i18Middleware = createI18nMiddleware(config.experimental.i18n); if (i18Middleware) { if (onRequest) { diff --git a/packages/astro/test/i18-routing.test.js b/packages/astro/test/i18-routing.test.js index 01c101365037..dff4b7c77858 100644 --- a/packages/astro/test/i18-routing.test.js +++ b/packages/astro/test/i18-routing.test.js @@ -1,6 +1,7 @@ import { loadFixture } from './test-utils.js'; import { expect } from 'chai'; import * as cheerio from 'cheerio'; +import testAdapter from './test-adapter.js'; describe('[DEV] i18n routing', () => { describe('i18n routing', () => { @@ -146,7 +147,7 @@ describe('[DEV] i18n routing', () => { expect(await response2.text()).includes('Hola mundo'); }); - it('should render the english locale, which is the first fallback', async () => { + 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); expect(await response.text()).includes('Hello'); @@ -309,7 +310,7 @@ describe('[SSG] i18n routing', () => { expect($('body').text()).includes('Hola mundo'); }); - it('should render the english locale, which is the first fallback', async () => { + 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'); console.log(html); @@ -328,3 +329,140 @@ describe('[SSG] i18n routing', () => { }); }); }); + +describe('[SSR] i18n routing', () => { + let app; + describe('i18n routing', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-routing/', + output: 'server', + adapter: testAdapter(), + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + }); + + it('should render the en locale', async () => { + let request = new Request('http://example.com/en/start'); + let response = await app.render(request); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Hello'); + }); + + it('should render localised page correctly', async () => { + let request = new Request('http://example.com/pt/start'); + let response = await app.render(request); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Hola'); + }); + + 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); + 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 () => { + let request = new Request('http://example.com/fr/start'); + let response = await app.render(request); + expect(response.status).to.equal(404); + }); + }); + + describe('i18n routing, with base', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-routing/', + output: 'server', + adapter: testAdapter(), + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + }); + + it('should render the en locale', async () => { + let request = new Request('http://example.com/new-site/en/start'); + let response = await app.render(request); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Hello'); + }); + + it('should render localised page correctly', async () => { + let request = new Request('http://example.com/new-site/pt/start'); + let response = await app.render(request); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Hola'); + }); + + 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); + 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 () => { + let request = new Request('http://example.com/new-site/fr/start'); + let response = await app.render(request); + expect(response.status).to.equal(404); + }); + }); + + describe('i18n routing with fallback [redirect]', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-routing/', + output: 'server', + adapter: testAdapter(), + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'pt', 'it'], + fallback: { + it: ['en'], + }, + fallbackControl: 'redirect', + }, + }, + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + }); + + it('should render the en locale', async () => { + let request = new Request('http://example.com/new-site/en/start'); + let response = await app.render(request); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Hello'); + }); + + it('should render localised page correctly', async () => { + let request = new Request('http://example.com/new-site/pt/start'); + let response = await app.render(request); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Hola'); + }); + + it('should redirect to the english locale, which is the first fallback', async () => { + let request = new Request('http://example.com/new-site/it/start'); + let response = await app.render(request); + expect(response.status).to.equal(302); + expect(response.headers.get('location')).to.equal('/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 () => { + let request = new Request('http://example.com/new-site/fr/start'); + let response = await app.render(request); + expect(response.status).to.equal(404); + }); + }); +});