Snippet - Parse prop-types

4 years ago

There are several prop-type generators out there, but if you like to play with JavaScript ASTs and prefer to roll your own, here is a starting snippet using acorn, a JavaScript parser. This will generate a "mostly" react-docgen compatible schema.

I highly recommend using react-docgen, especially the BETA version which has support for things like static properties, async...await, and dynamic imports.

npm install acorn acorn-es7 acorn-jsx acorn-object-rest-spread acorn-stage3 acorn-static-class-property-initializer
const fs = require('fs')

const walk = require('acorn/dist/walk')

let acorn = [
  require('acorn-bigint/inject'),
  require('acorn-class-fields/inject'),
  require('acorn-dynamic-import/lib/inject').default,
  require('acorn-es7'),
  require('acorn-import-meta/inject'),
  require('acorn-json-superset/inject'),
  require('acorn-jsx/inject'),
  require('acorn-object-rest-spread/inject'),
  require('acorn-optional-catch-binding/inject'),
  require('acorn-private-methods/inject'),
  require('acorn-static-class-property-initializer/inject')
].reduce((acc, plugin) => plugin(acc), require('acorn'))

const acornPlugins = {
  bigInt: true,
  classFields: true,
  dynamicImport: true,
  es7: true,
  importMeta: true,
  jsonSuperset: true,
  jsx: true,
  objectRestSpread: true,
  optionalCatchBinding: true,
  privateMethods: true,
  staticClassPropertyInitializer: true
}

const getModuleExports = parsedFile => {
  const exportNamedDeclaration = parsedFile.body.find(b => b.type === 'ExportNamedDeclaration')
  const exportNamedDeclarations = exportNamedDeclaration
    ? exportNamedDeclaration.specifiers.map(s => s.exported.name)
    : []
  let exportDefaultDeclaration = parsedFile.body.find(b => b.type === 'ExportDefaultDeclaration')
  exportDefaultDeclaration = exportDefaultDeclaration ? [exportDefaultDeclaration.declaration.name] : []

  return [...exportNamedDeclarations, ...exportDefaultDeclaration]
}

const getModulePropTypes = property => {
  return {
    [property.key.name]: {
      type: getPropertyNameValue(property.value),
      required: property.value.property ? property.value.property.name === 'isRequired' : false,
      description: ''
    }
  }
}

const getProperties = (parsedFile, moduleExport, propertyName = 'propTypes') => {
  let propTypesList = []
  const bodies = parsedFile.body.filter(b => b.type === 'ClassDeclaration' || b.type === 'ExpressionStatement')

  for (let body of bodies) {
    switch (body.type) {
      case 'ClassDeclaration':
        if (body.body) {
          if (body.body.body) {
            const bodyWithModuleExport = body.body.body.find(b => b.key.name === propertyName)
            if (body.id.name === moduleExport && bodyWithModuleExport) {
              propTypesList = bodyWithModuleExport.value.properties
            }
          }
        }
        break
      case 'ExpressionStatement':
        if (body.expression.left.object.name === moduleExport && body.expression.left.property.name === propertyName) {
          if (body.expression.right.type !== 'Identifier') {
            propTypesList = body.expression.right.properties
          } else {
            try {
              // THIS WORKS WHEN SIMPLE / CHOKES ON JSX
              walk.full(parsedFile, node => {
                if (node.type === 'Program') {
                  const variableBody = node.body
                    .filter(b => b.type === 'VariableDeclaration')
                    .find(b => b.declarations.find(d => d.id.name === propertyName) !== undefined)
                  if (variableBody) {
                    propTypesList = variableBody.declarations.find(d => d.id.name === propertyName).init.properties
                  }
                }
              })
            } catch (error) {
              console.log(moduleExport + error)
            }
          }
        }
        break
      default:
        break
    }
    if (propTypesList.length > 0) {
      break
    }
  }
  return propTypesList
}

const getPropertyName = propTypeValue => {
  return propTypeValue.object
    ? propTypeValue.object.property
      ? propTypeValue.object.property.name
      : propTypeValue.property.name
    : propTypeValue.callee
    ? propTypeValue.callee.property.name
    : propTypeValue.property
    ? propTypeValue.property.name
    : 'custom' // function
}

const getPropertyNameValue = propTypeValue => {
  let value
  let parameters
  let name = getPropertyName(propTypeValue)
  switch (name) {
    case 'custom':
      parameters = propTypeValue.params.map(p => p.name).join(', ')
      break
    case 'instanceOf':
      value = propTypeValue.arguments[0].name
      break
    case 'oneOf':
      name = 'enum'
      value = propTypeValue.arguments[0].elements.map(a => ({
        value: "'" + a.value + "'",
        computed: false
      }))
      break
    case 'oneOfType':
      name = 'union'
      value = propTypeValue.arguments[0].elements.map(a => ({
        ...getPropertyNameValue(a)
      }))
      break
    case 'arrayOf':
    case 'objectOf':
      value = propTypeValue.arguments.reduce(
        (acc, a) => ({
          ...acc,
          ...getPropertyNameValue(a)
        }),
        {}
      )
      break
    case 'shape':
      console.log(JSON.stringify(propTypeValue, null, 2))
      value = propTypeValue.arguments[0].properties
        ? propTypeValue.arguments[0].properties.reduce(
            (acc, p) => ({
              ...acc,
              [p.key.name]: {
                ...getPropertyNameValue(p.value),
                required: p.value.property ? p.value.property.name === 'isRequired' : false
              }
            }),
            {}
          )
        : {}
      break
    default:
      break
  }
  return {
    name,
    value,
    parameters
  }
}

const getPropTypesByFileName = fileName => {
  const fileNameCode = fs.readFileSync(fileName).toString()

  // Strip JSX as Acorn doesn't handle this well
  const regex = /<[^]*>/g
  let code = fileNameCode.replace(regex, ' null')

  const parsedFile = acorn.parse(code, {
    sourceType: 'module',
    ecmaVersion: 10,
    plugins: acornPlugins,
    allowImportExportEverywhere: true
  })

  return getModuleExports(parsedFile).reduce((combinedPropTypes, moduleExport) => {
    const properties = getProperties(parsedFile, moduleExport, 'propTypes')
    combinedPropTypes[moduleExport] = properties.reduce((acc, property) => {
      return {
        ...acc,
        ...getModulePropTypes(property)
      }
    }, {})
    return combinedPropTypes
  }, {})
}

module.exports = {
  getPropTypesByFileName
}
Discuss on Twitter