diff --git a/core/.eslintrc.json b/core/.eslintrc.json index 6a6916b1a2c0aef4d1f49b21c18bf21c423f067b..7b1ed46abeca6b8ae943e070edf3cd77e47a8b29 100644 --- a/core/.eslintrc.json +++ b/core/.eslintrc.json @@ -21,6 +21,7 @@ "_": true, "Cookies": true, "Backbone": true, + "htmx": true, "loadjs": true, "Shepherd": true, "Sortable": true, diff --git a/core/core.libraries.yml b/core/core.libraries.yml index 5ec851e898e7652d7f8ab474dfaf6ef3b138f4c4..48eb3b17a2309a17d532210ed39cc121dd86d74a 100644 --- a/core/core.libraries.yml +++ b/core/core.libraries.yml @@ -614,6 +614,17 @@ drupal.form: - core/drupal.debounce - core/once +drupal.htmx: + version: VERSION + js: + misc/htmx/htmx-assets.js: {} + misc/htmx/htmx-behaviors.js: {} + dependencies: + - core/htmx + - core/drupal + - core/drupalSettings + - core/loadjs + drupal.machine-name: version: VERSION js: diff --git a/core/core.services.yml b/core/core.services.yml index 4372e6512c3e34c73f1db7c5174306690d134bd0..ce288b667a3a163a832cea23f52aaf927fcf34f4 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -1400,6 +1400,14 @@ services: html_response.subscriber: class: Drupal\Core\EventSubscriber\HtmlResponseSubscriber arguments: ['@html_response.attachments_processor'] + htmx_response.attachments_processor: + class: Drupal\Core\Render\Hypermedia\HtmxResponseAttachmentsProcessor + parent: html_response.attachments_processor + htmx.response_subscriber: + class: Drupal\Core\EventSubscriber\HtmxResponseSubscriber + arguments: [ '@request_stack', !service_closure '@htmx_response.attachments_processor' ] + tags: + - { name: event_subscriber } finish_response_subscriber: class: Drupal\Core\EventSubscriber\FinishResponseSubscriber arguments: ['@language_manager', '@config.factory', '@page_cache_request_policy', '@page_cache_response_policy', '@cache_contexts_manager', '@datetime.time', '%http.response.debug_cacheability_headers%'] diff --git a/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php new file mode 100644 index 0000000000000000000000000000000000000000..2ec01b9e5dff23289bb3c832abd16b14779c757a --- /dev/null +++ b/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php @@ -0,0 +1,79 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\EventSubscriber; + +use Drupal\Component\Utility\Html; +use Drupal\Core\Render\AttachmentsResponseProcessorInterface; +use Drupal\Core\Render\HtmlResponse; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\Event\ResponseEvent; +use Symfony\Component\HttpKernel\KernelEvents; + +/** + * Adds assets on responses to HTMX requests. + * + * @see \Drupal\Core\Render\Hypermedia\HtmxResponseAttachmentsProcessor + */ +class HtmxResponseSubscriber implements EventSubscriberInterface { + + /** + * Constructs a HtmxResponseSubscriber object. + */ + public function __construct( + protected readonly RequestStack $requestStack, + protected \Closure $attachmentsProcessor, + ) {} + + /** + * Add assemble and attachments add HTMX attributes. + * + * @see \Drupal\Core\EventSubscriber\HtmlResponseSubscriber::onRespond + */ + public function onRespond(ResponseEvent $event): void { + $response = $event->getResponse(); + $requestHeaders = $this->requestStack->getCurrentRequest()->headers; + if (!($response instanceof HtmlResponse && $requestHeaders->has('HX-Request'))) { + // Only operate on HTML responses from an HTMX request. + return; + } + $processedResponse = $this->getAttachementsProcessor()->processAttachments($response); + if (!($processedResponse instanceof HtmlResponse)) { + throw new \TypeError("ResponseEvent::setResponse() requires an HtmlResponse object"); + } + $event->setResponse($processedResponse); + } + + protected function getAttachementsProcessor(): AttachmentsResponseProcessorInterface { + return ($this->attachmentsProcessor)(); + } + + /** + * Sets \Drupal\Component\Utility\Html::$isAjax to TRUE. + * + * @param \Symfony\Component\HttpKernel\Event\RequestEvent $event + * The request event, which contains the current request. + */ + public function onRequest(RequestEvent $event) { + $requestHeaders = $event->getRequest()->headers; + // Pass to the Html class that the current request is an Ajax request. + if ($requestHeaders->has('HX-Request')) { + Html::setIsAjax(TRUE); + } + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + // Our method needs to run before the HtmlResponseSubscriber(weight 0). + KernelEvents::RESPONSE => ['onRespond', 100], + KernelEvents::REQUEST => ['onRequest', 50], + ]; + } + +} diff --git a/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php b/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php new file mode 100644 index 0000000000000000000000000000000000000000..8493c1c1b367179367d54068b7e87aa29c2b6a49 --- /dev/null +++ b/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php @@ -0,0 +1,95 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Core\Render\Hypermedia; + +use Drupal\Component\Serialization\Json; +use Drupal\Component\Utility\UrlHelper; +use Drupal\Core\Asset\AttachedAssetsInterface; +use Drupal\Core\Render\AttachmentsInterface; +use Drupal\Core\Render\HtmlResponse; +use Drupal\Core\Render\HtmlResponseAttachmentsProcessor; + +/** + * Prepares attachment for HTMX powered responses. + * + * Extends the HTML response processor encode attachment data. + * + * @see \Drupal\Core\EventSubscriber\HtmxResponseSubscriber + * @see \Drupal\Core\Render\HtmlResponseAttachmentsProcessor::processAttachments + * @see core/misc/htmx-behaviors.js + */ +class HtmxResponseAttachmentsProcessor extends HtmlResponseAttachmentsProcessor { + + /** + * {@inheritdoc} + */ + protected function processAssetLibraries(AttachedAssetsInterface $assets, array $placeholders) { + $request = $this->requestStack->getCurrentRequest(); + if ($request->headers->has('HX-Page-State')) { + $uncompressed = UrlHelper::uncompressQueryParameter($request->headers->get('HX-Page-State')); + $libraries = explode(',', $uncompressed); + $assets->setAlreadyLoadedLibraries($libraries); + } + $settings = []; + $variables = []; + $maintenance_mode = defined('MAINTENANCE_MODE') || \Drupal::state()->get('system.maintenance_mode'); + + // Print styles - if present. + if (isset($placeholders['styles'])) { + // Optimize CSS if necessary, but only during normal site operation. + $optimize_css = !$maintenance_mode && $this->config->get('css.preprocess'); + $variables['styles'] = $this->cssCollectionRenderer->render($this->assetResolver->getCssAssets($assets, $optimize_css, $this->languageManager->getCurrentLanguage())); + } + + // Copy and adjust parent::processAssetLibraries to adjust and + // remove drupalSettings in line with + // AjaxResponseAttachmentsProcessor::buildAttachmentsCommands + // Print scripts - if any are present. + if (isset($placeholders['scripts']) || isset($placeholders['scripts_bottom'])) { + // Optimize JS if necessary, but only during normal site operation. + $optimize_js = !$maintenance_mode && $this->config->get('js.preprocess'); + [$js_assets_header, $js_assets_footer] = $this->assetResolver + ->getJsAssets($assets, $optimize_js, $this->languageManager->getCurrentLanguage()); + if (isset($js_assets_header['drupalSettings'])) { + $settings = $js_assets_header['drupalSettings']['data']; + unset($js_assets_header['drupalSettings']); + } + if (isset($js_assets_footer['drupalSettings'])) { + $settings = $js_assets_footer['drupalSettings']['data']; + unset($js_assets_footer['drupalSettings']); + } + if (empty($settings)) { + $settings = $assets->getSettings(); + } + $variables['scripts'] = $this->jsCollectionRenderer->render($js_assets_header); + $variables['scripts_bottom'] = $this->jsCollectionRenderer->render($js_assets_footer); + unset($settings['path']); + $variables['settings'] = $settings; + } + /* + * @todo Update to use `#htmx` render array property when available. + */ + return [ + 'styles' => [], + 'scripts' => [], + 'scripts_bottom' => [ + '#type' => 'container', + '#attributes' => [ + 'data-hx-swap-oob' => 'beforeend:body', + ], + 'libraries' => [ + '#type' => 'html_tag', + '#tag' => 'script', + '#attributes' => [ + 'type' => 'application/json', + 'data-drupal-selector' => 'drupal-htmx-assets', + ], + '#value' => Json::encode($variables), + ], + ], + ]; + } + +} diff --git a/core/misc/htmx/htmx-assets.js b/core/misc/htmx/htmx-assets.js new file mode 100644 index 0000000000000000000000000000000000000000..5829a4a682fb4493b76720ce6a7964b87e3ece3d --- /dev/null +++ b/core/misc/htmx/htmx-assets.js @@ -0,0 +1,182 @@ +/** + * @file + * Adds needed assets to the current page. + */ + +// JavaScript should be made compatible with libraries other than jQuery by +// wrapping it in an anonymous closure. +(function (Drupal, drupalSettings, once, loadjs, htmx) { + /** + * Helper function to merge two objects recursively. + * + * @param current + * The object to receive the merged values. + * @param sources + * The objects to merge into current. + * + * @return object + * The merged object. + * + * @see https://youmightnotneedjquery.com/#deep_extend + */ + function mergeSettings(current, ...sources) { + if (!current) { + return {}; + } + + sources + .filter((obj) => Boolean(obj)) + .forEach((obj) => { + Object.entries(obj).forEach(([key, value]) => { + switch (Object.prototype.toString.call(value)) { + case '[object Object]': + current[key] = current[key] || {}; + current[key] = mergeSettings(current[key], value); + break; + + case '[object Array]': + current[key] = mergeSettings(new Array(value.length), value); + break; + + default: + current[key] = value; + } + }); + }); + + return current; + } + + /** + * Function to process and send the current ajax page state with each request. + * + * @param configRequestEvent + * HTMX event for request configuration. + */ + function htmxDrupalData(configRequestEvent) { + const url = new URL(configRequestEvent.detail.path, document.location.href); + const origin = document.location.origin; + const sameHost = origin === url.origin; + if (sameHost) { + // We only need to add this data for htmx requests back to the site. + configRequestEvent.detail.headers['HX-Page-State'] = + drupalSettings.ajaxPageState.libraries; + // HTMX sets these header values using #id values. + // Swap in drupal data selectors as #id values are altered to be unique. + configRequestEvent.detail.headers['HX-Target'] = + configRequestEvent.detail.target.dataset.drupalSelector; + configRequestEvent.detail.headers['HX-Trigger'] = + configRequestEvent.detail.elt.dataset.drupalSelector; + } + } + + /** + * Function to obtain, process, and attach new assets. + * + * @param {event} oobSwapEvent + * The event fired by HTMX. + */ + function htmxDrupalAssetProcessor(oobSwapEvent) { + // Find any inserted assets. + const assetsTag = oobSwapEvent.detail.target.querySelector( + 'script[data-drupal-selector="drupal-htmx-assets"]', + ); + if (!(assetsTag instanceof HTMLElement)) { + return; + } + // Parse assets and initialize container variables. + const assets = JSON.parse(assetsTag.textContent); + let cssItems = []; + let scriptItems = []; + let scriptBottomItems = []; + let paths = []; + if (assets.constructor.name === 'Object') { + // Variable assets should have properties 'styles', 'scripts, + // 'scripts_bottom', 'settings'. See + // HtmxResponseAttachmentsProcessor::processAssetLibraries + cssItems = new Map( + assets.styles.map((item) => [`css!${item['#attributes'].href}`, item]), + ); + scriptItems = new Map( + assets.scripts.map((item) => [item['#attributes'].src, item]), + ); + scriptBottomItems = new Map( + assets.scripts_bottom.map((item) => [item['#attributes'].src, item]), + ); + mergeSettings(drupalSettings, assets.settings); + } + // Load CSS. + if (cssItems instanceof Map && cssItems.size > 0) { + // Fetch in parallel but load in sequence. + cssItems.forEach((value, key) => paths.push(key)); + loadjs(paths, { + async: false, + before(path, linkElement) { + const item = cssItems.get(path); + Object.entries(item['#attributes']).forEach( + ([attributeKey, attributeValue]) => { + linkElement.setAttribute(attributeKey, attributeValue); + }, + ); + }, + }); + } + /* By default, loadjs appends the script to the head. When scripts + * are loaded via HTMX, their location has no impact on + * functionality. But, since Drupal loaded scripts can choose + * their parent element, we provide that option here for the sake of + * consistency. + */ + if (scriptItems instanceof Map && scriptItems.size > 0) { + paths = []; + scriptItems.forEach((value, key) => paths.push(key)); + // Load head JS. + // Fetch in parallel but load in sequence. + loadjs(paths, { + async: false, + before(path, scriptElement) { + const item = scriptItems.get(path); + Object.entries(item['#attributes']).forEach( + ([attributeKey, attributeValue]) => { + scriptElement.setAttribute(attributeKey, attributeValue); + }, + ); + }, + }); + } + if (scriptBottomItems instanceof Map && scriptBottomItems.size > 0) { + paths = []; + scriptBottomItems.forEach((value, key) => paths.push(key)); + // Fetch in parallel but load in sequence. + loadjs(paths, { + async: false, + before(path, scriptElement) { + const item = scriptBottomItems.get(path); + Object.entries(item['#attributes']).forEach( + ([attributeKey, attributeValue]) => { + scriptElement.setAttribute(attributeKey, attributeValue); + }, + ); + document.body.appendChild(scriptElement); + // Return false to bypass loadjs' default DOM insertion + // mechanism. + return false; + }, + }); + } + // Any assets are now processed. Remove the found asset tag. + htmx.remove(assetsTag); + } + + /* Initialize listeners. */ + + Drupal.behaviors.htmxAssets = { + attach: () => { + if (!once('htmxAssets', 'html').length) { + return; + } + window.addEventListener('htmx:configRequest', htmxDrupalData); + window.addEventListener('htmx:oobAfterSwap', htmxDrupalAssetProcessor); + }, + }; +})(Drupal, drupalSettings, once, loadjs, htmx); diff --git a/core/misc/htmx/htmx-behaviors.js b/core/misc/htmx/htmx-behaviors.js new file mode 100644 index 0000000000000000000000000000000000000000..90a44426736d37e447e780b59d8eb23ad8cce4ff --- /dev/null +++ b/core/misc/htmx/htmx-behaviors.js @@ -0,0 +1,32 @@ +/** + * @file + * Connect Drupal.behaviors to htmx inserted content. + */ +(function (Drupal, htmx, drupalSettings) { + + + // @todo replace with a custom event that will trigger once all the assets + // have been loaded. + htmx.on('htmx:load', (event) => { + Drupal.attachBehaviors( + // The attachBehaviors method searches within the context. + // We need to go up one level so that the loaded content is processed + // completely. + event.detail.elt?.parentElement, + drupalSettings, + ); + }); + + // When htmx removes elements from the DOM, make sure they're detached first. + htmx.on('htmx:beforeSwap', (event) => { + Drupal.detachBehaviors(event.detail.elt, drupalSettings, 'unload'); + }); + + /** + * @todo move that to ajax.js this is not necessary since htmx initializes the markup it adds to the page automatically. + * Also send new markup through htmx processing. + */ + Drupal.behaviors.htmxProcess = { + attach: htmx.process, + }; +})(Drupal, htmx, drupalSettings); diff --git a/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php new file mode 100644 index 0000000000000000000000000000000000000000..b100d8adbea53f246c50b691c41f2320f6c9d6cf --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php @@ -0,0 +1,78 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\test_htmx\Controller; + +use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\Url; + +/** + * Returns responses for HTMX Test Attachments routes. + */ +final class HtmxTestAttachmentsController extends ControllerBase { + + /** + * Builds the response. + * + * @return mixed[] + * A render array. + */ + public function page(): array { + $url = Url::fromRoute('test_htmx.attachments.replace'); + $build['replace'] = [ + '#type' => 'button', + '#attributes' => [ + 'name' => 'replace', + 'data-hx-get' => $url->toString(), + 'data-hx-select' => 'div.dropbutton-wrapper', + 'data-hx-target' => '[data-drupal-htmx-target]', + ], + '#value' => 'Click this', + '#context' => [ + 'url' => $url, + ], + ]; + + $build['content'] = [ + '#type' => 'container', + '#attached' => [ + 'library' => [ + 'core/drupal.htmx', + ], + ], + '#attributes' => [ + 'data-drupal-htmx-target' => TRUE, + 'class' => ['htmx-test-container'], + ], + ]; + + return $build; + } + + /** + * Builds the HTMX response. + * + * @return mixed[] + * A render array. + */ + public function replace(): array { + $build['content'] = [ + '#type' => 'dropbutton', + '#dropbutton_type' => 'small', + '#links' => [ + 'simple_form' => [ + 'title' => $this->t('Sample link 1'), + 'url' => Url::fromRoute('system.timezone'), + ], + 'demo' => [ + 'title' => $this->t('Sample link 2'), + 'url' => Url::fromRoute('<front>'), + ], + ], + ]; + + return $build; + } + +} diff --git a/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml b/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml new file mode 100644 index 0000000000000000000000000000000000000000..713f8a551a5baa2809c4cd4758990afc0113952c --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml @@ -0,0 +1,5 @@ +name: 'HTMX Test Fixtures' +type: module +description: 'Test fixtures for HTMX integration' +package: Testing +core_version_requirement: ^11 diff --git a/core/modules/system/tests/modules/test_htmx/test_htmx.routing.yml b/core/modules/system/tests/modules/test_htmx/test_htmx.routing.yml new file mode 100644 index 0000000000000000000000000000000000000000..b6a82293bffc4df7f25cdd6d796c8215c88c6b9b --- /dev/null +++ b/core/modules/system/tests/modules/test_htmx/test_htmx.routing.yml @@ -0,0 +1,17 @@ +test_htmx.attachments.page: + path: '/htmx-test-attachments/page' + defaults: + _title: 'Page' + _controller: '\Drupal\test_htmx\Controller\HtmxTestAttachmentsController::page' + requirements: + _permission: 'access content' + +test_htmx.attachments.replace: + path: '/htmx-test-attachments/replace' + defaults: + _title: 'Dropbutton' + _controller: '\Drupal\test_htmx\Controller\HtmxTestAttachmentsController::replace' + requirements: + _permission: 'access content' + options: + _htmx_route: 'true' diff --git a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js new file mode 100644 index 0000000000000000000000000000000000000000..8af529d826e137f3a89e5f59760f2fdff1a748f9 --- /dev/null +++ b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js @@ -0,0 +1,42 @@ +// The javascript that creates dropbuttons is not present on the /page at +// initial load. If the once data property is added then the JS was loaded +// and triggered on the inserted content. +// @see \Drupal\test_htmx\Controller\HtmxTestAttachmentsController +// @see core/modules/system/tests/modules/test_htmx/js/reveal-merged-settings.js +module.exports = { + '@tags': ['core', 'htmx'], + before(browser) { + browser.drupalInstall({ + setupFile: 'core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php', + installProfile: 'minimal', + }); + }, + afterEach(browser) { + browser.drupalLogAndEnd({ onlyOnError: true }); + }, + after(browser) { + browser.drupalUninstall(); + }, + + 'Asset Load': (browser) => { + // Load the route htmx will request and confirm the markup we will be looking + // for is present in the source markup. + browser + .drupalRelativeURL('/htmx-test-attachments/replace') + .waitForElementVisible('body', 1000) + .assert.elementPresent('script[src*="dropbutton.js"]'); + // Now load the page with the htmx enhanced button and verify the absence + // of the markup to be inserted. Click the button + // and check for inserted javascript and markup. + browser + .drupalRelativeURL('/htmx-test-attachments/page') + .waitForElementVisible('body', 1000) + .assert.not.elementPresent('script[src*="dropbutton.js"]') + .waitForElementVisible('[name="replace"]', 1000) + .pause(1000) + .click('[name="replace"]') + .waitForElementVisible('div.dropbutton-wrapper', 60000) + .waitForElementVisible('div[data-once="dropbutton"]', 60000) + .assert.elementPresent('script[src*="dropbutton.js"]'); + }, +}; diff --git a/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php b/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php new file mode 100644 index 0000000000000000000000000000000000000000..03bb3fbdb113060d23eb3bcbad0fd3bcc67581fd --- /dev/null +++ b/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\TestSite; + +use Drupal\Core\Extension\ModuleInstallerInterface; +use Drupal\Core\Extension\ThemeInstallerInterface; + +/** + * Setup file used by tests/src/Nightwatch/Tests/htmxAssetLoadTest.js. + * + * @see \Drupal\Tests\Scripts\TestSiteApplicationTest + */ +class HtmxAssetLoadTestSetup implements TestSetupInterface { + + /** + * {@inheritdoc} + */ + public function setup(): void { + // Install Olivero and set it as the default theme. + $theme_installer = \Drupal::service('theme_installer'); + assert($theme_installer instanceof ThemeInstallerInterface); + $theme_installer->install(['olivero'], TRUE); + $system_theme_config = \Drupal::configFactory()->getEditable('system.theme'); + $system_theme_config->set('default', 'olivero')->save(); + + // Install required modules. + $module_installer = \Drupal::service('module_installer'); + assert($module_installer instanceof ModuleInstallerInterface); + $module_installer->install(['test_htmx']); + } + +}