@department-of-veterans-affairs/generator-vets-website
Version:
Generate a React app for vets-website
455 lines (383 loc) • 13.2 kB
JavaScript
import fs from 'node:fs';
import path from 'node:path';
import Generator from 'yeoman-generator';
import chalk from 'chalk';
import yosay from 'yosay';
import {
validateAllCliArguments,
isNonInteractiveMode,
validateRequiredCliArguments,
} from '../../lib/cli-validation.js';
import { initializeFileTracking } from '../../lib/fs-tracker.js';
import { getFieldDefinitions, generatePrompts } from '../../lib/prompts.js';
import {
generateOptions,
initializePropsFromOptions,
} from '../../lib/generator-config.js';
import {
isInvalidFolderName,
isInvalidEntryName,
isInvalidSlackGroup,
checkForDuplicates,
} from '../../utils/validation.js';
import {
uuidv4,
checkNodeCompatibility,
makeBool,
} from '../../utils/generator-helpers.js';
import {
isDryRunMode,
initializeDryRunMode,
normalizeDryRunOptions,
shouldSkipValidation,
handleDryRunPrompting,
showTrackedFiles,
} from '../../lib/dry-run-helpers.js';
import { store } from '../../lib/store.js';
// Import strategies
import AppStrategy from './strategies/app-strategy.js';
import FormStrategy from './strategies/form-strategy.js';
/**
* Unified generator that handles both app and form generation using strategies
*/
export default class VetsWebsiteGenerator extends Generator {
constructor(args, options) {
checkNodeCompatibility();
super(args, options);
this.allFields = getFieldDefinitions('all');
this.strategy = null;
initializeDryRunMode(this.options);
initializeFileTracking(this);
generateOptions(this, this.allFields);
}
/**
* Display validation errors and exit
* @private
*/
_handleValidationErrors(errors) {
console.log(chalk.red('\n❌ Validation errors:'));
errors.forEach((error) => {
console.log(chalk.red(` • ${error}`));
});
console.log(chalk.red('\nSUMMARY:'));
console.log(chalk.red('❌ Generator failed due to validation errors'));
process.exit(1);
}
initializing() {
const tempThis = {
props: {},
options: this.options,
};
initializePropsFromOptions(tempThis, this.allFields);
store.setOptions(this.options);
store.setProps(tempThis.props);
if (!store.getValue('productId')) {
store.setProp('productId', uuidv4());
}
// Handle backward compatibility: contentLoc -> contentRepoLocation
// Priority: contentRepoLocation (new) > contentLoc (legacy)
const contentRepoLocation =
store.getValue('contentRepoLocation') || store.getValue('contentLoc');
if (contentRepoLocation) {
store.setProp('contentRepoLocation', contentRepoLocation);
}
normalizeDryRunOptions(this.options);
if (shouldSkipValidation(this.options)) {
// If we have isForm in options, we can initialize strategy early
if (this.options.isForm !== undefined) {
this.strategy = this._createStrategy(makeBool(this.options.isForm));
}
return;
}
const cliValidationErrors = validateAllCliArguments(this.options);
if (cliValidationErrors.length > 0) {
this._handleValidationErrors(cliValidationErrors);
}
store.setProp(
'isForm',
this.options.isForm === undefined ? null : makeBool(this.options.isForm),
);
if (store.getValue('isForm') !== null) {
this.strategy = this._createStrategy(store.getValue('isForm'));
}
this._validateFields(store);
}
prompting() {
const dryRunResult = handleDryRunPrompting(this, this.allFields);
if (dryRunResult !== null) {
return dryRunResult;
}
// If non-interactive mode, skip all prompts and validate required fields
if (isNonInteractiveMode(this.options)) {
// Validate that all required fields are provided
const missingFieldErrors = validateRequiredCliArguments(this.options);
const formatValidationErrors = validateAllCliArguments(this.options);
const allErrors = [...missingFieldErrors, ...formatValidationErrors];
if (allErrors.length > 0) {
this._handleValidationErrors(allErrors);
}
// All required fields provided, continue without prompting
if (!store.getValue('productId')) {
store.setProp('productId', uuidv4());
}
this.strategy = this._createStrategy(store.getValue('isForm'));
// Process the CLI options to compute derived properties (like formIdConst)
if (this.strategy && this.strategy.processPromptResults) {
this.strategy.processPromptResults(this, store);
}
return Promise.resolve();
}
this.log(yosay(`Welcome to the ${chalk.red('vets-website app')} generator!`));
this.log(
`For a guide on using this Yeoman generator, including example answers for each prompt:\n${chalk.cyan(
'https://depo-platform-documentation.scrollhelp.site/developer-docs/va-gov-application-generator',
)}\n`,
);
const corePrompts = generatePrompts(this, this.allFields, [
'appName',
'folderName',
'entryName',
'rootUrl',
'isForm',
'contentRepoLocation',
'slackGroup',
]);
return this.prompt(corePrompts).then((coreProps) => {
const updatedProps = { ...store.getAllProps(), ...coreProps };
store.setProps(updatedProps);
this.strategy = this._createStrategy(store.getValue('isForm'));
const additionalPrompts = this.strategy.getAdditionalPrompts(this, store);
if (additionalPrompts.length > 0) {
return this.prompt(additionalPrompts).then((additionalProps) => {
const finalProps = { ...store.getAllProps(), ...additionalProps };
store.setProps(finalProps);
if (!store.getValue('productId')) {
store.setProp('productId', uuidv4());
}
// Let strategy process the results
this.strategy.processPromptResults(this, store);
});
}
if (!store.getValue('productId')) {
store.setProp('productId', uuidv4());
}
this.strategy.processPromptResults(this, store);
});
}
configuring() {
if (!isDryRunMode(this.options)) {
this._updateAllowlist();
}
if (this.strategy) {
this.strategy.configure(this, store);
}
}
writingNewFiles() {
this._generateSharedFiles(store);
if (this.strategy) {
this.strategy.generateFiles(this, store);
}
}
updateRegistry() {
const contentBuildPath = path.resolve(this.destinationRoot(), '../content-build');
const registryFile = path.join(contentBuildPath, 'src/applications/registry.json');
if (isDryRunMode(this.options)) {
store.trackFile(registryFile);
if (this.strategy) {
this.strategy.updateExternalFiles(this, store);
}
return;
}
if (!isDryRunMode(this.options)) {
this.log(chalk.blue(`Updating registry file at: ${registryFile}`));
}
try {
let registry;
try {
const registryContent = fs.readFileSync(registryFile, 'utf8');
registry = JSON.parse(registryContent);
} catch {
if (!isDryRunMode(this.options)) {
this.log(
chalk.yellow(
`Warning: Could not read ${registryFile}. Creating new registry.`,
),
);
}
registry = [];
}
if (!Array.isArray(registry)) {
if (!isDryRunMode(this.options)) {
this.log(
chalk.yellow(
`Warning: Registry file exists but is not an array. Initializing as empty array.`,
),
);
}
registry = [];
}
const newApp = {
appName: store.getValue('appName'),
entryName: store.getValue('entryName'),
rootUrl: store.getValue('rootUrl'),
productId: store.getValue('productId'),
};
const duplicates = checkForDuplicates(registry, newApp);
if (duplicates.length > 0) {
const errorMessage = `Cannot create application due to duplicate entries in content-build registry:
${duplicates.join('\n')}`;
this.log(chalk.red(errorMessage));
throw new Error(errorMessage);
}
const templateType = store.getValue('templateType');
const registryEntry = {
appName: store.getValue('appName'),
entryName: store.getValue('entryName'),
rootUrl: store.getValue('rootUrl'),
productId: store.getValue('productId'),
template:
templateType === 'FORM_ENGINE'
? {
vagovprod: false,
title: store.getValue('appName'),
layout: 'page-react.html',
}
: {
vagovprod: false,
layout: 'page-react.html',
...(store.getValue('usesMinimalHeader')
? {
includeBreadcrumbs: false,
minimalExcludePaths: ['/introduction', '/confirmation'],
minimalFooter: true,
minimalHeader: {
title: store.getValue('appName'),
subtitle: `${store.getValue(
'benefitDescription',
)} (VA Form ${store.getValue('formNumber')})`,
},
}
: {}),
},
};
registry.push(registryEntry);
this.fs.writeJSON(registryFile, registry);
// Let strategy update external files
if (this.strategy) {
this.strategy.updateExternalFiles(this, store);
}
} catch (error) {
this.log(chalk.red(`Could not write to ${registryFile}. ${error.message}`));
throw error;
}
}
end() {
if (isDryRunMode(this.options)) {
showTrackedFiles(this, this.allFields);
} else if (this.strategy) {
process.nextTick(() => {
this.log(this.strategy.getCompletionMessage(store));
});
}
}
/**
* Create the appropriate strategy based on isForm flag
* @private
*/
_createStrategy(isForm) {
return isForm ? new FormStrategy() : new AppStrategy();
}
/**
* Generate files that are shared between all strategies
* @private
*/
_generateSharedFiles(store) {
// For Form Engine, use special path structure
const templateType = store.getValue('templateType');
const appPath =
templateType === 'FORM_ENGINE'
? `src/applications/simple-forms-form-engine/${store.getValue('folderName')}`
: `src/applications/${store.getValue('folderName')}`;
const allProps = store.getAllProps();
this.props = { ...this.props, ...allProps };
this.fs.copyTpl(
this.templatePath('shared/manifest.json.ejs'),
this.destinationPath(`${appPath}/manifest.json`),
allProps,
);
this.fs.copyTpl(
this.templatePath('shared/app-entry.jsx.ejs'),
this.destinationPath(`${appPath}/app-entry.jsx`),
allProps,
);
// Use different README template for Form Engine
const readmeTemplate =
templateType === 'FORM_ENGINE'
? 'shared/README-form-engine.md.ejs'
: 'shared/README.md.ejs';
this.fs.copyTpl(
this.templatePath(readmeTemplate),
this.destinationPath(`${appPath}/README.md`),
allProps,
);
}
/**
* Update allowlist configuration
* @private
*/
_updateAllowlist() {
const configPath = path.join('config', 'changed-apps-build.json');
try {
const config = this.fs.readJSON(configPath);
const isNewApp = !fs.existsSync(
path.join('src', 'applications', store.getValue('folderName')),
);
if (store.getValue('slackGroup') !== 'none' && isNewApp) {
const appPaths = store.getValue('folderName').split(path.sep);
const isSingleApp = appPaths.length === 1;
if (isSingleApp) {
config.apps.push({
rootFolder: store.getValue('folderName'),
slackGroup: store.getValue('slackGroup'),
});
} else {
const rootFolder = appPaths[0];
const existingApp = config.apps.find((app) => app.rootFolder === rootFolder);
if (!existingApp) {
config.apps.push({
rootFolder: appPaths[0],
slackGroup: store.getValue('slackGroup'),
});
}
}
this.fs.writeJSON(configPath, config);
}
} catch (error) {
this.log(chalk.red(`Could not write to ${configPath}. ${error}`));
}
}
/**
* Validate required fields
* @private
*/
_validateFields(store) {
if (store.getValue('folderName')) {
const badFolder = isInvalidFolderName(store.getValue('folderName'));
if (badFolder !== true) {
this.emit('error', new Error(badFolder));
}
}
if (store.getValue('entryName')) {
const badEntryName = isInvalidEntryName(store.getValue('entryName'));
if (badEntryName !== true) {
this.emit('error', new Error(badEntryName));
}
}
if (store.getValue('slackGroup')) {
const badSlackGroup = isInvalidSlackGroup(store.getValue('slackGroup'));
if (badSlackGroup !== true) {
this.emit('error', new Error(badSlackGroup));
}
}
}
}