[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:
parent
1f49cddf9e
commit
665a2c2225
5 changed files with 78 additions and 58 deletions
8
.changeset/fluffy-onions-wink.md
Normal file
8
.changeset/fluffy-onions-wink.md
Normal 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
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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@@'",
|
||||||
|
|
|
@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 });
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue