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
Changes
@@@ -4,6 +4,16 @@ 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.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.@@@ -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` |@@@ -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"}@@@ -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',@@@ -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"@@@ -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",nnew file mode 100644@@@ -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 };++}));nnew file mode 100644@@@ -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); });
@@ -4,6 +4,16 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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.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
## 1.7.0 — 2026-09-24
`openvibe-shared/trace` (Track O): one W3C trace across the services a request touches.
`openvibe-shared/trace` (Track O): one W3C trace across the services a request touches.
@@ -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` 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`) |
| 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` |
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "1.7.0",
+ "version": "1.8.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"updated": "2026-09-24"
"updated": "2026-09-24"
}
}
@@ -17,6 +17,7 @@ const BROWSER = Object.freeze([
@@ -17,6 +17,7 @@ const BROWSER = Object.freeze([
'nav-icons.js', // navbar glyphs beyond its own, fetched by navbar.js after first paint
'nav-icons.js', // navbar glyphs beyond its own, fetched by navbar.js after first paint
'theme-loader.js', // applies the theme before first paint
'theme-loader.js', // applies the theme before first paint
'footer.js', // shared footer (also required on the server by chrome-ssr.js)
'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
'notification-ui.js', // bell + notification panel
'account-switcher.js',
'account-switcher.js',
'user-card.js',
'user-card.js',
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.7.0",
+ "version": "1.8.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.7.0",
+ "version": "1.8.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.7.0",
+ "version": "1.8.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": {
@@ -23,6 +23,7 @@
@@ -23,6 +23,7 @@
"./egress": "./egress.js",
"./egress": "./egress.js",
"./files": "./files.js",
"./files": "./files.js",
"./footer": "./footer.js",
"./footer": "./footer.js",
+ "./shipped": "./shipped.js",
"./history": "./history.js",
"./history": "./history.js",
"./icons": "./ov-icons.js",
"./icons": "./ov-icons.js",
"./island": "./island.js",
"./island": "./island.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -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 };
+}));
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -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); });