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/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);
-});