fix: add support for js/mjs
file extensions for Content Collections config file (#6229)
* test: add fixture * test: add test case * test: fix tests * feat: support mjs/ js file extensions for cc config * chore: sync lockfile * test: make assertion more specific * test: make template minimal * chore: add changeset * feat: add warning when `allowJs` is `false` * improve warning * extract tsconfig loader to another function * rename to more descriptive variable * apply review suggestion Co-authored-by: Ben Holmes <hey@bholmes.dev> --------- Co-authored-by: Ben Holmes <hey@bholmes.dev>
This commit is contained in:
parent
27a0b6339b
commit
c397be324f
12 changed files with 151 additions and 23 deletions
5
.changeset/three-peaches-guess.md
Normal file
5
.changeset/three-peaches-guess.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'astro': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add support for `.js/.mjs` file extensions for Content Collections configuration file.
|
|
@ -1,12 +1,14 @@
|
||||||
import { cyan } from 'kleur/colors';
|
import { bold, cyan } from 'kleur/colors';
|
||||||
import type fsMod from 'node:fs';
|
import type fsMod from 'node:fs';
|
||||||
import { pathToFileURL } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
import type { ViteDevServer } from 'vite';
|
import type { ViteDevServer } 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, warn } from '../core/logger/core.js';
|
||||||
import { appendForwardSlash } from '../core/path.js';
|
import { appendForwardSlash } from '../core/path.js';
|
||||||
import { createContentTypesGenerator } from './types-generator.js';
|
import { createContentTypesGenerator } from './types-generator.js';
|
||||||
import { getContentPaths, globalContentConfigObserver } from './utils.js';
|
import { ContentPaths, getContentPaths, globalContentConfigObserver } from './utils.js';
|
||||||
|
import { loadTSConfig } from '../core/config/tsconfig.js';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
interface ContentServerListenerParams {
|
interface ContentServerListenerParams {
|
||||||
fs: typeof fsMod;
|
fs: typeof fsMod;
|
||||||
|
@ -21,7 +23,10 @@ export async function attachContentServerListeners({
|
||||||
logging,
|
logging,
|
||||||
settings,
|
settings,
|
||||||
}: ContentServerListenerParams) {
|
}: ContentServerListenerParams) {
|
||||||
const contentPaths = getContentPaths(settings.config);
|
const contentPaths = getContentPaths(settings.config, fs);
|
||||||
|
|
||||||
|
const maybeTsConfigStats = getTSConfigStatsWhenAllowJsFalse({ contentPaths, settings });
|
||||||
|
if (maybeTsConfigStats) warnAllowJsIsFalse({ ...maybeTsConfigStats, logging });
|
||||||
|
|
||||||
if (fs.existsSync(contentPaths.contentDir)) {
|
if (fs.existsSync(contentPaths.contentDir)) {
|
||||||
info(
|
info(
|
||||||
|
@ -71,3 +76,51 @@ export async function attachContentServerListeners({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function warnAllowJsIsFalse({
|
||||||
|
logging,
|
||||||
|
tsConfigFileName,
|
||||||
|
contentConfigFileName,
|
||||||
|
}: {
|
||||||
|
logging: LogOptions;
|
||||||
|
tsConfigFileName: string;
|
||||||
|
contentConfigFileName: string;
|
||||||
|
}) {
|
||||||
|
if (!['info', 'warn'].includes(logging.level))
|
||||||
|
warn(
|
||||||
|
logging,
|
||||||
|
'content',
|
||||||
|
`Make sure you have the ${bold('allowJs')} compiler option set to ${bold(
|
||||||
|
'true'
|
||||||
|
)} in your ${bold(tsConfigFileName)} file to have autocompletion in your ${bold(
|
||||||
|
contentConfigFileName
|
||||||
|
)} file.
|
||||||
|
See ${bold('https://www.typescriptlang.org/tsconfig#allowJs')} for more information.
|
||||||
|
`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTSConfigStatsWhenAllowJsFalse({
|
||||||
|
contentPaths,
|
||||||
|
settings,
|
||||||
|
}: {
|
||||||
|
contentPaths: ContentPaths;
|
||||||
|
settings: AstroSettings;
|
||||||
|
}) {
|
||||||
|
const isContentConfigJsFile = ['.js', '.mjs'].some((ext) =>
|
||||||
|
contentPaths.config.url.pathname.endsWith(ext)
|
||||||
|
);
|
||||||
|
if (!isContentConfigJsFile) return;
|
||||||
|
|
||||||
|
const inputConfig = loadTSConfig(fileURLToPath(settings.config.root), false);
|
||||||
|
const tsConfigFileName = inputConfig.exists && inputConfig.path.split(path.sep).pop();
|
||||||
|
if (!tsConfigFileName) return;
|
||||||
|
|
||||||
|
const contentConfigFileName = contentPaths.config.url.pathname.split(path.sep).pop()!;
|
||||||
|
const allowJSOption = inputConfig?.config?.compilerOptions?.allowJs;
|
||||||
|
const hasAllowJs =
|
||||||
|
allowJSOption === true || (tsConfigFileName === 'jsconfig.json' && allowJSOption !== false);
|
||||||
|
if (hasAllowJs) return;
|
||||||
|
|
||||||
|
return { tsConfigFileName, contentConfigFileName };
|
||||||
|
}
|
||||||
|
|
|
@ -51,7 +51,7 @@ export async function createContentTypesGenerator({
|
||||||
viteServer,
|
viteServer,
|
||||||
}: CreateContentGeneratorParams) {
|
}: CreateContentGeneratorParams) {
|
||||||
const contentTypes: ContentTypes = {};
|
const contentTypes: ContentTypes = {};
|
||||||
const contentPaths = getContentPaths(settings.config);
|
const contentPaths = getContentPaths(settings.config, fs);
|
||||||
|
|
||||||
let events: Promise<{ shouldGenerateTypes: boolean; error?: Error }>[] = [];
|
let events: Promise<{ shouldGenerateTypes: boolean; error?: Error }>[] = [];
|
||||||
let debounceTimeout: NodeJS.Timeout | undefined;
|
let debounceTimeout: NodeJS.Timeout | undefined;
|
||||||
|
@ -65,7 +65,7 @@ export async function createContentTypesGenerator({
|
||||||
return { typesGenerated: false, reason: 'no-content-dir' };
|
return { typesGenerated: false, reason: 'no-content-dir' };
|
||||||
}
|
}
|
||||||
|
|
||||||
events.push(handleEvent({ name: 'add', entry: contentPaths.config }, { logLevel: 'warn' }));
|
events.push(handleEvent({ name: 'add', entry: contentPaths.config.url }, { logLevel: 'warn' }));
|
||||||
const globResult = await glob('**', {
|
const globResult = await glob('**', {
|
||||||
cwd: fileURLToPath(contentPaths.contentDir),
|
cwd: fileURLToPath(contentPaths.contentDir),
|
||||||
fs: {
|
fs: {
|
||||||
|
@ -77,7 +77,7 @@ export async function createContentTypesGenerator({
|
||||||
.map((e) => new URL(e, contentPaths.contentDir))
|
.map((e) => new URL(e, contentPaths.contentDir))
|
||||||
.filter(
|
.filter(
|
||||||
// Config loading handled first. Avoid running twice.
|
// Config loading handled first. Avoid running twice.
|
||||||
(e) => !e.href.startsWith(contentPaths.config.href)
|
(e) => !e.href.startsWith(contentPaths.config.url.href)
|
||||||
);
|
);
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
events.push(handleEvent({ name: 'add', entry }, { logLevel: 'warn' }));
|
events.push(handleEvent({ name: 'add', entry }, { logLevel: 'warn' }));
|
||||||
|
@ -331,7 +331,7 @@ async function writeContentFiles({
|
||||||
}
|
}
|
||||||
|
|
||||||
let configPathRelativeToCacheDir = normalizePath(
|
let configPathRelativeToCacheDir = normalizePath(
|
||||||
path.relative(contentPaths.cacheDir.pathname, contentPaths.config.pathname)
|
path.relative(contentPaths.cacheDir.pathname, contentPaths.config.url.pathname)
|
||||||
);
|
);
|
||||||
if (!isRelativePath(configPathRelativeToCacheDir))
|
if (!isRelativePath(configPathRelativeToCacheDir))
|
||||||
configPathRelativeToCacheDir = './' + configPathRelativeToCacheDir;
|
configPathRelativeToCacheDir = './' + configPathRelativeToCacheDir;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { slug as githubSlug } from 'github-slugger';
|
import { slug as githubSlug } from 'github-slugger';
|
||||||
import matter from 'gray-matter';
|
import matter from 'gray-matter';
|
||||||
import type fsMod from 'node:fs';
|
import fsMod from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
import { ErrorPayload as ViteErrorPayload, normalizePath, ViteDevServer } from 'vite';
|
import { ErrorPayload as ViteErrorPayload, normalizePath, ViteDevServer } from 'vite';
|
||||||
|
@ -169,7 +169,7 @@ export function getEntryType(
|
||||||
return 'ignored';
|
return 'ignored';
|
||||||
} else if ((contentFileExts as readonly string[]).includes(ext)) {
|
} else if ((contentFileExts as readonly string[]).includes(ext)) {
|
||||||
return 'content';
|
return 'content';
|
||||||
} else if (fileUrl.href === paths.config.href) {
|
} else if (fileUrl.href === paths.config.url.href) {
|
||||||
return 'config';
|
return 'config';
|
||||||
} else {
|
} else {
|
||||||
return 'unsupported';
|
return 'unsupported';
|
||||||
|
@ -250,13 +250,13 @@ export async function loadContentConfig({
|
||||||
settings: AstroSettings;
|
settings: AstroSettings;
|
||||||
viteServer: ViteDevServer;
|
viteServer: ViteDevServer;
|
||||||
}): Promise<ContentConfig | undefined> {
|
}): Promise<ContentConfig | undefined> {
|
||||||
const contentPaths = getContentPaths(settings.config);
|
const contentPaths = getContentPaths(settings.config, fs);
|
||||||
let unparsedConfig;
|
let unparsedConfig;
|
||||||
if (!fs.existsSync(contentPaths.config)) {
|
if (!contentPaths.config.exists) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const configPathname = fileURLToPath(contentPaths.config);
|
const configPathname = fileURLToPath(contentPaths.config.url);
|
||||||
unparsedConfig = await viteServer.ssrLoadModule(configPathname);
|
unparsedConfig = await viteServer.ssrLoadModule(configPathname);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw e;
|
throw e;
|
||||||
|
@ -313,19 +313,34 @@ export type ContentPaths = {
|
||||||
cacheDir: URL;
|
cacheDir: URL;
|
||||||
typesTemplate: URL;
|
typesTemplate: URL;
|
||||||
virtualModTemplate: URL;
|
virtualModTemplate: URL;
|
||||||
config: URL;
|
config: {
|
||||||
|
exists: boolean;
|
||||||
|
url: URL;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getContentPaths({
|
export function getContentPaths(
|
||||||
srcDir,
|
{ srcDir, root }: Pick<AstroConfig, 'root' | 'srcDir'>,
|
||||||
root,
|
fs: typeof fsMod = fsMod
|
||||||
}: Pick<AstroConfig, 'root' | 'srcDir'>): ContentPaths {
|
): ContentPaths {
|
||||||
|
const configStats = search(fs, srcDir);
|
||||||
const templateDir = new URL('../../src/content/template/', import.meta.url);
|
const templateDir = new URL('../../src/content/template/', import.meta.url);
|
||||||
return {
|
return {
|
||||||
cacheDir: new URL('.astro/', root),
|
cacheDir: new URL('.astro/', root),
|
||||||
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.ts', srcDir),
|
config: configStats,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
function search(fs: typeof fsMod, srcDir: URL) {
|
||||||
|
const paths = ['config.mjs', 'config.js', 'config.ts'].map(
|
||||||
|
(p) => new URL(`./content/${p}`, srcDir)
|
||||||
|
);
|
||||||
|
for (const file of paths) {
|
||||||
|
if (fs.existsSync(file)) {
|
||||||
|
return { exists: true, url: file };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { exists: false, url: paths[0] };
|
||||||
|
}
|
||||||
|
|
|
@ -30,7 +30,7 @@ export function astroContentImportPlugin({
|
||||||
fs: typeof fsMod;
|
fs: typeof fsMod;
|
||||||
settings: AstroSettings;
|
settings: AstroSettings;
|
||||||
}): Plugin {
|
}): Plugin {
|
||||||
const contentPaths = getContentPaths(settings.config);
|
const contentPaths = getContentPaths(settings.config, fs);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: 'astro:content-imports',
|
name: 'astro:content-imports',
|
||||||
|
|
|
@ -199,6 +199,20 @@ describe('Content Collections', () => {
|
||||||
expect(error).to.be.null;
|
expect(error).to.be.null;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('With config.mjs', () => {
|
||||||
|
it("Errors when frontmatter doesn't match schema", async () => {
|
||||||
|
const fixture = await loadFixture({
|
||||||
|
root: './fixtures/content-collections-with-config-mjs/',
|
||||||
|
});
|
||||||
|
let error;
|
||||||
|
try {
|
||||||
|
await fixture.build();
|
||||||
|
} catch (e) {
|
||||||
|
error = e.message;
|
||||||
|
}
|
||||||
|
expect(error).to.include('"title" should be string, not number.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('SSR integration', () => {
|
describe('SSR integration', () => {
|
||||||
let app;
|
let app;
|
||||||
|
|
9
packages/astro/test/fixtures/content-collections-with-config-mjs/package.json
vendored
Normal file
9
packages/astro/test/fixtures/content-collections-with-config-mjs/package.json
vendored
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"name": "@test/content-with-spaces-in-folder-name",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"astro": "workspace:*",
|
||||||
|
"@astrojs/mdx": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: 10000
|
||||||
|
---
|
||||||
|
|
||||||
|
# Hi there!
|
11
packages/astro/test/fixtures/content-collections-with-config-mjs/src/content/config.mjs
vendored
Normal file
11
packages/astro/test/fixtures/content-collections-with-config-mjs/src/content/config.mjs
vendored
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { z, defineCollection } from 'astro:content';
|
||||||
|
|
||||||
|
const blog = defineCollection({
|
||||||
|
schema: z.object({
|
||||||
|
title: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collections = {
|
||||||
|
blog
|
||||||
|
}
|
5
packages/astro/test/fixtures/content-collections-with-config-mjs/src/pages/index.astro
vendored
Normal file
5
packages/astro/test/fixtures/content-collections-with-config-mjs/src/pages/index.astro
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
import {getEntryBySlug} from "astro:content"
|
||||||
|
const blogEntry = await getEntryBySlug("blog", "introduction");
|
||||||
|
---
|
||||||
|
{blogEntry.data.title}
|
|
@ -5,7 +5,10 @@ import { fileURLToPath } from 'node:url';
|
||||||
describe('Content Collections - getEntryType', () => {
|
describe('Content Collections - getEntryType', () => {
|
||||||
const contentDir = new URL('src/content/', import.meta.url);
|
const contentDir = new URL('src/content/', import.meta.url);
|
||||||
const contentPaths = {
|
const contentPaths = {
|
||||||
config: new URL('src/content/config.ts', import.meta.url),
|
config: {
|
||||||
|
url: new URL('src/content/config.ts', import.meta.url),
|
||||||
|
exists: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
it('Returns "content" for Markdown files', () => {
|
it('Returns "content" for Markdown files', () => {
|
||||||
|
@ -25,7 +28,7 @@ describe('Content Collections - getEntryType', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Returns "config" for config files', () => {
|
it('Returns "config" for config files', () => {
|
||||||
const entry = fileURLToPath(contentPaths.config);
|
const entry = fileURLToPath(contentPaths.config.url);
|
||||||
const type = getEntryType(entry, contentPaths);
|
const type = getEntryType(entry, contentPaths);
|
||||||
expect(type).to.equal('config');
|
expect(type).to.equal('config');
|
||||||
});
|
});
|
||||||
|
|
|
@ -1677,6 +1677,14 @@ importers:
|
||||||
'@astrojs/mdx': link:../../../../integrations/mdx
|
'@astrojs/mdx': link:../../../../integrations/mdx
|
||||||
astro: link:../../..
|
astro: link:../../..
|
||||||
|
|
||||||
|
packages/astro/test/fixtures/content-collections-with-config-mjs:
|
||||||
|
specifiers:
|
||||||
|
'@astrojs/mdx': workspace:*
|
||||||
|
astro: workspace:*
|
||||||
|
dependencies:
|
||||||
|
'@astrojs/mdx': link:../../../../integrations/mdx
|
||||||
|
astro: link:../../..
|
||||||
|
|
||||||
packages/astro/test/fixtures/content-ssr-integration:
|
packages/astro/test/fixtures/content-ssr-integration:
|
||||||
specifiers:
|
specifiers:
|
||||||
'@astrojs/mdx': workspace:*
|
'@astrojs/mdx': workspace:*
|
||||||
|
|
Loading…
Reference in a new issue