Passed
Push — trunk ( f797a9...e92141 )
by Christian
22:24 queued 10:00
created

ThemeCompiler::concatenateStyles()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 13
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 6
nc 2
nop 3
dl 0
loc 13
rs 10
c 0
b 0
f 0
1
<?php declare(strict_types=1);
2
3
namespace Shopware\Storefront\Theme;
4
5
use League\Flysystem\FilesystemOperator;
6
use Padaliyajay\PHPAutoprefixer\Autoprefixer;
7
use ScssPhp\ScssPhp\OutputStyle;
8
use Shopware\Core\Framework\Adapter\Cache\CacheInvalidator;
9
use Shopware\Core\Framework\Adapter\Filesystem\Plugin\CopyBatch;
10
use Shopware\Core\Framework\Context;
11
use Shopware\Core\Framework\Feature;
12
use Shopware\Core\Framework\Uuid\Uuid;
13
use Shopware\Storefront\Event\ThemeCompilerConcatenatedScriptsEvent;
14
use Shopware\Storefront\Event\ThemeCompilerConcatenatedStylesEvent;
15
use Shopware\Storefront\Theme\Event\ThemeCompilerEnrichScssVariablesEvent;
16
use Shopware\Storefront\Theme\Exception\InvalidThemeException;
17
use Shopware\Storefront\Theme\Exception\ThemeCompileException;
18
use Shopware\Storefront\Theme\Message\DeleteThemeFilesMessage;
19
use Shopware\Storefront\Theme\StorefrontPluginConfiguration\FileCollection;
20
use Shopware\Storefront\Theme\StorefrontPluginConfiguration\StorefrontPluginConfiguration;
21
use Shopware\Storefront\Theme\StorefrontPluginConfiguration\StorefrontPluginConfigurationCollection;
22
use Symfony\Component\Asset\Package;
23
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
24
use Symfony\Component\Messenger\Envelope;
25
use Symfony\Component\Messenger\MessageBusInterface;
26
use Symfony\Component\Messenger\Stamp\DelayStamp;
27
28
/**
29
 * @package storefront
30
 */
31
class ThemeCompiler implements ThemeCompilerInterface
32
{
33
    /**
34
     * @internal
35
     *
36
     * @param Package[] $packages
37
     */
38
    public function __construct(
39
        private FilesystemOperator $filesystem,
40
        private FilesystemOperator $tempFilesystem,
41
        private ThemeFileResolver $themeFileResolver,
42
        private bool $debug,
43
        private EventDispatcherInterface $eventDispatcher,
44
        private ThemeFileImporterInterface $themeFileImporter,
45
        private iterable $packages,
46
        private CacheInvalidator $logger,
47
        private AbstractThemePathBuilder $themePathBuilder,
48
        private string $projectDir,
49
        private AbstractScssCompiler $scssCompiler,
50
        private MessageBusInterface $messageBus
51
    ) {
52
    }
53
54
    public function compileTheme(
55
        string $salesChannelId,
56
        string $themeId,
57
        StorefrontPluginConfiguration $themeConfig,
58
        StorefrontPluginConfigurationCollection $configurationCollection,
59
        bool $withAssets,
60
        Context $context
61
    ): void {
62
        $resolvedFiles = $this->themeFileResolver->resolveFiles($themeConfig, $configurationCollection, false);
63
64
        $styleFiles = $resolvedFiles[ThemeFileResolver::STYLE_FILES];
65
66
        $concatenatedStyles = $this->concatenateStyles(
67
            $styleFiles,
68
            $themeConfig,
69
            $salesChannelId
70
        );
71
72
        $compiled = $this->compileStyles(
73
            $concatenatedStyles,
74
            $themeConfig,
75
            $styleFiles->getResolveMappings(),
76
            $salesChannelId,
77
            $context
78
        );
79
80
        $concatenatedScripts = $this->getConcatenatedScripts($resolvedFiles[ThemeFileResolver::SCRIPT_FILES], $themeConfig, $salesChannelId);
81
82
        $newThemeHash = Uuid::randomHex();
83
        $themePrefix = $this->themePathBuilder->generateNewPath($salesChannelId, $themeId, $newThemeHash);
0 ignored issues
show
Deprecated Code introduced by
The function Shopware\Storefront\Them...lder::generateNewPath() has been deprecated: tag:v6.6.0 - Method will be abstract in v6.6.0, so implement the method in your implementations ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

83
        $themePrefix = /** @scrutinizer ignore-deprecated */ $this->themePathBuilder->generateNewPath($salesChannelId, $themeId, $newThemeHash);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
84
        $oldThemePrefix = $this->themePathBuilder->assemblePath($salesChannelId, $themeId);
85
86
        try {
87
            $this->writeCompiledFiles($themePrefix, $compiled, $concatenatedScripts, $withAssets, $themeConfig, $configurationCollection);
88
        } catch (\Throwable $e) {
89
            // delete folder in case of error and rethrow exception
90
            if ($themePrefix !== $oldThemePrefix) {
91
                $this->filesystem->deleteDirectory($themePrefix);
92
            }
93
94
            throw $e;
95
        }
96
97
        $this->themePathBuilder->saveSeed($salesChannelId, $themeId, $newThemeHash);
0 ignored issues
show
Deprecated Code introduced by
The function Shopware\Storefront\Them...PathBuilder::saveSeed() has been deprecated: tag:v6.6.0 - Method will be abstract in v6.6.0, so implement the method in your implementations ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-deprecated  annotation

97
        /** @scrutinizer ignore-deprecated */ $this->themePathBuilder->saveSeed($salesChannelId, $themeId, $newThemeHash);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
98
99
        if ($themePrefix !== $oldThemePrefix) {
100
            // only delete the old directory if the `themePathBuilder` actually returned a new path and supports seeding
101
            // also delete with a delay of one our, so that the old theme is still available for a while in case some CDN delivers stale content
102
            $this->messageBus->dispatch(
103
                new Envelope(
104
                    new DeleteThemeFilesMessage($oldThemePrefix, $salesChannelId, $themeId),
105
                    // one hour in milliseconds
106
                    [new DelayStamp(3600 * 1000)]
107
                )
108
            );
109
        }
110
111
        // Reset cache buster state for improving performance in getMetadata
112
        $this->logger->invalidate(['theme-metaData'], true);
113
    }
114
115
    /**
116
     * @param array<string, string> $resolveMappings
117
     */
118
    public function getResolveImportPathsCallback(array $resolveMappings): \Closure
119
    {
120
        return function ($originalPath) use ($resolveMappings) {
121
            foreach ($resolveMappings as $resolve => $resolvePath) {
122
                $resolve = '~' . $resolve;
123
                if (mb_strpos($originalPath, $resolve) === 0) {
124
                    $dirname = $resolvePath . \dirname(mb_substr($originalPath, mb_strlen($resolve)));
125
126
                    $filename = basename($originalPath);
127
                    $extension = $this->getImportFileExtension(pathinfo($filename, \PATHINFO_EXTENSION));
0 ignored issues
show
Bug introduced by
It seems like pathinfo($filename, PATHINFO_EXTENSION) can also be of type array; however, parameter $extension of Shopware\Storefront\Them...etImportFileExtension() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

127
                    $extension = $this->getImportFileExtension(/** @scrutinizer ignore-type */ pathinfo($filename, \PATHINFO_EXTENSION));
Loading history...
128
                    $path = $dirname . \DIRECTORY_SEPARATOR . $filename . $extension;
129
                    if (file_exists($path)) {
130
                        return $path;
131
                    }
132
133
                    $path = $dirname . \DIRECTORY_SEPARATOR . '_' . $filename . $extension;
134
                    if (file_exists($path)) {
135
                        return $path;
136
                    }
137
                }
138
            }
139
140
            return null;
141
        };
142
    }
143
144
    private function copyAssets(
145
        StorefrontPluginConfiguration $configuration,
146
        StorefrontPluginConfigurationCollection $configurationCollection,
147
        string $outputPath
148
    ): void {
149
        if (!$configuration->getAssetPaths()) {
150
            return;
151
        }
152
153
        foreach ($configuration->getAssetPaths() as $asset) {
154
            if (mb_strpos($asset, '@') === 0) {
155
                $name = mb_substr($asset, 1);
156
                $config = $configurationCollection->getByTechnicalName($name);
157
                if (!$config) {
158
                    throw new InvalidThemeException($name);
159
                }
160
161
                $this->copyAssets($config, $configurationCollection, $outputPath);
162
163
                continue;
164
            }
165
166
            if ($asset[0] !== '/' && file_exists($this->projectDir . '/' . $asset)) {
167
                $asset = $this->projectDir . '/' . $asset;
168
            }
169
170
            $assets = $this->themeFileImporter->getCopyBatchInputsForAssets($asset, $outputPath, $configuration);
171
172
            // method copyBatch is provided by copyBatch filesystem plugin
173
            CopyBatch::copy($this->filesystem, ...$assets);
174
        }
175
    }
176
177
    /**
178
     * @param array<string, string> $resolveMappings
179
     */
180
    private function compileStyles(
181
        string $concatenatedStyles,
182
        StorefrontPluginConfiguration $configuration,
183
        array $resolveMappings,
184
        string $salesChannelId,
185
        Context $context
186
    ): string {
187
        $variables = $this->dumpVariables($configuration->getThemeConfig() ?? [], $salesChannelId, $context);
188
        $features = $this->getFeatureConfigScssMap();
189
190
        $resolveImportPath = $this->getResolveImportPathsCallback($resolveMappings);
191
192
        $importPaths = [];
193
194
        $cwd = \getcwd();
195
        if ($cwd !== false) {
196
            $importPaths[] = $cwd;
197
        }
198
199
        $importPaths[] = $resolveImportPath;
200
201
        $compilerConfig = new CompilerConfiguration(
202
            [
203
                'importPaths' => $importPaths,
204
                'outputStyle' => $this->debug ? OutputStyle::EXPANDED : OutputStyle::COMPRESSED,
205
            ]
206
        );
207
208
        try {
209
            $cssOutput = $this->scssCompiler->compileString(
210
                $compilerConfig,
211
                $features . $variables . $concatenatedStyles
212
            );
213
        } catch (\Throwable $exception) {
214
            throw new ThemeCompileException(
215
                $configuration->getTechnicalName(),
216
                $exception->getMessage()
217
            );
218
        }
219
        $autoPreFixer = new Autoprefixer($cssOutput);
220
        /** @var string|false $compiled */
221
        $compiled = $autoPreFixer->compile($this->debug);
222
        if ($compiled === false) {
223
            throw new ThemeCompileException(
224
                $configuration->getTechnicalName(),
225
                'CSS parser not initialized'
226
            );
227
        }
228
229
        return $compiled;
230
    }
231
232
    private function getImportFileExtension(string $extension): string
233
    {
234
        // If the import has no extension, it must be a SCSS module.
235
        if ($extension === '') {
236
            return '.scss';
237
        }
238
239
        // If the import has a .min extension, we assume it must be a compiled CSS file.
240
        if ($extension === 'min') {
241
            return '.css';
242
        }
243
244
        // If it has any other extension, we don't assume a specific extension.
245
        return '';
246
    }
247
248
    /**
249
     * Converts the feature config array to a SCSS map syntax.
250
     * This allows reading of the feature flag config inside SCSS via `map.get` function.
251
     *
252
     * Output example:
253
     * $sw-features: ("FEATURE_NEXT_1234": false, "FEATURE_NEXT_1235": true);
254
     *
255
     * @see https://sass-lang.com/documentation/values/maps
256
     */
257
    private function getFeatureConfigScssMap(): string
258
    {
259
        $allFeatures = Feature::getAll();
260
261
        $featuresScss = implode(',', array_map(function ($value, $key) {
262
            return sprintf('"%s": %s', $key, json_encode($value));
263
        }, $allFeatures, array_keys($allFeatures)));
264
265
        return sprintf('$sw-features: (%s);', $featuresScss);
266
    }
267
268
    /**
269
     * @param array<string, string> $variables
270
     *
271
     * @return array<string>
272
     */
273
    private function formatVariables(array $variables): array
274
    {
275
        return array_map(function ($value, $key) {
276
            return sprintf('$%s: %s;', $key, (!empty($value) ? $value : 0));
277
        }, $variables, array_keys($variables));
278
    }
279
280
    /**
281
     * @param array{fields?: array{value: null|string|array<mixed>, scss?: bool, type: string}[]} $config
282
     */
283
    private function dumpVariables(array $config, string $salesChannelId, Context $context): string
284
    {
285
        $variables = [];
286
        foreach ($config['fields'] ?? [] as $key => $data) {
287
            if (!\is_array($data) || !$this->isDumpable($data)) {
288
                continue;
289
            }
290
291
            if (\in_array($data['type'], ['media', 'textarea'], true) && \is_string($data['value'])) {
292
                $variables[$key] = '\'' . $data['value'] . '\'';
293
            } elseif ($data['type'] === 'switch' || $data['type'] === 'checkbox') {
294
                $variables[$key] = (int) ($data['value']);
295
            } else {
296
                $variables[$key] = $data['value'];
297
            }
298
        }
299
300
        foreach ($this->packages as $key => $package) {
301
            $variables[sprintf('sw-asset-%s-url', $key)] = sprintf('\'%s\'', $package->getUrl(''));
302
        }
303
304
        $themeVariablesEvent = new ThemeCompilerEnrichScssVariablesEvent(
305
            $variables,
306
            $salesChannelId,
307
            $context
308
        );
309
310
        $this->eventDispatcher->dispatch($themeVariablesEvent);
311
312
        $dump = str_replace(
313
            ['#class#', '#variables#'],
314
            [self::class, implode(\PHP_EOL, $this->formatVariables($themeVariablesEvent->getVariables()))],
315
            $this->getVariableDumpTemplate()
316
        );
317
318
        $this->tempFilesystem->write('theme-variables.scss', $dump);
319
320
        return $dump;
321
    }
322
323
    /**
324
     * @param array{value: string|array<mixed>|null, scss?: bool, type: string} $data
325
     */
326
    private function isDumpable(array $data): bool
327
    {
328
        if (!isset($data['value'])) {
329
            return false;
330
        }
331
332
        // Do not include fields which have the scss option set to false
333
        if (\array_key_exists('scss', $data) && $data['scss'] === false) {
334
            return false;
335
        }
336
337
        // Do not include fields which haa an array as value
338
        if (\is_array($data['value'])) {
339
            return false;
340
        }
341
342
        // value must not be an empty string since because an empty value can not be compiled
343
        if ($data['value'] === '') {
344
            return false;
345
        }
346
347
        // if no type is set just use the value and continue
348
        if (!isset($data['type'])) {
349
            return false;
350
        }
351
352
        return true;
353
    }
354
355
    private function getVariableDumpTemplate(): string
356
    {
357
        return <<<PHP_EOL
358
// ATTENTION! This file is auto generated by the #class# and should not be edited.
359
360
#variables#
361
362
PHP_EOL;
363
    }
364
365
    private function concatenateStyles(
366
        FileCollection $styleFiles,
367
        StorefrontPluginConfiguration $themeConfig,
368
        string $salesChannelId
369
    ): string {
370
        $concatenatedStyles = '';
371
        foreach ($styleFiles as $file) {
372
            $concatenatedStyles .= $this->themeFileImporter->getConcatenableStylePath($file, $themeConfig);
0 ignored issues
show
Bug introduced by
$file of type array is incompatible with the type Shopware\Storefront\Them...luginConfiguration\File expected by parameter $file of Shopware\Storefront\Them...ConcatenableStylePath(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

372
            $concatenatedStyles .= $this->themeFileImporter->getConcatenableStylePath(/** @scrutinizer ignore-type */ $file, $themeConfig);
Loading history...
373
        }
374
        $concatenatedStylesEvent = new ThemeCompilerConcatenatedStylesEvent($concatenatedStyles, $salesChannelId);
375
        $this->eventDispatcher->dispatch($concatenatedStylesEvent);
376
377
        return $concatenatedStylesEvent->getConcatenatedStyles();
378
    }
379
380
    private function getConcatenatedScripts(
381
        FileCollection $scriptFiles,
382
        StorefrontPluginConfiguration $themeConfig,
383
        string $salesChannelId
384
    ): string {
385
        $concatenatedScripts = '';
386
        foreach ($scriptFiles as $file) {
387
            $concatenatedScripts .= $this->themeFileImporter->getConcatenableScriptPath($file, $themeConfig);
0 ignored issues
show
Bug introduced by
$file of type array is incompatible with the type Shopware\Storefront\Them...luginConfiguration\File expected by parameter $file of Shopware\Storefront\Them...oncatenableScriptPath(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

387
            $concatenatedScripts .= $this->themeFileImporter->getConcatenableScriptPath(/** @scrutinizer ignore-type */ $file, $themeConfig);
Loading history...
388
        }
389
390
        $concatenatedScriptsEvent = new ThemeCompilerConcatenatedScriptsEvent($concatenatedScripts, $salesChannelId);
391
        $this->eventDispatcher->dispatch($concatenatedScriptsEvent);
392
393
        return $concatenatedScriptsEvent->getConcatenatedScripts();
394
    }
395
396
    private function writeCompiledFiles(
397
        string $themePrefix,
398
        string $compiled,
399
        string $concatenatedScripts,
400
        bool $withAssets,
401
        StorefrontPluginConfiguration $themeConfig,
402
        StorefrontPluginConfigurationCollection $configurationCollection
403
    ): void {
404
        $path = 'theme' . \DIRECTORY_SEPARATOR . $themePrefix;
405
406
        if ($this->filesystem->has($path)) {
407
            $this->filesystem->deleteDirectory($path);
408
        }
409
410
        $cssFilePath = $path . \DIRECTORY_SEPARATOR . 'css' . \DIRECTORY_SEPARATOR . 'all.css';
411
        $this->filesystem->write($cssFilePath, $compiled);
412
413
        $scriptFilepath = $path . \DIRECTORY_SEPARATOR . 'js' . \DIRECTORY_SEPARATOR . 'all.js';
414
        $this->filesystem->write($scriptFilepath, $concatenatedScripts);
415
416
        // assets
417
        if ($withAssets) {
418
            $this->copyAssets($themeConfig, $configurationCollection, $path);
419
        }
420
    }
421
}
422