diff --git a/.changeset/fluffy-onions-wink.md b/.changeset/fluffy-onions-wink.md new file mode 100644 index 000000000000..617ebd632e16 --- /dev/null +++ b/.changeset/fluffy-onions-wink.md @@ -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 diff --git a/packages/astro/src/cli/sync/index.ts b/packages/astro/src/cli/sync/index.ts index c91bd9baccf1..92cd05651a9b 100644 --- a/packages/astro/src/cli/sync/index.ts +++ b/packages/astro/src/cli/sync/index.ts @@ -21,7 +21,15 @@ export async function sync( fs, 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) { throw new AstroError(AstroErrorData.GenerateContentTypesError); } diff --git a/packages/astro/src/content/types-generator.ts b/packages/astro/src/content/types-generator.ts index 9df6bdb4482a..813f21c22672 100644 --- a/packages/astro/src/content/types-generator.ts +++ b/packages/astro/src/content/types-generator.ts @@ -22,11 +22,6 @@ type ChokidarEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir'; type RawContentEvent = { name: ChokidarEvent; entry: string }; type ContentEvent = { name: ChokidarEvent; entry: URL }; -export type GenerateContentTypes = { - init(): Promise; - queueEvent(event: RawContentEvent): void; -}; - type ContentTypesEntryMetadata = { slug: string }; type ContentTypes = Record>; @@ -46,7 +41,7 @@ export async function createContentTypesGenerator({ fs, logging, settings, -}: CreateContentGeneratorParams): Promise { +}: CreateContentGeneratorParams) { const contentTypes: ContentTypes = {}; const contentPaths = getContentPaths(settings.config); @@ -55,9 +50,15 @@ export async function createContentTypesGenerator({ const contentTypesBase = await fs.promises.readFile(contentPaths.typesTemplate, 'utf-8'); - async function init() { - await handleEvent({ name: 'add', entry: contentPaths.config }, { logLevel: 'warn' }); - const globResult = await glob('./**/*.*', { + async function init(): Promise< + { typesGenerated: true } | { typesGenerated: false; reason: 'no-content-dir' } + > { + 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), fs: { readdir: fs.readdir.bind(fs), @@ -74,6 +75,7 @@ export async function createContentTypesGenerator({ events.push(handleEvent({ name: 'add', entry }, { logLevel: 'warn' })); } await runEvents(); + return { typesGenerated: true }; } async function handleEvent( @@ -109,10 +111,10 @@ export async function createContentTypesGenerator({ if (fileType === 'config') { contentConfigObserver.set({ status: 'loading' }); const config = await loadContentConfig({ fs, settings }); - if (config instanceof Error) { - contentConfigObserver.set({ status: 'error', error: config }); - } else { + if (config) { contentConfigObserver.set({ status: 'loaded', config }); + } else { + contentConfigObserver.set({ status: 'error' }); } return { shouldGenerateTypes: true }; @@ -258,13 +260,13 @@ export function getEntryType( entryPath: string, paths: ContentPaths ): '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); if ((contentFileExts as readonly string[]).includes(ext)) { return 'content'; - } else if (new URL(name, dir).pathname === paths.config.pathname) { + } else if (new URL(base, dir).href === paths.config.href) { 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'; } else { return 'unknown'; @@ -313,6 +315,11 @@ async function writeContentFiles({ if (!isRelativePath(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( "'@@CONTENT_CONFIG_TYPE@@'", diff --git a/packages/astro/src/content/utils.ts b/packages/astro/src/content/utils.ts index a2599a9a67ee..a14be460aa65 100644 --- a/packages/astro/src/content/utils.ts +++ b/packages/astro/src/content/utils.ts @@ -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({ fs, settings, }: { fs: typeof fsMod; settings: AstroSettings; -}): Promise { +}): Promise { const contentPaths = getContentPaths(settings.config); const tempConfigServer: ViteDevServer = await createServer({ root: fileURLToPath(settings.config.root), @@ -222,10 +219,13 @@ export async function loadContentConfig({ plugins: [astroContentVirtualModPlugin({ settings })], }); let unparsedConfig; + if (!fs.existsSync(contentPaths.config)) { + return undefined; + } try { unparsedConfig = await tempConfigServer.ssrLoadModule(contentPaths.config.pathname); - } catch { - return new NotFoundError('Failed to resolve content config.'); + } catch (e) { + throw e; } finally { await tempConfigServer.close(); } @@ -233,14 +233,14 @@ export async function loadContentConfig({ if (config.success) { return config.data; } else { - return new ZodParseError('Content config file is invalid.'); + return undefined; } } type ContentCtx = | { status: 'loading' } - | { status: 'loaded'; config: ContentConfig } - | { status: 'error'; error: NotFoundError | ZodParseError }; + | { status: 'error' } + | { status: 'loaded'; config: ContentConfig }; type Observable = { get: () => C; @@ -292,6 +292,6 @@ export function getContentPaths({ contentDir: new URL('./content/', srcDir), typesTemplate: new URL('types.d.ts', templateDir), virtualModTemplate: new URL('virtual-mod.mjs', templateDir), - config: new URL('./content/config', srcDir), + config: new URL('./content/config.ts', srcDir), }; } diff --git a/packages/astro/src/content/vite-plugin-content-server.ts b/packages/astro/src/content/vite-plugin-content-server.ts index 347ebbe1854b..dda1a416f3d9 100644 --- a/packages/astro/src/content/vite-plugin-content-server.ts +++ b/packages/astro/src/content/vite-plugin-content-server.ts @@ -5,13 +5,10 @@ import { pathToFileURL } from 'node:url'; import type { Plugin } from 'vite'; import type { AstroSettings } from '../@types/astro.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 { contentFileExts, CONTENT_FLAG } from './consts.js'; -import { - createContentTypesGenerator, - GenerateContentTypes, - getEntryType, -} from './types-generator.js'; +import { createContentTypesGenerator, getEntryType } from './types-generator.js'; import { ContentConfig, contentObservable, @@ -36,37 +33,33 @@ export function astroContentServerPlugin({ mode, }: AstroContentServerPluginParams): Plugin[] { const contentPaths = getContentPaths(settings.config); - let contentDirExists = false; - let contentGenerator: GenerateContentTypes; const contentConfigObserver = contentObservable({ status: 'loading' }); + async function initContentGenerator() { + const contentGenerator = await createContentTypesGenerator({ + fs, + settings, + logging, + contentConfigObserver, + }); + await contentGenerator.init(); + return contentGenerator; + } + return [ { name: 'astro-content-server-plugin', async config(viteConfig) { - try { - await fs.promises.stat(contentPaths.contentDir); - contentDirExists = true; - } 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'); + // Production build type gen + if (fs.existsSync(contentPaths.contentDir) && viteConfig.build?.ssr === true) { + await initContentGenerator(); } }, async configureServer(viteServer) { if (mode !== 'dev') return; - if (contentDirExists) { + // Dev server type gen + if (fs.existsSync(contentPaths.contentDir)) { info( logging, 'content', @@ -74,18 +67,22 @@ export function astroContentServerPlugin({ contentPaths.contentDir.href.replace(settings.config.root.href, '') )} for changes` ); - attachListeners(); + await attachListeners(); } else { - viteServer.watcher.on('addDir', (dir) => { - if (pathToFileURL(dir).href === contentPaths.contentDir.href) { + viteServer.watcher.on('addDir', contentDirListener); + async function contentDirListener(dir: string) { + if (appendForwardSlash(pathToFileURL(dir).href) === contentPaths.contentDir.href) { info(logging, 'content', `Content dir found. Watching for changes`); - contentDirExists = true; - attachListeners(); + await 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) => { contentGenerator.queueEvent({ name: 'add', entry }); });