Renaming to import.meta.fetchContent (#70)
* Change to import.meta.glob() Change of plans—maintain parity with Snowpack and Vite because our Collections API will use a different interface * Get basic pagination working * Get params working * Rename to import.meta.fetchContent * Upgrade to fdir
This commit is contained in:
parent
687ff5bacd
commit
3639190b4e
19 changed files with 1736 additions and 1946 deletions
197
README.md
197
README.md
|
@ -43,7 +43,7 @@ export default {
|
|||
astroRoot: './astro',
|
||||
/** When running `astro build`, path to final static output */
|
||||
dist: './_site',
|
||||
/** A folder of static files Astro will copy to the root. Useful for favicons, images, and other files that don‘t need processing. */
|
||||
/** A folder of static files Astro will copy to the root. Useful for favicons, images, and other files that don’t need processing. */
|
||||
public: './public',
|
||||
/** Extension-specific handlings */
|
||||
extensions: {
|
||||
|
@ -80,7 +80,7 @@ Our goal is to support all popular state management libraries, as long as there
|
|||
|
||||
### 💅 Styling
|
||||
|
||||
If you‘ve used [Svelte][svelte]’s styles before, Astro works almost the same way. In any `.astro` file, start writing styles in a `<style>` tag like so:
|
||||
If you’ve used [Svelte][svelte]’s styles before, Astro works almost the same way. In any `.astro` file, start writing styles in a `<style>` tag like so:
|
||||
|
||||
```html
|
||||
<style>
|
||||
|
@ -136,7 +136,7 @@ module.exports = {
|
|||
|
||||
_Note: a Tailwind config file is currently required to enable Tailwind in Astro, even if you use the default options._
|
||||
|
||||
Then write Tailwind in your project just like you‘re used to:
|
||||
Then write Tailwind in your project just like you’re used to:
|
||||
|
||||
```html
|
||||
<style>
|
||||
|
@ -148,61 +148,174 @@ Then write Tailwind in your project just like you‘re used to:
|
|||
|
||||
#### 🍱 Collections (beta)
|
||||
|
||||
Astro’s Collections API is useful for grabbing collections of content. Currently only `*.md` files are supported.
|
||||
Astro’s Collections API can be used for paginating content whether local `*.md` files or data from a headless CMS.
|
||||
|
||||
##### 🔽 Markdown
|
||||
First, decide on a URL schema. For our example, perhaps you want all your paginated posts at `/posts/1`, `/posts/2`, etc. But in addition, you also wanted `/tag/[tag]` and `/year/[year]` collections where posts are filtered by tag or year.
|
||||
|
||||
```jsx
|
||||
// pages/blog.astro
|
||||
Next, for each “owner” of a URL tree, create a `/astro/pages/$[collection].astro` file. So in our example, we’d need 3:
|
||||
|
||||
```
|
||||
└── astro/
|
||||
└── pages/
|
||||
├── $posts.astro -> /posts/1, /posts/2, …
|
||||
├── $tag.astro -> /tag/[tag]/1, /tag/[tag]/2, …
|
||||
└── $year.astro -> /year/[year]/1, /year/[year]/2, …
|
||||
```
|
||||
|
||||
Lastly, in each `$[collection].astro` file, add 2 things:
|
||||
|
||||
```js
|
||||
export let collection: any;
|
||||
```
|
||||
|
||||
```js
|
||||
export async function createCollection() {
|
||||
return {
|
||||
async data() {
|
||||
// return data here to load (we’ll cover how later)
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
These are important so your data is exposed to the page as a prop, and also Astro has everything it needs to gather your data and generate the proper routes. How it does this is more clear if we walk through a practical example.
|
||||
|
||||
##### Example 1: Simple pagination
|
||||
|
||||
Assume we have Markdown files that have `title`, `tag`, and `date` in their frontmatter, like so:
|
||||
|
||||
```md
|
||||
---
|
||||
title: My Blog Post
|
||||
tag: javascript
|
||||
date: 2021-03-01 09:34:00
|
||||
---
|
||||
|
||||
# My Blog post
|
||||
|
||||
…
|
||||
```
|
||||
|
||||
It’s important to know that these could be anything! There’s no restrictions around what can go in your frontmatter, but these will explain values we see later. Assume nothing is “special“ or reserved; we named everything.
|
||||
|
||||
Also, assume we want the following final routes:
|
||||
|
||||
- Individual blog posts live at `/post/[slug]`.
|
||||
- The paginated blog posts live at `/posts/1` for page 1, `/posts/2` for page 2, etc.
|
||||
- We also want to add `/tag/[tag]/1` for tagged posts, page 1, or `/year/[year]/1` for posts by year. We’ll add these at the end.
|
||||
|
||||
Let’s start with paginated posts. Since we want `/posts/` to be the root, we’ll create a file at `/astro/pages/$posts.astro` (the `$` indicates that this is a multi-route page):
|
||||
|
||||
```html
|
||||
// /astro/pages/$posts.astro
|
||||
---
|
||||
import Pagination from '../components/Pagination.astro';
|
||||
import PostPreview from '../components/PostPreview.astro';
|
||||
|
||||
const blogPosts = import.meta.collections('./post/*.md');
|
||||
export let collection: any;
|
||||
|
||||
export async function createCollection() {
|
||||
const allPosts = import.meta.fetchContent('./post/*.md'); // load data that already lives at `/post/[slug]`
|
||||
allPosts.sort((a, b) => new Date(b.date) - new Date(a.date)); // sort newest -> oldest (we got "date" from frontmatter!)
|
||||
|
||||
// (load more data here, if needed)
|
||||
|
||||
return {
|
||||
async data() {
|
||||
return allPosts;
|
||||
},
|
||||
pageSize: 10, // how many we want to show per-page (default: 25)
|
||||
};
|
||||
}
|
||||
---
|
||||
|
||||
<main>
|
||||
<h1>Blog Posts</h1>
|
||||
{blogPosts.map((post) => (
|
||||
<PostPreview post={post} />
|
||||
)}
|
||||
</main>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Blog Posts: page {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} />
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h5>Results {collection.start + 1}–{collection.start + 1 + collection.page.size} of {collection.total}</h6>
|
||||
{collection.data.map((post) => (
|
||||
<PostPreview post={post} />
|
||||
)}
|
||||
</main>
|
||||
<footer>
|
||||
<Pagination
|
||||
currentPage={collection.page.current}
|
||||
totalPages={collection.page.last}
|
||||
prevURL={collection.url.prev}
|
||||
nextURL={collection.url.next}
|
||||
/>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
This will load all markdown files located in `/pages/post/*.md`, compile them into an array, then expose them to the page.
|
||||
Let’s walk through some of the key parts:
|
||||
|
||||
If you were to inspect the array, you‘d find the following schema:
|
||||
- `export let collection`: this is important because it exposes a prop to the page for Astro to return with all your data loaded. ⚠️ **It must be named `collection`**.
|
||||
- `export async function createCollection()`: this is also required, **and must be named this exactly.** This is an async function that lets you load data from anywhere (even a remote API!). At the end, you must return an object with `{ data: yourData }`. There are other options such as `pageSize` we’ll cover later.
|
||||
- `{collection.data.map((post) => (…`: this lets us iterate over all the markdown posts. This will take the shape of whatever you loaded in `createCollection()`. It will always be an array.
|
||||
- `{collection.page.current}`: this, and other properties, simply return more info such as what page a user is on, what the URL is, etc. etc.
|
||||
|
||||
```js
|
||||
const blogPosts = [
|
||||
{
|
||||
content: string, // Markdown converted to HTML
|
||||
// all other frontmatter data
|
||||
},
|
||||
// …
|
||||
];
|
||||
```
|
||||
It should be noted that the above example shows `<PostPreview />` and `<Pagination />` components. Pretend those are custom components that you made to display the post data, and the pagination navigation. There’s nothing special about them; only consider those examples of how you’d use collection data to display everything the way you’d like.
|
||||
|
||||
##### 🧑🍳 Advanced usage
|
||||
##### Example 2: Advanced filtering & pagination
|
||||
|
||||
All of the following options are supported under the 2nd parameter of `import.meta.collections()`:
|
||||
In our earlier example, we covered simple pagination for `/posts/1`, but we’d still like to make `/tag/[tag]/1` and `/year/[year]/1`. To do that, we’ll create 2 more collections: `/astro/pages/$tag.astro` and `astro/pages/$year.astro`. Assume that the markup is the same, but we’ve expanded the `createCollection()` function with more data.
|
||||
|
||||
```js
|
||||
const collection = import.meta.collections('./post/*.md', {
|
||||
/** If `page` is omitted, all results are returned */
|
||||
page: 1, // ⚠️ starts at 1, not 0
|
||||
/** How many items should be returned per-page (ignored if `page` is missing; default: 25) */
|
||||
perPage: 25,
|
||||
/** How items should be sorted (default: no sort) */
|
||||
sort(a, b) {
|
||||
return new Date(b.date) - new Date(a.date); // sort newest first, by `date` in frontmatter
|
||||
```diff
|
||||
// /astro/pages/$tag.astro
|
||||
---
|
||||
import Pagination from '../components/Pagination.astro';
|
||||
import PostPreview from '../components/PostPreview.astro';
|
||||
|
||||
export let collection: any;
|
||||
|
||||
export async function createCollection() {
|
||||
const allPosts = import.meta.fetchContent('./post/*.md');
|
||||
allPosts.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||
+ const allTags = [...new Set(allPosts.map((post) => post.tags).flat())]; // gather all unique tags (we got "tag" from frontmatter!)
|
||||
+ allTags.sort((a, b) => a.localeCompare(b)); // sort tags A -> Z
|
||||
+ const routes = allTags.map((tag) => ({ tag })); // this is where we set { params: { tag } }
|
||||
|
||||
return {
|
||||
- async data() {
|
||||
- return allPosts;
|
||||
+ async data({ params }) {
|
||||
+ return allPosts.filter((post) => post.tag === params.tag); // filter post by "date" frontmatter, from params (we get `{ params }` from the routes array above)
|
||||
},
|
||||
pageSize: 10,
|
||||
+ routes,
|
||||
+ permalink: ({ params }) => `/tag/${params.tag}/` // important! the root must match (/tag/[tag] -> $tag.astro)
|
||||
};
|
||||
}
|
||||
/** Should items be filtered by their frontmatter data? */
|
||||
filter(post) {
|
||||
return post.tag === 'movie'; // (optional) only return posts tagged "movie"
|
||||
}
|
||||
});
|
||||
---
|
||||
```
|
||||
|
||||
Some important concepts here:
|
||||
|
||||
- `routes = allTags.map((tag) => ({ tag }))`: Astro handles pagination for you automatically. But when it needs to generate multiple routes, this is where you tell Astro about all the possible routes. This way, when you run `astro build`, your static build isn’t missing any pages.
|
||||
- `permalink: ({ params }) => `/tag/${params.tag}/`: this is where you tell Astro what the generated URL should be. Note that while you have control over this, the root of this must match the filename (it’s best **NOT** to use `/pages/$tag.astro`to generate`/year/$year.astro`; that should live at `/pages/$year.astro` as a separate file).
|
||||
- `allPosts.filter((post) => post.tag === params.tag)`: we aren’t returning all posts here; we’re only returning posts with a matching tag. _What tag,_ you ask? The `routes` array has `[{ tag: 'javascript' }, { tag: '…`, and all the routes we need to gather. So we first need to query everything, but only return the `.filter()`ed posts at the very end.
|
||||
|
||||
Other things of note is that we are sorting like before, but we filter by the frontmatter `tag` property, and return those at URLs.
|
||||
|
||||
These are still paginated, too! But since there are other conditions applied, they live at a different URL.
|
||||
|
||||
Lastly, what about `/year/*`? Well hopefully you can figure that out from here. It follows the exact same pattern, except using `post.date` frontmatter. You’ll grab the year from that date string, and sort probably newest to oldest rather than alphabetical. You’ll also change `params.tag` to `params.year` (or whatever you name it), but otherwise most everything else should be the same.
|
||||
|
||||
##### Tips
|
||||
|
||||
- Having to load different collections in different `$[collection].astro` files might seem like a pain at first, until you remember **you can create reusable components!** Treat `/pages/*.astro` files as your one-off routing & data fetching logic, and treat `/components/*.astro` as your reusable markup. If you find yourself duplicating things too much, you can probably use a component instead!
|
||||
- Stay true to `/pages/$[collection].astro` naming. If you have an `/all-posts/*` route, then use `/pages/$all-posts.astro` to manage that. Don’t try and trick `permalink` to generate too many URL trees; it’ll only result in pages being missed when it comes time to build.
|
||||
- Need to load local markdown? Try `import.meta.fetchContent('./data/*.md')`
|
||||
- Need to load remote data? Simply `fetch()` to make it happen!
|
||||
|
||||
## 🚀 Build & Deployment
|
||||
|
||||
Add a `build` npm script to your `/package.json` file:
|
||||
|
|
|
@ -1,17 +1,15 @@
|
|||
import React, { useState } from 'react';
|
||||
import confetti from 'canvas-confetti';
|
||||
// import confetti from 'canvas-confetti';
|
||||
|
||||
export default function Counter() {
|
||||
// Declare a new state variable, which we'll call "count"
|
||||
const [count, setCount] = useState(0);
|
||||
console.log(confetti());
|
||||
// console.log(confetti());
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>You clicked {count} times</p>
|
||||
<button onClick={() => setCount(count + 1)}>
|
||||
Click me
|
||||
</button>
|
||||
<button onClick={() => setCount(count + 1)}>Click me</button>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -37,8 +37,8 @@ a {
|
|||
<h1 class="title">Muppet Blog</h1>
|
||||
<ul class="nav">
|
||||
<li><a href="/">All Posts</a></li>
|
||||
<li><a href="/?tag=movies">Movies</a></li>
|
||||
<li><a href="/?tag=television">Television</a></li>
|
||||
<li><a href="/tag/movie/1">Movies</a></li>
|
||||
<li><a href="/tag/television/1">Television</a></li>
|
||||
<li><a href="/about">About</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
|
|
@ -1,12 +1,34 @@
|
|||
---
|
||||
export let currentPage: number;
|
||||
export let maxPages: number;
|
||||
export let prevUrl: string;
|
||||
export let nextUrl: string;
|
||||
---
|
||||
|
||||
<nav>
|
||||
<a href="">Prev</a>
|
||||
<a href="?p=1">1</a>
|
||||
<a href="?p=2">2</a>
|
||||
<a href="?p=3">3</a>
|
||||
<a href="?p=2">Next</a>
|
||||
<style lang="scss">
|
||||
.nav {
|
||||
display: flex;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.prev,
|
||||
.next {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.prev {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.next {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
<nav class="nav">
|
||||
<a class="prev" href={prevUrl || '#'}>Prev</a>
|
||||
<a class="next" href={nextUrl || '#'}>Next</a>
|
||||
</nav>
|
||||
|
|
48
examples/blog/astro/pages/$posts.astro
Normal file
48
examples/blog/astro/pages/$posts.astro
Normal file
|
@ -0,0 +1,48 @@
|
|||
---
|
||||
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 = 'Muppet Blog: Home';
|
||||
let description = 'An example blog on Astro';
|
||||
|
||||
// collection
|
||||
import authorData from '../data/authors.json';
|
||||
export let collection: any;
|
||||
export async function createCollection() {
|
||||
return {
|
||||
async data() {
|
||||
let allPosts = await import.meta.fetchContent('./post/*.md');
|
||||
allPosts.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||
return allPosts;
|
||||
},
|
||||
pageSize: 3
|
||||
};
|
||||
}
|
||||
---
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<title>{title}</title>
|
||||
<MainHead title={title} description={description} />
|
||||
<link rel="canonical" href={'https://mysite.dev' + collection.url.current} />
|
||||
{collection.url.next && <link rel="next" href={'https://mysite.dev' + collection.url.next} />}
|
||||
{collection.url.prev && <link rel="prev" href={'https://mysite.dev' + collection.url.prev} />}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Nav />
|
||||
|
||||
<main class="wrapper">
|
||||
<h1>All Posts</h1>
|
||||
<small>{collection.start + 1}–{collection.end + 1} of {collection.total}</small><br />
|
||||
{collection.data.map((post) => <PostPreview post={post} author={authorData[post.author]} />)}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<Pagination prevUrl={collection.url.prev} nextUrl={collection.url.next} />
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
58
examples/blog/astro/pages/$tag.astro
Normal file
58
examples/blog/astro/pages/$tag.astro
Normal file
|
@ -0,0 +1,58 @@
|
|||
---
|
||||
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 = 'Muppet Blog: Home';
|
||||
let description = 'An example blog on Astro';
|
||||
|
||||
// collection
|
||||
import authorData from '../data/authors.json';
|
||||
export let collection: any;
|
||||
export async function createCollection() {
|
||||
let allPosts = import.meta.fetchContent('./post/*.md');
|
||||
let allTags = new Set();
|
||||
let routes = [];
|
||||
for (const post of allPosts) {
|
||||
if (!allTags.has(post.tag)) {
|
||||
allTags.add(post.tag);
|
||||
routes.push({ tag: post.tag });
|
||||
}
|
||||
}
|
||||
return {
|
||||
async data({ params }) {
|
||||
allPosts.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||
return allPosts.filter((post) => post.tag === params.tag);
|
||||
},
|
||||
routes,
|
||||
permalink: ({ params }) => `/tag/${params.tag}`,
|
||||
pageSize: 3
|
||||
};
|
||||
}
|
||||
---
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<title>{title}</title>
|
||||
<MainHead title={title} description={description} />
|
||||
<link rel="canonical" href={'https://mysite.dev' + collection.url.current} />
|
||||
{collection.url.next && <link rel="next" href={'https://mysite.dev' + collection.url.next} />}
|
||||
{collection.url.prev && <link rel="prev" href={'https://mysite.dev' + collection.url.prev} />}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Nav />
|
||||
|
||||
<main class="wrapper">
|
||||
<h1>Tagged: {collection.params.tag}</h1>
|
||||
<small>{collection.start + 1}–{collection.end + 1} of {collection.total}</small><br />
|
||||
{collection.data.map((post) => <PostPreview post={post} author={authorData[post.author]} />)}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<Pagination prevUrl={collection.url.prev} nextUrl={collection.url.next} />
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
|
@ -4,22 +4,18 @@ import Nav from '../components/Nav.astro';
|
|||
import PostPreview from '../components/PostPreview.astro';
|
||||
import Pagination from '../components/Pagination.astro';
|
||||
|
||||
// posts
|
||||
import authorData from '../data/authors.json';
|
||||
|
||||
const postData = import.meta.collection('./post/*.md');
|
||||
|
||||
const PER_PAGE = 10;
|
||||
postData.sort((a, b) => new Date(b.date) - new Date(a.date)); // new -> old
|
||||
|
||||
const start = 0;
|
||||
const currentPage = 1;
|
||||
const maxPages = 1;
|
||||
const posts = postData.splice(start, PER_PAGE);
|
||||
|
||||
// page
|
||||
let title = 'Muppet Blog: Home';
|
||||
let description = 'An example blog on Astro';
|
||||
|
||||
// collection
|
||||
// note: we want to show first 3 posts here, but we don’t want to paginate at /1, /2, /3, etc.
|
||||
// so we show a preview of posts here, but actually paginate from $posts.astro
|
||||
import authorData from '../data/authors.json';
|
||||
|
||||
let allPosts = import.meta.fetchContent('./post/*.md');
|
||||
allPosts.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||
let firstThree = allPosts.slice(0, 3);
|
||||
---
|
||||
|
||||
<html>
|
||||
|
@ -33,11 +29,11 @@ let description = 'An example blog on Astro';
|
|||
|
||||
<main class="wrapper">
|
||||
<h1>Recent posts</h1>
|
||||
{posts.map((post) => <PostPreview post={post} author={authorData[post.author]} />)}
|
||||
{firstThree.map((post) => <PostPreview post={post} author={authorData[post.author]} />)}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<Pagination currentPage={currentPage} maxPages={maxPages} />
|
||||
<Pagination nextUrl="/posts/2" />
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
|
2737
examples/blog/package-lock.json
generated
2737
examples/blog/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -1,6 +1,9 @@
|
|||
{
|
||||
"scripts": {
|
||||
"build": "../../astro.mjs build",
|
||||
"start": "../../astro.mjs dev"
|
||||
"start": "nodemon -w ../../lib -x '../../astro.mjs dev'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^2.0.7"
|
||||
}
|
||||
}
|
||||
|
|
52
package-lock.json
generated
52
package-lock.json
generated
|
@ -104,9 +104,9 @@
|
|||
}
|
||||
},
|
||||
"@babel/parser": {
|
||||
"version": "7.13.11",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.13.11.tgz",
|
||||
"integrity": "sha512-PhuoqeHoO9fc4ffMEVk4qb/w/s2iOSWohvbHxLtxui0eBg3Lg5gN1U8wp1V1u61hOWkPQJJyJzGH6Y+grwkq8Q=="
|
||||
"version": "7.13.15",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.13.15.tgz",
|
||||
"integrity": "sha512-b9COtcAlVEQljy/9fbcMHpG+UIW9ReF+gpaxDHTlZd0c6/UU9ng8zdySAW9sRTzpvcdCHn6bUcbuYUgGzLAWVQ=="
|
||||
},
|
||||
"@babel/template": {
|
||||
"version": "7.12.13",
|
||||
|
@ -119,21 +119,30 @@
|
|||
}
|
||||
},
|
||||
"@babel/traverse": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.13.0.tgz",
|
||||
"integrity": "sha512-xys5xi5JEhzC3RzEmSGrs/b3pJW/o87SypZ+G/PhaE7uqVQNv/jlmVIBXuoh5atqQ434LfXV+sf23Oxj0bchJQ==",
|
||||
"version": "7.13.15",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.13.15.tgz",
|
||||
"integrity": "sha512-/mpZMNvj6bce59Qzl09fHEs8Bt8NnpEDQYleHUPZQ3wXUMvXi+HJPLars68oAbmp839fGoOkv2pSL2z9ajCIaQ==",
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.12.13",
|
||||
"@babel/generator": "^7.13.0",
|
||||
"@babel/generator": "^7.13.9",
|
||||
"@babel/helper-function-name": "^7.12.13",
|
||||
"@babel/helper-split-export-declaration": "^7.12.13",
|
||||
"@babel/parser": "^7.13.0",
|
||||
"@babel/types": "^7.13.0",
|
||||
"@babel/parser": "^7.13.15",
|
||||
"@babel/types": "^7.13.14",
|
||||
"debug": "^4.1.0",
|
||||
"globals": "^11.1.0",
|
||||
"lodash": "^4.17.19"
|
||||
"globals": "^11.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/types": {
|
||||
"version": "7.13.14",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.13.14.tgz",
|
||||
"integrity": "sha512-A2aa3QTkWoyqsZZFl56MLUsfmh7O0gN41IPvXAE/++8ojpbz12SszD7JEGYVdn4f9Kt4amIei07swF1h4AqmmQ==",
|
||||
"requires": {
|
||||
"@babel/helper-validator-identifier": "^7.12.11",
|
||||
"lodash": "^4.17.19",
|
||||
"to-fast-properties": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"globals": {
|
||||
"version": "11.12.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
|
||||
|
@ -142,9 +151,9 @@
|
|||
}
|
||||
},
|
||||
"@babel/types": {
|
||||
"version": "7.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.13.0.tgz",
|
||||
"integrity": "sha512-hE+HE8rnG1Z6Wzo+MhaKE5lM5eMx71T4EHJgku2E3xIfaULhDcxiiRxUYgwX8qwP1BBSlag+TdGOt6JAidIZTA==",
|
||||
"version": "7.13.14",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.13.14.tgz",
|
||||
"integrity": "sha512-A2aa3QTkWoyqsZZFl56MLUsfmh7O0gN41IPvXAE/++8ojpbz12SszD7JEGYVdn4f9Kt4amIei07swF1h4AqmmQ==",
|
||||
"requires": {
|
||||
"@babel/helper-validator-identifier": "^7.12.11",
|
||||
"lodash": "^4.17.19",
|
||||
|
@ -256,6 +265,15 @@
|
|||
"@babel/types": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"@types/babel__parser": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__parser/-/babel__parser-7.1.1.tgz",
|
||||
"integrity": "sha512-baSzIb0QQOUQSglfR9gwXVSbHH91YvY00C9Zjq6E7sPdnp8oyPyUsonIj3SF4wUl0s96vR/kyWeVv30gmM/xZw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/parser": "*"
|
||||
}
|
||||
},
|
||||
"@types/babel__traverse": {
|
||||
"version": "7.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.11.1.tgz",
|
||||
|
@ -2439,9 +2457,9 @@
|
|||
"dev": true
|
||||
},
|
||||
"picomatch": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz",
|
||||
"integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg=="
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.3.tgz",
|
||||
"integrity": "sha512-KpELjfwcCDUb9PeigTs2mBJzXUPzAuP2oPcA989He8Rte0+YUAjw1JVedDhuTKPkHjSYzMN3npC9luThGYEKdg=="
|
||||
},
|
||||
"pify": {
|
||||
"version": "2.3.0",
|
||||
|
|
|
@ -30,7 +30,8 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@babel/generator": "^7.13.9",
|
||||
"@babel/traverse": "^7.13.0",
|
||||
"@babel/parser": "^7.13.15",
|
||||
"@babel/traverse": "^7.13.15",
|
||||
"@snowpack/plugin-sass": "^1.4.0",
|
||||
"@snowpack/plugin-svelte": "^3.6.0",
|
||||
"@snowpack/plugin-vue": "^2.4.0",
|
||||
|
@ -53,6 +54,7 @@
|
|||
"micromark-extension-mdx-expression": "^0.3.2",
|
||||
"micromark-extension-mdx-jsx": "^0.3.3",
|
||||
"node-fetch": "^2.6.1",
|
||||
"picomatch": "^2.2.3",
|
||||
"postcss": "^8.2.8",
|
||||
"postcss-icss-keyframes": "^0.2.1",
|
||||
"preact": "^10.5.13",
|
||||
|
@ -69,8 +71,9 @@
|
|||
"yargs-parser": "^20.2.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/parser": "^7.13.11",
|
||||
"@babel/types": "^7.13.14",
|
||||
"@types/babel__generator": "^7.6.2",
|
||||
"@types/babel__parser": "^7.1.1",
|
||||
"@types/babel__traverse": "^7.11.1",
|
||||
"@types/estree": "0.0.46",
|
||||
"@types/github-slugger": "^1.3.0",
|
||||
|
|
|
@ -26,6 +26,8 @@ export interface TransformResult {
|
|||
imports: string[];
|
||||
html: string;
|
||||
css?: string;
|
||||
/** If this page exports a collection, the JS to be executed as a string */
|
||||
createCollection?: string;
|
||||
}
|
||||
|
||||
export interface CompileResult {
|
||||
|
@ -35,3 +37,45 @@ export interface CompileResult {
|
|||
}
|
||||
|
||||
export type RuntimeMode = 'development' | 'production';
|
||||
|
||||
export type Params = Record<string, string | number>;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface CollectionResult<T = any> {
|
||||
/** result */
|
||||
data: T[];
|
||||
|
||||
/** 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;
|
||||
/** url of the next page (if there is one) */
|
||||
next?: string;
|
||||
};
|
||||
/** Matched parameters, if any */
|
||||
params: Params;
|
||||
}
|
||||
|
|
78
src/compiler/codegen/content.ts
Normal file
78
src/compiler/codegen/content.ts
Normal file
|
@ -0,0 +1,78 @@
|
|||
import path from 'path';
|
||||
import { fdir, PathsOutput } from 'fdir';
|
||||
|
||||
/**
|
||||
* Handling for import.meta.glob and import.meta.globEager
|
||||
*/
|
||||
|
||||
interface GlobOptions {
|
||||
namespace: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
interface GlobResult {
|
||||
/** Array of import statements to inject */
|
||||
imports: Set<string>;
|
||||
/** Replace original code with */
|
||||
code: string;
|
||||
}
|
||||
|
||||
const crawler = new fdir();
|
||||
|
||||
/** General glob handling */
|
||||
function globSearch(spec: string, { filename }: { filename: string }): string[] {
|
||||
try {
|
||||
// Note: fdir’s glob requires you to do some work finding the closest non-glob folder.
|
||||
// For example, this fails: .glob("./post/*.md").crawl("/…/astro/pages") ❌
|
||||
// …but this doesn’t: .glob("*.md").crawl("/…/astro/pages/post") ✅
|
||||
let globDir = '';
|
||||
let glob = spec;
|
||||
for (const part of spec.split('/')) {
|
||||
if (!part.includes('*')) {
|
||||
// iterate through spec until first '*' is reached
|
||||
globDir = path.posix.join(globDir, part); // this must be POSIX-style
|
||||
glob = glob.replace(`${part}/`, ''); // move parent dirs off spec, and onto globDir
|
||||
} else {
|
||||
// at first '*', exit
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const cwd = path.join(path.dirname(filename), globDir.replace(/\//g, path.sep)); // this must match OS (could be '/' or '\')
|
||||
let found = crawler.glob(glob).crawl(cwd).sync() as PathsOutput;
|
||||
if (!found.length) {
|
||||
throw new Error(`No files matched "${spec}" from ${filename}`);
|
||||
}
|
||||
return found.map((importPath) => {
|
||||
if (importPath.startsWith('http') || importPath.startsWith('.')) return importPath;
|
||||
return `./` + globDir + '/' + importPath;
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error(`No files matched "${spec}" from ${filename}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** import.meta.fetchContent() */
|
||||
export function fetchContent(spec: string, { namespace, filename }: GlobOptions): GlobResult {
|
||||
let code = '';
|
||||
const imports = new Set<string>();
|
||||
const importPaths = globSearch(spec, { filename });
|
||||
|
||||
// gather imports
|
||||
importPaths.forEach((importPath, j) => {
|
||||
const id = `${namespace}_${j}`;
|
||||
imports.add(`import { __content as ${id} } from '${importPath}';`);
|
||||
|
||||
// add URL if this appears within the /pages/ directory (probably can be improved)
|
||||
const fullPath = path.resolve(path.dirname(filename), importPath);
|
||||
if (fullPath.includes(`${path.sep}pages${path.sep}`)) {
|
||||
const url = importPath.replace(/^\./, '').replace(/\.md$/, '');
|
||||
imports.add(`${id}.url = '${url}';`);
|
||||
}
|
||||
});
|
||||
|
||||
// generate replacement code
|
||||
code += `${namespace} = [${importPaths.map((_, j) => `${namespace}_${j}`).join(',')}];\n`;
|
||||
|
||||
return { imports, code };
|
||||
}
|
|
@ -1,17 +1,22 @@
|
|||
import type { CompileOptions } from '../@types/compiler';
|
||||
import type { AstroConfig, ValidExtensionPlugins } from '../@types/astro';
|
||||
import type { Ast, Script, Style, TemplateNode } from '../parser/interfaces';
|
||||
import type { JsxItem, TransformResult } from '../@types/astro';
|
||||
import type { CompileOptions } from '../../@types/compiler';
|
||||
import type { AstroConfig, ValidExtensionPlugins } from '../../@types/astro';
|
||||
import type { Ast, Script, Style, TemplateNode } from '../../parser/interfaces';
|
||||
import type { TransformResult } from '../../@types/astro';
|
||||
|
||||
import eslexer from 'es-module-lexer';
|
||||
import esbuild from 'esbuild';
|
||||
import { fdir, PathsOutput } from 'fdir';
|
||||
import path from 'path';
|
||||
import { walk } from 'estree-walker';
|
||||
import babelParser from '@babel/parser';
|
||||
import _babelGenerator from '@babel/generator';
|
||||
import babelParser from '@babel/parser';
|
||||
import * as babelTraverse from '@babel/traverse';
|
||||
import { ImportDeclaration, ExportNamedDeclaration, VariableDeclarator, Identifier } from '@babel/types';
|
||||
import { warn } from '../../logger.js';
|
||||
import { fetchContent } from './content.js';
|
||||
import { isImportMetaDeclaration } from './utils.js';
|
||||
import { yellow } from 'kleur/colors';
|
||||
|
||||
const traverse: typeof babelTraverse.default = (babelTraverse.default as any).default;
|
||||
const babelGenerator: typeof _babelGenerator =
|
||||
// @ts-ignore
|
||||
_babelGenerator.default;
|
||||
|
@ -36,15 +41,6 @@ function internalImport(internalPath: string) {
|
|||
return `/_astro_internal/${internalPath}`;
|
||||
}
|
||||
|
||||
/** Is this an import.meta.* built-in? You can pass an optional 2nd param to see if the name matches as well. */
|
||||
function isImportMetaDeclaration(declaration: VariableDeclarator, metaName?: string): boolean {
|
||||
const { init } = declaration;
|
||||
if (!init || init.type !== 'CallExpression' || init.callee.type !== 'MemberExpression' || init.callee.object.type !== 'MetaProperty') return false;
|
||||
// optional: if metaName specified, match that
|
||||
if (metaName && (init.callee.property.type !== 'Identifier' || init.callee.property.name !== metaName)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Retrieve attributes from TemplateNode */
|
||||
function getAttributes(attrs: Attribute[]): Record<string, string> {
|
||||
let result: Record<string, string> = {};
|
||||
|
@ -283,6 +279,12 @@ async function acquireDynamicComponentImports(plugins: Set<ValidExtensionPlugins
|
|||
|
||||
type Components = Record<string, { type: string; url: string; plugin: string | undefined }>;
|
||||
|
||||
interface CompileResult {
|
||||
script: string;
|
||||
componentPlugins: Set<ValidExtensionPlugins>;
|
||||
createCollection?: string;
|
||||
}
|
||||
|
||||
interface CodegenState {
|
||||
filename: string;
|
||||
components: Components;
|
||||
|
@ -291,22 +293,20 @@ interface CodegenState {
|
|||
dynamicImports: DynamicImportMap;
|
||||
}
|
||||
|
||||
// cache filesystem pings
|
||||
const miniGlobCache = new Map<string, Map<string, string[]>>();
|
||||
|
||||
/** Compile/prepare Astro frontmatter scripts */
|
||||
function compileModule(module: Script, state: CodegenState, compileOptions: CompileOptions) {
|
||||
function compileModule(module: Script, state: CodegenState, compileOptions: CompileOptions): CompileResult {
|
||||
const { extensions = defaultExtensions } = compileOptions;
|
||||
|
||||
const componentImports: ImportDeclaration[] = [];
|
||||
const componentProps: VariableDeclarator[] = [];
|
||||
const componentExports: ExportNamedDeclaration[] = [];
|
||||
|
||||
const collectionImports = new Map<string, string>();
|
||||
const contentImports = new Map<string, { spec: string; declarator: string }>();
|
||||
|
||||
let script = '';
|
||||
let propsStatement = '';
|
||||
let dataStatement = '';
|
||||
let contentCode = ''; // code for handling import.meta.fetchContent(), if any;
|
||||
let createCollection = ''; // function for executing collection
|
||||
const componentPlugins = new Set<ValidExtensionPlugins>();
|
||||
|
||||
if (module) {
|
||||
|
@ -320,45 +320,64 @@ function compileModule(module: Script, state: CodegenState, compileOptions: Comp
|
|||
while (--i >= 0) {
|
||||
const node = body[i];
|
||||
switch (node.type) {
|
||||
case 'ExportNamedDeclaration': {
|
||||
if (!node.declaration) break;
|
||||
// const replacement = extract_exports(node);
|
||||
|
||||
if (node.declaration.type === 'VariableDeclaration') {
|
||||
// case 1: prop (export let title)
|
||||
|
||||
const declaration = node.declaration.declarations[0];
|
||||
if ((declaration.id as Identifier).name === '__layout' || (declaration.id as Identifier).name === '__content') {
|
||||
componentExports.push(node);
|
||||
} else {
|
||||
componentProps.push(declaration);
|
||||
}
|
||||
body.splice(i, 1);
|
||||
} else if (node.declaration.type === 'FunctionDeclaration') {
|
||||
// case 2: createCollection (export async function)
|
||||
if (!node.declaration.id || node.declaration.id.name !== 'createCollection') break;
|
||||
createCollection = module.content.substring(node.declaration.start || 0, node.declaration.end || 0);
|
||||
|
||||
// remove node
|
||||
body.splice(i, 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'FunctionDeclaration': {
|
||||
break;
|
||||
}
|
||||
case 'ImportDeclaration': {
|
||||
componentImports.push(node);
|
||||
body.splice(i, 1); // remove node
|
||||
break;
|
||||
}
|
||||
case 'ExportNamedDeclaration': {
|
||||
if (node.declaration?.type !== 'VariableDeclaration') {
|
||||
// const replacement = extract_exports(node);
|
||||
break;
|
||||
}
|
||||
const declaration = node.declaration.declarations[0];
|
||||
if ((declaration.id as Identifier).name === '__layout' || (declaration.id as Identifier).name === '__content') {
|
||||
componentExports.push(node);
|
||||
} else {
|
||||
componentProps.push(declaration);
|
||||
}
|
||||
body.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
case 'VariableDeclaration': {
|
||||
for (const declaration of node.declarations) {
|
||||
// only select import.meta.collection() calls here. this utility filters those out for us.
|
||||
if (!isImportMetaDeclaration(declaration, 'collection')) continue;
|
||||
if (declaration.id.type !== 'Identifier') continue;
|
||||
const { id, init } = declaration;
|
||||
if (!id || !init || init.type !== 'CallExpression') continue;
|
||||
// only select import.meta.fetchContent() calls here. this utility filters those out for us.
|
||||
if (!isImportMetaDeclaration(declaration, 'fetchContent')) continue;
|
||||
|
||||
// remove node
|
||||
body.splice(i, 1);
|
||||
|
||||
// a bit of munging
|
||||
let { id, init } = declaration;
|
||||
if (!id || !init || id.type !== 'Identifier') continue;
|
||||
if (init.type === 'AwaitExpression') {
|
||||
init = init.argument;
|
||||
const shortname = path.relative(compileOptions.astroConfig.projectRoot.pathname, state.filename);
|
||||
warn(compileOptions.logging, shortname, yellow('awaiting import.meta.fetchContent() not necessary'));
|
||||
}
|
||||
if (init.type !== 'CallExpression') continue;
|
||||
|
||||
// gather data
|
||||
const namespace = id.name;
|
||||
|
||||
// TODO: support more types (currently we can; it’s just a matter of parsing out the expression)
|
||||
if ((init as any).arguments[0].type !== 'StringLiteral') {
|
||||
throw new Error(`[import.meta.collection] Only string literals allowed, ex: \`import.meta.collection('./post/*.md')\`\n ${state.filename}`);
|
||||
throw new Error(`[import.meta.fetchContent] Only string literals allowed, ex: \`import.meta.fetchContent('./post/*.md')\`\n ${state.filename}`);
|
||||
}
|
||||
const spec = (init as any).arguments[0].value;
|
||||
if (typeof spec === 'string') collectionImports.set(namespace, spec);
|
||||
|
||||
// remove node
|
||||
body.splice(i, 1);
|
||||
if (typeof spec === 'string') contentImports.set(namespace, { spec, declarator: node.kind });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
@ -402,59 +421,73 @@ function compileModule(module: Script, state: CodegenState, compileOptions: Comp
|
|||
propsStatement += `} = props;\n`;
|
||||
}
|
||||
|
||||
// handle importing data
|
||||
for (const [namespace, spec] of collectionImports.entries()) {
|
||||
// only allow for .md files
|
||||
if (!spec.endsWith('.md')) {
|
||||
throw new Error(`Only *.md pages are supported for import.meta.collection(). Attempted to load "${spec}"`);
|
||||
}
|
||||
// handle createCollection, if any
|
||||
if (createCollection) {
|
||||
// TODO: improve this? while transforming in-place isn’t great, this happens at most once per-route
|
||||
const ast = babelParser.parse(createCollection, {
|
||||
sourceType: 'module',
|
||||
});
|
||||
traverse(ast, {
|
||||
enter({ node }) {
|
||||
switch (node.type) {
|
||||
case 'VariableDeclaration': {
|
||||
for (const declaration of node.declarations) {
|
||||
// only select import.meta.collection() calls here. this utility filters those out for us.
|
||||
if (!isImportMetaDeclaration(declaration, 'fetchContent')) continue;
|
||||
|
||||
// locate files
|
||||
try {
|
||||
let found: string[];
|
||||
// a bit of munging
|
||||
let { id, init } = declaration;
|
||||
if (!id || !init || id.type !== 'Identifier') continue;
|
||||
if (init.type === 'AwaitExpression') {
|
||||
init = init.argument;
|
||||
const shortname = path.relative(compileOptions.astroConfig.projectRoot.pathname, state.filename);
|
||||
warn(compileOptions.logging, shortname, yellow('awaiting import.meta.fetchContent() not necessary'));
|
||||
}
|
||||
if (init.type !== 'CallExpression') continue;
|
||||
|
||||
// use cache
|
||||
let cachedLookups = miniGlobCache.get(state.filename);
|
||||
if (!cachedLookups) {
|
||||
cachedLookups = new Map();
|
||||
miniGlobCache.set(state.filename, cachedLookups);
|
||||
}
|
||||
if (cachedLookups.get(spec)) {
|
||||
found = cachedLookups.get(spec) as string[];
|
||||
} else {
|
||||
found = new fdir().glob(spec).withFullPaths().crawl(path.dirname(state.filename)).sync() as PathsOutput;
|
||||
cachedLookups.set(spec, found);
|
||||
miniGlobCache.set(state.filename, cachedLookups);
|
||||
}
|
||||
// gather data
|
||||
const namespace = id.name;
|
||||
|
||||
// throw error, purge cache if no results found
|
||||
if (!found.length) {
|
||||
cachedLookups.delete(spec);
|
||||
miniGlobCache.set(state.filename, cachedLookups);
|
||||
throw new Error(`No files matched "${spec}" from ${state.filename}`);
|
||||
}
|
||||
if ((init as any).arguments[0].type !== 'StringLiteral') {
|
||||
throw new Error(`[import.meta.fetchContent] Only string literals allowed, ex: \`import.meta.fetchContent('./post/*.md')\`\n ${state.filename}`);
|
||||
}
|
||||
const spec = (init as any).arguments[0].value;
|
||||
if (typeof spec !== 'string') break;
|
||||
|
||||
const data = found.map((importPath) => {
|
||||
if (importPath.startsWith('http') || importPath.startsWith('.')) return importPath;
|
||||
return `./` + importPath;
|
||||
});
|
||||
const globResult = fetchContent(spec, { namespace, filename: state.filename });
|
||||
|
||||
// add static imports (probably not the best, but async imports don‘t work just yet)
|
||||
data.forEach((importPath, j) => {
|
||||
state.importExportStatements.add(`const ${namespace}_${j} = import('${importPath}').then((m) => ({ ...m.__content, url: '${importPath.replace(/\.md$/, '')}' }));`);
|
||||
});
|
||||
let imports = '';
|
||||
for (const importStatement of globResult.imports) {
|
||||
imports += importStatement + '\n';
|
||||
}
|
||||
|
||||
// expose imported data to Astro script
|
||||
dataStatement += `const ${namespace} = await Promise.all([${found.map((_, j) => `${namespace}_${j}`).join(',')}]);\n`;
|
||||
} catch (err) {
|
||||
throw new Error(`No files matched "${spec}" from ${state.filename}`);
|
||||
}
|
||||
createCollection =
|
||||
imports + '\n\nexport ' + createCollection.substring(0, declaration.start || 0) + globResult.code + createCollection.substring(declaration.end || 0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
script = propsStatement + dataStatement + babelGenerator(program).code;
|
||||
// import.meta.fetchContent()
|
||||
for (const [namespace, { declarator, spec }] of contentImports.entries()) {
|
||||
const globResult = fetchContent(spec, { namespace, filename: state.filename });
|
||||
for (const importStatement of globResult.imports) {
|
||||
state.importExportStatements.add(importStatement);
|
||||
}
|
||||
contentCode += globResult.code;
|
||||
}
|
||||
|
||||
script = propsStatement + contentCode + babelGenerator(program).code;
|
||||
}
|
||||
|
||||
return { script, componentPlugins };
|
||||
return {
|
||||
script,
|
||||
componentPlugins,
|
||||
createCollection: createCollection || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Compile styles */
|
||||
|
@ -606,7 +639,7 @@ export async function codegen(ast: Ast, { compileOptions, filename }: CodeGenOpt
|
|||
dynamicImports: new Map(),
|
||||
};
|
||||
|
||||
const { script, componentPlugins } = compileModule(ast.module, state, compileOptions);
|
||||
const { script, componentPlugins, createCollection } = compileModule(ast.module, state, compileOptions);
|
||||
state.dynamicImports = await acquireDynamicComponentImports(componentPlugins, compileOptions.resolve);
|
||||
|
||||
compileCss(ast.css, state);
|
||||
|
@ -618,5 +651,6 @@ export async function codegen(ast: Ast, { compileOptions, filename }: CodeGenOpt
|
|||
imports: Array.from(state.importExportStatements),
|
||||
html,
|
||||
css: state.css.length ? state.css.join('\n\n') : undefined,
|
||||
createCollection,
|
||||
};
|
||||
}
|
20
src/compiler/codegen/utils.ts
Normal file
20
src/compiler/codegen/utils.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* Codegen utils
|
||||
*/
|
||||
|
||||
import type { VariableDeclarator } from '@babel/types';
|
||||
|
||||
/** Is this an import.meta.* built-in? You can pass an optional 2nd param to see if the name matches as well. */
|
||||
export function isImportMetaDeclaration(declaration: VariableDeclarator, metaName?: string): boolean {
|
||||
let { init } = declaration;
|
||||
if (!init) return false; // definitely not import.meta
|
||||
// this could be `await import.meta`; if so, evaluate that:
|
||||
if (init.type === 'AwaitExpression') {
|
||||
init = init.argument;
|
||||
}
|
||||
// continue evaluating
|
||||
if (init.type !== 'CallExpression' || init.callee.type !== 'MemberExpression' || init.callee.object.type !== 'MetaProperty') return false;
|
||||
// optional: if metaName specified, match that
|
||||
if (metaName && (init.callee.property.type !== 'Identifier' || init.callee.property.name !== metaName)) return false;
|
||||
return true;
|
||||
}
|
|
@ -12,7 +12,7 @@ import { createMarkdownHeadersCollector } from './markdown/micromark-collect-hea
|
|||
import { encodeMarkdown } from './markdown/micromark-encode.js';
|
||||
import { encodeAstroMdx } from './markdown/micromark-mdx-astro.js';
|
||||
import { transform } from './transform/index.js';
|
||||
import { codegen } from './codegen.js';
|
||||
import { codegen } from './codegen/index.js';
|
||||
|
||||
/** Return Astro internal import URL */
|
||||
function internalImport(internalPath: string) {
|
||||
|
@ -132,6 +132,8 @@ async function __render(props, ...children) {
|
|||
}
|
||||
export default __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}) {
|
||||
|
|
|
@ -12,7 +12,8 @@ const SvelteRenderer: ComponentRenderer<SvelteComponent> = {
|
|||
render({ Component, root, props }) {
|
||||
return `new ${Component}({
|
||||
target: ${root},
|
||||
props: ${props}
|
||||
props: ${props},
|
||||
hydrate: true
|
||||
})`;
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import type { SnowpackDevServer, ServerRuntime as SnowpackServerRuntime, SnowpackConfig } from 'snowpack';
|
||||
import type { AstroConfig, RuntimeMode } from './@types/astro';
|
||||
import type { AstroConfig, CollectionResult, CreateCollection, Params, RuntimeMode } from './@types/astro';
|
||||
import type { LogOptions } from './logger';
|
||||
import type { CompileError } from './parser/utils/error.js';
|
||||
import { debug, info } from './logger.js';
|
||||
|
@ -78,6 +78,80 @@ async function load(config: RuntimeConfig, rawPathname: string | undefined): Pro
|
|||
try {
|
||||
const mod = await backendSnowpackRuntime.importModule(snowpackURL);
|
||||
debug(logging, 'resolve', `${reqPath} -> ${snowpackURL}`);
|
||||
|
||||
// handle collection
|
||||
let collection = {} as CollectionResult;
|
||||
if (mod.exports.createCollection) {
|
||||
const createCollection: CreateCollection = await mod.exports.createCollection();
|
||||
for (const key of Object.keys(createCollection)) {
|
||||
if (key !== 'data' && key !== 'routes' && key !== 'permalink' && key !== 'pageSize') {
|
||||
throw new Error(`[createCollection] unknown option: "${key}"`);
|
||||
}
|
||||
}
|
||||
let { data: loadData, routes, permalink, pageSize } = createCollection;
|
||||
if (!pageSize) pageSize = 25; // can’t be 0
|
||||
let currentParams: Params = {};
|
||||
|
||||
// params
|
||||
if (routes || permalink) {
|
||||
if (!routes || !permalink) {
|
||||
throw new Error('createCollection() must have both routes and permalink options. Include both together, or omit both.');
|
||||
}
|
||||
let requestedParams = routes.find((p) => {
|
||||
const baseURL = (permalink as any)({ params: p });
|
||||
return baseURL === reqPath || `${baseURL}/${searchResult.currentPage || 1}` === reqPath;
|
||||
});
|
||||
if (requestedParams) {
|
||||
currentParams = requestedParams;
|
||||
collection.params = requestedParams;
|
||||
}
|
||||
}
|
||||
|
||||
let data: any[] = await loadData({ params: currentParams });
|
||||
|
||||
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 = (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}`);
|
||||
}
|
||||
|
||||
data = data.slice(start, end);
|
||||
} else if (createCollection.pageSize) {
|
||||
// TODO: fix bug where redirect doesn’t happen
|
||||
// This happens because a pageSize is set, but the user isn’t on a paginated route. Redirect:
|
||||
return {
|
||||
statusCode: 301,
|
||||
location: reqPath + '/1',
|
||||
};
|
||||
}
|
||||
|
||||
// if we’ve paginated too far, this is a 404
|
||||
if (!data.length)
|
||||
return {
|
||||
statusCode: 404,
|
||||
error: new Error('Not Found'),
|
||||
};
|
||||
|
||||
collection.data = data;
|
||||
}
|
||||
|
||||
let html = (await mod.exports.__renderPage({
|
||||
request: {
|
||||
host: fullurl.hostname,
|
||||
|
@ -85,7 +159,7 @@ async function load(config: RuntimeConfig, rawPathname: string | undefined): Pro
|
|||
href: fullurl.toString(),
|
||||
},
|
||||
children: [],
|
||||
props: {},
|
||||
props: { collection },
|
||||
})) as string;
|
||||
|
||||
// inject styles
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import { existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { fdir, PathsOutput } from 'fdir';
|
||||
|
||||
interface PageLocation {
|
||||
fileURL: URL;
|
||||
|
@ -23,6 +25,7 @@ type SearchResult =
|
|||
statusCode: 200;
|
||||
location: PageLocation;
|
||||
pathname: string;
|
||||
currentPage?: number;
|
||||
}
|
||||
| {
|
||||
statusCode: 301;
|
||||
|
@ -32,7 +35,8 @@ type SearchResult =
|
|||
| {
|
||||
statusCode: 404;
|
||||
};
|
||||
/** searchForPage - look for astro or md pages */
|
||||
|
||||
/** Given a URL, attempt to locate its source file (similar to Snowpack’s load()) */
|
||||
export function searchForPage(url: URL, astroRoot: URL): SearchResult {
|
||||
const reqPath = decodeURI(url.pathname);
|
||||
const base = reqPath.substr(1);
|
||||
|
@ -72,7 +76,60 @@ export function searchForPage(url: URL, astroRoot: URL): SearchResult {
|
|||
};
|
||||
}
|
||||
|
||||
// Try and load collections (but only for non-extension files)
|
||||
const hasExt = !!path.extname(reqPath);
|
||||
if (!location && !hasExt) {
|
||||
const collection = loadCollection(reqPath, astroRoot);
|
||||
if (collection) {
|
||||
return {
|
||||
statusCode: 200,
|
||||
location: collection.location,
|
||||
pathname: reqPath,
|
||||
currentPage: collection.currentPage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 404,
|
||||
};
|
||||
}
|
||||
|
||||
const crawler = new fdir();
|
||||
|
||||
/** load a collection route */
|
||||
function loadCollection(url: string, astroRoot: URL): { currentPage?: number; location: PageLocation } | undefined {
|
||||
const pages = (crawler.glob('**/*').crawl(path.join(astroRoot.pathname, 'pages')).sync() as PathsOutput).filter(
|
||||
(filepath) => filepath.startsWith('$') || filepath.includes('/$')
|
||||
);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
location: {
|
||||
fileURL: new URL(`./pages/${pageURL}`, astroRoot),
|
||||
snowpackURL: `/_astro/pages/${pageURL}.js`,
|
||||
},
|
||||
currentPage,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** convert a value to a number, if possible */
|
||||
function maybeNum(val: string): string | number {
|
||||
const num = parseFloat(val);
|
||||
if (num.toString() === val) return num;
|
||||
return val;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue