Skip to content

Instantly share code, notes, and snippets.

@DerrikMilligan
Last active September 6, 2023 21:52
Show Gist options
  • Save DerrikMilligan/5dbe22244ad666a2d527548424a74a1f to your computer and use it in GitHub Desktop.
Save DerrikMilligan/5dbe22244ad666a2d527548424a74a1f to your computer and use it in GitHub Desktop.
Generate Wave Typescript Defintions
const fs = require('fs')
const baseDocPath = './src/documentation/views/ui-components/' // 'tag/api.vue'
const basePath = './src/wave-ui/components/'
/** @type Record<string, {splitDocs: boolean, docs: string | false, file?: string}> */
const components = {
'w-accordion': { splitDocs: false, docs: 'accordion' },
'w-alert': { splitDocs: false, docs: 'alert' },
'w-app': { splitDocs: false, docs: 'app' },
'w-badge': { splitDocs: false, docs: 'badge' },
'w-breadcrumbs': { splitDocs: false, docs: 'breadcrumbs' },
'w-button': { splitDocs: false, docs: 'button', file: 'w-button/index' },
'w-card': { splitDocs: false, docs: 'card' },
'w-checkbox': { splitDocs: true, docs: 'checkbox' },
'w-checkboxes': { splitDocs: true, docs: 'checkbox' },
'w-confirm': { splitDocs: false, docs: 'confirm' },
// 'w-date-picker': { splitDocs: false }, // Not a real component yet
'w-dialog': { splitDocs: false, docs: 'dialog' },
'w-divider': { splitDocs: false, docs: 'divider' },
'w-drawer': { splitDocs: false, docs: 'drawer' },
'w-flex': { splitDocs: false, docs: false }, // No API docs
'w-form-element': { splitDocs: false, docs: false }, // No API docs
'w-form': { splitDocs: false, docs: 'form' },
'w-grid': { splitDocs: false, docs: false }, // No API docs
'w-icon': { splitDocs: false, docs: 'icon' },
'w-image': { splitDocs: false, docs: 'image' },
'w-input': { splitDocs: false, docs: 'input' },
'w-list': { splitDocs: false, docs: 'list' },
'w-menu': { splitDocs: false, docs: 'menu' },
'w-notification-manager': { splitDocs: true, docs: 'notification' },
'w-notification': { splitDocs: true, docs: 'notification' },
'w-overlay': { splitDocs: false, docs: 'overlay' },
// 'w-paralax': { splitDocs: false, docs: false }, // Not a real component yet
'w-progress': { splitDocs: false, docs: 'progress' },
'w-radio': { splitDocs: true, docs: 'radio' },
'w-radios': { splitDocs: true, docs: 'radio' },
'w-rating': { splitDocs: false, docs: 'rating' },
// 'w-scrollable': { splitDocs: false, docs: 'scrollable' },
'w-select': { splitDocs: false, docs: 'select' },
'w-slider': { splitDocs: false, docs: 'slider' },
// 'w-slideshow': { splitDocs: false, docs: 'slideshow' }, // Not a real component yet
'w-spinner': { splitDocs: false, docs: 'spinner' },
'w-steps': { splitDocs: false, docs: 'steps' },
'w-switch': { splitDocs: false, docs: 'switch' },
'w-table': { splitDocs: false, docs: 'table' },
'w-tabs': { splitDocs: false, docs: 'tabs', file: 'w-tabs/index' },
'w-tag': { splitDocs: false, docs: 'tag' },
'w-textarea': { splitDocs: false, docs: 'textarea' },
'w-timeline': { splitDocs: false, docs: 'timeline' },
'w-toolbar': { splitDocs: false, docs: 'toolbar' },
'w-tooltip': { splitDocs: false, docs: 'tooltip' },
'w-tree': { splitDocs: false, docs: 'tree' },
}
function cleanComponentName(componentTag) {
const [_, ...pieces] = componentTag.split('-')
return pieces.map(piece => piece.charAt(0).toUpperCase() + piece.slice(1)).join('')
}
/**
* Cleans out html tags from an html description and makes them markdown styled with backticks
*
* @param {string} description
* @returns {string}
*/
function cleanHtmlDescription(description) {
return description
.replace(/<code>(.*?)<\/code>/gmi, `\`$1\``)
.replace(/<strong.*?>(.*?)<\/strong>/gmi, `\`$1\``)
.replace(/<em.*?>(.*?)<\/em>/gmi, `\`$1\``)
.replace(/<a.*?>(.*?)<\/a>/gmi, `\`$1\``)
.replace(/<span.*?>(.*?)<\/span>/gmi, `\`$1\``)
.replace('&amp;', '&')
.replace('&lt;', '<')
.replace('&gt;', '>')
.replace('\\', '')
.split('<br>')
.map(s => s.trim())
.join('\n * ')
}
/**
* Attempt to build a typescript union type from an array of javascript types
*
* @param {Array<string>} types - An array of javascript types as strings
* @returns {string} the typescript type
*/
function buildTypeScriptType(types) {
if (Array.isArray(types) === false || types.length === 0) {
return 'any'
}
return types.reduce((acc, type, index) => {
if (type === 'String') {
acc += 'string'
} else if (type === 'Number') {
acc += 'number'
} else if (type === 'Boolean') {
acc += 'boolean'
} else if (type === 'Array') {
acc += 'Array<any>'
} else if (type === 'Object') {
acc += '{}'
} else if (type instanceof String) {
acc += `'${type}'`
}
if ((index + 1) < types.length) {
acc += '|'
}
return acc
}, '')
}
const propsRegex = new RegExp('\\s+(\\S+):\\s+{\\n?\\s*(type:\\s\\[?(.*?)\\]?)?,?\\n?\\s*?(default:\\s+(.*?)\\s+)?\\s*(required:\\s+(true|false)\\s+)?\\s*?},?\\s*(\\/\\/.*)?$', 'gm')
const propsDescriptionRegex = new RegExp("\\s+(\\S+):\\s+'(.*?)',?$", 'gm')
/**
* Extract props from a component file and an apiFile
*
* @param {string} componentFile - The body of the component file
* @param {string} apiFile - The body of the API documentation file
* @returns {Array<{name: string, types: Array<string>, default: any, required: boolean, description: string}>}
*/
function extractProps(componentFile, apiFile) {
const propsDescriptions = [
...apiFile
.split(/propsDescs\s?[=:] {/i)[1]
?.split('}')[0]
?.matchAll(propsDescriptionRegex) || [],
].map(matches => ({
name : matches[1],
description: matches[2],
}))
const props = [ ...componentFile.matchAll(propsRegex) ]
return props
.map(match => {
// Remove the entry containing the enitre everything
match.splice(0, 1)
// Base props object, pieces 0 will always be the prop name
const prop = {
name : match[0],
types : [],
default : undefined,
required : false,
description: '',
}
let index = match.findIndex(piece => piece && piece.includes('type'))
if (index !== -1 && match[index + 1]) {
prop.types = match[index + 1].split(',').map(s => s.trim())
}
index = match.findIndex(piece => piece && piece.includes('default'))
if (index !== -1 && match[index + 1]) {
prop.default = match[index + 1]
}
index = match.findIndex(piece => piece && piece.includes('required'))
if (index !== -1 && match[index + 1]) {
prop.required = !!match[index + 1]
}
index = match.findIndex(piece => piece && piece.includes('//'))
if (index !== -1 && match[index + 1]) {
prop.description = match[index + 1]
}
const apiDescription = propsDescriptions.find(p => p.name === prop.name)?.description ?? ''
if (apiDescription !== undefined) {
prop.description = apiDescription
}
return prop
})
}
/**
* Build a typescript interface for a given components props
*
* @param {string} component - The component name: eg: w-tag
* @param {Array<{name: string, types: Array<string>, default: any, required: boolean, description: string}>} props - All the prop information
* @returns {string} The typescript interface
*/
function buildPropsTypescriptDefinitions(component, props) {
const properComponentName = cleanComponentName(component)
let output = `export interface Wave${properComponentName}Props {\n`
for (const prop of props) {
const types = buildTypeScriptType(prop.types)
let links = [ ...prop.description?.match(/href=".*?"/gmi) ?? [] ]
.map(href => href.split('"')[1])
.map(link => `\n * @see https://antoniandre.github.io/wave-ui/${link}`)
.join('')
const description = cleanHtmlDescription(prop.description)
const defaults = prop.default !== undefined
? ` - Default: ${prop.default}`
: ''
const propBody = ` /**
* ${description.length > 0 ? description : 'TODO: Add Description'}
* @property {${types}} ${prop.required ? '[' : ''}${prop.name}${prop.required ? ']' : ''}${defaults}
* @see https://antoniandre.github.io/wave-ui/${component}${links}
*/
${prop.name}${prop.required ? '' : '?'}: ${types}\n\n`
output += propBody
}
output += `}\n`
return output
}
/**
* Extract emits from a component file and an apiFile
*
* @param {string} componentFile - The body of the component file
* @param {string} apiFile - The body of the API documentation file
* @returns {Array<{name: string, description: string, params: Record<string, string>}>}
*/
function extractEmits(componentFile, apiFile) {
if (componentFile.includes('emits') === false) {
return []
}
/** @type Array<string> */
const emitDeclarations = JSON.parse('[' + componentFile.split('emits: [')[1].split(']')[0].replace(/'/g, '"') + ']')
// Super ghetto and potentially dangerous... But it works
/** @type Record<string, { description?: string, params?: Array<Record<string, string>>}> */
let emitDescriptions = apiFile.replace('eventsDescs = {', 'events = {').split('events = {') || []
if (Array.isArray(emitDescriptions) && emitDescriptions.length > 1 && emitDescriptions[1].length > 0 && emitDescriptions[1][0] !== '}') {
emitDescriptions = emitDescriptions[1].split('\n}')
if (Array.isArray(emitDescriptions) && emitDescriptions.length > 0) {
emitDescriptions = eval(`({${emitDescriptions[0]}})`)
}
}
const emits = emitDeclarations.map(emitName => {
const description = Object.prototype.hasOwnProperty.call(emitDescriptions, emitName) ? emitDescriptions[emitName] : null
const emit = {
name: emitName,
...description,
}
emit.description = emit.description ?? ''
emit.params = emit.params ?? {}
emit.params = Object.keys(emit.params).reduce((acc, param) => {
// eslint-disable-next-line no-useless-escape
acc[param.replace(/[\[\]]/ig, '')] = emit.params[param]
return acc
}, {})
return emit
})
return emits
}
/**
* Build a typescript interface for a given components emits
*
* @param {string} component - The component name: eg: w-tag
* @param {Array<{name: string, description: string, params: Record<string, string>}>} emits - All the emit information
* @returns {string} The typescript interface
*/
function buildEmitsTypescriptDefinitions(component, emits) {
const properComponentName = cleanComponentName(component)
let output = `export interface Wave${properComponentName}Emits {\n`
for (const emit of emits) {
let methodName = emit.name.replace(/-(.)/ig, (_, char) => char.toUpperCase())
methodName = `on${methodName.charAt(0).toUpperCase()}${methodName.slice(1)}`
const methodDefParams = Object.keys(emit.params)
.map((param, index) => `renameMe${index + 1}: ${buildTypeScriptType(param)}`)
.join(', ')
const methodDef = `'${methodName}'?: (${methodDefParams}) => void`
const jsdocParams = Object.keys(emit.params)
.map((param, index) => `\n * @param {${buildTypeScriptType(param)}} renameMe${index + 1}${emit.params[param] ? ' - ' + emit.params[param] : '' }`)
.join('')
const emitBody = ` /**
* ${emit.description.length > 0 ? emit.description : 'TODO: Add Description'}${jsdocParams}
* @see https://antoniandre.github.io/wave-ui/${component}
*/
${methodDef}\n\n`
output += emitBody
}
output += `}\n`
return output
}
const computedDefRegex = new RegExp('((?:^\\s+(\\w+)\\s?\\(.*?\\)\\s*?{)+)', 'gm')
/**
* Extract computed properties from a component file and an apiFile
*
* @param {string} componentFile - The body of the component file
* @param {string} apiFile - The body of the API documentation file
* @returns {Array<{name: string>}
*/
function extractComputed(componentFile, apiFile) {
if (componentFile.includes('computed') === false) {
return []
}
const computedSection = componentFile.split('computed: {')[1].split('\n }')[0]
const computedDeclarations = [ ...computedSection.matchAll(computedDefRegex) ]
.filter(matches => matches[2] !== 'if' && matches[2] !== 'switch' && matches[2] !== 'for')
.map(matches => ({ name: matches[2] }))
return computedDeclarations
}
/**
* Build a typescript interface for a given components computed items
*
* @param {string} component - The component name: eg: w-tag
* @param {Array<{name: string>} computeds - All the computed information
* @returns {string} The typescript interface
*/
function buildComputedTypescriptDefinitions(component, computeds) {
const properComponentName = cleanComponentName(component)
let output = `export interface Wave${properComponentName}Computeds extends ComputedOptions {\n`
for (const computed of computeds) {
const computedBody = ` /**
* TODO: Add Description
* @see https://antoniandre.github.io/wave-ui/${component}
*/
${computed.name}: ComputedGetter<any>\n\n`
output += computedBody
}
output += `}\n`
return output
}
const methodsDefRegex = new RegExp('((?:^\\s+(\\w+)\\s?\\((.*?)\\)\\s*?{)+)', 'gm')
/**
* Extract methods from a component file and an apiFile
*
* @param {string} componentFile - The body of the component file
* @param {string} apiFile - The body of the API documentation file
* @returns {Array<{name: string, params: Array<{name: string, default: string | undefined}>>}
*/
function extractMethods(componentFile, apiFile) {
if (componentFile.includes('methods') === false) {
return []
}
const methodsSection = componentFile.split('methods: {')[1].split('\n }')[0]
const methodDeclarations = [ ...methodsSection.matchAll(methodsDefRegex) ]
.filter(matches => matches[2] !== 'if' && matches[2] !== 'for')
.map(matches => ({
name: matches[2],
params: (matches[3] || '')
.split(',')
?.map(param => param.trim())
?.filter(param => param.length > 0)
?.map(param => {
const pieces = param.split('=').map(p => p.trim())
return pieces.length > 1 ? { name: pieces[0], default: pieces[1] } : { name: pieces[0], default: undefined }
}) || []
}))
return methodDeclarations
}
/**
* Build a typescript interface for a given components methods
*
* @param {string} component - The component name: eg: w-tag
* @param {Array<{name: string, params: Array<{name: string, default: string | undefined}>>} methods - All the method information
* @returns {string} The typescript interface
*/
function buildMethodsTypescriptDefinitions(component, methods) {
const properComponentName = cleanComponentName(component)
let output = `export interface Wave${properComponentName}Methods extends MethodOptions {\n`
for (const method of methods) {
const methodDefParams = method.params
.map(param => `${param.name}${param.default !== undefined ? '?' : ''}: any`)
.join(', ')
const methodDef = `${method.name}(${methodDefParams}): void`
const jsdocParams = method.params
.map(param => `\n * @param {any} ${param.default !== undefined ? '[' : ''}${param.name}${param.default !== undefined ? `] - ${param.default}` : ''}`)
.join('')
const methodBody = ` /**
* TODO: Add Description${jsdocParams}
* @see https://antoniandre.github.io/wave-ui/${component}
*/
${methodDef}\n\n`
output += methodBody
}
output += `}\n`
return output
}
const slotDefRegex = new RegExp('((?:^\\s+slot(\\(((.|\\n)*?)\\)(\\n|\\s))?)+)', 'igm')
const extractAttributesRegex = new RegExp(':?(\\S+)="(.*?)"', 'igm')
/**
* Extract slots from a component file and an apiFile
*
* @param {string} componentFile - The body of the component file
* @param {string} apiFile - The body of the API documentation file
* @returns {Array<{name: string, description: string, params: Record<string, string>>}
*/
function extractSlots(componentFile, apiFile) {
if (componentFile.includes('slot') === false) {
return []
}
const templateSection = componentFile.split(/<template.*?>/i)[1].split('</template>')[0]
// Super ghetto and potentially dangerous... But it works
/** @type Record<string, { description?: string, params?: Array<Record<string, string>>}> */
let slotDescriptions = apiFile.split(/slots\s?[:=] {/) || []
if (Array.isArray(slotDescriptions) && slotDescriptions.length > 1 && slotDescriptions[1][0] !== '}') {
let pieces = slotDescriptions[1].split('\n}')
if (Array.isArray(pieces) && pieces.length > 1) {
slotDescriptions = eval(`({${pieces[0]}})`)
}
}
const slots = [ ...templateSection.matchAll(slotDefRegex) ]
.map(matches => {
const attributes = matches[3] !== undefined ? [ ...matches[3].matchAll(extractAttributesRegex) ] : []
const name = (attributes.find(m => m[1].includes('name')) || [ 0, 0, 'default' ])[2].replace(/\${i.*?}/i, 'x').replace(/`/g, '')
const params = attributes
.filter(m => m[1].includes('v-') === false)
.filter(m => m[1].includes('name') === false)
.map(m => m[1])
const description = Object.prototype.hasOwnProperty.call(slotDescriptions, name) ? slotDescriptions[name] : null
const slot = { name, ...description }
slot.description = slot.description ?? ''
slot.params = slot.params ?? {}
// Add any potentially missing slot params
for (const param of params) {
if (Object.prototype.hasOwnProperty.call(slot.params, param) === false) {
slot.params[param] = 'TODO: Describe me!'
}
}
return slot
})
// Remove the duplicate items...
return [ ...new Set(slots.map(s => s.name)) ].map(name => {
const matchingSlot = slots.find(s => s.name === name)
return {
name,
params: matchingSlot.params,
description: matchingSlot.description,
}
})
}
/**
* Build a typescript interface for a given components slots
*
* @param {string} component - The component name: eg: w-tag
* @param {Array<{name: string, description: string, params: Record<string, string>>} slots - All the slot information
* @returns {string} The typescript interface
*/
function buildSlotsTypescriptDefinitions(component, slots) {
const properComponentName = cleanComponentName(component)
let output = `export type Wave${properComponentName}Slots = SlotsType<{\n`
let dynamicSlotDefs = ''
for (const slot of slots) {
const paramNames = Object.keys(slot.params)
const slotParams = paramNames
.map(param => `${param}: any`)
.join(', ')
const methodDef = `(${paramNames.length > 0 ? `_: { ${slotParams} }` : ''}) => any`
const jsdocParams = paramNames
.map(paramName => `\n * @param {any} ${paramName}${slot.params[paramName].length > 0 ? ` ${cleanHtmlDescription(slot.params[paramName])}` : ''}`)
.join('')
const slotDescription = ` /**
* ${slot.description?.length > 0 ? cleanHtmlDescription(slot.description) : 'TODO: Add Description'}${jsdocParams}
* @see https://antoniandre.github.io/wave-ui/${component}
*/`
output += `${slotDescription}\n '${slot.name}': ${methodDef}\n\n`
if (slot.name.includes('.x')) {
const cleanName = slot.name.replace('.x', '')
dynamicSlotDefs += `} & {\n${slotDescription}\n [k in \`${cleanName}\${number}\`]: ${methodDef}\n`
}
}
output += `${dynamicSlotDefs}}>\n`
return output
}
/**
* Build a full typescript declaration file for a given component
*
* @async
* @param {string} component - Component name
* @param {boolean} splitDocs - Whether the documentation is shared between multiple components
*/
function buildFullComponentDefinition(component, splitDocs) {
const componentPath = components[component].file !== undefined
? `${basePath}${components[component].file}.vue`
: `${basePath}${component}.vue`
const componentFile = fs.readFileSync(componentPath, 'utf8')
let componentApiFile = components[component].docs
? fs.readFileSync(`${baseDocPath}${components[component].docs}/api.vue`, 'utf8')
: ''
const properComponentName = cleanComponentName(component)
if (splitDocs) {
let pieces = componentApiFile.split(`${component.split('-')[1]} = {`)
if (pieces && pieces.length > 1) {
pieces = pieces[1].split('\n}')
if (pieces && pieces.length > 0) {
componentApiFile = pieces[0].replace(/^\s\s/igm, '')
}
}
}
const props = extractProps(componentFile, componentApiFile)
const propsTypescriptDef = buildPropsTypescriptDefinitions(component, props)
const emits = extractEmits(componentFile, componentApiFile)
const emitsTypescriptDef = buildEmitsTypescriptDefinitions(component, emits)
const computeds = extractComputed(componentFile, componentApiFile)
const computedTypescriptDef = buildComputedTypescriptDefinitions(component, computeds)
const methods = extractMethods(componentFile, componentApiFile)
const methodsTypescriptDef = buildMethodsTypescriptDefinitions(component, methods)
const slots = extractSlots(componentFile, componentApiFile)
const slotsTypescriptDef = buildSlotsTypescriptDefinitions(component, slots)
return `/* eslint-disable no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/ban-types */
import {
ComputedGetter,
ComputedOptions,
DefineComponent,
EmitsOptions,
ExtractDefaultPropTypes,
MethodOptions,
SlotsType
} from 'vue'
import {
PublicProps,
ResolveProps
} from '../extra-vue-types'
// ----------------------------------------------------------------------------
// Props
// ----------------------------------------------------------------------------
${propsTypescriptDef}
// ----------------------------------------------------------------------------
// Emits
// ----------------------------------------------------------------------------
${emitsTypescriptDef}
// ----------------------------------------------------------------------------
// Computeds
// ----------------------------------------------------------------------------
${computedTypescriptDef}
// ----------------------------------------------------------------------------
// Methods
// ----------------------------------------------------------------------------
${methodsTypescriptDef}
// ----------------------------------------------------------------------------
// Slots
// ----------------------------------------------------------------------------
${slotsTypescriptDef}
// ----------------------------------------------------------------------------
// Component
// ----------------------------------------------------------------------------
export type W${properComponentName} = DefineComponent<
Wave${properComponentName}Props,
{},
{},
Wave${properComponentName}Computeds,
Wave${properComponentName}Methods,
{},
{},
Wave${properComponentName}Emits & EmitsOptions,
string,
PublicProps,
ResolveProps<Wave${properComponentName}Props & Wave${properComponentName}Emits, EmitsOptions>,
ExtractDefaultPropTypes<Wave${properComponentName}Props>,
Wave${properComponentName}Slots
>
`
}
function main() {
for (const component of Object.keys(components)) {
const properComponentName = cleanComponentName(component)
const typedefs = buildFullComponentDefinition(component, components[component].splitDocs)
const path = `./src/@types/components/W${properComponentName}.ts`
fs.stat(path, (err, stat) => {
if (err === null) {
fs.rmSync(path)
}
fs.writeFileSync(path, typedefs)
})
}
const path = `./src/@types/components/index.ts`
fs.stat(path, (err, stat) => {
if (err === null) {
fs.rmSync(path)
}
fs.writeFileSync(path, Object.keys(components)
.map(component => {
const properComponentName = cleanComponentName(component)
return `export { W${properComponentName} } from './W${properComponentName}'`
})
.join('\n') + '\n'
)
})
}
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment