diff options
Diffstat (limited to 'packages/astro/src')
28 files changed, 882 insertions, 434 deletions
diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index cbaf568c7..fff91ca10 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -2112,6 +2112,11 @@ interface AstroSharedContext< */ preferredLocaleList: string[] | undefined; + + /** + * The current locale computed from the URL of the request. It matches the locales in `i18n.locales`, and returns `undefined` otherwise. + */ + currentLocale: string | undefined; } export interface APIContext< @@ -2241,6 +2246,11 @@ export interface APIContext< * [quality value]: https://developer.mozilla.org/en-US/docs/Glossary/Quality_values */ preferredLocaleList: string[] | undefined; + + /** + * The current locale computed from the URL of the request. It matches the locales in `i18n.locales`, and returns `undefined` otherwise. + */ + currentLocale: string | undefined; } export type EndpointOutput = @@ -2424,16 +2434,21 @@ export interface RouteData { prerender: boolean; redirect?: RedirectConfig; redirectRoute?: RouteData; + fallbackRoutes: RouteData[]; } export type RedirectRouteData = RouteData & { redirect: string; }; -export type SerializedRouteData = Omit<RouteData, 'generate' | 'pattern' | 'redirectRoute'> & { +export type SerializedRouteData = Omit< + RouteData, + 'generate' | 'pattern' | 'redirectRoute' | 'fallbackRoutes' +> & { generate: undefined; pattern: string; redirectRoute: SerializedRouteData | undefined; + fallbackRoutes: SerializedRouteData[]; _meta: { trailingSlash: AstroConfig['trailingSlash']; }; diff --git a/packages/astro/src/cli/add/index.ts b/packages/astro/src/cli/add/index.ts index 42b160665..80c0e10ff 100644 --- a/packages/astro/src/cli/add/index.ts +++ b/packages/astro/src/cli/add/index.ts @@ -731,8 +731,11 @@ async function fetchPackageJson( const res = await fetch(`${registry}/${packageName}/${tag}`); if (res.status >= 200 && res.status < 300) { return await res.json(); - } else { + } else if (res.status === 404) { + // 404 means the package doesn't exist, so we don't need an error message here return new Error(); + } else { + return new Error(`Failed to fetch ${registry}/${packageName}/${tag} - GET ${res.status}`); } } @@ -754,6 +757,9 @@ export async function validateIntegrations(integrations: string[]): Promise<Inte } else { const firstPartyPkgCheck = await fetchPackageJson('@astrojs', name, tag); if (firstPartyPkgCheck instanceof Error) { + if (firstPartyPkgCheck.message) { + spinner.warn(yellow(firstPartyPkgCheck.message)); + } spinner.warn( yellow(`${bold(integration)} is not an official Astro package. Use at your own risk!`) ); @@ -780,6 +786,9 @@ export async function validateIntegrations(integrations: string[]): Promise<Inte if (pkgType === 'third-party') { const thirdPartyPkgCheck = await fetchPackageJson(scope, name, tag); if (thirdPartyPkgCheck instanceof Error) { + if (thirdPartyPkgCheck.message) { + spinner.warn(yellow(thirdPartyPkgCheck.message)); + } throw new Error(`Unable to fetch ${bold(integration)}. Does the package exist?`); } else { pkgJson = thirdPartyPkgCheck as any; diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index 4c6fb5783..23ecba837 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -127,6 +127,13 @@ export class App { } return pathname; } + + #getPathnameFromRequest(request: Request): string { + const url = new URL(request.url); + const pathname = prependForwardSlash(this.removeBase(url.pathname)); + return pathname; + } + match(request: Request, _opts: MatchOptions = {}): RouteData | undefined { const url = new URL(request.url); // ignore requests matching public assets @@ -151,7 +158,8 @@ export class App { } Reflect.set(request, clientLocalsSymbol, locals ?? {}); - const defaultStatus = this.#getDefaultStatusCode(routeData.route); + const pathname = this.#getPathnameFromRequest(request); + const defaultStatus = this.#getDefaultStatusCode(routeData, pathname); const mod = await this.#getModuleForRoute(routeData); const pageModule = (await mod.page()) as any; @@ -234,7 +242,9 @@ export class App { status, env: this.#pipeline.env, mod: handler as any, - locales: this.#manifest.i18n ? this.#manifest.i18n.locales : undefined, + locales: this.#manifest.i18n?.locales, + routingStrategy: this.#manifest.i18n?.routingStrategy, + defaultLocale: this.#manifest.i18n?.defaultLocale, }); } else { const pathname = prependForwardSlash(this.removeBase(url.pathname)); @@ -269,7 +279,9 @@ export class App { status, mod, env: this.#pipeline.env, - locales: this.#manifest.i18n ? this.#manifest.i18n.locales : undefined, + locales: this.#manifest.i18n?.locales, + routingStrategy: this.#manifest.i18n?.routingStrategy, + defaultLocale: this.#manifest.i18n?.defaultLocale, }); } } @@ -365,8 +377,15 @@ export class App { }); } - #getDefaultStatusCode(route: string): number { - route = removeTrailingForwardSlash(route); + #getDefaultStatusCode(routeData: RouteData, pathname: string): number { + if (!routeData.pattern.exec(pathname)) { + for (const fallbackRoute of routeData.fallbackRoutes) { + if (fallbackRoute.pattern.test(pathname)) { + return 302; + } + } + } + const route = removeTrailingForwardSlash(routeData.route); if (route.endsWith('/404')) return 404; if (route.endsWith('/500')) return 500; return 200; diff --git a/packages/astro/src/core/build/buildPipeline.ts b/packages/astro/src/core/build/buildPipeline.ts index fc315ff7d..e9b3c683e 100644 --- a/packages/astro/src/core/build/buildPipeline.ts +++ b/packages/astro/src/core/build/buildPipeline.ts @@ -164,17 +164,15 @@ export class BuildPipeline extends Pipeline { } } - 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()) || - (routeIsFallback(pageData.route) && pageData.route.route === '/')) - ) { - pages.set(pageData, path); - } + for (const [path, pageData] of this.#internals.pagesByComponent.entries()) { + if (routeIsRedirect(pageData.route)) { + pages.set(pageData, path); + } else if ( + routeIsFallback(pageData.route) && + (i18nHasFallback(this.getConfig()) || + (routeIsFallback(pageData.route) && pageData.route.route === '/')) + ) { + pages.set(pageData, path); } } diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 4f7c36e8e..3ffd13b7d 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -328,6 +328,9 @@ async function generatePage( : 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}`); + } } else { logger.info(null, `${icon} ${pageData.route.component}`); } @@ -349,6 +352,13 @@ async function generatePage( } } +function* eachRouteInRouteData(data: PageBuildData) { + yield data.route; + for (const fallbackRoute of data.route.fallbackRoutes) { + yield fallbackRoute; + } +} + async function getPathsForRoute( pageData: PageBuildData, mod: ComponentInstance, @@ -361,18 +371,24 @@ async function getPathsForRoute( if (pageData.route.pathname) { paths.push(pageData.route.pathname); builtPaths.add(pageData.route.pathname); + for (const virtualRoute of pageData.route.fallbackRoutes) { + if (virtualRoute.pathname) { + paths.push(virtualRoute.pathname); + builtPaths.add(virtualRoute.pathname); + } + } } else { - const route = pageData.route; - const staticPaths = await callGetStaticPaths({ - mod, - route, - routeCache: opts.routeCache, - logger, - ssr: isServerLikeOutput(opts.settings.config), - }).catch((err) => { - logger.debug('build', `├── ${bold(red('✗'))} ${route.component}`); - throw err; - }); + 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', `├── ${bold(red('✗'))} ${route.component}`); + throw err; + }); const label = staticPaths.length === 1 ? 'page' : 'pages'; logger.debug( @@ -382,35 +398,38 @@ async function getPathsForRoute( )}` ); - 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; - } - - // 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.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; + }) + ); - // Add each path to the builtPaths set, to avoid building it again later. - for (const staticPath of paths) { - builtPaths.add(removeTrailingForwardSlash(staticPath)); + // Add each path to the builtPaths set, to avoid building it again later. + for (const staticPath of paths) { + builtPaths.add(removeTrailingForwardSlash(staticPath)); + } } } @@ -497,99 +516,102 @@ async function generatePath(pathname: string, gopts: GeneratePathOptions, pipeli const manifest = pipeline.getManifest(); const { mod, scripts: hoistedScripts, styles: _styles, pageData } = gopts; - // This adds the page name to the array so it can be shown as part of stats. - if (pageData.route.type === 'page') { - addPageName(pathname, pipeline.getStaticBuildOptions()); - } - - pipeline.getEnvironment().logger.debug('build', `Generating: ${pathname}`); + 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()); + } - // 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); + pipeline.getEnvironment().logger.debug('build', `Generating: ${pathname}`); - 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: '', - }); - } + // 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); - // Add all injected scripts to the page. - for (const script of pipeline.getSettings().scripts) { - if (script.stage === 'head-inline') { + 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: {}, - children: script.content, + props: { type: 'module', src }, + children: '', }); } - } - const ssr = isServerLikeOutput(pipeline.getConfig()); - const url = getUrlForPath( - pathname, - pipeline.getConfig().base, - pipeline.getStaticBuildOptions().origin, - pipeline.getConfig().build.format, - pageData.route.type - ); + // 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 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: pageData.route, - env: pipeline.getEnvironment(), - mod, - locales: i18n ? i18n.locales : undefined, - }); + const ssr = isServerLikeOutput(pipeline.getConfig()); + const url = getUrlForPath( + pathname, + pipeline.getConfig().base, + pipeline.getStaticBuildOptions().origin, + pipeline.getConfig().build.format, + route.type + ); - let body: string | Uint8Array; - let encoding: BufferEncoding | undefined; + 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, + }); - 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 body: string | Uint8Array; + let encoding: BufferEncoding | undefined; - if (response.status >= 300 && response.status < 400) { - // If redirects is set to false, don't output the HTML - if (!pipeline.getConfig().build.redirects) { - return; + 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; } - 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"> @@ -597,26 +619,27 @@ 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 (pageData.route.type !== 'redirect') { - pageData.route.redirect = location.toString(); + 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'; } - } 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, pageData.route.type); - const outFile = getOutFile(pipeline.getConfig(), outFolder, pathname, pageData.route.type); - pageData.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/build/internal.ts b/packages/astro/src/core/build/internal.ts index 1dc38e735..3babef38f 100644 --- a/packages/astro/src/core/build/internal.ts +++ b/packages/astro/src/core/build/internal.ts @@ -2,7 +2,6 @@ import type { Rollup } from 'vite'; import type { RouteData, SSRResult } from '../../@types/astro.js'; import type { PageOptions } from '../../vite-plugin-astro/types.js'; import { prependForwardSlash, removeFileExtension } from '../path.js'; -import { routeIsFallback } from '../redirects/helpers.js'; import { viteID } from '../util.js'; import { ASTRO_PAGE_RESOLVED_MODULE_ID, @@ -38,17 +37,10 @@ export interface BuildInternals { /** * A map for page-specific information. - * // TODO: Remove in Astro 4.0 - * @deprecated */ pagesByComponent: Map<string, PageBuildData>; /** - * TODO: Use this in Astro 4.0 - */ - pagesByComponents: Map<string, PageBuildData[]>; - - /** * A map for page-specific output. */ pageOptionsByPage: Map<string, PageOptions>; @@ -126,7 +118,6 @@ export function createBuildInternals(): BuildInternals { entrySpecifierToBundleMap: new Map<string, string>(), pageToBundleMap: new Map<string, string>(), pagesByComponent: new Map(), - pagesByComponents: new Map(), pageOptionsByPage: new Map(), pagesByViteID: new Map(), pagesByClientOnly: new Map(), @@ -152,16 +143,7 @@ export function trackPageData( componentURL: URL ): void { pageData.moduleSpecifier = componentModuleId; - 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.pagesByComponent.set(component, pageData); internals.pagesByViteID.set(viteID(componentURL), pageData); } @@ -258,10 +240,8 @@ export function* eachPageData(internals: BuildInternals) { } export function* eachPageFromAllPages(allPages: AllPagesData): Generator<[string, PageBuildData]> { - for (const [path, list] of Object.entries(allPages)) { - for (const pageData of list) { - yield [path, pageData]; - } + for (const [path, pageData] of Object.entries(allPages)) { + yield [path, pageData]; } } diff --git a/packages/astro/src/core/build/page-data.ts b/packages/astro/src/core/build/page-data.ts index 7292cb4e8..89eca3ffc 100644 --- a/packages/astro/src/core/build/page-data.ts +++ b/packages/astro/src/core/build/page-data.ts @@ -47,29 +47,16 @@ export async function collectPagesData( clearInterval(routeCollectionLogTimeout); }, 10000); builtPaths.add(route.pathname); - 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, - }, - ]; - } + + allPages[route.component] = { + component: route.component, + route, + moduleSpecifier: '', + styles: [], + propagatedStyles: new Map(), + propagatedScripts: new Map(), + hoistedScript: undefined, + }; clearInterval(routeCollectionLogTimeout); if (settings.config.output === 'static') { @@ -84,29 +71,16 @@ export async function collectPagesData( continue; } // dynamic route: - 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, - }, - ]; - } + + allPages[route.component] = { + component: route.component, + route, + moduleSpecifier: '', + styles: [], + propagatedStyles: new Map(), + propagatedScripts: new Map(), + hoistedScript: undefined, + }; } clearInterval(dataCollectionLogTimeout); diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index 95404c6d6..2580e585e 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -51,17 +51,15 @@ export async function viteBuild(opts: StaticBuildOptions) { // Build internals needed by the CSS plugin const internals = createBuildInternals(); - 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); + for (const [component, pageData] of Object.entries(allPages)) { + 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); - } + if (!routeIsRedirect(pageData.route)) { + pageInput.add(astroModuleId); } } @@ -150,9 +148,7 @@ async function ssrBuild( const { allPages, settings, viteConfig } = opts; const ssr = isServerLikeOutput(settings.config); const out = getOutputDirectory(settings.config); - const routes = Object.values(allPages) - .flat() - .map((pageData) => pageData.route); + const routes = Object.values(allPages).flatMap((pageData) => pageData.route); const isContentCache = !ssr && settings.config.experimental.contentCollectionCache; const { lastVitePlugins, vitePlugins } = await container.runBeforeHook('server', input); diff --git a/packages/astro/src/core/build/types.ts b/packages/astro/src/core/build/types.ts index 00d6ce046..59fa06f6b 100644 --- a/packages/astro/src/core/build/types.ts +++ b/packages/astro/src/core/build/types.ts @@ -30,7 +30,8 @@ export interface PageBuildData { hoistedScript: { type: 'inline' | 'external'; value: string } | undefined; styles: Array<{ depth: number; order: number; sheet: StylesheetAsset }>; } -export type AllPagesData = Record<ComponentPath, PageBuildData[]>; + +export type AllPagesData = Record<ComponentPath, PageBuildData>; /** Options for the static build */ export interface StaticBuildOptions { diff --git a/packages/astro/src/core/endpoint/index.ts b/packages/astro/src/core/endpoint/index.ts index d5484f0df..f9c61d053 100644 --- a/packages/astro/src/core/endpoint/index.ts +++ b/packages/astro/src/core/endpoint/index.ts @@ -12,7 +12,11 @@ import { ASTRO_VERSION } from '../constants.js'; import { AstroCookies, attachCookiesToResponse } from '../cookies/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; import { callMiddleware } from '../middleware/callMiddleware.js'; -import { computePreferredLocale, computePreferredLocaleList } from '../render/context.js'; +import { + computeCurrentLocale, + computePreferredLocale, + computePreferredLocaleList, +} from '../render/context.js'; import { type Environment, type RenderContext } from '../render/index.js'; const encoder = new TextEncoder(); @@ -27,6 +31,8 @@ type CreateAPIContext = { props: Record<string, any>; adapterName?: string; locales: string[] | undefined; + routingStrategy: 'prefix-always' | 'prefix-other-locales' | undefined; + defaultLocale: string | undefined; }; /** @@ -41,9 +47,12 @@ export function createAPIContext({ props, adapterName, locales, + routingStrategy, + defaultLocale, }: CreateAPIContext): APIContext { let preferredLocale: string | undefined = undefined; let preferredLocaleList: string[] | undefined = undefined; + let currentLocale: string | undefined = undefined; const context = { cookies: new AstroCookies(request), @@ -83,6 +92,16 @@ export function createAPIContext({ return undefined; }, + get currentLocale(): string | undefined { + if (currentLocale) { + return currentLocale; + } + if (locales) { + currentLocale = computeCurrentLocale(request, locales, routingStrategy, defaultLocale); + } + + return currentLocale; + }, url: new URL(request.url), get clientAddress() { if (clientAddressSymbol in request) { @@ -153,8 +172,7 @@ export async function callEndpoint<MiddlewareResult = Response | EndpointOutput> mod: EndpointHandler, env: Environment, ctx: RenderContext, - onRequest: MiddlewareHandler<MiddlewareResult> | undefined, - locales: undefined | string[] + onRequest: MiddlewareHandler<MiddlewareResult> | undefined ): Promise<Response> { const context = createAPIContext({ request: ctx.request, @@ -162,7 +180,9 @@ export async function callEndpoint<MiddlewareResult = Response | EndpointOutput> props: ctx.props, site: env.site, adapterName: env.adapterName, - locales, + routingStrategy: ctx.routingStrategy, + defaultLocale: ctx.defaultLocale, + locales: ctx.locales, }); let response; diff --git a/packages/astro/src/core/middleware/index.ts b/packages/astro/src/core/middleware/index.ts index 77da30aee..c02761351 100644 --- a/packages/astro/src/core/middleware/index.ts +++ b/packages/astro/src/core/middleware/index.ts @@ -35,6 +35,8 @@ function createContext({ request, params, userDefinedLocales = [] }: CreateConte props: {}, site: undefined, locales: userDefinedLocales, + defaultLocale: undefined, + routingStrategy: undefined, }); } diff --git a/packages/astro/src/core/pipeline.ts b/packages/astro/src/core/pipeline.ts index bd203b437..87f833ee5 100644 --- a/packages/astro/src/core/pipeline.ts +++ b/packages/astro/src/core/pipeline.ts @@ -128,6 +128,8 @@ export class Pipeline { site: env.site, adapterName: env.adapterName, locales: renderContext.locales, + routingStrategy: renderContext.routingStrategy, + defaultLocale: renderContext.defaultLocale, }); switch (renderContext.route.type) { @@ -158,13 +160,7 @@ export class Pipeline { } } case 'endpoint': { - return await callEndpoint( - mod as any as EndpointHandler, - env, - renderContext, - onRequest, - renderContext.locales - ); + return await callEndpoint(mod as any as EndpointHandler, env, renderContext, onRequest); } default: throw new Error(`Couldn't find route of type [${renderContext.route.type}]`); diff --git a/packages/astro/src/core/render/context.ts b/packages/astro/src/core/render/context.ts index 851c41bc5..0f0bf39b0 100644 --- a/packages/astro/src/core/render/context.ts +++ b/packages/astro/src/core/render/context.ts @@ -29,6 +29,8 @@ export interface RenderContext { props: Props; locals?: object; locales: string[] | undefined; + defaultLocale: string | undefined; + routingStrategy: 'prefix-always' | 'prefix-other-locales' | undefined; } export type CreateRenderContextArgs = Partial< @@ -60,6 +62,8 @@ export async function createRenderContext( params, props, locales: options.locales, + routingStrategy: options.routingStrategy, + defaultLocale: options.defaultLocale, }; // We define a custom property, so we can check the value passed to locals @@ -208,3 +212,23 @@ export function computePreferredLocaleList(request: Request, locales: string[]) return result; } + +export function computeCurrentLocale( + request: Request, + locales: string[], + 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 (routingStrategy === 'prefix-other-locales') { + return defaultLocale; + } + return undefined; +} diff --git a/packages/astro/src/core/render/core.ts b/packages/astro/src/core/render/core.ts index a3235003f..5b120bb07 100644 --- a/packages/astro/src/core/render/core.ts +++ b/packages/astro/src/core/render/core.ts @@ -60,6 +60,8 @@ export async function renderPage({ mod, renderContext, env, cookies }: RenderPag cookies, locals: renderContext.locals ?? {}, locales: renderContext.locales, + defaultLocale: renderContext.defaultLocale, + routingStrategy: renderContext.routingStrategy, }); // TODO: Remove in Astro 4.0 diff --git a/packages/astro/src/core/render/result.ts b/packages/astro/src/core/render/result.ts index f4a1b0769..2c37f38c4 100644 --- a/packages/astro/src/core/render/result.ts +++ b/packages/astro/src/core/render/result.ts @@ -12,7 +12,11 @@ import { chunkToString } from '../../runtime/server/render/index.js'; import { AstroCookies } from '../cookies/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; import type { Logger } from '../logger/core.js'; -import { computePreferredLocale, computePreferredLocaleList } from './context.js'; +import { + computeCurrentLocale, + computePreferredLocale, + computePreferredLocaleList, +} from './context.js'; const clientAddressSymbol = Symbol.for('astro.clientAddress'); const responseSentSymbol = Symbol.for('astro.responseSent'); @@ -47,6 +51,8 @@ export interface CreateResultArgs { locals: App.Locals; cookies?: AstroCookies; locales: string[] | undefined; + defaultLocale: string | undefined; + routingStrategy: 'prefix-always' | 'prefix-other-locales' | undefined; } function getFunctionExpression(slot: any) { @@ -148,6 +154,7 @@ export function createResult(args: CreateResultArgs): SSRResult { let cookies: AstroCookies | undefined = args.cookies; let preferredLocale: string | undefined = undefined; let preferredLocaleList: string[] | undefined = undefined; + let currentLocale: string | undefined = undefined; // Create the result object that will be passed into the render function. // This object starts here as an empty shell (not yet the result) but then @@ -218,6 +225,24 @@ export function createResult(args: CreateResultArgs): SSRResult { return undefined; }, + get currentLocale(): string | undefined { + if (currentLocale) { + return currentLocale; + } + if (args.locales) { + currentLocale = computeCurrentLocale( + request, + args.locales, + args.routingStrategy, + args.defaultLocale + ); + if (currentLocale) { + return currentLocale; + } + } + + return undefined; + }, params, props, locals, diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/manifest/create.ts index ded3e13a8..9ab331504 100644 --- a/packages/astro/src/core/routing/manifest/create.ts +++ b/packages/astro/src/core/routing/manifest/create.ts @@ -347,6 +347,7 @@ export function createRouteManifest( generate, pathname: pathname || undefined, prerender, + fallbackRoutes: [], }); } }); @@ -422,6 +423,7 @@ export function createRouteManifest( generate, pathname: pathname || void 0, prerender: prerenderInjected ?? prerender, + fallbackRoutes: [], }); }); @@ -461,6 +463,7 @@ export function createRouteManifest( prerender: false, redirect: to, redirectRoute: routes.find((r) => r.route === to), + fallbackRoutes: [], }; const lastSegmentIsDynamic = (r: RouteData) => !!r.segments.at(-1)?.at(-1)?.dynamic; @@ -549,6 +552,7 @@ export function createRouteManifest( validateSegment(s); return getParts(s, route); }); + routes.push({ ...indexDefaultRoute, pathname, @@ -622,14 +626,21 @@ export function createRouteManifest( validateSegment(s); return getParts(s, route); }); - routes.push({ - ...fallbackToRoute, - pathname, - route, - segments, - pattern: getPattern(segments, config, config.trailingSlash), - type: 'fallback', - }); + + const index = routes.findIndex((r) => r === fallbackToRoute); + if (index) { + const fallbackRoute: RouteData = { + ...fallbackToRoute, + pathname, + route, + segments, + pattern: getPattern(segments, config, config.trailingSlash), + type: 'fallback', + fallbackRoutes: [], + }; + const routeData = routes[index]; + routeData.fallbackRoutes.push(fallbackRoute); + } } } } diff --git a/packages/astro/src/core/routing/manifest/serialization.ts b/packages/astro/src/core/routing/manifest/serialization.ts index 71ffc221d..f70aa84dd 100644 --- a/packages/astro/src/core/routing/manifest/serialization.ts +++ b/packages/astro/src/core/routing/manifest/serialization.ts @@ -13,6 +13,9 @@ export function serializeRouteData( redirectRoute: routeData.redirectRoute ? serializeRouteData(routeData.redirectRoute, trailingSlash) : undefined, + fallbackRoutes: routeData.fallbackRoutes.map((fallbackRoute) => { + return serializeRouteData(fallbackRoute, trailingSlash); + }), _meta: { trailingSlash }, }; } @@ -32,5 +35,8 @@ export function deserializeRouteData(rawRouteData: SerializedRouteData): RouteDa redirectRoute: rawRouteData.redirectRoute ? deserializeRouteData(rawRouteData.redirectRoute) : undefined, + fallbackRoutes: rawRouteData.fallbackRoutes.map((fallback) => { + return deserializeRouteData(fallback); + }), }; } diff --git a/packages/astro/src/core/routing/match.ts b/packages/astro/src/core/routing/match.ts index 9b91e1e9a..97659253e 100644 --- a/packages/astro/src/core/routing/match.ts +++ b/packages/astro/src/core/routing/match.ts @@ -2,7 +2,13 @@ import type { ManifestData, RouteData } from '../../@types/astro.js'; /** Find matching route from pathname */ export function matchRoute(pathname: string, manifest: ManifestData): RouteData | undefined { - return manifest.routes.find((route) => route.pattern.test(decodeURI(pathname))); + const decodedPathname = decodeURI(pathname); + return manifest.routes.find((route) => { + return ( + route.pattern.test(decodedPathname) || + route.fallbackRoutes.some((fallbackRoute) => fallbackRoute.pattern.test(decodedPathname)) + ); + }); } /** Finds all matching routes from pathname */ diff --git a/packages/astro/src/prefetch/index.ts b/packages/astro/src/prefetch/index.ts index f47cff060..573efe573 100644 --- a/packages/astro/src/prefetch/index.ts +++ b/packages/astro/src/prefetch/index.ts @@ -56,7 +56,7 @@ function initTapStrategy() { event, (e) => { if (elMatchesStrategy(e.target, 'tap')) { - prefetch(e.target.href, { with: 'fetch' }); + prefetch(e.target.href, { with: 'fetch', ignoreSlowConnection: true }); } }, { passive: true } @@ -176,6 +176,10 @@ export interface PrefetchOptions { * - `'fetch'`: use `fetch()`, has higher loading priority. */ with?: 'link' | 'fetch'; + /** + * Should prefetch even on data saver mode or slow connection. (default `false`) + */ + ignoreSlowConnection?: boolean; } /** @@ -190,7 +194,8 @@ export interface PrefetchOptions { * @param opts Additional options for prefetching. */ export function prefetch(url: string, opts?: PrefetchOptions) { - if (!canPrefetchUrl(url)) return; + const ignoreSlowConnection = opts?.ignoreSlowConnection ?? false; + if (!canPrefetchUrl(url, ignoreSlowConnection)) return; prefetchedUrls.add(url); const priority = opts?.with ?? 'link'; @@ -211,15 +216,11 @@ export function prefetch(url: string, opts?: PrefetchOptions) { } } -function canPrefetchUrl(url: string) { +function canPrefetchUrl(url: string, ignoreSlowConnection: boolean) { // Skip prefetch if offline if (!navigator.onLine) return false; - if ('connection' in navigator) { - // Untyped Chrome-only feature: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/connection - const conn = navigator.connection as any; - // Skip prefetch if using data saver mode or slow connection - if (conn.saveData || /(2|3)g/.test(conn.effectiveType)) return false; - } + // Skip prefetch if using data saver mode or slow connection + if (!ignoreSlowConnection && isSlowConnection()) return false; // Else check if URL is within the same origin, not the current page, and not already prefetched try { const urlObj = new URL(url, location.href); @@ -241,6 +242,12 @@ function elMatchesStrategy(el: EventTarget | null, strategy: string): el is HTML if (attrValue === 'false') { return false; } + + // Fallback to tap strategy if using data saver mode or slow connection + if (strategy === 'tap' && (attrValue != null || prefetchAll) && isSlowConnection()) { + return true; + } + // If anchor has no dataset but we want to prefetch all, or has dataset but no value, // check against fallback default strategy if ((attrValue == null && prefetchAll) || attrValue === '') { @@ -254,6 +261,15 @@ function elMatchesStrategy(el: EventTarget | null, strategy: string): el is HTML return false; } +function isSlowConnection() { + if ('connection' in navigator) { + // Untyped Chrome-only feature: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/connection + const conn = navigator.connection as any; + return conn.saveData || /(2|3)g/.test(conn.effectiveType); + } + return false; +} + /** * Listen to page loads and handle Astro's View Transition specific events */ diff --git a/packages/astro/src/runtime/server/transition.ts b/packages/astro/src/runtime/server/transition.ts index 17eece1d9..d38a0eac6 100644 --- a/packages/astro/src/runtime/server/transition.ts +++ b/packages/astro/src/runtime/server/transition.ts @@ -1,7 +1,9 @@ import type { SSRResult, TransitionAnimation, + TransitionAnimationPair, TransitionAnimationValue, + TransitionDirectionalAnimations, } from '../../@types/astro.js'; import { fade, slide } from '../../transitions/index.js'; import { markHTMLString } from './escape.js'; @@ -34,6 +36,19 @@ const getAnimations = (name: TransitionAnimationValue) => { if (typeof name === 'object') return name; }; +const addPairs = ( + animations: TransitionDirectionalAnimations | Record<string, TransitionAnimationPair>, + stylesheet: ViewTransitionStyleSheet +) => { + for (const [direction, images] of Object.entries(animations) as Entries<typeof animations>) { + for (const [image, rules] of Object.entries(images) as Entries< + (typeof animations)[typeof direction] + >) { + stylesheet.addAnimationPair(direction, image, rules); + } + } +}; + export function renderTransition( result: SSRResult, hash: string, @@ -48,13 +63,7 @@ export function renderTransition( const animations = getAnimations(animationName); if (animations) { - for (const [direction, images] of Object.entries(animations) as Entries<typeof animations>) { - for (const [image, rules] of Object.entries(images) as Entries< - (typeof animations)[typeof direction] - >) { - sheet.addAnimationPair(direction, image, rules); - } - } + addPairs(animations, sheet); } else if (animationName === 'none') { sheet.addFallback('old', 'animation: none; mix-blend-mode: normal;'); sheet.addModern('old', 'animation: none; opacity: 0; mix-blend-mode: normal;'); @@ -65,6 +74,19 @@ export function renderTransition( return scope; } +export function createAnimationScope( + transitionName: string, + animations: Record<string, TransitionAnimationPair> +) { + const hash = Math.random().toString(36).slice(2, 8); + const scope = `astro-${hash}`; + const sheet = new ViewTransitionStyleSheet(scope, transitionName); + + addPairs(animations, sheet); + + return { scope, styles: sheet.toString().replaceAll('"', '') }; +} + class ViewTransitionStyleSheet { private modern: string[] = []; private fallback: string[] = []; @@ -113,13 +135,18 @@ class ViewTransitionStyleSheet { } addAnimationPair( - direction: 'forwards' | 'backwards', + direction: 'forwards' | 'backwards' | string, image: 'old' | 'new', rules: TransitionAnimation | TransitionAnimation[] ) { const { scope, name } = this; const animation = stringifyAnimation(rules); - const prefix = direction === 'backwards' ? `[data-astro-transition=back]` : ''; + const prefix = + direction === 'backwards' + ? `[data-astro-transition=back]` + : direction === 'forwards' + ? '' + : `[data-astro-transition=${direction}]`; this.addRule('modern', `${prefix}::view-transition-${image}(${name}) { ${animation} }`); this.addRule( 'fallback', diff --git a/packages/astro/src/transitions/events.ts b/packages/astro/src/transitions/events.ts new file mode 100644 index 000000000..b3921b31f --- /dev/null +++ b/packages/astro/src/transitions/events.ts @@ -0,0 +1,184 @@ +import { updateScrollPosition } from './router.js'; +import type { Direction, NavigationTypeString } from './types.js'; + +export const TRANSITION_BEFORE_PREPARATION = 'astro:before-preparation'; +export const TRANSITION_AFTER_PREPARATION = 'astro:after-preparation'; +export const TRANSITION_BEFORE_SWAP = 'astro:before-swap'; +export const TRANSITION_AFTER_SWAP = 'astro:after-swap'; +export const TRANSITION_PAGE_LOAD = 'astro:page-load'; + +type Events = + | typeof TRANSITION_AFTER_PREPARATION + | typeof TRANSITION_AFTER_SWAP + | typeof TRANSITION_PAGE_LOAD; +export const triggerEvent = (name: Events) => document.dispatchEvent(new Event(name)); +export const onPageLoad = () => triggerEvent(TRANSITION_PAGE_LOAD); + +/* + * Common stuff + */ +class BeforeEvent extends Event { + readonly from: URL; + to: URL; + direction: Direction | string; + readonly navigationType: NavigationTypeString; + readonly sourceElement: Element | undefined; + readonly info: any; + newDocument: Document; + + constructor( + type: string, + eventInitDict: EventInit | undefined, + from: URL, + to: URL, + direction: Direction | string, + navigationType: NavigationTypeString, + sourceElement: Element | undefined, + info: any, + newDocument: Document + ) { + super(type, eventInitDict); + this.from = from; + this.to = to; + this.direction = direction; + this.navigationType = navigationType; + this.sourceElement = sourceElement; + this.info = info; + this.newDocument = newDocument; + + Object.defineProperties(this, { + from: { enumerable: true }, + to: { enumerable: true, writable: true }, + direction: { enumerable: true, writable: true }, + navigationType: { enumerable: true }, + sourceElement: { enumerable: true }, + info: { enumerable: true }, + newDocument: { enumerable: true, writable: true }, + }); + } +} + +/* + * TransitionBeforePreparationEvent + + */ +export const isTransitionBeforePreparationEvent = ( + value: any +): value is TransitionBeforePreparationEvent => value.type === TRANSITION_BEFORE_PREPARATION; +export class TransitionBeforePreparationEvent extends BeforeEvent { + formData: FormData | undefined; + loader: () => Promise<void>; + constructor( + from: URL, + to: URL, + direction: Direction | string, + navigationType: NavigationTypeString, + sourceElement: Element | undefined, + info: any, + newDocument: Document, + formData: FormData | undefined, + loader: (event: TransitionBeforePreparationEvent) => Promise<void> + ) { + super( + TRANSITION_BEFORE_PREPARATION, + { cancelable: true }, + from, + to, + direction, + navigationType, + sourceElement, + info, + newDocument + ); + this.formData = formData; + this.loader = loader.bind(this, this); + Object.defineProperties(this, { + formData: { enumerable: true }, + loader: { enumerable: true, writable: true }, + }); + } +} + +/* + * TransitionBeforeSwapEvent + */ + +export const isTransitionBeforeSwapEvent = (value: any): value is TransitionBeforeSwapEvent => + value.type === TRANSITION_BEFORE_SWAP; +export class TransitionBeforeSwapEvent extends BeforeEvent { + readonly direction: Direction | string; + readonly viewTransition: ViewTransition; + swap: () => void; + + constructor( + afterPreparation: BeforeEvent, + viewTransition: ViewTransition, + swap: (event: TransitionBeforeSwapEvent) => void + ) { + super( + TRANSITION_BEFORE_SWAP, + undefined, + afterPreparation.from, + afterPreparation.to, + afterPreparation.direction, + afterPreparation.navigationType, + afterPreparation.sourceElement, + afterPreparation.info, + afterPreparation.newDocument + ); + this.direction = afterPreparation.direction; + this.viewTransition = viewTransition; + this.swap = swap.bind(this, this); + + Object.defineProperties(this, { + direction: { enumerable: true }, + viewTransition: { enumerable: true }, + swap: { enumerable: true, writable: true }, + }); + } +} + +export async function doPreparation( + from: URL, + to: URL, + direction: Direction | string, + navigationType: NavigationTypeString, + sourceElement: Element | undefined, + info: any, + formData: FormData | undefined, + defaultLoader: (event: TransitionBeforePreparationEvent) => Promise<void> +) { + const event = new TransitionBeforePreparationEvent( + from, + to, + direction, + navigationType, + sourceElement, + info, + window.document, + formData, + defaultLoader + ); + if (document.dispatchEvent(event)) { + await event.loader(); + if (!event.defaultPrevented) { + triggerEvent(TRANSITION_AFTER_PREPARATION); + if (event.navigationType !== 'traverse') { + // save the current scroll position before we change the DOM and transition to the new page + updateScrollPosition({ scrollX, scrollY }); + } + } + } + return event; +} + +export async function doSwap( + afterPreparation: BeforeEvent, + viewTransition: ViewTransition, + defaultSwap: (event: TransitionBeforeSwapEvent) => void +) { + const event = new TransitionBeforeSwapEvent(afterPreparation, viewTransition, defaultSwap); + document.dispatchEvent(event); + event.swap(); + return event; +} diff --git a/packages/astro/src/transitions/index.ts b/packages/astro/src/transitions/index.ts index 0a58d2d4b..d87052f2d 100644 --- a/packages/astro/src/transitions/index.ts +++ b/packages/astro/src/transitions/index.ts @@ -1,4 +1,5 @@ import type { TransitionAnimationPair, TransitionDirectionalAnimations } from '../@types/astro.js'; +export { createAnimationScope } from '../runtime/server/transition.js'; const EASE_IN_OUT_QUART = 'cubic-bezier(0.76, 0, 0.24, 1)'; diff --git a/packages/astro/src/transitions/router.ts b/packages/astro/src/transitions/router.ts index c4da38c2c..a97abfcf7 100644 --- a/packages/astro/src/transitions/router.ts +++ b/packages/astro/src/transitions/router.ts @@ -1,23 +1,27 @@ -export type Fallback = 'none' | 'animate' | 'swap'; -export type Direction = 'forward' | 'back'; -export type Options = { - history?: 'auto' | 'push' | 'replace'; - formData?: FormData; -}; +import { + TRANSITION_AFTER_SWAP, + TransitionBeforeSwapEvent, + doPreparation, + doSwap, + type TransitionBeforePreparationEvent, +} from './events.js'; +import type { Direction, Fallback, Options } from './types.js'; type State = { index: number; scrollX: number; scrollY: number; - intraPage?: boolean; }; type Events = 'astro:page-load' | 'astro:after-swap'; // only update history entries that are managed by us // leave other entries alone and do not accidently add state. -const updateScrollPosition = (positions: { scrollX: number; scrollY: number }) => - history.state && history.replaceState({ ...history.state, ...positions }, ''); - +export const updateScrollPosition = (positions: { scrollX: number; scrollY: number }) => { + if (history.state) { + history.scrollRestoration = 'manual'; + history.replaceState({ ...history.state, ...positions }, ''); + } +}; const inBrowser = import.meta.env.SSR === false; export const supportsViewTransitions = inBrowser && !!document.startViewTransition; @@ -25,8 +29,21 @@ export const supportsViewTransitions = inBrowser && !!document.startViewTransiti export const transitionEnabledOnThisPage = () => inBrowser && !!document.querySelector('[name="astro-view-transitions-enabled"]'); -const samePage = (otherLocation: URL) => - location.pathname === otherLocation.pathname && location.search === otherLocation.search; +const samePage = (thisLocation: URL, otherLocation: URL) => + thisLocation.origin === otherLocation.origin && + thisLocation.pathname === otherLocation.pathname && + thisLocation.search === otherLocation.search; + +// When we traverse the history, the window.location is already set to the new location. +// This variable tells us where we came from +let originalLocation: URL; +// The result of startViewTransition (browser or simulation) +let viewTransition: ViewTransition | undefined; +// skip transition flag for fallback simulation +let skipTransition = false; +// The resolve function of the finished promise for fallback simulation +let viewTransitionFinished: () => void; + const triggerEvent = (name: Events) => document.dispatchEvent(new Event(name)); const onPageLoad = () => triggerEvent('astro:page-load'); const announce = () => { @@ -48,6 +65,9 @@ const announce = () => { }; const PERSIST_ATTR = 'data-astro-transition-persist'; +const DIRECTION_ATTR = 'data-astro-transition'; +const OLD_NEW_ATTR = 'data-astro-transition-fallback'; + const VITE_ID = 'data-vite-dev-id'; let parser: DOMParser; @@ -66,7 +86,8 @@ if (inBrowser) { } else if (transitionEnabledOnThisPage()) { // This page is loaded from the browser addressbar or via a link from extern, // it needs a state in the history - history.replaceState({ index: currentHistoryIndex, scrollX, scrollY, intraPage: false }, ''); + history.replaceState({ index: currentHistoryIndex, scrollX, scrollY }, ''); + history.scrollRestoration = 'manual'; } } @@ -147,50 +168,61 @@ function runScripts() { return wait; } -function isInfinite(animation: Animation) { - const effect = animation.effect; - if (!effect || !(effect instanceof KeyframeEffect) || !effect.target) return false; - const style = window.getComputedStyle(effect.target, effect.pseudoElement); - return style.animationIterationCount === 'infinite'; -} - // Add a new entry to the browser history. This also sets the new page in the browser addressbar. // Sets the scroll position according to the hash fragment of the new location. -const moveToLocation = (toLocation: URL, replace: boolean, intraPage: boolean) => { - const fresh = !samePage(toLocation); +const moveToLocation = (to: URL, from: URL, options: Options, historyState?: State) => { + const intraPage = samePage(from, to); + let scrolledToTop = false; - if (toLocation.href !== location.href) { - if (replace) { - history.replaceState({ ...history.state }, '', toLocation.href); + if (to.href !== location.href && !historyState) { + if (options.history === 'replace') { + const current = history.state; + history.replaceState( + { + ...options.state, + index: current.index, + scrollX: current.scrollX, + scrollY: current.scrollY, + }, + '', + to.href + ); } else { - history.replaceState({ ...history.state, intraPage }, ''); history.pushState( - { index: ++currentHistoryIndex, scrollX: 0, scrollY: 0 }, + { ...options.state, index: ++currentHistoryIndex, scrollX: 0, scrollY: 0 }, '', - toLocation.href + to.href ); } - // now we are on the new page for non-history navigations! - // (with history navigation page change happens before popstate is fired) - // freshly loaded pages start from the top - if (fresh) { - scrollTo({ left: 0, top: 0, behavior: 'instant' }); - scrolledToTop = true; - } + history.scrollRestoration = 'manual'; } - if (toLocation.hash) { - // because we are already on the target page ... - // ... what comes next is a intra-page navigation - // that won't reload the page but instead scroll to the fragment - location.href = toLocation.href; + // now we are on the new page for non-history navigations! + // (with history navigation page change happens before popstate is fired) + originalLocation = to; + + // freshly loaded pages start from the top + if (!intraPage) { + scrollTo({ left: 0, top: 0, behavior: 'instant' }); + scrolledToTop = true; + } + + if (historyState) { + scrollTo(historyState.scrollX, historyState.scrollY); } else { - if (!scrolledToTop) { - scrollTo({ left: 0, top: 0, behavior: 'instant' }); + if (to.hash) { + // because we are already on the target page ... + // ... what comes next is a intra-page navigation + // that won't reload the page but instead scroll to the fragment + location.href = to.href; + } else { + if (!scrolledToTop) { + scrollTo({ left: 0, top: 0, behavior: 'instant' }); + } } } }; -function stylePreloadLinks(newDocument: Document) { +function preloadStyleLinks(newDocument: Document) { const links: Promise<any>[] = []; for (const el of newDocument.querySelectorAll('head link[rel=stylesheet]')) { // Do not preload links that are already on the page. @@ -221,24 +253,23 @@ function stylePreloadLinks(newDocument: Document) { // if popState is given, this holds the scroll position for history navigation // if fallback === "animate" then simulate view transitions async function updateDOM( - newDocument: Document, - toLocation: URL, + preparationEvent: TransitionBeforePreparationEvent, options: Options, - popState?: State, + historyState?: State, fallback?: Fallback ) { // Check for a head element that should persist and returns it, // either because it has the data attribute or is a link el. // Returns null if the element is not part of the new head, undefined if it should be left alone. - const persistedHeadElement = (el: HTMLElement): Element | null => { + const persistedHeadElement = (el: HTMLElement, newDoc: Document): Element | null => { const id = el.getAttribute(PERSIST_ATTR); - const newEl = id && newDocument.head.querySelector(`[${PERSIST_ATTR}="${id}"]`); + const newEl = id && newDoc.head.querySelector(`[${PERSIST_ATTR}="${id}"]`); if (newEl) { return newEl; } if (el.matches('link[rel=stylesheet]')) { const href = el.getAttribute('href'); - return newDocument.head.querySelector(`link[rel=stylesheet][href="${href}"]`); + return newDoc.head.querySelector(`link[rel=stylesheet][href="${href}"]`); } return null; }; @@ -282,22 +313,22 @@ async function updateDOM( } }; - const swap = () => { + const defaultSwap = (beforeSwapEvent: TransitionBeforeSwapEvent) => { // swap attributes of the html element // - delete all attributes from the current document // - insert all attributes from doc // - reinsert all original attributes that are named 'data-astro-*' const html = document.documentElement; - const astro = [...html.attributes].filter( + const astroAttributes = [...html.attributes].filter( ({ name }) => (html.removeAttribute(name), name.startsWith('data-astro-')) ); - [...newDocument.documentElement.attributes, ...astro].forEach(({ name, value }) => - html.setAttribute(name, value) + [...beforeSwapEvent.newDocument.documentElement.attributes, ...astroAttributes].forEach( + ({ name, value }) => html.setAttribute(name, value) ); // Replace scripts in both the head and body. for (const s1 of document.scripts) { - for (const s2 of newDocument.scripts) { + for (const s2 of beforeSwapEvent.newDocument.scripts) { if ( // Inline (!s1.src && s1.textContent === s2.textContent) || @@ -313,7 +344,7 @@ async function updateDOM( // Swap head for (const el of Array.from(document.head.children)) { - const newEl = persistedHeadElement(el as HTMLElement); + const newEl = persistedHeadElement(el as HTMLElement, beforeSwapEvent.newDocument); // If the element exists in the document already, remove it // from the new document and leave the current node alone if (newEl) { @@ -325,7 +356,7 @@ async function updateDOM( } // Everything left in the new head is new, append it all. - document.head.append(...newDocument.head.children); + document.head.append(...beforeSwapEvent.newDocument.head.children); // Persist elements in the existing body const oldBody = document.body; @@ -333,7 +364,7 @@ async function updateDOM( const savedFocus = saveFocus(); // this will reset scroll Position - document.body.replaceWith(newDocument.body); + document.body.replaceWith(beforeSwapEvent.newDocument.body); for (const el of oldBody.querySelectorAll(`[${PERSIST_ATTR}]`)) { const id = el.getAttribute(PERSIST_ATTR); @@ -345,103 +376,187 @@ async function updateDOM( } } restoreFocus(savedFocus); - - if (popState) { - scrollTo(popState.scrollX, popState.scrollY); // usings 'auto' scrollBehavior - } else { - moveToLocation(toLocation, options.history === 'replace', false); - } - - triggerEvent('astro:after-swap'); }; - const links = stylePreloadLinks(newDocument); - links.length && (await Promise.all(links)); - - if (fallback === 'animate') { + async function animate(phase: string) { + function isInfinite(animation: Animation) { + const effect = animation.effect; + if (!effect || !(effect instanceof KeyframeEffect) || !effect.target) return false; + const style = window.getComputedStyle(effect.target, effect.pseudoElement); + return style.animationIterationCount === 'infinite'; + } // Trigger the animations const currentAnimations = document.getAnimations(); - document.documentElement.dataset.astroTransitionFallback = 'old'; - const newAnimations = document - .getAnimations() - .filter((a) => !currentAnimations.includes(a) && !isInfinite(a)); - const finished = Promise.all(newAnimations.map((a) => a.finished)); - await finished; - swap(); - document.documentElement.dataset.astroTransitionFallback = 'new'; + document.documentElement.setAttribute(OLD_NEW_ATTR, phase); + const nextAnimations = document.getAnimations(); + const newAnimations = nextAnimations.filter( + (a) => !currentAnimations.includes(a) && !isInfinite(a) + ); + return Promise.all(newAnimations.map((a) => a.finished)); + } + + if (!skipTransition) { + document.documentElement.setAttribute(DIRECTION_ATTR, preparationEvent.direction); + + if (fallback === 'animate') { + await animate('old'); + } } else { - swap(); + // that's what Chrome does + throw new DOMException('Transition was skipped'); + } + + const swapEvent = await doSwap(preparationEvent, viewTransition!, defaultSwap); + moveToLocation(swapEvent.to, swapEvent.from, options, historyState); + triggerEvent(TRANSITION_AFTER_SWAP); + + if (fallback === 'animate' && !skipTransition) { + animate('new').then(() => viewTransitionFinished()); } } async function transition( direction: Direction, - toLocation: URL, + from: URL, + to: URL, options: Options, - popState?: State + historyState?: State ) { - let finished: Promise<void>; - const href = toLocation.href; - const init: RequestInit = {}; - if (options.formData) { - init.method = 'POST'; - init.body = options.formData; + const navigationType = historyState + ? 'traverse' + : options.history === 'replace' + ? 'replace' + : 'push'; + + if (samePage(from, to) && !options.formData /* not yet: && to.hash*/) { + if (navigationType !== 'traverse') { + updateScrollPosition({ scrollX, scrollY }); + } + moveToLocation(to, from, options, historyState); + return; } - const response = await fetchHTML(href, init); - // If there is a problem fetching the new page, just do an MPA navigation to it. - if (response === null) { - location.href = href; + + const prepEvent = await doPreparation( + from, + to, + direction, + navigationType, + options.sourceElement, + options.info, + options.formData, + defaultLoader + ); + if (prepEvent.defaultPrevented) { + location.href = to.href; return; } - // if there was a redirection, show the final URL in the browser's address bar - if (response.redirected) { - toLocation = new URL(response.redirected); + + function pageMustReload(preparationEvent: TransitionBeforePreparationEvent) { + return ( + preparationEvent.to.hash === '' || + !samePage(preparationEvent.from, preparationEvent.to) || + preparationEvent.sourceElement instanceof HTMLFormElement + ); } - parser ??= new DOMParser(); + async function defaultLoader(preparationEvent: TransitionBeforePreparationEvent) { + if (pageMustReload(preparationEvent)) { + const href = preparationEvent.to.href; + const init: RequestInit = {}; + if (preparationEvent.formData) { + init.method = 'POST'; + init.body = preparationEvent.formData; + } + const response = await fetchHTML(href, init); + // If there is a problem fetching the new page, just do an MPA navigation to it. + if (response === null) { + preparationEvent.preventDefault(); + return; + } + // if there was a redirection, show the final URL in the browser's address bar + if (response.redirected) { + preparationEvent.to = new URL(response.redirected); + } + + parser ??= new DOMParser(); - const newDocument = parser.parseFromString(response.html, response.mediaType); - // The next line might look like a hack, - // but it is actually necessary as noscript elements - // and their contents are returned as markup by the parser, - // see https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString - newDocument.querySelectorAll('noscript').forEach((el) => el.remove()); + preparationEvent.newDocument = parser.parseFromString(response.html, response.mediaType); + // The next line might look like a hack, + // but it is actually necessary as noscript elements + // and their contents are returned as markup by the parser, + // see https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString + preparationEvent.newDocument.querySelectorAll('noscript').forEach((el) => el.remove()); - // If ViewTransitions is not enabled on the incoming page, do a full page load to it. - // Unless this was a form submission, in which case we do not want to trigger another mutation. - if (!newDocument.querySelector('[name="astro-view-transitions-enabled"]') && !options.formData) { - location.href = href; - return; - } + // If ViewTransitions is not enabled on the incoming page, do a full page load to it. + // Unless this was a form submission, in which case we do not want to trigger another mutation. + if ( + !preparationEvent.newDocument.querySelector('[name="astro-view-transitions-enabled"]') && + !preparationEvent.formData + ) { + preparationEvent.preventDefault(); + return; + } - if (import.meta.env.DEV) await prepareForClientOnlyComponents(newDocument, toLocation); + const links = preloadStyleLinks(preparationEvent.newDocument); + links.length && (await Promise.all(links)); - if (!popState) { - // save the current scroll position before we change the DOM and transition to the new page - history.replaceState({ ...history.state, scrollX, scrollY }, ''); + if (import.meta.env.DEV) + await prepareForClientOnlyComponents(preparationEvent.newDocument, preparationEvent.to); + } else { + preparationEvent.newDocument = document; + return; + } } - document.documentElement.dataset.astroTransition = direction; + + skipTransition = false; if (supportsViewTransitions) { - finished = document.startViewTransition(() => - updateDOM(newDocument, toLocation, options, popState) - ).finished; + viewTransition = document.startViewTransition( + async () => await updateDOM(prepEvent, options, historyState) + ); } else { - finished = updateDOM(newDocument, toLocation, options, popState, getFallback()); + const updateDone = (async () => { + // immediatelly paused to setup the ViewTransition object for Fallback mode + await new Promise((r) => setTimeout(r)); + await updateDOM(prepEvent, options, historyState, getFallback()); + })(); + + // When the updateDone promise is settled, + // we have run and awaited all swap functions and the after-swap event + // This qualifies for "updateCallbackDone". + // + // For the build in ViewTransition, "ready" settles shortly after "updateCallbackDone", + // i.e. after all pseudo elements are created and the animation is about to start. + // In simulation mode the "old" animation starts before swap, + // the "new" animation starts after swap. That is not really comparable. + // Thus we go with "very, very shortly after updateCallbackDone" and make both equal. + // + // "finished" resolves after all animations are done. + + viewTransition = { + updateCallbackDone: updateDone, // this is about correct + ready: updateDone, // good enough + finished: new Promise((r) => (viewTransitionFinished = r)), // see end of updateDOM + skipTransition: () => { + skipTransition = true; + }, + }; } - try { - await finished; - } finally { - // skip this for the moment as it tends to stop fallback animations - // document.documentElement.removeAttribute('data-astro-transition'); + + viewTransition.ready.then(async () => { await runScripts(); onPageLoad(); announce(); - } + }); + viewTransition.finished.then(() => { + document.documentElement.removeAttribute(DIRECTION_ATTR); + document.documentElement.removeAttribute(OLD_NEW_ATTR); + }); + await viewTransition.ready; } let navigateOnServerWarned = false; -export function navigate(href: string, options?: Options) { +export async function navigate(href: string, options?: Options) { if (inBrowser === false) { if (!navigateOnServerWarned) { // instantiate an error for the stacktrace to show to user. @@ -461,17 +576,7 @@ export function navigate(href: string, options?: Options) { location.href = href; return; } - const toLocation = new URL(href, location.href); - // We do not have page transitions on navigations to the same page (intra-page navigation) - // *unless* they are form posts which have side-effects and so need to happen - // but we want to handle prevent reload on navigation to the same page - // Same page means same origin, path and query params (but maybe different hash) - if (location.origin === toLocation.origin && samePage(toLocation) && !options?.formData) { - moveToLocation(toLocation, options?.history === 'replace', true); - } else { - // different origin will be detected by fetch - transition('forward', toLocation, options ?? {}); - } + await transition('forward', originalLocation, new URL(href, location.href), options ?? {}); } function onPopState(ev: PopStateEvent) { @@ -479,10 +584,6 @@ function onPopState(ev: PopStateEvent) { // The current page doesn't have View Transitions enabled // but the page we navigate to does (because it set the state). // Do a full page refresh to reload the client-side router from the new page. - // Scroll restauration will then happen during the reload when the router's code is re-executed - if (history.scrollRestoration) { - history.scrollRestoration = 'manual'; - } location.reload(); return; } @@ -492,28 +593,13 @@ function onPopState(ev: PopStateEvent) { // Just ignore stateless entries. // The browser will handle navigation fine without our help if (ev.state === null) { - if (history.scrollRestoration) { - history.scrollRestoration = 'auto'; - } return; } - - // With the default "auto", the browser will jump to the old scroll position - // before the ViewTransition is complete. - if (history.scrollRestoration) { - history.scrollRestoration = 'manual'; - } - const state: State = history.state; - if (state.intraPage) { - // this is non transition intra-page scrolling - scrollTo(state.scrollX, state.scrollY); - } else { - const nextIndex = state.index; - const direction: Direction = nextIndex > currentHistoryIndex ? 'forward' : 'back'; - currentHistoryIndex = nextIndex; - transition(direction, new URL(location.href), {}, state); - } + const nextIndex = state.index; + const direction: Direction = nextIndex > currentHistoryIndex ? 'forward' : 'back'; + currentHistoryIndex = nextIndex; + transition(direction, originalLocation, new URL(location.href), {}, state); } // There's not a good way to record scroll position before a back button. @@ -522,8 +608,10 @@ const onScroll = () => { updateScrollPosition({ scrollX, scrollY }); }; +// initialization if (inBrowser) { if (supportsViewTransitions || getFallback() !== 'none') { + originalLocation = new URL(location.href); addEventListener('popstate', onPopState); addEventListener('load', onPageLoad); if ('onscrollend' in window) addEventListener('scrollend', onScroll); diff --git a/packages/astro/src/transitions/types.ts b/packages/astro/src/transitions/types.ts new file mode 100644 index 000000000..0e70825e5 --- /dev/null +++ b/packages/astro/src/transitions/types.ts @@ -0,0 +1,10 @@ +export type Fallback = 'none' | 'animate' | 'swap'; +export type Direction = 'forward' | 'back'; +export type NavigationTypeString = 'push' | 'replace' | 'traverse'; +export type Options = { + history?: 'auto' | 'push' | 'replace'; + info?: any; + state?: any; + formData?: FormData; + sourceElement?: Element; // more than HTMLElement, e.g. SVGAElement +}; diff --git a/packages/astro/src/transitions/vite-plugin-transitions.ts b/packages/astro/src/transitions/vite-plugin-transitions.ts index cd5b0e616..a3d68ade6 100644 --- a/packages/astro/src/transitions/vite-plugin-transitions.ts +++ b/packages/astro/src/transitions/vite-plugin-transitions.ts @@ -27,7 +27,14 @@ export default function astroTransitions({ settings }: { settings: AstroSettings } if (id === resolvedVirtualClientModuleId) { return ` - export * from "astro/virtual-modules/transitions-router.js"; + export { navigate, supportsViewTransitions, transitionEnabledOnThisPage } from "astro/virtual-modules/transitions-router.js"; + export * from "astro/virtual-modules/transitions-types.js"; + export { + TRANSITION_BEFORE_PREPARATION, isTransitionBeforePreparationEvent, TransitionBeforePreparationEvent, + TRANSITION_AFTER_PREPARATION, + TRANSITION_BEFORE_SWAP, isTransitionBeforeSwapEvent, TransitionBeforeSwapEvent, + TRANSITION_AFTER_SWAP, TRANSITION_PAGE_LOAD + } from "astro/virtual-modules/transitions-events.js"; `; } }, diff --git a/packages/astro/src/virtual-modules/transitions-events.ts b/packages/astro/src/virtual-modules/transitions-events.ts new file mode 100644 index 000000000..35ecaf64f --- /dev/null +++ b/packages/astro/src/virtual-modules/transitions-events.ts @@ -0,0 +1 @@ +export * from '../transitions/events.js'; diff --git a/packages/astro/src/virtual-modules/transitions-types.ts b/packages/astro/src/virtual-modules/transitions-types.ts new file mode 100644 index 000000000..66dfb1d0e --- /dev/null +++ b/packages/astro/src/virtual-modules/transitions-types.ts @@ -0,0 +1 @@ +export * from '../transitions/types.js'; diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts index 0863ad1b4..f87c4e147 100644 --- a/packages/astro/src/vite-plugin-astro-server/route.ts +++ b/packages/astro/src/vite-plugin-astro-server/route.ts @@ -215,6 +215,7 @@ export async function handleRoute({ segments: [], type: 'fallback', route: '', + fallbackRoutes: [], }; renderContext = await createRenderContext({ request, @@ -222,6 +223,9 @@ export async function handleRoute({ env, mod, route, + locales: manifest.i18n?.locales, + routingStrategy: manifest.i18n?.routingStrategy, + defaultLocale: manifest.i18n?.defaultLocale, }); } else { return handle404Response(origin, incomingRequest, incomingResponse); @@ -278,7 +282,9 @@ export async function handleRoute({ route: options.route, mod, env, - locales: i18n ? i18n.locales : undefined, + locales: i18n?.locales, + routingStrategy: i18n?.routingStrategy, + defaultLocale: i18n?.defaultLocale, }); } |