diff --git a/.changeset/fresh-fans-study.md b/.changeset/fresh-fans-study.md new file mode 100644 index 000000000000..9a837b1fd7a7 --- /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 000000000000..7d211e62674f --- /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/serious-pumas-run.md b/.changeset/serious-pumas-run.md new file mode 100644 index 000000000000..e6f7c9af1af7 --- /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/smooth-chicken-wash.md b/.changeset/smooth-chicken-wash.md new file mode 100644 index 000000000000..3ced01f52f77 --- /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 34a4972f78b1..3a437b3fe357 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 000000000000..80b8ea67b8e4 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 000000000000..6c83959601e3 --- /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} + +![image ${i}](../../image.jpg) + + +`; + 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 000000000000..73c6afe7a8d0 --- /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} + +![image ${i}](../../image.jpg) + +`; + 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 000000000000..98e1495d13e1 --- /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} + +![image ${i}](../../image.jpg) + + +`; + 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 000000000000..c08c2fb9fe23 --- /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} + +![image ${i}](../../image.jpg) + +`; + 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 5f7206242b45..d56a6d1b0673 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/examples/basics/src/env.d.ts b/examples/basics/src/env.d.ts index f964fe0cffd8..9bc5cb41c24e 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/src/env.d.ts b/examples/blog/src/env.d.ts index acef35f175aa..e16c13c6952a 100644 --- a/examples/blog/src/env.d.ts +++ b/examples/blog/src/env.d.ts @@ -1,2 +1 @@ /// -/// diff --git a/examples/container-with-vitest/package.json b/examples/container-with-vitest/package.json index 02cbfd1776e3..7bddf6df4c50 100644 --- a/examples/container-with-vitest/package.json +++ b/examples/container-with-vitest/package.json @@ -19,7 +19,7 @@ "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/src/env.d.ts b/examples/framework-alpine/src/env.d.ts index f964fe0cffd8..e16c13c6952a 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/src/env.d.ts b/examples/framework-lit/src/env.d.ts index f964fe0cffd8..e16c13c6952a 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/src/env.d.ts b/examples/framework-multiple/src/env.d.ts index f964fe0cffd8..e16c13c6952a 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/src/env.d.ts b/examples/framework-preact/src/env.d.ts index f964fe0cffd8..e16c13c6952a 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/src/env.d.ts b/examples/framework-react/src/env.d.ts index f964fe0cffd8..e16c13c6952a 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/src/env.d.ts b/examples/framework-solid/src/env.d.ts index f964fe0cffd8..e16c13c6952a 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/src/env.d.ts b/examples/framework-svelte/src/env.d.ts index f964fe0cffd8..e16c13c6952a 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/src/env.d.ts b/examples/framework-vue/src/env.d.ts index f964fe0cffd8..e16c13c6952a 100644 --- a/examples/framework-vue/src/env.d.ts +++ b/examples/framework-vue/src/env.d.ts @@ -1 +1 @@ -/// +/// diff --git a/examples/hackernews/src/env.d.ts b/examples/hackernews/src/env.d.ts index f964fe0cffd8..e16c13c6952a 100644 --- a/examples/hackernews/src/env.d.ts +++ b/examples/hackernews/src/env.d.ts @@ -1 +1 @@ -/// +/// diff --git a/examples/middleware/src/env.d.ts b/examples/middleware/src/env.d.ts index 44f67965a3cc..74b9019e5746 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/src/env.d.ts b/examples/minimal/src/env.d.ts index f964fe0cffd8..e16c13c6952a 100644 --- a/examples/minimal/src/env.d.ts +++ b/examples/minimal/src/env.d.ts @@ -1 +1 @@ -/// +/// diff --git a/examples/non-html-pages/src/env.d.ts b/examples/non-html-pages/src/env.d.ts index f964fe0cffd8..e16c13c6952a 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/src/env.d.ts b/examples/portfolio/src/env.d.ts index acef35f175aa..e16c13c6952a 100644 --- a/examples/portfolio/src/env.d.ts +++ b/examples/portfolio/src/env.d.ts @@ -1,2 +1 @@ /// -/// diff --git a/examples/ssr/src/env.d.ts b/examples/ssr/src/env.d.ts index f964fe0cffd8..e16c13c6952a 100644 --- a/examples/ssr/src/env.d.ts +++ b/examples/ssr/src/env.d.ts @@ -1 +1 @@ -/// +/// diff --git a/examples/with-markdoc/src/env.d.ts b/examples/with-markdoc/src/env.d.ts index acef35f175aa..e16c13c6952a 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/src/env.d.ts b/examples/with-markdown-plugins/src/env.d.ts index f964fe0cffd8..e16c13c6952a 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/src/env.d.ts b/examples/with-markdown-shiki/src/env.d.ts index f964fe0cffd8..e16c13c6952a 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/src/env.d.ts b/examples/with-mdx/src/env.d.ts index f964fe0cffd8..e16c13c6952a 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/src/env.d.ts b/examples/with-nanostores/src/env.d.ts index f964fe0cffd8..e16c13c6952a 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/src/env.d.ts b/examples/with-tailwindcss/src/env.d.ts index f964fe0cffd8..e16c13c6952a 100644 --- a/examples/with-tailwindcss/src/env.d.ts +++ b/examples/with-tailwindcss/src/env.d.ts @@ -1 +1 @@ -/// +/// diff --git a/packages/astro/package.json b/packages/astro/package.json index c16f39d7e766..31591eb2920c 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -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", @@ -132,6 +133,7 @@ "@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", @@ -163,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", @@ -181,8 +185,10 @@ "vite": "^5.4.0", "vitefu": "^0.2.5", "which-pm": "^3.0.0", + "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" @@ -203,6 +209,7 @@ "@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", "astro-scripts": "workspace:*", diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 645b07900dd3..2cf4160609b1 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 {} @@ -2183,6 +2185,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; }; } @@ -2279,6 +2500,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. */ @@ -2307,6 +2532,8 @@ export type DataEntryModule = { }; }; +export type ContentEntryRenderFuction = (entry: DataEntry) => Promise; + export interface ContentEntryType { extensions: string[]; getEntryInfo(params: { @@ -2322,6 +2549,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. @@ -2363,6 +2592,11 @@ export interface AstroAdapterFeatures { functionPerRoute: boolean; } +export interface InjectedType { + filename: string; + content: string; +} + export interface AstroSettings { config: AstroConfig; adapter: AstroAdapter | undefined; @@ -2398,6 +2632,7 @@ export interface AstroSettings { latestAstroVersion: string | undefined; serverIslandMap: NonNullable; serverIslandNameMap: NonNullable; + injectedTypes: Array; } export type AsyncRendererComponentFn = ( @@ -3099,6 +3334,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: { diff --git a/packages/astro/src/actions/consts.ts b/packages/astro/src/actions/consts.ts index 6a55386d869a..beb8c45b641d 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 04a911856f9c..2423b7017d43 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/assets/utils/resolveImports.ts b/packages/astro/src/assets/utils/resolveImports.ts new file mode 100644 index 000000000000..8d147552ece5 --- /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/build/index.ts b/packages/astro/src/cli/build/index.ts index 9c10aff1569e..dd44e6170165 100644 --- a/packages/astro/src/cli/build/index.ts +++ b/packages/astro/src/cli/build/index.ts @@ -14,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.'], ], }, @@ -24,5 +28,5 @@ export async function build({ flags }: BuildOptions) { const inlineConfig = flagsToAstroInlineConfig(flags); - await _build(inlineConfig, { force: !!flags.force }); + await _build(inlineConfig); } diff --git a/packages/astro/src/cli/check/index.ts b/packages/astro/src/cli/check/index.ts index 490d23970b51..9a354c8e00a5 100644 --- a/packages/astro/src/cli/check/index.ts +++ b/packages/astro/src/cli/check/index.ts @@ -32,7 +32,7 @@ export async function check(flags: Flags) { // 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/dev/index.ts b/packages/astro/src/cli/dev/index.ts index 2bd22b7c6aeb..fe9a1094c640 100644 --- a/packages/astro/src/cli/dev/index.ts +++ b/packages/astro/src/cli/dev/index.ts @@ -18,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/flags.ts b/packages/astro/src/cli/flags.ts index c8180708f7a3..59dfbf00a4bd 100644 --- a/packages/astro/src/cli/flags.ts +++ b/packages/astro/src/cli/flags.ts @@ -12,6 +12,7 @@ export function flagsToAstroInlineConfig(flags: Flags): AstroInlineConfig { 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, diff --git a/packages/astro/src/cli/sync/index.ts b/packages/astro/src/cli/sync/index.ts index 89623e6a800b..7ffe662c5c10 100644 --- a/packages/astro/src/cli/sync/index.ts +++ b/packages/astro/src/cli/sync/index.ts @@ -12,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.`, }); @@ -20,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/content/consts.ts b/packages/astro/src/content/consts.ts index f65652453b60..ac619c2b5e78 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 000000000000..9a6d4ed37542 --- /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 000000000000..f416082f9db0 --- /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 000000000000..cbc684a99788 --- /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 000000000000..4a4ecbcac8d3 --- /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 000000000000..30b4bfbe5334 --- /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 000000000000..f372967277b7 --- /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 34d2f10e927d..b462e2e23a46 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; - } - - const { type, entries } = lookupMap[collection]; - const entry = entries[lookupId]; - - 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 }; - }); + 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); + + 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; + } + + // 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 ea0c3cc80e8d..6fa0db94beb2 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 ce6dc63ca808..79a37a28d02f 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 dd6dacc7cad9..b810b8f71a4e 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 de642329a3d8..5a944716c353 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 8c5365368de8..64e5d98ee410 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/build/index.ts b/packages/astro/src/core/build/index.ts index 1382f85bc2f0..72df05b89170 100644 --- a/packages/astro/src/core/build/index.ts +++ b/packages/astro/src/core/build/index.ts @@ -29,12 +29,12 @@ 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 @@ -44,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; } /** @@ -69,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)); @@ -243,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/config/schema.ts b/packages/astro/src/core/config/schema.ts index 9ffb58934ba0..f49b4708e714 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -89,9 +89,11 @@ export const ASTRO_CONFIG_DEFAULTS = { clientPrerender: false, globalRoutePriority: false, serverIslands: false, + contentIntellisense: false, env: { validateSecrets: false, }, + contentLayer: false, }, } satisfies AstroUserConfig & { server: { open: boolean } }; @@ -538,6 +540,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 c3c62edd45a0..6c878d7f3323 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/dev/container.ts b/packages/astro/src/core/dev/container.ts index fc401ca710cf..159d5e44769c 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 3d8424174f4c..127f34b94989 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/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index 8415a9c0a31e..aa19b6dc3b0e 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 e0f9f6c82412..bdd7c7f05939 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/sync/constants.ts b/packages/astro/src/core/sync/constants.ts new file mode 100644 index 000000000000..7ff603105a75 --- /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 7b0d3268a00c..c9b2ec235b98 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 39eb062e5bcd..000000000000 --- a/packages/astro/src/core/sync/setup-env-ts.ts +++ /dev/null @@ -1,94 +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)), - }, - ]; - if (settings.config.experimental.env) { - injectedTypes.push({ - filename: ENV_TYPES_FILE, - }); - } - - 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 000000000000..56ab131f1be7 --- /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/env/sync.ts b/packages/astro/src/env/sync.ts index 9ba11469ad7a..27436f967be7 100644 --- a/packages/astro/src/env/sync.ts +++ b/packages/astro/src/env/sync.ts @@ -1,9 +1,9 @@ import fsMod from 'node:fs'; import type { AstroSettings } from '../@types/astro.js'; -import { ENV_TYPES_FILE, TYPES_TEMPLATE_URL } from './constants.js'; +import { TYPES_TEMPLATE_URL } from './constants.js'; import { getEnvFieldType } from './validators.js'; -export function syncAstroEnv(settings: AstroSettings, fs = fsMod) { +export function syncAstroEnv(settings: AstroSettings, fs = fsMod): void { if (!settings.config.experimental.env) { return; } @@ -23,8 +23,10 @@ export function syncAstroEnv(settings: AstroSettings, fs = fsMod) { } const template = fs.readFileSync(TYPES_TEMPLATE_URL, 'utf-8'); - const dts = template.replace('// @@CLIENT@@', client).replace('// @@SERVER@@', server); + const content = template.replace('// @@CLIENT@@', client).replace('// @@SERVER@@', server); - fs.mkdirSync(settings.dotAstroDir, { recursive: true }); - fs.writeFileSync(new URL(ENV_TYPES_FILE, settings.dotAstroDir), dts, 'utf-8'); + settings.injectedTypes.push({ + filename: 'astro/env.d.ts', + content, + }); } diff --git a/packages/astro/src/integrations/hooks.ts b/packages/astro/src/integrations/hooks.ts index e3de85ddd5f6..c0b9604335b3 100644 --- a/packages/astro/src/integrations/hooks.ts +++ b/packages/astro/src/integrations/hooks.ts @@ -101,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, @@ -328,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, diff --git a/packages/astro/src/preferences/index.ts b/packages/astro/src/preferences/index.ts index 9318824bf67e..2b92c5fb6cb5 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/vite-plugin-markdown/content-entry-type.ts b/packages/astro/src/vite-plugin-markdown/content-entry-type.ts index 131422298d3f..9b78d8f35149 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/templates/content/module.mjs b/packages/astro/templates/content/module.mjs index f246678a25ec..2d395db49541 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 8572771b1eac..9f277576a4bf 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-sync.test.js b/packages/astro/test/astro-sync.test.js index a92993eae368..dfe4755d1102 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'); + 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( - 'src/env.d.ts', - `/// `, + '.astro/types.d.ts', + `/// `, ); - }); - }); - - describe('Astro Env', () => { - it('Writes types to `.astro`', async () => { - await fixture.whenSyncing('./fixtures/astro-env/'); - fixture.thenFileShouldExist('.astro/env.d.ts'); + fixture.thenFileShouldExist('.astro/astro/env.d.ts'); fixture.thenFileContentShouldInclude( - '.astro/env.d.ts', + '.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 000000000000..dc93919999a1 --- /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 000000000000..c5279b9e7522 --- /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 000000000000..fa743e719ca4 --- /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 000000000000..41c05206bad2 --- /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/content-collections/astro.config.mjs b/packages/astro/test/fixtures/content-collections/astro.config.mjs index aa89463ab72b..911cb3a99881 100644 --- a/packages/astro/test/fixtures/content-collections/astro.config.mjs +++ b/packages/astro/test/fixtures/content-collections/astro.config.mjs @@ -4,4 +4,7 @@ import { defineConfig } from 'astro/config'; // https://astro.build/config export default defineConfig({ integrations: [mdx()], + experimental: { + contentIntellisense: true + } }); diff --git a/packages/astro/test/fixtures/content-intellisense/astro.config.mjs b/packages/astro/test/fixtures/content-intellisense/astro.config.mjs new file mode 100644 index 000000000000..f6358a896fab --- /dev/null +++ b/packages/astro/test/fixtures/content-intellisense/astro.config.mjs @@ -0,0 +1,12 @@ +import markdoc from "@astrojs/markdoc"; +import mdx from '@astrojs/mdx'; +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({ + integrations: [mdx(), markdoc()], + experimental: { + contentLayer: true, + contentIntellisense: true + } +}); diff --git a/packages/astro/test/fixtures/content-intellisense/package.json b/packages/astro/test/fixtures/content-intellisense/package.json new file mode 100644 index 000000000000..1e22bf9946f6 --- /dev/null +++ b/packages/astro/test/fixtures/content-intellisense/package.json @@ -0,0 +1,10 @@ +{ + "name": "@test/content-intellisense", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*", + "@astrojs/mdx": "workspace:*", + "@astrojs/markdoc": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/content-intellisense/src/blog-cl/entry.md b/packages/astro/test/fixtures/content-intellisense/src/blog-cl/entry.md new file mode 100644 index 000000000000..caaa4ebeff82 --- /dev/null +++ b/packages/astro/test/fixtures/content-intellisense/src/blog-cl/entry.md @@ -0,0 +1,3 @@ +--- +title: "Markdown" +--- diff --git a/packages/astro/test/fixtures/content-intellisense/src/blog-cl/entry2.mdx b/packages/astro/test/fixtures/content-intellisense/src/blog-cl/entry2.mdx new file mode 100644 index 000000000000..0872819c8cf1 --- /dev/null +++ b/packages/astro/test/fixtures/content-intellisense/src/blog-cl/entry2.mdx @@ -0,0 +1,3 @@ +--- +title: "MDX" +--- diff --git a/packages/astro/test/fixtures/content-intellisense/src/blog-cl/entry3.mdoc b/packages/astro/test/fixtures/content-intellisense/src/blog-cl/entry3.mdoc new file mode 100644 index 000000000000..e13eaa2f6ac8 --- /dev/null +++ b/packages/astro/test/fixtures/content-intellisense/src/blog-cl/entry3.mdoc @@ -0,0 +1,3 @@ +--- +title: "Markdoc" +--- diff --git a/packages/astro/test/fixtures/content-intellisense/src/content/blog-cc/entry.md b/packages/astro/test/fixtures/content-intellisense/src/content/blog-cc/entry.md new file mode 100644 index 000000000000..caaa4ebeff82 --- /dev/null +++ b/packages/astro/test/fixtures/content-intellisense/src/content/blog-cc/entry.md @@ -0,0 +1,3 @@ +--- +title: "Markdown" +--- diff --git a/packages/astro/test/fixtures/content-intellisense/src/content/blog-cc/entry2.mdx b/packages/astro/test/fixtures/content-intellisense/src/content/blog-cc/entry2.mdx new file mode 100644 index 000000000000..0872819c8cf1 --- /dev/null +++ b/packages/astro/test/fixtures/content-intellisense/src/content/blog-cc/entry2.mdx @@ -0,0 +1,3 @@ +--- +title: "MDX" +--- diff --git a/packages/astro/test/fixtures/content-intellisense/src/content/blog-cc/entry3.mdoc b/packages/astro/test/fixtures/content-intellisense/src/content/blog-cc/entry3.mdoc new file mode 100644 index 000000000000..e13eaa2f6ac8 --- /dev/null +++ b/packages/astro/test/fixtures/content-intellisense/src/content/blog-cc/entry3.mdoc @@ -0,0 +1,3 @@ +--- +title: "Markdoc" +--- diff --git a/packages/astro/test/fixtures/content-intellisense/src/content/config.ts b/packages/astro/test/fixtures/content-intellisense/src/content/config.ts new file mode 100644 index 000000000000..64120adabe20 --- /dev/null +++ b/packages/astro/test/fixtures/content-intellisense/src/content/config.ts @@ -0,0 +1,24 @@ +import { glob } from 'astro/loaders'; +import { defineCollection, z } from 'astro:content'; + +const blogCC = defineCollection({ + type: 'content', + schema: z.object({ + title: z.string(), + description: z.string().optional(), + }), +}); + +const blogCL = defineCollection({ + // By default the ID is a slug, generated from the path of the file relative to `base` + loader: glob({ pattern: "**/*", base: "./src/blog-cl" }), + schema: z.object({ + title: z.string(), + description: z.string().optional(), + }), +}); + +export const collections = { + "blog-cc": blogCC, + "blog-cl": blogCL, +}; diff --git a/packages/astro/test/fixtures/content-intellisense/src/utils.js b/packages/astro/test/fixtures/content-intellisense/src/utils.js new file mode 100644 index 000000000000..3a6244327862 --- /dev/null +++ b/packages/astro/test/fixtures/content-intellisense/src/utils.js @@ -0,0 +1,8 @@ +export function stripRenderFn(entryWithRender) { + const { render, ...entry } = entryWithRender; + return entry; +} + +export function stripAllRenderFn(collection = []) { + return collection.map(stripRenderFn); +} diff --git a/packages/astro/test/fixtures/content-layer-markdoc/astro.config.mjs b/packages/astro/test/fixtures/content-layer-markdoc/astro.config.mjs new file mode 100644 index 000000000000..bbb19ad302db --- /dev/null +++ b/packages/astro/test/fixtures/content-layer-markdoc/astro.config.mjs @@ -0,0 +1,9 @@ +import markdoc from '@astrojs/markdoc'; +import preact from '@astrojs/preact'; +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({ + integrations: [markdoc(), preact()], + experimental: { contentLayer: true } +}); diff --git a/packages/astro/test/fixtures/content-layer-markdoc/content/_nested.mdoc b/packages/astro/test/fixtures/content-layer-markdoc/content/_nested.mdoc new file mode 100644 index 000000000000..68f529280124 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer-markdoc/content/_nested.mdoc @@ -0,0 +1,3 @@ +Render components from a deeply nested partial: + +{% deeply-nested /%} diff --git a/packages/astro/test/fixtures/content-layer-markdoc/content/blog/_counter.mdoc b/packages/astro/test/fixtures/content-layer-markdoc/content/blog/_counter.mdoc new file mode 100644 index 000000000000..4a015695c68e --- /dev/null +++ b/packages/astro/test/fixtures/content-layer-markdoc/content/blog/_counter.mdoc @@ -0,0 +1,7 @@ +# Hello from a partial! + +Render a component from a partial: + +{% counter /%} + +{% partial file="../_nested.mdoc" /%} diff --git a/packages/astro/test/fixtures/content-layer-markdoc/content/blog/with-components.mdoc b/packages/astro/test/fixtures/content-layer-markdoc/content/blog/with-components.mdoc new file mode 100644 index 000000000000..eb7d20426e59 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer-markdoc/content/blog/with-components.mdoc @@ -0,0 +1,19 @@ +--- +title: Post with components +--- + +## Post with components + +This uses a custom marquee component with a shortcode: + +{% marquee-element direction="right" %} +I'm a marquee too! +{% /marquee-element %} + +{% partial file="_counter.mdoc" /%} + +And a code component for code blocks: + +```js +const isRenderedWithShiki = true; +``` diff --git a/packages/astro/test/fixtures/content-layer-markdoc/markdoc.config.ts b/packages/astro/test/fixtures/content-layer-markdoc/markdoc.config.ts new file mode 100644 index 000000000000..6093ec5938a5 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer-markdoc/markdoc.config.ts @@ -0,0 +1,32 @@ +import { Markdoc, component, defineMarkdocConfig } from '@astrojs/markdoc/config'; + +export default defineMarkdocConfig({ + nodes: { + fence: { + render: component('./src/components/Code.astro'), + attributes: { + language: { type: String }, + content: { type: String }, + }, + }, + }, + tags: { + 'marquee-element': { + render: component('./src/components/CustomMarquee.astro'), + attributes: { + direction: { + type: String, + default: 'left', + matches: ['left', 'right', 'up', 'down'], + errorLevel: 'critical', + }, + }, + }, + counter: { + render: component('./src/components/CounterWrapper.astro'), + }, + 'deeply-nested': { + render: component('./src/components/DeeplyNested.astro'), + }, + }, +}); diff --git a/packages/astro/test/fixtures/content-layer-markdoc/package.json b/packages/astro/test/fixtures/content-layer-markdoc/package.json new file mode 100644 index 000000000000..91ca2f8c9c00 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer-markdoc/package.json @@ -0,0 +1,11 @@ +{ + "name": "@test/content-layer-markdoc", + "version": "0.0.0", + "private": true, + "dependencies": { + "@astrojs/markdoc": "workspace:*", + "@astrojs/preact": "workspace:*", + "astro": "workspace:*", + "preact": "^10.23.1" + } +} \ No newline at end of file diff --git a/packages/astro/test/fixtures/content-layer-markdoc/src/components/Code.astro b/packages/astro/test/fixtures/content-layer-markdoc/src/components/Code.astro new file mode 100644 index 000000000000..18bf1399f22e --- /dev/null +++ b/packages/astro/test/fixtures/content-layer-markdoc/src/components/Code.astro @@ -0,0 +1,12 @@ +--- +import { Code } from 'astro/components'; + +type Props = { + content: string; + language: string; +} + +const { content, language } = Astro.props as Props; +--- + + diff --git a/packages/astro/test/fixtures/content-layer-markdoc/src/components/Counter.tsx b/packages/astro/test/fixtures/content-layer-markdoc/src/components/Counter.tsx new file mode 100644 index 000000000000..f1e239718cf8 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer-markdoc/src/components/Counter.tsx @@ -0,0 +1,10 @@ +import { useState } from 'preact/hooks'; + +export default function Counter() { + const [count, setCount] = useState(1); + return ( + + ); +} diff --git a/packages/astro/test/fixtures/content-layer-markdoc/src/components/CounterWrapper.astro b/packages/astro/test/fixtures/content-layer-markdoc/src/components/CounterWrapper.astro new file mode 100644 index 000000000000..e45ac6438152 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer-markdoc/src/components/CounterWrapper.astro @@ -0,0 +1,5 @@ +--- +import Counter from './Counter'; +--- + + diff --git a/packages/astro/test/fixtures/content-layer-markdoc/src/components/CustomMarquee.astro b/packages/astro/test/fixtures/content-layer-markdoc/src/components/CustomMarquee.astro new file mode 100644 index 000000000000..3108b997354c --- /dev/null +++ b/packages/astro/test/fixtures/content-layer-markdoc/src/components/CustomMarquee.astro @@ -0,0 +1 @@ + diff --git a/packages/astro/test/fixtures/content-layer-markdoc/src/components/DeeplyNested.astro b/packages/astro/test/fixtures/content-layer-markdoc/src/components/DeeplyNested.astro new file mode 100644 index 000000000000..eb23f675a0f6 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer-markdoc/src/components/DeeplyNested.astro @@ -0,0 +1,5 @@ +--- + +--- + +

Deeply nested partial

diff --git a/packages/astro/test/fixtures/content-layer-markdoc/src/content/config.ts b/packages/astro/test/fixtures/content-layer-markdoc/src/content/config.ts new file mode 100644 index 000000000000..18ead9aa161f --- /dev/null +++ b/packages/astro/test/fixtures/content-layer-markdoc/src/content/config.ts @@ -0,0 +1,11 @@ +import { defineCollection } from 'astro:content'; +import { glob } from 'astro/loaders'; + +const blog = defineCollection({ + loader: glob({ + pattern: '*.mdoc', + base: 'content/blog', + }), +}); + +export const collections = { blog }; diff --git a/packages/astro/test/fixtures/content-layer-markdoc/src/pages/index.astro b/packages/astro/test/fixtures/content-layer-markdoc/src/pages/index.astro new file mode 100644 index 000000000000..5c7747eef923 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer-markdoc/src/pages/index.astro @@ -0,0 +1,19 @@ +--- +import { getEntry, render } from "astro:content"; + +const post = await getEntry('blog', 'with-components'); +const { Content } = await render(post); +--- + + + + + + + + Content + + + + + diff --git a/packages/astro/test/fixtures/content-layer-rendering/.gitignore b/packages/astro/test/fixtures/content-layer-rendering/.gitignore new file mode 100644 index 000000000000..16d3c4dbbfec --- /dev/null +++ b/packages/astro/test/fixtures/content-layer-rendering/.gitignore @@ -0,0 +1 @@ +.cache diff --git a/packages/astro/test/fixtures/content-layer-rendering/astro.config.mjs b/packages/astro/test/fixtures/content-layer-rendering/astro.config.mjs new file mode 100644 index 000000000000..c3fd1366a07a --- /dev/null +++ b/packages/astro/test/fixtures/content-layer-rendering/astro.config.mjs @@ -0,0 +1,17 @@ +import mdx from '@astrojs/mdx'; +import { defineConfig } from 'astro/config'; +import { fileURLToPath } from 'node:url'; + +export default defineConfig({ + integrations: [mdx()], + vite: { + resolve: { + alias: { + '@images': fileURLToPath(new URL('./images', import.meta.url)) + } + }, + }, + experimental: { + contentLayer: true, + }, +}); diff --git a/packages/astro/test/fixtures/content-layer-rendering/content-outside-src-mdx/iguana.mdx b/packages/astro/test/fixtures/content-layer-rendering/content-outside-src-mdx/iguana.mdx new file mode 100644 index 000000000000..1266a73753b9 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer-rendering/content-outside-src-mdx/iguana.mdx @@ -0,0 +1,16 @@ +--- +title: Iguana +description: 'Introduction to Iguana.' +publishedDate: 'Sat May 21 2022 00:00:00 GMT-0400 (Eastern Daylight Time)' +tags: [cats, felines] +--- + +import H2 from "../src/components/H2.astro"; + +

Iguana

+ +### Iguana + +This is a rendered entry + +![file](./shuttle.jpg) diff --git a/packages/astro/test/fixtures/content-layer-rendering/content-outside-src-mdx/shuttle.jpg b/packages/astro/test/fixtures/content-layer-rendering/content-outside-src-mdx/shuttle.jpg new file mode 100644 index 000000000000..80b8ea67b8e4 Binary files /dev/null and b/packages/astro/test/fixtures/content-layer-rendering/content-outside-src-mdx/shuttle.jpg differ diff --git a/packages/astro/test/fixtures/content-layer-rendering/images/atlantis.avif b/packages/astro/test/fixtures/content-layer-rendering/images/atlantis.avif new file mode 100644 index 000000000000..6b9f6d5a5913 Binary files /dev/null and b/packages/astro/test/fixtures/content-layer-rendering/images/atlantis.avif differ diff --git a/packages/astro/test/fixtures/content-layer-rendering/images/launch.webp b/packages/astro/test/fixtures/content-layer-rendering/images/launch.webp new file mode 100644 index 000000000000..144ded978ad2 Binary files /dev/null and b/packages/astro/test/fixtures/content-layer-rendering/images/launch.webp differ diff --git a/packages/astro/test/fixtures/content-layer-rendering/images/shuttle.jpg b/packages/astro/test/fixtures/content-layer-rendering/images/shuttle.jpg new file mode 100644 index 000000000000..80b8ea67b8e4 Binary files /dev/null and b/packages/astro/test/fixtures/content-layer-rendering/images/shuttle.jpg differ diff --git a/packages/astro/test/fixtures/content-layer-rendering/package.json b/packages/astro/test/fixtures/content-layer-rendering/package.json new file mode 100644 index 000000000000..6679b8d6ecfb --- /dev/null +++ b/packages/astro/test/fixtures/content-layer-rendering/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/content-layer-rendering", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*", + "@astrojs/mdx": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/content-layer-rendering/src/components/H2.astro b/packages/astro/test/fixtures/content-layer-rendering/src/components/H2.astro new file mode 100644 index 000000000000..d1ad359c2ee1 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer-rendering/src/components/H2.astro @@ -0,0 +1,4 @@ +--- +--- + +

diff --git a/packages/astro/test/fixtures/content-layer-rendering/src/components/H3.astro b/packages/astro/test/fixtures/content-layer-rendering/src/components/H3.astro new file mode 100644 index 000000000000..fa476e929eae --- /dev/null +++ b/packages/astro/test/fixtures/content-layer-rendering/src/components/H3.astro @@ -0,0 +1,2 @@ + +

\ No newline at end of file diff --git a/packages/astro/test/fixtures/content-layer-rendering/src/components/LayoutProp.astro b/packages/astro/test/fixtures/content-layer-rendering/src/components/LayoutProp.astro new file mode 100644 index 000000000000..a2954162a396 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer-rendering/src/components/LayoutProp.astro @@ -0,0 +1,39 @@ +--- +import { CollectionEntry, getCollection } from 'astro:content'; +import H3 from './H3.astro' +// Test for recursive `getCollection()` calls +const blog = await getCollection('blog'); + +type Props = { + content: CollectionEntry<'blog'>['data']; +}; + +const { + content: { title }, +} = Astro.props; +--- + + + + + + + + With Layout Prop + + +

{title}

+

H3 inserted in the layout

+ + + + + diff --git a/packages/astro/test/fixtures/content-layer-rendering/src/components/WithScripts.astro b/packages/astro/test/fixtures/content-layer-rendering/src/components/WithScripts.astro new file mode 100644 index 000000000000..e37694eaf1f0 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer-rendering/src/components/WithScripts.astro @@ -0,0 +1,11 @@ +

Hoisted script didn't update me :(

+ +

Inline script didn't update me :(

+ + + + diff --git a/packages/astro/test/fixtures/content-layer-rendering/src/content/config.ts b/packages/astro/test/fixtures/content-layer-rendering/src/content/config.ts new file mode 100644 index 000000000000..eb175fa99bd8 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer-rendering/src/content/config.ts @@ -0,0 +1,18 @@ +import { defineCollection, z, reference } from 'astro:content'; +import { glob } from 'astro/loaders'; + +const reptiles = defineCollection({ + loader: glob({ + pattern: '*.mdx', + base: new URL('../../content-outside-src-mdx', import.meta.url), + }), + schema: () => + z.object({ + title: z.string(), + description: z.string(), + publishedDate: z.coerce.date(), + tags: z.array(z.string()), + }), +}); + +export const collections = { reptiles }; diff --git a/packages/astro/test/fixtures/content-layer-rendering/src/pages/reptiles/[slug].astro b/packages/astro/test/fixtures/content-layer-rendering/src/pages/reptiles/[slug].astro new file mode 100644 index 000000000000..526805e09947 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer-rendering/src/pages/reptiles/[slug].astro @@ -0,0 +1,25 @@ +--- +import type { GetStaticPaths } from "astro"; +import { getCollection, render } from "astro:content" +import { Image } from "astro:assets" + +export const getStaticPaths = (async () => { + const collection = await getCollection("reptiles"); + if(!collection) return [] + return collection.map((reptile) => ({ + params: { + slug: `${reptile.id}` + }, + props: { + reptile + } + })); +}) satisfies GetStaticPaths; + +const { reptile } = Astro.props as any +const { Content } = await render(reptile) + +--- + +

{reptile.data.title}

+ diff --git a/packages/astro/test/fixtures/content-layer/astro.config.mjs b/packages/astro/test/fixtures/content-layer/astro.config.mjs new file mode 100644 index 000000000000..3266e5e8c0ad --- /dev/null +++ b/packages/astro/test/fixtures/content-layer/astro.config.mjs @@ -0,0 +1,18 @@ +import mdx from '@astrojs/mdx'; +import { defineConfig } from 'astro/config'; +import { fileURLToPath } from 'node:url'; + +export default defineConfig({ + integrations: [mdx()], + vite: { + resolve: { + alias: { + '@images': fileURLToPath(new URL('./images', import.meta.url)) + } + }, + }, + experimental: { + contentLayer: true, + contentIntellisense: true, + }, +}); diff --git a/packages/astro/test/fixtures/content-layer/content-outside-src/columbia-copy.md b/packages/astro/test/fixtures/content-layer/content-outside-src/columbia-copy.md new file mode 100644 index 000000000000..6e770b2afd95 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer/content-outside-src/columbia-copy.md @@ -0,0 +1,16 @@ +--- +title: More Columbia +description: 'Learn about the Columbia NASA space shuttle.' +publishedDate: 'Sat May 21 2022 00:00:00 GMT-0400 (Eastern Daylight Time)' +tags: [space, 90s] +cat: tabby +--- + +**Source:** [Wikipedia](https://en.wikipedia.org/wiki/Space_Shuttle_Endeavour) + +Space Shuttle Endeavour (Orbiter Vehicle Designation: OV-105) is a retired orbiter from NASA's Space Shuttle program and the fifth and final operational Shuttle built. It embarked on its first mission, STS-49, in May 1992 and its 25th and final mission, STS-134, in May 2011. STS-134 was expected to be the final mission of the Space Shuttle program, but with the authorization of STS-135, Atlantis became the last shuttle to fly. + +The United States Congress approved the construction of Endeavour in 1987 to replace the Space Shuttle Challenger, which was destroyed in 1986. + +NASA chose, on cost grounds, to build much of Endeavour from spare parts rather than refitting the Space Shuttle Enterprise, and used structural spares built during the construction of Discovery and Atlantis in its assembly. +Space Shuttle Endeavour (Orbiter Vehicle Designation: OV-105) is a retired orbiter from NASA's Space Shuttle program and the fifth and final operational Shuttle built. It embarked on its first mission, STS-49, in May 1992 and its 25th and final mission, STS-134, in May 2011. STS-134 was expected to be the final mission of the Space Shuttle program, but with the authorization of STS-135, Atlantis became the last shuttle to fly. diff --git a/packages/astro/test/fixtures/content-layer/content-outside-src/columbia.md b/packages/astro/test/fixtures/content-layer/content-outside-src/columbia.md new file mode 100644 index 000000000000..e8a7bf248647 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer/content-outside-src/columbia.md @@ -0,0 +1,22 @@ +--- +title: Columbia +description: 'Learn about the Columbia NASA space shuttle.' +publishedDate: 'Sat May 21 2022 00:00:00 GMT-0400 (Eastern Daylight Time)' +tags: [space, 90s] +heroImage: "@images/launch.webp" +--- + +**Source:** [Wikipedia](https://en.wikipedia.org/wiki/Space_Shuttle_Endeavour) + +![columbia bare](shuttle.jpg) +![columbia relative](./shuttle.jpg) +![atlantis alias](@images/atlantis.avif) +![columbia relative 2](./shuttle.jpg) + +## Columbia + +Space Shuttle Columbia (Orbiter Vehicle Designation: OV-102) was the first space-rated orbiter in NASA's Space Shuttle fleet. It launched for the first time on mission STS-1 on April 12, 1981, the first flight of the Space Shuttle program. Over 22 years of service, it completed 27 missions before disintegrating during re-entry near the end of its 28th mission, STS-107 on February 1, 2003, resulting in the deaths of all seven crew members. + +Columbia was named after the American sloop Columbia Rediviva which, from 1787 to 1793, under the command of Captain Robert Gray, explored the US Pacific Northwest and became the first American vessel to circumnavigate the globe. It is also named after the Command Module of Apollo 11, the first crewed landing on another celestial body. Columbia + +Columbia was the first space shuttle to reach space, on mission STS-1. It was also the first space shuttle to be launched more than once, with a total of 28 missions flown over 22 years. In 2003, Columbia was destroyed as it re-entered Earth's atmosphere, killing all seven crew members. The disaster led to the grounding of the space shuttle fleet for over two years, and the eventual retirement of the remaining orbiters. diff --git a/packages/astro/test/fixtures/content-layer/content-outside-src/endeavour.md b/packages/astro/test/fixtures/content-layer/content-outside-src/endeavour.md new file mode 100644 index 000000000000..51d6e8c42178 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer/content-outside-src/endeavour.md @@ -0,0 +1,14 @@ +--- +title: Endeavour +description: 'Learn about the Endeavour NASA space shuttle.' +publishedDate: 'Sun Jul 11 2021 00:00:00 GMT-0400 (Eastern Daylight Time)' +tags: [space, 90s] +--- + +**Source:** [Wikipedia](https://en.wikipedia.org/wiki/Space_Shuttle_Endeavour) + +Space Shuttle Endeavour (Orbiter Vehicle Designation: OV-105) is a retired orbiter from NASA's Space Shuttle program and the fifth and final operational Shuttle built. It embarked on its first mission, STS-49, in May 1992 and its 25th and final mission, STS-134, in May 2011. STS-134 was expected to be the final mission of the Space Shuttle program, but with the authorization of STS-135, Atlantis became the last shuttle to fly. + +The United States Congress approved the construction of Endeavour in 1987 to replace the Space Shuttle Challenger, which was destroyed in 1986. + +NASA chose, on cost grounds, to build much of Endeavour from spare parts rather than refitting the Space Shuttle Enterprise, and used structural spares built during the construction of Discovery and Atlantis in its assembly. diff --git a/packages/astro/test/fixtures/content-layer/content-outside-src/enterprise.md b/packages/astro/test/fixtures/content-layer/content-outside-src/enterprise.md new file mode 100644 index 000000000000..3131e6d5df3a --- /dev/null +++ b/packages/astro/test/fixtures/content-layer/content-outside-src/enterprise.md @@ -0,0 +1,14 @@ +--- +title: 'Enterprise' +description: 'Learn about the Enterprise NASA space shuttle.' +publishedDate: 'Tue Jun 08 2021 00:00:00 GMT-0400 (Eastern Daylight Time)' +tags: [space, 70s] +--- + +**Source:** [Wikipedia](https://en.wikipedia.org/wiki/Space_Shuttle_Enterprise) + +Space Shuttle Enterprise (Orbiter Vehicle Designation: OV-101) was the first orbiter of the Space Shuttle system. Rolled out on September 17, 1976, it was built for NASA as part of the Space Shuttle program to perform atmospheric test flights after being launched from a modified Boeing 747. It was constructed without engines or a functional heat shield. As a result, it was not capable of spaceflight. + +Originally, Enterprise had been intended to be refitted for orbital flight to become the second space-rated orbiter in service. However, during the construction of Space Shuttle Columbia, details of the final design changed, making it simpler and less costly to build Challenger around a body frame that had been built as a test article. Similarly, Enterprise was considered for refit to replace Challenger after the latter was destroyed, but Endeavour was built from structural spares instead. + +Enterprise was restored and placed on display in 2003 at the Smithsonian's new Steven F. Udvar-Hazy Center in Virginia. Following the retirement of the Space Shuttle fleet, Discovery replaced Enterprise at the Udvar-Hazy Center, and Enterprise was transferred to the Intrepid Sea, Air & Space Museum in New York City, where it has been on display since July 2012. diff --git a/packages/astro/test/fixtures/content-layer/content-outside-src/index.md b/packages/astro/test/fixtures/content-layer/content-outside-src/index.md new file mode 100644 index 000000000000..4971108e36ed --- /dev/null +++ b/packages/astro/test/fixtures/content-layer/content-outside-src/index.md @@ -0,0 +1,15 @@ +--- +title: Columbia +description: 'Learn about the Columbia NASA space shuttle.' +publishedDate: 'Sat May 21 2022 00:00:00 GMT-0400 (Eastern Daylight Time)' +tags: [space, 90s] +--- + +**Source:** [Wikipedia](https://en.wikipedia.org/wiki/Space_Shuttle_Endeavour) + +Space Shuttle Endeavour (Orbiter Vehicle Designation: OV-105) is a retired orbiter from NASA's Space Shuttle program and the fifth and final operational Shuttle built. It embarked on its first mission, STS-49, in May 1992 and its 25th and final mission, STS-134, in May 2011. STS-134 was expected to be the final mission of the Space Shuttle program, but with the authorization of STS-135, Atlantis became the last shuttle to fly. + +The United States Congress approved the construction of Endeavour in 1987 to replace the Space Shuttle Challenger, which was destroyed in 1986. + +NASA chose, on cost grounds, to build much of Endeavour from spare parts rather than refitting the Space Shuttle Enterprise, and used structural spares built during the construction of Discovery and Atlantis in its assembly. +Space Shuttle Endeavour (Orbiter Vehicle Designation: OV-105) is a retired orbiter from NASA's Space Shuttle program and the fifth and final operational Shuttle built. It embarked on its first mission, STS-49, in May 1992 and its 25th and final mission, STS-134, in May 2011. STS-134 was expected to be the final mission of the Space Shuttle program, but with the authorization of STS-135, Atlantis became the last shuttle to fly. diff --git a/packages/astro/test/fixtures/content-layer/content-outside-src/shuttle.jpg b/packages/astro/test/fixtures/content-layer/content-outside-src/shuttle.jpg new file mode 100644 index 000000000000..80b8ea67b8e4 Binary files /dev/null and b/packages/astro/test/fixtures/content-layer/content-outside-src/shuttle.jpg differ diff --git a/packages/astro/test/fixtures/content-layer/images/atlantis.avif b/packages/astro/test/fixtures/content-layer/images/atlantis.avif new file mode 100644 index 000000000000..6b9f6d5a5913 Binary files /dev/null and b/packages/astro/test/fixtures/content-layer/images/atlantis.avif differ diff --git a/packages/astro/test/fixtures/content-layer/images/launch.webp b/packages/astro/test/fixtures/content-layer/images/launch.webp new file mode 100644 index 000000000000..144ded978ad2 Binary files /dev/null and b/packages/astro/test/fixtures/content-layer/images/launch.webp differ diff --git a/packages/astro/test/fixtures/content-layer/images/shuttle.jpg b/packages/astro/test/fixtures/content-layer/images/shuttle.jpg new file mode 100644 index 000000000000..80b8ea67b8e4 Binary files /dev/null and b/packages/astro/test/fixtures/content-layer/images/shuttle.jpg differ diff --git a/packages/astro/test/fixtures/content-layer/package.json b/packages/astro/test/fixtures/content-layer/package.json new file mode 100644 index 000000000000..fc73ce6f7ac7 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer/package.json @@ -0,0 +1,9 @@ +{ + "name": "@test/content-layer", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*", + "@astrojs/mdx": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/content-layer/src/components/H2.astro b/packages/astro/test/fixtures/content-layer/src/components/H2.astro new file mode 100644 index 000000000000..d1ad359c2ee1 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer/src/components/H2.astro @@ -0,0 +1,4 @@ +--- +--- + +

diff --git a/packages/astro/test/fixtures/content-layer/src/components/H3.astro b/packages/astro/test/fixtures/content-layer/src/components/H3.astro new file mode 100644 index 000000000000..fa476e929eae --- /dev/null +++ b/packages/astro/test/fixtures/content-layer/src/components/H3.astro @@ -0,0 +1,2 @@ + +

\ No newline at end of file diff --git a/packages/astro/test/fixtures/content-layer/src/components/LayoutProp.astro b/packages/astro/test/fixtures/content-layer/src/components/LayoutProp.astro new file mode 100644 index 000000000000..a2954162a396 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer/src/components/LayoutProp.astro @@ -0,0 +1,39 @@ +--- +import { CollectionEntry, getCollection } from 'astro:content'; +import H3 from './H3.astro' +// Test for recursive `getCollection()` calls +const blog = await getCollection('blog'); + +type Props = { + content: CollectionEntry<'blog'>['data']; +}; + +const { + content: { title }, +} = Astro.props; +--- + + + + + + + + With Layout Prop + + +

{title}

+

H3 inserted in the layout

+ + + + + diff --git a/packages/astro/test/fixtures/content-layer/src/components/WithScripts.astro b/packages/astro/test/fixtures/content-layer/src/components/WithScripts.astro new file mode 100644 index 000000000000..e37694eaf1f0 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer/src/components/WithScripts.astro @@ -0,0 +1,11 @@ +

Hoisted script didn't update me :(

+ +

Inline script didn't update me :(

+ + + + diff --git a/packages/astro/test/fixtures/content-layer/src/content/config.ts b/packages/astro/test/fixtures/content-layer/src/content/config.ts new file mode 100644 index 000000000000..1689a56b1c28 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer/src/content/config.ts @@ -0,0 +1,111 @@ +import { defineCollection, z, reference } from 'astro:content'; +import { file, glob } from 'astro/loaders'; +import { loader } from '../loaders/post-loader.js'; + +const blog = defineCollection({ + loader: loader({ url: 'https://jsonplaceholder.typicode.com/posts' }), +}); + +const dogs = defineCollection({ + loader: file('src/data/dogs.json'), + schema: z.object({ + breed: z.string(), + id: z.string(), + size: z.string(), + origin: z.string(), + lifespan: z.string(), + temperament: z.array(z.string()), + }), +}); + +const cats = defineCollection({ + loader: async function () { + return [ + { + breed: 'Siamese', + id: 'siamese', + size: 'Medium', + origin: 'Thailand', + lifespan: '15 years', + temperament: ['Active', 'Affectionate', 'Social', 'Playful'], + }, + { + breed: 'Persian', + id: 'persian', + size: 'Medium', + origin: 'Iran', + lifespan: '15 years', + temperament: ['Calm', 'Affectionate', 'Social'], + }, + { + breed: 'Tabby', + id: 'tabby', + size: 'Medium', + origin: 'Egypt', + lifespan: '15 years', + temperament: ['Curious', 'Playful', 'Independent'], + }, + { + breed: 'Ragdoll', + id: 'ragdoll', + size: 'Medium', + origin: 'United States', + lifespan: '15 years', + temperament: ['Calm', 'Affectionate', 'Social'], + }, + ]; + }, + schema: z.object({ + breed: z.string(), + id: z.string(), + size: z.string(), + origin: z.string(), + lifespan: z.string(), + temperament: z.array(z.string()), + }), +}); + +// Absolute paths should also work +const absoluteRoot = new URL('../../content-outside-src', import.meta.url); + +const spacecraft = defineCollection({ + loader: glob({ pattern: '*.md', base: absoluteRoot }), + schema: ({ image }) => + z.object({ + title: z.string(), + description: z.string(), + publishedDate: z.coerce.date(), + tags: z.array(z.string()), + heroImage: image().optional(), + cat: reference('cats').optional(), + }), +}); + +const numbers = defineCollection({ + loader: glob({ pattern: 'src/data/glob-data/*', base: '.' }), +}); + +const increment = defineCollection({ + loader: { + name: 'increment-loader', + load: async ({ store }) => { + const entry = store.get<{lastValue: number}>('value'); + const lastValue = entry?.data.lastValue ?? 0; + store.set({ + id: 'value', + data: { + lastValue: lastValue + 1, + lastUpdated: new Date(), + }, + }); + }, + // Example of a loader that returns an async schema function + schema: async () => z.object({ + lastValue: z.number(), + lastUpdated: z.date(), + }), + }, + +}); + +export const collections = { blog, dogs, cats, numbers, spacecraft, increment }; diff --git a/packages/astro/test/fixtures/content-layer/src/data/dogs.json b/packages/astro/test/fixtures/content-layer/src/data/dogs.json new file mode 100644 index 000000000000..66e6320c65e1 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer/src/data/dogs.json @@ -0,0 +1,302 @@ +[ + { + "breed": "Labrador Retriever", + "id": "labrador-retriever", + "size": "Large", + "origin": "Canada", + "lifespan": "10-12 years", + "temperament": [ + "Friendly", + "Active", + "Outgoing" + ] + }, + { + "breed": "German Shepherd", + "id": "german-shepherd", + "size": "Large", + "origin": "Germany", + "lifespan": "9-13 years", + "temperament": [ + "Loyal", + "Intelligent", + "Confident" + ] + }, + { + "breed": "Golden Retriever", + "id": "golden-retriever", + "size": "Large", + "origin": "Scotland", + "lifespan": "10-12 years", + "temperament": [ + "Intelligent", + "Friendly", + "Devoted" + ] + }, + { + "breed": "French Bulldog", + "id": "french-bulldog", + "size": "Small", + "origin": "France", + "lifespan": "10-12 years", + "temperament": [ + "Playful", + "Adaptable", + "Sociable" + ] + }, + { + "breed": "Bulldog", + "id": "bulldog", + "size": "Medium", + "origin": "England", + "lifespan": "8-10 years", + "temperament": [ + "Docile", + "Willful", + "Friendly" + ] + }, + { + "breed": "Beagle", + "id": "beagle", + "size": "Small to Medium", + "origin": "England", + "lifespan": "12-15 years", + "temperament": [ + "Friendly", + "Curious", + "Merry" + ] + }, + { + "breed": "Poodle", + "id": "poodle", + "size": "Toy, Miniature, or Standard", + "origin": "Germany", + "lifespan": "12-15 years", + "temperament": [ + "Intelligent", + "Active", + "Alert" + ] + }, + { + "breed": "Rottweiler", + "id": "rottweiler", + "size": "Large", + "origin": "Germany", + "lifespan": "8-10 years", + "temperament": [ + "Loyal", + "Confident", + "Courageous" + ] + }, + { + "breed": "German Shorthaired Pointer", + "id": "german-shorthaired-pointer", + "size": "Medium to Large", + "origin": "Germany", + "lifespan": "10-12 years", + "temperament": [ + "Friendly", + "Intelligent", + "Willing to Please" + ] + }, + { + "breed": "Yorkshire Terrier", + "id": "yorkshire-terrier", + "size": "Toy", + "origin": "England", + "lifespan": "13-16 years", + "temperament": [ + "Affectionate", + "Sprightly", + "Tomboyish" + ] + }, + { + "breed": "Boxer", + "id": "boxer", + "size": "Medium to Large", + "origin": "Germany", + "lifespan": "10-12 years", + "temperament": [ + "Playful", + "Energetic", + "Loyal" + ] + }, + { + "breed": "Dachshund", + "id": "dachshund", + "size": "Small", + "origin": "Germany", + "lifespan": "12-16 years", + "temperament": [ + "Clever", + "Stubborn", + "Devoted" + ] + }, + { + "breed": "Siberian Husky", + "id": "siberian-husky", + "size": "Medium", + "origin": "Siberia", + "lifespan": "12-14 years", + "temperament": [ + "Outgoing", + "Mischievous", + "Loyal" + ] + }, + { + "breed": "Great Dane", + "id": "great-dane", + "size": "Large", + "origin": "Germany", + "lifespan": "7-10 years", + "temperament": [ + "Friendly", + "Patient", + "Dependable" + ] + }, + { + "breed": "Doberman Pinscher", + "id": "doberman-pinscher", + "size": "Large", + "origin": "Germany", + "lifespan": "10-12 years", + "temperament": [ + "Loyal", + "Fearless", + "Alert" + ] + }, + { + "breed": "Australian Shepherd", + "id": "australian-shepherd", + "size": "Medium", + "origin": "United States", + "lifespan": "12-15 years", + "temperament": [ + "Intelligent", + "Work-oriented", + "Exuberant" + ] + }, + { + "breed": "Miniature Schnauzer", + "id": "miniature-schnauzer", + "size": "Small", + "origin": "Germany", + "lifespan": "12-15 years", + "temperament": [ + "Friendly", + "Smart", + "Obedient" + ] + }, + { + "breed": "Cavalier King Charles Spaniel", + "id": "cavalier-king-charles-spaniel", + "size": "Small", + "origin": "United Kingdom", + "lifespan": "9-14 years", + "temperament": [ + "Affectionate", + "Gentle", + "Graceful" + ] + }, + { + "breed": "Shih Tzu", + "id": "shih-tzu", + "size": "Small", + "origin": "China", + "lifespan": "10-18 years", + "temperament": [ + "Affectionate", + "Playful", + "Outgoing" + ] + }, + { + "breed": "Boston Terrier", + "id": "boston-terrier", + "size": "Small", + "origin": "United States", + "lifespan": "11-13 years", + "temperament": [ + "Friendly", + "Lively", + "Intelligent" + ] + }, + { + "breed": "Bernese Mountain Dog", + "id": "bernese-mountain-dog", + "size": "Large", + "origin": "Switzerland", + "lifespan": "7-10 years", + "temperament": [ + "Good-natured", + "Calm", + "Strong" + ] + }, + { + "breed": "Pomeranian", + "id": "pomeranian", + "size": "Toy", + "origin": "Germany", + "lifespan": "12-16 years", + "temperament": [ + "Lively", + "Bold", + "Inquisitive" + ] + }, + { + "breed": "Havanese", + "id": "havanese", + "size": "Small", + "origin": "Cuba", + "lifespan": "14-16 years", + "temperament": [ + "Playful", + "Intelligent", + "Outgoing" + ] + }, + { + "breed": "English Springer Spaniel", + "id": "english-springer-spaniel", + "size": "Medium", + "origin": "England", + "lifespan": "12-14 years", + "temperament": [ + "Friendly", + "Playful", + "Obedient" + ] + }, + { + "breed": "Shetland Sheepdog", + "id": "shetland-sheepdog", + "size": "Small", + "origin": "Scotland", + "lifespan": "12-14 years", + "temperament": [ + "Playful", + "Energetic", + "Intelligent" + ] + } +] diff --git a/packages/astro/test/fixtures/content-layer/src/data/glob-data/index.json b/packages/astro/test/fixtures/content-layer/src/data/glob-data/index.json new file mode 100644 index 000000000000..efc60137d68e --- /dev/null +++ b/packages/astro/test/fixtures/content-layer/src/data/glob-data/index.json @@ -0,0 +1,3 @@ +{ + "title": "One" +} diff --git a/packages/astro/test/fixtures/content-layer/src/data/glob-data/one.json b/packages/astro/test/fixtures/content-layer/src/data/glob-data/one.json new file mode 100644 index 000000000000..efc60137d68e --- /dev/null +++ b/packages/astro/test/fixtures/content-layer/src/data/glob-data/one.json @@ -0,0 +1,3 @@ +{ + "title": "One" +} diff --git a/packages/astro/test/fixtures/content-layer/src/data/glob-data/three.json b/packages/astro/test/fixtures/content-layer/src/data/glob-data/three.json new file mode 100644 index 000000000000..7d028e937a71 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer/src/data/glob-data/three.json @@ -0,0 +1,3 @@ +{ + "title": "Three" +} diff --git a/packages/astro/test/fixtures/content-layer/src/data/glob-data/two.json b/packages/astro/test/fixtures/content-layer/src/data/glob-data/two.json new file mode 100644 index 000000000000..1a8215509b0a --- /dev/null +++ b/packages/astro/test/fixtures/content-layer/src/data/glob-data/two.json @@ -0,0 +1,3 @@ +{ + "title": "Two" +} diff --git a/packages/astro/test/fixtures/content-layer/src/loaders/post-loader.ts b/packages/astro/test/fixtures/content-layer/src/loaders/post-loader.ts new file mode 100644 index 000000000000..b03c1442227e --- /dev/null +++ b/packages/astro/test/fixtures/content-layer/src/loaders/post-loader.ts @@ -0,0 +1,45 @@ +import { z } from 'astro:content'; +import type { Loader } from "astro/loaders" + +export interface PostLoaderConfig { + url: string; +} + +export function loader(config:PostLoaderConfig): Loader { + return { + name: "post-loader", + load: async ({ + store, meta, logger + }) => { + logger.info('Loading posts'); + + const lastSynced = meta.get('lastSynced'); + + // Don't sync more than once a minute + if (lastSynced && (Date.now() - Number(lastSynced) < 1000 * 60)) { + logger.info('Skipping sync'); + return; + } + + const posts = await fetch(config.url) + .then((res) => res.json()); + + store.clear(); + + for (const data of posts) { + store.set({id: data.id, data}); + } + meta.set('lastSynced', String(Date.now())); + }, + schema: async () => { + // Simulate a delay + await new Promise((resolve) => setTimeout(resolve, 1000)); + return z.object({ + title: z.string(), + body: z.string(), + userId: z.number(), + id: z.number(), + }); + } + }; +} diff --git a/packages/astro/test/fixtures/content-layer/src/pages/blog/[id].astro b/packages/astro/test/fixtures/content-layer/src/pages/blog/[id].astro new file mode 100644 index 000000000000..850833907ba7 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer/src/pages/blog/[id].astro @@ -0,0 +1,29 @@ +--- +import type { GetStaticPaths } from "astro"; +import { getCollection } from "astro:content" +export const getStaticPaths = (async () => { + const collection = await getCollection("blog"); + if(!collection) return [] + return collection.map((post) => ({ + params: { + id: post.id + }, + props: { + post: post.data + } + })); +}) satisfies GetStaticPaths; + +interface Props { + post: { + title: string; + body: string; + } +} + +const { post } = Astro.props + +--- + +

{post.title}

+

{post.body}

diff --git a/packages/astro/test/fixtures/content-layer/src/pages/collections.json.js b/packages/astro/test/fixtures/content-layer/src/pages/collections.json.js new file mode 100644 index 000000000000..f637bf80d02f --- /dev/null +++ b/packages/astro/test/fixtures/content-layer/src/pages/collections.json.js @@ -0,0 +1,30 @@ +import { getCollection, getEntry } from 'astro:content'; +import * as devalue from 'devalue'; + +export async function GET() { + const customLoader = await getCollection('blog', (entry) => { + return entry.data.id < 6; + }); + const fileLoader = await getCollection('dogs'); + + const dataEntry = await getEntry('dogs', 'beagle'); + + const simpleLoader = await getCollection('cats'); + + const entryWithReference = await getEntry('spacecraft', 'columbia-copy'); + const referencedEntry = await getEntry(entryWithReference.data.cat); + + const increment = await getEntry('increment', 'value'); + + return new Response( + devalue.stringify({ + customLoader, + fileLoader, + dataEntry, + simpleLoader, + entryWithReference, + referencedEntry, + increment, + }) + ); +} diff --git a/packages/astro/test/fixtures/content-layer/src/pages/dogs/[slug].astro b/packages/astro/test/fixtures/content-layer/src/pages/dogs/[slug].astro new file mode 100644 index 000000000000..977ae6efa167 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer/src/pages/dogs/[slug].astro @@ -0,0 +1,40 @@ +--- +import type { GetStaticPaths } from "astro"; +import { getCollection } from "astro:content" +export const getStaticPaths = (async () => { + const collection = await getCollection("dogs"); + if(!collection) return [] + return collection.map((dog) => ({ + params: { + slug: dog.id + }, + props: { + dog: dog.data + } + })); +}) satisfies GetStaticPaths; + + +interface Props { + dog: { + breed: string; + id: string; + size: string; + origin: string; + lifespan: string; + temperament: string[]; + } +} + +const { dog } = Astro.props + + +--- + +

{dog.breed}

+
    +
  • Size: {dog.size}
  • +
  • Origin: {dog.origin}
  • +
  • Lifespan: {dog.lifespan}
  • +
  • Temperament: {dog.temperament.join(", ")}
  • +
diff --git a/packages/astro/test/fixtures/content-layer/src/pages/index.astro b/packages/astro/test/fixtures/content-layer/src/pages/index.astro new file mode 100644 index 000000000000..dbd18118a045 --- /dev/null +++ b/packages/astro/test/fixtures/content-layer/src/pages/index.astro @@ -0,0 +1,33 @@ +--- +import { getCollection, getEntry } from 'astro:content'; + +const blog = await getCollection('blog'); +const first = await getEntry('blog', 1); +const dogs = await getCollection('dogs'); +const increment = await getEntry('increment', 'value'); +--- + + + Index + + +

Last updated: {increment.data.lastUpdated.toLocaleTimeString()}

+ +

Dogs

+ +

Blog Posts

+ +

{first.data.title}

+ + + + + diff --git a/packages/astro/test/fixtures/content-layer/src/pages/spacecraft/[slug].astro b/packages/astro/test/fixtures/content-layer/src/pages/spacecraft/[slug].astro new file mode 100644 index 000000000000..80314606f96f --- /dev/null +++ b/packages/astro/test/fixtures/content-layer/src/pages/spacecraft/[slug].astro @@ -0,0 +1,35 @@ +--- +import type { GetStaticPaths } from "astro"; +import { getCollection, getEntry, render } from "astro:content" +import { Image } from "astro:assets" + +export const getStaticPaths = (async () => { + const collection = await getCollection("spacecraft"); + if(!collection) return [] + return collection.map((craft) => ({ + params: { + slug: `/${craft.id}` + }, + props: { + craft + } + })); +}) satisfies GetStaticPaths; + + + + +const { craft } = Astro.props as any + +let cat = craft.data.cat ? await getEntry(craft.data.cat) : undefined +const { Content, headings } = await render(craft) + +--- + +

{craft.data.title}

+ +{cat?

🐈: {cat.data.breed}

: undefined} +{craft.data.heroImage ? {craft.data.title} : undefined} + diff --git a/packages/astro/test/test-utils.js b/packages/astro/test/test-utils.js index 4ed888f970f4..95edeebd2673 100644 --- a/packages/astro/test/test-utils.js +++ b/packages/astro/test/test-utils.js @@ -7,6 +7,8 @@ import fastGlob from 'fast-glob'; import stripAnsi from 'strip-ansi'; import { Agent } from 'undici'; import { check } from '../dist/cli/check/index.js'; +import { globalContentLayer } from '../dist/content/content-layer.js'; +import { globalContentConfigObserver } from '../dist/content/utils.js'; import build from '../dist/core/build/index.js'; import { RESOLVED_SPLIT_MODULE_ID } from '../dist/core/build/plugins/plugin-ssr.js'; import { getVirtualModulePageName } from '../dist/core/build/plugins/util.js'; @@ -22,6 +24,7 @@ process.env.ASTRO_TELEMETRY_DISABLED = true; /** * @typedef {import('../src/core/dev/dev').DevServer} DevServer * @typedef {import('../src/@types/astro').AstroInlineConfig & { root?: string | URL }} AstroInlineConfig + * @typedef {import('../src/@types/astro').AstroConfig} AstroConfig * @typedef {import('../src/core/preview/index').PreviewServer} PreviewServer * @typedef {import('../src/core/app/index').App} App * @typedef {import('../src/cli/check/index').AstroChecker} AstroChecker @@ -36,7 +39,7 @@ process.env.ASTRO_TELEMETRY_DISABLED = true; * @property {(path: string) => Promise} pathExists * @property {(url: string, opts: Parameters[1]) => Promise} fetch * @property {(path: string) => Promise} readFile - * @property {(path: string, updater: (content: string) => string) => Promise} writeFile + * @property {(path: string, updater: (content: string) => string) => Promise} editFile * @property {(path: string) => Promise} readdir * @property {(pattern: string) => Promise} glob * @property {typeof dev} startDevServer @@ -47,6 +50,7 @@ process.env.ASTRO_TELEMETRY_DISABLED = true; * @property {() => Promise} onNextChange * @property {typeof check} check * @property {typeof sync} sync + * @property {AstroConfig} config * * This function returns an instance of the Check * @@ -157,15 +161,22 @@ export async function loadFixture(inlineConfig) { let devServer; return { - build: async (extraInlineConfig = {}) => { + build: async (extraInlineConfig = {}, options = {}) => { + globalContentLayer.dispose(); + globalContentConfigObserver.set({ status: 'init' }); process.env.NODE_ENV = 'production'; - return build(mergeConfig(inlineConfig, extraInlineConfig), { teardownCompiler: false }); + return build(mergeConfig(inlineConfig, extraInlineConfig), { + teardownCompiler: false, + ...options, + }); }, sync, check: async (opts) => { return await check(opts); }, startDevServer: async (extraInlineConfig = {}) => { + globalContentLayer.dispose(); + globalContentConfigObserver.set({ status: 'init' }); process.env.NODE_ENV = 'development'; devServer = await dev(mergeConfig(inlineConfig, extraInlineConfig)); config.server.host = parseAddressToHost(devServer.address.address); // update host @@ -275,7 +286,7 @@ export async function loadFixture(inlineConfig) { typeof newContentsOrCallback === 'function' ? newContentsOrCallback(contents) : newContentsOrCallback; - const nextChange = onNextChange(); + const nextChange = devServer ? onNextChange() : Promise.resolve(); await fs.promises.writeFile(fileUrl, newContents); await nextChange; return reset; diff --git a/packages/astro/test/units/dev/collections-mixed-content-errors.test.js b/packages/astro/test/units/dev/collections-mixed-content-errors.test.js index 73ce15377f34..3417650fc963 100644 --- a/packages/astro/test/units/dev/collections-mixed-content-errors.test.js +++ b/packages/astro/test/units/dev/collections-mixed-content-errors.test.js @@ -8,13 +8,15 @@ const root = new URL('../../fixtures/content-mixed-errors/', import.meta.url); async function sync({ fs }) { try { - await _sync({ - inlineConfig: { + await _sync( + { root: fileURLToPath(root), logLevel: 'silent', }, - fs, - }); + { + fs, + }, + ); return 0; } catch (_) { return 1; @@ -121,11 +123,7 @@ title: Post root, ); - try { - const res = await sync({ fs }); - assert.equal(res, 0); - } catch (e) { - assert.fail(`Did not expect sync to throw: ${e.message}`); - } + const res = await sync({ fs }); + assert.equal(res, 0); }); }); diff --git a/packages/astro/test/units/dev/restart.test.js b/packages/astro/test/units/dev/restart.test.js index a09bfce98c6f..339b95fc1acb 100644 --- a/packages/astro/test/units/dev/restart.test.js +++ b/packages/astro/test/units/dev/restart.test.js @@ -194,4 +194,31 @@ describe('dev container restarts', () => { await restart.container.close(); } }); + + it('Is able to restart project on .astro/settings.json changes', async () => { + const fs = createFs( + { + '/src/pages/index.astro': ``, + '/.astro/settings.json': `{}`, + }, + root, + ); + + const restart = await createContainerWithAutomaticRestart({ + fs, + inlineConfig: { root: fileURLToPath(root), logLevel: 'silent' }, + }); + await startContainer(restart.container); + assert.equal(isStarted(restart.container), true); + + try { + let restartComplete = restart.restarted(); + fs.mkdirSync('/.astro/', { recursive: true }); + fs.writeFileSync('/.astro/settings.json', `{ }`); + triggerFSEvent(restart.container, fs, '/.astro/settings.json', 'change'); + await restartComplete; + } finally { + await restart.container.close(); + } + }); }); diff --git a/packages/astro/test/units/integrations/api.test.js b/packages/astro/test/units/integrations/api.test.js index 960e003fc3f1..6122ba6408f6 100644 --- a/packages/astro/test/units/integrations/api.test.js +++ b/packages/astro/test/units/integrations/api.test.js @@ -1,7 +1,11 @@ import * as assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { validateSupportedFeatures } from '../../../dist/integrations/features-validation.js'; -import { runHookBuildSetup, runHookConfigSetup } from '../../../dist/integrations/hooks.js'; +import { + normalizeInjectedTypeFilename, + runHookBuildSetup, + runHookConfigSetup, +} from '../../../dist/integrations/hooks.js'; import { defaultLogger } from '../test-utils.js'; describe('Integration API', () => { @@ -311,3 +315,20 @@ describe('Astro feature map', function () { }); }); }); + +describe('normalizeInjectedTypeFilename', () => { + // invalid filename + assert.throws(() => normalizeInjectedTypeFilename('types', 'integration')); + // valid filename + assert.doesNotThrow(() => normalizeInjectedTypeFilename('types.d.ts', 'integration')); + // filename normalization + assert.equal( + normalizeInjectedTypeFilename('aA1-*/_"~.d.ts', 'integration'), + './integrations/integration/aA1-_____.d.ts', + ); + // integration name normalization + assert.equal( + normalizeInjectedTypeFilename('types.d.ts', 'aA1-*/_"~.'), + './integrations/aA1-_____./types.d.ts', + ); +}); diff --git a/packages/astro/types/content.d.ts b/packages/astro/types/content.d.ts index 18ddf2c8ca5e..1715a30a42e3 100644 --- a/packages/astro/types/content.d.ts +++ b/packages/astro/types/content.d.ts @@ -20,6 +20,31 @@ declare module 'astro:content' { >; }>; + export interface DataEntry { + id: string; + data: Record; + filePath?: string; + body?: string; + } + + export interface DataStore { + get: (key: string) => DataEntry; + entries: () => Array<[id: string, DataEntry]>; + set: (key: string, data: Record, body?: string, filePath?: string) => void; + values: () => Array; + keys: () => Array; + delete: (key: string) => void; + clear: () => void; + has: (key: string) => boolean; + } + + export interface MetaStore { + get: (key: string) => string | undefined; + set: (key: string, value: string) => void; + delete: (key: string) => void; + has: (key: string) => boolean; + } + type BaseSchemaWithoutEffects = | import('astro/zod').AnyZodObject | import('astro/zod').ZodUnion<[BaseSchemaWithoutEffects, ...BaseSchemaWithoutEffects[]]> @@ -32,6 +57,12 @@ declare module 'astro:content' { export type SchemaContext = { image: ImageFunction }; + type ContentLayerConfig = { + type?: 'content_layer'; + schema?: S | ((context: SchemaContext) => S); + loader: import('astro/loaders').Loader | (() => Array | Promise>); + }; + type DataCollectionConfig = { type: 'data'; schema?: S | ((context: SchemaContext) => S); @@ -40,11 +71,13 @@ declare module 'astro:content' { type ContentCollectionConfig = { type?: 'content'; schema?: S | ((context: SchemaContext) => S); + loader?: never; }; export type CollectionConfig = | ContentCollectionConfig - | DataCollectionConfig; + | DataCollectionConfig + | ContentLayerConfig; export function defineCollection( input: CollectionConfig, diff --git a/packages/db/src/core/integration/index.ts b/packages/db/src/core/integration/index.ts index 6573c838af13..68439cfb9541 100644 --- a/packages/db/src/core/integration/index.ts +++ b/packages/db/src/core/integration/index.ts @@ -22,7 +22,7 @@ import { resolveDbConfig } from '../load-file.js'; import { SEED_DEV_FILE_NAME } from '../queries.js'; import { type VitePlugin, getDbDirectoryUrl } from '../utils.js'; import { fileURLIntegration } from './file-url.js'; -import { typegenInternal } from './typegen.js'; +import { getDtsContent } from './typegen.js'; import { type LateSeedFiles, type LateTables, @@ -30,7 +30,6 @@ import { resolved, vitePluginDb, } from './vite-plugin-db.js'; -import { vitePluginInjectEnvTs } from './vite-plugin-inject-env-ts.js'; function astroDBIntegration(): AstroIntegration { let connectToStudio = false; @@ -102,11 +101,11 @@ function astroDBIntegration(): AstroIntegration { updateConfig({ vite: { assetsInclude: [DB_PATH], - plugins: [dbPlugin, vitePluginInjectEnvTs(config, logger)], + plugins: [dbPlugin], }, }); }, - 'astro:config:done': async ({ config }) => { + 'astro:config:done': async ({ config, injectTypes }) => { if (command === 'preview') return; // TODO: refine where we load tables @@ -122,7 +121,10 @@ function astroDBIntegration(): AstroIntegration { await writeFile(localDbUrl, ''); } - await typegenInternal({ tables: tables.get() ?? {}, root: config.root }); + injectTypes({ + filename: 'db.d.ts', + content: getDtsContent(tables.get() ?? {}), + }); }, 'astro:server:setup': async ({ server, logger }) => { seedHandler.execute = async (fileUrl) => { diff --git a/packages/db/src/core/integration/typegen.ts b/packages/db/src/core/integration/typegen.ts index 725738fc5a51..91364b3c3dc1 100644 --- a/packages/db/src/core/integration/typegen.ts +++ b/packages/db/src/core/integration/typegen.ts @@ -1,18 +1,7 @@ -import { existsSync } from 'node:fs'; -import { mkdir, writeFile } from 'node:fs/promises'; -import type { AstroConfig } from 'astro'; -import { DB_TYPES_FILE, RUNTIME_IMPORT } from '../consts.js'; -import { resolveDbConfig } from '../load-file.js'; +import { RUNTIME_IMPORT } from '../consts.js'; import type { DBTable, DBTables } from '../types.js'; -// Exported for use in Astro core CLI -export async function typegen(astroConfig: Pick) { - const { dbConfig } = await resolveDbConfig(astroConfig); - - await typegenInternal({ tables: dbConfig.tables, root: astroConfig.root }); -} - -export async function typegenInternal({ tables, root }: { tables: DBTables; root: URL }) { +export function getDtsContent(tables: DBTables) { const content = `// This file is generated by Astro DB declare module 'astro:db' { ${Object.entries(tables) @@ -20,14 +9,7 @@ ${Object.entries(tables) .join('\n')} } `; - - const dotAstroDir = new URL('.astro/', root); - - if (!existsSync(dotAstroDir)) { - await mkdir(dotAstroDir); - } - - await writeFile(new URL(DB_TYPES_FILE, dotAstroDir), content); + return content; } function generateTableType(name: string, table: DBTable): string { diff --git a/packages/db/src/core/integration/vite-plugin-inject-env-ts.ts b/packages/db/src/core/integration/vite-plugin-inject-env-ts.ts deleted file mode 100644 index 14257d480fef..000000000000 --- a/packages/db/src/core/integration/vite-plugin-inject-env-ts.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { existsSync } from 'node:fs'; -import { readFile, writeFile } from 'node:fs/promises'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import type { AstroIntegrationLogger } from 'astro'; -import { bold } from 'kleur/colors'; -import { normalizePath } from 'vite'; -import { DB_TYPES_FILE } from '../consts.js'; -import type { VitePlugin } from '../utils.js'; - -export function vitePluginInjectEnvTs( - { srcDir, root }: { srcDir: URL; root: URL }, - logger: AstroIntegrationLogger, -): VitePlugin { - return { - name: 'db-inject-env-ts', - enforce: 'post', - async config() { - await setUpEnvTs({ srcDir, root, logger }); - }, - }; -} - -export async function setUpEnvTs({ - srcDir, - root, - logger, -}: { - srcDir: URL; - root: URL; - logger: AstroIntegrationLogger; -}) { - const envTsPath = getEnvTsPath({ srcDir }); - const envTsPathRelativetoRoot = normalizePath( - path.relative(fileURLToPath(root), fileURLToPath(envTsPath)), - ); - - if (existsSync(envTsPath)) { - let typesEnvContents = await readFile(envTsPath, 'utf-8'); - const dotAstroDir = new URL('.astro/', root); - - if (!existsSync(dotAstroDir)) return; - - const dbTypeReference = getDBTypeReference({ srcDir, dotAstroDir }); - - if (!typesEnvContents.includes(dbTypeReference)) { - typesEnvContents = `${dbTypeReference}\n${typesEnvContents}`; - await writeFile(envTsPath, typesEnvContents, 'utf-8'); - logger.info(`Added ${bold(envTsPathRelativetoRoot)} types`); - } - } -} - -function getDBTypeReference({ srcDir, dotAstroDir }: { srcDir: URL; dotAstroDir: URL }) { - const dbTypesFile = new URL(DB_TYPES_FILE, dotAstroDir); - const contentTypesRelativeToSrcDir = normalizePath( - path.relative(fileURLToPath(srcDir), fileURLToPath(dbTypesFile)), - ); - - return `/// `; -} - -function getEnvTsPath({ srcDir }: { srcDir: URL }) { - return new URL('env.d.ts', srcDir); -} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index b26507130471..f7022a24a236 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,4 +1,3 @@ export type { TableConfig } from './core/types.js'; export { cli } from './core/cli/index.js'; export { integration as default } from './core/integration/index.js'; -export { typegen } from './core/integration/typegen.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58191b539a1d..01db0912ed26 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: pretty-bytes: specifier: ^6.1.1 version: 6.1.1 + sharp: + specifier: ^0.33.3 + version: 0.33.3 benchmark/packages/timer: dependencies: @@ -585,6 +588,9 @@ importers: '@oslojs/encoding': specifier: ^0.4.1 version: 0.4.1 + '@rollup/pluginutils': + specifier: ^5.1.0 + version: 5.1.0(rollup@4.20.0) '@types/babel__core': specifier: ^7.20.5 version: 7.20.5 @@ -675,9 +681,15 @@ importers: magic-string: specifier: ^0.30.11 version: 0.30.11 + micromatch: + specifier: ^4.0.7 + version: 4.0.7 mrmime: specifier: ^2.0.0 version: 2.0.0 + neotraverse: + specifier: ^0.6.9 + version: 0.6.9 ora: specifier: ^8.0.1 version: 8.0.1 @@ -729,12 +741,18 @@ importers: which-pm: specifier: ^3.0.0 version: 3.0.0 + xxhash-wasm: + specifier: ^1.0.2 + version: 1.0.2 zod: specifier: ^3.23.8 version: 3.23.8 zod-to-json-schema: specifier: ^3.23.2 version: 3.23.2(zod@3.23.8) + zod-to-ts: + specifier: ^1.2.0 + version: 1.2.0(typescript@5.5.4)(zod@3.23.8) optionalDependencies: sharp: specifier: ^0.33.3 @@ -785,6 +803,9 @@ importers: '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 + '@types/micromatch': + specifier: ^4.0.9 + version: 4.0.9 '@types/prompts': specifier: ^2.4.9 version: 2.4.9 @@ -2683,6 +2704,51 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/content-intellisense: + dependencies: + '@astrojs/markdoc': + specifier: workspace:* + version: link:../../../../integrations/markdoc + '@astrojs/mdx': + specifier: workspace:* + version: link:../../../../integrations/mdx + astro: + specifier: workspace:* + version: link:../../.. + + packages/astro/test/fixtures/content-layer: + dependencies: + '@astrojs/mdx': + specifier: workspace:* + version: link:../../../../integrations/mdx + astro: + specifier: workspace:* + version: link:../../.. + + packages/astro/test/fixtures/content-layer-markdoc: + dependencies: + '@astrojs/markdoc': + specifier: workspace:* + version: link:../../../../integrations/markdoc + '@astrojs/preact': + specifier: workspace:* + version: link:../../../../integrations/preact + astro: + specifier: workspace:* + version: link:../../.. + preact: + specifier: ^10.23.1 + version: 10.23.1 + + packages/astro/test/fixtures/content-layer-rendering: + dependencies: + '@astrojs/mdx': + specifier: workspace:* + version: link:../../../../integrations/mdx + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/content-mixed-errors: dependencies: astro: @@ -6077,6 +6143,10 @@ packages: resolution: {integrity: sha512-jKiTsW2xmWwxT1ixIdfXUZp+P5yURx2suzLZr5Hi64rURpDYdMW0pv+Uf17EYk2Rd428Lx4tLsnjGJzYKDM/6A==} engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.24.7': + resolution: {integrity: sha512-Rq76wjt7yz9AAc1KnlRKNAi/dMSVWgDRx43FHoJEbcYU6xOWaE2dVPwcdTukJrjxS65GITyfbvEYHvkirZ6uEg==} + engines: {node: '>=6.9.0'} + '@babel/helper-plugin-utils@7.24.8': resolution: {integrity: sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg==} engines: {node: '>=6.9.0'} @@ -6193,6 +6263,10 @@ packages: resolution: {integrity: sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==} engines: {node: '>=6.9.0'} + '@babel/template@7.24.7': + resolution: {integrity: sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==} + engines: {node: '>=6.9.0'} + '@babel/template@7.25.0': resolution: {integrity: sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==} engines: {node: '>=6.9.0'} @@ -6744,6 +6818,10 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/regexpp@4.10.0': + resolution: {integrity: sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + '@eslint-community/regexpp@4.11.0': resolution: {integrity: sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -6915,6 +6993,9 @@ packages: resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.4.15': + resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + '@jridgewell/sourcemap-codec@1.5.0': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} @@ -7340,6 +7421,9 @@ packages: '@types/body-parser@1.19.5': resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} + '@types/braces@3.0.4': + resolution: {integrity: sha512-0WR3b8eaISjEW7RpZnclONaLFDf7buaowRHdqLp4vLj54AsSAYWfh3DRbfiYJY9XDxMgx1B4sE1Afw2PGpuHOA==} + '@types/canvas-confetti@1.6.4': resolution: {integrity: sha512-fNyZ/Fdw/Y92X0vv7B+BD6ysHL4xVU5dJcgzgxLdGbn8O3PezZNIJpml44lKM0nsGur+o/6+NZbZeNTt00U1uA==} @@ -7427,6 +7511,9 @@ packages: '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} + '@types/micromatch@4.0.9': + resolution: {integrity: sha512-7V+8ncr22h4UoYRLnLXSpTxjQrNUXtWHGeMPRJt1nULXI57G9bIcpyrHlmrQ7QK24EyyuXvYcSSWAM8GA9nqCg==} + '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} @@ -7748,6 +7835,11 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn@8.12.0: + resolution: {integrity: sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw==} + engines: {node: '>=0.4.0'} + hasBin: true + acorn@8.12.1: resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==} engines: {node: '>=0.4.0'} @@ -7883,6 +7975,9 @@ packages: peerDependencies: postcss: ^8.1.0 + axobject-query@4.0.0: + resolution: {integrity: sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -7958,6 +8053,15 @@ packages: resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} engines: {node: '>=8'} + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.23.1: + resolution: {integrity: sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + browserslist@4.23.3: resolution: {integrity: sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -8001,8 +8105,11 @@ packages: resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} engines: {node: '>=14.16'} - caniuse-lite@1.0.30001647: - resolution: {integrity: sha512-n83xdNiyeNcHpzWY+1aFbqCK7LuLfBricc4+alSQL2Xb6OR3XpnQAmlDG+pQcdTfiHRuLcQ96VOfrPSGiNJYSg==} + caniuse-lite@1.0.30001639: + resolution: {integrity: sha512-eFHflNTBIlFwP2AIKaYuBQN/apnUoKNhBdza8ZnW/h2di4LCZ4xFqYlxUxo+LQ76KFI1PGcC1QDxMbxTZpSCAg==} + + caniuse-lite@1.0.30001649: + resolution: {integrity: sha512-fJegqZZ0ZX8HOWr6rcafGr72+xcgJKI9oWfDW5DrD7ExUtgZC7a7R7ZYmZqplh7XDocFdGeIFn7roAxhOeYrPQ==} canvas-confetti@1.9.3: resolution: {integrity: sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g==} @@ -8529,6 +8636,9 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.4.816: + resolution: {integrity: sha512-EKH5X5oqC6hLmiS7/vYtZHZFTNdhsYG5NVPRN6Yn0kQHNBlT59+xSM8HBy66P5fxWpKgZbPqb+diC64ng295Jw==} + electron-to-chromium@1.5.4: resolution: {integrity: sha512-orzA81VqLyIGUEA77YkVA1D+N+nNfl2isJVjjmOyrlxuooZ19ynb+dOlaDTqd/idKRS9lDCSBmtzM+kyCsMnkA==} @@ -8767,6 +8877,10 @@ packages: resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} engines: {node: '>=8'} + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + finalhandler@1.2.0: resolution: {integrity: sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==} engines: {node: '>= 0.8'} @@ -9468,6 +9582,9 @@ packages: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true + magic-string@0.30.10: + resolution: {integrity: sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==} + magic-string@0.30.11: resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} @@ -9710,8 +9827,8 @@ packages: micromark@4.0.0: resolution: {integrity: sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==} - micromatch@4.0.5: - resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} + micromatch@4.0.7: + resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==} engines: {node: '>=8.6'} mime-db@1.52.0: @@ -9829,6 +9946,10 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} + neotraverse@0.6.9: + resolution: {integrity: sha512-IJ00Agk9rMj4ChQwT/nWhLG/vC7PgNAk+BEKOE0DU5ToOHrmv3wefk8+2TcPDb4TRTQNpq0TIaDlaqvmrTAbrw==} + engines: {node: '>= 18'} + nlcst-to-string@4.0.0: resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} @@ -9869,6 +9990,9 @@ packages: resolution: {integrity: sha512-X/GpUpNNiPDYUeUD183W8V4OW6OHYWI29w/QDyb+c/GzOfVEAlo6HjbW9++eXT2aV2lGg+uS+XqTD2q0pNREQA==} engines: {node: '>=14'} + node-releases@2.0.14: + resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} + node-releases@2.0.18: resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} @@ -10098,6 +10222,9 @@ packages: periscopic@3.1.0: resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} + picocolors@1.0.0: + resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + picocolors@1.0.1: resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} @@ -10336,8 +10463,8 @@ packages: preact@10.23.1: resolution: {integrity: sha512-O5UdRsNh4vdZaTieWe3XOgSpdMAmkIYBCT3VhQDlKrzyCm8lUYsk0fmVEvoQQifoOjFRTaHZO69ylrzTW2BH+A==} - preferred-pm@3.1.4: - resolution: {integrity: sha512-lEHd+yEm22jXdCphDrkvIJQU66EuLojPPtvZkpKIkiD+l0DMThF/niqZKJSoU8Vl7iuvtmzyMhir9LdVy5WMnA==} + preferred-pm@3.1.3: + resolution: {integrity: sha512-MkXsENfftWSRpzCzImcp4FRsCc3y1opwB73CfCNWyzMqArju2CrlMHlqB7VexKiPEOjGMbttv1r9fSCn5S610w==} engines: {node: '>=10'} preferred-pm@4.0.0: @@ -10664,6 +10791,11 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.6.2: + resolution: {integrity: sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==} + engines: {node: '>=10'} + hasBin: true + semver@7.6.3: resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} engines: {node: '>=10'} @@ -11193,6 +11325,9 @@ packages: resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} engines: {node: '>=18'} + unified@11.0.4: + resolution: {integrity: sha512-apMPnyLjAX+ty4OrNap7yumyVAMlKx5IWU2wlzzUdYJO9A8f1p9m/gywF/GM2ZDFcjQPrx59Mc90KwmxsoklxQ==} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -11266,6 +11401,12 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + update-browserslist-db@1.0.16: + resolution: {integrity: sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + update-browserslist-db@1.1.0: resolution: {integrity: sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==} hasBin: true @@ -11561,8 +11702,8 @@ packages: resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} engines: {node: '>=4'} - which-pm@2.2.0: - resolution: {integrity: sha512-MOiaDbA5ZZgUjkeMWM5EkJp4loW5ZRoa5bc3/aeMox/PJelMhE6t7S/mLuiY43DBupyxH+S0U1bTui9kWUlmsw==} + which-pm@2.0.0: + resolution: {integrity: sha512-Lhs9Pmyph0p5n5Z3mVnN0yWcbQYUAD7rbQUiMsQxOJ3T57k7RFe35SUwWMf7dsbDZks1uOmw4AecB/JMDj3v/w==} engines: {node: '>=8.15'} which-pm@3.0.0: @@ -11635,6 +11776,9 @@ packages: resolution: {integrity: sha512-f9s+fUkX04BxQf+7mMWAp5zk61pciie+fFLC9hX9UVvCeJQfNHRHXpeo5MPcR0EUf57PYLdt+ZO4f3Ipk2oZUw==} engines: {node: '>=0.1'} + xxhash-wasm@1.0.2: + resolution: {integrity: sha512-ibF0Or+FivM9lNrg+HGJfVX8WJqgo+kCLDc4vx6xMeTce7Aj+DLttKbxxRR/gNLSAelRc1omAPlJ77N/Jem07A==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -11678,6 +11822,12 @@ packages: peerDependencies: zod: ^3.23.3 + zod-to-ts@1.2.0: + resolution: {integrity: sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==} + peerDependencies: + typescript: ^4.9.4 || ^5.0.2 + zod: ^3 + zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} @@ -11826,7 +11976,7 @@ snapshots: dependencies: '@babel/compat-data': 7.25.2 '@babel/helper-validator-option': 7.24.8 - browserslist: 4.23.3 + browserslist: 4.23.1 lru-cache: 5.1.1 semver: 6.3.1 @@ -11851,7 +12001,7 @@ snapshots: '@babel/helper-function-name@7.24.7': dependencies: - '@babel/template': 7.25.0 + '@babel/template': 7.24.7 '@babel/types': 7.25.2 '@babel/helper-member-expression-to-functions@7.24.7': @@ -11890,6 +12040,8 @@ snapshots: dependencies: '@babel/types': 7.25.2 + '@babel/helper-plugin-utils@7.24.7': {} + '@babel/helper-plugin-utils@7.24.8': {} '@babel/helper-replace-supers@7.24.7(@babel/core@7.25.2)': @@ -11945,7 +12097,7 @@ snapshots: dependencies: '@babel/core': 7.25.2 '@babel/helper-create-class-features-plugin': 7.24.7(@babel/core@7.25.2) - '@babel/helper-plugin-utils': 7.24.8 + '@babel/helper-plugin-utils': 7.24.7 '@babel/plugin-syntax-decorators': 7.24.1(@babel/core@7.25.2) transitivePeerDependencies: - supports-color @@ -11953,17 +12105,17 @@ snapshots: '@babel/plugin-syntax-decorators@7.24.1(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 - '@babel/helper-plugin-utils': 7.24.8 + '@babel/helper-plugin-utils': 7.24.7 '@babel/plugin-syntax-import-attributes@7.24.1(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 - '@babel/helper-plugin-utils': 7.24.8 + '@babel/helper-plugin-utils': 7.24.7 '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 - '@babel/helper-plugin-utils': 7.24.8 + '@babel/helper-plugin-utils': 7.24.7 '@babel/plugin-syntax-jsx@7.24.7(@babel/core@7.25.2)': dependencies: @@ -11973,7 +12125,7 @@ snapshots: '@babel/plugin-syntax-typescript@7.24.7(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 - '@babel/helper-plugin-utils': 7.24.8 + '@babel/helper-plugin-utils': 7.24.7 '@babel/plugin-transform-react-jsx-development@7.24.7(@babel/core@7.25.2)': dependencies: @@ -11985,12 +12137,12 @@ snapshots: '@babel/plugin-transform-react-jsx-self@7.24.7(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 - '@babel/helper-plugin-utils': 7.24.8 + '@babel/helper-plugin-utils': 7.24.7 '@babel/plugin-transform-react-jsx-source@7.24.1(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 - '@babel/helper-plugin-utils': 7.24.8 + '@babel/helper-plugin-utils': 7.24.7 '@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.25.2)': dependencies: @@ -12008,7 +12160,7 @@ snapshots: '@babel/core': 7.25.2 '@babel/helper-annotate-as-pure': 7.24.7 '@babel/helper-create-class-features-plugin': 7.24.7(@babel/core@7.25.2) - '@babel/helper-plugin-utils': 7.24.8 + '@babel/helper-plugin-utils': 7.24.7 '@babel/plugin-syntax-typescript': 7.24.7(@babel/core@7.25.2) transitivePeerDependencies: - supports-color @@ -12017,6 +12169,12 @@ snapshots: dependencies: regenerator-runtime: 0.14.1 + '@babel/template@7.24.7': + dependencies: + '@babel/code-frame': 7.24.7 + '@babel/parser': 7.25.3 + '@babel/types': 7.25.2 + '@babel/template@7.25.0': dependencies: '@babel/code-frame': 7.24.7 @@ -12146,7 +12304,7 @@ snapshots: mri: 1.2.0 outdent: 0.5.0 p-limit: 2.3.0 - preferred-pm: 3.1.4 + preferred-pm: 3.1.3 resolve-from: 5.0.0 semver: 7.6.3 spawndamnit: 2.0.0 @@ -12160,7 +12318,7 @@ snapshots: '@changesets/types': 6.0.0 '@manypkg/get-packages': 1.1.3 fs-extra: 7.0.1 - micromatch: 4.0.5 + micromatch: 4.0.7 '@changesets/errors@0.2.0': dependencies: @@ -12200,7 +12358,7 @@ snapshots: '@changesets/types': 6.0.0 '@manypkg/get-packages': 1.1.3 is-subdir: 1.2.0 - micromatch: 4.0.5 + micromatch: 4.0.7 spawndamnit: 2.0.0 '@changesets/logger@0.1.0': @@ -12251,13 +12409,13 @@ snapshots: '@clack/core@0.3.4': dependencies: - picocolors: 1.0.1 + picocolors: 1.0.0 sisteransi: 1.0.5 '@clack/prompts@0.7.0': dependencies: '@clack/core': 0.3.4 - picocolors: 1.0.1 + picocolors: 1.0.0 sisteransi: 1.0.5 '@colors/colors@1.5.0': @@ -12603,6 +12761,8 @@ snapshots: eslint: 9.9.0(jiti@1.21.0) eslint-visitor-keys: 3.4.3 + '@eslint-community/regexpp@4.10.0': {} + '@eslint-community/regexpp@4.11.0': {} '@eslint/config-array@0.17.1': @@ -12732,19 +12892,21 @@ snapshots: '@jridgewell/gen-mapping@0.3.5': dependencies: '@jridgewell/set-array': 1.2.1 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.4.15 '@jridgewell/trace-mapping': 0.3.25 '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/set-array@1.2.1': {} + '@jridgewell/sourcemap-codec@1.4.15': {} + '@jridgewell/sourcemap-codec@1.5.0': {} '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.4.15 '@jsdevtools/rehype-toc@3.0.2': {} @@ -13212,6 +13374,8 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 18.19.31 + '@types/braces@3.0.4': {} + '@types/canvas-confetti@1.6.4': {} '@types/clean-css@4.2.11': @@ -13304,6 +13468,10 @@ snapshots: '@types/mdx@2.0.13': {} + '@types/micromatch@4.0.9': + dependencies: + '@types/braces': 3.0.4 + '@types/mime@1.3.5': {} '@types/ms@0.7.34': {} @@ -13511,7 +13679,7 @@ snapshots: estree-walker: 2.0.2 glob: 7.2.3 graceful-fs: 4.2.11 - micromatch: 4.0.5 + micromatch: 4.0.7 node-gyp-build: 4.8.0 resolve-from: 5.0.0 transitivePeerDependencies: @@ -13638,9 +13806,9 @@ snapshots: '@vue/babel-plugin-jsx@1.2.2(@babel/core@7.25.2)': dependencies: '@babel/helper-module-imports': 7.22.15 - '@babel/helper-plugin-utils': 7.24.8 + '@babel/helper-plugin-utils': 7.24.7 '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.25.2) - '@babel/template': 7.25.0 + '@babel/template': 7.24.7 '@babel/traverse': 7.25.3 '@babel/types': 7.25.2 '@vue/babel-helper-vue-transform-on': 1.2.2 @@ -13658,7 +13826,7 @@ snapshots: '@babel/code-frame': 7.24.7 '@babel/core': 7.25.2 '@babel/helper-module-imports': 7.22.15 - '@babel/helper-plugin-utils': 7.24.8 + '@babel/helper-plugin-utils': 7.24.7 '@babel/parser': 7.25.3 '@vue/compiler-sfc': 3.4.37 @@ -13765,6 +13933,8 @@ snapshots: dependencies: acorn: 8.12.1 + acorn@8.12.0: {} + acorn@8.12.1: {} agent-base@6.0.2: @@ -13906,20 +14076,24 @@ snapshots: progress: 2.0.3 reinterval: 1.1.0 retimer: 3.0.0 - semver: 7.6.3 + semver: 7.6.2 subarg: 1.0.0 timestring: 6.0.0 autoprefixer@10.4.20(postcss@8.4.41): dependencies: browserslist: 4.23.3 - caniuse-lite: 1.0.30001647 + caniuse-lite: 1.0.30001649 fraction.js: 4.3.7 normalize-range: 0.1.2 picocolors: 1.0.1 postcss: 8.4.41 postcss-value-parser: 4.2.0 + axobject-query@4.0.0: + dependencies: + dequal: 2.0.3 + axobject-query@4.1.0: {} babel-plugin-jsx-dom-expressions@0.37.19(@babel/core@7.25.2): @@ -14011,9 +14185,20 @@ snapshots: dependencies: fill-range: 7.0.1 + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.23.1: + dependencies: + caniuse-lite: 1.0.30001639 + electron-to-chromium: 1.4.816 + node-releases: 2.0.14 + update-browserslist-db: 1.0.16(browserslist@4.23.1) + browserslist@4.23.3: dependencies: - caniuse-lite: 1.0.30001647 + caniuse-lite: 1.0.30001649 electron-to-chromium: 1.5.4 node-releases: 2.0.18 update-browserslist-db: 1.1.0(browserslist@4.23.3) @@ -14052,7 +14237,9 @@ snapshots: camelcase@7.0.1: {} - caniuse-lite@1.0.30001647: {} + caniuse-lite@1.0.30001639: {} + + caniuse-lite@1.0.30001649: {} canvas-confetti@1.9.3: {} @@ -14172,7 +14359,7 @@ snapshots: code-red@1.0.4: dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.4.15 '@types/estree': 1.0.5 acorn: 8.12.1 estree-walker: 3.0.3 @@ -14449,6 +14636,8 @@ snapshots: ee-first@1.1.1: {} + electron-to-chromium@1.4.816: {} + electron-to-chromium@1.5.4: {} emmet@2.4.7: @@ -14730,7 +14919,7 @@ snapshots: '@nodelib/fs.walk': 1.2.8 glob-parent: 5.1.2 merge2: 1.4.1 - micromatch: 4.0.5 + micromatch: 4.0.7 fast-json-stable-stringify@2.1.0: {} @@ -14761,6 +14950,10 @@ snapshots: dependencies: to-regex-range: 5.0.1 + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + finalhandler@1.2.0: dependencies: debug: 2.6.9 @@ -14787,7 +14980,7 @@ snapshots: find-yarn-workspace-root2@1.2.16: dependencies: - micromatch: 4.0.5 + micromatch: 4.0.7 pkg-dir: 4.2.0 flat-cache@4.0.1: @@ -15603,13 +15796,17 @@ snapshots: lz-string@1.5.0: {} + magic-string@0.30.10: + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + magic-string@0.30.11: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 magic-string@0.30.5: dependencies: - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.4.15 make-dir@3.1.0: dependencies: @@ -16133,9 +16330,9 @@ snapshots: transitivePeerDependencies: - supports-color - micromatch@4.0.5: + micromatch@4.0.7: dependencies: - braces: 3.0.2 + braces: 3.0.3 picomatch: 2.3.1 mime-db@1.52.0: {} @@ -16216,6 +16413,8 @@ snapshots: negotiator@0.6.3: {} + neotraverse@0.6.9: {} + nlcst-to-string@4.0.0: dependencies: '@types/nlcst': 2.0.3 @@ -16265,6 +16464,8 @@ snapshots: range-parser: 1.2.1 type-is: 1.6.18 + node-releases@2.0.14: {} + node-releases@2.0.18: {} nopt@5.0.0: @@ -16494,6 +16695,8 @@ snapshots: estree-walker: 3.0.3 is-reference: 3.0.2 + picocolors@1.0.0: {} + picocolors@1.0.1: {} picomatch@2.3.1: {} @@ -16771,12 +16974,12 @@ snapshots: preact@10.23.1: {} - preferred-pm@3.1.4: + preferred-pm@3.1.3: dependencies: find-up: 5.0.0 find-yarn-workspace-root2: 1.2.16 path-exists: 4.0.0 - which-pm: 2.2.0 + which-pm: 2.0.0 preferred-pm@4.0.0: dependencies: @@ -16885,7 +17088,7 @@ snapshots: refa@0.12.1: dependencies: - '@eslint-community/regexpp': 4.11.0 + '@eslint-community/regexpp': 4.10.0 regenerator-runtime@0.13.11: {} @@ -16893,7 +17096,7 @@ snapshots: regexp-ast-analysis@0.7.1: dependencies: - '@eslint-community/regexpp': 4.11.0 + '@eslint-community/regexpp': 4.10.0 refa: 0.12.1 rehype-autolink-headings@7.1.0: @@ -16902,7 +17105,7 @@ snapshots: '@ungap/structured-clone': 1.2.0 hast-util-heading-rank: 3.0.0 hast-util-is-element: 3.0.0 - unified: 11.0.5 + unified: 11.0.4 unist-util-visit: 5.0.0 rehype-mathjax@6.0.0: @@ -17175,7 +17378,7 @@ snapshots: scslre@0.3.0: dependencies: - '@eslint-community/regexpp': 4.11.0 + '@eslint-community/regexpp': 4.10.0 refa: 0.12.1 regexp-ast-analysis: 0.7.1 @@ -17186,6 +17389,8 @@ snapshots: semver@6.3.1: {} + semver@7.6.2: {} + semver@7.6.3: {} send@0.18.0: @@ -17242,7 +17447,7 @@ snapshots: dependencies: color: 4.2.3 detect-libc: 2.0.3 - semver: 7.6.3 + semver: 7.6.2 optionalDependencies: '@img/sharp-darwin-arm64': 0.33.3 '@img/sharp-darwin-x64': 0.33.3 @@ -17507,18 +17712,18 @@ snapshots: svelte@4.2.18: dependencies: '@ampproject/remapping': 2.3.0 - '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/sourcemap-codec': 1.4.15 '@jridgewell/trace-mapping': 0.3.25 '@types/estree': 1.0.5 - acorn: 8.12.1 + acorn: 8.12.0 aria-query: 5.3.0 - axobject-query: 4.1.0 + axobject-query: 4.0.0 code-red: 1.0.4 css-tree: 2.3.1 estree-walker: 3.0.3 is-reference: 3.0.2 locate-character: 3.0.0 - magic-string: 0.30.11 + magic-string: 0.30.10 periscopic: 3.1.0 svg-tags@1.0.0: {} @@ -17531,7 +17736,7 @@ snapshots: css-tree: 2.3.1 css-what: 6.1.0 csso: 5.0.5 - picocolors: 1.0.1 + picocolors: 1.0.0 symbol-tree@3.2.4: {} @@ -17547,7 +17752,7 @@ snapshots: is-glob: 4.0.3 jiti: 1.21.0 lilconfig: 2.1.0 - micromatch: 4.0.5 + micromatch: 4.0.7 normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.0.1 @@ -17748,6 +17953,16 @@ snapshots: unicorn-magic@0.1.0: {} + unified@11.0.4: + dependencies: + '@types/unist': 3.0.2 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.2 + unified@11.0.5: dependencies: '@types/unist': 3.0.2 @@ -17844,6 +18059,12 @@ snapshots: unpipe@1.0.0: {} + update-browserslist-db@1.0.16(browserslist@4.23.1): + dependencies: + browserslist: 4.23.1 + escalade: 3.1.2 + picocolors: 1.0.1 + update-browserslist-db@1.1.0(browserslist@4.23.3): dependencies: browserslist: 4.23.3 @@ -18156,7 +18377,7 @@ snapshots: which-pm-runs@1.1.0: {} - which-pm@2.2.0: + which-pm@2.0.0: dependencies: load-yaml-file: 0.2.0 path-exists: 4.0.0 @@ -18217,6 +18438,8 @@ snapshots: xmldom-sre@0.1.31: {} + xxhash-wasm@1.0.2: {} + y18n@5.0.8: {} yallist@2.1.2: {} @@ -18249,6 +18472,11 @@ snapshots: dependencies: zod: 3.23.8 + zod-to-ts@1.2.0(typescript@5.5.4)(zod@3.23.8): + dependencies: + typescript: 5.5.4 + zod: 3.23.8 + zod@3.23.8: {} zwitch@2.0.4: {}