Skip to content

Instantly share code, notes, and snippets.

@kylpo
Created February 8, 2021 18:01
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kylpo/9ff293d06a880898b21072c35f2a2db5 to your computer and use it in GitHub Desktop.
Save kylpo/9ff293d06a880898b21072c35f2a2db5 to your computer and use it in GitHub Desktop.
/**
* This script is used to generate `argTypes` for Storybook.
*
* For helpful documentation, see:
* - https://github.com/storybookjs/storybook/blob/next/addons/controls/src/components/ControlsPanel.tsx#L14
* - https://github.com/storybookjs/storybook/blob/next/lib/components/src/blocks/ArgsTable/ArgRow.tsx#L98
* - https://github.com/storybookjs/storybook/blob/next/lib/components/src/blocks/ArgsTable/ArgValue.tsx#L129
*
* @example <caption>Usage example for Avatar</caption>
* yarn docgen ./monorail/src/v2/core/Avatar/Avatar.tsx
*
*/
const fs = require('fs')
const path = require('path')
const docgen = require('react-docgen-typescript')
const prettier = require('prettier')
const prettierConfig = require('../prettier.config')
const componentPath = process.argv[2]
const componentName = process.argv[3] || path.parse(componentPath).name
//#region Helpers
const DOUBLE_QUOTE_REGEX = RegExp(/"/g)
const PASCAL_CASE_REGEX = /^[A-Z][a-z]+(?:[A-Z][a-z]+)*$/
const STARTS_WITH_CURLY = /^{/
function buildDocgenCommand() {
let result = `yarn docgen ${componentPath}`
if (process.argv[3]) {
result += ` ${process.argv[3]}`
}
return result
}
/**
* @param {string} name
* @returns {Array<string>}
*/
function getItemsFromName(name) {
// Assume "object" if starts with { or PascalCase
if (PASCAL_CASE_REGEX.test(name) || STARTS_WITH_CURLY.test(name)) {
return ['object']
}
// For names that won't split well, just bail out
if (/[(){}[\]<>]/.test(name)) {
return []
}
const splittedItems = name.split('|').map(value => value.trim())
// Handle non-enum case
if (splittedItems.length === 1) {
return splittedItems
}
// Handle enum case
const items = splittedItems.map(item =>
DOUBLE_QUOTE_REGEX.test(item) ? item.replace(DOUBLE_QUOTE_REGEX, '') : item,
)
return items
}
/**
* Build control object
*
* For all control options, see https://storybook.js.org/docs/react/essentials/controls#annotation
*
* @param {string} type
*/
function buildControlForType(type) {
const items = getItemsFromName(type)
// case: invalid items
if (items.length === 0) {
return { type: null }
}
// case: non-enum type
else if (items.length === 1) {
const item = items[0]
switch (item) {
case 'string':
return { type: 'text' }
default:
return { type: item }
}
}
// case: enum with many items
else if (items.length > 4) {
return { type: 'select', options: items }
}
// case: enum with items
else {
return { type: 'radio', options: items }
}
}
/**
* @param {{required: boolean, type: {name: string}}} item
*/
function transformPropDataForStorybook(item) {
// Handle enums by converting their values to unions
let type =
item.type.name === 'enum'
? item.type.value.map(({ value }) => value).join(' | ')
: item.type.name
return {
...item,
type: { ...item.type, required: item.required },
table: { type: { summary: type } },
control: buildControlForType(type),
}
}
//#endregion
//#region Generate argTypes
/*
* Create the parser with our typescript config
*
* Note: `shouldExtractValuesFromUnion` is disabled since it breaks optional types (everything becomes an enum)
*
* For more on these options, see:
* https://github.com/styleguidist/react-docgen-typescript/blob/master/src/parser.ts#L550
*/
const tsConfigParser = docgen.withCustomConfig('./tsconfig.json', {
shouldExtractLiteralValuesFromEnum: true,
shouldRemoveUndefinedFromOptional: true,
propFilter: (prop, component) => {
if (prop.parent) {
return (
// Filter out standard HTML/DOM attributes
!prop.parent.fileName.includes('node_modules/@types/react/') &&
// And specific parent names
![
'StyledComponentProps', // (Mui) e.g. innerRef
'CommonProps', // (Mui) e.g. className, style
].includes(prop.parent.name)
)
}
return true
},
})
const parsedFile = tsConfigParser.parse(componentPath)
const parsedComponent =
parsedFile.find(component => component.displayName === componentName) ||
parsedFile[0]
const parsedProps = parsedComponent.props
const unorderedArgTypes = {}
for (const [key, value] of Object.entries(parsedProps)) {
unorderedArgTypes[key] = transformPropDataForStorybook(value)
}
const argTypes = {}
Object.keys(unorderedArgTypes)
.sort((a, b) => {
// Sort required props to top
const aValue = unorderedArgTypes[a]
const bValue = unorderedArgTypes[b]
if (aValue.required && !bValue.required) {
return -1
}
if (!aValue.required && bValue.required) {
return 1
}
// Alphabetize the rest
return a.toLowerCase().localeCompare(b.toLowerCase())
})
.forEach(key => {
argTypes[key] = unorderedArgTypes[key]
})
//#endregion
const content = {
'!!!!! DO NOT EDIT THIS FILE !!!!!!': {
'REGENERATE THIS WITH ONE OF THE FOLLOWING': [
buildDocgenCommand(),
'yarn docgen:all',
],
},
parameters: {
docs: {
description: {
component: parsedComponent.description,
},
},
},
argTypes,
}
const formattedContent = prettier.format(JSON.stringify(content), {
...prettierConfig,
parser: 'json',
})
const pathToComponentArgTypesFile = path.join(
componentPath,
'..',
'__stories__',
`${componentName}.meta.json`,
)
fs.writeFileSync(pathToComponentArgTypesFile, formattedContent, {
encoding: 'utf8',
flag: 'w',
})
//#endregion
console.log(`Successfully overwrote ${pathToComponentArgTypesFile}`)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment