-
-
Notifications
You must be signed in to change notification settings - Fork 2.9k
feat: live content collections #13685
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 40 commits
Commits
Show all changes
41 commits
Select commit
Hold shift + click to select a range
4ccdc58
feat: add support for live content loaders
ascorbic 10a4f37
Add experimental flag
ascorbic 67675ae
Merge branch 'main' into live-loaders
ascorbic cca4987
Merge branch 'main' into live-loaders
ascorbic cffc454
feat: initial live loader implementation (#13688)
ascorbic d11a12e
Merge branch 'main' into live-loaders
ascorbic e10e0cf
Lock
ascorbic c072818
feat: add schema parsing to live collections (#13763)
ascorbic c4fafc7
feat: add cache hint support to live loaders (#13767)
ascorbic 578ff42
Export defineCollection from astro/config (#13814)
ascorbic 8ca6450
Fix types
ascorbic c9b0024
Merge branch 'main' into live-loaders
ascorbic 68f0d3c
Update lockfile
ascorbic cf0c30b
chore: rename feature to live content collections (#13861)
ascorbic c05382f
Merge branch 'main' into live-loaders
ascorbic c21a947
Lock
ascorbic 6144157
Merge branch 'main' into live-loaders
ascorbic 5caa8a6
Lock
ascorbic fe042ee
feat(live loaders): rename functions and add error handling (#13846)
ascorbic 3128861
Merge branch 'main' into live-loaders
ascorbic c916fc2
Lock
ascorbic 3d7dd7d
Dedupe
ascorbic 3f5869b
Merge branch 'main' into live-loaders
ascorbic bcfebec
Merge branch 'main' into live-loaders
ascorbic 7ef8f79
Merge branch 'main' into live-loaders
ascorbic d766e76
chore: change export for defineCollection (#13934)
ascorbic dc34de1
types
ascorbic d14b23a
Split out to defineLiveCollection
ascorbic 7316cac
typo
ascorbic 0525d9a
Indentation
ascorbic d8cb6ca
Merge branch 'main' into live-loaders
ascorbic c63db9f
More type fixes!
ascorbic 697e89c
Merge branch 'main' into live-loaders
ascorbic cce6e1f
fix: normalise path
ascorbic aeeb5e8
Add changeset
ascorbic c8594ab
skip csp test for now
ascorbic 13b63b3
undo
ascorbic e2f4e9e
Merge branch 'main' into live-loaders
ascorbic 60254cd
Lock
ascorbic b876f4b
Merge branch 'main' into live-loaders
ematipico 6bbca1e
Update .changeset/pretty-doodles-wash.md
ascorbic File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| --- | ||
| 'astro': minor | ||
| --- | ||
|
|
||
| Adds experimental support for live content collections | ||
|
|
||
| Live content collections are a new type of [content collection](https://docs.astro.build/en/guides/content-collections/) that fetch their data at runtime rather than build time. This allows you to access frequently-updated data from CMSs, APIs, databases, or other sources using a unified API, without needing to rebuild your site when the data changes. | ||
|
|
||
| In Astro 5.0, the content layer API added support for adding diverse content sources to content collections. You can create loaders that fetch data from any source at build time, and then access it inside a page via `getEntry` and `getCollection`. The data is cached between builds, giving fast access and updates. | ||
|
|
||
| However there is no method for updating the data store between builds, meaning any updates to the data need a full site deploy, even if the pages are rendered on-demand. This means that content collections are not suitable for pages that update frequently. Instead, today these pages tend to access the APIs directly in the frontmatter. This works, but leads to a lot of boilerplate, and means users don't benefit from the simple, unified API that content loaders offer. In most cases users tend to individually create loader libraries that they share between pages. | ||
|
|
||
| Live content collections solve this problem by allowing you to create loaders that fetch data at runtime, rather than build time. This means that the data is always up-to-date, without needing to rebuild the site. | ||
|
|
||
| ## How to use | ||
|
|
||
| To enable live collections add the `experimental.liveContentCollections` flag to your `astro.config.mjs` file: | ||
|
|
||
| ```js title="astro.config.mjs" | ||
| { | ||
| experimental: { | ||
| liveContentCollections: true, | ||
| }, | ||
| } | ||
| ``` | ||
|
|
||
| Then create a new `src/live.config.ts` file (alongside your `src/content.config.ts` if you have one) to define your live collections with a [live loader](https://docs.astro.build/en/reference/experimental-flags/live-content-collections/#creating-a-live-loader) and optionally a [schema](https://docs.astro.build/en/reference/experimental-flags/live-content-collections/#using-zod-schemas) using the new `defineLiveCollection()` function from the `astro:content` module. | ||
|
|
||
| ```ts title="src/live.config.ts" | ||
| import { defineLiveCollection } from 'astro:content'; | ||
| import { storeLoader } from '@mystore/astro-loader'; | ||
|
|
||
| const products = defineLiveCollection({ | ||
| type: 'live', | ||
| loader: storeLoader({ | ||
| apiKey: process.env.STORE_API_KEY, | ||
| endpoint: 'https://api.mystore.com/v1', | ||
| }), | ||
| }); | ||
|
|
||
| export const collections = { products }; | ||
| ``` | ||
|
|
||
| You can then use the dedicated `getLiveCollection()` and `getLiveEntry()` functions to access your live data: | ||
|
|
||
| ```astro | ||
| --- | ||
| import { getLiveCollection, getLiveEntry, render } from 'astro:content'; | ||
|
|
||
| // Get all products | ||
| const { entries: allProducts, error } = await getLiveCollection('products'); | ||
| if (error) { | ||
| // Handle error appropriately | ||
| console.error(error.message); | ||
| } | ||
|
|
||
| // Get products with a filter (if supported by your loader) | ||
| const { entries: electronics } = await getLiveCollection('products', { category: 'electronics' }); | ||
|
|
||
| // Get a single product by ID (string syntax) | ||
| const { entry: product, error: productError } = await getLiveEntry('products', Astro.params.id); | ||
| if (productError) { | ||
| return Astro.redirect('/404'); | ||
| } | ||
|
|
||
| // Get a single product with a custom query (if supported by your loader) using a filter object | ||
| const { entry: productBySlug } = await getLiveEntry('products', { slug: Astro.params.slug }); | ||
|
|
||
| const { Content } = await render(product); | ||
|
|
||
| --- | ||
|
|
||
| <h1>{product.title}</h1> | ||
| <Content /> | ||
|
|
||
| ``` | ||
|
|
||
| See [the docs for the experimental live content collections feature](https://docs.astro.build/en/reference/experimental-flags/live-content-collections/) for more details on how to use this feature, including how to create a live loader. Please give feedback on [the RFC PR](https://github.com/withastro/roadmap/pull/1164) if you have any suggestions or issues. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| import { defineCollection } from 'astro:content'; | ||
|
|
||
| export const collections = { | ||
| docs: defineCollection({}) | ||
| docs: defineCollection({}), | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,178 @@ | ||
| import type { ZodLiteral, ZodNumber, ZodObject, ZodString, ZodType, ZodUnion } from 'zod'; | ||
| import { CONTENT_LAYER_TYPE, LIVE_CONTENT_TYPE } from './consts.js'; | ||
| import type { LiveLoader, Loader } from './loaders/types.js'; | ||
| import { AstroError, AstroErrorData, AstroUserError } from '../core/errors/index.js'; | ||
|
|
||
| function getImporterFilename() { | ||
| // Find the first line in the stack trace that doesn't include 'defineCollection' or 'getImporterFilename' | ||
| const stackLine = new Error().stack | ||
| ?.split('\n') | ||
| .find( | ||
| (line) => | ||
| !line.includes('defineCollection') && | ||
| !line.includes('defineLiveCollection') && | ||
| !line.includes('getImporterFilename') && | ||
| line !== 'Error', | ||
| ); | ||
| if (!stackLine) { | ||
| return undefined; | ||
| } | ||
| // Extract the relative path from the stack line | ||
| const match = /\/((?:src|chunks)\/.*?):\d+:\d+/.exec(stackLine); | ||
|
|
||
| return match?.[1] ?? undefined; | ||
| } | ||
|
|
||
| // This needs to be in sync with ImageMetadata | ||
| export type ImageFunction = () => ZodObject<{ | ||
| src: ZodString; | ||
| width: ZodNumber; | ||
| height: ZodNumber; | ||
| format: ZodUnion< | ||
| [ | ||
| ZodLiteral<'png'>, | ||
| ZodLiteral<'jpg'>, | ||
| ZodLiteral<'jpeg'>, | ||
| ZodLiteral<'tiff'>, | ||
| ZodLiteral<'webp'>, | ||
| ZodLiteral<'gif'>, | ||
| ZodLiteral<'svg'>, | ||
| ZodLiteral<'avif'>, | ||
| ] | ||
| >; | ||
| }>; | ||
|
|
||
| export interface DataEntry { | ||
| id: string; | ||
| data: Record<string, unknown>; | ||
| filePath?: string; | ||
| body?: string; | ||
| } | ||
|
|
||
| export interface DataStore { | ||
| get: (key: string) => DataEntry; | ||
| entries: () => Array<[id: string, DataEntry]>; | ||
| set: (key: string, data: Record<string, unknown>, body?: string, filePath?: string) => void; | ||
| values: () => Array<DataEntry>; | ||
| keys: () => Array<string>; | ||
| 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; | ||
| } | ||
|
|
||
| export type BaseSchema = ZodType; | ||
|
|
||
| export type SchemaContext = { image: ImageFunction }; | ||
|
|
||
| type ContentLayerConfig<S extends BaseSchema, TData extends { id: string } = { id: string }> = { | ||
| type?: 'content_layer'; | ||
| schema?: S | ((context: SchemaContext) => S); | ||
| loader: | ||
| | Loader | ||
| | (() => | ||
| | Array<TData> | ||
| | Promise<Array<TData>> | ||
| | Record<string, Omit<TData, 'id'> & { id?: string }> | ||
| | Promise<Record<string, Omit<TData, 'id'> & { id?: string }>>); | ||
| }; | ||
|
|
||
| type DataCollectionConfig<S extends BaseSchema> = { | ||
| type: 'data'; | ||
| schema?: S | ((context: SchemaContext) => S); | ||
| }; | ||
|
|
||
| type ContentCollectionConfig<S extends BaseSchema> = { | ||
| type?: 'content'; | ||
| schema?: S | ((context: SchemaContext) => S); | ||
| loader?: never; | ||
| }; | ||
|
|
||
| export type LiveCollectionConfig<L extends LiveLoader, S extends BaseSchema | undefined = undefined> = { | ||
| type: 'live'; | ||
| schema?: S; | ||
| loader: L; | ||
| }; | ||
|
|
||
| export type CollectionConfig<S extends BaseSchema> = | ||
| | ContentCollectionConfig<S> | ||
| | DataCollectionConfig<S> | ||
| | ContentLayerConfig<S>; | ||
|
|
||
| export function defineLiveCollection< | ||
| L extends LiveLoader, | ||
| S extends BaseSchema | undefined = undefined, | ||
| >(config: LiveCollectionConfig<L, S>): LiveCollectionConfig<L, S> { | ||
| const importerFilename = getImporterFilename(); | ||
| if (!importerFilename?.includes('live.config')) { | ||
| throw new AstroError({ | ||
| ...AstroErrorData.LiveContentConfigError, | ||
| message: AstroErrorData.LiveContentConfigError.message( | ||
| 'Live collections must be defined in a `src/live.config.ts` file.', | ||
| importerFilename ?? 'your content config file', | ||
| ), | ||
| }); | ||
| } | ||
| if (config.type !== LIVE_CONTENT_TYPE) { | ||
| throw new AstroError({ | ||
| ...AstroErrorData.LiveContentConfigError, | ||
| message: AstroErrorData.LiveContentConfigError.message( | ||
| 'Collections in a live config file must have a type of `live`.', | ||
| importerFilename, | ||
| ), | ||
| }); | ||
| } | ||
|
|
||
| if (!config.loader) { | ||
| throw new AstroError({ | ||
| ...AstroErrorData.LiveContentConfigError, | ||
| message: AstroErrorData.LiveContentConfigError.message( | ||
| 'Live collections must have a `loader` defined.', | ||
| importerFilename, | ||
| ), | ||
| }); | ||
| } | ||
| if (typeof config.schema === 'function') { | ||
| throw new AstroError({ | ||
| ...AstroErrorData.LiveContentConfigError, | ||
| message: AstroErrorData.LiveContentConfigError.message( | ||
| 'The schema cannot be a function for live collections. Please use a schema object instead.', | ||
| importerFilename, | ||
| ), | ||
| }); | ||
| } | ||
| return config; | ||
| } | ||
|
|
||
| export function defineCollection<S extends BaseSchema>( | ||
| config: CollectionConfig<S>, | ||
| ): CollectionConfig<S> { | ||
| const importerFilename = getImporterFilename(); | ||
|
|
||
| if (importerFilename?.includes('live.config')) { | ||
| throw new AstroError({ | ||
| ...AstroErrorData.LiveContentConfigError, | ||
| message: AstroErrorData.LiveContentConfigError.message( | ||
| 'Collections in a live config file must use `defineLiveCollection`.', | ||
| importerFilename, | ||
| ), | ||
| }); | ||
| } | ||
|
|
||
| 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 ${importerFilename ?? 'your content config file'}.`, | ||
| ); | ||
| } | ||
| config.type = CONTENT_LAYER_TYPE; | ||
| } | ||
| if (!config.type) config.type = 'content'; | ||
| return config; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import type { ZodError } from "zod"; | ||
|
|
||
| export class LiveCollectionError extends Error { | ||
| constructor( | ||
| public readonly collection: string, | ||
| public readonly message: string, | ||
| public readonly cause?: Error, | ||
| ) { | ||
| super(message); | ||
| this.name = 'LiveCollectionError'; | ||
| } | ||
| static is(error: unknown): error is LiveCollectionError { | ||
| return error instanceof LiveCollectionError; | ||
| } | ||
| } | ||
|
|
||
| export class LiveEntryNotFoundError extends LiveCollectionError { | ||
| constructor(collection: string, entryFilter: string | Record<string, unknown>) { | ||
| super( | ||
| collection, | ||
| `Entry ${collection} → ${typeof entryFilter === 'string' ? entryFilter : JSON.stringify(entryFilter)} was not found.`, | ||
| ); | ||
| this.name = 'LiveEntryNotFoundError'; | ||
| } | ||
| static is(error: unknown): error is LiveEntryNotFoundError { | ||
| return (error as any)?.name === 'LiveEntryNotFoundError'; | ||
| } | ||
| } | ||
|
|
||
| export class LiveCollectionValidationError extends LiveCollectionError { | ||
| constructor(collection: string, entryId: string, error: ZodError) { | ||
| super( | ||
| collection, | ||
| [ | ||
| `**${collection} → ${entryId}** data does not match the collection schema.\n`, | ||
| ...error.errors.map((zodError) => ` **${zodError.path.join('.')}**: ${zodError.message}`), | ||
| '', | ||
| ].join('\n'), | ||
| ); | ||
| this.name = 'LiveCollectionValidationError'; | ||
| } | ||
| static is(error: unknown): error is LiveCollectionValidationError { | ||
| return (error as any)?.name === 'LiveCollectionValidationError'; | ||
| } | ||
| } | ||
|
|
||
| export class LiveCollectionCacheHintError extends LiveCollectionError { | ||
| constructor(collection: string, entryId: string | undefined, error: ZodError) { | ||
| super( | ||
| collection, | ||
| [ | ||
| `**${String(collection)}${entryId ? ` → ${String(entryId)}` : ''}** returned an invalid cache hint.\n`, | ||
| ...error.errors.map((zodError) => ` **${zodError.path.join('.')}**: ${zodError.message}`), | ||
| '', | ||
| ].join('\n'), | ||
| ); | ||
| this.name = 'LiveCollectionCacheHintError'; | ||
| } | ||
| static is(error: unknown): error is LiveCollectionCacheHintError { | ||
| return (error as any)?.name === 'LiveCollectionCacheHintError'; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.