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:
Drew Powers 2021-04-12 17:21:29 -06:00 committed by GitHub
parent 687ff5bacd
commit 3639190b4e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 1736 additions and 1946 deletions

197
README.md
View file

@ -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 dont need processing. */
/** A folder of static files Astro will copy to the root. Useful for favicons, images, and other files that dont 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 youve 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 youve 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 youre used to:
Then write Tailwind in your project just like youre used to:
```html
<style>
@ -148,61 +148,174 @@ Then write Tailwind in your project just like youre used to:
#### 🍱 Collections (beta)
Astros Collections API is useful for grabbing collections of content. Currently only `*.md` files are supported.
Astros 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, wed 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 (well 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
```
Its important to know that these could be anything! Theres 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. Well add these at the end.
Lets start with paginated posts. Since we want `/posts/` to be the root, well 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.
Lets walk through some of the key parts:
If you were to inspect the array, youd 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` well 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. Theres nothing special about them; only consider those examples of how youd use collection data to display everything the way youd 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 wed still like to make `/tag/[tag]/1` and `/year/[year]/1`. To do that, well create 2 more collections: `/astro/pages/$tag.astro` and `astro/pages/$year.astro`. Assume that the markup is the same, but weve 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 isnt 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 (its 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 arent returning all posts here; were 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. Youll grab the year from that date string, and sort probably newest to oldest rather than alphabetical. Youll 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. Dont try and trick `permalink` to generate too many URL trees; itll 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:

View 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>
);
}

View file

@ -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>

View file

@ -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>

View 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>

View 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>

View file

@ -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 dont 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>

File diff suppressed because it is too large Load diff

View file

@ -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
View file

@ -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",

View file

@ -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",

View file

@ -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;
}

View 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: fdirs 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 doesnt: .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 };
}

View file

@ -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; its 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 isnt 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 dont 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,
};
}

View 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;
}

View file

@ -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}) {

View file

@ -12,7 +12,8 @@ const SvelteRenderer: ComponentRenderer<SvelteComponent> = {
render({ Component, root, props }) {
return `new ${Component}({
target: ${root},
props: ${props}
props: ${props},
hydrate: true
})`;
},
};

View file

@ -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; // cant 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 doesnt happen
// This happens because a pageSize is set, but the user isnt on a paginated route. Redirect:
return {
statusCode: 301,
location: reqPath + '/1',
};
}
// if weve 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

View file

@ -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 Snowpacks 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;
}