Skip to content

Commit

Permalink
feat(Typescript): Adds factory functions for Typescript
Browse files Browse the repository at this point in the history
See #84
  • Loading branch information
nokome committed Jul 23, 2019
1 parent 0eb1553 commit 39d0fc6
Show file tree
Hide file tree
Showing 12 changed files with 421 additions and 95 deletions.
10 changes: 9 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,15 @@
],
"jest": {
"preset": "ts-jest",
"testEnvironment": "node"
"testEnvironment": "node",
"setupFilesAfterEnv": [
"<rootDir>/tests/matchers.ts"
],
"watchPathIgnorePatterns": [
"__file_snapshots__",
"built",
"dist"
]
},
"husky": {
"hooks": {
Expand Down
2 changes: 1 addition & 1 deletion src/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export function props(
return {
all: props,
inherited: props.filter(prop => prop.inherited),
own: props.filter(prop => !prop.inherited),
own: props.filter(prop => !prop.inherited || !prop.optional),
required: props.filter(prop => !prop.optional),
optional: props.filter(prop => prop.optional)
}
Expand Down
85 changes: 85 additions & 0 deletions src/typescript-jstt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Generate Typescript language bindings using `json-schema-to-typescript`
*/

import fs from 'fs-extra'
import globby from 'globby'
import * as jstt from 'json-schema-to-typescript'
// @ts-ignore
import ObjectfromEntries from 'object.fromentries'
import path from 'path'
import tempy from 'tempy'
import * as schema from './schema'

/**
* Run `build()` when this file is run as a Node script
*/
// eslint-disable-next-line @typescript-eslint/no-floating-promises
if (module.parent === null) build()

/**
* Generate `dist/types.d.ts` from `built/*.schema.json`.
*/
async function build(): Promise<void> {
// Ensure `*.schema.json` files are up to date
await schema.build()

// Our use of the `extends` keyword is different to that
// used by `json-schema-to-typescript`. So we need to create
// new set of `.schema.json` which do not have that keyword to avoid the clash.
const files = await globby('*.schema.json', { cwd: 'built' })
const temp = tempy.directory()
await Promise.all(
files.map(async file => {
const schema = await fs.readJSON(path.join('built', file))
delete schema.extends
return fs.writeJSON(path.join(temp, file), schema)
})
)

// Output `types.schema.json`
// This 'meta' schema provides a list of type schemas as:
// - an entry point for the generation of Typescript type definitions
// - a lookup for all types for use in `util.ts` functions
const types = {
$schema: 'http://json-schema.org/draft-07/schema#',
title: 'Types',
properties: ObjectfromEntries(
files.map(file => [
file.replace('.schema.json', ''),
{ allOf: [{ $ref: file }] }
])
),
required: files.map(file => file.replace('.schema.json', ''))
}
const typesFile = path.join(temp, 'types.schema.json')
await fs.writeJSON(typesFile, types)

const dist = path.join(__dirname, '..', 'dist')
await fs.ensureDir(dist)

// Generate the Typescript
const options = {
bannerComment: `/* eslint-disable */
/**
* This file was automatically generated by ${__filename}.
* Do not modify it by hand. Instead, modify the source \`.schema.yaml\` files
* in the \`schema\` directory and run \`npm run build:ts\` to regenerate this file.
*/
`
}
const ts = await jstt.compileFromFile(typesFile, options)
await fs.writeFile(path.join(dist, 'types.d.ts'), ts)

// Copy the JSON Schema files into `dist` too
await Promise.all(
files.map(async (file: string) =>
fs.copy(path.join('built', file), path.join(dist, file))
)
)

// Create an index.js for require.resolve('@stencila/schema') to work
// properly in Encoda
// TODO This won't be necessary when using tsc to compile an index.js
await fs.writeFile(path.join(dist, 'index.js'), '\n')
}
245 changes: 184 additions & 61 deletions src/typescript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,32 @@
*/

import fs from 'fs-extra'
import globby from 'globby'
import * as jstt from 'json-schema-to-typescript'
// @ts-ignore
import ObjectfromEntries from 'object.fromentries'
import path from 'path'
import tempy from 'tempy'
import * as schema from './schema'
import { props, read, Schema, types, unions } from './bindings'

/**
* Generate `dist/types.d.ts` from schemas.
*/
export const build = async (): Promise<string> => {
const schemas = await read()

const typesCode = types(schemas)
.map(typeGenerator)
.join('')
const unionsCode = unions(schemas)
.map(unionGenerator)
.join('')

const code = `
${typesCode}
${unionsCode}
`

const file = path.join(__dirname, '..', 'dist', 'types.ts')
await fs.writeFile(file, code)
return file
}

/**
* Run `build()` when this file is run as a Node script
Expand All @@ -18,68 +37,172 @@ import * as schema from './schema'
if (module.parent === null) build()

/**
* Generate `dist/types.d.ts` from `built/*.schema.json`.
* Generate a `interface` and a factory function for each type.
*/
async function build(): Promise<void> {
// Ensure `*.schema.json` files are up to date
await schema.build()

// Our use of the `extends` keyword is different to that
// used by `json-schema-to-typescript`. So we need to create
// new set of `.schema.json` which do not have that keyword to avoid the clash.
const files = await globby('*.schema.json', { cwd: 'built' })
const temp = tempy.directory()
await Promise.all(
files.map(async file => {
const schema = await fs.readJSON(path.join('built', file))
delete schema.extends
return fs.writeJSON(path.join(temp, file), schema)
})
)
export const typeGenerator = (schema: Schema): string => {
const { title = 'Undefined', extends: parent, description } = schema
const { own, required, optional } = props(schema)

const type =
schema.properties !== undefined
? schema.properties.type !== undefined
? schema.properties.type.enum !== undefined
? schema.properties.type.enum.map(type => `'${type}'`).join(' | ')
: ''
: ''
: ''

let code = ''

// Output `types.schema.json`
// This 'meta' schema provides a list of type schemas as:
// - an entry point for the generation of Typescript type definitions
// - a lookup for all types for use in `util.ts` functions
const types = {
$schema: 'http://json-schema.org/draft-07/schema#',
title: 'Types',
properties: ObjectfromEntries(
files.map(file => [
file.replace('.schema.json', ''),
{ allOf: [{ $ref: file }] }
])
// Interface
code += docComment(description)
code += `export interface ${title} ${
parent !== undefined ? `extends ${parent}` : ''
} {\n`
code += ` type: ${type}\n`
code += own
.map(
({ name, schema, optional }) =>
` ${name}${optional ? `?` : ''}: ${schemaToType(schema)}`
)
.join('\n')
code += '\n}\n\n'

// Factory function
code += docComment(`Create a \`${title}\` node`, [
...required.map(
({ name, schema }) =>
`@param ${name} {${schemaToType(schema)}} ${schema.description}`
),
required: files.map(file => file.replace('.schema.json', ''))
}
const typesFile = path.join(temp, 'types.schema.json')
await fs.writeJSON(typesFile, types)
`@param options Optional properties`,
`@returns {${title}}`
])
code += `export const ${funcName(title)} = (\n`
code += required
.map(({ name, schema }) => ` ${name}: ${schemaToType(schema)},\n`)
.join('')
code += ` options: {\n`
code += optional
.map(({ name, schema }) => ` ${name}?: ${schemaToType(schema)}`)
.join(',\n')
code += `\n } = {}\n`
code += `): ${title} => ({\n`
code += required.map(({ name }) => ` ${name},\n`).join('')
code += ` ...options,\n`
code += ` type: '${title}'\n`
code += '})\n\n'

const dist = path.join(__dirname, '..', 'dist')
await fs.ensureDir(dist)
return code
}

// Generate the Typescript
const options = {
bannerComment: `/* eslint-disable */
/**
* This file was automatically generated by ${__filename}.
* Do not modify it by hand. Instead, modify the source \`.schema.yaml\` files
* in the \`schema\` directory and run \`npm run build:ts\` to regenerate this file.
* Generate a `Union` type.
*/
`
}
const ts = await jstt.compileFromFile(typesFile, options)
await fs.writeFile(path.join(dist, 'types.d.ts'), ts)

// Copy the JSON Schema files into `dist` too
await Promise.all(
files.map(async (file: string) =>
fs.copy(path.join('built', file), path.join(dist, file))
)
export const unionGenerator = (schema: Schema): string => {
const { title, description } = schema
let code = docComment(description)
code += `export type ${title} = ${schemaToType(schema)}\n\n`
return code
}

/**
* Generate factory function name
*/
const funcName = (name: string) => {
const func = `${name.substring(0, 1).toLowerCase() + name.substring(1)}`
const reserved: { [key: string]: string } = { delete: 'del' }
if (reserved[func] !== undefined) return reserved[func]
else return func
}

/**
* Generate a JSDoc style comment
*/
const docComment = (description?: string, tags: string[] = []): string => {
description = description !== undefined ? description : ''
return (
'/**\n' +
' * ' +
description.trim().replace('\n', '\n * ') +
'\n' +
tags.map(tag => ' * ' + tag.trim().replace('\n', ' ') + '\n').join('') +
' */\n'
)
}

/**
* Convert a schema definition to a Python type
*/
const schemaToType = (schema: Schema): string => {
const { type, anyOf, allOf, $ref } = schema

if ($ref !== undefined) return `${$ref.replace('.schema.json', '')}`
if (anyOf !== undefined) return anyOfToType(anyOf)
if (allOf !== undefined) return allOfToType(allOf)
if (schema.enum !== undefined) return enumToType(schema.enum)

if (type === 'null') return 'null'
if (type === 'boolean') return 'boolean'
if (type === 'number') return 'number'
if (type === 'integer') return 'number'
if (type === 'string') return 'string'
if (type === 'array') return arrayToType(schema)
if (type === 'object') return '{[key: string]: any}'

throw new Error(`Unhandled schema: ${JSON.stringify(schema)}`)
}

/**
* Convert a schema with the `anyOf` property to a Python `Union` type.
*/
const anyOfToType = (anyOf: Schema[]): string => {
const types = anyOf
.map(schema => schemaToType(schema))
.reduce(
(prev: string[], curr) => (prev.includes(curr) ? prev : [...prev, curr]),
[]
)
if (types.length === 0) return ''
if (types.length === 1) return types[0]
return types.join(' | ')
}

/**
* Convert a schema with the `allOf` property to a Python type.
*
* If the `allOf` is singular then just use that (this usually arises
* because the `allOf` is used for a property with a `$ref`). Otherwise,
* use the last schema (this is usually because one or more codecs can be
* used on a property and the last schema is the final, expected, type of
* the property).
*/
const allOfToType = (allOf: Schema[]): string => {
if (allOf.length === 1) return schemaToType(allOf[0])
else return schemaToType(allOf[allOf.length - 1])
}

/**
* Convert a schema with the `array` property to a Typescript `Array` type.
*
* Uses the more explicity `Array<>` syntax over the shorter`[]` syntax
* because the latter necessitates the use of, sometime superfluous, parentheses.
*/
const arrayToType = (schema: Schema): string => {
const items = Array.isArray(schema.items)
? anyOfToType(schema.items)
: schema.items !== undefined
? schemaToType(schema.items)
: 'any'
return `Array<${items}>`
}

// Create an index.js for require.resolve('@stencila/schema') to work
// properly in Encoda
// TODO This won't be necessary when using tsc to compile an index.js
await fs.writeFile(path.join(dist, 'index.js'), '\n')
/**
* Convert a schema with the `enum` property to Typescript "or values".
*/
export const enumToType = (enu: (string | number)[]): string => {
return enu
.map(schema => {
return JSON.stringify(schema)
})
.join(' | ')
}
5 changes: 5 additions & 0 deletions tests/__file_snapshots__/BlockContent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* Block content.
*/
export type BlockContent = CodeBlock | CodeChunk | Heading | List | ListItem | Paragraph | QuoteBlock | Table | ThematicBreak

Loading

0 comments on commit 39d0fc6

Please sign in to comment.