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)

OP OpenVibers committed 24 Sept 2026 · c5b47f3a2d · 10 files, +525 −54


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex cfb8a0e..fdee9b1 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
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.
ddiff --git a/README.md b/README.md
iindex 0cab500..719294a 100644
---- a/README.md
++++ b/README.md
@@@ -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` |
ddiff --git a/footer.js b/footer.js
iindex e3f6869..1732da2 100644
---- a/footer.js
++++ b/footer.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 &amp; 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 = {}) {
ddiff --git a/navbar.js b/navbar.js
iindex 7051abb..7d9d602 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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,
},
};
ddiff --git a/package-lock.json b/package-lock.json
iindex 1e76ba8..2070ac8 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 19b65fd..f6e6c38 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/shipped.js b/shipped.js
iindex 5867b86..6069fc4 100644
---- a/shipped.js
++++ b/shipped.js
@@@ -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 };
}));
ddiff --git a/test/navbar-auth.test.js b/test/navbar-auth.test.js
iindex c6199fa..efe94f4 100644
---- a/test/navbar-auth.test.js
++++ b/test/navbar-auth.test.js
@@@ -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({
ddiff --git a/test/shared-modules.test.js b/test/shared-modules.test.js
iindex 1ea2f07..6b39748 100644
---- a/test/shared-modules.test.js
++++ b/test/shared-modules.test.js
@@@ -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');
}
ddiff --git a/test/shipped.test.js b/test/shipped.test.js
iindex d28bac5..b763208 100644
---- a/test/shipped.test.js
++++ b/test/shipped.test.js
@@@ -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 network
const 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); });