Verified Commit 73041c2c authored by Lee Rowlands's avatar Lee Rowlands
Browse files

Issue #3521173 by nod_, fathershawn, bbrala, longwave, larowlan, catch:...

Issue #3521173 by nod_, fathershawn, bbrala, longwave, larowlan, catch: Process attachments (CSS/JS) for HTMX responses and add drupal asset libraries

(cherry picked from commit 0bae16bb)
parent 572c03af
Loading
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -21,6 +21,7 @@
    "_": true,
    "Cookies": true,
    "Backbone": true,
    "htmx": true,
    "loadjs": true,
    "Shepherd": true,
    "Sortable": true,
+18 −0
Original line number Diff line number Diff line
@@ -614,6 +614,24 @@ 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
  drupalSettings:
    # These placeholder values will be set by system_js_settings_alter().
    ajaxPageState:
      libraries: null
      theme: null
      theme_token: null
    ajaxTrustedUrl: {}

drupal.machine-name:
  version: VERSION
  js:
+174 −0
Original line number Diff line number Diff line
/**
 * @file
 * Adds assets the current page requires.
 *
 * This script fires a custom `htmx:drupal:load` event when the request has
 * settled and all script and css files have been successfully loaded on the
 * page.
 */

(function (Drupal, drupalSettings, loadjs, htmx) {
  // Disable htmx loading of script tags since we're handling it.
  htmx.config.allowScriptTags = false;

  /**
   * Used to hold the loadjs promise.
   *
   * It's declared in htmx:beforeSwap and checked in htmx:afterSettle to trigger
   * the custom htmx:drupal:load event.
   *
   * @type {WeakMap<XMLHttpRequest, Promise>}
   */
  const requestAssetsLoaded = new WeakMap();

  /**
   * 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;
  }

  /**
   * Send the current ajax page state with each request.
   *
   * @param configRequestEvent
   *   HTMX event for request configuration.
   *
   * @see system_js_settings_alter()
   * @see \Drupal\Core\Render\HtmlResponseAttachmentsProcessor::processAttachments
   * @see https://htmx.org/api/#on
   * @see https://htmx.org/events/#htmx:configRequest
   */
  htmx.on('htmx:configRequest', ({ detail }) => {
    const url = new URL(detail.path, document.location.href);
    if (Drupal.url.isLocal(url.toString())) {
      // Allow Drupal to return new JavaScript and CSS files to load without
      // returning the ones already loaded.
      // @see \Drupal\Core\StackMiddleWare\AjaxPageState
      // @see \Drupal\Core\Theme\AjaxBasePageNegotiator
      // @see \Drupal\Core\Asset\LibraryDependencyResolverInterface::getMinimalRepresentativeSubset()
      // @see system_js_settings_alter()
      const pageState = drupalSettings.ajaxPageState;
      detail.parameters['ajax_page_state[theme]'] = pageState.theme;
      detail.parameters['ajax_page_state[theme_token]'] = pageState.theme_token;
      detail.parameters['ajax_page_state[libraries]'] = pageState.libraries;
    }
  });

  // @see https://htmx.org/events/#htmx:beforeSwap
  htmx.on('htmx:beforeSwap', ({ detail }) => {
    // Custom event to detach behaviors.
    htmx.trigger(detail.elt, 'htmx:drupal:unload');

    // We need to parse the response to find all the assets to load.
    // htmx cleans up too many things to be able to rely on their dom fragment.
    let responseHTML = Document.parseHTMLUnsafe(detail.serverResponse);

    // Update drupalSettings
    // Use direct child elements to harden against XSS exploits when CSP is on.
    const settingsElement = responseHTML.querySelector(
      ':is(head, body) > script[type="application/json"][data-drupal-selector="drupal-settings-json"]',
    );
    if (settingsElement !== null) {
      mergeSettings(drupalSettings, JSON.parse(settingsElement.textContent));
    }

    // Load all assets files. We sent ajax_page_state in the request so this is only the diff with the current page.
    const assetsTags = responseHTML.querySelectorAll(
      'link[rel="stylesheet"][href], script[src]',
    );
    const bundleIds = Array.from(assetsTags)
      .filter(({ href, src }) => !loadjs.isDefined(href ?? src))
      .map(({ href, src, type, attributes }) => {
        const bundleId = href ?? src;
        let prefix = 'css!';
        if (src) {
          prefix = type === 'module' ? 'module!' : 'js!';
        }

        loadjs(prefix + bundleId, bundleId, {
          // JS files are loaded in order, so this needs to be false when 'src'
          // is defined.
          async: !src,
          // Copy asset tag attributes to the new element.
          before(path, element) {
            // This allows all attributes to be added, like defer, async and
            // crossorigin.
            Object.values(attributes).forEach((attr) => {
              element.setAttribute(attr.name, attr.value);
            });
          },
        });

        return bundleId;
      });

    // Helps with memory management.
    responseHTML = null;

    // Nothing to load, we resolve the promise right away.
    let assetsLoaded = Promise.resolve();
    // If there are assets to load, use loadjs to manage this process.
    if (bundleIds.length) {
      // Trigger the event once all the dependencies have loaded.
      assetsLoaded = new Promise((resolve, reject) => {
        loadjs.ready(bundleIds, {
          success: resolve,
          error(depsNotFound) {
            const message = Drupal.t(
              `The following files could not be loaded: @dependencies`,
              { '@dependencies': depsNotFound.join(', ') },
            );
            reject(message);
          },
        });
      });
    }

    requestAssetsLoaded.set(detail.xhr, assetsLoaded);
  });

  // Trigger the Drupal processing once all assets have been loaded.
  // @see https://htmx.org/events/#htmx:afterSettle
  htmx.on('htmx:afterSettle', ({ detail }) => {
    requestAssetsLoaded.get(detail.xhr).then(() => {
      htmx.trigger(detail.elt, 'htmx:drupal:load');
      // This should be automatic but don't wait for the garbage collector.
      requestAssetsLoaded.delete(detail.xhr);
    });
  });
})(Drupal, drupalSettings, loadjs, htmx);
+41 −0
Original line number Diff line number Diff line
/**
 * @file
 * Connect Drupal.behaviors to htmx inserted content.
 */
(function (Drupal, htmx, drupalSettings) {
  // Flag used to prevent running htmx initialization twice on elements we know
  // have already been processed.
  let attachFromHtmx = false;

  // This is a custom event that triggers once the htmx request settled and
  // all JS and CSS assets have been loaded successfully.
  // @see https://htmx.org/api/#on
  // @see htmx-assets.js
  htmx.on('htmx:drupal:load', ({ detail }) => {
    attachFromHtmx = true;
    Drupal.attachBehaviors(detail.elt, drupalSettings);
    attachFromHtmx = false;
  });

  // When htmx removes elements from the DOM, make sure they're detached first.
  // This event is currently an alias of htmx:beforeSwap
  htmx.on('htmx:drupal:unload', ({ detail }) => {
    Drupal.detachBehaviors(detail.elt, drupalSettings, 'unload');
  });

  /**
   * Initialize HTMX library on content added by Drupal Ajax Framework.
   *
   * @type {Drupal~behavior}
   *
   * @prop {Drupal~behaviorAttach} attach
   *   Initialize htmx behavior.
   */
  Drupal.behaviors.htmx = {
    attach(context) {
      if (!attachFromHtmx && context !== document) {
        htmx.process(context);
      }
    },
  };
})(Drupal, htmx, drupalSettings);
+5 −1
Original line number Diff line number Diff line
@@ -276,7 +276,11 @@ public function jsSettingsAlter(&$settings, AttachedAssetsInterface $assets): vo
    // before doing so. Also add the loaded libraries to ajaxPageState.
    /** @var \Drupal\Core\Asset\LibraryDependencyResolver $library_dependency_resolver */
    $library_dependency_resolver = \Drupal::service('library.dependency_resolver');
    if (isset($settings['ajaxPageState']) || in_array('core/drupal.ajax', $library_dependency_resolver->getLibrariesWithDependencies($assets->getAlreadyLoadedLibraries()))) {
    $loaded_libraries = [];
    if (!isset($settings['ajaxPageState'])) {
      $loaded_libraries = $library_dependency_resolver->getLibrariesWithDependencies($assets->getAlreadyLoadedLibraries());
    }
    if (isset($settings['ajaxPageState']) || in_array('core/drupal.ajax', $loaded_libraries) || in_array('core/drupal.htmx', $loaded_libraries)) {
      if (!defined('MAINTENANCE_MODE')) {
        // The theme token is only validated when the theme requested is not the
        // default, so don't generate it unless necessary.
Loading