diff --git a/src/handlers/componentInstall.ts b/src/handlers/componentInstall.ts index 83b8edd..1a942e6 100644 --- a/src/handlers/componentInstall.ts +++ b/src/handlers/componentInstall.ts @@ -19,7 +19,7 @@ import catchLater from '../util/catchLater'; */ export default async function componentInstall( name: string, - { force, all }: InstallComponentHandlerOptions + { force, all, subtheme }: InstallComponentHandlerOptions ): Promise { const emulsifyConfig = await getEmulsifyConfig(); if (!emulsifyConfig) { @@ -89,10 +89,10 @@ export default async function componentInstall( ); } - if (!name && !all) { + if (!name && !all && !subtheme) { return log( 'error', - 'Please specify a component to install, or pass --all to install all available components.' + 'Please specify a component to install, or subtheme name through option --subtheme, or pass --all to install all available components.' ); } @@ -114,11 +114,34 @@ export default async function componentInstall( ]) ); } + // Pull subtheme marked modules. + else if (subtheme) { + const parentComponents = variantConf.components + .filter((component) => component.subtheme?.includes(subtheme)) + .map((component) => component.name); + const componentsWithDependencies = buildComponentDependencyList( + variantConf.components, + parentComponents + ); + componentsWithDependencies.forEach((componentName) => { + components.push([ + componentName, + catchLater( + installComponentFromCache( + systemConf, + variantConf, + componentName, + force + ) + ), + ]); + }); + } // If there is only one component to install, add one single promise for the single component. else { const componentsWithDependencies = buildComponentDependencyList( variantConf.components, - name + [name] ); componentsWithDependencies.forEach((componentName) => { components.push([ diff --git a/src/handlers/systemImport.ts b/src/handlers/systemImport.ts new file mode 100644 index 0000000..a71aec2 --- /dev/null +++ b/src/handlers/systemImport.ts @@ -0,0 +1,185 @@ +import type { InstallSystemHandlerOptions } from '@emulsify-cli/handlers'; +import type { GitCloneOptions } from '@emulsify-cli/git'; +import type { EmulsifySystem } from '@emulsify-cli/config'; + +import R from 'ramda'; +import Ajv from 'ajv'; +import addFormats from 'ajv-formats'; +import { + EXIT_ERROR, + EXIT_SUCCESS, + EMULSIFY_SYSTEM_CONFIG_FILE, +} from '../lib/constants'; +import log from '../lib/log'; +import getAvailableSystems from '../util/system/getAvailableSystems'; +import getGitRepoNameFromUrl from '../util/getGitRepoNameFromUrl'; +import cloneIntoCache from '../util/cache/cloneIntoCache'; +import installComponentFromCache from '../util/project/installComponentFromCache'; +import getJsonFromCachedFile from '../util/cache/getJsonFromCachedFile'; +import getEmulsifyConfig from '../util/project/getEmulsifyConfig'; +import systemSchema from '../schemas/system.json'; +import variantSchema from '../schemas/variant.json'; +import buildComponentDependencyList from '../util/project/buildComponentDependencyList'; + +/** + * Helper function that uses InstallSystemHandlerOptions input to determine what + * system should be installed, if any. + * + * @param options InstallSystemHandlerOptions object. + * + * @returns GitCloneOptions or void, if no valid system could be found using the input. + */ +export async function getSystemRepoInfo( + name: string | void, + { repository, checkout }: InstallSystemHandlerOptions +): Promise<(GitCloneOptions & { name: string }) | void> { + // If a repository and checkout were specified, use that to return system information. + if (repository && checkout) { + const repoName = getGitRepoNameFromUrl(repository); + if (repoName) { + return { + name: repoName, + repository, + checkout, + }; + } + } + + // If a name was provided, attempt to find an out-of-the-box system with + // the name, and use it to return system information. + if (name) { + const system = (await getAvailableSystems()).find(R.propEq('name', name)); + if (system) { + return { + name, + repository: system.repository, + checkout: system.checkout, + }; + } + } +} + +/** + * Handler for the `system import` command. + */ +export default async function systemImport(): Promise { + // @TODO: extract some of this into a common util. + // Attempt to load emulsify config. If none is found, this is not an Emulsify project. + const projectConfig = await getEmulsifyConfig(); + if (!projectConfig) { + return log( + 'error', + 'No Emulsify project detected. You must run this command within an existing Emulsify project. For more information about creating Emulsify projects, run "emulsify init --help"', + EXIT_ERROR + ); + } + + // This is totaly wrong. You can't query for predefined and preset only system repo. + const repo = await getSystemRepoInfo( + '', + projectConfig.system as InstallSystemHandlerOptions + ); + if (!repo) { + return log( + 'error', + 'Unable to download specified system. You must either specify a valid name of an out-of-the-box system using the --name flag, or specify a valid repository and branch/tag/commit using the --repository and --checkout flags.', + EXIT_ERROR + ); + } + + // Clone should work as middleware for get repo. + await cloneIntoCache('systems', [repo.name])({ + repository: repo.repository, + checkout: repo.checkout, + }); + + // Load the system configuration file. + const systemConf: EmulsifySystem | void = await getJsonFromCachedFile( + 'systems', + [repo.name], + EMULSIFY_SYSTEM_CONFIG_FILE + ); + + // If there is no configuration file within the system, error. + if (!systemConf) { + return log( + 'error', + `The system you attempted to install (${repo.name}) is invalid, as it does not contain a valid configuration file.`, + EXIT_ERROR + ); + } + + // Validate the system configuration file. + try { + const ajv = new Ajv(); + // This is unfortunate... + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore The ajv-formats typing is bad :( + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + addFormats(ajv, ['uri']); + ajv.addSchema(variantSchema, 'variant.json'); + const validate = ajv.compile(systemSchema); + + if (!validate(systemConf)) { + throw validate.errors; + } + } catch (e) { + // We're logging to the console here instead of our normal logging mechanism + // in order to have more readable output from the AJV validation. + console.error('System configuration errors:', e); + return log( + 'error', + `The system install failed due to the validation errors reported above. Please fix the the errors in the "${systemConf.name}" configuration and try again.`, + EXIT_ERROR + ); + } + + // Extract the variant name, and error if no variant is determinable. + const variantName: string | void = projectConfig.project.platform; + if (!variantName) { + return log( + 'error', + 'Unable to determine a variant for the specified system. Please either pass in a valid variant using the --variant flag.', + EXIT_ERROR + ); + } + + // @TODO: clone variants into their own cache bucket if a reference is provided. + const variantConf = systemConf.variants?.find( + ({ platform }) => platform === variantName + ); + if (!variantConf) { + return log( + 'error', + `Unable to find a variant (${variantName}) within the system (${systemConf.name}). Please check your Emulsify project config and make sure the project.platform value is correct, or select a system with a variant that is compatible with the platform you are using.`, + EXIT_ERROR + ); + } + + try { + // Import all components in the list. + const componentsList = projectConfig.variant?.components || []; + const componentsWithDependencies = buildComponentDependencyList( + variantConf.components, + componentsList.map((component) => component.name) + ); + + for (const component of componentsWithDependencies) { + await installComponentFromCache(systemConf, variantConf, component, true); + } + } catch (e) { + return log( + 'error', + `Unable to install system assets and/or required components: ${R.toString( + e + )}`, + EXIT_ERROR + ); + } + + return log( + 'success', + `Successfully updated the ${systemConf.name}.`, + EXIT_SUCCESS + ); +} diff --git a/src/index.ts b/src/index.ts index 89a3c21..4daf236 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import withProgressBar from './handlers/hofs/withProgressBar'; import init from './handlers/init'; import systemList from './handlers/systemList'; import systemInstall from './handlers/systemInstall'; +import systemImport from './handlers/systemImport'; import componentList from './handlers/componentList'; import componentInstall from './handlers/componentInstall'; @@ -66,6 +67,10 @@ system } ) .action(systemInstall); +system + .command('import') + .description('Import components listed in project configuration file') + .action(systemImport); // Component sub-commands. const component = program @@ -90,6 +95,10 @@ component '-a --all', 'Use this to install all available components, rather than specifying a single component to install' ) + .option( + '-s --subtheme ', + 'This option use name as subtheme to pull group of components from full list of the components' + ) .alias('i') .description( "Install a component from within the current project's system and variant" diff --git a/src/schemas/emulsifyProjectConfig.json b/src/schemas/emulsifyProjectConfig.json index da77ccd..32f083c 100644 --- a/src/schemas/emulsifyProjectConfig.json +++ b/src/schemas/emulsifyProjectConfig.json @@ -62,6 +62,9 @@ "structureImplementations": { "$ref": "variant.json#/definitions/structureImplementations" }, + "components": { + "$ref": "variant.json#/definitions/components" + }, "repository": { "type": "string", "description": "Git repository containing the system this project is utilizing" diff --git a/src/schemas/variant.json b/src/schemas/variant.json index b03a8c2..e0c2b97 100644 --- a/src/schemas/variant.json +++ b/src/schemas/variant.json @@ -56,6 +56,13 @@ "items": { "type": "string" } + }, + "subtheme": { + "type": "array", + "description": "List of all subtheme to which this component belonning to and should be installed as part of subtheme", + "items": { + "type": "string" + } } }, "additionalProperties": false, diff --git a/src/types/_emulsifyProjectConfig.d.ts b/src/types/_emulsifyProjectConfig.d.ts index 6019959..cee0bd9 100644 --- a/src/types/_emulsifyProjectConfig.d.ts +++ b/src/types/_emulsifyProjectConfig.d.ts @@ -66,6 +66,35 @@ export interface EmulsifyProjectConfiguration { */ directory: string; }[]; + /** + * Array containing objects that describe each component available within the variant + */ + components?: { + /** + * Name of the component. MUST correspond with the folder name containing the component + */ + name: string; + /** + * Name of the structure to which the component belongs. This, along with the name, will determine which folder the component will live in + */ + structure: string; + /** + * Text describing the intended purpose of the component + */ + description?: string; + /** + * Boolean indicating whether or not the component is required + */ + required?: boolean; + /** + * Array containing list of all components from which depends current conponent + */ + dependency?: string[]; + /** + * List of all subtheme to which this component belonning to and should be installed as part of subtheme + */ + subtheme?: string[]; + }[]; /** * Git repository containing the system this project is utilizing */ diff --git a/src/types/_system.d.ts b/src/types/_system.d.ts index c552076..8e39cfd 100644 --- a/src/types/_system.d.ts +++ b/src/types/_system.d.ts @@ -76,6 +76,10 @@ export interface EmulsifySystem { * Array containing list of all components from which depends current conponent */ dependency?: string[]; + /** + * List of all subtheme to which this component belonning to and should be installed as part of subtheme + */ + subtheme?: string[]; }[]; /** * Array containing objects that define general directories. These directories should contain files and assets that do not belong in a structure folder (such as font files) diff --git a/src/types/_variant.d.ts b/src/types/_variant.d.ts index 3103921..b73b158 100644 --- a/src/types/_variant.d.ts +++ b/src/types/_variant.d.ts @@ -46,6 +46,10 @@ export type Components = { * Array containing list of all components from which depends current conponent */ dependency?: string[]; + /** + * List of all subtheme to which this component belonning to and should be installed as part of subtheme + */ + subtheme?: string[]; }[]; /** * Array containing objects that define general directories. These directories should contain files and assets that do not belong in a structure folder (such as font files) diff --git a/src/types/handlers.d.ts b/src/types/handlers.d.ts index d83a6c0..09f9fbe 100644 --- a/src/types/handlers.d.ts +++ b/src/types/handlers.d.ts @@ -19,5 +19,6 @@ declare module '@emulsify-cli/handlers' { export type InstallComponentHandlerOptions = { force?: boolean; all?: boolean; + subtheme: string; }; } diff --git a/src/util/project/buildComponentDependencyList.test.ts b/src/util/project/buildComponentDependencyList.test.ts index 37d963a..b5ab8bb 100644 --- a/src/util/project/buildComponentDependencyList.test.ts +++ b/src/util/project/buildComponentDependencyList.test.ts @@ -36,17 +36,17 @@ describe('buildComponentDependencyList', () => { ] as Components; it('Build list of components without dependency', () => { - expect(buildComponentDependencyList(components, 'buttons')).toEqual([ + expect(buildComponentDependencyList(components, ['buttons'])).toEqual([ 'buttons', ]); }); it('Build all components dependency for not existing component', () => { - expect(buildComponentDependencyList(components, 'test')).toEqual([]); + expect(buildComponentDependencyList(components, ['test'])).toEqual([]); }); it('Build all components dependency tree returning flat list without duplicates', () => { - expect(buildComponentDependencyList(components, 'card')).toEqual([ + expect(buildComponentDependencyList(components, ['card'])).toEqual([ 'card', 'images', 'text', @@ -56,11 +56,17 @@ describe('buildComponentDependencyList', () => { }); it('Build all components dependency tree with hierarchical dependency', () => { - expect(buildComponentDependencyList(components, 'menus')).toEqual([ + expect(buildComponentDependencyList(components, ['menus'])).toEqual([ 'menus', 'images', 'text', 'links', ]); }); + + it('Build all components dependency tree for 2 components', () => { + expect(buildComponentDependencyList(components, ['menus', 'card'])).toEqual( + ['menus', 'card', 'images', 'text', 'links', 'buttons'] + ); + }); }); diff --git a/src/util/project/buildComponentDependencyList.ts b/src/util/project/buildComponentDependencyList.ts index 0e775c4..dddf669 100644 --- a/src/util/project/buildComponentDependencyList.ts +++ b/src/util/project/buildComponentDependencyList.ts @@ -2,26 +2,28 @@ import type { Components } from '@emulsify-cli/config'; export default function buildComponentDependencyList( components: Components, - name: string + componentsList: string[] ) { - const rootComponent = components.filter( - (component) => component.name == name + const rootComponents = components.filter((component) => + componentsList.includes(component.name) ); - if (rootComponent.length == 0) return []; - let finalList = [name]; - if (rootComponent.length > 0) { - const list = rootComponent[0].dependency as string[]; - if (list && list.length > 0) { - list.forEach((componentName: string) => { - finalList = [ - ...new Set( - finalList.concat( - buildComponentDependencyList(components, componentName) - ) - ), - ]; - }); - } + if (rootComponents.length == 0) return []; + let finalList = [...componentsList]; + if (rootComponents.length > 0) { + rootComponents.forEach((rootComponent) => { + const list = rootComponent.dependency as string[]; + if (list && list.length > 0) { + list.forEach((componentName: string) => { + finalList = [ + ...new Set( + finalList.concat( + buildComponentDependencyList(components, [componentName]) + ) + ), + ]; + }); + } + }); } return finalList; }