Loading core/core.libraries.yml +1 −0 Original line number Diff line number Diff line Loading @@ -617,6 +617,7 @@ drupal.form: drupal.htmx: version: VERSION js: misc/htmx/htmx-utils.js: {} misc/htmx/htmx-assets.js: {} misc/htmx/htmx-behaviors.js: {} dependencies: Loading core/misc/htmx/htmx-assets.js +32 −89 Original line number Diff line number Diff line Loading @@ -7,10 +7,7 @@ * page. */ (function (Drupal, drupalSettings, loadjs, htmx) { // Disable htmx loading of script tags since we're handling it. htmx.config.allowScriptTags = false; (function (Drupal, drupalSettings, htmx) { /** * Used to hold the loadjs promise. * Loading @@ -22,46 +19,12 @@ 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; } }); htmx.on('htmx:beforeRequest', ({ detail }) => { requestAssetsLoaded.set(detail.xhr, Promise.resolve()); }); return current; } /** * Send the current ajax page state with each request. * Loading Loading @@ -94,6 +57,10 @@ // Custom event to detach behaviors. htmx.trigger(detail.elt, 'htmx:drupal:unload'); if (!detail.xhr) { return; } // 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); Loading @@ -103,73 +70,49 @@ const settingsElement = responseHTML.querySelector( ':is(head, body) > script[type="application/json"][data-drupal-selector="drupal-settings-json"]', ); // Remove so that HTML doesn't add this during swap. settingsElement?.remove(); if (settingsElement !== null) { mergeSettings(drupalSettings, JSON.parse(settingsElement.textContent)); Drupal.htmx.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( const assetsElements = 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); // Remove all assets from the serverResponse where we handle the loading. assetsElements.forEach((element) => element.remove()); // Transform the data from the DOM into an ajax command like format. const data = Array.from(assetsElements).map(({ attributes }) => { const attrs = {}; Object.values(attributes).forEach(({ name, value }) => { attrs[name] = value; }); }, return attrs; }); return bundleId; }); // The response is the whole page without the assets we handle with loadjs. detail.serverResponse = responseHTML.documentElement.outerHTML; // 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); requestAssetsLoaded.get(detail.xhr).then(() => Drupal.htmx.addAssets(data)); }); // 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(() => { (requestAssetsLoaded.get(detail.xhr) || Promise.resolve()).then(() => { // Some HTMX swaps put the incoming element before or after detail.elt. htmx.trigger(detail.elt.parentNode, 'htmx:drupal:load'); // This should be automatic but don't wait for the garbage collector. requestAssetsLoaded.delete(detail.xhr); }); }); })(Drupal, drupalSettings, loadjs, htmx); })(Drupal, drupalSettings, htmx); core/misc/htmx/htmx-utils.js 0 → 100644 +109 −0 Original line number Diff line number Diff line /** * @file * Connect Drupal.behaviors to htmx inserted content. */ (function (Drupal, htmx, drupalSettings, loadjs) { /** * Namespace for htmx utilities. */ Drupal.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 */ 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] = Drupal.htmx.mergeSettings(current[key], value); break; case '[object Array]': current[key] = Drupal.htmx.mergeSettings( new Array(value.length), value, ); break; default: current[key] = value; } }); }); return current; }, /** * * @param {array} data * * @return {Promise} */ addAssets(data) { const bundleIds = data .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!' : ''; } 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.entries(attributes).forEach(([name, value]) => { element.setAttribute(name, value); }); }, }); return bundleId; }); // 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); }, }); }); } return assetsLoaded; }, }; })(Drupal, htmx, drupalSettings, loadjs); core/modules/big_pipe/big_pipe.libraries.yml +6 −1 Original line number Diff line number Diff line big_pipe: version: VERSION js: js/big_pipe.commands.js: {} js/big_pipe.js: {} drupalSettings: bigPipePlaceholderIds: [] dependencies: - core/drupal.ajax - core/htmx - core/drupal - core/drupal.htmx - core/drupalSettings - core/loadjs - core/drupal.message core/modules/big_pipe/js/big_pipe.commands.js 0 → 100644 +164 −0 Original line number Diff line number Diff line ((Drupal, drupalSettings, htmx) => { /** * Holds helpers for big pipe processing. * * @namespace */ Drupal.bigPipe = {}; /** * Helper method to make sure commands are executed in sequence. * * @param {Array} response * Drupal Ajax response. * @param {number} status * XMLHttpRequest status. * * @return {Promise} * The promise that will resolve once all commands have finished executing. */ Drupal.bigPipe.commandExecutionQueue = function (response, status) { const ajaxCommands = Drupal.bigPipe.commands; return Object.keys(response || {}).reduce( // Add all commands to a single execution queue. (executionQueue, key) => executionQueue.then(() => { const { command } = response[key]; if (command && ajaxCommands[command]) { // When a command returns a promise, the remaining commands will not // execute until that promise has been fulfilled. This is typically // used to ensure JavaScript files added via the 'add_js' command // have loaded before subsequent commands execute. return ajaxCommands[command](response[key], status); } }), Promise.resolve(), ); }; /** * Implementation of Drupal ajax commands with htmx. * * @type {object} */ Drupal.bigPipe.commands = { /** * Command to insert new content into the DOM. * * @param {object} response * The response from the Ajax request. * @param {string} response.data * The new HTML. * @param {string} [response.method] * The jQuery DOM manipulation method to be used. * @param {string} [response.selector] * An optional selector string. */ insert({ data, method, selector }) { const target = htmx.find(selector); // Detach behaviors. htmx.trigger(target, 'htmx:drupal:unload'); // Map jQuery manipulation methods to the DOM equivalent. const styleMap = { replaceWith: 'outerHTML', html: 'innerHTML', before: 'beforebegin', prepend: 'afterbegin', append: 'beforeend', after: 'afterend', }; // Make the actual swap and initialize everything. htmx.swap(target, data, { swapStyle: styleMap[method] || 'outerHTML', }); }, /** * Command to set the window.location, redirecting the browser. * * @param {object} response * The response from the Ajax request. * @param {string} response.url * The URL to redirect to. */ redirect({ url }) { window.location = url; }, /** * Command to set the settings used for other commands in this response. * * This method will also remove expired `drupalSettings.ajax` settings. * * @param {object} response * The response from the Ajax request. * @param {boolean} response.merge * Determines whether the additional settings should be merged to the * global settings. * @param {object} response.settings * Contains additional settings to add to the global settings. */ settings({ merge, settings }) { if (merge) { Drupal.htmx.mergeSettings(drupalSettings, settings); } }, /** * Command to add css. * * @param {object} response * The response from the Ajax request. * @param {object[]} response.data * An array of styles to be added. */ add_css({ data }) { return Drupal.htmx.addAssets(data); }, /** * Command to add a message to the message area. * * @param {object} response * The response from the Ajax request. * @param {string} response.messageWrapperQuerySelector * The zone where to add the message. If null, the default will be used. * @param {string} response.message * The message text. * @param {string} response.messageOptions * The options argument for Drupal.Message().add(). * @param {boolean} response.clearPrevious * If true, clear previous messages. */ message({ message, messageOptions, messageWrapperQuerySelector, clearPrevious, }) { const messages = new Drupal.Message( document.querySelector(messageWrapperQuerySelector), ); if (clearPrevious) { messages.clear(); } messages.add(message, messageOptions); }, /** * Command to add JS. * * @param {object} response * The response from the Ajax request. * @param {Array} response.data * An array of objects of script attributes. */ add_js({ data }) { return Drupal.htmx.addAssets(data).then(() => { htmx.trigger(document.body, 'htmx:drupal:load'); }); }, }; })(Drupal, drupalSettings, htmx); Loading
core/core.libraries.yml +1 −0 Original line number Diff line number Diff line Loading @@ -617,6 +617,7 @@ drupal.form: drupal.htmx: version: VERSION js: misc/htmx/htmx-utils.js: {} misc/htmx/htmx-assets.js: {} misc/htmx/htmx-behaviors.js: {} dependencies: Loading
core/misc/htmx/htmx-assets.js +32 −89 Original line number Diff line number Diff line Loading @@ -7,10 +7,7 @@ * page. */ (function (Drupal, drupalSettings, loadjs, htmx) { // Disable htmx loading of script tags since we're handling it. htmx.config.allowScriptTags = false; (function (Drupal, drupalSettings, htmx) { /** * Used to hold the loadjs promise. * Loading @@ -22,46 +19,12 @@ 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; } }); htmx.on('htmx:beforeRequest', ({ detail }) => { requestAssetsLoaded.set(detail.xhr, Promise.resolve()); }); return current; } /** * Send the current ajax page state with each request. * Loading Loading @@ -94,6 +57,10 @@ // Custom event to detach behaviors. htmx.trigger(detail.elt, 'htmx:drupal:unload'); if (!detail.xhr) { return; } // 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); Loading @@ -103,73 +70,49 @@ const settingsElement = responseHTML.querySelector( ':is(head, body) > script[type="application/json"][data-drupal-selector="drupal-settings-json"]', ); // Remove so that HTML doesn't add this during swap. settingsElement?.remove(); if (settingsElement !== null) { mergeSettings(drupalSettings, JSON.parse(settingsElement.textContent)); Drupal.htmx.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( const assetsElements = 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); // Remove all assets from the serverResponse where we handle the loading. assetsElements.forEach((element) => element.remove()); // Transform the data from the DOM into an ajax command like format. const data = Array.from(assetsElements).map(({ attributes }) => { const attrs = {}; Object.values(attributes).forEach(({ name, value }) => { attrs[name] = value; }); }, return attrs; }); return bundleId; }); // The response is the whole page without the assets we handle with loadjs. detail.serverResponse = responseHTML.documentElement.outerHTML; // 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); requestAssetsLoaded.get(detail.xhr).then(() => Drupal.htmx.addAssets(data)); }); // 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(() => { (requestAssetsLoaded.get(detail.xhr) || Promise.resolve()).then(() => { // Some HTMX swaps put the incoming element before or after detail.elt. htmx.trigger(detail.elt.parentNode, 'htmx:drupal:load'); // This should be automatic but don't wait for the garbage collector. requestAssetsLoaded.delete(detail.xhr); }); }); })(Drupal, drupalSettings, loadjs, htmx); })(Drupal, drupalSettings, htmx);
core/misc/htmx/htmx-utils.js 0 → 100644 +109 −0 Original line number Diff line number Diff line /** * @file * Connect Drupal.behaviors to htmx inserted content. */ (function (Drupal, htmx, drupalSettings, loadjs) { /** * Namespace for htmx utilities. */ Drupal.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 */ 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] = Drupal.htmx.mergeSettings(current[key], value); break; case '[object Array]': current[key] = Drupal.htmx.mergeSettings( new Array(value.length), value, ); break; default: current[key] = value; } }); }); return current; }, /** * * @param {array} data * * @return {Promise} */ addAssets(data) { const bundleIds = data .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!' : ''; } 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.entries(attributes).forEach(([name, value]) => { element.setAttribute(name, value); }); }, }); return bundleId; }); // 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); }, }); }); } return assetsLoaded; }, }; })(Drupal, htmx, drupalSettings, loadjs);
core/modules/big_pipe/big_pipe.libraries.yml +6 −1 Original line number Diff line number Diff line big_pipe: version: VERSION js: js/big_pipe.commands.js: {} js/big_pipe.js: {} drupalSettings: bigPipePlaceholderIds: [] dependencies: - core/drupal.ajax - core/htmx - core/drupal - core/drupal.htmx - core/drupalSettings - core/loadjs - core/drupal.message
core/modules/big_pipe/js/big_pipe.commands.js 0 → 100644 +164 −0 Original line number Diff line number Diff line ((Drupal, drupalSettings, htmx) => { /** * Holds helpers for big pipe processing. * * @namespace */ Drupal.bigPipe = {}; /** * Helper method to make sure commands are executed in sequence. * * @param {Array} response * Drupal Ajax response. * @param {number} status * XMLHttpRequest status. * * @return {Promise} * The promise that will resolve once all commands have finished executing. */ Drupal.bigPipe.commandExecutionQueue = function (response, status) { const ajaxCommands = Drupal.bigPipe.commands; return Object.keys(response || {}).reduce( // Add all commands to a single execution queue. (executionQueue, key) => executionQueue.then(() => { const { command } = response[key]; if (command && ajaxCommands[command]) { // When a command returns a promise, the remaining commands will not // execute until that promise has been fulfilled. This is typically // used to ensure JavaScript files added via the 'add_js' command // have loaded before subsequent commands execute. return ajaxCommands[command](response[key], status); } }), Promise.resolve(), ); }; /** * Implementation of Drupal ajax commands with htmx. * * @type {object} */ Drupal.bigPipe.commands = { /** * Command to insert new content into the DOM. * * @param {object} response * The response from the Ajax request. * @param {string} response.data * The new HTML. * @param {string} [response.method] * The jQuery DOM manipulation method to be used. * @param {string} [response.selector] * An optional selector string. */ insert({ data, method, selector }) { const target = htmx.find(selector); // Detach behaviors. htmx.trigger(target, 'htmx:drupal:unload'); // Map jQuery manipulation methods to the DOM equivalent. const styleMap = { replaceWith: 'outerHTML', html: 'innerHTML', before: 'beforebegin', prepend: 'afterbegin', append: 'beforeend', after: 'afterend', }; // Make the actual swap and initialize everything. htmx.swap(target, data, { swapStyle: styleMap[method] || 'outerHTML', }); }, /** * Command to set the window.location, redirecting the browser. * * @param {object} response * The response from the Ajax request. * @param {string} response.url * The URL to redirect to. */ redirect({ url }) { window.location = url; }, /** * Command to set the settings used for other commands in this response. * * This method will also remove expired `drupalSettings.ajax` settings. * * @param {object} response * The response from the Ajax request. * @param {boolean} response.merge * Determines whether the additional settings should be merged to the * global settings. * @param {object} response.settings * Contains additional settings to add to the global settings. */ settings({ merge, settings }) { if (merge) { Drupal.htmx.mergeSettings(drupalSettings, settings); } }, /** * Command to add css. * * @param {object} response * The response from the Ajax request. * @param {object[]} response.data * An array of styles to be added. */ add_css({ data }) { return Drupal.htmx.addAssets(data); }, /** * Command to add a message to the message area. * * @param {object} response * The response from the Ajax request. * @param {string} response.messageWrapperQuerySelector * The zone where to add the message. If null, the default will be used. * @param {string} response.message * The message text. * @param {string} response.messageOptions * The options argument for Drupal.Message().add(). * @param {boolean} response.clearPrevious * If true, clear previous messages. */ message({ message, messageOptions, messageWrapperQuerySelector, clearPrevious, }) { const messages = new Drupal.Message( document.querySelector(messageWrapperQuerySelector), ); if (clearPrevious) { messages.clear(); } messages.add(message, messageOptions); }, /** * Command to add JS. * * @param {object} response * The response from the Ajax request. * @param {Array} response.data * An array of objects of script attributes. */ add_js({ data }) { return Drupal.htmx.addAssets(data).then(() => { htmx.trigger(document.body, 'htmx:drupal:load'); }); }, }; })(Drupal, drupalSettings, htmx);