diff options
-rw-r--r-- | .changeset/curvy-sheep-lick.md | 5 | ||||
-rw-r--r-- | packages/astro/src/core/build/generate.ts | 403 | ||||
-rw-r--r-- | packages/astro/src/core/routing/manifest/create.ts | 23 | ||||
-rw-r--r-- | packages/astro/src/core/routing/params.ts | 2 | ||||
-rw-r--r-- | packages/astro/src/i18n/middleware.ts | 4 | ||||
-rw-r--r-- | packages/astro/test/i18n-routing.test.js | 28 | ||||
-rw-r--r-- | packages/astro/test/ssr-split-manifest.test.js | 1 |
7 files changed, 245 insertions, 221 deletions
diff --git a/.changeset/curvy-sheep-lick.md b/.changeset/curvy-sheep-lick.md new file mode 100644 index 000000000..f00ac34c9 --- /dev/null +++ b/.changeset/curvy-sheep-lick.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Consistely emit fallback routes in the correct folders. diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 35f8ecb66..1ac2f05b6 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -13,6 +13,7 @@ import type { MiddlewareEndpointHandler, RouteData, RouteType, + SSRElement, SSRError, SSRLoadedRenderer, SSRManifest, @@ -261,21 +262,49 @@ async function generatePage( builtPaths: Set<string>, pipeline: BuildPipeline ) { - let timeStart = performance.now(); + // prepare information we need const logger = pipeline.getLogger(); const config = pipeline.getConfig(); + const manifest = pipeline.getManifest(); + const pageModulePromise = ssrEntry.page; + const onRequest = ssrEntry.onRequest; const pageInfo = getPageDataByComponent(pipeline.getInternals(), pageData.route.component); - // may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc. - const linkIds: [] = []; - const scripts = pageInfo?.hoistedScript ?? null; - const styles = pageData.styles + // Calculate information of the page, like scripts, links and styles + const hoistedScripts = pageInfo?.hoistedScript ?? null; + const moduleStyles = pageData.styles .sort(cssOrder) .map(({ sheet }) => sheet) .reduce(mergeInlineCss, []); - - const pageModulePromise = ssrEntry.page; - const onRequest = ssrEntry.onRequest; + // may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc. + const links = new Set<never>(); + const styles = createStylesheetElementSet(moduleStyles, manifest.base, manifest.assetsPrefix); + const scripts = createModuleScriptsSet( + hoistedScripts ? [hoistedScripts] : [], + manifest.base, + manifest.assetsPrefix + ); + if (pipeline.getSettings().scripts.some((script) => script.stage === 'page')) { + const hashedFilePath = pipeline.getInternals().entrySpecifierToBundleMap.get(PAGE_SCRIPT_ID); + if (typeof hashedFilePath !== 'string') { + throw new Error(`Cannot find the built path for ${PAGE_SCRIPT_ID}`); + } + const src = createAssetLink(hashedFilePath, manifest.base, manifest.assetsPrefix); + scripts.add({ + props: { type: 'module', src }, + children: '', + }); + } + // Add all injected scripts to the page. + for (const script of pipeline.getSettings().scripts) { + if (script.stage === 'head-inline') { + scripts.add({ + props: {}, + children: script.content, + }); + } + } + // prepare the middleware const i18nMiddleware = createI18nMiddleware( pipeline.getManifest().i18n, pipeline.getManifest().base, @@ -309,43 +338,24 @@ async function generatePage( return; } - const generationOptions: Readonly<GeneratePathOptions> = { - pageData, - linkIds, - scripts, - styles, - mod: pageModule, - }; - - const icon = - pageData.route.type === 'page' || - pageData.route.type === 'redirect' || - pageData.route.type === 'fallback' - ? green('▶') - : magenta('λ'); - if (isRelativePath(pageData.route.component)) { - logger.info(null, `${icon} ${pageData.route.route}`); - for (const fallbackRoute of pageData.route.fallbackRoutes) { - logger.info(null, `${icon} ${fallbackRoute.route}`); + // Now we explode the routes. A route render itself, and it can render its fallbacks (i18n routing) + for (const route of eachRouteInRouteData(pageData)) { + // Get paths for the route, calling getStaticPaths if needed. + const paths = await getPathsForRoute(route, pageModule, pipeline, builtPaths); + let timeStart = performance.now(); + let prevTimeEnd = timeStart; + for (let i = 0; i < paths.length; i++) { + const path = paths[i]; + pipeline.getEnvironment().logger.debug('build', `Generating: ${path}`); + await generatePath(path, pipeline, route, links, scripts, styles, pageModule); + const timeEnd = performance.now(); + const timeChange = getTimeStat(prevTimeEnd, timeEnd); + const timeIncrease = `(+${timeChange})`; + const filePath = getOutputFilename(pipeline.getConfig(), path, pageData.route.type); + const lineIcon = i === paths.length - 1 ? '└─' : '├─'; + logger.info(null, ` ${cyan(lineIcon)} ${dim(filePath)} ${dim(timeIncrease)}`); + prevTimeEnd = timeEnd; } - } else { - logger.info(null, `${icon} ${pageData.route.component}`); - } - - // Get paths for the route, calling getStaticPaths if needed. - const paths = await getPathsForRoute(pageData, pageModule, pipeline, builtPaths); - - let prevTimeEnd = timeStart; - for (let i = 0; i < paths.length; i++) { - const path = paths[i]; - await generatePath(path, generationOptions, pipeline); - const timeEnd = performance.now(); - const timeChange = getTimeStat(prevTimeEnd, timeEnd); - const timeIncrease = `(+${timeChange})`; - const filePath = getOutputFilename(pipeline.getConfig(), path, pageData.route.type); - const lineIcon = i === paths.length - 1 ? '└─' : '├─'; - logger.info(null, ` ${cyan(lineIcon)} ${dim(filePath)} ${dim(timeIncrease)}`); - prevTimeEnd = timeEnd; } } @@ -357,7 +367,7 @@ function* eachRouteInRouteData(data: PageBuildData) { } async function getPathsForRoute( - pageData: PageBuildData, + route: RouteData, mod: ComponentInstance, pipeline: BuildPipeline, builtPaths: Set<string> @@ -365,68 +375,64 @@ async function getPathsForRoute( const opts = pipeline.getStaticBuildOptions(); const logger = pipeline.getLogger(); let paths: Array<string> = []; - if (pageData.route.pathname) { - paths.push(pageData.route.pathname); - builtPaths.add(pageData.route.pathname); - for (const virtualRoute of pageData.route.fallbackRoutes) { + if (route.pathname) { + paths.push(route.pathname); + builtPaths.add(route.pathname); + for (const virtualRoute of route.fallbackRoutes) { if (virtualRoute.pathname) { paths.push(virtualRoute.pathname); builtPaths.add(virtualRoute.pathname); } } } else { - for (const route of eachRouteInRouteData(pageData)) { - const staticPaths = await callGetStaticPaths({ - mod, - route, - routeCache: opts.routeCache, - logger, - ssr: isServerLikeOutput(opts.settings.config), - }).catch((err) => { - logger.debug('build', `├── ${colors.bold(colors.red('✗'))} ${route.component}`); - throw err; - }); + const staticPaths = await callGetStaticPaths({ + mod, + route, + routeCache: opts.routeCache, + logger, + ssr: isServerLikeOutput(opts.settings.config), + }).catch((err) => { + logger.debug('build', `├── ${colors.bold(colors.red('✗'))} ${route.component}`); + throw err; + }); - const label = staticPaths.length === 1 ? 'page' : 'pages'; - logger.debug( - 'build', - `├── ${colors.bold(colors.green('✔'))} ${route.component} → ${colors.magenta( - `[${staticPaths.length} ${label}]` - )}` - ); + const label = staticPaths.length === 1 ? 'page' : 'pages'; + logger.debug( + 'build', + `├── ${colors.bold(colors.green('✔'))} ${route.component} → ${colors.magenta( + `[${staticPaths.length} ${label}]` + )}` + ); - paths.push( - ...staticPaths - .map((staticPath) => { - try { - return route.generate(staticPath.params); - } catch (e) { - if (e instanceof TypeError) { - throw getInvalidRouteSegmentError(e, route, staticPath); - } - throw e; - } - }) - .filter((staticPath) => { - // The path hasn't been built yet, include it - if (!builtPaths.has(removeTrailingForwardSlash(staticPath))) { - return true; - } - - // The path was already built once. Check the manifest to see if - // this route takes priority for the final URL. - // NOTE: The same URL may match multiple routes in the manifest. - // Routing priority needs to be verified here for any duplicate - // paths to ensure routing priority rules are enforced in the final build. - const matchedRoute = matchRoute(staticPath, opts.manifest); - return matchedRoute === route; - }) - ); + paths = staticPaths + .map((staticPath) => { + try { + return route.generate(staticPath.params); + } catch (e) { + if (e instanceof TypeError) { + throw getInvalidRouteSegmentError(e, route, staticPath); + } + throw e; + } + }) + .filter((staticPath) => { + // The path hasn't been built yet, include it + if (!builtPaths.has(removeTrailingForwardSlash(staticPath))) { + return true; + } - // Add each path to the builtPaths set, to avoid building it again later. - for (const staticPath of paths) { - builtPaths.add(removeTrailingForwardSlash(staticPath)); - } + // The path was already built once. Check the manifest to see if + // this route takes priority for the final URL. + // NOTE: The same URL may match multiple routes in the manifest. + // Routing priority needs to be verified here for any duplicate + // paths to ensure routing priority rules are enforced in the final build. + const matchedRoute = matchRoute(staticPath, opts.manifest); + return matchedRoute === route; + }); + + // Add each path to the builtPaths set, to avoid building it again later. + for (const staticPath of paths) { + builtPaths.add(removeTrailingForwardSlash(staticPath)); } } @@ -509,106 +515,92 @@ function getUrlForPath( return url; } -async function generatePath(pathname: string, gopts: GeneratePathOptions, pipeline: BuildPipeline) { +async function generatePath( + pathname: string, + pipeline: BuildPipeline, + route: RouteData, + links: Set<never>, + scripts: Set<SSRElement>, + styles: Set<SSRElement>, + mod: ComponentInstance +) { const manifest = pipeline.getManifest(); - const { mod, scripts: hoistedScripts, styles: _styles, pageData } = gopts; - - for (const route of eachRouteInRouteData(pageData)) { - // This adds the page name to the array so it can be shown as part of stats. - if (route.type === 'page') { - addPageName(pathname, pipeline.getStaticBuildOptions()); - } - - pipeline.getEnvironment().logger.debug('build', `Generating: ${pathname}`); + const logger = pipeline.getLogger(); + pipeline.getEnvironment().logger.debug('build', `Generating: ${pathname}`); - // may be used in the future for handling rel=modulepreload, rel=icon, rel=manifest etc. - const links = new Set<never>(); - const scripts = createModuleScriptsSet( - hoistedScripts ? [hoistedScripts] : [], - manifest.base, - manifest.assetsPrefix - ); - const styles = createStylesheetElementSet(_styles, manifest.base, manifest.assetsPrefix); + const icon = + route.type === 'page' || route.type === 'redirect' || route.type === 'fallback' + ? green('▶') + : magenta('λ'); + if (isRelativePath(route.component)) { + logger.info(null, `${icon} ${route.route}`); + } else { + logger.info(null, `${icon} ${route.component}`); + } - if (pipeline.getSettings().scripts.some((script) => script.stage === 'page')) { - const hashedFilePath = pipeline.getInternals().entrySpecifierToBundleMap.get(PAGE_SCRIPT_ID); - if (typeof hashedFilePath !== 'string') { - throw new Error(`Cannot find the built path for ${PAGE_SCRIPT_ID}`); - } - const src = createAssetLink(hashedFilePath, manifest.base, manifest.assetsPrefix); - scripts.add({ - props: { type: 'module', src }, - children: '', - }); - } + // This adds the page name to the array so it can be shown as part of stats. + if (route.type === 'page') { + addPageName(pathname, pipeline.getStaticBuildOptions()); + } - // Add all injected scripts to the page. - for (const script of pipeline.getSettings().scripts) { - if (script.stage === 'head-inline') { - scripts.add({ - props: {}, - children: script.content, - }); - } - } + const ssr = isServerLikeOutput(pipeline.getConfig()); + const url = getUrlForPath( + pathname, + pipeline.getConfig().base, + pipeline.getStaticBuildOptions().origin, + pipeline.getConfig().build.format, + route.type + ); - const ssr = isServerLikeOutput(pipeline.getConfig()); - const url = getUrlForPath( - pathname, - pipeline.getConfig().base, - pipeline.getStaticBuildOptions().origin, - pipeline.getConfig().build.format, - route.type - ); + const request = createRequest({ + url, + headers: new Headers(), + logger: pipeline.getLogger(), + ssr, + }); + const i18n = pipeline.getConfig().experimental.i18n; - const request = createRequest({ - url, - headers: new Headers(), - logger: pipeline.getLogger(), - ssr, - }); - const i18n = pipeline.getConfig().experimental.i18n; - const renderContext = await createRenderContext({ - pathname, - request, - componentMetadata: manifest.componentMetadata, - scripts, - styles, - links, - route, - env: pipeline.getEnvironment(), - mod, - locales: i18n?.locales, - routingStrategy: i18n?.routingStrategy, - defaultLocale: i18n?.defaultLocale, - }); + const renderContext = await createRenderContext({ + pathname, + request, + componentMetadata: manifest.componentMetadata, + scripts, + styles, + links, + route, + env: pipeline.getEnvironment(), + mod, + locales: i18n?.locales, + routingStrategy: i18n?.routingStrategy, + defaultLocale: i18n?.defaultLocale, + }); - let body: string | Uint8Array; - let encoding: BufferEncoding | undefined; + let body: string | Uint8Array; + let encoding: BufferEncoding | undefined; - let response: Response; - try { - response = await pipeline.renderRoute(renderContext, mod); - } catch (err) { - if (!AstroError.is(err) && !(err as SSRError).id && typeof err === 'object') { - (err as SSRError).id = pageData.component; - } - throw err; + let response: Response; + try { + response = await pipeline.renderRoute(renderContext, mod); + } catch (err) { + if (!AstroError.is(err) && !(err as SSRError).id && typeof err === 'object') { + (err as SSRError).id = route.component; } + throw err; + } - if (response.status >= 300 && response.status < 400) { - // If redirects is set to false, don't output the HTML - if (!pipeline.getConfig().build.redirects) { - return; - } - const locationSite = getRedirectLocationOrThrow(response.headers); - const siteURL = pipeline.getConfig().site; - const location = siteURL ? new URL(locationSite, siteURL) : locationSite; - const fromPath = new URL(renderContext.request.url).pathname; - // A short delay causes Google to interpret the redirect as temporary. - // https://developers.google.com/search/docs/crawling-indexing/301-redirects#metarefresh - const delay = response.status === 302 ? 2 : 0; - body = `<!doctype html> + if (response.status >= 300 && response.status < 400) { + // If redirects is set to false, don't output the HTML + if (!pipeline.getConfig().build.redirects) { + return; + } + const locationSite = getRedirectLocationOrThrow(response.headers); + const siteURL = pipeline.getConfig().site; + const location = siteURL ? new URL(locationSite, siteURL) : locationSite; + const fromPath = new URL(renderContext.request.url).pathname; + // A short delay causes Google to interpret the redirect as temporary. + // https://developers.google.com/search/docs/crawling-indexing/301-redirects#metarefresh + const delay = response.status === 302 ? 2 : 0; + body = `<!doctype html> <title>Redirecting to: ${location}</title> <meta http-equiv="refresh" content="${delay};url=${location}"> <meta name="robots" content="noindex"> @@ -616,27 +608,26 @@ async function generatePath(pathname: string, gopts: GeneratePathOptions, pipeli <body> <a href="${location}">Redirecting from <code>${fromPath}</code> to <code>${location}</code></a> </body>`; - if (pipeline.getConfig().compressHTML === true) { - body = body.replaceAll('\n', ''); - } - // A dynamic redirect, set the location so that integrations know about it. - if (route.type !== 'redirect') { - route.redirect = location.toString(); - } - } else { - // If there's no body, do nothing - if (!response.body) return; - body = Buffer.from(await response.arrayBuffer()); - encoding = (response.headers.get('X-Astro-Encoding') as BufferEncoding | null) ?? 'utf-8'; + if (pipeline.getConfig().compressHTML === true) { + body = body.replaceAll('\n', ''); + } + // A dynamic redirect, set the location so that integrations know about it. + if (route.type !== 'redirect') { + route.redirect = location.toString(); } + } else { + // If there's no body, do nothing + if (!response.body) return; + body = Buffer.from(await response.arrayBuffer()); + encoding = (response.headers.get('X-Astro-Encoding') as BufferEncoding | null) ?? 'utf-8'; + } - const outFolder = getOutFolder(pipeline.getConfig(), pathname, route.type); - const outFile = getOutFile(pipeline.getConfig(), outFolder, pathname, route.type); - route.distURL = outFile; + const outFolder = getOutFolder(pipeline.getConfig(), pathname, route.type); + const outFile = getOutFile(pipeline.getConfig(), outFolder, pathname, route.type); + route.distURL = outFile; - await fs.promises.mkdir(outFolder, { recursive: true }); - await fs.promises.writeFile(outFile, body, encoding); - } + await fs.promises.mkdir(outFolder, { recursive: true }); + await fs.promises.writeFile(outFile, body, encoding); } /** diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/manifest/create.ts index 44482fdcb..b6960e3da 100644 --- a/packages/astro/src/core/routing/manifest/create.ts +++ b/packages/astro/src/core/routing/manifest/create.ts @@ -603,22 +603,22 @@ export function createRouteManifest( if (!hasRoute) { let pathname: string | undefined; let route: string; - if (fallbackToLocale === i18n.defaultLocale) { + if ( + fallbackToLocale === i18n.defaultLocale && + i18n.routingStrategy === 'prefix-other-locales' + ) { if (fallbackToRoute.pathname) { pathname = `/${fallbackFromLocale}${fallbackToRoute.pathname}`; } route = `/${fallbackFromLocale}${fallbackToRoute.route}`; } else { - pathname = fallbackToRoute.pathname?.replace( - `/${fallbackToLocale}`, - `/${fallbackFromLocale}` - ); - route = fallbackToRoute.route.replace( - `/${fallbackToLocale}`, - `/${fallbackFromLocale}` - ); + pathname = fallbackToRoute.pathname + ?.replace(`/${fallbackToLocale}/`, `/${fallbackFromLocale}/`) + .replace(`/${fallbackToLocale}`, `/${fallbackFromLocale}`); + route = fallbackToRoute.route + .replace(`/${fallbackToLocale}`, `/${fallbackFromLocale}`) + .replace(`/${fallbackToLocale}/`, `/${fallbackFromLocale}/`); } - const segments = removeLeadingForwardSlash(route) .split(path.posix.sep) .filter(Boolean) @@ -626,7 +626,7 @@ export function createRouteManifest( validateSegment(s); return getParts(s, route); }); - + const generate = getRouteGenerator(segments, config.trailingSlash); const index = routes.findIndex((r) => r === fallbackToRoute); if (index) { const fallbackRoute: RouteData = { @@ -634,6 +634,7 @@ export function createRouteManifest( pathname, route, segments, + generate, pattern: getPattern(segments, config, config.trailingSlash), type: 'fallback', fallbackRoutes: [], diff --git a/packages/astro/src/core/routing/params.ts b/packages/astro/src/core/routing/params.ts index 987528d57..56497dac6 100644 --- a/packages/astro/src/core/routing/params.ts +++ b/packages/astro/src/core/routing/params.ts @@ -31,7 +31,7 @@ export function getParams(array: string[]) { export function stringifyParams(params: GetStaticPathsItem['params'], route: RouteData) { // validate parameter values then stringify each value const validatedParams = Object.entries(params).reduce((acc, next) => { - validateGetStaticPathsParameter(next, route.component); + validateGetStaticPathsParameter(next, route.route); const [key, value] = next; if (value !== undefined) { acc[key] = typeof value === 'string' ? trimSlashes(value) : value.toString(); diff --git a/packages/astro/src/i18n/middleware.ts b/packages/astro/src/i18n/middleware.ts index 854a39b77..03b7e4017 100644 --- a/packages/astro/src/i18n/middleware.ts +++ b/packages/astro/src/i18n/middleware.ts @@ -41,7 +41,7 @@ export function createI18nMiddleware( } const url = context.url; - const { locales, defaultLocale, fallback } = i18n; + const { locales, defaultLocale, fallback, routingStrategy } = i18n; const response = await next(); if (response instanceof Response) { @@ -82,7 +82,7 @@ export function createI18nMiddleware( 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 (fallbackLocale === defaultLocale && routingStrategy === 'prefix-other-locales') { newPathname = url.pathname.replace(`/${urlLocale}`, ``); } else { newPathname = url.pathname.replace(`/${urlLocale}`, `/${fallbackLocale}`); diff --git a/packages/astro/test/i18n-routing.test.js b/packages/astro/test/i18n-routing.test.js index 34a6dcbf0..c48adc030 100644 --- a/packages/astro/test/i18n-routing.test.js +++ b/packages/astro/test/i18n-routing.test.js @@ -646,6 +646,34 @@ describe('[SSG] i18n routing', () => { expect($('script').text()).includes('console.log("this is a script")'); }); }); + + describe('i18n routing with fallback and [prefix-always]', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-routing-prefix-always/', + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'pt', 'it'], + fallback: { + it: 'en', + }, + routingStrategy: 'prefix-always', + }, + }, + }); + await fixture.build(); + }); + + it('should render the en locale', async () => { + let html = await fixture.readFile('/it/start/index.html'); + expect(html).to.include('http-equiv="refresh'); + expect(html).to.include('url=/new-site/en/start'); + }); + }); }); describe('[SSR] i18n routing', () => { let app; diff --git a/packages/astro/test/ssr-split-manifest.test.js b/packages/astro/test/ssr-split-manifest.test.js index 89c8e00ef..74d2fe74e 100644 --- a/packages/astro/test/ssr-split-manifest.test.js +++ b/packages/astro/test/ssr-split-manifest.test.js @@ -109,7 +109,6 @@ describe('astro:ssr-manifest, split', () => { const request = new Request('http://example.com/'); const response = await app.render(request); const html = await response.text(); - console.log(html); expect(html.includes('<title>Testing</title>')).to.be.true; }); }); |