diff --git a/.changeset/good-suns-mate.md b/.changeset/good-suns-mate.md new file mode 100644 index 000000000..20ff5f251 --- /dev/null +++ b/.changeset/good-suns-mate.md @@ -0,0 +1,5 @@ +--- +'astro': minor +--- + +Add `astro sync` CLI command for type generation diff --git a/packages/astro/src/cli/index.ts b/packages/astro/src/cli/index.ts index 0a0d84b45..02a9075ec 100644 --- a/packages/astro/src/cli/index.ts +++ b/packages/astro/src/cli/index.ts @@ -31,6 +31,7 @@ type CLICommand = | 'build' | 'preview' | 'reload' + | 'sync' | 'check' | 'telemetry'; @@ -48,6 +49,7 @@ function printAstroHelp() { ['dev', 'Start the development server.'], ['docs', 'Open documentation in your web browser.'], ['preview', 'Preview your build locally.'], + ['sync', 'Generate content collection types.'], ['telemetry', 'Configure telemetry settings.'], ], 'Global Flags': [ @@ -74,6 +76,7 @@ async function printVersion() { function resolveCommand(flags: Arguments): CLICommand { const cmd = flags._[2] as string; if (cmd === 'add') return 'add'; + if (cmd === 'sync') return 'sync'; if (cmd === 'telemetry') return 'telemetry'; if (flags.version) return 'version'; else if (flags.help) return 'help'; @@ -202,6 +205,13 @@ async function runCommand(cmd: string, flags: yargs.Arguments) { return process.exit(ret); } + case 'sync': { + const { sync } = await import('./sync/index.js'); + + const ret = await sync(settings, { logging, fs }); + return process.exit(ret); + } + case 'preview': { const { default: preview } = await import('../core/preview/index.js'); diff --git a/packages/astro/src/cli/sync/index.ts b/packages/astro/src/cli/sync/index.ts new file mode 100644 index 000000000..0e63fb02e --- /dev/null +++ b/packages/astro/src/cli/sync/index.ts @@ -0,0 +1,31 @@ +import type fsMod from 'node:fs'; +import { performance } from 'node:perf_hooks'; +import { dim } from 'kleur/colors'; +import type { AstroSettings } from '../../@types/astro'; +import { info, LogOptions } from '../../core/logger/core.js'; +import { contentObservable, createContentTypesGenerator } from '../../content/index.js'; +import { getTimeStat } from '../../core/build/util.js'; +import { AstroError, AstroErrorData } from '../../core/errors/index.js'; + +export async function sync( + settings: AstroSettings, + { logging, fs }: { logging: LogOptions; fs: typeof fsMod } +): Promise<0 | 1> { + const timerStart = performance.now(); + + try { + const contentTypesGenerator = await createContentTypesGenerator({ + contentConfigObserver: contentObservable({ status: 'loading' }), + logging, + fs, + settings, + }); + await contentTypesGenerator.init(); + } catch (e) { + throw new AstroError(AstroErrorData.GenerateContentTypesError); + } + + info(logging, 'content', `Types generated ${dim(getTimeStat(timerStart, performance.now()))}`); + + return 0; +} diff --git a/packages/astro/src/content/index.ts b/packages/astro/src/content/index.ts index 15b61b5f2..ccb36982d 100644 --- a/packages/astro/src/content/index.ts +++ b/packages/astro/src/content/index.ts @@ -5,3 +5,5 @@ export { } from './vite-plugin-content-assets.js'; export { astroContentServerPlugin } from './vite-plugin-content-server.js'; export { astroContentVirtualModPlugin } from './vite-plugin-content-virtual-mod.js'; +export { contentObservable } from './utils.js'; +export { createContentTypesGenerator } from './types-generator.js'; diff --git a/packages/astro/src/content/types-generator.ts b/packages/astro/src/content/types-generator.ts index 5162cbcd5..9b9f925b8 100644 --- a/packages/astro/src/content/types-generator.ts +++ b/packages/astro/src/content/types-generator.ts @@ -1,6 +1,6 @@ import glob from 'fast-glob'; import { cyan } from 'kleur/colors'; -import fsMod from 'node:fs'; +import type fsMod from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { normalizePath } from 'vite'; @@ -8,7 +8,13 @@ import type { AstroSettings } from '../@types/astro.js'; import { info, LogOptions, warn } from '../core/logger/core.js'; import { appendForwardSlash, isRelativePath } from '../core/path.js'; import { contentFileExts, CONTENT_TYPES_FILE } from './consts.js'; -import { ContentConfig, ContentObservable, ContentPaths, loadContentConfig } from './utils.js'; +import { + ContentConfig, + ContentObservable, + ContentPaths, + getContentPaths, + loadContentConfig, +} from './utils.js'; type ChokidarEvent = 'add' | 'addDir' | 'change' | 'unlink' | 'unlinkDir'; type RawContentEvent = { name: ChokidarEvent; entry: string }; @@ -28,7 +34,6 @@ type ContentTypesEntryMetadata = { slug: string }; type ContentTypes = Record>; type CreateContentGeneratorParams = { - contentPaths: ContentPaths; contentConfigObserver: ContentObservable; logging: LogOptions; settings: AstroSettings; @@ -40,18 +45,18 @@ type EventOpts = { logLevel: 'info' | 'warn' }; class UnsupportedFileTypeError extends Error {} export async function createContentTypesGenerator({ - contentPaths, contentConfigObserver, fs, logging, settings, }: CreateContentGeneratorParams): Promise { const contentTypes: ContentTypes = {}; + const contentPaths: ContentPaths = getContentPaths({ srcDir: settings.config.srcDir }); let events: Promise<{ shouldGenerateTypes: boolean; error?: Error }>[] = []; let debounceTimeout: NodeJS.Timeout | undefined; - const contentTypesBase = await fsMod.promises.readFile( + const contentTypesBase = await fs.promises.readFile( new URL(CONTENT_TYPES_FILE, contentPaths.generatedInputDir), 'utf-8' ); diff --git a/packages/astro/src/content/vite-plugin-content-server.ts b/packages/astro/src/content/vite-plugin-content-server.ts index 5df38b742..a56df3b42 100644 --- a/packages/astro/src/content/vite-plugin-content-server.ts +++ b/packages/astro/src/content/vite-plugin-content-server.ts @@ -60,7 +60,6 @@ export function astroContentServerPlugin({ settings, logging, contentConfigObserver, - contentPaths, }); await contentGenerator.init(); info(logging, 'content', 'Types generated'); diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index cc6e164c5..57cf9f4d6 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -552,6 +552,30 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati message: (legacyConfigKey: string) => `Legacy configuration detected: \`${legacyConfigKey}\`.`, hint: 'Please update your configuration to the new format.\nSee https://astro.build/config for more information.', }, + /** + * @docs + * @kind heading + * @name CLI Errors + */ + // CLI Errors - 8xxx + UnknownCLIError: { + title: 'Unknown CLI Error.', + code: 8000, + }, + /** + * @docs + * @description + * `astro sync` command failed to generate content collection types. + * @see + * - [Content collections documentation](https://docs.astro.build/en/guides/content-collections/) + */ + GenerateContentTypesError: { + title: 'Failed to generate content types.', + code: 8001, + message: '`astro sync` command failed to generate content collection types.', + hint: 'Check your `src/content/config.*` file for typos.', + }, + // Generic catch-all UnknownError: { title: 'Unknown Error.', diff --git a/packages/astro/test/content-collections.test.js b/packages/astro/test/content-collections.test.js index d52521959..a66287080 100644 --- a/packages/astro/test/content-collections.test.js +++ b/packages/astro/test/content-collections.test.js @@ -1,10 +1,41 @@ +import * as fs from 'node:fs'; +import * as devalue from 'devalue'; +import * as cheerio from 'cheerio'; import { expect } from 'chai'; import { loadFixture } from './test-utils.js'; import testAdapter from './test-adapter.js'; -import * as devalue from 'devalue'; -import * as cheerio from 'cheerio'; 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/content-collections/.gitignore b/packages/astro/test/fixtures/content-collections/.gitignore new file mode 100644 index 000000000..54f79dcd6 --- /dev/null +++ b/packages/astro/test/fixtures/content-collections/.gitignore @@ -0,0 +1 @@ +types.generated.d.ts diff --git a/packages/astro/test/fixtures/content-collections/src/content/types.generated.d.ts b/packages/astro/test/fixtures/content-collections/src/content/types.generated.d.ts deleted file mode 100644 index bf03efb76..000000000 --- a/packages/astro/test/fixtures/content-collections/src/content/types.generated.d.ts +++ /dev/null @@ -1,125 +0,0 @@ -declare module 'astro:content' { - export { z } from 'astro/zod'; - export type CollectionEntry = - typeof entryMap[C][keyof typeof entryMap[C]] & Render; - - type BaseCollectionConfig = { - schema?: S; - slug?: (entry: { - id: CollectionEntry['id']; - defaultSlug: string; - collection: string; - body: string; - data: import('astro/zod').infer>; - }) => string | Promise; - }; - export function defineCollection( - input: BaseCollectionConfig - ): BaseCollectionConfig; - - export function getEntry( - collection: C, - entryKey: E - ): Promise; - export function getCollection< - C extends keyof typeof entryMap, - E extends keyof typeof entryMap[C] - >( - collection: C, - filter?: (data: typeof entryMap[C][E]) => boolean - ): Promise<(typeof entryMap[C][E] & Render)[]>; - - type InferEntrySchema = import('astro/zod').infer< - import('astro/zod').ZodObject['schema']> - >; - - type Render = { - render(): Promise<{ - Content: import('astro').MarkdownInstance<{}>['Content']; - headings: import('astro').MarkdownHeading[]; - injectedFrontmatter: Record; - }>; - }; - - const entryMap: { - "with-schema-config": { -"one.md": { - id: "one.md", - slug: "one", - body: string, - collection: "with-schema-config", - data: InferEntrySchema<"with-schema-config"> -}, -"three.md": { - id: "three.md", - slug: "three", - body: string, - collection: "with-schema-config", - data: InferEntrySchema<"with-schema-config"> -}, -"two.md": { - id: "two.md", - slug: "two", - body: string, - collection: "with-schema-config", - data: InferEntrySchema<"with-schema-config"> -}, -}, -"with-slug-config": { -"one.md": { - id: "one.md", - slug: string, - body: string, - collection: "with-slug-config", - data: InferEntrySchema<"with-slug-config"> -}, -"three.md": { - id: "three.md", - slug: string, - body: string, - collection: "with-slug-config", - data: InferEntrySchema<"with-slug-config"> -}, -"two.md": { - id: "two.md", - slug: string, - body: string, - collection: "with-slug-config", - data: InferEntrySchema<"with-slug-config"> -}, -}, -"without-config": { -"columbia.md": { - id: "columbia.md", - slug: "columbia", - body: string, - collection: "without-config", - data: any -}, -"endeavour.md": { - id: "endeavour.md", - slug: "endeavour", - body: string, - collection: "without-config", - data: any -}, -"enterprise.md": { - id: "enterprise.md", - slug: "enterprise", - body: string, - collection: "without-config", - data: any -}, -"promo/launch-week.mdx": { - id: "promo/launch-week.mdx", - slug: "promo/launch-week", - body: string, - collection: "without-config", - data: any -}, -}, - - }; - - type ContentConfig = typeof import("./config"); -} diff --git a/packages/astro/test/test-utils.js b/packages/astro/test/test-utils.js index 3c14a9194..3e370e647 100644 --- a/packages/astro/test/test-utils.js +++ b/packages/astro/test/test-utils.js @@ -7,6 +7,7 @@ import { createSettings } from '../dist/core/config/index.js'; import dev from '../dist/core/dev/index.js'; import build from '../dist/core/build/index.js'; import preview from '../dist/core/preview/index.js'; +import { sync } from '../dist/cli/sync/index.js'; import { nodeLogDestination } from '../dist/core/logger/node.js'; import os from 'os'; import stripAnsi from 'strip-ansi'; @@ -139,6 +140,7 @@ export async function loadFixture(inlineConfig) { return { build: (opts = {}) => build(settings, { logging, telemetry, ...opts }), + sync: (opts) => sync(settings, { logging, fs, ...opts }), startDevServer: async (opts = {}) => { devServer = await dev(settings, { logging, telemetry, ...opts }); config.server.host = parseAddressToHost(devServer.address.address); // update host