diff --git a/package.json b/package.json index c837a3252..19ad60f9f 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "fast-glob": "^3.3.3", "git-url-parse": "^16.1.0", "jiti": "^2.4.2", + "json-schema-to-typescript": "^15.0.4", "knitwork": "^1.2.0", "listhen": "^1.9.0", "mdast-util-to-hast": "^13.2.0", @@ -95,8 +96,7 @@ "unist-util-visit": "^5.0.0", "ws": "^8.18.1", "zod": "^3.24.3", - "zod-to-json-schema": "^3.24.5", - "zod-to-ts": "^1.2.0" + "zod-to-json-schema": "^3.24.5" }, "peerDependencies": { "@electric-sql/pglite": "*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ddb6ce304..aecce3ab2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: jiti: specifier: ^2.4.2 version: 2.4.2 + json-schema-to-typescript: + specifier: ^15.0.4 + version: 15.0.4 knitwork: specifier: ^1.2.0 version: 1.2.0 @@ -149,9 +152,6 @@ importers: zod-to-json-schema: specifier: ^3.24.5 version: 3.24.5(zod@3.24.3) - zod-to-ts: - specifier: ^1.2.0 - version: 1.2.0(typescript@5.8.3)(zod@3.24.3) devDependencies: '@cloudflare/workers-types': specifier: ^4.20250423.0 @@ -367,6 +367,10 @@ packages: '@antfu/utils@8.1.1': resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==} + '@apidevtools/json-schema-ref-parser@11.9.3': + resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==} + engines: {node: '>= 16'} + '@babel/code-frame@7.26.2': resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} @@ -1377,6 +1381,9 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@jsdevtools/ono@7.1.3': + resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + '@kwsites/file-exists@1.1.1': resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} @@ -2645,6 +2652,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/lodash@4.17.16': + resolution: {integrity: sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -4996,6 +5006,11 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-schema-to-typescript@15.0.4: + resolution: {integrity: sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==} + engines: {node: '>=16.0.0'} + hasBin: true + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -5602,6 +5617,7 @@ packages: node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead node-emoji@2.2.0: resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} @@ -7728,12 +7744,6 @@ packages: peerDependencies: zod: ^3.24.1 - zod-to-ts@1.2.0: - resolution: {integrity: sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==} - peerDependencies: - typescript: ^4.9.4 || ^5.0.2 - zod: ^3 - zod@3.22.3: resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} @@ -7759,6 +7769,12 @@ snapshots: '@antfu/utils@8.1.1': {} + '@apidevtools/json-schema-ref-parser@11.9.3': + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + js-yaml: 4.1.0 + '@babel/code-frame@7.26.2': dependencies: '@babel/helper-validator-identifier': 7.25.9 @@ -8657,6 +8673,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@jsdevtools/ono@7.1.3': {} + '@kwsites/file-exists@1.1.1': dependencies: debug: 4.4.0 @@ -8890,7 +8908,7 @@ snapshots: simple-git: 3.27.0 sirv: 3.0.1 structured-clone-es: 1.0.0 - tinyglobby: 0.2.12 + tinyglobby: 0.2.13 vite: 6.2.6(@types/node@22.14.1)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(yaml@2.7.1) vite-plugin-inspect: 11.0.0(@nuxt/kit@3.16.2(magicast@0.3.5))(vite@6.2.6(@types/node@22.14.1)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(yaml@2.7.1)) vite-plugin-vue-tracer: 0.1.3(vite@6.2.6(@types/node@22.14.1)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(yaml@2.7.1))(vue@3.5.13(typescript@5.8.3)) @@ -8997,7 +9015,7 @@ snapshots: ohash: 2.0.11 pathe: 2.0.3 sirv: 3.0.1 - tinyglobby: 0.2.12 + tinyglobby: 0.2.13 ufo: 1.6.1 unifont: 0.1.7 unplugin: 2.3.1 @@ -9040,7 +9058,7 @@ snapshots: pathe: 2.0.3 picomatch: 4.0.2 std-env: 3.9.0 - tinyglobby: 0.2.12 + tinyglobby: 0.2.13 transitivePeerDependencies: - magicast - supports-color @@ -9328,7 +9346,7 @@ snapshots: scule: 1.3.0 tailwind-variants: 1.0.0(tailwindcss@4.1.3) tailwindcss: 4.1.3 - tinyglobby: 0.2.12 + tinyglobby: 0.2.13 typescript: 5.8.3 unplugin: 2.3.1 unplugin-auto-import: 19.1.2(@nuxt/kit@3.16.2(magicast@0.3.5))(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.3))) @@ -10433,6 +10451,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/lodash@4.17.16': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -13101,6 +13121,18 @@ snapshots: json-buffer@3.0.1: {} + json-schema-to-typescript@15.0.4: + dependencies: + '@apidevtools/json-schema-ref-parser': 11.9.3 + '@types/json-schema': 7.0.15 + '@types/lodash': 4.17.16 + is-glob: 4.0.3 + js-yaml: 4.1.0 + lodash: 4.17.21 + minimist: 1.2.8 + prettier: 3.5.3 + tinyglobby: 0.2.13 + json-schema-traverse@0.4.1: {} json-stable-stringify-without-jsonify@1.0.1: {} @@ -16079,7 +16111,7 @@ snapshots: pkg-types: 1.3.1 scule: 1.3.0 strip-literal: 3.0.0 - tinyglobby: 0.2.12 + tinyglobby: 0.2.13 unplugin: 2.3.1 unplugin-utils: 0.2.4 @@ -16113,7 +16145,7 @@ snapshots: pkg-types: 2.1.0 scule: 1.3.0 strip-literal: 3.0.0 - tinyglobby: 0.2.12 + tinyglobby: 0.2.13 unplugin: 2.3.1 unplugin-utils: 0.2.4 @@ -16204,7 +16236,7 @@ snapshots: local-pkg: 1.1.1 magic-string: 0.30.17 mlly: 1.7.4 - tinyglobby: 0.2.12 + tinyglobby: 0.2.13 unplugin: 2.3.1 unplugin-utils: 0.2.4 vue: 3.5.13(typescript@5.8.3) @@ -16440,7 +16472,7 @@ snapshots: picomatch: 4.0.2 strip-ansi: 7.1.0 tiny-invariant: 1.3.3 - tinyglobby: 0.2.12 + tinyglobby: 0.2.13 vite: 6.2.6(@types/node@22.14.1)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(yaml@2.7.1) vscode-uri: 3.1.0 optionalDependencies: @@ -16758,7 +16790,7 @@ snapshots: dependencies: eslint-visitor-keys: 3.4.3 lodash: 4.17.21 - yaml: 2.7.0 + yaml: 2.7.1 yaml@2.7.0: {} @@ -16819,11 +16851,6 @@ snapshots: dependencies: zod: 3.24.3 - zod-to-ts@1.2.0(typescript@5.8.3)(zod@3.24.3): - dependencies: - typescript: 5.8.3 - zod: 3.24.3 - zod@3.22.3: {} zod@3.24.3: {} diff --git a/src/runtime/internal/preview/collection.ts b/src/runtime/internal/preview/collection.ts index 1f4c834e9..63fbff228 100644 --- a/src/runtime/internal/preview/collection.ts +++ b/src/runtime/internal/preview/collection.ts @@ -115,7 +115,7 @@ function computeValuesBasedOnCollectionSchema(collection: CollectionInfo, data: const fields: string[] = [] const values: Array = [] const properties = (collection.schema.definitions![collection.name] as JsonSchema7ObjectType).properties - const sortedKeys = getOrderedSchemaKeys(properties) + const sortedKeys = getOrderedSchemaKeys(collection.schema) sortedKeys.forEach((key) => { const value = (properties)[key] diff --git a/src/runtime/internal/schema.ts b/src/runtime/internal/schema.ts index 0a8556547..dbf8365d2 100644 --- a/src/runtime/internal/schema.ts +++ b/src/runtime/internal/schema.ts @@ -1,6 +1,16 @@ -import type { ZodRawShape } from 'zod' +import type { Draft07, Draft07DefinitionProperty, Draft07DefinitionPropertyAllOf, Draft07DefinitionPropertyAnyOf } from '@nuxt/content' -export function getOrderedSchemaKeys(shape: ZodRawShape | Record) { +const propertyTypes = { + string: 'VARCHAR', + number: 'INT', + boolean: 'BOOLEAN', + date: 'DATE', + enum: 'VARCHAR', + object: 'TEXT', +} + +export function getOrderedSchemaKeys(schema: Draft07) { + const shape = Object.values(schema.definitions)[0].properties const keys = new Set([ shape.id ? 'id' : undefined, shape.title ? 'title' : undefined, @@ -9,3 +19,73 @@ export function getOrderedSchemaKeys(shape: ZodRawShape | Record t.type === 'null') + if (anyOf.length === 2 && nullIndex !== -1) { + type = nullIndex === 0 + ? getPropertyType(anyOf[1]) + : getPropertyType(anyOf[0]) + } + } + + if (Array.isArray(propertyType) && propertyType.includes('null') && propertyType.length === 2) { + type = propertyType[0] === 'null' + ? propertyTypes[propertyType[1] as keyof typeof propertyTypes] || 'TEXT' + : propertyTypes[propertyType[0] as keyof typeof propertyTypes] || 'TEXT' + } + + return type +} + +export function describeProperty(schema: Draft07, property: string) { + const def = Object.values(schema.definitions)[0] + const shape = def.properties + if (!shape[property]) { + throw new Error(`Property ${property} not found in schema`) + } + + const type = (shape[property] as Draft07DefinitionProperty).type + + const result: { name: string, sqlType: string, type?: string, default?: unknown, nullable: boolean, maxLength?: number } = { + name: property, + sqlType: getPropertyType(shape[property]), + type, + nullable: false, + maxLength: (shape[property] as Draft07DefinitionProperty).maxLength, + } + if ((shape[property] as Draft07DefinitionPropertyAnyOf).anyOf) { + if (((shape[property] as Draft07DefinitionPropertyAnyOf).anyOf).find(t => t.type === 'null')) { + result.nullable = true + } + } + + if (Array.isArray(type) && type.includes('null')) { + result.nullable = true + } + + // default value + if ('default' in shape[property]) { + result.default = shape[property].default + } + else if (!def.required.includes(property)) { + result.nullable = true + } + + return result +} diff --git a/src/types/collection.ts b/src/types/collection.ts index e1cf7853a..418b7cc83 100644 --- a/src/types/collection.ts +++ b/src/types/collection.ts @@ -1,5 +1,5 @@ import type { ZodObject, ZodRawShape } from 'zod' -import type { zodToJsonSchema } from 'zod-to-json-schema' +import type { Draft07 } from '../types/schema' import type { MarkdownRoot } from './content' export interface PageCollections {} @@ -52,22 +52,17 @@ export interface DataCollection { export type Collection = PageCollection | DataCollection -export interface DefinedCollection { +export interface DefinedCollection { type: CollectionType source: ResolvedCollectionSource[] | undefined - schema: ZodObject - extendedSchema: ZodObject + schema: Draft07 + extendedSchema: Draft07 fields: Record } -export interface ResolvedCollection { +export interface ResolvedCollection extends DefinedCollection { name: string tableName: string - type: CollectionType - source: ResolvedCollectionSource[] | undefined - schema: ZodObject - extendedSchema: ZodObject - fields: Record /** * Whether the collection is private or not. * Private collections will not be available in the runtime. @@ -81,7 +76,7 @@ export interface CollectionInfo { tableName: string source: ResolvedCollectionSource[] type: CollectionType - schema: ReturnType + schema: Draft07 fields: Record tableDefinition: string } diff --git a/src/types/index.ts b/src/types/index.ts index 0e7d4021c..4b81f7a02 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -8,3 +8,4 @@ export type * from './content' export type * from './tree' export type * from './database' export type * from './preview' +export type * from './schema' diff --git a/src/types/schema.ts b/src/types/schema.ts new file mode 100644 index 000000000..bbef8b4eb --- /dev/null +++ b/src/types/schema.ts @@ -0,0 +1,37 @@ +export interface Draft07 { + $schema: 'http://json-schema.org/draft-07/schema#' + $ref: string + definitions: Record +} + +export interface Draft07Definition { + type: string + properties: Record + required: string[] + additionalProperties: boolean +} + +export interface Draft07DefinitionProperty { + type?: string // missing type means any + properties?: Record + required?: string[] + default?: unknown + maxLength?: number + format?: string + enum?: string[] + additionalProperties?: boolean | Record + $content?: { + editor?: { + input?: 'media' | 'icon' // Override the default input for the field + hidden?: boolean // Do not display the field in the editor + } + } +} + +export interface Draft07DefinitionPropertyAnyOf { + anyOf: Draft07DefinitionProperty[] +} + +export interface Draft07DefinitionPropertyAllOf { + allOf: Draft07DefinitionProperty[] +} diff --git a/src/utils/collection.ts b/src/utils/collection.ts index 6c976a683..80be5fd18 100644 --- a/src/utils/collection.ts +++ b/src/utils/collection.ts @@ -1,12 +1,11 @@ -import type { ZodObject, ZodOptionalDef, ZodRawShape, ZodStringDef, ZodType } from 'zod' +import type { ZodObject, ZodRawShape } from 'zod' import { hash } from 'ohash' import type { Collection, ResolvedCollection, CollectionSource, DefinedCollection, ResolvedCollectionSource, CustomCollectionSource, ResolvedCustomCollectionSource } from '../types/collection' -import { getOrderedSchemaKeys } from '../runtime/internal/schema' -import type { ParsedContentFile } from '../types' +import { getOrderedSchemaKeys, describeProperty } from '../runtime/internal/schema' +import type { Draft07, ParsedContentFile } from '../types' import { defineLocalSource, defineGitHubSource, defineBitbucketSource } from './source' -import { metaSchema, pageSchema } from './schema' -import type { ZodFieldType } from './zod' -import { getUnderlyingType, ZodToSqlFieldTypes, z, getUnderlyingTypeName } from './zod' +import { emptyStandardSchema, mergeStandardSchema, metaSchema, metaStandardSchema, pageSchema, pageStandardSchema } from './schema' +import { getUnderlyingTypeName, z, zodToStandardSchema } from './zod' import { logger } from './dev' const JSON_FIELDS_TYPES = ['ZodObject', 'ZodArray', 'ZodRecord', 'ZodIntersection', 'ZodUnion', 'ZodAny', 'ZodMap'] @@ -16,18 +15,25 @@ export function getTableName(name: string) { } export function defineCollection(collection: Collection): DefinedCollection { + let standardSchema: Draft07 = emptyStandardSchema + if (collection.schema instanceof z.ZodObject) { + standardSchema = zodToStandardSchema(collection.schema, '__SCHEMA__') + } let schema = collection.schema || z.object({}) + let extendedSchema: Draft07 = standardSchema if (collection.type === 'page') { schema = pageSchema.extend((schema as ZodObject).shape) + extendedSchema = mergeStandardSchema(pageStandardSchema, extendedSchema) } schema = metaSchema.extend((schema as ZodObject).shape) + extendedSchema = mergeStandardSchema(metaStandardSchema, extendedSchema) return { type: collection.type, source: resolveSource(collection.source), - schema: collection.schema || z.object({}), - extendedSchema: schema, + schema: standardSchema, // zodToStandardSchema(collection.schema || z.object({}), '__SCHEMA__'), + extendedSchema: extendedSchema, // zodToStandardSchema(schema, '__SCHEMA__'), fields: Object.keys(schema.shape).reduce((acc, key) => { const underlyingType = getUnderlyingTypeName(schema.shape[key as keyof typeof schema.shape]) if (JSON_FIELDS_TYPES.includes(underlyingType)) { @@ -85,21 +91,17 @@ export function resolveCollection(name: string, collection: DefinedCollection): } export function resolveCollections(collections: Record): ResolvedCollection[] { + const infoSchema = zodToStandardSchema(z.object({ + id: z.string(), + version: z.string(), + structureVersion: z.string(), + ready: z.boolean(), + }), 'info') collections.info = { type: 'data', source: undefined, - schema: z.object({ - id: z.string(), - version: z.string(), - structureVersion: z.string(), - ready: z.boolean(), - }), - extendedSchema: z.object({ - id: z.string(), - version: z.string(), - structureVersion: z.string(), - ready: z.boolean(), - }), + schema: infoSchema, + extendedSchema: infoSchema, fields: {}, } @@ -154,13 +156,14 @@ export const SLICE_SIZE = 70000 export function generateCollectionInsert(collection: ResolvedCollection, data: ParsedContentFile): { queries: string[], hash: string } { const fields: string[] = [] const values: Array = [] - const sortedKeys = getOrderedSchemaKeys((collection.extendedSchema).shape) + + const sortedKeys = getOrderedSchemaKeys(collection.extendedSchema) sortedKeys.forEach((key) => { - const value = (collection.extendedSchema).shape[key] - const underlyingType = getUnderlyingType(value as ZodType) + const property = describeProperty(collection.extendedSchema, key) + // const value = (collection.extendedSchema).shape[key] - const defaultValue = value?._def.defaultValue ? value?._def.defaultValue() : 'NULL' + const defaultValue = property?.default ? property.default : 'NULL' const valueToInsert = (typeof data[key] === 'undefined' || String(data[key]) === 'null') ? defaultValue @@ -176,7 +179,7 @@ export function generateCollectionInsert(collection: ResolvedCollection, data: P if (collection.fields[key] === 'json') { values.push(`'${JSON.stringify(valueToInsert).replace(/'/g, '\'\'')}'`) } - else if (['ZodString', 'ZodEnum'].includes(underlyingType.constructor.name)) { + else if ((property?.sqlType || '').match(/^(VARCHAR|TEXT)/)) { values.push(`'${String(valueToInsert).replace(/\n/g, '\\n').replace(/'/g, '\'\'')}'`) } else if (collection.fields[key] === 'date') { @@ -185,8 +188,11 @@ export function generateCollectionInsert(collection: ResolvedCollection, data: P else if (collection.fields[key] === 'boolean') { values.push(!!valueToInsert) } + else if (collection.fields[key] === 'number') { + values.push(Number(valueToInsert)) + } else { - values.push(valueToInsert) + values.push(String(valueToInsert)) } }) @@ -247,40 +253,31 @@ export function generateCollectionInsert(collection: ResolvedCollection, data: P // Convert a collection with Zod schema to SQL table definition export function generateCollectionTableDefinition(collection: ResolvedCollection, opts: { drop?: boolean } = {}) { - const sortedKeys = getOrderedSchemaKeys((collection.extendedSchema).shape) + const sortedKeys = getOrderedSchemaKeys(collection.extendedSchema) const sqlFields = sortedKeys.map((key) => { - const type = (collection.extendedSchema).shape[key]! - const underlyingType = getUnderlyingType(type) - if (key === 'id') return `${key} TEXT PRIMARY KEY` - let sqlType: string = ZodToSqlFieldTypes[underlyingType.constructor.name as ZodFieldType] + const property = describeProperty(collection.extendedSchema, key) - // Convert nested objects to TEXT - if (JSON_FIELDS_TYPES.includes(underlyingType.constructor.name)) { - sqlType = 'TEXT' - } + let sqlType = property?.sqlType - if (!sqlType) throw new Error(`Unsupported Zod type: ${underlyingType.constructor.name}`) + if (!sqlType) throw new Error(`Unsupported Zod type: ${property?.type}`) // Handle string length - if (underlyingType.constructor.name === 'ZodString') { - const checks = (underlyingType._def as ZodStringDef).checks || [] - if (checks.some(check => check.kind === 'max')) { - sqlType += `(${checks.find(check => check.kind === 'max')?.value})` - } + if (property.sqlType === 'VARCHAR' && property.maxLength) { + sqlType += `(${property.maxLength})` } // Handle optional fields - const constraints = [ - type.isNullable() ? ' NULL' : '', + const constraints: string[] = [ + property?.nullable ? ' NULL' : '', ] // Handle default values - if (type._def.defaultValue !== undefined) { - let defaultValue = typeof type._def.defaultValue() === 'string' - ? `'${type._def.defaultValue()}'` - : type._def.defaultValue() + if ('default' in property) { + let defaultValue = typeof property.default === 'string' + ? `'${property.default}'` + : property.default if (!(defaultValue instanceof Date) && typeof defaultValue === 'object') { defaultValue = `'${JSON.stringify(defaultValue)}'` diff --git a/src/utils/content/index.ts b/src/utils/content/index.ts index c8e3c1e6c..e2a2aea83 100644 --- a/src/utils/content/index.ts +++ b/src/utils/content/index.ts @@ -10,8 +10,9 @@ import { createJiti } from 'jiti' import { createOnigurumaEngine } from 'shiki/engine/oniguruma' import { visit } from 'unist-util-visit' import type { ResolvedCollection } from '../../types/collection' -import type { FileAfterParseHook, FileBeforeParseHook, ModuleOptions, ContentFile, ContentTransformer } from '../../types' +import type { FileAfterParseHook, FileBeforeParseHook, ModuleOptions, ContentFile, ContentTransformer, ParsedContentFile } from '../../types' import { logger } from '../dev' +import { getOrderedSchemaKeys } from '../../runtime/internal/schema' import { transformContent } from './transformers' let parserOptions = { @@ -169,10 +170,10 @@ export async function createParser(collection: ResolvedCollection, nuxt?: Nuxt) transformers: extraTransformers, }) const { id: id, ...parsedContentFields } = parsedContent - const result = { id } as typeof collection.extendedSchema._type + const result = { id } as ParsedContentFile const meta = {} as Record - const collectionKeys = Object.keys(collection.extendedSchema.shape) + const collectionKeys = getOrderedSchemaKeys(collection.extendedSchema) for (const key of Object.keys(parsedContentFields)) { if (collectionKeys.includes(key)) { result[key] = parsedContent[key] @@ -190,9 +191,9 @@ export async function createParser(collection: ResolvedCollection, nuxt?: Nuxt) } if (collectionKeys.includes('seo')) { - result.seo = result.seo || {} - result.seo.title = result.seo.title || result.title - result.seo.description = result.seo.description || result.description + const seo = result.seo = (result.seo || {}) as Record + seo.title = seo.title || result.title + seo.description = seo.description || result.description } const afterParseCtx: FileAfterParseHook = { file: hookedFile, content: result, collection } diff --git a/src/utils/database.ts b/src/utils/database.ts index 8d439e25e..0f0c296ce 100644 --- a/src/utils/database.ts +++ b/src/utils/database.ts @@ -4,7 +4,6 @@ import type { Resolver } from '@nuxt/kit' import cloudflareD1Connector from 'db0/connectors/cloudflare-d1' import { isAbsolute, join, dirname } from 'pathe' import { isWebContainer } from '@webcontainer/env' -import { z } from 'zod' import type { CacheEntry, D1DatabaseConfig, LocalDevelopmentDatabase, ResolvedCollection, SqliteDatabaseConfig } from '../types' import type { ModuleOptions, SQLiteConnector } from '../types/module' import { logger } from './dev' @@ -70,14 +69,23 @@ const _localDatabase: Record = {} export async function getLocalDatabase(database: SqliteDatabaseConfig | D1DatabaseConfig, { connector, sqliteConnector }: { connector?: Connector, nativeSqlite?: boolean, sqliteConnector?: SQLiteConnector } = {}): Promise { const databaseLocation = database.type === 'sqlite' ? database.filename : database.bindingName const db = _localDatabase[databaseLocation] || connector || await getDatabase(database, { sqliteConnector }) - const cacheCollection = { tableName: '_development_cache', - extendedSchema: z.object({ - id: z.string(), - value: z.string(), - checksum: z.string(), - }), + extendedSchema: { + $schema: 'http://json-schema.org/draft-07/schema#', + $ref: '#/definitions/cache', + definitions: { + cache: { + type: 'object', + properties: { + id: { type: 'string' }, + value: { type: 'string' }, + checksum: { type: 'string' }, + }, + required: ['id', 'value', 'checksum'], + }, + }, + }, fields: { id: 'string', value: 'string', diff --git a/src/utils/schema.ts b/src/utils/schema.ts index fc00fdc73..92b4d8c42 100644 --- a/src/utils/schema.ts +++ b/src/utils/schema.ts @@ -1,6 +1,10 @@ import * as z from 'zod' import { ContentFileExtension } from '../types/content' -import { getEnumValues } from './zod' +import type { Draft07 } from '../types' + +export function getEnumValues>(obj: T) { + return Object.values(obj) as [(typeof obj)[keyof T]] +} export const metaSchema = z.object({ id: z.string(), @@ -9,6 +13,58 @@ export const metaSchema = z.object({ meta: z.record(z.string(), z.any()), }) +export const emptyStandardSchema: Draft07 = { + $schema: 'http://json-schema.org/draft-07/schema#', + $ref: '#/definitions/__SCHEMA__', + definitions: { + __SCHEMA__: { + type: 'object', + properties: {}, + required: [], + additionalProperties: false, + }, + }, +} + +export const metaStandardSchema: Draft07 = { + $schema: 'http://json-schema.org/draft-07/schema#', + $ref: '#/definitions/__SCHEMA__', + definitions: { + __SCHEMA__: { + type: 'object', + properties: { + id: { + type: 'string', + }, + stem: { + type: 'string', + }, + extension: { + type: 'string', + enum: [ + 'md', + 'yaml', + 'yml', + 'json', + 'csv', + 'xml', + ], + }, + meta: { + type: 'object', + additionalProperties: {}, + }, + }, + required: [ + 'id', + 'stem', + 'extension', + 'meta', + ], + additionalProperties: false, + }, + }, +} export const pageSchema = z.object({ path: z.string(), title: z.string(), @@ -34,3 +90,112 @@ export const pageSchema = z.object({ }), ]).optional().default(true), }) + +export const pageStandardSchema: Draft07 = { + $schema: 'http://json-schema.org/draft-07/schema#', + $ref: '#/definitions/__SCHEMA__', + definitions: { + __SCHEMA__: { + type: 'object', + properties: { + path: { + type: 'string', + }, + title: { + type: 'string', + }, + description: { + type: 'string', + }, + seo: { + allOf: [ + { + type: 'object', + properties: { + title: { + type: 'string', + }, + description: { + type: 'string', + }, + }, + }, + { + type: 'object', + additionalProperties: {}, + }, + ], + default: {}, + }, + body: { + type: 'object', + properties: { + type: { + type: 'string', + }, + children: {}, + toc: {}, + }, + required: [ + 'type', + ], + additionalProperties: false, + }, + navigation: { + anyOf: [ + { + type: 'boolean', + }, + { + type: 'object', + properties: { + title: { + type: 'string', + }, + description: { + type: 'string', + }, + icon: { + type: 'string', + }, + }, + required: [ + 'title', + 'description', + 'icon', + ], + additionalProperties: false, + }, + ], + default: true, + }, + }, + required: [ + 'path', + 'title', + 'description', + 'body', + ], + additionalProperties: false, + }, + }, +} + +export function mergeStandardSchema(s1: Draft07, s2: Draft07): Draft07 { + return { + $schema: s1.$schema, + $ref: s1.$ref, + definitions: Object.fromEntries( + Object.entries(s1.definitions).map(([key, def1]) => { + const def2 = s2.definitions[key] + if (!def2) return [key, def1] + + return [key, { + ...def1, + properties: { ...def1.properties, ...def2.properties }, + required: [...new Set([...def1.required, ...(def2.required || [])])], + }] + }), + ), + } +} diff --git a/src/utils/templates.ts b/src/utils/templates.ts index c69958b29..4eaf06eeb 100644 --- a/src/utils/templates.ts +++ b/src/utils/templates.ts @@ -1,24 +1,15 @@ import { gzip } from 'node:zlib' -import { printNode, zodToTs } from 'zod-to-ts' -import { zodToJsonSchema, ignoreOverride } from 'zod-to-json-schema' import type { NuxtTemplate } from '@nuxt/schema' import { isAbsolute, join, relative } from 'pathe' import { genDynamicImport } from 'knitwork' +import { compile as jsonSchemaToTypescript, type JSONSchema } from 'json-schema-to-typescript' import { pascalCase } from 'scule' import type { Schema } from 'untyped' -import { createDefu } from 'defu' import type { CollectionInfo, ResolvedCollection } from '../types/collection' import type { Manifest } from '../types/manifest' import type { GitInfo } from './git' import { generateCollectionTableDefinition } from './collection' -const defu = createDefu((obj, key, value) => { - if (Array.isArray(obj[key]) && Array.isArray(value)) { - obj[key] = value - return true - } -}) - const compress = (text: string): Promise => { return new Promise((resolve, reject) => gzip(text, (err, buff) => { if (err) { @@ -47,7 +38,7 @@ export const moduleTemplates = { export const contentTypesTemplate = (collections: ResolvedCollection[]) => ({ filename: moduleTemplates.types as `${string}.d.ts`, - getContents: ({ options }) => { + getContents: async ({ options }) => { const publicCollections = (options.collections as ResolvedCollection[]).filter(c => !c.private) const pagesCollections = publicCollections.filter(c => c.type === 'page') @@ -56,9 +47,13 @@ export const contentTypesTemplate = (collections: ResolvedCollection[]) => ({ 'import type { PageCollectionItemBase, DataCollectionItemBase } from \'@nuxt/content\'', '', 'declare module \'@nuxt/content\' {', - ...publicCollections.map(c => - indentLines(`interface ${pascalCase(c.name)}CollectionItem extends ${parentInterface(c)} ${printNode(zodToTs(c.schema, pascalCase(c.name)).node)}`), - ), + ...(await Promise.all( + publicCollections.map(async (c) => { + const type = await jsonSchemaToTypescript(c.schema as JSONSchema, 'CLASS') + .then(code => code.replace('export interface CLASS', `interface ${pascalCase(c.name)}CollectionItem extends ${parentInterface(c)}`)) + return indentLines(` ${type}`) + }), + )), '', ' interface PageCollections {', ...pagesCollections.map(c => indentLines(`${c.name}: ${pascalCase(c.name)}CollectionItem`, 4)), @@ -208,7 +203,7 @@ export const previewTemplate = (collections: ResolvedCollection[], gitInfo: GitI source: collection.source?.filter(source => source.repository ? undefined : collection.source) || [], type: collection.type, fields: collection.fields, - schema: generateJsonSchema(collection), + schema: collection.schema, tableDefinition: generateCollectionTableDefinition(collection), } return acc @@ -231,23 +226,3 @@ export const previewTemplate = (collections: ResolvedCollection[], gitInfo: GitI }, write: true, }) - -function generateJsonSchema(collection: ResolvedCollection): ReturnType { - const jsonSchema = zodToJsonSchema(collection.extendedSchema, collection.name) - const jsonSchemaWithEditorMeta = zodToJsonSchema( - collection.extendedSchema, - { - name: collection.name, - override: (def) => { - if (def.editor) { - return { - editor: def.editor, - } as never - } - - return ignoreOverride - }, - }) - - return defu(jsonSchema, jsonSchemaWithEditorMeta) as ReturnType -} diff --git a/src/utils/zod.ts b/src/utils/zod.ts index 765eeefdf..75248432a 100644 --- a/src/utils/zod.ts +++ b/src/utils/zod.ts @@ -1,5 +1,15 @@ import type { ZodOptionalDef, ZodType } from 'zod' +import { zodToJsonSchema, ignoreOverride } from 'zod-to-json-schema' import { z as zod } from 'zod' +import { createDefu } from 'defu' +import type { Draft07 } from '../types' + +const defu = createDefu((obj, key, value) => { + if (Array.isArray(obj[key]) && Array.isArray(value)) { + obj[key] = value + return true + } +}) declare module 'zod' { interface ZodTypeDef { @@ -27,18 +37,6 @@ export type SqlFieldType = 'VARCHAR' | 'INT' | 'BOOLEAN' | 'DATE' | 'TEXT' export const z = zod -export const ZodToSqlFieldTypes: Record = { - ZodString: 'VARCHAR', - ZodNumber: 'INT', - ZodBoolean: 'BOOLEAN', - ZodDate: 'DATE', - ZodEnum: 'VARCHAR', -} as const - -export function getEnumValues>(obj: T) { - return Object.values(obj) as [(typeof obj)[keyof T]] -} - // Function to get the underlying Zod type export function getUnderlyingType(zodType: ZodType): ZodType { while ((zodType._def as ZodOptionalDef).innerType) { @@ -50,3 +48,27 @@ export function getUnderlyingType(zodType: ZodType): ZodType { export function getUnderlyingTypeName(zodType: ZodType): string { return getUnderlyingType(zodType).constructor.name } + +export function zodToStandardSchema(schema: zod.ZodSchema, name: string): Draft07 { + const jsonSchema = zodToJsonSchema(schema, name) as Draft07 + const jsonSchemaWithEditorMeta = zodToJsonSchema( + schema, + { + name, + override: (def) => { + if (def.editor) { + return { + $content: { + editor: def.editor, + }, + // @deprecated Use $content.editor instead + editor: def.editor, + } as never + } + + return ignoreOverride + }, + }) as Draft07 + + return defu(jsonSchema, jsonSchemaWithEditorMeta) +} diff --git a/test/unit/defineCollection.test.ts b/test/unit/defineCollection.test.ts index 1be273090..6c7f776d6 100644 --- a/test/unit/defineCollection.test.ts +++ b/test/unit/defineCollection.test.ts @@ -5,7 +5,7 @@ import { defineCollection } from '../../src/utils/collection' const metaFields = ['id', 'stem', 'meta', 'extension'] const pageFields = ['path', 'title', 'description', 'seo', 'body', 'navigation'] -function expectProperties(shape: z.ZodRawShape, fields: string[]) { +function expectProperties(shape: Record, fields: string[]) { fields.forEach(field => expect(shape).toHaveProperty(field)) } @@ -23,11 +23,10 @@ describe('defineCollection', () => { cwd: '', }], }) + expect(collection.schema.definitions.__SCHEMA__.properties).not.toHaveProperty('title') - expect(collection.schema.shape).not.ownProperty('title') - - expectProperties(collection.extendedSchema.shape, metaFields) - expectProperties(collection.extendedSchema.shape, pageFields) + expectProperties(collection.extendedSchema.definitions.__SCHEMA__.properties, metaFields) + expectProperties(collection.extendedSchema.definitions.__SCHEMA__.properties, pageFields) }) test('Page with custom schema', () => { @@ -38,12 +37,13 @@ describe('defineCollection', () => { customField: z.string(), }), }) + console.log(collection.schema.definitions) - expect(collection.schema.shape).ownProperty('customField') - expect(collection.extendedSchema.shape).toHaveProperty('customField') + expect(collection.schema.definitions.__SCHEMA__.properties).ownProperty('customField') + expect(collection.extendedSchema.definitions.__SCHEMA__.properties).toHaveProperty('customField') - expectProperties(collection.extendedSchema.shape, metaFields) - expectProperties(collection.extendedSchema.shape, pageFields) + expectProperties(collection.extendedSchema.definitions.__SCHEMA__.properties, metaFields) + expectProperties(collection.extendedSchema.definitions.__SCHEMA__.properties, pageFields) }) test('Page with object source', () => { @@ -69,10 +69,10 @@ describe('defineCollection', () => { }], }) - expect(collection.schema.shape).ownProperty('customField') - expect(collection.extendedSchema.shape).toHaveProperty('customField') + expect(collection.schema.definitions.__SCHEMA__.properties).ownProperty('customField') + expect(collection.extendedSchema.definitions.__SCHEMA__.properties).toHaveProperty('customField') - expectProperties(collection.extendedSchema.shape, pageFields) + expectProperties(collection.extendedSchema.definitions.__SCHEMA__.properties, pageFields) }) test('Data with schema', () => { @@ -93,11 +93,11 @@ describe('defineCollection', () => { }], }) - expect(collection.schema.shape).toHaveProperty('customField') - expect(collection.extendedSchema.shape).toHaveProperty('customField') - expect(collection.schema.shape).not.toHaveProperty('title') + expect(collection.schema.definitions.__SCHEMA__.properties).toHaveProperty('customField') + expect(collection.extendedSchema.definitions.__SCHEMA__.properties).toHaveProperty('customField') + expect(collection.schema.definitions.__SCHEMA__.properties).not.toHaveProperty('title') - expectProperties(collection.extendedSchema.shape, metaFields) + expectProperties(collection.extendedSchema.definitions.__SCHEMA__.properties, metaFields) }) test('Data with object source', () => {