[Content collections] Move generated types to .astro directory (#5786)

* feat: change cacheDir to `.astro`

* feat: write reference in env.d.ts if none exists

* chore: update with-content types

* test: env.d.ts transform

* nit: setUp -> add

* refactor: content.d.ts -> types.d.ts

* chore: update confirmation log

* chore: changeset

* feat: inject env.d.ts if none exists

* feat: set up env.d.ts on `astro sync`

* chore: duplicate envTsPathRelative

* docs: update changeset

* fix: make srcDir if none exists

* fix: types.generated -> .astro in gitignore

* feat: add env.d.ts to test gitignore

* chore: remove env.d.ts from content-collections

* test: move sync tests to `astro sync`, add file write test

* refactor: simplify test gitignore to base

* fix: add / to `.astro` bc that scares me
This commit is contained in:
Ben Holmes 2023-01-10 18:01:52 -05:00 committed by GitHub
parent 813073addd
commit c2180746b4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 263 additions and 97 deletions

View file

@ -0,0 +1,9 @@
---
'astro': minor
---
Move generated content collection types to a `.astro` directory. This replaces the previously generated `src/content/types.generated.d.ts` file.
If you're using Git for version control, we recommend ignoring this generated directory by adding `.astro` to your .gitignore.
Astro will also generate the [TypeScript reference path](https://www.typescriptlang.org/docs/handbook/triple-slash-directives.html#-reference-path-) to include `.astro` types in your project. This will update your project's `src/env.d.ts` file, or write one if none exists.

View file

@ -55,44 +55,45 @@ declare module 'astro:content' {
};
const entryMap: {
blog: {
'first-post.md': {
id: 'first-post.md';
slug: 'first-post';
body: string;
collection: 'blog';
data: InferEntrySchema<'blog'>;
};
'markdown-style-guide.md': {
id: 'markdown-style-guide.md';
slug: 'markdown-style-guide';
body: string;
collection: 'blog';
data: InferEntrySchema<'blog'>;
};
'second-post.md': {
id: 'second-post.md';
slug: 'second-post';
body: string;
collection: 'blog';
data: InferEntrySchema<'blog'>;
};
'third-post.md': {
id: 'third-post.md';
slug: 'third-post';
body: string;
collection: 'blog';
data: InferEntrySchema<'blog'>;
};
'using-mdx.mdx': {
id: 'using-mdx.mdx';
slug: 'using-mdx';
body: string;
collection: 'blog';
data: InferEntrySchema<'blog'>;
};
};
"blog": {
"first-post.md": {
id: "first-post.md",
slug: "first-post",
body: string,
collection: "blog",
data: InferEntrySchema<"blog">
},
"markdown-style-guide.md": {
id: "markdown-style-guide.md",
slug: "markdown-style-guide",
body: string,
collection: "blog",
data: InferEntrySchema<"blog">
},
"second-post.md": {
id: "second-post.md",
slug: "second-post",
body: string,
collection: "blog",
data: InferEntrySchema<"blog">
},
"third-post.md": {
id: "third-post.md",
slug: "third-post",
body: string,
collection: "blog",
data: InferEntrySchema<"blog">
},
"using-mdx.mdx": {
id: "using-mdx.mdx",
slug: "using-mdx",
body: string,
collection: "blog",
data: InferEntrySchema<"blog">
},
},
};
type ContentConfig = typeof import('./config');
type ContentConfig = typeof import("../src/content/config");
}

View file

@ -1 +1,2 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />

View file

@ -6,6 +6,7 @@ import { contentObservable, createContentTypesGenerator } from '../../content/in
import { getTimeStat } from '../../core/build/util.js';
import { AstroError, AstroErrorData } from '../../core/errors/index.js';
import { info, LogOptions } from '../../core/logger/core.js';
import { setUpEnvTs } from '../../vite-plugin-inject-env-ts/index.js';
export async function sync(
settings: AstroSettings,
@ -26,6 +27,7 @@ export async function sync(
}
info(logging, 'content', `Types generated ${dim(getTimeStat(timerStart, performance.now()))}`);
await setUpEnvTs({ settings, logging, fs });
return 0;
}

View file

@ -5,6 +5,4 @@ export const VIRTUAL_MODULE_ID = 'astro:content';
export const LINKS_PLACEHOLDER = '@@ASTRO-LINKS@@';
export const STYLES_PLACEHOLDER = '@@ASTRO-STYLES@@';
export const CONTENT_BASE = 'types.generated';
export const CONTENT_FILE = CONTENT_BASE + '.mjs';
export const CONTENT_TYPES_FILE = CONTENT_BASE + '.d.ts';
export const CONTENT_TYPES_FILE = 'types.d.ts';

View file

@ -1,5 +1,5 @@
export { createContentTypesGenerator } from './types-generator.js';
export { contentObservable, getContentPaths } from './utils.js';
export { contentObservable, getContentPaths, getDotAstroTypeReference } from './utils.js';
export {
astroBundleDelayedAssetPlugin,
astroDelayedAssetPlugin,

View file

@ -7,12 +7,14 @@ import { normalizePath } from 'vite';
import type { AstroSettings } from '../@types/astro.js';
import { info, LogOptions, warn } from '../core/logger/core.js';
import { appendForwardSlash, isRelativePath } from '../core/path.js';
import { getEnvTsPath } from '../vite-plugin-inject-env-ts/index.js';
import { contentFileExts, CONTENT_TYPES_FILE } from './consts.js';
import {
ContentConfig,
ContentObservable,
ContentPaths,
getContentPaths,
getDotAstroTypeReference,
getEntryInfo,
loadContentConfig,
NoCollectionError,
@ -48,15 +50,12 @@ export async function createContentTypesGenerator({
settings,
}: CreateContentGeneratorParams): Promise<GenerateContentTypes> {
const contentTypes: ContentTypes = {};
const contentPaths: ContentPaths = getContentPaths({ srcDir: settings.config.srcDir });
const contentPaths = getContentPaths(settings.config);
let events: Promise<{ shouldGenerateTypes: boolean; error?: Error }>[] = [];
let debounceTimeout: NodeJS.Timeout | undefined;
const contentTypesBase = await fs.promises.readFile(
new URL(CONTENT_TYPES_FILE, contentPaths.generatedInputDir),
'utf-8'
);
const contentTypesBase = await fs.promises.readFile(contentPaths.typesTemplate, 'utf-8');
async function init() {
await handleEvent({ name: 'add', entry: contentPaths.config }, { logLevel: 'warn' });
@ -306,6 +305,10 @@ async function writeContentFiles({
contentTypesStr += `},\n`;
}
if (!fs.existsSync(contentPaths.cacheDir)) {
fs.mkdirSync(contentPaths.cacheDir, { recursive: true });
}
let configPathRelativeToCacheDir = normalizePath(
path.relative(contentPaths.cacheDir.pathname, contentPaths.config.pathname)
);

View file

@ -5,8 +5,9 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createServer, ErrorPayload as ViteErrorPayload, normalizePath, ViteDevServer } from 'vite';
import { z } from 'zod';
import { AstroSettings } from '../@types/astro.js';
import { AstroConfig, AstroSettings } from '../@types/astro.js';
import { AstroError, AstroErrorData } from '../core/errors/index.js';
import { CONTENT_TYPES_FILE } from './consts.js';
import { astroContentVirtualModPlugin } from './vite-plugin-content-virtual-mod.js';
export const collectionConfigParser = z.object({
@ -26,6 +27,15 @@ export const collectionConfigParser = z.object({
.optional(),
});
export function getDotAstroTypeReference({ root, srcDir }: { root: URL; srcDir: URL }) {
const { cacheDir } = getContentPaths({ root, srcDir });
const contentTypesRelativeToSrcDir = normalizePath(
path.relative(fileURLToPath(srcDir), fileURLToPath(new URL(CONTENT_TYPES_FILE, cacheDir)))
);
return `/// <reference path=${JSON.stringify(contentTypesRelativeToSrcDir)} />`;
}
export const contentConfigParser = z.object({
collections: z.record(collectionConfigParser),
});
@ -201,7 +211,7 @@ export async function loadContentConfig({
fs: typeof fsMod;
settings: AstroSettings;
}): Promise<ContentConfig | Error> {
const contentPaths = getContentPaths({ srcDir: settings.config.srcDir });
const contentPaths = getContentPaths(settings.config);
const tempConfigServer: ViteDevServer = await createServer({
root: fileURLToPath(settings.config.root),
server: { middlewareMode: true, hmr: false },
@ -267,16 +277,21 @@ export function contentObservable(initialCtx: ContentCtx): ContentObservable {
export type ContentPaths = {
contentDir: URL;
cacheDir: URL;
generatedInputDir: URL;
typesTemplate: URL;
virtualModTemplate: URL;
config: URL;
};
export function getContentPaths({ srcDir }: { srcDir: URL }): ContentPaths {
export function getContentPaths({
srcDir,
root,
}: Pick<AstroConfig, 'root' | 'srcDir'>): ContentPaths {
const templateDir = new URL('../../src/content/template/', import.meta.url);
return {
// Output generated types in content directory. May change in the future!
cacheDir: new URL('./content/', srcDir),
cacheDir: new URL('.astro/', root),
contentDir: new URL('./content/', srcDir),
generatedInputDir: new URL('../../src/content/template/', import.meta.url),
typesTemplate: new URL('types.d.ts', templateDir),
virtualModTemplate: new URL('virtual-mod.mjs', templateDir),
config: new URL('./content/config', srcDir),
};
}

View file

@ -15,7 +15,6 @@ import {
import {
ContentConfig,
contentObservable,
ContentPaths,
getContentPaths,
getEntryData,
getEntryInfo,
@ -36,7 +35,7 @@ export function astroContentServerPlugin({
logging,
mode,
}: AstroContentServerPluginParams): Plugin[] {
const contentPaths: ContentPaths = getContentPaths({ srcDir: settings.config.srcDir });
const contentPaths = getContentPaths(settings.config);
let contentDirExists = false;
let contentGenerator: GenerateContentTypes;
const contentConfigObserver = contentObservable({ status: 'loading' });

View file

@ -4,7 +4,7 @@ import type { Plugin } from 'vite';
import { normalizePath } from 'vite';
import type { AstroSettings } from '../@types/astro.js';
import { appendForwardSlash, prependForwardSlash } from '../core/path.js';
import { contentFileExts, CONTENT_FILE, VIRTUAL_MODULE_ID } from './consts.js';
import { contentFileExts, VIRTUAL_MODULE_ID } from './consts.js';
import { getContentPaths } from './utils.js';
interface AstroContentVirtualModPluginParams {
@ -14,15 +14,17 @@ interface AstroContentVirtualModPluginParams {
export function astroContentVirtualModPlugin({
settings,
}: AstroContentVirtualModPluginParams): Plugin {
const paths = getContentPaths({ srcDir: settings.config.srcDir });
const contentPaths = getContentPaths(settings.config);
const relContentDir = normalizePath(
appendForwardSlash(
prependForwardSlash(path.relative(settings.config.root.pathname, paths.contentDir.pathname))
prependForwardSlash(
path.relative(settings.config.root.pathname, contentPaths.contentDir.pathname)
)
)
);
const entryGlob = `${relContentDir}**/*{${contentFileExts.join(',')}}`;
const astroContentModContents = fsMod
.readFileSync(new URL(CONTENT_FILE, paths.generatedInputDir), 'utf-8')
const virtualModContents = fsMod
.readFileSync(contentPaths.virtualModTemplate, 'utf-8')
.replace('@@CONTENT_DIR@@', relContentDir)
.replace('@@ENTRY_GLOB_PATH@@', entryGlob)
.replace('@@RENDER_ENTRY_GLOB_PATH@@', entryGlob);
@ -40,7 +42,7 @@ export function astroContentVirtualModPlugin({
load(id) {
if (id === astroContentVirtualModuleId) {
return {
code: astroContentModContents,
code: virtualModContents,
};
}
},

View file

@ -24,6 +24,7 @@ import markdownVitePlugin from '../vite-plugin-markdown/index.js';
import astroScannerPlugin from '../vite-plugin-scanner/index.js';
import astroScriptsPlugin from '../vite-plugin-scripts/index.js';
import astroScriptsPageSSRPlugin from '../vite-plugin-scripts/page-ssr.js';
import { astroInjectEnvTsPlugin } from '../vite-plugin-inject-env-ts/index.js';
interface CreateViteOptions {
settings: AstroSettings;
@ -102,6 +103,7 @@ export async function createVite(
astroScriptsPageSSRPlugin({ settings }),
astroHeadPropagationPlugin({ settings }),
astroScannerPlugin({ settings, logging }),
astroInjectEnvTsPlugin({ settings, logging, fs }),
...(settings.config.experimental.contentCollections
? [
astroContentVirtualModPlugin({ settings }),

View file

@ -0,0 +1,79 @@
import type { AstroSettings } from '../@types/astro.js';
import type fsMod from 'node:fs';
import { normalizePath, Plugin } from 'vite';
import path from 'node:path';
import { getContentPaths, getDotAstroTypeReference } from '../content/index.js';
import { info, LogOptions } from '../core/logger/core.js';
import { fileURLToPath } from 'node:url';
import { bold } from 'kleur/colors';
export function getEnvTsPath({ srcDir }: { srcDir: URL }) {
return new URL('env.d.ts', srcDir);
}
export function astroInjectEnvTsPlugin({
settings,
logging,
fs,
}: {
settings: AstroSettings;
logging: LogOptions;
fs: typeof fsMod;
}): Plugin {
return {
name: 'astro-inject-env-ts',
// Use `post` to ensure project setup is complete
// Ex. `.astro` types have been written
enforce: 'post',
async config() {
await setUpEnvTs({ settings, logging, fs });
},
};
}
export async function setUpEnvTs({
settings,
logging,
fs,
}: {
settings: AstroSettings;
logging: LogOptions;
fs: typeof fsMod;
}) {
const envTsPath = getEnvTsPath(settings.config);
const dotAstroDir = getContentPaths(settings.config).cacheDir;
const dotAstroTypeReference = getDotAstroTypeReference(settings.config);
const envTsPathRelativetoRoot = normalizePath(
path.relative(fileURLToPath(settings.config.root), fileURLToPath(envTsPath))
);
if (fs.existsSync(envTsPath)) {
// Add `.astro` types reference if none exists
if (!fs.existsSync(dotAstroDir)) return;
let typesEnvContents = await fs.promises.readFile(envTsPath, 'utf-8');
const expectedTypeReference = getDotAstroTypeReference(settings.config);
if (!typesEnvContents.includes(expectedTypeReference)) {
typesEnvContents = `${expectedTypeReference}\n${typesEnvContents}`;
await fs.promises.writeFile(envTsPath, typesEnvContents, 'utf-8');
info(logging, 'content', `Added ${bold(envTsPathRelativetoRoot)} types`);
}
} else {
// Otherwise, inject the `env.d.ts` file
let referenceDefs: string[] = [];
if (settings.config.integrations.find((i) => i.name === '@astrojs/image')) {
referenceDefs.push('/// <reference types="@astrojs/image/client" />');
} else {
referenceDefs.push('/// <reference types="astro/client" />');
}
if (fs.existsSync(dotAstroDir)) {
referenceDefs.push(dotAstroTypeReference);
}
await fs.promises.mkdir(settings.config.srcDir, { recursive: true });
await fs.promises.writeFile(envTsPath, referenceDefs.join('\n'), 'utf-8');
info(logging, 'astro', `Added ${bold(envTsPathRelativetoRoot)} types`);
}
}

View file

@ -0,0 +1,88 @@
import * as fs from 'node:fs';
import { expect } from 'chai';
import { loadFixture } from './test-utils.js';
describe('astro sync', () => {
let fixture;
before(async () => {
fixture = await loadFixture({ root: './fixtures/content-collections/' });
});
it('Writes types to `.astro`', async () => {
let writtenFiles = {};
const fsMock = {
...fs,
promises: {
...fs.promises,
async writeFile(path, contents) {
writtenFiles[path] = contents;
},
},
};
await fixture.sync({ fs: fsMock });
const expectedTypesFile = new URL('.astro/types.d.ts', fixture.config.root).href;
expect(writtenFiles).to.haveOwnProperty(expectedTypesFile);
// smoke test `astro check` asserts whether content types pass.
expect(writtenFiles[expectedTypesFile]).to.include(
`declare module 'astro:content' {`,
'Types file does not include `astro:content` module declaration'
);
});
it('Adds type reference to `src/env.d.ts`', async () => {
let writtenFiles = {};
const typesEnvPath = new URL('env.d.ts', fixture.config.srcDir).href;
const fsMock = {
...fs,
existsSync(path, ...args) {
if (path.toString() === typesEnvPath) {
return true;
}
return fs.existsSync(path, ...args);
},
promises: {
...fs.promises,
async readFile(path, ...args) {
if (path.toString() === typesEnvPath) {
return `/// <reference path="astro/client" />`;
} else {
return fs.promises.readFile(path, ...args);
}
},
async writeFile(path, contents) {
writtenFiles[path] = contents;
},
},
};
await fixture.sync({ fs: fsMock });
expect(writtenFiles, 'Did not try to update env.d.ts file.').to.haveOwnProperty(typesEnvPath);
expect(writtenFiles[typesEnvPath]).to.include(`/// <reference path="../.astro/types.d.ts" />`);
});
it('Writes `src/env.d.ts` if none exists', async () => {
let writtenFiles = {};
const typesEnvPath = new URL('env.d.ts', fixture.config.srcDir).href;
const fsMock = {
...fs,
existsSync(path, ...args) {
if (path.toString() === typesEnvPath) {
return false;
}
return fs.existsSync(path, ...args);
},
promises: {
...fs.promises,
async writeFile(path, contents) {
writtenFiles[path] = contents;
},
},
};
await fixture.sync({ fs: fsMock });
expect(writtenFiles, 'Did not try to write env.d.ts file.').to.haveOwnProperty(typesEnvPath);
expect(writtenFiles[typesEnvPath]).to.include(`/// <reference types="astro/client" />`);
expect(writtenFiles[typesEnvPath]).to.include(`/// <reference path="../.astro/types.d.ts" />`);
});
});

View file

@ -1,4 +1,3 @@
import * as fs from 'node:fs';
import * as devalue from 'devalue';
import * as cheerio from 'cheerio';
import { expect } from 'chai';
@ -6,36 +5,6 @@ import { loadFixture } from './test-utils.js';
import testAdapter from './test-adapter.js';
describe('Content Collections', () => {
describe('Type generation', () => {
let fixture;
before(async () => {
fixture = await loadFixture({ root: './fixtures/content-collections/' });
});
it('Writes types to `src/content/`', async () => {
let writtenFiles = {};
const fsMock = {
...fs,
promises: {
...fs.promises,
async writeFile(path, contents) {
writtenFiles[path] = contents;
},
},
};
const expectedTypesFile = new URL('./content/types.generated.d.ts', fixture.config.srcDir)
.href;
await fixture.sync({ fs: fsMock });
expect(Object.keys(writtenFiles)).to.have.lengthOf(1);
expect(writtenFiles).to.haveOwnProperty(expectedTypesFile);
// smoke test `astro check` asserts whether content types pass.
expect(writtenFiles[expectedTypesFile]).to.include(
`declare module 'astro:content' {`,
'Types file does not include `astro:content` module declaration'
);
});
});
describe('Query', () => {
let fixture;
before(async () => {

View file

@ -0,0 +1,2 @@
.astro/
env.d.ts

View file

@ -1 +0,0 @@
types.generated.d.ts

View file

@ -1 +0,0 @@
types.generated.d.ts

View file

@ -1 +0,0 @@
types.generated.d.ts

View file

@ -1 +0,0 @@
types.generated.d.ts