Skip to content

Commit 4d590a2

Browse files
ascorbicsarah11918
andauthored
Implement legacy collections using glob (#11976)
* feat: support pattern arrays with glob * wip * feat: emulate legacy content collections * Fixes * Lint * Correctly handle legacy data * Fix tests * Switch flag handling * Fix warnings * Add layout warning * Update fixtures * More tests! * Handle empty md files * Lockfile * Dedupe name * Handle data ID unslug * Fix e2e * Clean build * Clean builds in tests * Test fixes * Fix test * Fix typegen * Fix tests * Fixture updates * Test updates * Update changeset * Test * Remove wait in test * Handle race condition * Lock * chore: changes from review * Handle folders without config * lint * Fix test * Update wording for auto-collections * Delete legacyId * Sort another fixture * Rename flag to `legacy.collections` * Apply suggestions from code review Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * Changes from review * Apply suggestions from code review Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * lockfile * lock --------- Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
1 parent 953e6e0 commit 4d590a2

117 files changed

Lines changed: 2167 additions & 506 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/quick-onions-leave.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
'astro': patch
3+
---
4+
5+
Refactors legacy `content` and `data` collections to use the Content Layer API `glob()` loader for better performance and to support backwards compatibility. Also introduces the `legacy.collections` flag for projects that are unable to update to the new behavior immediately.
6+
7+
:warning: **BREAKING CHANGE FOR LEGACY CONTENT COLLECTIONS** :warning:
8+
9+
By default, collections that use the old types (`content` or `data`) and do not define a `loader` are now implemented under the hood using the Content Layer API's built-in `glob()` loader, with extra backward-compatibility handling.
10+
11+
In order to achieve backwards compatibility with existing `content` collections, the following have been implemented:
12+
13+
- a `glob` loader collection is defined, with patterns that match the previous handling (matches `src/content/<collection name>/**/*.md` and other content extensions depending on installed integrations, with underscore-prefixed files and folders ignored)
14+
- When used in the runtime, the entries have an ID based on the filename in the same format as legacy collections
15+
- A `slug` field is added with the same format as before
16+
- A `render()` method is added to the entry, so they can be called using `entry.render()`
17+
- `getEntryBySlug` is supported
18+
19+
In order to achieve backwards compatibility with existing `data` collections, the following have been implemented:
20+
21+
- a `glob` loader collection is defined, with patterns that match the previous handling (matches `src/content/<collection name>/**/*{.json,.yaml}` and other data extensions, with underscore-prefixed files and folders ignored)
22+
- Entries have an ID that is not slugified
23+
- `getDataEntryById` is supported
24+
25+
While this backwards compatibility implementation is able to emulate most of the features of legacy collections, **there are some differences and limitations that may cause breaking changes to existing collections**:
26+
27+
- In previous versions of Astro, collections would be generated for all folders in `src/content/`, even if they were not defined in `src/content/config.ts`. This behavior is now deprecated, and collections should always be defined in `src/content/config.ts`. For existing collections, these can just be empty declarations (e.g. `const blog = defineCollection({})`) and Astro will implicitly define your legacy collection for you in a way that is compatible with the new loading behavior.
28+
- The special `layout` field is not supported in Markdown collection entries. This property is intended only for standalone page files located in `src/pages/` and not likely to be in your collection entries. However, if you were using this property, you must now create dynamic routes that include your page styling.
29+
- Sort order of generated collections is non-deterministic and platform-dependent. This means that if you are calling `getCollection()`, the order in which entries are returned may be different than before. If you need a specific order, you should sort the collection entries yourself.
30+
- `image().refine()` is not supported. If you need to validate the properties of an image you will need to do this at runtime in your page or component.
31+
- the `key` argument of `getEntry(collection, key)` is typed as `string`, rather than having types for every entry.
32+
33+
A new legacy configuration flag `legacy.collections` is added for users that want to keep their current legacy (content and data) collections behavior (available in Astro v2 - v4), or who are not yet ready to update their projects:
34+
35+
```js
36+
// astro.config.mjs
37+
import { defineConfig } from 'astro/config';
38+
39+
export default defineConfig({
40+
legacy: {
41+
collections: true
42+
}
43+
});
44+
```
45+
46+
When set, no changes to your existing collections are necessary, and the restrictions on storing both new and old collections continue to exist: legacy collections (only) must continue to remain in `src/content/`, while new collections using a loader from the Content Layer API are forbidden in that folder.
47+
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { defineCollection } from 'astro:content';
2+
3+
export const collections = {
4+
docs: defineCollection({})
5+
};
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { defineCollection } from "astro:content";
2+
3+
4+
const posts = defineCollection({});
5+
6+
export const collections = { posts };

packages/astro/src/content/data-store.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export interface DataEntry<TData extends Record<string, unknown> = Record<string
3434
*/
3535
deferredRender?: boolean;
3636
assetImports?: Array<string>;
37+
/** @deprecated */
38+
legacyId?: string;
3739
}
3840

3941
/**

packages/astro/src/content/loaders/glob.ts

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ function generateIdDefault({ entry, base, data }: GenerateIdOptions): string {
3535
if (data.slug) {
3636
return data.slug as string;
3737
}
38-
const entryURL = new URL(entry, base);
38+
const entryURL = new URL(encodeURI(entry), base);
3939
const { slug } = getContentEntryIdAndSlug({
4040
entry: entryURL,
4141
contentDir: base,
@@ -55,6 +55,15 @@ function checkPrefix(pattern: string | Array<string>, prefix: string) {
5555
* Loads multiple entries, using a glob pattern to match files.
5656
* @param pattern A glob pattern to match files, relative to the content directory.
5757
*/
58+
export function glob(globOptions: GlobOptions): Loader;
59+
/** @private */
60+
export function glob(
61+
globOptions: GlobOptions & {
62+
/** @deprecated */
63+
_legacy?: true;
64+
},
65+
): Loader;
66+
5867
export function glob(globOptions: GlobOptions): Loader {
5968
if (checkPrefix(globOptions.pattern, '../')) {
6069
throw new Error(
@@ -80,19 +89,21 @@ export function glob(globOptions: GlobOptions): Loader {
8089
>();
8190

8291
const untouchedEntries = new Set(store.keys());
83-
92+
const isLegacy = (globOptions as any)._legacy;
93+
// If global legacy collection handling flag is *not* enabled then this loader is used to emulate them instead
94+
const emulateLegacyCollections = !config.legacy.collections;
8495
async function syncData(entry: string, base: URL, entryType?: ContentEntryType) {
8596
if (!entryType) {
8697
logger.warn(`No entry type found for ${entry}`);
8798
return;
8899
}
89-
const fileUrl = new URL(entry, base);
100+
const fileUrl = new URL(encodeURI(entry), base);
90101
const contents = await fs.readFile(fileUrl, 'utf-8').catch((err) => {
91102
logger.error(`Error reading ${entry}: ${err.message}`);
92103
return;
93104
});
94105

95-
if (!contents) {
106+
if (!contents && contents !== '') {
96107
logger.warn(`No contents found for ${entry}`);
97108
return;
98109
}
@@ -103,6 +114,17 @@ export function glob(globOptions: GlobOptions): Loader {
103114
});
104115

105116
const id = generateId({ entry, base, data });
117+
let legacyId: string | undefined;
118+
119+
if (isLegacy) {
120+
const entryURL = new URL(encodeURI(entry), base);
121+
const legacyOptions = getContentEntryIdAndSlug({
122+
entry: entryURL,
123+
contentDir: base,
124+
collection: '',
125+
});
126+
legacyId = legacyOptions.id;
127+
}
106128
untouchedEntries.delete(id);
107129

108130
const existingEntry = store.get(id);
@@ -132,6 +154,12 @@ export function glob(globOptions: GlobOptions): Loader {
132154
filePath,
133155
});
134156
if (entryType.getRenderFunction) {
157+
if (isLegacy && data.layout) {
158+
logger.error(
159+
`The Markdown "layout" field is not supported in content collections in Astro 5. Ignoring layout for ${JSON.stringify(entry)}. Enable "legacy.collections" if you need to use the layout field.`,
160+
);
161+
}
162+
135163
let render = renderFunctionByContentType.get(entryType);
136164
if (!render) {
137165
render = await entryType.getRenderFunction(config);
@@ -160,6 +188,7 @@ export function glob(globOptions: GlobOptions): Loader {
160188
digest,
161189
rendered,
162190
assetImports: rendered?.metadata?.imagePaths,
191+
legacyId,
163192
});
164193

165194
// todo: add an explicit way to opt in to deferred rendering
@@ -171,9 +200,10 @@ export function glob(globOptions: GlobOptions): Loader {
171200
filePath: relativePath,
172201
digest,
173202
deferredRender: true,
203+
legacyId,
174204
});
175205
} else {
176-
store.set({ id, data: parsedData, body, filePath: relativePath, digest });
206+
store.set({ id, data: parsedData, body, filePath: relativePath, digest, legacyId });
177207
}
178208

179209
fileToIdMap.set(filePath, id);
@@ -222,7 +252,7 @@ export function glob(globOptions: GlobOptions): Loader {
222252
if (isConfigFile(entry)) {
223253
return;
224254
}
225-
if (isInContentDir(entry)) {
255+
if (!emulateLegacyCollections && isInContentDir(entry)) {
226256
skippedFiles.push(entry);
227257
return;
228258
}
@@ -240,7 +270,9 @@ export function glob(globOptions: GlobOptions): Loader {
240270
? globOptions.pattern.join(', ')
241271
: globOptions.pattern;
242272

243-
logger.warn(`The glob() loader cannot be used for files in ${bold('src/content')}.`);
273+
logger.warn(
274+
`The glob() loader cannot be used for files in ${bold('src/content')} when legacy mode is enabled.`,
275+
);
244276
if (skipCount > 10) {
245277
logger.warn(
246278
`Skipped ${green(skippedFiles.length)} files that matched ${green(patternList)}.`,

packages/astro/src/content/mutable-data-store.ts

Lines changed: 16 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Traverse } from 'neotraverse/modern';
44
import { imageSrcToImportId, importIdToSymbolName } from '../assets/utils/resolveImports.js';
55
import { AstroError, AstroErrorData } from '../core/errors/index.js';
66
import { IMAGE_IMPORT_PREFIX } from './consts.js';
7-
import { type DataEntry, ImmutableDataStore, type RenderedContent } from './data-store.js';
7+
import { type DataEntry, ImmutableDataStore } from './data-store.js';
88
import { contentModuleToId } from './utils.js';
99

1010
const SAVE_DEBOUNCE_MS = 500;
@@ -197,7 +197,17 @@ export default new Map([\n${lines.join(',\n')}]);
197197
entries: () => this.entries(collectionName),
198198
values: () => this.values(collectionName),
199199
keys: () => this.keys(collectionName),
200-
set: ({ id: key, data, body, filePath, deferredRender, digest, rendered, assetImports }) => {
200+
set: ({
201+
id: key,
202+
data,
203+
body,
204+
filePath,
205+
deferredRender,
206+
digest,
207+
rendered,
208+
assetImports,
209+
legacyId,
210+
}) => {
201211
if (!key) {
202212
throw new Error(`ID must be a non-empty string`);
203213
}
@@ -244,6 +254,9 @@ export default new Map([\n${lines.join(',\n')}]);
244254
if (rendered) {
245255
entry.rendered = rendered;
246256
}
257+
if (legacyId) {
258+
entry.legacyId = legacyId;
259+
}
247260
if (deferredRender) {
248261
entry.deferredRender = deferredRender;
249262
if (filePath) {
@@ -335,30 +348,7 @@ export interface DataStore {
335348
key: string,
336349
) => DataEntry<TData> | undefined;
337350
entries: () => Array<[id: string, DataEntry]>;
338-
set: <TData extends Record<string, unknown>>(opts: {
339-
/** The ID of the entry. Must be unique per collection. */
340-
id: string;
341-
/** The data to store. */
342-
data: TData;
343-
/** The raw body of the content, if applicable. */
344-
body?: string;
345-
/** The file path of the content, if applicable. Relative to the site root. */
346-
filePath?: string;
347-
/** A content digest, to check if the content has changed. */
348-
digest?: number | string;
349-
/** The rendered content, if applicable. */
350-
rendered?: RenderedContent;
351-
/**
352-
* If an entry is a deferred, its rendering phase is delegated to a virtual module during the runtime phase.
353-
*/
354-
deferredRender?: boolean;
355-
/**
356-
* Assets such as images to process during the build. These should be files on disk, with a path relative to filePath.
357-
* Any values that use image() in the schema will already be added automatically.
358-
* @internal
359-
*/
360-
assetImports?: Array<string>;
361-
}) => boolean;
351+
set: <TData extends Record<string, unknown>>(opts: DataEntry<TData>) => boolean;
362352
values: () => Array<DataEntry>;
363353
keys: () => Array<string>;
364354
delete: (key: string) => void;

0 commit comments

Comments
 (0)