diff --git a/.changeset/curvy-foxes-reply.md b/.changeset/curvy-foxes-reply.md new file mode 100644 index 000000000000..99eb66d6980c --- /dev/null +++ b/.changeset/curvy-foxes-reply.md @@ -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. diff --git a/examples/with-content/src/content/types.generated.d.ts b/examples/with-content/.astro/types.d.ts similarity index 67% rename from examples/with-content/src/content/types.generated.d.ts rename to examples/with-content/.astro/types.d.ts index d9d0b5667df9..37355d003481 100644 --- a/examples/with-content/src/content/types.generated.d.ts +++ b/examples/with-content/.astro/types.d.ts @@ -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"); } diff --git a/examples/with-content/src/env.d.ts b/examples/with-content/src/env.d.ts index f964fe0cffd8..acef35f175aa 100644 --- a/examples/with-content/src/env.d.ts +++ b/examples/with-content/src/env.d.ts @@ -1 +1,2 @@ +/// /// diff --git a/packages/astro/src/cli/sync/index.ts b/packages/astro/src/cli/sync/index.ts index 251599dcba01..c91bd9baccf1 100644 --- a/packages/astro/src/cli/sync/index.ts +++ b/packages/astro/src/cli/sync/index.ts @@ -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; } diff --git a/packages/astro/src/content/consts.ts b/packages/astro/src/content/consts.ts index 8ac53f933379..38379a55896b 100644 --- a/packages/astro/src/content/consts.ts +++ b/packages/astro/src/content/consts.ts @@ -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'; diff --git a/packages/astro/src/content/index.ts b/packages/astro/src/content/index.ts index 38eb1cf9f9e8..f4fab01aeb5d 100644 --- a/packages/astro/src/content/index.ts +++ b/packages/astro/src/content/index.ts @@ -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, diff --git a/packages/astro/src/content/template/types.generated.d.ts b/packages/astro/src/content/template/types.d.ts similarity index 100% rename from packages/astro/src/content/template/types.generated.d.ts rename to packages/astro/src/content/template/types.d.ts diff --git a/packages/astro/src/content/template/types.generated.mjs b/packages/astro/src/content/template/virtual-mod.mjs similarity index 100% rename from packages/astro/src/content/template/types.generated.mjs rename to packages/astro/src/content/template/virtual-mod.mjs diff --git a/packages/astro/src/content/types-generator.ts b/packages/astro/src/content/types-generator.ts index 667a667c2698..7133688bf64f 100644 --- a/packages/astro/src/content/types-generator.ts +++ b/packages/astro/src/content/types-generator.ts @@ -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 { 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) ); diff --git a/packages/astro/src/content/utils.ts b/packages/astro/src/content/utils.ts index a6fcd13b135c..a2599a9a67ee 100644 --- a/packages/astro/src/content/utils.ts +++ b/packages/astro/src/content/utils.ts @@ -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 `/// `; +} + export const contentConfigParser = z.object({ collections: z.record(collectionConfigParser), }); @@ -201,7 +211,7 @@ export async function loadContentConfig({ fs: typeof fsMod; settings: AstroSettings; }): Promise { - 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): 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), }; } diff --git a/packages/astro/src/content/vite-plugin-content-server.ts b/packages/astro/src/content/vite-plugin-content-server.ts index 5030ec0020b9..347ebbe1854b 100644 --- a/packages/astro/src/content/vite-plugin-content-server.ts +++ b/packages/astro/src/content/vite-plugin-content-server.ts @@ -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' }); diff --git a/packages/astro/src/content/vite-plugin-content-virtual-mod.ts b/packages/astro/src/content/vite-plugin-content-virtual-mod.ts index b2f38bee5228..120083ebfc37 100644 --- a/packages/astro/src/content/vite-plugin-content-virtual-mod.ts +++ b/packages/astro/src/content/vite-plugin-content-virtual-mod.ts @@ -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, }; } }, diff --git a/packages/astro/src/core/create-vite.ts b/packages/astro/src/core/create-vite.ts index cccda8545d9f..c61f04b3f2c1 100644 --- a/packages/astro/src/core/create-vite.ts +++ b/packages/astro/src/core/create-vite.ts @@ -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 }), diff --git a/packages/astro/src/vite-plugin-inject-env-ts/index.ts b/packages/astro/src/vite-plugin-inject-env-ts/index.ts new file mode 100644 index 000000000000..3440b3dc15db --- /dev/null +++ b/packages/astro/src/vite-plugin-inject-env-ts/index.ts @@ -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('/// '); + } else { + referenceDefs.push('/// '); + } + + 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`); + } +} diff --git a/packages/astro/test/astro-sync.test.js b/packages/astro/test/astro-sync.test.js new file mode 100644 index 000000000000..5f1fb2d14890 --- /dev/null +++ b/packages/astro/test/astro-sync.test.js @@ -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 `/// `; + } 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(`/// `); + }); + + 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(`/// `); + expect(writtenFiles[typesEnvPath]).to.include(`/// `); + }); +}); diff --git a/packages/astro/test/content-collections.test.js b/packages/astro/test/content-collections.test.js index 66b4fdf36629..4a900bdfc9d3 100644 --- a/packages/astro/test/content-collections.test.js +++ b/packages/astro/test/content-collections.test.js @@ -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 () => { diff --git a/packages/astro/test/fixtures/.gitignore b/packages/astro/test/fixtures/.gitignore new file mode 100644 index 000000000000..7f0fa55b0a86 --- /dev/null +++ b/packages/astro/test/fixtures/.gitignore @@ -0,0 +1,2 @@ +.astro/ +env.d.ts diff --git a/packages/astro/test/fixtures/content-collections/.gitignore b/packages/astro/test/fixtures/content-collections/.gitignore deleted file mode 100644 index 54f79dcd65e7..000000000000 --- a/packages/astro/test/fixtures/content-collections/.gitignore +++ /dev/null @@ -1 +0,0 @@ -types.generated.d.ts diff --git a/packages/astro/test/fixtures/content-ssr-integration/.gitignore b/packages/astro/test/fixtures/content-ssr-integration/.gitignore deleted file mode 100644 index 54f79dcd65e7..000000000000 --- a/packages/astro/test/fixtures/content-ssr-integration/.gitignore +++ /dev/null @@ -1 +0,0 @@ -types.generated.d.ts diff --git a/packages/astro/test/fixtures/content-static-paths-integration/.gitignore b/packages/astro/test/fixtures/content-static-paths-integration/.gitignore deleted file mode 100644 index 54f79dcd65e7..000000000000 --- a/packages/astro/test/fixtures/content-static-paths-integration/.gitignore +++ /dev/null @@ -1 +0,0 @@ -types.generated.d.ts diff --git a/packages/astro/test/fixtures/content/.gitignore b/packages/astro/test/fixtures/content/.gitignore deleted file mode 100644 index 54f79dcd65e7..000000000000 --- a/packages/astro/test/fixtures/content/.gitignore +++ /dev/null @@ -1 +0,0 @@ -types.generated.d.ts