Add file-based routing /w dynamic paths (#1010)
* wip: add file-based routing * add pagination tests and nested pagination support
This commit is contained in:
parent
b54c01bf66
commit
0f0cc2b9d8
99 changed files with 1615 additions and 1458 deletions
5
.changeset/light-ads-grin.md
Normal file
5
.changeset/light-ads-grin.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'astro': minor
|
||||
---
|
||||
|
||||
Replace collections API with new file-based routing support
|
|
@ -12,13 +12,15 @@ export const SIDEBAR = {
|
|||
{ text: 'Components', link: 'core-concepts/astro-components' },
|
||||
{ text: 'Pages', link: 'core-concepts/astro-pages' },
|
||||
{ text: 'Layouts', link: 'core-concepts/layouts' },
|
||||
{ text: 'Collections', link: 'core-concepts/collections' },
|
||||
{ text: 'Routing', link: 'core-concepts/routing' },
|
||||
{ text: 'Partial Hydration', link: 'core-concepts/component-hydration' },
|
||||
|
||||
{ text: 'Guides', header: true },
|
||||
{ text: 'Styling & CSS', link: 'guides/styling' },
|
||||
{ text: 'Data Fetching', link: 'guides/data-fetching' },
|
||||
{ text: 'Markdown', link: 'guides/markdown-content' },
|
||||
{ text: 'Data Fetching', link: 'guides/data-fetching' },
|
||||
{ text: 'Pagination', link: 'guides/pagination' },
|
||||
{ text: 'RSS', link: 'guides/rss' },
|
||||
{ text: 'Supported Imports', link: 'guides/imports' },
|
||||
{ text: 'Aliases', link: 'guides/aliases' },
|
||||
{ text: 'Deploy a Website', link: 'guides/deploy' },
|
||||
|
|
|
@ -1,292 +0,0 @@
|
|||
---
|
||||
layout: ~/layouts/MainLayout.astro
|
||||
title: Collections
|
||||
---
|
||||
|
||||
**Collections** are a special type of [page](/core-concepts/astro-pages) in Astro that can generate multiple pages at different URLs for a larger set of data. If you've seen an Astro file that starts with a dollar sign (ex: `$posts.astro`), that's a collection.
|
||||
|
||||
Example use-cases include:
|
||||
|
||||
- Generating multiple pages from remote data
|
||||
- Generating multiple pages from local data (ex: list all markdown posts)
|
||||
- pagination: `/posts/1`, `/posts/2`, etc.
|
||||
- Grouping items into multiple pages: `/author/fred`, `/author/matthew`, etc.
|
||||
- Generating one page per item: `/pokemon/pikachu`, `/pokemon/charmander`, etc.
|
||||
|
||||
**Use a Collection when you need to generate multiple pages from a single template.** If you just want to generate a single page -- like a long list linking to every post on your blog -- then you can just use a normal [page](/core-concepts/astro-pages).
|
||||
|
||||
## Using Collections
|
||||
|
||||
To create a new Astro Collection, you need to do two things:
|
||||
|
||||
### 1. Create the File
|
||||
|
||||
Create a new file in the `src/pages` directory that starts with the dollar sign (`$`) symbol. This symbol is required to enable the Collections API.
|
||||
|
||||
Astro uses file-based routing, which means that the file must match the URL that you expect to generate. You are able to define a custom route structure in the next step, but the collection file name must always match the start of the URL.
|
||||
|
||||
- **Example**: `src/pages/$tags.astro` -> `/tags/:tag`
|
||||
- **Example**: `src/pages/$posts.astro` -> `/posts/1`, `/posts/2`, etc.
|
||||
|
||||
### 2. Export createCollection
|
||||
|
||||
Every collection must define and export a `createCollection` function inside the component script. This exported function is where you fetch your data for the entire collection and tell Astro the exact URLs that you'd like to generate. It **MUST** be named `createCollection` and it must be exported. Check out the examples below for examples of how this should be implemented.
|
||||
|
||||
```astro
|
||||
---
|
||||
export async function createCollection() {
|
||||
/* fetch collection data here */
|
||||
return { /* see examples below */ };
|
||||
}
|
||||
---
|
||||
<!-- Not shown: Page HTML template -->
|
||||
```
|
||||
|
||||
API Reference: [createCollection](/reference/api-reference#collections-api)
|
||||
|
||||
## Example: Individual Pages
|
||||
|
||||
One of the most common reasons to use a collection is to generate a page for every item fetched from a larger dataset. In this example, we'll query a remote API and use the result to generate 150 different pages: one for each pokemon returned by the API call.
|
||||
|
||||
Run this example in development, and then visit [http://localhost:3000/pokemon/pikachu](http://localhost:3000/pokemon/pikachu) to see one of the generated pages.
|
||||
|
||||
```jsx
|
||||
---
|
||||
// Example: src/pages/$pokemon.astro
|
||||
// Define a `createCollection` function.
|
||||
// In this example, we'll create a new page for every single pokemon.
|
||||
export async function createCollection() {
|
||||
// Do your data fetching here.
|
||||
const allPokemonResponse = await fetch(`https://pokeapi.co/api/v2/pokemon?limit=150`);
|
||||
const allPokemonResult = await allPokemonResponse.json();
|
||||
const allPokemon = allPokemonResult.results;
|
||||
return {
|
||||
// `route` defines the URL structure for your collection.
|
||||
// You can use any URL path pattern here, as long as it
|
||||
// matches the filename prefix (`$pokemon.astro` -> `/pokemon/*`).
|
||||
route: `/pokemon/:name`,
|
||||
// `paths` tells Astro which pages to generate in your collection.
|
||||
// Provide an array of `params` objects that match the `route` pattern.
|
||||
paths() {
|
||||
return allPokemon.map((pokemon, i) => ({params: {name: pokemon.name}}));
|
||||
},
|
||||
// For each individual page, return the data needed on each page.
|
||||
// If you needed to fetch more data for each page, you can do that here as well.
|
||||
// Luckily, we loaded all of the data that we need at the top of the function.
|
||||
async props({ params }) {
|
||||
return {item: allPokemon.find((pokemon) => pokemon.name === params.name)};
|
||||
},
|
||||
};
|
||||
}
|
||||
// The rest of your component script now runs on each individual page.
|
||||
// "item" is one of the props returned in the `props()` function.
|
||||
const {item} = Astro.props;
|
||||
---
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Pokemon: {item.name}</head>
|
||||
<body>
|
||||
Who's that pokemon? It's {item.name}!
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Example: Grouping Content by Page
|
||||
|
||||
You can also group items by page. In this example, we'll fetch data from the same Pokemon API. But instead of generating 150 pages, we'll just generate one page for every letter of the alphabet, creating an alphabetical index of Pokemon.
|
||||
|
||||
_Note: Looking for pagination? Collections have built-in support to make pagination easy. Be sure to check out the next example._
|
||||
|
||||
```jsx
|
||||
---
|
||||
// Define a `createCollection` function.
|
||||
export async function createCollection() {
|
||||
// Do your data fetching here.
|
||||
const allPokemonResponse = await fetch(`https://pokeapi.co/api/v2/pokemon?limit=150`);
|
||||
const allPokemonResult = await allPokemonResponse.json();
|
||||
const allPokemon = allPokemonResult.results;
|
||||
const allLetters = ['a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','x','y','z'];
|
||||
return {
|
||||
// `route` defines the URL structure for your collection.
|
||||
// You can use any URL path pattern here, as long as it
|
||||
// matches the filename prefix (`$pokemon.astro` -> `/pokemon/*`).
|
||||
route: `/pokemon/:letter`,
|
||||
// `paths` tells Astro which pages to generate in your collection.
|
||||
// Provide an array of `params` objects that match the `route` pattern.
|
||||
// Here, we create a route for each letter (ex: "a" -> {letter: "a"}).
|
||||
paths() {
|
||||
return allLetters.map(letter => ({params: {letter}}));
|
||||
},
|
||||
// `props` returns the data needed on each page.
|
||||
// For each individual page, return the data needed on each page.
|
||||
// If you needed to fetch more data for each page, you can do that here as well.
|
||||
// Luckily, we loaded all of the data that we need at the top of the function.
|
||||
async props({ params }) {
|
||||
return {
|
||||
letter: params.letter,
|
||||
items: allPokemon.filter((pokemon) => pokemon.name[0] === params.letter)};
|
||||
},
|
||||
};
|
||||
}
|
||||
// The rest of your component script now runs on each individual page.
|
||||
// "item" is one of the props returned in the `props()` function.
|
||||
const {letter, items} = Astro.props;
|
||||
---
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Page: {letter}</head>
|
||||
<body>
|
||||
{items.map((pokemon) => (<h1>{pokemon.name}</h1>))}
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Example: Pagination
|
||||
|
||||
Pagination is a common use-case for static websites. Astro has built-in pagination support that was designed to make pagination effortless. Just pass `paginate: true` in the `createCollection` return object to enable automatic pagination.
|
||||
|
||||
This example provides a basic implementation of pagination. In the previous examples, we had fetched from a remote API. In this example, we'll fetch our local markdown files to create a paginated list of all posts for a blog.
|
||||
|
||||
```jsx
|
||||
---
|
||||
// Define a `createCollection` function.
|
||||
export async function createCollection() {
|
||||
const allPosts = Astro.fetchContent('../posts/*.md') // fetch local posts...
|
||||
.sort((a, b) => a.title.localeCompare(b.title)); // ... and sort by title.
|
||||
|
||||
return {
|
||||
// Set "paginate" to true to enable pagination.
|
||||
paginate: true,
|
||||
// Remember to add the ":page?" param for pagination.
|
||||
// The "?" indicates an optional param, since the first page does not use it.
|
||||
// Example: `/posts`, `/posts/2`, `/posts/3`, etc.
|
||||
route: '/posts/:page?',
|
||||
// `paths()` - not needed if `:page?` is your only route param.
|
||||
// If you define have other params in your route, then you will still
|
||||
// need a paths() function similar to the examples above.
|
||||
//
|
||||
// `props()` - notice the new `{paginate}` argument! This is passed to
|
||||
// the props() function when `paginate` is set to true. We can now use
|
||||
// it to enable pagination on a certain prop. In this example, we paginate
|
||||
// "posts" so that multiple pages will be generated based on the given page size.
|
||||
async props({paginate}) {
|
||||
return {
|
||||
posts: paginate(allPosts, {pageSize: 10}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
// Now, you can get the paginated posts from your props.
|
||||
// Note that a paginated prop is a custom object format, where the data
|
||||
// for the page is available at `posts.data`. See the next example to
|
||||
// learn how to use the other properties of this object.
|
||||
const {posts} = Astro.props;
|
||||
---
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Pagination Example</title>
|
||||
</head>
|
||||
<body>
|
||||
{posts.data.map((post) => (
|
||||
<h1>{post.title}</h1>
|
||||
<time>{formatDate(post.published_at)}</time>
|
||||
<a href={post.url}>Read Post</a>
|
||||
))}
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Example: Pagination Metadata
|
||||
|
||||
Building on the example above: when you use the `paginate` API you get access to several other properties in the paginated data prop. Your paginated prop includes important metadata
|
||||
for the collection, such as: `.page` for keeping track of your page number and `.url` for linking to other pages in the collection.
|
||||
|
||||
In this example, we'll use these values to add pagination UI controls to your HTML template.
|
||||
|
||||
```jsx
|
||||
---
|
||||
export async function createCollection() { /* See Previous Example */ }
|
||||
// Remember that a paginated prop uses a custom object format to help with pagination.
|
||||
const {posts} = Astro.props;
|
||||
---
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Pagination Example: Page Number {posts.page.current}</title>
|
||||
<link rel="canonical" href={posts.url.current} />
|
||||
<link rel="prev" href={posts.url.prev} />
|
||||
<link rel="next" href={posts.url.next} />
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h5>Results {posts.start + 1}–{posts.end + 1} of {posts.total}</h5>
|
||||
</main>
|
||||
<footer>
|
||||
<h4>Page {posts.page.current} / {posts.page.last}</h4>
|
||||
<nav class="nav">
|
||||
<a class="prev" href={posts.url.prev || '#'}>Prev</a>
|
||||
<a class="next" href={posts.url.next || '#'}>Next</a>
|
||||
</nav>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## RSS Feeds
|
||||
|
||||
You can generate an RSS 2.0 feed from the `createCollection()` result by adding the `rss` option. Here are all the options:
|
||||
|
||||
```jsx
|
||||
export async function createCollection() {
|
||||
return {
|
||||
paginate: true,
|
||||
route: '/posts/:page?',
|
||||
async props({ paginate }) {
|
||||
/* Not shown: see examples above */
|
||||
},
|
||||
rss: {
|
||||
title: 'My RSS Feed',
|
||||
// if you want a full text feed, add your markup here (e.g. item.astro.html)
|
||||
description: 'Description of the feed',
|
||||
// (optional) add xmlns:* properties to root element
|
||||
xmlns: {
|
||||
itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd',
|
||||
content: 'http://purl.org/rss/1.0/modules/content/',
|
||||
},
|
||||
// (optional) add arbitrary XML to <channel>
|
||||
customData: `<language>en-us</language>
|
||||
<itunes:author>The Sunset Explorers</itunes:author>`,
|
||||
// Format each paginated item in the collection
|
||||
item: (item) => ({
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
// enforce GMT timezone (otherwise it'll be different based on where it's built)
|
||||
pubDate: item.pubDate + 'Z',
|
||||
// link is required, shows up in RSS readers
|
||||
link: '/collection/' + item.id
|
||||
// (optional) custom data is supported here as well
|
||||
customData: ``<id>${Astro.site}collection/${item.id}</id>``
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Astro will generate your RSS feed at the URL `/feed/[collection].xml`. For example, `/src/pages/$podcast.astro` would generate URL `/feed/podcast.xml`.
|
||||
|
||||
Even though Astro will create the RSS feed for you, you'll still need to add `<link>` tags manually in your `<head>` HTML for feed readers and browsers to pick up:
|
||||
|
||||
```html
|
||||
<link
|
||||
rel="alternate"
|
||||
type="application/rss+xml"
|
||||
title="My RSS Feed"
|
||||
href="/feed/podcast.xml"
|
||||
/>
|
||||
```
|
||||
|
||||
### 📚 Further Reading
|
||||
|
||||
- [Fetching data in Astro](/guides/data-fetching)
|
||||
- API Reference: [createCollection()](/reference/api-reference#createcollection)
|
||||
- API Reference: [createCollection() > Pagination](/reference/api-reference#pagination)
|
||||
- API Reference: [createCollection() > RSS](/reference/api-reference#rss)
|
103
docs/src/pages/core-concepts/routing.md
Normal file
103
docs/src/pages/core-concepts/routing.md
Normal file
|
@ -0,0 +1,103 @@
|
|||
---
|
||||
layout: ~/layouts/MainLayout.astro
|
||||
title: Routing
|
||||
---
|
||||
|
||||
Astro uses **file-based routing** to generate your build URLs based on the file layout of your project `src/pages` directory. When a file is added to the `src/pages` directory of your project, it is automatically available as a route based on its filename.
|
||||
|
||||
## Static routes
|
||||
|
||||
Astro Components (`.astro`) and Markdown Files (`.md`) in the `src/pages` directory become pages on your website. Each page's route is decided based on it's filename and path within the `src/pages` directory. This means that there is no separate "routing config" to maintain in an Astro project.
|
||||
|
||||
```bash
|
||||
# Example: Static routes
|
||||
src/pages/index.astro -> mysite.com/
|
||||
src/pages/about.astro -> mysite.com/about
|
||||
src/pages/about/index.astro -> mysite.com/about
|
||||
src/pages/about/me.astro -> mysite.com/about/me
|
||||
src/pages/posts/1.md -> mysite.com/posts/1
|
||||
```
|
||||
|
||||
## Dynamic routes
|
||||
|
||||
Sometimes, you need to generate many URLs from a single page component. Astro uses file-based routing to support **dynamic route parameters** in the filename, so that one page can match many dynamic routes based on some pattern.
|
||||
|
||||
An important thing to keep in mind: Astro is a static site builder. There is no Astro server to run in production, which means that every page must be built ahead of time. Pages that use dynamic routes must export a `getStaticPaths()` function which will tell Astro exactly what pages to generate. Learn more by viewing the complete [API Reference](/reference/api-reference#getstaticpaths).
|
||||
|
||||
|
||||
|
||||
### Named parameters
|
||||
|
||||
Dynamic parameters are encoded into the filename using `[bracket]` notation:
|
||||
|
||||
- `pages/blog/[slug].astro` → `/blog/:slug` (`/blog/hello-world`, `/blog/post-2`, etc.)
|
||||
- `pages/[username]/settings.astro` → (`/fred/settings`, `/drew/settings`, etc.)
|
||||
- `pages/[lang]-[version]/info.astro` → (`/en-v1/info`, `/fr-v2/info`, etc.)
|
||||
|
||||
#### Example: Named parameters
|
||||
|
||||
Consider the following page `pages/post/[pid].astro`:
|
||||
|
||||
```jsx
|
||||
---
|
||||
// Example: src/pages/post/[pid].astro
|
||||
const {pid} = Astro.request.params;
|
||||
---
|
||||
<p>Post: {pid}</p>
|
||||
```
|
||||
|
||||
Any route like `/post/1`, `/post/abc`, etc. will be matched by `pages/post/[pid].astro`. The matched path parameter will be passed to the page component at `Astro.request.params`.
|
||||
|
||||
For example, the route `/post/abc` will have the following `Astro.request.params` object available:
|
||||
|
||||
```json
|
||||
{ "pid": "abc" }
|
||||
```
|
||||
|
||||
Multiple dynamic route segments can be combined to work the same way. The page `pages/post/[pid]/[comment].astro` will match the route `/post/abc/a-comment` and its `query` object will be:
|
||||
|
||||
```json
|
||||
{ "pid": "abc", "comment": "a-comment" }
|
||||
```
|
||||
|
||||
### Rest parameters
|
||||
|
||||
If you need more flexibility in your URL routing, you can use a rest parameter as a universal catch-all. You do this by adding three dots (`...`) inside your brackets. For example:
|
||||
|
||||
- `pages/post/[...slug].astro` → (`/post/a`, `/post/a/b`, `/post/a/b/c`, etc.)
|
||||
|
||||
Matched parameters will be sent as a query parameter (`slug` in the example) to the page. In the example above, the path `/post/a/b/c` will have the following `query` object:
|
||||
|
||||
```json
|
||||
{ "slug": "a/b/c" }
|
||||
```
|
||||
|
||||
You can use names other than `slug`, such as: `[...param]` or `[...name]`.
|
||||
|
||||
Rest parameters are optional by default, so `pages/post/[...slug].astro` could match `/post/` as well.
|
||||
|
||||
#### Example: Rest parameters
|
||||
|
||||
For a real-world example, you might implement GitHub's file viewer like so:
|
||||
|
||||
```
|
||||
/[org]/[repo]/tree/[branch]/[...file]
|
||||
```
|
||||
|
||||
In this example, a request for `/snowpackjs/astro/tree/main/docs/public/favicon.svg` would result in the following parameters being available to the page:
|
||||
|
||||
```js
|
||||
{
|
||||
org: 'snowpackjs',
|
||||
repo: 'astro',
|
||||
branch: 'main',
|
||||
file: 'docs/public/favicon.svg'
|
||||
}
|
||||
```
|
||||
|
||||
## Caveats
|
||||
|
||||
- Static routes without path params will take precedence over all other routes, and named path params over catch all path params. Take a look at the following examples:
|
||||
- `pages/post/create.astro` - Will match `/post/create`
|
||||
- `pages/post/[pid].astro` - Will match `/post/1`, `/post/abc`, etc. But not `/post/create`
|
||||
- `pages/post/[...slug].astro` - Will match `/post/1/2`, `/post/a/b/c`, etc. But not `/post/create`, `/post/abc`
|
1
docs/src/pages/foo/index.astro
Normal file
1
docs/src/pages/foo/index.astro
Normal file
|
@ -0,0 +1 @@
|
|||
<h1>hello</h1>
|
108
docs/src/pages/guides/pagination.md
Normal file
108
docs/src/pages/guides/pagination.md
Normal file
|
@ -0,0 +1,108 @@
|
|||
---
|
||||
layout: ~/layouts/MainLayout.astro
|
||||
title: Pagination
|
||||
---
|
||||
|
||||
Astro supports built-in, automatic pagination for large collections of data that need to be split into multiple pages. Astro also automatically includes pagination metadata for things like previous/next page URL, total number of pages, and more.
|
||||
|
||||
## When to use pagination
|
||||
|
||||
Pagination is only useful when you need to generate multiple, numbered pages from a larger data set.
|
||||
|
||||
If all of your data can fit on a single page then you should consider using a static [page component](/core-concepts/astro-pages) instead.
|
||||
|
||||
If you need to split your data into multiple pages but do not want those page URLs to be numbered, then you should use a [dynamic page](/core-concepts/routing) instead without pagination (Example: `/tag/[tag].astro`).
|
||||
|
||||
## How to use pagination
|
||||
|
||||
### Create your page component
|
||||
|
||||
To automatically paginate some data, you'll first need to create your page component. This is the component `.astro` file that every page in the paginated collection will inherit from.
|
||||
|
||||
Pagination is built on top of dynamic page routing, with the page number in the URL represented as a dynamic route param: `[page].astro` or `[...page].astro`. If you aren't familiar with routing in Astro, quickly familiarize yourself with our [Routing documentation](/core-concepts/routing) before continuing.
|
||||
|
||||
Your first page URL will be different depending on which type of query param you use:
|
||||
|
||||
- `/posts/[page].astro` will generate the URLs `/posts/1`, `/posts/2`, `/posts/3`, etc.
|
||||
- `/posts/[...page].astro` will generate the URLs `/posts`, `/posts/2`, `/posts/3`, etc.
|
||||
|
||||
### calling the `paginate()` function
|
||||
|
||||
Once you have decided on the file name/path for your page component, you'll need to export a [`getStaticPaths()`](/reference/api-reference#getstaticpaths) function from the component. `getStaticPaths()` is where you tell Astro what pages to generate.
|
||||
|
||||
`getStaticPaths()` provides the `paginate()` function that we'll use to paginate your data. In the example below, we'll use `paginate()` to split a list of 150 Pokemon into 15 pages of 10 Pokemon each.
|
||||
|
||||
```js
|
||||
export async function getStaticPaths({ paginate }) {
|
||||
// Load your data with fetch(), Astro.fetchContent(), etc.
|
||||
const response = await fetch(`https://pokeapi.co/api/v2/pokemon?limit=150`);
|
||||
const result = await response.json();
|
||||
const allPokemon = result.results;
|
||||
// Return a paginated collection of paths for all posts
|
||||
return paginate(allPokemon, { pageSize: 10 });
|
||||
}
|
||||
// If set up correctly, The page prop now has everything that
|
||||
// you need to render a single page (see next section).
|
||||
const { page } = Astro.props;
|
||||
```
|
||||
|
||||
`paginate()` generates the correct array of path objects for `getStaticPaths()`. This automatically tells Astro to create a new URL for every page of the collection. The page data will then be passed as a `page` prop to the `.astro` page component.
|
||||
|
||||
### using the `page` prop
|
||||
|
||||
Once you've set up your page component and defined your `getStaticPaths()` function, you're ready to design your page template. Each page in the paginated collection will be passed its data in the `page` prop.
|
||||
|
||||
```astro
|
||||
---
|
||||
export async function getStaticPaths { /* ... */ }
|
||||
const { page } = Astro.props;
|
||||
---
|
||||
<h1>Page {page.currentPage}</h1>
|
||||
<ul>
|
||||
{page.data.map(item => <li>{item.title}</h1>)}
|
||||
</ul>
|
||||
```
|
||||
|
||||
The `page` prop has several useful properties, but the most important one is `page.data`. This is the array containing the page's slice of data that you passed to the `paginate()` function. For example, if you called `paginate()` on an array of 150 Pokemon:
|
||||
|
||||
- `/1`: `page.data` would be an array of the first 10 Pokemon
|
||||
- `/2`: `page.data` would be an array of Pokemon 11-20
|
||||
- `/3`: `page.data` would be an array of Pokemon 21-30
|
||||
- etc. etc.
|
||||
|
||||
The `page` prop includes other helpful metadata, like `page.url.next`, `page.url.prev`, `page.total`, and more. See our [API reference](/reference/api-reference#the-pagination-page-prop) for the full `page` interface.
|
||||
|
||||
|
||||
## Nested pagination
|
||||
|
||||
A more advanced use-case for pagination is **nested pagination.** This is when pagination is combined with other dynamic route params. You can use nested pagination to group your paginated collection by some property or tag.
|
||||
|
||||
For example, if you want to group your paginated markdown posts by some tag, you would use nested pagination by creating a `/src/pages/[tag]/[page].astro` page that would match the following URLS:
|
||||
|
||||
- `/red/1` (tag=red)
|
||||
- `/red/2` (tag=red)
|
||||
- `/blue/1` (tag=blue)
|
||||
- `/green/1` (tag=green)
|
||||
|
||||
Nested pagination works by returning an array of `paginate()` results from `getStaticPaths()`, one for each grouping. In the following example, we will implement nested pagination to build the URLs listed above:
|
||||
|
||||
```js
|
||||
---
|
||||
// Example: /src/pages/[tag]/[page].astro
|
||||
export function getStaticPaths({paginate}) {
|
||||
const allTags = ['red', 'blue', 'green'];
|
||||
const allPosts = Astro.fetchContent('../../posts/*.md');
|
||||
// For every tag, return a paginate() result.
|
||||
// Make sure that you pass `{params: {tag}}` to `paginate()`
|
||||
// so that Astro knows which tag grouping the result is for.
|
||||
return allTags.map((tag) => {
|
||||
const filteredPosts = allPosts.filter((post) => post.tag === tag);
|
||||
return paginate(filteredPosts, {
|
||||
params: { tag },
|
||||
pageSize: 10
|
||||
});
|
||||
});
|
||||
}
|
||||
const { page } = Astro.props;
|
||||
const { params } = Astro.request;
|
||||
```
|
42
docs/src/pages/guides/rss.md
Normal file
42
docs/src/pages/guides/rss.md
Normal file
|
@ -0,0 +1,42 @@
|
|||
---
|
||||
layout: ~/layouts/MainLayout.astro
|
||||
title: RSS
|
||||
---
|
||||
|
||||
Astro supports fast, automatic RSS feed generation for blogs and other content websites.
|
||||
|
||||
You can create an RSS feed from any Astro page that uses a `getStaticPaths()` function for routing. Only dynamic routes can use `getStaticPaths()` today (see [Routing](/core-concepts/routing).
|
||||
|
||||
> We hope to make this feature available to all other pages before v1.0. As a workaround, you can convert a static route to a dynamic route that only generates a single page. See [Routing](/core-concepts/routing) for more information about dynamic routes.
|
||||
|
||||
Create an RSS Feed by calling the `rss()` function that is passed as an argument to `getStaticPaths()`. This will create an `rss.xml` file in your final build based on the data that you provide using the `items` array.
|
||||
|
||||
```js
|
||||
// Example: /src/pages/posts/[...page].astro
|
||||
// Place this function inside your Astro component script.
|
||||
export async function getStaticPaths({rss}) {
|
||||
const allPosts = Astro.fetchContent('../post/*.md');
|
||||
const sortedPosts = allPosts.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||
// Generate an RSS feed from this collection
|
||||
rss({
|
||||
// The RSS Feed title, description, and custom metadata.
|
||||
title: 'Don’s Blog',
|
||||
description: 'An example blog on Astro',
|
||||
customData: `<language>en-us</language>`,
|
||||
// The list of items for your RSS feed, sorted.
|
||||
items: sortedPosts.map(item => ({
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
link: item.url,
|
||||
pubDate: item.date,
|
||||
})),
|
||||
// Optional: Customize where the file is written to.
|
||||
// Otherwise, defaults to "/rss.xml"
|
||||
dest: "/my/custom/feed.xml",
|
||||
});
|
||||
// Return your paths
|
||||
return [...];
|
||||
}
|
||||
```
|
||||
|
||||
Note: RSS feeds will **not** be built during development. Currently, RSS feeds are only generated during your final build.
|
|
@ -64,92 +64,152 @@ const data = Astro.fetchContent('../pages/post/*.md'); // returns an array of po
|
|||
|
||||
`Astro.site` returns a `URL` made from `buildOptions.site` in your Astro config. If undefined, this will return a URL generated from `localhost`.
|
||||
|
||||
## Collections API
|
||||
## `getStaticPaths()`
|
||||
|
||||
A collection is any file in the `src/pages` directory that starts with a dollar sign (`$`) and includes an exported `createCollection` function in the component script.
|
||||
If a page uses dynamic params in the filename, that component will need to export a `getStaticPaths()` function.
|
||||
|
||||
Check out our [Astro Collections](/core-concepts/collections) guide for more information and examples.
|
||||
|
||||
### `createCollection()`
|
||||
This function is required because Astro is a static site builder. That means that your entire site is built ahead of time. If Astro doesn't know to generate a page at build time, your users won't see it when they visit your site.
|
||||
|
||||
```jsx
|
||||
---
|
||||
export async function createCollection() {
|
||||
return { /* ... */ };
|
||||
export async function getStaticPaths() {
|
||||
return [
|
||||
{ params: { ... } },
|
||||
{ params: { ... } },
|
||||
{ params: { ... } },
|
||||
// ...
|
||||
];
|
||||
}
|
||||
---
|
||||
<!-- Your HTML template here. -->
|
||||
```
|
||||
|
||||
⚠️ The `createCollection()` function executes in its own isolated scope before page loads. Therefore you can't reference anything from its parent scope, other than file imports. The compiler will warn if you break this requirement.
|
||||
The `getStaticPaths()` function should return an array of objects to determine which paths will be pre-rendered by Astro.
|
||||
|
||||
The `createCollection()` function should returns an object of the following shape:
|
||||
⚠️ The `getStaticPaths()` function executes in its own isolated scope once, before any page loads. Therefore you can't reference anything from its parent scope, other than file imports. The compiler will warn if you break this requirement.
|
||||
|
||||
| Name | Type | Description |
|
||||
| :--------- | :--------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `route` | `string` | **Required.** A route pattern for matching URL requests. This is used to generate the page URL in your final build. It must begin with the file name: for example, `pages/$tags.astro` must return a `route` that starts with `/tags/`. |
|
||||
| `paths` | `{params: Params}[]` | Return an array of all URL to be generated. |
|
||||
| `props` | `async ({ params, paginate }) => object` | **Required.** Load data for the page that will get passed to the page component as props. |
|
||||
| `paginate` | `boolean` | Optional: Enable automatic pagination. See next section. |
|
||||
| `rss` | [RSS](/reference/api-reference#rss-feed) | Optional: generate an RSS 2.0 feed from this collection ([docs](/reference/api-reference#rss-feed)) |
|
||||
### `params`
|
||||
|
||||
### Pagination
|
||||
The `params` key of every returned object tells Astro what routes to build. The returned params must map back to the dynamic parameters and rest parameters defined in your component filepath.
|
||||
|
||||
Enable pagination for a collection by returning `paginate: true` from `createCollection()`. This passes a `paginate()` argument to `props()` that you can use to return paginated data in your HTML template via props.
|
||||
`params` are encoded into the URL, so only strings are supported as values. The value for each `params` object must match the parameters used in the page name.
|
||||
|
||||
The `paginate()` function that you use inside of `props()` has the following interface:
|
||||
For example, suppose that you have a page at `src/pages/posts/[id].astro`. If you export `getStaticPaths` from this page and return the following for paths:
|
||||
|
||||
```ts
|
||||
/* the "paginate()" passed to props({paginate}) */
|
||||
type PaginateFunction = (
|
||||
data: any[],
|
||||
args?: {
|
||||
/* pageSize: set the number of items to be shown on every page. Defaults to 10. */
|
||||
pageSize?: number;
|
||||
}
|
||||
) => PaginatedCollectionResult;
|
||||
```js
|
||||
---
|
||||
export async function getStaticPaths() {
|
||||
return [
|
||||
{ params: { id: '1' } },
|
||||
{ params: { id: '2' } }
|
||||
];
|
||||
}
|
||||
const {id} = Astro.request.params;
|
||||
---
|
||||
<body><h1>{id}</h1></body>
|
||||
```
|
||||
|
||||
/* the paginated return value, aka the prop passed to every page in the collection. */
|
||||
interface PaginatedCollectionResult {
|
||||
/** result */
|
||||
data: any[];
|
||||
Then Astro will statically generate `posts/1` and `posts/2` at build time.
|
||||
|
||||
/** metadata */
|
||||
/** the count of the first item on the page, starting from 0 */
|
||||
start: number;
|
||||
/** the count of the last item on the page, starting from 0 */
|
||||
end: number;
|
||||
/** total number of results */
|
||||
total: number;
|
||||
page: {
|
||||
/** the current page number, starting from 1 */
|
||||
current: number;
|
||||
/** number of items per page (default: 25) */
|
||||
size: number;
|
||||
/** number of last page */
|
||||
last: number;
|
||||
};
|
||||
url: {
|
||||
/** url of the current page */
|
||||
current: string;
|
||||
/** url of the previous page (if there is one) */
|
||||
prev: string | undefined;
|
||||
/** url of the next page (if there is one) */
|
||||
next: string | undefined;
|
||||
};
|
||||
### Data Passing with `props`
|
||||
|
||||
To pass additional data to each generated page, you can also set a `props` value on every returned path object. Unlike `params`, `props` are not encoded into the URL and so aren't limited to only strings.
|
||||
|
||||
For example, suppose that you generate pages based off of data fetched from a remote API. You can pass the full data object to the page component inside of `getStaticPaths`:
|
||||
|
||||
```js
|
||||
---
|
||||
export async function getStaticPaths() {
|
||||
const data = await fetch('...').then(response => response.json());
|
||||
return data.map((post) => {
|
||||
return {
|
||||
params: { id: post.id },
|
||||
props: { post } };
|
||||
});
|
||||
}
|
||||
const {id} = Astro.request.params;
|
||||
const {post} = Astro.props;
|
||||
---
|
||||
<body><h1>{id}: {post.name}</h1></body>
|
||||
```
|
||||
|
||||
Then Astro will statically generate `posts/1` and `posts/2` at build time using the page component in `pages/posts/[id].astro`. The page can reference this data using `Astro.props`:
|
||||
|
||||
### `paginate()`
|
||||
|
||||
Pagination is a common use-case for websites that Astro natively supports via the `paginate()` function. `paginate()` will automatically generate the array to return from `getStaticPaths()` that creates one URL for every page of the paginated collection. The page number will be passed as a param, and the page data will be passed as a `page` prop.
|
||||
|
||||
```js
|
||||
export async function getStaticPaths({ paginate }) {
|
||||
// Load your data with fetch(), Astro.fetchContent(), etc.
|
||||
const response = await fetch(`https://pokeapi.co/api/v2/pokemon?limit=150`);
|
||||
const result = await response.json();
|
||||
const allPokemon = result.results;
|
||||
// Return a paginated collection of paths for all posts
|
||||
return paginate(allPokemon, { pageSize: 10 });
|
||||
}
|
||||
// If set up correctly, The page prop now has everything that
|
||||
// you need to render a single page (see next section).
|
||||
const { page } = Astro.props;
|
||||
```
|
||||
|
||||
`paginate()` assumes a file name of `[page].astro` or `[...page].astro`. The `page` param becomes the page number in your URL:
|
||||
|
||||
- `/posts/[page].astro` would generate the URLs `/posts/1`, `/posts/2`, `/posts/3`, etc.
|
||||
- `/posts/[...page].astro` would generate the URLs `/posts`, `/posts/2`, `/posts/3`, etc.
|
||||
|
||||
#### The pagination `page` prop
|
||||
|
||||
Pagination will pass a `page` prop to every rendered page that represents a single page of data in the paginated collection. This includes the data that you've paginated (`page.data`) as well as metadata for the page (`page.url`, `page.start`, `page.end`, `page.total`, etc). This metadata is useful for for things like a "Next Page" button or a "Showing 1-10 of 100" message.
|
||||
|
||||
| Name | Type | Description |
|
||||
| :----------------- | :-------------------: | :-------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `page.data` | `Array` | Array of data returned from `data()` for the current page. |
|
||||
| `page.start` | `number` | Index of first item on current page, starting at `0` (e.g. if `pageSize: 25`, this would be `0` on page 1, `25` on page 2, etc.). |
|
||||
| `page.end` | `number` | Index of last item on current page. |
|
||||
| `page.size` | `number` | How many items per-page. |
|
||||
| `page.total` | `number` | The total number of items across all pages. |
|
||||
| `page.currentPage` | `number` | The current page number, starting with `1`. |
|
||||
| `page.lastPage` | `number` | The total number of pages. |
|
||||
| `page.url.current` | `string` | Get the URL of the current page (useful for canonical URLs) |
|
||||
| `page.url.prev` | `string \| undefined` | Get the URL of the previous page (will be `undefined` if on page 1). |
|
||||
| `page.url.next` | `string \| undefined` | Get the URL of the next page (will be `undefined` if no more pages). |
|
||||
|
||||
### `rss()`
|
||||
|
||||
RSS feeds are another common use-case that Astro supports natively. Call the `rss()` function to generate an `/rss.xml` feed for your project using the same data that you loaded for this page. This file location can be customized (see below).
|
||||
|
||||
```js
|
||||
// Example: /src/pages/posts/[...page].astro
|
||||
// Place this function inside your Astro component script.
|
||||
export async function getStaticPaths({rss}) {
|
||||
const allPosts = Astro.fetchContent('../post/*.md');
|
||||
const sortedPosts = allPosts.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||
// Generate an RSS feed from this collection
|
||||
rss({
|
||||
// The RSS Feed title, description, and custom metadata.
|
||||
title: 'Don’s Blog',
|
||||
description: 'An example blog on Astro',
|
||||
customData: `<language>en-us</language>`,
|
||||
// The list of items for your RSS feed, sorted.
|
||||
items: sortedPosts.map(item => ({
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
link: item.url,
|
||||
pubDate: item.date,
|
||||
})),
|
||||
// Optional: Customize where the file is written to.
|
||||
// Defaults to "/rss.xml"
|
||||
dest: "/my/custom/feed.xml",
|
||||
});
|
||||
// Return a paginated collection of paths for all posts
|
||||
return [...];
|
||||
}
|
||||
```
|
||||
|
||||
📚 Learn more about pagination (and see an example) in our [Astro Collections guide.](/core-concepts/collections).
|
||||
|
||||
### RSS
|
||||
|
||||
Create an RSS 2.0 feed for a collection by returning `paginate: true` & an `rss` object from `createCollection()`. The `rss` object will be used to generate the contents of the RSS XML file.
|
||||
|
||||
The `rss` object follows the `CollectionRSS`data type:
|
||||
|
||||
```ts
|
||||
export interface CollectionRSS<T = any> {
|
||||
// The full type definition for the rss() function argument:
|
||||
interface RSSArgument {
|
||||
/** (required) Title of the RSS Feed */
|
||||
title: string;
|
||||
/** (required) Description of the RSS Feed */
|
||||
|
@ -158,8 +218,14 @@ export interface CollectionRSS<T = any> {
|
|||
xmlns?: Record<string, string>;
|
||||
/** Specify custom data in opening of file */
|
||||
customData?: string;
|
||||
/**
|
||||
* Specify where the RSS xml file should be written.
|
||||
* Relative to final build directory. Example: '/foo/bar.xml'
|
||||
* Defaults to '/rss.xml'.
|
||||
*/
|
||||
dest?: string;
|
||||
/** Return data about each item */
|
||||
item: (item: T) => {
|
||||
items: {
|
||||
/** (required) Title of item */
|
||||
title: string;
|
||||
/** (required) Link to item */
|
||||
|
@ -170,12 +236,10 @@ export interface CollectionRSS<T = any> {
|
|||
description?: string;
|
||||
/** Append some other XML-valid data to this item */
|
||||
customData?: string;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
```
|
||||
|
||||
📚 Learn more about RSS feed generation (and see an example) in our [Astro Collections guide.](/core-concepts/collections).
|
||||
|
||||
## `import.meta`
|
||||
|
||||
> In this section we use `[dot]` to mean `.`. This is because of a bug in our build engine that is rewriting `import[dot]meta[dot]env` if we use `.` instead of `[dot]`.
|
||||
|
|
|
@ -4,7 +4,7 @@ export default {
|
|||
// dist: './dist', // When running `astro build`, path to final static output
|
||||
// public: './public', // A folder of static files Astro will copy to the root. Useful for favicons, images, and other files that don’t need processing.
|
||||
buildOptions: {
|
||||
// site: 'http://example.com', // Your public domain, e.g.: https://my-site.dev/. Used to generate sitemaps and canonical URLs.
|
||||
site: 'http://example.com', // Your public domain, e.g.: https://my-site.dev/. Used to generate sitemaps and canonical URLs.
|
||||
sitemap: true, // Generate sitemap (set to "false" to disable)
|
||||
},
|
||||
devOptions: {
|
||||
|
|
|
@ -56,8 +56,8 @@ a {
|
|||
<ul class="nav">
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="/posts">All Posts</a></li>
|
||||
<li><a href="/author/don">Author: Don</a></li>
|
||||
<li><a href="/author/sancho">Author: Sancho</a></li>
|
||||
<li><a href="/authors/don">Author: Don</a></li>
|
||||
<li><a href="/authors/sancho">Author: Sancho</a></li>
|
||||
<li><a href="/about">About</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
|
|
@ -56,7 +56,7 @@ time {
|
|||
|
||||
<div class="data">
|
||||
<h2>{post.title}</h2>
|
||||
<a class="author" href={`/author/${post.author}`}>{author.name}</a>
|
||||
<a class="author" href={`/authors/${post.author}`}>{author.name}</a>
|
||||
<time class="date" datetime={post.date}>{formatDate(post.date)}</time>
|
||||
<p class="description">
|
||||
{post.description}
|
||||
|
|
|
@ -1,105 +0,0 @@
|
|||
---
|
||||
import MainHead from '../components/MainHead.astro';
|
||||
import Nav from '../components/Nav.astro';
|
||||
import PostPreview from '../components/PostPreview.astro';
|
||||
import Pagination from '../components/Pagination.astro';
|
||||
|
||||
// page
|
||||
let title = 'Don’s Blog';
|
||||
let description = 'An example blog on Astro';
|
||||
let canonicalURL = Astro.request.canonicalURL;
|
||||
|
||||
// collection
|
||||
import authorData from '../data/authors.json';
|
||||
|
||||
export function createCollection() {
|
||||
/** Load posts */
|
||||
let allPosts = Astro.fetchContent('./post/*.md');
|
||||
return {
|
||||
paginate: true,
|
||||
route: `/author/:author/:page?`,
|
||||
paths() {
|
||||
let allAuthorsUnique = [...new Set(allPosts.map(p => p.author))];
|
||||
return allAuthorsUnique.map(author => ({params: {author}}))
|
||||
},
|
||||
async props({ params, paginate }) {
|
||||
/** filter posts by author, sort by date */
|
||||
const filteredPosts = allPosts
|
||||
.filter((post) => post.author === params.author)
|
||||
.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||
return {
|
||||
posts: paginate(filteredPosts, {pageSize: 1}),
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const { posts } = Astro.props;
|
||||
const { params } = Astro.request;
|
||||
const author = authorData[posts.data[0].author];
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{title}</title>
|
||||
<MainHead
|
||||
title={title}
|
||||
description={description}
|
||||
image={posts.data[0].image}
|
||||
canonicalURL={canonicalURL}
|
||||
prev={posts.url.prev}
|
||||
next={posts.url.next}
|
||||
/>
|
||||
|
||||
<style lang="scss">
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3em;
|
||||
letter-spacing: -0.04em;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
margin-right: 0.5em;
|
||||
border-radius: 50%;
|
||||
overflow:hidden;
|
||||
|
||||
&-img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 1em;
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Nav title={title} />
|
||||
|
||||
<main class="wrapper">
|
||||
<h2 class="title">
|
||||
<div class="avatar"><img class="avatar-img" src={author.image} alt=""}></div>
|
||||
{author.name}
|
||||
</h2>
|
||||
<small class="count">{posts.start + 1}–{posts.end + 1} of {posts.total}</small>
|
||||
{posts.data.map((post) => <PostPreview post={post} author={author} />)}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<Pagination prevUrl={posts.url.prev} nextUrl={posts.url.next} />
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
|
@ -1,74 +0,0 @@
|
|||
---
|
||||
import MainHead from '../components/MainHead.astro';
|
||||
import Nav from '../components/Nav.astro';
|
||||
import PostPreview from '../components/PostPreview.astro';
|
||||
import Pagination from '../components/Pagination.astro';
|
||||
|
||||
// page
|
||||
let title = 'Don’s Blog';
|
||||
let description = 'An example blog on Astro';
|
||||
let canonicalURL = Astro.request.canonicalURL;
|
||||
|
||||
// collection
|
||||
import authorData from '../data/authors.json';
|
||||
export function createCollection() {
|
||||
return {
|
||||
route: `/posts/:page?`,
|
||||
paginate: true,
|
||||
async props({ paginate }) {
|
||||
/** filter posts by author, sort by date */
|
||||
const allPosts = Astro.fetchContent('./post/*.md');
|
||||
const sortedPosts = allPosts.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||
return {
|
||||
posts: paginate(sortedPosts, {pageSize: 2}),
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { posts } = Astro.props;
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{title}</title>
|
||||
<MainHead
|
||||
title={title}
|
||||
description={description}
|
||||
image={posts.data[0].image}
|
||||
canonicalURL={canonicalURL}
|
||||
prev={posts.url.prev}
|
||||
next={posts.url.next}
|
||||
/>
|
||||
|
||||
<style lang="scss">
|
||||
.title {
|
||||
font-size: 3em;
|
||||
letter-spacing: -0.04em;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 1em;
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Nav title={title} />
|
||||
|
||||
<main class="wrapper">
|
||||
<h2 class="title">All Posts</h2>
|
||||
<small class="count">{posts.start + 1}–{posts.end + 1} of {posts.total}</small>
|
||||
{posts.data.map((post) => <PostPreview post={post} author={authorData[post.author]} />)}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<Pagination prevUrl={posts.url.prev} nextUrl={posts.url.next} />
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,85 @@
|
|||
---
|
||||
import MainHead from '../../components/MainHead.astro';
|
||||
import Nav from '../../components/Nav.astro';
|
||||
import PostPreview from '../../components/PostPreview.astro';
|
||||
import Pagination from '../../components/Pagination.astro';
|
||||
|
||||
// page
|
||||
let title = 'Don’s Blog';
|
||||
let description = 'An example blog on Astro';
|
||||
let canonicalURL = Astro.request.canonicalURL;
|
||||
|
||||
// collection
|
||||
import authorData from '../../data/authors.json';
|
||||
export function getStaticPaths() {
|
||||
const allPosts = Astro.fetchContent('../post/*.md');
|
||||
let allAuthorsUnique = [...new Set(allPosts.map(p => p.author))];
|
||||
return allAuthorsUnique.map(author => ({params: {author}, props: {allPosts}}));
|
||||
}
|
||||
|
||||
const { allPosts } = Astro.props;
|
||||
const { params } = Astro.request;
|
||||
|
||||
/** filter posts by author, sort by date */
|
||||
const posts = allPosts
|
||||
.filter((post) => post.author === params.author)
|
||||
.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||
const author = authorData[posts[0].author];
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{title}</title>
|
||||
<MainHead
|
||||
title={title}
|
||||
description={description}
|
||||
image={posts[0].image}
|
||||
canonicalURL={canonicalURL}
|
||||
/>
|
||||
|
||||
<style lang="scss">
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3em;
|
||||
letter-spacing: -0.04em;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
margin-right: 0.5em;
|
||||
border-radius: 50%;
|
||||
overflow:hidden;
|
||||
|
||||
&-img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 1em;
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Nav title={title} />
|
||||
|
||||
<main class="wrapper">
|
||||
<h2 class="title">
|
||||
<div class="avatar"><img class="avatar-img" src={author.image} alt=""}></div>
|
||||
{author.name}
|
||||
</h2>
|
||||
{posts.map((post) => <PostPreview post={post} author={author} />)}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,79 @@
|
|||
---
|
||||
import MainHead from '../../components/MainHead.astro';
|
||||
import Nav from '../../components/Nav.astro';
|
||||
import PostPreview from '../../components/PostPreview.astro';
|
||||
import Pagination from '../../components/Pagination.astro';
|
||||
|
||||
// page
|
||||
let title = 'Don’s Blog';
|
||||
let description = 'An example blog on Astro';
|
||||
let canonicalURL = Astro.request.canonicalURL;
|
||||
|
||||
// collection
|
||||
import authorData from '../../data/authors.json';
|
||||
export async function getStaticPaths({paginate, rss}) {
|
||||
const allPosts = Astro.fetchContent('../post/*.md');
|
||||
const sortedPosts = allPosts.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||
// Generate an RSS feed from this collection
|
||||
// TODO: DONT MERGE: This requires buildOptions.site to be set, which can't be set in a template
|
||||
rss({
|
||||
title: 'Don’s Blog',
|
||||
description: 'An example blog on Astro',
|
||||
customData: `<language>en-us</language>`,
|
||||
items: sortedPosts.map(item => ({
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
link: item.url,
|
||||
pubDate: item.date,
|
||||
})),
|
||||
});
|
||||
// Return a paginated collection of paths for all posts
|
||||
return paginate(sortedPosts, {pageSize: 1});
|
||||
}
|
||||
|
||||
const { page } = Astro.props;
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{title}</title>
|
||||
<MainHead
|
||||
title={title}
|
||||
description={description}
|
||||
image={page.data[0].image}
|
||||
canonicalURL={canonicalURL}
|
||||
prev={page.url.prev}
|
||||
next={page.url.next}
|
||||
/>
|
||||
|
||||
<style lang="scss">
|
||||
.title {
|
||||
font-size: 3em;
|
||||
letter-spacing: -0.04em;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 1em;
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Nav title={title} />
|
||||
|
||||
<main class="wrapper">
|
||||
<h2 class="title">All Posts</h2>
|
||||
<small class="count">{page.start + 1}–{page.end + 1} of {page.total}</small>
|
||||
{page.data.map((post) => <PostPreview post={post} author={authorData[post.author]} />)}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<Pagination prevUrl={page.url.prev} nextUrl={page.url.next} />
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
|
@ -1,6 +1,18 @@
|
|||
import type { ImportSpecifier, ImportDefaultSpecifier, ImportNamespaceSpecifier } from '@babel/types';
|
||||
import type { AstroMarkdownOptions } from '@astrojs/markdown-support';
|
||||
|
||||
export interface RouteData {
|
||||
type: 'page';
|
||||
pattern: RegExp;
|
||||
params: string[];
|
||||
path: string | null;
|
||||
component: string;
|
||||
generate: (data?: any) => string;
|
||||
}
|
||||
|
||||
export interface ManifestData {
|
||||
routes: RouteData[];
|
||||
}
|
||||
export interface AstroConfigRaw {
|
||||
dist: string;
|
||||
projectRoot: string;
|
||||
|
@ -61,8 +73,7 @@ export interface TransformResult {
|
|||
exports: string[];
|
||||
html: string;
|
||||
css?: string;
|
||||
/** If this page exports a collection, the JS to be executed as a string */
|
||||
createCollection?: string;
|
||||
getStaticPaths?: string;
|
||||
hasCustomElements: boolean;
|
||||
customElementCandidates: Map<string, string>;
|
||||
}
|
||||
|
@ -75,7 +86,8 @@ export interface CompileResult {
|
|||
|
||||
export type RuntimeMode = 'development' | 'production';
|
||||
|
||||
export type Params = Record<string, string>;
|
||||
export type Params = Record<string, string | undefined>;
|
||||
export type Props = Record<string, any>;
|
||||
|
||||
/** Entire output of `astro build`, stored in memory */
|
||||
export interface BuildOutput {
|
||||
|
@ -109,15 +121,8 @@ export interface PageDependencies {
|
|||
|
||||
export type PaginateFunction<T = any> = (data: T[], args?: { pageSize?: number }) => PaginatedCollectionResult<T>;
|
||||
|
||||
export interface CreateCollectionResult {
|
||||
paginate?: boolean;
|
||||
route: string;
|
||||
paths?: () => { params: Params }[];
|
||||
props: (args: { params: Params; paginate?: PaginateFunction }) => object | Promise<object>;
|
||||
rss?: CollectionRSS;
|
||||
}
|
||||
|
||||
export interface CollectionRSS<T = any> {
|
||||
export type GetStaticPathsResult = { params: Params; props?: Props }[] | { params: Params; props?: Props }[];
|
||||
export interface CollectionRSS {
|
||||
/** (required) Title of the RSS Feed */
|
||||
title: string;
|
||||
/** (required) Description of the RSS Feed */
|
||||
|
@ -126,8 +131,14 @@ export interface CollectionRSS<T = any> {
|
|||
xmlns?: Record<string, string>;
|
||||
/** Specify custom data in opening of file */
|
||||
customData?: string;
|
||||
/**
|
||||
* Specify where the RSS xml file should be written.
|
||||
* Relative to final build directory. Example: '/foo/bar.xml'
|
||||
* Defaults to '/rss.xml'.
|
||||
*/
|
||||
dest?: string;
|
||||
/** Return data about each item */
|
||||
item: (item: T) => {
|
||||
items: {
|
||||
/** (required) Title of item */
|
||||
title: string;
|
||||
/** (required) Link to item */
|
||||
|
@ -138,7 +149,7 @@ export interface CollectionRSS<T = any> {
|
|||
description?: string;
|
||||
/** Append some other XML-valid data to this item */
|
||||
customData?: string;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface PaginatedCollectionResult<T = any> {
|
||||
|
|
|
@ -1,22 +1,21 @@
|
|||
import type { AstroConfig, BundleMap, BuildOutput, RuntimeMode, PageDependencies } from './@types/astro';
|
||||
import type { LogOptions } from './logger';
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { performance } from 'perf_hooks';
|
||||
import eslexer from 'es-module-lexer';
|
||||
import cheerio from 'cheerio';
|
||||
import del from 'del';
|
||||
import { bold, green, yellow, red, dim, underline } from 'kleur/colors';
|
||||
import eslexer from 'es-module-lexer';
|
||||
import fs from 'fs';
|
||||
import { bold, green, red, underline, yellow } from 'kleur/colors';
|
||||
import mime from 'mime';
|
||||
import path from 'path';
|
||||
import { performance } from 'perf_hooks';
|
||||
import glob from 'tiny-glob';
|
||||
import { fileURLToPath } from 'url';
|
||||
import type { AstroConfig, BuildOutput, BundleMap, PageDependencies, RouteData, RuntimeMode } from './@types/astro';
|
||||
import { bundleCSS } from './build/bundle/css.js';
|
||||
import { bundleJS, collectJSImports } from './build/bundle/js.js';
|
||||
import { buildCollectionPage, buildStaticPage, getPageType } from './build/page.js';
|
||||
import { buildStaticPage, getStaticPathsForPage } from './build/page.js';
|
||||
import { generateSitemap } from './build/sitemap.js';
|
||||
import { logURLStats, collectBundleStats, mapBundleStatsToURLStats } from './build/stats.js';
|
||||
import { collectBundleStats, logURLStats, mapBundleStatsToURLStats } from './build/stats.js';
|
||||
import { getDistPath, stopTimer } from './build/util.js';
|
||||
import type { LogOptions } from './logger';
|
||||
import { debug, defaultLogDestination, defaultLogLevel, error, info, warn } from './logger.js';
|
||||
import { createRuntime } from './runtime.js';
|
||||
|
||||
|
@ -25,13 +24,6 @@ const defaultLogging: LogOptions = {
|
|||
dest: defaultLogDestination,
|
||||
};
|
||||
|
||||
/** Return contents of src/pages */
|
||||
async function allPages(root: URL): Promise<URL[]> {
|
||||
const cwd = fileURLToPath(root);
|
||||
const files = await glob('**/*.{astro,md}', { cwd, filesOnly: true });
|
||||
return files.map((f) => new URL(f, root));
|
||||
}
|
||||
|
||||
/** Is this URL remote or embedded? */
|
||||
function isRemoteOrEmbedded(url: string) {
|
||||
return url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//') || url.startsWith('data:');
|
||||
|
@ -39,7 +31,7 @@ function isRemoteOrEmbedded(url: string) {
|
|||
|
||||
/** The primary build action */
|
||||
export async function build(astroConfig: AstroConfig, logging: LogOptions = defaultLogging): Promise<0 | 1> {
|
||||
const { projectRoot, pages: pagesRoot } = astroConfig;
|
||||
const { projectRoot } = astroConfig;
|
||||
const dist = new URL(astroConfig.dist + '/', projectRoot);
|
||||
const buildState: BuildOutput = {};
|
||||
const depTree: BundleMap = {};
|
||||
|
@ -69,22 +61,48 @@ export async function build(astroConfig: AstroConfig, logging: LogOptions = defa
|
|||
* Source files are built in parallel and stored in memory. Most assets are also gathered here, too.
|
||||
*/
|
||||
timer.build = performance.now();
|
||||
const pages = await allPages(pagesRoot);
|
||||
info(logging, 'build', yellow('! building pages...'));
|
||||
try {
|
||||
await Promise.all(
|
||||
pages.map((filepath) => {
|
||||
const buildPage = getPageType(filepath) === 'collection' ? buildCollectionPage : buildStaticPage;
|
||||
return buildPage({
|
||||
const allRoutesAndPaths = await Promise.all(
|
||||
runtimeConfig.manifest.routes.map(async (route): Promise<[RouteData, string[]]> => {
|
||||
if (route.path) {
|
||||
return [route, [route.path]];
|
||||
} else {
|
||||
const result = await getStaticPathsForPage({
|
||||
astroConfig,
|
||||
buildState,
|
||||
filepath,
|
||||
logging,
|
||||
mode,
|
||||
route,
|
||||
snowpackRuntime,
|
||||
astroRuntime: runtime,
|
||||
site: astroConfig.buildOptions.site,
|
||||
logging,
|
||||
});
|
||||
if (result.rss.xml) {
|
||||
if (buildState[result.rss.url]) {
|
||||
throw new Error(`[getStaticPaths] RSS feed ${result.rss.url} already exists.\nUse \`rss(data, {url: '...'})\` to choose a unique, custom URL. (${route.component})`);
|
||||
}
|
||||
buildState[result.rss.url] = {
|
||||
srcPath: new URL(result.rss.url, projectRoot),
|
||||
contents: result.rss.xml,
|
||||
contentType: 'text/xml',
|
||||
encoding: 'utf8',
|
||||
};
|
||||
}
|
||||
return [route, result.paths];
|
||||
}
|
||||
})
|
||||
);
|
||||
try {
|
||||
// TODO: 2x Promise.all? Might be hard to debug + overwhelm resources.
|
||||
await Promise.all(
|
||||
allRoutesAndPaths.map(async ([route, paths]: [RouteData, string[]]) => {
|
||||
await Promise.all(
|
||||
paths.map((p) =>
|
||||
buildStaticPage({
|
||||
astroConfig,
|
||||
buildState,
|
||||
route,
|
||||
path: p,
|
||||
astroRuntime: runtime,
|
||||
})
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
|
@ -95,7 +113,6 @@ export async function build(astroConfig: AstroConfig, logging: LogOptions = defa
|
|||
.split('\n');
|
||||
stack.splice(1, 0, ` at file://${e.filename}`);
|
||||
stack = stack.join('\n');
|
||||
|
||||
error(
|
||||
logging,
|
||||
'build',
|
||||
|
|
|
@ -1,115 +1,61 @@
|
|||
import path from 'path';
|
||||
import { compile as compilePathToRegexp } from 'path-to-regexp';
|
||||
import _path from 'path';
|
||||
import type { ServerRuntime as SnowpackServerRuntime } from 'snowpack';
|
||||
import { fileURLToPath } from 'url';
|
||||
import type { AstroConfig, BuildOutput, CreateCollectionResult, RuntimeMode } from '../@types/astro';
|
||||
import type { LogOptions } from '../logger';
|
||||
import type { AstroRuntime, LoadResult } from '../runtime';
|
||||
import { validateCollectionModule, validateCollectionResult } from '../util.js';
|
||||
import { generateRSS } from './rss.js';
|
||||
import type { AstroConfig, BuildOutput, GetStaticPathsResult, RouteData } from '../@types/astro';
|
||||
import { LogOptions } from '../logger';
|
||||
import type { AstroRuntime } from '../runtime.js';
|
||||
import { convertMatchToLocation, validateGetStaticPathsModule, validateGetStaticPathsResult } from '../util.js';
|
||||
import { generatePaginateFunction } from './paginate.js';
|
||||
import { generateRssFunction } from './rss.js';
|
||||
|
||||
interface PageBuildOptions {
|
||||
astroConfig: AstroConfig;
|
||||
buildState: BuildOutput;
|
||||
logging: LogOptions;
|
||||
filepath: URL;
|
||||
mode: RuntimeMode;
|
||||
snowpackRuntime: SnowpackServerRuntime;
|
||||
path: string;
|
||||
route: RouteData;
|
||||
astroRuntime: AstroRuntime;
|
||||
site?: string;
|
||||
}
|
||||
|
||||
/** Collection utility */
|
||||
export function getPageType(filepath: URL): 'collection' | 'static' {
|
||||
if (/\$[^.]+.astro$/.test(filepath.pathname)) return 'collection';
|
||||
return 'static';
|
||||
}
|
||||
|
||||
/** Build collection */
|
||||
export async function buildCollectionPage({ astroConfig, filepath, astroRuntime, snowpackRuntime, site, buildState }: PageBuildOptions): Promise<void> {
|
||||
const { pages: pagesRoot } = astroConfig;
|
||||
const srcURL = filepath.pathname.replace(pagesRoot.pathname, '');
|
||||
const pagesPath = astroConfig.pages.pathname.replace(astroConfig.projectRoot.pathname, '');
|
||||
const snowpackURL = `/_astro/${pagesPath}${srcURL}.js`;
|
||||
const mod = await snowpackRuntime.importModule(snowpackURL);
|
||||
validateCollectionModule(mod, filepath.pathname);
|
||||
const pageCollection: CreateCollectionResult = await mod.exports.createCollection();
|
||||
validateCollectionResult(pageCollection, filepath.pathname);
|
||||
let { route, paths: getPaths = () => [{ params: {} }] } = pageCollection;
|
||||
const toPath = compilePathToRegexp(route);
|
||||
const allPaths = getPaths();
|
||||
const allRoutes: string[] = allPaths.map((p) => toPath(p.params));
|
||||
|
||||
// Keep track of all files that have been built, to prevent duplicates.
|
||||
const builtURLs = new Set<string>();
|
||||
|
||||
/** Recursively build collection URLs */
|
||||
async function loadPage(url: string): Promise<{ url: string; result: LoadResult } | undefined> {
|
||||
if (builtURLs.has(url)) {
|
||||
return;
|
||||
}
|
||||
builtURLs.add(url);
|
||||
const result = await astroRuntime.load(url);
|
||||
if (result.statusCode === 200) {
|
||||
const outPath = path.posix.join(url, '/index.html');
|
||||
buildState[outPath] = {
|
||||
srcPath: filepath,
|
||||
contents: result.contents,
|
||||
contentType: 'text/html',
|
||||
encoding: 'utf8',
|
||||
};
|
||||
}
|
||||
return { url, result };
|
||||
}
|
||||
|
||||
const loadResults = await Promise.all(allRoutes.map(loadPage));
|
||||
for (const loadResult of loadResults) {
|
||||
if (!loadResult) {
|
||||
continue;
|
||||
}
|
||||
const result = loadResult.result;
|
||||
if (result.statusCode >= 500) {
|
||||
throw new Error((result as any).error);
|
||||
}
|
||||
if (result.statusCode === 200) {
|
||||
const { collectionInfo } = result;
|
||||
if (collectionInfo?.rss) {
|
||||
if (!site) {
|
||||
throw new Error(`[${srcURL}] createCollection() tried to generate RSS but "buildOptions.site" missing in astro.config.mjs`);
|
||||
}
|
||||
const feedURL = '/feed' + loadResult.url + '.xml';
|
||||
const rss = generateRSS({ ...(collectionInfo.rss as any), site }, { srcFile: srcURL, feedURL });
|
||||
buildState[feedURL] = {
|
||||
srcPath: filepath,
|
||||
contents: rss,
|
||||
contentType: 'application/rss+xml',
|
||||
encoding: 'utf8',
|
||||
};
|
||||
}
|
||||
if (collectionInfo?.additionalURLs) {
|
||||
await Promise.all([...collectionInfo.additionalURLs].map(loadPage));
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Build dynamic page */
|
||||
export async function getStaticPathsForPage({
|
||||
astroConfig,
|
||||
snowpackRuntime,
|
||||
route,
|
||||
logging,
|
||||
}: {
|
||||
astroConfig: AstroConfig;
|
||||
route: RouteData;
|
||||
snowpackRuntime: SnowpackServerRuntime;
|
||||
logging: LogOptions;
|
||||
}): Promise<{ paths: string[]; rss: any }> {
|
||||
const location = convertMatchToLocation(route, astroConfig);
|
||||
const mod = await snowpackRuntime.importModule(location.snowpackURL);
|
||||
validateGetStaticPathsModule(mod);
|
||||
const [rssFunction, rssResult] = generateRssFunction(astroConfig.buildOptions.site, route);
|
||||
const staticPaths: GetStaticPathsResult = await mod.exports.getStaticPaths({
|
||||
paginate: generatePaginateFunction(route),
|
||||
rss: rssFunction,
|
||||
}).flat();
|
||||
validateGetStaticPathsResult(staticPaths, logging);
|
||||
return {
|
||||
paths: staticPaths.map((staticPath) => staticPath.params && route.generate(staticPath.params)).filter(Boolean),
|
||||
rss: rssResult,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build static page */
|
||||
export async function buildStaticPage({ astroConfig, buildState, filepath, astroRuntime }: PageBuildOptions): Promise<void> {
|
||||
const { pages: pagesRoot } = astroConfig;
|
||||
const url = filepath.pathname
|
||||
.replace(pagesRoot.pathname, '/')
|
||||
.replace(/.(astro|md)$/, '')
|
||||
.replace(/\/index$/, '/');
|
||||
const result = await astroRuntime.load(url);
|
||||
export async function buildStaticPage({ astroConfig, buildState, path, route, astroRuntime }: PageBuildOptions): Promise<void> {
|
||||
const location = convertMatchToLocation(route, astroConfig);
|
||||
const result = await astroRuntime.load(path);
|
||||
if (result.statusCode !== 200) {
|
||||
let err = (result as any).error;
|
||||
if (!(err instanceof Error)) err = new Error(err);
|
||||
err.filename = fileURLToPath(filepath);
|
||||
err.filename = fileURLToPath(location.fileURL);
|
||||
throw err;
|
||||
}
|
||||
const outFile = path.posix.join(url, '/index.html');
|
||||
const outFile = _path.posix.join(path, '/index.html');
|
||||
buildState[outFile] = {
|
||||
srcPath: filepath,
|
||||
srcPath: location.fileURL,
|
||||
contents: result.contents,
|
||||
contentType: 'text/html',
|
||||
encoding: 'utf8',
|
||||
|
|
64
packages/astro/src/build/paginate.ts
Normal file
64
packages/astro/src/build/paginate.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
import { GetStaticPathsResult, Params, Props, RouteData } from '../@types/astro';
|
||||
|
||||
// return filters.map((filter) => {
|
||||
// const filteredRecipes = allRecipes.filter((recipe) =>
|
||||
// filterKeys.some((key) => recipe[key] === filter)
|
||||
// );
|
||||
// return paginate(filteredRecipes, {
|
||||
// params: { slug: slugify(filter) },
|
||||
// props: { filter },
|
||||
// });
|
||||
// });
|
||||
|
||||
|
||||
export function generatePaginateFunction(routeMatch: RouteData) {
|
||||
return function paginateUtility(data: any[], args: { pageSize?: number, params?: Params, props?: Props } = {}) {
|
||||
let { pageSize: _pageSize, params: _params, props: _props } = args;
|
||||
const pageSize = _pageSize || 10;
|
||||
const paramName = 'page';
|
||||
const additoonalParams = _params || {};
|
||||
const additoonalProps = _props || {};
|
||||
let includesFirstPageNumber: boolean;
|
||||
if (routeMatch.params.includes(`...${paramName}`)) {
|
||||
includesFirstPageNumber = false;
|
||||
} else if (routeMatch.params.includes(`${paramName}`)) {
|
||||
includesFirstPageNumber = true;
|
||||
} else {
|
||||
throw new Error(
|
||||
`[paginate()] page number param \`${paramName}\` not found in your filepath.\nRename your file to \`[...page].astro\` or customize the param name via the \`paginate([], {param: '...'}\` option.`
|
||||
);
|
||||
}
|
||||
const lastPage = Math.max(1, Math.ceil(data.length / pageSize));
|
||||
|
||||
const result: GetStaticPathsResult = [...Array(lastPage).keys()].map((num) => {
|
||||
const pageNum = num + 1;
|
||||
const start = pageSize === Infinity ? 0 : (pageNum - 1) * pageSize; // currentPage is 1-indexed
|
||||
const end = Math.min(start + pageSize, data.length);
|
||||
const params = {
|
||||
...additoonalParams,
|
||||
[paramName]: includesFirstPageNumber || pageNum > 1 ? String(pageNum) : undefined,
|
||||
};
|
||||
return {
|
||||
params,
|
||||
props: {
|
||||
...additoonalProps,
|
||||
page: {
|
||||
data: data.slice(start, end),
|
||||
start,
|
||||
end: end - 1,
|
||||
size: pageSize,
|
||||
total: data.length,
|
||||
currentPage: pageNum,
|
||||
lastPage: lastPage,
|
||||
url: {
|
||||
current: routeMatch.generate({ ...params }),
|
||||
next: pageNum === lastPage ? undefined : routeMatch.generate({ ...params, page: String(pageNum + 1) }),
|
||||
prev: pageNum === 1 ? undefined : routeMatch.generate({ ...params, page: !includesFirstPageNumber && pageNum - 1 === 1 ? undefined : String(pageNum - 1) }),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
return result;
|
||||
};
|
||||
}
|
|
@ -1,31 +1,29 @@
|
|||
import type { CollectionRSS } from '../@types/astro';
|
||||
import type { CollectionRSS, RouteData } from '../@types/astro';
|
||||
import parser from 'fast-xml-parser';
|
||||
import { canonicalURL } from './util.js';
|
||||
|
||||
/** Validates createCollection.rss */
|
||||
export function validateRSS(rss: CollectionRSS, srcFile: string): void {
|
||||
if (!rss.title) throw new Error(`[${srcFile}] rss.title required`);
|
||||
if (!rss.description) throw new Error(`[${srcFile}] rss.description required`);
|
||||
if (typeof rss.item !== 'function') throw new Error(`[${srcFile}] rss.item() function required`);
|
||||
/** Validates getStaticPaths.rss */
|
||||
export function validateRSS(args: GenerateRSSArgs): void {
|
||||
const { rssData, srcFile } = args;
|
||||
if (!rssData.title) throw new Error(`[${srcFile}] rss.title required`);
|
||||
if (!rssData.description) throw new Error(`[${srcFile}] rss.description required`);
|
||||
if ((rssData as any).item) throw new Error(`[${srcFile}] \`item: Function\` should be \`items: Item[]\``);
|
||||
if (!Array.isArray(rssData.items)) throw new Error(`[${srcFile}] rss.items should be an array of items`);
|
||||
}
|
||||
|
||||
type RSSInput<T> = { data: T[]; site: string } & CollectionRSS<T>;
|
||||
interface RSSOptions {
|
||||
srcFile: string;
|
||||
feedURL: string;
|
||||
}
|
||||
type GenerateRSSArgs = { site: string; rssData: CollectionRSS; srcFile: string; feedURL: string };
|
||||
|
||||
/** Generate RSS 2.0 feed */
|
||||
export function generateRSS<T>(input: RSSInput<T>, options: RSSOptions): string {
|
||||
const { srcFile, feedURL } = options;
|
||||
|
||||
validateRSS(input as any, srcFile);
|
||||
export function generateRSS(args: GenerateRSSArgs): string {
|
||||
validateRSS(args);
|
||||
const { srcFile, feedURL, rssData, site } = args;
|
||||
if ((rssData as any).item) throw new Error(`[${srcFile}] rss() \`item()\` function was deprecated, and is now \`items: object[]\`.`);
|
||||
|
||||
let xml = `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"`;
|
||||
|
||||
// xmlns
|
||||
if (input.xmlns) {
|
||||
for (const [k, v] of Object.entries(input.xmlns)) {
|
||||
if (rssData.xmlns) {
|
||||
for (const [k, v] of Object.entries(rssData.xmlns)) {
|
||||
xml += ` xmlns:${k}="${v}"`;
|
||||
}
|
||||
}
|
||||
|
@ -33,22 +31,19 @@ export function generateRSS<T>(input: RSSInput<T>, options: RSSOptions): string
|
|||
xml += `<channel>`;
|
||||
|
||||
// title, description, customData
|
||||
xml += `<title><![CDATA[${input.title}]]></title>`;
|
||||
xml += `<description><![CDATA[${input.description}]]></description>`;
|
||||
xml += `<link>${canonicalURL(feedURL, input.site).href}</link>`;
|
||||
if (typeof input.customData === 'string') xml += input.customData;
|
||||
|
||||
xml += `<title><![CDATA[${rssData.title}]]></title>`;
|
||||
xml += `<description><![CDATA[${rssData.description}]]></description>`;
|
||||
xml += `<link>${canonicalURL(feedURL, site).href}</link>`;
|
||||
if (typeof rssData.customData === 'string') xml += rssData.customData;
|
||||
// items
|
||||
if (!Array.isArray(input.data) || !input.data.length) throw new Error(`[${srcFile}] data() returned no items. Can’t generate RSS feed.`);
|
||||
for (const item of input.data) {
|
||||
for (const result of rssData.items) {
|
||||
xml += `<item>`;
|
||||
const result = input.item(item);
|
||||
// validate
|
||||
if (typeof result !== 'object') throw new Error(`[${srcFile}] rss.item() expected to return an object, returned ${typeof result}.`);
|
||||
if (!result.title) throw new Error(`[${srcFile}] rss.item() returned object but required "title" is missing.`);
|
||||
if (!result.link) throw new Error(`[${srcFile}] rss.item() returned object but required "link" is missing.`);
|
||||
if (typeof result !== 'object') throw new Error(`[${srcFile}] rss.items expected an object. got: "${JSON.stringify(result)}"`);
|
||||
if (!result.title) throw new Error(`[${srcFile}] rss.items required "title" property is missing. got: "${JSON.stringify(result)}"`);
|
||||
if (!result.link) throw new Error(`[${srcFile}] rss.items required "link" property is missing. got: "${JSON.stringify(result)}"`);
|
||||
xml += `<title><![CDATA[${result.title}]]></title>`;
|
||||
xml += `<link>${canonicalURL(result.link, input.site).href}</link>`;
|
||||
xml += `<link>${canonicalURL(result.link, site).href}</link>`;
|
||||
if (result.description) xml += `<description><![CDATA[${result.description}]]></description>`;
|
||||
if (result.pubDate) {
|
||||
// note: this should be a Date, but if user provided a string or number, we can work with that, too.
|
||||
|
@ -74,3 +69,17 @@ export function generateRSS<T>(input: RSSInput<T>, options: RSSOptions): string
|
|||
|
||||
return xml;
|
||||
}
|
||||
|
||||
export function generateRssFunction(site: string | undefined, routeMatch: RouteData): [(args: any) => void, { url?: string; xml?: string }] {
|
||||
let result: { url?: string; xml?: string } = {};
|
||||
function rssUtility(args: any) {
|
||||
if (!site) {
|
||||
throw new Error(`[${routeMatch.component}] rss() tried to generate RSS but "buildOptions.site" missing in astro.config.mjs`);
|
||||
}
|
||||
const { dest, ...rssData } = args;
|
||||
const feedURL = dest || '/rss.xml';
|
||||
result.url = feedURL;
|
||||
result.xml = generateRSS({ rssData, site, srcFile: routeMatch.component, feedURL });
|
||||
}
|
||||
return [rssUtility, result];
|
||||
}
|
||||
|
|
|
@ -183,7 +183,7 @@ interface GetComponentWrapperOptions {
|
|||
const PlainExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']);
|
||||
/** Generate Astro-friendly component import */
|
||||
function getComponentWrapper(_name: string, hydration: HydrationAttributes, { url, importSpecifier }: ComponentInfo, opts: GetComponentWrapperOptions) {
|
||||
const { astroConfig, filename } = opts;
|
||||
const { astroConfig, filename, compileOptions } = opts;
|
||||
|
||||
let name = _name;
|
||||
let method = hydration.method;
|
||||
|
@ -194,7 +194,6 @@ function getComponentWrapper(_name: string, hydration: HydrationAttributes, { ur
|
|||
name = legacyName;
|
||||
method = legacyMethod as HydrationAttributes['method'];
|
||||
|
||||
const { compileOptions, filename } = opts;
|
||||
const shortname = path.posix.relative(compileOptions.astroConfig.projectRoot.pathname, filename);
|
||||
warn(compileOptions.logging, shortname, yellow(`Deprecation warning: Partial hydration now uses a directive syntax. Please update to "<${name} client:${method} />"`));
|
||||
}
|
||||
|
@ -308,7 +307,7 @@ function transpileExpressionSafe(
|
|||
|
||||
interface CompileResult {
|
||||
script: string;
|
||||
createCollection?: string;
|
||||
getStaticPaths?: string;
|
||||
}
|
||||
|
||||
interface CodegenState {
|
||||
|
@ -335,7 +334,7 @@ function compileModule(ast: Ast, module: Script, state: CodegenState, compileOpt
|
|||
|
||||
let script = '';
|
||||
let propsStatement = '';
|
||||
let createCollection = ''; // function for executing collection
|
||||
let getStaticPaths = ''; // function for executing collection
|
||||
|
||||
if (module) {
|
||||
const parseOptions: babelParser.ParserOptions = {
|
||||
|
@ -408,9 +407,9 @@ function compileModule(ast: Ast, module: Script, state: CodegenState, compileOpt
|
|||
componentProps.push(declaration);
|
||||
}
|
||||
} else if (node.declaration.type === 'FunctionDeclaration') {
|
||||
// case 2: createCollection (export async function)
|
||||
if (!node.declaration.id || node.declaration.id.name !== 'createCollection') break;
|
||||
createCollection = babelGenerator(node).code;
|
||||
// case 2: getStaticPaths (export async function)
|
||||
if (!node.declaration.id || node.declaration.id.name !== 'getStaticPaths') break;
|
||||
getStaticPaths = babelGenerator(node).code;
|
||||
}
|
||||
|
||||
body.splice(i, 1);
|
||||
|
@ -490,7 +489,7 @@ const { ${props.join(', ')} } = Astro.props;\n`)
|
|||
|
||||
return {
|
||||
script,
|
||||
createCollection: createCollection || undefined,
|
||||
getStaticPaths: getStaticPaths || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -881,7 +880,7 @@ export async function codegen(ast: Ast, { compileOptions, filename, fileID }: Co
|
|||
customElementCandidates: new Map(),
|
||||
};
|
||||
|
||||
const { script, createCollection } = compileModule(ast, ast.module, state, compileOptions);
|
||||
const { script, getStaticPaths } = compileModule(ast, ast.module, state, compileOptions);
|
||||
|
||||
(ast.css || []).map((css) => compileCss(css, state));
|
||||
|
||||
|
@ -893,7 +892,7 @@ export async function codegen(ast: Ast, { compileOptions, filename, fileID }: Co
|
|||
exports: Array.from(state.exportStatements),
|
||||
html,
|
||||
css: state.css.length ? state.css.join('\n\n') : undefined,
|
||||
createCollection,
|
||||
getStaticPaths,
|
||||
hasCustomElements: Boolean(ast.meta.features & FEATURE_CUSTOM_ELEMENT),
|
||||
customElementCandidates: state.customElementCandidates,
|
||||
};
|
||||
|
|
|
@ -138,7 +138,7 @@ const __astro_element_registry = new AstroElementRegistry({
|
|||
: ''
|
||||
}
|
||||
|
||||
${result.createCollection || ''}
|
||||
${result.getStaticPaths || ''}
|
||||
|
||||
// \`__render()\`: Render the contents of the Astro module.
|
||||
import { h, Fragment } from 'astro/dist/internal/h.js';
|
||||
|
|
|
@ -40,18 +40,11 @@ export default async function dev(astroConfig: AstroConfig) {
|
|||
res.end();
|
||||
break;
|
||||
}
|
||||
case 301:
|
||||
case 302: {
|
||||
res.statusCode = result.statusCode;
|
||||
res.setHeader('Location', result.location);
|
||||
res.end();
|
||||
break;
|
||||
}
|
||||
case 404: {
|
||||
const { hostname, port } = astroConfig.devOptions;
|
||||
const fullurl = new URL(req.url || '/', astroConfig.buildOptions.site || `http://${hostname}:${port}`);
|
||||
const reqPath = decodeURI(fullurl.pathname);
|
||||
error(logging, 'static', 'Not found', reqPath);
|
||||
error(logging, 'access', 'Not Found:', reqPath);
|
||||
res.statusCode = 404;
|
||||
|
||||
const fourOhFourResult = await runtime.load('/404');
|
||||
|
|
|
@ -7,7 +7,7 @@ export const __astro_slot = ({ name = 'default' }: { name: string }, _children:
|
|||
if (name === 'default' && typeof _children === 'string') {
|
||||
return _children ? _children : fallback;
|
||||
}
|
||||
if (!_children.$slots) {
|
||||
if (!_children || !_children.$slots) {
|
||||
throw new Error(`__astro_slot encountered an unexpected child:\n${JSON.stringify(_children)}`);
|
||||
}
|
||||
const children = _children.$slots[name];
|
||||
|
|
|
@ -11,12 +11,10 @@ export function fetchContent(importMetaGlobResult: Record<string, any>, url: str
|
|||
return;
|
||||
}
|
||||
const urlSpec = new URL(spec, url).pathname.replace(/[\\/\\\\]/, '/');
|
||||
if (!urlSpec.includes('/pages/')) {
|
||||
return mod.__content;
|
||||
}
|
||||
return {
|
||||
...mod.__content,
|
||||
url: urlSpec.replace(/^.*\/pages\//, '/').replace(/\.md$/, ''),
|
||||
url: urlSpec.includes('/pages/') && urlSpec.replace(/^.*\/pages\//, '/').replace(/\.md$/, ''),
|
||||
file: new URL(spec, url),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
|
|
@ -43,8 +43,8 @@ export type LoggerLevel = 'debug' | 'info' | 'warn' | 'error' | 'silent'; // sam
|
|||
export type LoggerEvent = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
export interface LogOptions {
|
||||
dest: LogWritable<LogMessage>;
|
||||
level: LoggerLevel;
|
||||
dest?: LogWritable<LogMessage>;
|
||||
level?: LoggerLevel;
|
||||
}
|
||||
|
||||
export const defaultLogOptions: LogOptions = {
|
||||
|
@ -68,7 +68,9 @@ const levels: Record<LoggerLevel, number> = {
|
|||
};
|
||||
|
||||
/** Full logging API */
|
||||
export function log(opts: LogOptions = defaultLogOptions, level: LoggerLevel, type: string | null, ...args: Array<any>) {
|
||||
export function log(opts: LogOptions = {}, level: LoggerLevel, type: string | null, ...args: Array<any>) {
|
||||
const logLevel = opts.level ?? defaultLogOptions.level!;
|
||||
const dest = opts.dest ?? defaultLogOptions.dest!;
|
||||
const event: LogMessage = {
|
||||
type,
|
||||
level,
|
||||
|
@ -77,11 +79,11 @@ export function log(opts: LogOptions = defaultLogOptions, level: LoggerLevel, ty
|
|||
};
|
||||
|
||||
// test if this level is enabled or not
|
||||
if (levels[opts.level] > levels[level]) {
|
||||
if (levels[logLevel] > levels[level]) {
|
||||
return; // do nothing
|
||||
}
|
||||
|
||||
opts.dest.write(event);
|
||||
dest.write(event);
|
||||
}
|
||||
|
||||
/** Emit a message only shown in debug mode */
|
||||
|
|
274
packages/astro/src/manifest/create.ts
Normal file
274
packages/astro/src/manifest/create.ts
Normal file
|
@ -0,0 +1,274 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { compile } from 'path-to-regexp';
|
||||
import slash from 'slash';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { AstroConfig, ManifestData, RouteData } from '../@types/astro';
|
||||
interface Part {
|
||||
content: string;
|
||||
dynamic: boolean;
|
||||
spread: boolean;
|
||||
}
|
||||
|
||||
interface Item {
|
||||
basename: string;
|
||||
ext: string;
|
||||
parts: Part[];
|
||||
file: string;
|
||||
isDir: boolean;
|
||||
isIndex: boolean;
|
||||
isPage: boolean;
|
||||
routeSuffix: string;
|
||||
}
|
||||
|
||||
// Needed?
|
||||
// const specials = new Set([]);
|
||||
|
||||
export function createManifest({ config, cwd }: { config: AstroConfig; cwd?: string }): ManifestData {
|
||||
const components: string[] = [];
|
||||
const routes: RouteData[] = [];
|
||||
|
||||
function walk(dir: string, parentSegments: Part[][], parentParams: string[]) {
|
||||
let items: Item[] = [];
|
||||
fs.readdirSync(dir).forEach((basename) => {
|
||||
const resolved = path.join(dir, basename);
|
||||
const file = slash(path.relative(cwd || fileURLToPath(config.projectRoot), resolved));
|
||||
const isDir = fs.statSync(resolved).isDirectory();
|
||||
|
||||
const ext = path.extname(basename);
|
||||
const name = ext ? basename.slice(0, -ext.length) : basename;
|
||||
|
||||
if (name[0] === '_') {
|
||||
return;
|
||||
}
|
||||
if (basename[0] === '.' && basename !== '.well-known') {
|
||||
return;
|
||||
}
|
||||
// filter out "foo.astro_tmp" files, etc
|
||||
if (!isDir && !/^(\.[a-z0-9]+)+$/i.test(ext)) {
|
||||
return;
|
||||
}
|
||||
const segment = isDir ? basename : name;
|
||||
if (/^\$/.test(segment)) {
|
||||
throw new Error(`Invalid route ${file} — Astro's Collections API has been replaced by dynamic route params.`);
|
||||
}
|
||||
if (/\]\[/.test(segment)) {
|
||||
throw new Error(`Invalid route ${file} — parameters must be separated`);
|
||||
}
|
||||
if (countOccurrences('[', segment) !== countOccurrences(']', segment)) {
|
||||
throw new Error(`Invalid route ${file} — brackets are unbalanced`);
|
||||
}
|
||||
if (/.+\[\.\.\.[^\]]+\]/.test(segment) || /\[\.\.\.[^\]]+\].+/.test(segment)) {
|
||||
throw new Error(`Invalid route ${file} — rest parameter must be a standalone segment`);
|
||||
}
|
||||
|
||||
const parts = getParts(segment, file);
|
||||
const isIndex = isDir ? false : basename.startsWith('index.');
|
||||
const routeSuffix = basename.slice(basename.indexOf('.'), -ext.length);
|
||||
|
||||
items.push({
|
||||
basename,
|
||||
ext,
|
||||
parts,
|
||||
file: slash(file),
|
||||
isDir,
|
||||
isIndex,
|
||||
isPage: true,
|
||||
routeSuffix,
|
||||
});
|
||||
});
|
||||
items = items.sort(comparator);
|
||||
|
||||
items.forEach((item) => {
|
||||
const segments = parentSegments.slice();
|
||||
|
||||
if (item.isIndex) {
|
||||
if (item.routeSuffix) {
|
||||
if (segments.length > 0) {
|
||||
const lastSegment = segments[segments.length - 1].slice();
|
||||
const lastPart = lastSegment[lastSegment.length - 1];
|
||||
|
||||
if (lastPart.dynamic) {
|
||||
lastSegment.push({
|
||||
dynamic: false,
|
||||
spread: false,
|
||||
content: item.routeSuffix,
|
||||
});
|
||||
} else {
|
||||
lastSegment[lastSegment.length - 1] = {
|
||||
dynamic: false,
|
||||
spread: false,
|
||||
content: `${lastPart.content}${item.routeSuffix}`,
|
||||
};
|
||||
}
|
||||
|
||||
segments[segments.length - 1] = lastSegment;
|
||||
} else {
|
||||
segments.push(item.parts);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
segments.push(item.parts);
|
||||
}
|
||||
|
||||
const params = parentParams.slice();
|
||||
params.push(...item.parts.filter((p) => p.dynamic).map((p) => p.content));
|
||||
|
||||
if (item.isDir) {
|
||||
walk(path.join(dir, item.basename), segments, params);
|
||||
} else {
|
||||
components.push(item.file);
|
||||
const component = item.file;
|
||||
const pattern = getPattern(segments, true);
|
||||
const generate = getGenerator(segments, false);
|
||||
const pathname = segments.every((segment) => segment.length === 1 && !segment[0].dynamic) ? `/${segments.map((segment) => segment[0].content).join('/')}` : null;
|
||||
|
||||
routes.push({
|
||||
type: 'page',
|
||||
pattern,
|
||||
params,
|
||||
component,
|
||||
generate,
|
||||
path: pathname,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
walk(fileURLToPath(config.pages), [], []);
|
||||
|
||||
return {
|
||||
routes,
|
||||
};
|
||||
}
|
||||
|
||||
function countOccurrences(needle: string, haystack: string) {
|
||||
let count = 0;
|
||||
for (let i = 0; i < haystack.length; i += 1) {
|
||||
if (haystack[i] === needle) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function isSpread(path: string) {
|
||||
const spreadPattern = /\[\.{3}/g;
|
||||
return spreadPattern.test(path);
|
||||
}
|
||||
|
||||
function comparator(a: Item, b: Item) {
|
||||
if (a.isIndex !== b.isIndex) {
|
||||
if (a.isIndex) return isSpread(a.file) ? 1 : -1;
|
||||
|
||||
return isSpread(b.file) ? -1 : 1;
|
||||
}
|
||||
|
||||
const max = Math.max(a.parts.length, b.parts.length);
|
||||
|
||||
for (let i = 0; i < max; i += 1) {
|
||||
const aSubPart = a.parts[i];
|
||||
const bSubPart = b.parts[i];
|
||||
|
||||
if (!aSubPart) return 1; // b is more specific, so goes first
|
||||
if (!bSubPart) return -1;
|
||||
|
||||
// if spread && index, order later
|
||||
if (aSubPart.spread && bSubPart.spread) {
|
||||
return a.isIndex ? 1 : -1;
|
||||
}
|
||||
|
||||
// If one is ...spread order it later
|
||||
if (aSubPart.spread !== bSubPart.spread) return aSubPart.spread ? 1 : -1;
|
||||
|
||||
if (aSubPart.dynamic !== bSubPart.dynamic) {
|
||||
return aSubPart.dynamic ? 1 : -1;
|
||||
}
|
||||
|
||||
if (!aSubPart.dynamic && aSubPart.content !== bSubPart.content) {
|
||||
return bSubPart.content.length - aSubPart.content.length || (aSubPart.content < bSubPart.content ? -1 : 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (a.isPage !== b.isPage) {
|
||||
return a.isPage ? 1 : -1;
|
||||
}
|
||||
|
||||
// otherwise sort alphabetically
|
||||
return a.file < b.file ? -1 : 1;
|
||||
}
|
||||
|
||||
function getParts(part: string, file: string) {
|
||||
const result: Part[] = [];
|
||||
part.split(/\[(.+?\(.+?\)|.+?)\]/).map((str, i) => {
|
||||
if (!str) return;
|
||||
const dynamic = i % 2 === 1;
|
||||
|
||||
const [, content] = dynamic ? /([^(]+)$/.exec(str) || [null, null] : [null, str];
|
||||
|
||||
if (!content || (dynamic && !/^(\.\.\.)?[a-zA-Z0-9_$]+$/.test(content))) {
|
||||
throw new Error(`Invalid route ${file} — parameter name must match /^[a-zA-Z0-9_$]+$/`);
|
||||
}
|
||||
|
||||
result.push({
|
||||
content,
|
||||
dynamic,
|
||||
spread: dynamic && /^\.{3}.+$/.test(content),
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getPattern(segments: Part[][], addTrailingSlash: boolean) {
|
||||
const pathname = segments
|
||||
.map((segment) => {
|
||||
return segment[0].spread
|
||||
? '(?:\\/(.*?))?'
|
||||
: '\\/' +
|
||||
segment
|
||||
.map((part) => {
|
||||
if (part)
|
||||
return part.dynamic
|
||||
? '([^/]+?)'
|
||||
: part.content
|
||||
.normalize()
|
||||
.replace(/\?/g, '%3F')
|
||||
.replace(/#/g, '%23')
|
||||
.replace(/%5B/g, '[')
|
||||
.replace(/%5D/g, ']')
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
})
|
||||
.join('');
|
||||
})
|
||||
.join('');
|
||||
|
||||
const trailing = addTrailingSlash && segments.length ? '\\/?$' : '$';
|
||||
return new RegExp(`^${pathname || '\\/'}${trailing}`);
|
||||
}
|
||||
|
||||
function getGenerator(segments: Part[][], addTrailingSlash: boolean) {
|
||||
const template = segments
|
||||
.map((segment) => {
|
||||
return segment[0].spread
|
||||
? `/:${segment[0].content.substr(3)}(.*)?`
|
||||
: '/' +
|
||||
segment
|
||||
.map((part) => {
|
||||
if (part)
|
||||
return part.dynamic
|
||||
? `:${part.content}`
|
||||
: part.content
|
||||
.normalize()
|
||||
.replace(/\?/g, '%3F')
|
||||
.replace(/#/g, '%23')
|
||||
.replace(/%5B/g, '[')
|
||||
.replace(/%5D/g, ']')
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
})
|
||||
.join('');
|
||||
})
|
||||
.join('');
|
||||
|
||||
const trailing = addTrailingSlash && segments.length ? '/' : '';
|
||||
const toPath = compile(template + trailing);
|
||||
return toPath;
|
||||
}
|
|
@ -1,31 +1,31 @@
|
|||
import type { AstroConfig, PaginatedCollectionResult, CollectionRSS, CreateCollectionResult, PaginateFunction, RuntimeMode } from './@types/astro';
|
||||
import type { CompileError as ICompileError } from '@astrojs/parser';
|
||||
|
||||
import { compile as compilePathToRegexp, match as matchPathToRegexp } from 'path-to-regexp';
|
||||
import resolve from 'resolve';
|
||||
import parser from '@astrojs/parser';
|
||||
import { existsSync, promises as fs } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { posix as path } from 'path';
|
||||
import { performance } from 'perf_hooks';
|
||||
import resolve from 'resolve';
|
||||
import {
|
||||
loadConfiguration,
|
||||
logger as snowpackLogger,
|
||||
NotFoundError,
|
||||
SnowpackDevServer,
|
||||
ServerRuntime as SnowpackServerRuntime,
|
||||
SnowpackConfig,
|
||||
SnowpackDevServer,
|
||||
startServer as startSnowpackServer,
|
||||
} from 'snowpack';
|
||||
import parser from '@astrojs/parser';
|
||||
const { CompileError } = parser;
|
||||
import { fileURLToPath } from 'url';
|
||||
import type { AstroConfig, CollectionRSS, GetStaticPathsResult, ManifestData, Params, RuntimeMode } from './@types/astro';
|
||||
import { generatePaginateFunction } from './build/paginate.js';
|
||||
import { canonicalURL, getSrcPath, stopTimer } from './build/util.js';
|
||||
import { LogOptions, debug, info, warn } from './logger.js';
|
||||
import { configureSnowpackLogger } from './snowpack-logger.js';
|
||||
import { searchForPage } from './search.js';
|
||||
import snowpackExternals from './external.js';
|
||||
import { nodeBuiltinsMap } from './node_builtins.js';
|
||||
import { ConfigManager } from './config_manager.js';
|
||||
import { validateCollectionModule, validateCollectionResult } from './util.js';
|
||||
import snowpackExternals from './external.js';
|
||||
import { debug, info, LogOptions } from './logger.js';
|
||||
import { createManifest } from './manifest/create.js';
|
||||
import { nodeBuiltinsMap } from './node_builtins.js';
|
||||
import { configureSnowpackLogger } from './snowpack-logger.js';
|
||||
import { convertMatchToLocation, validateGetStaticPathsModule, validateGetStaticPathsResult } from './util.js';
|
||||
|
||||
const { CompileError } = parser;
|
||||
|
||||
interface RuntimeConfig {
|
||||
astroConfig: AstroConfig;
|
||||
|
@ -35,22 +35,16 @@ interface RuntimeConfig {
|
|||
snowpackRuntime: SnowpackServerRuntime;
|
||||
snowpackConfig: SnowpackConfig;
|
||||
configManager: ConfigManager;
|
||||
}
|
||||
|
||||
// info needed for collection generation
|
||||
interface CollectionInfo {
|
||||
additionalURLs: Set<string>;
|
||||
rss?: { data: any[] & CollectionRSS };
|
||||
manifest: ManifestData;
|
||||
}
|
||||
|
||||
type LoadResultSuccess = {
|
||||
statusCode: 200;
|
||||
contents: string | Buffer;
|
||||
contentType?: string | false;
|
||||
collectionInfo?: CollectionInfo;
|
||||
rss?: { data: any[] & CollectionRSS };
|
||||
};
|
||||
type LoadResultNotFound = { statusCode: 404; error: Error };
|
||||
type LoadResultRedirect = { statusCode: 301 | 302; location: string };
|
||||
type LoadResultError = { statusCode: 500 } & (
|
||||
| { type: 'parse-error'; error: ICompileError }
|
||||
| { type: 'ssr'; error: Error }
|
||||
|
@ -58,11 +52,32 @@ type LoadResultError = { statusCode: 500 } & (
|
|||
| { type: 'unknown'; error: Error }
|
||||
);
|
||||
|
||||
export type LoadResult = LoadResultSuccess | LoadResultNotFound | LoadResultRedirect | LoadResultError;
|
||||
export type LoadResult = LoadResultSuccess | LoadResultNotFound | LoadResultError;
|
||||
|
||||
// Disable snowpack from writing to stdout/err.
|
||||
configureSnowpackLogger(snowpackLogger);
|
||||
|
||||
function getParams(array: string[]) {
|
||||
// given an array of params like `['x', 'y', 'z']` for
|
||||
// src/routes/[x]/[y]/[z]/svelte, create a function
|
||||
// that turns a RegExpExecArray into ({ x, y, z })
|
||||
const fn = (match: RegExpExecArray) => {
|
||||
const params: Params = {};
|
||||
array.forEach((key, i) => {
|
||||
if (key.startsWith('...')) {
|
||||
params[key.slice(3)] = match[i + 1] ? decodeURIComponent(match[i + 1]) : undefined;
|
||||
} else {
|
||||
params[key] = decodeURIComponent(match[i + 1]);
|
||||
}
|
||||
});
|
||||
return params;
|
||||
};
|
||||
|
||||
return fn;
|
||||
}
|
||||
|
||||
let cachedStaticPaths: Record<string, GetStaticPathsResult> = {};
|
||||
|
||||
/** Pass a URL to Astro to resolve and build */
|
||||
async function load(config: RuntimeConfig, rawPathname: string | undefined): Promise<LoadResult> {
|
||||
const { logging, snowpackRuntime, snowpack, configManager } = config;
|
||||
|
@ -72,134 +87,63 @@ async function load(config: RuntimeConfig, rawPathname: string | undefined): Pro
|
|||
const fullurl = new URL(rawPathname || '/', site.origin);
|
||||
|
||||
const reqPath = decodeURI(fullurl.pathname);
|
||||
info(logging, 'access', reqPath);
|
||||
|
||||
const searchResult = await searchForPage(fullurl, config.astroConfig);
|
||||
if (searchResult.statusCode === 404) {
|
||||
try {
|
||||
const result = await snowpack.loadUrl(reqPath);
|
||||
if (!result) throw new Error(`Unable to load ${reqPath}`);
|
||||
// success
|
||||
return {
|
||||
statusCode: 200,
|
||||
...result,
|
||||
};
|
||||
} catch (err) {
|
||||
// build error
|
||||
if (err.failed) {
|
||||
return { statusCode: 500, type: 'unknown', error: err };
|
||||
}
|
||||
|
||||
// not found
|
||||
return { statusCode: 404, error: err };
|
||||
try {
|
||||
const result = await snowpack.loadUrl(reqPath);
|
||||
if (!result) throw new Error(`Unable to load ${reqPath}`);
|
||||
// success
|
||||
debug(logging, 'access', reqPath);
|
||||
return {
|
||||
statusCode: 200,
|
||||
...result,
|
||||
};
|
||||
} catch (err) {
|
||||
// build error
|
||||
if (err.failed) {
|
||||
return { statusCode: 500, type: 'unknown', error: err };
|
||||
}
|
||||
// not found, load a page instead
|
||||
// continue...
|
||||
}
|
||||
|
||||
if (searchResult.statusCode === 301) {
|
||||
return { statusCode: 301, location: searchResult.pathname };
|
||||
info(logging, 'access', reqPath);
|
||||
const routeMatch = config.manifest.routes.find((route) => route.pattern.test(reqPath));
|
||||
if (!routeMatch) {
|
||||
return { statusCode: 404, error: new Error('No matching route found.') };
|
||||
}
|
||||
|
||||
const snowpackURL = searchResult.location.snowpackURL;
|
||||
let collectionInfo: CollectionInfo | undefined;
|
||||
const paramsMatch = routeMatch.pattern.exec(reqPath)!;
|
||||
const routeLocation = convertMatchToLocation(routeMatch, config.astroConfig);
|
||||
const params = getParams(routeMatch.params)(paramsMatch);
|
||||
let pageProps = {} as Record<string, any>;
|
||||
|
||||
try {
|
||||
if (configManager.needsUpdate()) {
|
||||
await configManager.update();
|
||||
}
|
||||
const mod = await snowpackRuntime.importModule(snowpackURL);
|
||||
debug(logging, 'resolve', `${reqPath} -> ${snowpackURL}`);
|
||||
const mod = await snowpackRuntime.importModule(routeLocation.snowpackURL);
|
||||
debug(logging, 'resolve', `${reqPath} -> ${routeLocation.snowpackURL}`);
|
||||
|
||||
// If this URL matched a collection, run the createCollection() function.
|
||||
// TODO(perf): The createCollection() function is meant to be run once, but right now
|
||||
// it re-runs on every new page load. This is especially problematic during build.
|
||||
if (path.posix.basename(searchResult.location.fileURL.pathname).startsWith('$')) {
|
||||
validateCollectionModule(mod, searchResult.pathname);
|
||||
const pageCollection: CreateCollectionResult = await mod.exports.createCollection();
|
||||
validateCollectionResult(pageCollection, searchResult.pathname);
|
||||
const { route, paths: getPaths = () => [{ params: {} }], props: getProps, paginate: isPaginated, rss: createRSS } = pageCollection;
|
||||
debug(logging, 'collection', `use route "${route}" to match request "${reqPath}"`);
|
||||
const reqToParams = matchPathToRegexp<any>(route);
|
||||
const toPath = compilePathToRegexp(route);
|
||||
const reqParams = reqToParams(reqPath);
|
||||
if (!reqParams) {
|
||||
throw new Error(`[createCollection] route pattern does not match request: "${route}". (${searchResult.pathname})`);
|
||||
}
|
||||
if (isPaginated && reqParams.params.page === '1') {
|
||||
return { statusCode: 404, error: new Error(`[createCollection] The first page of a paginated collection has no page number in the URL. (${searchResult.pathname})`) };
|
||||
}
|
||||
const pageNum = parseInt(reqParams.params.page || 1);
|
||||
const allPaths = getPaths();
|
||||
const matchedPathObject = allPaths.find((p) => toPath({ ...p.params, page: reqParams.params.page }) === reqPath);
|
||||
debug(logging, 'collection', `matched path: ${JSON.stringify(matchedPathObject)}`);
|
||||
if (!matchedPathObject) {
|
||||
throw new Error(`[createCollection] no matching path found: "${route}". (${searchResult.pathname})`);
|
||||
}
|
||||
const matchedParams = matchedPathObject.params;
|
||||
if (matchedParams.page) {
|
||||
throw new Error(`[createCollection] "page" param is reserved for pagination and handled for you by Astro. It cannot be returned by "paths()". (${searchResult.pathname})`);
|
||||
}
|
||||
let paginateUtility: PaginateFunction = () => {
|
||||
throw new Error(`[createCollection] paginate() function was called but "paginate: true" was not set. (${searchResult.pathname})`);
|
||||
};
|
||||
let lastPage: number | undefined;
|
||||
let paginateCallCount: number | undefined;
|
||||
if (isPaginated) {
|
||||
paginateCallCount = 0;
|
||||
paginateUtility = (data, args = {}) => {
|
||||
paginateCallCount!++;
|
||||
let { pageSize } = args;
|
||||
if (!pageSize) {
|
||||
pageSize = 10;
|
||||
}
|
||||
const start = pageSize === Infinity ? 0 : (pageNum - 1) * pageSize; // currentPage is 1-indexed
|
||||
const end = Math.min(start + pageSize, data.length);
|
||||
lastPage = Math.max(1, Math.ceil(data.length / pageSize));
|
||||
// The first page of any collection should generate a collectionInfo
|
||||
// metadata object. Important for the final build.
|
||||
if (pageNum === 1) {
|
||||
collectionInfo = {
|
||||
additionalURLs: new Set<string>(),
|
||||
rss: undefined,
|
||||
};
|
||||
if (createRSS) {
|
||||
collectionInfo.rss = {
|
||||
...createRSS,
|
||||
data: [...data] as any,
|
||||
};
|
||||
}
|
||||
for (const page of [...Array(lastPage - 1).keys()]) {
|
||||
collectionInfo.additionalURLs.add(toPath({ ...matchedParams, page: page + 2 }));
|
||||
}
|
||||
}
|
||||
return {
|
||||
data: data.slice(start, end),
|
||||
start,
|
||||
end: end - 1,
|
||||
total: data.length,
|
||||
page: {
|
||||
size: pageSize,
|
||||
current: pageNum,
|
||||
last: lastPage,
|
||||
},
|
||||
url: {
|
||||
current: reqPath,
|
||||
next: pageNum === lastPage ? undefined : toPath({ ...matchedParams, page: pageNum + 1 }),
|
||||
prev: pageNum === 1 ? undefined : toPath({ ...matchedParams, page: pageNum - 1 === 1 ? undefined : pageNum - 1 }),
|
||||
},
|
||||
} as PaginatedCollectionResult;
|
||||
};
|
||||
}
|
||||
pageProps = await getProps({ params: matchedParams, paginate: paginateUtility });
|
||||
debug(logging, 'collection', `page props: ${JSON.stringify(pageProps)}`);
|
||||
if (paginateCallCount !== undefined && paginateCallCount !== 1) {
|
||||
throw new Error(
|
||||
`[createCollection] paginate() function must be called 1 time when "paginate: true". Called ${paginateCallCount} times instead. (${searchResult.pathname})`
|
||||
);
|
||||
}
|
||||
if (lastPage !== undefined && pageNum > lastPage) {
|
||||
return { statusCode: 404, error: new Error(`[createCollection] page ${pageNum} does not exist. Available pages: 1-${lastPage} (${searchResult.pathname})`) };
|
||||
// if path isn't static, we need to generate the valid paths first and check against them
|
||||
// this helps us to prevent incorrect matches in dev that wouldn't exist in build.
|
||||
if (!routeMatch.path) {
|
||||
validateGetStaticPathsModule(mod);
|
||||
cachedStaticPaths[routeMatch.component] =
|
||||
cachedStaticPaths[routeMatch.component] ||
|
||||
(await mod.exports.getStaticPaths({
|
||||
paginate: generatePaginateFunction(routeMatch),
|
||||
rss: () => {
|
||||
/* noop */
|
||||
},
|
||||
})).flat();
|
||||
|
||||
validateGetStaticPathsResult(cachedStaticPaths[routeMatch.component], logging);
|
||||
const routePathParams: GetStaticPathsResult = cachedStaticPaths[routeMatch.component];
|
||||
const matchedStaticPath = routePathParams.find(({ params: _params }) => JSON.stringify(_params) === JSON.stringify(params));
|
||||
if (!matchedStaticPath) {
|
||||
return { statusCode: 404, error: new Error(`[getStaticPaths] route pattern matched, but no matching static path found. (${reqPath})`) };
|
||||
}
|
||||
pageProps = { ...matchedStaticPath.props } || {};
|
||||
}
|
||||
|
||||
const requestURL = new URL(fullurl.toString());
|
||||
|
@ -212,7 +156,7 @@ async function load(config: RuntimeConfig, rawPathname: string | undefined): Pro
|
|||
|
||||
let html = (await mod.exports.__renderPage({
|
||||
request: {
|
||||
// params should go here when implemented
|
||||
params,
|
||||
url: requestURL,
|
||||
canonicalURL: canonicalURL(requestURL.pathname, site.toString()),
|
||||
},
|
||||
|
@ -225,7 +169,7 @@ async function load(config: RuntimeConfig, rawPathname: string | undefined): Pro
|
|||
statusCode: 200,
|
||||
contentType: 'text/html; charset=utf-8',
|
||||
contents: html,
|
||||
collectionInfo,
|
||||
rss: undefined, // TODO: Add back rss support
|
||||
};
|
||||
} catch (err) {
|
||||
if (err.code === 'parse-error' || err instanceof SyntaxError) {
|
||||
|
@ -277,6 +221,8 @@ async function load(config: RuntimeConfig, rawPathname: string | undefined): Pro
|
|||
code,
|
||||
filename: srcFile.pathname,
|
||||
start,
|
||||
// TODO: why did I need to add this?
|
||||
end: 1,
|
||||
message: `Could not find${missingFile ? ` "${missingFile}"` : ' file'}`,
|
||||
}),
|
||||
};
|
||||
|
@ -432,11 +378,6 @@ async function createSnowpack(astroConfig: AstroConfig, options: CreateSnowpackO
|
|||
return { snowpack, snowpackRuntime, snowpackConfig, configManager };
|
||||
}
|
||||
|
||||
interface PageLocation {
|
||||
fileURL: URL;
|
||||
snowpackURL: string;
|
||||
}
|
||||
|
||||
/** Core Astro runtime */
|
||||
export async function createRuntime(astroConfig: AstroConfig, { mode, logging }: RuntimeOptions): Promise<AstroRuntime> {
|
||||
let snowpack: SnowpackDevServer;
|
||||
|
@ -465,8 +406,18 @@ export async function createRuntime(astroConfig: AstroConfig, { mode, logging }:
|
|||
snowpackRuntime,
|
||||
snowpackConfig,
|
||||
configManager,
|
||||
manifest: createManifest({ config: astroConfig }),
|
||||
};
|
||||
|
||||
snowpack.onFileChange(({ filePath }: { filePath: string }) => {
|
||||
// Clear out any cached getStaticPaths() data.
|
||||
cachedStaticPaths = {};
|
||||
// Rebuild the manifest, if needed
|
||||
if (filePath.includes(fileURLToPath(astroConfig.pages))) {
|
||||
runtimeConfig.manifest = createManifest({ config: astroConfig });
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
runtimeConfig,
|
||||
load: load.bind(null, runtimeConfig),
|
||||
|
|
|
@ -1,142 +0,0 @@
|
|||
import type { AstroConfig } from './@types/astro';
|
||||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import glob from 'tiny-glob';
|
||||
import slash from 'slash';
|
||||
|
||||
interface PageLocation {
|
||||
fileURL: URL;
|
||||
snowpackURL: string;
|
||||
}
|
||||
/** findAnyPage and return the _astro candidate for snowpack */
|
||||
function findAnyPage(candidates: Array<string>, astroConfig: AstroConfig): PageLocation | false {
|
||||
for (let candidate of candidates) {
|
||||
const url = new URL(`./${candidate}`, astroConfig.pages);
|
||||
if (existsSync(url)) {
|
||||
const pagesPath = astroConfig.pages.pathname.replace(astroConfig.projectRoot.pathname, '');
|
||||
return {
|
||||
fileURL: url,
|
||||
snowpackURL: `/_astro/${pagesPath}${candidate}.js`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type SearchResult =
|
||||
| {
|
||||
statusCode: 200;
|
||||
location: PageLocation;
|
||||
pathname: string;
|
||||
}
|
||||
| {
|
||||
statusCode: 301;
|
||||
location: null;
|
||||
pathname: string;
|
||||
}
|
||||
| {
|
||||
statusCode: 404;
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a URL, attempt to locate its source file (similar to Snowpack’s load()).
|
||||
*
|
||||
* TODO(perf): This function (and findCollectionPage(), its helper function) make several
|
||||
* checks against the file system on every request. It would be better to keep an in-memory
|
||||
* list of all known files that we could make instant, synchronous checks against.
|
||||
*/
|
||||
export async function searchForPage(url: URL, astroConfig: AstroConfig): Promise<SearchResult> {
|
||||
const reqPath = decodeURI(url.pathname);
|
||||
const base = reqPath.substr(1);
|
||||
|
||||
// Try to find index.astro/md paths
|
||||
if (reqPath.endsWith('/')) {
|
||||
const candidates = [`${base}index.astro`, `${base}index.md`];
|
||||
const location = findAnyPage(candidates, astroConfig);
|
||||
if (location) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
location,
|
||||
pathname: reqPath,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Try to find the page by its name.
|
||||
const candidates = [`${base}.astro`, `${base}.md`];
|
||||
let location = findAnyPage(candidates, astroConfig);
|
||||
if (location) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
location,
|
||||
pathname: reqPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find name/index.astro/md
|
||||
const candidates = [`${base}/index.astro`, `${base}/index.md`];
|
||||
const location = findAnyPage(candidates, astroConfig);
|
||||
if (location) {
|
||||
return {
|
||||
statusCode: 301,
|
||||
location: null,
|
||||
pathname: reqPath + '/',
|
||||
};
|
||||
}
|
||||
|
||||
// Try and load collections (but only for non-extension files)
|
||||
const hasExt = !!path.extname(reqPath);
|
||||
if (!location && !hasExt) {
|
||||
const collectionLocation = await findCollectionPage(reqPath, astroConfig);
|
||||
if (collectionLocation) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
location: collectionLocation,
|
||||
pathname: reqPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (reqPath === '/500') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
location: {
|
||||
fileURL: new URL('./frontend/500.astro', import.meta.url),
|
||||
snowpackURL: `/_astro_frontend/500.astro.js`,
|
||||
},
|
||||
pathname: reqPath,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 404,
|
||||
};
|
||||
}
|
||||
|
||||
/** Find a collection page file in the pages directory that matches the request. */
|
||||
async function findCollectionPage(reqPath: string, astroConfig: AstroConfig): Promise<PageLocation | undefined> {
|
||||
const cwd = fileURLToPath(astroConfig.pages);
|
||||
const allCollections: Record<string, PageLocation> = {};
|
||||
const files = await glob('**/$*.{astro,md}', { cwd, filesOnly: true });
|
||||
for (const srcURL of files) {
|
||||
const pagesPath = astroConfig.pages.pathname.replace(astroConfig.projectRoot.pathname, '');
|
||||
const snowpackURL = `/_astro/${pagesPath}${srcURL}.js`;
|
||||
const reqURL =
|
||||
'/' +
|
||||
srcURL
|
||||
.replace(/\.(astro|md)$/, '')
|
||||
.replace(/(^|[\/])\$/, '$1')
|
||||
.replace(/index$/, '');
|
||||
allCollections[reqURL] = { snowpackURL, fileURL: new URL(srcURL, astroConfig.pages) };
|
||||
}
|
||||
|
||||
// Match the more specific filename first. If no match, return nothing.
|
||||
let collectionMatchState = reqPath;
|
||||
do {
|
||||
if (allCollections[collectionMatchState]) {
|
||||
return allCollections[collectionMatchState];
|
||||
}
|
||||
collectionMatchState = collectionMatchState.substring(0, collectionMatchState.lastIndexOf('/'));
|
||||
} while (collectionMatchState.length > 0);
|
||||
}
|
|
@ -1,30 +1,45 @@
|
|||
import type { CreateCollectionResult } from './@types/astro';
|
||||
import { AstroConfig, GetStaticPathsResult, RouteData } from './@types/astro';
|
||||
import { LogOptions, warn } from './logger.js';
|
||||
|
||||
export function validateCollectionModule(mod: any, filename: string) {
|
||||
if (!mod.exports.createCollection) {
|
||||
throw new Error(`No "createCollection()" export found. Add one or remove the "$" from the filename. ("${filename}")`);
|
||||
interface PageLocation {
|
||||
fileURL: URL;
|
||||
snowpackURL: string;
|
||||
}
|
||||
|
||||
/** convertMatchToLocation and return the _astro candidate for snowpack */
|
||||
export function convertMatchToLocation(routeMatch: RouteData, astroConfig: AstroConfig): PageLocation {
|
||||
const url = new URL(`./${routeMatch.component}`, astroConfig.projectRoot);
|
||||
return {
|
||||
fileURL: url,
|
||||
snowpackURL: `/_astro/${routeMatch.component}.js`,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateGetStaticPathsModule(mod: any) {
|
||||
if (mod.exports.createCollection) {
|
||||
throw new Error(`[createCollection] deprecated. Please use getStaticPaths() instead.`);
|
||||
}
|
||||
if (!mod.exports.getStaticPaths) {
|
||||
throw new Error(`[getStaticPaths] getStaticPaths() function is required. Make sure that you \`export\` the function from your component.`);
|
||||
}
|
||||
}
|
||||
export function validateCollectionResult(result: CreateCollectionResult, filename: string) {
|
||||
const LEGACY_KEYS = new Set(['permalink', 'data', 'routes']);
|
||||
for (const key of Object.keys(result)) {
|
||||
if (LEGACY_KEYS.has(key)) {
|
||||
throw new Error(`[deprecated] it looks like you're using the legacy createCollection() API. (key "${key}". (${filename})`);
|
||||
|
||||
export function validateGetStaticPathsResult(result: GetStaticPathsResult, logging: LogOptions) {
|
||||
if (!Array.isArray(result)) {
|
||||
throw new Error(`[getStaticPaths] invalid return value. Expected an array of path objects, but got \`${JSON.stringify(result)}\`.`);
|
||||
}
|
||||
result.forEach((pathObject) => {
|
||||
if (!pathObject.params) {
|
||||
warn(logging, 'getStaticPaths', `invalid path object. Expected an object with key \`params\`, but got \`${JSON.stringify(pathObject)}\`. Skipped.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const VALID_KEYS = new Set(['route', 'paths', 'props', 'paginate', 'rss']);
|
||||
for (const key of Object.keys(result)) {
|
||||
if (!VALID_KEYS.has(key)) {
|
||||
throw new Error(`[createCollection] unknown option: "${key}". (${filename})`);
|
||||
for (const [key, val] of Object.entries(pathObject.params)) {
|
||||
if (!(typeof val === 'undefined' || typeof val === 'string')) {
|
||||
warn(logging, 'getStaticPaths', `invalid path param: ${key}. A string value was expected, but got \`${JSON.stringify(val)}\`.`);
|
||||
}
|
||||
if (val === '') {
|
||||
warn(logging, 'getStaticPaths', `invalid path param: ${key}. \`undefined\` expected for an optional param, but got empty string.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const REQUIRED_KEYS = new Set(['route', 'props']);
|
||||
for (const key of REQUIRED_KEYS) {
|
||||
if (!(result as any)[key]) {
|
||||
throw new Error(`[createCollection] missing required option: "${key}". (${filename})`);
|
||||
}
|
||||
}
|
||||
if (result.paginate && !result.route.includes(':page?')) {
|
||||
throw new Error(`[createCollection] when "paginate: true" route must include a "/:page?" param. (${filename})`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,74 +0,0 @@
|
|||
import { suite } from 'uvu';
|
||||
import * as assert from 'uvu/assert';
|
||||
import { doc } from './test-utils.js';
|
||||
import { setup } from './helpers.js';
|
||||
|
||||
const Collections = suite('Collections');
|
||||
|
||||
setup(Collections, './fixtures/astro-collection');
|
||||
|
||||
Collections('generates pagination successfully', async ({ runtime }) => {
|
||||
const result = await runtime.load('/paginated');
|
||||
assert.ok(!result.error, `build error: ${result.error}`);
|
||||
const $ = doc(result.contents);
|
||||
const prev = $('#prev-page');
|
||||
const next = $('#next-page');
|
||||
assert.equal(prev.length, 0); // this is first page; should be missing
|
||||
assert.equal(next.length, 1); // this should be on-page
|
||||
});
|
||||
|
||||
Collections('can load remote data', async ({ runtime }) => {
|
||||
const PACKAGES_TO_TEST = ['canvas-confetti', 'preact', 'svelte'];
|
||||
for (const packageName of PACKAGES_TO_TEST) {
|
||||
const result = await runtime.load(`/remote/${packageName}`);
|
||||
assert.ok(!result.error, `build error: ${result.error}`);
|
||||
const $ = doc(result.contents);
|
||||
assert.ok($(`#pkg-${packageName}`).length);
|
||||
}
|
||||
});
|
||||
|
||||
Collections('generates pages based on params successfully', async ({ runtime }) => {
|
||||
const AUTHORS_TO_TEST = [
|
||||
{
|
||||
id: 'author-one',
|
||||
posts: ['one', 'three'],
|
||||
},
|
||||
{
|
||||
id: 'author-two',
|
||||
posts: ['two'],
|
||||
},
|
||||
{
|
||||
id: 'author-three',
|
||||
posts: ['nested/a'],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { id, posts } of AUTHORS_TO_TEST) {
|
||||
const result = await runtime.load(`/grouped/${id}`);
|
||||
assert.ok(!result.error, `build error: ${result.error}`);
|
||||
const $ = doc(result.contents);
|
||||
|
||||
assert.ok($(`#${id}`).length);
|
||||
|
||||
for (const post of posts) {
|
||||
assert.ok($(`a[href="/post/${post}"]`).length);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Collections('paginates pages based on params successfully', async ({ runtime }) => {
|
||||
const TAGS_TO_TEST = ['tag1', 'tag2', 'tag3'];
|
||||
for (const tagName of TAGS_TO_TEST) {
|
||||
// Test that first page is generated:
|
||||
const resultFirstPage = await runtime.load(`/params-and-paginated/${tagName}`);
|
||||
if (resultFirstPage.error) throw new Error(resultFirstPage.error);
|
||||
assert.ok(doc(resultFirstPage.contents)(`#${tagName}`).length);
|
||||
|
||||
// Test that second page is generated:
|
||||
const resultSecondPage = await runtime.load(`/params-and-paginated/${tagName}/2`);
|
||||
if (resultSecondPage.error) throw new Error(resultSecondPage.error);
|
||||
assert.ok(doc(resultSecondPage.contents)(`#${tagName}`).length);
|
||||
}
|
||||
});
|
||||
|
||||
Collections.run();
|
|
@ -22,7 +22,7 @@ Global('Astro.request.canonicalURL', async (context) => {
|
|||
const canonicalURLs = {
|
||||
'/': 'https://mysite.dev/blog/',
|
||||
'/post/post': 'https://mysite.dev/blog/post/post/',
|
||||
'/posts': 'https://mysite.dev/blog/posts/',
|
||||
'/posts/1': 'https://mysite.dev/blog/posts/',
|
||||
'/posts/2': 'https://mysite.dev/blog/posts/2/',
|
||||
};
|
||||
|
||||
|
|
68
packages/astro/test/astro-pagination.test.js
Normal file
68
packages/astro/test/astro-pagination.test.js
Normal file
|
@ -0,0 +1,68 @@
|
|||
import { suite } from 'uvu';
|
||||
import * as assert from 'uvu/assert';
|
||||
import { doc } from './test-utils.js';
|
||||
import { setup } from './helpers.js';
|
||||
|
||||
const Global = suite('Astro.*');
|
||||
|
||||
setup(Global, './fixtures/astro-pagination');
|
||||
|
||||
Global('optional root page', async (context) => {
|
||||
{
|
||||
const result = await context.runtime.load('/posts/optional-root-page/');
|
||||
assert.ok(!result.error, `build error: ${result.error}`);
|
||||
}
|
||||
{
|
||||
const result = await context.runtime.load('/posts/optional-root-page/2');
|
||||
assert.ok(!result.error, `build error: ${result.error}`);
|
||||
}
|
||||
{
|
||||
const result = await context.runtime.load('/posts/optional-root-page/3');
|
||||
assert.ok(!result.error, `build error: ${result.error}`);
|
||||
}
|
||||
});
|
||||
|
||||
Global('named root page', async (context) => {
|
||||
{
|
||||
const result = await context.runtime.load('/posts/named-root-page/1');
|
||||
assert.ok(!result.error, `build error: ${result.error}`);
|
||||
}
|
||||
{
|
||||
const result = await context.runtime.load('/posts/named-root-page/2');
|
||||
assert.ok(!result.error, `build error: ${result.error}`);
|
||||
}
|
||||
{
|
||||
const result = await context.runtime.load('/posts/named-root-page/3');
|
||||
assert.ok(!result.error, `build error: ${result.error}`);
|
||||
}
|
||||
});
|
||||
|
||||
Global('multiple params', async (context) => {
|
||||
{
|
||||
const result = await context.runtime.load('/posts/red/1');
|
||||
assert.ok(!result.error, `build error: ${result.error}`);
|
||||
const $ = doc(result.contents);
|
||||
assert.equal($('#page-a').text(), '1');
|
||||
assert.equal($('#page-b').text(), '1');
|
||||
assert.equal($('#filter').text(), 'red');
|
||||
|
||||
}
|
||||
{
|
||||
const result = await context.runtime.load('/posts/blue/1');
|
||||
assert.ok(!result.error, `build error: ${result.error}`);
|
||||
const $ = doc(result.contents);
|
||||
assert.equal($('#page-a').text(), '1');
|
||||
assert.equal($('#page-b').text(), '1');
|
||||
assert.equal($('#filter').text(), 'blue');
|
||||
}
|
||||
{
|
||||
const result = await context.runtime.load('/posts/blue/2');
|
||||
assert.ok(!result.error, `build error: ${result.error}`);
|
||||
const $ = doc(result.contents);
|
||||
assert.equal($('#page-a').text(), '2');
|
||||
assert.equal($('#page-b').text(), '2');
|
||||
assert.equal($('#filter').text(), 'blue');
|
||||
}
|
||||
});
|
||||
|
||||
Global.run();
|
|
@ -1,39 +0,0 @@
|
|||
import { suite } from 'uvu';
|
||||
import * as assert from 'uvu/assert';
|
||||
import { doc } from './test-utils.js';
|
||||
import { setupBuild } from './helpers.js';
|
||||
|
||||
const Resolution = suite('Astro Resolution');
|
||||
|
||||
setupBuild(Resolution, './fixtures/astro-resolve');
|
||||
|
||||
Resolution('Assets', async (context) => {
|
||||
await context.build();
|
||||
|
||||
// test 1: public/ asset resolved
|
||||
assert.ok(await context.readFile('/svg.svg'));
|
||||
|
||||
// test 2: asset in src/pages resolved (and didn’t overwrite /svg.svg)
|
||||
assert.ok(await context.readFile('/_astro/src/pages/svg.svg'));
|
||||
});
|
||||
|
||||
Resolution('<script type="module">', async (context) => {
|
||||
await context.build();
|
||||
|
||||
// public/ asset resolved
|
||||
const $ = doc(await context.readFile('/scripts/index.html'));
|
||||
|
||||
// test 1: not `type="module"` left alone
|
||||
assert.equal($('script[src="./relative.js"]').attr('type'), undefined);
|
||||
|
||||
// test 2: inline script left alone
|
||||
assert.equal($('script:not([type]):not([src])').length, 1);
|
||||
|
||||
// test 3: relative script resolved
|
||||
assert.equal($('script[type="module"][src="/_astro/src/pages/relative.js"]').length, 2); // we have 2 of these!
|
||||
|
||||
// test 4: absolute script left alone
|
||||
assert.equal($('script[type="module"][src="/absolute.js"]').length, 1);
|
||||
});
|
||||
|
||||
Resolution.run();
|
|
@ -6,11 +6,11 @@ const RSS = suite('RSS Generation');
|
|||
|
||||
setupBuild(RSS, './fixtures/astro-rss');
|
||||
|
||||
const snapshot = `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title><![CDATA[MF Doomcast]]></title><description><![CDATA[The podcast about the things you find on a picnic, or at a picnic table]]></description><link>https://mysite.dev/feed/episodes.xml</link><language>en-us</language><itunes:author>MF Doom</itunes:author><item><title><![CDATA[Rap Snitch Knishes (feat. Mr. Fantastik)]]></title><link>https://mysite.dev/episode/rap-snitch-knishes/</link><description><![CDATA[Complex named this song the “22nd funniest rap song of all time.”]]></description><pubDate>Tue, 16 Nov 2004 00:00:00 GMT</pubDate><itunes:episodeType>music</itunes:episodeType><itunes:duration>172</itunes:duration><itunes:explicit>true</itunes:explicit></item><item><title><![CDATA[Fazers]]></title><link>https://mysite.dev/episode/fazers/</link><description><![CDATA[Rhapsody ranked Take Me to Your Leader 17th on its list “Hip-Hop’s Best Albums of the Decade”]]></description><pubDate>Thu, 03 Jul 2003 00:00:00 GMT</pubDate><itunes:episodeType>music</itunes:episodeType><itunes:duration>197</itunes:duration><itunes:explicit>true</itunes:explicit></item><item><title><![CDATA[Rhymes Like Dimes (feat. Cucumber Slice)]]></title><link>https://mysite.dev/episode/rhymes-like-dimes/</link><description><![CDATA[Operation: Doomsday has been heralded as an underground classic that established MF Doom's rank within the underground hip-hop scene during the early to mid-2000s.\n]]></description><pubDate>Tue, 19 Oct 1999 00:00:00 GMT</pubDate><itunes:episodeType>music</itunes:episodeType><itunes:duration>259</itunes:duration><itunes:explicit>true</itunes:explicit></item></channel></rss>`;
|
||||
const snapshot = `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title><![CDATA[MF Doomcast]]></title><description><![CDATA[The podcast about the things you find on a picnic, or at a picnic table]]></description><link>https://mysite.dev/custom/feed.xml</link><language>en-us</language><itunes:author>MF Doom</itunes:author><item><title><![CDATA[Rap Snitch Knishes (feat. Mr. Fantastik)]]></title><link>https://mysite.dev/episode/rap-snitch-knishes/</link><description><![CDATA[Complex named this song the “22nd funniest rap song of all time.”]]></description><pubDate>Tue, 16 Nov 2004 00:00:00 GMT</pubDate><itunes:episodeType>music</itunes:episodeType><itunes:duration>172</itunes:duration><itunes:explicit>true</itunes:explicit></item><item><title><![CDATA[Fazers]]></title><link>https://mysite.dev/episode/fazers/</link><description><![CDATA[Rhapsody ranked Take Me to Your Leader 17th on its list “Hip-Hop’s Best Albums of the Decade”]]></description><pubDate>Thu, 03 Jul 2003 00:00:00 GMT</pubDate><itunes:episodeType>music</itunes:episodeType><itunes:duration>197</itunes:duration><itunes:explicit>true</itunes:explicit></item><item><title><![CDATA[Rhymes Like Dimes (feat. Cucumber Slice)]]></title><link>https://mysite.dev/episode/rhymes-like-dimes/</link><description><![CDATA[Operation: Doomsday has been heralded as an underground classic that established MF Doom's rank within the underground hip-hop scene during the early to mid-2000s.\n]]></description><pubDate>Tue, 19 Oct 1999 00:00:00 GMT</pubDate><itunes:episodeType>music</itunes:episodeType><itunes:duration>259</itunes:duration><itunes:explicit>true</itunes:explicit></item></channel></rss>`;
|
||||
|
||||
RSS('Generates RSS correctly', async (context) => {
|
||||
await context.build();
|
||||
let rss = await context.readFile('/feed/episodes.xml');
|
||||
let rss = await context.readFile('/custom/feed.xml');
|
||||
assert.match(rss, snapshot);
|
||||
});
|
||||
|
||||
|
|
|
@ -12,30 +12,18 @@ Search('Finds the root page', async ({ runtime }) => {
|
|||
});
|
||||
|
||||
Search('Matches pathname to filename', async ({ runtime }) => {
|
||||
const result = await runtime.load('/news');
|
||||
assert.equal(result.statusCode, 200);
|
||||
assert.equal((await runtime.load('/news')).statusCode, 200);
|
||||
assert.equal((await runtime.load('/news/')).statusCode, 200);
|
||||
});
|
||||
|
||||
Search('A URL with a trailing slash can match a folder with an index.astro', async ({ runtime }) => {
|
||||
const result = await runtime.load('/nested-astro/');
|
||||
assert.equal(result.statusCode, 200);
|
||||
Search('Matches pathname to a nested index.astro file', async ({ runtime }) => {
|
||||
assert.equal((await runtime.load('/nested-astro')).statusCode, 200);
|
||||
assert.equal((await runtime.load('/nested-astro/')).statusCode, 200);
|
||||
});
|
||||
|
||||
Search('A URL with a trailing slash can match a folder with an index.md', async ({ runtime }) => {
|
||||
const result = await runtime.load('/nested-md/');
|
||||
assert.equal(result.statusCode, 200);
|
||||
});
|
||||
|
||||
Search('A URL without a trailing slash can redirect to a folder with an index.astro', async ({ runtime }) => {
|
||||
const result = await runtime.load('/nested-astro');
|
||||
assert.equal(result.statusCode, 301);
|
||||
assert.equal(result.location, '/nested-astro/');
|
||||
});
|
||||
|
||||
Search('A URL without a trailing slash can redirect to a folder with an index.md', async ({ runtime }) => {
|
||||
const result = await runtime.load('/nested-md');
|
||||
assert.equal(result.statusCode, 301);
|
||||
assert.equal(result.location, '/nested-md/');
|
||||
Search('Matches pathname to a nested index.md file', async ({ runtime }) => {
|
||||
assert.equal((await runtime.load('/nested-md')).statusCode, 200);
|
||||
assert.equal((await runtime.load('/nested-md/')).statusCode, 200);
|
||||
});
|
||||
|
||||
Search.run();
|
||||
|
|
|
@ -1,28 +0,0 @@
|
|||
---
|
||||
|
||||
export function createCollection() {
|
||||
const allPosts = Astro.fetchContent('./post/**/*.md');
|
||||
const allAuthors = allPosts.map(p => p.author);
|
||||
const uniqueAuthors = [...new Set(allAuthors)];
|
||||
return {
|
||||
route: `/grouped/:name`,
|
||||
paths() {
|
||||
return uniqueAuthors.map(name => ({ params: {name} }));
|
||||
},
|
||||
async props({ params }) {
|
||||
return {
|
||||
name: params.name,
|
||||
posts: allPosts.filter(p => p.author === params.name)
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { posts, name } = Astro.props;
|
||||
---
|
||||
|
||||
<div id={name}>
|
||||
{posts.map((post) => (
|
||||
<a href={post.url}>{post.title}</a>
|
||||
))}
|
||||
</div>
|
|
@ -1,30 +0,0 @@
|
|||
---
|
||||
export function createCollection() {
|
||||
return {
|
||||
paginate: true,
|
||||
route: '/paginated/:page?',
|
||||
async props({paginate}) {
|
||||
let data = Astro.fetchContent('./post/**/*.md');
|
||||
data.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||
return {posts: paginate(data, {pageSize: 1})};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const { posts } = Astro.props;
|
||||
---
|
||||
|
||||
|
||||
<div id="posts">
|
||||
{posts.data.map((post) => (
|
||||
<article>
|
||||
<h1>{post.title}</h1>
|
||||
<a href={post.url}>Read more</a>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
{posts.url.prev && <a id="prev-page" href={posts.url.prev}>Previous page</a>}
|
||||
{posts.url.next && <a id="next-page" href={posts.url.next}>Next page</a>}
|
||||
</nav>
|
|
@ -1,33 +0,0 @@
|
|||
---
|
||||
export function createCollection() {
|
||||
return {
|
||||
paginate: true,
|
||||
route: '/params-and-paginated/:tag/:page?',
|
||||
paths() {
|
||||
return [
|
||||
{params: {tag: 'tag1'}},
|
||||
{params: {tag: 'tag2'}},
|
||||
{params: {tag: 'tag3'}},
|
||||
];
|
||||
},
|
||||
async props({params, paginate}) {
|
||||
let data = Astro.fetchContent('./post/**/*.md');
|
||||
data.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||
return {tag: params.tag, posts: paginate(data, {pageSize: 1})};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const { posts, tag } = Astro.props;
|
||||
---
|
||||
|
||||
|
||||
<h1 id={tag}>{tag}</h1>
|
||||
<div id="posts">
|
||||
{posts.data.map((post) => (
|
||||
<article>
|
||||
<h1>{post.title}</h1>
|
||||
<a href={post.url}>Read more</a>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
|
@ -1,26 +0,0 @@
|
|||
---
|
||||
|
||||
export function createCollection() {
|
||||
return {
|
||||
route: '/remote/:lib',
|
||||
paths() {
|
||||
return [
|
||||
{params: {lib: 'canvas-confetti'}},
|
||||
{params: {lib: 'preact'}},
|
||||
{params: {lib: 'svelte'}},
|
||||
]
|
||||
},
|
||||
async props({params}) {
|
||||
return {
|
||||
pkg: await fetch(`https://api.skypack.dev/v1/package/${params.lib}`).then((res) => res.json())
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { pkg } = Astro.props;
|
||||
---
|
||||
|
||||
<div>
|
||||
<div id={`pkg-${pkg.name}`}>{pkg.name}</div>
|
||||
</div>
|
|
@ -1,9 +0,0 @@
|
|||
---
|
||||
title: Post A
|
||||
date: 2021-04-16 00:00:00
|
||||
author: author-three
|
||||
---
|
||||
|
||||
# Post A
|
||||
|
||||
I’m the "A" blog post
|
|
@ -1,9 +0,0 @@
|
|||
---
|
||||
title: Post One
|
||||
date: 2021-04-13 00:00:00
|
||||
author: author-one
|
||||
---
|
||||
|
||||
# Post One
|
||||
|
||||
I’m the first blog post
|
|
@ -1,9 +0,0 @@
|
|||
---
|
||||
title: Post Three
|
||||
date: 2021-04-15 00:00:00
|
||||
author: author-one
|
||||
---
|
||||
|
||||
# Post Three
|
||||
|
||||
I’m the third blog post
|
|
@ -1,9 +0,0 @@
|
|||
---
|
||||
title: Post Two
|
||||
date: 2021-04-14 00:00:00
|
||||
author: author-two
|
||||
---
|
||||
|
||||
# Post Two
|
||||
|
||||
I’m the second blog post
|
|
@ -1,30 +0,0 @@
|
|||
---
|
||||
|
||||
export function createCollection() {
|
||||
return {
|
||||
paginate: true,
|
||||
route: '/posts/:page?',
|
||||
async props({paginate}) {
|
||||
const data = Astro.fetchContent('./post/*.md');
|
||||
return {posts: paginate(data, {pageSize: 1})};
|
||||
},
|
||||
};
|
||||
}
|
||||
const { posts } = Astro.props;
|
||||
const { params, canonicalURL} = Astro.request;
|
||||
---
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<title>All Posts</title>
|
||||
<link rel="canonical" href={canonicalURL.href} />
|
||||
</head>
|
||||
<body>
|
||||
{posts.data.map((data) => (
|
||||
<div>
|
||||
<h1>{data.title}</h1>
|
||||
<a href={data.url}>Read</a>
|
||||
</div>
|
||||
))}
|
||||
</body>
|
||||
</html>
|
23
packages/astro/test/fixtures/astro-global/src/pages/posts/[page].astro
vendored
Normal file
23
packages/astro/test/fixtures/astro-global/src/pages/posts/[page].astro
vendored
Normal file
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
export function getStaticPaths({paginate}) {
|
||||
const data = Astro.fetchContent('../post/*.md');
|
||||
return paginate(data, {pageSize: 1});
|
||||
}
|
||||
const { page } = Astro.props;
|
||||
const { params, canonicalURL} = Astro.request;
|
||||
---
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<title>All Posts</title>
|
||||
<link rel="canonical" href={canonicalURL.href} />
|
||||
</head>
|
||||
<body>
|
||||
{page.data.map((data) => (
|
||||
<div>
|
||||
<h1>{data.title}</h1>
|
||||
<a href={data.url}>Read</a>
|
||||
</div>
|
||||
))}
|
||||
</body>
|
||||
</html>
|
6
packages/astro/test/fixtures/astro-pagination/astro.config.mjs
vendored
Normal file
6
packages/astro/test/fixtures/astro-pagination/astro.config.mjs
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
buildOptions: {
|
||||
site: 'https://mysite.dev/blog/',
|
||||
sitemap: false,
|
||||
},
|
||||
};
|
15
packages/astro/test/fixtures/astro-pagination/src/pages/index.astro
vendored
Normal file
15
packages/astro/test/fixtures/astro-pagination/src/pages/index.astro
vendored
Normal file
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
import Child from '../components/Child.astro';
|
||||
---
|
||||
<html>
|
||||
<head>
|
||||
<title>Test</title>
|
||||
<link rel="canonical" href={Astro.request.canonicalURL.href}>
|
||||
</head>
|
||||
<body>
|
||||
<div id="pathname">{Astro.request.url.pathname}</div>
|
||||
<a id="site" href={Astro.site}>Home</a>
|
||||
|
||||
<Child />
|
||||
</body>
|
||||
</html>
|
6
packages/astro/test/fixtures/astro-pagination/src/pages/post/post-2.md
vendored
Normal file
6
packages/astro/test/fixtures/astro-pagination/src/pages/post/post-2.md
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
title: 'My Post 2'
|
||||
tag: red
|
||||
---
|
||||
|
||||
# Post 2
|
6
packages/astro/test/fixtures/astro-pagination/src/pages/post/post-3.md
vendored
Normal file
6
packages/astro/test/fixtures/astro-pagination/src/pages/post/post-3.md
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
title: 'My Post 3'
|
||||
tag: blue
|
||||
---
|
||||
|
||||
# Post 2
|
6
packages/astro/test/fixtures/astro-pagination/src/pages/post/post.md
vendored
Normal file
6
packages/astro/test/fixtures/astro-pagination/src/pages/post/post.md
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
title: 'My Post'
|
||||
tag: blue
|
||||
---
|
||||
|
||||
# My Post
|
27
packages/astro/test/fixtures/astro-pagination/src/pages/posts/[slug]/[page].astro
vendored
Normal file
27
packages/astro/test/fixtures/astro-pagination/src/pages/posts/[slug]/[page].astro
vendored
Normal file
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
export function getStaticPaths({paginate}) {
|
||||
const allPosts = Astro.fetchContent('../../post/*.md');
|
||||
return ['red', 'blue'].map((filter) => {
|
||||
const filteredPosts = allPosts.filter((post) => post.tag === filter);
|
||||
return paginate(filteredPosts, {
|
||||
params: { slug: filter },
|
||||
props: { filter },
|
||||
pageSize: 1
|
||||
});
|
||||
});
|
||||
}
|
||||
const { page, filter } = Astro.props;
|
||||
const { params, canonicalURL} = Astro.request;
|
||||
---
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<title>Page</title>
|
||||
<link rel="canonical" href={canonicalURL.href} />
|
||||
</head>
|
||||
<body>
|
||||
<div id="page-a">{params.page}</div>
|
||||
<div id="page-b">{page.currentPage}</div>
|
||||
<div id="filter">{filter}</div>
|
||||
</body>
|
||||
</html>
|
16
packages/astro/test/fixtures/astro-pagination/src/pages/posts/named-root-page/[page].astro
vendored
Normal file
16
packages/astro/test/fixtures/astro-pagination/src/pages/posts/named-root-page/[page].astro
vendored
Normal file
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
export function getStaticPaths({paginate}) {
|
||||
const data = Astro.fetchContent('../../post/*.md');
|
||||
return paginate(data, {pageSize: 1});
|
||||
}
|
||||
const { page } = Astro.props;
|
||||
const { params, canonicalURL} = Astro.request;
|
||||
---
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<title>Page</title>
|
||||
<link rel="canonical" href={canonicalURL.href} />
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
16
packages/astro/test/fixtures/astro-pagination/src/pages/posts/optional-root-page/[...page].astro
vendored
Normal file
16
packages/astro/test/fixtures/astro-pagination/src/pages/posts/optional-root-page/[...page].astro
vendored
Normal file
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
export function getStaticPaths({paginate}) {
|
||||
const data = Astro.fetchContent('../../post/*.md');
|
||||
return paginate(data, {pageSize: 1});
|
||||
}
|
||||
const { page } = Astro.props;
|
||||
const { params, canonicalURL} = Astro.request;
|
||||
---
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<title>Page</title>
|
||||
<link rel="canonical" href={canonicalURL.href} />
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
|
@ -1 +0,0 @@
|
|||
console.log('I’m absolute');
|
|
@ -1 +0,0 @@
|
|||
<svg></svg>
|
Before Width: | Height: | Size: 12 B |
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"workspaceRoot": "../../../../../"
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- public/ -->
|
||||
<img src="/svg.svg" />
|
||||
|
||||
<!-- src/ -->
|
||||
<img src="./svg.svg" />
|
||||
</body>
|
||||
</html>
|
|
@ -1 +0,0 @@
|
|||
console.log('I’m relative');
|
|
@ -1,20 +0,0 @@
|
|||
<html>
|
||||
<head></head>
|
||||
|
||||
<body>
|
||||
<!-- not `type="module"` -->
|
||||
<script src="./relative.js"></script>
|
||||
|
||||
<!-- no `src` -->
|
||||
<script></script>
|
||||
|
||||
<!-- type="module" with NO "./" -->
|
||||
<script type="module" src="relative.js"></script>
|
||||
|
||||
<!-- type="module" WITH leading "./" -->
|
||||
<script type="module" src="./relative.js"></script>
|
||||
|
||||
<!-- absolute URL -->
|
||||
<script type="module" src="/absolute.js"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -1 +0,0 @@
|
|||
<svg></svg>
|
Before Width: | Height: | Size: 12 B |
|
@ -1,45 +0,0 @@
|
|||
---
|
||||
|
||||
export function createCollection() {
|
||||
const episodes = Astro.fetchContent('./episode/*.md')
|
||||
.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate));
|
||||
return {
|
||||
paginate: true,
|
||||
route: '/episodes/:page?',
|
||||
async props({paginate}) {
|
||||
return {episodes: paginate(episodes)};
|
||||
},
|
||||
rss: {
|
||||
title: 'MF Doomcast',
|
||||
description: 'The podcast about the things you find on a picnic, or at a picnic table',
|
||||
xmlns: {
|
||||
itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd',
|
||||
content: 'http://purl.org/rss/1.0/modules/content/',
|
||||
},
|
||||
customData: `<language>en-us</language>` +
|
||||
`<itunes:author>MF Doom</itunes:author>`,
|
||||
item: (item) => ({
|
||||
title: item.title,
|
||||
link: item.url,
|
||||
description: item.description,
|
||||
pubDate: item.pubDate + 'Z',
|
||||
customData: `<itunes:episodeType>${item.type}</itunes:episodeType>` +
|
||||
`<itunes:duration>${item.duration}</itunes:duration>` +
|
||||
`<itunes:explicit>${item.explicit || false}</itunes:explicit>`,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { episodes } = Astro.props;
|
||||
---
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<title>Podcast Episodes</title>
|
||||
<link rel="alternate" type="application/rss+2.0" href="/feed/episodes.xml" />
|
||||
</head>
|
||||
<body>
|
||||
{episodes.data.map((ep) => (<li>{ep.title}</li>))}
|
||||
</body>
|
||||
</html>
|
39
packages/astro/test/fixtures/astro-rss/src/pages/episodes/[...page].astro
vendored
Normal file
39
packages/astro/test/fixtures/astro-rss/src/pages/episodes/[...page].astro
vendored
Normal file
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
|
||||
export function getStaticPaths({paginate, rss}) {
|
||||
const episodes = Astro.fetchContent('../episode/*.md').sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate));
|
||||
rss({
|
||||
title: 'MF Doomcast',
|
||||
description: 'The podcast about the things you find on a picnic, or at a picnic table',
|
||||
xmlns: {
|
||||
itunes: 'http://www.itunes.com/dtds/podcast-1.0.dtd',
|
||||
content: 'http://purl.org/rss/1.0/modules/content/',
|
||||
},
|
||||
customData: `<language>en-us</language>` +
|
||||
`<itunes:author>MF Doom</itunes:author>`,
|
||||
items: episodes.map((item) => ({
|
||||
title: item.title,
|
||||
link: item.url,
|
||||
description: item.description,
|
||||
pubDate: item.pubDate + 'Z',
|
||||
customData: `<itunes:episodeType>${item.type}</itunes:episodeType>` +
|
||||
`<itunes:duration>${item.duration}</itunes:duration>` +
|
||||
`<itunes:explicit>${item.explicit || false}</itunes:explicit>`,
|
||||
})),
|
||||
dest: '/custom/feed.xml',
|
||||
});
|
||||
return paginate(episodes);
|
||||
}
|
||||
|
||||
const { page } = Astro.props;
|
||||
---
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<title>Podcast Episodes</title>
|
||||
<link rel="alternate" type="application/rss+2.0" href="/rss.xml" />
|
||||
</head>
|
||||
<body>
|
||||
{page.data.map((ep) => (<li>{ep.title}</li>))}
|
||||
</body>
|
||||
</html>
|
0
packages/astro/test/fixtures/route-manifest/basic/about.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/basic/about.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/basic/blog/[slug].astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/basic/blog/[slug].astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/basic/blog/index.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/basic/blog/index.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/basic/index.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/basic/index.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/encoding/#.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/encoding/#.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/hidden-dot/.unknown/foo.txt.js
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/hidden-dot/.unknown/foo.txt.js
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/hidden-dot/.well-known/dnt-policy.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/hidden-dot/.well-known/dnt-policy.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/hidden-underscore/_foo.js
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/hidden-underscore/_foo.js
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/hidden-underscore/a/_b/c/d.js
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/hidden-underscore/a/_b/c/d.js
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/hidden-underscore/e/f/g/h.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/hidden-underscore/e/f/g/h.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/hidden-underscore/i/_j.js
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/hidden-underscore/i/_j.js
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/hidden-underscore/index.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/hidden-underscore/index.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/invalid-params/[foo][bar].astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/invalid-params/[foo][bar].astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/invalid-rest/foo-[...rest]-bar.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/invalid-rest/foo-[...rest]-bar.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/lockfiles/foo.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/lockfiles/foo.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/lockfiles/foo.astro_tmp
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/lockfiles/foo.astro_tmp
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/multiple-slugs/[file].[ext].astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/multiple-slugs/[file].[ext].astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/[...rest]/abc.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/[...rest]/abc.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/[...rest]/deep/[...deep_rest]/xyz.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/[...rest]/deep/[...deep_rest]/xyz.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/[...rest]/deep/index.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/[...rest]/deep/index.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/[...rest]/index.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/[...rest]/index.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/[wildcard].astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/[wildcard].astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/_layout.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/_layout.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/about.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/about.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/index.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/index.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/post/[id].astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/post/[id].astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/post/_default.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/post/_default.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/post/bar.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/post/bar.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/post/f[xx].astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/post/f[xx].astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/post/f[yy].astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/post/f[yy].astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/post/foo.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/post/foo.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/post/index.astro
vendored
Normal file
0
packages/astro/test/fixtures/route-manifest/sorting/post/index.astro
vendored
Normal file
|
@ -34,7 +34,7 @@ export function setup(Suite, fixturePath, { runtimeOptions = {} } = {}) {
|
|||
const astroConfig = await loadConfig(fileURLToPath(new URL(fixturePath, import.meta.url)));
|
||||
|
||||
runtime = await createRuntime(astroConfig, {
|
||||
logging: { level: 'error', dest: process.stderr },
|
||||
logging: { level: 'error' },
|
||||
...runtimeOptions,
|
||||
}).catch((err) => {
|
||||
createRuntimeError = err;
|
||||
|
@ -83,7 +83,7 @@ export function setupBuild(Suite, fixturePath) {
|
|||
|
||||
const astroConfig = await loadConfig(fileURLToPath(new URL(fixturePath, import.meta.url)));
|
||||
|
||||
context.build = () => astroBuild(astroConfig, { level: 'error', dest: process.stderr });
|
||||
context.build = () => astroBuild(astroConfig, { level: 'error' });
|
||||
context.readFile = async (path) => {
|
||||
const resolved = fileURLToPath(new URL(`${fixturePath}/${astroConfig.dist}${path}`, import.meta.url));
|
||||
return readFileSync(resolved, { encoding: 'utf8' });
|
||||
|
|
166
packages/astro/test/route-manifest.test.js
Normal file
166
packages/astro/test/route-manifest.test.js
Normal file
|
@ -0,0 +1,166 @@
|
|||
import { fileURLToPath } from 'url';
|
||||
import { test } from 'uvu';
|
||||
import * as assert from 'uvu/assert';
|
||||
import { createManifest } from '../dist/manifest/create.js';
|
||||
|
||||
const cwd = new URL('./fixtures/route-manifest/', import.meta.url);
|
||||
|
||||
/**
|
||||
* @param {string} dir
|
||||
* @param {string[]} [extensions]
|
||||
* @returns
|
||||
*/
|
||||
const create = (dir) => {
|
||||
return createManifest({
|
||||
config: {
|
||||
projectRoot: cwd,
|
||||
pages: new URL(dir, cwd),
|
||||
},
|
||||
cwd: fileURLToPath(cwd),
|
||||
});
|
||||
};
|
||||
function cleanRoutes(routes) {
|
||||
return routes.map((r) => {
|
||||
delete r.generate;
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
test('creates routes', () => {
|
||||
const { routes } = create('basic');
|
||||
assert.equal(cleanRoutes(routes), [
|
||||
{
|
||||
type: 'page',
|
||||
pattern: /^\/$/,
|
||||
params: [],
|
||||
component: 'basic/index.astro',
|
||||
path: '/',
|
||||
},
|
||||
|
||||
{
|
||||
type: 'page',
|
||||
pattern: /^\/about\/?$/,
|
||||
params: [],
|
||||
component: 'basic/about.astro',
|
||||
path: '/about',
|
||||
},
|
||||
|
||||
{
|
||||
type: 'page',
|
||||
pattern: /^\/blog\/?$/,
|
||||
params: [],
|
||||
component: 'basic/blog/index.astro',
|
||||
path: '/blog',
|
||||
},
|
||||
|
||||
{
|
||||
type: 'page',
|
||||
pattern: /^\/blog\/([^/]+?)\/?$/,
|
||||
params: ['slug'],
|
||||
component: 'basic/blog/[slug].astro',
|
||||
path: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('encodes invalid characters', () => {
|
||||
const { routes } = create('encoding');
|
||||
|
||||
// had to remove ? and " because windows
|
||||
|
||||
// const quote = 'encoding/".astro';
|
||||
const hash = 'encoding/#.astro';
|
||||
// const question_mark = 'encoding/?.astro';
|
||||
|
||||
assert.equal(
|
||||
routes.map((p) => p.pattern),
|
||||
[
|
||||
// /^\/%22$/,
|
||||
/^\/%23\/?$/,
|
||||
// /^\/%3F$/
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores files and directories with leading underscores', () => {
|
||||
const { routes } = create('hidden-underscore');
|
||||
|
||||
assert.equal(routes.map((r) => r.component).filter(Boolean), ['hidden-underscore/index.astro', 'hidden-underscore/e/f/g/h.astro']);
|
||||
});
|
||||
|
||||
test('ignores files and directories with leading dots except .well-known', () => {
|
||||
const { routes } = create('hidden-dot');
|
||||
|
||||
assert.equal(routes.map((r) => r.component).filter(Boolean), ['hidden-dot/.well-known/dnt-policy.astro']);
|
||||
});
|
||||
|
||||
test('fails if dynamic params are not separated', () => {
|
||||
assert.throws(() => {
|
||||
create('invalid-params');
|
||||
}, /Invalid route invalid-params\/\[foo\]\[bar\]\.astro — parameters must be separated/);
|
||||
});
|
||||
|
||||
test('disallows rest parameters inside segments', () => {
|
||||
assert.throws(
|
||||
() => {
|
||||
create('invalid-rest');
|
||||
},
|
||||
/** @param {Error} e */
|
||||
(e) => {
|
||||
return e.message === 'Invalid route invalid-rest/foo-[...rest]-bar.astro — rest parameter must be a standalone segment';
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores things that look like lockfiles', () => {
|
||||
const { routes } = create('lockfiles');
|
||||
assert.equal(cleanRoutes(routes), [
|
||||
{
|
||||
type: 'page',
|
||||
pattern: /^\/foo\/?$/,
|
||||
params: [],
|
||||
component: 'lockfiles/foo.astro',
|
||||
path: '/foo',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('allows multiple slugs', () => {
|
||||
const { routes } = create('multiple-slugs');
|
||||
|
||||
assert.equal(cleanRoutes(routes), [
|
||||
{
|
||||
type: 'page',
|
||||
pattern: /^\/([^/]+?)\.([^/]+?)\/?$/,
|
||||
component: 'multiple-slugs/[file].[ext].astro',
|
||||
params: ['file', 'ext'],
|
||||
path: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('sorts routes correctly', () => {
|
||||
const { routes } = create('sorting');
|
||||
|
||||
assert.equal(
|
||||
routes.map((p) => p.component),
|
||||
[
|
||||
'sorting/index.astro',
|
||||
'sorting/about.astro',
|
||||
'sorting/post/index.astro',
|
||||
'sorting/post/bar.astro',
|
||||
'sorting/post/foo.astro',
|
||||
'sorting/post/f[xx].astro',
|
||||
'sorting/post/f[yy].astro',
|
||||
'sorting/post/[id].astro',
|
||||
'sorting/[wildcard].astro',
|
||||
'sorting/[...rest]/deep/[...deep_rest]/xyz.astro',
|
||||
'sorting/[...rest]/deep/[...deep_rest]/index.astro',
|
||||
'sorting/[...rest]/deep/index.astro',
|
||||
'sorting/[...rest]/abc.astro',
|
||||
'sorting/[...rest]/index.astro',
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test.run();
|
Loading…
Reference in a new issue