Skip to content

Commit dd212c0

Browse files
committed
feat: tsdefs improvements & cli, expose & build schema-schema, fix type exports
1 parent 011d54f commit dd212c0

31 files changed

+484
-71
lines changed

bin/cli.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { validate } from './validate.js'
44
import { toJSON } from './to-json.js'
55
import { toSchema } from './to-schema.js'
66
import { toJS } from './to-js.js'
7+
import { toTSDefs } from './to-tsdefs.js'
78
import { jsonToSchema } from './json-to-schema.js'
89
import _yargs from 'yargs'
910
import { hideBin } from 'yargs/helpers'
@@ -13,6 +14,14 @@ const toOpts = {
1314
alias: 't',
1415
type: 'boolean',
1516
describe: 'print with tabs instead of spaces'
17+
},
18+
'include-comments': {
19+
type: 'boolean',
20+
describe: 'include comments in the output'
21+
},
22+
'include-annotations': {
23+
type: 'boolean',
24+
describe: 'include annotations in the output'
1625
}
1726
}
1827

@@ -35,6 +44,12 @@ const yargs = _yargs(hideBin(process.argv))
3544
cjs: { boolean: true }
3645
}
3746
)
47+
.command('to-tsdefs',
48+
'Accepts .ipldsch and .md files, if none are passed will read from stdin, prints a TypeScript module implementing schema interfaces for the <root type>',
49+
{
50+
cjs: { boolean: true }
51+
}
52+
)
3853
.command('json-to-schema',
3954
'Accepts .json files, if none are passed will read from stdin, prints the canonical IPLD Schema form of the schema represented by the JSON',
4055
// @ts-ignore
@@ -70,6 +85,9 @@ switch (yargs.argv._[0]) {
7085
case 'to-js':
7186
runCommand(toJS)
7287
break
88+
case 'to-tsdefs':
89+
runCommand(toTSDefs)
90+
break
7391
case 'json-to-schema':
7492
runCommand(jsonToSchema)
7593
break

bin/to-js.js

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
#!/usr/bin/env node
22

3-
import { readFile } from 'fs/promises'
43
import parser from '../lib/parser.cjs'
54
import { transformError } from '../lib/util.js'
5+
import { pkgDecjsor } from './util.js'
66
import { collectInput } from './collect-input.js'
77
import { Builder, safeReference } from '../lib/typed.js'
88

@@ -20,6 +20,7 @@ export async function toJS (files, options) {
2020
let schema
2121
for (const { filename, contents } of input) {
2222
try {
23+
/** @type {any} */
2324
const parsed = parser.parse(contents)
2425
if (!schema) {
2526
schema = parsed
@@ -55,8 +56,3 @@ ${options.cjs === true ? `module.exports${safeReference(type)}` : `export const
5556
}`)
5657
}
5758
}
58-
59-
async function pkgDecjsor () {
60-
const p = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'))
61-
return `${p.name}@v${p.version}`
62-
}

bin/to-json.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,28 @@ let indent = ' '
1212

1313
/**
1414
* @param {string[]} files
15-
* @param {{tabs?:boolean}} options
15+
* @param {{tabs?:boolean, 'include-comments'?:boolean, 'include-annotations'?:boolean}} options
1616
* @returns
1717
*/
1818
export async function toJSON (files, options) {
1919
if (options.tabs) {
2020
indent = '\t'
2121
}
22+
const parseOptions = {}
23+
if (options['include-comments']) {
24+
parseOptions.includeComments = true
25+
}
26+
if (options['include-annotations']) {
27+
parseOptions.includeAnnotations = true
28+
}
2229

2330
const input = await collectInput(files)
2431

2532
/** @type {Schema|null} */
2633
let schema = null
2734
for (const { filename, contents } of input) {
2835
try {
29-
const parsed = parser.parse(contents)
36+
const parsed = /** @type {Schema} */(parser.parse(contents, parseOptions))
3037
if (schema == null) {
3138
schema = parsed
3239
} else {

bin/to-tsdefs.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#!/usr/bin/env node
2+
3+
import parser from '../lib/parser.cjs'
4+
import { transformError } from '../lib/util.js'
5+
import { pkgDecjsor } from './util.js'
6+
import { collectInput } from './collect-input.js'
7+
import { generateTypeScript } from '../lib/gen.js'
8+
9+
/**
10+
* @typedef {import('../schema-schema').Schema} Schema
11+
*/
12+
13+
/**
14+
* @param {string[]} files
15+
* @param {{cjs:boolean}} _options
16+
* @returns
17+
*/
18+
export async function toTSDefs (files, _options) {
19+
const input = await collectInput(files)
20+
let schema
21+
for (const { filename, contents } of input) {
22+
try {
23+
/** @type {any} */
24+
const parsed = parser.parse(contents, { includeComments: true, includeAnnotations: true })
25+
if (!schema) {
26+
schema = parsed
27+
} else {
28+
for (const [type, defn] of Object.entries(parsed.types)) {
29+
if (schema.types[type]) {
30+
console.error(`Error: duplicate type "${type}" found in schema(s)`)
31+
return process.exit(1)
32+
}
33+
schema.types[type] = defn
34+
}
35+
}
36+
} catch (err) {
37+
// @ts-ignore
38+
console.error(`Error parsing ${filename}: ${transformError(err).message}`)
39+
process.exit(1)
40+
}
41+
}
42+
43+
const schemaContent = input.map(({ contents }) => contents).join('\n').replace(/^/mg, ' * ').replace(/\s+$/mg, '')
44+
console.log(`/** Auto-generated with ${await pkgDecjsor()} at ${new Date().toDateString()} from IPLD Schema:\n *\n${schemaContent}\n */\n`)
45+
console.log(generateTypeScript(schema))
46+
}

bin/util.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { readFile } from 'fs/promises'
2+
3+
export async function pkgDecjsor () {
4+
const p = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'))
5+
return `${p.name}@v${p.version}`
6+
}

ipld-schema.pegjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,10 @@ TypeDef =
100100
}
101101
const typ = Object.keys(definition)[0]
102102
const comments = processComments(precomments)
103-
if (comments) {
103+
if (options.includeComments && comments) {
104104
definition[typ].comments = extend(definition[typ].comments || {}, { type: comments })
105105
}
106-
if (annotations && annotations.length) {
106+
if (options.includeComments && annotations && annotations.length) {
107107
definition[typ].annotations = extend(definition[typ].annotations || {}, { type: annotations })
108108
}
109109
return { [name]: definition }

lib/from-dsl.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ import { transformError } from './util.js'
77

88
/**
99
* @param {string} input
10+
* @param {Record<string, any>} [options]
1011
* @returns {Schema}
1112
*/
12-
export function fromDSL (input) {
13+
export function fromDSL (input, options = {}) {
1314
try {
14-
return /** @type {Schema} */(parser.parse(input))
15+
return /** @type {Schema} */(parser.parse(input, options))
1516
} catch (err) {
1617
throw transformError(err)
1718
}

lib/gen/typescript.js

Lines changed: 123 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,24 @@
99
*/
1010
export function generateTypeScript (schema) {
1111
/** @type {[string, string][]} */
12-
let imports = []
12+
const imports = []
1313

1414
let typesrc = ''
1515
for (const [typeName, typeDefn] of Object.entries(schema.types)) {
16+
if (Object.keys(typeDefn).length !== 1) {
17+
throw new Error('Unexpected type definition: ' + JSON.stringify(typeDefn))
18+
}
19+
const typeKind = Object.keys(typeDefn)[0]
1620
if ('struct' in typeDefn) {
1721
typesrc += `export type ${typeName} = {\n`
1822

23+
/** @type {string[]} */
24+
const fieldValidators = []
25+
let requiredFieldCount = 0
1926
for (let [fieldName, fieldDefn] of Object.entries(typeDefn.struct.fields)) {
27+
if (!fieldDefn.optional && !fieldDefn.optional) {
28+
requiredFieldCount++
29+
}
2030
/** @type { { [k in string]: string }[]} */
2131
let annotations = []
2232
if (typeof typeDefn.struct.annotations === 'object' && typeof typeDefn.struct.annotations.type === 'object') {
@@ -60,17 +70,97 @@ export function generateTypeScript (schema) {
6070
}
6171
fieldType = fixTypeScriptType(imports, fieldType, slice)
6272
fieldName = fixTypeScriptName(annotations, fieldName)
63-
typesrc += ` ${fieldName}: ${fieldType}${linecomment}\n`
73+
typesrc += ` ${fieldName}${fieldDefn.optional ? '?' : ''}: ${fieldType}${linecomment}\n`
74+
const inCheck = !fieldDefn.optional && !fieldDefn.optional ? `'${fieldName}' in value &&` : `!('${fieldName}' in value) ||`
75+
if (fieldType.endsWith('[]')) {
76+
let elementType = getTypeScriptType([], fieldType.slice(0, -2))
77+
elementType = fixTypeScriptType(imports, elementType, false)
78+
fieldValidators.push(` (${inCheck} (${fieldDefn.nullable ? `value.${fieldName} === null || ` : ''}(Array.isArray(value.${fieldName}) && value.${fieldName}.every(${elementType}.is${elementType}))))`)
79+
} else {
80+
fieldValidators.push(` (${inCheck} (${fieldDefn.nullable ? `value.${fieldName} === null || ` : ''}(${fieldType}.is${fieldType}(value.${fieldName}))))`)
81+
}
6482
}
6583

6684
typesrc += '}\n\n'
85+
86+
const kind = fixTypeScriptType(imports, '@ipld/schema/schema-schema.js#KindMap', false)
87+
typesrc += `export namespace ${typeName} {\n`
88+
typesrc += ` export function is${typeName}(value: any): value is ${typeName} {\n`
89+
typesrc += ` if (!${kind}.is${kind}(value)) {\n`
90+
typesrc += ' return false\n'
91+
typesrc += ' }\n'
92+
typesrc += ' const keyCount = Object.keys(value).length\n'
93+
typesrc += ' return '
94+
if (requiredFieldCount === Object.keys(typeDefn.struct.fields).length) {
95+
typesrc += `keyCount === ${requiredFieldCount} &&\n`
96+
} else {
97+
// TODO: this isn't really a complete check, we probably should check for extra fields
98+
typesrc += `keyCount >= ${requiredFieldCount} && keyCount <= ${Object.keys(typeDefn.struct.fields).length} &&\n`
99+
}
100+
typesrc += fieldValidators.join(' &&\n')
101+
typesrc += '\n }\n'
102+
typesrc += '}\n\n'
103+
} else if ('list' in typeDefn) {
104+
if (typeof typeDefn.list.valueType !== 'string') {
105+
throw new Error('Unhandled list value type: ' + JSON.stringify(typeDefn))
106+
}
107+
let valueType = getTypeScriptType([], typeDefn.list.valueType)
108+
valueType = fixTypeScriptType(imports, valueType, false)
109+
typesrc += `export type ${typeName} = ${valueType}[]\n\n`
110+
typesrc += `export namespace ${typeName} {\n`
111+
typesrc += ` export function is${typeName}(value: any): value is ${typeName} {\n`
112+
typesrc += ` return Array.isArray(value) && value.every(${valueType}.is${valueType})\n`
113+
typesrc += ' }\n'
114+
typesrc += '}\n\n'
115+
} else if ('copy' in typeDefn) {
116+
const { fromType } = typeDefn.copy
117+
typesrc += `export type ${typeName} = ${fromType}\n\n`
118+
typesrc += `export namespace ${typeName} {\n`
119+
typesrc += ` export function is${typeName}(value: any): value is ${typeName} {\n`
120+
typesrc += ` return ${fromType}.is${fromType}(value)\n`
121+
typesrc += ' }\n'
122+
typesrc += '}\n\n'
123+
} else if (['bool', 'string', 'bytes', 'int', 'float', 'link', 'null'].includes(typeKind)) {
124+
const kind = fixTypeScriptType(imports, `@ipld/schema/schema-schema.js#Kind${typeKind.charAt(0).toUpperCase()}${typeKind.slice(1)}`, false)
125+
typesrc += `export type ${typeName} = ${kind}\n\n`
126+
typesrc += `export namespace ${typeName} {\n`
127+
typesrc += ` export function is${typeName}(value: any): value is ${typeName} {\n`
128+
typesrc += ` return ${kind}.is${kind}(value)\n`
129+
typesrc += ' }\n'
130+
typesrc += '}\n\n'
131+
} else if ('union' in typeDefn) {
132+
if (!('kinded' in typeDefn.union.representation)) {
133+
throw new Error('Unhandled union representation: ' + Object.keys(typeDefn.union.representation)[0])
134+
}
135+
if (typeDefn.union.members.some((member) => typeof member !== 'string')) {
136+
throw new Error('Unhandled union member type(s): ' + JSON.stringify(typeDefn.union.members))
137+
}
138+
const kinds = typeDefn.union.members.map((member) => {
139+
return fixTypeScriptType(imports, getTypeScriptType([], String(member)), false)
140+
})
141+
typesrc += `export type ${typeName} = ${kinds.join(' | ')}\n\n`
142+
typesrc += `export namespace ${typeName} {\n`
143+
typesrc += ` export function is${typeName}(value: any): value is ${typeName} {\n`
144+
typesrc += ` return ${kinds.map((kind) => `${kind}.is${kind}(value)`).join(' || ')}\n`
145+
typesrc += ' }\n'
146+
typesrc += '}\n\n'
147+
} else {
148+
throw new Error('Unimplemented type kind: ' + typeKind)
67149
}
68150
}
69151

70152
let ts = ''
71-
imports = fixTypeScriptImports(imports)
72-
for (const imp of imports) {
73-
ts += `import { ${imp[1]} } from '${imp[0]}'\n`
153+
const fixedImports = fixTypeScriptImports(imports)
154+
for (const imp of fixedImports) {
155+
if (imp[1].length === 1) {
156+
ts += `import { ${imp[1]} } from '${imp[0]}'\n`
157+
} else {
158+
ts += 'import {\n'
159+
for (const imported of imp[1]) {
160+
ts += ` ${imported},\n`
161+
}
162+
ts += `} from '${imp[0]}'\n`
163+
}
74164
}
75165
if (imports.length > 0) {
76166
ts += '\n'
@@ -93,8 +183,7 @@ function fixTypeScriptName (annotations, fieldName) {
93183
}
94184
}
95185
}
96-
// snakeCase with lower-case first letter
97-
return fieldName.charAt(0).toLowerCase() + fieldName.slice(1)
186+
return fieldName
98187
}
99188

100189
/**
@@ -112,26 +201,21 @@ function getTypeScriptType (annotations, ipldType) {
112201
}
113202
}
114203
switch (ipldType) {
115-
case 'Int':
116-
return 'number'
117-
case 'Float':
118-
return 'number'
119204
case 'Bool':
120-
return 'boolean'
121205
case 'String':
122-
return 'string'
123206
case 'Bytes':
124-
return 'Uint8Array'
125-
case 'Link':
126-
return 'multiformats/cid#CID'
207+
case 'Int':
208+
case 'Float':
209+
case 'Null':
127210
case 'Map':
128-
return 'object'
129211
case 'List':
130-
return 'any[]'
212+
case 'Link':
131213
case 'Union':
132-
return 'any' // TODO:
214+
case 'Struct':
215+
case 'Enum':
216+
return `@ipld/schema/schema-schema.js#Kind${ipldType}`
133217
case 'Any':
134-
return 'any' // TODO:
218+
return 'any' // TODO: something here?
135219
}
136220

137221
return ipldType
@@ -160,9 +244,25 @@ function fixTypeScriptType (imports, tstype, slice) {
160244

161245
/**
162246
* @param {[string, string][]} imports
163-
* @returns {[string, string][]}
247+
* @returns {[string, string[]][]}
164248
*/
165249
function fixTypeScriptImports (imports) {
166-
// TODO: implement for user imports
167-
return imports
250+
/** @type {Record<string, string[]>} */
251+
const groupedImports = {}
252+
for (const [source, imported] of imports) {
253+
if (!groupedImports[source]) {
254+
groupedImports[source] = []
255+
}
256+
if (!groupedImports[source].includes(imported)) {
257+
groupedImports[source].push(imported)
258+
}
259+
}
260+
261+
/** @type {[string, string[]][]} */
262+
const result = []
263+
for (const source in groupedImports) {
264+
result.push([source, groupedImports[source].sort()])
265+
}
266+
267+
return result
168268
}

0 commit comments

Comments
 (0)