Add View Transitions announcer (#8621)

* Add View Transitions announcer

* fix astro check

* Append the text in a setTimeout

* Use 60 for the timeout

* Add comment on magic number

* Add a changeset

* Update .changeset/small-rules-relax.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Bring back announce logic

* Remove mention of env file

---------

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
Matthew Phillips 2023-09-28 03:21:56 +08:00 committed by GitHub
parent 5121740de7
commit e6be2d8146
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 787 additions and 0 deletions

View file

@ -0,0 +1,11 @@
---
'astro': minor
---
Route Announcer in `<ViewTransitions />`
The View Transitions router now does route announcement. When transitioning between pages with a traditional MPA approach, assistive technologies will announce the page title when the page finishes loading. This does not automatically happen during client-side routing, so visitors relying on these technologies to announce routes are not aware when a page has changed.
The view transitions route announcer runs after the `astro:page-load` event, looking for the page `<title>` to announce. If one cannot be found, the announcer falls back to the first `<h1>` it finds, or otherwise announces the pathname. We recommend you always include a `<title>` in each page for accessibility.
See the [View Transitions docs](https://docs.astro.build/en/guides/view-transitions/) for more on how accessibility is handled.

View file

@ -0,0 +1,30 @@
# Astro Movies View Transitions Demo
### 👉🏽 [Live Demo](https://astro-movies.pages.dev/)
![Screenshot](./screenshot.png)
## 🚀 Getting Started
1. Clone this repository and install dependencies with `npm install`.
2. Start the project locally with npm run dev, or deploy it to your favorite server.
3. Have fun! ✨
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :--------------------- | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:3000` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Check out [Astro's documentation](https://docs.astro.build) or jump into their [Discord server](https://astro.build/chat).
You can also reach out to [Maxi on Twitter](https://twitter.com/charca).

View file

@ -0,0 +1,15 @@
import { defineConfig } from 'astro/config'
import tailwind from '@astrojs/tailwind';
import nodejs from '@astrojs/node';
// https://astro.build/config
export default defineConfig({
integrations: [tailwind()],
output: 'server',
adapter: nodejs({ mode: 'standalone' }),
vite: {
define: {
'process.env.TMDB_API_KEY': JSON.stringify(process.env.TMDB_API_KEY),
},
},
})

View file

@ -0,0 +1,17 @@
{
"name": "@example/view-transitions",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"devDependencies": {
"@astrojs/tailwind": "^5.0.0",
"@astrojs/node": "^6.0.0",
"astro": "^3.1.1"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1,17 @@
<footer class="border border-t border-gray-800">
<div class="container mx-auto text-sm px-4 py-6">
Made with ❤️ by <a
href="https://www.twitter.com/charca"
target="_blank"
class="underline hover:text-gray-300">Maxi Ferreira</a
> — Powered by <a
href="https://astro.build"
target="_blank"
class="underline hover:text-gray-300">Astro</a
> and <a
href="https://www.themoviedb.org/documentation/api"
target="_blank"
class="underline hover:text-gray-300">TMDb API</a
>.
</div>
</footer>

View file

@ -0,0 +1,23 @@
---
const { movie } = Astro.props;
---
<div class="mt-8">
<a href={`/movies/${movie.id}`}>
<img src={`https://image.tmdb.org/t/p/w500${movie.poster_path}`}
alt={`${movie.title} Poster`}
class="thumbnail hover:opacity-75 transition ease-in-out duration-150"
id={`movie-poster-${movie.id}`}
transition:name={`poster-${movie.id}`}>
</a>
<div class="mt-2">
<a href={`/movies/${movie.id}`} class="text-lg mt-2 hover:text-gray-300">{movie.title}</a>
<div class="flex items-center text-gray-400 text-sm mt-1">
<svg class="fill-current text-orange-500 w-4" viewBox="0 0 24 24"><g data-name="Layer 2"><path d="M17.56 21a1 1 0 01-.46-.11L12 18.22l-5.1 2.67a1 1 0 01-1.45-1.06l1-5.63-4.12-4a1 1 0 01-.25-1 1 1 0 01.81-.68l5.7-.83 2.51-5.13a1 1 0 011.8 0l2.54 5.12 5.7.83a1 1 0 01.81.68 1 1 0 01-.25 1l-4.12 4 1 5.63a1 1 0 01-.4 1 1 1 0 01-.62.18z" data-name="star"/></g></svg>
<span class="ml-1">{movie.vote_average}</span>
<span class="mx-2">|</span>
<span>{movie.release_date}</span>
</div>
<div class="text-gray-400 text-sm">{movie.genres}</div>
</div>
</div>

View file

@ -0,0 +1,100 @@
---
const { data } = Astro.props;
const movie = {
...data,
poster_path: data.poster_path
? 'https://image.tmdb.org/t/p/w500/' + data.poster_path
: 'https://via.placeholder.com/500x750',
vote_average: (data.vote_average * 10).toFixed(2) + '%',
release_date: new Date(data.release_date).toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
day: 'numeric'
}),
genres: data.genres.map((g: any) => g.name).join(', '),
crew: data.credits.crew.slice(0,3),
cast: data.credits.cast.slice(0,5).map((c: any) => ({
...c,
profile_path: c.profile_path
? 'https://image.tmdb.org/t/p/w300/' + c.profile_path
: 'https://via.placeholder.com/300x450'
})),
images: data.images.backdrops.slice(0, 9),
}
---
<div class="movie-info border-b border-gray-800">
<div class="container mx-auto px-4 py-16 flex flex-col md:flex-row">
<div class="flex-none">
<img src={movie.poster_path}
alt={`${movie.title} Poster`}
class="movie-poster w-64 lg:w-96"
id="movie-poster"
transition:name={`poster-${movie.id}`}>
</div>
<div class="md:ml-24">
<h2 class="text-4xl mt-4 md:mt-0 mb-2 font-semibold">{movie.title}</h2>
<div class="flex flex-wrap items-center text-gray-400 text-sm">
<svg class="fill-current text-orange-500 w-4" viewBox="0 0 24 24"><g data-name="Layer 2"><path d="M17.56 21a1 1 0 01-.46-.11L12 18.22l-5.1 2.67a1 1 0 01-1.45-1.06l1-5.63-4.12-4a1 1 0 01-.25-1 1 1 0 01.81-.68l5.7-.83 2.51-5.13a1 1 0 011.8 0l2.54 5.12 5.7.83a1 1 0 01.81.68 1 1 0 01-.25 1l-4.12 4 1 5.63a1 1 0 01-.4 1 1 1 0 01-.62.18z" data-name="star"/></g></svg>
<span class="ml-1">{movie.vote_average}</span>
<span class="mx-2">|</span>
<span>{movie.release_date}</span>
<span class="mx-2">|</span>
<span>{movie.genres}</span>
</div>
<p class="text-gray-300 mt-8">
{movie.overview}
</p>
<div class="mt-12">
<h4 class="text-white font-semibold">Featured Crew</h4>
<div class="flex mt-4">
{movie.crew.map((crew: any) => (
<div class="mr-8">
<div>{crew.name}</div>
<div class="text-gray-400 text-sm">{crew.job}</div>
</div>
))}
</div>
</div>
</div>
</div>
</div> <!-- end movie-info -->
<div class="movie-cast border-b border-gray-800">
<div class="container mx-auto px-4 py-16">
<h2 class="text-4xl font-semibold">Cast</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-8">
{movie.cast.map((cast: any) => (
<div class="mt-8">
<span>
<img id={`person-photo-${cast.id}`} src={cast.profile_path} alt={cast.name} class="thumbnail hover:opacity-75 transition ease-in-out duration-150">
</span>
<div class="mt-2">
<span class="text-lg mt-2 hover:text-gray:300">{cast.name}</span>
<div class="text-sm text-gray-400">
{cast.character}
</div>
</div>
</div>
))}
</div>
</div>
</div> <!-- end movie-cast -->
<div class="movie-images">
<div class="container mx-auto px-4 py-16">
<h2 class="text-4xl font-semibold">Images</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8">
{movie.images.map((image: any) => (
<div class="mt-8">
<span>
<img src={`https://image.tmdb.org/t/p/w500${image.file_path}`} loading="lazy" alt={movie.name} class="hover:opacity-75 transition ease-in-out duration-150">
</span>
</div>
))}
</div>
</div>
</div> <!-- end movie-images -->

View file

@ -0,0 +1,19 @@
---
import MovieCard from './MovieCard.astro';
import movies from '../popular-movies.json';
const popularMovies = movies.results;
---
<div class="container mx-auto px-4 pt-16 mb-16">
<div class="popular-movies">
<h2 class="uppercase tracking-wider text-orange-500 text-lg font-semibold">
Popular Movies
</h2>
<div
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-8"
>
{popularMovies.map((movie) => <MovieCard movie={movie} />)}
</div>
</div>
<!-- end pouplar-movies -->
</div>

View file

@ -0,0 +1,16 @@
<nav class="nav border-b border-gray-800 sticky top-0 z-30 bg-gray-900">
<div class="container mx-auto px-4 flex flex-col md:flex-row items-center justify-between px-4 py-6">
<ul class="flex flex-col md:flex-row items-center">
<li>
<a href="/" class="flex items-center font-bold text-xl">
<span>Movies</span>
&nbsp;
<span class="text-orange-500">List</span>
</a>
</li>
<li class="md:ml-16 mt-3 md:mt-0">
<a href="/" class="hover:text-gray-300">Movies</a>
</li>
</ul>
</div>
</nav>

View file

@ -0,0 +1,12 @@
import { z, defineCollection } from 'astro:content';
const movies = defineCollection({
type: 'data',
schema: z.object({
data: z.any(),
}),
});
// Expose your defined collection to Astro
// with the `collections` export
export const collections = { movies };

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,36 @@
---
import '../styles/styles.css';
import { ViewTransitions } from 'astro:transitions';
import Footer from "../components/Footer.astro";
import Nav from "../components/Nav.astro";
export interface Props {
title: string;
}
const { title } = Astro.props as Props;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="generator" content={Astro.generator} />
<meta name="view-transition" content="same-origin" />
<title>{title}</title>
<ViewTransitions />
</head>
<body class="font-sans bg-gray-900 text-white">
<div class="h-screen overflow-hidden flex flex-col">
<Nav />
<div id="container" class="h-full flex-1 overflow-y-auto">
<div id="content">
<slot />
</div>
<Footer />
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,8 @@
---
import Layout from '../layouts/Layout.astro';
import MovieList from '../components/MovieList.astro';
---
<Layout title="Movies List">
<MovieList />
</Layout>

View file

@ -0,0 +1,14 @@
---
import Layout from '../../layouts/Layout.astro';
import MovieDetails from '../../components/MovieDetails.astro';
import { getDataEntryById } from 'astro:content';
// Data collection bug?
const id: any = '/src/content/movies/'+Astro.params.id;
const result = await getDataEntryById('movies', id);
const data = result.data.data;
---
<Layout title={`${data.title} on Movies List`}>
<MovieDetails data={data} />
</Layout>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,262 @@
import {
getNavigationType,
getPathId,
isBackNavigation,
shouldNotIntercept,
updateTheDOMSomehow,
useTvFragment,
} from './utils'
// View Transitions support cross-document navigations.
// Should compare performace.
// https://github.com/WICG/view-transitions/blob/main/explainer.md#cross-document-same-origin-transitions
// https://github.com/WICG/view-transitions/blob/main/explainer.md#script-events
function shouldDisableSpa() {
return false;
}
navigation.addEventListener('navigate', (navigateEvent) => {
if (shouldDisableSpa()) return
if (shouldNotIntercept(navigateEvent)) return
const toUrl = new URL(navigateEvent.destination.url)
const toPath = toUrl.pathname
const fromPath = location.pathname
const navigationType = getNavigationType(fromPath, toPath)
if (location.origin !== toUrl.origin) return
switch (navigationType) {
case 'home-to-movie':
case 'tv-to-show':
handleHomeToMovieTransition(navigateEvent, getPathId(toPath))
break
case 'movie-to-home':
case 'show-to-tv':
handleMovieToHomeTransition(navigateEvent, getPathId(fromPath))
break
case 'movie-to-person':
handleMovieToPersonTransition(
navigateEvent,
getPathId(fromPath),
getPathId(toPath)
)
break
case 'person-to-movie':
case 'person-to-show':
handlePersonToMovieTransition(
navigateEvent,
getPathId(fromPath),
getPathId(toPath)
)
break
default:
return
}
})
// TODO: https://developer.chrome.com/docs/web-platform/view-transitions/#transitions-as-an-enhancement
function handleHomeToMovieTransition(navigateEvent, movieId) {
navigateEvent.intercept({
async handler() {
const fragmentUrl = useTvFragment(navigateEvent)
? '/fragments/TvDetails'
: '/fragments/MovieDetails'
const response = await fetch(`${fragmentUrl}/${movieId}`)
const data = await response.text()
if (!document.startViewTransition) {
updateTheDOMSomehow(data);
return;
}
const thumbnail = document.getElementById(`movie-poster-${movieId}`)
if (thumbnail) {
thumbnail.style.viewTransitionName = 'movie-poster'
}
const transition = document.startViewTransition(() => {
if (thumbnail) {
thumbnail.style.viewTransitionName = ''
}
document.getElementById('container').scrollTop = 0
updateTheDOMSomehow(data)
})
await transition.finished
},
})
}
function handleMovieToHomeTransition(navigateEvent, movieId) {
navigateEvent.intercept({
scroll: 'manual',
async handler() {
const fragmentUrl = useTvFragment(navigateEvent)
? '/fragments/TvList'
: '/fragments/MovieList'
const response = await fetch(fragmentUrl)
const data = await response.text()
if (!document.startViewTransition) {
updateTheDOMSomehow(data)
return
}
const tempHomePage = document.createElement('div')
const moviePoster = document.getElementById(`movie-poster`)
let thumbnail
// If the movie poster is not in the home page, removes the transition style so that
// the poster doesn't stay on the page while transitioning
tempHomePage.innerHTML = data
if (!tempHomePage.querySelector(`#movie-poster-${movieId}`)) {
moviePoster?.classList.remove('movie-poster')
}
const transition = document.startViewTransition(() => {
updateTheDOMSomehow(data)
thumbnail = document.getElementById(`movie-poster-${movieId}`)
if (thumbnail) {
thumbnail.scrollIntoViewIfNeeded()
thumbnail.style.viewTransitionName = 'movie-poster'
}
})
await transition.finished
if (thumbnail) {
thumbnail.style.viewTransitionName = ''
}
},
})
}
function handleMovieToPersonTransition(navigateEvent, movieId, personId) {
// TODO: https://developer.chrome.com/docs/web-platform/view-transitions/#not-a-polyfill
// ...has example of `back-transition` class applied to document
const isBack = isBackNavigation(navigateEvent)
navigateEvent.intercept({
async handler() {
const response = await fetch('/fragments/PersonDetails/' + personId)
const data = await response.text()
if (!document.startViewTransition) {
updateTheDOMSomehow(data)
return
}
let personThumbnail
let moviePoster
let movieThumbnail
if (!isBack) {
// We're transitioning the person photo; we need to remove the transition of the poster
// so that it doesn't stay on the page while transitioning
moviePoster = document.getElementById(`movie-poster`)
if (moviePoster) {
moviePoster.classList.remove('movie-poster')
}
personThumbnail = document.getElementById(`person-photo-${personId}`)
if (personThumbnail) {
personThumbnail.style.viewTransitionName = 'person-photo'
}
}
const transition = document.startViewTransition(() => {
updateTheDOMSomehow(data)
if (personThumbnail) {
personThumbnail.style.viewTransitionName = ''
}
if (isBack) {
// If we're coming back to the person page, we're transitioning
// into the movie poster thumbnail, so we need to add the tag to it
movieThumbnail = document.getElementById(`movie-poster-${movieId}`)
if (movieThumbnail) {
movieThumbnail.scrollIntoViewIfNeeded()
movieThumbnail.style.viewTransitionName = 'movie-poster'
}
}
document.getElementById('container').scrollTop = 0
})
await transition.finished
if (movieThumbnail) {
movieThumbnail.style.viewTransitionName = ''
}
},
})
}
function handlePersonToMovieTransition(navigateEvent, personId, movieId) {
const isBack = isBackNavigation(navigateEvent)
navigateEvent.intercept({
scroll: 'manual',
async handler() {
const fragmentUrl = useTvFragment(navigateEvent)
? '/fragments/TvDetails'
: '/fragments/MovieDetails'
const response = await fetch(`${fragmentUrl}/${movieId}`)
const data = await response.text()
if (!document.startViewTransition) {
updateTheDOMSomehow(data)
return
}
let thumbnail
let moviePoster
let movieThumbnail
if (!isBack) {
movieThumbnail = document.getElementById(`movie-poster-${movieId}`)
if (movieThumbnail) {
movieThumbnail.style.viewTransitionName = 'movie-poster'
}
}
const transition = document.startViewTransition(() => {
updateTheDOMSomehow(data)
if (isBack) {
moviePoster = document.getElementById(`movie-poster`)
if (moviePoster) {
moviePoster.classList.remove('movie-poster')
}
if (personId) {
thumbnail = document.getElementById(`person-photo-${personId}`)
if (thumbnail) {
thumbnail.scrollIntoViewIfNeeded()
thumbnail.style.viewTransitionName = 'person-photo'
}
}
} else {
document.getElementById('container').scrollTop = 0
if (movieThumbnail) {
movieThumbnail.style.viewTransitionName = ''
}
}
})
await transition.finished
if (thumbnail) {
thumbnail.style.viewTransitionName = ''
}
if (moviePoster) {
moviePoster.classList.add('movie-poster')
}
},
})
}

View file

@ -0,0 +1,79 @@
export function getNavigationType(fromPath, toPath) {
if (fromPath.startsWith('/movies') && toPath === '/') {
return 'movie-to-home'
}
if (fromPath === '/tv' && toPath.startsWith('/tv/')) {
return 'tv-to-show'
}
if (fromPath === '/' && toPath.startsWith('/movies')) {
return 'home-to-movie'
}
if (fromPath.startsWith('/tv/') && toPath === '/tv') {
return 'show-to-tv'
}
if (
(fromPath.startsWith('/movies') || fromPath.startsWith('/tv')) &&
toPath.startsWith('/people')
) {
return 'movie-to-person'
}
if (
fromPath.startsWith('/people') &&
(toPath.startsWith('/movies') || toPath.startsWith('/tv/'))
) {
return 'person-to-movie'
}
return 'other'
}
export function isBackNavigation(navigateEvent) {
if (
navigateEvent.navigationType === 'push' ||
navigateEvent.navigationType === 'replace'
) {
return false
}
if (
navigateEvent.destination.index !== -1 &&
navigateEvent.destination.index < navigation.currentEntry.index
) {
return true
}
return false
}
export function shouldNotIntercept(navigationEvent) {
return (
navigationEvent.canIntercept === false ||
// If this is just a hashChange,
// just let the browser handle scrolling to the content.
navigationEvent.hashChange ||
// If this is a download,
// let the browser perform the download.
navigationEvent.downloadRequest ||
// If this is a form submission,
// let that go to the server.
navigationEvent.formData
)
}
export function useTvFragment(navigateEvent) {
const toUrl = new URL(navigateEvent.destination.url)
const toPath = toUrl.pathname
return toPath.startsWith('/tv')
}
export function getPathId(path) {
return path.split('/')[2]
}
export function updateTheDOMSomehow(data) {
document.getElementById('content').innerHTML = data
}

View file

@ -0,0 +1,61 @@
@keyframes fade-in {
from {
opacity: 0;
}
}
@keyframes fade-out {
to {
opacity: 0;
}
}
@keyframes slide-from-right {
from {
transform: translateX(30px);
}
}
@keyframes slide-to-left {
to {
transform: translateX(-30px);
}
}
::view-transition-old(root) {
animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out,
300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left;
}
::view-transition-new(root) {
animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in,
300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right;
}
::view-transition-old(movie-poster),
::view-transition-new(movie-poster) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-image-pair(movie-poster) {
isolation: none;
}
.nav {
view-transition-name: main-header;
contain: paint;
}
.movie-poster {
contain: paint;
}
.person-photo {
view-transition-name: person-photo;
contain: paint;
}
.thumbnail {
contain: paint;
}

View file

@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {
width: {
96: '24rem',
},
},
},
plugins: [],
}

View file

@ -0,0 +1,6 @@
{
"extends": "astro/tsconfigs/base",
"compilerOptions": {
"resolveJsonModule": true
}
}

View file

@ -20,6 +20,21 @@ const samePage = (otherLocation: URL) =>
location.pathname === otherLocation.pathname && location.search === otherLocation.search;
const triggerEvent = (name: Events) => document.dispatchEvent(new Event(name));
const onPageLoad = () => triggerEvent('astro:page-load');
const announce = () => {
let div = document.createElement('div');
div.setAttribute('aria-live', 'assertive');
div.setAttribute('aria-atomic', 'true');
div.setAttribute('style', 'position:absolute;left:0;top:0;clip:rect(0 0 0 0);clip-path:inset(50%);overflow:hidden;white-space:nowrap;width:1px;height:1px');
document.body.append(div);
setTimeout(() => {
let title = document.title || document.querySelector('h1')?.textContent || location.pathname;
div.textContent = title;
},
// Much thought went into this magic number; the gist is that screen readers
// need to see that the element changed and might not do so if it happens
// too quickly.
60);
};
const PERSIST_ATTR = 'data-astro-transition-persist';
const parser = new DOMParser();
// explained at its usage
@ -359,6 +374,7 @@ async function transition(
await runScripts();
markScriptsExec();
onPageLoad();
announce();
}
}

View file

@ -360,6 +360,18 @@ importers:
specifier: ^4.2.0
version: 4.2.0
examples/view-transitions:
devDependencies:
'@astrojs/node':
specifier: ^6.0.0
version: link:../../packages/integrations/node
'@astrojs/tailwind':
specifier: ^5.0.0
version: link:../../packages/integrations/tailwind
astro:
specifier: ^3.1.1
version: link:../../packages/astro
examples/with-markdoc:
dependencies:
'@astrojs/markdoc':