Skip to content

Commit

Permalink
feature #975 New "add-lines" configurator for simple file patching + …
Browse files Browse the repository at this point in the history
…importmap support (kbond)

This PR was merged into the 1.x branch.

Discussion
----------

New "add-lines" configurator for simple file patching + importmap support

Hi!

Today, WebpackEncoreBundle's recipe contains the UX/Stimulus files. Soon, I will introduce a new StimulusBundle - https://github.com/weaverryan/stimulus-bundle - so that the WebpackEncoreBundle can be used without Stimulus and (more importantly) the `stimulus_()` functions can be used with AssetMapper (i.e. without Encore).

This makes the recipe setup more... interesting :). This PR adds 2 things:

## importmap support in `JsonSynchronizer`

`JsonSynchronizer` now has 2 modes, based on the presence/absence of the `importmap.php` file. If that file is present, then:

* A) A new [symfony.importmap](https://github.com/weaverryan/stimulus-bundle/blob/148f6f9412e7063f9945d0947f206081d3311d7a/assets/package.json#L8-L11) config is read from the bundle's `package.json` file and these are added to the `importmap.php` file by running the `bin/console importmap:require` command. The `path:` prefix is used to refer to a "local" file in the bundle. Sometimes the importmap entries will be different than what's needed for `package.json`, hence having both configs.

* B) The `controllers.json` file is updated like normal

Also, a new [symfony.needsPackageAsADependency](https://github.com/weaverryan/stimulus-bundle/blob/148f6f9412e7063f9945d0947f206081d3311d7a/assets/package.json#LL7C10-L7C35) config key was added specifically for StimulusBundle. If `true`, no `file:/vendor/...` package will be added to `package.json` when using Encore.

## add-lines Configurator

The new `add-lines` configurator is able to add entire lines to the `top`, `bottom` of `after_target` of existing files (if they exist). Example usage:

```json
"add-lines": [
    {
        "file": "webpack.config.js",
        "content": "\n    // enables the Symfony UX Stimulus bridge (used in assets/bootstrap.js)\n    .enableStimulusBridge('./assets/controllers.json')",
        "position": "after_target",
        "target": ".splitEntryChunks()"
    },
    {
        "file": "assets/app.js",
        "content": "import './bootstrap.js';",
        "position": "top",
        "warn_if_missing": true
    }
]
```

There is also a `requires` key to only run if another package is installed. This is needed because StimulusBundle will need a [different assets/bootstrap.js](https://github.com/weaverryan/recipes/blob/3ab0e996ae22af665ec83bf0634f1163b533bc36/symfony/stimulus-bundle/1.0/manifest.json#L23-L33) based on if Encore vs AssetMapper is installed

Cheers!

Commits
-------

0b70eb3 add AddLinesConfigurator + updating PackageJsonSynchronizer for symfony/asset-mapper
  • Loading branch information
fabpot committed May 26, 2023
2 parents 51077ed + 0b70eb3 commit 49059a1
Show file tree
Hide file tree
Showing 10 changed files with 1,140 additions and 45 deletions.
29 changes: 25 additions & 4 deletions src/Configurator.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class Configurator
private $io;
private $options;
private $configurators;
private $postInstallConfigurators;
private $cache;

public function __construct(Composer $composer, IOInterface $io, Options $options)
Expand All @@ -45,6 +46,9 @@ public function __construct(Composer $composer, IOInterface $io, Options $option
'dockerfile' => Configurator\DockerfileConfigurator::class,
'docker-compose' => Configurator\DockerComposeConfigurator::class,
];
$this->postInstallConfigurators = [
'add-lines' => Configurator\AddLinesConfigurator::class,
];
}

public function install(Recipe $recipe, Lock $lock, array $options = [])
Expand All @@ -57,11 +61,25 @@ public function install(Recipe $recipe, Lock $lock, array $options = [])
}
}

/**
* Run after all recipes have been installed to run post-install configurators.
*/
public function postInstall(Recipe $recipe, Lock $lock, array $options = [])
{
$manifest = $recipe->getManifest();
foreach (array_keys($this->postInstallConfigurators) as $key) {
if (isset($manifest[$key])) {
$this->get($key)->configure($recipe, $manifest[$key], $lock, $options);
}
}
}

public function populateUpdate(RecipeUpdate $recipeUpdate): void
{
$originalManifest = $recipeUpdate->getOriginalRecipe()->getManifest();
$newManifest = $recipeUpdate->getNewRecipe()->getManifest();
foreach (array_keys($this->configurators) as $key) {
$allConfigurators = array_merge($this->configurators, $this->postInstallConfigurators);
foreach (array_keys($allConfigurators) as $key) {
if (!isset($originalManifest[$key]) && !isset($newManifest[$key])) {
continue;
}
Expand All @@ -73,7 +91,10 @@ public function populateUpdate(RecipeUpdate $recipeUpdate): void
public function unconfigure(Recipe $recipe, Lock $lock)
{
$manifest = $recipe->getManifest();
foreach (array_keys($this->configurators) as $key) {

$allConfigurators = array_merge($this->configurators, $this->postInstallConfigurators);

foreach (array_keys($allConfigurators) as $key) {
if (isset($manifest[$key])) {
$this->get($key)->unconfigure($recipe, $manifest[$key], $lock);
}
Expand All @@ -82,15 +103,15 @@ public function unconfigure(Recipe $recipe, Lock $lock)

private function get($key): AbstractConfigurator
{
if (!isset($this->configurators[$key])) {
if (!isset($this->configurators[$key]) && !isset($this->postInstallConfigurators[$key])) {
throw new \InvalidArgumentException(sprintf('Unknown configurator "%s".', $key));
}

if (isset($this->cache[$key])) {
return $this->cache[$key];
}

$class = $this->configurators[$key];
$class = isset($this->configurators[$key]) ? $this->configurators[$key] : $this->postInstallConfigurators[$key];

return $this->cache[$key] = new $class($this->composer, $this->io, $this->options);
}
Expand Down
4 changes: 2 additions & 2 deletions src/Configurator/AbstractConfigurator.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,15 @@ abstract public function unconfigure(Recipe $recipe, $config, Lock $lock);

abstract public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void;

protected function write($messages)
protected function write($messages, $verbosity = IOInterface::VERBOSE)
{
if (!\is_array($messages)) {
$messages = [$messages];
}
foreach ($messages as $i => $message) {
$messages[$i] = ' '.$message;
}
$this->io->writeError($messages, true, IOInterface::VERBOSE);
$this->io->writeError($messages, true, $verbosity);
}

protected function isFileMarked(Recipe $recipe, string $file): bool
Expand Down
230 changes: 230 additions & 0 deletions src/Configurator/AddLinesConfigurator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
<?php

namespace Symfony\Flex\Configurator;

use Composer\IO\IOInterface;
use Symfony\Flex\Lock;
use Symfony\Flex\Recipe;
use Symfony\Flex\Update\RecipeUpdate;

/**
* @author Kevin Bond <[email protected]>
* @author Ryan Weaver <[email protected]>
*/
class AddLinesConfigurator extends AbstractConfigurator
{
private const POSITION_TOP = 'top';
private const POSITION_BOTTOM = 'bottom';
private const POSITION_AFTER_TARGET = 'after_target';

private const VALID_POSITIONS = [
self::POSITION_TOP,
self::POSITION_BOTTOM,
self::POSITION_AFTER_TARGET,
];

public function configure(Recipe $recipe, $config, Lock $lock, array $options = []): void
{
foreach ($config as $patch) {
if (!isset($patch['file'])) {
$this->write(sprintf('The "file" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName()));

continue;
}

if (isset($patch['requires']) && !$this->isPackageInstalled($patch['requires'])) {
continue;
}

if (!isset($patch['content'])) {
$this->write(sprintf('The "content" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName()));

continue;
}
$content = $patch['content'];

$file = $this->path->concatenate([$this->options->get('root-dir'), $patch['file']]);
$warnIfMissing = isset($patch['warn_if_missing']) && $patch['warn_if_missing'];
if (!is_file($file)) {
$this->write([
sprintf('Could not add lines to file <info>%s</info> as it does not exist. Missing lines:', $patch['file']),
'<comment>"""</comment>',
$content,
'<comment>"""</comment>',
'',
], $warnIfMissing ? IOInterface::NORMAL : IOInterface::VERBOSE);

continue;
}

$this->write(sprintf('Patching file "%s"', $patch['file']));

if (!isset($patch['position'])) {
$this->write(sprintf('The "position" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName()));

continue;
}
$position = $patch['position'];
if (!\in_array($position, self::VALID_POSITIONS, true)) {
$this->write(sprintf('The "position" key must be one of "%s" for the "add-lines" configurator for recipe "%s". Skipping', implode('", "', self::VALID_POSITIONS), $recipe->getName()));

continue;
}

if (self::POSITION_AFTER_TARGET === $position && !isset($patch['target'])) {
$this->write(sprintf('The "target" key is required when "position" is "%s" for the "add-lines" configurator for recipe "%s". Skipping', self::POSITION_AFTER_TARGET, $recipe->getName()));

continue;
}
$target = isset($patch['target']) ? $patch['target'] : null;

$this->patchFile($file, $content, $position, $target, $warnIfMissing);
}
}

public function unconfigure(Recipe $recipe, $config, Lock $lock): void
{
foreach ($config as $patch) {
if (!isset($patch['file'])) {
$this->write(sprintf('The "file" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName()));

continue;
}

// Ignore "requires": the target packages may have just become uninstalled.
// Checking for a "content" match is enough.

$file = $this->path->concatenate([$this->options->get('root-dir'), $patch['file']]);
if (!is_file($file)) {
continue;
}

if (!isset($patch['content'])) {
$this->write(sprintf('The "content" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName()));

continue;
}
$value = $patch['content'];

$this->unPatchFile($file, $value);
}
}

public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void
{
$originalConfig = array_filter($originalConfig, function ($item) {
return !isset($item['requires']) || $this->isPackageInstalled($item['requires']);
});
$newConfig = array_filter($newConfig, function ($item) {
return !isset($item['requires']) || $this->isPackageInstalled($item['requires']);
});

$filterDuplicates = function (array $sourceConfig, array $comparisonConfig) {
$filtered = [];
foreach ($sourceConfig as $sourceItem) {
$found = false;
foreach ($comparisonConfig as $comparisonItem) {
if ($sourceItem['file'] === $comparisonItem['file'] && $sourceItem['content'] === $comparisonItem['content']) {
$found = true;
break;
}
}
if (!$found) {
$filtered[] = $sourceItem;
}
}

return $filtered;
};

// remove any config where the file+value is the same before & after
$filteredOriginalConfig = $filterDuplicates($originalConfig, $newConfig);
$filteredNewConfig = $filterDuplicates($newConfig, $originalConfig);

$this->unconfigure($recipeUpdate->getOriginalRecipe(), $filteredOriginalConfig, $recipeUpdate->getLock());
$this->configure($recipeUpdate->getNewRecipe(), $filteredNewConfig, $recipeUpdate->getLock());
}

private function patchFile(string $file, string $value, string $position, ?string $target, bool $warnIfMissing)
{
$fileContents = file_get_contents($file);

if (false !== strpos($fileContents, $value)) {
return; // already includes value, skip
}

switch ($position) {
case self::POSITION_BOTTOM:
$fileContents .= "\n".$value;

break;
case self::POSITION_TOP:
$fileContents = $value."\n".$fileContents;

break;
case self::POSITION_AFTER_TARGET:
$lines = explode("\n", $fileContents);
$targetFound = false;
foreach ($lines as $key => $line) {
if (false !== strpos($line, $target)) {
array_splice($lines, $key + 1, 0, $value);
$targetFound = true;

break;
}
}
$fileContents = implode("\n", $lines);

if (!$targetFound) {
$this->write([
sprintf('Could not add lines after "%s" as no such string was found in "%s". Missing lines:', $target, $file),
'<comment>"""</comment>',
$value,
'<comment>"""</comment>',
'',
], $warnIfMissing ? IOInterface::NORMAL : IOInterface::VERBOSE);
}

break;
}

file_put_contents($file, $fileContents);
}

private function unPatchFile(string $file, $value)
{
$fileContents = file_get_contents($file);

if (false === strpos($fileContents, $value)) {
return; // value already gone!
}

if (false !== strpos($fileContents, "\n".$value)) {
$value = "\n".$value;
} elseif (false !== strpos($fileContents, $value."\n")) {
$value = $value."\n";
}

$position = strpos($fileContents, $value);
$fileContents = substr_replace($fileContents, '', $position, \strlen($value));

file_put_contents($file, $fileContents);
}

private function isPackageInstalled($packages): bool
{
if (\is_string($packages)) {
$packages = [$packages];
}

$installedRepo = $this->composer->getRepositoryManager()->getLocalRepository();

foreach ($packages as $packageName) {
if (null === $installedRepo->findPackage($packageName, '*')) {
return false;
}
}

return true;
}
}
21 changes: 12 additions & 9 deletions src/Flex.php
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,7 @@ public function install(Event $event)
$installContribs = $this->composer->getPackage()->getExtra()['symfony']['allow-contrib'] ?? false;
$manifest = null;
$originalComposerJsonHash = $this->getComposerJsonHash();
$postInstallRecipes = [];
foreach ($recipes as $recipe) {
if ('install' === $recipe->getJob() && !$installContribs && $recipe->isContrib()) {
$warning = $this->io->isInteractive() ? 'WARNING' : 'IGNORING';
Expand Down Expand Up @@ -519,6 +520,7 @@ function ($value) {

switch ($recipe->getJob()) {
case 'install':
$postInstallRecipes[] = $recipe;
$this->io->writeError(sprintf(' - Configuring %s', $this->formatOrigin($recipe)));
$this->configurator->install($recipe, $this->lock, [
'force' => $event instanceof UpdateEvent && $event->force(),
Expand All @@ -542,6 +544,12 @@ function ($value) {
}
}

foreach ($postInstallRecipes as $recipe) {
$this->configurator->postInstall($recipe, $this->lock, [
'force' => $event instanceof UpdateEvent && $event->force(),
]);
}

if (null !== $manifest) {
array_unshift(
$this->postInstallOutput,
Expand Down Expand Up @@ -572,17 +580,12 @@ private function synchronizePackageJson(string $rootDir)
$rootDir = realpath($rootDir);
$vendorDir = trim((new Filesystem())->makePathRelative($this->config->get('vendor-dir'), $rootDir), '/');

$synchronizer = new PackageJsonSynchronizer($rootDir, $vendorDir);
$executor = new ScriptExecutor($this->composer, $this->io, $this->options);
$synchronizer = new PackageJsonSynchronizer($rootDir, $vendorDir, $executor);

if ($synchronizer->shouldSynchronize()) {
$lockData = $this->composer->getLocker()->getLockData();

if (method_exists($synchronizer, 'addPackageJsonLink') && 'string' === (new \ReflectionParameter([$synchronizer, 'addPackageJsonLink'], 'phpPackage'))->getType()->getName()) {
// support for smooth upgrades from older flex versions
$lockData['packages'] = array_column($lockData['packages'] ?? [], 'name');
$lockData['packages-dev'] = array_column($lockData['packages-dev'] ?? [], 'name');
}

if ($synchronizer->synchronize(array_merge($lockData['packages'] ?? [], $lockData['packages-dev'] ?? []))) {
$this->io->writeError('<info>Synchronizing package.json with PHP packages</>');
$this->io->writeError('<warning>Don\'t forget to run npm install --force or yarn install --force to refresh your JavaScript dependencies!</>');
Expand Down Expand Up @@ -773,7 +776,7 @@ public function fetchRecipes(array $operations, bool $reset): array
$job = method_exists($operation, 'getOperationType') ? $operation->getOperationType() : $operation->getJobType();

if (!isset($manifests[$name]) && isset($data['conflicts'][$name])) {
$this->io->writeError(sprintf(' - Skipping recipe for %s: all versions of the recipe conflict with your package versions.', $name), true, IOInterface::VERBOSE);
$this->io->writeError(sprintf(' - Skipping recipe for %s: all versions of the recipe conflict with your package versions.', $name));
continue;
}

Expand All @@ -784,7 +787,7 @@ public function fetchRecipes(array $operations, bool $reset): array

if (!isset($newManifests[$name])) {
// no older recipe found
$this->io->writeError(sprintf(' - Skipping recipe for %s: all versions of the recipe conflict with your package versions.', $name), true, IOInterface::VERBOSE);
$this->io->writeError(sprintf(' - Skipping recipe for %s: all versions of the recipe conflict with your package versions.', $name));

continue 2;
}
Expand Down
Loading

0 comments on commit 49059a1

Please sign in to comment.