diff --git a/.changeset/eleven-pens-glow.md b/.changeset/eleven-pens-glow.md
new file mode 100644
index 0000000000..d031fba49c
--- /dev/null
+++ b/.changeset/eleven-pens-glow.md
@@ -0,0 +1,41 @@
+---
+'astro': minor
+---
+
+Deprecates the option for route-generating files to export a dynamic value for `prerender`. Only static values are now supported (e.g. `export const prerender = true` or `= false`). This allows for better treeshaking and bundling configuration in the future.
+
+Adds a new [`"astro:route:setup"` hook](https://docs.astro.build/en/reference/integrations-reference/#astroroutesetup) to the Integrations API to allow you to dynamically set options for a route at build or request time through an integration, such as enabling [on-demand server rendering](https://docs.astro.build/en/guides/server-side-rendering/#opting-in-to-pre-rendering-in-server-mode).
+
+To migrate from a dynamic export to the new hook, update or remove any dynamic `prerender` exports from individual routing files:
+
+```diff
+// src/pages/blog/[slug].astro
+- export const prerender = import.meta.env.PRERENDER
+```
+
+Instead, create an integration with the `"astro:route:setup"` hook and update the route's `prerender` option:
+
+```js
+// astro.config.mjs
+import { defineConfig } from 'astro/config';
+import { loadEnv } from 'vite';
+
+export default defineConfig({
+ integrations: [setPrerender()],
+});
+
+function setPrerender() {
+ const { PRERENDER } = loadEnv(process.env.NODE_ENV, process.cwd(), '');
+
+ return {
+ name: 'set-prerender',
+ hooks: {
+ 'astro:route:setup': ({ route }) => {
+ if (route.component.endsWith('/blog/[slug].astro')) {
+ route.prerender = PRERENDER;
+ }
+ },
+ },
+ };
+}
+```
diff --git a/.changeset/fifty-stingrays-flow.md b/.changeset/fifty-stingrays-flow.md
new file mode 100644
index 0000000000..3f4b96b049
--- /dev/null
+++ b/.changeset/fifty-stingrays-flow.md
@@ -0,0 +1,6 @@
+---
+'astro': patch
+'@astrojs/db': patch
+---
+
+Refactors internally to use `node:util` `parseArgs` instead of `yargs-parser`
diff --git a/.changeset/fresh-fans-study.md b/.changeset/fresh-fans-study.md
new file mode 100644
index 0000000000..9a837b1fd7
--- /dev/null
+++ b/.changeset/fresh-fans-study.md
@@ -0,0 +1,18 @@
+---
+'@astrojs/db': minor
+---
+
+Changes how type generation works
+
+The generated `.d.ts` file is now at a new location:
+
+```diff
+- .astro/db-types.d.ts
++ .astro/integrations/astro_db/db.d.ts
+```
+
+The following line can now be removed from `src/env.d.ts`:
+
+```diff
+- ///
+```
diff --git a/.changeset/mean-horses-kiss.md b/.changeset/mean-horses-kiss.md
new file mode 100644
index 0000000000..7d211e6267
--- /dev/null
+++ b/.changeset/mean-horses-kiss.md
@@ -0,0 +1,35 @@
+---
+'astro': minor
+---
+
+Adds a new [`injectTypes()` utility](https://docs.astro.build/en/reference/integrations-reference/#injecttypes-options) to the Integration API and refactors how type generation works
+
+Use `injectTypes()` in the `astro:config:done` hook to inject types into your user's project by adding a new a `*.d.ts` file.
+
+The `filename` property will be used to generate a file at `/.astro/integrations//.d.ts` and must end with `".d.ts"`.
+
+The `content` property will create the body of the file, and must be valid TypeScript.
+
+Additionally, `injectTypes()` returns a URL to the normalized path so you can overwrite its content later on, or manipulate it in any way you want.
+
+```js
+// my-integration/index.js
+export default {
+ name: 'my-integration',
+ 'astro:config:done': ({ injectTypes }) => {
+ injectTypes({
+ filename: "types.d.ts",
+ content: "declare module 'virtual:my-integration' {}"
+ })
+ }
+};
+```
+
+Codegen has been refactored. Although `src/env.d.ts` will continue to work as is, we recommend you update it:
+
+```diff
+- ///
++ ///
+- ///
+- ///
+```
\ No newline at end of file
diff --git a/.changeset/odd-buttons-pay.md b/.changeset/odd-buttons-pay.md
new file mode 100644
index 0000000000..728068ef2a
--- /dev/null
+++ b/.changeset/odd-buttons-pay.md
@@ -0,0 +1,22 @@
+---
+"astro": minor
+---
+
+Adds a new property `meta` to Astro's [built-in `
` component](https://docs.astro.build/en/reference/api-reference/#code-).
+
+This allows you to provide a value for [Shiki's `meta` attribute](https://shiki.style/guide/transformers#meta) to pass options to transformers.
+
+The following example passes an option to highlight lines 1 and 3 to Shiki's `tranformerMetaHighlight`:
+
+```astro
+---
+// src/components/Card.astro
+import { Code } from "astro:components";
+import { transformerMetaHighlight } from '@shikijs/transformers';
+---
+
+```
diff --git a/.changeset/rude-queens-shop.md b/.changeset/rude-queens-shop.md
new file mode 100644
index 0000000000..6610b16a53
--- /dev/null
+++ b/.changeset/rude-queens-shop.md
@@ -0,0 +1,7 @@
+---
+'create-astro': patch
+'@astrojs/upgrade': patch
+---
+
+Refactors internally to use `node:util` `parseArgs` instead of `arg`
+
diff --git a/.changeset/serious-pumas-run.md b/.changeset/serious-pumas-run.md
new file mode 100644
index 0000000000..e6f7c9af1a
--- /dev/null
+++ b/.changeset/serious-pumas-run.md
@@ -0,0 +1,21 @@
+---
+'astro': minor
+---
+
+Adds support for Intellisense features (e.g. code completion, quick hints) for your content collection entries in compatible editors under the `experimental.contentIntellisense` flag.
+
+```js
+import { defineConfig } from 'astro';
+
+export default defineConfig({
+ experimental: {
+ contentIntellisense: true
+ }
+})
+```
+
+When enabled, this feature will generate and add JSON schemas to the `.astro` directory in your project. These files can be used by the Astro language server to provide Intellisense inside content files (`.md`, `.mdx`, `.mdoc`).
+
+Note that at this time, this also require enabling the `astro.content-intellisense` option in your editor, or passing the `contentIntellisense: true` initialization parameter to the Astro language server for editors using it directly.
+
+See the [experimental content Intellisense docs](https://docs.astro.build/en/reference/configuration-reference/#experimentalcontentintellisense) for more information updates as this feature develops.
diff --git a/.changeset/small-ties-sort.md b/.changeset/small-ties-sort.md
new file mode 100644
index 0000000000..e3f3d67eb4
--- /dev/null
+++ b/.changeset/small-ties-sort.md
@@ -0,0 +1,50 @@
+---
+'astro': major
+---
+
+Fixes attribute rendering for non-[boolean HTML attributes](https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML) with boolean values to match proper attribute handling in browsers.
+
+Previously, non-boolean attributes may not have included their values when rendered to HTML. In Astro v5.0, the values are now explicitly rendered as `="true"` or `="false"`
+
+In the following `.astro` examples, only `allowfullscreen` is a boolean attribute:
+
+```astro
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+Astro v5.0 now preserves the full data attribute with its value when rendering the HTML of non-boolean attributes:
+
+```diff
+
+
+
+
+-
++
+
+-
++
+-
++
+```
+
+If you rely on attribute values, for example to locate elements or to conditionally render, update your code to match the new non-boolean attribute values:
+
+```diff
+- el.getAttribute('inherit') === ''
++ el.getAttribute('inherit') === 'false'
+
+- el.hasAttribute('data-light')
++ el.dataset.light === 'true'
+```
diff --git a/.changeset/smooth-chicken-wash.md b/.changeset/smooth-chicken-wash.md
new file mode 100644
index 0000000000..3ced01f52f
--- /dev/null
+++ b/.changeset/smooth-chicken-wash.md
@@ -0,0 +1,107 @@
+---
+'astro': minor
+---
+
+Adds experimental support for the Content Layer API.
+
+The new Content Layer API builds upon content collections, taking them beyond local files in `src/content/` and allowing you to fetch content from anywhere, including remote APIs. These new collections work alongside your existing content collections, and you can migrate them to the new API at your own pace. There are significant improvements to performance with large collections of local files.
+
+### Getting started
+
+To try out the new Content Layer API, enable it in your Astro config:
+
+```js
+import { defineConfig } from 'astro';
+
+export default defineConfig({
+ experimental: {
+ contentLayer: true
+ }
+})
+```
+
+You can then create collections in your `src/content/config.ts` using the Content Layer API.
+
+### Loading your content
+
+The core of the new Content Layer API is the loader, a function that fetches content from a source and caches it in a local data store. Astro 4.14 ships with built-in `glob()` and `file()` loaders to handle your local Markdown, MDX, Markdoc, and JSON files:
+
+```ts {3,7}
+// src/content/config.ts
+import { defineCollection, z } from 'astro:content';
+import { glob } from 'astro/loaders';
+
+const blog = defineCollection({
+ // The ID is a slug generated from the path of the file relative to `base`
+ loader: glob({ pattern: "**/*.md", base: "./src/data/blog" }),
+ schema: z.object({
+ title: z.string(),
+ description: z.string(),
+ publishDate: z.coerce.date(),
+ })
+});
+
+export const collections = { blog };
+```
+
+You can then query using the existing content collections functions, and enjoy a simplified `render()` function to display your content:
+
+```astro
+---
+import { getEntry, render } from 'astro:content';
+
+const post = await getEntry('blog', Astro.params.slug);
+
+const { Content } = await render(entry);
+---
+
+
+```
+
+### Creating a loader
+
+You're not restricted to the built-in loaders – we hope you'll try building your own. You can fetch content from anywhere and return an array of entries:
+
+```ts
+// src/content/config.ts
+const countries = defineCollection({
+ loader: async () => {
+ const response = await fetch("https://restcountries.com/v3.1/all");
+ const data = await response.json();
+ // Must return an array of entries with an id property,
+ // or an object with IDs as keys and entries as values
+ return data.map((country) => ({
+ id: country.cca3,
+ ...country,
+ }));
+ },
+ // optionally add a schema to validate the data and make it type-safe for users
+ // schema: z.object...
+});
+
+export const collections = { countries };
+```
+
+For more advanced loading logic, you can define an object loader. This allows incremental updates and conditional loading, and gives full access to the data store. It also allows a loader to define its own schema, including generating it dynamically based on the source API. See the [the Content Layer API RFC](https://github.com/withastro/roadmap/blob/content-layer/proposals/0047-content-layer.md#loaders) for more details.
+
+### Sharing your loaders
+
+Loaders are better when they're shared. You can create a package that exports a loader and publish it to npm, and then anyone can use it on their site. We're excited to see what the community comes up with! To get started, [take a look at some examples](https://github.com/ascorbic/astro-loaders/). Here's how to load content using an RSS/Atom feed loader:
+
+```ts
+// src/content/config.ts
+import { defineCollection } from "astro:content";
+import { feedLoader } from "@ascorbic/feed-loader";
+
+const podcasts = defineCollection({
+ loader: feedLoader({
+ url: "https://feeds.99percentinvisible.org/99percentinvisible",
+ }),
+});
+
+export const collections = { podcasts };
+```
+
+### Learn more
+
+To find out more about using the Content Layer API, check out [the Content Layer RFC](https://github.com/withastro/roadmap/blob/content-layer/proposals/0047-content-layer.md) and [share your feedback](https://github.com/withastro/roadmap/pull/982).
diff --git a/benchmark/bench/memory.js b/benchmark/bench/memory.js
index 34a4972f78..3a437b3fe3 100644
--- a/benchmark/bench/memory.js
+++ b/benchmark/bench/memory.js
@@ -18,7 +18,7 @@ export async function run(projectDir, outputFile) {
const outputFilePath = fileURLToPath(outputFile);
console.log('Building and benchmarking...');
- await execaCommand(`node --expose-gc --max_old_space_size=256 ${astroBin} build`, {
+ await execaCommand(`node --expose-gc --max_old_space_size=10000 ${astroBin} build --silent`, {
cwd: root,
stdio: 'inherit',
env: {
diff --git a/benchmark/make-project/image.jpg b/benchmark/make-project/image.jpg
new file mode 100644
index 0000000000..80b8ea67b8
Binary files /dev/null and b/benchmark/make-project/image.jpg differ
diff --git a/benchmark/make-project/markdown-cc1.js b/benchmark/make-project/markdown-cc1.js
new file mode 100644
index 0000000000..6c83959601
--- /dev/null
+++ b/benchmark/make-project/markdown-cc1.js
@@ -0,0 +1,63 @@
+import fs from 'node:fs/promises';
+import { loremIpsumMd } from './_util.js';
+
+/**
+ * @param {URL} projectDir
+ */
+export async function run(projectDir) {
+ await fs.rm(projectDir, { recursive: true, force: true });
+ await fs.mkdir(new URL('./src/pages/blog', projectDir), { recursive: true });
+ await fs.mkdir(new URL('./src/content/blog', projectDir), { recursive: true });
+ await fs.copyFile(new URL('./image.jpg', import.meta.url), new URL('./src/image.jpg', projectDir));
+
+ const promises = [];
+
+
+ for (let i = 0; i < 10000; i++) {
+ const content = `\
+# Article ${i}
+
+${loremIpsumMd}
+
+
+
+
+`;
+ promises.push(
+ fs.writeFile(new URL(`./src/content/blog/article-${i}.md`, projectDir), content, 'utf-8')
+ );
+ }
+
+
+ await fs.writeFile(
+ new URL(`./src/pages/blog/[...slug].astro`, projectDir),
+ `\
+---
+import { getCollection } from 'astro:content';
+export async function getStaticPaths() {
+ const blogEntries = await getCollection('blog');
+ return blogEntries.map(entry => ({
+ params: { slug: entry.slug }, props: { entry },
+ }));
+}
+const { entry } = Astro.props;
+const { Content } = await entry.render();
+---
+{entry.data.title}
+
+`,
+ 'utf-8'
+ );
+
+ await Promise.all(promises);
+
+ await fs.writeFile(
+ new URL('./astro.config.js', projectDir),
+ `\
+import { defineConfig } from 'astro/config';
+
+export default defineConfig({
+});`,
+ 'utf-8'
+ );
+}
diff --git a/benchmark/make-project/markdown-cc2.js b/benchmark/make-project/markdown-cc2.js
new file mode 100644
index 0000000000..73c6afe7a8
--- /dev/null
+++ b/benchmark/make-project/markdown-cc2.js
@@ -0,0 +1,80 @@
+import fs from 'node:fs/promises';
+import { loremIpsumMd } from './_util.js';
+
+/**
+ * @param {URL} projectDir
+ */
+export async function run(projectDir) {
+ await fs.rm(projectDir, { recursive: true, force: true });
+ await fs.mkdir(new URL('./src/pages/blog', projectDir), { recursive: true });
+ await fs.mkdir(new URL('./data/blog', projectDir), { recursive: true });
+ await fs.mkdir(new URL('./src/content', projectDir), { recursive: true });
+ await fs.copyFile(new URL('./image.jpg', import.meta.url), new URL('./image.jpg', projectDir));
+
+ const promises = [];
+
+ for (let i = 0; i < 10000; i++) {
+ const content = `\
+# Article ${i}
+
+${loremIpsumMd}
+
+
+
+`;
+ promises.push(
+ fs.writeFile(new URL(`./data/blog/article-${i}.md`, projectDir), content, 'utf-8')
+ );
+ }
+
+ await fs.writeFile(
+ new URL(`./src/content/config.ts`, projectDir),
+ /*ts */ `
+ import { defineCollection, z } from 'astro:content';
+ import { glob } from 'astro/loaders';
+
+ const blog = defineCollection({
+ loader: glob({ pattern: '*', base: './data/blog' }),
+ });
+
+ export const collections = { blog }
+
+ `
+ );
+
+ await fs.writeFile(
+ new URL(`./src/pages/blog/[...slug].astro`, projectDir),
+ `\
+---
+import { getCollection, render } from 'astro:content';
+export async function getStaticPaths() {
+ const blogEntries = await getCollection('blog');
+ return blogEntries.map(entry => ({
+ params: { slug: entry.id }, props: { entry },
+ }));
+}
+const { entry } = Astro.props;
+const { Content } = await render(entry);
+
+---
+{entry.data.title}
+
+`,
+ 'utf-8'
+ );
+
+ await Promise.all(promises);
+
+ await fs.writeFile(
+ new URL('./astro.config.js', projectDir),
+ `\
+import { defineConfig } from 'astro/config';
+
+export default defineConfig({
+ experimental: {
+ contentLayer: true
+ }
+});`,
+ 'utf-8'
+ );
+}
diff --git a/benchmark/make-project/mdx-cc1.js b/benchmark/make-project/mdx-cc1.js
new file mode 100644
index 0000000000..98e1495d13
--- /dev/null
+++ b/benchmark/make-project/mdx-cc1.js
@@ -0,0 +1,66 @@
+import fs from 'node:fs/promises';
+import { loremIpsumMd } from './_util.js';
+
+/**
+ * @param {URL} projectDir
+ */
+export async function run(projectDir) {
+ await fs.rm(projectDir, { recursive: true, force: true });
+ await fs.mkdir(new URL('./src/pages/blog', projectDir), { recursive: true });
+ await fs.mkdir(new URL('./src/content/blog', projectDir), { recursive: true });
+ await fs.copyFile(new URL('./image.jpg', import.meta.url), new URL('./src/image.jpg', projectDir));
+
+ const promises = [];
+
+
+ for (let i = 0; i < 10000; i++) {
+ const content = `\
+# Article ${i}
+
+${loremIpsumMd}
+
+
+
+
+`;
+ promises.push(
+ fs.writeFile(new URL(`./src/content/blog/article-${i}.mdx`, projectDir), content, 'utf-8')
+ );
+ }
+
+
+ await fs.writeFile(
+ new URL(`./src/pages/blog/[...slug].astro`, projectDir),
+ `\
+---
+import { getCollection } from 'astro:content';
+export async function getStaticPaths() {
+ const blogEntries = await getCollection('blog');
+ return blogEntries.map(entry => ({
+ params: { slug: entry.slug }, props: { entry },
+ }));
+}
+const { entry } = Astro.props;
+const { Content } = await entry.render();
+---
+{entry.data.title}
+
+`,
+ 'utf-8'
+ );
+
+ await Promise.all(promises);
+
+ await fs.writeFile(
+ new URL('./astro.config.js', projectDir),
+ `\
+import { defineConfig } from 'astro/config';
+
+import mdx from '@astrojs/mdx';
+
+export default defineConfig({
+ integrations: [mdx()],
+});`,
+ 'utf-8'
+ );
+}
diff --git a/benchmark/make-project/mdx-cc2.js b/benchmark/make-project/mdx-cc2.js
new file mode 100644
index 0000000000..c08c2fb9fe
--- /dev/null
+++ b/benchmark/make-project/mdx-cc2.js
@@ -0,0 +1,83 @@
+import fs from 'node:fs/promises';
+import { loremIpsumMd } from './_util.js';
+
+/**
+ * @param {URL} projectDir
+ */
+export async function run(projectDir) {
+ await fs.rm(projectDir, { recursive: true, force: true });
+ await fs.mkdir(new URL('./src/pages/blog', projectDir), { recursive: true });
+ await fs.mkdir(new URL('./data/blog', projectDir), { recursive: true });
+ await fs.mkdir(new URL('./src/content', projectDir), { recursive: true });
+ await fs.copyFile(new URL('./image.jpg', import.meta.url), new URL('./image.jpg', projectDir));
+
+ const promises = [];
+
+ for (let i = 0; i < 10000; i++) {
+ const content = `\
+# Article ${i}
+
+${loremIpsumMd}
+
+
+
+`;
+ promises.push(
+ fs.writeFile(new URL(`./data/blog/article-${i}.mdx`, projectDir), content, 'utf-8')
+ );
+ }
+
+ await fs.writeFile(
+ new URL(`./src/content/config.ts`, projectDir),
+ /*ts */ `
+ import { defineCollection, z } from 'astro:content';
+ import { glob } from 'astro/loaders';
+
+ const blog = defineCollection({
+ loader: glob({ pattern: '*', base: './data/blog' }),
+ });
+
+ export const collections = { blog }
+
+ `
+ );
+
+ await fs.writeFile(
+ new URL(`./src/pages/blog/[...slug].astro`, projectDir),
+ `\
+---
+import { getCollection, render } from 'astro:content';
+export async function getStaticPaths() {
+ const blogEntries = await getCollection('blog');
+ return blogEntries.map(entry => ({
+ params: { slug: entry.id }, props: { entry },
+ }));
+}
+const { entry } = Astro.props;
+const { Content } = await render(entry);
+
+---
+{entry.data.title}
+
+`,
+ 'utf-8'
+ );
+
+ await Promise.all(promises);
+
+ await fs.writeFile(
+ new URL('./astro.config.js', projectDir),
+ `\
+import { defineConfig } from 'astro/config';
+
+import mdx from '@astrojs/mdx';
+
+export default defineConfig({
+ integrations: [mdx()],
+ experimental: {
+ contentLayer: true
+ }
+});`,
+ 'utf-8'
+ );
+}
diff --git a/benchmark/package.json b/benchmark/package.json
index 5f7206242b..d56a6d1b06 100644
--- a/benchmark/package.json
+++ b/benchmark/package.json
@@ -16,6 +16,7 @@
"markdown-table": "^3.0.3",
"mri": "^1.2.0",
"port-authority": "^2.0.1",
- "pretty-bytes": "^6.1.1"
+ "pretty-bytes": "^6.1.1",
+ "sharp": "^0.33.3"
}
}
diff --git a/biome.json b/biome.json
index 8effd9cfce..2928e8f4fe 100644
--- a/biome.json
+++ b/biome.json
@@ -1,5 +1,5 @@
{
- "$schema": "https://biomejs.dev/schemas/1.8.1/schema.json",
+ "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json",
"files": {
"ignore": [
"vendor",
diff --git a/examples/basics/package.json b/examples/basics/package.json
index b523cad78b..02e40cb4ce 100644
--- a/examples/basics/package.json
+++ b/examples/basics/package.json
@@ -11,6 +11,6 @@
"astro": "astro"
},
"dependencies": {
- "astro": "^4.13.3"
+ "astro": "^4.13.4"
}
}
diff --git a/examples/basics/src/env.d.ts b/examples/basics/src/env.d.ts
index f964fe0cff..9bc5cb41c2 100644
--- a/examples/basics/src/env.d.ts
+++ b/examples/basics/src/env.d.ts
@@ -1 +1 @@
-///
+///
\ No newline at end of file
diff --git a/examples/blog/package.json b/examples/blog/package.json
index 21a14e62cf..667fceb416 100644
--- a/examples/blog/package.json
+++ b/examples/blog/package.json
@@ -14,6 +14,6 @@
"@astrojs/mdx": "^3.1.3",
"@astrojs/rss": "^4.0.7",
"@astrojs/sitemap": "^3.1.6",
- "astro": "^4.13.3"
+ "astro": "^4.13.4"
}
}
diff --git a/examples/blog/src/env.d.ts b/examples/blog/src/env.d.ts
index acef35f175..e16c13c695 100644
--- a/examples/blog/src/env.d.ts
+++ b/examples/blog/src/env.d.ts
@@ -1,2 +1 @@
///
-///
diff --git a/examples/component/package.json b/examples/component/package.json
index 711f47ab6c..6e4ce727e4 100644
--- a/examples/component/package.json
+++ b/examples/component/package.json
@@ -15,7 +15,7 @@
],
"scripts": {},
"devDependencies": {
- "astro": "^4.13.3"
+ "astro": "^4.13.4"
},
"peerDependencies": {
"astro": "^4.0.0"
diff --git a/examples/container-with-vitest/package.json b/examples/container-with-vitest/package.json
index 59f4d937ff..7bddf6df4c 100644
--- a/examples/container-with-vitest/package.json
+++ b/examples/container-with-vitest/package.json
@@ -12,14 +12,14 @@
"test": "vitest run"
},
"dependencies": {
- "astro": "^4.13.3",
+ "astro": "^4.13.4",
"@astrojs/react": "^3.6.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"vitest": "^2.0.5"
},
"devDependencies": {
- "@types/react-dom": "^18.3.0",
- "@types/react": "^18.3.3"
+ "@types/react": "^18.3.3",
+ "@types/react-dom": "^18.3.0"
}
}
diff --git a/examples/framework-alpine/package.json b/examples/framework-alpine/package.json
index 6cd4bb07f8..0de84cbbec 100644
--- a/examples/framework-alpine/package.json
+++ b/examples/framework-alpine/package.json
@@ -14,6 +14,6 @@
"@astrojs/alpinejs": "^0.4.0",
"@types/alpinejs": "^3.13.10",
"alpinejs": "^3.14.1",
- "astro": "^4.13.3"
+ "astro": "^4.13.4"
}
}
diff --git a/examples/framework-alpine/src/env.d.ts b/examples/framework-alpine/src/env.d.ts
index f964fe0cff..e16c13c695 100644
--- a/examples/framework-alpine/src/env.d.ts
+++ b/examples/framework-alpine/src/env.d.ts
@@ -1 +1 @@
-///
+///
diff --git a/examples/framework-lit/package.json b/examples/framework-lit/package.json
index cef951081d..638a28be09 100644
--- a/examples/framework-lit/package.json
+++ b/examples/framework-lit/package.json
@@ -13,7 +13,7 @@
"dependencies": {
"@astrojs/lit": "^4.3.0",
"@webcomponents/template-shadowroot": "^0.2.1",
- "astro": "^4.13.3",
- "lit": "^3.1.4"
+ "astro": "^4.13.4",
+ "lit": "^3.2.0"
}
}
diff --git a/examples/framework-lit/src/env.d.ts b/examples/framework-lit/src/env.d.ts
index f964fe0cff..e16c13c695 100644
--- a/examples/framework-lit/src/env.d.ts
+++ b/examples/framework-lit/src/env.d.ts
@@ -1 +1 @@
-///
+///
diff --git a/examples/framework-multiple/package.json b/examples/framework-multiple/package.json
index 49e0ce9712..800f0b0b68 100644
--- a/examples/framework-multiple/package.json
+++ b/examples/framework-multiple/package.json
@@ -18,12 +18,12 @@
"@astrojs/vue": "^4.5.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
- "astro": "^4.13.3",
+ "astro": "^4.13.4",
"preact": "^10.23.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "solid-js": "^1.8.19",
+ "solid-js": "^1.8.20",
"svelte": "^4.2.18",
- "vue": "^3.4.35"
+ "vue": "^3.4.37"
}
}
diff --git a/examples/framework-multiple/src/env.d.ts b/examples/framework-multiple/src/env.d.ts
index f964fe0cff..e16c13c695 100644
--- a/examples/framework-multiple/src/env.d.ts
+++ b/examples/framework-multiple/src/env.d.ts
@@ -1 +1 @@
-///
+///
diff --git a/examples/framework-preact/package.json b/examples/framework-preact/package.json
index 577bf5a514..42cf975fc5 100644
--- a/examples/framework-preact/package.json
+++ b/examples/framework-preact/package.json
@@ -13,7 +13,7 @@
"dependencies": {
"@astrojs/preact": "^3.5.1",
"@preact/signals": "^1.3.0",
- "astro": "^4.13.3",
+ "astro": "^4.13.4",
"preact": "^10.23.1"
}
}
diff --git a/examples/framework-preact/src/env.d.ts b/examples/framework-preact/src/env.d.ts
index f964fe0cff..e16c13c695 100644
--- a/examples/framework-preact/src/env.d.ts
+++ b/examples/framework-preact/src/env.d.ts
@@ -1 +1 @@
-///
+///
diff --git a/examples/framework-react/package.json b/examples/framework-react/package.json
index 5ee3222ad9..8228bf5569 100644
--- a/examples/framework-react/package.json
+++ b/examples/framework-react/package.json
@@ -14,7 +14,7 @@
"@astrojs/react": "^3.6.2",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
- "astro": "^4.13.3",
+ "astro": "^4.13.4",
"react": "^18.3.1",
"react-dom": "^18.3.1"
}
diff --git a/examples/framework-react/src/env.d.ts b/examples/framework-react/src/env.d.ts
index f964fe0cff..e16c13c695 100644
--- a/examples/framework-react/src/env.d.ts
+++ b/examples/framework-react/src/env.d.ts
@@ -1 +1 @@
-///
+///
diff --git a/examples/framework-solid/package.json b/examples/framework-solid/package.json
index ae8ff13f12..5152ce823a 100644
--- a/examples/framework-solid/package.json
+++ b/examples/framework-solid/package.json
@@ -12,7 +12,7 @@
},
"dependencies": {
"@astrojs/solid-js": "^4.4.1",
- "astro": "^4.13.3",
- "solid-js": "^1.8.19"
+ "astro": "^4.13.4",
+ "solid-js": "^1.8.20"
}
}
diff --git a/examples/framework-solid/src/env.d.ts b/examples/framework-solid/src/env.d.ts
index f964fe0cff..e16c13c695 100644
--- a/examples/framework-solid/src/env.d.ts
+++ b/examples/framework-solid/src/env.d.ts
@@ -1 +1 @@
-///
+///
diff --git a/examples/framework-svelte/package.json b/examples/framework-svelte/package.json
index c8095dd917..cc89304bb7 100644
--- a/examples/framework-svelte/package.json
+++ b/examples/framework-svelte/package.json
@@ -12,7 +12,7 @@
},
"dependencies": {
"@astrojs/svelte": "^5.7.0",
- "astro": "^4.13.3",
+ "astro": "^4.13.4",
"svelte": "^4.2.18"
}
}
diff --git a/examples/framework-svelte/src/env.d.ts b/examples/framework-svelte/src/env.d.ts
index f964fe0cff..e16c13c695 100644
--- a/examples/framework-svelte/src/env.d.ts
+++ b/examples/framework-svelte/src/env.d.ts
@@ -1 +1 @@
-///
+///
diff --git a/examples/framework-vue/package.json b/examples/framework-vue/package.json
index df136b65e7..3cd8d2325a 100644
--- a/examples/framework-vue/package.json
+++ b/examples/framework-vue/package.json
@@ -12,7 +12,7 @@
},
"dependencies": {
"@astrojs/vue": "^4.5.0",
- "astro": "^4.13.3",
- "vue": "^3.4.35"
+ "astro": "^4.13.4",
+ "vue": "^3.4.37"
}
}
diff --git a/examples/framework-vue/src/env.d.ts b/examples/framework-vue/src/env.d.ts
index f964fe0cff..e16c13c695 100644
--- a/examples/framework-vue/src/env.d.ts
+++ b/examples/framework-vue/src/env.d.ts
@@ -1 +1 @@
-///
+///
diff --git a/examples/hackernews/package.json b/examples/hackernews/package.json
index e47cf63d7c..836cb84864 100644
--- a/examples/hackernews/package.json
+++ b/examples/hackernews/package.json
@@ -11,7 +11,7 @@
"astro": "astro"
},
"dependencies": {
- "@astrojs/node": "^8.3.2",
- "astro": "^4.13.3"
+ "@astrojs/node": "^8.3.3",
+ "astro": "^4.13.4"
}
}
diff --git a/examples/hackernews/src/env.d.ts b/examples/hackernews/src/env.d.ts
index f964fe0cff..e16c13c695 100644
--- a/examples/hackernews/src/env.d.ts
+++ b/examples/hackernews/src/env.d.ts
@@ -1 +1 @@
-///
+///
diff --git a/examples/integration/package.json b/examples/integration/package.json
index cd735e915f..0445107121 100644
--- a/examples/integration/package.json
+++ b/examples/integration/package.json
@@ -15,7 +15,7 @@
],
"scripts": {},
"devDependencies": {
- "astro": "^4.13.3"
+ "astro": "^4.13.4"
},
"peerDependencies": {
"astro": "^4.0.0"
diff --git a/examples/middleware/package.json b/examples/middleware/package.json
index 3a20bc13a4..b86e1b3115 100644
--- a/examples/middleware/package.json
+++ b/examples/middleware/package.json
@@ -12,8 +12,8 @@
"server": "node dist/server/entry.mjs"
},
"dependencies": {
- "@astrojs/node": "^8.3.2",
- "astro": "^4.13.3",
+ "@astrojs/node": "^8.3.3",
+ "astro": "^4.13.4",
"html-minifier": "^4.0.0"
},
"devDependencies": {
diff --git a/examples/middleware/src/env.d.ts b/examples/middleware/src/env.d.ts
index 44f67965a3..74b9019e57 100644
--- a/examples/middleware/src/env.d.ts
+++ b/examples/middleware/src/env.d.ts
@@ -1,4 +1,4 @@
-///
+///
declare namespace App {
interface Locals {
user: {
diff --git a/examples/minimal/package.json b/examples/minimal/package.json
index cb9e5b9f68..a2892c7e46 100644
--- a/examples/minimal/package.json
+++ b/examples/minimal/package.json
@@ -11,6 +11,6 @@
"astro": "astro"
},
"dependencies": {
- "astro": "^4.13.3"
+ "astro": "^4.13.4"
}
}
diff --git a/examples/minimal/src/env.d.ts b/examples/minimal/src/env.d.ts
index f964fe0cff..e16c13c695 100644
--- a/examples/minimal/src/env.d.ts
+++ b/examples/minimal/src/env.d.ts
@@ -1 +1 @@
-///
+///
diff --git a/examples/non-html-pages/package.json b/examples/non-html-pages/package.json
index c84aae0b1f..a6dc53a808 100644
--- a/examples/non-html-pages/package.json
+++ b/examples/non-html-pages/package.json
@@ -11,6 +11,6 @@
"astro": "astro"
},
"dependencies": {
- "astro": "^4.13.3"
+ "astro": "^4.13.4"
}
}
diff --git a/examples/non-html-pages/src/env.d.ts b/examples/non-html-pages/src/env.d.ts
index f964fe0cff..e16c13c695 100644
--- a/examples/non-html-pages/src/env.d.ts
+++ b/examples/non-html-pages/src/env.d.ts
@@ -1 +1 @@
-///
+///
diff --git a/examples/portfolio/package.json b/examples/portfolio/package.json
index faec95f814..1e2b79ea03 100644
--- a/examples/portfolio/package.json
+++ b/examples/portfolio/package.json
@@ -11,6 +11,6 @@
"astro": "astro"
},
"dependencies": {
- "astro": "^4.13.3"
+ "astro": "^4.13.4"
}
}
diff --git a/examples/portfolio/src/env.d.ts b/examples/portfolio/src/env.d.ts
index acef35f175..e16c13c695 100644
--- a/examples/portfolio/src/env.d.ts
+++ b/examples/portfolio/src/env.d.ts
@@ -1,2 +1 @@
///
-///
diff --git a/examples/server-islands/package.json b/examples/server-islands/package.json
index 899f4d4af0..3a1ae97fbe 100644
--- a/examples/server-islands/package.json
+++ b/examples/server-islands/package.json
@@ -10,17 +10,17 @@
"astro": "astro"
},
"devDependencies": {
- "@astrojs/node": "^8.3.2",
+ "@astrojs/node": "^8.3.3",
"@astrojs/react": "^3.6.2",
"@astrojs/tailwind": "^5.1.0",
"@fortawesome/fontawesome-free": "^6.6.0",
"@tailwindcss/forms": "^0.5.7",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
- "astro": "^4.13.3",
- "postcss": "^8.4.40",
+ "astro": "^4.13.4",
+ "postcss": "^8.4.41",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "tailwindcss": "^3.4.7"
+ "tailwindcss": "^3.4.9"
}
}
diff --git a/examples/ssr/package.json b/examples/ssr/package.json
index 530d4c9293..10703eb9df 100644
--- a/examples/ssr/package.json
+++ b/examples/ssr/package.json
@@ -12,9 +12,9 @@
"server": "node dist/server/entry.mjs"
},
"dependencies": {
- "@astrojs/node": "^8.3.2",
+ "@astrojs/node": "^8.3.3",
"@astrojs/svelte": "^5.7.0",
- "astro": "^4.13.3",
+ "astro": "^4.13.4",
"svelte": "^4.2.18"
}
}
diff --git a/examples/ssr/src/env.d.ts b/examples/ssr/src/env.d.ts
index f964fe0cff..e16c13c695 100644
--- a/examples/ssr/src/env.d.ts
+++ b/examples/ssr/src/env.d.ts
@@ -1 +1 @@
-///
+///
diff --git a/examples/starlog/package.json b/examples/starlog/package.json
index cfcc76527b..33e546e77c 100644
--- a/examples/starlog/package.json
+++ b/examples/starlog/package.json
@@ -10,7 +10,7 @@
"astro": "astro"
},
"dependencies": {
- "astro": "^4.13.3",
+ "astro": "^4.13.4",
"sass": "^1.77.8",
"sharp": "^0.33.3"
}
diff --git a/examples/toolbar-app/package.json b/examples/toolbar-app/package.json
index 1c0d67f7f9..6f147be74c 100644
--- a/examples/toolbar-app/package.json
+++ b/examples/toolbar-app/package.json
@@ -15,6 +15,6 @@
"./app": "./dist/app.js"
},
"devDependencies": {
- "astro": "^4.13.3"
+ "astro": "^4.13.4"
}
}
diff --git a/examples/view-transitions/package.json b/examples/view-transitions/package.json
index 42b1401b05..6328e7665e 100644
--- a/examples/view-transitions/package.json
+++ b/examples/view-transitions/package.json
@@ -11,7 +11,7 @@
},
"devDependencies": {
"@astrojs/tailwind": "^5.1.0",
- "@astrojs/node": "^8.3.2",
- "astro": "^4.13.3"
+ "@astrojs/node": "^8.3.3",
+ "astro": "^4.13.4"
}
}
diff --git a/examples/with-markdoc/package.json b/examples/with-markdoc/package.json
index dbc01d6ba3..dcf8596bd6 100644
--- a/examples/with-markdoc/package.json
+++ b/examples/with-markdoc/package.json
@@ -12,6 +12,6 @@
},
"dependencies": {
"@astrojs/markdoc": "^0.11.3",
- "astro": "^4.13.3"
+ "astro": "^4.13.4"
}
}
diff --git a/examples/with-markdoc/src/env.d.ts b/examples/with-markdoc/src/env.d.ts
index acef35f175..e16c13c695 100644
--- a/examples/with-markdoc/src/env.d.ts
+++ b/examples/with-markdoc/src/env.d.ts
@@ -1,2 +1 @@
///
-///
diff --git a/examples/with-markdown-plugins/package.json b/examples/with-markdown-plugins/package.json
index 5dfb45ea0f..407130b83b 100644
--- a/examples/with-markdown-plugins/package.json
+++ b/examples/with-markdown-plugins/package.json
@@ -12,7 +12,7 @@
},
"dependencies": {
"@astrojs/markdown-remark": "^5.2.0",
- "astro": "^4.13.3",
+ "astro": "^4.13.4",
"hast-util-select": "^6.0.2",
"rehype-autolink-headings": "^7.1.0",
"rehype-slug": "^6.0.0",
diff --git a/examples/with-markdown-plugins/src/env.d.ts b/examples/with-markdown-plugins/src/env.d.ts
index f964fe0cff..e16c13c695 100644
--- a/examples/with-markdown-plugins/src/env.d.ts
+++ b/examples/with-markdown-plugins/src/env.d.ts
@@ -1 +1 @@
-///
+///
diff --git a/examples/with-markdown-shiki/package.json b/examples/with-markdown-shiki/package.json
index 511e370f54..8a32a5f368 100644
--- a/examples/with-markdown-shiki/package.json
+++ b/examples/with-markdown-shiki/package.json
@@ -11,6 +11,6 @@
"astro": "astro"
},
"dependencies": {
- "astro": "^4.13.3"
+ "astro": "^4.13.4"
}
}
diff --git a/examples/with-markdown-shiki/src/env.d.ts b/examples/with-markdown-shiki/src/env.d.ts
index f964fe0cff..e16c13c695 100644
--- a/examples/with-markdown-shiki/src/env.d.ts
+++ b/examples/with-markdown-shiki/src/env.d.ts
@@ -1 +1 @@
-///
+///
diff --git a/examples/with-mdx/package.json b/examples/with-mdx/package.json
index 20bb7349c9..8782c588ce 100644
--- a/examples/with-mdx/package.json
+++ b/examples/with-mdx/package.json
@@ -13,7 +13,7 @@
"dependencies": {
"@astrojs/mdx": "^3.1.3",
"@astrojs/preact": "^3.5.1",
- "astro": "^4.13.3",
+ "astro": "^4.13.4",
"preact": "^10.23.1"
}
}
diff --git a/examples/with-mdx/src/env.d.ts b/examples/with-mdx/src/env.d.ts
index f964fe0cff..e16c13c695 100644
--- a/examples/with-mdx/src/env.d.ts
+++ b/examples/with-mdx/src/env.d.ts
@@ -1 +1 @@
-///
+///
diff --git a/examples/with-nanostores/package.json b/examples/with-nanostores/package.json
index 8933b0bac4..2c73ab7344 100644
--- a/examples/with-nanostores/package.json
+++ b/examples/with-nanostores/package.json
@@ -13,8 +13,8 @@
"dependencies": {
"@astrojs/preact": "^3.5.1",
"@nanostores/preact": "^0.5.2",
- "astro": "^4.13.3",
- "nanostores": "^0.11.0",
+ "astro": "^4.13.4",
+ "nanostores": "^0.11.2",
"preact": "^10.23.1"
}
}
diff --git a/examples/with-nanostores/src/env.d.ts b/examples/with-nanostores/src/env.d.ts
index f964fe0cff..e16c13c695 100644
--- a/examples/with-nanostores/src/env.d.ts
+++ b/examples/with-nanostores/src/env.d.ts
@@ -1 +1 @@
-///
+///
diff --git a/examples/with-tailwindcss/package.json b/examples/with-tailwindcss/package.json
index ae2329dd69..16cb073c72 100644
--- a/examples/with-tailwindcss/package.json
+++ b/examples/with-tailwindcss/package.json
@@ -14,10 +14,10 @@
"@astrojs/mdx": "^3.1.3",
"@astrojs/tailwind": "^5.1.0",
"@types/canvas-confetti": "^1.6.4",
- "astro": "^4.13.3",
+ "astro": "^4.13.4",
"autoprefixer": "^10.4.20",
"canvas-confetti": "^1.9.3",
- "postcss": "^8.4.40",
- "tailwindcss": "^3.4.7"
+ "postcss": "^8.4.41",
+ "tailwindcss": "^3.4.9"
}
}
diff --git a/examples/with-tailwindcss/src/env.d.ts b/examples/with-tailwindcss/src/env.d.ts
index f964fe0cff..e16c13c695 100644
--- a/examples/with-tailwindcss/src/env.d.ts
+++ b/examples/with-tailwindcss/src/env.d.ts
@@ -1 +1 @@
-///
+///
diff --git a/examples/with-vitest/package.json b/examples/with-vitest/package.json
index 738dac867d..2bc1e2d033 100644
--- a/examples/with-vitest/package.json
+++ b/examples/with-vitest/package.json
@@ -12,7 +12,7 @@
"test": "vitest"
},
"dependencies": {
- "astro": "^4.13.3",
+ "astro": "^4.13.4",
"vitest": "^2.0.5"
}
}
diff --git a/package.json b/package.json
index 8417847d81..5f2eb056c9 100644
--- a/package.json
+++ b/package.json
@@ -52,13 +52,13 @@
"astro-benchmark": "workspace:*"
},
"devDependencies": {
- "@astrojs/check": "^0.9.1",
- "@biomejs/biome": "1.8.1",
+ "@astrojs/check": "^0.9.2",
+ "@biomejs/biome": "1.8.3",
"@changesets/changelog-github": "^0.5.0",
"@changesets/cli": "^2.27.7",
"@types/node": "^18.17.8",
"esbuild": "^0.21.5",
- "eslint": "^9.8.0",
+ "eslint": "^9.9.0",
"eslint-plugin-no-only-tests": "^3.1.0",
"eslint-plugin-regexp": "^2.6.0",
"globby": "^14.0.2",
diff --git a/packages/astro/CHANGELOG.md b/packages/astro/CHANGELOG.md
index c183b7afbb..9db8916b24 100644
--- a/packages/astro/CHANGELOG.md
+++ b/packages/astro/CHANGELOG.md
@@ -1,5 +1,21 @@
# astro
+## 4.13.4
+
+### Patch Changes
+
+- [#11678](https://github.com/withastro/astro/pull/11678) [`34da907`](https://github.com/withastro/astro/commit/34da907f3b4fb411024e6d28fdb291fa78116950) Thanks [@ematipico](https://github.com/ematipico)! - Fixes a case where omitting a semicolon and line ending with carriage return - CRLF - in the `prerender` option could throw an error.
+
+- [#11535](https://github.com/withastro/astro/pull/11535) [`932bd2e`](https://github.com/withastro/astro/commit/932bd2eb07f1d7cb2c91e7e7d31fe84c919e302b) Thanks [@matthewp](https://github.com/matthewp)! - Encrypt server island props
+
+ Server island props are now encrypted with a key generated at build-time. This is intended to prevent accidentally leaking secrets caused by exposing secrets through prop-passing. This is not intended to allow a server island to be trusted to skip authentication, or to protect against any other vulnerabilities other than secret leakage.
+
+ See the RFC for an explanation: https://github.com/withastro/roadmap/blob/server-islands/proposals/server-islands.md#props-serialization
+
+- [#11655](https://github.com/withastro/astro/pull/11655) [`dc0a297`](https://github.com/withastro/astro/commit/dc0a297e2a4bea3db8310cc98c51b2f94ede5fde) Thanks [@billy-le](https://github.com/billy-le)! - Fixes Astro Actions `input` validation when using `default` values with a form input.
+
+- [#11689](https://github.com/withastro/astro/pull/11689) [`c7bda4c`](https://github.com/withastro/astro/commit/c7bda4cd672864babc3cebd19a2dd2e1af85c087) Thanks [@ematipico](https://github.com/ematipico)! - Fixes an issue in the Astro actions, where the size of the generated cookie was exceeding the size permitted by the `Set-Cookie` header.
+
## 4.13.3
### Patch Changes
@@ -7562,7 +7578,7 @@
## 2.0.0
> **Note**
-> This is a detailed changelog of all changes in Astro v2.
+> This is a detailed changelog of all changes in Astro v2.
> See our [upgrade guide](https://docs.astro.build/en/guides/upgrade-to/v2/) for an overview of steps needed to upgrade an existing project.
### Major Changes
diff --git a/packages/astro/components/Code.astro b/packages/astro/components/Code.astro
index 0cc639d7d5..8818b2ae0d 100644
--- a/packages/astro/components/Code.astro
+++ b/packages/astro/components/Code.astro
@@ -23,6 +23,13 @@ interface Props extends Omit, 'lang'> {
* @default "plaintext"
*/
lang?: BuiltinLanguage | SpecialLanguage | LanguageRegistration;
+ /**
+ * A metastring to pass to the highlighter.
+ * Allows passing information to transformers: https://shiki.style/guide/transformers#meta
+ *
+ * @default undefined
+ */
+ meta?: string;
/**
* The styling theme.
* Supports all themes listed here: https://shiki.style/themes
@@ -72,6 +79,7 @@ interface Props extends Omit, 'lang'> {
const {
code,
lang = 'plaintext',
+ meta,
theme = 'github-dark',
themes = {},
defaultColor = 'light',
@@ -110,6 +118,7 @@ const highlighter = await getCachedHighlighter({
const html = await highlighter.highlight(code, typeof lang === 'string' ? lang : lang.name, {
inline,
+ meta,
attributes: rest as any,
});
---
diff --git a/packages/astro/e2e/fixtures/actions-blog/package.json b/packages/astro/e2e/fixtures/actions-blog/package.json
index 35e036ee01..311b7a3788 100644
--- a/packages/astro/e2e/fixtures/actions-blog/package.json
+++ b/packages/astro/e2e/fixtures/actions-blog/package.json
@@ -10,7 +10,7 @@
"astro": "astro"
},
"dependencies": {
- "@astrojs/check": "^0.9.1",
+ "@astrojs/check": "^0.9.2",
"@astrojs/db": "workspace:*",
"@astrojs/node": "workspace:*",
"@astrojs/react": "workspace:*",
diff --git a/packages/astro/e2e/fixtures/actions-react-19/package.json b/packages/astro/e2e/fixtures/actions-react-19/package.json
index d67b081e23..a0f446eb95 100644
--- a/packages/astro/e2e/fixtures/actions-react-19/package.json
+++ b/packages/astro/e2e/fixtures/actions-react-19/package.json
@@ -10,7 +10,7 @@
"astro": "astro"
},
"dependencies": {
- "@astrojs/check": "^0.9.1",
+ "@astrojs/check": "^0.9.2",
"@astrojs/db": "workspace:*",
"@astrojs/node": "workspace:*",
"@astrojs/react": "workspace:*",
diff --git a/packages/astro/e2e/fixtures/astro-envs/package.json b/packages/astro/e2e/fixtures/astro-envs/package.json
index a2e272e8b2..bc11078b8c 100644
--- a/packages/astro/e2e/fixtures/astro-envs/package.json
+++ b/packages/astro/e2e/fixtures/astro-envs/package.json
@@ -5,6 +5,6 @@
"dependencies": {
"@astrojs/vue": "workspace:*",
"astro": "workspace:*",
- "vue": "^3.4.35"
+ "vue": "^3.4.37"
}
}
diff --git a/packages/astro/e2e/fixtures/client-only/package.json b/packages/astro/e2e/fixtures/client-only/package.json
index 6af924c3d4..2a1ce6d7f1 100644
--- a/packages/astro/e2e/fixtures/client-only/package.json
+++ b/packages/astro/e2e/fixtures/client-only/package.json
@@ -14,8 +14,8 @@
"preact": "^10.23.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "solid-js": "^1.8.19",
+ "solid-js": "^1.8.20",
"svelte": "^4.2.18",
- "vue": "^3.4.35"
+ "vue": "^3.4.37"
}
}
diff --git a/packages/astro/e2e/fixtures/errors/package.json b/packages/astro/e2e/fixtures/errors/package.json
index e6216ea15b..2bd6a5fd3b 100644
--- a/packages/astro/e2e/fixtures/errors/package.json
+++ b/packages/astro/e2e/fixtures/errors/package.json
@@ -13,8 +13,8 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"sass": "^1.77.8",
- "solid-js": "^1.8.19",
+ "solid-js": "^1.8.20",
"svelte": "^4.2.18",
- "vue": "^3.4.35"
+ "vue": "^3.4.37"
}
}
diff --git a/packages/astro/e2e/fixtures/lit-component/package.json b/packages/astro/e2e/fixtures/lit-component/package.json
index d569109375..0db0d41118 100644
--- a/packages/astro/e2e/fixtures/lit-component/package.json
+++ b/packages/astro/e2e/fixtures/lit-component/package.json
@@ -6,6 +6,6 @@
"@astrojs/lit": "workspace:*",
"@webcomponents/template-shadowroot": "^0.2.1",
"astro": "workspace:*",
- "lit": "^3.1.4"
+ "lit": "^3.2.0"
}
}
diff --git a/packages/astro/e2e/fixtures/multiple-frameworks/package.json b/packages/astro/e2e/fixtures/multiple-frameworks/package.json
index 2114a28b1e..b05763561e 100644
--- a/packages/astro/e2e/fixtures/multiple-frameworks/package.json
+++ b/packages/astro/e2e/fixtures/multiple-frameworks/package.json
@@ -13,12 +13,12 @@
},
"dependencies": {
"@webcomponents/template-shadowroot": "^0.2.1",
- "lit": "^3.1.4",
+ "lit": "^3.2.0",
"preact": "^10.23.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "solid-js": "^1.8.19",
+ "solid-js": "^1.8.20",
"svelte": "^4.2.18",
- "vue": "^3.4.35"
+ "vue": "^3.4.37"
}
}
diff --git a/packages/astro/e2e/fixtures/nested-in-preact/package.json b/packages/astro/e2e/fixtures/nested-in-preact/package.json
index 37e20359ac..855c013a99 100644
--- a/packages/astro/e2e/fixtures/nested-in-preact/package.json
+++ b/packages/astro/e2e/fixtures/nested-in-preact/package.json
@@ -14,8 +14,8 @@
"preact": "^10.23.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "solid-js": "^1.8.19",
+ "solid-js": "^1.8.20",
"svelte": "^4.2.18",
- "vue": "^3.4.35"
+ "vue": "^3.4.37"
}
}
diff --git a/packages/astro/e2e/fixtures/nested-in-react/package.json b/packages/astro/e2e/fixtures/nested-in-react/package.json
index 29d2a9e9a5..81f74f3ad4 100644
--- a/packages/astro/e2e/fixtures/nested-in-react/package.json
+++ b/packages/astro/e2e/fixtures/nested-in-react/package.json
@@ -14,8 +14,8 @@
"preact": "^10.23.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "solid-js": "^1.8.19",
+ "solid-js": "^1.8.20",
"svelte": "^4.2.18",
- "vue": "^3.4.35"
+ "vue": "^3.4.37"
}
}
diff --git a/packages/astro/e2e/fixtures/nested-in-solid/package.json b/packages/astro/e2e/fixtures/nested-in-solid/package.json
index d2c4402070..0d9b59c9d8 100644
--- a/packages/astro/e2e/fixtures/nested-in-solid/package.json
+++ b/packages/astro/e2e/fixtures/nested-in-solid/package.json
@@ -14,8 +14,8 @@
"preact": "^10.23.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "solid-js": "^1.8.19",
+ "solid-js": "^1.8.20",
"svelte": "^4.2.18",
- "vue": "^3.4.35"
+ "vue": "^3.4.37"
}
}
diff --git a/packages/astro/e2e/fixtures/nested-in-svelte/package.json b/packages/astro/e2e/fixtures/nested-in-svelte/package.json
index 31bec20c30..9cc7bdb306 100644
--- a/packages/astro/e2e/fixtures/nested-in-svelte/package.json
+++ b/packages/astro/e2e/fixtures/nested-in-svelte/package.json
@@ -14,8 +14,8 @@
"preact": "^10.23.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "solid-js": "^1.8.19",
+ "solid-js": "^1.8.20",
"svelte": "^4.2.18",
- "vue": "^3.4.35"
+ "vue": "^3.4.37"
}
}
diff --git a/packages/astro/e2e/fixtures/nested-in-vue/package.json b/packages/astro/e2e/fixtures/nested-in-vue/package.json
index 1985df63f9..abbd45a703 100644
--- a/packages/astro/e2e/fixtures/nested-in-vue/package.json
+++ b/packages/astro/e2e/fixtures/nested-in-vue/package.json
@@ -14,8 +14,8 @@
"preact": "^10.23.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "solid-js": "^1.8.19",
+ "solid-js": "^1.8.20",
"svelte": "^4.2.18",
- "vue": "^3.4.35"
+ "vue": "^3.4.37"
}
}
diff --git a/packages/astro/e2e/fixtures/nested-recursive/package.json b/packages/astro/e2e/fixtures/nested-recursive/package.json
index 7a39d16e24..18a9d346ae 100644
--- a/packages/astro/e2e/fixtures/nested-recursive/package.json
+++ b/packages/astro/e2e/fixtures/nested-recursive/package.json
@@ -14,9 +14,9 @@
"preact": "^10.23.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
- "solid-js": "^1.8.19",
+ "solid-js": "^1.8.20",
"svelte": "^4.2.18",
- "vue": "^3.4.35"
+ "vue": "^3.4.37"
},
"scripts": {
"dev": "astro dev"
diff --git a/packages/astro/e2e/fixtures/server-islands/src/components/Island.astro b/packages/astro/e2e/fixtures/server-islands/src/components/Island.astro
index b7c376f517..5eab0dc4df 100644
--- a/packages/astro/e2e/fixtures/server-islands/src/components/Island.astro
+++ b/packages/astro/e2e/fixtures/server-islands/src/components/Island.astro
@@ -1,4 +1,6 @@
---
+const { secret } = Astro.props;
---
I am an island
+{secret}
diff --git a/packages/astro/e2e/fixtures/server-islands/src/pages/index.astro b/packages/astro/e2e/fixtures/server-islands/src/pages/index.astro
index 998d6c0740..de9a6c456f 100644
--- a/packages/astro/e2e/fixtures/server-islands/src/pages/index.astro
+++ b/packages/astro/e2e/fixtures/server-islands/src/pages/index.astro
@@ -8,7 +8,7 @@ import Self from '../components/Self.astro';
-
+
children
diff --git a/packages/astro/e2e/fixtures/solid-circular/package.json b/packages/astro/e2e/fixtures/solid-circular/package.json
index 36bd984892..4b52eaed79 100644
--- a/packages/astro/e2e/fixtures/solid-circular/package.json
+++ b/packages/astro/e2e/fixtures/solid-circular/package.json
@@ -7,6 +7,6 @@
"astro": "workspace:*"
},
"devDependencies": {
- "solid-js": "^1.8.19"
+ "solid-js": "^1.8.20"
}
}
diff --git a/packages/astro/e2e/fixtures/solid-component/package.json b/packages/astro/e2e/fixtures/solid-component/package.json
index db9e2e995b..f7f4d2610e 100644
--- a/packages/astro/e2e/fixtures/solid-component/package.json
+++ b/packages/astro/e2e/fixtures/solid-component/package.json
@@ -6,6 +6,6 @@
"@astrojs/mdx": "workspace:*",
"@astrojs/solid-js": "workspace:*",
"astro": "workspace:*",
- "solid-js": "^1.8.19"
+ "solid-js": "^1.8.20"
}
}
diff --git a/packages/astro/e2e/fixtures/solid-recurse/package.json b/packages/astro/e2e/fixtures/solid-recurse/package.json
index b099ccc50d..fe2f588ee8 100644
--- a/packages/astro/e2e/fixtures/solid-recurse/package.json
+++ b/packages/astro/e2e/fixtures/solid-recurse/package.json
@@ -7,6 +7,6 @@
"astro": "workspace:*"
},
"devDependencies": {
- "solid-js": "^1.8.19"
+ "solid-js": "^1.8.20"
}
}
diff --git a/packages/astro/e2e/fixtures/tailwindcss/package.json b/packages/astro/e2e/fixtures/tailwindcss/package.json
index dc5b9c4b5a..0852f72614 100644
--- a/packages/astro/e2e/fixtures/tailwindcss/package.json
+++ b/packages/astro/e2e/fixtures/tailwindcss/package.json
@@ -6,7 +6,7 @@
"@astrojs/tailwind": "workspace:*",
"astro": "workspace:*",
"autoprefixer": "^10.4.20",
- "postcss": "^8.4.40",
- "tailwindcss": "^3.4.7"
+ "postcss": "^8.4.41",
+ "tailwindcss": "^3.4.9"
}
}
diff --git a/packages/astro/e2e/fixtures/view-transitions/package.json b/packages/astro/e2e/fixtures/view-transitions/package.json
index 2223ccbb71..e50f5de5b7 100644
--- a/packages/astro/e2e/fixtures/view-transitions/package.json
+++ b/packages/astro/e2e/fixtures/view-transitions/package.json
@@ -11,6 +11,6 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"svelte": "^4.2.18",
- "vue": "^3.4.35"
+ "vue": "^3.4.37"
}
}
diff --git a/packages/astro/e2e/fixtures/vue-component/package.json b/packages/astro/e2e/fixtures/vue-component/package.json
index aeaf4cef0a..dffb08551c 100644
--- a/packages/astro/e2e/fixtures/vue-component/package.json
+++ b/packages/astro/e2e/fixtures/vue-component/package.json
@@ -6,6 +6,6 @@
"@astrojs/mdx": "workspace:*",
"@astrojs/vue": "workspace:*",
"astro": "workspace:*",
- "vue": "^3.4.35"
+ "vue": "^3.4.37"
}
}
diff --git a/packages/astro/e2e/server-islands.test.js b/packages/astro/e2e/server-islands.test.js
index b036eaafa3..b37495b288 100644
--- a/packages/astro/e2e/server-islands.test.js
+++ b/packages/astro/e2e/server-islands.test.js
@@ -38,6 +38,12 @@ test.describe('Server islands', () => {
await expect(el, 'element rendered').toBeVisible();
});
+ test('Props are encrypted', async ({ page, astro }) => {
+ await page.goto(astro.resolveUrl('/base/'));
+ let el = page.locator('#secret');
+ await expect(el).toHaveText('test');
+ });
+
test('Self imported module can server defer', async ({ page, astro }) => {
await page.goto(astro.resolveUrl('/base/'));
let el = page.locator('.now');
@@ -69,5 +75,11 @@ test.describe('Server islands', () => {
await expect(el, 'element rendered').toBeVisible();
await expect(el, 'should have content').toHaveText('I am an island');
});
+
+ test('Props are encrypted', async ({ page, astro }) => {
+ await page.goto(astro.resolveUrl('/'));
+ let el = page.locator('#secret');
+ await expect(el).toHaveText('test');
+ });
});
});
diff --git a/packages/astro/package.json b/packages/astro/package.json
index 64ec709d59..31591eb292 100644
--- a/packages/astro/package.json
+++ b/packages/astro/package.json
@@ -1,6 +1,6 @@
{
"name": "astro",
- "version": "4.13.3",
+ "version": "4.13.4",
"description": "Astro is a modern site builder with web best practices, performance, and DX front-of-mind.",
"type": "module",
"author": "withastro",
@@ -68,6 +68,7 @@
"./assets/services/sharp": "./dist/assets/services/sharp.js",
"./assets/services/squoosh": "./dist/assets/services/squoosh.js",
"./assets/services/noop": "./dist/assets/services/noop.js",
+ "./loaders": "./dist/content/loaders/index.js",
"./content/runtime": "./dist/content/runtime.js",
"./content/runtime-assets": "./dist/content/runtime-assets.js",
"./debug": "./components/Debug.astro",
@@ -122,7 +123,7 @@
"test:node": "astro-scripts test \"test/**/*.test.js\""
},
"dependencies": {
- "@astrojs/compiler": "^2.10.1",
+ "@astrojs/compiler": "^2.10.2",
"@astrojs/internal-helpers": "workspace:*",
"@astrojs/markdown-remark": "workspace:*",
"@astrojs/telemetry": "workspace:*",
@@ -132,6 +133,8 @@
"@babel/plugin-transform-react-jsx": "^7.25.2",
"@babel/traverse": "^7.25.3",
"@babel/types": "^7.25.2",
+ "@rollup/pluginutils": "^5.1.0",
+ "@oslojs/encoding": "^0.4.1",
"@types/babel__core": "^7.20.5",
"@types/cookie": "^0.6.0",
"acorn": "^8.12.1",
@@ -162,7 +165,9 @@
"js-yaml": "^4.1.0",
"kleur": "^4.1.5",
"magic-string": "^0.30.11",
+ "micromatch": "^4.0.7",
"mrmime": "^2.0.0",
+ "neotraverse": "^0.6.9",
"ora": "^8.0.1",
"p-limit": "^6.1.0",
"p-queue": "^8.0.1",
@@ -177,19 +182,20 @@
"tsconfck": "^3.1.1",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.2",
- "vite": "^5.3.5",
+ "vite": "^5.4.0",
"vitefu": "^0.2.5",
"which-pm": "^3.0.0",
- "yargs-parser": "^21.1.1",
+ "xxhash-wasm": "^1.0.2",
"zod": "^3.23.8",
- "zod-to-json-schema": "^3.23.2"
+ "zod-to-json-schema": "^3.23.2",
+ "zod-to-ts": "^1.2.0"
},
"optionalDependencies": {
"sharp": "^0.33.3"
},
"devDependencies": {
- "@astrojs/check": "^0.9.1",
- "@playwright/test": "^1.45.3",
+ "@astrojs/check": "^0.9.2",
+ "@playwright/test": "^1.46.0",
"@types/aria-query": "^5.0.4",
"@types/babel__generator": "^7.6.8",
"@types/babel__traverse": "^7.20.6",
@@ -203,11 +209,11 @@
"@types/html-escaper": "^3.0.2",
"@types/http-cache-semantics": "^4.0.4",
"@types/js-yaml": "^4.0.9",
+ "@types/micromatch": "^4.0.9",
"@types/prompts": "^2.4.9",
"@types/semver": "^7.5.8",
- "@types/yargs-parser": "^21.0.3",
"astro-scripts": "workspace:*",
- "cheerio": "1.0.0-rc.12",
+ "cheerio": "1.0.0",
"eol": "^0.9.1",
"expect-type": "^0.19.0",
"mdast-util-mdx": "^3.0.0",
@@ -221,7 +227,7 @@
"remark-code-titles": "^0.1.2",
"rollup": "^4.20.0",
"sass": "^1.77.8",
- "undici": "^6.19.5",
+ "undici": "^6.19.7",
"unified": "^11.0.5"
},
"engines": {
diff --git a/packages/astro/performance/content-benchmark.mjs b/packages/astro/performance/content-benchmark.mjs
index a710bd7625..98ef5f0eac 100644
--- a/packages/astro/performance/content-benchmark.mjs
+++ b/packages/astro/performance/content-benchmark.mjs
@@ -1,8 +1,8 @@
/* eslint-disable no-console */
import { fileURLToPath } from 'node:url';
+import { parseArgs } from 'node:util';
import { bold, cyan, dim } from 'kleur/colors';
-import yargs from 'yargs-parser';
import { loadFixture } from '../test/test-utils.js';
import { generatePosts } from './scripts/generate-posts.mjs';
@@ -40,7 +40,7 @@ async function benchmark({ fixtures, templates, numPosts }) {
// Test the build performance for content collections across multiple file types (md, mdx, mdoc)
(async function benchmarkAll() {
try {
- const flags = yargs(process.argv.slice(2));
+ const { values: flags } = parseArgs({ strict: false });
const test = Array.isArray(flags.test)
? flags.test
: typeof flags.test === 'string'
diff --git a/packages/astro/performance/package.json b/packages/astro/performance/package.json
index c0833b9522..36d6252817 100644
--- a/packages/astro/performance/package.json
+++ b/packages/astro/performance/package.json
@@ -11,8 +11,6 @@
"author": "",
"license": "ISC",
"devDependencies": {
- "@types/yargs-parser": "^21.0.3",
- "kleur": "^4.1.5",
- "yargs-parser": "^21.1.1"
+ "kleur": "^4.1.5"
}
}
diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts
index 545149c778..19528017ae 100644
--- a/packages/astro/src/@types/astro.ts
+++ b/packages/astro/src/@types/astro.ts
@@ -1,5 +1,3 @@
-import type { OutgoingHttpHeaders } from 'node:http';
-import type { AddressInfo } from 'node:net';
import type {
MarkdownHeading,
MarkdownVFile,
@@ -9,6 +7,8 @@ import type {
ShikiConfig,
} from '@astrojs/markdown-remark';
import type * as babel from '@babel/core';
+import type { OutgoingHttpHeaders } from 'node:http';
+import type { AddressInfo } from 'node:net';
import type * as rollup from 'rollup';
import type * as vite from 'vite';
import type {
@@ -18,6 +18,7 @@ import type {
ActionReturnType,
} from '../actions/runtime/virtual/server.js';
import type { RemotePattern } from '../assets/utils/remotePattern.js';
+import type { DataEntry, RenderedContent } from '../content/data-store.js';
import type { AssetsPrefix, SSRManifest, SerializedSSRManifest } from '../core/app/types.js';
import type { PageBuildData } from '../core/build/types.js';
import type { AstroConfigType } from '../core/config/index.js';
@@ -78,7 +79,7 @@ export type {
UnresolvedImageTransform,
} from '../assets/types.js';
export type { RemotePattern } from '../assets/utils/remotePattern.js';
-export type { SSRManifest, AssetsPrefix } from '../core/app/types.js';
+export type { AssetsPrefix, SSRManifest } from '../core/app/types.js';
export type {
AstroCookieGetOptions,
AstroCookieSetOptions,
@@ -123,6 +124,7 @@ export type TransitionAnimationValue =
| TransitionDirectionalAnimations;
// Allow users to extend this for astro-jsx.d.ts
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface AstroClientDirectives {}
@@ -2111,6 +2113,225 @@ export interface AstroUserConfig {
* For a complete overview, and to give feedback on this experimental API, see the [Server Islands RFC](https://github.com/withastro/roadmap/pull/963).
*/
serverIslands?: boolean;
+
+ /**
+ * @docs
+ * @name experimental.contentIntellisense
+ * @type {boolean}
+ * @default `false`
+ * @version 4.14.0
+ * @description
+ *
+ * Enables Intellisense features (e.g. code completion, quick hints) for your content collection entries in compatible editors.
+ *
+ * When enabled, this feature will generate and add JSON schemas to the `.astro` directory in your project. These files can be used by the Astro language server to provide Intellisense inside content files (`.md`, `.mdx`, `.mdoc`).
+ *
+ * ```js
+ * {
+ * experimental: {
+ * contentIntellisense: true,
+ * },
+ * }
+ * ```
+ *
+ * To use this feature with the Astro VS Code extension, you must also enable the `astro.content-intellisense` option in your VS Code settings. For editors using the Astro language server directly, pass the `contentIntellisense: true` initialization parameter to enable this feature.
+ */
+ contentIntellisense?: boolean;
+
+ /**
+ * @docs
+ * @name experimental.contentLayer
+ * @type {boolean}
+ * @default `false`
+ * @version 4.14.0
+ * @description
+ *
+ * The Content Layer API is a new way to handle content and data in Astro. It is similar to and builds upon [content collections](/en/guides/content-collections/), taking them beyond local files in `src/content/` and allowing you to fetch content from anywhere, including remote APIs, by adding a `loader` to your collection.
+ *
+ * Your existing content collections can be [migrated to the Content Layer API](#migrating-a-content-collection-to-content-layer) with a few small changes. However, it is not necessary to update all your collections at once to add a new collection powered by the Content Layer API. You may have collections using both the existing and new APIs defined in `src/content/config.ts` at the same time.
+ *
+ * The Content Layer API is designed to be more powerful and more performant, helping sites scale to thousands of pages. Data is cached between builds and updated incrementally. Markdown parsing is also 5-10 times faster, with similar scale reductions in memory, and MDX is 2-3 times faster.
+ *
+ * To enable, add the `contentLayer` flag to the `experimental` object in your Astro config:
+ *
+ * ```js
+ * // astro.config.mjs
+ * {
+ * experimental: {
+ * contentLayer: true,
+ * }
+ * }
+ * ```
+ *
+ * #### Fetching data with a `loader`
+ *
+ * The Content Layer API allows you to fetch your content from outside of the `src/content/` folder (whether stored locally in your project or remotely), and uses a `loader` property to retrieve your data.
+ *
+ * The `loader` is defined in the collection's schema and returns an array of entries. Astro provides two built-in loader functions (`glob()` and `file()`) for fetching your local content, as well as access to the API to [construct your own loader and fetch remote data](#creating-a-loader).
+ *
+ * The `glob()` loader creates entries from directories of Markdown, MDX, Markdoc, or JSON files from anywhere on the filesystem. It accepts a `pattern` of entry files to match, and a `base` file path of where your files are located. Use this when you have one file per entry.
+ *
+ * The `file()` loader creates multiple entries from a single local file. Use this when all your entries are stored in an array of objects.
+ *
+ * ```ts {3,8,19}
+ * // src/content/config.ts
+ * import { defineCollection, z } from 'astro:content';
+ * import { glob, file } from 'astro/loaders';
+ *
+ * const blog = defineCollection({
+ * // By default the ID is a slug generated from
+ * // the path of the file relative to `base`
+ * loader: glob({ pattern: "**\/*.md", base: "./src/data/blog" }),
+ * schema: z.object({
+ * title: z.string(),
+ * description: z.string(),
+ * pubDate: z.coerce.date(),
+ * updatedDate: z.coerce.date().optional(),
+ * })
+ * });
+ *
+ * const dogs = defineCollection({
+ * // The path is relative to the project root, or an absolute path.
+ * loader: file("src/data/dogs.json"),
+ * schema: z.object({
+ * id: z.string(),
+ * breed: z.string(),
+ * temperament: z.array(z.string()),
+ * }),
+ * });
+ *
+ * export const collections = { blog, dogs };
+ * ```
+ *
+ * #### Querying and rendering with the Content Layer API
+ *
+ * The collection can be [queried in the same way as content collections](/en/guides/content-collections/#querying-collections):
+ *
+ * ```ts
+ * // src/pages/index.astro
+ * import { getCollection, getEntry } from 'astro:content';
+ *
+ * // Get all entries from a collection.
+ * // Requires the name of the collection as an argument.
+ * const allBlogPosts = await getCollection('blog');
+ *
+ * // Get a single entry from a collection.
+ * // Requires the name of the collection and ID
+ * const labradorData = await getEntry('dogs', 'labrador-retriever');
+ * ```
+ *
+ * Entries generated from Markdown, MDX or Markdoc can be rendered directly to a page using the `render()` function.
+ *
+ * :::note
+ * The syntax for rendering collection entries is different from current content collections syntax.
+ * :::
+ *
+ * ```astro title="src/pages/[slug].astro"
+ * ---
+ * import { getEntry, render } from 'astro:content';
+ *
+ * const post = await getEntry('blog', Astro.params.slug);
+ *
+ * const { Content, headings } = await render(entry);
+ * ---
+ *
+ *
+ * ```
+ *
+ * #### Creating a loader
+ *
+ * With the Content Layer API, you can build loaders to load or generate content from anywhere.
+ *
+ * For example, you can create a loader that fetches collection entries from a remote API.
+ *
+ * ```ts
+ * // src/content/config.ts
+ * const countries = defineCollection({
+ * loader: async () => {
+ * const response = await fetch("https://restcountries.com/v3.1/all");
+ * const data = await response.json();
+ * // Must return an array of entries with an id property,
+ * // or an object with IDs as keys and entries as values
+ * return data.map((country) => ({
+ * id: country.cca3,
+ * ...country,
+ * }));
+ * },
+ * // optionally add a schema
+ * // schema: z.object...
+ * });
+ *
+ * export const collections = { countries };
+ * ```
+ *
+ * For more advanced loading logic, you can define an object loader. This allows incremental updates and conditional loading, and gives full access to the data store. See the API in [the Content Layer API RFC](https://github.com/withastro/roadmap/blob/content-layer/proposals/0047-content-layer.md#loaders).
+ *
+ * #### Migrating an existing content collection to use the Content Layer API
+ *
+ * You can convert an existing content collection with Markdown, MDX, Markdoc, or JSON entries to use the Content Layer API.
+ *
+ * 1. **Move the collection folder out of `src/content/`** (e.g. to `src/data/`). All collections located in the `src/content/` folder will use the existing Content Collections API.
+ *
+ * **Do not move the existing `src/content/config.ts` file**. This file will define all collections, using either API.
+ *
+ * 2. **Edit the collection definition**. Your updated collection requires a `loader`, and the option to select a collection `type` is no longer available.
+ *
+ * ```diff
+ * // src/content/config.ts
+ * import { defineCollection, z } from 'astro:content';
+ * + import { glob } from 'astro/loaders';
+ *
+ * const blog = defineCollection({
+ * // For content layer you no longer define a `type`
+ * - type: 'content',
+ * + loader: glob({ pattern: "**\/*.md", base: "./src/data/blog" }),
+ * schema: z.object({
+ * title: z.string(),
+ * description: z.string(),
+ * pubDate: z.coerce.date(),
+ * updatedDate: z.coerce.date().optional(),
+ * }),
+ * });
+ * ```
+ *
+ * 3. **Change references from `slug` to `id`**. Content layer collections do not have a `slug` field. Instead, all updated collections will have an `id`.
+ *
+ * ```diff
+ * // src/pages/index.astro
+ * ---
+ * export async function getStaticPaths() {
+ * const posts = await getCollection('blog');
+ * return posts.map((post) => ({
+ * - params: { slug: post.slug },
+ * + params: { slug: post.id },
+ * props: post,
+ * }));
+ * }
+ * ---
+ * ```
+ *
+ * 4. **Switch to the new `render()` function**. Entries no longer have a `render()` method, as they are now serializable plain objects. Instead, import the `render()` function from `astro:content`.
+ *
+ * ```diff
+ * // src/pages/index.astro
+ * ---
+ * - import { getEntry } from 'astro:content';
+ * + import { getEntry, render } from 'astro:content';
+ *
+ * const post = await getEntry('blog', params.slug);
+ *
+ * - const { Content, headings } = await post.render();
+ * + const { Content, headings } = await render(post);
+ * ---
+ *
+ *
+ * ```
+ *
+ * #### Learn more
+ *
+ * For a complete overview and the full API reference, see [the Content Layer API RFC](https://github.com/withastro/roadmap/blob/content-layer/proposals/0047-content-layer.md) and [share your feedback](https://github.com/withastro/roadmap/pull/982).
+ */
+ contentLayer?: boolean;
};
}
@@ -2151,6 +2372,21 @@ export interface ResolvedInjectedRoute extends InjectedRoute {
resolvedEntryPoint?: URL;
}
+export interface RouteOptions {
+ /**
+ * The path to this route relative to the project root. The slash is normalized as forward slash
+ * across all OS.
+ * @example "src/pages/blog/[...slug].astro"
+ */
+ readonly component: string;
+ /**
+ * Whether this route should be prerendered. If the route has an explicit `prerender` export,
+ * the value will be passed here. Otherwise, it's undefined and will fallback to a prerender
+ * default depending on the `output` option.
+ */
+ prerender?: boolean;
+}
+
/**
* Resolved Astro Config
* Config with user settings along with all defaults filled in.
@@ -2192,6 +2428,10 @@ export interface AstroInlineOnlyConfig {
* @default "info"
*/
logLevel?: LoggerLevel;
+ /**
+ * Clear the content layer cache, forcing a rebuild of all content entries.
+ */
+ force?: boolean;
/**
* @internal for testing only, use `logLevel` instead.
*/
@@ -2220,6 +2460,8 @@ export type DataEntryModule = {
};
};
+export type ContentEntryRenderFuction = (entry: DataEntry) => Promise;
+
export interface ContentEntryType {
extensions: string[];
getEntryInfo(params: {
@@ -2235,6 +2477,8 @@ export interface ContentEntryType {
},
): rollup.LoadResult | Promise;
contentModuleTypes?: string;
+ getRenderFunction?(settings: AstroSettings): Promise;
+
/**
* Handle asset propagation for rendered content to avoid bleed.
* Ex. MDX content can import styles and scripts, so `handlePropagation` should be true.
@@ -2267,7 +2511,7 @@ export type GetDataEntryInfoReturnType = { data: Record; rawDat
export interface AstroAdapterFeatures {
/**
- * Creates and edge function that will communiate with the Astro middleware
+ * Creates an edge function that will communiate with the Astro middleware
*/
edgeMiddleware: boolean;
/**
@@ -2276,6 +2520,11 @@ export interface AstroAdapterFeatures {
functionPerRoute: boolean;
}
+export interface InjectedType {
+ filename: string;
+ content: string;
+}
+
export interface AstroSettings {
config: AstroConfig;
adapter: AstroAdapter | undefined;
@@ -2311,6 +2560,7 @@ export interface AstroSettings {
latestAstroVersion: string | undefined;
serverIslandMap: NonNullable;
serverIslandNameMap: NonNullable;
+ injectedTypes: Array;
}
export type AsyncRendererComponentFn = (
@@ -3012,6 +3262,7 @@ declare global {
'astro:config:done': (options: {
config: AstroConfig;
setAdapter: (adapter: AstroAdapter) => void;
+ injectTypes: (injectedType: InjectedType) => URL;
logger: AstroIntegrationLogger;
}) => void | Promise;
'astro:server:setup': (options: {
@@ -3056,6 +3307,10 @@ declare global {
logger: AstroIntegrationLogger;
cacheManifest: boolean;
}) => void | Promise;
+ 'astro:route:setup': (options: {
+ route: RouteOptions;
+ logger: AstroIntegrationLogger;
+ }) => void | Promise;
}
}
}
@@ -3211,6 +3466,7 @@ export interface SSRResult {
cookies: AstroCookies | undefined;
serverIslandNameMap: Map;
trailingSlash: AstroConfig['trailingSlash'];
+ key: Promise;
_metadata: SSRMetadata;
}
diff --git a/packages/astro/src/actions/consts.ts b/packages/astro/src/actions/consts.ts
index 6a55386d86..beb8c45b64 100644
--- a/packages/astro/src/actions/consts.ts
+++ b/packages/astro/src/actions/consts.ts
@@ -1,6 +1,6 @@
export const VIRTUAL_MODULE_ID = 'astro:actions';
export const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;
-export const ACTIONS_TYPES_FILE = 'actions.d.ts';
+export const ACTIONS_TYPES_FILE = 'astro/actions.d.ts';
export const VIRTUAL_INTERNAL_MODULE_ID = 'astro:internal-actions';
export const RESOLVED_VIRTUAL_INTERNAL_MODULE_ID = '\0astro:internal-actions';
export const NOOP_ACTIONS = '\0noop-actions';
diff --git a/packages/astro/src/actions/index.ts b/packages/astro/src/actions/index.ts
index 04a911856f..2423b7017d 100644
--- a/packages/astro/src/actions/index.ts
+++ b/packages/astro/src/actions/index.ts
@@ -30,9 +30,6 @@ export default function astroActions({
throw error;
}
- const stringifiedActionsImport = JSON.stringify(
- viteID(new URL('./actions', params.config.srcDir)),
- );
params.updateConfig({
vite: {
plugins: [vitePluginUserActions({ settings }), vitePluginActions(fs)],
@@ -49,11 +46,18 @@ export default function astroActions({
entrypoint: 'astro/actions/runtime/middleware.js',
order: 'post',
});
+ },
+ 'astro:config:done': (params) => {
+ const stringifiedActionsImport = JSON.stringify(
+ viteID(new URL('./actions', params.config.srcDir)),
+ );
+ settings.injectedTypes.push({
+ filename: ACTIONS_TYPES_FILE,
+ content: `declare module "astro:actions" {
+ type Actions = typeof import(${stringifiedActionsImport})["server"];
- await typegen({
- stringifiedActionsImport,
- root: params.config.root,
- fs,
+ export const actions: Actions;
+}`,
});
},
},
@@ -119,24 +123,3 @@ const vitePluginActions = (fs: typeof fsMod): VitePlugin => ({
return code;
},
});
-
-async function typegen({
- stringifiedActionsImport,
- root,
- fs,
-}: {
- stringifiedActionsImport: string;
- root: URL;
- fs: typeof fsMod;
-}) {
- const content = `declare module "astro:actions" {
- type Actions = typeof import(${stringifiedActionsImport})["server"];
-
- export const actions: Actions;
-}`;
-
- const dotAstroDir = new URL('.astro/', root);
-
- await fs.promises.mkdir(dotAstroDir, { recursive: true });
- await fs.promises.writeFile(new URL(ACTIONS_TYPES_FILE, dotAstroDir), content);
-}
diff --git a/packages/astro/src/actions/runtime/virtual/server.ts b/packages/astro/src/actions/runtime/virtual/server.ts
index 7aea22b2fc..9bc387d6b8 100644
--- a/packages/astro/src/actions/runtime/virtual/server.ts
+++ b/packages/astro/src/actions/runtime/virtual/server.ts
@@ -134,11 +134,25 @@ export function formDataToObject(
const obj: Record = {};
for (const [key, baseValidator] of Object.entries(schema.shape)) {
let validator = baseValidator;
- while (validator instanceof z.ZodOptional || validator instanceof z.ZodNullable) {
+
+ while (
+ validator instanceof z.ZodOptional ||
+ validator instanceof z.ZodNullable ||
+ validator instanceof z.ZodDefault
+ ) {
+ // use default value when key is undefined
+ if (validator instanceof z.ZodDefault && !formData.has(key)) {
+ obj[key] = validator._def.defaultValue();
+ }
validator = validator._def.innerType;
}
- if (validator instanceof z.ZodBoolean) {
- obj[key] = formData.has(key);
+
+ if (!formData.has(key) && key in obj) {
+ // continue loop if form input is not found and default value is set
+ continue;
+ } else if (validator instanceof z.ZodBoolean) {
+ const val = formData.get(key);
+ obj[key] = val === 'true' ? true : val === 'false' ? false : formData.has(key);
} else if (validator instanceof z.ZodArray) {
obj[key] = handleFormDataGetAll(key, formData, validator);
} else {
diff --git a/packages/astro/src/actions/runtime/virtual/shared.ts b/packages/astro/src/actions/runtime/virtual/shared.ts
index 57dc404496..d792a9af55 100644
--- a/packages/astro/src/actions/runtime/virtual/shared.ts
+++ b/packages/astro/src/actions/runtime/virtual/shared.ts
@@ -213,6 +213,9 @@ export type SerializedActionResult =
export function serializeActionResult(res: SafeResult): SerializedActionResult {
if (res.error) {
+ if (import.meta.env?.DEV) {
+ actionResultErrorStack.set(res.error.stack);
+ }
return {
type: 'error',
status: res.error.status,
@@ -220,7 +223,6 @@ export function serializeActionResult(res: SafeResult): SerializedActi
body: JSON.stringify({
...res.error,
message: res.error.message,
- stack: import.meta.env.PROD ? undefined : res.error.stack,
}),
};
}
@@ -243,7 +245,16 @@ export function serializeActionResult(res: SafeResult): SerializedActi
export function deserializeActionResult(res: SerializedActionResult): SafeResult {
if (res.type === 'error') {
- return { error: ActionError.fromJson(JSON.parse(res.body)), data: undefined };
+ if (import.meta.env?.PROD) {
+ return { error: ActionError.fromJson(JSON.parse(res.body)), data: undefined };
+ } else {
+ const error = ActionError.fromJson(JSON.parse(res.body));
+ error.stack = actionResultErrorStack.get();
+ return {
+ error,
+ data: undefined,
+ };
+ }
}
if (res.type === 'empty') {
return { data: undefined, error: undefined };
@@ -255,3 +266,16 @@ export function deserializeActionResult(res: SerializedActionResult): SafeResult
error: undefined,
};
}
+
+// in-memory singleton to save the stack trace
+const actionResultErrorStack = (function actionResultErrorStackFn() {
+ let errorStack: string | undefined;
+ return {
+ set(stack: string | undefined) {
+ errorStack = stack;
+ },
+ get() {
+ return errorStack;
+ },
+ };
+})();
diff --git a/packages/astro/src/assets/utils/resolveImports.ts b/packages/astro/src/assets/utils/resolveImports.ts
new file mode 100644
index 0000000000..8d147552ec
--- /dev/null
+++ b/packages/astro/src/assets/utils/resolveImports.ts
@@ -0,0 +1,40 @@
+import { isRemotePath, removeBase } from '@astrojs/internal-helpers/path';
+import { CONTENT_IMAGE_FLAG, IMAGE_IMPORT_PREFIX } from '../../content/consts.js';
+import { shorthash } from '../../runtime/server/shorthash.js';
+import { VALID_INPUT_FORMATS } from '../consts.js';
+
+/**
+ * Resolves an image src from a content file (such as markdown) to a module ID or import that can be resolved by Vite.
+ *
+ * @param imageSrc The src attribute of an image tag
+ * @param filePath The path to the file that contains the imagem relative to the site root
+ * @returns A module id of the image that can be rsolved by Vite, or undefined if it is not a local image
+ */
+export function imageSrcToImportId(imageSrc: string, filePath: string): string | undefined {
+ // If the import is coming from the data store it will have a special prefix to identify it
+ // as an image import. We remove this prefix so that we can resolve the image correctly.
+ imageSrc = removeBase(imageSrc, IMAGE_IMPORT_PREFIX);
+
+ // We only care about local imports
+ if (isRemotePath(imageSrc) || imageSrc.startsWith('/')) {
+ return;
+ }
+ // We only care about images
+ const ext = imageSrc.split('.').at(-1) as (typeof VALID_INPUT_FORMATS)[number] | undefined;
+ if (!ext || !VALID_INPUT_FORMATS.includes(ext)) {
+ return;
+ }
+
+ // The import paths are relative to the content (md) file, but when it's actually resolved it will
+ // be in a single assets file, so relative paths will no longer work. To deal with this we use
+ // a query parameter to store the original path to the file and append a query param flag.
+ // This allows our Vite plugin to intercept the import and resolve the path relative to the
+ // importer and get the correct full path for the imported image.
+
+ const params = new URLSearchParams(CONTENT_IMAGE_FLAG);
+ params.set('importer', filePath);
+ return `${imageSrc}?${params.toString()}`;
+}
+
+export const importIdToSymbolName = (importId: string) =>
+ `__ASTRO_IMAGE_IMPORT_${shorthash(importId)}`;
diff --git a/packages/astro/src/cli/add/index.ts b/packages/astro/src/cli/add/index.ts
index 7d33fe33a5..f710184d2c 100644
--- a/packages/astro/src/cli/add/index.ts
+++ b/packages/astro/src/cli/add/index.ts
@@ -9,7 +9,6 @@ import ora from 'ora';
import preferredPM from 'preferred-pm';
import prompts from 'prompts';
import maxSatisfying from 'semver/ranges/max-satisfying.js';
-import type yargs from 'yargs-parser';
import {
loadTSConfig,
resolveConfig,
@@ -29,14 +28,14 @@ import { appendForwardSlash } from '../../core/path.js';
import { apply as applyPolyfill } from '../../core/polyfill.js';
import { ensureProcessNodeEnv, parseNpmName } from '../../core/util.js';
import { eventCliSession, telemetry } from '../../events/index.js';
-import { createLoggerFromFlags, flagsToAstroInlineConfig } from '../flags.js';
+import { type Flags, createLoggerFromFlags, flagsToAstroInlineConfig } from '../flags.js';
import { fetchPackageJson, fetchPackageVersions } from '../install-package.js';
import { generate, parse, t, visit } from './babel.js';
import { ensureImport } from './imports.js';
import { wrapDefaultExport } from './wrapper.js';
interface AddOptions {
- flags: yargs.Arguments;
+ flags: Flags;
}
interface IntegrationInfo {
@@ -143,7 +142,7 @@ export async function add(names: string[], { flags }: AddOptions) {
}
// Some packages might have a common alias! We normalize those here.
- const cwd = flags.root;
+ const cwd = inlineConfig.root;
const logger = createLoggerFromFlags(flags);
const integrationNames = names.map((name) => (ALIASES.has(name) ? ALIASES.get(name)! : name));
const integrations = await validateIntegrations(integrationNames);
@@ -249,7 +248,7 @@ export async function add(names: string[], { flags }: AddOptions) {
const rawConfigPath = await resolveConfigPath({
root: rootPath,
- configFile: flags.config,
+ configFile: inlineConfig.configFile,
fs: fsMod,
});
let configURL = rawConfigPath ? pathToFileURL(rawConfigPath) : undefined;
@@ -580,7 +579,7 @@ async function updateAstroConfig({
}: {
configURL: URL;
ast: t.File;
- flags: yargs.Arguments;
+ flags: Flags;
logger: Logger;
logAdapterInstructions: boolean;
}): Promise {
@@ -717,7 +716,7 @@ async function tryToInstallIntegrations({
}: {
integrations: IntegrationInfo[];
cwd?: string;
- flags: yargs.Arguments;
+ flags: Flags;
logger: Logger;
}): Promise {
const installCommand = await getInstallIntegrationsCommand({ integrations, cwd, logger });
@@ -893,7 +892,7 @@ async function updateTSConfig(
cwd = process.cwd(),
logger: Logger,
integrationsInfo: IntegrationInfo[],
- flags: yargs.Arguments,
+ flags: Flags,
): Promise {
const integrations = integrationsInfo.map(
(integration) => integration.id as frameworkWithTSSettings,
@@ -996,7 +995,7 @@ function parseIntegrationName(spec: string) {
return { scope, name, tag };
}
-async function askToContinue({ flags }: { flags: yargs.Arguments }): Promise {
+async function askToContinue({ flags }: { flags: Flags }): Promise {
if (flags.yes || flags.y) return true;
const response = await prompts({
@@ -1038,7 +1037,7 @@ function getDiffContent(input: string, output: string): string | null {
async function setupIntegrationConfig(opts: {
root: URL;
logger: Logger;
- flags: yargs.Arguments;
+ flags: Flags;
integrationName: string;
possibleConfigFiles: string[];
defaultConfigFile: string;
diff --git a/packages/astro/src/cli/build/index.ts b/packages/astro/src/cli/build/index.ts
index 15ff584317..dd44e61701 100644
--- a/packages/astro/src/cli/build/index.ts
+++ b/packages/astro/src/cli/build/index.ts
@@ -1,10 +1,9 @@
-import type yargs from 'yargs-parser';
import _build from '../../core/build/index.js';
import { printHelp } from '../../core/messages.js';
-import { flagsToAstroInlineConfig } from '../flags.js';
+import { type Flags, flagsToAstroInlineConfig } from '../flags.js';
interface BuildOptions {
- flags: yargs.Arguments;
+ flags: Flags;
}
export async function build({ flags }: BuildOptions) {
@@ -15,6 +14,10 @@ export async function build({ flags }: BuildOptions) {
tables: {
Flags: [
['--outDir ', `Specify the output directory for the build.`],
+ [
+ '--force',
+ 'Clear the content layer and content collection cache, forcing a full rebuild.',
+ ],
['--help (-h)', 'See all available flags.'],
],
},
@@ -25,5 +28,5 @@ export async function build({ flags }: BuildOptions) {
const inlineConfig = flagsToAstroInlineConfig(flags);
- await _build(inlineConfig, { force: flags.force ?? false });
+ await _build(inlineConfig);
}
diff --git a/packages/astro/src/cli/check/index.ts b/packages/astro/src/cli/check/index.ts
index a95e1074a5..9a354c8e00 100644
--- a/packages/astro/src/cli/check/index.ts
+++ b/packages/astro/src/cli/check/index.ts
@@ -1,13 +1,15 @@
import path from 'node:path';
-import type { Arguments } from 'yargs-parser';
import { ensureProcessNodeEnv } from '../../core/util.js';
-import { createLoggerFromFlags, flagsToAstroInlineConfig } from '../flags.js';
+import { type Flags, createLoggerFromFlags, flagsToAstroInlineConfig } from '../flags.js';
import { getPackage } from '../install-package.js';
-export async function check(flags: Arguments) {
+export async function check(flags: Flags) {
ensureProcessNodeEnv('production');
const logger = createLoggerFromFlags(flags);
- const getPackageOpts = { skipAsk: flags.yes || flags.y, cwd: flags.root };
+ const getPackageOpts = {
+ skipAsk: !!flags.yes || !!flags.y,
+ cwd: typeof flags.root == 'string' ? flags.root : undefined,
+ };
const checkPackage = await getPackage(
'@astrojs/check',
logger,
@@ -30,7 +32,7 @@ export async function check(flags: Arguments) {
// For now, we run this once as usually `astro check --watch` is ran alongside `astro dev` which also calls `astro sync`.
const { default: sync } = await import('../../core/sync/index.js');
try {
- await sync({ inlineConfig: flagsToAstroInlineConfig(flags) });
+ await sync(flagsToAstroInlineConfig(flags));
} catch (_) {
return process.exit(1);
}
diff --git a/packages/astro/src/cli/db/index.ts b/packages/astro/src/cli/db/index.ts
index dc6da36e1d..c6be7411bb 100644
--- a/packages/astro/src/cli/db/index.ts
+++ b/packages/astro/src/cli/db/index.ts
@@ -1,18 +1,26 @@
-import type { Arguments } from 'yargs-parser';
import type { AstroConfig } from '../../@types/astro.js';
import { resolveConfig } from '../../core/config/config.js';
import { apply as applyPolyfill } from '../../core/polyfill.js';
-import { createLoggerFromFlags, flagsToAstroInlineConfig } from '../flags.js';
+import { type Flags, createLoggerFromFlags, flagsToAstroInlineConfig } from '../flags.js';
import { getPackage } from '../install-package.js';
+interface YargsArguments {
+ _: Array;
+ '--'?: Array;
+ [argName: string]: any;
+}
+
type DBPackage = {
- cli: (args: { flags: Arguments; config: AstroConfig }) => unknown;
+ cli: (args: { flags: YargsArguments; config: AstroConfig }) => unknown;
};
-export async function db({ flags }: { flags: Arguments }) {
+export async function db({ positionals, flags }: { positionals: string[]; flags: Flags }) {
applyPolyfill();
const logger = createLoggerFromFlags(flags);
- const getPackageOpts = { skipAsk: flags.yes || flags.y, cwd: flags.root };
+ const getPackageOpts = {
+ skipAsk: !!flags.yes || !!flags.y,
+ cwd: typeof flags.root == 'string' ? flags.root : undefined,
+ };
const dbPackage = await getPackage('@astrojs/db', logger, getPackageOpts, []);
if (!dbPackage) {
@@ -27,5 +35,10 @@ export async function db({ flags }: { flags: Arguments }) {
const inlineConfig = flagsToAstroInlineConfig(flags);
const { astroConfig } = await resolveConfig(inlineConfig, 'build');
- await cli({ flags, config: astroConfig });
+ const yargsArgs: YargsArguments = {
+ _: positionals,
+ ...flags,
+ };
+
+ await cli({ flags: yargsArgs, config: astroConfig });
}
diff --git a/packages/astro/src/cli/dev/index.ts b/packages/astro/src/cli/dev/index.ts
index 531cddde4c..fe9a1094c6 100644
--- a/packages/astro/src/cli/dev/index.ts
+++ b/packages/astro/src/cli/dev/index.ts
@@ -1,11 +1,10 @@
import { cyan } from 'kleur/colors';
-import type yargs from 'yargs-parser';
import devServer from '../../core/dev/index.js';
import { printHelp } from '../../core/messages.js';
-import { flagsToAstroInlineConfig } from '../flags.js';
+import { type Flags, flagsToAstroInlineConfig } from '../flags.js';
interface DevOptions {
- flags: yargs.Arguments;
+ flags: Flags;
}
export async function dev({ flags }: DevOptions) {
@@ -19,6 +18,7 @@ export async function dev({ flags }: DevOptions) {
['--host', `Listen on all addresses, including LAN and public addresses.`],
['--host ', `Expose on a network IP address at `],
['--open', 'Automatically open the app in the browser on server start'],
+ ['--force', 'Clear the content layer cache, forcing a full rebuild.'],
['--help (-h)', 'See all available flags.'],
],
},
diff --git a/packages/astro/src/cli/docs/index.ts b/packages/astro/src/cli/docs/index.ts
index cd6325577b..afb5a1c622 100644
--- a/packages/astro/src/cli/docs/index.ts
+++ b/packages/astro/src/cli/docs/index.ts
@@ -1,9 +1,9 @@
-import type yargs from 'yargs-parser';
import { printHelp } from '../../core/messages.js';
+import type { Flags } from '../flags.js';
import { openInBrowser } from './open.js';
interface DocsOptions {
- flags: yargs.Arguments;
+ flags: Flags;
}
export async function docs({ flags }: DocsOptions) {
diff --git a/packages/astro/src/cli/flags.ts b/packages/astro/src/cli/flags.ts
index 0af16806df..59dfbf00a4 100644
--- a/packages/astro/src/cli/flags.ts
+++ b/packages/astro/src/cli/flags.ts
@@ -1,14 +1,18 @@
-import type { Arguments as Flags } from 'yargs-parser';
+import type { parseArgs } from 'node:util';
import type { AstroInlineConfig } from '../@types/astro.js';
import { type LogOptions, Logger } from '../core/logger/core.js';
import { nodeLogDestination } from '../core/logger/node.js';
+export type ParsedArgsResult = ReturnType;
+export type Flags = ParsedArgsResult['values'];
+
export function flagsToAstroInlineConfig(flags: Flags): AstroInlineConfig {
return {
// Inline-only configs
configFile: typeof flags.config === 'string' ? flags.config : undefined,
mode: typeof flags.mode === 'string' ? (flags.mode as AstroInlineConfig['mode']) : undefined,
logLevel: flags.verbose ? 'debug' : flags.silent ? 'silent' : undefined,
+ force: flags.force ? true : undefined,
// Astro user configs
root: typeof flags.root === 'string' ? flags.root : undefined,
@@ -16,7 +20,7 @@ export function flagsToAstroInlineConfig(flags: Flags): AstroInlineConfig {
base: typeof flags.base === 'string' ? flags.base : undefined,
outDir: typeof flags.outDir === 'string' ? flags.outDir : undefined,
server: {
- port: typeof flags.port === 'number' ? flags.port : undefined,
+ port: typeof flags.port === 'string' ? Number(flags.port) : undefined,
host:
typeof flags.host === 'string' || typeof flags.host === 'boolean' ? flags.host : undefined,
open:
diff --git a/packages/astro/src/cli/index.ts b/packages/astro/src/cli/index.ts
index 2d37132ac3..b3a819e586 100644
--- a/packages/astro/src/cli/index.ts
+++ b/packages/astro/src/cli/index.ts
@@ -1,7 +1,8 @@
+import { parseArgs } from 'node:util';
/* eslint-disable no-console */
import * as colors from 'kleur/colors';
-import yargs from 'yargs-parser';
import { ASTRO_VERSION } from '../core/constants.js';
+import type { ParsedArgsResult } from './flags.js';
type CLICommand =
| 'help'
@@ -65,9 +66,9 @@ function printVersion() {
}
/** Determine which command the user requested */
-function resolveCommand(flags: yargs.Arguments): CLICommand {
- const cmd = flags._[2] as string;
- if (flags.version) return 'version';
+function resolveCommand(args: ParsedArgsResult): CLICommand {
+ const cmd = args.positionals[2] as string;
+ if (args.values.version) return 'version';
const supportedCommands = new Set([
'add',
@@ -97,7 +98,9 @@ function resolveCommand(flags: yargs.Arguments): CLICommand {
* NOTE: This function provides no error handling, so be sure
* to present user-friendly error output where the fn is called.
**/
-async function runCommand(cmd: string, flags: yargs.Arguments) {
+async function runCommand(cmd: string, args: ParsedArgsResult) {
+ const flags = args.values;
+
// These commands can run directly without parsing the user config.
switch (cmd) {
case 'help':
@@ -120,7 +123,7 @@ async function runCommand(cmd: string, flags: yargs.Arguments) {
// Do not track session start, since the user may be trying to enable,
// disable, or modify telemetry settings.
const { update } = await import('./telemetry/index.js');
- const subcommand = flags._[3]?.toString();
+ const subcommand = args.positionals[3];
await update(subcommand, { flags });
return;
}
@@ -131,7 +134,7 @@ async function runCommand(cmd: string, flags: yargs.Arguments) {
}
case 'preferences': {
const { preferences } = await import('./preferences/index.js');
- const [subcommand, key, value] = flags._.slice(3).map((v) => v.toString());
+ const [subcommand, key, value] = args.positionals.slice(3);
const exitCode = await preferences(subcommand, key, value, { flags });
return process.exit(exitCode);
}
@@ -151,7 +154,7 @@ async function runCommand(cmd: string, flags: yargs.Arguments) {
switch (cmd) {
case 'add': {
const { add } = await import('./add/index.js');
- const packages = flags._.slice(3) as string[];
+ const packages = args.positionals.slice(3);
await add(packages, { flags });
return;
}
@@ -161,7 +164,7 @@ async function runCommand(cmd: string, flags: yargs.Arguments) {
case 'link':
case 'init': {
const { db } = await import('./db/index.js');
- await db({ flags });
+ await db({ positionals: args.positionals, flags });
return;
}
case 'dev': {
@@ -201,11 +204,21 @@ async function runCommand(cmd: string, flags: yargs.Arguments) {
}
/** The primary CLI action */
-export async function cli(args: string[]) {
- const flags = yargs(args, { boolean: ['global'], alias: { g: 'global' } });
- const cmd = resolveCommand(flags);
+export async function cli(argv: string[]) {
+ const args = parseArgs({
+ args: argv,
+ allowPositionals: true,
+ strict: false,
+ options: {
+ global: { type: 'boolean', short: 'g' },
+ host: { type: 'string' }, // Can be boolean too, which is covered by `strict: false`
+ open: { type: 'string' }, // Can be boolean too, which is covered by `strict: false`
+ // TODO: Add more flags here
+ },
+ });
+ const cmd = resolveCommand(args);
try {
- await runCommand(cmd, flags);
+ await runCommand(cmd, args);
} catch (err) {
const { throwAndExit } = await import('./throw-and-exit.js');
await throwAndExit(cmd, err);
diff --git a/packages/astro/src/cli/info/index.ts b/packages/astro/src/cli/info/index.ts
index cb61e45bfc..3fa91802f2 100644
--- a/packages/astro/src/cli/info/index.ts
+++ b/packages/astro/src/cli/info/index.ts
@@ -3,15 +3,14 @@ import { arch, platform } from 'node:os';
/* eslint-disable no-console */
import * as colors from 'kleur/colors';
import prompts from 'prompts';
-import type yargs from 'yargs-parser';
import type { AstroConfig, AstroUserConfig } from '../../@types/astro.js';
import { resolveConfig } from '../../core/config/index.js';
import { ASTRO_VERSION } from '../../core/constants.js';
import { apply as applyPolyfill } from '../../core/polyfill.js';
-import { flagsToAstroInlineConfig } from '../flags.js';
+import { type Flags, flagsToAstroInlineConfig } from '../flags.js';
interface InfoOptions {
- flags: yargs.Arguments;
+ flags: Flags;
}
export async function getInfoOutput({
diff --git a/packages/astro/src/cli/preferences/index.ts b/packages/astro/src/cli/preferences/index.ts
index 5735a9b6c2..3811e7f487 100644
--- a/packages/astro/src/cli/preferences/index.ts
+++ b/packages/astro/src/cli/preferences/index.ts
@@ -1,5 +1,4 @@
/* eslint-disable no-console */
-import type yargs from 'yargs-parser';
import type { AstroSettings } from '../../@types/astro.js';
import { fileURLToPath } from 'node:url';
@@ -15,10 +14,10 @@ import * as msg from '../../core/messages.js';
import { apply as applyPolyfill } from '../../core/polyfill.js';
import { DEFAULT_PREFERENCES } from '../../preferences/defaults.js';
import { type PreferenceKey, coerce, isValidKey } from '../../preferences/index.js';
-import { createLoggerFromFlags, flagsToAstroInlineConfig } from '../flags.js';
+import { type Flags, createLoggerFromFlags, flagsToAstroInlineConfig } from '../flags.js';
interface PreferencesOptions {
- flags: yargs.Arguments;
+ flags: Flags;
}
const PREFERENCES_SUBCOMMANDS = [
@@ -77,7 +76,7 @@ export async function preferences(
const settings = await createSettings(astroConfig, fileURLToPath(astroConfig.root));
const opts: SubcommandOptions = {
location: flags.global ? 'global' : undefined,
- json: flags.json,
+ json: !!flags.json,
};
if (subcommand === 'list') {
diff --git a/packages/astro/src/cli/preview/index.ts b/packages/astro/src/cli/preview/index.ts
index 387c1f241a..468332ce3b 100644
--- a/packages/astro/src/cli/preview/index.ts
+++ b/packages/astro/src/cli/preview/index.ts
@@ -1,11 +1,10 @@
import { cyan } from 'kleur/colors';
-import type yargs from 'yargs-parser';
import { printHelp } from '../../core/messages.js';
import previewServer from '../../core/preview/index.js';
-import { flagsToAstroInlineConfig } from '../flags.js';
+import { type Flags, flagsToAstroInlineConfig } from '../flags.js';
interface PreviewOptions {
- flags: yargs.Arguments;
+ flags: Flags;
}
export async function preview({ flags }: PreviewOptions) {
diff --git a/packages/astro/src/cli/sync/index.ts b/packages/astro/src/cli/sync/index.ts
index 6849fee708..7ffe662c5c 100644
--- a/packages/astro/src/cli/sync/index.ts
+++ b/packages/astro/src/cli/sync/index.ts
@@ -1,10 +1,9 @@
-import type yargs from 'yargs-parser';
import { printHelp } from '../../core/messages.js';
import _sync from '../../core/sync/index.js';
-import { flagsToAstroInlineConfig } from '../flags.js';
+import { type Flags, flagsToAstroInlineConfig } from '../flags.js';
interface SyncOptions {
- flags: yargs.Arguments;
+ flags: Flags;
}
export async function sync({ flags }: SyncOptions) {
@@ -13,7 +12,10 @@ export async function sync({ flags }: SyncOptions) {
commandName: 'astro sync',
usage: '[...flags]',
tables: {
- Flags: [['--help (-h)', 'See all available flags.']],
+ Flags: [
+ ['--force', 'Clear the content layer cache, forcing a full rebuild.'],
+ ['--help (-h)', 'See all available flags.'],
+ ],
},
description: `Generates TypeScript types for all Astro modules.`,
});
@@ -21,7 +23,7 @@ export async function sync({ flags }: SyncOptions) {
}
try {
- await _sync({ inlineConfig: flagsToAstroInlineConfig(flags), telemetry: true });
+ await _sync(flagsToAstroInlineConfig(flags), { telemetry: true });
return 0;
} catch (_) {
return 1;
diff --git a/packages/astro/src/cli/telemetry/index.ts b/packages/astro/src/cli/telemetry/index.ts
index 277b1cab67..276f00ef19 100644
--- a/packages/astro/src/cli/telemetry/index.ts
+++ b/packages/astro/src/cli/telemetry/index.ts
@@ -1,11 +1,10 @@
/* eslint-disable no-console */
-import type yargs from 'yargs-parser';
import * as msg from '../../core/messages.js';
import { telemetry } from '../../events/index.js';
-import { createLoggerFromFlags } from '../flags.js';
+import { type Flags, createLoggerFromFlags } from '../flags.js';
interface TelemetryOptions {
- flags: yargs.Arguments;
+ flags: Flags;
}
export async function notify() {
diff --git a/packages/astro/src/container/index.ts b/packages/astro/src/container/index.ts
index 645b8bb134..ca83e8a1f6 100644
--- a/packages/astro/src/container/index.ts
+++ b/packages/astro/src/container/index.ts
@@ -17,6 +17,7 @@ import type {
import { getDefaultClientDirectives } from '../core/client-directive/index.js';
import { ASTRO_CONFIG_DEFAULTS } from '../core/config/schema.js';
import { validateConfig } from '../core/config/validate.js';
+import { createKey } from '../core/encryption.js';
import { Logger } from '../core/logger/core.js';
import { nodeLogDestination } from '../core/logger/node.js';
import { removeLeadingForwardSlash } from '../core/path.js';
@@ -130,6 +131,7 @@ function createManifest(
checkOrigin: false,
middleware: manifest?.middleware ?? middleware ?? defaultMiddleware,
envGetSecretEnabled: false,
+ key: createKey(),
};
}
diff --git a/packages/astro/src/content/consts.ts b/packages/astro/src/content/consts.ts
index f65652453b..ac619c2b5e 100644
--- a/packages/astro/src/content/consts.ts
+++ b/packages/astro/src/content/consts.ts
@@ -2,18 +2,42 @@ export const PROPAGATED_ASSET_FLAG = 'astroPropagatedAssets';
export const CONTENT_RENDER_FLAG = 'astroRenderContent';
export const CONTENT_FLAG = 'astroContentCollectionEntry';
export const DATA_FLAG = 'astroDataCollectionEntry';
+export const CONTENT_IMAGE_FLAG = 'astroContentImageFlag';
+export const CONTENT_MODULE_FLAG = 'astroContentModuleFlag';
export const VIRTUAL_MODULE_ID = 'astro:content';
export const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;
+export const DATA_STORE_VIRTUAL_ID = 'astro:data-layer-content';
+export const RESOLVED_DATA_STORE_VIRTUAL_ID = '\0' + DATA_STORE_VIRTUAL_ID;
+
+// Used by the content layer to create a virtual module that loads the `modules.mjs`, a file created by the content layer
+// to map modules that are renderer at runtime
+export const MODULES_MJS_ID = 'astro:content-module-imports';
+export const MODULES_MJS_VIRTUAL_ID = '\0' + MODULES_MJS_ID;
+
+export const DEFERRED_MODULE = 'astro:content-layer-deferred-module';
+
+// Used by the content layer to create a virtual module that loads the `assets.mjs`
+export const ASSET_IMPORTS_VIRTUAL_ID = 'astro:asset-imports';
+export const ASSET_IMPORTS_RESOLVED_STUB_ID = '\0' + ASSET_IMPORTS_VIRTUAL_ID;
export const LINKS_PLACEHOLDER = '@@ASTRO-LINKS@@';
export const STYLES_PLACEHOLDER = '@@ASTRO-STYLES@@';
export const SCRIPTS_PLACEHOLDER = '@@ASTRO-SCRIPTS@@';
+export const IMAGE_IMPORT_PREFIX = '__ASTRO_IMAGE_';
export const CONTENT_FLAGS = [
CONTENT_FLAG,
CONTENT_RENDER_FLAG,
DATA_FLAG,
PROPAGATED_ASSET_FLAG,
+ CONTENT_IMAGE_FLAG,
+ CONTENT_MODULE_FLAG,
] as const;
-export const CONTENT_TYPES_FILE = 'types.d.ts';
+export const CONTENT_TYPES_FILE = 'astro/content.d.ts';
+
+export const DATA_STORE_FILE = 'data-store.json';
+export const ASSET_IMPORTS_FILE = 'assets.mjs';
+export const MODULES_IMPORTS_FILE = 'modules.mjs';
+
+export const CONTENT_LAYER_TYPE = 'content_layer';
diff --git a/packages/astro/src/content/content-layer.ts b/packages/astro/src/content/content-layer.ts
new file mode 100644
index 0000000000..9a6d4ed375
--- /dev/null
+++ b/packages/astro/src/content/content-layer.ts
@@ -0,0 +1,306 @@
+import { promises as fs, existsSync } from 'node:fs';
+import { isAbsolute } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import type { FSWatcher } from 'vite';
+import xxhash from 'xxhash-wasm';
+import type { AstroSettings } from '../@types/astro.js';
+import { AstroUserError } from '../core/errors/errors.js';
+import type { Logger } from '../core/logger/core.js';
+import {
+ ASSET_IMPORTS_FILE,
+ CONTENT_LAYER_TYPE,
+ DATA_STORE_FILE,
+ MODULES_IMPORTS_FILE,
+} from './consts.js';
+import type { DataStore } from './data-store.js';
+import type { LoaderContext } from './loaders/types.js';
+import { getEntryDataAndImages, globalContentConfigObserver, posixRelative } from './utils.js';
+
+export interface ContentLayerOptions {
+ store: DataStore;
+ settings: AstroSettings;
+ logger: Logger;
+ watcher?: FSWatcher;
+}
+
+export class ContentLayer {
+ #logger: Logger;
+ #store: DataStore;
+ #settings: AstroSettings;
+ #watcher?: FSWatcher;
+ #lastConfigDigest?: string;
+ #unsubscribe?: () => void;
+
+ #generateDigest?: (data: Record | string) => string;
+
+ #loading = false;
+ constructor({ settings, logger, store, watcher }: ContentLayerOptions) {
+ // The default max listeners is 10, which can be exceeded when using a lot of loaders
+ watcher?.setMaxListeners(50);
+
+ this.#logger = logger;
+ this.#store = store;
+ this.#settings = settings;
+ this.#watcher = watcher;
+ }
+
+ /**
+ * Whether the content layer is currently loading content
+ */
+ get loading() {
+ return this.#loading;
+ }
+
+ /**
+ * Watch for changes to the content config and trigger a sync when it changes.
+ */
+ watchContentConfig() {
+ this.#unsubscribe?.();
+ this.#unsubscribe = globalContentConfigObserver.subscribe(async (ctx) => {
+ if (
+ !this.#loading &&
+ ctx.status === 'loaded' &&
+ ctx.config.digest !== this.#lastConfigDigest
+ ) {
+ this.sync();
+ }
+ });
+ }
+
+ unwatchContentConfig() {
+ this.#unsubscribe?.();
+ }
+
+ /**
+ * Run the `load()` method of each collection's loader, which will load the data and save it in the data store.
+ * The loader itself is responsible for deciding whether this will clear and reload the full collection, or
+ * perform an incremental update. After the data is loaded, the data store is written to disk.
+ */
+ async sync() {
+ if (this.#loading) {
+ return;
+ }
+ this.#loading = true;
+ try {
+ await this.#doSync();
+ } finally {
+ this.#loading = false;
+ }
+ }
+
+ async #getGenerateDigest() {
+ if (this.#generateDigest) {
+ return this.#generateDigest;
+ }
+ // xxhash is a very fast non-cryptographic hash function that is used to generate a content digest
+ // It uses wasm, so we need to load it asynchronously.
+ const { h64ToString } = await xxhash();
+
+ this.#generateDigest = (data: Record | string) => {
+ const dataString = typeof data === 'string' ? data : JSON.stringify(data);
+ return h64ToString(dataString);
+ };
+
+ return this.#generateDigest;
+ }
+
+ async #getLoaderContext({
+ collectionName,
+ loaderName = 'content',
+ parseData,
+ }: {
+ collectionName: string;
+ loaderName: string;
+ parseData: LoaderContext['parseData'];
+ }): Promise {
+ return {
+ collection: collectionName,
+ store: this.#store.scopedStore(collectionName),
+ meta: this.#store.metaStore(collectionName),
+ logger: this.#logger.forkIntegrationLogger(loaderName),
+ settings: this.#settings,
+ parseData,
+ generateDigest: await this.#getGenerateDigest(),
+ watcher: this.#watcher,
+ };
+ }
+
+ async #doSync() {
+ const contentConfig = globalContentConfigObserver.get();
+ const logger = this.#logger.forkIntegrationLogger('content');
+ if (contentConfig?.status !== 'loaded') {
+ logger.debug('Content config not loaded, skipping sync');
+ return;
+ }
+ if (!this.#settings.config.experimental.contentLayer) {
+ const contentLayerCollections = Object.entries(contentConfig.config.collections).filter(
+ ([_, collection]) => collection.type === CONTENT_LAYER_TYPE,
+ );
+ if (contentLayerCollections.length > 0) {
+ throw new AstroUserError(
+ `The following collections have a loader defined, but the content layer is not enabled: ${contentLayerCollections.map(([title]) => title).join(', ')}.`,
+ 'To enable the Content Layer API, set `experimental: { contentLayer: true }` in your Astro config file.',
+ );
+ }
+ return;
+ }
+
+ logger.info('Syncing content');
+ const { digest: currentConfigDigest } = contentConfig.config;
+ this.#lastConfigDigest = currentConfigDigest;
+
+ const previousConfigDigest = await this.#store.metaStore().get('config-digest');
+ if (currentConfigDigest && previousConfigDigest !== currentConfigDigest) {
+ logger.info('Content config changed, clearing cache');
+ this.#store.clearAll();
+ await this.#store.metaStore().set('config-digest', currentConfigDigest);
+ }
+
+ await Promise.all(
+ Object.entries(contentConfig.config.collections).map(async ([name, collection]) => {
+ if (collection.type !== CONTENT_LAYER_TYPE) {
+ return;
+ }
+
+ let { schema } = collection;
+
+ if (!schema && typeof collection.loader === 'object') {
+ schema = collection.loader.schema;
+ if (typeof schema === 'function') {
+ schema = await schema();
+ }
+ }
+
+ const collectionWithResolvedSchema = { ...collection, schema };
+
+ const parseData: LoaderContext['parseData'] = async ({ id, data, filePath = '' }) => {
+ const { imageImports, data: parsedData } = await getEntryDataAndImages(
+ {
+ id,
+ collection: name,
+ unvalidatedData: data,
+ _internal: {
+ rawData: undefined,
+ filePath,
+ },
+ },
+ collectionWithResolvedSchema,
+ false,
+ );
+ if (imageImports?.length) {
+ this.#store.addAssetImports(
+ imageImports,
+ // This path may already be relative, if we're re-parsing an existing entry
+ isAbsolute(filePath)
+ ? posixRelative(fileURLToPath(this.#settings.config.root), filePath)
+ : filePath,
+ );
+ }
+
+ return parsedData;
+ };
+
+ const context = await this.#getLoaderContext({
+ collectionName: name,
+ parseData,
+ loaderName: collection.loader.name,
+ });
+
+ if (typeof collection.loader === 'function') {
+ return simpleLoader(collection.loader, context);
+ }
+
+ if (!collection.loader.load) {
+ throw new Error(`Collection loader for ${name} does not have a load method`);
+ }
+
+ return collection.loader.load(context);
+ }),
+ );
+ if (!existsSync(this.#settings.config.cacheDir)) {
+ await fs.mkdir(this.#settings.config.cacheDir, { recursive: true });
+ }
+ const cacheFile = new URL(DATA_STORE_FILE, this.#settings.config.cacheDir);
+ await this.#store.writeToDisk(cacheFile);
+ if (!existsSync(this.#settings.dotAstroDir)) {
+ await fs.mkdir(this.#settings.dotAstroDir, { recursive: true });
+ }
+ const assetImportsFile = new URL(ASSET_IMPORTS_FILE, this.#settings.dotAstroDir);
+ await this.#store.writeAssetImports(assetImportsFile);
+ const modulesImportsFile = new URL(MODULES_IMPORTS_FILE, this.#settings.dotAstroDir);
+ await this.#store.writeModuleImports(modulesImportsFile);
+ logger.info('Synced content');
+ if (this.#settings.config.experimental.contentIntellisense) {
+ await this.regenerateCollectionFileManifest();
+ }
+ }
+
+ async regenerateCollectionFileManifest() {
+ const collectionsManifest = new URL('collections/collections.json', this.#settings.dotAstroDir);
+ this.#logger.debug('content', 'Regenerating collection file manifest');
+ if (existsSync(collectionsManifest)) {
+ try {
+ const collections = await fs.readFile(collectionsManifest, 'utf-8');
+ const collectionsJson = JSON.parse(collections);
+ collectionsJson.entries ??= {};
+
+ for (const { hasSchema, name } of collectionsJson.collections) {
+ if (!hasSchema) {
+ continue;
+ }
+ const entries = this.#store.values(name);
+ if (!entries?.[0]?.filePath) {
+ continue;
+ }
+ for (const { filePath } of entries) {
+ if (!filePath) {
+ continue;
+ }
+ const key = new URL(filePath, this.#settings.config.root).href.toLowerCase();
+ collectionsJson.entries[key] = name;
+ }
+ }
+ await fs.writeFile(collectionsManifest, JSON.stringify(collectionsJson, null, 2));
+ } catch {
+ this.#logger.error('content', 'Failed to regenerate collection file manifest');
+ }
+ }
+ this.#logger.debug('content', 'Regenerated collection file manifest');
+ }
+}
+
+export async function simpleLoader(
+ handler: () => Array | Promise>,
+ context: LoaderContext,
+) {
+ const data = await handler();
+ context.store.clear();
+ for (const raw of data) {
+ const item = await context.parseData({ id: raw.id, data: raw });
+ context.store.set({ id: raw.id, data: item });
+ }
+}
+
+function contentLayerSingleton() {
+ let instance: ContentLayer | null = null;
+ return {
+ initialized: () => Boolean(instance),
+ init: (options: ContentLayerOptions) => {
+ instance?.unwatchContentConfig();
+ instance = new ContentLayer(options);
+ return instance;
+ },
+ get: () => {
+ if (!instance) {
+ throw new Error('Content layer not initialized');
+ }
+ return instance;
+ },
+ dispose: () => {
+ instance?.unwatchContentConfig();
+ instance = null;
+ },
+ };
+}
+
+export const globalContentLayer = contentLayerSingleton();
diff --git a/packages/astro/src/content/data-store.ts b/packages/astro/src/content/data-store.ts
new file mode 100644
index 0000000000..f416082f9d
--- /dev/null
+++ b/packages/astro/src/content/data-store.ts
@@ -0,0 +1,467 @@
+import type { MarkdownHeading } from '@astrojs/markdown-remark';
+import * as devalue from 'devalue';
+import { existsSync, promises as fs, type PathLike } from 'fs';
+import { imageSrcToImportId, importIdToSymbolName } from '../assets/utils/resolveImports.js';
+import { AstroError, AstroErrorData } from '../core/errors/index.js';
+import { CONTENT_MODULE_FLAG, DEFERRED_MODULE } from './consts.js';
+
+const SAVE_DEBOUNCE_MS = 500;
+
+export interface RenderedContent {
+ /** Rendered HTML string. If present then `render(entry)` will return a component that renders this HTML. */
+ html: string;
+ metadata?: {
+ /** Any images that are present in this entry. Relative to the {@link DataEntry} filePath. */
+ imagePaths?: Array;
+ /** Any headings that are present in this file. */
+ headings?: MarkdownHeading[];
+ /** Raw frontmatter, parsed parsed from the file. This may include data from remark plugins. */
+ frontmatter?: Record;
+ /** Any other metadata that is present in this file. */
+ [key: string]: unknown;
+ };
+}
+
+export interface DataEntry = Record> {
+ /** The ID of the entry. Unique per collection. */
+ id: string;
+ /** The parsed entry data */
+ data: TData;
+ /** The file path of the content, if applicable. Relative to the site root. */
+ filePath?: string;
+ /** The raw body of the content, if applicable. */
+ body?: string;
+ /** An optional content digest, to check if the content has changed. */
+ digest?: number | string;
+ /** The rendered content of the entry, if applicable. */
+ rendered?: RenderedContent;
+ /**
+ * If an entry is a deferred, its rendering phase is delegated to a virtual module during the runtime phase when calling `renderEntry`.
+ */
+ deferredRender?: boolean;
+}
+
+export class DataStore {
+ #collections = new Map>();
+
+ #file?: PathLike;
+
+ #assetsFile?: PathLike;
+ #modulesFile?: PathLike;
+
+ #saveTimeout: NodeJS.Timeout | undefined;
+ #assetsSaveTimeout: NodeJS.Timeout | undefined;
+ #modulesSaveTimeout: NodeJS.Timeout | undefined;
+
+ #dirty = false;
+ #assetsDirty = false;
+ #modulesDirty = false;
+
+ #assetImports = new Set();
+ #moduleImports = new Map();
+
+ constructor() {
+ this.#collections = new Map();
+ }
+
+ get(collectionName: string, key: string): T | undefined {
+ return this.#collections.get(collectionName)?.get(String(key));
+ }
+
+ entries(collectionName: string): Array<[id: string, T]> {
+ const collection = this.#collections.get(collectionName) ?? new Map();
+ return [...collection.entries()];
+ }
+
+ values(collectionName: string): Array {
+ const collection = this.#collections.get(collectionName) ?? new Map();
+ return [...collection.values()];
+ }
+
+ keys(collectionName: string): Array {
+ const collection = this.#collections.get(collectionName) ?? new Map();
+ return [...collection.keys()];
+ }
+
+ set(collectionName: string, key: string, value: unknown) {
+ const collection = this.#collections.get(collectionName) ?? new Map();
+ collection.set(String(key), value);
+ this.#collections.set(collectionName, collection);
+ this.#saveToDiskDebounced();
+ }
+
+ delete(collectionName: string, key: string) {
+ const collection = this.#collections.get(collectionName);
+ if (collection) {
+ collection.delete(String(key));
+ this.#saveToDiskDebounced();
+ }
+ }
+
+ clear(collectionName: string) {
+ this.#collections.delete(collectionName);
+ this.#saveToDiskDebounced();
+ }
+
+ clearAll() {
+ this.#collections.clear();
+ this.#saveToDiskDebounced();
+ }
+
+ has(collectionName: string, key: string) {
+ const collection = this.#collections.get(collectionName);
+ if (collection) {
+ return collection.has(String(key));
+ }
+ return false;
+ }
+
+ hasCollection(collectionName: string) {
+ return this.#collections.has(collectionName);
+ }
+
+ collections() {
+ return this.#collections;
+ }
+
+ addAssetImport(assetImport: string, filePath: string) {
+ const id = imageSrcToImportId(assetImport, filePath);
+ if (id) {
+ this.#assetImports.add(id);
+ // We debounce the writes to disk because addAssetImport is called for every image in every file,
+ // and can be called many times in quick succession by a filesystem watcher. We only want to write
+ // the file once, after all the imports have been added.
+ this.#writeAssetsImportsDebounced();
+ }
+ }
+
+ addAssetImports(assets: Array, filePath: string) {
+ assets.forEach((asset) => this.addAssetImport(asset, filePath));
+ }
+
+ addModuleImport(fileName: string) {
+ const id = contentModuleToId(fileName);
+ if (id) {
+ this.#moduleImports.set(fileName, id);
+ // We debounce the writes to disk because addAssetImport is called for every image in every file,
+ // and can be called many times in quick succession by a filesystem watcher. We only want to write
+ // the file once, after all the imports have been added.
+ this.#writeModulesImportsDebounced();
+ }
+ }
+
+ async writeAssetImports(filePath: PathLike) {
+ this.#assetsFile = filePath;
+
+ if (this.#assetImports.size === 0) {
+ try {
+ await fs.writeFile(filePath, 'export default new Map();');
+ } catch (err) {
+ throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause: err });
+ }
+ }
+
+ if (!this.#assetsDirty && existsSync(filePath)) {
+ return;
+ }
+ // Import the assets, with a symbol name that is unique to the import id. The import
+ // for each asset is an object with path, format and dimensions.
+ // We then export them all, mapped by the import id, so we can find them again in the build.
+ const imports: Array = [];
+ const exports: Array = [];
+ this.#assetImports.forEach((id) => {
+ const symbol = importIdToSymbolName(id);
+ imports.push(`import ${symbol} from '${id}';`);
+ exports.push(`[${JSON.stringify(id)}, ${symbol}]`);
+ });
+ const code = /* js */ `
+${imports.join('\n')}
+export default new Map([${exports.join(', ')}]);
+ `;
+ try {
+ await fs.writeFile(filePath, code);
+ } catch (err) {
+ throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause: err });
+ }
+ this.#assetsDirty = false;
+ }
+
+ async writeModuleImports(filePath: PathLike) {
+ this.#modulesFile = filePath;
+
+ if (this.#moduleImports.size === 0) {
+ try {
+ await fs.writeFile(filePath, 'export default new Map();');
+ } catch (err) {
+ throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause: err });
+ }
+ }
+
+ if (!this.#modulesDirty && existsSync(filePath)) {
+ return;
+ }
+
+ // Import the assets, with a symbol name that is unique to the import id. The import
+ // for each asset is an object with path, format and dimensions.
+ // We then export them all, mapped by the import id, so we can find them again in the build.
+ const lines: Array = [];
+ for (const [fileName, specifier] of this.#moduleImports) {
+ lines.push(`['${fileName}', () => import('${specifier}')]`);
+ }
+ const code = `
+export default new Map([\n${lines.join(',\n')}]);
+ `;
+ try {
+ await fs.writeFile(filePath, code);
+ } catch (err) {
+ throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause: err });
+ }
+ this.#modulesDirty = false;
+ }
+
+ #writeAssetsImportsDebounced() {
+ this.#assetsDirty = true;
+ if (this.#assetsFile) {
+ if (this.#assetsSaveTimeout) {
+ clearTimeout(this.#assetsSaveTimeout);
+ }
+ this.#assetsSaveTimeout = setTimeout(() => {
+ this.#assetsSaveTimeout = undefined;
+ this.writeAssetImports(this.#assetsFile!);
+ }, SAVE_DEBOUNCE_MS);
+ }
+ }
+
+ #writeModulesImportsDebounced() {
+ this.#modulesDirty = true;
+ if (this.#modulesFile) {
+ if (this.#modulesSaveTimeout) {
+ clearTimeout(this.#modulesSaveTimeout);
+ }
+ this.#modulesSaveTimeout = setTimeout(() => {
+ this.#modulesSaveTimeout = undefined;
+ this.writeModuleImports(this.#modulesFile!);
+ }, SAVE_DEBOUNCE_MS);
+ }
+ }
+
+ #saveToDiskDebounced() {
+ this.#dirty = true;
+ // Only save to disk if it has already been saved once
+ if (this.#file) {
+ if (this.#saveTimeout) {
+ clearTimeout(this.#saveTimeout);
+ }
+ this.#saveTimeout = setTimeout(() => {
+ this.#saveTimeout = undefined;
+ this.writeToDisk(this.#file!);
+ }, SAVE_DEBOUNCE_MS);
+ }
+ }
+
+ scopedStore(collectionName: string): ScopedDataStore {
+ return {
+ get: = Record>(key: string) =>
+ this.get>(collectionName, key),
+ entries: () => this.entries(collectionName),
+ values: () => this.values(collectionName),
+ keys: () => this.keys(collectionName),
+ set: ({ id: key, data, body, filePath, deferredRender, digest, rendered }) => {
+ if (!key) {
+ throw new Error(`ID must be a non-empty string`);
+ }
+ const id = String(key);
+ if (digest) {
+ const existing = this.get(collectionName, id);
+ if (existing && existing.digest === digest) {
+ return false;
+ }
+ }
+ const entry: DataEntry = {
+ id,
+ data,
+ };
+ // We do it like this so we don't waste space stringifying
+ // the fields if they are not set
+ if (body) {
+ entry.body = body;
+ }
+ if (filePath) {
+ if (filePath.startsWith('/')) {
+ throw new Error(`File path must be relative to the site root. Got: ${filePath}`);
+ }
+ entry.filePath = filePath;
+ }
+ if (digest) {
+ entry.digest = digest;
+ }
+ if (rendered) {
+ entry.rendered = rendered;
+ }
+ if (deferredRender) {
+ entry.deferredRender = deferredRender;
+ if (filePath) {
+ this.addModuleImport(filePath);
+ }
+ }
+ this.set(collectionName, id, entry);
+ return true;
+ },
+ delete: (key: string) => this.delete(collectionName, key),
+ clear: () => this.clear(collectionName),
+ has: (key: string) => this.has(collectionName, key),
+ addAssetImport: (assetImport: string, fileName: string) =>
+ this.addAssetImport(assetImport, fileName),
+ addAssetImports: (assets: Array, fileName: string) =>
+ this.addAssetImports(assets, fileName),
+ addModuleImport: (fileName: string) => this.addModuleImport(fileName),
+ };
+ }
+ /**
+ * Returns a MetaStore for a given collection, or if no collection is provided, the default meta collection.
+ */
+ metaStore(collectionName = ':meta'): MetaStore {
+ const collectionKey = `meta:${collectionName}`;
+ return {
+ get: (key: string) => this.get(collectionKey, key),
+ set: (key: string, data: string) => this.set(collectionKey, key, data),
+ delete: (key: string) => this.delete(collectionKey, key),
+ has: (key: string) => this.has(collectionKey, key),
+ };
+ }
+
+ toString() {
+ return devalue.stringify(this.#collections);
+ }
+
+ async writeToDisk(filePath: PathLike) {
+ if (!this.#dirty) {
+ return;
+ }
+ try {
+ await fs.writeFile(filePath, this.toString());
+ this.#file = filePath;
+ this.#dirty = false;
+ } catch (err) {
+ throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause: err });
+ }
+ }
+
+ /**
+ * Attempts to load a DataStore from the virtual module.
+ * This only works in Vite.
+ */
+ static async fromModule() {
+ try {
+ // @ts-expect-error - this is a virtual module
+ const data = await import('astro:data-layer-content');
+ const map = devalue.unflatten(data.default);
+ return DataStore.fromMap(map);
+ } catch {}
+ return new DataStore();
+ }
+
+ static async fromMap(data: Map>) {
+ const store = new DataStore();
+ store.#collections = data;
+ return store;
+ }
+
+ static async fromString(data: string) {
+ const map = devalue.parse(data);
+ return DataStore.fromMap(map);
+ }
+
+ static async fromFile(filePath: string | URL) {
+ try {
+ if (existsSync(filePath)) {
+ const data = await fs.readFile(filePath, 'utf-8');
+ return DataStore.fromString(data);
+ }
+ } catch {}
+ return new DataStore();
+ }
+}
+
+export interface ScopedDataStore {
+ get: = Record>(
+ key: string,
+ ) => DataEntry | undefined;
+ entries: () => Array<[id: string, DataEntry]>;
+ set: >(opts: {
+ /** The ID of the entry. Must be unique per collection. */
+ id: string;
+ /** The data to store. */
+ data: TData;
+ /** The raw body of the content, if applicable. */
+ body?: string;
+ /** The file path of the content, if applicable. Relative to the site root. */
+ filePath?: string;
+ /** A content digest, to check if the content has changed. */
+ digest?: number | string;
+ /** The rendered content, if applicable. */
+ rendered?: RenderedContent;
+ /**
+ * If an entry is a deferred, its rendering phase is delegated to a virtual module during the runtime phase.
+ */
+ deferredRender?: boolean;
+ }) => boolean;
+ values: () => Array;
+ keys: () => Array;
+ delete: (key: string) => void;
+ clear: () => void;
+ has: (key: string) => boolean;
+ /**
+ * @internal Adds asset imports to the store. This is used to track image imports for the build. This API is subject to change.
+ */
+ addAssetImports: (assets: Array, fileName: string) => void;
+ /**
+ * @internal Adds an asset import to the store. This is used to track image imports for the build. This API is subject to change.
+ */
+ addAssetImport: (assetImport: string, fileName: string) => void;
+ /**
+ * Adds a single asset to the store. This asset will be transformed
+ * by Vite, and the URL will be available in the final build.
+ * @param fileName
+ * @param specifier
+ * @returns
+ */
+ addModuleImport: (fileName: string) => void;
+}
+
+/**
+ * A key-value store for metadata strings. Useful for storing things like sync tokens.
+ */
+
+export interface MetaStore {
+ get: (key: string) => string | undefined;
+ set: (key: string, value: string) => void;
+ has: (key: string) => boolean;
+ delete: (key: string) => void;
+}
+
+function dataStoreSingleton() {
+ let instance: Promise | DataStore | undefined = undefined;
+ return {
+ get: async () => {
+ if (!instance) {
+ instance = DataStore.fromModule();
+ }
+ return instance;
+ },
+ set: (store: DataStore) => {
+ instance = store;
+ },
+ };
+}
+
+// TODO: find a better place to put this image
+export function contentModuleToId(fileName: string) {
+ const params = new URLSearchParams(DEFERRED_MODULE);
+ params.set('fileName', fileName);
+ params.set(CONTENT_MODULE_FLAG, 'true');
+ return `${DEFERRED_MODULE}?${params.toString()}`;
+}
+
+/** @internal */
+export const globalDataStore = dataStoreSingleton();
diff --git a/packages/astro/src/content/loaders/file.ts b/packages/astro/src/content/loaders/file.ts
new file mode 100644
index 0000000000..cbc684a997
--- /dev/null
+++ b/packages/astro/src/content/loaders/file.ts
@@ -0,0 +1,83 @@
+import { promises as fs, existsSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { posixRelative } from '../utils.js';
+import type { Loader, LoaderContext } from './types.js';
+
+/**
+ * Loads entries from a JSON file. The file must contain an array of objects that contain unique `id` fields, or an object with string keys.
+ * @todo Add support for other file types, such as YAML, CSV etc.
+ * @param fileName The path to the JSON file to load, relative to the content directory.
+ */
+export function file(fileName: string): Loader {
+ if (fileName.includes('*')) {
+ // TODO: AstroError
+ throw new Error('Glob patterns are not supported in `file` loader. Use `glob` loader instead.');
+ }
+
+ async function syncData(filePath: string, { logger, parseData, store, settings }: LoaderContext) {
+ let json: Array>;
+
+ try {
+ const data = await fs.readFile(filePath, 'utf-8');
+ json = JSON.parse(data);
+ } catch (error: any) {
+ logger.error(`Error reading data from ${fileName}`);
+ logger.debug(error.message);
+ return;
+ }
+
+ if (Array.isArray(json)) {
+ if (json.length === 0) {
+ logger.warn(`No items found in ${fileName}`);
+ }
+ logger.debug(`Found ${json.length} item array in ${fileName}`);
+ store.clear();
+ for (const rawItem of json) {
+ const id = (rawItem.id ?? rawItem.slug)?.toString();
+ if (!id) {
+ logger.error(`Item in ${fileName} is missing an id or slug field.`);
+ continue;
+ }
+ const data = await parseData({ id, data: rawItem, filePath });
+ store.set({
+ id,
+ data,
+ filePath: posixRelative(fileURLToPath(settings.config.root), filePath),
+ });
+ }
+ } else if (typeof json === 'object') {
+ const entries = Object.entries>(json);
+ logger.debug(`Found object with ${entries.length} entries in ${fileName}`);
+ store.clear();
+ for (const [id, rawItem] of entries) {
+ const data = await parseData({ id, data: rawItem, filePath });
+ store.set({ id, data });
+ }
+ } else {
+ logger.error(`Invalid data in ${fileName}. Must be an array or object.`);
+ }
+ }
+
+ return {
+ name: 'file-loader',
+ load: async (options) => {
+ const { settings, logger, watcher } = options;
+ logger.debug(`Loading data from ${fileName}`);
+ const url = new URL(fileName, settings.config.root);
+ if (!existsSync(url)) {
+ logger.error(`File not found: ${fileName}`);
+ return;
+ }
+ const filePath = fileURLToPath(url);
+
+ await syncData(filePath, options);
+
+ watcher?.on('change', async (changedPath) => {
+ if (changedPath === filePath) {
+ logger.info(`Reloading data from ${fileName}`);
+ await syncData(filePath, options);
+ }
+ });
+ },
+ };
+}
diff --git a/packages/astro/src/content/loaders/glob.ts b/packages/astro/src/content/loaders/glob.ts
new file mode 100644
index 0000000000..4a4ecbcac8
--- /dev/null
+++ b/packages/astro/src/content/loaders/glob.ts
@@ -0,0 +1,296 @@
+import { promises as fs } from 'node:fs';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import fastGlob from 'fast-glob';
+import { bold, green } from 'kleur/colors';
+import micromatch from 'micromatch';
+import pLimit from 'p-limit';
+import type { ContentEntryRenderFuction, ContentEntryType } from '../../@types/astro.js';
+import type { RenderedContent } from '../data-store.js';
+import { getContentEntryIdAndSlug, getEntryConfigByExtMap, posixRelative } from '../utils.js';
+import type { Loader } from './types.js';
+
+export interface GenerateIdOptions {
+ /** The path to the entry file, relative to the base directory. */
+ entry: string;
+
+ /** The base directory URL. */
+ base: URL;
+ /** The parsed, unvalidated data of the entry. */
+ data: Record;
+}
+
+export interface GlobOptions {
+ /** The glob pattern to match files, relative to the base directory */
+ pattern: string;
+ /** The base directory to resolve the glob pattern from. Relative to the root directory, or an absolute file URL. Defaults to `.` */
+ base?: string | URL;
+ /**
+ * Function that generates an ID for an entry. Default implementation generates a slug from the entry path.
+ * @returns The ID of the entry. Must be unique per collection.
+ **/
+ generateId?: (options: GenerateIdOptions) => string;
+}
+
+function generateIdDefault({ entry, base, data }: GenerateIdOptions): string {
+ if (data.slug) {
+ return data.slug as string;
+ }
+ const entryURL = new URL(entry, base);
+ const { slug } = getContentEntryIdAndSlug({
+ entry: entryURL,
+ contentDir: base,
+ collection: '',
+ });
+ return slug;
+}
+
+/**
+ * Loads multiple entries, using a glob pattern to match files.
+ * @param pattern A glob pattern to match files, relative to the content directory.
+ */
+export function glob(globOptions: GlobOptions): Loader {
+ if (globOptions.pattern.startsWith('../')) {
+ throw new Error(
+ 'Glob patterns cannot start with `../`. Set the `base` option to a parent directory instead.',
+ );
+ }
+ if (globOptions.pattern.startsWith('/')) {
+ throw new Error(
+ 'Glob patterns cannot start with `/`. Set the `base` option to a parent directory or use a relative path instead.',
+ );
+ }
+
+ const generateId = globOptions?.generateId ?? generateIdDefault;
+
+ const fileToIdMap = new Map();
+
+ return {
+ name: 'glob-loader',
+ load: async ({ settings, logger, watcher, parseData, store, generateDigest }) => {
+ const renderFunctionByContentType = new WeakMap<
+ ContentEntryType,
+ ContentEntryRenderFuction
+ >();
+
+ const untouchedEntries = new Set(store.keys());
+
+ async function syncData(entry: string, base: URL, entryType?: ContentEntryType) {
+ if (!entryType) {
+ logger.warn(`No entry type found for ${entry}`);
+ return;
+ }
+ const fileUrl = new URL(entry, base);
+ const contents = await fs.readFile(fileUrl, 'utf-8').catch((err) => {
+ logger.error(`Error reading ${entry}: ${err.message}`);
+ return;
+ });
+
+ if (!contents) {
+ logger.warn(`No contents found for ${entry}`);
+ return;
+ }
+
+ const { body, data } = await entryType.getEntryInfo({
+ contents,
+ fileUrl,
+ });
+
+ const id = generateId({ entry, base, data });
+ untouchedEntries.delete(id);
+
+ const existingEntry = store.get(id);
+
+ const digest = generateDigest(contents);
+
+ if (existingEntry && existingEntry.digest === digest && existingEntry.filePath) {
+ if (existingEntry.deferredRender) {
+ store.addModuleImport(existingEntry.filePath);
+ }
+
+ if (existingEntry.rendered?.metadata?.imagePaths?.length) {
+ // Add asset imports for existing entries
+ store.addAssetImports(
+ existingEntry.rendered.metadata.imagePaths,
+ existingEntry.filePath,
+ );
+ }
+ // Re-parsing to resolve images and other effects
+ await parseData(existingEntry);
+ return;
+ }
+
+ const filePath = fileURLToPath(fileUrl);
+
+ const relativePath = posixRelative(fileURLToPath(settings.config.root), filePath);
+
+ const parsedData = await parseData({
+ id,
+ data,
+ filePath,
+ });
+ if (entryType.getRenderFunction) {
+ let render = renderFunctionByContentType.get(entryType);
+ if (!render) {
+ render = await entryType.getRenderFunction(settings);
+ // Cache the render function for this content type, so it can re-use parsers and other expensive setup
+ renderFunctionByContentType.set(entryType, render);
+ }
+ let rendered: RenderedContent | undefined = undefined;
+
+ try {
+ rendered = await render?.({
+ id,
+ data: parsedData,
+ body,
+ filePath,
+ digest,
+ });
+ } catch (error: any) {
+ logger.error(`Error rendering ${entry}: ${error.message}`);
+ }
+
+ store.set({
+ id,
+ data: parsedData,
+ body,
+ filePath: relativePath,
+ digest,
+ rendered,
+ });
+ if (rendered?.metadata?.imagePaths?.length) {
+ store.addAssetImports(rendered.metadata.imagePaths, relativePath);
+ }
+ // todo: add an explicit way to opt in to deferred rendering
+ } else if ('contentModuleTypes' in entryType) {
+ store.set({
+ id,
+ data: parsedData,
+ body,
+ filePath: relativePath,
+ digest,
+ deferredRender: true,
+ });
+ } else {
+ store.set({ id, data: parsedData, body, filePath: relativePath, digest });
+ }
+
+ fileToIdMap.set(filePath, id);
+ }
+
+ const entryConfigByExt = getEntryConfigByExtMap([
+ ...settings.contentEntryTypes,
+ ...settings.dataEntryTypes,
+ ] as Array);
+
+ const baseDir = globOptions.base
+ ? new URL(globOptions.base, settings.config.root)
+ : settings.config.root;
+
+ if (!baseDir.pathname.endsWith('/')) {
+ baseDir.pathname = `${baseDir.pathname}/`;
+ }
+
+ const files = await fastGlob(globOptions.pattern, {
+ cwd: fileURLToPath(baseDir),
+ });
+
+ function configForFile(file: string) {
+ const ext = file.split('.').at(-1);
+ if (!ext) {
+ logger.warn(`No extension found for ${file}`);
+ return;
+ }
+ return entryConfigByExt.get(`.${ext}`);
+ }
+
+ const limit = pLimit(10);
+ const skippedFiles: Array = [];
+
+ const contentDir = new URL('content/', settings.config.srcDir);
+
+ function isInContentDir(file: string) {
+ const fileUrl = new URL(file, baseDir);
+ return fileUrl.href.startsWith(contentDir.href);
+ }
+
+ const configFiles = new Set(
+ ['config.js', 'config.ts', 'config.mjs'].map((file) => new URL(file, contentDir).href),
+ );
+
+ function isConfigFile(file: string) {
+ const fileUrl = new URL(file, baseDir);
+ return configFiles.has(fileUrl.href);
+ }
+
+ await Promise.all(
+ files.map((entry) => {
+ if (isConfigFile(entry)) {
+ return;
+ }
+ if (isInContentDir(entry)) {
+ skippedFiles.push(entry);
+ return;
+ }
+ return limit(async () => {
+ const entryType = configForFile(entry);
+ await syncData(entry, baseDir, entryType);
+ });
+ }),
+ );
+
+ const skipCount = skippedFiles.length;
+
+ if (skipCount > 0) {
+ logger.warn(`The glob() loader cannot be used for files in ${bold('src/content')}.`);
+ if (skipCount > 10) {
+ logger.warn(
+ `Skipped ${green(skippedFiles.length)} files that matched ${green(globOptions.pattern)}.`,
+ );
+ } else {
+ logger.warn(`Skipped the following files that matched ${green(globOptions.pattern)}:`);
+ skippedFiles.forEach((file) => logger.warn(`• ${green(file)}`));
+ }
+ }
+
+ // Remove entries that were not found this time
+ untouchedEntries.forEach((id) => store.delete(id));
+
+ if (!watcher) {
+ return;
+ }
+
+ const matcher: RegExp = micromatch.makeRe(globOptions.pattern);
+
+ const matchesGlob = (entry: string) => !entry.startsWith('../') && matcher.test(entry);
+
+ const basePath = fileURLToPath(baseDir);
+
+ async function onChange(changedPath: string) {
+ const entry = posixRelative(basePath, changedPath);
+ if (!matchesGlob(entry)) {
+ return;
+ }
+ const entryType = configForFile(changedPath);
+ const baseUrl = pathToFileURL(basePath);
+ await syncData(entry, baseUrl, entryType);
+ logger.info(`Reloaded data from ${green(entry)}`);
+ }
+
+ watcher.on('change', onChange);
+
+ watcher.on('add', onChange);
+
+ watcher.on('unlink', async (deletedPath) => {
+ const entry = posixRelative(basePath, deletedPath);
+ if (!matchesGlob(entry)) {
+ return;
+ }
+ const id = fileToIdMap.get(deletedPath);
+ if (id) {
+ store.delete(id);
+ fileToIdMap.delete(deletedPath);
+ }
+ });
+ },
+ };
+}
diff --git a/packages/astro/src/content/loaders/index.ts b/packages/astro/src/content/loaders/index.ts
new file mode 100644
index 0000000000..30b4bfbe53
--- /dev/null
+++ b/packages/astro/src/content/loaders/index.ts
@@ -0,0 +1,3 @@
+export { file } from './file.js';
+export { glob } from './glob.js';
+export * from './types.js';
diff --git a/packages/astro/src/content/loaders/types.ts b/packages/astro/src/content/loaders/types.ts
new file mode 100644
index 0000000000..f372967277
--- /dev/null
+++ b/packages/astro/src/content/loaders/types.ts
@@ -0,0 +1,43 @@
+import type { FSWatcher } from 'vite';
+import type { ZodSchema } from 'zod';
+import type { AstroIntegrationLogger, AstroSettings } from '../../@types/astro.js';
+import type { MetaStore, ScopedDataStore } from '../data-store.js';
+
+export interface ParseDataOptions> {
+ /** The ID of the entry. Unique per collection */
+ id: string;
+ /** The raw, unvalidated data of the entry */
+ data: TData;
+ /** An optional file path, where the entry represents a local file. */
+ filePath?: string;
+}
+
+export interface LoaderContext {
+ /** The unique name of the collection */
+ collection: string;
+ /** A database abstraction to store the actual data */
+ store: ScopedDataStore;
+ /** A simple KV store, designed for things like sync tokens */
+ meta: MetaStore;
+ logger: AstroIntegrationLogger;
+
+ settings: AstroSettings;
+
+ /** Validates and parses the data according to the collection schema */
+ parseData>(props: ParseDataOptions): Promise;
+
+ /** Generates a non-cryptographic content digest. This can be used to check if the data has changed */
+ generateDigest(data: Record | string): string;
+
+ /** When running in dev, this is a filesystem watcher that can be used to trigger updates */
+ watcher?: FSWatcher;
+}
+
+export interface Loader {
+ /** Unique name of the loader, e.g. the npm package name */
+ name: string;
+ /** Do the actual loading of the data */
+ load: (context: LoaderContext) => Promise;
+ /** Optionally, define the schema of the data. Will be overridden by user-defined schema */
+ schema?: ZodSchema | Promise | (() => ZodSchema | Promise);
+}
diff --git a/packages/astro/src/content/runtime.ts b/packages/astro/src/content/runtime.ts
index 34d2f10e92..b462e2e23a 100644
--- a/packages/astro/src/content/runtime.ts
+++ b/packages/astro/src/content/runtime.ts
@@ -1,7 +1,10 @@
import type { MarkdownHeading } from '@astrojs/markdown-remark';
+import { Traverse } from 'neotraverse/modern';
import pLimit from 'p-limit';
-import { ZodIssueCode, string as zodString } from 'zod';
-import { AstroError, AstroErrorData } from '../core/errors/index.js';
+import { ZodIssueCode, z } from 'zod';
+import type { GetImageResult, ImageMetadata } from '../@types/astro.js';
+import { imageSrcToImportId } from '../assets/utils/resolveImports.js';
+import { AstroError, AstroErrorData, AstroUserError } from '../core/errors/index.js';
import { prependForwardSlash } from '../core/path.js';
import {
type AstroComponentFactory,
@@ -11,8 +14,11 @@ import {
renderScriptElement,
renderTemplate,
renderUniqueStylesheet,
+ render as serverRender,
unescapeHTML,
} from '../runtime/server/index.js';
+import { CONTENT_LAYER_TYPE, IMAGE_IMPORT_PREFIX } from './consts.js';
+import { type DataEntry, globalDataStore } from './data-store.js';
import type { ContentLookupMap } from './utils.js';
type LazyImport = () => Promise;
@@ -21,6 +27,15 @@ type CollectionToEntryMap = Record;
type GetEntryImport = (collection: string, lookupId: string) => Promise;
export function defineCollection(config: any) {
+ if ('loader' in config) {
+ if (config.type && config.type !== CONTENT_LAYER_TYPE) {
+ throw new AstroUserError(
+ 'Collections that use the Content Layer API must have a `loader` defined and no `type` set.',
+ "Check your collection definitions in `src/content/config.*`.'",
+ );
+ }
+ config.type = CONTENT_LAYER_TYPE;
+ }
if (!config.type) config.type = 'content';
return config;
}
@@ -56,11 +71,34 @@ export function createGetCollection({
cacheEntriesByCollection: Map;
}) {
return async function getCollection(collection: string, filter?: (entry: any) => unknown) {
+ const hasFilter = typeof filter === 'function';
+ const store = await globalDataStore.get();
let type: 'content' | 'data';
if (collection in contentCollectionToEntryMap) {
type = 'content';
} else if (collection in dataCollectionToEntryMap) {
type = 'data';
+ } else if (store.hasCollection(collection)) {
+ // @ts-expect-error virtual module
+ const { default: imageAssetMap } = await import('astro:asset-imports');
+
+ const result = [];
+ for (const rawEntry of store.values(collection)) {
+ const data = rawEntry.filePath
+ ? updateImageReferencesInData(rawEntry.data, rawEntry.filePath, imageAssetMap)
+ : rawEntry.data;
+
+ const entry = {
+ ...rawEntry,
+ data,
+ collection,
+ };
+ if (hasFilter && !filter(entry)) {
+ continue;
+ }
+ result.push(entry);
+ }
+ return result;
} else {
// eslint-disable-next-line no-console
console.warn(
@@ -70,6 +108,7 @@ export function createGetCollection({
);
return [];
}
+
const lazyImports = Object.values(
type === 'content'
? contentCollectionToEntryMap[collection]
@@ -111,7 +150,7 @@ export function createGetCollection({
);
cacheEntriesByCollection.set(collection, entries);
}
- if (typeof filter === 'function') {
+ if (hasFilter) {
return entries.filter(filter);
} else {
// Clone the array so users can safely mutate it.
@@ -124,11 +163,27 @@ export function createGetCollection({
export function createGetEntryBySlug({
getEntryImport,
getRenderEntryImport,
+ collectionNames,
}: {
getEntryImport: GetEntryImport;
getRenderEntryImport: GetEntryImport;
+ collectionNames: Set;
}) {
return async function getEntryBySlug(collection: string, slug: string) {
+ const store = await globalDataStore.get();
+
+ if (!collectionNames.has(collection)) {
+ if (store.hasCollection(collection)) {
+ throw new AstroError({
+ ...AstroErrorData.GetEntryDeprecationError,
+ message: AstroErrorData.GetEntryDeprecationError.message(collection, 'getEntryBySlug'),
+ });
+ }
+ // eslint-disable-next-line no-console
+ console.warn(`The collection ${JSON.stringify(collection)} does not exist.`);
+ return undefined;
+ }
+
const entryImport = await getEntryImport(collection, slug);
if (typeof entryImport !== 'function') return undefined;
@@ -151,8 +206,28 @@ export function createGetEntryBySlug({
};
}
-export function createGetDataEntryById({ getEntryImport }: { getEntryImport: GetEntryImport }) {
+export function createGetDataEntryById({
+ getEntryImport,
+ collectionNames,
+}: {
+ getEntryImport: GetEntryImport;
+ collectionNames: Set;
+}) {
return async function getDataEntryById(collection: string, id: string) {
+ const store = await globalDataStore.get();
+
+ if (!collectionNames.has(collection)) {
+ if (store.hasCollection(collection)) {
+ throw new AstroError({
+ ...AstroErrorData.GetEntryDeprecationError,
+ message: AstroErrorData.GetEntryDeprecationError.message(collection, 'getDataEntryById'),
+ });
+ }
+ // eslint-disable-next-line no-console
+ console.warn(`The collection ${JSON.stringify(collection)} does not exist.`);
+ return undefined;
+ }
+
const lazyImport = await getEntryImport(collection, id);
// TODO: AstroError
@@ -187,9 +262,11 @@ type EntryLookupObject = { collection: string; id: string } | { collection: stri
export function createGetEntry({
getEntryImport,
getRenderEntryImport,
+ collectionNames,
}: {
getEntryImport: GetEntryImport;
getRenderEntryImport: GetEntryImport;
+ collectionNames: Set;
}) {
return async function getEntry(
// Can either pass collection and identifier as 2 positional args,
@@ -216,6 +293,33 @@ export function createGetEntry({
: collectionOrLookupObject.slug;
}
+ const store = await globalDataStore.get();
+
+ if (store.hasCollection(collection)) {
+ const entry = store.get(collection, lookupId);
+ if (!entry) {
+ // eslint-disable-next-line no-console
+ console.warn(`Entry ${collection} → ${lookupId} was not found.`);
+ return;
+ }
+
+ if (entry.filePath) {
+ // @ts-expect-error virtual module
+ const { default: imageAssetMap } = await import('astro:asset-imports');
+ entry.data = updateImageReferencesInData(entry.data, entry.filePath, imageAssetMap);
+ }
+ return {
+ ...entry,
+ collection,
+ } as DataEntryResult | ContentEntryResult;
+ }
+
+ if (!collectionNames.has(collection)) {
+ // eslint-disable-next-line no-console
+ console.warn(`The collection ${JSON.stringify(collection)} does not exist.`);
+ return undefined;
+ }
+
const entryImport = await getEntryImport(collection, lookupId);
if (typeof entryImport !== 'function') return undefined;
@@ -261,6 +365,115 @@ type RenderResult = {
remarkPluginFrontmatter: Record;
};
+const CONTENT_LAYER_IMAGE_REGEX = /__ASTRO_IMAGE_="([^"]+)"/g;
+
+async function updateImageReferencesInBody(html: string, fileName: string) {
+ // @ts-expect-error Virtual module
+ const { default: imageAssetMap } = await import('astro:asset-imports');
+
+ const imageObjects = new Map();
+
+ // @ts-expect-error Virtual module resolved at runtime
+ const { getImage } = await import('astro:assets');
+
+ // First load all the images. This is done outside of the replaceAll
+ // function because getImage is async.
+ for (const [_full, imagePath] of html.matchAll(CONTENT_LAYER_IMAGE_REGEX)) {
+ try {
+ const decodedImagePath = JSON.parse(imagePath.replaceAll('"', '"'));
+ const id = imageSrcToImportId(decodedImagePath.src, fileName);
+
+ const imported = imageAssetMap.get(id);
+ if (!id || imageObjects.has(id) || !imported) {
+ continue;
+ }
+ const image: GetImageResult = await getImage({ ...decodedImagePath, src: imported });
+ imageObjects.set(imagePath, image);
+ } catch {
+ throw new Error(`Failed to parse image reference: ${imagePath}`);
+ }
+ }
+
+ return html.replaceAll(CONTENT_LAYER_IMAGE_REGEX, (full, imagePath) => {
+ const image = imageObjects.get(imagePath);
+
+ if (!image) {
+ return full;
+ }
+
+ const { index, ...attributes } = image.attributes;
+
+ return Object.entries({
+ ...attributes,
+ src: image.src,
+ srcset: image.srcSet.attribute,
+ })
+ .map(([key, value]) => (value ? `${key}=${JSON.stringify(String(value))}` : ''))
+ .join(' ');
+ });
+}
+
+function updateImageReferencesInData>(
+ data: T,
+ fileName: string,
+ imageAssetMap: Map,
+): T {
+ return new Traverse(data).map(function (ctx, val) {
+ if (typeof val === 'string' && val.startsWith(IMAGE_IMPORT_PREFIX)) {
+ const src = val.replace(IMAGE_IMPORT_PREFIX, '');
+ const id = imageSrcToImportId(src, fileName);
+ if (!id) {
+ ctx.update(src);
+ return;
+ }
+ const imported = imageAssetMap.get(id);
+ if (imported) {
+ ctx.update(imported);
+ } else {
+ ctx.update(src);
+ }
+ }
+ });
+}
+
+export async function renderEntry(
+ entry: DataEntry | { render: () => Promise<{ Content: AstroComponentFactory }> },
+) {
+ if (entry && 'render' in entry) {
+ // This is an old content collection entry, so we use its render method
+ return entry.render();
+ }
+
+ if (entry.deferredRender) {
+ try {
+ // @ts-expect-error virtual module
+ const { default: contentModules } = await import('astro:content-module-imports');
+ const module = contentModules.get(entry.filePath);
+ const deferredMod = await module();
+ return {
+ Content: deferredMod.Content,
+ headings: deferredMod.getHeadings?.() ?? [],
+ remarkPluginFrontmatter: deferredMod.frontmatter ?? {},
+ };
+ } catch (e) {
+ // eslint-disable-next-line
+ console.error(e);
+ }
+ }
+
+ const html =
+ entry?.rendered?.metadata?.imagePaths?.length && entry.filePath
+ ? await updateImageReferencesInBody(entry.rendered.html, entry.filePath)
+ : entry?.rendered?.html;
+
+ const Content = createComponent(() => serverRender`${unescapeHTML(html)}`);
+ return {
+ Content,
+ headings: entry?.rendered?.metadata?.headings ?? [],
+ remarkPluginFrontmatter: entry?.rendered?.metadata?.frontmatter ?? {},
+ };
+}
+
async function render({
collection,
id,
@@ -357,36 +570,92 @@ async function render({
export function createReference({ lookupMap }: { lookupMap: ContentLookupMap }) {
return function reference(collection: string) {
- return zodString().transform((lookupId: string, ctx) => {
- const flattenedErrorPath = ctx.path.join('.');
- if (!lookupMap[collection]) {
- ctx.addIssue({
- code: ZodIssueCode.custom,
- message: `**${flattenedErrorPath}:** Reference to ${collection} invalid. Collection does not exist or is empty.`,
- });
- return;
- }
+ return z
+ .union([
+ z.string(),
+ z.object({
+ id: z.string(),
+ collection: z.string(),
+ }),
+ z.object({
+ slug: z.string(),
+ collection: z.string(),
+ }),
+ ])
+ .transform(
+ async (
+ lookup:
+ | string
+ | { id: string; collection: string }
+ | { slug: string; collection: string },
+ ctx,
+ ) => {
+ const flattenedErrorPath = ctx.path.join('.');
+ const store = await globalDataStore.get();
+ const collectionIsInStore = store.hasCollection(collection);
- const { type, entries } = lookupMap[collection];
- const entry = entries[lookupId];
+ if (typeof lookup === 'object') {
+ // If these don't match then something is wrong with the reference
+ if (lookup.collection !== collection) {
+ ctx.addIssue({
+ code: ZodIssueCode.custom,
+ message: `**${flattenedErrorPath}**: Reference to ${collection} invalid. Expected ${collection}. Received ${lookup.collection}.`,
+ });
+ return;
+ }
- if (!entry) {
- ctx.addIssue({
- code: ZodIssueCode.custom,
- message: `**${flattenedErrorPath}**: Reference to ${collection} invalid. Expected ${Object.keys(
- entries,
- )
- .map((c) => JSON.stringify(c))
- .join(' | ')}. Received ${JSON.stringify(lookupId)}.`,
- });
- return;
- }
- // Content is still identified by slugs, so map to a `slug` key for consistency.
- if (type === 'content') {
- return { slug: lookupId, collection };
- }
- return { id: lookupId, collection };
- });
+ // A reference object might refer to an invalid collection, because when we convert it we don't have access to the store.
+ // If it is an object then we're validating later in the pipeline, so we can check the collection at that point.
+ if (!lookupMap[collection] && !collectionIsInStore) {
+ ctx.addIssue({
+ code: ZodIssueCode.custom,
+ message: `**${flattenedErrorPath}:** Reference to ${collection} invalid. Collection does not exist or is empty.`,
+ });
+ return;
+ }
+ return lookup;
+ }
+
+ if (collectionIsInStore) {
+ const entry = store.get(collection, lookup);
+ if (!entry) {
+ ctx.addIssue({
+ code: ZodIssueCode.custom,
+ message: `**${flattenedErrorPath}**: Reference to ${collection} invalid. Entry ${lookup} does not exist.`,
+ });
+ return;
+ }
+ return { id: lookup, collection };
+ }
+
+ if (!lookupMap[collection] && store.collections().size === 0) {
+ // If the collection is not in the lookup map or store, it may be a content layer collection and the store may not yet be populated.
+ // For now, we can't validate this reference, so we'll optimistically convert it to a reference object which we'll validate
+ // later in the pipeline when we do have access to the store.
+ return { id: lookup, collection };
+ }
+
+ const { type, entries } = lookupMap[collection];
+ const entry = entries[lookup];
+
+ if (!entry) {
+ ctx.addIssue({
+ code: ZodIssueCode.custom,
+ message: `**${flattenedErrorPath}**: Reference to ${collection} invalid. Expected ${Object.keys(
+ entries,
+ )
+ .map((c) => JSON.stringify(c))
+ .join(' | ')}. Received ${JSON.stringify(lookup)}.`,
+ });
+ return;
+ }
+ // Content is still identified by slugs, so map to a `slug` key for consistency.
+ if (type === 'content') {
+ return { slug: lookup, collection };
+ }
+ return { id: lookup, collection };
+ },
+ );
};
}
diff --git a/packages/astro/src/content/types-generator.ts b/packages/astro/src/content/types-generator.ts
index ea0c3cc80e..6fa0db94be 100644
--- a/packages/astro/src/content/types-generator.ts
+++ b/packages/astro/src/content/types-generator.ts
@@ -1,18 +1,20 @@
+import glob from 'fast-glob';
+import { bold, cyan } from 'kleur/colors';
import type fsMod from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
-import glob from 'fast-glob';
-import { bold, cyan } from 'kleur/colors';
import { type ViteDevServer, normalizePath } from 'vite';
-import { z } from 'zod';
+import { z, type ZodSchema } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
+import { printNode, zodToTs } from 'zod-to-ts';
import type { AstroSettings, ContentEntryType } from '../@types/astro.js';
import { AstroError } from '../core/errors/errors.js';
import { AstroErrorData } from '../core/errors/index.js';
import type { Logger } from '../core/logger/core.js';
import { isRelativePath } from '../core/path.js';
-import { CONTENT_TYPES_FILE, VIRTUAL_MODULE_ID } from './consts.js';
+import { CONTENT_LAYER_TYPE, CONTENT_TYPES_FILE, VIRTUAL_MODULE_ID } from './consts.js';
import {
+ type CollectionConfig,
type ContentConfig,
type ContentObservable,
type ContentPaths,
@@ -44,7 +46,7 @@ type CollectionEntryMap = {
entries: Record;
}
| {
- type: 'data';
+ type: 'data' | typeof CONTENT_LAYER_TYPE;
entries: Record;
};
};
@@ -245,7 +247,7 @@ export async function createContentTypesGenerator({
collectionEntryMap[collectionKey] = {
type: 'content',
entries: {
- ...collectionInfo.entries,
+ ...(collectionInfo.entries as Record),
[entryKey]: { slug: addedSlug },
},
};
@@ -356,6 +358,51 @@ function normalizeConfigPath(from: string, to: string) {
return `"${isRelativePath(configPath) ? '' : './'}${normalizedPath}"` as const;
}
+const schemaCache = new Map();
+
+async function getContentLayerSchema(
+ collection: ContentConfig['collections'][T],
+ collectionKey: T,
+): Promise {
+ const cached = schemaCache.get(collectionKey);
+ if (cached) {
+ return cached;
+ }
+
+ if (
+ collection?.type === CONTENT_LAYER_TYPE &&
+ typeof collection.loader === 'object' &&
+ collection.loader.schema
+ ) {
+ let schema = collection.loader.schema;
+ if (typeof schema === 'function') {
+ schema = await schema();
+ }
+ if (schema) {
+ schemaCache.set(collectionKey, await schema);
+ return schema;
+ }
+ }
+}
+
+async function typeForCollection(
+ collection: ContentConfig['collections'][T] | undefined,
+ collectionKey: T,
+): Promise {
+ if (collection?.schema) {
+ return `InferEntrySchema<${collectionKey}>`;
+ }
+
+ if (collection?.type === CONTENT_LAYER_TYPE) {
+ const schema = await getContentLayerSchema(collection, collectionKey);
+ if (schema) {
+ const ast = zodToTs(schema);
+ return printNode(ast.node);
+ }
+ }
+ return 'any';
+}
+
async function writeContentFiles({
fs,
contentPaths,
@@ -391,12 +438,15 @@ async function writeContentFiles({
entries: {},
};
}
+
+ let contentCollectionsMap: CollectionEntryMap = {};
for (const collectionKey of Object.keys(collectionEntryMap).sort()) {
const collectionConfig = contentConfig?.collections[JSON.parse(collectionKey)];
const collection = collectionEntryMap[collectionKey];
if (
collectionConfig?.type &&
collection.type !== 'unknown' &&
+ collectionConfig.type !== CONTENT_LAYER_TYPE &&
collection.type !== collectionConfig.type
) {
viteServer.hot.send({
@@ -419,15 +469,15 @@ async function writeContentFiles({
});
return;
}
- const resolvedType: 'content' | 'data' =
+ const resolvedType =
collection.type === 'unknown'
? // Add empty / unknown collections to the data type map by default
// This ensures `getCollection('empty-collection')` doesn't raise a type error
- collectionConfig?.type ?? 'data'
+ (collectionConfig?.type ?? 'data')
: collection.type;
const collectionEntryKeys = Object.keys(collection.entries).sort();
- const dataType = collectionConfig?.schema ? `InferEntrySchema<${collectionKey}>` : 'any';
+ const dataType = await typeForCollection(collectionConfig, collectionKey);
switch (resolvedType) {
case 'content':
if (collectionEntryKeys.length === 0) {
@@ -446,6 +496,9 @@ async function writeContentFiles({
}
contentTypesStr += `};\n`;
break;
+ case CONTENT_LAYER_TYPE:
+ dataTypesStr += `${collectionKey}: Record;\n`;
+ break;
case 'data':
if (collectionEntryKeys.length === 0) {
dataTypesStr += `${collectionKey}: Record;\n`;
@@ -458,40 +511,60 @@ async function writeContentFiles({
}
if (collectionConfig?.schema) {
- let zodSchemaForJson =
- typeof collectionConfig.schema === 'function'
- ? collectionConfig.schema({ image: () => z.string() })
- : collectionConfig.schema;
- if (zodSchemaForJson instanceof z.ZodObject) {
- zodSchemaForJson = zodSchemaForJson.extend({
- $schema: z.string().optional(),
- });
- }
- try {
- await fs.promises.writeFile(
- new URL(`./${collectionKey.replace(/"/g, '')}.schema.json`, collectionSchemasDir),
- JSON.stringify(
- zodToJsonSchema(zodSchemaForJson, {
- name: collectionKey.replace(/"/g, ''),
- markdownDescription: true,
- errorMessages: true,
- // Fix for https://github.com/StefanTerdell/zod-to-json-schema/issues/110
- dateStrategy: ['format:date-time', 'format:date', 'integer'],
- }),
- null,
- 2,
- ),
- );
- } catch (err) {
- // This should error gracefully and not crash the dev server
- logger.warn(
- 'content',
- `An error was encountered while creating the JSON schema for the ${collectionKey} collection. Proceeding without it. Error: ${err}`,
- );
- }
+ await generateJSONSchema(
+ fs,
+ collectionConfig,
+ collectionKey,
+ collectionSchemasDir,
+ logger,
+ );
}
break;
}
+
+ if (
+ settings.config.experimental.contentIntellisense &&
+ collectionConfig &&
+ (collectionConfig.schema || (await getContentLayerSchema(collectionConfig, collectionKey)))
+ ) {
+ await generateJSONSchema(fs, collectionConfig, collectionKey, collectionSchemasDir, logger);
+
+ contentCollectionsMap[collectionKey] = collection;
+ }
+ }
+
+ if (settings.config.experimental.contentIntellisense) {
+ let contentCollectionManifest: {
+ collections: { hasSchema: boolean; name: string }[];
+ entries: Record;
+ } = {
+ collections: [],
+ entries: {},
+ };
+ Object.entries(contentCollectionsMap).forEach(([collectionKey, collection]) => {
+ const collectionConfig = contentConfig?.collections[JSON.parse(collectionKey)];
+ const key = JSON.parse(collectionKey);
+
+ contentCollectionManifest.collections.push({
+ hasSchema: Boolean(collectionConfig?.schema || schemaCache.has(collectionKey)),
+ name: key,
+ });
+
+ Object.keys(collection.entries).forEach((entryKey) => {
+ const entryPath = new URL(
+ JSON.parse(entryKey),
+ contentPaths.contentDir + `${key}/`,
+ ).toString();
+
+ // Save entry path in lower case to avoid case sensitivity issues between Windows and Unix
+ contentCollectionManifest.entries[entryPath.toLowerCase()] = key;
+ });
+ });
+
+ await fs.promises.writeFile(
+ new URL('./collections.json', collectionSchemasDir),
+ JSON.stringify(contentCollectionManifest, null, 2),
+ );
}
if (!fs.existsSync(settings.dotAstroDir)) {
@@ -499,7 +572,7 @@ async function writeContentFiles({
}
const configPathRelativeToCacheDir = normalizeConfigPath(
- settings.dotAstroDir.pathname,
+ new URL('astro', settings.dotAstroDir).pathname,
contentPaths.config.url.pathname,
);
@@ -515,8 +588,62 @@ async function writeContentFiles({
contentConfig ? `typeof import(${configPathRelativeToCacheDir})` : 'never',
);
- await fs.promises.writeFile(
- new URL(CONTENT_TYPES_FILE, settings.dotAstroDir),
- typeTemplateContent,
- );
+ // If it's the first time, we inject types the usual way. sync() will handle creating files and references. If it's not the first time, we just override the dts content
+ if (settings.injectedTypes.some((t) => t.filename === CONTENT_TYPES_FILE)) {
+ fs.promises.writeFile(
+ new URL(CONTENT_TYPES_FILE, settings.dotAstroDir),
+ typeTemplateContent,
+ 'utf-8',
+ );
+ } else {
+ settings.injectedTypes.push({
+ filename: CONTENT_TYPES_FILE,
+ content: typeTemplateContent,
+ });
+ }
+}
+
+async function generateJSONSchema(
+ fsMod: typeof import('node:fs'),
+ collectionConfig: CollectionConfig,
+ collectionKey: string,
+ collectionSchemasDir: URL,
+ logger: Logger,
+) {
+ let zodSchemaForJson =
+ typeof collectionConfig.schema === 'function'
+ ? collectionConfig.schema({ image: () => z.string() })
+ : collectionConfig.schema;
+
+ if (!zodSchemaForJson && collectionConfig.type === CONTENT_LAYER_TYPE) {
+ zodSchemaForJson = await getContentLayerSchema(collectionConfig, collectionKey);
+ }
+
+ if (zodSchemaForJson instanceof z.ZodObject) {
+ zodSchemaForJson = zodSchemaForJson.extend({
+ $schema: z.string().optional(),
+ });
+ }
+ try {
+ await fsMod.promises.writeFile(
+ new URL(`./${collectionKey.replace(/"/g, '')}.schema.json`, collectionSchemasDir),
+ JSON.stringify(
+ zodToJsonSchema(zodSchemaForJson, {
+ name: collectionKey.replace(/"/g, ''),
+ markdownDescription: true,
+ errorMessages: true,
+ // Fix for https://github.com/StefanTerdell/zod-to-json-schema/issues/110
+ dateStrategy: ['format:date-time', 'format:date', 'integer'],
+ }),
+ null,
+ 2,
+ ),
+ );
+ } catch (err) {
+ // This should error gracefully and not crash the dev server
+ logger.warn(
+ 'content',
+ `An error was encountered while creating the JSON schema for the ${collectionKey} collection. Proceeding without it. Error: ${err}`,
+ );
+ }
}
diff --git a/packages/astro/src/content/utils.ts b/packages/astro/src/content/utils.ts
index ce6dc63ca8..79a37a28d0 100644
--- a/packages/astro/src/content/utils.ts
+++ b/packages/astro/src/content/utils.ts
@@ -5,6 +5,7 @@ import { slug as githubSlug } from 'github-slugger';
import matter from 'gray-matter';
import type { PluginContext } from 'rollup';
import { type ViteDevServer, normalizePath } from 'vite';
+import xxhash from 'xxhash-wasm';
import { z } from 'zod';
import type {
AstroConfig,
@@ -15,7 +16,13 @@ import type {
import { AstroError, AstroErrorData, MarkdownError, errorMap } from '../core/errors/index.js';
import { isYAMLException } from '../core/errors/utils.js';
import type { Logger } from '../core/logger/core.js';
-import { CONTENT_FLAGS, PROPAGATED_ASSET_FLAG } from './consts.js';
+import {
+ CONTENT_FLAGS,
+ CONTENT_LAYER_TYPE,
+ CONTENT_MODULE_FLAG,
+ IMAGE_IMPORT_PREFIX,
+ PROPAGATED_ASSET_FLAG,
+} from './consts.js';
import { createImage } from './runtime-assets.js';
/**
* Amap from a collection + slug to the local file path.
@@ -35,6 +42,54 @@ const collectionConfigParser = z.union([
type: z.literal('data'),
schema: z.any().optional(),
}),
+ z.object({
+ type: z.literal(CONTENT_LAYER_TYPE),
+ schema: z.any().optional(),
+ loader: z.union([
+ z.function().returns(
+ z.union([
+ z.array(
+ z
+ .object({
+ id: z.string(),
+ })
+ .catchall(z.unknown()),
+ ),
+ z.promise(
+ z.array(
+ z
+ .object({
+ id: z.string(),
+ })
+ .catchall(z.unknown()),
+ ),
+ ),
+ ]),
+ ),
+ z.object({
+ name: z.string(),
+ load: z.function(
+ z.tuple(
+ [
+ z.object({
+ collection: z.string(),
+ store: z.any(),
+ meta: z.any(),
+ logger: z.any(),
+ settings: z.any(),
+ parseData: z.any(),
+ generateDigest: z.function(z.tuple([z.any()], z.string())),
+ watcher: z.any().optional(),
+ }),
+ ],
+ z.unknown(),
+ ),
+ ),
+ schema: z.any().optional(),
+ render: z.function(z.tuple([z.any()], z.unknown())).optional(),
+ }),
+ ]),
+ }),
]);
const contentConfigParser = z.object({
@@ -42,7 +97,7 @@ const contentConfigParser = z.object({
});
export type CollectionConfig = z.infer;
-export type ContentConfig = z.infer;
+export type ContentConfig = z.infer & { digest?: string };
type EntryInternal = { rawData: string | undefined; filePath: string };
@@ -67,30 +122,46 @@ export function parseEntrySlug({
}
}
-export async function getEntryData(
+export async function getEntryDataAndImages<
+ TInputData extends Record = Record,
+ TOutputData extends TInputData = TInputData,
+>(
entry: {
id: string;
collection: string;
- unvalidatedData: Record;
+ unvalidatedData: TInputData;
_internal: EntryInternal;
},
collectionConfig: CollectionConfig,
shouldEmitFile: boolean,
- pluginContext: PluginContext,
-) {
- let data;
- if (collectionConfig.type === 'data') {
- data = entry.unvalidatedData;
+ pluginContext?: PluginContext,
+): Promise<{ data: TOutputData; imageImports: Array }> {
+ let data: TOutputData;
+ if (collectionConfig.type === 'data' || collectionConfig.type === CONTENT_LAYER_TYPE) {
+ data = entry.unvalidatedData as TOutputData;
} else {
const { slug, ...unvalidatedData } = entry.unvalidatedData;
- data = unvalidatedData;
+ data = unvalidatedData as TOutputData;
}
let schema = collectionConfig.schema;
+
+ const imageImports = new Set();
+
if (typeof schema === 'function') {
- schema = schema({
- image: createImage(pluginContext, shouldEmitFile, entry._internal.filePath),
- });
+ if (pluginContext) {
+ schema = schema({
+ image: createImage(pluginContext, shouldEmitFile, entry._internal.filePath),
+ });
+ } else if (collectionConfig.type === CONTENT_LAYER_TYPE) {
+ schema = schema({
+ image: () =>
+ z.string().transform((val) => {
+ imageImports.add(val);
+ return `${IMAGE_IMPORT_PREFIX}${val}`;
+ }),
+ });
+ }
}
if (schema) {
@@ -119,7 +190,7 @@ export async function getEntryData(
},
});
if (parsed.success) {
- data = parsed.data as Record;
+ data = parsed.data as TOutputData;
} else {
if (!formattedError) {
formattedError = new AstroError({
@@ -139,6 +210,27 @@ export async function getEntryData(
throw formattedError;
}
}
+
+ return { data, imageImports: Array.from(imageImports) };
+}
+
+export async function getEntryData(
+ entry: {
+ id: string;
+ collection: string;
+ unvalidatedData: Record;
+ _internal: EntryInternal;
+ },
+ collectionConfig: CollectionConfig,
+ shouldEmitFile: boolean,
+ pluginContext?: PluginContext,
+) {
+ const { data } = await getEntryDataAndImages(
+ entry,
+ collectionConfig,
+ shouldEmitFile,
+ pluginContext,
+ );
return data;
}
@@ -383,6 +475,11 @@ export function hasContentFlag(viteId: string, flag: (typeof CONTENT_FLAGS)[numb
return flags.has(flag);
}
+export function isDeferredModule(viteId: string): boolean {
+ const flags = new URLSearchParams(viteId.split('?')[1] ?? '');
+ return flags.has(CONTENT_MODULE_FLAG);
+}
+
async function loadContentConfig({
fs,
settings,
@@ -402,7 +499,10 @@ async function loadContentConfig({
const config = contentConfigParser.safeParse(unparsedConfig);
if (config.success) {
- return config.data;
+ // Generate a digest of the config file so we can invalidate the cache if it changes
+ const hasher = await xxhash();
+ const digest = await hasher.h64ToString(await fs.promises.readFile(configPathname, 'utf-8'));
+ return { ...config.data, digest };
} else {
return undefined;
}
@@ -556,3 +656,17 @@ export function hasAssetPropagationFlag(id: string): boolean {
return false;
}
}
+
+/**
+ * Convert a platform path to a posix path.
+ */
+export function posixifyPath(filePath: string) {
+ return filePath.split(path.sep).join('/');
+}
+
+/**
+ * Unlike `path.posix.relative`, this function will accept a platform path and return a posix path.
+ */
+export function posixRelative(from: string, to: string) {
+ return posixifyPath(path.relative(from, to));
+}
diff --git a/packages/astro/src/content/vite-plugin-content-assets.ts b/packages/astro/src/content/vite-plugin-content-assets.ts
index dd6dacc7ca..b810b8f71a 100644
--- a/packages/astro/src/content/vite-plugin-content-assets.ts
+++ b/packages/astro/src/content/vite-plugin-content-assets.ts
@@ -1,5 +1,5 @@
import { extname } from 'node:path';
-import { pathToFileURL } from 'node:url';
+import { fileURLToPath, pathToFileURL } from 'node:url';
import type { Plugin } from 'vite';
import type { AstroSettings, SSRElement } from '../@types/astro.js';
import { getAssetsPrefix } from '../assets/utils/getAssetsPrefix.js';
@@ -12,6 +12,7 @@ import { joinPaths, prependForwardSlash } from '../core/path.js';
import { getStylesForURL } from '../vite-plugin-astro-server/css.js';
import { getScriptsForURL } from '../vite-plugin-astro-server/scripts.js';
import {
+ CONTENT_IMAGE_FLAG,
CONTENT_RENDER_FLAG,
LINKS_PLACEHOLDER,
PROPAGATED_ASSET_FLAG,
@@ -32,6 +33,17 @@ export function astroContentAssetPropagationPlugin({
name: 'astro:content-asset-propagation',
enforce: 'pre',
async resolveId(id, importer, opts) {
+ if (hasContentFlag(id, CONTENT_IMAGE_FLAG)) {
+ const [base, query] = id.split('?');
+ const params = new URLSearchParams(query);
+ const importerParam = params.get('importer');
+
+ const importerPath = importerParam
+ ? fileURLToPath(new URL(importerParam, settings.config.root))
+ : importer;
+
+ return this.resolve(base, importerPath, { skipSelf: true, ...opts });
+ }
if (hasContentFlag(id, CONTENT_RENDER_FLAG)) {
const base = id.split('?')[0];
diff --git a/packages/astro/src/content/vite-plugin-content-imports.ts b/packages/astro/src/content/vite-plugin-content-imports.ts
index de642329a3..5a944716c3 100644
--- a/packages/astro/src/content/vite-plugin-content-imports.ts
+++ b/packages/astro/src/content/vite-plugin-content-imports.ts
@@ -158,6 +158,7 @@ export const _internal = {
// The content config could depend on collection entries via `reference()`.
// Reload the config in case of changes.
+ // Changes to the config file itself are handled in types-generator.ts, so we skip them here
if (entryType === 'content' || entryType === 'data') {
await reloadContentConfigObserver({ fs, settings, viteServer });
}
diff --git a/packages/astro/src/content/vite-plugin-content-virtual-mod.ts b/packages/astro/src/content/vite-plugin-content-virtual-mod.ts
index 8c5365368d..64e5d98ee4 100644
--- a/packages/astro/src/content/vite-plugin-content-virtual-mod.ts
+++ b/packages/astro/src/content/vite-plugin-content-virtual-mod.ts
@@ -1,6 +1,7 @@
import nodeFs from 'node:fs';
import { extname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
+import { dataToEsm } from '@rollup/pluginutils';
import glob from 'fast-glob';
import pLimit from 'p-limit';
import type { Plugin } from 'vite';
@@ -13,9 +14,18 @@ import { rootRelativePath } from '../core/viteUtils.js';
import type { AstroPluginMetadata } from '../vite-plugin-astro/index.js';
import { createDefaultAstroMetadata } from '../vite-plugin-astro/metadata.js';
import {
+ ASSET_IMPORTS_FILE,
+ ASSET_IMPORTS_RESOLVED_STUB_ID,
+ ASSET_IMPORTS_VIRTUAL_ID,
CONTENT_FLAG,
CONTENT_RENDER_FLAG,
DATA_FLAG,
+ DATA_STORE_FILE,
+ DATA_STORE_VIRTUAL_ID,
+ MODULES_IMPORTS_FILE,
+ MODULES_MJS_ID,
+ MODULES_MJS_VIRTUAL_ID,
+ RESOLVED_DATA_STORE_VIRTUAL_ID,
RESOLVED_VIRTUAL_MODULE_ID,
VIRTUAL_MODULE_ID,
} from './consts.js';
@@ -30,6 +40,7 @@ import {
getEntrySlug,
getEntryType,
getExtGlob,
+ isDeferredModule,
} from './utils.js';
interface AstroContentVirtualModPluginParams {
@@ -43,13 +54,14 @@ export function astroContentVirtualModPlugin({
}: AstroContentVirtualModPluginParams): Plugin {
let IS_DEV = false;
const IS_SERVER = isServerLikeOutput(settings.config);
+ const dataStoreFile = new URL(DATA_STORE_FILE, settings.config.cacheDir);
return {
name: 'astro-content-virtual-mod-plugin',
enforce: 'pre',
configResolved(config) {
IS_DEV = config.mode === 'development';
},
- resolveId(id) {
+ async resolveId(id) {
if (id === VIRTUAL_MODULE_ID) {
if (!settings.config.experimental.contentCollectionCache) {
return RESOLVED_VIRTUAL_MODULE_ID;
@@ -61,6 +73,38 @@ export function astroContentVirtualModPlugin({
return { id: RESOLVED_VIRTUAL_MODULE_ID, external: true };
}
}
+ if (id === DATA_STORE_VIRTUAL_ID) {
+ return RESOLVED_DATA_STORE_VIRTUAL_ID;
+ }
+
+ if (isDeferredModule(id)) {
+ const [, query] = id.split('?');
+ const params = new URLSearchParams(query);
+ const fileName = params.get('fileName');
+ let importerPath = undefined;
+ if (fileName && URL.canParse(fileName, settings.config.root.toString())) {
+ importerPath = fileURLToPath(new URL(fileName, settings.config.root));
+ }
+ if (importerPath) {
+ return await this.resolve(importerPath);
+ }
+ }
+
+ if (id === MODULES_MJS_ID) {
+ const modules = new URL(MODULES_IMPORTS_FILE, settings.dotAstroDir);
+ if (fs.existsSync(modules)) {
+ return fileURLToPath(modules);
+ }
+ return MODULES_MJS_VIRTUAL_ID;
+ }
+
+ if (id === ASSET_IMPORTS_VIRTUAL_ID) {
+ const assetImportsFile = new URL(ASSET_IMPORTS_FILE, settings.dotAstroDir);
+ if (fs.existsSync(assetImportsFile)) {
+ return fileURLToPath(assetImportsFile);
+ }
+ return ASSET_IMPORTS_RESOLVED_STUB_ID;
+ }
},
async load(id, args) {
if (id === RESOLVED_VIRTUAL_MODULE_ID) {
@@ -87,6 +131,41 @@ export function astroContentVirtualModPlugin({
} satisfies AstroPluginMetadata,
};
}
+ if (id === RESOLVED_DATA_STORE_VIRTUAL_ID) {
+ if (!fs.existsSync(dataStoreFile)) {
+ return 'export default new Map()';
+ }
+ const jsonData = await fs.promises.readFile(dataStoreFile, 'utf-8');
+
+ try {
+ const parsed = JSON.parse(jsonData);
+ return {
+ code: dataToEsm(parsed, {
+ compact: true,
+ }),
+ map: { mappings: '' },
+ };
+ } catch (err) {
+ const message = 'Could not parse JSON file';
+ this.error({ message, id, cause: err });
+ }
+ }
+
+ if (id === ASSET_IMPORTS_RESOLVED_STUB_ID) {
+ const assetImportsFile = new URL(ASSET_IMPORTS_FILE, settings.dotAstroDir);
+ if (!fs.existsSync(assetImportsFile)) {
+ return 'export default new Map()';
+ }
+ return fs.readFileSync(assetImportsFile, 'utf-8');
+ }
+
+ if (id === MODULES_MJS_VIRTUAL_ID) {
+ const modules = new URL(MODULES_IMPORTS_FILE, settings.dotAstroDir);
+ if (!fs.existsSync(modules)) {
+ return 'export default new Map()';
+ }
+ return fs.readFileSync(modules, 'utf-8');
+ }
},
renderChunk(code, chunk) {
if (!settings.config.experimental.contentCollectionCache) {
@@ -98,6 +177,31 @@ export function astroContentVirtualModPlugin({
return code.replaceAll(RESOLVED_VIRTUAL_MODULE_ID, `${prefix}content/entry.mjs`);
}
},
+
+ configureServer(server) {
+ const dataStorePath = fileURLToPath(dataStoreFile);
+ // Watch for changes to the data store file
+ if (Array.isArray(server.watcher.options.ignored)) {
+ // The data store file is in node_modules, so is ignored by default,
+ // so we need to un-ignore it.
+ server.watcher.options.ignored.push(`!${dataStorePath}`);
+ }
+ server.watcher.add(dataStorePath);
+
+ server.watcher.on('change', (changedPath) => {
+ // If the datastore file changes, invalidate the virtual module
+ if (changedPath === dataStorePath) {
+ const module = server.moduleGraph.getModuleById(RESOLVED_DATA_STORE_VIRTUAL_ID);
+ if (module) {
+ server.moduleGraph.invalidateModule(module);
+ }
+ server.ws.send({
+ type: 'full-reload',
+ path: '*',
+ });
+ }
+ });
+ },
};
}
diff --git a/packages/astro/src/core/app/common.ts b/packages/astro/src/core/app/common.ts
index 19bbee1954..7cfe1c5dd7 100644
--- a/packages/astro/src/core/app/common.ts
+++ b/packages/astro/src/core/app/common.ts
@@ -1,3 +1,4 @@
+import { decodeKey } from '../encryption.js';
import { deserializeRouteData } from '../routing/manifest/serialization.js';
import type { RouteInfo, SSRManifest, SerializedSSRManifest } from './types.js';
@@ -18,6 +19,7 @@ export function deserializeManifest(serializedManifest: SerializedSSRManifest):
const inlinedScripts = new Map(serializedManifest.inlinedScripts);
const clientDirectives = new Map(serializedManifest.clientDirectives);
const serverIslandNameMap = new Map(serializedManifest.serverIslandNameMap);
+ const key = decodeKey(serializedManifest.key);
return {
// in case user middleware exists, this no-op middleware will be reassigned (see plugin-ssr.ts)
@@ -31,5 +33,6 @@ export function deserializeManifest(serializedManifest: SerializedSSRManifest):
clientDirectives,
routes,
serverIslandNameMap,
+ key,
};
}
diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts
index d19a4da7d3..8041dda3c6 100644
--- a/packages/astro/src/core/app/index.ts
+++ b/packages/astro/src/core/app/index.ts
@@ -416,13 +416,15 @@ export class App {
`${this.#baseWithoutTrailingSlash}/${status}${maybeDotHtml}`,
url,
);
- const response = await fetch(statusURL.toString());
+ if (statusURL.toString() !== request.url) {
+ const response = await fetch(statusURL.toString());
- // response for /404.html and 500.html is 200, which is not meaningful
- // so we create an override
- const override = { status };
+ // response for /404.html and 500.html is 200, which is not meaningful
+ // so we create an override
+ const override = { status };
- return this.#mergeResponses(response, originalResponse, override);
+ return this.#mergeResponses(response, originalResponse, override);
+ }
}
const mod = await this.#pipeline.getModuleForRoute(errorRouteData);
try {
diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts
index e028d79aa5..6934f45303 100644
--- a/packages/astro/src/core/app/types.ts
+++ b/packages/astro/src/core/app/types.ts
@@ -66,6 +66,7 @@ export type SSRManifest = {
pageMap?: Map;
serverIslandMap?: Map Promise>;
serverIslandNameMap?: Map;
+ key: Promise;
i18n: SSRManifestI18n | undefined;
middleware: MiddlewareHandler;
checkOrigin: boolean;
@@ -89,6 +90,7 @@ export type SerializedSSRManifest = Omit<
| 'inlinedScripts'
| 'clientDirectives'
| 'serverIslandNameMap'
+ | 'key'
> & {
routes: SerializedRouteInfo[];
assets: string[];
@@ -96,4 +98,5 @@ export type SerializedSSRManifest = Omit<
inlinedScripts: [string, string][];
clientDirectives: [string, string][];
serverIslandNameMap: [string, string][];
+ key: string;
};
diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts
index 334b975971..473a315c59 100644
--- a/packages/astro/src/core/build/generate.ts
+++ b/packages/astro/src/core/build/generate.ts
@@ -77,6 +77,7 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil
internals,
renderers.renderers as SSRLoadedRenderer[],
middleware,
+ options.key,
);
}
const pipeline = BuildPipeline.create({ internals, manifest, options });
@@ -521,6 +522,7 @@ function createBuildManifest(
internals: BuildInternals,
renderers: SSRLoadedRenderer[],
middleware: MiddlewareHandler,
+ key: Promise,
): SSRManifest {
let i18nManifest: SSRManifestI18n | undefined = undefined;
if (settings.config.i18n) {
@@ -551,6 +553,7 @@ function createBuildManifest(
buildFormat: settings.config.build.format,
middleware,
checkOrigin: settings.config.security?.checkOrigin ?? false,
+ key,
envGetSecretEnabled: false,
};
}
diff --git a/packages/astro/src/core/build/index.ts b/packages/astro/src/core/build/index.ts
index 8df72d8b22..72df05b891 100644
--- a/packages/astro/src/core/build/index.ts
+++ b/packages/astro/src/core/build/index.ts
@@ -23,17 +23,18 @@ import { resolveConfig } from '../config/config.js';
import { createNodeLogger } from '../config/logging.js';
import { createSettings } from '../config/settings.js';
import { createVite } from '../create-vite.js';
+import { createKey } from '../encryption.js';
import type { Logger } from '../logger/core.js';
import { levels, timerMessage } from '../logger/core.js';
import { apply as applyPolyfill } from '../polyfill.js';
import { createRouteManifest } from '../routing/index.js';
import { getServerIslandRouteData } from '../server-islands/endpoint.js';
+import { clearContentLayerCache } from '../sync/index.js';
import { ensureProcessNodeEnv, isServerLikeOutput } from '../util.js';
import { collectPagesData } from './page-data.js';
import { staticBuild, viteBuild } from './static-build.js';
import type { StaticBuildOptions } from './types.js';
import { getTimeStat } from './util.js';
-
export interface BuildOptions {
/**
* Teardown the compiler WASM instance after build. This can improve performance when
@@ -43,14 +44,6 @@ export interface BuildOptions {
* @default true
*/
teardownCompiler?: boolean;
-
- /**
- * If `experimental.contentCollectionCache` is enabled, this flag will clear the cache before building
- *
- * @internal not part of our public api
- * @default false
- */
- force?: boolean;
}
/**
@@ -68,13 +61,16 @@ export default async function build(
const logger = createNodeLogger(inlineConfig);
const { userConfig, astroConfig } = await resolveConfig(inlineConfig, 'build');
telemetry.record(eventCliSession('build', userConfig));
- if (astroConfig.experimental.contentCollectionCache && options.force) {
- const contentCacheDir = new URL('./content/', astroConfig.cacheDir);
- if (fs.existsSync(contentCacheDir)) {
- logger.debug('content', 'clearing content cache');
- await fs.promises.rm(contentCacheDir, { force: true, recursive: true });
- logger.warn('content', 'content cache cleared (force)');
+ if (inlineConfig.force) {
+ if (astroConfig.experimental.contentCollectionCache) {
+ const contentCacheDir = new URL('./content/', astroConfig.cacheDir);
+ if (fs.existsSync(contentCacheDir)) {
+ logger.debug('content', 'clearing content cache');
+ await fs.promises.rm(contentCacheDir, { force: true, recursive: true });
+ logger.warn('content', 'content cache cleared (force)');
+ }
}
+ await clearContentLayerCache({ astroConfig, logger, fs });
}
const settings = await createSettings(astroConfig, fileURLToPath(astroConfig.root));
@@ -201,6 +197,7 @@ class AstroBuilder {
pageNames,
teardownCompiler: this.teardownCompiler,
viteConfig,
+ key: createKey(),
};
const { internals, ssrOutputChunkNames, contentFileNames } = await viteBuild(opts);
@@ -241,18 +238,21 @@ class AstroBuilder {
buildMode: this.settings.config.output,
});
}
-
- // Benchmark results
- this.settings.timer.writeStats();
}
/** Build the given Astro project. */
async run() {
+ this.settings.timer.start('Total build');
+
const setupData = await this.setup();
try {
await this.build(setupData);
} catch (_err) {
throw _err;
+ } finally {
+ this.settings.timer.end('Total build');
+ // Benchmark results
+ this.settings.timer.writeStats();
}
}
diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts
index e04589c959..5aee33337b 100644
--- a/packages/astro/src/core/build/plugins/plugin-manifest.ts
+++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts
@@ -12,6 +12,7 @@ import type {
SerializedRouteInfo,
SerializedSSRManifest,
} from '../../app/types.js';
+import { encodeKey } from '../../encryption.js';
import { fileExtension, joinPaths, prependForwardSlash } from '../../path.js';
import { serializeRouteData } from '../../routing/index.js';
import { addRollupInput } from '../add-rollup-input.js';
@@ -132,7 +133,8 @@ async function createManifest(
}
const staticFiles = internals.staticFiles;
- return buildManifest(buildOpts, internals, Array.from(staticFiles));
+ const encodedKey = await encodeKey(await buildOpts.key);
+ return buildManifest(buildOpts, internals, Array.from(staticFiles), encodedKey);
}
/**
@@ -150,6 +152,7 @@ function buildManifest(
opts: StaticBuildOptions,
internals: BuildInternals,
staticFiles: string[],
+ encodedKey: string,
): SerializedSSRManifest {
const { settings } = opts;
@@ -277,6 +280,7 @@ function buildManifest(
buildFormat: settings.config.build.format,
checkOrigin: settings.config.security?.checkOrigin ?? false,
serverIslandNameMap: Array.from(settings.serverIslandNameMap),
+ key: encodedKey,
envGetSecretEnabled:
(settings.adapter?.supportedAstroFeatures.envGetSecret ?? 'unsupported') !== 'unsupported',
};
diff --git a/packages/astro/src/core/build/plugins/plugin-ssr.ts b/packages/astro/src/core/build/plugins/plugin-ssr.ts
index f395141a0f..70997e40ea 100644
--- a/packages/astro/src/core/build/plugins/plugin-ssr.ts
+++ b/packages/astro/src/core/build/plugins/plugin-ssr.ts
@@ -37,6 +37,11 @@ function vitePluginSSR(
inputs.add(getVirtualModulePageName(ASTRO_PAGE_MODULE_ID, pageData.component));
}
+ const adapterServerEntrypoint = options.settings.adapter?.serverEntrypoint;
+ if (adapterServerEntrypoint) {
+ inputs.add(adapterServerEntrypoint);
+ }
+
inputs.add(SSR_VIRTUAL_MODULE_ID);
return addRollupInput(opts, Array.from(inputs));
},
@@ -246,8 +251,8 @@ function generateSSRCode(settings: AstroSettings, adapter: AstroAdapter, middlew
const imports = [
`import { renderers } from '${RENDERERS_MODULE_ID}';`,
- `import { manifest as defaultManifest } from '${SSR_MANIFEST_VIRTUAL_MODULE_ID}';`,
`import * as serverEntrypointModule from '${adapter.serverEntrypoint}';`,
+ `import { manifest as defaultManifest } from '${SSR_MANIFEST_VIRTUAL_MODULE_ID}';`,
edgeMiddleware ? `` : `import { onRequest as middleware } from '${middlewareId}';`,
settings.config.experimental.serverIslands
? `import { serverIslandMap } from '${VIRTUAL_ISLAND_MAP_ID}';`
diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts
index 8626019562..7e2272dde6 100644
--- a/packages/astro/src/core/build/static-build.ts
+++ b/packages/astro/src/core/build/static-build.ts
@@ -255,6 +255,8 @@ async function ssrBuild(
return 'renderers.mjs';
} else if (chunkInfo.facadeModuleId === RESOLVED_SSR_MANIFEST_VIRTUAL_MODULE_ID) {
return 'manifest_[hash].mjs';
+ } else if (chunkInfo.facadeModuleId === settings.adapter?.serverEntrypoint) {
+ return 'adapter_[hash].mjs';
} else if (
settings.config.experimental.contentCollectionCache &&
chunkInfo.facadeModuleId &&
diff --git a/packages/astro/src/core/build/types.ts b/packages/astro/src/core/build/types.ts
index 11724b8244..572140ef66 100644
--- a/packages/astro/src/core/build/types.ts
+++ b/packages/astro/src/core/build/types.ts
@@ -42,6 +42,7 @@ export interface StaticBuildOptions {
pageNames: string[];
viteConfig: InlineConfig;
teardownCompiler: boolean;
+ key: Promise;
}
type ImportComponentInstance = () => Promise;
diff --git a/packages/astro/src/core/config/config.ts b/packages/astro/src/core/config/config.ts
index 2e43661a43..c10066ce32 100644
--- a/packages/astro/src/core/config/config.ts
+++ b/packages/astro/src/core/config/config.ts
@@ -2,14 +2,12 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as colors from 'kleur/colors';
-import type { Arguments as Flags } from 'yargs-parser';
import { ZodError } from 'zod';
import type {
AstroConfig,
AstroInlineConfig,
AstroInlineOnlyConfig,
AstroUserConfig,
- CLIFlags,
} from '../../@types/astro.js';
import { eventConfigError, telemetry } from '../../events/index.js';
import { trackAstroConfigZodError } from '../errors/errors.js';
@@ -19,23 +17,6 @@ import { mergeConfig } from './merge.js';
import { validateConfig } from './validate.js';
import { loadConfigWithVite } from './vite-load.js';
-/** Convert the generic "yargs" flag object into our own, custom TypeScript object. */
-// NOTE: This function will be removed in a later PR. Use `flagsToAstroInlineConfig` instead.
-// All CLI related flow should be located in the `packages/astro/src/cli` directory.
-export function resolveFlags(flags: Partial): CLIFlags {
- return {
- root: typeof flags.root === 'string' ? flags.root : undefined,
- site: typeof flags.site === 'string' ? flags.site : undefined,
- base: typeof flags.base === 'string' ? flags.base : undefined,
- port: typeof flags.port === 'number' ? flags.port : undefined,
- config: typeof flags.config === 'string' ? flags.config : undefined,
- host:
- typeof flags.host === 'string' || typeof flags.host === 'boolean' ? flags.host : undefined,
- open:
- typeof flags.open === 'string' || typeof flags.open === 'boolean' ? flags.open : undefined,
- };
-}
-
export function resolveRoot(cwd?: string | URL): string {
if (cwd instanceof URL) {
cwd = fileURLToPath(cwd);
@@ -66,7 +47,7 @@ async function search(fsMod: typeof fs, root: string) {
interface ResolveConfigPathOptions {
root: string;
- configFile?: string;
+ configFile?: string | false;
fs: typeof fs;
}
diff --git a/packages/astro/src/core/config/index.ts b/packages/astro/src/core/config/index.ts
index 3beaa56635..7ffc290141 100644
--- a/packages/astro/src/core/config/index.ts
+++ b/packages/astro/src/core/config/index.ts
@@ -2,7 +2,6 @@ export {
configPaths,
resolveConfig,
resolveConfigPath,
- resolveFlags,
resolveRoot,
} from './config.js';
export { createNodeLogger } from './logging.js';
diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts
index 313089182a..eb85950099 100644
--- a/packages/astro/src/core/config/schema.ts
+++ b/packages/astro/src/core/config/schema.ts
@@ -93,6 +93,8 @@ export const ASTRO_CONFIG_DEFAULTS = {
clientPrerender: false,
globalRoutePriority: false,
serverIslands: false,
+ contentIntellisense: false,
+ contentLayer: false,
},
} satisfies AstroUserConfig & { server: { open: boolean } };
@@ -537,6 +539,11 @@ export const AstroConfigSchema = z.object({
.boolean()
.optional()
.default(ASTRO_CONFIG_DEFAULTS.experimental.serverIslands),
+ contentIntellisense: z
+ .boolean()
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.experimental.contentIntellisense),
+ contentLayer: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.contentLayer),
})
.strict(
`Invalid or outdated experimental feature.\nCheck for incorrect spelling or outdated Astro version.\nSee https://docs.astro.build/en/reference/configuration-reference/#experimental-flags for a list of all current experiments.`,
diff --git a/packages/astro/src/core/config/settings.ts b/packages/astro/src/core/config/settings.ts
index c3c62edd45..6c878d7f33 100644
--- a/packages/astro/src/core/config/settings.ts
+++ b/packages/astro/src/core/config/settings.ts
@@ -14,7 +14,8 @@ import { loadTSConfig } from './tsconfig.js';
export function createBaseSettings(config: AstroConfig): AstroSettings {
const { contentDir } = getContentPaths(config);
- const preferences = createPreferences(config);
+ const dotAstroDir = new URL('.astro/', config.root);
+ const preferences = createPreferences(config, dotAstroDir);
return {
config,
preferences,
@@ -106,8 +107,9 @@ export function createBaseSettings(config: AstroConfig): AstroSettings {
watchFiles: [],
devToolbarApps: [],
timer: new AstroTimer(),
- dotAstroDir: new URL('.astro/', config.root),
+ dotAstroDir,
latestAstroVersion: undefined, // Will be set later if applicable when the dev server starts
+ injectedTypes: [],
};
}
diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts
index dcf99c687c..61e6a2f9c9 100644
--- a/packages/astro/src/core/create-vite.ts
+++ b/packages/astro/src/core/create-vite.ts
@@ -132,7 +132,7 @@ export async function createVite(
// The server plugin is for dev only and having it run during the build causes
// the build to run very slow as the filewatcher is triggered often.
mode !== 'build' && vitePluginAstroServer({ settings, logger, fs }),
- envVitePlugin({ settings }),
+ envVitePlugin({ settings, logger }),
astroEnv({ settings, mode, sync }),
markdownVitePlugin({ settings, logger }),
htmlVitePlugin(),
diff --git a/packages/astro/src/core/dev/container.ts b/packages/astro/src/core/dev/container.ts
index fc401ca710..159d5e4476 100644
--- a/packages/astro/src/core/dev/container.ts
+++ b/packages/astro/src/core/dev/container.ts
@@ -97,6 +97,7 @@ export async function createContainer({
skip: {
content: true,
},
+ force: inlineConfig?.force,
});
const viteServer = await vite.createServer(viteConfig);
diff --git a/packages/astro/src/core/dev/dev.ts b/packages/astro/src/core/dev/dev.ts
index 3d8424174f..127f34b949 100644
--- a/packages/astro/src/core/dev/dev.ts
+++ b/packages/astro/src/core/dev/dev.ts
@@ -1,4 +1,4 @@
-import fs from 'node:fs';
+import fs, { existsSync } from 'node:fs';
import type http from 'node:http';
import type { AddressInfo } from 'node:net';
import { green } from 'kleur/colors';
@@ -6,7 +6,11 @@ import { performance } from 'perf_hooks';
import { gt, major, minor, patch } from 'semver';
import type * as vite from 'vite';
import type { AstroInlineConfig } from '../../@types/astro.js';
+import { DATA_STORE_FILE } from '../../content/consts.js';
+import { globalContentLayer } from '../../content/content-layer.js';
+import { DataStore, globalDataStore } from '../../content/data-store.js';
import { attachContentServerListeners } from '../../content/index.js';
+import { globalContentConfigObserver } from '../../content/utils.js';
import { telemetry } from '../../events/index.js';
import * as msg from '../messages.js';
import { ensureProcessNodeEnv } from '../util.js';
@@ -102,6 +106,36 @@ export default async function dev(inlineConfig: AstroInlineConfig): Promise handleServerRestart();
- // Set up shortcuts, overriding Vite's default shortcuts so it works for Astro
+ // Set up shortcuts
+
+ const customShortcuts: Array = [
+ // Disable default Vite shortcuts that don't work well with Astro
+ { key: 'r', description: '' },
+ { key: 'u', description: '' },
+ { key: 'c', description: '' },
+ ];
+
+ if (restart.container.settings.config.experimental.contentLayer) {
+ customShortcuts.push({
+ key: 's',
+ description: 'sync content layer',
+ action: () => {
+ if (globalContentLayer.initialized()) {
+ globalContentLayer.get().sync();
+ }
+ },
+ });
+ }
restart.container.viteServer.bindCLIShortcuts({
- customShortcuts: [
- // Disable Vite's builtin "r" (restart server), "u" (print server urls) and "c" (clear console) shortcuts
- { key: 'r', description: '' },
- { key: 'u', description: '' },
- { key: 'c', description: '' },
- ],
+ customShortcuts,
});
}
setupContainer();
diff --git a/packages/astro/src/core/encryption.ts b/packages/astro/src/core/encryption.ts
new file mode 100644
index 0000000000..ccfc9bdd27
--- /dev/null
+++ b/packages/astro/src/core/encryption.ts
@@ -0,0 +1,88 @@
+import { decodeBase64, decodeHex, encodeBase64, encodeHexUpperCase } from '@oslojs/encoding';
+
+// Chose this algorithm for no particular reason, can change.
+// This algo does check against text manipulation though. See
+// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/encrypt#aes-gcm
+const ALGORITHM = 'AES-GCM';
+
+/**
+ * Creates a CryptoKey object that can be used to encrypt any string.
+ */
+export async function createKey() {
+ const key = await crypto.subtle.generateKey(
+ {
+ name: ALGORITHM,
+ length: 256,
+ },
+ true,
+ ['encrypt', 'decrypt'],
+ );
+ return key;
+}
+
+/**
+ * Takes a key that has been serialized to an array of bytes and returns a CryptoKey
+ */
+export async function importKey(bytes: Uint8Array): Promise {
+ const key = await crypto.subtle.importKey('raw', bytes, ALGORITHM, true, ['encrypt', 'decrypt']);
+ return key;
+}
+
+/**
+ * Encodes a CryptoKey to base64 string, so that it can be embedded in JSON / JavaScript
+ */
+export async function encodeKey(key: CryptoKey) {
+ const exported = await crypto.subtle.exportKey('raw', key);
+ const encodedKey = encodeBase64(new Uint8Array(exported));
+ return encodedKey;
+}
+
+/**
+ * Decodes a base64 string into bytes and then imports the key.
+ */
+export async function decodeKey(encoded: string): Promise {
+ const bytes = decodeBase64(encoded);
+ return crypto.subtle.importKey('raw', bytes, ALGORITHM, true, ['encrypt', 'decrypt']);
+}
+
+const encoder = new TextEncoder();
+const decoder = new TextDecoder();
+// The length of the initialization vector
+// See https://developer.mozilla.org/en-US/docs/Web/API/AesGcmParams
+const IV_LENGTH = 24;
+
+/**
+ * Using a CryptoKey, encrypt a string into a base64 string.
+ */
+export async function encryptString(key: CryptoKey, raw: string) {
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH / 2));
+ const data = encoder.encode(raw);
+ const buffer = await crypto.subtle.encrypt(
+ {
+ name: ALGORITHM,
+ iv,
+ },
+ key,
+ data,
+ );
+ // iv is 12, hex brings it to 24
+ return encodeHexUpperCase(iv) + encodeBase64(new Uint8Array(buffer));
+}
+
+/**
+ * Takes a base64 encoded string, decodes it and returns the decrypted text.
+ */
+export async function decryptString(key: CryptoKey, encoded: string) {
+ const iv = decodeHex(encoded.slice(0, IV_LENGTH));
+ const dataArray = decodeBase64(encoded.slice(IV_LENGTH));
+ const decryptedBuffer = await crypto.subtle.decrypt(
+ {
+ name: ALGORITHM,
+ iv,
+ },
+ key,
+ dataArray,
+ );
+ const decryptedString = decoder.decode(decryptedBuffer);
+ return decryptedString;
+}
diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts
index 8415a9c0a3..aa19b6dc3b 100644
--- a/packages/astro/src/core/errors/errors-data.ts
+++ b/packages/astro/src/core/errors/errors-data.ts
@@ -1291,6 +1291,17 @@ export const RewriteWithBodyUsed = {
'Astro.rewrite() cannot be used if the request body has already been read. If you need to read the body, first clone the request.',
} satisfies ErrorData;
+/**
+ * @docs
+ * @description
+ * An unknown error occured while reading or writing files to disk. It can be caused by many things, eg. missing permissions or a file not existing we attempt to read.
+ */
+export const UnknownFilesystemError = {
+ name: 'UnknownFilesystemError',
+ title: 'An unknown error occured while reading or writing files to disk.',
+ hint: 'It can be caused by many things, eg. missing permissions or a file not existing we attempt to read. Check the error cause for more details.',
+} satisfies ErrorData;
+
/**
* @docs
* @kind heading
@@ -1469,6 +1480,20 @@ export const UnknownContentCollectionError = {
name: 'UnknownContentCollectionError',
title: 'Unknown Content Collection Error.',
} satisfies ErrorData;
+
+/**
+ * @docs
+ * @description
+ * The `getDataEntryById` and `getEntryBySlug` functions are deprecated and cannot be used with content layer collections. Use the `getEntry` function instead.
+ */
+export const GetEntryDeprecationError = {
+ name: 'GetEntryDeprecationError',
+ title: 'Invalid use of `getDataEntryById` or `getEntryBySlug` function.',
+ message: (collection: string, method: string) =>
+ `The \`${method}\` function is deprecated and cannot be used to query the "${collection}" collection. Use \`getEntry\` instead.`,
+ hint: 'Use the `getEntry` or `getCollection` functions to query content layer collections.',
+} satisfies ErrorData;
+
/**
* @docs
* @message
diff --git a/packages/astro/src/core/index.ts b/packages/astro/src/core/index.ts
index e0f9f6c824..bdd7c7f059 100644
--- a/packages/astro/src/core/index.ts
+++ b/packages/astro/src/core/index.ts
@@ -23,4 +23,4 @@ export const build = (inlineConfig: AstroInlineConfig) => _build(inlineConfig);
* @experimental The JavaScript API is experimental
*/
// Wrap `_sync` to prevent exposing internal options
-export const sync = (inlineConfig: AstroInlineConfig) => _sync({ inlineConfig });
+export const sync = (inlineConfig: AstroInlineConfig) => _sync(inlineConfig);
diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts
index a572215744..a19d11080a 100644
--- a/packages/astro/src/core/render-context.ts
+++ b/packages/astro/src/core/render-context.ts
@@ -344,6 +344,7 @@ export class RenderContext {
styles,
actionResult,
serverIslandNameMap: manifest.serverIslandNameMap ?? new Map(),
+ key: manifest.key,
trailingSlash: manifest.trailingSlash,
_metadata: {
hasHydrationScript: false,
diff --git a/packages/astro/src/core/server-islands/endpoint.ts b/packages/astro/src/core/server-islands/endpoint.ts
index 638e228829..368a1b9b19 100644
--- a/packages/astro/src/core/server-islands/endpoint.ts
+++ b/packages/astro/src/core/server-islands/endpoint.ts
@@ -11,6 +11,7 @@ import {
renderTemplate,
} from '../../runtime/server/index.js';
import { createSlotValueFromString } from '../../runtime/server/render/slot.js';
+import { decryptString } from '../encryption.js';
import { getPattern } from '../routing/manifest/pattern.js';
export const SERVER_ISLAND_ROUTE = '/_server-islands/[name]';
@@ -48,7 +49,7 @@ export function ensureServerIslandRoute(config: ConfigFields, routeManifest: Man
type RenderOptions = {
componentExport: string;
- props: Record;
+ encryptedProps: string;
slots: Record;
};
@@ -74,7 +75,11 @@ export function createEndpoint(manifest: SSRManifest) {
});
}
- const props = data.props;
+ const key = await manifest.key;
+ const encryptedProps = data.encryptedProps;
+ const propString = await decryptString(key, encryptedProps);
+ const props = JSON.parse(propString);
+
const componentModule = await imp();
const Component = (componentModule as any)[data.componentExport];
diff --git a/packages/astro/src/core/sync/constants.ts b/packages/astro/src/core/sync/constants.ts
new file mode 100644
index 0000000000..7ff603105a
--- /dev/null
+++ b/packages/astro/src/core/sync/constants.ts
@@ -0,0 +1,2 @@
+// TODO: use types.d.ts for backward compatibility. Use astro.d.ts in Astro 5.0
+export const REFERENCE_FILE = './types.d.ts';
diff --git a/packages/astro/src/core/sync/index.ts b/packages/astro/src/core/sync/index.ts
index 7b0d3268a0..c9b2ec235b 100644
--- a/packages/astro/src/core/sync/index.ts
+++ b/packages/astro/src/core/sync/index.ts
@@ -1,16 +1,17 @@
-import fsMod from 'node:fs';
+import fsMod, { existsSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
-import { fileURLToPath } from 'node:url';
import { dim } from 'kleur/colors';
import { type HMRPayload, createServer } from 'vite';
import type { AstroConfig, AstroInlineConfig, AstroSettings } from '../../@types/astro.js';
-import { getPackage } from '../../cli/install-package.js';
+import { DATA_STORE_FILE } from '../../content/consts.js';
+import { globalContentLayer } from '../../content/content-layer.js';
+import { DataStore, globalDataStore } from '../../content/data-store.js';
import { createContentTypesGenerator } from '../../content/index.js';
import { globalContentConfigObserver } from '../../content/utils.js';
import { syncAstroEnv } from '../../env/sync.js';
import { telemetry } from '../../events/index.js';
import { eventCliSession } from '../../events/session.js';
-import { runHookConfigSetup } from '../../integrations/hooks.js';
+import { runHookConfigDone, runHookConfigSetup } from '../../integrations/hooks.js';
import { getTimeStat } from '../build/util.js';
import { resolveConfig } from '../config/config.js';
import { createNodeLogger } from '../config/logging.js';
@@ -27,7 +28,7 @@ import {
import type { Logger } from '../logger/core.js';
import { formatErrorMessage } from '../messages.js';
import { ensureProcessNodeEnv } from '../util.js';
-import { setUpEnvTs } from './setup-env-ts.js';
+import { writeFiles } from './write-files.js';
export type SyncOptions = {
/**
@@ -36,21 +37,17 @@ export type SyncOptions = {
fs?: typeof fsMod;
logger: Logger;
settings: AstroSettings;
+ force?: boolean;
skip?: {
// Must be skipped in dev
content?: boolean;
};
};
-type DBPackage = {
- typegen?: (args: Pick) => Promise;
-};
-
-export default async function sync({
- inlineConfig,
- fs,
- telemetry: _telemetry = false,
-}: { inlineConfig: AstroInlineConfig; fs?: typeof fsMod; telemetry?: boolean }) {
+export default async function sync(
+ inlineConfig: AstroInlineConfig,
+ { fs, telemetry: _telemetry = false }: { fs?: typeof fsMod; telemetry?: boolean } = {},
+) {
ensureProcessNodeEnv('production');
const logger = createNodeLogger(inlineConfig);
const { astroConfig, userConfig } = await resolveConfig(inlineConfig ?? {}, 'sync');
@@ -63,7 +60,24 @@ export default async function sync({
settings,
logger,
});
- return await syncInternal({ settings, logger, fs });
+ await runHookConfigDone({ settings, logger });
+ return await syncInternal({ settings, logger, fs, force: inlineConfig.force });
+}
+
+/**
+ * Clears the content layer and content collection cache, forcing a full rebuild.
+ */
+export async function clearContentLayerCache({
+ astroConfig,
+ logger,
+ fs = fsMod,
+}: { astroConfig: AstroConfig; logger: Logger; fs?: typeof fsMod }) {
+ const dataStore = new URL(DATA_STORE_FILE, astroConfig.cacheDir);
+ if (fs.existsSync(dataStore)) {
+ logger.debug('content', 'clearing data store');
+ await fs.promises.rm(dataStore, { force: true });
+ logger.warn('content', 'data store cleared (force)');
+ }
}
/**
@@ -77,28 +91,43 @@ export async function syncInternal({
fs = fsMod,
settings,
skip,
+ force,
}: SyncOptions): Promise {
- const cwd = fileURLToPath(settings.config.root);
+ if (force) {
+ await clearContentLayerCache({ astroConfig: settings.config, logger, fs });
+ }
const timerStart = performance.now();
- const dbPackage = await getPackage(
- '@astrojs/db',
- logger,
- {
- optional: true,
- cwd,
- },
- [],
- );
try {
- await dbPackage?.typegen?.(settings.config);
if (!skip?.content) {
await syncContentCollections(settings, { fs, logger });
+ settings.timer.start('Sync content layer');
+ let store: DataStore | undefined;
+ try {
+ const dataStoreFile = new URL(DATA_STORE_FILE, settings.config.cacheDir);
+ if (existsSync(dataStoreFile)) {
+ store = await DataStore.fromFile(dataStoreFile);
+ globalDataStore.set(store);
+ }
+ } catch (err: any) {
+ logger.error('content', err.message);
+ }
+ if (!store) {
+ store = new DataStore();
+ globalDataStore.set(store);
+ }
+ const contentLayer = globalContentLayer.init({
+ settings,
+ logger,
+ store,
+ });
+ await contentLayer.sync();
+ settings.timer.end('Sync content layer');
}
syncAstroEnv(settings, fs);
- await setUpEnvTs({ settings, logger, fs });
+ await writeFiles(settings, fs, logger);
logger.info('types', `Generated ${dim(getTimeStat(timerStart, performance.now()))}`);
} catch (err) {
const error = createSafeError(err);
diff --git a/packages/astro/src/core/sync/setup-env-ts.ts b/packages/astro/src/core/sync/setup-env-ts.ts
deleted file mode 100644
index d92e27ef83..0000000000
--- a/packages/astro/src/core/sync/setup-env-ts.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-import type fsMod from 'node:fs';
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
-import { bold } from 'kleur/colors';
-import { normalizePath } from 'vite';
-import type { AstroSettings } from '../../@types/astro.js';
-import { ACTIONS_TYPES_FILE } from '../../actions/consts.js';
-import { CONTENT_TYPES_FILE } from '../../content/consts.js';
-import { ENV_TYPES_FILE } from '../../env/constants.js';
-import { type Logger } from '../logger/core.js';
-
-function getDotAstroTypeReference({
- settings,
- filename,
-}: { settings: AstroSettings; filename: string }) {
- const relativePath = normalizePath(
- path.relative(
- fileURLToPath(settings.config.srcDir),
- fileURLToPath(new URL(filename, settings.dotAstroDir)),
- ),
- );
-
- return `/// `;
-}
-
-type InjectedType = { filename: string; meetsCondition?: () => boolean | Promise };
-
-export async function setUpEnvTs({
- settings,
- logger,
- fs,
-}: {
- settings: AstroSettings;
- logger: Logger;
- fs: typeof fsMod;
-}) {
- const envTsPath = new URL('env.d.ts', settings.config.srcDir);
- const envTsPathRelativetoRoot = normalizePath(
- path.relative(fileURLToPath(settings.config.root), fileURLToPath(envTsPath)),
- );
-
- const injectedTypes: Array = [
- {
- filename: CONTENT_TYPES_FILE,
- meetsCondition: () => fs.existsSync(new URL(CONTENT_TYPES_FILE, settings.dotAstroDir)),
- },
- {
- filename: ACTIONS_TYPES_FILE,
- meetsCondition: () => fs.existsSync(new URL(ACTIONS_TYPES_FILE, settings.dotAstroDir)),
- },
- {
- filename: ENV_TYPES_FILE,
- meetsCondition: () => fs.existsSync(new URL(ENV_TYPES_FILE, settings.dotAstroDir)),
- },
- ];
-
- if (fs.existsSync(envTsPath)) {
- const initialEnvContents = await fs.promises.readFile(envTsPath, 'utf-8');
- let typesEnvContents = initialEnvContents;
-
- for (const injectedType of injectedTypes) {
- if (!injectedType.meetsCondition || (await injectedType.meetsCondition?.())) {
- const expectedTypeReference = getDotAstroTypeReference({
- settings,
- filename: injectedType.filename,
- });
-
- if (!typesEnvContents.includes(expectedTypeReference)) {
- typesEnvContents = `${expectedTypeReference}\n${typesEnvContents}`;
- }
- }
- }
-
- if (initialEnvContents !== typesEnvContents) {
- logger.info('types', `Updated ${bold(envTsPathRelativetoRoot)} type declarations.`);
- await fs.promises.writeFile(envTsPath, typesEnvContents, 'utf-8');
- }
- } else {
- // Otherwise, inject the `env.d.ts` file
- let referenceDefs: string[] = [];
- referenceDefs.push('/// ');
-
- for (const injectedType of injectedTypes) {
- if (!injectedType.meetsCondition || (await injectedType.meetsCondition?.())) {
- referenceDefs.push(getDotAstroTypeReference({ settings, filename: injectedType.filename }));
- }
- }
-
- await fs.promises.mkdir(settings.config.srcDir, { recursive: true });
- await fs.promises.writeFile(envTsPath, referenceDefs.join('\n'), 'utf-8');
- logger.info('types', `Added ${bold(envTsPathRelativetoRoot)} type declarations`);
- }
-}
diff --git a/packages/astro/src/core/sync/write-files.ts b/packages/astro/src/core/sync/write-files.ts
new file mode 100644
index 0000000000..56ab131f1b
--- /dev/null
+++ b/packages/astro/src/core/sync/write-files.ts
@@ -0,0 +1,78 @@
+import type fsMod from 'node:fs';
+import { dirname, relative } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { bold } from 'kleur/colors';
+import { normalizePath } from 'vite';
+import type { AstroSettings } from '../../@types/astro.js';
+import type { Logger } from '../logger/core.js';
+import { REFERENCE_FILE } from './constants.js';
+import { AstroError, AstroErrorData } from '../errors/index.js';
+
+export async function writeFiles(settings: AstroSettings, fs: typeof fsMod, logger: Logger) {
+ try {
+ writeInjectedTypes(settings, fs);
+ await setUpEnvTs(settings, fs, logger);
+ } catch (e) {
+ throw new AstroError(AstroErrorData.UnknownFilesystemError, { cause: e });
+ }
+}
+
+function getTsReference(type: 'path' | 'types', value: string) {
+ return `/// `;
+}
+
+const CLIENT_TYPES_REFERENCE = getTsReference('types', 'astro/client');
+
+function writeInjectedTypes(settings: AstroSettings, fs: typeof fsMod) {
+ const references: Array = [];
+
+ for (const { filename, content } of settings.injectedTypes) {
+ const filepath = normalizePath(fileURLToPath(new URL(filename, settings.dotAstroDir)));
+ fs.mkdirSync(dirname(filepath), { recursive: true });
+ fs.writeFileSync(filepath, content, 'utf-8');
+ references.push(normalizePath(relative(fileURLToPath(settings.dotAstroDir), filepath)));
+ }
+
+ const astroDtsContent = `${CLIENT_TYPES_REFERENCE}\n${references.map((reference) => getTsReference('path', reference)).join('\n')}`;
+ if (references.length === 0) {
+ fs.mkdirSync(settings.dotAstroDir, { recursive: true });
+ }
+ fs.writeFileSync(
+ normalizePath(fileURLToPath(new URL(REFERENCE_FILE, settings.dotAstroDir))),
+ astroDtsContent,
+ 'utf-8',
+ );
+}
+
+async function setUpEnvTs(settings: AstroSettings, fs: typeof fsMod, logger: Logger) {
+ const envTsPath = normalizePath(fileURLToPath(new URL('env.d.ts', settings.config.srcDir)));
+ const envTsPathRelativetoRoot = normalizePath(
+ relative(fileURLToPath(settings.config.root), envTsPath),
+ );
+ const relativePath = normalizePath(
+ relative(
+ fileURLToPath(settings.config.srcDir),
+ fileURLToPath(new URL(REFERENCE_FILE, settings.dotAstroDir)),
+ ),
+ );
+ const expectedTypeReference = getTsReference('path', relativePath);
+
+ if (fs.existsSync(envTsPath)) {
+ const initialEnvContents = await fs.promises.readFile(envTsPath, 'utf-8');
+ let typesEnvContents = initialEnvContents;
+
+ if (!typesEnvContents.includes(expectedTypeReference)) {
+ typesEnvContents = `${expectedTypeReference}\n${typesEnvContents}`;
+ }
+
+ if (initialEnvContents !== typesEnvContents) {
+ logger.info('types', `Updated ${bold(envTsPathRelativetoRoot)} type declarations.`);
+ await fs.promises.writeFile(envTsPath, typesEnvContents, 'utf-8');
+ }
+ } else {
+ // Otherwise, inject the `env.d.ts` file
+ await fs.promises.mkdir(settings.config.srcDir, { recursive: true });
+ await fs.promises.writeFile(envTsPath, expectedTypeReference, 'utf-8');
+ logger.info('types', `Added ${bold(envTsPathRelativetoRoot)} type declarations`);
+ }
+}
diff --git a/packages/astro/src/core/util.ts b/packages/astro/src/core/util.ts
index 2dd4ddd3b4..654d198299 100644
--- a/packages/astro/src/core/util.ts
+++ b/packages/astro/src/core/util.ts
@@ -153,7 +153,7 @@ export function isPage(file: URL, settings: AstroSettings): boolean {
export function isEndpoint(file: URL, settings: AstroSettings): boolean {
if (!isInPagesDir(file, settings.config)) return false;
if (!isPublicRoute(file, settings.config)) return false;
- return !endsWithPageExt(file, settings);
+ return !endsWithPageExt(file, settings) && !file.toString().includes('?astro');
}
export function isServerLikeOutput(config: AstroConfig) {
diff --git a/packages/astro/src/env/sync.ts b/packages/astro/src/env/sync.ts
index 597d222b62..f0d9933ffa 100644
--- a/packages/astro/src/env/sync.ts
+++ b/packages/astro/src/env/sync.ts
@@ -1,9 +1,8 @@
-import fsMod from 'node:fs';
import type { AstroSettings } from '../@types/astro.js';
import { ENV_TYPES_FILE } from './constants.js';
import { getEnvFieldType } from './validators.js';
-export function syncAstroEnv(settings: AstroSettings, fs = fsMod) {
+export function syncAstroEnv(settings: AstroSettings): void {
let client: string | null = null;
let server: string | null = null;
@@ -21,17 +20,18 @@ export function syncAstroEnv(settings: AstroSettings, fs = fsMod) {
let content: string | null = null;
if (client !== null) {
content = `declare module 'astro:env/client' {
-${client}
-}`;
+${client}}`;
}
if (server !== null) {
content ??= '';
content += `declare module 'astro:env/server' {
-${server}
-}`;
+${server}}`;
}
+
if (content) {
- fs.mkdirSync(settings.dotAstroDir, { recursive: true });
- fs.writeFileSync(new URL(ENV_TYPES_FILE, settings.dotAstroDir), content, 'utf-8');
+ settings.injectedTypes.push({
+ filename: ENV_TYPES_FILE,
+ content,
+ });
}
}
diff --git a/packages/astro/src/integrations/hooks.ts b/packages/astro/src/integrations/hooks.ts
index 9b2859e48b..c0b9604335 100644
--- a/packages/astro/src/integrations/hooks.ts
+++ b/packages/astro/src/integrations/hooks.ts
@@ -13,6 +13,7 @@ import type {
DataEntryType,
HookParameters,
RouteData,
+ RouteOptions,
} from '../@types/astro.js';
import type { SerializedSSRManifest } from '../core/app/types.js';
import type { PageBuildData } from '../core/build/types.js';
@@ -100,6 +101,18 @@ export function getToolbarServerCommunicationHelpers(server: ViteDevServer) {
};
}
+// Will match any invalid characters (will be converted to _). We only allow a-zA-Z0-9.-_
+const SAFE_CHARS_RE = /[^\w.-]/g;
+
+export function normalizeInjectedTypeFilename(filename: string, integrationName: string): string {
+ if (!filename.endsWith('.d.ts')) {
+ throw new Error(
+ `Integration ${bold(integrationName)} is injecting a type that does not end with "${bold('.d.ts')}"`,
+ );
+ }
+ return `./integrations/${integrationName.replace(SAFE_CHARS_RE, '_')}/${filename.replace(SAFE_CHARS_RE, '_')}`;
+}
+
export async function runHookConfigSetup({
settings,
command,
@@ -327,6 +340,19 @@ export async function runHookConfigDone({
}
settings.adapter = adapter;
},
+ injectTypes(injectedType) {
+ const normalizedFilename = normalizeInjectedTypeFilename(
+ injectedType.filename,
+ integration.name,
+ );
+
+ settings.injectedTypes.push({
+ filename: normalizedFilename,
+ content: injectedType.content,
+ });
+
+ return new URL(normalizedFilename, settings.config.root);
+ },
logger: getLogger(integration, logger),
}),
logger,
@@ -558,6 +584,47 @@ export async function runHookBuildDone({
}
}
+export async function runHookRouteSetup({
+ route,
+ settings,
+ logger,
+}: {
+ route: RouteOptions;
+ settings: AstroSettings;
+ logger: Logger;
+}) {
+ const prerenderChangeLogs: { integrationName: string; value: boolean | undefined }[] = [];
+
+ for (const integration of settings.config.integrations) {
+ if (integration?.hooks?.['astro:route:setup']) {
+ const originalRoute = { ...route };
+ const integrationLogger = getLogger(integration, logger);
+
+ await withTakingALongTimeMsg({
+ name: integration.name,
+ hookName: 'astro:route:setup',
+ hookResult: integration.hooks['astro:route:setup']({
+ route,
+ logger: integrationLogger,
+ }),
+ logger,
+ });
+
+ if (route.prerender !== originalRoute.prerender) {
+ prerenderChangeLogs.push({ integrationName: integration.name, value: route.prerender });
+ }
+ }
+ }
+
+ if (prerenderChangeLogs.length > 1) {
+ logger.debug(
+ 'router',
+ `The ${route.component} route's prerender option has been changed multiple times by integrations:\n` +
+ prerenderChangeLogs.map((log) => `- ${log.integrationName}: ${log.value}`).join('\n'),
+ );
+ }
+}
+
export function isFunctionPerRouteEnabled(adapter: AstroAdapter | undefined): boolean {
if (adapter?.adapterFeatures?.functionPerRoute === true) {
return true;
diff --git a/packages/astro/src/preferences/index.ts b/packages/astro/src/preferences/index.ts
index 9318824bf6..2b92c5fb6c 100644
--- a/packages/astro/src/preferences/index.ts
+++ b/packages/astro/src/preferences/index.ts
@@ -82,9 +82,9 @@ export function coerce(key: string, value: unknown) {
return value as any;
}
-export default function createPreferences(config: AstroConfig): AstroPreferences {
+export default function createPreferences(config: AstroConfig, dotAstroDir: URL): AstroPreferences {
const global = new PreferenceStore(getGlobalPreferenceDir());
- const project = new PreferenceStore(fileURLToPath(new URL('./.astro/', config.root)));
+ const project = new PreferenceStore(fileURLToPath(dotAstroDir));
const stores: Record = { global, project };
return {
diff --git a/packages/astro/src/runtime/server/render/server-islands.ts b/packages/astro/src/runtime/server/render/server-islands.ts
index c2263addaa..58cce4e148 100644
--- a/packages/astro/src/runtime/server/render/server-islands.ts
+++ b/packages/astro/src/runtime/server/render/server-islands.ts
@@ -1,4 +1,5 @@
import type { SSRResult } from '../../../@types/astro.js';
+import { encryptString } from '../../../core/encryption.js';
import { renderChild } from './any.js';
import type { RenderInstance } from './common.js';
import { type ComponentSlots, renderSlotToString } from './slot.js';
@@ -59,6 +60,9 @@ export function renderServerIsland(
}
}
+ const key = await result.key;
+ const propsEncrypted = await encryptString(key, JSON.stringify(props));
+
const hostId = crypto.randomUUID();
const serverIslandUrl = `${result.base}_server-islands/${componentId}${result.trailingSlash === 'always' ? '/' : ''}`;
@@ -68,7 +72,7 @@ let componentExport = ${safeJsonStringify(componentExport)};
let script = document.querySelector('script[data-island-id="${hostId}"]');
let data = {
componentExport,
- props: ${safeJsonStringify(props)},
+ encryptedProps: ${safeJsonStringify(propsEncrypted)},
slots: ${safeJsonStringify(renderedSlots)},
};
diff --git a/packages/astro/src/runtime/server/render/util.ts b/packages/astro/src/runtime/server/render/util.ts
index 5dc821aa5c..019bf9a408 100644
--- a/packages/astro/src/runtime/server/render/util.ts
+++ b/packages/astro/src/runtime/server/render/util.ts
@@ -8,9 +8,6 @@ export const voidElementNames =
/^(area|base|br|col|command|embed|hr|img|input|keygen|link|meta|param|source|track|wbr)$/i;
const htmlBooleanAttributes =
/^(?:allowfullscreen|async|autofocus|autoplay|controls|default|defer|disabled|disablepictureinpicture|disableremoteplayback|formnovalidate|hidden|loop|nomodule|novalidate|open|playsinline|readonly|required|reversed|scoped|seamless|itemscope)$/i;
-const htmlEnumAttributes = /^(?:contenteditable|draggable|spellcheck|value)$/i;
-// Note: SVG is case-sensitive!
-const svgEnumAttributes = /^(?:autoReverse|externalResourcesRequired|focusable|preserveAlpha)$/i;
const AMPERSAND_REGEX = /&/g;
const DOUBLE_QUOTE_REGEX = /"/g;
@@ -67,13 +64,6 @@ export function addAttribute(value: any, key: string, shouldEscape = true) {
return '';
}
- if (value === false) {
- if (htmlEnumAttributes.test(key) || svgEnumAttributes.test(key)) {
- return markHTMLString(` ${key}="false"`);
- }
- return '';
- }
-
// compiler directives cannot be applied dynamically, log a warning and ignore.
if (STATIC_DIRECTIVES.has(key)) {
// eslint-disable-next-line no-console
@@ -115,11 +105,16 @@ Make sure to use the static attribute syntax (\`${key}={value}\`) instead of the
}
// Boolean values only need the key
- if (value === true && (key.startsWith('data-') || htmlBooleanAttributes.test(key))) {
- return markHTMLString(` ${key}`);
- } else {
- return markHTMLString(` ${key}="${toAttributeString(value, shouldEscape)}"`);
+ if (htmlBooleanAttributes.test(key)) {
+ return markHTMLString(value ? ` ${key}` : '');
}
+
+ // Other attributes with an empty string value can omit rendering the value
+ if (value === '') {
+ return markHTMLString(` ${key}`);
+ }
+
+ return markHTMLString(` ${key}="${toAttributeString(value, shouldEscape)}"`);
}
// Adds support for `
diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts
index 0d8e2dc74a..f79caa284f 100644
--- a/packages/astro/src/vite-plugin-astro-server/plugin.ts
+++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts
@@ -4,6 +4,7 @@ import { IncomingMessage } from 'node:http';
import type * as vite from 'vite';
import type { AstroSettings, ManifestData, SSRManifest } from '../@types/astro.js';
import type { SSRManifestI18n } from '../core/app/types.js';
+import { createKey } from '../core/encryption.js';
import { getViteErrorPayload } from '../core/errors/dev/index.js';
import { AstroError, AstroErrorData } from '../core/errors/index.js';
import { patchOverlay } from '../core/errors/overlay.js';
@@ -129,6 +130,7 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest
domainLookupTable: {},
};
}
+
return {
hrefRoot: settings.config.root.toString(),
trailingSlash: settings.config.trailingSlash,
@@ -148,6 +150,7 @@ export function createDevelopmentManifest(settings: AstroSettings): SSRManifest
i18n: i18nManifest,
checkOrigin: settings.config.security?.checkOrigin ?? false,
envGetSecretEnabled: false,
+ key: createKey(),
middleware(_, next) {
return next();
},
diff --git a/packages/astro/src/vite-plugin-env/index.ts b/packages/astro/src/vite-plugin-env/index.ts
index ea75e752f5..350a29b7ff 100644
--- a/packages/astro/src/vite-plugin-env/index.ts
+++ b/packages/astro/src/vite-plugin-env/index.ts
@@ -1,12 +1,15 @@
import { fileURLToPath } from 'node:url';
import { transform } from 'esbuild';
+import { bold } from 'kleur/colors';
import MagicString from 'magic-string';
import type * as vite from 'vite';
import { loadEnv } from 'vite';
import type { AstroConfig, AstroSettings } from '../@types/astro.js';
+import type { Logger } from '../core/logger/core.js';
interface EnvPluginOptions {
settings: AstroSettings;
+ logger: Logger;
}
// Match `import.meta.env` directly without trailing property access
@@ -116,7 +119,7 @@ async function replaceDefine(
};
}
-export default function envVitePlugin({ settings }: EnvPluginOptions): vite.Plugin {
+export default function envVitePlugin({ settings, logger }: EnvPluginOptions): vite.Plugin {
let privateEnv: Record;
let defaultDefines: Record;
let isDev: boolean;
@@ -170,13 +173,25 @@ export default function envVitePlugin({ settings }: EnvPluginOptions): vite.Plug
s.prepend(devImportMetaEnvPrepend);
// EDGE CASE: We need to do a static replacement for `export const prerender` for `vite-plugin-scanner`
+ // TODO: Remove in Astro 5
+ let exportConstPrerenderStr: string | undefined;
s.replace(exportConstPrerenderRe, (m, key) => {
+ exportConstPrerenderStr = m;
if (privateEnv[key] != null) {
return `export const prerender = ${privateEnv[key]}`;
} else {
return m;
}
});
+ if (exportConstPrerenderStr) {
+ logger.warn(
+ 'router',
+ `Exporting dynamic values from prerender is deprecated. Please use an integration with the "astro:route:setup" hook ` +
+ `to update the route's \`prerender\` option instead. This allows for better treeshaking and bundling configuration ` +
+ `in the future. See https://docs.astro.build/en/reference/integrations-reference/#astroroutesetup for a migration example.` +
+ `\nFound \`${bold(exportConstPrerenderStr)}\` in ${bold(id)}.`,
+ );
+ }
return {
code: s.toString(),
diff --git a/packages/astro/src/vite-plugin-markdown/content-entry-type.ts b/packages/astro/src/vite-plugin-markdown/content-entry-type.ts
index 131422298d..9b78d8f351 100644
--- a/packages/astro/src/vite-plugin-markdown/content-entry-type.ts
+++ b/packages/astro/src/vite-plugin-markdown/content-entry-type.ts
@@ -1,4 +1,5 @@
-import { fileURLToPath } from 'node:url';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { createMarkdownProcessor } from '@astrojs/markdown-remark';
import type { ContentEntryType } from '../@types/astro.js';
import { safeParseFrontmatter } from '../content/utils.js';
@@ -15,4 +16,27 @@ export const markdownContentEntryType: ContentEntryType = {
},
// We need to handle propagation for Markdown because they support layouts which will bring in styles.
handlePropagation: true,
+
+ async getRenderFunction(settings) {
+ const processor = await createMarkdownProcessor(settings.config.markdown);
+ return async function renderToString(entry) {
+ if (!entry.body) {
+ return {
+ html: '',
+ };
+ }
+ const result = await processor.render(entry.body, {
+ frontmatter: entry.data,
+ // @ts-expect-error Internal API
+ fileURL: entry.filePath ? pathToFileURL(entry.filePath) : undefined,
+ });
+ return {
+ html: result.code,
+ metadata: {
+ ...result.metadata,
+ imagePaths: Array.from(result.metadata.imagePaths),
+ },
+ };
+ };
+ },
};
diff --git a/packages/astro/src/vite-plugin-scanner/index.ts b/packages/astro/src/vite-plugin-scanner/index.ts
index 05be99a074..842857777a 100644
--- a/packages/astro/src/vite-plugin-scanner/index.ts
+++ b/packages/astro/src/vite-plugin-scanner/index.ts
@@ -2,11 +2,13 @@ import { extname } from 'node:path';
import { bold } from 'kleur/colors';
import type { Plugin as VitePlugin } from 'vite';
import { normalizePath } from 'vite';
-import type { AstroSettings } from '../@types/astro.js';
+import type { AstroSettings, RouteOptions } from '../@types/astro.js';
import { type Logger } from '../core/logger/core.js';
import { isEndpoint, isPage, isServerLikeOutput } from '../core/util.js';
import { rootRelativePath } from '../core/viteUtils.js';
+import { runHookRouteSetup } from '../integrations/hooks.js';
import { getPrerenderDefault } from '../prerender/utils.js';
+import type { PageOptions } from '../vite-plugin-astro/types.js';
import { scan } from './scan.js';
export interface AstroPluginScannerOptions {
@@ -39,12 +41,8 @@ export default function astroScannerPlugin({
const fileIsPage = isPage(fileURL, settings);
const fileIsEndpoint = isEndpoint(fileURL, settings);
if (!(fileIsPage || fileIsEndpoint)) return;
- const defaultPrerender = getPrerenderDefault(settings.config);
- const pageOptions = await scan(code, id, settings);
+ const pageOptions = await getPageOptions(code, id, fileURL, settings, logger);
- if (typeof pageOptions.prerender === 'undefined') {
- pageOptions.prerender = defaultPrerender;
- }
// `getStaticPaths` warning is just a string check, should be good enough for most cases
if (
!pageOptions.prerender &&
@@ -76,3 +74,29 @@ export default function astroScannerPlugin({
},
};
}
+
+async function getPageOptions(
+ code: string,
+ id: string,
+ fileURL: URL,
+ settings: AstroSettings,
+ logger: Logger,
+): Promise {
+ // Run initial scan
+ const pageOptions = await scan(code, id, settings);
+
+ // Run integration hooks to alter page options
+ const route: RouteOptions = {
+ component: rootRelativePath(settings.config.root, fileURL, false),
+ prerender: pageOptions.prerender,
+ };
+ await runHookRouteSetup({ route, settings, logger });
+ pageOptions.prerender = route.prerender;
+
+ // Fallback if unset
+ if (typeof pageOptions.prerender === 'undefined') {
+ pageOptions.prerender = getPrerenderDefault(settings.config);
+ }
+
+ return pageOptions;
+}
diff --git a/packages/astro/src/vite-plugin-scanner/scan.ts b/packages/astro/src/vite-plugin-scanner/scan.ts
index f447a1f28f..52b2d6bd82 100644
--- a/packages/astro/src/vite-plugin-scanner/scan.ts
+++ b/packages/astro/src/vite-plugin-scanner/scan.ts
@@ -65,7 +65,13 @@ export async function scan(
.trim();
// For a given export, check the value of the first non-whitespace token.
// Basically extract the `true` from the statement `export const prerender = true`
- const suffix = code.slice(endOfLocalName).trim().replace(/=/, '').trim().split(/[;\n]/)[0];
+ const suffix = code
+ .slice(endOfLocalName)
+ .trim()
+ .replace(/=/, '')
+ .trim()
+ .split(/[;\n\r]/)[0]
+ .trim();
if (prefix !== 'const' || !(isTruthy(suffix) || isFalsy(suffix))) {
throw new AstroError({
...AstroErrorData.InvalidPrerenderExport,
diff --git a/packages/astro/templates/content/module.mjs b/packages/astro/templates/content/module.mjs
index f246678a25..2d395db495 100644
--- a/packages/astro/templates/content/module.mjs
+++ b/packages/astro/templates/content/module.mjs
@@ -9,7 +9,7 @@ import {
createReference,
} from 'astro/content/runtime';
-export { defineCollection } from 'astro/content/runtime';
+export { defineCollection, renderEntry as render } from 'astro/content/runtime';
export { z } from 'astro/zod';
const contentDir = '@@CONTENT_DIR@@';
@@ -33,6 +33,8 @@ const collectionToEntryMap = createCollectionToGlobResultMap({
let lookupMap = {};
/* @@LOOKUP_MAP_ASSIGNMENT@@ */
+const collectionNames = new Set(Object.keys(lookupMap));
+
function createGlobLookup(glob) {
return async (collection, lookupId) => {
const filePath = lookupMap[collection]?.entries[lookupId];
@@ -59,15 +61,18 @@ export const getCollection = createGetCollection({
export const getEntryBySlug = createGetEntryBySlug({
getEntryImport: createGlobLookup(contentCollectionToEntryMap),
getRenderEntryImport: createGlobLookup(collectionToRenderEntryMap),
+ collectionNames,
});
export const getDataEntryById = createGetDataEntryById({
getEntryImport: createGlobLookup(dataCollectionToEntryMap),
+ collectionNames,
});
export const getEntry = createGetEntry({
getEntryImport: createGlobLookup(collectionToEntryMap),
getRenderEntryImport: createGlobLookup(collectionToRenderEntryMap),
+ collectionNames,
});
export const getEntries = createGetEntries(getEntry);
diff --git a/packages/astro/templates/content/types.d.ts b/packages/astro/templates/content/types.d.ts
index 8572771b1e..9f277576a4 100644
--- a/packages/astro/templates/content/types.d.ts
+++ b/packages/astro/templates/content/types.d.ts
@@ -1,10 +1,21 @@
declare module 'astro:content' {
+
+ interface RenderResult {
+ Content: import('astro/runtime/server/index.js').AstroComponentFactory;
+ headings: import('astro').MarkdownHeading[];
+ remarkPluginFrontmatter: Record;
+ }
interface Render {
- '.md': Promise<{
- Content: import('astro').MarkdownInstance<{}>['Content'];
- headings: import('astro').MarkdownHeading[];
- remarkPluginFrontmatter: Record;
- }>;
+ '.md': Promise;
+ }
+
+
+ export interface RenderedContent {
+ html: string;
+ metadata?: {
+ imagePaths: Array;
+ [key: string]: unknown;
+ };
}
}
@@ -100,6 +111,10 @@ declare module 'astro:content' {
}[],
): Promise[]>;
+ export function render(
+ entry: AnyEntryMap[C][string],
+ ): Promise;
+
export function reference(
collection: C,
): import('astro/zod').ZodEffects<
diff --git a/packages/astro/test/astro-attrs.test.js b/packages/astro/test/astro-attrs.test.js
index 2e020f8ea0..a981a5b152 100644
--- a/packages/astro/test/astro-attrs.test.js
+++ b/packages/astro/test/astro-attrs.test.js
@@ -16,21 +16,41 @@ describe('Attributes', async () => {
const $ = cheerio.load(html);
const attrs = {
- 'false-str': { attribute: 'attr', value: 'false' },
- 'true-str': { attribute: 'attr', value: 'true' },
- false: { attribute: 'attr', value: undefined },
- true: { attribute: 'attr', value: 'true' },
- empty: { attribute: 'attr', value: '' },
+ 'boolean-attr-true': { attribute: 'allowfullscreen', value: '' },
+ 'boolean-attr-false': { attribute: 'allowfullscreen', value: undefined },
+ 'boolean-attr-string-truthy': { attribute: 'allowfullscreen', value: '' },
+ 'boolean-attr-string-falsy': { attribute: 'allowfullscreen', value: undefined },
+ 'boolean-attr-number-truthy': { attribute: 'allowfullscreen', value: '' },
+ 'boolean-attr-number-falsy': { attribute: 'allowfullscreen', value: undefined },
+ 'data-attr-true': { attribute: 'data-foobar', value: 'true' },
+ 'data-attr-false': { attribute: 'data-foobar', value: 'false' },
+ 'data-attr-string-truthy': { attribute: 'data-foobar', value: 'foo' },
+ 'data-attr-string-falsy': { attribute: 'data-foobar', value: '' },
+ 'data-attr-number-truthy': { attribute: 'data-foobar', value: '1' },
+ 'data-attr-number-falsy': { attribute: 'data-foobar', value: '0' },
+ 'normal-attr-true': { attribute: 'foobar', value: 'true' },
+ 'normal-attr-false': { attribute: 'foobar', value: 'false' },
+ 'normal-attr-string-truthy': { attribute: 'foobar', value: 'foo' },
+ 'normal-attr-string-falsy': { attribute: 'foobar', value: '' },
+ 'normal-attr-number-truthy': { attribute: 'foobar', value: '1' },
+ 'normal-attr-number-falsy': { attribute: 'foobar', value: '0' },
null: { attribute: 'attr', value: undefined },
undefined: { attribute: 'attr', value: undefined },
- 'html-boolean': { attribute: 'async', value: 'async' },
- 'html-boolean-true': { attribute: 'async', value: 'async' },
- 'html-boolean-false': { attribute: 'async', value: undefined },
'html-enum': { attribute: 'draggable', value: 'true' },
'html-enum-true': { attribute: 'draggable', value: 'true' },
'html-enum-false': { attribute: 'draggable', value: 'false' },
};
+ assert.ok(!/allowfullscreen=/.test(html), 'boolean attributes should not have values');
+ assert.ok(
+ !/id="data-attr-string-falsy"\s+data-foobar=/.test(html),
+ "data attributes should not have values if it's an empty string"
+ );
+ assert.ok(
+ !/id="normal-attr-string-falsy"\s+data-foobar=/.test(html),
+ "normal attributes should not have values if it's an empty string"
+ );
+
// cheerio will unescape the values, so checking that the url rendered unescaped to begin with has to be done manually
assert.equal(
html.includes('https://example.com/api/og?title=hello&description=somedescription'),
@@ -46,7 +66,7 @@ describe('Attributes', async () => {
for (const id of Object.keys(attrs)) {
const { attribute, value } = attrs[id];
const attr = $(`#${id}`).attr(attribute);
- assert.equal(attr, value);
+ assert.equal(attr, value, `Expected ${attribute} to be ${value} for #${id}`);
}
});
diff --git a/packages/astro/test/astro-sync.test.js b/packages/astro/test/astro-sync.test.js
index a92993eae3..dfe4755d11 100644
--- a/packages/astro/test/astro-sync.test.js
+++ b/packages/astro/test/astro-sync.test.js
@@ -1,8 +1,10 @@
+// @ts-check
import assert from 'node:assert/strict';
import * as fs from 'node:fs';
-import { before, describe, it } from 'node:test';
+import { beforeEach, describe, it } from 'node:test';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
+import { normalizePath } from 'vite';
import { loadFixture } from './test-utils.js';
const createFixture = () => {
@@ -11,66 +13,79 @@ const createFixture = () => {
/** @type {Record} */
const writtenFiles = {};
+ /**
+ * @param {string} path
+ */
+ const getExpectedPath = (path) =>
+ normalizePath(fileURLToPath(new URL(path, astroFixture.config.root)));
+
return {
/** @param {string} root */
- async whenSyncing(root) {
+ async load(root) {
astroFixture = await loadFixture({ root });
-
- const envPath = new URL('env.d.ts', astroFixture.config.srcDir).href;
- const typesDtsPath = new URL('.astro/types.d.ts', astroFixture.config.root).href;
-
+ return astroFixture.config;
+ },
+ clean() {
+ const envPath = new URL('env.d.ts', astroFixture.config.srcDir);
+ if (fs.existsSync(envPath)) {
+ fs.unlinkSync(new URL('env.d.ts', astroFixture.config.srcDir));
+ }
+ fs.rmSync(new URL('./.astro/', astroFixture.config.root), { force: true, recursive: true });
+ },
+ async whenSyncing() {
const fsMock = {
...fs,
- existsSync(path, ...args) {
- if (path.toString() === envPath) {
- return false;
- }
- if (path.toString() === typesDtsPath) {
- return true;
- }
- return fs.existsSync(path, ...args);
- },
+ /**
+ * @param {fs.PathLike} path
+ * @param {string} contents
+ */
writeFileSync(path, contents) {
writtenFiles[path.toString()] = contents;
+ return fs.writeFileSync(path, contents);
},
promises: {
...fs.promises,
- async readFile(path, ...args) {
- if (path.toString() === envPath) {
- return `/// `;
- } else {
- return fs.promises.readFile(path, ...args);
- }
- },
- async writeFile(path, contents) {
+ /**
+ * @param {fs.PathLike} path
+ * @param {string} contents
+ */
+ writeFile(path, contents) {
writtenFiles[path.toString()] = contents;
+ return fs.promises.writeFile(path, contents);
},
},
};
- await astroFixture.sync({
- inlineConfig: { root: fileURLToPath(new URL(root, import.meta.url)) },
- fs: fsMock,
- });
+ await astroFixture.sync(
+ { root: fileURLToPath(astroFixture.config.root) },
+ {
+ // @ts-ignore
+ fs: fsMock,
+ },
+ );
},
/** @param {string} path */
thenFileShouldExist(path) {
- const expectedPath = new URL(path, astroFixture.config.root).href;
- assert.equal(writtenFiles.hasOwnProperty(expectedPath), true, `${path} does not exist`);
+ assert.equal(
+ writtenFiles.hasOwnProperty(getExpectedPath(path)),
+ true,
+ `${path} does not exist`,
+ );
},
/**
* @param {string} path
* @param {string} content
* @param {string | undefined} error
*/
- thenFileContentShouldInclude(path, content, error) {
- const expectedPath = new URL(path, astroFixture.config.root).href;
- assert.equal(writtenFiles[expectedPath].includes(content), true, error);
+ thenFileContentShouldInclude(path, content, error = undefined) {
+ assert.equal(writtenFiles[getExpectedPath(path)].includes(content), true, error);
},
+ /**
+ * @param {string} path
+ */
thenFileShouldBeValidTypescript(path) {
- const expectedPath = new URL(path, astroFixture.config.root).href;
try {
- const content = writtenFiles[expectedPath];
+ const content = writtenFiles[getExpectedPath(path)];
const result = ts.transpileModule(content, {
compilerOptions: {
module: ts.ModuleKind.ESNext,
@@ -91,33 +106,71 @@ const createFixture = () => {
describe('astro sync', () => {
/** @type {ReturnType} */
let fixture;
- before(async () => {
+ beforeEach(async () => {
fixture = createFixture();
});
- it('Writes `src/env.d.ts` if none exists', async () => {
- await fixture.whenSyncing('./fixtures/astro-basic/');
- fixture.thenFileShouldExist('src/env.d.ts');
- fixture.thenFileContentShouldInclude('src/env.d.ts', `/// `);
+ describe('References', () => {
+ it('Writes `src/env.d.ts` if none exists', async () => {
+ await fixture.load('./fixtures/astro-basic/');
+ fixture.clean();
+ await fixture.whenSyncing();
+ fixture.thenFileShouldExist('src/env.d.ts');
+ fixture.thenFileContentShouldInclude(
+ 'src/env.d.ts',
+ `/// `,
+ );
+ });
+
+ it('Updates `src/env.d.ts` if one exists', async () => {
+ const config = await fixture.load('./fixtures/astro-basic/');
+ fixture.clean();
+ fs.writeFileSync(new URL('./env.d.ts', config.srcDir), '// whatever', 'utf-8');
+ await fixture.whenSyncing();
+ fixture.thenFileShouldExist('src/env.d.ts');
+ fixture.thenFileContentShouldInclude(
+ 'src/env.d.ts',
+ `/// `,
+ );
+ });
+
+ it('Writes `src/types.d.ts`', async () => {
+ await fixture.load('./fixtures/astro-basic/');
+ fixture.clean();
+ await fixture.whenSyncing();
+ fixture.thenFileShouldExist('.astro/types.d.ts');
+ fixture.thenFileContentShouldInclude(
+ '.astro/types.d.ts',
+ `/// `,
+ );
+ });
});
describe('Content collections', () => {
- it('Writes types to `.astro`', async () => {
- await fixture.whenSyncing('./fixtures/content-collections/');
+ it('Adds reference to `.astro/types.d.ts`', async () => {
+ await fixture.load('./fixtures/content-collections/');
+ fixture.clean();
+ await fixture.whenSyncing();
fixture.thenFileShouldExist('.astro/types.d.ts');
fixture.thenFileContentShouldInclude(
'.astro/types.d.ts',
+ `/// `,
+ );
+ fixture.thenFileShouldExist('.astro/astro/content.d.ts');
+ fixture.thenFileContentShouldInclude(
+ '.astro/astro/content.d.ts',
`declare module 'astro:content' {`,
'Types file does not include `astro:content` module declaration',
);
- fixture.thenFileShouldBeValidTypescript('.astro/types.d.ts');
+ fixture.thenFileShouldBeValidTypescript('.astro/astro/content.d.ts');
});
it('Writes types for empty collections', async () => {
- await fixture.whenSyncing('./fixtures/content-collections-empty-dir/');
- fixture.thenFileShouldExist('.astro/types.d.ts');
+ await fixture.load('./fixtures/content-collections-empty-dir/');
+ fixture.clean();
+ await fixture.whenSyncing();
fixture.thenFileContentShouldInclude(
- '.astro/types.d.ts',
+ '.astro/astro/content.d.ts',
`"blog": Record {
'Types file does not include empty collection type',
);
fixture.thenFileContentShouldInclude(
- '.astro/types.d.ts',
+ '.astro/astro/content.d.ts',
`"blogMeta": Record {
'Types file does not include empty collection type',
);
});
-
- it('Adds type reference to `src/env.d.ts`', async () => {
- await fixture.whenSyncing('./fixtures/content-collections/');
- fixture.thenFileShouldExist('src/env.d.ts');
- fixture.thenFileContentShouldInclude(
- 'src/env.d.ts',
- `/// `,
- );
- });
});
- describe('Astro Env', () => {
- it('Writes types to `.astro`', async () => {
- await fixture.whenSyncing('./fixtures/astro-env/');
- fixture.thenFileShouldExist('.astro/env.d.ts');
+ describe('astro:env', () => {
+ it('Adds reference to `.astro/types.d.ts`', async () => {
+ await fixture.load('./fixtures/astro-env/');
+ fixture.clean();
+ await fixture.whenSyncing();
+ fixture.thenFileShouldExist('.astro/types.d.ts');
fixture.thenFileContentShouldInclude(
- '.astro/env.d.ts',
+ '.astro/types.d.ts',
+ `/// `,
+ );
+ fixture.thenFileShouldExist('.astro/astro/env.d.ts');
+ fixture.thenFileContentShouldInclude(
+ '.astro/astro/env.d.ts',
`declare module 'astro:env/client' {`,
'Types file does not include `astro:env` module declaration',
);
});
- it('Adds type reference to `src/env.d.ts`', async () => {
- await fixture.whenSyncing('./fixtures/astro-env/');
- fixture.thenFileShouldExist('src/env.d.ts');
- fixture.thenFileContentShouldInclude(
- 'src/env.d.ts',
- `/// `,
- );
- });
-
it('Does not throw if a public variable is required', async () => {
- let error = null;
try {
- await fixture.whenSyncing('./fixtures/astro-env-required-public/');
- } catch (e) {
- error = e;
+ await fixture.load('./fixtures/astro-env-required-public/');
+ fixture.clean();
+ await fixture.whenSyncing();
+ assert.ok(true);
+ } catch {
+ assert.fail();
}
-
- assert.equal(error, null, 'Syncing should not throw astro:env validation errors');
});
});
- describe('Astro Actions', () => {
- // We can't check for the file existence or content yet because
- // it's an integration and does not use the fs mock
-
- it('Adds type reference to `src/env.d.ts`', async () => {
- await fixture.whenSyncing('./fixtures/actions/');
- fixture.thenFileShouldExist('src/env.d.ts');
+ describe('astro:actions', () => {
+ it('Adds reference to `.astro/types.d.ts`', async () => {
+ await fixture.load('./fixtures/actions/');
+ fixture.clean();
+ await fixture.whenSyncing();
+ fixture.thenFileShouldExist('.astro/types.d.ts');
fixture.thenFileContentShouldInclude(
- 'src/env.d.ts',
- `/// `,
+ '.astro/types.d.ts',
+ `/// `,
);
+ fixture.thenFileShouldExist('.astro/astro/actions.d.ts');
+ fixture.thenFileContentShouldInclude(
+ '.astro/astro/actions.d.ts',
+ `declare module "astro:actions" {`,
+ 'Types file does not include `astro:actions` module declaration',
+ );
+ fixture.thenFileShouldBeValidTypescript('.astro/astro/actions.d.ts');
});
});
});
diff --git a/packages/astro/test/content-intellisense.test.js b/packages/astro/test/content-intellisense.test.js
new file mode 100644
index 0000000000..dc93919999
--- /dev/null
+++ b/packages/astro/test/content-intellisense.test.js
@@ -0,0 +1,79 @@
+import assert from 'node:assert/strict';
+import { before, describe, it } from 'node:test';
+import { loadFixture } from './test-utils.js';
+
+describe('Content Intellisense', () => {
+ /** @type {import("./test-utils.js").Fixture} */
+ let fixture;
+
+ /** @type {string[]} */
+ let collectionsDir = [];
+
+ /** @type {{collections: {hasSchema: boolean, name: string}[], entries: Record}} */
+ let manifest = undefined;
+
+ before(async () => {
+ fixture = await loadFixture({ root: './fixtures/content-intellisense/' });
+ await fixture.build();
+
+ collectionsDir = await fixture.readdir('../.astro/collections');
+ manifest = JSON.parse(await fixture.readFile('../.astro/collections/collections.json'));
+ });
+
+ it('generate JSON schemas for content collections', async () => {
+ assert.deepEqual(collectionsDir.includes('blog-cc.schema.json'), true);
+ });
+
+ it('generate JSON schemas for content layer', async () => {
+ assert.deepEqual(collectionsDir.includes('blog-cl.schema.json'), true);
+ });
+
+ it('manifest exists', async () => {
+ assert.notEqual(manifest, undefined);
+ });
+
+ it('manifest has content collections', async () => {
+ const manifestCollections = manifest.collections.map((collection) => collection.name);
+ assert.equal(
+ manifestCollections.includes('blog-cc'),
+ true,
+ "Expected 'blog-cc' collection in manifest",
+ );
+ });
+
+ it('manifest has content layer', async () => {
+ const manifestCollections = manifest.collections.map((collection) => collection.name);
+ assert.equal(
+ manifestCollections.includes('blog-cl'),
+ true,
+ "Expected 'blog-cl' collection in manifest",
+ );
+ });
+
+ it('has entries for content collections', async () => {
+ const collectionEntries = Object.entries(manifest.entries).filter((entry) =>
+ entry[0].includes(
+ '/astro/packages/astro/test/fixtures/content-intellisense/src/content/blog-cc/',
+ ),
+ );
+ assert.equal(collectionEntries.length, 3, "Expected 3 entries for 'blog-cc' collection");
+ assert.equal(
+ collectionEntries.every((entry) => entry[1] === 'blog-cc'),
+ true,
+ "Expected 3 entries for 'blog-cc' collection to have 'blog-cc' as collection",
+ );
+ });
+
+ it('has entries for content layer', async () => {
+ const collectionEntries = Object.entries(manifest.entries).filter((entry) =>
+ entry[0].includes('/astro/packages/astro/test/fixtures/content-intellisense/src/blog-cl/'),
+ );
+
+ assert.equal(collectionEntries.length, 3, "Expected 3 entries for 'blog-cl' collection");
+ assert.equal(
+ collectionEntries.every((entry) => entry[1] === 'blog-cl'),
+ true,
+ "Expected 3 entries for 'blog-cl' collection to have 'blog-cl' as collection name",
+ );
+ });
+});
diff --git a/packages/astro/test/content-layer-markdoc.test.js b/packages/astro/test/content-layer-markdoc.test.js
new file mode 100644
index 0000000000..c5279b9e75
--- /dev/null
+++ b/packages/astro/test/content-layer-markdoc.test.js
@@ -0,0 +1,88 @@
+import assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+import * as cheerio from 'cheerio';
+import { loadFixture } from './test-utils.js';
+
+describe('Content layer markdoc', () => {
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/content-layer-markdoc/',
+ });
+ });
+
+ describe('dev', () => {
+ let devServer;
+
+ before(async () => {
+ devServer = await fixture.startDevServer();
+ });
+
+ after(async () => {
+ await devServer.stop();
+ });
+
+ it('renders content - with components', async () => {
+ const res = await fixture.fetch('/');
+ const html = await res.text();
+
+ renderComponentsChecks(html);
+ });
+
+ it('renders content - with components inside partials', async () => {
+ const res = await fixture.fetch('/');
+ const html = await res.text();
+
+ renderComponentsInsidePartialsChecks(html);
+ });
+ });
+
+ describe('build', () => {
+ before(async () => {
+ await fixture.build();
+ });
+
+ it('renders content - with components', async () => {
+ const html = await fixture.readFile('/index.html');
+
+ renderComponentsChecks(html);
+ });
+
+ it('renders content - with components inside partials', async () => {
+ const html = await fixture.readFile('/index.html');
+
+ renderComponentsInsidePartialsChecks(html);
+ });
+ });
+});
+
+/** @param {string} html */
+function renderComponentsChecks(html) {
+ const $ = cheerio.load(html);
+ const h2 = $('h2');
+ assert.equal(h2.text(), 'Post with components');
+
+ // Renders custom shortcode component
+ const marquee = $('marquee');
+ assert.notEqual(marquee, null);
+ assert.equal(marquee.attr('data-custom-marquee'), '');
+
+ // Renders Astro Code component
+ const pre = $('pre');
+ assert.notEqual(pre, null);
+ assert.ok(pre.hasClass('github-dark'));
+ assert.ok(pre.hasClass('astro-code'));
+}
+
+/** @param {string} html */
+function renderComponentsInsidePartialsChecks(html) {
+ const $ = cheerio.load(html);
+ // renders Counter.tsx
+ const button = $('#counter');
+ assert.equal(button.text(), '1');
+
+ // renders DeeplyNested.astro
+ const deeplyNested = $('#deeply-nested');
+ assert.equal(deeplyNested.text(), 'Deeply nested partial');
+}
diff --git a/packages/astro/test/content-layer-render.test.js b/packages/astro/test/content-layer-render.test.js
new file mode 100644
index 0000000000..fa743e719c
--- /dev/null
+++ b/packages/astro/test/content-layer-render.test.js
@@ -0,0 +1,45 @@
+import assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+import { loadFixture } from './test-utils.js';
+
+describe('Content Layer MDX rendering dev', () => {
+ /** @type {import("./test-utils.js").Fixture} */
+ let fixture;
+
+ let devServer;
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/content-layer-rendering/',
+ });
+ devServer = await fixture.startDevServer();
+ });
+
+ after(async () => {
+ devServer?.stop();
+ });
+
+ it('Render an MDX file', async () => {
+ const html = await fixture.fetch('/reptiles/iguana').then((r) => r.text());
+
+ assert.match(html, /Iguana/);
+ assert.match(html, /This is a rendered entry/);
+ });
+});
+
+describe('Content Layer MDX rendering build', () => {
+ /** @type {import("./test-utils.js").Fixture} */
+ let fixture;
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/content-layer-rendering/',
+ });
+ await fixture.build();
+ });
+
+ it('Render an MDX file', async () => {
+ const html = await fixture.readFile('/reptiles/iguana/index.html');
+
+ assert.match(html, /Iguana/);
+ assert.match(html, /This is a rendered entry/);
+ });
+});
diff --git a/packages/astro/test/content-layer.test.js b/packages/astro/test/content-layer.test.js
new file mode 100644
index 0000000000..41c05206ba
--- /dev/null
+++ b/packages/astro/test/content-layer.test.js
@@ -0,0 +1,279 @@
+import assert from 'node:assert/strict';
+import { promises as fs } from 'node:fs';
+import { sep } from 'node:path';
+import { sep as posixSep } from 'node:path/posix';
+import { after, before, describe, it } from 'node:test';
+import * as devalue from 'devalue';
+
+import { loadFixture } from './test-utils.js';
+describe('Content Layer', () => {
+ /** @type {import("./test-utils.js").Fixture} */
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({ root: './fixtures/content-layer/' });
+ });
+
+ describe('Build', () => {
+ let json;
+ before(async () => {
+ fixture = await loadFixture({ root: './fixtures/content-layer/' });
+ await fs
+ .unlink(new URL('./node_modules/.astro/data-store.json', fixture.config.root))
+ .catch(() => {});
+ await fixture.build();
+ const rawJson = await fixture.readFile('/collections.json');
+ json = devalue.parse(rawJson);
+ });
+
+ it('Returns custom loader collection', async () => {
+ assert.ok(json.hasOwnProperty('customLoader'));
+ assert.ok(Array.isArray(json.customLoader));
+
+ const item = json.customLoader[0];
+ assert.deepEqual(item, {
+ id: '1',
+ collection: 'blog',
+ data: {
+ userId: 1,
+ id: 1,
+ title: 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit',
+ body: 'quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto',
+ },
+ });
+ });
+
+ it('filters collection items', async () => {
+ assert.ok(json.hasOwnProperty('customLoader'));
+ assert.ok(Array.isArray(json.customLoader));
+ assert.equal(json.customLoader.length, 5);
+ });
+
+ it('Returns `file()` loader collection', async () => {
+ assert.ok(json.hasOwnProperty('fileLoader'));
+ assert.ok(Array.isArray(json.fileLoader));
+
+ const ids = json.fileLoader.map((item) => item.data.id);
+ assert.deepEqual(ids, [
+ 'labrador-retriever',
+ 'german-shepherd',
+ 'golden-retriever',
+ 'french-bulldog',
+ 'bulldog',
+ 'beagle',
+ 'poodle',
+ 'rottweiler',
+ 'german-shorthaired-pointer',
+ 'yorkshire-terrier',
+ 'boxer',
+ 'dachshund',
+ 'siberian-husky',
+ 'great-dane',
+ 'doberman-pinscher',
+ 'australian-shepherd',
+ 'miniature-schnauzer',
+ 'cavalier-king-charles-spaniel',
+ 'shih-tzu',
+ 'boston-terrier',
+ 'bernese-mountain-dog',
+ 'pomeranian',
+ 'havanese',
+ 'english-springer-spaniel',
+ 'shetland-sheepdog',
+ ]);
+ });
+
+ it('Returns data entry by id', async () => {
+ assert.ok(json.hasOwnProperty('dataEntry'));
+ assert.equal(json.dataEntry.filePath?.split(sep).join(posixSep), 'src/data/dogs.json');
+ delete json.dataEntry.filePath;
+ assert.deepEqual(json.dataEntry, {
+ id: 'beagle',
+ collection: 'dogs',
+ data: {
+ breed: 'Beagle',
+ id: 'beagle',
+ size: 'Small to Medium',
+ origin: 'England',
+ lifespan: '12-15 years',
+ temperament: ['Friendly', 'Curious', 'Merry'],
+ },
+ });
+ });
+
+ it('returns collection from a simple loader', async () => {
+ assert.ok(json.hasOwnProperty('simpleLoader'));
+ assert.ok(Array.isArray(json.simpleLoader));
+
+ const item = json.simpleLoader[0];
+ assert.deepEqual(item, {
+ id: 'siamese',
+ collection: 'cats',
+ data: {
+ breed: 'Siamese',
+ id: 'siamese',
+ size: 'Medium',
+ origin: 'Thailand',
+ lifespan: '15 years',
+ temperament: ['Active', 'Affectionate', 'Social', 'Playful'],
+ },
+ });
+ });
+
+ it('transforms a reference id to a reference object', async () => {
+ assert.ok(json.hasOwnProperty('entryWithReference'));
+ assert.deepEqual(json.entryWithReference.data.cat, { collection: 'cats', id: 'tabby' });
+ });
+
+ it('can store Date objects', async () => {
+ assert.ok(json.entryWithReference.data.publishedDate instanceof Date);
+ });
+
+ it('returns a referenced entry', async () => {
+ assert.ok(json.hasOwnProperty('referencedEntry'));
+ assert.deepEqual(json.referencedEntry, {
+ collection: 'cats',
+ data: {
+ breed: 'Tabby',
+ id: 'tabby',
+ size: 'Medium',
+ origin: 'Egypt',
+ lifespan: '15 years',
+ temperament: ['Curious', 'Playful', 'Independent'],
+ },
+ id: 'tabby',
+ });
+ });
+
+ it('updates the store on new builds', async () => {
+ assert.equal(json.increment.data.lastValue, 1);
+ await fixture.build();
+ const newJson = devalue.parse(await fixture.readFile('/collections.json'));
+ assert.equal(newJson.increment.data.lastValue, 2);
+ });
+
+ it('clears the store on new build with force flag', async () => {
+ let newJson = devalue.parse(await fixture.readFile('/collections.json'));
+ assert.equal(newJson.increment.data.lastValue, 2);
+ await fixture.build({ force: true }, {});
+ newJson = devalue.parse(await fixture.readFile('/collections.json'));
+ assert.equal(newJson.increment.data.lastValue, 1);
+ });
+
+ it('clears the store on new build if the config has changed', async () => {
+ let newJson = devalue.parse(await fixture.readFile('/collections.json'));
+ assert.equal(newJson.increment.data.lastValue, 1);
+ await fixture.editFile('src/content/config.ts', (prev) => {
+ return `${prev}\nexport const foo = 'bar';`;
+ });
+ await fixture.build();
+ newJson = devalue.parse(await fixture.readFile('/collections.json'));
+ assert.equal(newJson.increment.data.lastValue, 1);
+ await fixture.resetAllFiles();
+ });
+ });
+
+ describe('Dev', () => {
+ let devServer;
+ let json;
+ before(async () => {
+ devServer = await fixture.startDevServer();
+ const rawJsonResponse = await fixture.fetch('/collections.json');
+ const rawJson = await rawJsonResponse.text();
+ json = devalue.parse(rawJson);
+ });
+
+ after(async () => {
+ devServer?.stop();
+ });
+
+ it('Returns custom loader collection', async () => {
+ assert.ok(json.hasOwnProperty('customLoader'));
+ assert.ok(Array.isArray(json.customLoader));
+
+ const item = json.customLoader[0];
+ assert.deepEqual(item, {
+ id: '1',
+ collection: 'blog',
+ data: {
+ userId: 1,
+ id: 1,
+ title: 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit',
+ body: 'quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto',
+ },
+ });
+ });
+
+ it('Returns `file()` loader collection', async () => {
+ assert.ok(json.hasOwnProperty('fileLoader'));
+ assert.ok(Array.isArray(json.fileLoader));
+
+ const ids = json.fileLoader.map((item) => item.data.id);
+ assert.deepEqual(ids, [
+ 'labrador-retriever',
+ 'german-shepherd',
+ 'golden-retriever',
+ 'french-bulldog',
+ 'bulldog',
+ 'beagle',
+ 'poodle',
+ 'rottweiler',
+ 'german-shorthaired-pointer',
+ 'yorkshire-terrier',
+ 'boxer',
+ 'dachshund',
+ 'siberian-husky',
+ 'great-dane',
+ 'doberman-pinscher',
+ 'australian-shepherd',
+ 'miniature-schnauzer',
+ 'cavalier-king-charles-spaniel',
+ 'shih-tzu',
+ 'boston-terrier',
+ 'bernese-mountain-dog',
+ 'pomeranian',
+ 'havanese',
+ 'english-springer-spaniel',
+ 'shetland-sheepdog',
+ ]);
+ });
+
+ it('Returns data entry by id', async () => {
+ assert.ok(json.hasOwnProperty('dataEntry'));
+ assert.equal(json.dataEntry.filePath?.split(sep).join(posixSep), 'src/data/dogs.json');
+ delete json.dataEntry.filePath;
+ assert.deepEqual(json.dataEntry, {
+ id: 'beagle',
+ collection: 'dogs',
+ data: {
+ breed: 'Beagle',
+ id: 'beagle',
+ size: 'Small to Medium',
+ origin: 'England',
+ lifespan: '12-15 years',
+ temperament: ['Friendly', 'Curious', 'Merry'],
+ },
+ });
+ });
+
+ it('updates collection when data file is changed', async () => {
+ const rawJsonResponse = await fixture.fetch('/collections.json');
+ const initialJson = devalue.parse(await rawJsonResponse.text());
+ assert.equal(initialJson.fileLoader[0].data.temperament.includes('Bouncy'), false);
+
+ await fixture.editFile('/src/data/dogs.json', (prev) => {
+ const data = JSON.parse(prev);
+ data[0].temperament.push('Bouncy');
+ return JSON.stringify(data, null, 2);
+ });
+
+ // Writes are debounced to 500ms
+ await new Promise((r) => setTimeout(r, 700));
+
+ const updatedJsonResponse = await fixture.fetch('/collections.json');
+ const updated = devalue.parse(await updatedJsonResponse.text());
+ assert.ok(updated.fileLoader[0].data.temperament.includes('Bouncy'));
+ await fixture.resetAllFiles();
+ });
+ });
+});
diff --git a/packages/astro/test/fixtures/0-css/package.json b/packages/astro/test/fixtures/0-css/package.json
index 7da522185d..8697a7abe5 100644
--- a/packages/astro/test/fixtures/0-css/package.json
+++ b/packages/astro/test/fixtures/0-css/package.json
@@ -10,6 +10,6 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"svelte": "^4.2.18",
- "vue": "^3.4.35"
+ "vue": "^3.4.37"
}
}
diff --git a/packages/astro/test/fixtures/astro-attrs/src/pages/index.astro b/packages/astro/test/fixtures/astro-attrs/src/pages/index.astro
index 7ac96635fd..8f2576650f 100644
--- a/packages/astro/test/fixtures/astro-attrs/src/pages/index.astro
+++ b/packages/astro/test/fixtures/astro-attrs/src/pages/index.astro
@@ -1,19 +1,30 @@
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
/tmp/hello.txt"} />
-
-
-
-
+