Skip to content
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

Add experimental content layer flag #11652

Merged
merged 13 commits into from
Aug 9, 2024
253 changes: 253 additions & 0 deletions packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,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 {}

Expand Down Expand Up @@ -2184,6 +2185,258 @@ 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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, to make things simpler, I have mocked up something in a branch of docs itself with an idea of what I'm thinking: https://content-layer-draft--astro-docs-2.netlify.app/en/reference/configuration-reference/#experimentalcontentlayer

I did take take a little from the RFC and mix it in here. I mostly tried to laser-focus things a bit because it's already quite long, and for experimental docs, we can be a bit more minimal and let the RFC do some of the work.

Putting on my "seeing this for the first time, want to play with it" hat, I feel like it's the loader stuff that people need to get is fundamentally different, and most important, in all this. So I spent a bit more time there, and didn't spend as much time on the querying and rendering (even combined them into one section!) because they work so much like content collections do already.

So see what you think about something that kind of takes this form! When we have a good shape, then we can squish it in here to this file!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's great. Is it on a branch somewhere? Also do you have any suggestions for a workflow for working on this? It's really hard editing inside the JSDoc!

* @name experimental.contentLayer
* @type {boolean}
* @default `false`
* @version 4.16.0
* @description
*
* The Content Layer API is a new way to handle content and data in Astro. It takes [content collections](https://docs.astro.build/en/guides/content-collections/) beyond local files in `src/content` and allowing you to fetch content from anywhere, including remote APIs, or files anywhere in your project. As well as being more powerful, the Content Layer API is designed to be more performant, helping sites scale to tens of thousands of pages. Data is cached between builds and updated incrementally. Markdown parsing is also 5-10x faster, with similar scale reductions in memory. While the feature is experimental and subject to breaking changes, we invite you to try it today and let us know how it works for you.
*
* #### Enabling the Content Layer API
*
* To enable, add the `contentLayer` flag to the `experimental` object in your Astro config:
*
* ```js
* {
* experimental: {
* contentLayer: true,
* }
* }
* ```
*
* #### Using the Content Layer API
*
* :::tip
* The Content Layer API is a new way to define content collections, but many of the APIs are the same. It will be helpful to refer to the current [content collection docs](https://docs.astro.build/en/guides/content-collections/) for more information. Any differences in the API usage are highlighted below.
* :::
*
* To use the Content Layer API, create a collection in `src/content/config.ts` with a `loader` property. For local files where there is one entry per file, use the `glob()` loader. You can put your content files anywhere, but *not* in `src/content` because these would be handled by the current content collections APIs instead. In this example the files are in `src/data`.
*
* ```ts
* import { defineCollection, z } from 'astro:content';
* import { glob } 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(),
* }),
* });
*
* export const collections = { blog };
* ```
*
* You can load multiple entries from a single JSON file using the `file()` loader. In this case the data must either be an array of objects, which each contain an `id` property, or an object where each key is the ID.
*
* **Array syntax:**
*
* ```json
* [
* {
* "id": "labrador-retriever",
* "breed": "Labrador Retriever",
* "size": "Large",
* "origin": "Canada",
* "lifespan": "10-12 years",
* "temperament": [
* "Friendly",
* "Active",
* "Outgoing"
* ]
* },
* {
* "id": "german-shepherd",
* "breed": "German Shepherd",
* "size": "Large",
* "origin": "Germany",
* "lifespan": "9-13 years",
* "temperament": [
* "Loyal",
* "Intelligent",
* "Confident"
* ]
* }
* ]
* ```
*
* **Object syntax:**
*
* ```json
* {
* "labrador-retriever": {
* "breed": "Labrador Retriever",
* "size": "Large",
* "origin": "Canada",
* "lifespan": "10-12 years",
* "temperament": [
* "Friendly",
* "Active",
* "Outgoing"
* ]
* },
* "german-shepherd": {
* "breed": "German Shepherd",
* "size": "Large",
* "origin": "Germany",
* "lifespan": "9-13 years",
* "temperament": [
* "Loyal",
* "Intelligent",
* "Confident"
* ]
* }
* }
* ```
*
* The collection is then defined using the `file()` loader:
*
* ```ts
* import { defineCollection, z } from 'astro:content';
* import { file } from 'astro/loaders';
*
* const dogs = defineCollection({
* loader: file('src/data/dogs.json'),
* schema: z.object({
* id: z.string(),
* breed: z.string(),
* size: z.string(),
* origin: z.string(),
* lifespan: z.string(),
* temperament: z.array(z.string()),
* }),
* });
*
* export const collections = { dogs };
* ```
*
* The collection can be queried in the same way as existing content collections:
*
* ```ts
* 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');
* ```
*
* #### Rendering content
*
* Entries generated from markdown or MDX can be rendered directly to a page using the `render()` function.
*
* :::caution
* The syntax for rendering entries from collections that use the Content Layer is different from current content collections syntax.
* :::
*
* ```astro
* ---
* import { getEntry, render } from 'astro:content';
*
* const post = await getEntry('blog', Astro.params.slug);
*
* const { Content, headings } = await render(entry);
* ---
*
* <Content />
* ```
*
* #### Creating a loader
*
* Content loaders aren't restricted to just loading local files. You can also use loaders to fetch or generate content from anywhere. The simplest type of loader is an async function that returns an array of objects, each of which has an `id`:
*
* ```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 RFC](https://github.com/withastro/roadmap/blob/content-layer/proposals/content-layer.md#loaders).
*
* ### Migrating an existing content collection to use the Content Layer API
*
* You can convert an existing content collection to use the Content Layer API if it uses markdown, MDX or JSON, with these steps:
*
* 1. **Move the collection folder out of `src/content`.** This is so it won't be handled using the existing content collection APIs. This example assumes the content has been moved to `src/data`. The `config.ts` file must remain in `src/content`.
* 2. **Edit the collection definition**. The collection should not have `type` set, and needs a `loader` defined.
*
* ```diff
* import { defineCollection, z } from 'astro:content';
* + import { glob } from 'astro/loaders';
*
* const blog = defineCollection({
* // For content layer you do not 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 collections created with the Content Layer API do not have a `slug` field. You should use `id` instead, which has the same syntax.
*
* ```diff
* ---
* 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
* ---
* - 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);
* ---
*
* <Content />
* ```
*
* The `getEntryBySlug` and `getDataEntryByID` functions are deprecated and cannot be used with collections that use the Content Layer API. Instead, use `getEntry`, which is a drop-in replacement for both.
*
* #### Learn more
*
* To see the full API look at [the RFC](https://github.com/withastro/roadmap/blob/content-layer/proposals/content-layer.md) and [share your feedback on the feature and API](https://github.com/withastro/roadmap/pull/982).
*/
contentLayer?: boolean;
};
}

Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/content/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,4 @@ 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 = 'experimental_content';
export const CONTENT_LAYER_TYPE = 'content_layer';
18 changes: 16 additions & 2 deletions packages/astro/src/content/content-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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,
Expand Down Expand Up @@ -125,13 +126,26 @@ export class ContentLayer {
}

async #doSync() {
const logger = this.#logger.forkIntegrationLogger('content');
logger.info('Syncing content');
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;

Expand Down
17 changes: 8 additions & 9 deletions packages/astro/src/content/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,14 @@ type CollectionToEntryMap = Record<string, GlobResult>;
type GetEntryImport = (collection: string, lookupId: string) => Promise<LazyImport>;

export function defineCollection(config: any) {
if (
('loader' in config && config.type !== CONTENT_LAYER_TYPE) ||
(config.type === CONTENT_LAYER_TYPE && !('loader' in config))
) {
// TODO: when this moves out of experimental, we will set the type automatically
throw new AstroUserError(
'Collections that use the content layer must have a `loader` defined and `type` set to `experimental_content`',
"Check your collection definitions in `src/content/config.*`.'",
);
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;
Expand Down
2 changes: 2 additions & 0 deletions packages/astro/src/core/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export const ASTRO_CONFIG_DEFAULTS = {
env: {
validateSecrets: false,
},
contentLayer: false,
},
} satisfies AstroUserConfig & { server: { open: boolean } };

Expand Down Expand Up @@ -538,6 +539,7 @@ export const AstroConfigSchema = z.object({
.boolean()
.optional()
.default(ASTRO_CONFIG_DEFAULTS.experimental.serverIslands),
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.`,
Expand Down
3 changes: 3 additions & 0 deletions packages/astro/src/core/dev/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ export default async function dev(inlineConfig: AstroInlineConfig): Promise<DevS
}

const config = globalContentConfigObserver.get();
if (config.status === 'error') {
logger.error('content', config.error.message);
}
if (config.status === 'loaded') {
const contentLayer = globalContentLayer.init({
settings: restart.container.settings,
Expand Down
Loading
Loading