Skip to content

Instantly share code, notes, and snippets.

@tbusser
Last active March 14, 2024 21:40
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save tbusser/d4748329be4d3e26dbc83beefeb3e3d6 to your computer and use it in GitHub Desktop.
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`.
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>
`;
};
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);
/**
* 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