alexskrypnyk / customizer
模板项目的交互式定制
Requires
- php: >=8.2
- composer-plugin-api: ^2.0
Requires (Dev)
- composer/composer: ^2.7
- dealerdirect/phpcodesniffer-composer-installer: ^1.0
- drupal/coder: ^8.3
- ergebnis/composer-normalize: ^2.42
- phpcompatibility/php-compatibility: ^9.3
- phpmd/phpmd: ^2.15
- phpstan/phpstan: ^1.10
- phpunit/phpunit: ^11.1
- rector/rector: ^1.0
This package is auto-updated.
Last update: 2024-09-16 08:39:52 UTC
README
模板项目的交互式定制
Customizer 允许模板项目作者在 composer create-project
命令期间向用户提问,并根据收到的答案更新新创建的项目。
TL;DR
运行以下命令从 模板项目示例 创建新项目并查看 Customizer 的实际操作
composer create-project alexskrypnyk/template-project-example my-project
特性
- 简单安装到模板项目
- 在
composer create-project
上运行定制 - 通过
composer customize
命令在composer create-project --no-install
上运行定制 - 问题及处理逻辑的配置文件
- 模板项目的测试框架以测试问题和处理逻辑
- 最小化依赖,无额外依赖
安装
- 将 Customizer 添加到模板项目作为 Composer 依赖项
"require-dev": { "alexskrypnyk/customizer": "^1.0" }, "config": { "allow-plugins": { "alexskrypnyk/customizer": true } }
在您的项目用户运行 composer create-project
命令后,Customizer 将删除这些条目。
- 创建包含与您的模板项目相关的问题和处理逻辑的
customize.php
文件,并将其放置在项目的任何位置。
有关更多信息,请参阅下面的 配置 部分。
使用示例
当您的用户运行 composer create-project
命令时,Customizer 将询问他们问题,并处理答案来定制他们的模板项目实例。
运行以下命令从 模板项目示例 创建新项目并查看 Customizer 的实际操作
composer create-project alexskrypnyk/template-project-example my-project
在此示例中,演示问题 将要求您提供 包名、描述 和 许可证类型。然后,这些答案将通过对 composer.json
文件进行更新,以及在项目文件的包名中进行替换来处理。
--no-install
如果您的用户想在安装依赖项之前调整项目,例如,他们可能会运行 composer create-project --no-install
命令。在此情况下,Customizer 不会运行,因为它尚未安装,其依赖项条目将保留在 composer.json
文件中。
用户必须手动运行 composer customize
以运行 Customizer。在项目的 README 文件中让您的用户了解此命令可能会有所帮助。
配置
您可以通过提供任意类(任何命名空间)在 customize.php
文件中来配置 Customizer,包括定义问题和处理逻辑。
该类必须实现 public static
方法
questions()
- 定义问题;必需process()
- 根据收到的答案定义处理逻辑;必需cleanup()
- 定义composer.json
文件的处理逻辑;可选messages()
- 可选方法以覆盖用户看到的消息;可选
questions()
定义问题、它们的发现和验证回调。问题将按照定义的顺序进行提问。问题可以使用到目前为止收到的先前问题的答案。
发现回调是可选的,在提问之前运行。它可以用来自动确定默认答案,基于项目的当前状态。发现的价值传递给问题回调。它可以是匿名函数或配置类的discover<问题名称>
方法。
验证回调应返回验证后的答案或抛出一个包含要显示给用户的消息的异常。它使用内置的SymfonyStyle的ask()
方法提问。
customize.php
提供了questions()
方法的示例。
请注意,虽然定制器示例使用SymfonyStyle的ask()
方法,但您可以使用其他TUI交互方法构建自己的提问逻辑。例如,您可以使用Laravel Prompts。
process()
定义所有答案的处理逻辑。此方法将在收到所有答案并确认预期更改后调用。它有权访问所有答案和定制器的公共属性和方法。
所有文件操作都应在该方法内进行。
customize.php
提供了process()
方法的示例。
cleanup()
定义在所有文件处理完毕但在所有依赖项更新之前执行的cleanup()
方法。
customize.php
提供了cleanup()
方法的示例。
messages()
定义覆盖定制器显示给用户的消息。
customize.php
提供了messages()
方法的示例。定制器提供的消息。
示例配置
点击展开示例配置文件customize.php
<?php declare(strict_types=1); use AlexSkrypnyk\Customizer\CustomizeCommand; /** * Customizer configuration. * * Example configuration for the Customizer command. * * phpcs:disable Drupal.Classes.ClassFileName.NoMatch * * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ 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. * * @SuppressWarnings(PHPMD.UnusedFormalParameter) * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ public static function questions(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' => static function (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' => static fn(string $discovered, array $answers, CustomizeCommand $c): mixed => $c->io->ask('Package name', $discovered, static function (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)) { throw new \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' => static fn(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' => static fn(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. */ public static function discoverLicense(CustomizeCommand $c): string { return isset($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. */ public static function process(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 ($files as $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. */ public static function cleanup(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']['customizer'] = TRUE; $c->writeComposerJson($c->composerjson, $json); } return TRUE; } /** * 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. * * @SuppressWarnings(PHPMD.UnusedFormalParameter) */ public static function messages(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', ]; } }
辅助工具
定制器提供了一些辅助工具来简化答案的处理。这些作为传递给处理回调的定制器$c
实例的属性和方法。
cwd
- 当前工作目录。fs
- SymfonyFilesystem
实例。io
- Symfony input/output实例。isComposerDependenciesInstalled
- 定制器启动前是否安装了Composer依赖项。readComposerJson()
- 将composer.json
文件的内容读取到数组中。writeComposerJson()
- 将数组的内容写入composer.json
文件。replaceInPath()
- 在文件或目录中的文件中替换一个字符串。replaceInPathBetweenMarkers()
- 在文件或目录中的文件中替换两个标记之间的字符串。uncommentLine()
- 在文件或目录中的文件中取消注释一行。arrayUnsetDeep()
- 在嵌套数组中取消设置完全匹配或部分匹配的值,删除空数组。
此类中没有提供问题验证辅助工具,但您可以使用自定义正则表达式轻松创建它们,或者从AlexSkrypnyk/str2name包中添加。
开发和测试您的问题
手动测试
- 按照安装部分所述,将自定义器安装到您的模板项目中。
- 创建一个新的测试目录并切换到它。
- 在此目录中创建一个项目
composer create-project yournamespace/yourscaffold="@dev" --repository '{"type": "path", "url": "/path/to/yourscaffold", "options": {"symlink": false}}' .
- 应出现自定义器屏幕。
根据需要重复此过程以测试您的问题和处理逻辑。
在运行composer create-project
命令之前添加export COMPOSER_ALLOW_XDEBUG=1
以启用XDebug调试。
自动功能测试
自定义器提供了一个[测试工具](tests/phpunit/Functional),可以帮助您轻松地测试您的问题和处理。
模板项目作者可以使用相同的测试工具来测试他们自己的问题和处理逻辑
- 在您的模板项目中设置PHPUnit以运行测试。
- 从
CustomizerTestCase.php
继承您的类(当您将自定义器添加到模板项目时,此文件包含在分发中)。 - 在您的项目中创建一个名为
tests/phpunit/Fixtures/<name_of_test_snake_case>
的目录,并将测试固定在此处。如果您使用数据提供者,可以在提供者内部创建一个名为数据集的子目录。 - 将测试添加为base/expected目录结构,并断言期望的结果。
请参阅模板项目示例中的示例。
比较固定目录
基本测试类CustomizerTestCase.php
提供了assertFixtureDirectoryEqualsSut()
方法,用于比较测试目录与期望的结果。
该方法使用base和expected目录来比较结果:base用作您正在测试的项目在自定义化运行之前的状态,而expected用作期望的结果,在自定义化之后将与实际结果进行比较。
由于项目在composer install
期间可能添加了依赖项和其他与自定义化无关的文件,因此该方法允许您使用类似于.gitignore
的语法指定要忽略的文件列表,在比较时忽略内容更改,但仍然评估文件的存在。
有关更多信息,请参阅assertFixtureDirectoryEqualsSut()
的描述。
维护
composer install # Install dependencies.
composer lint # Check coding standards.
composer lint-fix # Fix coding standards.
composer test # Run tests.
此存储库使用getscaffold.dev项目脚手架模板创建。