openvibers/openvibe.shared Commits

1.8.0: shipped.js, 'Recently shipped' for any OpenVibe site from the network changelog on OpenVibe.Blog (per site or network-wide, each line linked to its commit, the latest Patch notes post linked); DOM nodes only, https links only, hidden when empty or failing; served at openvibe.network/shared/shipped.js

OP OpenVibers committed 24 Sept 2026 · cccccba510 · 8 files, +164 −5


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex cfb12d9..cfb8a0e 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,16 @@ 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.8.0 — 2026-09-24
++
++`shipped.js` (browser file, also `openvibe-shared/shipped`): "Recently shipped" for any OpenVibe site.
++`OpenVibeShipped.mount(el, { service, limit, title })` reads the network changelog OpenVibe.Blog keeps
++(`GET https://openvibe.blog/api/v1/changelog`): what each site deployed, newest first, each line
++linked to its commit, and a link to the latest "Patch notes" post that gathers a batch of changes.
++Without `service` it shows the whole network with site labels. DOM nodes only (commit text is never
++parsed as HTML), https links only, hidden when there is nothing or the fetch fails; styles follow
++the theme variables. Additive.
++
## 1.7.0 — 2026-09-24
`openvibe-shared/trace` (Track O): one W3C trace across the services a request touches.
ddiff --git a/README.md b/README.md
iindex 11c5e23..0cab500 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` 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` ("Recently shipped" 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/STATUS.json b/STATUS.json
iindex c0f3d7b..f7a3bc0 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "1.7.0",
++ "version": "1.8.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"updated": "2026-09-24"
}
ddiff --git a/files.js b/files.js
iindex 2c38c73..4b00546 100644
---- a/files.js
++++ b/files.js
@@@ -17,6 +17,7 @@ const BROWSER = Object.freeze([
'nav-icons.js', // navbar glyphs beyond its own, fetched by navbar.js after first paint
'theme-loader.js', // applies the theme before first paint
'footer.js', // shared footer (also required on the server by chrome-ssr.js)
++ 'shipped.js', // "Recently shipped" from the network changelog (openvibe.blog)
'notification-ui.js', // bell + notification panel
'account-switcher.js',
'user-card.js',
ddiff --git a/package-lock.json b/package-lock.json
iindex 826a6a9..1e76ba8 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "1.7.0",
++ "version": "1.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "1.7.0",
++ "version": "1.8.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 1d23794..19b65fd 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "1.7.0",
++ "version": "1.8.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"exports": {
@@@ -23,6 +23,7 @@
"./egress": "./egress.js",
"./files": "./files.js",
"./footer": "./footer.js",
++ "./shipped": "./shipped.js",
"./history": "./history.js",
"./icons": "./ov-icons.js",
"./island": "./island.js",
ddiff --git a/shipped.js b/shipped.js
nnew file mode 100644
iindex 0000000..5867b86
---- /dev/null
++++ b/shipped.js
@@@ -0,0 +1,98 @@
++/*!
++ * 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.
++ *
++ * <div id="shipped"></div>
++ * <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.
++ */
++(function (root, factory) {
++ const api = factory();
++ if (typeof module === 'object' && module.exports) module.exports = api;
++ if (typeof window !== 'undefined') window.OpenVibeShipped = api;
++}(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 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))}';
++
++ function ago(iso) {
++ const t = Date.parse(iso);
++ if (!t) return '';
++ const s = Math.max(0, (Date.now() - t) / 1000);
++ 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`;
++ }
++ function el(tag, attrs, text) {
++ const n = document.createElement(tag);
++ for (const k of Object.keys(attrs || {})) n.setAttribute(k, attrs[k]);
++ if (text != null) n.textContent = text;
++ return n;
++ }
++ function style() {
++ if (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; }
++
++ /** 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));
++ node.textContent = '';
++ if (!entries.length) { node.hidden = true; return node; }
++ node.classList.add('ov-shipped');
++ if (o.title !== '') node.appendChild(el('h3', {}, o.title || 'Recently shipped'));
++ const ul = el('ul');
++ 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));
++ 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)));
++ ul.appendChild(li);
++ }
++ node.appendChild(ul);
++ 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'} →`));
++ node.hidden = false;
++ return node;
++ }
++
++ /** Fetch and render into a selector or element. Resolves the element (hidden when there is nothing). */
++ async function mount(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; }
++ }
++
++ return { mount, render, API };
++}));
ddiff --git a/test/shipped.test.js b/test/shipped.test.js
nnew file mode 100644
iindex 0000000..d28bac5
---- /dev/null
++++ b/test/shipped.test.js
@@@ -0,0 +1,49 @@
++'use strict';
++/** openvibe-shared/shipped: the network changelog rendered as DOM nodes (never HTML), hidden when empty or failing. */
++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>');
++globalThis.window = window; globalThis.document = document;
++const shipped = require('../shipped');
++
++const data = {
++ entries: [
++ { service: 'live', subject: 'Fix <img src=x onerror=alert(1)> in chat', url: 'https://github.com/OpenVibers/OpenVibe.Live/commit/abc', deployed_at: new Date(Date.now() - 3 * 3600e3).toISOString(), major: false },
++ { service: 'tools', subject: 'Recent tools in the launcher', url: 'javascript:alert(1)', deployed_at: new Date().toISOString(), major: true },
++ ],
++ latest_post: { title: 'Patch notes: Recent tools', url: 'https://openvibe.blog/@openvibe/patch-notes-recent-tools' },
++};
++
++(async () => {
++ const node = document.getElementById('s');
++ shipped.render(node, data, { limit: 5 });
++ assert.strictEqual(node.hidden, false);
++ assert.strictEqual(node.querySelector('h3').textContent, 'Recently shipped');
++ const items = node.querySelectorAll('li');
++ assert.strictEqual(items.length, 2);
++ assert.strictEqual(node.querySelectorAll('img').length, 0, 'commit text is text, never markup');
++ assert.ok(items[0].textContent.includes('<img src=x'));
++ assert.strictEqual(items[0].querySelector('.ov-shipped-site').textContent, 'Live', 'network-wide: each line names its site');
++ assert.strictEqual(items[1].querySelector('a'), null, 'a non-https link is not a link');
++ assert.ok(items[0].querySelector('time').textContent.endsWith('h ago'));
++ assert.strictEqual(node.querySelector('.ov-shipped-notes').getAttribute('href'), data.latest_post.url);
++
++ const one = document.getElementById('n');
++ shipped.render(one, data, { service: 'live', title: '' });
++ assert.strictEqual(one.querySelector('h3'), null);
++ assert.strictEqual(one.querySelector('.ov-shipped-site'), null, 'one site: no site labels');
++ shipped.render(one, { entries: [] }, {});
++ assert.strictEqual(one.hidden, true, 'nothing shipped: hidden');
++
++ // mount(): the request, and a failure hides the element.
++ 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');
++ 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');
++ console.log('shipped: all checks passed');
++})().catch((e) => { console.error(e); process.exit(1); });