/*
* openvibe-shared/boost.js — smooth page moves for a server-rendered site (plan T11). Every page stays a normal page
* (it works with no JavaScript, crawlers see the same HTML); boost only makes a same-site move feel like an app:
*
* on every page that may be swapped (the server's release id)
* … the part that changes
* starts itself (or call OVBoost.start below)
*
* A click on a same-site link (no modifier key, no target, no download, not data-no-boost, not /auth/ or /api/) fetches
* the next page's HTML (already prefetched on hover, focus or touch), loads its new stylesheets first, swaps with
* the shared transition (route-transition.js: the top bar, "Loading Content…", a fade), updates the title, the head
* tags that describe the page (description, canonical, Open Graph, Twitter, JSON-LD), pushes history, restores scroll on
* back/forward and moves focus to the new content. Any doubt is a normal page load: a different release marker, a
* different set of external scripts, a non-HTML answer, a redirect off the site, an error, a timeout.
* Inline scripts inside the new run after the swap (once); pages listen for `:boost:load` to wire widgets.
*
* OVBoost.start({ main, prefix = 'ov', exclude = [], timeout = 8000, prefetch = true, fx = true }) → controller
* controller.go(url) controller.stop() controller.stats()
*/
(function (root) {
'use strict';
if (typeof document === 'undefined' || root.OVBoost) return;
const selfSrc = document.currentScript && document.currentScript.src;
const selfData = (document.currentScript && document.currentScript.dataset) || {};
const HEAD_TAGS = 'meta[name="description"],link[rel="canonical"],meta[property^="og:"],meta[name^="twitter:"],script[type="application/ld+json"],link[rel="alternate"][hreflang],meta[name="robots"]';
const FILE_EXT = /\.(?:pdf|zip|gz|tgz|png|jpe?g|gif|webp|avif|svg|mp4|webm|mp3|wav|ogg|json|xml|txt|csv|ics)$/i;
function start(o = {}) {
const mainSel = o.main || 'main';
const prefix = o.prefix || 'ov';
const timeout = o.timeout || 8000;
const exclude = ['/auth/', '/api/', '/logout', ...(o.exclude || [])];
const cache = new Map(); // url → { at, promise }
const scrolls = Object.create(null); // history key → y
const s = { moves: 0, fallbacks: 0, prefetches: 0, last: null };
let inflight = null, stopped = false, key = 0;
// The page whose is shown (path and query) and where it was scrolled before a link to one of its parts.
let shown = root.location.pathname + root.location.search, partFromY = null;
const emit = (type, detail) => document.dispatchEvent(new root.CustomEvent(`${prefix}:boost:${type}`, { bubbles: true, detail }));
const marker = (doc) => { const m = doc.querySelector('meta[name="ov-boost"]'); return m ? m.getAttribute('content') : null; };
const externalScripts = (doc) => [...doc.querySelectorAll('script[src]')].map((t) => { try { const u = new URL(t.getAttribute('src'), root.location.href); return u.origin + u.pathname; } catch { return ''; } }).sort().join('\n');
const here = { marker: marker(document), scripts: externalScripts(document) };
if (!here.marker || !document.querySelector(mainSel)) return { go: (u) => root.location.assign(u), stop() {}, stats: () => ({ ...s, off: 'no ov-boost marker or main element' }) };
function boostable(a, ev) {
if (!a || a.target && a.target !== '_self' || a.hasAttribute('download') || a.closest('[data-no-boost]')) return null;
if (ev && (ev.defaultPrevented || ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey)) return null;
let u; try { u = new URL(a.href, root.location.href); } catch { return null; }
if (u.origin !== root.location.origin || !/^https?:$/.test(u.protocol)) return null;
if (FILE_EXT.test(u.pathname) || exclude.some((p) => u.pathname.startsWith(p))) return null;
if (u.pathname === root.location.pathname && u.search === root.location.search && u.hash) return null; // in-page anchor
return u;
}
function fetchPage(url) {
const hit = cache.get(url);
if (hit && Date.now() - hit.at < 15000) return hit.promise;
const ctl = new AbortController();
const t = root.setTimeout(() => ctl.abort(), timeout);
const promise = root.fetch(url, { credentials: 'same-origin', headers: { 'X-OV-Boost': '1', Accept: 'text/html' }, signal: ctl.signal, redirect: 'follow' })
.then(async (r) => {
const ct = r.headers.get('content-type') || '';
if (!r.ok || !/text\/html/.test(ct)) throw new Error(`not a page (${r.status} ${ct})`);
const final = new URL(r.url || url);
if (final.origin !== root.location.origin) throw new Error('redirected off the site');
return { html: await r.text(), url: final.href };
})
.finally(() => root.clearTimeout(t));
cache.set(url, { at: Date.now(), promise });
promise.catch(() => cache.delete(url));
return promise;
}
function prefetch(u) {
if (o.prefetch === false) return;
const c = root.navigator && root.navigator.connection;
if (c && (c.saveData || /2g/.test(c.effectiveType || ''))) return;
if (cache.has(u.href)) return;
s.prefetches++;
fetchPage(u.href).catch(() => {});
}
function loadStylesFirst(doc) {
const have = new Set([...document.querySelectorAll('link[rel="stylesheet"][href]')].map((l) => new URL(l.href, root.location.href).href));
const added = [...doc.querySelectorAll('link[rel="stylesheet"][href]')]
.map((l) => new URL(l.getAttribute('href'), root.location.href).href)
.filter((h) => !have.has(h))
.map((href) => new Promise((done) => {
const l = document.createElement('link'); l.rel = 'stylesheet'; l.href = href;
l.onload = l.onerror = () => done(); root.setTimeout(done, 4000);
document.head.appendChild(l);
}));
return Promise.all(added);
}
function updateHead(doc) {
document.title = doc.title;
document.head.querySelectorAll(HEAD_TAGS).forEach((el) => el.remove());
doc.head.querySelectorAll(HEAD_TAGS).forEach((el) => document.head.appendChild(document.importNode(el, true)));
const lang = doc.documentElement.getAttribute('lang');
if (lang) document.documentElement.setAttribute('lang', lang);
if (doc.body) document.body.className = doc.body.className; // page-type classes
}
function runInlineScripts(scope) {
scope.querySelectorAll('script:not([src])').forEach((old) => {
if (old.type && !/^(text|application)\/(javascript|ecmascript)$|^module$/.test(old.type)) return;
const s2 = document.createElement('script');
for (const a of old.attributes) s2.setAttribute(a.name, a.value);
s2.textContent = old.textContent;
old.replaceWith(s2);
});
}
// route-transition.js's content hash (scripts/sibling-revs.js writes it), so a release gives it a new URL (4.138.0).
const BOOST_REV = { 'route-transition.js': '1bd3c4576b8c' };
function fx(mainEl) {
if (o.fx === false) return Promise.resolve(null);
if (root.OVRouteFx) return Promise.resolve(root.OVRouteFx);
if (!selfSrc) return Promise.resolve(null);
return new Promise((done) => {
const t = document.createElement('script');
t.src = selfSrc.replace(/boost\.js(\?.*)?$/, 'route-transition.js') + (BOOST_REV['route-transition.js'] ? `?v=${BOOST_REV['route-transition.js']}` : '');
t.onload = () => done(root.OVRouteFx || null); t.onerror = () => done(null);
document.head.appendChild(t);
});
}
async function go(href, { push = true, restoreY = null } = {}) {
if (stopped) return root.location.assign(href);
const target = new URL(href, root.location.href);
const mine = inflight = {};
const mainEl = document.querySelector(mainSel);
const Fx = await fx(mainEl);
const h = Fx ? Fx.start(mainEl, { prefix }) : null;
emit('before', { url: target.href });
let doc = null;
try {
const page = await fetchPage(target.href);
if (inflight !== mine) return; // a newer move won
doc = new root.DOMParser().parseFromString(page.html, 'text/html');
const next = doc.querySelector(mainSel);
// A different release, other external scripts, or no : this page cannot be swapped safely.
if (!next || marker(doc) !== here.marker || externalScripts(doc) !== here.scripts) throw new Error('not swappable');
if (h) h.status('applying styles');
await loadStylesFirst(doc);
if (inflight !== mine) return;
if (push) {
scrolls[key] = root.scrollY;
key += 1;
root.history.pushState({ ovBoost: key }, '', page.url + (target.hash && !new URL(page.url).hash ? target.hash : ''));
}
// The same element keeps its identity (the transition fades it, listeners on it survive): its
// attributes and children become the new page's.
const imported = document.importNode(next, true);
for (const at of [...mainEl.attributes]) if (!imported.hasAttribute(at.name)) mainEl.removeAttribute(at.name);
for (const at of [...imported.attributes]) mainEl.setAttribute(at.name, at.value);
mainEl.replaceChildren(...imported.childNodes);
try { const shownUrl = new URL(page.url, root.location.href); shown = shownUrl.pathname + shownUrl.search; } catch { shown = root.location.pathname + root.location.search; }
partFromY = null;
const fresh = mainEl;
updateHead(doc);
runInlineScripts(fresh);
const anchor = target.hash && document.getElementById(decodeURIComponent(target.hash.slice(1)));
if (restoreY != null) root.scrollTo(0, restoreY);
else if (anchor) anchor.scrollIntoView();
else root.scrollTo(0, 0);
if (!fresh.hasAttribute('tabindex')) fresh.setAttribute('tabindex', '-1');
try { fresh.focus({ preventScroll: true }); } catch { /* */ }
s.moves++; s.last = page.url;
if (h) h.done();
emit('load', { url: page.url });
} catch (err) {
if (inflight !== mine) return;
s.fallbacks++;
if (h) h.done();
root.location.assign(target.href); // any doubt: a normal page load
} finally {
// Let the parsed page go. Its own copy of a