Skip to content

Commit 4361f68

Browse files
committed
feat: custom slugs + better type checking
1 parent 3037931 commit 4361f68

File tree

6 files changed

+287
-167
lines changed

6 files changed

+287
-167
lines changed

packages/astro/src/content/internal.ts

Lines changed: 2 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
import { z } from 'zod';
21
import { prependForwardSlash } from '../core/path.js';
32

43
type GlobResult = Record<string, () => Promise<any>>;
54
type CollectionToEntryMap = Record<string, GlobResult>;
6-
type CollectionsConfig = Record<string, { schema: z.ZodRawShape }>;
75

86
export function createCollectionToGlobResultMap({
97
globResult,
@@ -25,90 +23,22 @@ export function createCollectionToGlobResultMap({
2523
return collectionToGlobResultMap;
2624
}
2725

28-
export async function parseEntryData(
29-
collection: string,
30-
entry: { id: string; data: any; _internal: { rawData: string; filePath: string } },
31-
collectionsConfig: CollectionsConfig
32-
) {
33-
if (!('schema' in (collectionsConfig[collection] ?? {}))) {
34-
throw new Error(getErrorMsg.schemaDefMissing(collection));
35-
}
36-
const { schema } = collectionsConfig[collection];
37-
// Use `safeParseAsync` to allow async transforms
38-
const parsed = await z.object(schema).safeParseAsync(entry.data, { errorMap });
39-
40-
if (parsed.success) {
41-
return parsed.data;
42-
} else {
43-
const formattedError = new Error(
44-
[
45-
`Could not parse frontmatter in ${String(collection)}${String(entry.id)}`,
46-
...parsed.error.errors.map((zodError) => zodError.message),
47-
].join('\n')
48-
);
49-
(formattedError as any).loc = {
50-
file: entry._internal.filePath,
51-
line: getFrontmatterErrorLine(
52-
entry._internal.rawData,
53-
String(parsed.error.errors[0].path[0])
54-
),
55-
column: 1,
56-
};
57-
throw formattedError;
58-
}
59-
}
60-
61-
const flattenPath = (path: (string | number)[]) => path.join('.');
62-
63-
const errorMap: z.ZodErrorMap = (error, ctx) => {
64-
if (error.code === 'invalid_type') {
65-
const badKeyPath = JSON.stringify(flattenPath(error.path));
66-
if (error.received === 'undefined') {
67-
return { message: `${badKeyPath} is required.` };
68-
} else {
69-
return { message: `${badKeyPath} should be ${error.expected}, not ${error.received}.` };
70-
}
71-
}
72-
return { message: ctx.defaultError };
73-
};
74-
75-
// WARNING: MAXIMUM JANK AHEAD
76-
function getFrontmatterErrorLine(rawFrontmatter: string, frontmatterKey: string) {
77-
const indexOfFrontmatterKey = rawFrontmatter.indexOf(`\n${frontmatterKey}`);
78-
if (indexOfFrontmatterKey === -1) return 0;
79-
80-
const frontmatterBeforeKey = rawFrontmatter.substring(0, indexOfFrontmatterKey + 1);
81-
const numNewlinesBeforeKey = frontmatterBeforeKey.split('\n').length;
82-
return numNewlinesBeforeKey;
83-
}
84-
85-
export const getErrorMsg = {
86-
schemaFileMissing: (collection: string) =>
87-
`${collection} does not have a config. We suggest adding one for type safety!`,
88-
schemaDefMissing: (collection: string) =>
89-
`${collection} needs a schema definition. Check your src/content/config!`,
90-
};
91-
9226
export function createGetCollection({
9327
collectionToEntryMap,
94-
getCollectionsConfig,
9528
}: {
9629
collectionToEntryMap: CollectionToEntryMap;
97-
getCollectionsConfig: () => Promise<CollectionsConfig>;
9830
}) {
9931
return async function getCollection(collection: string, filter?: () => boolean) {
10032
const lazyImports = Object.values(collectionToEntryMap[collection] ?? {});
101-
const collectionsConfig = await getCollectionsConfig();
10233
const entries = Promise.all(
10334
lazyImports.map(async (lazyImport) => {
10435
const entry = await lazyImport();
105-
const data = await parseEntryData(collection, entry, collectionsConfig);
10636
return {
10737
id: entry.id,
10838
slug: entry.slug,
10939
body: entry.body,
11040
collection: entry.collection,
111-
data,
41+
data: entry.data,
11242
};
11343
})
11444
);
@@ -122,24 +52,20 @@ export function createGetCollection({
12252

12353
export function createGetEntry({
12454
collectionToEntryMap,
125-
getCollectionsConfig,
12655
}: {
12756
collectionToEntryMap: CollectionToEntryMap;
128-
getCollectionsConfig: () => Promise<CollectionsConfig>;
12957
}) {
13058
return async function getEntry(collection: string, entryId: string) {
13159
const lazyImport = collectionToEntryMap[collection]?.[entryId];
132-
const collectionsConfig = await getCollectionsConfig();
13360
if (!lazyImport) throw new Error(`Ah! ${entryId}`);
13461

13562
const entry = await lazyImport();
136-
const data = await parseEntryData(collection, entry, collectionsConfig);
13763
return {
13864
id: entry.id,
13965
slug: entry.slug,
14066
body: entry.body,
14167
collection: entry.collection,
142-
data,
68+
data: entry.data,
14369
};
14470
};
14571
}

packages/astro/src/content/template/types.generated.d.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,19 @@ declare module 'astro:content' {
66
collection: C
77
): Promise<import('astro').GetStaticPathsResult>;
88

9-
type BaseCollectionConfig = { schema: import('astro/zod').ZodRawShape };
10-
export function defineCollection<C extends BaseCollectionConfig>(input: C): C;
9+
type BaseCollectionConfig<S extends import('astro/zod').ZodRawShape> = {
10+
schema?: S;
11+
slug?: (entry: {
12+
id: CollectionEntry<keyof typeof entryMap>['id'];
13+
defaultSlug: CollectionEntry<keyof typeof entryMap>['slug'];
14+
collection: string;
15+
body: string;
16+
data: import('astro/zod').infer<import('astro/zod').ZodObject<S>>;
17+
}) => string | Promise<string>;
18+
};
19+
export function defineCollection<S extends import('astro/zod').ZodRawShape>(
20+
input: BaseCollectionConfig<S>
21+
): BaseCollectionConfig<S>;
1122

1223
export function getEntry<C extends keyof typeof entryMap, E extends keyof typeof entryMap[C]>(
1324
collection: C,
@@ -33,12 +44,12 @@ declare module 'astro:content' {
3344
}>;
3445

3546
type InferEntrySchema<C extends keyof typeof entryMap> = import('astro/zod').infer<
36-
import('astro/zod').ZodObject<CollectionsConfig['collections'][C]['schema']>
47+
import('astro/zod').ZodObject<Required<ContentConfig['collections'][C]>['schema']>
3748
>;
3849

3950
const entryMap: {
4051
// @@ENTRY_MAP@@
4152
};
4253

43-
type CollectionsConfig = typeof import('@@COLLECTIONS_IMPORT_PATH@@');
54+
type ContentConfig = '@@CONTENT_CONFIG_TYPE@@';
4455
}

packages/astro/src/content/template/types.generated.mjs

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,6 @@ const collectionToEntryMap = createCollectionToGlobResultMap({
2222
contentDir,
2323
});
2424

25-
async function getCollectionsConfig() {
26-
const res = await import('@@COLLECTIONS_IMPORT_PATH@@');
27-
if ('collections' in res) {
28-
return res.collections;
29-
}
30-
return {};
31-
}
32-
3325
const renderEntryGlob = import.meta.glob('@@RENDER_ENTRY_GLOB_PATH@@', {
3426
query: { astroAssetSsr: true },
3527
});
@@ -40,13 +32,10 @@ const collectionToRenderEntryMap = createCollectionToGlobResultMap({
4032

4133
export const getCollection = createGetCollection({
4234
collectionToEntryMap,
43-
getCollectionsConfig,
4435
});
4536

4637
export const getEntry = createGetEntry({
4738
collectionToEntryMap,
48-
getCollectionsConfig,
49-
contentDir,
5039
});
5140

5241
export const renderEntry = createRenderEntry({ collectionToRenderEntryMap });

packages/astro/src/content/utils.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import matter from 'gray-matter';
2+
import { z } from 'zod';
3+
import { createServer, ErrorPayload as ViteErrorPayload, ViteDevServer } from 'vite';
4+
import { astroContentVirtualModPlugin, getPaths } from './vite-plugin-content.js';
5+
import { AstroSettings } from '../@types/astro.js';
6+
7+
export const collectionConfigParser = z.object({
8+
schema: z.any().optional(),
9+
slug: z
10+
.function()
11+
.args(
12+
z.object({
13+
id: z.string(),
14+
collection: z.string(),
15+
defaultSlug: z.string(),
16+
body: z.string(),
17+
data: z.record(z.any()),
18+
})
19+
)
20+
.returns(z.union([z.string(), z.promise(z.string())]))
21+
.optional(),
22+
});
23+
24+
export const contentConfigParser = z.object({
25+
collections: z.record(collectionConfigParser),
26+
});
27+
28+
export type CollectionConfig = z.infer<typeof collectionConfigParser>;
29+
export type ContentConfig = z.infer<typeof contentConfigParser>;
30+
31+
type Entry = {
32+
id: string;
33+
collection: string;
34+
slug: string;
35+
data: any;
36+
body: string;
37+
_internal: { rawData: string; filePath: string };
38+
};
39+
40+
export const msg = {
41+
collectionConfigMissing: (collection: string) =>
42+
`${collection} does not have a config. We suggest adding one for type safety!`,
43+
};
44+
45+
export async function getEntrySlug(entry: Entry, collectionConfig: CollectionConfig) {
46+
return (
47+
collectionConfig.slug?.({
48+
id: entry.id,
49+
data: entry.data,
50+
defaultSlug: entry.slug,
51+
collection: entry.collection,
52+
body: entry.body,
53+
}) ?? entry.slug
54+
);
55+
}
56+
57+
export async function getEntryData(entry: Entry, collectionConfig: CollectionConfig) {
58+
let data = entry.data;
59+
if (collectionConfig.schema) {
60+
// Use `safeParseAsync` to allow async transforms
61+
const parsed = await z.object(collectionConfig.schema).safeParseAsync(entry.data, { errorMap });
62+
if (parsed.success) {
63+
data = parsed.data;
64+
} else {
65+
const formattedError = new Error(
66+
[
67+
`Could not parse frontmatter in ${String(entry.collection)}${String(entry.id)}`,
68+
...parsed.error.errors.map((zodError) => zodError.message),
69+
].join('\n')
70+
);
71+
(formattedError as any).loc = {
72+
file: entry._internal.filePath,
73+
line: getFrontmatterErrorLine(
74+
entry._internal.rawData,
75+
String(parsed.error.errors[0].path[0])
76+
),
77+
column: 1,
78+
};
79+
throw formattedError;
80+
}
81+
}
82+
return data;
83+
}
84+
85+
const flattenPath = (path: (string | number)[]) => path.join('.');
86+
87+
const errorMap: z.ZodErrorMap = (error, ctx) => {
88+
if (error.code === 'invalid_type') {
89+
const badKeyPath = JSON.stringify(flattenPath(error.path));
90+
if (error.received === 'undefined') {
91+
return { message: `${badKeyPath} is required.` };
92+
} else {
93+
return { message: `${badKeyPath} should be ${error.expected}, not ${error.received}.` };
94+
}
95+
}
96+
return { message: ctx.defaultError };
97+
};
98+
99+
// WARNING: MAXIMUM JANK AHEAD
100+
function getFrontmatterErrorLine(rawFrontmatter: string, frontmatterKey: string) {
101+
const indexOfFrontmatterKey = rawFrontmatter.indexOf(`\n${frontmatterKey}`);
102+
if (indexOfFrontmatterKey === -1) return 0;
103+
104+
const frontmatterBeforeKey = rawFrontmatter.substring(0, indexOfFrontmatterKey + 1);
105+
const numNewlinesBeforeKey = frontmatterBeforeKey.split('\n').length;
106+
return numNewlinesBeforeKey;
107+
}
108+
109+
/**
110+
* Match YAML exception handling from Astro core errors
111+
* @see 'astro/src/core/errors.ts'
112+
*/
113+
export function parseFrontmatter(fileContents: string, filePath: string) {
114+
try {
115+
return matter(fileContents);
116+
} catch (e: any) {
117+
if (e.name === 'YAMLException') {
118+
const err: Error & ViteErrorPayload['err'] = e;
119+
err.id = filePath;
120+
err.loc = { file: e.id, line: e.mark.line + 1, column: e.mark.column };
121+
err.message = e.reason;
122+
throw err;
123+
} else {
124+
throw e;
125+
}
126+
}
127+
}
128+
129+
export async function loadContentConfig({
130+
settings,
131+
}: {
132+
settings: AstroSettings;
133+
}): Promise<ContentConfig | Error> {
134+
const paths = getPaths({ srcDir: settings.config.srcDir });
135+
const tempConfigServer: ViteDevServer = await createServer({
136+
root: settings.config.root.pathname,
137+
server: { middlewareMode: true, hmr: false },
138+
optimizeDeps: { entries: [] },
139+
clearScreen: false,
140+
appType: 'custom',
141+
logLevel: 'silent',
142+
plugins: [astroContentVirtualModPlugin({ settings })],
143+
});
144+
let unparsedConfig;
145+
try {
146+
unparsedConfig = await tempConfigServer.ssrLoadModule(paths.config.pathname);
147+
} catch {
148+
return new Error('Failed to resolve content config.');
149+
} finally {
150+
await tempConfigServer.close();
151+
}
152+
const config = contentConfigParser.safeParse(unparsedConfig);
153+
if (config.success) {
154+
return config.data;
155+
} else {
156+
return new TypeError('Content config file is invalid.');
157+
}
158+
}

0 commit comments

Comments
 (0)