Skip to content
This repository has been archived by the owner on Mar 4, 2020. It is now read-only.

docs(Props): improve table with props #1634

Merged
merged 12 commits into from
Jul 17, 2019
164 changes: 99 additions & 65 deletions build/gulp/plugins/util/getComponentInfo.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,36 @@
import * as Babel from '@babel/core'
import * as t from '@babel/types'
import _ from 'lodash'
import path from 'path'
import fs from 'fs'

import { BehaviorInfo, ComponentInfo, ComponentProp } from 'docs/src/types'
import * as docgen from './docgen'
import parseDefaultValue from './parseDefaultValue'
import parseDocblock from './parseDocblock'
import parseType from './parseType'
import * as docgen from './docgen'
import getShorthandInfo from './getShorthandInfo'

const getAvailableBehaviors = (accessibilityProp: ComponentProp): BehaviorInfo[] => {
const docTags = accessibilityProp && accessibilityProp.tags
const availableTag = _.find(docTags, { title: 'available' })
const availableBehaviorNames = _.get(availableTag, 'description', '')

if (!availableBehaviorNames) {
return undefined
}

interface BehaviorInfo {
name: string
displayName: string
category: string
return availableBehaviorNames
.replace(/\s/g, '')
.split(',')
.map(name => ({
name,
displayName: _.upperFirst(name.replace('Behavior', '')),
category: _.upperFirst(name.split(/(?=[A-Z])/)[0]),
}))
}

const getComponentInfo = (filepath: string, ignoredParentInterfaces: string[]) => {
const getComponentInfo = (filepath: string, ignoredParentInterfaces: string[]): ComponentInfo => {
const absPath = path.resolve(process.cwd(), filepath)

const dir = path.dirname(absPath)
Expand All @@ -22,7 +40,7 @@ const getComponentInfo = (filepath: string, ignoredParentInterfaces: string[]) =

// singular form of the component's ../../ directory
// "element" for "src/elements/Button/Button.js"
const componentType = path.basename(path.dirname(dir)).replace(/s$/, '')
const componentType = path.basename(path.dirname(dir)).replace(/s$/, '') as ComponentInfo['type']

const components = docgen.withDefaultConfig().parse(absPath)

Expand All @@ -37,111 +55,127 @@ const getComponentInfo = (filepath: string, ignoredParentInterfaces: string[]) =
].join(' '),
)
}
const info: any = components[0]
const info: docgen.ComponentDoc = components[0]

// add exported Component info
//
// this 'require' instruction might break by producing partially initialized types - because of ts-node module's cache used during processing
// - in that case we might consider to disable ts-node cache when running this command: https://github.com/ReactiveX/rxjs/commit/2f86b9ddccbf020b2e695dd8fe0b79194efa3f56
const Component = require(absPath).default
info.constructorName = _.get(Component, 'prototype.constructor.name', null)

if (!Component) {
throw new Error(`Your file with component under "${absPath}" doesn't have a default export`)
}

const componentFile = Babel.parse(fs.readFileSync(absPath).toString(), {
configFile: false,
presets: [['@babel/preset-typescript', { allExtensions: true, isTSX: true }]],
}) as t.File
const constructorName = _.get(Component, 'prototype.constructor.name', null)

// add component type
info.type = componentType
const type = componentType

// add parent/child info
info.isParent = filenameWithoutExt === dirname
info.isChild = !info.isParent
info.parentDisplayName = info.isParent ? null : dirname
const isParent = filenameWithoutExt === dirname
const isChild = !isParent
const parentDisplayName = isParent ? null : dirname
// "Field" for "FormField" since it is accessed as "Form.Field" in the API
info.subcomponentName = info.isParent
? null
: info.displayName.replace(info.parentDisplayName, '')
const subcomponentName = isParent ? null : info.displayName.replace(parentDisplayName, '')

// "ListItem.js" is a subcomponent is the "List" directory
const subcomponentRegExp = new RegExp(`^${dirname}\\w+\\.tsx$`)

info.subcomponents = info.isParent
const subcomponents = isParent
? fs
.readdirSync(dir)
.filter(file => subcomponentRegExp.test(file))
.map(file => path.basename(file, path.extname(file)))
: null

// where this component should be exported in the api
info.apiPath = info.isChild
? `${info.parentDisplayName}.${info.subcomponentName}`
: info.displayName
const apiPath = isChild ? `${parentDisplayName}.${subcomponentName}` : info.displayName

// class name for the component
// example, the "button" in class="ui-button"
// name of the component, sub component, or plural parent for sub component groups
info.componentClassName = (info.isChild
? _.includes(info.subcomponentName, 'Group')
? `ui-${info.parentDisplayName}s`
: `ui-${info.parentDisplayName}__${info.subcomponentName}`
const componentClassName = (isChild
? _.includes(subcomponentName, 'Group')
? `ui-${parentDisplayName}s`
: `ui-${parentDisplayName}__${subcomponentName}`
: `ui-${info.displayName}`
).toLowerCase()

// replace the component.description string with a parsed docblock object
info.docblock = parseDocblock(info.description)
delete info.description
const docblock = parseDocblock(info.description)

// file and path info
info.repoPath = absPath
const repoPath = absPath
.replace(`${process.cwd()}${path.sep}`, '')
.replace(new RegExp(_.escapeRegExp(path.sep), 'g'), '/')
info.filename = filename
info.filenameWithoutExt = filenameWithoutExt

// replace prop `description` strings with a parsed docblock object and updated `type`
_.each(info.props, (propDef, propName) => {
const { description, tags } = parseDocblock(propDef.description)
const { name, value } = parseType(propName, propDef)
let props: ComponentProp[] = []

_.forEach(info.props, (propDef: docgen.PropItem, propName: string) => {
const { description, tags } = parseDocblock(propDef.description)
const parentInterface = _.get(propDef, 'parent.name')
const visibleInDefinition = !_.includes(ignoredParentInterfaces, parentInterface)

if (visibleInDefinition) {
info.props[propName] = {
...propDef,
// `propDef.parent` should be defined to avoid insertion of computed props
const visibleInDefinition =
propDef.parent && !_.includes(ignoredParentInterfaces, parentInterface)
const visibleInTags = !_.find(tags, { title: 'docSiteIgnore' })

if (visibleInDefinition && visibleInTags) {
const types = parseType(componentFile, info.displayName, propName, propDef)
const defaultValue = parseDefaultValue(Component, propDef, types)

props.push({
description,
defaultValue,
tags,
value,
defaultValue: parseDefaultValue(propDef, name),
types,
name: propName,
type: name,
}
} else {
delete info.props[propName]
required: propDef.required,
})
}
})

// manually insert `as` prop
if (info.props.as) {
bmdalex marked this conversation as resolved.
Show resolved Hide resolved
props.push({
description: 'An element type to render as (string or component).',
defaultValue: parseDefaultValue(Component, info.props.as, []) || 'div',
tags: [],
types: [{ name: 'React.ElementType' }],
name: 'as',
required: false,
})
}

// sort props
info.props = _.sortBy(info.props, 'name')
props = _.sortBy(props, 'name')

// available behaviors
info.behaviors = getAvailableBehaviors(_.find(info.props, { name: 'accessibility' }))
return info
}

const getAvailableBehaviors: (accessibilityProp: any) => BehaviorInfo = accessibilityProp => {
const docTags = accessibilityProp && accessibilityProp.tags
const availableTag = _.find(docTags, { title: 'available' })
const availableBehaviorNames = _.get(availableTag, 'description', '')

if (!availableBehaviorNames) {
return undefined
const behaviors = getAvailableBehaviors(_.find(props, { name: 'accessibility' }))

return {
...getShorthandInfo(componentFile, info.displayName),
apiPath,
behaviors,
componentClassName,
constructorName,
displayName: info.displayName,
docblock,
filename,
filenameWithoutExt,
isChild,
isParent,
parentDisplayName,
props,
repoPath,
subcomponentName,
subcomponents,
type,
}

return availableBehaviorNames
.replace(/\s/g, '')
.split(',')
.map(name => ({
name,
displayName: _.upperFirst(name.replace('Behavior', '')),
category: _.upperFirst(name.split(/(?=[A-Z])/)[0]),
}))
}

export default getComponentInfo
71 changes: 71 additions & 0 deletions build/gulp/plugins/util/getShorthandInfo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import * as Babel from '@babel/core'
import { NodePath } from '@babel/traverse'
import * as t from '@babel/types'

import { ComponentInfo } from 'docs/src/types'

type ShorthandInfo = Required<
Pick<ComponentInfo, 'implementsCreateShorthand' | 'mappedShorthandProp'>
>

/**
* Checks that an expression matches signature:
* [componentName].create = createShorthandFactory([config])
*/
const isShorthandExpression = (
componentName: string,
path: NodePath<t.AssignmentExpression>,
): boolean => {
const left = path.get('left')
const right = path.get('right')

if (!left.isMemberExpression() || !right.isCallExpression()) {
return false
}

const object = left.get('object')
const property = left.get('property') as NodePath<t.Identifier>
const callee = right.get('callee')

return (
object.isIdentifier({ name: componentName }) &&
property.isIdentifier({ name: 'create' }) &&
callee.isIdentifier({ name: 'createShorthandFactory' })
bmdalex marked this conversation as resolved.
Show resolved Hide resolved
)
}

const getShorthandInfo = (componentFile: t.File, componentName: string): ShorthandInfo => {
let implementsCreateShorthand = false
let mappedShorthandProp = undefined

Babel.traverse(componentFile, {
AssignmentExpression: path => {
if (isShorthandExpression(componentName, path)) {
implementsCreateShorthand = true

const config = path.get('right.arguments.0') as NodePath<t.ObjectExpression>
config.assertObjectExpression()

const mappedProperty = config.node.properties.find((property: t.ObjectProperty) => {
return t.isIdentifier(property.key, { name: 'mappedProp' })
}) as t.ObjectProperty | null

if (mappedProperty) {
// @ts-ignore
t.assertStringLiteral(mappedProperty.value)
mappedShorthandProp = (mappedProperty.value as t.StringLiteral).value
} else {
// `mappedProp` is optional in `createShorthandFactory()`
mappedShorthandProp = 'children'
}
}
},
})

return {
implementsCreateShorthand,
mappedShorthandProp,
}
}

export default getShorthandInfo
46 changes: 43 additions & 3 deletions build/gulp/plugins/util/parseDefaultValue.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,45 @@
import _ from 'lodash'
import * as React from 'react'

// export default propDef => _.get(propDef, 'defaultValue.value', undefined)
export default (propDef, typeName) =>
_.get(propDef, 'defaultValue.value', typeName === 'boolean' ? 'false' : 'undefined')
import { ComponentPropType } from 'docs/src/types'
import { PropItem } from './docgen'

const parseDefaultValue = (
Component: React.ComponentType,
propDef: PropItem,
types: ComponentPropType[],
) => {
if (Component.defaultProps && _.has(Component.defaultProps, propDef.name)) {
const defaultValue = Component.defaultProps[propDef.name]

if (_.isFunction(defaultValue)) {
return defaultValue.name
}

if (_.isNumber(defaultValue) || _.isString(defaultValue) || _.isBoolean(defaultValue)) {
return defaultValue
}

if (_.isPlainObject(defaultValue)) {
return defaultValue
}

if (_.isNull(defaultValue)) {
return null
}

throw new Error(`Can't parse a value in "${Component.name}.defaultProps.${propDef.name}"`)
}

if (propDef.name === 'as') {
return 'div'
}

if (types.length === 1 && types[0].name === 'boolean') {
return false
}

return undefined
}

export default parseDefaultValue
6 changes: 4 additions & 2 deletions build/gulp/plugins/util/parseDocblock.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import * as doctrine from 'doctrine'

export default docblock => {
const parseDocblock = (docblock: string) => {
const { description = '', tags = [], ...rest } = doctrine.parse(docblock || '', { unwrap: true })

return {
...rest,
description,
tags,
description: description.split('\n'),
}
}

export default parseDocblock
Loading