1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
|
/**
* @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);
|