@betterportal/theme-bootstrap1 10.1.40 → 10.1.42

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