import * as path from 'path'; import { Parser, ComponentDoc } from 'react-docgen-typescript'; import ts, { SymbolFlags, TypeFlags, SyntaxKind } from 'typescript'; import { isEmpty, isEqual } from 'lodash'; import { existsSync, readFileSync } from 'fs-extra'; import findConfig from 'find-config'; import { debug } from '../../core'; import { Json } from '../../types'; import { transformItem } from '../transform'; import generateDTS from './generateDTS'; import { IParseArgs } from '../index'; const log = debug.extend('parse:ts'); type ExtendedType = ts.Type & { id: string; typeArguments: any[]; }; function getNextParentIds(parentIds: number[], type: ts.Type) { // @ts-ignore const id = type?.symbol?.id; if (id) { return [...parentIds, id]; } return parentIds; } function getSymbolName(symbol: ts.Symbol) { // @ts-ignore const prefix: string = symbol?.parent && getSymbolName(symbol.parent); const name = symbol.getName(); if (prefix && prefix.length <= 20) { return `${prefix}.${name}`; } return name; } function getFunctionParams(parameters: any[] = [], checker, parentIds, type) { return parameters.map((node) => { const typeObject = checker.getTypeOfSymbolAtLocation(node.symbol, node.symbol.valueDeclaration); const v = getDocgenTypeHelper(checker, typeObject, false, getNextParentIds(parentIds, type)); const name = node.symbol.escapedName; return { name, propType: v, }; }); } function getFunctionReturns(node: any, checker, parentIds, type) { if (!node) return {}; const propType = getDocgenTypeHelper( checker, node.type, false, getNextParentIds(parentIds, type), ); return { propType, }; } const blacklistNames = [ 'prototype', 'getDerivedStateFromProps', 'propTypes', 'defaultProps', 'contextTypes', 'displayName', 'contextType', 'Provider', 'Consumer', ]; const blacklistPatterns = [ /^HTML/, /^React\./, /^Object$/, /^Date$/, /^Promise$/, /^XML/, /^Function$/, ]; // function hasTooManyTypes(type) { // return type?.types?.length >= 20; // } function isComplexType(type) { let isAliasSymbol = false; let symbol = type?.symbol; if (!symbol) { symbol = type?.aliasSymbol; isAliasSymbol = true; } if (!symbol) return false; if (isAliasSymbol) { return false; } const name = getSymbolName(symbol); if (blacklistPatterns.some((patt) => patt.test(name))) { return true; } return false; } function getDocgenTypeHelper( checker: ts.TypeChecker, type: ts.Type, skipRequired = false, parentIds: number[] = [], isRequired = false, ): any { function isTuple(_type: ts.Type) { // @ts-ignore use internal methods return checker.isArrayLikeType(_type) && !checker.isArrayType(_type); } let required: boolean; if (isRequired !== undefined) { required = isRequired; } else { required = !(type.flags & SymbolFlags.Optional) || isRequired; } function makeResult(typeInfo: Json) { if (skipRequired) { return { raw: checker.typeToString(type), ...typeInfo, }; } else { return { required, raw: checker.typeToString(type), ...typeInfo, }; } } function getShapeFromArray(symbolArr: ts.Symbol[], _type: ts.Type) { const shape: Array<{ key: | { name: string; } | string; value: any; }> = symbolArr.map((prop) => { const propType = checker.getTypeOfSymbolAtLocation( prop, // @ts-ignore prop.valueDeclaration || (prop.declarations && prop.declarations[0]) || {}, ); return { key: prop.getName(), value: getDocgenTypeHelper( checker, propType, false, // @ts-ignore getNextParentIds(parentIds, _type), // @ts-ignore !prop?.valueDeclaration?.questionToken, ), }; }); // @ts-ignore use internal methods if (checker.isArrayLikeType(_type)) { return shape; } if (_type.getStringIndexType()) { // @ts-ignore use internal methods if (!_type.stringIndexInfo) { return shape; } shape.push({ key: { name: 'string', }, value: getDocgenTypeHelper( checker, // @ts-ignore use internal methods _type.stringIndexInfo.type, false, getNextParentIds(parentIds, _type), ), }); } else if (_type.getNumberIndexType()) { // @ts-ignore use internal methods if (!_type.numberIndexInfo) { return shape; } shape.push({ key: { name: 'number', }, value: getDocgenTypeHelper( checker, // @ts-ignore use internal methods _type.numberIndexInfo.type, false, getNextParentIds(parentIds, _type), ), }); } return shape; } function getShape(_type: ts.Type) { const { symbol } = _type; if (symbol && symbol.members) { // @ts-ignore const props: ts.Symbol[] = Array.from(symbol.members.values()); // if (props.length >= 20) { // throw new Error('too many props'); // } return getShapeFromArray( props.filter((prop) => prop.getName() !== '__index'), _type, ); } else { // @ts-ignore const args = _type.resolvedTypeArguments || []; const props = checker.getPropertiesOfType(_type); // if (props.length >= 20) { // throw new Error('too many props'); // } const shape = getShapeFromArray(props.slice(0, args.length), _type); return shape; } } // @ts-ignore if (type?.kind === SyntaxKind.VoidExpression) { return makeResult({ name: 'void', raw: 'void', }); } const pattern = /^__global\.(.+)$/; // @ts-ignore if (parentIds.includes(type?.symbol?.id)) { return makeResult({ name: 'object', // checker.typeToString(type), }); } if (type.symbol) { const symbolName = getSymbolName(type.symbol); if (symbolName) { const matches = pattern.exec(symbolName); if (matches) { return makeResult({ name: matches[1], }); } } } if (type.flags & TypeFlags.Number) { return makeResult({ name: 'number', }); } else if (type.flags & TypeFlags.String) { return makeResult({ name: 'string', }); } else if (type.flags & TypeFlags.NumberLiteral) { return makeResult({ name: 'literal', // @ts-ignore value: type.value, }); } else if (type.flags & TypeFlags.Literal) { return makeResult({ name: 'literal', value: checker.typeToString(type), }); } else if (type.symbol?.flags & SymbolFlags.Enum) { return makeResult({ name: 'union', // @ts-ignore value: type.types.map((t) => t.value), }); // @ts-ignore } else if (type.flags & TypeFlags.DisjointDomains) { return makeResult({ name: checker.typeToString(type), }); } else if (type.flags & TypeFlags.Any) { return makeResult({ name: 'any', }); } else if (type.flags & TypeFlags.Undefined) { return makeResult({ name: 'undefined', }); } else if (type.flags & TypeFlags.Union && !isComplexType(type)) { return makeResult({ name: 'union', // @ts-ignore value: type.types.map((t) => getDocgenTypeHelper(checker, t, true, getNextParentIds(parentIds, type))), }); } else if (isComplexType(type)) { return makeResult({ name: getSymbolName(type?.symbol || type?.aliasSymbol), }); } else if (type.flags & (TypeFlags.Object | TypeFlags.Intersection)) { if (isTuple(type)) { try { const props = getShape(type); return makeResult({ name: 'tuple', value: props.map((p) => p.value), }); } catch (e) { return makeResult({ name: 'object', }); } // @ts-ignore } else if (checker.isArrayType(type)) { return makeResult({ name: 'Array', // @ts-ignore elements: [ getDocgenTypeHelper( checker, (type as ExtendedType).typeArguments[0], false, getNextParentIds(parentIds, type), ), ], }); // @ts-ignore } else if (type?.symbol?.valueDeclaration?.parameters?.length) { return makeResult({ name: 'func', params: getFunctionParams( // @ts-ignore type?.symbol?.valueDeclaration?.parameters, checker, parentIds, type, ), returns: getFunctionReturns( checker.typeToTypeNode(type, type?.symbol?.valueDeclaration), checker, parentIds, type, ), }); } else if ( // @ts-ignore type?.members?.get('__call')?.declarations[0]?.symbol?.declarations[0]?.parameters?.length ) { return makeResult({ name: 'func', params: getFunctionParams( // @ts-ignore type?.members?.get('__call')?.declarations[0]?.symbol?.declarations[0]?.parameters, checker, parentIds, type, ), }); } else { try { const props = getShape(type); return makeResult({ name: 'signature', type: { signature: { properties: props, }, }, }); } catch (e) { return makeResult({ name: 'object', }); } } } else { return makeResult({ name: 'object', }); } } class MyParser extends Parser { getDocgenType(propType: ts.Type): any { const parentIds = []; // @ts-ignore const parentId = propType?.symbol?.parent?.id; if (parentId) { parentIds.push(parentId); } // @ts-ignore const result = getDocgenTypeHelper(this.checker, propType, true, parentIds); return result; } // override the builtin method, to avoid the false positive public extractPropsFromTypeIfStatelessComponent(type: ts.Type): ts.Symbol | null { const callSignatures = type.getCallSignatures(); if (callSignatures.length) { // Could be a stateless component. Is a function, so the props object we're interested // in is the (only) parameter. for (const sig of callSignatures) { const params = sig.getParameters(); if (params.length === 0) { continue; } // @ts-ignore const returnSymbol = this.checker.getReturnTypeOfSignature(sig); if (!returnSymbol) continue; const symbol = returnSymbol?.symbol; if (!symbol) continue; // @ts-ignore const typeString = this.checker.symbolToString(symbol); if ( typeString.startsWith('ReactElement') || typeString.startsWith('Element') || typeString.startsWith('RaxElement') ) { const propsParam = params[0]; if (propsParam) { return propsParam; } } } } return null; } } const getCompilerOptions = (reactTypePath, originalReactTypePath) => { const options: any = { jsx: ts.JsxEmit.React, module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.Latest, allowSyntheticDefaultImports: true, }; // if (reactTypePath) { // options.paths = { // react: [reactTypePath], // }; // options.exclude = [path.dirname(originalReactTypePath)]; // options.types = []; // options.skipLibCheck = true; // } return options; }; interface SymbolWithMeta extends ts.Symbol { meta?: { exportName: string; subName?: string; }; } function getComponentName(exportName, displayName) { if (displayName) { const firstCharCode = displayName.charCodeAt(0); if (firstCharCode >= 65 && firstCharCode <= 90) { return displayName || exportName; } } return exportName; } const defaultTsConfigPath = path.resolve(__dirname, './tsconfig.json'); export default function parseTS(filePath: string, args: IParseArgs): ComponentDoc[] { if (!filePath) return []; let basePath = args.moduleDir || args.workDir || path.dirname(filePath); let tsConfigPath = findConfig('tsconfig.json', { cwd: basePath }); // path.resolve(basePath, 'tsconfig.json') if ( !tsConfigPath || !existsSync(tsConfigPath) || (args.accesser === 'online' && tsConfigPath === 'tsconfig.json') ) { tsConfigPath = defaultTsConfigPath; } else { basePath = path.dirname(tsConfigPath); } log('ts config path is', tsConfigPath); const { config, error } = ts.readConfigFile(tsConfigPath, (filename) => readFileSync(filename, 'utf8')); if (error !== undefined) { const errorText = `Cannot load custom tsconfig.json from provided path: ${tsConfigPath}, with error code: ${error.code}, message: ${error.messageText}`; throw new Error(errorText); } const { options, errors } = ts.parseJsonConfigFileContent( config, ts.sys, basePath, {}, tsConfigPath, ); if (errors && errors.length) { throw errors[0]; } log('ts config is', options); // const filePaths = Array.isArray(filePathOrPaths) ? filePathOrPaths : [filePathOrPaths]; generateDTS(args); const program = ts.createProgram([filePath], options); const parser = new MyParser(program, {}); const checker = program.getTypeChecker(); const result = [filePath] .map((fPath) => program.getSourceFile(fPath)) .filter((sourceFile) => typeof sourceFile !== 'undefined') .reduce((docs: any[], sourceFile) => { const moduleSymbol = checker.getSymbolAtLocation(sourceFile as ts.Node); if (!moduleSymbol) { return docs; } const exportSymbols = checker.getExportsOfModule(moduleSymbol); for (let index = 0; index < exportSymbols.length; index++) { const sym: SymbolWithMeta = exportSymbols[index]; const name = sym.getName(); if (blacklistNames.includes(name)) { continue; } // polyfill valueDeclaration sym.valueDeclaration = sym.valueDeclaration || (Array.isArray(sym.declarations) && sym.declarations[0]); if (!sym.valueDeclaration) { continue; } const info = parser.getComponentInfo(sym, sourceFile); if (info === null) { continue; } const exportName = sym.meta && sym.meta.exportName; const meta = { subName: exportName ? name : '', exportName: exportName || name, }; if (docs.find((x) => isEqual(x.meta, meta))) { continue; } docs.push({ ...info, meta, }); // find sub components if (!!sym.declarations && sym.declarations.length === 0) { continue; } const type = checker.getTypeOfSymbolAtLocation( sym, sym.valueDeclaration || sym.declarations[0], ); Array.prototype.push.apply( exportSymbols, type.getProperties().map((x: SymbolWithMeta) => { x.meta = { exportName: name }; return x; }), ); } return docs; }, []); const coms = result.reduce((res: any[], info: any) => { if (!info || !info.props || isEmpty(info.props)) return res; const props = Object.keys(info.props).reduce((acc: any[], name) => { // omit aria related properties temporarily if (name.startsWith('aria-')) { return acc; } try { const item: any = transformItem(name, info.props[name]); acc.push(item); } catch (e) { log(e); } return acc; }, []); const exportName = info?.meta?.exportName; res.push({ componentName: getComponentName(exportName, info.displayName), props, meta: info.meta || {}, }); return res; }, []); return coms; }