diff --git a/src/Asset/RetrofitJsCollectionRenderer.php b/src/Asset/RetrofitJsCollectionRenderer.php new file mode 100644 index 00000000..ba7eaa2d --- /dev/null +++ b/src/Asset/RetrofitJsCollectionRenderer.php @@ -0,0 +1,53 @@ + + */ + protected array $retrofitFooter = []; + + public function __construct( + private readonly AssetCollectionRendererInterface $inner, + ) { + } + + /** + * @param mixed[] $assets + * @return mixed[] + */ + public function render(array $assets): array + { + $is_footer = isset($assets['retrofit']); + unset($assets['retrofit']); + $elements = $this->inner->render($assets); + if ($is_footer && !empty($this->retrofitFooter)) { + $elements = array_merge($elements, $this->retrofitFooter); + } + return $elements; + } + + /** + * @param array{ + * '#type': string, + * '#tag': string, + * '#value'?: string, + * '#attributes'?: mixed[], + * } $element + */ + public function addRetrofitFooter(array $element): void + { + $this->retrofitFooter[] = $element; + } +} diff --git a/src/Asset/RetrofitLibraryDiscovery.php b/src/Asset/RetrofitLibraryDiscovery.php new file mode 100644 index 00000000..33a57cd7 --- /dev/null +++ b/src/Asset/RetrofitLibraryDiscovery.php @@ -0,0 +1,106 @@ + + */ + protected array $retrofitLibraries = []; + + public function __construct( + private readonly LibraryDiscoveryInterface $inner, + ) { + } + + /** + * @param string $extension + * @return array + */ + public function getLibrariesByExtension($extension): array + { + return match ($extension) { + 'retrofit' => $this->retrofitLibraries, + default => $this->inner->getLibrariesByExtension($extension), + }; + } + + /** + * @param string $extension + * @param string $name + * @return mixed[]|false + */ + public function getLibraryByName($extension, $name): array|false + { + return match ($extension) { + 'retrofit' => $this->retrofitLibraries[$name] ?? false, + default => $this->inner->getLibraryByName($extension, $name), + }; + } + + public function clearCachedDefinitions(): void + { + $this->inner->clearCachedDefinitions(); + } + + /** + * @param array{ + * css?: mixed[], + * js?: mixed[], + * requires_jquery?: bool, + * } $attachments + */ + public function setRetrofitLibrary(string $key, array $attachments): void + { + $this->retrofitLibraries[$key]['license'] = []; + if (!empty($attachments['js'])) { + $this->retrofitLibraries[$key]['dependencies'][] = 'core/drupalSettings'; + $this->retrofitLibraries[$key]['js'][] = [ + 'data' => 'retrofit', + 'scope' => 'footer', + ]; + if (!empty($attachments['requires_jquery'])) { + $this->retrofitLibraries[$key]['dependencies'][] = 'core/jquery'; + $this->retrofitLibraries[$key]['dependencies'][] = 'core/once'; + } + } + foreach (['css', 'js'] as $type) { + foreach ($attachments[$type] ?? [] as $data => $options) { + if (!is_array($options)) { + $options = ['data' => $options]; + } + if (!is_numeric($data)) { + $options['data'] = $data; + } + $options += [ + 'type' => 'file', + 'version' => -1, + ]; + switch ($type) { + case 'css': + $options['weight'] ??= 0; + $options['weight'] += match ($options['group'] ?? CSS_DEFAULT) { + CSS_SYSTEM => CSS_LAYOUT, + CSS_THEME, 100 => CSS_AGGREGATE_THEME, + default => CSS_AGGREGATE_DEFAULT, + }; + if (!isset($options['group']) || $options['group'] !== CSS_AGGREGATE_THEME) { + $options['group'] = CSS_AGGREGATE_DEFAULT; + } + break; + + case 'js': + $options['group'] = JS_LIBRARY; + $options['minified'] ??= false; + break; + } + $this->retrofitLibraries[$key][$type][] = $options; + } + } + } +} diff --git a/src/Provider.php b/src/Provider.php index e44f09a1..c0392ea0 100644 --- a/src/Provider.php +++ b/src/Provider.php @@ -7,6 +7,8 @@ use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\DependencyInjection\ServiceProviderBase; use Drupal\Core\Template\Loader\FilesystemLoader; +use Retrofit\Drupal\Asset\RetrofitJsCollectionRenderer; +use Retrofit\Drupal\Asset\RetrofitLibraryDiscovery; use Retrofit\Drupal\Controller\RetrofitTitleResolver; use Retrofit\Drupal\Field\FieldTypePluginManager; use Retrofit\Drupal\Form\FormBuilder; @@ -112,11 +114,21 @@ public function register(ContainerBuilder $container) $container->register(RetrofitHtmlResponseAttachmentsProcessor::class) ->setDecoratedService('html_response.attachments_processor') - ->addArgument(new Reference(RetrofitHtmlResponseAttachmentsProcessor::class . '.inner')); + ->addArgument(new Reference(RetrofitHtmlResponseAttachmentsProcessor::class . '.inner')) + ->addArgument(new Reference('asset.js.collection_renderer')) + ->setAutowired(true); $container->register(AttachmentResponseSubscriber::class) ->addTag('event_subscriber'); + $container->register(RetrofitLibraryDiscovery::class) + ->setDecoratedService('library.discovery') + ->setAutowired(true); + + $container->register(RetrofitJsCollectionRenderer::class) + ->setDecoratedService('asset.js.collection_renderer') + ->setAutowired(true); + $container->setDefinition( FieldTypePluginManager::class, (new ChildDefinition('plugin.manager.field.field_type')) diff --git a/src/Render/RetrofitHtmlResponseAttachmentsProcessor.php b/src/Render/RetrofitHtmlResponseAttachmentsProcessor.php index 9cf34187..73508c44 100644 --- a/src/Render/RetrofitHtmlResponseAttachmentsProcessor.php +++ b/src/Render/RetrofitHtmlResponseAttachmentsProcessor.php @@ -4,14 +4,21 @@ namespace Retrofit\Drupal\Render; +use Drupal\Component\Utility\Crypt; use Drupal\Component\Utility\NestedArray; +use Drupal\Core\Asset\AssetCollectionRendererInterface; +use Drupal\Core\Asset\LibraryDiscoveryInterface; use Drupal\Core\Render\AttachmentsInterface; use Drupal\Core\Render\AttachmentsResponseProcessorInterface; +use Retrofit\Drupal\Asset\RetrofitJsCollectionRenderer; +use Retrofit\Drupal\Asset\RetrofitLibraryDiscovery; final class RetrofitHtmlResponseAttachmentsProcessor implements AttachmentsResponseProcessorInterface { public function __construct( private readonly AttachmentsResponseProcessorInterface $inner, + private readonly AssetCollectionRendererInterface $jsCollectionRenderer, + private readonly LibraryDiscoveryInterface $libraryDiscovery, ) { } @@ -29,19 +36,110 @@ public function processAttachments(AttachmentsInterface $response) } } } - if (isset($attachments['js']) && is_array($attachments['js'])) { - foreach ($attachments['js'] as $key => $item) { - if (is_array($item) && isset($item['type'], $item['data']) && $item['type'] === 'setting') { - $attachments['drupalSettings'] = NestedArray::mergeDeepArray( - [$attachments['drupalSettings'] ?? [], $item['data']], - true, + $retrofit_library = []; + if (isset($attachments['css']) && is_array($attachments['css'])) { + foreach ($attachments['css'] as $key => $item) { + if (is_array($item) && isset($item['type'], $item['data']) && $item['type'] === 'inline') { + $element = [ + '#tag' => 'style', + '#value' => $item['data'], + '#weight' => $item['weight'] ?? 0, + ]; + unset( + $item['data'], + $item['type'], + $item['basename'], + $item['group'], + $item['every_page'], + $item['weight'], + $item['preprocess'], + $item['browsers'], ); - unset($attachments['js'][$key]); + $element['#attributes'] = $item; + $attachments['html_head'][] = [ + $element, + "retrofit:$key", + ]; + unset($attachments['css'][$key]); + } + } + $retrofit_library['css'] = $attachments['css']; + unset($attachments['css']); + asort($retrofit_library['css']); + } + if (isset($attachments['js']) && is_array($attachments['js'])) { + $requires_jquery = false; + foreach ($attachments['js'] as $key => &$item) { + if (is_array($item)) { + $requires_jquery = !empty($item['requires_jquery']) ?: $requires_jquery; + switch ($item['type'] ?? 'file') { + case 'inline': + $element = [ + '#tag' => 'script', + '#value' => $item['data'] ?? $key, + '#weight' => $item['weight'] ?? 0, + ]; + $scope = $item['scope'] ?? 'footer'; + unset( + $item['data'], + $item['type'], + $item['scope'], + $item['group'], + $item['every_page'], + $item['weight'], + $item['requires_jquery'], + $item['cache'], + $item['preprocess'], + ); + $element['#attributes'] = $item; + unset($attachments['js'][$key]); + switch ($scope) { + case 'header': + $attachments['html_head'][] = [ + $element, + "retrofit:$key", + ]; + break; + + default: + $element['#type'] = 'html_tag'; + assert($this->jsCollectionRenderer instanceof RetrofitJsCollectionRenderer); + $this->jsCollectionRenderer->addRetrofitFooter($element); + break; + } + break; + + case 'setting': + if (isset($item['data'])) { + $attachments['drupalSettings'] = NestedArray::mergeDeepArray( + [$attachments['drupalSettings'] ?? [], $item['data']], + true, + ); + } + unset($attachments['js'][$key]); + break; + + default: + $item['attributes']['defer'] = $item['defer'] ?? false; + break; + } } } + assert($this->jsCollectionRenderer instanceof RetrofitJsCollectionRenderer); + $this->jsCollectionRenderer->addRetrofitFooter([ + '#type' => 'html_tag', + '#tag' => 'script', + '#value' => 'Drupal.settings = drupalSettings;', + ]); + $retrofit_library['requires_jquery'] = $requires_jquery; + $retrofit_library['js'] = $attachments['js']; + unset($attachments['js']); + asort($retrofit_library['js']); } - // @todo log these removals? - unset($attachments['css'], $attachments['js']); + $name = Crypt::hashBase64(serialize($retrofit_library)); + assert($this->libraryDiscovery instanceof RetrofitLibraryDiscovery); + $this->libraryDiscovery->setRetrofitLibrary($name, $retrofit_library); + $attachments['library'][] = "retrofit/$name"; $response->setAttachments($attachments); return $this->inner->processAttachments($response); } diff --git a/src/constants/common.php b/src/constants/common.php index a2f230f9..8f84e33c 100644 --- a/src/constants/common.php +++ b/src/constants/common.php @@ -4,3 +4,5 @@ // In Drupal 10 this has been replaced with CSS_AGGREGATE_DEFAULT. const CSS_DEFAULT = 0; + +const CSS_SYSTEM = -100; diff --git a/src/functions/common.php b/src/functions/common.php index 50ee6da2..956d6df6 100644 --- a/src/functions/common.php +++ b/src/functions/common.php @@ -214,34 +214,38 @@ function drupal_add_library(string $module, string $name, ?bool $every_page = nu */ function drupal_add_js(array|string|null $data = null, array|string|null $options = null): array { - if ($data === null) { - return []; - } + $attachment_subscriber = \Drupal::getContainer()->get(AttachmentResponseSubscriber::class); assert($attachment_subscriber instanceof AttachmentResponseSubscriber); - if (is_string($options)) { + $options ??= []; + if (!is_array($options)) { $options = ['type' => $options]; - } elseif ($options === null) { - $options = []; } - - $type = $options['type'] ?? 'file'; - switch ($type) { + $options += [ + 'type' => 'file', + 'requires_jquery' => isset($options['type']) && $options['type'] === 'setting', + 'scope' => 'footer', + 'cache' => true, + 'defer' => false, + 'preprocess' => true, + 'data' => $data, + ]; + $options['preprocess'] = $options['cache'] ? $options['preprocess'] : false; + switch ($options['type']) { case 'setting': if (is_array($data)) { $attachment_subscriber->addAttachments([ 'drupalSettings' => $data, ]); - } else { - // @todo log warning if string? Cannot discern what D7 did. } - break; case 'inline': $attachment_subscriber->addAttachments([ - 'js' => $options, + 'js' => [ + $options, + ], ]); break; diff --git a/tests/src/Unit/Render/RetrofitHtmlResponseAttachmentsProcessorTest.php b/tests/src/Unit/Render/RetrofitHtmlResponseAttachmentsProcessorTest.php index b30c2afc..a9a7573f 100644 --- a/tests/src/Unit/Render/RetrofitHtmlResponseAttachmentsProcessorTest.php +++ b/tests/src/Unit/Render/RetrofitHtmlResponseAttachmentsProcessorTest.php @@ -4,9 +4,11 @@ namespace Retrofit\Drupal\Tests\Unit\Render; +use Drupal\Core\Asset\AssetCollectionRendererInterface; use Drupal\Core\Render\AttachmentsResponseProcessorInterface; use Drupal\Core\Render\HtmlResponse; use PHPUnit\Framework\TestCase; +use Retrofit\Drupal\Asset\RetrofitLibraryDiscovery; use Retrofit\Drupal\Render\RetrofitHtmlResponseAttachmentsProcessor; /** @@ -29,12 +31,15 @@ public function testProcessAttachments(): void $inner->expects(self::once()) ->method('processAttachments') ->with($response); - $sut = new RetrofitHtmlResponseAttachmentsProcessor($inner); + $jsCollectionRenderer = $this->createMock(AssetCollectionRendererInterface::class); + $libraryDiscovery = $this->createMock(RetrofitLibraryDiscovery::class); + $sut = new RetrofitHtmlResponseAttachmentsProcessor($inner, $jsCollectionRenderer, $libraryDiscovery); $sut->processAttachments($response); self::assertEquals( [ 'library' => [ - 'foo/bar' + 'foo/bar', + 'retrofit/NXhscRe0440PFpI5dSznEVgmauL25KojD7u4e9aZwOM', ], ], $response->getAttachments()