Skip to content

Commit

Permalink
feat: custom slugs + better type checking
Browse files Browse the repository at this point in the history
  • Loading branch information
bholmesdev committed Dec 5, 2022
1 parent 3037931 commit 4361f68
Show file tree
Hide file tree
Showing 6 changed files with 287 additions and 167 deletions.
78 changes: 2 additions & 76 deletions packages/astro/src/content/internal.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { z } from 'zod';
import { prependForwardSlash } from '../core/path.js';

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

export function createCollectionToGlobResultMap({
globResult,
Expand All @@ -25,90 +23,22 @@ export function createCollectionToGlobResultMap({
return collectionToGlobResultMap;
}

export async function parseEntryData(
collection: string,
entry: { id: string; data: any; _internal: { rawData: string; filePath: string } },
collectionsConfig: CollectionsConfig
) {
if (!('schema' in (collectionsConfig[collection] ?? {}))) {
throw new Error(getErrorMsg.schemaDefMissing(collection));
}
const { schema } = collectionsConfig[collection];
// Use `safeParseAsync` to allow async transforms
const parsed = await z.object(schema).safeParseAsync(entry.data, { errorMap });

if (parsed.success) {
return parsed.data;
} else {
const formattedError = new Error(
[
`Could not parse frontmatter in ${String(collection)}${String(entry.id)}`,
...parsed.error.errors.map((zodError) => zodError.message),
].join('\n')
);
(formattedError as any).loc = {
file: entry._internal.filePath,
line: getFrontmatterErrorLine(
entry._internal.rawData,
String(parsed.error.errors[0].path[0])
),
column: 1,
};
throw formattedError;
}
}

const flattenPath = (path: (string | number)[]) => path.join('.');

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
function getFrontmatterErrorLine(rawFrontmatter: string, frontmatterKey: string) {
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;
}

export const getErrorMsg = {
schemaFileMissing: (collection: string) =>
`${collection} does not have a config. We suggest adding one for type safety!`,
schemaDefMissing: (collection: string) =>
`${collection} needs a schema definition. Check your src/content/config!`,
};

export function createGetCollection({
collectionToEntryMap,
getCollectionsConfig,
}: {
collectionToEntryMap: CollectionToEntryMap;
getCollectionsConfig: () => Promise<CollectionsConfig>;
}) {
return async function getCollection(collection: string, filter?: () => boolean) {
const lazyImports = Object.values(collectionToEntryMap[collection] ?? {});
const collectionsConfig = await getCollectionsConfig();
const entries = Promise.all(
lazyImports.map(async (lazyImport) => {
const entry = await lazyImport();
const data = await parseEntryData(collection, entry, collectionsConfig);
return {
id: entry.id,
slug: entry.slug,
body: entry.body,
collection: entry.collection,
data,
data: entry.data,
};
})
);
Expand All @@ -122,24 +52,20 @@ export function createGetCollection({

export function createGetEntry({
collectionToEntryMap,
getCollectionsConfig,
}: {
collectionToEntryMap: CollectionToEntryMap;
getCollectionsConfig: () => Promise<CollectionsConfig>;
}) {
return async function getEntry(collection: string, entryId: string) {
const lazyImport = collectionToEntryMap[collection]?.[entryId];
const collectionsConfig = await getCollectionsConfig();
if (!lazyImport) throw new Error(`Ah! ${entryId}`);

const entry = await lazyImport();
const data = await parseEntryData(collection, entry, collectionsConfig);
return {
id: entry.id,
slug: entry.slug,
body: entry.body,
collection: entry.collection,
data,
data: entry.data,
};
};
}
Expand Down
19 changes: 15 additions & 4 deletions packages/astro/src/content/template/types.generated.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,19 @@ declare module 'astro:content' {
collection: C
): Promise<import('astro').GetStaticPathsResult>;

type BaseCollectionConfig = { schema: import('astro/zod').ZodRawShape };
export function defineCollection<C extends BaseCollectionConfig>(input: C): C;
type BaseCollectionConfig<S extends import('astro/zod').ZodRawShape> = {
schema?: S;
slug?: (entry: {
id: CollectionEntry<keyof typeof entryMap>['id'];
defaultSlug: CollectionEntry<keyof typeof entryMap>['slug'];
collection: string;
body: string;
data: import('astro/zod').infer<import('astro/zod').ZodObject<S>>;
}) => string | Promise<string>;
};
export function defineCollection<S extends import('astro/zod').ZodRawShape>(
input: BaseCollectionConfig<S>
): BaseCollectionConfig<S>;

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

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

const entryMap: {
// @@ENTRY_MAP@@
};

type CollectionsConfig = typeof import('@@COLLECTIONS_IMPORT_PATH@@');
type ContentConfig = '@@CONTENT_CONFIG_TYPE@@';
}
11 changes: 0 additions & 11 deletions packages/astro/src/content/template/types.generated.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,6 @@ const collectionToEntryMap = createCollectionToGlobResultMap({
contentDir,
});

async function getCollectionsConfig() {
const res = await import('@@COLLECTIONS_IMPORT_PATH@@');
if ('collections' in res) {
return res.collections;
}
return {};
}

const renderEntryGlob = import.meta.glob('@@RENDER_ENTRY_GLOB_PATH@@', {
query: { astroAssetSsr: true },
});
Expand All @@ -40,13 +32,10 @@ const collectionToRenderEntryMap = createCollectionToGlobResultMap({

export const getCollection = createGetCollection({
collectionToEntryMap,
getCollectionsConfig,
});

export const getEntry = createGetEntry({
collectionToEntryMap,
getCollectionsConfig,
contentDir,
});

export const renderEntry = createRenderEntry({ collectionToRenderEntryMap });
Expand Down
158 changes: 158 additions & 0 deletions packages/astro/src/content/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import matter from 'gray-matter';
import { z } from 'zod';
import { createServer, ErrorPayload as ViteErrorPayload, ViteDevServer } from 'vite';
import { astroContentVirtualModPlugin, getPaths } from './vite-plugin-content.js';
import { AstroSettings } from '../@types/astro.js';

export const collectionConfigParser = z.object({
schema: z.any().optional(),
slug: z
.function()
.args(
z.object({
id: z.string(),
collection: z.string(),
defaultSlug: z.string(),
body: z.string(),
data: z.record(z.any()),
})
)
.returns(z.union([z.string(), z.promise(z.string())]))
.optional(),
});

export const contentConfigParser = z.object({
collections: z.record(collectionConfigParser),
});

export type CollectionConfig = z.infer<typeof collectionConfigParser>;
export type ContentConfig = z.infer<typeof contentConfigParser>;

type Entry = {
id: string;
collection: string;
slug: string;
data: any;
body: string;
_internal: { rawData: string; filePath: string };
};

export const msg = {
collectionConfigMissing: (collection: string) =>
`${collection} does not have a config. We suggest adding one for type safety!`,
};

export async function getEntrySlug(entry: Entry, collectionConfig: CollectionConfig) {
return (
collectionConfig.slug?.({
id: entry.id,
data: entry.data,
defaultSlug: entry.slug,
collection: entry.collection,
body: entry.body,
}) ?? entry.slug
);
}

export async function getEntryData(entry: Entry, collectionConfig: CollectionConfig) {
let data = entry.data;
if (collectionConfig.schema) {
// Use `safeParseAsync` to allow async transforms
const parsed = await z.object(collectionConfig.schema).safeParseAsync(entry.data, { errorMap });
if (parsed.success) {
data = parsed.data;
} else {
const formattedError = new Error(
[
`Could not parse frontmatter in ${String(entry.collection)}${String(entry.id)}`,
...parsed.error.errors.map((zodError) => zodError.message),
].join('\n')
);
(formattedError as any).loc = {
file: entry._internal.filePath,
line: getFrontmatterErrorLine(
entry._internal.rawData,
String(parsed.error.errors[0].path[0])
),
column: 1,
};
throw formattedError;
}
}
return data;
}

const flattenPath = (path: (string | number)[]) => path.join('.');

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
function getFrontmatterErrorLine(rawFrontmatter: string, frontmatterKey: string) {
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;
}

/**
* Match YAML exception handling from Astro core errors
* @see 'astro/src/core/errors.ts'
*/
export function parseFrontmatter(fileContents: string, filePath: string) {
try {
return matter(fileContents);
} catch (e: any) {
if (e.name === 'YAMLException') {
const err: Error & ViteErrorPayload['err'] = e;
err.id = filePath;
err.loc = { file: e.id, line: e.mark.line + 1, column: e.mark.column };
err.message = e.reason;
throw err;
} else {
throw e;
}
}
}

export async function loadContentConfig({
settings,
}: {
settings: AstroSettings;
}): Promise<ContentConfig | Error> {
const paths = getPaths({ srcDir: settings.config.srcDir });
const tempConfigServer: ViteDevServer = await createServer({
root: settings.config.root.pathname,
server: { middlewareMode: true, hmr: false },
optimizeDeps: { entries: [] },
clearScreen: false,
appType: 'custom',
logLevel: 'silent',
plugins: [astroContentVirtualModPlugin({ settings })],
});
let unparsedConfig;
try {
unparsedConfig = await tempConfigServer.ssrLoadModule(paths.config.pathname);
} catch {
return new Error('Failed to resolve content config.');
} finally {
await tempConfigServer.close();
}
const config = contentConfigParser.safeParse(unparsedConfig);
if (config.success) {
return config.data;
} else {
return new TypeError('Content config file is invalid.');
}
}
Loading

0 comments on commit 4361f68

Please sign in to comment.