Last active
March 14, 2024 21:40
-
-
Save tbusser/d4748329be4d3e26dbc83beefeb3e3d6 to your computer and use it in GitHub Desktop.
Script files for scaffolding a new Vue component using the `script setup` syntax, and a Storybook story file. The scripts `component.js` and `story.js` are expected in a subfolder named `scaffold-templates`.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
module.exports = function scaffoldComponent(name) { | |
return `<script setup lang="ts"> | |
export interface Props { | |
//Component props go here. | |
} | |
/* -------------------------------------------------------------------------- */ | |
const emit = defineEmits<{ | |
// component events go here. | |
}>(); | |
const props = defineProps<Props>(); | |
</script> | |
<template> | |
<div>This is ${name}</div> | |
</template> | |
<style lang="scss" scoped> | |
// Component styling goes here. | |
</style> | |
`; | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const fs = require('fs'); | |
const path = require('path'); | |
const scaffoldComponent = require('./scaffold-templates/component'); | |
const scaffoldStory = require('./scaffold-templates/story'); | |
/* ========================================================================== */ | |
const componentRootPath = 'src/components/'; | |
const importRootPath = '@/components/'; | |
const fileExtension = { | |
component: '.vue', | |
story: '.stories.ts' | |
}; | |
const writeOptions = { | |
encoding: 'utf8' | |
}; | |
const validationError = { | |
NameTaken: 'name-taken', | |
SingleWordName: 'single-word-name' | |
}; | |
/* ========================================================================== */ | |
function createComponentFolder(componentName) { | |
const folder = generateFolderName(componentName); | |
try { | |
fs.mkdirSync(folder); | |
} catch (error) { | |
console.error(`Unable to create folder "${folder}"`, error); | |
process.exit(1); | |
} | |
} | |
function createComponentScaffold(componentName) { | |
const componentFileName = generateFileName( | |
componentName, | |
fileExtension.component | |
); | |
try { | |
fs.writeFileSync( | |
path.resolve(componentFileName), | |
scaffoldComponent(componentName), | |
writeOptions | |
); | |
} catch (error) { | |
console.error( | |
`Unable to write scaffolded component to "${componentFileName}"`, | |
error | |
); | |
process.exit(1); | |
} | |
} | |
function createStoryScaffold(componentName) { | |
const importPath = generateFileName( | |
componentName, | |
fileExtension.component, | |
importRootPath | |
); | |
const storyFileName = generateFileName(componentName, fileExtension.story); | |
try { | |
fs.writeFileSync( | |
path.resolve(storyFileName), | |
scaffoldStory(componentName, importPath), | |
writeOptions | |
); | |
} catch (error) { | |
console.error( | |
`Unable to write scaffolded story to "${storyFileName}"`, | |
error | |
); | |
process.exit(1); | |
} | |
} | |
function generateFileName(componentName, extension, root = componentRootPath) { | |
const folder = generateFolderName(componentName, root); | |
return `${folder}/${componentName}${extension}`; | |
} | |
function generateFolderName(componentName, root = componentRootPath) { | |
return `${root}${componentName}`; | |
} | |
/** | |
* Checks if the provided does NOT exist on disk. | |
* @param {string} path The path to check for availability. | |
* @returns {boolean} True when the path doesn't yet exist; otherwise false. | |
*/ | |
function isPathAvailable(path) { | |
try { | |
const exists = fs.existsSync(path); | |
return !exists; | |
} catch (err) { | |
console.log('Error checking path "${path}": ', err); | |
process.exit(1); | |
} | |
} | |
function logResultToConsole(componentName) { | |
// Log the result to the console, include the file names so on capable | |
// platforms the file name is a link to the actual file. | |
console.log(''); | |
console.log(`Component "${componentName}" has been scaffolded.`); | |
console.log('Next steps:'); | |
console.log( | |
'* Implement the Vue component in \x1b[33m%s\x1b[0m', | |
generateFileName(componentName, fileExtension.component) | |
); | |
console.log( | |
'* Write a story for your component in \x1b[33m%s\x1b[0m', | |
generateFileName(componentName, fileExtension.story) | |
); | |
console.log(''); | |
console.log(''); | |
} | |
function logValidationError(componentName, errorCode) { | |
switch (errorCode) { | |
case validationError.NameTaken: | |
console.warn( | |
`Unable to scaffold component "${componentName}", a component with the name already exists.` | |
); | |
break; | |
case validationError.SingleWordName: | |
console.warn( | |
`The provided name "${componentName}" is a single word, Vue components should have a multi-word named.` | |
); | |
break; | |
} | |
} | |
function normalizeComponentName(name) { | |
return ( | |
name | |
// Split the input on " ", "_", "-" and all capital letters. Use a | |
// positive lookahead for the capital letters or else they will be | |
// removed in the split result. | |
.split(/\s|-|_|(?=[A-Z])/) | |
// Convert every first letter to uppercase and the rest | |
// to lowercase. | |
.map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) | |
.join('') | |
); | |
} | |
function validateName(componentName) { | |
const uppercaseCount = componentName.match(/[A-Z]/g).length; | |
if (uppercaseCount < 2) { | |
return { valid: false, error: validationError.SingleWordName }; | |
} | |
const componentFolderName = path.resolve(generateFolderName(componentName)); | |
if (!isPathAvailable(componentFolderName)) { | |
return { valid: false, error: validationError.NameTaken }; | |
} | |
return { valid: true }; | |
} | |
/* ========================================================================== */ | |
// Normalize the name by removing all " ", "-", and "_". In addition it will | |
// uppercase the first letter of each name part. | |
const componentName = normalizeComponentName(process.argv[2]); | |
// Validate if the names passes our requirements for a Vue component name. | |
const validationResult = validateName(componentName); | |
// When the validation failed, log a message for the failed requirement | |
// and exit. | |
if (!validationResult.valid) { | |
logValidationError(componentName, validationResult.error); | |
process.exit(1); | |
} | |
// The folder for the component has to be created before attempting to write a | |
// file to folder. Failure to do so will result in an error. | |
createComponentFolder(componentName); | |
createComponentScaffold(componentName); | |
createStoryScaffold(componentName); | |
logResultToConsole(componentName); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Inserts a hyphen between the words in the Pascal cased input and makes | |
* everything lowercase. | |
* @param {string} name The Pascal cased name to conver to kebab case. | |
* @returns {string} The input string with a hyphen inserted between words and | |
* everything in lower case. | |
*/ | |
function kebabCase(name) { | |
return pascalCaseToParts(name) | |
.map(part => part.toLowerCase()) | |
.join('-'); | |
} | |
/** | |
* Splits the Pascal cased input in individual words. | |
* @param {string} name The Pascal cased name to split into words. | |
* @returns {string[]} An array with the individual words in the input string. | |
*/ | |
function pascalCaseToParts(name) { | |
// Use a positive lookahead to keep the capital in the split result. | |
return name.split(/(?=[A-Z])/); | |
} | |
/** | |
* Inserts a space between the words in the Pascal cased input string. | |
* @param {string} name A Pascal cased name to convert to start case. | |
* @returns {string} The input string with a space inserted between words. | |
*/ | |
function startCase(name) { | |
return pascalCaseToParts(name).join(' '); | |
} | |
/* ========================================================================== */ | |
/** | |
* @param {string} name The name of the component in Pascal casing. | |
* @param {string} importPath The path to import the component from. | |
*/ | |
module.exports = function scaffoldStory(name, importPath) { | |
const elementName = kebabCase(name); | |
const displayName = startCase(name); | |
return `import { Meta, Story } from '@storybook/vue3'; | |
import ${name}, { Props } from '${importPath}'; | |
/* ========================================================================== */ | |
export default { | |
argTypes: { | |
// Component arg types go here | |
}, | |
component: ${name}, | |
title: '${displayName}' | |
} as Meta; | |
const Template: Story<Props> = (args: Props) => ({ | |
components: { ${name} }, | |
/* ---------------------------------------------------------------------- */ | |
setup() { | |
return { args }; | |
}, | |
/* ---------------------------------------------------------------------- */ | |
template: | |
'<${elementName} v-bind="args">This is ${name}</${elementName}>' | |
}); | |
/* ========================================================================== */ | |
export const Default = Template.bind({}); | |
Default.storyName = '${displayName}'; | |
`; | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment