@betterportal/theme-bootstrap1 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/bsb-plugin.json +23 -0
- package/bsb-tests.json +14 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +3 -0
- package/lib/index.js.map +1 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/assets.cleanup.md +84 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/assets.d.ts +6 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/assets.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/assets.js +2094 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/assets.js.map +1 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/index.d.ts +106 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/index.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/index.js +1029 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/index.js.map +1 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/theme/index.d.ts +72 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/theme/index.d.ts.map +1 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/theme/index.js +1942 -0
- package/lib/plugins/service-betterportal-theme-bootstrap1/theme/index.js.map +1 -0
- package/lib/schemas/service-betterportal-theme-bootstrap1.json +131 -0
- package/lib/schemas/service-betterportal-theme-bootstrap1.plugin.json +144 -0
- package/package.json +57 -0
|
@@ -0,0 +1,2094 @@
|
|
|
1
|
+
/** @jsxImportSource jsx-htmx */
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { js } from "jsx-htmx";
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
const BootstrapCssPath = require.resolve("bootstrap/dist/css/bootstrap.min.css");
|
|
7
|
+
const BootstrapBundlePath = require.resolve("bootstrap/dist/js/bootstrap.bundle.min.js");
|
|
8
|
+
const HtmxPath = require.resolve("htmx.org/dist/htmx.min.js");
|
|
9
|
+
const HtmxSsePath = require.resolve("htmx.org/dist/ext/hx-sse.min.js");
|
|
10
|
+
const HtmxPreloadPath = require.resolve("htmx.org/dist/ext/hx-preload.min.js");
|
|
11
|
+
const AssetCache = new Map();
|
|
12
|
+
function readTextAsset(filePath, contentType) {
|
|
13
|
+
return readFile(filePath, "utf8").then((body) => ({ body, contentType }));
|
|
14
|
+
}
|
|
15
|
+
function shellRuntimeSource() {
|
|
16
|
+
// esbuild/tsx wraps functions with __name() for .name preservation;
|
|
17
|
+
// shim it for the browser where that helper doesn't exist
|
|
18
|
+
const body = js(() => {
|
|
19
|
+
(() => {
|
|
20
|
+
htmx.config.sse = {
|
|
21
|
+
reconnect: true, // Auto-reconnect on stream end (default: true for hx-sse:connect, false for hx-get)
|
|
22
|
+
reconnectDelay: 500, // Initial reconnect delay in ms (default: 500)
|
|
23
|
+
reconnectMaxDelay: 60000, // Maximum reconnect delay in ms (default: 60000)
|
|
24
|
+
reconnectMaxAttempts: Infinity, // Maximum reconnection attempts (default: Infinity)
|
|
25
|
+
reconnectJitter: 0.3, // Jitter factor 0-1 for delay randomization (default: 0.3)
|
|
26
|
+
pauseOnBackground: true // Disconnect when tab is backgrounded (default: true for hx-sse:connect, false for hx-get)
|
|
27
|
+
};
|
|
28
|
+
const HX_METHODS = ["hx-get", "hx-post", "hx-put", "hx-delete", "hx-patch"];
|
|
29
|
+
const DOWNLOAD_ATTR = "hx-download";
|
|
30
|
+
// -- DOM helpers --
|
|
31
|
+
const shellRoot = () => document.querySelector("[data-bp-shell-root]");
|
|
32
|
+
const routeLinks = () => Array.from(document.querySelectorAll("[data-bp-route-link]"));
|
|
33
|
+
const titleNode = () => document.querySelector("[data-bp-current-title]");
|
|
34
|
+
const breadcrumbNode = () => document.querySelector("[data-bp-current-breadcrumb]");
|
|
35
|
+
const navGroups = () => Array.from(document.querySelectorAll("[data-bp-nav-group]"));
|
|
36
|
+
const mainOutlet = () => document.querySelector("#bp-main");
|
|
37
|
+
const contentFrame = () => document.querySelector(".bp-admin__content-frame");
|
|
38
|
+
const topbarProgress = () => document.querySelector("#bp-topbar-progress");
|
|
39
|
+
const errorNode = () => document.querySelector("#bp-content-error");
|
|
40
|
+
const profileSlot = () => document.querySelector("[data-bp-slot='nav-profile']");
|
|
41
|
+
const profileMirror = () => document.querySelector("[data-bp-profile-mirror]");
|
|
42
|
+
const syncProfileMirror = () => {
|
|
43
|
+
const slot = profileSlot();
|
|
44
|
+
const mirror = profileMirror();
|
|
45
|
+
if (!slot || !mirror)
|
|
46
|
+
return;
|
|
47
|
+
// Clone content, strip data-bp-shell-route to avoid double-processing
|
|
48
|
+
mirror.innerHTML = slot.innerHTML;
|
|
49
|
+
// Re-init bootstrap components on cloned content (dropdowns etc.)
|
|
50
|
+
initBootstrapComponents(mirror);
|
|
51
|
+
};
|
|
52
|
+
const isMainTarget = (target) => !!target && (target === "#bp-main" // htmx.ajax target selectors stay strings in ctx
|
|
53
|
+
|| target.id === "bp-main"
|
|
54
|
+
|| target === mainOutlet());
|
|
55
|
+
const normalizePath = (path) => {
|
|
56
|
+
const normalized = (path || "/").replace(/\/+$/, "");
|
|
57
|
+
return normalized === "" ? "/" : normalized;
|
|
58
|
+
};
|
|
59
|
+
const requestTargetsMain = (detail) => {
|
|
60
|
+
const ctx = detail && (detail.ctx || detail);
|
|
61
|
+
if (!ctx)
|
|
62
|
+
return false;
|
|
63
|
+
if (isMainTarget(ctx.target) || isMainTarget(detail?.target))
|
|
64
|
+
return true;
|
|
65
|
+
const source = ctx.sourceElement || detail?.elt;
|
|
66
|
+
const sel = source && source.getAttribute ? source.getAttribute("hx-target") : null;
|
|
67
|
+
return sel === "#bp-main";
|
|
68
|
+
};
|
|
69
|
+
const camelChromeKey = (key) => key.replace(/-([a-z0-9])/g, (_m, ch) => String(ch).toUpperCase());
|
|
70
|
+
const parseChromeFromContentType = (contentType) => {
|
|
71
|
+
const chrome = {};
|
|
72
|
+
const re = /(?:^|;)\s*bp-chrome-([a-z][a-z0-9-]*)=([^;]*)/g;
|
|
73
|
+
let match;
|
|
74
|
+
while ((match = re.exec(contentType)) !== null) {
|
|
75
|
+
const key = camelChromeKey(match[1]);
|
|
76
|
+
const raw = decodeURIComponent((match[2] || "").trim().replace(/^"|"$/g, ""));
|
|
77
|
+
chrome[key] =
|
|
78
|
+
raw === "true" ? true :
|
|
79
|
+
raw === "false" ? false :
|
|
80
|
+
raw !== "" && Number.isFinite(Number(raw)) ? Number(raw) :
|
|
81
|
+
raw;
|
|
82
|
+
}
|
|
83
|
+
return Object.keys(chrome).length ? chrome : null;
|
|
84
|
+
};
|
|
85
|
+
const setChromeFullScreen = (fullScreen) => {
|
|
86
|
+
const root = shellRoot();
|
|
87
|
+
if (!root)
|
|
88
|
+
return;
|
|
89
|
+
root.setAttribute("data-bp-chrome-full-screen", fullScreen ? "true" : "false");
|
|
90
|
+
};
|
|
91
|
+
const applyChromeFromResponse = (detail) => {
|
|
92
|
+
if (!requestTargetsMain(detail))
|
|
93
|
+
return;
|
|
94
|
+
const response = detail?.ctx?.response;
|
|
95
|
+
const contentType = response?.headers?.get?.("content-type") || "";
|
|
96
|
+
if (!contentType.includes("text/html"))
|
|
97
|
+
return;
|
|
98
|
+
const chrome = parseChromeFromContentType(contentType);
|
|
99
|
+
setChromeFullScreen(chrome?.fullScreen === true);
|
|
100
|
+
};
|
|
101
|
+
// -- Bootstrap component lifecycle --
|
|
102
|
+
const teleportedModals = new Set();
|
|
103
|
+
const teleportedOffcanvas = new Set();
|
|
104
|
+
const bootstrap = window.bootstrap;
|
|
105
|
+
const cleanupTeleportedModals = () => {
|
|
106
|
+
teleportedModals.forEach((el) => {
|
|
107
|
+
try {
|
|
108
|
+
const inst = bootstrap && bootstrap.Modal.getInstance(el);
|
|
109
|
+
if (inst) {
|
|
110
|
+
inst.hide();
|
|
111
|
+
inst.dispose();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch { /* already disposed */ }
|
|
115
|
+
el.remove();
|
|
116
|
+
});
|
|
117
|
+
teleportedModals.clear();
|
|
118
|
+
};
|
|
119
|
+
const cleanupTeleportedOffcanvas = () => {
|
|
120
|
+
teleportedOffcanvas.forEach((el) => {
|
|
121
|
+
try {
|
|
122
|
+
const inst = bootstrap && bootstrap.Offcanvas.getInstance(el);
|
|
123
|
+
if (inst) {
|
|
124
|
+
inst.hide();
|
|
125
|
+
inst.dispose();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch { /* already disposed */ }
|
|
129
|
+
el.remove();
|
|
130
|
+
});
|
|
131
|
+
teleportedOffcanvas.clear();
|
|
132
|
+
};
|
|
133
|
+
const cleanupStaleBootstrapOverlays = () => {
|
|
134
|
+
if (!bootstrap)
|
|
135
|
+
return;
|
|
136
|
+
const hasVisibleOverlay = !!document.querySelector(".modal.show, .modal.showing, .offcanvas.show, .offcanvas.showing");
|
|
137
|
+
const hasActiveOverlay = Array.from(document.querySelectorAll(".modal, .offcanvas")).some((el) => {
|
|
138
|
+
const bs = bootstrap;
|
|
139
|
+
const instance = bs.Modal?.getInstance(el) ?? bs.Offcanvas?.getInstance(el);
|
|
140
|
+
const state = instance;
|
|
141
|
+
return Boolean(state?._isShown || state?._isTransitioning);
|
|
142
|
+
});
|
|
143
|
+
if (hasActiveOverlay)
|
|
144
|
+
return;
|
|
145
|
+
if (hasVisibleOverlay)
|
|
146
|
+
return;
|
|
147
|
+
document.querySelectorAll(".modal-backdrop, .offcanvas-backdrop").forEach((el) => el.remove());
|
|
148
|
+
document.body.classList.remove("modal-open");
|
|
149
|
+
document.body.style.removeProperty("overflow");
|
|
150
|
+
document.body.style.removeProperty("padding-right");
|
|
151
|
+
};
|
|
152
|
+
const closeContainingOffcanvas = (source) => {
|
|
153
|
+
const panel = source?.closest?.(".offcanvas.show");
|
|
154
|
+
if (!panel || !bootstrap)
|
|
155
|
+
return;
|
|
156
|
+
try {
|
|
157
|
+
(bootstrap.Offcanvas.getInstance(panel) || new bootstrap.Offcanvas(panel)).hide();
|
|
158
|
+
}
|
|
159
|
+
catch { /* non-fatal */ }
|
|
160
|
+
};
|
|
161
|
+
const contentServiceIdFor = (root) => {
|
|
162
|
+
return serviceIdAttr(root) || serviceIdAttr(mainOutlet()) || currentServiceId();
|
|
163
|
+
};
|
|
164
|
+
const teleportModals = (root) => {
|
|
165
|
+
if (!root)
|
|
166
|
+
return;
|
|
167
|
+
const ownerServiceId = contentServiceIdFor(root);
|
|
168
|
+
root.querySelectorAll(".modal").forEach((modal) => {
|
|
169
|
+
modal.setAttribute("data-bp-content-owned", "true");
|
|
170
|
+
if (ownerServiceId && !serviceIdAttr(modal))
|
|
171
|
+
modal.setAttribute("data-bp-service", ownerServiceId);
|
|
172
|
+
document.body.appendChild(modal);
|
|
173
|
+
teleportedModals.add(modal);
|
|
174
|
+
});
|
|
175
|
+
};
|
|
176
|
+
const teleportOffcanvas = (root) => {
|
|
177
|
+
if (!root)
|
|
178
|
+
return;
|
|
179
|
+
const ownerServiceId = contentServiceIdFor(root);
|
|
180
|
+
root.querySelectorAll(".offcanvas").forEach((panel) => {
|
|
181
|
+
panel.setAttribute("data-bp-content-owned", "true");
|
|
182
|
+
if (ownerServiceId && !serviceIdAttr(panel))
|
|
183
|
+
panel.setAttribute("data-bp-service", ownerServiceId);
|
|
184
|
+
document.body.appendChild(panel);
|
|
185
|
+
teleportedOffcanvas.add(panel);
|
|
186
|
+
});
|
|
187
|
+
};
|
|
188
|
+
// Convert <div data-bp-sidebar="id"> wrappers into Bootstrap offcanvas markup.
|
|
189
|
+
// Falls back gracefully if JS fails - content shows inline.
|
|
190
|
+
const convertSidebars = (root) => {
|
|
191
|
+
if (!root)
|
|
192
|
+
return;
|
|
193
|
+
const scope = root.querySelectorAll
|
|
194
|
+
? root.querySelectorAll('[data-bp-sidebar]:not([data-bp-sidebar-ready])')
|
|
195
|
+
: [];
|
|
196
|
+
scope.forEach((el) => {
|
|
197
|
+
if (el.hasAttribute('data-bp-sidebar-ready'))
|
|
198
|
+
return;
|
|
199
|
+
const id = el.getAttribute('data-bp-sidebar') || ('bp-sidebar-' + Math.random().toString(36).slice(2));
|
|
200
|
+
const title = el.getAttribute('data-bp-sidebar-title') || '';
|
|
201
|
+
const position = el.getAttribute('data-bp-sidebar-position') || 'end';
|
|
202
|
+
const width = el.getAttribute('data-bp-sidebar-width');
|
|
203
|
+
const innerHtml = el.innerHTML;
|
|
204
|
+
el.setAttribute('id', id);
|
|
205
|
+
el.setAttribute('data-bp-sidebar-ready', '');
|
|
206
|
+
el.className = ('offcanvas offcanvas-' + position + ' ' + (el.className || '')).trim();
|
|
207
|
+
el.setAttribute('tabindex', '-1');
|
|
208
|
+
if (width)
|
|
209
|
+
el.style.width = width;
|
|
210
|
+
el.innerHTML =
|
|
211
|
+
'<div class="offcanvas-header">' +
|
|
212
|
+
(title ? '<h5 class="offcanvas-title">' + title + '</h5>' : '<span></span>') +
|
|
213
|
+
'<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>' +
|
|
214
|
+
'</div>' +
|
|
215
|
+
'<div class="offcanvas-body">' + innerHtml + '</div>';
|
|
216
|
+
});
|
|
217
|
+
// Wire up open triggers
|
|
218
|
+
const triggers = root.querySelectorAll
|
|
219
|
+
? root.querySelectorAll('[data-bp-sidebar-open]:not([data-bp-trigger-ready])')
|
|
220
|
+
: [];
|
|
221
|
+
triggers.forEach((btn) => {
|
|
222
|
+
btn.setAttribute('data-bp-trigger-ready', '');
|
|
223
|
+
btn.setAttribute('data-bs-toggle', 'offcanvas');
|
|
224
|
+
btn.setAttribute('data-bs-target', '#' + btn.getAttribute('data-bp-sidebar-open'));
|
|
225
|
+
});
|
|
226
|
+
};
|
|
227
|
+
const initBootstrapComponents = (root) => {
|
|
228
|
+
if (!root || !bootstrap)
|
|
229
|
+
return;
|
|
230
|
+
convertSidebars(root);
|
|
231
|
+
root.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((el) => {
|
|
232
|
+
if (!bootstrap.Tooltip.getInstance(el))
|
|
233
|
+
new bootstrap.Tooltip(el);
|
|
234
|
+
});
|
|
235
|
+
root.querySelectorAll('[data-bs-toggle="popover"]').forEach((el) => {
|
|
236
|
+
if (!bootstrap.Popover.getInstance(el))
|
|
237
|
+
new bootstrap.Popover(el);
|
|
238
|
+
});
|
|
239
|
+
};
|
|
240
|
+
const disposeBootstrapComponents = (root) => {
|
|
241
|
+
if (!root || !bootstrap)
|
|
242
|
+
return;
|
|
243
|
+
root.querySelectorAll('[data-bs-toggle="tooltip"]').forEach((el) => {
|
|
244
|
+
const inst = bootstrap.Tooltip.getInstance(el);
|
|
245
|
+
if (inst)
|
|
246
|
+
inst.dispose();
|
|
247
|
+
});
|
|
248
|
+
root.querySelectorAll('[data-bs-toggle="popover"]').forEach((el) => {
|
|
249
|
+
const inst = bootstrap.Popover.getInstance(el);
|
|
250
|
+
if (inst)
|
|
251
|
+
inst.dispose();
|
|
252
|
+
});
|
|
253
|
+
};
|
|
254
|
+
const scrollPageToTop = () => {
|
|
255
|
+
const workspace = document.querySelector(".bp-admin__workspace");
|
|
256
|
+
const frame = contentFrame();
|
|
257
|
+
const main = document.querySelector(".bp-shell__main");
|
|
258
|
+
[workspace, frame, main, document.scrollingElement, document.documentElement, document.body].forEach((el) => {
|
|
259
|
+
if (el && typeof el.scrollTo === "function") {
|
|
260
|
+
el.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
|
261
|
+
}
|
|
262
|
+
else if (el) {
|
|
263
|
+
el.scrollTop = 0;
|
|
264
|
+
el.scrollLeft = 0;
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
|
268
|
+
};
|
|
269
|
+
// -- Loading / error UI --
|
|
270
|
+
const markLoaded = () => { mainOutlet()?.setAttribute("data-bp-loaded", "yes"); };
|
|
271
|
+
const hasLoaded = () => mainOutlet()?.getAttribute("data-bp-loaded") === "yes";
|
|
272
|
+
const disableInitialMainLoad = () => {
|
|
273
|
+
const outlet = mainOutlet();
|
|
274
|
+
if (!outlet)
|
|
275
|
+
return;
|
|
276
|
+
if ((outlet.getAttribute("hx-trigger") || "").trim() === "load") {
|
|
277
|
+
outlet.removeAttribute("hx-trigger");
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
const setLoading = (loading) => {
|
|
281
|
+
contentFrame()?.classList.toggle("is-loading", loading);
|
|
282
|
+
topbarProgress()?.classList.toggle("is-active", loading);
|
|
283
|
+
};
|
|
284
|
+
const clearError = () => {
|
|
285
|
+
const node = errorNode();
|
|
286
|
+
if (!node)
|
|
287
|
+
return;
|
|
288
|
+
node.innerHTML = "";
|
|
289
|
+
node.classList.remove("is-visible");
|
|
290
|
+
};
|
|
291
|
+
const renderErrorAction = (action) => {
|
|
292
|
+
if (!action)
|
|
293
|
+
return "";
|
|
294
|
+
return `<button type="button" class="btn btn-sm btn-outline-danger" data-bp-error-action="${action.kind}">${action.label}</button>`;
|
|
295
|
+
};
|
|
296
|
+
const bannerActionForStatus = (status) => {
|
|
297
|
+
if (status === 401) {
|
|
298
|
+
const loginUrl = shellRoot()?.getAttribute("data-bp-login-url");
|
|
299
|
+
if (loginUrl)
|
|
300
|
+
return { kind: "login", label: "Sign in" };
|
|
301
|
+
return { kind: "reload", label: "Reload" };
|
|
302
|
+
}
|
|
303
|
+
return { kind: "reload", label: "Reload" };
|
|
304
|
+
};
|
|
305
|
+
const replaceMainWithError = (title, message, action, context) => {
|
|
306
|
+
const outlet = mainOutlet();
|
|
307
|
+
if (!outlet)
|
|
308
|
+
return;
|
|
309
|
+
outlet.innerHTML =
|
|
310
|
+
`<div class="bp-shell__empty-state">` +
|
|
311
|
+
`<div class="bp-shell__empty-card">` +
|
|
312
|
+
`<div class="bp-shell__empty-title">${title}</div>` +
|
|
313
|
+
(context ? `<div class="bp-shell__empty-copy"><code>${context}</code></div>` : "") +
|
|
314
|
+
`<div class="bp-shell__empty-copy">${message}</div>` +
|
|
315
|
+
`<div class="bp-shell__empty-actions">${renderErrorAction(action)}</div>` +
|
|
316
|
+
`</div>` +
|
|
317
|
+
`</div>`;
|
|
318
|
+
};
|
|
319
|
+
const errorMessage = (status) => {
|
|
320
|
+
switch (status) {
|
|
321
|
+
case 401: return "Session expired. Sign in again to continue.";
|
|
322
|
+
case 403: return "Access denied for this view.";
|
|
323
|
+
case 404: return "View not found.";
|
|
324
|
+
case 502:
|
|
325
|
+
case 503: return "Service unavailable. Try again shortly.";
|
|
326
|
+
default: return "Request failed. Try again.";
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
const isThemeOriginUrl = (url) => {
|
|
330
|
+
try {
|
|
331
|
+
return new URL(url, window.location.origin).host === window.location.host;
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
const serviceOrigins = (() => {
|
|
338
|
+
try {
|
|
339
|
+
return JSON.parse(shellRoot()?.getAttribute("data-bp-services") || "{}");
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
return {};
|
|
343
|
+
}
|
|
344
|
+
})();
|
|
345
|
+
const unresolvedServiceOrigin = "https://never.betterportal.cloud";
|
|
346
|
+
const loadBackgroundFragments = async () => {
|
|
347
|
+
const outlet = document.querySelector("[data-bp-background-fragments]");
|
|
348
|
+
if (!(outlet instanceof HTMLElement) || outlet.dataset.bpLoaded === "1")
|
|
349
|
+
return;
|
|
350
|
+
outlet.dataset.bpLoaded = "1";
|
|
351
|
+
const byService = new Map();
|
|
352
|
+
for (const route of buildServiceRouteMap()) {
|
|
353
|
+
if (route.serviceId && route.serviceOrigin && !byService.has(route.serviceId)) {
|
|
354
|
+
byService.set(route.serviceId, { serviceId: route.serviceId, origin: route.serviceOrigin });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const escapeAttr = (value) => value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
358
|
+
const nodes = [];
|
|
359
|
+
await Promise.all(Array.from(byService.values()).map(async (service) => {
|
|
360
|
+
try {
|
|
361
|
+
const base = service.origin.replace(/\/+$/, "");
|
|
362
|
+
const response = await fetch(base + "/.well-known/bp/schema.json", { headers: { Accept: "application/json" }, cache: "default", mode: "cors" });
|
|
363
|
+
if (!response.ok)
|
|
364
|
+
return;
|
|
365
|
+
const schema = await response.json();
|
|
366
|
+
for (const route of schema.routes || []) {
|
|
367
|
+
for (const fragment of route.fragments || []) {
|
|
368
|
+
if (fragment.fragmentLocation !== "background" || !fragment.fragmentId)
|
|
369
|
+
continue;
|
|
370
|
+
const key = "background." + fragment.fragmentId;
|
|
371
|
+
const url = base + route.path + (route.path.includes("?") ? "&" : "?") + "_f=" + encodeURIComponent(key);
|
|
372
|
+
nodes.push('<div data-bp-fragment="' + escapeAttr(fragment.fragmentId) + '" data-bp-fragment-location="background" data-bp-service="' + escapeAttr(service.serviceId) + '" hx-get="' + escapeAttr(url) + '" hx-trigger="load" hx-target="this" hx-swap="innerHTML"></div>');
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
catch { /* service unavailable; skip background fragments */ }
|
|
377
|
+
}));
|
|
378
|
+
outlet.innerHTML = nodes.join("");
|
|
379
|
+
if (typeof htmx.process === "function")
|
|
380
|
+
htmx.process(outlet);
|
|
381
|
+
};
|
|
382
|
+
const serviceIdByOrigin = (() => {
|
|
383
|
+
const map = {};
|
|
384
|
+
for (const [id, origin] of Object.entries(serviceOrigins)) {
|
|
385
|
+
try {
|
|
386
|
+
map[new URL(origin).origin] = id;
|
|
387
|
+
}
|
|
388
|
+
catch { /* skip invalid */ }
|
|
389
|
+
}
|
|
390
|
+
return map;
|
|
391
|
+
})();
|
|
392
|
+
const BP_HEADERS_KEY = "bp.headers";
|
|
393
|
+
const DEFAULT_HEADER_REFRESH_BEFORE_SECONDS = 60;
|
|
394
|
+
const headerRefreshTimers = new Map();
|
|
395
|
+
let headerRefreshInFlight = null;
|
|
396
|
+
const readBpHeaders = () => {
|
|
397
|
+
try {
|
|
398
|
+
return JSON.parse(localStorage.getItem(BP_HEADERS_KEY) || "{}");
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
return {};
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
const writeBpHeaders = (headers) => {
|
|
405
|
+
try {
|
|
406
|
+
localStorage.setItem(BP_HEADERS_KEY, JSON.stringify(headers));
|
|
407
|
+
}
|
|
408
|
+
catch { /* storage unavailable - headers just won't persist */ }
|
|
409
|
+
};
|
|
410
|
+
/** Drop expired entries; returns the live set. */
|
|
411
|
+
const liveBpHeaders = () => {
|
|
412
|
+
const stored = readBpHeaders();
|
|
413
|
+
const now = Math.floor(Date.now() / 1000);
|
|
414
|
+
let changed = false;
|
|
415
|
+
for (const [name, entry] of Object.entries(stored)) {
|
|
416
|
+
if (entry && typeof entry.expires === "number" && entry.expires <= now) {
|
|
417
|
+
delete stored[name];
|
|
418
|
+
changed = true;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (changed)
|
|
422
|
+
writeBpHeaders(stored);
|
|
423
|
+
return stored;
|
|
424
|
+
};
|
|
425
|
+
const serviceIdForUrl = (url) => {
|
|
426
|
+
try {
|
|
427
|
+
return serviceIdByOrigin[new URL(url, window.location.origin).origin] || "";
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
return "";
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
const originForServiceId = (id) => id ? (serviceOrigins[id] || unresolvedServiceOrigin) : "";
|
|
434
|
+
const originFromAbsoluteUrl = (value) => {
|
|
435
|
+
try {
|
|
436
|
+
return value ? new URL(value).origin : "";
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
return "";
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
const refreshUrlForHeader = (entry) => {
|
|
443
|
+
if (!entry.refresh)
|
|
444
|
+
return "";
|
|
445
|
+
const base = serviceOrigins[entry.owner]
|
|
446
|
+
|| originFromAbsoluteUrl(entry.owner)
|
|
447
|
+
|| (entry.scope ? serviceOrigins[entry.scope] : "")
|
|
448
|
+
|| window.location.origin;
|
|
449
|
+
try {
|
|
450
|
+
return new URL(entry.refresh, base).href;
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
return "";
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
const headerRefreshDue = (entry, force) => {
|
|
457
|
+
if (!entry.refresh)
|
|
458
|
+
return false;
|
|
459
|
+
if (force)
|
|
460
|
+
return true;
|
|
461
|
+
if (typeof entry.expires !== "number")
|
|
462
|
+
return false;
|
|
463
|
+
const before = typeof entry.refreshBefore === "number"
|
|
464
|
+
? entry.refreshBefore
|
|
465
|
+
: DEFAULT_HEADER_REFRESH_BEFORE_SECONDS;
|
|
466
|
+
return entry.expires - Math.floor(Date.now() / 1000) <= before;
|
|
467
|
+
};
|
|
468
|
+
const refreshStoredHeader = async (name, entry) => {
|
|
469
|
+
const refreshUrl = refreshUrlForHeader(entry);
|
|
470
|
+
if (!refreshUrl)
|
|
471
|
+
return false;
|
|
472
|
+
const headers = {
|
|
473
|
+
"accept": "application/json",
|
|
474
|
+
"content-type": "application/json"
|
|
475
|
+
};
|
|
476
|
+
attachBpHeaders(headers, refreshUrl, entry.owner);
|
|
477
|
+
let response;
|
|
478
|
+
try {
|
|
479
|
+
response = await fetch(refreshUrl, {
|
|
480
|
+
method: "POST",
|
|
481
|
+
mode: "cors",
|
|
482
|
+
cache: "no-store",
|
|
483
|
+
headers,
|
|
484
|
+
body: "{}"
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
applyBpHeaderDirectives(response, refreshUrl);
|
|
491
|
+
return response.ok && !!liveBpHeaders()[name];
|
|
492
|
+
};
|
|
493
|
+
const refreshStoredHeaders = async (force = false) => {
|
|
494
|
+
const entries = Object.entries(liveBpHeaders()).filter(([, entry]) => headerRefreshDue(entry, force));
|
|
495
|
+
if (entries.length === 0)
|
|
496
|
+
return false;
|
|
497
|
+
let refreshed = false;
|
|
498
|
+
for (const [name, entry] of entries) {
|
|
499
|
+
refreshed = await refreshStoredHeader(name, entry) || refreshed;
|
|
500
|
+
}
|
|
501
|
+
return refreshed;
|
|
502
|
+
};
|
|
503
|
+
const refreshStoredHeadersOnce = (force = false) => {
|
|
504
|
+
if (!headerRefreshInFlight) {
|
|
505
|
+
headerRefreshInFlight = refreshStoredHeaders(force).finally(() => {
|
|
506
|
+
headerRefreshInFlight = null;
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
return headerRefreshInFlight;
|
|
510
|
+
};
|
|
511
|
+
const scheduleHeaderRefreshes = () => {
|
|
512
|
+
headerRefreshTimers.forEach((timer) => window.clearTimeout(timer));
|
|
513
|
+
headerRefreshTimers.clear();
|
|
514
|
+
const nowMs = Date.now();
|
|
515
|
+
for (const [name, entry] of Object.entries(readBpHeaders())) {
|
|
516
|
+
if (!entry?.refresh || typeof entry.expires !== "number")
|
|
517
|
+
continue;
|
|
518
|
+
const before = typeof entry.refreshBefore === "number"
|
|
519
|
+
? entry.refreshBefore
|
|
520
|
+
: DEFAULT_HEADER_REFRESH_BEFORE_SECONDS;
|
|
521
|
+
const dueMs = (entry.expires - before) * 1000;
|
|
522
|
+
const delay = Math.max(0, dueMs - nowMs);
|
|
523
|
+
headerRefreshTimers.set(name, window.setTimeout(() => {
|
|
524
|
+
void refreshStoredHeader(name, entry).finally(scheduleHeaderRefreshes);
|
|
525
|
+
}, delay));
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
/** Attach stored headers to an outgoing request's header map. */
|
|
529
|
+
const attachBpHeaders = (requestHeaders, requestUrl, explicitServiceId = "") => {
|
|
530
|
+
const targetServiceId = explicitServiceId || serviceIdForUrl(requestUrl);
|
|
531
|
+
for (const [name, entry] of Object.entries(liveBpHeaders())) {
|
|
532
|
+
if (!entry || typeof entry.value !== "string")
|
|
533
|
+
continue;
|
|
534
|
+
if (entry.scope && entry.scope !== targetServiceId)
|
|
535
|
+
continue;
|
|
536
|
+
if (requestHeaders[name] !== undefined)
|
|
537
|
+
continue; // explicit wins
|
|
538
|
+
requestHeaders[name] = entry.value;
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
/**
|
|
542
|
+
* Process BP-SetHeader / BP-RemoveHeader response headers.
|
|
543
|
+
* Wire format: "Name=value; locked=true; expires=1735689600; scope=true"
|
|
544
|
+
* Owner = the service that sent the response (locked headers can only be
|
|
545
|
+
* overwritten or removed by their owner).
|
|
546
|
+
*/
|
|
547
|
+
const applyBpHeaderDirectives = (response, requestUrl) => {
|
|
548
|
+
const setRaw = response?.headers?.get?.("bp-setheader");
|
|
549
|
+
const removeRaw = response?.headers?.get?.("bp-removeheader");
|
|
550
|
+
if (!setRaw && !removeRaw)
|
|
551
|
+
return;
|
|
552
|
+
// A responder is known by its service id AND its origin - owner checks
|
|
553
|
+
// accept either, so entries stored before the service map knew this
|
|
554
|
+
// service (owner = origin fallback) stay controllable by their owner.
|
|
555
|
+
const responderOrigin = (() => {
|
|
556
|
+
try {
|
|
557
|
+
return new URL(requestUrl, window.location.origin).origin;
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
return "";
|
|
561
|
+
}
|
|
562
|
+
})();
|
|
563
|
+
const responderId = serviceIdForUrl(requestUrl);
|
|
564
|
+
const responder = responderId || responderOrigin;
|
|
565
|
+
const ownerMatches = (owner) => (!!responderId && owner === responderId) || (!!responderOrigin && owner === responderOrigin);
|
|
566
|
+
const stored = liveBpHeaders();
|
|
567
|
+
let changed = false;
|
|
568
|
+
if (setRaw) {
|
|
569
|
+
// Multiple BP-SetHeader values arrive comma-joined via Headers.get().
|
|
570
|
+
// Values (JWTs, etc.) contain no commas, so a comma followed by a
|
|
571
|
+
// token= prefix is a safe directive boundary.
|
|
572
|
+
for (const directive of setRaw.split(/,(?=\s*[^;,=]+=)/)) {
|
|
573
|
+
const [pair, ...attrParts] = directive.split(";");
|
|
574
|
+
const eq = (pair || "").indexOf("=");
|
|
575
|
+
if (eq <= 0)
|
|
576
|
+
continue;
|
|
577
|
+
const name = pair.slice(0, eq).trim();
|
|
578
|
+
const value = pair.slice(eq + 1).trim();
|
|
579
|
+
if (!name)
|
|
580
|
+
continue;
|
|
581
|
+
const existing = stored[name];
|
|
582
|
+
if (existing && existing.locked && !ownerMatches(existing.owner))
|
|
583
|
+
continue;
|
|
584
|
+
const attrs = {};
|
|
585
|
+
for (const part of attrParts) {
|
|
586
|
+
const aEq = part.indexOf("=");
|
|
587
|
+
if (aEq <= 0)
|
|
588
|
+
continue;
|
|
589
|
+
attrs[part.slice(0, aEq).trim().toLowerCase()] = part.slice(aEq + 1).trim();
|
|
590
|
+
}
|
|
591
|
+
const rawScope = (attrs["scope"] || "").toLowerCase();
|
|
592
|
+
const scope = rawScope === "true" ? responder
|
|
593
|
+
: rawScope === "false" ? null
|
|
594
|
+
: attrs["scope"] || null;
|
|
595
|
+
stored[name] = {
|
|
596
|
+
value,
|
|
597
|
+
owner: responder,
|
|
598
|
+
locked: attrs["locked"] === "true",
|
|
599
|
+
expires: attrs["expires"] ? Number(attrs["expires"]) || null : null,
|
|
600
|
+
scope,
|
|
601
|
+
refresh: attrs["refresh"] || null,
|
|
602
|
+
refreshBefore: attrs["refreshbefore"] ? Number(attrs["refreshbefore"]) || null : null
|
|
603
|
+
};
|
|
604
|
+
changed = true;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
if (removeRaw) {
|
|
608
|
+
for (const rawName of removeRaw.split(",")) {
|
|
609
|
+
const name = rawName.trim();
|
|
610
|
+
const existing = stored[name];
|
|
611
|
+
if (!existing)
|
|
612
|
+
continue;
|
|
613
|
+
if (existing.locked && !ownerMatches(existing.owner))
|
|
614
|
+
continue;
|
|
615
|
+
delete stored[name];
|
|
616
|
+
changed = true;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (changed) {
|
|
620
|
+
writeBpHeaders(stored);
|
|
621
|
+
scheduleHeaderRefreshes();
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
const contentDispositionFilename = (value) => {
|
|
625
|
+
if (!value)
|
|
626
|
+
return "";
|
|
627
|
+
const utf8 = /filename\*=UTF-8''([^;]+)/i.exec(value);
|
|
628
|
+
if (utf8?.[1]) {
|
|
629
|
+
try {
|
|
630
|
+
return decodeURIComponent(utf8[1].trim().replace(/^"|"$/g, ""));
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
return utf8[1].trim();
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
const simple = /filename=([^;]+)/i.exec(value);
|
|
637
|
+
return simple?.[1]?.trim().replace(/^"|"$/g, "") || "";
|
|
638
|
+
};
|
|
639
|
+
const fallbackDownloadName = (url) => {
|
|
640
|
+
try {
|
|
641
|
+
const name = new URL(url, window.location.origin).pathname.split("/").filter(Boolean).pop();
|
|
642
|
+
return name || "download";
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
return "download";
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
const resolveDownloadUrl = (el) => {
|
|
649
|
+
const context = serviceContextFor(el);
|
|
650
|
+
const rawAttr = el.getAttribute(DOWNLOAD_ATTR);
|
|
651
|
+
const rawHref = el.tagName === "A" ? (el.getAttribute("href") || "") : "";
|
|
652
|
+
const raw = ((rawAttr ?? "").trim() || rawHref).trim();
|
|
653
|
+
if (!raw)
|
|
654
|
+
return "";
|
|
655
|
+
if (isThisReference(raw))
|
|
656
|
+
return resolveThisServiceUrl(el, context);
|
|
657
|
+
if (isRelativeServicePath(raw))
|
|
658
|
+
return context.origin ? context.origin + raw : raw;
|
|
659
|
+
try {
|
|
660
|
+
return new URL(raw, window.location.origin).href;
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
return "";
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
const downloadBlob = async (el) => {
|
|
667
|
+
if (el.getAttribute("data-bp-download-loading") === "true")
|
|
668
|
+
return;
|
|
669
|
+
const url = resolveDownloadUrl(el);
|
|
670
|
+
if (!url)
|
|
671
|
+
return;
|
|
672
|
+
el.setAttribute("data-bp-download-loading", "true");
|
|
673
|
+
const headers = {
|
|
674
|
+
Accept: el.getAttribute("hx-accept") || "application/octet-stream"
|
|
675
|
+
};
|
|
676
|
+
attachBpHeaders(headers, url);
|
|
677
|
+
try {
|
|
678
|
+
const response = await fetch(url, {
|
|
679
|
+
method: "GET",
|
|
680
|
+
mode: "cors",
|
|
681
|
+
cache: "no-store",
|
|
682
|
+
headers
|
|
683
|
+
});
|
|
684
|
+
applyBpHeaderDirectives(response, url);
|
|
685
|
+
if (!response.ok) {
|
|
686
|
+
renderRouteError("Download Failed", `The download request failed with HTTP ${response.status}.`, { kind: "reload", label: "Reload" }, el);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
const blob = await response.blob();
|
|
690
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
691
|
+
const anchor = document.createElement("a");
|
|
692
|
+
anchor.href = objectUrl;
|
|
693
|
+
anchor.download =
|
|
694
|
+
contentDispositionFilename(response.headers.get("content-disposition"))
|
|
695
|
+
|| el.getAttribute("download")
|
|
696
|
+
|| fallbackDownloadName(url);
|
|
697
|
+
document.body.appendChild(anchor);
|
|
698
|
+
anchor.click();
|
|
699
|
+
anchor.remove();
|
|
700
|
+
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
|
701
|
+
}
|
|
702
|
+
catch {
|
|
703
|
+
renderRouteError("Download Failed", "The download request could not be completed.", { kind: "reload", label: "Reload" }, el);
|
|
704
|
+
}
|
|
705
|
+
finally {
|
|
706
|
+
el.removeAttribute("data-bp-download-loading");
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
const bindDownload = (el) => {
|
|
710
|
+
if (!el.hasAttribute(DOWNLOAD_ATTR) || el.getAttribute("data-bp-download-bound") === "true")
|
|
711
|
+
return;
|
|
712
|
+
el.setAttribute("data-bp-download-bound", "true");
|
|
713
|
+
el.addEventListener("click", (event) => {
|
|
714
|
+
event.preventDefault();
|
|
715
|
+
event.stopPropagation();
|
|
716
|
+
void downloadBlob(el);
|
|
717
|
+
});
|
|
718
|
+
const trigger = (el.getAttribute("hx-trigger") || "").toLowerCase();
|
|
719
|
+
if (trigger.split(/[,\s]+/).includes("load")) {
|
|
720
|
+
window.setTimeout(() => void downloadBlob(el), 0);
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
const clearAuthorizationHeader = () => {
|
|
724
|
+
const stored = liveBpHeaders();
|
|
725
|
+
if (!stored.Authorization)
|
|
726
|
+
return;
|
|
727
|
+
delete stored.Authorization;
|
|
728
|
+
writeBpHeaders(stored);
|
|
729
|
+
scheduleHeaderRefreshes();
|
|
730
|
+
};
|
|
731
|
+
scheduleHeaderRefreshes();
|
|
732
|
+
const isLocalDevHost = () => {
|
|
733
|
+
const host = window.location.hostname;
|
|
734
|
+
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
735
|
+
};
|
|
736
|
+
const devReloadEnabled = () => {
|
|
737
|
+
const raw = (shellRoot()?.getAttribute("data-bp-dev-reload") || "auto").toLowerCase();
|
|
738
|
+
if (["false", "0", "no", "off"].includes(raw))
|
|
739
|
+
return false;
|
|
740
|
+
if (["true", "1", "yes", "on"].includes(raw))
|
|
741
|
+
return true;
|
|
742
|
+
return isLocalDevHost();
|
|
743
|
+
};
|
|
744
|
+
const activeRouteLink = () => {
|
|
745
|
+
const path = normalizePath(window.location.pathname);
|
|
746
|
+
return routeLinks().find((link) => normalizePath(link.getAttribute("href") || "/") === path) || null;
|
|
747
|
+
};
|
|
748
|
+
const currentServiceId = () => mainOutlet()?.getAttribute("data-bp-service") || activeRouteLink()?.getAttribute("data-bp-service") || "";
|
|
749
|
+
const currentRouteRequestUrl = () => activeRouteLink()?.getAttribute("data-bp-route-request") || mainOutlet()?.getAttribute("hx-get") || "";
|
|
750
|
+
const reloadCurrentRoute = (requestUrl, source) => {
|
|
751
|
+
const action = requestUrl || currentRouteRequestUrl();
|
|
752
|
+
const outlet = mainOutlet();
|
|
753
|
+
if (!action || !outlet)
|
|
754
|
+
return;
|
|
755
|
+
clearError();
|
|
756
|
+
setLoading(hasLoaded());
|
|
757
|
+
const routePath = source?.closest?.("[data-bp-route-link]")?.getAttribute("href")
|
|
758
|
+
|| activeRouteLink()?.getAttribute("href")
|
|
759
|
+
|| window.location.pathname + window.location.search;
|
|
760
|
+
triggerShellLink(routePath, action, true);
|
|
761
|
+
};
|
|
762
|
+
const serviceHealthUrl = (serviceId) => {
|
|
763
|
+
const origin = serviceId ? serviceOrigins[serviceId] : "";
|
|
764
|
+
return origin ? origin.replace(/\/+$/, "") + "/.well-known/bp/health" : "";
|
|
765
|
+
};
|
|
766
|
+
const checkServiceHealth = async (serviceId) => {
|
|
767
|
+
const url = serviceHealthUrl(serviceId);
|
|
768
|
+
if (!url)
|
|
769
|
+
return false;
|
|
770
|
+
try {
|
|
771
|
+
const response = await fetch(url, {
|
|
772
|
+
method: "GET",
|
|
773
|
+
cache: "no-store",
|
|
774
|
+
headers: { Accept: "application/json" }
|
|
775
|
+
});
|
|
776
|
+
return response.ok;
|
|
777
|
+
}
|
|
778
|
+
catch {
|
|
779
|
+
return false;
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
const devHealthState = new Map();
|
|
783
|
+
const scheduleDevServiceRecovery = (serviceId, requestUrl, source, path = window.location.pathname) => {
|
|
784
|
+
if (!devReloadEnabled() || !serviceId)
|
|
785
|
+
return;
|
|
786
|
+
const state = devHealthState.get(serviceId) || { wasDown: true, polling: false };
|
|
787
|
+
state.wasDown = true;
|
|
788
|
+
if (state.polling) {
|
|
789
|
+
devHealthState.set(serviceId, state);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
state.polling = true;
|
|
793
|
+
devHealthState.set(serviceId, state);
|
|
794
|
+
let attempts = 0;
|
|
795
|
+
let sawUnhealthy = false;
|
|
796
|
+
const poll = async () => {
|
|
797
|
+
attempts += 1;
|
|
798
|
+
const healthy = await checkServiceHealth(serviceId);
|
|
799
|
+
if (healthy) {
|
|
800
|
+
state.polling = false;
|
|
801
|
+
state.wasDown = false;
|
|
802
|
+
devHealthState.set(serviceId, state);
|
|
803
|
+
if (sawUnhealthy && normalizePath(window.location.pathname) === normalizePath(path)) {
|
|
804
|
+
reloadCurrentRoute(requestUrl, source);
|
|
805
|
+
}
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
sawUnhealthy = true;
|
|
809
|
+
if (attempts < 60) {
|
|
810
|
+
window.setTimeout(poll, 750);
|
|
811
|
+
}
|
|
812
|
+
else {
|
|
813
|
+
state.polling = false;
|
|
814
|
+
devHealthState.set(serviceId, state);
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
window.setTimeout(poll, 750);
|
|
818
|
+
};
|
|
819
|
+
const buildServiceRouteMap = () => {
|
|
820
|
+
const routes = [];
|
|
821
|
+
const addRoute = (tenantPathRaw, requestUrl, serviceId) => {
|
|
822
|
+
if (!requestUrl || !serviceId)
|
|
823
|
+
return;
|
|
824
|
+
const origin = serviceOrigins[serviceId];
|
|
825
|
+
if (!origin)
|
|
826
|
+
return;
|
|
827
|
+
try {
|
|
828
|
+
const tenantPath = normalizePath(tenantPathRaw || "/");
|
|
829
|
+
const servicePath = normalizePath(new URL(requestUrl).pathname);
|
|
830
|
+
routes.push({ tenantPath, servicePath, serviceOrigin: origin, serviceId });
|
|
831
|
+
}
|
|
832
|
+
catch { /* skip invalid */ }
|
|
833
|
+
};
|
|
834
|
+
try {
|
|
835
|
+
const allRoutes = JSON.parse(shellRoot()?.getAttribute("data-bp-routes") || "[]");
|
|
836
|
+
allRoutes.forEach((route) => addRoute(route.href || "/", route.requestUrl || "", route.serviceId || ""));
|
|
837
|
+
}
|
|
838
|
+
catch { /* fallback to DOM links */ }
|
|
839
|
+
routeLinks().forEach((link) => {
|
|
840
|
+
addRoute(link.getAttribute("href") || "/", link.getAttribute("data-bp-route-request") || "", link.getAttribute("data-bp-service") || "");
|
|
841
|
+
});
|
|
842
|
+
// Sort by service path length descending for longest-prefix-first matching
|
|
843
|
+
routes.sort((a, b) => b.servicePath.length - a.servicePath.length);
|
|
844
|
+
return routes;
|
|
845
|
+
};
|
|
846
|
+
const matchServiceRoute = (serviceId, path) => {
|
|
847
|
+
const routes = buildServiceRouteMap();
|
|
848
|
+
const normalPath = normalizePath(path);
|
|
849
|
+
const tryMatch = (filterServiceId) => {
|
|
850
|
+
for (const route of routes) {
|
|
851
|
+
if (filterServiceId && route.serviceId !== filterServiceId)
|
|
852
|
+
continue;
|
|
853
|
+
if (normalPath === route.servicePath) {
|
|
854
|
+
return { route, suffix: "" };
|
|
855
|
+
}
|
|
856
|
+
if (normalPath.startsWith(route.servicePath + "/")) {
|
|
857
|
+
return { route, suffix: normalPath.slice(route.servicePath.length) };
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
return null;
|
|
861
|
+
};
|
|
862
|
+
// Try current service first, then fallback to any service (cross-service links)
|
|
863
|
+
return tryMatch(serviceId) || tryMatch(null);
|
|
864
|
+
};
|
|
865
|
+
// Reverse of matchServiceRoute: resolve a TENANT path (what the URL bar /
|
|
866
|
+
// an HX-Location shows) to its owning route. Authoritative across services,
|
|
867
|
+
// so it works for programmatic navigations where the DOM has no owning
|
|
868
|
+
// element context (e.g. post-login HX-Location to a tenant path).
|
|
869
|
+
const matchTenantRoute = (path) => {
|
|
870
|
+
const routes = buildServiceRouteMap()
|
|
871
|
+
.slice()
|
|
872
|
+
.sort((a, b) => b.tenantPath.length - a.tenantPath.length);
|
|
873
|
+
const normalPath = normalizePath(path);
|
|
874
|
+
for (const route of routes) {
|
|
875
|
+
if (normalPath === route.tenantPath)
|
|
876
|
+
return { route, suffix: "" };
|
|
877
|
+
if (normalPath.startsWith(route.tenantPath + "/")) {
|
|
878
|
+
return { route, suffix: normalPath.slice(route.tenantPath.length) };
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return null;
|
|
882
|
+
};
|
|
883
|
+
const tenantUrlForServiceUrl = (value) => {
|
|
884
|
+
try {
|
|
885
|
+
const url = new URL(value, window.location.origin);
|
|
886
|
+
const serviceId = serviceIdByOrigin[url.origin] || "";
|
|
887
|
+
const match = matchServiceRoute(serviceId, url.pathname);
|
|
888
|
+
if (!match)
|
|
889
|
+
return value;
|
|
890
|
+
return normalizePath(match.route.tenantPath + match.suffix) + url.search + url.hash;
|
|
891
|
+
}
|
|
892
|
+
catch {
|
|
893
|
+
return value;
|
|
894
|
+
}
|
|
895
|
+
};
|
|
896
|
+
const serviceUrlForTenantUrl = (value) => {
|
|
897
|
+
try {
|
|
898
|
+
const url = new URL(value, window.location.origin);
|
|
899
|
+
const match = matchTenantRoute(url.pathname);
|
|
900
|
+
if (!match)
|
|
901
|
+
return value;
|
|
902
|
+
return match.route.serviceOrigin + normalizePath(match.route.servicePath + match.suffix) + url.search + url.hash;
|
|
903
|
+
}
|
|
904
|
+
catch {
|
|
905
|
+
return value;
|
|
906
|
+
}
|
|
907
|
+
};
|
|
908
|
+
const triggerShellLink = (tenantUrl, serviceUrl = serviceUrlForTenantUrl(tenantUrl), replace = false) => {
|
|
909
|
+
const link = document.createElement("a");
|
|
910
|
+
link.href = tenantUrl;
|
|
911
|
+
link.setAttribute("hx-get", serviceUrl);
|
|
912
|
+
link.setAttribute("hx-trigger", "load");
|
|
913
|
+
link.setAttribute("hx-target", "#bp-main");
|
|
914
|
+
link.setAttribute("hx-swap", "innerHTML");
|
|
915
|
+
link.setAttribute(replace ? "hx-replace-url" : "hx-push-url", tenantUrl);
|
|
916
|
+
link.setAttribute("data-bp-no-route", "");
|
|
917
|
+
link.hidden = true;
|
|
918
|
+
const cleanup = () => link.remove();
|
|
919
|
+
link.addEventListener("htmx:afterRequest", cleanup, { once: true });
|
|
920
|
+
document.body.appendChild(link);
|
|
921
|
+
htmx.process(link);
|
|
922
|
+
window.setTimeout(cleanup, 30000);
|
|
923
|
+
};
|
|
924
|
+
const applyConfigToken = (cfg, token) => {
|
|
925
|
+
const trimmed = token.trim();
|
|
926
|
+
if (!trimmed)
|
|
927
|
+
return;
|
|
928
|
+
const eqIdx = trimmed.indexOf("=");
|
|
929
|
+
const rawKey = eqIdx === -1 ? trimmed : trimmed.slice(0, eqIdx).trim();
|
|
930
|
+
const rawValue = eqIdx === -1 ? "" : trimmed.slice(eqIdx + 1).trim();
|
|
931
|
+
if (!rawKey)
|
|
932
|
+
return;
|
|
933
|
+
if (rawKey === "ignore") {
|
|
934
|
+
cfg.ignore = true;
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
const negative = rawKey.startsWith("no-");
|
|
938
|
+
const key = negative ? rawKey.slice(3) : rawKey;
|
|
939
|
+
const value = negative
|
|
940
|
+
? false
|
|
941
|
+
: eqIdx === -1
|
|
942
|
+
? true
|
|
943
|
+
: !["false", "0", "no", "off"].includes(rawValue.toLowerCase());
|
|
944
|
+
if (key === "preload")
|
|
945
|
+
cfg.preload = Boolean(value);
|
|
946
|
+
else if (key === "rewrite")
|
|
947
|
+
cfg.rewrite = Boolean(value);
|
|
948
|
+
else if (key === "service" && eqIdx !== -1 && rawValue)
|
|
949
|
+
cfg.service = rawValue;
|
|
950
|
+
};
|
|
951
|
+
const parseConfigAttr = (cfg, raw) => {
|
|
952
|
+
if (!raw)
|
|
953
|
+
return;
|
|
954
|
+
raw.split(";").forEach((token) => applyConfigToken(cfg, token));
|
|
955
|
+
};
|
|
956
|
+
const bpConfigFor = (el) => {
|
|
957
|
+
const chain = [];
|
|
958
|
+
let current = el;
|
|
959
|
+
while (current) {
|
|
960
|
+
chain.unshift(current);
|
|
961
|
+
current = current.parentElement;
|
|
962
|
+
}
|
|
963
|
+
const cfg = {};
|
|
964
|
+
for (const node of chain) {
|
|
965
|
+
parseConfigAttr(cfg, node.getAttribute("data-bp-config"));
|
|
966
|
+
parseConfigAttr(cfg, node.getAttribute("bp-config"));
|
|
967
|
+
}
|
|
968
|
+
return cfg;
|
|
969
|
+
};
|
|
970
|
+
const serviceIdAttr = (el) => {
|
|
971
|
+
if (!el)
|
|
972
|
+
return "";
|
|
973
|
+
return (el.getAttribute("bp-service-id") ||
|
|
974
|
+
el.getAttribute("data-bp-service-id") ||
|
|
975
|
+
el.getAttribute("data-bp-service") ||
|
|
976
|
+
"");
|
|
977
|
+
};
|
|
978
|
+
const serviceContextFor = (el, fallbackServiceId = "") => {
|
|
979
|
+
const cfgServiceId = el ? bpConfigFor(el).service || "" : "";
|
|
980
|
+
const ownerEl = el?.closest?.("[bp-service-id], [data-bp-service-id], [data-bp-service]") || null;
|
|
981
|
+
const id = cfgServiceId || serviceIdAttr(ownerEl) || fallbackServiceId;
|
|
982
|
+
return { id, origin: id ? originForServiceId(id) : "" };
|
|
983
|
+
};
|
|
984
|
+
const explicitServiceContextFor = (el) => {
|
|
985
|
+
const cfgServiceId = el ? bpConfigFor(el).service || "" : "";
|
|
986
|
+
const explicitEl = el?.closest?.("[bp-service-id], [data-bp-service-id]") || null;
|
|
987
|
+
const id = cfgServiceId || serviceIdAttr(explicitEl);
|
|
988
|
+
return { id, origin: id ? originForServiceId(id) : "" };
|
|
989
|
+
};
|
|
990
|
+
const isPreloadableAnchor = (el) => {
|
|
991
|
+
if (el.tagName !== "A")
|
|
992
|
+
return false;
|
|
993
|
+
const href = el.getAttribute("href") || "";
|
|
994
|
+
if (!href || href.startsWith("#"))
|
|
995
|
+
return false;
|
|
996
|
+
if (href.startsWith("mailto:") || href.startsWith("tel:") || href.startsWith("javascript:"))
|
|
997
|
+
return false;
|
|
998
|
+
const target = el.getAttribute("target");
|
|
999
|
+
if (target && target !== "_self")
|
|
1000
|
+
return false;
|
|
1001
|
+
return !el.hasAttribute("download");
|
|
1002
|
+
};
|
|
1003
|
+
const isRelativeServicePath = (value) => {
|
|
1004
|
+
const trimmed = (value || "").trim();
|
|
1005
|
+
return trimmed.startsWith("/") && !trimmed.startsWith("//");
|
|
1006
|
+
};
|
|
1007
|
+
const isThisReference = (value) => (value || "").trim().toLowerCase() === "this";
|
|
1008
|
+
const resolveThisServiceUrl = (el, context) => {
|
|
1009
|
+
const explicitContext = explicitServiceContextFor(el);
|
|
1010
|
+
if (explicitContext.origin) {
|
|
1011
|
+
return explicitContext.origin + window.location.pathname + window.location.search;
|
|
1012
|
+
}
|
|
1013
|
+
const routeMatch = matchTenantRoute(window.location.pathname);
|
|
1014
|
+
if (routeMatch) {
|
|
1015
|
+
return routeMatch.route.serviceOrigin + normalizePath(routeMatch.route.servicePath + routeMatch.suffix) + window.location.search;
|
|
1016
|
+
}
|
|
1017
|
+
const serviceOrigin = context.origin;
|
|
1018
|
+
if (serviceOrigin) {
|
|
1019
|
+
return serviceOrigin + window.location.pathname + window.location.search;
|
|
1020
|
+
}
|
|
1021
|
+
const requestUrl = currentRouteRequestUrl();
|
|
1022
|
+
if (requestUrl) {
|
|
1023
|
+
try {
|
|
1024
|
+
const resolved = new URL(requestUrl, serviceOrigin || window.location.origin);
|
|
1025
|
+
if (resolved.origin !== window.location.origin || !serviceOrigin)
|
|
1026
|
+
return resolved.href;
|
|
1027
|
+
return serviceOrigin + resolved.pathname + resolved.search;
|
|
1028
|
+
}
|
|
1029
|
+
catch {
|
|
1030
|
+
if (isRelativeServicePath(requestUrl) && serviceOrigin)
|
|
1031
|
+
return serviceOrigin + requestUrl.trim();
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
return "";
|
|
1035
|
+
};
|
|
1036
|
+
window.bpLoginSubmit = async (event) => {
|
|
1037
|
+
event.preventDefault();
|
|
1038
|
+
event.stopImmediatePropagation();
|
|
1039
|
+
const form = event.currentTarget;
|
|
1040
|
+
if (!form)
|
|
1041
|
+
return false;
|
|
1042
|
+
const errEl = document.getElementById("bp-login-error");
|
|
1043
|
+
if (errEl)
|
|
1044
|
+
errEl.classList.add("d-none");
|
|
1045
|
+
const fd = new FormData(form);
|
|
1046
|
+
const queryNext = new URLSearchParams(window.location.search).get("next");
|
|
1047
|
+
if (!fd.get("next") && queryNext)
|
|
1048
|
+
fd.set("next", queryNext);
|
|
1049
|
+
const context = serviceContextFor(form);
|
|
1050
|
+
const rawAction = form.getAttribute("hx-post") || form.getAttribute("action") || "this";
|
|
1051
|
+
const action = isThisReference(rawAction)
|
|
1052
|
+
? resolveThisServiceUrl(form, context)
|
|
1053
|
+
: new URL(rawAction, context.origin || window.location.origin).href;
|
|
1054
|
+
try {
|
|
1055
|
+
const response = await fetch(action, {
|
|
1056
|
+
method: "POST",
|
|
1057
|
+
mode: "cors",
|
|
1058
|
+
credentials: "include",
|
|
1059
|
+
headers: {
|
|
1060
|
+
Accept: "application/json",
|
|
1061
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
1062
|
+
},
|
|
1063
|
+
body: new URLSearchParams(fd)
|
|
1064
|
+
});
|
|
1065
|
+
applyBpHeaderDirectives(response, action);
|
|
1066
|
+
let body = null;
|
|
1067
|
+
try {
|
|
1068
|
+
body = await response.json();
|
|
1069
|
+
}
|
|
1070
|
+
catch { /* non-JSON */ }
|
|
1071
|
+
if (!response.ok || !body || body.status !== "ok") {
|
|
1072
|
+
if (errEl) {
|
|
1073
|
+
errEl.textContent = (body && body.message) || ("Login failed (HTTP " + response.status + ")");
|
|
1074
|
+
errEl.classList.remove("d-none");
|
|
1075
|
+
}
|
|
1076
|
+
return false;
|
|
1077
|
+
}
|
|
1078
|
+
triggerShellLink(String(fd.get("next") || "/"), undefined, true);
|
|
1079
|
+
}
|
|
1080
|
+
catch {
|
|
1081
|
+
if (errEl) {
|
|
1082
|
+
errEl.textContent = "Login failed. Service unavailable.";
|
|
1083
|
+
errEl.classList.remove("d-none");
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
return false;
|
|
1087
|
+
};
|
|
1088
|
+
const applyPreloadConfig = (el, cfg) => {
|
|
1089
|
+
if (!isPreloadableAnchor(el))
|
|
1090
|
+
return false;
|
|
1091
|
+
if (cfg.preload === false) {
|
|
1092
|
+
if (!el.hasAttribute("hx-preload"))
|
|
1093
|
+
return false;
|
|
1094
|
+
el.removeAttribute("hx-preload");
|
|
1095
|
+
return true;
|
|
1096
|
+
}
|
|
1097
|
+
if (!el.hasAttribute("hx-preload")) {
|
|
1098
|
+
el.setAttribute("hx-preload", "mouseover");
|
|
1099
|
+
return true;
|
|
1100
|
+
}
|
|
1101
|
+
return false;
|
|
1102
|
+
};
|
|
1103
|
+
const bindBpPreload = (el) => {
|
|
1104
|
+
if (!isPreloadableAnchor(el))
|
|
1105
|
+
return;
|
|
1106
|
+
if (!el.hasAttribute("hx-preload"))
|
|
1107
|
+
return;
|
|
1108
|
+
if (el.hasAttribute("data-bp-preload-bound"))
|
|
1109
|
+
return;
|
|
1110
|
+
const preload = () => {
|
|
1111
|
+
if (!el.hasAttribute("hx-preload"))
|
|
1112
|
+
return;
|
|
1113
|
+
const hxGet = el.getAttribute("hx-get");
|
|
1114
|
+
if (!hxGet)
|
|
1115
|
+
return;
|
|
1116
|
+
const action = hxGet.replace(/#.*$/, "");
|
|
1117
|
+
const state = el._htmx ?? (el._htmx = {});
|
|
1118
|
+
if (state.preload)
|
|
1119
|
+
return;
|
|
1120
|
+
const headers = { Accept: "text/html; theme=bootstrap1; mode=page" };
|
|
1121
|
+
attachBpHeaders(headers, action);
|
|
1122
|
+
state.preload = {
|
|
1123
|
+
prefetch: fetch(hxGet, {
|
|
1124
|
+
method: "GET",
|
|
1125
|
+
mode: "cors",
|
|
1126
|
+
cache: "no-store",
|
|
1127
|
+
headers
|
|
1128
|
+
}),
|
|
1129
|
+
action,
|
|
1130
|
+
expiresAt: Date.now() + 5000
|
|
1131
|
+
};
|
|
1132
|
+
state.preload.prefetch.catch(() => {
|
|
1133
|
+
if (state.preload?.action === action)
|
|
1134
|
+
delete state.preload;
|
|
1135
|
+
});
|
|
1136
|
+
};
|
|
1137
|
+
el.addEventListener("mouseover", preload, { passive: true });
|
|
1138
|
+
el.addEventListener("focusin", preload, { passive: true });
|
|
1139
|
+
el.setAttribute("data-bp-preload-bound", "");
|
|
1140
|
+
};
|
|
1141
|
+
// -- Service link resolution --
|
|
1142
|
+
// Keep service-rendered HTMX requests in their lane. Content may replace
|
|
1143
|
+
// #bp-main or content-owned overlays; fragments may only replace themselves
|
|
1144
|
+
// or descendants inside their own fragment container.
|
|
1145
|
+
const sourceLaneRoot = (el) => {
|
|
1146
|
+
if (!el)
|
|
1147
|
+
return null;
|
|
1148
|
+
return el.closest("[data-bp-fragment]") || el.closest("#bp-main");
|
|
1149
|
+
};
|
|
1150
|
+
const isContentOwnedTarget = (target) => !!target.closest("[data-bp-content-owned='true']");
|
|
1151
|
+
const targetWithinLane = (source, target) => {
|
|
1152
|
+
if (!source || !target)
|
|
1153
|
+
return false;
|
|
1154
|
+
const lane = sourceLaneRoot(source);
|
|
1155
|
+
if (!lane)
|
|
1156
|
+
return true;
|
|
1157
|
+
if (lane.hasAttribute("data-bp-fragment")) {
|
|
1158
|
+
return target === lane || lane.contains(target);
|
|
1159
|
+
}
|
|
1160
|
+
if (lane.id === "bp-main") {
|
|
1161
|
+
return target === lane || lane.contains(target) || isContentOwnedTarget(target);
|
|
1162
|
+
}
|
|
1163
|
+
return false;
|
|
1164
|
+
};
|
|
1165
|
+
const resolvePolicyTarget = (source, targetSpec, ctxTarget) => {
|
|
1166
|
+
if (ctxTarget instanceof Element)
|
|
1167
|
+
return ctxTarget;
|
|
1168
|
+
const spec = (targetSpec || "").trim();
|
|
1169
|
+
if (!spec || spec === "this")
|
|
1170
|
+
return source;
|
|
1171
|
+
if (spec === "#bp-main")
|
|
1172
|
+
return mainOutlet();
|
|
1173
|
+
if (spec === "body")
|
|
1174
|
+
return document.body;
|
|
1175
|
+
if (spec === "html")
|
|
1176
|
+
return document.documentElement;
|
|
1177
|
+
if (spec.startsWith("closest "))
|
|
1178
|
+
return source.closest(spec.slice("closest ".length).trim());
|
|
1179
|
+
if (spec.startsWith("find "))
|
|
1180
|
+
return source.querySelector(spec.slice("find ".length).trim());
|
|
1181
|
+
try {
|
|
1182
|
+
return document.querySelector(spec);
|
|
1183
|
+
}
|
|
1184
|
+
catch {
|
|
1185
|
+
return null;
|
|
1186
|
+
}
|
|
1187
|
+
};
|
|
1188
|
+
const sanitizeHtmxTarget = (el) => {
|
|
1189
|
+
if (el.hasAttribute("data-bp-no-route") || el.hasAttribute("data-bp-route-link"))
|
|
1190
|
+
return false;
|
|
1191
|
+
if (!el.hasAttribute("data-bp-explicit-target"))
|
|
1192
|
+
return false;
|
|
1193
|
+
const lane = sourceLaneRoot(el);
|
|
1194
|
+
if (!lane)
|
|
1195
|
+
return false;
|
|
1196
|
+
const targetSpec = el.getAttribute("hx-target");
|
|
1197
|
+
if (!targetSpec)
|
|
1198
|
+
return false;
|
|
1199
|
+
const target = resolvePolicyTarget(el, targetSpec);
|
|
1200
|
+
if (target && targetWithinLane(el, target))
|
|
1201
|
+
return false;
|
|
1202
|
+
if (lane.hasAttribute("data-bp-fragment")) {
|
|
1203
|
+
el.setAttribute("hx-target", "closest [data-bp-fragment]");
|
|
1204
|
+
if (!el.hasAttribute("hx-swap"))
|
|
1205
|
+
el.setAttribute("hx-swap", "innerHTML");
|
|
1206
|
+
}
|
|
1207
|
+
else {
|
|
1208
|
+
el.setAttribute("hx-target", "#bp-main");
|
|
1209
|
+
if (!el.hasAttribute("hx-swap"))
|
|
1210
|
+
el.setAttribute("hx-swap", "innerHTML");
|
|
1211
|
+
}
|
|
1212
|
+
return true;
|
|
1213
|
+
};
|
|
1214
|
+
const requestTargetEscapesLane = (detail) => {
|
|
1215
|
+
const source = detail?.ctx?.sourceElement instanceof Element
|
|
1216
|
+
? detail.ctx.sourceElement
|
|
1217
|
+
: null;
|
|
1218
|
+
if (!source || source.hasAttribute("data-bp-no-route") || source.hasAttribute("data-bp-route-link"))
|
|
1219
|
+
return false;
|
|
1220
|
+
const lane = sourceLaneRoot(source);
|
|
1221
|
+
if (!lane)
|
|
1222
|
+
return false;
|
|
1223
|
+
if (!source.hasAttribute("data-bp-explicit-target"))
|
|
1224
|
+
return false;
|
|
1225
|
+
const target = resolvePolicyTarget(source, source.getAttribute("hx-target"), detail?.ctx?.target);
|
|
1226
|
+
return !!target && !targetWithinLane(source, target);
|
|
1227
|
+
};
|
|
1228
|
+
const resolveServiceLinks = (root, reprocess = true) => {
|
|
1229
|
+
if (!root)
|
|
1230
|
+
return;
|
|
1231
|
+
// Determine service context for this content
|
|
1232
|
+
const rootService = serviceContextFor(root);
|
|
1233
|
+
const serviceId = rootService.id;
|
|
1234
|
+
const serviceOrigin = rootService.origin;
|
|
1235
|
+
// Collect all elements. hx-sse:connect contains a colon which CSS
|
|
1236
|
+
// selectors can't express portably; query it with a separate pass.
|
|
1237
|
+
const selector = 'a[href], form, [hx-download], [hx-get], [hx-post], [hx-put], [hx-patch], [hx-delete], script[src], img[src], link[href][rel="stylesheet"], [sse-connect]';
|
|
1238
|
+
const elements = root.matches?.(selector) ? [root] : [];
|
|
1239
|
+
root.querySelectorAll(selector).forEach((el) => elements.push(el));
|
|
1240
|
+
if (root.hasAttribute?.("hx-sse:connect"))
|
|
1241
|
+
elements.push(root);
|
|
1242
|
+
root.querySelectorAll("*").forEach((el) => {
|
|
1243
|
+
if (el.hasAttribute("hx-sse:connect") && !elements.includes(el))
|
|
1244
|
+
elements.push(el);
|
|
1245
|
+
});
|
|
1246
|
+
let changed = false;
|
|
1247
|
+
const newlyHtmxedForms = [];
|
|
1248
|
+
for (const el of elements) {
|
|
1249
|
+
const bpCfg = bpConfigFor(el);
|
|
1250
|
+
if (bpCfg.ignore)
|
|
1251
|
+
continue;
|
|
1252
|
+
if (applyPreloadConfig(el, bpCfg))
|
|
1253
|
+
changed = true;
|
|
1254
|
+
if (sanitizeHtmxTarget(el))
|
|
1255
|
+
changed = true;
|
|
1256
|
+
// Skip already-processed or shell-owned route links after config/preload handling
|
|
1257
|
+
if (el.hasAttribute("data-bp-shell-route")) {
|
|
1258
|
+
bindBpPreload(el);
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1261
|
+
if (el.hasAttribute("data-bp-route-link")) {
|
|
1262
|
+
bindBpPreload(el);
|
|
1263
|
+
continue;
|
|
1264
|
+
}
|
|
1265
|
+
if (el.hasAttribute("data-bp-no-route"))
|
|
1266
|
+
continue;
|
|
1267
|
+
if (bpCfg.rewrite === false)
|
|
1268
|
+
continue;
|
|
1269
|
+
const tag = el.tagName;
|
|
1270
|
+
if (el.hasAttribute(DOWNLOAD_ATTR)) {
|
|
1271
|
+
const elContext = serviceContextFor(el, serviceId);
|
|
1272
|
+
const rawDownload = (el.getAttribute(DOWNLOAD_ATTR) || "").trim();
|
|
1273
|
+
const rawHref = tag === "A" ? (el.getAttribute("href") || "") : "";
|
|
1274
|
+
const raw = rawDownload || rawHref;
|
|
1275
|
+
const resolved = isThisReference(raw)
|
|
1276
|
+
? resolveThisServiceUrl(el, elContext)
|
|
1277
|
+
: isRelativeServicePath(raw) && elContext.origin
|
|
1278
|
+
? elContext.origin + raw
|
|
1279
|
+
: raw;
|
|
1280
|
+
if (resolved)
|
|
1281
|
+
el.setAttribute(DOWNLOAD_ATTR, resolved);
|
|
1282
|
+
if (elContext.id)
|
|
1283
|
+
el.setAttribute("data-bp-service", elContext.id);
|
|
1284
|
+
el.setAttribute("data-bp-shell-route", "download");
|
|
1285
|
+
bindDownload(el);
|
|
1286
|
+
changed = true;
|
|
1287
|
+
continue;
|
|
1288
|
+
}
|
|
1289
|
+
// -- Static assets: just rewrite to absolute --
|
|
1290
|
+
if ((tag === "SCRIPT" || tag === "IMG") && el.hasAttribute("src")) {
|
|
1291
|
+
const src = el.getAttribute("src") || "";
|
|
1292
|
+
const assetContext = serviceContextFor(el, serviceId);
|
|
1293
|
+
const assetOrigin = assetContext.origin || serviceOrigin;
|
|
1294
|
+
if (isRelativeServicePath(src) && assetOrigin) {
|
|
1295
|
+
el.setAttribute("src", assetOrigin + src);
|
|
1296
|
+
el.setAttribute("data-bp-shell-route", "asset");
|
|
1297
|
+
}
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
if (tag === "LINK" && el.hasAttribute("href")) {
|
|
1301
|
+
const href = el.getAttribute("href") || "";
|
|
1302
|
+
const assetContext = serviceContextFor(el, serviceId);
|
|
1303
|
+
const assetOrigin = assetContext.origin || serviceOrigin;
|
|
1304
|
+
if (isRelativeServicePath(href) && assetOrigin) {
|
|
1305
|
+
el.setAttribute("href", assetOrigin + href);
|
|
1306
|
+
el.setAttribute("data-bp-shell-route", "asset");
|
|
1307
|
+
}
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
// -- SSE: rewrite hx-sse:connect / sse-connect to absolute service origin --
|
|
1311
|
+
const sseAttr = el.hasAttribute("hx-sse:connect")
|
|
1312
|
+
? "hx-sse:connect"
|
|
1313
|
+
: el.hasAttribute("sse-connect")
|
|
1314
|
+
? "sse-connect"
|
|
1315
|
+
: null;
|
|
1316
|
+
if (sseAttr) {
|
|
1317
|
+
const sseUrl = el.getAttribute(sseAttr) || "";
|
|
1318
|
+
if (isRelativeServicePath(sseUrl)) {
|
|
1319
|
+
const elContext = serviceContextFor(el, serviceId);
|
|
1320
|
+
const elServiceOrigin = elContext.origin || serviceOrigin;
|
|
1321
|
+
if (elServiceOrigin) {
|
|
1322
|
+
el.setAttribute(sseAttr, elServiceOrigin + sseUrl);
|
|
1323
|
+
el.setAttribute("data-bp-shell-route", "sse");
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
continue;
|
|
1327
|
+
}
|
|
1328
|
+
// -- Determine what type of element --
|
|
1329
|
+
// Find hx-method attr if present
|
|
1330
|
+
let hxMethodAttr = null;
|
|
1331
|
+
let hxMethodVal = null;
|
|
1332
|
+
for (const attr of HX_METHODS) {
|
|
1333
|
+
const val = el.getAttribute(attr);
|
|
1334
|
+
if (val !== null) {
|
|
1335
|
+
hxMethodAttr = attr;
|
|
1336
|
+
hxMethodVal = val;
|
|
1337
|
+
break;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
// -- Form default action --
|
|
1341
|
+
// A <form> with no hx-method and no native action posts back to the
|
|
1342
|
+
// view that rendered it ("this") - a bare <form> in any BP view is a
|
|
1343
|
+
// working form with zero wiring. Native `action` or an explicit
|
|
1344
|
+
// hx-method opts out of the default.
|
|
1345
|
+
if (tag === "FORM" && !hxMethodAttr && !el.hasAttribute("action")) {
|
|
1346
|
+
el.setAttribute("hx-post", "this");
|
|
1347
|
+
hxMethodAttr = "hx-post";
|
|
1348
|
+
hxMethodVal = "this";
|
|
1349
|
+
newlyHtmxedForms.push(el);
|
|
1350
|
+
changed = true;
|
|
1351
|
+
}
|
|
1352
|
+
// Anchor href
|
|
1353
|
+
const isAnchor = tag === "A";
|
|
1354
|
+
const rawHref = isAnchor ? (el.getAttribute("href") || "") : "";
|
|
1355
|
+
const hasHref = isAnchor && isRelativeServicePath(rawHref);
|
|
1356
|
+
// Skip anchors with target="_blank" etc. or non-navigable hrefs
|
|
1357
|
+
if (isAnchor) {
|
|
1358
|
+
const linkTarget = el.getAttribute("target");
|
|
1359
|
+
if (linkTarget && linkTarget !== "_self")
|
|
1360
|
+
continue;
|
|
1361
|
+
if (el.hasAttribute("download"))
|
|
1362
|
+
continue;
|
|
1363
|
+
if (!hasHref && !hxMethodAttr)
|
|
1364
|
+
continue;
|
|
1365
|
+
if (rawHref.startsWith("#") || rawHref.startsWith("mailto:") || rawHref.startsWith("tel:") || rawHref.startsWith("javascript:"))
|
|
1366
|
+
continue;
|
|
1367
|
+
}
|
|
1368
|
+
// Nothing to resolve
|
|
1369
|
+
if (!hasHref && !hxMethodAttr)
|
|
1370
|
+
continue;
|
|
1371
|
+
const hadExplicitTarget = el.hasAttribute("hx-target");
|
|
1372
|
+
if (hadExplicitTarget)
|
|
1373
|
+
el.setAttribute("data-bp-explicit-target", "");
|
|
1374
|
+
// -- Shell default targeting --
|
|
1375
|
+
// Any hx-action element that doesn't declare its own target swaps the
|
|
1376
|
+
// main content panel with innerHTML. Applied at parse time for EVERY
|
|
1377
|
+
// method element (relative, absolute, or "this") so views never have
|
|
1378
|
+
// to hand-wire hx-target/hx-swap. Opt out with an explicit hx-target
|
|
1379
|
+
// (e.g. "this") or data-bp="rewrite:false".
|
|
1380
|
+
if (hxMethodAttr && !hadExplicitTarget) {
|
|
1381
|
+
el.setAttribute("hx-target", "#bp-main");
|
|
1382
|
+
if (!el.hasAttribute("hx-swap"))
|
|
1383
|
+
el.setAttribute("hx-swap", "innerHTML");
|
|
1384
|
+
changed = true;
|
|
1385
|
+
}
|
|
1386
|
+
// Element-level service override
|
|
1387
|
+
const elContext = serviceContextFor(el, serviceId);
|
|
1388
|
+
const elServiceId = elContext.id;
|
|
1389
|
+
const elServiceOrigin = elContext.origin || serviceOrigin || unresolvedServiceOrigin;
|
|
1390
|
+
// Path to resolve (prefer hx-method value, fallback to href)
|
|
1391
|
+
const hxThisUrl = hxMethodAttr && isThisReference(hxMethodVal)
|
|
1392
|
+
? resolveThisServiceUrl(el, { id: elServiceId, origin: elServiceOrigin })
|
|
1393
|
+
: "";
|
|
1394
|
+
const hxMethodPath = isRelativeServicePath(hxMethodVal) ? (hxMethodVal || "").trim() : "";
|
|
1395
|
+
if (hxMethodAttr && !hxMethodPath && !hxThisUrl)
|
|
1396
|
+
continue;
|
|
1397
|
+
const resolvePath = hxMethodPath || rawHref;
|
|
1398
|
+
if (!hxThisUrl && !isRelativeServicePath(resolvePath))
|
|
1399
|
+
continue;
|
|
1400
|
+
// Had an explicit hx-target BEFORE shell default targeting -> the
|
|
1401
|
+
// element knows its own context; don't apply page-nav semantics.
|
|
1402
|
+
if (hadExplicitTarget) {
|
|
1403
|
+
// -- Contextual request: just rewrite URL to absolute --
|
|
1404
|
+
if (hxMethodAttr && (hxThisUrl || hxMethodPath) && elServiceOrigin) {
|
|
1405
|
+
el.setAttribute(hxMethodAttr, hxThisUrl || (elServiceOrigin + hxMethodPath));
|
|
1406
|
+
el.setAttribute("data-bp-shell-route", "ctx");
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
else {
|
|
1410
|
+
if (hxMethodAttr && hxThisUrl) {
|
|
1411
|
+
el.setAttribute(hxMethodAttr, hxThisUrl);
|
|
1412
|
+
el.setAttribute("data-bp-shell-route", "ctx");
|
|
1413
|
+
changed = true;
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1416
|
+
// -- Full page navigation: resolve service path -> tenant path --
|
|
1417
|
+
const pathParts = resolvePath.split("?");
|
|
1418
|
+
const pathOnly = normalizePath(pathParts[0] || "/");
|
|
1419
|
+
const query = pathParts[1] ? "?" + pathParts[1] : "";
|
|
1420
|
+
const match = elServiceId ? matchServiceRoute(elServiceId, pathOnly) : null;
|
|
1421
|
+
if (match) {
|
|
1422
|
+
// Known route - rewrite to tenant path and add htmx attrs
|
|
1423
|
+
const tenantUrl = normalizePath(match.route.tenantPath + match.suffix) + query;
|
|
1424
|
+
const absoluteServiceUrl = match.route.serviceOrigin + pathOnly + query;
|
|
1425
|
+
if (isAnchor)
|
|
1426
|
+
el.setAttribute("href", tenantUrl);
|
|
1427
|
+
if (hxMethodAttr) {
|
|
1428
|
+
el.setAttribute(hxMethodAttr, absoluteServiceUrl);
|
|
1429
|
+
}
|
|
1430
|
+
else {
|
|
1431
|
+
el.setAttribute("hx-get", absoluteServiceUrl);
|
|
1432
|
+
}
|
|
1433
|
+
el.setAttribute("hx-target", "#bp-main");
|
|
1434
|
+
el.setAttribute("hx-swap", "innerHTML");
|
|
1435
|
+
if (!hxMethodAttr || hxMethodAttr === "hx-get")
|
|
1436
|
+
el.setAttribute("hx-push-url", tenantUrl);
|
|
1437
|
+
el.setAttribute("data-bp-shell-route", "page");
|
|
1438
|
+
}
|
|
1439
|
+
else if (elServiceOrigin && hxMethodAttr && hxMethodPath) {
|
|
1440
|
+
// Unknown route but has hx-method - at minimum make URL absolute
|
|
1441
|
+
// and treat as full-page since no target
|
|
1442
|
+
el.setAttribute(hxMethodAttr, elServiceOrigin + hxMethodPath);
|
|
1443
|
+
el.setAttribute("hx-target", "#bp-main");
|
|
1444
|
+
el.setAttribute("hx-swap", "innerHTML");
|
|
1445
|
+
el.setAttribute("data-bp-shell-route", "page");
|
|
1446
|
+
}
|
|
1447
|
+
else if (hasHref && !hxMethodAttr && elServiceOrigin) {
|
|
1448
|
+
// Anchor with unknown service path - still make absolute + page nav
|
|
1449
|
+
const absoluteUrl = elServiceOrigin + resolvePath;
|
|
1450
|
+
el.setAttribute("hx-get", absoluteUrl);
|
|
1451
|
+
el.setAttribute("hx-target", "#bp-main");
|
|
1452
|
+
el.setAttribute("hx-swap", "innerHTML");
|
|
1453
|
+
el.setAttribute("hx-push-url", resolvePath);
|
|
1454
|
+
el.setAttribute("data-bp-shell-route", "page");
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
changed = true;
|
|
1458
|
+
bindBpPreload(el);
|
|
1459
|
+
}
|
|
1460
|
+
if (changed && reprocess && htmx && typeof htmx.process === "function") {
|
|
1461
|
+
htmx.process(root);
|
|
1462
|
+
}
|
|
1463
|
+
else if (newlyHtmxedForms.length > 0 && htmx && typeof htmx.process === "function") {
|
|
1464
|
+
// Forms that gained hx-post AFTER htmx processed the swap have no
|
|
1465
|
+
// submit binding yet. Process just those forms - never the whole
|
|
1466
|
+
// root, which would re-fire hx-trigger="load" requests.
|
|
1467
|
+
for (const form of newlyHtmxedForms)
|
|
1468
|
+
htmx.process(form);
|
|
1469
|
+
}
|
|
1470
|
+
};
|
|
1471
|
+
// -- Active route management --
|
|
1472
|
+
const setActiveRoute = (path) => {
|
|
1473
|
+
let activeLink = null;
|
|
1474
|
+
routeLinks().forEach((link) => {
|
|
1475
|
+
const isActive = link.getAttribute("href") === path;
|
|
1476
|
+
link.classList.toggle("active", isActive);
|
|
1477
|
+
link.setAttribute("aria-current", isActive ? "page" : "false");
|
|
1478
|
+
if (isActive) {
|
|
1479
|
+
activeLink = link;
|
|
1480
|
+
const title = link.getAttribute("data-bp-route-title") || link.textContent || path;
|
|
1481
|
+
const tn = titleNode();
|
|
1482
|
+
if (tn)
|
|
1483
|
+
tn.textContent = title;
|
|
1484
|
+
const svcId = link.getAttribute("data-bp-service");
|
|
1485
|
+
if (svcId)
|
|
1486
|
+
mainOutlet()?.setAttribute("data-bp-service", svcId);
|
|
1487
|
+
}
|
|
1488
|
+
});
|
|
1489
|
+
navGroups().forEach((group) => {
|
|
1490
|
+
if (group.querySelector("[data-bp-route-link].active"))
|
|
1491
|
+
group.open = true;
|
|
1492
|
+
});
|
|
1493
|
+
const bcNode = breadcrumbNode();
|
|
1494
|
+
if (bcNode) {
|
|
1495
|
+
const breadcrumb = activeLink ? (activeLink.getAttribute("data-bp-route-breadcrumb") || "") : "";
|
|
1496
|
+
bcNode.textContent = breadcrumb;
|
|
1497
|
+
bcNode.toggleAttribute("hidden", !breadcrumb);
|
|
1498
|
+
}
|
|
1499
|
+
};
|
|
1500
|
+
// -- Click handler: error actions --
|
|
1501
|
+
const routeContextFromSource = (source, fallbackPath = window.location.pathname) => {
|
|
1502
|
+
const link = source?.closest?.("[data-bp-route-link]") || activeRouteLink();
|
|
1503
|
+
return {
|
|
1504
|
+
path: link?.getAttribute("href") || fallbackPath,
|
|
1505
|
+
title: link?.getAttribute("data-bp-route-title") || link?.textContent?.trim() || fallbackPath,
|
|
1506
|
+
breadcrumb: link?.getAttribute("data-bp-route-breadcrumb") || "",
|
|
1507
|
+
serviceId: link?.getAttribute("data-bp-service") || currentServiceId()
|
|
1508
|
+
};
|
|
1509
|
+
};
|
|
1510
|
+
const renderRouteError = (title, message, action, source) => {
|
|
1511
|
+
const route = routeContextFromSource(source);
|
|
1512
|
+
setActiveRoute(route.path);
|
|
1513
|
+
const tn = titleNode();
|
|
1514
|
+
if (tn)
|
|
1515
|
+
tn.textContent = route.title;
|
|
1516
|
+
const bcNode = breadcrumbNode();
|
|
1517
|
+
if (bcNode) {
|
|
1518
|
+
bcNode.textContent = route.breadcrumb;
|
|
1519
|
+
bcNode.toggleAttribute("hidden", !route.breadcrumb);
|
|
1520
|
+
}
|
|
1521
|
+
if (route.serviceId)
|
|
1522
|
+
mainOutlet()?.setAttribute("data-bp-service", route.serviceId);
|
|
1523
|
+
replaceMainWithError(title, message, action, route.path);
|
|
1524
|
+
markLoaded();
|
|
1525
|
+
setLoading(false);
|
|
1526
|
+
scrollPageToTop();
|
|
1527
|
+
};
|
|
1528
|
+
const handleErrorAction = (event) => {
|
|
1529
|
+
const trigger = event.target?.closest?.("[data-bp-error-action]");
|
|
1530
|
+
if (!trigger)
|
|
1531
|
+
return;
|
|
1532
|
+
const action = trigger.getAttribute("data-bp-error-action");
|
|
1533
|
+
if (action === "login") {
|
|
1534
|
+
const loginUrl = shellRoot()?.getAttribute("data-bp-login-url");
|
|
1535
|
+
if (loginUrl)
|
|
1536
|
+
loadLoginIntoShell(loginUrl);
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
if (action === "reload")
|
|
1540
|
+
triggerShellLink(window.location.pathname + window.location.search, undefined, true);
|
|
1541
|
+
};
|
|
1542
|
+
const loadLoginIntoShell = (loginUrl) => {
|
|
1543
|
+
if (!loginUrl)
|
|
1544
|
+
return;
|
|
1545
|
+
try {
|
|
1546
|
+
const u = new URL(loginUrl, window.location.origin);
|
|
1547
|
+
const current = window.location.pathname + window.location.search;
|
|
1548
|
+
const nextPath = window.location.pathname === u.pathname ? "/" : current;
|
|
1549
|
+
u.searchParams.set("next", nextPath);
|
|
1550
|
+
const serviceLoginUrl = u.href;
|
|
1551
|
+
const tenantLoginUrl = tenantUrlForServiceUrl(serviceLoginUrl);
|
|
1552
|
+
triggerShellLink(tenantLoginUrl, serviceLoginUrl);
|
|
1553
|
+
}
|
|
1554
|
+
catch { /* ignore */ }
|
|
1555
|
+
};
|
|
1556
|
+
let lastAuthRefreshRetryUrl = "";
|
|
1557
|
+
const retryMainRequest = (ctx) => {
|
|
1558
|
+
const action = ctx?.request?.action;
|
|
1559
|
+
if (!action)
|
|
1560
|
+
return false;
|
|
1561
|
+
const method = String(ctx?.request?.verb || ctx?.request?.method || "GET").toUpperCase();
|
|
1562
|
+
if (method !== "GET")
|
|
1563
|
+
return false;
|
|
1564
|
+
triggerShellLink(window.location.pathname + window.location.search, action, true);
|
|
1565
|
+
return true;
|
|
1566
|
+
};
|
|
1567
|
+
const handleShellRouteClick = (event) => {
|
|
1568
|
+
if (event.defaultPrevented)
|
|
1569
|
+
return;
|
|
1570
|
+
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
|
|
1571
|
+
return;
|
|
1572
|
+
const anchor = event.target?.closest?.("a[href][hx-get]");
|
|
1573
|
+
if (!anchor)
|
|
1574
|
+
return;
|
|
1575
|
+
if (anchor.hasAttribute("download"))
|
|
1576
|
+
return;
|
|
1577
|
+
const targetAttr = anchor.getAttribute("target");
|
|
1578
|
+
if (targetAttr && targetAttr !== "_self")
|
|
1579
|
+
return;
|
|
1580
|
+
const hxGet = anchor.getAttribute("hx-get");
|
|
1581
|
+
const hxTarget = anchor.getAttribute("hx-target") || "#bp-main";
|
|
1582
|
+
if (!hxGet || hxTarget !== "#bp-main")
|
|
1583
|
+
return;
|
|
1584
|
+
closeContainingOffcanvas(anchor);
|
|
1585
|
+
};
|
|
1586
|
+
// -- DOM setup --
|
|
1587
|
+
document.addEventListener("DOMContentLoaded", () => {
|
|
1588
|
+
cleanupStaleBootstrapOverlays();
|
|
1589
|
+
setActiveRoute(window.location.pathname);
|
|
1590
|
+
resolveServiceLinks(document.body);
|
|
1591
|
+
initBootstrapComponents(document.body);
|
|
1592
|
+
void loadBackgroundFragments();
|
|
1593
|
+
if (!hasLoaded())
|
|
1594
|
+
topbarProgress()?.classList.add("is-active");
|
|
1595
|
+
// P14: kick off menu service health checks for the admin shell only.
|
|
1596
|
+
if (shellRoot()?.getAttribute("data-bp-auth-mode") !== "true") {
|
|
1597
|
+
runMenuHealthChecks();
|
|
1598
|
+
setInterval(runMenuHealthChecks, 60 * 60 * 1000);
|
|
1599
|
+
}
|
|
1600
|
+
});
|
|
1601
|
+
// -- Menu health check (P14) --
|
|
1602
|
+
// Pings /.well-known/bp/health on each service in serviceOrigins.
|
|
1603
|
+
// Adds .bp-service-down to anchors whose service is unreachable.
|
|
1604
|
+
// Clicking a downed link triggers a force re-check and clears state on success.
|
|
1605
|
+
const runMenuHealthChecks = async () => {
|
|
1606
|
+
const origins = (() => { try {
|
|
1607
|
+
return JSON.parse(shellRoot()?.getAttribute("data-bp-services") || "{}");
|
|
1608
|
+
}
|
|
1609
|
+
catch {
|
|
1610
|
+
return {};
|
|
1611
|
+
} })();
|
|
1612
|
+
const entries = Object.entries(origins);
|
|
1613
|
+
const results = {};
|
|
1614
|
+
await Promise.all(entries.map(async ([sid, origin]) => {
|
|
1615
|
+
try {
|
|
1616
|
+
const r = await fetch(`${origin.replace(/\/+$/, "")}/.well-known/bp/health`, { method: "GET", mode: "cors", cache: "no-store" });
|
|
1617
|
+
results[sid] = r.ok;
|
|
1618
|
+
}
|
|
1619
|
+
catch {
|
|
1620
|
+
results[sid] = false;
|
|
1621
|
+
}
|
|
1622
|
+
}));
|
|
1623
|
+
document.querySelectorAll("[data-bp-service]").forEach((el) => {
|
|
1624
|
+
const sid = el.getAttribute("data-bp-service");
|
|
1625
|
+
if (!sid)
|
|
1626
|
+
return;
|
|
1627
|
+
const up = results[sid];
|
|
1628
|
+
// undefined = no entry in origins (skip), true/false = known
|
|
1629
|
+
if (up === undefined)
|
|
1630
|
+
return;
|
|
1631
|
+
if (up) {
|
|
1632
|
+
el.classList.remove("bp-service-down");
|
|
1633
|
+
el.removeAttribute("aria-disabled");
|
|
1634
|
+
}
|
|
1635
|
+
else {
|
|
1636
|
+
el.classList.add("bp-service-down");
|
|
1637
|
+
el.setAttribute("aria-disabled", "true");
|
|
1638
|
+
}
|
|
1639
|
+
});
|
|
1640
|
+
};
|
|
1641
|
+
// Force-recheck on click of disabled menu link; if back up, allow nav.
|
|
1642
|
+
document.body.addEventListener("click", async (event) => {
|
|
1643
|
+
const target = event.target?.closest?.(".bp-service-down");
|
|
1644
|
+
if (!target)
|
|
1645
|
+
return;
|
|
1646
|
+
event.preventDefault();
|
|
1647
|
+
event.stopPropagation();
|
|
1648
|
+
await runMenuHealthChecks();
|
|
1649
|
+
if (!target.classList.contains("bp-service-down")) {
|
|
1650
|
+
// Recovered - replay click as a normal navigation.
|
|
1651
|
+
target.click();
|
|
1652
|
+
}
|
|
1653
|
+
}, true);
|
|
1654
|
+
document.body.addEventListener("click", handleErrorAction);
|
|
1655
|
+
document.body.addEventListener("click", handleShellRouteClick);
|
|
1656
|
+
document.addEventListener("htmx:before:history:update", (event) => {
|
|
1657
|
+
const detail = event.detail;
|
|
1658
|
+
if (detail?.history?.path) {
|
|
1659
|
+
detail.history.path = tenantUrlForServiceUrl(detail.history.path);
|
|
1660
|
+
}
|
|
1661
|
+
});
|
|
1662
|
+
document.body.addEventListener("click", (event) => {
|
|
1663
|
+
const el = event.target;
|
|
1664
|
+
const toggleBtn = el?.closest?.("[data-bp-toggle-detail]");
|
|
1665
|
+
if (toggleBtn) {
|
|
1666
|
+
const pane = toggleBtn.closest(".bp-split-pane");
|
|
1667
|
+
if (pane) {
|
|
1668
|
+
const open = pane.getAttribute("data-bp-detail-open") === "true";
|
|
1669
|
+
pane.setAttribute("data-bp-detail-open", open ? "false" : "true");
|
|
1670
|
+
}
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
const closeBtn = el?.closest?.("[data-bp-close-detail]");
|
|
1674
|
+
if (closeBtn) {
|
|
1675
|
+
const pane = closeBtn.closest(".bp-split-pane");
|
|
1676
|
+
if (pane)
|
|
1677
|
+
pane.setAttribute("data-bp-detail-open", "false");
|
|
1678
|
+
}
|
|
1679
|
+
});
|
|
1680
|
+
// -- HTMX extension: bp-shell --
|
|
1681
|
+
htmx.registerExtension("bp-shell", {
|
|
1682
|
+
// Resolve relative service URLs before htmx processes the element.
|
|
1683
|
+
// This catches elements with existing hx-methods that resolveServiceLinks
|
|
1684
|
+
// already rewrote, PLUS any that were missed (dynamically added, etc.)
|
|
1685
|
+
htmx_before_init(elt) {
|
|
1686
|
+
if (!elt || !elt.getAttribute)
|
|
1687
|
+
return;
|
|
1688
|
+
if (elt instanceof Element && elt.closest("[data-bp-no-route]"))
|
|
1689
|
+
return;
|
|
1690
|
+
if (elt instanceof Element)
|
|
1691
|
+
resolveServiceLinks(elt, false);
|
|
1692
|
+
if (elt instanceof Element && elt.hasAttribute(DOWNLOAD_ATTR))
|
|
1693
|
+
bindDownload(elt);
|
|
1694
|
+
for (const attr of HX_METHODS) {
|
|
1695
|
+
const val = elt.getAttribute(attr);
|
|
1696
|
+
if (isThisReference(val)) {
|
|
1697
|
+
const context = elt instanceof Element ? serviceContextFor(elt) : { id: "", origin: "" };
|
|
1698
|
+
const action = elt instanceof Element ? resolveThisServiceUrl(elt, context) : "";
|
|
1699
|
+
if (action)
|
|
1700
|
+
elt.setAttribute(attr, action);
|
|
1701
|
+
}
|
|
1702
|
+
else if (isRelativeServicePath(val)) {
|
|
1703
|
+
const { origin } = elt instanceof Element ? serviceContextFor(elt) : { origin: "" };
|
|
1704
|
+
if (origin)
|
|
1705
|
+
elt.setAttribute(attr, origin + (val || "").trim());
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
// Also rewrite SSE connect URL so hx-sse ext captures absolute URL
|
|
1709
|
+
// when it reads the attribute during htmx_after_process.
|
|
1710
|
+
if (elt.hasAttribute?.("hx-sse:connect")) {
|
|
1711
|
+
const sseVal = elt.getAttribute("hx-sse:connect");
|
|
1712
|
+
if (sseVal && sseVal.startsWith("/")) {
|
|
1713
|
+
const { origin } = elt instanceof Element ? serviceContextFor(elt) : { origin: "" };
|
|
1714
|
+
if (origin)
|
|
1715
|
+
elt.setAttribute("hx-sse:connect", origin + sseVal);
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
},
|
|
1719
|
+
htmx_after_process(elt) {
|
|
1720
|
+
if (elt instanceof Element)
|
|
1721
|
+
resolveServiceLinks(elt, false);
|
|
1722
|
+
},
|
|
1723
|
+
htmx_config_request(elt, detail) {
|
|
1724
|
+
const ctx = detail.ctx;
|
|
1725
|
+
if (!ctx || !ctx.request)
|
|
1726
|
+
return;
|
|
1727
|
+
const source = ctx.sourceElement instanceof Element
|
|
1728
|
+
? ctx.sourceElement
|
|
1729
|
+
: elt instanceof Element
|
|
1730
|
+
? elt
|
|
1731
|
+
: null;
|
|
1732
|
+
// Don't clobber Accept header for SSE-connect requests - hx-sse ext
|
|
1733
|
+
// sets it to "text/html, text/event-stream".
|
|
1734
|
+
if (source?.hasAttribute?.("hx-sse:connect") || source?.hasAttribute?.("sse-connect"))
|
|
1735
|
+
return;
|
|
1736
|
+
const mode = isMainTarget(ctx.target) ? "page" : "fragment";
|
|
1737
|
+
const hasAcceptHeader = Object.keys(ctx.request.headers).some((key) => key.toLowerCase() === "accept");
|
|
1738
|
+
if (!hasAcceptHeader) {
|
|
1739
|
+
ctx.request.headers["Accept"] = "text/html; theme=bootstrap1; mode=" + mode;
|
|
1740
|
+
}
|
|
1741
|
+
// Attach stored BP headers (Authorization etc.) to every BP request -
|
|
1742
|
+
// this is what carries the login token to services after sign-in.
|
|
1743
|
+
// Rewrite same-origin action URLs (e.g. hx-post="" -> current path on theme origin)
|
|
1744
|
+
// to the owning service origin. Without this, a form rendered by a service-owned
|
|
1745
|
+
// route would POST back to the theme - which has no such route. Service inferred
|
|
1746
|
+
// from the element's nearest data-bp-service ancestor, falling back to bp-main's.
|
|
1747
|
+
try {
|
|
1748
|
+
const action = ctx.request?.action || "";
|
|
1749
|
+
if (!action)
|
|
1750
|
+
return;
|
|
1751
|
+
if (source?.closest?.("[data-bp-no-route]")) {
|
|
1752
|
+
attachBpHeaders(ctx.request.headers, action);
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
const themeOrigin = window.location.origin;
|
|
1756
|
+
const url = new URL(action, themeOrigin);
|
|
1757
|
+
if (url.origin === themeOrigin) {
|
|
1758
|
+
const explicitContext = explicitServiceContextFor(source);
|
|
1759
|
+
if (explicitContext.origin) {
|
|
1760
|
+
ctx.request.action = explicitContext.origin + url.pathname + url.search;
|
|
1761
|
+
}
|
|
1762
|
+
else {
|
|
1763
|
+
// Authoritative fallback: if the path matches a known route's TENANT path,
|
|
1764
|
+
// rewrite to that route's service origin + service path. Correct even
|
|
1765
|
+
// for programmatic navigations (e.g. a post-login HX-Location to a
|
|
1766
|
+
// tenant path) where the DOM owning-element context belongs to a
|
|
1767
|
+
// different service (the auth service that rendered the login form).
|
|
1768
|
+
const routeMatch = matchTenantRoute(url.pathname);
|
|
1769
|
+
if (routeMatch) {
|
|
1770
|
+
ctx.request.action =
|
|
1771
|
+
routeMatch.route.serviceOrigin
|
|
1772
|
+
+ normalizePath(routeMatch.route.servicePath + routeMatch.suffix)
|
|
1773
|
+
+ url.search;
|
|
1774
|
+
}
|
|
1775
|
+
else {
|
|
1776
|
+
// Fallback: infer the service from the element's data-bp-service
|
|
1777
|
+
// ancestor (or #bp-main). Used for in-context requests like a form
|
|
1778
|
+
// POSTing back to the service-owned route that rendered it.
|
|
1779
|
+
const ownerContext = serviceContextFor(source || mainOutlet());
|
|
1780
|
+
const origin = ownerContext.origin || unresolvedServiceOrigin;
|
|
1781
|
+
ctx.request.action = origin + url.pathname + url.search;
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
catch { /* non-fatal */ }
|
|
1787
|
+
// Scope checks must use the final action after any service-origin rewrite.
|
|
1788
|
+
attachBpHeaders(ctx.request.headers, ctx.request?.action || "");
|
|
1789
|
+
},
|
|
1790
|
+
// Show loading state: main panel gets glaze, fragments get overlay
|
|
1791
|
+
htmx_before_request(_elt, detail) {
|
|
1792
|
+
const source = detail.ctx?.sourceElement;
|
|
1793
|
+
const preload = source?._htmx?.preload;
|
|
1794
|
+
if (preload && preload.action === detail.ctx?.request?.action && Date.now() < preload.expiresAt) {
|
|
1795
|
+
detail.ctx.fetch = () => preload.prefetch;
|
|
1796
|
+
delete source._htmx.preload;
|
|
1797
|
+
}
|
|
1798
|
+
if (requestTargetEscapesLane(detail)) {
|
|
1799
|
+
if (source instanceof Element && sanitizeHtmxTarget(source)) {
|
|
1800
|
+
htmx.process(source);
|
|
1801
|
+
}
|
|
1802
|
+
return false;
|
|
1803
|
+
}
|
|
1804
|
+
const target = detail.ctx?.target;
|
|
1805
|
+
if (requestTargetsMain(detail)) {
|
|
1806
|
+
closeContainingOffcanvas(detail.ctx?.sourceElement);
|
|
1807
|
+
cleanupStaleBootstrapOverlays();
|
|
1808
|
+
if (isMainTarget(detail.ctx?.sourceElement))
|
|
1809
|
+
disableInitialMainLoad();
|
|
1810
|
+
const action = detail.ctx?.request?.action || "";
|
|
1811
|
+
if (action && isThemeOriginUrl(action)) {
|
|
1812
|
+
const message = "Invalid BetterPortal route: content service resolves to the theme origin.";
|
|
1813
|
+
disableInitialMainLoad();
|
|
1814
|
+
renderRouteError("Route Configuration Error", message, { kind: "reload", label: "Reload" }, detail.ctx?.sourceElement);
|
|
1815
|
+
return false;
|
|
1816
|
+
}
|
|
1817
|
+
clearError();
|
|
1818
|
+
if (hasLoaded())
|
|
1819
|
+
setLoading(true);
|
|
1820
|
+
}
|
|
1821
|
+
else if (target instanceof Element) {
|
|
1822
|
+
target.classList.add("bp-fragment-loading");
|
|
1823
|
+
}
|
|
1824
|
+
},
|
|
1825
|
+
// Let htmx v4 swap HTTP error HTML by default. Only block data
|
|
1826
|
+
// responses and handle BP's auth-refresh/login escape hatch here.
|
|
1827
|
+
htmx_before_swap(_elt, detail) {
|
|
1828
|
+
const ctx = detail.ctx;
|
|
1829
|
+
const status = ctx?.response?.status;
|
|
1830
|
+
const target = ctx?.target;
|
|
1831
|
+
applyChromeFromResponse(detail);
|
|
1832
|
+
// JSON is data, never markup - block it from swapping into ANY target
|
|
1833
|
+
// regardless of status. Scripts that want the body (login) read it via
|
|
1834
|
+
// htmx:afterRequest; error states surface via htmx:error / 401 flow.
|
|
1835
|
+
const swapContentType = ctx?.response?.headers?.get?.("content-type") || "";
|
|
1836
|
+
const isJson = swapContentType.includes("application/json");
|
|
1837
|
+
if (isJson) {
|
|
1838
|
+
if (status && status >= 400 && isMainTarget(target)) {
|
|
1839
|
+
// fall through to the error handling below (401->login etc.)
|
|
1840
|
+
}
|
|
1841
|
+
else {
|
|
1842
|
+
return false;
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
if (status && status >= 400 && isMainTarget(target)) {
|
|
1846
|
+
// Themed status views (adapter content-type "...; mode=status") are
|
|
1847
|
+
// real server-rendered error states - let them swap like any view
|
|
1848
|
+
// (e.g. register POST 400 re-rendering its form with the message).
|
|
1849
|
+
const source = ctx?.sourceElement;
|
|
1850
|
+
if (status === 401 && source instanceof Element && source.closest("#bp-login-form")) {
|
|
1851
|
+
return false;
|
|
1852
|
+
}
|
|
1853
|
+
// On 401, load the login view INTO the shell (#bp-main) - the user
|
|
1854
|
+
// never leaves the theme origin. Services render in-shell via HTMX;
|
|
1855
|
+
// a full-page navigation to the auth service origin is wrong (and
|
|
1856
|
+
// such services may only be reachable from a browser with the shell
|
|
1857
|
+
// open). We use the login URL the THEME resolved from app.auth config,
|
|
1858
|
+
// never a service-supplied HX-Location (a content service has no
|
|
1859
|
+
// reliable knowledge of where the auth provider lives).
|
|
1860
|
+
setLoading(false);
|
|
1861
|
+
if (status === 401) {
|
|
1862
|
+
const loginUrl = shellRoot()?.getAttribute("data-bp-login-url");
|
|
1863
|
+
const action = ctx?.request?.action || "";
|
|
1864
|
+
if (loginUrl && action !== lastAuthRefreshRetryUrl) {
|
|
1865
|
+
void refreshStoredHeadersOnce(true).then((refreshed) => {
|
|
1866
|
+
if (refreshed) {
|
|
1867
|
+
lastAuthRefreshRetryUrl = action;
|
|
1868
|
+
if (retryMainRequest(ctx))
|
|
1869
|
+
return;
|
|
1870
|
+
}
|
|
1871
|
+
lastAuthRefreshRetryUrl = "";
|
|
1872
|
+
clearAuthorizationHeader();
|
|
1873
|
+
loadLoginIntoShell(loginUrl);
|
|
1874
|
+
});
|
|
1875
|
+
}
|
|
1876
|
+
else if (loginUrl) {
|
|
1877
|
+
lastAuthRefreshRetryUrl = "";
|
|
1878
|
+
clearAuthorizationHeader();
|
|
1879
|
+
loadLoginIntoShell(loginUrl);
|
|
1880
|
+
}
|
|
1881
|
+
else {
|
|
1882
|
+
const source = ctx?.sourceElement instanceof Element ? ctx.sourceElement : activeRouteLink();
|
|
1883
|
+
renderRouteError("Session Expired", errorMessage(status), bannerActionForStatus(status), source);
|
|
1884
|
+
}
|
|
1885
|
+
return false;
|
|
1886
|
+
}
|
|
1887
|
+
if (!isJson) {
|
|
1888
|
+
disposeBootstrapComponents(target);
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
return false; // cancel swap - htmx:error handles the UI
|
|
1892
|
+
}
|
|
1893
|
+
if (isMainTarget(target)) {
|
|
1894
|
+
disposeBootstrapComponents(target);
|
|
1895
|
+
}
|
|
1896
|
+
},
|
|
1897
|
+
// Rewrite SSE connect URLs in the response body before the swap
|
|
1898
|
+
// pipeline builds task fragments, so hx-sse ext reads the absolute
|
|
1899
|
+
// service-origin URL once the new content is processed.
|
|
1900
|
+
htmx_after_request(_elt, detail) {
|
|
1901
|
+
cleanupStaleBootstrapOverlays();
|
|
1902
|
+
applyChromeFromResponse(detail);
|
|
1903
|
+
// Apply BP-SetHeader / BP-RemoveHeader directives from EVERY response
|
|
1904
|
+
// (success or error) before anything else - e.g. login's Authorization.
|
|
1905
|
+
try {
|
|
1906
|
+
applyBpHeaderDirectives(detail.ctx?.response, detail.ctx?.request?.action || "");
|
|
1907
|
+
}
|
|
1908
|
+
catch { /* non-fatal */ }
|
|
1909
|
+
// HX-Location with a bare path has no target in htmx4 - the follow-up
|
|
1910
|
+
// ajax would swap document.body and blow away the shell. Rewrite it
|
|
1911
|
+
// into a config object that swaps the main outlet and pushes the
|
|
1912
|
+
// tenant path (config_request later maps the path to its service).
|
|
1913
|
+
try {
|
|
1914
|
+
const loc = detail.ctx?.hx?.location;
|
|
1915
|
+
if (typeof loc === "string" && loc && loc[0] !== "{" && !/[\s,]/.test(loc)) {
|
|
1916
|
+
detail.ctx.hx.location = JSON.stringify({
|
|
1917
|
+
path: loc,
|
|
1918
|
+
target: "#bp-main",
|
|
1919
|
+
swap: "innerHTML",
|
|
1920
|
+
push: tenantUrlForServiceUrl(loc)
|
|
1921
|
+
});
|
|
1922
|
+
}
|
|
1923
|
+
else if (typeof loc === "string" && loc.trim().startsWith("{")) {
|
|
1924
|
+
const parsed = JSON.parse(loc);
|
|
1925
|
+
detail.ctx.hx.location = JSON.stringify({
|
|
1926
|
+
...parsed,
|
|
1927
|
+
target: "#bp-main",
|
|
1928
|
+
swap: parsed.swap || "innerHTML",
|
|
1929
|
+
push: typeof parsed.push === "string"
|
|
1930
|
+
? tenantUrlForServiceUrl(parsed.push)
|
|
1931
|
+
: typeof parsed.path === "string"
|
|
1932
|
+
? tenantUrlForServiceUrl(parsed.path)
|
|
1933
|
+
: parsed.push
|
|
1934
|
+
});
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
catch { /* non-fatal */ }
|
|
1938
|
+
try {
|
|
1939
|
+
const ctx = detail.ctx;
|
|
1940
|
+
const text = ctx?.text;
|
|
1941
|
+
const requestUrl = ctx?.request?.action;
|
|
1942
|
+
if (!text || !requestUrl)
|
|
1943
|
+
return;
|
|
1944
|
+
if (!/hx-sse:connect="\/|sse-connect="\//.test(text))
|
|
1945
|
+
return;
|
|
1946
|
+
const origin = new URL(requestUrl, window.location.origin).origin;
|
|
1947
|
+
ctx.text = text
|
|
1948
|
+
.replace(/(hx-sse:connect=")\//g, "$1" + origin + "/")
|
|
1949
|
+
.replace(/(sse-connect=")\//g, "$1" + origin + "/");
|
|
1950
|
+
}
|
|
1951
|
+
catch { /* non-fatal */ }
|
|
1952
|
+
},
|
|
1953
|
+
// After successful swap: clear loading, resolve service links, reload Bootstrap
|
|
1954
|
+
htmx_after_swap(_elt, detail) {
|
|
1955
|
+
let target = detail.ctx?.target;
|
|
1956
|
+
if (!target)
|
|
1957
|
+
return;
|
|
1958
|
+
if (target instanceof Element && !target.isConnected && target.id) {
|
|
1959
|
+
target = document.getElementById(target.id) || target;
|
|
1960
|
+
}
|
|
1961
|
+
if (isMainTarget(target)) {
|
|
1962
|
+
disableInitialMainLoad();
|
|
1963
|
+
markLoaded();
|
|
1964
|
+
setLoading(false);
|
|
1965
|
+
clearError();
|
|
1966
|
+
cleanupTeleportedModals();
|
|
1967
|
+
cleanupTeleportedOffcanvas();
|
|
1968
|
+
cleanupStaleBootstrapOverlays();
|
|
1969
|
+
teleportModals(target);
|
|
1970
|
+
teleportOffcanvas(target);
|
|
1971
|
+
scrollPageToTop();
|
|
1972
|
+
}
|
|
1973
|
+
else if (target instanceof Element) {
|
|
1974
|
+
target.classList.remove("bp-fragment-loading");
|
|
1975
|
+
}
|
|
1976
|
+
// Resolve links after swaps without re-processing the swap target.
|
|
1977
|
+
// Re-processing #bp-main can re-fire its hx-trigger="load" request.
|
|
1978
|
+
resolveServiceLinks(target, false);
|
|
1979
|
+
initBootstrapComponents(target);
|
|
1980
|
+
// Sync profile mirror for mobile offcanvas
|
|
1981
|
+
if (target === profileSlot())
|
|
1982
|
+
syncProfileMirror();
|
|
1983
|
+
},
|
|
1984
|
+
// Belt-and-suspenders: clear loading after settle
|
|
1985
|
+
htmx_after_settle(elt) {
|
|
1986
|
+
if (isMainTarget(elt))
|
|
1987
|
+
setLoading(false);
|
|
1988
|
+
else if (elt instanceof Element)
|
|
1989
|
+
elt.classList.remove("bp-fragment-loading");
|
|
1990
|
+
cleanupStaleBootstrapOverlays();
|
|
1991
|
+
},
|
|
1992
|
+
// Update sidebar active state on history navigation
|
|
1993
|
+
htmx_after_history_push() { setActiveRoute(window.location.pathname); },
|
|
1994
|
+
htmx_after_history_replace() { setActiveRoute(window.location.pathname); },
|
|
1995
|
+
htmx_response_error(_elt, detail) {
|
|
1996
|
+
const ctx = detail?.ctx;
|
|
1997
|
+
if (!requestTargetsMain(detail))
|
|
1998
|
+
return;
|
|
1999
|
+
setLoading(false);
|
|
2000
|
+
const status = ctx?.response?.status || 0;
|
|
2001
|
+
if ([502, 503, 504].includes(status)) {
|
|
2002
|
+
const source = ctx.sourceElement instanceof Element ? ctx.sourceElement : activeRouteLink();
|
|
2003
|
+
const serviceId = source?.getAttribute("data-bp-service") ||
|
|
2004
|
+
currentServiceId();
|
|
2005
|
+
scheduleDevServiceRecovery(serviceId, ctx.request?.action, source, window.location.pathname);
|
|
2006
|
+
}
|
|
2007
|
+
},
|
|
2008
|
+
// htmx v4 reports network, timeout, target, and swap failures here.
|
|
2009
|
+
htmx_error(_elt, detail) {
|
|
2010
|
+
const ctx = detail?.ctx;
|
|
2011
|
+
const target = ctx?.target;
|
|
2012
|
+
// Clear fragment loading on error
|
|
2013
|
+
if (target instanceof Element && !isMainTarget(target)) {
|
|
2014
|
+
target.classList.remove("bp-fragment-loading");
|
|
2015
|
+
}
|
|
2016
|
+
if (!requestTargetsMain(detail))
|
|
2017
|
+
return;
|
|
2018
|
+
// Themed status views already swapped meaningful content - no banner.
|
|
2019
|
+
setLoading(false);
|
|
2020
|
+
const source = ctx?.sourceElement instanceof Element ? ctx.sourceElement : activeRouteLink();
|
|
2021
|
+
const serviceId = source?.getAttribute("data-bp-service") ||
|
|
2022
|
+
currentServiceId();
|
|
2023
|
+
scheduleDevServiceRecovery(serviceId, ctx?.request?.action, source, window.location.pathname);
|
|
2024
|
+
renderRouteError("Connection Error", "Service unavailable or blocked by network policy.", { kind: "reload", label: "Reload" }, source);
|
|
2025
|
+
},
|
|
2026
|
+
});
|
|
2027
|
+
})();
|
|
2028
|
+
}).toString();
|
|
2029
|
+
return `const __name=function(f){return f};${body}`;
|
|
2030
|
+
}
|
|
2031
|
+
export async function loadBootstrap1Asset(assetPath) {
|
|
2032
|
+
const normalized = assetPath.replace(/^\/+/, "");
|
|
2033
|
+
if (normalized === "bootstrap.min.css") {
|
|
2034
|
+
const cacheKey = normalized;
|
|
2035
|
+
if (!AssetCache.has(cacheKey)) {
|
|
2036
|
+
AssetCache.set(cacheKey, readTextAsset(BootstrapCssPath, "text/css; charset=utf-8"));
|
|
2037
|
+
}
|
|
2038
|
+
return AssetCache.get(cacheKey) ?? null;
|
|
2039
|
+
}
|
|
2040
|
+
if (normalized === "bootstrap.bundle.min.js") {
|
|
2041
|
+
const cacheKey = normalized;
|
|
2042
|
+
if (!AssetCache.has(cacheKey)) {
|
|
2043
|
+
AssetCache.set(cacheKey, readTextAsset(BootstrapBundlePath, "application/javascript; charset=utf-8"));
|
|
2044
|
+
}
|
|
2045
|
+
return AssetCache.get(cacheKey) ?? null;
|
|
2046
|
+
}
|
|
2047
|
+
if (normalized === "htmx.min.js") {
|
|
2048
|
+
const cacheKey = normalized;
|
|
2049
|
+
if (!AssetCache.has(cacheKey)) {
|
|
2050
|
+
AssetCache.set(cacheKey, readTextAsset(HtmxPath, "application/javascript; charset=utf-8"));
|
|
2051
|
+
}
|
|
2052
|
+
return AssetCache.get(cacheKey) ?? null;
|
|
2053
|
+
}
|
|
2054
|
+
if (normalized === "hx-sse.min.js") {
|
|
2055
|
+
const cacheKey = normalized;
|
|
2056
|
+
if (!AssetCache.has(cacheKey)) {
|
|
2057
|
+
AssetCache.set(cacheKey, readTextAsset(HtmxSsePath, "application/javascript; charset=utf-8"));
|
|
2058
|
+
}
|
|
2059
|
+
return AssetCache.get(cacheKey) ?? null;
|
|
2060
|
+
}
|
|
2061
|
+
if (normalized === "hx-preload.min.js") {
|
|
2062
|
+
const cacheKey = normalized;
|
|
2063
|
+
if (!AssetCache.has(cacheKey)) {
|
|
2064
|
+
AssetCache.set(cacheKey, readTextAsset(HtmxPreloadPath, "application/javascript; charset=utf-8"));
|
|
2065
|
+
}
|
|
2066
|
+
return AssetCache.get(cacheKey) ?? null;
|
|
2067
|
+
}
|
|
2068
|
+
if (normalized === "bootstrap1-shell.js") {
|
|
2069
|
+
return {
|
|
2070
|
+
body: shellRuntimeSource(),
|
|
2071
|
+
contentType: "application/javascript; charset=utf-8"
|
|
2072
|
+
};
|
|
2073
|
+
}
|
|
2074
|
+
// Single-request core bundle: htmx MUST execute before the shell runtime and
|
|
2075
|
+
// extensions register against it.
|
|
2076
|
+
if (normalized === "bootstrap1-core.js") {
|
|
2077
|
+
const read = (filePath) => {
|
|
2078
|
+
if (!AssetCache.has(filePath)) {
|
|
2079
|
+
AssetCache.set(filePath, readTextAsset(filePath, "application/javascript; charset=utf-8"));
|
|
2080
|
+
}
|
|
2081
|
+
return AssetCache.get(filePath).then((asset) => asset.body);
|
|
2082
|
+
};
|
|
2083
|
+
const [htmx, sse] = await Promise.all([
|
|
2084
|
+
read(HtmxPath),
|
|
2085
|
+
read(HtmxSsePath)
|
|
2086
|
+
]);
|
|
2087
|
+
return {
|
|
2088
|
+
body: [htmx, shellRuntimeSource(), sse].join("\n;\n"),
|
|
2089
|
+
contentType: "application/javascript; charset=utf-8"
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
return null;
|
|
2093
|
+
}
|
|
2094
|
+
//# sourceMappingURL=assets.js.map
|