alexskrypnyk/customizer

Interactive customization for template projects

Fund package maintenance!
alexskrypnyk
Patreon

Installs: 92

Dependents: 1

Suggesters: 0

Security: 0

Stars: 1

Watchers: 1

Forks: 0

Type:composer-plugin

0.3.0 2024-05-28 09:10 UTC

This package is auto-updated.

Last update: 2024-06-25 23:44:47 UTC


README

Customizer logo

Interactive customization for template projects

GitHub Issues GitHub Pull Requests Test PHP codecov GitHub release (latest by date) LICENSE Renovate

The Customizer allows template project authors to ask users questions during the composer create-project command and then update the code base based on their answers.

TL;DR

Run the command below to create a new project from the template project example and see the Customizer in action:

composer create-project alexskrypnyk/template-project-example my-project

Features

  • Can be included as a dependency
  • Can be used without dependencies to support composer create-project --no-install
  • Questions and processing logic are defined in a standalone file
  • Provides a set of helpers for processing answers
  • Provides a test harness that can be used in the template project to test questions and processing logic

Installation

Customizer can be installed into the template project in two ways:

  1. As a Composer dependency: easier to manage, but does not work with composer create-project --no-install
  2. As a standalone class: harder to manage, but works with composer create-project --no-install

As a Composer dependency

When creating projects from other template projects, users typically use composer create-project (without the --no-install), which installs all required dependencies. This means that Customizer can be used as a dependency, allowing template project authors to focus on the questions and processing logic without managing the Customizer's code itself.

  1. Add the following to your composer.json file ( see this example):
"require-dev": {
    "alexskrypnyk/customizer": "^1.0"
},
"config": {
    "allow-plugins": {
        "alexskrypnyk/customizer": true
    }
}
  1. Create customize.php file with questions and processing logic relevant to your template project and place it in anywhere in your project.

These entries will be removed by the Customizer after your project's users run the composer create-project command.

See the Configuration section below for more information.

As a standalone class

There may be cases where template project authors want to ensure customization takes place even if the user doesn't install dependencies. In this situation, the Customizer class needs to be stored within the template project so that Composer can access the code without installing dependencies.

The Customizer provides a single file that can be copied to your project and only relies on Composer.

  1. Copy the CustomizeCommand.php file to the root, src or any other directory of your project.
  2. Adjust the namespace within the class.
  3. Add the following to your composer.json file ( see this example):
"autoload": {
    "classmap": [
        "src/CustomizeCommand.php"
    ]
},
"scripts": {
    "customize": [
        "YourNamespace\\Customizer\\CustomizeCommand"
    ],
    "post-create-project-cmd": [
        "@customize"
    ]
}

Make sure to adjust the path in the classmap and update YourNamespace\\Customizer\\CustomizeCommand with the correct namespace.

These entries will be removed by the Customizer after your project's users run the composer create-project command.

  1. Create customize.php file with questions and processing logic relevant to your template project and place it in anywhere in your project.

See the Configuration section below for more information.

Usage example

After the installation into the template project, the Customizer will be triggered automatically after a user runs the composer create-project command.

It will ask the user a series of questions, and will process the answers to customize their instance of the template project.

Run the command below to create a new project from the template project example and see the Customizer in action:

composer create-project alexskrypnyk/template-project-example my-project

The demonstration questions provided in the customize.php file will ask you to provide a package name, description, and license. The answers are then processed by updating the composer.json file and replacing the package name in other project files.

Configuration

The template project authors can configure the Customizer, including defining questions and processing logic, by providing a an arbitrary class (with any namespace) in a customize.php file.

The class has to implement public static methods to perform the configuration.

questions()

Define questions and their processing callbacks. Questions will be asked in the order they are defined. Questions can use answers from previous questions received so far.

Answers will be processed in the order they are defined. Process callbacks have access to all answers and Customizer's class public properties and methods.

If a question does not have a processAnswers() callback explicitly specified, a static method prefixed with processAnswers and a camel-cased question title will be called. If the method does not exist, there will be no processing.

customize.php has an example of the questions() method.

<?php

declare(strict_types=1);

use AlexSkrypnyk\Customizer\CustomizeCommand;

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 question callback function defines how the question is asked.
        // In this case, we ask the user to provide a package name as a string.
        'question' => static fn(array $answers, CustomizeCommand $c): mixed => $c->io->ask('Package name', NULL, static function (string $value): string {
          // This is a validation callback that checks if the package name is
          // valid. If not, an 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;
        }),
        // The `processAnswers()` callback function defines how the answer is processed.
        // The processing takes place only after all answers are received and
        // the user confirms the intended changes.
        'processAnswers' => static function (string $title, string $answer, array $answers, CustomizeCommand $c): void {
          $name = is_string($c->composerjsonData['name'] ?? NULL) ? $c->composerjsonData['name'] : '';
          // Update the package data.
          $c->composerjsonData['name'] = $answer;
          // Write the updated composer.json file.
          CustomizeCommand::writeComposerJson($c->cwd . '/composer.json', $c->composerjsonData);
          // Replace the package name in the project files.
          $c->replaceInPath($c->cwd, $name, $answer);
        },
      ],
      'Description' => [
        // For this question, we are using an answer from the previous question
        // in the title of the question.
        'question' => static fn(array $answers, CustomizeCommand $c): mixed => $c->io->ask(sprintf('Description for %s', $answers['Name'])),
        'processAnswers' => static function (string $title, string $answer, array $answers, CustomizeCommand $c): void {
          $description = is_string($c->composerjsonData['description'] ?? NULL) ? $c->composerjsonData['description'] : '';
          $c->composerjsonData['description'] = $answer;
          CustomizeCommand::writeComposerJson($c->cwd . '/composer.json', $c->composerjsonData);
          $c->replaceInPath($c->cwd, $description, $answer);
        },
      ],
      'License' => [
        // For this question, we are using a pre-defined list of options.
        // For processing, we are using a separate method named 'processLicense'
        // (only for the demonstration purposes; it could have been an
        // anonymous function).
        'question' => static fn(array $answers, CustomizeCommand $c): mixed => $c->io->choice('License type', [
          'MIT',
          'GPL-3.0-or-later',
          'Apache-2.0',
        ], 'GPL-3.0-or-later'),
      ],
    ];
  }

  public static function processLicense(string $title, string $answer, array $answers, CustomizeCommand $c): void {
    $c->composerjsonData['license'] = $answer;
    CustomizeCommand::writeComposerJson($c->cwd . '/composer.json', $c->composerjsonData);
  }

}

cleanupSelf()

Using the cleanupSelf() method, the template project authors can additionally process the composer.json file content before all dependencies are updated. This runs after all answers are received and the user confirms the intended changes.

Use $composerjson = []; to prevent dependencies updates by the Customizer. This essentially means that you are managing that process outside of this method.

/**
 * A callback to process cleanup.
 *
 * @param array<string,mixed> $composerjson
 *   The composer.json file content passed by reference.
 * @param \AlexSkrypnyk\Customizer\CustomizeCommand $c
 *   The Customizer instance.
 */
public static function cleanupSelf(array &$composerjson, CustomizeCommand $c): void {
  // Here you can remove any sections from the composer.json file that are not
  // needed for the project before all dependencies are updated.
  //
  // You can also additionally process files.
}

messages()

Using the messages() method, the template project authors can overwrite messages provided by the Customizer.

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',
  ];
}

Advanced configuration

In case when a template repository authors want to make the Customizer to be truly drop-in single-file solution (installation option 2 without customize.php file), they can define the questions and processing logic in the CustomizeCommand.php file itself. In this case, customize.php will not be required (but is still supported).

Note that if the customize.php file is present in the project, the questions defined in the CustomizeCommand.php file will be ignored in favour of the questions provided in the customize.php file.

Helpers

The Customizer provides a few helpers to make processing answers easier. These are available as properties and methods of the $c instance passed to the processing callbacks:

  • cwd - current working directory.
  • fs - Symfony Filesystem instance.
  • io - Symfony input/output instance.
  • isComposerDependenciesInstalled - whether the Composer dependencies were installed before the Customizer started.
  • readComposerJson() - Read the contents of the composer.json file into an array.
  • writeComposerJson() - Write the contents of the array to the composer.json file.
  • replaceInPath() - Replace a string in a file or all files in a directory.
  • replaceInPathBetween() - Replace a string in a file or all files in a 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 nested array, removing empty arrays.

Question validation helpers are not provided in this class, but you can easily create them using custom regular expression or add them from the AlexSkrypnyk/str2name package.

Developing and testing your questions

Testing manually

  1. Install the Customizer into your template project as described in the Installation section.
  2. Create a new testing directory and change into it.
  3. Create a project in this directory:
composer create-project yournamespace/yourscaffold="@dev" --repository '{"type": "path", "url": "/path/to/yourscaffold", "options": {"symlink": false}}' .
  1. The Customizer screen should appear.

Repeat the process as many times as needed to test your questions and processing logic.

Add export COMPOSER_ALLOW_XDEBUG=1 before running the composer create-project command to enable debugging with XDebug.

Automated functional tests

This project uses automated functional tests to check that composer create-project asks the questions and processes the answers correctly.

You can setup PHPUnit in your template project to run these tests. Once done, use CustomizerTestCase.php as a base class for your tests. See this example within the template project example.

Maintenance

composer install
composer lint
composer test

This repository was created using the getscaffold.dev project scaffold template