diff options
author | 2023-11-22 22:37:18 +0800 | |
---|---|---|
committer | 2023-11-22 22:37:18 +0800 | |
commit | 19fd19f3934a115471e5caff08fe5b8ba404b739 (patch) | |
tree | 8802cfef36ad13cfe9b9b24b3480d4612cf3f76e | |
parent | 710be505c9ddf416e77a75343d8cae9c497d72c6 (diff) | |
parent | 4d1274c47987bf3c247f0ad02309f3c2b9968d6e (diff) | |
download | astro-19fd19f3934a115471e5caff08fe5b8ba404b739.tar.gz astro-19fd19f3934a115471e5caff08fe5b8ba404b739.tar.zst astro-19fd19f3934a115471e5caff08fe5b8ba404b739.zip |
Merge branch 'main' into next
74 files changed, 1320 insertions, 523 deletions
diff --git a/.changeset/sour-games-burn.md b/.changeset/sour-games-burn.md index 64203dbd4..f0d596603 100644 --- a/.changeset/sour-games-burn.md +++ b/.changeset/sour-games-burn.md @@ -7,5 +7,7 @@ Refactors virtual modules exports. This should not break your project unless you - `astro/middleware/namespace` - `astro/transitions` - `astro/transitions/router` +- `astro/transitions/events` +- `astro/transitions/types` - `astro/prefetch` - `astro/i18n` diff --git a/examples/basics/package.json b/examples/basics/package.json index 1be44bed6..df75ae3d1 100644 --- a/examples/basics/package.json +++ b/examples/basics/package.json @@ -11,6 +11,6 @@ "astro": "astro" }, "dependencies": { - "astro": "^3.5.5" + "astro": "^3.6.0" } } diff --git a/examples/blog/package.json b/examples/blog/package.json index afee1f028..f52fb2940 100644 --- a/examples/blog/package.json +++ b/examples/blog/package.json @@ -14,6 +14,6 @@ "@astrojs/mdx": "^1.1.5", "@astrojs/rss": "^3.0.0", "@astrojs/sitemap": "^3.0.3", - "astro": "^3.5.5" + "astro": "^3.6.0" } } diff --git a/examples/component/package.json b/examples/component/package.json index 2c7812172..b91bdea71 100644 --- a/examples/component/package.json +++ b/examples/component/package.json @@ -15,7 +15,7 @@ ], "scripts": {}, "devDependencies": { - "astro": "^3.5.5" + "astro": "^3.6.0" }, "peerDependencies": { "astro": "^3.0.0" diff --git a/examples/framework-alpine/package.json b/examples/framework-alpine/package.json index 9e25ec9fc..be3bdaf24 100644 --- a/examples/framework-alpine/package.json +++ b/examples/framework-alpine/package.json @@ -14,6 +14,6 @@ "@astrojs/alpinejs": "^0.3.1", "@types/alpinejs": "^3.13.5", "alpinejs": "^3.13.3", - "astro": "^3.5.5" + "astro": "^3.6.0" } } diff --git a/examples/framework-lit/package.json b/examples/framework-lit/package.json index 97f9a4dcb..91f9cfaa8 100644 --- a/examples/framework-lit/package.json +++ b/examples/framework-lit/package.json @@ -13,7 +13,7 @@ "dependencies": { "@astrojs/lit": "^3.0.3", "@webcomponents/template-shadowroot": "^0.2.1", - "astro": "^3.5.5", + "astro": "^3.6.0", "lit": "^2.8.0" } } diff --git a/examples/framework-multiple/package.json b/examples/framework-multiple/package.json index a63bbf128..b2cd71afc 100644 --- a/examples/framework-multiple/package.json +++ b/examples/framework-multiple/package.json @@ -12,11 +12,11 @@ }, "dependencies": { "@astrojs/preact": "^3.0.1", - "@astrojs/react": "^3.0.5", + "@astrojs/react": "^3.0.6", "@astrojs/solid-js": "^3.0.2", "@astrojs/svelte": "^4.0.4", "@astrojs/vue": "^3.0.4", - "astro": "^3.5.5", + "astro": "^3.6.0", "preact": "^10.19.2", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/examples/framework-preact/package.json b/examples/framework-preact/package.json index 5842b2ac9..dfe5d6ee1 100644 --- a/examples/framework-preact/package.json +++ b/examples/framework-preact/package.json @@ -13,7 +13,7 @@ "dependencies": { "@astrojs/preact": "^3.0.1", "@preact/signals": "^1.2.1", - "astro": "^3.5.5", + "astro": "^3.6.0", "preact": "^10.19.2" } } diff --git a/examples/framework-react/package.json b/examples/framework-react/package.json index c1a1487bf..1c4bbdb4f 100644 --- a/examples/framework-react/package.json +++ b/examples/framework-react/package.json @@ -11,10 +11,10 @@ "astro": "astro" }, "dependencies": { - "@astrojs/react": "^3.0.5", + "@astrojs/react": "^3.0.6", "@types/react": "^18.2.37", "@types/react-dom": "^18.2.15", - "astro": "^3.5.5", + "astro": "^3.6.0", "react": "^18.2.0", "react-dom": "^18.2.0" } diff --git a/examples/framework-solid/package.json b/examples/framework-solid/package.json index d1e57543f..94fae75ad 100644 --- a/examples/framework-solid/package.json +++ b/examples/framework-solid/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@astrojs/solid-js": "^3.0.2", - "astro": "^3.5.5", + "astro": "^3.6.0", "solid-js": "^1.8.5" } } diff --git a/examples/framework-svelte/package.json b/examples/framework-svelte/package.json index 370a90141..3f1a0b61c 100644 --- a/examples/framework-svelte/package.json +++ b/examples/framework-svelte/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@astrojs/svelte": "^4.0.4", - "astro": "^3.5.5", + "astro": "^3.6.0", "svelte": "^4.2.5" } } diff --git a/examples/framework-vue/package.json b/examples/framework-vue/package.json index d9fd35d0f..6ac6c2b6c 100644 --- a/examples/framework-vue/package.json +++ b/examples/framework-vue/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@astrojs/vue": "^3.0.4", - "astro": "^3.5.5", + "astro": "^3.6.0", "vue": "^3.3.8" } } diff --git a/examples/hackernews/package.json b/examples/hackernews/package.json index fa19009ab..527678f4b 100644 --- a/examples/hackernews/package.json +++ b/examples/hackernews/package.json @@ -12,6 +12,6 @@ }, "dependencies": { "@astrojs/node": "^6.0.4", - "astro": "^3.5.5" + "astro": "^3.6.0" } } diff --git a/examples/integration/package.json b/examples/integration/package.json index 262ba7aeb..463e0dfa6 100644 --- a/examples/integration/package.json +++ b/examples/integration/package.json @@ -15,7 +15,7 @@ ], "scripts": {}, "devDependencies": { - "astro": "^3.5.5" + "astro": "^3.6.0" }, "peerDependencies": { "astro": "^3.0.0" diff --git a/examples/middleware/package.json b/examples/middleware/package.json index 7c3da323e..efa7572e1 100644 --- a/examples/middleware/package.json +++ b/examples/middleware/package.json @@ -13,7 +13,7 @@ }, "dependencies": { "@astrojs/node": "^6.0.4", - "astro": "^3.5.5", + "astro": "^3.6.0", "html-minifier": "^4.0.0" } } diff --git a/examples/minimal/package.json b/examples/minimal/package.json index 9577132a2..c2cd8766b 100644 --- a/examples/minimal/package.json +++ b/examples/minimal/package.json @@ -11,6 +11,6 @@ "astro": "astro" }, "dependencies": { - "astro": "^3.5.5" + "astro": "^3.6.0" } } diff --git a/examples/non-html-pages/package.json b/examples/non-html-pages/package.json index 9b8a6e248..80da364de 100644 --- a/examples/non-html-pages/package.json +++ b/examples/non-html-pages/package.json @@ -11,6 +11,6 @@ "astro": "astro" }, "dependencies": { - "astro": "^3.5.5" + "astro": "^3.6.0" } } diff --git a/examples/portfolio/package.json b/examples/portfolio/package.json index 375bb8ff5..427e47fd4 100644 --- a/examples/portfolio/package.json +++ b/examples/portfolio/package.json @@ -11,6 +11,6 @@ "astro": "astro" }, "dependencies": { - "astro": "^3.5.5" + "astro": "^3.6.0" } } diff --git a/examples/ssr/package.json b/examples/ssr/package.json index ad05511bb..80b315e62 100644 --- a/examples/ssr/package.json +++ b/examples/ssr/package.json @@ -14,7 +14,7 @@ "dependencies": { "@astrojs/node": "^6.0.4", "@astrojs/svelte": "^4.0.4", - "astro": "^3.5.5", + "astro": "^3.6.0", "svelte": "^4.2.5" } } diff --git a/examples/view-transitions/package.json b/examples/view-transitions/package.json index b2904039c..03790360e 100644 --- a/examples/view-transitions/package.json +++ b/examples/view-transitions/package.json @@ -12,6 +12,6 @@ "devDependencies": { "@astrojs/tailwind": "^5.0.2", "@astrojs/node": "^6.0.4", - "astro": "^3.5.5" + "astro": "^3.6.0" } } diff --git a/examples/with-markdoc/package.json b/examples/with-markdoc/package.json index 8447aa0cf..1ff0c3e23 100644 --- a/examples/with-markdoc/package.json +++ b/examples/with-markdoc/package.json @@ -12,6 +12,6 @@ }, "dependencies": { "@astrojs/markdoc": "^0.7.2", - "astro": "^3.5.5" + "astro": "^3.6.0" } } diff --git a/examples/with-markdown-plugins/package.json b/examples/with-markdown-plugins/package.json index 4d2efc5e8..0efdc3381 100644 --- a/examples/with-markdown-plugins/package.json +++ b/examples/with-markdown-plugins/package.json @@ -12,7 +12,7 @@ }, "dependencies": { "@astrojs/markdown-remark": "^3.5.0", - "astro": "^3.5.5", + "astro": "^3.6.0", "hast-util-select": "^6.0.2", "rehype-autolink-headings": "^7.1.0", "rehype-slug": "^6.0.0", diff --git a/examples/with-markdown-shiki/package.json b/examples/with-markdown-shiki/package.json index f44759d76..f69cbf555 100644 --- a/examples/with-markdown-shiki/package.json +++ b/examples/with-markdown-shiki/package.json @@ -11,6 +11,6 @@ "astro": "astro" }, "dependencies": { - "astro": "^3.5.5" + "astro": "^3.6.0" } } diff --git a/examples/with-mdx/package.json b/examples/with-mdx/package.json index ab0577144..edcdd454f 100644 --- a/examples/with-mdx/package.json +++ b/examples/with-mdx/package.json @@ -13,7 +13,7 @@ "dependencies": { "@astrojs/mdx": "^1.1.5", "@astrojs/preact": "^3.0.1", - "astro": "^3.5.5", + "astro": "^3.6.0", "preact": "^10.19.2" } } diff --git a/examples/with-nanostores/package.json b/examples/with-nanostores/package.json index 7946c0e3c..7c00f6e3a 100644 --- a/examples/with-nanostores/package.json +++ b/examples/with-nanostores/package.json @@ -13,7 +13,7 @@ "dependencies": { "@astrojs/preact": "^3.0.1", "@nanostores/preact": "^0.5.0", - "astro": "^3.5.5", + "astro": "^3.6.0", "nanostores": "^0.9.5", "preact": "^10.19.2" } diff --git a/examples/with-tailwindcss/package.json b/examples/with-tailwindcss/package.json index 166fd02ea..aa4d421b3 100644 --- a/examples/with-tailwindcss/package.json +++ b/examples/with-tailwindcss/package.json @@ -14,7 +14,7 @@ "@astrojs/mdx": "^1.1.5", "@astrojs/tailwind": "^5.0.2", "@types/canvas-confetti": "^1.6.3", - "astro": "^3.5.5", + "astro": "^3.6.0", "autoprefixer": "^10.4.15", "canvas-confetti": "^1.9.1", "postcss": "^8.4.28", diff --git a/examples/with-vite-plugin-pwa/package.json b/examples/with-vite-plugin-pwa/package.json index 775f5752e..70b172ed5 100644 --- a/examples/with-vite-plugin-pwa/package.json +++ b/examples/with-vite-plugin-pwa/package.json @@ -11,7 +11,7 @@ "astro": "astro" }, "dependencies": { - "astro": "^3.5.5", + "astro": "^3.6.0", "vite-plugin-pwa": "0.17.0", "workbox-window": "^7.0.0" } diff --git a/examples/with-vitest/package.json b/examples/with-vitest/package.json index 5259966bb..277d7eb94 100644 --- a/examples/with-vitest/package.json +++ b/examples/with-vitest/package.json @@ -12,7 +12,7 @@ "test": "vitest" }, "dependencies": { - "astro": "^3.5.5", + "astro": "^3.6.0", "vitest": "^0.34.2" } } diff --git a/packages/astro/CHANGELOG.md b/packages/astro/CHANGELOG.md index a30618339..1e6a1769a 100644 --- a/packages/astro/CHANGELOG.md +++ b/packages/astro/CHANGELOG.md @@ -1,5 +1,53 @@ # astro +## 3.6.0 + +### Minor Changes + +- [#9090](https://github.com/withastro/astro/pull/9090) [`c87223c21`](https://github.com/withastro/astro/commit/c87223c21ab5d515fb8f04ee10be5c0ca51e0b29) Thanks [@martrapp](https://github.com/martrapp)! - Take full control over the behavior of view transitions! + + Three new events now complement the existing `astro:after-swap` and `astro:page-load` events: + + ```javascript + astro: before - preparation; // Control how the DOM and other resources of the target page are loaded + astro: after - preparation; // Last changes before taking off? Remove that loading indicator? Here you go! + astro: before - swap; // Control how the DOM is updated to match the new page + ``` + + The `astro:before-*` events allow you to change properties and strategies of the view transition implementation. + The `astro:after-*` events are notifications that a phase is complete. + Head over to docs to see [the full view transitions lifecycle](https://docs.astro.build/en/guides/view-transitions/#lifecycle-events) including these new events! + +- [#9092](https://github.com/withastro/astro/pull/9092) [`0ea4bd47e`](https://github.com/withastro/astro/commit/0ea4bd47e0d7cc98c43568a55aa87da772bd2e0a) Thanks [@smitbarmase](https://github.com/smitbarmase)! - Changes the fallback prefetch behavior on slow connections and when data saver mode is enabled. Instead of disabling prefetch entirely, the `tap` strategy will be used. + +- [#9166](https://github.com/withastro/astro/pull/9166) [`cba6cf32d`](https://github.com/withastro/astro/commit/cba6cf32d9bf1f5c3268808f185a4824d6fbd7f4) Thanks [@matthewp](https://github.com/matthewp)! - The Picture component is no longer experimental + + The `<Picture />` component, part of `astro:assets`, has exited experimental status and is now recommended for use. There are no code changes to the component, and no upgrade to your project is necessary. + + This is only a change in documentation/recommendation. If you were waiting to use the `<Picture />` component until it had exited the experimental stage, wait no more! + +- [#9092](https://github.com/withastro/astro/pull/9092) [`0ea4bd47e`](https://github.com/withastro/astro/commit/0ea4bd47e0d7cc98c43568a55aa87da772bd2e0a) Thanks [@smitbarmase](https://github.com/smitbarmase)! - Adds a `ignoreSlowConnection` option to the `prefetch()` API to prefetch even on data saver mode or slow connection. + +## 3.5.7 + +### Patch Changes + +- [#9157](https://github.com/withastro/astro/pull/9157) [`7ff8d62bf`](https://github.com/withastro/astro/commit/7ff8d62bf861694067491ff17d01b1b0f6809d6b) Thanks [@ematipico](https://github.com/ematipico)! - Revert fix around fallback system, which broken injected styles + +## 3.5.6 + +### Patch Changes + +- [#9121](https://github.com/withastro/astro/pull/9121) [`f4efd1c80`](https://github.com/withastro/astro/commit/f4efd1c808476c7e60fe00fcfb86276cf14fee79) Thanks [@peng](https://github.com/peng)! - Adds a warning if `astro add` fetches a package but returns a non-404 status + +- [#9142](https://github.com/withastro/astro/pull/9142) [`7d55cf68d`](https://github.com/withastro/astro/commit/7d55cf68d89cb46bfb89a109b09af61be8431c89) Thanks [@ematipico](https://github.com/ematipico)! - Consistely emit fallback routes in the correct folders. + +- [#9119](https://github.com/withastro/astro/pull/9119) [`306781795`](https://github.com/withastro/astro/commit/306781795d5f4b755bbdf650a937f1f3c00030bd) Thanks [@ematipico](https://github.com/ematipico)! - Fix a flaw in the i18n fallback logic, where the routes didn't preserve their metadata, such as hoisted scripts + +- [#9140](https://github.com/withastro/astro/pull/9140) [`7742fd7dc`](https://github.com/withastro/astro/commit/7742fd7dc26533c6f7cd497b00b72de935c57628) Thanks [@martrapp](https://github.com/martrapp)! - View Transitions: handle clicks on SVGAElements and image maps" + +- [#9101](https://github.com/withastro/astro/pull/9101) [`e3dce215a`](https://github.com/withastro/astro/commit/e3dce215a5ea06bcff1b21027e5613e6518c69d4) Thanks [@ematipico](https://github.com/ematipico)! - Add a new property `Astro.currentLocale`, available when `i18n` is enabled. + ## 3.5.5 ### Patch Changes diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts index ee4c9df7e..fe1a23311 100644 --- a/packages/astro/client.d.ts +++ b/packages/astro/client.d.ts @@ -109,6 +109,7 @@ declare module 'astro:transitions' { type TransitionModule = typeof import('./dist/virtual-modules/transitions.js'); export const slide: TransitionModule['slide']; export const fade: TransitionModule['fade']; + export const createAnimationScope: TransitionModule['createAnimationScope']; type ViewTransitionsModule = typeof import('./components/ViewTransitions.astro'); export const ViewTransitions: ViewTransitionsModule['default']; @@ -116,10 +117,30 @@ declare module 'astro:transitions' { declare module 'astro:transitions/client' { type TransitionRouterModule = typeof import('./dist/virtual-modules/transitions-router.js'); - export const supportsViewTransitions: TransitionRouterModule['supportsViewTransitions']; - export const transitionEnabledOnThisPage: TransitionRouterModule['transitionEnabledOnThisPage']; export const navigate: TransitionRouterModule['navigate']; - export type Options = import('./dist/virtual-modules/transitions-router.js').Options; + + type TransitionUtilModule = typeof import('./dist/virtual-modules/transitions-util.js'); + export const supportsViewTransitions: TransitionUtilModule['supportsViewTransitions']; + export const getFallback: TransitionUtilModule['getFallback']; + export const transitionEnabledOnThisPage: TransitionUtilModule['transitionEnabledOnThisPage']; + + export type Fallback = import('./dist/virtual-modules/transitions-types.js').Fallback; + export type Direction = import('./dist/virtual-modules/transitions-types.ts').Direction; + export type NavigationTypeString = import('./dist/virtual-modules/transitions-types.js').NavigationTypeString; + export type Options = import('./dist/virtual-modules/transitions-types.js').Options; + + type EventModule = typeof import('./dist/virtual-modules/transitions-events.js'); + export const TRANSITION_BEFORE_PREPARATION: EventModule['TRANSITION_BEFORE_PREPARATION']; + export const TRANSITION_AFTER_PREPARATION: EventModule['TRANSITION_AFTER_PREPARATION']; + export const TRANSITION_BEFORE_SWAP: EventModule['TRANSITION_BEFORE_SWAP']; + export const TRANSITION_AFTER_SWAP: EventModule['TRANSITION_AFTER_SWAP']; + export const TRANSITION_PAGE_LOAD: EventModule['TRANSITION_PAGE_LOAD']; + export type TransitionBeforePreparationEvent = + import('./dist/virtual-modules/transitions-events.js').TransitionBeforePreparationEvent; + export type TransitionBeforeSwapEvent = + import('./dist/virtual-modules/transitions-events.js').TransitionBeforeSwapEvent; + export const isTransitionBeforePreparationEvent: EventModule['isTransitionBeforePreparationEvent']; + export const isTransitionBeforeSwapEvent: EventModule['isTransitionBeforeSwapEvent']; } declare module 'astro:prefetch' { diff --git a/packages/astro/components/ViewTransitions.astro b/packages/astro/components/ViewTransitions.astro index 1d2d72d8a..1fa3611dd 100644 --- a/packages/astro/components/ViewTransitions.astro +++ b/packages/astro/components/ViewTransitions.astro @@ -30,9 +30,10 @@ const { fallback = 'animate', handleForms } = Astro.props; import type { Options } from 'astro:transitions/client'; import { supportsViewTransitions, navigate } from 'astro:transitions/client'; // NOTE: import from `astro/virtual-modules/prefetch.js` as `astro:prefetch` requires the `prefetch` config to be enabled + // @ts-ignore import { init } from 'astro/virtual-modules/prefetch.js'; - export type Fallback = 'none' | 'animate' | 'swap'; + type Fallback = 'none' | 'animate' | 'swap'; function getFallback(): Fallback { const el = document.querySelector('[name="astro-view-transitions-fallback"]'); @@ -42,27 +43,34 @@ const { fallback = 'animate', handleForms } = Astro.props; return 'animate'; } - function isReloadEl(el: HTMLElement): boolean { + function isReloadEl(el: HTMLElement | SVGAElement): boolean { return el.dataset.astroReload !== undefined; } if (supportsViewTransitions || getFallback() !== 'none') { document.addEventListener('click', (ev) => { let link = ev.target; - if (link instanceof Element && link.tagName !== 'A') { - link = link.closest('a'); + if (link instanceof Element) { + link = link.closest('a, area'); } + if ( + !(link instanceof HTMLAnchorElement) && + !(link instanceof SVGAElement) && + !(link instanceof HTMLAreaElement) + ) + return; // This check verifies that the click is happening on an anchor // that is going to another page within the same origin. Basically it determines // same-origin navigation, but omits special key combos for new tabs, etc. + const linkTarget = link instanceof HTMLElement ? link.target : link.target.baseVal; + const href = link instanceof HTMLElement ? link.href : link.href.baseVal; + const origin = new URL(href, location.href).origin; if ( - !link || - !(link instanceof HTMLAnchorElement) || isReloadEl(link) || link.hasAttribute('download') || !link.href || - (link.target && link.target !== '_self') || - link.origin !== location.origin || + (linkTarget && linkTarget !== '_self') || + origin !== location.origin || ev.button !== 0 || // left clicks only ev.metaKey || // new tab (mac) ev.ctrlKey || // new tab (windows) @@ -75,8 +83,9 @@ const { fallback = 'animate', handleForms } = Astro.props; return; } ev.preventDefault(); - navigate(link.href, { + navigate(href, { history: link.dataset.astroHistory === 'replace' ? 'replace' : 'auto', + sourceElement: link, }); }); @@ -94,7 +103,7 @@ const { fallback = 'animate', handleForms } = Astro.props; let action = submitter?.getAttribute('formaction') ?? form.action ?? location.pathname; const method = submitter?.getAttribute('formmethod') ?? form.method; - const options: Options = {}; + const options: Options = { sourceElement: submitter ?? form }; if (method === 'get') { const params = new URLSearchParams(formData as any); const url = new URL(action); diff --git a/packages/astro/e2e/fixtures/view-transitions/src/pages/non-html-anchor.astro b/packages/astro/e2e/fixtures/view-transitions/src/pages/non-html-anchor.astro new file mode 100644 index 000000000..8d5ea8d46 --- /dev/null +++ b/packages/astro/e2e/fixtures/view-transitions/src/pages/non-html-anchor.astro @@ -0,0 +1,22 @@ +--- +import Layout from '../components/Layout.astro'; +--- +<Layout> +<h1>SVGA and Image Map links</h1> + +<svg viewBox="0 0 160 40" xmlns="http://www.w3.org/2000/svg"> + <a href="/two" id="svga"> + <text x="10" y="25" id="insidesvga">text within a svga</text> + </a> +</svg> + + +<map name="map"> + <area shape="default" href="/two" alt="logo" id="area"/> +</map> +<img id="logo" usemap="#map" src="/logo.svg" alt="logo" /> +<style> + body { + background: #888; + } +</style> diff --git a/packages/astro/e2e/view-transitions.test.js b/packages/astro/e2e/view-transitions.test.js index 20ea8adbc..c56abae24 100644 --- a/packages/astro/e2e/view-transitions.test.js +++ b/packages/astro/e2e/view-transitions.test.js @@ -1016,4 +1016,47 @@ test.describe('View Transitions', () => { const result = page.locator('#three-result'); await expect(result, 'should have content').toHaveText('Got: Testing'); }); + + test('click on an svg anchor should trigger navigation', async ({ page, astro }) => { + const loads = []; + page.addListener('load', (p) => { + loads.push(p.title()); + }); + + await page.goto(astro.resolveUrl('/non-html-anchor')); + let locator = page.locator('#insidesvga'); + await expect(locator, 'should have attribute').toHaveAttribute('x', '10'); + await page.click('#svga'); + const p = page.locator('#two'); + await expect(p, 'should have content').toHaveText('Page 2'); + expect(loads.length, 'There should only be 1 page load').toEqual(1); + }); + + test('click inside an svg anchor should trigger navigation', async ({ page, astro }) => { + const loads = []; + page.addListener('load', (p) => { + loads.push(p.title()); + }); + await page.goto(astro.resolveUrl('/non-html-anchor')); + let locator = page.locator('#insidesvga'); + await expect(locator, 'should have content').toHaveText('text within a svga'); + await page.click('#insidesvga'); + const p = page.locator('#two'); + await expect(p, 'should have content').toHaveText('Page 2'); + expect(loads.length, 'There should only be 1 page load').toEqual(1); + }); + + test('click on an area in an image map should trigger navigation', async ({ page, astro }) => { + const loads = []; + page.addListener('load', (p) => { + loads.push(p.title()); + }); + await page.goto(astro.resolveUrl('/non-html-anchor')); + let locator = page.locator('#area'); + await expect(locator, 'should have attribute').toHaveAttribute('shape', 'default'); + await page.click('#logo'); + const p = page.locator('#two'); + await expect(p, 'should have content').toHaveText('Page 2'); + expect(loads.length, 'There should only be 1 page load').toEqual(1); + }); }); diff --git a/packages/astro/package.json b/packages/astro/package.json index 4cd290206..a12c58c98 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -1,6 +1,6 @@ { "name": "astro", - "version": "3.5.5", + "version": "3.6.0", "description": "Astro is a modern site builder with web best practices, performance, and DX front-of-mind.", "type": "module", "author": "withastro", 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, }); } diff --git a/packages/astro/test/fixtures/i18n-routing-fallback/src/pages/index.astro b/packages/astro/test/fixtures/i18n-routing-fallback/src/pages/index.astro index 05faf7b0b..34b39fcd6 100644 --- a/packages/astro/test/fixtures/i18n-routing-fallback/src/pages/index.astro +++ b/packages/astro/test/fixtures/i18n-routing-fallback/src/pages/index.astro @@ -1,8 +1,13 @@ <html> <head> <title>Astro</title> + <script> + console.log("this is a script") + </script> </head> <body> Hello </body> </html> + + diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/en/start.astro b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/en/start.astro index 990baecd9..92e189636 100644 --- a/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/en/start.astro +++ b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/en/start.astro @@ -1,8 +1,13 @@ +--- +const currentLocale = Astro.currentLocale; +--- <html> <head> <title>Astro</title> </head> <body> Start +Current Locale: {currentLocale ? currentLocale : "none"} + </body> </html> diff --git a/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/pt/start.astro b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/pt/start.astro index 5a4a84c2c..6f82c3790 100644 --- a/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/pt/start.astro +++ b/packages/astro/test/fixtures/i18n-routing-prefix-always/src/pages/pt/start.astro @@ -1,8 +1,12 @@ +--- +const currentLocale = Astro.currentLocale; +--- <html> <head> <title>Astro</title> </head> <body> Oi essa e start +Current Locale: {currentLocale ? currentLocale : "none"} </body> </html> diff --git a/packages/astro/test/fixtures/i18n-routing/src/pages/current-locale.astro b/packages/astro/test/fixtures/i18n-routing/src/pages/current-locale.astro new file mode 100644 index 000000000..64af0118b --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing/src/pages/current-locale.astro @@ -0,0 +1,12 @@ +--- +const currentLocale = Astro.currentLocale; +--- + +<html> +<head> + <title>Astro</title> +</head> +<body> + Current Locale: {currentLocale ? currentLocale : "none"} +</body> +</html> diff --git a/packages/astro/test/fixtures/i18n-routing/src/pages/dynamic/[id].astro b/packages/astro/test/fixtures/i18n-routing/src/pages/dynamic/[id].astro new file mode 100644 index 000000000..58141fec0 --- /dev/null +++ b/packages/astro/test/fixtures/i18n-routing/src/pages/dynamic/[id].astro @@ -0,0 +1,19 @@ + +--- +export function getStaticPaths() { + return [ + { id: "lorem" } + ] +} +const currentLocale = Astro.currentLocale; + +--- + +<html> +<head> + <title>Astro</title> +</head> +<body> +Current Locale: {currentLocale ? currentLocale : "none"} +</body> +</html> diff --git a/packages/astro/test/fixtures/i18n-routing/src/pages/pt/start.astro b/packages/astro/test/fixtures/i18n-routing/src/pages/pt/start.astro index 15a63a7b8..9a37428ca 100644 --- a/packages/astro/test/fixtures/i18n-routing/src/pages/pt/start.astro +++ b/packages/astro/test/fixtures/i18n-routing/src/pages/pt/start.astro @@ -1,8 +1,12 @@ +--- +const currentLocale = Astro.currentLocale; +--- <html> <head> <title>Astro</title> </head> <body> Hola +Current Locale: {currentLocale ? currentLocale : "none"} </body> </html> diff --git a/packages/astro/test/i18-routing.test.js b/packages/astro/test/i18n-routing.test.js index a7e8b318d..2c9b87813 100644 --- a/packages/astro/test/i18-routing.test.js +++ b/packages/astro/test/i18n-routing.test.js @@ -639,6 +639,72 @@ describe('[SSG] i18n routing', () => { return true; } }); + + it('should render the page with client scripts', async () => { + let html = await fixture.readFile('/index.html'); + let $ = cheerio.load(html); + 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(); + }); + + // TODO: enable once we fix fallback + it.skip('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('i18n routing with fallback and redirect', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-routing-fallback/', + redirects: { + '/': '/en', + }, + experimental: { + i18n: { + defaultLocale: 'en', + locales: ['en', 'pt', 'it'], + fallback: { + it: 'en', + }, + }, + }, + }); + await fixture.build(); + }); + + it('should render the en locale', async () => { + let html = await fixture.readFile('/index.html'); + let $ = cheerio.load(html); + expect(html).to.include('http-equiv="refresh'); + expect(html).to.include('Redirecting to: /en'); + }); }); }); describe('[SSR] i18n routing', () => { @@ -887,8 +953,9 @@ describe('[SSR] i18n routing', () => { it('should redirect to the english locale, which is the first fallback', async () => { let request = new Request('http://example.com/new-site/it/start'); let response = await app.render(request); - expect(response.status).to.equal(302); - expect(response.headers.get('location')).to.equal('/new-site/start'); + console.log(await response.text()); + // expect(response.status).to.equal(302); + // expect(response.headers.get('location')).to.equal('/new-site/start'); }); it("should render a 404 because the route `fr` isn't included in the list of locales of the configuration", async () => { @@ -991,6 +1058,73 @@ describe('[SSR] i18n routing', () => { }); }); }); + + describe('current locale', () => { + describe('with [prefix-other-locales]', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-routing/', + output: 'server', + adapter: testAdapter(), + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + }); + + it('should return the default locale', async () => { + let request = new Request('http://example.com/current-locale', {}); + let response = await app.render(request); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Current Locale: en'); + }); + + it('should return the default locale of the current URL', async () => { + let request = new Request('http://example.com/pt/start', {}); + let response = await app.render(request); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Current Locale: pt'); + }); + + it('should return the default locale when a route is dynamic', async () => { + let request = new Request('http://example.com/dynamic/lorem', {}); + let response = await app.render(request); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Current Locale: en'); + }); + }); + + describe('with [prefix-always]', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/i18n-routing-prefix-always/', + output: 'server', + adapter: testAdapter(), + }); + await fixture.build(); + app = await fixture.loadTestAdapterApp(); + }); + + it('should return the locale of the current URL (en)', async () => { + let request = new Request('http://example.com/en/start', {}); + let response = await app.render(request); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Current Locale: en'); + }); + + it('should return the locale of the current URL (pt)', async () => { + let request = new Request('http://example.com/pt/start', {}); + let response = await app.render(request); + expect(response.status).to.equal(200); + expect(await response.text()).includes('Current Locale: pt'); + }); + }); + }); }); describe('i18n routing does not break assets and endpoints', () => { diff --git a/packages/integrations/react/CHANGELOG.md b/packages/integrations/react/CHANGELOG.md index 5b127b753..46d3a2512 100644 --- a/packages/integrations/react/CHANGELOG.md +++ b/packages/integrations/react/CHANGELOG.md @@ -1,5 +1,11 @@ # @astrojs/react +## 3.0.6 + +### Patch Changes + +- [#9141](https://github.com/withastro/astro/pull/9141) [`af43fb517`](https://github.com/withastro/astro/commit/af43fb51726fa2242cec03cb019fa4fa4a4403ef) Thanks [@lilnasy](https://github.com/lilnasy)! - Fixes an issue where slotting self-closing elements (img, br, hr) into react components with `experimentalReactChildren` enabled led to an error. + ## 3.0.5 ### Patch Changes diff --git a/packages/integrations/react/package.json b/packages/integrations/react/package.json index 911acc34a..482dfccf9 100644 --- a/packages/integrations/react/package.json +++ b/packages/integrations/react/package.json @@ -1,7 +1,7 @@ { "name": "@astrojs/react", "description": "Use React components within Astro", - "version": "3.0.5", + "version": "3.0.6", "type": "module", "types": "./dist/index.d.ts", "author": "withastro", @@ -42,7 +42,8 @@ "scripts": { "build": "astro-scripts build \"src/**/*.ts\" && tsc", "build:ci": "astro-scripts build \"src/**/*.ts\"", - "dev": "astro-scripts dev \"src/**/*.ts\"" + "dev": "astro-scripts dev \"src/**/*.ts\"", + "test": "mocha --exit --timeout 20000" }, "dependencies": { "@vitejs/plugin-react": "^4.2.0", @@ -57,7 +58,8 @@ "cheerio": "1.0.0-rc.12", "react": "^18.1.0", "react-dom": "^18.1.0", - "vite": "^5.0.0" + "vite": "^5.0.0", + "mocha": "^10.2.0" }, "peerDependencies": { "@types/react": "^17.0.50 || ^18.0.21", diff --git a/packages/integrations/react/test/parsed-react-children.test.js b/packages/integrations/react/test/parsed-react-children.test.js new file mode 100644 index 000000000..1c845836f --- /dev/null +++ b/packages/integrations/react/test/parsed-react-children.test.js @@ -0,0 +1,15 @@ +import { expect } from 'chai'; +import convert from '../vnode-children.js'; + +describe('experimental react children', () => { + it('has undefined as children for direct children', () => { + const [imgVNode] = convert('<img src="abc"></img>'); + expect(imgVNode.props).to.deep.include({ children: undefined }); + }); + + it('has undefined as children for nested children', () => { + const [divVNode] = convert('<div><img src="xyz"></img></div>'); + const [imgVNode] = divVNode.props.children; + expect(imgVNode.props).to.deep.include({ children: undefined }); + }); +}); diff --git a/packages/integrations/react/vnode-children.js b/packages/integrations/react/vnode-children.js index cc8ec3510..0b26ef423 100644 --- a/packages/integrations/react/vnode-children.js +++ b/packages/integrations/react/vnode-children.js @@ -8,24 +8,19 @@ export default function convert(children) { let key = 0; function createReactElementFromNode(node) { - const childVnodes = Array.isArray(node.children) - ? node.children - .map((child) => { - if (child.type === ELEMENT_NODE) { - return createReactElementFromNode(child); - } else if (child.type === TEXT_NODE) { - // 0-length text gets omitted in JSX - return child.value.trim() ? child.value : undefined; - } - }) - .filter((n) => !!n) - : undefined; + const childVnodes = + Array.isArray(node.children) && node.children.length + ? node.children.map((child) => createReactElementFromNode(child)).filter(Boolean) + : undefined; if (node.type === DOCUMENT_NODE) { return createElement(Fragment, {}, childVnodes); } else if (node.type === ELEMENT_NODE) { const { class: className, ...props } = node.attributes; return createElement(node.name, { ...props, className, key: `${id}-${key++}` }, childVnodes); + } else if (node.type === TEXT_NODE) { + // 0-length text gets omitted in JSX + return node.value.trim() ? node.value : undefined; } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb9dbe3e8..8321619a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,7 +125,7 @@ importers: examples/basics: dependencies: astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro examples/blog: @@ -140,13 +140,13 @@ importers: specifier: ^3.0.3 version: link:../../packages/integrations/sitemap astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro examples/component: devDependencies: astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro examples/framework-alpine: @@ -161,7 +161,7 @@ importers: specifier: ^3.13.3 version: 3.13.3 astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro examples/framework-lit: @@ -173,7 +173,7 @@ importers: specifier: ^0.2.1 version: 0.2.1 astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro lit: specifier: ^2.8.0 @@ -185,7 +185,7 @@ importers: specifier: ^3.0.1 version: link:../../packages/integrations/preact '@astrojs/react': - specifier: ^3.0.5 + specifier: ^3.0.6 version: link:../../packages/integrations/react '@astrojs/solid-js': specifier: ^3.0.2 @@ -197,7 +197,7 @@ importers: specifier: ^3.0.4 version: link:../../packages/integrations/vue astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro preact: specifier: ^10.19.2 @@ -227,7 +227,7 @@ importers: specifier: ^1.2.1 version: 1.2.1(preact@10.19.2) astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro preact: specifier: ^10.19.2 @@ -236,7 +236,7 @@ importers: examples/framework-react: dependencies: '@astrojs/react': - specifier: ^3.0.5 + specifier: ^3.0.6 version: link:../../packages/integrations/react '@types/react': specifier: ^18.2.37 @@ -245,7 +245,7 @@ importers: specifier: ^18.2.15 version: 18.2.15 astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro react: specifier: ^18.2.0 @@ -260,7 +260,7 @@ importers: specifier: ^3.0.2 version: link:../../packages/integrations/solid astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro solid-js: specifier: ^1.8.5 @@ -272,7 +272,7 @@ importers: specifier: ^4.0.4 version: link:../../packages/integrations/svelte astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro svelte: specifier: ^4.2.5 @@ -284,7 +284,7 @@ importers: specifier: ^3.0.4 version: link:../../packages/integrations/vue astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro vue: specifier: ^3.3.8 @@ -296,13 +296,13 @@ importers: specifier: ^6.0.4 version: link:../../packages/integrations/node astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro examples/integration: devDependencies: astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro examples/middleware: @@ -311,7 +311,7 @@ importers: specifier: ^6.0.4 version: link:../../packages/integrations/node astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro html-minifier: specifier: ^4.0.0 @@ -320,19 +320,19 @@ importers: examples/minimal: dependencies: astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro examples/non-html-pages: dependencies: astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro examples/portfolio: dependencies: astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro examples/ssr: @@ -344,7 +344,7 @@ importers: specifier: ^4.0.4 version: link:../../packages/integrations/svelte astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro svelte: specifier: ^4.2.5 @@ -359,7 +359,7 @@ importers: specifier: ^5.0.2 version: link:../../packages/integrations/tailwind astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro examples/with-markdoc: @@ -368,7 +368,7 @@ importers: specifier: ^0.7.2 version: link:../../packages/integrations/markdoc astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro examples/with-markdown-plugins: @@ -377,7 +377,7 @@ importers: specifier: ^3.5.0 version: link:../../packages/markdown/remark astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro hast-util-select: specifier: ^6.0.2 @@ -398,7 +398,7 @@ importers: examples/with-markdown-shiki: dependencies: astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro examples/with-mdx: @@ -410,7 +410,7 @@ importers: specifier: ^3.0.1 version: link:../../packages/integrations/preact astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro preact: specifier: ^10.19.2 @@ -425,7 +425,7 @@ importers: specifier: ^0.5.0 version: 0.5.0(nanostores@0.9.5)(preact@10.19.2) astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro nanostores: specifier: ^0.9.5 @@ -446,7 +446,7 @@ importers: specifier: ^1.6.3 version: 1.6.3 astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro autoprefixer: specifier: ^10.4.15 @@ -464,7 +464,7 @@ importers: examples/with-vite-plugin-pwa: dependencies: astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro vite-plugin-pwa: specifier: 0.17.0 @@ -476,7 +476,7 @@ importers: examples/with-vitest: dependencies: astro: - specifier: ^3.5.5 + specifier: ^3.6.0 version: link:../../packages/astro vitest: specifier: ^0.34.2 @@ -4470,6 +4470,9 @@ importers: cheerio: specifier: 1.0.0-rc.12 version: 1.0.0-rc.12 + mocha: + specifier: ^10.2.0 + version: 10.2.0 react: specifier: ^18.1.0 version: 18.2.0 |