openvibers/openvibe.shared Commits
1.9.0: one shipped/update-log system for every site (shipped.js latest/list/log views from data-ov-shipped markup, via the Network changelog proxy; the footer's 'shipped X ago' line and Updates link); fixes: the footer's empty signed-in bar (display:flex beat [hidden]) and 'Sign In' for signed-in people on sites with their own session (a stale or refused token now falls back to sessionUrl; logoutUrl ends the site session; {path} in login/logout URLs)
Changes
@@@ -4,6 +4,15 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 1.9.0 — 2026-09-24++++- **One "shipped" and update-log system for every site** (`shipped.js`). There are three views: `latest` (the "🚀 shipped 5m ago: …" pill), `list` (the recent changes plus the latest Patch notes link and an "All updates" link) and `log` (a full `/updates` page: changes grouped by day, "Load more" by cursor, this site or the whole network with a filter per site, and the Patch notes posts). Views mount from markup, `data-ov-shipped="latest|list|log"` with `data-service`, `data-limit`, `data-more` and `data-href`, when the script loads, or from code with `latest()`, `list()` (`mount()`), `log()` and `scan()`. The feed is now `https://openvibe.network/api/v1/changelog`, a cached proxy of OpenVibe.Blog's changelog that every site's CSP already allows. One request is shared per page; the "ago" text stays current; `serviceFor(host)` maps hostnames to registry ids.++- **Footer.** Every footer carries the "shipped X ago" line and an **Updates** link. The link goes to the site's own log (`updates: '/updates'`) or, by default, `openvibe.network/updates?site=<host>`. The footer loads `shipped.js` lazily from its own origin; turn it off with `shipped: false`. `detectService()` now knows the TLD sites (`openvibe.wiki` → `wiki`, `openre.stream` → `openre`).++- **Fix: an empty bordered bar in the footer.** The signed-in row's `display:flex` beat its `hidden` attribute on sites without a global `[hidden]` rule.++- **Fix: "Sign In" shown to signed-in people on sites with their own session** (openvibe.blog and the other publishing sites). When a stored token is stale or refused, the navbar now drops it and asks the site's `sessionUrl` instead of giving up. The navbar also takes a new `logoutUrl` (for example `/auth/logout?next={path}`), so Sign out ends the site's server-side session too. `loginUrl` and `logoutUrl` accept `{url}` (the full return URL) and `{path}` (its local path).++++Everything is additive.++## 1.8.0 — 2026-09-24`shipped.js` (browser file, also `openvibe-shared/shipped`): "Recently shipped" for any OpenVibe site.@@@ -24,7 +24,7 @@ were reconciled first.| Kind | Files ||---|---|| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `openvibe-sw.js` |--| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `auth-client`, `brand`, `builtin-themes`, `chrome-ssr`, `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`; `footer`, `shipped` ("Recently shipped" from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |++| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `auth-client`, `brand`, `builtin-themes`, `chrome-ssr`, `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`; `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` || Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) || Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |@@@ -28,6 +28,9 @@'use strict';const root = typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : {});++ // Where this file was served from: shipped.js is loaded from the same place (the site's own++ // /shared/ copy, or openvibe.network), so the site's CSP already allows it.++ const OWN_SRC = (function () { try { return document.currentScript && document.currentScript.src ? document.currentScript.src : null; } catch { return null; } })();// ── Shared chrome data (https://openvibe.network/api/chrome) ─────────────────────────────// Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30// minutes in localStorage and refreshed in the background, so pages paint from cache and the@@@ -110,6 +113,8 @@showNetwork: true,showAccount: true,sitemap: '/sitemap.xml',++ shipped: true, // the "shipped X ago" line (openvibe-shared/shipped.js)++ updates: null, // this site's update log; default: openvibe.network/updates?site=<host>};let _cfg = { ...DEFAULTS };@@@ -121,6 +126,9 @@if (h.endsWith('openvibe.live')) return 'live';if (h.endsWith('openvibe.network')) return 'network';if (h.endsWith('openvibe.games')) return 'games';++ if (/(^|\.)openre\.stream$/.test(h)) return 'openre';++ const tld = /(?:^|\.)openvibe\.([a-z]+)$/.exec(h);++ if (tld && tld[1] !== 'tools' && TLD_LABELS[tld[1]]) return tld[1];const sub = h.replace(/\.openvibe\.tools$/, '');return sub === h ? 'tools' : (sub || 'tools');}@@@ -178,6 +186,18 @@return `<a href="${esc(item.url)}"${rel}${onclick}${title}>${esc(item.name)}</a>`;}++ /** The "🚀 shipped X ago: …" line, filled by shipped.js from the network changelog; hidden until then. */++ function shippedLine(c) {++ if (c.shipped === false) return '';++ const host = typeof location !== 'undefined' ? location.hostname : '';++ const updates = c.updates || `${NETWORK_URL}/updates?site=${encodeURIComponent(host || c.service || '')}`;++ return `<a class="ovf-shipped" data-ov-shipped="latest" data-service="auto" href="${esc(updates)}" hidden></a>`;++ }++ function updatesHref(c) {++ const host = typeof location !== 'undefined' ? location.hostname : '';++ return c.updates || `${NETWORK_URL}/updates?site=${encodeURIComponent(host || c.service || '')}`;++ }++function buildHTML(cfg) {const c = { ...DEFAULTS, ...(cfg || {}) };const service = c.service || detectService();@@@ -198,8 +218,10 @@<nav class="ovf-compact-links" aria-label="Site links">${(c.links[0] ? c.links[0].items.slice(0, 3) : []).map(linkTag).join('')}<a href="${NETWORK_URL}" target="_blank" rel="noopener">Network</a>++ ${c.shipped === false ? '' : `<a href="${esc(updatesHref(c))}">Updates</a>`}${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}</nav>++ ${shippedLine(c)}<span class="ovf-compact-meta">Open source · ${year}</span></div>`;}@@@ -239,7 +261,9 @@<div class="ovf-account" id="ovf-account" hidden></div><div class="ovf-bar"><span class="ovf-copy">Open source & community driven · built in the open</span>++ ${shippedLine(c)}<span class="ovf-legal-inline">++ ${c.shipped === false ? '' : `<a href="${esc(updatesHref(c))}">Updates</a>`}${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}</span></div>@@@ -330,6 +354,8 @@padding-inline:clamp(0px, calc(84px - (100vw - 1240px) / 2), 84px)}.ovf-copy{color:var(--text-muted,#8b93ad);font-size:.78rem}.ovf-legal-inline,.ovf-compact-links{display:flex;flex-wrap:wrap;gap:14px}++.ovf [hidden],.ovf-account[hidden]{display:none!important}++.ovf .ovf-shipped.ov-shipped-latest{font-size:.76rem;padding:4px 12px;max-width:min(520px,100%);background:transparent}.ovf-legal-inline a,.ovf-compact-links a{color:var(--text-muted,#8b93ad);font-size:.78rem;text-decoration:none;transition:color .15s}.ovf-legal-inline a:hover,.ovf-compact-links a:hover{color:var(--accent,#60a5fa)}.ovf-compact-meta{color:var(--text-muted,#8b93ad);font-size:.74rem}@@@ -364,6 +390,23 @@_mounted.innerHTML = buildHTML(_cfg);try { if (typeof window !== 'undefined' && typeof window.ovMarkMount === 'function') window.ovMarkMount(_mounted); } catch { /* */ }fillAccount(_cfg);++ if (_cfg.shipped !== false) loadShipped();++ }++++ // shipped.js mounts every [data-ov-shipped] element when it loads; loaded once, lazily, after the footer.++ let _shippedLoading = false;++ function loadShipped() {++ if (typeof document === 'undefined') return;++ if (root.OpenVibeShipped && typeof root.OpenVibeShipped.scan === 'function') { try { root.OpenVibeShipped.scan(document); } catch { /* */ } return; }++ if (_shippedLoading || document.querySelector('script[data-ov-shipped-src]')) return;++ _shippedLoading = true;++ const sc = document.createElement('script');++ let src = `${NETWORK_URL}/shared/shipped.js`;++ try { if (OWN_SRC) src = new URL('shipped.js', OWN_SRC).href; } catch { /* */ }++ sc.src = src;++ sc.defer = true;++ sc.setAttribute('data-ov-shipped-src', '');++ (document.head || document.documentElement).appendChild(sc);}function init(cfg = {}) {@@@ -4,7 +4,10 @@// notification bell, account switcher, and theme-aware styling.// Usage: OpenVibeNavbar.init({ service, token, user, apiBase })// Optional: loginUrl (override the Sign In href), sessionUrl (same-origin--// endpoint returning { user } — used only when no ov_token exists),++// endpoint returning { user } — asked when no usable ov_token exists, including++// when a stored token turned out stale), logoutUrl (where Sign out goes, e.g. the++// site's '/auth/logout?next={path}', so a server-side session ends too; {url} is++// the full return URL, {path} its local path — loginUrl takes both too),// onLogin/onLogout callbacks. When no `user` is passed the navbar resolves// it itself: ov_token cookie → localStorage → token opt → sessionUrl, then// GET {apiBase}/api/auth/me with `Authorization: Bearer <token>`.@@@ -28,7 +31,7 @@let _config = {service: 'network', token: null, user: null, apiBase: 'https://openvibe.network',-- onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null,++ onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null, logoutUrl: null,brand: null, brandName: null, brandIcon: null, compact: 'auto',links: null, menu: null, recent: true,// history: { type: 'tool'|'stream'|'paste'|'page'|…, title, url, icon } — recorded for the@@@ -981,7 +984,7 @@/** Where "Sign In" goes on this host. */function resolveLoginHref(host, returnUrl) {-- if (_config.loginUrl) return String(_config.loginUrl).replace('{url}', encodeURIComponent(returnUrl || '/'));++ if (_config.loginUrl) return fillUrl(_config.loginUrl, returnUrl);if (onToolsDomain(host)) {// Every *.openvibe.tools host signs in through the gateway apex: it// holds the OAuth state cookie + redirect_uri (both apex-host-only)@@@ -1040,6 +1043,14 @@}}++ /** loginUrl/logoutUrl templates: {url} = the full return URL, {path} = its path + query (for sites that only accept local next=). */++ function fillUrl(template, returnUrl) {++ const full = returnUrl || (typeof location !== 'undefined' ? location.href : '/');++ let path = '/';++ try { const u = new URL(full, typeof location !== 'undefined' ? location.href : 'https://openvibe.network/'); path = u.pathname + u.search; } catch { /* */ }++ return String(template).replace('{url}', encodeURIComponent(full)).replace('{path}', encodeURIComponent(path));++ }++/** Same-origin session refresh — mounted by the tools gateway (POST /auth/refresh). */async function gatewayRefresh() {try {@@@ -1088,10 +1099,15 @@const user = n.user || (await fetchMe(n.token)).user;if (user) { persistToken(n.token); return { user, token: n.token }; }}++ // A stored token nobody accepts any more: drop it, so it cannot keep hiding a++ // good session (the site's own below, or the next sign-in).++ try { if (localStorage.getItem('ov_token') === token) localStorage.removeItem('ov_token'); } catch { /* */ }}-- return null;++ if (!_config.sessionUrl) return null;}-- // No token anywhere — ask the page's own session endpoint if it has one++ // No usable token — ask the page's own session endpoint if it has one (a site with a++ // server-side session, such as openvibe.blog, knows who is signed in even when this++ // browser's stored token is stale or was never shared with the page).if (_config.sessionUrl) {try {const res = await fetch(_config.sessionUrl, { credentials: 'include' });@@@ -1451,7 +1467,10 @@_config.token = null;try { root.OpenVibeSSO && root.OpenVibeSSO.preventSilent(); } catch { /* */ }try { localStorage.setItem('ov_sso_hint', 'guest'); } catch { /* */ }-- if (onToolsDomain()) {++ if (_config.logoutUrl) {++ // The site ends its own server-side session too, then sends us back.++ window.location.href = fillUrl(_config.logoutUrl, window.location.href);++ } else if (onToolsDomain()) {// The gateway also clears the Domain=.openvibe.tools cookie// and the httpOnly refresh cookie, then sends us back here.window.location.href = `https://${TOOLS_APEX}/auth/logout?next=${encodeURIComponent(window.location.href)}`;@@@ -1591,6 +1610,7 @@resolveSessionUser,clearAuthState,persistToken,++ fillUrl,},};@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "1.8.0",++ "version": "1.9.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "1.8.0",++ "version": "1.9.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "1.8.0",++ "version": "1.9.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","exports": {@@@ -1,61 +1,193 @@/*!-- * openvibe-shared/shipped — "Recently shipped" for any OpenVibe site: what was deployed, newest first,-- * from the network changelog OpenVibe.Blog keeps (GET https://openvibe.blog/api/v1/changelog), with a-- * link to the latest "Patch notes" post that gathers each batch of changes.++ * openvibe-shared/shipped — what shipped, on every OpenVibe site, from one feed: the network changelog++ * OpenVibe.Blog keeps (every deployed commit of every site) and serves through OpenVibe.Network at++ * GET https://openvibe.network/api/v1/changelog, which every site's CSP already allows.*-- * <div id="shipped"></div>++ * Three views, the same on every site:++ * latest the one-liner "🚀 shipped 5m ago: <newest change>", linking to the site's updates page++ * list the newest few changes, each linked to its commit, plus the latest "Patch notes" post++ * log the full updates page: changes grouped by day, "Load more", this site or the whole++ * network (with a filter per site), and the patch notes posts++ *++ * Markup only (the script mounts every element on the page, and the shared footer loads it):++ * <a data-ov-shipped="latest" data-service="wiki" href="/updates"></a>++ * <section data-ov-shipped="list" data-service="wiki" data-limit="8" data-more="/updates"></section>++ * <div data-ov-shipped="log" data-service="wiki"></div> data-service="" = the whole network* <script src="https://openvibe.network/shared/shipped.js" defer></script>-- * OpenVibeShipped.mount('#shipped', { service: 'live', limit: 5 }); // one site-- * OpenVibeShipped.mount('#shipped', { limit: 8 }); // the whole network*-- * Options: service (a registry id; omitted = every site), limit (1-20), title (heading text; '' for-- * none), api (the changelog URL, for tests), showSite (label each line with its site; default when no-- * service). Builds DOM nodes only (commit text is never parsed as HTML); a failed fetch leaves the-- * element empty and hidden, never an error on the page. Styles follow the theme's CSS variables.-- * A classic script: no modules, no dependencies.++ * Or from code: OpenVibeShipped.latest(el, opts), .list(el, opts) (alias mount), .log(el, opts),++ * .scan(root). Options: service (a registry id; 'auto' = from the hostname; omitted = every site),++ * limit, title ('' = no heading), href / more (links), api (the feed URL, for tests). DOM nodes only++ * (commit text is never parsed as HTML), https links only; a failed or empty feed hides the element,++ * never an error on the page. Styles follow the theme's CSS variables. A classic script, no modules.*/(function (root, factory) {const api = factory();if (typeof module === 'object' && module.exports) module.exports = api;-- if (typeof window !== 'undefined') window.OpenVibeShipped = api;++ if (typeof window !== 'undefined') {++ window.OpenVibeShipped = api;++ if (typeof document !== 'undefined') {++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => api.scan(document));++ else api.scan(document);++ }++ }}(typeof globalThis !== 'undefined' ? globalThis : this, function () {'use strict';-- const API = 'https://openvibe.blog/api/v1/changelog';-- const SITE_NAMES = { live: 'Live', network: 'Network', tools: 'Tools', media: 'Media', community: 'Community', chat: 'Chat', games: 'Games', blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', deals: 'Deals', coupons: 'Coupons', trade: 'Trade', codes: 'Codes', host: 'Host', ai: 'AI', search: 'Search', sources: 'Sources', events: 'Events', billing: 'Billing', tips: 'Tips', vip: 'VIP', openre: 'OpenRe', sites: 'Sites' };++ const API = 'https://openvibe.network/api/v1/changelog';++ const SITE_NAMES = { live: 'Live', network: 'Network', tools: 'Tools', media: 'Media', community: 'Community', chat: 'Chat', games: 'Games', blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', deals: 'Deals', coupons: 'Coupons', trade: 'Trade', codes: 'Codes', host: 'Host', ai: 'AI', search: 'Search', sources: 'Sources', events: 'Events', billing: 'Billing', tips: 'Tips', vip: 'VIP', openre: 'OpenRe', sites: 'Sites', realtime: 'Realtime' };const STYLE_ID = 'ov-shipped-style';-- const CSS = '.ov-shipped{font-size:.92rem}.ov-shipped h3{font-size:1rem;margin:0 0 .5rem;display:flex;gap:.4rem;align-items:center}'-- + '.ov-shipped ul{list-style:none;margin:0;padding:0;display:grid;gap:.35rem}.ov-shipped li{display:flex;gap:.5rem;align-items:baseline;min-width:0}'-- + '.ov-shipped .ov-shipped-site{flex:none;font-size:.72rem;font-weight:700;padding:.05rem .45rem;border-radius:999px;background:color-mix(in srgb,var(--accent,#3b82f6) 16%,transparent);color:var(--accent-light,var(--accent,#3b82f6))}'-- + '.ov-shipped a.ov-shipped-subject{color:var(--text,inherit);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}'-- + '.ov-shipped a.ov-shipped-subject:hover{text-decoration:underline}.ov-shipped time{flex:none;margin-left:auto;font-size:.75rem;opacity:.7}'-- + '.ov-shipped .ov-shipped-major{font-weight:600}.ov-shipped .ov-shipped-notes{display:inline-block;margin-top:.6rem;font-weight:600;color:var(--accent-light,var(--accent,#3b82f6))}';++ const TEXT = 'var(--text-primary,var(--text,inherit))';++ const MUTED = 'var(--text-secondary,var(--text-muted,#8b93ad))';++ const ACCENT = 'var(--accent-light,var(--accent,#3b82f6))';++ const BORDER = 'var(--border,rgba(127,127,127,.25))';++ const CSS = [++ `.ov-shipped-latest{display:inline-flex;align-items:center;gap:7px;padding:6px 14px;border-radius:999px;font-size:.8rem;text-decoration:none;color:${MUTED};background:color-mix(in srgb,var(--bg-secondary,#111827) 70%,transparent);border:1px solid color-mix(in srgb,var(--accent,#3b82f6) 22%,transparent);max-width:min(640px,100%);min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-sizing:border-box}`,++ `.ov-shipped-latest:hover{border-color:var(--accent,#3b82f6);color:${TEXT}}.ov-shipped-latest svg{flex:none;color:var(--accent,#3b82f6)}`,++ `.ov-shipped-latest b{color:${TEXT};font-weight:600;overflow:hidden;text-overflow:ellipsis;min-width:0}`,++ '[data-ov-shipped][hidden],.ov-shipped[hidden],.ov-shipped-latest[hidden]{display:none!important}',++ '.ov-shipped{font-size:.92rem;min-width:0}.ov-shipped h3{font-size:1rem;margin:0 0 .5rem;display:flex;gap:.4rem;align-items:center}',++ '.ov-shipped ul{list-style:none;margin:0;padding:0;display:grid;gap:.35rem}.ov-shipped li{display:flex;gap:.5rem;align-items:baseline;min-width:0}',++ `.ov-shipped-site{flex:none;font-size:.72rem;font-weight:700;padding:.05rem .45rem;border-radius:999px;background:color-mix(in srgb,var(--accent,#3b82f6) 16%,transparent);color:${ACCENT};text-decoration:none}`,++ `.ov-shipped a.ov-shipped-subject{color:${TEXT};text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}`,++ '.ov-shipped a.ov-shipped-subject:hover{text-decoration:underline}.ov-shipped li time{flex:none;margin-left:auto;font-size:.75rem;opacity:.7}',++ `.ov-shipped-major{font-weight:600}.ov-shipped-links{display:flex;flex-wrap:wrap;gap:.4rem 1rem;margin-top:.6rem}.ov-shipped-links a{font-weight:600;color:${ACCENT};text-decoration:none}.ov-shipped-links a:hover{text-decoration:underline}`,++ '.ov-shipped-log{display:flex;flex-direction:column;gap:22px;min-width:0}',++ '.ov-shipped-bar{display:flex;flex-wrap:wrap;gap:6px;align-items:center}',++ `.ov-shipped-chip{font:inherit;font-size:.8rem;padding:4px 12px;border-radius:999px;border:1px solid ${BORDER};background:transparent;color:${MUTED};cursor:pointer;text-decoration:none}`,++ `.ov-shipped-chip:hover{color:${TEXT};border-color:var(--accent,#3b82f6)}.ov-shipped-chip[aria-pressed="true"]{background:var(--accent,#3b82f6);border-color:var(--accent,#3b82f6);color:var(--on-accent,#fff)}`,++ `.ov-shipped-day h3{font-size:.95rem;font-weight:600;color:${MUTED};padding-bottom:8px;border-bottom:1px solid ${BORDER};margin:0 0 4px}`,++ '.ov-shipped-entries{display:flex;flex-direction:column;gap:2px}',++ '.ov-shipped-entry{display:flex;align-items:baseline;gap:10px;padding:8px 12px;border-radius:6px;min-width:0}',++ '.ov-shipped-entry:hover{background:var(--bg-tertiary,rgba(127,127,127,.08))}',++ `.ov-shipped-hash{font-family:'JetBrains Mono','Fira Code',ui-monospace,monospace;font-size:.8rem;color:${ACCENT};text-decoration:none;flex:none}.ov-shipped-hash:hover{text-decoration:underline}`,++ `.ov-shipped-text{flex:1;min-width:0;font-size:.9rem;color:${TEXT};overflow-wrap:anywhere}.ov-shipped-text.ov-shipped-major{font-weight:600}`,++ `.ov-shipped-meta{font-size:.75rem;color:${MUTED};flex:none;white-space:nowrap}`,++ `.ov-shipped-more{align-self:center;font:inherit;font-size:.85rem;padding:8px 18px;border-radius:999px;border:1px solid ${BORDER};background:transparent;color:${TEXT};cursor:pointer}.ov-shipped-more:hover{border-color:var(--accent,#3b82f6)}.ov-shipped-more[disabled]{opacity:.6;cursor:default}`,++ `.ov-shipped-posts{border:1px solid ${BORDER};border-radius:12px;padding:12px 16px}.ov-shipped-posts h3{font-size:.95rem;margin:0 0 8px}.ov-shipped-posts ul{list-style:none;margin:0;padding:0;display:grid;gap:6px}`,++ `.ov-shipped-posts a{color:${ACCENT};text-decoration:none;font-weight:600}.ov-shipped-posts a:hover{text-decoration:underline}.ov-shipped-posts small{color:${MUTED};margin-left:6px}`,++ `.ov-shipped-empty{color:${MUTED};text-align:center;padding:24px 0;margin:0}`,++ '@media (max-width:600px){.ov-shipped-entry{flex-wrap:wrap;gap:4px 8px}.ov-shipped-meta{width:100%}}',++ ].join('');++ const ROCKET = 'M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09zM12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2zM9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5';-- function ago(iso) {++ // ── helpers ─────────────────────────────────────────────────++ function ago(iso, now) {const t = Date.parse(iso);if (!t) return '';-- const s = Math.max(0, (Date.now() - t) / 1000);++ const s = Math.max(0, ((now || Date.now()) - t) / 1000);++ if (s < 45) return 'just now';if (s < 3600) return `${Math.max(1, Math.round(s / 60))}m ago`;if (s < 86400) return `${Math.round(s / 3600)}h ago`;-- return `${Math.round(s / 86400)}d ago`;++ if (s < 86400 * 45) return `${Math.round(s / 86400)}d ago`;++ return `${Math.round(s / (86400 * 30))}mo ago`;}function el(tag, attrs, text) {const n = document.createElement(tag);-- for (const k of Object.keys(attrs || {})) n.setAttribute(k, attrs[k]);++ for (const k of Object.keys(attrs || {})) if (attrs[k] != null) n.setAttribute(k, attrs[k]);if (text != null) n.textContent = text;return n;}function style() {-- if (document.getElementById(STYLE_ID)) return;++ if (typeof document === 'undefined' || document.getElementById(STYLE_ID)) return;const s = el('style', { id: STYLE_ID });s.textContent = CSS;(document.head || document.documentElement).appendChild(s);}function safeUrl(u) { return /^https:\/\//.test(String(u || '')) ? String(u) : null; }++ function localHref(u) { const s = String(u || ''); return /^\/(?!\/)/.test(s) || /^https:\/\//.test(s) ? s : null; }++ function clamp(n, lo, hi, d) { const v = parseInt(n, 10); return Number.isFinite(v) ? Math.min(Math.max(v, lo), hi) : d; }++ const siteName = (id) => SITE_NAMES[id] || id;++ function timeEl(iso) { const t = el('time', { datetime: iso, 'data-ov-ago': '' }, ago(iso)); t.title = new Date(iso).toLocaleString(); return t; }++ function rocket() {++ const ns = 'http://www.w3.org/2000/svg';++ const svg = document.createElementNS(ns, 'svg');++ for (const [k, v] of Object.entries({ width: '14', height: '14', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true' })) svg.setAttribute(k, v);++ const p = document.createElementNS(ns, 'path');++ p.setAttribute('d', ROCKET);++ svg.appendChild(p);++ return svg;++ }++++ /** The registry id for a hostname (or an id already): openvibe.wiki → wiki, pdf.openvibe.tools → tools. */++ function serviceFor(value) {++ const v = String(value || '').toLowerCase().trim();++ if (SITE_NAMES[v]) return v;++ const h = v.replace(/:\d+$/, '').replace(/^www\./, '');++ if (/(^|\.)openre\.stream$/.test(h)) return 'openre';++ if (/(^|\.)openvibe\.tools$/.test(h)) return 'tools';++ if (/(^|\.)openvibe\.network$/.test(h)) {++ const sub = h.replace(/\.?openvibe\.network$/, '');++ return SITE_NAMES[sub] && sub !== 'network' ? sub : 'network';++ }++ const m = /(?:^|\.)openvibe\.([a-z]+)$/.exec(h);++ return m && SITE_NAMES[m[1]] ? m[1] : null;++ }++ function resolveService(s) {++ if (s === 'auto') return typeof location !== 'undefined' ? serviceFor(location.hostname) : null;++ return s ? serviceFor(s) : null;++ }++++ // One request per feed URL for 30 s, shared by the footer line, the pill and the list on a page.++ const _feeds = new Map();++ function feed({ service = null, limit = 5, before = null, api = null } = {}) {++ const url = new URL(api || API);++ if (service) url.searchParams.set('service', String(service));++ url.searchParams.set('limit', String(clamp(limit, 1, 100, 5)));++ if (before) url.searchParams.set('before', String(before));++ const key = url.toString();++ const hit = _feeds.get(key);++ if (hit && Date.now() - hit.at < 30000) return hit.promise;++ const promise = fetch(key, { headers: { Accept: 'application/json' }, credentials: 'omit' })++ .then((r) => (r.ok ? r.json() : null)).catch(() => null);++ _feeds.set(key, { at: Date.now(), promise });++ return promise;++ }++++ // "5m ago" stays true while the page is open.++ let _ticker = null;++ function tick() {++ if (_ticker || typeof setInterval === 'undefined' || typeof document === 'undefined') return;++ _ticker = setInterval(() => {++ for (const t of document.querySelectorAll('time[data-ov-ago]')) t.textContent = ago(t.getAttribute('datetime'));++ }, 60000);++ if (_ticker.unref) _ticker.unref();++ }++++ // ── latest: the one-liner ───────────────────────────────────++ /** Render the newest entry into `node` (exported for tests). A node that is an <a> keeps its own href. */++ function renderLatest(node, data, opts) {++ const o = opts || {};++ const e = data && Array.isArray(data.entries) ? data.entries[0] : null;++ node.textContent = '';++ if (!e || !e.subject) { node.hidden = true; return node; }++ node.classList.add('ov-shipped-latest');++ const href = localHref(o.href) || (node.tagName === 'A' && node.getAttribute('href')) || null;++ if (node.tagName === 'A' && href) node.setAttribute('href', href);++ node.appendChild(rocket());++ node.appendChild(document.createTextNode(o.service ? ' shipped ' : ` ${siteName(e.service)} shipped `));++ node.appendChild(timeEl(e.deployed_at));++ node.appendChild(document.createTextNode(': '));++ node.appendChild(el('b', {}, e.subject));++ node.title = e.subject;++ node.hidden = false;++ if (node.style && node.style.display === 'none') node.style.display = '';++ tick();++ return node;++ }++ async function latest(target, opts) {++ const o = { ...(opts || {}) };++ const node = typeof target === 'string' ? document.querySelector(target) : target;++ if (!node) return null;++ o.service = resolveService(o.service);++ style();++ return renderLatest(node, await feed({ service: o.service, limit: 1, api: o.api }), o);++ }++ // ── list: the newest few ────────────────────────────────────/** Render a changelog answer into `node` (exported for tests). */function render(node, data, opts) {const o = opts || {};-- const entries = (data && Array.isArray(data.entries) ? data.entries : []).slice(0, Math.min(Math.max(o.limit || 5, 1), 20));++ const entries = (data && Array.isArray(data.entries) ? data.entries : []).slice(0, clamp(o.limit, 1, 20, 5));node.textContent = '';if (!entries.length) { node.hidden = true; return node; }node.classList.add('ov-shipped');@@@ -64,35 +196,176 @@const showSite = o.showSite != null ? o.showSite : !o.service;for (const e of entries) {const li = el('li');-- if (showSite) li.appendChild(el('span', { class: 'ov-shipped-site' }, SITE_NAMES[e.service] || e.service));++ if (showSite) li.appendChild(el('span', { class: 'ov-shipped-site' }, siteName(e.service)));const href = safeUrl(e.url);const a = el(href ? 'a' : 'span', href ? { href, class: `ov-shipped-subject${e.major ? ' ov-shipped-major' : ''}`, rel: 'noopener', target: '_blank', title: e.subject } : { class: 'ov-shipped-subject' }, e.subject);li.appendChild(a);-- if (e.deployed_at) li.appendChild(el('time', { datetime: e.deployed_at }, ago(e.deployed_at)));++ if (e.deployed_at) li.appendChild(timeEl(e.deployed_at));ul.appendChild(li);}node.appendChild(ul);++ const links = el('div', { class: 'ov-shipped-links' });++ const more = localHref(o.more);++ if (more) links.appendChild(el('a', { href: more }, 'All updates →'));const post = data.latest_post && safeUrl(data.latest_post.url) ? data.latest_post : null;-- if (post) node.appendChild(el('a', { class: 'ov-shipped-notes', href: post.url }, `Read the patch notes: ${post.title || 'latest'} →`));++ if (post) links.appendChild(el('a', { class: 'ov-shipped-notes', href: post.url }, `Patch notes: ${String(post.title || 'latest').replace(/^Patch notes:\s*/, '')} →`));++ if (links.childNodes.length) node.appendChild(links);node.hidden = false;++ tick();return node;}++ async function list(target, opts) {++ const o = { ...(opts || {}) };++ const node = typeof target === 'string' ? document.querySelector(target) : target;++ if (!node) return null;++ o.service = resolveService(o.service);++ style();++ return render(node, await feed({ service: o.service, limit: clamp(o.limit, 1, 20, 5), api: o.api }), o);++ }-- /** Fetch and render into a selector or element. Resolves the element (hidden when there is nothing). */-- async function mount(target, opts) {-- const o = opts || {};++ // ── log: the updates page ───────────────────────────────────++ function dayOf(iso) { return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }); }++ function entryRow(e, showSite) {++ const row = el('div', { class: 'ov-shipped-entry' });++ const href = safeUrl(e.url);++ row.appendChild(el(href ? 'a' : 'span', href ? { class: 'ov-shipped-hash', href, target: '_blank', rel: 'noopener', title: 'The change on GitHub' } : { class: 'ov-shipped-hash' }, e.short || String(e.sha || '').slice(0, 7)));++ if (showSite) row.appendChild(el('span', { class: 'ov-shipped-site' }, siteName(e.service)));++ row.appendChild(el('span', { class: `ov-shipped-text${e.major ? ' ov-shipped-major' : ''}` }, e.subject));++ const meta = el('span', { class: 'ov-shipped-meta' });++ const time = new Date(e.deployed_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });++ meta.textContent = `${e.author ? `${e.author} · ` : ''}${time}`;++ row.appendChild(meta);++ return row;++ }++ /** Append entries under day headings, continuing the last day already shown (exported for tests). */++ function appendDays(box, entries, showSite) {++ let day = box.lastElementChild && box.lastElementChild.classList.contains('ov-shipped-day') ? box.lastElementChild : null;++ for (const e of entries) {++ const d = dayOf(e.deployed_at);++ if (!day || day.getAttribute('data-day') !== d) {++ day = el('section', { class: 'ov-shipped-day', 'data-day': d });++ day.appendChild(el('h3', {}, d));++ day.appendChild(el('div', { class: 'ov-shipped-entries' }));++ box.appendChild(day);++ }++ day.lastElementChild.appendChild(entryRow(e, showSite));++ }++ }++ async function log(target, opts) {++ const o = { ...(opts || {}) };const node = typeof target === 'string' ? document.querySelector(target) : target;if (!node) return null;style();-- const url = new URL(o.api || API);-- if (o.service) url.searchParams.set('service', String(o.service));-- url.searchParams.set('limit', String(Math.min(Math.max(o.limit || 5, 1), 20)));-- try {-- const res = await fetch(url.toString(), { headers: { Accept: 'application/json' }, credentials: 'omit' });-- if (!res.ok) { node.hidden = true; return node; }-- return render(node, await res.json(), o);-- } catch (_) { node.hidden = true; return node; }++ const own = resolveService(o.service);++ const pageSize = clamp(o.limit, 10, 100, 50);++ const params = typeof location !== 'undefined' ? new URLSearchParams(location.search) : new URLSearchParams();++ // ?site= picks the site on the whole-network page (and "all" on a site's own page).++ let current = own;++ const asked = params.get('site');++ if (asked === 'all') current = null;++ else if (asked && serviceFor(asked)) current = serviceFor(asked);++++ node.textContent = '';++ node.classList.add('ov-shipped-log');++ const bar = el('div', { class: 'ov-shipped-bar', role: 'group', 'aria-label': 'Which site' });++ const posts = el('aside', { class: 'ov-shipped-posts', hidden: '' });++ const days = el('div', { class: 'ov-shipped-days' });++ days.style.cssText = 'display:flex;flex-direction:column;gap:24px;min-width:0';++ const more = el('button', { type: 'button', class: 'ov-shipped-more', hidden: '' }, 'Load more');++ node.append(bar, posts, days, more);++ let next = null;++ let seq = 0;++++ function setUrl(svc) {++ if (o.syncUrl === false || typeof history === 'undefined' || !history.replaceState) return;++ const u = new URL(location.href);++ if (own ? svc === own : !svc) u.searchParams.delete('site'); else u.searchParams.set('site', svc || 'all');++ history.replaceState(history.state, '', u.pathname + u.search + u.hash);++ }++ function chip(label, svc) {++ const b = el('button', { type: 'button', class: 'ov-shipped-chip', 'aria-pressed': String((svc || null) === (current || null)) }, label);++ b.addEventListener('click', () => { current = svc || null; setUrl(current); load(true); });++ return b;++ }++ function drawBar(sites) {++ bar.textContent = '';++ if (own) { bar.append(chip(siteName(own), own), chip('All of OpenVibe', null)); return; }++ bar.append(chip('Everything', null));++ for (const s of (sites || []).slice(0, 30)) bar.appendChild(chip(siteName(s.service), s.service));++ }++ function drawPosts(list) {++ posts.textContent = '';++ const good = (list || []).filter((p) => safeUrl(p.url)).slice(0, 5);++ if (!good.length || o.posts === false) { posts.hidden = true; return; }++ posts.appendChild(el('h3', {}, 'Patch notes'));++ const ul = el('ul');++ for (const p of good) {++ const li = el('li');++ li.appendChild(el('a', { href: p.url }, p.title || 'Patch notes'));++ if (p.published_at) li.appendChild(el('small', {}, `${new Date(p.published_at).toLocaleDateString()}${p.entries ? ` · ${p.entries} changes` : ''}`));++ ul.appendChild(li);++ }++ posts.appendChild(ul);++ posts.hidden = false;++ }++ let sitesSeen = null;++ async function load(reset) {++ const mine = ++seq;++ if (reset) {++ next = null;++ days.textContent = '';++ days.appendChild(el('p', { class: 'ov-shipped-empty' }, 'Loading…'));++ for (const b of bar.querySelectorAll('.ov-shipped-chip')) b.setAttribute('aria-pressed', 'false');++ }++ more.disabled = true;++ const data = await feed({ service: current, limit: pageSize, before: reset ? null : next, api: o.api });++ if (mine !== seq) return;++ if (reset) {++ days.textContent = '';++ if (data && data.sites) sitesSeen = data.sites;++ if (!own && !sitesSeen) {++ const all = await feed({ service: null, limit: 1, api: o.api });++ sitesSeen = all && all.sites;++ }++ drawBar(sitesSeen);++ for (const b of bar.querySelectorAll('.ov-shipped-chip')) if (b.textContent === (current ? siteName(current) : (own ? 'All of OpenVibe' : 'Everything'))) b.setAttribute('aria-pressed', 'true');++ drawPosts(data && data.posts);++ }++ if (!data) {++ if (reset) days.appendChild(el('p', { class: 'ov-shipped-empty' }, 'The update history could not be loaded just now.'));++ more.hidden = true;++ return;++ }++ const entries = Array.isArray(data.entries) ? data.entries : [];++ if (reset && !entries.length) days.appendChild(el('p', { class: 'ov-shipped-empty' }, 'Nothing has shipped here yet.'));++ appendDays(days, entries, !current);++ next = data.next || null;++ more.hidden = !next;++ more.disabled = false;++ tick();++ }++ more.addEventListener('click', () => load(false));++ await load(true);++ return node;++ }++++ // ── markup ──────────────────────────────────────────────────++ const VIEWS = { latest, list, log };++ /** Mount every [data-ov-shipped] element under `root` once. */++ function scan(rootEl) {++ const r = rootEl || (typeof document !== 'undefined' ? document : null);++ if (!r || !r.querySelectorAll) return [];++ const out = [];++ for (const n of r.querySelectorAll('[data-ov-shipped]')) {++ if (n.hasAttribute('data-ov-shipped-mounted')) continue;++ const view = VIEWS[n.getAttribute('data-ov-shipped')];++ if (!view) continue;++ n.setAttribute('data-ov-shipped-mounted', '');++ const d = n.dataset || {};++ out.push(view(n, { service: d.service || null, limit: d.limit, title: d.title, href: d.href, more: d.more, api: d.api, posts: d.posts === 'off' ? false : undefined }).catch(() => null));++ }++ return out;}-- return { mount, render, API };++ return { latest, list, mount: list, log, scan, render, renderLatest, appendDays, serviceFor, ago, API, SITE_NAMES };}));@@@ -282,6 +282,45 @@ const USER = { id: 7, username: 'vibe', display_name: 'Vibe', avatar_url: null,assert.strictEqual(user.username, 'vibe');}++ // ── 12b. A stale stored token must not hide the site's own session (openvibe.blog) ─++ {++ const { navbar, storage } = loadNavbar({++ hostname: 'openvibe.blog',++ storage: { ov_token: 'stale-jwt' },++ fetch: async (url) => {++ if (url === '/auth/me') return jsonResponse(200, { user: USER });++ if (String(url).endsWith('/api/auth/me')) return jsonResponse(401, { error: 'expired' });++ if (String(url).endsWith('/api/auth/refresh')) return jsonResponse(401, { error: 'too old' });++ return jsonResponse(404, {});++ },++ });++ navbar.init({ service: 'blog', apiBase: 'https://openvibe.network', sessionUrl: '/auth/me', logoutUrl: '/auth/logout?next={url}' });++ const user = await navbar.refreshAuth();++ assert.ok(user && user.username === 'vibe', 'the site session answers when the stored token is stale');++ assert.strictEqual(storage.getItem('ov_token'), null, 'the stale token is dropped');++ }++++ // ── 12b2. {path} and {url} in loginUrl/logoutUrl ─++ {++ const { navbar } = loadNavbar({ hostname: 'openvibe.blog', href: 'https://openvibe.blog/@openvibe/post?x=1' });++ const f = navbar._auth.fillUrl;++ assert.strictEqual(f('/auth/logout?next={path}', 'https://openvibe.blog/@openvibe/post?x=1'), '/auth/logout?next=%2F%40openvibe%2Fpost%3Fx%3D1');++ assert.strictEqual(f('/auth/login?next={url}', 'https://openvibe.blog/a'), '/auth/login?next=https%3A%2F%2Fopenvibe.blog%2Fa');++ navbar.init({ service: 'blog', apiBase: 'https://openvibe.network', loginUrl: '/auth/login?next={path}' });++ assert.strictEqual(navbar._auth.resolveLoginHref('openvibe.blog', 'https://openvibe.blog/w?y=2'), '/auth/login?next=%2Fw%3Fy%3D2');++ }++++ // ── 12c. Without a sessionUrl a stale token still means signed out ─++ {++ const { navbar } = loadNavbar({++ hostname: 'openvibe.network',++ storage: { ov_token: 'stale-jwt' },++ fetch: async () => jsonResponse(401, {}),++ });++ navbar.init({ service: 'network', apiBase: 'https://openvibe.network' });++ assert.strictEqual(await navbar.refreshAuth(), null);++ }++// ── 13. Explicit user passed → no auth traffic at all ────{const { navbar, calls } = loadNavbar({@@@ -20,7 +20,7 @@ console.log('shared modules: all checks passed');// The browser bundles must at least evaluate in a bare global (this caught a missing `root` once).{const vm = require('vm'); const fs = require('fs'); const path = require('path');-- for (const f of ['footer.js', 'navbar.js', 'island.js', 'ui.js', 'ov-icons.js', 'history.js', 'sso-client.js', 'panels.js']) {++ for (const f of ['footer.js', 'navbar.js', 'island.js', 'ui.js', 'ov-icons.js', 'history.js', 'sso-client.js', 'panels.js', 'shipped.js']) {const ctx = vm.createContext({ console });ctx.globalThis = ctx; ctx.window = ctx; ctx.self = ctx;ctx.localStorage = { getItem: () => null, setItem() {} }; ctx.location = { hostname: 'openvibe.tools', href: 'https://openvibe.tools/' };@@@ -30,5 +30,17 @@ console.log('shared modules: all checks passed');vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'footer.js'), 'utf8'), ctx);const html = ctx.OpenVibeFooter.buildHTML({ service: 'media', variant: 'full' });assert.ok(html.includes('Legal') && html.includes('ovf-brand'), 'footer renders without chrome data');++ // Every footer carries the "shipped X ago" line and an Updates link (the shared update system).++ assert.ok(html.includes('data-ov-shipped="latest"'), 'the shipped line is in the footer');++ assert.ok(html.includes('https://openvibe.network/updates?site=openvibe.media'), 'Updates defaults to the network log for this host');++ const own = ctx.OpenVibeFooter.buildHTML({ service: 'media', variant: 'compact', updates: '/updates' });++ assert.ok(own.includes('href="/updates"') && own.includes('data-ov-shipped="latest"'), 'a site with its own log links to it, compact too');++ const off = ctx.OpenVibeFooter.buildHTML({ service: 'media', variant: 'full', shipped: false });++ assert.ok(!off.includes('data-ov-shipped') && !off.includes('>Updates<'), 'shipped: false leaves it out');++ for (const [host, id] of [['openvibe.wiki', 'wiki'], ['openvibe.live', 'live'], ['pdf.openvibe.tools', 'pdf'], ['openvibe.tools', 'tools'], ['openre.stream', 'openre']]) {++ const c2 = vm.createContext({ console }); c2.globalThis = c2; c2.window = c2; c2.location = { hostname: host, href: `https://${host}/` }; c2.localStorage = { getItem: () => null, setItem() {} };++ vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'footer.js'), 'utf8'), c2);++ assert.strictEqual(c2.OpenVibeFooter.detectService(), id, `detectService on ${host}`);++ }console.log('browser bundles evaluate: ok');}@@@ -3,8 +3,9 @@const assert = require('assert');const { parseHTML } = require('linkedom');--const { window, document } = parseHTML('<!doctype html><html><head></head><body><div id="s"></div><div id="n"></div></body></html>');++const { window, document } = parseHTML('<!doctype html><html><head></head><body><div id="s"></div><div id="n"></div><a id="pill" href="/updates"></a><div id="log"></div></body></html>');globalThis.window = window; globalThis.document = document;++globalThis.fetch = async () => ({ ok: false, json: async () => ({}) }); // the load-time scan never reaches the networkconst shipped = require('../shipped');const data = {@@@ -40,10 +41,84 @@ const data = {let asked = null;globalThis.fetch = async (url) => { asked = url; return { ok: true, json: async () => data }; };await shipped.mount('#s', { service: 'live', limit: 3 });-- assert.strictEqual(asked, 'https://openvibe.blog/api/v1/changelog?service=live&limit=3');++ assert.strictEqual(asked, 'https://openvibe.network/api/v1/changelog?service=live&limit=3', 'the Network proxy every site\'s CSP already allows');globalThis.fetch = async () => { throw new Error('offline'); };const failed = await shipped.mount('#n', {});assert.strictEqual(failed.hidden, true);assert.ok(document.getElementById('ov-shipped-style'), 'styles injected once');++++ // latest: the one-liner keeps the element's own link and never renders markup.++ const pill = document.getElementById('pill');++ shipped.renderLatest(pill, data, { service: 'live' });++ assert.strictEqual(pill.hidden, false);++ assert.strictEqual(pill.getAttribute('href'), '/updates');++ assert.ok(/shipped/.test(pill.textContent) && pill.querySelector('b').textContent.startsWith('Fix <img'));++ assert.strictEqual(pill.querySelectorAll('img').length, 0);++ assert.ok(pill.querySelector('time[data-ov-ago]'));++ shipped.renderLatest(pill, { entries: [] }, {});++ assert.strictEqual(pill.hidden, true, 'nothing shipped: the pill hides');++ const net = document.createElement('a');++ shipped.renderLatest(net, data, {});++ assert.ok(net.textContent.includes('Live shipped'), 'network-wide: the pill names the site');++++ // list: an "All updates" link only for a local or https path.++ shipped.render(one, data, { service: 'live', more: '/updates' });++ assert.strictEqual(one.querySelector('.ov-shipped-links a').getAttribute('href'), '/updates');++ shipped.render(one, data, { service: 'live', more: 'javascript:alert(1)' });++ assert.ok(![...one.querySelectorAll('.ov-shipped-links a')].some((a) => /javascript/.test(a.getAttribute('href'))));++++ // log: day groups continue across pages; the site label only network-wide.++ const box = document.createElement('div');++ const at = (h) => new Date(Date.UTC(2026, 8, 24, h)).toISOString();++ shipped.appendDays(box, [{ service: 'live', sha: 'a'.repeat(40), short: 'aaaaaaa', subject: 'one', author: 'OpenVibers', deployed_at: at(12), url: 'https://github.com/x/y/commit/a' }], true);++ shipped.appendDays(box, [{ service: 'tools', sha: 'b'.repeat(40), subject: 'two', deployed_at: at(11), url: 'https://github.com/x/y/commit/b' }], true);++ assert.strictEqual(box.querySelectorAll('.ov-shipped-day').length, 1, 'the same day continues');++ assert.strictEqual(box.querySelectorAll('.ov-shipped-entry').length, 2);++ assert.strictEqual(box.querySelectorAll('.ov-shipped-site').length, 2);++ assert.ok(box.querySelector('.ov-shipped-meta').textContent.startsWith('OpenVibers · '));++ assert.strictEqual(box.querySelectorAll('.ov-shipped-hash')[1].textContent, 'bbbbbbb', 'short sha from the full sha');++++ // serviceFor: hosts to registry ids.++ const sf = shipped.serviceFor;++ assert.strictEqual(sf('openvibe.wiki'), 'wiki');++ assert.strictEqual(sf('pdf.openvibe.tools'), 'tools');++ assert.strictEqual(sf('my.openvibe.network'), 'network');++ assert.strictEqual(sf('events.openvibe.network'), 'events');++ assert.strictEqual(sf('ingest.openre.stream'), 'openre');++ assert.strictEqual(sf('www.openvibe.live'), 'live');++ assert.strictEqual(sf('example.com'), null);++ assert.strictEqual(sf('live'), 'live');++++ // log(): pages with the cursor, and the "Load more" button disappears on the last page.++ const calls = [];++ const page1 = { entries: [{ service: 'wiki', sha: 'c'.repeat(40), subject: 'three', deployed_at: at(10) }], next: 'CUR', posts: [{ title: 'Patch notes: x', url: 'https://openvibe.blog/@openvibe/x', published_at: at(9), entries: 40 }], sites: [{ service: 'wiki' }] };++ const page2 = { entries: [{ service: 'wiki', sha: 'd'.repeat(40), subject: 'four', deployed_at: at(8) }], next: null, posts: [] };++ globalThis.fetch = async (url) => { calls.push(url); return { ok: true, json: async () => (url.includes('before=CUR') ? page2 : page1) }; };++ const logEl = document.getElementById('log');++ await shipped.log(logEl, { service: 'wiki', api: 'https://example.test/api/v1/changelog' });++ assert.ok(logEl.textContent.includes('three'));++ assert.strictEqual(logEl.querySelector('.ov-shipped-posts a').getAttribute('href'), 'https://openvibe.blog/@openvibe/x');++ const more = logEl.querySelector('.ov-shipped-more');++ assert.strictEqual(more.hidden, false);++ assert.deepStrictEqual([...logEl.querySelectorAll('.ov-shipped-chip')].map((b) => b.textContent), ['Wiki', 'All of OpenVibe']);++ more.dispatchEvent(new window.Event('click'));++ await new Promise((r) => setTimeout(r, 20));++ assert.ok(calls.some((u) => u.includes('before=CUR') && u.includes('service=wiki')));++ assert.ok(logEl.textContent.includes('four'));++ assert.strictEqual(more.hidden, true, 'no next cursor: no button');++++ // scan(): markup mounts once.++ globalThis.fetch = async () => ({ ok: true, json: async () => data });++ const autoEl = document.createElement('section');++ autoEl.id = 'auto';++ for (const [k, v] of Object.entries({ 'data-ov-shipped': 'list', 'data-service': 'wiki', 'data-limit': '2', 'data-more': '/updates', 'data-api': 'https://example.test/feed' })) autoEl.setAttribute(k, v);++ document.body.appendChild(autoEl);++ const first = shipped.scan(document);++ assert.strictEqual(first.length, 1);++ await Promise.all(first);++ const auto = document.getElementById('auto');++ assert.ok(auto.hasAttribute('data-ov-shipped-mounted'));++ assert.strictEqual(auto.querySelectorAll('li').length, 2);++ assert.strictEqual(shipped.scan(document).length, 0, 'already mounted: not again');console.log('shipped: all checks passed');})().catch((e) => { console.error(e); process.exit(1); });
@@ -4,6 +4,15 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,15 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 1.9.0 — 2026-09-24
+
+- **One "shipped" and update-log system for every site** (`shipped.js`). There are three views: `latest` (the "🚀 shipped 5m ago: …" pill), `list` (the recent changes plus the latest Patch notes link and an "All updates" link) and `log` (a full `/updates` page: changes grouped by day, "Load more" by cursor, this site or the whole network with a filter per site, and the Patch notes posts). Views mount from markup, `data-ov-shipped="latest|list|log"` with `data-service`, `data-limit`, `data-more` and `data-href`, when the script loads, or from code with `latest()`, `list()` (`mount()`), `log()` and `scan()`. The feed is now `https://openvibe.network/api/v1/changelog`, a cached proxy of OpenVibe.Blog's changelog that every site's CSP already allows. One request is shared per page; the "ago" text stays current; `serviceFor(host)` maps hostnames to registry ids.
+- **Footer.** Every footer carries the "shipped X ago" line and an **Updates** link. The link goes to the site's own log (`updates: '/updates'`) or, by default, `openvibe.network/updates?site=<host>`. The footer loads `shipped.js` lazily from its own origin; turn it off with `shipped: false`. `detectService()` now knows the TLD sites (`openvibe.wiki` → `wiki`, `openre.stream` → `openre`).
+- **Fix: an empty bordered bar in the footer.** The signed-in row's `display:flex` beat its `hidden` attribute on sites without a global `[hidden]` rule.
+- **Fix: "Sign In" shown to signed-in people on sites with their own session** (openvibe.blog and the other publishing sites). When a stored token is stale or refused, the navbar now drops it and asks the site's `sessionUrl` instead of giving up. The navbar also takes a new `logoutUrl` (for example `/auth/logout?next={path}`), so Sign out ends the site's server-side session too. `loginUrl` and `logoutUrl` accept `{url}` (the full return URL) and `{path}` (its local path).
+
+Everything is additive.
+
## 1.8.0 — 2026-09-24
## 1.8.0 — 2026-09-24
`shipped.js` (browser file, also `openvibe-shared/shipped`): "Recently shipped" for any OpenVibe site.
`shipped.js` (browser file, also `openvibe-shared/shipped`): "Recently shipped" for any OpenVibe site.
@@ -24,7 +24,7 @@ were reconciled first.
@@ -24,7 +24,7 @@ were reconciled first.
| Kind | Files |
| Kind | Files |
|---|---|
|---|---|
| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `openvibe-sw.js` |
| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `openvibe-sw.js` |
-| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `auth-client`, `brand`, `builtin-themes`, `chrome-ssr`, `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`; `footer`, `shipped` ("Recently shipped" from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
+| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `auth-client`, `brand`, `builtin-themes`, `chrome-ssr`, `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`; `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |
@@ -28,6 +28,9 @@
@@ -28,6 +28,9 @@
'use strict';
'use strict';
const root = typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : {});
const root = typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : {});
+ // Where this file was served from: shipped.js is loaded from the same place (the site's own
+ // /shared/ copy, or openvibe.network), so the site's CSP already allows it.
+ const OWN_SRC = (function () { try { return document.currentScript && document.currentScript.src ? document.currentScript.src : null; } catch { return null; } })();
// ── Shared chrome data (https://openvibe.network/api/chrome) ─────────────────────────────
// ── Shared chrome data (https://openvibe.network/api/chrome) ─────────────────────────────
// Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30
// Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30
// minutes in localStorage and refreshed in the background, so pages paint from cache and the
// minutes in localStorage and refreshed in the background, so pages paint from cache and the
@@ -110,6 +113,8 @@
@@ -110,6 +113,8 @@
showNetwork: true,
showNetwork: true,
showAccount: true,
showAccount: true,
sitemap: '/sitemap.xml',
sitemap: '/sitemap.xml',
+ shipped: true, // the "shipped X ago" line (openvibe-shared/shipped.js)
+ updates: null, // this site's update log; default: openvibe.network/updates?site=<host>
};
};
let _cfg = { ...DEFAULTS };
let _cfg = { ...DEFAULTS };
@@ -121,6 +126,9 @@
@@ -121,6 +126,9 @@
if (h.endsWith('openvibe.live')) return 'live';
if (h.endsWith('openvibe.live')) return 'live';
if (h.endsWith('openvibe.network')) return 'network';
if (h.endsWith('openvibe.network')) return 'network';
if (h.endsWith('openvibe.games')) return 'games';
if (h.endsWith('openvibe.games')) return 'games';
+ if (/(^|\.)openre\.stream$/.test(h)) return 'openre';
+ const tld = /(?:^|\.)openvibe\.([a-z]+)$/.exec(h);
+ if (tld && tld[1] !== 'tools' && TLD_LABELS[tld[1]]) return tld[1];
const sub = h.replace(/\.openvibe\.tools$/, '');
const sub = h.replace(/\.openvibe\.tools$/, '');
return sub === h ? 'tools' : (sub || 'tools');
return sub === h ? 'tools' : (sub || 'tools');
}
}
@@ -178,6 +186,18 @@
@@ -178,6 +186,18 @@
return `<a href="${esc(item.url)}"${rel}${onclick}${title}>${esc(item.name)}</a>`;
return `<a href="${esc(item.url)}"${rel}${onclick}${title}>${esc(item.name)}</a>`;
}
}
+ /** The "🚀 shipped X ago: …" line, filled by shipped.js from the network changelog; hidden until then. */
+ function shippedLine(c) {
+ if (c.shipped === false) return '';
+ const host = typeof location !== 'undefined' ? location.hostname : '';
+ const updates = c.updates || `${NETWORK_URL}/updates?site=${encodeURIComponent(host || c.service || '')}`;
+ return `<a class="ovf-shipped" data-ov-shipped="latest" data-service="auto" href="${esc(updates)}" hidden></a>`;
+ }
+ function updatesHref(c) {
+ const host = typeof location !== 'undefined' ? location.hostname : '';
+ return c.updates || `${NETWORK_URL}/updates?site=${encodeURIComponent(host || c.service || '')}`;
+ }
+
function buildHTML(cfg) {
function buildHTML(cfg) {
const c = { ...DEFAULTS, ...(cfg || {}) };
const c = { ...DEFAULTS, ...(cfg || {}) };
const service = c.service || detectService();
const service = c.service || detectService();
@@ -198,8 +218,10 @@
@@ -198,8 +218,10 @@
<nav class="ovf-compact-links" aria-label="Site links">
<nav class="ovf-compact-links" aria-label="Site links">
${(c.links[0] ? c.links[0].items.slice(0, 3) : []).map(linkTag).join('')}
${(c.links[0] ? c.links[0].items.slice(0, 3) : []).map(linkTag).join('')}
<a href="${NETWORK_URL}" target="_blank" rel="noopener">Network</a>
<a href="${NETWORK_URL}" target="_blank" rel="noopener">Network</a>
+ ${c.shipped === false ? '' : `<a href="${esc(updatesHref(c))}">Updates</a>`}
${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}
${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}
</nav>
</nav>
+ ${shippedLine(c)}
<span class="ovf-compact-meta">Open source · ${year}</span>
<span class="ovf-compact-meta">Open source · ${year}</span>
</div>`;
</div>`;
}
}
@@ -239,7 +261,9 @@
@@ -239,7 +261,9 @@
<div class="ovf-account" id="ovf-account" hidden></div>
<div class="ovf-account" id="ovf-account" hidden></div>
<div class="ovf-bar">
<div class="ovf-bar">
<span class="ovf-copy">Open source & community driven · built in the open</span>
<span class="ovf-copy">Open source & community driven · built in the open</span>
+ ${shippedLine(c)}
<span class="ovf-legal-inline">
<span class="ovf-legal-inline">
+ ${c.shipped === false ? '' : `<a href="${esc(updatesHref(c))}">Updates</a>`}
${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}
${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}
</span>
</span>
</div>
</div>
@@ -330,6 +354,8 @@
@@ -330,6 +354,8 @@
padding-inline:clamp(0px, calc(84px - (100vw - 1240px) / 2), 84px)}
padding-inline:clamp(0px, calc(84px - (100vw - 1240px) / 2), 84px)}
.ovf-copy{color:var(--text-muted,#8b93ad);font-size:.78rem}
.ovf-copy{color:var(--text-muted,#8b93ad);font-size:.78rem}
.ovf-legal-inline,.ovf-compact-links{display:flex;flex-wrap:wrap;gap:14px}
.ovf-legal-inline,.ovf-compact-links{display:flex;flex-wrap:wrap;gap:14px}
+.ovf [hidden],.ovf-account[hidden]{display:none!important}
+.ovf .ovf-shipped.ov-shipped-latest{font-size:.76rem;padding:4px 12px;max-width:min(520px,100%);background:transparent}
.ovf-legal-inline a,.ovf-compact-links a{color:var(--text-muted,#8b93ad);font-size:.78rem;text-decoration:none;transition:color .15s}
.ovf-legal-inline a,.ovf-compact-links a{color:var(--text-muted,#8b93ad);font-size:.78rem;text-decoration:none;transition:color .15s}
.ovf-legal-inline a:hover,.ovf-compact-links a:hover{color:var(--accent,#60a5fa)}
.ovf-legal-inline a:hover,.ovf-compact-links a:hover{color:var(--accent,#60a5fa)}
.ovf-compact-meta{color:var(--text-muted,#8b93ad);font-size:.74rem}
.ovf-compact-meta{color:var(--text-muted,#8b93ad);font-size:.74rem}
@@ -364,6 +390,23 @@
@@ -364,6 +390,23 @@
_mounted.innerHTML = buildHTML(_cfg);
_mounted.innerHTML = buildHTML(_cfg);
try { if (typeof window !== 'undefined' && typeof window.ovMarkMount === 'function') window.ovMarkMount(_mounted); } catch { /* */ }
try { if (typeof window !== 'undefined' && typeof window.ovMarkMount === 'function') window.ovMarkMount(_mounted); } catch { /* */ }
fillAccount(_cfg);
fillAccount(_cfg);
+ if (_cfg.shipped !== false) loadShipped();
+ }
+
+ // shipped.js mounts every [data-ov-shipped] element when it loads; loaded once, lazily, after the footer.
+ let _shippedLoading = false;
+ function loadShipped() {
+ if (typeof document === 'undefined') return;
+ if (root.OpenVibeShipped && typeof root.OpenVibeShipped.scan === 'function') { try { root.OpenVibeShipped.scan(document); } catch { /* */ } return; }
+ if (_shippedLoading || document.querySelector('script[data-ov-shipped-src]')) return;
+ _shippedLoading = true;
+ const sc = document.createElement('script');
+ let src = `${NETWORK_URL}/shared/shipped.js`;
+ try { if (OWN_SRC) src = new URL('shipped.js', OWN_SRC).href; } catch { /* */ }
+ sc.src = src;
+ sc.defer = true;
+ sc.setAttribute('data-ov-shipped-src', '');
+ (document.head || document.documentElement).appendChild(sc);
}
}
function init(cfg = {}) {
function init(cfg = {}) {
@@ -4,7 +4,10 @@
@@ -4,7 +4,10 @@
// notification bell, account switcher, and theme-aware styling.
// notification bell, account switcher, and theme-aware styling.
// Usage: OpenVibeNavbar.init({ service, token, user, apiBase })
// Usage: OpenVibeNavbar.init({ service, token, user, apiBase })
// Optional: loginUrl (override the Sign In href), sessionUrl (same-origin
// Optional: loginUrl (override the Sign In href), sessionUrl (same-origin
-// endpoint returning { user } — used only when no ov_token exists),
+// endpoint returning { user } — asked when no usable ov_token exists, including
+// when a stored token turned out stale), logoutUrl (where Sign out goes, e.g. the
+// site's '/auth/logout?next={path}', so a server-side session ends too; {url} is
+// the full return URL, {path} its local path — loginUrl takes both too),
// onLogin/onLogout callbacks. When no `user` is passed the navbar resolves
// onLogin/onLogout callbacks. When no `user` is passed the navbar resolves
// it itself: ov_token cookie → localStorage → token opt → sessionUrl, then
// it itself: ov_token cookie → localStorage → token opt → sessionUrl, then
// GET {apiBase}/api/auth/me with `Authorization: Bearer <token>`.
// GET {apiBase}/api/auth/me with `Authorization: Bearer <token>`.
@@ -28,7 +31,7 @@
@@ -28,7 +31,7 @@
let _config = {
let _config = {
service: 'network', token: null, user: null, apiBase: 'https://openvibe.network',
service: 'network', token: null, user: null, apiBase: 'https://openvibe.network',
- onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null,
+ onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null, logoutUrl: null,
brand: null, brandName: null, brandIcon: null, compact: 'auto',
brand: null, brandName: null, brandIcon: null, compact: 'auto',
links: null, menu: null, recent: true,
links: null, menu: null, recent: true,
// history: { type: 'tool'|'stream'|'paste'|'page'|…, title, url, icon } — recorded for the
// history: { type: 'tool'|'stream'|'paste'|'page'|…, title, url, icon } — recorded for the
@@ -981,7 +984,7 @@
@@ -981,7 +984,7 @@
/** Where "Sign In" goes on this host. */
/** Where "Sign In" goes on this host. */
function resolveLoginHref(host, returnUrl) {
function resolveLoginHref(host, returnUrl) {
- if (_config.loginUrl) return String(_config.loginUrl).replace('{url}', encodeURIComponent(returnUrl || '/'));
+ if (_config.loginUrl) return fillUrl(_config.loginUrl, returnUrl);
if (onToolsDomain(host)) {
if (onToolsDomain(host)) {
// Every *.openvibe.tools host signs in through the gateway apex: it
// Every *.openvibe.tools host signs in through the gateway apex: it
// holds the OAuth state cookie + redirect_uri (both apex-host-only)
// holds the OAuth state cookie + redirect_uri (both apex-host-only)
@@ -1040,6 +1043,14 @@
@@ -1040,6 +1043,14 @@
}
}
}
}
+ /** loginUrl/logoutUrl templates: {url} = the full return URL, {path} = its path + query (for sites that only accept local next=). */
+ function fillUrl(template, returnUrl) {
+ const full = returnUrl || (typeof location !== 'undefined' ? location.href : '/');
+ let path = '/';
+ try { const u = new URL(full, typeof location !== 'undefined' ? location.href : 'https://openvibe.network/'); path = u.pathname + u.search; } catch { /* */ }
+ return String(template).replace('{url}', encodeURIComponent(full)).replace('{path}', encodeURIComponent(path));
+ }
+
/** Same-origin session refresh — mounted by the tools gateway (POST /auth/refresh). */
/** Same-origin session refresh — mounted by the tools gateway (POST /auth/refresh). */
async function gatewayRefresh() {
async function gatewayRefresh() {
try {
try {
@@ -1088,10 +1099,15 @@
@@ -1088,10 +1099,15 @@
const user = n.user || (await fetchMe(n.token)).user;
const user = n.user || (await fetchMe(n.token)).user;
if (user) { persistToken(n.token); return { user, token: n.token }; }
if (user) { persistToken(n.token); return { user, token: n.token }; }
}
}
+ // A stored token nobody accepts any more: drop it, so it cannot keep hiding a
+ // good session (the site's own below, or the next sign-in).
+ try { if (localStorage.getItem('ov_token') === token) localStorage.removeItem('ov_token'); } catch { /* */ }
}
}
- return null;
+ if (!_config.sessionUrl) return null;
}
}
- // No token anywhere — ask the page's own session endpoint if it has one
+ // No usable token — ask the page's own session endpoint if it has one (a site with a
+ // server-side session, such as openvibe.blog, knows who is signed in even when this
+ // browser's stored token is stale or was never shared with the page).
if (_config.sessionUrl) {
if (_config.sessionUrl) {
try {
try {
const res = await fetch(_config.sessionUrl, { credentials: 'include' });
const res = await fetch(_config.sessionUrl, { credentials: 'include' });
@@ -1451,7 +1467,10 @@
@@ -1451,7 +1467,10 @@
_config.token = null;
_config.token = null;
try { root.OpenVibeSSO && root.OpenVibeSSO.preventSilent(); } catch { /* */ }
try { root.OpenVibeSSO && root.OpenVibeSSO.preventSilent(); } catch { /* */ }
try { localStorage.setItem('ov_sso_hint', 'guest'); } catch { /* */ }
try { localStorage.setItem('ov_sso_hint', 'guest'); } catch { /* */ }
- if (onToolsDomain()) {
+ if (_config.logoutUrl) {
+ // The site ends its own server-side session too, then sends us back.
+ window.location.href = fillUrl(_config.logoutUrl, window.location.href);
+ } else if (onToolsDomain()) {
// The gateway also clears the Domain=.openvibe.tools cookie
// The gateway also clears the Domain=.openvibe.tools cookie
// and the httpOnly refresh cookie, then sends us back here.
// and the httpOnly refresh cookie, then sends us back here.
window.location.href = `https://${TOOLS_APEX}/auth/logout?next=${encodeURIComponent(window.location.href)}`;
window.location.href = `https://${TOOLS_APEX}/auth/logout?next=${encodeURIComponent(window.location.href)}`;
@@ -1591,6 +1610,7 @@
@@ -1591,6 +1610,7 @@
resolveSessionUser,
resolveSessionUser,
clearAuthState,
clearAuthState,
persistToken,
persistToken,
+ fillUrl,
},
},
};
};
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.8.0",
+ "version": "1.9.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.8.0",
+ "version": "1.9.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.8.0",
+ "version": "1.9.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"exports": {
"exports": {
@@ -1,61 +1,193 @@
@@ -1,61 +1,193 @@
/*!
/*!
- * openvibe-shared/shipped — "Recently shipped" for any OpenVibe site: what was deployed, newest first,
- * from the network changelog OpenVibe.Blog keeps (GET https://openvibe.blog/api/v1/changelog), with a
- * link to the latest "Patch notes" post that gathers each batch of changes.
+ * openvibe-shared/shipped — what shipped, on every OpenVibe site, from one feed: the network changelog
+ * OpenVibe.Blog keeps (every deployed commit of every site) and serves through OpenVibe.Network at
+ * GET https://openvibe.network/api/v1/changelog, which every site's CSP already allows.
*
*
- * <div id="shipped"></div>
+ * Three views, the same on every site:
+ * latest the one-liner "🚀 shipped 5m ago: <newest change>", linking to the site's updates page
+ * list the newest few changes, each linked to its commit, plus the latest "Patch notes" post
+ * log the full updates page: changes grouped by day, "Load more", this site or the whole
+ * network (with a filter per site), and the patch notes posts
+ *
+ * Markup only (the script mounts every element on the page, and the shared footer loads it):
+ * <a data-ov-shipped="latest" data-service="wiki" href="/updates"></a>
+ * <section data-ov-shipped="list" data-service="wiki" data-limit="8" data-more="/updates"></section>
+ * <div data-ov-shipped="log" data-service="wiki"></div> data-service="" = the whole network
* <script src="https://openvibe.network/shared/shipped.js" defer></script>
* <script src="https://openvibe.network/shared/shipped.js" defer></script>
- * OpenVibeShipped.mount('#shipped', { service: 'live', limit: 5 }); // one site
- * OpenVibeShipped.mount('#shipped', { limit: 8 }); // the whole network
*
*
- * Options: service (a registry id; omitted = every site), limit (1-20), title (heading text; '' for
- * none), api (the changelog URL, for tests), showSite (label each line with its site; default when no
- * service). Builds DOM nodes only (commit text is never parsed as HTML); a failed fetch leaves the
- * element empty and hidden, never an error on the page. Styles follow the theme's CSS variables.
- * A classic script: no modules, no dependencies.
+ * Or from code: OpenVibeShipped.latest(el, opts), .list(el, opts) (alias mount), .log(el, opts),
+ * .scan(root). Options: service (a registry id; 'auto' = from the hostname; omitted = every site),
+ * limit, title ('' = no heading), href / more (links), api (the feed URL, for tests). DOM nodes only
+ * (commit text is never parsed as HTML), https links only; a failed or empty feed hides the element,
+ * never an error on the page. Styles follow the theme's CSS variables. A classic script, no modules.
*/
*/
(function (root, factory) {
(function (root, factory) {
const api = factory();
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
if (typeof module === 'object' && module.exports) module.exports = api;
- if (typeof window !== 'undefined') window.OpenVibeShipped = api;
+ if (typeof window !== 'undefined') {
+ window.OpenVibeShipped = api;
+ if (typeof document !== 'undefined') {
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => api.scan(document));
+ else api.scan(document);
+ }
+ }
}(typeof globalThis !== 'undefined' ? globalThis : this, function () {
}(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
'use strict';
- const API = 'https://openvibe.blog/api/v1/changelog';
- const SITE_NAMES = { live: 'Live', network: 'Network', tools: 'Tools', media: 'Media', community: 'Community', chat: 'Chat', games: 'Games', blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', deals: 'Deals', coupons: 'Coupons', trade: 'Trade', codes: 'Codes', host: 'Host', ai: 'AI', search: 'Search', sources: 'Sources', events: 'Events', billing: 'Billing', tips: 'Tips', vip: 'VIP', openre: 'OpenRe', sites: 'Sites' };
+ const API = 'https://openvibe.network/api/v1/changelog';
+ const SITE_NAMES = { live: 'Live', network: 'Network', tools: 'Tools', media: 'Media', community: 'Community', chat: 'Chat', games: 'Games', blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', deals: 'Deals', coupons: 'Coupons', trade: 'Trade', codes: 'Codes', host: 'Host', ai: 'AI', search: 'Search', sources: 'Sources', events: 'Events', billing: 'Billing', tips: 'Tips', vip: 'VIP', openre: 'OpenRe', sites: 'Sites', realtime: 'Realtime' };
const STYLE_ID = 'ov-shipped-style';
const STYLE_ID = 'ov-shipped-style';
- const CSS = '.ov-shipped{font-size:.92rem}.ov-shipped h3{font-size:1rem;margin:0 0 .5rem;display:flex;gap:.4rem;align-items:center}'
- + '.ov-shipped ul{list-style:none;margin:0;padding:0;display:grid;gap:.35rem}.ov-shipped li{display:flex;gap:.5rem;align-items:baseline;min-width:0}'
- + '.ov-shipped .ov-shipped-site{flex:none;font-size:.72rem;font-weight:700;padding:.05rem .45rem;border-radius:999px;background:color-mix(in srgb,var(--accent,#3b82f6) 16%,transparent);color:var(--accent-light,var(--accent,#3b82f6))}'
- + '.ov-shipped a.ov-shipped-subject{color:var(--text,inherit);text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}'
- + '.ov-shipped a.ov-shipped-subject:hover{text-decoration:underline}.ov-shipped time{flex:none;margin-left:auto;font-size:.75rem;opacity:.7}'
- + '.ov-shipped .ov-shipped-major{font-weight:600}.ov-shipped .ov-shipped-notes{display:inline-block;margin-top:.6rem;font-weight:600;color:var(--accent-light,var(--accent,#3b82f6))}';
+ const TEXT = 'var(--text-primary,var(--text,inherit))';
+ const MUTED = 'var(--text-secondary,var(--text-muted,#8b93ad))';
+ const ACCENT = 'var(--accent-light,var(--accent,#3b82f6))';
+ const BORDER = 'var(--border,rgba(127,127,127,.25))';
+ const CSS = [
+ `.ov-shipped-latest{display:inline-flex;align-items:center;gap:7px;padding:6px 14px;border-radius:999px;font-size:.8rem;text-decoration:none;color:${MUTED};background:color-mix(in srgb,var(--bg-secondary,#111827) 70%,transparent);border:1px solid color-mix(in srgb,var(--accent,#3b82f6) 22%,transparent);max-width:min(640px,100%);min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-sizing:border-box}`,
+ `.ov-shipped-latest:hover{border-color:var(--accent,#3b82f6);color:${TEXT}}.ov-shipped-latest svg{flex:none;color:var(--accent,#3b82f6)}`,
+ `.ov-shipped-latest b{color:${TEXT};font-weight:600;overflow:hidden;text-overflow:ellipsis;min-width:0}`,
+ '[data-ov-shipped][hidden],.ov-shipped[hidden],.ov-shipped-latest[hidden]{display:none!important}',
+ '.ov-shipped{font-size:.92rem;min-width:0}.ov-shipped h3{font-size:1rem;margin:0 0 .5rem;display:flex;gap:.4rem;align-items:center}',
+ '.ov-shipped ul{list-style:none;margin:0;padding:0;display:grid;gap:.35rem}.ov-shipped li{display:flex;gap:.5rem;align-items:baseline;min-width:0}',
+ `.ov-shipped-site{flex:none;font-size:.72rem;font-weight:700;padding:.05rem .45rem;border-radius:999px;background:color-mix(in srgb,var(--accent,#3b82f6) 16%,transparent);color:${ACCENT};text-decoration:none}`,
+ `.ov-shipped a.ov-shipped-subject{color:${TEXT};text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}`,
+ '.ov-shipped a.ov-shipped-subject:hover{text-decoration:underline}.ov-shipped li time{flex:none;margin-left:auto;font-size:.75rem;opacity:.7}',
+ `.ov-shipped-major{font-weight:600}.ov-shipped-links{display:flex;flex-wrap:wrap;gap:.4rem 1rem;margin-top:.6rem}.ov-shipped-links a{font-weight:600;color:${ACCENT};text-decoration:none}.ov-shipped-links a:hover{text-decoration:underline}`,
+ '.ov-shipped-log{display:flex;flex-direction:column;gap:22px;min-width:0}',
+ '.ov-shipped-bar{display:flex;flex-wrap:wrap;gap:6px;align-items:center}',
+ `.ov-shipped-chip{font:inherit;font-size:.8rem;padding:4px 12px;border-radius:999px;border:1px solid ${BORDER};background:transparent;color:${MUTED};cursor:pointer;text-decoration:none}`,
+ `.ov-shipped-chip:hover{color:${TEXT};border-color:var(--accent,#3b82f6)}.ov-shipped-chip[aria-pressed="true"]{background:var(--accent,#3b82f6);border-color:var(--accent,#3b82f6);color:var(--on-accent,#fff)}`,
+ `.ov-shipped-day h3{font-size:.95rem;font-weight:600;color:${MUTED};padding-bottom:8px;border-bottom:1px solid ${BORDER};margin:0 0 4px}`,
+ '.ov-shipped-entries{display:flex;flex-direction:column;gap:2px}',
+ '.ov-shipped-entry{display:flex;align-items:baseline;gap:10px;padding:8px 12px;border-radius:6px;min-width:0}',
+ '.ov-shipped-entry:hover{background:var(--bg-tertiary,rgba(127,127,127,.08))}',
+ `.ov-shipped-hash{font-family:'JetBrains Mono','Fira Code',ui-monospace,monospace;font-size:.8rem;color:${ACCENT};text-decoration:none;flex:none}.ov-shipped-hash:hover{text-decoration:underline}`,
+ `.ov-shipped-text{flex:1;min-width:0;font-size:.9rem;color:${TEXT};overflow-wrap:anywhere}.ov-shipped-text.ov-shipped-major{font-weight:600}`,
+ `.ov-shipped-meta{font-size:.75rem;color:${MUTED};flex:none;white-space:nowrap}`,
+ `.ov-shipped-more{align-self:center;font:inherit;font-size:.85rem;padding:8px 18px;border-radius:999px;border:1px solid ${BORDER};background:transparent;color:${TEXT};cursor:pointer}.ov-shipped-more:hover{border-color:var(--accent,#3b82f6)}.ov-shipped-more[disabled]{opacity:.6;cursor:default}`,
+ `.ov-shipped-posts{border:1px solid ${BORDER};border-radius:12px;padding:12px 16px}.ov-shipped-posts h3{font-size:.95rem;margin:0 0 8px}.ov-shipped-posts ul{list-style:none;margin:0;padding:0;display:grid;gap:6px}`,
+ `.ov-shipped-posts a{color:${ACCENT};text-decoration:none;font-weight:600}.ov-shipped-posts a:hover{text-decoration:underline}.ov-shipped-posts small{color:${MUTED};margin-left:6px}`,
+ `.ov-shipped-empty{color:${MUTED};text-align:center;padding:24px 0;margin:0}`,
+ '@media (max-width:600px){.ov-shipped-entry{flex-wrap:wrap;gap:4px 8px}.ov-shipped-meta{width:100%}}',
+ ].join('');
+ const ROCKET = 'M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09zM12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2zM9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5';
- function ago(iso) {
+ // ── helpers ─────────────────────────────────────────────────
+ function ago(iso, now) {
const t = Date.parse(iso);
const t = Date.parse(iso);
if (!t) return '';
if (!t) return '';
- const s = Math.max(0, (Date.now() - t) / 1000);
+ const s = Math.max(0, ((now || Date.now()) - t) / 1000);
+ if (s < 45) return 'just now';
if (s < 3600) return `${Math.max(1, Math.round(s / 60))}m ago`;
if (s < 3600) return `${Math.max(1, Math.round(s / 60))}m ago`;
if (s < 86400) return `${Math.round(s / 3600)}h ago`;
if (s < 86400) return `${Math.round(s / 3600)}h ago`;
- return `${Math.round(s / 86400)}d ago`;
+ if (s < 86400 * 45) return `${Math.round(s / 86400)}d ago`;
+ return `${Math.round(s / (86400 * 30))}mo ago`;
}
}
function el(tag, attrs, text) {
function el(tag, attrs, text) {
const n = document.createElement(tag);
const n = document.createElement(tag);
- for (const k of Object.keys(attrs || {})) n.setAttribute(k, attrs[k]);
+ for (const k of Object.keys(attrs || {})) if (attrs[k] != null) n.setAttribute(k, attrs[k]);
if (text != null) n.textContent = text;
if (text != null) n.textContent = text;
return n;
return n;
}
}
function style() {
function style() {
- if (document.getElementById(STYLE_ID)) return;
+ if (typeof document === 'undefined' || document.getElementById(STYLE_ID)) return;
const s = el('style', { id: STYLE_ID });
const s = el('style', { id: STYLE_ID });
s.textContent = CSS;
s.textContent = CSS;
(document.head || document.documentElement).appendChild(s);
(document.head || document.documentElement).appendChild(s);
}
}
function safeUrl(u) { return /^https:\/\//.test(String(u || '')) ? String(u) : null; }
function safeUrl(u) { return /^https:\/\//.test(String(u || '')) ? String(u) : null; }
+ function localHref(u) { const s = String(u || ''); return /^\/(?!\/)/.test(s) || /^https:\/\//.test(s) ? s : null; }
+ function clamp(n, lo, hi, d) { const v = parseInt(n, 10); return Number.isFinite(v) ? Math.min(Math.max(v, lo), hi) : d; }
+ const siteName = (id) => SITE_NAMES[id] || id;
+ function timeEl(iso) { const t = el('time', { datetime: iso, 'data-ov-ago': '' }, ago(iso)); t.title = new Date(iso).toLocaleString(); return t; }
+ function rocket() {
+ const ns = 'http://www.w3.org/2000/svg';
+ const svg = document.createElementNS(ns, 'svg');
+ for (const [k, v] of Object.entries({ width: '14', height: '14', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true' })) svg.setAttribute(k, v);
+ const p = document.createElementNS(ns, 'path');
+ p.setAttribute('d', ROCKET);
+ svg.appendChild(p);
+ return svg;
+ }
+
+ /** The registry id for a hostname (or an id already): openvibe.wiki → wiki, pdf.openvibe.tools → tools. */
+ function serviceFor(value) {
+ const v = String(value || '').toLowerCase().trim();
+ if (SITE_NAMES[v]) return v;
+ const h = v.replace(/:\d+$/, '').replace(/^www\./, '');
+ if (/(^|\.)openre\.stream$/.test(h)) return 'openre';
+ if (/(^|\.)openvibe\.tools$/.test(h)) return 'tools';
+ if (/(^|\.)openvibe\.network$/.test(h)) {
+ const sub = h.replace(/\.?openvibe\.network$/, '');
+ return SITE_NAMES[sub] && sub !== 'network' ? sub : 'network';
+ }
+ const m = /(?:^|\.)openvibe\.([a-z]+)$/.exec(h);
+ return m && SITE_NAMES[m[1]] ? m[1] : null;
+ }
+ function resolveService(s) {
+ if (s === 'auto') return typeof location !== 'undefined' ? serviceFor(location.hostname) : null;
+ return s ? serviceFor(s) : null;
+ }
+
+ // One request per feed URL for 30 s, shared by the footer line, the pill and the list on a page.
+ const _feeds = new Map();
+ function feed({ service = null, limit = 5, before = null, api = null } = {}) {
+ const url = new URL(api || API);
+ if (service) url.searchParams.set('service', String(service));
+ url.searchParams.set('limit', String(clamp(limit, 1, 100, 5)));
+ if (before) url.searchParams.set('before', String(before));
+ const key = url.toString();
+ const hit = _feeds.get(key);
+ if (hit && Date.now() - hit.at < 30000) return hit.promise;
+ const promise = fetch(key, { headers: { Accept: 'application/json' }, credentials: 'omit' })
+ .then((r) => (r.ok ? r.json() : null)).catch(() => null);
+ _feeds.set(key, { at: Date.now(), promise });
+ return promise;
+ }
+
+ // "5m ago" stays true while the page is open.
+ let _ticker = null;
+ function tick() {
+ if (_ticker || typeof setInterval === 'undefined' || typeof document === 'undefined') return;
+ _ticker = setInterval(() => {
+ for (const t of document.querySelectorAll('time[data-ov-ago]')) t.textContent = ago(t.getAttribute('datetime'));
+ }, 60000);
+ if (_ticker.unref) _ticker.unref();
+ }
+
+ // ── latest: the one-liner ───────────────────────────────────
+ /** Render the newest entry into `node` (exported for tests). A node that is an <a> keeps its own href. */
+ function renderLatest(node, data, opts) {
+ const o = opts || {};
+ const e = data && Array.isArray(data.entries) ? data.entries[0] : null;
+ node.textContent = '';
+ if (!e || !e.subject) { node.hidden = true; return node; }
+ node.classList.add('ov-shipped-latest');
+ const href = localHref(o.href) || (node.tagName === 'A' && node.getAttribute('href')) || null;
+ if (node.tagName === 'A' && href) node.setAttribute('href', href);
+ node.appendChild(rocket());
+ node.appendChild(document.createTextNode(o.service ? ' shipped ' : ` ${siteName(e.service)} shipped `));
+ node.appendChild(timeEl(e.deployed_at));
+ node.appendChild(document.createTextNode(': '));
+ node.appendChild(el('b', {}, e.subject));
+ node.title = e.subject;
+ node.hidden = false;
+ if (node.style && node.style.display === 'none') node.style.display = '';
+ tick();
+ return node;
+ }
+ async function latest(target, opts) {
+ const o = { ...(opts || {}) };
+ const node = typeof target === 'string' ? document.querySelector(target) : target;
+ if (!node) return null;
+ o.service = resolveService(o.service);
+ style();
+ return renderLatest(node, await feed({ service: o.service, limit: 1, api: o.api }), o);
+ }
+ // ── list: the newest few ────────────────────────────────────
/** Render a changelog answer into `node` (exported for tests). */
/** Render a changelog answer into `node` (exported for tests). */
function render(node, data, opts) {
function render(node, data, opts) {
const o = opts || {};
const o = opts || {};
- const entries = (data && Array.isArray(data.entries) ? data.entries : []).slice(0, Math.min(Math.max(o.limit || 5, 1), 20));
+ const entries = (data && Array.isArray(data.entries) ? data.entries : []).slice(0, clamp(o.limit, 1, 20, 5));
node.textContent = '';
node.textContent = '';
if (!entries.length) { node.hidden = true; return node; }
if (!entries.length) { node.hidden = true; return node; }
node.classList.add('ov-shipped');
node.classList.add('ov-shipped');
@@ -64,35 +196,176 @@
@@ -64,35 +196,176 @@
const showSite = o.showSite != null ? o.showSite : !o.service;
const showSite = o.showSite != null ? o.showSite : !o.service;
for (const e of entries) {
for (const e of entries) {
const li = el('li');
const li = el('li');
- if (showSite) li.appendChild(el('span', { class: 'ov-shipped-site' }, SITE_NAMES[e.service] || e.service));
+ if (showSite) li.appendChild(el('span', { class: 'ov-shipped-site' }, siteName(e.service)));
const href = safeUrl(e.url);
const href = safeUrl(e.url);
const a = el(href ? 'a' : 'span', href ? { href, class: `ov-shipped-subject${e.major ? ' ov-shipped-major' : ''}`, rel: 'noopener', target: '_blank', title: e.subject } : { class: 'ov-shipped-subject' }, e.subject);
const a = el(href ? 'a' : 'span', href ? { href, class: `ov-shipped-subject${e.major ? ' ov-shipped-major' : ''}`, rel: 'noopener', target: '_blank', title: e.subject } : { class: 'ov-shipped-subject' }, e.subject);
li.appendChild(a);
li.appendChild(a);
- if (e.deployed_at) li.appendChild(el('time', { datetime: e.deployed_at }, ago(e.deployed_at)));
+ if (e.deployed_at) li.appendChild(timeEl(e.deployed_at));
ul.appendChild(li);
ul.appendChild(li);
}
}
node.appendChild(ul);
node.appendChild(ul);
+ const links = el('div', { class: 'ov-shipped-links' });
+ const more = localHref(o.more);
+ if (more) links.appendChild(el('a', { href: more }, 'All updates →'));
const post = data.latest_post && safeUrl(data.latest_post.url) ? data.latest_post : null;
const post = data.latest_post && safeUrl(data.latest_post.url) ? data.latest_post : null;
- if (post) node.appendChild(el('a', { class: 'ov-shipped-notes', href: post.url }, `Read the patch notes: ${post.title || 'latest'} →`));
+ if (post) links.appendChild(el('a', { class: 'ov-shipped-notes', href: post.url }, `Patch notes: ${String(post.title || 'latest').replace(/^Patch notes:\s*/, '')} →`));
+ if (links.childNodes.length) node.appendChild(links);
node.hidden = false;
node.hidden = false;
+ tick();
return node;
return node;
}
}
+ async function list(target, opts) {
+ const o = { ...(opts || {}) };
+ const node = typeof target === 'string' ? document.querySelector(target) : target;
+ if (!node) return null;
+ o.service = resolveService(o.service);
+ style();
+ return render(node, await feed({ service: o.service, limit: clamp(o.limit, 1, 20, 5), api: o.api }), o);
+ }
- /** Fetch and render into a selector or element. Resolves the element (hidden when there is nothing). */
- async function mount(target, opts) {
- const o = opts || {};
+ // ── log: the updates page ───────────────────────────────────
+ function dayOf(iso) { return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }); }
+ function entryRow(e, showSite) {
+ const row = el('div', { class: 'ov-shipped-entry' });
+ const href = safeUrl(e.url);
+ row.appendChild(el(href ? 'a' : 'span', href ? { class: 'ov-shipped-hash', href, target: '_blank', rel: 'noopener', title: 'The change on GitHub' } : { class: 'ov-shipped-hash' }, e.short || String(e.sha || '').slice(0, 7)));
+ if (showSite) row.appendChild(el('span', { class: 'ov-shipped-site' }, siteName(e.service)));
+ row.appendChild(el('span', { class: `ov-shipped-text${e.major ? ' ov-shipped-major' : ''}` }, e.subject));
+ const meta = el('span', { class: 'ov-shipped-meta' });
+ const time = new Date(e.deployed_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
+ meta.textContent = `${e.author ? `${e.author} · ` : ''}${time}`;
+ row.appendChild(meta);
+ return row;
+ }
+ /** Append entries under day headings, continuing the last day already shown (exported for tests). */
+ function appendDays(box, entries, showSite) {
+ let day = box.lastElementChild && box.lastElementChild.classList.contains('ov-shipped-day') ? box.lastElementChild : null;
+ for (const e of entries) {
+ const d = dayOf(e.deployed_at);
+ if (!day || day.getAttribute('data-day') !== d) {
+ day = el('section', { class: 'ov-shipped-day', 'data-day': d });
+ day.appendChild(el('h3', {}, d));
+ day.appendChild(el('div', { class: 'ov-shipped-entries' }));
+ box.appendChild(day);
+ }
+ day.lastElementChild.appendChild(entryRow(e, showSite));
+ }
+ }
+ async function log(target, opts) {
+ const o = { ...(opts || {}) };
const node = typeof target === 'string' ? document.querySelector(target) : target;
const node = typeof target === 'string' ? document.querySelector(target) : target;
if (!node) return null;
if (!node) return null;
style();
style();
- const url = new URL(o.api || API);
- if (o.service) url.searchParams.set('service', String(o.service));
- url.searchParams.set('limit', String(Math.min(Math.max(o.limit || 5, 1), 20)));
- try {
- const res = await fetch(url.toString(), { headers: { Accept: 'application/json' }, credentials: 'omit' });
- if (!res.ok) { node.hidden = true; return node; }
- return render(node, await res.json(), o);
- } catch (_) { node.hidden = true; return node; }
+ const own = resolveService(o.service);
+ const pageSize = clamp(o.limit, 10, 100, 50);
+ const params = typeof location !== 'undefined' ? new URLSearchParams(location.search) : new URLSearchParams();
+ // ?site= picks the site on the whole-network page (and "all" on a site's own page).
+ let current = own;
+ const asked = params.get('site');
+ if (asked === 'all') current = null;
+ else if (asked && serviceFor(asked)) current = serviceFor(asked);
+
+ node.textContent = '';
+ node.classList.add('ov-shipped-log');
+ const bar = el('div', { class: 'ov-shipped-bar', role: 'group', 'aria-label': 'Which site' });
+ const posts = el('aside', { class: 'ov-shipped-posts', hidden: '' });
+ const days = el('div', { class: 'ov-shipped-days' });
+ days.style.cssText = 'display:flex;flex-direction:column;gap:24px;min-width:0';
+ const more = el('button', { type: 'button', class: 'ov-shipped-more', hidden: '' }, 'Load more');
+ node.append(bar, posts, days, more);
+ let next = null;
+ let seq = 0;
+
+ function setUrl(svc) {
+ if (o.syncUrl === false || typeof history === 'undefined' || !history.replaceState) return;
+ const u = new URL(location.href);
+ if (own ? svc === own : !svc) u.searchParams.delete('site'); else u.searchParams.set('site', svc || 'all');
+ history.replaceState(history.state, '', u.pathname + u.search + u.hash);
+ }
+ function chip(label, svc) {
+ const b = el('button', { type: 'button', class: 'ov-shipped-chip', 'aria-pressed': String((svc || null) === (current || null)) }, label);
+ b.addEventListener('click', () => { current = svc || null; setUrl(current); load(true); });
+ return b;
+ }
+ function drawBar(sites) {
+ bar.textContent = '';
+ if (own) { bar.append(chip(siteName(own), own), chip('All of OpenVibe', null)); return; }
+ bar.append(chip('Everything', null));
+ for (const s of (sites || []).slice(0, 30)) bar.appendChild(chip(siteName(s.service), s.service));
+ }
+ function drawPosts(list) {
+ posts.textContent = '';
+ const good = (list || []).filter((p) => safeUrl(p.url)).slice(0, 5);
+ if (!good.length || o.posts === false) { posts.hidden = true; return; }
+ posts.appendChild(el('h3', {}, 'Patch notes'));
+ const ul = el('ul');
+ for (const p of good) {
+ const li = el('li');
+ li.appendChild(el('a', { href: p.url }, p.title || 'Patch notes'));
+ if (p.published_at) li.appendChild(el('small', {}, `${new Date(p.published_at).toLocaleDateString()}${p.entries ? ` · ${p.entries} changes` : ''}`));
+ ul.appendChild(li);
+ }
+ posts.appendChild(ul);
+ posts.hidden = false;
+ }
+ let sitesSeen = null;
+ async function load(reset) {
+ const mine = ++seq;
+ if (reset) {
+ next = null;
+ days.textContent = '';
+ days.appendChild(el('p', { class: 'ov-shipped-empty' }, 'Loading…'));
+ for (const b of bar.querySelectorAll('.ov-shipped-chip')) b.setAttribute('aria-pressed', 'false');
+ }
+ more.disabled = true;
+ const data = await feed({ service: current, limit: pageSize, before: reset ? null : next, api: o.api });
+ if (mine !== seq) return;
+ if (reset) {
+ days.textContent = '';
+ if (data && data.sites) sitesSeen = data.sites;
+ if (!own && !sitesSeen) {
+ const all = await feed({ service: null, limit: 1, api: o.api });
+ sitesSeen = all && all.sites;
+ }
+ drawBar(sitesSeen);
+ for (const b of bar.querySelectorAll('.ov-shipped-chip')) if (b.textContent === (current ? siteName(current) : (own ? 'All of OpenVibe' : 'Everything'))) b.setAttribute('aria-pressed', 'true');
+ drawPosts(data && data.posts);
+ }
+ if (!data) {
+ if (reset) days.appendChild(el('p', { class: 'ov-shipped-empty' }, 'The update history could not be loaded just now.'));
+ more.hidden = true;
+ return;
+ }
+ const entries = Array.isArray(data.entries) ? data.entries : [];
+ if (reset && !entries.length) days.appendChild(el('p', { class: 'ov-shipped-empty' }, 'Nothing has shipped here yet.'));
+ appendDays(days, entries, !current);
+ next = data.next || null;
+ more.hidden = !next;
+ more.disabled = false;
+ tick();
+ }
+ more.addEventListener('click', () => load(false));
+ await load(true);
+ return node;
+ }
+
+ // ── markup ──────────────────────────────────────────────────
+ const VIEWS = { latest, list, log };
+ /** Mount every [data-ov-shipped] element under `root` once. */
+ function scan(rootEl) {
+ const r = rootEl || (typeof document !== 'undefined' ? document : null);
+ if (!r || !r.querySelectorAll) return [];
+ const out = [];
+ for (const n of r.querySelectorAll('[data-ov-shipped]')) {
+ if (n.hasAttribute('data-ov-shipped-mounted')) continue;
+ const view = VIEWS[n.getAttribute('data-ov-shipped')];
+ if (!view) continue;
+ n.setAttribute('data-ov-shipped-mounted', '');
+ const d = n.dataset || {};
+ out.push(view(n, { service: d.service || null, limit: d.limit, title: d.title, href: d.href, more: d.more, api: d.api, posts: d.posts === 'off' ? false : undefined }).catch(() => null));
+ }
+ return out;
}
}
- return { mount, render, API };
+ return { latest, list, mount: list, log, scan, render, renderLatest, appendDays, serviceFor, ago, API, SITE_NAMES };
}));
}));
@@ -282,6 +282,45 @@ const USER = { id: 7, username: 'vibe', display_name: 'Vibe', avatar_url: null,
@@ -282,6 +282,45 @@ const USER = { id: 7, username: 'vibe', display_name: 'Vibe', avatar_url: null,
assert.strictEqual(user.username, 'vibe');
assert.strictEqual(user.username, 'vibe');
}
}
+ // ── 12b. A stale stored token must not hide the site's own session (openvibe.blog) ─
+ {
+ const { navbar, storage } = loadNavbar({
+ hostname: 'openvibe.blog',
+ storage: { ov_token: 'stale-jwt' },
+ fetch: async (url) => {
+ if (url === '/auth/me') return jsonResponse(200, { user: USER });
+ if (String(url).endsWith('/api/auth/me')) return jsonResponse(401, { error: 'expired' });
+ if (String(url).endsWith('/api/auth/refresh')) return jsonResponse(401, { error: 'too old' });
+ return jsonResponse(404, {});
+ },
+ });
+ navbar.init({ service: 'blog', apiBase: 'https://openvibe.network', sessionUrl: '/auth/me', logoutUrl: '/auth/logout?next={url}' });
+ const user = await navbar.refreshAuth();
+ assert.ok(user && user.username === 'vibe', 'the site session answers when the stored token is stale');
+ assert.strictEqual(storage.getItem('ov_token'), null, 'the stale token is dropped');
+ }
+
+ // ── 12b2. {path} and {url} in loginUrl/logoutUrl ─
+ {
+ const { navbar } = loadNavbar({ hostname: 'openvibe.blog', href: 'https://openvibe.blog/@openvibe/post?x=1' });
+ const f = navbar._auth.fillUrl;
+ assert.strictEqual(f('/auth/logout?next={path}', 'https://openvibe.blog/@openvibe/post?x=1'), '/auth/logout?next=%2F%40openvibe%2Fpost%3Fx%3D1');
+ assert.strictEqual(f('/auth/login?next={url}', 'https://openvibe.blog/a'), '/auth/login?next=https%3A%2F%2Fopenvibe.blog%2Fa');
+ navbar.init({ service: 'blog', apiBase: 'https://openvibe.network', loginUrl: '/auth/login?next={path}' });
+ assert.strictEqual(navbar._auth.resolveLoginHref('openvibe.blog', 'https://openvibe.blog/w?y=2'), '/auth/login?next=%2Fw%3Fy%3D2');
+ }
+
+ // ── 12c. Without a sessionUrl a stale token still means signed out ─
+ {
+ const { navbar } = loadNavbar({
+ hostname: 'openvibe.network',
+ storage: { ov_token: 'stale-jwt' },
+ fetch: async () => jsonResponse(401, {}),
+ });
+ navbar.init({ service: 'network', apiBase: 'https://openvibe.network' });
+ assert.strictEqual(await navbar.refreshAuth(), null);
+ }
+
// ── 13. Explicit user passed → no auth traffic at all ────
// ── 13. Explicit user passed → no auth traffic at all ────
{
{
const { navbar, calls } = loadNavbar({
const { navbar, calls } = loadNavbar({
@@ -20,7 +20,7 @@ console.log('shared modules: all checks passed');
@@ -20,7 +20,7 @@ console.log('shared modules: all checks passed');
// The browser bundles must at least evaluate in a bare global (this caught a missing `root` once).
// The browser bundles must at least evaluate in a bare global (this caught a missing `root` once).
{
{
const vm = require('vm'); const fs = require('fs'); const path = require('path');
const vm = require('vm'); const fs = require('fs'); const path = require('path');
- for (const f of ['footer.js', 'navbar.js', 'island.js', 'ui.js', 'ov-icons.js', 'history.js', 'sso-client.js', 'panels.js']) {
+ for (const f of ['footer.js', 'navbar.js', 'island.js', 'ui.js', 'ov-icons.js', 'history.js', 'sso-client.js', 'panels.js', 'shipped.js']) {
const ctx = vm.createContext({ console });
const ctx = vm.createContext({ console });
ctx.globalThis = ctx; ctx.window = ctx; ctx.self = ctx;
ctx.globalThis = ctx; ctx.window = ctx; ctx.self = ctx;
ctx.localStorage = { getItem: () => null, setItem() {} }; ctx.location = { hostname: 'openvibe.tools', href: 'https://openvibe.tools/' };
ctx.localStorage = { getItem: () => null, setItem() {} }; ctx.location = { hostname: 'openvibe.tools', href: 'https://openvibe.tools/' };
@@ -30,5 +30,17 @@ console.log('shared modules: all checks passed');
@@ -30,5 +30,17 @@ console.log('shared modules: all checks passed');
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'footer.js'), 'utf8'), ctx);
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'footer.js'), 'utf8'), ctx);
const html = ctx.OpenVibeFooter.buildHTML({ service: 'media', variant: 'full' });
const html = ctx.OpenVibeFooter.buildHTML({ service: 'media', variant: 'full' });
assert.ok(html.includes('Legal') && html.includes('ovf-brand'), 'footer renders without chrome data');
assert.ok(html.includes('Legal') && html.includes('ovf-brand'), 'footer renders without chrome data');
+ // Every footer carries the "shipped X ago" line and an Updates link (the shared update system).
+ assert.ok(html.includes('data-ov-shipped="latest"'), 'the shipped line is in the footer');
+ assert.ok(html.includes('https://openvibe.network/updates?site=openvibe.media'), 'Updates defaults to the network log for this host');
+ const own = ctx.OpenVibeFooter.buildHTML({ service: 'media', variant: 'compact', updates: '/updates' });
+ assert.ok(own.includes('href="/updates"') && own.includes('data-ov-shipped="latest"'), 'a site with its own log links to it, compact too');
+ const off = ctx.OpenVibeFooter.buildHTML({ service: 'media', variant: 'full', shipped: false });
+ assert.ok(!off.includes('data-ov-shipped') && !off.includes('>Updates<'), 'shipped: false leaves it out');
+ for (const [host, id] of [['openvibe.wiki', 'wiki'], ['openvibe.live', 'live'], ['pdf.openvibe.tools', 'pdf'], ['openvibe.tools', 'tools'], ['openre.stream', 'openre']]) {
+ const c2 = vm.createContext({ console }); c2.globalThis = c2; c2.window = c2; c2.location = { hostname: host, href: `https://${host}/` }; c2.localStorage = { getItem: () => null, setItem() {} };
+ vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'footer.js'), 'utf8'), c2);
+ assert.strictEqual(c2.OpenVibeFooter.detectService(), id, `detectService on ${host}`);
+ }
console.log('browser bundles evaluate: ok');
console.log('browser bundles evaluate: ok');
}
}
@@ -3,8 +3,9 @@
@@ -3,8 +3,9 @@
const assert = require('assert');
const assert = require('assert');
const { parseHTML } = require('linkedom');
const { parseHTML } = require('linkedom');
-const { window, document } = parseHTML('<!doctype html><html><head></head><body><div id="s"></div><div id="n"></div></body></html>');
+const { window, document } = parseHTML('<!doctype html><html><head></head><body><div id="s"></div><div id="n"></div><a id="pill" href="/updates"></a><div id="log"></div></body></html>');
globalThis.window = window; globalThis.document = document;
globalThis.window = window; globalThis.document = document;
+globalThis.fetch = async () => ({ ok: false, json: async () => ({}) }); // the load-time scan never reaches the network
const shipped = require('../shipped');
const shipped = require('../shipped');
const data = {
const data = {
@@ -40,10 +41,84 @@ const data = {
@@ -40,10 +41,84 @@ const data = {
let asked = null;
let asked = null;
globalThis.fetch = async (url) => { asked = url; return { ok: true, json: async () => data }; };
globalThis.fetch = async (url) => { asked = url; return { ok: true, json: async () => data }; };
await shipped.mount('#s', { service: 'live', limit: 3 });
await shipped.mount('#s', { service: 'live', limit: 3 });
- assert.strictEqual(asked, 'https://openvibe.blog/api/v1/changelog?service=live&limit=3');
+ assert.strictEqual(asked, 'https://openvibe.network/api/v1/changelog?service=live&limit=3', 'the Network proxy every site\'s CSP already allows');
globalThis.fetch = async () => { throw new Error('offline'); };
globalThis.fetch = async () => { throw new Error('offline'); };
const failed = await shipped.mount('#n', {});
const failed = await shipped.mount('#n', {});
assert.strictEqual(failed.hidden, true);
assert.strictEqual(failed.hidden, true);
assert.ok(document.getElementById('ov-shipped-style'), 'styles injected once');
assert.ok(document.getElementById('ov-shipped-style'), 'styles injected once');
+
+ // latest: the one-liner keeps the element's own link and never renders markup.
+ const pill = document.getElementById('pill');
+ shipped.renderLatest(pill, data, { service: 'live' });
+ assert.strictEqual(pill.hidden, false);
+ assert.strictEqual(pill.getAttribute('href'), '/updates');
+ assert.ok(/shipped/.test(pill.textContent) && pill.querySelector('b').textContent.startsWith('Fix <img'));
+ assert.strictEqual(pill.querySelectorAll('img').length, 0);
+ assert.ok(pill.querySelector('time[data-ov-ago]'));
+ shipped.renderLatest(pill, { entries: [] }, {});
+ assert.strictEqual(pill.hidden, true, 'nothing shipped: the pill hides');
+ const net = document.createElement('a');
+ shipped.renderLatest(net, data, {});
+ assert.ok(net.textContent.includes('Live shipped'), 'network-wide: the pill names the site');
+
+ // list: an "All updates" link only for a local or https path.
+ shipped.render(one, data, { service: 'live', more: '/updates' });
+ assert.strictEqual(one.querySelector('.ov-shipped-links a').getAttribute('href'), '/updates');
+ shipped.render(one, data, { service: 'live', more: 'javascript:alert(1)' });
+ assert.ok(![...one.querySelectorAll('.ov-shipped-links a')].some((a) => /javascript/.test(a.getAttribute('href'))));
+
+ // log: day groups continue across pages; the site label only network-wide.
+ const box = document.createElement('div');
+ const at = (h) => new Date(Date.UTC(2026, 8, 24, h)).toISOString();
+ shipped.appendDays(box, [{ service: 'live', sha: 'a'.repeat(40), short: 'aaaaaaa', subject: 'one', author: 'OpenVibers', deployed_at: at(12), url: 'https://github.com/x/y/commit/a' }], true);
+ shipped.appendDays(box, [{ service: 'tools', sha: 'b'.repeat(40), subject: 'two', deployed_at: at(11), url: 'https://github.com/x/y/commit/b' }], true);
+ assert.strictEqual(box.querySelectorAll('.ov-shipped-day').length, 1, 'the same day continues');
+ assert.strictEqual(box.querySelectorAll('.ov-shipped-entry').length, 2);
+ assert.strictEqual(box.querySelectorAll('.ov-shipped-site').length, 2);
+ assert.ok(box.querySelector('.ov-shipped-meta').textContent.startsWith('OpenVibers · '));
+ assert.strictEqual(box.querySelectorAll('.ov-shipped-hash')[1].textContent, 'bbbbbbb', 'short sha from the full sha');
+
+ // serviceFor: hosts to registry ids.
+ const sf = shipped.serviceFor;
+ assert.strictEqual(sf('openvibe.wiki'), 'wiki');
+ assert.strictEqual(sf('pdf.openvibe.tools'), 'tools');
+ assert.strictEqual(sf('my.openvibe.network'), 'network');
+ assert.strictEqual(sf('events.openvibe.network'), 'events');
+ assert.strictEqual(sf('ingest.openre.stream'), 'openre');
+ assert.strictEqual(sf('www.openvibe.live'), 'live');
+ assert.strictEqual(sf('example.com'), null);
+ assert.strictEqual(sf('live'), 'live');
+
+ // log(): pages with the cursor, and the "Load more" button disappears on the last page.
+ const calls = [];
+ const page1 = { entries: [{ service: 'wiki', sha: 'c'.repeat(40), subject: 'three', deployed_at: at(10) }], next: 'CUR', posts: [{ title: 'Patch notes: x', url: 'https://openvibe.blog/@openvibe/x', published_at: at(9), entries: 40 }], sites: [{ service: 'wiki' }] };
+ const page2 = { entries: [{ service: 'wiki', sha: 'd'.repeat(40), subject: 'four', deployed_at: at(8) }], next: null, posts: [] };
+ globalThis.fetch = async (url) => { calls.push(url); return { ok: true, json: async () => (url.includes('before=CUR') ? page2 : page1) }; };
+ const logEl = document.getElementById('log');
+ await shipped.log(logEl, { service: 'wiki', api: 'https://example.test/api/v1/changelog' });
+ assert.ok(logEl.textContent.includes('three'));
+ assert.strictEqual(logEl.querySelector('.ov-shipped-posts a').getAttribute('href'), 'https://openvibe.blog/@openvibe/x');
+ const more = logEl.querySelector('.ov-shipped-more');
+ assert.strictEqual(more.hidden, false);
+ assert.deepStrictEqual([...logEl.querySelectorAll('.ov-shipped-chip')].map((b) => b.textContent), ['Wiki', 'All of OpenVibe']);
+ more.dispatchEvent(new window.Event('click'));
+ await new Promise((r) => setTimeout(r, 20));
+ assert.ok(calls.some((u) => u.includes('before=CUR') && u.includes('service=wiki')));
+ assert.ok(logEl.textContent.includes('four'));
+ assert.strictEqual(more.hidden, true, 'no next cursor: no button');
+
+ // scan(): markup mounts once.
+ globalThis.fetch = async () => ({ ok: true, json: async () => data });
+ const autoEl = document.createElement('section');
+ autoEl.id = 'auto';
+ for (const [k, v] of Object.entries({ 'data-ov-shipped': 'list', 'data-service': 'wiki', 'data-limit': '2', 'data-more': '/updates', 'data-api': 'https://example.test/feed' })) autoEl.setAttribute(k, v);
+ document.body.appendChild(autoEl);
+ const first = shipped.scan(document);
+ assert.strictEqual(first.length, 1);
+ await Promise.all(first);
+ const auto = document.getElementById('auto');
+ assert.ok(auto.hasAttribute('data-ov-shipped-mounted'));
+ assert.strictEqual(auto.querySelectorAll('li').length, 2);
+ assert.strictEqual(shipped.scan(document).length, 0, 'already mounted: not again');
console.log('shipped: all checks passed');
console.log('shipped: all checks passed');
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });