[Content collections] Improve content config handling (#5824)

* fix: always generate types on init

* fix: skip type generation when no content dir found

* fix: avoid stripping `.ts` for existsSync check

* chore: changeset

* fix: run type gen when content/ dir added in dev
This commit is contained in:
Ben Holmes 2023-01-11 12:46:53 -05:00 committed by GitHub
parent 1f49cddf9e
commit 665a2c2225
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 78 additions and 58 deletions

View file

@ -0,0 +1,8 @@
---
'astro': patch
---
Better handle content type generation failures:
- Generate types when content directory is empty
- Log helpful error when running `astro sync` without a content directory
- Avoid swallowing `config.ts` syntax errors from Vite

View file

@ -21,7 +21,15 @@ export async function sync(
fs, fs,
settings, settings,
}); });
await contentTypesGenerator.init(); const typesResult = await contentTypesGenerator.init();
if (typesResult.typesGenerated === false) {
switch (typesResult.reason) {
case 'no-content-dir':
default:
info(logging, 'content', 'No content directory found. Skipping type generation.');
return 0;
}
}
} catch (e) { } catch (e) {
throw new AstroError(AstroErrorData.GenerateContentTypesError); throw new AstroError(AstroErrorData.GenerateContentTypesError);
} }

View file

@ -22,11 +22,6 @@ type ChokidarEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir';
type RawContentEvent = { name: ChokidarEvent; entry: string }; type RawContentEvent = { name: ChokidarEvent; entry: string };
type ContentEvent = { name: ChokidarEvent; entry: URL }; type ContentEvent = { name: ChokidarEvent; entry: URL };
export type GenerateContentTypes = {
init(): Promise<void>;
queueEvent(event: RawContentEvent): void;
};
type ContentTypesEntryMetadata = { slug: string }; type ContentTypesEntryMetadata = { slug: string };
type ContentTypes = Record<string, Record<string, ContentTypesEntryMetadata>>; type ContentTypes = Record<string, Record<string, ContentTypesEntryMetadata>>;
@ -46,7 +41,7 @@ export async function createContentTypesGenerator({
fs, fs,
logging, logging,
settings, settings,
}: CreateContentGeneratorParams): Promise<GenerateContentTypes> { }: CreateContentGeneratorParams) {
const contentTypes: ContentTypes = {}; const contentTypes: ContentTypes = {};
const contentPaths = getContentPaths(settings.config); const contentPaths = getContentPaths(settings.config);
@ -55,9 +50,15 @@ export async function createContentTypesGenerator({
const contentTypesBase = await fs.promises.readFile(contentPaths.typesTemplate, 'utf-8'); const contentTypesBase = await fs.promises.readFile(contentPaths.typesTemplate, 'utf-8');
async function init() { async function init(): Promise<
await handleEvent({ name: 'add', entry: contentPaths.config }, { logLevel: 'warn' }); { typesGenerated: true } | { typesGenerated: false; reason: 'no-content-dir' }
const globResult = await glob('./**/*.*', { > {
if (!fs.existsSync(contentPaths.contentDir)) {
return { typesGenerated: false, reason: 'no-content-dir' };
}
events.push(handleEvent({ name: 'add', entry: contentPaths.config }, { logLevel: 'warn' }));
const globResult = await glob('**', {
cwd: fileURLToPath(contentPaths.contentDir), cwd: fileURLToPath(contentPaths.contentDir),
fs: { fs: {
readdir: fs.readdir.bind(fs), readdir: fs.readdir.bind(fs),
@ -74,6 +75,7 @@ export async function createContentTypesGenerator({
events.push(handleEvent({ name: 'add', entry }, { logLevel: 'warn' })); events.push(handleEvent({ name: 'add', entry }, { logLevel: 'warn' }));
} }
await runEvents(); await runEvents();
return { typesGenerated: true };
} }
async function handleEvent( async function handleEvent(
@ -109,10 +111,10 @@ export async function createContentTypesGenerator({
if (fileType === 'config') { if (fileType === 'config') {
contentConfigObserver.set({ status: 'loading' }); contentConfigObserver.set({ status: 'loading' });
const config = await loadContentConfig({ fs, settings }); const config = await loadContentConfig({ fs, settings });
if (config instanceof Error) { if (config) {
contentConfigObserver.set({ status: 'error', error: config });
} else {
contentConfigObserver.set({ status: 'loaded', config }); contentConfigObserver.set({ status: 'loaded', config });
} else {
contentConfigObserver.set({ status: 'error' });
} }
return { shouldGenerateTypes: true }; return { shouldGenerateTypes: true };
@ -258,13 +260,13 @@ export function getEntryType(
entryPath: string, entryPath: string,
paths: ContentPaths paths: ContentPaths
): 'content' | 'config' | 'unknown' | 'generated-types' { ): 'content' | 'config' | 'unknown' | 'generated-types' {
const { dir: rawDir, ext, name, base } = path.parse(entryPath); const { dir: rawDir, ext, base } = path.parse(entryPath);
const dir = appendForwardSlash(pathToFileURL(rawDir).href); const dir = appendForwardSlash(pathToFileURL(rawDir).href);
if ((contentFileExts as readonly string[]).includes(ext)) { if ((contentFileExts as readonly string[]).includes(ext)) {
return 'content'; return 'content';
} else if (new URL(name, dir).pathname === paths.config.pathname) { } else if (new URL(base, dir).href === paths.config.href) {
return 'config'; return 'config';
} else if (new URL(base, dir).pathname === new URL(CONTENT_TYPES_FILE, paths.cacheDir).pathname) { } else if (new URL(base, dir).href === new URL(CONTENT_TYPES_FILE, paths.cacheDir).href) {
return 'generated-types'; return 'generated-types';
} else { } else {
return 'unknown'; return 'unknown';
@ -313,6 +315,11 @@ async function writeContentFiles({
if (!isRelativePath(configPathRelativeToCacheDir)) if (!isRelativePath(configPathRelativeToCacheDir))
configPathRelativeToCacheDir = './' + configPathRelativeToCacheDir; configPathRelativeToCacheDir = './' + configPathRelativeToCacheDir;
// Remove `.ts` from import path
if (configPathRelativeToCacheDir.endsWith('.ts')) {
configPathRelativeToCacheDir = configPathRelativeToCacheDir.replace(/\.ts$/, '');
}
contentTypesBase = contentTypesBase.replace('// @@ENTRY_MAP@@', contentTypesStr); contentTypesBase = contentTypesBase.replace('// @@ENTRY_MAP@@', contentTypesStr);
contentTypesBase = contentTypesBase.replace( contentTypesBase = contentTypesBase.replace(
"'@@CONTENT_CONFIG_TYPE@@'", "'@@CONTENT_CONFIG_TYPE@@'",

View file

@ -201,16 +201,13 @@ export function parseFrontmatter(fileContents: string, filePath: string) {
} }
} }
export class NotFoundError extends TypeError {}
export class ZodParseError extends TypeError {}
export async function loadContentConfig({ export async function loadContentConfig({
fs, fs,
settings, settings,
}: { }: {
fs: typeof fsMod; fs: typeof fsMod;
settings: AstroSettings; settings: AstroSettings;
}): Promise<ContentConfig | Error> { }): Promise<ContentConfig | undefined> {
const contentPaths = getContentPaths(settings.config); const contentPaths = getContentPaths(settings.config);
const tempConfigServer: ViteDevServer = await createServer({ const tempConfigServer: ViteDevServer = await createServer({
root: fileURLToPath(settings.config.root), root: fileURLToPath(settings.config.root),
@ -222,10 +219,13 @@ export async function loadContentConfig({
plugins: [astroContentVirtualModPlugin({ settings })], plugins: [astroContentVirtualModPlugin({ settings })],
}); });
let unparsedConfig; let unparsedConfig;
if (!fs.existsSync(contentPaths.config)) {
return undefined;
}
try { try {
unparsedConfig = await tempConfigServer.ssrLoadModule(contentPaths.config.pathname); unparsedConfig = await tempConfigServer.ssrLoadModule(contentPaths.config.pathname);
} catch { } catch (e) {
return new NotFoundError('Failed to resolve content config.'); throw e;
} finally { } finally {
await tempConfigServer.close(); await tempConfigServer.close();
} }
@ -233,14 +233,14 @@ export async function loadContentConfig({
if (config.success) { if (config.success) {
return config.data; return config.data;
} else { } else {
return new ZodParseError('Content config file is invalid.'); return undefined;
} }
} }
type ContentCtx = type ContentCtx =
| { status: 'loading' } | { status: 'loading' }
| { status: 'loaded'; config: ContentConfig } | { status: 'error' }
| { status: 'error'; error: NotFoundError | ZodParseError }; | { status: 'loaded'; config: ContentConfig };
type Observable<C> = { type Observable<C> = {
get: () => C; get: () => C;
@ -292,6 +292,6 @@ export function getContentPaths({
contentDir: new URL('./content/', srcDir), contentDir: new URL('./content/', srcDir),
typesTemplate: new URL('types.d.ts', templateDir), typesTemplate: new URL('types.d.ts', templateDir),
virtualModTemplate: new URL('virtual-mod.mjs', templateDir), virtualModTemplate: new URL('virtual-mod.mjs', templateDir),
config: new URL('./content/config', srcDir), config: new URL('./content/config.ts', srcDir),
}; };
} }

View file

@ -5,13 +5,10 @@ import { pathToFileURL } from 'node:url';
import type { Plugin } from 'vite'; import type { Plugin } from 'vite';
import type { AstroSettings } from '../@types/astro.js'; import type { AstroSettings } from '../@types/astro.js';
import { info, LogOptions } from '../core/logger/core.js'; import { info, LogOptions } from '../core/logger/core.js';
import { appendForwardSlash } from '../core/path.js';
import { escapeViteEnvReferences, getFileInfo } from '../vite-plugin-utils/index.js'; import { escapeViteEnvReferences, getFileInfo } from '../vite-plugin-utils/index.js';
import { contentFileExts, CONTENT_FLAG } from './consts.js'; import { contentFileExts, CONTENT_FLAG } from './consts.js';
import { import { createContentTypesGenerator, getEntryType } from './types-generator.js';
createContentTypesGenerator,
GenerateContentTypes,
getEntryType,
} from './types-generator.js';
import { import {
ContentConfig, ContentConfig,
contentObservable, contentObservable,
@ -36,37 +33,33 @@ export function astroContentServerPlugin({
mode, mode,
}: AstroContentServerPluginParams): Plugin[] { }: AstroContentServerPluginParams): Plugin[] {
const contentPaths = getContentPaths(settings.config); const contentPaths = getContentPaths(settings.config);
let contentDirExists = false;
let contentGenerator: GenerateContentTypes;
const contentConfigObserver = contentObservable({ status: 'loading' }); const contentConfigObserver = contentObservable({ status: 'loading' });
async function initContentGenerator() {
const contentGenerator = await createContentTypesGenerator({
fs,
settings,
logging,
contentConfigObserver,
});
await contentGenerator.init();
return contentGenerator;
}
return [ return [
{ {
name: 'astro-content-server-plugin', name: 'astro-content-server-plugin',
async config(viteConfig) { async config(viteConfig) {
try { // Production build type gen
await fs.promises.stat(contentPaths.contentDir); if (fs.existsSync(contentPaths.contentDir) && viteConfig.build?.ssr === true) {
contentDirExists = true; await initContentGenerator();
} catch {
/* silently move on */
return;
}
if (contentDirExists && (mode === 'dev' || viteConfig.build?.ssr === true)) {
contentGenerator = await createContentTypesGenerator({
fs,
settings,
logging,
contentConfigObserver,
});
await contentGenerator.init();
info(logging, 'content', 'Types generated');
} }
}, },
async configureServer(viteServer) { async configureServer(viteServer) {
if (mode !== 'dev') return; if (mode !== 'dev') return;
if (contentDirExists) { // Dev server type gen
if (fs.existsSync(contentPaths.contentDir)) {
info( info(
logging, logging,
'content', 'content',
@ -74,18 +67,22 @@ export function astroContentServerPlugin({
contentPaths.contentDir.href.replace(settings.config.root.href, '') contentPaths.contentDir.href.replace(settings.config.root.href, '')
)} for changes` )} for changes`
); );
attachListeners(); await attachListeners();
} else { } else {
viteServer.watcher.on('addDir', (dir) => { viteServer.watcher.on('addDir', contentDirListener);
if (pathToFileURL(dir).href === contentPaths.contentDir.href) { async function contentDirListener(dir: string) {
if (appendForwardSlash(pathToFileURL(dir).href) === contentPaths.contentDir.href) {
info(logging, 'content', `Content dir found. Watching for changes`); info(logging, 'content', `Content dir found. Watching for changes`);
contentDirExists = true; await attachListeners();
attachListeners(); viteServer.watcher.removeListener('addDir', contentDirListener);
} }
}); }
} }
function attachListeners() { async function attachListeners() {
const contentGenerator = await initContentGenerator();
info(logging, 'content', 'Types generated');
viteServer.watcher.on('add', (entry) => { viteServer.watcher.on('add', (entry) => {
contentGenerator.queueEvent({ name: 'add', entry }); contentGenerator.queueEvent({ name: 'add', entry });
}); });