Skip to content

Commit 49059a1

Browse files
committed
feature #975 New "add-lines" configurator for simple file patching + 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
2 parents 51077ed + 0b70eb3 commit 49059a1

File tree

10 files changed

+1140
-45
lines changed

10 files changed

+1140
-45
lines changed

src/Configurator.php

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class Configurator
2525
private $io;
2626
private $options;
2727
private $configurators;
28+
private $postInstallConfigurators;
2829
private $cache;
2930

3031
public function __construct(Composer $composer, IOInterface $io, Options $options)
@@ -45,6 +46,9 @@ public function __construct(Composer $composer, IOInterface $io, Options $option
4546
'dockerfile' => Configurator\DockerfileConfigurator::class,
4647
'docker-compose' => Configurator\DockerComposeConfigurator::class,
4748
];
49+
$this->postInstallConfigurators = [
50+
'add-lines' => Configurator\AddLinesConfigurator::class,
51+
];
4852
}
4953

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

64+
/**
65+
* Run after all recipes have been installed to run post-install configurators.
66+
*/
67+
public function postInstall(Recipe $recipe, Lock $lock, array $options = [])
68+
{
69+
$manifest = $recipe->getManifest();
70+
foreach (array_keys($this->postInstallConfigurators) as $key) {
71+
if (isset($manifest[$key])) {
72+
$this->get($key)->configure($recipe, $manifest[$key], $lock, $options);
73+
}
74+
}
75+
}
76+
6077
public function populateUpdate(RecipeUpdate $recipeUpdate): void
6178
{
6279
$originalManifest = $recipeUpdate->getOriginalRecipe()->getManifest();
6380
$newManifest = $recipeUpdate->getNewRecipe()->getManifest();
64-
foreach (array_keys($this->configurators) as $key) {
81+
$allConfigurators = array_merge($this->configurators, $this->postInstallConfigurators);
82+
foreach (array_keys($allConfigurators) as $key) {
6583
if (!isset($originalManifest[$key]) && !isset($newManifest[$key])) {
6684
continue;
6785
}
@@ -73,7 +91,10 @@ public function populateUpdate(RecipeUpdate $recipeUpdate): void
7391
public function unconfigure(Recipe $recipe, Lock $lock)
7492
{
7593
$manifest = $recipe->getManifest();
76-
foreach (array_keys($this->configurators) as $key) {
94+
95+
$allConfigurators = array_merge($this->configurators, $this->postInstallConfigurators);
96+
97+
foreach (array_keys($allConfigurators) as $key) {
7798
if (isset($manifest[$key])) {
7899
$this->get($key)->unconfigure($recipe, $manifest[$key], $lock);
79100
}
@@ -82,15 +103,15 @@ public function unconfigure(Recipe $recipe, Lock $lock)
82103

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

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

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

95116
return $this->cache[$key] = new $class($this->composer, $this->io, $this->options);
96117
}

src/Configurator/AbstractConfigurator.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,15 @@ abstract public function unconfigure(Recipe $recipe, $config, Lock $lock);
4343

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

46-
protected function write($messages)
46+
protected function write($messages, $verbosity = IOInterface::VERBOSE)
4747
{
4848
if (!\is_array($messages)) {
4949
$messages = [$messages];
5050
}
5151
foreach ($messages as $i => $message) {
5252
$messages[$i] = ' '.$message;
5353
}
54-
$this->io->writeError($messages, true, IOInterface::VERBOSE);
54+
$this->io->writeError($messages, true, $verbosity);
5555
}
5656

5757
protected function isFileMarked(Recipe $recipe, string $file): bool
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
<?php
2+
3+
namespace Symfony\Flex\Configurator;
4+
5+
use Composer\IO\IOInterface;
6+
use Symfony\Flex\Lock;
7+
use Symfony\Flex\Recipe;
8+
use Symfony\Flex\Update\RecipeUpdate;
9+
10+
/**
11+
* @author Kevin Bond <[email protected]>
12+
* @author Ryan Weaver <[email protected]>
13+
*/
14+
class AddLinesConfigurator extends AbstractConfigurator
15+
{
16+
private const POSITION_TOP = 'top';
17+
private const POSITION_BOTTOM = 'bottom';
18+
private const POSITION_AFTER_TARGET = 'after_target';
19+
20+
private const VALID_POSITIONS = [
21+
self::POSITION_TOP,
22+
self::POSITION_BOTTOM,
23+
self::POSITION_AFTER_TARGET,
24+
];
25+
26+
public function configure(Recipe $recipe, $config, Lock $lock, array $options = []): void
27+
{
28+
foreach ($config as $patch) {
29+
if (!isset($patch['file'])) {
30+
$this->write(sprintf('The "file" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName()));
31+
32+
continue;
33+
}
34+
35+
if (isset($patch['requires']) && !$this->isPackageInstalled($patch['requires'])) {
36+
continue;
37+
}
38+
39+
if (!isset($patch['content'])) {
40+
$this->write(sprintf('The "content" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName()));
41+
42+
continue;
43+
}
44+
$content = $patch['content'];
45+
46+
$file = $this->path->concatenate([$this->options->get('root-dir'), $patch['file']]);
47+
$warnIfMissing = isset($patch['warn_if_missing']) && $patch['warn_if_missing'];
48+
if (!is_file($file)) {
49+
$this->write([
50+
sprintf('Could not add lines to file <info>%s</info> as it does not exist. Missing lines:', $patch['file']),
51+
'<comment>"""</comment>',
52+
$content,
53+
'<comment>"""</comment>',
54+
'',
55+
], $warnIfMissing ? IOInterface::NORMAL : IOInterface::VERBOSE);
56+
57+
continue;
58+
}
59+
60+
$this->write(sprintf('Patching file "%s"', $patch['file']));
61+
62+
if (!isset($patch['position'])) {
63+
$this->write(sprintf('The "position" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName()));
64+
65+
continue;
66+
}
67+
$position = $patch['position'];
68+
if (!\in_array($position, self::VALID_POSITIONS, true)) {
69+
$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()));
70+
71+
continue;
72+
}
73+
74+
if (self::POSITION_AFTER_TARGET === $position && !isset($patch['target'])) {
75+
$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()));
76+
77+
continue;
78+
}
79+
$target = isset($patch['target']) ? $patch['target'] : null;
80+
81+
$this->patchFile($file, $content, $position, $target, $warnIfMissing);
82+
}
83+
}
84+
85+
public function unconfigure(Recipe $recipe, $config, Lock $lock): void
86+
{
87+
foreach ($config as $patch) {
88+
if (!isset($patch['file'])) {
89+
$this->write(sprintf('The "file" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName()));
90+
91+
continue;
92+
}
93+
94+
// Ignore "requires": the target packages may have just become uninstalled.
95+
// Checking for a "content" match is enough.
96+
97+
$file = $this->path->concatenate([$this->options->get('root-dir'), $patch['file']]);
98+
if (!is_file($file)) {
99+
continue;
100+
}
101+
102+
if (!isset($patch['content'])) {
103+
$this->write(sprintf('The "content" key is required for the "add-lines" configurator for recipe "%s". Skipping', $recipe->getName()));
104+
105+
continue;
106+
}
107+
$value = $patch['content'];
108+
109+
$this->unPatchFile($file, $value);
110+
}
111+
}
112+
113+
public function update(RecipeUpdate $recipeUpdate, array $originalConfig, array $newConfig): void
114+
{
115+
$originalConfig = array_filter($originalConfig, function ($item) {
116+
return !isset($item['requires']) || $this->isPackageInstalled($item['requires']);
117+
});
118+
$newConfig = array_filter($newConfig, function ($item) {
119+
return !isset($item['requires']) || $this->isPackageInstalled($item['requires']);
120+
});
121+
122+
$filterDuplicates = function (array $sourceConfig, array $comparisonConfig) {
123+
$filtered = [];
124+
foreach ($sourceConfig as $sourceItem) {
125+
$found = false;
126+
foreach ($comparisonConfig as $comparisonItem) {
127+
if ($sourceItem['file'] === $comparisonItem['file'] && $sourceItem['content'] === $comparisonItem['content']) {
128+
$found = true;
129+
break;
130+
}
131+
}
132+
if (!$found) {
133+
$filtered[] = $sourceItem;
134+
}
135+
}
136+
137+
return $filtered;
138+
};
139+
140+
// remove any config where the file+value is the same before & after
141+
$filteredOriginalConfig = $filterDuplicates($originalConfig, $newConfig);
142+
$filteredNewConfig = $filterDuplicates($newConfig, $originalConfig);
143+
144+
$this->unconfigure($recipeUpdate->getOriginalRecipe(), $filteredOriginalConfig, $recipeUpdate->getLock());
145+
$this->configure($recipeUpdate->getNewRecipe(), $filteredNewConfig, $recipeUpdate->getLock());
146+
}
147+
148+
private function patchFile(string $file, string $value, string $position, ?string $target, bool $warnIfMissing)
149+
{
150+
$fileContents = file_get_contents($file);
151+
152+
if (false !== strpos($fileContents, $value)) {
153+
return; // already includes value, skip
154+
}
155+
156+
switch ($position) {
157+
case self::POSITION_BOTTOM:
158+
$fileContents .= "\n".$value;
159+
160+
break;
161+
case self::POSITION_TOP:
162+
$fileContents = $value."\n".$fileContents;
163+
164+
break;
165+
case self::POSITION_AFTER_TARGET:
166+
$lines = explode("\n", $fileContents);
167+
$targetFound = false;
168+
foreach ($lines as $key => $line) {
169+
if (false !== strpos($line, $target)) {
170+
array_splice($lines, $key + 1, 0, $value);
171+
$targetFound = true;
172+
173+
break;
174+
}
175+
}
176+
$fileContents = implode("\n", $lines);
177+
178+
if (!$targetFound) {
179+
$this->write([
180+
sprintf('Could not add lines after "%s" as no such string was found in "%s". Missing lines:', $target, $file),
181+
'<comment>"""</comment>',
182+
$value,
183+
'<comment>"""</comment>',
184+
'',
185+
], $warnIfMissing ? IOInterface::NORMAL : IOInterface::VERBOSE);
186+
}
187+
188+
break;
189+
}
190+
191+
file_put_contents($file, $fileContents);
192+
}
193+
194+
private function unPatchFile(string $file, $value)
195+
{
196+
$fileContents = file_get_contents($file);
197+
198+
if (false === strpos($fileContents, $value)) {
199+
return; // value already gone!
200+
}
201+
202+
if (false !== strpos($fileContents, "\n".$value)) {
203+
$value = "\n".$value;
204+
} elseif (false !== strpos($fileContents, $value."\n")) {
205+
$value = $value."\n";
206+
}
207+
208+
$position = strpos($fileContents, $value);
209+
$fileContents = substr_replace($fileContents, '', $position, \strlen($value));
210+
211+
file_put_contents($file, $fileContents);
212+
}
213+
214+
private function isPackageInstalled($packages): bool
215+
{
216+
if (\is_string($packages)) {
217+
$packages = [$packages];
218+
}
219+
220+
$installedRepo = $this->composer->getRepositoryManager()->getLocalRepository();
221+
222+
foreach ($packages as $packageName) {
223+
if (null === $installedRepo->findPackage($packageName, '*')) {
224+
return false;
225+
}
226+
}
227+
228+
return true;
229+
}
230+
}

src/Flex.php

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,7 @@ public function install(Event $event)
473473
$installContribs = $this->composer->getPackage()->getExtra()['symfony']['allow-contrib'] ?? false;
474474
$manifest = null;
475475
$originalComposerJsonHash = $this->getComposerJsonHash();
476+
$postInstallRecipes = [];
476477
foreach ($recipes as $recipe) {
477478
if ('install' === $recipe->getJob() && !$installContribs && $recipe->isContrib()) {
478479
$warning = $this->io->isInteractive() ? 'WARNING' : 'IGNORING';
@@ -519,6 +520,7 @@ function ($value) {
519520

520521
switch ($recipe->getJob()) {
521522
case 'install':
523+
$postInstallRecipes[] = $recipe;
522524
$this->io->writeError(sprintf(' - Configuring %s', $this->formatOrigin($recipe)));
523525
$this->configurator->install($recipe, $this->lock, [
524526
'force' => $event instanceof UpdateEvent && $event->force(),
@@ -542,6 +544,12 @@ function ($value) {
542544
}
543545
}
544546

547+
foreach ($postInstallRecipes as $recipe) {
548+
$this->configurator->postInstall($recipe, $this->lock, [
549+
'force' => $event instanceof UpdateEvent && $event->force(),
550+
]);
551+
}
552+
545553
if (null !== $manifest) {
546554
array_unshift(
547555
$this->postInstallOutput,
@@ -572,17 +580,12 @@ private function synchronizePackageJson(string $rootDir)
572580
$rootDir = realpath($rootDir);
573581
$vendorDir = trim((new Filesystem())->makePathRelative($this->config->get('vendor-dir'), $rootDir), '/');
574582

575-
$synchronizer = new PackageJsonSynchronizer($rootDir, $vendorDir);
583+
$executor = new ScriptExecutor($this->composer, $this->io, $this->options);
584+
$synchronizer = new PackageJsonSynchronizer($rootDir, $vendorDir, $executor);
576585

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

580-
if (method_exists($synchronizer, 'addPackageJsonLink') && 'string' === (new \ReflectionParameter([$synchronizer, 'addPackageJsonLink'], 'phpPackage'))->getType()->getName()) {
581-
// support for smooth upgrades from older flex versions
582-
$lockData['packages'] = array_column($lockData['packages'] ?? [], 'name');
583-
$lockData['packages-dev'] = array_column($lockData['packages-dev'] ?? [], 'name');
584-
}
585-
586589
if ($synchronizer->synchronize(array_merge($lockData['packages'] ?? [], $lockData['packages-dev'] ?? []))) {
587590
$this->io->writeError('<info>Synchronizing package.json with PHP packages</>');
588591
$this->io->writeError('<warning>Don\'t forget to run npm install --force or yarn install --force to refresh your JavaScript dependencies!</>');
@@ -773,7 +776,7 @@ public function fetchRecipes(array $operations, bool $reset): array
773776
$job = method_exists($operation, 'getOperationType') ? $operation->getOperationType() : $operation->getJobType();
774777

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

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

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

789792
continue 2;
790793
}

0 commit comments

Comments
 (0)