[Content] Add astro sync
type gen command (#5647)
* feat: add `astro sync` command * chore: move fixture types.generated to gitignore * test: types generate with astro:content * chore: changeset * docs: Astro error for CLI errors
This commit is contained in:
parent
68c20be66b
commit
d72da52907
11 changed files with 118 additions and 133 deletions
5
.changeset/good-suns-mate.md
Normal file
5
.changeset/good-suns-mate.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'astro': minor
|
||||
---
|
||||
|
||||
Add `astro sync` CLI command for type generation
|
|
@ -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');
|
||||
|
||||
|
|
31
packages/astro/src/cli/sync/index.ts
Normal file
31
packages/astro/src/cli/sync/index.ts
Normal file
|
@ -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;
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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<string, Record<string, ContentTypesEntryMetadata>>;
|
||||
|
||||
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<GenerateContentTypes> {
|
||||
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'
|
||||
);
|
||||
|
|
|
@ -60,7 +60,6 @@ export function astroContentServerPlugin({
|
|||
settings,
|
||||
logging,
|
||||
contentConfigObserver,
|
||||
contentPaths,
|
||||
});
|
||||
await contentGenerator.init();
|
||||
info(logging, 'content', 'Types generated');
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
1
packages/astro/test/fixtures/content-collections/.gitignore
vendored
Normal file
1
packages/astro/test/fixtures/content-collections/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
types.generated.d.ts
|
|
@ -1,125 +0,0 @@
|
|||
declare module 'astro:content' {
|
||||
export { z } from 'astro/zod';
|
||||
export type CollectionEntry<C extends keyof typeof entryMap> =
|
||||
typeof entryMap[C][keyof typeof entryMap[C]] & Render;
|
||||
|
||||
type BaseCollectionConfig<S extends import('astro/zod').ZodRawShape> = {
|
||||
schema?: S;
|
||||
slug?: (entry: {
|
||||
id: CollectionEntry<keyof typeof entryMap>['id'];
|
||||
defaultSlug: string;
|
||||
collection: string;
|
||||
body: string;
|
||||
data: import('astro/zod').infer<import('astro/zod').ZodObject<S>>;
|
||||
}) => string | Promise<string>;
|
||||
};
|
||||
export function defineCollection<S extends import('astro/zod').ZodRawShape>(
|
||||
input: BaseCollectionConfig<S>
|
||||
): BaseCollectionConfig<S>;
|
||||
|
||||
export function getEntry<C extends keyof typeof entryMap, E extends keyof typeof entryMap[C]>(
|
||||
collection: C,
|
||||
entryKey: E
|
||||
): Promise<typeof entryMap[C][E] & Render>;
|
||||
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<C extends keyof typeof entryMap> = import('astro/zod').infer<
|
||||
import('astro/zod').ZodObject<Required<ContentConfig['collections'][C]>['schema']>
|
||||
>;
|
||||
|
||||
type Render = {
|
||||
render(): Promise<{
|
||||
Content: import('astro').MarkdownInstance<{}>['Content'];
|
||||
headings: import('astro').MarkdownHeading[];
|
||||
injectedFrontmatter: Record<string, any>;
|
||||
}>;
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue