diff options
22 files changed, 359 insertions, 6 deletions
diff --git a/.changeset/slow-items-heal.md b/.changeset/slow-items-heal.md new file mode 100644 index 000000000..0095974f6 --- /dev/null +++ b/.changeset/slow-items-heal.md @@ -0,0 +1,44 @@ +--- +"astro": minor +--- + +Adds experimental JSON Schema support for content collections. + +This feature will auto-generate a JSON Schema for content collections of `type: 'data'` which can be used as the `$schema` value for TypeScript-style autocompletion/hints in tools like VSCode. + +To enable this feature, add the experimental flag: + +```diff +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + experimental: { ++ contentCollectionJsonSchema: true + } +}); +``` + +This experimental implementation requires you to manually reference the schema in each data entry file of the collection: + +```diff +// src/content/test/entry.json +{ ++ "$schema": "../../../.astro/collections/test.schema.json", + "test": "test" +} +``` + +Alternatively, you can set this in your [VSCode `json.schemas` settings](https://code.visualstudio.com/docs/languages/json#_json-schemas-and-settings): + +```diff +"json.schemas": [ + { + "fileMatch": [ + "/src/content/test/**" + ], + "url": "../../../.astro/collections/test.schema.json" + } +] +``` + +Note that this initial implementation uses a library with [known issues for advanced Zod schemas](https://github.com/StefanTerdell/zod-to-json-schema#known-issues), so you may wish to consult these limitations before enabling the experimental flag. diff --git a/packages/astro/package.json b/packages/astro/package.json index b1bbf0b2f..ea8393f5d 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -177,7 +177,8 @@ "vitefu": "^0.2.5", "which-pm": "^2.1.1", "yargs-parser": "^21.1.1", - "zod": "^3.22.4" + "zod": "^3.22.4", + "zod-to-json-schema": "^3.22.4" }, "optionalDependencies": { "sharp": "^0.32.6" diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 932d656e7..74d557924 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1646,6 +1646,54 @@ export interface AstroUserConfig { /** * @docs + * @name experimental.contentCollectionJsonSchema + * @type {boolean} + * @default `false` + * @version 4.5.0 + * @description + * This feature will auto-generate a JSON schema for content collections of `type: 'data'` which can be used as the `$schema` value for TypeScript-style autocompletion/hints in tools like VSCode. + * + * To enable this feature, add the experimental flag: + * + * ```diff + * import { defineConfig } from 'astro/config'; + + * export default defineConfig({ + * experimental: { + * + contentCollectionJsonSchema: true + * } + * }); + * ``` + * + * This experimental implementation requires you to manually reference the schema in each data entry file of the collection: + * + * ```diff + * // src/content/test/entry.json + * { + * + "$schema": "../../../.astro/collections/test.schema.json", + * "test": "test" + * } + * ``` + * + * Alternatively, you can set this in your [VSCode `json.schemas` settings](https://code.visualstudio.com/docs/languages/json#_json-schemas-and-settings): + * + * ```diff + * "json.schemas": [ + * { + * "fileMatch": [ + * "/src/content/test/**" + * ], + * "url": "../../../.astro/collections/test.schema.json" + * } + * ] + * ``` + * + * Note that this initial implementation uses a library with [known issues for advanced Zod schemas](https://github.com/StefanTerdell/zod-to-json-schema#known-issues), so you may wish to consult these limitations before enabling the experimental flag. + */ + contentCollectionJsonSchema?: boolean; + + /** + * @docs * @name experimental.clientPrerender * @type {boolean} * @default `false` diff --git a/packages/astro/src/content/types-generator.ts b/packages/astro/src/content/types-generator.ts index a16105ed7..5eabbc3c1 100644 --- a/packages/astro/src/content/types-generator.ts +++ b/packages/astro/src/content/types-generator.ts @@ -4,6 +4,8 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import glob from 'fast-glob'; import { bold, cyan } from 'kleur/colors'; import { type ViteDevServer, normalizePath } from 'vite'; +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; import type { AstroSettings, ContentEntryType } from '../@types/astro.js'; import { AstroError } from '../core/errors/errors.js'; import { AstroErrorData } from '../core/errors/index.js'; @@ -119,7 +121,10 @@ export async function createContentTypesGenerator({ switch (event.name) { case 'addDir': - collectionEntryMap[JSON.stringify(collection)] = { type: 'unknown', entries: {} }; + collectionEntryMap[JSON.stringify(collection)] = { + type: 'unknown', + entries: {}, + }; logger.debug('content', `${cyan(collection)} collection added`); break; case 'unlinkDir': @@ -204,7 +209,11 @@ export async function createContentTypesGenerator({ const contentEntryType = contentEntryConfigByExt.get(path.extname(event.entry.pathname)); if (!contentEntryType) return { shouldGenerateTypes: false }; - const { id, slug: generatedSlug } = getContentEntryIdAndSlug({ entry, contentDir, collection }); + const { id, slug: generatedSlug } = getContentEntryIdAndSlug({ + entry, + contentDir, + collection, + }); const collectionKey = JSON.stringify(collection); if (!(collectionKey in collectionEntryMap)) { @@ -237,7 +246,10 @@ export async function createContentTypesGenerator({ if (!(entryKey in collectionEntryMap[collectionKey].entries)) { collectionEntryMap[collectionKey] = { type: 'content', - entries: { ...collectionInfo.entries, [entryKey]: { slug: addedSlug } }, + entries: { + ...collectionInfo.entries, + [entryKey]: { slug: addedSlug }, + }, }; } return { shouldGenerateTypes: true }; @@ -310,6 +322,8 @@ export async function createContentTypesGenerator({ contentConfig: observable.status === 'loaded' ? observable.config : undefined, contentEntryTypes: settings.contentEntryTypes, viteServer, + logger, + settings, }); invalidateVirtualMod(viteServer); } @@ -352,6 +366,8 @@ async function writeContentFiles({ contentEntryTypes, contentConfig, viteServer, + logger, + settings, }: { fs: typeof fsMod; contentPaths: ContentPaths; @@ -360,11 +376,25 @@ async function writeContentFiles({ contentEntryTypes: Pick<ContentEntryType, 'contentModuleTypes'>[]; contentConfig?: ContentConfig; viteServer: Pick<ViteDevServer, 'hot'>; + logger: Logger; + settings: AstroSettings; }) { let contentTypesStr = ''; let dataTypesStr = ''; + + const collectionSchemasDir = new URL('./collections/', contentPaths.cacheDir); + if ( + settings.config.experimental.contentCollectionJsonSchema && + !fs.existsSync(collectionSchemasDir) + ) { + fs.mkdirSync(collectionSchemasDir, { recursive: true }); + } + for (const [collection, config] of Object.entries(contentConfig?.collections ?? {})) { - collectionEntryMap[JSON.stringify(collection)] ??= { type: config.type, entries: {} }; + collectionEntryMap[JSON.stringify(collection)] ??= { + type: config.type, + entries: {}, + }; } for (const collectionKey of Object.keys(collectionEntryMap).sort()) { const collectionConfig = contentConfig?.collections[JSON.parse(collectionKey)]; @@ -387,7 +417,9 @@ async function writeContentFiles({ collection.type === 'data' ? "Try adding `type: 'data'` to your collection config." : undefined, - location: { file: '' /** required for error overlay `hot` messages */ }, + location: { + file: '' /** required for error overlay `hot` messages */, + }, }) as any, }); return; @@ -419,6 +451,36 @@ async function writeContentFiles({ for (const entryKey of Object.keys(collection.entries).sort()) { const dataType = collectionConfig?.schema ? `InferEntrySchema<${collectionKey}>` : 'any'; dataTypesStr += `${entryKey}: {\n id: ${entryKey};\n collection: ${collectionKey};\n data: ${dataType}\n};\n`; + if ( + settings.config.experimental.contentCollectionJsonSchema && + collectionConfig?.schema + ) { + let zodSchemaForJson = collectionConfig.schema; + if (zodSchemaForJson instanceof z.ZodObject) { + zodSchemaForJson = zodSchemaForJson.extend({ + $schema: z.string().optional(), + }); + } + try { + await fs.promises.writeFile( + new URL(`./${collectionKey.replace(/"/g, '')}.schema.json`, collectionSchemasDir), + JSON.stringify( + zodToJsonSchema(zodSchemaForJson, { + name: collectionKey.replace(/"/g, ''), + markdownDescription: true, + errorMessages: true, + }), + null, + 2 + ) + ); + } catch (err) { + logger.warn( + 'content', + `An error was encountered while creating the JSON schema. Proceeding without it. Error: ${err}` + ); + } + } } dataTypesStr += `};\n`; break; diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 4c0276ddf..3af553993 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -60,6 +60,7 @@ const ASTRO_CONFIG_DEFAULTS = { experimental: { optimizeHoistedScript: false, contentCollectionCache: false, + contentCollectionJsonSchema: false, clientPrerender: false, globalRoutePriority: false, i18nDomains: false, @@ -458,6 +459,10 @@ export const AstroConfigSchema = z.object({ .boolean() .optional() .default(ASTRO_CONFIG_DEFAULTS.experimental.contentCollectionCache), + contentCollectionJsonSchema: z + .boolean() + .optional() + .default(ASTRO_CONFIG_DEFAULTS.experimental.contentCollectionJsonSchema), clientPrerender: z .boolean() .optional() diff --git a/packages/astro/test/data-collections-schema.test.js b/packages/astro/test/data-collections-schema.test.js new file mode 100644 index 000000000..7a726802e --- /dev/null +++ b/packages/astro/test/data-collections-schema.test.js @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; +import { loadFixture } from './test-utils.js'; + +describe('Content Collections - data collections', () => { + let fixture; + before(async () => { + fixture = await loadFixture({ root: './fixtures/data-collections-schema/' }); + await fixture.build(); + }); + + describe('Translations Collection', () => { + it('Generates schema file', async () => { + const schemaExists = await fixture.pathExists('../.astro/collections/i18n.schema.json'); + assert.equal(schemaExists, true); + }); + + it('Generates valid schema file', async () => { + const rawJson = await fixture.readFile('../.astro/collections/i18n.schema.json'); + assert.deepEqual( + JSON.stringify({ + $ref: '#/definitions/i18n', + definitions: { + i18n: { + type: 'object', + properties: { + homepage: { + type: 'object', + properties: { + greeting: { + type: 'string', + }, + preamble: { + type: 'string', + }, + }, + required: ['greeting', 'preamble'], + additionalProperties: false, + }, + $schema: { + type: 'string', + }, + }, + required: ['homepage'], + additionalProperties: false, + }, + }, + $schema: 'http://json-schema.org/draft-07/schema#', + }), + JSON.stringify(JSON.parse(rawJson)) + ); + }); + }); +}); diff --git a/packages/astro/test/fixtures/data-collections-schema/astro.config.mjs b/packages/astro/test/fixtures/data-collections-schema/astro.config.mjs new file mode 100644 index 000000000..59e5784d1 --- /dev/null +++ b/packages/astro/test/fixtures/data-collections-schema/astro.config.mjs @@ -0,0 +1,8 @@ +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({ + experimental: { + contentCollectionJsonSchema: true + } +}); diff --git a/packages/astro/test/fixtures/data-collections-schema/package.json b/packages/astro/test/fixtures/data-collections-schema/package.json new file mode 100644 index 000000000..77b213415 --- /dev/null +++ b/packages/astro/test/fixtures/data-collections-schema/package.json @@ -0,0 +1,16 @@ +{ + "name": "@test/data-collections-schema", + "type": "module", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro build", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/data-collections-schema/src/content/authors-without-config/Ben Holmes.yml b/packages/astro/test/fixtures/data-collections-schema/src/content/authors-without-config/Ben Holmes.yml new file mode 100644 index 000000000..54e6743d9 --- /dev/null +++ b/packages/astro/test/fixtures/data-collections-schema/src/content/authors-without-config/Ben Holmes.yml @@ -0,0 +1,2 @@ +name: Ben J Holmes +twitter: https://twitter.com/bholmesdev diff --git a/packages/astro/test/fixtures/data-collections-schema/src/content/authors-without-config/Fred K Schott.yml b/packages/astro/test/fixtures/data-collections-schema/src/content/authors-without-config/Fred K Schott.yml new file mode 100644 index 000000000..0b51067d9 --- /dev/null +++ b/packages/astro/test/fixtures/data-collections-schema/src/content/authors-without-config/Fred K Schott.yml @@ -0,0 +1,2 @@ +name: Fred K Schott +twitter: https://twitter.com/FredKSchott diff --git a/packages/astro/test/fixtures/data-collections-schema/src/content/authors-without-config/Nate Moore.yml b/packages/astro/test/fixtures/data-collections-schema/src/content/authors-without-config/Nate Moore.yml new file mode 100644 index 000000000..953f348a0 --- /dev/null +++ b/packages/astro/test/fixtures/data-collections-schema/src/content/authors-without-config/Nate Moore.yml @@ -0,0 +1,2 @@ +name: Nate Something Moore +twitter: https://twitter.com/n_moore diff --git a/packages/astro/test/fixtures/data-collections-schema/src/content/config.ts b/packages/astro/test/fixtures/data-collections-schema/src/content/config.ts new file mode 100644 index 000000000..5f3de9423 --- /dev/null +++ b/packages/astro/test/fixtures/data-collections-schema/src/content/config.ts @@ -0,0 +1,20 @@ +import { defineCollection, z } from 'astro:content'; + +const docs = defineCollection({ + type: 'content', + schema: z.object({ + title: z.string(), + }) +}); + +const i18n = defineCollection({ + type: 'data', + schema: z.object({ + homepage: z.object({ + greeting: z.string(), + preamble: z.string(), + }) + }), +}); + +export const collections = { docs, i18n }; diff --git a/packages/astro/test/fixtures/data-collections-schema/src/content/docs/example.md b/packages/astro/test/fixtures/data-collections-schema/src/content/docs/example.md new file mode 100644 index 000000000..356e65f64 --- /dev/null +++ b/packages/astro/test/fixtures/data-collections-schema/src/content/docs/example.md @@ -0,0 +1,3 @@ +--- +title: The future of content +--- diff --git a/packages/astro/test/fixtures/data-collections-schema/src/content/i18n/en.json b/packages/astro/test/fixtures/data-collections-schema/src/content/i18n/en.json new file mode 100644 index 000000000..51d127f4a --- /dev/null +++ b/packages/astro/test/fixtures/data-collections-schema/src/content/i18n/en.json @@ -0,0 +1,6 @@ +{ + "homepage": { + "greeting": "Hello World!", + "preamble": "Welcome to the future of content." + } +} diff --git a/packages/astro/test/fixtures/data-collections-schema/src/content/i18n/es.json b/packages/astro/test/fixtures/data-collections-schema/src/content/i18n/es.json new file mode 100644 index 000000000..bf4c7af0f --- /dev/null +++ b/packages/astro/test/fixtures/data-collections-schema/src/content/i18n/es.json @@ -0,0 +1,6 @@ +{ + "homepage": { + "greeting": "¡Hola Mundo!", + "preamble": "Bienvenido al futuro del contenido." + } +} diff --git a/packages/astro/test/fixtures/data-collections-schema/src/content/i18n/fr.yaml b/packages/astro/test/fixtures/data-collections-schema/src/content/i18n/fr.yaml new file mode 100644 index 000000000..90a86d411 --- /dev/null +++ b/packages/astro/test/fixtures/data-collections-schema/src/content/i18n/fr.yaml @@ -0,0 +1,3 @@ +homepage: + greeting: "Bonjour le monde!" + preamble: "Bienvenue dans le futur du contenu." diff --git a/packages/astro/test/fixtures/data-collections-schema/src/pages/authors/[id].json.js b/packages/astro/test/fixtures/data-collections-schema/src/pages/authors/[id].json.js new file mode 100644 index 000000000..8d5365a2e --- /dev/null +++ b/packages/astro/test/fixtures/data-collections-schema/src/pages/authors/[id].json.js @@ -0,0 +1,18 @@ +import { getEntry } from 'astro:content'; + +const ids = ['Ben Holmes', 'Fred K Schott', 'Nate Moore']; + +export function getStaticPaths() { + return ids.map((id) => ({ params: { id } })); +} + +/** @param {import('astro').APIContext} params */ +export async function GET({ params }) { + const { id } = params; + const author = await getEntry('authors-without-config', id); + if (!author) { + return Response.json({ error: `Author ${id} Not found` }); + } else { + return Response.json(author); + } +} diff --git a/packages/astro/test/fixtures/data-collections-schema/src/pages/authors/all.json.js b/packages/astro/test/fixtures/data-collections-schema/src/pages/authors/all.json.js new file mode 100644 index 000000000..79dd8cd9d --- /dev/null +++ b/packages/astro/test/fixtures/data-collections-schema/src/pages/authors/all.json.js @@ -0,0 +1,6 @@ +import { getCollection } from 'astro:content'; + +export async function GET() { + const authors = await getCollection('authors-without-config'); + return Response.json(authors); +} diff --git a/packages/astro/test/fixtures/data-collections-schema/src/pages/translations/[lang].json.js b/packages/astro/test/fixtures/data-collections-schema/src/pages/translations/[lang].json.js new file mode 100644 index 000000000..c6b0cfff6 --- /dev/null +++ b/packages/astro/test/fixtures/data-collections-schema/src/pages/translations/[lang].json.js @@ -0,0 +1,18 @@ +import { getEntry } from 'astro:content'; + +const langs = ['en', 'es', 'fr']; + +export function getStaticPaths() { + return langs.map((lang) => ({ params: { lang } })); +} + +/** @param {import('astro').APIContext} params */ +export async function GET({ params }) { + const { lang } = params; + const translations = await getEntry('i18n', lang); + if (!translations) { + return Response.json({ error: `Translation ${lang} Not found` }); + } else { + return Response.json(translations); + } +} diff --git a/packages/astro/test/fixtures/data-collections-schema/src/pages/translations/all.json.js b/packages/astro/test/fixtures/data-collections-schema/src/pages/translations/all.json.js new file mode 100644 index 000000000..f1ebb15b7 --- /dev/null +++ b/packages/astro/test/fixtures/data-collections-schema/src/pages/translations/all.json.js @@ -0,0 +1,6 @@ +import { getCollection } from 'astro:content'; + +export async function GET() { + const translations = await getCollection('i18n'); + return Response.json(translations); +} diff --git a/packages/astro/test/fixtures/data-collections-schema/src/pages/translations/by-id.json.js b/packages/astro/test/fixtures/data-collections-schema/src/pages/translations/by-id.json.js new file mode 100644 index 000000000..5f71c80e9 --- /dev/null +++ b/packages/astro/test/fixtures/data-collections-schema/src/pages/translations/by-id.json.js @@ -0,0 +1,6 @@ +import { getDataEntryById } from 'astro:content'; + +export async function GET() { + const item = await getDataEntryById('i18n', 'en'); + return Response.json(item); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 130057300..79bd466bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -689,6 +689,9 @@ importers: zod: specifier: ^3.22.4 version: 3.22.4 + zod-to-json-schema: + specifier: ^3.22.4 + version: 3.22.4(zod@3.22.4) optionalDependencies: sharp: specifier: ^0.32.6 @@ -2708,6 +2711,12 @@ importers: specifier: workspace:* version: link:../../.. + packages/astro/test/fixtures/data-collections-schema: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/debug-component: dependencies: astro: @@ -17053,6 +17062,14 @@ packages: engines: {node: '>=12.20'} dev: false + /zod-to-json-schema@3.22.4(zod@3.22.4): + resolution: {integrity: sha512-2Ed5dJ+n/O3cU383xSY28cuVi0BCQhF8nYqWU5paEpl7fVdqdAmiLdqLyfblbNdfOFwFfi/mqU4O1pwc60iBhQ==} + peerDependencies: + zod: ^3.22.4 + dependencies: + zod: 3.22.4 + dev: false + /zod@3.22.4: resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} dev: false |