refreshless-8.x-1.x-dev/modules/refreshless_turbo/js/refreshless.js

modules/refreshless_turbo/js/refreshless.js
(function(
  html, Drupal,
  TurboBehaviours, TurboDrupalSettings, TurboProgressBar, TurboScriptManager,
  TurboStylesheetManager,
  drupalSettings, $, Turbo,
) {

  'use strict';

  /**
   * Name of the attribute we add to cached snapshot bodies to identify them.
   *
   * @type {String}
   */
  const cachedSnapshotAttr = 'data-refreshless-cached-snapshot';

  /**
   * Our event namespace.
   *
   * @type {String}
   *
   * @see https://learn.jquery.com/events/event-basics/#namespacing-events
   */
  const eventNamespace = 'refreshless-turbo-core';

  /**
   * Name of the attribute on elements to be removed from cached pages.
   *
   * @type {String}
   *
   * @see https://turbo.hotwired.dev/handbook/building#preparing-the-page-to-be-cached
   */
  const temporaryElementAttr = 'data-refreshless-temporary';

  /**
   * RefreshLess Turbo adapter class.
   */
  class RefreshLessTurbo {

    /**
     * The context element to attach to; usually the <html> element.
     *
     * @type {HTMLElement}
     */
    #context;

    /**
     * Behaviours class instance; responsible for attaching/detaching.
     *
     * @type {TurboBehaviours}
     */
    #Behaviours;

    /**
     * drupalSettings updater class instance.
     *
     * @type {TurboDrupalSettings}
     */
    #DrupalSettings;

    /**
     * Script manager class instance.
     *
     * @type {TurboScriptManager}
     */
    #ScriptManager;

    /**
     * Progress bar class instance.
     *
     * @type {TurboProgressBar}
     */
    #ProgressBar;

    /**
     * Stylesheet manager class instance.
     *
     * @type {TurboStylesheetManager}
     */
    #StylesheetManager;

    /**
     * Hotwire Turbo instance.
     *
     * @type {Turbo}
     */
    #Turbo;

    /**
     * Whether the current response was a redirect.
     *
     * Turbo doesn't provide a clean way to detect this in a
     * 'turbo:before-render' or 'turbo:render' event listener so we need to
     * track whether the most recent response was the result of a redirect to
     * avoid triggering a double set of behaviour detach, settings update, and
     * behaviour attach.
     *
     * @type {Boolean}
     *
     * @see https://www.drupal.org/project/refreshless/issues/3397466
     *   Documents the problem and potential solutions.
     *
     * @see this.#skipRender
     */
    #isRedirected = false;

    /**
     * Wether to skip the next render, i.e. detach, settings update, and attach.
     *
     * We need a second flag in addition to this.#isRedirected to ensure that we
     * do still detach behaviours even on a redirect navigation, inelegant
     * though it may be.
     *
     * @type {Boolean}
     *
     * @see this.#isRedirected
     */
    #skipRender = false;

    /**
     * Whether the current render is a cached preview being displayed.
     *
     * @type {Boolean}
     */
    #isPreview = false;

    /**
     * Whether the previous render was a cached preview being displayed.
     *
     * This is useful for actions that must be taken during a before-render that
     * occurs after a cached render.
     *
     * @type {Boolean}
     */
    #previousPreview = false;

    /**
     * Whether the current render is a cached snapshot.
     *
     * This is similar but slightly different from a cached preview, in that a
     * cached snapshot can be both a preview and the final content when Turbo
     * performs a restoration visit, i.e. navigating via the browser history.
     * In other words, all previews are cached snapshots but not all cached
     * snapshots are previews.
     *
     * @type {Boolean}
     *
     * @see https://turbo.hotwired.dev/handbook/building#understanding-caching
     */
    #isCachedSnapshot = false;

    /**
     * Whether the current render is a fresh copy replacing a preview.
     *
     * This is primarily intended for implementing page transitions and other
     * tasks during rendering that need to be informed of this specific state.
     *
     * @type {Boolean}
     */
    #isFreshReplacingPreview = false;

    /**
     * Flag indicating whether refreshless:load event is initial load.
     *
     * I.e. this is only true on the first triggering of that event and then
     * false thereafter.
     *
     * @type {Boolean}
     */
    #initialLoad = true;

    constructor(
      context, Behaviours, DrupalSettings, ProgressBar, ScriptManager,
      StylesheetManager, Turbo,
    ) {

      this.#context = context;

      this.#Turbo = Turbo;

      this.#Behaviours = new Behaviours(this.#context);

      this.#DrupalSettings = new DrupalSettings(this.#context);

      this.#ProgressBar = new ProgressBar(this.#context);

      this.#ScriptManager = new ScriptManager(this.#context);

      this.#StylesheetManager = new StylesheetManager(this.#context);

      this.#bindEventHandlers();

    }

    /**
     * Destroy this instance and all its components.
     */
    destroy() {

      this.#unbindEventHandlers();

      this.#Behaviours.destroy();

      this.#DrupalSettings.destroy();

      this.#ProgressBar.destroy();

      this.#ScriptManager.destroy();

      this.#StylesheetManager.destroy();

    }

    /**
     * Perform a RefreshLess visit to a location if possible.
     *
     * This wraps Turbo.visit() as a convenience method, and also adds a check
     * to only perform a RefreshLess visit if Turbo.session.drive is not false.
     *
     * @param {Location|String|URL} location
     *
     * @param {Object} options
     *
     * @see https://turbo.hotwired.dev/reference/drive#turbo.visit
     *   Currently just a wrapper around the Turbo method for convenience.
     *
     * @todo Provide our own equivalent to Turbo.session.drive and allow
     *   changing it.
     */
    visit(location, options) {

      if (this.#Turbo.session.drive === false) {

        window.location = location.toString();

        return;

      }

      this.#Turbo.visit(location, options);

    }

    /**
     * Bind all of our event handlers.
     */
    #bindEventHandlers() {

      // @see https://ambientimpact.com/web/snippets/javascript-template-literal-as-object-property-name
      $(this.#context).on({
        [`turbo:before-render.${eventNamespace}`]: async (event) => {
          await this.#beforeRenderHandler(event);
        },
        [`turbo:render.${eventNamespace}`]: (event) => {
          this.#renderHandler(event);
        },
        [`turbo:before-fetch-request.${eventNamespace}`]: (event) => {
          this.#beforeFetchRequestHandler(event);
        },
        [`turbo:before-cache.${eventNamespace}`]: async (event) => {
          await this.#beforeCacheHandler(event);
        },
        [`turbo:before-fetch-response.${eventNamespace}`]: (event) => {
          this.#beforeFetchResponseHandler(event);
        },
        [`turbo:fetch-request-error.${eventNamespace}`]: (event) => {
          this.#fetchRequestErrorHandler(event);
        },
        [`turbo:submit-start.${eventNamespace}`]: (event) => {
          this.#formSubmitStartHandler(event);
        },
        [`turbo:submit-end.${eventNamespace}`]: (event) => {
          this.#formSubmitEndHandler(event);
        },
        [`turbo:load.${eventNamespace}`]: (event) => {
          this.#loadHandler(event);
        },
        [`turbo:reload.${eventNamespace}`]: (event) => {
          this.#reloadHandler(event);
        },
        [`turbo:before-prefetch.${eventNamespace}`]: (event) => {
          this.#beforePrefetchHandler(event);
        },
        [`turbo:prefetch-used.${eventNamespace}`]: (event) => {
          this.#prefetchUsedHandler(event);
        },
        [`turbo:click.${eventNamespace}`]: (event) => {
          this.#clickHandler(event);
        },
      });

    }

    /**
     * Unbind all of our event handlers.
     */
    #unbindEventHandlers() {

      // Why jQuery event namespaces are awesome:
      $(this.#context).off(`.${eventNamespace}`);

    }

    /**
     * 'turbo:load' event handler.
     *
     * @param {jQuery.Event} event
     *   The event object generated by Turbo.
     */
    #loadHandler(event) {

      const loadEvent = new CustomEvent(
        'refreshless:load', {
          detail: $.extend(true, {}, event.detail, {
            initial: this.#initialLoad,
          }),
        },
      );

      this.#context.dispatchEvent(loadEvent);

      this.#initialLoad = false;

    }

    /**
     * 'turbo:before-cache' event handler.
     *
     * @param {jQuery.Event} event
     *   The event object generated by Turbo.
     */
    async #beforeCacheHandler(event) {

      $('body', event.target).attr(cachedSnapshotAttr, true);

      const refreshlessEvent = new CustomEvent(
        'refreshless:before-cache',
      );

      this.#context.dispatchEvent(refreshlessEvent);

      // Translate our attribute to Turbo's.
      $(`[${temporaryElementAttr}]`, event.target).attr(
        'data-turbo-temporary', true,
      );

      await this.#Behaviours.detach(true, 'refreshless:before-cache');

    }

    /**
     * 'turbo:before-prefetch' event handler.
     *
     * @param {jQuery.Event} event
     *   The event object generated by Turbo.
     */
    #beforePrefetchHandler(event) {

      const beforePrefetchEvent = new CustomEvent(
        'refreshless:before-prefetch', {
          bubbles:    true,
          cancelable: true,
          detail:     {url: new URL(event.target.href)}
        },
      );

      event.target.dispatchEvent(beforePrefetchEvent);

      if (beforePrefetchEvent.defaultPrevented === true) {
        event.preventDefault();
      }

    }

    /**
     * 'turbo:prefetch-used' event handler.
     *
     * @param {jQuery.Event} event
     *   The event object generated by Turbo.
     */
    #prefetchUsedHandler(event) {

      /**
       * Whether the back-end was notified for this prefetch.
       *
       * @type {Boolean}
       */
      let backendNotified = false;

      /**
       * A Response from the most recent notification, if any.
       *
       * @type {Response}
       */
      let notifiedResponse;

      const notifyBackend = async (
        resend = false,
        fetchOptions = event.detail.fetchOptions,
        ) => {

        if (backendNotified === true && resend === false) {
          return await Promise.resolve(notifiedResponse);
        }

        fetchOptions = $.extend(true, {
          headers: {
            [drupalSettings.refreshless.prefetchNotifyHeaderName]: true,
            // Since we're not sending this through Turbo, the before fetch
            // handler won't be triggered and it won't add our identifying
            // header, so add it here.
            [drupalSettings.refreshless.headerName]: true,
          }
        }, fetchOptions);

        notifiedResponse = await fetch(event.detail.url, fetchOptions);

        backendNotified = true;

        return notifiedResponse;

      };

      const refreshlessEvent = new CustomEvent(
        'refreshless:prefetch-used', {
          bubbles: true,
          detail: {
            fetchOptions:     event.detail.fetchOptions,
            fetchRequest:     event.detail.fetchRequest,
            notifyBackend:    notifyBackend,
            backendNotified:  backendNotified,
            url:              event.detail.url,
          },
        },
      );

      // Note that we're not using event.target as that's usually the <html>
      // element for this event, but the fetch request itself references the
      // link that will have been prefetched.
      event.detail.fetchRequest.target.dispatchEvent(refreshlessEvent);

    }

    /**
     * 'turbo:click' event handler.
     *
     * @param {jQuery.Event} event
     *   The event object generated by Turbo.
     */
    #clickHandler(event) {

      const clickEvent = new CustomEvent(
        'refreshless:click', {
          bubbles:    true,
          cancelable: true,
          detail: $.extend({}, event.detail, {
            url: new URL(event.detail.url),
          }),
        },
      );

      event.target.dispatchEvent(clickEvent);

      if (clickEvent.defaultPrevented === true) {
        event.preventDefault();
      }

    }

    /**
     * 'turbo:before-render' event handler.
     *
     * @param {jQuery.Event} event
     *   The event object generated by Turbo.
     *
     * @see this.#beforeRenderTasks()
     */
    async #beforeRenderHandler(event) {

      this.#previousPreview = this.#isPreview;

      // We want to set this here rather than use a getter method to ensure this
      // remains the same throughout the render lifecycle as getting out of
      // sync has occasionally been an issue with Turbo.
      this.#isPreview = (
        typeof $(event.target).attr('data-turbo-preview') !== 'undefined'
      );

      this.#isCachedSnapshot = (
        typeof $(event.detail.newBody).attr(cachedSnapshotAttr) !== 'undefined'
      );

      this.#isFreshReplacingPreview = (
        this.#isPreview === false &&
        this.#previousPreview === true &&
        this.#isCachedSnapshot === false
      );

      // If told to skip this render, set the variable back to false and return.
      if (this.#skipRender === true) {

        this.#skipRender = false;

        return;

      }

      try {

        await this.#beforeRenderTasks(event);

      } catch (error) {

        console.error(error);

        // If anything threw an exception somewhere in our event handler, ensure
        // that Turbo resumes rendering as a failsafe.
        return await event.detail.resume();

      }

    }

    /**
     * 'turbo:before-render' event tasks.
     *
     * @param {jQuery.Event} event
     *   The event object generated by Turbo.
     */
    async #beforeRenderTasks(event) {

      // Pause Turbo rendering.
      event.preventDefault();

      /**
       * Zero or more render delaying promises.
       *
       * This functions as a stack that multiple event subscribers push on to,
       * with rendering only resuming once all Promises are resolved or
       * rejected.
       *
       * @type {Promise[]}
       */
      const renderDelayPromises = [];

      const delay = (executor) => {

        // Ensure any errors thrown during the executor function are caught so
        // that they don't break rendering nor affect other executors.
        const promise = new Promise(async (resolve, reject) => {

          try {

            await executor(resolve, reject);

          } catch (error) {

            reject(error);

          }

        }).catch((error) => {
          console.error(error);
        });

        renderDelayPromises.push(promise);

        return promise;

      };

      const beforeRenderEvent = new CustomEvent(
        'refreshless:before-render', {detail: {
          delay:                    delay,
          isCachedSnapshot:         this.#isCachedSnapshot,
          isFreshReplacingPreview:  this.#isFreshReplacingPreview,
          isPreview:                this.#isPreview,
          newBody:                  event.detail.newBody,
          previousPreview:          this.#previousPreview,
          render:                   event.detail.render,
          renderMethod:             event.detail.renderMethod,
        },
      });

      this.#context.dispatchEvent(beforeRenderEvent);

      await Promise.allSettled(renderDelayPromises);

      // Only detach if we're about to render a preview, or if the previous
      // render was not a preview.
      if (
        this.#isPreview === true ||
        this.#previousPreview === false
      ) {
        await this.#Behaviours.detach();
      }

      // Resume Turbo rendering.
      return await event.detail.resume();

    }

    /**
     * 'turbo:reload' event handler.
     *
     * @param {jQuery.Event} event
     *   The event object generated by Turbo.
     */
    #reloadHandler(event) {

      const reloadEvent = new CustomEvent(
        'refreshless:reload', {detail: event.detail},
      );

      this.#context.dispatchEvent(reloadEvent);

    }

    /**
     * 'turbo:submit-end' event handler.
     *
     * @param {jQuery.Event} event
     *   The event object generated by Turbo.
     */
    #formSubmitStartHandler(event) {

      const refreshlessEvent = new CustomEvent(
        'refreshless:form-submit-start', {detail: event.detail},
      );

      this.#context.dispatchEvent(refreshlessEvent);

    }

    /**
     * 'turbo:submit-end' event handler.
     *
     * @param {jQuery.Event} event
     *   The event object generated by Turbo.
     */
    #formSubmitEndHandler(event) {

      const refreshlessEvent = new CustomEvent(
        'refreshless:form-submit-response', {detail: event.detail},
      );

      this.#context.dispatchEvent(refreshlessEvent);

    }

    /**
     * 'turbo:render' event handler.
     *
     * @param {jQuery.Event} event
     *   The event object generated by Turbo.
     *
     * @see this.#renderTasks()
     */
    async #renderHandler(event) {

      // If this render happens right after a redirect, skip the next render.
      if (this.#isRedirected === true) {

        this.#isRedirected = false;

        this.#skipRender = true;

        return;

      }

      // Ensure an exception being thrown in the render tasks does not break
      // navigation but is still logged as an error.
      try {

        await this.#renderTasks(event);

      } catch (error) {

        console.error(error);

      }

    }

    /**
     * 'turbo:render' event tasks.
     *
     * @param {jQuery.Event} event
     *   The event object generated by Turbo.
     */
    async #renderTasks(event) {

      const renderEvent = new CustomEvent(
        'refreshless:render', {detail: {
          isCachedSnapshot:         this.#isCachedSnapshot,
          isFreshReplacingPreview:  this.#isFreshReplacingPreview,
          isPreview:                this.#isPreview,
          previousPreview:          this.#previousPreview,
          renderMethod:             event.detail.renderMethod,
        }},
      );

      this.#context.dispatchEvent(renderEvent);

      // Don't update drupalSettings and don't attach behaviours if this is a
      // preview; only do that on the fresh version from a server response.
      if (this.#isPreview === true) {
        return;
      }

      // When Turbo renders a cached snapshot that isn't a preview - i.e.
      // usually the result of a restore navigation via browser history - a lot
      // of behaviours will not attach because the page already has elements
      // marked by once() attributes as that's how they were cached. Since we
      // want previews to be visually similar or identical to the final content
      // that replaces them, we have to detach from the now rendered snapshot
      // here to attempt to undo changes for the attach callbacks to apply
      // again.
      if (this.#isCachedSnapshot === true) {
        await this.#Behaviours.detach(true, 'refreshless:cached-snapshot');
      }

      // Catch any exception thrown by TurboDrupalSettings and log it so that
      // execution continues and behaviours can be attached. While this may
      // lead to some broken things, it's likely a lot less broken than without
      // behaviours.
      try {

        await this.#DrupalSettings.update();

      } catch (error) {

        console.error(error);

      }

      await this.#Behaviours.attach();

    }

    /**
     * 'turbo:before-fetch-request' event handler.
     *
     * @param {jQuery.Event} event
     *   The event object generated by Turbo.
     */
    #beforeFetchRequestHandler(event) {

      // Add a header indicating that this is a RefreshLess request.
      event.detail.fetchOptions.headers[
        drupalSettings.refreshless.headerName
      ] = 'true';

      /**
       * Whether the current request is a prefetch request.
       *
       * @type {Boolean}
       */
      let isPrefetch = false;

      if (
        'X-Sec-Purpose' in event.detail.fetchOptions.headers &&
        event.detail.fetchOptions.headers['X-Sec-Purpose'] === 'prefetch'
      ) {
        isPrefetch = true;
      }

      /**
       * Whether the current request is a preload request.
       *
       * @type {Boolean}
       */
      let isPreload = false;

      if (
        'X-Sec-Purpose' in event.detail.fetchOptions.headers &&
        event.detail.fetchOptions.headers['X-Sec-Purpose'] === 'preload'
      ) {
        isPreload = true;
      }

      /**
       * Whether the current request is a form submit request.
       *
       * Note that we're not checking if this is a POST request because GET
       * requests are also made in some cases, notably Views exposed forms when
       * clicking the Reset button.
       *
       * @type {Boolean}
       */
      let isFormSubmit = $(event.target).is('form');

      /**
       * The request URL object.
       *
       * @type {URL}
       */
      const requestUrl = event.detail.url;

      event.detail.refreshless = {
        isPrefetch,
        isPreload,
        isFormSubmit,
        requestUrl,
      };

      const beforeFetchEvent = new CustomEvent(
        'refreshless:before-fetch-request', {
          cancelable: true,
          detail: {
            fetchOptions: event.detail.fetchOptions,
            isFormSubmit: isFormSubmit,
            isPrefetch:   isPrefetch,
            isPreload:    isPreload,
            resume:       event.detail.resume,
            url:          requestUrl,
          },
        },
      );

      this.#context.dispatchEvent(beforeFetchEvent);

      if (beforeFetchEvent.defaultPrevented === true) {
        event.preventDefault();
      }

    }

    /**
     * 'turbo:before-fetch-response' event handler.
     *
     * @param {jQuery.Event} event
     *   The event object generated by Turbo.
     */
    #beforeFetchResponseHandler(event) {

      // Turbo does not perform a double render when submitting a form, so
      // return on that redirect as that would otherwise cause the destination
      // to not have behaviours attached.
      if (event.detail.refreshless.isFormSubmit === true) {
        return;
      }

      /**
       * The response URL object.
       *
       * @type {URL}
       */
      const responseUrl = new URL(
        event.detail.fetchResponse.response.url,
      );

      // If this fetch request was made to prefetch, trigger the event and
      // return.
      if (event.detail.refreshless.isPrefetch === true) {

        this.#triggerPrefetch(event, responseUrl);

        return;

      }

      // If this fetch request was made to preload, trigger the event and
      // return.
      if (event.detail.refreshless.isPreload === true) {

        this.#triggerPreload(event, responseUrl);

        return;

      }

      // If this was a redirect, trigger the redirect event and return.
      if (event.detail.fetchResponse.response.redirected === true) {

        this.#triggerRedirect(
          event, event.detail.refreshless.requestUrl, responseUrl,
        );

        return;

      }

      // If none of the above, trigger the navigation event.
      this.#triggerNavigation(event, responseUrl);

    }

    /**
     * 'turbo:fetch-request-error' event handler.
     *
     * @param {jQuery.Event} event
     *   The event object generated by Turbo.
     */
    #fetchRequestErrorHandler(event) {

      const fetchErrorEvent = new CustomEvent(
        'refreshless:fetch-request-error', {
          detail: {
            fetchRequest: event.detail.request,
            error:        event.detail.error,
          },
        },
      );

      this.#context.dispatchEvent(fetchErrorEvent);

    }

    /**
     * Trigger the 'refreshless:navigation-response' event.
     *
     * @param {jQuery.Event} turboEvent
     *   The event object generated by Turbo.
     *
     * @param {URL} url
     *   The URL that the response is navigating to.
     */
    #triggerNavigation(turboEvent, url) {

      const navigationResponseEvent = new CustomEvent(
        'refreshless:navigation-response', {
          detail: {
            fetchResponse: turboEvent.detail.fetchResponse,
            url: url,
          },
        }
      );

      this.#context.dispatchEvent(navigationResponseEvent);

    }

    /**
     * Trigger the 'refreshless:prefetch' event.
     *
     * @param {jQuery.Event} turboEvent
     *   The event object generated by Turbo.
     *
     * @param {URL} url
     *   The URL that was prefetched.
     */
    #triggerPrefetch(turboEvent, url) {

      const prefetchEvent = new CustomEvent(
        'refreshless:prefetch', {
          detail: {
            fetchResponse: turboEvent.detail.fetchResponse,
            url: url,
          },
        },
      );

      this.#context.dispatchEvent(prefetchEvent);

    }

    /**
     * Trigger the 'refreshless:preload' event.
     *
     * @param {jQuery.Event} turboEvent
     *   The event object generated by Turbo.
     *
     * @param {URL} url
     *   The URL that was preloaded.
     */
    #triggerPreload(turboEvent, url) {

      const prefetchEvent = new CustomEvent(
        'refreshless:preload', {
          detail: {
            fetchResponse: turboEvent.detail.fetchResponse,
            url: url,
          },
        },
      );

      this.#context.dispatchEvent(prefetchEvent);

    }

    /**
     * Trigger the 'refreshless:redirect' event.
     *
     * @param {jQuery.Event} turboEvent
     *   The event object generated by Turbo.
     *
     * @param {URL} fromUrl
     *   The originally requested URL.
     *
     * @param {URL} toUrl
     *   The URL redirected to.
     */
    #triggerRedirect(turboEvent, fromUrl, toUrl) {

      const redirectEvent = new CustomEvent(
        'refreshless:redirect', {
          detail: {
            fetchResponse: turboEvent.detail.fetchResponse,
            from: fromUrl,
            to:   toUrl,
          },
        },
      );

      this.#context.dispatchEvent(redirectEvent);

      this.#isRedirected = true;

    }

  }

  // Don't accidentally initialize more than once. This shouldn't happen, but
  // there are still edge cases in our additive aggregation.
  if ('Turbo' in Drupal.RefreshLess) {
    return;
  }

  // Merge Drupal.RefreshLess.classes into the existing Drupal global.
  $.extend(true, Drupal, {RefreshLess: {classes: {Turbo: RefreshLessTurbo}}});

  Drupal.RefreshLess.Turbo = new RefreshLessTurbo(
    html, TurboBehaviours, TurboDrupalSettings, TurboProgressBar,
    TurboScriptManager, TurboStylesheetManager, Turbo,
  );

  // Provides a shorter alias for our visit method.
  //
  // @todo Create a way for implementations to provide a set of expected methods
  //   on Drupal.RefreshLess; ideally will involve TypeScript interfaces when we
  //   switch over.
  Drupal.RefreshLess.visit = (location, options) => {
    Drupal.RefreshLess.Turbo.visit(location, options);
  };

})(
  document.documentElement,
  Drupal,
  Drupal.RefreshLess.classes.TurboBehaviours,
  Drupal.RefreshLess.classes.TurboDrupalSettings,
  Drupal.RefreshLess.classes.TurboProgressBar,
  Drupal.RefreshLess.classes.TurboScriptManager,
  Drupal.RefreshLess.classes.TurboStylesheetManager,
  drupalSettings,
  jQuery,
  Turbo,
);

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc