614 lines
16 KiB
TypeScript

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