Content Collections (#5291)

* feat: port content plugin as-is

* feat: add `fetchContent` to `.astro`

* refactor: move more logic to content-internals

* refactor: move internals -> content dir

* feat: nested dir support

* feat: add with-content playground

* edit: update README

* fix: serialize route pattern for Netlify edge

Co-authored-by: Jackie Macharia <jackiewmacharia>

* wip: add ?astro-asset-ssr flag

* wip: exclude astro-ssr-asset styles from build

* revert: SSR asset flag babel changes

* feat: only load CSS when mdx is rendered!

* fix(vercel): Include all files inside `dist/` instead of only `entry.mjs` (#5175)

* chore: only-allow pnpm (#5131)

* [MDX] Support `recmaPlugins` config (#5146)

* feat: support recma plugins

* chore: add `test:match` to MDX

* chore: changeset

* docs: add recmaPlugins to README

* removes default content-visibility styles from image components (#5180)

* wip: check renderContent works

* nit: injectDelayedAssetPlugin doesn't need enforce

* feat: render content with generated entries

* feat: renderContent style injection POC

* wip: scrappy renderContent binding

* deps: parse-imports

* feat: only bind renderContent when imported

* feat: new-and-improved render-content

* fix: update example to use render-content

* refactor: extract normalizeFilename helper

* feat: move renderContent to `.astro`

* feat: add `getStaticPaths` demo 👀

* chore: remove console log

* refactor: internals -> internal

* fix: style bleed on dynamic routes

* chore: remove console log

* refactor: move rendercontentmap inside same file

* fix: style bleed in dev!

* feat: clean up example

* refactor: curry generateContent

* nit: add datetime demo

* refactor: rename and move content plugins

* feat: granular content updates in development

* chore: console.log

* refactor: move write logic, fix edge cases

* fix: use z.any() when ~schema is missing

* refactor: clean up logging

* fix: add delayed asset flag to render map

* fix: use new server loader

* chore: import type

* fix: split delayed assets to separate chunks

* chore: bad merge changesets

* feat: rework content map to Vite globs

* fix: formatted errors

* fix: get `renderContent` working with new APIs

* refactor: rename to getCollection, getEntry, renderEntry

* feat: streamline type alias!

* fix: check schema import is an import

* refactor: change ~schema -> index with defineCollection

* feat: auto apply env.d.ts reference

* feat: expose `headings` on `renderEntry`

* refactor: expose zod from astro/zod

* feat: add Collection util type

* refactor: generated types in src/content/

* edit: remove env.d.ts change from with-content

* feat: collectionToPaths utility

* refactor: migrate `examples/blog`

* refactor: simplify [...slug]

* fix: invalid date

* fix: renderEntry

* feat: migrate examples/docs to content!

* refactor: union -> enum

* fix: remove duplicate header

* chore: changeset

* Revert "fix: serialize route pattern for Netlify edge"

This reverts commit ac46a0d323.

* fix: lint

* fix: include zod in package files

* chore: push type gen for astro check

* feat: move to single defineCollections file

* feat: move examples to new collections model

* chore: type check valid collection names

* fix: ignore collections path in err logs

* refactor: content/index -> content/config

* refactor: defineCollections -> defineCollection

* fix: update docs to defineCollection

* chore: update with-content to new `astro:content` setup

* fix: renderEntry styles

* fix: bad Collection type

* fix: blog types generated

* feat: expose injectedFrontmatter on renderEntry

* chore: `import type`

* chore: add MDX to changeset

* fix: template package file

* fix: unneeded log on content config change

* chore: add `injectedFrontmatter` to generated types

* fix: escape import.meta.env in `body`

* fix: escape import.meta.env in frontmatter

* chore: unused import

* fix: use URL for config pathname

* feat: allow zod async transforms

* deps: devalue

* feat: custom slugs + better type checking

* fix: update types on config added or removed

* chore: add error msg for MDX preview release

* refactor: restore old normalizeFilename

* fix: add loadContentConfig error classes back

* Add initial tests for renderEntry

* add fixture tests

* Use head propagation APIs in renderEntry

* chore: new type gen

* fix: sort content keys before writing

* fix: avoid writing '.' collection with warning

* chore: docs types generated

* fix: generated types only once in build

* fix: use relative path to config for git

* chore: update generated types

* fix: glob pattern on valid exts

* feat: warn on possible collection typos

* chore: better error on bad lazy import

* fix: clear gray-matter cache for rawData

* fix: wait for config file while generating

* test: get collection and get entry basic

* test: multiple collections and entries

* test: add publishedDate test on entry

* tests: getStaticPaths integration

* test: ssr endpoint integration

* chore: consistent test names

* refactor: simplify generates... test

* chore: tests lockfile

* feat: add `experimental` flag

* chore: revert blog and docs examples

* chore: add experimental flag to with-content

* refactor: reuse contentFileExts

* refactor: make contentCollectionCss required

* chore: remove parse-imports dependency

* chore: remove unresolved idea redundancy

* chore: update changeset to minor v

* chore: clean up with-content pass 1

* refactor: de-monolith vite-plugin-content

* chore: remove maximum jank warning (it's acceptable jank)

* refactor: extract types-generator to separate file

* refactor: onEvent -> handleEvent

* edit: change content log to "types generated"

* fix: more experimental checks in prod build

* refactor: 2 jsToTreeNode calls -> 1

* feat: change with-content example to blog example

* fix: Content component type

* Fix use of renderEntry within a slot

* fix: warn for non-underscore _ files, refine logs

* chore: Content type in tests

* fix: types no updating with config changes

* fix: remove `result: SSRResult` type

* chore: entry var shadowing

* fix: bad experimental flag call

* wip: skip render entry tests

* fix: path resolution for .relative on windows

* chore: remove dead code

* wip: log entryPath into

* fix: use URL for entry

* refactor: getEntryInfo normalization

* refactor: move debounce to queueEvent only

* fix: resolve loader on config error

* fix: pathToFileURL -> new URL

* fix: dangerous pathname comparison

* refactor: move rawEvent -> event mapping to queueEvent

* fix: bad type

* wip: remove mkdir call

* wip: log content paths

* fix: more bad paths

* wip: more logs

* fix: correct glob path

* fix: normalize virtual mod path

* fix: content server file paths

* chore: remove render entry test skip

* wip: skip renderEntry tests (windows issues?)

* chore: remove console log

* feat: renderEntry -> render

* chore: add `--experimental-content-collections`

* refactor: only skip renderEntry tests on windows

* refactor: index.ts barrel export

* fix: cli type error

* fix: proper test skip on windows

* refactor: remove collectionToPaths :(

* refactor: use AstroError for frontmatter error

* nit: its schema, .*

* fix: bad isWindows import

* fix: await mod -> mod = await

Co-authored-by: Juan Martín Seery <me@juanm04.com>
Co-authored-by: 花果山大圣 <316783812@qq.com>
Co-authored-by: Tony Sullivan <tony.f.sullivan@outlook.com>
Co-authored-by: Matthew Phillips <matthew@skypack.dev>
Co-authored-by: bholmesdev <bholmesdev@gmail.com>
This commit is contained in:
Ben Holmes 2022-12-16 14:19:53 -05:00 committed by GitHub
parent 6f5746746a
commit 5ec0f6ed55
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
124 changed files with 3896 additions and 64 deletions

View file

@ -0,0 +1,9 @@
---
'astro': minor
'@astrojs/mdx': minor
---
Introduce Content Collections experimental API
- Organize your Markdown and MDX content into easy-to-manage collections.
- Add type safety to your frontmatter with schemas.
- Generate landing pages, static routes, and SSR endpoints from your content using the collection query APIs.

19
examples/with-content/.gitignore vendored Normal file
View file

@ -0,0 +1,19 @@
# build output
dist/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store

View file

@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

View file

@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

View file

@ -0,0 +1,52 @@
# Astro Content Collections (Experimental)
This demos our Blog Starter using **[the experimental Content Collections API.](https://docs.astro.build/en/guides/content-collections)**
[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/withastro/astro/tree/latest/examples/with-content)
[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/s/github/withastro/astro/tree/latest/examples/with-content)
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```
├── public/
├── src/
│ └── content/
│ └── blog/
│ ├── first.md
│ └── second.md
│ └── config.ts
│   ├── components/
│   ├── layouts/
│   └── pages/
├── astro.config.mjs
├── README.md
├── package.json
└── tsconfig.json
```
`src/content/` contains "collections" of related Markdown and MDX documents. Astro will generate a `getCollection` function to retrieve posts from `src/content/`, and type-check your frontmatter using an optional schema (see `src/content/config.ts`).
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :--------------------- | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:3000` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Check out [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
## Credit
This theme is based off of the lovely [Bear Blog](https://github.com/HermanMartinus/bearblog/).

View file

@ -0,0 +1,13 @@
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
// https://astro.build/config
export default defineConfig({
site: 'https://example.com',
integrations: [mdx(), sitemap()],
experimental: {
contentCollections: true,
},
});

View file

@ -0,0 +1,18 @@
{
"name": "@example/with-content",
"type": "module",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"astro": "^1.6.15",
"@astrojs/mdx": "^0.12.2",
"@astrojs/sitemap": "^1.0.0"
}
}

View file

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 36 36">
<path fill="#000" d="M22.25 4h-8.5a1 1 0 0 0-.96.73l-5.54 19.4a.5.5 0 0 0 .62.62l5.05-1.44a2 2 0 0 0 1.38-1.4l3.22-11.66a.5.5 0 0 1 .96 0l3.22 11.67a2 2 0 0 0 1.38 1.39l5.05 1.44a.5.5 0 0 0 .62-.62l-5.54-19.4a1 1 0 0 0-.96-.73Z"/>
<path fill="url(#gradient)" d="M18 28a7.63 7.63 0 0 1-5-2c-1.4 2.1-.35 4.35.6 5.55.14.17.41.07.47-.15.44-1.8 2.93-1.22 2.93.6 0 2.28.87 3.4 1.72 3.81.34.16.59-.2.49-.56-.31-1.05-.29-2.46 1.29-3.25 3-1.5 3.17-4.83 2.5-6-.67.67-2.6 2-5 2Z"/>
<defs>
<linearGradient id="gradient" x1="16" x2="16" y1="32" y2="24" gradientUnits="userSpaceOnUse">
<stop stop-color="#000"/>
<stop offset="1" stop-color="#000" stop-opacity="0"/>
</linearGradient>
</defs>
<style>
@media (prefers-color-scheme:dark){:root{filter:invert(100%)}}
</style>
</svg>

After

Width:  |  Height:  |  Size: 873 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -0,0 +1,11 @@
{
"infiniteLoopProtection": true,
"hardReloadOnChange": false,
"view": "browser",
"template": "node",
"container": {
"port": 3000,
"startScript": "start",
"node": "14"
}
}

View file

@ -0,0 +1,38 @@
---
// Import the global.css file here so that it is included on
// all pages through the use of the <BaseHead /> component.
import '../styles/global.css';
export interface Props {
title: string;
description: string;
image?: string;
}
const { title, description, image = '/placeholder-social.jpg' } = Astro.props;
---
<!-- Global Metadata -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<!-- Primary Meta Tags -->
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content={Astro.url} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={new URL(image, Astro.url)} />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={Astro.url} />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={new URL(image, Astro.url)} />

View file

@ -0,0 +1,13 @@
---
const today = new Date();
---
<footer>
&copy; {today.getFullYear()} YOUR NAME HERE. All rights reserved.
</footer>
<style>
footer {
padding: 25px;
text-align: center;
}
</style>

View file

@ -0,0 +1,25 @@
---
import HeaderLink from './HeaderLink.astro';
import { SITE_TITLE } from '../consts';
---
<header>
<h2>
{SITE_TITLE}
</h2>
<nav>
<HeaderLink href="/">Home</HeaderLink>
<HeaderLink href="/blog">Blog</HeaderLink>
<HeaderLink href="/about">About</HeaderLink>
<HeaderLink href="https://twitter.com/astrodotbuild" target="_blank">Twitter</HeaderLink>
<HeaderLink href="https://github.com/withastro/astro" target="_blank">GitHub</HeaderLink>
</nav>
</header>
<style>
header {
margin: 0em 0 2em;
}
h2 {
margin: 0.5em 0;
}
</style>

View file

@ -0,0 +1,22 @@
---
export interface Props extends astroHTML.JSX.AnchorHTMLAttributes {}
const { href, class: className, ...props } = Astro.props;
const { pathname } = Astro.url;
const isActive = href === pathname || href === pathname.replace(/\/$/, '');
---
<a href={href} class:list={[className, { active: isActive }]} {...props}>
<slot />
</a>
<style>
a {
display: inline-block;
text-decoration: none;
}
a.active {
font-weight: bolder;
text-decoration: underline;
}
</style>

View file

@ -0,0 +1,5 @@
// Place any global data in this file.
// You can import this data from anywhere in your site by using the `import` keyword.
export const SITE_TITLE = 'My personal website.';
export const SITE_DESCRIPTION = 'Welcome to my website!';

View file

@ -0,0 +1,16 @@
---
title: "First post"
description: "Lorem ipsum dolor sit amet"
pubDate: "Jul 08 2022"
heroImage: "/placeholder-hero.jpg"
---
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Vitae ultricies leo integer malesuada nunc vel risus commodo viverra. Adipiscing enim eu turpis egestas pretium. Euismod elementum nisi quis eleifend quam adipiscing. In hac habitasse platea dictumst vestibulum. Sagittis purus sit amet volutpat. Netus et malesuada fames ac turpis egestas. Eget magna fermentum iaculis eu non diam phasellus vestibulum lorem. Varius sit amet mattis vulputate enim. Habitasse platea dictumst quisque sagittis. Integer quis auctor elit sed vulputate mi. Dictumst quisque sagittis purus sit amet.
Morbi tristique senectus et netus. Id semper risus in hendrerit gravida rutrum quisque non tellus. Habitasse platea dictumst quisque sagittis purus sit amet. Tellus molestie nunc non blandit massa. Cursus vitae congue mauris rhoncus. Accumsan tortor posuere ac ut. Fringilla urna porttitor rhoncus dolor. Elit ullamcorper dignissim cras tincidunt lobortis. In cursus turpis massa tincidunt dui ut ornare lectus. Integer feugiat scelerisque varius morbi enim nunc. Bibendum neque egestas congue quisque egestas diam. Cras ornare arcu dui vivamus arcu felis bibendum. Dignissim suspendisse in est ante in nibh mauris. Sed tempus urna et pharetra pharetra massa massa ultricies mi.
Mollis nunc sed id semper risus in. Convallis a cras semper auctor neque. Diam sit amet nisl suscipit. Lacus viverra vitae congue eu consequat ac felis donec. Egestas integer eget aliquet nibh praesent tristique magna sit amet. Eget magna fermentum iaculis eu non diam. In vitae turpis massa sed elementum. Tristique et egestas quis ipsum suspendisse ultrices. Eget lorem dolor sed viverra ipsum. Vel turpis nunc eget lorem dolor sed viverra. Posuere ac ut consequat semper viverra nam. Laoreet suspendisse interdum consectetur libero id faucibus. Diam phasellus vestibulum lorem sed risus ultricies tristique. Rhoncus dolor purus non enim praesent elementum facilisis. Ultrices tincidunt arcu non sodales neque. Tempus egestas sed sed risus pretium quam vulputate. Viverra suspendisse potenti nullam ac tortor vitae purus faucibus ornare. Fringilla urna porttitor rhoncus dolor purus non. Amet dictum sit amet justo donec enim.
Mattis ullamcorper velit sed ullamcorper morbi tincidunt. Tortor posuere ac ut consequat semper viverra. Tellus mauris a diam maecenas sed enim ut sem viverra. Venenatis urna cursus eget nunc scelerisque viverra mauris in. Arcu ac tortor dignissim convallis aenean et tortor at. Curabitur gravida arcu ac tortor dignissim convallis aenean et tortor. Egestas tellus rutrum tellus pellentesque eu. Fusce ut placerat orci nulla pellentesque dignissim enim sit amet. Ut enim blandit volutpat maecenas volutpat blandit aliquam etiam. Id donec ultrices tincidunt arcu. Id cursus metus aliquam eleifend mi.
Tempus quam pellentesque nec nam aliquam sem. Risus at ultrices mi tempus imperdiet. Id porta nibh venenatis cras sed felis eget velit. Ipsum a arcu cursus vitae. Facilisis magna etiam tempor orci eu lobortis elementum. Tincidunt dui ut ornare lectus sit. Quisque non tellus orci ac. Blandit libero volutpat sed cras. Nec tincidunt praesent semper feugiat nibh sed pulvinar proin gravida. Egestas integer eget aliquet nibh praesent tristique magna.

View file

@ -0,0 +1,102 @@
---
title: "Markdown Style Guide"
description: "Here is a sample of some basic Markdown syntax that can be used when writing Markdown content in Astro."
pubDate: "Jul 01 2022"
heroImage: "/placeholder-hero.jpg"
---
Here is a sample of some basic Markdown syntax that can be used when writing Markdown content in Astro.
## Headings
The following HTML `<h1>`—`<h6>` elements represent six levels of section headings. `<h1>` is the highest section level while `<h6>` is the lowest.
# H1
## H2
### H3
#### H4
##### H5
###### H6
## Paragraph
Xerum, quo qui aut unt expliquam qui dolut labo. Aque venitatiusda cum, voluptionse latur sitiae dolessi aut parist aut dollo enim qui voluptate ma dolestendit peritin re plis aut quas inctum laceat est volestemque commosa as cus endigna tectur, offic to cor sequas etum rerum idem sintibus eiur? Quianimin porecus evelectur, cum que nis nust voloribus ratem aut omnimi, sitatur? Quiatem. Nam, omnis sum am facea corem alique molestrunt et eos evelece arcillit ut aut eos eos nus, sin conecerem erum fuga. Ri oditatquam, ad quibus unda veliamenimin cusam et facea ipsamus es exerum sitate dolores editium rerore eost, temped molorro ratiae volorro te reribus dolorer sperchicium faceata tiustia prat.
Itatur? Quiatae cullecum rem ent aut odis in re eossequodi nonsequ idebis ne sapicia is sinveli squiatum, core et que aut hariosam ex eat.
## Images
![This is a placeholder image description](/placeholder-social.jpg)
## Blockquotes
The blockquote element represents content that is quoted from another source, optionally with a citation which must be within a `footer` or `cite` element, and optionally with in-line changes such as annotations and abbreviations.
#### Blockquote without attribution
> Tiam, ad mint andaepu dandae nostion secatur sequo quae.
> **Note** that you can use *Markdown syntax* within a blockquote.
#### Blockquote with attribution
> Don't communicate by sharing memory, share memory by communicating.<br>
> — <cite>Rob Pike[^1]</cite>
[^1]: The above quote is excerpted from Rob Pike's [talk](https://www.youtube.com/watch?v=PAAkCSZUG1c) during Gopherfest, November 18, 2015.
## Tables
| Italics | Bold | Code |
| -------- | -------- | ------ |
| *italics* | **bold** | `code` |
## Code Blocks
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Example HTML5 Document</title>
</head>
<body>
<p>Test</p>
</body>
</html>
```
## List Types
#### Ordered List
1. First item
2. Second item
3. Third item
#### Unordered List
* List item
* Another item
* And another item
#### Nested list
* Fruit
* Apple
* Orange
* Banana
* Dairy
* Milk
* Cheese
## Other Elements — abbr, sub, sup, kbd, mark
<abbr title="Graphics Interchange Format">GIF</abbr> is a bitmap image format.
H<sub>2</sub>O
X<sup>n</sup> + Y<sup>n</sup> = Z<sup>n</sup>
Press <kbd><kbd>CTRL</kbd>+<kbd>ALT</kbd>+<kbd>Delete</kbd></kbd> to end the session.
Most <mark>salamanders</mark> are nocturnal, and hunt for insects, worms, and other small creatures.

View file

@ -0,0 +1,16 @@
---
title: "Second post"
description: "Lorem ipsum dolor sit amet"
pubDate: "Jul 22 2022"
heroImage: "/placeholder-hero.jpg"
---
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Vitae ultricies leo integer malesuada nunc vel risus commodo viverra. Adipiscing enim eu turpis egestas pretium. Euismod elementum nisi quis eleifend quam adipiscing. In hac habitasse platea dictumst vestibulum. Sagittis purus sit amet volutpat. Netus et malesuada fames ac turpis egestas. Eget magna fermentum iaculis eu non diam phasellus vestibulum lorem. Varius sit amet mattis vulputate enim. Habitasse platea dictumst quisque sagittis. Integer quis auctor elit sed vulputate mi. Dictumst quisque sagittis purus sit amet.
Morbi tristique senectus et netus. Id semper risus in hendrerit gravida rutrum quisque non tellus. Habitasse platea dictumst quisque sagittis purus sit amet. Tellus molestie nunc non blandit massa. Cursus vitae congue mauris rhoncus. Accumsan tortor posuere ac ut. Fringilla urna porttitor rhoncus dolor. Elit ullamcorper dignissim cras tincidunt lobortis. In cursus turpis massa tincidunt dui ut ornare lectus. Integer feugiat scelerisque varius morbi enim nunc. Bibendum neque egestas congue quisque egestas diam. Cras ornare arcu dui vivamus arcu felis bibendum. Dignissim suspendisse in est ante in nibh mauris. Sed tempus urna et pharetra pharetra massa massa ultricies mi.
Mollis nunc sed id semper risus in. Convallis a cras semper auctor neque. Diam sit amet nisl suscipit. Lacus viverra vitae congue eu consequat ac felis donec. Egestas integer eget aliquet nibh praesent tristique magna sit amet. Eget magna fermentum iaculis eu non diam. In vitae turpis massa sed elementum. Tristique et egestas quis ipsum suspendisse ultrices. Eget lorem dolor sed viverra ipsum. Vel turpis nunc eget lorem dolor sed viverra. Posuere ac ut consequat semper viverra nam. Laoreet suspendisse interdum consectetur libero id faucibus. Diam phasellus vestibulum lorem sed risus ultricies tristique. Rhoncus dolor purus non enim praesent elementum facilisis. Ultrices tincidunt arcu non sodales neque. Tempus egestas sed sed risus pretium quam vulputate. Viverra suspendisse potenti nullam ac tortor vitae purus faucibus ornare. Fringilla urna porttitor rhoncus dolor purus non. Amet dictum sit amet justo donec enim.
Mattis ullamcorper velit sed ullamcorper morbi tincidunt. Tortor posuere ac ut consequat semper viverra. Tellus mauris a diam maecenas sed enim ut sem viverra. Venenatis urna cursus eget nunc scelerisque viverra mauris in. Arcu ac tortor dignissim convallis aenean et tortor at. Curabitur gravida arcu ac tortor dignissim convallis aenean et tortor. Egestas tellus rutrum tellus pellentesque eu. Fusce ut placerat orci nulla pellentesque dignissim enim sit amet. Ut enim blandit volutpat maecenas volutpat blandit aliquam etiam. Id donec ultrices tincidunt arcu. Id cursus metus aliquam eleifend mi.
Tempus quam pellentesque nec nam aliquam sem. Risus at ultrices mi tempus imperdiet. Id porta nibh venenatis cras sed felis eget velit. Ipsum a arcu cursus vitae. Facilisis magna etiam tempor orci eu lobortis elementum. Tincidunt dui ut ornare lectus sit. Quisque non tellus orci ac. Blandit libero volutpat sed cras. Nec tincidunt praesent semper feugiat nibh sed pulvinar proin gravida. Egestas integer eget aliquet nibh praesent tristique magna.

View file

@ -0,0 +1,16 @@
---
title: "Third post"
description: "Lorem ipsum dolor sit amet"
pubDate: "Jul 15 2022"
heroImage: "/placeholder-hero.jpg"
---
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Vitae ultricies leo integer malesuada nunc vel risus commodo viverra. Adipiscing enim eu turpis egestas pretium. Euismod elementum nisi quis eleifend quam adipiscing. In hac habitasse platea dictumst vestibulum. Sagittis purus sit amet volutpat. Netus et malesuada fames ac turpis egestas. Eget magna fermentum iaculis eu non diam phasellus vestibulum lorem. Varius sit amet mattis vulputate enim. Habitasse platea dictumst quisque sagittis. Integer quis auctor elit sed vulputate mi. Dictumst quisque sagittis purus sit amet.
Morbi tristique senectus et netus. Id semper risus in hendrerit gravida rutrum quisque non tellus. Habitasse platea dictumst quisque sagittis purus sit amet. Tellus molestie nunc non blandit massa. Cursus vitae congue mauris rhoncus. Accumsan tortor posuere ac ut. Fringilla urna porttitor rhoncus dolor. Elit ullamcorper dignissim cras tincidunt lobortis. In cursus turpis massa tincidunt dui ut ornare lectus. Integer feugiat scelerisque varius morbi enim nunc. Bibendum neque egestas congue quisque egestas diam. Cras ornare arcu dui vivamus arcu felis bibendum. Dignissim suspendisse in est ante in nibh mauris. Sed tempus urna et pharetra pharetra massa massa ultricies mi.
Mollis nunc sed id semper risus in. Convallis a cras semper auctor neque. Diam sit amet nisl suscipit. Lacus viverra vitae congue eu consequat ac felis donec. Egestas integer eget aliquet nibh praesent tristique magna sit amet. Eget magna fermentum iaculis eu non diam. In vitae turpis massa sed elementum. Tristique et egestas quis ipsum suspendisse ultrices. Eget lorem dolor sed viverra ipsum. Vel turpis nunc eget lorem dolor sed viverra. Posuere ac ut consequat semper viverra nam. Laoreet suspendisse interdum consectetur libero id faucibus. Diam phasellus vestibulum lorem sed risus ultricies tristique. Rhoncus dolor purus non enim praesent elementum facilisis. Ultrices tincidunt arcu non sodales neque. Tempus egestas sed sed risus pretium quam vulputate. Viverra suspendisse potenti nullam ac tortor vitae purus faucibus ornare. Fringilla urna porttitor rhoncus dolor purus non. Amet dictum sit amet justo donec enim.
Mattis ullamcorper velit sed ullamcorper morbi tincidunt. Tortor posuere ac ut consequat semper viverra. Tellus mauris a diam maecenas sed enim ut sem viverra. Venenatis urna cursus eget nunc scelerisque viverra mauris in. Arcu ac tortor dignissim convallis aenean et tortor at. Curabitur gravida arcu ac tortor dignissim convallis aenean et tortor. Egestas tellus rutrum tellus pellentesque eu. Fusce ut placerat orci nulla pellentesque dignissim enim sit amet. Ut enim blandit volutpat maecenas volutpat blandit aliquam etiam. Id donec ultrices tincidunt arcu. Id cursus metus aliquam eleifend mi.
Tempus quam pellentesque nec nam aliquam sem. Risus at ultrices mi tempus imperdiet. Id porta nibh venenatis cras sed felis eget velit. Ipsum a arcu cursus vitae. Facilisis magna etiam tempor orci eu lobortis elementum. Tincidunt dui ut ornare lectus sit. Quisque non tellus orci ac. Blandit libero volutpat sed cras. Nec tincidunt praesent semper feugiat nibh sed pulvinar proin gravida. Egestas integer eget aliquet nibh praesent tristique magna.

View file

@ -0,0 +1,31 @@
---
title: 'Using MDX'
description: 'Lorem ipsum dolor sit amet'
pubDate: 'Jul 02 2022'
heroImage: '/placeholder-hero.jpg'
---
This theme comes with the [@astrojs/mdx](https://docs.astro.build/en/guides/integrations-guide/mdx/) integration installed and configured in your `astro.config.mjs` config file. If you prefer not to use MDX, you can disable support by removing the integration from your config file.
## Why MDX?
MDX is a special flavor of Markdown that supports embedded JavaScript & JSX syntax. This unlocks the ability to [mix JavaScript and UI Components into your Markdown content](https://docs.astro.build/en/guides/markdown-content/#mdx-features) for things like interactive charts or alerts.
If you have existing content authored in MDX, this integration will hopefully make migrating to Astro a breeze.
## Example
Here is how you import and use a UI component inside of MDX.
When you open this page in the browser, you should see the clickable button below.
import HeaderLink from '../../components/HeaderLink.astro';
<HeaderLink href="#" onclick="alert('clicked!')">
Embedded component in MDX
</HeaderLink>
## More Links
- [MDX Syntax Documentation](https://mdxjs.com/docs/what-is-mdx)
- [Astro Usage Documentation](https://docs.astro.build/en/guides/markdown-content/#markdown-and-mdx-pages)
- **Note:** [Client Directives](https://docs.astro.build/en/reference/directives-reference/#client-directives) are still required to create interactive components. Otherwise, all components in your MDX will render as static HTML (no JavaScript) by default.

View file

@ -0,0 +1,18 @@
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
// Type-check frontmatter using a schema
schema: {
title: z.string(),
description: z.string(),
// Transform string to Date object
pubDate: z.string().transform((str) => new Date(str)),
updatedDate: z
.string()
.optional()
.transform((str) => (str ? new Date(str) : undefined)),
heroImage: z.string().optional(),
},
});
export const collections = { blog };

View file

@ -0,0 +1,86 @@
declare module 'astro:content' {
export { z } from 'astro/zod';
export type CollectionEntry<C extends keyof typeof entryMap> =
typeof entryMap[C][keyof typeof entryMap[C]] & Render;
type BaseCollectionConfig<S extends import('astro/zod').ZodRawShape> = {
schema?: S;
slug?: (entry: {
id: CollectionEntry<keyof typeof entryMap>['id'];
defaultSlug: string;
collection: string;
body: string;
data: import('astro/zod').infer<import('astro/zod').ZodObject<S>>;
}) => string | Promise<string>;
};
export function defineCollection<S extends import('astro/zod').ZodRawShape>(
input: BaseCollectionConfig<S>
): BaseCollectionConfig<S>;
export function getEntry<C extends keyof typeof entryMap, E extends keyof typeof entryMap[C]>(
collection: C,
entryKey: E
): Promise<typeof entryMap[C][E] & Render>;
export function getCollection<
C extends keyof typeof entryMap,
E extends keyof typeof entryMap[C]
>(
collection: C,
filter?: (data: typeof entryMap[C][E]) => boolean
): Promise<(typeof entryMap[C][E] & Render)[]>;
type InferEntrySchema<C extends keyof typeof entryMap> = import('astro/zod').infer<
import('astro/zod').ZodObject<Required<ContentConfig['collections'][C]>['schema']>
>;
type Render = {
render(): Promise<{
Content: import('astro').MarkdownInstance<{}>['Content'];
headings: import('astro').MarkdownHeading[];
injectedFrontmatter: Record<string, any>;
}>;
};
const entryMap: {
"blog": {
"first-post.md": {
id: "first-post.md",
slug: "first-post",
body: string,
collection: "blog",
data: InferEntrySchema<"blog">
},
"markdown-style-guide.md": {
id: "markdown-style-guide.md",
slug: "markdown-style-guide",
body: string,
collection: "blog",
data: InferEntrySchema<"blog">
},
"second-post.md": {
id: "second-post.md",
slug: "second-post",
body: string,
collection: "blog",
data: InferEntrySchema<"blog">
},
"third-post.md": {
id: "third-post.md",
slug: "third-post",
body: string,
collection: "blog",
data: InferEntrySchema<"blog">
},
"using-mdx.mdx": {
id: "using-mdx.mdx",
slug: "using-mdx",
body: string,
collection: "blog",
data: InferEntrySchema<"blog">
},
},
};
type ContentConfig = typeof import("./config");
}

1
examples/with-content/src/env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="astro/client" />

View file

@ -0,0 +1,49 @@
---
import BaseHead from '../components/BaseHead.astro';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import type { CollectionEntry } from 'astro:content';
type Props = CollectionEntry<'blog'>['data'];
const {
title, description, pubDate, updatedDate, heroImage,
} = Astro.props;
---
<html lang="en">
<head>
<BaseHead title={title} description={description} />
<style>
.title {
font-size: 2em;
margin: 0.25em 0 0;
}
hr {
border-top: 1px solid #ddd;
margin: 1rem 0;
}
</style>
</head>
<body>
<Header />
<main>
<article>
{heroImage && <img width={720} height={360} src={heroImage} alt="" />}
<h1 class="title">{title}</h1>
{pubDate && <time>{pubDate}</time>}
{
updatedDate && (
<div>
Last updated on <time>{updatedDate}</time>
</div>
)
}
<hr />
<slot />
</article>
</main>
<Footer />
</body>
</html>

View file

@ -0,0 +1,17 @@
---
layout: "../layouts/BlogPost.astro"
title: "About Me"
description: "Lorem ipsum dolor sit amet"
updatedDate: "August 08 2022"
heroImage: "/placeholder-about.jpg"
---
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Vitae ultricies leo integer malesuada nunc vel risus commodo viverra. Adipiscing enim eu turpis egestas pretium. Euismod elementum nisi quis eleifend quam adipiscing. In hac habitasse platea dictumst vestibulum. Sagittis purus sit amet volutpat. Netus et malesuada fames ac turpis egestas. Eget magna fermentum iaculis eu non diam phasellus vestibulum lorem. Varius sit amet mattis vulputate enim. Habitasse platea dictumst quisque sagittis. Integer quis auctor elit sed vulputate mi. Dictumst quisque sagittis purus sit amet.
Morbi tristique senectus et netus. Id semper risus in hendrerit gravida rutrum quisque non tellus. Habitasse platea dictumst quisque sagittis purus sit amet. Tellus molestie nunc non blandit massa. Cursus vitae congue mauris rhoncus. Accumsan tortor posuere ac ut. Fringilla urna porttitor rhoncus dolor. Elit ullamcorper dignissim cras tincidunt lobortis. In cursus turpis massa tincidunt dui ut ornare lectus. Integer feugiat scelerisque varius morbi enim nunc. Bibendum neque egestas congue quisque egestas diam. Cras ornare arcu dui vivamus arcu felis bibendum. Dignissim suspendisse in est ante in nibh mauris. Sed tempus urna et pharetra pharetra massa massa ultricies mi.
Mollis nunc sed id semper risus in. Convallis a cras semper auctor neque. Diam sit amet nisl suscipit. Lacus viverra vitae congue eu consequat ac felis donec. Egestas integer eget aliquet nibh praesent tristique magna sit amet. Eget magna fermentum iaculis eu non diam. In vitae turpis massa sed elementum. Tristique et egestas quis ipsum suspendisse ultrices. Eget lorem dolor sed viverra ipsum. Vel turpis nunc eget lorem dolor sed viverra. Posuere ac ut consequat semper viverra nam. Laoreet suspendisse interdum consectetur libero id faucibus. Diam phasellus vestibulum lorem sed risus ultricies tristique. Rhoncus dolor purus non enim praesent elementum facilisis. Ultrices tincidunt arcu non sodales neque. Tempus egestas sed sed risus pretium quam vulputate. Viverra suspendisse potenti nullam ac tortor vitae purus faucibus ornare. Fringilla urna porttitor rhoncus dolor purus non. Amet dictum sit amet justo donec enim.
Mattis ullamcorper velit sed ullamcorper morbi tincidunt. Tortor posuere ac ut consequat semper viverra. Tellus mauris a diam maecenas sed enim ut sem viverra. Venenatis urna cursus eget nunc scelerisque viverra mauris in. Arcu ac tortor dignissim convallis aenean et tortor at. Curabitur gravida arcu ac tortor dignissim convallis aenean et tortor. Egestas tellus rutrum tellus pellentesque eu. Fusce ut placerat orci nulla pellentesque dignissim enim sit amet. Ut enim blandit volutpat maecenas volutpat blandit aliquam etiam. Id donec ultrices tincidunt arcu. Id cursus metus aliquam eleifend mi.
Tempus quam pellentesque nec nam aliquam sem. Risus at ultrices mi tempus imperdiet. Id porta nibh venenatis cras sed felis eget velit. Ipsum a arcu cursus vitae. Facilisis magna etiam tempor orci eu lobortis elementum. Tincidunt dui ut ornare lectus sit. Quisque non tellus orci ac. Blandit libero volutpat sed cras. Nec tincidunt praesent semper feugiat nibh sed pulvinar proin gravida. Egestas integer eget aliquet nibh praesent tristique magna.

View file

@ -0,0 +1,21 @@
---
import { CollectionEntry, getCollection } from "astro:content";
import BlogPost from "../../layouts/BlogPost.astro";
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map(post => ({
params: { slug: post.slug },
props: post,
}));
}
type Props = CollectionEntry<'blog'>;
const post = Astro.props;
const { Content } = await post.render();
---
<BlogPost {...post.data}>
<h1>{post.data.title}</h1>
<Content />
</BlogPost>

View file

@ -0,0 +1,57 @@
---
import BaseHead from '../../components/BaseHead.astro';
import Header from '../../components/Header.astro';
import Footer from '../../components/Footer.astro';
import { SITE_TITLE, SITE_DESCRIPTION } from '../../consts';
import { getCollection } from 'astro:content';
const posts = (await getCollection('blog')).sort(
(a, b) => a.data.pubDate.valueOf() - b.data.pubDate.valueOf()
)
---
<!DOCTYPE html>
<html lang="en">
<head>
<BaseHead title={SITE_TITLE} description={SITE_DESCRIPTION} />
<style>
ul {
list-style-type: none;
padding: unset;
}
ul li {
display: flex;
}
ul li time {
flex: 0 0 130px;
font-style: italic;
color: #595959;
}
ul li a:visited {
color: #8e32dc;
}
</style>
</head>
<body>
<Header />
<main>
<section>
<ul>
{posts.map((post) => (
<li>
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</time>
<a href={'/blog/' + post.slug}>{post.data.title}</a>
</li>
))}
</ul>
</section>
</main>
<Footer />
</body>
</html>

View file

@ -0,0 +1,50 @@
---
import BaseHead from '../components/BaseHead.astro';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import { SITE_TITLE, SITE_DESCRIPTION } from '../consts';
---
<!DOCTYPE html>
<html lang="en">
<head>
<BaseHead title={SITE_TITLE} description={SITE_DESCRIPTION} />
</head>
<body>
<Header title={SITE_TITLE} />
<main>
<h1>🧑‍🚀 Hello, Astronaut!</h1>
<p>
Welcome to the official <a href="https://astro.build/">Astro</a> blog starter template. This
template serves as a lightweight, minimally-styled starting point for anyone looking to build
a personal website, blog, or portfolio with Astro.
</p>
<p>
This template comes with a few integrations already configured in your
<code>astro.config.mjs</code> file. You can customize your setup with
<a href="https://astro.build/integrations">Astro Integrations</a> to add tools like Tailwind,
React, or Vue to your project.
</p>
<p>Here are a few ideas on how to get started with the template:</p>
<ul>
<li>Edit this page in <code>src/pages/index.astro</code></li>
<li>Edit the site header items in <code>src/components/Header.astro</code></li>
<li>Add your name to the footer in <code>src/components/Footer.astro</code></li>
<li>Check out the included blog posts in <code>src/pages/blog/</code></li>
<li>Customize the blog post page layout in <code>src/layouts/BlogPost.astro</code></li>
</ul>
<p>
Have fun! If you get stuck, remember to <a href="https://docs.astro.build/"
>read the docs
</a> or <a href="https://astro.build/chat">join us on Discord</a> to ask questions.
</p>
<p>
Looking for a blog template with a bit more personality? Check out <a
href="https://github.com/Charca/astro-blog-template"
>astro-blog-template
</a> by <a href="https://twitter.com/Charca">Maxi Ferreira</a>.
</p>
</main>
<Footer />
</body>
</html>

View file

@ -0,0 +1,67 @@
/*
The CSS in this style tag is based off of Bear Blog's default CSS.
https://github.com/HermanMartinus/bearblog/blob/297026a877bc2ab2b3bdfbd6b9f7961c350917dd/templates/styles/blog/default.css
License MIT: https://github.com/HermanMartinus/bearblog/blob/master/LICENSE.md
*/
body {
font-family: Verdana, sans-serif;
margin: auto;
padding: 20px;
max-width: 65ch;
text-align: left;
background-color: #fff;
word-wrap: break-word;
overflow-wrap: break-word;
line-height: 1.5;
color: #444;
}
h1,
h2,
h3,
h4,
h5,
h6,
strong,
b {
color: #222;
}
a {
color: #3273dc;
}
nav a {
margin-right: 10px;
}
textarea {
width: 100%;
font-size: 16px;
}
input {
font-size: 16px;
}
content {
line-height: 1.6;
}
table {
width: 100%;
}
img {
max-width: 100%;
height: auto;
}
code {
padding: 2px 5px;
background-color: #f2f2f2;
}
pre {
padding: 1rem;
}
pre > code {
all: unset;
}
blockquote {
border: 1px solid #999;
color: #222;
padding: 2px 0px 2px 20px;
margin: 0px;
font-style: italic;
}

View file

@ -0,0 +1,3 @@
{
"extends": "astro/tsconfigs/base"
}

View file

@ -89,6 +89,7 @@
"eslint-plugin-no-only-tests": "^2.6.0", "eslint-plugin-no-only-tests": "^2.6.0",
"eslint-plugin-prettier": "^4.0.0", "eslint-plugin-prettier": "^4.0.0",
"execa": "^6.1.0", "execa": "^6.1.0",
"only-allow": "^1.1.1",
"organize-imports-cli": "^0.10.0", "organize-imports-cli": "^0.10.0",
"prettier": "^2.7.0", "prettier": "^2.7.0",
"prettier-plugin-astro": "^0.7.0", "prettier-plugin-astro": "^0.7.0",

View file

@ -47,6 +47,7 @@
"./client/*": "./dist/runtime/client/*", "./client/*": "./dist/runtime/client/*",
"./components": "./components/index.ts", "./components": "./components/index.ts",
"./components/*": "./components/*", "./components/*": "./components/*",
"./content/internal": "./dist/content/internal.js",
"./debug": "./components/Debug.astro", "./debug": "./components/Debug.astro",
"./internal/*": "./dist/runtime/server/*", "./internal/*": "./dist/runtime/server/*",
"./package.json": "./package.json", "./package.json": "./package.json",
@ -62,7 +63,11 @@
"./vite-plugin-markdown-legacy/*": "./dist/vite-plugin-markdown-legacy/*", "./vite-plugin-markdown-legacy/*": "./dist/vite-plugin-markdown-legacy/*",
"./vite-plugin-markdown": "./dist/vite-plugin-markdown/index.js", "./vite-plugin-markdown": "./dist/vite-plugin-markdown/index.js",
"./vite-plugin-markdown/*": "./dist/vite-plugin-markdown/*", "./vite-plugin-markdown/*": "./dist/vite-plugin-markdown/*",
"./dist/jsx/*": "./dist/jsx/*" "./dist/jsx/*": "./dist/jsx/*",
"./zod": {
"types": "./zod.d.ts",
"default": "./zod.mjs"
}
}, },
"imports": { "imports": {
"#astro/*": "./dist/*.js" "#astro/*": "./dist/*.js"
@ -77,6 +82,8 @@
"astro.js", "astro.js",
"config.d.ts", "config.d.ts",
"config.mjs", "config.mjs",
"zod.d.ts",
"zod.mjs",
"env.d.ts", "env.d.ts",
"client.d.ts", "client.d.ts",
"client-base.d.ts", "client-base.d.ts",
@ -84,7 +91,8 @@
"astro-jsx.d.ts", "astro-jsx.d.ts",
"types.d.ts", "types.d.ts",
"README.md", "README.md",
"vendor" "vendor",
"src/content/template"
], ],
"scripts": { "scripts": {
"prebuild": "astro-scripts prebuild --to-string \"src/runtime/server/astro-island.ts\" \"src/runtime/client/{idle,load,media,only,visible}.ts\"", "prebuild": "astro-scripts prebuild --to-string \"src/runtime/server/astro-island.ts\" \"src/runtime/client/{idle,load,media,only,visible}.ts\"",
@ -122,6 +130,7 @@
"cookie": "^0.5.0", "cookie": "^0.5.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"deepmerge-ts": "^4.2.2", "deepmerge-ts": "^4.2.2",
"devalue": "^4.2.0",
"diff": "^5.1.0", "diff": "^5.1.0",
"es-module-lexer": "^1.1.0", "es-module-lexer": "^1.1.0",
"execa": "^6.1.0", "execa": "^6.1.0",

View file

@ -84,6 +84,7 @@ export interface CLIFlags {
drafts?: boolean; drafts?: boolean;
experimentalErrorOverlay?: boolean; experimentalErrorOverlay?: boolean;
experimentalPrerender?: boolean; experimentalPrerender?: boolean;
experimentalContentCollections?: boolean;
} }
export interface BuildConfig { export interface BuildConfig {
@ -930,6 +931,25 @@ export interface AstroUserConfig {
* ``` * ```
*/ */
prerender?: boolean; prerender?: boolean;
/**
* @docs
* @name experimental.contentCollections
* @type {boolean}
* @default `false`
* @version 1.7.0
* @description
* Enable experimental support for [Content Collections](/en/guides/content-collections). This makes the `src/content/` directory a reserved directory for Astro to manage, and introduces the `astro:content` module for querying this content.
*
* To enable this feature, set `experimental.contentCollections` to `true` in your Astro config:
*
* ```js
* {
* experimental: {
* contentCollections: true,
* },
* }
*/
contentCollections?: boolean;
}; };
// Legacy options to be removed // Legacy options to be removed

View file

@ -0,0 +1,10 @@
export const contentFileExts = ['.md', '.mdx'];
export const DELAYED_ASSET_FLAG = 'astroAssetSsr';
export const CONTENT_FLAG = 'astroContent';
export const VIRTUAL_MODULE_ID = 'astro:content';
export const LINKS_PLACEHOLDER = '@@ASTRO-LINKS@@';
export const STYLES_PLACEHOLDER = '@@ASTRO-STYLES@@';
export const CONTENT_BASE = 'types.generated';
export const CONTENT_FILE = CONTENT_BASE + '.mjs';
export const CONTENT_TYPES_FILE = CONTENT_BASE + '.d.ts';

View file

@ -0,0 +1,6 @@
export {
astroDelayedAssetPlugin,
astroBundleDelayedAssetPlugin,
} from './vite-plugin-content-assets.js';
export { astroContentServerPlugin } from './vite-plugin-content-server.js';
export { astroContentVirtualModPlugin } from './vite-plugin-content-virtual-mod.js';

View file

@ -0,0 +1,150 @@
import { prependForwardSlash } from '../core/path.js';
import {
createComponent,
createHeadAndContent,
renderComponent,
renderTemplate,
renderUniqueStylesheet,
renderStyleElement,
unescapeHTML,
} from '../runtime/server/index.js';
type GlobResult = Record<string, () => Promise<any>>;
type CollectionToEntryMap = Record<string, GlobResult>;
export function createCollectionToGlobResultMap({
globResult,
contentDir,
}: {
globResult: GlobResult;
contentDir: string;
}) {
const collectionToGlobResultMap: CollectionToEntryMap = {};
for (const key in globResult) {
const keyRelativeToContentDir = key.replace(new RegExp(`^${contentDir}`), '');
const segments = keyRelativeToContentDir.split('/');
if (segments.length <= 1) continue;
const collection = segments[0];
const entryId = segments.slice(1).join('/');
collectionToGlobResultMap[collection] ??= {};
collectionToGlobResultMap[collection][entryId] = globResult[key];
}
return collectionToGlobResultMap;
}
export function createGetCollection({
collectionToEntryMap,
collectionToRenderEntryMap,
}: {
collectionToEntryMap: CollectionToEntryMap;
collectionToRenderEntryMap: CollectionToEntryMap;
}) {
return async function getCollection(collection: string, filter?: () => boolean) {
const lazyImports = Object.values(collectionToEntryMap[collection] ?? {});
const entries = Promise.all(
lazyImports.map(async (lazyImport) => {
const entry = await lazyImport();
return {
id: entry.id,
slug: entry.slug,
body: entry.body,
collection: entry.collection,
data: entry.data,
async render() {
return render({
collection: entry.collection,
id: entry.id,
collectionToRenderEntryMap,
});
},
};
})
);
if (typeof filter === 'function') {
return (await entries).filter(filter);
} else {
return entries;
}
};
}
export function createGetEntry({
collectionToEntryMap,
collectionToRenderEntryMap,
}: {
collectionToEntryMap: CollectionToEntryMap;
collectionToRenderEntryMap: CollectionToEntryMap;
}) {
return async function getEntry(collection: string, entryId: string) {
const lazyImport = collectionToEntryMap[collection]?.[entryId];
if (!lazyImport) throw new Error(`Failed to import ${JSON.stringify(entryId)}.`);
const entry = await lazyImport();
return {
id: entry.id,
slug: entry.slug,
body: entry.body,
collection: entry.collection,
data: entry.data,
async render() {
return render({
collection: entry.collection,
id: entry.id,
collectionToRenderEntryMap,
});
},
};
};
}
async function render({
collection,
id,
collectionToRenderEntryMap,
}: {
collection: string;
id: string;
collectionToRenderEntryMap: CollectionToEntryMap;
}) {
const lazyImport = collectionToRenderEntryMap[collection]?.[id];
if (!lazyImport) throw new Error(`${String(collection)}${String(id)} does not exist.`);
const mod = await lazyImport();
const Content = createComponent({
factory(result, props, slots) {
let styles = '',
links = '';
if (Array.isArray(mod?.collectedStyles)) {
styles = mod.collectedStyles.map((style: any) => renderStyleElement(style)).join('');
}
if (Array.isArray(mod?.collectedLinks)) {
links = mod.collectedLinks
.map((link: any) => {
return renderUniqueStylesheet(result, {
href: prependForwardSlash(link),
});
})
.join('');
}
return createHeadAndContent(
unescapeHTML(styles + links) as any,
renderTemplate`${renderComponent(result, 'Content', mod.Content, props, slots)}`
);
},
propagation: 'self',
});
if (!mod._internal && id.endsWith('.mdx')) {
throw new Error(
`[Content] Failed to render MDX entry. Try installing @astrojs/mdx@next--content-schemas`
);
}
return {
Content,
headings: mod.getHeadings(),
injectedFrontmatter: mod._internal.injectedFrontmatter,
};
}

View file

@ -0,0 +1,49 @@
declare module 'astro:content' {
export { z } from 'astro/zod';
export type CollectionEntry<C extends keyof typeof entryMap> =
typeof entryMap[C][keyof typeof entryMap[C]] & Render;
type BaseCollectionConfig<S extends import('astro/zod').ZodRawShape> = {
schema?: S;
slug?: (entry: {
id: CollectionEntry<keyof typeof entryMap>['id'];
defaultSlug: string;
collection: string;
body: string;
data: import('astro/zod').infer<import('astro/zod').ZodObject<S>>;
}) => string | Promise<string>;
};
export function defineCollection<S extends import('astro/zod').ZodRawShape>(
input: BaseCollectionConfig<S>
): BaseCollectionConfig<S>;
export function getEntry<C extends keyof typeof entryMap, E extends keyof typeof entryMap[C]>(
collection: C,
entryKey: E
): Promise<typeof entryMap[C][E] & Render>;
export function getCollection<
C extends keyof typeof entryMap,
E extends keyof typeof entryMap[C]
>(
collection: C,
filter?: (data: typeof entryMap[C][E]) => boolean
): Promise<(typeof entryMap[C][E] & Render)[]>;
type InferEntrySchema<C extends keyof typeof entryMap> = import('astro/zod').infer<
import('astro/zod').ZodObject<Required<ContentConfig['collections'][C]>['schema']>
>;
type Render = {
render(): Promise<{
Content: import('astro').MarkdownInstance<{}>['Content'];
headings: import('astro').MarkdownHeading[];
injectedFrontmatter: Record<string, any>;
}>;
};
const entryMap: {
// @@ENTRY_MAP@@
};
type ContentConfig = '@@CONTENT_CONFIG_TYPE@@';
}

View file

@ -0,0 +1,40 @@
// astro-head-inject
import {
createGetCollection,
createGetEntry,
createCollectionToGlobResultMap,
} from 'astro/content/internal';
export { z } from 'astro/zod';
export function defineCollection(config) {
return config;
}
const contentDir = '@@CONTENT_DIR@@';
const entryGlob = import.meta.glob('@@ENTRY_GLOB_PATH@@', {
query: { astroContent: true },
});
const collectionToEntryMap = createCollectionToGlobResultMap({
globResult: entryGlob,
contentDir,
});
const renderEntryGlob = import.meta.glob('@@RENDER_ENTRY_GLOB_PATH@@', {
query: { astroAssetSsr: true },
});
const collectionToRenderEntryMap = createCollectionToGlobResultMap({
globResult: renderEntryGlob,
contentDir,
});
export const getCollection = createGetCollection({
collectionToEntryMap,
collectionToRenderEntryMap,
});
export const getEntry = createGetEntry({
collectionToEntryMap,
collectionToRenderEntryMap,
});

View file

@ -0,0 +1,356 @@
import { normalizePath } from 'vite';
import glob from 'fast-glob';
import fsMod from 'node:fs';
import * as path from 'node:path';
import { cyan } from 'kleur/colors';
import { info, LogOptions, warn } from '../core/logger/core.js';
import type { AstroSettings } from '../@types/astro.js';
import { appendForwardSlash, isRelativePath } from '../core/path.js';
import { contentFileExts, CONTENT_TYPES_FILE } from './consts.js';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { ContentConfig, loadContentConfig, ContentPaths, ContentObservable } from './utils.js';
type ChokidarEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir';
type RawContentEvent = { name: ChokidarEvent; entry: string };
type ContentEvent = { name: ChokidarEvent; entry: URL };
type EntryInfo = {
id: string;
slug: string;
collection: string;
};
export type GenerateContentTypes = {
init(): Promise<void>;
queueEvent(event: RawContentEvent): void;
};
type ContentTypesEntryMetadata = { slug: string };
type ContentTypes = Record<string, Record<string, ContentTypesEntryMetadata>>;
type CreateContentGeneratorParams = {
contentPaths: ContentPaths;
contentConfigObserver: ContentObservable;
logging: LogOptions;
settings: AstroSettings;
fs: typeof fsMod;
};
type EventOpts = { logLevel: 'info' | 'warn' };
class UnsupportedFileTypeError extends Error {}
export async function createContentTypesGenerator({
contentPaths,
contentConfigObserver,
fs,
logging,
settings,
}: CreateContentGeneratorParams): Promise<GenerateContentTypes> {
const contentTypes: ContentTypes = {};
let events: Promise<{ shouldGenerateTypes: boolean; error?: Error }>[] = [];
let debounceTimeout: NodeJS.Timeout | undefined;
const contentTypesBase = await fsMod.promises.readFile(
new URL(CONTENT_TYPES_FILE, contentPaths.generatedInputDir),
'utf-8'
);
async function init() {
await handleEvent({ name: 'add', entry: contentPaths.config }, { logLevel: 'warn' });
const globResult = await glob('./**/*.*', {
cwd: fileURLToPath(contentPaths.contentDir),
fs: {
readdir: fs.readdir.bind(fs),
readdirSync: fs.readdirSync.bind(fs),
},
});
const entries = globResult
.map((e) => new URL(e, contentPaths.contentDir))
.filter(
// Config loading handled first. Avoid running twice.
(e) => !e.href.startsWith(contentPaths.config.href)
);
for (const entry of entries) {
events.push(handleEvent({ name: 'add', entry }, { logLevel: 'warn' }));
}
await runEvents();
}
async function handleEvent(
event: ContentEvent,
opts?: EventOpts
): Promise<{ shouldGenerateTypes: boolean; error?: Error }> {
const logLevel = opts?.logLevel ?? 'info';
if (event.name === 'addDir' || event.name === 'unlinkDir') {
const collection = normalizePath(
path.relative(fileURLToPath(contentPaths.contentDir), fileURLToPath(event.entry))
);
// If directory is multiple levels deep, it is not a collection. Ignore event.
const isCollectionEvent = collection.split('/').length === 1;
if (!isCollectionEvent) return { shouldGenerateTypes: false };
switch (event.name) {
case 'addDir':
addCollection(contentTypes, JSON.stringify(collection));
if (logLevel === 'info') {
info(logging, 'content', `${cyan(collection)} collection added`);
}
break;
case 'unlinkDir':
removeCollection(contentTypes, JSON.stringify(collection));
break;
}
return { shouldGenerateTypes: true };
}
const fileType = getEntryType(fileURLToPath(event.entry), contentPaths);
if (fileType === 'generated-types') {
return { shouldGenerateTypes: false };
}
if (fileType === 'config') {
contentConfigObserver.set({ status: 'loading' });
const config = await loadContentConfig({ fs, settings });
if (config instanceof Error) {
contentConfigObserver.set({ status: 'error', error: config });
} else {
contentConfigObserver.set({ status: 'loaded', config });
}
return { shouldGenerateTypes: true };
}
const entryInfo = getEntryInfo({
entry: event.entry,
contentDir: contentPaths.contentDir,
});
// Not a valid `src/content/` entry. Silently return.
if (entryInfo instanceof Error) return { shouldGenerateTypes: false };
if (fileType === 'unknown') {
if (entryInfo.id.startsWith('_') && (event.name === 'add' || event.name === 'change')) {
// Silently ignore `_` files.
return { shouldGenerateTypes: false };
} else {
return {
shouldGenerateTypes: false,
error: new UnsupportedFileTypeError(entryInfo.id),
};
}
}
if (entryInfo.collection === '.') {
if (['info', 'warn'].includes(logLevel)) {
warn(
logging,
'content',
`${cyan(
normalizePath(
path.relative(fileURLToPath(contentPaths.contentDir), fileURLToPath(event.entry))
)
)} must be nested in a collection directory. Skipping.`
);
}
return { shouldGenerateTypes: false };
}
const { id, slug, collection } = entryInfo;
const collectionKey = JSON.stringify(collection);
const entryKey = JSON.stringify(id);
switch (event.name) {
case 'add':
if (!(collectionKey in contentTypes)) {
addCollection(contentTypes, collectionKey);
}
if (!(entryKey in contentTypes[collectionKey])) {
addEntry(contentTypes, collectionKey, entryKey, slug);
}
return { shouldGenerateTypes: true };
case 'unlink':
if (collectionKey in contentTypes && entryKey in contentTypes[collectionKey]) {
removeEntry(contentTypes, collectionKey, entryKey);
}
return { shouldGenerateTypes: true };
case 'change':
// noop. Frontmatter types are inferred from collection schema import, so they won't change!
return { shouldGenerateTypes: false };
}
}
function queueEvent(rawEvent: RawContentEvent, opts?: EventOpts) {
const event = {
entry: pathToFileURL(rawEvent.entry),
name: rawEvent.name,
};
if (!event.entry.pathname.startsWith(contentPaths.contentDir.pathname)) return;
events.push(handleEvent(event, opts));
debounceTimeout && clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(
async () => runEvents(opts),
50 /* debounce to batch chokidar events */
);
}
async function runEvents(opts?: EventOpts) {
const logLevel = opts?.logLevel ?? 'info';
const eventResponses = await Promise.all(events);
events = [];
let unsupportedFiles = [];
for (const response of eventResponses) {
if (response.error instanceof UnsupportedFileTypeError) {
unsupportedFiles.push(response.error.message);
}
}
if (unsupportedFiles.length > 0 && ['info', 'warn'].includes(logLevel)) {
warn(
logging,
'content',
`Unsupported file types found. Prefix with an underscore (\`_\`) to ignore:\n- ${unsupportedFiles.join(
'\n'
)}`
);
}
const observable = contentConfigObserver.get();
if (eventResponses.some((r) => r.shouldGenerateTypes)) {
await writeContentFiles({
fs,
contentTypes,
contentPaths,
contentTypesBase,
contentConfig: observable.status === 'loaded' ? observable.config : undefined,
});
if (observable.status === 'loaded' && ['info', 'warn'].includes(logLevel)) {
warnNonexistentCollections({
logging,
contentConfig: observable.config,
contentTypes,
});
}
}
}
return { init, queueEvent };
}
function addCollection(contentMap: ContentTypes, collectionKey: string) {
contentMap[collectionKey] = {};
}
function removeCollection(contentMap: ContentTypes, collectionKey: string) {
delete contentMap[collectionKey];
}
function addEntry(
contentTypes: ContentTypes,
collectionKey: string,
entryKey: string,
slug: string
) {
contentTypes[collectionKey][entryKey] = { slug };
}
function removeEntry(contentTypes: ContentTypes, collectionKey: string, entryKey: string) {
delete contentTypes[collectionKey][entryKey];
}
export function getEntryInfo({
entry,
contentDir,
}: Pick<ContentPaths, 'contentDir'> & { entry: URL }): EntryInfo | Error {
const rawRelativePath = path.relative(fileURLToPath(contentDir), fileURLToPath(entry));
const rawCollection = path.dirname(rawRelativePath).split(path.sep).shift();
if (!rawCollection) return new Error();
const rawId = path.relative(rawCollection, rawRelativePath);
const rawSlug = rawId.replace(path.extname(rawId), '');
const res = {
id: normalizePath(rawId),
slug: normalizePath(rawSlug),
collection: normalizePath(rawCollection),
};
return res;
}
export function getEntryType(
entryPath: string,
paths: ContentPaths
): 'content' | 'config' | 'unknown' | 'generated-types' {
const { dir: rawDir, ext, name, base } = path.parse(entryPath);
const dir = appendForwardSlash(pathToFileURL(rawDir).href);
if ((contentFileExts as readonly string[]).includes(ext)) {
return 'content';
} else if (new URL(name, dir).pathname === paths.config.pathname) {
return 'config';
} else if (new URL(base, dir).pathname === new URL(CONTENT_TYPES_FILE, paths.cacheDir).pathname) {
return 'generated-types';
} else {
return 'unknown';
}
}
async function writeContentFiles({
fs,
contentPaths,
contentTypes,
contentTypesBase,
contentConfig,
}: {
fs: typeof fsMod;
contentPaths: ContentPaths;
contentTypes: ContentTypes;
contentTypesBase: string;
contentConfig?: ContentConfig;
}) {
let contentTypesStr = '';
const collectionKeys = Object.keys(contentTypes).sort();
for (const collectionKey of collectionKeys) {
const collectionConfig = contentConfig?.collections[JSON.parse(collectionKey)];
contentTypesStr += `${collectionKey}: {\n`;
const entryKeys = Object.keys(contentTypes[collectionKey]).sort();
for (const entryKey of entryKeys) {
const entryMetadata = contentTypes[collectionKey][entryKey];
const dataType = collectionConfig?.schema ? `InferEntrySchema<${collectionKey}>` : 'any';
// If user has custom slug function, we can't predict slugs at type compilation.
// Would require parsing all data and evaluating ahead-of-time;
// We evaluate with lazy imports at dev server runtime
// to prevent excessive errors
const slugType = collectionConfig?.slug ? 'string' : JSON.stringify(entryMetadata.slug);
contentTypesStr += `${entryKey}: {\n id: ${entryKey},\n slug: ${slugType},\n body: string,\n collection: ${collectionKey},\n data: ${dataType}\n},\n`;
}
contentTypesStr += `},\n`;
}
let configPathRelativeToCacheDir = normalizePath(
path.relative(contentPaths.cacheDir.pathname, contentPaths.config.pathname)
);
if (!isRelativePath(configPathRelativeToCacheDir))
configPathRelativeToCacheDir = './' + configPathRelativeToCacheDir;
contentTypesBase = contentTypesBase.replace('// @@ENTRY_MAP@@', contentTypesStr);
contentTypesBase = contentTypesBase.replace(
"'@@CONTENT_CONFIG_TYPE@@'",
contentConfig ? `typeof import(${JSON.stringify(configPathRelativeToCacheDir)})` : 'never'
);
await fs.promises.writeFile(new URL(CONTENT_TYPES_FILE, contentPaths.cacheDir), contentTypesBase);
}
function warnNonexistentCollections({
contentConfig,
contentTypes,
logging,
}: {
contentConfig: ContentConfig;
contentTypes: ContentTypes;
logging: LogOptions;
}) {
for (const configuredCollection in contentConfig.collections) {
if (!contentTypes[JSON.stringify(configuredCollection)]) {
warn(
logging,
'content',
`${JSON.stringify(
configuredCollection
)} is not a collection. Check your content config for typos.`
);
}
}
}

View file

@ -0,0 +1,224 @@
import type fsMod from 'node:fs';
import matter from 'gray-matter';
import { z } from 'zod';
import { createServer, ErrorPayload as ViteErrorPayload, ViteDevServer } from 'vite';
import { AstroSettings } from '../@types/astro.js';
import { astroContentVirtualModPlugin } from './vite-plugin-content-virtual-mod.js';
import { fileURLToPath } from 'node:url';
import { AstroError, AstroErrorData } from '../core/errors/index.js';
export const collectionConfigParser = z.object({
schema: z.any().optional(),
slug: z
.function()
.args(
z.object({
id: z.string(),
collection: z.string(),
defaultSlug: z.string(),
body: z.string(),
data: z.record(z.any()),
})
)
.returns(z.union([z.string(), z.promise(z.string())]))
.optional(),
});
export const contentConfigParser = z.object({
collections: z.record(collectionConfigParser),
});
export type CollectionConfig = z.infer<typeof collectionConfigParser>;
export type ContentConfig = z.infer<typeof contentConfigParser>;
type Entry = {
id: string;
collection: string;
slug: string;
data: any;
body: string;
_internal: { rawData: string; filePath: string };
};
export const msg = {
collectionConfigMissing: (collection: string) =>
`${collection} does not have a config. We suggest adding one for type safety!`,
};
export async function getEntrySlug(entry: Entry, collectionConfig: CollectionConfig) {
return (
collectionConfig.slug?.({
id: entry.id,
data: entry.data,
defaultSlug: entry.slug,
collection: entry.collection,
body: entry.body,
}) ?? entry.slug
);
}
export async function getEntryData(entry: Entry, collectionConfig: CollectionConfig) {
let data = entry.data;
if (collectionConfig.schema) {
// Use `safeParseAsync` to allow async transforms
const parsed = await z.object(collectionConfig.schema).safeParseAsync(entry.data, { errorMap });
if (parsed.success) {
data = parsed.data;
} else {
const formattedError = new AstroError({
...AstroErrorData.MarkdownContentSchemaValidationError,
message: AstroErrorData.MarkdownContentSchemaValidationError.message(
entry.collection,
entry.id,
parsed.error
),
location: {
file: entry._internal.filePath,
line: getFrontmatterErrorLine(
entry._internal.rawData,
String(parsed.error.errors[0].path[0])
),
column: 0,
},
});
throw formattedError;
}
}
return data;
}
const flattenPath = (path: (string | number)[]) => path.join('.');
const errorMap: z.ZodErrorMap = (error, ctx) => {
if (error.code === 'invalid_type') {
const badKeyPath = JSON.stringify(flattenPath(error.path));
if (error.received === 'undefined') {
return { message: `${badKeyPath} is required.` };
} else {
return { message: `${badKeyPath} should be ${error.expected}, not ${error.received}.` };
}
}
return { message: ctx.defaultError };
};
function getFrontmatterErrorLine(rawFrontmatter: string, frontmatterKey: string) {
const indexOfFrontmatterKey = rawFrontmatter.indexOf(`\n${frontmatterKey}`);
if (indexOfFrontmatterKey === -1) return 0;
const frontmatterBeforeKey = rawFrontmatter.substring(0, indexOfFrontmatterKey + 1);
const numNewlinesBeforeKey = frontmatterBeforeKey.split('\n').length;
return numNewlinesBeforeKey;
}
/**
* Match YAML exception handling from Astro core errors
* @see 'astro/src/core/errors.ts'
*/
export function parseFrontmatter(fileContents: string, filePath: string) {
try {
// `matter` is empty string on cache results
// clear cache to prevent this
(matter as any).clearCache();
return matter(fileContents);
} catch (e: any) {
if (e.name === 'YAMLException') {
const err: Error & ViteErrorPayload['err'] = e;
err.id = filePath;
err.loc = { file: e.id, line: e.mark.line + 1, column: e.mark.column };
err.message = e.reason;
throw err;
} else {
throw e;
}
}
}
export class NotFoundError extends TypeError {}
export class ZodParseError extends TypeError {}
export async function loadContentConfig({
fs,
settings,
}: {
fs: typeof fsMod;
settings: AstroSettings;
}): Promise<ContentConfig | Error> {
const contentPaths = getContentPaths({ srcDir: settings.config.srcDir });
const tempConfigServer: ViteDevServer = await createServer({
root: fileURLToPath(settings.config.root),
server: { middlewareMode: true, hmr: false },
optimizeDeps: { entries: [] },
clearScreen: false,
appType: 'custom',
logLevel: 'silent',
plugins: [astroContentVirtualModPlugin({ settings })],
});
let unparsedConfig;
try {
unparsedConfig = await tempConfigServer.ssrLoadModule(contentPaths.config.pathname);
} catch {
return new NotFoundError('Failed to resolve content config.');
} finally {
await tempConfigServer.close();
}
const config = contentConfigParser.safeParse(unparsedConfig);
if (config.success) {
return config.data;
} else {
return new ZodParseError('Content config file is invalid.');
}
}
type ContentCtx =
| { status: 'loading' }
| { status: 'loaded'; config: ContentConfig }
| { status: 'error'; error: NotFoundError | ZodParseError };
type Observable<C> = {
get: () => C;
set: (ctx: C) => void;
subscribe: (fn: (ctx: C) => void) => () => void;
};
export type ContentObservable = Observable<ContentCtx>;
export function contentObservable(initialCtx: ContentCtx): ContentObservable {
type Subscriber = (ctx: ContentCtx) => void;
const subscribers = new Set<Subscriber>();
let ctx = initialCtx;
function get() {
return ctx;
}
function set(_ctx: ContentCtx) {
ctx = _ctx;
subscribers.forEach((fn) => fn(ctx));
}
function subscribe(fn: Subscriber) {
subscribers.add(fn);
return () => {
subscribers.delete(fn);
};
}
return {
get,
set,
subscribe,
};
}
export type ContentPaths = {
contentDir: URL;
cacheDir: URL;
generatedInputDir: URL;
config: URL;
};
export function getContentPaths({ srcDir }: { srcDir: URL }): ContentPaths {
return {
// Output generated types in content directory. May change in the future!
cacheDir: new URL('./content/', srcDir),
contentDir: new URL('./content/', srcDir),
generatedInputDir: new URL('../../src/content/template/', import.meta.url),
config: new URL('./content/config', srcDir),
};
}

View file

@ -0,0 +1,96 @@
import type { Plugin } from 'vite';
import type { ModuleLoader } from '../core/module-loader/loader.js';
import { moduleIsTopLevelPage, walkParentInfos } from '../core/build/graph.js';
import { BuildInternals, getPageDataByViteID } from '../core/build/internal.js';
import { getStylesForURL } from '../core/render/dev/css.js';
import { pathToFileURL } from 'url';
import { createViteLoader } from '../core/module-loader/vite.js';
import {
contentFileExts,
DELAYED_ASSET_FLAG,
LINKS_PLACEHOLDER,
STYLES_PLACEHOLDER,
} from './consts.js';
function isDelayedAsset(url: URL): boolean {
return (
url.searchParams.has(DELAYED_ASSET_FLAG) &&
contentFileExts.some((ext) => url.pathname.endsWith(ext))
);
}
export function astroDelayedAssetPlugin({ mode }: { mode: string }): Plugin {
let devModuleLoader: ModuleLoader;
return {
name: 'astro-delayed-asset-plugin',
enforce: 'pre',
configureServer(server) {
if (mode === 'dev') {
devModuleLoader = createViteLoader(server);
}
},
load(id) {
const url = new URL(id, 'file://');
if (isDelayedAsset(url)) {
const code = `
export { Content, getHeadings, _internal } from ${JSON.stringify(url.pathname)};
export const collectedLinks = ${JSON.stringify(LINKS_PLACEHOLDER)};
export const collectedStyles = ${JSON.stringify(STYLES_PLACEHOLDER)};
`;
return { code };
}
},
async transform(code, id, options) {
if (!options?.ssr) return;
const url = new URL(id, 'file://');
if (devModuleLoader && isDelayedAsset(url)) {
const { pathname } = url;
if (!devModuleLoader.getModuleById(pathname)?.ssrModule) {
await devModuleLoader.import(pathname);
}
const { stylesMap, urls } = await getStylesForURL(
pathToFileURL(pathname),
devModuleLoader,
'development'
);
return {
code: code
.replace(JSON.stringify(LINKS_PLACEHOLDER), JSON.stringify([...urls]))
.replace(JSON.stringify(STYLES_PLACEHOLDER), JSON.stringify([...stylesMap.values()])),
};
}
},
};
}
export function astroBundleDelayedAssetPlugin({
internals,
}: {
internals: BuildInternals;
}): Plugin {
return {
name: 'astro-bundle-delayed-asset-plugin',
async generateBundle(_options, bundle) {
for (const [_, chunk] of Object.entries(bundle)) {
if (chunk.type === 'chunk' && chunk.code.includes(LINKS_PLACEHOLDER)) {
for (const id of Object.keys(chunk.modules)) {
for (const [pageInfo, depth, order] of walkParentInfos(id, this)) {
if (moduleIsTopLevelPage(pageInfo)) {
const pageViteID = pageInfo.id;
const pageData = getPageDataByViteID(internals, pageViteID);
if (!pageData) continue;
const entryCss = pageData.contentCollectionCss?.get(id);
if (!entryCss) continue;
chunk.code = chunk.code.replace(
JSON.stringify(LINKS_PLACEHOLDER),
JSON.stringify([...entryCss])
);
}
}
}
}
}
},
};
}

View file

@ -0,0 +1,199 @@
import type { Plugin } from 'vite';
import fsMod from 'node:fs';
import { cyan } from 'kleur/colors';
import { info, LogOptions } from '../core/logger/core.js';
import type { AstroSettings } from '../@types/astro.js';
import { contentFileExts, CONTENT_FLAG } from './consts.js';
import { escapeViteEnvReferences } from '../vite-plugin-utils/index.js';
import {
getEntryData,
getEntrySlug,
getContentPaths,
contentObservable,
parseFrontmatter,
ContentPaths,
ContentConfig,
} from './utils.js';
import * as devalue from 'devalue';
import {
createContentTypesGenerator,
GenerateContentTypes,
getEntryInfo,
getEntryType,
} from './types-generator.js';
import { pathToFileURL } from 'node:url';
import { prependForwardSlash } from '../core/path.js';
interface AstroContentServerPluginParams {
fs: typeof fsMod;
logging: LogOptions;
settings: AstroSettings;
mode: string;
}
export function astroContentServerPlugin({
fs,
settings,
logging,
mode,
}: AstroContentServerPluginParams): Plugin[] {
const contentPaths: ContentPaths = getContentPaths({ srcDir: settings.config.srcDir });
let contentDirExists = false;
let contentGenerator: GenerateContentTypes;
const contentConfigObserver = contentObservable({ status: 'loading' });
return [
{
name: 'astro-content-server-plugin',
async config(viteConfig) {
try {
await fs.promises.stat(contentPaths.contentDir);
contentDirExists = true;
} catch {
/* silently move on */
return;
}
if (contentDirExists && (mode === 'dev' || viteConfig.build?.ssr === true)) {
contentGenerator = await createContentTypesGenerator({
fs,
settings,
logging,
contentConfigObserver,
contentPaths,
});
await contentGenerator.init();
info(logging, 'content', 'Types generated');
}
},
async configureServer(viteServer) {
if (mode !== 'dev') return;
if (contentDirExists) {
info(
logging,
'content',
`Watching ${cyan(
contentPaths.contentDir.href.replace(settings.config.root.href, '')
)} for changes`
);
attachListeners();
} else {
viteServer.watcher.on('addDir', (dir) => {
if (pathToFileURL(dir).href === contentPaths.contentDir.href) {
info(logging, 'content', `Content dir found. Watching for changes`);
contentDirExists = true;
attachListeners();
}
});
}
function attachListeners() {
viteServer.watcher.on('add', (entry) => {
contentGenerator.queueEvent({ name: 'add', entry });
});
viteServer.watcher.on('addDir', (entry) =>
contentGenerator.queueEvent({ name: 'addDir', entry })
);
viteServer.watcher.on('change', (entry) =>
contentGenerator.queueEvent({ name: 'change', entry })
);
viteServer.watcher.on('unlink', (entry) => {
contentGenerator.queueEvent({ name: 'unlink', entry });
});
viteServer.watcher.on('unlinkDir', (entry) =>
contentGenerator.queueEvent({ name: 'unlinkDir', entry })
);
}
},
},
{
name: 'astro-content-flag-plugin',
async load(id) {
const fileUrl = new URL(prependForwardSlash(id), 'file://');
if (isContentFlagImport(fileUrl)) {
const observable = contentConfigObserver.get();
let contentConfig: ContentConfig | undefined =
observable.status === 'loaded' ? observable.config : undefined;
if (observable.status === 'loading') {
// Wait for config to load
contentConfig = await new Promise((resolve) => {
const unsubscribe = contentConfigObserver.subscribe((ctx) => {
if (ctx.status === 'loaded') {
resolve(ctx.config);
unsubscribe();
} else if (ctx.status === 'error') {
resolve(undefined);
unsubscribe();
}
});
});
}
const rawContents = await fs.promises.readFile(fileUrl, 'utf-8');
const {
content: body,
data: unparsedData,
matter: rawData = '',
} = parseFrontmatter(rawContents, fileUrl.pathname);
const entryInfo = getEntryInfo({
entry: fileUrl,
contentDir: contentPaths.contentDir,
});
if (entryInfo instanceof Error) return;
const _internal = { filePath: fileUrl.pathname, rawData };
const partialEntry = { data: unparsedData, body, _internal, ...entryInfo };
const collectionConfig = contentConfig?.collections[entryInfo.collection];
const data = collectionConfig
? await getEntryData(partialEntry, collectionConfig)
: unparsedData;
const slug = collectionConfig
? await getEntrySlug({ ...partialEntry, data }, collectionConfig)
: entryInfo.slug;
const code = escapeViteEnvReferences(`
export const id = ${JSON.stringify(entryInfo.id)};
export const collection = ${JSON.stringify(entryInfo.collection)};
export const slug = ${JSON.stringify(slug)};
export const body = ${JSON.stringify(body)};
export const data = ${devalue.uneval(data) /* TODO: reuse astro props serializer */};
export const _internal = {
filePath: ${JSON.stringify(fileUrl.pathname)},
rawData: ${JSON.stringify(rawData)},
};
`);
return { code };
}
},
configureServer(viteServer) {
viteServer.watcher.on('all', async (event, entry) => {
if (
['add', 'unlink', 'change'].includes(event) &&
getEntryType(entry, contentPaths) === 'config'
) {
// Content modules depend on config, so we need to invalidate them.
for (const modUrl of viteServer.moduleGraph.urlToModuleMap.keys()) {
if (isContentFlagImport(new URL(modUrl, 'file://'))) {
const mod = await viteServer.moduleGraph.getModuleByUrl(modUrl);
if (mod) {
viteServer.moduleGraph.invalidateModule(mod);
}
}
}
}
});
},
async transform(code, id) {
if (isContentFlagImport(new URL(id, 'file://'))) {
// Escape before Rollup internal transform.
// Base on MUCH trial-and-error, inspired by MDX integration 2-step transform.
return { code: escapeViteEnvReferences(code) };
}
},
},
];
}
function isContentFlagImport({ searchParams, pathname }: Pick<URL, 'searchParams' | 'pathname'>) {
return searchParams.has(CONTENT_FLAG) && contentFileExts.some((ext) => pathname.endsWith(ext));
}

View file

@ -0,0 +1,48 @@
import type { Plugin } from 'vite';
import { normalizePath } from 'vite';
import fsMod from 'node:fs';
import * as path from 'node:path';
import type { AstroSettings } from '../@types/astro.js';
import { appendForwardSlash, prependForwardSlash } from '../core/path.js';
import { contentFileExts, CONTENT_FILE, VIRTUAL_MODULE_ID } from './consts.js';
import { getContentPaths } from './utils.js';
interface AstroContentVirtualModPluginParams {
settings: AstroSettings;
}
export function astroContentVirtualModPlugin({
settings,
}: AstroContentVirtualModPluginParams): Plugin {
const paths = getContentPaths({ srcDir: settings.config.srcDir });
const relContentDir = normalizePath(
appendForwardSlash(
prependForwardSlash(path.relative(settings.config.root.pathname, paths.contentDir.pathname))
)
);
const entryGlob = `${relContentDir}**/*{${contentFileExts.join(',')}}`;
const astroContentModContents = fsMod
.readFileSync(new URL(CONTENT_FILE, paths.generatedInputDir), 'utf-8')
.replace('@@CONTENT_DIR@@', relContentDir)
.replace('@@ENTRY_GLOB_PATH@@', entryGlob)
.replace('@@RENDER_ENTRY_GLOB_PATH@@', entryGlob);
const astroContentVirtualModuleId = '\0' + VIRTUAL_MODULE_ID;
return {
name: 'astro-content-virtual-mod-plugin',
enforce: 'pre',
resolveId(id) {
if (id === VIRTUAL_MODULE_ID) {
return astroContentVirtualModuleId;
}
},
load(id) {
if (id === astroContentVirtualModuleId) {
return {
code: astroContentModContents,
};
}
},
};
}

View file

@ -6,6 +6,7 @@ import { resolvedPagesVirtualModuleId } from '../app/index.js';
export function* walkParentInfos( export function* walkParentInfos(
id: string, id: string,
ctx: { getModuleInfo: GetModuleInfo }, ctx: { getModuleInfo: GetModuleInfo },
until?: (importer: string) => boolean,
depth = 0, depth = 0,
order = 0, order = 0,
seen = new Set<string>(), seen = new Set<string>(),
@ -19,12 +20,13 @@ export function* walkParentInfos(
} }
yield [info, depth, order]; yield [info, depth, order];
} }
if (until?.(id)) return;
const importers = (info?.importers || []).concat(info?.dynamicImporters || []); const importers = (info?.importers || []).concat(info?.dynamicImporters || []);
for (const imp of importers) { for (const imp of importers) {
if (seen.has(imp)) { if (seen.has(imp)) {
continue; continue;
} }
yield* walkParentInfos(imp, ctx, ++depth, order, seen, id); yield* walkParentInfos(imp, ctx, until, ++depth, order, seen, id);
} }
} }

View file

@ -54,6 +54,7 @@ export async function collectPagesData(
route, route,
moduleSpecifier: '', moduleSpecifier: '',
css: new Map(), css: new Map(),
contentCollectionCss: new Map(),
hoistedScript: undefined, hoistedScript: undefined,
}; };
@ -75,6 +76,7 @@ export async function collectPagesData(
route, route,
moduleSpecifier: '', moduleSpecifier: '',
css: new Map(), css: new Map(),
contentCollectionCss: new Map(),
hoistedScript: undefined, hoistedScript: undefined,
}; };
} }

View file

@ -14,6 +14,7 @@ import { emptyDir, removeDir } from '../../core/fs/index.js';
import { prependForwardSlash } from '../../core/path.js'; import { prependForwardSlash } from '../../core/path.js';
import { isModeServerWithNoAdapter } from '../../core/util.js'; import { isModeServerWithNoAdapter } from '../../core/util.js';
import { runHookBuildSetup } from '../../integrations/index.js'; import { runHookBuildSetup } from '../../integrations/index.js';
import { astroBundleDelayedAssetPlugin } from '../../content/index.js';
import { PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js'; import { PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js';
import { AstroError, AstroErrorData } from '../errors/index.js'; import { AstroError, AstroErrorData } from '../errors/index.js';
import { info } from '../logger/core.js'; import { info } from '../logger/core.js';
@ -168,6 +169,8 @@ async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, inp
}), }),
vitePluginPrerender(opts, internals), vitePluginPrerender(opts, internals),
...(viteConfig.plugins || []), ...(viteConfig.plugins || []),
settings.config.experimental.contentCollections &&
astroBundleDelayedAssetPlugin({ internals }),
// SSR needs to be last // SSR needs to be last
ssr && vitePluginSSR(internals, settings.adapter!), ssr && vitePluginSSR(internals, settings.adapter!),
], ],

View file

@ -21,6 +21,7 @@ export interface PageBuildData {
route: RouteData; route: RouteData;
moduleSpecifier: string; moduleSpecifier: string;
css: Map<string, { depth: number; order: number }>; css: Map<string, { depth: number; order: number }>;
contentCollectionCss: Map<string, Set<string>>;
hoistedScript: { type: 'inline' | 'external'; value: string } | undefined; hoistedScript: { type: 'inline' | 'external'; value: string } | undefined;
} }
export type AllPagesData = Record<ComponentPath, PageBuildData>; export type AllPagesData = Record<ComponentPath, PageBuildData>;

View file

@ -1,12 +1,13 @@
import type { GetModuleInfo } from 'rollup'; import type { GetModuleInfo } from 'rollup';
import type { BuildInternals } from './internal'; import type { BuildInternals } from './internal';
import type { PageBuildData, StaticBuildOptions } from './types'; import type { PageBuildData, StaticBuildOptions } from './types';
import * as crypto from 'node:crypto';
import * as npath from 'node:path';
import { Plugin as VitePlugin, ResolvedConfig, transformWithEsbuild } from 'vite'; import { Plugin as VitePlugin, ResolvedConfig, transformWithEsbuild } from 'vite';
import { isCSSRequest } from '../render/util.js'; import { isCSSRequest } from '../render/util.js';
import * as assetName from './css-asset-name.js'; import * as assetName from './css-asset-name.js';
import { moduleIsTopLevelPage, walkParentInfos } from './graph.js'; import { getTopLevelPages, moduleIsTopLevelPage, walkParentInfos } from './graph.js';
import { import {
eachPageData, eachPageData,
getPageDataByViteID, getPageDataByViteID,
@ -14,6 +15,7 @@ import {
getPageDatasByHoistedScriptId, getPageDatasByHoistedScriptId,
isHoistedScript, isHoistedScript,
} from './internal.js'; } from './internal.js';
import { DELAYED_ASSET_FLAG } from '../../content/consts.js';
interface PluginOptions { interface PluginOptions {
internals: BuildInternals; internals: BuildInternals;
@ -27,6 +29,17 @@ export function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[]
let resolvedConfig: ResolvedConfig; let resolvedConfig: ResolvedConfig;
function createNameHash(baseId: string, hashIds: string[]): string {
const baseName = baseId ? npath.parse(baseId).name : 'index';
const hash = crypto.createHash('sha256');
for (const id of hashIds) {
hash.update(id, 'utf-8');
}
const h = hash.digest('hex').slice(0, 8);
const proposedName = baseName + '.' + h;
return proposedName;
}
function* getParentClientOnlys( function* getParentClientOnlys(
id: string, id: string,
ctx: { getModuleInfo: GetModuleInfo } ctx: { getModuleInfo: GetModuleInfo }
@ -63,6 +76,17 @@ export function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[]
// For CSS, create a hash of all of the pages that use it. // For CSS, create a hash of all of the pages that use it.
// This causes CSS to be built into shared chunks when used by multiple pages. // This causes CSS to be built into shared chunks when used by multiple pages.
if (isCSSRequest(id)) { if (isCSSRequest(id)) {
if (settings.config.experimental.contentCollections) {
for (const [pageInfo] of walkParentInfos(id, {
getModuleInfo: args[0].getModuleInfo,
})) {
if (new URL(pageInfo.id, 'file://').searchParams.has(DELAYED_ASSET_FLAG)) {
// Split delayed assets to separate modules
// so they can be injected where needed
return createNameHash(id, [id]);
}
}
}
return createNameForParentPages(id, args[0]); return createNameForParentPages(id, args[0]);
} }
}; };
@ -105,6 +129,7 @@ export function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[]
for (const [_, chunk] of Object.entries(bundle)) { for (const [_, chunk] of Object.entries(bundle)) {
if (chunk.type === 'chunk') { if (chunk.type === 'chunk') {
const c = chunk; const c = chunk;
if ('viteMetadata' in chunk) { if ('viteMetadata' in chunk) {
const meta = chunk['viteMetadata'] as ViteMetadata; const meta = chunk['viteMetadata'] as ViteMetadata;
@ -145,8 +170,39 @@ export function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin[]
// For this CSS chunk, walk parents until you find a page. Add the CSS to that page. // For this CSS chunk, walk parents until you find a page. Add the CSS to that page.
for (const id of Object.keys(c.modules)) { for (const id of Object.keys(c.modules)) {
for (const [pageInfo, depth, order] of walkParentInfos(id, this)) { for (const [pageInfo, depth, order] of walkParentInfos(
if (moduleIsTopLevelPage(pageInfo)) { id,
this,
function until(importer) {
if (settings.config.experimental.contentCollections) {
// Short circuit when `contentCollections` is enabled.
return new URL(importer, 'file://').searchParams.has(DELAYED_ASSET_FLAG);
}
return false;
}
)) {
if (
settings.config.experimental.contentCollections &&
new URL(pageInfo.id, 'file://').searchParams.has(DELAYED_ASSET_FLAG)
) {
for (const parent of walkParentInfos(id, this)) {
const parentInfo = parent[0];
if (moduleIsTopLevelPage(parentInfo)) {
const pageViteID = parentInfo.id;
const pageData = getPageDataByViteID(internals, pageViteID);
if (pageData) {
for (const css of meta.importedCss) {
const existingCss =
pageData.contentCollectionCss.get(pageInfo.id) ?? new Set();
pageData.contentCollectionCss.set(
pageInfo.id,
new Set([...existingCss, css])
);
}
}
}
}
} else if (moduleIsTopLevelPage(pageInfo)) {
const pageViteID = pageInfo.id; const pageViteID = pageInfo.id;
const pageData = getPageDataByViteID(internals, pageViteID); const pageData = getPageDataByViteID(internals, pageViteID);
if (pageData) { if (pageData) {

View file

@ -106,6 +106,10 @@ export function resolveFlags(flags: Partial<Flags>): CLIFlags {
: undefined, : undefined,
experimentalPrerender: experimentalPrerender:
typeof flags.experimentalPrerender === 'boolean' ? flags.experimentalPrerender : undefined, typeof flags.experimentalPrerender === 'boolean' ? flags.experimentalPrerender : undefined,
experimentalContentCollections:
typeof flags.experimentalContentCollections === 'boolean'
? flags.experimentalContentCollections
: undefined,
}; };
} }
@ -136,6 +140,7 @@ function mergeCLIFlags(astroConfig: AstroUserConfig, flags: CLIFlags, cmd: strin
} }
if (flags.experimentalErrorOverlay) astroConfig.experimental.errorOverlay = true; if (flags.experimentalErrorOverlay) astroConfig.experimental.errorOverlay = true;
if (flags.experimentalPrerender) astroConfig.experimental.prerender = true; if (flags.experimentalPrerender) astroConfig.experimental.prerender = true;
if (flags.experimentalContentCollections) astroConfig.experimental.contentCollections = true;
return astroConfig; return astroConfig;
} }

View file

@ -50,6 +50,7 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = {
experimental: { experimental: {
errorOverlay: false, errorOverlay: false,
prerender: false, prerender: false,
contentCollections: false,
}, },
}; };
@ -195,6 +196,10 @@ export const AstroConfigSchema = z.object({
.object({ .object({
errorOverlay: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.errorOverlay), errorOverlay: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.errorOverlay),
prerender: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.prerender), prerender: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.prerender),
contentCollections: z
.boolean()
.optional()
.default(ASTRO_CONFIG_DEFAULTS.experimental.contentCollections),
}) })
.optional() .optional()
.default({}), .default({}),

View file

@ -22,6 +22,11 @@ import astroScriptsPlugin from '../vite-plugin-scripts/index.js';
import astroScriptsPageSSRPlugin from '../vite-plugin-scripts/page-ssr.js'; import astroScriptsPageSSRPlugin from '../vite-plugin-scripts/page-ssr.js';
import { createCustomViteLogger } from './errors/dev/index.js'; import { createCustomViteLogger } from './errors/dev/index.js';
import { resolveDependency } from './util.js'; import { resolveDependency } from './util.js';
import {
astroContentServerPlugin,
astroContentVirtualModPlugin,
astroDelayedAssetPlugin,
} from '../content/index.js';
interface CreateViteOptions { interface CreateViteOptions {
settings: AstroSettings; settings: AstroSettings;
@ -116,6 +121,13 @@ export async function createVite(
astroScriptsPageSSRPlugin({ settings }), astroScriptsPageSSRPlugin({ settings }),
astroHeadPropagationPlugin({ settings }), astroHeadPropagationPlugin({ settings }),
settings.config.experimental.prerender && astroScannerPlugin({ settings, logging }), settings.config.experimental.prerender && astroScannerPlugin({ settings, logging }),
...(settings.config.experimental.contentCollections
? [
astroContentVirtualModPlugin({ settings }),
astroContentServerPlugin({ fs, settings, logging, mode }),
astroDelayedAssetPlugin({ mode }),
]
: []),
], ],
publicDir: fileURLToPath(settings.config.publicDir), publicDir: fileURLToPath(settings.config.publicDir),
root: fileURLToPath(settings.config.root), root: fileURLToPath(settings.config.root),

View file

@ -2,6 +2,8 @@
// Additionally, this code, much like `@types/astro.ts`, is used to generate documentation, so make sure to pass // Additionally, this code, much like `@types/astro.ts`, is used to generate documentation, so make sure to pass
// your changes by our wonderful docs team before merging! // your changes by our wonderful docs team before merging!
import type { ZodError } from 'zod';
interface ErrorData { interface ErrorData {
code: number; code: number;
title: string; title: string;
@ -491,6 +493,30 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati
title: 'Failed to parse Markdown frontmatter.', title: 'Failed to parse Markdown frontmatter.',
code: 6001, code: 6001,
}, },
/**
* @docs
* @message
* **Example error message:**<br/>
* Could not parse frontmatter in **blog** **post.md**<br/>
* "title" is required.<br/>
* "date" must be a valid date.
* @description
* A Markdown document's frontmatter in `src/content/` does not match its collection schema.
* Make sure that all required fields are present, and that all fields are of the correct type.
* You can check against the collection schema in your `src/content/config.*` file.
* See the [Content collections documentation](https://docs.astro.build/en/guides/content-collections) for more information.
*/
MarkdownContentSchemaValidationError: {
title: 'Content collection frontmatter invalid.',
code: 6002,
message: (collection: string, entryId: string, error: ZodError) => {
return [
`${String(collection)}${String(entryId)} frontmatter does not match collection schema.`,
...error.errors.map((zodError) => zodError.message),
].join('\n');
},
hint: 'See https://docs.astro.build/en/guides/content-collections for more information on content schemas.',
},
// Config Errors - 7xxx // Config Errors - 7xxx
UnknownConfigError: { UnknownConfigError: {
title: 'Unknown configuration error.', title: 'Unknown configuration error.',

View file

@ -2,6 +2,7 @@ import type { ModuleLoader, ModuleNode } from '../../module-loader/index';
import npath from 'path'; import npath from 'path';
import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from '../../constants.js'; import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from '../../constants.js';
import { DELAYED_ASSET_FLAG } from '../../../content/consts.js';
import { unwrapId } from '../../util.js'; import { unwrapId } from '../../util.js';
import { STYLE_EXTENSIONS } from '../util.js'; import { STYLE_EXTENSIONS } from '../util.js';
@ -22,6 +23,8 @@ export async function* crawlGraph(
): AsyncGenerator<ModuleNode, void, unknown> { ): AsyncGenerator<ModuleNode, void, unknown> {
const id = unwrapId(_id); const id = unwrapId(_id);
const importedModules = new Set<ModuleNode>(); const importedModules = new Set<ModuleNode>();
if (new URL(id, 'file://').searchParams.has(DELAYED_ASSET_FLAG)) return;
const moduleEntriesForId = isRootFile const moduleEntriesForId = isRootFile
? // "getModulesByFile" pulls from a delayed module cache (fun implementation detail), ? // "getModulesByFile" pulls from a delayed module cache (fun implementation detail),
// So we can get up-to-date info on initial server load. // So we can get up-to-date info on initial server load.

View file

@ -99,7 +99,10 @@ class Slots {
`Expected second parameter to be an array, received a ${typeof args}. If you're trying to pass an array as a single argument and getting unexpected results, make sure you're passing your array as a item of an array. Ex: Astro.slots.render('default', [["Hello", "World"]])` `Expected second parameter to be an array, received a ${typeof args}. If you're trying to pass an array as a single argument and getting unexpected results, make sure you're passing your array as a item of an array. Ex: Astro.slots.render('default', [["Hello", "World"]])`
); );
} else if (args.length > 0) { } else if (args.length > 0) {
const component = await this.#slots[name](); const slotValue = this.#slots[name];
const component = typeof slotValue === 'function' ?
await slotValue() :
await slotValue;
// Astro // Astro
const expression = getFunctionExpression(component); const expression = getFunctionExpression(component);

View file

@ -20,7 +20,7 @@ function createComponentWithOptions(opts: CreateComponentOptions) {
return cb; return cb;
} }
// Used in creating the component. aka the main export. // Used in creating the component. aka the main export.
export function createComponent(arg1: AstroComponentFactory, moduleId: string) { export function createComponent(arg1: AstroComponentFactory | CreateComponentOptions, moduleId?: string) {
if (typeof arg1 === 'function') { if (typeof arg1 === 'function') {
return baseCreateComponent(arg1, moduleId); return baseCreateComponent(arg1, moduleId);
} else { } else {

View file

@ -21,12 +21,15 @@ export {
renderTemplate, renderTemplate,
renderToString, renderToString,
renderUniqueStylesheet, renderUniqueStylesheet,
renderStyleElement,
stringifyChunk, stringifyChunk,
voidElementNames, voidElementNames,
} from './render/index.js'; } from './render/index.js';
export type { export type {
AstroComponentFactory, AstroComponentFactory,
AstroComponentInstance, AstroComponentInstance,
AstroComponentSlots,
AstroComponentSlotsWithValues,
RenderInstruction, RenderInstruction,
} from './render/index.js'; } from './render/index.js';

View file

@ -12,7 +12,7 @@ export type AstroFactoryReturnValue = RenderTemplateResult | Response | HeadAndC
export interface AstroComponentFactory { export interface AstroComponentFactory {
(result: any, props: any, slots: any): AstroFactoryReturnValue; (result: any, props: any, slots: any): AstroFactoryReturnValue;
isAstroComponentFactory?: boolean; isAstroComponentFactory?: boolean;
moduleId: string | undefined; moduleId?: string | undefined;
propagation?: PropagationHint; propagation?: PropagationHint;
} }

View file

@ -1,7 +1,7 @@
export type { AstroComponentFactory } from './factory'; export type { AstroComponentFactory } from './factory';
export { isAstroComponentFactory, renderToString } from './factory.js'; export { isAstroComponentFactory, renderToString } from './factory.js';
export { createHeadAndContent, isHeadAndContent } from './head-and-content.js'; export { createHeadAndContent, isHeadAndContent } from './head-and-content.js';
export type { AstroComponentInstance } from './instance'; export type { AstroComponentInstance, ComponentSlots, ComponentSlotsWithValues } from './instance';
export { createAstroComponentInstance, isAstroComponentInstance } from './instance.js'; export { createAstroComponentInstance, isAstroComponentInstance } from './instance.js';
export { export {
isRenderTemplateResult, isRenderTemplateResult,

View file

@ -1,5 +1,6 @@
import type { SSRResult } from '../../../../@types/astro'; import type { SSRResult } from '../../../../@types/astro';
import type { AstroComponentFactory, AstroFactoryReturnValue } from './factory.js'; import type { AstroComponentFactory, AstroFactoryReturnValue } from './factory.js';
import type { renderTemplate } from './render-template.js';
import { HydrationDirectiveProps } from '../../hydration.js'; import { HydrationDirectiveProps } from '../../hydration.js';
import { isPromise } from '../../util.js'; import { isPromise } from '../../util.js';
@ -8,6 +9,9 @@ import { isAPropagatingComponent } from './factory.js';
import { isHeadAndContent } from './head-and-content.js'; import { isHeadAndContent } from './head-and-content.js';
type ComponentProps = Record<string | number, any>; type ComponentProps = Record<string | number, any>;
type ComponentSlotValue = () => ReturnType<typeof renderTemplate>;
export type ComponentSlots = Record<string, ComponentSlotValue>;
export type ComponentSlotsWithValues = Record<string, ReturnType<ComponentSlotValue>>;
const astroComponentInstanceSym = Symbol.for('astro.componentInstance'); const astroComponentInstanceSym = Symbol.for('astro.componentInstance');
@ -16,23 +20,26 @@ export class AstroComponentInstance {
private readonly result: SSRResult; private readonly result: SSRResult;
private readonly props: ComponentProps; private readonly props: ComponentProps;
private readonly slots: any; private readonly slotValues: ComponentSlotsWithValues;
private readonly factory: AstroComponentFactory; private readonly factory: AstroComponentFactory;
private returnValue: ReturnType<AstroComponentFactory> | undefined; private returnValue: ReturnType<AstroComponentFactory> | undefined;
constructor( constructor(
result: SSRResult, result: SSRResult,
props: ComponentProps, props: ComponentProps,
slots: any, slots: ComponentSlots,
factory: AstroComponentFactory factory: AstroComponentFactory
) { ) {
this.result = result; this.result = result;
this.props = props; this.props = props;
this.slots = slots;
this.factory = factory; this.factory = factory;
this.slotValues = {};
for(const name in slots) {
this.slotValues[name] = slots[name]();
}
} }
async init() { async init() {
this.returnValue = this.factory(this.result, this.props, this.slots); this.returnValue = this.factory(this.result, this.props, this.slotValues);
return this.returnValue; return this.returnValue;
} }

View file

@ -1,4 +1,9 @@
export type { AstroComponentFactory, AstroComponentInstance } from './astro/index'; export type {
AstroComponentFactory,
AstroComponentInstance,
ComponentSlots as AstroComponentSlots,
ComponentSlotsWithValues as AstroComponentSlotsWithValues
} from './astro/index';
export { export {
createHeadAndContent, createHeadAndContent,
renderAstroTemplateResult, renderAstroTemplateResult,
@ -11,6 +16,7 @@ export { renderHTMLElement } from './dom.js';
export { maybeRenderHead, renderHead } from './head.js'; export { maybeRenderHead, renderHead } from './head.js';
export { renderPage } from './page.js'; export { renderPage } from './page.js';
export { renderSlot } from './slot.js'; export { renderSlot } from './slot.js';
export { renderUniqueStylesheet } from './stylesheet.js'; export { renderUniqueStylesheet, renderStyleElement } from './tags.js';
export type { RenderInstruction } from './types'; export type { RenderInstruction } from './types';
export { addAttribute, defineScriptVars, voidElementNames } from './util.js'; export { addAttribute, defineScriptVars, voidElementNames } from './util.js';

View file

@ -1,12 +1,17 @@
import { SSRResult } from '../../../@types/astro'; import { SSRResult } from '../../../@types/astro';
import { markHTMLString } from '../escape.js';
import { renderElement } from './util.js'; import { renderElement } from './util.js';
const stylesheetRel = 'stylesheet'; const stylesheetRel = 'stylesheet';
export function renderStyleElement(children: string) {
return renderElement('style', {
props: {},
children
})
}
export function renderStylesheet({ href }: { href: string }) { export function renderStylesheet({ href }: { href: string }) {
return markHTMLString( return renderElement(
renderElement(
'link', 'link',
{ {
props: { props: {
@ -16,7 +21,6 @@ export function renderStylesheet({ href }: { href: string }) {
children: '', children: '',
}, },
false false
)
); );
} }

View file

@ -10,7 +10,11 @@ import type { LogOptions } from '../core/logger/core.js';
import { warn } from '../core/logger/core.js'; import { warn } from '../core/logger/core.js';
import { isMarkdownFile } from '../core/util.js'; import { isMarkdownFile } from '../core/util.js';
import type { PluginMetadata } from '../vite-plugin-astro/types.js'; import type { PluginMetadata } from '../vite-plugin-astro/types.js';
import { getFileInfo, safelyGetAstroData } from '../vite-plugin-utils/index.js'; import {
escapeViteEnvReferences,
getFileInfo,
safelyGetAstroData,
} from '../vite-plugin-utils/index.js';
interface AstroPluginOptions { interface AstroPluginOptions {
settings: AstroSettings; settings: AstroSettings;
@ -93,6 +97,9 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu
const html = ${JSON.stringify(html)}; const html = ${JSON.stringify(html)};
export const _internal = {
injectedFrontmatter: ${JSON.stringify(injectedFrontmatter)},
}
export const frontmatter = ${JSON.stringify(frontmatter)}; export const frontmatter = ${JSON.stringify(frontmatter)};
export const file = ${JSON.stringify(fileId)}; export const file = ${JSON.stringify(fileId)};
export const url = ${JSON.stringify(fileUrl)}; export const url = ${JSON.stringify(fileUrl)};
@ -169,10 +176,3 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu
}, },
}; };
} }
// Converts the first dot in `import.meta.env` to its Unicode escape sequence,
// which prevents Vite from replacing strings like `import.meta.env.SITE`
// in our JS representation of loaded Markdown files
function escapeViteEnvReferences(code: string) {
return code.replace(/import\.meta\.env/g, 'import\\u002Emeta.env');
}

View file

@ -1,7 +1,6 @@
import { Plugin as VitePlugin } from 'vite'; import { Plugin as VitePlugin } from 'vite';
import { AstroSettings } from '../@types/astro.js'; import { AstroSettings } from '../@types/astro.js';
import { PAGE_SSR_SCRIPT_ID } from './index.js'; import { PAGE_SSR_SCRIPT_ID } from './index.js';
import MagicString from 'magic-string'; import MagicString from 'magic-string';
import { isPage } from '../core/util.js'; import { isPage } from '../core/util.js';
import { normalizeFilename } from '../vite-plugin-utils/index.js'; import { normalizeFilename } from '../vite-plugin-utils/index.js';
@ -14,7 +13,6 @@ export default function astroScriptsPostPlugin({
return { return {
name: 'astro:scripts:page-ssr', name: 'astro:scripts:page-ssr',
enforce: 'post', enforce: 'post',
transform(this, code, id, options) { transform(this, code, id, options) {
if (!options?.ssr) return; if (!options?.ssr) return;

View file

@ -1,5 +1,5 @@
import ancestor from 'common-ancestor-path'; import ancestor from 'common-ancestor-path';
import { Data } from 'vfile'; import type { Data } from 'vfile';
import type { AstroConfig, MarkdownAstroData } from '../@types/astro'; import type { AstroConfig, MarkdownAstroData } from '../@types/astro';
import { import {
appendExtension, appendExtension,
@ -7,6 +7,15 @@ import {
removeLeadingForwardSlashWindows, removeLeadingForwardSlashWindows,
} from '../core/path.js'; } from '../core/path.js';
/**
* Converts the first dot in `import.meta.env` to its Unicode escape sequence,
* which prevents Vite from replacing strings like `import.meta.env.SITE`
* in our JS representation of modules like Markdown
*/
export function escapeViteEnvReferences(code: string) {
return code.replace(/import\.meta\.env/g, 'import\\u002Emeta.env');
}
export function getFileInfo(id: string, config: AstroConfig) { export function getFileInfo(id: string, config: AstroConfig) {
const sitePathname = appendForwardSlash( const sitePathname = appendForwardSlash(
config.site ? new URL(config.base, config.site).pathname : config.base config.site ? new URL(config.base, config.site).pathname : config.base

View file

@ -0,0 +1,189 @@
import { expect } from 'chai';
import { loadFixture } from './test-utils.js';
import testAdapter from './test-adapter.js';
import * as devalue from 'devalue';
import * as cheerio from 'cheerio';
describe('Content Collections', () => {
describe('Query', () => {
let fixture;
before(async () => {
fixture = await loadFixture({ root: './fixtures/content-collections/' });
await fixture.build();
});
describe('Collection', () => {
let json;
before(async () => {
const rawJson = await fixture.readFile('/collections.json');
json = devalue.parse(rawJson);
});
it('Returns `without config` collection', async () => {
expect(json).to.haveOwnProperty('withoutConfig');
expect(Array.isArray(json.withoutConfig)).to.equal(true);
const ids = json.withoutConfig.map((item) => item.id);
expect(ids).to.deep.equal([
'columbia.md',
'endeavour.md',
'enterprise.md',
'promo/launch-week.mdx',
]);
});
it('Returns `with schema` collection', async () => {
expect(json).to.haveOwnProperty('withSchemaConfig');
expect(Array.isArray(json.withSchemaConfig)).to.equal(true);
const ids = json.withSchemaConfig.map((item) => item.id);
const publishedDates = json.withSchemaConfig.map((item) => item.data.publishedAt);
expect(ids).to.deep.equal(['one.md', 'three.md', 'two.md']);
expect(publishedDates.every((date) => date instanceof Date)).to.equal(
true,
'Not all publishedAt dates are Date objects'
);
expect(publishedDates.map((date) => date.toISOString())).to.deep.equal([
'2021-01-01T00:00:00.000Z',
'2021-01-03T00:00:00.000Z',
'2021-01-02T00:00:00.000Z',
]);
});
it('Returns `with custom slugs` collection', async () => {
expect(json).to.haveOwnProperty('withSlugConfig');
expect(Array.isArray(json.withSlugConfig)).to.equal(true);
const slugs = json.withSlugConfig.map((item) => item.slug);
expect(slugs).to.deep.equal(['fancy-one.md', 'excellent-three.md', 'interesting-two.md']);
});
});
describe('Entry', () => {
let json;
before(async () => {
const rawJson = await fixture.readFile('/entries.json');
json = devalue.parse(rawJson);
});
it('Returns `without config` collection entry', async () => {
expect(json).to.haveOwnProperty('columbiaWithoutConfig');
expect(json.columbiaWithoutConfig.id).to.equal('columbia.md');
});
it('Returns `with schema` collection entry', async () => {
expect(json).to.haveOwnProperty('oneWithSchemaConfig');
expect(json.oneWithSchemaConfig.id).to.equal('one.md');
expect(json.oneWithSchemaConfig.data.publishedAt instanceof Date).to.equal(true);
expect(json.oneWithSchemaConfig.data.publishedAt.toISOString()).to.equal(
'2021-01-01T00:00:00.000Z'
);
});
it('Returns `with custom slugs` collection entry', async () => {
expect(json).to.haveOwnProperty('twoWithSlugConfig');
expect(json.twoWithSlugConfig.slug).to.equal('interesting-two.md');
});
});
});
const blogSlugToContents = {
'first-post': {
title: 'First post',
element: 'blockquote',
content: 'First post loaded: yes!',
},
'second-post': {
title: 'Second post',
element: 'blockquote',
content: 'Second post loaded: yes!',
},
'third-post': {
title: 'Third post',
element: 'blockquote',
content: 'Third post loaded: yes!',
},
'using-mdx': {
title: 'Using MDX',
element: 'a[href="#"]',
content: 'Embedded component in MDX',
},
};
describe('Static paths integration', () => {
let fixture;
before(async () => {
fixture = await loadFixture({ root: './fixtures/content-static-paths-integration/' });
await fixture.build();
});
it('Generates expected pages', async () => {
for (const slug in blogSlugToContents) {
expect(fixture.pathExists(`/posts/${slug}`)).to.equal(true);
}
});
it('Renders titles', async () => {
for (const slug in blogSlugToContents) {
const post = await fixture.readFile(`/posts/${slug}/index.html`);
const $ = cheerio.load(post);
expect($('h1').text()).to.equal(blogSlugToContents[slug].title);
}
});
it('Renders content', async () => {
for (const slug in blogSlugToContents) {
const post = await fixture.readFile(`/posts/${slug}/index.html`);
const $ = cheerio.load(post);
expect($(blogSlugToContents[slug].element).text().trim()).to.equal(
blogSlugToContents[slug].content
);
}
});
});
describe('SSR integration', () => {
let app;
before(async () => {
const fixture = await loadFixture({
root: './fixtures/content-ssr-integration/',
output: 'server',
adapter: testAdapter(),
});
await fixture.build();
app = await fixture.loadTestAdapterApp();
});
it('Responds 200 for expected pages', async () => {
for (const slug in blogSlugToContents) {
const request = new Request('http://example.com/posts/' + slug);
const response = await app.render(request);
expect(response.status).to.equal(200);
}
});
it('Renders titles', async () => {
for (const slug in blogSlugToContents) {
const request = new Request('http://example.com/posts/' + slug);
const response = await app.render(request);
const body = await response.text();
const $ = cheerio.load(body);
expect($('h1').text()).to.equal(blogSlugToContents[slug].title);
}
});
it('Renders content', async () => {
for (const slug in blogSlugToContents) {
const request = new Request('http://example.com/posts/' + slug);
const response = await app.render(request);
const body = await response.text();
const $ = cheerio.load(body);
expect($(blogSlugToContents[slug].element).text().trim()).to.equal(
blogSlugToContents[slug].content
);
}
});
});
});

View file

@ -0,0 +1,10 @@
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
// https://astro.build/config
export default defineConfig({
integrations: [mdx()],
experimental: {
contentCollections: true,
},
});

View file

@ -0,0 +1,9 @@
{
"name": "@test/content-collections",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*",
"@astrojs/mdx": "workspace:*"
}
}

View file

@ -0,0 +1,24 @@
import { z, defineCollection } from 'astro:content';
const withSlugConfig = defineCollection({
slug({ id, data }) {
return `${data.prefix}-${id}`;
},
schema: {
prefix: z.string(),
}
});
const withSchemaConfig = defineCollection({
schema: {
title: z.string(),
isDraft: z.boolean().default(false),
lang: z.enum(['en', 'fr', 'es']).default('en'),
publishedAt: z.date().transform((val) => new Date(val)),
}
});
export const collections = {
'with-slug-config': withSlugConfig,
'with-schema-config': withSchemaConfig,
}

View file

@ -0,0 +1,125 @@
declare module 'astro:content' {
export { z } from 'astro/zod';
export type CollectionEntry<C extends keyof typeof entryMap> =
typeof entryMap[C][keyof typeof entryMap[C]] & Render;
type BaseCollectionConfig<S extends import('astro/zod').ZodRawShape> = {
schema?: S;
slug?: (entry: {
id: CollectionEntry<keyof typeof entryMap>['id'];
defaultSlug: string;
collection: string;
body: string;
data: import('astro/zod').infer<import('astro/zod').ZodObject<S>>;
}) => string | Promise<string>;
};
export function defineCollection<S extends import('astro/zod').ZodRawShape>(
input: BaseCollectionConfig<S>
): BaseCollectionConfig<S>;
export function getEntry<C extends keyof typeof entryMap, E extends keyof typeof entryMap[C]>(
collection: C,
entryKey: E
): Promise<typeof entryMap[C][E] & Render>;
export function getCollection<
C extends keyof typeof entryMap,
E extends keyof typeof entryMap[C]
>(
collection: C,
filter?: (data: typeof entryMap[C][E]) => boolean
): Promise<(typeof entryMap[C][E] & Render)[]>;
type InferEntrySchema<C extends keyof typeof entryMap> = import('astro/zod').infer<
import('astro/zod').ZodObject<Required<ContentConfig['collections'][C]>['schema']>
>;
type Render = {
render(): Promise<{
Content: import('astro').MarkdownInstance<{}>['Content'];
headings: import('astro').MarkdownHeading[];
injectedFrontmatter: Record<string, any>;
}>;
};
const entryMap: {
"with-schema-config": {
"one.md": {
id: "one.md",
slug: "one",
body: string,
collection: "with-schema-config",
data: InferEntrySchema<"with-schema-config">
},
"three.md": {
id: "three.md",
slug: "three",
body: string,
collection: "with-schema-config",
data: InferEntrySchema<"with-schema-config">
},
"two.md": {
id: "two.md",
slug: "two",
body: string,
collection: "with-schema-config",
data: InferEntrySchema<"with-schema-config">
},
},
"with-slug-config": {
"one.md": {
id: "one.md",
slug: string,
body: string,
collection: "with-slug-config",
data: InferEntrySchema<"with-slug-config">
},
"three.md": {
id: "three.md",
slug: string,
body: string,
collection: "with-slug-config",
data: InferEntrySchema<"with-slug-config">
},
"two.md": {
id: "two.md",
slug: string,
body: string,
collection: "with-slug-config",
data: InferEntrySchema<"with-slug-config">
},
},
"without-config": {
"columbia.md": {
id: "columbia.md",
slug: "columbia",
body: string,
collection: "without-config",
data: any
},
"endeavour.md": {
id: "endeavour.md",
slug: "endeavour",
body: string,
collection: "without-config",
data: any
},
"enterprise.md": {
id: "enterprise.md",
slug: "enterprise",
body: string,
collection: "without-config",
data: any
},
"promo/launch-week.mdx": {
id: "promo/launch-week.mdx",
slug: "promo/launch-week",
body: string,
collection: "without-config",
data: any
},
},
};
type ContentConfig = typeof import("./config");
}

View file

@ -0,0 +1,8 @@
---
title: One
description: The first page
lang: en
publishedAt: 2021-01-01
---
# It's the first page, fancy!

View file

@ -0,0 +1,8 @@
---
title: Three
description: The third page
lang: es
publishedAt: 2021-01-03
---
# It's the third page, excellent!

View file

@ -0,0 +1,8 @@
---
title: Two
description: The second page
lang: en
publishedAt: 2021-01-02
---
# It's the second page, interesting!

View file

@ -0,0 +1,5 @@
---
prefix: fancy
---
# It's the first page, fancy!

View file

@ -0,0 +1,5 @@
---
prefix: excellent
---
# It's the third page, excellent!

View file

@ -0,0 +1,5 @@
---
prefix: interesting
---
# It's the second page, interesting!

View file

@ -0,0 +1,15 @@
---
title: Columbia
description: 'Learn about the Columbia NASA space shuttle.'
publishedDate: 'Sat May 21 2022 00:00:00 GMT-0400 (Eastern Daylight Time)'
tags: [space, 90s]
---
**Source:** [Wikipedia](https://en.wikipedia.org/wiki/Space_Shuttle_Endeavour)
Space Shuttle Endeavour (Orbiter Vehicle Designation: OV-105) is a retired orbiter from NASA's Space Shuttle program and the fifth and final operational Shuttle built. It embarked on its first mission, STS-49, in May 1992 and its 25th and final mission, STS-134, in May 2011. STS-134 was expected to be the final mission of the Space Shuttle program, but with the authorization of STS-135, Atlantis became the last shuttle to fly.
The United States Congress approved the construction of Endeavour in 1987 to replace the Space Shuttle Challenger, which was destroyed in 1986.
NASA chose, on cost grounds, to build much of Endeavour from spare parts rather than refitting the Space Shuttle Enterprise, and used structural spares built during the construction of Discovery and Atlantis in its assembly.
Space Shuttle Endeavour (Orbiter Vehicle Designation: OV-105) is a retired orbiter from NASA's Space Shuttle program and the fifth and final operational Shuttle built. It embarked on its first mission, STS-49, in May 1992 and its 25th and final mission, STS-134, in May 2011. STS-134 was expected to be the final mission of the Space Shuttle program, but with the authorization of STS-135, Atlantis became the last shuttle to fly.

View file

@ -0,0 +1,14 @@
---
title: Endeavour
description: 'Learn about the Endeavour NASA space shuttle.'
publishedDate: 'Sun Jul 11 2021 00:00:00 GMT-0400 (Eastern Daylight Time)'
tags: [space, 90s]
---
**Source:** [Wikipedia](https://en.wikipedia.org/wiki/Space_Shuttle_Endeavour)
Space Shuttle Endeavour (Orbiter Vehicle Designation: OV-105) is a retired orbiter from NASA's Space Shuttle program and the fifth and final operational Shuttle built. It embarked on its first mission, STS-49, in May 1992 and its 25th and final mission, STS-134, in May 2011. STS-134 was expected to be the final mission of the Space Shuttle program, but with the authorization of STS-135, Atlantis became the last shuttle to fly.
The United States Congress approved the construction of Endeavour in 1987 to replace the Space Shuttle Challenger, which was destroyed in 1986.
NASA chose, on cost grounds, to build much of Endeavour from spare parts rather than refitting the Space Shuttle Enterprise, and used structural spares built during the construction of Discovery and Atlantis in its assembly.

View file

@ -0,0 +1,14 @@
---
title: 'Enterprise'
description: 'Learn about the Enterprise NASA space shuttle.'
publishedDate: 'Tue Jun 08 2021 00:00:00 GMT-0400 (Eastern Daylight Time)'
tags: [space, 70s]
---
**Source:** [Wikipedia](https://en.wikipedia.org/wiki/Space_Shuttle_Enterprise)
Space Shuttle Enterprise (Orbiter Vehicle Designation: OV-101) was the first orbiter of the Space Shuttle system. Rolled out on September 17, 1976, it was built for NASA as part of the Space Shuttle program to perform atmospheric test flights after being launched from a modified Boeing 747. It was constructed without engines or a functional heat shield. As a result, it was not capable of spaceflight.
Originally, Enterprise had been intended to be refitted for orbital flight to become the second space-rated orbiter in service. However, during the construction of Space Shuttle Columbia, details of the final design changed, making it simpler and less costly to build Challenger around a body frame that had been built as a test article. Similarly, Enterprise was considered for refit to replace Challenger after the latter was destroyed, but Endeavour was built from structural spares instead.
Enterprise was restored and placed on display in 2003 at the Smithsonian's new Steven F. Udvar-Hazy Center in Virginia. Following the retirement of the Space Shuttle fleet, Discovery replaced Enterprise at the Udvar-Hazy Center, and Enterprise was transferred to the Intrepid Sea, Air & Space Museum in New York City, where it has been on display since July 2012.

View file

@ -0,0 +1,3 @@
body {
font-family: 'Comic Sans MS', sans-serif;
}

View file

@ -0,0 +1,14 @@
---
title: 'Launch week!'
description: 'Join us for the exciting launch of SPACE BLOG'
publishedDate: 'Sat May 21 2022 00:00:00 GMT-0400 (Eastern Daylight Time)'
tags: ['announcement']
---
import './launch-week-styles.css';
Join us for the space blog launch!
- THIS THURSDAY
- Houston, TX
- Dress code: **interstellar casual** ✨

View file

@ -0,0 +1,12 @@
import { getCollection } from 'astro:content';
import * as devalue from 'devalue';
import { stripAllRenderFn } from '../utils.js';
export async function get() {
const withoutConfig = stripAllRenderFn(await getCollection('without-config'));
const withSchemaConfig = stripAllRenderFn(await getCollection('with-schema-config'));
const withSlugConfig = stripAllRenderFn(await getCollection('with-slug-config'));
return {
body: devalue.stringify({withoutConfig, withSchemaConfig, withSlugConfig}),
}
}

View file

@ -0,0 +1,12 @@
import { getEntry } from 'astro:content';
import * as devalue from 'devalue';
import { stripRenderFn } from '../utils.js';
export async function get() {
const columbiaWithoutConfig = stripRenderFn(await getEntry('without-config', 'columbia.md'));
const oneWithSchemaConfig = stripRenderFn(await getEntry('with-schema-config', 'one.md'));
const twoWithSlugConfig = stripRenderFn(await getEntry('with-slug-config', 'two.md'));
return {
body: devalue.stringify({columbiaWithoutConfig, oneWithSchemaConfig, twoWithSlugConfig}),
}
}

View file

@ -0,0 +1,24 @@
---
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>It's content time!</title>
<style>
html,
body {
font-family: system-ui;
margin: 0;
}
body {
padding: 2rem;
}
</style>
</head>
<body>
<main>
</main>
</body>
</html>

View file

@ -0,0 +1,8 @@
export function stripRenderFn(entryWithRender) {
const { render, ...entry } = entryWithRender;
return entry;
}
export function stripAllRenderFn(collection = []) {
return collection.map(stripRenderFn);
}

View file

@ -0,0 +1,10 @@
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
// https://astro.build/config
export default defineConfig({
integrations: [mdx()],
experimental: {
contentCollections: true,
},
});

View file

@ -0,0 +1,9 @@
{
"name": "@test/content-ssr-integration",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*",
"@astrojs/mdx": "workspace:*"
}
}

View file

@ -0,0 +1,22 @@
---
export interface Props extends astroHTML.JSX.AnchorHTMLAttributes {}
const { href, class: className, ...props } = Astro.props;
const { pathname } = Astro.url;
const isActive = href === pathname || href === pathname.replace(/\/$/, '');
---
<a href={href} class:list={[className, { active: isActive }]} {...props}>
<slot />
</a>
<style>
a {
display: inline-block;
text-decoration: none;
}
a.active {
font-weight: bolder;
text-decoration: underline;
}
</style>

View file

@ -0,0 +1,18 @@
---
title: "First post"
description: "Lorem ipsum dolor sit amet"
pubDate: "Jul 08 2022"
heroImage: "/placeholder-hero.jpg"
---
> First post loaded: yes!
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Vitae ultricies leo integer malesuada nunc vel risus commodo viverra. Adipiscing enim eu turpis egestas pretium. Euismod elementum nisi quis eleifend quam adipiscing. In hac habitasse platea dictumst vestibulum. Sagittis purus sit amet volutpat. Netus et malesuada fames ac turpis egestas. Eget magna fermentum iaculis eu non diam phasellus vestibulum lorem. Varius sit amet mattis vulputate enim. Habitasse platea dictumst quisque sagittis. Integer quis auctor elit sed vulputate mi. Dictumst quisque sagittis purus sit amet.
Morbi tristique senectus et netus. Id semper risus in hendrerit gravida rutrum quisque non tellus. Habitasse platea dictumst quisque sagittis purus sit amet. Tellus molestie nunc non blandit massa. Cursus vitae congue mauris rhoncus. Accumsan tortor posuere ac ut. Fringilla urna porttitor rhoncus dolor. Elit ullamcorper dignissim cras tincidunt lobortis. In cursus turpis massa tincidunt dui ut ornare lectus. Integer feugiat scelerisque varius morbi enim nunc. Bibendum neque egestas congue quisque egestas diam. Cras ornare arcu dui vivamus arcu felis bibendum. Dignissim suspendisse in est ante in nibh mauris. Sed tempus urna et pharetra pharetra massa massa ultricies mi.
Mollis nunc sed id semper risus in. Convallis a cras semper auctor neque. Diam sit amet nisl suscipit. Lacus viverra vitae congue eu consequat ac felis donec. Egestas integer eget aliquet nibh praesent tristique magna sit amet. Eget magna fermentum iaculis eu non diam. In vitae turpis massa sed elementum. Tristique et egestas quis ipsum suspendisse ultrices. Eget lorem dolor sed viverra ipsum. Vel turpis nunc eget lorem dolor sed viverra. Posuere ac ut consequat semper viverra nam. Laoreet suspendisse interdum consectetur libero id faucibus. Diam phasellus vestibulum lorem sed risus ultricies tristique. Rhoncus dolor purus non enim praesent elementum facilisis. Ultrices tincidunt arcu non sodales neque. Tempus egestas sed sed risus pretium quam vulputate. Viverra suspendisse potenti nullam ac tortor vitae purus faucibus ornare. Fringilla urna porttitor rhoncus dolor purus non. Amet dictum sit amet justo donec enim.
Mattis ullamcorper velit sed ullamcorper morbi tincidunt. Tortor posuere ac ut consequat semper viverra. Tellus mauris a diam maecenas sed enim ut sem viverra. Venenatis urna cursus eget nunc scelerisque viverra mauris in. Arcu ac tortor dignissim convallis aenean et tortor at. Curabitur gravida arcu ac tortor dignissim convallis aenean et tortor. Egestas tellus rutrum tellus pellentesque eu. Fusce ut placerat orci nulla pellentesque dignissim enim sit amet. Ut enim blandit volutpat maecenas volutpat blandit aliquam etiam. Id donec ultrices tincidunt arcu. Id cursus metus aliquam eleifend mi.
Tempus quam pellentesque nec nam aliquam sem. Risus at ultrices mi tempus imperdiet. Id porta nibh venenatis cras sed felis eget velit. Ipsum a arcu cursus vitae. Facilisis magna etiam tempor orci eu lobortis elementum. Tincidunt dui ut ornare lectus sit. Quisque non tellus orci ac. Blandit libero volutpat sed cras. Nec tincidunt praesent semper feugiat nibh sed pulvinar proin gravida. Egestas integer eget aliquet nibh praesent tristique magna.

View file

@ -0,0 +1,18 @@
---
title: "Second post"
description: "Lorem ipsum dolor sit amet"
pubDate: "Jul 22 2022"
heroImage: "/placeholder-hero.jpg"
---
> Second post loaded: yes!
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Vitae ultricies leo integer malesuada nunc vel risus commodo viverra. Adipiscing enim eu turpis egestas pretium. Euismod elementum nisi quis eleifend quam adipiscing. In hac habitasse platea dictumst vestibulum. Sagittis purus sit amet volutpat. Netus et malesuada fames ac turpis egestas. Eget magna fermentum iaculis eu non diam phasellus vestibulum lorem. Varius sit amet mattis vulputate enim. Habitasse platea dictumst quisque sagittis. Integer quis auctor elit sed vulputate mi. Dictumst quisque sagittis purus sit amet.
Morbi tristique senectus et netus. Id semper risus in hendrerit gravida rutrum quisque non tellus. Habitasse platea dictumst quisque sagittis purus sit amet. Tellus molestie nunc non blandit massa. Cursus vitae congue mauris rhoncus. Accumsan tortor posuere ac ut. Fringilla urna porttitor rhoncus dolor. Elit ullamcorper dignissim cras tincidunt lobortis. In cursus turpis massa tincidunt dui ut ornare lectus. Integer feugiat scelerisque varius morbi enim nunc. Bibendum neque egestas congue quisque egestas diam. Cras ornare arcu dui vivamus arcu felis bibendum. Dignissim suspendisse in est ante in nibh mauris. Sed tempus urna et pharetra pharetra massa massa ultricies mi.
Mollis nunc sed id semper risus in. Convallis a cras semper auctor neque. Diam sit amet nisl suscipit. Lacus viverra vitae congue eu consequat ac felis donec. Egestas integer eget aliquet nibh praesent tristique magna sit amet. Eget magna fermentum iaculis eu non diam. In vitae turpis massa sed elementum. Tristique et egestas quis ipsum suspendisse ultrices. Eget lorem dolor sed viverra ipsum. Vel turpis nunc eget lorem dolor sed viverra. Posuere ac ut consequat semper viverra nam. Laoreet suspendisse interdum consectetur libero id faucibus. Diam phasellus vestibulum lorem sed risus ultricies tristique. Rhoncus dolor purus non enim praesent elementum facilisis. Ultrices tincidunt arcu non sodales neque. Tempus egestas sed sed risus pretium quam vulputate. Viverra suspendisse potenti nullam ac tortor vitae purus faucibus ornare. Fringilla urna porttitor rhoncus dolor purus non. Amet dictum sit amet justo donec enim.
Mattis ullamcorper velit sed ullamcorper morbi tincidunt. Tortor posuere ac ut consequat semper viverra. Tellus mauris a diam maecenas sed enim ut sem viverra. Venenatis urna cursus eget nunc scelerisque viverra mauris in. Arcu ac tortor dignissim convallis aenean et tortor at. Curabitur gravida arcu ac tortor dignissim convallis aenean et tortor. Egestas tellus rutrum tellus pellentesque eu. Fusce ut placerat orci nulla pellentesque dignissim enim sit amet. Ut enim blandit volutpat maecenas volutpat blandit aliquam etiam. Id donec ultrices tincidunt arcu. Id cursus metus aliquam eleifend mi.
Tempus quam pellentesque nec nam aliquam sem. Risus at ultrices mi tempus imperdiet. Id porta nibh venenatis cras sed felis eget velit. Ipsum a arcu cursus vitae. Facilisis magna etiam tempor orci eu lobortis elementum. Tincidunt dui ut ornare lectus sit. Quisque non tellus orci ac. Blandit libero volutpat sed cras. Nec tincidunt praesent semper feugiat nibh sed pulvinar proin gravida. Egestas integer eget aliquet nibh praesent tristique magna.

View file

@ -0,0 +1,18 @@
---
title: "Third post"
description: "Lorem ipsum dolor sit amet"
pubDate: "Jul 15 2022"
heroImage: "/placeholder-hero.jpg"
---
> Third post loaded: yes!
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Vitae ultricies leo integer malesuada nunc vel risus commodo viverra. Adipiscing enim eu turpis egestas pretium. Euismod elementum nisi quis eleifend quam adipiscing. In hac habitasse platea dictumst vestibulum. Sagittis purus sit amet volutpat. Netus et malesuada fames ac turpis egestas. Eget magna fermentum iaculis eu non diam phasellus vestibulum lorem. Varius sit amet mattis vulputate enim. Habitasse platea dictumst quisque sagittis. Integer quis auctor elit sed vulputate mi. Dictumst quisque sagittis purus sit amet.
Morbi tristique senectus et netus. Id semper risus in hendrerit gravida rutrum quisque non tellus. Habitasse platea dictumst quisque sagittis purus sit amet. Tellus molestie nunc non blandit massa. Cursus vitae congue mauris rhoncus. Accumsan tortor posuere ac ut. Fringilla urna porttitor rhoncus dolor. Elit ullamcorper dignissim cras tincidunt lobortis. In cursus turpis massa tincidunt dui ut ornare lectus. Integer feugiat scelerisque varius morbi enim nunc. Bibendum neque egestas congue quisque egestas diam. Cras ornare arcu dui vivamus arcu felis bibendum. Dignissim suspendisse in est ante in nibh mauris. Sed tempus urna et pharetra pharetra massa massa ultricies mi.
Mollis nunc sed id semper risus in. Convallis a cras semper auctor neque. Diam sit amet nisl suscipit. Lacus viverra vitae congue eu consequat ac felis donec. Egestas integer eget aliquet nibh praesent tristique magna sit amet. Eget magna fermentum iaculis eu non diam. In vitae turpis massa sed elementum. Tristique et egestas quis ipsum suspendisse ultrices. Eget lorem dolor sed viverra ipsum. Vel turpis nunc eget lorem dolor sed viverra. Posuere ac ut consequat semper viverra nam. Laoreet suspendisse interdum consectetur libero id faucibus. Diam phasellus vestibulum lorem sed risus ultricies tristique. Rhoncus dolor purus non enim praesent elementum facilisis. Ultrices tincidunt arcu non sodales neque. Tempus egestas sed sed risus pretium quam vulputate. Viverra suspendisse potenti nullam ac tortor vitae purus faucibus ornare. Fringilla urna porttitor rhoncus dolor purus non. Amet dictum sit amet justo donec enim.
Mattis ullamcorper velit sed ullamcorper morbi tincidunt. Tortor posuere ac ut consequat semper viverra. Tellus mauris a diam maecenas sed enim ut sem viverra. Venenatis urna cursus eget nunc scelerisque viverra mauris in. Arcu ac tortor dignissim convallis aenean et tortor at. Curabitur gravida arcu ac tortor dignissim convallis aenean et tortor. Egestas tellus rutrum tellus pellentesque eu. Fusce ut placerat orci nulla pellentesque dignissim enim sit amet. Ut enim blandit volutpat maecenas volutpat blandit aliquam etiam. Id donec ultrices tincidunt arcu. Id cursus metus aliquam eleifend mi.
Tempus quam pellentesque nec nam aliquam sem. Risus at ultrices mi tempus imperdiet. Id porta nibh venenatis cras sed felis eget velit. Ipsum a arcu cursus vitae. Facilisis magna etiam tempor orci eu lobortis elementum. Tincidunt dui ut ornare lectus sit. Quisque non tellus orci ac. Blandit libero volutpat sed cras. Nec tincidunt praesent semper feugiat nibh sed pulvinar proin gravida. Egestas integer eget aliquet nibh praesent tristique magna.

View file

@ -0,0 +1,31 @@
---
title: 'Using MDX'
description: 'Lorem ipsum dolor sit amet'
pubDate: 'Jul 02 2022'
heroImage: '/placeholder-hero.jpg'
---
This theme comes with the [@astrojs/mdx](https://docs.astro.build/en/guides/integrations-guide/mdx/) integration installed and configured in your `astro.config.mjs` config file. If you prefer not to use MDX, you can disable support by removing the integration from your config file.
## Why MDX?
MDX is a special flavor of Markdown that supports embedded JavaScript & JSX syntax. This unlocks the ability to [mix JavaScript and UI Components into your Markdown content](https://docs.astro.build/en/guides/markdown-content/#mdx-features) for things like interactive charts or alerts.
If you have existing content authored in MDX, this integration will hopefully make migrating to Astro a breeze.
## Example
Here is how you import and use a UI component inside of MDX.
When you open this page in the browser, you should see the clickable button below.
import HeaderLink from '../../components/HeaderLink.astro';
<HeaderLink href="#" onclick="alert('clicked!')">
Embedded component in MDX
</HeaderLink>
## More Links
- [MDX Syntax Documentation](https://mdxjs.com/docs/what-is-mdx)
- [Astro Usage Documentation](https://docs.astro.build/en/guides/markdown-content/#markdown-and-mdx-pages)
- **Note:** [Client Directives](https://docs.astro.build/en/reference/directives-reference/#client-directives) are still required to create interactive components. Otherwise, all components in your MDX will render as static HTML (no JavaScript) by default.

View file

@ -0,0 +1,16 @@
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
schema: {
title: z.string(),
description: z.string(),
pubDate: z.string().transform((str) => new Date(str)),
updatedDate: z
.string()
.optional()
.transform((str) => (str ? new Date(str) : undefined)),
heroImage: z.string().optional(),
},
});
export const collections = { blog };

View file

@ -0,0 +1,79 @@
declare module 'astro:content' {
export { z } from 'astro/zod';
export type CollectionEntry<C extends keyof typeof entryMap> =
typeof entryMap[C][keyof typeof entryMap[C]] & Render;
type BaseCollectionConfig<S extends import('astro/zod').ZodRawShape> = {
schema?: S;
slug?: (entry: {
id: CollectionEntry<keyof typeof entryMap>['id'];
defaultSlug: string;
collection: string;
body: string;
data: import('astro/zod').infer<import('astro/zod').ZodObject<S>>;
}) => string | Promise<string>;
};
export function defineCollection<S extends import('astro/zod').ZodRawShape>(
input: BaseCollectionConfig<S>
): BaseCollectionConfig<S>;
export function getEntry<C extends keyof typeof entryMap, E extends keyof typeof entryMap[C]>(
collection: C,
entryKey: E
): Promise<typeof entryMap[C][E] & Render>;
export function getCollection<
C extends keyof typeof entryMap,
E extends keyof typeof entryMap[C]
>(
collection: C,
filter?: (data: typeof entryMap[C][E]) => boolean
): Promise<(typeof entryMap[C][E] & Render)[]>;
type InferEntrySchema<C extends keyof typeof entryMap> = import('astro/zod').infer<
import('astro/zod').ZodObject<Required<ContentConfig['collections'][C]>['schema']>
>;
type Render = {
render(): Promise<{
Content: import('astro').MarkdownInstance<{}>['Content'];
headings: import('astro').MarkdownHeading[];
injectedFrontmatter: Record<string, any>;
}>;
};
const entryMap: {
"blog": {
"first-post.md": {
id: "first-post.md",
slug: "first-post",
body: string,
collection: "blog",
data: InferEntrySchema<"blog">
},
"second-post.md": {
id: "second-post.md",
slug: "second-post",
body: string,
collection: "blog",
data: InferEntrySchema<"blog">
},
"third-post.md": {
id: "third-post.md",
slug: "third-post",
body: string,
collection: "blog",
data: InferEntrySchema<"blog">
},
"using-mdx.mdx": {
id: "using-mdx.mdx",
slug: "using-mdx",
body: string,
collection: "blog",
data: InferEntrySchema<"blog">
},
},
};
type ContentConfig = typeof import("./config");
}

View file

@ -0,0 +1,24 @@
---
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>It's content time!</title>
<style>
html,
body {
font-family: system-ui;
margin: 0;
}
body {
padding: 2rem;
}
</style>
</head>
<body>
<main>
</main>
</body>
</html>

View file

@ -0,0 +1,33 @@
---
import { getCollection } from 'astro:content';
const { slug } = Astro.params;
const posts = await getCollection('blog');
const post = posts.find((post) => post.slug === slug);
if (!post) {
throw new Error(`Not found: ${slug}`);
}
const { Content } = await post.render();
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{post.data.title}</title>
<style>
html,
body {
font-family: system-ui;
margin: 0;
}
body {
padding: 2rem;
}
</style>
</head>
<body>
<h1>{post.data.title}</h1>
<Content />
</body>
</html>

View file

@ -0,0 +1,10 @@
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
// https://astro.build/config
export default defineConfig({
integrations: [mdx()],
experimental: {
contentCollections: true,
},
});

View file

@ -0,0 +1,9 @@
{
"name": "@test/content-static-paths-integration",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*",
"@astrojs/mdx": "workspace:*"
}
}

View file

@ -0,0 +1,22 @@
---
export interface Props extends astroHTML.JSX.AnchorHTMLAttributes {}
const { href, class: className, ...props } = Astro.props;
const { pathname } = Astro.url;
const isActive = href === pathname || href === pathname.replace(/\/$/, '');
---
<a href={href} class:list={[className, { active: isActive }]} {...props}>
<slot />
</a>
<style>
a {
display: inline-block;
text-decoration: none;
}
a.active {
font-weight: bolder;
text-decoration: underline;
}
</style>

Some files were not shown because too many files have changed in this diff Show more