diff --git a/packages/astro/content-reference/content.d.ts b/packages/astro/content-reference/content.d.ts index ef4734d5d..a43e69eaf 100644 --- a/packages/astro/content-reference/content.d.ts +++ b/packages/astro/content-reference/content.d.ts @@ -1,13 +1,24 @@ import { z } from 'zod'; declare const defaultSchemaFileResolved: { - schema: { - parse: (mod) => any; - }; + schema: { + parse: (mod) => any; + }; }; export declare const contentMap: { - // GENERATED_CONTENT_MAP_ENTRIES + // GENERATED_CONTENT_MAP_ENTRIES }; export declare const schemaMap: { - // GENERATED_SCHEMA_MAP_ENTRIES + // GENERATED_SCHEMA_MAP_ENTRIES }; +export declare function fetchContentByEntry< + C extends keyof typeof contentMap, + E extends keyof typeof contentMap[C] +>(collection: C, entryKey: E): Promise; +export declare function fetchContent< + C extends keyof typeof contentMap, + E extends keyof typeof contentMap[C] +>( + collection: C, + filter?: (data: typeof contentMap[C][E]['data']) => boolean +): Promise; diff --git a/packages/astro/content-reference/content.mjs b/packages/astro/content-reference/content.mjs index 4598a5681..8d912f559 100644 --- a/packages/astro/content-reference/content.mjs +++ b/packages/astro/content-reference/content.mjs @@ -1,18 +1,89 @@ +import { z } from 'zod'; +import { getFrontmatterErrorLine, errorMap } from 'astro/content-internals'; + const NO_SCHEMA_MSG = (/** @type {string} */ collection) => - `${collection} does not have a ~schema file. We suggest adding one for type safety!`; + `${collection} does not have a ~schema file. We suggest adding one for type safety!`; const defaultSchemaFileResolved = { schema: { parse: (mod) => mod } }; /** Used to stub out `schemaMap` entries that don't have a `~schema.ts` file */ const defaultSchemaFile = (/** @type {string} */ collection) => - new Promise((/** @type {(value: typeof defaultSchemaFileResolved) => void} */ resolve) => { - console.warn(NO_SCHEMA_MSG(collection)); - resolve(defaultSchemaFileResolved); - }); + new Promise((/** @type {(value: typeof defaultSchemaFileResolved) => void} */ resolve) => { + console.warn(NO_SCHEMA_MSG(collection)); + resolve(defaultSchemaFileResolved); + }); + +const getSchemaError = (collection) => + new Error(`${collection}/~schema needs a named \`schema\` export.`); + +async function parseEntryData( + /** @type {string} */ collection, + /** @type {string} */ entryKey, + /** @type {{ data: any; rawData: string; }} */ unparsedEntry, + /** @type {{ schemaMap: any }} */ { schemaMap } +) { + const defineSchemaResult = await schemaMap[collection]; + if (!defineSchemaResult) throw getSchemaError(collection); + const { schema } = defineSchemaResult; + + try { + return schema.parse(unparsedEntry.data, { errorMap }); + } catch (e) { + if (e instanceof z.ZodError) { + const formattedError = new Error( + [ + `Could not parse frontmatter in ${String(collection)} → ${String(entryKey)}`, + ...e.errors.map((e) => e.message), + ].join('\n') + ); + formattedError.loc = { + file: 'TODO', + line: getFrontmatterErrorLine(unparsedEntry.rawData, String(e.errors[0].path[0])), + column: 1, + }; + throw formattedError; + } + } +} export const contentMap = { - // GENERATED_CONTENT_MAP_ENTRIES + // GENERATED_CONTENT_MAP_ENTRIES }; export const schemaMap = { - // GENERATED_SCHEMA_MAP_ENTRIES + // GENERATED_SCHEMA_MAP_ENTRIES }; + +export async function fetchContentByEntry( + /** @type {string} */ collection, + /** @type {string} */ entryKey +) { + const entry = contentMap[collection][entryKey]; + + return { + id: entry.id, + slug: entry.slug, + body: entry.body, + data: await parseEntryData(collection, entryKey, entry, { schemaMap }), + }; +} + +export async function fetchContent( + /** @type {string} */ collection, + /** @type {undefined | (() => boolean)} */ filter +) { + const entries = Promise.all( + Object.entries(contentMap[collection]).map(async ([key, entry]) => { + return { + id: entry.id, + slug: entry.slug, + body: entry.body, + data: await parseEntryData(collection, key, entry, { schemaMap }), + }; + }) + ); + if (typeof filter === 'function') { + return (await entries).filter(filter); + } else { + return entries; + } +} diff --git a/packages/astro/package.json b/packages/astro/package.json index e42d13db4..2b7f9b1cb 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -42,6 +42,7 @@ "types": "./config.d.ts", "default": "./config.mjs" }, + "./content-internals": "./dist/content-internals/index.js", "./app": "./dist/core/app/index.js", "./app/node": "./dist/core/app/node.js", "./client/*": "./dist/runtime/client/*", diff --git a/packages/astro/src/content-internals/index.ts b/packages/astro/src/content-internals/index.ts new file mode 100644 index 000000000..2c4a2b726 --- /dev/null +++ b/packages/astro/src/content-internals/index.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +const flattenPath = (path: (string | number)[]) => path.join('.'); + +export const errorMap: z.ZodErrorMap = (error, ctx) => { + if (error.code === 'invalid_type') { + const badKeyPath = JSON.stringify(flattenPath(error.path)); + if (error.received === 'undefined') { + return { message: `${badKeyPath} is required.` }; + } else { + return { message: `${badKeyPath} should be ${error.expected}, not ${error.received}.` }; + } + } + return { message: ctx.defaultError }; +}; + +// WARNING: MAXIMUM JANK AHEAD +export function getFrontmatterErrorLine(rawFrontmatter: string, frontmatterKey: string) { + console.log({ rawFrontmatter, frontmatterKey }); + const indexOfFrontmatterKey = rawFrontmatter.indexOf(`\n${frontmatterKey}`); + if (indexOfFrontmatterKey === -1) return 0; + + const frontmatterBeforeKey = rawFrontmatter.substring(0, indexOfFrontmatterKey + 1); + const numNewlinesBeforeKey = frontmatterBeforeKey.split('\n').length; + return numNewlinesBeforeKey; +} diff --git a/packages/astro/src/vite-plugin-content/index.ts b/packages/astro/src/vite-plugin-content/index.ts index fc86f83c6..8475cb04f 100644 --- a/packages/astro/src/vite-plugin-content/index.ts +++ b/packages/astro/src/vite-plugin-content/index.ts @@ -190,8 +190,8 @@ async function getEntriesByCollection( const id = path.relative(contentDir.pathname, filePath); const slug = entryKey.replace(/\.mdx?$/, ''); const body = await fs.readFile(filePath, 'utf-8'); - const { data, matter: rawData } = parseFrontmatter(body, filePath); - return [entryKey, { id, slug, body, data, rawData }]; + const { data, matter } = parseFrontmatter(body, filePath); + return [entryKey, { id, slug, body, data, rawData: matter ?? '' }]; }) ); }