New Collections API (#703)

* updated createCollection API

* Update examples/portfolio/src/pages/projects.astro

Co-authored-by: Caleb Jasik <calebjasik@jasik.xyz>

* Update docs/reference/api-reference.md

Co-authored-by: Caleb Jasik <calebjasik@jasik.xyz>

* fix(docs): collection doc typos (#758)

* keep cleaning up docs and adding tests

Co-authored-by: Caleb Jasik <calebjasik@jasik.xyz>
Co-authored-by: Mark Pinero <markspinero@gmail.com>
This commit is contained in:
Fred K. Schott 2021-07-21 07:11:57 -07:00 committed by GitHub
parent 5fcd466d95
commit f67e8f5f55
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 713 additions and 732 deletions

View file

@ -0,0 +1,9 @@
---
'astro': minor
---
New Collections API (createCollection)
[BREAKING CHANGE:] The expected return format from createCollection() has been changed. Visit https://docs.astro.build/core-concepts/collections to learn the new API.
This feature was implemented with backwards-compatible deprecation warnings, to help you find and update pages that are using the legacy API.

View file

@ -3,60 +3,191 @@ layout: ~/layouts/Main.astro
title: Collections
---
**Collections** are a special type of [Page](/core-concepts/astro-pages) that help you generate multiple pages from a larger set of data. Example use-cases include:
**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.
- Pagination: `/posts/1`, `/posts/2`, etc.
- Grouping content by author: `/author/fred`, `/author/matthew`, etc.
- Grouping content by some tag: `/tags/red`, `/tags/blue`, etc.
- Working with remote data
- Mixing remote and local data
Example use-cases include:
**Use a Collection when you need to generate multiple pages from a single template.** If you just want to generate a single page (ex: a long list of every post on your site) then you can just fetch that data on a normal Astro page without using the Collection API.
- 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 must do three things:
To create a new Astro Collection, you need to do two things:
1. Create a new file in the `src/pages` directory that starts with the `$` symbol. This is required to enable the Collections API.
### 1. Create the File
- Example: `src/pages/$posts.astro` -> `/posts/1`, `/posts/2`, etc.
- Example: `src/pages/$tags.astro` -> `/tags/:tag` (or `/tags/:tag/1`)
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.
2. Define and export the `collection` prop: `collection.data` is how you'll access the data for every page in the collection. Astro populates this prop for you automatically. It MUST be named `collection` and it must be exported.
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: `const { collection } = Astro.props;`
- **Example**: `src/pages/$tags.astro` -> `/tags/:tag`
- **Example**: `src/pages/$posts.astro` -> `/posts/1`, `/posts/2`, etc.
3. Define and export `createCollection` function: this tells Astro how to load and structure your collection data. Check out the examples below for documentation on how it should be implemented. It MUST be named `createCollection` and it must be exported.
### 2. Export createCollection
- Example: `export async function createCollection() { /* ... */ }`
- API Reference: [createCollection](/reference/api-reference#collections-api)
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.
## Example: Simple Pagination
```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
---
// Define the `collection` prop.
const { collection } = Astro.props;
// 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() {
const allPosts = Astro.fetchContent('../posts/*.md'); // fetch local posts.
allPosts.sort((a, b) => a.title.localeCompare(b.title)); // sort by title.
// 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 {
// Because you are not doing anything more than simple pagination,
// its fine to just return the full set of posts for the collection data.
async data() { return allPosts; },
// number of posts loaded per page (default: 25)
pageSize: 10,
// `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>Pagination Example: Page Number {collection.page.current}</title>
<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>
{collection.data.map((post) => (
{posts.data.map((post) => (
<h1>{post.title}</h1>
<time>{formatDate(post.published_at)}</time>
<a href={post.url}>Read Post</a>
@ -67,144 +198,83 @@ export async function createCollection() {
## 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
---
// In addition to `collection.data` usage illustrated above, the `collection`
// prop also provides some important metadata for you to use, like: `collection.page`,
// `collection.url`, `collection.start`, `collection.end`, and `collection.total`.
// In this example, we'll use these values to do pagination in the template.
const { collection } = Astro.props;
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 {collection.page.current}</title>
<link rel="canonical" href={collection.url.current} />
<link rel="prev" href={collection.url.prev} />
<link rel="next" href={collection.url.next} />
<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 {collection.start + 1}{collection.end + 1} of {collection.total}</h5>
{collection.data.map((post) => (
<h1>{post.title}</h1>
<time>{formatDate(post.published_at)}</time>
<a href={post.url}>Read Post</a>
))}
<h5>Results {posts.start + 1}{posts.end + 1} of {posts.total}</h5>
</main>
<footer>
<h4>Page {collection.page.current} / {collection.page.last}</h4>
<h4>Page {posts.page.current} / {posts.page.last}</h4>
<nav class="nav">
<a class="prev" href={collection.url.prev || '#'}>Prev</a>
<a class="next" href={collection.url.next || '#'}>Next</a>
<a class="prev" href={posts.url.prev || '#'}>Prev</a>
<a class="next" href={posts.url.next || '#'}>Next</a>
</nav>
</footer>
</body>
</html>
```
## Example: Grouping Content by Tag, Author, etc.
## 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
---
// Define the `collection` prop.
const { collection } = Astro.props;
// Define a `createCollection` function.
// In this example, we'll customize the URLs that we generate to
// create a new page to group every pokemon by first letter of their name.
export async function createCollection() {
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 {
// `routes` defines the total collection of routes as `params` data objects.
// In this example, we format each letter (ex: "a") to params (ex: {letter: "a"}).
routes: allLetters.map(letter => {
const params = {letter};
return params;
}),
// `permalink` defines the final URL for each route object defined in `routes`.
// It should always match the file location (ex: `src/pages/$pokemon.astro`).
permalink: ({ params }) => `/pokemon/${params.letter}`,
// `data` is now responsible for return the data for each page.
// Luckily we had already loaded all of the data at the top of the function,
// so we just filter the data here to group pages by first letter.
// If you needed to fetch more data for each page, you can do that here as well.
async data({ params }) {
return allPokemon.filter((pokemon) => pokemon.name[0] === params.letter);
paginate: true,
route: '/posts/:page?',
async props({paginate}) { /* Not shown: see examples above */ },
rss: {
title: 'My RSS Feed',
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 itll be different based on where its built)
pubDate: item.pubDate + 'Z',
// custom data is supported here as well
}),
},
// Finally, `pageSize` and `pagination` is still on by default. Because
// we don't want to paginate the already-grouped pages a second time, we'll
// disable pagination.
pageSize: Infinity,
};
}
---
<html lang="en">
<head>
<title>Pokemon: {collection.params.letter}</head>
<body>
{collection.data.map((pokemon) => (<h1>{pokemon.name}</h1>))}
</body>
</html>
```
## Example: Individual Pages from a Collection
Astro will generate your RSS feed at the URL `/feed/[collection].xml`. For example, `/src/pages/$podcast.astro` would generate URL `/feed/podcast.xml`.
**Note**: collection.data and .params are being fetched async, use optional chaining or some other way of handling this in template. Otherwise you will get build errors.
Even though Astro will create the RSS feed for you, youll still need to add `<link>` tags manually in your `<head>` HTML for feed readers and browsers to pick up:
```jsx
---
// Define the `collection` prop.
const { collection } = Astro.props;
// Define a `createCollection` function.
// In this example, we'll create a new page for every single pokemon.
export async function createCollection() {
const allPokemonResponse = await fetch(`https://pokeapi.co/api/v2/pokemon?limit=150`);
const allPokemonResult = await allPokemonResponse.json();
const allPokemon = allPokemonResult.results;
return {
// `routes` defines the total collection of routes as data objects.
routes: allPokemon.map((pokemon, i) => {
const params = {name: pokemon.name, index: i};
return params;
}),
// `permalink` defines the final URL for each route object defined in `routes`.
permalink: ({ params }) => `/pokemon/${params.name}`,
// `data` is now responsible for return the data for each page.
// Luckily we had already loaded all of the data at the top of the function,
// so we just filter the data here to group pages by first letter.
// If you needed to fetch more data for each page, you can do that here as well.
// Note: data() is expected to return an array!
async data({ params }) {
return [allPokemon[params.index]];
},
// Note: The default pageSize is fine because technically only one data object
// is ever returned per route. We set it to Infinity in this example for completeness.
pageSize: Infinity,
};
}
---
<html lang="en">
<head>
<title>Pokemon: {collection.params?.name}</title>
</head>
<body>
Who's that pokemon? It's {collection.data[0]?.name}!
</body>
</html>
```html
<link rel="alternate" type="application/rss+xml" title="My RSS Feed" href="/feed/podcast.xml" />
```
## Tips
- If you find yourself duplicating markup across many pages and collections, you should probably be using more reusable components.
### 📚 Further Reading
- [Fetching data in Astro](/guides/data-fetching)
- API Reference: [collection](/reference/api-reference#collections-api)
- API Reference: [createCollection()](/reference/api-reference#createcollection)
- API Reference: [Creating an RSS feed](/reference/api-reference#rss-feed)
- API Reference: [createCollection() > Pagination](/reference/api-reference#pagination)
- API Reference: [createCollection() > RSS](/reference/api-reference#rss)

View file

@ -66,106 +66,112 @@ const data = Astro.fetchContent('../pages/post/*.md'); // returns an array of po
## Collections API
### `collection` prop
```jsx
const { collection } = Astro.props;
```
When using the [Collections API](/core-concepts/collections), `collection` is a prop exposed to the page with the following shape:
| Name | Type | Description |
| :------------------------ | :-------------------: | :-------------------------------------------------------------------------------------------------------------------------------- |
| `collection.data` | `Array` | Array of data returned from `data()` for the current page. |
| `collection.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.). |
| `collection.end` | `number` | Index of last item on current page. |
| `collection.total` | `number` | The total number of items across all pages. |
| `collection.page.current` | `number` | The current page number, starting with `1`. |
| `collection.page.size` | `number` | How many items per-page. |
| `collection.page.last` | `number` | The total number of pages. |
| `collection.url.current` | `string` | Get the URL of the current page (useful for canonical URLs) |
| `collection.url.prev` | `string \| undefined` | Get the URL of the previous page (will be `undefined` if on page 1). |
| `collection.url.next` | `string \| undefined` | Get the URL of the next page (will be `undefined` if no more pages). |
| `collection.params` | `object` | If page params were used, this returns a `{ key: value }` object of all values. |
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.
Check out our [Astro Collections](/core-concepts/collections) guide for more information and examples.
### `createCollection()`
```jsx
---
export async function createCollection() {
return {
async data({ params }) {
// load data
},
pageSize: 25,
routes: [{ tag: 'movie' }, { tag: 'television' }],
permalink: ({ params }) => `/tag/${params.tag}`,
return { /* ... */ };
}
---
<!-- Your HTML template here. -->
```
⚠️ The `createCollection()` function executes in its own isolated scope before page loads. Therefore you cant reference anything from its parent scope, other than file imports. The compiler will warn if you break this requirement.
The `createCollection()` function should returns an object of the following shape:
| 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)) |
### Pagination
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.
The `paginate()` function that you use inside of `props()` has the following interface:
```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;
/* the paginated return value, aka the prop passed to every page in the collection. */
interface PaginatedCollectionResult {
/** result */
data: any[];
/** 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;
};
}
```
When using the [Collections API](/core-concepts/collections), `createCollection()` is an async function that returns an object of the following shape:
📚 Learn more about pagination (and see an example) in our [Astro Collections guide.](/core-concepts/collections).
| Name | Type | Description |
| :---------- | :--------------------------------------: | :--------------------------------------------------------------------------------------------------------- |
| `data` | `async ({ params }) => any[]` | **Required.** Load an array of data with this function to be returned. |
| `pageSize` | `number` | Specify number of items per page (default: `25`). |
| `routes` | `params[]` | **Required for URL Params.** Return an array of all possible URL `param` values in `{ name: value }` form. |
| `permalink` | `({ params }) => string` | **Required for URL Params.** Given a `param` object of `{ name: value }`, generate the final URL.\* |
| `rss` | [RSS](/reference/api-reference#rss-feed) | Optional: generate an RSS 2.0 feed from this collection ([docs](/reference/api-reference#rss-feed)) |
### RSS
_\* Note: dont create confusing URLs with `permalink`, e.g. rearranging params conditionally based on their values._
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.
⚠️ `createCollection()` executes in its own isolated scope before page loads. Therefore you cant reference anything from its parent scope. If you need to load data you may fetch or use async `import()`s within the function body for anything you need (thats why its `async`—to give you this ability). If it wasnt isolated, then `collection` would be undefined! Therefore, duplicating imports between `createCollection()` and your Astro component is OK.
The `rss` object follows the `CollectionRSS`data type:
#### RSS Feed
You can optionally generate an RSS 2.0 feed from `createCollection()` by adding an `rss` option. Here are all the options:
```jsx
export async function createCollection() {
return {
async data({ params }) {
// load data
},
pageSize: 25,
rss: {
title: 'My RSS Feed',
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 item from things returned in data() */
item: (item) => ({
title: item.title,
description: item.description,
pubDate: item.pubDate + 'Z', // enforce GMT timezone (otherwise itll be different based on where its built)
/** (optional) add arbitrary XML to each <item> */
customData: `<itunes:episodeType>${item.type}</itunes:episodeType>
<itunes:duration>${item.duration}</itunes:duration>
<itunes:explicit>${item.explicit || false}</itunes:explicit>`,
}),
},
```ts
export interface CollectionRSS<T = any> {
/** (required) Title of the RSS Feed */
title: string;
/** (required) Description of the RSS Feed */
description: string;
/** Specify arbitrary metadata on opening <xml> tag */
xmlns?: Record<string, string>;
/** Specify custom data in opening of file */
customData?: string;
/** Return data about each item */
item: (item: T) => {
/** (required) Title of item */
title: string;
/** (required) Link to item */
link: string;
/** Publication date of item */
pubDate?: Date;
/** Item description */
description?: string;
/** Append some other XML-valid data to this item */
customData?: string;
};
}
```
Astro will generate an RSS 2.0 feed at `/feed/[collection].xml` (for example, `/src/pages/$podcast.xml` would generate `/feed/podcast.xml`).
📚 Learn more about RSS feed generation (and see an example) in our [Astro Collections guide.](/core-concepts/collections).
⚠️ Even though Astro will create the RSS feed for you, youll still need to add `<link>` tags manually in your `<head>` HTML:
```html
<link
rel="alternate"
type="application/rss+xml"
title="My RSS Feed"
href="/feed/podcast.xml"
/>
```
## `import.meta`

View file

@ -12,37 +12,32 @@ let canonicalURL = Astro.request.canonicalURL;
// collection
import authorData from '../data/authors.json';
let { collection } = Astro.props;
export async function createCollection() {
export function createCollection() {
/** Load posts */
let allPosts = Astro.fetchContent('./post/*.md');
let allAuthors = new Set();
/** Loop through all posts, gather all authors */
let routes = [];
for (const post of allPosts) {
if (!allAuthors.has(post.author)) {
allAuthors.add(post.author);
routes.push({ author: post.author });
}
}
return {
/** Sort posts newest -> oldest, filter by params.author */
async data({ params }) {
allPosts.sort((a, b) => new Date(b.date) - new Date(a.date));
return allPosts.filter((post) => post.author === params.author);
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}),
}
},
/** Set page size */
pageSize: 3,
/** Set permalink URL */
permalink: ({ params }) => `/author/${params.author}`,
/** Pass back all routes so Astro can generate the static build */
routes,
};
}
const author = authorData[collection.params.author];
const { posts } = Astro.props;
const { params } = Astro.request;
const author = authorData[posts.data[0].author];
---
<html lang="en">
@ -51,10 +46,10 @@ const author = authorData[collection.params.author];
<MainHead
title={title}
description={description}
image={collection.data[0].image}
image={posts.data[0].image}
canonicalURL={canonicalURL}
prev={collection.url.prev}
next={collection.url.next}
prev={posts.url.prev}
next={posts.url.next}
/>
<style lang="scss">
@ -99,12 +94,12 @@ const author = authorData[collection.params.author];
<div class="avatar"><img class="avatar-img" src={author.image} alt=""}></div>
{author.name}
</h2>
<small class="count">{collection.start + 1}{collection.end + 1} of {collection.total}</small>
{collection.data.map((post) => <PostPreview post={post} author={author} />)}
<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={collection.url.prev} nextUrl={collection.url.next} />
<Pagination prevUrl={posts.url.prev} nextUrl={posts.url.next} />
</footer>
</body>
</html>

View file

@ -11,18 +11,18 @@ let canonicalURL = Astro.request.canonicalURL;
// collection
import authorData from '../data/authors.json';
let { collection } = Astro.props;
export async function createCollection() {
export function createCollection() {
return {
/** Load posts, sort newest -> oldest */
async data() {
let allPosts = Astro.fetchContent('./post/*.md');
allPosts.sort((a, b) => new Date(b.date) - new Date(a.date));
return allPosts;
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}),
}
},
/** Set page size */
pageSize: 2,
/** Generate RSS feed (only for main /posts/ feed) */
rss: {
title: 'Dons Blog',
description: 'An example blog on Astro',
@ -36,6 +36,8 @@ export async function createCollection() {
}
};
}
const { posts } = Astro.props;
---
<html lang="en">
@ -44,10 +46,10 @@ export async function createCollection() {
<MainHead
title={title}
description={description}
image={collection.data[0].image}
image={posts.data[0].image}
canonicalURL={canonicalURL}
prev={collection.url.prev}
next={collection.url.next}
prev={posts.url.prev}
next={posts.url.next}
/>
<style lang="scss">
@ -72,12 +74,12 @@ export async function createCollection() {
<main class="wrapper">
<h2 class="title">All Posts</h2>
<small class="count">{collection.start + 1}{collection.end + 1} of {collection.total}</small>
{collection.data.map((post) => <PostPreview post={post} author={authorData[post.author]} />)}
<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={collection.url.prev} nextUrl={collection.url.next} />
<Pagination prevUrl={posts.url.prev} nextUrl={posts.url.next} />
</footer>
</body>
</html>

View file

@ -27,7 +27,7 @@ let firstPage = allPosts.slice(0, 2);
<MainHead
title={title}
description={description}
image={firstPage[0].image}
image={allPosts[0].image}
canonicalURL={Astro.request.canonicalURL.href}
/>
</head>
@ -36,7 +36,7 @@ let firstPage = allPosts.slice(0, 2);
<Nav />
<main class="wrapper">
{firstPage.map((post) => <PostPreview post={post} author={authorData[post.author]} />)}
{allPosts.map((post) => <PostPreview post={post} author={authorData[post.author]} />)}
</main>
<footer>

View file

@ -4,16 +4,9 @@ import Footer from '../components/Footer/index.jsx';
import Nav from '../components/Nav/index.jsx';
import PortfolioPreview from '../components/PortfolioPreview/index.jsx';
let { collection } = Astro.props;
export async function createCollection() {
return {
async data() {
const projects = Astro.fetchContent('./project/*.md');
projects.sort((a, b) => new Date(b.published_at) - new Date(a.published_at));
return projects.filter(({ published_at }) => !!published_at);
}
}
}
const projects = Astro.fetchContent('./project/*.md')
.filter(({ published_at }) => !!published_at)
.sort((a, b) => new Date(b.published_at) - new Date(a.published_at));
---
<html lang="en">
@ -31,7 +24,7 @@ export async function createCollection() {
<div class="wrapper">
<h1 class="title mt4 mb4">All Projects</h1>
<div class="grid">
{collection.data.map((project) => (
{projects.map((project) => (
<PortfolioPreview project={project} />
))}
</div>

View file

@ -77,6 +77,7 @@
"mime": "^2.5.2",
"moize": "^6.0.1",
"node-fetch": "^2.6.1",
"path-to-regexp": "^6.2.0",
"picomatch": "^2.2.3",
"postcss": "^8.2.15",
"postcss-icss-keyframes": "^0.2.1",

View file

@ -75,7 +75,7 @@ export interface CompileResult {
export type RuntimeMode = 'development' | 'production';
export type Params = Record<string, string | number>;
export type Params = Record<string, string>;
/** Entire output of `astro build`, stored in memory */
export interface BuildOutput {
@ -107,15 +107,14 @@ export interface PageDependencies {
images: Set<string>;
}
export interface CreateCollection<T = any> {
data: ({ params }: { params: Params }) => T[];
routes?: Params[];
/** tool for generating current page URL */
permalink?: ({ params }: { params: Params }) => string;
/** page size */
pageSize?: number;
/** Generate RSS feed from data() */
rss?: CollectionRSS<T>;
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> {
@ -142,7 +141,7 @@ export interface CollectionRSS<T = any> {
};
}
export interface CollectionResult<T = any> {
export interface PaginatedCollectionResult<T = any> {
/** result */
data: T[];
@ -165,12 +164,10 @@ export interface CollectionResult<T = any> {
/** url of the current page */
current: string;
/** url of the previous page (if there is one) */
prev?: string;
prev: string | undefined;
/** url of the next page (if there is one) */
next?: string;
next: string | undefined;
};
/** Matched parameters, if any */
params: Params;
}
export interface ComponentInfo {

View file

@ -59,7 +59,7 @@ export async function build(astroConfig: AstroConfig, logging: LogOptions = defa
const mode: RuntimeMode = 'production';
const runtime = await createRuntime(astroConfig, { mode, logging: runtimeLogging });
const { runtimeConfig } = runtime;
const { snowpack } = runtimeConfig;
const { snowpackRuntime } = runtimeConfig;
try {
// 0. erase build directory
@ -82,8 +82,8 @@ export async function build(astroConfig: AstroConfig, logging: LogOptions = defa
filepath,
logging,
mode,
resolvePackageUrl: (pkgName: string) => snowpack.getUrlForPackage(pkgName),
runtime,
snowpackRuntime,
astroRuntime: runtime,
site: astroConfig.buildOptions.site,
});
})
@ -106,7 +106,7 @@ ${stack}
`
);
} else {
error(logging, 'build', e);
error(logging, 'build', e.message);
}
error(logging, 'build', red('✕ building pages failed!'));
@ -249,7 +249,7 @@ ${stack}
info(logging, 'build', bold(green('▶ Build Complete!')));
return 0;
} catch (err) {
error(logging, 'build', err);
error(logging, 'build', err.message);
await runtime.shutdown();
return 1;
}

View file

@ -1,9 +1,12 @@
import type { AstroConfig, BuildOutput, RuntimeMode } from '../@types/astro';
import type { AstroRuntime, LoadResult } from '../runtime';
import type { LogOptions } from '../logger';
import path from 'path';
import { generateRSS } from './rss.js';
import { compile as compilePathToRegexp } from 'path-to-regexp';
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';
interface PageBuildOptions {
astroConfig: AstroConfig;
@ -11,8 +14,8 @@ interface PageBuildOptions {
logging: LogOptions;
filepath: URL;
mode: RuntimeMode;
resolvePackageUrl: (s: string) => Promise<string>;
runtime: AstroRuntime;
snowpackRuntime: SnowpackServerRuntime;
astroRuntime: AstroRuntime;
site?: string;
}
@ -23,18 +26,30 @@ export function getPageType(filepath: URL): 'collection' | 'static' {
}
/** Build collection */
export async function buildCollectionPage({ astroConfig, filepath, runtime, site, buildState }: PageBuildOptions): Promise<void> {
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 outURL = srcURL.replace(/\$([^.]+)\.astro$/, '$1');
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));
const builtURLs = new Set<string>(); // !important: internal cache that prevents building the same URLs
// Keep track of all files that have been built, to prevent duplicates.
const builtURLs = new Set<string>();
/** Recursively build collection URLs */
async function loadCollection(url: string): Promise<LoadResult | undefined> {
if (builtURLs.has(url)) return; // this stops us from recursively building the same pages over and over
const result = await runtime.load(url);
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] = {
@ -44,58 +59,48 @@ export async function buildCollectionPage({ astroConfig, filepath, runtime, site
encoding: 'utf8',
};
}
return result;
return { url, result };
}
const [result] = await Promise.all([
loadCollection(outURL) as Promise<LoadResult>, // first run will always return a result so assert type here
]);
if (result.statusCode >= 500) {
throw new Error((result as any).error);
}
if (result.statusCode === 200 && !result.collectionInfo) {
throw new Error(`[${srcURL}]: Collection page must export createCollection() function`);
}
// note: for pages that require params (/tag/:tag), we will get a 404 but will still get back collectionInfo that tell us what the URLs should be
if (result.collectionInfo) {
// build subsequent pages
await Promise.all(
[...result.collectionInfo.additionalURLs].map(async (url) => {
// for the top set of additional URLs, we render every new URL generated
const addlResult = await loadCollection(url);
builtURLs.add(url);
if (addlResult && addlResult.collectionInfo) {
// believe it or not, we may still have a few unbuilt pages left. this is our last crawl:
await Promise.all([...addlResult.collectionInfo.additionalURLs].map(async (url2) => loadCollection(url2)));
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`);
}
})
);
if (result.collectionInfo.rss) {
if (!site) throw new Error(`[${srcURL}] createCollection() tried to generate RSS but "buildOptions.site" missing in astro.config.mjs`);
let feedURL = outURL === '/' ? '/index' : outURL;
feedURL = '/feed' + feedURL + '.xml';
const rss = generateRSS({ ...(result.collectionInfo.rss as any), site }, { srcFile: srcURL, feedURL });
buildState[feedURL] = {
srcPath: filepath,
contents: rss,
contentType: 'application/rss+xml',
encoding: 'utf8',
};
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 static page */
export async function buildStaticPage({ astroConfig, buildState, filepath, runtime }: PageBuildOptions): Promise<void> {
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 runtime.load(url);
const result = await astroRuntime.load(url);
if (result.statusCode !== 200) {
let err = (result as any).error;
if (!(err instanceof Error)) err = new Error(err);

View file

@ -134,6 +134,8 @@ const __astro_element_registry = new AstroElementRegistry({
: ''
}
${result.createCollection || ''}
// \`__render()\`: Render the contents of the Astro module.
import { h, Fragment } from 'astro/dist/internal/h.js';
const __astroInternal = Symbol('astro.internal');
@ -151,8 +153,6 @@ async function __render(props, ...children) {
}
export default { isAstroComponent: true, __render };
${result.createCollection || ''}
// \`__renderPage()\`: Render the contents of the Astro module as a page. This is a special flow,
// triggered by loading a component directly by URL.
export async function __renderPage({request, children, props, css}) {

View file

@ -4,7 +4,6 @@
* result, by default.
*/
export function fetchContent(importMetaGlobResult: Record<string, any>, url: string) {
console.log(importMetaGlobResult);
return [...Object.entries(importMetaGlobResult)]
.map(([spec, mod]) => {
// Only return Markdown files, which export the __content object.

View file

@ -1,7 +1,7 @@
import type { LogOptions } from './logger';
import type { AstroConfig, CollectionResult, CollectionRSS, CreateCollection, Params, RuntimeMode } from './@types/astro';
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 { existsSync, promises as fs } from 'fs';
import { fileURLToPath } from 'url';
@ -19,12 +19,13 @@ import {
import parser from '@astrojs/parser';
const { CompileError } = parser;
import { canonicalURL, getSrcPath, stopTimer } from './build/util.js';
import { debug, info } from './logger.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';
interface RuntimeConfig {
astroConfig: AstroConfig;
@ -46,9 +47,10 @@ type LoadResultSuccess = {
statusCode: 200;
contents: string | Buffer;
contentType?: string | false;
collectionInfo?: CollectionInfo;
};
type LoadResultNotFound = { statusCode: 404; error: Error; collectionInfo?: CollectionInfo };
type LoadResultRedirect = { statusCode: 301 | 302; location: string; collectionInfo?: CollectionInfo };
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 }
@ -56,7 +58,7 @@ type LoadResultError = { statusCode: 500 } & (
| { type: 'unknown'; error: Error }
);
export type LoadResult = (LoadResultSuccess | LoadResultNotFound | LoadResultRedirect | LoadResultError) & { collectionInfo?: CollectionInfo };
export type LoadResult = LoadResultSuccess | LoadResultNotFound | LoadResultRedirect | LoadResultError;
// Disable snowpack from writing to stdout/err.
configureSnowpackLogger(snowpackLogger);
@ -72,7 +74,7 @@ async function load(config: RuntimeConfig, rawPathname: string | undefined): Pro
const reqPath = decodeURI(fullurl.pathname);
info(logging, 'access', reqPath);
const searchResult = searchForPage(fullurl, config.astroConfig);
const searchResult = await searchForPage(fullurl, config.astroConfig);
if (searchResult.statusCode === 404) {
try {
const result = await snowpack.loadUrl(reqPath);
@ -98,7 +100,8 @@ async function load(config: RuntimeConfig, rawPathname: string | undefined): Pro
}
const snowpackURL = searchResult.location.snowpackURL;
let rss: { data: any[] & CollectionRSS } = {} as any;
let collectionInfo: CollectionInfo | undefined;
let pageProps = {} as Record<string, any>;
try {
if (configManager.needsUpdate()) {
@ -107,116 +110,96 @@ async function load(config: RuntimeConfig, rawPathname: string | undefined): Pro
const mod = await snowpackRuntime.importModule(snowpackURL);
debug(logging, 'resolve', `${reqPath} -> ${snowpackURL}`);
// handle collection
let collection = {} as CollectionResult;
let additionalURLs = new Set<string>();
if (mod.exports.createCollection) {
const createCollection: CreateCollection = await mod.exports.createCollection();
const VALID_KEYS = new Set(['data', 'routes', 'permalink', 'pageSize', 'rss']);
for (const key of Object.keys(createCollection)) {
if (!VALID_KEYS.has(key)) {
throw new Error(`[createCollection] unknown option: "${key}". Expected one of ${[...VALID_KEYS].join(', ')}.`);
}
// 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})`);
}
let { data: loadData, routes, permalink, pageSize, rss: createRSS } = createCollection;
if (!loadData) throw new Error(`[createCollection] must return \`data()\` function to create a collection.`);
if (!pageSize) pageSize = 25; // cant be 0
let currentParams: Params = {};
// params
if (routes || permalink) {
if (!routes) throw new Error('[createCollection] `permalink` requires `routes` as well.');
if (!permalink) throw new Error('[createCollection] `routes` requires `permalink` as well.');
let requestedParams = routes.find((p) => {
const baseURL = (permalink as any)({ params: p });
additionalURLs.add(baseURL);
return baseURL === reqPath || `${baseURL}/${searchResult.currentPage || 1}` === reqPath;
});
if (requestedParams) {
currentParams = requestedParams;
collection.params = requestedParams;
}
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})`) };
}
let data: any[] = await loadData({ params: currentParams });
if (!data) throw new Error(`[createCollection] \`data()\` returned nothing (empty data)"`);
if (!Array.isArray(data)) data = [data]; // note: this is supposed to be a little friendlier to the user, but should we error out instead?
// handle RSS
if (createRSS) {
rss = {
...createRSS,
data: [...data] as any,
};
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})`);
}
collection.start = 0;
collection.end = data.length - 1;
collection.total = data.length;
collection.page = { current: 1, size: pageSize, last: 1 };
collection.url = { current: reqPath };
// paginate
if (searchResult.currentPage) {
const start = pageSize === Infinity ? 0 : (searchResult.currentPage - 1) * pageSize; // currentPage is 1-indexed
const end = Math.min(start + pageSize, data.length);
collection.start = start;
collection.end = end - 1;
collection.page.current = searchResult.currentPage;
collection.page.last = Math.ceil(data.length / pageSize);
// TODO: fix the .replace() hack
if (end < data.length) {
collection.url.next = collection.url.current.replace(/(\/\d+)?$/, `/${searchResult.currentPage + 1}`);
}
if (searchResult.currentPage > 1) {
collection.url.prev = collection.url.current
.replace(/\d+$/, `${searchResult.currentPage - 1 || 1}`) // update page #
.replace(/\/1$/, ''); // if end is `/1`, then just omit
}
// from page 2 to the end, add all pages as additional URLs (needed for build)
for (let n = 1; n <= collection.page.last; n++) {
if (additionalURLs.size) {
// if this is a param-based collection, paginate all params
additionalURLs.forEach((url) => {
additionalURLs.add(url.replace(/(\/\d+)?$/, `/${n}`));
});
} else {
// if this has no params, simply add page
additionalURLs.add(reqPath.replace(/(\/\d+)?$/, `/${n}`));
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;
}
}
data = data.slice(start, end);
} else if (createCollection.pageSize) {
// TODO: fix bug where redirect doesnt happen
// This happens because a pageSize is set, but the user isnt on a paginated route. Redirect:
return {
statusCode: 301,
location: reqPath + '/1',
collectionInfo: {
additionalURLs,
rss: rss.data ? rss : undefined,
},
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;
};
}
// if weve paginated too far, this is a 404
if (!data.length) {
return {
statusCode: 404,
error: new Error('Not Found'),
collectionInfo: {
additionalURLs,
rss: rss.data ? rss : undefined,
},
};
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})`) };
}
collection.data = data;
}
const requestURL = new URL(fullurl.toString());
@ -234,7 +217,7 @@ async function load(config: RuntimeConfig, rawPathname: string | undefined): Pro
canonicalURL: canonicalURL(requestURL.pathname, requestURL.origin),
},
children: [],
props: Object.keys(collection).length > 0 ? { collection } : {},
props: pageProps,
css: Array.isArray(mod.css) ? mod.css : typeof mod.css === 'string' ? [mod.css] : [],
})) as string;
@ -242,10 +225,7 @@ async function load(config: RuntimeConfig, rawPathname: string | undefined): Pro
statusCode: 200,
contentType: 'text/html; charset=utf-8',
contents: html,
collectionInfo: {
additionalURLs,
rss: rss.data ? rss : undefined,
},
collectionInfo,
};
} catch (err) {
if (err.code === 'parse-error' || err instanceof SyntaxError) {
@ -442,6 +422,11 @@ 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;

View file

@ -2,7 +2,8 @@ import type { AstroConfig } from './@types/astro';
import { existsSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import glob from 'tiny-glob/sync.js';
import glob from 'tiny-glob';
import slash from 'slash';
interface PageLocation {
fileURL: URL;
@ -28,7 +29,6 @@ type SearchResult =
statusCode: 200;
location: PageLocation;
pathname: string;
currentPage?: number;
}
| {
statusCode: 301;
@ -39,8 +39,14 @@ type SearchResult =
statusCode: 404;
};
/** Given a URL, attempt to locate its source file (similar to Snowpacks load()) */
export function searchForPage(url: URL, astroConfig: AstroConfig): SearchResult {
/**
* Given a URL, attempt to locate its source file (similar to Snowpacks 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);
@ -82,13 +88,12 @@ export function searchForPage(url: URL, astroConfig: AstroConfig): SearchResult
// Try and load collections (but only for non-extension files)
const hasExt = !!path.extname(reqPath);
if (!location && !hasExt) {
const collection = loadCollection(reqPath, astroConfig);
if (collection) {
const collectionLocation = await findCollectionPage(reqPath, astroConfig);
if (collectionLocation) {
return {
statusCode: 200,
location: collection.location,
location: collectionLocation,
pathname: reqPath,
currentPage: collection.currentPage || 1,
};
}
}
@ -109,31 +114,29 @@ export function searchForPage(url: URL, astroConfig: AstroConfig): SearchResult
};
}
/** load a collection route */
function loadCollection(url: string, astroConfig: AstroConfig): { currentPage?: number; location: PageLocation } | undefined {
const pages = glob('**/$*.astro', { cwd: fileURLToPath(astroConfig.pages), filesOnly: true });
for (const pageURL of pages) {
const reqURL = new RegExp('^/' + pageURL.replace(/\$([^/]+)\.astro/, '$1') + '(?:/(.*)|/?$)');
const match = url.match(reqURL);
if (match) {
let currentPage: number | undefined;
if (match[1]) {
const segments = match[1].split('/').filter((s) => !!s);
if (segments.length) {
const last = segments.pop() as string;
if (parseInt(last, 10)) {
currentPage = parseInt(last, 10);
}
}
}
const pagesPath = astroConfig.pages.pathname.replace(astroConfig.projectRoot.pathname, '');
return {
location: {
fileURL: new URL(`./${pageURL}`, astroConfig.pages),
snowpackURL: `/_astro/${pagesPath}${pageURL}.js`,
},
currentPage,
};
}
/** 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);
}

View file

@ -0,0 +1,30 @@
import type { CreateCollectionResult } from './@types/astro';
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}")`);
}
}
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})`);
}
}
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})`);
}
}
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})`);
}
}

View file

@ -7,31 +7,6 @@ const Collections = suite('Collections');
setup(Collections, './fixtures/astro-collection');
Collections('shallow selector (*.md)', async ({ runtime }) => {
const result = await runtime.load('/shallow');
assert.ok(!result.error, `build error: ${result.error}`);
const $ = doc(result.contents);
const urls = [
...$('#posts a').map(function () {
return $(this).attr('href');
}),
];
// assert they loaded in newest -> oldest order (not alphabetical)
assert.equal(urls, ['/post/three', '/post/two', '/post/one']);
});
Collections('deep selector (**/*.md)', async ({ runtime }) => {
const result = await runtime.load('/nested');
assert.ok(!result.error, `build error: ${result.error}`);
const $ = doc(result.contents);
const urls = [
...$('#posts a').map(function () {
return $(this).attr('href');
}),
];
assert.equal(urls, ['/post/nested/a', '/post/three', '/post/two', '/post/one']);
});
Collections('generates pagination successfully', async ({ runtime }) => {
const result = await runtime.load('/paginated');
assert.ok(!result.error, `build error: ${result.error}`);
@ -43,18 +18,16 @@ Collections('generates pagination successfully', async ({ runtime }) => {
});
Collections('can load remote data', async ({ runtime }) => {
const result = await runtime.load('/remote');
assert.ok(!result.error, `build error: ${result.error}`);
const $ = doc(result.contents);
const PACKAGES_TO_TEST = ['canvas-confetti', 'preact', 'svelte'];
for (const pkg of PACKAGES_TO_TEST) {
assert.ok($(`#pkg-${pkg}`).length);
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 grouped by author', async ({ runtime }) => {
Collections('generates pages based on params successfully', async ({ runtime }) => {
const AUTHORS_TO_TEST = [
{
id: 'author-one',
@ -83,44 +56,19 @@ Collections('generates pages grouped by author', async ({ runtime }) => {
}
});
Collections('generates individual pages from a collection', async ({ runtime }) => {
const PAGES_TO_TEST = [
{
slug: 'one',
title: 'Post One',
},
{
slug: 'two',
title: 'Post Two',
},
{
slug: 'three',
title: 'Post Three',
},
];
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);
for (const { slug, title } of PAGES_TO_TEST) {
const result = await runtime.load(`/individual/${slug}`);
assert.ok(!result.error, `build error: ${result.error}`);
const $ = doc(result.contents);
assert.ok($(`#${slug}`).length);
assert.equal($(`h1`).text(), title);
// 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('matches collection filename exactly', async ({ runtime }) => {
const result = await runtime.load('/individuals');
assert.ok(!result.error, `build error: ${result.error}`);
const $ = doc(result.contents);
assert.ok($('#posts').length);
const urls = [
...$('#posts a').map(function () {
return $(this).attr('href');
}),
];
assert.equal(urls, ['/post/nested/a', '/post/three', '/post/two', '/post/one']);
});
Collections.run();

View file

@ -21,7 +21,6 @@ Global('Astro.request.canonicalURL', async (context) => {
'/': 'https://mysite.dev/',
'/post/post': 'https://mysite.dev/post/post/',
'/posts': 'https://mysite.dev/posts/',
'/posts/1': 'https://mysite.dev/posts/', // should be the same as /posts
'/posts/2': 'https://mysite.dev/posts/2/',
};

View file

@ -1,30 +1,28 @@
---
const { collection } = Astro.props;
export async function createCollection() {
export function createCollection() {
const allPosts = Astro.fetchContent('./post/**/*.md');
const allAuthors = allPosts.map(p => p.author);
const uniqueAuthors = [...new Set(allAuthors)];
return {
routes: uniqueAuthors.map(author => {
const params = { name: author };
return params;
}),
permalink: ({ params }) => `/grouped/${params.name}`,
async data({ params }) {
return allPosts.filter(p => p.author === params.name)
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)
};
},
pageSize: Infinity
};
}
const { posts, name } = Astro.props;
---
<div id={collection.params.name}>
{collection.data.map((post) => (
<div id={name}>
{posts.map((post) => (
<a href={post.url}>{post.title}</a>
))}
</div>

View file

@ -1,29 +0,0 @@
---
const { collection } = Astro.props;
export async function createCollection() {
const allPosts = Astro.fetchContent('./post/*.md');
return {
routes: allPosts.map((post, i) => {
const params = {
slug: post.url.replace('/post/', ''),
index: i
};
return params;
}),
permalink: ({ params }) => `/individual/${params.slug}`,
async data({ params }) {
return [allPosts[params.index]];
},
pageSize: Infinity
};
}
---
<div id={collection.params.slug}>
<h1>{collection.data[0].title}</h1>
</div>

View file

@ -1,24 +0,0 @@
---
const { collection } = Astro.props;
export async function createCollection() {
return {
async data() {
let data = Astro.fetchContent('./post/**/*.md');
data.sort((a, b) => new Date(b.date) - new Date(a.date));
return data;
},
pageSize: 10
};
}
---
<div id="posts">
{collection.data.map((post) => (
<article>
<h1>{post.title}</h1>
<a href={post.url}>Read more</a>
</article>
))}
</div>

View file

@ -1,27 +0,0 @@
---
const { collection } = Astro.props;
export async function createCollection() {
return {
async data() {
let data = Astro.fetchContent('./post/**/*.md');
data.sort((a, b) => new Date(b.date) - new Date(a.date));
return data;
}
};
}
---
<div id="posts">
{collection.data.map((post) => (
<article>
<h1>{post.title}</h1>
<a href={post.url}>Read more</a>
</article>
))}
</div>
<nav>
{collection.url.prev && <a id="prev-page" href={collection.url.prev}>Previous page</a>}
{collection.url.next && <a id="next-page" href={collection.url.next}>Next page</a>}
</nav>

View file

@ -1,20 +1,22 @@
---
const { collection } = Astro.props;
export async function createCollection() {
export function createCollection() {
return {
async data() {
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 data;
},
pageSize: 1
return {posts: paginate(data, {pageSize: 1})};
}
};
}
const { posts } = Astro.props;
---
<div id="posts">
{collection.data.map((post) => (
{posts.data.map((post) => (
<article>
<h1>{post.title}</h1>
<a href={post.url}>Read more</a>
@ -23,6 +25,6 @@ export async function createCollection() {
</div>
<nav>
{collection.url.prev && <a id="prev-page" href={collection.url.prev}>Previous page</a>}
{collection.url.next && <a id="next-page" href={collection.url.next}>Next page</a>}
{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>

View file

@ -0,0 +1,33 @@
---
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>

View file

@ -1,24 +1,26 @@
---
const { collection } = Astro.props;
export async function createCollection() {
const data = await Promise.all([
fetch('https://api.skypack.dev/v1/package/canvas-confetti').then((res) => res.json()),
fetch('https://api.skypack.dev/v1/package/preact').then((res) => res.json()),
fetch('https://api.skypack.dev/v1/package/svelte').then((res) => res.json()),
]);
export function createCollection() {
return {
async data() {
return data;
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>
{collection.data.map((pkg) => (
<div id={`pkg-${pkg.name}`}>{pkg.name}</div>
))}
<div id={`pkg-${pkg.name}`}>{pkg.name}</div>
</div>

View file

@ -1,28 +0,0 @@
---
const { collection } = Astro.props;
export async function createCollection() {
return {
async data() {
let data = Astro.fetchContent('./post/*.md');
data.sort((a, b) => new Date(b.date) - new Date(a.date));
return data;
},
pageSize: 4
};
}
---
<div id="posts">
{collection.data.map((post) => (
<article>
<h1>{post.title}</h1>
<a href={post.url}>Read more</a>
</article>
))}
</div>
<nav>
{collection.url.prev && <a id="prev-page" href={collection.url.prev}>Previous page</a>}
{collection.url.next && <a id="next-page" href={collection.url.next}>Next page</a>}
</nav>

View file

@ -1,24 +1,26 @@
---
const { collection } = Astro.props;
export function createCollection() {
return {
async data() {
paginate: true,
route: '/posts/:page?',
async props({paginate}) {
const data = Astro.fetchContent('./post/*.md');
return data;
return {posts: paginate(data, {pageSize: 1})};
},
pageSize: 1,
};
}
const { posts } = Astro.props;
const { params, canonicalURL} = Astro.request;
---
<html>
<head>
<title>All Posts</title>
<link rel="canonical" href={Astro.request.canonicalURL.href} />
<link rel="canonical" href={canonicalURL.href} />
</head>
<body>
{collection.data.map((data) => (
{posts.data.map((data) => (
<div>
<h1>{data.title}</h1>
<a href={data.url}>Read</a>

View file

@ -1,12 +1,13 @@
---
const { collection } = Astro.props;
export async function createCollection() {
export function createCollection() {
const episodes = Astro.fetchContent('./episode/*.md')
.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate));
return {
async data() {
const episodes = Astro.fetchContent('./episode/*.md');
episodes.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate));
return episodes;
paginate: true,
route: '/episodes/:page?',
async props({paginate}) {
return {episodes: paginate(episodes)};
},
rss: {
title: 'MF Doomcast',
@ -29,6 +30,8 @@ export async function createCollection() {
}
}
}
const { episodes } = Astro.props;
---
<html>
@ -36,5 +39,7 @@ export async function createCollection() {
<title>Podcast Episodes</title>
<link rel="alternate" type="application/rss+2.0" href="/feed/episodes.xml" />
</head>
<body></body>
<body>
{episodes.data.map((ep) => (<li>{ep.title}</li>))}
</body>
</html>

View file

@ -8971,6 +8971,11 @@ path-root@^0.1.1:
dependencies:
path-root-regex "^0.1.0"
path-to-regexp@^6.2.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.2.0.tgz#f7b3803336104c346889adece614669230645f38"
integrity sha512-f66KywYG6+43afgE/8j/GoiNyygk/bnoCbps++3ErRKsIYkGGupyv07R2Ok5m9i67Iqc+T2g1eAUGUPzWhYTyg==
path-type@^1.0.0:
version "1.1.0"
resolved "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz"