openvibers/openvibe.shared Commits
Shared 4.0.0: a share card for every site (openvibe-shared/social, og:image by default) (#62)
Changes
@@@ -4,8 +4,14 @@ 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).--## Unreleased--++## 4.0.0 — 2026-10-11++++- **Share cards for every site** (`openvibe-shared/social`, new): a 1200×630 card per service in++ `social/<service>.jpg` (about 60 KB each), drawn by `scripts/build-social-cards.js` from the Contracts service++ manifests' `site` blocks (name, tagline, what, first domain, accent) with the app-icon mark and embedded Inter.++ `serve.js` answers them at `/shared/social/<service>.jpg?v=<hash>` beside the browser files, and `shell.page()` uses++ the site's card for `og:image`/`twitter:image` (with `summary_large_image`) when the page names no image and has a++ canonical; `image: null` opts out. `social.url(service)` gives the URL to pages that build their own head.- **Breaking (for a caller relying on it):** `middleware.js` `extractToken` no longer reads Live's legacy `token`cookie: only the bearer, `ov_token` and the WebSocket query parameter. Live stopped setting that cookie and Chatstopped reading it (compatibility register C-05). No OpenVibers service imports this module today.@@@ -20,9 +20,9 @@ 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`, `notification-live.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`, `web-runtime.js`, `boost.js`, `openvibe-sw.js` |--| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,pg,retention,event}`), `app-icon`, `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `shell` (the server-rendered page shell: `page(o)` composes the doctype and `<head>` from `seo.headTags`/`seo.pageSummary`, the theme-loader before the first paint, `web-runtime`/`navbar`/`footer` through `serve.url`, the `frame.noscriptNav` no-JS nav, the body and `frame.footer`; `scripts(o)` is just the head scripts and the escaped runtime boot JSON), `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`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |++| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,pg,retention,event}`), `app-icon`, `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `social` (every site's 1200×630 share card: `url(service)`, served by `serve.js` at `/shared/social/<service>.jpg` and used by `shell.page()` for `og:image` when a page names none), `shell` (the server-rendered page shell: `page(o)` composes the doctype and `<head>` from `seo.headTags`/`seo.pageSummary`, the theme-loader before the first paint, `web-runtime`/`navbar`/`footer` through `serve.url`, the `frame.noscriptNav` no-JS nav, the body and `frame.footer`; `scripts(o)` is just the head scripts and the escaped runtime boot JSON), `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`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` || Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |--| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |++| 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`, `scripts/build-social-cards.js` (share cards from the Contracts manifests; Chrome + ImageMagick) || Pin bot | `scripts/pin-bump.js` (`node scripts/pin-bump.js <library> <tag> [--repos A,B] [--apply]`): after a release, one PR per repository that pins the library below the tag, with the lockfile refreshed and the docs-status fields rewritten; a dry run unless `--apply`; CI tests each PR || Checks (CI) | `scripts/pin-drift.js` (every openvibe-* pin is a published tag and not more than one minor behind, and openvibe-shared is installed once), `scripts/docs-status.js` (`node scripts/docs-status.js <dir> [--check]`: STATUS.json's contracts/sdk/shared fields and the README `<!-- versions:start/end -->` block must name the pinned releases; `--check` exits 1 on drift) and `scripts/floating-promises.js` (`node scripts/floating-promises.js <dir> [--json]`: an async call whose result nobody awaits — a failure is an unhandled rejection and a transaction commits without it — unless awaited, returned, assigned, `void`-ed, chained with `.then(`/`.catch(`/`.finally(` or opted out with `// floating-ok: <reason>`; exit 1 when found) |@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "3.0.1",++ "version": "4.0.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "3.0.1",++ "version": "4.0.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -57,6 +57,7 @@"./serve": "./serve.js","./shell": "./shell.js","./showcase": "./showcase.js",++ "./social": "./social.js","./shipped": "./shipped.js","./sso-client": "./sso-client.js","./test-runner": "./test-runner.js",@@@ -77,6 +78,7 @@"analytics/","docs/schemas/","scripts/",++ "social/","templates/","CHANGELOG.md"],nnew file mode 100644@@@ -0,0 +1,130 @@++#!/usr/bin/env node++'use strict';++// Render the share card (og:image, 1200×630 JPEG) of every OpenVibe site into social/<service>.jpg.++// node scripts/build-social-cards.js --contracts <OpenVibe.Contracts checkout> [--chrome <path>] [--only <id,id>]++// Needs Chrome (the drawing) and ImageMagick's `convert` (PNG → a ~60 KB progressive JPEG).++// The name, tagline, domain and accent come from each service manifest's `site` block (Contracts is the source of++// truth; this script only draws them). The mark is openvibe-shared/app-icon. Inter is fetched once from Google Fonts++// and embedded, so a card never depends on the fonts of the machine that rendered it. Re-run after a tagline,++// accent or domain changes, and commit the PNGs: sites serve them from their pinned copy (openvibe-shared/serve).++const fs = require('fs');++const os = require('os');++const path = require('path');++const https = require('https');++const { execFileSync } = require('child_process');++const icon = require('../app-icon');++const social = require('../social');++++const arg = (name, dflt) => { const i = process.argv.indexOf(`--${name}`); return i > 0 ? process.argv[i + 1] : dflt; };++const contracts = arg('contracts');++const chrome = arg('chrome', '/usr/bin/google-chrome');++const only = arg('only') ? new Set(arg('only').split(',')) : null;++if (!contracts) { console.error('usage: build-social-cards.js --contracts <OpenVibe.Contracts checkout> [--chrome <path>] [--only <ids>]'); process.exit(1); }++++const OUT = path.join(__dirname, '..', 'social');++const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');++++function get(url, headers = {}) {++ return new Promise((resolve, reject) => {++ https.get(url, { headers }, (res) => {++ if (res.statusCode !== 200) { res.resume(); return reject(new Error(`${url}: ${res.statusCode}`)); }++ const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => resolve(Buffer.concat(chunks)));++ }).on('error', reject);++ });++}++++async function interFaces() {++ // A desktop user agent gets woff2; each weight's latin subset is embedded as a data: URL.++ const css = (await get('https://fonts.googleapis.com/css2?family=Inter:wght@500;600;800&display=block',++ { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36' })).toString();++ const out = [];++ for (const [, block] of css.matchAll(/\/\* latin \*\/\s*@font-face\s*\{([^}]*)\}/g)) {++ const weight = /font-weight:\s*(\d+)/.exec(block)[1];++ const url = /url\((https:[^)]+\.woff2)\)/.exec(block)[1];++ const data = (await get(url)).toString('base64');++ out.push(`@font-face{font-family:'Inter';font-style:normal;font-weight:${weight};font-display:block;src:url(data:font/woff2;base64,${data}) format('woff2')}`);++ }++ if (!out.length) throw new Error('no latin Inter faces in the Google Fonts CSS');++ return out.join('\n');++}++++function sites() {++ const dir = path.join(contracts, 'manifests', 'services');++ const out = [];++ for (const f of fs.readdirSync(dir).filter((n) => n.endsWith('.json')).sort()) {++ const m = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));++ if (!m.site || !Array.isArray(m.domains) || !m.domains.length) continue;++ if (only && !only.has(m.id)) continue;++ out.push({ id: m.id, name: m.site.name, tagline: m.site.tagline, what: m.site.what || '', domain: m.domains[0], accent: social.accentOf(m.id, m.site.accent) });++ }++ return out;++}++++function card(site, fonts) {++ const [a1, a2] = site.accent;++ // The product name after "OpenVibe.", unless the product carries its own name: OpenRestream reads "OpenRe" +++ // "stream" as one word, the way the navbar draws it.++ const own = /^Open(Re)(.+)$/.exec(site.name);++ const brand = own++ ? `<span class="w">Open${own[1]}</span><span class="p">${esc(own[2])}</span>`++ : `<span class="w">OpenVibe</span><span class="d">.</span><span class="p">${esc(site.name)}</span>`;++ const long = site.name.length > 9;++ return `<!doctype html><html><head><meta charset="utf-8"><style>++${fonts}++*{margin:0;padding:0;box-sizing:border-box}++html,body{width:1200px;height:630px;overflow:hidden}++body{font-family:'Inter',sans-serif;color:#f8fafc;background:#0a0f1c;position:relative;-webkit-font-smoothing:antialiased}++.glow{position:absolute;inset:0;background:++ radial-gradient(760px 520px at 88% -8%, ${a1}38, transparent 62%),++ radial-gradient(620px 460px at -6% 112%, ${a2}2e, transparent 64%)}++.grid{position:absolute;inset:0;background-image:radial-gradient(#ffffff14 1.2px, transparent 1.3px);background-size:28px 28px;++ -webkit-mask-image:linear-gradient(115deg, transparent 30%, #000 75%);mask-image:linear-gradient(115deg, transparent 30%, #000 75%)}++.ring{position:absolute;right:-150px;top:50%;width:620px;height:620px;margin-top:-310px;border-radius:50%;++ border:44px solid ${a1};opacity:.09}++.ring2{position:absolute;right:-30px;top:50%;width:380px;height:380px;margin-top:-190px;border-radius:50%;border:2px solid ${a1};opacity:.16}++.wrap{position:absolute;inset:0;padding:60px 84px 60px;display:flex;flex-direction:column}++.top{display:flex;align-items:center;gap:22px}++.icon{width:88px;height:88px;border-radius:22px;overflow:hidden;box-shadow:0 18px 50px -12px ${a2}88, 0 0 0 1px #ffffff1a}++.icon svg{width:88px;height:88px;display:block}++.net{font-size:26px;font-weight:600;color:#94a3b8;letter-spacing:.01em}++.name{margin-top:36px;font-size:${long ? 92 : 108}px;font-weight:800;letter-spacing:-.035em;line-height:1}++.name .w{color:#f8fafc}.name .d{color:${a1}}++.name .p{background:linear-gradient(100deg, ${a1}, ${a2});-webkit-background-clip:text;background-clip:text;color:transparent}++.tag{margin-top:22px;max-width:900px;font-size:42px;font-weight:600;line-height:1.18;color:#cbd5e1;letter-spacing:-.012em;++ display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}++.what{margin-top:18px;max-width:900px;font-size:27px;font-weight:500;line-height:1.38;color:#8b9ab3;++ display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}++.foot{margin-top:auto;display:flex;align-items:center;justify-content:space-between}++.dom{display:inline-flex;align-items:center;gap:14px;font-size:30px;font-weight:600;color:#e2e8f0;++ padding:12px 24px 12px 18px;border-radius:999px;background:#ffffff0d;box-shadow:inset 0 0 0 1.5px ${a1}55}++.dom i{width:14px;height:14px;border-radius:50%;background:${a1};box-shadow:0 0 18px ${a1}}++.part{font-size:24px;font-weight:500;color:#64748b}++</style></head><body>++<div class="glow"></div><div class="grid"></div><div class="ring"></div><div class="ring2"></div>++<div class="wrap">++ <div class="top"><div class="icon">${icon.svg({ site: social.iconSite(site.id), maskable: false })}</div><div class="net">One account. All of OpenVibe.</div></div>++ <div class="name">${brand}</div>++ <div class="tag">${esc(site.tagline)}</div>++ ${site.what ? `<div class="what">${esc(site.what)}</div>` : ''}++ <div class="foot"><span class="dom"><i></i>${esc(site.domain)}</span><span class="part">Open source · part of the OpenVibe network</span></div>++</div></body></html>`;++}++++(async () => {++ fs.mkdirSync(OUT, { recursive: true });++ const fonts = await interFaces();++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-social-'));++ const list = sites();++ for (const site of list) {++ const html = path.join(tmp, `${site.id}.html`);++ fs.writeFileSync(html, card(site, fonts));++ const png = path.join(tmp, `${site.id}.png`);++ const jpg = path.join(OUT, `${site.id}.jpg`);++ execFileSync(chrome, ['--headless=new', '--disable-gpu', '--hide-scrollbars', '--force-device-scale-factor=1',++ '--window-size=1200,630', '--virtual-time-budget=3000', `--user-data-dir=${path.join(tmp, 'profile')}`,++ `--screenshot=${png}`, `file://${html}`], { stdio: 'ignore' });++ execFileSync('convert', [png, '-strip', '-quality', '82', '-sampling-factor', '4:2:0', '-interlace', 'JPEG', jpg]);++ console.log(`${site.id}: ${path.relative(process.cwd(), jpg)} (${Math.round(fs.statSync(jpg).size / 1024)} KB)`);++ }++ fs.rmSync(tmp, { recursive: true, force: true });++})().catch((e) => { console.error(e); process.exit(1); });@@@ -11,16 +11,19 @@** A request carrying the file's current hash (?v=) is cached for a year (immutable); anything else* for five minutes. ETag = the hash (304 on a match). CORS-open and cross-origin readable, like the-- * copies on openvibe.network. Only the files openvibe-shared/files lists are served.++ * copies on openvibe.network. Only the files openvibe-shared/files lists are served, and the share cards++ * (social/<service>.jpg, openvibe-shared/social) that og:image points at.*/const fs = require('fs');const files = require('./files');++const social = require('./social');const cache = require('./cache-policy');const { hashAsset } = require('./assets');const hashes = new Map();++const fileOf = (name) => (social.isCard(name) ? social.path(name) : files.path(name));function hashOf(name) {-- if (!hashes.has(name)) hashes.set(name, hashAsset(fs.readFileSync(files.path(name))));++ if (!hashes.has(name)) hashes.set(name, hashAsset(fs.readFileSync(fileOf(name))));return hashes.get(name);}@@@ -35,10 +38,10 @@ function handler() {return function openvibeShared(req, res, next) {if (req.method !== 'GET' && req.method !== 'HEAD') return next();const name = String(req.path || '').replace(/^\/+/, '');-- if (!files.isBrowserFile(name)) return next();++ if (!files.isBrowserFile(name) && !social.isCard(name)) return next();const hash = hashOf(name);const v = req.query ? req.query.v : new URL(req.url, 'http://x').searchParams.get('v');-- res.setHeader('Content-Type', name.endsWith('.css') ? 'text/css; charset=utf-8' : 'application/javascript; charset=utf-8');++ res.setHeader('Content-Type', name.endsWith('.css') ? 'text/css; charset=utf-8' : name.endsWith('.jpg') ? 'image/jpeg' : 'application/javascript; charset=utf-8');// The estate default is a day's stale-while-revalidate (cache-policy); serve.js has always used a minute,// because a stale shared script is the one thing a site's navbar must never run. Kept as it was.res.setHeader('Cache-Control', cache.assetHeaders(name, { hashed: v === hash, swr: 60 }));@@@ -48,7 +51,7 @@ function handler() {res.setHeader('X-Content-Type-Options', 'nosniff');if (req.headers['if-none-match'] === `"${hash}"`) { res.statusCode = 304; return res.end(); }if (req.method === 'HEAD') return res.end();-- fs.createReadStream(files.path(name)).on('error', next).pipe(res);++ fs.createReadStream(fileOf(name)).on('error', next).pipe(res);};}@@@ -28,6 +28,7 @@const seo = require('./seo');const frame = require('./frame');const serve = require('./serve');++const social = require('./social');const esc = seo.esc;@@@ -83,6 +84,12 @@ function scripts(o = {}) {* footer `frame.footer` config; defaults to { service: name, variant: 'compact' }* navbar, features, routes, versions the runtime boot (see `scripts`)*/++// A page that names no image shares its site's card (openvibe-shared/social), when the site has one and the page++// has a canonical URL to make it absolute against; `image: null` opts out.++function defaultImage(o) {++ return o.canonical ? social.url(o.service) : null;++}++function page(o = {}) {const name = o.name || o.service || 'OpenVibe';const title = o.title || name;@@@ -90,7 +97,7 @@ function page(o = {}) {: seo.pageSummary({ title, summary: o.summary, facts: o.facts, url: o.url || o.canonical, updated: o.updated });const head = seo.headTags({title, siteName: o.siteName || name, description: o.description,-- canonical: o.canonical, image: o.image, imageAlt: o.imageAlt, type: o.type,++ canonical: o.canonical, image: o.image === undefined ? defaultImage(o) : o.image, imageAlt: o.imageAlt || (o.image === undefined && defaultImage(o) ? o.siteName || name : undefined), type: o.type,robots: o.robots, keywords: o.keywords, author: o.author, themeColor: o.themeColor,twitterSite: o.twitterSite, locale: o.locale, titleSuffix: o.titleSuffix,alternates: o.alternates, jsonLd: o.jsonLd,nnew file mode 100644@@@ -0,0 +1,67 @@++'use strict';++// ═══════════════════════════════════════════════════════════════++// openvibe-shared/social — every OpenVibe site's share card (og:image / twitter:image).++//++// The cards are 1200×630 JPEGs in social/<service>.jpg, drawn by scripts/build-social-cards.js from the service++// manifests' `site` blocks (name, tagline, first domain, accent) with the app-icon mark. A site serves its pinned++// copy at /shared/social/<service>.jpg through openvibe-shared/serve, so a card never depends on another host.++//++// social.has(service) is there a card for this service id++// social.url(service, base?) '/shared/social/<service>.jpg?v=<content hash>', or null without a card++// social.isCard(name) 'social/<id>.jpg' names serve.js may answer++// social.path(name) the file on disk (throws for anything that is not a card)++// social.accentOf(service, accent?) the two accent stops the card uses (build script)++// social.iconSite(service) the app-icon variant for the service's mark (build script)++//++// shell.page() uses social.url(o.service) for og:image when the page names no image of its own.++// ═══════════════════════════════════════════════════════════════++const fs = require('fs');++const path = require('path');++const { hashAsset } = require('./assets');++const icon = require('./app-icon');++++const DIR = path.join(__dirname, 'social');++const NAME_RE = /^social\/([a-z0-9][a-z0-9-]{0,40})\.jpg$/;++const ID_RE = /^[a-z0-9][a-z0-9-]{0,40}$/;++const hashes = new Map();++++function isCard(name) {++ const m = NAME_RE.exec(String(name || ''));++ return Boolean(m) && fs.existsSync(path.join(DIR, `${m[1]}.jpg`));++}++++function cardPath(name) {++ if (!isCard(name)) throw new Error(`openvibe-shared/social: ${name} is not a share card`);++ return path.join(__dirname, name);++}++++const has = (service) => ID_RE.test(String(service || '')) && isCard(`social/${service}.jpg`);++++function url(service, base = '/shared') {++ if (!has(service)) return null;++ const name = `social/${service}.jpg`;++ if (!hashes.has(name)) hashes.set(name, hashAsset(fs.readFileSync(cardPath(name))));++ return `${String(base).replace(/\/+$/, '')}/${name}?v=${hashes.get(name)}`;++}++++// The mark's tint: app-icon has variants for a few sites; the rest wear the network's.++const iconSite = (service) => (Object.prototype.hasOwnProperty.call(icon.SITES, service) ? service : service === 'media-hub' ? 'media' : 'network');++++/** A deeper stop of the same hue (lightness down 16 points, saturation up 6), so a gradient never turns muddy. */++function deepen(hex) {++ const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);++ const max = Math.max(r, g, b), min = Math.min(r, g, b), l = (max + min) / 2, d = max - min;++ const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));++ const h = d === 0 ? 0 : max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;++ const L = Math.max(0, l - 0.16), S = Math.min(1, s + 0.06);++ const c = (1 - Math.abs(2 * L - 1)) * S, x = c * (1 - Math.abs(((h % 6) + 6) % 2 - 1)), m = L - c / 2;++ const [R, G, B] = h < 1 ? [c, x, 0] : h < 2 ? [x, c, 0] : h < 3 ? [0, c, x] : h < 4 ? [0, x, c] : h < 5 ? [x, 0, c] : [c, 0, x];++ return `#${[R, G, B].map((v) => Math.round((v + m) * 255).toString(16).padStart(2, '0')).join('')}`;++}++++/** [light, deep] accent stops: the manifest's accent and a deeper stop of it, else the app-icon variant's. */++function accentOf(service, accent) {++ return /^#[0-9a-f]{6}$/i.test(accent || '') ? [accent.toLowerCase(), deepen(accent)] : icon.SITES[iconSite(service)].accent;++}++++module.exports = { has, url, isCard, path: cardPath, accentOf, iconSite, DIR };nnew file mode 100644BBinary files /dev/null and b/social/actor.jpg differnnew file mode 100644BBinary files /dev/null and b/social/ai.jpg differnnew file mode 100644BBinary files /dev/null and b/social/blog.jpg differnnew file mode 100644BBinary files /dev/null and b/social/bot.jpg differnnew file mode 100644BBinary files /dev/null and b/social/chat.jpg differnnew file mode 100644BBinary files /dev/null and b/social/codes.jpg differnnew file mode 100644BBinary files /dev/null and b/social/community.jpg differnnew file mode 100644BBinary files /dev/null and b/social/coupons.jpg differnnew file mode 100644BBinary files /dev/null and b/social/deals.jpg differnnew file mode 100644BBinary files /dev/null and b/social/events.jpg differnnew file mode 100644BBinary files /dev/null and b/social/food.jpg differnnew file mode 100644BBinary files /dev/null and b/social/games.jpg differnnew file mode 100644BBinary files /dev/null and b/social/help.jpg differnnew file mode 100644BBinary files /dev/null and b/social/host.jpg differnnew file mode 100644BBinary files /dev/null and b/social/inventory.jpg differnnew file mode 100644BBinary files /dev/null and b/social/live.jpg differnnew file mode 100644BBinary files /dev/null and b/social/media-hub.jpg differnnew file mode 100644BBinary files /dev/null and b/social/media.jpg differnnew file mode 100644BBinary files /dev/null and b/social/network.jpg differnnew file mode 100644BBinary files /dev/null and b/social/news.jpg differnnew file mode 100644BBinary files /dev/null and b/social/openre.jpg differnnew file mode 100644BBinary files /dev/null and b/social/quest.jpg differnnew file mode 100644BBinary files /dev/null and b/social/rent.jpg differnnew file mode 100644BBinary files /dev/null and b/social/reviews.jpg differnnew file mode 100644BBinary files /dev/null and b/social/search.jpg differnnew file mode 100644BBinary files /dev/null and b/social/services.jpg differnnew file mode 100644BBinary files /dev/null and b/social/space.jpg differnnew file mode 100644BBinary files /dev/null and b/social/tips.jpg differnnew file mode 100644BBinary files /dev/null and b/social/tools.jpg differnnew file mode 100644BBinary files /dev/null and b/social/trade.jpg differnnew file mode 100644BBinary files /dev/null and b/social/vip.jpg differnnew file mode 100644BBinary files /dev/null and b/social/watch.jpg differnnew file mode 100644BBinary files /dev/null and b/social/wiki.jpg differnnew file mode 100644BBinary files /dev/null and b/social/work.jpg differnnew file mode 100644@@@ -0,0 +1,85 @@++'use strict';++// openvibe-shared/social: every site's share card, served from the site's pinned copy and used for og:image.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const http = require('http');++const express = require('express');++const social = require('../social');++const serve = require('../serve');++const shell = require('../shell');++++/** Width and height from a JPEG's first SOF marker. */++function jpegSize(buf) {++ assert.strictEqual(buf.readUInt16BE(0), 0xffd8, 'starts with SOI');++ let i = 2;++ while (i < buf.length) {++ const marker = buf.readUInt16BE(i);++ const len = buf.readUInt16BE(i + 2);++ if (marker >= 0xffc0 && marker <= 0xffcf && ![0xffc4, 0xffc8, 0xffcc].includes(marker)) {++ return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };++ }++ i += 2 + len;++ }++ throw new Error('no SOF marker');++}++++(async () => {++ const cards = fs.readdirSync(social.DIR);++ assert.ok(cards.length >= 30, `a card for every site (${cards.length})`);++ for (const f of cards) {++ assert.match(f, /^[a-z0-9-]+\.jpg$/, `${f}: only service-id JPEGs live in social/`);++ const buf = fs.readFileSync(path.join(social.DIR, f));++ assert.deepStrictEqual(jpegSize(buf), { width: 1200, height: 630 }, `${f} is 1200×630`);++ assert.ok(buf.length <= 120 * 1024, `${f} stays small (${Math.round(buf.length / 1024)} KB)`);++ }++++ assert.ok(social.has('news') && social.has('openre') && social.has('network'));++ assert.ok(!social.has('nope') && !social.has('../news') && !social.has('') && !social.has(null));++ assert.match(social.url('news'), /^\/shared\/social\/news\.jpg\?v=[0-9a-f]{12}$/);++ assert.match(social.url('news', 'https://cdn.example/s/'), /^https:\/\/cdn\.example\/s\/social\/news\.jpg\?v=/);++ assert.strictEqual(social.url('nope'), null);++ for (const bad of ['social/news.png', 'social/../package.json', 'social/News.jpg', 'news.jpg', 'social/a/b.jpg']) {++ assert.ok(!social.isCard(bad), `${bad} is not a card`);++ }++ assert.throws(() => social.path('social/../serve.js'));++++ // The accent stops: a manifest accent keeps its hue (a deeper stop of it), never the network blue.++ const [light, deep] = social.accentOf('news', '#fb923c');++ assert.strictEqual(light, '#fb923c');++ assert.notStrictEqual(deep, '#2563eb');++ assert.ok(parseInt(deep.slice(1, 3), 16) > parseInt(deep.slice(5, 7), 16), 'still orange: more red than blue');++ assert.deepStrictEqual(social.accentOf('live'), require('../app-icon').SITES.live.accent);++++ // serve.js answers the cards beside the browser files, content-addressed.++ const app = express();++ app.use('/shared', serve.handler());++ app.use((req, res) => res.status(404).end('nope'));++ const srv = http.createServer(app);++ await new Promise((r) => srv.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${srv.address().port}`;++ try {++ const r = await fetch(base + social.url('wiki'));++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('content-type'), 'image/jpeg');++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=31536000, immutable');++ assert.strictEqual(Buffer.from(await r.arrayBuffer()).length, fs.statSync(path.join(social.DIR, 'wiki.jpg')).size);++ for (const bad of ['/shared/social/nope.jpg', '/shared/social/wiki.png', '/shared/social/../serve.js', '/shared/social/%2e%2e/serve.js']) {++ assert.strictEqual((await fetch(base + bad)).status, 404, `${bad} is not served`);++ }++ } finally {++ srv.close();++ }++++ // shell.page: no image named → the site's card, absolute against the canonical; an explicit image wins;++ // image: null shares none; no canonical → no default.++ const html = shell.page({ service: 'news', name: 'OpenVibe.News', canonical: 'https://openvibe.news/', description: 'd' });++ assert.match(html, /<meta property="og:image" content="https:\/\/openvibe\.news\/shared\/social\/news\.jpg\?v=[0-9a-f]{12}">/);++ assert.match(html, /<meta property="og:image:alt" content="OpenVibe\.News">/);++ assert.match(html, /<meta name="twitter:card" content="summary_large_image">/);++ assert.match(shell.page({ service: 'news', canonical: 'https://openvibe.news/', image: '/cover.png' }), /og:image" content="https:\/\/openvibe\.news\/cover\.png"/);++ assert.ok(!/og:image/.test(shell.page({ service: 'news', canonical: 'https://openvibe.news/', image: null })));++ assert.ok(!/og:image/.test(shell.page({ service: 'news' })), 'no canonical, no relative og:image');++ assert.ok(!/og:image/.test(shell.page({ service: 'no-such-site', canonical: 'https://x.example/' })));++ console.log('social: ok');++})().catch((e) => { console.error(e); process.exit(1); });
@@ -4,8 +4,14 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,8 +4,14 @@ 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).
-## Unreleased
-
+## 4.0.0 — 2026-10-11
+
+- **Share cards for every site** (`openvibe-shared/social`, new): a 1200×630 card per service in
+ `social/<service>.jpg` (about 60 KB each), drawn by `scripts/build-social-cards.js` from the Contracts service
+ manifests' `site` blocks (name, tagline, what, first domain, accent) with the app-icon mark and embedded Inter.
+ `serve.js` answers them at `/shared/social/<service>.jpg?v=<hash>` beside the browser files, and `shell.page()` uses
+ the site's card for `og:image`/`twitter:image` (with `summary_large_image`) when the page names no image and has a
+ canonical; `image: null` opts out. `social.url(service)` gives the URL to pages that build their own head.
- **Breaking (for a caller relying on it):** `middleware.js` `extractToken` no longer reads Live's legacy `token`
- **Breaking (for a caller relying on it):** `middleware.js` `extractToken` no longer reads Live's legacy `token`
cookie: only the bearer, `ov_token` and the WebSocket query parameter. Live stopped setting that cookie and Chat
cookie: only the bearer, `ov_token` and the WebSocket query parameter. Live stopped setting that cookie and Chat
stopped reading it (compatibility register C-05). No OpenVibers service imports this module today.
stopped reading it (compatibility register C-05). No OpenVibers service imports this module today.
@@ -20,9 +20,9 @@ were reconciled first.
@@ -20,9 +20,9 @@ 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`, `notification-live.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`, `web-runtime.js`, `boost.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`, `notification-live.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`, `web-runtime.js`, `boost.js`, `openvibe-sw.js` |
-| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,pg,retention,event}`), `app-icon`, `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `shell` (the server-rendered page shell: `page(o)` composes the doctype and `<head>` from `seo.headTags`/`seo.pageSummary`, the theme-loader before the first paint, `web-runtime`/`navbar`/`footer` through `serve.url`, the `frame.noscriptNav` no-JS nav, the body and `frame.footer`; `scripts(o)` is just the head scripts and the escaped runtime boot JSON), `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`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
+| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,pg,retention,event}`), `app-icon`, `assets` (content-addressed image/asset URLs and responsive `<picture>` markup: `hash(bytes)`, `url(src, hash)`, `srcset(entries)`, `picture({ src, hash?, alt, width, height, sizes, sources })` — AVIF before WebP before the fallback `<img>`, an unhashed path kept exactly as given), `auth-client`, `brand`, `builtin-themes`, `cache-policy` (the one cache rule: a content-addressed asset — `?v=<hash>` or a hashed filename — is `public, max-age=31536000, immutable`, anything else is `public, max-age=300, stale-while-revalidate=86400`, HTML is `public, max-age=120, stale-while-revalidate=3600` and a personalised page is `private, no-store`; `assetHeaders`/`htmlHeaders`/`isHashed` plus an `applyHtml()` middleware, used by `serve.js`), `indexnow` (tell engines a page changed: `createIndexNow({ host, key, keyLocation?, endpoint?, fetch, log })` → `keyFile` for `/<key>.txt`, `ping(urls)` for one POST of a deduped, https-only, own-host batch of ≤10,000 URLs that never throws, and `pingSoon(urls)` debounced into one batch in 30s; an unset key sends nothing), `frame` (the OpenVibe Frame on the server), `legal`, `middleware`, `notifications`, `seo`, `social` (every site's 1200×630 share card: `url(service)`, served by `serve.js` at `/shared/social/<service>.jpg` and used by `shell.page()` for `og:image` when a page names none), `shell` (the server-rendered page shell: `page(o)` composes the doctype and `<head>` from `seo.headTags`/`seo.pageSummary`, the theme-loader before the first paint, `web-runtime`/`navbar`/`footer` through `serve.url`, the `frame.noscriptNav` no-JS nav, the body and `frame.footer`; `scripts(o)` is just the head scripts and the escaped runtime boot JSON), `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`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `test-runner` (the `npm test` runner of every service's `test/run.js`: each `*.test.js` in its own process; a line `<label>: skipped (<why>)` makes a file skipped, listed with ○ and never counted as passed, and `--strict` fails on skips), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| 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`, `scripts/build-social-cards.js` (share cards from the Contracts manifests; Chrome + ImageMagick) |
| Pin bot | `scripts/pin-bump.js` (`node scripts/pin-bump.js <library> <tag> [--repos A,B] [--apply]`): after a release, one PR per repository that pins the library below the tag, with the lockfile refreshed and the docs-status fields rewritten; a dry run unless `--apply`; CI tests each PR |
| Pin bot | `scripts/pin-bump.js` (`node scripts/pin-bump.js <library> <tag> [--repos A,B] [--apply]`): after a release, one PR per repository that pins the library below the tag, with the lockfile refreshed and the docs-status fields rewritten; a dry run unless `--apply`; CI tests each PR |
| Checks (CI) | `scripts/pin-drift.js` (every openvibe-* pin is a published tag and not more than one minor behind, and openvibe-shared is installed once), `scripts/docs-status.js` (`node scripts/docs-status.js <dir> [--check]`: STATUS.json's contracts/sdk/shared fields and the README `<!-- versions:start/end -->` block must name the pinned releases; `--check` exits 1 on drift) and `scripts/floating-promises.js` (`node scripts/floating-promises.js <dir> [--json]`: an async call whose result nobody awaits — a failure is an unhandled rejection and a transaction commits without it — unless awaited, returned, assigned, `void`-ed, chained with `.then(`/`.catch(`/`.finally(` or opted out with `// floating-ok: <reason>`; exit 1 when found) |
| Checks (CI) | `scripts/pin-drift.js` (every openvibe-* pin is a published tag and not more than one minor behind, and openvibe-shared is installed once), `scripts/docs-status.js` (`node scripts/docs-status.js <dir> [--check]`: STATUS.json's contracts/sdk/shared fields and the README `<!-- versions:start/end -->` block must name the pinned releases; `--check` exits 1 on drift) and `scripts/floating-promises.js` (`node scripts/floating-promises.js <dir> [--json]`: an async call whose result nobody awaits — a failure is an unhandled rejection and a transaction commits without it — unless awaited, returned, assigned, `void`-ed, chained with `.then(`/`.catch(`/`.finally(` or opted out with `// floating-ok: <reason>`; exit 1 when found) |
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "3.0.1",
+ "version": "4.0.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)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "3.0.1",
+ "version": "4.0.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",
"bin": {
"bin": {
@@ -57,6 +57,7 @@
@@ -57,6 +57,7 @@
"./serve": "./serve.js",
"./serve": "./serve.js",
"./shell": "./shell.js",
"./shell": "./shell.js",
"./showcase": "./showcase.js",
"./showcase": "./showcase.js",
+ "./social": "./social.js",
"./shipped": "./shipped.js",
"./shipped": "./shipped.js",
"./sso-client": "./sso-client.js",
"./sso-client": "./sso-client.js",
"./test-runner": "./test-runner.js",
"./test-runner": "./test-runner.js",
@@ -77,6 +78,7 @@
@@ -77,6 +78,7 @@
"analytics/",
"analytics/",
"docs/schemas/",
"docs/schemas/",
"scripts/",
"scripts/",
+ "social/",
"templates/",
"templates/",
"CHANGELOG.md"
"CHANGELOG.md"
],
],
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+#!/usr/bin/env node
+'use strict';
+// Render the share card (og:image, 1200×630 JPEG) of every OpenVibe site into social/<service>.jpg.
+// node scripts/build-social-cards.js --contracts <OpenVibe.Contracts checkout> [--chrome <path>] [--only <id,id>]
+// Needs Chrome (the drawing) and ImageMagick's `convert` (PNG → a ~60 KB progressive JPEG).
+// The name, tagline, domain and accent come from each service manifest's `site` block (Contracts is the source of
+// truth; this script only draws them). The mark is openvibe-shared/app-icon. Inter is fetched once from Google Fonts
+// and embedded, so a card never depends on the fonts of the machine that rendered it. Re-run after a tagline,
+// accent or domain changes, and commit the PNGs: sites serve them from their pinned copy (openvibe-shared/serve).
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const https = require('https');
+const { execFileSync } = require('child_process');
+const icon = require('../app-icon');
+const social = require('../social');
+
+const arg = (name, dflt) => { const i = process.argv.indexOf(`--${name}`); return i > 0 ? process.argv[i + 1] : dflt; };
+const contracts = arg('contracts');
+const chrome = arg('chrome', '/usr/bin/google-chrome');
+const only = arg('only') ? new Set(arg('only').split(',')) : null;
+if (!contracts) { console.error('usage: build-social-cards.js --contracts <OpenVibe.Contracts checkout> [--chrome <path>] [--only <ids>]'); process.exit(1); }
+
+const OUT = path.join(__dirname, '..', 'social');
+const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
+
+function get(url, headers = {}) {
+ return new Promise((resolve, reject) => {
+ https.get(url, { headers }, (res) => {
+ if (res.statusCode !== 200) { res.resume(); return reject(new Error(`${url}: ${res.statusCode}`)); }
+ const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => resolve(Buffer.concat(chunks)));
+ }).on('error', reject);
+ });
+}
+
+async function interFaces() {
+ // A desktop user agent gets woff2; each weight's latin subset is embedded as a data: URL.
+ const css = (await get('https://fonts.googleapis.com/css2?family=Inter:wght@500;600;800&display=block',
+ { 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36' })).toString();
+ const out = [];
+ for (const [, block] of css.matchAll(/\/\* latin \*\/\s*@font-face\s*\{([^}]*)\}/g)) {
+ const weight = /font-weight:\s*(\d+)/.exec(block)[1];
+ const url = /url\((https:[^)]+\.woff2)\)/.exec(block)[1];
+ const data = (await get(url)).toString('base64');
+ out.push(`@font-face{font-family:'Inter';font-style:normal;font-weight:${weight};font-display:block;src:url(data:font/woff2;base64,${data}) format('woff2')}`);
+ }
+ if (!out.length) throw new Error('no latin Inter faces in the Google Fonts CSS');
+ return out.join('\n');
+}
+
+function sites() {
+ const dir = path.join(contracts, 'manifests', 'services');
+ const out = [];
+ for (const f of fs.readdirSync(dir).filter((n) => n.endsWith('.json')).sort()) {
+ const m = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
+ if (!m.site || !Array.isArray(m.domains) || !m.domains.length) continue;
+ if (only && !only.has(m.id)) continue;
+ out.push({ id: m.id, name: m.site.name, tagline: m.site.tagline, what: m.site.what || '', domain: m.domains[0], accent: social.accentOf(m.id, m.site.accent) });
+ }
+ return out;
+}
+
+function card(site, fonts) {
+ const [a1, a2] = site.accent;
+ // The product name after "OpenVibe.", unless the product carries its own name: OpenRestream reads "OpenRe" +
+ // "stream" as one word, the way the navbar draws it.
+ const own = /^Open(Re)(.+)$/.exec(site.name);
+ const brand = own
+ ? `<span class="w">Open${own[1]}</span><span class="p">${esc(own[2])}</span>`
+ : `<span class="w">OpenVibe</span><span class="d">.</span><span class="p">${esc(site.name)}</span>`;
+ const long = site.name.length > 9;
+ return `<!doctype html><html><head><meta charset="utf-8"><style>
+${fonts}
+*{margin:0;padding:0;box-sizing:border-box}
+html,body{width:1200px;height:630px;overflow:hidden}
+body{font-family:'Inter',sans-serif;color:#f8fafc;background:#0a0f1c;position:relative;-webkit-font-smoothing:antialiased}
+.glow{position:absolute;inset:0;background:
+ radial-gradient(760px 520px at 88% -8%, ${a1}38, transparent 62%),
+ radial-gradient(620px 460px at -6% 112%, ${a2}2e, transparent 64%)}
+.grid{position:absolute;inset:0;background-image:radial-gradient(#ffffff14 1.2px, transparent 1.3px);background-size:28px 28px;
+ -webkit-mask-image:linear-gradient(115deg, transparent 30%, #000 75%);mask-image:linear-gradient(115deg, transparent 30%, #000 75%)}
+.ring{position:absolute;right:-150px;top:50%;width:620px;height:620px;margin-top:-310px;border-radius:50%;
+ border:44px solid ${a1};opacity:.09}
+.ring2{position:absolute;right:-30px;top:50%;width:380px;height:380px;margin-top:-190px;border-radius:50%;border:2px solid ${a1};opacity:.16}
+.wrap{position:absolute;inset:0;padding:60px 84px 60px;display:flex;flex-direction:column}
+.top{display:flex;align-items:center;gap:22px}
+.icon{width:88px;height:88px;border-radius:22px;overflow:hidden;box-shadow:0 18px 50px -12px ${a2}88, 0 0 0 1px #ffffff1a}
+.icon svg{width:88px;height:88px;display:block}
+.net{font-size:26px;font-weight:600;color:#94a3b8;letter-spacing:.01em}
+.name{margin-top:36px;font-size:${long ? 92 : 108}px;font-weight:800;letter-spacing:-.035em;line-height:1}
+.name .w{color:#f8fafc}.name .d{color:${a1}}
+.name .p{background:linear-gradient(100deg, ${a1}, ${a2});-webkit-background-clip:text;background-clip:text;color:transparent}
+.tag{margin-top:22px;max-width:900px;font-size:42px;font-weight:600;line-height:1.18;color:#cbd5e1;letter-spacing:-.012em;
+ display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+.what{margin-top:18px;max-width:900px;font-size:27px;font-weight:500;line-height:1.38;color:#8b9ab3;
+ display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+.foot{margin-top:auto;display:flex;align-items:center;justify-content:space-between}
+.dom{display:inline-flex;align-items:center;gap:14px;font-size:30px;font-weight:600;color:#e2e8f0;
+ padding:12px 24px 12px 18px;border-radius:999px;background:#ffffff0d;box-shadow:inset 0 0 0 1.5px ${a1}55}
+.dom i{width:14px;height:14px;border-radius:50%;background:${a1};box-shadow:0 0 18px ${a1}}
+.part{font-size:24px;font-weight:500;color:#64748b}
+</style></head><body>
+<div class="glow"></div><div class="grid"></div><div class="ring"></div><div class="ring2"></div>
+<div class="wrap">
+ <div class="top"><div class="icon">${icon.svg({ site: social.iconSite(site.id), maskable: false })}</div><div class="net">One account. All of OpenVibe.</div></div>
+ <div class="name">${brand}</div>
+ <div class="tag">${esc(site.tagline)}</div>
+ ${site.what ? `<div class="what">${esc(site.what)}</div>` : ''}
+ <div class="foot"><span class="dom"><i></i>${esc(site.domain)}</span><span class="part">Open source · part of the OpenVibe network</span></div>
+</div></body></html>`;
+}
+
+(async () => {
+ fs.mkdirSync(OUT, { recursive: true });
+ const fonts = await interFaces();
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-social-'));
+ const list = sites();
+ for (const site of list) {
+ const html = path.join(tmp, `${site.id}.html`);
+ fs.writeFileSync(html, card(site, fonts));
+ const png = path.join(tmp, `${site.id}.png`);
+ const jpg = path.join(OUT, `${site.id}.jpg`);
+ execFileSync(chrome, ['--headless=new', '--disable-gpu', '--hide-scrollbars', '--force-device-scale-factor=1',
+ '--window-size=1200,630', '--virtual-time-budget=3000', `--user-data-dir=${path.join(tmp, 'profile')}`,
+ `--screenshot=${png}`, `file://${html}`], { stdio: 'ignore' });
+ execFileSync('convert', [png, '-strip', '-quality', '82', '-sampling-factor', '4:2:0', '-interlace', 'JPEG', jpg]);
+ console.log(`${site.id}: ${path.relative(process.cwd(), jpg)} (${Math.round(fs.statSync(jpg).size / 1024)} KB)`);
+ }
+ fs.rmSync(tmp, { recursive: true, force: true });
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -11,16 +11,19 @@
@@ -11,16 +11,19 @@
*
*
* A request carrying the file's current hash (?v=) is cached for a year (immutable); anything else
* A request carrying the file's current hash (?v=) is cached for a year (immutable); anything else
* for five minutes. ETag = the hash (304 on a match). CORS-open and cross-origin readable, like the
* for five minutes. ETag = the hash (304 on a match). CORS-open and cross-origin readable, like the
- * copies on openvibe.network. Only the files openvibe-shared/files lists are served.
+ * copies on openvibe.network. Only the files openvibe-shared/files lists are served, and the share cards
+ * (social/<service>.jpg, openvibe-shared/social) that og:image points at.
*/
*/
const fs = require('fs');
const fs = require('fs');
const files = require('./files');
const files = require('./files');
+const social = require('./social');
const cache = require('./cache-policy');
const cache = require('./cache-policy');
const { hashAsset } = require('./assets');
const { hashAsset } = require('./assets');
const hashes = new Map();
const hashes = new Map();
+const fileOf = (name) => (social.isCard(name) ? social.path(name) : files.path(name));
function hashOf(name) {
function hashOf(name) {
- if (!hashes.has(name)) hashes.set(name, hashAsset(fs.readFileSync(files.path(name))));
+ if (!hashes.has(name)) hashes.set(name, hashAsset(fs.readFileSync(fileOf(name))));
return hashes.get(name);
return hashes.get(name);
}
}
@@ -35,10 +38,10 @@ function handler() {
@@ -35,10 +38,10 @@ function handler() {
return function openvibeShared(req, res, next) {
return function openvibeShared(req, res, next) {
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
if (req.method !== 'GET' && req.method !== 'HEAD') return next();
const name = String(req.path || '').replace(/^\/+/, '');
const name = String(req.path || '').replace(/^\/+/, '');
- if (!files.isBrowserFile(name)) return next();
+ if (!files.isBrowserFile(name) && !social.isCard(name)) return next();
const hash = hashOf(name);
const hash = hashOf(name);
const v = req.query ? req.query.v : new URL(req.url, 'http://x').searchParams.get('v');
const v = req.query ? req.query.v : new URL(req.url, 'http://x').searchParams.get('v');
- res.setHeader('Content-Type', name.endsWith('.css') ? 'text/css; charset=utf-8' : 'application/javascript; charset=utf-8');
+ res.setHeader('Content-Type', name.endsWith('.css') ? 'text/css; charset=utf-8' : name.endsWith('.jpg') ? 'image/jpeg' : 'application/javascript; charset=utf-8');
// The estate default is a day's stale-while-revalidate (cache-policy); serve.js has always used a minute,
// The estate default is a day's stale-while-revalidate (cache-policy); serve.js has always used a minute,
// because a stale shared script is the one thing a site's navbar must never run. Kept as it was.
// because a stale shared script is the one thing a site's navbar must never run. Kept as it was.
res.setHeader('Cache-Control', cache.assetHeaders(name, { hashed: v === hash, swr: 60 }));
res.setHeader('Cache-Control', cache.assetHeaders(name, { hashed: v === hash, swr: 60 }));
@@ -48,7 +51,7 @@ function handler() {
@@ -48,7 +51,7 @@ function handler() {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Content-Type-Options', 'nosniff');
if (req.headers['if-none-match'] === `"${hash}"`) { res.statusCode = 304; return res.end(); }
if (req.headers['if-none-match'] === `"${hash}"`) { res.statusCode = 304; return res.end(); }
if (req.method === 'HEAD') return res.end();
if (req.method === 'HEAD') return res.end();
- fs.createReadStream(files.path(name)).on('error', next).pipe(res);
+ fs.createReadStream(fileOf(name)).on('error', next).pipe(res);
};
};
}
}
@@ -28,6 +28,7 @@
@@ -28,6 +28,7 @@
const seo = require('./seo');
const seo = require('./seo');
const frame = require('./frame');
const frame = require('./frame');
const serve = require('./serve');
const serve = require('./serve');
+const social = require('./social');
const esc = seo.esc;
const esc = seo.esc;
@@ -83,6 +84,12 @@ function scripts(o = {}) {
@@ -83,6 +84,12 @@ function scripts(o = {}) {
* footer `frame.footer` config; defaults to { service: name, variant: 'compact' }
* footer `frame.footer` config; defaults to { service: name, variant: 'compact' }
* navbar, features, routes, versions the runtime boot (see `scripts`)
* navbar, features, routes, versions the runtime boot (see `scripts`)
*/
*/
+// A page that names no image shares its site's card (openvibe-shared/social), when the site has one and the page
+// has a canonical URL to make it absolute against; `image: null` opts out.
+function defaultImage(o) {
+ return o.canonical ? social.url(o.service) : null;
+}
+
function page(o = {}) {
function page(o = {}) {
const name = o.name || o.service || 'OpenVibe';
const name = o.name || o.service || 'OpenVibe';
const title = o.title || name;
const title = o.title || name;
@@ -90,7 +97,7 @@ function page(o = {}) {
@@ -90,7 +97,7 @@ function page(o = {}) {
: seo.pageSummary({ title, summary: o.summary, facts: o.facts, url: o.url || o.canonical, updated: o.updated });
: seo.pageSummary({ title, summary: o.summary, facts: o.facts, url: o.url || o.canonical, updated: o.updated });
const head = seo.headTags({
const head = seo.headTags({
title, siteName: o.siteName || name, description: o.description,
title, siteName: o.siteName || name, description: o.description,
- canonical: o.canonical, image: o.image, imageAlt: o.imageAlt, type: o.type,
+ canonical: o.canonical, image: o.image === undefined ? defaultImage(o) : o.image, imageAlt: o.imageAlt || (o.image === undefined && defaultImage(o) ? o.siteName || name : undefined), type: o.type,
robots: o.robots, keywords: o.keywords, author: o.author, themeColor: o.themeColor,
robots: o.robots, keywords: o.keywords, author: o.author, themeColor: o.themeColor,
twitterSite: o.twitterSite, locale: o.locale, titleSuffix: o.titleSuffix,
twitterSite: o.twitterSite, locale: o.locale, titleSuffix: o.titleSuffix,
alternates: o.alternates, jsonLd: o.jsonLd,
alternates: o.alternates, jsonLd: o.jsonLd,
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════
+// openvibe-shared/social — every OpenVibe site's share card (og:image / twitter:image).
+//
+// The cards are 1200×630 JPEGs in social/<service>.jpg, drawn by scripts/build-social-cards.js from the service
+// manifests' `site` blocks (name, tagline, first domain, accent) with the app-icon mark. A site serves its pinned
+// copy at /shared/social/<service>.jpg through openvibe-shared/serve, so a card never depends on another host.
+//
+// social.has(service) is there a card for this service id
+// social.url(service, base?) '/shared/social/<service>.jpg?v=<content hash>', or null without a card
+// social.isCard(name) 'social/<id>.jpg' names serve.js may answer
+// social.path(name) the file on disk (throws for anything that is not a card)
+// social.accentOf(service, accent?) the two accent stops the card uses (build script)
+// social.iconSite(service) the app-icon variant for the service's mark (build script)
+//
+// shell.page() uses social.url(o.service) for og:image when the page names no image of its own.
+// ═══════════════════════════════════════════════════════════════
+const fs = require('fs');
+const path = require('path');
+const { hashAsset } = require('./assets');
+const icon = require('./app-icon');
+
+const DIR = path.join(__dirname, 'social');
+const NAME_RE = /^social\/([a-z0-9][a-z0-9-]{0,40})\.jpg$/;
+const ID_RE = /^[a-z0-9][a-z0-9-]{0,40}$/;
+const hashes = new Map();
+
+function isCard(name) {
+ const m = NAME_RE.exec(String(name || ''));
+ return Boolean(m) && fs.existsSync(path.join(DIR, `${m[1]}.jpg`));
+}
+
+function cardPath(name) {
+ if (!isCard(name)) throw new Error(`openvibe-shared/social: ${name} is not a share card`);
+ return path.join(__dirname, name);
+}
+
+const has = (service) => ID_RE.test(String(service || '')) && isCard(`social/${service}.jpg`);
+
+function url(service, base = '/shared') {
+ if (!has(service)) return null;
+ const name = `social/${service}.jpg`;
+ if (!hashes.has(name)) hashes.set(name, hashAsset(fs.readFileSync(cardPath(name))));
+ return `${String(base).replace(/\/+$/, '')}/${name}?v=${hashes.get(name)}`;
+}
+
+// The mark's tint: app-icon has variants for a few sites; the rest wear the network's.
+const iconSite = (service) => (Object.prototype.hasOwnProperty.call(icon.SITES, service) ? service : service === 'media-hub' ? 'media' : 'network');
+
+/** A deeper stop of the same hue (lightness down 16 points, saturation up 6), so a gradient never turns muddy. */
+function deepen(hex) {
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
+ const max = Math.max(r, g, b), min = Math.min(r, g, b), l = (max + min) / 2, d = max - min;
+ const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
+ const h = d === 0 ? 0 : max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
+ const L = Math.max(0, l - 0.16), S = Math.min(1, s + 0.06);
+ const c = (1 - Math.abs(2 * L - 1)) * S, x = c * (1 - Math.abs(((h % 6) + 6) % 2 - 1)), m = L - c / 2;
+ const [R, G, B] = h < 1 ? [c, x, 0] : h < 2 ? [x, c, 0] : h < 3 ? [0, c, x] : h < 4 ? [0, x, c] : h < 5 ? [x, 0, c] : [c, 0, x];
+ return `#${[R, G, B].map((v) => Math.round((v + m) * 255).toString(16).padStart(2, '0')).join('')}`;
+}
+
+/** [light, deep] accent stops: the manifest's accent and a deeper stop of it, else the app-icon variant's. */
+function accentOf(service, accent) {
+ return /^#[0-9a-f]{6}$/i.test(accent || '') ? [accent.toLowerCase(), deepen(accent)] : icon.SITES[iconSite(service)].accent;
+}
+
+module.exports = { has, url, isCard, path: cardPath, accentOf, iconSite, DIR };
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/actor.jpg differ
Binary files /dev/null and b/social/actor.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/ai.jpg differ
Binary files /dev/null and b/social/ai.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/blog.jpg differ
Binary files /dev/null and b/social/blog.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/bot.jpg differ
Binary files /dev/null and b/social/bot.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/chat.jpg differ
Binary files /dev/null and b/social/chat.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/codes.jpg differ
Binary files /dev/null and b/social/codes.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/community.jpg differ
Binary files /dev/null and b/social/community.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/coupons.jpg differ
Binary files /dev/null and b/social/coupons.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/deals.jpg differ
Binary files /dev/null and b/social/deals.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/events.jpg differ
Binary files /dev/null and b/social/events.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/food.jpg differ
Binary files /dev/null and b/social/food.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/games.jpg differ
Binary files /dev/null and b/social/games.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/help.jpg differ
Binary files /dev/null and b/social/help.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/host.jpg differ
Binary files /dev/null and b/social/host.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/inventory.jpg differ
Binary files /dev/null and b/social/inventory.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/live.jpg differ
Binary files /dev/null and b/social/live.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/media-hub.jpg differ
Binary files /dev/null and b/social/media-hub.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/media.jpg differ
Binary files /dev/null and b/social/media.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/network.jpg differ
Binary files /dev/null and b/social/network.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/news.jpg differ
Binary files /dev/null and b/social/news.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/openre.jpg differ
Binary files /dev/null and b/social/openre.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/quest.jpg differ
Binary files /dev/null and b/social/quest.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/rent.jpg differ
Binary files /dev/null and b/social/rent.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/reviews.jpg differ
Binary files /dev/null and b/social/reviews.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/search.jpg differ
Binary files /dev/null and b/social/search.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/services.jpg differ
Binary files /dev/null and b/social/services.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/space.jpg differ
Binary files /dev/null and b/social/space.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/tips.jpg differ
Binary files /dev/null and b/social/tips.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/tools.jpg differ
Binary files /dev/null and b/social/tools.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/trade.jpg differ
Binary files /dev/null and b/social/trade.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/vip.jpg differ
Binary files /dev/null and b/social/vip.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/watch.jpg differ
Binary files /dev/null and b/social/watch.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/wiki.jpg differ
Binary files /dev/null and b/social/wiki.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/work.jpg differ
Binary files /dev/null and b/social/work.jpg differ
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+'use strict';
+// openvibe-shared/social: every site's share card, served from the site's pinned copy and used for og:image.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const http = require('http');
+const express = require('express');
+const social = require('../social');
+const serve = require('../serve');
+const shell = require('../shell');
+
+/** Width and height from a JPEG's first SOF marker. */
+function jpegSize(buf) {
+ assert.strictEqual(buf.readUInt16BE(0), 0xffd8, 'starts with SOI');
+ let i = 2;
+ while (i < buf.length) {
+ const marker = buf.readUInt16BE(i);
+ const len = buf.readUInt16BE(i + 2);
+ if (marker >= 0xffc0 && marker <= 0xffcf && ![0xffc4, 0xffc8, 0xffcc].includes(marker)) {
+ return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
+ }
+ i += 2 + len;
+ }
+ throw new Error('no SOF marker');
+}
+
+(async () => {
+ const cards = fs.readdirSync(social.DIR);
+ assert.ok(cards.length >= 30, `a card for every site (${cards.length})`);
+ for (const f of cards) {
+ assert.match(f, /^[a-z0-9-]+\.jpg$/, `${f}: only service-id JPEGs live in social/`);
+ const buf = fs.readFileSync(path.join(social.DIR, f));
+ assert.deepStrictEqual(jpegSize(buf), { width: 1200, height: 630 }, `${f} is 1200×630`);
+ assert.ok(buf.length <= 120 * 1024, `${f} stays small (${Math.round(buf.length / 1024)} KB)`);
+ }
+
+ assert.ok(social.has('news') && social.has('openre') && social.has('network'));
+ assert.ok(!social.has('nope') && !social.has('../news') && !social.has('') && !social.has(null));
+ assert.match(social.url('news'), /^\/shared\/social\/news\.jpg\?v=[0-9a-f]{12}$/);
+ assert.match(social.url('news', 'https://cdn.example/s/'), /^https:\/\/cdn\.example\/s\/social\/news\.jpg\?v=/);
+ assert.strictEqual(social.url('nope'), null);
+ for (const bad of ['social/news.png', 'social/../package.json', 'social/News.jpg', 'news.jpg', 'social/a/b.jpg']) {
+ assert.ok(!social.isCard(bad), `${bad} is not a card`);
+ }
+ assert.throws(() => social.path('social/../serve.js'));
+
+ // The accent stops: a manifest accent keeps its hue (a deeper stop of it), never the network blue.
+ const [light, deep] = social.accentOf('news', '#fb923c');
+ assert.strictEqual(light, '#fb923c');
+ assert.notStrictEqual(deep, '#2563eb');
+ assert.ok(parseInt(deep.slice(1, 3), 16) > parseInt(deep.slice(5, 7), 16), 'still orange: more red than blue');
+ assert.deepStrictEqual(social.accentOf('live'), require('../app-icon').SITES.live.accent);
+
+ // serve.js answers the cards beside the browser files, content-addressed.
+ const app = express();
+ app.use('/shared', serve.handler());
+ app.use((req, res) => res.status(404).end('nope'));
+ const srv = http.createServer(app);
+ await new Promise((r) => srv.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${srv.address().port}`;
+ try {
+ const r = await fetch(base + social.url('wiki'));
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('content-type'), 'image/jpeg');
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=31536000, immutable');
+ assert.strictEqual(Buffer.from(await r.arrayBuffer()).length, fs.statSync(path.join(social.DIR, 'wiki.jpg')).size);
+ for (const bad of ['/shared/social/nope.jpg', '/shared/social/wiki.png', '/shared/social/../serve.js', '/shared/social/%2e%2e/serve.js']) {
+ assert.strictEqual((await fetch(base + bad)).status, 404, `${bad} is not served`);
+ }
+ } finally {
+ srv.close();
+ }
+
+ // shell.page: no image named → the site's card, absolute against the canonical; an explicit image wins;
+ // image: null shares none; no canonical → no default.
+ const html = shell.page({ service: 'news', name: 'OpenVibe.News', canonical: 'https://openvibe.news/', description: 'd' });
+ assert.match(html, /<meta property="og:image" content="https:\/\/openvibe\.news\/shared\/social\/news\.jpg\?v=[0-9a-f]{12}">/);
+ assert.match(html, /<meta property="og:image:alt" content="OpenVibe\.News">/);
+ assert.match(html, /<meta name="twitter:card" content="summary_large_image">/);
+ assert.match(shell.page({ service: 'news', canonical: 'https://openvibe.news/', image: '/cover.png' }), /og:image" content="https:\/\/openvibe\.news\/cover\.png"/);
+ assert.ok(!/og:image/.test(shell.page({ service: 'news', canonical: 'https://openvibe.news/', image: null })));
+ assert.ok(!/og:image/.test(shell.page({ service: 'news' })), 'no canonical, no relative og:image');
+ assert.ok(!/og:image/.test(shell.page({ service: 'no-such-site', canonical: 'https://x.example/' })));
+ console.log('social: ok');
+})().catch((e) => { console.error(e); process.exit(1); });