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
|
|
|
#[\Shopware\Core\Framework\Log\Package('storefront')] |
29
|
|
|
class ThemeCompiler implements ThemeCompilerInterface |
30
|
|
|
{ |
31
|
|
|
/** |
32
|
|
|
* @internal |
33
|
|
|
* |
34
|
|
|
* @param Package[] $packages |
35
|
|
|
*/ |
36
|
|
|
public function __construct( |
37
|
|
|
private readonly FilesystemOperator $filesystem, |
38
|
|
|
private readonly FilesystemOperator $tempFilesystem, |
39
|
|
|
private readonly ThemeFileResolver $themeFileResolver, |
40
|
|
|
private readonly bool $debug, |
41
|
|
|
private readonly EventDispatcherInterface $eventDispatcher, |
42
|
|
|
private readonly ThemeFileImporterInterface $themeFileImporter, |
43
|
|
|
private readonly iterable $packages, |
44
|
|
|
private readonly CacheInvalidator $logger, |
45
|
|
|
private readonly AbstractThemePathBuilder $themePathBuilder, |
46
|
|
|
private readonly string $projectDir, |
47
|
|
|
private readonly AbstractScssCompiler $scssCompiler, |
48
|
|
|
private readonly MessageBusInterface $messageBus, |
49
|
|
|
private readonly int $themeFileDeleteDelay, |
50
|
|
|
private readonly bool $autoPrefix = false |
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
|
|
|
$themeId, |
78
|
|
|
$context |
79
|
|
|
); |
80
|
|
|
|
81
|
|
|
$concatenatedScripts = $this->getConcatenatedScripts($resolvedFiles[ThemeFileResolver::SCRIPT_FILES], $themeConfig, $salesChannelId); |
82
|
|
|
|
83
|
|
|
$newThemeHash = Uuid::randomHex(); |
84
|
|
|
$themePrefix = $this->themePathBuilder->generateNewPath($salesChannelId, $themeId, $newThemeHash); |
|
|
|
|
85
|
|
|
$oldThemePrefix = $this->themePathBuilder->assemblePath($salesChannelId, $themeId); |
86
|
|
|
|
87
|
|
|
try { |
88
|
|
|
$this->writeCompiledFiles($themePrefix, $themeId, $compiled, $concatenatedScripts, $withAssets, $themeConfig, $configurationCollection); |
89
|
|
|
} catch (\Throwable $e) { |
90
|
|
|
// delete folder in case of error and rethrow exception |
91
|
|
|
if ($themePrefix !== $oldThemePrefix) { |
92
|
|
|
$this->filesystem->deleteDirectory($themePrefix); |
93
|
|
|
} |
94
|
|
|
|
95
|
|
|
throw $e; |
96
|
|
|
} |
97
|
|
|
|
98
|
|
|
$this->themePathBuilder->saveSeed($salesChannelId, $themeId, $newThemeHash); |
|
|
|
|
99
|
|
|
|
100
|
|
|
// only delete the old directory if the `themePathBuilder` actually returned a new path and supports seeding |
101
|
|
|
if ($themePrefix !== $oldThemePrefix) { |
102
|
|
|
$stamps = []; |
103
|
|
|
|
104
|
|
|
if ($this->themeFileDeleteDelay > 0) { |
105
|
|
|
// also delete with a delay, so that the old theme is still available for a while in case some CDN delivers stale content |
106
|
|
|
// delay is configured in seconds, symfony expects milliseconds |
107
|
|
|
$stamps[] = new DelayStamp($this->themeFileDeleteDelay * 1000); |
108
|
|
|
} |
109
|
|
|
$this->messageBus->dispatch( |
110
|
|
|
new Envelope( |
111
|
|
|
new DeleteThemeFilesMessage($oldThemePrefix, $salesChannelId, $themeId), |
112
|
|
|
$stamps |
113
|
|
|
) |
114
|
|
|
); |
115
|
|
|
} |
116
|
|
|
|
117
|
|
|
// Reset cache buster state for improving performance in getMetadata |
118
|
|
|
$this->logger->invalidate(['theme-metaData'], true); |
119
|
|
|
} |
120
|
|
|
|
121
|
|
|
/** |
122
|
|
|
* @param array<string, string> $resolveMappings |
123
|
|
|
*/ |
124
|
|
|
public function getResolveImportPathsCallback(array $resolveMappings): \Closure |
125
|
|
|
{ |
126
|
|
|
return function ($originalPath) use ($resolveMappings) { |
127
|
|
|
foreach ($resolveMappings as $resolve => $resolvePath) { |
128
|
|
|
$resolve = '~' . $resolve; |
129
|
|
|
if (mb_strpos($originalPath, $resolve) === 0) { |
130
|
|
|
$dirname = $resolvePath . \dirname(mb_substr($originalPath, mb_strlen($resolve))); |
131
|
|
|
|
132
|
|
|
$filename = basename($originalPath); |
133
|
|
|
$extension = $this->getImportFileExtension(pathinfo($filename, \PATHINFO_EXTENSION)); |
|
|
|
|
134
|
|
|
$path = $dirname . \DIRECTORY_SEPARATOR . $filename . $extension; |
135
|
|
|
if (file_exists($path)) { |
136
|
|
|
return $path; |
137
|
|
|
} |
138
|
|
|
|
139
|
|
|
$path = $dirname . \DIRECTORY_SEPARATOR . '_' . $filename . $extension; |
140
|
|
|
if (file_exists($path)) { |
141
|
|
|
return $path; |
142
|
|
|
} |
143
|
|
|
} |
144
|
|
|
} |
145
|
|
|
|
146
|
|
|
return null; |
147
|
|
|
}; |
148
|
|
|
} |
149
|
|
|
|
150
|
|
|
private function copyAssets( |
151
|
|
|
StorefrontPluginConfiguration $configuration, |
152
|
|
|
StorefrontPluginConfigurationCollection $configurationCollection, |
153
|
|
|
string $outputPath |
154
|
|
|
): void { |
155
|
|
|
if (!$configuration->getAssetPaths()) { |
156
|
|
|
return; |
157
|
|
|
} |
158
|
|
|
|
159
|
|
|
foreach ($configuration->getAssetPaths() as $asset) { |
160
|
|
|
if (mb_strpos((string) $asset, '@') === 0) { |
161
|
|
|
$name = mb_substr((string) $asset, 1); |
162
|
|
|
$config = $configurationCollection->getByTechnicalName($name); |
163
|
|
|
if (!$config) { |
164
|
|
|
throw new InvalidThemeException($name); |
165
|
|
|
} |
166
|
|
|
|
167
|
|
|
$this->copyAssets($config, $configurationCollection, $outputPath); |
168
|
|
|
|
169
|
|
|
continue; |
170
|
|
|
} |
171
|
|
|
|
172
|
|
|
if ($asset[0] !== '/' && file_exists($this->projectDir . '/' . $asset)) { |
173
|
|
|
$asset = $this->projectDir . '/' . $asset; |
174
|
|
|
} |
175
|
|
|
|
176
|
|
|
$assets = $this->themeFileImporter->getCopyBatchInputsForAssets($asset, $outputPath, $configuration); |
177
|
|
|
|
178
|
|
|
CopyBatch::copy($this->filesystem, ...$assets); |
179
|
|
|
} |
180
|
|
|
} |
181
|
|
|
|
182
|
|
|
/** |
183
|
|
|
* @param array<string, string> $resolveMappings |
184
|
|
|
*/ |
185
|
|
|
private function compileStyles( |
186
|
|
|
string $concatenatedStyles, |
187
|
|
|
StorefrontPluginConfiguration $configuration, |
188
|
|
|
array $resolveMappings, |
189
|
|
|
string $salesChannelId, |
190
|
|
|
string $themeId, |
191
|
|
|
Context $context |
192
|
|
|
): string { |
193
|
|
|
$variables = $this->dumpVariables($configuration->getThemeConfig() ?? [], $themeId, $salesChannelId, $context); |
194
|
|
|
$features = $this->getFeatureConfigScssMap(); |
195
|
|
|
|
196
|
|
|
$resolveImportPath = $this->getResolveImportPathsCallback($resolveMappings); |
197
|
|
|
|
198
|
|
|
$importPaths = []; |
199
|
|
|
|
200
|
|
|
$cwd = \getcwd(); |
201
|
|
|
if ($cwd !== false) { |
202
|
|
|
$importPaths[] = $cwd; |
203
|
|
|
} |
204
|
|
|
|
205
|
|
|
$importPaths[] = $resolveImportPath; |
206
|
|
|
|
207
|
|
|
$compilerConfig = new CompilerConfiguration( |
208
|
|
|
[ |
209
|
|
|
'importPaths' => $importPaths, |
210
|
|
|
'outputStyle' => $this->debug ? OutputStyle::EXPANDED : OutputStyle::COMPRESSED, |
211
|
|
|
] |
212
|
|
|
); |
213
|
|
|
|
214
|
|
|
try { |
215
|
|
|
$cssOutput = $this->scssCompiler->compileString( |
216
|
|
|
$compilerConfig, |
217
|
|
|
$features . $variables . $concatenatedStyles |
218
|
|
|
); |
219
|
|
|
} catch (\Throwable $exception) { |
220
|
|
|
throw new ThemeCompileException( |
221
|
|
|
$configuration->getTechnicalName(), |
222
|
|
|
$exception->getMessage() |
223
|
|
|
); |
224
|
|
|
} |
225
|
|
|
|
226
|
|
|
if ($this->autoPrefix === true) { |
227
|
|
|
$autoPreFixer = new Autoprefixer($cssOutput); |
228
|
|
|
/** @var string|false $cssOutput */ |
229
|
|
|
$cssOutput = $autoPreFixer->compile($this->debug); |
230
|
|
|
if ($cssOutput === false) { |
231
|
|
|
throw new ThemeCompileException( |
232
|
|
|
$configuration->getTechnicalName(), |
233
|
|
|
'CSS parser not initialized' |
234
|
|
|
); |
235
|
|
|
} |
236
|
|
|
} |
237
|
|
|
|
238
|
|
|
return $cssOutput; |
239
|
|
|
} |
240
|
|
|
|
241
|
|
|
private function getImportFileExtension(string $extension): string |
242
|
|
|
{ |
243
|
|
|
// If the import has no extension, it must be a SCSS module. |
244
|
|
|
if ($extension === '') { |
245
|
|
|
return '.scss'; |
246
|
|
|
} |
247
|
|
|
|
248
|
|
|
// If the import has a .min extension, we assume it must be a compiled CSS file. |
249
|
|
|
if ($extension === 'min') { |
250
|
|
|
return '.css'; |
251
|
|
|
} |
252
|
|
|
|
253
|
|
|
// If it has any other extension, we don't assume a specific extension. |
254
|
|
|
return ''; |
255
|
|
|
} |
256
|
|
|
|
257
|
|
|
/** |
258
|
|
|
* Converts the feature config array to a SCSS map syntax. |
259
|
|
|
* This allows reading of the feature flag config inside SCSS via `map.get` function. |
260
|
|
|
* |
261
|
|
|
* Output example: |
262
|
|
|
* $sw-features: ("FEATURE_NEXT_1234": false, "FEATURE_NEXT_1235": true); |
263
|
|
|
* |
264
|
|
|
* @see https://sass-lang.com/documentation/values/maps |
265
|
|
|
*/ |
266
|
|
|
private function getFeatureConfigScssMap(): string |
267
|
|
|
{ |
268
|
|
|
$allFeatures = Feature::getAll(); |
269
|
|
|
|
270
|
|
|
$featuresScss = implode(',', array_map(fn ($value, $key) => sprintf('"%s": %s', $key, json_encode($value, \JSON_THROW_ON_ERROR)), $allFeatures, array_keys($allFeatures))); |
271
|
|
|
|
272
|
|
|
return sprintf('$sw-features: (%s);', $featuresScss); |
273
|
|
|
} |
274
|
|
|
|
275
|
|
|
/** |
276
|
|
|
* @param array<string, string|int> $variables |
277
|
|
|
* |
278
|
|
|
* @return array<string> |
279
|
|
|
*/ |
280
|
|
|
private function formatVariables(array $variables): array |
281
|
|
|
{ |
282
|
|
|
return array_map(fn ($value, $key) => sprintf('$%s: %s;', $key, (!empty($value) ? $value : 0)), $variables, array_keys($variables)); |
283
|
|
|
} |
284
|
|
|
|
285
|
|
|
/** |
286
|
|
|
* @param array{fields?: array{value: null|string|array<mixed>, scss?: bool, type: string}[]} $config |
287
|
|
|
*/ |
288
|
|
|
private function dumpVariables(array $config, string $themeId, string $salesChannelId, Context $context): string |
289
|
|
|
{ |
290
|
|
|
$variables = [ |
291
|
|
|
'theme-id' => $themeId, |
292
|
|
|
]; |
293
|
|
|
|
294
|
|
|
foreach ($config['fields'] ?? [] as $key => $data) { |
295
|
|
|
if (!\is_array($data) || !$this->isDumpable($data)) { |
296
|
|
|
continue; |
297
|
|
|
} |
298
|
|
|
|
299
|
|
|
if (\in_array($data['type'], ['media', 'textarea'], true) && \is_string($data['value'])) { |
300
|
|
|
$variables[$key] = '\'' . $data['value'] . '\''; |
301
|
|
|
} elseif ($data['type'] === 'switch' || $data['type'] === 'checkbox') { |
302
|
|
|
$variables[$key] = (int) ($data['value']); |
303
|
|
|
} elseif (!\is_array($data['value'])) { |
304
|
|
|
$variables[$key] = (string) $data['value']; |
305
|
|
|
} |
306
|
|
|
} |
307
|
|
|
|
308
|
|
|
foreach ($this->packages as $key => $package) { |
309
|
|
|
$variables[sprintf('sw-asset-%s-url', $key)] = sprintf('\'%s\'', $package->getUrl('')); |
310
|
|
|
} |
311
|
|
|
|
312
|
|
|
$themeVariablesEvent = new ThemeCompilerEnrichScssVariablesEvent( |
313
|
|
|
$variables, |
314
|
|
|
$salesChannelId, |
315
|
|
|
$context |
316
|
|
|
); |
317
|
|
|
|
318
|
|
|
$this->eventDispatcher->dispatch($themeVariablesEvent); |
319
|
|
|
|
320
|
|
|
$dump = str_replace( |
321
|
|
|
['#class#', '#variables#'], |
322
|
|
|
[self::class, implode(\PHP_EOL, $this->formatVariables($themeVariablesEvent->getVariables()))], |
323
|
|
|
$this->getVariableDumpTemplate() |
324
|
|
|
); |
325
|
|
|
|
326
|
|
|
$this->tempFilesystem->write('theme-variables.scss', $dump); |
327
|
|
|
|
328
|
|
|
return $dump; |
329
|
|
|
} |
330
|
|
|
|
331
|
|
|
/** |
332
|
|
|
* @param array{value: string|array<mixed>|null, scss?: bool, type: string} $data |
333
|
|
|
*/ |
334
|
|
|
private function isDumpable(array $data): bool |
335
|
|
|
{ |
336
|
|
|
if (!isset($data['value'])) { |
337
|
|
|
return false; |
338
|
|
|
} |
339
|
|
|
|
340
|
|
|
// Do not include fields which have the scss option set to false |
341
|
|
|
if (\array_key_exists('scss', $data) && $data['scss'] === false) { |
342
|
|
|
return false; |
343
|
|
|
} |
344
|
|
|
|
345
|
|
|
// Do not include fields which haa an array as value |
346
|
|
|
if (\is_array($data['value'])) { |
347
|
|
|
return false; |
348
|
|
|
} |
349
|
|
|
|
350
|
|
|
// value must not be an empty string since because an empty value can not be compiled |
351
|
|
|
if ($data['value'] === '') { |
352
|
|
|
return false; |
353
|
|
|
} |
354
|
|
|
|
355
|
|
|
// if no type is set just use the value and continue |
356
|
|
|
if (!isset($data['type'])) { |
357
|
|
|
return false; |
358
|
|
|
} |
359
|
|
|
|
360
|
|
|
return true; |
361
|
|
|
} |
362
|
|
|
|
363
|
|
|
private function getVariableDumpTemplate(): string |
364
|
|
|
{ |
365
|
|
|
return <<<PHP_EOL |
366
|
|
|
// ATTENTION! This file is auto generated by the #class# and should not be edited. |
367
|
|
|
|
368
|
|
|
#variables# |
369
|
|
|
|
370
|
|
|
PHP_EOL; |
371
|
|
|
} |
372
|
|
|
|
373
|
|
|
private function concatenateStyles( |
374
|
|
|
FileCollection $styleFiles, |
375
|
|
|
StorefrontPluginConfiguration $themeConfig, |
376
|
|
|
string $salesChannelId |
377
|
|
|
): string { |
378
|
|
|
$concatenatedStyles = ''; |
379
|
|
|
foreach ($styleFiles as $file) { |
380
|
|
|
$concatenatedStyles .= $this->themeFileImporter->getConcatenableStylePath($file, $themeConfig); |
|
|
|
|
381
|
|
|
} |
382
|
|
|
$concatenatedStylesEvent = new ThemeCompilerConcatenatedStylesEvent($concatenatedStyles, $salesChannelId); |
383
|
|
|
$this->eventDispatcher->dispatch($concatenatedStylesEvent); |
384
|
|
|
|
385
|
|
|
return $concatenatedStylesEvent->getConcatenatedStyles(); |
386
|
|
|
} |
387
|
|
|
|
388
|
|
|
private function getConcatenatedScripts( |
389
|
|
|
FileCollection $scriptFiles, |
390
|
|
|
StorefrontPluginConfiguration $themeConfig, |
391
|
|
|
string $salesChannelId |
392
|
|
|
): string { |
393
|
|
|
$concatenatedScripts = ''; |
394
|
|
|
foreach ($scriptFiles as $file) { |
395
|
|
|
$concatenatedScripts .= $this->themeFileImporter->getConcatenableScriptPath($file, $themeConfig); |
|
|
|
|
396
|
|
|
} |
397
|
|
|
|
398
|
|
|
$concatenatedScriptsEvent = new ThemeCompilerConcatenatedScriptsEvent($concatenatedScripts, $salesChannelId); |
399
|
|
|
$this->eventDispatcher->dispatch($concatenatedScriptsEvent); |
400
|
|
|
|
401
|
|
|
return $concatenatedScriptsEvent->getConcatenatedScripts(); |
402
|
|
|
} |
403
|
|
|
|
404
|
|
|
private function writeCompiledFiles( |
405
|
|
|
string $themePrefix, |
406
|
|
|
string $themeId, |
407
|
|
|
string $compiled, |
408
|
|
|
string $concatenatedScripts, |
409
|
|
|
bool $withAssets, |
410
|
|
|
StorefrontPluginConfiguration $themeConfig, |
411
|
|
|
StorefrontPluginConfigurationCollection $configurationCollection |
412
|
|
|
): void { |
413
|
|
|
$path = 'theme' . \DIRECTORY_SEPARATOR . $themePrefix; |
414
|
|
|
|
415
|
|
|
if ($this->filesystem->has($path)) { |
416
|
|
|
$this->filesystem->deleteDirectory($path); |
417
|
|
|
} |
418
|
|
|
|
419
|
|
|
$cssFilePath = $path . \DIRECTORY_SEPARATOR . 'css' . \DIRECTORY_SEPARATOR . 'all.css'; |
420
|
|
|
$this->filesystem->write($cssFilePath, $compiled); |
421
|
|
|
|
422
|
|
|
$scriptFilepath = $path . \DIRECTORY_SEPARATOR . 'js' . \DIRECTORY_SEPARATOR . 'all.js'; |
423
|
|
|
$this->filesystem->write($scriptFilepath, $concatenatedScripts); |
424
|
|
|
|
425
|
|
|
// assets |
426
|
|
|
if ($withAssets) { |
427
|
|
|
$assetPath = 'theme' . \DIRECTORY_SEPARATOR . $themeId; |
428
|
|
|
if ($this->filesystem->has($assetPath)) { |
429
|
|
|
$this->filesystem->deleteDirectory($assetPath); |
430
|
|
|
} |
431
|
|
|
|
432
|
|
|
$this->copyAssets($themeConfig, $configurationCollection, $assetPath); |
433
|
|
|
} |
434
|
|
|
} |
435
|
|
|
} |
436
|
|
|
|
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.