diff --git a/package.json b/package.json index 831dfac761..22f8fa0563 100644 --- a/package.json +++ b/package.json @@ -74,5 +74,8 @@ ] ] } + }, + "dependencies": { + "typescript-parser": "^2.6.2" } } diff --git a/packages/assets-types/typings/atom.d.ts b/packages/assets-types/typings/atom.d.ts index c174a9a29a..a4124f5777 100644 --- a/packages/assets-types/typings/atom.d.ts +++ b/packages/assets-types/typings/atom.d.ts @@ -30,8 +30,14 @@ export type AtomPropsDefinition = Record; +export interface AtomLink { + url: string; + title: string; +} + export default interface AtomAsset { /** * The export module identifier of atom asset diff --git a/packages/preset-dumi/src/api-parser/index.ts b/packages/preset-dumi/src/api-parser/index.ts index 567993d9d1..52be0c96e1 100644 --- a/packages/preset-dumi/src/api-parser/index.ts +++ b/packages/preset-dumi/src/api-parser/index.ts @@ -2,7 +2,7 @@ import * as parser from 'react-docgen-typescript-dumi-tmp'; import { buildFilter as getBuiltinFilter } from 'react-docgen-typescript-dumi-tmp/lib/buildFilter'; import FileCache from '../utils/cache'; import ctx from '../context'; -import type { AtomPropsDefinition } from 'dumi-assets-types'; +import type { AtomLink, AtomPropsDefinition } from 'dumi-assets-types'; import type { PropFilter as IPropFilter, PropItem as IPropItem, diff --git a/packages/preset-dumi/src/theme/loader.ts b/packages/preset-dumi/src/theme/loader.ts index d93dd32636..3e08994b60 100644 --- a/packages/preset-dumi/src/theme/loader.ts +++ b/packages/preset-dumi/src/theme/loader.ts @@ -64,6 +64,7 @@ const FALLBACK_THEME = `${THEME_PREFIX}default`; export const REQUIRED_THEME_BUILTINS = [ 'Alert', 'API', + 'Entity', 'Badge', 'Example', 'Previewer', diff --git a/packages/preset-dumi/src/transformer/remark/api.ts b/packages/preset-dumi/src/transformer/remark/api.ts index 72fc2531c1..bb68ee45b1 100644 --- a/packages/preset-dumi/src/transformer/remark/api.ts +++ b/packages/preset-dumi/src/transformer/remark/api.ts @@ -182,7 +182,7 @@ export default function api(): IDumiUnifiedTransformer { sourcePath: path.dirname(this.data('fileAbsPath')), silent: true, }); - + parseOpts.componentName = vFile.data.componentName; definitions = parser(sourcePath, parseOpts); identifier = vFile.data.componentName; diff --git a/packages/preset-dumi/src/transformer/remark/domWarn.ts b/packages/preset-dumi/src/transformer/remark/domWarn.ts index 9eaf090b0f..832ddc10b3 100644 --- a/packages/preset-dumi/src/transformer/remark/domWarn.ts +++ b/packages/preset-dumi/src/transformer/remark/domWarn.ts @@ -6,6 +6,7 @@ const NO_PARAGRAPH_RULES: { type: string; tagName: string; [key: string]: any }[ { type: 'element', tagName: 'code', properties: { src: Boolean } }, { type: 'element', tagName: 'embed', properties: { src: Boolean } }, { type: 'element', tagName: 'API' }, + { type: 'element', tagName: 'Entity' }, ]; /** diff --git a/packages/preset-dumi/src/transformer/remark/entity.ts b/packages/preset-dumi/src/transformer/remark/entity.ts new file mode 100644 index 0000000000..cda9061004 --- /dev/null +++ b/packages/preset-dumi/src/transformer/remark/entity.ts @@ -0,0 +1,238 @@ +import fs from 'fs'; +import path from 'path'; +import type { Node } from 'unist'; +import deepmerge from 'deepmerge'; +import is from 'hast-util-is-element'; +import has from 'hast-util-has-property'; +import visit from 'unist-util-visit'; +import { parseElmAttrToProps } from './utils'; +import parser from '../../api-parser'; +import { getModuleResolvePath } from '../../utils/moduleResolver'; +import { listenFileOnceChange } from '../../utils/watcher'; +import ctx from '../../context'; +import type { ArgsType } from '@umijs/utils'; +import type { IDumiUnifiedTransformer, IDumiElmNode } from '.'; + +import { TypescriptParser, ClassLikeDeclaration } from 'typescript-parser'; +import { AtomPropsDefinition } from 'dumi-assets-types'; + +const tparser = new TypescriptParser(); + +function applyEntityData(identifier: string, definitions: ReturnType) { + if (identifier && definitions) { + ctx.umi?.applyPlugins({ + key: 'dumi.detectApi', + type: ctx.umi.ApplyPluginsType.event, + args: { + identifier, + data: definitions, + }, + }); + } +} + +/** + * serialize api node to [title, node, title, node, ...] + * @param node original api node + * @param identifier api parse identifier, mapping in .umi/dumi/apis.json + * @param definitions api definitions + */ +function serializeEntityNodes( + node: IDumiElmNode, + identifier: string, + definitions: ReturnType, +) { + const parsedAttrs = parseElmAttrToProps(node.properties); + const expts: string[] = parsedAttrs.exports || Object.keys(definitions); + const showTitle = !parsedAttrs.hideTitle; + + return expts.reduce<(IDumiElmNode | Node)[]>((list, expt, i) => { + // render large API title if it is default export + // or it is the first export and the exports attribute was not custom + + const isInsertAPITitle = expt === 'default' || (!i && !parsedAttrs.exports); + // render sub title for non-default export + const isInsertSubTitle = expt !== 'default'; + const entityNode = deepmerge({}, node); + + // insert API title + if (showTitle && isInsertAPITitle) { + list.push( + { + type: 'element', + tagName: 'h2', + properties: {}, + children: [{ type: 'text', value: '其他实体类型定义' }], + }, + { + type: 'text', + value: '\n', + }, + ); + } + + // insert export sub title + if (showTitle && isInsertSubTitle) { + list.push( + { + type: 'element', + tagName: 'h3', + properties: { id: `entity-${expt.toLowerCase()}` }, + children: [{ type: 'text', value: expt }], + }, + { + type: 'text', + value: '\n', + }, + ); + } + + // insert API Node + delete entityNode.properties.exports; + entityNode.properties.identifier = identifier; + entityNode.properties.export = expt; + + entityNode._dumi_parsed = true; + list.push(entityNode); + + return list; + }, []); +} + +/** + * detect component name via file path + */ +function guessComponentName(fileAbsPath: string) { + const parsed = path.parse(fileAbsPath); + + if (['index', 'index.d'].includes(parsed.name)) { + // button/index.tsx => button + // packages/button/src/index.tsx => button + // packages/button/lib/index.d.ts => button + // windows: button\\src\\index.tsx => button + // windows: button\\lib\\index.d.ts => button + return path.basename(parsed.dir.replace(/(\/|\\)(src|lib)$/, '')); + } + + // components/button.tsx => button + return parsed.name; +} + +/** + * watch component change to update api data + * @param absPath component absolute path + * @param identifier api identifier + * @param parseOpts extra parse options + */ +function watchComponentUpdate( + absPath: string, + identifier: string, + parseOpts: ArgsType[1], +) { + listenFileOnceChange(absPath, () => { + let definitions: ReturnType; + + try { + definitions = parseDefinitions(absPath); + } catch (err) { + /* noting */ + } + + // update api data + applyEntityData(identifier, definitions); + + // watch next turn + // FIXME: workaround for resolve no such file error + /* istanbul ignore next */ + setTimeout( + () => { + watchComponentUpdate(absPath, identifier, parseOpts); + }, + fs.existsSync(absPath) ? 0 : 50, + ); + }); +} + +/** + * remark plugin for parse embed tag to external module + */ +export default function entity(): IDumiUnifiedTransformer { + return (ast, vFile) => { + visit(ast, 'element', (node, i, parent) => { + if (is(node, 'Entity') && !node._dumi_parsed) { + let identifier: string; + let definitions: ReturnType; + const parseOpts = parseElmAttrToProps(node.properties); + // console.log('parseOpts', parseOpts) + + if (has(node, 'src')) { + const src = node.properties.src || ''; + let absPath = path.join(path.dirname(this.data('fileAbsPath')), src); + try { + absPath = getModuleResolvePath({ + basePath: process.cwd(), + sourcePath: src, + silent: true, + }); + } catch (err) { + // nothing + } + // guess component name if there has no identifier property + const componentName = node.properties.identifier || guessComponentName(absPath); + + parseOpts.componentName = componentName; + identifier = 'entity-' + componentName || src; + definitions = parseDefinitions(absPath) + + // trigger listener to update previewer props after this file changed + watchComponentUpdate(absPath, identifier, parseOpts); + } else if (vFile.data.componentName) { + try { + const sourcePath = getModuleResolvePath({ + basePath: process.cwd(), + sourcePath: path.dirname(this.data('fileAbsPath')), + silent: true, + }); + + parseOpts.componentName = vFile.data.componentName; + + definitions = parseDefinitions(sourcePath); + identifier = 'entity-' + vFile.data.componentName; + + // trigger listener to update previewer props after this file changed + watchComponentUpdate(sourcePath, identifier, parseOpts); + } catch (err) { + /* noting */ + } + } + + if (identifier && definitions) { + // replace original node + parent.children.splice(i, 1, ...serializeEntityNodes(node, identifier, definitions)); + + // apply api data + applyEntityData(identifier, definitions); + } + } + }); + }; +} +function parseDefinitions(sourcePath: string): AtomPropsDefinition { + const parsed = tparser.parseFileSync(sourcePath, ''); + const declarations = parsed.declarations; + const definitions: AtomPropsDefinition = {}; + declarations.forEach(declaration => { + if (Object.hasOwn(declaration, 'properties')) { + const d = declaration as ClassLikeDeclaration; + definitions[declaration.name] = d.properties.map(property => { + return { + identifier: property.name, + type: property.type, + description: '', + required: !property.isOptional ? true : undefined, + }; + }); + } + }); + return definitions; +} diff --git a/packages/preset-dumi/src/transformer/remark/index.ts b/packages/preset-dumi/src/transformer/remark/index.ts index 8059c211ea..99ba57b451 100644 --- a/packages/preset-dumi/src/transformer/remark/index.ts +++ b/packages/preset-dumi/src/transformer/remark/index.ts @@ -17,6 +17,7 @@ import codeBlock from './codeBlock'; import code from './code'; import embed from './embed'; import api from './api'; +import entity from './entity'; import mdComponent from './mdComponent'; import link from './link'; import img from './img'; @@ -139,6 +140,8 @@ export default (source: string, fileAbsPath: string, type: 'jsx' | 'html', maste .use(debug('comments')) .use(code) .use(debug('code')) + .use(entity) + .use(debug('entity')) .use(api) .use(debug('api')) .use(mdComponent) diff --git a/packages/preset-dumi/src/transformer/remark/raw.ts b/packages/preset-dumi/src/transformer/remark/raw.ts index 572d239ffa..e740e099ae 100644 --- a/packages/preset-dumi/src/transformer/remark/raw.ts +++ b/packages/preset-dumi/src/transformer/remark/raw.ts @@ -48,6 +48,11 @@ export default (): IDumiUnifiedTransformer => ast => { if (typeof node.value === 'string' && //g, '>'); } + + // special process for same reason in above + if (typeof node.value === 'string' && //g, '>'); + } }); // raw to hast tree diff --git a/packages/theme-default/src/builtins/API.tsx b/packages/theme-default/src/builtins/API.tsx index e51f726c96..102f636fb9 100644 --- a/packages/theme-default/src/builtins/API.tsx +++ b/packages/theme-default/src/builtins/API.tsx @@ -1,8 +1,50 @@ import React, { useContext } from 'react'; -import type { IApiComponentProps} from 'dumi/theme'; +import type { IApiComponentProps } from 'dumi/theme'; import { context, useApiData } from 'dumi/theme'; import Table from './Table'; +const PRIMARY_TYPES = [ + 'string', + 'number', + 'boolean', + 'object', + 'array', + 'function', + 'symbol', + 'any', + 'void', + 'null', + 'undefined', + 'never', + '', +]; + +const TYPE_SPLIT = /\||\&|,|\<|\>| |\=|\[|\]|\(|\)|\:|"|'/; + +const isPrimaryType = (type: string) => { + type = type.toLocaleLowerCase(); + if (PRIMARY_TYPES.includes(type)) return true; + if (type.startsWith('React.')) return true; + const types = type.split(TYPE_SPLIT); + if (types.length > 1) { + return types.every(isPrimaryType); + } else { + return PRIMARY_TYPES.includes(types[0]); + } +}; + +const getComplicatedType = (type: string) => { + type = type.toLocaleLowerCase(); + if (PRIMARY_TYPES.includes(type)) return ''; + if (type.startsWith('React.')) return ''; + const cts = type.split(TYPE_SPLIT).filter(t => !isPrimaryType(t)); + if (cts.length >= 1) { + return cts[0]; + } else { + return false; + } +}; + const LOCALE_TEXTS = { 'zh-CN': { name: '属性名', @@ -19,6 +61,12 @@ const LOCALE_TEXTS = { required: '(required)', }, }; +/** + * syntax: {@link namepathOrURL|link text} + */ +// const linkReg = /@link\s*?\{[^\}\(\)\{@]*?(\([^\}\(\)\{@]*?\))?\s*?\}/g; +const linkRegG = /\{@link\s*?(\S*?)\s*?\|(.*?)\}/g; +const linkReg = /\{@link\s*?(\S*?)\s*?\|(.*?)\}/; export default ({ identifier, export: expt }: IApiComponentProps) => { const data = useApiData(identifier); @@ -38,18 +86,47 @@ export default ({ identifier, export: expt }: IApiComponentProps) => { - {data[expt].map(row => ( - - {row.identifier} - {row.description || '--'} - - {row.type} - - - {row.default || (row.required && texts.required) || '--'} - - - ))} + {data[expt]?.map(row => { + if (linkReg.test(row.description)) { + const groups = row.description.match(linkRegG); + const links = groups.map(group => { + const [, target, name] = group.match(linkReg); + return { + title: name, + url: target, + }; + }); + row.links = links; + row.description = row.description.replaceAll(linkRegG, ''); + } + return ( + + {row.identifier} + + + {row.links?.map((link, i) => ( + + {link.title} + + ))} + + + + {row.type} + + + + {row.default || (row.required && texts.required) || '--'} + + + ); + })} )} diff --git a/packages/theme-default/src/builtins/Entity.tsx b/packages/theme-default/src/builtins/Entity.tsx new file mode 100644 index 0000000000..8f9cc69b30 --- /dev/null +++ b/packages/theme-default/src/builtins/Entity.tsx @@ -0,0 +1,58 @@ +import React, { useContext } from 'react'; +import type { IApiComponentProps} from 'dumi/theme'; +import { context, useApiData } from 'dumi/theme'; +import Table from './Table'; + +const LOCALE_TEXTS = { + 'zh-CN': { + name: '属性名', + description: '描述', + type: '类型', + default: '默认值', + required: '(必选)', + }, + 'en-US': { + name: 'Name', + description: 'Description', + type: 'Type', + default: 'Default', + required: '(required)', + }, +}; + +export default ({ identifier, export: expt }: IApiComponentProps) => { + const data = useApiData(identifier); + const { locale } = useContext(context); + const texts = /^zh|cn$/i.test(locale) ? LOCALE_TEXTS['zh-CN'] : LOCALE_TEXTS['en-US']; + + return ( + <> + {data && ( + + + + + + + + + + + {data[expt]?.map(row => ( + + + + + + + ))} + +
{texts.name}{texts.description}{texts.type}{texts.default}
{row.identifier}{row.description || '--'} + {row.type} + + {row.default || (row.required && texts.required) || '--'} +
+ )} + + ); +};