- Notifications
You must be signed in to change notification settings - Fork0
🎛 Interactive customization for template projects
License
AlexSkrypnyk/customizer
Folders and files
Name | Name | Last commit message | Last commit date | |
---|---|---|---|---|
Repository files navigation
The Customizer allows template project authors to ask users questions duringthecomposer create-project
command and then update the newly created projectbased on the received answers.
Run the command below to create a new project from thetemplate project exampleand see the Customizer in action:
composer create-project alexskrypnyk/template-project-example my-project
- Simple installation into template project
- Runs customization on
composer create-project
- Runs customization on
composer create-project --no-install
viacomposer customize
command - Configuration file for questions and processing logic
- Test harness for the template project to test questions and processing logic
- No additional dependencies for minimal footprint
Add to the template project as a Composer dependency:
"require-dev": {"alexskrypnyk/customizer":"^0.5"},"config": {"allow-plugins": {"alexskrypnyk/customizer":true }}
These entries will be removed by the Customizer after your project's usersrun the
composer create-project
command.Copy
customize.php
file with questions and processinglogic in any location within your template project and adjust it as needed.
See theConfiguration section below for more information.
When your users run thecomposer create-project
command, the Customizer willask them questions and process the answers to customize their instance of thetemplate project.
Run the command below to create a new project from thetemplate project exampleand see the Customizer in action:
composer create-project alexskrypnyk/template-project-example my-project
In this example, thedemonstration questionswill ask you to provide apackage name,description, andlicense type. The answers are then processed by updatingthecomposer.json
file and replacing the package name in other project files.
Your users may run thecomposer create-project --no-install
command if theywant to adjust the project before installing dependencies, for example.Customizer will not run in this case as it is not being installed yet andit's dependencies entries will stay in thecomposer.json
file.
The user will have to runcomposer customize
manually to run theCustomizer. It could be useful to let your users know about this commandin your project'sREADME
file.
You can configure how the Customizer processes your user’s template project by providing an arbitrary class (with any namespace) in acustomize.php
file. This includes defining questions and processing logic.
The class has to implementpublic static
methods :
questions()
- defines questions; requiredprocess()
- defines processing logic based on received answers; requiredcleanup()
- defines processing logic for thecomposer.json
file; optionalmessages()
- defines custom messages seen by the user; optional
Definesquestions, theirdiscovery andvalidation callbacks.Questions will be asked in the order they are defined. Questions can use answersfrom previous questions received so far.
Thediscovery callback is optional and runs before the question is asked. Itcan be used to discover the default answer based on the current state of theproject. The discovered value is passed to the question callback. It can be ananonymous function or a method of the configuration classnameddiscover<QuestionName>
.
Thevalidation callback should return the validated answer or throw anexception with a message to be shown to the user. This uses inbuiltSymfonyStyle'sask()
method for asking questions.
customize.php
has an example of thequestions()
method.
Note that while the Customizer examples use SymfonyStyle'sask()
method, you can build your own question asking logic using any other TUIinteraction methods. For example, you can useLaravel Prompts.
Defines processing logic for all answers. This method will be called after allanswers are received and the user confirms the intended changes. It has accessto all answers and Customizer's class public properties and methods.
All file manipulations should be done within this method.
customize.php
has an example of theprocess()
method.
Defines thecleanup()
method after all files were processed but before alldependencies are updated.
The Customizer will remove itself from the project and will update thecomposer.json
as required. This method allows to alter that process asneeded and, if necessary, cancel the original self-cleanup.
customize.php
has an example of thecleanup()
method.
Defines overrides for the Customizer's messages shown to the user.
customize.php
has an example of themessages()
method.
Click to expand an example configurationcustomize.php
file
<?phpdeclare(strict_types=1);useAlexSkrypnyk\Customizer\CustomizeCommand;/** * Customizer configuration. * * Example configuration for the Customizer command. * * phpcs:disable Drupal.Classes.ClassFileName.NoMatch */class Customize {/** * A required callback with question definitions. * * Place questions into this method if you are using Customizer as a * single-file drop-in for your scaffold project. Otherwise - place them into * the configuration class. * * Any questions defined in the `questions()` method of the configuration * class will **fully override** the questions defined here. This means that * the configuration class must provide a full set of questions. * * See `customize.php` for an example of how to define questions. * * @return array<string,array<string,string|callable>> * An associative array of questions with question title as a key and the * value of array with the following keys: * - question: Required question callback function used to ask the question. * The callback receives the following arguments: * - discovered: A value discovered by the discover callback or NULL. * - answers: An associative array of all answers received so far. * - command: The CustomizeCommand object. * - discover: Optional callback function used to discover the value from * the environment. Can be an anonymous function or a method of this class * as discover<PascalCasedQuestion>. If not provided, empty string will * be passed to the question callback. The callback receives the following * arguments: * - command: The CustomizeCommand object. */publicstaticfunctionquestions(CustomizeCommand$c):array {// This an example of questions that can be asked to customize the project.// You can adjust this method to ask questions that are relevant to your// project.//// In this example, we ask for the package name, description, and license.//// You may remove all the questions below and replace them with your own.return ['Name' => [// The discover callback function is used to discover the value from the// environment. In this case, we use the current directory name// and the GITHUB_ORG environment variable to generate the package name.'discover' =>staticfunction (CustomizeCommand$c):string {$name =basename((string)getcwd());$org =getenv('GITHUB_ORG') ?:'acme';return$org .'/' .$name; },// The question callback function defines how the question is asked.// In this case, we ask the user to provide a package name as a string.// The discovery callback is used to provide a default value.// The question callback provides a capability to validate the answer// before it can be accepted by providing a validation callback.'question' =>staticfn(string$discovered,array$answers,CustomizeCommand$c):mixed =>$c->io->ask('Package name',$discovered,staticfunction (string$value):string {// This is a validation callback that checks if the package name is// valid. If not, an \InvalidArgumentException exception is thrown// with a message shown to the user.if (!preg_match('/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/',$value)) {thrownew \InvalidArgumentException(sprintf('The package name "%s" is invalid, it should be lowercase and have a vendor name, a forward slash, and a package name.',$value)); }return$value; }), ],'Description' => [// For this question, we use an answer from the previous question// in the title of the question.'question' =>staticfn(string$discovered,array$answers,CustomizeCommand$c):mixed =>$c->io->ask(sprintf('Description for %s',$answers['Name'])), ],'License' => [// For this question, we use a pre-defined list of options.// For discovery, we use a separate method named 'discoverLicense'// (only for the demonstration purposes; it could have been an// anonymous function).'question' =>staticfn(string$discovered,array$answers,CustomizeCommand$c):mixed =>$c->io->choice('License type', ['MIT','GPL-3.0-or-later','Apache-2.0', ],// Note that the default value is the value discovered by the// 'discoverLicense' method. If the discovery did not return a value,// the default value of 'GPL-3.0-or-later' is used.empty($discovered) ?'GPL-3.0-or-later' :$discovered ), ], ]; }/** * A callback to discover the `License` value from the environment. * * This is an example of discovery function as a class method. * * @param \AlexSkrypnyk\Customizer\CustomizeCommand $c * The Customizer instance. */publicstaticfunctiondiscoverLicense(CustomizeCommand$c):string {returnisset($c->composerjsonData['license']) &&is_string($c->composerjsonData['license']) ?$c->composerjsonData['license'] :''; }/** * A required callback to process all answers. * * This method is called after all questions have been answered and a user * has confirmed the intent to proceed with the customization. * * Note that any manipulation of the composer.json file should be done here * and then written back to the file system. * * @param array<string,string> $answers * Gathered answers. * @param \AlexSkrypnyk\Customizer\CustomizeCommand $c * The Customizer instance. */publicstaticfunctionprocess(array$answers,CustomizeCommand$c):void {$c->debug('Updating composer configuration');$json =$c->readComposerJson($c->composerjson);$json['name'] =$answers['Name'];$json['description'] =$answers['Description'];$json['license'] =$answers['License'];$c->writeComposerJson($c->composerjson,$json);$c->debug('Removing an arbitrary file.');$files =$c->finder($c->cwd)->files()->name('LICENSE');foreach ($filesas$file) {$c->fs->remove($file->getRealPath()); } }/** * Cleanup after the customization. * * By the time this method is called, all the necessary changes have been made * to the project. * * The Customizer will remove itself from the project and will update the * composer.json as required. This method allows to alter that process as * needed and, if necessary, cancel the original self-cleanup. * * @param \AlexSkrypnyk\Customizer\CustomizeCommand $c * The CustomizeCommand object. * * @return bool * Return FALSE to skip the further self-cleanup. Returning TRUE will * proceed with the self-cleanup. */publicstaticfunctioncleanup(CustomizeCommand$c):bool {if ($c->isComposerDependenciesInstalled) {$c->debug('Add an example flag to composer.json.');$json =$c->readComposerJson($c->composerjson);$json['extra'] =is_array($json['extra']) ?$json['extra'] : [];$json['extra']['custom_field'] =TRUE;$c->writeComposerJson($c->composerjson,$json); }returnTRUE; }/** * Override some of the messages displayed to the user by Customizer. * * @param \AlexSkrypnyk\Customizer\CustomizeCommand $c * The Customizer instance. * * @return array<string,string|array<string>> * An associative array of messages with message name as key and the message * test as a string or an array of strings. */publicstaticfunctionmessages(CustomizeCommand$c):array {return [// This is an example of a custom message that overrides the default// message with name `welcome`.'title' =>'Welcome to the "{{ package.name }}" project customizer', ]; }}
The Customizer provides a few helpers to make processing answers easier.These are available as properties and methods of the Customizer instancepassed to the processing callbacks:
cwd
- current working directory.fs
- SymfonyFilesystem
instance.io
- Symfonyinput/output instance.isComposerDependenciesInstalled
- whether the Composer dependencies wereinstalled before the Customizer started.readComposerJson()
- Read the contents of thecomposer.json
file into anarray.writeComposerJson()
- Write the contents of the array to thecomposer.json
file.replaceInPath()
- Replace a string in a file or all files in a directory.replaceInPathBetweenMarkers()
- Replace a string in a file or all files ina directory between two markers.uncommentLine()
- Uncomment a line in a file or all files in a directory.arrayUnsetDeep()
- Unset a fully or partially matched value in a nestedarray, removing empty arrays.
Validation helpers for questions are not provided in this class, but you can easilycreate them using custom regular expression or add them from theAlexSkrypnyk/str2name package.
- Install the Customizer into your template project as described in theInstallation section.
- Create a new testing directory and change into it.
- Create a project in this directory:
composer create-project yournamespace/yourscaffold="@dev" --repository'{"type": "path", "url": "/path/to/yourscaffold", "options": {"symlink": false}}'.
- The Customizer screen should appear.
Repeat the process as many times as needed to test your questions and processinglogic.
Addexport COMPOSER_ALLOW_XDEBUG=1
before running thecomposer create-project
command to enable debugging with XDebug when running Composer commands.
The Customizer provides atest harness to help you, as a template projectauthor, to test the questions and processing with ease.
To use the test harness:
- Setup PHPUnit in your template project to run tests.
- Inherit your test classes from
CustomizerTestCase.php
(this file isincluded into distribution when you add Customizer to your template project). - Add path to
CustomizerTestCase.php
into theautoload-dev
section of yourtemplate project'scomposer.json
file:"autoload-dev": {"psr-4": {"AlexSkrypnyk\\Customizer\\Tests\\":"vendor/alexskrypnyk/customizer/tests/phpunit" } },
- Create a directory in your project with the name
tests/phpunit/Fixtures/<name_of_test_snake_case>
and place your test fixtures there. If you use data providers, you cancreate a sub-directory with the name of the data set within the provider (the top-level key withinthe data provider). - Add fixtures asbase/expected directory structures (see below) and assert for theexpected results in your test.
See examples within thetemplate project example.
The base test classCustomizerTestCase.php
providestheassertFixtureDirectoryEqualsSut()
method to compare a directory undertest with the expected results.
The method usesbase andexpected directories to compare the results:base is used as a state of the project you are testing before thecustomization ran, andexpected is used as an expected result, which will becompared to the actual result after the customization.
Because the projects can have dependencies added duringcomposer install
andother files that are not related to the customization, the method allows you tospecify the list of files to ignore during the comparison using.gitignore
-like syntax with the addition to ignore content changes but stillassess the file presence.
See the description inCustomizerTestCase::assertDirectoriesEqual()
for moreinformation about the comparison process.
composer install # Install dependencies.composer lint # Check coding standards.composer lint-fix # Fix coding standards.composer test # Run tests.
This repository was created using thegetscaffold.dev project scaffold template
About
🎛 Interactive customization for template projects