Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .changeset/dark-bees-stand.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
'astro': minor
---

Provides a markdown renderer to content loaders

When creating a content loader, you will now have access to a `renderMarkdown` function that allows you to render markdown content directly within your loaders. It uses the same settings and plugins as renderer used for markdown files in Astro, and follows any markdown settings you have configured in your Astro project.

This allows you to render markdown content from various sources, such as CMS or other data sources, directly in your loaders without needing to preprocess the markdown content separately.

```ts
import type { Loader } from 'astro/loaders';
import { loadFromCMS } from './cms';

export function myLoader(settings): Loader {
return {
name: 'my-loader',
async load({ renderMarkdown, store }) {
const entries = await loadFromCMS();

store.clear();

for (const entry of entries) {
// Assume each entry has a 'content' field with markdown content
store.set(entry.id, {
id: entry.id,
data: entry,
rendered: await renderMarkdown(entry.content),
});
}
},
};
}
```

The return value of `renderMarkdown` is an object with two properties: `html` and `metadata`. These match the `rendered` property of content entries in content collections, so you can use them to render the content in your components or pages.

```astro
---
import { getEntry, render } from 'astro:content';
const entry = await getEntry('my-collection', Astro.params.id);
const { Content } = await render(entry);
---
<Content />
```
14 changes: 13 additions & 1 deletion packages/astro/src/content/content-layer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { promises as fs, existsSync } from 'node:fs';
import { type MarkdownProcessor, createMarkdownProcessor } from '@astrojs/markdown-remark';
import PQueue from 'p-queue';
import type { FSWatcher } from 'vite';
import xxhash from 'xxhash-wasm';
Expand All @@ -14,6 +15,7 @@ import {
DATA_STORE_FILE,
MODULES_IMPORTS_FILE,
} from './consts.js';
import type { RenderedContent } from './data-store.js';
import type { LoaderContext } from './loaders/types.js';
import type { MutableDataStore } from './mutable-data-store.js';
import {
Expand Down Expand Up @@ -46,7 +48,7 @@ class ContentLayer {
#watcher?: WrappedWatcher;
#lastConfigDigest?: string;
#unsubscribe?: () => void;

#markdownProcessor?: MarkdownProcessor;
#generateDigest?: (data: Record<string, unknown> | string) => string;

#queue: PQueue;
Expand Down Expand Up @@ -127,6 +129,7 @@ class ContentLayer {
logger: this.#logger.forkIntegrationLogger(loaderName),
config: this.#settings.config,
parseData,
renderMarkdown: this.#processMarkdown.bind(this),
generateDigest: await this.#getGenerateDigest(),
watcher: this.#watcher,
refreshContextData,
Expand All @@ -137,6 +140,15 @@ class ContentLayer {
};
}

async #processMarkdown(content: string): Promise<RenderedContent> {
this.#markdownProcessor ??= await createMarkdownProcessor(this.#settings.config.markdown);
const { code, metadata } = await this.#markdownProcessor.render(content);
return {
html: code,
metadata,
};
}

/**
* Enqueues a sync job that runs 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
Expand Down
4 changes: 4 additions & 0 deletions packages/astro/src/content/loaders/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ZodSchema } from 'zod';
import type { AstroIntegrationLogger } from '../../core/logger/core.js';
import type { AstroConfig } from '../../types/public/config.js';
import type { ContentEntryType } from '../../types/public/content.js';
import type { RenderedContent } from '../data-store.js';
import type { DataStore, MetaStore } from '../mutable-data-store.js';

export type { DataStore, MetaStore };
Expand All @@ -29,6 +30,9 @@ export interface LoaderContext {
/** Validates and parses the data according to the collection schema */
parseData<TData extends Record<string, unknown>>(props: ParseDataOptions<TData>): Promise<TData>;

/** Renders markdown content to HTML and metadata */
renderMarkdown(content: string): Promise<RenderedContent>;

/** Generates a non-cryptographic content digest. This can be used to check if the data has changed */
generateDigest(data: Record<string, unknown> | string): string;

Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/content/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ const collectionConfigParser = z.union([
config: z.any(),
entryTypes: z.any(),
parseData: z.any(),
renderMarkdown: z.any(),
generateDigest: z.function(z.tuple([z.any()], z.string())),
watcher: z.any().optional(),
refreshContextData: z.record(z.unknown()).optional(),
Expand Down
5 changes: 5 additions & 0 deletions packages/astro/test/content-layer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ describe('Content Layer', () => {
]);
});

it('can render markdown in loaders', async () => {
const html = await fixture.readFile('/index.html');
assert.ok(cheerio.load(html)('section h1').text().includes('heading 1'));
});

it('handles negative matches in glob() loader', async () => {
assert.ok(json.hasOwnProperty('probes'));
assert.ok(Array.isArray(json.probes));
Expand Down
11 changes: 10 additions & 1 deletion packages/astro/test/fixtures/content-layer/src/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,10 +173,18 @@ const images = defineCollection({
}),
});

const markdownContent = `
# heading 1
hello
## heading 2
![image](./image.png)
![image 2](https://example.com/image.png)
`

const increment = defineCollection({
loader: {
name: 'increment-loader',
load: async ({ store, refreshContextData, parseData }) => {
load: async ({ store, refreshContextData, parseData, renderMarkdown }) => {
const entry = store.get<{ lastValue: number }>('value');
const lastValue = entry?.data.lastValue ?? 0;
const raw = {
Expand All @@ -192,6 +200,7 @@ const increment = defineCollection({
store.set({
id: raw.id,
data: parsed,
rendered: await renderMarkdown(markdownContent)
});
},
// Example of a loader that returns an async schema function
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
---
import { getCollection, getEntry } from 'astro:content';
import { getCollection, getEntry, render } 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');
const { Content } = await render(increment);
---
<html>
<head>
Expand All @@ -19,6 +20,9 @@ const increment = await getEntry('increment', 'value');
<li><a href={`/dogs/${dog.id}`}>{ dog.data?.breed }</a></li>
))}
</ul>
<section>
<Content />
</section>
<h1>Blog Posts</h1>

<h2>{first.data.title}</h2>
Expand Down