From 31b3252be89d06228c0b8aea04ac2011428da76b Mon Sep 17 00:00:00 2001 From: kotliarrr Date: Fri, 24 Jan 2025 20:49:36 +0200 Subject: [PATCH] BB-24798: Added support for Critical CSS in layout (#40191) - added new assets group "critical_css" - added PublicDirectoryProvider for handling public directory paths - added ThemeProvider::getStylesOutputContent to dynamically inject Critical CSS content - added critical CSS to GrapesJS editor --- .../Provider/PublicDirectoryProvider.php | 31 ++++ .../Resources/config/services.yml | 6 + .../Provider/PublicDirectoryProviderTest.php | 74 +++++++++ .../views/layouts/default/config/assets.yml | 4 + .../Resources/config/theme_services.yml | 4 + .../views/layouts/default/config/assets.yml | 4 + .../views/layouts/default/page/layout.yml | 5 + .../Theme/DataProvider/ThemeProvider.php | 46 ++++-- .../Theme/DataProvider/ThemeProviderTest.php | 144 +++++++++++++++++- 9 files changed, 307 insertions(+), 11 deletions(-) create mode 100644 src/Oro/Bundle/DistributionBundle/Provider/PublicDirectoryProvider.php create mode 100644 src/Oro/Bundle/DistributionBundle/Tests/Unit/Provider/PublicDirectoryProviderTest.php diff --git a/src/Oro/Bundle/DistributionBundle/Provider/PublicDirectoryProvider.php b/src/Oro/Bundle/DistributionBundle/Provider/PublicDirectoryProvider.php new file mode 100644 index 00000000000..ba99a45c18a --- /dev/null +++ b/src/Oro/Bundle/DistributionBundle/Provider/PublicDirectoryProvider.php @@ -0,0 +1,31 @@ +projectDir . DIRECTORY_SEPARATOR . 'composer.json'; + + if (!file_exists($composerFilePath)) { + return $this->projectDir . DIRECTORY_SEPARATOR . $defaultPublicDir; + } + + $composerConfig = json_decode(file_get_contents($composerFilePath), true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return $this->projectDir . DIRECTORY_SEPARATOR . $defaultPublicDir; + } + + return $this->projectDir . DIRECTORY_SEPARATOR . ($composerConfig['extra']['public-dir'] ?? $defaultPublicDir); + } +} diff --git a/src/Oro/Bundle/DistributionBundle/Resources/config/services.yml b/src/Oro/Bundle/DistributionBundle/Resources/config/services.yml index 671028bda71..f0018ef6dd8 100644 --- a/src/Oro/Bundle/DistributionBundle/Resources/config/services.yml +++ b/src/Oro/Bundle/DistributionBundle/Resources/config/services.yml @@ -90,3 +90,9 @@ services: class: Oro\Bundle\DistributionBundle\EventListener\ControllerTemplateListener tags: - { name: kernel.event_subscriber } + + oro_distribution.provider.public_directory_provider: + class: Oro\Bundle\DistributionBundle\Provider\PublicDirectoryProvider + arguments: + - '%kernel.project_dir%' + diff --git a/src/Oro/Bundle/DistributionBundle/Tests/Unit/Provider/PublicDirectoryProviderTest.php b/src/Oro/Bundle/DistributionBundle/Tests/Unit/Provider/PublicDirectoryProviderTest.php new file mode 100644 index 00000000000..1e21ab4216a --- /dev/null +++ b/src/Oro/Bundle/DistributionBundle/Tests/Unit/Provider/PublicDirectoryProviderTest.php @@ -0,0 +1,74 @@ +projectDir = sys_get_temp_dir() . '/test_project'; + if (!is_dir($this->projectDir)) { + mkdir($this->projectDir, 0777, true); + } + } + + protected function tearDown(): void + { + $this->removeDirectory($this->projectDir); + } + + public function testGetPublicDirectoryDefault(): void + { + $provider = new PublicDirectoryProvider($this->projectDir); + + self::assertSame($this->projectDir . '/public', $provider->getPublicDirectory()); + } + + public function testGetPublicDirectoryFromComposerConfig(): void + { + $composerJsonPath = $this->projectDir . '/composer.json'; + + file_put_contents($composerJsonPath, json_encode(['extra' => ['public-dir' => 'custom_public']])); + + $provider = new PublicDirectoryProvider($this->projectDir); + + self::assertSame($this->projectDir . '/custom_public', $provider->getPublicDirectory()); + } + + public function testGetPublicDirectoryInvalidJson(): void + { + $composerJsonPath = $this->projectDir . '/composer.json'; + + file_put_contents($composerJsonPath, '{invalid json}'); + + $provider = new PublicDirectoryProvider($this->projectDir); + + self::assertSame($this->projectDir . '/public', $provider->getPublicDirectory()); + } + + public function testGetPublicDirectoryWhenComposerFileDoesNotExist(): void + { + $provider = new PublicDirectoryProvider($this->projectDir); + + self::assertSame($this->projectDir . '/public', $provider->getPublicDirectory()); + } + + private function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + $files = array_diff(scandir($directory), ['.', '..']); + foreach ($files as $file) { + $filePath = $directory . '/' . $file; + is_dir($filePath) ? $this->removeDirectory($filePath) : unlink($filePath); + } + rmdir($directory); + } +} diff --git a/src/Oro/Bundle/FormBundle/Resources/views/layouts/default/config/assets.yml b/src/Oro/Bundle/FormBundle/Resources/views/layouts/default/config/assets.yml index 8c210f26051..dff31b45fd9 100644 --- a/src/Oro/Bundle/FormBundle/Resources/views/layouts/default/config/assets.yml +++ b/src/Oro/Bundle/FormBundle/Resources/views/layouts/default/config/assets.yml @@ -1,3 +1,7 @@ +critical_css: + inputs: + - 'bundles/oroform/default/scss/settings/global-settings.scss' + styles: inputs: - 'bundles/oroform/default/scss/settings/global-settings.scss' diff --git a/src/Oro/Bundle/LayoutBundle/Resources/config/theme_services.yml b/src/Oro/Bundle/LayoutBundle/Resources/config/theme_services.yml index a718f1d59e4..e5818aca0b6 100644 --- a/src/Oro/Bundle/LayoutBundle/Resources/config/theme_services.yml +++ b/src/Oro/Bundle/LayoutBundle/Resources/config/theme_services.yml @@ -141,8 +141,12 @@ services: arguments: - '@oro_layout.theme_manager' - '@oro_locale.provider.current_localization' + - '@oro_distribution.provider.public_directory_provider' + calls: + - ['setLogger', ['@logger']] tags: - { name: layout.data_provider, alias: theme } + - { name: monolog.logger, channel: oro_layout } oro_layout.expression_language.compiled_cache_warmer: class: Oro\Component\Layout\ExpressionLanguage\ExpressionLanguageCacheWarmer diff --git a/src/Oro/Bundle/UIBundle/Resources/views/layouts/default/config/assets.yml b/src/Oro/Bundle/UIBundle/Resources/views/layouts/default/config/assets.yml index b47921e2bec..95d0a22a8d5 100644 --- a/src/Oro/Bundle/UIBundle/Resources/views/layouts/default/config/assets.yml +++ b/src/Oro/Bundle/UIBundle/Resources/views/layouts/default/config/assets.yml @@ -1,3 +1,7 @@ +critical_css: + inputs: + - 'bundles/oroui/default/scss/settings/global-settings.scss' + styles: inputs: - 'bundles/oroui/default/scss/settings/global-settings.scss' diff --git a/src/Oro/Bundle/UIBundle/Resources/views/layouts/default/page/layout.yml b/src/Oro/Bundle/UIBundle/Resources/views/layouts/default/page/layout.yml index d6f6ad1ce6e..b267a9f3952 100644 --- a/src/Oro/Bundle/UIBundle/Resources/views/layouts/default/page/layout.yml +++ b/src/Oro/Bundle/UIBundle/Resources/views/layouts/default/page/layout.yml @@ -27,6 +27,10 @@ layout: visible: '=data["theme"].getIcon(context["theme"])!=null' href: '=data["asset"].getUrl(data["theme"].getIcon(context["theme"]))' rel: shortcut icon + critical_css: + blockType: style + options: + content: '=data["theme"].getStylesOutputContent(context["theme"], "critical_css")' styles: blockType: style options: @@ -73,6 +77,7 @@ layout: meta_charset: ~ meta_viewport: ~ theme_icon: ~ + critical_css: ~ service_worker: ~ styles: ~ print_styles: ~ diff --git a/src/Oro/Component/Layout/Extension/Theme/DataProvider/ThemeProvider.php b/src/Oro/Component/Layout/Extension/Theme/DataProvider/ThemeProvider.php index 02700e9e89a..b1c48d76289 100644 --- a/src/Oro/Component/Layout/Extension/Theme/DataProvider/ThemeProvider.php +++ b/src/Oro/Component/Layout/Extension/Theme/DataProvider/ThemeProvider.php @@ -2,26 +2,30 @@ namespace Oro\Component\Layout\Extension\Theme\DataProvider; +use Oro\Bundle\DistributionBundle\Provider\PublicDirectoryProvider; use Oro\Bundle\LocaleBundle\Provider\LocalizationProviderInterface; use Oro\Component\Layout\Extension\Theme\Model\Theme; use Oro\Component\Layout\Extension\Theme\Model\ThemeManager; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use Psr\Log\NullLogger; /** * Provides theme icon and path to css files in theme by passed styles entry point */ -class ThemeProvider +class ThemeProvider implements LoggerAwareInterface { - protected ThemeManager $themeManager; + use LoggerAwareTrait; - protected LocalizationProviderInterface $localizationProvider; + /** @var array */ + private array $themes = []; - /** @var Theme[] */ - protected $themes = []; - - public function __construct(ThemeManager $themeManager, LocalizationProviderInterface $localizationProvider) - { - $this->themeManager = $themeManager; - $this->localizationProvider = $localizationProvider; + public function __construct( + private readonly ThemeManager $themeManager, + private readonly LocalizationProviderInterface $localizationProvider, + private readonly PublicDirectoryProvider $publicDirectoryProvider, + ) { + $this->logger = new NullLogger(); } /** @@ -110,4 +114,26 @@ private function getOutputPath(string $themeName, string $sectionName): ?string return sprintf('%s.rtl%s', $matches['path'], $matches['extension'] ?? ''); } + + public function getStylesOutputContent(string $themeName, string $sectionName): string + { + $outputPath = $this->getStylesOutput($themeName, $sectionName); + + if ($outputPath === null) { + return ''; + } + + $filePath = sprintf('%s/%s', $this->publicDirectoryProvider->getPublicDirectory(), $outputPath); + + if (!is_file($filePath) || !is_readable($filePath)) { + $this->logger->error( + 'CSS file not found: {filePath}. Theme: "{themeName}", Section: "{sectionName}". ' . + 'Ensure the file exists and is readable.', + ['filePath' => $filePath, 'themeName' => $themeName, 'sectionName' => $sectionName] + ); + return ''; + } + + return file_get_contents($filePath); + } } diff --git a/src/Oro/Component/Layout/Tests/Unit/Extension/Theme/DataProvider/ThemeProviderTest.php b/src/Oro/Component/Layout/Tests/Unit/Extension/Theme/DataProvider/ThemeProviderTest.php index b31c18ecb27..4123825376b 100644 --- a/src/Oro/Component/Layout/Tests/Unit/Extension/Theme/DataProvider/ThemeProviderTest.php +++ b/src/Oro/Component/Layout/Tests/Unit/Extension/Theme/DataProvider/ThemeProviderTest.php @@ -2,11 +2,13 @@ namespace Oro\Component\Layout\Tests\Unit\Extension\Theme\DataProvider; +use Oro\Bundle\DistributionBundle\Provider\PublicDirectoryProvider; use Oro\Bundle\LocaleBundle\Entity\Localization; use Oro\Bundle\LocaleBundle\Provider\LocalizationProviderInterface; use Oro\Component\Layout\Extension\Theme\DataProvider\ThemeProvider; use Oro\Component\Layout\Extension\Theme\Model\Theme; use Oro\Component\Layout\Extension\Theme\Model\ThemeManager; +use Psr\Log\LoggerInterface; class ThemeProviderTest extends \PHPUnit\Framework\TestCase { @@ -19,13 +21,44 @@ class ThemeProviderTest extends \PHPUnit\Framework\TestCase /** @var ThemeProvider */ private $provider; + /** @var PublicDirectoryProvider|\PHPUnit\Framework\MockObject\MockObject */ + private $publicDirectoryProvider; + + /** @var LoggerInterface|\PHPUnit\Framework\MockObject\MockObject */ + private $logger; + #[\Override] protected function setUp(): void { + parent::setUp(); + $this->themeManager = $this->createMock(ThemeManager::class); $this->localizationProvider = $this->createMock(LocalizationProviderInterface::class); + $this->publicDirectoryProvider = $this->createMock(PublicDirectoryProvider::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->provider = new ThemeProvider( + $this->themeManager, + $this->localizationProvider, + $this->publicDirectoryProvider, + ); + + $this->provider->setLogger($this->logger); + + $this->publicDirectory = sys_get_temp_dir() . '/mocked_public_directory_' . uniqid('', true); + + if (is_dir($this->publicDirectory)) { + $this->removeDirectory($this->publicDirectory); + } - $this->provider = new ThemeProvider($this->themeManager, $this->localizationProvider); + mkdir($this->publicDirectory, 0777, true); + } + + protected function tearDown(): void + { + if (is_dir($this->publicDirectory)) { + $this->removeDirectory($this->publicDirectory); + } } public function testGetIcon(): void @@ -264,4 +297,113 @@ public function stylesOutputDataProvider(): array ], ]; } + + public function testGetStylesOutputContentWhenFileExists(): void + { + $themeName = 'test'; + $sectionName = 'styles'; + $outputPath = 'css/test.css'; + $fileContent = 'body { background: #fff; }'; + $filePath = $this->publicDirectory . '/build/' . $themeName . '/' . $outputPath; + + $this->createFileWithContent($filePath, $fileContent); + + $this->publicDirectoryProvider + ->method('getPublicDirectory') + ->willReturn($this->publicDirectory); + + $theme = new Theme($themeName); + $theme->setConfig(['assets' => ['styles' => ['output' => $outputPath]]]); + + $this->themeManager->expects(self::once()) + ->method('getTheme') + ->with($themeName) + ->willReturn($theme); + + $result = $this->provider->getStylesOutputContent($themeName, $sectionName); + + self::assertSame($fileContent, $result, 'The file content does not match the expected content.'); + } + + public function testGetStylesOutputContentWhenFileDoesNotExist(): void + { + $themeName = 'test'; + $sectionName = 'styles'; + $outputPath = 'css/nonexistent.css'; + + $filePath = $this->publicDirectory . '/build/' . $themeName . '/' . $outputPath; + + $this->publicDirectoryProvider + ->method('getPublicDirectory') + ->willReturn($this->publicDirectory); + + $theme = new Theme($themeName); + $theme->setConfig(['assets' => ['styles' => ['output' => $outputPath]]]); + + $this->themeManager->expects(self::once()) + ->method('getTheme') + ->with($themeName) + ->willReturn($theme); + + $this->logger->expects(self::once()) + ->method('error') + ->with( + self::stringContains('CSS file not found'), + self::callback(function ($context) use ($filePath, $themeName, $sectionName) { + return isset($context['filePath'], $context['themeName'], $context['sectionName']) + && $context['filePath'] === $filePath + && $context['themeName'] === $themeName + && $context['sectionName'] === $sectionName; + }) + ); + + $result = $this->provider->getStylesOutputContent($themeName, $sectionName); + self::assertSame('', $result); + } + + + public function testGetStylesOutputContentWhenOutputPathIsNull(): void + { + $themeName = 'test'; + $sectionName = 'styles'; + + $this->publicDirectoryProvider + ->method('getPublicDirectory') + ->willReturn($this->publicDirectory); + + $theme = new Theme($themeName); + $theme->setConfig(['assets' => ['styles' => []]]); + + $this->themeManager->expects(self::once()) + ->method('getTheme') + ->with($themeName) + ->willReturn($theme); + + $result = $this->provider->getStylesOutputContent($themeName, $sectionName); + + self::assertSame('', $result); + } + + private function createFileWithContent(string $filePath, string $content): void + { + $dir = dirname($filePath); + if (!is_dir($dir)) { + mkdir($dir, 0777, true); + } + file_put_contents($filePath, $content); + } + + private function removeDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + $files = array_diff(scandir($directory), ['.', '..']); + foreach ($files as $file) { + $filePath = $directory . '/' . $file; + is_dir($filePath) ? $this->removeDirectory($filePath) : unlink($filePath); + } + rmdir($directory); + } }