diff --git a/package.json b/package.json index a1dbd24..d983a61 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "marked": "latest", "monaco-editor": "latest", "prismjs": "latest", + "scule": "latest", "siroc": "latest", "standard-version": "latest", "ts-jest": "latest", diff --git a/src/generator/dts.ts b/src/generator/dts.ts index 6413b52..df172e4 100644 --- a/src/generator/dts.ts +++ b/src/generator/dts.ts @@ -22,6 +22,8 @@ const SCHEMA_KEYS = [ 'description', '$schema', 'type', + 'tsType', + 'markdownType', 'tags', 'args', 'id', @@ -67,7 +69,13 @@ function getTsType (type: TypeDescriptor | TypeDescriptor[]): string { if (Array.isArray(type)) { return [].concat(normalizeTypes(type.map(t => getTsType(t)))).join('|') || 'any' } - if (!type || !type.type) { + if (!type) { + return 'any' + } + if (type.tsType) { + return type.tsType + } + if (!type.type) { return 'any' } if (Array.isArray(type.type)) { @@ -89,7 +97,7 @@ export function genFunctionArgs (args: Schema['args']) { if (arg.optional || arg.default) { argStr += '?' } - if (arg.type) { + if (arg.type || arg.tsType) { argStr += `: ${getTsType(arg)}` } return argStr diff --git a/src/generator/md.ts b/src/generator/md.ts index 3692820..0a2347b 100644 --- a/src/generator/md.ts +++ b/src/generator/md.ts @@ -10,7 +10,7 @@ export function _generateMarkdown (schema: Schema, title: string, level: string) lines.push(`${level} ${title}`) - if ('properties' in schema) { + if (schema.type === 'object') { for (const key in schema.properties) { const val = schema.properties[key] as Schema lines.push('', ..._generateMarkdown(val, `\`${key}\``, level + '#')) @@ -19,7 +19,7 @@ export function _generateMarkdown (schema: Schema, title: string, level: string) } // Type and default - lines.push(`- **Type**: \`${schema.type}\``) + lines.push(`- **Type**: \`${schema.markdownType || schema.tsType || schema.type}\``) if ('default' in schema) { lines.push(`- **Default**: \`${JSON.stringify(schema.default)}\``) } diff --git a/src/loader/babel.ts b/src/loader/babel.ts index 3f0d2ab..fec6e33 100644 --- a/src/loader/babel.ts +++ b/src/loader/babel.ts @@ -1,7 +1,7 @@ import type { ConfigAPI, PluginItem, PluginObj } from '@babel/core' import * as t from '@babel/types' import { Schema, JSType, TypeDescriptor, FunctionArg } from '../types' -import { normalizeTypes, mergedTypes, cachedFn } from '../utils' +import { normalizeTypes, mergedTypes, cachedFn, getTypeDescriptor } from '../utils' import { version } from '../../package.json' @@ -114,7 +114,7 @@ export default function babelPluginUntyped (api: ConfigAPI) { const { type } = tag.match(/^@returns\s+\{(?[^}]+)\}/)?.groups || {} if (type) { schema.returns = schema.returns || {} - schema.returns.type = type + Object.assign(schema.returns, getTypeDescriptor(type)) return false } } @@ -123,7 +123,7 @@ export default function babelPluginUntyped (api: ConfigAPI) { if (type && param) { const arg = schema.args?.find(arg => arg.name === param) if (arg) { - arg.type = type + Object.assign(arg, getTypeDescriptor(type)) return false } } @@ -188,7 +188,13 @@ function parseJSDocs (input: string | string[]): Schema { const tags = clumpLines(lines.slice(firstTag), ['@'], '\n') for (const tag of tags) { if (tag.startsWith('@type')) { - schema.type = tag.match(/@type\s+\{([^}]+)\}/)?.[1] + const type = tag.match(/@type\s+\{([^}]+)\}/)?.[1] + Object.assign(schema, getTypeDescriptor(type)) + const typedef = tags.find(t => t.match(/@typedef\s+\{([^}]+)\} (.*)/)?.[2] === type) + if (typedef) { + schema.markdownType = type + schema.tsType = typedef.match(/@typedef\s+\{([^}]+)\}/)?.[1] + } continue } schema.tags.push(tag.trim()) @@ -237,17 +243,13 @@ const AST_JSTYPE_MAP: Partial> = function inferArgType (e: t.Expression, getCode: GetCodeFn): TypeDescriptor { if (AST_JSTYPE_MAP[e.type]) { - return { - type: AST_JSTYPE_MAP[e.type] - } + return getTypeDescriptor(AST_JSTYPE_MAP[e.type]) } if (e.type === 'AssignmentExpression') { return inferArgType(e.right, getCode) } if (e.type === 'NewExpression' && e.callee.type === 'Identifier') { - return { - type: e.callee.name - } + return getTypeDescriptor(e.callee.name) } if (e.type === 'ArrayExpression' || e.type === 'TupleExpression') { const itemTypes = e.elements @@ -279,9 +281,7 @@ function inferTSType (tsType: t.TSType, getCode: GetCodeFn): TypeDescriptor | nu items: inferTSType(tsType.typeParameters.params[0], getCode) } } - return { - type: getCode(tsType.loc) - } + return getTypeDescriptor((getCode(tsType.loc))) } if (tsType.type === 'TSUnionType') { return mergedTypes(...tsType.types.map(t => inferTSType(t, getCode))) @@ -293,9 +293,7 @@ function inferTSType (tsType: t.TSType, getCode: GetCodeFn): TypeDescriptor | nu } } // if (tsType.type.endsWith('Keyword')) { - return { - type: getCode(tsType.loc) - } + return getTypeDescriptor(getCode(tsType.loc)) // } // return null } diff --git a/src/types.ts b/src/types.ts index ea42d9f..85dcdc5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,7 +25,12 @@ export type JSType = export type ResolveFn = ((value: any, get: (key: string) => any) => JSValue) export interface TypeDescriptor { - type?: JSType | JSType[] | string | string[] + /** Used internally to handle schema types */ + type?: JSType | JSType[] + /** Fully resolved correct TypeScript type for generated TS declarations */ + tsType?: string + /** Human-readable type description for use in generated documentation */ + markdownType?: string items?: TypeDescriptor | TypeDescriptor[] } diff --git a/src/utils.ts b/src/utils.ts index 0dd60b9..35ce135 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,4 @@ +import { pascalCase } from 'scule' import type { Schema, JSType, TypeDescriptor } from './types' export function escapeKey (val: string): string { @@ -87,15 +88,17 @@ export function mergedTypes (...types: TypeDescriptor[]): TypeDescriptor { types = types.filter(Boolean) if (types.length === 0) { return {} } if (types.length === 1) { return types[0] } + const tsTypes = normalizeTypes(types.map(t => t.tsType).flat().filter(Boolean)) return { type: normalizeTypes(types.map(t => t.type).flat().filter(Boolean)), + tsType: Array.isArray(tsTypes) ? tsTypes.join(' | ') : tsTypes, items: mergedTypes(...types.map(t => t.items).flat().filter(Boolean)) } } -export function normalizeTypes (val: string[]) { +export function normalizeTypes (val: T[]) { const arr = unique(val.filter(str => str)) - if (!arr.length || arr.includes('any')) { return undefined } + if (!arr.length || arr.includes('any' as any)) { return undefined } return (arr.length > 1) ? arr : arr[0] } @@ -110,3 +113,31 @@ export function cachedFn (fn) { return val } } + +const jsTypes: JSType[] = ['string', 'number', 'bigint', 'boolean', 'symbol', 'function', 'object', 'any', 'array'] + +export function isJSType (val: unknown): val is JSType { + return jsTypes.includes(val as any) +} + +const FRIENDLY_TYPE_RE = /typeof import\(['"](?[^'"]+)['"]\)(\[['"]|\.)(?[^'"\s]+)(['"]\])?/g + +export function getTypeDescriptor (type: string | JSType): TypeDescriptor { + if (!type) { + return {} + } + + let markdownType = type + for (const match of type.matchAll(FRIENDLY_TYPE_RE) || []) { + const { importName, firstType } = match.groups || {} + if (importName && firstType) { + markdownType = markdownType.replace(match[0], pascalCase(importName) + pascalCase(firstType)) + } + } + + return { + ...isJSType(type) ? { type } : {}, + tsType: type, + ...markdownType !== type ? { markdownType } : {} + } +} diff --git a/test/transform.test.ts b/test/transform.test.ts index 9a1034c..c99c8c1 100644 --- a/test/transform.test.ts +++ b/test/transform.test.ts @@ -15,7 +15,7 @@ describe('transform (functions)', () => { type: 'string' }, { name: 'date', - type: 'Date' + tsType: 'Date' }, { name: 'append', optional: true, @@ -42,7 +42,7 @@ describe('transform (functions)', () => { } }, { name: 'append', - type: 'false' + tsType: 'false' }] } }) @@ -59,7 +59,7 @@ describe('transform (functions)', () => { type: 'function', args: [], returns: { - type: 'void' + tsType: 'void' } } }) @@ -84,7 +84,7 @@ describe('transform (functions)', () => { { name: 'b', type: 'number' } ], returns: { - type: 'void' + tsType: 'void' } } }) @@ -184,7 +184,42 @@ describe('transform (jsdoc)', () => { /** * @type {'src' | 'root'} */ - srcDir: 'src' + srcDir: 'src', + /** + * @type {null | typeof import('path').posix | typeof import('net')['Socket']['PassThrough']} + */ + posix: null + } + `) + expectCodeToMatch(result, /export default ([\s\S]*)$/, { + srcDir: { + $default: 'src', + $schema: { + title: '', + description: '', + tsType: "'src' | 'root'" + } + }, + posix: { + $default: null, + $schema: { + title: '', + description: '', + tsType: "null | typeof import('path').posix | typeof import('net')['Socket']['PassThrough']", + markdownType: 'null | PathPosix | NetSocket[\'PassThrough\']' + } + } + }) + }) + + it('correctly parses @typedef tags', () => { + const result = transform(` + export default { + /** + * @typedef {'src' | 'root'} HumanReadable + * @type {HumanReadable} + */ + srcDir: 'src', } `) expectCodeToMatch(result, /export default ([\s\S]*)$/, { @@ -193,7 +228,8 @@ describe('transform (jsdoc)', () => { $schema: { title: '', description: '', - type: "'src' | 'root'" + tsType: "'src' | 'root'", + markdownType: 'HumanReadable' } } }) diff --git a/test/types.test.ts b/test/types.test.ts index 364d032..fe8a42f 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -66,6 +66,7 @@ interface Untyped { }, { name: 'append', type: 'boolean', + tsType: 'false', optional: true }] } @@ -74,7 +75,7 @@ interface Untyped { expect(types).toBe(` interface Untyped { - add: (test?: Array, append?: boolean) => any, + add: (test?: Array, append?: false) => any, } `.trim()) }) diff --git a/yarn.lock b/yarn.lock index fbb544d..5799e05 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4482,6 +4482,11 @@ saxes@^5.0.1: dependencies: xmlchars "^2.2.0" +scule@latest: + version "0.2.1" + resolved "https://registry.yarnpkg.com/scule/-/scule-0.2.1.tgz#0c1dc847b18e07219ae9a3832f2f83224e2079dc" + integrity sha512-M9gnWtn3J0W+UhJOHmBxBTwv8mZCan5i1Himp60t6vvZcor0wr+IM0URKmIglsWJ7bRujNAVVN77fp+uZaWoKg== + "semver@2 || 3 || 4 || 5": version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"