/*! * 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. * * Three views, the same on every site: * latest the one-liner "πŸš€ shipped 5m ago: ", 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): * *
*
data-service="" = the whole network * * * 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 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.network/api/v1/changelog'; // Every OpenVibe service's display name (openvibe-contracts site.name). A service missing here still reads as a name // (siteName capitalises its id), never as a lowercase id next to capitalised ones. 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: 'Restream', bot: 'Bot', sites: 'Sites', realtime: 'Realtime', space: 'Space', services: 'Services', actor: 'Actor', run: 'Run', watch: 'Watch', link: 'Link', zone: 'Zone', download: 'Download', video: 'Video', pics: 'Pics', rent: 'Rent', homes: 'Homes', work: 'Work', food: 'Food', help: 'Help', quest: 'Quest', website: 'Website', fund: 'Fund', 'media-hub': 'Media Hub', extensions: 'Extensions', contracts: 'Contracts', sdk: 'SDK', shared: 'Shared', examples: 'Examples', publishing: 'Publishing', play: 'Play', inventory: 'Inventory', bench: 'Bench', cam: 'Cam', casino: 'Casino', design: 'Design', domains: 'Domains', exchange: 'Exchange', fun: 'Fun', meme: 'Meme', report: 'Report', surf: 'Surf', today: 'Today', tours: 'Tours', vision: 'Vision', shoes: 'Shoes', }; const STYLE_ID = 'ov-shipped-style'; 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-home{display:grid;gap:12px;justify-items:start;margin:16px 0;min-width:0}.ov-shipped-home>.ov-shipped{justify-self:stretch}', `.ov-shipped-n{margin-left:2px;white-space:pre;font-size:.78em;font-weight:600;font-variant-numeric:tabular-nums}`, `.ov-shipped-kinds{margin-top:-4px}.ov-shipped-house{margin-top:6px;border-top:1px dashed var(--border,rgba(255,255,255,.12));padding-top:6px}.ov-shipped-house>summary{cursor:pointer;color:${MUTED};font-size:13px;padding:6px 0}.ov-shipped-house .ov-shipped-entry{opacity:.85}`, `.ov-updates{min-width:0}.ov-updates h1{margin:.2em 0 .3em}.ov-updates-lede{color:${MUTED};line-height:1.55;margin:0 0 18px}`, '.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:${TEXT};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}`, // Touch (4.46.0): a taller hit box with no change to the layout (padding in, margin out); overflow:hidden rules out a pseudo-element. '@media (pointer:coarse){.ov-shipped ul{gap:0}.ov-shipped li{min-height:44px;align-items:center}.ov-shipped a.ov-shipped-subject{padding-top:12px;padding-bottom:12px}}', '.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-strong,#1d4ed8);border-color:var(--accent-strong,#1d4ed8);color:var(--on-accent-strong,#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}`, // 4.47.2: the same change on many sites is one row (a pin round was 31 rows of the same line). '.ov-shipped-group{flex-wrap:wrap}', `.ov-shipped-count{flex:none;min-width:2em;text-align:center;font-size:.72rem;font-weight:800;padding:.12rem .45rem;border-radius:999px;background:color-mix(in srgb,var(--accent,#3b82f6) 22%,transparent);color:${TEXT}}`, `.ov-shipped-notes{color:${ACCENT};font-size:.82rem;font-weight:600;text-decoration:none;white-space:nowrap}.ov-shipped-notes:hover{text-decoration:underline}`, // 4.53.1: the patch-notes link under the list carries a whole post title ("Patch notes: 81 changes across 43 sites // (Oct 30) β†’"); kept on one line it ran past a 320 px screen. It wraps there; the short "What's in it" stays whole. '.ov-shipped-links .ov-shipped-notes{white-space:normal;min-width:0;overflow-wrap:anywhere}', '@media (pointer:coarse){.ov-shipped-links a{display:inline-flex;align-items:center;min-height:44px}.ov-shipped-entry{padding-top:12px;padding-bottom:12px}.ov-shipped-hash{padding-top:12px;padding-bottom:12px;margin-top:-12px;margin-bottom:-12px}}', `.ov-shipped-fold{flex-basis:100%;min-width:0;font-size:.82rem;color:${MUTED}}.ov-shipped-fold summary{cursor:pointer;padding:3px 0;overflow-wrap:anywhere}`, `.ov-shipped-fold .ov-shipped-entries{margin:4px 0 2px;padding-left:10px;border-left:2px solid ${BORDER}}.ov-shipped-fold .ov-shipped-entry{padding:5px 8px}`, '@media (max-width:600px){.ov-shipped-entry{flex-wrap:wrap;gap:4px 8px}.ov-shipped-meta{width:100%}}', // 11-09 (Opus): the "What's new" card: a devblog lead with its picture, rows with a kind tag and area chips. `.ov-shipped-dev{display:grid;grid-template-columns:96px minmax(0,1fr);gap:12px;align-items:center;margin:0 0 12px;padding:8px;border:1px solid ${BORDER};border-radius:14px;text-decoration:none;color:${TEXT}}`, '.ov-shipped-dev:not(:has(img)){grid-template-columns:minmax(0,1fr)}', `.ov-shipped-dev:hover{border-color:var(--accent,#3b82f6)}.ov-shipped-dev img{width:96px;height:64px;object-fit:cover;border-radius:10px;display:block;background:${BORDER}}`, `.ov-shipped-dev-text{display:grid;gap:2px;min-width:0}.ov-shipped-dev-text small{font-size:.68rem;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:${ACCENT}}`, '.ov-shipped-dev-text b{font-size:.95rem;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}', `.ov-shipped-dev-text>span{font-size:.82rem;color:${MUTED};display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}`, '.ov-shipped .ov-shipped-row{display:flex;align-items:center;gap:8px;min-width:0}', // The kind of a change: a drawn mark (a star, an arrow up, a tick) and, for New and Fixed, the word. An improvement // is the mark alone: most rows are improvements, and the word beside every one of them read as noise. '.ov-shipped-kind{flex:none;display:inline-flex;align-items:center;gap:5px;font-size:.72rem;font-weight:700;line-height:1.2;padding:2px 9px 2px 6px;border-radius:999px}', '.ov-shipped-kind::before{content:"";flex:none;width:11px;height:11px;background:var(--k,currentColor)}', '.ov-shipped-kind.is-new::before{--k:#22c55e;clip-path:polygon(50% 0,63% 37%,100% 50%,63% 63%,50% 100%,37% 63%,0 50%,37% 37%)}', '.ov-shipped-kind.is-better::before{--k:var(--accent-light,var(--accent,#60a5fa));clip-path:polygon(50% 0,100% 52%,68% 52%,68% 100%,32% 100%,32% 52%,0 52%)}', '.ov-shipped-kind.is-fixed::before{--k:#f59e0b;clip-path:polygon(6% 54%,22% 38%,40% 56%,78% 12%,94% 28%,40% 88%)}', '.ov-shipped-kind.is-better{gap:0;padding:5px;font-size:0}', `.ov-shipped-kind.is-new{background:color-mix(in srgb,#22c55e 20%,transparent);color:${TEXT}}.ov-shipped-kind.is-better{background:color-mix(in srgb,var(--accent,#3b82f6) 20%,transparent);color:${TEXT}}.ov-shipped-kind.is-fixed{background:color-mix(in srgb,#f59e0b 22%,transparent);color:${TEXT}}`, `.ov-shipped-areas{display:inline-flex;gap:4px;flex:none}.ov-shipped-areas span{font-size:.68rem;padding:1px 7px;border-radius:999px;border:1px solid ${BORDER};color:${MUTED};white-space:nowrap}`, '@media (max-width:520px){.ov-shipped .ov-shipped-row{flex-wrap:wrap;row-gap:2px}.ov-shipped .ov-shipped-row .ov-shipped-subject{flex:1 1 60%;white-space:normal;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.ov-shipped-areas{order:5}}', ].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'; // ── helpers ───────────────────────────────────────────────── function ago(iso, now) { const t = Date.parse(iso); if (!t) return ''; 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`; 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 || {})) if (attrs[k] != null) n.setAttribute(k, attrs[k]); if (text != null) n.textContent = text; return n; } function style() { 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] || String(id || '').replace(/(^|-)([a-z])/g, (m, d, c) => `${d ? ' ' : ''}${c.toUpperCase()}`); 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'; } // The Services family lives on its subdomains (ai., bench.openvibe.services), like the Network's. if (/(^|\.)openvibe\.services$/.test(h)) { const sub = h.replace(/\.?openvibe\.services$/, ''); return SITE_NAMES[sub] && sub !== 'services' ? sub : 'services'; } if (/(^|\.)open\.shoes$/.test(h)) return 'shoes'; const m = /(?:^|\.)openvibe\.([a-z]+)$/.exec(h); return m && SITE_NAMES[m[1]] ? m[1] : null; } /** 'auto' = this page's host; a site id that is not a registry id (a Tools satellite's 'dev') also falls back to the host; '' = the whole network. */ function resolveService(s) { const host = () => (typeof location !== 'undefined' ? serviceFor(location.hostname) : null); if (s === 'auto') return host(); return s ? (serviceFor(s) || host()) : 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, kind = null, limit = 5, before = null, api = null } = {}) { const url = new URL(api || API); if (service) url.searchParams.set('service', String(service)); if (kind) url.searchParams.set('kind', String(kind)); 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(); } // ── what a visitor reads ──────────────────────────────────── // A library pin bump ("openvibe-shared v4.1.0 (was v4.0.0) (#34)", opened by pin-bump.js on every repo at once) // says nothing to a visitor: the one-liner and the short list pass over them, while the full log keeps every // deployed commit. If nothing else shipped, the newest entry is shown anyway, so a site never looks dead. const PIN_BUMP = /^openvibe-[a-z][a-z0-9-]* v\d+\.\d+\.\d+ \(was v\d+\.\d+\.\d+\)/; function isHousekeeping(e) { return Boolean(e && (e.kind === 'internal' || PIN_BUMP.test(String(e.subject || '')))); } function forVisitors(entries, n) { const all = Array.isArray(entries) ? entries : []; const told = all.filter((e) => !isHousekeeping(e)); return (told.length ? told : all).slice(0, n); } // ── latest: the one-liner ─────────────────────────────────── /** Render the newest entry into `node` (exported for tests). A node that is an keeps its own href. */ function renderLatest(node, data, opts) { const o = opts || {}; const e = forVisitors(data && data.entries, 1)[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(); // A few more than one, so a run of pin bumps does not hide the newest real change. return renderLatest(node, await feed({ service: o.service, limit: 12, api: o.api }), o); } // ── list: the newest few (the "What's new" card) ───────────── // Opus's design (11-09, the owner: "show the latest blog post… rich content… with the image… elegant… use AI for // tags or topics"): the site's newest devblog post leads with its picture, then the last changes as rows, each with // what kind of change it is (New, Better, Fixed: Blog's blog.changelog.tag task), its plain words and up to two // areas. Entries without those fields (before the task ran) read as before: their subject, no kind, no areas. const KINDS = { new: 'New', better: 'Improved', fixed: 'Fixed' }; function kindTag(e) { const k = e && KINDS[e.kind]; return k && e.kind !== 'better' ? el('span', { class: `ov-shipped-kind is-${e.kind}` }, k) : null; } function areaChips(e, n) { const areas = Array.isArray(e && e.areas) ? e.areas.filter((a) => typeof a === 'string' && a).slice(0, n) : []; if (!areas.length) return null; const box = el('span', { class: 'ov-shipped-areas' }); for (const a of areas) box.appendChild(el('span', {}, a)); return box; } function words(e) { return typeof (e && e.plain) === 'string' && e.plain.trim() ? e.plain.trim() : plainSubject(e); } function devblogCard(post) { const href = post && safeUrl(post.url); if (!href || !post.title) return null; const a = el('a', { class: 'ov-shipped-dev', href }); const fig = safeUrl(post.figure); if (fig) { const img = el('img', { src: fig, alt: '', loading: 'lazy', decoding: 'async' }); a.appendChild(img); } const text = el('span', { class: 'ov-shipped-dev-text' }); text.appendChild(el('small', {}, 'Devblog')); text.appendChild(el('b', {}, String(post.title))); if (post.summary) text.appendChild(el('span', {}, String(post.summary))); a.appendChild(text); return a; } /** Render a changelog answer into `node` (exported for tests). */ function render(node, data, opts) { const o = opts || {}; const entries = forVisitors(data && data.entries, clamp(o.limit, 1, 20, 5)); const lead = devblogCard(data && data.latest_devblog); if (typeof document !== 'undefined') style(); node.textContent = ''; if (!entries.length && !lead) { node.hidden = true; return node; } node.classList.add('ov-shipped'); if (o.title !== '') node.appendChild(el('h3', {}, o.title || "What's new")); if (lead) node.appendChild(lead); const ul = el('ul', { class: 'ov-shipped-rows' }); const showSite = o.showSite != null ? o.showSite : !o.service; for (const e of entries) { const li = el('li', { class: 'ov-shipped-row' }); const k = kindTag(e); if (k) li.appendChild(k); if (showSite) li.appendChild(el('span', { class: 'ov-shipped-site' }, siteName(e.service))); const href = safeUrl(e.url); const text = words(e); const a = el(href ? 'a' : 'span', href ? { href, class: `ov-shipped-subject${e.major ? ' ov-shipped-major' : ''}`, rel: 'noopener', target: '_blank', title: e.subject || text } : { class: `ov-shipped-subject${e.major ? ' ov-shipped-major' : ''}` }, text); li.appendChild(a); const chips = areaChips(e, 2); if (chips) li.appendChild(chips); if (e.deployed_at) li.appendChild(timeEl(e.deployed_at)); ul.appendChild(li); } if (entries.length) node.appendChild(ul); const links = el('div', { class: 'ov-shipped-links' }); const more = localHref(o.more); const go = (a) => { a.insertAdjacentHTML('beforeend', ''); return a; }; if (more) links.appendChild(go(el('a', { href: more }, 'All updates'))); const post = data.latest_post && safeUrl(data.latest_post.url) ? data.latest_post : null; if (post) links.appendChild(go(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: Math.min(50, clamp(o.limit, 1, 20, 5) * 3), api: o.api }), o); } // ── grouping (4.47.2): one row for the same change on many sites ── // A pin round lands the same commit line on thirty sites within a minute, and a fleet fix ("Touch targets on // phones") on a dozen: shown one by one they bury everything else. Entries of one day with the same change fold // into one row: a pin bump by package and version, anything else by its subject without the PR number. const PIN_KEY = /^(openvibe-[a-z][a-z0-9-]*) v(\d+\.\d+\.\d+)(?: \(was v\d+\.\d+\.\d+\))?/; const PKG = { 'openvibe-shared': ['Shared', 'OpenVibe.Shared'], 'openvibe-sdk': ['SDK', 'OpenVibe.SDK'], 'openvibe-contracts': ['Contracts', 'OpenVibe.Contracts'] }; // The subject without its PR number, and without a leading "Play: " naming the entry's own site, so "Play: sign-in // on …" and "Help: sign-in on …" are the same change. const squash = (x) => String(x || '').replace(/[\s.-]/g, '').toLowerCase(); function plainSubject(e) { let s = String((e && e.subject) || '').replace(/\s*\(#\d+\)\s*$/, '').trim(); const m = /^([A-Za-z][\w .-]{0,24}):\s+(.+)$/.exec(s); if (m && e) { const p = squash(m[1]).replace(/^openvibe/, ''); if (p && (p === squash(e.service) || p === squash(siteName(e.service)))) s = m[2].charAt(0).toUpperCase() + m[2].slice(1); } return s; } function pinOf(e) { const m = PIN_KEY.exec(plainSubject(e)); if (!m) return null; const k = PKG[m[1]] || [m[1].replace(/^openvibe-/, ''), null]; return { pkg: m[1], version: m[2], name: k[0], repo: k[1] }; } function groupKey(e) { const p = pinOf(e); return p ? `pin:${p.pkg}@${p.version}` : `s:${plainSubject(e).toLowerCase()}`; } /** Entries in their order, the same change on many sites folded into one group (exported: the server page uses it). */ function groupEntries(entries, dayKey) { const out = [], at = new Map(); for (const e of entries || []) { const k = `${dayKey ? dayKey(e) : ''}|${groupKey(e)}`; const g = at.get(k); if (g) g.entries.push(e); else { const ng = { key: k, entries: [e], pin: pinOf(e) }; at.set(k, ng); out.push(ng); } } return out; } /** What a group's row says: "Shared 4.45.0 reached 31 sites", or the shared subject. */ function groupTitle(g) { const sites = new Set(g.entries.map((e) => e.service)).size; if (g.pin) return `${g.pin.name} ${g.pin.version} reached ${sites} ${sites === 1 ? 'site' : 'sites'}`; return plainSubject(g.entries[0]); } function groupNotes(g) { return g.pin && g.pin.repo ? `https://github.com/OpenVibers/${g.pin.repo}/blob/v${g.pin.version}/CHANGELOG.md` : null; } // ── 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 sha = String(e.sha || ''); const repo = String(e.repo || '').split('/').pop(); const href = repo && /^[A-Za-z0-9_.-]+$/.test(repo) && /^[0-9a-f]{7,40}$/i.test(sha) ? `https://git.openvibe.codes/OpenVibers/${repo}/commit/${sha}` : safeUrl(e.url); row.appendChild(el(href ? 'a' : 'span', href ? { class: 'ov-shipped-hash', href, target: '_blank', rel: 'noopener', title: 'The change on OpenVibers Git' } : { class: 'ov-shipped-hash' }, e.short || sha.slice(0, 7))); if (showSite) row.appendChild(el('span', { class: 'ov-shipped-site' }, siteName(e.service))); const text = el('span', { class: `ov-shipped-text${e.major ? ' ov-shipped-major' : ''}`, title: String(e.subject || '') }); const kind = kindTag(e); if (kind) text.appendChild(kind); text.appendChild(document.createTextNode(words(e))); const areas = areaChips(e, 2); if (areas) text.appendChild(areas); row.appendChild(text); if (e.kind) row.setAttribute('data-kind', String(e.kind)); const meta = el('span', { class: 'ov-shipped-meta' }); const time = new Date(e.deployed_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); // Every commit is the OpenVibers account's: naming it on each row says nothing. meta.textContent = `${e.author && e.author !== 'OpenVibers' ? `${e.author} Β· ` : ''}${time}`; row.appendChild(meta); return row; } const hhmm = (iso) => new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); function groupRow(g, open) { const row = el('div', { class: 'ov-shipped-entry ov-shipped-group' }); const sites = [...new Set(g.entries.map((e) => e.service))]; row.appendChild(el('span', { class: 'ov-shipped-count', title: `${g.entries.length} changes` }, String(sites.length))); const text = el('span', { class: 'ov-shipped-text' }, groupTitle(g)); const notes = groupNotes(g); if (notes) { text.appendChild(document.createTextNode(' ')); text.appendChild(el('a', { class: 'ov-shipped-notes', href: notes, target: '_blank', rel: 'noopener' }, "What's in it")); } row.appendChild(text); const first = hhmm(g.entries[g.entries.length - 1].deployed_at), last = hhmm(g.entries[0].deployed_at); row.appendChild(el('span', { class: 'ov-shipped-meta' }, first === last ? last : `${first}–${last}`)); const fold = el('details', { class: 'ov-shipped-fold' }); if (open) fold.open = true; const names = sites.map(siteName); fold.appendChild(el('summary', {}, names.length > 7 ? `${names.slice(0, 6).join(', ')} and ${names.length - 6} more` : names.join(', '))); const list = el('div', { class: 'ov-shipped-entries' }); for (const e of g.entries) list.appendChild(entryRow(e, true)); fold.appendChild(list); row.appendChild(fold); 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); } // The same change again on this day: it joins that row (across "Load more" pages too). if (!day._groups) day._groups = new Map(); const k = groupKey(e), hit = day._groups.get(k); if (hit) { hit.g.entries.push(e); const det = hit.node.querySelector && hit.node.querySelector('details'); const fresh = groupRow(hit.g, Boolean(det && det.open)); hit.node.replaceWith(fresh); hit.node = fresh; } else if (e.kind === 'internal' && !pinOf(e)) { let fold = day.querySelector(':scope > .ov-shipped-house'); if (!fold) { fold = el('details', { class: 'ov-shipped-house' }); fold.appendChild(el('summary', {}, '')); fold.appendChild(el('div', { class: 'ov-shipped-entries' })); day.appendChild(fold); } fold.lastElementChild.appendChild(entryRow(e, showSite)); const n = fold.lastElementChild.children.length; fold.firstElementChild.textContent = `${n} housekeeping ${n === 1 ? 'change' : 'changes'} (library updates and internal work)`; } else { const node = entryRow(e, showSite); day._groups.set(k, { g: { key: k, entries: [e], pin: pinOf(e) }, node }); day.querySelector(':scope > .ov-shipped-entries').appendChild(node); } } } async function log(target, opts) { const o = { ...(opts || {}) }; const node = typeof target === 'string' ? document.querySelector(target) : target; if (!node) return null; style(); 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 kinds = el('div', { class: 'ov-shipped-bar ov-shipped-kinds', role: 'group', 'aria-label': 'Which kind of change' }); let kindNow = KINDS[params.get('kind')] ? params.get('kind') : ''; let facets = null; 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'); // The first load shows one "Loading…" line on its own (and a site's own chips, known up front), so the chips, // the patch notes and the days arrive together instead of pushing a visible line down the page (layout shift). const loading = el('p', { class: 'ov-shipped-empty' }, 'Loading…'); function drawKinds() { kinds.textContent = ''; for (const [k, label] of [['', 'All changes'], ['new', 'New'], ['better', 'Improved'], ['fixed', 'Fixed']]) { const count = k && facets && facets.kinds ? Number(facets.kinds[k]) || 0 : null; const b = el('button', { type: 'button', class: 'ov-shipped-chip', 'aria-pressed': String(kindNow === k) }, label); if (count != null) b.appendChild(el('span', { class: 'ov-shipped-n' }, ` ${count.toLocaleString()}`)); b.addEventListener('click', () => { kindNow = k; if (o.syncUrl !== false && typeof history !== 'undefined' && history.replaceState) { const u = new URL(location.href); if (k) u.searchParams.set('kind', k); else u.searchParams.delete('kind'); history.replaceState(history.state, '', u.pathname + u.search + u.hash); } drawKinds(); load(true); }); kinds.appendChild(b); } } drawKinds(); if (kindNow) days.setAttribute('data-kind', kindNow); node.append(bar, kinds, posts, loading, 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 = ''; if (!loading.parentNode) 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, kind: kindNow || null, limit: pageSize, before: reset ? null : next, api: o.api }); if (mine !== seq) return; if (reset) { days.textContent = ''; loading.remove(); 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); facets = data.facets || null; drawKinds(); } 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' }, kindNow ? `Nothing marked ${KINDS[kindNow]} in this history yet.` : '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)); if (own) drawBar(null); 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 { latest, list, mount: list, log, scan, render, renderLatest, appendDays, groupEntries, groupTitle, groupNotes, serviceFor, ago, API, SITE_NAMES, siteName, isHousekeeping }; }));