Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
246 changes: 246 additions & 0 deletions lib/create-component.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
/* eslint-disable no-console */
import mustache from '@forumone/tiny-mustache';
import { confirm, input, select } from '@inquirer/prompts';
import { camelCase, capitalCase, kebabCase, pascalCase } from 'change-case';
import {
access,
lstat,
mkdir,
readdir,
readFile,
writeFile,
} from 'node:fs/promises';
import path from 'node:path';

/**
* Creates the cascade layer name from the directory name.
* @param {string} directoryName - The directory name
* @return {string}
*/
function cascadeLayer(directoryName) {
const parts = directoryName.split('-');
return parts[parts.length - 1];
}

/**
* Checks whether the source directory is an accessible directory.
* @param {node:fs.PathLike} source - Source path
* @return {Promise<boolean>} - True if source is an accessible directory
*/
async function isDirectory(source) {
const stats = await lstat(source);
return stats.isDirectory();
}

/**
* Get available component directories.
* @param {node:fs.PathLike} source - Source path
* @return {Promise<string[]>} - Array of component directory paths
*/
async function getDirectories(source) {
/** @type {string[]} */
const directoryFiles = await readdir(source);
/** @type {string[]} */
const directoryPaths = directoryFiles
.filter(
dirName => !['00-config', '05-pages', '06-utility'].includes(dirName),
)
.map(name => path.join(source, name));
/** @type {Awaited<boolean>[]} */
const isDirectoryResults = await Promise.all(directoryPaths.map(isDirectory));
return directoryPaths.filter((value, index) => isDirectoryResults[index]);
}

/**
* Get the machine name from user input.
* @return {Promise<string>} - Machine name of new component
*/
async function getMachineName() {
const question = {
message: 'What is the name of your component?',
transformer: pascalCase,
required: true,
};
const componentName = await input(question);
return pascalCase(componentName).trim();
}

/**
* Get the human-readable name from user input.
* @param {string} componentName - Machine name of new component
* @returns {Promise<string>} - Human-readable name of new component
*/
async function getComponentTitle(componentName) {
const defaultComponentTitle = capitalCase(componentName);
const question = {
message: 'What is the human-readable title of your component?',
default: defaultComponentTitle,
transformer: capitalCase,
required: true,
};
const componentTitle = await input(question);
return componentTitle.trim();
}

/**
* Select the component folder from available directories.
* @returns {Promise<string>} - Name of selected folder
*/
async function getComponentFolder() {
const patternSrc = path.join(process.cwd(), 'source');
const patternDir = await getDirectories(patternSrc);
const question = {
message: 'Choose the component location:',
choices: patternDir.map(item => path.basename(item)),
};
return select(question);
}

/**
* Gets the name of the optional subdirectory.
* @returns {Promise<string>} - Subdirectory or empty string if no directory entered
*/
async function getComponentFolderSub() {
const question = {
message: 'Include subfolder or leave blank',
};
const componentFolderSub = await input(question);
return componentFolderSub.trim();
}

/**
* Gets whether to generate a Storybook story.
* @returns {Promise<boolean>}
*/
async function getUseStorybook() {
const question = {
message: 'Create a Storybook story?',
default: true,
};
return confirm(question);
}

/**
* Confirms whether to create component.
* @param {object} mustacheData - Data to fill in mustache templates.
* @returns {Promise<boolean>}
*/
async function confirmComponent(mustacheData) {
const output = mustache(
`---
Component Name: {{componentName}}
Component Title: {{componentTitle}}
Component Location: {{componentLocation}}
Include Story?: {{useStorybook}}
---`,
mustacheData,
);
console.log(output);
const question = {
message: 'Is this what you want?',
};
return confirm(question);
}

/**
* Creates a file from a template and given mustache data.
*
* @param {string} fileName - The name of the final file, with mustache placeholders if required.
* @param {string} templatePath - The path to the template file to be used as a base for the new file.
* @param {Object} mustacheData - An object containing key-value pairs for populating the Mustache templates.
* @return {Promise<void>}
*/
async function createFile(fileName, templatePath, mustacheData) {
const filePath = mustache(fileName, mustacheData);
const templateContents = await readFile(path.resolve(templatePath), {
encoding: 'utf-8',
});
const newFileContents = mustache(templateContents, mustacheData);
const directoryPath = path.dirname(filePath);
try {
await access(directoryPath);
} catch {
await mkdir(directoryPath, { recursive: true });
}
await writeFile(filePath, newFileContents, { encoding: 'utf-8', flag: 'w+' });
}


/**
* Generates a new component based on user input.
*
* @return {Promise<void>}
*/
async function generator() {
const componentName = await getMachineName();
const componentTitle = await getComponentTitle(componentName);
const componentFolder = await getComponentFolder();
const componentFolderSub = await getComponentFolderSub();
const componentLocation = path.join(
componentFolder,
pascalCase(componentFolderSub),
);
const useStorybook = await getUseStorybook();

const mustacheData = {
// Partials
propsName: '{{componentName}}Props',
componentAlias: '{{componentName}}Component',
argsName: '{{#camelCase}}{{componentName}}{{/camelCase}}Args',
// Variables
componentName,
componentTitle,
componentLocation,
componentFolder,
useStorybook,
// Lambdas
machineName: (text, render) => {
return pascalCase(render(text));
},
humanName: (text, render) => {
return camelCase(render(text));
},
cascadeLayer: (text, render) => {
return cascadeLayer(render(text));
},
kebabCase: (text, render) => {
return kebabCase(render(text));
},
camelCase: (text, render) => {
return camelCase(render(text));
},
titleCase: (text, render) => {
return capitalCase(render(text));
},
};
const confirmation = await confirmComponent(mustacheData);
if (confirmation) {
await createFile(
'./source/{{componentLocation}}/{{componentName}}/{{componentName}}.tsx',
'./lib/templates/Component.hbs',
mustacheData,
);
await createFile(
'./source/{{ componentLocation }}/{{ componentName }}/{{#kebabCase}}{{ componentName }}{{/kebabCase}}.module.css',
'./lib/templates/Stylesheet.hbs',
mustacheData,
);
if (useStorybook) {
await createFile(
'./source/{{ componentLocation }}/{{ componentName }}/{{#camelCase}}{{ componentName }}{{/camelCase}}Args.ts',
'./lib/templates/Data.hbs',
mustacheData,
);
await createFile(
'./source/{{ componentLocation }}/{{ componentName }}/{{ componentName }}.stories.tsx',
'./lib/templates/Story.hbs',
mustacheData,
);
}
console.log('Component created.');
} else {
console.error('Component canceled.');
}
}

generator();
3 changes: 0 additions & 3 deletions lib/plop-templates/Stylesheet.hbs

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import clsx from 'clsx';
import { GessoComponent } from 'gesso';
import { JSX } from 'react';
import styles from './{{ kebabCase componentName }}.module.css';
import styles from './{{#kebabCase}}{{componentName}}{{/kebabCase}}.module.css';

interface {{> propsName }} extends GessoComponent {}

Expand Down
File renamed without changes.
2 changes: 1 addition & 1 deletion lib/plop-templates/Story.hbs → lib/templates/Story.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {{> componentAlias }} from './{{ componentName }}';
import {{> argsName }} from './{{> argsName }}';

const meta: Meta<typeof {{> componentAlias }}> = {
title: '{{ titleCase (cascadeLayer componentFolder) }}/{{ componentTitle }}',
title: '{{#titleCase}}{{#cascadeLayer}}{{componentFolder}}{{/cascadeLayer}}{{/titleCase}}/{{ componentTitle }}',
component: {{> componentAlias }},
tags: ['autodocs'],
};
Expand Down
3 changes: 3 additions & 0 deletions lib/templates/Stylesheet.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@layer {{#cascadeLayer}}{{ componentFolder }}{{/cascadeLayer}} {
.wrapper {}
}
Loading