diff --git a/CHANGELOG.md b/CHANGELOG.md index a7c5f24..23966ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,12 @@ ## [Unreleased][unreleased] - + + +## [0.6.0][] - 2023-11-00 + +- Typescript generation module +- Documentation fixes ## [0.5.0][] - 2023-11-05 @@ -86,7 +89,8 @@ - Default exotic types: Any, Undefined, JSON - Custom Errors -[unreleased]: https://github.com/astrohelm/metaforge/compare/v0.5.0...HEAD +[unreleased]: https://github.com/astrohelm/metaforge/compare/v0.6.0...HEAD +[0.6.0]: https://github.com/astrohelm/metaforge/compare/v0.5.0...v0.6.0 [0.5.0]: https://github.com/astrohelm/metaforge/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/astrohelm/metaforge/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/astrohelm/metaforge/compare/v0.2.0...v0.3.0 diff --git a/README.md b/README.md index 9b89ddd..4e3f521 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

MetaForge v0.5.0 🕵️

+

MetaForge v0.6.0 🕵️

## Describe your data structures by subset of JavaScript and: @@ -20,7 +20,7 @@ npm i metaforge --save const userSchema = new Schema({ $id: 'userSchema', $meta: { name: 'user', description: 'schema for users testing' }, - phone: { $type: 'union', types: ['number', 'string'] }, //? anyof tyupe + phone: { $type: 'union', types: ['number', 'string'] }, //? number or string name: { $type: 'set', items: ['string', '?string'] }, //? set tuple mask: { $type: 'array', items: 'string' }, //? array ip: { @@ -50,7 +50,7 @@ const sample = [ //... ]; -systemSchema.warnings; // inspect after build warnings +systemSchema.warnings; // Inspect warnings after build systemSchema.test(sample); // Shema validation systemSchema.toTypescript('system'); // Typescript generation systemSchema.pull('userSchema').test(sample[0]); // Subschema validation @@ -60,14 +60,14 @@ systemSchema.pull('userSchema'); // Metadata: {..., name: 'user', description: ' ## Docs -- [About modules](./docs/modules.md#modules-or-another-words-plugins) +- ### [About modules / plugins](./docs/modules.md#modules-or-another-words-plugins) + - [Writing custom modules](./docs/modules.md#writing-custom-modules) + - [Metatype](./modules/types/README.md) | generate type annotations from schema - [Handyman](./modules/handyman/README.md) | quality of life module - [Metatest](./modules/test/README.md) | adds prototype testing - - [Metatype](./modules/types/README.md) | generate typescript:JSDOC from schema - - [Writing custom modules](./docs/prototypes.md#writing-custom-modules) -- [About prototypes](./docs/prototypes.md#readme-map) - - [Schemas contracts](./docs/prototypes.md#schemas-contracts) +- ### [About prototypes](./docs/prototypes.md#readme-map) - [How to build custom prototype](./docs/prototypes.md#writing-custom-prototypes) + - [Contracts](./docs/prototypes.md#schemas-contracts) ## Copyright & contributors diff --git a/modules/handyman/README.md b/modules/handyman/README.md index 271700f..be752c8 100644 --- a/modules/handyman/README.md +++ b/modules/handyman/README.md @@ -2,7 +2,7 @@ Handyman module allow you to: -- pass schema shorthands +- pass shorthands - validate & secure plans - pass schemas as namespace parameter - pass schemas as plans @@ -49,3 +49,23 @@ const schema = new Schema({ - tuple shorthand: ['string', 'number'] - enum shorthand: ['winter', 'spring'] - schema shorthand: new Schema('?string') + +### Example: + +```js +const schema = new Schema( + { + a: 'string', //? scalar shorthand + b: '?string', //? optional shorthand + c: ['string', 'string'], //? tuple + d: new Schema('?string'), //? Schema shorthand + e: ['winter', 'spring'], //? Enum shorthand + f: { a: 'number', b: 'string' }, //? Object shorthand + g: { $type: 'array', items: 'string' }, //? Array items shorthand + h: 'MyExternalSchema', + }, + { namespace: { MyExternalSchema: new Schema('string') } }, +); +``` + +> String shorthand is analog to { $type: type, required: type.includes('?') } diff --git a/modules/handyman/index.test.js b/modules/handyman/index.test.js new file mode 100644 index 0000000..63f9c62 --- /dev/null +++ b/modules/handyman/index.test.js @@ -0,0 +1,48 @@ +'use strict'; + +const [test, assert] = [require('node:test'), require('node:assert')]; +const Schema = require('../..'); + +test('[Handyman] Schema with namespace', () => { + const namespace = { User: new Schema('string') }; + const schema = new Schema(['User', 'User'], { namespace }); + const sample = ['Alexander', 'Ivanov']; + + assert.strictEqual(namespace.User.warnings.length + schema.warnings.length, 0); + assert.strictEqual(schema.test(sample).length, 0); +}); + +test('[Handyman] Pull schemas', () => { + const schema = new Schema({ + $id: 'MySchema', + a: 'string', + b: { $id: 'MySubSchema', c: 'number' }, + c: new Schema('?string'), + d: { $type: 'schema', schema: new Schema('number'), $id: 'MySubSchema2' }, + e: { $type: 'schema', schema: new Schema({ $type: 'number', $id: 'MySubSchema3' }) }, + }); + + assert.strictEqual(schema.warnings.length, 0); + assert.strictEqual(!!schema.pull('MySchema'), false); + assert.strictEqual(!!schema.pull('MySubSchema'), true); + assert.strictEqual(!!schema.pull('MySubSchema2'), true); + assert.strictEqual(!!schema.pull('MySubSchema3'), true); +}); + +test('[Handyman] Shorthands', () => { + const schema = new Schema( + { + a: 'string', //? scalar shorthand + b: '?string', //? optional shorthand + c: ['string', 'string'], //? tuple + d: new Schema('?string'), //? Schema shorthand + e: ['winter', 'spring'], //? Enum shorthand + f: { a: 'number', b: 'string' }, //? Object shorthand + g: { $type: 'array', items: 'string' }, //? Array items shorthand + h: 'MyExternalSchema', + }, + { namespace: { MyExternalSchema: new Schema('string') } }, + ); + + assert.strictEqual(schema.warnings.length, 0); +}); diff --git a/tests/basic.test.js b/modules/test/basic.test.js similarity index 98% rename from tests/basic.test.js rename to modules/test/basic.test.js index 1faebd9..7c49403 100644 --- a/tests/basic.test.js +++ b/modules/test/basic.test.js @@ -1,7 +1,7 @@ 'use strict'; const [test, assert] = [require('node:test'), require('node:assert')]; -const Schema = require('..'); +const Schema = require('../..'); test('Schema without errors & warnings', () => { const userSchema = new Schema({ diff --git a/modules/test/index.js b/modules/test/index.js index b393f60..dfe0156 100644 --- a/modules/test/index.js +++ b/modules/test/index.js @@ -13,11 +13,11 @@ module.exports = (schema, options) => { const Error = schema.tools.Error; function TestWrapper(plan) { - if (plan.$type === 'schema') return this.test.bind(this); + if (plan.$type === 'schema') return this.test; const planRules = plan?.$rules; const rules = Array.isArray(planRules) ? planRules : [planRules]; const tests = rules.filter(test => typeof test === 'string' || typeof test === 'function'); - typeof this.test === 'function' && tests.unshift(this.test.bind(this)); + typeof this.test === 'function' && tests.unshift(this.test); this.test = (sample, path = 'root', isPartial = false) => { if (sample === undefined || sample === null) { if (!this.$required) return []; diff --git a/tests/rules.test.js b/modules/test/rules.test.js similarity index 97% rename from tests/rules.test.js rename to modules/test/rules.test.js index 519f7cf..e65f09c 100644 --- a/tests/rules.test.js +++ b/modules/test/rules.test.js @@ -1,7 +1,7 @@ 'use strict'; const [test, assert] = [require('node:test'), require('node:assert')]; -const Schema = require('..'); +const Schema = require('../..'); test('Rules', () => { const rule1 = sample => sample?.length > 5; diff --git a/modules/types/README.md b/modules/types/README.md index 8d1564e..cf02ebb 100644 --- a/modules/types/README.md +++ b/modules/types/README.md @@ -1,3 +1,94 @@ -# Metatype module / generate typescript:JSDOC from schema +# Metatype module -> WORK IN PROGRESS +Generate type annotation from schema; + +> Warning: You will receive compressed version; + +## Usage + +By default module runs in mjs mode, that means that: + +- It will export all schemas with $id field & root schema +- it will export as default root schema + +> In cjs mode, it will export only root schema + +```js +const plan = 'string'; +const schema = new Schema(plan); +schema.dts('Example', { mode: 'mjs' }); +// type Example = string; +// export type = { Example }; +// export default Example; +``` + +## Example + +### Input: + +```js +{ + "firstName": 'string', + "lastName": 'string', + "label": ["member", "guest", "vip"] + "age": '?number', + settings: { alertLevel: 'string', $id: 'Setting' } +} +``` + +### Output (mjs mode): + +```ts +interface Settings { + alertLevel: string; +} + +interface Example { + firstName: string; + lastName: string; + label: 'member' | 'guest' | 'vip'; + age?: number; + settings: Settings; +} + +export type { Example }; +export default Example; +``` + +### Output (cjs mode): + +```ts +interface Settings { + alertLevel: string; +} + +interface Example { + firstName: string; + lastName: string; + label: 'member' | 'guest' | 'vip'; + settings: Settings; + age?: number; +} + +export = Example; +``` + +## Writing custom prototypes with Metatype + +By default all custom types will recieve unknown type; If you want to have custom type, you may +create custom prototype with toTypescript field; + +```js +function Date(plan, tools) { + this.toTypescript = (name, namespace) => { + // If you want to have your type in exports, you can add it name to exports; + const { definitions, exports } = namespace; + // If your type is complex, you can push your builded type / interface to definitions and return it name + const type = `type ${name} = Date`; + definitions.add(type); + //? You can return only name or value that can be assigned to type + return name; // Equal to: + return 'Date'; + }; +} +``` diff --git a/modules/types/index.js b/modules/types/index.js index 787fa93..71de5a5 100644 --- a/modules/types/index.js +++ b/modules/types/index.js @@ -1,11 +1,33 @@ 'use strict'; +const { nameFix } = require('./utils'); const types = require('./types'); module.exports = schema => { - function TypescriptWrapper() { - this.toTypescript = () => 'unknown'; - } for (const [name, proto] of types.entries()) schema.forge.attach(name, proto); - schema.forge.attach('before', TypescriptWrapper); + schema.forge.attach('before', { toTypescript: () => 'unknown' }); + schema.forge.attach('after', function TypescriptWrapper() { + const compile = this.toTypescript; + this.toTypescript = (name, namespace) => compile(nameFix(name), namespace); + }); + + schema.dts = (name = 'MetaForge', options = {}) => { + const mode = options.mode ?? 'mjs'; + if (name !== nameFix(name)) throw new Error('Invalid name format'); + const namespace = { definitions: new Set(), exports: new Set() }; + const type = schema.toTypescript(name, namespace); + if (type !== name) { + if (namespace.exports.size === 1) { + const definitions = Array.from(namespace.definitions).join(''); + if (mode === 'cjs') return definitions + `export = ${type}`; + return definitions + `export type ${name}=${type};export default ${type};`; + } + namespace.definitions.add(`type ${name}=${type};`); + } + namespace.exports.add(name); + const definitions = Array.from(namespace.definitions).join(''); + if (mode === 'cjs') return definitions + `export = ${name};`; + const exports = `export type{${Array.from(namespace.exports).join(',')}};`; + return definitions + exports + `export default ${name};`; + }; }; diff --git a/modules/types/index.test.js b/modules/types/index.test.js new file mode 100644 index 0000000..40b56a0 --- /dev/null +++ b/modules/types/index.test.js @@ -0,0 +1,95 @@ +/* eslint-disable quotes */ +'use strict'; + +const [test, assert] = [require('node:test'), require('node:assert')]; +const Schema = require('../../'); + +const generate = (type, name) => new Schema(type).dts(name); +const base = 'type MetaForge='; +const exp = 'export type{MetaForge};export default MetaForge;'; +test('[DTS] Basic', () => { + assert.strictEqual(generate({ $type: 'string' }), base + 'string;' + exp); + assert.strictEqual(generate('number'), base + 'number;' + exp); + assert.strictEqual(generate('bigint'), base + 'bigint;' + exp); + assert.strictEqual(generate('boolean'), base + 'boolean;' + exp); + assert.strictEqual(generate('unknown'), base + 'unknown;' + exp); + assert.strictEqual(generate('?any'), base + '(any|null|undefined);' + exp); +}); + +test('[DTS] Enumerable', () => { + assert.strictEqual(generate(['hello', 'world']), base + "('hello'|'world');" + exp); + const data = ['hello', 'there', 'my', 'dear', 'world']; + const result = `type MetaForge='hello'|'there'|'my'|'dear'|'world';`; + assert.strictEqual(generate(data), result + exp); +}); + +test('[DTS] Union', () => { + assert.strictEqual( + generate({ $type: 'union', types: ['string', '?number'] }), + 'type MetaForge=(string|(number|null|undefined));' + exp, + ); + assert.strictEqual( + generate({ $type: 'union', types: [{ $type: 'union', types: ['string', '?number'] }] }), + 'type MetaForge=((string|(number|null|undefined)));' + exp, + ); +}); + +test('[DTS] Array', () => { + assert.strictEqual( + generate(['string', '?number']), + 'type MetaForge=[string,(number|null|undefined)];' + exp, + ); + assert.strictEqual( + generate({ $type: 'set', items: ['string', '?number'] }), + 'type MetaForge=Set;' + exp, + ); + assert.strictEqual( + generate({ $type: 'array', items: { $type: 'union', types: ['string', '?number'] } }), + 'type MetaForge=((string|(number|null|undefined)))[];' + exp, + ); + assert.strictEqual( + generate({ $type: 'tuple', items: { $type: 'union', types: ['string', '?number'] } }), + 'type MetaForge=[(string|(number|null|undefined))];' + exp, + ); + const enumerable = ['hello', 'there', 'my', 'dear', 'world']; + const complex = ['?number', enumerable, { a: 'string', b: enumerable }]; + let result = "type MetaForge_1='hello'|'there'|'my'|'dear'|'world';"; + result += "type MetaForge_2_b='hello'|'there'|'my'|'dear'|'world';"; + result += 'interface MetaForge_2{a:string;b:MetaForge_2_b;};'; + result += 'type MetaForge=[(number|null|undefined),MetaForge_1,MetaForge_2];' + exp; + assert.strictEqual(generate(complex), result); +}); + +test('[DTS] Struct', () => { + const schema = { "'": 'string', '"': 'string', b: '?number', 'c+': { d: ['hello', 'world'] } }; + let result = "interface MetaForge_c{d:('hello'|'world');};"; + result += 'interface MetaForge{"\'":string;\'"\':string;'; + result += "b?:(number|null|undefined);'c+':MetaForge_c;};"; + result += exp; + assert.strictEqual(generate(schema), result); +}); + +test('[DTS] Schema', () => { + const schema = { + $id: 'MySchema', + a: 'string', + b: { $id: 'MySubSchema', c: 'number' }, + c: new Schema('?string'), + d: { $type: 'schema', schema: new Schema('number'), $id: 'MySubSchema2' }, + e: { $type: 'schema', schema: new Schema({ $type: 'number', $id: 'MySubSchema3' }) }, + }; + let r = 'interface MySubSchema{c:number;};type MySubSchema2=number;type MySubSchema3=number;'; + r += `interface MetaForge{a:string;b:MySubSchema;c?:(string|null|undefined);`; + r += 'd:MySubSchema2;e:MySubSchema3;};'; + r += 'export type{MySubSchema,MySubSchema2,MySubSchema3,MetaForge};export default MetaForge;'; + assert.strictEqual(generate(schema), r); +}); + +test('[DTS] Modes', () => { + const schema = new Schema({ a: { $id: 'MySubSchema', c: 'number' } }); + const result = 'interface MySubSchema{c:number;};interface MetaForge{a:MySubSchema;};'; + const mjs = result + 'export type{MySubSchema,MetaForge};export default MetaForge;'; + const cjs = result + 'export = MetaForge;'; + assert.strictEqual(schema.dts('MetaForge'), mjs); + assert.strictEqual(schema.dts('MetaForge', { mode: 'cjs' }), cjs); +}); diff --git a/modules/types/types.js b/modules/types/types.js index 891e704..cb49a66 100644 --- a/modules/types/types.js +++ b/modules/types/types.js @@ -1,54 +1,83 @@ 'use strict'; -const create = type => ({ toTypescript: () => type }); module.exports = new Map( Object.entries({ - unknown: create('unknown'), - boolean: create('boolean'), - string: create('string'), - number: create('number'), - bigint: create('bigint'), - any: create('any'), + unknown: Scalar, + boolean: Scalar, + string: Scalar, + number: Scalar, + bigint: Scalar, + any: Scalar, enum: Enumerable, union: Union, array: Iterable, tuple: Iterable, set: Iterable, + schema: Schema, object: Struct, record: Struct, map: Struct, }), ); +const { brackets, MAX_ITEMS } = require('./utils'); +function Scalar() { + this.toTypescript = () => (this.$required ? this.$type : `(${this.$type}|null|undefined)`); +} + function Enumerable() { - this.toTypescript = () => `(${this.$enum.join(' | ')})`; + this.toTypescript = (name, namespace) => { + const or = i => (this.$enum.length - 1 === i ? '' : '|'); + const type = this.$enum.reduce((acc, s, i) => acc + brackets(s, false) + or(i), ''); + if (this.$enum.length < MAX_ITEMS) return '(' + type + ')'; + namespace.definitions.add(`type ${name}=${type};`); + return name; + }; } function Iterable() { - this.toTypescipt = () => { - const builded = this.$items.map(item => item.toTypescript()); - if (this.$type === 'set') return `Set<${builded.join(' | ')}>`; - if (this.$isTuple) return `[${builded.join(', ')}]`; - return `(${builded.join(' | ')})[]`; + this.toTypescript = (name, namespace) => { + let type; + const builded = this.$items.map((item, i) => item.toTypescript(`${name}_${i}`, namespace)); + if (this.$type === 'set') type = `Set<${builded.join('|')}>`; + else if (this.$isTuple) type = `[${builded.join(',')}]`; + else type = `(${builded.join('|')})[]`; + if (builded.length < MAX_ITEMS) return type; + namespace.definitions.add(`type ${name}=${type};`); + return name; }; } function Struct() { - const patterns = this.$patterns.entries(); - this.toTypescript = () => { - let result = '{ '; - for (const [key, value] in this.$properties.entries()) { - // eslint-disable-next-line quotes, no-extra-parens - const sep = key.includes('`') ? (key.includes('"') ? "'" : '"') : '`'; - result += `${sep + key + sep}${value.$required ? '?' : ''}: ${value.toTypescript()}, `; + this.toTypescript = (name, namespace) => { + let result = `interface ${name}{`; + for (const [key, proto] of this.$properties.entries()) { + const type = proto.toTypescript(`${name}_${key}`, namespace); + result += `${brackets(key, true) + (proto.$required ? '' : '?')}:${type};`; } - if (!patterns.length) return result + '}'; - const types = patterns.map((_, value) => value.toTypescript()); - return result + `[key: string]?: ${types.join(' | ')}, }`; + namespace.definitions.add(result + '};'); + return name; }; } function Union() { - const sep = this.$condition === 'allof' ? ' & ' : ' | '; - this.toTypescript = () => this.$types.map(type => type.toTypescript()).join(sep); + this.toTypescript = (name, namespace) => { + const types = this.$types.map((type, i) => type.toTypescript(`${name}_${i}`, namespace)); + const type = types.join(this.$condition === 'allof' ? '&' : '|'); + if (types.length < MAX_ITEMS) return '(' + type + ')'; + namespace.definitions.add(`type ${name}=${type};`); + return name; + }; +} + +function Schema() { + const compile = this.toTypescript; + this.toTypescript = (name, namespace) => { + const id = this.$id ?? name; + const type = compile(id, namespace); + if (!this.$id) return type; + if (type !== id) namespace.definitions.add(`type ${id}=${type};`); + namespace.exports.add(id); + return id; + }; } diff --git a/modules/types/utils.js b/modules/types/utils.js new file mode 100644 index 0000000..6922ef3 --- /dev/null +++ b/modules/types/utils.js @@ -0,0 +1,18 @@ +'use strict'; + +const { string: astropack } = require('astropack'); + +const MAX_ITEMS = 5; +const SPECIAL = /[ `!@#%^&*()+\-=[\]{};':"\\|,.<>/?~]/; +const nameFix = name => name.replace(new RegExp(SPECIAL, 'g'), ''); +const brackets = (sample, allowSkip) => { + if (allowSkip) { + const skip = astropack.case.isFirstLetter(sample) && !SPECIAL.test(sample); + if (skip) return sample; + } + // eslint-disable-next-line quotes + const sep = sample.includes("'") ? '"' : "'"; + return sep + sample + sep; +}; + +module.exports = { nameFix, brackets, MAX_ITEMS }; diff --git a/package.json b/package.json index 8e85a04..adaf390 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "license": "MIT", - "version": "0.5.0", + "version": "0.6.0", "type": "commonjs", "name": "metaforge", "homepage": "https://astrohelm.ru", diff --git a/tests/types.test.js b/tests/custom.test.js similarity index 87% rename from tests/types.test.js rename to tests/custom.test.js index 8cd400a..3bff450 100644 --- a/tests/types.test.js +++ b/tests/custom.test.js @@ -36,3 +36,11 @@ test('Custom prototypes with meta replacement for old ones', () => { assert.strictEqual(numberSchema.about, 'This Number is awsome'); assert.strictEqual(numberSchema.desc, 'age'); }); + +test('Custom modules', () => { + let counter = 0; + const plugin = () => counter++; + Schema.modules.set('first', plugin); + new Schema().register('second', plugin); + assert.strictEqual(counter, 2); +}); diff --git a/tests/namespace.test.js b/tests/namespace.test.js deleted file mode 100644 index 7eb011b..0000000 --- a/tests/namespace.test.js +++ /dev/null @@ -1,13 +0,0 @@ -'use strict'; - -const [test, assert] = [require('node:test'), require('node:assert')]; -const Schema = require('..'); - -test('Schema with namespace', () => { - const namespace = { User: new Schema('string') }; - const schema = new Schema(['User', 'User'], { namespace }); - const sample = ['Alexander', 'Ivanov']; - - assert.strictEqual(namespace.User.warnings.length + schema.warnings.length, 0); - assert.strictEqual(schema.test(sample).length, 0); -}); diff --git a/types/index.d.ts b/types/index.d.ts index b69ffac..94b1389 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -70,7 +70,11 @@ class ForgePrototype { root?: string, partial?: boolean, ) => Array & { valid: boolean }; - toTypescript?: () => string; + dts?: (name?: string, options?: { mode?: 'cjs' | 'mjs' }) => string; + toTypescript?: ( + name: string, + namespace: { definitions: Set; exports: Set }, + ) => string; [key: string]: unknown; }