├── .gitignore ├── .npmignore ├── .prettierrc ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── base.ts ├── fields │ ├── array.ts │ ├── blocks.ts │ ├── boolean.ts │ ├── date.ts │ ├── datetime.ts │ ├── document.ts │ ├── field.ts │ ├── fieldset.ts │ ├── file.ts │ ├── geopoint.ts │ ├── image.ts │ ├── index.ts │ ├── number.ts │ ├── object.ts │ ├── reference.ts │ ├── slug.ts │ ├── string.ts │ ├── text.ts │ └── url.ts ├── index.ts ├── ordering.ts ├── preview.ts ├── test │ └── schema.test.ts ├── types.ts └── util │ ├── generate.ts │ └── title.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /dist 3 | /node_modules 4 | /coverage 5 | *.idea.md -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .prettierrc 3 | jest.config.js 4 | node_modules 5 | rollup.config.js 6 | src 7 | tsconfig.json 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Sanity Schema Builder

3 |

4 | NPM version 5 | NPM downloads 6 | GitHub Release Date 7 | License 8 |

9 |

10 |

11 |

A more efficient way of writing schema for Sanity.io.

12 |
13 |
14 |
15 | 16 | Sanity Schema Builder lets you write schema programmatically. You can use it for defining specific fields within your existing schema, or for generating entire document definitions. It's like the official [Structure Builder](https://www.sanity.io/docs/structure-builder-introduction), but for schema. 17 | 18 | Writing complex schema can often involve a lot of repetition. The Sanity Schema Builder API exposes convenient methods that allow you to quickly define documents, fields, block text, previews, orderings, et al. It's written in TypeScript, so you can benefit from automatic suggestions as you write. 19 | 20 | ## TL;DR 21 | 22 | Do this... 23 | 24 | ```ts 25 | import { SchemaBuilder } from 'sanity-schema-builder'; 26 | import { OkHandIcon } from '@sanity/icons'; 27 | const S = new SchemaBuilder(); 28 | 29 | export default S.doc('person') 30 | .icon(OkHandIcon) 31 | .fields([S.str('firstName'), S.str('lastName'), S.num('age')]) 32 | .generate(); 33 | ``` 34 | 35 | Instead of this... 36 | 37 | ```ts 38 | import { OkHandIcon } from '@sanity/icons'; 39 | 40 | export default { 41 | type: 'document', 42 | name: 'person', 43 | title: 'Person', 44 | icon: OkHandIcon, 45 | fields: [ 46 | { 47 | type: 'string', 48 | name: 'firstName', 49 | title: 'First Name', 50 | }, 51 | { 52 | type: 'string', 53 | name: 'lastName', 54 | title: 'Last Name', 55 | }, 56 | { 57 | type: 'number', 58 | name: 'age', 59 | title: 'Age', 60 | }, 61 | ], 62 | }; 63 | ``` 64 | 65 | ## Install 66 | 67 | ```bash 68 | $ npm i sanity-schema-builder # or yarn add sanity-schema-builder 69 | ``` 70 | 71 | ## Usage 72 | 73 | ```ts 74 | import { SchemaBuilder } from 'sanity-schema-builder'; 75 | 76 | const S = new SchemaBuilder(); 77 | 78 | export default S.document('person') 79 | /* Go wild here */ 80 | .generate(); 81 | ``` 82 | 83 | ## Detailed example 84 | 85 | The `SchemaBuilder` class aims to make it slightly easier for you to write your schema. It offers chainable and nestable methods as shorthand alternatives to defining longer form schema objects. Each time you would normally define a new object, you can instead use a method. 86 | 87 | Below is an example of a document schema with multiple fields of different types, each with their own properties and options, as well as a custom preview and orderings. Of course there are many more methods available, see the "Available Schema Types" section. 88 | 89 | ```ts 90 | const document = S.document('person') // Create a new document 91 | .fields([ // Add some fields to the document 92 | S.string('firstName'), // A basic string with the name 'firstName'. A title of 'First Name' will be generated. 93 | S.string('lastName', 'Family Name'), // Define the title explicitly 94 | S.str('nickname') // Use the "str" shorthand alias 95 | .title('Also known as...'), // Set the title using a method 96 | S.number('age') 97 | .description('Will be kept secret.'), // Add a description (a generic field property) 98 | S.geopoint('location') 99 | .readOnly() 100 | .hidden(), // Chain multiple properties 101 | S.array('pets').of([ // Create an array of objects 102 | S.object('pet').fields([ // Each object may have an array of fields 103 | S.string('species') 104 | .list(['Dog', 'Cat', 'Axolotl']) // Add a field specific option 105 | .layout('radio'), // Chain multiple options 106 | S.image('photo') 107 | .options({ hotspot: true, storeOriginalFilename: true }), // Set options explicitly 108 | ]), 109 | ]), 110 | S.reference('memberOf') 111 | .to(['team', 'group']) // Define an array of references 112 | .add('department'), // Or add references individually 113 | S.field('table', 'measurements'), // Define a custom field of type 'table' 114 | S.slug('handle') 115 | .validation(Rule => Rule.required()), // Use validation 116 | ]) 117 | .icon(SomeImportedIcon) // Define an icon used by the studio for this document type 118 | .preview( 119 | S.preview() // Use a nested preview method 120 | .select('firstName').select('lastName').select('alias', 'nickname') // Chain selections 121 | .prepare((selection) => ({ // Use a prepare function 122 | title: `${firstName} ${lastName}`, 123 | subtitle: selection.alias, 124 | })); 125 | ) 126 | .orderings([ // Use an array of nested orderings 127 | S.ordering('age').by('age', 'desc'), 128 | S.ordering('name').by('lastName', 'asc').by('firstName', 'asc'), // Add multiple sorts 129 | ]) 130 | .generate(); // IMPORTANT! Don't forget to actually generate the schema 131 | ``` 132 | 133 | You don't have to just generate documents in this way. For example, you may want to generate a reusable field: 134 | 135 | ```ts 136 | const field = S.obj('metadata') 137 | .fields([ 138 | S.str('title'), 139 | S.str('description'), 140 | S.url('canonical'), 141 | S.img('image'), 142 | ]) 143 | .generate(); 144 | ``` 145 | 146 | ## Available Schema Types 147 | 148 | Sanity Schema Builder supports all of the standard schema types listed [in the official documentation](https://www.sanity.io/docs/schema-types). These types are all available as methods on a `SchemaBuilder` class instance, as well as via their alias. e.g. `S.string()` or `S.str()`. 149 | 150 | | Type | Alias | Description | 151 | | --------- | ----- | --------------------------------------------------------------------- | 152 | | array | arr | An array of other types. | 153 | | blocks | | An array of block content (and other types). | 154 | | boolean | bool | Either `true` or `false`. | 155 | | date | | An ISO-8601 formatted string containing date. | 156 | | datetime | dt | An ISO-8601 formatted string UTC containing date and time. | 157 | | document | doc | The base schema type for Sanity Studio. | 158 | | file | | An object with a reference to a file asset. | 159 | | geopoint | geo | An object signifying a global latitude/longitude/altitude coordinate. | 160 | | image | img | An object with a reference to an image asset. | 161 | | number | num | A number. | 162 | | object | obj | For defining custom types that contain other fields. | 163 | | reference | ref | A reference to another document. | 164 | | slug | | A slug, typically for URLs. | 165 | | string | str | A string, or selectable list of strings. | 166 | | text | | A basic multi-line string. | 167 | | url | | A string representing a URL. | 168 | 169 | In addition, the following methods and aliases are available for more specific or nested functionality: 170 | 171 | | Type | Alias | Description | 172 | | -------- | ----- | -------------------------------------------------------------------------------------------------------- | 173 | | field | f | Low-level method for specifying custom field types. | 174 | | fieldset | fset | For defining [fieldsets](https://www.sanity.io/docs/object-type#fieldsets) inside documents and objects. | 175 | | ordering | sort | For defining [sort orders](https://www.sanity.io/docs/sort-orders). | 176 | | preview | view | For defining [previews](https://www.sanity.io/docs/previews-list-views). | 177 | | generate | | Generates the schema. All schema type chains must end with this method. | 178 | 179 | ## Predefined fields 180 | 181 | You can pass the schema builder predefined fields which you can then reference as strings when adding sub-fields to objects or other object-like fields. These can be generated using the Schema builder or written manually. 182 | 183 | Predefined fields can be passed to the SchemaBuilder constructor, or added after initialization using the `define` method. 184 | 185 | ```ts 186 | // Pass in via the constructor 187 | const S = new SchemaBuilder({ 188 | title: { 189 | type: 'string', 190 | name: 'title', 191 | title: 'Title', 192 | }, 193 | }); 194 | // Or using the define method 195 | S.define('name', S.str('name').generate()); 196 | // Create an array of predefined fields 197 | const someArray = S.arr().of(['title', 'name']); 198 | ``` 199 | 200 | ## Contributing 201 | 202 | Contributions are welcome, some ideas of things to help with: 203 | 204 | - Specific documentation for each class method. 205 | - Some types could be improved, check `@TODO` comments in the source code. 206 | - Test coverage could be expanded, especially for some of the higher-order methods. 207 | 208 | ## License 209 | 210 | MIT 211 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | collectCoverage: true, 4 | coverageDirectory: './coverage/', 5 | testEnvironment: 'node', 6 | testRegex: '.*\\.test\\.tsx?$', 7 | watchPathIgnorePatterns: ['/node_modules/'], 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sanity-schema-builder", 3 | "version": "0.1.3", 4 | "description": "Programmatic schema builder for Sanity.io", 5 | "author": "Rupert Dunk (https://rupertdunk.com/)", 6 | "license": "MIT", 7 | "main": "dist/index.cjs.js", 8 | "module": "dist/index.esm.js", 9 | "types": "dist/index.d.ts", 10 | "repository": { 11 | "type": "git", 12 | "url": "git+ssh://git@github.com/rdunk/sanity-schema-builder.git" 13 | }, 14 | "scripts": { 15 | "build": "npm run clean && npm run build:pkg", 16 | "build:pkg": "rollup -c rollup.config.js", 17 | "clean": "rimraf dist coverage", 18 | "postpublish": "npm run clean", 19 | "prepublishOnly": "npm run build", 20 | "test": "jest" 21 | }, 22 | "dependencies": { 23 | "to-title-case": "^1.0.0" 24 | }, 25 | "devDependencies": { 26 | "@types/jest": "^26.0.20", 27 | "@types/to-title-case": "^1.0.0", 28 | "jest": "^26.6.3", 29 | "react": "^17.0.1", 30 | "rollup": "^2.41.2", 31 | "rollup-plugin-typescript2": "^0.30.0", 32 | "ts-jest": "^26.5.3", 33 | "tslib": "^2.1.0", 34 | "typescript": "^4.2.3" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import ts from 'rollup-plugin-typescript2'; 2 | 3 | const pkg = require('./package.json'); 4 | 5 | const createConfig = (file, format, plugins = []) => ({ 6 | input: 'src/index.ts', 7 | output: { file, format }, 8 | external: Object.keys(pkg.dependencies), 9 | plugins: [ 10 | ts({ 11 | tsconfig: 'tsconfig.json', 12 | }), 13 | ...plugins, 14 | ], 15 | }); 16 | 17 | export default [createConfig(pkg.module, 'es'), createConfig(pkg.main, 'cjs')]; 18 | -------------------------------------------------------------------------------- /src/base.ts: -------------------------------------------------------------------------------- 1 | import { PreviewGenerator } from './preview'; 2 | import { generateTitle } from './util/title'; 3 | import { SchemaValidator, SchemaOptions, SchemaField } from './types'; 4 | import { subgenerate, subgenerateMany } from './util/generate'; 5 | 6 | export abstract class BaseGenerator { 7 | protected _name?: string; 8 | protected _title?: string; 9 | 10 | constructor(name?: string, title?: string) { 11 | this._name = name; 12 | this._title = title; 13 | } 14 | 15 | title(title: string) { 16 | this._title = title; 17 | return this; 18 | } 19 | 20 | name(name: string) { 21 | this._name = name; 22 | return this; 23 | } 24 | } 25 | 26 | export abstract class StandardGenerator extends BaseGenerator { 27 | protected _type: string; 28 | protected _description?: string; 29 | protected _readOnly: boolean = false; 30 | protected _hidden: boolean = false; 31 | protected _options: SchemaOptions = {}; 32 | protected _validation?: SchemaValidator; 33 | protected _preview?: PreviewGenerator; 34 | protected _fieldset?: string; 35 | 36 | constructor(type: string, name?: string, title?: string) { 37 | super(name, title); 38 | this._type = type; 39 | } 40 | 41 | description(description: string) { 42 | this._description = description; 43 | return this; 44 | } 45 | 46 | readOnly(readOnly = true) { 47 | this._readOnly = readOnly; 48 | return this; 49 | } 50 | 51 | hidden(hidden = true) { 52 | this._hidden = hidden; 53 | return this; 54 | } 55 | 56 | validation(fn: SchemaValidator) { 57 | this._validation = fn; 58 | return this; 59 | } 60 | 61 | preview(preview: PreviewGenerator) { 62 | this._preview = preview; 63 | return this; 64 | } 65 | 66 | fieldset(fieldset: string) { 67 | this._fieldset = fieldset; 68 | return this; 69 | } 70 | 71 | options(options: SchemaOptions) { 72 | this._options = options; 73 | return this; 74 | } 75 | 76 | option(property: string, value: any) { 77 | this._options[property] = value; 78 | return this; 79 | } 80 | 81 | // @ts-ignore 82 | protected extendProperties(field: SchemaField) {} 83 | 84 | generate() { 85 | if (!this._type) throw Error('Type is required'); 86 | const field: SchemaField = { 87 | type: this._type, 88 | }; 89 | 90 | if (this._name) { 91 | field.name = this._name; 92 | } 93 | 94 | const title = generateTitle(this._name, this._title); 95 | if (title) { 96 | field.title = title; 97 | } 98 | 99 | if (this._description) { 100 | field.description = this._description; 101 | } 102 | if (this._readOnly) { 103 | field.readOnly = this._readOnly; 104 | } 105 | if (this._hidden) { 106 | field.hidden = this._hidden; 107 | } 108 | if (Object.keys(this._options).length > 0) { 109 | field.options = this._options; 110 | } 111 | if (this._validation) { 112 | field.validation = this._validation; 113 | } 114 | if (this._preview) { 115 | field.preview = this._preview; 116 | } 117 | if (this._fieldset) { 118 | field.fieldset = this._fieldset; 119 | } 120 | 121 | this.extendProperties(field); 122 | 123 | if (field.of) { 124 | field.of = subgenerateMany(field.of); 125 | } 126 | 127 | if (field.preview) { 128 | field.preview = subgenerate(field.preview); 129 | } 130 | 131 | return field; 132 | } 133 | } 134 | 135 | export abstract class GeneratorWithFields extends StandardGenerator { 136 | protected _fields: StandardGenerator[] = []; 137 | protected _predefinedFields: Record; 138 | 139 | constructor( 140 | predefinedFields: Record | undefined, 141 | type: string, 142 | name?: string, 143 | title?: string 144 | ) { 145 | super(type, name, title); 146 | this._predefinedFields = predefinedFields || {}; 147 | } 148 | 149 | field(field: string | StandardGenerator) { 150 | // @TODO Better type checking here? 151 | if (typeof field === 'object') { 152 | this._fields.push(field); 153 | } else if (typeof field === 'string') { 154 | if (Object.prototype.hasOwnProperty.call(this._predefinedFields, field)) { 155 | this._fields.push(this._predefinedFields[field]); 156 | } else { 157 | throw new Error(`Predefined field "${field}" not found.`); 158 | } 159 | } 160 | return this; 161 | } 162 | 163 | fields(fields: Array) { 164 | fields.forEach(this.field, this); 165 | return this; 166 | } 167 | 168 | protected extendProperties(field: SchemaField) { 169 | if (this._fields.length) { 170 | field.fields = this._fields; 171 | } 172 | } 173 | 174 | generate() { 175 | const field = super.generate(); 176 | 177 | if (field.fields) { 178 | field.fields = subgenerateMany(field.fields); 179 | } 180 | 181 | return field; 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/fields/array.ts: -------------------------------------------------------------------------------- 1 | import { StandardGenerator, GeneratorWithFields } from '../base'; 2 | import { 3 | PredefinedField, 4 | SchemaArrayLayout, 5 | SchemaArrayList, 6 | SchemaField, 7 | SchemaArrayEditModal, 8 | } from '../types'; 9 | 10 | export class ArrayFieldGenerator extends GeneratorWithFields { 11 | protected _options: { 12 | sortable?: boolean; 13 | layout?: SchemaArrayLayout; 14 | list?: SchemaArrayList[]; 15 | editModal?: SchemaArrayEditModal; 16 | } = {}; 17 | 18 | constructor( 19 | predefinedFields: PredefinedField | undefined, 20 | name?: string, 21 | title?: string, 22 | ) { 23 | super(predefinedFields, 'array', name, title); 24 | } 25 | 26 | fields() { 27 | throw Error('Use the "of" method for arrays.'); 28 | return this; 29 | } 30 | 31 | of(fields: Array) { 32 | return super.fields(fields); 33 | } 34 | 35 | sortable(sortable = true) { 36 | this._options.sortable = sortable; 37 | return this; 38 | } 39 | 40 | layout(layout: SchemaArrayLayout) { 41 | this._options.layout = layout; 42 | return this; 43 | } 44 | 45 | list(items: SchemaArrayList[]) { 46 | this._options.list = items; 47 | return this; 48 | } 49 | 50 | editModal(editModal: SchemaArrayEditModal) { 51 | this._options.editModal = editModal; 52 | return this; 53 | } 54 | 55 | protected extendProperties(field: SchemaField) { 56 | if (this._fields.length) field.of = this._fields; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/fields/blocks.ts: -------------------------------------------------------------------------------- 1 | import { subgenerateMany } from '../util/generate'; 2 | import { StandardGenerator, GeneratorWithFields } from '../base'; 3 | import { 4 | PredefinedField, 5 | SchemaBlockList, 6 | SchemaBlockMarks, 7 | SchemaBlockStyle, 8 | SchemaField, 9 | } from '../types'; 10 | 11 | export class BlockFieldGenerator extends StandardGenerator { 12 | protected _styles: SchemaBlockStyle[] = []; 13 | protected _lists: SchemaBlockList[] = []; 14 | protected _marks: SchemaBlockMarks = {}; 15 | 16 | constructor( 17 | styles: SchemaBlockStyle[], 18 | lists: SchemaBlockList[], 19 | marks: SchemaBlockMarks, 20 | ) { 21 | super('block', ''); 22 | this._styles = styles; 23 | this._lists = lists; 24 | this._marks = marks; 25 | } 26 | 27 | protected extendProperties( 28 | field: SchemaField & { 29 | styles: SchemaBlockStyle[]; 30 | lists: SchemaBlockList[]; 31 | marks: SchemaBlockMarks; 32 | }, 33 | ) { 34 | if (this._styles.length) { 35 | field.styles = this._styles; 36 | } 37 | if (this._lists.length) { 38 | field.lists = this._lists; 39 | } 40 | if (Object.keys(this._marks).length > 0) { 41 | field.marks = this._marks; 42 | } 43 | } 44 | 45 | generate() { 46 | const field = super.generate() as SchemaField & { 47 | marks?: SchemaBlockMarks; 48 | }; 49 | if (field.marks?.annotations) { 50 | field.marks.annotations = subgenerateMany(field.marks.annotations); 51 | } 52 | return field; 53 | } 54 | } 55 | 56 | export class BlocksFieldGenerator extends GeneratorWithFields { 57 | protected _styles: SchemaBlockStyle[] = []; 58 | protected _lists: SchemaBlockList[] = []; 59 | protected _marks: SchemaBlockMarks = {}; 60 | 61 | constructor( 62 | predefinedFields: PredefinedField | undefined, 63 | name?: string, 64 | title?: string, 65 | ) { 66 | super(predefinedFields, 'array', name, title); 67 | } 68 | 69 | styles(styles: SchemaBlockStyle[]) { 70 | this._styles = styles; 71 | return this; 72 | } 73 | 74 | lists(lists: SchemaBlockList[]) { 75 | this._lists = lists; 76 | return this; 77 | } 78 | 79 | marks(marks: SchemaBlockMarks) { 80 | this._marks = marks; 81 | return this; 82 | } 83 | 84 | fields() { 85 | throw Error('Use the "of" method for blocks.'); 86 | return this; 87 | } 88 | 89 | of(fields: Array) { 90 | return super.fields(fields); 91 | } 92 | 93 | protected extendProperties(field: SchemaField) { 94 | this._fields.unshift( 95 | new BlockFieldGenerator(this._styles, this._lists, this._marks), 96 | ); 97 | field.of = this._fields; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/fields/boolean.ts: -------------------------------------------------------------------------------- 1 | import { StandardGenerator } from '../base'; 2 | import { SchemaBooleanLayout } from '../types'; 3 | 4 | export class BooleanFieldGenerator extends StandardGenerator { 5 | protected _options: { 6 | layout?: SchemaBooleanLayout; 7 | } = {}; 8 | 9 | constructor(name?: string, title?: string) { 10 | super('boolean', name, title); 11 | } 12 | 13 | layout(layout: SchemaBooleanLayout) { 14 | this._options.layout = layout; 15 | return this; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/fields/date.ts: -------------------------------------------------------------------------------- 1 | import { StandardGenerator } from '../base'; 2 | 3 | export class DateFieldGenerator extends StandardGenerator { 4 | protected _options: { 5 | dateFormat?: string; 6 | calendarTodayLabel?: string; 7 | } = {}; 8 | 9 | constructor(name?: string, title?: string) { 10 | super('date', name, title); 11 | } 12 | 13 | format(format: string) { 14 | this._options.dateFormat = format; 15 | return this; 16 | } 17 | 18 | todayLabel(label: string) { 19 | this._options.calendarTodayLabel = label; 20 | return this; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/fields/datetime.ts: -------------------------------------------------------------------------------- 1 | import { StandardGenerator } from '../base'; 2 | 3 | export class DatetimeFieldGenerator extends StandardGenerator { 4 | protected _options: { 5 | dateFormat?: string; 6 | timeFormat?: string; 7 | timeStep?: number; 8 | calendarTodayLabel?: string; 9 | } = {}; 10 | 11 | constructor(name?: string, title?: string) { 12 | super('datetime', name, title); 13 | } 14 | 15 | format({ date, time }: { date?: string; time?: string }) { 16 | if (date) { 17 | this._options.dateFormat = date; 18 | } 19 | if (time) { 20 | this._options.timeFormat = time; 21 | } 22 | return this; 23 | } 24 | 25 | step(step: number) { 26 | this._options.timeStep = step; 27 | return this; 28 | } 29 | 30 | todayLabel(label: string) { 31 | this._options.calendarTodayLabel = label; 32 | return this; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/fields/document.ts: -------------------------------------------------------------------------------- 1 | import { OrderingGenerator } from '../ordering'; 2 | import { GeneratorWithFields } from '../base'; 3 | import { PredefinedField, SchemaField, SchemaIcon } from '../types'; 4 | import { subgenerateMany } from '../util/generate'; 5 | import { FieldSetGenerator } from './fieldset'; 6 | 7 | export class DocumentGenerator extends GeneratorWithFields { 8 | protected _liveEdit?: boolean; 9 | protected _orderings: OrderingGenerator[] = []; 10 | protected _fieldsets: FieldSetGenerator[] = []; 11 | protected _icon?: SchemaIcon; 12 | 13 | constructor( 14 | predefinedFields: PredefinedField | undefined, 15 | name: string, 16 | title?: string, 17 | ) { 18 | super(predefinedFields, 'document', name, title); 19 | } 20 | 21 | liveEdit(liveEdit = true) { 22 | this._liveEdit = liveEdit; 23 | return this; 24 | } 25 | 26 | icon(icon: SchemaIcon) { 27 | this._icon = icon; 28 | return this; 29 | } 30 | 31 | orderings(orderings: OrderingGenerator[]) { 32 | this._orderings = orderings; 33 | return this; 34 | } 35 | 36 | fieldsets(sets: FieldSetGenerator[]) { 37 | this._fieldsets = sets; 38 | return this; 39 | } 40 | 41 | protected extendProperties( 42 | field: SchemaField & { 43 | orderings?: OrderingGenerator[]; 44 | icon?: any; 45 | liveEdit?: boolean; 46 | fieldsets: FieldSetGenerator[]; 47 | }, 48 | ) { 49 | super.extendProperties(field); 50 | if (this._orderings.length) { 51 | field.orderings = this._orderings; 52 | } 53 | if (this._icon) { 54 | field.icon = this._icon; 55 | } 56 | if (this._liveEdit) { 57 | field.liveEdit = true; 58 | } 59 | if (this._fieldsets.length) { 60 | field.fieldsets = this._fieldsets; 61 | } 62 | } 63 | 64 | generate() { 65 | const field = super.generate() as SchemaField & { 66 | fieldsets: FieldSetGenerator[]; 67 | orderings: OrderingGenerator[]; 68 | }; 69 | 70 | if (field.orderings) { 71 | field.orderings = subgenerateMany(field.orderings); 72 | } 73 | 74 | if (field.fieldsets) { 75 | field.fieldsets = subgenerateMany(field.fieldsets); 76 | } 77 | 78 | return field; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/fields/field.ts: -------------------------------------------------------------------------------- 1 | import { GeneratorWithFields } from '../base'; 2 | import { PredefinedField } from '../types'; 3 | 4 | export class FieldGenerator extends GeneratorWithFields { 5 | constructor( 6 | predefinedFields: PredefinedField | undefined, 7 | type: string, 8 | name?: string, 9 | title?: string, 10 | ) { 11 | super(predefinedFields, type, name || type, title); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/fields/fieldset.ts: -------------------------------------------------------------------------------- 1 | import { BaseGenerator } from '../base'; 2 | import { SchemaFieldset } from '../types'; 3 | import { generateTitle } from '../util/title'; 4 | 5 | export class FieldSetGenerator extends BaseGenerator { 6 | protected _name: string; 7 | protected _options: { 8 | collapsible?: boolean; 9 | collapsed?: boolean; 10 | columns?: number; 11 | } = {}; 12 | 13 | constructor(name: string, title?: string) { 14 | super(name, title); 15 | this._name = name; 16 | } 17 | 18 | collapsible(isCollapsible = true) { 19 | this._options.collapsible = isCollapsible; 20 | return this; 21 | } 22 | 23 | collapsed(isCollapsed = true) { 24 | this._options.collapsed = isCollapsed; 25 | return this; 26 | } 27 | 28 | columns(count: number) { 29 | this._options.columns = count; 30 | return this; 31 | } 32 | 33 | generate() { 34 | const fieldset: SchemaFieldset = { 35 | name: this._name, 36 | }; 37 | 38 | fieldset.title = generateTitle(this._name, this._title); 39 | 40 | if (Object.keys(this._options).length > 0) { 41 | fieldset.options = this._options; 42 | } 43 | 44 | return fieldset; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/fields/file.ts: -------------------------------------------------------------------------------- 1 | import { GeneratorWithFields } from '../base'; 2 | import { PredefinedField } from '../types'; 3 | 4 | export class FileFieldGenerator extends GeneratorWithFields { 5 | protected _options: { 6 | accept?: string; 7 | storeOriginalFilename?: boolean; 8 | } = {}; 9 | 10 | constructor( 11 | predefinedFields: PredefinedField | undefined, 12 | name?: string, 13 | title?: string, 14 | ) { 15 | super(predefinedFields, 'file', name, title); 16 | } 17 | 18 | accept(accept: string) { 19 | this._options.accept = accept; 20 | return this; 21 | } 22 | 23 | storeOriginalFilename(storeOriginalFilename = true) { 24 | this._options.storeOriginalFilename = storeOriginalFilename; 25 | return this; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/fields/geopoint.ts: -------------------------------------------------------------------------------- 1 | import { StandardGenerator } from '../base'; 2 | 3 | export class GeopointFieldGenerator extends StandardGenerator { 4 | constructor(name?: string, title?: string) { 5 | super('geopoint', name, title); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/fields/image.ts: -------------------------------------------------------------------------------- 1 | import { GeneratorWithFields } from '../base'; 2 | import { PredefinedField, SchemaImageMetadata } from '../types'; 3 | 4 | const alt = { 5 | name: 'alt', 6 | title: 'Alt Text', 7 | type: 'string', 8 | }; 9 | 10 | const caption = { 11 | name: 'caption', 12 | title: 'Caption', 13 | type: 'array', 14 | of: [{ type: 'block' }], 15 | }; 16 | 17 | export class ImageFieldGenerator extends GeneratorWithFields { 18 | protected _options: { 19 | metadata?: SchemaImageMetadata[]; 20 | hotspot?: boolean; 21 | storeOriginalFilename?: boolean; 22 | accept?: string; 23 | sources?: any[]; // @TODO Improve type 24 | } = {}; 25 | 26 | constructor( 27 | predefinedFields: PredefinedField | undefined, 28 | name?: string, 29 | title?: string, 30 | ) { 31 | super(predefinedFields, 'image', name, title); 32 | this._predefinedFields = { 33 | alt, 34 | caption, 35 | }; 36 | } 37 | 38 | metadata(metadata: SchemaImageMetadata[]) { 39 | this._options.metadata = metadata; 40 | return this; 41 | } 42 | 43 | hotspot(hotspot = true) { 44 | this._options.hotspot = hotspot; 45 | return this; 46 | } 47 | 48 | storeOriginalFilename(store = true) { 49 | this._options.storeOriginalFilename = store; 50 | return this; 51 | } 52 | 53 | accept(mimeType: string) { 54 | this._options.accept = mimeType; 55 | return this; 56 | } 57 | 58 | sources(sources: any[]) { 59 | this._options.sources = sources; 60 | return this; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/fields/index.ts: -------------------------------------------------------------------------------- 1 | export * from './array'; 2 | export * from './blocks'; 3 | export * from './boolean'; 4 | export * from './date'; 5 | export * from './datetime'; 6 | export * from './document'; 7 | export * from './field'; 8 | export * from './fieldset'; 9 | export * from './file'; 10 | export * from './geopoint'; 11 | export * from './image'; 12 | export * from './number'; 13 | export * from './object'; 14 | export * from './reference'; 15 | export * from './slug'; 16 | export * from './string'; 17 | export * from './text'; 18 | export * from './url'; 19 | -------------------------------------------------------------------------------- /src/fields/number.ts: -------------------------------------------------------------------------------- 1 | import { SchemaNumberPredefined } from 'src/types'; 2 | import { StandardGenerator } from '../base'; 3 | 4 | export class NumberFieldGenerator extends StandardGenerator { 5 | protected _options: { 6 | list?: SchemaNumberPredefined[]; 7 | } = {}; 8 | 9 | constructor(name?: string, title?: string) { 10 | super('number', name, title); 11 | } 12 | 13 | list(items: SchemaNumberPredefined[]) { 14 | this._options.list = items; 15 | return this; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/fields/object.ts: -------------------------------------------------------------------------------- 1 | import { subgenerateMany } from '../util/generate'; 2 | import { GeneratorWithFields } from '../base'; 3 | import { PredefinedField, SchemaField } from '../types'; 4 | import { FieldSetGenerator } from './fieldset'; 5 | 6 | export class ObjectFieldGenerator extends GeneratorWithFields { 7 | protected _fieldsets: FieldSetGenerator[] = []; 8 | protected _inputComponent: any; // @TODO type React? 9 | protected _options: { 10 | collapsible?: boolean; 11 | collapsed?: boolean; 12 | } = {}; 13 | 14 | constructor( 15 | predefinedFields: PredefinedField | undefined, 16 | name?: string, 17 | title?: string, 18 | ) { 19 | super(predefinedFields, 'object', name, title); 20 | } 21 | 22 | collapsible(isCollapsible = true) { 23 | this._options.collapsible = isCollapsible; 24 | return this; 25 | } 26 | 27 | collapsed(isCollapsed = true) { 28 | this._options.collapsed = isCollapsed; 29 | return this; 30 | } 31 | 32 | fieldsets(sets: FieldSetGenerator[]) { 33 | this._fieldsets = sets; 34 | return this; 35 | } 36 | 37 | inputComponent(component: any) { 38 | this._inputComponent = component; 39 | return this; 40 | } 41 | 42 | protected extendProperties( 43 | field: SchemaField & { 44 | fieldsets: FieldSetGenerator[]; 45 | inputComponent: any; 46 | }, 47 | ) { 48 | super.extendProperties(field); 49 | if (this._fieldsets.length) { 50 | field.fieldsets = this._fieldsets; 51 | } 52 | if (this._inputComponent) { 53 | field.inputComponent = this._inputComponent; 54 | } 55 | } 56 | 57 | generate() { 58 | const field = super.generate() as SchemaField & { 59 | fieldsets: FieldSetGenerator[]; 60 | }; 61 | 62 | if (field.fieldsets) { 63 | field.fieldsets = subgenerateMany(field.fieldsets); 64 | } 65 | 66 | return field; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/fields/reference.ts: -------------------------------------------------------------------------------- 1 | import { StandardGenerator } from '../base'; 2 | import { 3 | SchemaField, 4 | SchemaReference, 5 | SchemaReferenceFilter, 6 | SchemaRefrenceFilterParams, 7 | } from '../types'; 8 | 9 | export class ReferenceFieldGenerator extends StandardGenerator { 10 | protected _references: SchemaReference[] = []; 11 | protected _filter: SchemaReferenceFilter = ''; 12 | protected _filterParams?: SchemaRefrenceFilterParams = undefined; 13 | protected _weak?: boolean; 14 | 15 | constructor(name?: string, title?: string) { 16 | super('reference', name, title); 17 | } 18 | 19 | add(reference: SchemaReference | string) { 20 | if (typeof reference === 'string') { 21 | this._references.push({ type: reference }); 22 | } else if (typeof reference === 'object') { 23 | this._references.push(reference); 24 | } 25 | return this; 26 | } 27 | 28 | filter(filter: SchemaReferenceFilter, params: SchemaRefrenceFilterParams) { 29 | // groq string or function 30 | this._filter = filter; 31 | this._filterParams = params; 32 | return this; 33 | } 34 | 35 | to(references: SchemaReference[]) { 36 | this._references = references; 37 | return this; 38 | } 39 | 40 | weak(isWeak = true) { 41 | this._weak = isWeak; 42 | return this; 43 | } 44 | 45 | protected extendProperties( 46 | field: SchemaField & { to?: SchemaReference[]; weak?: boolean }, 47 | ) { 48 | if (this._weak !== undefined) { 49 | field.weak = this._weak; 50 | } 51 | if (this._references.length) { 52 | field.to = this._references; 53 | } 54 | if (this._filter) { 55 | field.options = { 56 | filter: this._filter, 57 | }; 58 | if (this._filterParams) { 59 | field.options.filterParams = this._filterParams; 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/fields/slug.ts: -------------------------------------------------------------------------------- 1 | import { SchemaSlugSlugify } from 'src/types'; 2 | import { StandardGenerator } from '../base'; 3 | 4 | export class SlugFieldGenerator extends StandardGenerator { 5 | protected _options: { 6 | source?: string; 7 | maxLength?: number; 8 | slugify?: SchemaSlugSlugify; 9 | isUnique?: () => any; 10 | } = {}; 11 | 12 | constructor(name?: string, title?: string) { 13 | super('slug', name, title); 14 | } 15 | 16 | source(source: string) { 17 | this._options.source = source; 18 | return this; 19 | } 20 | 21 | maxLength(maxLength: number) { 22 | this._options.maxLength = maxLength; 23 | return this; 24 | } 25 | 26 | slugify(slugifyFn: SchemaSlugSlugify) { 27 | this._options.slugify = slugifyFn; 28 | return this; 29 | } 30 | 31 | isUnique(fn: () => any) { 32 | this._options.isUnique = fn; 33 | return this; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/fields/string.ts: -------------------------------------------------------------------------------- 1 | import { StandardGenerator } from '../base'; 2 | import { 3 | SchemaStringPredefined, 4 | SchemaStringLayout, 5 | SchemaStringDirection, 6 | } from '../types'; 7 | 8 | export class StringFieldGenerator extends StandardGenerator { 9 | protected _options: { 10 | list?: SchemaStringPredefined[]; 11 | layout?: SchemaStringLayout; 12 | direction?: SchemaStringDirection; 13 | } = {}; 14 | 15 | constructor(name?: string, title?: string) { 16 | super('string', name, title); 17 | } 18 | 19 | list(items: SchemaStringPredefined[]) { 20 | this._options.list = items; 21 | return this; 22 | } 23 | 24 | layout(layout: SchemaStringLayout) { 25 | this._options.layout = layout; 26 | return this; 27 | } 28 | 29 | direction(direction: SchemaStringDirection) { 30 | this._options.direction = direction; 31 | return this; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/fields/text.ts: -------------------------------------------------------------------------------- 1 | import { StandardGenerator } from '../base'; 2 | import { SchemaField } from '../types'; 3 | 4 | export class TextFieldGenerator extends StandardGenerator { 5 | _rows?: number; 6 | 7 | constructor(name?: string, title?: string) { 8 | super('text', name, title); 9 | } 10 | 11 | rows(count: number) { 12 | this._rows = count; 13 | return this; 14 | } 15 | 16 | protected extendProperties(field: SchemaField & { rows?: number }) { 17 | if (this._rows) { 18 | field.rows = this._rows; 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/fields/url.ts: -------------------------------------------------------------------------------- 1 | import { StandardGenerator } from '../base'; 2 | 3 | export class UrlFieldGenerator extends StandardGenerator { 4 | constructor(name?: string, title?: string) { 5 | super('url', name, title); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArrayFieldGenerator, 3 | BlocksFieldGenerator, 4 | BooleanFieldGenerator, 5 | DateFieldGenerator, 6 | DatetimeFieldGenerator, 7 | DocumentGenerator, 8 | FieldGenerator, 9 | FieldSetGenerator, 10 | FileFieldGenerator, 11 | GeopointFieldGenerator, 12 | ImageFieldGenerator, 13 | NumberFieldGenerator, 14 | ObjectFieldGenerator, 15 | ReferenceFieldGenerator, 16 | SlugFieldGenerator, 17 | StringFieldGenerator, 18 | TextFieldGenerator, 19 | UrlFieldGenerator, 20 | } from './fields'; 21 | import { OrderingGenerator } from './ordering'; 22 | import { PreviewGenerator } from './preview'; 23 | import { PredefinedField } from './types'; 24 | 25 | export class SchemaBuilder { 26 | private _predefinedFields: Record; 27 | 28 | constructor(predefinedFields?: Record>) { 29 | this._predefinedFields = predefinedFields || {}; 30 | } 31 | 32 | define(key: string, field: PredefinedField) { 33 | this._predefinedFields[key] = field; 34 | } 35 | 36 | // Base 37 | f = this.field; 38 | field(type: string, name?: string, title?: string) { 39 | return new FieldGenerator(this._predefinedFields, type, name, title); 40 | } 41 | // Field types 42 | arr = this.array; 43 | array(name?: string, title?: string) { 44 | return new ArrayFieldGenerator(this._predefinedFields, name, title); 45 | } 46 | blocks(name?: string, title?: string) { 47 | return new BlocksFieldGenerator(this._predefinedFields, name, title); 48 | } 49 | bool = this.boolean; 50 | boolean(name?: string, title?: string) { 51 | return new BooleanFieldGenerator(name, title); 52 | } 53 | date(name?: string, title?: string) { 54 | return new DateFieldGenerator(name, title); 55 | } 56 | dt = this.datetime; 57 | datetime(name?: string, title?: string) { 58 | return new DatetimeFieldGenerator(name, title); 59 | } 60 | doc = this.document; 61 | document(name: string, title?: string) { 62 | return new DocumentGenerator(this._predefinedFields, name, title); 63 | } 64 | fset = this.fieldset; 65 | fieldset(name: string, title?: string) { 66 | return new FieldSetGenerator(name, title); 67 | } 68 | file(name?: string, title?: string) { 69 | return new FileFieldGenerator(this._predefinedFields, name, title); 70 | } 71 | geo = this.geopoint; 72 | geopoint(name?: string, title?: string) { 73 | return new GeopointFieldGenerator(name, title); 74 | } 75 | img = this.image; 76 | image(name?: string, title?: string) { 77 | return new ImageFieldGenerator(this._predefinedFields, name, title); 78 | } 79 | num = this.number; 80 | number(name?: string, title?: string) { 81 | return new NumberFieldGenerator(name, title); 82 | } 83 | obj = this.object; 84 | object(name?: string, title?: string) { 85 | return new ObjectFieldGenerator(this._predefinedFields, name, title); 86 | } 87 | ref = this.reference; 88 | reference(name?: string, title?: string) { 89 | return new ReferenceFieldGenerator(name, title); 90 | } 91 | slug(name?: string, title?: string) { 92 | return new SlugFieldGenerator(name, title); 93 | } 94 | str = this.string; 95 | string(name?: string, title?: string) { 96 | return new StringFieldGenerator(name, title); 97 | } 98 | text(name?: string, title?: string) { 99 | return new TextFieldGenerator(name, title); 100 | } 101 | url(name?: string, title?: string) { 102 | return new UrlFieldGenerator(name, title); 103 | } 104 | // Orderings 105 | sort = this.ordering; 106 | ordering(name?: string, title?: string) { 107 | return new OrderingGenerator(name, title); 108 | } 109 | // Preview 110 | view = this.ordering; 111 | preview(select?: Record) { 112 | return new PreviewGenerator(select); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/ordering.ts: -------------------------------------------------------------------------------- 1 | import { BaseGenerator } from './base'; 2 | import { SchemaOrder, SchemaOrdering } from './types'; 3 | 4 | export class OrderingGenerator extends BaseGenerator { 5 | protected _orders: SchemaOrder[]; 6 | 7 | constructor(name?: string, title?: string) { 8 | super(name, title); 9 | this._orders = []; 10 | } 11 | 12 | by(field: string, direction: string) { 13 | this._orders.push({ field, direction }); 14 | return this; 15 | } 16 | 17 | generate() { 18 | if (!this._name) throw Error('Name is required'); 19 | const ordering: SchemaOrdering = { 20 | name: this._name, 21 | }; 22 | if (this._title) ordering.title = this._title; 23 | if (this._orders.length) ordering.by = this._orders; 24 | return ordering; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/preview.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SchemaPreview, 3 | SchemaPreviewPrepare, 4 | SchemaPreviewComponent, 5 | } from './types'; 6 | 7 | export class PreviewGenerator { 8 | protected _select: Record; 9 | protected _prepare?: SchemaPreviewPrepare; 10 | protected _component?: SchemaPreviewComponent; 11 | 12 | constructor(select: Record = {}) { 13 | this._select = select; 14 | } 15 | 16 | select(property: string, selection?: string) { 17 | selection = selection || property; 18 | this._select[property] = selection; 19 | return this; 20 | } 21 | 22 | prepare(fn: SchemaPreviewPrepare) { 23 | this._prepare = fn; 24 | return this; 25 | } 26 | 27 | component(component: SchemaPreviewComponent) { 28 | this._component = component; 29 | return this; 30 | } 31 | 32 | generate() { 33 | if (Object.keys(this._select).length > 0) { 34 | const preview: SchemaPreview = { 35 | select: this._select, 36 | }; 37 | if (this._prepare) preview.prepare = this._prepare; 38 | if (this._component) preview.component = this._component; 39 | return preview; 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/test/schema.test.ts: -------------------------------------------------------------------------------- 1 | import { SchemaBuilder } from '../index'; 2 | 3 | const S = new SchemaBuilder(); 4 | 5 | test('generates a field using low-level field generator', () => { 6 | const schema = { 7 | type: 'string', 8 | name: 'testBasic', 9 | title: 'A Basic String', 10 | description: 'This is just a string.', 11 | }; 12 | const generated = S.field('string', 'testBasic', 'A Basic String') 13 | .description('This is just a string.') 14 | .generate(); 15 | expect(generated).toStrictEqual(schema); 16 | }); 17 | 18 | test('adds readonly property', () => { 19 | const schema = { 20 | type: 'string', 21 | name: 'testReadOnly', 22 | title: 'Test Read only', 23 | readOnly: true, 24 | }; 25 | const generated = S.str('testReadOnly').readOnly().generate(); 26 | expect(generated).toStrictEqual(schema); 27 | }); 28 | 29 | test('adds readonly property', () => { 30 | const schema = { 31 | type: 'string', 32 | name: 'testHidden', 33 | title: 'Test Hidden', 34 | hidden: true, 35 | }; 36 | const generated = S.str('testHidden').hidden().generate(); 37 | expect(generated).toStrictEqual(schema); 38 | }); 39 | 40 | test('generates a title automatically if not supplied', () => { 41 | const schema = { 42 | type: 'string', 43 | name: 'testTitle', 44 | title: 'Test Title', 45 | }; 46 | const generated = S.field('string', 'testTitle').generate(); 47 | expect(generated).toStrictEqual(schema); 48 | }); 49 | 50 | test('generates without a title if explicitly set to an empty string', () => { 51 | const schema = { 52 | type: 'string', 53 | name: 'testNoTitle', 54 | }; 55 | const generated = S.field('string', 'testNoTitle', '').generate(); 56 | expect(generated).toStrictEqual(schema); 57 | }); 58 | 59 | test('set name and title via method', () => { 60 | const schema = { 61 | type: 'string', 62 | name: 'testName', 63 | title: 'A Custom Title', 64 | }; 65 | const generated = S.string('') 66 | .name('testName') 67 | .title('A Custom Title') 68 | .generate(); 69 | expect(generated).toStrictEqual(schema); 70 | }); 71 | 72 | test('generates a document', () => { 73 | const schema = { 74 | type: 'document', 75 | name: 'testDocument', 76 | title: 'Test Document', 77 | liveEdit: true, 78 | }; 79 | const generated = S.doc('testDocument').liveEdit().generate(); 80 | expect(generated).toStrictEqual(schema); 81 | }); 82 | 83 | test('generates a document with a field', () => { 84 | const schema = { 85 | type: 'document', 86 | name: 'testDocument', 87 | title: 'Test Document', 88 | fields: [ 89 | { 90 | type: 'string', 91 | name: 'title', 92 | title: 'Title', 93 | }, 94 | ], 95 | }; 96 | const generated = S.doc('testDocument') 97 | .fields([S.str('title')]) 98 | .generate(); 99 | expect(generated).toStrictEqual(schema); 100 | }); 101 | 102 | test('generates a document with a fieldset', () => { 103 | const schema = { 104 | type: 'document', 105 | name: 'testDocument', 106 | title: 'Test Document', 107 | fieldsets: [ 108 | { 109 | name: 'testFieldset', 110 | title: 'Test Fieldset', 111 | options: { 112 | collapsed: true, 113 | collapsible: false, 114 | columns: 3, 115 | }, 116 | }, 117 | ], 118 | fields: [ 119 | { 120 | type: 'string', 121 | name: 'title', 122 | title: 'Title', 123 | fieldset: 'testFieldset', 124 | }, 125 | ], 126 | }; 127 | const generated = S.doc('testDocument') 128 | .fieldsets([ 129 | S.fieldset('testFieldset').collapsed().collapsible(false).columns(3), 130 | ]) 131 | .fields([S.str('title').fieldset('testFieldset')]) 132 | .generate(); 133 | expect(generated).toStrictEqual(schema); 134 | }); 135 | 136 | test('generates a document with a preview', () => { 137 | const schema = { 138 | type: 'document', 139 | name: 'testDocument', 140 | title: 'Test Document', 141 | preview: { 142 | select: { 143 | title: 'title', 144 | subtitle: 'testSubtitle', 145 | }, 146 | }, 147 | }; 148 | const generated = S.doc('testDocument') 149 | .preview(S.preview().select('title').select('subtitle', 'testSubtitle')) 150 | .generate(); 151 | expect(generated).toStrictEqual(schema); 152 | }); 153 | 154 | test('generates a document with an ordering', () => { 155 | const schema = { 156 | type: 'document', 157 | name: 'testDocument', 158 | title: 'Test Document', 159 | orderings: [ 160 | { 161 | name: 'publishedDate', 162 | title: 'Published Date, New', 163 | by: [ 164 | { 165 | direction: 'desc', 166 | field: 'published_on', 167 | }, 168 | ], 169 | }, 170 | ], 171 | }; 172 | 173 | const generated = S.doc('testDocument') 174 | .orderings([ 175 | S.ordering('publishedDate') 176 | .title('Published Date, New') 177 | .by('published_on', 'desc'), 178 | ]) 179 | .generate(); 180 | expect(generated).toStrictEqual(schema); 181 | }); 182 | 183 | test('generates a string', () => { 184 | const schema = { 185 | type: 'string', 186 | name: 'testString', 187 | title: 'Test String', 188 | options: { 189 | list: ['foo', { title: 'Bar!?', value: 'bar' }], 190 | layout: 'dropdown', 191 | direction: 'vertical', 192 | }, 193 | }; 194 | const generated = S.str('testString') 195 | .list([ 196 | 'foo', 197 | { 198 | title: 'Bar!?', 199 | value: 'bar', 200 | }, 201 | ]) 202 | .layout('dropdown') 203 | .direction('vertical') 204 | .generate(); 205 | expect(generated).toStrictEqual(schema); 206 | }); 207 | 208 | test('generates a date with format', () => { 209 | const schema = { 210 | type: 'date', 211 | name: 'testDate', 212 | title: 'Test Date', 213 | options: { 214 | calendarTodayLabel: 'Today!', 215 | dateFormat: 'YYYY-MM-DD', 216 | }, 217 | }; 218 | const generated = S.date('testDate') 219 | .format('YYYY-MM-DD') 220 | .todayLabel('Today!') 221 | .generate(); 222 | expect(generated).toStrictEqual(schema); 223 | }); 224 | 225 | test('generates a datetime with options', () => { 226 | const schema = { 227 | type: 'datetime', 228 | name: 'testDatetime', 229 | title: 'Test Datetime', 230 | options: { 231 | calendarTodayLabel: 'Today!', 232 | dateFormat: 'YYYY-MM-DD', 233 | timeFormat: 'HH:mm', 234 | timeStep: 15, 235 | }, 236 | }; 237 | const generated = S.datetime('testDatetime') 238 | .format({ date: 'YYYY-MM-DD', time: 'HH:mm' }) 239 | .step(15) 240 | .todayLabel('Today!') 241 | .generate(); 242 | expect(generated).toStrictEqual(schema); 243 | }); 244 | 245 | test('generates a reference', () => { 246 | const schema = { 247 | type: 'reference', 248 | name: 'testRef', 249 | title: 'Test Ref', 250 | to: [{ type: 'foo' }, { type: 'bar' }, { type: 'baz' }], 251 | weak: true, 252 | options: { 253 | filter: 'qux == $qux', 254 | filterParams: { 255 | qux: 'Quux', 256 | }, 257 | }, 258 | }; 259 | const generated = S.ref('testRef') 260 | .to([{ type: 'foo' }]) 261 | .add('bar') 262 | .add({ type: 'baz' }) 263 | .weak() 264 | .filter('qux == $qux', { qux: 'Quux' }) 265 | .generate(); 266 | expect(generated).toStrictEqual(schema); 267 | }); 268 | 269 | test('generates a boolean', () => { 270 | const schema = { 271 | type: 'boolean', 272 | name: 'testBoolean', 273 | title: 'Test Boolean', 274 | options: { 275 | layout: 'checkbox', 276 | }, 277 | }; 278 | const generated = S.bool('testBoolean').layout('checkbox').generate(); 279 | expect(generated).toStrictEqual(schema); 280 | }); 281 | 282 | test('generates a slug', () => { 283 | const schema = { 284 | type: 'slug', 285 | name: 'testSlug', 286 | title: 'Test Slug', 287 | options: { 288 | maxLength: 30, 289 | source: 'title', 290 | }, 291 | }; 292 | const generated = S.slug('testSlug').source('title').maxLength(30).generate(); 293 | expect(generated).toStrictEqual(schema); 294 | }); 295 | 296 | test('generates a file', () => { 297 | const schema = { 298 | type: 'file', 299 | name: 'testFile', 300 | title: 'Test File', 301 | options: { 302 | accept: 'image/*,.pdf', 303 | storeOriginalFilename: false, 304 | }, 305 | }; 306 | const generated = S.file('testFile') 307 | .accept('image/*,.pdf') 308 | .storeOriginalFilename(false) 309 | .generate(); 310 | expect(generated).toStrictEqual(schema); 311 | }); 312 | 313 | test('generates a geopoint', () => { 314 | const schema = { 315 | type: 'geopoint', 316 | name: 'testGeopoint', 317 | title: 'Test Geopoint', 318 | }; 319 | const generated = S.geo('testGeopoint').generate(); 320 | expect(generated).toStrictEqual(schema); 321 | }); 322 | 323 | test('generates a number', () => { 324 | const schema = { 325 | type: 'number', 326 | name: 'testNumber', 327 | title: 'Test Number', 328 | options: { 329 | list: [1, { title: 'Two', value: 2 }], 330 | }, 331 | }; 332 | const generated = S.num('testNumber') 333 | .list([1, { title: 'Two', value: 2 }]) 334 | .generate(); 335 | expect(generated).toStrictEqual(schema); 336 | }); 337 | 338 | test('generates a url', () => { 339 | const schema = { 340 | type: 'url', 341 | name: 'testUrl', 342 | title: 'Test URL', 343 | }; 344 | const generated = S.url('testUrl', 'Test URL').generate(); 345 | expect(generated).toStrictEqual(schema); 346 | }); 347 | 348 | test('generates basic text', () => { 349 | const schema = { 350 | type: 'text', 351 | name: 'testText', 352 | title: 'Test Text', 353 | rows: 100, 354 | }; 355 | const generated = S.text('testText').rows(100).generate(); 356 | expect(generated).toStrictEqual(schema); 357 | }); 358 | 359 | test('generates an array of strings with options', () => { 360 | const schema = { 361 | type: 'array', 362 | name: 'testArray', 363 | title: 'Test Array', 364 | of: [ 365 | { 366 | name: 'title', 367 | title: 'Title', 368 | type: 'string', 369 | }, 370 | { 371 | name: 'description', 372 | title: 'Description', 373 | type: 'string', 374 | }, 375 | ], 376 | options: { 377 | sortable: false, 378 | layout: 'grid', 379 | }, 380 | }; 381 | const generated = S.arr('testArray') 382 | .of([S.str('title'), S.str('description')]) 383 | .sortable(false) 384 | .layout('grid') 385 | .generate(); 386 | expect(generated).toStrictEqual(schema); 387 | }); 388 | 389 | test('generates an array of predefined strings', () => { 390 | const list = [ 391 | { title: 'Foo', value: 'foo' }, 392 | { title: 'Bar', value: 'bar' }, 393 | { title: 'Baz', value: 'baz' }, 394 | ]; 395 | const schema = { 396 | type: 'array', 397 | name: 'testArray', 398 | title: 'Test Array', 399 | of: [{ type: 'string' }], 400 | options: { list }, 401 | }; 402 | const generated = S.arr('testArray').of([S.str()]).list(list).generate(); 403 | expect(generated).toStrictEqual(schema); 404 | }); 405 | 406 | test('throws if predefined array field not found', () => { 407 | const generatorFn = () => 408 | S.array('testArray').of(['thisIsNotDefined']).generate(); 409 | expect(generatorFn).toThrow(); 410 | }); 411 | 412 | test('throws if fields method is used on array', () => { 413 | const generatorFn = () => 414 | S.arr('testArray') 415 | // @ts-expect-error 416 | .fields([{ type: 'string' }]) 417 | .generate(); 418 | expect(generatorFn).toThrow(); 419 | }); 420 | 421 | test('generates blocktext', () => { 422 | const styles = [ 423 | { title: 'Normal', value: 'normal' }, 424 | { title: 'Heading 1', value: 'h1' }, 425 | ]; 426 | const lists = [ 427 | { title: 'Bullet', value: 'bullet' }, 428 | { title: 'Numbered', value: 'number' }, 429 | ]; 430 | const decorators = [ 431 | { title: 'Strong', value: 'strong' }, 432 | { title: 'Emphasis', value: 'em' }, 433 | ]; 434 | const schema = { 435 | type: 'array', 436 | name: 'testBlockText', 437 | title: 'Test Block Text', 438 | of: [ 439 | { 440 | type: 'block', 441 | styles, 442 | lists, 443 | marks: { 444 | decorators, 445 | annotations: [ 446 | { 447 | title: 'Link', 448 | name: 'link', 449 | type: 'object', 450 | fields: [ 451 | { 452 | title: 'URL', 453 | name: 'href', 454 | type: 'url', 455 | }, 456 | { 457 | title: 'Open in New Tab?', 458 | name: 'newtab', 459 | type: 'boolean', 460 | }, 461 | ], 462 | }, 463 | ], 464 | }, 465 | }, 466 | { 467 | name: 'testImage', 468 | title: 'Test Image', 469 | type: 'image', 470 | }, 471 | ], 472 | }; 473 | const generated = S.blocks('testBlockText') 474 | .of([S.img('testImage')]) 475 | .styles(styles) 476 | .lists(lists) 477 | .marks({ 478 | decorators, 479 | annotations: [ 480 | S.obj('link').fields([ 481 | S.url('href', 'URL'), 482 | S.bool('newtab', 'Open in New Tab?'), 483 | ]), 484 | ], 485 | }) 486 | .generate(); 487 | expect(generated).toStrictEqual(schema); 488 | }); 489 | 490 | test('generates an object', () => { 491 | const schema = { 492 | type: 'object', 493 | name: 'testObject', 494 | title: 'Test Object', 495 | options: { 496 | collapsible: true, 497 | collapsed: false, 498 | }, 499 | }; 500 | const generated = S.obj('testObject') 501 | .collapsible() 502 | .collapsed(false) 503 | .generate(); 504 | expect(generated).toStrictEqual(schema); 505 | }); 506 | 507 | test('generates an object with fieldsets', () => { 508 | const schema = { 509 | type: 'object', 510 | name: 'testObject', 511 | title: 'Test Object', 512 | fieldsets: [ 513 | { 514 | name: 'testFieldset', 515 | title: 'Test Fieldset', 516 | options: { 517 | collapsed: true, 518 | collapsible: false, 519 | columns: 3, 520 | }, 521 | }, 522 | ], 523 | fields: [ 524 | { 525 | type: 'string', 526 | name: 'name', 527 | title: 'Name', 528 | fieldset: 'testFieldset', 529 | }, 530 | ], 531 | }; 532 | const generated = S.obj('testObject') 533 | .fieldsets([ 534 | S.fieldset('testFieldset').collapsed().collapsible(false).columns(3), 535 | ]) 536 | .fields([S.str('name').fieldset('testFieldset')]) 537 | .generate(); 538 | expect(generated).toStrictEqual(schema); 539 | }); 540 | 541 | test('generates an object with a predefined field', () => { 542 | const predefinedField = { 543 | type: 'string', 544 | name: 'title', 545 | title: 'Title', 546 | }; 547 | const S = new SchemaBuilder({ 548 | title: predefinedField, 549 | }); 550 | const schema = { 551 | type: 'object', 552 | name: 'testObject', 553 | title: 'Test Object', 554 | fields: [predefinedField], 555 | }; 556 | const generated = S.obj('testObject').field('title').generate(); 557 | expect(generated).toStrictEqual(schema); 558 | }); 559 | 560 | test('generates an image with a field', () => { 561 | const schema = { 562 | type: 'image', 563 | name: 'testImage', 564 | title: 'Test Image', 565 | fields: [ 566 | { 567 | type: 'string', 568 | name: 'testAlt', 569 | title: 'Test Alt', 570 | }, 571 | ], 572 | options: { 573 | metadata: ['exif'], 574 | hotspot: true, 575 | storeOriginalFilename: true, 576 | accept: 'image/*', 577 | }, 578 | }; 579 | const generated = S.img('testImage') 580 | .field(S.str('testAlt')) 581 | .metadata(['exif']) 582 | .hotspot() 583 | .storeOriginalFilename() 584 | .accept('image/*') 585 | .generate(); 586 | expect(generated).toStrictEqual(schema); 587 | }); 588 | 589 | test('adds a predefined field after initialisation', () => { 590 | const predefinedField = { 591 | type: 'string', 592 | name: 'title', 593 | title: 'Title', 594 | }; 595 | const S = new SchemaBuilder(); 596 | S.define('title', predefinedField); 597 | const schema = { 598 | type: 'object', 599 | name: 'testObject', 600 | title: 'Test Object', 601 | fields: [predefinedField], 602 | }; 603 | const generated = S.obj('testObject').field('title').generate(); 604 | expect(generated).toStrictEqual(schema); 605 | }); 606 | 607 | test('use a predefined field in an array', () => { 608 | const predefinedField = { 609 | type: 'string', 610 | name: 'title', 611 | title: 'Title', 612 | }; 613 | const S = new SchemaBuilder({ title: predefinedField }); 614 | const schema = { 615 | type: 'array', 616 | name: 'testArray', 617 | title: 'Test Array', 618 | of: [predefinedField], 619 | }; 620 | const generated = S.arr('testArray').of(['title']).generate(); 621 | expect(generated).toStrictEqual(schema); 622 | }); 623 | 624 | test('adds a predefined field created with the schema builder', () => { 625 | const S = new SchemaBuilder(); 626 | const predefinedField = S.str('title').generate(); 627 | S.define('title', predefinedField); 628 | const schema = { 629 | type: 'object', 630 | name: 'testObject', 631 | title: 'Test Object', 632 | fields: [ 633 | { 634 | type: 'string', 635 | name: 'title', 636 | title: 'Title', 637 | }, 638 | ], 639 | }; 640 | const generated = S.obj('testObject').field('title').generate(); 641 | expect(generated).toStrictEqual(schema); 642 | }); 643 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { StandardGenerator } from './base'; 2 | import { PreviewGenerator } from './preview'; 3 | 4 | export type PredefinedField = Record; 5 | 6 | // @TODO type properly 7 | export type SchemaValidator = (rule: any) => any; 8 | 9 | // @TODO type properly 10 | export type SchemaIcon = any; 11 | 12 | // @TODO type properly 13 | export type SchemaOptions = Record; 14 | 15 | // @TODO type properly 16 | export type SchemaPreviewSelect = Record; 17 | 18 | // @TODO type properly 19 | export type SchemaPreviewComponent = any; 20 | 21 | // @TODO type properly 22 | export type SchemaPreviewPrepare = ( 23 | selected: Record 24 | ) => Record; 25 | 26 | export interface SchemaPreview { 27 | select: SchemaPreviewSelect; 28 | prepare?: SchemaPreviewPrepare; 29 | component?: SchemaPreviewComponent; 30 | } 31 | 32 | export interface SchemaField { 33 | type: string; 34 | name?: string; 35 | title?: string | null; 36 | fieldset?: string; 37 | description?: string; 38 | readOnly?: boolean; 39 | hidden?: boolean; 40 | options?: SchemaOptions; 41 | validation?: SchemaValidator; 42 | preview?: PreviewGenerator; 43 | fields?: StandardGenerator[] | Record; 44 | of?: StandardGenerator[] | Record; 45 | } 46 | 47 | export interface SchemaOrder { 48 | field: string; 49 | direction: string; 50 | } 51 | 52 | export interface SchemaOrdering { 53 | name: string; 54 | title?: string; 55 | by?: SchemaOrder[]; 56 | } 57 | 58 | export interface SchemaFieldset { 59 | name: string; 60 | title?: string | null; 61 | options?: { 62 | collapsible?: boolean; 63 | collapsed?: boolean; 64 | columns?: number; 65 | }; 66 | } 67 | 68 | export type SchemaArrayLayout = 'tags' | 'grid'; 69 | export type SchemaArrayEditModal = 'dialog' | 'fullscreen' | 'popover'; 70 | 71 | export type SchemaArrayList = { 72 | title: string; 73 | value: string; 74 | }; 75 | 76 | export interface SchemaObjectFieldset { 77 | name: string; 78 | title?: string; 79 | options?: { 80 | collapsible?: boolean; 81 | collapsed?: boolean; 82 | columns?: number; 83 | }; 84 | } 85 | 86 | export type SchemaNumberPredefined = 87 | | number 88 | | { 89 | title: string; 90 | value: number; 91 | }; 92 | 93 | export type SchemaStringPredefined = 94 | | string 95 | | { 96 | title: string; 97 | value: string; 98 | }; 99 | 100 | export type SchemaBooleanLayout = 'switch' | 'checkbox'; 101 | 102 | export type SchemaStringLayout = 'dropdown' | 'radio'; 103 | 104 | export type SchemaStringDirection = 'horizontal' | 'vertical'; 105 | 106 | // @TODO type properly 107 | export type SchemaReference = Record; 108 | 109 | // @TODO type properly 110 | type SchemaReferenceFilterFn = (options: { 111 | object: any; 112 | parent: any; 113 | parentPath: string; 114 | }) => Promise<{ filter: any; params: any }> | { filter: any; params: any }; 115 | 116 | export type SchemaReferenceFilter = SchemaReferenceFilterFn | string; 117 | 118 | export type SchemaRefrenceFilterParams = Record; 119 | 120 | export interface SchemaBlockStyle { 121 | title: string; 122 | value: string; 123 | } 124 | 125 | export interface SchemaBlockList { 126 | title: string; 127 | value: string; 128 | } 129 | 130 | export interface SchemaBlockMarks { 131 | decorators?: { 132 | title: string; 133 | value: string; 134 | icon?: SchemaIcon; 135 | }[]; 136 | annotations?: Array; 137 | } 138 | 139 | export type SchemaSlugSlugify = ( 140 | input: string, 141 | type: Record 142 | ) => string; 143 | 144 | export type SchemaImageMetadata = 'exif' | 'location' | 'lqip' | 'palette'; 145 | -------------------------------------------------------------------------------- /src/util/generate.ts: -------------------------------------------------------------------------------- 1 | import { StandardGenerator } from '../base'; 2 | 3 | export const subgenerate = ( 4 | generator: StandardGenerator | Record, 5 | ) => (generator.generate ? generator.generate() : generator); 6 | 7 | export const subgenerateMany = ( 8 | generators: StandardGenerator[] | Record, 9 | ) => { 10 | return generators.map(subgenerate); 11 | }; 12 | -------------------------------------------------------------------------------- /src/util/title.ts: -------------------------------------------------------------------------------- 1 | import toTitleCase from 'to-title-case'; 2 | 3 | export function generateTitle( 4 | name: string | undefined, 5 | title: string | undefined, 6 | ) { 7 | if (title) { 8 | return title; 9 | } else if (!name || title === '') { 10 | return undefined; 11 | } 12 | return toTitleCase(name); 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "declaration": true, 5 | "declarationMap": false, 6 | "downlevelIteration": true, 7 | "esModuleInterop": true, 8 | "experimentalDecorators": true, 9 | "importHelpers": true, 10 | "lib": ["es2018"], 11 | "moduleResolution": "node", 12 | "noEmitOnError": true, 13 | "noImplicitAny": true, 14 | "noImplicitThis": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "outDir": "./dist", 18 | "pretty": true, 19 | "rootDir": "src", 20 | "skipLibCheck": true, 21 | "sourceMap": true, 22 | "strict": true, 23 | "strictFunctionTypes": true, 24 | "strictNullChecks": true, 25 | "target": "es5" 26 | }, 27 | "include": ["./src/**/*.ts", "./src/**/*.tsx"], 28 | "exclude": [ 29 | "**/*.test.ts", 30 | "**/*.test.tsx", 31 | "**/test/*", 32 | "node_modules", 33 | "**/node_modules/*" 34 | ] 35 | } 36 | --------------------------------------------------------------------------------