openvibers/openvibe.shared shipped.js

shipped.js

main 42.2 KB · 606 lines · javascript

1/*!
2 * openvibe-shared/shipped — what shipped, on every OpenVibe site, from one feed: the network changelog
3 * OpenVibe.Blog keeps (every deployed commit of every site) and serves through OpenVibe.Network at
4 * GET https://openvibe.network/api/v1/changelog, which every site's CSP already allows.
5 *
6 * Three views, the same on every site:
7 * latest the one-liner "🚀 shipped 5m ago: <newest change>", linking to the site's updates page
8 * list the newest few changes, each linked to its commit, plus the latest "Patch notes" post
9 * log the full updates page: changes grouped by day, "Load more", this site or the whole
10 * network (with a filter per site), and the patch notes posts
11 *
12 * Markup only (the script mounts every element on the page, and the shared footer loads it):
13 * <a data-ov-shipped="latest" data-service="wiki" href="/updates"></a>
14 * <section data-ov-shipped="list" data-service="wiki" data-limit="8" data-more="/updates"></section>
15 * <div data-ov-shipped="log" data-service="wiki"></div> data-service="" = the whole network
16 * <script src="https://openvibe.network/shared/shipped.js" defer></script>
17 *
18 * Or from code: OpenVibeShipped.latest(el, opts), .list(el, opts) (alias mount), .log(el, opts),
19 * .scan(root). Options: service (a registry id; 'auto' = from the hostname; omitted = every site),
20 * limit, title ('' = no heading), href / more (links), api (the feed URL, for tests). DOM nodes only
21 * (commit text is never parsed as HTML), https links only; a failed or empty feed hides the element,
22 * never an error on the page. Styles follow the theme's CSS variables. A classic script, no modules.
23 */
24(function (root, factory) {
25 const api = factory();
26 if (typeof module === 'object' && module.exports) module.exports = api;
27 if (typeof window !== 'undefined') {
28 window.OpenVibeShipped = api;
29 if (typeof document !== 'undefined') {
30 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => api.scan(document));
31 else api.scan(document);
32 }
33 }
34}(typeof globalThis !== 'undefined' ? globalThis : this, function () {
35 'use strict';
36 const API = 'https://openvibe.network/api/v1/changelog';
37 // Every OpenVibe service's display name (openvibe-contracts site.name). A service missing here still reads as a name
38 // (siteName capitalises its id), never as a lowercase id next to capitalised ones.
39 const SITE_NAMES = {
40 live: 'Live', network: 'Network', tools: 'Tools', media: 'Media', community: 'Community', chat: 'Chat', games: 'Games',
41 blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', deals: 'Deals', coupons: 'Coupons', trade: 'Trade',
42 codes: 'Codes', host: 'Host', ai: 'AI', search: 'Search', sources: 'Sources', events: 'Events', billing: 'Billing',
43 tips: 'Tips', vip: 'VIP', openre: 'Restream', bot: 'Bot', sites: 'Sites', realtime: 'Realtime', space: 'Space',
44 services: 'Services', actor: 'Actor', run: 'Run', watch: 'Watch', link: 'Link', zone: 'Zone', download: 'Download',
45 video: 'Video', pics: 'Pics', rent: 'Rent', homes: 'Homes', work: 'Work', food: 'Food', help: 'Help', quest: 'Quest',
46 website: 'Website', fund: 'Fund', 'media-hub': 'Media Hub', extensions: 'Extensions', contracts: 'Contracts', sdk: 'SDK',
47 shared: 'Shared', examples: 'Examples', publishing: 'Publishing', play: 'Play', inventory: 'Inventory',
48 bench: 'Bench', cam: 'Cam', casino: 'Casino', design: 'Design', domains: 'Domains', exchange: 'Exchange', fun: 'Fun',
49 meme: 'Meme', report: 'Report', surf: 'Surf', today: 'Today', tours: 'Tours', vision: 'Vision', shoes: 'Shoes',
50 };
51 const STYLE_ID = 'ov-shipped-style';
52 const TEXT = 'var(--text-primary,var(--text,inherit))';
53 const MUTED = 'var(--text-secondary,var(--text-muted,#8b93ad))';
54 const ACCENT = 'var(--accent-light,var(--accent,#3b82f6))';
55 const BORDER = 'var(--border,rgba(127,127,127,.25))';
56 const CSS = [
57 `.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}`,
58 `.ov-shipped-latest:hover{border-color:var(--accent,#3b82f6);color:${TEXT}}.ov-shipped-latest svg{flex:none;color:var(--accent,#3b82f6)}`,
59 `.ov-shipped-latest b{color:${TEXT};font-weight:600;overflow:hidden;text-overflow:ellipsis;min-width:0}`,
60 '[data-ov-shipped][hidden],.ov-shipped[hidden],.ov-shipped-latest[hidden]{display:none!important}',
61 '.ov-shipped-home{display:grid;gap:12px;justify-items:start;margin:16px 0;min-width:0}.ov-shipped-home>.ov-shipped{justify-self:stretch}',
62 `.ov-shipped-n{margin-left:2px;white-space:pre;font-size:.78em;font-weight:600;font-variant-numeric:tabular-nums}`,
63 `.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}`,
64 `.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}`,
65 '.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}',
66 '.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}',
67 `.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}`,
68 `.ov-shipped a.ov-shipped-subject{color:${TEXT};text-decoration:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}`,
69 // 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.
70 '@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}}',
71 '.ov-shipped a.ov-shipped-subject:hover{text-decoration:underline}.ov-shipped li time{flex:none;margin-left:auto;font-size:.75rem;opacity:.7}',
72 `.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}`,
73 '.ov-shipped-log{display:flex;flex-direction:column;gap:22px;min-width:0}',
74 '.ov-shipped-bar{display:flex;flex-wrap:wrap;gap:6px;align-items:center}',
75 `.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}`,
76 `.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)}`,
77 `.ov-shipped-day h3{font-size:.95rem;font-weight:600;color:${MUTED};padding-bottom:8px;border-bottom:1px solid ${BORDER};margin:0 0 4px}`,
78 '.ov-shipped-entries{display:flex;flex-direction:column;gap:2px}',
79 '.ov-shipped-entry{display:flex;align-items:baseline;gap:10px;padding:8px 12px;border-radius:6px;min-width:0}',
80 '.ov-shipped-entry:hover{background:var(--bg-tertiary,rgba(127,127,127,.08))}',
81 `.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}`,
82 `.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}`,
83 `.ov-shipped-meta{font-size:.75rem;color:${MUTED};flex:none;white-space:nowrap}`,
84 `.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}`,
85 `.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}`,
86 `.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}`,
87 `.ov-shipped-empty{color:${MUTED};text-align:center;padding:24px 0;margin:0}`,
88 // 4.47.2: the same change on many sites is one row (a pin round was 31 rows of the same line).
89 '.ov-shipped-group{flex-wrap:wrap}',
90 `.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}}`,
91 `.ov-shipped-notes{color:${ACCENT};font-size:.82rem;font-weight:600;text-decoration:none;white-space:nowrap}.ov-shipped-notes:hover{text-decoration:underline}`,
92 // 4.53.1: the patch-notes link under the list carries a whole post title ("Patch notes: 81 changes across 43 sites
93 // (Oct 30) →"); kept on one line it ran past a 320 px screen. It wraps there; the short "What's in it" stays whole.
94 '.ov-shipped-links .ov-shipped-notes{white-space:normal;min-width:0;overflow-wrap:anywhere}',
95 '@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}}',
96 `.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}`,
97 `.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}`,
98 '@media (max-width:600px){.ov-shipped-entry{flex-wrap:wrap;gap:4px 8px}.ov-shipped-meta{width:100%}}',
99 // 11-09 (Opus): the "What's new" card: a devblog lead with its picture, rows with a kind tag and area chips.
100 `.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}}`,
101 '.ov-shipped-dev:not(:has(img)){grid-template-columns:minmax(0,1fr)}',
102 `.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}}`,
103 `.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}}`,
104 '.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}',
105 `.ov-shipped-dev-text>span{font-size:.82rem;color:${MUTED};display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}`,
106 '.ov-shipped .ov-shipped-row{display:flex;align-items:center;gap:8px;min-width:0}',
107 // The kind of a change: a drawn mark (a star, an arrow up, a tick) and, for New and Fixed, the word. An improvement
108 // is the mark alone: most rows are improvements, and the word beside every one of them read as noise.
109 '.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}',
110 '.ov-shipped-kind::before{content:"";flex:none;width:11px;height:11px;background:var(--k,currentColor)}',
111 '.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%)}',
112 '.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%)}',
113 '.ov-shipped-kind.is-fixed::before{--k:#f59e0b;clip-path:polygon(6% 54%,22% 38%,40% 56%,78% 12%,94% 28%,40% 88%)}',
114 '.ov-shipped-kind.is-better{gap:0;padding:5px;font-size:0}',
115 `.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}}`,
116 `.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}`,
117 '@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}}',
118 ].join('');
119 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';
120
121 // ── helpers ─────────────────────────────────────────────────
122 function ago(iso, now) {
123 const t = Date.parse(iso);
124 if (!t) return '';
125 const s = Math.max(0, ((now || Date.now()) - t) / 1000);
126 if (s < 45) return 'just now';
127 if (s < 3600) return `${Math.max(1, Math.round(s / 60))}m ago`;
128 if (s < 86400) return `${Math.round(s / 3600)}h ago`;
129 if (s < 86400 * 45) return `${Math.round(s / 86400)}d ago`;
130 return `${Math.round(s / (86400 * 30))}mo ago`;
131 }
132 function el(tag, attrs, text) {
133 const n = document.createElement(tag);
134 for (const k of Object.keys(attrs || {})) if (attrs[k] != null) n.setAttribute(k, attrs[k]);
135 if (text != null) n.textContent = text;
136 return n;
137 }
138 function style() {
139 if (typeof document === 'undefined' || document.getElementById(STYLE_ID)) return;
140 const s = el('style', { id: STYLE_ID });
141 s.textContent = CSS;
142 (document.head || document.documentElement).appendChild(s);
143 }
144 function safeUrl(u) { return /^https:\/\//.test(String(u || '')) ? String(u) : null; }
145 function localHref(u) { const s = String(u || ''); return /^\/(?!\/)/.test(s) || /^https:\/\//.test(s) ? s : null; }
146 function clamp(n, lo, hi, d) { const v = parseInt(n, 10); return Number.isFinite(v) ? Math.min(Math.max(v, lo), hi) : d; }
147 const siteName = (id) => SITE_NAMES[id] || String(id || '').replace(/(^|-)([a-z])/g, (m, d, c) => `${d ? ' ' : ''}${c.toUpperCase()}`);
148 function timeEl(iso) { const t = el('time', { datetime: iso, 'data-ov-ago': '' }, ago(iso)); t.title = new Date(iso).toLocaleString(); return t; }
149 function rocket() {
150 const ns = 'http://www.w3.org/2000/svg';
151 const svg = document.createElementNS(ns, 'svg');
152 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);
153 const p = document.createElementNS(ns, 'path');
154 p.setAttribute('d', ROCKET);
155 svg.appendChild(p);
156 return svg;
157 }
158
159 /** The registry id for a hostname (or an id already): openvibe.wiki → wiki, pdf.openvibe.tools → tools. */
160 function serviceFor(value) {
161 const v = String(value || '').toLowerCase().trim();
162 if (SITE_NAMES[v]) return v;
163 const h = v.replace(/:\d+$/, '').replace(/^www\./, '');
164 if (/(^|\.)openre\.stream$/.test(h)) return 'openre';
165 if (/(^|\.)openvibe\.tools$/.test(h)) return 'tools';
166 if (/(^|\.)openvibe\.network$/.test(h)) {
167 const sub = h.replace(/\.?openvibe\.network$/, '');
168 return SITE_NAMES[sub] && sub !== 'network' ? sub : 'network';
169 }
170 // The Services family lives on its subdomains (ai., bench.openvibe.services), like the Network's.
171 if (/(^|\.)openvibe\.services$/.test(h)) {
172 const sub = h.replace(/\.?openvibe\.services$/, '');
173 return SITE_NAMES[sub] && sub !== 'services' ? sub : 'services';
174 }
175 if (/(^|\.)open\.shoes$/.test(h)) return 'shoes';
176 const m = /(?:^|\.)openvibe\.([a-z]+)$/.exec(h);
177 return m && SITE_NAMES[m[1]] ? m[1] : null;
178 }
179 /** '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. */
180 function resolveService(s) {
181 const host = () => (typeof location !== 'undefined' ? serviceFor(location.hostname) : null);
182 if (s === 'auto') return host();
183 return s ? (serviceFor(s) || host()) : null;
184 }
185
186 // One request per feed URL for 30 s, shared by the footer line, the pill and the list on a page.
187 const _feeds = new Map();
188 function feed({ service = null, kind = null, limit = 5, before = null, api = null } = {}) {
189 const url = new URL(api || API);
190 if (service) url.searchParams.set('service', String(service));
191 if (kind) url.searchParams.set('kind', String(kind));
192 url.searchParams.set('limit', String(clamp(limit, 1, 100, 5)));
193 if (before) url.searchParams.set('before', String(before));
194 const key = url.toString();
195 const hit = _feeds.get(key);
196 if (hit && Date.now() - hit.at < 30000) return hit.promise;
197 const promise = fetch(key, { headers: { Accept: 'application/json' }, credentials: 'omit' })
198 .then((r) => (r.ok ? r.json() : null)).catch(() => null);
199 _feeds.set(key, { at: Date.now(), promise });
200 return promise;
201 }
202
203 // "5m ago" stays true while the page is open.
204 let _ticker = null;
205 function tick() {
206 if (_ticker || typeof setInterval === 'undefined' || typeof document === 'undefined') return;
207 _ticker = setInterval(() => {
208 for (const t of document.querySelectorAll('time[data-ov-ago]')) t.textContent = ago(t.getAttribute('datetime'));
209 }, 60000);
210 if (_ticker.unref) _ticker.unref();
211 }
212
213 // ── what a visitor reads ────────────────────────────────────
214 // A library pin bump ("openvibe-shared v4.1.0 (was v4.0.0) (#34)", opened by pin-bump.js on every repo at once)
215 // says nothing to a visitor: the one-liner and the short list pass over them, while the full log keeps every
216 // deployed commit. If nothing else shipped, the newest entry is shown anyway, so a site never looks dead.
217 const PIN_BUMP = /^openvibe-[a-z][a-z0-9-]* v\d+\.\d+\.\d+ \(was v\d+\.\d+\.\d+\)/;
218 function isHousekeeping(e) { return Boolean(e && (e.kind === 'internal' || PIN_BUMP.test(String(e.subject || '')))); }
219 function forVisitors(entries, n) {
220 const all = Array.isArray(entries) ? entries : [];
221 const told = all.filter((e) => !isHousekeeping(e));
222 return (told.length ? told : all).slice(0, n);
223 }
224
225 // ── latest: the one-liner ───────────────────────────────────
226 /** Render the newest entry into `node` (exported for tests). A node that is an <a> keeps its own href. */
227 function renderLatest(node, data, opts) {
228 const o = opts || {};
229 const e = forVisitors(data && data.entries, 1)[0] || null;
230 node.textContent = '';
231 if (!e || !e.subject) { node.hidden = true; return node; }
232 node.classList.add('ov-shipped-latest');
233 const href = localHref(o.href) || (node.tagName === 'A' && node.getAttribute('href')) || null;
234 if (node.tagName === 'A' && href) node.setAttribute('href', href);
235 node.appendChild(rocket());
236 node.appendChild(document.createTextNode(o.service ? ' shipped ' : ` ${siteName(e.service)} shipped `));
237 node.appendChild(timeEl(e.deployed_at));
238 node.appendChild(document.createTextNode(': '));
239 node.appendChild(el('b', {}, e.subject));
240 node.title = e.subject;
241 node.hidden = false;
242 if (node.style && node.style.display === 'none') node.style.display = '';
243 tick();
244 return node;
245 }
246 async function latest(target, opts) {
247 const o = { ...(opts || {}) };
248 const node = typeof target === 'string' ? document.querySelector(target) : target;
249 if (!node) return null;
250 o.service = resolveService(o.service);
251 style();
252 // A few more than one, so a run of pin bumps does not hide the newest real change.
253 return renderLatest(node, await feed({ service: o.service, limit: 12, api: o.api }), o);
254 }
255
256 // ── list: the newest few (the "What's new" card) ─────────────
257 // Opus's design (11-09, the owner: "show the latest blog post… rich content… with the image… elegant… use AI for
258 // tags or topics"): the site's newest devblog post leads with its picture, then the last changes as rows, each with
259 // what kind of change it is (New, Better, Fixed: Blog's blog.changelog.tag task), its plain words and up to two
260 // areas. Entries without those fields (before the task ran) read as before: their subject, no kind, no areas.
261 const KINDS = { new: 'New', better: 'Improved', fixed: 'Fixed' };
262 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; }
263 function areaChips(e, n) {
264 const areas = Array.isArray(e && e.areas) ? e.areas.filter((a) => typeof a === 'string' && a).slice(0, n) : [];
265 if (!areas.length) return null;
266 const box = el('span', { class: 'ov-shipped-areas' });
267 for (const a of areas) box.appendChild(el('span', {}, a));
268 return box;
269 }
270 function words(e) { return typeof (e && e.plain) === 'string' && e.plain.trim() ? e.plain.trim() : plainSubject(e); }
271 function devblogCard(post) {
272 const href = post && safeUrl(post.url);
273 if (!href || !post.title) return null;
274 const a = el('a', { class: 'ov-shipped-dev', href });
275 const fig = safeUrl(post.figure);
276 if (fig) { const img = el('img', { src: fig, alt: '', loading: 'lazy', decoding: 'async' }); a.appendChild(img); }
277 const text = el('span', { class: 'ov-shipped-dev-text' });
278 text.appendChild(el('small', {}, 'Devblog'));
279 text.appendChild(el('b', {}, String(post.title)));
280 if (post.summary) text.appendChild(el('span', {}, String(post.summary)));
281 a.appendChild(text);
282 return a;
283 }
284 /** Render a changelog answer into `node` (exported for tests). */
285 function render(node, data, opts) {
286 const o = opts || {};
287 const entries = forVisitors(data && data.entries, clamp(o.limit, 1, 20, 5));
288 const lead = devblogCard(data && data.latest_devblog);
289 if (typeof document !== 'undefined') style();
290 node.textContent = '';
291 if (!entries.length && !lead) { node.hidden = true; return node; }
292 node.classList.add('ov-shipped');
293 if (o.title !== '') node.appendChild(el('h3', {}, o.title || "What's new"));
294 if (lead) node.appendChild(lead);
295 const ul = el('ul', { class: 'ov-shipped-rows' });
296 const showSite = o.showSite != null ? o.showSite : !o.service;
297 for (const e of entries) {
298 const li = el('li', { class: 'ov-shipped-row' });
299 const k = kindTag(e); if (k) li.appendChild(k);
300 if (showSite) li.appendChild(el('span', { class: 'ov-shipped-site' }, siteName(e.service)));
301 const href = safeUrl(e.url);
302 const text = words(e);
303 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);
304 li.appendChild(a);
305 const chips = areaChips(e, 2); if (chips) li.appendChild(chips);
306 if (e.deployed_at) li.appendChild(timeEl(e.deployed_at));
307 ul.appendChild(li);
308 }
309 if (entries.length) node.appendChild(ul);
310 const links = el('div', { class: 'ov-shipped-links' });
311 const more = localHref(o.more);
312 if (more) links.appendChild(el('a', { href: more }, 'All updates →'));
313 const post = data.latest_post && safeUrl(data.latest_post.url) ? data.latest_post : null;
314 if (post) links.appendChild(el('a', { class: 'ov-shipped-notes', href: post.url }, `Patch notes: ${String(post.title || 'latest').replace(/^Patch notes:\s*/, '')} →`));
315 if (links.childNodes.length) node.appendChild(links);
316 node.hidden = false;
317 tick();
318 return node;
319 }
320 async function list(target, opts) {
321 const o = { ...(opts || {}) };
322 const node = typeof target === 'string' ? document.querySelector(target) : target;
323 if (!node) return null;
324 o.service = resolveService(o.service);
325 style();
326 return render(node, await feed({ service: o.service, limit: Math.min(50, clamp(o.limit, 1, 20, 5) * 3), api: o.api }), o);
327 }
328
329 // ── grouping (4.47.2): one row for the same change on many sites ──
330 // A pin round lands the same commit line on thirty sites within a minute, and a fleet fix ("Touch targets on
331 // phones") on a dozen: shown one by one they bury everything else. Entries of one day with the same change fold
332 // into one row: a pin bump by package and version, anything else by its subject without the PR number.
333 const PIN_KEY = /^(openvibe-[a-z][a-z0-9-]*) v(\d+\.\d+\.\d+)(?: \(was v\d+\.\d+\.\d+\))?/;
334 const PKG = { 'openvibe-shared': ['Shared', 'OpenVibe.Shared'], 'openvibe-sdk': ['SDK', 'OpenVibe.SDK'], 'openvibe-contracts': ['Contracts', 'OpenVibe.Contracts'] };
335 // The subject without its PR number, and without a leading "Play: " naming the entry's own site, so "Play: sign-in
336 // on …" and "Help: sign-in on …" are the same change.
337 const squash = (x) => String(x || '').replace(/[\s.-]/g, '').toLowerCase();
338 function plainSubject(e) {
339 let s = String((e && e.subject) || '').replace(/\s*\(#\d+\)\s*$/, '').trim();
340 const m = /^([A-Za-z][\w .-]{0,24}):\s+(.+)$/.exec(s);
341 if (m && e) {
342 const p = squash(m[1]).replace(/^openvibe/, '');
343 if (p && (p === squash(e.service) || p === squash(siteName(e.service)))) s = m[2].charAt(0).toUpperCase() + m[2].slice(1);
344 }
345 return s;
346 }
347 function pinOf(e) {
348 const m = PIN_KEY.exec(plainSubject(e));
349 if (!m) return null;
350 const k = PKG[m[1]] || [m[1].replace(/^openvibe-/, ''), null];
351 return { pkg: m[1], version: m[2], name: k[0], repo: k[1] };
352 }
353 function groupKey(e) { const p = pinOf(e); return p ? `pin:${p.pkg}@${p.version}` : `s:${plainSubject(e).toLowerCase()}`; }
354 /** Entries in their order, the same change on many sites folded into one group (exported: the server page uses it). */
355 function groupEntries(entries, dayKey) {
356 const out = [], at = new Map();
357 for (const e of entries || []) {
358 const k = `${dayKey ? dayKey(e) : ''}|${groupKey(e)}`;
359 const g = at.get(k);
360 if (g) g.entries.push(e);
361 else { const ng = { key: k, entries: [e], pin: pinOf(e) }; at.set(k, ng); out.push(ng); }
362 }
363 return out;
364 }
365 /** What a group's row says: "Shared 4.45.0 reached 31 sites", or the shared subject. */
366 function groupTitle(g) {
367 const sites = new Set(g.entries.map((e) => e.service)).size;
368 if (g.pin) return `${g.pin.name} ${g.pin.version} reached ${sites} ${sites === 1 ? 'site' : 'sites'}`;
369 return plainSubject(g.entries[0]);
370 }
371 function groupNotes(g) { return g.pin && g.pin.repo ? `https://github.com/OpenVibers/${g.pin.repo}/blob/v${g.pin.version}/CHANGELOG.md` : null; }
372
373 // ── log: the updates page ───────────────────────────────────
374 function dayOf(iso) { return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }); }
375 function entryRow(e, showSite) {
376 const row = el('div', { class: 'ov-shipped-entry' });
377 const sha = String(e.sha || '');
378 const repo = String(e.repo || '').split('/').pop();
379 const href = repo && /^[A-Za-z0-9_.-]+$/.test(repo) && /^[0-9a-f]{7,40}$/i.test(sha)
380 ? `https://git.openvibe.codes/OpenVibers/${repo}/commit/${sha}` : safeUrl(e.url);
381 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)));
382 if (showSite) row.appendChild(el('span', { class: 'ov-shipped-site' }, siteName(e.service)));
383 const text = el('span', { class: `ov-shipped-text${e.major ? ' ov-shipped-major' : ''}`, title: String(e.subject || '') });
384 const kind = kindTag(e);
385 if (kind) text.appendChild(kind);
386 text.appendChild(document.createTextNode(words(e)));
387 const areas = areaChips(e, 2);
388 if (areas) text.appendChild(areas);
389 row.appendChild(text);
390 if (e.kind) row.setAttribute('data-kind', String(e.kind));
391 const meta = el('span', { class: 'ov-shipped-meta' });
392 const time = new Date(e.deployed_at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
393 // Every commit is the OpenVibers account's: naming it on each row says nothing.
394 meta.textContent = `${e.author && e.author !== 'OpenVibers' ? `${e.author} · ` : ''}${time}`;
395 row.appendChild(meta);
396 return row;
397 }
398 const hhmm = (iso) => new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
399 function groupRow(g, open) {
400 const row = el('div', { class: 'ov-shipped-entry ov-shipped-group' });
401 const sites = [...new Set(g.entries.map((e) => e.service))];
402 row.appendChild(el('span', { class: 'ov-shipped-count', title: `${g.entries.length} changes` }, String(sites.length)));
403 const text = el('span', { class: 'ov-shipped-text' }, groupTitle(g));
404 const notes = groupNotes(g);
405 if (notes) { text.appendChild(document.createTextNode(' ')); text.appendChild(el('a', { class: 'ov-shipped-notes', href: notes, target: '_blank', rel: 'noopener' }, "What's in it")); }
406 row.appendChild(text);
407 const first = hhmm(g.entries[g.entries.length - 1].deployed_at), last = hhmm(g.entries[0].deployed_at);
408 row.appendChild(el('span', { class: 'ov-shipped-meta' }, first === last ? last : `${first}–${last}`));
409 const fold = el('details', { class: 'ov-shipped-fold' });
410 if (open) fold.open = true;
411 const names = sites.map(siteName);
412 fold.appendChild(el('summary', {}, names.length > 7 ? `${names.slice(0, 6).join(', ')} and ${names.length - 6} more` : names.join(', ')));
413 const list = el('div', { class: 'ov-shipped-entries' });
414 for (const e of g.entries) list.appendChild(entryRow(e, true));
415 fold.appendChild(list);
416 row.appendChild(fold);
417 return row;
418 }
419 /** Append entries under day headings, continuing the last day already shown (exported for tests). */
420 function appendDays(box, entries, showSite) {
421 let day = box.lastElementChild && box.lastElementChild.classList.contains('ov-shipped-day') ? box.lastElementChild : null;
422 for (const e of entries) {
423 const d = dayOf(e.deployed_at);
424 if (!day || day.getAttribute('data-day') !== d) {
425 day = el('section', { class: 'ov-shipped-day', 'data-day': d });
426 day.appendChild(el('h3', {}, d));
427 day.appendChild(el('div', { class: 'ov-shipped-entries' }));
428 box.appendChild(day);
429 }
430 // The same change again on this day: it joins that row (across "Load more" pages too).
431 if (!day._groups) day._groups = new Map();
432 const k = groupKey(e), hit = day._groups.get(k);
433 if (hit) {
434 hit.g.entries.push(e);
435 const det = hit.node.querySelector && hit.node.querySelector('details');
436 const fresh = groupRow(hit.g, Boolean(det && det.open));
437 hit.node.replaceWith(fresh);
438 hit.node = fresh;
439 } else if (e.kind === 'internal' && !pinOf(e)) {
440 let fold = day.querySelector(':scope > .ov-shipped-house');
441 if (!fold) {
442 fold = el('details', { class: 'ov-shipped-house' });
443 fold.appendChild(el('summary', {}, ''));
444 fold.appendChild(el('div', { class: 'ov-shipped-entries' }));
445 day.appendChild(fold);
446 }
447 fold.lastElementChild.appendChild(entryRow(e, showSite));
448 const n = fold.lastElementChild.children.length;
449 fold.firstElementChild.textContent = `${n} housekeeping ${n === 1 ? 'change' : 'changes'} (library updates and internal work)`;
450 } else {
451 const node = entryRow(e, showSite);
452 day._groups.set(k, { g: { key: k, entries: [e], pin: pinOf(e) }, node });
453 day.querySelector(':scope > .ov-shipped-entries').appendChild(node);
454 }
455 }
456 }
457 async function log(target, opts) {
458 const o = { ...(opts || {}) };
459 const node = typeof target === 'string' ? document.querySelector(target) : target;
460 if (!node) return null;
461 style();
462 const own = resolveService(o.service);
463 const pageSize = clamp(o.limit, 10, 100, 50);
464 const params = typeof location !== 'undefined' ? new URLSearchParams(location.search) : new URLSearchParams();
465 // ?site= picks the site on the whole-network page (and "all" on a site's own page).
466 let current = own;
467 const asked = params.get('site');
468 if (asked === 'all') current = null;
469 else if (asked && serviceFor(asked)) current = serviceFor(asked);
470
471 node.textContent = '';
472 node.classList.add('ov-shipped-log');
473 const bar = el('div', { class: 'ov-shipped-bar', role: 'group', 'aria-label': 'Which site' });
474 const kinds = el('div', { class: 'ov-shipped-bar ov-shipped-kinds', role: 'group', 'aria-label': 'Which kind of change' });
475 let kindNow = KINDS[params.get('kind')] ? params.get('kind') : '';
476 let facets = null;
477 const posts = el('aside', { class: 'ov-shipped-posts', hidden: '' });
478 const days = el('div', { class: 'ov-shipped-days' });
479 days.style.cssText = 'display:flex;flex-direction:column;gap:24px;min-width:0';
480 const more = el('button', { type: 'button', class: 'ov-shipped-more', hidden: '' }, 'Load more');
481 // The first load shows one "Loading…" line on its own (and a site's own chips, known up front), so the chips,
482 // the patch notes and the days arrive together instead of pushing a visible line down the page (layout shift).
483 const loading = el('p', { class: 'ov-shipped-empty' }, 'Loading…');
484 function drawKinds() {
485 kinds.textContent = '';
486 for (const [k, label] of [['', 'All changes'], ['new', 'New'], ['better', 'Improved'], ['fixed', 'Fixed']]) {
487 const count = k && facets && facets.kinds ? Number(facets.kinds[k]) || 0 : null;
488 const b = el('button', { type: 'button', class: 'ov-shipped-chip', 'aria-pressed': String(kindNow === k) }, label);
489 if (count != null) b.appendChild(el('span', { class: 'ov-shipped-n' }, ` ${count.toLocaleString()}`));
490 b.addEventListener('click', () => {
491 kindNow = k;
492 if (o.syncUrl !== false && typeof history !== 'undefined' && history.replaceState) {
493 const u = new URL(location.href);
494 if (k) u.searchParams.set('kind', k); else u.searchParams.delete('kind');
495 history.replaceState(history.state, '', u.pathname + u.search + u.hash);
496 }
497 drawKinds();
498 load(true);
499 });
500 kinds.appendChild(b);
501 }
502 }
503 drawKinds();
504 if (kindNow) days.setAttribute('data-kind', kindNow);
505 node.append(bar, kinds, posts, loading, days, more);
506 let next = null;
507 let seq = 0;
508
509 function setUrl(svc) {
510 if (o.syncUrl === false || typeof history === 'undefined' || !history.replaceState) return;
511 const u = new URL(location.href);
512 if (own ? svc === own : !svc) u.searchParams.delete('site'); else u.searchParams.set('site', svc || 'all');
513 history.replaceState(history.state, '', u.pathname + u.search + u.hash);
514 }
515 function chip(label, svc) {
516 const b = el('button', { type: 'button', class: 'ov-shipped-chip', 'aria-pressed': String((svc || null) === (current || null)) }, label);
517 b.addEventListener('click', () => { current = svc || null; setUrl(current); load(true); });
518 return b;
519 }
520 function drawBar(sites) {
521 bar.textContent = '';
522 if (own) { bar.append(chip(siteName(own), own), chip('All of OpenVibe', null)); return; }
523 bar.append(chip('Everything', null));
524 for (const s of (sites || []).slice(0, 30)) bar.appendChild(chip(siteName(s.service), s.service));
525 }
526 function drawPosts(list) {
527 posts.textContent = '';
528 const good = (list || []).filter((p) => safeUrl(p.url)).slice(0, 5);
529 if (!good.length || o.posts === false) { posts.hidden = true; return; }
530 posts.appendChild(el('h3', {}, 'Patch notes'));
531 const ul = el('ul');
532 for (const p of good) {
533 const li = el('li');
534 li.appendChild(el('a', { href: p.url }, p.title || 'Patch notes'));
535 if (p.published_at) li.appendChild(el('small', {}, `${new Date(p.published_at).toLocaleDateString()}${p.entries ? ` · ${p.entries} changes` : ''}`));
536 ul.appendChild(li);
537 }
538 posts.appendChild(ul);
539 posts.hidden = false;
540 }
541 let sitesSeen = null;
542 async function load(reset) {
543 const mine = ++seq;
544 if (reset) {
545 next = null;
546 days.textContent = '';
547 if (!loading.parentNode) days.appendChild(el('p', { class: 'ov-shipped-empty' }, 'Loading…'));
548 for (const b of bar.querySelectorAll('.ov-shipped-chip')) b.setAttribute('aria-pressed', 'false');
549 }
550 more.disabled = true;
551 const data = await feed({ service: current, kind: kindNow || null, limit: pageSize, before: reset ? null : next, api: o.api });
552 if (mine !== seq) return;
553 if (reset) {
554 days.textContent = '';
555 loading.remove();
556 if (data && data.sites) sitesSeen = data.sites;
557 if (!own && !sitesSeen) {
558 const all = await feed({ service: null, limit: 1, api: o.api });
559 sitesSeen = all && all.sites;
560 }
561 drawBar(sitesSeen);
562 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');
563 drawPosts(data && data.posts);
564 facets = data.facets || null;
565 drawKinds();
566 }
567 if (!data) {
568 if (reset) days.appendChild(el('p', { class: 'ov-shipped-empty' }, 'The update history could not be loaded just now.'));
569 more.hidden = true;
570 return;
571 }
572 const entries = Array.isArray(data.entries) ? data.entries : [];
573 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.'));
574 appendDays(days, entries, !current);
575 next = data.next || null;
576 more.hidden = !next;
577 more.disabled = false;
578 tick();
579 }
580 more.addEventListener('click', () => load(false));
581 if (own) drawBar(null);
582 await load(true);
583 return node;
584 }
585
586 // ── markup ──────────────────────────────────────────────────
587 const VIEWS = { latest, list, log };
588 /** Mount every [data-ov-shipped] element under `root` once. */
589 function scan(rootEl) {
590 const r = rootEl || (typeof document !== 'undefined' ? document : null);
591 if (!r || !r.querySelectorAll) return [];
592 const out = [];
593 for (const n of r.querySelectorAll('[data-ov-shipped]')) {
594 if (n.hasAttribute('data-ov-shipped-mounted')) continue;
595 const view = VIEWS[n.getAttribute('data-ov-shipped')];
596 if (!view) continue;
597 n.setAttribute('data-ov-shipped-mounted', '');
598 const d = n.dataset || {};
599 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));
600 }
601 return out;
602 }
603
604 return { latest, list, mount: list, log, scan, render, renderLatest, appendDays, groupEntries, groupTitle, groupNotes, serviceFor, ago, API, SITE_NAMES, siteName, isHousekeeping };
605}));
606